import pytest import yaml from tht.evidence.authoring import ( EvidencePreparationError, normalize_source_text, prepare_workspace_evidence, ) from tht.evidence.canonical import ( CuratedEvidence, ManualEvidenceProvenance, dump_curated_markdown, parse_curated_markdown, ) from tht.evidence.local_archive import ArchiveConflict, LocalEvidenceArchive, _digest def write_unit(root, *, rule="Use order ID and year.", source=False): provenance = {"kind": "manual", "declared_by": "local curator"} if source: path = root / "evidence/source/orders.md" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(rule) provenance = { "source_file": "source/orders.md", "source_sha256": "sha256:" + _digest(normalize_source_text(rule).encode()), "supporting_excerpts": [rule], } unit = CuratedEvidence.model_validate( { "schema_version": 4, "id": "evidence:order-key", "title": "Order key", "kind": "domain", "language": "en", "purposes": ["sql_generation"], "payload": {"rule": rule}, "provenance": provenance, } ) path = root / "evidence/curated/domain/order-key.md" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(dump_curated_markdown(unit)) return path def read_active(archive): return parse_curated_markdown( (archive.active_snapshot() / "curated/domain/order-key.md").read_text() ) def test_manual_creation_and_visible_edit_reach_only_the_consolidated_snapshot(tmp_path): path = write_unit(tmp_path) archive = LocalEvidenceArchive(tmp_path) first = archive.consolidate(actor="Alice", activate=lambda _: None) assert first["status"] == "active" assert read_active(archive).provenance.declared_by == "Alice" path.write_text(path.read_text().replace("Use order ID and year.", "Use ID, year and company.")) assert read_active(archive).payload.rule == "Use order ID and year." candidate = archive.consolidate(actor="Bob") assert candidate["status"] == "pending_activation" assert read_active(archive).payload.rule == "Use order ID and year." archive.consolidate(actor="Bob", activate=lambda _: None) assert read_active(archive).payload.rule == "Use ID, year and company." assert read_active(archive).provenance.declared_by == "Bob" def test_document_correction_becomes_manual_and_retains_original_source_separately(tmp_path): path = write_unit(tmp_path, source=True) archive = LocalEvidenceArchive(tmp_path) archive.initialize() original = parse_curated_markdown(path.read_text()).provenance path.write_text( path.read_text().replace( "## Rule\n\nUse order ID and year.", "## Rule\n\nThe approved key also includes company.", ) ) archive.consolidate(actor="Curator", activate=lambda _: None) current = read_active(archive) assert isinstance(current.provenance, ManualEvidenceProvenance) assert current.provenance.original == original assert current.provenance.declared_by == "Curator" assert (archive.active_snapshot() / "source/orders.md").read_text() == "Use order ID and year." def test_failed_activation_and_restart_retry_reuse_the_candidate(tmp_path): path = write_unit(tmp_path) archive = LocalEvidenceArchive(tmp_path) archive.consolidate(actor="Alice", activate=lambda _: None) previous = archive.active_snapshot() path.write_text( path.read_text().replace("Use order ID and year.", "Use the reviewed composite key.") ) calls = [] def fail(candidate): calls.append(candidate) raise RuntimeError("Index unavailable") with pytest.raises(RuntimeError, match="Index unavailable"): archive.consolidate(actor="Alice", activate=fail) recovered = LocalEvidenceArchive(tmp_path) assert recovered.active_snapshot() == previous result = recovered.consolidate(actor="Alice", activate=calls.append) assert calls[0] == calls[1] assert result["status"] == "active" def test_deleted_source_unit_stays_deleted_after_restart_and_refinement_attempt(tmp_path): path = write_unit(tmp_path, source=True) archive = LocalEvidenceArchive(tmp_path) archive.initialize() archive.consolidate(actor="Alice", activate=lambda _: None) path.unlink() archive.consolidate(actor="Alice", activate=lambda _: None) state = yaml.safe_load((tmp_path / "evidence/.local/state.yaml").read_text()) assert state["deleted_ids"] == ["evidence:order-key"] assert state["suppressed_sources"] == ["source/orders.md"] class MustNotCall: def restructure(self, request): raise AssertionError("Deleted knowledge must not be regenerated") with pytest.raises(EvidencePreparationError, match="explicit_source_refresh"): prepare_workspace_evidence(tmp_path, restructurer=MustNotCall(), git_status=lambda _: ()) assert not path.exists() assert not list(LocalEvidenceArchive(tmp_path).active_snapshot().rglob("*.md")) def test_invalid_edit_does_not_change_files_metadata_or_active_snapshot(tmp_path): path = write_unit(tmp_path) archive = LocalEvidenceArchive(tmp_path) archive.consolidate(actor="Alice", activate=lambda _: None) old = archive.active_snapshot() state = (tmp_path / "evidence/.local/state.yaml").read_bytes() path.write_text(path.read_text().replace("## Rule", "## Wrong heading")) invalid = path.read_bytes() with pytest.raises(ValueError, match="curated/domain/order-key.md.*section"): archive.consolidate(actor="Alice", activate=lambda _: pytest.fail("Must not activate")) assert path.read_bytes() == invalid assert (tmp_path / "evidence/.local/state.yaml").read_bytes() == state assert archive.active_snapshot() == old def test_workflow_correction_checks_revision_and_never_overwrites_a_manual_edit(tmp_path): path = write_unit(tmp_path) archive = LocalEvidenceArchive(tmp_path) archive.consolidate(actor="Alice", activate=lambda _: None) before = archive.get("evidence:order-key") proposed = before["unit"].model_copy(update={"title": "Workflow correction"}) path.write_text(path.read_text().replace("Use order ID and year.", "Manual edit in progress.")) with pytest.raises(ArchiveConflict): archive.save(proposed, expected_revision=before["revision"], actor="Reviewer") assert "Manual edit in progress." in path.read_text() latest = archive.get("evidence:order-key") archive.save( latest["unit"].model_copy(update={"title": "Approved title"}), expected_revision=latest["revision"], actor="Reviewer", ) assert archive.get("evidence:order-key")["unit"].title == "Approved title" def test_source_refresh_does_not_replace_manual_content_or_historical_citation(tmp_path): path = write_unit(tmp_path, source=True) archive = LocalEvidenceArchive(tmp_path) archive.initialize() path.write_text( path.read_text().replace("## Rule\n\nUse order ID and year.", "## Rule\n\nManual rule.") ) archive.consolidate(actor="Alice", activate=lambda _: None) (tmp_path / "evidence/source/orders.md").write_text("Contradictory newer source.") archive.consolidate(actor="Alice", activate=lambda _: None) assert read_active(archive).payload.rule == "Manual rule." assert (archive.active_snapshot() / "source/orders.md").read_text() == "Use order ID and year." def test_symlinks_cannot_escape_the_archive(tmp_path): path = write_unit(tmp_path) path.unlink() outside = tmp_path / "outside.md" outside.write_text("Private unrelated file") path.symlink_to(outside) with pytest.raises(ValueError, match="symlinks"): LocalEvidenceArchive(tmp_path).consolidate(actor="Alice") @pytest.mark.parametrize("edit_after_interruption", [False, True]) def test_interrupted_normalization_recovers_without_losing_new_edits( tmp_path, monkeypatch, edit_after_interruption ): import tht.evidence.local_archive as module path = write_unit(tmp_path, source=True) archive = LocalEvidenceArchive(tmp_path) archive.initialize() archive.consolidate(actor="Alice", activate=lambda _: None) old_active = archive.active_snapshot() path.write_text( path.read_text().replace("## Rule\n\nUse order ID and year.", "## Rule\n\nReviewed rule.") ) atomic = module._atomic def interrupted(target, data): if target == path: raise OSError("Interrupted file write") atomic(target, data) monkeypatch.setattr(module, "_atomic", interrupted) with pytest.raises(OSError, match="Interrupted"): archive.consolidate(actor="Bob", activate=lambda _: pytest.fail("Must not activate")) assert archive.active_snapshot() == old_active if edit_after_interruption: path.write_text(path.read_text().replace("Reviewed rule.", "Further manual correction.")) monkeypatch.setattr(module, "_atomic", atomic) recovered = LocalEvidenceArchive(tmp_path) recovered.consolidate(actor="Bob", activate=lambda _: None) assert read_active(recovered).payload.rule == ( "Further manual correction." if edit_after_interruption else "Reviewed rule." ) assert read_active(recovered).provenance.declared_by == "Bob" assert read_active(recovered).provenance.original.source_file == "source/orders.md" def test_missing_archive_directory_is_not_interpreted_as_deletion(tmp_path): write_unit(tmp_path) archive = LocalEvidenceArchive(tmp_path) archive.consolidate(actor="Alice", activate=lambda _: None) active = archive.active_snapshot() (archive.evidence / "curated").rename(archive.evidence / "unmounted") with pytest.raises(ValueError, match="absence is not deletion"): archive.consolidate(actor="Alice", activate=lambda _: pytest.fail("Must not activate")) assert archive.active_snapshot() == active