feat: 增加项目无关的本地清洗评审器

This commit is contained in:
2026-08-28 19:06:10 +08:00
parent 10c026c7ad
commit 6c0dd5974b
82 changed files with 9335 additions and 48 deletions
+1 -1
View File
@@ -362,7 +362,7 @@ def test_empty_document_and_empty_rule_set_are_no_ops() -> None:
def test_installed_package_version_matches_delivery_candidate() -> None:
assert distribution_version("mdpolish") == "0.6.1"
assert distribution_version("mdpolish") == "0.7.0"
def test_parameters_preserve_rule_order_and_record_all_options() -> None:
+297
View File
@@ -0,0 +1,297 @@
from __future__ import annotations
from json import dumps, loads
from typing import cast
import pytest
from mdpolish import (
DocumentSnapshot,
Modifier,
Pipeline,
ProposedChange,
RunStatus,
TextEdit,
TextSpan,
)
from mdpolish.review import (
ReviewDetail,
ReviewDocument,
ReviewParseError,
build_review_document,
parse_json_report,
render_json_report,
)
def _replace_modifier(
needle: str,
replacement: str,
*,
modifier_id: str,
reason: str = "应用虚构测试替换",
) -> Modifier:
def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
start = snapshot.markdown.find(needle)
if start < 0:
return ()
return (
ProposedChange(
snapshot_sha256=snapshot.sha256,
reason=reason,
edits=(
TextEdit(
snapshot_sha256=snapshot.sha256,
span=TextSpan(start, start + len(needle)),
expected_text=needle,
replacement=replacement,
),
),
),
)
return Modifier(
modifier_id=modifier_id,
version="1.0.0",
parameters={"needle": needle, "replacement": replacement},
applicability="只处理虚构 JSON reader 测试标记。",
propose=propose,
)
def _noop_modifier(modifier_id: str = "test.noop") -> Modifier:
return Modifier(
modifier_id=modifier_id,
version="1.0.0",
parameters=(),
applicability="不修改任何文本。",
propose=lambda _snapshot: (),
)
def _success_review() -> ReviewDocument:
input_markdown = "\ufeff首行\r\n🙂Cafe\u0301\r末行"
result = Pipeline(
(
_noop_modifier(),
_replace_modifier("🙂Cafe\u0301", "完成", modifier_id="test.unicode"),
)
).transform(input_markdown)
assert result.status is RunStatus.SUCCESS
return build_review_document(input_markdown, result)
def _unstable_review() -> ReviewDocument:
residual_marker = "RESIDUAL_SECRET"
def residual(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
start = snapshot.markdown.find(residual_marker)
if start < 0:
return ()
return (
ProposedChange(
snapshot_sha256=snapshot.sha256,
reason="残留虚构候选",
edits=(
TextEdit(
snapshot_sha256=snapshot.sha256,
span=TextSpan(start, start + len(residual_marker)),
expected_text=residual_marker,
replacement="resolved",
),
),
),
)
result = Pipeline(
(
Modifier(
modifier_id="test.residual",
version="1.0.0",
parameters=(),
applicability="只在最终复查产生候选。",
propose=residual,
),
_replace_modifier("start", residual_marker, modifier_id="test.producer"),
)
).transform("start")
assert result.status is RunStatus.UNSTABLE
return build_review_document("start", result)
def _error_reviews() -> tuple[ReviewDocument, ...]:
preflight_result = Pipeline(cast(tuple[Modifier, ...], (object(),))).transform("preflight")
def explode(_snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
raise RuntimeError("DIAGNOSTIC_SECRET")
transform_result = Pipeline(
(
Modifier(
modifier_id="test.transform-error",
version="1.0.0",
parameters=(),
applicability="只测试执行错误。",
propose=explode,
),
)
).transform("transform")
def explode_on_done(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
if snapshot.markdown == "done":
raise RuntimeError("FINAL_DIAGNOSTIC_SECRET")
return ()
final_result = Pipeline(
(
Modifier(
modifier_id="test.final-error",
version="1.0.0",
parameters=(),
applicability="只测试最终复查错误。",
propose=explode_on_done,
),
_replace_modifier("start", "done", modifier_id="test.final-producer"),
)
).transform("start")
return (
build_review_document("preflight", preflight_result),
build_review_document("transform", transform_result),
build_review_document("start", final_result),
)
@pytest.mark.parametrize("detail", tuple(ReviewDetail))
def test_reader_accepts_every_official_detail(detail: ReviewDetail) -> None:
review = _success_review()
report = render_json_report(review, detail=detail)
parsed = parse_json_report(report, expected_detail=detail)
assert parsed == loads(report)
assert parsed["detail"] == detail.value
def test_reader_accepts_unstable_summary_without_materializing_residuals() -> None:
parsed = parse_json_report(render_json_report(_unstable_review(), detail="summary"))
assert parsed["status"] == "unstable"
assert "residual_proposals" not in parsed
@pytest.mark.parametrize("review", _error_reviews())
@pytest.mark.parametrize("detail", tuple(ReviewDetail))
def test_reader_accepts_each_failed_stage(review: ReviewDocument, detail: ReviewDetail) -> None:
parsed = parse_json_report(render_json_report(review, detail=detail))
assert parsed["status"] == "failed"
def test_reader_accepts_compatible_minor_and_unknown_object_field() -> None:
payload = loads(render_json_report(_success_review(), detail="full"))
payload["schema_version"] = "1.9"
payload["future_summary"] = {"available": True}
payload["hash_contract"]["future_hash_note"] = "compatible"
payload["coordinate_contract"]["future_coordinate_note"] = "compatible"
parsed = parse_json_report(dumps(payload, ensure_ascii=False))
assert parsed["schema_version"] == "1.9"
assert parsed["future_summary"] == {"available": True}
def test_reader_rejects_stage_after_that_was_not_produced_by_changes() -> None:
payload = loads(render_json_report(_success_review(), detail="full"))
stage = payload["stages"][1]
forged = "伪造结果"
forged_hash = DocumentSnapshot(forged).sha256
stage["after"] = {
"sha256": forged_hash,
"code_point_length": len(forged),
"markdown": forged,
}
stage["changes"][0]["after_sha256"] = forged_hash
payload["current"] = {
"sha256": forged_hash,
"code_point_length": len(forged),
"markdown": forged,
}
with pytest.raises(ReviewParseError, match="replayed changes do not produce"):
parse_json_report(dumps(payload, ensure_ascii=False), expected_detail="full")
def test_reader_rejects_changed_zero_change_stage() -> None:
payload = loads(render_json_report(_success_review(), detail="full"))
stage = payload["stages"][0]
forged = "改变了零修改阶段"
forged_hash = DocumentSnapshot(forged).sha256
stage["after"] = {
"sha256": forged_hash,
"code_point_length": len(forged),
"markdown": forged,
}
with pytest.raises(ReviewParseError, match="zero-change stage"):
parse_json_report(dumps(payload, ensure_ascii=False), expected_detail="full")
def test_reader_rejects_conflicting_change_batch() -> None:
payload = loads(render_json_report(_success_review(), detail="full"))
stage = payload["stages"][1]
duplicate = dict(stage["changes"][0])
duplicate["edit_index"] = 1
stage["changes"].append(duplicate)
stage["change_count"] = 2
payload["counts"]["change_count"] = 2
with pytest.raises(ReviewParseError, match="edit contract"):
parse_json_report(dumps(payload, ensure_ascii=False), expected_detail="full")
def test_reader_rejects_wrong_location_without_leaking_text() -> None:
payload = loads(render_json_report(_success_review(), detail="full"))
change = payload["stages"][1]["changes"][0]
change["location"] = {"line": 99, "column": 99}
with pytest.raises(ReviewParseError) as error:
parse_json_report(dumps(payload, ensure_ascii=False), expected_detail="full")
assert "🙂Cafe\u0301" not in str(error.value)
assert "location does not match" in str(error.value)
@pytest.mark.parametrize(
("report", "message"),
(
('{"schema_name":"a","schema_name":"b"}', "keys must be unique"),
('{"value":NaN}', "non-finite"),
('{"schema_name":"mdpolish.review","schema_version":"2.0"}', "schema major"),
(dumps({"schema_name": "\ud800"}), "valid Unicode"),
("not JSON", "malformed JSON"),
),
)
def test_reader_rejects_malformed_or_unsupported_json(report: str, message: str) -> None:
with pytest.raises(ReviewParseError, match=message):
parse_json_report(report)
def test_reader_rejects_detail_mismatch_and_invalid_expected_detail() -> None:
report = render_json_report(_success_review(), detail="summary")
with pytest.raises(ReviewParseError, match="does not match"):
parse_json_report(report, expected_detail="full")
with pytest.raises(ReviewParseError, match="expected_detail"):
parse_json_report(report, expected_detail="unknown")
def test_reader_enforces_body_exposure_boundaries() -> None:
summary = loads(render_json_report(_success_review(), detail="summary"))
summary["future"] = {"markdown": "SUMMARY_SECRET"}
with pytest.raises(ReviewParseError, match="body-bearing"):
parse_json_report(dumps(summary, ensure_ascii=False))
changes = loads(render_json_report(_success_review(), detail="changes"))
changes["future"] = {"markdown": "CHANGES_SECRET"}
with pytest.raises(ReviewParseError, match="full markdown"):
parse_json_report(dumps(changes, ensure_ascii=False))
+303
View File
@@ -0,0 +1,303 @@
from __future__ import annotations
import http.client
import json
import threading
from pathlib import Path
from typing import cast
import pytest
from mdpolish import DocumentSnapshot, Modifier, Pipeline, ProposedChange, TextEdit, TextSpan
from mdpolish.review import build_review_document, render_json_report
from mdpolish.reviewer import ReviewDataError, ReviewRepository, create_server, main
def _object(value: object) -> dict[str, object]:
assert isinstance(value, dict)
return cast(dict[str, object], value)
def _array(value: object) -> list[object]:
assert isinstance(value, list)
return cast(list[object], value)
def _full_review_json(source: str = "😀 old\r\nlast line\r\n") -> str:
"""生成包含 emoji、CRLF、一次修改和一个零修改阶段的虚构 full JSON。"""
def replace_old(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
start = snapshot.markdown.find("old")
if start < 0:
return ()
return (
ProposedChange(
snapshot_sha256=snapshot.sha256,
reason="替换虚构测试词",
edits=(
TextEdit(
snapshot_sha256=snapshot.sha256,
span=TextSpan(start, start + 3),
expected_text="old",
replacement="new",
),
),
),
)
pipeline = Pipeline(
(
Modifier(
modifier_id="test.replace",
version="1.0.0",
parameters={"mode": "exact"},
applicability="只替换虚构测试词。",
propose=replace_old,
),
Modifier(
modifier_id="test.zero",
version="1.0.0",
parameters={},
applicability="用于验证零修改阶段。",
propose=lambda _snapshot: (),
),
)
)
result = pipeline.transform(source)
review = build_review_document(source, result)
return render_json_report(review, detail="full") + "\n"
def _review_directory(tmp_path: Path, content: str | None = None) -> Path:
review_dir = tmp_path / "reviews"
review_dir.mkdir(parents=True)
(review_dir / "paper.review.json").write_text(
_full_review_json() if content is None else content,
encoding="utf-8",
newline="",
)
return review_dir
def test_repository_exposes_collection_document_and_modifier_stages(tmp_path: Path) -> None:
repository = ReviewRepository(_review_directory(tmp_path))
collection = repository.collection_response()
document = repository.document("paper").document_response()
first_stage = repository.document("paper").stage_response(0)
zero_stage = repository.document("paper").stage_response(1)
assert collection["collection"] == {"label": "reviews", "status": "success"}
assert collection["summary"] == {
"document_count": 1,
"success_count": 1,
"failed_count": 0,
"unstable_count": 0,
"change_count": 1,
}
assert "input_markdown" not in json.dumps(collection)
assert document["input_markdown"] == "😀 old\r\nlast line\r\n"
assert document["current_markdown"] == "😀 new\r\nlast line\r\n"
modifiers = _array(document["modifiers"])
assert [_object(item)["modifier_id"] for item in modifiers] == ["test.replace", "test.zero"]
changes = _array(document["changes"])
# Python 码点 2 位于 emoji 后; CodeMirror UTF-16 下标因此是 3。
assert _object(changes[0])["span"] == {"start": 2, "end": 5}
assert _object(changes[0])["editor_range"] == {"start": 3, "end": 6}
assert first_stage["before_markdown"] == "😀 old\r\nlast line\r\n"
assert first_stage["after_markdown"] == "😀 new\r\nlast line\r\n"
assert zero_stage["before_markdown"] == zero_stage["after_markdown"]
assert zero_stage["changes"] == []
def test_repository_handles_unicode_empty_text_and_file_labels(tmp_path: Path) -> None:
source = "中e\u0301😀\r\nold\rend"
repository = ReviewRepository(_review_directory(tmp_path, _full_review_json(source)))
document = repository.document("paper").document_response()
changes = _array(document["changes"])
assert _object(changes[0])["location"] == {"line": 2, "column": 1}
assert _object(changes[0])["span"] == {"start": 6, "end": 9}
assert _object(changes[0])["editor_range"] == {"start": 7, "end": 10}
assert document["current_markdown"] == "中e\u0301😀\r\nnew\rend"
assert _object(document["document"])["source_label"] == "paper"
empty_directory = tmp_path / "empty"
empty_directory.mkdir()
(empty_directory / "empty.review.json").write_text(
_full_review_json(""),
encoding="utf-8",
newline="",
)
empty_document = ReviewRepository(empty_directory).document("empty").document_response()
assert empty_document["input_markdown"] == ""
assert empty_document["current_markdown"] == ""
@pytest.mark.parametrize(
"mutation",
(
lambda payload: payload.__setitem__("detail", "changes"),
lambda payload: payload["input"].__setitem__(
"markdown",
payload["input"]["markdown"].replace("old", "bad"),
),
lambda payload: payload["stages"][0]["changes"][0]["span"].__setitem__("start", 0),
),
)
def test_repository_rejects_untrusted_review_shapes(tmp_path: Path, mutation: object) -> None:
payload = json.loads(_full_review_json())
assert callable(mutation)
mutation(payload)
review_dir = _review_directory(tmp_path, json.dumps(payload, ensure_ascii=False) + "\n")
with pytest.raises(ReviewDataError) as raised:
ReviewRepository(review_dir)
assert raised.value.code == "invalid_review"
assert str(raised.value).startswith("paper.review.json")
def test_repository_accepts_compatible_schema_minor(tmp_path: Path) -> None:
payload = json.loads(_full_review_json())
payload["schema_version"] = "1.4"
payload["future_summary"] = {"available": True}
repository = ReviewRepository(
_review_directory(tmp_path, json.dumps(payload, ensure_ascii=False) + "\n")
)
assert repository.document("paper").status == "success"
def test_repository_rejects_bom_invalid_utf8_and_symlinks(tmp_path: Path) -> None:
review_dir = _review_directory(tmp_path)
review_path = review_dir / "paper.review.json"
review_path.write_bytes(b"\xef\xbb\xbf" + review_path.read_bytes())
with pytest.raises(ReviewDataError, match="BOM"):
ReviewRepository(review_dir)
review_path.write_bytes(b'{"bad": "\xff"}')
with pytest.raises(ReviewDataError, match="UTF-8"):
ReviewRepository(review_dir)
review_path.unlink()
target = tmp_path / "outside.review.json"
target.write_text(_full_review_json(), encoding="utf-8", newline="")
review_path.symlink_to(target)
with pytest.raises(ReviewDataError) as raised:
ReviewRepository(review_dir)
assert raised.value.code == "unsafe_path"
review_path.unlink()
review_path.write_text(_full_review_json(), encoding="utf-8", newline="")
link = tmp_path / "review-link"
link.symlink_to(review_dir, target_is_directory=True)
with pytest.raises(ReviewDataError) as raised:
ReviewRepository(link)
assert raised.value.code == "unsafe_path"
def test_repository_rejects_empty_directory_and_empty_label(tmp_path: Path) -> None:
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(ReviewDataError) as raised:
ReviewRepository(empty)
assert raised.value.code == "empty_review_directory"
(empty / ".review.json").write_text(_full_review_json(), encoding="utf-8", newline="")
with pytest.raises(ReviewDataError, match="缺少文档标签"):
ReviewRepository(empty)
def test_console_entry_reports_expected_startup_failure_without_traceback(tmp_path: Path) -> None:
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(SystemExit) as raised:
main(["--review-dir", str(empty)])
assert str(raised.value) == "评审器启动失败:评审目录没有直属 full review JSON。"
def test_http_server_is_loopback_read_only_and_does_not_leak_text(tmp_path: Path) -> None:
repository = ReviewRepository(_review_directory(tmp_path))
static_root = tmp_path / "static"
static_root.mkdir()
(static_root / "index.html").write_text("<!doctype html><title>review</title>", encoding="utf-8")
server = create_server(repository, static_root)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
host = cast(str, server.server_address[0])
port = server.server_address[1]
try:
assert host == "127.0.0.1"
connection = http.client.HTTPConnection(host, port)
connection.request("GET", "/api/v1/collection")
response = connection.getresponse()
body = response.read().decode("utf-8")
assert response.status == 200
assert response.getheader("Cache-Control") == "no-store"
assert response.getheader("Access-Control-Allow-Origin") is None
assert "😀 old" not in body
connection.request("POST", "/api/v1/collection")
response = connection.getresponse()
response.read()
assert response.status == 405
connection.request("GET", "/api/v1/documents/missing")
response = connection.getresponse()
response.read()
assert response.status == 404
connection.request("GET", "/api/v1/documents/paper/modifiers/99")
response = connection.getresponse()
response.read()
assert response.status == 404
connection.request("HEAD", "/")
response = connection.getresponse()
assert response.status == 200
assert response.read() == b""
assert response.getheader("Content-Security-Policy") is not None
assert response.getheader("X-Content-Type-Options") == "nosniff"
connection.putrequest("GET", "/api/v1/collection", skip_host=True)
connection.putheader("Host", "example.com")
connection.endheaders()
response = connection.getresponse()
response.read()
assert response.status == 403
connection.close()
connection = http.client.HTTPConnection(host, port)
connection.putrequest("GET", "/api/v1/collection")
connection.putheader("Origin", "http://example.com")
connection.endheaders()
response = connection.getresponse()
response.read()
assert response.status == 403
connection.close()
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
def test_server_rejects_missing_or_symlink_static_root(tmp_path: Path) -> None:
repository = ReviewRepository(_review_directory(tmp_path))
missing = tmp_path / "missing-static"
with pytest.raises(ReviewDataError) as raised:
create_server(repository, missing)
assert raised.value.code == "missing_build"
real = tmp_path / "real-static"
real.mkdir()
(real / "index.html").write_text("ok", encoding="utf-8")
link = tmp_path / "static-link"
link.symlink_to(real, target_is_directory=True)
with pytest.raises(ReviewDataError) as raised:
create_server(repository, link)
assert raised.value.code == "missing_build"