From 6e774e191a1ef570031db78b73204916d979c1c0 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 09:08:38 -0400 Subject: [PATCH] feat: require pagination proof in reconciliation PR inventory (#308) Add assess_reconcile_inventory_report verifier to block complete queue scan and all-already-landed claims without final-page metadata, requested page size, pages fetched, per-page counts, and no-next-page proof. Wire into build_final_report and export from review_proofs. Co-Authored-By: Claude Opus 4.8 (1M context) --- review_proofs.py | 27 ++++ reviewer_reconcile_inventory.py | 162 +++++++++++++++++++++ tests/test_llm_workflow_split.py | 6 + tests/test_reviewer_reconcile_inventory.py | 102 +++++++++++++ 4 files changed, 297 insertions(+) create mode 100644 reviewer_reconcile_inventory.py create mode 100644 tests/test_reviewer_reconcile_inventory.py diff --git a/review_proofs.py b/review_proofs.py index fdebea9..62344f0 100644 --- a/review_proofs.py +++ b/review_proofs.py @@ -1476,6 +1476,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": []} + ) if baseline_validation is None and report_text: baseline_validation = assess_reviewer_baseline_validation_proof( report_text=report_text, @@ -1632,6 +1637,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 baseline_validation.get("proven"): downgrade_reasons.append( "reviewer baseline validation proof missing or failed (#325)" @@ -1717,6 +1727,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 [] + ), "baseline_validation_proven": bool(baseline_validation.get("proven")), "baseline_validation_violations": list( baseline_validation.get("violations") or [] @@ -3753,6 +3767,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, +) + _GATE_PASSED_VALUE = re.compile(r"\bpassed\b", re.I) _NOT_APPLICABLE_VALUE = re.compile( @@ -4142,3 +4162,10 @@ def assess_non_mergeable_skip_proof(report_text, **kwargs): from reviewer_mergeability_skip import assess_non_mergeable_skip_proof as _assess 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) 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 8f208c4..cfb916a 100644 --- a/tests/test_llm_workflow_split.py +++ b/tests/test_llm_workflow_split.py @@ -146,3 +146,9 @@ def test_non_mergeable_skip_verifier_exported(): from review_proofs import assess_non_mergeable_skip_proof assert callable(assess_non_mergeable_skip_proof) + + +def test_reconcile_inventory_verifier_exported(): + from review_proofs import assess_reconcile_inventory_report + + assert callable(assess_reconcile_inventory_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 -- 2.43.7