scan.py
python
sha256:0e9549ec7b463911bc08b7d586dc320b1ac9b1f5c943ee7e3865dcc6cb0f6f83
chore(governance): sync handover+roadmap to 84db8c8 (drift:…
Human
2 days ago
| 1 | """Read-only governance gate scan against roadmap + handover (§KH1.9).""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import re |
| 6 | from pathlib import Path |
| 7 | |
| 8 | from adapters.config import GovernanceGatesConfig, OverseerConfig |
| 9 | from tools.freeze_reviewer.artifact import extract_existing_stamp, parse_artifact |
| 10 | from tools.governance_gates.checklist import ( |
| 11 | BUILD_VERIFICATION_INVOKE, |
| 12 | FREEZE_REVIEW_INVOKE, |
| 13 | ) |
| 14 | from tools.governance_gates.types import GateScanResult, PendingGate |
| 15 | |
| 16 | ROADMAP_ROW_RE = re.compile( |
| 17 | r"^\|\s*\*\*(?P<phase>[^|*]+)\*\*\s*\|\s*(?P<model>[^|]+)\|\s*\*\*(?P<status>[^|*]+)\*\*\s*\|\s*(?P<deliverable>[^|]+)", |
| 18 | re.MULTILINE, |
| 19 | ) |
| 20 | PHASE_DOC_RE = re.compile(r"docs/archive/phases/PHASE-[A-Za-z0-9_.-]+\.md") |
| 21 | NEXT_ID_RE = re.compile(r"\|\s*\*\*ID\*\*\s*\|\s*\*\*([^*]+)\*\*", re.MULTILINE) |
| 22 | BUILD_VERIFICATION_PASS_RE = re.compile( |
| 23 | r"build[- ]verification(?:[- ]review)?[^\n]{0,80}\bpass\b", |
| 24 | re.IGNORECASE, |
| 25 | ) |
| 26 | HANDOVER_PASTE_MARKER = "Governance gates" |
| 27 | |
| 28 | |
| 29 | def scan_governance_gates( |
| 30 | config: OverseerConfig, |
| 31 | repo_root: Path, |
| 32 | *, |
| 33 | handover_text: str | None = None, |
| 34 | roadmap_text: str | None = None, |
| 35 | ) -> GateScanResult: |
| 36 | """Scan active roadmap slice for pending freeze review / build verification gates.""" |
| 37 | gates = config.governance_gates |
| 38 | if not gates.remind: |
| 39 | return GateScanResult( |
| 40 | enabled=True, |
| 41 | suppressed=True, |
| 42 | pending=(), |
| 43 | active_phases=(), |
| 44 | ) |
| 45 | |
| 46 | handover_path = repo_root / config.repo.root_relative_docs / config.docs.handover |
| 47 | roadmap_path = repo_root / config.repo.root_relative_docs / config.docs.roadmap |
| 48 | handover = handover_text if handover_text is not None else _read_text(handover_path) |
| 49 | roadmap = roadmap_text if roadmap_text is not None else _read_text(roadmap_path) |
| 50 | |
| 51 | active = _active_phases(handover, roadmap) |
| 52 | pending: list[PendingGate] = [] |
| 53 | |
| 54 | if gates.freeze_review_required: |
| 55 | pending.extend(_scan_freeze_review(repo_root, config, roadmap, active)) |
| 56 | |
| 57 | if gates.build_verification_required: |
| 58 | pending.extend(_scan_build_verification(handover, roadmap, active)) |
| 59 | |
| 60 | if "handover-paste" in gates.surfaces and handover is not None: |
| 61 | if HANDOVER_PASTE_MARKER not in handover: |
| 62 | pending.append( |
| 63 | PendingGate( |
| 64 | gate_id="handover_paste", |
| 65 | phase_id=_next_phase_id(handover) or "unknown", |
| 66 | artifact=config.docs.handover, |
| 67 | message="handover paste-ready prompt missing Governance gates checklist", |
| 68 | invoke="include §KH1.9 checklist in paste-ready prompt fence", |
| 69 | ) |
| 70 | ) |
| 71 | |
| 72 | return GateScanResult( |
| 73 | enabled=True, |
| 74 | suppressed=False, |
| 75 | pending=tuple(pending), |
| 76 | active_phases=active, |
| 77 | ) |
| 78 | |
| 79 | |
| 80 | def _read_text(path: Path) -> str | None: |
| 81 | if not path.is_file(): |
| 82 | return None |
| 83 | return path.read_text(encoding="utf-8") |
| 84 | |
| 85 | |
| 86 | def _active_phases(handover: str | None, roadmap: str | None) -> tuple[str, ...]: |
| 87 | phases: set[str] = set() |
| 88 | if handover: |
| 89 | next_id = _next_phase_id(handover) |
| 90 | if next_id: |
| 91 | phases.add(_normalize_phase_id(next_id)) |
| 92 | if roadmap: |
| 93 | for match in ROADMAP_ROW_RE.finditer(roadmap): |
| 94 | status = match.group("status").strip().upper() |
| 95 | if status in {"WIP", "TODO", "BLOCKED"}: |
| 96 | phases.add(_normalize_phase_id(match.group("phase"))) |
| 97 | return tuple(sorted(phases)) |
| 98 | |
| 99 | |
| 100 | def _next_phase_id(handover: str) -> str | None: |
| 101 | match = NEXT_ID_RE.search(handover) |
| 102 | if not match: |
| 103 | return None |
| 104 | return match.group(1).strip() |
| 105 | |
| 106 | |
| 107 | def _normalize_phase_id(text: str) -> str: |
| 108 | cleaned = text.strip() |
| 109 | cleaned = re.sub(r"\s+", " ", cleaned) |
| 110 | return cleaned |
| 111 | |
| 112 | |
| 113 | def _scan_freeze_review( |
| 114 | repo_root: Path, |
| 115 | config: OverseerConfig, |
| 116 | roadmap: str | None, |
| 117 | active: tuple[str, ...], |
| 118 | ) -> list[PendingGate]: |
| 119 | pending: list[PendingGate] = [] |
| 120 | docs_root = repo_root / config.repo.root_relative_docs |
| 121 | for phase_id in active: |
| 122 | contract = _contract_path_for_phase(repo_root, docs_root, roadmap, phase_id) |
| 123 | if contract is None or not contract.is_file(): |
| 124 | continue |
| 125 | rel = contract.relative_to(repo_root).as_posix() |
| 126 | try: |
| 127 | parsed = parse_artifact(contract, rel_path=rel) |
| 128 | except (ValueError, OSError): |
| 129 | continue |
| 130 | if parsed.declaration != "present": |
| 131 | continue |
| 132 | stamp = extract_existing_stamp(parsed) |
| 133 | if stamp and stamp.get("verdict") == "pass": |
| 134 | continue |
| 135 | if _narrative_freeze_pass(contract.read_text(encoding="utf-8")): |
| 136 | continue |
| 137 | pending.append( |
| 138 | PendingGate( |
| 139 | gate_id="freeze_review", |
| 140 | phase_id=phase_id, |
| 141 | artifact=rel, |
| 142 | message=f"frozen artifact lacks reviewed → pass ({rel})", |
| 143 | invoke=FREEZE_REVIEW_INVOKE, |
| 144 | ) |
| 145 | ) |
| 146 | return pending |
| 147 | |
| 148 | |
| 149 | def _scan_build_verification( |
| 150 | handover: str | None, |
| 151 | roadmap: str | None, |
| 152 | active: tuple[str, ...], |
| 153 | ) -> list[PendingGate]: |
| 154 | if roadmap is None: |
| 155 | return [] |
| 156 | pending: list[PendingGate] = [] |
| 157 | corpus = (handover or "") + "\n" + roadmap |
| 158 | for match in ROADMAP_ROW_RE.finditer(roadmap): |
| 159 | phase_label = _normalize_phase_id(match.group("phase")) |
| 160 | if phase_label not in active: |
| 161 | continue |
| 162 | model = match.group("model") |
| 163 | status = match.group("status").strip().upper() |
| 164 | if not _is_auto_model(model): |
| 165 | continue |
| 166 | if status not in {"WIP", "DONE"}: |
| 167 | continue |
| 168 | if _build_verification_recorded(corpus, phase_label): |
| 169 | continue |
| 170 | pending.append( |
| 171 | PendingGate( |
| 172 | gate_id="build_verification", |
| 173 | phase_id=phase_label, |
| 174 | artifact=None, |
| 175 | message=( |
| 176 | f"Auto phase {status} without recorded build-verification pass " |
| 177 | f"({phase_label})" |
| 178 | ), |
| 179 | invoke=BUILD_VERIFICATION_INVOKE, |
| 180 | ) |
| 181 | ) |
| 182 | return pending |
| 183 | |
| 184 | |
| 185 | def _is_auto_model(model: str) -> bool: |
| 186 | lowered = model.lower() |
| 187 | if "auto" not in lowered: |
| 188 | return False |
| 189 | if lowered.strip() == "thinking": |
| 190 | return False |
| 191 | return True |
| 192 | |
| 193 | |
| 194 | def _build_verification_recorded(corpus: str, phase_id: str) -> bool: |
| 195 | if not BUILD_VERIFICATION_PASS_RE.search(corpus): |
| 196 | return False |
| 197 | phase_tokens = [token for token in re.split(r"[\s/]+", phase_id.lower()) if token] |
| 198 | if not phase_tokens: |
| 199 | return False |
| 200 | window = 400 |
| 201 | for match in BUILD_VERIFICATION_PASS_RE.finditer(corpus): |
| 202 | start = max(0, match.start() - window) |
| 203 | end = min(len(corpus), match.end() + window) |
| 204 | snippet = corpus[start:end].lower() |
| 205 | if any(token in snippet for token in phase_tokens): |
| 206 | return True |
| 207 | return False |
| 208 | |
| 209 | |
| 210 | def _contract_path_for_phase( |
| 211 | repo_root: Path, |
| 212 | docs_root: Path, |
| 213 | roadmap: str | None, |
| 214 | phase_id: str, |
| 215 | ) -> Path | None: |
| 216 | if roadmap: |
| 217 | for row in ROADMAP_ROW_RE.finditer(roadmap): |
| 218 | if _normalize_phase_id(row.group("phase")) != phase_id: |
| 219 | continue |
| 220 | deliverable = row.group("deliverable") |
| 221 | doc_match = PHASE_DOC_RE.search(deliverable) |
| 222 | if doc_match: |
| 223 | candidate = (repo_root / doc_match.group(0)).resolve() |
| 224 | if not str(candidate).startswith(str(repo_root.resolve())): |
| 225 | return None |
| 226 | return candidate |
| 227 | slug = re.sub(r"[^A-Za-z0-9]+", "-", phase_id).strip("-").upper() |
| 228 | candidates = sorted(docs_root.glob(f"PHASE-{slug}*.md")) |
| 229 | if candidates: |
| 230 | return candidates[0] |
| 231 | token = phase_id.split()[0].upper() |
| 232 | candidates = sorted(docs_root.glob(f"PHASE-{token}*.md")) |
| 233 | return candidates[0] if candidates else None |
| 234 | |
| 235 | |
| 236 | def _narrative_freeze_pass(text: str) -> bool: |
| 237 | return bool( |
| 238 | re.search(r"reviewed\s*→\s*`pass`", text, re.IGNORECASE) |
| 239 | or re.search(r"Freeze status:.*\bpass\b", text, re.IGNORECASE) |
| 240 | or re.search(r"→\s*`pass`.*Cleared", text, re.IGNORECASE) |
| 241 | ) |
File History
1 commit
sha256:6abcf1fa82a7a621ccbc945f19acdba5bc0db54569599404a1452fb4a096a199
fix(ISR): default require_independent_second_reviewer to require
Human
minor
⚠
2 days ago