plan-governance-cli 0.3.0 → 0.3.2

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.
@@ -2,7 +2,9 @@
2
2
  import argparse
3
3
  import hashlib
4
4
  import json
5
+ import posixpath
5
6
  import re
7
+ import secrets
6
8
  import subprocess
7
9
  import sys
8
10
  from datetime import date, datetime, timezone
@@ -26,6 +28,9 @@ ACTIVE = {"待实施", "实施中"}
26
28
  WARNING_ACTIVE = {"候选", "设计中", "待实施", "实施中"}
27
29
  IMPLEMENTING = {"实施中"}
28
30
  INACTIVE = {"已替代", "已合并", "已废弃"}
31
+ ATTESTATION_PURPOSES = {"phase_completion", "release_gate", "compliance"}
32
+ ATTESTATION_REVIEW_STATUSES = {"current", "superseded", "needs_review"}
33
+ ATTESTATION_SNAPSHOT_RE = re.compile(r"^\d{8}T\d{6}Z-[0-9a-f]{8}$")
29
34
 
30
35
 
31
36
  def fail(errors, message):
@@ -83,6 +88,8 @@ def mask_fenced_code(text):
83
88
 
84
89
 
85
90
  def markdown_section(text, heading_names):
91
+ if not text:
92
+ return None
86
93
  heading_pattern = "|".join(re.escape(name) for name in heading_names)
87
94
  pattern = re.compile(rf"^#+\s+({heading_pattern})\b.*$", re.MULTILINE)
88
95
  match = pattern.search(mask_fenced_code(text))
@@ -183,6 +190,9 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
183
190
  return
184
191
 
185
192
  current_phase = data["phase"]
193
+ current_section = top_level_section(plan_text, "当前阶段")
194
+ summary_section = markdown_section(current_section, ["阶段准入摘要"])
195
+ summary = key_value_table(summary_section)
186
196
  roadmap = phase_roadmap_rows(plan_text)
187
197
  matching_rows = [row for row in roadmap if row and row[0] == current_phase]
188
198
  if not matching_rows:
@@ -195,20 +205,20 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
195
205
  )
196
206
  else:
197
207
  roadmap_status = matching_rows[-1][4] if len(matching_rows[-1]) > 4 else ""
198
- if roadmap_status and roadmap_status != data["status"]:
208
+ phase_status = summary.get("阶段状态", "").strip()
209
+ expected_roadmap_status = phase_status or data["status"]
210
+ if roadmap_status and roadmap_status != expected_roadmap_status:
199
211
  readiness_issue(
200
212
  warnings,
201
213
  errors,
202
214
  strict,
203
- f"{plan_name}: 当前阶段 {current_phase} 的路线图状态 {roadmap_status} 与 PLAN_MAP 状态 {data['status']} 不一致",
215
+ f"{plan_name}: 当前阶段 {current_phase} 的路线图状态 {roadmap_status} 与阶段状态 {expected_roadmap_status} 不一致",
204
216
  )
205
217
 
206
218
  if markdown_section(plan_text, ["当前阶段"]) is None:
207
219
  section_hint = structural_heading_hint(plan_text, "当前阶段")
208
220
  readiness_issue(warnings, errors, strict, f"{plan_name}: 缺少 `## 当前阶段` 章节{section_hint}")
209
221
 
210
- summary_section = markdown_section(plan_text, ["阶段准入摘要"])
211
- summary = key_value_table(summary_section)
212
222
  missing_fields = sorted(READINESS_FIELDS - set(summary))
213
223
  if missing_fields:
214
224
  title_hint = ""
@@ -274,7 +284,18 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
274
284
  f"{plan_name}: 最新独立准入复核结论不是通过:{review['结论']}",
275
285
  )
276
286
 
277
- history = [row for row in review_history_rows(plan_text) if len(row) >= 4 and row[2] == current_phase]
287
+ history = [
288
+ row
289
+ for row in review_history_rows(plan_text)
290
+ if len(row) >= 4
291
+ and row[2] == current_phase
292
+ and (
293
+ "准入" in row[1]
294
+ or ("完成验收" in row[1] and row[3].startswith("通过"))
295
+ or ("完成复核" in row[1] and row[3].startswith("通过"))
296
+ or "readiness" in row[1].lower()
297
+ )
298
+ ]
278
299
  if not history:
279
300
  history_hint = structural_heading_hint(plan_text, "独立复核记录")
280
301
  readiness_issue(
@@ -360,6 +381,34 @@ def extract_affected_targets(plan_text):
360
381
  return targets
361
382
 
362
383
 
384
+ def extract_phase_evidence_targets(plan_text):
385
+ """读取当前阶段显式声明的证据路径,并返回合法路径和非法原文。"""
386
+ current = top_level_section(plan_text, "当前阶段")
387
+ section = markdown_section(current, ["阶段证据"])
388
+ valid = []
389
+ invalid = []
390
+ for item in markdown_list_items(section):
391
+ backtick = re.search(r"`([^`]+)`", item)
392
+ token = backtick.group(1).strip() if backtick else (item.strip().split(None, 1)[0] if item.strip() else "")
393
+ if not token or token in PLACEHOLDER_VALUES:
394
+ continue
395
+ raw = token.replace("\\", "/")
396
+ if (
397
+ raw.startswith("/")
398
+ or re.match(r"^[A-Za-z]:/", raw)
399
+ or any(part == ".." for part in raw.split("/"))
400
+ or any(char in raw for char in "*?[]{}")
401
+ ):
402
+ invalid.append(token)
403
+ continue
404
+ normalized = normalize_scope_path(raw)
405
+ if not normalized or normalized == ".":
406
+ invalid.append(token)
407
+ continue
408
+ valid.append(normalized)
409
+ return valid, invalid
410
+
411
+
363
412
  def extract_plan_references(plan_text, known_plans, current_name):
364
413
  references = set()
365
414
  for match in re.finditer(r"\[\[([^\]]+)\]\]", plan_text):
@@ -505,16 +554,112 @@ def changed_files(root, staged=False):
505
554
  return files
506
555
 
507
556
 
508
- def warn_uncovered_changes(warnings, mode, files, active_plan_targets):
557
+ def changed_plan_map_lines(root, staged=False):
558
+ """返回 PLAN_MAP diff 中实际变更的行;无法读取时由调用方按未知归属处理。"""
559
+ args = ["diff"]
560
+ if staged:
561
+ args.append("--cached")
562
+ args.extend(["--unified=0", "--", "docs/PLAN_MAP.md"])
563
+ result = subprocess.run(
564
+ ["git", *args],
565
+ cwd=root,
566
+ check=True,
567
+ capture_output=True,
568
+ text=True,
569
+ )
570
+ lines = []
571
+ for line in result.stdout.splitlines():
572
+ if line.startswith(("+++", "---", "@@")):
573
+ continue
574
+ if line.startswith(("+", "-")):
575
+ content = line[1:].strip()
576
+ if content:
577
+ lines.append(content)
578
+ return lines
579
+
580
+
581
+ def plan_map_line_candidates(line, known_plans):
582
+ linked_plans = sorted(
583
+ {
584
+ name
585
+ for name in re.findall(r"plans/([A-Za-z0-9\u4e00-\u9fff._-]+)\.md", line)
586
+ if name in known_plans
587
+ }
588
+ )
589
+ if linked_plans:
590
+ return linked_plans
591
+ candidates = []
592
+ for name in known_plans:
593
+ if re.search(rf"(?<![A-Za-z0-9_-]){re.escape(name)}(?![A-Za-z0-9_-])", line):
594
+ candidates.append(name)
595
+ return sorted(set(candidates))
596
+
597
+
598
+ def plan_map_change_owners(root, staged, known_plans):
599
+ try:
600
+ lines = changed_plan_map_lines(root, staged=staged)
601
+ except (subprocess.CalledProcessError, FileNotFoundError):
602
+ return None
603
+ return [plan_map_line_candidates(line, known_plans) for line in lines]
604
+
605
+
606
+ def plan_map_change_is_covered(plan_map_owners, active_plan_names):
607
+ if plan_map_owners is None or not plan_map_owners:
608
+ return False
609
+ return all(len(candidates) == 1 and candidates[0] in active_plan_names for candidates in plan_map_owners)
610
+
611
+
612
+ def warn_uncovered_changes(
613
+ warnings,
614
+ mode,
615
+ files,
616
+ plan_targets,
617
+ root=None,
618
+ known_plans=None,
619
+ covering_plan_names=None,
620
+ ):
509
621
  if not files:
510
622
  return
511
- if not active_plan_targets:
623
+ if not plan_targets:
512
624
  warn(warnings, f"{mode}: 存在变更文件,但没有活跃计划声明影响范围")
513
625
  return
514
- for changed_file in uncovered_changed_files(files, active_plan_targets):
626
+ covering_plan_names = set(covering_plan_names or plan_targets)
627
+ map_owners = plan_map_change_owners(root, mode == "--pre-commit", known_plans or set()) if root else None
628
+ uncovered = []
629
+ for changed_file in sorted(files):
630
+ normalized_file = normalize_scope_path(changed_file)
631
+ if normalized_file == "docs/PLAN_MAP.md":
632
+ if plan_map_change_is_covered(map_owners, covering_plan_names):
633
+ continue
634
+ warn(warnings, f"{mode}: PLAN_MAP.md 变更无法唯一归属到活跃计划索引行")
635
+ continue
636
+ if any(target_matches_path(target, normalized_file) for targets in plan_targets.values() for target in targets):
637
+ continue
638
+ uncovered.append(changed_file)
639
+ for changed_file in uncovered:
515
640
  warn(warnings, f"{mode}: 变更文件未被活跃计划影响范围覆盖:{changed_file}")
516
641
 
517
642
 
643
+ def completed_plan_drift_targets(plans, plan_texts, root, files):
644
+ """关闭窗口内只覆盖已完成计划的自身和显式阶段证据。
645
+
646
+ 已完成计划不再属于活跃计划,但关闭阶段时通常还要原子地同步计划、地图和
647
+ 验收证据。只有当该计划文件本身也在本次变更中时,才开启这个窄窗口;不会
648
+ 复用已完成计划的完整影响范围,从而避免历史计划吞掉新的未声明变更。
649
+ """
650
+ changed = {normalize_scope_path(path) for path in files}
651
+ targets = {}
652
+ for name, data in plans.items():
653
+ if data["status"] not in COMPLETED:
654
+ continue
655
+ plan_relative = data["path"].relative_to(root).as_posix()
656
+ if plan_relative not in changed:
657
+ continue
658
+ phase_evidence, _ = extract_phase_evidence_targets(plan_texts.get(name, ""))
659
+ targets[name] = [plan_relative, *phase_evidence]
660
+ return targets
661
+
662
+
518
663
  def warn_stale_plans(warnings, plans, stale_days, today=None):
519
664
  today = today or date.today()
520
665
  for name, data in plans.items():
@@ -540,7 +685,27 @@ def relative_to_root(path, root):
540
685
  return path.relative_to(root).as_posix()
541
686
 
542
687
 
543
- def create_attestation(root, plan_map, plans, plan_name, errors):
688
+ def safe_relative_path(value):
689
+ raw = str(value or "").strip().replace("\\", "/")
690
+ if not raw or raw.startswith("/") or re.match(r"^[A-Za-z]:/", raw):
691
+ return None
692
+ parts = [part for part in raw.split("/") if part not in {"", "."}]
693
+ if ".." in parts:
694
+ return None
695
+ normalized = posixpath.normpath("/".join(parts))
696
+ return normalized if normalized not in {"", ".", "/"} else None
697
+
698
+
699
+ def create_attestation(
700
+ root,
701
+ plan_map,
702
+ plans,
703
+ plan_name,
704
+ errors,
705
+ purpose=None,
706
+ supersedes="",
707
+ review_status="current",
708
+ ):
544
709
  data = plans.get(plan_name)
545
710
  if data is None:
546
711
  fail(errors, f"{plan_name}: 未登记计划,无法创建完成快照")
@@ -561,17 +726,51 @@ def create_attestation(root, plan_map, plans, plan_name, errors):
561
726
  "created_by": "plan-governance",
562
727
  "reason": "阶段完成快照",
563
728
  }
564
- target = root / "docs" / "attestations" / f"{plan_name}.json"
729
+ if purpose is not None:
730
+ if purpose not in ATTESTATION_PURPOSES:
731
+ fail(errors, f"{plan_name}: attestation purpose 非法:{purpose}")
732
+ return None
733
+ if review_status not in ATTESTATION_REVIEW_STATUSES:
734
+ fail(errors, f"{plan_name}: attestation review_status 非法:{review_status}")
735
+ return None
736
+ normalized_supersedes = ""
737
+ if supersedes:
738
+ normalized_supersedes = safe_relative_path(supersedes)
739
+ if normalized_supersedes is None:
740
+ fail(errors, f"{plan_name}: attestation supersedes 必须是仓库内相对路径:{supersedes}")
741
+ return None
742
+ snapshot_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + f"-{secrets.token_hex(4)}"
743
+ attestation.update(
744
+ {
745
+ "purpose": purpose,
746
+ "snapshot_id": snapshot_id,
747
+ "supersedes": normalized_supersedes,
748
+ "review_status": review_status,
749
+ }
750
+ )
751
+ filename = f"{plan_name}--{purpose}--{snapshot_id}.json"
752
+ else:
753
+ filename = f"{plan_name}.json"
754
+ target = root / "docs" / "attestations" / filename
565
755
  target.parent.mkdir(parents=True, exist_ok=True)
566
756
  target.write_text(json.dumps(attestation, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
567
757
  return target
568
758
 
569
759
 
570
- def warn_attestation_drift(warnings, root, plans):
760
+ def attestation_issue(warnings, errors, strict, message):
761
+ if strict:
762
+ fail(errors, message)
763
+ else:
764
+ warn(warnings, message)
765
+
766
+
767
+ def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, reports=None):
768
+ errors = errors if errors is not None else []
571
769
  attestations_dir = root / "docs" / "attestations"
572
770
  if not attestations_dir.exists():
573
771
  return
574
772
 
773
+ records = []
575
774
  for path in sorted(attestations_dir.glob("*.json")):
576
775
  try:
577
776
  attestation = json.loads(path.read_text(encoding="utf-8"))
@@ -584,8 +783,52 @@ def warn_attestation_drift(warnings, root, plans):
584
783
  warn(warnings, f"{path}: 快照引用了未登记计划:{plan_name or '<missing>'}")
585
784
  continue
586
785
 
587
- plan_path = root / str(attestation.get("plan_path", ""))
588
- plan_map_path = root / str(attestation.get("plan_map_path", "docs/PLAN_MAP.md"))
786
+ legacy = "purpose" not in attestation
787
+ purpose = str(attestation.get("purpose", "phase_completion")).strip()
788
+ if purpose not in ATTESTATION_PURPOSES:
789
+ attestation_issue(warnings, errors, strict, f"{path}: attestation purpose 非法:{purpose or '<missing>'}")
790
+ continue
791
+ snapshot_id = str(attestation.get("snapshot_id", "")).strip()
792
+ review_status = str(attestation.get("review_status", "current")).strip()
793
+ supersedes = attestation.get("supersedes", "")
794
+ invalid_structure = False
795
+ if not legacy:
796
+ for field in ["snapshot_id", "supersedes", "review_status"]:
797
+ if field not in attestation:
798
+ attestation_issue(warnings, errors, strict, f"{path}: 新 attestation 缺少字段:{field}")
799
+ invalid_structure = True
800
+ if not ATTESTATION_SNAPSHOT_RE.fullmatch(snapshot_id):
801
+ attestation_issue(warnings, errors, strict, f"{path}: snapshot_id 格式非法:{snapshot_id or '<missing>'}")
802
+ invalid_structure = True
803
+ expected_name = f"{plan_name}--{purpose}--{snapshot_id}.json"
804
+ if path.name != expected_name:
805
+ attestation_issue(warnings, errors, strict, f"{path}: 文件名应为 {expected_name}")
806
+ invalid_structure = True
807
+ if review_status not in ATTESTATION_REVIEW_STATUSES:
808
+ attestation_issue(warnings, errors, strict, f"{path}: review_status 非法:{review_status or '<missing>'}")
809
+ invalid_structure = True
810
+ if not isinstance(supersedes, str):
811
+ attestation_issue(warnings, errors, strict, f"{path}: supersedes 必须是相对路径字符串")
812
+ supersedes = ""
813
+ invalid_structure = True
814
+ normalized_supersedes = safe_relative_path(supersedes) if supersedes else ""
815
+ if supersedes and normalized_supersedes is None:
816
+ attestation_issue(warnings, errors, strict, f"{path}: supersedes 必须是仓库内相对路径:{supersedes}")
817
+ normalized_supersedes = ""
818
+ invalid_structure = True
819
+
820
+ plan_path_value = attestation.get("plan_path", "")
821
+ plan_map_path_value = attestation.get("plan_map_path", "docs/PLAN_MAP.md")
822
+ plan_path_rel = safe_relative_path(plan_path_value)
823
+ plan_map_path_rel = safe_relative_path(plan_map_path_value)
824
+ if plan_path_rel is None:
825
+ warn(warnings, f"{path}: 快照引用的计划路径非法:{plan_path_value}")
826
+ continue
827
+ if plan_map_path_rel is None:
828
+ warn(warnings, f"{path}: 快照引用的 PLAN_MAP 路径非法:{plan_map_path_value}")
829
+ continue
830
+ plan_path = root / plan_path_rel
831
+ plan_map_path = root / plan_map_path_rel
589
832
  if not plan_path.exists():
590
833
  warn(warnings, f"{path}: 快照引用的计划文件不存在:{plan_path}")
591
834
  continue
@@ -593,10 +836,93 @@ def warn_attestation_drift(warnings, root, plans):
593
836
  warn(warnings, f"{path}: 快照引用的 PLAN_MAP.md 不存在:{plan_map_path}")
594
837
  continue
595
838
 
839
+ drifted = False
596
840
  if sha256_file(plan_path) != attestation.get("plan_sha256"):
597
841
  warn(warnings, f"{path}: {plan_name} 计划文件 hash 已变化,需要人工复核")
842
+ drifted = True
598
843
  if sha256_file(plan_map_path) != attestation.get("plan_map_sha256"):
599
844
  warn(warnings, f"{path}: PLAN_MAP.md hash 已变化,需要人工复核")
845
+ drifted = True
846
+ records.append(
847
+ {
848
+ "path": path,
849
+ "relative_path": path.relative_to(root).as_posix(),
850
+ "plan": plan_name,
851
+ "purpose": purpose,
852
+ "review_status": review_status,
853
+ "supersedes": normalized_supersedes,
854
+ "drifted": drifted,
855
+ "legacy": legacy,
856
+ "invalid_structure": invalid_structure,
857
+ }
858
+ )
859
+
860
+ by_path = {record["relative_path"]: record for record in records}
861
+ incoming = {}
862
+ edges = {}
863
+ for record in records:
864
+ target = record["supersedes"]
865
+ if not target:
866
+ continue
867
+ target_record = by_path.get(target)
868
+ if target_record is None:
869
+ attestation_issue(warnings, errors, strict, f"{record['path']}: supersedes 目标不存在:{target}")
870
+ record["invalid_structure"] = True
871
+ continue
872
+ if target_record["purpose"] != record["purpose"] or target_record["plan"] != record["plan"]:
873
+ attestation_issue(
874
+ warnings,
875
+ errors,
876
+ strict,
877
+ f"{record['path']}: supersedes 目标必须与当前快照属于同一计划和 purpose:{target}",
878
+ )
879
+ record["invalid_structure"] = True
880
+ continue
881
+ if target == record["relative_path"]:
882
+ attestation_issue(warnings, errors, strict, f"{record['path']}: supersedes 不能指向自身")
883
+ record["invalid_structure"] = True
884
+ continue
885
+ incoming.setdefault(target, []).append(record["relative_path"])
886
+ edges.setdefault(record["relative_path"], []).append(target)
887
+
888
+ cycles = detect_dependency_cycles(edges)
889
+ for cycle in cycles:
890
+ attestation_issue(warnings, errors, strict, f"attestation supersedes 存在环:{cycle}")
891
+ for node in cycle.split(" -> "):
892
+ if node in by_path:
893
+ by_path[node]["invalid_structure"] = True
894
+
895
+ current_by_key = {}
896
+ for record in records:
897
+ effective = record["review_status"]
898
+ if record["invalid_structure"]:
899
+ effective = "needs_review"
900
+ elif incoming.get(record["relative_path"]):
901
+ effective = "superseded"
902
+ elif record["drifted"] or record["review_status"] == "needs_review":
903
+ effective = "needs_review"
904
+ if effective == "current":
905
+ key = (record["plan"], record["purpose"])
906
+ current_by_key.setdefault(key, []).append(record["relative_path"])
907
+ record["effective_status"] = effective
908
+
909
+ for key, paths in current_by_key.items():
910
+ if len(paths) > 1:
911
+ attestation_issue(
912
+ warnings,
913
+ errors,
914
+ strict,
915
+ f"attestation 同一计划/purpose 存在多个 current:{key[0]}/{key[1]}:{', '.join(paths)}",
916
+ )
917
+ for record in records:
918
+ if record["relative_path"] in paths:
919
+ record["effective_status"] = "needs_review"
920
+
921
+ if reports is not None:
922
+ for record in records:
923
+ reports.append(
924
+ f"{record['relative_path']} | plan={record['plan']} | purpose={record['purpose']} | status={record['effective_status']}"
925
+ )
600
926
 
601
927
 
602
928
  def detect_dependency_cycles(edges):
@@ -621,9 +947,968 @@ def detect_dependency_cycles(edges):
621
947
  return cycles
622
948
 
623
949
 
950
+ def load_plan_records(root, errors):
951
+ """读取计划索引,供只读工作集和步骤校验入口复用。"""
952
+ docs = root / "docs"
953
+ plan_map = docs / "PLAN_MAP.md"
954
+ if not plan_map.exists():
955
+ fail(errors, "未找到 docs/PLAN_MAP.md;当前仓库尚未初始化计划治理")
956
+ return "", plan_map, {}
957
+
958
+ plan_map_text = read_utf8(plan_map, errors)
959
+ plans = {}
960
+ for row in table_rows(plan_map_text, "计划索引"):
961
+ if len(row) < 6:
962
+ continue
963
+ link = extract_plan_link(row[0])
964
+ if not link:
965
+ continue
966
+ name = Path(link).stem
967
+ plans[name] = {
968
+ "path": docs / link,
969
+ "status": row[1].strip("` "),
970
+ "phase": row[2].strip("` "),
971
+ "depends": row[4],
972
+ }
973
+ if not plans:
974
+ fail(errors, "docs/PLAN_MAP.md: 缺少可读取的计划索引")
975
+ return plan_map_text, plan_map, plans
976
+
977
+
978
+ def plan_index_issues(plan_map_text):
979
+ """检查工作集入口依赖的计划索引结构,不改变既有基础检查语义。"""
980
+ issues = []
981
+ seen = set()
982
+ for row_index, row in enumerate(table_rows(plan_map_text, "计划索引"), start=1):
983
+ if len(row) < 6:
984
+ issues.append(f"计划索引第 {row_index} 行字段不足,无法安全派生工作集")
985
+ continue
986
+ link = extract_plan_link(row[0])
987
+ if not link:
988
+ issues.append(f"计划索引第 {row_index} 行缺少计划链接,无法安全派生工作集")
989
+ continue
990
+ name = Path(link).stem
991
+ if name in seen:
992
+ issues.append(f"计划索引存在重复计划 ID:{name}")
993
+ seen.add(name)
994
+ status = row[1].strip("` ")
995
+ if status not in VALID_STATUSES:
996
+ issues.append(f"{name}: 计划状态非法:{status}")
997
+ return issues
998
+
999
+
1000
+ def unresolved_current_blockers(plan_text):
1001
+ blockers = []
1002
+ for row in table_rows(plan_text, "未决问题"):
1003
+ if len(row) < 4 or not re.search(r"\b(Yes|是)\b", row[2]):
1004
+ continue
1005
+ state = row[3].strip()
1006
+ if state in {"已决定", "已收敛", "已完成", "无"}:
1007
+ continue
1008
+ blockers.append(row[0].strip() or "未命名阻塞项")
1009
+ return blockers
1010
+
1011
+
1012
+ def top_level_section(text, heading):
1013
+ pattern = re.compile(rf"^##\s+{re.escape(heading)}\s*$", re.MULTILINE)
1014
+ match = pattern.search(text)
1015
+ if not match:
1016
+ return None
1017
+ tail = text[match.end():]
1018
+ next_heading = re.search(r"^##\s+", tail, re.MULTILINE)
1019
+ return tail[: next_heading.start()] if next_heading else tail
1020
+
1021
+
1022
+ def current_summary(plan_text):
1023
+ return key_value_table(markdown_section(top_level_section(plan_text, "当前阶段"), ["阶段准入摘要"]))
1024
+
1025
+
1026
+ def current_review_passes(plan_text, phase):
1027
+ review = key_value_table(markdown_section(plan_text, ["最新独立准入复核"]))
1028
+ return review.get("阶段") == phase and review.get("结论", "").startswith("通过")
1029
+
1030
+
1031
+ def structured_next_action(plan_text):
1032
+ section = top_level_section(plan_text, "当前阶段") or ""
1033
+ values = key_value_table(section)
1034
+ value = values.get("下一动作", "").strip()
1035
+ if not value:
1036
+ match = re.search(r"(?:^|\n)\s*下一动作\s*[::]\s*([^\n]+)", section)
1037
+ value = match.group(1).strip() if match else ""
1038
+ mapping = {
1039
+ "验证": "verify",
1040
+ "verify": "verify",
1041
+ "同步": "sync",
1042
+ "sync": "sync",
1043
+ "实施": "implement",
1044
+ "implement": "implement",
1045
+ "无": "none",
1046
+ "none": "none",
1047
+ }
1048
+ if not value:
1049
+ return {"state": "unknown", "kind": "unknown", "reason": "缺少结构化下一动作"}
1050
+ kind = mapping.get(value.lower(), "unknown")
1051
+ if kind == "unknown":
1052
+ return {"state": "unknown", "kind": "unknown", "reason": f"下一动作值无法识别:{value}"}
1053
+ return {"state": "known", "kind": kind, "reason": "来自当前阶段结构化下一动作"}
1054
+
1055
+
1056
+ def parallel_summary(plan_map_text, plan_name, relation_valid=True):
1057
+ if not relation_valid:
1058
+ return {
1059
+ "state": "unknown",
1060
+ "peers": [],
1061
+ "reason": "阶段关系存在结构错误,不能派生确定并行关系",
1062
+ }
1063
+ rows = table_rows(plan_map_text, "阶段关系")
1064
+ peers = []
1065
+ evidence = []
1066
+ for row in rows:
1067
+ if len(row) < 7 or row[0] == "来源计划":
1068
+ continue
1069
+ source, target = row[0].strip("` "), row[2].strip("` ")
1070
+ if plan_name not in {source, target}:
1071
+ continue
1072
+ peer = target if source == plan_name else source
1073
+ if peer and peer != plan_name and peer not in peers:
1074
+ peers.append(peer)
1075
+ if row[6] and row[6] not in evidence:
1076
+ evidence.append(row[6])
1077
+ if not rows or not peers:
1078
+ return {"state": "unknown", "peers": [], "reason": "缺少当前计划的直接阶段关系证据"}
1079
+ return {
1080
+ "state": "known",
1081
+ "peers": peers,
1082
+ "reason": "只读透传阶段关系表中的直接关系;完整关系校验由后续阶段负责",
1083
+ "evidence": evidence,
1084
+ }
1085
+
1086
+
1087
+ RELATION_COLUMNS = ["来源计划", "来源阶段", "目标计划", "目标阶段", "关系类型", "解除条件", "证据"]
1088
+ RELATION_TYPES = {"hard_gate", "evidence", "soft_context"}
1089
+ SHARED_WRITE_COLUMNS = [
1090
+ "来源计划",
1091
+ "来源阶段",
1092
+ "目标计划",
1093
+ "目标阶段",
1094
+ "约束类型",
1095
+ "共享目标",
1096
+ "串行顺序",
1097
+ "解除条件",
1098
+ "证据",
1099
+ ]
1100
+
1101
+
1102
+ def relation_rows(plan_map_text):
1103
+ section = markdown_section(plan_map_text, ["阶段关系"])
1104
+ if section is None:
1105
+ return None, []
1106
+ rows = markdown_table_rows(section)
1107
+ header_index = next((index for index, row in enumerate(rows) if row[: len(RELATION_COLUMNS)] == RELATION_COLUMNS), None)
1108
+ if header_index is None:
1109
+ return [], []
1110
+ return RELATION_COLUMNS, rows[header_index + 1 :]
1111
+
1112
+
1113
+ def shared_write_rows(plan_map_text):
1114
+ section = markdown_section(plan_map_text, ["机器可检查共享写入约束"])
1115
+ if section is None:
1116
+ return None, []
1117
+ rows = markdown_table_rows(section)
1118
+ header_index = next(
1119
+ (index for index, row in enumerate(rows) if row[: len(SHARED_WRITE_COLUMNS)] == SHARED_WRITE_COLUMNS),
1120
+ None,
1121
+ )
1122
+ if header_index is None:
1123
+ return [], []
1124
+ return SHARED_WRITE_COLUMNS, rows[header_index + 1 :]
1125
+
1126
+
1127
+ def clean_relation_value(value):
1128
+ return (value or "").strip().strip("`").strip()
1129
+
1130
+
1131
+ def valid_phase_name(value):
1132
+ return bool(re.fullmatch(r"阶段\s*[0-9][0-9]*", clean_relation_value(value)))
1133
+
1134
+
1135
+ def declared_plan_phases(plan_data):
1136
+ path = plan_data.get("path") if plan_data else None
1137
+ if not path or not path.exists():
1138
+ return set()
1139
+ try:
1140
+ text = path.read_text(encoding="utf-8")
1141
+ except (OSError, UnicodeDecodeError):
1142
+ return set()
1143
+ return {
1144
+ clean_relation_value(row[0])
1145
+ for row in phase_roadmap_rows(text)
1146
+ if row and clean_relation_value(row[0])
1147
+ }
1148
+
1149
+
1150
+ def validate_relation_evidence(evidence, plans, row_label):
1151
+ """仅校验 Markdown 链接形式的相对证据路径;纯文本运行记录保持兼容。"""
1152
+ if not evidence or not plans:
1153
+ return []
1154
+ docs_root = next(iter(plans.values()))["path"].parent.parent
1155
+ errors = []
1156
+ for match in re.finditer(r"\]\(([^)#]+)(?:#[^)]+)?\)", evidence):
1157
+ target = match.group(1).strip()
1158
+ if not target or re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", target):
1159
+ continue
1160
+ candidate = (docs_root / target).resolve()
1161
+ if not candidate.exists():
1162
+ errors.append(f"PLAN_MAP.md: {row_label} 证据链接不存在:{target}")
1163
+ return errors
1164
+
1165
+
1166
+ def validate_relation_graph(plan_map_text, plans):
1167
+ """校验阶段关系和可选机器共享写入约束;不校验业务完成结论。"""
1168
+ errors = []
1169
+ warnings = []
1170
+ headers, rows = relation_rows(plan_map_text)
1171
+ if headers is None:
1172
+ relation_defined = False
1173
+ elif not headers:
1174
+ errors.append("PLAN_MAP.md: `阶段关系` 缺少固定七列表头")
1175
+ relation_defined = True
1176
+ else:
1177
+ relation_defined = True
1178
+
1179
+ gate_pairs = set()
1180
+ relation_graph = {}
1181
+ phase_cache = {name: declared_plan_phases(data) for name, data in plans.items()}
1182
+ if headers:
1183
+ for row_index, row in enumerate(rows, start=1):
1184
+ if not any(clean_relation_value(cell) for cell in row):
1185
+ continue
1186
+ if len(row) != len(RELATION_COLUMNS):
1187
+ errors.append(f"PLAN_MAP.md: 阶段关系第 {row_index} 行字段数应为 {len(RELATION_COLUMNS)}")
1188
+ continue
1189
+ values = [clean_relation_value(cell) for cell in row]
1190
+ source, source_phase, target, target_phase, relation_type, unlock, evidence = values
1191
+ for label, value in [
1192
+ ("来源计划", source),
1193
+ ("来源阶段", source_phase),
1194
+ ("目标计划", target),
1195
+ ("目标阶段", target_phase),
1196
+ ("关系类型", relation_type),
1197
+ ("证据", evidence),
1198
+ ]:
1199
+ if not value or is_placeholder(value):
1200
+ errors.append(f"PLAN_MAP.md: 阶段关系第 {row_index} 行缺少有效{label}")
1201
+ if source not in plans:
1202
+ errors.append(f"PLAN_MAP.md: 阶段关系第 {row_index} 行来源计划不存在:{source}")
1203
+ if target not in plans:
1204
+ errors.append(f"PLAN_MAP.md: 阶段关系第 {row_index} 行目标计划不存在:{target}")
1205
+ if not valid_phase_name(source_phase):
1206
+ errors.append(f"PLAN_MAP.md: 阶段关系第 {row_index} 行来源阶段格式非法:{source_phase}")
1207
+ if not valid_phase_name(target_phase):
1208
+ errors.append(f"PLAN_MAP.md: 阶段关系第 {row_index} 行目标阶段格式非法:{target_phase}")
1209
+ if source in plans and valid_phase_name(source_phase):
1210
+ declared = phase_cache.get(source, set())
1211
+ if declared and source_phase not in declared:
1212
+ errors.append(f"PLAN_MAP.md: 阶段关系第 {row_index} 行来源阶段不存在于计划路线图:{source_phase}")
1213
+ if target in plans and valid_phase_name(target_phase):
1214
+ declared = phase_cache.get(target, set())
1215
+ if declared and target_phase not in declared:
1216
+ errors.append(f"PLAN_MAP.md: 阶段关系第 {row_index} 行目标阶段不存在于计划路线图:{target_phase}")
1217
+ if relation_type not in RELATION_TYPES:
1218
+ errors.append(f"PLAN_MAP.md: 阶段关系第 {row_index} 行关系类型非法:{relation_type}")
1219
+ elif relation_type != "soft_context" and (not unlock or is_placeholder(unlock)):
1220
+ errors.append(f"PLAN_MAP.md: 阶段关系第 {row_index} 行缺少有效解除条件")
1221
+ errors.extend(validate_relation_evidence(evidence, plans, f"阶段关系第 {row_index} 行"))
1222
+ if source == target and source_phase == target_phase:
1223
+ errors.append(f"PLAN_MAP.md: 阶段关系第 {row_index} 行不能引用自身同一阶段:{source}")
1224
+ if source not in plans or target not in plans or relation_type not in RELATION_TYPES:
1225
+ continue
1226
+ source_node = f"{source}@{source_phase}"
1227
+ target_node = f"{target}@{target_phase}"
1228
+ relation_graph.setdefault(source_node, [])
1229
+ relation_graph.setdefault(target_node, [])
1230
+ if relation_type in {"hard_gate", "evidence"}:
1231
+ relation_graph[source_node].append(target_node)
1232
+ if relation_type == "hard_gate":
1233
+ gate_pairs.add((source, target))
1234
+
1235
+ for cycle in detect_dependency_cycles(relation_graph):
1236
+ errors.append(f"PLAN_MAP.md: 阶段关系存在 hard_gate/evidence 环依赖:{cycle}")
1237
+
1238
+ for source, target in sorted(gate_pairs):
1239
+ declared = extract_declared_dependencies(plans[target].get("depends", ""))
1240
+ if source not in declared:
1241
+ warnings.append(
1242
+ f"PLAN_MAP.md: 阶段关系 {source} -> {target} 为 hard_gate,但计划索引依赖列未包含 {source};以阶段关系为准"
1243
+ )
1244
+
1245
+ shared_headers, shared_rows = shared_write_rows(plan_map_text)
1246
+ if shared_headers == []:
1247
+ errors.append("PLAN_MAP.md: `机器可检查共享写入约束` 缺少固定九列表头")
1248
+ elif shared_headers:
1249
+ for row_index, row in enumerate(shared_rows, start=1):
1250
+ if not any(clean_relation_value(cell) for cell in row):
1251
+ continue
1252
+ if len(row) != len(SHARED_WRITE_COLUMNS):
1253
+ errors.append(f"PLAN_MAP.md: 共享写入约束第 {row_index} 行字段数应为 {len(SHARED_WRITE_COLUMNS)}")
1254
+ continue
1255
+ values = [clean_relation_value(cell) for cell in row]
1256
+ source, source_phase, target, target_phase, constraint, target_paths, order, unlock, evidence = values
1257
+ for label, value in [
1258
+ ("来源计划", source),
1259
+ ("来源阶段", source_phase),
1260
+ ("目标计划", target),
1261
+ ("目标阶段", target_phase),
1262
+ ("约束类型", constraint),
1263
+ ("共享目标", target_paths),
1264
+ ("串行顺序", order),
1265
+ ("解除条件", unlock),
1266
+ ("证据", evidence),
1267
+ ]:
1268
+ if not value or is_placeholder(value):
1269
+ errors.append(f"PLAN_MAP.md: 共享写入约束第 {row_index} 行缺少有效{label}")
1270
+ if source not in plans:
1271
+ errors.append(f"PLAN_MAP.md: 共享写入约束第 {row_index} 行来源计划不存在:{source}")
1272
+ if target not in plans:
1273
+ errors.append(f"PLAN_MAP.md: 共享写入约束第 {row_index} 行目标计划不存在:{target}")
1274
+ if not valid_phase_name(source_phase):
1275
+ errors.append(f"PLAN_MAP.md: 共享写入约束第 {row_index} 行来源阶段格式非法:{source_phase}")
1276
+ if not valid_phase_name(target_phase):
1277
+ errors.append(f"PLAN_MAP.md: 共享写入约束第 {row_index} 行目标阶段格式非法:{target_phase}")
1278
+ if constraint != "shared_write_risk":
1279
+ errors.append(f"PLAN_MAP.md: 共享写入约束第 {row_index} 行约束类型非法:{constraint}")
1280
+ errors.extend(validate_relation_evidence(evidence, plans, f"共享写入约束第 {row_index} 行"))
1281
+ expected_order = f"{source}-before-{target}"
1282
+ if source and target and order != expected_order:
1283
+ errors.append(
1284
+ f"PLAN_MAP.md: 共享写入约束第 {row_index} 行串行顺序应为 {expected_order}:{order}"
1285
+ )
1286
+
1287
+ return errors, warnings
1288
+
1289
+
1290
+ def recent_evidence(plan_text):
1291
+ current = top_level_section(plan_text, "当前阶段") or ""
1292
+ section = markdown_section(current, ["最近实施/验证记录"])
1293
+ rows = markdown_table_rows(section)
1294
+ return [row for row in rows if len(row) >= 2 and row[0] not in {"日期", "字段", "类型"}]
1295
+
1296
+
1297
+ def workset_payload(root, include_history=False, strict=False):
1298
+ errors = []
1299
+ strict_failures = []
1300
+ plan_map_text, _, plans = load_plan_records(root, errors)
1301
+ output = {
1302
+ "schema_version": 1,
1303
+ "source": "derived",
1304
+ "plans": [],
1305
+ "warnings": [],
1306
+ }
1307
+ if errors:
1308
+ output["warnings"] = errors
1309
+ return output, 1
1310
+
1311
+ index_issues = plan_index_issues(plan_map_text)
1312
+ relation_errors, relation_warnings = validate_relation_graph(plan_map_text, plans)
1313
+ output["warnings"].extend(relation_warnings)
1314
+ if relation_errors:
1315
+ output["warnings"].extend(relation_errors)
1316
+ strict_failures.extend(relation_errors)
1317
+
1318
+ for name, data in plans.items():
1319
+ if not include_history and data["status"] not in WARNING_ACTIVE:
1320
+ continue
1321
+ path = data["path"]
1322
+ if not path.exists():
1323
+ message = f"{name}: 计划文件不存在:{path}"
1324
+ output["warnings"].append(message)
1325
+ strict_failures.append(message)
1326
+ continue
1327
+ plan_text = read_utf8(path, errors)
1328
+ summary = current_summary(plan_text)
1329
+ blockers = unresolved_current_blockers(plan_text)
1330
+ if blockers:
1331
+ readiness = "blocked"
1332
+ action = {
1333
+ "state": "known",
1334
+ "kind": "resolve_blocker",
1335
+ "reason": "当前阶段存在未解决阻塞项",
1336
+ }
1337
+ elif data["status"] == "待实施":
1338
+ readiness = "ready"
1339
+ action = {"state": "known", "kind": "implement", "reason": "计划状态为待实施"}
1340
+ elif data["status"] == "实施中":
1341
+ readiness = "in_progress"
1342
+ action = structured_next_action(plan_text)
1343
+ elif data["status"] in {"候选", "设计中"}:
1344
+ missing = [
1345
+ field for field in ["Step 0", "样本矩阵", "验证方式", "失败/回滚边界"]
1346
+ if not summary.get(field) or is_placeholder(summary.get(field))
1347
+ ]
1348
+ if missing:
1349
+ readiness = "design"
1350
+ action = {
1351
+ "state": "known",
1352
+ "kind": "complete_step0",
1353
+ "reason": f"阶段准入摘要缺少或占位字段:{', '.join(missing)}",
1354
+ }
1355
+ elif not current_review_passes(plan_text, data["phase"]):
1356
+ readiness = "design"
1357
+ action = {
1358
+ "state": "known",
1359
+ "kind": "independent_review",
1360
+ "reason": "当前阶段尚无通过的最新独立准入复核",
1361
+ }
1362
+ else:
1363
+ readiness = "design"
1364
+ action = {"state": "unknown", "kind": "unknown", "reason": "缺少结构化下一动作"}
1365
+ else:
1366
+ readiness = "unknown"
1367
+ action = {"state": "unknown", "kind": "unknown", "reason": "计划状态不在工作集派生范围"}
1368
+
1369
+ output["plans"].append(
1370
+ {
1371
+ "plan": name,
1372
+ "status": data["status"],
1373
+ "phase": data["phase"],
1374
+ "readiness": readiness,
1375
+ "blockers": blockers,
1376
+ "next_action": action,
1377
+ "parallel": parallel_summary(plan_map_text, name, relation_valid=not relation_errors),
1378
+ "recent_evidence": recent_evidence(plan_text),
1379
+ }
1380
+ )
1381
+
1382
+ if errors:
1383
+ output["warnings"].extend(errors)
1384
+ strict_failures.extend(errors)
1385
+ if index_issues:
1386
+ output["plans"] = []
1387
+ output["warnings"].extend(index_issues)
1388
+ strict_failures.extend(index_issues)
1389
+ return output, 1 if strict and strict_failures else 0
1390
+
1391
+
1392
+ def print_workset_text(payload, strict=False):
1393
+ print("工作集(只读派生)")
1394
+ for item in payload["plans"]:
1395
+ action = item["next_action"]
1396
+ parallel = item["parallel"]
1397
+ print(
1398
+ f"- {item['plan']} | {item['status']} | {item['phase']} | "
1399
+ f"{item['readiness']} | next={action['kind']} | "
1400
+ f"parallel={parallel['state']}"
1401
+ )
1402
+ if item["blockers"]:
1403
+ print(f" blockers: {';'.join(item['blockers'])}")
1404
+ for warning in payload["warnings"]:
1405
+ print(f"{'ERROR' if strict else 'WARNING'}: {warning}")
1406
+
1407
+
1408
+ STEP_COLUMNS = ["步骤 ID", "前置步骤", "动作", "证据", "完成条件", "状态", "分支记录"]
1409
+ STEP_STATES = {"未开始", "执行中", "已完成", "阻塞", "不适用"}
1410
+ CONSTRAINT_COLUMNS = ["约束 ID", "类型", "步骤", "共享目标", "建议顺序", "说明"]
1411
+
1412
+
1413
+ def split_prerequisites(value):
1414
+ if not value or value.strip("` -") == "":
1415
+ return []
1416
+ return [item for item in re.split(r"[,,、\s]+", value.strip("` ")) if item and item != "-"]
1417
+
1418
+
1419
+ def branch_record_has_value(branch_record, marker):
1420
+ match = re.search(rf"{re.escape(marker)}\s*[::]\s*([^;;,,\n]+)", branch_record)
1421
+ return bool(match and not is_placeholder(match.group(1).strip()))
1422
+
1423
+
1424
+ def find_execution_rows(plan_text):
1425
+ section = markdown_section(plan_text, ["执行清单"])
1426
+ if section is None:
1427
+ return None, []
1428
+ rows = markdown_table_rows(section)
1429
+ header_index = next((index for index, row in enumerate(rows) if row[: len(STEP_COLUMNS)] == STEP_COLUMNS), None)
1430
+ if header_index is None:
1431
+ return [], []
1432
+ return STEP_COLUMNS, rows[header_index + 1 :]
1433
+
1434
+
1435
+ def validate_step_plan(root, plan_name, strict=False):
1436
+ errors = []
1437
+ _, _, plans = load_plan_records(root, errors)
1438
+ if errors:
1439
+ payload = {
1440
+ "schema_version": 1,
1441
+ "plan": plan_name,
1442
+ "phase": "",
1443
+ "enabled": False,
1444
+ "status": "invalid",
1445
+ "steps": [],
1446
+ "errors": errors,
1447
+ "warnings": [],
1448
+ }
1449
+ return payload, 1
1450
+
1451
+ data = plans.get(plan_name)
1452
+ if data is None or not data["path"].exists():
1453
+ payload = {
1454
+ "schema_version": 1,
1455
+ "plan": plan_name,
1456
+ "phase": data["phase"] if data else "",
1457
+ "enabled": False,
1458
+ "status": "invalid",
1459
+ "steps": [],
1460
+ "errors": [f"{plan_name}: 未登记计划或计划文件不存在"],
1461
+ "warnings": [],
1462
+ }
1463
+ return payload, 1
1464
+
1465
+ plan_errors = []
1466
+ plan_text = read_utf8(data["path"], plan_errors)
1467
+ if plan_errors:
1468
+ return {
1469
+ "schema_version": 1,
1470
+ "plan": plan_name,
1471
+ "phase": data["phase"],
1472
+ "enabled": False,
1473
+ "status": "invalid",
1474
+ "steps": [],
1475
+ "errors": plan_errors,
1476
+ "warnings": [],
1477
+ }, 1
1478
+ parse_text = mask_fenced_code(plan_text)
1479
+ if not re.search(r"^\s*execution_mode\s*:\s*autonomous-continuous\s*$", parse_text, re.MULTILINE):
1480
+ return {
1481
+ "schema_version": 1,
1482
+ "plan": plan_name,
1483
+ "phase": data["phase"],
1484
+ "enabled": False,
1485
+ "status": "not_enabled",
1486
+ "steps": [],
1487
+ "errors": [],
1488
+ "warnings": [],
1489
+ }, 0
1490
+
1491
+ errors = []
1492
+ policy_match = re.search(r"^\s*execution_policy\s*:\s*(\S+)\s*$", parse_text, re.MULTILINE)
1493
+ policy = policy_match.group(1).strip("`") if policy_match else "serial"
1494
+ if policy not in {"serial", "parallel"}:
1495
+ errors.append(f"{plan_name}: 字段 execution_policy 值非法:{policy}")
1496
+
1497
+ headers, rows = find_execution_rows(plan_text)
1498
+ if headers is None:
1499
+ errors.append(f"{plan_name}: 缺少 `### 执行清单` 章节")
1500
+ rows = []
1501
+ elif not headers:
1502
+ errors.append(f"{plan_name}: 执行清单缺少固定七列表头")
1503
+ elif not rows:
1504
+ errors.append(f"{plan_name}: 执行清单至少需要一个步骤")
1505
+
1506
+ steps = []
1507
+ ids = set()
1508
+ dependencies = {}
1509
+ for row_index, row in enumerate(rows, start=1):
1510
+ if len(row) < len(STEP_COLUMNS):
1511
+ errors.append(f"{plan_name}: 执行清单第 {row_index} 行字段不足")
1512
+ continue
1513
+ step = dict(zip(STEP_COLUMNS, row[: len(STEP_COLUMNS)]))
1514
+ step_id = step["步骤 ID"].strip("` ")
1515
+ if not step_id:
1516
+ errors.append(f"{plan_name}: 执行清单第 {row_index} 行缺少步骤 ID")
1517
+ continue
1518
+ if step_id in ids:
1519
+ errors.append(f"{plan_name}: 步骤 {step_id} 重复")
1520
+ ids.add(step_id)
1521
+ dependencies[step_id] = split_prerequisites(step["前置步骤"])
1522
+ for field in ["动作", "证据", "完成条件"]:
1523
+ if not step[field].strip("` ") or is_placeholder(step[field]):
1524
+ errors.append(f"{plan_name}: 步骤 {step_id} 缺少字段 {field}")
1525
+ state = step["状态"].strip("` ")
1526
+ if state not in STEP_STATES:
1527
+ errors.append(f"{plan_name}: 步骤 {step_id} 状态非法:{state}")
1528
+ if state == "不适用":
1529
+ branch_record = step["分支记录"].strip("` ")
1530
+ if is_placeholder(branch_record):
1531
+ errors.append(f"{plan_name}: 步骤 {step_id} 为不适用但缺少分支记录")
1532
+ else:
1533
+ for marker in ["触发条件", "理由", "替代证据"]:
1534
+ if not branch_record_has_value(branch_record, marker):
1535
+ errors.append(f"{plan_name}: 步骤 {step_id} 的不适用分支记录缺少有效{marker}")
1536
+ if state in {"已完成", "不适用"} and is_placeholder(step["证据"]):
1537
+ errors.append(f"{plan_name}: 步骤 {step_id} 为 {state} 但缺少证据")
1538
+ steps.append(
1539
+ {
1540
+ "id": step_id,
1541
+ "preconditions": dependencies[step_id],
1542
+ "action": step["动作"],
1543
+ "evidence": step["证据"],
1544
+ "completion": step["完成条件"],
1545
+ "state": state,
1546
+ "branch_record": step["分支记录"],
1547
+ }
1548
+ )
1549
+
1550
+ for step_id, prerequisites in dependencies.items():
1551
+ for prerequisite in prerequisites:
1552
+ if prerequisite not in ids:
1553
+ errors.append(f"{plan_name}: 步骤 {step_id} 引用了未知前置步骤 {prerequisite}")
1554
+
1555
+ graph = {step_id: [item for item in prerequisites if item in ids] for step_id, prerequisites in dependencies.items()}
1556
+ errors.extend(f"{plan_name}: 执行清单存在环依赖:{cycle}" for cycle in detect_dependency_cycles(graph))
1557
+ status = "invalid" if errors else "valid"
1558
+ warnings = list(errors) if errors and not strict else []
1559
+ return {
1560
+ "schema_version": 1,
1561
+ "plan": plan_name,
1562
+ "phase": data["phase"],
1563
+ "enabled": True,
1564
+ "status": status,
1565
+ "execution_policy": policy,
1566
+ "steps": steps if not errors else [],
1567
+ "errors": errors,
1568
+ "warnings": warnings,
1569
+ }, 1 if errors and strict else 0
1570
+
1571
+
1572
+ def run_workset(root, as_json=False, include_history=False, strict=False):
1573
+ payload, status = workset_payload(root, include_history=include_history, strict=strict)
1574
+ if as_json:
1575
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1576
+ else:
1577
+ print_workset_text(payload, strict=strict)
1578
+ return status
1579
+
1580
+
1581
+ def run_validate_steps(root, plan_name, as_json=False, strict=False):
1582
+ payload, status = validate_step_plan(root, plan_name, strict=strict)
1583
+ if as_json:
1584
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1585
+ else:
1586
+ print(f"步骤校验:{payload['plan']} | {payload['phase']} | {payload['status']}")
1587
+ for error in payload["errors"]:
1588
+ print(f"{'ERROR' if strict else 'WARNING'}: {error}")
1589
+ for warning in payload["warnings"]:
1590
+ if warning not in payload["errors"]:
1591
+ print(f"WARNING: {warning}")
1592
+ return status
1593
+
1594
+
1595
+ def split_constraint_values(value):
1596
+ if not value or value.strip("` -") == "":
1597
+ return []
1598
+ return [
1599
+ item.strip("` ")
1600
+ for item in re.split(r"\s*->\s*|[,,、\s]+", value.strip("` "))
1601
+ if item and item != "-"
1602
+ ]
1603
+
1604
+
1605
+ def parse_execution_constraints(plan_text, step_ids):
1606
+ section = markdown_section(plan_text, ["执行约束"])
1607
+ if section is None:
1608
+ return [], []
1609
+ rows = markdown_table_rows(section)
1610
+ header_index = next(
1611
+ (index for index, row in enumerate(rows) if row[: len(CONSTRAINT_COLUMNS)] == CONSTRAINT_COLUMNS),
1612
+ None,
1613
+ )
1614
+ if header_index is None:
1615
+ return [], ["执行约束缺少固定六列表头"]
1616
+
1617
+ constraints = []
1618
+ errors = []
1619
+ seen = set()
1620
+ for row_index, row in enumerate(rows[header_index + 1 :], start=1):
1621
+ if len(row) < len(CONSTRAINT_COLUMNS):
1622
+ errors.append(f"执行约束第 {row_index} 行字段不足")
1623
+ continue
1624
+ constraint = dict(zip(CONSTRAINT_COLUMNS, row[: len(CONSTRAINT_COLUMNS)]))
1625
+ constraint_id = constraint["约束 ID"].strip("` ")
1626
+ kind = constraint["类型"].strip("` ")
1627
+ step_values = split_constraint_values(constraint["步骤"])
1628
+ targets = split_constraint_values(constraint["共享目标"])
1629
+ recommended_order = split_constraint_values(constraint["建议顺序"])
1630
+ if not constraint_id or constraint_id in seen:
1631
+ errors.append(f"执行约束第 {row_index} 行约束 ID 缺失或重复:{constraint_id or '-'}")
1632
+ seen.add(constraint_id)
1633
+ if kind != "shared_write_risk":
1634
+ errors.append(f"执行约束 {constraint_id or row_index} 类型非法:{kind}")
1635
+ if not step_values:
1636
+ errors.append(f"执行约束 {constraint_id or row_index} 缺少步骤")
1637
+ unknown_steps = [item for item in step_values if item not in step_ids]
1638
+ if unknown_steps:
1639
+ errors.append(
1640
+ f"执行约束 {constraint_id or row_index} 引用了未知步骤:{', '.join(unknown_steps)}"
1641
+ )
1642
+ if not targets:
1643
+ errors.append(f"执行约束 {constraint_id or row_index} 缺少共享目标")
1644
+ if recommended_order and any(item not in step_values for item in recommended_order):
1645
+ errors.append(f"执行约束 {constraint_id or row_index} 建议顺序引用了约束外步骤")
1646
+ constraints.append(
1647
+ {
1648
+ "id": constraint_id,
1649
+ "kind": kind,
1650
+ "step_ids": step_values,
1651
+ "targets": targets,
1652
+ "recommended_order": recommended_order,
1653
+ "description": constraint["说明"],
1654
+ }
1655
+ )
1656
+ return constraints, errors
1657
+
1658
+
1659
+ def phase_gate_is_blocked(plan_text, current_phase):
1660
+ roadmap = phase_roadmap_rows(plan_text)
1661
+ current_index = next(
1662
+ (index for index, row in enumerate(roadmap) if row and row[0] == current_phase),
1663
+ None,
1664
+ )
1665
+ if current_index is None:
1666
+ return False
1667
+ current_row = roadmap[current_index]
1668
+ current_status = current_row[4].strip() if len(current_row) > 4 else ""
1669
+ if current_status != "已完成":
1670
+ return False
1671
+ following = roadmap[current_index + 1 :]
1672
+ if not following:
1673
+ return False
1674
+ next_status = following[0][4].strip() if len(following[0]) > 4 else ""
1675
+ return next_status not in {"待实施", "实施中", "已完成"}
1676
+
1677
+
1678
+ def plan_next_payload(root, plan_name):
1679
+ errors = []
1680
+ _, _, plans = load_plan_records(root, errors)
1681
+ data = plans.get(plan_name)
1682
+ if errors or data is None or not data["path"].exists():
1683
+ return {
1684
+ "schema_version": 1,
1685
+ "plan": plan_name,
1686
+ "phase": data["phase"] if data else "",
1687
+ "execution_mode": None,
1688
+ "status": "blocked",
1689
+ "ready_steps": [],
1690
+ "blocked_steps": [
1691
+ {
1692
+ "id": "__plan__",
1693
+ "reasons": [{"kind": "plan_not_found", "detail": f"未登记计划或计划文件不存在:{plan_name}"}],
1694
+ }
1695
+ ],
1696
+ "constraints": [],
1697
+ "next_action": {"kind": "resolve_blocked", "reason": "plan_not_found"},
1698
+ }, 2
1699
+
1700
+ plan_errors = []
1701
+ plan_text = read_utf8(data["path"], plan_errors)
1702
+ if plan_errors:
1703
+ return {
1704
+ "schema_version": 1,
1705
+ "plan": plan_name,
1706
+ "phase": data["phase"],
1707
+ "execution_mode": None,
1708
+ "status": "blocked",
1709
+ "ready_steps": [],
1710
+ "blocked_steps": [
1711
+ {"id": "__plan__", "reasons": [{"kind": "invalid_structure", "detail": item} for item in plan_errors]},
1712
+ ],
1713
+ "constraints": [],
1714
+ "next_action": {"kind": "resolve_blocked", "reason": "invalid_structure"},
1715
+ }, 1
1716
+
1717
+ validated, validation_status = validate_step_plan(root, plan_name, strict=True)
1718
+ if validated["status"] == "not_enabled":
1719
+ return {
1720
+ "schema_version": 1,
1721
+ "plan": plan_name,
1722
+ "phase": data["phase"],
1723
+ "execution_mode": None,
1724
+ "status": "not_enabled",
1725
+ "ready_steps": [],
1726
+ "blocked_steps": [],
1727
+ "constraints": [],
1728
+ "next_action": {"kind": "none", "reason": "plan_not_enabled"},
1729
+ }, 0
1730
+
1731
+ if validated["status"] != "valid":
1732
+ evidence_errors = [
1733
+ error
1734
+ for error in validated.get("errors", [])
1735
+ if "缺少证据" in error or "缺少字段 证据" in error
1736
+ ]
1737
+ other_errors = [
1738
+ error
1739
+ for error in validated.get("errors", [])
1740
+ if "缺少证据" not in error and "缺少字段 证据" not in error
1741
+ ]
1742
+ if evidence_errors and not other_errors:
1743
+ blocked_steps = []
1744
+ for error in evidence_errors:
1745
+ match = re.search(r"步骤\s+([^: ]+)", error)
1746
+ step_id = match.group(1).strip("` ") if match else "__plan__"
1747
+ blocked_steps.append(
1748
+ {
1749
+ "id": step_id,
1750
+ "reasons": [{"kind": "missing_evidence", "detail": error}],
1751
+ }
1752
+ )
1753
+ return {
1754
+ "schema_version": 1,
1755
+ "plan": plan_name,
1756
+ "phase": data["phase"],
1757
+ "execution_mode": "autonomous-continuous",
1758
+ "execution_policy": validated.get("execution_policy", "serial"),
1759
+ "status": "blocked",
1760
+ "ready_steps": [],
1761
+ "blocked_steps": blocked_steps,
1762
+ "constraints": [],
1763
+ "next_action": {"kind": "resolve_blocked", "reason": "missing_evidence"},
1764
+ }, 0
1765
+ reasons = [
1766
+ {"kind": "invalid_structure", "detail": error}
1767
+ for error in validated.get("errors", [])
1768
+ ] or [{"kind": "invalid_structure", "detail": "步骤表无法安全解析"}]
1769
+ return {
1770
+ "schema_version": 1,
1771
+ "plan": plan_name,
1772
+ "phase": data["phase"],
1773
+ "execution_mode": "autonomous-continuous",
1774
+ "execution_policy": validated.get("execution_policy", "serial"),
1775
+ "status": "blocked",
1776
+ "ready_steps": [],
1777
+ "blocked_steps": [{"id": "__plan__", "reasons": reasons}],
1778
+ "constraints": [],
1779
+ "next_action": {"kind": "resolve_blocked", "reason": "invalid_structure"},
1780
+ }, 1
1781
+
1782
+ steps = validated["steps"]
1783
+ step_ids = {step["id"] for step in steps}
1784
+ constraints, constraint_errors = parse_execution_constraints(plan_text, step_ids)
1785
+ if constraint_errors:
1786
+ return {
1787
+ "schema_version": 1,
1788
+ "plan": plan_name,
1789
+ "phase": data["phase"],
1790
+ "execution_mode": "autonomous-continuous",
1791
+ "execution_policy": validated.get("execution_policy", "serial"),
1792
+ "status": "blocked",
1793
+ "ready_steps": [],
1794
+ "blocked_steps": [
1795
+ {
1796
+ "id": "__plan__",
1797
+ "reasons": [{"kind": "invalid_structure", "detail": error} for error in constraint_errors],
1798
+ }
1799
+ ],
1800
+ "constraints": constraints,
1801
+ "next_action": {"kind": "resolve_blocked", "reason": "invalid_structure"},
1802
+ }, 1
1803
+
1804
+ terminal_states = {"已完成", "不适用"}
1805
+ terminal_ids = {step["id"] for step in steps if step["state"] in terminal_states}
1806
+ ready = []
1807
+ blocked = []
1808
+ for step in steps:
1809
+ if step["state"] in terminal_states:
1810
+ continue
1811
+ if step["state"] == "阻塞":
1812
+ blocked.append(
1813
+ {"id": step["id"], "reasons": [{"kind": "failed_step", "detail": "步骤处于阻塞状态"}]}
1814
+ )
1815
+ continue
1816
+ if step["state"] == "执行中":
1817
+ blocked.append(
1818
+ {"id": step["id"], "reasons": [{"kind": "step_in_progress", "detail": "步骤已处于执行中"}]}
1819
+ )
1820
+ continue
1821
+ missing = [item for item in step["preconditions"] if item not in terminal_ids]
1822
+ if missing:
1823
+ blocked.append(
1824
+ {
1825
+ "id": step["id"],
1826
+ "reasons": [
1827
+ {
1828
+ "kind": "missing_predecessor",
1829
+ "step_ids": missing,
1830
+ }
1831
+ ],
1832
+ }
1833
+ )
1834
+ continue
1835
+ ready.append(
1836
+ {
1837
+ "id": step["id"],
1838
+ "reason": {"kind": "dependencies_satisfied"},
1839
+ }
1840
+ )
1841
+
1842
+ policy = validated.get("execution_policy", "serial")
1843
+ if policy == "serial" and ready:
1844
+ ready = ready[:1]
1845
+
1846
+ all_terminal = all(step["state"] in terminal_states for step in steps)
1847
+ if ready:
1848
+ status = "ready"
1849
+ action_kind = "review_shared_write_order" if constraints else "run_ready_steps"
1850
+ action_reason = "shared_write_risk_present" if constraints else "steps_ready"
1851
+ exit_status = 0
1852
+ elif all_terminal and phase_gate_is_blocked(plan_text, data["phase"]):
1853
+ status = "phase_gate"
1854
+ action_kind = "await_phase_gate"
1855
+ action_reason = "next_phase_not_admitted"
1856
+ exit_status = 0
1857
+ elif all_terminal:
1858
+ status = "complete"
1859
+ action_kind = "await_independent_acceptance"
1860
+ action_reason = "implementation_complete_acceptance_pending"
1861
+ exit_status = 0
1862
+ else:
1863
+ status = "blocked"
1864
+ action_kind = "resolve_blocked"
1865
+ action_reason = blocked[0]["reasons"][0]["kind"] if blocked else "no_ready_steps"
1866
+ exit_status = 0
1867
+
1868
+ return {
1869
+ "schema_version": 1,
1870
+ "plan": plan_name,
1871
+ "phase": data["phase"],
1872
+ "execution_mode": "autonomous-continuous",
1873
+ "execution_policy": policy,
1874
+ "status": status,
1875
+ "ready_steps": ready,
1876
+ "blocked_steps": blocked,
1877
+ "constraints": constraints,
1878
+ "next_action": {"kind": action_kind, "reason": action_reason},
1879
+ }, exit_status
1880
+
1881
+
1882
+ def print_plan_next_text(payload):
1883
+ print(f"下一步骤:{payload['plan']} | {payload['phase']} | {payload['status']}")
1884
+ if payload.get("ready_steps"):
1885
+ print(" ready: " + ", ".join(item["id"] for item in payload["ready_steps"]))
1886
+ if payload.get("blocked_steps"):
1887
+ print(" blocked: " + ", ".join(item["id"] for item in payload["blocked_steps"]))
1888
+ if payload.get("constraints"):
1889
+ print(" constraints: " + ", ".join(item["id"] for item in payload["constraints"]))
1890
+ action = payload["next_action"]
1891
+ print(f" next: {action['kind']} ({action['reason']})")
1892
+
1893
+
1894
+ def run_plan_next(root, plan_name, as_json=False):
1895
+ payload, status = plan_next_payload(root, plan_name)
1896
+ if as_json:
1897
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1898
+ else:
1899
+ print_plan_next_text(payload)
1900
+ return status
1901
+
1902
+
624
1903
  def parse_args(argv):
625
1904
  parser = argparse.ArgumentParser(description="检查计划治理文档的一致性。")
626
1905
  parser.add_argument("root", nargs="?", default=".", help="仓库根目录,默认当前目录。")
1906
+ parser.add_argument("--workset", action="store_true", help="输出只读当前工作集。")
1907
+ parser.add_argument("--validate-steps", action="store_true", help="只读校验指定计划的自主执行步骤表。")
1908
+ parser.add_argument("--next-plan", metavar="PLAN", help="只读查询指定计划的下一步骤集合。")
1909
+ parser.add_argument("--json", action="store_true", help="以 JSON 输出工作集或步骤校验结果。")
1910
+ parser.add_argument("--include-history", action="store_true", help="工作集包含历史计划。")
1911
+ parser.add_argument("--root", dest="mode_root", help="工作集/步骤校验的仓库根目录。")
627
1912
  parser.add_argument("--drift", action="store_true", help="检查工作区变更是否被活跃计划影响范围覆盖。")
628
1913
  parser.add_argument("--pre-commit", action="store_true", help="检查 staged 变更是否被活跃计划影响范围覆盖。")
629
1914
  parser.add_argument(
@@ -632,6 +1917,18 @@ def parse_args(argv):
632
1917
  help="将待实施/实施中计划的阶段准入结构缺陷从 WARNING 提升为 ERROR。",
633
1918
  )
634
1919
  parser.add_argument("--attest", metavar="PLAN", help="为已登记计划创建或覆盖完成快照。")
1920
+ parser.add_argument(
1921
+ "--attest-purpose",
1922
+ choices=sorted(ATTESTATION_PURPOSES),
1923
+ help="创建带 purpose/snapshot_id 关系的完成快照;不传时保持旧 <plan>.json 格式。",
1924
+ )
1925
+ parser.add_argument("--supersedes", help="新 attestation 要替代的仓库内相对快照路径。")
1926
+ parser.add_argument(
1927
+ "--review-status",
1928
+ choices=sorted(ATTESTATION_REVIEW_STATUSES),
1929
+ default="current",
1930
+ help="新 attestation 的初始复核状态,默认 current。",
1931
+ )
635
1932
  parser.add_argument("--check-attestations", action="store_true", help="检查完成快照 hash 是否漂移。")
636
1933
  parser.add_argument(
637
1934
  "--stale-days",
@@ -646,6 +1943,22 @@ def parse_args(argv):
646
1943
 
647
1944
  def main(argv=None):
648
1945
  args = parse_args(sys.argv[1:] if argv is None else argv)
1946
+ if args.workset:
1947
+ return run_workset(
1948
+ Path(args.mode_root or args.root),
1949
+ as_json=args.json,
1950
+ include_history=args.include_history,
1951
+ strict=args.strict_readiness,
1952
+ )
1953
+ if args.validate_steps:
1954
+ return run_validate_steps(
1955
+ Path(args.mode_root or "."),
1956
+ args.root,
1957
+ as_json=args.json,
1958
+ strict=args.strict_readiness,
1959
+ )
1960
+ if args.next_plan:
1961
+ return run_plan_next(Path(args.mode_root or "."), args.next_plan, as_json=args.json)
649
1962
  root = Path(args.root)
650
1963
  docs = root / "docs"
651
1964
  plan_map = docs / "PLAN_MAP.md"
@@ -693,6 +2006,13 @@ def main(argv=None):
693
2006
  else:
694
2007
  fail(errors, f"docs/PLAN_MAP.md: 计划行缺少 docs/plans 链接:{row[0]}")
695
2008
 
2009
+ relation_errors, relation_warnings = validate_relation_graph(text, plans)
2010
+ warnings.extend(relation_warnings)
2011
+ if args.strict_readiness:
2012
+ errors.extend(relation_errors)
2013
+ else:
2014
+ warnings.extend(relation_errors)
2015
+
696
2016
  edges = {}
697
2017
  inactive = {name for name, data in plans.items() if data["status"] in INACTIVE}
698
2018
  for name, data in plans.items():
@@ -721,7 +2041,20 @@ def main(argv=None):
721
2041
  if data["status"] in ACTIVE and has_current_blocker(plan_text):
722
2042
  fail(errors, f"{data['path']}: 活跃计划仍有未解决的当前阶段阻塞项")
723
2043
  if data["status"] in WARNING_ACTIVE:
724
- active_plan_targets[name] = extract_affected_targets(plan_text)
2044
+ targets = extract_affected_targets(plan_text)
2045
+ plan_relative = data["path"].relative_to(root).as_posix()
2046
+ if plan_relative not in targets:
2047
+ targets.append(plan_relative)
2048
+ phase_evidence, invalid_evidence = extract_phase_evidence_targets(plan_text)
2049
+ for evidence in phase_evidence:
2050
+ if evidence not in targets:
2051
+ targets.append(evidence)
2052
+ for invalid in invalid_evidence:
2053
+ warn(
2054
+ warnings,
2055
+ f"{data['path']}: 阶段证据路径非法,未纳入 drift 覆盖:{invalid}",
2056
+ )
2057
+ active_plan_targets[name] = targets
725
2058
 
726
2059
  check_phase_readiness(
727
2060
  text,
@@ -748,10 +2081,37 @@ def main(argv=None):
748
2081
  warn(warnings, f"{name}: PLAN_MAP.md 声明依赖 {unreferenced},但计划正文未引用")
749
2082
 
750
2083
  try:
2084
+ drift_files = changed_files(root) if args.drift else set()
2085
+ pre_commit_files = changed_files(root, staged=True) if args.pre_commit else set()
2086
+ completion_window_targets = completed_plan_drift_targets(
2087
+ plans,
2088
+ plan_texts,
2089
+ root,
2090
+ drift_files | pre_commit_files,
2091
+ )
2092
+ drift_plan_targets = dict(active_plan_targets)
2093
+ drift_plan_targets.update(completion_window_targets)
2094
+ drift_covering_plan_names = set(active_plan_targets) | set(completion_window_targets)
751
2095
  if args.drift:
752
- warn_uncovered_changes(warnings, "--drift", changed_files(root), active_plan_targets)
2096
+ warn_uncovered_changes(
2097
+ warnings,
2098
+ "--drift",
2099
+ drift_files,
2100
+ drift_plan_targets,
2101
+ root=root,
2102
+ known_plans=set(plans),
2103
+ covering_plan_names=drift_covering_plan_names,
2104
+ )
753
2105
  if args.pre_commit:
754
- warn_uncovered_changes(warnings, "--pre-commit", changed_files(root, staged=True), active_plan_targets)
2106
+ warn_uncovered_changes(
2107
+ warnings,
2108
+ "--pre-commit",
2109
+ pre_commit_files,
2110
+ drift_plan_targets,
2111
+ root=root,
2112
+ known_plans=set(plans),
2113
+ covering_plan_names=drift_covering_plan_names,
2114
+ )
755
2115
  except (subprocess.CalledProcessError, FileNotFoundError) as exc:
756
2116
  warn(warnings, f"Git 变更检查不可用:{exc}")
757
2117
 
@@ -762,15 +2122,39 @@ def main(argv=None):
762
2122
  warn_stale_plans(warnings, plans, args.stale_days)
763
2123
 
764
2124
  if args.check_attestations:
765
- warn_attestation_drift(warnings, root, plans)
2125
+ attestation_reports = []
2126
+ warn_attestation_drift(
2127
+ warnings,
2128
+ root,
2129
+ plans,
2130
+ errors=errors,
2131
+ strict=args.strict_readiness,
2132
+ reports=attestation_reports,
2133
+ )
2134
+ else:
2135
+ attestation_reports = []
766
2136
 
767
2137
  attested_path = None
2138
+ if (args.attest_purpose or args.supersedes) and not args.attest:
2139
+ fail(errors, "--attest-purpose/--supersedes 必须与 --attest 一起使用")
768
2140
  if args.attest:
769
- attested_path = create_attestation(root, plan_map, plans, args.attest, errors)
2141
+ attested_path = create_attestation(
2142
+ root,
2143
+ plan_map,
2144
+ plans,
2145
+ args.attest,
2146
+ errors,
2147
+ purpose=args.attest_purpose,
2148
+ supersedes=args.supersedes or "",
2149
+ review_status=args.review_status,
2150
+ )
770
2151
 
771
2152
  for warning in warnings:
772
2153
  print(f"WARNING: {warning}")
773
2154
 
2155
+ for report in attestation_reports:
2156
+ print(f"ATTESTATION: {report}")
2157
+
774
2158
  if errors:
775
2159
  for error in errors:
776
2160
  print(f"ERROR: {error}")