import hashlib import unicodedata import pytest from pydantic import ValidationError from tht.evidence import ( CuratedEvidence, EvidenceManifest, EvidencePreparationError, EvidenceRestructurer, RestructureCandidate, dump_curated_markdown, dump_manifest, load_curated_tree, load_manifest, prepare_workspace_evidence, validate_workspace_evidence, ) def _evidence(source_text: str, *, review_items=()): normalized_source = unicodedata.normalize( "NFC", source_text.replace("\r\n", "\n").replace("\r", "\n"), ) if not normalized_source.endswith("\n"): normalized_source += "\n" return CuratedEvidence.model_validate({ "schema_version": 1, "id": "evidence:fascia-pediatrica", "title": "Fascia pediatrica", "kind": "domain", "purposes": ["disambiguation"], "applies_to": {"concepts": ["fascia pediatrica"]}, "language": "it", "provenance": { "source_file": "source/domain/patient.md", "source_sha256": "sha256:" + hashlib.sha256(normalized_source.encode()).hexdigest(), "supporting_excerpts": ["I pazienti sotto i 18 anni sono pediatrici."], }, "review_items": list(review_items), "payload": {"rule": "La fascia pediatrica comprende i minori."}, }) def _write_workspace(root, evidence, source_text): evidence_root = root / "evidence" source_path = evidence_root / "source" / "domain" / "patient.md" curated_path = evidence_root / "curated" / "domain" / "fascia-pediatrica.md" source_path.parent.mkdir(parents=True) curated_path.parent.mkdir(parents=True) source_path.write_text(source_text, encoding="utf-8") curated_path.write_text(dump_curated_markdown(evidence), encoding="utf-8") (evidence_root / "manifest.yaml").write_text(dump_manifest(EvidenceManifest.model_validate({ "schema_version": 1, "pipeline_version": "evidence-authoring-v1", "sources": { evidence.provenance.source_file: { "sha256": evidence.provenance.source_sha256, "units": [evidence.id], }, }, "orphans": [], })), encoding="utf-8") def test_workspace_validation_allows_a_coherent_curated_unit(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) report = validate_workspace_evidence(tmp_path) assert report.publishable is True assert report.findings == () def test_workspace_validation_keeps_review_items_visible_and_blocks_publication(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text, review_items=[{ "code": "ambiguous_source_statement", "message": "Il sorgente non chiarisce la data di riferimento.", "field": "payload.rule", }]), source_text) report = validate_workspace_evidence(tmp_path) assert report.publishable is False assert [finding.code for finding in report.findings] == ["unresolved_review_item"] def test_manifest_round_trip_is_versioned_and_deterministic(tmp_path): manifest = EvidenceManifest.model_validate({ "schema_version": 1, "pipeline_version": "evidence-authoring-v1", "sources": { "source/domain/patient.md": { "sha256": "sha256:" + "a" * 64, "units": ["evidence:fascia-pediatrica"], }, }, "orphans": [], }) path = tmp_path / "evidence" / "manifest.yaml" path.parent.mkdir(parents=True) path.write_text(dump_manifest(manifest), encoding="utf-8") loaded = load_manifest(path) assert loaded == manifest assert path.read_text(encoding="utf-8") == dump_manifest(manifest) def test_manifest_rejects_unknown_fields_and_incompatible_pipeline_version(): with pytest.raises(ValidationError): EvidenceManifest.model_validate({ "schema_version": 1, "pipeline_version": "evidence-authoring-v2", "sources": {}, "orphans": [], "approved": True, }) def test_workspace_validation_requires_a_manifest(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) (tmp_path / "evidence" / "manifest.yaml").unlink() report = validate_workspace_evidence(tmp_path) assert report.publishable is False assert [finding.code for finding in report.findings] == ["manifest_missing"] def test_workspace_validation_reports_a_non_utf8_manifest_without_raising(tmp_path): evidence_root = tmp_path / "evidence" evidence_root.mkdir() (evidence_root / "manifest.yaml").write_bytes(b"\xff") report = validate_workspace_evidence(tmp_path) assert report.publishable is False assert [finding.code for finding in report.findings] == ["manifest_invalid"] def test_workspace_validation_reports_invalid_curated_documents_without_raising(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) (tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md").write_text( "not canonical frontmatter", encoding="utf-8", ) report = validate_workspace_evidence(tmp_path) assert report.publishable is False assert [finding.code for finding in report.findings] == ["curated_invalid"] def test_workspace_validation_preserves_manifest_orphans_but_blocks_publication(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) manifest_path = tmp_path / "evidence" / "manifest.yaml" manifest = load_manifest(manifest_path).model_copy(update={"orphans": ("evidence:retired",)}) manifest_path.write_text(dump_manifest(manifest), encoding="utf-8") report = validate_workspace_evidence(tmp_path) assert report.publishable is False assert [finding.code for finding in report.findings] == ["orphaned_unit"] def test_workspace_validation_requires_manifest_and_provenance_to_agree(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." evidence = _evidence(source_text) _write_workspace(tmp_path, evidence, source_text) manifest_path = tmp_path / "evidence" / "manifest.yaml" manifest = EvidenceManifest.model_validate({ "schema_version": 1, "pipeline_version": "evidence-authoring-v1", "sources": { evidence.provenance.source_file: { "sha256": "sha256:" + "b" * 64, "units": ["evidence:another-unit"], }, }, "orphans": [], }) manifest_path.write_text(dump_manifest(manifest), encoding="utf-8") report = validate_workspace_evidence(tmp_path) assert report.publishable is False assert {finding.code for finding in report.findings} == { "manifest_source_hash_mismatch", "manifest_unit_missing", "source_hash_mismatch", "manifest_unit_without_curated", } def test_workspace_validation_checks_unreferenced_manifest_sources_and_units(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) manifest_path = tmp_path / "evidence" / "manifest.yaml" manifest = EvidenceManifest.model_validate({ "schema_version": 1, "pipeline_version": "evidence-authoring-v1", "sources": { "source/domain/missing.md": { "sha256": "sha256:" + "a" * 64, "units": ["evidence:removed"], }, }, "orphans": [], }) manifest_path.write_text(dump_manifest(manifest), encoding="utf-8") report = validate_workspace_evidence(tmp_path) assert report.publishable is False assert {finding.code for finding in report.findings} == { "manifest_source_missing", "source_missing", "manifest_unit_without_curated", } def test_manifest_rejects_unsafe_source_paths_and_legacy_unit_identifiers(): with pytest.raises(ValidationError): EvidenceManifest.model_validate({ "schema_version": 1, "pipeline_version": "evidence-authoring-v1", "sources": {"../patient.pdf": {"sha256": "sha256:" + "a" * 64, "units": ["domain:patient"]}}, "orphans": [], }) def test_manifest_rejects_a_unit_declared_by_two_sources(): with pytest.raises(ValidationError, match="only one source"): EvidenceManifest.model_validate({ "schema_version": 1, "pipeline_version": "evidence-authoring-v1", "sources": { "source/domain/one.md": { "sha256": "sha256:" + "a" * 64, "units": ["evidence:shared"], }, "source/domain/two.md": { "sha256": "sha256:" + "b" * 64, "units": ["evidence:shared"], }, }, "orphans": [], }) def test_workspace_validation_rejects_duplicate_curated_ids_and_changed_sources(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." evidence = _evidence(source_text) _write_workspace(tmp_path, evidence, source_text) duplicate_path = tmp_path / "evidence" / "curated" / "domain" / "duplicate.md" duplicate_path.write_text(dump_curated_markdown(evidence), encoding="utf-8") (tmp_path / "evidence" / "source" / "domain" / "patient.md").write_text( "Il testo sorgente è cambiato.", encoding="utf-8", ) report = validate_workspace_evidence(tmp_path) assert report.publishable is False assert {finding.code for finding in report.findings} == { "duplicate_evidence_id", "source_hash_mismatch", "supporting_excerpt_missing", } def test_workspace_validation_reports_unreadable_or_oversized_sources(tmp_path, monkeypatch): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) source_path = tmp_path / "evidence" / "source" / "domain" / "patient.md" source_path.write_bytes(b"\xff") unreadable = validate_workspace_evidence(tmp_path) assert [finding.code for finding in unreadable.findings] == ["source_unreadable"] source_path.write_text(source_text, encoding="utf-8") monkeypatch.setattr("tht.evidence.authoring.MAX_AUTHORING_FILE_BYTES", 1) oversized = validate_workspace_evidence(tmp_path) assert [finding.code for finding in oversized.findings] == ["source_oversized"] def test_workspace_validation_rejects_a_source_symlink(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) source_path = tmp_path / "evidence" / "source" / "domain" / "patient.md" outside = tmp_path / "outside.md" outside.write_text(source_text, encoding="utf-8") source_path.unlink() source_path.symlink_to(outside) report = validate_workspace_evidence(tmp_path) assert [finding.code for finding in report.findings] == ["source_unsafe"] class _Restructurer(EvidenceRestructurer): def __init__(self, candidates): self.candidates = candidates self.requests = [] def restructure(self, request): self.requests.append(request) return tuple(self.candidates) def _candidate(*, title="Fascia pediatrica", existing_id=None, kind="domain", payload=None): return RestructureCandidate.model_validate({ "schema_version": 1, "existing_id": existing_id, "title": title, "kind": kind, "purposes": ["disambiguation"], "applies_to": {"concepts": ["fascia pediatrica"]}, "language": "it", "supporting_excerpts": ["I pazienti sotto i 18 anni sono pediatrici."], "review_items": [], "payload": payload or {"rule": "La fascia pediatrica comprende i minori."}, }) def test_prepare_changed_source_uses_one_model_call_and_applies_a_valid_batch(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) source_path = tmp_path / "evidence" / "source" / "domain" / "patient.md" source_path.write_text(source_text + " La regola è revisionata.\n", encoding="utf-8") restructurer = _Restructurer([_candidate(existing_id="evidence:fascia-pediatrica")]) report = prepare_workspace_evidence(tmp_path, restructurer=restructurer, git_status=lambda _: ()) assert report.model_calls == 1 assert report.changed == ("source/domain/patient.md",) assert report.created == () assert len(restructurer.requests) == 1 assert restructurer.requests[0].previous_units[0].id == "evidence:fascia-pediatrica" assert validate_workspace_evidence(tmp_path).publishable is True def test_prepare_unchanged_source_skips_model_and_does_not_write(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md" original = curated_path.read_text(encoding="utf-8") restructurer = _Restructurer([]) report = prepare_workspace_evidence(tmp_path, restructurer=restructurer, git_status=lambda _: ()) assert report.model_calls == 0 assert report.unchanged == ("source/domain/patient.md",) assert curated_path.read_text(encoding="utf-8") == original assert restructurer.requests == [] def test_prepare_rejects_dirty_curated_state_before_model_call(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) restructurer = _Restructurer([]) with pytest.raises(EvidencePreparationError, match="authoring_worktree_dirty"): prepare_workspace_evidence( tmp_path, restructurer=restructurer, git_status=lambda _: (" M evidence/curated/domain/patient.md",), ) assert restructurer.requests == [] def test_prepare_refuses_an_incompatible_pipeline_version_without_writing(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) manifest_path = tmp_path / "evidence" / "manifest.yaml" curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md" manifest_path.write_text( manifest_path.read_text(encoding="utf-8").replace("evidence-authoring-v1", "evidence-authoring-v2"), encoding="utf-8", ) original_manifest = manifest_path.read_text(encoding="utf-8") original_curated = curated_path.read_text(encoding="utf-8") with pytest.raises(EvidencePreparationError, match="pipeline_upgrade_required"): prepare_workspace_evidence(tmp_path, restructurer=_Restructurer([]), git_status=lambda _: ()) assert manifest_path.read_text(encoding="utf-8") == original_manifest assert curated_path.read_text(encoding="utf-8") == original_curated def test_prepare_preserves_an_id_when_reclassified_and_allocates_a_new_id_for_a_split(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) source_path = tmp_path / "evidence" / "source" / "domain" / "patient.md" source_path.write_text(source_text + " I pazienti sopra i 18 anni sono adulti.\n", encoding="utf-8") restructurer = _Restructurer([ _candidate( existing_id="evidence:fascia-pediatrica", kind="glossary", payload={"definition": "Paziente con meno di 18 anni."}, ), _candidate(title="Fascia adulta"), ]) report = prepare_workspace_evidence(tmp_path, restructurer=restructurer, git_status=lambda _: ()) assert report.created == ("evidence:fascia-adulta",) documents = {document.id: document for document in load_curated_tree(tmp_path / "evidence" / "curated")} assert documents["evidence:fascia-pediatrica"].kind == "glossary" assert documents["evidence:fascia-adulta"].kind == "domain" def test_prepare_retains_units_from_a_removed_source_as_blocking_orphans(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) (tmp_path / "evidence" / "source" / "domain" / "patient.md").unlink() restructurer = _Restructurer([]) report = prepare_workspace_evidence(tmp_path, restructurer=restructurer, git_status=lambda _: ()) assert report.model_calls == 0 assert report.orphaned == ("evidence:fascia-pediatrica",) assert (tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md").is_file() assert [finding.code for finding in report.findings] == ["orphaned_unit"] def test_prepare_preserves_ids_without_orphans_for_a_unique_source_rename(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) source_root = tmp_path / "evidence" / "source" / "domain" (source_root / "patient.md").rename(source_root / "patient-age.md") restructurer = _Restructurer([]) report = prepare_workspace_evidence(tmp_path, restructurer=restructurer, git_status=lambda _: ()) assert report.model_calls == 0 assert report.orphaned == () assert report.unchanged == ("source/domain/patient-age.md",) assert validate_workspace_evidence(tmp_path).publishable is True def test_prepare_rejects_an_unknown_existing_id_without_writing_the_batch(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) source_path = tmp_path / "evidence" / "source" / "domain" / "patient.md" source_path.write_text(source_text + " Revisione.\n", encoding="utf-8") original = (tmp_path / "evidence" / "manifest.yaml").read_text(encoding="utf-8") restructurer = _Restructurer([_candidate(existing_id="evidence:not-supplied")]) with pytest.raises(EvidencePreparationError, match="unknown_existing_id"): prepare_workspace_evidence(tmp_path, restructurer=restructurer, git_status=lambda _: ()) assert (tmp_path / "evidence" / "manifest.yaml").read_text(encoding="utf-8") == original def test_prepare_marks_an_omitted_prior_unit_for_human_review(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) (tmp_path / "evidence" / "source" / "domain" / "patient.md").write_text( "La classificazione pediatrica non è documentata.\n", encoding="utf-8", ) restructurer = _Restructurer([]) report = prepare_workspace_evidence(tmp_path, restructurer=restructurer, git_status=lambda _: ()) assert report.model_calls == 1 assert [finding.code for finding in report.findings] == [ "supporting_excerpt_missing", "unresolved_review_item", ] retained = load_curated_tree(tmp_path / "evidence" / "curated")[0] assert retained.review_items[0].code == "source_no_longer_supports_unit" def test_resolve_retires_an_orphan_and_updates_curated_content_and_manifest_atomically(tmp_path): from tht.evidence import resolve_workspace_evidence source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) source_path = tmp_path / "evidence" / "source" / "domain" / "patient.md" source_path.unlink() prepare_workspace_evidence(tmp_path, restructurer=_Restructurer([]), git_status=lambda _: ()) report = resolve_workspace_evidence( tmp_path, "evidence:fascia-pediatrica", retire=True, git_status=lambda _: (), ) assert report.action == "retired" assert report.evidence_id == "evidence:fascia-pediatrica" assert not (tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md").exists() manifest = load_manifest(tmp_path / "evidence" / "manifest.yaml") assert manifest.orphans == () assert manifest.sources == {} def test_resolve_relinks_an_unit_and_updates_its_manifest_membership(tmp_path): from tht.evidence import resolve_workspace_evidence source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) next_source = tmp_path / "evidence" / "source" / "domain" / "patient-age.md" next_source.write_text(source_text, encoding="utf-8") report = resolve_workspace_evidence( tmp_path, "evidence:fascia-pediatrica", source="source/domain/patient-age.md", git_status=lambda _: (), ) assert report.action == "relinked" assert report.source_file == "source/domain/patient-age.md" document = load_curated_tree(tmp_path / "evidence" / "curated")[0] assert document.provenance.source_file == "source/domain/patient-age.md" manifest = load_manifest(tmp_path / "evidence" / "manifest.yaml") assert manifest.sources["source/domain/patient.md"].units == () assert manifest.sources["source/domain/patient-age.md"].units == ("evidence:fascia-pediatrica",) assert validate_workspace_evidence(tmp_path).publishable is True def test_resolve_relink_clears_an_unsupported_review_item_when_the_new_source_supports_it(tmp_path): from tht.evidence import resolve_workspace_evidence source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) source_path = tmp_path / "evidence" / "source" / "domain" / "patient.md" source_path.write_text("La classificazione pediatrica non è documentata.\n", encoding="utf-8") prepare_workspace_evidence(tmp_path, restructurer=_Restructurer([]), git_status=lambda _: ()) replacement_source = tmp_path / "evidence" / "source" / "domain" / "patient-revised.md" replacement_source.write_text(source_text, encoding="utf-8") report = resolve_workspace_evidence( tmp_path, "evidence:fascia-pediatrica", source="source/domain/patient-revised.md", git_status=lambda _: (), ) assert report.findings == () document = load_curated_tree(tmp_path / "evidence" / "curated")[0] assert all(item.code != "source_no_longer_supports_unit" for item in document.review_items) assert validate_workspace_evidence(tmp_path).publishable is True def test_resolve_refuses_an_unsafe_relink_without_writing(tmp_path): from tht.evidence import EvidencePreparationError, resolve_workspace_evidence source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md" manifest_path = tmp_path / "evidence" / "manifest.yaml" original_curated = curated_path.read_text(encoding="utf-8") original_manifest = manifest_path.read_text(encoding="utf-8") with pytest.raises(EvidencePreparationError, match="source_invalid"): resolve_workspace_evidence( tmp_path, "evidence:fascia-pediatrica", source="../outside.md", git_status=lambda _: (), ) assert curated_path.read_text(encoding="utf-8") == original_curated assert manifest_path.read_text(encoding="utf-8") == original_manifest def test_resolve_refuses_a_relink_through_a_symlinked_source_directory(tmp_path): from tht.evidence import EvidencePreparationError, resolve_workspace_evidence source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md" manifest_path = tmp_path / "evidence" / "manifest.yaml" original_curated = curated_path.read_text(encoding="utf-8") original_manifest = manifest_path.read_text(encoding="utf-8") source_root = tmp_path / "evidence" / "source" outside_source = tmp_path / "outside-source" source_root.rename(outside_source) source_root.symlink_to(outside_source, target_is_directory=True) with pytest.raises(EvidencePreparationError, match="source_invalid"): resolve_workspace_evidence( tmp_path, "evidence:fascia-pediatrica", source="source/domain/patient.md", git_status=lambda _: (), ) assert curated_path.read_text(encoding="utf-8") == original_curated assert manifest_path.read_text(encoding="utf-8") == original_manifest def test_resolve_rejects_any_dirty_worktree_without_writing(tmp_path): from tht.evidence import EvidencePreparationError, resolve_workspace_evidence source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md" manifest_path = tmp_path / "evidence" / "manifest.yaml" original_curated = curated_path.read_text(encoding="utf-8") original_manifest = manifest_path.read_text(encoding="utf-8") with pytest.raises(EvidencePreparationError, match="worktree_dirty"): resolve_workspace_evidence( tmp_path, "evidence:fascia-pediatrica", retire=True, git_status=lambda _: (" M README.md",), ) assert curated_path.read_text(encoding="utf-8") == original_curated assert manifest_path.read_text(encoding="utf-8") == original_manifest