check_next.py python
624 lines 21.5 KB
Raw
sha256:0e9549ec7b463911bc08b7d586dc320b1ac9b1f5c943ee7e3865dcc6cb0f6f83 chore(governance): sync handover+roadmap to 84db8c8 (drift:… Human 1 day ago
1 """Relay freshness predicate and member board resolution (§MR.5 / §MR.7)."""
2
3 from __future__ import annotations
4
5 import os
6 from pathlib import Path
7 from typing import Any
8
9 from adapters.config import OverseerConfig
10 from cli.docs_paths import living_doc_abs
11 from tools.workspace.board_names import board_name_violation
12 from tools.workspace.manifest import (
13 discover_manifest,
14 load_member_config,
15 resolve_member_root,
16 )
17 from tools.workspace.next_extract import (
18 count_next_session_headings,
19 extract_next_blocks,
20 heading_role_mismatch,
21 legacy_forbidden_archived_headings,
22 primary_paste_hash,
23 select_live_primary,
24 select_product_tip,
25 with_computed_hash,
26 )
27 from tools.workspace.types import (
28 EXIT_CONFIG,
29 EXIT_OK,
30 EXIT_WORKSPACE_RELAY,
31 CheckNextResult,
32 FreshnessFinding,
33 MemberBoardPaths,
34 NextRole,
35 RelayState,
36 WorkspaceLoadError,
37 WorkspaceManifest,
38 WorkspaceStatusReport,
39 )
40
41
42 def _read_text(path: Path) -> str:
43 return path.read_text(encoding="utf-8")
44
45
46 def resolve_member_boards(
47 manifest: WorkspaceManifest,
48 *,
49 environ: dict[str, str] | None = None,
50 home: Path | None = None,
51 strict_all: bool = False,
52 ) -> list[MemberBoardPaths]:
53 """Resolve each member's root and default-lane board paths."""
54 env = environ if environ is not None else dict(os.environ)
55 out: list[MemberBoardPaths] = []
56 for member in manifest.members:
57 root = resolve_member_root(member.root_raw, environ=env, home=home)
58 if root is None or not root.is_dir() or not (root / ".overseer" / "config.yaml").is_file():
59 status = "missing_required" if member.required else "absent"
60 if member.required or strict_all:
61 # required missing always missing_required
62 status = "missing_required" if member.required else "absent"
63 out.append(
64 MemberBoardPaths(
65 member_id=member.id,
66 root=root,
67 present=False,
68 handover_path=None,
69 roadmap_path=None,
70 handover_basename=None,
71 roadmap_basename=None,
72 handover_title=None,
73 roadmap_title=None,
74 regime=member.regime,
75 role=member.role,
76 relay=member.relay,
77 required=member.required,
78 member_status=status, # type: ignore[arg-type]
79 )
80 )
81 continue
82 try:
83 cfg = load_member_config(root)
84 except WorkspaceLoadError as exc:
85 out.append(
86 MemberBoardPaths(
87 member_id=member.id,
88 root=root,
89 present=True,
90 handover_path=None,
91 roadmap_path=None,
92 handover_basename=None,
93 roadmap_basename=None,
94 handover_title=None,
95 roadmap_title=None,
96 regime=member.regime,
97 role=member.role,
98 relay=member.relay,
99 required=member.required,
100 member_status="error",
101 error=str(exc),
102 )
103 )
104 continue
105
106 handover_name = member.handover or cfg.docs.handover
107 roadmap_name = member.roadmap or cfg.docs.roadmap
108 handover_path = living_doc_abs(root, cfg, handover_name)
109 roadmap_path = living_doc_abs(root, cfg, roadmap_name)
110 # Prefer join via docs root for basename (config may store bare filename)
111 handover_basename = Path(handover_name).name
112 roadmap_basename = Path(roadmap_name).name
113 violation = board_name_violation(
114 repo_name=cfg.repo.name,
115 handover_basename=handover_basename,
116 roadmap_basename=roadmap_basename,
117 strict=manifest.strict_board_names,
118 )
119 out.append(
120 MemberBoardPaths(
121 member_id=member.id,
122 root=root,
123 present=True,
124 handover_path=handover_path if handover_path.is_file() else None,
125 roadmap_path=roadmap_path if roadmap_path.is_file() else None,
126 handover_basename=handover_basename,
127 roadmap_basename=roadmap_basename,
128 handover_title=cfg.docs.handover_title,
129 roadmap_title=cfg.docs.roadmap_title,
130 regime=cfg.vcs.regime,
131 role=member.role,
132 relay=member.relay,
133 required=member.required,
134 member_status="ok",
135 board_name_violation=violation,
136 )
137 )
138 return out
139
140
141 def _primary_snapshot(path: Path, text: str, *, lane: str, strict_markers: bool) -> tuple[dict[str, Any] | None, list[FreshnessFinding]]:
142 findings: list[FreshnessFinding] = []
143 forbidden = legacy_forbidden_archived_headings(text)
144 if forbidden:
145 findings.append(
146 FreshnessFinding(
147 code="ambiguous_primary",
148 message=(
149 f"forbidden legacy archived NEXT heading at {path}:{forbidden[0][0]}: "
150 f"{forbidden[0][1]}"
151 ),
152 primary_path=str(path),
153 )
154 )
155
156 blocks = extract_next_blocks(text)
157 next_count = count_next_session_headings(text)
158 if next_count != 1:
159 findings.append(
160 FreshnessFinding(
161 code="ambiguous_primary",
162 message=f"expected exactly one ## NEXT SESSION — heading, found {next_count}",
163 primary_path=str(path),
164 )
165 )
166
167 unmarked = [b for b in blocks if b.unmarked]
168 if unmarked:
169 if strict_markers:
170 findings.append(
171 FreshnessFinding(
172 code="ambiguous_primary",
173 message=f"unmarked_next at {path}:{unmarked[0].heading_line}",
174 primary_path=str(path),
175 )
176 )
177 else:
178 findings.append(
179 FreshnessFinding(
180 code="ok",
181 message=f"unmarked_next warning at {path}:{unmarked[0].heading_line}",
182 primary_path=str(path),
183 )
184 )
185
186 primaries = [
187 b
188 for b in blocks
189 if b.role is NextRole.PRIMARY and b.status == "live" and not b.unmarked and (b.lane is None or b.lane == lane)
190 ]
191 if len(primaries) > 1:
192 findings.append(
193 FreshnessFinding(
194 code="ambiguous_primary",
195 message=f"multiple LIVE PRIMARY markers on {path}",
196 primary_path=str(path),
197 )
198 )
199 return None, findings
200 if len(primaries) == 0:
201 # Try unmarked only when not strict
202 if not strict_markers and unmarked:
203 block = with_computed_hash(unmarked[0])
204 else:
205 findings.append(
206 FreshnessFinding(
207 code="missing_primary",
208 message=f"missing_primary on {path}",
209 primary_path=str(path),
210 )
211 )
212 return None, findings
213 else:
214 block = with_computed_hash(primaries[0])
215 mismatch = heading_role_mismatch(block)
216 if mismatch:
217 findings.append(
218 FreshnessFinding(
219 code="ambiguous_primary",
220 message=f"{mismatch} at {path}:{block.heading_line}",
221 primary_path=str(path),
222 )
223 )
224
225 digest = primary_paste_hash(block)
226 if not digest or not block.step_id or not block.model:
227 findings.append(
228 FreshnessFinding(
229 code="missing_primary",
230 message=f"PRIMARY fence missing Step/Model/fence on {path}",
231 primary_path=str(path),
232 )
233 )
234 return None, findings
235
236 snap = {
237 "path": str(path),
238 "step_id": block.step_id,
239 "model": block.model,
240 "tip_hash": digest,
241 "lane": lane,
242 "heading": block.heading,
243 }
244 # Drop soft "ok" warnings from hard-fail codes
245 hard = [f for f in findings if f.code != "ok"]
246 if hard:
247 return snap, findings
248 return snap, findings
249
250
251 def check_next(
252 manifest: WorkspaceManifest,
253 *,
254 lane: str | None = None,
255 environ: dict[str, str] | None = None,
256 home: Path | None = None,
257 strict_all: bool = False,
258 ) -> CheckNextResult:
259 """Evaluate relay freshness for the product lane (or ``--lane``)."""
260 target_lane = lane or manifest.primary_lane().id
261 boards = resolve_member_boards(manifest, environ=environ, home=home, strict_all=strict_all)
262 by_id = {b.member_id: b for b in boards}
263 findings: list[FreshnessFinding] = []
264
265 for board in boards:
266 if board.member_status == "missing_required":
267 findings.append(
268 FreshnessFinding(
269 code="missing_member",
270 message=f"required member {board.member_id!r} missing or incomplete",
271 )
272 )
273 if any(f.code == "missing_member" for f in findings):
274 return CheckNextResult(
275 exit_code=EXIT_WORKSPACE_RELAY,
276 state="missing_member",
277 ok=False,
278 lane=target_lane,
279 findings=tuple(findings),
280 messages=tuple(f.message for f in findings),
281 )
282
283 po_member = manifest.product_order()
284 po_board = by_id.get(po_member.id)
285 if po_board is None or po_board.handover_path is None:
286 findings.append(
287 FreshnessFinding(
288 code="missing_primary",
289 message=f"product_order handover missing for {po_member.id}",
290 )
291 )
292 return CheckNextResult(
293 exit_code=EXIT_WORKSPACE_RELAY,
294 state="missing_primary",
295 ok=False,
296 lane=target_lane,
297 findings=tuple(findings),
298 messages=tuple(f.message for f in findings),
299 )
300
301 po_text = _read_text(po_board.handover_path)
302 primary, po_findings = _primary_snapshot(
303 po_board.handover_path,
304 po_text,
305 lane=target_lane,
306 strict_markers=manifest.strict_markers,
307 )
308 findings.extend([f for f in po_findings if f.code != "ok"])
309 soft_warnings = [f.message for f in po_findings if f.code == "ok"]
310
311 hard_codes = {f.code for f in findings}
312 if "ambiguous_primary" in hard_codes:
313 return CheckNextResult(
314 exit_code=EXIT_WORKSPACE_RELAY,
315 state="ambiguous_primary",
316 ok=False,
317 lane=target_lane,
318 findings=tuple(findings),
319 primary=primary,
320 messages=tuple(f.message for f in findings),
321 )
322 if primary is None or "missing_primary" in hard_codes:
323 return CheckNextResult(
324 exit_code=EXIT_WORKSPACE_RELAY,
325 state="missing_primary",
326 ok=False,
327 lane=target_lane,
328 findings=tuple(findings),
329 primary=primary,
330 messages=tuple(f.message for f in findings),
331 )
332
333 relay_rows: list[dict[str, Any]] = []
334 for member in manifest.members:
335 if not member.relay:
336 continue
337 board = by_id[member.id]
338 if board.member_status == "absent":
339 continue
340 if board.handover_path is None:
341 findings.append(
342 FreshnessFinding(
343 code="stale_relay",
344 message=f"stale_relay: {member.id} handover missing",
345 relay_path=None,
346 primary_path=primary["path"],
347 )
348 )
349 continue
350 text = _read_text(board.handover_path)
351 if legacy_forbidden_archived_headings(text):
352 findings.append(
353 FreshnessFinding(
354 code="ambiguous_primary",
355 message=f"forbidden legacy archived NEXT on relay {board.handover_path}",
356 relay_path=str(board.handover_path),
357 primary_path=primary["path"],
358 )
359 )
360 continue
361 blocks = extract_next_blocks(text)
362 unmarked = [b for b in blocks if b.unmarked]
363 if unmarked and manifest.strict_markers:
364 findings.append(
365 FreshnessFinding(
366 code="ambiguous_primary",
367 message=f"unmarked_next on relay {board.handover_path}:{unmarked[0].heading_line}",
368 relay_path=str(board.handover_path),
369 primary_path=primary["path"],
370 )
371 )
372 continue
373
374 tip, tip_err = select_product_tip(blocks, lane=target_lane)
375 if tip_err == "ambiguous_primary":
376 findings.append(
377 FreshnessFinding(
378 code="ambiguous_primary",
379 message=(
380 f"ambiguous_primary: both role=relay and role=product_relay "
381 f"on {board.handover_path}"
382 ),
383 relay_path=str(board.handover_path),
384 primary_path=primary["path"],
385 )
386 )
387 continue
388 if tip is None:
389 findings.append(
390 FreshnessFinding(
391 code="stale_relay",
392 message=(
393 f"stale_relay: {board.handover_path} missing product tip "
394 f"(relay or product_relay)"
395 ),
396 relay_path=str(board.handover_path),
397 primary_path=primary["path"],
398 )
399 )
400 continue
401
402 tip_hash = (tip.tip_hash or "").lower()
403 tip_step = tip.step_id
404 tip_model = tip.model
405 # When tip_hash missing on marker, try fence hash only for embed mode
406 if not tip_hash and tip.fence:
407 from tools.workspace.next_extract import tip_hash_hex
408
409 tip_hash = tip_hash_hex(tip.fence)
410
411 row = {
412 "member_id": member.id,
413 "path": str(board.handover_path),
414 "role": tip.role.value,
415 "step_id": tip_step,
416 "model": tip_model,
417 "tip_hash": tip_hash,
418 "product_order": tip.product_order,
419 }
420 relay_rows.append(row)
421
422 mismatches: list[str] = []
423 if tip_step != primary["step_id"]:
424 mismatches.append(f"step_id {tip_step!r}!={primary['step_id']!r}")
425 if tip_model != primary["model"]:
426 mismatches.append(f"model {tip_model!r}!={primary['model']!r}")
427 if tip_hash != primary["tip_hash"]:
428 mismatches.append(f"tip_hash sha256:{tip_hash}!=sha256:{primary['tip_hash']}")
429 if tip.product_order and tip.product_order != manifest.product_order_member:
430 mismatches.append(
431 f"product_order {tip.product_order!r}!={manifest.product_order_member!r}"
432 )
433 if mismatches:
434 findings.append(
435 FreshnessFinding(
436 code="stale_relay",
437 message=(
438 f"stale_relay: {board.handover_path} tip=({', '.join(mismatches)}) "
439 f"!= product_order {primary['path']} "
440 f"primary=(step={primary['step_id']}, model={primary['model']}, "
441 f"tip_hash=sha256:{primary['tip_hash']})"
442 ),
443 relay_path=str(board.handover_path),
444 primary_path=primary["path"],
445 )
446 )
447
448 if any(f.code == "ambiguous_primary" for f in findings):
449 state: RelayState = "ambiguous_primary"
450 code = EXIT_WORKSPACE_RELAY
451 ok = False
452 elif any(f.code == "stale_relay" for f in findings):
453 state = "stale_relay"
454 code = EXIT_WORKSPACE_RELAY
455 ok = False
456 elif any(f.code == "missing_primary" for f in findings):
457 state = "missing_primary"
458 code = EXIT_WORKSPACE_RELAY
459 ok = False
460 else:
461 state = "ok"
462 code = EXIT_OK
463 ok = True
464
465 messages = tuple(f.message for f in findings) + tuple(soft_warnings)
466 return CheckNextResult(
467 exit_code=code,
468 state=state,
469 ok=ok,
470 lane=target_lane,
471 findings=tuple(findings),
472 primary=primary,
473 relays=tuple(relay_rows),
474 messages=messages,
475 )
476
477
478 def build_status_report(
479 config: OverseerConfig,
480 repo_root: Path,
481 *,
482 environ: dict[str, str] | None = None,
483 home: Path | None = None,
484 strict_all: bool = False,
485 ) -> WorkspaceStatusReport:
486 """Build ``ok workspace status`` report (exit 0 + not_configured when absent)."""
487 if config.workspace is None:
488 return WorkspaceStatusReport(
489 configured=False,
490 ok=True,
491 state="not_configured",
492 constellation_id=None,
493 product_order_member=None,
494 manifest_source=None,
495 manifest_path=None,
496 authoritative_handover=None,
497 members=(),
498 lanes=(),
499 check_next=None,
500 warnings=("workspace: not_configured",),
501 )
502
503 try:
504 manifest = discover_manifest(config, repo_root, environ=environ, home=home)
505 except WorkspaceLoadError as exc:
506 return WorkspaceStatusReport(
507 configured=True,
508 ok=False,
509 state="error",
510 constellation_id=config.workspace.constellation_id,
511 product_order_member=None,
512 manifest_source=None,
513 manifest_path=None,
514 authoritative_handover=None,
515 members=(),
516 lanes=(),
517 check_next=None,
518 warnings=(str(exc),),
519 diagnostics=(str(exc),),
520 )
521 assert manifest is not None
522
523 boards = resolve_member_boards(manifest, environ=environ, home=home, strict_all=strict_all)
524 check = check_next(manifest, environ=environ, home=home, strict_all=strict_all)
525
526 member_payloads: list[dict[str, Any]] = []
527 for board in boards:
528 member_payloads.append(
529 {
530 "id": board.member_id,
531 "role": board.role,
532 "relay": board.relay,
533 "required": board.required,
534 "regime": board.regime,
535 "root": str(board.root) if board.root else None,
536 "member_status": board.member_status,
537 "handover_basename": board.handover_basename,
538 "roadmap_basename": board.roadmap_basename,
539 "handover_title": board.handover_title,
540 "roadmap_title": board.roadmap_title,
541 "handover_path": str(board.handover_path) if board.handover_path else None,
542 "board_name_violation": board.board_name_violation,
543 "error": board.error,
544 }
545 )
546
547 lane_payloads = [
548 {
549 "id": lane.id,
550 "primary": lane.primary,
551 "owner_member": lane.owner_member or manifest.product_order_member,
552 }
553 for lane in manifest.lanes
554 ]
555
556 po_board = next((b for b in boards if b.member_id == manifest.product_order_member), None)
557 authoritative = None
558 if po_board and po_board.handover_path:
559 authoritative = str(po_board.handover_path)
560 elif po_board and po_board.handover_basename and po_board.root:
561 authoritative = str(po_board.root / "docs" / po_board.handover_basename)
562
563 warnings: list[str] = []
564 if manifest.manifest_source == "home_index":
565 warnings.append("manifest_source: home_index (last resort — prefer product_order)")
566
567 return WorkspaceStatusReport(
568 configured=True,
569 ok=check.ok,
570 state=check.state,
571 constellation_id=manifest.id,
572 product_order_member=manifest.product_order_member,
573 manifest_source=manifest.manifest_source,
574 manifest_path=str(manifest.source_path),
575 authoritative_handover=authoritative,
576 members=tuple(member_payloads),
577 lanes=tuple(lane_payloads),
578 check_next={
579 "ok": check.ok,
580 "state": check.state,
581 "exit_code": check.exit_code,
582 "lane": check.lane,
583 "primary": check.primary,
584 "relays": list(check.relays),
585 "messages": list(check.messages),
586 },
587 warnings=tuple(warnings),
588 )
589
590
591 def workspace_relay_footer_state(
592 config: OverseerConfig,
593 repo_root: Path,
594 *,
595 environ: dict[str, str] | None = None,
596 home: Path | None = None,
597 ) -> RelayState:
598 """Read-only workspace_relay state for governance-sync footer (§MR.8)."""
599 if config.workspace is None:
600 return "not_configured"
601 try:
602 manifest = discover_manifest(config, repo_root, environ=environ, home=home)
603 if manifest is None:
604 return "not_configured"
605 result = check_next(manifest, environ=environ, home=home)
606 return result.state
607 except WorkspaceLoadError:
608 return "error"
609
610
611 def load_manifest_for_repo(
612 config: OverseerConfig,
613 repo_root: Path,
614 *,
615 environ: dict[str, str] | None = None,
616 home: Path | None = None,
617 ) -> WorkspaceManifest:
618 """Load manifest or raise WorkspaceLoadError / signal not configured."""
619 if config.workspace is None:
620 raise WorkspaceLoadError("workspace not configured", citation=str(repo_root))
621 manifest = discover_manifest(config, repo_root, environ=environ, home=home)
622 if manifest is None:
623 raise WorkspaceLoadError("workspace not configured", citation=str(repo_root))
624 return manifest
File History 1 commit
sha256:6abcf1fa82a7a621ccbc945f19acdba5bc0db54569599404a1452fb4a096a199 fix(ISR): default require_independent_second_reviewer to require Human minor 1 day ago