land_check.py python
378 lines 11.3 KB
Raw
sha256:0e9549ec7b463911bc08b7d586dc320b1ac9b1f5c943ee7e3865dcc6cb0f6f83 chore(governance): sync handover+roadmap to 84db8c8 (drift:… Human 9 hours ago
1 """Close-ritual land check — compare require_paths to origin/main (never merge)."""
2
3 from __future__ import annotations
4
5 import hashlib
6 import subprocess
7 from dataclasses import dataclass
8 from pathlib import Path
9 from typing import Any, Callable
10
11 from adapters.config import CloseRitualConfig, OverseerConfig
12 from adapters.factory import create_adapter
13 from adapters.runner import CommandRunner, SubprocessRunner
14 from tools.governance_freshness import check_governance_freshness
15 from tools.land_closeout import check_land_closeout
16
17
18 @dataclass(frozen=True)
19 class LandCheckResult:
20 """Outcome of ``ok land-check``."""
21
22 exit_code: int
23 landed: bool
24 mode: str
25 ref: str
26 paths: tuple[dict[str, Any], ...]
27 dirty_paths: tuple[str, ...]
28 messages: tuple[str, ...]
29 auto_merge: bool = False # always False — Tier 3
30
31
32 def _sha256_bytes(data: bytes) -> str:
33 return hashlib.sha256(data).hexdigest()
34
35
36 def _git(repo_root: Path, *args: str) -> subprocess.CompletedProcess[str]:
37 return subprocess.run(
38 ["git", *args],
39 cwd=str(repo_root),
40 capture_output=True,
41 text=True,
42 )
43
44
45 def compare_paths_to_main(
46 repo_root: Path,
47 paths: tuple[str, ...],
48 *,
49 remote: str,
50 main_branch: str,
51 ) -> tuple[str, list[dict[str, Any]], list[str]]:
52 """Return (ref, path_reports, dirty_paths)."""
53 ref = f"{remote}/{main_branch}"
54 if _git(repo_root, "rev-parse", "--verify", ref).returncode != 0:
55 if _git(repo_root, "rev-parse", "--verify", main_branch).returncode == 0:
56 ref = main_branch
57 else:
58 return ref, [], list(paths)
59
60 reports: list[dict[str, Any]] = []
61 for rel in paths:
62 wt_path = repo_root / rel
63 wt = _sha256_bytes(wt_path.read_bytes()) if wt_path.is_file() else None
64 shown = _git(repo_root, "show", f"{ref}:{rel}")
65 main_sha = _sha256_bytes(shown.stdout.encode("utf-8")) if shown.returncode == 0 else None
66 reports.append(
67 {
68 "path": rel,
69 "workingTreeSha256": wt,
70 "mainSha256": main_sha,
71 "match": wt is not None and main_sha is not None and wt == main_sha,
72 }
73 )
74
75 dirty = _git(repo_root, "status", "--porcelain", "--", *paths)
76 dirty_paths = [line[3:].strip() for line in dirty.stdout.splitlines() if line.strip()]
77 return ref, reports, dirty_paths
78
79
80 def run_land_check(
81 config: OverseerConfig,
82 repo_root: Path,
83 *,
84 mode: str | None = None,
85 emit: Callable[[str], None] | None = None,
86 runner: CommandRunner | None = None,
87 ) -> LandCheckResult:
88 """Run close_ritual land check. Never merges to main."""
89 ritual: CloseRitualConfig = config.close_ritual
90 messages: list[str] = []
91
92 def _emit(line: str) -> None:
93 messages.append(line)
94 if emit:
95 emit(line)
96
97 if not ritual.enabled:
98 _emit("close_ritual.enabled is false — land-check is a no-op (exit 0)")
99 return LandCheckResult(
100 exit_code=0,
101 landed=True,
102 mode=mode or ritual.mode,
103 ref="",
104 paths=(),
105 dirty_paths=(),
106 messages=tuple(messages),
107 )
108
109 effective_mode = mode or ritual.mode
110 if effective_mode not in {"verify_landed", "prepare_pr"}:
111 _emit(f"unsupported land-check mode: {effective_mode}")
112 return LandCheckResult(
113 exit_code=2,
114 landed=False,
115 mode=effective_mode,
116 ref="",
117 paths=(),
118 dirty_paths=(),
119 messages=tuple(messages),
120 )
121
122 if ritual.consumer_verify_script:
123 script = repo_root / ritual.consumer_verify_script
124 if not script.is_file():
125 _emit(f"consumer_verify_script missing: {ritual.consumer_verify_script}")
126 return LandCheckResult(
127 exit_code=1,
128 landed=False,
129 mode=effective_mode,
130 ref="",
131 paths=(),
132 dirty_paths=(),
133 messages=tuple(messages),
134 )
135 completed = subprocess.run(
136 ["python3", str(script)],
137 cwd=str(repo_root),
138 capture_output=True,
139 text=True,
140 )
141 out = (completed.stdout or completed.stderr or "").strip()
142 if out:
143 _emit(out)
144 if completed.returncode != 0:
145 _emit(
146 "note: ok land-check never merges; use ok pr-land --authorized for wait-for-green land"
147 )
148 return LandCheckResult(
149 exit_code=completed.returncode,
150 landed=False,
151 mode=effective_mode,
152 ref="consumer_script",
153 paths=(),
154 dirty_paths=(),
155 messages=tuple(messages),
156 )
157 freshness_fail = _freshness_gate(
158 config,
159 repo_root,
160 mode=effective_mode,
161 runner=runner,
162 emit=_emit,
163 messages=messages,
164 )
165 if freshness_fail is not None:
166 return freshness_fail
167 closeout_fail = _closeout_gate(
168 config,
169 repo_root,
170 mode=effective_mode,
171 runner=runner,
172 emit=_emit,
173 messages=messages,
174 )
175 if closeout_fail is not None:
176 return closeout_fail
177 _emit("note: ok land-check never merges; use ok pr-land --authorized for wait-for-green land")
178 return LandCheckResult(
179 exit_code=0,
180 landed=True,
181 mode=effective_mode,
182 ref="consumer_script",
183 paths=(),
184 dirty_paths=(),
185 messages=tuple(messages),
186 )
187
188 paths = ritual.require_paths
189 if not paths:
190 _emit("close_ritual.require_paths is empty — configure paths or consumer_verify_script")
191 return LandCheckResult(
192 exit_code=2,
193 landed=False,
194 mode=effective_mode,
195 ref="",
196 paths=(),
197 dirty_paths=(),
198 messages=tuple(messages),
199 )
200
201 remote = config.vcs.git.remote
202 main_branch = config.vcs.git.main_branch
203 ref, reports, dirty_paths = compare_paths_to_main(
204 repo_root,
205 paths,
206 remote=remote,
207 main_branch=main_branch,
208 )
209 all_match = all(r.get("match") for r in reports) and not dirty_paths
210
211 if effective_mode == "prepare_pr":
212 if dirty_paths:
213 _emit("prepare_pr: dirty require_paths — commit before opening PR")
214 for d in dirty_paths:
215 _emit(f" dirty: {d}")
216 _emit(
217 'Tier 3: use ok pr-land --authorized "…" to wait-for-green merge (never blind --auto)'
218 )
219 return LandCheckResult(
220 exit_code=1,
221 landed=False,
222 mode=effective_mode,
223 ref=ref,
224 paths=tuple(reports),
225 dirty_paths=tuple(dirty_paths),
226 messages=tuple(messages),
227 )
228 freshness_fail = _freshness_gate(
229 config,
230 repo_root,
231 mode=effective_mode,
232 runner=runner,
233 emit=_emit,
234 messages=messages,
235 )
236 if freshness_fail is not None:
237 return freshness_fail
238 _emit("prepare_pr: require_paths clean — push feature branch and open PR")
239 _emit(
240 'Tier 3: use ok pr-land --authorized "…" to wait-for-green merge (never blind --auto)'
241 )
242 return LandCheckResult(
243 exit_code=0,
244 landed=False,
245 mode=effective_mode,
246 ref=ref,
247 paths=tuple(reports),
248 dirty_paths=(),
249 messages=tuple(messages),
250 )
251
252 # verify_landed
253 if not all_match:
254 _emit(f"verify_landed: FAIL — paths do not match {ref}")
255 for r in reports:
256 if not r.get("match"):
257 _emit(f" mismatch: {r['path']}")
258 for d in dirty_paths:
259 _emit(f" dirty: {d}")
260 _emit(
261 'Tier 3: use ok pr-land --authorized "…" to wait-for-green merge (never blind --auto)'
262 )
263 return LandCheckResult(
264 exit_code=1,
265 landed=False,
266 mode=effective_mode,
267 ref=ref,
268 paths=tuple(reports),
269 dirty_paths=tuple(dirty_paths),
270 messages=tuple(messages),
271 )
272
273 freshness_fail = _freshness_gate(
274 config,
275 repo_root,
276 mode=effective_mode,
277 runner=runner,
278 emit=_emit,
279 messages=messages,
280 )
281 if freshness_fail is not None:
282 return freshness_fail
283
284 closeout_fail = _closeout_gate(
285 config,
286 repo_root,
287 mode=effective_mode,
288 runner=runner,
289 emit=_emit,
290 messages=messages,
291 )
292 if closeout_fail is not None:
293 return closeout_fail
294
295 _emit(f"verify_landed: PASS — require_paths match {ref}")
296 _emit("Tier 3 land complete once paths match; further merges use ok pr-land --authorized")
297 return LandCheckResult(
298 exit_code=0,
299 landed=True,
300 mode=effective_mode,
301 ref=ref,
302 paths=tuple(reports),
303 dirty_paths=(),
304 messages=tuple(messages),
305 )
306
307
308 def _freshness_gate(
309 config: OverseerConfig,
310 repo_root: Path,
311 *,
312 mode: str,
313 runner: CommandRunner | None,
314 emit: Callable[[str], None],
315 messages: list[str],
316 ) -> LandCheckResult | None:
317 """§GFG.6: when close_ritual is enabled, fail land-check on stale freshness (exit 2)."""
318 active_runner = runner or SubprocessRunner()
319 adapter = create_adapter(config, repo_root, runner=active_runner)
320 report = check_governance_freshness(
321 config,
322 repo_root,
323 adapter=adapter,
324 runner=active_runner,
325 )
326 if report.ok:
327 return None
328 emit(f"governance_freshness: {report.state} — {report.message}")
329 if report.remediation:
330 emit(f"governance_freshness-remediation: {report.remediation}")
331 emit("land-check refused — freshness gate (never merges)")
332 return LandCheckResult(
333 exit_code=2,
334 landed=False,
335 mode=mode,
336 ref="",
337 paths=(),
338 dirty_paths=(),
339 messages=tuple(messages),
340 )
341
342
343 def _closeout_gate(
344 config: OverseerConfig,
345 repo_root: Path,
346 *,
347 mode: str,
348 runner: CommandRunner | None,
349 emit: Callable[[str], None],
350 messages: list[str],
351 ) -> LandCheckResult | None:
352 """§PMHF.6.2: ``landed=True`` only when closeout is ``complete``/``not_applicable``.
353
354 ``land_a_in_progress``, ``post_merge_incomplete``, ``land_b_in_progress``, and
355 ``unreadable`` all refuse landed (exit 2) with remediation — never merges.
356 """
357 active_runner = runner or SubprocessRunner()
358 report = check_land_closeout(
359 config,
360 repo_root,
361 runner=active_runner,
362 probe_merged_pr=config.vcs.regime != "muse-only",
363 )
364 if report.state in {"complete", "not_applicable"}:
365 return None
366 emit(f"land_closeout: {report.state} — {report.message}")
367 if report.remediation:
368 emit(f"land_closeout-remediation: {report.remediation}")
369 emit("land-check refused — land closeout incomplete (never merges)")
370 return LandCheckResult(
371 exit_code=2,
372 landed=False,
373 mode=mode,
374 ref="",
375 paths=(),
376 dirty_paths=(),
377 messages=tuple(messages),
378 )
File History 1 commit
sha256:6abcf1fa82a7a621ccbc945f19acdba5bc0db54569599404a1452fb4a096a199 fix(ISR): default require_independent_second_reviewer to require Human minor 8 hours ago