open-code-review-toolkit 0.4.2__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/PKG-INFO +2 -2
  2. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/README.md +1 -1
  3. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/_version.py +2 -2
  4. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/posting/formatting.py +15 -2
  5. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/posting/workflow.py +108 -33
  6. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/preflight.py +1 -1
  7. open_code_review_toolkit-0.4.3/src/ocr_toolkit/result_contract.py +271 -0
  8. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/review_runner.py +25 -23
  9. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/.gitignore +0 -0
  10. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/LICENSE +0 -0
  11. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/pyproject.toml +0 -0
  12. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/__init__.py +0 -0
  13. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/cli.py +0 -0
  14. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/common/__init__.py +0 -0
  15. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/common/git.py +0 -0
  16. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/common/language.py +0 -0
  17. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/common/markdown.py +0 -0
  18. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/common/redaction.py +0 -0
  19. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/config_writer.py +0 -0
  20. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/configure.py +0 -0
  21. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/__init__.py +0 -0
  22. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/__main__.py +0 -0
  23. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/ansible.py +0 -0
  24. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/ansible_requirements.py +0 -0
  25. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/artifacts.py +0 -0
  26. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/categorize.py +0 -0
  27. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/collect.py +0 -0
  28. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/collectors.py +0 -0
  29. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/composer_manifests.py +0 -0
  30. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/go_manifests.py +0 -0
  31. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/infrastructure.py +0 -0
  32. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/invocation.py +0 -0
  33. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/javascript_manifests.py +0 -0
  34. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/manifest_model.py +0 -0
  35. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/mcp.py +0 -0
  36. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/model.py +0 -0
  37. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/project.py +0 -0
  38. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/python_manifests.py +0 -0
  39. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/repository.py +0 -0
  40. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/evidence/store.py +0 -0
  41. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/mcp_config.py +0 -0
  42. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/ocr_result.py +0 -0
  43. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/posting/__init__.py +0 -0
  44. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/posting/__main__.py +0 -0
  45. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/posting/comments.py +0 -0
  46. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/posting/gitlab.py +0 -0
  47. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/posting/markers.py +0 -0
  48. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/posting/payloads.py +0 -0
  49. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/posting/result.py +0 -0
  50. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/posting/settings.py +0 -0
  51. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/posting/snapshot.py +0 -0
  52. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/providers/__init__.py +0 -0
  53. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/providers/gitlab.py +0 -0
  54. {open_code_review_toolkit-0.4.2 → open_code_review_toolkit-0.4.3}/src/ocr_toolkit/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-code-review-toolkit
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Unofficial GitLab CI integration layer for Open Code Review
5
5
  Project-URL: Homepage, https://github.com/xeonvs/open-code-review-toolkit
6
6
  Project-URL: Repository, https://github.com/xeonvs/open-code-review-toolkit
@@ -241,7 +241,7 @@ ocr --version
241
241
  ocr-ci --help
242
242
  ```
243
243
 
244
- The current compatibility target is OCR `1.8.3`. CI should pin the release and verify its published checksum before execution.
244
+ The current compatibility target is OCR `1.8.6`. CI should pin the release and verify its published checksum before execution.
245
245
  The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases.
246
246
  Review output defaults to English. `OCR_REVIEW_LANGUAGE` accepts another explicit language name when a project needs localized review output; for example, `OCR_REVIEW_LANGUAGE=Russian`.
247
247
 
@@ -15,7 +15,7 @@ ocr --version
15
15
  ocr-ci --help
16
16
  ```
17
17
 
18
- The current compatibility target is OCR `1.8.3`. CI should pin the release and verify its published checksum before execution.
18
+ The current compatibility target is OCR `1.8.6`. CI should pin the release and verify its published checksum before execution.
19
19
  The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases.
20
20
  Review output defaults to English. `OCR_REVIEW_LANGUAGE` accepts another explicit language name when a project needs localized review output; for example, `OCR_REVIEW_LANGUAGE=Russian`.
21
21
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.2'
22
- __version_tuple__ = version_tuple = (0, 4, 2)
21
+ __version__ = version = '0.4.3'
22
+ __version_tuple__ = version_tuple = (0, 4, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -689,6 +689,7 @@ def format_reviewer_guide(
689
689
  omitted_count: int,
690
690
  *,
691
691
  outcome_status: str = "success",
692
+ coverage_summary: str = "",
692
693
  ) -> str:
693
694
  """Build a bounded reviewer guide from already published OCR findings."""
694
695
 
@@ -715,10 +716,12 @@ def format_reviewer_guide(
715
716
  f"- Estimated effort to review: {effort}/5",
716
717
  ]
717
718
  )
718
- if outcome_status == "budget_exceeded":
719
+ if outcome_status in {"budget_exceeded", "partial"}:
719
720
  lines.append(
720
- "- ⚠️ Review scope: OCR reached its token budget; treat all findings as a partial review."
721
+ "- ⚠️ Review scope: OCR reported partial coverage; treat all findings as a partial review."
721
722
  )
723
+ if coverage_summary:
724
+ lines.append(f"- Review coverage: {coverage_summary}")
722
725
 
723
726
  if not security_comments and omitted_count:
724
727
  lines.append(
@@ -771,6 +774,7 @@ def summarize_result(
771
774
  mr_head_sha: str = "",
772
775
  outcome_status: str = "success",
773
776
  outcome_message: str = "",
777
+ coverage_summary: str = "",
774
778
  emoji: bool | None = None,
775
779
  ) -> str:
776
780
  """Build a compact summary note for the MR."""
@@ -782,6 +786,10 @@ def summarize_result(
782
786
  "completed_with_warnings": "⚠️",
783
787
  "completed_with_errors": "❌",
784
788
  "budget_exceeded": "⚠️",
789
+ "clean": "✅",
790
+ "warning": "⚠️",
791
+ "partial": "⚠️",
792
+ "failed": "❌",
785
793
  }
786
794
  marker = f"{status_markers.get(outcome_status, '❌')} " if use_emoji else ""
787
795
  safe_message = neutralize_quick_actions(
@@ -790,6 +798,8 @@ def summarize_result(
790
798
  if not safe_message:
791
799
  if outcome_status == "budget_exceeded":
792
800
  safe_message = "Review stopped after reaching its token budget; findings are partial."
801
+ elif outcome_status == "partial":
802
+ safe_message = "Review completed with partial coverage; findings are incomplete."
793
803
  else:
794
804
  safe_message = f"Found {total} issue(s)." if total else "No issues found."
795
805
  lines = [
@@ -808,6 +818,9 @@ def summarize_result(
808
818
  if mr_head_sha and mr_head_sha != reviewed_sha:
809
819
  lines.append(f"- MR head SHA: {_inline_code(mr_head_sha)}")
810
820
 
821
+ if coverage_summary and not reviewer_guide:
822
+ lines.append(f"- {coverage_summary}")
823
+
811
824
  severity_counts = format_metadata_counts(comments, "severity", OCR_FINDING_SEVERITY_ORDER)
812
825
  if severity_counts:
813
826
  lines.append(f"- severity tags: {severity_counts}")
@@ -56,6 +56,7 @@ from ocr_toolkit.posting.snapshot import (
56
56
  publish_failure_exit,
57
57
  rollback_current_run_comments,
58
58
  )
59
+ from ocr_toolkit.result_contract import OcrResultContractError, ReviewOutcome, parse_result_outcome
59
60
 
60
61
  # Kept as a module-level compatibility seam for tests and external monkey-patching.
61
62
  post_review_note = gitlab_api.post_review_note
@@ -386,31 +387,16 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int:
386
387
  tool_calls_summary = format_tool_calls_summary(result.get("tool_calls"))
387
388
  mcp_usage_summary = format_mcp_usage_summary(result.get(TOOLKIT_RESULT_KEY))
388
389
  token_usage_summary = format_token_usage_summary(result)
389
- status = clean_text(result.get("status")) or "success"
390
- allowed_statuses = {
391
- "success",
392
- "skipped",
393
- "completed_with_warnings",
394
- "completed_with_errors",
395
- "budget_exceeded",
396
- }
397
- if status not in allowed_statuses:
398
- return invalid_ocr_schema_exit(config, "field 'status' is unsupported")
390
+ try:
391
+ outcome = parse_result_outcome(result)
392
+ except OcrResultContractError as exc:
393
+ return invalid_ocr_schema_exit(config, str(exc))
399
394
  outcome_message = clean_text(result.get("message"))
400
395
 
401
396
  if not isinstance(comments_value, list):
402
397
  return invalid_ocr_schema_exit(config, "field 'comments' must be a list")
403
398
  if not isinstance(warnings_value, list):
404
399
  return invalid_ocr_schema_exit(config, "field 'warnings' must be a list")
405
- summary_value = result.get("summary")
406
- summary_budget_exceeded = (
407
- isinstance(summary_value, dict) and summary_value.get("budget_exceeded") is True
408
- )
409
- if (status == "budget_exceeded") != summary_budget_exceeded:
410
- return invalid_ocr_schema_exit(
411
- config, "fields 'status' and 'summary.budget_exceeded' disagree"
412
- )
413
-
414
400
  comments: list[dict[str, Any]] = []
415
401
  for index, comment in enumerate(comments_value):
416
402
  if not isinstance(comment, dict):
@@ -418,6 +404,17 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int:
418
404
  comments.append(comment)
419
405
 
420
406
  warnings = warnings_value
407
+ if outcome.kind == "failed":
408
+ return post_manifest_failure(
409
+ config,
410
+ outcome,
411
+ outcome_message,
412
+ warnings,
413
+ tool_calls_summary=tool_calls_summary,
414
+ mcp_usage_summary=mcp_usage_summary,
415
+ token_usage_summary=token_usage_summary,
416
+ )
417
+
421
418
  billing_warnings = llm_billing_failure_warnings(warnings)
422
419
  if billing_warnings:
423
420
  print(
@@ -464,7 +461,12 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int:
464
461
  comments = comments[:publish_limit]
465
462
 
466
463
  emoji = post_emoji()
467
- reviewer_guide = format_reviewer_guide(comments, omitted_count, outcome_status=status)
464
+ reviewer_guide = format_reviewer_guide(
465
+ comments,
466
+ omitted_count,
467
+ outcome_status=outcome.kind,
468
+ coverage_summary=outcome.coverage_summary,
469
+ )
468
470
 
469
471
  if publishable_comment_count == 0:
470
472
  raw_message = clean_text(result.get("message"))
@@ -477,21 +479,20 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int:
477
479
  )
478
480
  message = neutralize_quick_actions(redact_sensitive(raw_message))
479
481
  marker = {
480
- "success": "✅",
482
+ "clean": "✅",
483
+ "warning": "⚠️",
484
+ "partial": "⚠️",
481
485
  "skipped": "ℹ️", # noqa: RUF001 - intentional information emoji
482
- "completed_with_warnings": "⚠️",
483
- "completed_with_errors": "❌",
484
- "budget_exceeded": "⚠️",
485
- }[status]
486
+ }[outcome.kind]
486
487
  fallback_message = (
487
488
  "No review comments generated. No issues found."
488
- if status == "success"
489
+ if outcome.kind == "clean"
489
490
  else (
490
491
  "No supported files changed."
491
- if status == "skipped"
492
+ if outcome.kind == "skipped"
492
493
  else (
493
494
  "Review stopped after reaching its token budget; this is a partial result."
494
- if status == "budget_exceeded"
495
+ if outcome.budget_exceeded
495
496
  else "Review did not complete cleanly."
496
497
  )
497
498
  )
@@ -515,6 +516,8 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int:
515
516
  body += f"\n{mcp_usage_summary}"
516
517
  if token_usage_summary:
517
518
  body += f"\n{token_usage_summary}"
519
+ if outcome.coverage_summary:
520
+ body += f"\n{outcome.coverage_summary}"
518
521
  if reviewer_guide:
519
522
  body += f"\n\n{reviewer_guide}"
520
523
  response = post_review_note_bounded(
@@ -528,8 +531,7 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int:
528
531
  return posting_failure_exit(config, previous_bot_comment_refs, draft_note_ids)
529
532
  if not finalize_posting(config, draft_note_ids):
530
533
  return publish_failure_exit(config, draft_note_ids)
531
- delete_previous_bot_comments_if_collected(config, previous_bot_comment_refs)
532
- resolve_requested_discussions(config, previous_bot_comment_refs)
534
+ finalize_previous_review_state(config, previous_bot_comment_refs, outcome)
533
535
  return 0
534
536
 
535
537
  refs = get_diff_refs(config)
@@ -665,8 +667,9 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int:
665
667
  fallback_reasons=fallback_reasons,
666
668
  reviewed_sha=reviewed_sha(),
667
669
  mr_head_sha=mr_head_sha(),
668
- outcome_status=status,
670
+ outcome_status=outcome.kind,
669
671
  outcome_message=outcome_message,
672
+ coverage_summary=outcome.coverage_summary,
670
673
  emoji=emoji,
671
674
  ),
672
675
  draft_note_ids,
@@ -679,8 +682,7 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int:
679
682
  if not finalize_posting(config, draft_note_ids):
680
683
  return publish_failure_exit(config, draft_note_ids)
681
684
 
682
- delete_previous_bot_comments_if_collected(config, previous_bot_comment_refs)
683
- resolve_requested_discussions(config, previous_bot_comment_refs)
685
+ finalize_previous_review_state(config, previous_bot_comment_refs, outcome)
684
686
 
685
687
  print(
686
688
  f"Posted OCR comments: mode={post_mode()}, inline={inline_count}, "
@@ -690,6 +692,20 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int:
690
692
  return 0
691
693
 
692
694
 
695
+ def finalize_previous_review_state(
696
+ config: GitLabConfig,
697
+ previous_refs: BotCommentRefs,
698
+ outcome: ReviewOutcome,
699
+ ) -> None:
700
+ """Replace prior notes only after a complete outcome; preserve them for partial coverage."""
701
+
702
+ if outcome.kind == "partial":
703
+ print("OCR coverage is partial; preserving previous review comments until a complete run.")
704
+ else:
705
+ delete_previous_bot_comments_if_collected(config, previous_refs)
706
+ resolve_requested_discussions(config, previous_refs)
707
+
708
+
693
709
  def resolve_requested_discussions(
694
710
  config: GitLabConfig, previous_refs: BotCommentRefs | None
695
711
  ) -> None:
@@ -737,6 +753,65 @@ def invalid_ocr_schema_exit(
737
753
  return 1 if strict_posting() else 0
738
754
 
739
755
 
756
+ def post_manifest_failure(
757
+ config: GitLabConfig,
758
+ outcome: ReviewOutcome,
759
+ message: str,
760
+ warnings: Sequence[Any],
761
+ *,
762
+ tool_calls_summary: str = "",
763
+ mcp_usage_summary: str = "",
764
+ token_usage_summary: str = "",
765
+ ) -> int:
766
+ """Post a manifest-declared run failure while preserving prior review notes."""
767
+
768
+ draft_note_ids: list[int] = []
769
+ safe_message = compact_escaped_text(
770
+ neutralize_quick_actions(redact_sensitive(message)),
771
+ 1_200,
772
+ )
773
+ body_parts = [
774
+ "OCR reported that the review run failed before it could produce a complete result.",
775
+ "",
776
+ "- Normal review comments were not published.",
777
+ "- Previous OCR review comments were preserved.",
778
+ ]
779
+ if safe_message:
780
+ body_parts.append(f"- Outcome: {safe_message}")
781
+ if outcome.coverage_summary:
782
+ body_parts.append(f"- {outcome.coverage_summary}")
783
+
784
+ warning_items: list[str] = []
785
+ for warning in warnings[:10]:
786
+ safe_warning = compact_escaped_text(
787
+ neutralize_quick_actions(redact_sensitive(ocr_warning_text(warning))),
788
+ 1_200,
789
+ )
790
+ if safe_warning:
791
+ warning_items.append(f"- {safe_warning}")
792
+ if warning_items:
793
+ body_parts.extend(["", "**Warnings:**", *warning_items])
794
+ if tool_calls_summary:
795
+ body_parts.extend(["", tool_calls_summary])
796
+ if mcp_usage_summary:
797
+ body_parts.append(mcp_usage_summary)
798
+ if token_usage_summary:
799
+ body_parts.append(token_usage_summary)
800
+
801
+ response = post_review_note_bounded(
802
+ config,
803
+ "**Open Code Review failed result**",
804
+ "\n".join(body_parts),
805
+ draft_note_ids,
806
+ )
807
+ if response is None:
808
+ print("Failed to create OCR manifest-failure note.", file=sys.stderr)
809
+ return posting_failure_exit(config, None, draft_note_ids)
810
+ if not finalize_posting(config, draft_note_ids):
811
+ return publish_failure_exit(config, draft_note_ids)
812
+ return 1 if strict_posting() else 0
813
+
814
+
740
815
  def post_llm_provider_failure(
741
816
  config: GitLabConfig,
742
817
  warnings: Sequence[str],
@@ -24,7 +24,7 @@ DEFAULT_REQUEST_HEADERS = {
24
24
  "Accept": "application/json",
25
25
  "User-Agent": "open-code-review-ci-preflight/1.0",
26
26
  }
27
- EXPECTED_OCR_VERSION = "1.8.3"
27
+ EXPECTED_OCR_VERSION = "1.8.6"
28
28
 
29
29
 
30
30
  class PreflightError(Exception):
@@ -0,0 +1,271 @@
1
+ """Validate and normalize versioned Open Code Review result outcomes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any, Literal
8
+
9
+ OutcomeKind = Literal["clean", "warning", "partial", "failed", "skipped"]
10
+
11
+ MANIFEST_SCHEMA = "ocr.run-manifest/v1"
12
+ MAX_COVERAGE_RECORDS = 10_000
13
+ MAX_ITEM_ID_CHARS = 256
14
+ FAILURE_CLASSES = {
15
+ "provider",
16
+ "timeout",
17
+ "cancelled",
18
+ "configuration",
19
+ "input",
20
+ "budget",
21
+ "panic",
22
+ "unknown",
23
+ }
24
+ RUN_FAILURE_CLASSES = {
25
+ "input",
26
+ "configuration",
27
+ "timeout",
28
+ "cancelled",
29
+ "budget",
30
+ "internal",
31
+ "unknown",
32
+ }
33
+ LEGACY_OUTCOMES: dict[str, OutcomeKind] = {
34
+ "success": "clean",
35
+ "completed_with_warnings": "warning",
36
+ "completed_with_errors": "partial",
37
+ "budget_exceeded": "partial",
38
+ "skipped": "skipped",
39
+ }
40
+ MANIFEST_OUTCOMES: dict[str, OutcomeKind] = {
41
+ "complete": "clean",
42
+ "partial": "partial",
43
+ "failed": "failed",
44
+ "skipped": "skipped",
45
+ }
46
+
47
+
48
+ class OcrResultContractError(ValueError):
49
+ """An OCR result contradicts the supported legacy or manifest contract."""
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class ReviewOutcome:
54
+ """Expose one validated OCR outcome without provider-specific branching."""
55
+
56
+ status: str
57
+ kind: OutcomeKind
58
+ budget_exceeded: bool
59
+ manifest_present: bool = False
60
+ selected_count: int = 0
61
+ completed_count: int = 0
62
+ reused_count: int = 0
63
+ failed_count: int = 0
64
+ waived_count: int = 0
65
+
66
+ @property
67
+ def requires_evidence_mcp(self) -> bool:
68
+ """Return whether the result represents review work that must use evidence."""
69
+
70
+ return self.kind in {"clean", "warning", "partial"}
71
+
72
+ @property
73
+ def coverage_summary(self) -> str:
74
+ """Return a path-free summary of versioned manifest coverage."""
75
+
76
+ if not self.manifest_present:
77
+ return ""
78
+ return (
79
+ f"Coverage: selected {self.selected_count}; completed {self.completed_count}; "
80
+ f"reused {self.reused_count}; failed {self.failed_count}; "
81
+ f"waived {self.waived_count}."
82
+ )
83
+
84
+
85
+ def _budget_exceeded(result: Mapping[str, Any]) -> bool:
86
+ """Read the optional summary budget flag without accepting truthy substitutes."""
87
+
88
+ summary = result.get("summary")
89
+ if summary is None:
90
+ return False
91
+ if not isinstance(summary, Mapping):
92
+ raise OcrResultContractError("field 'summary' must be an object")
93
+ value = summary.get("budget_exceeded", False)
94
+ if not isinstance(value, bool):
95
+ raise OcrResultContractError("field 'summary.budget_exceeded' must be a boolean")
96
+ return value
97
+
98
+
99
+ def _coverage_ids(value: Any, field: str) -> tuple[set[str], list[Mapping[str, Any]]]:
100
+ """Return bounded unique item identities from one coverage array."""
101
+
102
+ if not isinstance(value, list):
103
+ raise OcrResultContractError(f"field 'manifest.coverage.{field}' must be a list")
104
+ if len(value) > MAX_COVERAGE_RECORDS:
105
+ raise OcrResultContractError(
106
+ f"field 'manifest.coverage.{field}' exceeds {MAX_COVERAGE_RECORDS} records"
107
+ )
108
+ identities: set[str] = set()
109
+ records: list[Mapping[str, Any]] = []
110
+ for index, record in enumerate(value):
111
+ if not isinstance(record, Mapping):
112
+ raise OcrResultContractError(
113
+ f"field 'manifest.coverage.{field}[{index}]' must be an object"
114
+ )
115
+ item_id = record.get("item_id")
116
+ if (
117
+ not isinstance(item_id, str)
118
+ or not item_id
119
+ or len(item_id) > MAX_ITEM_ID_CHARS
120
+ or any(character.isspace() for character in item_id)
121
+ ):
122
+ raise OcrResultContractError(
123
+ f"field 'manifest.coverage.{field}[{index}].item_id' is invalid"
124
+ )
125
+ if item_id in identities:
126
+ raise OcrResultContractError(
127
+ f"field 'manifest.coverage.{field}' contains duplicate item_id values"
128
+ )
129
+ identities.add(item_id)
130
+ records.append(record)
131
+ return identities, records
132
+
133
+
134
+ def _validate_failure_records(records: list[Mapping[str, Any]]) -> bool:
135
+ """Validate item failure classes and report whether budget caused one."""
136
+
137
+ budget_failure = False
138
+ for index, record in enumerate(records):
139
+ classification = record.get("classification")
140
+ if classification not in FAILURE_CLASSES:
141
+ raise OcrResultContractError(
142
+ f"field 'manifest.coverage.failed[{index}].classification' is unsupported"
143
+ )
144
+ budget_failure = budget_failure or classification == "budget"
145
+ return budget_failure
146
+
147
+
148
+ def _validate_waived_records(records: list[Mapping[str, Any]]) -> None:
149
+ """Require an explicit bounded reason for every waived item."""
150
+
151
+ for index, record in enumerate(records):
152
+ reason = record.get("reason")
153
+ if not isinstance(reason, str) or not reason.strip() or len(reason) > 2_000:
154
+ raise OcrResultContractError(
155
+ f"field 'manifest.coverage.waived[{index}].reason' is invalid"
156
+ )
157
+
158
+
159
+ def _manifest_outcome(
160
+ status: str, manifest: Mapping[str, Any], budget_exceeded: bool
161
+ ) -> ReviewOutcome:
162
+ """Validate the v1 coverage partition and derive its terminal outcome."""
163
+
164
+ if manifest.get("schema_version") != MANIFEST_SCHEMA:
165
+ raise OcrResultContractError("field 'manifest.schema_version' is unsupported")
166
+ if manifest.get("operation") != "review":
167
+ raise OcrResultContractError("field 'manifest.operation' must be 'review'")
168
+ terminal_state = manifest.get("terminal_state")
169
+ if terminal_state not in MANIFEST_OUTCOMES:
170
+ raise OcrResultContractError("field 'manifest.terminal_state' is unsupported")
171
+ if status != terminal_state:
172
+ raise OcrResultContractError("fields 'status' and 'manifest.terminal_state' disagree")
173
+
174
+ coverage = manifest.get("coverage")
175
+ if not isinstance(coverage, Mapping):
176
+ raise OcrResultContractError("field 'manifest.coverage' must be an object")
177
+
178
+ selected, _selected_records = _coverage_ids(coverage.get("selected"), "selected")
179
+ completed, _completed_records = _coverage_ids(coverage.get("completed"), "completed")
180
+ reused, _reused_records = _coverage_ids(coverage.get("reused"), "reused")
181
+ failed, failed_records = _coverage_ids(coverage.get("failed"), "failed")
182
+ waived, waived_records = _coverage_ids(coverage.get("waived"), "waived")
183
+
184
+ terminal_sets = (completed, reused, failed, waived)
185
+ terminal_union: set[str] = set()
186
+ for terminal_set in terminal_sets:
187
+ if terminal_union.intersection(terminal_set):
188
+ raise OcrResultContractError("manifest coverage terminal sets are not disjoint")
189
+ terminal_union.update(terminal_set)
190
+ if terminal_union != selected:
191
+ raise OcrResultContractError(
192
+ "manifest coverage selected items do not equal the terminal partition"
193
+ )
194
+
195
+ budget_failure = _validate_failure_records(failed_records)
196
+ _validate_waived_records(waived_records)
197
+
198
+ run_failure = manifest.get("run_failure")
199
+ if run_failure is not None:
200
+ if not isinstance(run_failure, Mapping):
201
+ raise OcrResultContractError("field 'manifest.run_failure' must be an object")
202
+ if run_failure.get("classification") not in RUN_FAILURE_CLASSES:
203
+ raise OcrResultContractError(
204
+ "field 'manifest.run_failure.classification' is unsupported"
205
+ )
206
+
207
+ derived_state = (
208
+ "failed"
209
+ if run_failure is not None
210
+ else "skipped"
211
+ if not selected
212
+ else "complete"
213
+ if not failed
214
+ else "failed"
215
+ if failed == selected
216
+ else "partial"
217
+ )
218
+ if terminal_state != derived_state:
219
+ raise OcrResultContractError(
220
+ "field 'manifest.terminal_state' disagrees with coverage and run_failure"
221
+ )
222
+ if budget_exceeded and not (
223
+ budget_failure
224
+ or (isinstance(run_failure, Mapping) and run_failure.get("classification") == "budget")
225
+ ):
226
+ raise OcrResultContractError(
227
+ "field 'summary.budget_exceeded' has no matching manifest budget failure"
228
+ )
229
+ if terminal_state in {"complete", "skipped"} and budget_exceeded:
230
+ raise OcrResultContractError(
231
+ "complete or skipped manifest cannot report summary.budget_exceeded"
232
+ )
233
+
234
+ return ReviewOutcome(
235
+ status=status,
236
+ kind=MANIFEST_OUTCOMES[status],
237
+ budget_exceeded=budget_exceeded,
238
+ manifest_present=True,
239
+ selected_count=len(selected),
240
+ completed_count=len(completed),
241
+ reused_count=len(reused),
242
+ failed_count=len(failed),
243
+ waived_count=len(waived),
244
+ )
245
+
246
+
247
+ def parse_result_outcome(result: Mapping[str, Any]) -> ReviewOutcome:
248
+ """Validate one OCR result and normalize legacy and manifest outcomes."""
249
+
250
+ status_value = result.get("status", "success")
251
+ if not isinstance(status_value, str) or not status_value:
252
+ raise OcrResultContractError("field 'status' must be a non-empty string")
253
+ budget_exceeded = _budget_exceeded(result)
254
+ manifest = result.get("manifest")
255
+
256
+ if manifest is not None:
257
+ if not isinstance(manifest, Mapping):
258
+ raise OcrResultContractError("field 'manifest' must be an object")
259
+ return _manifest_outcome(status_value, manifest, budget_exceeded)
260
+
261
+ if status_value not in LEGACY_OUTCOMES:
262
+ if status_value in MANIFEST_OUTCOMES:
263
+ raise OcrResultContractError(f"status {status_value!r} requires a supported manifest")
264
+ raise OcrResultContractError("field 'status' is unsupported")
265
+ if (status_value == "budget_exceeded") != budget_exceeded:
266
+ raise OcrResultContractError("fields 'status' and 'summary.budget_exceeded' disagree")
267
+ return ReviewOutcome(
268
+ status=status_value,
269
+ kind=LEGACY_OUTCOMES[status_value],
270
+ budget_exceeded=budget_exceeded,
271
+ )
@@ -32,6 +32,7 @@ from ocr_toolkit.ocr_result import (
32
32
  attach_toolkit_metadata,
33
33
  )
34
34
  from ocr_toolkit.providers.gitlab import invocation_identifiers
35
+ from ocr_toolkit.result_contract import OcrResultContractError, parse_result_outcome
35
36
 
36
37
  STDERR_PROBE_BYTES = 64 * 1024
37
38
  DEFAULT_DIAGNOSTIC_CHARS = 4_000
@@ -83,28 +84,29 @@ def _mcp_usage_receipt(
83
84
  ) -> dict[str, object]:
84
85
  """Return bounded MCP usage tied to one validated review-time registry."""
85
86
 
86
- status = payload.get("status")
87
- if status not in {
88
- "success",
89
- "completed_with_warnings",
90
- "completed_with_errors",
91
- "budget_exceeded",
92
- }:
93
- if status == "skipped":
94
- tool_calls = payload.get("tool_calls")
95
- by_tool = tool_calls.get("by_tool") if isinstance(tool_calls, dict) else None
96
- if (
97
- payload.get("message") != "No supported files changed."
98
- or payload.get("comments") != []
99
- or not isinstance(tool_calls, dict)
100
- or tool_calls.get("total") != 0
101
- or by_tool != {}
102
- ):
103
- raise ReviewRunnerError(
104
- "OCR skipped result does not match the pinned no-supported-files contract"
105
- )
106
- return {"mcp_usage": {}}
107
- raise ReviewRunnerError("OCR result has an unsupported status")
87
+ try:
88
+ outcome = parse_result_outcome(payload)
89
+ except OcrResultContractError as exc:
90
+ raise ReviewRunnerError(f"OCR result has an unsupported outcome contract: {exc}") from exc
91
+
92
+ if outcome.kind == "skipped":
93
+ tool_calls = payload.get("tool_calls")
94
+ by_tool = tool_calls.get("by_tool") if isinstance(tool_calls, dict) else None
95
+ legacy_message_invalid = (
96
+ not outcome.manifest_present and payload.get("message") != "No supported files changed."
97
+ )
98
+ if (
99
+ legacy_message_invalid
100
+ or payload.get("comments") != []
101
+ or not isinstance(tool_calls, dict)
102
+ or tool_calls.get("total") != 0
103
+ or by_tool != {}
104
+ ):
105
+ raise ReviewRunnerError(
106
+ "OCR skipped result does not match the pinned no-supported-files contract"
107
+ )
108
+ return {"mcp_usage": {}}
109
+
108
110
  tool_calls = payload.get("tool_calls")
109
111
  by_tool = tool_calls.get("by_tool") if isinstance(tool_calls, dict) else None
110
112
  owners = {
@@ -124,7 +126,7 @@ def _mcp_usage_receipt(
124
126
  ):
125
127
  owner = owners[tool]
126
128
  usage[owner] = usage.get(owner, 0) + count
127
- if usage.get(mcp_config.BUILTIN_EVIDENCE_SERVER, 0) <= 0:
129
+ if outcome.requires_evidence_mcp and usage.get(mcp_config.BUILTIN_EVIDENCE_SERVER, 0) <= 0:
128
130
  raise ReviewRunnerError(f"OCR review did not call the mandatory {TOOL_NAME} tool")
129
131
  return {"mcp_usage": dict(sorted(usage.items()))}
130
132