feat(evidence): resolve curated evidence explicitly
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"decision add-batch",
|
||||
"decision add-join-set",
|
||||
"evidence prepare",
|
||||
"evidence resolve",
|
||||
"evidence validate",
|
||||
"memory promote",
|
||||
"memory save-one",
|
||||
|
||||
@@ -32,7 +32,7 @@ def test_typer_tree_matches_the_approved_command_surface():
|
||||
approved = _approved_surface()
|
||||
expected = set(approved["maintained"]) | set(approved["enhanced"])
|
||||
|
||||
assert len(approved["maintained"]) == 57
|
||||
assert len(approved["maintained"]) == 58
|
||||
assert len(approved["enhanced"]) == 8
|
||||
assert len(approved["erased"]) == 14
|
||||
assert not (expected & set(approved["erased"]))
|
||||
|
||||
@@ -316,18 +316,18 @@ class _Restructurer(EvidenceRestructurer):
|
||||
return tuple(self.candidates)
|
||||
|
||||
|
||||
def _candidate(*, title="Fascia pediatrica", existing_id=None):
|
||||
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": "domain",
|
||||
"kind": kind,
|
||||
"purposes": ["disambiguation"],
|
||||
"applies_to": {"concepts": ["fascia pediatrica"]},
|
||||
"language": "it",
|
||||
"supporting_excerpts": ["I pazienti sotto i 18 anni sono pediatrici."],
|
||||
"review_items": [],
|
||||
"payload": {"rule": "La fascia pediatrica comprende i minori."},
|
||||
"payload": payload or {"rule": "La fascia pediatrica comprende i minori."},
|
||||
})
|
||||
|
||||
|
||||
@@ -378,6 +378,47 @@ def test_prepare_rejects_dirty_curated_state_before_model_call(tmp_path):
|
||||
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)
|
||||
@@ -437,3 +478,146 @@ def test_prepare_marks_an_omitted_prior_unit_for_human_review(tmp_path):
|
||||
]
|
||||
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
|
||||
|
||||
@@ -10,9 +10,86 @@ def test_evidence_authoring_commands_are_distinct_from_runtime_preprocessing():
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "prepare" in result.output
|
||||
assert "resolve" in result.output
|
||||
assert "validate" in result.output
|
||||
|
||||
|
||||
def test_evidence_resolve_json_is_pristine(monkeypatch, tmp_path):
|
||||
from tht.cli import evidence_cmd
|
||||
from tht.evidence.authoring import EvidenceResolutionReport
|
||||
|
||||
monkeypatch.setattr(evidence_cmd, "_canonical_worktree", lambda root: root)
|
||||
monkeypatch.setattr(evidence_cmd, "resolve_workspace_evidence", lambda *args, **kwargs: EvidenceResolutionReport(
|
||||
action="retired",
|
||||
evidence_id="evidence:fascia-pediatrica",
|
||||
source_file=None,
|
||||
findings=(),
|
||||
))
|
||||
|
||||
result = CliRunner().invoke(app, [
|
||||
"evidence", "resolve", str(tmp_path), "evidence:fascia-pediatrica", "--retire", "--json",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.stderr == ""
|
||||
assert json.loads(result.stdout) == {
|
||||
"action": "retired",
|
||||
"evidenceId": "evidence:fascia-pediatrica",
|
||||
"findings": [],
|
||||
"operation": "evidence_resolve",
|
||||
"schemaVersion": 1,
|
||||
"sourceFile": None,
|
||||
"status": "resolved",
|
||||
}
|
||||
|
||||
|
||||
def test_evidence_resolve_reports_unsafe_source_as_cli_misuse(monkeypatch, tmp_path):
|
||||
from tht.cli import evidence_cmd
|
||||
from tht.evidence import EvidencePreparationError
|
||||
|
||||
monkeypatch.setattr(evidence_cmd, "_canonical_worktree", lambda root: root)
|
||||
monkeypatch.setattr(
|
||||
evidence_cmd,
|
||||
"resolve_workspace_evidence",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(EvidencePreparationError("source_invalid")),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(app, [
|
||||
"evidence", "resolve", str(tmp_path), "evidence:fascia-pediatrica", "--source", "source/domain/x.md", "--json",
|
||||
])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert result.stderr == ""
|
||||
assert json.loads(result.stdout) == {
|
||||
"code": "source_invalid",
|
||||
"operation": "evidence_resolve",
|
||||
"schemaVersion": 1,
|
||||
"status": "failed",
|
||||
}
|
||||
|
||||
|
||||
def test_evidence_resolve_reports_structural_findings_as_operational_failure(monkeypatch, tmp_path):
|
||||
from tht.cli import evidence_cmd
|
||||
from tht.evidence import ValidationFinding
|
||||
from tht.evidence.authoring import EvidenceResolutionReport
|
||||
|
||||
monkeypatch.setattr(evidence_cmd, "_canonical_worktree", lambda root: root)
|
||||
monkeypatch.setattr(evidence_cmd, "resolve_workspace_evidence", lambda *args, **kwargs: EvidenceResolutionReport(
|
||||
action="relinked",
|
||||
evidence_id="evidence:fascia-pediatrica",
|
||||
source_file="source/domain/patient.md",
|
||||
findings=(ValidationFinding(
|
||||
"error", "supporting_excerpt_missing", "source/domain/patient.md", "Excerpt missing.",
|
||||
),),
|
||||
))
|
||||
|
||||
result = CliRunner().invoke(app, [
|
||||
"evidence", "resolve", str(tmp_path), "evidence:fascia-pediatrica", "--source", "source/domain/patient.md",
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
def test_evidence_validate_json_is_pristine_and_reports_review_required(monkeypatch, tmp_path):
|
||||
from tht.cli import evidence_cmd
|
||||
from tht.evidence import ValidationFinding, ValidationReport
|
||||
|
||||
@@ -14,6 +14,7 @@ from tht.evidence import (
|
||||
EvidencePreparationError,
|
||||
PiEvidenceRestructurer,
|
||||
prepare_workspace_evidence,
|
||||
resolve_workspace_evidence,
|
||||
validate_workspace_evidence,
|
||||
)
|
||||
|
||||
@@ -103,3 +104,38 @@ def validate_cmd(
|
||||
if all(finding.code in {"orphaned_unit", "unresolved_review_item"} for finding in report.findings):
|
||||
raise typer.Exit(code=3)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
@evidence_app.command("resolve")
|
||||
def resolve_cmd(
|
||||
workspace_root: Path,
|
||||
evidence_id: str,
|
||||
retire: Annotated[bool, typer.Option(help="Retire the Evidence unit.")] = False,
|
||||
source: Annotated[Path | None, typer.Option(help="Relink the unit to this source/ path.")] = None,
|
||||
json_output: Annotated[bool, typer.Option("--json", help="Write machine JSON to stdout.")] = False,
|
||||
) -> None:
|
||||
"""Explicitly retire or relink one Evidence unit without publishing it."""
|
||||
if retire == (source is not None):
|
||||
raise typer.BadParameter("choose exactly one of --retire or --source")
|
||||
root = _canonical_worktree(workspace_root)
|
||||
try:
|
||||
report = resolve_workspace_evidence(root, evidence_id, retire=retire, source=source)
|
||||
except EvidencePreparationError as error:
|
||||
_emit({"schemaVersion": 1, "operation": "evidence_resolve", "status": "failed", "code": error.code}, json_output)
|
||||
exit_code = 2 if error.code in {
|
||||
"evidence_id_invalid", "resolve_mode_invalid", "source_invalid",
|
||||
} else 1
|
||||
raise typer.Exit(code=exit_code) from error
|
||||
_emit({
|
||||
"schemaVersion": 1,
|
||||
"operation": "evidence_resolve",
|
||||
"status": "resolved",
|
||||
"action": report.action,
|
||||
"evidenceId": report.evidence_id,
|
||||
"sourceFile": report.source_file,
|
||||
"findings": _findings_payload(report.findings),
|
||||
}, json_output)
|
||||
if report.findings:
|
||||
if all(finding.code in {"orphaned_unit", "unresolved_review_item"} for finding in report.findings):
|
||||
raise typer.Exit(code=3)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
@@ -5,6 +5,7 @@ from tht.evidence.authoring import (
|
||||
EvidenceManifest,
|
||||
EvidencePreparationError,
|
||||
EvidencePreparationReport,
|
||||
EvidenceResolutionReport,
|
||||
EvidenceRestructurer,
|
||||
PiEvidenceRestructurer,
|
||||
RestructureCandidate,
|
||||
@@ -14,6 +15,7 @@ from tht.evidence.authoring import (
|
||||
dump_manifest,
|
||||
load_manifest,
|
||||
prepare_workspace_evidence,
|
||||
resolve_workspace_evidence,
|
||||
validate_workspace_evidence,
|
||||
)
|
||||
from tht.evidence.canonical import (
|
||||
@@ -54,6 +56,7 @@ __all__ = [
|
||||
"EvidenceManifest",
|
||||
"EvidencePreparationError",
|
||||
"EvidencePreparationReport",
|
||||
"EvidenceResolutionReport",
|
||||
"EvidenceRestructurer",
|
||||
"EvidenceSource",
|
||||
"EvidenceSourceError",
|
||||
@@ -80,6 +83,7 @@ __all__ = [
|
||||
"prepare_workspace_evidence",
|
||||
"project_session",
|
||||
"resolve_citation",
|
||||
"resolve_workspace_evidence",
|
||||
"validate_corpus_workspace",
|
||||
"validate_namespaced_value",
|
||||
"validate_safe_metadata",
|
||||
|
||||
@@ -173,6 +173,16 @@ class EvidencePreparationReport:
|
||||
model_calls: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvidenceResolutionReport:
|
||||
"""The result of one curator-directed Evidence resolution."""
|
||||
|
||||
action: Literal["retired", "relinked"]
|
||||
evidence_id: str
|
||||
source_file: str | None
|
||||
findings: tuple[ValidationFinding, ...]
|
||||
|
||||
|
||||
class ManifestSource(StrictModel):
|
||||
sha256: str
|
||||
units: tuple[str, ...]
|
||||
@@ -531,6 +541,100 @@ def prepare_workspace_evidence(
|
||||
)
|
||||
|
||||
|
||||
def resolve_workspace_evidence(
|
||||
workspace_root: Path,
|
||||
evidence_id: str,
|
||||
*,
|
||||
retire: bool = False,
|
||||
source: str | Path | None = None,
|
||||
git_status: Callable[[Path], tuple[str, ...]] | None = None,
|
||||
) -> EvidenceResolutionReport:
|
||||
"""Retire or relink one Evidence unit as one staged curator update.
|
||||
|
||||
The operation intentionally neither creates a commit nor publishes anything. Both
|
||||
the curated tree and the managed manifest are replaced only after the staged tree
|
||||
has been written and structurally validated.
|
||||
"""
|
||||
if retire == (source is not None):
|
||||
raise EvidencePreparationError("resolve_mode_invalid")
|
||||
if not is_evidence_id(evidence_id):
|
||||
raise EvidencePreparationError("evidence_id_invalid")
|
||||
|
||||
workspace_root = workspace_root.resolve()
|
||||
evidence_root = workspace_root / "evidence"
|
||||
_reject_dirty_worktree(workspace_root, git_status or _git_status)
|
||||
try:
|
||||
manifest = load_manifest(evidence_root / "manifest.yaml")
|
||||
documents = load_curated_tree(evidence_root / "curated")
|
||||
except (OSError, ValidationError, ValueError) as error:
|
||||
raise EvidencePreparationError("authoring_state_invalid") from error
|
||||
|
||||
documents_by_id = {document.id: document for document in documents}
|
||||
if len(documents_by_id) != len(documents):
|
||||
raise EvidencePreparationError("duplicate_evidence_id")
|
||||
if evidence_id not in documents_by_id:
|
||||
raise EvidencePreparationError("evidence_not_found")
|
||||
|
||||
source_units = {path: tuple(entry.units) for path, entry in manifest.sources.items()}
|
||||
orphaned = set(manifest.orphans)
|
||||
source_file: str | None = None
|
||||
action: Literal["retired", "relinked"]
|
||||
|
||||
if retire:
|
||||
action = "retired"
|
||||
del documents_by_id[evidence_id]
|
||||
for path, units in tuple(source_units.items()):
|
||||
source_units[path] = tuple(unit for unit in units if unit != evidence_id)
|
||||
orphaned.discard(evidence_id)
|
||||
else:
|
||||
action = "relinked"
|
||||
assert source is not None
|
||||
source_file = _resolve_source_file(evidence_root, source)
|
||||
source_text = _load_resolution_source(evidence_root, source_file)
|
||||
document = documents_by_id[evidence_id]
|
||||
review_items = document.review_items
|
||||
if all(_normalize(excerpt) in source_text for excerpt in document.provenance.supporting_excerpts):
|
||||
review_items = tuple(
|
||||
item for item in review_items if item.code != "source_no_longer_supports_unit"
|
||||
)
|
||||
documents_by_id[evidence_id] = document.model_copy(update={
|
||||
"provenance": document.provenance.model_copy(update={
|
||||
"source_file": source_file,
|
||||
"source_sha256": _source_hash(source_text),
|
||||
}),
|
||||
"review_items": review_items,
|
||||
})
|
||||
for path, units in tuple(source_units.items()):
|
||||
source_units[path] = tuple(unit for unit in units if unit != evidence_id)
|
||||
source_units[source_file] = tuple(sorted((*source_units.get(source_file, ()), evidence_id)))
|
||||
orphaned.discard(evidence_id)
|
||||
|
||||
sources = {
|
||||
path: ManifestSource(
|
||||
sha256=(
|
||||
_source_hash(_load_resolution_source(evidence_root, path))
|
||||
if path == source_file
|
||||
else manifest.sources[path].sha256
|
||||
),
|
||||
units=units,
|
||||
)
|
||||
for path, units in sorted(source_units.items())
|
||||
}
|
||||
next_manifest = EvidenceManifest(
|
||||
schema_version=1,
|
||||
pipeline_version=manifest.pipeline_version,
|
||||
sources=sources,
|
||||
orphans=tuple(sorted(orphaned)),
|
||||
)
|
||||
findings = _stage_and_apply_authoring_tree(workspace_root, documents_by_id, next_manifest)
|
||||
return EvidenceResolutionReport(
|
||||
action=action,
|
||||
evidence_id=evidence_id,
|
||||
source_file=source_file,
|
||||
findings=findings,
|
||||
)
|
||||
|
||||
|
||||
def _empty_manifest() -> EvidenceManifest:
|
||||
return EvidenceManifest(
|
||||
schema_version=1,
|
||||
@@ -558,6 +662,30 @@ def _load_preparation_manifest(path: Path, *, upgrade: bool) -> EvidenceManifest
|
||||
raise EvidencePreparationError("authoring_state_invalid") from error
|
||||
|
||||
|
||||
def _resolve_source_file(evidence_root: Path, source: str | Path) -> str:
|
||||
raw_source = source.as_posix() if isinstance(source, Path) else source
|
||||
try:
|
||||
source_file = validate_source_file(raw_source)
|
||||
except ValueError as error:
|
||||
raise EvidencePreparationError("source_invalid", raw_source) from error
|
||||
path = evidence_root / source_file
|
||||
ancestor = evidence_root
|
||||
for part in Path(source_file).parts:
|
||||
ancestor /= part
|
||||
if ancestor.is_symlink():
|
||||
raise EvidencePreparationError("source_invalid", source_file)
|
||||
if not path.is_file() or path.stat().st_size > MAX_AUTHORING_FILE_BYTES:
|
||||
raise EvidencePreparationError("source_invalid", source_file)
|
||||
return source_file
|
||||
|
||||
|
||||
def _load_resolution_source(evidence_root: Path, source_file: str) -> str:
|
||||
try:
|
||||
return normalize_source_text((evidence_root / source_file).read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError) as error:
|
||||
raise EvidencePreparationError("source_invalid", source_file) from error
|
||||
|
||||
|
||||
def _git_status(workspace_root: Path) -> tuple[str, ...]:
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
@@ -581,6 +709,14 @@ def _reject_dirty_authoring_state(
|
||||
raise EvidencePreparationError("authoring_worktree_dirty")
|
||||
|
||||
|
||||
def _reject_dirty_worktree(
|
||||
workspace_root: Path,
|
||||
git_status: Callable[[Path], tuple[str, ...]],
|
||||
) -> None:
|
||||
if git_status(workspace_root):
|
||||
raise EvidencePreparationError("worktree_dirty")
|
||||
|
||||
|
||||
def _load_source_texts(evidence_root: Path) -> dict[str, str]:
|
||||
source_root = evidence_root / "source"
|
||||
if not source_root.is_dir():
|
||||
|
||||
Reference in New Issue
Block a user