diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ce3ec3f..f1d1aa11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,16 @@ All notable changes to vouch are documented here. Format follows per-prompt block, the session banner, `vouch status` and the opt-in question all say so rather than calling it "this repo's" knowledge. +### Fixed +- `sync_vault` catches the `ProposalError` that `propose_page` raises for a + deleted citation (missing claim/entity/source id, with + `ArtifactNotFoundError` as cause), not just the raw + `ArtifactNotFoundError`. other proposal failures (empty title, page-kind + validation) surface as a neutral `vault edit rejected` `VaultSyncError` + rather than being mislabelled as an unknown artifact. the CLI's + `except VaultSyncError` renderer then prints a one-line `Error:` instead + of an uncaught traceback (#547). + ## [1.5.0] — 2026-07-20 ### Added diff --git a/src/vouch/vault_sync.py b/src/vouch/vault_sync.py index d7a7f027..f3e0d0e7 100644 --- a/src/vouch/vault_sync.py +++ b/src/vouch/vault_sync.py @@ -51,7 +51,7 @@ ClaimStatus, PageStatus, ) -from .proposals import propose_page +from .proposals import ProposalError, propose_page from .storage import ( ArtifactNotFoundError, KBStore, @@ -465,10 +465,20 @@ def sync_vault( try: r = vault_to_kb(store, vault_dir, actor=actor) except ArtifactNotFoundError as e: - # A vault edit referenced a claim/entity/source that no longer - # exists in the KB. That's a *real* conflict the user has to + # a vault edit referenced a claim/entity/source that no longer + # exists in the KB. that's a *real* conflict the user has to # resolve in Obsidian, not a vouch bug; surface it cleanly. raise VaultSyncError(f"vault edit references unknown artifact: {e}") from e + except ProposalError as e: + # propose_page converts missing claim/entity/source ids to + # ProposalError (ArtifactNotFoundError as __cause__), so the + # handler above never fired and the error escaped as a traceback + # past the CLI's VaultSyncError renderer. other ProposalErrors + # (empty title, page-kind validation) stay distinct — don't + # mislabel them as unknown artifacts. + if isinstance(e.__cause__, ArtifactNotFoundError): + raise VaultSyncError(f"vault edit references unknown artifact: {e}") from e + raise VaultSyncError(f"vault edit rejected: {e}") from e combined.pages_proposed.extend(r.pages_proposed) combined.pages_skipped_unchanged.extend(r.pages_skipped_unchanged) combined.pages_skipped_unknown_id.extend(r.pages_skipped_unknown_id) diff --git a/tests/test_vault_sync.py b/tests/test_vault_sync.py index b765b08c..82f9c156 100644 --- a/tests/test_vault_sync.py +++ b/tests/test_vault_sync.py @@ -248,6 +248,55 @@ def test_sync_vault_rejects_missing_vault(store: KBStore, tmp_path: Path) -> Non sync_vault(store, tmp_path / "does-not-exist") +def test_sync_vault_surfaces_deleted_citation_as_vault_sync_error( + store: KBStore, vault: Path, +) -> None: + """a vault edit to a page whose cited claim was since deleted must + surface as a clean VaultSyncError, not the raw ProposalError that + propose_page raises for an unknown id — otherwise it escapes the dead + `except ArtifactNotFoundError` handler as an uncaught traceback, + bypassing the CLI's VaultSyncError renderer. fixture cites `alpha-claim`.""" + kb_to_vault(store, vault) + mirror = vault / VAULT_DIR / "pages" / "alpha-page.md" + mirror.write_text( + mirror.read_text(encoding="utf-8").replace("Original body.", "Edited."), + encoding="utf-8", + ) + # delete the cited claim so propose_page's id validation fails. + (store.kb_dir / "claims" / "alpha-claim.yaml").unlink() + + with pytest.raises(VaultSyncError, match="unknown artifact"): + sync_vault(store, vault, direction="forward") + + +def test_sync_vault_does_not_mislabel_non_artifact_proposal_errors( + store: KBStore, vault: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """other ProposalErrors from propose_page (e.g. empty title) must not + be reported as 'unknown artifact' — that wording is reserved for + missing claim/entity/source ids (#547 / CodeRabbit on #548). empty + titles are caught earlier by deserialise-and-skip, so drive the + non-artifact path by stubbing propose_page.""" + from vouch.proposals import ProposalError + + kb_to_vault(store, vault) + mirror = vault / VAULT_DIR / "pages" / "alpha-page.md" + mirror.write_text( + mirror.read_text(encoding="utf-8").replace("Original body.", "Edited."), + encoding="utf-8", + ) + + def _reject(*_args: object, **_kwargs: object) -> None: + raise ProposalError("page title is empty") + + monkeypatch.setattr("vouch.vault_sync.propose_page", _reject) + + with pytest.raises(VaultSyncError, match="vault edit rejected") as ei: + sync_vault(store, vault, direction="forward") + assert "unknown artifact" not in str(ei.value) + assert "page title is empty" in str(ei.value) + + # --- CLI surface ---------------------------------------------------------- @@ -291,6 +340,36 @@ def test_cli_sync_missing_vault_is_clean_error( assert "Error:" in result.output +def test_cli_sync_deleted_citation_is_clean_error( + store: KBStore, vault: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """deleted-citation must hit the CLI's VaultSyncError renderer as a + one-line Error:, not an uncaught ProposalError traceback (#547).""" + monkeypatch.chdir(store.root) + runner = CliRunner() + runner.invoke(cli, ["sync", "--vault", str(vault)]) + mirror = vault / VAULT_DIR / "pages" / "alpha-page.md" + mirror.write_text( + mirror.read_text(encoding="utf-8").replace("Original body.", "Edited."), + encoding="utf-8", + ) + (store.kb_dir / "claims" / "alpha-claim.yaml").unlink() + + result = runner.invoke( + cli, ["sync", "--vault", str(vault), "--direction", "forward"], + ) + assert result.exit_code != 0 + assert "Traceback" not in result.output + # documented CLI contract (#547): exactly one Error: line, not a + # multiline non-traceback rendering that still contains "Error:". + lines = [line for line in result.output.splitlines() if line.strip()] + assert lines == [ + "Error: vault edit references unknown artifact: unknown claim id: alpha-claim" + ] + assert "unknown artifact" in lines[0] + assert "unknown claim id" in lines[0] + + def test_cli_sync_requires_vault_flag( store: KBStore, monkeypatch: pytest.MonkeyPatch, ) -> None: