From 056dd093a25d6c7760f92f88b9ba082cb5622496 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Mon, 16 Mar 2026 10:05:40 +0100 Subject: [PATCH] feat: add reward ledger to code review bundle Persist code-review reward history with a Supabase-first ledger, keep an offline JSON fallback, and expose ledger commands so review runs can be tracked over time. Made-with: Cursor --- CHANGELOG.md | 13 + docs/modules/code-review.md | 41 +++ .../specfact-code-review/module-package.yaml | 6 +- .../specfact_code_review/ledger/__init__.py | 7 + .../src/specfact_code_review/ledger/client.py | 247 ++++++++++++++++++ .../specfact_code_review/ledger/commands.py | 85 ++++++ .../resources/supabase/review_ledger_ddl.sql | 35 +++ .../specfact_code_review/review/commands.py | 2 +- .../ledger/test_client.py | 177 +++++++++++++ .../ledger/test_commands.py | 106 ++++++++ 10 files changed, 715 insertions(+), 4 deletions(-) create mode 100644 packages/specfact-code-review/src/specfact_code_review/ledger/__init__.py create mode 100644 packages/specfact-code-review/src/specfact_code_review/ledger/client.py create mode 100644 packages/specfact-code-review/src/specfact_code_review/ledger/commands.py create mode 100644 packages/specfact-code-review/src/specfact_code_review/resources/supabase/review_ledger_ddl.sql create mode 100644 tests/unit/specfact_code_review/ledger/test_client.py create mode 100644 tests/unit/specfact_code_review/ledger/test_commands.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e2001674..cedbc5d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this repository will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows SemVer for bundle versions. +## [0.42.0] - 2026-03-16 + +### Added + +- Add a `specfact-code-review` reward ledger with Supabase-first persistence, + local JSON fallback, and `ledger update|status|reset` commands under + `specfact code review`. + +### Changed + +- Document the new ledger workflow, including the review-report pipe and the + offline fallback path used when Supabase is not configured. + ## [0.41.5] - 2026-03-13 ### Added diff --git a/docs/modules/code-review.md b/docs/modules/code-review.md index 0f776647..0cea039f 100644 --- a/docs/modules/code-review.md +++ b/docs/modules/code-review.md @@ -163,6 +163,47 @@ Operational note: - the current development environment includes CrossHair, but installed-user environments still need that executable available for the fast pass to run +## Ledger commands + +The `specfact-code-review` bundle now includes a reward ledger that persists +review outcomes and exposes a small CLI surface under `specfact code review +ledger`. + +### Command flow + +Use the governed review report as the canonical input for ledger updates: + +```bash +specfact code review run --json | specfact code review ledger update +specfact code review ledger status +specfact code review ledger reset --confirm +``` + +### Storage behavior + +- When `SUPABASE_URL` and `SUPABASE_KEY` are present, the ledger writes review + runs to `ai_sync.review_runs` and appends ledger snapshots to + `ai_sync.reward_ledger`. +- The reviewed DDL lives with the bundle at + `packages/specfact-code-review/src/specfact_code_review/resources/supabase/review_ledger_ddl.sql` + instead of a repo-root infrastructure folder. +- When Supabase is unavailable or not configured, the bundle falls back to the + local JSON ledger at `~/.specfact/ledger.json`. +- `ledger status` prints coins, pass/block streaks, the last verdict, and the + top three violation rules seen so far. +- `ledger reset` only clears the local JSON fallback and requires `--confirm`. + +### Local module development + +If the CLI warns that bundled modules are missing or outdated while you are +testing local bundle changes, refresh the project-scope modules first: + +```bash +specfact module init --scope project +``` + +Then rerun the ledger command from the same repository checkout. + ## Review orchestration `specfact_code_review.run.runner.run_review(files, no_tests=False)` orchestrates the diff --git a/packages/specfact-code-review/module-package.yaml b/packages/specfact-code-review/module-package.yaml index 0be286ce..e6fb1c32 100644 --- a/packages/specfact-code-review/module-package.yaml +++ b/packages/specfact-code-review/module-package.yaml @@ -1,5 +1,5 @@ name: nold-ai/specfact-code-review -version: 0.41.7 +version: 0.42.0 commands: - code tier: official @@ -12,5 +12,5 @@ description: Official SpecFact code review bundle package. category: codebase bundle_group_command: code integrity: - checksum: sha256:f3384cd61ea1073d43b9e0b51b5bede967a199f0d93f8b94abe35dec3a80faaf - signature: 4f/CSwuvn/T20kHlema6JnBUH1vTcKDdC4UHfOJHZDg+TU1TzQfWi6ftFdjxzsnTb4EquR5ey5ARLOM0/6SHAw== + checksum: sha256:d1be400445c194a80caa44e711d82c582f6f3a94875d5c1f07f8744ed940b536 + signature: 32d7FpwI2LDzVC9s4yax27lFmJiebAlQhgPrHBBgQgGHuW9Z5xLiER04gs2M+JUEA8OSqIRDP4IL+aDa5BXMDA== diff --git a/packages/specfact-code-review/src/specfact_code_review/ledger/__init__.py b/packages/specfact-code-review/src/specfact_code_review/ledger/__init__.py new file mode 100644 index 00000000..5a04a307 --- /dev/null +++ b/packages/specfact-code-review/src/specfact_code_review/ledger/__init__.py @@ -0,0 +1,7 @@ +"""Reward-ledger package surface.""" + +from specfact_code_review.ledger.client import LedgerClient +from specfact_code_review.ledger.commands import app + + +__all__ = ["LedgerClient", "app"] diff --git a/packages/specfact-code-review/src/specfact_code_review/ledger/client.py b/packages/specfact-code-review/src/specfact_code_review/ledger/client.py new file mode 100644 index 00000000..0ff98960 --- /dev/null +++ b/packages/specfact-code-review/src/specfact_code_review/ledger/client.py @@ -0,0 +1,247 @@ +"""Reward-ledger persistence with Supabase-first and local JSON fallback.""" + +from __future__ import annotations + +import json +from collections import Counter +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal + +import requests +from beartype import beartype +from icontract import ensure, require +from pydantic import BaseModel, Field + +from specfact_code_review.run.findings import FAIL, ReviewFinding, ReviewReport + + +LedgerVerdict = Literal["PASS", "PASS_WITH_ADVISORY", "FAIL"] +DEFAULT_AGENT = "claude-code" +DEFAULT_LOCAL_PATH = Path.home() / ".specfact" / "ledger.json" + + +class LedgerRun(BaseModel): + """Persisted review-run payload.""" + + session_id: str = Field(..., description="Stable run identifier.") + issue_number: int | None = Field(default=None, description="Optional linked issue number.") + agent: str = Field(default=DEFAULT_AGENT, description="Agent name.") + changed_files: list[str] = Field(default_factory=list, description="Changed files captured in the run.") + score: int = Field(..., ge=0, le=120, description="Governed review score.") + reward_delta: int = Field(..., description="Raw reward delta from ReviewReport.") + verdict: LedgerVerdict = Field(..., description="Overall review verdict.") + findings_json: list[dict[str, Any]] = Field(default_factory=list, description="Serialized findings payload.") + house_rules_ver: int = Field(default=1, description="House-rules version observed during the run.") + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), description="UTC creation timestamp.") + + +class LedgerState(BaseModel): + """Current reward-ledger state.""" + + agent: str = Field(default=DEFAULT_AGENT, description="Agent name.") + cumulative_coins: float = Field(default=0.0, description="Accumulated reward coins.") + streak_pass: int = Field(default=0, ge=0, description="Current passing streak length.") + streak_block: int = Field(default=0, ge=0, description="Current blocking streak length.") + last_delta: int = Field(default=0, description="Most recent raw reward delta.") + last_verdict: LedgerVerdict | None = Field(default=None, description="Most recent review verdict.") + violation_counts: dict[str, int] = Field(default_factory=dict, description="Aggregated rule counts.") + runs: list[LedgerRun] = Field(default_factory=list, description="Locally persisted runs.") + + +class LedgerClient: + """Persist review-run rewards to Supabase with a local JSON fallback.""" + + def __init__( + self, + *, + supabase_url: str | None = None, + supabase_key: str | None = None, + local_path: Path | None = None, + agent: str = DEFAULT_AGENT, + ) -> None: + self._supabase_url = (supabase_url or "").rstrip("/") + self._supabase_key = supabase_key or "" + self._local_path = local_path or DEFAULT_LOCAL_PATH + self._agent = agent + + @beartype + @require(lambda report: isinstance(report, ReviewReport), "report must be a ReviewReport") + @ensure(lambda result: isinstance(result, dict), "record_run must return a status dictionary") + def record_run(self, report: ReviewReport) -> dict[str, object]: + """Record a review run and return the updated ledger status.""" + current_state = self._read_supabase_state() if self._supabase_enabled else None + if current_state is None: + current_state = self._read_local_state() + + updated_state, run_entry = self._apply_report(current_state, report) + if self._supabase_enabled and self._write_supabase(run_entry, updated_state): + return self._status_payload(updated_state) + + self._write_local_state(updated_state) + return self._status_payload(updated_state) + + @beartype + @ensure(lambda result: isinstance(result, dict), "get_status must return a status dictionary") + def get_status(self) -> dict[str, object]: + """Return the current ledger status from Supabase or the local fallback.""" + state = self._read_supabase_state() + if state is None: + state = self._read_local_state() + return self._status_payload(state) + + @beartype + @ensure(lambda result: isinstance(result, bool), "reset_local must return a boolean") + def reset_local(self) -> bool: + """Delete the local fallback ledger if it exists.""" + if self._local_path.exists(): + self._local_path.unlink() + return True + + @property + def _supabase_enabled(self) -> bool: + return bool(self._supabase_url and self._supabase_key) + + def _apply_report(self, current_state: LedgerState, report: ReviewReport) -> tuple[LedgerState, LedgerRun]: + run_entry = LedgerRun( + session_id=report.run_id, + agent=self._agent, + changed_files=self._changed_files_for(report), + score=report.score, + reward_delta=report.reward_delta or 0, + verdict=self._verdict_for(report), + findings_json=[finding.model_dump(mode="json") for finding in report.findings], + created_at=report.timestamp, + ) + next_pass_streak = current_state.streak_pass + 1 if run_entry.verdict != FAIL else 0 + next_block_streak = current_state.streak_block + 1 if run_entry.verdict == FAIL else 0 + + coin_delta = (run_entry.reward_delta or 0) / 10.0 + if run_entry.verdict != FAIL and next_pass_streak >= 5: + coin_delta += 0.5 + if run_entry.verdict == FAIL and next_block_streak >= 3: + coin_delta -= 1.0 + + violation_counts = Counter(current_state.violation_counts) + violation_counts.update(self._rule_counts(report.findings)) + + updated_state = LedgerState( + agent=self._agent, + cumulative_coins=round(current_state.cumulative_coins + coin_delta, 2), + streak_pass=next_pass_streak, + streak_block=next_block_streak, + last_delta=run_entry.reward_delta, + last_verdict=run_entry.verdict, + violation_counts=dict(violation_counts), + runs=[*current_state.runs, run_entry], + ) + return updated_state, run_entry + + def _changed_files_for(self, report: ReviewReport) -> list[str]: + return sorted({finding.file for finding in report.findings if finding.file}) + + def _verdict_for(self, report: ReviewReport) -> LedgerVerdict: + if report.overall_verdict is None: + return "PASS_WITH_ADVISORY" + return report.overall_verdict + + def _rule_counts(self, findings: list[ReviewFinding]) -> Counter[str]: + return Counter(finding.rule for finding in findings) + + def _read_local_state(self) -> LedgerState: + if not self._local_path.exists(): + return LedgerState(agent=self._agent) + try: + payload = json.loads(self._local_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return LedgerState(agent=self._agent) + return LedgerState.model_validate(payload) + + def _write_local_state(self, state: LedgerState) -> None: + self._local_path.parent.mkdir(parents=True, exist_ok=True) + self._local_path.write_text(state.model_dump_json(indent=2), encoding="utf-8") + + def _read_supabase_state(self) -> LedgerState | None: + if not self._supabase_enabled: + return None + try: + response = requests.get( + f"{self._supabase_url}/rest/v1/reward_ledger", + headers=self._supabase_headers(), + params={ + "agent": f"eq.{self._agent}", + "order": "updated_at.desc", + "limit": "1", + }, + timeout=10, + ) + response.raise_for_status() + payload = response.json() + except (requests.RequestException, ValueError): + return None + + if not isinstance(payload, list) or not payload: + return LedgerState(agent=self._agent) + + latest_row = payload[0] + if not isinstance(latest_row, dict): + return LedgerState(agent=self._agent) + return LedgerState( + agent=str(latest_row.get("agent", self._agent)), + cumulative_coins=float(latest_row.get("cumulative_coins", 0.0)), + streak_pass=int(latest_row.get("streak_pass", 0)), + streak_block=int(latest_row.get("streak_block", 0)), + last_delta=int(latest_row.get("last_delta", 0) or 0), + last_verdict=latest_row.get("last_verdict"), + violation_counts={}, + runs=[], + ) + + def _write_supabase(self, run_entry: LedgerRun, state: LedgerState) -> bool: + try: + run_response = requests.post( + f"{self._supabase_url}/rest/v1/review_runs", + headers=self._supabase_headers(prefer_representation=True), + json=run_entry.model_dump(mode="json"), + timeout=10, + ) + run_response.raise_for_status() + ledger_response = requests.post( + f"{self._supabase_url}/rest/v1/reward_ledger", + headers=self._supabase_headers(prefer_representation=True), + json={ + "agent": state.agent, + "session_id": run_entry.session_id, + "cumulative_coins": state.cumulative_coins, + "last_delta": state.last_delta, + "last_verdict": state.last_verdict, + "streak_pass": state.streak_pass, + "streak_block": state.streak_block, + "updated_at": datetime.now(UTC).isoformat(), + }, + timeout=10, + ) + ledger_response.raise_for_status() + except requests.RequestException: + return False + return True + + def _supabase_headers(self, *, prefer_representation: bool = False) -> dict[str, str]: + headers = { + "apikey": self._supabase_key, + "Authorization": f"Bearer {self._supabase_key}", + "Content-Type": "application/json", + } + if prefer_representation: + headers["Prefer"] = "return=representation" + return headers + + def _status_payload(self, state: LedgerState) -> dict[str, object]: + top_violations = sorted(state.violation_counts.items(), key=lambda item: (-item[1], item[0]))[:3] + return { + "coins": round(state.cumulative_coins, 2), + "streak_pass": state.streak_pass, + "streak_block": state.streak_block, + "last_verdict": state.last_verdict or "UNKNOWN", + "top_violations": top_violations, + } diff --git a/packages/specfact-code-review/src/specfact_code_review/ledger/commands.py b/packages/specfact-code-review/src/specfact_code_review/ledger/commands.py new file mode 100644 index 00000000..59747f76 --- /dev/null +++ b/packages/specfact-code-review/src/specfact_code_review/ledger/commands.py @@ -0,0 +1,85 @@ +"""Typer command surface for the review reward ledger.""" + +from __future__ import annotations + +import sys + +import typer +from pydantic import ValidationError +from rich.console import Console + +from specfact_code_review.ledger.client import LedgerClient +from specfact_code_review.run.findings import ReviewReport + + +app = typer.Typer(help="Persist and inspect review reward history.", no_args_is_help=True) +console = Console() + + +@app.command("update") +def update() -> None: + """Read a ReviewReport JSON payload from stdin and update the ledger.""" + raw_payload = sys.stdin.read() + if not raw_payload.strip(): + typer.echo("ReviewReport JSON is required on stdin.", err=True) + raise typer.Exit(code=1) + + try: + report = ReviewReport.model_validate_json(raw_payload) + except ValidationError: + typer.echo("Invalid ReviewReport JSON.", err=True) + raise typer.Exit(code=1) from None + + status = LedgerClient().record_run(report) + console.print( + "Updated ledger: " + f"{float(status['coins']):.2f} coins, " + f"pass streak {int(status['streak_pass'])}, " + f"block streak {int(status['streak_block'])}, " + f"last verdict {status['last_verdict']}" + ) + + +@app.command("status") +def status() -> None: + """Print the current ledger state.""" + current_status = LedgerClient().get_status() + console.print(f"Coins: {float(current_status['coins']):.2f}") + console.print(f"Pass streak: {int(current_status['streak_pass'])}") + console.print(f"Block streak: {int(current_status['streak_block'])}") + console.print(f"Last verdict: {current_status['last_verdict']}") + + top_violations = current_status.get("top_violations", []) + if isinstance(top_violations, list) and top_violations: + rendered = ", ".join(_format_violation(entry) for entry in top_violations) + console.print(f"Top violations: {rendered}") + + +@app.command("reset") +def reset( + confirm: bool = typer.Option(False, "--confirm", help="Delete the local fallback ledger."), +) -> None: + """Delete the local JSON fallback ledger.""" + if not confirm: + typer.echo("Refusing to reset the local ledger without --confirm.", err=True) + raise typer.Exit(code=1) + + LedgerClient().reset_local() + console.print("Local ledger reset.") + + +def _format_violation(entry: object) -> str: + if isinstance(entry, tuple) and len(entry) == 2: + rule, count = entry + return f"{rule} ({count})" + if isinstance(entry, list) and len(entry) == 2: + rule, count = entry + return f"{rule} ({count})" + if isinstance(entry, dict): + rule = entry.get("rule", "unknown") + count = entry.get("count", "?") + return f"{rule} ({count})" + return str(entry) + + +__all__ = ["app"] diff --git a/packages/specfact-code-review/src/specfact_code_review/resources/supabase/review_ledger_ddl.sql b/packages/specfact-code-review/src/specfact_code_review/resources/supabase/review_ledger_ddl.sql new file mode 100644 index 00000000..c7cb1c2c --- /dev/null +++ b/packages/specfact-code-review/src/specfact_code_review/resources/supabase/review_ledger_ddl.sql @@ -0,0 +1,35 @@ +CREATE SCHEMA IF NOT EXISTS ai_sync; + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS ai_sync.review_runs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + session_id text NOT NULL, + issue_number integer, + agent text NOT NULL DEFAULT 'claude-code', + changed_files text[] NOT NULL DEFAULT '{}'::text[], + score integer NOT NULL CHECK (score >= 0 AND score <= 120), + reward_delta integer NOT NULL, + verdict text NOT NULL CHECK (verdict IN ('PASS', 'PASS_WITH_ADVISORY', 'FAIL')), + findings_json jsonb NOT NULL DEFAULT '[]'::jsonb, + house_rules_ver integer NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT timezone('utc', now()) +); + +CREATE INDEX IF NOT EXISTS review_runs_agent_created_at_idx + ON ai_sync.review_runs (agent, created_at DESC); + +CREATE TABLE IF NOT EXISTS ai_sync.reward_ledger ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + agent text NOT NULL, + session_id text, + cumulative_coins numeric(10, 2) NOT NULL DEFAULT 0, + last_delta integer, + last_verdict text CHECK (last_verdict IN ('PASS', 'PASS_WITH_ADVISORY', 'FAIL')), + streak_pass integer NOT NULL DEFAULT 0 CHECK (streak_pass >= 0), + streak_block integer NOT NULL DEFAULT 0 CHECK (streak_block >= 0), + updated_at timestamptz NOT NULL DEFAULT timezone('utc', now()) +); + +CREATE INDEX IF NOT EXISTS reward_ledger_agent_updated_at_idx + ON ai_sync.reward_ledger (agent, updated_at DESC); diff --git a/packages/specfact-code-review/src/specfact_code_review/review/commands.py b/packages/specfact-code-review/src/specfact_code_review/review/commands.py index 2d37e367..ea44511a 100644 --- a/packages/specfact-code-review/src/specfact_code_review/review/commands.py +++ b/packages/specfact-code-review/src/specfact_code_review/review/commands.py @@ -4,12 +4,12 @@ import typer +from specfact_code_review.ledger.commands import app as ledger_app from specfact_code_review.run.commands import app as run_app app = typer.Typer(help="Code command extensions for structured review workflows.", no_args_is_help=True) review_app = typer.Typer(help="Governed code review workflows.", no_args_is_help=True) -ledger_app = typer.Typer(help="Review ledger commands (stub).", no_args_is_help=False) rules_app = typer.Typer(help="Review house rules commands (stub).", no_args_is_help=False) review_app.add_typer(run_app, name="run") diff --git a/tests/unit/specfact_code_review/ledger/test_client.py b/tests/unit/specfact_code_review/ledger/test_client.py new file mode 100644 index 00000000..3b233492 --- /dev/null +++ b/tests/unit/specfact_code_review/ledger/test_client.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import pytest + +from specfact_code_review.ledger.client import LedgerClient +from specfact_code_review.run.findings import ReviewFinding, ReviewReport + + +class _Response: + def __init__(self, payload: Any) -> None: + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> Any: + return self._payload + + +def _report( + *, + run_id: str = "run-001", + score: int = 85, + findings: list[ReviewFinding] | None = None, +) -> ReviewReport: + return ReviewReport( + run_id=run_id, + timestamp=datetime(2026, 3, 16, tzinfo=UTC), + score=score, + findings=findings or [], + summary="Review report for ledger tests.", + ) + + +def _blocking_finding() -> ReviewFinding: + return ReviewFinding( + category="architecture", + severity="error", + tool="pylint", + rule="W0702", + file="packages/specfact-code-review/src/specfact_code_review/review/commands.py", + line=12, + message="Blocking governance issue.", + fixable=False, + ) + + +def _write_state(path: Path, *, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload), encoding="utf-8") + + +def test_record_run_with_supabase_available_inserts_row(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + posts: list[tuple[str, dict[str, Any]]] = [] + + def fake_get(url: str, **_: Any) -> _Response: + assert url.endswith("/rest/v1/reward_ledger") + return _Response([]) + + def fake_post(url: str, **kwargs: Any) -> _Response: + posts.append((url, kwargs)) + return _Response([{"ok": True}]) + + monkeypatch.setattr("specfact_code_review.ledger.client.requests.get", fake_get) + monkeypatch.setattr("specfact_code_review.ledger.client.requests.post", fake_post) + + client = LedgerClient( + supabase_url="https://example.supabase.co", + supabase_key="service-role", + local_path=tmp_path / "ledger.json", + ) + + status = client.record_run(_report()) + + assert len(posts) == 2 + assert posts[0][0].endswith("/rest/v1/review_runs") + assert posts[1][0].endswith("/rest/v1/reward_ledger") + ledger_payload = posts[1][1]["json"] + assert ledger_payload["cumulative_coins"] == pytest.approx(0.5) + assert ledger_payload["streak_pass"] == 1 + assert status["coins"] == pytest.approx(0.5) + + +def test_record_run_without_supabase_writes_to_local_json(tmp_path: Path) -> None: + ledger_path = tmp_path / "ledger.json" + client = LedgerClient(local_path=ledger_path) + + status = client.record_run(_report()) + payload = json.loads(ledger_path.read_text(encoding="utf-8")) + + assert payload["runs"][0]["session_id"] == "run-001" + assert payload["runs"][0]["verdict"] == "PASS" + assert status["coins"] == pytest.approx(0.5) + + +def test_record_run_applies_pass_streak_bonus_at_five(tmp_path: Path) -> None: + ledger_path = tmp_path / "ledger.json" + _write_state( + ledger_path, + payload={ + "agent": "claude-code", + "cumulative_coins": 1.0, + "streak_pass": 4, + "streak_block": 0, + "last_delta": 5, + "last_verdict": "PASS", + "violation_counts": {}, + "runs": [], + }, + ) + client = LedgerClient(local_path=ledger_path) + + status = client.record_run(_report(run_id="run-005", score=85)) + + assert status["coins"] == pytest.approx(2.0) + assert status["streak_pass"] == 5 + + +def test_record_run_applies_block_streak_penalty_at_three(tmp_path: Path) -> None: + ledger_path = tmp_path / "ledger.json" + _write_state( + ledger_path, + payload={ + "agent": "claude-code", + "cumulative_coins": 2.0, + "streak_pass": 0, + "streak_block": 2, + "last_delta": 0, + "last_verdict": "FAIL", + "violation_counts": {"W0702": 2}, + "runs": [], + }, + ) + client = LedgerClient(local_path=ledger_path) + + status = client.record_run(_report(run_id="run-006", score=80, findings=[_blocking_finding()])) + + assert status["coins"] == pytest.approx(1.0) + assert status["streak_block"] == 3 + assert status["last_verdict"] == "FAIL" + + +def test_get_status_returns_correct_dict(tmp_path: Path) -> None: + ledger_path = tmp_path / "ledger.json" + _write_state( + ledger_path, + payload={ + "agent": "claude-code", + "cumulative_coins": 12.5, + "streak_pass": 3, + "streak_block": 0, + "last_delta": 5, + "last_verdict": "PASS", + "violation_counts": {"E501": 4, "W0702": 2}, + "runs": [], + }, + ) + client = LedgerClient(local_path=ledger_path) + + status = client.get_status() + + assert status["coins"] == pytest.approx(12.5) + assert status["streak_pass"] == 3 + assert status["last_verdict"] == "PASS" + + +def test_record_run_uses_reward_delta_divided_by_ten(tmp_path: Path) -> None: + client = LedgerClient(local_path=tmp_path / "ledger.json") + + status = client.record_run(_report(run_id="run-007", score=87)) + + assert status["coins"] == pytest.approx(0.7) diff --git a/tests/unit/specfact_code_review/ledger/test_commands.py b/tests/unit/specfact_code_review/ledger/test_commands.py new file mode 100644 index 00000000..f1ac2649 --- /dev/null +++ b/tests/unit/specfact_code_review/ledger/test_commands.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from typer.testing import CliRunner + +from specfact_code_review.review.commands import app +from specfact_code_review.run.findings import ReviewReport + + +runner = CliRunner() + + +def _report_json() -> str: + report = ReviewReport( + run_id="run-commands-001", + timestamp=datetime(2026, 3, 16, tzinfo=UTC), + score=85, + findings=[], + summary="Command test report.", + ) + return report.model_dump_json() + + +def test_ledger_update_reads_valid_json_stdin_and_calls_record_run(monkeypatch: Any) -> None: + recorded: dict[str, ReviewReport] = {} + + class FakeLedgerClient: + def record_run(self, report: ReviewReport) -> dict[str, object]: + recorded["report"] = report + return {"coins": 0.5, "streak_pass": 1, "streak_block": 0, "last_verdict": "PASS", "top_violations": []} + + monkeypatch.setattr("specfact_code_review.ledger.commands.LedgerClient", FakeLedgerClient) + + result = runner.invoke(app, ["review", "ledger", "update"], input=_report_json()) + + assert result.exit_code == 0 + assert recorded["report"].run_id == "run-commands-001" + + +def test_ledger_update_with_invalid_json_exits_with_error(monkeypatch: Any) -> None: + class FakeLedgerClient: + def record_run(self, report: ReviewReport) -> dict[str, object]: + raise AssertionError("record_run should not be called") + + monkeypatch.setattr("specfact_code_review.ledger.commands.LedgerClient", FakeLedgerClient) + + result = runner.invoke(app, ["review", "ledger", "update"], input="{not-json") + + assert result.exit_code == 1 + assert "Invalid ReviewReport JSON" in result.output + + +def test_ledger_status_prints_current_state(monkeypatch: Any) -> None: + class FakeLedgerClient: + def get_status(self) -> dict[str, object]: + return { + "coins": 7.3, + "streak_pass": 2, + "streak_block": 0, + "last_verdict": "PASS", + "top_violations": [("E501", 3), ("W0702", 1)], + } + + monkeypatch.setattr("specfact_code_review.ledger.commands.LedgerClient", FakeLedgerClient) + + result = runner.invoke(app, ["review", "ledger", "status"]) + + assert result.exit_code == 0 + assert "7.30" in result.output + assert "2" in result.output + assert "PASS" in result.output + + +def test_ledger_reset_without_confirm_refuses_deletion(monkeypatch: Any) -> None: + called = {"reset": False} + + class FakeLedgerClient: + def reset_local(self) -> bool: + called["reset"] = True + return True + + monkeypatch.setattr("specfact_code_review.ledger.commands.LedgerClient", FakeLedgerClient) + + result = runner.invoke(app, ["review", "ledger", "reset"]) + + assert result.exit_code == 1 + assert "--confirm" in result.output + assert called["reset"] is False + + +def test_ledger_reset_with_confirm_clears_local_ledger(monkeypatch: Any) -> None: + called = {"reset": False} + + class FakeLedgerClient: + def reset_local(self) -> bool: + called["reset"] = True + return True + + monkeypatch.setattr("specfact_code_review.ledger.commands.LedgerClient", FakeLedgerClient) + + result = runner.invoke(app, ["review", "ledger", "reset", "--confirm"]) + + assert result.exit_code == 0 + assert called["reset"] is True