实现本地 Markdown 清洗评审器

This commit is contained in:
2026-08-24 01:02:16 +08:00
parent 80001a8ab9
commit ac798a2610
39 changed files with 9357 additions and 119 deletions
+1
View File
@@ -0,0 +1 @@
24
+1
View File
@@ -0,0 +1 @@
"""Repository-local Markdown cleaning reviewer."""
+40
View File
@@ -0,0 +1,40 @@
import eslint from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "coverage", "node_modules"] },
eslint.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.flat.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
"@typescript-eslint/no-confusing-void-expression": "off",
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
"react-hooks/set-state-in-effect": "off",
},
},
{
files: ["tests/**/*.{ts,tsx}"],
rules: {
"@typescript-eslint/no-non-null-assertion": "off",
},
},
);
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>mdpolish 评审器</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>
+4599
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "mdpolish-reviewer",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": "^24.0.0"
},
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "npm run typecheck && vite build",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "eslint src tests vite.config.ts",
"test": "vitest run",
"check": "npm run lint && npm run test && npm run build"
},
"dependencies": {
"@codemirror/lang-markdown": "6.5.2",
"@codemirror/merge": "6.12.2",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.9",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.2",
"@types/node": "24.13.3",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "6.1.0",
"eslint": "10.9.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.4",
"globals": "17.11.0",
"jsdom": "30.0.1",
"typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"vite": "8.2.2",
"vitest": "4.1.11"
}
}
+5
View File
@@ -0,0 +1,5 @@
"""Read-only artifact adapter and local HTTP service."""
from reviewer.server.artifacts import ReviewArtifactError, ReviewArtifacts
__all__ = ["ReviewArtifactError", "ReviewArtifacts"]
+233
View File
@@ -0,0 +1,233 @@
"""Serve one local review run through a loopback-only read-only HTTP API."""
from __future__ import annotations
import argparse
import json
import mimetypes
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlsplit
from reviewer.server.artifacts import JsonObject, ReviewArtifactError, ReviewArtifacts
_SECURITY_HEADERS = {
"Cache-Control": "no-store",
"Content-Security-Policy": (
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'none'; "
"font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
),
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
}
class ReviewerHttpServer(ThreadingHTTPServer):
"""Threaded local server whose workers never keep process shutdown alive."""
daemon_threads = True
def _valid_local_request(handler: BaseHTTPRequestHandler) -> bool:
host = handler.headers.get("Host")
if host is None:
return False
try:
parsed_host = urlsplit(f"//{host}")
if parsed_host.username is not None or parsed_host.password is not None:
return False
if parsed_host.hostname not in {"127.0.0.1", "localhost"}:
return False
if parsed_host.port is not None and not 0 < parsed_host.port < 65536:
return False
except ValueError:
return False
origin = handler.headers.get("Origin")
if origin is None:
return True
try:
parsed_origin = urlsplit(origin)
return parsed_origin.scheme == "http" and parsed_origin.netloc == host
except ValueError:
return False
def _api_response(repository: ReviewArtifacts, path: str) -> JsonObject:
if path == "/api/v1/run":
return repository.run_summary()
parts = [part for part in path.split("/") if part]
try:
if len(parts) == 4 and parts[:3] == ["api", "v1", "documents"]:
return repository.document_comparison(unquote(parts[3], encoding="utf-8", errors="strict"))
if len(parts) == 6 and parts[:3] == ["api", "v1", "documents"] and parts[4] == "components":
document_id = unquote(parts[3], encoding="utf-8", errors="strict")
try:
component_position = int(parts[5])
except ValueError:
raise ReviewArtifactError("unknown_component", "组件位置不存在。", 404) from None
return repository.component_stage(document_id, component_position)
except UnicodeDecodeError:
raise ReviewArtifactError("not_found", "请求的资源不存在。", 404) from None
raise ReviewArtifactError("not_found", "请求的资源不存在。", 404)
def _handler_factory(repository: ReviewArtifacts, static_root: Path) -> type[BaseHTTPRequestHandler]:
class ReviewRequestHandler(BaseHTTPRequestHandler):
server_version = "mdpolish-reviewer"
sys_version = ""
def log_message(self, format_: str, *args: Any) -> None:
del format_, args
def _headers(self, status: int, content_type: str, content_length: int) -> None:
self.send_response(status)
for name, value in _SECURITY_HEADERS.items():
self.send_header(name, value)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(content_length))
self.end_headers()
def _json(self, status: int, payload: JsonObject, *, head_only: bool) -> None:
content = (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode()
self._headers(status, "application/json; charset=utf-8", len(content))
if not head_only:
self.wfile.write(content)
def _error(self, error: Exception, *, head_only: bool) -> None:
if isinstance(error, ReviewArtifactError):
status = error.http_status
code = error.code
message = str(error)
else:
status = 500
code = "internal_error"
message = "评审器无法完成该请求。"
self._json(status, {"error": {"code": code, "message": message}}, head_only=head_only)
def _static(self, path: str, *, head_only: bool) -> None:
requested = "index.html" if path == "/" else unquote(path[1:], encoding="utf-8", errors="strict")
if "\0" in requested:
raise ReviewArtifactError("not_found", "请求的资源不存在。", 404)
candidate = static_root / requested
try:
if candidate.is_symlink():
raise ReviewArtifactError("not_found", "请求的资源不存在。", 404)
resolved = candidate.resolve(strict=True)
if not resolved.is_relative_to(static_root) or not resolved.is_file():
raise FileNotFoundError
except (FileNotFoundError, OSError):
if Path(requested).suffix:
raise ReviewArtifactError("not_found", "请求的资源不存在。", 404) from None
resolved = (static_root / "index.html").resolve(strict=True)
content = resolved.read_bytes()
content_type = mimetypes.guess_type(resolved.name)[0] or "application/octet-stream"
if content_type.startswith("text/") or content_type in {"application/javascript", "application/json"}:
content_type += "; charset=utf-8"
self._headers(200, content_type, len(content))
if not head_only:
self.wfile.write(content)
def _handle(self, method: str) -> None:
head_only = method == "HEAD"
if method not in {"GET", "HEAD"}:
self.send_response(405)
for name, value in _SECURITY_HEADERS.items():
self.send_header(name, value)
self.send_header("Allow", "GET, HEAD")
payload: JsonObject = {
"error": {"code": "method_not_allowed", "message": "只允许 GET 和 HEAD。"}
}
content = (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
return
try:
if not _valid_local_request(self):
raise ReviewArtifactError("invalid_origin", "只接受本机同源请求。", 403)
request_path = urlsplit(self.path).path
if request_path.startswith("/api/"):
self._json(200, _api_response(repository, request_path), head_only=head_only)
else:
self._static(request_path, head_only=head_only)
except Exception as error: # the response intentionally hides unexpected implementation details
self._error(error, head_only=head_only)
def do_GET(self) -> None:
self._handle("GET")
def do_HEAD(self) -> None:
self._handle("HEAD")
def do_POST(self) -> None:
self._handle("POST")
def do_PUT(self) -> None:
self._handle("PUT")
def do_PATCH(self) -> None:
self._handle("PATCH")
def do_DELETE(self) -> None:
self._handle("DELETE")
def do_OPTIONS(self) -> None:
self._handle("OPTIONS")
return ReviewRequestHandler
def create_server(
repository: ReviewArtifacts,
static_root: Path,
*,
port: int = 0,
) -> ReviewerHttpServer:
"""Create, but do not start, the loopback reviewer server."""
index = static_root / "index.html"
if not index.is_file():
raise ReviewArtifactError("missing_build", "未找到前端构建结果,请先运行 npm run build。", 400)
return ReviewerHttpServer(("127.0.0.1", port), _handler_factory(repository, static_root.resolve()))
def _arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="只读查看一次 mdpolish 本地清洗运行。")
parser.add_argument("--run-dir", required=True, help="一次已发布运行目录的绝对或相对路径")
parser.add_argument("--port", type=int, default=0, help="本机端口;默认 0 表示自动选择")
arguments = parser.parse_args(argv)
if arguments.port < 0 or arguments.port > 65535:
parser.error("--port 必须在 0 到 65535 之间")
return arguments
def main(argv: list[str] | None = None) -> None:
arguments = _arguments(argv)
repository = ReviewArtifacts(arguments.run_dir)
static_root = Path(__file__).resolve().parents[1] / "dist"
try:
server = create_server(repository, static_root, port=arguments.port)
except OSError as error:
raise ReviewArtifactError("server_error", "无法启动本地评审服务。", 500) from error
port = server.server_address[1]
print(
f"mdpolish 评审器已启动:http://127.0.0.1:{port}{repository.run_id}"
f"{len(repository.documents)} 份文档)",
flush=True,
)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
try:
main()
except ReviewArtifactError as error:
raise SystemExit(f"评审器启动失败:{error}") from None
+691
View File
@@ -0,0 +1,691 @@
"""Strict adapters from published mdpolish artifacts to the reviewer API."""
from __future__ import annotations
import json
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
from typing import NoReturn, TypeAlias, cast
from mdpolish._artifact_replay import (
LocatedChange,
ReplayChange,
ReplayComponent,
ReplayError,
ReplayResult,
replay_change_chain,
)
JsonValue: TypeAlias = bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] | None
JsonObject: TypeAlias = dict[str, JsonValue]
_STATUSES = {"success", "failed", "unstable"}
_ERROR_STAGES = {"transform", "final_review"}
class ReviewArtifactError(ValueError):
"""Published artifacts cannot be exposed as a trustworthy review response."""
def __init__(self, code: str, message: str, http_status: int = 422) -> None:
super().__init__(message)
self.code = code
self.http_status = http_status
@dataclass(frozen=True, slots=True)
class ComponentRecord:
component_id: str
version: str
parameters: JsonValue
applicability: str
@dataclass(frozen=True, slots=True)
class ManifestDocument:
document_id: str
source_label: str
status: str
input_sha256: str
current_sha256: str
change_count: int
result_path: str
cleaned_path: str | None
diff_path: str | None
@dataclass(frozen=True, slots=True)
class LocatorDocument:
document_id: str
source_path: Path
input_sha256: str
@dataclass(frozen=True, slots=True)
class ChangeRecord:
replay: ReplayChange
reason: str
recorded_line: int
recorded_column: int
@dataclass(frozen=True, slots=True)
class ResultRecord:
status: str
input_sha256: str
current_sha256: str
changes: tuple[ChangeRecord, ...]
errors: tuple[JsonObject, ...]
residual_proposals: tuple[JsonObject, ...]
@dataclass(frozen=True, slots=True)
class DocumentRecord:
manifest: ManifestDocument
result: ResultRecord
locator: LocatorDocument | None
def _fail(code: str, message: str, http_status: int = 422) -> NoReturn:
raise ReviewArtifactError(code, message, http_status)
def _object(value: object, label: str) -> dict[str, object]:
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
_fail("invalid_artifact", f"{label} 必须是 JSON 对象。")
return cast(dict[str, object], value)
def _array(value: object, label: str) -> list[object]:
if not isinstance(value, list):
_fail("invalid_artifact", f"{label} 必须是数组。")
return cast(list[object], value)
def _string(value: object, label: str, *, allow_empty: bool = False) -> str:
if not isinstance(value, str) or "\0" in value or (not allow_empty and not value):
_fail("invalid_artifact", f"{label} 必须是字符串。")
return value
def _integer(value: object, label: str, *, minimum: int = 0) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
_fail("invalid_artifact", f"{label} 必须是不小于 {minimum} 的整数。")
return value
def _hash(value: object, label: str) -> str:
digest = _string(value, label)
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
_fail("invalid_artifact", f"{label} 必须是小写 SHA-256 摘要。")
return digest
def _status(value: object, label: str) -> str:
status = _string(value, label)
if status not in _STATUSES:
_fail("invalid_artifact", f"{label} 不是已知状态。")
return status
def _json_value(value: object, label: str) -> JsonValue:
if value is None or isinstance(value, str | int | float | bool):
return value
if isinstance(value, list):
return [_json_value(item, label) for item in value]
if isinstance(value, dict) and all(isinstance(key, str) for key in value):
return {cast(str, key): _json_value(item, label) for key, item in value.items()}
_fail("invalid_artifact", f"{label} 包含不支持的 JSON 值。")
raise AssertionError("unreachable")
def _read_json(path: Path, label: str) -> dict[str, object]:
try:
content = path.read_bytes()
except OSError:
_fail("missing_artifact", f"无法读取{label}")
if content.startswith(b"\xef\xbb\xbf"):
_fail("invalid_artifact", f"{label} 不能包含 UTF-8 BOM。")
if not content.endswith(b"\n"):
_fail("invalid_artifact", f"{label} 必须以换行结尾。")
try:
text = content.decode("utf-8", errors="strict")
except UnicodeDecodeError:
_fail("invalid_utf8", f"{label} 不是严格 UTF-8。")
try:
payload = cast(
object,
json.loads(
text,
parse_constant=lambda _value: _fail(
"invalid_artifact", f"{label} 不能包含非有限数值。"
),
),
)
except json.JSONDecodeError:
_fail("invalid_artifact", f"{label} 不是合法 JSON。")
return _object(payload, label)
def _read_markdown(path: Path, expected_hash: str, label: str, error_code: str) -> str:
try:
if path.is_symlink() or not path.is_file():
_fail(error_code, f"{label}不是普通文件。", 409)
content = path.read_bytes()
except OSError:
_fail(error_code, f"无法读取{label}", 409)
if sha256(content).hexdigest() != expected_hash:
_fail("hash_mismatch", f"{label}的内容哈希已经变化。", 409)
try:
return content.decode("utf-8", errors="strict")
except UnicodeDecodeError:
_fail("invalid_utf8", f"{label}不是严格 UTF-8。", 409)
raise AssertionError("unreachable")
def _run_path(run_directory: Path, relative_path: str, label: str) -> Path:
relative = Path(relative_path)
if relative.is_absolute() or "\0" in relative_path:
_fail("unsafe_path", f"{label}必须是运行目录内的相对路径。")
unresolved = run_directory / relative
try:
if unresolved.is_symlink() or not unresolved.is_file():
_fail("missing_artifact", f"无法读取{label}")
resolved = unresolved.resolve(strict=True)
except OSError:
_fail("missing_artifact", f"无法读取{label}")
if not resolved.is_relative_to(run_directory):
_fail("unsafe_path", f"{label}越过了运行目录边界。")
return resolved
def _component(value: object, position: int) -> ComponentRecord:
item = _object(value, f"pipeline.components[{position}]")
return ComponentRecord(
component_id=_string(item.get("component_id"), "component_id"),
version=_string(item.get("version"), "component version"),
parameters=_json_value(item.get("parameters"), "component parameters"),
applicability=_string(item.get("applicability"), "component applicability"),
)
def _manifest_document(value: object, position: int) -> ManifestDocument:
item = _object(value, f"manifest.documents[{position}]")
document_id = _string(item.get("document_id"), "document_id")
status = _status(item.get("status"), "document status")
result_path = _string(item.get("result_path"), "result_path")
cleaned_value = item.get("cleaned_path")
diff_value = item.get("diff_path")
cleaned_path = None if cleaned_value is None else _string(cleaned_value, "cleaned_path")
diff_path = None if diff_value is None else _string(diff_value, "diff_path")
base = f"documents/{document_id}"
if (
result_path != f"{base}/result.json"
or cleaned_path != (f"{base}/cleaned.md" if status == "success" else None)
or diff_path != (f"{base}/changes.diff" if status == "success" else None)
):
_fail("invalid_artifact", "manifest 文档产物路径不符合 schema 1。")
return ManifestDocument(
document_id=document_id,
source_label=_string(item.get("source_label"), "source_label"),
status=status,
input_sha256=_hash(item.get("input_sha256"), "input_sha256"),
current_sha256=_hash(item.get("current_sha256"), "current_sha256"),
change_count=_integer(item.get("change_count"), "change_count"),
result_path=result_path,
cleaned_path=cleaned_path,
diff_path=diff_path,
)
def _parse_change(value: object, position: int) -> ChangeRecord:
item = _object(value, f"changes[{position}]")
proposal = _object(item.get("proposal_ref"), "proposal_ref")
span = _object(item.get("span"), "span")
location = _object(item.get("location"), "location")
start = _integer(span.get("start"), "span.start")
end = _integer(span.get("end"), "span.end")
if end < start:
_fail("invalid_artifact", "change span 必须满足 start <= end。")
replay = ReplayChange(
component_id=_string(item.get("component_id"), "change component_id"),
component_version=_string(item.get("component_version"), "change component_version"),
component_position=_integer(item.get("component_position"), "change component_position"),
proposal_component_position=_integer(
proposal.get("component_position"), "proposal component_position"
),
proposal_snapshot_sha256=_hash(proposal.get("snapshot_sha256"), "proposal snapshot_sha256"),
proposal_index=_integer(proposal.get("proposal_index"), "proposal_index"),
edit_index=_integer(item.get("edit_index"), "edit_index"),
start=start,
end=end,
before=_string(item.get("before"), "before", allow_empty=True),
after=_string(item.get("after"), "after", allow_empty=True),
before_sha256=_hash(item.get("before_sha256"), "before_sha256"),
after_sha256=_hash(item.get("after_sha256"), "after_sha256"),
)
return ChangeRecord(
replay=replay,
reason=_string(item.get("reason"), "change reason"),
recorded_line=_integer(location.get("line"), "location.line", minimum=1),
recorded_column=_integer(location.get("column"), "location.column", minimum=1),
)
def _parse_error(value: object, position: int) -> JsonObject:
item = _object(value, f"errors[{position}]")
stage = _string(item.get("stage"), "error stage")
if stage not in _ERROR_STAGES:
_fail("invalid_artifact", "error stage 不是已知阶段。")
return {
"component_id": _string(item.get("component_id"), "error component_id"),
"component_version": _string(item.get("component_version"), "error component_version"),
"component_position": _integer(item.get("component_position"), "error component_position"),
"stage": stage,
"error_type": _string(item.get("error_type"), "error_type"),
"message": _string(item.get("message"), "error message"),
}
def _parse_residual(value: object, position: int) -> JsonObject:
item = _object(value, f"residual_proposals[{position}]")
reference = _object(item.get("proposal_ref"), "residual proposal_ref")
proposal = _object(item.get("proposal"), "residual proposal")
component_position = _integer(item.get("component_position"), "residual component_position")
proposal_snapshot = _hash(proposal.get("snapshot_sha256"), "residual proposal snapshot_sha256")
if (
_integer(reference.get("component_position"), "residual reference component_position")
!= component_position
or _hash(reference.get("snapshot_sha256"), "residual reference snapshot_sha256")
!= proposal_snapshot
):
_fail("invalid_artifact", "residual proposal_ref 与候选身份不一致。")
_integer(reference.get("proposal_index"), "residual proposal_index")
edits = _array(proposal.get("edits"), "residual proposal edits")
if not edits:
_fail("invalid_artifact", "residual proposal edits 不能为空。")
for edit_position, edit_value in enumerate(edits):
edit = _object(edit_value, f"residual edit[{edit_position}]")
span = _object(edit.get("span"), "residual edit span")
if _hash(edit.get("snapshot_sha256"), "residual edit snapshot_sha256") != proposal_snapshot:
_fail("invalid_artifact", "residual edit 与候选快照不一致。")
start = _integer(span.get("start"), "residual span.start")
end = _integer(span.get("end"), "residual span.end")
expected = _string(edit.get("expected_text"), "residual expected_text", allow_empty=True)
_string(edit.get("replacement"), "residual replacement", allow_empty=True)
if end < start or len(expected) != end - start:
_fail("invalid_artifact", "residual edit 范围与 expected_text 不一致。")
return {
"component_id": _string(item.get("component_id"), "residual component_id"),
"component_version": _string(item.get("component_version"), "residual component_version"),
"component_position": component_position,
"reason": _string(proposal.get("reason"), "residual reason"),
"edit_count": len(edits),
}
def _component_json(component: ComponentRecord, position: int, change_count: int) -> JsonObject:
return {
"component_position": position,
"component_id": component.component_id,
"version": component.version,
"parameters": component.parameters,
"applicability": component.applicability,
"change_count": change_count,
}
class ReviewArtifacts:
"""Validated, read-only view over one explicitly selected run directory."""
def __init__(self, run_directory: str | Path) -> None:
requested = Path(run_directory)
try:
if requested.is_symlink() or not requested.is_dir():
_fail("missing_run", "指定的运行目录不存在或不是普通目录。", 400)
self.run_directory = requested.resolve(strict=True)
except OSError:
_fail("missing_run", "无法读取指定的运行目录。", 400)
manifest = _read_json(_run_path(self.run_directory, "manifest.json", "manifest.json"), "manifest.json")
if manifest.get("schema_version") != 1:
_fail("unsupported_schema", "只支持 manifest.json schema 1。", 409)
run = _object(manifest.get("run"), "manifest.run")
pipeline = _object(manifest.get("pipeline"), "manifest.pipeline")
summary = _object(manifest.get("summary"), "manifest.summary")
self.run_id = _string(run.get("run_id"), "run_id")
self.run_json: JsonObject = {
"run_id": self.run_id,
"run_date": _string(run.get("run_date"), "run_date"),
"status": _status(run.get("status"), "run status"),
"started_at_utc": _string(run.get("started_at_utc"), "started_at_utc"),
"completed_at_utc": _string(run.get("completed_at_utc"), "completed_at_utc"),
"retention_until": _string(run.get("retention_until"), "retention_until"),
}
self.components = tuple(
_component(value, position)
for position, value in enumerate(_array(pipeline.get("components"), "pipeline.components"))
)
manifest_documents = tuple(
_manifest_document(value, position)
for position, value in enumerate(_array(manifest.get("documents"), "manifest.documents"))
)
if len({item.document_id for item in manifest_documents}) != len(manifest_documents):
_fail("invalid_artifact", "manifest 中的 document_id 必须唯一。")
self.summary_json: JsonObject = {
"document_count": _integer(summary.get("document_count"), "summary.document_count"),
"success_count": _integer(summary.get("success_count"), "summary.success_count"),
"failed_count": _integer(summary.get("failed_count"), "summary.failed_count"),
"unstable_count": _integer(summary.get("unstable_count"), "summary.unstable_count"),
"change_count": _integer(summary.get("change_count"), "summary.change_count"),
}
expected_summary: JsonObject = {
"document_count": len(manifest_documents),
"success_count": sum(item.status == "success" for item in manifest_documents),
"failed_count": sum(item.status == "failed" for item in manifest_documents),
"unstable_count": sum(item.status == "unstable" for item in manifest_documents),
"change_count": sum(item.change_count for item in manifest_documents),
}
if self.summary_json != expected_summary:
_fail("invalid_artifact", "manifest 汇总与文档索引不一致。")
expected_status = (
"failed"
if expected_summary["failed_count"]
else "unstable"
if expected_summary["unstable_count"]
else "success"
)
if self.run_json["status"] != expected_status:
_fail("invalid_artifact", "manifest 运行状态与文档状态不一致。")
locator_path = self.run_directory / "review-locator.json"
locators: tuple[LocatorDocument, ...] | None = None
self.original_run_location_changed = False
if locator_path.exists():
locator = _read_json(
_run_path(self.run_directory, "review-locator.json", "review-locator.json"),
"review-locator.json",
)
if locator.get("schema_version") != 1:
_fail("unsupported_schema", "只支持 review-locator.json schema 1。", 409)
locator_run = _object(locator.get("run"), "review locator run")
if (
_string(locator_run.get("run_id"), "locator run_id") != self.run_id
or locator_run.get("manifest_path") != "manifest.json"
):
_fail("invalid_artifact", "review locator 与 manifest 运行身份不一致。")
recorded_directory_text = _string(locator_run.get("run_directory"), "locator run_directory")
recorded_directory = Path(recorded_directory_text)
if not recorded_directory.is_absolute() or str(recorded_directory) != str(recorded_directory.resolve()):
_fail("invalid_artifact", "locator run_directory 必须是绝对解析路径。")
self.original_run_location_changed = recorded_directory != self.run_directory
parsed_locators: list[LocatorDocument] = []
for position, value in enumerate(_array(locator.get("documents"), "locator documents")):
item = _object(value, f"locator.documents[{position}]")
source_text = _string(item.get("source_path"), "source_path")
source_path = Path(source_text)
if not source_path.is_absolute() or str(source_path) != str(source_path.resolve()):
_fail("invalid_artifact", "source_path 必须是绝对解析路径。")
parsed_locators.append(
LocatorDocument(
document_id=_string(item.get("document_id"), "locator document_id"),
source_path=source_path,
input_sha256=_hash(item.get("input_sha256"), "locator input_sha256"),
)
)
locators = tuple(parsed_locators)
if len(locators) != len(manifest_documents):
_fail("invalid_artifact", "review locator 文档数量与 manifest 不一致。")
for indexed, located in zip(manifest_documents, locators, strict=True):
if indexed.document_id != located.document_id or indexed.input_sha256 != located.input_sha256:
_fail("invalid_artifact", "review locator 文档身份与 manifest 不一致。")
records: list[DocumentRecord] = []
for position, manifest_document in enumerate(manifest_documents):
result = self._parse_result(manifest_document)
records.append(
DocumentRecord(
manifest=manifest_document,
result=result,
locator=None if locators is None else locators[position],
)
)
self.documents = tuple(records)
self.documents_by_id = {item.manifest.document_id: item for item in self.documents}
def _parse_result(self, manifest: ManifestDocument) -> ResultRecord:
payload = _read_json(_run_path(self.run_directory, manifest.result_path, "result.json"), "result.json")
if payload.get("schema_version") != 1:
_fail("unsupported_schema", "只支持 result.json schema 1。", 409)
document = _object(payload.get("document"), "result.document")
if (
_string(document.get("document_id"), "result document_id") != manifest.document_id
or _string(document.get("source_label"), "result source_label") != manifest.source_label
):
_fail("invalid_artifact", "result 与 manifest 文档身份不一致。")
status = _status(payload.get("status"), "result status")
input_hash = _hash(payload.get("input_sha256"), "result input_sha256")
current_hash = _hash(payload.get("current_sha256"), "result current_sha256")
changes = tuple(
_parse_change(value, position)
for position, value in enumerate(_array(payload.get("changes"), "result changes"))
)
errors = tuple(
_parse_error(value, position)
for position, value in enumerate(_array(payload.get("errors"), "result errors"))
)
residuals = tuple(
_parse_residual(value, position)
for position, value in enumerate(_array(payload.get("residual_proposals"), "result residual_proposals"))
)
output = _object(payload.get("output"), "result output")
cleaned = output.get("cleaned_path")
diff = output.get("diff_path")
expected_cleaned: object = "cleaned.md" if status == "success" else None
expected_diff: object = "changes.diff" if status == "success" else None
if cleaned != expected_cleaned or diff != expected_diff:
_fail("invalid_artifact", "result 状态与输出路径不一致。")
if (
status != manifest.status
or input_hash != manifest.input_sha256
or current_hash != manifest.current_sha256
or len(changes) != manifest.change_count
):
_fail("invalid_artifact", "result 与 manifest 文档索引不一致。")
if status == "success" and (errors or residuals):
_fail("invalid_artifact", "success 文档不能包含错误或残留候选。")
if status == "failed" and (not errors or residuals):
_fail("invalid_artifact", "failed 文档的错误或残留状态不合法。")
if status == "unstable" and (errors or not residuals):
_fail("invalid_artifact", "unstable 文档的错误或残留状态不合法。")
for change in changes:
position = change.replay.component_position
if position >= len(self.components):
_fail("invalid_artifact", "change 没有对应的流水线组件。")
component = self.components[position]
if (
change.replay.component_id != component.component_id
or change.replay.component_version != component.version
):
_fail("invalid_artifact", "change 身份与流水线组件不一致。")
for evidence in (*errors, *residuals):
position_value = evidence["component_position"]
component_id = evidence["component_id"]
component_version = evidence["component_version"]
if not isinstance(position_value, int) or position_value >= len(self.components):
_fail("invalid_artifact", "审计证据没有对应的流水线组件。")
component = self.components[position_value]
if component_id != component.component_id or component_version != component.version:
_fail("invalid_artifact", "审计证据身份与流水线组件不一致。")
if manifest.diff_path is not None:
_run_path(self.run_directory, manifest.diff_path, "changes.diff")
return ResultRecord(status, input_hash, current_hash, changes, errors, residuals)
def _source(self, record: DocumentRecord) -> tuple[str | None, str | None]:
if record.locator is None:
return None, "这次历史运行没有 review-locator.json,无法定位完整原文。"
try:
return (
_read_markdown(
record.locator.source_path,
record.manifest.input_sha256,
"原文",
"unavailable_source",
),
None,
)
except ReviewArtifactError as error:
return None, str(error)
def _cleaned(self, record: DocumentRecord) -> tuple[str | None, str | None]:
if record.manifest.status != "success" or record.manifest.cleaned_path is None:
return None, None
try:
path = _run_path(self.run_directory, record.manifest.cleaned_path, "cleaned.md")
return (
_read_markdown(path, record.manifest.current_sha256, "清洗结果", "missing_artifact"),
None,
)
except ReviewArtifactError as error:
return None, str(error)
def _replay(self, record: DocumentRecord, original: str, cleaned: str | None) -> ReplayResult:
try:
replayed = replay_change_chain(
input_markdown=original,
input_sha256=record.result.input_sha256,
components=tuple(ReplayComponent(item.component_id, item.version) for item in self.components),
changes=tuple(item.replay for item in record.result.changes),
current_sha256=record.result.current_sha256,
current_markdown=cleaned,
include_zero_change_stages=record.result.status == "success",
)
except ReplayError as error:
raise ReviewArtifactError("untrusted_replay", f"产物无法可信重放:{error}", 409) from error
for recorded, located in zip(record.result.changes, replayed.changes, strict=True):
if recorded.recorded_line != located.line or recorded.recorded_column != located.column:
_fail("untrusted_replay", "产物记录的位置与重放快照不一致。", 409)
return replayed
@staticmethod
def _summary_json(
record: DocumentRecord,
original: str | None,
source_error: str | None,
cleaned: str | None,
output_error: str | None,
) -> JsonObject:
return {
"document_id": record.manifest.document_id,
"source_label": record.manifest.source_label,
"status": record.manifest.status,
"input_sha256": record.manifest.input_sha256,
"current_sha256": record.manifest.current_sha256,
"change_count": record.manifest.change_count,
"source_available": original is not None,
"output_available": cleaned is not None,
"availability_error": source_error or output_error,
}
def _summary(self, record: DocumentRecord) -> JsonObject:
original, source_error = self._source(record)
cleaned, output_error = self._cleaned(record)
return self._summary_json(record, original, source_error, cleaned, output_error)
def _component_summaries(self, changes: tuple[ChangeRecord, ...]) -> list[JsonValue]:
counts = [0] * len(self.components)
for change in changes:
if 0 <= change.replay.component_position < len(counts):
counts[change.replay.component_position] += 1
return [_component_json(item, position, counts[position]) for position, item in enumerate(self.components)]
@staticmethod
def _change_json(recorded: ChangeRecord, located: LocatedChange | None) -> JsonObject:
change = recorded.replay
return {
"component_id": change.component_id,
"component_version": change.component_version,
"component_position": change.component_position,
"proposal_ref": {
"component_position": change.proposal_component_position,
"snapshot_sha256": change.proposal_snapshot_sha256,
"proposal_index": change.proposal_index,
},
"edit_index": change.edit_index,
"reason": recorded.reason,
"location": {
"line": recorded.recorded_line if located is None else located.line,
"column": recorded.recorded_column if located is None else located.column,
},
"editor_range": (
None if located is None else {"start": located.editor_start, "end": located.editor_end}
),
"before": change.before,
"after": change.after,
}
def run_summary(self) -> JsonObject:
all_changes = tuple(change for document in self.documents for change in document.result.changes)
return {
"schema_version": 1,
"run": self.run_json,
"components": self._component_summaries(all_changes),
"documents": [self._summary(item) for item in self.documents],
"summary": self.summary_json,
"original_run_location_changed": self.original_run_location_changed,
}
def _document(self, document_id: str) -> DocumentRecord:
record = self.documents_by_id.get(document_id)
if record is None:
_fail("unknown_document", "文档不存在。", 404)
return record
def document_comparison(self, document_id: str) -> JsonObject:
record = self._document(document_id)
original, source_error = self._source(record)
cleaned, output_error = self._cleaned(record)
replayed = None if original is None else self._replay(record, original, cleaned)
located = () if replayed is None else replayed.changes
return {
"schema_version": 1,
"document": self._summary_json(record, original, source_error, cleaned, output_error),
"components": self._component_summaries(record.result.changes),
"original_markdown": original,
"cleaned_markdown": cleaned,
"changes": [
self._change_json(change, located[position] if position < len(located) else None)
for position, change in enumerate(record.result.changes)
],
"errors": list(record.result.errors),
"residual_proposals": list(record.result.residual_proposals),
}
def component_stage(self, document_id: str, component_position: int) -> JsonObject:
record = self._document(document_id)
if record.manifest.status != "success":
_fail("stage_unavailable", "只有 success 文档具有完整组件阶段。", 409)
if component_position < 0 or component_position >= len(self.components):
_fail("unknown_component", "组件位置不存在。", 404)
original, source_error = self._source(record)
cleaned, output_error = self._cleaned(record)
if original is None or cleaned is None:
_fail("comparison_unavailable", source_error or output_error or "组件阶段不可用。", 409)
replayed = self._replay(record, original, cleaned)
stage = replayed.stages[component_position]
component = self.components[component_position]
recorded_changes = tuple(
item for item in record.result.changes if item.replay.component_position == component_position
)
return {
"schema_version": 1,
"document_id": record.manifest.document_id,
"component": _component_json(component, component_position, len(stage.changes)),
"before_sha256": stage.before_sha256,
"after_sha256": stage.after_sha256,
"before_markdown": stage.before_markdown,
"after_markdown": stage.after_markdown,
"changes": [
self._change_json(change, located)
for change, located in zip(recorded_changes, stage.changes, strict=True)
],
}
+378
View File
@@ -0,0 +1,378 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type {
ChangeDetail,
ComponentStageResponse,
DocumentComparisonResponse,
RunStatus,
RunSummaryResponse,
} from "../shared/api.js";
import { fetchComponentStage, fetchDocument, fetchRun } from "./api-client.js";
const DiffView = lazy(async () => {
const module = await import("./DiffView.js");
return { default: module.DiffView };
});
interface AsyncState<T> {
loading: boolean;
value: T | null;
error: string | null;
}
const emptyState = <T,>(): AsyncState<T> => ({ loading: true, value: null, error: null });
function statusLabel(status: RunStatus): string {
switch (status) {
case "success":
return "成功";
case "failed":
return "失败";
case "unstable":
return "不稳定";
}
}
function shortHash(hash: string): string {
return `${hash.slice(0, 8)}${hash.slice(-6)}`;
}
function ErrorPanel({ message }: { message: string }) {
return (
<div className="state-panel state-panel--error" role="alert">
<span className="eyebrow"></span>
<p>{message}</p>
</div>
);
}
function LoadingPanel() {
return (
<div className="state-panel" role="status">
<span className="loading-dot" />
<p></p>
</div>
);
}
function ChangeList({
changes,
onSelect,
canJump,
}: {
changes: ChangeDetail[];
onSelect: (change: ChangeDetail) => void;
canJump: boolean;
}) {
if (changes.length === 0) {
return <p className="quiet-message"></p>;
}
return (
<ol className="change-list">
{changes.map((change) => (
<li key={`${change.proposal_ref.snapshot_sha256}-${change.proposal_ref.proposal_index}-${change.edit_index}`}>
<button type="button" onClick={() => onSelect(change)} disabled={!canJump}>
<span className="change-location">
{change.location.line} {change.location.column} ·
{change.proposal_ref.proposal_index + 1} / {change.edit_index + 1}
</span>
<strong>{change.reason}</strong>
<span className="change-sample">
<del>{change.before || "∅"}</del>
<span aria-hidden="true"></span>
<ins>{change.after || "∅"}</ins>
</span>
</button>
</li>
))}
</ol>
);
}
export function App() {
const [runState, setRunState] = useState<AsyncState<RunSummaryResponse>>(emptyState);
const [selectedDocument, setSelectedDocument] = useState<string | null>(null);
const [documentState, setDocumentState] = useState<AsyncState<DocumentComparisonResponse>>({
loading: false,
value: null,
error: null,
});
const [selectedComponent, setSelectedComponent] = useState<number | null>(null);
const [stageState, setStageState] = useState<AsyncState<ComponentStageResponse>>({
loading: false,
value: null,
error: null,
});
const [focusRange, setFocusRange] = useState<{ start: number; end: number } | null>(null);
useEffect(() => {
const controller = new AbortController();
fetchRun(controller.signal)
.then((run) => {
setRunState({ loading: false, value: run, error: null });
setSelectedDocument(run.documents[0]?.document_id ?? null);
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setRunState({
loading: false,
value: null,
error: error instanceof Error ? error.message : "无法读取运行摘要。",
});
}
});
return () => controller.abort();
}, []);
useEffect(() => {
setSelectedComponent(null);
setFocusRange(null);
if (selectedDocument === null) {
setDocumentState({ loading: false, value: null, error: null });
return undefined;
}
const controller = new AbortController();
setDocumentState(emptyState());
fetchDocument(selectedDocument, controller.signal)
.then((document) => setDocumentState({ loading: false, value: document, error: null }))
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setDocumentState({
loading: false,
value: null,
error: error instanceof Error ? error.message : "无法读取文档。",
});
}
});
return () => controller.abort();
}, [selectedDocument]);
useEffect(() => {
if (selectedDocument === null || selectedComponent === null) {
setStageState({ loading: false, value: null, error: null });
return undefined;
}
const controller = new AbortController();
setStageState(emptyState());
fetchComponentStage(selectedDocument, selectedComponent, controller.signal)
.then((stage) => setStageState({ loading: false, value: stage, error: null }))
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setStageState({
loading: false,
value: null,
error: error instanceof Error ? error.message : "无法读取组件阶段。",
});
}
});
return () => controller.abort();
}, [selectedDocument, selectedComponent]);
const visibleChanges = useMemo(() => {
const document = documentState.value;
if (document === null) {
return [];
}
if (selectedComponent === null) {
return document.changes;
}
return document.changes.filter((change) => change.component_position === selectedComponent);
}, [documentState.value, selectedComponent]);
const selectChange = (change: ChangeDetail): void => {
setSelectedComponent(change.component_position);
setFocusRange(change.editor_range);
};
if (runState.loading) {
return <LoadingPanel />;
}
if (runState.error !== null || runState.value === null) {
return <ErrorPanel message={runState.error ?? "运行摘要为空。"} />;
}
const run = runState.value;
const document = documentState.value;
const selectedSummary = run.documents.find((item) => item.document_id === selectedDocument);
const selectedStage = stageState.value;
const canCompare =
document?.document.status === "success" &&
document.document.source_available &&
document.document.output_available &&
document.original_markdown !== null &&
document.cleaned_markdown !== null;
const beforeText = selectedStage?.before_markdown ?? document?.original_markdown ?? "";
const afterText = selectedStage?.after_markdown ?? document?.cleaned_markdown ?? "";
const beforeLabel = selectedStage === null ? "清洗前" : `组件 ${selectedStage.component.component_position + 1} 执行前`;
const afterLabel = selectedStage === null ? "清洗后" : `组件 ${selectedStage.component.component_position + 1} 执行后`;
return (
<div className="app-shell">
<header className="topbar">
<div>
<span className="brand-mark">md</span>
<div>
<p className="eyebrow"></p>
<h1>{run.run.run_id}</h1>
</div>
</div>
<div className="run-facts">
<span className={`status status--${run.run.status}`}>{statusLabel(run.run.status)}</span>
<span>{run.summary.document_count} </span>
<span>{run.summary.change_count} </span>
</div>
</header>
{run.original_run_location_changed ? (
<div className="notice" role="status">
使
</div>
) : null}
<div className="layout">
<aside className="sidebar" aria-label="运行导航">
<section>
<div className="section-heading">
<h2></h2>
<span>{run.documents.length}</span>
</div>
<nav className="document-list" aria-label="文档列表">
{run.documents.map((item) => (
<button
type="button"
key={item.document_id}
className={item.document_id === selectedDocument ? "is-active" : ""}
onClick={() => setSelectedDocument(item.document_id)}
aria-current={item.document_id === selectedDocument ? "page" : undefined}
>
<span className={`status-dot status-dot--${item.status}`} />
<span>
<strong>{item.source_label}</strong>
<small>{item.change_count} </small>
</span>
</button>
))}
</nav>
</section>
<section className="component-section">
<div className="section-heading">
<h2>线</h2>
<button
type="button"
className="text-button"
onClick={() => {
setSelectedComponent(null);
setFocusRange(null);
}}
disabled={selectedComponent === null}
>
</button>
</div>
<ol className="component-list">
{(document?.components ?? run.components).map((component) => (
<li key={component.component_id}>
<button
type="button"
className={component.component_position === selectedComponent ? "is-active" : ""}
onClick={() => {
setSelectedComponent(component.component_position);
setFocusRange(null);
}}
disabled={!canCompare}
>
<span className="component-index">{component.component_position + 1}</span>
<span>
<strong>{component.component_id}</strong>
<small>
v{component.version} · {component.change_count}
</small>
</span>
</button>
</li>
))}
</ol>
</section>
</aside>
<main className="workspace">
<section className="document-header">
<div>
<p className="eyebrow"></p>
<h2>{selectedSummary?.source_label ?? "未选择"}</h2>
</div>
{selectedSummary === undefined ? null : (
<div className="document-meta">
<span className={`status status--${selectedSummary.status}`}>
{statusLabel(selectedSummary.status)}
</span>
<span title={selectedSummary.input_sha256}> {shortHash(selectedSummary.input_sha256)}</span>
<span title={selectedSummary.current_sha256}> {shortHash(selectedSummary.current_sha256)}</span>
</div>
)}
</section>
{documentState.loading || stageState.loading ? <LoadingPanel /> : null}
{documentState.error !== null ? <ErrorPanel message={documentState.error} /> : null}
{stageState.error !== null ? <ErrorPanel message={stageState.error} /> : null}
{!documentState.loading && document !== null && document.document.status === "success" && !canCompare ? (
<ErrorPanel message={document.document.availability_error ?? "完整原文或清洗结果不可用。"} />
) : null}
{!documentState.loading && document !== null && document.document.status !== "success" ? (
<div className="diagnostic-panel">
<p className="eyebrow"></p>
<h3>{statusLabel(document.document.status)}</h3>
<p>
<code>cleaned.md</code>
</p>
{document.errors.map((error) => (
<article key={`${error.component_position}-${error.stage}-${error.error_type}`}>
<strong>{error.error_type}</strong>
<span>{error.message}</span>
</article>
))}
{document.residual_proposals.map((proposal) => (
<article key={`${proposal.component_position}-${proposal.reason}`}>
<strong> {proposal.edit_count} </strong>
<span>{proposal.reason}</span>
</article>
))}
</div>
) : null}
{!documentState.loading && !stageState.loading && canCompare && stageState.error === null ? (
<Suspense fallback={<LoadingPanel />}>
<DiffView
before={beforeText}
after={afterText}
beforeLabel={beforeLabel}
afterLabel={afterLabel}
focusRange={focusRange}
/>
</Suspense>
) : null}
{document !== null ? (
<section className="changes-panel" aria-label="修改详情">
<div className="changes-heading">
<div>
<p className="eyebrow"></p>
<h3>
{selectedComponent === null
? `全部组件 · ${visibleChanges.length}`
: `${document.components[selectedComponent]?.component_id ?? "组件"} · ${visibleChanges.length}`}
</h3>
</div>
{selectedStage === null ? null : <p>{selectedStage.component.applicability}</p>}
</div>
<ChangeList changes={visibleChanges} onSelect={selectChange} canJump={canCompare} />
</section>
) : null}
</main>
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { markdown } from "@codemirror/lang-markdown";
import { MergeView } from "@codemirror/merge";
import { EditorState } from "@codemirror/state";
import { EditorView, lineNumbers } from "@codemirror/view";
import { useEffect, useRef } from "react";
interface DiffViewProps {
before: string;
after: string;
beforeLabel: string;
afterLabel: string;
focusRange?: { start: number; end: number } | null;
}
const editorTheme = EditorView.theme({
"&": {
height: "100%",
backgroundColor: "#fbfaf7",
color: "#262822",
fontSize: "13px",
},
".cm-scroller": {
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
lineHeight: "1.68",
},
".cm-gutters": {
backgroundColor: "#f2f0ea",
color: "#8a877e",
border: "none",
},
".cm-content": {
padding: "18px 0 36px",
},
".cm-line": {
padding: "0 14px",
},
"&.cm-focused": {
outline: "2px solid #a7b9ac",
outlineOffset: "-2px",
},
});
const readOnlyExtensions = [
lineNumbers(),
markdown(),
EditorState.readOnly.of(true),
EditorView.editable.of(false),
EditorView.lineWrapping,
editorTheme,
];
export function DiffView({ before, after, beforeLabel, afterLabel, focusRange }: DiffViewProps) {
const host = useRef<HTMLDivElement>(null);
const merge = useRef<MergeView | null>(null);
useEffect(() => {
if (host.current === null) {
return undefined;
}
const view = new MergeView({
parent: host.current,
a: { doc: before, extensions: readOnlyExtensions },
b: { doc: after, extensions: readOnlyExtensions },
orientation: "a-b",
gutter: true,
highlightChanges: true,
collapseUnchanged: { margin: 4, minSize: 8 },
});
merge.current = view;
return () => {
view.destroy();
merge.current = null;
};
}, [before, after]);
useEffect(() => {
const view = merge.current;
if (view === null || focusRange === null || focusRange === undefined) {
return;
}
const anchor = Math.min(Math.max(focusRange.start, 0), view.a.state.doc.length);
const head = Math.min(Math.max(focusRange.end, anchor), view.a.state.doc.length);
view.a.dispatch({
selection: { anchor, head },
effects: EditorView.scrollIntoView(anchor, { y: "center" }),
});
view.a.focus();
}, [focusRange]);
return (
<section className="diff-shell" aria-label={`${beforeLabel}${afterLabel}对比`}>
<div className="diff-labels" aria-hidden="true">
<span>{beforeLabel}</span>
<span>{afterLabel}</span>
</div>
<div className="diff-host" ref={host} />
</section>
);
}
+288
View File
@@ -0,0 +1,288 @@
import type {
ApiErrorResponse,
ComponentStageResponse,
DocumentComparisonResponse,
RunSummaryResponse,
} from "../shared/api.js";
export class ReviewerApiError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "ReviewerApiError";
this.code = code;
}
}
type JsonRecord = Record<string, unknown>;
function invalid(label: string): never {
throw new ReviewerApiError("invalid_response", `本地服务返回的 ${label} 格式不正确。`);
}
function record(value: unknown, label: string): JsonRecord {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return invalid(label);
}
return value as JsonRecord;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
return invalid(label);
}
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string") {
return invalid(label);
}
return value;
}
function integer(value: unknown, label: string, minimum = 0): number {
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
return invalid(label);
}
return value as number;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
return invalid(label);
}
return value;
}
function nullableString(value: unknown, label: string): string | null {
return value === null ? null : string(value, label);
}
function hash(value: unknown, label: string): string {
const digest = string(value, label);
if (!/^[0-9a-f]{64}$/.test(digest)) {
return invalid(label);
}
return digest;
}
function status(value: unknown): "success" | "failed" | "unstable" {
if (value !== "success" && value !== "failed" && value !== "unstable") {
return invalid("status");
}
return value;
}
function component(value: unknown): RunSummaryResponse["components"][number] {
const item = record(value, "component");
return {
component_position: integer(item.component_position, "component_position"),
component_id: string(item.component_id, "component_id"),
version: string(item.version, "component version"),
parameters: item.parameters,
applicability: string(item.applicability, "component applicability"),
change_count: integer(item.change_count, "component change_count"),
};
}
function documentSummary(value: unknown): RunSummaryResponse["documents"][number] {
const item = record(value, "document summary");
return {
document_id: string(item.document_id, "document_id"),
source_label: string(item.source_label, "source_label"),
status: status(item.status),
input_sha256: hash(item.input_sha256, "input_sha256"),
current_sha256: hash(item.current_sha256, "current_sha256"),
change_count: integer(item.change_count, "document change_count"),
source_available: boolean(item.source_available, "source_available"),
output_available: boolean(item.output_available, "output_available"),
availability_error: nullableString(item.availability_error, "availability_error"),
};
}
function summary(value: unknown): RunSummaryResponse["summary"] {
const item = record(value, "summary");
return {
document_count: integer(item.document_count, "document_count"),
success_count: integer(item.success_count, "success_count"),
failed_count: integer(item.failed_count, "failed_count"),
unstable_count: integer(item.unstable_count, "unstable_count"),
change_count: integer(item.change_count, "change_count"),
};
}
function change(value: unknown): DocumentComparisonResponse["changes"][number] {
const item = record(value, "change");
const proposal = record(item.proposal_ref, "proposal_ref");
const location = record(item.location, "location");
const editorValue = item.editor_range;
const editorRange =
editorValue === null
? null
: (() => {
const editor = record(editorValue, "editor_range");
const start = integer(editor.start, "editor_range.start");
const end = integer(editor.end, "editor_range.end");
if (end < start) {
return invalid("editor_range");
}
return { start, end };
})();
return {
component_id: string(item.component_id, "change component_id"),
component_version: string(item.component_version, "change component_version"),
component_position: integer(item.component_position, "change component_position"),
proposal_ref: {
component_position: integer(proposal.component_position, "proposal component_position"),
snapshot_sha256: hash(proposal.snapshot_sha256, "proposal snapshot_sha256"),
proposal_index: integer(proposal.proposal_index, "proposal_index"),
},
edit_index: integer(item.edit_index, "edit_index"),
reason: string(item.reason, "reason"),
location: {
line: integer(location.line, "location.line", 1),
column: integer(location.column, "location.column", 1),
},
editor_range: editorRange,
before: string(item.before, "before"),
after: string(item.after, "after"),
};
}
function runError(value: unknown): DocumentComparisonResponse["errors"][number] {
const item = record(value, "run error");
const stage = item.stage;
if (stage !== "transform" && stage !== "final_review") {
return invalid("error stage");
}
return {
component_id: string(item.component_id, "error component_id"),
component_version: string(item.component_version, "error component_version"),
component_position: integer(item.component_position, "error component_position"),
stage,
error_type: string(item.error_type, "error_type"),
message: string(item.message, "error message"),
};
}
function residual(value: unknown): DocumentComparisonResponse["residual_proposals"][number] {
const item = record(value, "residual proposal");
return {
component_id: string(item.component_id, "residual component_id"),
component_version: string(item.component_version, "residual component_version"),
component_position: integer(item.component_position, "residual component_position"),
reason: string(item.reason, "residual reason"),
edit_count: integer(item.edit_count, "residual edit_count", 1),
};
}
function parseRun(value: unknown): RunSummaryResponse {
const payload = record(value, "run response");
if (payload.schema_version !== 1) {
return invalid("run schema_version");
}
const run = record(payload.run, "run");
return {
schema_version: 1,
run: {
run_id: string(run.run_id, "run_id"),
run_date: string(run.run_date, "run_date"),
status: status(run.status),
started_at_utc: string(run.started_at_utc, "started_at_utc"),
completed_at_utc: string(run.completed_at_utc, "completed_at_utc"),
retention_until: string(run.retention_until, "retention_until"),
},
components: array(payload.components, "components").map(component),
documents: array(payload.documents, "documents").map(documentSummary),
summary: summary(payload.summary),
original_run_location_changed: boolean(
payload.original_run_location_changed,
"original_run_location_changed",
),
};
}
function parseDocument(value: unknown): DocumentComparisonResponse {
const payload = record(value, "document response");
if (payload.schema_version !== 1) {
return invalid("document schema_version");
}
return {
schema_version: 1,
document: documentSummary(payload.document),
components: array(payload.components, "components").map(component),
original_markdown: nullableString(payload.original_markdown, "original_markdown"),
cleaned_markdown: nullableString(payload.cleaned_markdown, "cleaned_markdown"),
changes: array(payload.changes, "changes").map(change),
errors: array(payload.errors, "errors").map(runError),
residual_proposals: array(payload.residual_proposals, "residual_proposals").map(residual),
};
}
function parseStage(value: unknown): ComponentStageResponse {
const payload = record(value, "component stage response");
if (payload.schema_version !== 1) {
return invalid("component stage schema_version");
}
return {
schema_version: 1,
document_id: string(payload.document_id, "document_id"),
component: component(payload.component),
before_sha256: hash(payload.before_sha256, "before_sha256"),
after_sha256: hash(payload.after_sha256, "after_sha256"),
before_markdown: string(payload.before_markdown, "before_markdown"),
after_markdown: string(payload.after_markdown, "after_markdown"),
changes: array(payload.changes, "changes").map(change),
};
}
async function getJson<T>(
pathname: string,
parse: (payload: unknown) => T,
signal?: AbortSignal,
): Promise<T> {
const response = await fetch(pathname, {
method: "GET",
cache: "no-store",
credentials: "same-origin",
signal,
});
const payload: unknown = await response.json();
if (!response.ok) {
const errorPayload = payload as Partial<ApiErrorResponse>;
throw new ReviewerApiError(
errorPayload.error?.code ?? "request_failed",
errorPayload.error?.message ?? `请求失败(HTTP ${response.status})。`,
);
}
return parse(payload);
}
export function fetchRun(signal?: AbortSignal): Promise<RunSummaryResponse> {
return getJson("/api/v1/run", parseRun, signal);
}
export function fetchDocument(
documentId: string,
signal?: AbortSignal,
): Promise<DocumentComparisonResponse> {
return getJson(
`/api/v1/documents/${encodeURIComponent(documentId)}`,
parseDocument,
signal,
);
}
export function fetchComponentStage(
documentId: string,
componentPosition: number,
signal?: AbortSignal,
): Promise<ComponentStageResponse> {
return getJson(
`/api/v1/documents/${encodeURIComponent(documentId)}/components/${componentPosition}`,
parseStage,
signal,
);
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
import "./styles.css";
const root = document.getElementById("root");
if (root === null) {
throw new Error("missing #root element");
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
+554
View File
@@ -0,0 +1,554 @@
:root {
color: #252720;
background: #ecebe5;
font-family:
Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-width: 1180px;
min-height: 100%;
margin: 0;
}
button {
color: inherit;
font: inherit;
}
button:focus-visible {
outline: 2px solid #315f4b;
outline-offset: 2px;
}
.app-shell {
min-height: 100vh;
background:
radial-gradient(circle at 12% 0%, rgb(255 255 255 / 72%), transparent 34%),
#ecebe5;
}
.topbar {
position: sticky;
z-index: 20;
top: 0;
display: flex;
min-height: 76px;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
border-bottom: 1px solid #d7d5cd;
background: rgb(248 247 242 / 94%);
backdrop-filter: blur(18px);
}
.topbar > div:first-child {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: 12px;
background: #284d3d;
color: #f3f4ed;
font-family: Georgia, serif;
font-size: 19px;
letter-spacing: -0.08em;
}
.eyebrow {
margin: 0 0 3px;
color: #78796f;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
.topbar h1 {
margin: 0;
font-family: Georgia, "Songti SC", serif;
font-size: 19px;
font-weight: 600;
}
.run-facts,
.document-meta {
display: flex;
align-items: center;
gap: 10px;
color: #66685f;
font-size: 12px;
}
.run-facts > span:not(.status),
.document-meta > span:not(.status) {
padding-left: 10px;
border-left: 1px solid #d5d2c9;
}
.status {
display: inline-flex;
align-items: center;
border: 1px solid currentColor;
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
font-weight: 700;
}
.status--success {
color: #277052;
background: #edf6ef;
}
.status--failed {
color: #a04338;
background: #fff0ed;
}
.status--unstable {
color: #986617;
background: #fff7df;
}
.notice {
padding: 9px 24px;
border-bottom: 1px solid #e6d09c;
background: #fff7de;
color: #77561d;
font-size: 12px;
}
.layout {
display: grid;
min-height: calc(100vh - 76px);
grid-template-columns: 300px minmax(0, 1fr);
}
.sidebar {
position: sticky;
top: 76px;
overflow-y: auto;
height: calc(100vh - 76px);
border-right: 1px solid #d7d5cd;
background: #f7f6f1;
}
.sidebar section {
padding: 20px 16px;
}
.sidebar section + section {
border-top: 1px solid #dfddd5;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.section-heading h2 {
margin: 0;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.section-heading > span {
color: #888980;
font-size: 11px;
}
.document-list,
.component-list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.document-list button,
.component-list button {
display: grid;
width: 100%;
align-items: center;
border: 0;
border-radius: 9px;
background: transparent;
cursor: pointer;
text-align: left;
}
.document-list button {
grid-template-columns: 9px 1fr;
gap: 10px;
padding: 9px 10px;
}
.document-list button:hover,
.component-list button:hover:not(:disabled) {
background: #eceae2;
}
.document-list button.is-active,
.component-list button.is-active {
background: #e0e8e0;
color: #234b39;
}
.document-list strong,
.component-list strong {
display: block;
overflow: hidden;
font-size: 12px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-list small,
.component-list small {
display: block;
margin-top: 3px;
color: #7d7e75;
font-size: 10px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #999;
}
.status-dot--success {
background: #348361;
}
.status-dot--failed {
background: #b64b3f;
}
.status-dot--unstable {
background: #bd831c;
}
.text-button {
border: 0;
background: transparent;
color: #315f4b;
cursor: pointer;
font-size: 11px;
}
.text-button:disabled {
color: #aaa99f;
cursor: default;
}
.component-list {
counter-reset: components;
}
.component-list button {
grid-template-columns: 26px minmax(0, 1fr);
gap: 8px;
padding: 8px;
}
.component-list button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.component-index {
display: grid;
width: 24px;
height: 24px;
place-items: center;
border: 1px solid #d3d1c8;
border-radius: 50%;
color: #74766e;
font-family: Georgia, serif;
font-size: 11px;
}
.workspace {
display: grid;
min-width: 0;
align-content: start;
gap: 14px;
padding: 18px 20px 30px;
}
.document-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.document-header h2 {
margin: 0;
font-family: Georgia, "Songti SC", serif;
font-size: 21px;
font-weight: 600;
}
.diff-shell,
.changes-panel,
.diagnostic-panel,
.state-panel {
overflow: hidden;
border: 1px solid #d5d3ca;
border-radius: 13px;
background: #fbfaf7;
box-shadow: 0 12px 36px rgb(55 57 48 / 7%);
}
.diff-shell {
min-height: 510px;
}
.diff-labels {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 1px solid #dcdbd3;
background: #f4f2ec;
color: #6f7168;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.diff-labels span {
padding: 9px 14px;
}
.diff-labels span + span {
border-left: 1px solid #dcdbd3;
}
.diff-host,
.diff-host > .cm-mergeView {
height: 510px;
}
.diff-host .cm-mergeViewEditors {
height: 100%;
}
.diff-host .cm-editor {
min-width: 0;
height: 100%;
}
.changes-panel {
min-height: 120px;
}
.changes-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 15px 18px;
border-bottom: 1px solid #e0ded6;
}
.changes-heading h3 {
margin: 0;
font-size: 14px;
}
.changes-heading > p {
max-width: 58%;
margin: 0;
color: #77786f;
font-size: 11px;
line-height: 1.5;
}
.change-list {
display: grid;
max-height: 310px;
gap: 1px;
overflow-y: auto;
margin: 0;
padding: 0;
background: #e4e2da;
list-style: none;
}
.change-list button {
display: grid;
width: 100%;
grid-template-columns: 145px minmax(240px, 1fr) minmax(260px, 0.9fr);
align-items: center;
gap: 16px;
border: 0;
padding: 11px 18px;
background: #fbfaf7;
cursor: pointer;
text-align: left;
}
.change-list button:hover {
background: #f4f5ef;
}
.change-list button:disabled {
cursor: default;
}
.change-location {
color: #73756c;
font-size: 11px;
}
.change-list strong {
font-size: 12px;
font-weight: 600;
}
.change-sample {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 11px;
}
.change-sample del,
.change-sample ins {
overflow: hidden;
max-width: 46%;
border-radius: 4px;
padding: 2px 5px;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.change-sample del {
background: #f9ded9;
color: #913e34;
}
.change-sample ins {
background: #dcecdf;
color: #276348;
}
.quiet-message {
margin: 0;
padding: 22px 18px;
color: #77786f;
font-size: 12px;
}
.state-panel,
.diagnostic-panel {
padding: 28px;
}
.state-panel {
display: grid;
min-height: 180px;
place-items: center;
align-content: center;
color: #66685f;
}
.state-panel p {
margin: 8px 0 0;
}
.state-panel--error {
border-color: #e2b6ae;
color: #8f3e34;
}
.loading-dot {
width: 11px;
height: 11px;
border-radius: 50%;
background: #3d735b;
box-shadow: 0 0 0 7px #dce9df;
animation: pulse 1.25s ease-in-out infinite;
}
.diagnostic-panel h3 {
margin-bottom: 8px;
}
.diagnostic-panel > p:not(.eyebrow) {
color: #686a61;
font-size: 13px;
}
.diagnostic-panel article {
display: grid;
grid-template-columns: 220px 1fr;
gap: 12px;
padding: 10px 0;
border-top: 1px solid #e0ded6;
font-size: 12px;
}
code {
border-radius: 4px;
padding: 1px 4px;
background: #eceae3;
font-family: "SFMono-Regular", Consolas, monospace;
}
@keyframes pulse {
0%,
100% {
opacity: 0.45;
transform: scale(0.82);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 1280px) {
.layout {
grid-template-columns: 270px minmax(0, 1fr);
}
.change-list button {
grid-template-columns: 125px minmax(180px, 1fr) minmax(220px, 0.8fr);
}
}
+113
View File
@@ -0,0 +1,113 @@
export type RunStatus = "success" | "failed" | "unstable";
export interface ComponentSummary {
component_position: number;
component_id: string;
version: string;
parameters: unknown;
applicability: string;
change_count: number;
}
export interface DocumentSummary {
document_id: string;
source_label: string;
status: RunStatus;
input_sha256: string;
current_sha256: string;
change_count: number;
source_available: boolean;
output_available: boolean;
availability_error: string | null;
}
export interface RunSummaryResponse {
schema_version: 1;
run: {
run_id: string;
run_date: string;
status: RunStatus;
started_at_utc: string;
completed_at_utc: string;
retention_until: string;
};
components: ComponentSummary[];
documents: DocumentSummary[];
summary: {
document_count: number;
success_count: number;
failed_count: number;
unstable_count: number;
change_count: number;
};
original_run_location_changed: boolean;
}
export interface ChangeDetail {
component_id: string;
component_version: string;
component_position: number;
proposal_ref: {
component_position: number;
snapshot_sha256: string;
proposal_index: number;
};
edit_index: number;
reason: string;
location: {
line: number;
column: number;
};
editor_range: {
start: number;
end: number;
} | null;
before: string;
after: string;
}
export interface RunErrorDetail {
component_id: string;
component_version: string;
component_position: number;
stage: "transform" | "final_review";
error_type: string;
message: string;
}
export interface ResidualProposalDetail {
component_id: string;
component_version: string;
component_position: number;
reason: string;
edit_count: number;
}
export interface DocumentComparisonResponse {
schema_version: 1;
document: DocumentSummary;
components: ComponentSummary[];
original_markdown: string | null;
cleaned_markdown: string | null;
changes: ChangeDetail[];
errors: RunErrorDetail[];
residual_proposals: ResidualProposalDetail[];
}
export interface ComponentStageResponse {
schema_version: 1;
document_id: string;
component: ComponentSummary;
before_sha256: string;
after_sha256: string;
before_markdown: string;
after_markdown: string;
changes: ChangeDetail[];
}
export interface ApiErrorResponse {
error: {
code: string;
message: string;
};
}
+256
View File
@@ -0,0 +1,256 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "../src/client/App.js";
import type {
ComponentStageResponse,
DocumentComparisonResponse,
RunSummaryResponse,
} from "../src/shared/api.js";
vi.mock("../src/client/DiffView.js", () => ({
DiffView: ({ beforeLabel, afterLabel }: { beforeLabel: string; afterLabel: string }) => (
<div data-testid="diff-view">
{beforeLabel} / {afterLabel}
</div>
),
}));
const components = [
{
component_position: 0,
component_id: "paper.rule",
version: "1.0.0",
parameters: [],
applicability: "替换测试单词。",
change_count: 1,
},
{
component_position: 1,
component_id: "paper.zero",
version: "1.0.0",
parameters: [],
applicability: "不修改当前测试文档。",
change_count: 0,
},
];
const documentSummary = {
document_id: "paper",
source_label: "inputs/paper.md",
status: "success" as const,
input_sha256: "1".repeat(64),
current_sha256: "2".repeat(64),
change_count: 1,
source_available: true,
output_available: true,
availability_error: null,
};
const runResponse: RunSummaryResponse = {
schema_version: 1,
run: {
run_id: "review-run",
run_date: "2026-08-23",
status: "success",
started_at_utc: "2026-08-23T01:00:00Z",
completed_at_utc: "2026-08-23T01:01:00Z",
retention_until: "2026-09-22T01:01:00Z",
},
components,
documents: [documentSummary],
summary: {
document_count: 1,
success_count: 1,
failed_count: 0,
unstable_count: 0,
change_count: 1,
},
original_run_location_changed: false,
};
const change = {
component_id: "paper.rule",
component_version: "1.0.0",
component_position: 0,
proposal_ref: {
component_position: 0,
snapshot_sha256: "1".repeat(64),
proposal_index: 0,
},
edit_index: 0,
reason: "替换测试单词",
location: { line: 1, column: 3 },
editor_range: { start: 0, end: 3 },
before: "old",
after: "new",
};
const documentResponse: DocumentComparisonResponse = {
schema_version: 1,
document: documentSummary,
components,
original_markdown: "old",
cleaned_markdown: "new",
changes: [change],
errors: [],
residual_proposals: [],
};
const stageResponse: ComponentStageResponse = {
schema_version: 1,
document_id: "paper",
component: components[0]!,
before_sha256: "1".repeat(64),
after_sha256: "2".repeat(64),
before_markdown: "old",
after_markdown: "new",
changes: [change],
};
function response(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("App", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("shows the run, document comparison, component order and component stage", async () => {
const fetchMock = vi.fn((input: string | URL | Request) => {
const pathname =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (pathname === "/api/v1/run") {
return Promise.resolve(response(runResponse));
}
if (pathname.endsWith("/components/0")) {
return Promise.resolve(response(stageResponse));
}
return Promise.resolve(response(documentResponse));
});
vi.stubGlobal("fetch", fetchMock);
render(<App />);
expect(await screen.findByRole("heading", { name: "review-run" })).toBeInTheDocument();
expect(await screen.findByTestId("diff-view")).toHaveTextContent("清洗前 / 清洗后");
expect(screen.getByText("paper.rule")).toBeInTheDocument();
expect(screen.getByText("paper.zero")).toBeInTheDocument();
expect(screen.getByText("替换测试单词")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /paper\.rule/ }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("components/0"), expect.anything());
});
expect(await screen.findByTestId("diff-view")).toHaveTextContent("组件 1 执行前 / 组件 1 执行后");
});
it("shows API failures without rendering document text", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(
response({ error: { code: "unsupported_schema", message: "不支持这个产物版本。" } }, 409),
),
),
);
render(<App />);
expect(await screen.findByRole("alert")).toHaveTextContent("不支持这个产物版本");
expect(screen.queryByTestId("diff-view")).not.toBeInTheDocument();
});
it("shows failed audit evidence without inventing a cleaned result", async () => {
const failedSummary = {
...documentSummary,
status: "failed" as const,
current_sha256: documentSummary.input_sha256,
change_count: 0,
output_available: false,
};
const failedRun: RunSummaryResponse = {
...runResponse,
run: { ...runResponse.run, status: "failed" },
documents: [failedSummary],
summary: {
document_count: 1,
success_count: 0,
failed_count: 1,
unstable_count: 0,
change_count: 0,
},
};
const failedDocument: DocumentComparisonResponse = {
schema_version: 1,
document: failedSummary,
components: components.map((component) => ({ ...component, change_count: 0 })),
original_markdown: "原文",
cleaned_markdown: null,
changes: [],
errors: [
{
component_id: "paper.rule",
component_version: "1.0.0",
component_position: 0,
stage: "transform",
error_type: "SyntheticError",
message: "测试组件失败。",
},
],
residual_proposals: [],
};
vi.stubGlobal(
"fetch",
vi.fn((input: string | URL | Request) => {
const pathname =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return Promise.resolve(response(pathname === "/api/v1/run" ? failedRun : failedDocument));
}),
);
render(<App />);
expect(await screen.findByRole("heading", { name: "失败文档只展示审计证据" })).toBeInTheDocument();
expect(screen.getByText("测试组件失败。")).toBeInTheDocument();
expect(screen.queryByTestId("diff-view")).not.toBeInTheDocument();
});
it("keeps audit details visible when a successful run has lost its original source", async () => {
const unavailableSummary = {
...documentSummary,
source_available: false,
availability_error: "原文路径已经失效。",
};
const unavailableRun: RunSummaryResponse = {
...runResponse,
documents: [unavailableSummary],
};
const unavailableDocument: DocumentComparisonResponse = {
...documentResponse,
document: unavailableSummary,
original_markdown: null,
changes: [{ ...change, editor_range: null }],
};
vi.stubGlobal(
"fetch",
vi.fn((input: string | URL | Request) => {
const pathname =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return Promise.resolve(
response(pathname === "/api/v1/run" ? unavailableRun : unavailableDocument),
);
}),
);
render(<App />);
expect(await screen.findByRole("alert")).toHaveTextContent("原文路径已经失效");
expect(screen.getByText("替换测试单词")).toBeInTheDocument();
expect(screen.queryByTestId("diff-view")).not.toBeInTheDocument();
});
});
+21
View File
@@ -0,0 +1,21 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DiffView } from "../src/client/DiffView.js";
describe("DiffView", () => {
it("keeps Markdown and raw HTML as inert editor text", () => {
render(
<DiffView
before={'# title\n<img src="https://example.com/private.png" onerror="alert(1)">'}
after={'# title\n<script>alert("x")</script>'}
beforeLabel="清洗前"
afterLabel="清洗后"
/>,
);
expect(screen.getByRole("region", { name: "清洗前与清洗后对比" })).toBeInTheDocument();
expect(document.querySelector("img")).toBeNull();
expect(document.querySelector("script")).toBeNull();
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from "vitest";
import { fetchRun } from "../src/client/api-client.js";
describe("API response validation", () => {
it("rejects a successful HTTP response with an unknown schema", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(
new Response(JSON.stringify({ schema_version: 2 }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
),
);
await expect(fetchRun()).rejects.toMatchObject({
code: "invalid_response",
});
});
});
+6
View File
@@ -0,0 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => cleanup());
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["node", "vite/client", "vitest/globals", "@testing-library/jest-dom/vitest"]
},
"include": ["src", "tests", "vite.config.ts"]
}
+29
View File
@@ -0,0 +1,29 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
server: {
host: "127.0.0.1",
port: 5173,
strictPort: true,
proxy: {
"/api": {
target: "http://127.0.0.1:4174",
changeOrigin: true,
configure(proxy) {
proxy.on("proxyReq", (request) => request.removeHeader("origin"));
},
},
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
test: {
environment: "jsdom",
setupFiles: "./tests/setup.ts",
css: true,
},
});