plan-governance-cli 0.3.5 → 1.0.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 +50 -12
- package/bin/plan-governance-cli.mjs +27 -0
- package/package.json +3 -2
- package/resources/manifest.json +5 -1
- package/resources/skill/SKILL.md +22 -311
- package/resources/skill/agents/openai.yaml +1 -1
- package/resources/skill/assets/PLAN_MAP.template.md +1 -0
- package/resources/skill/assets/plan.template.md +28 -17
- package/resources/skill/assets/spec.template.md +32 -0
- package/resources/skill/references/cli.md +141 -0
- package/resources/skill/references/planning.md +128 -0
- package/resources/skill/references/verification.md +83 -0
- package/scripts/check_plan_governance.py +924 -65
- package/scripts/init_plan_governance.py +19 -89
- package/scripts/plan_governance_hook.py +32 -44
|
@@ -48,10 +48,13 @@ def read_utf8(path, errors):
|
|
|
48
48
|
fail(errors, f"{path}: not valid UTF-8")
|
|
49
49
|
except FileNotFoundError:
|
|
50
50
|
fail(errors, f"{path}: file not found")
|
|
51
|
+
except OSError as exc:
|
|
52
|
+
fail(errors, f"{path}: cannot read file: {exc}")
|
|
51
53
|
return ""
|
|
52
54
|
|
|
53
55
|
|
|
54
56
|
def table_rows(text, heading):
|
|
57
|
+
text = mask_fenced_code(text)
|
|
55
58
|
pattern = re.compile(rf"^##\s+{re.escape(heading)}\s*$", re.MULTILINE)
|
|
56
59
|
match = pattern.search(text)
|
|
57
60
|
if not match:
|
|
@@ -59,28 +62,24 @@ def table_rows(text, heading):
|
|
|
59
62
|
tail = text[match.end():]
|
|
60
63
|
next_heading = re.search(r"^##\s+", tail, re.MULTILINE)
|
|
61
64
|
section = tail[: next_heading.start()] if next_heading else tail
|
|
62
|
-
|
|
63
|
-
for line in section.splitlines():
|
|
64
|
-
stripped = line.strip()
|
|
65
|
-
if not stripped.startswith("|") or "---" in stripped:
|
|
66
|
-
continue
|
|
67
|
-
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
|
68
|
-
if cells and cells[0] not in {"计划", "问题"}:
|
|
69
|
-
rows.append(cells)
|
|
70
|
-
return rows
|
|
65
|
+
return [cells for cells in markdown_table_rows(section) if cells[0] not in {"计划", "问题"}]
|
|
71
66
|
|
|
72
67
|
|
|
73
68
|
def mask_fenced_code(text):
|
|
74
69
|
"""用等长空格屏蔽 fenced code 中的伪 Markdown 标题,保留偏移量。"""
|
|
75
70
|
masked = []
|
|
76
|
-
|
|
71
|
+
fence = None
|
|
77
72
|
for line in text.splitlines(keepends=True):
|
|
78
73
|
stripped = line.lstrip()
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
74
|
+
marker = re.match(r"(`{3,}|~{3,})", stripped)
|
|
75
|
+
was_in_fence = fence is not None
|
|
76
|
+
if marker:
|
|
77
|
+
value = marker.group(1)
|
|
78
|
+
if fence is None:
|
|
79
|
+
fence = value
|
|
80
|
+
elif value[0] == fence[0] and len(value) >= len(fence) and not stripped[len(value):].strip():
|
|
81
|
+
fence = None
|
|
82
|
+
if was_in_fence or fence is not None:
|
|
84
83
|
masked.append("".join("\n" if char == "\n" else "\r" if char == "\r" else " " for char in line))
|
|
85
84
|
else:
|
|
86
85
|
masked.append(line)
|
|
@@ -96,7 +95,7 @@ def markdown_section(text, heading_names):
|
|
|
96
95
|
if not match:
|
|
97
96
|
return None
|
|
98
97
|
tail = text[match.end():]
|
|
99
|
-
next_heading = re.search(r"^#+\s+",
|
|
98
|
+
next_heading = re.search(r"^#+\s+", mask_fenced_code(text)[match.end():], re.MULTILINE)
|
|
100
99
|
return tail[: next_heading.start()] if next_heading else tail
|
|
101
100
|
|
|
102
101
|
|
|
@@ -104,12 +103,12 @@ def markdown_table_rows(section):
|
|
|
104
103
|
if section is None:
|
|
105
104
|
return []
|
|
106
105
|
rows = []
|
|
107
|
-
for line in section.splitlines():
|
|
106
|
+
for line in mask_fenced_code(section).splitlines():
|
|
108
107
|
stripped = line.strip()
|
|
109
|
-
if not stripped.startswith("|")
|
|
108
|
+
if not stripped.startswith("|"):
|
|
110
109
|
continue
|
|
111
110
|
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
|
112
|
-
if cells:
|
|
111
|
+
if cells and not all(re.fullmatch(r":?-+:?", cell) for cell in cells):
|
|
113
112
|
rows.append(cells)
|
|
114
113
|
return rows
|
|
115
114
|
|
|
@@ -123,6 +122,19 @@ def key_value_table(section):
|
|
|
123
122
|
return values
|
|
124
123
|
|
|
125
124
|
|
|
125
|
+
def fixed_section(text, heading):
|
|
126
|
+
"""结构化事实只接受完整的固定标题;正文保留,表格解析另行屏蔽示例。"""
|
|
127
|
+
if not text:
|
|
128
|
+
return None
|
|
129
|
+
masked = mask_fenced_code(text)
|
|
130
|
+
match = re.search(rf"^#+\s+{re.escape(heading)}\s*$", masked, re.MULTILINE)
|
|
131
|
+
if not match:
|
|
132
|
+
return None
|
|
133
|
+
tail = text[match.end():]
|
|
134
|
+
end = re.search(r"^#+\s+", masked[match.end():], re.MULTILINE)
|
|
135
|
+
return tail[:end.start()] if end else tail
|
|
136
|
+
|
|
137
|
+
|
|
126
138
|
def numbered_structural_heading(plan_text, heading_names):
|
|
127
139
|
"""查找误把阶段编号写入结构化章节标题的常见形式。"""
|
|
128
140
|
phase_number = r"[0-9一二三四五六七八九十百]+"
|
|
@@ -150,19 +162,19 @@ def structural_heading_hint(plan_text, heading_name):
|
|
|
150
162
|
|
|
151
163
|
|
|
152
164
|
def phase_roadmap_rows(plan_text):
|
|
153
|
-
section =
|
|
165
|
+
section = fixed_section(plan_text, "阶段路线图")
|
|
154
166
|
return [row for row in markdown_table_rows(section) if row and row[0] != "阶段"]
|
|
155
167
|
|
|
156
168
|
|
|
157
169
|
def review_history_rows(plan_text):
|
|
158
|
-
section =
|
|
170
|
+
section = fixed_section(plan_text, "独立复核记录")
|
|
159
171
|
return [row for row in markdown_table_rows(section) if row and row[0] != "日期"]
|
|
160
172
|
|
|
161
173
|
|
|
162
174
|
def is_placeholder(value, allow_empty=False):
|
|
163
175
|
normalized = re.sub(r"\s+", "", value or "")
|
|
164
176
|
if not normalized:
|
|
165
|
-
return allow_empty
|
|
177
|
+
return not allow_empty
|
|
166
178
|
return normalized in {re.sub(r"\s+", "", item) for item in PLACEHOLDER_VALUES}
|
|
167
179
|
|
|
168
180
|
|
|
@@ -184,14 +196,210 @@ def readiness_issue(warnings, errors, strict, message):
|
|
|
184
196
|
warn(warnings, message)
|
|
185
197
|
|
|
186
198
|
|
|
187
|
-
def
|
|
199
|
+
def review_outcome(conclusion):
|
|
200
|
+
"""新策略只识别明确结论;未进行、失败和不确定声明不能混为通过。"""
|
|
201
|
+
conclusion = conclusion.strip()
|
|
202
|
+
choice_separator = r"\s*(?:[//]|或(?:者)?)\s*"
|
|
203
|
+
if re.match(
|
|
204
|
+
rf"^(?:通过{choice_separator}(?:未通过|不通过)|(?:未通过|不通过){choice_separator}通过)",
|
|
205
|
+
conclusion,
|
|
206
|
+
):
|
|
207
|
+
# 开头候选始终不是结论,不能因尾标点/说明漏匹配后落入 passed。
|
|
208
|
+
return "unknown"
|
|
209
|
+
if conclusion.startswith(("未通过", "不通过", "失败", "不满足", "拒绝")):
|
|
210
|
+
return "failed"
|
|
211
|
+
if re.match(r"^通过(?:$|[。.!!::,,;;((\s])", conclusion):
|
|
212
|
+
return "passed"
|
|
213
|
+
if any(token in conclusion for token in ("不可用", "超时", "证据冲突", "证据失效")):
|
|
214
|
+
return "failed"
|
|
215
|
+
if conclusion in {"未进行", "尚未进行", "待复核", "待自验", "待独立复核", "待验证"}:
|
|
216
|
+
return "pending"
|
|
217
|
+
return "unknown"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def risk_review_state(plan_text, phase):
|
|
221
|
+
"""显式风险策略的共享事实判定;不评估风险真实性或自动检测证据漂移。"""
|
|
222
|
+
summary_section = fixed_section(top_level_section(plan_text, "当前阶段"), "阶段准入摘要")
|
|
223
|
+
policies = [row for row in markdown_table_rows(summary_section) if row[0] == "复核策略"]
|
|
224
|
+
result = {"enabled": bool(policies), "passed": False, "pending_action": None,
|
|
225
|
+
"issues": [], "blockers": []}
|
|
226
|
+
if not policies:
|
|
227
|
+
return result
|
|
228
|
+
issues, blockers = result["issues"], result["blockers"]
|
|
229
|
+
if len(policies) != 1 or len(policies[0]) != 2 or policies[0][1] != "风险分流":
|
|
230
|
+
issues.append("复核策略必须唯一且为 风险分流;空值、未知值或重复声明不能准入")
|
|
231
|
+
summary = key_value_table(summary_section)
|
|
232
|
+
if "最新独立准入复核" in summary:
|
|
233
|
+
issues.append("风险分流的阶段准入摘要应以 最新阶段复核 替代 最新独立准入复核")
|
|
234
|
+
if is_placeholder(summary.get("最新阶段复核")):
|
|
235
|
+
issues.append("阶段准入摘要缺少有效的 最新阶段复核 链接")
|
|
236
|
+
|
|
237
|
+
review = key_value_table(fixed_section(plan_text, "最新阶段复核"))
|
|
238
|
+
if any(len(row) != 2 for row in markdown_table_rows(fixed_section(plan_text, "最新阶段复核"))):
|
|
239
|
+
issues.append("最新阶段复核必须使用字段/内容两列表格")
|
|
240
|
+
fields = {"日期", "阶段", "方式", "风险", "风险依据", "结论", "证据", "复核者"}
|
|
241
|
+
for field in sorted(fields - set(review)):
|
|
242
|
+
issues.append(f"最新阶段复核缺少字段:{field}")
|
|
243
|
+
outcome = review_outcome(review.get("结论", ""))
|
|
244
|
+
if review.get("阶段") != phase:
|
|
245
|
+
issues.append(f"最新阶段复核阶段与 PLAN_MAP 当前阶段 {phase} 不一致")
|
|
246
|
+
if review.get("方式") not in {"自验", "独立"}:
|
|
247
|
+
issues.append("最新阶段复核方式必须为 自验/独立")
|
|
248
|
+
if review.get("风险") not in {"低风险", "高影响", "待判断"}:
|
|
249
|
+
issues.append("最新阶段复核风险必须为 低风险/高影响/待判断")
|
|
250
|
+
if review.get("风险") == "待判断":
|
|
251
|
+
issues.append("最新阶段复核风险待判断,不能准入")
|
|
252
|
+
if review.get("方式") == "自验" and review.get("风险") != "低风险":
|
|
253
|
+
issues.append("自验只适用于低风险,不能代替高影响独立复核")
|
|
254
|
+
if is_placeholder(review.get("风险依据")):
|
|
255
|
+
issues.append("最新阶段复核风险依据为空或占位")
|
|
256
|
+
if outcome == "unknown":
|
|
257
|
+
issues.append("最新阶段复核结论为空或未知")
|
|
258
|
+
if outcome == "failed" and review.get("阶段") == phase:
|
|
259
|
+
blockers.append("最新阶段复核:" + review["结论"])
|
|
260
|
+
|
|
261
|
+
def valid_date(value):
|
|
262
|
+
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
|
263
|
+
return None
|
|
264
|
+
try:
|
|
265
|
+
return parse_plan_date(value)
|
|
266
|
+
except ValueError:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
def qualified(row, legacy=False):
|
|
270
|
+
# 仅完整的独立通过可以解除先前独立失败;失败记录即使材料残缺也保留。
|
|
271
|
+
return (len(row) == (6 if legacy else 8) and not any(is_placeholder(value) for value in row)
|
|
272
|
+
and valid_date(row[0]) is not None
|
|
273
|
+
and (legacy or (row[3] == "独立" and row[4] in {"低风险", "高影响"})))
|
|
274
|
+
|
|
275
|
+
known_phases = {row[0] for row in phase_roadmap_rows(plan_text)}
|
|
276
|
+
|
|
277
|
+
def known_other_phase(value):
|
|
278
|
+
return value != phase and (value in known_phases or re.fullmatch(r"阶段\s*\d+", value))
|
|
279
|
+
|
|
280
|
+
def current_history(title, width):
|
|
281
|
+
section = fixed_section(plan_text, title)
|
|
282
|
+
rows = markdown_table_rows(section)
|
|
283
|
+
expected = (["日期", "类型", "阶段", "方式", "风险", "结论", "证据", "复核者"]
|
|
284
|
+
if width == 8 else ["日期", "类型", "阶段", "结论", "证据", "复核者"])
|
|
285
|
+
if rows and rows[0] != expected:
|
|
286
|
+
issues.append(f"{title} 必须使用固定的 {width} 列表头")
|
|
287
|
+
if rows.count(expected) > 1:
|
|
288
|
+
issues.append(f"{title} 存在重复表头,无法确认记录结构")
|
|
289
|
+
current = []
|
|
290
|
+
previous_date = None
|
|
291
|
+
for row in rows:
|
|
292
|
+
if row == expected:
|
|
293
|
+
continue
|
|
294
|
+
if len(row) > 2 and row[2] != phase:
|
|
295
|
+
if known_other_phase(row[2]):
|
|
296
|
+
continue
|
|
297
|
+
issues.append(f"{title} 记录阶段为空或未知,不能忽略")
|
|
298
|
+
continue
|
|
299
|
+
if len(row) != width or len(row) < 3 or row[2] != phase:
|
|
300
|
+
issues.append(f"{title} 记录字段不足、列数不符或阶段不明")
|
|
301
|
+
continue
|
|
302
|
+
current.append(row)
|
|
303
|
+
if is_placeholder(row[1]):
|
|
304
|
+
issues.append(f"{title} 类型为空或占位")
|
|
305
|
+
parsed = valid_date(row[0])
|
|
306
|
+
if not is_placeholder(row[0]) and parsed is None:
|
|
307
|
+
issues.append(f"{title} 日期不合法:{row[0]}")
|
|
308
|
+
if parsed and previous_date and parsed < previous_date:
|
|
309
|
+
issues.append(f"{title} 当前阶段记录日期逆序,无法确认最新结果")
|
|
310
|
+
if parsed:
|
|
311
|
+
previous_date = parsed
|
|
312
|
+
return current
|
|
313
|
+
|
|
314
|
+
history = current_history("阶段复核记录", 8)
|
|
315
|
+
for row in history:
|
|
316
|
+
if row[3] not in {"自验", "独立"} or row[4] not in {"低风险", "高影响", "待判断"}:
|
|
317
|
+
issues.append("阶段复核记录方式或风险未知")
|
|
318
|
+
if row[3] == "自验" and row[4] != "低风险":
|
|
319
|
+
issues.append("阶段复核记录自验只适用于低风险")
|
|
320
|
+
if review_outcome(row[5]) == "unknown":
|
|
321
|
+
issues.append("阶段复核记录结论为空或未知")
|
|
322
|
+
if review_outcome(row[5]) == "passed" and (
|
|
323
|
+
any(is_placeholder(value) for value in row) or valid_date(row[0]) is None or row[4] == "待判断"
|
|
324
|
+
):
|
|
325
|
+
issues.append("阶段复核记录通过缺少有效日期、类型、风险、证据或身份")
|
|
326
|
+
if history:
|
|
327
|
+
latest = history[-1]
|
|
328
|
+
for field, index in [("日期", 0), ("方式", 3), ("风险", 4), ("结论", 5), ("证据", 6), ("复核者", 7)]:
|
|
329
|
+
if review.get(field) != latest[index]:
|
|
330
|
+
issues.append(f"最新阶段复核与当前阶段历史记录最后一条 {field} 冲突")
|
|
331
|
+
if review_outcome(latest[5]) == "failed":
|
|
332
|
+
blockers.append("阶段复核记录最新结果:" + latest[5])
|
|
333
|
+
elif outcome != "pending":
|
|
334
|
+
issues.append(f"阶段复核记录缺少当前阶段 {phase} 的记录")
|
|
335
|
+
if outcome == "passed":
|
|
336
|
+
for field in sorted(fields & set(review)):
|
|
337
|
+
if is_placeholder(review[field]):
|
|
338
|
+
issues.append(f"最新阶段复核字段 {field} 为空或占位")
|
|
339
|
+
if (outcome == "passed" or not is_placeholder(review.get("日期"))) and valid_date(review.get("日期", "")) is None:
|
|
340
|
+
issues.append("最新阶段复核日期不合法")
|
|
341
|
+
|
|
342
|
+
independent = [row for row in history if row[3] == "独立"]
|
|
343
|
+
unresolved = None
|
|
344
|
+
for row in independent:
|
|
345
|
+
row_outcome = review_outcome(row[5])
|
|
346
|
+
if row_outcome == "failed":
|
|
347
|
+
unresolved = row[5]
|
|
348
|
+
elif row_outcome == "passed" and qualified(row):
|
|
349
|
+
unresolved = None
|
|
350
|
+
if unresolved:
|
|
351
|
+
blockers.append("阶段复核记录存在未解除的独立失败:" + unresolved)
|
|
352
|
+
|
|
353
|
+
# 旧独立记录仍是独立事实源;完成失败也不能被新自验覆盖。
|
|
354
|
+
legacy_history = current_history("独立复核记录", 6)
|
|
355
|
+
legacy_section = fixed_section(plan_text, "最新独立准入复核")
|
|
356
|
+
legacy = key_value_table(legacy_section)
|
|
357
|
+
legacy_current = legacy.get("阶段") == phase
|
|
358
|
+
if legacy_section is not None and not legacy_current and not known_other_phase(legacy.get("阶段", "")):
|
|
359
|
+
issues.append("旧最新独立准入复核阶段为空或未知,不能降级放行")
|
|
360
|
+
if legacy_history:
|
|
361
|
+
latest = legacy_history[-1]
|
|
362
|
+
if not legacy_current or any(legacy.get(field) != latest[index]
|
|
363
|
+
for field, index in [("日期", 0), ("证据", 4), ("复核者", 5)]) or (
|
|
364
|
+
latest[3] != legacy.get("结论") and not (
|
|
365
|
+
review_outcome(latest[3]) == review_outcome(legacy.get("结论", "")) == "passed"
|
|
366
|
+
and latest[3] in legacy.get("结论", "")
|
|
367
|
+
)
|
|
368
|
+
):
|
|
369
|
+
issues.append("旧最新独立准入复核与当前阶段历史记录冲突,不能降级放行")
|
|
370
|
+
unresolved = None
|
|
371
|
+
for row in legacy_history:
|
|
372
|
+
row_outcome = review_outcome(row[3])
|
|
373
|
+
if row_outcome == "failed":
|
|
374
|
+
unresolved = row[3]
|
|
375
|
+
elif row_outcome == "passed" and qualified(row, legacy=True):
|
|
376
|
+
unresolved = None
|
|
377
|
+
elif row_outcome == "passed":
|
|
378
|
+
issues.append("旧独立通过记录缺少有效日期、类型、证据或身份")
|
|
379
|
+
elif row_outcome == "unknown":
|
|
380
|
+
issues.append("旧独立复核记录结论为空或未知")
|
|
381
|
+
if unresolved:
|
|
382
|
+
blockers.append("旧独立复核记录存在未解除的失败:" + unresolved)
|
|
383
|
+
if legacy_current:
|
|
384
|
+
if review_outcome(legacy.get("结论", "")) == "failed":
|
|
385
|
+
blockers.append("最新独立准入复核:" + legacy["结论"])
|
|
386
|
+
elif not legacy_history or any(is_placeholder(legacy.get(field))
|
|
387
|
+
for field in ["日期", "阶段", "结论", "证据", "复核者"]) or valid_date(legacy.get("日期", "")) is None:
|
|
388
|
+
issues.append("旧最新独立准入复核缺少有效字段或当前阶段历史,不能降级放行")
|
|
389
|
+
result["passed"] = outcome == "passed" and not issues and not blockers
|
|
390
|
+
if outcome == "pending" and not issues and not blockers:
|
|
391
|
+
result["pending_action"] = "verify" if review.get("方式") == "自验" else "independent_review"
|
|
392
|
+
return result
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def check_phase_structure(plan_name, data, plan_text, strict, warnings, errors, review_state=None):
|
|
188
396
|
"""检查待实施/实施中计划的阶段准入结构,不判断业务证据真实性。"""
|
|
189
397
|
if data["status"] not in ACTIVE:
|
|
190
398
|
return
|
|
191
399
|
|
|
192
400
|
current_phase = data["phase"]
|
|
193
401
|
current_section = top_level_section(plan_text, "当前阶段")
|
|
194
|
-
summary_section =
|
|
402
|
+
summary_section = fixed_section(current_section, "阶段准入摘要")
|
|
195
403
|
summary = key_value_table(summary_section)
|
|
196
404
|
roadmap = phase_roadmap_rows(plan_text)
|
|
197
405
|
matching_rows = [row for row in roadmap if row and row[0] == current_phase]
|
|
@@ -207,7 +415,7 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
|
|
|
207
415
|
roadmap_status = matching_rows[-1][4] if len(matching_rows[-1]) > 4 else ""
|
|
208
416
|
phase_status = summary.get("阶段状态", "").strip()
|
|
209
417
|
expected_roadmap_status = phase_status or data["status"]
|
|
210
|
-
if roadmap_status
|
|
418
|
+
if roadmap_status != expected_roadmap_status:
|
|
211
419
|
readiness_issue(
|
|
212
420
|
warnings,
|
|
213
421
|
errors,
|
|
@@ -219,7 +427,11 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
|
|
|
219
427
|
section_hint = structural_heading_hint(plan_text, "当前阶段")
|
|
220
428
|
readiness_issue(warnings, errors, strict, f"{plan_name}: 缺少 `## 当前阶段` 章节{section_hint}")
|
|
221
429
|
|
|
222
|
-
|
|
430
|
+
review_state = review_state if review_state is not None else risk_review_state(plan_text, current_phase)
|
|
431
|
+
required_fields = READINESS_FIELDS
|
|
432
|
+
if review_state["enabled"]:
|
|
433
|
+
required_fields = (READINESS_FIELDS - {"最新独立准入复核"}) | {"最新阶段复核"}
|
|
434
|
+
missing_fields = sorted(required_fields - set(summary))
|
|
223
435
|
if missing_fields:
|
|
224
436
|
title_hint = ""
|
|
225
437
|
if summary_section is None:
|
|
@@ -230,7 +442,7 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
|
|
|
230
442
|
strict,
|
|
231
443
|
f"{plan_name}: 阶段准入摘要缺少字段:{', '.join(missing_fields)}{title_hint}",
|
|
232
444
|
)
|
|
233
|
-
for field in sorted(
|
|
445
|
+
for field in sorted(required_fields - {"当前阻塞项"}):
|
|
234
446
|
if field in summary and is_placeholder(summary[field]):
|
|
235
447
|
readiness_issue(
|
|
236
448
|
warnings,
|
|
@@ -238,7 +450,7 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
|
|
|
238
450
|
strict,
|
|
239
451
|
f"{plan_name}: 阶段准入摘要字段 {field} 仍是占位内容",
|
|
240
452
|
)
|
|
241
|
-
if
|
|
453
|
+
if "当前阻塞项" in summary and is_placeholder(summary["当前阻塞项"]) and summary["当前阻塞项"].strip() != "无":
|
|
242
454
|
readiness_issue(
|
|
243
455
|
warnings,
|
|
244
456
|
errors,
|
|
@@ -253,7 +465,14 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
|
|
|
253
465
|
f"{plan_name}: 阶段准入摘要状态 {summary['准入状态']} 与 PLAN_MAP 状态 {data['status']} 不一致",
|
|
254
466
|
)
|
|
255
467
|
|
|
256
|
-
|
|
468
|
+
if review_state["enabled"]:
|
|
469
|
+
for issue in review_state["issues"] + review_state["blockers"]:
|
|
470
|
+
readiness_issue(warnings, errors, strict, f"{plan_name}: {issue}")
|
|
471
|
+
if review_state["pending_action"]:
|
|
472
|
+
readiness_issue(warnings, errors, strict, f"{plan_name}: 最新阶段复核尚未通过")
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
review = key_value_table(fixed_section(plan_text, "最新独立准入复核"))
|
|
257
476
|
review_fields = {"日期", "阶段", "结论", "证据", "复核者"}
|
|
258
477
|
missing_review = sorted(review_fields - set(review))
|
|
259
478
|
if missing_review:
|
|
@@ -264,6 +483,10 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
|
|
|
264
483
|
strict,
|
|
265
484
|
f"{plan_name}: 最新独立准入复核缺少字段:{', '.join(missing_review)}{review_hint}",
|
|
266
485
|
)
|
|
486
|
+
for field in sorted(review_fields & set(review)):
|
|
487
|
+
if is_placeholder(review[field]):
|
|
488
|
+
readiness_issue(warnings, errors, strict,
|
|
489
|
+
f"{plan_name}: 最新独立准入复核字段 {field} 仍是占位内容")
|
|
267
490
|
if review.get("日期"):
|
|
268
491
|
try:
|
|
269
492
|
parse_plan_date(review["日期"])
|
|
@@ -474,9 +697,11 @@ def has_coverage_evidence(plan_text):
|
|
|
474
697
|
|
|
475
698
|
|
|
476
699
|
def has_current_blocker(plan_text):
|
|
700
|
+
"""保留默认 check 对已有开放问题的硬错误,不扩大到新增来源。"""
|
|
477
701
|
for row in table_rows(plan_text, "未决问题"):
|
|
478
|
-
|
|
479
|
-
|
|
702
|
+
if (len(row) >= 4 and row[2].strip().lower() in {"是", "yes"}
|
|
703
|
+
and row[3].strip().lower() not in CLOSED_BLOCKER_STATES
|
|
704
|
+
and re.search(r"Open|待确认|未解决|待处理|未决定", row[3], re.IGNORECASE)):
|
|
480
705
|
return True
|
|
481
706
|
return False
|
|
482
707
|
|
|
@@ -696,6 +921,271 @@ def safe_relative_path(value):
|
|
|
696
921
|
return normalized if normalized not in {"", ".", "/"} else None
|
|
697
922
|
|
|
698
923
|
|
|
924
|
+
def binding_path(value):
|
|
925
|
+
"""范围绑定只接受明确文件路径;不改变旧路径解析的兼容行为。"""
|
|
926
|
+
if not isinstance(value, str) or re.search(r"[\x00*?\[\]]", value) or re.match(r"^[A-Za-z]:", value):
|
|
927
|
+
raise ValueError(f"binding 路径非法:{value!r}")
|
|
928
|
+
normalized = safe_relative_path(value)
|
|
929
|
+
if normalized is None:
|
|
930
|
+
raise ValueError(f"binding 路径必须是仓库内相对文件:{value!r}")
|
|
931
|
+
return normalized
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def binding_file(root, value, require_file=True):
|
|
935
|
+
relative = binding_path(value)
|
|
936
|
+
path = root
|
|
937
|
+
if path.is_symlink():
|
|
938
|
+
raise ValueError("binding 仓库根目录不能是 symlink")
|
|
939
|
+
parts = relative.split("/")
|
|
940
|
+
for index, part in enumerate(parts):
|
|
941
|
+
path = path / part
|
|
942
|
+
if path.is_symlink():
|
|
943
|
+
raise ValueError(f"binding 路径不能包含 symlink:{relative}")
|
|
944
|
+
if index < len(parts) - 1 and path.exists() and not path.is_dir():
|
|
945
|
+
raise ValueError(f"binding 父路径不是目录:{relative}")
|
|
946
|
+
if require_file and not path.is_file():
|
|
947
|
+
raise ValueError(f"binding 文件不存在或不是普通文件:{relative}")
|
|
948
|
+
return path
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def canonical_sha256(value):
|
|
952
|
+
content = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
953
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def binding_table(text, heading, columns, repeated_header=False):
|
|
957
|
+
count = len(re.findall(rf"^##\s+{re.escape(heading)}\s*$", mask_fenced_code(text), re.MULTILINE))
|
|
958
|
+
if count > 1:
|
|
959
|
+
raise ValueError(f"binding PLAN_MAP 重复结构章节:{heading}")
|
|
960
|
+
section = top_level_section(text, heading)
|
|
961
|
+
if section is None or not section.strip():
|
|
962
|
+
return []
|
|
963
|
+
rows = markdown_table_rows(section)
|
|
964
|
+
if not rows or rows[0] != columns:
|
|
965
|
+
raise ValueError(f"binding PLAN_MAP {heading} 表头非法")
|
|
966
|
+
result = []
|
|
967
|
+
for row in rows[1:]:
|
|
968
|
+
if row == columns and repeated_header:
|
|
969
|
+
continue
|
|
970
|
+
if len(row) != len(columns) or row == columns:
|
|
971
|
+
raise ValueError(f"binding PLAN_MAP {heading} 列数或重复表头非法")
|
|
972
|
+
if row in result:
|
|
973
|
+
raise ValueError(f"binding PLAN_MAP {heading} 重复行")
|
|
974
|
+
result.append(row)
|
|
975
|
+
return sorted(result)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def binding_plan_id(cell):
|
|
979
|
+
link = extract_plan_link(cell)
|
|
980
|
+
return Path(link).stem if link else clean_relation_value(cell)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def binding_metadata(root, plan_map_relative, plan_name):
|
|
984
|
+
text = binding_file(root, plan_map_relative).read_text(encoding="utf-8")
|
|
985
|
+
index = binding_table(text, "计划索引", ["计划", "状态", "当前阶段", "最后更新", "依赖", "证据"], True)
|
|
986
|
+
dependency = binding_table(text, "依赖关系", ["计划", "依赖", "原因"])
|
|
987
|
+
relations = binding_table(text, "阶段关系", RELATION_COLUMNS)
|
|
988
|
+
shared = binding_table(text, "机器可检查共享写入约束", SHARED_WRITE_COLUMNS)
|
|
989
|
+
blockers = binding_table(text, "当前阻塞项", ["问题", "推荐方案", "影响范围", "是否阻塞当前阶段", "状态"])
|
|
990
|
+
by_name = {}
|
|
991
|
+
dependencies = {}
|
|
992
|
+
for row in index:
|
|
993
|
+
link = extract_plan_link(row[0])
|
|
994
|
+
name = binding_plan_id(row[0])
|
|
995
|
+
if not link or name in by_name:
|
|
996
|
+
raise ValueError(f"binding PLAN_MAP 计划链接非法或重复 ID:{name}")
|
|
997
|
+
binding_path("docs/" + link)
|
|
998
|
+
if clean_relation_value(row[1]) not in VALID_STATUSES or not valid_phase_name(row[2]):
|
|
999
|
+
raise ValueError(f"binding PLAN_MAP 计划状态或阶段非法:{name}")
|
|
1000
|
+
parse_plan_date(clean_relation_value(row[3]))
|
|
1001
|
+
by_name[name] = row
|
|
1002
|
+
deps = extract_declared_dependencies(row[4])
|
|
1003
|
+
if len(set(deps)) != len(deps):
|
|
1004
|
+
raise ValueError(f"binding PLAN_MAP 重复依赖:{name}")
|
|
1005
|
+
dependencies[name] = set(deps)
|
|
1006
|
+
if plan_name not in by_name:
|
|
1007
|
+
raise ValueError(f"binding 未登记计划:{plan_name}")
|
|
1008
|
+
for name, deps in dependencies.items():
|
|
1009
|
+
if deps - by_name.keys():
|
|
1010
|
+
raise ValueError(f"binding PLAN_MAP 未知依赖:{name}")
|
|
1011
|
+
if detect_dependency_cycles(dependencies):
|
|
1012
|
+
raise ValueError("binding PLAN_MAP 索引依赖存在环")
|
|
1013
|
+
seen = set()
|
|
1014
|
+
for row in dependency:
|
|
1015
|
+
name = binding_plan_id(row[0])
|
|
1016
|
+
if name not in by_name or name in seen:
|
|
1017
|
+
raise ValueError(f"binding PLAN_MAP 依赖详情未知或重复计划:{name}")
|
|
1018
|
+
deps = extract_declared_dependencies(row[1])
|
|
1019
|
+
if len(set(deps)) != len(deps) or set(deps) != dependencies[name]:
|
|
1020
|
+
raise ValueError(f"binding PLAN_MAP 依赖详情与索引不一致:{name}")
|
|
1021
|
+
seen.add(name)
|
|
1022
|
+
for rows, label in [(relations, "阶段关系"), (shared, "共享写入约束")]:
|
|
1023
|
+
for row in rows:
|
|
1024
|
+
source, source_phase, target, target_phase, kind = map(clean_relation_value, row[:5])
|
|
1025
|
+
if source not in by_name or target not in by_name:
|
|
1026
|
+
raise ValueError(f"binding PLAN_MAP {label} 未知关系端点")
|
|
1027
|
+
kinds = RELATION_TYPES if rows is relations else {"shared_write_risk"}
|
|
1028
|
+
if kind not in kinds or not valid_phase_name(source_phase) or not valid_phase_name(target_phase):
|
|
1029
|
+
raise ValueError(f"binding PLAN_MAP {label} 类型或阶段非法")
|
|
1030
|
+
scopes = []
|
|
1031
|
+
for row in blockers:
|
|
1032
|
+
if all(cell in {"", "-"} for cell in row[:3]) and row[3].lower() in {"否", "no"}:
|
|
1033
|
+
continue
|
|
1034
|
+
scope = re.sub(r"\[[^\]]+\]\((?:\./)?plans/([^/)]+)\.md(?:#[^)]*)?\)", r"\1", row[2]).replace("`", "")
|
|
1035
|
+
targets = set(filter(None, re.split(r"[,,、/;;\s]+", scope.strip())))
|
|
1036
|
+
if not targets or targets - by_name.keys():
|
|
1037
|
+
raise ValueError(f"binding PLAN_MAP 阻塞影响范围无法归属:{row[2]}")
|
|
1038
|
+
scopes.append((row, targets))
|
|
1039
|
+
members = {plan_name}
|
|
1040
|
+
while True:
|
|
1041
|
+
expanded = members | set().union(*(dependencies[name] for name in members))
|
|
1042
|
+
expanded.update(clean_relation_value(row[0]) for row in relations
|
|
1043
|
+
if clean_relation_value(row[2]) in members and clean_relation_value(row[4]) in {"hard_gate", "evidence"})
|
|
1044
|
+
if expanded == members:
|
|
1045
|
+
break
|
|
1046
|
+
members = expanded
|
|
1047
|
+
projection = {
|
|
1048
|
+
"version": 1,
|
|
1049
|
+
"members": sorted(members),
|
|
1050
|
+
"index_rows": sorted(by_name[name] for name in members),
|
|
1051
|
+
"dependency_rows": [row for row in dependency if binding_plan_id(row[0]) in members],
|
|
1052
|
+
"relation_rows": [row for row in relations if clean_relation_value(row[0]) in members or clean_relation_value(row[2]) in members],
|
|
1053
|
+
"shared_write_rows": [row for row in shared if clean_relation_value(row[0]) in members or clean_relation_value(row[2]) in members],
|
|
1054
|
+
"blocker_rows": [row for row, targets in scopes if targets & members],
|
|
1055
|
+
}
|
|
1056
|
+
related = []
|
|
1057
|
+
for name in sorted(members):
|
|
1058
|
+
relative = binding_path("docs/" + extract_plan_link(by_name[name][0]))
|
|
1059
|
+
path = binding_file(root, relative)
|
|
1060
|
+
related.append({"plan": name, "path": relative, "sha256": sha256_file(path)})
|
|
1061
|
+
return projection, related
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def make_attestation_binding(root, plan_map_relative, plan_name, files):
|
|
1065
|
+
if not files:
|
|
1066
|
+
raise ValueError("binding files 不能为空")
|
|
1067
|
+
entries = []
|
|
1068
|
+
seen = set()
|
|
1069
|
+
for value in files:
|
|
1070
|
+
relative = binding_path(value)
|
|
1071
|
+
if relative in seen:
|
|
1072
|
+
raise ValueError(f"binding 文件重复:{relative}")
|
|
1073
|
+
seen.add(relative)
|
|
1074
|
+
entries.append({"path": relative, "sha256": sha256_file(binding_file(root, relative))})
|
|
1075
|
+
projection, related = binding_metadata(root, plan_map_relative, plan_name)
|
|
1076
|
+
head = None
|
|
1077
|
+
try:
|
|
1078
|
+
result = subprocess.run(["git", "rev-parse", "--verify", "HEAD"], cwd=root, capture_output=True, text=True, check=False)
|
|
1079
|
+
value = result.stdout.strip()
|
|
1080
|
+
if result.returncode == 0 and re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", value):
|
|
1081
|
+
head = value
|
|
1082
|
+
except OSError:
|
|
1083
|
+
pass
|
|
1084
|
+
return {"version": 1, "files": sorted(entries, key=lambda entry: entry["path"]),
|
|
1085
|
+
"related_plans": related, "plan_map_projection": projection,
|
|
1086
|
+
"plan_map_projection_sha256": canonical_sha256(projection),
|
|
1087
|
+
"revision": {"source": "working_tree", "head": head}}
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def validate_attestation_binding(attestation):
|
|
1091
|
+
"""验证保存的结构,不访问已可能变化/删除的文件。"""
|
|
1092
|
+
binding = attestation["binding"]
|
|
1093
|
+
if not isinstance(binding, dict) or type(binding.get("version")) is not int or binding["version"] != 1:
|
|
1094
|
+
raise ValueError("binding version 必须为 1")
|
|
1095
|
+
projection = binding.get("plan_map_projection")
|
|
1096
|
+
columns = {"index_rows": 6, "dependency_rows": 3, "relation_rows": 7, "shared_write_rows": 9, "blocker_rows": 5}
|
|
1097
|
+
if not isinstance(projection, dict) or set(projection) != {"version", "members", *columns}:
|
|
1098
|
+
raise ValueError("binding plan_map_projection 结构非法")
|
|
1099
|
+
if type(projection["version"]) is not int or projection["version"] != 1:
|
|
1100
|
+
raise ValueError("binding projection version 必须为 1")
|
|
1101
|
+
members = projection["members"]
|
|
1102
|
+
if (not isinstance(members, list) or not members or any(not isinstance(name, str) or not name for name in members)
|
|
1103
|
+
or members != sorted(set(members)) or attestation.get("plan") not in members):
|
|
1104
|
+
raise ValueError("binding projection members 非法")
|
|
1105
|
+
for field, width in columns.items():
|
|
1106
|
+
rows = projection[field]
|
|
1107
|
+
if (not isinstance(rows, list) or any(not isinstance(row, list) or len(row) != width
|
|
1108
|
+
or any(not isinstance(cell, str) for cell in row) for row in rows)):
|
|
1109
|
+
raise ValueError(f"binding {field} 必须为固定列数的字符串二维数组")
|
|
1110
|
+
if rows != sorted(rows) or len({tuple(row) for row in rows}) != len(rows):
|
|
1111
|
+
raise ValueError(f"binding {field} 未排序或重复")
|
|
1112
|
+
if binding.get("plan_map_projection_sha256") != canonical_sha256(projection):
|
|
1113
|
+
raise ValueError("binding projection hash 不匹配")
|
|
1114
|
+
for field, key in [("files", "path"), ("related_plans", "plan")]:
|
|
1115
|
+
entries = binding.get(field)
|
|
1116
|
+
if not isinstance(entries, list) or not entries:
|
|
1117
|
+
raise ValueError(f"binding {field} 不能为空")
|
|
1118
|
+
keys = []
|
|
1119
|
+
for entry in entries:
|
|
1120
|
+
if not isinstance(entry, dict) or not isinstance(entry.get(key), str) or not entry[key]:
|
|
1121
|
+
raise ValueError(f"binding {field} 条目非法")
|
|
1122
|
+
if binding_path(entry.get("path")) != entry["path"]:
|
|
1123
|
+
raise ValueError(f"binding {field} 路径未规范化")
|
|
1124
|
+
if not isinstance(entry.get("sha256"), str) or not re.fullmatch(r"[0-9a-f]{64}", entry["sha256"]):
|
|
1125
|
+
raise ValueError(f"binding {field} sha256 非法")
|
|
1126
|
+
keys.append(entry[key])
|
|
1127
|
+
if keys != sorted(set(keys)):
|
|
1128
|
+
raise ValueError(f"binding {field} 未排序或重复")
|
|
1129
|
+
related = binding["related_plans"]
|
|
1130
|
+
if [entry["plan"] for entry in related] != members:
|
|
1131
|
+
raise ValueError("binding related_plans 与 members 不一致")
|
|
1132
|
+
index_names = [binding_plan_id(row[0]) for row in projection["index_rows"]]
|
|
1133
|
+
if sorted(index_names) != members:
|
|
1134
|
+
raise ValueError("binding index_rows 与 members 不一致")
|
|
1135
|
+
for entry in related:
|
|
1136
|
+
row = next(row for row in projection["index_rows"] if binding_plan_id(row[0]) == entry["plan"])
|
|
1137
|
+
link = extract_plan_link(row[0])
|
|
1138
|
+
if not link or entry["path"] != binding_path("docs/" + link):
|
|
1139
|
+
raise ValueError("binding related_plans 与索引路径不一致")
|
|
1140
|
+
target = next(entry for entry in related if entry["plan"] == attestation["plan"])
|
|
1141
|
+
if target["path"] != attestation.get("plan_path") or target["sha256"] != attestation.get("plan_sha256"):
|
|
1142
|
+
raise ValueError("binding 目标计划与原快照字段不一致")
|
|
1143
|
+
binding_path(attestation.get("plan_map_path"))
|
|
1144
|
+
revision = binding.get("revision")
|
|
1145
|
+
if not isinstance(revision, dict) or revision.get("source") != "working_tree" or "head" not in revision:
|
|
1146
|
+
raise ValueError("binding revision 非法")
|
|
1147
|
+
head = revision["head"]
|
|
1148
|
+
if head is not None and (not isinstance(head, str) or not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", head)):
|
|
1149
|
+
raise ValueError("binding HEAD 非法")
|
|
1150
|
+
if "purpose" not in attestation:
|
|
1151
|
+
raise ValueError("binding 必须使用 purpose 快照")
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def attestation_binding_drift(root, attestation):
|
|
1155
|
+
binding = attestation["binding"]
|
|
1156
|
+
changed = []
|
|
1157
|
+
for entry in binding["files"]:
|
|
1158
|
+
if sha256_file(binding_file(root, entry["path"])) != entry["sha256"]:
|
|
1159
|
+
changed.append(entry["path"])
|
|
1160
|
+
projection, related = binding_metadata(root, attestation["plan_map_path"], attestation["plan"])
|
|
1161
|
+
if projection != binding["plan_map_projection"] or related != binding["related_plans"]:
|
|
1162
|
+
changed.append("相关计划或 PLAN_MAP 投影")
|
|
1163
|
+
return changed
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def attestation_paths(root):
|
|
1167
|
+
directory = binding_file(root, "docs/attestations", require_file=False)
|
|
1168
|
+
try:
|
|
1169
|
+
directory.stat()
|
|
1170
|
+
except FileNotFoundError:
|
|
1171
|
+
return []
|
|
1172
|
+
# glob 会把 PermissionError 当作空结果;证据清单必须显式枚举。
|
|
1173
|
+
return sorted(path for path in directory.iterdir() if path.name.endswith(".json"))
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def has_attestation_binding(root):
|
|
1177
|
+
for path in attestation_paths(root):
|
|
1178
|
+
if path.is_symlink():
|
|
1179
|
+
return True
|
|
1180
|
+
try:
|
|
1181
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1182
|
+
except (UnicodeError, json.JSONDecodeError):
|
|
1183
|
+
continue
|
|
1184
|
+
if isinstance(data, dict) and "binding" in data:
|
|
1185
|
+
return True
|
|
1186
|
+
return False
|
|
1187
|
+
|
|
1188
|
+
|
|
699
1189
|
def create_attestation(
|
|
700
1190
|
root,
|
|
701
1191
|
plan_map,
|
|
@@ -705,7 +1195,20 @@ def create_attestation(
|
|
|
705
1195
|
purpose=None,
|
|
706
1196
|
supersedes="",
|
|
707
1197
|
review_status="current",
|
|
1198
|
+
attest_files=None,
|
|
708
1199
|
):
|
|
1200
|
+
binding = None
|
|
1201
|
+
if attest_files is not None:
|
|
1202
|
+
if errors:
|
|
1203
|
+
return None
|
|
1204
|
+
try:
|
|
1205
|
+
if purpose not in ATTESTATION_PURPOSES:
|
|
1206
|
+
raise ValueError("--attest-file 必须与 --attest 和 --attest-purpose 一起使用")
|
|
1207
|
+
binding_file(root, relative_to_root(plan_map, root))
|
|
1208
|
+
binding = make_attestation_binding(root, relative_to_root(plan_map, root), plan_name, attest_files)
|
|
1209
|
+
except (ValueError, OSError, UnicodeError) as exc:
|
|
1210
|
+
fail(errors, f"{plan_name}: 无法创建 binding 快照:{exc}")
|
|
1211
|
+
return None
|
|
709
1212
|
data = plans.get(plan_name)
|
|
710
1213
|
if data is None:
|
|
711
1214
|
fail(errors, f"{plan_name}: 未登记计划,无法创建完成快照")
|
|
@@ -714,14 +1217,22 @@ def create_attestation(
|
|
|
714
1217
|
fail(errors, f"{plan_name}: 计划文件不存在,无法创建完成快照")
|
|
715
1218
|
return None
|
|
716
1219
|
|
|
1220
|
+
try:
|
|
1221
|
+
plan_hash = sha256_file(data["path"])
|
|
1222
|
+
map_hash = sha256_file(plan_map)
|
|
1223
|
+
except OSError as exc:
|
|
1224
|
+
if binding is None:
|
|
1225
|
+
raise
|
|
1226
|
+
fail(errors, f"{plan_name}: binding 快照读取失败:{exc}")
|
|
1227
|
+
return None
|
|
717
1228
|
attestation = {
|
|
718
1229
|
"plan": plan_name,
|
|
719
1230
|
"phase": data["phase"],
|
|
720
1231
|
"status": data["status"],
|
|
721
1232
|
"plan_path": relative_to_root(data["path"], root),
|
|
722
1233
|
"plan_map_path": relative_to_root(plan_map, root),
|
|
723
|
-
"plan_sha256":
|
|
724
|
-
"plan_map_sha256":
|
|
1234
|
+
"plan_sha256": plan_hash,
|
|
1235
|
+
"plan_map_sha256": map_hash,
|
|
725
1236
|
"created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
726
1237
|
"created_by": "plan-governance",
|
|
727
1238
|
"reason": "阶段完成快照",
|
|
@@ -752,6 +1263,43 @@ def create_attestation(
|
|
|
752
1263
|
else:
|
|
753
1264
|
filename = f"{plan_name}.json"
|
|
754
1265
|
target = root / "docs" / "attestations" / filename
|
|
1266
|
+
if binding is not None:
|
|
1267
|
+
attestation["binding"] = binding
|
|
1268
|
+
try:
|
|
1269
|
+
relative = relative_to_root(target, root)
|
|
1270
|
+
binding_file(root, relative, require_file=False)
|
|
1271
|
+
if target.exists() or relative in {entry["path"] for entry in binding["files"]}:
|
|
1272
|
+
raise ValueError("binding 快照目标已存在或引用自身")
|
|
1273
|
+
validate_attestation_binding(attestation)
|
|
1274
|
+
preflight_errors = []
|
|
1275
|
+
warn_attestation_drift([], root, plans, errors=preflight_errors, strict=True,
|
|
1276
|
+
pending=(target, attestation), enforce_binding_review=False)
|
|
1277
|
+
if preflight_errors:
|
|
1278
|
+
errors.extend(preflight_errors)
|
|
1279
|
+
return None
|
|
1280
|
+
except (ValueError, OSError, UnicodeError) as exc:
|
|
1281
|
+
fail(errors, f"{plan_name}: binding 创建预检失败:{exc}")
|
|
1282
|
+
return None
|
|
1283
|
+
created_directory = False
|
|
1284
|
+
created_file = False
|
|
1285
|
+
try:
|
|
1286
|
+
if not target.parent.exists():
|
|
1287
|
+
target.parent.mkdir()
|
|
1288
|
+
created_directory = True
|
|
1289
|
+
with target.open("x", encoding="utf-8") as stream:
|
|
1290
|
+
created_file = True
|
|
1291
|
+
stream.write(json.dumps(attestation, ensure_ascii=False, indent=2) + "\n")
|
|
1292
|
+
except OSError as exc:
|
|
1293
|
+
fail(errors, f"{plan_name}: binding 快照写入失败:{exc}")
|
|
1294
|
+
for artifact, created in [(target, created_file), (target.parent, created_directory)]:
|
|
1295
|
+
if not created:
|
|
1296
|
+
continue
|
|
1297
|
+
try:
|
|
1298
|
+
artifact.unlink() if artifact == target else artifact.rmdir()
|
|
1299
|
+
except OSError as cleanup_error:
|
|
1300
|
+
fail(errors, f"binding 清理失败,残留路径 {artifact}:{cleanup_error}")
|
|
1301
|
+
return None
|
|
1302
|
+
return target
|
|
755
1303
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
756
1304
|
target.write_text(json.dumps(attestation, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
757
1305
|
return target
|
|
@@ -764,34 +1312,57 @@ def attestation_issue(warnings, errors, strict, message):
|
|
|
764
1312
|
warn(warnings, message)
|
|
765
1313
|
|
|
766
1314
|
|
|
767
|
-
def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, reports=None
|
|
1315
|
+
def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, reports=None,
|
|
1316
|
+
pending=None, enforce_binding_review=True):
|
|
768
1317
|
errors = errors if errors is not None else []
|
|
769
1318
|
attestations_dir = root / "docs" / "attestations"
|
|
770
|
-
|
|
1319
|
+
try:
|
|
1320
|
+
paths = attestation_paths(root)
|
|
1321
|
+
scoped_checks = pending is not None or has_attestation_binding(root)
|
|
1322
|
+
except (ValueError, OSError) as exc:
|
|
1323
|
+
attestation_issue(warnings, errors, strict, f"{attestations_dir}: 快照清单无法读取:{exc}")
|
|
771
1324
|
return
|
|
772
1325
|
|
|
773
1326
|
records = []
|
|
774
|
-
|
|
1327
|
+
if pending is not None:
|
|
1328
|
+
paths.append(pending[0])
|
|
1329
|
+
for path in sorted(paths):
|
|
1330
|
+
if path.is_symlink():
|
|
1331
|
+
attestation_issue(warnings, errors, strict, f"{path}: 快照不能是 symlink")
|
|
1332
|
+
continue
|
|
775
1333
|
try:
|
|
776
|
-
attestation = json.loads(path.read_text(encoding="utf-8"))
|
|
1334
|
+
attestation = pending[1] if pending is not None and path == pending[0] else json.loads(path.read_text(encoding="utf-8"))
|
|
1335
|
+
except OSError as exc:
|
|
1336
|
+
attestation_issue(warnings, errors, strict, f"{path}: attestation 无法读取:{exc}")
|
|
1337
|
+
continue
|
|
777
1338
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
778
1339
|
warn(warnings, f"{path}: attestation JSON 无法解析:{exc}")
|
|
779
1340
|
continue
|
|
1341
|
+
if not isinstance(attestation, dict):
|
|
1342
|
+
warn(warnings, f"{path}: attestation JSON 必须为对象")
|
|
1343
|
+
continue
|
|
780
1344
|
|
|
1345
|
+
bound = "binding" in attestation
|
|
1346
|
+
invalid_structure = False
|
|
781
1347
|
plan_name = str(attestation.get("plan", "")).strip()
|
|
782
1348
|
if not plan_name or plan_name not in plans:
|
|
783
|
-
|
|
784
|
-
|
|
1349
|
+
message = f"{path}: 快照引用了未登记计划:{plan_name or '<missing>'}"
|
|
1350
|
+
if not bound:
|
|
1351
|
+
warn(warnings, message)
|
|
1352
|
+
continue
|
|
1353
|
+
# 删除计划也是可被后继替代的内容变化,记录不能消失。
|
|
1354
|
+
warn(warnings, message)
|
|
785
1355
|
|
|
786
1356
|
legacy = "purpose" not in attestation
|
|
787
1357
|
purpose = str(attestation.get("purpose", "phase_completion")).strip()
|
|
788
1358
|
if purpose not in ATTESTATION_PURPOSES:
|
|
789
1359
|
attestation_issue(warnings, errors, strict, f"{path}: attestation purpose 非法:{purpose or '<missing>'}")
|
|
790
|
-
|
|
1360
|
+
if not bound:
|
|
1361
|
+
continue
|
|
1362
|
+
invalid_structure = True
|
|
791
1363
|
snapshot_id = str(attestation.get("snapshot_id", "")).strip()
|
|
792
1364
|
review_status = str(attestation.get("review_status", "current")).strip()
|
|
793
1365
|
supersedes = attestation.get("supersedes", "")
|
|
794
|
-
invalid_structure = False
|
|
795
1366
|
if not legacy:
|
|
796
1367
|
for field in ["snapshot_id", "supersedes", "review_status"]:
|
|
797
1368
|
if field not in attestation:
|
|
@@ -817,6 +1388,27 @@ def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, rep
|
|
|
817
1388
|
normalized_supersedes = ""
|
|
818
1389
|
invalid_structure = True
|
|
819
1390
|
|
|
1391
|
+
binding_changes = []
|
|
1392
|
+
if bound:
|
|
1393
|
+
try:
|
|
1394
|
+
validate_attestation_binding(attestation)
|
|
1395
|
+
if path != (pending[0] if pending is not None else None):
|
|
1396
|
+
binding_file(root, path.relative_to(root).as_posix())
|
|
1397
|
+
except (ValueError, OSError, UnicodeError) as exc:
|
|
1398
|
+
attestation_issue(warnings, errors, strict, f"{path}: binding 结构非法:{exc}")
|
|
1399
|
+
invalid_structure = True
|
|
1400
|
+
if not invalid_structure:
|
|
1401
|
+
try:
|
|
1402
|
+
binding_changes = attestation_binding_drift(root, attestation)
|
|
1403
|
+
except (ValueError, OSError, UnicodeError) as exc:
|
|
1404
|
+
binding_changes = [str(exc)]
|
|
1405
|
+
records.append({"path": path, "relative_path": path.relative_to(root).as_posix(),
|
|
1406
|
+
"plan": plan_name, "purpose": purpose, "review_status": review_status,
|
|
1407
|
+
"supersedes": normalized_supersedes, "drifted": bool(binding_changes),
|
|
1408
|
+
"legacy": legacy, "invalid_structure": invalid_structure,
|
|
1409
|
+
"bound": True, "binding_changes": binding_changes})
|
|
1410
|
+
continue
|
|
1411
|
+
|
|
820
1412
|
plan_path_value = attestation.get("plan_path", "")
|
|
821
1413
|
plan_map_path_value = attestation.get("plan_map_path", "docs/PLAN_MAP.md")
|
|
822
1414
|
plan_path_rel = safe_relative_path(plan_path_value)
|
|
@@ -829,6 +1421,13 @@ def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, rep
|
|
|
829
1421
|
continue
|
|
830
1422
|
plan_path = root / plan_path_rel
|
|
831
1423
|
plan_map_path = root / plan_map_path_rel
|
|
1424
|
+
if scoped_checks:
|
|
1425
|
+
try:
|
|
1426
|
+
binding_file(root, plan_path_rel)
|
|
1427
|
+
binding_file(root, plan_map_path_rel)
|
|
1428
|
+
except (ValueError, OSError) as exc:
|
|
1429
|
+
attestation_issue(warnings, errors, strict, f"{path}: 混合快照路径读取预检失败:{exc}")
|
|
1430
|
+
continue
|
|
832
1431
|
if not plan_path.exists():
|
|
833
1432
|
warn(warnings, f"{path}: 快照引用的计划文件不存在:{plan_path}")
|
|
834
1433
|
continue
|
|
@@ -837,11 +1436,18 @@ def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, rep
|
|
|
837
1436
|
continue
|
|
838
1437
|
|
|
839
1438
|
drifted = False
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1439
|
+
try:
|
|
1440
|
+
if sha256_file(plan_path) != attestation.get("plan_sha256"):
|
|
1441
|
+
warn(warnings, f"{path}: {plan_name} 计划文件 hash 已变化,需要人工复核")
|
|
1442
|
+
drifted = True
|
|
1443
|
+
if sha256_file(plan_map_path) != attestation.get("plan_map_sha256"):
|
|
1444
|
+
warn(warnings, f"{path}: PLAN_MAP.md hash 已变化,需要人工复核")
|
|
1445
|
+
drifted = True
|
|
1446
|
+
except OSError as exc:
|
|
1447
|
+
if not scoped_checks:
|
|
1448
|
+
raise
|
|
1449
|
+
# 内容不可读不使已存在的替代目标消失,也不阻止后续绑定报告。
|
|
1450
|
+
attestation_issue(warnings, errors, strict, f"{path}: 混合快照内容读取失败:{exc}")
|
|
845
1451
|
drifted = True
|
|
846
1452
|
records.append(
|
|
847
1453
|
{
|
|
@@ -854,6 +1460,7 @@ def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, rep
|
|
|
854
1460
|
"drifted": drifted,
|
|
855
1461
|
"legacy": legacy,
|
|
856
1462
|
"invalid_structure": invalid_structure,
|
|
1463
|
+
"bound": False,
|
|
857
1464
|
}
|
|
858
1465
|
)
|
|
859
1466
|
|
|
@@ -882,6 +1489,10 @@ def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, rep
|
|
|
882
1489
|
attestation_issue(warnings, errors, strict, f"{record['path']}: supersedes 不能指向自身")
|
|
883
1490
|
record["invalid_structure"] = True
|
|
884
1491
|
continue
|
|
1492
|
+
if target_record["bound"] and not record["bound"]:
|
|
1493
|
+
attestation_issue(warnings, errors, strict, f"{record['path']}: binding 前驱必须由带 binding 的后继替代")
|
|
1494
|
+
record["invalid_structure"] = True
|
|
1495
|
+
continue
|
|
885
1496
|
incoming.setdefault(target, []).append(record["relative_path"])
|
|
886
1497
|
edges.setdefault(record["relative_path"], []).append(target)
|
|
887
1498
|
|
|
@@ -892,6 +1503,22 @@ def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, rep
|
|
|
892
1503
|
if node in by_path:
|
|
893
1504
|
by_path[node]["invalid_structure"] = True
|
|
894
1505
|
|
|
1506
|
+
# 新范围关系的结构错误向后继传播,不能通过损坏中间节点压掉前驱。
|
|
1507
|
+
changed = True
|
|
1508
|
+
while changed:
|
|
1509
|
+
changed = False
|
|
1510
|
+
for record in records:
|
|
1511
|
+
target_record = by_path.get(record["supersedes"])
|
|
1512
|
+
if (target_record and (record["bound"] or target_record["bound"])
|
|
1513
|
+
and target_record["invalid_structure"] and not record["invalid_structure"]):
|
|
1514
|
+
attestation_issue(warnings, errors, strict, f"{record['path']}: supersedes 目标结构无效")
|
|
1515
|
+
record["invalid_structure"] = True
|
|
1516
|
+
changed = True
|
|
1517
|
+
incoming = {target: [source for source in sources
|
|
1518
|
+
if not (by_path[target]["bound"] or by_path[source]["bound"])
|
|
1519
|
+
or not (by_path[target]["invalid_structure"] or by_path[source]["invalid_structure"])]
|
|
1520
|
+
for target, sources in incoming.items()}
|
|
1521
|
+
|
|
895
1522
|
current_by_key = {}
|
|
896
1523
|
for record in records:
|
|
897
1524
|
effective = record["review_status"]
|
|
@@ -918,6 +1545,18 @@ def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, rep
|
|
|
918
1545
|
if record["relative_path"] in paths:
|
|
919
1546
|
record["effective_status"] = "needs_review"
|
|
920
1547
|
|
|
1548
|
+
if enforce_binding_review:
|
|
1549
|
+
for record in records:
|
|
1550
|
+
if not record["bound"]:
|
|
1551
|
+
continue
|
|
1552
|
+
pending_review = record["drifted"] or record["review_status"] == "needs_review"
|
|
1553
|
+
if pending_review:
|
|
1554
|
+
details = ", ".join(record["binding_changes"]) or "声明待复核"
|
|
1555
|
+
# 有效替代仅免除历史内容漂移,存储结构错误在上面仍阻断。
|
|
1556
|
+
retired = bool(incoming.get(record["relative_path"]))
|
|
1557
|
+
label = "binding 历史内容已变化,已有有效替代" if retired else "binding 需要复核"
|
|
1558
|
+
attestation_issue(warnings, errors, strict and not retired, f"{record['path']}: {label}:{details}")
|
|
1559
|
+
|
|
921
1560
|
if reports is not None:
|
|
922
1561
|
for record in records:
|
|
923
1562
|
reports.append(
|
|
@@ -978,6 +1617,8 @@ def load_plan_records(root, errors):
|
|
|
978
1617
|
def plan_index_issues(plan_map_text):
|
|
979
1618
|
"""检查工作集入口依赖的计划索引结构,不改变既有基础检查语义。"""
|
|
980
1619
|
issues = []
|
|
1620
|
+
if len(re.findall(r"^##\s+计划索引\s*$", mask_fenced_code(plan_map_text), re.MULTILINE)) > 1:
|
|
1621
|
+
issues.append("PLAN_MAP 存在重复计划索引章节,无法安全派生工作集")
|
|
981
1622
|
seen = set()
|
|
982
1623
|
for row_index, row in enumerate(table_rows(plan_map_text, "计划索引"), start=1):
|
|
983
1624
|
if len(row) < 6:
|
|
@@ -998,38 +1639,163 @@ def plan_index_issues(plan_map_text):
|
|
|
998
1639
|
|
|
999
1640
|
|
|
1000
1641
|
def unresolved_current_blockers(plan_text):
|
|
1001
|
-
|
|
1642
|
+
return problem_blockers(plan_text)[0]
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
CLOSED_BLOCKER_STATES = {"已决定", "已收敛", "已完成", "已解决", "已关闭", "无", "resolved", "closed", "done"}
|
|
1646
|
+
OPEN_BLOCKER_STATES = {"open", "待确认", "未解决", "待处理", "未决定", "待补充"}
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
def blocker_is_open(impact, state, label, issues):
|
|
1650
|
+
impact, state = impact.strip().lower(), state.strip().lower()
|
|
1651
|
+
if impact in {"否", "no"} or state in CLOSED_BLOCKER_STATES:
|
|
1652
|
+
return False
|
|
1653
|
+
if impact not in {"是", "yes"}:
|
|
1654
|
+
issues.append(f"{label}: 是否阻塞当前阶段为空或未知:{impact or '(空)'}")
|
|
1655
|
+
return False
|
|
1656
|
+
if state not in OPEN_BLOCKER_STATES:
|
|
1657
|
+
issues.append(f"{label}: 当前阻塞状态为空或未知:{state or '(空)'}")
|
|
1658
|
+
return True
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
def problem_blockers(plan_text):
|
|
1662
|
+
blockers, issues = [], []
|
|
1002
1663
|
for row in table_rows(plan_text, "未决问题"):
|
|
1003
|
-
if
|
|
1664
|
+
if not any(row):
|
|
1004
1665
|
continue
|
|
1005
|
-
|
|
1006
|
-
|
|
1666
|
+
if len(row) < 4:
|
|
1667
|
+
issues.append("未决问题表字段不足,无法确认当前阻塞")
|
|
1007
1668
|
continue
|
|
1008
|
-
|
|
1009
|
-
|
|
1669
|
+
label = row[0] or "未命名阻塞项"
|
|
1670
|
+
if blocker_is_open(row[2], row[3], label, issues):
|
|
1671
|
+
blockers.append(label)
|
|
1672
|
+
return blockers, issues
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
def map_blockers(plan_map_text):
|
|
1676
|
+
"""只按显式 ID/链接归属;无法归属的开放行保留为全局诊断。"""
|
|
1677
|
+
names = {
|
|
1678
|
+
Path(link).stem for row in table_rows(plan_map_text, "计划索引")
|
|
1679
|
+
if row and (link := extract_plan_link(row[0]))
|
|
1680
|
+
}
|
|
1681
|
+
result, issues = {}, []
|
|
1682
|
+
if len(re.findall(r"^##\s+当前阻塞项\s*$", mask_fenced_code(plan_map_text), re.MULTILINE)) > 1:
|
|
1683
|
+
issues.append("PLAN_MAP 存在重复当前阻塞项章节,无法确认阻塞来源")
|
|
1684
|
+
for row in table_rows(plan_map_text, "当前阻塞项"):
|
|
1685
|
+
if not any(row):
|
|
1686
|
+
continue
|
|
1687
|
+
if len(row) < 5:
|
|
1688
|
+
issues.append("PLAN_MAP 当前阻塞项字段不足,无法归属")
|
|
1689
|
+
continue
|
|
1690
|
+
label = row[0] or "未命名阻塞项"
|
|
1691
|
+
if not blocker_is_open(row[3], row[4], f"PLAN_MAP {label}", issues):
|
|
1692
|
+
continue
|
|
1693
|
+
scope = re.sub(r"\[[^\]]+\]\((?:\./)?plans/([^/)]+)\.md(?:#[^)]*)?\)",
|
|
1694
|
+
r"\1", row[2]).replace("`", "")
|
|
1695
|
+
targets = set(filter(None, re.split(r"[,,、/;;\s]+", scope.strip())))
|
|
1696
|
+
if not targets or targets - names:
|
|
1697
|
+
issues.append(f"PLAN_MAP 当前阻塞项 {label} 的影响范围无法归属:{row[2]}")
|
|
1698
|
+
continue
|
|
1699
|
+
for name in sorted(targets):
|
|
1700
|
+
result.setdefault(name, []).append(label)
|
|
1701
|
+
return result, issues
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
def structural_ambiguities(plan_text, phase):
|
|
1705
|
+
issues = []
|
|
1706
|
+
current = top_level_section(plan_text, "当前阶段") or ""
|
|
1707
|
+
sections = [(plan_text, "当前阶段"), (current, "阶段准入摘要"),
|
|
1708
|
+
(plan_text, "最新独立准入复核"), (plan_text, "阶段路线图"),
|
|
1709
|
+
(plan_text, "独立复核记录"), (plan_text, "未决问题")]
|
|
1710
|
+
if "复核策略" in key_value_table(fixed_section(current, "阶段准入摘要")):
|
|
1711
|
+
sections.extend([(plan_text, "最新阶段复核"), (plan_text, "阶段复核记录")])
|
|
1712
|
+
for text, title in sections:
|
|
1713
|
+
level = "##" if title in {"当前阶段", "未决问题"} else "#+"
|
|
1714
|
+
if len(re.findall(rf"^{level}\s+{re.escape(title)}\s*$", mask_fenced_code(text), re.MULTILINE)) > 1:
|
|
1715
|
+
issues.append(f"重复结构化章节:{title}")
|
|
1716
|
+
if title in {"阶段准入摘要", "最新独立准入复核", "最新阶段复核"}:
|
|
1717
|
+
seen = set()
|
|
1718
|
+
for row in markdown_table_rows(fixed_section(text, title)):
|
|
1719
|
+
if len(row) < 2 or row[0] == "字段":
|
|
1720
|
+
continue
|
|
1721
|
+
if row[0] in seen:
|
|
1722
|
+
issues.append(f"{title} 存在重复字段:{row[0]}")
|
|
1723
|
+
seen.add(row[0])
|
|
1724
|
+
if len([r for r in phase_roadmap_rows(plan_text) if r[0] == phase]) > 1:
|
|
1725
|
+
issues.append(f"阶段路线图重复当前阶段:{phase}")
|
|
1726
|
+
return issues
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def phase_gate(plan_map_text, plan_name, data, plan_text):
|
|
1730
|
+
"""共享只读派生;严重级别由 check/workset/hook 的入口契约决定。"""
|
|
1731
|
+
issues = structural_ambiguities(plan_text, data["phase"])
|
|
1732
|
+
warnings = []
|
|
1733
|
+
review_state = risk_review_state(plan_text, data["phase"])
|
|
1734
|
+
check_phase_structure(plan_name, data, plan_text, True, warnings, issues, review_state)
|
|
1735
|
+
if review_state["enabled"]:
|
|
1736
|
+
issues.extend(review_state["issues"] + review_state["blockers"])
|
|
1737
|
+
if data["status"] in ACTIVE and is_placeholder(data["phase"]):
|
|
1738
|
+
issues.append("PLAN_MAP 当前阶段为空或占位")
|
|
1739
|
+
details, detail_issues = problem_blockers(plan_text)
|
|
1740
|
+
issues.extend(detail_issues)
|
|
1741
|
+
mapped, _ = map_blockers(plan_map_text)
|
|
1742
|
+
indexed = mapped.get(plan_name, [])
|
|
1743
|
+
summary = current_summary(plan_text)
|
|
1744
|
+
value = summary.get("当前阻塞项", "").strip()
|
|
1745
|
+
declared = [value] if value and not is_placeholder(value) else []
|
|
1746
|
+
blockers = list(dict.fromkeys(details + indexed + declared))
|
|
1747
|
+
if blockers:
|
|
1748
|
+
issues.append("当前阶段存在未解决阻塞项:" + ";".join(blockers))
|
|
1749
|
+
if ((details or indexed) and value == "无") or ((details or declared) and not indexed):
|
|
1750
|
+
issues.append("当前阻塞来源需同步:PLAN_MAP 索引、未决问题或阶段准入摘要缺失/冲突")
|
|
1751
|
+
if review_state["enabled"]:
|
|
1752
|
+
blockers.extend(review_state["blockers"])
|
|
1753
|
+
else:
|
|
1754
|
+
review = key_value_table(fixed_section(plan_text, "最新独立准入复核"))
|
|
1755
|
+
conclusion = review.get("结论", "").strip()
|
|
1756
|
+
if review.get("阶段") == data["phase"] and conclusion.startswith(("未通过", "不通过", "失败", "不满足", "拒绝")):
|
|
1757
|
+
blockers.append("最新独立准入复核:" + conclusion)
|
|
1758
|
+
if data["status"] not in ACTIVE:
|
|
1759
|
+
issues.append(blockers[-1])
|
|
1760
|
+
_, recent_warnings = current_recent_evidence(plan_text, data["phase"])
|
|
1761
|
+
warnings.extend(recent_warnings)
|
|
1762
|
+
issues = [message.removeprefix(f"{plan_name}: ") for message in issues]
|
|
1763
|
+
return {"blockers": list(dict.fromkeys(blockers)), "issues": list(dict.fromkeys(issues)),
|
|
1764
|
+
"warnings": warnings, "review": review_state}
|
|
1765
|
+
|
|
1766
|
+
|
|
1767
|
+
def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, warnings, errors):
|
|
1768
|
+
if data["status"] not in WARNING_ACTIVE:
|
|
1769
|
+
return
|
|
1770
|
+
gate = phase_gate(plan_map_text, plan_name, data, plan_text)
|
|
1771
|
+
warnings.extend(f"{plan_name}: {message}" for message in gate["warnings"])
|
|
1772
|
+
for message in gate["issues"]:
|
|
1773
|
+
# 缺失材料的设计计划仍合法;其显式冲突和阻塞保持可见而不强制准入。
|
|
1774
|
+
readiness_issue(warnings, errors, strict and data["status"] in ACTIVE, f"{plan_name}: {message}")
|
|
1010
1775
|
|
|
1011
1776
|
|
|
1012
1777
|
def top_level_section(text, heading):
|
|
1013
1778
|
pattern = re.compile(rf"^##\s+{re.escape(heading)}\s*$", re.MULTILINE)
|
|
1014
|
-
|
|
1779
|
+
masked = mask_fenced_code(text)
|
|
1780
|
+
match = pattern.search(masked)
|
|
1015
1781
|
if not match:
|
|
1016
1782
|
return None
|
|
1017
1783
|
tail = text[match.end():]
|
|
1018
|
-
next_heading = re.search(r"^##\s+",
|
|
1784
|
+
next_heading = re.search(r"^##\s+", masked[match.end():], re.MULTILINE)
|
|
1019
1785
|
return tail[: next_heading.start()] if next_heading else tail
|
|
1020
1786
|
|
|
1021
1787
|
|
|
1022
1788
|
def current_summary(plan_text):
|
|
1023
|
-
return key_value_table(
|
|
1789
|
+
return key_value_table(fixed_section(top_level_section(plan_text, "当前阶段"), "阶段准入摘要"))
|
|
1024
1790
|
|
|
1025
1791
|
|
|
1026
1792
|
def current_review_passes(plan_text, phase):
|
|
1027
|
-
review = key_value_table(
|
|
1793
|
+
review = key_value_table(fixed_section(plan_text, "最新独立准入复核"))
|
|
1028
1794
|
return review.get("阶段") == phase and review.get("结论", "").startswith("通过")
|
|
1029
1795
|
|
|
1030
1796
|
|
|
1031
1797
|
def structured_next_action(plan_text):
|
|
1032
|
-
section = top_level_section(plan_text, "当前阶段") or ""
|
|
1798
|
+
section = mask_fenced_code(top_level_section(plan_text, "当前阶段") or "")
|
|
1033
1799
|
values = key_value_table(section)
|
|
1034
1800
|
value = values.get("下一动作", "").strip()
|
|
1035
1801
|
if not value:
|
|
@@ -1044,12 +1810,15 @@ def structured_next_action(plan_text):
|
|
|
1044
1810
|
"implement": "implement",
|
|
1045
1811
|
"无": "none",
|
|
1046
1812
|
"none": "none",
|
|
1813
|
+
"等待用户验收": "none",
|
|
1047
1814
|
}
|
|
1048
1815
|
if not value:
|
|
1049
1816
|
return {"state": "unknown", "kind": "unknown", "reason": "缺少结构化下一动作"}
|
|
1050
1817
|
kind = mapping.get(value.lower(), "unknown")
|
|
1051
1818
|
if kind == "unknown":
|
|
1052
1819
|
return {"state": "unknown", "kind": "unknown", "reason": f"下一动作值无法识别:{value}"}
|
|
1820
|
+
if value == "等待用户验收":
|
|
1821
|
+
return {"state": "known", "kind": "none", "reason": "等待用户验收;技术完成不自动关闭计划"}
|
|
1053
1822
|
return {"state": "known", "kind": kind, "reason": "来自当前阶段结构化下一动作"}
|
|
1054
1823
|
|
|
1055
1824
|
|
|
@@ -1287,11 +2056,22 @@ def validate_relation_graph(plan_map_text, plans):
|
|
|
1287
2056
|
return errors, warnings
|
|
1288
2057
|
|
|
1289
2058
|
|
|
1290
|
-
def
|
|
2059
|
+
def current_recent_evidence(plan_text, phase=None):
|
|
1291
2060
|
current = top_level_section(plan_text, "当前阶段") or ""
|
|
1292
|
-
section =
|
|
2061
|
+
section = fixed_section(current, "最近实施/验证记录")
|
|
2062
|
+
warnings = []
|
|
2063
|
+
if section is None and phase:
|
|
2064
|
+
for title in [f"{phase} 最近验证记录", f"{phase} 最近实施/验证记录"]:
|
|
2065
|
+
section = fixed_section(current, title)
|
|
2066
|
+
if section is not None:
|
|
2067
|
+
warnings.append(f"兼容记录标题 `{title}`,请使用固定标题 `最近实施/验证记录`")
|
|
2068
|
+
break
|
|
1293
2069
|
rows = markdown_table_rows(section)
|
|
1294
|
-
return [row for row in rows if len(row) >= 2 and row[0] not in {"日期", "字段", "类型"}]
|
|
2070
|
+
return [row for row in rows if len(row) >= 2 and row[0] not in {"日期", "字段", "类型"}], warnings
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
def recent_evidence(plan_text, phase=None):
|
|
2074
|
+
return current_recent_evidence(plan_text, phase)[0]
|
|
1295
2075
|
|
|
1296
2076
|
|
|
1297
2077
|
def workset_payload(root, include_history=False, strict=False):
|
|
@@ -1309,6 +2089,9 @@ def workset_payload(root, include_history=False, strict=False):
|
|
|
1309
2089
|
return output, 1
|
|
1310
2090
|
|
|
1311
2091
|
index_issues = plan_index_issues(plan_map_text)
|
|
2092
|
+
_, blocker_index_issues = map_blockers(plan_map_text)
|
|
2093
|
+
output["warnings"].extend(blocker_index_issues)
|
|
2094
|
+
strict_failures.extend(blocker_index_issues)
|
|
1312
2095
|
relation_errors, relation_warnings = validate_relation_graph(plan_map_text, plans)
|
|
1313
2096
|
output["warnings"].extend(relation_warnings)
|
|
1314
2097
|
if relation_errors:
|
|
@@ -1326,7 +2109,12 @@ def workset_payload(root, include_history=False, strict=False):
|
|
|
1326
2109
|
continue
|
|
1327
2110
|
plan_text = read_utf8(path, errors)
|
|
1328
2111
|
summary = current_summary(plan_text)
|
|
1329
|
-
|
|
2112
|
+
gate = phase_gate(plan_map_text, name, data, plan_text)
|
|
2113
|
+
blockers = gate["blockers"]
|
|
2114
|
+
output["warnings"].extend(f"{name}: {message}" for message in gate["issues"] + gate["warnings"])
|
|
2115
|
+
if data["status"] in ACTIVE:
|
|
2116
|
+
strict_failures.extend(gate["issues"])
|
|
2117
|
+
uncertain = gate["issues"] or blocker_index_issues or relation_errors
|
|
1330
2118
|
if blockers:
|
|
1331
2119
|
readiness = "blocked"
|
|
1332
2120
|
action = {
|
|
@@ -1334,9 +2122,12 @@ def workset_payload(root, include_history=False, strict=False):
|
|
|
1334
2122
|
"kind": "resolve_blocker",
|
|
1335
2123
|
"reason": "当前阶段存在未解决阻塞项",
|
|
1336
2124
|
}
|
|
2125
|
+
elif uncertain and data["status"] in WARNING_ACTIVE:
|
|
2126
|
+
readiness = "unknown"
|
|
2127
|
+
action = {"state": "unknown", "kind": "unknown", "reason": "准入信息存在缺失或冲突,见 warnings"}
|
|
1337
2128
|
elif data["status"] == "待实施":
|
|
1338
2129
|
readiness = "ready"
|
|
1339
|
-
action = {"state": "known", "kind": "implement", "reason": "
|
|
2130
|
+
action = {"state": "known", "kind": "implement", "reason": "待实施计划的结构化准入检查通过"}
|
|
1340
2131
|
elif data["status"] == "实施中":
|
|
1341
2132
|
readiness = "in_progress"
|
|
1342
2133
|
action = structured_next_action(plan_text)
|
|
@@ -1352,6 +2143,17 @@ def workset_payload(root, include_history=False, strict=False):
|
|
|
1352
2143
|
"kind": "complete_step0",
|
|
1353
2144
|
"reason": f"阶段准入摘要缺少或占位字段:{', '.join(missing)}",
|
|
1354
2145
|
}
|
|
2146
|
+
elif gate["review"]["enabled"]:
|
|
2147
|
+
readiness = "design"
|
|
2148
|
+
kind = "sync" if gate["review"]["passed"] else gate["review"]["pending_action"]
|
|
2149
|
+
action = {
|
|
2150
|
+
"state": "known" if kind else "unknown",
|
|
2151
|
+
"kind": kind or "unknown",
|
|
2152
|
+
"reason": ("当前阶段复核通过,待同步 PLAN_MAP 准入状态" if kind == "sync" else
|
|
2153
|
+
"低风险阶段待自验" if kind == "verify" else
|
|
2154
|
+
"当前阶段待独立复核" if kind == "independent_review" else
|
|
2155
|
+
"当前阶段复核声明无法确定"),
|
|
2156
|
+
}
|
|
1355
2157
|
elif not current_review_passes(plan_text, data["phase"]):
|
|
1356
2158
|
readiness = "design"
|
|
1357
2159
|
action = {
|
|
@@ -1375,7 +2177,7 @@ def workset_payload(root, include_history=False, strict=False):
|
|
|
1375
2177
|
"blockers": blockers,
|
|
1376
2178
|
"next_action": action,
|
|
1377
2179
|
"parallel": parallel_summary(plan_map_text, name, relation_valid=not relation_errors),
|
|
1378
|
-
"recent_evidence": recent_evidence(plan_text),
|
|
2180
|
+
"recent_evidence": recent_evidence(plan_text, data["phase"]),
|
|
1379
2181
|
}
|
|
1380
2182
|
)
|
|
1381
2183
|
|
|
@@ -1402,7 +2204,8 @@ def print_workset_text(payload, strict=False):
|
|
|
1402
2204
|
if item["blockers"]:
|
|
1403
2205
|
print(f" blockers: {';'.join(item['blockers'])}")
|
|
1404
2206
|
for warning in payload["warnings"]:
|
|
1405
|
-
|
|
2207
|
+
level = "ERROR" if strict and "兼容记录标题" not in warning else "WARNING"
|
|
2208
|
+
print(f"{level}: {warning}")
|
|
1406
2209
|
|
|
1407
2210
|
|
|
1408
2211
|
def run_workset(root, as_json=False, include_history=False, strict=False):
|
|
@@ -1429,6 +2232,8 @@ def parse_args(argv):
|
|
|
1429
2232
|
help="将待实施/实施中计划的阶段准入结构缺陷从 WARNING 提升为 ERROR。",
|
|
1430
2233
|
)
|
|
1431
2234
|
parser.add_argument("--attest", metavar="PLAN", help="为已登记计划创建或覆盖完成快照。")
|
|
2235
|
+
parser.add_argument("--attest-file", action="append", metavar="PATH",
|
|
2236
|
+
help="可重复:与 --attest/--attest-purpose 显式绑定被审查的普通文件。")
|
|
1432
2237
|
parser.add_argument(
|
|
1433
2238
|
"--attest-purpose",
|
|
1434
2239
|
choices=sorted(ATTESTATION_PURPOSES),
|
|
@@ -1455,6 +2260,9 @@ def parse_args(argv):
|
|
|
1455
2260
|
|
|
1456
2261
|
def main(argv=None):
|
|
1457
2262
|
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
2263
|
+
if args.attest_file is not None and (not args.attest or not args.attest_purpose or args.workset):
|
|
2264
|
+
print("ERROR: --attest-file 必须与 --attest/--attest-purpose 一起使用,不能用于 workset")
|
|
2265
|
+
return 1
|
|
1458
2266
|
if args.workset:
|
|
1459
2267
|
return run_workset(
|
|
1460
2268
|
Path(args.mode_root or args.root),
|
|
@@ -1468,16 +2276,56 @@ def main(argv=None):
|
|
|
1468
2276
|
errors = []
|
|
1469
2277
|
warnings = []
|
|
1470
2278
|
|
|
2279
|
+
try:
|
|
2280
|
+
bound_input = args.attest_file is not None or (args.check_attestations and has_attestation_binding(root))
|
|
2281
|
+
except (ValueError, OSError) as exc:
|
|
2282
|
+
level = "ERROR" if args.strict_readiness else "WARNING"
|
|
2283
|
+
print(f"{level}: 快照清单无法读取:{exc}")
|
|
2284
|
+
return 1 if args.strict_readiness else 0
|
|
2285
|
+
unsafe_map = False
|
|
2286
|
+
if bound_input:
|
|
2287
|
+
try:
|
|
2288
|
+
binding_file(root, "docs/PLAN_MAP.md")
|
|
2289
|
+
except (ValueError, OSError) as exc:
|
|
2290
|
+
if args.attest_file is not None:
|
|
2291
|
+
print(f"ERROR: binding 创建预检失败:{exc}")
|
|
2292
|
+
return 1
|
|
2293
|
+
unsafe_map = True
|
|
2294
|
+
if unsafe_map:
|
|
2295
|
+
reports = []
|
|
2296
|
+
warn_attestation_drift(warnings, root, {}, errors=errors, strict=args.strict_readiness, reports=reports)
|
|
2297
|
+
for warning in warnings:
|
|
2298
|
+
print(f"WARNING: {warning}")
|
|
2299
|
+
for report in reports:
|
|
2300
|
+
print(f"ATTESTATION: {report}")
|
|
2301
|
+
for error in errors:
|
|
2302
|
+
print(f"ERROR: {error}")
|
|
2303
|
+
return 1 if errors else 0
|
|
1471
2304
|
if not plan_map.exists():
|
|
2305
|
+
if args.check_attestations:
|
|
2306
|
+
reports = []
|
|
2307
|
+
warn_attestation_drift(warnings, root, {}, errors=errors, strict=args.strict_readiness, reports=reports)
|
|
2308
|
+
if reports or errors:
|
|
2309
|
+
for warning in warnings:
|
|
2310
|
+
print(f"WARNING: {warning}")
|
|
2311
|
+
for report in reports:
|
|
2312
|
+
print(f"ATTESTATION: {report}")
|
|
2313
|
+
for error in errors:
|
|
2314
|
+
print(f"ERROR: {error}")
|
|
2315
|
+
return 1 if errors else 0
|
|
1472
2316
|
print("未找到 docs/PLAN_MAP.md;当前仓库尚未初始化计划治理。")
|
|
1473
2317
|
return 0
|
|
1474
2318
|
|
|
1475
2319
|
text = read_utf8(plan_map, errors)
|
|
2320
|
+
_, blocker_index_issues = map_blockers(text)
|
|
2321
|
+
for issue in plan_index_issues(text) + blocker_index_issues:
|
|
2322
|
+
readiness_issue(warnings, errors, args.strict_readiness, issue)
|
|
1476
2323
|
plan_rows = table_rows(text, "计划索引")
|
|
1477
2324
|
if not plan_rows:
|
|
1478
2325
|
fail(errors, "docs/PLAN_MAP.md: 缺少计划索引表")
|
|
1479
2326
|
|
|
1480
2327
|
plans = {}
|
|
2328
|
+
unsafe_plans = set()
|
|
1481
2329
|
for row in plan_rows:
|
|
1482
2330
|
if len(row) < 2:
|
|
1483
2331
|
continue
|
|
@@ -1497,6 +2345,13 @@ def main(argv=None):
|
|
|
1497
2345
|
last_updated_date = date.min
|
|
1498
2346
|
if link:
|
|
1499
2347
|
path = docs / link
|
|
2348
|
+
if bound_input:
|
|
2349
|
+
try:
|
|
2350
|
+
binding_file(root, "docs/" + link)
|
|
2351
|
+
except (ValueError, OSError) as exc:
|
|
2352
|
+
attestation_issue(warnings, errors, args.attest_file is not None or args.strict_readiness,
|
|
2353
|
+
f"{name}: binding 计划读取预检失败:{exc}")
|
|
2354
|
+
unsafe_plans.add(name)
|
|
1500
2355
|
if not path.exists():
|
|
1501
2356
|
fail(errors, f"docs/PLAN_MAP.md: 引用的计划文件不存在:{link}")
|
|
1502
2357
|
plans[name] = {
|
|
@@ -1509,7 +2364,8 @@ def main(argv=None):
|
|
|
1509
2364
|
else:
|
|
1510
2365
|
fail(errors, f"docs/PLAN_MAP.md: 计划行缺少 docs/plans 链接:{row[0]}")
|
|
1511
2366
|
|
|
1512
|
-
|
|
2367
|
+
readable_plans = {name: data for name, data in plans.items() if name not in unsafe_plans}
|
|
2368
|
+
relation_errors, relation_warnings = validate_relation_graph(text, readable_plans)
|
|
1513
2369
|
warnings.extend(relation_warnings)
|
|
1514
2370
|
if args.strict_readiness:
|
|
1515
2371
|
errors.extend(relation_errors)
|
|
@@ -1535,6 +2391,8 @@ def main(argv=None):
|
|
|
1535
2391
|
active_plan_targets = {}
|
|
1536
2392
|
plan_texts = {}
|
|
1537
2393
|
for name, data in plans.items():
|
|
2394
|
+
if name in unsafe_plans:
|
|
2395
|
+
continue
|
|
1538
2396
|
plan_text = read_utf8(data["path"], errors)
|
|
1539
2397
|
plan_texts[name] = plan_text
|
|
1540
2398
|
if data["status"] in COMPLETED and not has_completion_evidence(plan_text):
|
|
@@ -1650,6 +2508,7 @@ def main(argv=None):
|
|
|
1650
2508
|
purpose=args.attest_purpose,
|
|
1651
2509
|
supersedes=args.supersedes or "",
|
|
1652
2510
|
review_status=args.review_status,
|
|
2511
|
+
attest_files=args.attest_file,
|
|
1653
2512
|
)
|
|
1654
2513
|
|
|
1655
2514
|
for warning in warnings:
|