实现本地 Markdown 清洗评审器
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Test support package for repository-local integration fixtures."""
|
||||
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
|
||||
class ReviewFixture(TypedDict):
|
||||
run_directory: Path
|
||||
source_path: Path
|
||||
manifest_path: Path
|
||||
locator_path: Path
|
||||
result_path: Path
|
||||
original: str
|
||||
cleaned: str
|
||||
|
||||
|
||||
def digest(text: str) -> str:
|
||||
return sha256(text.encode()).hexdigest()
|
||||
|
||||
|
||||
def write_json(path: Path, payload: object) -> None:
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any]:
|
||||
return cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def create_review_run(
|
||||
root: Path,
|
||||
*,
|
||||
status: str = "success",
|
||||
with_locator: bool = True,
|
||||
) -> ReviewFixture:
|
||||
run_directory = root / "artifacts/2026-08-23/runs/review-run"
|
||||
document_directory = run_directory / "documents/paper"
|
||||
document_directory.mkdir(parents=True)
|
||||
source_path = root / "paper.md"
|
||||
original = "\ufeff😀 old\r\nCafe\u0301\n"
|
||||
cleaned = "\ufeff😀 new\r\nCafe\u0301\n"
|
||||
source_path.write_text(original, encoding="utf-8")
|
||||
input_hash = digest(original)
|
||||
current_hash = digest(cleaned) if status == "success" else input_hash
|
||||
changes: list[dict[str, object]] = []
|
||||
if status == "success":
|
||||
changes.append(
|
||||
{
|
||||
"component_id": "paper.rule",
|
||||
"component_version": "1.0.0",
|
||||
"component_position": 0,
|
||||
"proposal_ref": {
|
||||
"component_position": 0,
|
||||
"snapshot_sha256": input_hash,
|
||||
"proposal_index": 0,
|
||||
},
|
||||
"edit_index": 0,
|
||||
"reason": "替换测试单词",
|
||||
"span": {"start": 3, "end": 6},
|
||||
"location": {"line": 1, "column": 4},
|
||||
"before": "old",
|
||||
"after": "new",
|
||||
"before_sha256": input_hash,
|
||||
"after_sha256": current_hash,
|
||||
}
|
||||
)
|
||||
errors: list[dict[str, object]] = []
|
||||
if status == "failed":
|
||||
errors.append(
|
||||
{
|
||||
"component_id": "paper.rule",
|
||||
"component_version": "1.0.0",
|
||||
"component_position": 0,
|
||||
"stage": "transform",
|
||||
"error_type": "SyntheticError",
|
||||
"message": "测试组件失败。",
|
||||
}
|
||||
)
|
||||
residuals: list[dict[str, object]] = []
|
||||
if status == "unstable":
|
||||
residuals.append(
|
||||
{
|
||||
"component_id": "paper.rule",
|
||||
"component_version": "1.0.0",
|
||||
"component_position": 0,
|
||||
"proposal_ref": {
|
||||
"component_position": 0,
|
||||
"snapshot_sha256": input_hash,
|
||||
"proposal_index": 0,
|
||||
},
|
||||
"proposal": {
|
||||
"snapshot_sha256": input_hash,
|
||||
"reason": "仍可替换测试单词",
|
||||
"edits": [
|
||||
{
|
||||
"snapshot_sha256": input_hash,
|
||||
"span": {"start": 3, "end": 6},
|
||||
"expected_text": "old",
|
||||
"replacement": "new",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
result = {
|
||||
"schema_version": 1,
|
||||
"document": {"document_id": "paper", "source_label": "inputs/paper.md"},
|
||||
"status": status,
|
||||
"input_sha256": input_hash,
|
||||
"current_sha256": current_hash,
|
||||
"changes": changes,
|
||||
"errors": errors,
|
||||
"residual_proposals": residuals,
|
||||
"output": {
|
||||
"cleaned_path": "cleaned.md" if status == "success" else None,
|
||||
"diff_path": "changes.diff" if status == "success" else None,
|
||||
},
|
||||
}
|
||||
result_path = document_directory / "result.json"
|
||||
write_json(result_path, result)
|
||||
if status == "success":
|
||||
(document_directory / "cleaned.md").write_text(cleaned, encoding="utf-8")
|
||||
(document_directory / "changes.diff").write_text("synthetic diff\n", encoding="utf-8")
|
||||
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"run": {
|
||||
"run_id": "review-run",
|
||||
"run_date": "2026-08-23",
|
||||
"utc_offset": "+08:00",
|
||||
"status": status,
|
||||
"started_at_utc": "2026-08-23T01:00:00Z",
|
||||
"completed_at_utc": "2026-08-23T01:01:00Z",
|
||||
"retention_until": "2026-09-22T01:01:00Z",
|
||||
},
|
||||
"tool": {
|
||||
"name": "mdpolish",
|
||||
"package_version": "0.1.0",
|
||||
"python_version": "3.13.11",
|
||||
"platform": "linux-x86_64",
|
||||
"git_commit": None,
|
||||
"git_dirty": None,
|
||||
},
|
||||
"pipeline": {
|
||||
"components": [
|
||||
{
|
||||
"component_id": "paper.rule",
|
||||
"version": "1.0.0",
|
||||
"parameters": [],
|
||||
"applicability": "替换测试单词。",
|
||||
},
|
||||
{
|
||||
"component_id": "paper.zero",
|
||||
"version": "1.0.0",
|
||||
"parameters": [],
|
||||
"applicability": "不修改当前测试文档。",
|
||||
},
|
||||
]
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"document_id": "paper",
|
||||
"source_label": "inputs/paper.md",
|
||||
"status": status,
|
||||
"input_sha256": input_hash,
|
||||
"current_sha256": current_hash,
|
||||
"change_count": len(changes),
|
||||
"result_path": "documents/paper/result.json",
|
||||
"cleaned_path": "documents/paper/cleaned.md" if status == "success" else None,
|
||||
"diff_path": "documents/paper/changes.diff" if status == "success" else None,
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"document_count": 1,
|
||||
"success_count": int(status == "success"),
|
||||
"failed_count": int(status == "failed"),
|
||||
"unstable_count": int(status == "unstable"),
|
||||
"change_count": len(changes),
|
||||
},
|
||||
}
|
||||
manifest_path = run_directory / "manifest.json"
|
||||
write_json(manifest_path, manifest)
|
||||
locator_path = run_directory / "review-locator.json"
|
||||
if with_locator:
|
||||
write_json(
|
||||
locator_path,
|
||||
{
|
||||
"schema_version": 1,
|
||||
"run": {
|
||||
"run_id": "review-run",
|
||||
"run_directory": str(run_directory.resolve()),
|
||||
"manifest_path": "manifest.json",
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"document_id": "paper",
|
||||
"source_path": str(source_path.resolve()),
|
||||
"input_sha256": input_hash,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
return {
|
||||
"run_directory": run_directory,
|
||||
"source_path": source_path,
|
||||
"manifest_path": manifest_path,
|
||||
"locator_path": locator_path,
|
||||
"result_path": result_path,
|
||||
"original": original,
|
||||
"cleaned": cleaned,
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from mdpolish._artifact_replay import (
|
||||
ReplayChange,
|
||||
ReplayComponent,
|
||||
ReplayError,
|
||||
replay_change_chain,
|
||||
)
|
||||
from mdpolish.models import markdown_sha256
|
||||
|
||||
|
||||
def change(
|
||||
*,
|
||||
component_position: int,
|
||||
before_text: str,
|
||||
after_text: str,
|
||||
start: int,
|
||||
end: int,
|
||||
before_sha256: str,
|
||||
after_sha256: str,
|
||||
proposal_index: int = 0,
|
||||
edit_index: int = 0,
|
||||
) -> ReplayChange:
|
||||
return ReplayChange(
|
||||
component_id=f"test.{component_position}",
|
||||
component_version="1.0.0",
|
||||
component_position=component_position,
|
||||
proposal_component_position=component_position,
|
||||
proposal_snapshot_sha256=before_sha256,
|
||||
proposal_index=proposal_index,
|
||||
edit_index=edit_index,
|
||||
start=start,
|
||||
end=end,
|
||||
before=before_text,
|
||||
after=after_text,
|
||||
before_sha256=before_sha256,
|
||||
after_sha256=after_sha256,
|
||||
)
|
||||
|
||||
|
||||
def test_replay_builds_zero_change_stages_and_utf16_editor_ranges() -> None:
|
||||
original = "\ufeff😀 old\r\nCafe\u0301"
|
||||
final = "\ufeff😀 new\r\nCafe\u0301"
|
||||
input_hash = markdown_sha256(original)
|
||||
final_hash = markdown_sha256(final)
|
||||
recorded = change(
|
||||
component_position=0,
|
||||
before_text="old",
|
||||
after_text="new",
|
||||
start=3,
|
||||
end=6,
|
||||
before_sha256=input_hash,
|
||||
after_sha256=final_hash,
|
||||
)
|
||||
|
||||
replayed = replay_change_chain(
|
||||
input_markdown=original,
|
||||
input_sha256=input_hash,
|
||||
components=(ReplayComponent("test.0", "1.0.0"), ReplayComponent("test.1", "1.0.0")),
|
||||
changes=(recorded,),
|
||||
current_sha256=final_hash,
|
||||
current_markdown=final,
|
||||
include_zero_change_stages=True,
|
||||
)
|
||||
|
||||
assert replayed.current_markdown == final
|
||||
assert len(replayed.stages) == 2
|
||||
assert replayed.stages[1].before_markdown == final
|
||||
assert replayed.stages[1].after_markdown == final
|
||||
assert replayed.stages[1].changes == ()
|
||||
assert (replayed.changes[0].line, replayed.changes[0].column) == (1, 4)
|
||||
assert (replayed.changes[0].editor_start, replayed.changes[0].editor_end) == (4, 7)
|
||||
|
||||
|
||||
def test_replay_uses_full_descending_application_key_not_record_order() -> None:
|
||||
original = "abcd"
|
||||
final = "aXXcYY"
|
||||
input_hash = markdown_sha256(original)
|
||||
final_hash = markdown_sha256(final)
|
||||
right = change(
|
||||
component_position=0,
|
||||
before_text="d",
|
||||
after_text="YY",
|
||||
start=3,
|
||||
end=4,
|
||||
before_sha256=input_hash,
|
||||
after_sha256=final_hash,
|
||||
proposal_index=1,
|
||||
)
|
||||
left = change(
|
||||
component_position=0,
|
||||
before_text="b",
|
||||
after_text="XX",
|
||||
start=1,
|
||||
end=2,
|
||||
before_sha256=input_hash,
|
||||
after_sha256=final_hash,
|
||||
)
|
||||
|
||||
replayed = replay_change_chain(
|
||||
input_markdown=original,
|
||||
input_sha256=input_hash,
|
||||
components=(ReplayComponent("test.0", "1.0.0"),),
|
||||
changes=(right, left),
|
||||
current_sha256=final_hash,
|
||||
current_markdown=final,
|
||||
include_zero_change_stages=True,
|
||||
)
|
||||
|
||||
assert replayed.current_markdown == final
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutate", "message"),
|
||||
[
|
||||
(lambda item: replace(item, component_position=2), "component position"),
|
||||
(lambda item: replace(item, proposal_component_position=1), "proposal reference"),
|
||||
(lambda item: replace(item, before="bad"), "recorded snapshot"),
|
||||
(lambda item: replace(item, after_sha256="0" * 64), "batch hash"),
|
||||
],
|
||||
)
|
||||
def test_replay_rejects_untrusted_change_chains(
|
||||
mutate: Callable[[ReplayChange], ReplayChange], message: str
|
||||
) -> None:
|
||||
original = "old"
|
||||
final = "new"
|
||||
input_hash = markdown_sha256(original)
|
||||
final_hash = markdown_sha256(final)
|
||||
valid = change(
|
||||
component_position=0,
|
||||
before_text="old",
|
||||
after_text="new",
|
||||
start=0,
|
||||
end=3,
|
||||
before_sha256=input_hash,
|
||||
after_sha256=final_hash,
|
||||
)
|
||||
tampered = mutate(valid)
|
||||
|
||||
with pytest.raises(ReplayError, match=message):
|
||||
replay_change_chain(
|
||||
input_markdown=original,
|
||||
input_sha256=input_hash,
|
||||
components=(ReplayComponent("test.0", "1.0.0"),),
|
||||
changes=(tampered,),
|
||||
current_sha256=final_hash,
|
||||
current_markdown=final,
|
||||
include_zero_change_stages=True,
|
||||
)
|
||||
|
||||
|
||||
def test_replay_rejects_conflicting_ranges() -> None:
|
||||
original = "abc"
|
||||
input_hash = markdown_sha256(original)
|
||||
first = change(
|
||||
component_position=0,
|
||||
before_text="ab",
|
||||
after_text="x",
|
||||
start=0,
|
||||
end=2,
|
||||
before_sha256=input_hash,
|
||||
after_sha256="0" * 64,
|
||||
)
|
||||
second = change(
|
||||
component_position=0,
|
||||
before_text="bc",
|
||||
after_text="y",
|
||||
start=1,
|
||||
end=3,
|
||||
before_sha256=input_hash,
|
||||
after_sha256="0" * 64,
|
||||
proposal_index=1,
|
||||
)
|
||||
|
||||
with pytest.raises(ReplayError, match="conflicting"):
|
||||
replay_change_chain(
|
||||
input_markdown=original,
|
||||
input_sha256=input_hash,
|
||||
components=(ReplayComponent("test.0", "1.0.0"),),
|
||||
changes=(first, second),
|
||||
current_sha256=input_hash,
|
||||
current_markdown=None,
|
||||
include_zero_change_stages=False,
|
||||
)
|
||||
@@ -10,6 +10,8 @@ import pytest
|
||||
import mdpolish.artifact_store as artifact_store
|
||||
from mdpolish.artifact_store import ArtifactStoreError, StoredDocument, publish_run
|
||||
|
||||
_SOURCE_BYTES = b"source\n"
|
||||
|
||||
|
||||
def stored_document(document_id: str = "paper", output: bytes | None = b"cleaned\n") -> StoredDocument:
|
||||
status = "success" if output is not None else "failed"
|
||||
@@ -18,7 +20,7 @@ def stored_document(document_id: str = "paper", output: bytes | None = b"cleaned
|
||||
"schema_version": 1,
|
||||
"document": {"document_id": document_id, "source_label": f"inputs/{document_id}.md"},
|
||||
"status": status,
|
||||
"input_sha256": "1" * 64,
|
||||
"input_sha256": sha256(_SOURCE_BYTES).hexdigest(),
|
||||
"current_sha256": current_sha256,
|
||||
"changes": [],
|
||||
"errors": [] if status == "success" else [{"error_type": "SyntheticError"}],
|
||||
@@ -82,15 +84,49 @@ def manifest_json(
|
||||
return (json.dumps(payload, indent=2) + "\n").encode()
|
||||
|
||||
|
||||
def review_locator_json(
|
||||
artifacts_root: Path,
|
||||
run_date: str,
|
||||
run_id: str,
|
||||
documents: tuple[StoredDocument, ...],
|
||||
) -> bytes:
|
||||
source_root = artifacts_root.parent / f"{artifacts_root.name}-sources"
|
||||
source_root.mkdir(exist_ok=True)
|
||||
locator_documents: list[dict[str, object]] = []
|
||||
for position, document in enumerate(documents):
|
||||
source_path = source_root / f"{position}-{document.document_id}.md"
|
||||
source_path.write_bytes(_SOURCE_BYTES)
|
||||
report = json.loads(document.result_json)
|
||||
locator_documents.append(
|
||||
{
|
||||
"document_id": document.document_id,
|
||||
"source_path": str(source_path.resolve()),
|
||||
"input_sha256": report["input_sha256"],
|
||||
}
|
||||
)
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"run": {
|
||||
"run_id": run_id,
|
||||
"run_directory": str((artifacts_root / run_date / "runs" / run_id).resolve()),
|
||||
"manifest_path": "manifest.json",
|
||||
},
|
||||
"documents": locator_documents,
|
||||
}
|
||||
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))
|
||||
locator_json = review_locator_json(artifacts_root, "2026-08-22", "example-run", documents)
|
||||
|
||||
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),
|
||||
review_locator_json=locator_json,
|
||||
documents=documents,
|
||||
)
|
||||
|
||||
@@ -100,6 +136,7 @@ def test_publish_run_creates_private_date_layout_and_status_specific_files(tmp_p
|
||||
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 (run_directory / "review-locator.json").read_bytes() == locator_json
|
||||
assert not (run_directory / "documents/failed/cleaned.md").exists()
|
||||
assert not (run_directory / "documents/failed/changes.diff").exists()
|
||||
|
||||
@@ -121,11 +158,13 @@ def test_publish_run_rejects_existing_target_without_overwriting(tmp_path: Path)
|
||||
artifacts_root = tmp_path / "artifacts"
|
||||
documents = (stored_document(),)
|
||||
first_manifest = manifest_json("2026-08-22", "same-run", documents)
|
||||
locator_json = review_locator_json(artifacts_root, "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,
|
||||
review_locator_json=locator_json,
|
||||
documents=documents,
|
||||
)
|
||||
|
||||
@@ -135,6 +174,7 @@ def test_publish_run_rejects_existing_target_without_overwriting(tmp_path: Path)
|
||||
run_date="2026-08-22",
|
||||
run_id="same-run",
|
||||
manifest_json=first_manifest,
|
||||
review_locator_json=locator_json,
|
||||
documents=documents,
|
||||
)
|
||||
|
||||
@@ -152,13 +192,15 @@ def test_publish_run_rejects_existing_target_without_overwriting(tmp_path: Path)
|
||||
],
|
||||
)
|
||||
def test_publish_run_rejects_unsafe_date_and_run_id(tmp_path: Path, run_date: str, run_id: str) -> None:
|
||||
artifacts_root = tmp_path / "artifacts"
|
||||
documents = (stored_document(),)
|
||||
with pytest.raises(ArtifactStoreError):
|
||||
publish_run(
|
||||
artifacts_root=tmp_path / "artifacts",
|
||||
artifacts_root=artifacts_root,
|
||||
run_date=run_date,
|
||||
run_id=run_id,
|
||||
manifest_json=manifest_json(run_date, run_id, documents),
|
||||
review_locator_json=review_locator_json(artifacts_root, run_date, run_id, documents),
|
||||
documents=documents,
|
||||
)
|
||||
|
||||
@@ -166,22 +208,30 @@ def test_publish_run_rejects_unsafe_date_and_run_id(tmp_path: Path, run_date: st
|
||||
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)
|
||||
first_artifacts_root = tmp_path / "artifacts-a"
|
||||
with pytest.raises(ArtifactStoreError, match="output hash"):
|
||||
publish_run(
|
||||
artifacts_root=tmp_path / "artifacts-a",
|
||||
artifacts_root=first_artifacts_root,
|
||||
run_date="2026-08-22",
|
||||
run_id="bad-hash",
|
||||
manifest_json=manifest_json("2026-08-22", "bad-hash", (bad_hash,)),
|
||||
review_locator_json=review_locator_json(
|
||||
first_artifacts_root, "2026-08-22", "bad-hash", (bad_hash,)
|
||||
),
|
||||
documents=(bad_hash,),
|
||||
)
|
||||
|
||||
duplicates = (stored_document(), stored_document())
|
||||
second_artifacts_root = tmp_path / "artifacts-b"
|
||||
with pytest.raises(ArtifactStoreError, match="unique"):
|
||||
publish_run(
|
||||
artifacts_root=tmp_path / "artifacts-b",
|
||||
artifacts_root=second_artifacts_root,
|
||||
run_date="2026-08-22",
|
||||
run_id="duplicate",
|
||||
manifest_json=manifest_json("2026-08-22", "duplicate", duplicates),
|
||||
review_locator_json=review_locator_json(
|
||||
second_artifacts_root, "2026-08-22", "duplicate", duplicates
|
||||
),
|
||||
documents=duplicates,
|
||||
)
|
||||
|
||||
@@ -189,32 +239,81 @@ def test_publish_run_rejects_inconsistent_or_duplicate_document_artifacts(tmp_pa
|
||||
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)
|
||||
first_artifacts_root = tmp_path / "artifacts-a"
|
||||
with pytest.raises(ArtifactStoreError, match="identity"):
|
||||
publish_run(
|
||||
artifacts_root=tmp_path / "artifacts-a",
|
||||
artifacts_root=first_artifacts_root,
|
||||
run_date="2026-08-22",
|
||||
run_id="review",
|
||||
manifest_json=wrong_date,
|
||||
review_locator_json=review_locator_json(
|
||||
first_artifacts_root, "2026-08-22", "review", documents
|
||||
),
|
||||
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()
|
||||
second_artifacts_root = tmp_path / "artifacts-b"
|
||||
with pytest.raises(ArtifactStoreError, match="index"):
|
||||
publish_run(
|
||||
artifacts_root=tmp_path / "artifacts-b",
|
||||
artifacts_root=second_artifacts_root,
|
||||
run_date="2026-08-22",
|
||||
run_id="review",
|
||||
manifest_json=mismatched_index,
|
||||
review_locator_json=review_locator_json(
|
||||
second_artifacts_root, "2026-08-22", "review", documents
|
||||
),
|
||||
documents=documents,
|
||||
)
|
||||
|
||||
|
||||
def test_publish_run_rejects_inconsistent_review_locator(tmp_path: Path) -> None:
|
||||
artifacts_root = tmp_path / "artifacts"
|
||||
documents = (stored_document(),)
|
||||
locator = json.loads(review_locator_json(artifacts_root, "2026-08-22", "review", documents))
|
||||
locator["documents"][0]["input_sha256"] = "0" * 64
|
||||
inconsistent_locator = (json.dumps(locator, indent=2) + "\n").encode()
|
||||
|
||||
with pytest.raises(ArtifactStoreError, match="document identity"):
|
||||
publish_run(
|
||||
artifacts_root=artifacts_root,
|
||||
run_date="2026-08-22",
|
||||
run_id="review",
|
||||
manifest_json=manifest_json("2026-08-22", "review", documents),
|
||||
review_locator_json=inconsistent_locator,
|
||||
documents=documents,
|
||||
)
|
||||
|
||||
assert not artifacts_root.exists()
|
||||
|
||||
|
||||
def test_publish_run_rejects_source_changed_after_locator_creation(tmp_path: Path) -> None:
|
||||
artifacts_root = tmp_path / "artifacts"
|
||||
documents = (stored_document(),)
|
||||
locator_json = review_locator_json(artifacts_root, "2026-08-22", "review", documents)
|
||||
locator = json.loads(locator_json)
|
||||
Path(locator["documents"][0]["source_path"]).write_bytes(b"changed\n")
|
||||
|
||||
with pytest.raises(ArtifactStoreError, match="input hash"):
|
||||
publish_run(
|
||||
artifacts_root=artifacts_root,
|
||||
run_date="2026-08-22",
|
||||
run_id="review",
|
||||
manifest_json=manifest_json("2026-08-22", "review", documents),
|
||||
review_locator_json=locator_json,
|
||||
documents=documents,
|
||||
)
|
||||
|
||||
assert not artifacts_root.exists()
|
||||
|
||||
|
||||
def test_publish_race_does_not_replace_a_new_target(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
artifacts_root = tmp_path / "artifacts"
|
||||
documents = (stored_document(),)
|
||||
original_rename = artifact_store._rename_no_replace
|
||||
|
||||
@@ -226,10 +325,11 @@ def test_publish_race_does_not_replace_a_new_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",
|
||||
artifacts_root=artifacts_root,
|
||||
run_date="2026-08-22",
|
||||
run_id="raced",
|
||||
manifest_json=manifest_json("2026-08-22", "raced", documents),
|
||||
review_locator_json=review_locator_json(artifacts_root, "2026-08-22", "raced", documents),
|
||||
documents=documents,
|
||||
)
|
||||
|
||||
@@ -253,14 +353,44 @@ def test_write_failure_cleans_temporary_directory_and_does_not_publish(
|
||||
original_write(path, content)
|
||||
|
||||
monkeypatch.setattr(artifact_store, "_write_private_file", fail_second_write)
|
||||
artifacts_root = tmp_path / "artifacts"
|
||||
documents = (stored_document(),)
|
||||
|
||||
with pytest.raises(OSError, match="synthetic"):
|
||||
publish_run(
|
||||
artifacts_root=tmp_path / "artifacts",
|
||||
artifacts_root=artifacts_root,
|
||||
run_date="2026-08-22",
|
||||
run_id="broken",
|
||||
manifest_json=manifest_json("2026-08-22", "broken", documents),
|
||||
review_locator_json=review_locator_json(artifacts_root, "2026-08-22", "broken", documents),
|
||||
documents=documents,
|
||||
)
|
||||
|
||||
runs_directory = tmp_path / "artifacts/2026-08-22/runs"
|
||||
assert list(runs_directory.iterdir()) == []
|
||||
|
||||
|
||||
def test_locator_write_failure_does_not_publish(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
original_write = artifact_store._write_private_file
|
||||
|
||||
def fail_locator_write(path: Path, content: bytes) -> None:
|
||||
if path.name == "review-locator.json":
|
||||
raise OSError("synthetic locator write failure")
|
||||
original_write(path, content)
|
||||
|
||||
monkeypatch.setattr(artifact_store, "_write_private_file", fail_locator_write)
|
||||
artifacts_root = tmp_path / "artifacts"
|
||||
documents = (stored_document(),)
|
||||
|
||||
with pytest.raises(OSError, match="locator"):
|
||||
publish_run(
|
||||
artifacts_root=artifacts_root,
|
||||
run_date="2026-08-22",
|
||||
run_id="locator-failure",
|
||||
manifest_json=manifest_json("2026-08-22", "locator-failure", documents),
|
||||
review_locator_json=review_locator_json(
|
||||
artifacts_root, "2026-08-22", "locator-failure", documents
|
||||
),
|
||||
documents=documents,
|
||||
)
|
||||
|
||||
|
||||
@@ -109,6 +109,27 @@ def test_run_experiment_publishes_manifest_reports_diff_and_preserves_inputs(tmp
|
||||
assert (result.run_directory / "documents/second/changes.diff").read_bytes() == b""
|
||||
|
||||
manifest = json.loads((result.run_directory / "manifest.json").read_bytes())
|
||||
locator = json.loads((result.run_directory / "review-locator.json").read_bytes())
|
||||
assert locator == {
|
||||
"schema_version": 1,
|
||||
"run": {
|
||||
"run_id": "local-review",
|
||||
"run_directory": str(result.run_directory.resolve()),
|
||||
"manifest_path": "manifest.json",
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"document_id": "first",
|
||||
"source_path": str(first_path.resolve()),
|
||||
"input_sha256": manifest["documents"][0]["input_sha256"],
|
||||
},
|
||||
{
|
||||
"document_id": "second",
|
||||
"source_path": str(second_path.resolve()),
|
||||
"input_sha256": manifest["documents"][1]["input_sha256"],
|
||||
},
|
||||
],
|
||||
}
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from reviewer.server import ReviewArtifactError, ReviewArtifacts
|
||||
from tests.reviewer_fixture import create_review_run, read_json, write_json
|
||||
|
||||
|
||||
def test_reviewer_returns_full_comparison_and_all_component_stages(tmp_path: Path) -> None:
|
||||
fixture = create_review_run(tmp_path)
|
||||
repository = ReviewArtifacts(fixture["run_directory"])
|
||||
|
||||
run = cast(dict[str, Any], repository.run_summary())
|
||||
comparison = cast(dict[str, Any], repository.document_comparison("paper"))
|
||||
changed = cast(dict[str, Any], repository.component_stage("paper", 0))
|
||||
unchanged = cast(dict[str, Any], repository.component_stage("paper", 1))
|
||||
|
||||
assert run["summary"] == {
|
||||
"document_count": 1,
|
||||
"success_count": 1,
|
||||
"failed_count": 0,
|
||||
"unstable_count": 0,
|
||||
"change_count": 1,
|
||||
}
|
||||
assert run["documents"][0]["source_available"] is True
|
||||
assert comparison["original_markdown"] == fixture["original"]
|
||||
assert comparison["cleaned_markdown"] == fixture["cleaned"]
|
||||
assert comparison["changes"][0]["editor_range"] == {"start": 4, "end": 7}
|
||||
assert changed["before_markdown"] == fixture["original"]
|
||||
assert changed["after_markdown"] == fixture["cleaned"]
|
||||
assert unchanged["before_markdown"] == fixture["cleaned"]
|
||||
assert unchanged["after_markdown"] == fixture["cleaned"]
|
||||
assert unchanged["changes"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["failed", "unstable"])
|
||||
def test_non_success_documents_expose_audit_but_no_formal_output(tmp_path: Path, status: str) -> None:
|
||||
fixture = create_review_run(tmp_path, status=status)
|
||||
repository = ReviewArtifacts(fixture["run_directory"])
|
||||
|
||||
comparison = cast(dict[str, Any], repository.document_comparison("paper"))
|
||||
|
||||
assert comparison["original_markdown"] == fixture["original"]
|
||||
assert comparison["cleaned_markdown"] is None
|
||||
if status == "failed":
|
||||
assert comparison["errors"]
|
||||
assert comparison["residual_proposals"] == []
|
||||
else:
|
||||
assert comparison["errors"] == []
|
||||
assert comparison["residual_proposals"]
|
||||
with pytest.raises(ReviewArtifactError, match="success"):
|
||||
repository.component_stage("paper", 0)
|
||||
|
||||
|
||||
def test_historical_run_without_locator_reports_unavailable_source(tmp_path: Path) -> None:
|
||||
fixture = create_review_run(tmp_path, with_locator=False)
|
||||
repository = ReviewArtifacts(fixture["run_directory"])
|
||||
|
||||
summary = cast(dict[str, Any], repository.run_summary())
|
||||
|
||||
assert summary["documents"][0]["source_available"] is False
|
||||
assert "review-locator.json" in summary["documents"][0]["availability_error"]
|
||||
comparison = cast(dict[str, Any], repository.document_comparison("paper"))
|
||||
assert comparison["original_markdown"] is None
|
||||
assert comparison["cleaned_markdown"] == fixture["cleaned"]
|
||||
assert comparison["changes"][0]["editor_range"] is None
|
||||
with pytest.raises(ReviewArtifactError, match=r"review-locator\.json"):
|
||||
repository.component_stage("paper", 0)
|
||||
|
||||
|
||||
def test_source_hash_change_is_not_silently_displayed(tmp_path: Path) -> None:
|
||||
fixture = create_review_run(tmp_path)
|
||||
repository = ReviewArtifacts(fixture["run_directory"])
|
||||
source_path = fixture["source_path"]
|
||||
assert isinstance(source_path, Path)
|
||||
source_path.write_text("changed\n", encoding="utf-8")
|
||||
|
||||
summary = cast(dict[str, Any], repository.run_summary())
|
||||
|
||||
assert summary["documents"][0]["source_available"] is False
|
||||
assert "哈希" in summary["documents"][0]["availability_error"]
|
||||
comparison = cast(dict[str, Any], repository.document_comparison("paper"))
|
||||
assert comparison["original_markdown"] is None
|
||||
assert comparison["changes"][0]["editor_range"] is None
|
||||
with pytest.raises(ReviewArtifactError, match="哈希"):
|
||||
repository.component_stage("paper", 0)
|
||||
|
||||
|
||||
def test_reviewer_rejects_unknown_schema_and_artifact_path_escape(tmp_path: Path) -> None:
|
||||
fixture = create_review_run(tmp_path)
|
||||
manifest_path = fixture["manifest_path"]
|
||||
assert isinstance(manifest_path, Path)
|
||||
manifest = read_json(manifest_path)
|
||||
manifest["schema_version"] = 2
|
||||
write_json(manifest_path, manifest)
|
||||
|
||||
with pytest.raises(ReviewArtifactError) as unknown:
|
||||
ReviewArtifacts(fixture["run_directory"])
|
||||
assert unknown.value.code == "unsupported_schema"
|
||||
|
||||
second = create_review_run(tmp_path / "second")
|
||||
second_manifest_path = second["manifest_path"]
|
||||
assert isinstance(second_manifest_path, Path)
|
||||
second_manifest = read_json(second_manifest_path)
|
||||
second_manifest["documents"][0]["result_path"] = "../outside.json"
|
||||
write_json(second_manifest_path, second_manifest)
|
||||
with pytest.raises(ReviewArtifactError, match="路径"):
|
||||
ReviewArtifacts(second["run_directory"])
|
||||
|
||||
|
||||
def test_reviewer_rejects_tampered_snapshot_chain_and_unknown_identity(tmp_path: Path) -> None:
|
||||
fixture = create_review_run(tmp_path)
|
||||
result_path = fixture["result_path"]
|
||||
assert isinstance(result_path, Path)
|
||||
result = read_json(result_path)
|
||||
result["changes"][0]["after_sha256"] = "0" * 64
|
||||
write_json(result_path, result)
|
||||
repository = ReviewArtifacts(fixture["run_directory"])
|
||||
|
||||
with pytest.raises(ReviewArtifactError) as replay_error:
|
||||
repository.document_comparison("paper")
|
||||
assert replay_error.value.code == "untrusted_replay"
|
||||
|
||||
with pytest.raises(ReviewArtifactError) as document_error:
|
||||
repository.document_comparison("missing")
|
||||
assert document_error.value.http_status == 404
|
||||
with pytest.raises(ReviewArtifactError) as component_error:
|
||||
repository.component_stage("paper", 99)
|
||||
assert component_error.value.http_status == 404
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from collections.abc import Generator
|
||||
from http.client import HTTPConnection, HTTPResponse
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from reviewer.server import ReviewArtifacts
|
||||
from reviewer.server.__main__ import create_server
|
||||
from tests.reviewer_fixture import ReviewFixture, create_review_run
|
||||
|
||||
|
||||
def request(
|
||||
port: int,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> tuple[HTTPResponse, bytes]:
|
||||
connection = HTTPConnection("127.0.0.1", port, timeout=3)
|
||||
connection.request(method, path, headers=headers or {})
|
||||
response = connection.getresponse()
|
||||
content = response.read()
|
||||
connection.close()
|
||||
return response, content
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def running_server(tmp_path: Path) -> Generator[tuple[int, ReviewFixture], None, None]:
|
||||
fixture = create_review_run(tmp_path)
|
||||
static_root = tmp_path / "static"
|
||||
(static_root / "assets").mkdir(parents=True)
|
||||
(static_root / "index.html").write_text("<main>reviewer</main>\n", encoding="utf-8")
|
||||
(static_root / "assets/app.js").write_text("export {};\n", encoding="utf-8")
|
||||
repository = ReviewArtifacts(fixture["run_directory"])
|
||||
server = create_server(repository, static_root)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server.server_address[1], fixture
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
|
||||
|
||||
def test_server_exposes_same_origin_api_and_static_build(
|
||||
running_server: tuple[int, ReviewFixture],
|
||||
) -> None:
|
||||
port, fixture = running_server
|
||||
|
||||
api_response, api_content = request(port, "GET", "/api/v1/run")
|
||||
page_response, page_content = request(port, "GET", "/")
|
||||
asset_response, _ = request(port, "HEAD", "/assets/app.js")
|
||||
|
||||
payload = cast(dict[str, Any], json.loads(api_content))
|
||||
assert api_response.status == 200
|
||||
assert payload["run"]["run_id"] == "review-run"
|
||||
assert str(fixture["source_path"]) not in api_content.decode()
|
||||
assert api_response.getheader("Access-Control-Allow-Origin") is None
|
||||
assert api_response.getheader("Cache-Control") == "no-store"
|
||||
assert "default-src 'self'" in cast(str, api_response.getheader("Content-Security-Policy"))
|
||||
assert page_response.status == 200
|
||||
assert page_content == b"<main>reviewer</main>\n"
|
||||
assert asset_response.status == 200
|
||||
assert asset_response.getheader("Content-Type") == "text/javascript; charset=utf-8"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "headers", "status", "code"),
|
||||
[
|
||||
("POST", "/api/v1/run", None, 405, "method_not_allowed"),
|
||||
("GET", "/api/v1/run", {"Host": "example.com"}, 403, "invalid_origin"),
|
||||
(
|
||||
"GET",
|
||||
"/api/v1/run",
|
||||
{"Origin": "http://example.com"},
|
||||
403,
|
||||
"invalid_origin",
|
||||
),
|
||||
("GET", "/api/v1/documents/missing", None, 404, "unknown_document"),
|
||||
("GET", "/api/v1/documents/paper/components/99", None, 404, "unknown_component"),
|
||||
("GET", "/assets/missing.js", None, 404, "not_found"),
|
||||
("GET", "/..%2Fsecret.txt", None, 404, "not_found"),
|
||||
],
|
||||
)
|
||||
def test_server_rejects_unsafe_or_unknown_requests(
|
||||
running_server: tuple[int, ReviewFixture],
|
||||
method: str,
|
||||
path: str,
|
||||
headers: dict[str, str] | None,
|
||||
status: int,
|
||||
code: str,
|
||||
) -> None:
|
||||
port, _ = running_server
|
||||
|
||||
response, content = request(port, method, path, headers=headers)
|
||||
payload = cast(dict[str, Any], json.loads(content))
|
||||
|
||||
assert response.status == status
|
||||
assert payload["error"]["code"] == code
|
||||
Reference in New Issue
Block a user