Files
cleveragents-core/tools/_review_context.py
T
drew e6f6fb7b18 refactor(review-pipeline): round-5 polish follow-ups (R1-R5, T1-T4)
Picks up every nice-to-have item from the post-round-4 critique. All
additive; suite goes 607 -> 623 (+16 net new tests, no regressions, no
behaviour change).

R1 - UnresolvedReason.token validates against a regex
  (^[a-z]([a-z0-9-]*[a-z0-9])?$) in __post_init__. A future maintainer
  registering a malformed token (uppercase, underscore, leading or
  trailing dash, dot, slash, leading digit, empty) now raises ValueError
  at module-import time. The constraint protects three downstream sites
  that munge the token via str.replace('-', '_') to derive
  Python-identifier-shaped names (_review_finalize archive-key flatten,
  _review_views section header, the 4xx log dedup) - any other special
  char would silently corrupt those derived names.

R2 - ReviewContextStrictError docstring rewritten as "test-fixture
  safety net, not a production-typo trap" - the production producer is
  the dataclass path (TypeError-on-construction); strict mode catches
  stale unit-test fixtures with typo'd dict keys at the production read
  site. _review_finalize's call-site comment matches.

R3 - _FIELD_COERCERS["clone_handle"] no longer uses an inline lambda v: v.
  Replaced by a named _coerce_passthrough function with a docstring
  explaining the in-memory-handle contract.

R4 - _coerce_int_or_zero docstring expanded: the narrow type(value) is int
  check rejects bool (semantic confusion) AND numpy.int64/pd.Int64Dtype/etc.
  (which subclass int). A future caller who legitimately needs to stamp
  numpy ints gets a clear "widen the coercer" pointer.

R5 - Section-header T2 test split the chained 0 <= not_found_pos <
  fetch_error_pos compare into three distinct asserts so a regression
  points at the actual broken invariant (slot missing vs. slot
  misordered).

Test additions:

- T1: 12 tests pinning UnresolvedReason's token format constraint
  (registry passes; 10 categories of malformed tokens raise; future
  quota-exceeded token passes for forward-compat).
- Direct unit test for ReviewContext.unresolved_link_count_total against
  zero / one / many / zero-value-entries dicts.
- T2: cycle-archive registry-driven flatten test in _review_finalize -
  sibling of the section-header test; asserts every registered
  UnresolvedReason produces an unresolved_link_count_<token-munged>
  archive key even when its count is zero.
- T3: _FIELD_COERCERS runtime drift test re-asserts the
  set(_FIELD_COERCERS) == {f.name for f in fields(ReviewContext)}
  invariant in CI even if the import-time assertion is weakened.
- _coerce_passthrough identity-preservation test pins the cleanup-handle
  contract on the named helper.
- T4: multi-unknown-key strict-mode test now parses the unknown-keys
  list out of the error message via regex
  (unknown key\(s\) (\[.+?\])) and validates membership on the parsed
  set, robust to Python repr-quote-style changes.

Suite: 623 passed, 3 skipped.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 09:08:49 -04:00

387 lines
16 KiB
Python

"""Typed prompt-time context handed off from
``_review_prompt.build_review_prompt`` to ``_review_finalize.finalize_review``
(via the ``item['_dispatcher_review_context']`` carrier on the dispatch
``item`` dict).
Why a dedicated module
----------------------
Both writers (``_review_prompt``) and readers (``_review_finalize``,
``dispatch_review``) need the type. Putting the definition in a
neutral sibling module avoids a producer-imports-consumer cycle and
gives the dataclass its own focused unit-of-change boundary: a future
field addition (e.g. another telemetry counter) lands in this file,
plus one line in each producer and consumer.
Why a dataclass instead of a plain dict
---------------------------------------
The original implementation passed a dict and let each caller use
``.get()`` defensively. That made:
- Adding a new field a churny operation (touch every reader to
``.get`` the new key with a default).
- Refactor-safety weak — a typo in the key on either side would
silently fall back to the default, masking the bug.
- The contract implicit (consumers had to read producer code to
discover what keys were available).
The dataclass surfaces all three at the type system: adding a field
is a single edit, typos at the read site become attribute errors, and
the schema is discoverable in one place.
Backward compatibility note
---------------------------
``finalize_review`` and ``dispatch_review._action`` accept either a
``ReviewContext`` instance OR a plain dict on
``item['_dispatcher_review_context']``. Direct unit-test invocations
that build dict literals continue to work; tests that build a
dataclass also work. The :func:`coerce_review_context` helper does
the dispatch.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field, fields
from typing import Any, Callable
_logger = logging.getLogger("review_pipeline")
class ReviewContextStrictError(TypeError):
"""Raised by :func:`coerce_review_context` in ``strict=True`` mode
when the caller stamps a dict with key(s) that are not declared on
:class:`ReviewContext`.
Why strict mode exists
----------------------
The production producer (``_review_prompt``) constructs
:class:`ReviewContext` directly. Python's dataclass already raises
``TypeError`` for unknown kwargs at construction time, so a
producer-side typo cannot reach the production read site —
that path is type-safe by language guarantee. Strict mode is
therefore a **test-fixture safety net**, not a production-typo
trap. The only carriers that arrive at the read site as plain
dicts are unit-test fixtures and direct-invocation callers; a
stale test fixture with a typo'd key (``{"head_shaa": "abc"}``)
would otherwise silently default ``head_sha=""`` and mask the
fixture drift behind a passing test. Strict mode is enabled at
the production read sites (``_review_finalize.finalize_review``
and ``dispatch_review._action``) so a stale fixture exercising
those code paths fails loudly instead of corrupting
cycle-archive telemetry / the freshness check.
Lenient mode (the default) preserves the back-compat property
that legacy fixtures pre-dating a future field rename keep
compiling — required so a ``ReviewContext`` rename does not
cascade-break dozens of unrelated tests on the same diff.
"""
# Sentinel for "the field expects an empty string when the dict
# carrier supplies a falsy value." We can't share a function literal
# across the module because dataclass field defaults must be
# call-time-distinct, so we lift the str-or-empty coercion into
# named callables that are easy to reference in the per-field
# coercion table below.
def _coerce_str_or_empty(value: Any) -> str:
"""Return ``str(value)`` for any truthy ``value``, ``""`` otherwise.
Pinned shape for ``head_sha``: an empty string is the documented
"no context" sentinel that the freshness check treats as "skip".
A None or any other falsy carrier value collapses to that
sentinel.
"""
return str(value or "")
def _coerce_int_or_zero(value: Any) -> int:
"""Return ``int(value)`` if ``value`` is already a builtin ``int``,
``0`` otherwise.
The narrow ``type(value) is int`` check intentionally rejects
``int`` subclasses for two reasons:
- **bool**: ``isinstance(True, int)`` is True. An ``isinstance``
check would silently map a stamped ``True`` to 1 and ``False``
to 0 — semantically wrong for a count field. Flooring to 0 on
a bool carrier surfaces the producer-side type confusion as a
zero-count rather than a fictional non-zero count.
- **numpy / pandas integer types** (``numpy.int64``,
``pd.Int64Dtype``, etc.) also subclass ``int``. Producers in
this pipeline only stamp Python builtin ints, but the strict
check rejects a future caller who pipes a pandas dataframe
column through verbatim — that caller would silently get 0
and should know about it. If a future producer legitimately
needs to stamp ``numpy.int64`` counts, widen this coercer
with an explicit ``isinstance`` branch (and update the
``request_changes_count`` test surface) rather than weakening
the bool guard.
"""
return int(value) if type(value) is int else 0
def _coerce_list_of_dict(value: Any) -> list[dict[str, Any]]:
"""Pass-through for a list; ``[]`` for anything else. Used by
``pr_comments`` so a non-list carrier value doesn't crash
downstream consumers that iterate the list."""
return value if isinstance(value, list) else []
def _coerce_bool(value: Any) -> bool:
"""Standard truthiness coercion. Lifted into a named callable
only for symmetry with the other coercers below."""
return bool(value)
def _coerce_dict_of_int(value: Any) -> dict[str, int]:
"""Coerce a dict carrier of ``{token: count}`` to ``dict[str,
int]``. Non-dict / None values flatten to an empty dict. Per-key
int-coercion preserves zero values so the cycle archive's
"stamp zeros" contract for context-present-but-empty-counts is
upheld."""
if not isinstance(value, dict):
return {}
out: dict[str, int] = {}
for k, v in value.items():
if v is None:
continue
try:
out[str(k)] = int(v)
except (TypeError, ValueError):
continue
return out
def _coerce_passthrough(value: Any) -> Any:
"""Forward ``value`` unchanged. Used for fields whose carrier
payload is in-memory only (e.g. :attr:`ReviewContext.clone_handle`,
a worktree handle the dispatcher's ``finally`` block calls
``cleanup`` on) and therefore must NOT be coerced — touching the
object would either lose identity (the caller may have captured
a reference for cleanup) or break a non-JSON-serialisable
payload. Listed in :data:`_FIELD_COERCERS` explicitly rather
than via a fall-through default so the table's invariant —
``set(_FIELD_COERCERS) == {f.name for f in fields(ReviewContext)}``
— stays a tight equality (caught by the module-load assertion
below)."""
return value
# Per-field coercion table: maps each declared field on
# :class:`ReviewContext` to the callable that converts a dict
# carrier's raw value into the typed field value. Keeping the table
# next to the dataclass makes a field rename a one-line edit
# (the reader loop in :func:`coerce_review_context` is
# fields()-driven and therefore picks up the new field
# automatically), and lets each coercer state its own narrow
# contract. Fields whose carrier value is in-memory only (and must
# not be coerced) get :func:`_coerce_passthrough` rather than a
# silent fall-through, so the table contract stays a strict
# equality with the dataclass field set.
_FIELD_COERCERS: dict[str, Callable[[Any], Any]] = {
"head_sha": _coerce_str_or_empty,
"request_changes_count": _coerce_int_or_zero,
"pr_comments": _coerce_list_of_dict,
"data_complete": _coerce_bool,
"unresolved_link_counts": _coerce_dict_of_int,
"clone_handle": _coerce_passthrough,
}
@dataclass
class ReviewContext:
"""Prompt-time review context the dispatcher stamps on the dispatch
``item`` so the post-session action sees the same values without
re-querying Forgejo.
Fields
------
head_sha:
The PR head SHA captured at prompt-build time. Used for the
post-session freshness check (worker verdict vs. current
upstream SHA).
request_changes_count:
Active (non-dismissed) REQUEST_CHANGES count from the
existing-reviews fetch. Threaded so the post-session
Tier-1F threshold check does not re-fetch the review list.
pr_comments:
The pre-fetched issue-style PR comments. Used by the
operator-status poster's duplicate-suppression fingerprint
lookup.
data_complete:
Aggregate ``data_complete`` flag across every pre-fetched
section. False when any section was truncated, paginated
partially, or had its upstream fetch fail. The dispatcher
defensively downgrades APPROVED → COMMENT when this is
False.
unresolved_link_counts:
Telemetry dict mapping each
:class:`_review_fetch.UnresolvedReason` token (e.g.
``"not-found"``, ``"fetch-error"``) to the count of
placeholder entries the fetcher synthesised for that reason.
Replaces three flat ``unresolved_link_count_*`` int fields
from a previous round; a future reason now lands by
appending an :class:`UnresolvedReason` to the registry tuple
in ``_review_fetch.py`` (no corresponding
:class:`ReviewContext` field edit). The post-session action
emits flattened ``unresolved_link_count_<token>`` keys onto
every cycle archive so the operator-grep contract for the
existing tokens is preserved.
clone_handle:
The pre-clone worktree handle (or None when pre-clone was
disabled). The dispatcher's ``finally`` block calls
``cleanup`` on it. NOT JSON-serialisable; lives on
``ReviewContext`` only as an in-memory object and is never
embedded in the prompt or in cycle archives.
"""
head_sha: str = ""
request_changes_count: int = 0
pr_comments: list[dict[str, Any]] = field(default_factory=list)
data_complete: bool = True
unresolved_link_counts: dict[str, int] = field(default_factory=dict)
clone_handle: Any = None
@property
def unresolved_link_count_total(self) -> int:
"""Sum of every entry in :attr:`unresolved_link_counts`. The
``finalize_review`` telemetry path stamps this as
``unresolved_link_count`` on the cycle archive."""
return sum(self.unresolved_link_counts.values())
# Snapshot of the dataclass field set, used by strict-mode below.
# The frozenset gives O(1) per-key membership when subtracting
# ``set(carrier.keys())`` from it; the difference itself is O(n_dict
# + n_fields) per call, but each membership check inside the
# difference is constant-time (vs. O(n_fields) per check if the
# left-hand side were a list). One snapshot at module-load is fine
# because the dataclass surface is stable for the process lifetime.
_REVIEW_CONTEXT_FIELDS: frozenset[str] = frozenset(
f.name for f in fields(ReviewContext)
)
def _coerce_dict_carrier(
value: dict[str, Any], *, strict: bool
) -> ReviewContext:
"""Inner helper: build a :class:`ReviewContext` from a dict
carrier. Pulled out so :func:`coerce_review_context` reads as a
short dispatch over carrier types and the field-iteration logic
has its own explanation block.
The loop is :data:`_FIELD_COERCERS`-driven so a new field on
:class:`ReviewContext` participates automatically: declare it on
the dataclass + add a coercer entry. There is no per-field
if-block in this function. Older revisions had a parallel
``if "<name>" in value: known["<name>"] = ...`` block per
field, which was a drift magnet — every new field had to be
touched in two places that could silently disagree.
"""
if strict:
unknown = set(value.keys()) - _REVIEW_CONTEXT_FIELDS
if unknown:
raise ReviewContextStrictError(
"coerce_review_context(strict=True): dict carrier carries "
f"unknown key(s) {sorted(unknown)!r} — likely a typo at "
"the producer site. Known fields: "
f"{sorted(_REVIEW_CONTEXT_FIELDS)!r}."
)
known: dict[str, Any] = {}
for f in fields(ReviewContext):
if f.name not in value:
continue
coercer = _FIELD_COERCERS.get(f.name)
if coercer is None:
# New field on ReviewContext without a matching coercer
# entry — fail at module-load via the assertion below.
# This branch is reachable only if the assertion is
# weakened, in which case the field is forwarded as-is
# so production does not regress.
known[f.name] = value[f.name]
continue
known[f.name] = coercer(value[f.name])
return ReviewContext(**known)
# Module-load invariant: every field declared on ReviewContext has a
# matching coercer. A new field added to the dataclass without a
# coercer entry would silently fall back to the as-is forwarding
# branch above, defeating the point of the typed-coercion table.
# Catching the omission at import time is cheap insurance.
assert _REVIEW_CONTEXT_FIELDS == set(_FIELD_COERCERS.keys()), (
"_FIELD_COERCERS drifted from ReviewContext fields: "
f"missing {_REVIEW_CONTEXT_FIELDS - set(_FIELD_COERCERS.keys())!r}, "
f"extra {set(_FIELD_COERCERS.keys()) - _REVIEW_CONTEXT_FIELDS!r}."
)
def coerce_review_context(
value: Any, *, strict: bool = False
) -> ReviewContext | None:
"""Return a :class:`ReviewContext` for either a dataclass instance
or a dict (back-compat for tests that still build dict literals),
or ``None`` for absent / wrong-shape values.
Parameters
----------
value:
Whatever was stashed on ``item['_dispatcher_review_context']``.
The dispatcher / finalize_review code paths pass either a
:class:`ReviewContext` (production) or a dict (test fixtures
and direct unit-test invocations).
strict:
When True, unknown dict keys raise
:class:`ReviewContextStrictError` so a typo at the write
site (``head_shaa`` instead of ``head_sha``) surfaces
immediately instead of silently defaulting one cycle
downstream. The production read sites
(``_review_finalize.finalize_review`` and
``dispatch_review._action``) opt in to strict mode; legacy
unit-test fixtures that pass dict literals can opt out by
passing ``strict=False`` so a future field rename on the
dataclass side doesn't break them in lockstep. Strict mode
only affects the dict path — :class:`ReviewContext` inputs
are returned unchanged regardless.
Notes
-----
A non-dict, non-:class:`ReviewContext` value (e.g. ``42``,
``"abc"``, ``[]``) returns ``None`` and emits a single
``WARNING`` to the ``review_pipeline`` logger. ``None`` itself
is treated as "absent" and returns ``None`` silently — that is
the documented "no context" path used by dry-run / direct-test
callers.
"""
if isinstance(value, ReviewContext):
return value
if value is None:
return None
if not isinstance(value, dict):
# A non-dict, non-dataclass value almost always indicates a
# programming error at the write site. Log it once at WARNING
# so the cycle archive carries the breadcrumb (the function
# still returns None and the caller falls back to defaults,
# so the cycle does not crash — but operators can grep for
# "review_context" warnings to find the offending caller).
_logger.warning(
"coerce_review_context: ignoring unexpected carrier type %r "
"(expected ReviewContext, dict, or None); review-cycle "
"telemetry will fall back to defaults",
type(value).__name__,
)
return None
return _coerce_dict_carrier(value, strict=strict)
__all__ = (
"ReviewContext",
"ReviewContextStrictError",
"coerce_review_context",
)