plan-governance-cli 2.0.2 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -4
- package/bin/plan-governance-cli.mjs +58 -20
- package/package.json +1 -1
- package/resources/skill/SKILL.md +3 -1
- package/resources/skill/assets/PLAN_MAP.template.md +23 -12
- package/resources/skill/references/cli.md +24 -9
- package/resources/skill/references/planning.md +18 -0
- package/resources/skill/references/verification.md +2 -0
- package/scripts/check_plan_governance.py +500 -82
- package/scripts/graph_governance.mjs +67 -95
- package/scripts/init_plan_governance.py +80 -13
- package/scripts/plan_governance_hook.py +72 -27
|
@@ -600,9 +600,9 @@ def risk_review_state(plan_text, phase):
|
|
|
600
600
|
return result
|
|
601
601
|
|
|
602
602
|
|
|
603
|
-
def check_phase_structure(plan_name, data, plan_text, strict, warnings, errors, review_state=None):
|
|
603
|
+
def check_phase_structure(plan_name, data, plan_text, strict, warnings, errors, review_state=None, selected=False):
|
|
604
604
|
"""检查待实施/实施中计划的阶段准入结构,不判断业务证据真实性。"""
|
|
605
|
-
if data["status"] not in ACTIVE:
|
|
605
|
+
if data["status"] not in ACTIVE and not selected:
|
|
606
606
|
return
|
|
607
607
|
|
|
608
608
|
current_phase = data["phase"]
|
|
@@ -1452,6 +1452,8 @@ def create_attestation(
|
|
|
1452
1452
|
attest_files=None,
|
|
1453
1453
|
):
|
|
1454
1454
|
binding = None
|
|
1455
|
+
if errors:
|
|
1456
|
+
return None
|
|
1455
1457
|
if attest_files is not None:
|
|
1456
1458
|
if errors:
|
|
1457
1459
|
return None
|
|
@@ -1567,7 +1569,7 @@ def attestation_issue(warnings, errors, strict, message):
|
|
|
1567
1569
|
|
|
1568
1570
|
|
|
1569
1571
|
def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, reports=None,
|
|
1570
|
-
pending=None, enforce_binding_review=True):
|
|
1572
|
+
pending=None, enforce_binding_review=True, selected=None):
|
|
1571
1573
|
errors = errors if errors is not None else []
|
|
1572
1574
|
attestations_dir = root / "docs" / "attestations"
|
|
1573
1575
|
try:
|
|
@@ -1599,6 +1601,8 @@ def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, rep
|
|
|
1599
1601
|
bound = "binding" in attestation
|
|
1600
1602
|
invalid_structure = False
|
|
1601
1603
|
plan_name = str(attestation.get("plan", "")).strip()
|
|
1604
|
+
if selected and plan_name in plans and plan_name not in selected:
|
|
1605
|
+
continue
|
|
1602
1606
|
if not plan_name or plan_name not in plans:
|
|
1603
1607
|
message = f"{path}: 快照引用了未登记计划:{plan_name or '<missing>'}"
|
|
1604
1608
|
if not bound:
|
|
@@ -1898,6 +1902,10 @@ def plan_index_issues(plan_map_text):
|
|
|
1898
1902
|
status = row[1].strip("` ")
|
|
1899
1903
|
if status not in VALID_STATUSES:
|
|
1900
1904
|
issues.append(f"{name}: 计划状态非法:{status}")
|
|
1905
|
+
try:
|
|
1906
|
+
parse_plan_date(row[3].strip("` "))
|
|
1907
|
+
except ValueError:
|
|
1908
|
+
issues.append(f"{name}: 最后更新日期不合法:{row[3]}")
|
|
1901
1909
|
return issues
|
|
1902
1910
|
|
|
1903
1911
|
|
|
@@ -1992,12 +2000,12 @@ def structural_ambiguities(plan_text, phase):
|
|
|
1992
2000
|
return issues
|
|
1993
2001
|
|
|
1994
2002
|
|
|
1995
|
-
def phase_gate(plan_map_text, plan_name, data, plan_text):
|
|
2003
|
+
def phase_gate(plan_map_text, plan_name, data, plan_text, selected=False):
|
|
1996
2004
|
"""共享只读派生;严重级别由 check/workset/hook 的入口契约决定。"""
|
|
1997
2005
|
issues = structural_ambiguities(plan_text, data["phase"])
|
|
1998
2006
|
warnings = []
|
|
1999
2007
|
review_state = risk_review_state(plan_text, data["phase"])
|
|
2000
|
-
check_phase_structure(plan_name, data, plan_text, True, warnings, issues, review_state)
|
|
2008
|
+
check_phase_structure(plan_name, data, plan_text, True, warnings, issues, review_state, selected=selected)
|
|
2001
2009
|
if review_state["enabled"]:
|
|
2002
2010
|
issues.extend(review_state["issues"] + review_state["blockers"])
|
|
2003
2011
|
if data["status"] in ACTIVE and is_placeholder(data["phase"]):
|
|
@@ -2008,17 +2016,28 @@ def phase_gate(plan_map_text, plan_name, data, plan_text):
|
|
|
2008
2016
|
indexed = mapped.get(plan_name, [])
|
|
2009
2017
|
summary = current_summary(plan_text)
|
|
2010
2018
|
value = summary.get("当前阻塞项", "").strip()
|
|
2011
|
-
declared = [value] if value and not is_placeholder(value) else []
|
|
2019
|
+
declared = [value] if value and not is_placeholder(value) and not re.match(r"^无(?:[;;,,。]|$)", value) else []
|
|
2012
2020
|
blockers = list(dict.fromkeys(details + indexed + declared))
|
|
2021
|
+
issue_codes = {}
|
|
2013
2022
|
if blockers:
|
|
2014
|
-
|
|
2023
|
+
message = "当前阶段存在未解决阻塞项:" + ";".join(blockers)
|
|
2024
|
+
issues.append(message)
|
|
2025
|
+
issue_codes[message] = "CURRENT_PHASE_BLOCKED"
|
|
2015
2026
|
if ((details or indexed) and value == "无") or ((details or declared) and not indexed):
|
|
2016
2027
|
issues.append("当前阻塞来源需同步:PLAN_MAP 索引、未决问题或阶段准入摘要缺失/冲突")
|
|
2017
2028
|
if review_state["enabled"]:
|
|
2018
2029
|
blockers.extend(review_state["blockers"])
|
|
2019
2030
|
else:
|
|
2020
2031
|
legacy_state = legacy_review_state(plan_text, data["phase"])
|
|
2021
|
-
|
|
2032
|
+
history = [r for r in review_history_rows(plan_text) if len(r) == 6 and r[2] == data["phase"]]
|
|
2033
|
+
completion_positions = [i for i, r in enumerate(history)
|
|
2034
|
+
if "完成" in r[1] and review_outcome(r[3]) == "passed"
|
|
2035
|
+
and valid_review_date(r[0]) is not None
|
|
2036
|
+
and not any(is_placeholder(v) for v in r)]
|
|
2037
|
+
completed_review = (data["status"] in COMPLETED and completion_positions
|
|
2038
|
+
and all(review_outcome(r[3]) == "passed" for r in history[completion_positions[-1]:]))
|
|
2039
|
+
if not completed_review:
|
|
2040
|
+
blockers.extend(legacy_state["blockers"])
|
|
2022
2041
|
review = key_value_table(fixed_section(plan_text, "最新独立准入复核"))
|
|
2023
2042
|
conclusion = review.get("结论", "").strip()
|
|
2024
2043
|
if review.get("阶段") == data["phase"] and conclusion.startswith(("未通过", "不通过", "失败", "不满足", "拒绝")):
|
|
@@ -2029,17 +2048,26 @@ def phase_gate(plan_map_text, plan_name, data, plan_text):
|
|
|
2029
2048
|
warnings.extend(recent_warnings)
|
|
2030
2049
|
issues = [message.removeprefix(f"{plan_name}: ") for message in issues]
|
|
2031
2050
|
return {"blockers": list(dict.fromkeys(blockers)), "issues": list(dict.fromkeys(issues)),
|
|
2032
|
-
"warnings": warnings, "review": review_state}
|
|
2051
|
+
"warnings": warnings, "review": review_state, "issue_codes": issue_codes}
|
|
2033
2052
|
|
|
2034
2053
|
|
|
2035
|
-
def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, warnings, errors):
|
|
2054
|
+
def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, warnings, errors, selected=False):
|
|
2055
|
+
if selected and data["status"] in INACTIVE:
|
|
2056
|
+
readiness_issue(warnings, errors, strict, f"{plan_name}: 已废弃/替代/合并的计划不能准入")
|
|
2057
|
+
return
|
|
2036
2058
|
if data["status"] not in WARNING_ACTIVE:
|
|
2037
2059
|
return
|
|
2038
|
-
gate = phase_gate(plan_map_text, plan_name, data, plan_text)
|
|
2060
|
+
gate = phase_gate(plan_map_text, plan_name, data, plan_text, selected=selected)
|
|
2039
2061
|
warnings.extend(f"{plan_name}: {message}" for message in gate["warnings"])
|
|
2040
2062
|
for message in gate["issues"]:
|
|
2063
|
+
for target in (errors, warnings):
|
|
2064
|
+
if isinstance(target, DiagnosticList):
|
|
2065
|
+
sources = target.metadata["sources"]
|
|
2066
|
+
target.context(plan_name, data["phase"], category="readiness",
|
|
2067
|
+
code=gate["issue_codes"].get(message, "PHASE_NOT_READY"),
|
|
2068
|
+
path=sources[0]["path"] if sources else data["path"], section="当前阶段")
|
|
2041
2069
|
# 缺失材料的设计计划仍合法;其显式冲突和阻塞保持可见而不强制准入。
|
|
2042
|
-
readiness_issue(warnings, errors, strict and data["status"] in ACTIVE, f"{plan_name}: {message}")
|
|
2070
|
+
readiness_issue(warnings, errors, strict and (data["status"] in ACTIVE or selected), f"{plan_name}: {message}")
|
|
2043
2071
|
|
|
2044
2072
|
|
|
2045
2073
|
def top_level_section(text, heading):
|
|
@@ -2343,10 +2371,244 @@ def recent_evidence(plan_text, phase=None):
|
|
|
2343
2371
|
return current_recent_evidence(plan_text, phase)[0]
|
|
2344
2372
|
|
|
2345
2373
|
|
|
2346
|
-
|
|
2374
|
+
class UsageError(ValueError):
|
|
2375
|
+
pass
|
|
2376
|
+
|
|
2377
|
+
|
|
2378
|
+
def validate_options(parser, argv, repeatable=()):
|
|
2379
|
+
"""Argparse handles values/types; reject ambiguous duplicate declarations."""
|
|
2380
|
+
seen = set()
|
|
2381
|
+
for token in argv:
|
|
2382
|
+
if token == "--":
|
|
2383
|
+
break
|
|
2384
|
+
option = token.split("=", 1)[0]
|
|
2385
|
+
action = parser._option_string_actions.get(option)
|
|
2386
|
+
if action is None or action.dest in repeatable:
|
|
2387
|
+
continue
|
|
2388
|
+
if action.dest in seen:
|
|
2389
|
+
parser.error(f"{option} 只能指定一次")
|
|
2390
|
+
seen.add(action.dest)
|
|
2391
|
+
|
|
2392
|
+
|
|
2393
|
+
def nonempty(value):
|
|
2394
|
+
if not value.strip():
|
|
2395
|
+
raise argparse.ArgumentTypeError("参数值不能为空")
|
|
2396
|
+
return value
|
|
2397
|
+
|
|
2398
|
+
|
|
2399
|
+
def scoped_table(text, title, keep):
|
|
2400
|
+
section = top_level_section(text, title)
|
|
2401
|
+
if section is None:
|
|
2402
|
+
return text
|
|
2403
|
+
rows = markdown_table_rows(section)
|
|
2404
|
+
selected = [row for i, row in enumerate(rows) if i == 0 or keep(row)]
|
|
2405
|
+
replacement = "\n\n" + "\n".join("| " + " | ".join(row) + " |" for row in selected) + "\n\n"
|
|
2406
|
+
start = re.search(rf"^##\s+{re.escape(title)}\s*$", mask_fenced_code(text), re.MULTILINE).end()
|
|
2407
|
+
return text[:start] + replacement + text[start + len(section):]
|
|
2408
|
+
|
|
2409
|
+
|
|
2410
|
+
def scoped_map(text, names):
|
|
2411
|
+
for title in ("阶段关系", "机器可检查共享写入约束"):
|
|
2412
|
+
text = scoped_table(text, title, lambda row: len(row) < 3 or bool({clean_relation_value(row[0]), clean_relation_value(row[2])} & names))
|
|
2413
|
+
return text
|
|
2414
|
+
|
|
2415
|
+
|
|
2416
|
+
def historical_phase_review(text, phase):
|
|
2417
|
+
"""复用历史校验,不使用后续阶段的最新结果替代来源阶段。"""
|
|
2418
|
+
for title, width in (("阶段复核记录", 8), ("独立复核记录", 6)):
|
|
2419
|
+
rows = markdown_table_rows(fixed_section(text, title))
|
|
2420
|
+
expected = (["日期", "类型", "阶段", "方式", "风险", "结论", "证据", "复核者"]
|
|
2421
|
+
if width == 8 else ["日期", "类型", "阶段", "结论", "证据", "复核者"])
|
|
2422
|
+
relevant = [r for r in rows[1:] if len(r) < 3 or r[2] == phase or not valid_phase_name(r[2])]
|
|
2423
|
+
if relevant and (rows[0] != expected or rows.count(expected) > 1
|
|
2424
|
+
or any(len(r) != width or len(r) < 3 or not valid_phase_name(r[2]) for r in relevant)):
|
|
2425
|
+
return {"passed": False, "issues": [f"来源阶段 {phase} 的 {title} 结构无效"], "blockers": []}
|
|
2426
|
+
explicit = [r for r in markdown_table_rows(fixed_section(text, "阶段复核记录")) if len(r) == 8 and r[2] == phase]
|
|
2427
|
+
legacy = [r for r in review_history_rows(text) if len(r) == 6 and r[2] == phase]
|
|
2428
|
+
if explicit:
|
|
2429
|
+
row = explicit[-1]
|
|
2430
|
+
latest = dict(zip(["日期", "阶段", "方式", "风险", "结论", "证据", "复核者"], [row[0], row[2], *row[3:]]))
|
|
2431
|
+
latest["风险依据"] = "来源阶段历史证据"
|
|
2432
|
+
synthetic = "## 当前阶段\n\n### 阶段准入摘要\n\n| 字段 | 内容 |\n|---|---|\n| 复核策略 | 默认自验 |\n| 最新阶段复核 | #最新阶段复核 |\n\n## 最新阶段复核\n\n| 字段 | 内容 |\n|---|---|\n"
|
|
2433
|
+
synthetic += "\n".join(f"| {key} | {value} |" for key, value in latest.items())
|
|
2434
|
+
synthetic += "\n\n## 阶段复核记录\n\n| 日期 | 类型 | 阶段 | 方式 | 风险 | 结论 | 证据 | 复核者 |\n|---|---|---|---|---|---|---|---|\n"
|
|
2435
|
+
synthetic += "\n".join("| " + " | ".join(r) + " |" for r in explicit)
|
|
2436
|
+
return risk_review_state(synthetic, phase)
|
|
2437
|
+
if legacy:
|
|
2438
|
+
row = legacy[-1]
|
|
2439
|
+
synthetic = "## 最新独立准入复核\n\n| 字段 | 内容 |\n|---|---|\n"
|
|
2440
|
+
synthetic += "\n".join(f"| {key} | {value} |" for key, value in zip(["日期", "阶段", "结论", "证据", "复核者"], [row[0], *row[2:]]))
|
|
2441
|
+
synthetic += "\n\n## 独立复核记录\n\n| 日期 | 类型 | 阶段 | 结论 | 证据 | 复核者 |\n|---|---|---|---|---|---|\n"
|
|
2442
|
+
synthetic += "\n".join("| " + " | ".join(r) + " |" for r in legacy)
|
|
2443
|
+
return legacy_review_state(synthetic, phase)
|
|
2444
|
+
return {"passed": False, "issues": [f"来源阶段 {phase} 缺少有效复核历史"], "blockers": []}
|
|
2445
|
+
|
|
2446
|
+
|
|
2447
|
+
def build_scope(root, requested, text, plans):
|
|
2448
|
+
if len(requested) != len(set(requested)):
|
|
2449
|
+
raise UsageError("--plan 不接受重复计划 ID")
|
|
2450
|
+
unknown = set(requested) - set(plans)
|
|
2451
|
+
if unknown:
|
|
2452
|
+
raise UsageError("未登记的 --plan:" + "、".join(sorted(unknown)))
|
|
2453
|
+
scope = {"requested_plans": requested, "focused_phases": [{"plan": n, "phase": plans[n]["phase"]} for n in requested],
|
|
2454
|
+
"evaluated_plans": list(requested), "dependencies": [], "evidence_links": [], "shared_writes": [], "global_diagnostics": []}
|
|
2455
|
+
relation_header, relations = relation_rows(text)
|
|
2456
|
+
shared_header, shared = shared_write_rows(text)
|
|
2457
|
+
relations = [[clean_relation_value(cell) for cell in row] for row in relations]
|
|
2458
|
+
shared = [[clean_relation_value(cell) for cell in row] for row in shared]
|
|
2459
|
+
if relation_header == [] or shared_header == []:
|
|
2460
|
+
scope["global_diagnostics"].append("相关关系表缺少固定表头,无法可信确认作用域")
|
|
2461
|
+
queue = [(name, plans[name]["phase"]) for name in requested]
|
|
2462
|
+
visited = set()
|
|
2463
|
+
edges = {}
|
|
2464
|
+
for name, phase in queue:
|
|
2465
|
+
if (name, phase) in visited:
|
|
2466
|
+
continue
|
|
2467
|
+
visited.add((name, phase))
|
|
2468
|
+
# 已完成前置由完成证据核验,不重新开启其已经关闭的历史依赖链。
|
|
2469
|
+
if plans[name]["status"] in COMPLETED:
|
|
2470
|
+
continue
|
|
2471
|
+
incoming = [r for r in relations if len(r) == 7 and clean_relation_value(r[2]) == name and clean_relation_value(r[3]) == phase and r[4] == "hard_gate"]
|
|
2472
|
+
predecessors = [(clean_relation_value(r[0]), clean_relation_value(r[1]), r[5], r[6]) for r in incoming]
|
|
2473
|
+
explicit = {row[0] for row in predecessors}
|
|
2474
|
+
for dep in extract_declared_dependencies(plans[name]["depends"]) if phase == plans[name]["phase"] else []:
|
|
2475
|
+
if dep not in explicit:
|
|
2476
|
+
predecessors.append((dep, plans.get(dep, {}).get("phase", ""), "计划索引依赖", "PLAN_MAP.md"))
|
|
2477
|
+
for dep, source_phase, unlock, evidence in predecessors:
|
|
2478
|
+
if dep not in plans:
|
|
2479
|
+
scope["global_diagnostics"].append(f"{name}: 必要前置不存在:{dep}")
|
|
2480
|
+
continue
|
|
2481
|
+
edges.setdefault(f"{name}@{phase}", []).append(f"{dep}@{source_phase}")
|
|
2482
|
+
record = {"plan": dep, "phase": source_phase, "required_by": name, "reason": unlock, "evidence": evidence}
|
|
2483
|
+
if record not in scope["dependencies"]:
|
|
2484
|
+
scope["dependencies"].append(record)
|
|
2485
|
+
queue.append((dep, source_phase))
|
|
2486
|
+
for cycle in detect_dependency_cycles(edges):
|
|
2487
|
+
scope["global_diagnostics"].append(f"相关阶段依赖存在环:{cycle}")
|
|
2488
|
+
names = {name for name, _ in visited}
|
|
2489
|
+
scope["evaluated_plans"] = sorted(names)
|
|
2490
|
+
filtered = scoped_map(text, names)
|
|
2491
|
+
for row in relations:
|
|
2492
|
+
if len(row) == 7 and {row[0], row[2]} & names and row[4] in {"evidence", "soft_context"}:
|
|
2493
|
+
scope["evidence_links"].append({"plan": row[0], "phase": row[1], "required_by": row[2], "relation": row[4], "evidence": row[6]})
|
|
2494
|
+
for row in shared:
|
|
2495
|
+
if len(row) == 9 and {row[0], row[2]} & names:
|
|
2496
|
+
scope["shared_writes"].append({"plans": [row[0], row[2]], "targets": row[5], "order": row[6], "reason": row[7], "evidence": row[8]})
|
|
2497
|
+
# 同伴仅检查范围声明,不投影其业务状态。
|
|
2498
|
+
affected = {}
|
|
2499
|
+
for name, data in plans.items():
|
|
2500
|
+
if name in names or data["status"] in WARNING_ACTIVE:
|
|
2501
|
+
try:
|
|
2502
|
+
affected[name] = extract_affected_targets(data["path"].read_text(encoding="utf-8"))
|
|
2503
|
+
except (OSError, UnicodeError):
|
|
2504
|
+
affected[name] = []
|
|
2505
|
+
for name in requested:
|
|
2506
|
+
for peer, targets in affected.items():
|
|
2507
|
+
if peer in requested:
|
|
2508
|
+
continue
|
|
2509
|
+
overlap = sorted({a for a in affected.get(name, []) for b in targets if a == b or a.startswith(b.rstrip("/") + "/") or b.startswith(a.rstrip("/") + "/")})
|
|
2510
|
+
if overlap:
|
|
2511
|
+
scope["shared_writes"].append({"plans": [name, peer], "targets": overlap, "reason": "影响范围重叠;需要单一写入或串行合并"})
|
|
2512
|
+
projection = {"index": [r for r in table_rows(text, "计划索引") if r and (link := extract_plan_link(r[0])) and Path(link).stem in names],
|
|
2513
|
+
"relations": [r for r in relations if len(r) < 3 or {r[0], r[2]} & names],
|
|
2514
|
+
"shared": scope["shared_writes"], "headers": [relation_header, shared_header],
|
|
2515
|
+
"scope_issues": scope["global_diagnostics"], "identity_issues": plan_index_issues(text),
|
|
2516
|
+
"blockers": {k: v for k, v in map_blockers(text)[0].items() if k in names}, "blocker_issues": map_blockers(text)[1]}
|
|
2517
|
+
contents = {}
|
|
2518
|
+
for name in sorted(names):
|
|
2519
|
+
try:
|
|
2520
|
+
contents[name] = sha256_file(plans[name]["path"])
|
|
2521
|
+
except OSError:
|
|
2522
|
+
contents[name] = None
|
|
2523
|
+
scope["facts_version"] = hashlib.sha256(json.dumps([projection, contents], ensure_ascii=False, sort_keys=True).encode()).hexdigest()
|
|
2524
|
+
return scope, filtered
|
|
2525
|
+
|
|
2526
|
+
|
|
2527
|
+
def dependency_diagnostics(root, scope, text, plans):
|
|
2528
|
+
errors = list(scope["global_diagnostics"])
|
|
2529
|
+
for dep in scope["dependencies"]:
|
|
2530
|
+
name, phase = dep["plan"], dep["phase"]
|
|
2531
|
+
data = plans[name]
|
|
2532
|
+
read_errors = []
|
|
2533
|
+
body = read_utf8(data["path"], read_errors)
|
|
2534
|
+
reasons = list(read_errors)
|
|
2535
|
+
if data["status"] in INACTIVE:
|
|
2536
|
+
reasons.append("必要前置已废弃/替代,须确认有效承接")
|
|
2537
|
+
elif phase != data["phase"]:
|
|
2538
|
+
review = historical_phase_review(body, phase)
|
|
2539
|
+
reasons.extend(review["issues"] + review["blockers"])
|
|
2540
|
+
if not review["passed"]:
|
|
2541
|
+
reasons.append(f"来源阶段 {phase} 未通过")
|
|
2542
|
+
elif data["status"] in COMPLETED:
|
|
2543
|
+
if not has_completion_evidence(body) or not has_coverage_evidence(body):
|
|
2544
|
+
reasons.append("已完成前置缺少完成/验证证据")
|
|
2545
|
+
reasons.extend(phase_gate(text, name, data, body)["blockers"])
|
|
2546
|
+
else:
|
|
2547
|
+
gate = phase_gate(text, name, data, body)
|
|
2548
|
+
reasons.extend(gate["issues"])
|
|
2549
|
+
review = gate["review"] if gate["review"]["enabled"] else legacy_review_state(body, phase)
|
|
2550
|
+
if not review["passed"]:
|
|
2551
|
+
reasons.append("必要前置当前阶段尚未通过")
|
|
2552
|
+
dep["phase_passed"] = not reasons
|
|
2553
|
+
dep["blockers"] = list(dict.fromkeys(reasons))
|
|
2554
|
+
errors.extend(f"{name}@{phase}: {reason}" for reason in dep["blockers"])
|
|
2555
|
+
return list(dict.fromkeys(errors))
|
|
2556
|
+
|
|
2557
|
+
|
|
2558
|
+
def scope_unchanged(root, scope):
|
|
2559
|
+
errors = []
|
|
2560
|
+
text, _, plans = load_plan_records(root, errors)
|
|
2561
|
+
if errors or plan_index_issues(text):
|
|
2562
|
+
return False
|
|
2563
|
+
try:
|
|
2564
|
+
fresh, _ = build_scope(root, scope["requested_plans"], text, plans)
|
|
2565
|
+
return fresh["facts_version"] == scope["facts_version"]
|
|
2566
|
+
except (UsageError, OSError, ValueError):
|
|
2567
|
+
return False
|
|
2568
|
+
|
|
2569
|
+
|
|
2570
|
+
def workset_payload(root, include_history=False, strict=False, requested=None):
|
|
2571
|
+
if not requested:
|
|
2572
|
+
return unscoped_workset_payload(root, include_history, strict)
|
|
2573
|
+
errors = []
|
|
2574
|
+
text, _, plans = load_plan_records(root, errors)
|
|
2575
|
+
errors.extend(plan_index_issues(text))
|
|
2576
|
+
empty = {"schema_version": 1, "source": "derived", "plans": [], "warnings": errors,
|
|
2577
|
+
"scope": {"requested_plans": requested, "evaluated_plans": [], "dependencies": []}}
|
|
2578
|
+
if errors:
|
|
2579
|
+
return empty, 1
|
|
2580
|
+
scope, selected_map = build_scope(root, requested, text, plans)
|
|
2581
|
+
output, status = unscoped_workset_payload(root, include_history, strict, focus=requested, supplied=(selected_map, plans))
|
|
2582
|
+
dep_errors = dependency_diagnostics(root, scope, selected_map, plans)
|
|
2583
|
+
output["warnings"].extend(dep_errors)
|
|
2584
|
+
output["scope"] = scope
|
|
2585
|
+
blocked = {d["required_by"] for d in scope["dependencies"] if not d["phase_passed"]}
|
|
2586
|
+
while True:
|
|
2587
|
+
expanded = blocked | {d["required_by"] for d in scope["dependencies"] if d["plan"] in blocked}
|
|
2588
|
+
if expanded == blocked:
|
|
2589
|
+
break
|
|
2590
|
+
blocked = expanded
|
|
2591
|
+
for item in output["plans"]:
|
|
2592
|
+
if item["plan"] in blocked or scope["global_diagnostics"]:
|
|
2593
|
+
item["readiness"] = "blocked"
|
|
2594
|
+
item["next_action"] = {"state": "known", "kind": "resolve_blocker", "reason": "必要前置尚未满足,见 scope.dependencies"}
|
|
2595
|
+
if not scope_unchanged(root, scope):
|
|
2596
|
+
output["warnings"].append("相关事实在读取期间发生变化;请重读聚焦工作集")
|
|
2597
|
+
for item in output["plans"]:
|
|
2598
|
+
item["readiness"] = "unknown"
|
|
2599
|
+
item["next_action"] = {"state": "unknown", "kind": "unknown", "reason": "相关事实已变更"}
|
|
2600
|
+
status = 1 if strict else status
|
|
2601
|
+
return output, 1 if scope["global_diagnostics"] or (strict and dep_errors) else status
|
|
2602
|
+
|
|
2603
|
+
|
|
2604
|
+
def unscoped_workset_payload(root, include_history=False, strict=False, focus=None, supplied=None):
|
|
2347
2605
|
errors = []
|
|
2348
2606
|
strict_failures = []
|
|
2349
|
-
|
|
2607
|
+
document_failures = []
|
|
2608
|
+
if supplied:
|
|
2609
|
+
plan_map_text, plans = supplied
|
|
2610
|
+
else:
|
|
2611
|
+
plan_map_text, _, plans = load_plan_records(root, errors)
|
|
2350
2612
|
output = {
|
|
2351
2613
|
"schema_version": 1,
|
|
2352
2614
|
"source": "derived",
|
|
@@ -2368,20 +2630,23 @@ def workset_payload(root, include_history=False, strict=False):
|
|
|
2368
2630
|
strict_failures.extend(relation_errors)
|
|
2369
2631
|
|
|
2370
2632
|
for name, data in plans.items():
|
|
2371
|
-
if
|
|
2633
|
+
if focus and name not in focus:
|
|
2634
|
+
continue
|
|
2635
|
+
if not focus and not include_history and data["status"] not in WARNING_ACTIVE:
|
|
2372
2636
|
continue
|
|
2373
2637
|
path = data["path"]
|
|
2374
2638
|
if not path.exists():
|
|
2375
2639
|
message = f"{name}: 计划文件不存在:{path}"
|
|
2376
2640
|
output["warnings"].append(message)
|
|
2377
2641
|
strict_failures.append(message)
|
|
2642
|
+
document_failures.append(message)
|
|
2378
2643
|
continue
|
|
2379
2644
|
plan_text = read_utf8(path, errors)
|
|
2380
2645
|
summary = current_summary(plan_text)
|
|
2381
|
-
gate = phase_gate(plan_map_text, name, data, plan_text)
|
|
2646
|
+
gate = phase_gate(plan_map_text, name, data, plan_text, selected=bool(focus) and strict and data["status"] in WARNING_ACTIVE)
|
|
2382
2647
|
blockers = gate["blockers"]
|
|
2383
2648
|
output["warnings"].extend(f"{name}: {message}" for message in gate["issues"] + gate["warnings"])
|
|
2384
|
-
if data["status"] in ACTIVE:
|
|
2649
|
+
if data["status"] in ACTIVE or focus:
|
|
2385
2650
|
strict_failures.extend(gate["issues"])
|
|
2386
2651
|
uncertain = gate["issues"] or blocker_index_issues or relation_errors
|
|
2387
2652
|
if blockers:
|
|
@@ -2436,6 +2701,17 @@ def workset_payload(root, include_history=False, strict=False):
|
|
|
2436
2701
|
else:
|
|
2437
2702
|
readiness = "design"
|
|
2438
2703
|
action = {"state": "unknown", "kind": "unknown", "reason": "缺少结构化下一动作"}
|
|
2704
|
+
elif focus and data["status"] in COMPLETED:
|
|
2705
|
+
valid = has_completion_evidence(plan_text) and has_coverage_evidence(plan_text) and not blockers
|
|
2706
|
+
readiness = "unknown"
|
|
2707
|
+
action = {"state": "unknown", "kind": "unknown", "reason": "当前完成声明已检查;终态无下一实施动作" if valid else "完成声明缺少有效证据"}
|
|
2708
|
+
if not valid:
|
|
2709
|
+
strict_failures.append(f"{name}: 完成声明缺少有效证据")
|
|
2710
|
+
document_failures.append(f"{name}: 完成声明缺少有效证据")
|
|
2711
|
+
elif focus and data["status"] in INACTIVE:
|
|
2712
|
+
readiness = "unknown"
|
|
2713
|
+
action = {"state": "unknown", "kind": "unknown", "reason": "终态计划不能准入"}
|
|
2714
|
+
strict_failures.append(f"{name}: 终态计划不能准入")
|
|
2439
2715
|
else:
|
|
2440
2716
|
readiness = "unknown"
|
|
2441
2717
|
action = {"state": "unknown", "kind": "unknown", "reason": "计划状态不在工作集派生范围"}
|
|
@@ -2460,7 +2736,7 @@ def workset_payload(root, include_history=False, strict=False):
|
|
|
2460
2736
|
output["plans"] = []
|
|
2461
2737
|
output["warnings"].extend(index_issues)
|
|
2462
2738
|
strict_failures.extend(index_issues)
|
|
2463
|
-
return output, 1 if strict and strict_failures else 0
|
|
2739
|
+
return output, 1 if errors or index_issues or document_failures or (strict and strict_failures) else 0
|
|
2464
2740
|
|
|
2465
2741
|
|
|
2466
2742
|
def print_workset_text(payload, strict=False):
|
|
@@ -2519,26 +2795,91 @@ def limit_workset_evidence(root, payload, limit):
|
|
|
2519
2795
|
return result
|
|
2520
2796
|
|
|
2521
2797
|
|
|
2522
|
-
def run_workset(root, as_json=False, include_history=False, strict=False, evidence_limit=None):
|
|
2523
|
-
payload, status = workset_payload(root, include_history=include_history, strict=strict)
|
|
2798
|
+
def run_workset(root, as_json=False, include_history=False, strict=False, evidence_limit=None, requested=None):
|
|
2799
|
+
payload, status = workset_payload(root, include_history=include_history, strict=strict, requested=requested)
|
|
2524
2800
|
if as_json:
|
|
2525
2801
|
if evidence_limit is not None:
|
|
2526
2802
|
payload = limit_workset_evidence(root, payload, evidence_limit)
|
|
2803
|
+
scope = payload.get("scope")
|
|
2804
|
+
if scope and scope.get("facts_version") and not scope_unchanged(root, scope):
|
|
2805
|
+
payload["warnings"].append("证据窗口读取期间相关事实发生变化;请重读聚焦工作集")
|
|
2806
|
+
for item in payload["plans"]:
|
|
2807
|
+
item["readiness"] = "unknown"
|
|
2808
|
+
item["next_action"] = {"state": "unknown", "kind": "unknown", "reason": "相关事实已变更"}
|
|
2809
|
+
status = 1 if strict else status
|
|
2527
2810
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
2528
2811
|
else:
|
|
2529
2812
|
print_workset_text(payload, strict=strict)
|
|
2530
2813
|
return status
|
|
2531
2814
|
|
|
2532
2815
|
|
|
2816
|
+
class DiagnosticList(list):
|
|
2817
|
+
def __init__(self, records, severity):
|
|
2818
|
+
super().__init__()
|
|
2819
|
+
self.records = records
|
|
2820
|
+
self.severity = severity
|
|
2821
|
+
self.context()
|
|
2822
|
+
|
|
2823
|
+
def context(self, plan=None, phase=None, category="document", code=None, path=None, section=None):
|
|
2824
|
+
self.metadata = {"plan": plan, "phase": phase, "category": category,
|
|
2825
|
+
"code": code or ("DOCUMENT_ERROR" if category == "document" else "PHASE_NOT_READY"),
|
|
2826
|
+
"sources": [{"path": str(path), "section": section}] if path else []}
|
|
2827
|
+
|
|
2828
|
+
def append(self, value):
|
|
2829
|
+
super().append(value)
|
|
2830
|
+
self.records.append({**self.metadata, "severity": self.severity, "message": value})
|
|
2831
|
+
|
|
2832
|
+
def extend(self, values):
|
|
2833
|
+
for value in values:
|
|
2834
|
+
self.append(value)
|
|
2835
|
+
|
|
2836
|
+
|
|
2837
|
+
def finish_check(args, records, scope=None, reports=None, attested_path=None, not_applicable=False):
|
|
2838
|
+
unique = {}
|
|
2839
|
+
for item in records:
|
|
2840
|
+
key = (item["plan"], item["phase"], item["code"], item["message"])
|
|
2841
|
+
if key not in unique or item["severity"] == "error":
|
|
2842
|
+
unique[key] = item
|
|
2843
|
+
diagnostics = list(unique.values())
|
|
2844
|
+
counts = {level + "s": sum(item["severity"] == level for item in diagnostics) for level in ("error", "warning")}
|
|
2845
|
+
status = "failed" if counts["errors"] else "not_applicable" if not_applicable else "warning" if counts["warnings"] else "passed"
|
|
2846
|
+
code = 1 if counts["errors"] else 0
|
|
2847
|
+
result = {"schema_version": 1, "command": "check", "mode": "readiness" if args.strict_readiness else "consistency",
|
|
2848
|
+
"status": status, "exit_code": code, "scope": scope or {"requested_plans": [], "evaluated_plans": getattr(args, "evaluated_plans", []), "dependencies": []},
|
|
2849
|
+
"summary": counts, "diagnostics": diagnostics}
|
|
2850
|
+
if reports:
|
|
2851
|
+
result["attestations"] = reports
|
|
2852
|
+
if attested_path:
|
|
2853
|
+
result["created_attestation"] = str(attested_path)
|
|
2854
|
+
if args.json:
|
|
2855
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
2856
|
+
else:
|
|
2857
|
+
for item in diagnostics:
|
|
2858
|
+
print(f"{item['severity'].upper()}: {item['message']}", file=sys.stderr if item["severity"] == "error" else sys.stdout)
|
|
2859
|
+
for report in reports or []:
|
|
2860
|
+
print(f"ATTESTATION: {report}")
|
|
2861
|
+
if attested_path:
|
|
2862
|
+
print(f"已创建完成快照:{attested_path}")
|
|
2863
|
+
if not code:
|
|
2864
|
+
if not_applicable:
|
|
2865
|
+
print("未执行:当前目录未启用计划治理。")
|
|
2866
|
+
elif counts["warnings"]:
|
|
2867
|
+
print(f"{'准入' if args.strict_readiness else '文档'}检查完成,有 {counts['warnings']} 项告警。")
|
|
2868
|
+
else:
|
|
2869
|
+
print("准入检查通过。" if args.strict_readiness else "计划治理检查通过(文档一致性)。")
|
|
2870
|
+
return code
|
|
2871
|
+
|
|
2872
|
+
|
|
2533
2873
|
def parse_args(argv):
|
|
2534
|
-
parser = argparse.ArgumentParser(description="
|
|
2535
|
-
parser.add_argument("root", nargs="?", default=
|
|
2874
|
+
parser = argparse.ArgumentParser(description="检查计划治理文档的一致性;--strict-readiness 用于准入。", allow_abbrev=False)
|
|
2875
|
+
parser.add_argument("root", nargs="?", type=nonempty, default=None, help="仓库根目录,默认当前目录。")
|
|
2536
2876
|
parser.add_argument("--workset", action="store_true", help="输出只读当前工作集。")
|
|
2537
2877
|
parser.add_argument("--json", action="store_true", help="以 JSON 输出工作集。")
|
|
2538
2878
|
parser.add_argument("--include-history", action="store_true", help="工作集包含历史计划。")
|
|
2539
2879
|
parser.add_argument("--evidence-limit", type=int, metavar="N",
|
|
2540
2880
|
help="仅工作集 JSON:每个计划显示末尾 N 条记录(正整数),省略时返回全部。")
|
|
2541
|
-
parser.add_argument("--root", dest="mode_root", help="
|
|
2881
|
+
parser.add_argument("--root", dest="mode_root", type=nonempty, help="仓库根目录,与 positional root 二选一。")
|
|
2882
|
+
parser.add_argument("--plan", action="append", type=nonempty, default=[], help="可重复:聚焦已登记计划 ID,各会话独立传入。")
|
|
2542
2883
|
parser.add_argument("--drift", action="store_true", help="检查工作区变更是否被活跃计划影响范围覆盖。")
|
|
2543
2884
|
parser.add_argument("--pre-commit", action="store_true", help="检查 staged 变更是否被活跃计划影响范围覆盖。")
|
|
2544
2885
|
parser.add_argument(
|
|
@@ -2546,15 +2887,15 @@ def parse_args(argv):
|
|
|
2546
2887
|
action="store_true",
|
|
2547
2888
|
help="将待实施/实施中计划的阶段准入结构缺陷从 WARNING 提升为 ERROR。",
|
|
2548
2889
|
)
|
|
2549
|
-
parser.add_argument("--attest", metavar="PLAN", help="为已登记计划创建或覆盖完成快照。")
|
|
2550
|
-
parser.add_argument("--attest-file", action="append", metavar="PATH",
|
|
2890
|
+
parser.add_argument("--attest", type=nonempty, metavar="PLAN", help="为已登记计划创建或覆盖完成快照。")
|
|
2891
|
+
parser.add_argument("--attest-file", type=nonempty, action="append", metavar="PATH",
|
|
2551
2892
|
help="可重复:与 --attest/--attest-purpose 显式绑定被审查的普通文件。")
|
|
2552
2893
|
parser.add_argument(
|
|
2553
2894
|
"--attest-purpose",
|
|
2554
2895
|
choices=sorted(ATTESTATION_PURPOSES),
|
|
2555
2896
|
help="创建带 purpose/snapshot_id 关系的完成快照;不传时保持旧 <plan>.json 格式。",
|
|
2556
2897
|
)
|
|
2557
|
-
parser.add_argument("--supersedes", help="新 attestation 要替代的仓库内相对快照路径。")
|
|
2898
|
+
parser.add_argument("--supersedes", type=nonempty, help="新 attestation 要替代的仓库内相对快照路径。")
|
|
2558
2899
|
parser.add_argument(
|
|
2559
2900
|
"--review-status",
|
|
2560
2901
|
choices=sorted(ATTESTATION_REVIEW_STATUSES),
|
|
@@ -2570,7 +2911,30 @@ def parse_args(argv):
|
|
|
2570
2911
|
default=None,
|
|
2571
2912
|
help="检查活跃计划是否超过 N 天未更新;省略 N 时默认 10 天。",
|
|
2572
2913
|
)
|
|
2914
|
+
validate_options(parser, argv, {"plan", "attest_file"})
|
|
2573
2915
|
args = parser.parse_args(argv)
|
|
2916
|
+
if args.mode_root is not None and args.root is not None:
|
|
2917
|
+
parser.error("root 与 --root 不能同时使用")
|
|
2918
|
+
args.root = args.mode_root or args.root or "."
|
|
2919
|
+
if len(args.plan) != len(set(args.plan)):
|
|
2920
|
+
parser.error("--plan 不接受重复 ID")
|
|
2921
|
+
if args.workset:
|
|
2922
|
+
if any((args.drift, args.pre_commit, args.stale_days is not None, args.attest, args.attest_file is not None, args.attest_purpose, args.supersedes, args.check_attestations)):
|
|
2923
|
+
parser.error("workset 不接受 check 的检查/快照参数")
|
|
2924
|
+
elif args.include_history:
|
|
2925
|
+
parser.error("--include-history 仅适用于 workset")
|
|
2926
|
+
if args.attest and any((args.plan, args.drift, args.pre_commit, args.workset)):
|
|
2927
|
+
parser.error("--attest 不能与 workset/plan/drift/pre-commit 混用")
|
|
2928
|
+
if args.attest_file is not None and (not args.attest or not args.attest_purpose):
|
|
2929
|
+
parser.error("--attest-file 必须与 --attest/--attest-purpose 一起使用")
|
|
2930
|
+
if (args.attest_purpose or args.supersedes) and not args.attest:
|
|
2931
|
+
parser.error("--attest-purpose/--supersedes 必须与 --attest 一起使用")
|
|
2932
|
+
if any(token.split("=", 1)[0] == "--review-status" for token in argv) and not args.attest_purpose:
|
|
2933
|
+
parser.error("--review-status 必须与 --attest/--attest-purpose 一起使用")
|
|
2934
|
+
if args.supersedes and not args.attest_purpose:
|
|
2935
|
+
parser.error("--supersedes 必须与 --attest-purpose 一起使用")
|
|
2936
|
+
if args.stale_days is not None and args.stale_days < 0:
|
|
2937
|
+
parser.error("--stale-days 必须是非负整数")
|
|
2574
2938
|
if args.evidence_limit is not None:
|
|
2575
2939
|
if args.evidence_limit <= 0:
|
|
2576
2940
|
parser.error("--evidence-limit 必须为正整数")
|
|
@@ -2584,63 +2948,61 @@ def main(argv=None):
|
|
|
2584
2948
|
if args.attest_file is not None and (not args.attest or not args.attest_purpose or args.workset):
|
|
2585
2949
|
print("ERROR: --attest-file 必须与 --attest/--attest-purpose 一起使用,不能用于 workset")
|
|
2586
2950
|
return 1
|
|
2951
|
+
root = Path(args.root).expanduser().resolve()
|
|
2587
2952
|
if args.workset:
|
|
2953
|
+
if not root.is_dir():
|
|
2954
|
+
payload = {"schema_version": 1, "source": "derived", "plans": [], "warnings": [f"root 不存在或不是目录:{root}"]}
|
|
2955
|
+
print(json.dumps(payload, ensure_ascii=False) if args.json else payload["warnings"][0])
|
|
2956
|
+
return 1
|
|
2588
2957
|
return run_workset(
|
|
2589
|
-
|
|
2958
|
+
root,
|
|
2590
2959
|
as_json=args.json,
|
|
2591
2960
|
include_history=args.include_history,
|
|
2592
2961
|
strict=args.strict_readiness,
|
|
2593
2962
|
evidence_limit=args.evidence_limit,
|
|
2963
|
+
requested=args.plan,
|
|
2594
2964
|
)
|
|
2595
|
-
root = Path(args.root)
|
|
2596
2965
|
docs = root / "docs"
|
|
2597
2966
|
plan_map = docs / "PLAN_MAP.md"
|
|
2598
|
-
|
|
2599
|
-
|
|
2967
|
+
records = []
|
|
2968
|
+
errors = DiagnosticList(records, "error")
|
|
2969
|
+
warnings = DiagnosticList(records, "warning")
|
|
2970
|
+
scope = None
|
|
2971
|
+
if not root.is_dir():
|
|
2972
|
+
errors.context(category="runtime", code="INVALID_ROOT")
|
|
2973
|
+
errors.append(f"root 不存在或不是目录:{root}")
|
|
2974
|
+
return finish_check(args, records)
|
|
2600
2975
|
|
|
2601
2976
|
try:
|
|
2602
2977
|
bound_input = args.attest_file is not None or (args.check_attestations and has_attestation_binding(root))
|
|
2603
2978
|
except (ValueError, OSError) as exc:
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
return 1 if args.strict_readiness else 0
|
|
2979
|
+
attestation_issue(warnings, errors, args.strict_readiness, f"快照清单无法读取:{exc}")
|
|
2980
|
+
return finish_check(args, records)
|
|
2607
2981
|
unsafe_map = False
|
|
2608
2982
|
if bound_input:
|
|
2609
2983
|
try:
|
|
2610
2984
|
binding_file(root, "docs/PLAN_MAP.md")
|
|
2611
2985
|
except (ValueError, OSError) as exc:
|
|
2612
2986
|
if args.attest_file is not None:
|
|
2613
|
-
|
|
2614
|
-
return
|
|
2987
|
+
errors.append(f"binding 创建预检失败:{exc}")
|
|
2988
|
+
return finish_check(args, records)
|
|
2615
2989
|
unsafe_map = True
|
|
2616
2990
|
if unsafe_map:
|
|
2617
2991
|
reports = []
|
|
2618
2992
|
warn_attestation_drift(warnings, root, {}, errors=errors, strict=args.strict_readiness, reports=reports)
|
|
2619
|
-
|
|
2620
|
-
print(f"WARNING: {warning}")
|
|
2621
|
-
for report in reports:
|
|
2622
|
-
print(f"ATTESTATION: {report}")
|
|
2623
|
-
for error in errors:
|
|
2624
|
-
print(f"ERROR: {error}")
|
|
2625
|
-
return 1 if errors else 0
|
|
2993
|
+
return finish_check(args, records, reports=reports)
|
|
2626
2994
|
if not plan_map.exists():
|
|
2995
|
+
reports = []
|
|
2627
2996
|
if args.check_attestations:
|
|
2628
|
-
reports = []
|
|
2629
2997
|
warn_attestation_drift(warnings, root, {}, errors=errors, strict=args.strict_readiness, reports=reports)
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
print(f"WARNING: {warning}")
|
|
2633
|
-
for report in reports:
|
|
2634
|
-
print(f"ATTESTATION: {report}")
|
|
2635
|
-
for error in errors:
|
|
2636
|
-
print(f"ERROR: {error}")
|
|
2637
|
-
return 1 if errors else 0
|
|
2638
|
-
print("未找到 docs/PLAN_MAP.md;当前仓库尚未初始化计划治理。")
|
|
2639
|
-
return 0
|
|
2998
|
+
readiness_issue(warnings, errors, args.strict_readiness or bool(args.plan), "未找到 docs/PLAN_MAP.md;当前仓库尚未初始化计划治理")
|
|
2999
|
+
return finish_check(args, records, reports=reports, not_applicable=not errors)
|
|
2640
3000
|
|
|
2641
3001
|
text = read_utf8(plan_map, errors)
|
|
2642
3002
|
_, blocker_index_issues = map_blockers(text)
|
|
2643
|
-
for issue in plan_index_issues(text)
|
|
3003
|
+
for issue in plan_index_issues(text):
|
|
3004
|
+
fail(errors, issue)
|
|
3005
|
+
for issue in blocker_index_issues:
|
|
2644
3006
|
readiness_issue(warnings, errors, args.strict_readiness, issue)
|
|
2645
3007
|
plan_rows = table_rows(text, "计划索引")
|
|
2646
3008
|
if not plan_rows:
|
|
@@ -2674,7 +3036,7 @@ def main(argv=None):
|
|
|
2674
3036
|
attestation_issue(warnings, errors, args.attest_file is not None or args.strict_readiness,
|
|
2675
3037
|
f"{name}: binding 计划读取预检失败:{exc}")
|
|
2676
3038
|
unsafe_plans.add(name)
|
|
2677
|
-
if not path.exists():
|
|
3039
|
+
if not path.exists() and (not args.plan or name in args.plan):
|
|
2678
3040
|
fail(errors, f"docs/PLAN_MAP.md: 引用的计划文件不存在:{link}")
|
|
2679
3041
|
plans[name] = {
|
|
2680
3042
|
"path": path,
|
|
@@ -2686,6 +3048,12 @@ def main(argv=None):
|
|
|
2686
3048
|
else:
|
|
2687
3049
|
fail(errors, f"docs/PLAN_MAP.md: 计划行缺少 docs/plans 链接:{row[0]}")
|
|
2688
3050
|
|
|
3051
|
+
if args.plan and not errors:
|
|
3052
|
+
scope, text = build_scope(root, args.plan, text, plans)
|
|
3053
|
+
elif args.plan:
|
|
3054
|
+
return finish_check(args, records)
|
|
3055
|
+
|
|
3056
|
+
args.evaluated_plans = list(plans)
|
|
2689
3057
|
readable_plans = {name: data for name, data in plans.items() if name not in unsafe_plans}
|
|
2690
3058
|
relation_errors, relation_warnings = validate_relation_graph(text, readable_plans)
|
|
2691
3059
|
warnings.extend(relation_warnings)
|
|
@@ -2698,31 +3066,38 @@ def main(argv=None):
|
|
|
2698
3066
|
inactive = {name for name, data in plans.items() if data["status"] in INACTIVE}
|
|
2699
3067
|
for name, data in plans.items():
|
|
2700
3068
|
deps = extract_declared_dependencies(data["depends"])
|
|
3069
|
+
if not scope:
|
|
3070
|
+
for missing in set(deps) - set(plans):
|
|
3071
|
+
errors.append(f"{name}: 声明依赖的计划不存在:{missing}")
|
|
2701
3072
|
edges[name] = [dep for dep in deps if dep in plans]
|
|
2702
|
-
if data["status"] in IMPLEMENTING:
|
|
3073
|
+
if data["status"] in IMPLEMENTING and (not scope or name in scope["evaluated_plans"]):
|
|
2703
3074
|
for dep in edges[name]:
|
|
2704
3075
|
if dep in inactive:
|
|
2705
3076
|
fail(errors, f"{name}: 实施中计划依赖了非活跃计划 {dep}")
|
|
2706
3077
|
|
|
2707
3078
|
for cycle in detect_dependency_cycles(edges):
|
|
2708
|
-
|
|
3079
|
+
if not scope:
|
|
3080
|
+
fail(errors, f"计划依赖存在环:{cycle}")
|
|
2709
3081
|
|
|
2710
|
-
|
|
2711
|
-
|
|
3082
|
+
if not scope:
|
|
3083
|
+
for orphan in find_orphan_plans(docs, plans):
|
|
3084
|
+
warn(warnings, f"{orphan}: docs/plans 中存在未登记到 PLAN_MAP.md 的孤立计划")
|
|
2712
3085
|
|
|
2713
3086
|
active_plan_targets = {}
|
|
2714
3087
|
plan_texts = {}
|
|
2715
3088
|
for name, data in plans.items():
|
|
2716
|
-
if name in unsafe_plans:
|
|
3089
|
+
if name in unsafe_plans or (scope and name not in args.plan):
|
|
2717
3090
|
continue
|
|
3091
|
+
for target in (errors, warnings):
|
|
3092
|
+
target.context(name, data["phase"], path=data["path"].relative_to(root))
|
|
2718
3093
|
plan_text = read_utf8(data["path"], errors)
|
|
2719
3094
|
plan_texts[name] = plan_text
|
|
2720
3095
|
if data["status"] in COMPLETED and not has_completion_evidence(plan_text):
|
|
2721
3096
|
fail(errors, f"{data['path']}: 已完成计划缺少有效 Step 0 证据或验证方式")
|
|
2722
3097
|
if data["status"] in COMPLETED and not has_coverage_evidence(plan_text):
|
|
2723
3098
|
fail(errors, f"{data['path']}: 已完成计划缺少测试覆盖率证据")
|
|
2724
|
-
if data["status"] in
|
|
2725
|
-
fail(errors, f"{data['path']}:
|
|
3099
|
+
if data["status"] in COMPLETED and phase_gate(text, name, data, plan_text)["blockers"]:
|
|
3100
|
+
fail(errors, f"{data['path']}: 已完成计划仍有当前阶段阻塞/失败")
|
|
2726
3101
|
if data["status"] in WARNING_ACTIVE:
|
|
2727
3102
|
targets = extract_affected_targets(plan_text)
|
|
2728
3103
|
plan_relative = data["path"].relative_to(root).as_posix()
|
|
@@ -2739,6 +3114,8 @@ def main(argv=None):
|
|
|
2739
3114
|
)
|
|
2740
3115
|
active_plan_targets[name] = targets
|
|
2741
3116
|
|
|
3117
|
+
for target in (errors, warnings):
|
|
3118
|
+
target.context(name, data["phase"], category="readiness", path=data["path"].relative_to(root), section="当前阶段")
|
|
2742
3119
|
check_phase_readiness(
|
|
2743
3120
|
text,
|
|
2744
3121
|
name,
|
|
@@ -2747,15 +3124,19 @@ def main(argv=None):
|
|
|
2747
3124
|
args.strict_readiness,
|
|
2748
3125
|
warnings,
|
|
2749
3126
|
errors,
|
|
3127
|
+
selected=bool(scope),
|
|
2750
3128
|
)
|
|
2751
3129
|
|
|
3130
|
+
for target in (errors, warnings):
|
|
3131
|
+
target.context(category="advisory", code="SHARED_WRITE_RISK")
|
|
2752
3132
|
for target, plan_names in detect_overlapping_targets(active_plan_targets).items():
|
|
2753
3133
|
warn(warnings, f"{target}: 多个活跃计划声明相同影响目标:{', '.join(plan_names)}")
|
|
2754
3134
|
|
|
2755
3135
|
known_plans = set(plans)
|
|
2756
3136
|
for name, data in plans.items():
|
|
2757
|
-
if data["status"] not in WARNING_ACTIVE:
|
|
3137
|
+
if data["status"] not in WARNING_ACTIVE or (scope and name not in args.plan):
|
|
2758
3138
|
continue
|
|
3139
|
+
warnings.context(name, data["phase"], category="advisory", code="DEPENDENCY_REFERENCE", path=data["path"].relative_to(root))
|
|
2759
3140
|
declared = set(edges.get(name, []))
|
|
2760
3141
|
referenced = extract_plan_references(plan_texts.get(name, ""), known_plans, name)
|
|
2761
3142
|
for missing in sorted(referenced - declared):
|
|
@@ -2763,9 +3144,31 @@ def main(argv=None):
|
|
|
2763
3144
|
for unreferenced in sorted(declared - referenced):
|
|
2764
3145
|
warn(warnings, f"{name}: PLAN_MAP.md 声明依赖 {unreferenced},但计划正文未引用")
|
|
2765
3146
|
|
|
3147
|
+
for target in (errors, warnings):
|
|
3148
|
+
target.context(category="advisory", code="OPTIONAL_CHECK")
|
|
2766
3149
|
try:
|
|
2767
3150
|
drift_files = changed_files(root) if args.drift else set()
|
|
2768
3151
|
pre_commit_files = changed_files(root, staged=True) if args.pre_commit else set()
|
|
3152
|
+
if scope:
|
|
3153
|
+
peer_targets = {}
|
|
3154
|
+
for peer, data in plans.items():
|
|
3155
|
+
if peer not in args.plan and data["path"].is_file():
|
|
3156
|
+
body = data["path"].read_text(encoding="utf-8")
|
|
3157
|
+
peer_targets[peer] = extract_affected_targets(body) + [data["path"].relative_to(root).as_posix()]
|
|
3158
|
+
def relevant_changes(files, staged):
|
|
3159
|
+
selected = set()
|
|
3160
|
+
for file in files:
|
|
3161
|
+
own = any(target_matches_path(t, file) for targets in active_plan_targets.values() for t in targets)
|
|
3162
|
+
peers = any(target_matches_path(t, file) for targets in peer_targets.values() for t in targets)
|
|
3163
|
+
if file == "docs/PLAN_MAP.md":
|
|
3164
|
+
owners = plan_map_change_owners(root, staged, set(plans))
|
|
3165
|
+
if owners and all(owner and not set(owner) & set(args.plan) for owner in owners):
|
|
3166
|
+
continue
|
|
3167
|
+
if own or not peers:
|
|
3168
|
+
selected.add(file)
|
|
3169
|
+
return selected
|
|
3170
|
+
drift_files = relevant_changes(drift_files, False)
|
|
3171
|
+
pre_commit_files = relevant_changes(pre_commit_files, True)
|
|
2769
3172
|
completion_window_targets = completed_plan_drift_targets(
|
|
2770
3173
|
plans,
|
|
2771
3174
|
plan_texts,
|
|
@@ -2802,8 +3205,16 @@ def main(argv=None):
|
|
|
2802
3205
|
if args.stale_days < 0:
|
|
2803
3206
|
fail(errors, f"--stale-days 必须是非负整数:{args.stale_days}")
|
|
2804
3207
|
else:
|
|
2805
|
-
warn_stale_plans(warnings, plans, args.stale_days)
|
|
2806
|
-
|
|
3208
|
+
warn_stale_plans(warnings, {n: d for n, d in plans.items() if not scope or n in args.plan}, args.stale_days)
|
|
3209
|
+
|
|
3210
|
+
if scope:
|
|
3211
|
+
errors.context(category="document", code="SCOPE_STRUCTURE_INVALID")
|
|
3212
|
+
errors.extend(scope["global_diagnostics"])
|
|
3213
|
+
warnings.context(category="readiness", code="DEPENDENCY_BLOCKED")
|
|
3214
|
+
errors.context(category="readiness", code="DEPENDENCY_BLOCKED")
|
|
3215
|
+
for message in dependency_diagnostics(root, scope, text, plans):
|
|
3216
|
+
if message not in scope["global_diagnostics"]:
|
|
3217
|
+
readiness_issue(warnings, errors, args.strict_readiness, message)
|
|
2807
3218
|
if args.check_attestations:
|
|
2808
3219
|
attestation_reports = []
|
|
2809
3220
|
warn_attestation_drift(
|
|
@@ -2813,6 +3224,7 @@ def main(argv=None):
|
|
|
2813
3224
|
errors=errors,
|
|
2814
3225
|
strict=args.strict_readiness,
|
|
2815
3226
|
reports=attestation_reports,
|
|
3227
|
+
selected=scope["evaluated_plans"] if scope else None,
|
|
2816
3228
|
)
|
|
2817
3229
|
else:
|
|
2818
3230
|
attestation_reports = []
|
|
@@ -2821,6 +3233,16 @@ def main(argv=None):
|
|
|
2821
3233
|
if (args.attest_purpose or args.supersedes) and not args.attest:
|
|
2822
3234
|
fail(errors, "--attest-purpose/--supersedes 必须与 --attest 一起使用")
|
|
2823
3235
|
if args.attest:
|
|
3236
|
+
target = plans.get(args.attest)
|
|
3237
|
+
if target and not errors and args.attest_purpose != "compliance":
|
|
3238
|
+
body = read_utf8(target["path"], errors)
|
|
3239
|
+
if target["status"] in INACTIVE:
|
|
3240
|
+
errors.append("终态目标不能创建完成/发布门快照")
|
|
3241
|
+
elif target["status"] not in COMPLETED:
|
|
3242
|
+
gate = phase_gate(text, args.attest, target, body, selected=True)
|
|
3243
|
+
errors.extend(gate["issues"])
|
|
3244
|
+
elif phase_gate(text, args.attest, target, body)["blockers"]:
|
|
3245
|
+
fail(errors, "完成快照目标仍有未解决阻塞")
|
|
2824
3246
|
attested_path = create_attestation(
|
|
2825
3247
|
root,
|
|
2826
3248
|
plan_map,
|
|
@@ -2833,23 +3255,19 @@ def main(argv=None):
|
|
|
2833
3255
|
attest_files=args.attest_file,
|
|
2834
3256
|
)
|
|
2835
3257
|
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
if errors:
|
|
2843
|
-
for error in errors:
|
|
2844
|
-
print(f"ERROR: {error}")
|
|
2845
|
-
return 1
|
|
2846
|
-
|
|
2847
|
-
if attested_path is not None:
|
|
2848
|
-
print(f"已创建完成快照:{attested_path}")
|
|
2849
|
-
|
|
2850
|
-
print("计划治理检查通过。")
|
|
2851
|
-
return 0
|
|
3258
|
+
if scope and not scope_unchanged(root, scope):
|
|
3259
|
+
errors.context(category="readiness", code="FACTS_CHANGED")
|
|
3260
|
+
warnings.context(category="readiness", code="FACTS_CHANGED")
|
|
3261
|
+
readiness_issue(warnings, errors, args.strict_readiness, "相关事实在读取期间发生变化;请重新检查")
|
|
3262
|
+
return finish_check(args, records, scope=scope, reports=attestation_reports, attested_path=attested_path)
|
|
2852
3263
|
|
|
2853
3264
|
|
|
2854
3265
|
if __name__ == "__main__":
|
|
2855
|
-
|
|
3266
|
+
try:
|
|
3267
|
+
raise SystemExit(main())
|
|
3268
|
+
except UsageError as exc:
|
|
3269
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
3270
|
+
raise SystemExit(2)
|
|
3271
|
+
except (OSError, ValueError) as exc:
|
|
3272
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
3273
|
+
raise SystemExit(1)
|