"""Reviewer final-report schema verification before session output (#391). Composes the composable ``assess_final_report_validator`` (#327) with review-specific schema gates required before a reviewer session completes. """ from __future__ import annotations import re from typing import Any from final_report_validator import ( assess_final_report_validator, validator_finding, _handoff_fields, ) _LEGACY_STALE_FIELDS = ( "pinned reviewed head", "scratch worktree used", "workspace mutations", ) _REVIEWED_HEAD_RE = re.compile( r"(?:pinned reviewed head|reviewed head sha)\s*:\s*([0-9a-f]{7,40})", re.IGNORECASE, ) _VALIDATION_PASS_RE = re.compile( r"validation\s*:\s*(?:pass|passed|strong|ok|green)", re.IGNORECASE, ) _MERGED_CLAIM_RE = re.compile( r"\b(?:merged|merge result\s*:\s*merged|pr\s+#\d+\s+merged)\b", re.IGNORECASE, ) _MERGE_RESULT_RE = re.compile(r"merge result\s*:", re.IGNORECASE) _ANCESTRY_PROOF_RE = re.compile( r"(?:ancestor proof|target branch sha|merge commit)", re.IGNORECASE, ) _ISSUE_CLOSED_RE = re.compile( r"(?:linked issue(?:\s+live)?\s+status\s*:\s*closed|issue\s+#\d+\s+closed)", re.IGNORECASE, ) _LIVE_ISSUE_PROOF_RE = re.compile( r"gitea_view_issue|(?:issue|linked issue).{0,40}fetched live|live fetch proof", re.IGNORECASE, ) _FULL_SUITE_PASS_RE = re.compile( r"(?:full suite passed|full test suite passed|\d+\s+passed,\s*0\s+failed)", re.IGNORECASE, ) _TESTS_IGNORED_RE = re.compile( r"(?:ignored tests|tests?\s+ignored|skipped tests|not run|tests?\s+skipped)", re.IGNORECASE, ) _BLOCKED_HANDOFF_RE = re.compile( r"(?:blocker\s*:|recovery handoff|infra_stop|capability stop|mutation blocked)", re.IGNORECASE, ) _REPLAY_COMMAND_RE = re.compile( r"(?:gitea_submit_pr_review|gitea_merge_pr|gitea_review_pr|" r"submitted\s+['\"]approve['\"]|replay approve|replay merge)", re.IGNORECASE, ) _PENDING_RE = re.compile(r"\bPENDING\b", re.IGNORECASE) _APPROVED_RE = re.compile( r"(?:review decision\s*:\s*approve|submitted\s+approve|official review submitted)", re.IGNORECASE, ) _DRY_RUN_RE = re.compile(r"dry[- ]run", re.IGNORECASE) _FINDING_READY_RE = re.compile( r"(?:finding ready|ready to submit|not yet submitted)", re.IGNORECASE, ) _NARRATIVE_APPROVE_RE = re.compile( r"(?:^|\n)\s*(?:##\s+)?(?:summary|verdict|recommendation)\b[^\n]*\bapprove\b", re.IGNORECASE | re.MULTILINE, ) _HANDOFF_DECISION_RE = re.compile( r"review decision\s*:\s*(\w+)", re.IGNORECASE, ) def _rule_legacy_stale_fields(report_text: str) -> list[dict[str, str]]: fields = _handoff_fields(report_text) findings: list[dict[str, str]] = [] for stale in _LEGACY_STALE_FIELDS: if stale in fields and fields[stale].lower() not in {"", "none", "n/a"}: findings.append( validator_finding( "reviewer.legacy_stale_field", "block", stale.title(), f"legacy or stale handoff field '{stale}' must not appear in " "canonical reviewer final reports", "remove stale fields; use Candidate head SHA and precise " "mutation categories instead", ) ) return findings def _rule_reviewed_head_without_validation(report_text: str) -> list[dict[str, str]]: text = report_text or "" if not _REVIEWED_HEAD_RE.search(text): return [] if _VALIDATION_PASS_RE.search(text): return [] return [ validator_finding( "reviewer.reviewed_head_without_validation", "block", "Pinned reviewed head", "reviewed/pinned head SHA reported without validation pass proof", "document validation command, cwd, and pass result before pinning head SHA", ) ] def _rule_merged_without_proof(report_text: str) -> list[dict[str, str]]: text = report_text or "" if not _MERGED_CLAIM_RE.search(text): return [] if _MERGE_RESULT_RE.search(text) and _ANCESTRY_PROOF_RE.search(text): return [] return [ validator_finding( "reviewer.merged_without_proof", "block", "Merge result", "merged outcome claimed without merge result and target ancestry proof", "include Merge result, target branch SHA, and ancestor proof before claiming merged", ) ] def _rule_issue_closed_without_live_proof(report_text: str) -> list[dict[str, str]]: text = report_text or "" if not _ISSUE_CLOSED_RE.search(text): return [] fields = _handoff_fields(text) status_value = fields.get("linked issue live status", "") readonly_value = fields.get("read-only diagnostics", "") proof_blob = f"{status_value} {readonly_value}".lower() if _LIVE_ISSUE_PROOF_RE.search(proof_blob): return [] return [ validator_finding( "reviewer.issue_closed_without_live_proof", "block", "Linked issue", "issue closed claimed without live linked-issue verification proof", "fetch linked issue live (gitea_view_issue) and record Linked issue live status", ) ] def _rule_full_suite_pass_ignored_tests(report_text: str) -> list[dict[str, str]]: text = report_text or "" if not (_FULL_SUITE_PASS_RE.search(text) and _TESTS_IGNORED_RE.search(text)): return [] if re.search(r"explicitly\s+(?:ignored|skipped)", text, re.IGNORECASE): return [] return [ validator_finding( "reviewer.full_suite_pass_ignored_tests", "block", "Validation", "full suite pass claimed while tests were ignored or skipped without " "explicit disclosure", "state which tests were ignored/skipped or downgrade validation verdict", ) ] def _rule_blocked_handoff_replay(report_text: str) -> list[dict[str, str]]: text = report_text or "" if not _BLOCKED_HANDOFF_RE.search(text): return [] if not _REPLAY_COMMAND_RE.search(text): return [] return [ validator_finding( "reviewer.blocked_handoff_replay", "block", "Safe next action", "blocked recovery handoff includes direct approve or merge replay commands", "restart the full workflow after the blocker clears; do not replay mutations", ) ] def _rule_narrative_handoff_drift(report_text: str) -> list[dict[str, str]]: text = report_text or "" narrative_approve = bool(_NARRATIVE_APPROVE_RE.search(text)) decision_match = _HANDOFF_DECISION_RE.search(text) if not narrative_approve or not decision_match: return [] handoff_decision = decision_match.group(1).strip().lower() if handoff_decision in {"approve", "approved"}: return [] return [ validator_finding( "reviewer.narrative_handoff_drift", "block", "Review decision", f"narrative approves PR but controller handoff says '{handoff_decision}'", "align narrative summary with controller handoff Review decision field", ) ] def _rule_review_state_ambiguous(report_text: str) -> list[dict[str, str]]: text = report_text or "" findings: list[dict[str, str]] = [] if _PENDING_RE.search(text) and _APPROVED_RE.search(text): findings.append( validator_finding( "reviewer.pending_vs_approved", "block", "Review decision", "report conflates PENDING review state with APPROVED outcome", "distinguish official submitted review from pending or ready-to-submit state", ) ) if _DRY_RUN_RE.search(text) and _APPROVED_RE.search(text): if "dry-run only" not in text.lower(): findings.append( validator_finding( "reviewer.dry_run_vs_submitted", "block", "Review mutations", "dry-run language mixed with official approve/submitted claims", "mark dry-run explicitly or document the live review mutation proof", ) ) if _FINDING_READY_RE.search(text) and _APPROVED_RE.search(text): findings.append( validator_finding( "reviewer.finding_ready_vs_submitted", "downgrade", "Review decision", "finding-ready wording combined with submitted-approve claims", "state whether review was submitted live or only prepared for submission", ) ) return findings _SCHEMA_RULES = ( _rule_legacy_stale_fields, _rule_reviewed_head_without_validation, _rule_merged_without_proof, _rule_issue_closed_without_live_proof, _rule_full_suite_pass_ignored_tests, _rule_blocked_handoff_replay, _rule_narrative_handoff_drift, _rule_review_state_ambiguous, ) def _merge_validator_results( base: dict[str, Any], extra_findings: list[dict[str, str]], ) -> dict[str, Any]: findings = list(base.get("findings") or []) + extra_findings blocked = base.get("blocked") or any(f["severity"] == "block" for f in extra_findings) downgraded = base.get("downgraded") or any( f["severity"] == "downgrade" for f in extra_findings ) grade = base.get("grade", "A") if blocked: grade = "blocked" elif downgraded and grade == "A": grade = "downgraded" reasons = [f"{f['rule_id']}: {f['reason']}" for f in findings] safe_next = base.get("safe_next_action") or "none" if extra_findings: safe_next = extra_findings[0].get("safe_next_action", safe_next) return { **base, "grade": grade, "blocked": blocked, "downgraded": downgraded, "findings": findings, "reasons": reasons, "safe_next_action": safe_next, "complete": grade == "A", "schema_rules_applied": len(_SCHEMA_RULES), } def assess_review_final_report_schema( report_text: str, *, review_decision_lock: dict | None = None, linked_issue_lock: dict | None = None, validation_report: dict | None = None, action_log: list[dict] | None = None, mutations_observed: bool = False, local_edits: bool = False, validation_session: dict | None = None, ) -> dict[str, Any]: """Validate reviewer final report text before session completion (#391).""" base = assess_final_report_validator( report_text, "review_pr", review_decision_lock=review_decision_lock, linked_issue_lock=linked_issue_lock, validation_report=validation_report, action_log=action_log, mutations_observed=mutations_observed, local_edits=local_edits, validation_session=validation_session, ) extra: list[dict[str, str]] = [] for rule in _SCHEMA_RULES: extra.extend(rule(report_text)) return _merge_validator_results(base, extra)