diff --git a/review_proofs.py b/review_proofs.py index 332f866..9c26320 100644 --- a/review_proofs.py +++ b/review_proofs.py @@ -1710,6 +1710,11 @@ def build_final_report(checkout_proof, inventory, validation, contamination, if report_text else {"proven": True, "block": False, "reasons": [], "violations": []} ) + reconcile_inventory = ( + assess_reconcile_inventory_report(report_text) + if report_text and _RECONCILE_INVENTORY_HINT.search(report_text) + else {"proven": True, "block": False, "reasons": []} + ) reconcile_linked_issue = ( assess_reconcile_linked_issue_report(report_text) if report_text and _RECONCILE_LINKED_ISSUE_HINT.search(report_text) @@ -1886,6 +1891,11 @@ def build_final_report(checkout_proof, inventory, validation, contamination, "queue-status report violates loaded workflow proof gates (#339)" ) downgrade_reasons.extend(queue_status_report.get("reasons", [])) + if not reconcile_inventory.get("proven"): + downgrade_reasons.append( + "reconciliation PR inventory lacks pagination proof (#308)" + ) + downgrade_reasons.extend(reconcile_inventory.get("reasons", [])) if not reconcile_linked_issue.get("proven"): downgrade_reasons.append( "reconciliation linked issue status lacks live fetch proof (#300)" @@ -1999,6 +2009,10 @@ def build_final_report(checkout_proof, inventory, validation, contamination, "queue_status_violations": list( queue_status_report.get("violations") or [] ), + "reconcile_inventory_proven": bool(reconcile_inventory.get("proven")), + "reconcile_inventory_violations": list( + reconcile_inventory.get("reasons") or [] + ), "reconcile_linked_issue_proven": bool(reconcile_linked_issue.get("proven")), "reconcile_linked_issue_violations": list( reconcile_linked_issue.get("reasons") or [] @@ -4901,6 +4915,12 @@ _QUEUE_STATUS_REPORT_HINT = re.compile( re.I, ) +_RECONCILE_INVENTORY_HINT = re.compile( + r"already[- ]landed reconciliation|reconcile[- ]landed|" + r"open pr inventory|inventory pagination proof", + re.I, +) + _RECONCILE_LINKED_ISSUE_HINT = re.compile( r"already[- ]landed|reconcil|linked issue(?:\s+live)?\s+status", re.I, @@ -5334,6 +5354,13 @@ def assess_validation_worktree_edit_report(report_text, **kwargs): return _assess(report_text, **kwargs) +def assess_reconcile_inventory_report(report_text, **kwargs): + """#308: prove PR inventory pagination in reconciliation reports.""" + from reviewer_reconcile_inventory import assess_reconcile_inventory_report as _assess + + return _assess(report_text, **kwargs) + + def assess_reconcile_linked_issue_report(report_text, **kwargs): """#300: prove linked issue status was fetched live in reconciliation handoffs.""" from reviewer_reconcile_linked_issue import assess_reconcile_linked_issue_report as _assess diff --git a/reviewer_reconcile_inventory.py b/reviewer_reconcile_inventory.py new file mode 100644 index 0000000..9dfc0e5 --- /dev/null +++ b/reviewer_reconcile_inventory.py @@ -0,0 +1,162 @@ +"""Reconciliation PR inventory pagination proof verifier (#308).""" + +from __future__ import annotations + +import re +from typing import Any + +_COMPLETE_SCAN_CLAIM_RE = re.compile( + r"\b(?:all already[- ]landed(?:\s+prs?)?(?:\s+were)?\s+found|" + r"complete queue scan|all open prs? (?:were )?(?:found|checked|inventoried|listed)|" + r"inventory (?:is )?complete|exhaustive (?:pr )?inventory|" + r"no additional already[- ]landed|scanned (?:the )?(?:full )?open pr queue)\b", + re.I, +) + +_FINALITY_RE = re.compile( + r"is_final_page\s*:\s*true|has_more\s*:\s*false|no next page|final[- ]page|" + r"pagination_complete\s*:\s*true|inventory pagination proof\s*:\s*(?!assumed|none\b).+", + re.I, +) + +_REQUESTED_PAGE_SIZE_RE = re.compile( + r"requested page size\s*:\s*(\d+)|page[- ]?size\s*(?:=|:)\s*(\d+)", + re.I, +) +_PAGES_FETCHED_RE = re.compile( + r"pages? fetched\s*:\s*(\d+)|page count\s*:\s*(\d+)", + re.I, +) +_RETURNED_PER_PAGE_RE = re.compile( + r"returned pr count(?: per page)?\s*:|open pr count per page|" + r"returned \d+ open prs? per page|per[- ]page counts?\s*:", + re.I, +) +_EXACT_LIMIT_RETURN_RE = re.compile( + r"returned\s+(\d+)\s+open prs?|listed\s+(\d+)\s+open prs?", + re.I, +) + +_DEFAULT_PAGE_SIZE_ASSUMPTION = re.compile( + r"(?:less|fewer) than (?:the )?(?:default )?(?:gitea )?page[- ]?(?:size|limit)|" + r"default (?:gitea )?page[- ]?size|assumed complete", + re.I, +) + + +def _int_from_groups(match: re.Match[str] | None) -> int | None: + if not match: + return None + for group in match.groups(): + if group: + return int(group) + return None + + +def _pagination_proven(text: str, session: dict) -> bool: + if session.get("pagination_complete") or session.get("inventory_complete"): + return True + if _FINALITY_RE.search(text): + return True + pages = session.get("pages_fetched") + if isinstance(pages, int) and pages >= 1 and session.get("is_final_page"): + return True + return False + + +def _metadata_present(text: str, session: dict) -> list[str]: + missing: list[str] = [] + has_page_size = bool( + _REQUESTED_PAGE_SIZE_RE.search(text) + or session.get("requested_page_size") + ) + has_pages_fetched = bool( + _PAGES_FETCHED_RE.search(text) or session.get("pages_fetched") + ) + has_returned_counts = bool( + _RETURNED_PER_PAGE_RE.search(text) or session.get("returned_per_page") + ) + if not has_page_size: + missing.append("requested page size") + if not has_pages_fetched: + missing.append("page count fetched") + if not has_returned_counts: + missing.append("returned PR count per page") + if not _pagination_proven(text, session): + missing.append("final-page/no-next-page proof") + return missing + + +def assess_reconcile_inventory_report( + report_text: str, + *, + inventory_session: dict | None = None, +) -> dict[str, Any]: + """Validate PR inventory pagination proof in reconciliation reports (#308).""" + text = report_text or "" + session = dict(inventory_session or {}) + reasons: list[str] = [] + + claims_scan = bool(_COMPLETE_SCAN_CLAIM_RE.search(text)) + lists_open_prs = bool( + re.search(r"open prs? listed|listed open prs?|pr inventory", text, re.I) + ) + + if not claims_scan and not lists_open_prs and not session.get("inventory_required"): + return { + "proven": True, + "block": False, + "reasons": [], + "inventory_claimed": False, + "safe_next_action": "proceed", + } + + if _DEFAULT_PAGE_SIZE_ASSUMPTION.search(text) and not _pagination_proven(text, session): + reasons.append( + "reconciliation inventory assumed complete from page-size guess " + "without final-page proof (#308)" + ) + + if claims_scan and not _pagination_proven(text, session): + reasons.append( + "complete queue scan or all already-landed claim requires " + "pagination finality proof (#308)" + ) + + missing = _metadata_present(text, session) + if (claims_scan or lists_open_prs) and missing: + reasons.append( + "reconciliation inventory missing pagination metadata: " + + ", ".join(missing) + ) + + requested = session.get("requested_page_size") + returned = session.get("returned_page_size") + if isinstance(requested, int) and isinstance(returned, int): + if returned >= requested > 0 and not _pagination_proven(text, session): + reasons.append( + "exactly-full first reconciliation page without final-page proof (#308)" + ) + else: + for match in _EXACT_LIMIT_RETURN_RE.finditer(text): + count = _int_from_groups(match) + if count in {10, 20, 50} and not _pagination_proven(text, session): + reasons.append( + "exactly-full first reconciliation page without final-page proof (#308)" + ) + break + + proven = not reasons + return { + "proven": proven, + "block": not proven, + "reasons": list(dict.fromkeys(reasons)), + "inventory_claimed": claims_scan or lists_open_prs, + "pagination_proven": _pagination_proven(text, session), + "safe_next_action": ( + "include requested page size, pages fetched, per-page counts, and " + "final-page/no-next-page proof before claiming complete scan" + if reasons + else "proceed" + ), + } \ No newline at end of file diff --git a/tests/test_llm_workflow_split.py b/tests/test_llm_workflow_split.py index 255b8f0..f7e7a8e 100644 --- a/tests/test_llm_workflow_split.py +++ b/tests/test_llm_workflow_split.py @@ -201,6 +201,12 @@ def test_validation_worktree_edit_verifier_exported(): assert callable(assess_validation_worktree_edit_report) +def test_reconcile_inventory_verifier_exported(): + from review_proofs import assess_reconcile_inventory_report + + assert callable(assess_reconcile_inventory_report) + + def test_reconcile_linked_issue_verifier_exported(): from review_proofs import assess_reconcile_linked_issue_report diff --git a/tests/test_reviewer_reconcile_inventory.py b/tests/test_reviewer_reconcile_inventory.py new file mode 100644 index 0000000..7dfb6b9 --- /dev/null +++ b/tests/test_reviewer_reconcile_inventory.py @@ -0,0 +1,102 @@ +"""Tests for reconciliation inventory pagination verifier (#308).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from reviewer_reconcile_inventory import assess_reconcile_inventory_report # noqa: E402 + + +def _good_report() -> str: + return "\n".join([ + "## Already-landed reconciliation", + "Open PR inventory:", + "- Requested page size: 50", + "- Pages fetched: 2", + "- Returned PR count per page: page1=50, page2=6", + "- Inventory pagination proof: is_final_page: true, has_more: false", + "All already-landed PRs in the scanned open queue were found.", + "", + "## Controller Handoff", + "- Selected PR: #278", + "- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED", + ]) + + +class TestReconcileInventoryPagination(unittest.TestCase): + def test_complete_metadata_passes(self): + result = assess_reconcile_inventory_report(_good_report()) + self.assertTrue(result["proven"], result["reasons"]) + self.assertTrue(result["pagination_proven"]) + + def test_no_inventory_claim_skips_checks(self): + report = "## Controller Handoff\n- Selected PR: #278" + result = assess_reconcile_inventory_report(report) + self.assertTrue(result["proven"]) + self.assertFalse(result["inventory_claimed"]) + + def test_first_page_below_limit_with_finality_passes(self): + report = "\n".join([ + "Open PRs listed for reconciliation.", + "- Requested page size: 50", + "- Pages fetched: 1", + "- Returned PR count per page: page1=6", + "- Inventory pagination proof: is_final_page: true", + ]) + result = assess_reconcile_inventory_report(report) + self.assertTrue(result["proven"], result["reasons"]) + + def test_exactly_full_first_page_without_finality_blocks(self): + report = "\n".join([ + "Complete queue scan of open PRs.", + "- Requested page size: 50", + "- Pages fetched: 1", + "- Returned PR count per page: page1=50", + "Listed 50 open PRs on the first page.", + ]) + result = assess_reconcile_inventory_report( + report, + inventory_session={"requested_page_size": 50, "returned_page_size": 50}, + ) + self.assertFalse(result["proven"]) + self.assertTrue(any("full first" in r.lower() for r in result["reasons"])) + + def test_missing_pagination_metadata_blocks(self): + report = "All open PRs were inventoried. Inventory is complete." + result = assess_reconcile_inventory_report(report) + self.assertFalse(result["proven"]) + self.assertTrue(any("metadata" in r.lower() for r in result["reasons"])) + + def test_default_page_size_assumption_blocks(self): + report = ( + "Exhaustive PR inventory: fewer than the default Gitea page size returned." + ) + result = assess_reconcile_inventory_report(report) + self.assertFalse(result["proven"]) + self.assertTrue(any("page-size" in r.lower() or "page size" in r.lower() + for r in result["reasons"])) + + def test_session_pagination_complete_passes(self): + report = "All already-landed PRs were found after scanning open PRs." + result = assess_reconcile_inventory_report( + report, + inventory_session={ + "pagination_complete": True, + "requested_page_size": 50, + "pages_fetched": 2, + "returned_per_page": [50, 3], + }, + ) + self.assertTrue(result["proven"], result["reasons"]) + + +class TestExport(unittest.TestCase): + def test_review_proofs_reexport(self): + from review_proofs import assess_reconcile_inventory_report as exported + + self.assertTrue(callable(exported)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file