From 4e50f780e5fee7d34aeb0d9a5d8b19539dbcadee Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:08:17 +0200 Subject: [PATCH 01/15] docs: plan standalone local review and OCR 1.11.6 delivery --- PLANS.md | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/PLANS.md b/PLANS.md index 116f44f..5bc6c1b 100644 --- a/PLANS.md +++ b/PLANS.md @@ -5,3 +5,140 @@ before handoff or commit. Completed stable plans are indexed in [the execution-history archive](docs/engineering/execution_history/README.md). ## Active Work + +### Toolkit 0.9.2 โ€” local provider, diagnostics and OCR controls + +- **Status:** active +- **Plan Origin:** plan_mode_approved +- **Release classification:** release-required; stable delivery release-deferred +- **Target stable version:** 0.9.2 +- **Branch:** `codex/v0.9.2-ocr-1.11.6` + +#### Goal + +Deliver OCR 1.11.6, a standalone local provider with shared review summaries, +truthful local debug diagnostics, explicit reasoning controls and safe progress +through a green published Draft. No merge, stable release or issue closure. + +#### Baseline And Sources + +Main `8ae890b6f78388554281e649dc8201737679968a` reconciles stable 0.9.1; +the old feature branch is complete. #181 qualifies OCR 1.11.6 against 1.11.5 +in hosted run 34127679854. Upstream #1154 is the product focus; other adjacent +changes are compatibility inputs, not separate product projects. The approved +conversation plan and subsequent provider-support/none clarification are binding. +Local delivery is tracked by #182; both issues belong to milestone v0.9.2. +Canonical owners: project principles, development/release guides, configuration, +operations, security, review decision flow, strategy and backlog. Existing local +review verifies evidence but omits GitLab receipt; preflight still assumes GitLab. + +#### Requirement Traceability + +| Requirement | Work | Verification | +| --- | --- | --- | +| R1 Shared truthful summary and independent local output | WQ-02 | GitLab parity and local outcome matrix | +| R2 Explicit local mode with no GitLab dependency | WQ-03 | installed real Git/process/MCP and hostile CI environment | +| R3 Debug observes actual checks without bypass | WQ-04 | normal/debug parity, provenance, bounds, permissions and failures | +| R4 Protocol-correct reasoning controls | WQ-05 | config/preflight conflicts and real OCR wire probes | +| R5 Privacy-independent progress | WQ-05 | on/off parity, stop/signal/sink failure and bounded output | +| R6 Exact qualified OCR and local binary | WQ-06 | checksums, adjacent audit and no-LLM qualification | +| R7 Green signed Draft and external handoff | WQ-07 | local gates, hosted exact-head checks and remote readback | + +#### Current Work Queue + +| Queue | Status | Work | +| --- | --- | --- | +| WQ-01 | in_progress | Plan and milestone recorded; #181/#182 assigned; planning push/Draft pending | +| WQ-02 | pending | Shared report model/formatting and local provider | +| WQ-03 | pending | Explicit local preflight/review and end-to-end path | +| WQ-04 | pending | Fresh private debug bundle and actual decision journal | +| WQ-05 | pending | Reasoning environment control and bounded progress | +| WQ-06 | pending | OCR promotion, assets, no-LLM qualification and local update | +| WQ-07 | pending | Docs/backlog, full self-review, gates, push and Draft handoff | + +#### Locked Interfaces And Boundaries + +- Local provider is not a fake GitLab API. Share pure outcome, finding, + coverage, warning, tool/token, verified MCP and DLP reporting. Keep GitLab + posting/suppression/approval and envelopes at its adapter; no fake receipt. + Console Markdown has no HTML disclosure or remote badges, and prints every + admitted finding without posting caps. JSON remains in --result; progress + and diagnostic output use stderr. Every failure has an honest summary. +- Add `preflight --local` and `review --local`, with required result/stderr + paths and existing immutable commit/from/to input. Ignore inherited CI + identity for local runs; never acquire or mutate GitLab. Reject requested + MR context/adapters. Use JSON/agent audience and reject contradictory options. + Preserve mandatory evidence registry/self-query/completed-summary validation; + configured external MCP cannot replace the mandatory server. No working-tree + snapshot, scan, provider profiles or new authentication framework. +- `--debug-dir PATH` requires --local and a fresh owner-only directory. + Retain bounded original OCR result/stderr, safe final result, console summary + and a structured journal. Record actual configuration, identity/refs, evidence, + MCP preflight/use, preview, subprocess, result validation, DLP, cleanup and + reporting outcomes as passed/failed/degraded/not-run, with explicit missing + artifacts and truncation. Capture real filtering reasons/actions, safe + locations, sizes and digests at their owners, not a second approximation scan. + Raw rejected content stays in private files, never automatic console output. + No environment dump or unnecessary retained session/config. Keep normal + checks and cleanup; reject combination with legacy private-artifact retention. +- Preserve existing effort/budget behavior. OCR_LLM_REASONING_EFFORT accepts + unset/empty or case-insensitive none|minimal|low|medium|high|xhigh|max. + Unset adds nothing; none is an explicit wire value, not an omission sentinel. + OpenAI uses reasoning_effort; Responses uses reasoning.effort preserving + siblings. Reject nonempty shortcut for Anthropic. Equal extra-body values + are permitted; incompatible types or conflicts fail before inference. +- OCR_REVIEW_PROGRESS is empty/false by default or true (case-insensitive). + Emit only toolkit phases and a 30-second heartbeat, at most 120 messages. + Never read OCR stderr/results/sessions for progress or change audience to + human. No tee/FIFO, no progress in result/summary/receipts/DLP. Stop promptly + on completion, exceptions and signals; progress sink errors cannot change + review outcomes or leave live heartbeat work behind. +- Preserve zero runtime dependencies, supported Python matrix, immutable Git, + DLP, MCP and provider boundaries. Keep exact pins/evidence separate from + version-neutral current documentation. BL-016/018, BL-010 and BL-021 remain + conditional: none of their independent activation criteria is satisfied. + +#### Validation And Commit Gates + +Before each logical signed commit: format changed Python, targeted tests, +complete diff/self-review, repository Ruff format check and git diff --check. +Final gate: quality with scoped coverage floors, lock, manifest/evidence, +Towncrier, package/installed wheel and sdist tests, pinned Gitleaks for tree and +complete feature history. Hosted checks bind the exact final head. + +Required matrices: same normalized GitLab/local report data; clean/findings/ +warnings/partial/budget/failure, true DLP filtering and diagnostic-only input; +progress on/off and normal/debug canonical parity; late heartbeats, signals, +broken output, hostile paths/symlinks/permissions, capture/journal limits and +early failures. Installed tests cross real Git, subprocess and stdio MCP, +include external MCP and polluted CI environment, prove no GitLab calls and +failed mandatory evidence rejection. Real checksum-verified OCR uses local +deterministic protocol peers; no live provider/model calls in this cycle. + +#### External Qualification And Closure + +Create separate local-scenario tracking and relate it to #181 and the Draft. +Update public docs, decision flow, evidence matrix, changelog and backlog. +Preserve example stable toolkit pin until the later release PR; external +qualification must install the artifact built from the exact Draft head. + +External owner verifies GitLab, local and debug workflows with configured LLM. +Reasoning acceptance is a named gate for the exact provider/model/protocol: +test unset (absent on wire), explicit none and intended nonempty effort; +check correct wire shape, provider acceptance and documented/server-observable +support. HTTP 200 alone does not prove that a gateway applied the parameter; +mark unprovable application as unverified, never infer it from model prose. +Unsupported none/effort is explicit; unset remains the safe no-overlay option. +No automatic weakening of security/DLP or fallback to a different model/value. + +#### Resume Point + +Materialize tracking and signed planning Draft, then implement WQ-02. Keep +the plan active through deferred stable delivery and record precise checkpoints. + +#### Closure Gate + +- [ ] All scoped implementation and required local verification complete. +- [ ] Full self-review and signed commits; current Draft checks green. +- [ ] Local and remote heads agree, tree clean, temporary owned data cleaned. +- [ ] External qualification checklist recorded; issues/milestone remain open. From 9d0b9991966d1a6928f7c8a70f0c065762f54011 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:35:10 +0200 Subject: [PATCH 02/15] refactor(reporting): share review facts across output adapters --- .next-version | 2 +- PLANS.md | 67 +++- changelog.d/182.refactor.md | 1 + docs/development.md | 23 ++ docs/engineering/test_evidence_matrix.md | 7 + src/ocr_toolkit/posting/comments.py | 108 ++---- src/ocr_toolkit/posting/formatting.py | 442 +++-------------------- src/ocr_toolkit/posting/result.py | 208 +---------- src/ocr_toolkit/providers/local.py | 99 +++++ src/ocr_toolkit/reporting/__init__.py | 1 + src/ocr_toolkit/reporting/dlp.py | 166 +++++++++ src/ocr_toolkit/reporting/metadata.py | 90 +++++ src/ocr_toolkit/reporting/model.py | 127 +++++++ src/ocr_toolkit/reporting/outcome.py | 86 +++++ src/ocr_toolkit/reporting/result.py | 215 +++++++++++ src/ocr_toolkit/reporting/sections.py | 82 +++++ src/ocr_toolkit/reporting/text.py | 87 +++++ src/ocr_toolkit/reporting/usage.py | 224 ++++++++++++ src/ocr_toolkit/review_receipt.py | 137 +------ tests/reporting/test_adapter_parity.py | 60 +++ tests/reporting/test_local.py | 201 +++++++++++ tests/reporting/test_outcome.py | 51 +++ 22 files changed, 1660 insertions(+), 824 deletions(-) create mode 100644 changelog.d/182.refactor.md create mode 100644 src/ocr_toolkit/providers/local.py create mode 100644 src/ocr_toolkit/reporting/__init__.py create mode 100644 src/ocr_toolkit/reporting/dlp.py create mode 100644 src/ocr_toolkit/reporting/metadata.py create mode 100644 src/ocr_toolkit/reporting/model.py create mode 100644 src/ocr_toolkit/reporting/outcome.py create mode 100644 src/ocr_toolkit/reporting/result.py create mode 100644 src/ocr_toolkit/reporting/sections.py create mode 100644 src/ocr_toolkit/reporting/text.py create mode 100644 src/ocr_toolkit/reporting/usage.py create mode 100644 tests/reporting/test_adapter_parity.py create mode 100644 tests/reporting/test_local.py create mode 100644 tests/reporting/test_outcome.py diff --git a/.next-version b/.next-version index 2003b63..78bc1ab 100644 --- a/.next-version +++ b/.next-version @@ -1 +1 @@ -0.9.2 +0.10.0 diff --git a/PLANS.md b/PLANS.md index 5bc6c1b..fef4747 100644 --- a/PLANS.md +++ b/PLANS.md @@ -6,12 +6,12 @@ before handoff or commit. Completed stable plans are indexed in ## Active Work -### Toolkit 0.9.2 โ€” local provider, diagnostics and OCR controls +### Toolkit 0.10.0 โ€” local provider, diagnostics and OCR controls - **Status:** active - **Plan Origin:** plan_mode_approved - **Release classification:** release-required; stable delivery release-deferred -- **Target stable version:** 0.9.2 +- **Target stable version:** 0.10.0 (raised from 0.9.2 by user decision) - **Branch:** `codex/v0.9.2-ocr-1.11.6` #### Goal @@ -27,7 +27,8 @@ the old feature branch is complete. #181 qualifies OCR 1.11.6 against 1.11.5 in hosted run 34127679854. Upstream #1154 is the product focus; other adjacent changes are compatibility inputs, not separate product projects. The approved conversation plan and subsequent provider-support/none clarification are binding. -Local delivery is tracked by #182; both issues belong to milestone v0.9.2. +Local delivery is tracked by #182; both issues belong to milestone v0.10.0. +Retain the existing branch name and Draft #183; no history rewrite is needed. Canonical owners: project principles, development/release guides, configuration, operations, security, review decision flow, strategy and backlog. Existing local review verifies evidence but omits GitLab receipt; preflight still assumes GitLab. @@ -48,9 +49,9 @@ review verifies evidence but omits GitLab receipt; preflight still assumes GitLa | Queue | Status | Work | | --- | --- | --- | -| WQ-01 | in_progress | Plan and milestone recorded; #181/#182 assigned; planning push/Draft pending | -| WQ-02 | pending | Shared report model/formatting and local provider | -| WQ-03 | pending | Explicit local preflight/review and end-to-end path | +| WQ-01 | done | Signed planning commit 4e50f78 pushed; Draft #183, milestone and #181/#182 recorded | +| WQ-02 | done | Shared reporting package, characterized GitLab delegation and local Markdown adapter; execution-owner facts remain separate | +| WQ-03 | in_progress | Explicit local preflight/review and end-to-end path | | WQ-04 | pending | Fresh private debug bundle and actual decision journal | | WQ-05 | pending | Reasoning environment control and bounded progress | | WQ-06 | pending | OCR promotion, assets, no-LLM qualification and local update | @@ -64,6 +65,11 @@ review verifies evidence but omits GitLab receipt; preflight still assumes GitLa Console Markdown has no HTML disclosure or remote badges, and prints every admitted finding without posting caps. JSON remains in --result; progress and diagnostic output use stderr. Every failure has an honest summary. + Keep this core reusable by a future GitHub adapter: no GitLab MR identity, + receipt, API, settings or posting imports in shared report contracts/rendering. + Review health and admitted data are common; publication state, discussion + anchors, suppression and approval remain adapter-owned. GitHub implementation, + credentials and a generic forge API framework are outside this release scope. - Add `preflight --local` and `review --local`, with required result/stderr paths and existing immutable commit/from/to input. Ignore inherited CI identity for local runs; never acquire or mutate GitLab. Reject requested @@ -98,6 +104,40 @@ review verifies evidence but omits GitLab receipt; preflight still assumes GitLa version-neutral current documentation. BL-016/018, BL-010 and BL-021 remain conditional: none of their independent activation criteria is satisfied. +#### Provider Architecture Decisions + +- Separate provider input acquisition, common review execution/report data and + provider output/actions. A provider is not merely a formatter and is not a + mandatory all-methods base class. Local supplies immutable Git identity and + console output; forge adapters additionally own authenticated API acquisition + and platform mutations. Do not implement dummy discussion or approval methods + for local execution. +- Shared report data describes the admitted review, not an MR/PR or a posting + transaction. Keep immutable repository refs distinct from optional forge + identity/context. No fabricated author, change-request ID, protection state, + discussion history or publication receipt when those inputs do not exist. +- Distinguish unsupported capabilities, an empty successful acquisition, + disabled acquisition and failed acquisition. In particular, local absence of + discussions does not mean that discussions were fetched and no commands found. + Explicit unsupported requests fail before execution; they are not successful + no-ops or inferred from inherited CI variables. +- Commands from discussions require a supported input channel, authenticated + actor/provenance and action authorization. Common parsers and decision rules + may be reusable, but provider identity, permissions and mutation guards must + not be generalized from GitLab assumptions. Local review does not accept + discussion commands from repository text or model output. +- Share pure calculations and wording; adapt delivery separately. Findings + admitted by DLP, findings selected for posting, and findings actually posted + are distinct facts. Publication limits, suppression and failed delivery must + not rewrite core review health or silently remove local findings. MCP usage + is execution evidence; a forge receipt is a separate platform-bound artifact. +- Preserve existing GitLab acquisition/publication lifecycles in this release. + Do not mechanically equate GitLab discussions/approval with future GitHub + threads/reviews or promise identical retry, transaction and race guarantees. + Future GitHub work must characterize those API boundaries before reuse. + Add dependency tests for the shared report boundary and local tests proving + unsupported channels never acquire data or perform provider writes. + #### Validation And Commit Gates Before each logical signed commit: format changed Python, targeted tests, @@ -105,6 +145,13 @@ complete diff/self-review, repository Ruff format check and git diff --check. Final gate: quality with scoped coverage floors, lock, manifest/evidence, Towncrier, package/installed wheel and sdist tests, pinned Gitleaks for tree and complete feature history. Hosted checks bind the exact final head. +Before overall final self-review, run the Codex Security diff-scan skill over +the complete immutable feature range, triage findings, fix confirmed problems +within this scope and verify remediation. Record scan coverage and unresolved +limitations; a scanner score is not a substitute for semantic self-review. +Organize shared runtime and tests by layer, preserving characterized code through +mechanical moves where appropriate. Reconcile all affected public documentation, +decision-flow diagrams, contract schemas, threat model and test-evidence matrix. Required matrices: same normalized GitLab/local report data; clean/findings/ warnings/partial/budget/failure, true DLP filtering and diagnostic-only input; @@ -133,8 +180,12 @@ No automatic weakening of security/DLP or fallback to a different model/value. #### Resume Point -Materialize tracking and signed planning Draft, then implement WQ-02. Keep -the plan active through deferred stable delivery and record precise checkpoints. +WQ-02 is implemented with 475 passing targeted tests and 283 subtests, plus two +adapter-parity tests; Ruff, formatting, mypy and diff checks passed. Mechanical +AST comparison preserves 22 extracted definitions apart from their docstrings. +The local output adapter is not yet wired to the CLI: implement WQ-03 next on +Draft #183. Keep the plan active through deferred stable delivery; remaining +debug, controls, OCR qualification and final security/hosted gates are pending. #### Closure Gate diff --git a/changelog.d/182.refactor.md b/changelog.d/182.refactor.md new file mode 100644 index 0000000..0f4d492 --- /dev/null +++ b/changelog.d/182.refactor.md @@ -0,0 +1 @@ +Separate common review outcome, coverage, finding metadata, usage and DLP reporting from GitLab publication, preserving existing GitLab summaries and approval behavior. diff --git a/docs/development.md b/docs/development.md index 30b7b68..9beb2d6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -106,6 +106,29 @@ New runtime modules, classes, and functions need purpose-focused docstrings. Com Apply the [cohesive-module invariant](engineering/project_principles.md#product-and-architecture) during self-review. Prefer an extract-and-delegate refactor that moves already characterized functions or classes intact, preserves the intentional package facade, and reruns the same contract suite before and after each move. Split on distinct responsibility and dependency direction, not an arbitrary line count; do not rewrite a working algorithm merely to make a file shorter. Architecture tests should protect required owners and forbidden upward dependencies without freezing every future helper-module name. +## Review reporting and provider adapters + +`reporting/` owns provider-neutral report data and pure presentation: `model.py` +snapshots admitted results and execution-owner facts, `outcome.py` describes +review health separately from finding delivery, `result.py` normalizes coverage +and warnings, and `metadata.py`, `usage.py`, `dlp.py`, `sections.py`, and `text.py` +own their respective pure calculations. Shared reporting must not import posting, +forge acquisition, forge receipts, environment configuration or provider actions. +Posting facades preserve existing imports while GitLab retains its publication +transactions, discussion anchors, suppression and approval policy. + +`providers/local.py` writes plain Markdown from that shared report, with every +admitted finding and no posting cap, remote badge or HTML disclosure. It does not +acquire forge data or accept discussion commands. The runner, not the formatter, +must establish immutable identity, mandatory completed evidence use and DLP +admission before constructing a successful report. Passing a model-authored +receipt to a formatter is not evidence of successful execution. + +New shared reporting tests live in `tests/reporting/`; keep GitLab transaction +regressions with their existing owners. Dependency tests enforce the downward +boundary, while outcome and rendering tests prove the pure report contract. +Provider I/O and executable CLI claims require separate production-boundary tests. + ## Extending ecosystem evidence Normalized source adapters live under `src/ocr_toolkit/evidence/ecosystems/`. Shared parser result contracts belong in `ecosystems/contracts.py`; Python, JavaScript, Go, and PHP package metadata each have one adapter module. Ansible keeps Galaxy requirements and topology/inventory analysis as separate modules under `ecosystems/ansible/`. These adapters consume text or already bounded metadata and return normalized facts: they do not own Git or filesystem reads, subprocesses, network access, framework derivation, persistence, or MCP lifecycle. diff --git a/docs/engineering/test_evidence_matrix.md b/docs/engineering/test_evidence_matrix.md index d4d6feb..cab03a9 100644 --- a/docs/engineering/test_evidence_matrix.md +++ b/docs/engineering/test_evidence_matrix.md @@ -197,6 +197,13 @@ Every top-level test module is classified below. A module can contain more than | Serialized comment repair and failure arguments | Checksum-verified OCR 1.11.4 and 1.11.5 use a local deterministic gateway; native, serialized and repaired batches preserve fields and anchors, suspect batches fail with original arguments in private failure data | Binary behavior and field preservation, not model judgment | | Forward-only suite and historical evidence | Complete real no-LLM runs before/after refactoring yield identical JSON contracts; frozen promotion-baseline and historical-independence tests reject missing proof before writes | Historical readback does not imply supported execution of old binaries | +## Shared reporting and local output + +| Boundary | Owner and entry point | Evidence | Claim limit | +| --- | --- | --- | --- | +| Provider-neutral reporting | `reporting.model.report_from_result` and shared pure formatters | `tests/reporting/` exercises outcome, invalid admission, coverage recovery, mandatory action counts, snapshot isolation and forbidden forge dependencies; existing posting and approval tests preserve GitLab behavior | synthetic execution facts stand in for the runner's verified output; these tests do not prove OCR or MCP execution | +| Local Markdown delivery | `providers.local.write_local_report` into a real text stream | all admitted findings and code fields, posting-environment independence, fence/control escaping and truthful failure output | proves the output adapter, not standalone CLI acquisition, subprocess execution or debug retention | + ## Unsafe or nondeterministic external boundaries The suite intentionally does not perform live GitLab comment, discussion, cleanup, or approval writes; live GitHub issue/release mutations; or PyPI publication. Their tests prove closed payloads, ordering, fail-closed decisions, transport serialization, and receipt parsing. Release completion requires independent live readback as defined in `docs/release.md`. diff --git a/src/ocr_toolkit/posting/comments.py b/src/ocr_toolkit/posting/comments.py index 77ae58e..f1a3f93 100644 --- a/src/ocr_toolkit/posting/comments.py +++ b/src/ocr_toolkit/posting/comments.py @@ -1,87 +1,21 @@ -"""Small helpers for OCR comment value normalization.""" - -from __future__ import annotations - -import re -from typing import Any - -from ocr_toolkit.common.markdown import escape_control_chars - -MARKDOWN_INLINE_SPECIAL_RE = re.compile(r"([`*_{}\[\]()+.!|~-])") - - -def clean_text(value: Any) -> str: - """Convert a JSON value to a stripped string.""" - - return "" if value is None else str(value).strip() - - -def compact_text(value: str, max_chars: int) -> str: - """Collapse whitespace and bound text for one-line MR summaries.""" - - if max_chars <= 0: - return "" - - collapsed = " ".join(value.split()) - if len(collapsed) <= max_chars: - return collapsed - - if max_chars <= 3: - return "." * max_chars - - return collapsed[: max_chars - 3].rstrip() + "..." - - -def compact_escaped_text(value: str, max_chars: int) -> str: - """Escape Markdown-sensitive text before compacting MR summary snippets.""" - - escaped = escape_control_chars(value) - escaped = escaped.replace("&", "&").replace("<", "<").replace(">", ">") - escaped = escaped.replace("@", "@") - escaped = MARKDOWN_INLINE_SPECIAL_RE.sub(r"\\\1", escaped) - return compact_text(escaped, max_chars) - - -def compact_control_text(value: str, max_chars: int) -> str: - """Escape controls only before compacting text rendered inside inline code.""" - - return compact_text(escape_control_chars(value), max_chars) - - -def code_text(value: Any) -> str: - """Convert a JSON value to text while preserving code indentation.""" - - if value is None: - return "" - return str(value).rstrip("\n") - - -def line_number(value: Any) -> int: - """Parse a line number, returning zero when invalid.""" - - if isinstance(value, bool): - return 0 - if isinstance(value, int): - parsed = value - elif isinstance(value, str): - text = value.strip() - if not text.isdecimal(): - return 0 - parsed = int(text) - else: - return 0 - return parsed if parsed > 0 else 0 - - -def comment_line(comment: dict[str, Any]) -> int: - """Return the best new-line number for a GitLab inline discussion. - - Anchor on the start of the range so multi-line findings highlight the - first problematic line rather than the end of the span. - """ - - for key in ("start_line", "line", "end_line"): - parsed = line_number(comment.get(key)) - if parsed > 0: - return parsed - return 0 +"""Posting facade for shared report value normalization.""" + +from ocr_toolkit.reporting.text import ( + clean_text, + code_text, + comment_line, + compact_control_text, + compact_escaped_text, + compact_text, + line_number, +) + +__all__ = [ + "clean_text", + "code_text", + "comment_line", + "compact_control_text", + "compact_escaped_text", + "compact_text", + "line_number", +] diff --git a/src/ocr_toolkit/posting/formatting.py b/src/ocr_toolkit/posting/formatting.py index 16226e7..3e85739 100644 --- a/src/ocr_toolkit/posting/formatting.py +++ b/src/ocr_toolkit/posting/formatting.py @@ -16,15 +16,6 @@ from ocr_toolkit.common.markdown import ( inline_code as _inline_code, ) -from ocr_toolkit.common.redaction import redact_sensitive -from ocr_toolkit.evidence.actions import EVIDENCE_ACTIONS -from ocr_toolkit.ocr_result import ( - MAX_TOOLKIT_MCP_USAGE_COUNT, - MAX_TOOLKIT_MCP_USAGE_SERVERS, - PUBLIC_REVIEW_TOOL_CALL_NAMES, - TOOLKIT_MCP_SERVER_NAME_RE, - OcrToolkitAdvisory, -) from ocr_toolkit.posting.approval import ( ApprovalResult, approval_summary_line, @@ -39,7 +30,7 @@ line_number, ) from ocr_toolkit.posting.payloads import truncate_code_text, truncate_note_body -from ocr_toolkit.posting.result import CoverageDiagnostics, ocr_warning_text +from ocr_toolkit.posting.result import CoverageDiagnostics from ocr_toolkit.posting.settings import ( FALLBACK_NOTE_CHUNK_BUDGET, MAX_FALLBACK_CODE_DETAILS_CHARS, @@ -57,49 +48,38 @@ SuggestionState, safe_repository_path, ) -from ocr_toolkit.result_usage import normalize_token_usage -from ocr_toolkit.review_receipt import toolkit_receipt_is_valid - -OCR_FINDING_CATEGORIES = { - "bug", - "security", - "performance", - "maintainability", - "test", - "style", - "documentation", - "other", -} - -OCR_FINDING_SEVERITIES = {"critical", "high", "medium", "low"} -OCR_FINDING_SEVERITY_ORDER = ("critical", "high", "medium", "low") -OCR_FINDING_CATEGORY_ORDER = ( - "security", - "bug", - "performance", - "maintainability", - "test", - "documentation", - "style", - "other", +from ocr_toolkit.reporting.metadata import ( + CATEGORY_EMOJI as CATEGORY_EMOJI, ) - -SEVERITY_EMOJI = { - "critical": "โŒ", - "high": "๐Ÿšจ", - "medium": "โš ๏ธ", - "low": "โ„น๏ธ", # noqa: RUF001 - intentional information emoji -} -CATEGORY_EMOJI = { - "bug": "๐Ÿ›", - "security": "๐Ÿ”’", - "performance": "โšก", - "maintainability": "๐Ÿ› ๏ธ", - "test": "๐Ÿงช", - "style": "๐ŸŽจ", - "documentation": "๐Ÿ“š", - "other": "๐Ÿ“Œ", -} +from ocr_toolkit.reporting.metadata import ( + OCR_FINDING_CATEGORIES as OCR_FINDING_CATEGORIES, +) +from ocr_toolkit.reporting.metadata import ( + OCR_FINDING_CATEGORY_ORDER, + OCR_FINDING_SEVERITY_ORDER, + finding_metadata, +) +from ocr_toolkit.reporting.metadata import ( + OCR_FINDING_SEVERITIES as OCR_FINDING_SEVERITIES, +) +from ocr_toolkit.reporting.metadata import ( + SEVERITY_EMOJI as SEVERITY_EMOJI, +) +from ocr_toolkit.reporting.metadata import format_ocr_core_advisory as format_ocr_core_advisory +from ocr_toolkit.reporting.metadata import ( + format_token_usage_summary as format_token_usage_summary, +) +from ocr_toolkit.reporting.metadata import ( + normalized_ocr_metadata as normalized_ocr_metadata, +) +from ocr_toolkit.reporting.outcome import FindingVisibility, review_outcome_line +from ocr_toolkit.reporting.sections import report_sections +from ocr_toolkit.reporting.usage import format_tool_calls_summary as format_tool_calls_summary +from ocr_toolkit.reporting.usage import format_verified_mcp_usage +from ocr_toolkit.reporting.usage import nonnegative_int as nonnegative_int +from ocr_toolkit.reporting.usage import tool_call_counts_from_items as tool_call_counts_from_items +from ocr_toolkit.reporting.usage import tool_call_name as tool_call_name +from ocr_toolkit.review_receipt import toolkit_receipt_is_valid SHIELDS_BADGE_BASE_URL = "https://img.shields.io/badge" SHIELDS_SEVERITY_COLORS = { @@ -121,23 +101,6 @@ def inline_code(value: str) -> str: return _inline_code(value, escape_controls=True) -def normalized_ocr_metadata(value: Any, allowed_values: set[str]) -> str: - """Return a whitelisted OCR metadata value suitable for display.""" - - text = clean_text(value).casefold() - return text if text in allowed_values else "" - - -def finding_metadata(comment: dict[str, Any]) -> tuple[str, str]: - """Return structured OCR category/severity metadata from a finding.""" - - severity = normalized_ocr_metadata( - comment.get("severity"), OCR_FINDING_SEVERITIES - ) or normalized_ocr_metadata(comment.get("priority"), OCR_FINDING_SEVERITIES) - category = normalized_ocr_metadata(comment.get("category"), OCR_FINDING_CATEGORIES) - return severity, category - - def _finding_badge_label(*, severity: str, category: str) -> str: """Return a compact label built only from normalized closed enums.""" @@ -355,204 +318,16 @@ def format_metadata_counts( return ", ".join(parts) -def nonnegative_int(value: Any) -> int | None: - """Parse a non-negative integer from OCR JSON, ignoring malformed values.""" - - if isinstance(value, bool) or value is None: - return None - - if isinstance(value, int): - return value if value >= 0 else None - - if isinstance(value, float): - if value.is_integer() and value >= 0: - return int(value) - return None - - if isinstance(value, str): - try: - parsed = int(value.strip()) - except ValueError: - return None - return parsed if parsed >= 0 else None - - return None - - -def tool_call_name(value: Any) -> str: - """Extract one closed public tool name from common OCR call shapes.""" - - if isinstance(value, str): - name = clean_text(value) - return name if name in PUBLIC_REVIEW_TOOL_CALL_NAMES else "" - - if not isinstance(value, dict): - return "" - - for key in ("name", "tool", "tool_name"): - name = clean_text(value.get(key)) - if name in PUBLIC_REVIEW_TOOL_CALL_NAMES: - return name - - function_value = value.get("function") - if isinstance(function_value, dict): - name = clean_text(function_value.get("name")) - return name if name in PUBLIC_REVIEW_TOOL_CALL_NAMES else "" - - return "" - - -def tool_call_counts_from_items( - items: list[Any], -) -> tuple[int | None, list[tuple[str, int]]]: - """Summarize admitted calls from a legacy list-style OCR payload.""" - - counts: dict[str, int] = {} - for item in items: - name = tool_call_name(item) - if not name: - continue - count = counts.get(name, 0) + 1 - if count > MAX_TOOLKIT_MCP_USAGE_COUNT: - return None, [] - counts[name] = count - - total = sum(counts.values()) - if total == 0 and items: - return None, [] - - return total, list(counts.items()) - - -def format_tool_calls_summary(tool_calls: Any) -> str: - """Return one bounded MR line for admitted non-zero OCR tool counts.""" - - entries: list[tuple[str, int]] - total: int | None - if isinstance(tool_calls, list): - total, entries = tool_call_counts_from_items(tool_calls) - elif isinstance(tool_calls, dict): - by_tool_value = tool_calls.get("by_tool") - entries = [] - admitted_total = 0 - - if isinstance(by_tool_value, dict): - for raw_name, raw_count in by_tool_value.items(): - if not isinstance(raw_name, str) or raw_name not in PUBLIC_REVIEW_TOOL_CALL_NAMES: - continue - if ( - not isinstance(raw_count, int) - or isinstance(raw_count, bool) - or not 0 < raw_count <= MAX_TOOLKIT_MCP_USAGE_COUNT - ): - continue - admitted_total += raw_count - if admitted_total > MAX_TOOLKIT_MCP_USAGE_COUNT: - return "" - entries.append((raw_name, raw_count)) - - calls_value = tool_calls.get("calls") - if not by_tool_value and isinstance(calls_value, list): - list_total, entries = tool_call_counts_from_items(calls_value) - else: - list_total = None - - if "total" in tool_calls: - raw_total = tool_calls["total"] - if ( - not isinstance(raw_total, int) - or isinstance(raw_total, bool) - or not 0 < raw_total <= MAX_TOOLKIT_MCP_USAGE_COUNT - ): - return "" - total = raw_total - else: - if list_total is not None: - total = list_total - elif entries: - total = admitted_total - else: - return "" - else: - return "" - - if total is None: - return "" - if total == 0 or not entries: - return "" - if sum(count for _name, count in entries) > total: - return "" - - line = f"- all OCR tool calls: {total} total" - entries.sort(key=lambda item: (-item[1], item[0])) - detail_parts = [f"{inline_code(name)}: {count}" for name, count in entries] - - return f"{line} ({', '.join(detail_parts)})" - - def format_mcp_usage_summary(toolkit_metadata: Any) -> str: """Report MCP servers from the safe receipt produced by `ocr-ci review`.""" if not toolkit_receipt_is_valid(toolkit_metadata): return "" mcp = toolkit_metadata.get("mcp") - mcp_usage = mcp.get("usage") if isinstance(mcp, dict) else None - if ( - not isinstance(mcp_usage, dict) - or len(mcp_usage) > MAX_TOOLKIT_MCP_USAGE_SERVERS - or any( - not isinstance(server, str) - or TOOLKIT_MCP_SERVER_NAME_RE.fullmatch(server) is None - or not isinstance(count, int) - or isinstance(count, bool) - or not 0 < count <= MAX_TOOLKIT_MCP_USAGE_COUNT - for server, count in mcp_usage.items() - ) - ): - return "" - used = sorted(mcp_usage.items()) - if not used: - return "" - details = ", ".join(f"{inline_code(server)}: {count}" for server, count in used) - lines = [f"- reconciled MCP attempts: {len(used)} server(s) ({details})"] - evidence = toolkit_metadata.get("evidence") - actions = evidence.get("actions") if isinstance(evidence, dict) else None - if isinstance(actions, dict) and set(actions) == {"state", "attempted", "completed"}: - attempted = actions.get("attempted") - completed = actions.get("completed") - evidence_calls = evidence.get("calls") if isinstance(evidence, dict) else None - mandatory = evidence.get("mandatory") if isinstance(evidence, dict) else None - evidence_used = evidence.get("used") if isinstance(evidence, dict) else None - if not ( - actions.get("state") == "verified" - and isinstance(attempted, dict) - and set(attempted) == {*EVIDENCE_ACTIONS, "unattributed"} - and isinstance(completed, dict) - and set(completed) == set(EVIDENCE_ACTIONS) - and all( - isinstance(count, int) - and not isinstance(count, bool) - and 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT - for count in (*attempted.values(), *completed.values()) - ) - and isinstance(evidence_calls, int) - and not isinstance(evidence_calls, bool) - and 0 <= evidence_calls <= MAX_TOOLKIT_MCP_USAGE_COUNT - and isinstance(mandatory, bool) - and isinstance(evidence_used, bool) - and all(completed[action] <= attempted[action] for action in EVIDENCE_ACTIONS) - and sum(attempted.values()) == evidence_calls - and evidence_used is (sum(completed.values()) > 0) - and (not mandatory or completed["summary"] >= 1) - ): - return "\n".join(lines) - positive = [action for action in EVIDENCE_ACTIONS if completed[action] > 0] - if positive: - lines.append( - "- completed built-in evidence actions: " - + ", ".join(f"{action}: {completed[action]}" for action in positive) - ) - return "\n".join(lines) + return format_verified_mcp_usage( + mcp_usage=mcp.get("usage") if isinstance(mcp, dict) else None, + evidence=toolkit_metadata.get("evidence"), + ) def publication_dlp_signal( @@ -639,27 +414,6 @@ def format_publication_dlp_details(signal: dict[str, Any] | None) -> str: ) -def format_token_usage_summary(result: dict[str, Any]) -> str: - """Return one bounded MR summary line for structured OCR token usage.""" - - usage = normalize_token_usage(result) - if usage is None: - return "" - - total = usage.get("total") - details: list[str] = [] - for bucket in ("input", "output", "cached", "reasoning", "other"): - if (count := usage.get(bucket)) is not None and count > 0: - details.append(f"{bucket}: {count}") - - if total is None: - return f"- token usage: {', '.join(details)}" if details else "" - line = f"- token usage: {total} total" - if details: - line += f" ({', '.join(details)})" - return line - - SECURITY_SIGNAL_RE = re.compile( r"(?i)\b(" r"security|credential|secret|token|password|private[_ -]?token|" @@ -890,75 +644,15 @@ def _review_outcome_line( ) -> str: """Combine review health and finding publication into one visible status.""" - budget_stop = outcome_status == "budget_exceeded" or ( - outcome_status == "partial" and "budget" in outcome_message.casefold() - ) - partial_result = outcome_status in {"partial", "completed_with_errors", "budget_exceeded"} - has_finding_state = total > 0 or omitted_count > 0 or suppressed_count > 0 - if outcome_status == "skipped": - marker, status_text = "โ„น๏ธ", "Review skipped" # noqa: RUF001 - result_text = "no supported files changed" - elif outcome_status == "failed": - marker, status_text = "โŒ", "Review failed" - result_text = "no reliable review result was produced" - else: - if budget_stop: - marker, status_text = "โš ๏ธ", "Review stopped at token budget" - elif partial_result: - marker, status_text = "โš ๏ธ", "Review incomplete" - elif outcome_status == "publication-filtered": - marker, status_text = "โš ๏ธ", "Review complete with publication filtering" - elif outcome_status in {"warning", "completed_with_warnings"} or warning_count: - marker, status_text = "โš ๏ธ", "Review complete with warnings" - elif has_finding_state: - marker, status_text = "๐Ÿ”Ž", "Review complete" - else: - marker, status_text = "โœ…", "Review complete" - - if total: - noun = "finding" if total == 1 else "findings" - result_text = f"{total} {noun} published" - if partial_result: - result_text += " from reviewed files" - elif omitted_count: - result_text = ( - "no findings published from reviewed files" - if partial_result - else "no findings published" - ) - elif suppressed_count: - result_text = ( - "no new findings published from reviewed files" - if partial_result - else "no new findings published" - ) - elif partial_result: - result_text = "no findings in reviewed files" - else: - result_text = "no findings" - - if omitted_count: - noun = "finding" if omitted_count == 1 else "findings" - result_text += f"; {omitted_count} {noun} omitted by posting limit" - if suppressed_count: - noun = "finding" if suppressed_count == 1 else "findings" - result_text += f"; {suppressed_count} {noun} matched prior reviewer decisions" - if partial_result and diagnostics.file_count is not None: - noun = "file" if diagnostics.file_count == 1 else "files" - result_text += f"; {diagnostics.file_count} {noun} not reviewed" - - prefix = f"{marker} " if emoji else "" - return f"{prefix}**{status_text} โ€” {result_text}**" - - -def format_ocr_core_advisory(advisory: OcrToolkitAdvisory | None) -> str: - """Render one validated numeric OCR advisory for Technical details only.""" - - if advisory is None: - return "" - return ( - f"- OCR core advisory: background {advisory.actual} characters; recommended " - f"{advisory.recommended} characters; accepted by OCR core" + return review_outcome_line( + findings=FindingVisibility( + count=total, published=True, omitted=omitted_count, suppressed=suppressed_count + ), + warning_count=warning_count, + outcome_status=outcome_status, + outcome_message=outcome_message, + unreviewed_file_count=diagnostics.file_count, + emoji=emoji, ) @@ -1014,51 +708,7 @@ def summarize_result( ] ) - severity_counts: dict[str, int] = {} - category_counts: dict[str, int] = {} - for comment in comments: - severity, category = finding_metadata(comment) - if severity: - severity_counts[severity] = severity_counts.get(severity, 0) + 1 - if category: - category_counts[category] = category_counts.get(category, 0) + 1 - if severity_counts or category_counts: - lines.extend(["", "### Findings", ""]) - for value in OCR_FINDING_SEVERITY_ORDER: - count = severity_counts.get(value, 0) - if count: - icon = f"{SEVERITY_EMOJI[value]} " if use_emoji else "" - lines.append(f"- {icon}{inline_code(value)}: {count}") - for value in OCR_FINDING_CATEGORY_ORDER: - count = category_counts.get(value, 0) - if count: - icon = f"{CATEGORY_EMOJI[value]} " if use_emoji else "" - lines.append(f"- {icon}{inline_code(value)}: {count}") - - if diagnostics.records or diagnostics.invalid or diagnostics.omitted: - lines.extend(["", "### Incomplete coverage", ""]) - for diagnostic in diagnostics.records: - detail = f" โ€” {diagnostic.detail}" if diagnostic.detail else "" - lines.append(f"- {inline_code(diagnostic.path)} โ€” {diagnostic.reason}{detail}") - if diagnostics.invalid: - lines.append( - f"- {diagnostics.invalid} failed item(s) had no safe repository-relative path" - ) - if diagnostics.omitted: - lines.append(f"- ... and {diagnostics.omitted} more failed file record(s)") - - safe_warnings = [] - for warning in warnings[:10]: - safe = compact_escaped_text( - neutralize_quick_actions(redact_sensitive(ocr_warning_text(warning))), 500 - ) - if safe: - safe_warnings.append(safe) - if safe_warnings and not diagnostics.records: - lines.extend(["", "### Review warnings", ""]) - lines.extend(f"- {warning}" for warning in safe_warnings) - if len(warnings) > len(safe_warnings): - lines.append(f"- ... and {len(warnings) - len(safe_warnings)} more warning(s)") + lines.extend(report_sections(comments, diagnostics, warnings, use_emoji=use_emoji)) if reviewer_guide: lines.extend(["", reviewer_guide.strip()]) diff --git a/src/ocr_toolkit/posting/result.py b/src/ocr_toolkit/posting/result.py index 25886a0..34b78f4 100644 --- a/src/ocr_toolkit/posting/result.py +++ b/src/ocr_toolkit/posting/result.py @@ -1,26 +1,20 @@ -"""OCR result artifact loading and provider failure classification.""" +"""Posting facade for shared OCR result diagnostics.""" -from __future__ import annotations - -import re -from collections.abc import Sequence -from dataclasses import dataclass -from pathlib import PurePosixPath -from typing import Any - -from ocr_toolkit.common.markdown import neutralize_quick_actions -from ocr_toolkit.common.redaction import redact_sensitive -from ocr_toolkit.ocr_result import ( +from ocr_toolkit.reporting.result import ( + CoverageDiagnostic, + CoverageDiagnostics, OcrResultMalformed, OcrResultMissing, OcrResultTooLarge, + llm_billing_failure_reason, load_ocr_result, + normalize_coverage_diagnostics, + ocr_warning_text, ) -from ocr_toolkit.posting.comments import clean_text, compact_escaped_text -from ocr_toolkit.provider_failure import ProviderFailureReason -from ocr_toolkit.result_contract import ReviewOutcome __all__ = [ + "CoverageDiagnostic", + "CoverageDiagnostics", "OcrResultMalformed", "OcrResultMissing", "OcrResultTooLarge", @@ -29,187 +23,3 @@ "normalize_coverage_diagnostics", "ocr_warning_text", ] - -LLM_BILLING_FAILURE_RE = re.compile( - r"(?i)\b(" - r"(?:http\s*)?status(?:[_\s]*code)?[\"']?\s*[:=]\s*[\"']?402|" - r"code[\"']?\s*[:=]\s*[\"']?402|payment required|insufficient[_ -]?funds|insufficient user balance|" - r"insufficient balance|insufficient[_ -]?quota|quota[_ -]?exceeded|" - r"out of credits|credit balance" - r")\b" -) - -MAX_COVERAGE_DIAGNOSTICS = 10 -MAX_COVERAGE_DETAIL_CHARS = 240 -FAILURE_REASON_LABELS = { - "timeout": "review timed out", - "provider": "provider request failed", - "cancelled": "review cancelled", - "configuration": "configuration failed", - "input": "input could not be reviewed", - "budget": "token limit reached", - "panic": "unknown subtask failure", - "unknown": "unknown subtask failure", -} - - -@dataclass(frozen=True, slots=True) -class CoverageDiagnostic: - """Hold one safe actionable failed-file receipt.""" - - path: str - reason: str - detail: str = "" - - -@dataclass(frozen=True, slots=True) -class CoverageDiagnostics: - """Hold bounded diagnostics and explicit information loss counters.""" - - records: tuple[CoverageDiagnostic, ...] - omitted: int - invalid: int - failed_total: int - unique_file_count: int - - @property - def file_count(self) -> int | None: - """Return a safe unique-file count only when every failure has a path.""" - - return self.unique_file_count if self.unique_file_count > 0 and self.invalid == 0 else None - - -def _safe_repository_path(value: object) -> str: - """Return one normalized repository-relative path or an empty value.""" - - if not isinstance(value, str) or not value or len(value) > 1_024 or "\\" in value: - return "" - parts = value.split("/") - path = PurePosixPath(value) - if path.is_absolute() or any(part in {"", ".", ".."} for part in parts): - return "" - if any(character == "\x7f" or ord(character) < 32 for character in value): - return "" - return path.as_posix() - - -def _legacy_reason(warning: Any) -> str: - """Map legacy warning shapes into a closed reviewer-facing vocabulary.""" - - warning_type = clean_text(warning.get("type")) if isinstance(warning, dict) else "" - text = f"{warning_type}\n{ocr_warning_text(warning)}".casefold() - if "timeout" in text or "timed out" in text: - return "review timed out" - if "budget" in text or "token limit" in text: - return "token limit reached" - if "provider" in text or "request" in text: - return "provider request failed" - if "tool" in text and "loop" in text: - return "tool loop failed" - if "parse" in text or "invalid result" in text: - return "result could not be parsed" - return "unknown subtask failure" - - -def _safe_detail(value: object, reason: str) -> str: - """Return optional redacted Markdown-neutral detail within a small budget.""" - - text = compact_escaped_text( - neutralize_quick_actions(redact_sensitive(clean_text(value))), - MAX_COVERAGE_DETAIL_CHARS, - ) - return "" if not text or text.casefold() == reason.casefold() else text - - -def normalize_coverage_diagnostics( - outcome: ReviewOutcome, - warnings: Sequence[Any], - *, - legacy_warning_fallback: bool = True, -) -> CoverageDiagnostics: - """Normalize manifest failures or legacy warnings once at the posting boundary.""" - - candidates: list[tuple[object, str, object]] = [] - if outcome.manifest_present: - candidates.extend( - ( - item.path, - FAILURE_REASON_LABELS.get(item.classification, "unknown subtask failure"), - item.reason, - ) - for item in outcome.failed_items - ) - elif outcome.kind == "partial" and legacy_warning_fallback: - for warning in warnings: - path = warning.get("file") or warning.get("path") if isinstance(warning, dict) else None - candidates.append((path, _legacy_reason(warning), ocr_warning_text(warning))) - - records: list[CoverageDiagnostic] = [] - seen: set[tuple[str, str]] = set() - invalid = 0 - for raw_path, reason, raw_detail in candidates: - path = _safe_repository_path(raw_path) - if not path: - invalid += 1 - continue - key = (path, reason) - if key in seen: - continue - seen.add(key) - records.append(CoverageDiagnostic(path, reason, _safe_detail(raw_detail, reason))) - records.sort(key=lambda item: (item.path, item.reason, item.detail)) - omitted = max(0, len(records) - MAX_COVERAGE_DIAGNOSTICS) - unique_file_count = len({record.path for record in records}) - return CoverageDiagnostics( - records=tuple(records[:MAX_COVERAGE_DIAGNOSTICS]), - omitted=omitted, - invalid=invalid, - failed_total=len(seen) + invalid, - unique_file_count=unique_file_count, - ) - - -def ocr_warning_text(warning: Any, *, _seen: set[int] | None = None) -> str: - """Return warning text relevant for provider failure classification.""" - - if _seen is None: - _seen = set() - if isinstance(warning, (dict, list)): - marker = id(warning) - if marker in _seen: - return "" - _seen.add(marker) - if isinstance(warning, dict): - parts: list[str] = [] - for key in ("type", "message", "code", "status", "status_code", "detail"): - text = clean_text(warning.get(key)) - if text: - parts.append(f"{key}: {text}" if key in {"code", "status", "status_code"} else text) - for key in ("error", "details"): - nested = warning.get(key) - if isinstance(nested, dict): - text = ocr_warning_text(nested, _seen=_seen) - if text: - parts.append(text) - else: - text = clean_text(nested) - if text: - parts.append(text) - return "\n".join(parts)[:4000] - if isinstance(warning, list): - return "\n".join( - text for value in warning[:40] if (text := ocr_warning_text(value, _seen=_seen)) - )[:4000] - return clean_text(warning) - - -def llm_billing_failure_reason( - warnings: Sequence[Any], -) -> ProviderFailureReason | None: - """Map a legacy OCR billing warning to the shared safe provider reason.""" - - for warning in warnings: - text = ocr_warning_text(warning) - if text and LLM_BILLING_FAILURE_RE.search(text): - return ProviderFailureReason.RATE_OR_SPENDING_LIMIT - return None diff --git a/src/ocr_toolkit/providers/local.py b/src/ocr_toolkit/providers/local.py new file mode 100644 index 0000000..467a901 --- /dev/null +++ b/src/ocr_toolkit/providers/local.py @@ -0,0 +1,99 @@ +"""Standalone local Markdown output; no forge acquisition, actions or posting policy.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import TextIO + +from ocr_toolkit.common.markdown import inline_code, markdown_code_block +from ocr_toolkit.reporting.dlp import format_dlp_admission, publication_dlp_state +from ocr_toolkit.reporting.metadata import finding_metadata, format_ocr_core_advisory +from ocr_toolkit.reporting.model import ReviewReport +from ocr_toolkit.reporting.outcome import FindingVisibility, review_outcome_line +from ocr_toolkit.reporting.sections import report_sections +from ocr_toolkit.reporting.text import clean_text, code_text, comment_line + + +def local_summary(report: ReviewReport) -> str: + """Render common facts, with neither forge publication claims nor remote badges.""" + + lines = [ + "## Open Code Review", + "", + review_outcome_line( + findings=FindingVisibility(count=len(report.comments)), + warning_count=len(report.warnings), + outcome_status=( + "admission-filtered" + if publication_dlp_state(report.publication) == "publication-filtered" + and report.outcome.kind in {"clean", "warning"} + else report.outcome.status + ), + outcome_message=report.outcome_message, + unreviewed_file_count=report.diagnostics.file_count, + ), + ] + if report.failure_stage is not None: + lines.extend( + [ + "", + f"Stopped at: {inline_code(report.failure_stage, escape_controls=True)}.", + "Findings, coverage and execution usage are unavailable or untrusted. " + "See the diagnostic output for the blocking check.", + ] + ) + lines.extend(report_sections(report.comments, report.diagnostics, report.warnings)) + lines.extend(["", "### Technical details", "", "- Output: local console"]) + if report.reviewed_sha: + lines.append(f"- Reviewed commit: {inline_code(report.reviewed_sha, escape_controls=True)}") + if report.outcome.coverage_summary: + lines.append(f"- {report.outcome.coverage_summary}") + for detail in ( + report.mcp_usage_summary, + report.tool_calls_summary, + report.token_usage_summary, + format_ocr_core_advisory(report.advisory), + format_dlp_admission(report.publication), + ): + if detail: + lines.append(detail) + return "\n".join(lines) + + +def local_report_parts(report: ReviewReport) -> Iterator[str]: + """Yield every admitted finding without posting limits or executable markup.""" + + yield local_summary(report) + for ordinal, comment in enumerate(report.comments, 1): + location = clean_text(comment.get("path")) or "unknown" + line = comment_line(comment) + if line: + location += f":L{line}" + tags = ", ".join(part for part in finding_metadata(comment) if part) + header = f"### Finding {ordinal}: {inline_code(location, escape_controls=True)}" + if tags: + header += f" โ€” {inline_code(tags)}" + yield ( + "\n\n" + + header + + "\n\n" + + markdown_code_block( + "", clean_text(comment.get("content")) or "Open Code Review reported an issue here." + ) + ) + for field, label in ( + ("existing_code", "Existing code"), + ("suggestion_code", "Suggested code"), + ): + content = code_text(comment.get(field)) + if content: + yield f"\n\n#### {label}\n\n" + markdown_code_block("", content) + yield "\n" + + +def write_local_report(report: ReviewReport, stream: TextIO) -> None: + """Write admitted output incrementally; the runner owns output failure handling.""" + + for part in local_report_parts(report): + stream.write(part) + stream.flush() diff --git a/src/ocr_toolkit/reporting/__init__.py b/src/ocr_toolkit/reporting/__init__.py new file mode 100644 index 0000000..bd75484 --- /dev/null +++ b/src/ocr_toolkit/reporting/__init__.py @@ -0,0 +1 @@ +"""Provider-neutral review data and pure presentation; no provider I/O or actions.""" diff --git a/src/ocr_toolkit/reporting/dlp.py b/src/ocr_toolkit/reporting/dlp.py new file mode 100644 index 0000000..f86d30f --- /dev/null +++ b/src/ocr_toolkit/reporting/dlp.py @@ -0,0 +1,166 @@ +"""Provider-neutral validation of DLP admission and original coverage facts.""" + +from __future__ import annotations + +from typing import Any + +from ocr_toolkit.ocr_result import MAX_TOOLKIT_MCP_USAGE_COUNT +from ocr_toolkit.result_contract import OcrResultContractError, ReviewOutcome + + +def format_dlp_admission(publication: Any) -> str: + """Render only closed DLP facts, without implying a provider mutation.""" + + state = publication_dlp_state(publication) + if state is None: + return "- DLP admission: unavailable" + if state == "passed": + return "- DLP admission: passed" + reasons = ", ".join( + f"{reason}: {count}" + for reason, count in sorted(publication["reason_counts"].items()) + if count > 0 + ) + if state == "private-sanitized": + return ( + f"- DLP admission: private fields sanitized ({publication['sanitized_fields']}); " + f"admitted findings unchanged; reasons: {reasons}" + ) + retained, omitted = publication["retained"], publication["omitted"] + return ( + f"- DLP admission: filtered; retained {retained['comments']} finding(s), " + f"{retained['warnings']} warning(s); omitted {omitted['comments']} finding(s), " + f"{omitted['warnings']} warning(s), {omitted['fields']} field(s); reasons: {reasons}" + ) + + +def publication_dlp_state(value: Any) -> str | None: + """Validate the exact current publication-policy receipt.""" + + if value == {"state": "passed"}: + return "passed" + if not isinstance(value, dict): + return None + if value.get("state") == "private-sanitized": + if set(value) != {"state", "reason_counts", "sanitized_fields"}: + return None + reason_counts = value.get("reason_counts") + sanitized_fields = value.get("sanitized_fields") + if ( + not _valid_dlp_reason_counts(reason_counts) + or not any(reason_counts.values()) + or not isinstance(sanitized_fields, int) + or isinstance(sanitized_fields, bool) + or not 0 < sanitized_fields <= MAX_TOOLKIT_MCP_USAGE_COUNT + ): + return None + return "private-sanitized" + if not isinstance(value, dict) or set(value) != { + "state", + "reason_counts", + "retained", + "omitted", + "original", + }: + return None + if value.get("state") != "publication-filtered": + return None + reason_counts = value.get("reason_counts") + retained = value.get("retained") + omitted = value.get("omitted") + original = value.get("original") + if ( + not _valid_dlp_reason_counts(reason_counts) + or not any(reason_counts.values()) + or not isinstance(retained, dict) + or set(retained) != {"comments", "warnings"} + or not isinstance(omitted, dict) + or set(omitted) != {"comments", "warnings", "fields"} + or any( + not isinstance(count, int) + or isinstance(count, bool) + or not 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT + for counts in (retained, omitted) + for count in counts.values() + ) + or not isinstance(original, dict) + or set(original) != {"outcome", "selected", "completed", "reused", "failed", "waived"} + or original.get("outcome") not in {"clean", "warning", "partial", "failed", "skipped"} + or any( + not isinstance(original.get(field), int) + or isinstance(original.get(field), bool) + or not 0 <= original[field] <= MAX_TOOLKIT_MCP_USAGE_COUNT + for field in ("selected", "completed", "reused", "failed", "waived") + ) + ): + return None + selected = original["selected"] + completed = original["completed"] + reused = original["reused"] + failed = original["failed"] + waived = original["waived"] + outcome = original["outcome"] + derived_outcomes = {"failed"} | ( + {"skipped"} + if selected == 0 + else {"clean", "warning"} + if failed == 0 + else {"failed"} + if failed == selected + else {"partial"} + ) + if selected != completed + reused + failed + waived or outcome not in derived_outcomes: + return None + return "publication-filtered" + + +def _valid_dlp_reason_counts(value: Any) -> bool: + return bool( + isinstance(value, dict) + and set(value) == {"forbidden", "invalid_text", "laundering", "limit", "pii", "secret"} + and all( + isinstance(count, int) + and not isinstance(count, bool) + and 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT + for count in value.values() + ) + ) + + +def publication_outcome_for_summary(outcome: ReviewOutcome, publication: Any) -> ReviewOutcome: + """Recover only validated original coverage facts from a filtered receipt.""" + + if publication_dlp_state(publication) != "publication-filtered": + return outcome + if outcome.kind != "partial" or outcome.manifest_present: + raise OcrResultContractError( + "publication-filtered receipt is not bound to a safe result projection" + ) + original = publication["original"] + kind = original["outcome"] + if outcome.budget_exceeded and kind != "partial": + raise OcrResultContractError( + "publication-filtered receipt contradicts the result budget state" + ) + counts = { + field: original[field] for field in ("selected", "completed", "reused", "failed", "waived") + } + manifest_present = any(counts.values()) + status = { + "clean": "complete" if manifest_present else "success", + "warning": "completed_with_warnings", + "partial": "budget_exceeded" if outcome.budget_exceeded else "completed_with_errors", + "failed": "failed", + "skipped": "skipped", + }[kind] + return ReviewOutcome( + status=status, + kind=kind, + budget_exceeded=outcome.budget_exceeded and kind == "partial", + manifest_present=manifest_present, + selected_count=counts["selected"], + completed_count=counts["completed"], + reused_count=counts["reused"], + failed_count=counts["failed"], + waived_count=counts["waived"], + ) diff --git a/src/ocr_toolkit/reporting/metadata.py b/src/ocr_toolkit/reporting/metadata.py new file mode 100644 index 0000000..f9436cc --- /dev/null +++ b/src/ocr_toolkit/reporting/metadata.py @@ -0,0 +1,90 @@ +"""Closed finding metadata and token presentation shared by all adapters.""" + +from __future__ import annotations + +from typing import Any + +from ocr_toolkit.ocr_result import OcrToolkitAdvisory +from ocr_toolkit.reporting.text import clean_text +from ocr_toolkit.result_usage import normalize_token_usage + +OCR_FINDING_SEVERITY_ORDER = ("critical", "high", "medium", "low") +OCR_FINDING_CATEGORY_ORDER = ( + "security", + "bug", + "performance", + "maintainability", + "test", + "documentation", + "style", + "other", +) +OCR_FINDING_SEVERITIES = set(OCR_FINDING_SEVERITY_ORDER) +OCR_FINDING_CATEGORIES = set(OCR_FINDING_CATEGORY_ORDER) + + +def normalized_ocr_metadata(value: Any, allowed_values: set[str]) -> str: + """Return a whitelisted OCR metadata value suitable for display.""" + + text = clean_text(value).casefold() + return text if text in allowed_values else "" + + +def finding_metadata(comment: dict[str, Any]) -> tuple[str, str]: + """Return structured OCR category/severity metadata from a finding.""" + + severity = normalized_ocr_metadata( + comment.get("severity"), OCR_FINDING_SEVERITIES + ) or normalized_ocr_metadata(comment.get("priority"), OCR_FINDING_SEVERITIES) + category = normalized_ocr_metadata(comment.get("category"), OCR_FINDING_CATEGORIES) + return severity, category + + +def format_token_usage_summary(result: dict[str, Any]) -> str: + """Return one bounded summary line for structured OCR token usage.""" + + usage = normalize_token_usage(result) + if usage is None: + return "" + + total = usage.get("total") + details: list[str] = [] + for bucket in ("input", "output", "cached", "reasoning", "other"): + if (count := usage.get(bucket)) is not None and count > 0: + details.append(f"{bucket}: {count}") + + if total is None: + return f"- token usage: {', '.join(details)}" if details else "" + line = f"- token usage: {total} total" + if details: + line += f" ({', '.join(details)})" + return line + + +def format_ocr_core_advisory(advisory: OcrToolkitAdvisory | None) -> str: + """Render one validated numeric OCR advisory for Technical details only.""" + + if advisory is None: + return "" + return ( + f"- OCR core advisory: background {advisory.actual} characters; recommended " + f"{advisory.recommended} characters; accepted by OCR core" + ) + + +SEVERITY_EMOJI = { + "critical": "โŒ", + "high": "๐Ÿšจ", + "medium": "โš ๏ธ", + "low": "โ„น๏ธ", # noqa: RUF001 - intentional information emoji +} +CATEGORY_EMOJI = { + "bug": "๐Ÿ›", + "security": "๐Ÿ”’", + "performance": "โšก", + "maintainability": "๐Ÿ› ๏ธ", + "test": "๐Ÿงช", + "style": "๐ŸŽจ", + "documentation": "๐Ÿ“š", + "other": "๐Ÿ“Œ", +} diff --git a/src/ocr_toolkit/reporting/model.py b/src/ocr_toolkit/reporting/model.py new file mode 100644 index 0000000..031f1f9 --- /dev/null +++ b/src/ocr_toolkit/reporting/model.py @@ -0,0 +1,127 @@ +"""Compose admitted review data independently of provider acquisition and delivery.""" + +from __future__ import annotations + +import re +from copy import deepcopy +from dataclasses import dataclass +from typing import Any, Literal, get_args + +from ocr_toolkit.ocr_result import OcrToolkitAdvisory +from ocr_toolkit.reporting.dlp import publication_dlp_state, publication_outcome_for_summary +from ocr_toolkit.reporting.metadata import format_token_usage_summary +from ocr_toolkit.reporting.result import CoverageDiagnostics, normalize_coverage_diagnostics +from ocr_toolkit.reporting.usage import ( + format_tool_calls_summary, + format_verified_mcp_usage, + validated_completed_actions, +) +from ocr_toolkit.result_contract import OcrResultContractError, ReviewOutcome, parse_result_outcome + +FailureStage = Literal[ + "configuration", + "identity", + "evidence", + "mcp-preflight", + "preview", + "subprocess", + "result-validation", + "mcp-use", + "dlp", + "cleanup", + "reporting", +] + + +@dataclass(frozen=True, slots=True) +class ExecutionFacts: + """Carry execution-owner facts separately from model output or forge receipts. + + Only the runner's successful evidence reconciliation and DLP finalization may + supply these values. Rendering does not confer authority on input claims. + """ + + mcp_usage: dict[str, int] + evidence: dict[str, Any] + publication: dict[str, Any] + advisory: OcrToolkitAdvisory | None = None + + +@dataclass(frozen=True, slots=True) +class ReviewReport: + """Snapshot common review facts before an adapter applies delivery policy.""" + + outcome: ReviewOutcome + comments: tuple[dict[str, Any], ...] + warnings: tuple[Any, ...] + diagnostics: CoverageDiagnostics + reviewed_sha: str = "" + outcome_message: str = "" + tool_calls_summary: str = "" + token_usage_summary: str = "" + mcp_usage_summary: str = "" + publication: dict[str, Any] | None = None + advisory: OcrToolkitAdvisory | None = None + failure_stage: FailureStage | None = None + + +def report_from_result( + result: dict[str, Any], *, execution: ExecutionFacts, reviewed_sha: str = "" +) -> ReviewReport: + """Snapshot an already admitted result and separately verified execution facts.""" + + if reviewed_sha and re.fullmatch(r"[0-9a-f]{40}", reviewed_sha) is None: + raise OcrResultContractError("report reviewed commit is not an immutable SHA") + comments = result.get("comments", []) + warnings = result.get("warnings", []) + if not isinstance(comments, list) or any(not isinstance(item, dict) for item in comments): + raise OcrResultContractError("report comments must be a list of objects") + if not isinstance(warnings, list): + raise OcrResultContractError("report warnings must be a list") + state = publication_dlp_state(execution.publication) + if state is None: + raise OcrResultContractError("report DLP admission facts are unavailable") + outcome = publication_outcome_for_summary(parse_result_outcome(result), execution.publication) + mcp_summary = format_verified_mcp_usage( + mcp_usage=execution.mcp_usage, evidence=execution.evidence + ) + completed = validated_completed_actions(execution.evidence) + if outcome.requires_evidence_mcp and ( + not mcp_summary + or completed is None + or completed["summary"] < 1 + or execution.evidence.get("mandatory") is not True + ): + raise OcrResultContractError("report verified evidence usage is unavailable") + message = result.get("message") + return ReviewReport( + outcome=outcome, + comments=tuple(deepcopy(comments)), + warnings=tuple(deepcopy(warnings)), + diagnostics=normalize_coverage_diagnostics( + outcome, warnings, legacy_warning_fallback=state != "publication-filtered" + ), + reviewed_sha=reviewed_sha, + outcome_message=message if isinstance(message, str) else "", + tool_calls_summary=format_tool_calls_summary(result.get("tool_calls")), + token_usage_summary=format_token_usage_summary(result), + mcp_usage_summary=mcp_summary, + publication=deepcopy(execution.publication), + advisory=execution.advisory, + ) + + +def failed_report(stage: FailureStage, *, reviewed_sha: str = "") -> ReviewReport: + """Represent unavailable review data without rendering raw exceptions or output.""" + + if stage not in get_args(FailureStage): + raise OcrResultContractError("report failure stage is unsupported") + safe_sha = reviewed_sha if re.fullmatch(r"[0-9a-f]{40}", reviewed_sha) else "" + return ReviewReport( + outcome=ReviewOutcome(status="failed", kind="failed", budget_exceeded=False), + comments=(), + warnings=(), + diagnostics=CoverageDiagnostics((), 0, 0, 0, 0), + reviewed_sha=safe_sha, + failure_stage=stage, + ) diff --git a/src/ocr_toolkit/reporting/outcome.py b/src/ocr_toolkit/reporting/outcome.py new file mode 100644 index 0000000..8c87f52 --- /dev/null +++ b/src/ocr_toolkit/reporting/outcome.py @@ -0,0 +1,86 @@ +"""Pure review health and finding-visibility presentation for output adapters.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class FindingVisibility: + """Describe adapter-owned delivery without changing review health.""" + + count: int + published: bool = False + omitted: int = 0 + suppressed: int = 0 + + +def review_outcome_line( + *, + findings: FindingVisibility, + warning_count: int, + outcome_status: str, + outcome_message: str, + unreviewed_file_count: int | None = None, + emoji: bool = False, +) -> str: + """Render one shared decision line; publication facts belong to the adapter.""" + + budget_stop = outcome_status == "budget_exceeded" or ( + outcome_status == "partial" and "budget" in outcome_message.casefold() + ) + partial_result = outcome_status in {"partial", "completed_with_errors", "budget_exceeded"} + has_finding_state = findings.count > 0 or findings.omitted > 0 or findings.suppressed > 0 + if outcome_status == "skipped": + marker, status_text = "โ„น๏ธ", "Review skipped" # noqa: RUF001 + result_text = "no supported files changed" + elif outcome_status == "failed": + marker, status_text = "โŒ", "Review failed" + result_text = "no reliable review result was produced" + else: + if budget_stop: + marker, status_text = "โš ๏ธ", "Review stopped at token budget" + elif partial_result: + marker, status_text = "โš ๏ธ", "Review incomplete" + elif outcome_status == "publication-filtered": + marker, status_text = "โš ๏ธ", "Review complete with publication filtering" + elif outcome_status == "admission-filtered": + marker, status_text = "โš ๏ธ", "Review complete with DLP filtering" + elif outcome_status in {"warning", "completed_with_warnings"} or warning_count: + marker, status_text = "โš ๏ธ", "Review complete with warnings" + elif has_finding_state: + marker, status_text = "๐Ÿ”Ž", "Review complete" + else: + marker, status_text = "โœ…", "Review complete" + + delivered = " published" if findings.published else "" + if findings.count: + noun = "finding" if findings.count == 1 else "findings" + result_text = f"{findings.count} {noun}{delivered}" + if partial_result: + result_text += " from reviewed files" + elif findings.omitted: + result_text = f"no findings{delivered}" + if partial_result: + result_text += " from reviewed files" + elif findings.suppressed: + result_text = f"no new findings{delivered}" + if partial_result: + result_text += " from reviewed files" + elif partial_result: + result_text = "no findings in reviewed files" + else: + result_text = "no findings" + + if findings.omitted: + noun = "finding" if findings.omitted == 1 else "findings" + result_text += f"; {findings.omitted} {noun} omitted by posting limit" + if findings.suppressed: + noun = "finding" if findings.suppressed == 1 else "findings" + result_text += f"; {findings.suppressed} {noun} matched prior reviewer decisions" + if partial_result and unreviewed_file_count is not None: + noun = "file" if unreviewed_file_count == 1 else "files" + result_text += f"; {unreviewed_file_count} {noun} not reviewed" + + prefix = f"{marker} " if emoji else "" + return f"{prefix}**{status_text} โ€” {result_text}**" diff --git a/src/ocr_toolkit/reporting/result.py b/src/ocr_toolkit/reporting/result.py new file mode 100644 index 0000000..2185961 --- /dev/null +++ b/src/ocr_toolkit/reporting/result.py @@ -0,0 +1,215 @@ +"""OCR result artifact loading and provider failure classification.""" + +from __future__ import annotations + +import re +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import PurePosixPath +from typing import Any + +from ocr_toolkit.common.markdown import neutralize_quick_actions +from ocr_toolkit.common.redaction import redact_sensitive +from ocr_toolkit.ocr_result import ( + OcrResultMalformed, + OcrResultMissing, + OcrResultTooLarge, + load_ocr_result, +) +from ocr_toolkit.provider_failure import ProviderFailureReason +from ocr_toolkit.reporting.text import clean_text, compact_escaped_text +from ocr_toolkit.result_contract import ReviewOutcome + +__all__ = [ + "OcrResultMalformed", + "OcrResultMissing", + "OcrResultTooLarge", + "llm_billing_failure_reason", + "load_ocr_result", + "normalize_coverage_diagnostics", + "ocr_warning_text", +] + +LLM_BILLING_FAILURE_RE = re.compile( + r"(?i)\b(" + r"(?:http\s*)?status(?:[_\s]*code)?[\"']?\s*[:=]\s*[\"']?402|" + r"code[\"']?\s*[:=]\s*[\"']?402|payment required|insufficient[_ -]?funds|insufficient user balance|" + r"insufficient balance|insufficient[_ -]?quota|quota[_ -]?exceeded|" + r"out of credits|credit balance" + r")\b" +) + +MAX_COVERAGE_DIAGNOSTICS = 10 +MAX_COVERAGE_DETAIL_CHARS = 240 +FAILURE_REASON_LABELS = { + "timeout": "review timed out", + "provider": "provider request failed", + "cancelled": "review cancelled", + "configuration": "configuration failed", + "input": "input could not be reviewed", + "budget": "token limit reached", + "panic": "unknown subtask failure", + "unknown": "unknown subtask failure", +} + + +@dataclass(frozen=True, slots=True) +class CoverageDiagnostic: + """Hold one safe actionable failed-file receipt.""" + + path: str + reason: str + detail: str = "" + + +@dataclass(frozen=True, slots=True) +class CoverageDiagnostics: + """Hold bounded diagnostics and explicit information loss counters.""" + + records: tuple[CoverageDiagnostic, ...] + omitted: int + invalid: int + failed_total: int + unique_file_count: int + + @property + def file_count(self) -> int | None: + """Return a safe unique-file count only when every failure has a path.""" + + return self.unique_file_count if self.unique_file_count > 0 and self.invalid == 0 else None + + +def _safe_repository_path(value: object) -> str: + """Return one normalized repository-relative path or an empty value.""" + + if not isinstance(value, str) or not value or len(value) > 1_024 or "\\" in value: + return "" + parts = value.split("/") + path = PurePosixPath(value) + if path.is_absolute() or any(part in {"", ".", ".."} for part in parts): + return "" + if any(character == "\x7f" or ord(character) < 32 for character in value): + return "" + return path.as_posix() + + +def _legacy_reason(warning: Any) -> str: + """Map legacy warning shapes into a closed reviewer-facing vocabulary.""" + + warning_type = clean_text(warning.get("type")) if isinstance(warning, dict) else "" + text = f"{warning_type}\n{ocr_warning_text(warning)}".casefold() + if "timeout" in text or "timed out" in text: + return "review timed out" + if "budget" in text or "token limit" in text: + return "token limit reached" + if "provider" in text or "request" in text: + return "provider request failed" + if "tool" in text and "loop" in text: + return "tool loop failed" + if "parse" in text or "invalid result" in text: + return "result could not be parsed" + return "unknown subtask failure" + + +def _safe_detail(value: object, reason: str) -> str: + """Return optional redacted Markdown-neutral detail within a small budget.""" + + text = compact_escaped_text( + neutralize_quick_actions(redact_sensitive(clean_text(value))), + MAX_COVERAGE_DETAIL_CHARS, + ) + return "" if not text or text.casefold() == reason.casefold() else text + + +def normalize_coverage_diagnostics( + outcome: ReviewOutcome, + warnings: Sequence[Any], + *, + legacy_warning_fallback: bool = True, +) -> CoverageDiagnostics: + """Normalize manifest failures or legacy warnings at the reporting boundary.""" + + candidates: list[tuple[object, str, object]] = [] + if outcome.manifest_present: + candidates.extend( + ( + item.path, + FAILURE_REASON_LABELS.get(item.classification, "unknown subtask failure"), + item.reason, + ) + for item in outcome.failed_items + ) + elif outcome.kind == "partial" and legacy_warning_fallback: + for warning in warnings: + path = warning.get("file") or warning.get("path") if isinstance(warning, dict) else None + candidates.append((path, _legacy_reason(warning), ocr_warning_text(warning))) + + records: list[CoverageDiagnostic] = [] + seen: set[tuple[str, str]] = set() + invalid = 0 + for raw_path, reason, raw_detail in candidates: + path = _safe_repository_path(raw_path) + if not path: + invalid += 1 + continue + key = (path, reason) + if key in seen: + continue + seen.add(key) + records.append(CoverageDiagnostic(path, reason, _safe_detail(raw_detail, reason))) + records.sort(key=lambda item: (item.path, item.reason, item.detail)) + omitted = max(0, len(records) - MAX_COVERAGE_DIAGNOSTICS) + unique_file_count = len({record.path for record in records}) + return CoverageDiagnostics( + records=tuple(records[:MAX_COVERAGE_DIAGNOSTICS]), + omitted=omitted, + invalid=invalid, + failed_total=len(seen) + invalid, + unique_file_count=unique_file_count, + ) + + +def ocr_warning_text(warning: Any, *, _seen: set[int] | None = None) -> str: + """Return warning text relevant for provider failure classification.""" + + if _seen is None: + _seen = set() + if isinstance(warning, (dict, list)): + marker = id(warning) + if marker in _seen: + return "" + _seen.add(marker) + if isinstance(warning, dict): + parts: list[str] = [] + for key in ("type", "message", "code", "status", "status_code", "detail"): + text = clean_text(warning.get(key)) + if text: + parts.append(f"{key}: {text}" if key in {"code", "status", "status_code"} else text) + for key in ("error", "details"): + nested = warning.get(key) + if isinstance(nested, dict): + text = ocr_warning_text(nested, _seen=_seen) + if text: + parts.append(text) + else: + text = clean_text(nested) + if text: + parts.append(text) + return "\n".join(parts)[:4000] + if isinstance(warning, list): + return "\n".join( + text for value in warning[:40] if (text := ocr_warning_text(value, _seen=_seen)) + )[:4000] + return clean_text(warning) + + +def llm_billing_failure_reason( + warnings: Sequence[Any], +) -> ProviderFailureReason | None: + """Map a legacy OCR billing warning to the shared safe provider reason.""" + + for warning in warnings: + text = ocr_warning_text(warning) + if text and LLM_BILLING_FAILURE_RE.search(text): + return ProviderFailureReason.RATE_OR_SPENDING_LIMIT + return None diff --git a/src/ocr_toolkit/reporting/sections.py b/src/ocr_toolkit/reporting/sections.py new file mode 100644 index 0000000..7c2562a --- /dev/null +++ b/src/ocr_toolkit/reporting/sections.py @@ -0,0 +1,82 @@ +"""Pure shared finding, coverage and warning summary sections.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from ocr_toolkit.common.markdown import inline_code as _inline_code +from ocr_toolkit.common.markdown import neutralize_quick_actions +from ocr_toolkit.common.redaction import redact_sensitive +from ocr_toolkit.reporting.metadata import ( + CATEGORY_EMOJI, + OCR_FINDING_CATEGORY_ORDER, + OCR_FINDING_SEVERITY_ORDER, + SEVERITY_EMOJI, + finding_metadata, +) +from ocr_toolkit.reporting.result import CoverageDiagnostics, ocr_warning_text +from ocr_toolkit.reporting.text import compact_escaped_text + + +def inline_code(value: str) -> str: + """Escape controls and delimiters in report labels.""" + return _inline_code(value, escape_controls=True) + + +def report_sections( + comments: Sequence[dict[str, Any]], + diagnostics: CoverageDiagnostics, + warnings: Sequence[Any], + *, + use_emoji: bool = False, +) -> list[str]: + """Render the same bounded diagnostic sections for every output adapter.""" + lines: list[str] = [] + severity_counts: dict[str, int] = {} + category_counts: dict[str, int] = {} + for comment in comments: + severity, category = finding_metadata(comment) + if severity: + severity_counts[severity] = severity_counts.get(severity, 0) + 1 + if category: + category_counts[category] = category_counts.get(category, 0) + 1 + if severity_counts or category_counts: + lines.extend(["", "### Findings", ""]) + for value in OCR_FINDING_SEVERITY_ORDER: + count = severity_counts.get(value, 0) + if count: + icon = f"{SEVERITY_EMOJI[value]} " if use_emoji else "" + lines.append(f"- {icon}{inline_code(value)}: {count}") + for value in OCR_FINDING_CATEGORY_ORDER: + count = category_counts.get(value, 0) + if count: + icon = f"{CATEGORY_EMOJI[value]} " if use_emoji else "" + lines.append(f"- {icon}{inline_code(value)}: {count}") + + if diagnostics.records or diagnostics.invalid or diagnostics.omitted: + lines.extend(["", "### Incomplete coverage", ""]) + for diagnostic in diagnostics.records: + detail = f" โ€” {diagnostic.detail}" if diagnostic.detail else "" + lines.append(f"- {inline_code(diagnostic.path)} โ€” {diagnostic.reason}{detail}") + if diagnostics.invalid: + lines.append( + f"- {diagnostics.invalid} failed item(s) had no safe repository-relative path" + ) + if diagnostics.omitted: + lines.append(f"- ... and {diagnostics.omitted} more failed file record(s)") + + safe_warnings = [] + for warning in warnings[:10]: + safe = compact_escaped_text( + neutralize_quick_actions(redact_sensitive(ocr_warning_text(warning))), 500 + ) + if safe: + safe_warnings.append(safe) + if safe_warnings and not diagnostics.records: + lines.extend(["", "### Review warnings", ""]) + lines.extend(f"- {warning}" for warning in safe_warnings) + if len(warnings) > len(safe_warnings): + lines.append(f"- ... and {len(warnings) - len(safe_warnings)} more warning(s)") + + return lines diff --git a/src/ocr_toolkit/reporting/text.py b/src/ocr_toolkit/reporting/text.py new file mode 100644 index 0000000..11a7f04 --- /dev/null +++ b/src/ocr_toolkit/reporting/text.py @@ -0,0 +1,87 @@ +"""Small helpers for OCR comment value normalization.""" + +from __future__ import annotations + +import re +from typing import Any + +from ocr_toolkit.common.markdown import escape_control_chars + +MARKDOWN_INLINE_SPECIAL_RE = re.compile(r"([`*_{}\[\]()+.!|~-])") + + +def clean_text(value: Any) -> str: + """Convert a JSON value to a stripped string.""" + + return "" if value is None else str(value).strip() + + +def compact_text(value: str, max_chars: int) -> str: + """Collapse whitespace and bound text for one-line review summaries.""" + + if max_chars <= 0: + return "" + + collapsed = " ".join(value.split()) + if len(collapsed) <= max_chars: + return collapsed + + if max_chars <= 3: + return "." * max_chars + + return collapsed[: max_chars - 3].rstrip() + "..." + + +def compact_escaped_text(value: str, max_chars: int) -> str: + """Escape Markdown-sensitive text before compacting review summary snippets.""" + + escaped = escape_control_chars(value) + escaped = escaped.replace("&", "&").replace("<", "<").replace(">", ">") + escaped = escaped.replace("@", "@") + escaped = MARKDOWN_INLINE_SPECIAL_RE.sub(r"\\\1", escaped) + return compact_text(escaped, max_chars) + + +def compact_control_text(value: str, max_chars: int) -> str: + """Escape controls only before compacting text rendered inside inline code.""" + + return compact_text(escape_control_chars(value), max_chars) + + +def code_text(value: Any) -> str: + """Convert a JSON value to text while preserving code indentation.""" + + if value is None: + return "" + return str(value).rstrip("\n") + + +def line_number(value: Any) -> int: + """Parse a line number, returning zero when invalid.""" + + if isinstance(value, bool): + return 0 + if isinstance(value, int): + parsed = value + elif isinstance(value, str): + text = value.strip() + if not text.isdecimal(): + return 0 + parsed = int(text) + else: + return 0 + return parsed if parsed > 0 else 0 + + +def comment_line(comment: dict[str, Any]) -> int: + """Return the first relevant new-line number for a finding location. + + Anchor on the start of the range so multi-line findings highlight the + first problematic line rather than the end of the span. + """ + + for key in ("start_line", "line", "end_line"): + parsed = line_number(comment.get(key)) + if parsed > 0: + return parsed + return 0 diff --git a/src/ocr_toolkit/reporting/usage.py b/src/ocr_toolkit/reporting/usage.py new file mode 100644 index 0000000..7146210 --- /dev/null +++ b/src/ocr_toolkit/reporting/usage.py @@ -0,0 +1,224 @@ +"""Provider-neutral formatting of admitted OCR tool usage.""" + +from __future__ import annotations + +from typing import Any + +from ocr_toolkit.common.markdown import inline_code as _inline_code +from ocr_toolkit.evidence.actions import EVIDENCE_ACTIONS +from ocr_toolkit.ocr_result import ( + MAX_TOOLKIT_MCP_USAGE_COUNT, + MAX_TOOLKIT_MCP_USAGE_SERVERS, + PUBLIC_REVIEW_TOOL_CALL_NAMES, + TOOLKIT_MCP_SERVER_NAME_RE, +) +from ocr_toolkit.reporting.text import clean_text + + +def inline_code(value: str) -> str: + """Escape controls and delimiters in report labels.""" + return _inline_code(value, escape_controls=True) + + +def nonnegative_int(value: Any) -> int | None: + """Parse a non-negative integer from OCR JSON, ignoring malformed values.""" + + if isinstance(value, bool) or value is None: + return None + + if isinstance(value, int): + return value if value >= 0 else None + + if isinstance(value, float): + if value.is_integer() and value >= 0: + return int(value) + return None + + if isinstance(value, str): + try: + parsed = int(value.strip()) + except ValueError: + return None + return parsed if parsed >= 0 else None + + return None + + +def tool_call_name(value: Any) -> str: + """Extract one closed public tool name from common OCR call shapes.""" + + if isinstance(value, str): + name = clean_text(value) + return name if name in PUBLIC_REVIEW_TOOL_CALL_NAMES else "" + + if not isinstance(value, dict): + return "" + + for key in ("name", "tool", "tool_name"): + name = clean_text(value.get(key)) + if name in PUBLIC_REVIEW_TOOL_CALL_NAMES: + return name + + function_value = value.get("function") + if isinstance(function_value, dict): + name = clean_text(function_value.get("name")) + return name if name in PUBLIC_REVIEW_TOOL_CALL_NAMES else "" + + return "" + + +def tool_call_counts_from_items( + items: list[Any], +) -> tuple[int | None, list[tuple[str, int]]]: + """Summarize admitted calls from a legacy list-style OCR payload.""" + + counts: dict[str, int] = {} + for item in items: + name = tool_call_name(item) + if not name: + continue + count = counts.get(name, 0) + 1 + if count > MAX_TOOLKIT_MCP_USAGE_COUNT: + return None, [] + counts[name] = count + + total = sum(counts.values()) + if total == 0 and items: + return None, [] + + return total, list(counts.items()) + + +def format_tool_calls_summary(tool_calls: Any) -> str: + """Return one bounded report line for admitted non-zero OCR tool counts.""" + + entries: list[tuple[str, int]] + total: int | None + if isinstance(tool_calls, list): + total, entries = tool_call_counts_from_items(tool_calls) + elif isinstance(tool_calls, dict): + by_tool_value = tool_calls.get("by_tool") + entries = [] + admitted_total = 0 + + if isinstance(by_tool_value, dict): + for raw_name, raw_count in by_tool_value.items(): + if not isinstance(raw_name, str) or raw_name not in PUBLIC_REVIEW_TOOL_CALL_NAMES: + continue + if ( + not isinstance(raw_count, int) + or isinstance(raw_count, bool) + or not 0 < raw_count <= MAX_TOOLKIT_MCP_USAGE_COUNT + ): + continue + admitted_total += raw_count + if admitted_total > MAX_TOOLKIT_MCP_USAGE_COUNT: + return "" + entries.append((raw_name, raw_count)) + + calls_value = tool_calls.get("calls") + if not by_tool_value and isinstance(calls_value, list): + list_total, entries = tool_call_counts_from_items(calls_value) + else: + list_total = None + + if "total" in tool_calls: + raw_total = tool_calls["total"] + if ( + not isinstance(raw_total, int) + or isinstance(raw_total, bool) + or not 0 < raw_total <= MAX_TOOLKIT_MCP_USAGE_COUNT + ): + return "" + total = raw_total + else: + if list_total is not None: + total = list_total + elif entries: + total = admitted_total + else: + return "" + else: + return "" + + if total is None: + return "" + if total == 0 or not entries: + return "" + if sum(count for _name, count in entries) > total: + return "" + + line = f"- all OCR tool calls: {total} total" + entries.sort(key=lambda item: (-item[1], item[0])) + detail_parts = [f"{inline_code(name)}: {count}" for name, count in entries] + + return f"{line} ({', '.join(detail_parts)})" + + +def format_verified_mcp_usage(*, mcp_usage: Any, evidence: Any) -> str: + """Format execution-owner-verified facts, never model-supplied usage claims.""" + + if ( + not isinstance(mcp_usage, dict) + or len(mcp_usage) > MAX_TOOLKIT_MCP_USAGE_SERVERS + or any( + not isinstance(server, str) + or TOOLKIT_MCP_SERVER_NAME_RE.fullmatch(server) is None + or not isinstance(count, int) + or isinstance(count, bool) + or not 0 < count <= MAX_TOOLKIT_MCP_USAGE_COUNT + for server, count in mcp_usage.items() + ) + ): + return "" + used = sorted(mcp_usage.items()) + if not used: + return "" + details = ", ".join(f"{inline_code(server)}: {count}" for server, count in used) + lines = [f"- reconciled MCP attempts: {len(used)} server(s) ({details})"] + completed = validated_completed_actions(evidence) + if completed is not None: + positive = [action for action in EVIDENCE_ACTIONS if completed[action] > 0] + if positive: + lines.append( + "- completed built-in evidence actions: " + + ", ".join(f"{action}: {completed[action]}" for action in positive) + ) + return "\n".join(lines) + + +def validated_completed_actions(evidence: Any) -> dict[str, int] | None: + """Read exact execution action counts without consulting rendered prose.""" + + actions = evidence.get("actions") if isinstance(evidence, dict) else None + if isinstance(actions, dict) and set(actions) == {"state", "attempted", "completed"}: + attempted = actions.get("attempted") + completed = actions.get("completed") + evidence_calls = evidence.get("calls") if isinstance(evidence, dict) else None + mandatory = evidence.get("mandatory") if isinstance(evidence, dict) else None + evidence_used = evidence.get("used") if isinstance(evidence, dict) else None + if not ( + actions.get("state") == "verified" + and isinstance(attempted, dict) + and set(attempted) == {*EVIDENCE_ACTIONS, "unattributed"} + and isinstance(completed, dict) + and set(completed) == set(EVIDENCE_ACTIONS) + and all( + isinstance(count, int) + and not isinstance(count, bool) + and 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT + for count in (*attempted.values(), *completed.values()) + ) + and isinstance(evidence_calls, int) + and not isinstance(evidence_calls, bool) + and 0 <= evidence_calls <= MAX_TOOLKIT_MCP_USAGE_COUNT + and isinstance(mandatory, bool) + and isinstance(evidence_used, bool) + and all(completed[action] <= attempted[action] for action in EVIDENCE_ACTIONS) + and sum(attempted.values()) == evidence_calls + and evidence_used is (sum(completed.values()) > 0) + and (not mandatory or completed["summary"] >= 1) + ): + return None + return dict(completed) + return None diff --git a/src/ocr_toolkit/review_receipt.py b/src/ocr_toolkit/review_receipt.py index 066587e..f6db857 100644 --- a/src/ocr_toolkit/review_receipt.py +++ b/src/ocr_toolkit/review_receipt.py @@ -14,7 +14,10 @@ MAX_TOOLKIT_MCP_USAGE_SERVERS, TOOLKIT_MCP_SERVER_NAME_RE, ) -from ocr_toolkit.result_contract import OcrResultContractError, ReviewOutcome +from ocr_toolkit.reporting.dlp import publication_dlp_state as publication_dlp_state +from ocr_toolkit.reporting.dlp import ( + publication_outcome_for_summary as publication_outcome_for_summary, +) INVALID_APPROVAL_RECEIPT_REASON = "the review-time approval receipt is missing or invalid" UNPROTECTED_APPROVAL_REASON = ( @@ -113,99 +116,6 @@ def _sha256(value: Any) -> bool: ) -def publication_dlp_state(value: Any) -> str | None: - """Validate the exact current publication-policy receipt.""" - - if value == {"state": "passed"}: - return "passed" - if not isinstance(value, dict): - return None - if value.get("state") == "private-sanitized": - if set(value) != {"state", "reason_counts", "sanitized_fields"}: - return None - reason_counts = value.get("reason_counts") - sanitized_fields = value.get("sanitized_fields") - if ( - not _valid_dlp_reason_counts(reason_counts) - or not any(reason_counts.values()) - or not isinstance(sanitized_fields, int) - or isinstance(sanitized_fields, bool) - or not 0 < sanitized_fields <= MAX_TOOLKIT_MCP_USAGE_COUNT - ): - return None - return "private-sanitized" - if not isinstance(value, dict) or set(value) != { - "state", - "reason_counts", - "retained", - "omitted", - "original", - }: - return None - if value.get("state") != "publication-filtered": - return None - reason_counts = value.get("reason_counts") - retained = value.get("retained") - omitted = value.get("omitted") - original = value.get("original") - if ( - not _valid_dlp_reason_counts(reason_counts) - or not any(reason_counts.values()) - or not isinstance(retained, dict) - or set(retained) != {"comments", "warnings"} - or not isinstance(omitted, dict) - or set(omitted) != {"comments", "warnings", "fields"} - or any( - not isinstance(count, int) - or isinstance(count, bool) - or not 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT - for counts in (retained, omitted) - for count in counts.values() - ) - or not isinstance(original, dict) - or set(original) != {"outcome", "selected", "completed", "reused", "failed", "waived"} - or original.get("outcome") not in {"clean", "warning", "partial", "failed", "skipped"} - or any( - not isinstance(original.get(field), int) - or isinstance(original.get(field), bool) - or not 0 <= original[field] <= MAX_TOOLKIT_MCP_USAGE_COUNT - for field in ("selected", "completed", "reused", "failed", "waived") - ) - ): - return None - selected = original["selected"] - completed = original["completed"] - reused = original["reused"] - failed = original["failed"] - waived = original["waived"] - outcome = original["outcome"] - derived_outcomes = {"failed"} | ( - {"skipped"} - if selected == 0 - else {"clean", "warning"} - if failed == 0 - else {"failed"} - if failed == selected - else {"partial"} - ) - if selected != completed + reused + failed + waived or outcome not in derived_outcomes: - return None - return "publication-filtered" - - -def _valid_dlp_reason_counts(value: Any) -> bool: - return bool( - isinstance(value, dict) - and set(value) == {"forbidden", "invalid_text", "laundering", "limit", "pii", "secret"} - and all( - isinstance(count, int) - and not isinstance(count, bool) - and 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT - for count in value.values() - ) - ) - - def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: """Return the closed review-time receipt blocker for automatic approval.""" @@ -493,45 +403,6 @@ def validated_review_identity(toolkit_metadata: Any) -> ReceiptReviewIdentity | return receipt_review_identity(toolkit_metadata) -def publication_outcome_for_summary(outcome: ReviewOutcome, publication: Any) -> ReviewOutcome: - """Recover only validated original coverage facts from a filtered receipt.""" - - if publication_dlp_state(publication) != "publication-filtered": - return outcome - if outcome.kind != "partial" or outcome.manifest_present: - raise OcrResultContractError( - "publication-filtered receipt is not bound to a safe result projection" - ) - original = publication["original"] - kind = original["outcome"] - if outcome.budget_exceeded and kind != "partial": - raise OcrResultContractError( - "publication-filtered receipt contradicts the result budget state" - ) - counts = { - field: original[field] for field in ("selected", "completed", "reused", "failed", "waived") - } - manifest_present = any(counts.values()) - status = { - "clean": "complete" if manifest_present else "success", - "warning": "completed_with_warnings", - "partial": "budget_exceeded" if outcome.budget_exceeded else "completed_with_errors", - "failed": "failed", - "skipped": "skipped", - }[kind] - return ReviewOutcome( - status=status, - kind=kind, - budget_exceeded=outcome.budget_exceeded and kind == "partial", - manifest_present=manifest_present, - selected_count=counts["selected"], - completed_count=counts["completed"], - reused_count=counts["reused"], - failed_count=counts["failed"], - waived_count=counts["waived"], - ) - - def _completed_evidence_calls(value: Any) -> int: """Return the closed completed-action subtotal or a non-validating sentinel.""" diff --git a/tests/reporting/test_adapter_parity.py b/tests/reporting/test_adapter_parity.py new file mode 100644 index 0000000..1e4329f --- /dev/null +++ b/tests/reporting/test_adapter_parity.py @@ -0,0 +1,60 @@ +"""Characterize shared report facts independently of adapter delivery envelopes.""" + +from __future__ import annotations + +import pytest + +from ocr_toolkit.posting.formatting import summarize_result +from ocr_toolkit.providers.local import local_summary +from ocr_toolkit.reporting.model import ReviewReport +from ocr_toolkit.reporting.result import normalize_coverage_diagnostics +from ocr_toolkit.reporting.sections import report_sections +from ocr_toolkit.result_contract import ReviewOutcome + + +@pytest.mark.parametrize("partial", [False, True]) +def test_adapters_share_finding_warning_coverage_and_usage_facts(partial: bool) -> None: + comments = ({"path": "src/example.py", "severity": "high", "category": "bug"},) + warnings = ({"file": "src/other.py", "message": "provider request failed"},) + outcome = ReviewOutcome( + status="completed_with_errors" if partial else "completed_with_warnings", + kind="partial" if partial else "warning", + budget_exceeded=False, + ) + diagnostics = normalize_coverage_diagnostics(outcome, warnings) + report = ReviewReport( + outcome=outcome, + comments=comments, + warnings=warnings, + diagnostics=diagnostics, + tool_calls_summary="- all OCR tool calls: 1 total (`read_file`: 1)", + token_usage_summary="- token usage: 100 total", + mcp_usage_summary="- reconciled MCP attempts: 1 server(s) (`evidence`: 1)", + publication={"state": "passed"}, + ) + local = local_summary(report) + gitlab = summarize_result( + total=1, + inline_count=1, + fallback_count=0, + warning_count=1, + comments=comments, + warnings=warnings, + outcome_status=outcome.status, + coverage_diagnostics=diagnostics, + tool_calls_summary=report.tool_calls_summary, + token_usage_summary=report.token_usage_summary, + mcp_usage_summary=report.mcp_usage_summary, + emoji=False, + ) + shared = "\n".join(report_sections(comments, diagnostics, warnings)) + assert shared and shared in local and shared in gitlab + for detail in ( + report.tool_calls_summary, + report.token_usage_summary, + report.mcp_usage_summary, + ): + assert detail in local and detail in gitlab + assert "1 finding published" in gitlab + assert "1 finding published" not in local + assert "
" in gitlab and "
" not in local diff --git a/tests/reporting/test_local.py b/tests/reporting/test_local.py new file mode 100644 index 0000000..c61e815 --- /dev/null +++ b/tests/reporting/test_local.py @@ -0,0 +1,201 @@ +"""Production local adapter contracts, with synthetic execution-owner facts.""" + +from __future__ import annotations + +import ast +import io +from pathlib import Path + +import pytest + +from ocr_toolkit.evidence.actions import EVIDENCE_ACTIONS +from ocr_toolkit.providers.local import local_summary, write_local_report +from ocr_toolkit.reporting.model import ExecutionFacts, failed_report, report_from_result +from ocr_toolkit.result_contract import OcrResultContractError + + +def execution_facts() -> ExecutionFacts: + completed = dict.fromkeys(EVIDENCE_ACTIONS, 0) + completed["summary"] = 1 + return ExecutionFacts( + mcp_usage={"evidence": 1}, + evidence={ + "mandatory": True, + "used": True, + "calls": 1, + "actions": { + "state": "verified", + "attempted": {**completed, "unattributed": 0}, + "completed": completed, + }, + }, + publication={"state": "passed"}, + ) + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + ("success", "Review complete"), + ("completed_with_warnings", "Review complete with warnings"), + ("completed_with_errors", "Review incomplete"), + ("budget_exceeded", "Review stopped at token budget"), + ("skipped", "Review skipped"), + ], +) +def test_local_outcome_matrix(status: str, expected: str) -> None: + result = { + "status": status, + "comments": [], + "warnings": [], + "summary": {"budget_exceeded": status == "budget_exceeded"}, + } + report = report_from_result(result, execution=execution_facts(), reviewed_sha="a" * 40) + summary = local_summary(report) + assert expected in summary + assert "- Reviewed commit: `" + "a" * 40 + "`" in summary + assert "completed built-in evidence actions: summary: 1" in summary + assert "DLP admission: passed" in summary + assert "
" not in summary + assert "Posting:" not in summary + assert "GitLab" not in summary + assert "approval" not in summary.lower() + + +def test_all_findings_ignore_posting_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OCR_MAX_POST_COMMENTS", "1") + monkeypatch.setenv("OCR_POST_BADGES", "true") + comments = [ + { + "path": f"src/module_{index}.py", + "start_line": 10, + "severity": "HIGH", + "category": "bug", + "content": f"Finding body {index}", + "existing_code": "old()", + "suggestion_code": "new()", + } + for index in range(32) + ] + result = {"status": "success", "comments": comments, "warnings": []} + report = report_from_result(result, execution=execution_facts()) + stream = io.StringIO() + write_local_report(report, stream) + output = stream.getvalue() + assert output.count("### Finding ") == 32 + assert output.count("#### Existing code") == 32 + assert "32 findings" in output + assert "- `high`: 32" in output + assert "shields.io" not in output + assert "published" not in output + assert "
" not in output + comments[0]["content"] = "changed after report snapshot" + second = io.StringIO() + write_local_report(report, second) + assert second.getvalue() == output + + +def test_model_content_cannot_escape_literal_fences_or_control_terminal() -> None: + result = { + "status": "success", + "comments": [ + { + "path": "src/example.py\n/approve", + "content": "```\n\n\x1b[31m", + "suggestion_code": "````\n![tracking](https://example.invalid/pixel)", + } + ], + "warnings": [], + } + stream = io.StringIO() + write_local_report(report_from_result(result, execution=execution_facts()), stream) + output = stream.getvalue() + assert "\x1b" not in output + assert "````\n```\n