实现本地清洗实验与产物保存

This commit is contained in:
2026-08-22 17:25:44 +08:00
parent eed2119016
commit 6fcc7d5736
13 changed files with 2701 additions and 20 deletions
+268
View File
@@ -0,0 +1,268 @@
from __future__ import annotations
import json
import stat
from hashlib import sha256
from pathlib import Path
import pytest
import mdpolish.artifact_store as artifact_store
from mdpolish.artifact_store import ArtifactStoreError, StoredDocument, publish_run
def stored_document(document_id: str = "paper", output: bytes | None = b"cleaned\n") -> StoredDocument:
status = "success" if output is not None else "failed"
current_sha256 = sha256(output).hexdigest() if output is not None else "2" * 64
payload = {
"schema_version": 1,
"document": {"document_id": document_id, "source_label": f"inputs/{document_id}.md"},
"status": status,
"input_sha256": "1" * 64,
"current_sha256": current_sha256,
"changes": [],
"errors": [] if status == "success" else [{"error_type": "SyntheticError"}],
"residual_proposals": [],
"output": {
"cleaned_path": "cleaned.md" if output is not None else None,
"diff_path": "changes.diff" if output is not None else None,
},
}
return StoredDocument(
document_id=document_id,
result_json=(json.dumps(payload, indent=2) + "\n").encode(),
cleaned_markdown=output,
diff=b"" if output is not None else None,
output_sha256=sha256(output).hexdigest() if output is not None else None,
)
def manifest_json(
run_date: str,
run_id: str,
documents: tuple[StoredDocument, ...],
) -> bytes:
indexes: list[dict[str, object]] = []
statuses: list[str] = []
change_count = 0
for document in documents:
report = json.loads(document.result_json)
status = report["status"]
statuses.append(status)
change_count += len(report["changes"])
base = f"documents/{document.document_id}"
indexes.append(
{
"document_id": document.document_id,
"source_label": report["document"]["source_label"],
"status": status,
"input_sha256": report["input_sha256"],
"current_sha256": report["current_sha256"],
"change_count": len(report["changes"]),
"result_path": f"{base}/result.json",
"cleaned_path": f"{base}/cleaned.md" if status == "success" else None,
"diff_path": f"{base}/changes.diff" if status == "success" else None,
}
)
failed_count = statuses.count("failed")
unstable_count = statuses.count("unstable")
overall_status = "failed" if failed_count else "unstable" if unstable_count else "success"
payload = {
"schema_version": 1,
"run": {"run_id": run_id, "run_date": run_date, "status": overall_status},
"documents": indexes,
"summary": {
"document_count": len(documents),
"success_count": statuses.count("success"),
"failed_count": failed_count,
"unstable_count": unstable_count,
"change_count": change_count,
},
}
return (json.dumps(payload, indent=2) + "\n").encode()
def test_publish_run_creates_private_date_layout_and_status_specific_files(tmp_path: Path) -> None:
artifacts_root = tmp_path / "artifacts"
documents = (stored_document("success"), stored_document("failed", None))
run_directory = publish_run(
artifacts_root=artifacts_root,
run_date="2026-08-22",
run_id="example-run",
manifest_json=manifest_json("2026-08-22", "example-run", documents),
documents=documents,
)
assert run_directory == artifacts_root / "2026-08-22" / "runs" / "example-run"
assert json.loads((run_directory / "manifest.json").read_bytes())["run"]["status"] == "failed"
assert (run_directory / "documents/success/result.json").is_file()
assert (run_directory / "documents/success/cleaned.md").read_bytes() == b"cleaned\n"
assert (run_directory / "documents/success/changes.diff").read_bytes() == b""
assert (run_directory / "documents/failed/result.json").is_file()
assert not (run_directory / "documents/failed/cleaned.md").exists()
assert not (run_directory / "documents/failed/changes.diff").exists()
for directory in (
artifacts_root,
artifacts_root / "2026-08-22",
artifacts_root / "2026-08-22/runs",
run_directory,
run_directory / "documents",
run_directory / "documents/success",
):
assert stat.S_IMODE(directory.stat().st_mode) == 0o700
for artifact_file in run_directory.rglob("*"):
if artifact_file.is_file():
assert stat.S_IMODE(artifact_file.stat().st_mode) == 0o600
def test_publish_run_rejects_existing_target_without_overwriting(tmp_path: Path) -> None:
artifacts_root = tmp_path / "artifacts"
documents = (stored_document(),)
first_manifest = manifest_json("2026-08-22", "same-run", documents)
run_directory = publish_run(
artifacts_root=artifacts_root,
run_date="2026-08-22",
run_id="same-run",
manifest_json=first_manifest,
documents=documents,
)
with pytest.raises(ArtifactStoreError, match="already exists"):
publish_run(
artifacts_root=artifacts_root,
run_date="2026-08-22",
run_id="same-run",
manifest_json=first_manifest,
documents=documents,
)
assert (run_directory / "manifest.json").read_bytes() == first_manifest
@pytest.mark.parametrize(
("run_date", "run_id"),
[
("2026-8-22", "valid"),
("2026-02-30", "valid"),
("2026-08-22", "Uppercase"),
("2026-08-22", "../escape"),
("2026-08-22", "two..dots"),
],
)
def test_publish_run_rejects_unsafe_date_and_run_id(tmp_path: Path, run_date: str, run_id: str) -> None:
documents = (stored_document(),)
with pytest.raises(ArtifactStoreError):
publish_run(
artifacts_root=tmp_path / "artifacts",
run_date=run_date,
run_id=run_id,
manifest_json=manifest_json(run_date, run_id, documents),
documents=documents,
)
def test_publish_run_rejects_inconsistent_or_duplicate_document_artifacts(tmp_path: Path) -> None:
valid = stored_document()
bad_hash = StoredDocument("paper", valid.result_json, b"output", b"", "0" * 64)
with pytest.raises(ArtifactStoreError, match="output hash"):
publish_run(
artifacts_root=tmp_path / "artifacts-a",
run_date="2026-08-22",
run_id="bad-hash",
manifest_json=manifest_json("2026-08-22", "bad-hash", (bad_hash,)),
documents=(bad_hash,),
)
duplicates = (stored_document(), stored_document())
with pytest.raises(ArtifactStoreError, match="unique"):
publish_run(
artifacts_root=tmp_path / "artifacts-b",
run_date="2026-08-22",
run_id="duplicate",
manifest_json=manifest_json("2026-08-22", "duplicate", duplicates),
documents=duplicates,
)
def test_publish_run_rejects_manifest_path_or_document_mismatch(tmp_path: Path) -> None:
documents = (stored_document(),)
wrong_date = manifest_json("2026-08-21", "review", documents)
with pytest.raises(ArtifactStoreError, match="identity"):
publish_run(
artifacts_root=tmp_path / "artifacts-a",
run_date="2026-08-22",
run_id="review",
manifest_json=wrong_date,
documents=documents,
)
payload = json.loads(manifest_json("2026-08-22", "review", documents))
payload["documents"][0]["change_count"] = 99
mismatched_index = (json.dumps(payload, indent=2) + "\n").encode()
with pytest.raises(ArtifactStoreError, match="index"):
publish_run(
artifacts_root=tmp_path / "artifacts-b",
run_date="2026-08-22",
run_id="review",
manifest_json=mismatched_index,
documents=documents,
)
def test_publish_race_does_not_replace_a_new_target(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
documents = (stored_document(),)
original_rename = artifact_store._rename_no_replace
def create_competing_target(source: Path, target: Path) -> None:
target.mkdir(mode=0o700)
(target / "keep").write_bytes(b"existing")
original_rename(source, target)
monkeypatch.setattr(artifact_store, "_rename_no_replace", create_competing_target)
with pytest.raises(ArtifactStoreError, match="already exists"):
publish_run(
artifacts_root=tmp_path / "artifacts",
run_date="2026-08-22",
run_id="raced",
manifest_json=manifest_json("2026-08-22", "raced", documents),
documents=documents,
)
target = tmp_path / "artifacts/2026-08-22/runs/raced"
assert (target / "keep").read_bytes() == b"existing"
assert not any(path.name.startswith(".raced.") for path in target.parent.iterdir())
def test_write_failure_cleans_temporary_directory_and_does_not_publish(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
original_write = artifact_store._write_private_file
call_count = 0
def fail_second_write(path: Path, content: bytes) -> None:
nonlocal call_count
call_count += 1
if call_count == 2:
raise OSError("synthetic write failure")
original_write(path, content)
monkeypatch.setattr(artifact_store, "_write_private_file", fail_second_write)
documents = (stored_document(),)
with pytest.raises(OSError, match="synthetic"):
publish_run(
artifacts_root=tmp_path / "artifacts",
run_date="2026-08-22",
run_id="broken",
manifest_json=manifest_json("2026-08-22", "broken", documents),
documents=documents,
)
runs_directory = tmp_path / "artifacts/2026-08-22/runs"
assert list(runs_directory.iterdir()) == []
+335
View File
@@ -0,0 +1,335 @@
from __future__ import annotations
import json
from collections.abc import Mapping
from datetime import UTC, datetime, timedelta, timezone
from pathlib import Path
import pytest
from mdpolish import Component, DocumentSnapshot, Pipeline, ProposedChange, RunStatus, TextEdit, TextSpan
from mdpolish.experiment import ExperimentError, InputDocument, ToolMetadata, run_experiment
class ConditionalComponent(Component):
@property
def component_id(self) -> str:
return "test.conditional"
@property
def version(self) -> str:
return "1.0.0"
@property
def parameters(self) -> Mapping[str, object]:
return {"needle": "old", "replacement": "new"}
@property
def applicability(self) -> str:
return "替换测试标记 old,遇到 boom 时模拟组件失败,排除其他内容。"
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
if snapshot.markdown == "boom":
raise RuntimeError("private source")
position = snapshot.markdown.find("old")
if position < 0:
return ()
edit = TextEdit(snapshot.sha256, TextSpan(position, position + 3), "old", "new")
return (ProposedChange(snapshot.sha256, "replace old test marker", (edit,)),)
class NonIdempotentComponent(Component):
@property
def component_id(self) -> str:
return "test.non-idempotent"
@property
def version(self) -> str:
return "1.0.0"
@property
def parameters(self) -> Mapping[str, object]:
return {}
@property
def applicability(self) -> str:
return "在测试文本末尾反复插入标记,只用于验证 unstable 产物边界。"
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
position = len(snapshot.markdown)
edit = TextEdit(snapshot.sha256, TextSpan(position, position), "", "!")
return (ProposedChange(snapshot.sha256, "append synthetic marker", (edit,)),)
def tool_metadata() -> ToolMetadata:
return ToolMetadata(
name="mdpolish",
package_version="0.1.0",
python_version="3.13.11",
platform="linux-x86_64",
git_commit="a" * 40,
git_dirty=False,
)
def input_document(path: Path, document_id: str) -> InputDocument:
return InputDocument(document_id, path, f"inputs/{path.name}")
def test_run_experiment_publishes_manifest_reports_diff_and_preserves_inputs(tmp_path: Path) -> None:
first_path = tmp_path / "first.md"
second_path = tmp_path / "second.md"
first_path.write_bytes(b"before old\r\nafter\r\n")
second_path.write_text("unchanged\n", encoding="utf-8")
original_first = first_path.read_bytes()
original_second = second_path.read_bytes()
local_timezone = timezone(timedelta(hours=8))
started_at = datetime(2026, 8, 22, 10, 30, tzinfo=local_timezone)
completed_at = datetime(2026, 8, 22, 2, 31, tzinfo=UTC)
result = run_experiment(
pipeline=Pipeline([ConditionalComponent()]),
documents=(input_document(first_path, "first"), input_document(second_path, "second")),
run_id="local-review",
artifacts_root=tmp_path / "artifacts",
started_at=started_at,
completed_at=completed_at,
tool=tool_metadata(),
)
assert result.status is RunStatus.SUCCESS
assert result.run_directory == tmp_path / "artifacts/2026-08-22/runs/local-review"
assert result.document_count == 2
assert result.success_count == 2
assert result.change_count == 1
assert first_path.read_bytes() == original_first
assert second_path.read_bytes() == original_second
assert (result.run_directory / "documents/first/cleaned.md").read_bytes() == b"before new\r\nafter\r\n"
assert (result.run_directory / "documents/second/cleaned.md").read_bytes() == original_second
assert (result.run_directory / "documents/second/changes.diff").read_bytes() == b""
manifest = json.loads((result.run_directory / "manifest.json").read_bytes())
assert manifest["run"]["run_date"] == "2026-08-22"
assert manifest["run"]["utc_offset"] == "+08:00"
assert manifest["run"]["started_at_utc"] == "2026-08-22T02:30:00Z"
assert manifest["run"]["completed_at_utc"] == "2026-08-22T02:31:00Z"
assert manifest["run"]["retention_until"] == "2026-09-21T02:31:00Z"
assert manifest["pipeline"]["components"][0]["component_id"] == "test.conditional"
assert manifest["pipeline"]["components"][0]["parameters"] == [
["needle", "old"],
["replacement", "new"],
]
assert manifest["summary"] == {
"document_count": 2,
"success_count": 2,
"failed_count": 0,
"unstable_count": 0,
"change_count": 1,
}
first_report = json.loads((result.run_directory / "documents/first/result.json").read_bytes())
assert first_report["changes"][0]["location"] == {"line": 1, "column": 8}
assert first_report["changes"][0]["before"] == "old"
assert b"--- a/first.md\n+++ b/first.md\n" in (
result.run_directory / "documents/first/changes.diff"
).read_bytes()
def test_document_failure_is_isolated_and_never_writes_partial_markdown(tmp_path: Path) -> None:
good_path = tmp_path / "good.md"
bad_path = tmp_path / "bad.md"
good_path.write_text("old", encoding="utf-8")
bad_path.write_text("boom", encoding="utf-8")
result = run_experiment(
pipeline=Pipeline([ConditionalComponent()]),
documents=(input_document(good_path, "good"), input_document(bad_path, "bad")),
run_id="mixed",
artifacts_root=tmp_path / "artifacts",
started_at=datetime(2026, 8, 22, 10, tzinfo=UTC),
completed_at=datetime(2026, 8, 22, 10, 1, tzinfo=UTC),
tool=tool_metadata(),
)
assert result.status is RunStatus.FAILED
assert result.success_count == 1
assert result.failed_count == 1
assert (result.run_directory / "documents/good/cleaned.md").read_text() == "new"
assert (result.run_directory / "documents/bad/result.json").is_file()
assert not (result.run_directory / "documents/bad/cleaned.md").exists()
assert not (result.run_directory / "documents/bad/changes.diff").exists()
bad_report = json.loads((result.run_directory / "documents/bad/result.json").read_bytes())
assert bad_report["status"] == "failed"
assert "partial_markdown" not in bad_report
def test_preflight_rejects_invalid_utf8_without_running_or_creating_artifacts(tmp_path: Path) -> None:
invalid_path = tmp_path / "invalid.md"
invalid_path.write_bytes(b"\xff")
artifacts_root = tmp_path / "artifacts"
with pytest.raises(ExperimentError, match="strict UTF-8"):
run_experiment(
pipeline=Pipeline([ConditionalComponent()]),
documents=(input_document(invalid_path, "invalid"),),
run_id="invalid-input",
artifacts_root=artifacts_root,
started_at=datetime(2026, 8, 22, tzinfo=UTC),
tool=tool_metadata(),
)
assert not artifacts_root.exists()
def test_preflight_rejects_invalid_document_manifests_before_creating_artifacts(tmp_path: Path) -> None:
source_path = tmp_path / "paper.md"
source_path.write_text("content", encoding="utf-8")
directory_path = tmp_path / "directory"
directory_path.mkdir()
missing_path = tmp_path / "missing.md"
cases = (
(input_document(source_path, "duplicate"), input_document(source_path, "duplicate")),
(input_document(source_path, "first"), input_document(source_path, "second")),
(input_document(missing_path, "missing"),),
(input_document(directory_path, "directory"),),
(input_document(source_path, "two..dots"),),
)
for index, documents in enumerate(cases):
artifacts_root = tmp_path / f"artifacts-{index}"
with pytest.raises(ExperimentError):
run_experiment(
pipeline=Pipeline([]),
documents=documents,
run_id="preflight",
artifacts_root=artifacts_root,
started_at=datetime(2026, 8, 22, tzinfo=UTC),
tool=tool_metadata(),
)
assert not artifacts_root.exists()
def test_preflight_rejects_an_output_path_nested_under_an_input_file(tmp_path: Path) -> None:
source_path = tmp_path / "paper.md"
source_path.write_text("content", encoding="utf-8")
with pytest.raises(ExperimentError, match="cannot overlap"):
run_experiment(
pipeline=Pipeline([]),
documents=(input_document(source_path, "paper"),),
run_id="overlap",
artifacts_root=source_path,
started_at=datetime(2026, 8, 22, tzinfo=UTC),
tool=tool_metadata(),
)
assert source_path.read_text(encoding="utf-8") == "content"
def test_utf8_bom_crlf_and_missing_final_newline_are_preserved_exactly(tmp_path: Path) -> None:
source_path = tmp_path / "bom.md"
empty_path = tmp_path / "empty.md"
source_bytes = b"\xef\xbb\xbfhead\r\nlast"
source_path.write_bytes(source_bytes)
empty_path.write_bytes(b"")
result = run_experiment(
pipeline=Pipeline([]),
documents=(input_document(source_path, "bom"), input_document(empty_path, "empty")),
run_id="byte-preservation",
artifacts_root=tmp_path / "artifacts",
started_at=datetime(2026, 8, 22, tzinfo=UTC),
completed_at=datetime(2026, 8, 22, 0, 1, tzinfo=UTC),
tool=tool_metadata(),
)
assert source_path.read_bytes() == source_bytes
assert (result.run_directory / "documents/bom/cleaned.md").read_bytes() == source_bytes
assert (result.run_directory / "documents/bom/changes.diff").read_bytes() == b""
assert empty_path.read_bytes() == b""
assert (result.run_directory / "documents/empty/cleaned.md").read_bytes() == b""
assert (result.run_directory / "documents/empty/changes.diff").read_bytes() == b""
def test_unstable_document_only_publishes_a_result_json(tmp_path: Path) -> None:
source_path = tmp_path / "paper.md"
source_path.write_text("content", encoding="utf-8")
result = run_experiment(
pipeline=Pipeline([NonIdempotentComponent()]),
documents=(input_document(source_path, "paper"),),
run_id="unstable",
artifacts_root=tmp_path / "artifacts",
started_at=datetime(2026, 8, 22, tzinfo=UTC),
completed_at=datetime(2026, 8, 22, 0, 1, tzinfo=UTC),
tool=tool_metadata(),
)
assert result.status is RunStatus.UNSTABLE
assert result.unstable_count == 1
assert (result.run_directory / "documents/paper/result.json").is_file()
assert not (result.run_directory / "documents/paper/cleaned.md").exists()
assert not (result.run_directory / "documents/paper/changes.diff").exists()
manifest = json.loads((result.run_directory / "manifest.json").read_bytes())
assert manifest["run"]["status"] == "unstable"
def test_existing_same_date_run_is_rejected_without_overwriting(tmp_path: Path) -> None:
source_path = tmp_path / "paper.md"
source_path.write_text("old", encoding="utf-8")
artifacts_root = tmp_path / "artifacts"
pipeline = Pipeline([ConditionalComponent()])
documents = (input_document(source_path, "paper"),)
started_at = datetime(2026, 8, 22, tzinfo=UTC)
completed_at = datetime(2026, 8, 22, 0, 1, tzinfo=UTC)
tool = tool_metadata()
first = run_experiment(
pipeline=pipeline,
documents=documents,
run_id="same",
artifacts_root=artifacts_root,
started_at=started_at,
completed_at=completed_at,
tool=tool,
)
original_manifest = (first.run_directory / "manifest.json").read_bytes()
with pytest.raises(ExperimentError, match="already exists"):
run_experiment(
pipeline=pipeline,
documents=documents,
run_id="same",
artifacts_root=artifacts_root,
started_at=started_at,
completed_at=completed_at,
tool=tool,
)
assert (first.run_directory / "manifest.json").read_bytes() == original_manifest
def test_same_run_id_on_another_local_date_uses_a_separate_directory(tmp_path: Path) -> None:
source_path = tmp_path / "paper.md"
source_path.write_text("unchanged", encoding="utf-8")
artifacts_root = tmp_path / "artifacts"
first = run_experiment(
pipeline=Pipeline([]),
documents=(input_document(source_path, "paper"),),
run_id="daily",
artifacts_root=artifacts_root,
started_at=datetime(2026, 8, 22, 23, tzinfo=UTC),
completed_at=datetime(2026, 8, 22, 23, 1, tzinfo=UTC),
tool=tool_metadata(),
)
second = run_experiment(
pipeline=Pipeline([]),
documents=(input_document(source_path, "paper"),),
run_id="daily",
artifacts_root=artifacts_root,
started_at=datetime(2026, 8, 23, 0, tzinfo=UTC),
completed_at=datetime(2026, 8, 23, 0, 1, tzinfo=UTC),
tool=tool_metadata(),
)
assert first.run_directory.parent.parent.name == "2026-08-22"
assert second.run_directory.parent.parent.name == "2026-08-23"
+177
View File
@@ -0,0 +1,177 @@
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import replace
import pytest
from mdpolish import Component, DocumentSnapshot, Pipeline, ProposedChange, RunStatus, TextEdit, TextSpan
from mdpolish.reporting import ReportingError, build_document_report, build_unified_diff
class ReplaceComponent(Component):
def __init__(self, needle: str, replacement: str, component_id: str) -> None:
self.needle = needle
self.replacement = replacement
self._component_id = component_id
@property
def component_id(self) -> str:
return self._component_id
@property
def version(self) -> str:
return "1.0.0"
@property
def parameters(self) -> Mapping[str, object]:
return {"needle": self.needle, "replacement": self.replacement}
@property
def applicability(self) -> str:
return "处理精确测试标记,要求完整匹配,排除其他文本。"
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
position = snapshot.markdown.find(self.needle)
if position < 0:
return ()
edit = TextEdit(
snapshot.sha256,
TextSpan(position, position + len(self.needle)),
self.needle,
self.replacement,
)
return (ProposedChange(snapshot.sha256, f"replace {self.needle}", (edit,)),)
class ExplodingComponent(ReplaceComponent):
def __init__(self) -> None:
super().__init__("unused", "unused-replacement", "test.exploding")
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
raise RuntimeError(f"private: {snapshot.markdown}")
def test_success_report_replays_multiple_component_snapshots_and_derives_locations() -> None:
markdown = "x\r\ntarget\n"
pipeline = Pipeline(
[
ReplaceComponent("x", "XX", "test.expand"),
ReplaceComponent("target", "done", "test.target"),
]
)
result = pipeline.transform(markdown)
report = build_document_report(
document_id="paper",
source_label="data/md/paper.md",
input_markdown=markdown,
result=result,
)
payload = json.loads(report.result_json)
assert result.status is RunStatus.SUCCESS
assert report.cleaned_markdown == b"XX\r\ndone\n"
assert payload["changes"][0]["location"] == {"line": 1, "column": 1}
assert payload["changes"][1]["location"] == {"line": 2, "column": 1}
assert payload["changes"][1]["before"] == "target"
assert payload["changes"][1]["after"] == "done"
assert payload["changes"][1]["before_sha256"] == result.changes[1].before_sha256
assert payload["changes"][1]["after_sha256"] == result.changes[1].after_sha256
assert report.diff is not None
assert b"--- a/paper.md\n+++ b/paper.md\n" in report.diff
assert b"data/md/paper.md" not in report.diff
def test_zero_change_success_still_has_cleaned_markdown_and_empty_diff() -> None:
markdown = "中文\nCafe\u0301\n"
result = Pipeline([]).transform(markdown)
report = build_document_report(
document_id="unchanged",
source_label="论文.md",
input_markdown=markdown,
result=result,
)
assert report.cleaned_markdown == markdown.encode()
assert report.diff == b""
assert report.change_count == 0
assert report.result_json.endswith(b"\n")
assert "论文".encode() in report.result_json
assert b"\\u4e2d" not in report.result_json
def test_diff_preserves_final_newline_changes_and_uses_report_newlines() -> None:
diff = build_unified_diff("paper", "same\r\n", "same")
assert b"--- a/paper.md\n+++ b/paper.md\n" in diff
assert b"-same\n+same\n\\ No newline at end of file\n" in diff
assert b"\r" not in diff
def test_failed_report_keeps_change_audit_but_does_not_render_partial_markdown() -> None:
pipeline = Pipeline(
[
ReplaceComponent("a", "b", "test.first"),
ExplodingComponent(),
]
)
result = pipeline.transform("a")
report = build_document_report(
document_id="failed",
source_label="failed.md",
input_markdown="a",
result=result,
)
payload = json.loads(report.result_json)
assert result.status is RunStatus.FAILED
assert report.cleaned_markdown is None
assert report.diff is None
assert payload["output"] == {"cleaned_path": None, "diff_path": None}
assert len(payload["changes"]) == 1
assert payload["errors"][0]["error_type"] == "RuntimeError"
assert "private" not in payload["errors"][0]["message"]
assert "partial_markdown" not in payload
def test_unstable_report_serializes_residual_proposals_without_partial_markdown() -> None:
pipeline = Pipeline(
[
ReplaceComponent("bad", "good", "test.to-good"),
ReplaceComponent("good", "bad", "test.to-bad"),
]
)
result = pipeline.transform("bad")
report = build_document_report(
document_id="unstable",
source_label="unstable.md",
input_markdown="bad",
result=result,
)
payload = json.loads(report.result_json)
assert result.status is RunStatus.UNSTABLE
assert report.cleaned_markdown is None
assert report.diff is None
assert payload["residual_proposals"][0]["proposal"]["reason"] == "replace bad"
assert payload["residual_proposals"][0]["proposal"]["edits"][0]["expected_text"] == "bad"
assert "partial_markdown" not in payload
def test_report_rejects_a_tampered_change_hash_chain() -> None:
result = Pipeline([ReplaceComponent("a", "b", "test.replace")]).transform("a")
tampered_change = replace(result.changes[0], after_sha256="0" * 64)
tampered_result = replace(result, changes=(tampered_change,))
with pytest.raises(ReportingError, match="batch hash"):
build_document_report(
document_id="tampered",
source_label="tampered.md",
input_markdown="a",
result=tampered_result,
)