plan-governance-cli 0.1.1 → 0.2.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.
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import re
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ ACTIVE_STATUSES = {"候选", "设计中", "待实施", "实施中"}
10
+ PLACEHOLDER_VALUES = {"-", "待补充", "待补充。", "待确认", "无", "N/A"}
11
+
12
+
13
+ def read_text(path):
14
+ try:
15
+ return path.read_text(encoding="utf-8")
16
+ except FileNotFoundError:
17
+ return ""
18
+
19
+
20
+ def table_rows(text, heading):
21
+ pattern = re.compile(rf"^##\s+{re.escape(heading)}\s*$", re.MULTILINE)
22
+ match = pattern.search(text)
23
+ if not match:
24
+ return []
25
+ tail = text[match.end():]
26
+ next_heading = re.search(r"^##\s+", tail, re.MULTILINE)
27
+ section = tail[: next_heading.start()] if next_heading else tail
28
+ rows = []
29
+ for line in section.splitlines():
30
+ stripped = line.strip()
31
+ if not stripped.startswith("|") or "---" in stripped:
32
+ continue
33
+ cells = [cell.strip() for cell in stripped.strip("|").split("|")]
34
+ if cells and cells[0] not in {"计划", "问题"}:
35
+ rows.append(cells)
36
+ return rows
37
+
38
+
39
+ def extract_plan_link(cell):
40
+ match = re.search(r"\((plans/[^)]+\.md)\)", cell)
41
+ if match:
42
+ return match.group(1)
43
+ if cell.endswith(".md") and cell.startswith("plans/"):
44
+ return cell
45
+ return None
46
+
47
+
48
+ def extract_plan_name(cell, link):
49
+ if link:
50
+ return Path(link).stem
51
+ label = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", cell)
52
+ return label.strip("` ")
53
+
54
+
55
+ def markdown_section(text, heading_names):
56
+ heading_pattern = "|".join(re.escape(name) for name in heading_names)
57
+ pattern = re.compile(rf"^#+\s+({heading_pattern})\b.*$", re.MULTILINE)
58
+ match = pattern.search(text)
59
+ if not match:
60
+ return None
61
+ tail = text[match.end():]
62
+ next_heading = re.search(r"^#+\s+", tail, re.MULTILINE)
63
+ return tail[: next_heading.start()] if next_heading else tail
64
+
65
+
66
+ def markdown_list_items(section):
67
+ if section is None:
68
+ return []
69
+ items = []
70
+ for line in section.splitlines():
71
+ match = re.match(r"\s*[-*]\s+(.+?)\s*$", line)
72
+ if not match:
73
+ continue
74
+ item = match.group(1).strip().strip("` ")
75
+ if item and item not in PLACEHOLDER_VALUES:
76
+ items.append(item)
77
+ return items
78
+
79
+
80
+ def normalize_scope_path(value):
81
+ normalized = value.strip().strip("`").strip()
82
+ normalized = re.sub(r"/+", "/", normalized)
83
+ while normalized.startswith("./"):
84
+ normalized = normalized[2:]
85
+ normalized = normalized.strip("/")
86
+ return normalized
87
+
88
+
89
+ def extract_scope_token(item):
90
+ backtick = re.search(r"`([^`]+)`", item)
91
+ if backtick:
92
+ return normalize_scope_path(backtick.group(1))
93
+ token = item.strip().split(None, 1)[0] if item.strip() else ""
94
+ return normalize_scope_path(token.rstrip("::"))
95
+
96
+
97
+ def load_plans(root):
98
+ plan_map_path = root / "docs" / "PLAN_MAP.md"
99
+ text = read_text(plan_map_path)
100
+ plans = []
101
+ for row in table_rows(text, "计划索引"):
102
+ if len(row) < 6:
103
+ continue
104
+ link = extract_plan_link(row[0])
105
+ name = extract_plan_name(row[0], link)
106
+ plan_path = root / "docs" / link if link else None
107
+ plans.append(
108
+ {
109
+ "name": name,
110
+ "path": plan_path,
111
+ "status": row[1].strip("` "),
112
+ "phase": row[2].strip("` "),
113
+ "last_updated": row[3].strip("` "),
114
+ "evidence": row[5].strip(),
115
+ "targets": [],
116
+ }
117
+ )
118
+ for plan in plans:
119
+ if plan["path"] is None:
120
+ continue
121
+ plan_text = read_text(plan["path"])
122
+ section = markdown_section(plan_text, ["影响模块或文件"])
123
+ plan["targets"] = [
124
+ target
125
+ for item in markdown_list_items(section)
126
+ if (target := extract_scope_token(item))
127
+ and target not in PLACEHOLDER_VALUES
128
+ ]
129
+ return plans, table_rows(text, "当前阻塞项")
130
+
131
+
132
+ def active_plans(root):
133
+ plans, blockers = load_plans(root)
134
+ return [plan for plan in plans if plan["status"] in ACTIVE_STATUSES], blockers
135
+
136
+
137
+ def target_matches_path(target, path):
138
+ normalized_target = normalize_scope_path(target)
139
+ normalized_path = normalize_scope_path(path)
140
+ if not normalized_target or not normalized_path:
141
+ return False
142
+ if normalized_target == normalized_path:
143
+ return True
144
+ return normalized_path.startswith(f"{normalized_target.rstrip('/')}/")
145
+
146
+
147
+ def matching_plans(root, paths):
148
+ active, _ = active_plans(root)
149
+ matches = []
150
+ for plan in active:
151
+ if any(
152
+ target_matches_path(target, path)
153
+ for target in plan["targets"]
154
+ for path in paths
155
+ ):
156
+ matches.append(plan)
157
+ return matches
158
+
159
+
160
+ def print_session_start(root):
161
+ active, blockers = active_plans(root)
162
+ if not active:
163
+ print("[plan-governance] 未发现活跃计划。")
164
+ return 0
165
+
166
+ print("[plan-governance] 活跃计划摘要:")
167
+ for plan in active:
168
+ print(
169
+ f"- {plan['name']}: {plan['status']},{plan['phase']},"
170
+ f"最后更新 {plan['last_updated']},证据 {plan['evidence']}"
171
+ )
172
+ open_blockers = [
173
+ row for row in blockers if len(row) >= 5 and row[3].strip() in {"是", "Yes"}
174
+ ]
175
+ if open_blockers:
176
+ print("[plan-governance] 当前阻塞项:")
177
+ for row in open_blockers:
178
+ print(f"- {row[0]}: {row[4]}")
179
+ else:
180
+ print("[plan-governance] 当前阻塞项:无。")
181
+ return 0
182
+
183
+
184
+ def print_pre_write(root, paths):
185
+ matches = matching_plans(root, paths)
186
+ if not matches:
187
+ print("[plan-governance] 未匹配到相关活跃计划。")
188
+ return 0
189
+
190
+ print("[plan-governance] 写入前检查:")
191
+ for plan in matches:
192
+ print(f"- {plan['name']}: {plan['status']},{plan['phase']}")
193
+ print(" 当前阶段门禁:确认 Step 0 证据、验证方式、完成条件和公共契约约束。")
194
+ return 0
195
+
196
+
197
+ def is_plan_map(path):
198
+ return normalize_scope_path(path) == "docs/PLAN_MAP.md"
199
+
200
+
201
+ def is_plan_doc(path):
202
+ normalized = normalize_scope_path(path)
203
+ return normalized.startswith("docs/plans/") and normalized.endswith(".md")
204
+
205
+
206
+ def print_post_write(paths):
207
+ if any(is_plan_map(path) for path in paths):
208
+ print(
209
+ "[plan-governance] 已写入 PLAN_MAP.md:请同步状态、当前阶段、"
210
+ "最后更新、证据和反向引用检查。"
211
+ )
212
+ if any(is_plan_doc(path) for path in paths):
213
+ print(
214
+ "[plan-governance] 已写入计划文档:请同步 PLAN_MAP.md、验证证据、"
215
+ "测试覆盖率和反向引用检查。"
216
+ )
217
+ if not any(is_plan_map(path) or is_plan_doc(path) for path in paths):
218
+ print("[plan-governance] 写入完成:如影响计划事实,请同步治理文档。")
219
+ return 0
220
+
221
+
222
+ def print_stop(root):
223
+ checker = root / "scripts" / "check_plan_governance.py"
224
+ if not checker.exists():
225
+ print("[plan-governance] 未找到 scripts/check_plan_governance.py,跳过停止前检查。")
226
+ return 0
227
+
228
+ result = subprocess.run(
229
+ [sys.executable, str(checker), "."],
230
+ cwd=root,
231
+ check=False,
232
+ capture_output=True,
233
+ text=True,
234
+ )
235
+ output = (result.stdout or result.stderr).strip()
236
+ if output:
237
+ print(output)
238
+ print("[plan-governance] Stop 检查为非阻塞提示;请人工复核 WARNING/ERROR。")
239
+ return 0
240
+
241
+
242
+ def parse_args(argv):
243
+ parser = argparse.ArgumentParser(description="计划治理 hook runtime。")
244
+ parser.add_argument("--root", default=".", help="仓库根目录,默认当前目录。")
245
+ parser.add_argument(
246
+ "--event",
247
+ required=True,
248
+ choices=["session-start", "pre-write", "post-write", "stop"],
249
+ help="hook 事件类型。",
250
+ )
251
+ parser.add_argument("--paths", nargs="*", default=[], help="事件相关路径。")
252
+ return parser.parse_args(argv)
253
+
254
+
255
+ def main(argv=None):
256
+ args = parse_args(sys.argv[1:] if argv is None else argv)
257
+ root = Path(args.root)
258
+
259
+ if args.event == "session-start":
260
+ return print_session_start(root)
261
+ if args.event == "pre-write":
262
+ return print_pre_write(root, args.paths)
263
+ if args.event == "post-write":
264
+ return print_post_write(args.paths)
265
+ if args.event == "stop":
266
+ return print_stop(root)
267
+ return 0
268
+
269
+
270
+ if __name__ == "__main__":
271
+ raise SystemExit(main())