diff --git a/CHANGELOG.md b/CHANGELOG.md index 7559333a..5e7019a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ All notable changes to vouch are documented here. Format follows artifact the caller could not already retrieve, and it touches no write path. ### Fixed +- **`verify_all` / `doctor` treat missing externals like drift** (#622): + `vouch source verify` already marked `external_status=missing` as `!`, + but `verify_all`'s audit `failed` list and `health.doctor` only looked + for `drift`, so a deleted upstream file could leave doctor `ok: true` + while the CLI failed. missing now joins the failed set and emits a + `source_missing` warning. - **salience reflex excludes retracted claims**: `compute_salience` scanned every claim regardless of status, so the `_meta.vouch_salience` sidebar counted `ARCHIVED` / `SUPERSEDED` / `REDACTED` claims in `claim_count` and diff --git a/src/vouch/health.py b/src/vouch/health.py index cb474448..ce0269a2 100644 --- a/src/vouch/health.py +++ b/src/vouch/health.py @@ -313,6 +313,17 @@ def doctor(store: KBStore) -> HealthReport: [vr.source.id], ) ) + elif vr.external_status == "missing": + detail = f" ({vr.note})" if vr.note else "" + report.findings.append( + Finding( + "warning", + "source_missing", + f"external file {vr.source.locator} missing or unreadable" + f" since registration{detail}", + [vr.source.id], + ) + ) # Config sanity. if not store.config_path.exists(): diff --git a/src/vouch/verify.py b/src/vouch/verify.py index e4e3a887..d7244143 100644 --- a/src/vouch/verify.py +++ b/src/vouch/verify.py @@ -62,7 +62,7 @@ def verify_all(store: KBStore, *, actor: str = "vouch-verify" results = [verify_source(store, s) for s in store.list_sources()] failed = [ r.source.id for r in results - if not r.stored_ok or r.external_status == "drift" + if not r.stored_ok or r.external_status in {"drift", "missing"} ] audit.log_event( store.kb_dir, event="source.verify", actor=actor, diff --git a/tests/test_health.py b/tests/test_health.py index 822d8a76..f81374c1 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -59,6 +59,25 @@ def test_doctor_runs_full_sweep(store: KBStore) -> None: assert report.ok is True +def test_doctor_warns_on_missing_external_file(store: KBStore, tmp_path: Path) -> None: + """source verify marks missing externals as '!'; doctor must surface + them too (not only drift).""" + f = tmp_path / "doc.txt" + f.write_bytes(b"original") + src = store.put_source( + f.read_bytes(), title="doc", + locator=str(f.resolve()), source_type="file", + ) + f.unlink() + report = health.doctor(store) + missing = [f for f in report.findings if f.code == "source_missing"] + assert missing, [f.code for f in report.findings] + assert missing[0].severity == "warning" + assert src.id in missing[0].object_ids + # warning-only — same posture as source_drift + assert report.ok is True + + def test_lint_surfaces_legacy_uncited_claim_yaml_without_crashing( store: KBStore, ) -> None: diff --git a/tests/test_verify.py b/tests/test_verify.py index fd076b2b..f3a47f55 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -30,6 +30,34 @@ def test_verify_detects_external_drift(store: KBStore, tmp_path: Path) -> None: assert target.external_status == "drift" +def test_verify_all_counts_missing_external_as_failed( + store: KBStore, tmp_path: Path, +) -> None: + """CLI source verify treats missing as '!'; verify_all's audit failed + list must include it too (not only drift).""" + from vouch import audit + + f = tmp_path / "doc.txt" + f.write_bytes(b"original") + src = store.put_source( + f.read_bytes(), title="doc", + locator=str(f.resolve()), source_type="file", + ) + f.unlink() + results = verify.verify_all(store) + target = next(r for r in results if r.source.id == src.id) + assert target.stored_ok is True + assert target.external_status == "missing" + + events = [ + e for e in audit.read_events(store.kb_dir) + if e.event == "source.verify" + ] + assert events + assert src.id in events[-1].object_ids + assert events[-1].data["failed"] >= 1 + + def test_verify_refuses_off_root_file_locator(store: KBStore, tmp_path: Path) -> None: outside = tmp_path.parent / f"{tmp_path.name}-outside.txt" outside.write_bytes(b"secret")