From eefb941add227eb9226531fad1b8a64f1fb8107c Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 17:54:56 +0800 Subject: [PATCH 1/2] fix: resolve list_pages crash on corrupt page files list_pages() lacked the _load_or_skip resilience applied to other list_* paths, so one bad pages/*.md file crashed status, search, lint, and MCP tools. Skip unreadable pages with a warning instead. Co-authored-by: Cursor --- src/vouch/storage.py | 12 +++++++++++- tests/test_storage.py | 9 +++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/vouch/storage.py b/src/vouch/storage.py index bd8987b0..b6fecd8c 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -203,6 +203,15 @@ def _deserialize_page(text: str) -> Page: return Page(body=body, **meta) +def _load_page_or_skip(path: Path) -> Page | None: + """Parse one page file; skip corrupt/unreadable files like ``_load_or_skip``.""" + try: + return _deserialize_page(path.read_text(encoding="utf-8")) + except (yaml.YAMLError, ValidationError, UnicodeDecodeError, OSError, ValueError) as e: + _log.warning("skipping unreadable page %s: %s", path.name, e) + return None + + class KBStore: """File-backed CRUD layer. Pure I/O — no business logic.""" @@ -560,8 +569,9 @@ def list_pages(self) -> list[Page]: if not pdir.is_dir(): return [] return [ - _deserialize_page(p.read_text(encoding="utf-8")) + pg for p in sorted(pdir.glob("*.md")) + if (pg := _load_page_or_skip(p)) is not None ] # --- entities ---------------------------------------------------------- diff --git a/tests/test_storage.py b/tests/test_storage.py index 7e0ccdd6..c59a7f6f 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -927,3 +927,12 @@ def test_list_claims_skips_unreadable_file(store: KBStore) -> None: claims = store.list_claims() assert [c.id for c in claims] == ["c-ok"] + + +def test_list_pages_skips_unreadable_file(store: KBStore) -> None: + """One corrupt page must not take down vouch search/status/lint.""" + store.put_page(Page(id="p-ok", title="ok", body="content")) + (store.kb_dir / "pages" / "p-bad.md").write_text("not valid frontmatter") + + pages = store.list_pages() + assert [p.id for p in pages] == ["p-ok"] From 96576925a79d6e5a1da8a4a6288e80a22b8fe07b Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sat, 11 Jul 2026 05:59:23 +0800 Subject: [PATCH 2/2] docs(scripts): add repro script for list_pages crash (PR #360) Gives maintainers a one-command proof of the pre-fix crash path and the fixed skip-and-continue behavior. Co-authored-by: Cursor --- scripts/repro_list_pages_crash.py | 78 +++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 scripts/repro_list_pages_crash.py diff --git a/scripts/repro_list_pages_crash.py b/scripts/repro_list_pages_crash.py new file mode 100644 index 00000000..6cc35944 --- /dev/null +++ b/scripts/repro_list_pages_crash.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Proof for PR #360: one corrupt pages/*.md crashes bulk listing on main. + +Run from the repo root:: + + python scripts/repro_list_pages_crash.py + +On **main** (before the fix), ``store.list_pages()`` raises:: + + ValueError: page file missing YAML frontmatter + +That exception propagates through ``health.lint()``, ``health.status()``, +``kb.list_pages``, and any other caller of ``list_pages()`` — one bad file +takes down the whole KB listing surface. + +With the fix branch applied, ``list_pages()`` logs a warning and returns +only the readable pages. +""" + +from __future__ import annotations + +import shutil +import sys +import tempfile +from pathlib import Path + +from vouch import health +from vouch.models import Claim, Page, PageType +from vouch.storage import KBStore, _deserialize_page + + +def main() -> int: + root = Path(tempfile.mkdtemp(prefix="vouch-repro-list-pages-")) + try: + store = KBStore.init(root) + src = store.put_source(b"evidence") + store.put_claim(Claim(id="c1", text="fact", evidence=[src.id])) + store.put_page(Page(id="good-page", title="Good", body="ok", type=PageType.CONCEPT)) + bad_path = store.kb_dir / "pages" / "bad-page.md" + bad_path.write_text("not valid frontmatter — no YAML block", encoding="utf-8") + + print("=== 1. Direct deserialize (always fails on corrupt file) ===") + try: + _deserialize_page(bad_path.read_text(encoding="utf-8")) + print("unexpected: _deserialize_page succeeded") + except ValueError as e: + print(f"ValueError: {e}") + + print("\n=== 2. store.list_pages() ===") + try: + pages = store.list_pages() + print(f"OK — returned {len(pages)} page(s): {[p.id for p in pages]}") + print("(fix branch: bad-page.md skipped with a logged warning)") + except ValueError as e: + print(f"CRASH — ValueError: {e}") + print("(main branch: entire listing aborts here)") + + print("\n=== 3. health.lint(store) ===") + try: + report = health.lint(store) + print(f"OK — lint finished, ok={report.ok}, findings={len(report.findings)}") + except ValueError as e: + print(f"CRASH — ValueError: {e}") + + print("\n=== 4. health.status(store) ===") + try: + summary = health.status(store) + print(f"OK — pages={summary['pages']}") + except ValueError as e: + print(f"CRASH — ValueError: {e}") + + return 0 + finally: + shutil.rmtree(root, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main())