plan-governance-cli 0.2.4 → 0.3.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.
- package/README.md +46 -3
- package/bin/plan-governance-cli.mjs +28 -0
- package/package.json +7 -3
- package/resources/manifest.json +2 -1
- package/resources/skill/SKILL.md +54 -5
- package/resources/skill/agents/openai.yaml +1 -1
- package/resources/skill/assets/PLAN_MAP.template.md +2 -0
- package/resources/skill/assets/plan.template.md +35 -0
- package/scripts/check_plan_governance.py +1445 -22
- package/scripts/graph_governance.mjs +988 -0
- package/scripts/init_plan_governance.py +1 -0
- package/scripts/plan_governance_hook.py +30 -1
|
@@ -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))
|
|
@@ -116,6 +123,32 @@ def key_value_table(section):
|
|
|
116
123
|
return values
|
|
117
124
|
|
|
118
125
|
|
|
126
|
+
def numbered_structural_heading(plan_text, heading_names):
|
|
127
|
+
"""查找误把阶段编号写入结构化章节标题的常见形式。"""
|
|
128
|
+
phase_number = r"[0-9一二三四五六七八九十百]+"
|
|
129
|
+
variants = []
|
|
130
|
+
for name in heading_names:
|
|
131
|
+
variants.append(rf"阶段\s*{phase_number}\s+{re.escape(name)}")
|
|
132
|
+
if name.startswith("阶段"):
|
|
133
|
+
variants.append(rf"阶段\s*{phase_number}\s*{re.escape(name[2:])}")
|
|
134
|
+
pattern = re.compile(
|
|
135
|
+
rf"^#+\s+((?:{'|'.join(variants)}))\s*$",
|
|
136
|
+
re.MULTILINE,
|
|
137
|
+
)
|
|
138
|
+
match = pattern.search(mask_fenced_code(plan_text))
|
|
139
|
+
return match.group(1) if match else None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def structural_heading_hint(plan_text, heading_name):
|
|
143
|
+
numbered_heading = numbered_structural_heading(plan_text, [heading_name])
|
|
144
|
+
if not numbered_heading:
|
|
145
|
+
return ""
|
|
146
|
+
return (
|
|
147
|
+
f";检测到标题 `{numbered_heading}`,请改为固定标题 "
|
|
148
|
+
f"`{heading_name}`,阶段编号以 `PLAN_MAP.md` 的当前阶段为准"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
119
152
|
def phase_roadmap_rows(plan_text):
|
|
120
153
|
section = markdown_section(plan_text, ["阶段路线图"])
|
|
121
154
|
return [row for row in markdown_table_rows(section) if row and row[0] != "阶段"]
|
|
@@ -157,36 +190,45 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
|
|
|
157
190
|
return
|
|
158
191
|
|
|
159
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)
|
|
160
196
|
roadmap = phase_roadmap_rows(plan_text)
|
|
161
197
|
matching_rows = [row for row in roadmap if row and row[0] == current_phase]
|
|
162
198
|
if not matching_rows:
|
|
199
|
+
roadmap_hint = structural_heading_hint(plan_text, "阶段路线图")
|
|
163
200
|
readiness_issue(
|
|
164
201
|
warnings,
|
|
165
202
|
errors,
|
|
166
203
|
strict,
|
|
167
|
-
f"{plan_name}: PLAN_MAP 当前阶段 {current_phase} 未在计划阶段路线图中找到",
|
|
204
|
+
f"{plan_name}: PLAN_MAP 当前阶段 {current_phase} 未在计划阶段路线图中找到{roadmap_hint}",
|
|
168
205
|
)
|
|
169
206
|
else:
|
|
170
207
|
roadmap_status = matching_rows[-1][4] if len(matching_rows[-1]) > 4 else ""
|
|
171
|
-
|
|
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:
|
|
172
211
|
readiness_issue(
|
|
173
212
|
warnings,
|
|
174
213
|
errors,
|
|
175
214
|
strict,
|
|
176
|
-
f"{plan_name}: 当前阶段 {current_phase} 的路线图状态 {roadmap_status}
|
|
215
|
+
f"{plan_name}: 当前阶段 {current_phase} 的路线图状态 {roadmap_status} 与阶段状态 {expected_roadmap_status} 不一致",
|
|
177
216
|
)
|
|
178
217
|
|
|
179
218
|
if markdown_section(plan_text, ["当前阶段"]) is None:
|
|
180
|
-
|
|
219
|
+
section_hint = structural_heading_hint(plan_text, "当前阶段")
|
|
220
|
+
readiness_issue(warnings, errors, strict, f"{plan_name}: 缺少 `## 当前阶段` 章节{section_hint}")
|
|
181
221
|
|
|
182
|
-
summary = key_value_table(markdown_section(plan_text, ["阶段准入摘要"]))
|
|
183
222
|
missing_fields = sorted(READINESS_FIELDS - set(summary))
|
|
184
223
|
if missing_fields:
|
|
224
|
+
title_hint = ""
|
|
225
|
+
if summary_section is None:
|
|
226
|
+
title_hint = structural_heading_hint(plan_text, "阶段准入摘要")
|
|
185
227
|
readiness_issue(
|
|
186
228
|
warnings,
|
|
187
229
|
errors,
|
|
188
230
|
strict,
|
|
189
|
-
f"{plan_name}: 阶段准入摘要缺少字段:{', '.join(missing_fields)}",
|
|
231
|
+
f"{plan_name}: 阶段准入摘要缺少字段:{', '.join(missing_fields)}{title_hint}",
|
|
190
232
|
)
|
|
191
233
|
for field in sorted(READINESS_FIELDS - {"当前阻塞项"}):
|
|
192
234
|
if field in summary and is_placeholder(summary[field]):
|
|
@@ -215,11 +257,12 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
|
|
|
215
257
|
review_fields = {"日期", "阶段", "结论", "证据", "复核者"}
|
|
216
258
|
missing_review = sorted(review_fields - set(review))
|
|
217
259
|
if missing_review:
|
|
260
|
+
review_hint = structural_heading_hint(plan_text, "最新独立准入复核")
|
|
218
261
|
readiness_issue(
|
|
219
262
|
warnings,
|
|
220
263
|
errors,
|
|
221
264
|
strict,
|
|
222
|
-
f"{plan_name}: 最新独立准入复核缺少字段:{', '.join(missing_review)}",
|
|
265
|
+
f"{plan_name}: 最新独立准入复核缺少字段:{', '.join(missing_review)}{review_hint}",
|
|
223
266
|
)
|
|
224
267
|
if review.get("日期"):
|
|
225
268
|
try:
|
|
@@ -241,9 +284,26 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
|
|
|
241
284
|
f"{plan_name}: 最新独立准入复核结论不是通过:{review['结论']}",
|
|
242
285
|
)
|
|
243
286
|
|
|
244
|
-
history = [
|
|
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
|
+
]
|
|
245
299
|
if not history:
|
|
246
|
-
|
|
300
|
+
history_hint = structural_heading_hint(plan_text, "独立复核记录")
|
|
301
|
+
readiness_issue(
|
|
302
|
+
warnings,
|
|
303
|
+
errors,
|
|
304
|
+
strict,
|
|
305
|
+
f"{plan_name}: 独立复核记录缺少当前阶段 {current_phase} 的记录{history_hint}",
|
|
306
|
+
)
|
|
247
307
|
else:
|
|
248
308
|
latest_history = history[-1]
|
|
249
309
|
if review.get("日期") and latest_history[0] != review["日期"]:
|
|
@@ -321,6 +381,34 @@ def extract_affected_targets(plan_text):
|
|
|
321
381
|
return targets
|
|
322
382
|
|
|
323
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
|
+
|
|
324
412
|
def extract_plan_references(plan_text, known_plans, current_name):
|
|
325
413
|
references = set()
|
|
326
414
|
for match in re.finditer(r"\[\[([^\]]+)\]\]", plan_text):
|
|
@@ -466,16 +554,112 @@ def changed_files(root, staged=False):
|
|
|
466
554
|
return files
|
|
467
555
|
|
|
468
556
|
|
|
469
|
-
def
|
|
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
|
+
):
|
|
470
621
|
if not files:
|
|
471
622
|
return
|
|
472
|
-
if not
|
|
623
|
+
if not plan_targets:
|
|
473
624
|
warn(warnings, f"{mode}: 存在变更文件,但没有活跃计划声明影响范围")
|
|
474
625
|
return
|
|
475
|
-
|
|
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:
|
|
476
640
|
warn(warnings, f"{mode}: 变更文件未被活跃计划影响范围覆盖:{changed_file}")
|
|
477
641
|
|
|
478
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
|
+
|
|
479
663
|
def warn_stale_plans(warnings, plans, stale_days, today=None):
|
|
480
664
|
today = today or date.today()
|
|
481
665
|
for name, data in plans.items():
|
|
@@ -501,7 +685,27 @@ def relative_to_root(path, root):
|
|
|
501
685
|
return path.relative_to(root).as_posix()
|
|
502
686
|
|
|
503
687
|
|
|
504
|
-
def
|
|
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
|
+
):
|
|
505
709
|
data = plans.get(plan_name)
|
|
506
710
|
if data is None:
|
|
507
711
|
fail(errors, f"{plan_name}: 未登记计划,无法创建完成快照")
|
|
@@ -522,17 +726,51 @@ def create_attestation(root, plan_map, plans, plan_name, errors):
|
|
|
522
726
|
"created_by": "plan-governance",
|
|
523
727
|
"reason": "阶段完成快照",
|
|
524
728
|
}
|
|
525
|
-
|
|
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
|
|
526
755
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
527
756
|
target.write_text(json.dumps(attestation, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
528
757
|
return target
|
|
529
758
|
|
|
530
759
|
|
|
531
|
-
def
|
|
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 []
|
|
532
769
|
attestations_dir = root / "docs" / "attestations"
|
|
533
770
|
if not attestations_dir.exists():
|
|
534
771
|
return
|
|
535
772
|
|
|
773
|
+
records = []
|
|
536
774
|
for path in sorted(attestations_dir.glob("*.json")):
|
|
537
775
|
try:
|
|
538
776
|
attestation = json.loads(path.read_text(encoding="utf-8"))
|
|
@@ -545,8 +783,52 @@ def warn_attestation_drift(warnings, root, plans):
|
|
|
545
783
|
warn(warnings, f"{path}: 快照引用了未登记计划:{plan_name or '<missing>'}")
|
|
546
784
|
continue
|
|
547
785
|
|
|
548
|
-
|
|
549
|
-
|
|
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
|
|
550
832
|
if not plan_path.exists():
|
|
551
833
|
warn(warnings, f"{path}: 快照引用的计划文件不存在:{plan_path}")
|
|
552
834
|
continue
|
|
@@ -554,10 +836,93 @@ def warn_attestation_drift(warnings, root, plans):
|
|
|
554
836
|
warn(warnings, f"{path}: 快照引用的 PLAN_MAP.md 不存在:{plan_map_path}")
|
|
555
837
|
continue
|
|
556
838
|
|
|
839
|
+
drifted = False
|
|
557
840
|
if sha256_file(plan_path) != attestation.get("plan_sha256"):
|
|
558
841
|
warn(warnings, f"{path}: {plan_name} 计划文件 hash 已变化,需要人工复核")
|
|
842
|
+
drifted = True
|
|
559
843
|
if sha256_file(plan_map_path) != attestation.get("plan_map_sha256"):
|
|
560
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
|
+
)
|
|
561
926
|
|
|
562
927
|
|
|
563
928
|
def detect_dependency_cycles(edges):
|
|
@@ -582,9 +947,968 @@ def detect_dependency_cycles(edges):
|
|
|
582
947
|
return cycles
|
|
583
948
|
|
|
584
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
|
+
|
|
585
1903
|
def parse_args(argv):
|
|
586
1904
|
parser = argparse.ArgumentParser(description="检查计划治理文档的一致性。")
|
|
587
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="工作集/步骤校验的仓库根目录。")
|
|
588
1912
|
parser.add_argument("--drift", action="store_true", help="检查工作区变更是否被活跃计划影响范围覆盖。")
|
|
589
1913
|
parser.add_argument("--pre-commit", action="store_true", help="检查 staged 变更是否被活跃计划影响范围覆盖。")
|
|
590
1914
|
parser.add_argument(
|
|
@@ -593,6 +1917,18 @@ def parse_args(argv):
|
|
|
593
1917
|
help="将待实施/实施中计划的阶段准入结构缺陷从 WARNING 提升为 ERROR。",
|
|
594
1918
|
)
|
|
595
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
|
+
)
|
|
596
1932
|
parser.add_argument("--check-attestations", action="store_true", help="检查完成快照 hash 是否漂移。")
|
|
597
1933
|
parser.add_argument(
|
|
598
1934
|
"--stale-days",
|
|
@@ -607,6 +1943,22 @@ def parse_args(argv):
|
|
|
607
1943
|
|
|
608
1944
|
def main(argv=None):
|
|
609
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)
|
|
610
1962
|
root = Path(args.root)
|
|
611
1963
|
docs = root / "docs"
|
|
612
1964
|
plan_map = docs / "PLAN_MAP.md"
|
|
@@ -654,6 +2006,13 @@ def main(argv=None):
|
|
|
654
2006
|
else:
|
|
655
2007
|
fail(errors, f"docs/PLAN_MAP.md: 计划行缺少 docs/plans 链接:{row[0]}")
|
|
656
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
|
+
|
|
657
2016
|
edges = {}
|
|
658
2017
|
inactive = {name for name, data in plans.items() if data["status"] in INACTIVE}
|
|
659
2018
|
for name, data in plans.items():
|
|
@@ -682,7 +2041,20 @@ def main(argv=None):
|
|
|
682
2041
|
if data["status"] in ACTIVE and has_current_blocker(plan_text):
|
|
683
2042
|
fail(errors, f"{data['path']}: 活跃计划仍有未解决的当前阶段阻塞项")
|
|
684
2043
|
if data["status"] in WARNING_ACTIVE:
|
|
685
|
-
|
|
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
|
|
686
2058
|
|
|
687
2059
|
check_phase_readiness(
|
|
688
2060
|
text,
|
|
@@ -709,10 +2081,37 @@ def main(argv=None):
|
|
|
709
2081
|
warn(warnings, f"{name}: PLAN_MAP.md 声明依赖 {unreferenced},但计划正文未引用")
|
|
710
2082
|
|
|
711
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)
|
|
712
2095
|
if args.drift:
|
|
713
|
-
warn_uncovered_changes(
|
|
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
|
+
)
|
|
714
2105
|
if args.pre_commit:
|
|
715
|
-
warn_uncovered_changes(
|
|
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
|
+
)
|
|
716
2115
|
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
|
717
2116
|
warn(warnings, f"Git 变更检查不可用:{exc}")
|
|
718
2117
|
|
|
@@ -723,15 +2122,39 @@ def main(argv=None):
|
|
|
723
2122
|
warn_stale_plans(warnings, plans, args.stale_days)
|
|
724
2123
|
|
|
725
2124
|
if args.check_attestations:
|
|
726
|
-
|
|
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 = []
|
|
727
2136
|
|
|
728
2137
|
attested_path = None
|
|
2138
|
+
if (args.attest_purpose or args.supersedes) and not args.attest:
|
|
2139
|
+
fail(errors, "--attest-purpose/--supersedes 必须与 --attest 一起使用")
|
|
729
2140
|
if args.attest:
|
|
730
|
-
attested_path = create_attestation(
|
|
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
|
+
)
|
|
731
2151
|
|
|
732
2152
|
for warning in warnings:
|
|
733
2153
|
print(f"WARNING: {warning}")
|
|
734
2154
|
|
|
2155
|
+
for report in attestation_reports:
|
|
2156
|
+
print(f"ATTESTATION: {report}")
|
|
2157
|
+
|
|
735
2158
|
if errors:
|
|
736
2159
|
for error in errors:
|
|
737
2160
|
print(f"ERROR: {error}")
|