From e2a6c0ac9cec5e27a99d292528480a11f33903ed Mon Sep 17 00:00:00 2001 From: kai392 Date: Fri, 31 Jul 2026 04:00:49 +0800 Subject: [PATCH] fix: resolve stats crash on corrupt decided proposal files _list_decided parsed every decided/*.yaml strictly, so one bad file took down vouch stats / kb.stats. Skip unreadable files via _load_or_skip like list_proposals already does. Co-authored-by: Cursor --- CHANGELOG.md | 4 ++++ src/vouch/stats.py | 11 +++++++++-- tests/test_stats.py | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8405f9db..f01bd378 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ All notable changes to vouch are documented here. Format follows artifact the caller could not already retrieve, and it touches no write path. ### Fixed +- **`vouch stats` / `kb.stats` no longer crash on one corrupt `decided/*.yaml`**: + `_list_decided` parsed every decided proposal strictly, so a single bad file + aborted `review_summary` / `collect_stats`. It now uses `_load_or_skip` — + same resilience as `list_proposals` / `list_pages`. - **security: empty-quote receipts no longer clear the auto-approve gate** (#513 reopened, root-caused): `verify_receipt` and `verify_evidence` both guarded only on `quote is None`, not an empty string. An `Evidence` with diff --git a/src/vouch/stats.py b/src/vouch/stats.py index cdd0cc9b..b898e6e6 100644 --- a/src/vouch/stats.py +++ b/src/vouch/stats.py @@ -15,7 +15,7 @@ from . import audit, health from .models import Proposal, ProposalStatus from .proposals import EXPIRE_REASON -from .storage import KBStore, _yaml_load +from .storage import KBStore, _load_or_skip if TYPE_CHECKING: from .scoping import ViewerContext @@ -50,12 +50,19 @@ def _decision_bucket(proposal: Proposal) -> str: def _list_decided(store: KBStore) -> list[Proposal]: + """Load decided proposals for review_summary. + + Uses ``_load_or_skip`` so one corrupt ``decided/*.yaml`` cannot take down + ``vouch stats`` / ``kb.stats`` — same resilience as ``list_proposals``. + """ ddir = store.kb_dir / "decided" if not ddir.is_dir(): return [] out: list[Proposal] = [] for path in sorted(ddir.glob("*.yaml")): - out.append(Proposal.model_validate(_yaml_load(path.read_text(encoding="utf-8")))) + pr = _load_or_skip(path, Proposal, "proposal") + if pr is not None: + out.append(pr) return out diff --git a/tests/test_stats.py b/tests/test_stats.py index 16b26938..553ed375 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -97,6 +97,20 @@ def test_review_summary_counts_decisions(store: KBStore) -> None: assert review["by_agent"]["a2"]["rejected"] == 1 +def test_review_summary_skips_corrupt_decided_file(store: KBStore) -> None: + """One bad decided/*.yaml must not crash vouch stats / kb.stats.""" + src = store.put_source(b"x") + pr = propose_claim(store, text="ok", evidence=[src.id], proposed_by="a") + approve(store, pr.id, approved_by="human") + (store.kb_dir / "decided" / "corrupt.yaml").write_text( + "not: [valid\n", encoding="utf-8", + ) + review = stats.review_summary(store, since_days=None) + assert review["approved"] == 1 + body = stats.collect_stats(store, since_days=None) + assert body["review"]["approved"] == 1 + + def test_review_summary_respects_window(store: KBStore) -> None: src = store.put_source(b"x") pr = propose_claim(store, text="old", evidence=[src.id], proposed_by="a")