plan-governance-cli 0.3.5 → 1.0.1

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.
@@ -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
- rows = []
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
- in_fence = False
71
+ fence = None
77
72
  for line in text.splitlines(keepends=True):
78
73
  stripped = line.lstrip()
79
- if stripped.startswith("```"):
80
- in_fence = not in_fence
81
- masked.append("".join("\n" if char == "\n" else "\r" if char == "\r" else " " for char in line))
82
- continue
83
- if in_fence:
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+", tail, re.MULTILINE)
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("|") or "---" in stripped:
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 = markdown_section(plan_text, ["阶段路线图"])
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 = markdown_section(plan_text, ["独立复核记录"])
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 check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, warnings, errors):
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 = markdown_section(current_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 and roadmap_status != expected_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
- missing_fields = sorted(READINESS_FIELDS - set(summary))
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(READINESS_FIELDS - {"当前阻塞项"}):
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 summary.get("当前阻塞项") and is_placeholder(summary["当前阻塞项"]) and summary["当前阻塞项"].strip() != "无":
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
- review = key_value_table(markdown_section(plan_text, ["最新独立准入复核"]))
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
- joined = "|".join(row)
479
- if re.search(r"\b(Yes|是)\b", joined) and re.search(r"(Open|待确认|未解决|待处理|未决定)", joined, re.IGNORECASE):
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": sha256_file(data["path"]),
724
- "plan_map_sha256": sha256_file(plan_map),
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
- if not attestations_dir.exists():
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
- for path in sorted(attestations_dir.glob("*.json")):
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
- warn(warnings, f"{path}: 快照引用了未登记计划:{plan_name or '<missing>'}")
784
- continue
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
- continue
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
- if sha256_file(plan_path) != attestation.get("plan_sha256"):
841
- warn(warnings, f"{path}: {plan_name} 计划文件 hash 已变化,需要人工复核")
842
- drifted = True
843
- if sha256_file(plan_map_path) != attestation.get("plan_map_sha256"):
844
- warn(warnings, f"{path}: PLAN_MAP.md hash 已变化,需要人工复核")
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
- blockers = []
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 len(row) < 4 or not re.search(r"\b(Yes|是)\b", row[2]):
1664
+ if not any(row):
1665
+ continue
1666
+ if len(row) < 4:
1667
+ issues.append("未决问题表字段不足,无法确认当前阻塞")
1668
+ continue
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 当前阻塞项字段不足,无法归属")
1004
1689
  continue
1005
- state = row[3].strip()
1006
- if state in {"已决定", "已收敛", "已完成", "无"}:
1690
+ label = row[0] or "未命名阻塞项"
1691
+ if not blocker_is_open(row[3], row[4], f"PLAN_MAP {label}", issues):
1007
1692
  continue
1008
- blockers.append(row[0].strip() or "未命名阻塞项")
1009
- return blockers
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
- match = pattern.search(text)
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+", tail, re.MULTILINE)
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(markdown_section(top_level_section(plan_text, "当前阶段"), ["阶段准入摘要"]))
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(markdown_section(plan_text, ["最新独立准入复核"]))
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 recent_evidence(plan_text):
2059
+ def current_recent_evidence(plan_text, phase=None):
1291
2060
  current = top_level_section(plan_text, "当前阶段") or ""
1292
- section = markdown_section(current, ["最近实施/验证记录"])
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
- blockers = unresolved_current_blockers(plan_text)
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,12 +2204,54 @@ 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
- print(f"{'ERROR' if strict else 'WARNING'}: {warning}")
1406
-
1407
-
1408
- def run_workset(root, as_json=False, include_history=False, strict=False):
2207
+ level = "ERROR" if strict and "兼容记录标题" not in warning else "WARNING"
2208
+ print(f"{level}: {warning}")
2209
+
2210
+
2211
+ def limit_workset_evidence(root, payload, limit):
2212
+ """裁剪 JSON 阅读窗口;完整 payload 和门禁结果保持不变。"""
2213
+ result = {**payload, "plans": []}
2214
+ if not payload["plans"]:
2215
+ return result
2216
+ source_errors = []
2217
+ _, _, records = load_plan_records(root, source_errors)
2218
+ if source_errors:
2219
+ records = {}
2220
+ for item in payload["plans"]:
2221
+ rows = item["recent_evidence"]
2222
+ source = None
2223
+ record = records.get(item["plan"])
2224
+ if record and record["phase"] == item["phase"]:
2225
+ errors = []
2226
+ text = read_utf8(record["path"], errors)
2227
+ # 来源只辅助回查;再次读取失败或内容变化不能伪造定位。
2228
+ if not errors and recent_evidence(text, item["phase"]) == rows:
2229
+ current = top_level_section(text, "当前阶段") or ""
2230
+ titles = ["最近实施/验证记录"]
2231
+ if item["phase"]:
2232
+ titles.extend([f"{item['phase']} 最近验证记录", f"{item['phase']} 最近实施/验证记录"])
2233
+ section = next((title for title in titles if fixed_section(current, title) is not None), None)
2234
+ try:
2235
+ path = record["path"].relative_to(root).as_posix()
2236
+ except ValueError:
2237
+ path = record["path"].as_posix()
2238
+ source = {"path": path, "section": section}
2239
+ shown = rows[-limit:]
2240
+ result["plans"].append({
2241
+ **item,
2242
+ "recent_evidence": shown,
2243
+ "recent_evidence_window": {
2244
+ "total": len(rows), "omitted": len(rows) - len(shown), "source": source,
2245
+ },
2246
+ })
2247
+ return result
2248
+
2249
+
2250
+ def run_workset(root, as_json=False, include_history=False, strict=False, evidence_limit=None):
1409
2251
  payload, status = workset_payload(root, include_history=include_history, strict=strict)
1410
2252
  if as_json:
2253
+ if evidence_limit is not None:
2254
+ payload = limit_workset_evidence(root, payload, evidence_limit)
1411
2255
  print(json.dumps(payload, ensure_ascii=False, indent=2))
1412
2256
  else:
1413
2257
  print_workset_text(payload, strict=strict)
@@ -1420,6 +2264,8 @@ def parse_args(argv):
1420
2264
  parser.add_argument("--workset", action="store_true", help="输出只读当前工作集。")
1421
2265
  parser.add_argument("--json", action="store_true", help="以 JSON 输出工作集。")
1422
2266
  parser.add_argument("--include-history", action="store_true", help="工作集包含历史计划。")
2267
+ parser.add_argument("--evidence-limit", type=int, metavar="N",
2268
+ help="仅工作集 JSON:每个计划显示末尾 N 条记录(正整数),省略时返回全部。")
1423
2269
  parser.add_argument("--root", dest="mode_root", help="工作集的仓库根目录。")
1424
2270
  parser.add_argument("--drift", action="store_true", help="检查工作区变更是否被活跃计划影响范围覆盖。")
1425
2271
  parser.add_argument("--pre-commit", action="store_true", help="检查 staged 变更是否被活跃计划影响范围覆盖。")
@@ -1429,6 +2275,8 @@ def parse_args(argv):
1429
2275
  help="将待实施/实施中计划的阶段准入结构缺陷从 WARNING 提升为 ERROR。",
1430
2276
  )
1431
2277
  parser.add_argument("--attest", metavar="PLAN", help="为已登记计划创建或覆盖完成快照。")
2278
+ parser.add_argument("--attest-file", action="append", metavar="PATH",
2279
+ help="可重复:与 --attest/--attest-purpose 显式绑定被审查的普通文件。")
1432
2280
  parser.add_argument(
1433
2281
  "--attest-purpose",
1434
2282
  choices=sorted(ATTESTATION_PURPOSES),
@@ -1450,17 +2298,27 @@ def parse_args(argv):
1450
2298
  default=None,
1451
2299
  help="检查活跃计划是否超过 N 天未更新;省略 N 时默认 10 天。",
1452
2300
  )
1453
- return parser.parse_args(argv)
2301
+ args = parser.parse_args(argv)
2302
+ if args.evidence_limit is not None:
2303
+ if args.evidence_limit <= 0:
2304
+ parser.error("--evidence-limit 必须为正整数")
2305
+ if not args.workset or not args.json:
2306
+ parser.error("--evidence-limit 只能与 --workset 和 --json 一起使用")
2307
+ return args
1454
2308
 
1455
2309
 
1456
2310
  def main(argv=None):
1457
2311
  args = parse_args(sys.argv[1:] if argv is None else argv)
2312
+ if args.attest_file is not None and (not args.attest or not args.attest_purpose or args.workset):
2313
+ print("ERROR: --attest-file 必须与 --attest/--attest-purpose 一起使用,不能用于 workset")
2314
+ return 1
1458
2315
  if args.workset:
1459
2316
  return run_workset(
1460
2317
  Path(args.mode_root or args.root),
1461
2318
  as_json=args.json,
1462
2319
  include_history=args.include_history,
1463
2320
  strict=args.strict_readiness,
2321
+ evidence_limit=args.evidence_limit,
1464
2322
  )
1465
2323
  root = Path(args.root)
1466
2324
  docs = root / "docs"
@@ -1468,16 +2326,56 @@ def main(argv=None):
1468
2326
  errors = []
1469
2327
  warnings = []
1470
2328
 
2329
+ try:
2330
+ bound_input = args.attest_file is not None or (args.check_attestations and has_attestation_binding(root))
2331
+ except (ValueError, OSError) as exc:
2332
+ level = "ERROR" if args.strict_readiness else "WARNING"
2333
+ print(f"{level}: 快照清单无法读取:{exc}")
2334
+ return 1 if args.strict_readiness else 0
2335
+ unsafe_map = False
2336
+ if bound_input:
2337
+ try:
2338
+ binding_file(root, "docs/PLAN_MAP.md")
2339
+ except (ValueError, OSError) as exc:
2340
+ if args.attest_file is not None:
2341
+ print(f"ERROR: binding 创建预检失败:{exc}")
2342
+ return 1
2343
+ unsafe_map = True
2344
+ if unsafe_map:
2345
+ reports = []
2346
+ warn_attestation_drift(warnings, root, {}, errors=errors, strict=args.strict_readiness, reports=reports)
2347
+ for warning in warnings:
2348
+ print(f"WARNING: {warning}")
2349
+ for report in reports:
2350
+ print(f"ATTESTATION: {report}")
2351
+ for error in errors:
2352
+ print(f"ERROR: {error}")
2353
+ return 1 if errors else 0
1471
2354
  if not plan_map.exists():
2355
+ if args.check_attestations:
2356
+ reports = []
2357
+ warn_attestation_drift(warnings, root, {}, errors=errors, strict=args.strict_readiness, reports=reports)
2358
+ if reports or errors:
2359
+ for warning in warnings:
2360
+ print(f"WARNING: {warning}")
2361
+ for report in reports:
2362
+ print(f"ATTESTATION: {report}")
2363
+ for error in errors:
2364
+ print(f"ERROR: {error}")
2365
+ return 1 if errors else 0
1472
2366
  print("未找到 docs/PLAN_MAP.md;当前仓库尚未初始化计划治理。")
1473
2367
  return 0
1474
2368
 
1475
2369
  text = read_utf8(plan_map, errors)
2370
+ _, blocker_index_issues = map_blockers(text)
2371
+ for issue in plan_index_issues(text) + blocker_index_issues:
2372
+ readiness_issue(warnings, errors, args.strict_readiness, issue)
1476
2373
  plan_rows = table_rows(text, "计划索引")
1477
2374
  if not plan_rows:
1478
2375
  fail(errors, "docs/PLAN_MAP.md: 缺少计划索引表")
1479
2376
 
1480
2377
  plans = {}
2378
+ unsafe_plans = set()
1481
2379
  for row in plan_rows:
1482
2380
  if len(row) < 2:
1483
2381
  continue
@@ -1497,6 +2395,13 @@ def main(argv=None):
1497
2395
  last_updated_date = date.min
1498
2396
  if link:
1499
2397
  path = docs / link
2398
+ if bound_input:
2399
+ try:
2400
+ binding_file(root, "docs/" + link)
2401
+ except (ValueError, OSError) as exc:
2402
+ attestation_issue(warnings, errors, args.attest_file is not None or args.strict_readiness,
2403
+ f"{name}: binding 计划读取预检失败:{exc}")
2404
+ unsafe_plans.add(name)
1500
2405
  if not path.exists():
1501
2406
  fail(errors, f"docs/PLAN_MAP.md: 引用的计划文件不存在:{link}")
1502
2407
  plans[name] = {
@@ -1509,7 +2414,8 @@ def main(argv=None):
1509
2414
  else:
1510
2415
  fail(errors, f"docs/PLAN_MAP.md: 计划行缺少 docs/plans 链接:{row[0]}")
1511
2416
 
1512
- relation_errors, relation_warnings = validate_relation_graph(text, plans)
2417
+ readable_plans = {name: data for name, data in plans.items() if name not in unsafe_plans}
2418
+ relation_errors, relation_warnings = validate_relation_graph(text, readable_plans)
1513
2419
  warnings.extend(relation_warnings)
1514
2420
  if args.strict_readiness:
1515
2421
  errors.extend(relation_errors)
@@ -1535,6 +2441,8 @@ def main(argv=None):
1535
2441
  active_plan_targets = {}
1536
2442
  plan_texts = {}
1537
2443
  for name, data in plans.items():
2444
+ if name in unsafe_plans:
2445
+ continue
1538
2446
  plan_text = read_utf8(data["path"], errors)
1539
2447
  plan_texts[name] = plan_text
1540
2448
  if data["status"] in COMPLETED and not has_completion_evidence(plan_text):
@@ -1650,6 +2558,7 @@ def main(argv=None):
1650
2558
  purpose=args.attest_purpose,
1651
2559
  supersedes=args.supersedes or "",
1652
2560
  review_status=args.review_status,
2561
+ attest_files=args.attest_file,
1653
2562
  )
1654
2563
 
1655
2564
  for warning in warnings: