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
+93 -35
View File
@@ -3,10 +3,9 @@
`mdpolish` 是实验室共用的、项目无关的 Python Markdown 修改库。它提供函数式 `Modifier`、精确文本编辑执行器、
有序 `Pipeline`、正则修改器工厂,以及少量可以用合成样例完整说明的通用修改器。
当前发布版本是 [`v0.6.0`](https://github.com/Bepr4/mdpolish/releases/tag/v0.6.0),当前工作树候选版本是尚未发布的
`0.6.1`。库只处理内存中的 Markdown 字符串,
不读取或写入文件,不提供默认流水线,也不包含任何项目的规则集合、
数据清单、实验脚本或评审界面。
当前发布版本是 [`v0.7.0`](https://github.com/Bepr4/mdpolish/releases/tag/v0.7.0)。清洗核心只处理内存中的 Markdown
字符串,不读取或写入文件,不提供默认流水线,也不包含任何项目的规则集合、数据清单或实验脚本。安装包另外提供一个
必须显式启动的本地只读评审器,用来展示项目已经保存的正式 `full` review JSON;它不替项目运行清洗或管理产物。
## 当前能力
@@ -19,6 +18,8 @@
| `render_markdown_report()` | 把评审视图编排成完整 Markdown 源码报告字符串 | 只返回内存字符串,不创建文件或业务页面 |
| `review_document_to_dict()` | 按 schema `1.0` 把评审视图投影成普通 JSON 基本值 | 单向投影,不反序列化或重新应用修改 |
| `render_json_report()` | 复用正式 dict 投影生成确定的内存 JSON 字符串 | 不创建文件;默认摘要不等于公开安全日志 |
| `parse_json_report()` | 解析并校验正式 review JSON`full` 会验证哈希、阶段链和 Change 重放 | 不恢复 `ReviewDocument`,不接收路径 |
| `mdpolish-reviewer` | 在回环地址展示一个明确目录中的 `full` JSON | 只读,不运行 Pipeline,不提供业务审核流程 |
| `mdpolish.text_ranges` | 返回 CR/LF/CRLF 物理行的精确不可变原文范围 | 不解析 Markdown 块,不自动执行或修改文本 |
| `regex_replace()` | 把非空正则匹配转换为精确编辑 | 不提供规则注册表、配置加载或默认模式 |
| `mapped_line_join()` | 用精确、正则或可选本地词典规则合并跨行片段 | 无默认规则;代码、表格、未知结构和歧义失败关闭 |
@@ -38,15 +39,15 @@
不可移动的 tag
```bash
python -m pip install 'mdpolish @ git+https://github.com/Bepr4/mdpolish.git@v0.6.0'
python -m pip install 'mdpolish[lexical] @ git+https://github.com/Bepr4/mdpolish.git@v0.6.0'
python -m pip install 'mdpolish[frequency] @ git+https://github.com/Bepr4/mdpolish.git@v0.6.0'
python -m pip install 'mdpolish @ git+https://github.com/Bepr4/mdpolish.git@v0.7.0'
python -m pip install 'mdpolish[lexical] @ git+https://github.com/Bepr4/mdpolish.git@v0.7.0'
python -m pip install 'mdpolish[frequency] @ git+https://github.com/Bepr4/mdpolish.git@v0.7.0'
```
也可以安装同一 GitHub Release 附带的 wheel
```bash
python -m pip install 'mdpolish[lexical] @ https://github.com/Bepr4/mdpolish/releases/download/v0.6.0/mdpolish-0.6.0-py3-none-any.whl'
python -m pip install 'mdpolish[lexical] @ https://github.com/Bepr4/mdpolish/releases/download/v0.7.0/mdpolish-0.7.0-py3-none-any.whl'
```
Release 页面同时提供 wheel 的 SHA-256 校验值。仓库或 Release 如果是私有的,调用方需要自行配置 GitHub 访问权限;
@@ -57,7 +58,8 @@ python -m venv .venv
.venv/bin/python -m pip install -e '.[dev]'
```
核心运行时只依赖 Python 标准库,支持 Python 3.11 及以上版本。自动词典规则需要调用方明确安装并选择对应 extra:
核心和本地评审服务的 Python 运行时只依赖标准库,支持 Python 3.11 及以上版本。React 和 CodeMirror 已编译为 wheel
内的静态资源,使用评审器不需要 Node.js。自动词典规则需要调用方明确安装并选择对应 extra:
```bash
python -m pip install '/path/to/mdpolish[lexical]' # pyspellchecker + Pyphen
@@ -163,8 +165,9 @@ if result.status is RunStatus.SUCCESS and result.output_markdown is not None:
output_path.write_text(result.output_markdown, encoding="utf-8")
```
文件读取、输出命名、覆盖策略批处理和 CLI 都属于调用项目。`mdpolish` 可以生成通用的内存评审视图、机器投影、JSON
字符串与 Markdown 报告字符串,但不会自动保存它们,也不知道报告来自哪个文件。
清洗输入发现、输出命名、覆盖策略批处理属于调用项目。`mdpolish` 可以生成通用的内存评审视图、机器投影、JSON
字符串与 Markdown 报告字符串,但不会自动保存它们,也不知道报告来自哪个文件。唯一通用 CLI 是下面的只读评审器;它只
消费调用方已经保存的正式 JSON,不承担项目文件适配。
## 构建内存评审视图和报告
@@ -173,6 +176,7 @@ if result.status is RunStatus.SUCCESS and result.output_markdown is not None:
```python
from mdpolish.review import (
build_review_document,
parse_json_report,
render_json_report,
render_markdown_report,
review_document_to_dict,
@@ -185,11 +189,13 @@ review = build_review_document(input_markdown, result)
review_summary = review_document_to_dict(review)
report_json = render_json_report(review, detail="changes")
report_markdown = render_markdown_report(review)
parsed_report = parse_json_report(report_json, expected_detail="changes")
assert review.current_markdown == "an example text"
assert review_summary["schema_version"] == "1.0"
assert review_summary["detail"] == "summary"
assert report_json.startswith('{\n "schema_name": "mdpolish.review"')
assert parsed_report["status"] == "success"
assert report_markdown.startswith("# mdpolish review report\n")
```
@@ -210,9 +216,51 @@ Markdown reporter 会包含完整输入、当前全文、统一 diff 以及实
- `full`:再加入完整输入、当前全文和每个阶段的完整前后全文。
高 detail 可能还原敏感内容。即使 `summary` 不含正文,它仍然携带项目元数据和哈希,不能自动视为匿名或适合公开传播。
dict 和 JSON 都是单向派生视图,不用于恢复 `ReviewDocument` 或重新应用修改。完整 schema、坐标、哈希和兼容口径见
dict 和 JSON 都是单向派生视图,不用于恢复 `ReviewDocument` 或重新应用修改。`parse_json_report()` 返回新的普通 dict/list
容器;对 `full` 会验证全文哈希、阶段首尾,并用核心共用的精确编辑原语证明每个 stage after 确实由所列 Change 产生。
完整 schema、坐标、哈希和兼容口径见
[`review-projection-schema-v1.md`](research-wiki/reference/review-projection-schema-v1.md)。
## 使用本地评审页面
项目先把每份评审结果显式保存为直属的 `*.review.json`。页面需要完整输入、当前文本和所有 Modifier 阶段,因此必须使用
`detail="full"`
```python
from pathlib import Path
from mdpolish.review import build_review_document, render_json_report
review = build_review_document(input_markdown, result)
review_path = Path("artifacts/reviews/example.review.json")
review_path.parent.mkdir(parents=True, exist_ok=True)
review_path.write_text(
render_json_report(review, detail="full") + "\n",
encoding="utf-8",
)
```
文件路径、目录创建、权限、Git 忽略、覆盖和保留周期都属于项目。`full` JSON 重复包含完整文档和阶段全文,不能当作安全日志
或公开产物。
然后显式启动只读页面:
```bash
mdpolish-reviewer --review-dir artifacts/reviews
```
也可以使用等价模块入口:
```bash
python -m mdpolish.reviewer --review-dir artifacts/reviews
```
命令默认绑定 `127.0.0.1` 的系统空闲端口,并打印本机 URL。页面可以选择文档和 Modifier,比较完整 before/after,保留
零修改阶段,并通过 Change 列表跳转。服务只读取所给目录直属的 `*.review.json`,不递归、不跟随符号链接、不运行
Pipeline,也不渲染原文中的 Markdown、HTML、图片或脚本。文件名去掉 `.review.json` 后只是页面标签,不会被解释成输入
路径或业务文档 ID。完整操作与排障见
[`use-local-reviewer.md`](research-wiki/guides/use-local-reviewer.md)。
## 扫描精确物理行
项目 Modifier 如果需要识别独占行、检查相邻行或连同行尾删除一行,可以按原文 code point 范围扫描:
@@ -307,10 +355,14 @@ src/mdpolish/
├── modifier.py # 函数式 Modifier 契约
├── edits.py # 批次验证与原子应用
├── pipeline.py # 有序执行与最终稳定性复查
├── review.py # 可信评审视图、机器投影及内存 JSON/Markdown reporter
├── review.py # 可信评审视图、机器投影、JSON reader 及内存 reporter
├── _review_json.py # 正式 JSON 的私有解析和 full 语义校验
├── reviewer.py # 本地只读服务和公共 CLI
├── _reviewer_static/ # wheel 内的页面 bundle 与第三方许可证
├── regex.py # 正则修改器工厂
├── text_ranges.py # 公共精确物理行范围
└── modifiers/ # 少量项目无关的通用修改器
reviewer/ # React/TypeScript 源码、锁文件与合成界面测试
tests/ # 只使用虚构文本的核心与通用修改器测试
research-wiki/
├── design/ # 已批准决策及被冻结的历史记录
@@ -322,10 +374,10 @@ research-wiki/
## 当前不提供
- 文件适配器、公共 CLI、配置文件、profile 或批处理协议;
- 清洗文件适配器、清洗 CLI、配置文件、profile 或批处理协议;
- 自动规则发现、注册表或默认流水线;
- Markdown AST、完整 HTML parser 或必装的第三方运行依赖;
- artifact、自动保存的报告文件、正式 JSON Schema 文件、HTML reporter、Web/桌面评审器或项目审核流程;
- artifact、自动保存的报告文件、正式 JSON Schema 文件、远程/桌面评审器或项目审核流程;
- 任何业务项目的规则、固定参数、文档 ID、数据或验收统计。
公共边界与原因见
@@ -337,6 +389,8 @@ research-wiki/
查询口径见 [`review-projection-schema-v1.md`](research-wiki/reference/review-projection-schema-v1.md)。精确物理行公共接口的边界见
[`0013-public-physical-line-ranges.md`](research-wiki/design/0013-public-physical-line-ranges.md),稳定查询口径见
[`physical-line-ranges.md`](research-wiki/reference/physical-line-ranges.md)。旧 design 只保存历史决策,不代表当前交付能力。
项目无关本地评审器的批准边界见
[`0014-generic-local-reviewer.md`](research-wiki/design/0014-generic-local-reviewer.md)。
## 当前可用检查
@@ -349,6 +403,16 @@ research-wiki/
.venv/bin/python -m pip wheel . --no-deps --wheel-dir /tmp/mdpolish-wheel-check
```
修改前端源码或依赖时,再在 Node.js 24 环境中运行:
```bash
cd reviewer
nvm use 24
npm ci
npm run check
cd ..
```
检查本地变更:
```bash
@@ -356,27 +420,21 @@ git diff --check
git status --short
```
上述检查已于 2026-08-27 实际运行。Python 3.13.11 核心开发环境中 Ruff 和 mypy 通过pytest 为
`211 passed, 3 skipped`三个 skip 是该环境没有安装的真实 optional backend 路径,不计入发布验收
上述检查已于 2026-08-28 对 `v0.7.0` 实际运行。Python 3.13.11 开发环境中 Ruff 和 mypy 通过;未安装可选 backend 时
pytest 为 `330 passed, 3 skipped`三个 skip 分别对应真实 lexical 和 frequency backend 路径。
`mdpolish-0.4.0-py3-none-any.whl` 共 17 个文件,包含 `review.py``py.typed`,不包含 tests、Wiki、artifact、页面或
真实数据。安装全部 extras 后,Python 3.11.16 和 Python 3.13.11 环境分别得到 `214 passed`,没有 skip;仓库外消费者
smoke test 已覆盖核心正则、`pyspellchecker + Pyphen``wordfreq`、评审视图和 Markdown reporter。核心运行依赖仍只有
Python 标准库。Release wheel 的 SHA-256 是
`12e24863314958130ed082f78e89ab8bc0dad39a3848f2ae42e273d19a409693`
Node.js 24.19.0、npm 11.17.0 环境中,`npm ci` 未发现漏洞,ESLint、TypeScript、8 项 Vitest 和 Vite 生产构建通过。
生产 bundle 随 wheel 提供;普通使用者不需要 Node.js,页面运行时不从 CDN 下载资源。24 份生产依赖许可证文本和版本清单
已随 bundle 收录。
上述结果证明当前版本可安装并按合成契约运行,不代表任意词典阈值已经在真实业务语料上达到生产准确率。
同一个 Release wheel 在全新 Python 3.11.16 和 Python 3.13.11 环境中安装全部 extras 后,分别得到 `333 passed`,没有
skip;导入路径均确认来自环境的 `site-packages`。另一全新环境只安装 wheel、未安装第三方运行依赖,已实际完成 full JSON
生成、console script 与模块入口启动、集合/文档/Modifier API、UTF-16 定位和静态页面资源 smoke test。
`v0.6.0` 于 2026-08-28 在 Python 3.13.11 开发环境中实际得到:Ruff 和 mypy 通过,pytest 为
`290 passed, 3 skipped`;三个 skip 仍是没有安装的 optional backend。
`mdpolish-0.7.0-py3-none-any.whl` 压缩后为 315,279 bytes,共 49 个文件;解压后为 961,976 bytes,其中 29 个页面与许可证
文件为 742,678 bytes。相对未发布的 `0.6.1` 候选增加 32 个文件和 274,510 bytes 压缩体积。wheel 不包含 tests、Wiki、
`node_modules`、source map、真实报告、项目规则或数据。Release wheel 的 SHA-256 是
`b81a9a07fa0479854cd21d5a65f0f485cadf2031b5d731c3658ca10a1d72dc09`
Release wheel `mdpolish-0.6.0-py3-none-any.whl` 共 17 个文件,包含 `text_ranges.py``review.py``py.typed`,不包含
`_text_ranges.py`、tests、Wiki、报告或真实数据;在仓库外全新虚拟环境中无依赖安装后,版本、公共导入、精确混合行尾范围、
行尾集合、空行判断和 wheel 清单 smoke test 通过。Release wheel 的 SHA-256 是
`695502b1a443d4e98dbf63e8bdcee59452baea2185cb7e1e13160127f70c920f`
尚未发布的 `0.6.1` 候选修复了严格 HTML 表格扫描在未闭合或无法解析的外层 `<table>` 中继续处理完整内层表格的问题;
两个 HTML Modifier 的版本均为 `1.0.1`。2026-08-28 在 Python 3.13.11 开发环境中 Ruff 和 mypy 通过,pytest 为
`294 passed, 3 skipped`。安装候选 wheel 的全部 extras 后,Python 3.11.15 和 Python 3.13.11 环境分别得到
`297 passed`,没有 skip。候选 wheel 共 17 个文件并通过内容检查,SHA-256 是
`f628658a6d0b2860720e9425d190a477b78723dcda9b2d4b5a5e9f6e252faf07`
自动检查不能替代真实浏览器中的最终视觉、长文滚动和跨 Modifier 跳转人工确认。上述结果只证明当前版本可安装并按合成契约
运行,不代表任意清洗规则已经在真实业务语料上达到生产准确率。
+4 -1
View File
@@ -4,11 +4,14 @@ build-backend = "hatchling.build"
[project]
name = "mdpolish"
version = "0.6.1"
version = "0.7.0"
description = "Deterministic functional core for composing exact Markdown modifiers"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
mdpolish-reviewer = "mdpolish.reviewer:main"
[project.optional-dependencies]
frequency = [
"wordfreq>=3.1.1,<4",
@@ -0,0 +1,402 @@
# 0014:项目无关的本地清洗评审器
## 状态
已于 2026-08-28 获用户明确批准,按本文第 15 节实施。本文自批准起冻结;后续改变决策需新增 design 并使用
`supersedes` 指向本文。
用户在批准本文时同时明确要求:完成实施和验收后提交 Git,创建并推送 `v0.7.0` tag,再用同一个已验收 wheel 及其
SHA-256 校验文件创建 GitHub Release。该授权不包括 PyPI、其他包索引、PR 或其他仓库修改。
`supersedes: 0008`(范围有限):本文拟改变“Web 评审器全部留在项目端”的边界。项目规则、流水线、文件写入和业务审核流程
仍由使用项目拥有;`mdpolish` 只增加读取正式 review JSON 的通用本地查看工具。
`supersedes: 0011`(范围有限):本文拟增加 HTML 浏览器界面和本机只读服务,但不改变 `ReviewDocument`、可信重放、
Markdown reporter 或核心无文件 I/O 的决定。
`supersedes: 0012`(范围有限):本文拟增加正式 JSON 的只读解析与校验入口。它不会把 JSON 恢复为 `ReviewDocument`
不会重新运行 `Modifier`,也不会把机器投影变成可重新应用修改的权威输入。
历史 `0007` 已经被 `0008` 替代,不因本文重新生效。本文只借鉴其本机服务安全边界,以及
`/home/lihaoze/work/mdpolish-wheel-pilot` 中已经实现的双栏界面;不恢复旧 artifact、locator、manifest 或项目实验系统。
## 1. 问题与可观察现象
`mdpolish v0.6.0` 已经能把可信的 `ReviewDocument` 生成为 schema `1.0``full` JSON。这个 JSON 包含完整输入、当前文本、
所有 Modifier 阶段和实际 Change,足以支持准确的浏览器评审。
但是当前库仍明确不提供 JSON 读取器、本地服务或评审页面。每个项目如果要查看结果,还要重复完成以下工作:
1. 解析并校验 `mdpolish.review` JSON
2. 验证正文哈希、阶段链、Change 范围和计数;
3. 把 Python Unicode 码点坐标转换成浏览器编辑器使用的 UTF-16 坐标;
4. 编写本机只读服务、双栏界面和 Change 跳转;
5. 持续跟随 review schema 和前端依赖变化。
wheel-pilot 已经按自己的 `0004` design 实现一版 React + CodeMirror 评审器。只读调查确认它的主要交互是通用的:选择文档、
查看总体输入/输出、按 Modifier 查看完整阶段、保留零修改阶段、滚动长文档,以及点击 Change 跳转。当前参考生产构建约
842 KiB;这只是本次方案比较的观测值,不是未来 wheel 大小承诺。
wheel-pilot 中真正属于项目的部分是论文 Modifier、七步顺序、批处理、`artifacts/v0.6.0/` 路径和五篇文档身份。
页面和只读服务不需要理解这些业务事实。因此,让每个项目继续复制整套 viewer 会形成重复实现和不一致的校验口径。
## 2. 决定摘要
第一版采用以下边界:
```text
使用项目
├── 选择 Modifier、参数和顺序
├── Pipeline.transform()
├── build_review_document()
├── render_json_report(..., detail="full")
└── 自行保存 *.review.json、决定权限与保留周期
mdpolish-reviewer --review-dir <明确目录>
├── mdpolish 官方 JSON 解析与语义校验
├── Python 码点 → UTF-16 只读定位
├── 回环地址上的只读 HTTP 服务
└── React + CodeMirror 双栏页面
```
评审器随同一个 `mdpolish` wheel 交付,但与内存清洗核心隔离。安装 wheel 不会启动服务、读取文件或改变任何 Markdown;
只有用户显式运行评审器命令时,工具才读取明确传入的目录。
项目无需复制前端或 Python 服务。项目只要保存正式 `full` JSON,就能使用同一界面。
## 3. 目标与非目标
### 3.1 目标
- 为所有使用项目提供同一套本地只读清洗结果页面;
- 保留 wheel-pilot 当前已经验证的双栏布局、Modifier 时间线、完整滚动和 Change 跳转体验;
- 直接消费 `mdpolish.review` 正式机器投影,不建立项目 artifact schema
-`mdpolish` 提供正式 JSON 的解析和校验,不让 viewer 私下维护另一套 schema 解释;
-`full` 数据验证正文哈希、阶段首尾、Change 批次、位置、计数和状态一致性,失败时拒绝近似展示;
- 保持 Modifier 阶段坐标的原语义,只为 CodeMirror 额外派生 UTF-16 范围;
- 只绑定本机回环地址,只提供同源静态页面和只读 API;
- 前端生产资源随 wheel 提供,使用项目运行页面时不需要 Node.js,也不从 CDN 下载资源;
- 保持普通 Python 核心安装零第三方运行依赖;
- 使用合成文本覆盖 Unicode、不同换行、空文档、零修改阶段、失败和不稳定状态。
### 3.2 非目标
- 不替项目读取原始 Markdown、运行 Pipeline、选择 Modifier 或保存 review JSON
- 不定义项目的目录层级、批处理协议、文档 ID、标题、审核状态、权限模型或保留周期;
- 不编辑、接受、拒绝、撤销或重新应用 Change,不从页面触发清洗;
- 不渲染 Markdown 排版,不执行原文中的 HTML,不加载图片、字体或其他外部资源;
- 不提供上传、远程访问、账户、数据库、多人协作、批注或生产部署接口;
- 不把 JSON 恢复为 `ReviewDocument``TransformResult` 或 Pipeline
- 不改变 schema `1.0` 的字段、哈希、坐标、detail 或正文暴露语义;
- 不增加默认流水线、项目 profile、业务规则或真实样本;
- 不在本轮修改或删除 wheel-pilot 的 reviewer。它的迁移与清理必须在该仓库另行批准;
- 不读取、复制、修改或提交真实文档、历史报告和外部数据。
## 4. 职责边界
| 能力 | `mdpolish` | 使用项目 |
| --- | --- | --- |
| Modifier 规则、参数和顺序 | 不拥有 | 拥有 |
| 清洗执行与内存审计 | 提供通用核心 | 显式调用 |
| `ReviewDocument` 与正式 JSON 生产 | 提供 | 决定是否生成 |
| review JSON 文件名、目录和覆盖策略 | 不决定 | 拥有 |
| review JSON 内容解析与通用一致性校验 | 提供 | 不再重复实现 |
| UTF-16 编辑器定位 | reviewer 内部提供 | 不需要实现 |
| 双栏页面、Modifier 时间线和 Change 跳转 | 提供 | 直接使用 |
| 文档业务名称、审核结论、批注和权限 | 不拥有 | 如有需要自行实现 |
| 数据脱敏、访问控制和保留周期 | 只说明风险 | 拥有 |
reviewer 不知道 JSON 对应哪个原始文件。页面展示的文档标签只能来自 review JSON 文件名,不能猜测输入路径、论文标题或业务
身份。项目如果需要额外业务字段,应建设自己的外层页面;第一版不为此增加 sidecar manifest 或配置插件。
## 5. 方案比较
| 方案 | 优点 | 代价 | 决定 |
| --- | --- | --- | --- |
| 每个项目继续复制 wheel-pilot reviewer | 上游 wheel 最小 | 校验、API、前端和依赖重复;行为会漂移 | 不采用 |
| 单独发布 `mdpolish-reviewer` wheel | 核心分发物最小 | 两个包必须配对版本、安装和发布;当前 reviewer 无第三方 Python 依赖 | 第一版不采用 |
| 同一 wheel 内放独立 reviewer 模块和静态资源 | 一个版本同时约束 producer、reader 和页面;项目只安装一个 wheel | 所有人下载的 wheel 都会增加静态资源体积 | 采用 |
| 运行时从网络下载页面 | wheel 较小 | 引入网络、版本漂移、隐私和供应链风险 | 不采用 |
| pip 构建 wheel 时自动运行 npm | 不提交生产 bundle | Git direct install 需要 Node.js 和网络,破坏现有 Python 安装体验 | 不采用 |
| 提交经过检查的生产 bundle并打进 wheel | 使用者不需要 Node.jsPython 构建保持简单 | 源码与生成资源必须同步检查,Git diff 会包含压缩文件 | 采用 |
这里的“同一 wheel”不表示 reviewer 成为 Pipeline 的一部分。依赖方向固定为 reviewer 可以导入 review JSON 解析能力,
`models.py``edits.py``modifier.py``pipeline.py` 不导入 reviewer、HTTP 或前端资源。
## 6. 正式 JSON 读取入口
第一版拟在受支持的 `mdpolish.review` 路径增加:
```python
class ReviewParseError(ValueError):
"""机器投影 JSON 不能被安全读取。"""
def parse_json_report(
report: str,
*,
expected_detail: ReviewDetail | str | None = None,
) -> ReviewProjection:
...
```
它接收内存字符串并返回只含 JSON 基本值的新 dict/list 容器。它不接收路径,不读取文件,不返回 `ReviewDocument`,也不重新运行
Modifier。`expected_detail=None` 接受 schema 支持的任一已知 detailreviewer 必须显式要求 `full`
解析至少执行以下通用检查:
- 拒绝重复 object key、非标准 `NaN` / `Infinity`、孤立 surrogate 和非 JSON 值;
- 要求 `schema_name == "mdpolish.review"`,并按 schema major 兼容规则处理版本;
- 验证已知 detail 的必需字段、类型、枚举、整数范围、引用位置和正文暴露边界;
- 对同一 schema major 的未知 object 字段按现有兼容规则忽略其语义,不改变已知字段解释;
- 遇到未知状态、detail、坐标契约或无法安全解释的 enum 时失败,绝不把它降级成 `success`
- 错误消息只给字段路径和契约类别,不拼入正文、参数、reason 或诊断消息。
`summary``changes` 不含完整阶段文本,解析器只能验证它们实际携带的结构和引用。`full` 还必须验证:
1. input、current 和每个阶段全文的 UTF-8 SHA-256 与码点长度;
2. 第一个完成阶段从 input 开始,相邻阶段首尾完全相接;
3. 每个 Change 的 modifier 引用、哈希、范围、原文、行列和顺序;
4. 使用核心共用的精确编辑应用原语重放当前阶段 Change,结果必须等于 stage after
5. 零修改阶段的 before 与 after 完全相同;
6. 完成阶段末尾等于 current,阶段数量与 `stages_complete` 及错误阶段相容;
7. counts、错误、残留候选和 `success` / `failed` / `unstable` 状态相容。
第 4 点比 wheel-pilot 当前服务只检查“before 中能找到片段”更严格。它防止攻击者同时篡改 stage after 正文和哈希后,页面仍把
一个并非由所列 Change 产生的结果展示为可信阶段。
解析器可以从 `edits.py` 复用私有验证与应用原语,但不得复制一份不同的冲突、排序或字符串应用规则。正常清洗公共接口和
schema `1.0` 生产结果必须保持不变。
## 7. 输入集合与文件边界
第一版本地入口拟为:
```text
mdpolish-reviewer --review-dir <review_directory> [--port <port>]
```
同时支持等价的模块入口:
```text
python -m mdpolish.reviewer --review-dir <review_directory>
```
`--review-dir` 必填,没有当前目录或 `artifacts/` 的隐式默认值。服务只读取该目录直属的 `*.review.json` 普通文件,按文件名
确定顺序,不递归、不扫描父目录、不跟随目录或文件符号链接。目录为空时明确失败。
文件适配器负责严格 UTF-8、BOM、读取错误和路径检查,然后把内存字符串交给 `parse_json_report(...,
expected_detail="full")`。核心解析函数不知道路径。
第一版使用去掉末尾 `.review.json` 后的文件名作为页面标签和内部文档身份。例如 `paper-01.review.json` 显示为
`paper-01`;它不自动补 `.md`,也不声称这是原输入文件名。重名、空身份或无法安全形成 URL 身份时启动失败。
目录名只作为页面顶部的本地集合标签,不成为运行 ID、项目 ID 或 schema 字段。绝对路径不返回给浏览器,也不打印正文。
review JSON 可能包含完整敏感正文。`mdpolish` 不自动创建、复制、移动、删除或清理这些文件;项目继续负责把它们保存在
合适的本地目录,设置权限、Git 忽略和保留周期。
## 8. 本机服务和内部 API
服务必须保持以下边界:
- 只绑定 `127.0.0.1`,默认端口 `0` 由操作系统选择;
- 只接受 `GET``HEAD`,其他方法返回 `405`
- 校验 `Host` 与可选 `Origin`,不开放 CORS
- 不提供任意文件路径、写入、删除、移动、重新运行或 shell 接口;
- 静态资源只来自 wheel 内固定目录,拒绝路径穿越和符号链接;
- 页面和 API 设置 `no-store`、CSP、`nosniff``no-referrer` 和禁止 frame 的响应头;
- 日志不输出正文、修改片段、参数或绝对 review 目录;
- 退出时不修改项目目录或浏览器外状态;
- 不自动打开浏览器,终端只打印明确的回环 URL 和不含敏感路径的文档数量。
浏览器使用版本化但只服务同一 reviewer 的内部 `/api/v1/`。API 至少提供:
| 资源 | 内容 |
| --- | --- |
| 集合摘要 | 集合标签、聚合状态、文档顺序和计数,不含正文 |
| 文档比较 | 输入、成功 current、Modifier 摘要、全部 Change 和诊断 |
| Modifier 阶段 | 指定完整阶段的 before、after、Change 和 UTF-16 定位 |
这是 Python 服务与同 wheel 页面之间的内部契约,不承诺给第三方项目直接调用。跨项目稳定数据契约仍是
`mdpolish.review` schema 和第 6 节的解析入口,不能把本地 HTTP API 变成第二个公共 artifact schema。
第一版启动时校验目录内全部 review。任何一份损坏都会阻止服务启动,并指出不含正文的文件标签和错误类别;不在同一次集合
中混合“已可信”和“猜测展示”的文档。若真实项目证明需要隔离单篇坏文件,再新增 design 改变失败策略。
## 9. UTF-16 编辑器定位
schema `1.0``span.start` / `span.end` 是所属阶段 before 文本中的 Python Unicode 码点半开范围。CodeMirror 使用
JavaScript UTF-16 code unit。reviewer 在 full 阶段已经通过校验后,派生:
```json
"editor_range": {
"start": 10,
"end": 12
}
```
这个范围只存在于内部 API,用于左栏选区和滚动,不写回正式 review JSON,不成为新的修改权威。中文基本平面字符通常不改变
数值,emoji 等补充平面字符会占两个 UTF-16 code unit。组合字符仍按原字符串逐码点转换,不做 Unicode 规范化。
转换必须以该 Change 所属的 `stage.before.markdown` 为输入。不得把中间阶段 span 套到原始输入、最终 current 或其他
Modifier 阶段。
## 10. 页面行为
第一版以 wheel-pilot 当前页面为迁移基线,保留用户已经满意的视觉和主要交互:
- 启动后选择第一份文档,默认比较完整 input 与成功 current
- 左侧列出文档和按位置排序的全部 Modifier;
- 选择 Modifier 后比较该阶段完整 before / after
- 零修改 Modifier 仍显示,前后全文相同;
- 不折叠未修改区域,长文由 MergeView 容器完整纵向滚动;
- 总结果列出全部 Change,阶段视图只列当前 Modifier 的 Change
- 点击 Change 时先切换所属阶段,等待 MergeView 用新 before/after 重建,再选中并居中左栏范围;
- `failed``unstable` 只显示准确的 partial/错误/残留证据,不把 current 命名为“清洗后”;
- Markdown、HTML、图片和脚本语法只作为只读源码,不渲染、不请求外部资源;
- 页面文案使用简体中文,第一版不建设主题、国际化或项目定制接口。
前端仍采用 React、TypeScript、Vite、CodeMirror MergeView、Vitest 和 React Testing Library。准确版本只在
`reviewer/package.json` 与锁文件中维护,不在 design 和 README 复制第二份易漂移清单。Node.js 24 只用于仓库开发、测试和
生成生产 bundle;使用 wheel 查看结果不需要 Node.js。
## 11. 源码和交付结构
批准后拟增加:
```text
reviewer/
├── .nvmrc
├── package.json
├── package-lock.json
├── src/ # React、API client、运行时响应校验
└── tests/ # 合成前端测试
src/mdpolish/
├── review.py # 增加正式 JSON 解析入口
├── reviewer.py # 路径适配、本地 API、HTTP 服务和 CLI
└── _reviewer_static/ # 经检查并提交的生产 HTML/JS/CSS
tests/
├── test_review_parsing.py # schema 与 full 语义校验
└── test_reviewer.py # 目录、HTTP、安全和 UTF-16
```
最终文件拆分可以在不改变职责的前提下机械调整,例如把 HTTP handler 放入私有模块;不得把 viewer 逻辑塞进
`pipeline.py` 或让核心导入前端资源。
生产 bundle 提交到 `_reviewer_static/` 并包含在 wheel,使从 Git 地址或 Release wheel 安装时不调用 npm。前端源码或锁文件
变化后必须重新构建并检查 bundle;Python wheel 构建只打包现有已验证资源。测试必须发现缺失或陈旧入口资源,不能在没有
页面时静默构建一个“成功”wheel。
前端 bundle 引入的第三方代码必须在仓库和 wheel 中保留适用的版权与许可证说明。实现验收要列出实际 bundle 和 wheel 大小,
检查 wheel 不包含 `node_modules`、前端测试、coverage、source map、真实数据或 review JSON。
`pyproject.toml` 增加 `mdpolish-reviewer` console script 和静态 package data。普通 `mdpolish` 导入路径不重新导出服务对象;
公共 Python 读取入口仍位于 `mdpolish.review`
## 12. 兼容与版本
本文增加公共 JSON 读取函数、公共 CLI、HTML 页面和 wheel 文件,属于 `0.x` 阶段的功能性次版本变化。实施候选版本计划从当前
未发布的 `0.6.1` 更新为 `0.7.0`;不改变现有 Modifier 版本、Pipeline 结果或 schema `1.0`
reviewer 和 producer 随同一个 wheel 发布,避免建立第二套版本配对规则。页面内部 API 可以随同一 wheel 修改,但必须同步
Python、TypeScript 运行时校验和测试。
正式 `mdpolish.review` schema 继续独立版本。若未来 schema major 改变,reader 必须拒绝;同 major 的加法字段按
`0012` 的兼容规则处理。任何修改现有字段含义、正文暴露等级或坐标口径的工作仍需新的 design,不能借 reviewer 页面绕过。
## 13. 测试与验收
### 13.1 JSON 解析与失败关闭
只使用虚构小文本,至少覆盖:
- `summary``changes``full` 的必需字段和暴露边界;
- 空文档、空流水线、零修改 Modifier 和多 Modifier 链;
- success、unstable、preflight failure、transform failure 和 final review failure
- 中文、emoji、组合字符、BOM 字符、LF、CRLF、CR 和无末尾换行;
- full 中所有文本哈希、阶段链、Change 重放、位置、计数和状态;
- 重复键、BOM 文件、错误 UTF-8、非有限数、孤立 surrogate、未知 schema major/detail/enum
- 损坏 input/current/stage 哈希、断裂阶段、错误 span、冲突 Change、篡改 after、零修改阶段却改变文本;
- 解析错误不包含正文、参数、reason 或诊断消息哨兵;
- 解析器不读文件、不访问网络、不调用 Modifier,不改变传入或返回外部容器。
同一组合成 full report 应分别通过 `ReviewDocument` 生产路径和 JSON 读取路径,逐阶段比较相同的 before、after、哈希与 Change
顺序。只比较最终 current 不足以证明 reader 与 producer 一致。
### 13.2 本地服务
- 明确目录的多份合法 full JSON 可以得到集合、文档和 Modifier 阶段响应;
- 文件名顺序、标签、零修改阶段和聚合计数正确;
- 空目录、递归文件、符号链接、未知文件、损坏 JSON 和重复身份失败;
- 路径穿越、异常 Host/Origin、未知路由和非 GET/HEAD 请求被拒绝;
- 服务只绑定 `127.0.0.1`,安全响应头和媒体类型正确;
- API 和日志不返回绝对目录,不输出正文到终端;
- 码点到 UTF-16 的转换覆盖 emoji、组合字符、不同换行和空插入;
- 缺失静态资源时明确失败,不访问 CDN 或任意磁盘路径。
### 13.3 前端
- 文档列表、聚合状态和修改数显示正确;
- 总结果、Modifier 阶段和零修改阶段切换正确;
- Change 筛选、同阶段跳转、跨 Modifier 跳转和重建后聚焦正确;
- 长文不生成折叠区,MergeView 保持纵向滚动;
- Markdown 中的 HTML、图片和脚本保持惰性文本;
- failed、unstable、API 错误和未知内部响应不显示虚构成功结果;
- ESLint、TypeScript、Vitest 和 Vite 生产构建通过;
- 真实浏览器滚动和视觉仍需人工验收,jsdom 结果不能替代。
### 13.4 回归与交付
实施完成后运行根 README 当时列出的全部检查,并额外确认:
- 现有 Pipeline、Modifier、ReviewDocument、dict/JSON producer 和 Markdown reporter 行为不变;
- 普通核心安装仍没有第三方 Python 运行依赖;
- 最低和当前支持的 Python 环境都能启动 reviewer 并读取合成 full JSON
- wheel 能从仓库外安装和启动页面,静态资源、console script 与版本正确;
- 记录 wheel 文件清单、压缩/解压大小和相对 `0.6.1` 候选的增量;
- wheel 不包含 `node_modules`、前端测试、source map、真实报告、项目规则或数据;
- 前端第三方许可证说明完整;
- `AGENTS.md``CLAUDE.md` 除标题外正文一致;
- Git diff 不混入 wheel-pilot、真实文本、大型实验产物或用户已有改动。
真实项目数据不是实现正确性的必要条件。批准本文也不授权读取或复制 wheel-pilot 的 `local-data/``artifacts/`
若用户随后希望确认页面视觉,可以由 wheel-pilot 继续使用自己的已有结果,或在该仓库另行批准改用上游候选 wheel。
## 14. 风险与代价
- **wheel 明显变大:** 当前参考 bundle 约 842 KiB,实际实现仍需记录。换来的是项目不再安装 Node.js 或复制页面。
- **公共 CLI 需要兼容维护:** `--review-dir` 和只读行为一旦发布就不能随意更名;第一版参数保持最少。
- **提交生成资源会增加 diff** 这是保证 Git direct install 不依赖 npm 的代价,必须用构建和 wheel 测试防止陈旧 bundle。
- **解析器公共表面积增加:** 它需要长期跟随 schema,但比每个项目各写一套校验更可控。
- **full JSON 占用内存:** 每阶段重复全文,reader 和页面还会产生额外容器;第一版不承诺无限文档或批量规模。
- **本机 HTTP 仍有攻击面:** 回环、Host/Origin 校验、无 CORS、CSP、无写接口和明确目录都是必需边界。
- **文件名不等于业务身份:** 通用 schema 没有路径和标题;第一版宁可显示保守标签,也不引入项目 manifest。
- **页面可能被误当成审核系统:** 它只展示清洗证据,不记录批准、拒绝、责任人或结论。
- **wheel-pilot 暂时重复:** 上游实现和发布前,两边 reviewer 会并存。迁移应在消费者仓库单独评审,不能同时删除以制造大爆炸变更。
## 15. 批准后的实施边界
用户明确批准本文后,只授权:
1. 在当前 `mdpolish` 仓库实现第 6 至 11 节的 JSON reader、本机服务、前端、静态资源和 console script
2. 从 wheel-pilot 已提交的页面与服务中参考或迁移项目无关代码,但不修改该仓库,不读取其真实数据与 artifacts;
3. 为共享精确编辑语义做必要的私有机械复用,不改变公共清洗结果;
4. 新增合成 Python/TypeScript 测试,并完成第 13 节的构建、wheel 和仓库外 smoke test
5. 实现完成后更新 README 当前能力、`review-projection.md`、schema reference 和经实际验证的 guide
6. 把候选包版本更新为 `0.7.0`,报告实际 diff、测试、bundle、wheel 和许可证检查。
本次批准还授权在全部必需验收通过后:
1. 提交本文及其实施,使用中文 Git commit subject
2. 把当前分支提交和 `v0.7.0` tag 推送到 `origin`
3. 只把提交前已经验收的同一个 `mdpolish-0.7.0-py3-none-any.whl` 和 SHA-256 校验文件上传到
`v0.7.0` GitHub Release,不能在 tag 后重新构建另一份 wheel 冒充已验收产物。
本次批准不授权:
- 创建 PR,发布到 PyPI、GitHub Packages 或其他包索引;
- 修改 wheel-pilot、其他仓库、真实数据或历史 artifacts;
- 增加远程绑定、写接口、上传、认证、数据库、项目 metadata、审核工作流或 Markdown 渲染;
- 改变清洗规则、Modifier 顺序、误删容忍度、`RunStatus``ReviewDocument` 字段或 schema `1.0` 语义。
+43 -3
View File
@@ -21,9 +21,16 @@
└── review_document_to_dict(detail=...)
├───────────────────────────► 项目自己的界面或转换层
└── render_json_report() ──► 内存 JSON 字符串
parse_json_report()
本地只读 reviewer 页面
```
文件读取、保存位置、HTML 页面、权限和审核流程仍由调用项目决定。
清洗输入读取、报告保存位置、权限和审核流程仍由调用项目决定。项目可以把正式 `full` JSON 保存到自己的目录,再显式启动
`mdpolish-reviewer`;通用页面不替项目生成、命名或清理这些文件。
## 2. 构建过程为什么可以失败关闭
@@ -111,5 +118,38 @@ modifier 参数、位置和哈希,并在根对象写入 `schema_name=mdpolish.
拒绝 NaN / Infinity,不写文件或添加 BOM。Markdown reporter 继续直接读取 `ReviewDocument`:它面向人类排版并包含 diff、
动态围栏和 residual 展示限额,不依赖机器 schema。
机器投影只生产,不提供 JSON 到 `ReviewDocument` 反序列化,也不能用于重新应用修改。完整字段、坐标、错误代码和兼容规则
见 [`review-projection-schema-v1.md`](../reference/review-projection-schema-v1.md)。
`parse_json_report()` 读取内存 JSON 字符串,返回新的普通 dict/list 容器。它不是 `ReviewDocument` 反序列化,也不能用于
重新应用修改。三个 detail 都会检查字段、枚举、引用、顺序、计数和正文暴露边界;只有 `full` 带有完整阶段文本,因此还能
验证所有正文哈希、阶段链、Change 原文和行列,并使用 `edits.py` 的同一套精确编辑原语重放每个阶段。重放结果不等于
stage after 时直接抛出 `ReviewParseError`,不会因为攻击者同时更新正文和声明哈希就接受伪造阶段。
schema 同一 major 的未知 object 字段不改变已有字段解释;未知 major、detail、状态、坐标契约或 enum 会被拒绝。解析错误只
说明字段路径和契约类别,不复制正文、参数、reason 或诊断消息。
完整字段、坐标、错误代码、读取保证和兼容规则见
[`review-projection-schema-v1.md`](../reference/review-projection-schema-v1.md)。
## 7. 本地页面为什么仍然保持项目无关
本地 reviewer 只接受用户明确传入的一个目录,并读取其中直属的 `*.review.json`。它从文件名派生保守的页面标签,不读取
原始 Markdown 路径、项目 manifest、默认流水线或业务状态。Python 服务负责正式 JSON 校验和码点到 UTF-16 的只读定位,
React 页面只消费同源内部 API。
```text
项目保存的 full JSON
官方 reader:验证 schema、哈希、阶段和 Change
127.0.0.1 上的只读 API
文档列表 ── Modifier 时间线 ── 双栏源码比较 ── Change 跳转
```
`editor_range` 只用于 CodeMirror 选择和滚动。正式 span 仍是所属 `stage.before.markdown` 中的 Python 码点半开范围,不写回
JSON,也不变成新的审计权威。
服务只绑定回环地址,只接受 `GET` / `HEAD`,校验 Host 和 Origin,不开放 CORS,也没有写入、重新清洗、上传或 shell 接口。
页面不渲染 Markdown 和 HTML,不加载图片或外部资源。它展示的是清洗证据,不记录批准、拒绝、批注或审核结论。
@@ -0,0 +1,80 @@
# 使用本地清洗评审页面
项目已经生成 `ReviewDocument`,但不希望自己维护 JSON 校验、HTTP 服务和前端时,可以把正式 `full` JSON 保存到一个明确
目录,再由 `mdpolish-reviewer` 只读展示。评审器不会读取原始 Markdown 路径,也不会运行 Pipeline 或写回结果。
## 前置条件
- 已安装 `mdpolish 0.7.0`;普通 wheel 即可,不需要安装 Node.js 或任何第三方 Python 运行依赖;
- 调用项目已经显式选择 Modifier、完成 `Pipeline.transform()` 并得到相应输入文本;
- 项目已经决定评审 JSON 的保存目录、权限、Git 忽略和保留周期。
`full` JSON 会重复包含输入、当前文本和各 Modifier 阶段全文。不要把它放进公开目录、提交到 Git,或当作脱敏日志。
## 1. 保存正式 full JSON
下面的 `input_markdown``result` 来自调用项目已有的内存清洗流程:
```python
from pathlib import Path
from mdpolish.review import build_review_document, render_json_report
review = build_review_document(input_markdown, result)
review_path = Path("artifacts/reviews/example.review.json")
review_path.parent.mkdir(parents=True, exist_ok=True)
review_path.write_text(
render_json_report(review, detail="full") + "\n",
encoding="utf-8",
)
```
一个目录可以放多份直属的 `*.review.json`。评审器不会递归查找子目录,也不会跟随文件或目录符号链接。文件名去掉
`.review.json` 后只是页面标签,不代表原始文件路径或业务身份。
## 2. 启动页面
```bash
mdpolish-reviewer --review-dir artifacts/reviews
```
也可以使用等价入口:
```bash
python -m mdpolish.reviewer --review-dir artifacts/reviews
```
默认由系统选择空闲端口。成功时终端会显示类似结果:
```text
mdpolish 评审器已启动:http://127.0.0.1:431271 份文档)
```
在同一台机器的浏览器中打开实际打印的 URL。页面左侧选择文档或 Modifier;总结果比较完整输入与成功输出,Modifier 视图
比较该阶段的完整 before/after。点击 Change 会切换到所属阶段并定位左栏原文;零修改阶段仍可选择。
`Ctrl+C` 停止服务。服务只绑定 `127.0.0.1`,停止时不会改动评审目录。
## 3. 失败时怎么判断
| 现象 | 含义与处理 |
| --- | --- |
| `评审目录没有直属 full review JSON` | 检查目录是否正确,以及文件名是否以 `.review.json` 结尾 |
| `detail does not match the requested value` | 项目保存的不是 `detail="full"`,重新从可信 `ReviewDocument` 生成 |
| `review JSON parsing failed` | JSON 结构、哈希、阶段链、Change 重放或状态不一致;不要绕过校验展示 |
| `评审目录不能是符号链接` | 传入真实目录路径,不使用符号链接 |
| `无法启动本地评审服务` | 指定端口可能被占用;删除 `--port` 让系统选择,或换一个本机端口 |
任意一份 JSON 损坏都会阻止整个集合启动。错误只用于定位契约类别;不要把正文、修改片段或绝对目录补进日志。
## 验证记录
本流程于 2026-08-28 使用发布候选 wheel 和虚构的 emoji、CRLF、一次修改及一个零修改阶段实际验证:
- wheel 在无第三方 Python 依赖的全新环境中安装成功;
- console script 与模块入口均可用,预期启动错误不产生 traceback;
- 集合、文档和 Modifier API 返回正确,Python 码点范围正确转换为 UTF-16;
- wheel 内首页和生产 JavaScript 可以通过回环服务读取;
- 未读取或复制真实文档,也未写入调用项目目录。
这次验证覆盖安装、数据校验和服务路径,不替代真实浏览器中的最终视觉、长文滚动和交互人工确认。
@@ -1,8 +1,9 @@
# ReviewDocument 机器投影 schema 1.0
本文记录 `mdpolish.review.review_document_to_dict()``render_json_report()` 当前稳定的跨进程查询口径。公共 Python 类型和
运行校验以 `src/mdpolish/review.py` 与测试为准;设计理由和批准边界
[`0012-review-document-machine-projection.md`](../design/0012-review-document-machine-projection.md)
本文记录 `mdpolish.review.review_document_to_dict()``render_json_report()` `parse_json_report()` 当前稳定的跨进程查询
口径。公共 Python 类型和运行校验以代码与测试为准;生产契约的设计理由
[`0012-review-document-machine-projection.md`](../design/0012-review-document-machine-projection.md),只读解析与本地页面边界见
[`0014-generic-local-reviewer.md`](../design/0014-generic-local-reviewer.md)。
## 1. Schema 身份与入口
@@ -18,13 +19,15 @@
schema 版本独立于 `mdpolish` 包版本和 modifier 版本。公共入口是:
```python
from mdpolish.review import render_json_report, review_document_to_dict
from mdpolish.review import parse_json_report, render_json_report, review_document_to_dict
payload = review_document_to_dict(review, detail="summary")
json_text = render_json_report(review, detail="full")
parsed = parse_json_report(json_text, expected_detail="full")
```
两者只接受内存中的 `ReviewDocument`。JSON reporter 编码同 detail 的正式 dict,不定义另一套字段,也不读写文件。
前两个生产入口只接受内存中的 `ReviewDocument`。JSON reporter 编码同 detail 的正式 dict,不定义另一套字段,也不读写文件。
reader 只接受内存字符串,返回新的普通 JSON 基本值容器;它不接收路径,也不恢复 `ReviewDocument`
## 2. 顶层字段
@@ -234,7 +237,40 @@ array,不根据二元组外形猜成 JSON object
返回值是 Python `str`。调用方保存或发送时负责 UTF-8 编码、媒体类型、权限和保留周期。库不接收路径或文件对象。
## 7. 兼容策略
## 7. 只读解析保证
```python
from mdpolish.review import ReviewParseError, parse_json_report
payload = parse_json_report(json_text)
full_payload = parse_json_report(json_text, expected_detail="full")
```
`expected_detail` 可以省略,也可以显式指定 `summary``changes``full`。报告自己的 detail 不匹配时失败。返回结果是本次
解析新建的 dict/list;修改它不会恢复或改变 producer 侧的 `ReviewDocument`
所有 detail 都检查:
- JSON object key 唯一,字符串可以严格编码为 UTF-8,整数和浮点数满足第 4 节范围;
- schema、detail、enum、稳定错误代码、数组顺序、引用位置和计数;
- `summary` / `changes` 没有越过各自的正文暴露边界;
- 状态、`current_kind`、错误阶段、完成阶段和残留候选相容。
`summary``changes` 没有完整阶段正文,reader 不会声称能验证不存在的文本。`full` 另外检查:
- input、current、所有 stage before/after 的码点长度和 SHA-256
- 第一个阶段从 input 开始,相邻阶段首尾相接,最后一个完成阶段等于 current;
- Change span 的原文、位置、哈希、proposal/edit index、冲突和正式报告顺序;
- 用核心共用的精确编辑原语重放每个 Change 批次,结果与 stage after 完全相同;
- 零修改阶段的 before 和 after 完全相同;
- residual edit 的原文、范围、顺序和冲突。
任一步失败都抛出 `ReviewParseError`。顶层错误消息只包含字段路径和契约类别,不包含正文、modifier 参数、reason 或诊断消息。
reader 不调用 Modifier,不修复、截断或猜测损坏结果。
这是只读解析,不是反序列化。返回 dict 不能重新运行 Pipeline、恢复 Python 模型或获得原始 `TransformResult` 的权威身份。
## 8. 兼容策略
`schema_version` 使用 `MAJOR.MINOR`
@@ -247,4 +283,5 @@ array,不根据二元组外形猜成 JSON object
同一 major 的消费者必须忽略未知 object 字段,但必须保持 array 顺序;不得把未知状态当成 `success`。消费者应拒绝自己不
支持的 schema major。
schema `1.0` 是单向生产契约。当前没有官方反序列化器、JSON Schema 文件、历史迁移器或数据库 schema。
schema `1.0` 是单向生产契约。官方 reader 接受同一 major 的已知字段语义,并忽略未知 object 字段的语义;未知状态、
detail、enum 或坐标契约仍会失败。当前没有 `ReviewDocument` 反序列化器、JSON Schema 文件、历史迁移器或数据库 schema。
+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>
+3578
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "mdpolish-reviewer-frontend",
"version": "0.7.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"
}
}
+29
View File
@@ -0,0 +1,29 @@
mdpolish reviewer third-party notices
The bundled browser interface contains the following production dependencies.
Each dependency's complete license text is included at the referenced path.
@codemirror/autocomplete 6.20.3 — MIT — licenses/codemirror__autocomplete.txt
@codemirror/lang-css 6.3.1 — MIT — licenses/codemirror__lang-css.txt
@codemirror/lang-html 6.4.12 — MIT — licenses/codemirror__lang-html.txt
@codemirror/lang-javascript 6.2.5 — MIT — licenses/codemirror__lang-javascript.txt
@codemirror/lang-markdown 6.5.2 — MIT — licenses/codemirror__lang-markdown.txt
@codemirror/language 6.12.4 — MIT — licenses/codemirror__language.txt
@codemirror/lint 6.9.7 — MIT — licenses/codemirror__lint.txt
@codemirror/merge 6.12.2 — MIT — licenses/codemirror__merge.txt
@codemirror/state 6.7.1 — MIT — licenses/codemirror__state.txt
@codemirror/view 6.43.9 — MIT — licenses/codemirror__view.txt
@lezer/common 1.5.2 — MIT — licenses/lezer__common.txt
@lezer/css 1.3.6 — MIT — licenses/lezer__css.txt
@lezer/highlight 1.2.3 — MIT — licenses/lezer__highlight.txt
@lezer/html 1.3.13 — MIT — licenses/lezer__html.txt
@lezer/javascript 1.5.4 — MIT — licenses/lezer__javascript.txt
@lezer/lr 1.4.10 — MIT — licenses/lezer__lr.txt
@lezer/markdown 1.7.2 — MIT — licenses/lezer__markdown.txt
@marijn/find-cluster-break 1.0.4 — MIT — licenses/marijn__find-cluster-break.txt
crelt 1.0.7 — MIT — licenses/crelt.txt
style-mod 4.1.3 — MIT — licenses/style-mod.txt
w3c-keyname 2.2.8 — MIT — licenses/w3c-keyname.txt
react 19.2.8 — MIT — licenses/react.txt
react-dom 19.2.8 — MIT — licenses/react-dom.txt
scheduler 0.27.0 — MIT — licenses/scheduler.txt
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2022 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+19
View File
@@ -0,0 +1,19 @@
Copyright (C) 2020 by Marijn Haverbeke <marijn@haverbeke.berlin>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2020 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2024 by Marijn Haverbeke <marijn@haverbeke.berlin>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19
View File
@@ -0,0 +1,19 @@
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+19
View File
@@ -0,0 +1,19 @@
Copyright (C) 2016 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+381
View File
@@ -0,0 +1,381 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type {
ChangeDetail,
CollectionSummaryResponse,
DocumentComparisonResponse,
ModifierStageResponse,
RunStatus,
} from "../shared/api.js";
import { fetchCollection, fetchDocument, fetchModifierStage } 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"> Modifier </p>;
}
return (
<ol className="change-list">
{changes.map((change) => (
<li
key={`${change.modifier_position}-${change.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_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 [collectionState, setCollectionState] =
useState<AsyncState<CollectionSummaryResponse>>(emptyState);
const [selectedDocument, setSelectedDocument] = useState<string | null>(null);
const [documentState, setDocumentState] = useState<AsyncState<DocumentComparisonResponse>>({
loading: false,
value: null,
error: null,
});
const [selectedModifier, setSelectedModifier] = useState<number | null>(null);
const [stageState, setStageState] = useState<AsyncState<ModifierStageResponse>>({
loading: false,
value: null,
error: null,
});
const [focusRange, setFocusRange] = useState<{ start: number; end: number } | null>(null);
useEffect(() => {
const controller = new AbortController();
fetchCollection(controller.signal)
.then((collection) => {
setCollectionState({ loading: false, value: collection, error: null });
setSelectedDocument(collection.documents[0]?.document_id ?? null);
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setCollectionState({
loading: false,
value: null,
error: error instanceof Error ? error.message : "无法读取评审集合摘要。",
});
}
});
return () => controller.abort();
}, []);
useEffect(() => {
setSelectedModifier(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 || selectedModifier === null) {
setStageState({ loading: false, value: null, error: null });
return undefined;
}
const controller = new AbortController();
setStageState(emptyState());
fetchModifierStage(selectedDocument, selectedModifier, 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 : "无法读取 Modifier 阶段。",
});
}
});
return () => controller.abort();
}, [selectedDocument, selectedModifier]);
const visibleChanges = useMemo(() => {
const document = documentState.value;
if (document === null) {
return [];
}
if (selectedModifier === null) {
return document.changes;
}
return document.changes.filter((change) => change.modifier_position === selectedModifier);
}, [documentState.value, selectedModifier]);
const selectChange = (change: ChangeDetail): void => {
setSelectedModifier(change.modifier_position);
setFocusRange(change.editor_range);
};
if (collectionState.loading) {
return <LoadingPanel />;
}
if (collectionState.error !== null || collectionState.value === null) {
return <ErrorPanel message={collectionState.error ?? "评审集合摘要为空。"} />;
}
const collection = collectionState.value;
const document = documentState.value;
const selectedSummary = collection.documents.find(
(item) => item.document_id === selectedDocument,
);
const selectedStage = stageState.value;
const canCompare =
document?.document.status === "success" && document.current_markdown !== null;
const beforeText = selectedStage?.before_markdown ?? document?.input_markdown ?? "";
const afterText = selectedStage?.after_markdown ?? document?.current_markdown ?? "";
const beforeLabel =
selectedStage === null
? "清洗前"
: `Modifier ${selectedStage.modifier.modifier_position + 1} 执行前`;
const afterLabel =
selectedStage === null
? "清洗后"
: `Modifier ${selectedStage.modifier.modifier_position + 1} 执行后`;
return (
<div className="app-shell">
<header className="topbar">
<div>
<span className="brand-mark">md</span>
<div>
<p className="eyebrow"></p>
<h1>{collection.collection.label}</h1>
</div>
</div>
<div className="run-facts">
<span className={`status status--${collection.collection.status}`}>
{statusLabel(collection.collection.status)}
</span>
<span>{collection.summary.document_count} </span>
<span>{collection.summary.change_count} </span>
</div>
</header>
<div className="layout">
<aside className="sidebar" aria-label="评审导航">
<section>
<div className="section-heading">
<h2></h2>
<span>{collection.documents.length}</span>
</div>
<nav className="document-list" aria-label="文档列表">
{collection.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="modifier-section">
<div className="section-heading">
<h2>Modifier 线</h2>
<button
type="button"
className="text-button"
onClick={() => {
setSelectedModifier(null);
setFocusRange(null);
}}
disabled={selectedModifier === null}
>
</button>
</div>
<ol className="modifier-list">
{(document?.modifiers ?? []).map((modifier) => (
<li key={`${modifier.modifier_position}-${modifier.modifier_id}`}>
<button
type="button"
className={modifier.modifier_position === selectedModifier ? "is-active" : ""}
onClick={() => {
setSelectedModifier(modifier.modifier_position);
setFocusRange(null);
}}
disabled={!canCompare || !modifier.stage_available}
>
<span className="modifier-index">{modifier.modifier_position + 1}</span>
<span>
<strong>{modifier.modifier_id}</strong>
<small>
v{modifier.modifier_version} · {modifier.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" ? (
<div className="diagnostic-panel">
<p className="eyebrow"></p>
<h3>{statusLabel(document.document.status)}</h3>
<p>partial output </p>
{document.errors.map((error) => (
<article key={`${error.modifier_position}-${error.stage}-${error.code}`}>
<strong>{error.diagnostic_type}</strong>
<span>{error.message}</span>
</article>
))}
{document.residual_proposals.map((proposal) => (
<article key={`${proposal.modifier_position}-${proposal.proposal_index}`}>
<strong> {proposal.edits.length} </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>
{selectedModifier === null
? `全部 Modifier · ${visibleChanges.length}`
: `${document.modifiers[selectedModifier]?.modifier_id ?? "Modifier"} · ${
visibleChanges.length
}`}
</h3>
</div>
{selectedStage === null ? null : <p>{selectedStage.modifier.applicability}</p>}
</div>
<ChangeList changes={visibleChanges} onSelect={selectChange} canJump={canCompare} />
</section>
) : null}
</main>
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { markdown } from "@codemirror/lang-markdown";
import { MergeView } from "@codemirror/merge";
import { EditorSelection, EditorState } from "@codemirror/state";
import { drawSelection, 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),
drawSelection(),
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,
});
merge.current = view;
return () => {
view.destroy();
merge.current = null;
};
}, [before, after]);
// 必须在对应阶段文本重建完成后再次聚焦,不能只依赖 focusRange。
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: EditorSelection.range(anchor, head),
effects: EditorView.scrollIntoView(anchor, { y: "center" }),
});
view.a.focus();
}, [focusRange, before, after]);
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>
);
}
+282
View File
@@ -0,0 +1,282 @@
import type {
ApiErrorResponse,
ChangeDetail,
CollectionSummaryResponse,
DocumentComparisonResponse,
DocumentSummary,
ErrorDetail,
ModifierStageResponse,
ModifierSummary,
ResidualProposalDetail,
RunStatus,
} 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 nullableString(value: unknown, label: string): string | null {
return value === null ? null : string(value, label);
}
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 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): RunStatus {
if (value !== "success" && value !== "failed" && value !== "unstable") {
return invalid("status");
}
return value;
}
function documentSummary(value: unknown): DocumentSummary {
const item = record(value, "document summary");
const runStatus = status(item.status);
const currentKind = item.current_kind;
if (currentKind !== "success_output" && currentKind !== "partial_output") {
return invalid("current_kind");
}
if ((runStatus === "success") !== (currentKind === "success_output")) {
return invalid("document status/current_kind");
}
return {
document_id: string(item.document_id, "document_id"),
source_label: string(item.source_label, "source_label"),
status: runStatus,
current_kind: currentKind,
input_sha256: hash(item.input_sha256, "input_sha256"),
current_sha256: hash(item.current_sha256, "current_sha256"),
modifier_count: integer(item.modifier_count, "modifier_count"),
completed_stage_count: integer(item.completed_stage_count, "completed_stage_count"),
change_count: integer(item.change_count, "change_count"),
error_count: integer(item.error_count, "error_count"),
residual_proposal_count: integer(item.residual_proposal_count, "residual_proposal_count"),
};
}
function modifier(value: unknown): ModifierSummary {
const item = record(value, "modifier");
return {
modifier_position: integer(item.modifier_position, "modifier_position"),
modifier_id: string(item.modifier_id, "modifier_id"),
modifier_version: string(item.modifier_version, "modifier_version"),
parameters: item.parameters,
applicability: string(item.applicability, "applicability"),
change_count: integer(item.change_count, "modifier change_count"),
stage_available: boolean(item.stage_available, "stage_available"),
};
}
function range(value: unknown, label: string): { start: number; end: number } {
const item = record(value, label);
const start = integer(item.start, `${label}.start`);
const end = integer(item.end, `${label}.end`);
if (end < start) {
return invalid(label);
}
return { start, end };
}
function change(value: unknown): ChangeDetail {
const item = record(value, "change");
const location = record(item.location, "change location");
return {
modifier_position: integer(item.modifier_position, "change modifier_position"),
modifier_id: string(item.modifier_id, "change modifier_id"),
modifier_version: string(item.modifier_version, "change modifier_version"),
proposal_index: integer(item.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),
},
span: range(item.span, "span"),
editor_range: range(item.editor_range, "editor_range"),
before: string(item.before, "before"),
after: string(item.after, "after"),
before_sha256: hash(item.before_sha256, "before_sha256"),
after_sha256: hash(item.after_sha256, "after_sha256"),
};
}
function runError(value: unknown): ErrorDetail {
const item = record(value, "run error");
return {
code: string(item.code, "error code"),
stage: string(item.stage, "error stage"),
modifier_position: integer(item.modifier_position, "error modifier_position"),
modifier_id: string(item.modifier_id, "error modifier_id"),
modifier_version: string(item.modifier_version, "error modifier_version"),
diagnostic_type: string(item.diagnostic_type, "diagnostic_type"),
message: string(item.message, "error message"),
};
}
function residual(value: unknown): ResidualProposalDetail {
const item = record(value, "residual proposal");
return {
modifier_position: integer(item.modifier_position, "residual modifier_position"),
modifier_id: string(item.modifier_id, "residual modifier_id"),
modifier_version: string(item.modifier_version, "residual modifier_version"),
proposal_index: integer(item.proposal_index, "residual proposal_index"),
snapshot_sha256: hash(item.snapshot_sha256, "residual snapshot_sha256"),
reason: string(item.reason, "residual reason"),
edits: array(item.edits, "residual edits"),
};
}
function parseCollection(value: unknown): CollectionSummaryResponse {
const payload = record(value, "collection response");
if (payload.schema_version !== 1) {
return invalid("collection schema_version");
}
const collection = record(payload.collection, "collection");
const summary = record(payload.summary, "summary");
return {
schema_version: 1,
collection: {
label: string(collection.label, "collection label"),
status: status(collection.status),
},
documents: array(payload.documents, "documents").map(documentSummary),
summary: {
document_count: integer(summary.document_count, "document_count"),
success_count: integer(summary.success_count, "success_count"),
failed_count: integer(summary.failed_count, "failed_count"),
unstable_count: integer(summary.unstable_count, "unstable_count"),
change_count: integer(summary.change_count, "change_count"),
},
};
}
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),
modifiers: array(payload.modifiers, "modifiers").map(modifier),
input_markdown: string(payload.input_markdown, "input_markdown"),
current_markdown: nullableString(payload.current_markdown, "current_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): ModifierStageResponse {
const payload = record(value, "modifier stage response");
if (payload.schema_version !== 1) {
return invalid("modifier stage schema_version");
}
return {
schema_version: 1,
document_id: string(payload.document_id, "document_id"),
modifier: modifier(payload.modifier),
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 fetchCollection(signal?: AbortSignal): Promise<CollectionSummaryResponse> {
return getJson("/api/v1/collection", parseCollection, signal);
}
export function fetchDocument(
documentId: string,
signal?: AbortSignal,
): Promise<DocumentComparisonResponse> {
return getJson(`/api/v1/documents/${encodeURIComponent(documentId)}`, parseDocument, signal);
}
export function fetchModifierStage(
documentId: string,
modifierPosition: number,
signal?: AbortSignal,
): Promise<ModifierStageResponse> {
return getJson(
`/api/v1/documents/${encodeURIComponent(documentId)}/modifiers/${modifierPosition}`,
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>,
);
+539
View File
@@ -0,0 +1,539 @@
: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;
}
.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,
.modifier-list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.document-list button,
.modifier-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,
.modifier-list button:hover:not(:disabled) {
background: #eceae2;
}
.document-list button.is-active,
.modifier-list button.is-active {
background: #e0e8e0;
color: #234b39;
}
.document-list strong,
.modifier-list strong {
display: block;
overflow: hidden;
font-size: 12px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-list small,
.modifier-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;
}
.modifier-list button {
grid-template-columns: 26px minmax(0, 1fr);
gap: 8px;
padding: 8px;
}
.modifier-list button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.modifier-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 {
height: 510px;
}
.diff-host > .cm-mergeView {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
}
.diff-host .cm-mergeViewEditors {
min-height: 100%;
}
.diff-host .cm-editor {
min-width: 0;
}
.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;
}
@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);
}
}
+115
View File
@@ -0,0 +1,115 @@
export type RunStatus = "success" | "failed" | "unstable";
export interface ModifierSummary {
modifier_position: number;
modifier_id: string;
modifier_version: string;
parameters: unknown;
applicability: string;
change_count: number;
stage_available: boolean;
}
export interface DocumentSummary {
document_id: string;
source_label: string;
status: RunStatus;
current_kind: "success_output" | "partial_output";
input_sha256: string;
current_sha256: string;
modifier_count: number;
completed_stage_count: number;
change_count: number;
error_count: number;
residual_proposal_count: number;
}
export interface CollectionSummaryResponse {
schema_version: 1;
collection: {
label: string;
status: RunStatus;
};
documents: DocumentSummary[];
summary: {
document_count: number;
success_count: number;
failed_count: number;
unstable_count: number;
change_count: number;
};
}
export interface ChangeDetail {
modifier_position: number;
modifier_id: string;
modifier_version: string;
proposal_index: number;
edit_index: number;
reason: string;
location: {
line: number;
column: number;
};
span: {
start: number;
end: number;
};
editor_range: {
start: number;
end: number;
};
before: string;
after: string;
before_sha256: string;
after_sha256: string;
}
export interface ErrorDetail {
code: string;
stage: string;
modifier_position: number;
modifier_id: string;
modifier_version: string;
diagnostic_type: string;
message: string;
}
export interface ResidualProposalDetail {
modifier_position: number;
modifier_id: string;
modifier_version: string;
proposal_index: number;
snapshot_sha256: string;
reason: string;
edits: unknown[];
}
export interface DocumentComparisonResponse {
schema_version: 1;
document: DocumentSummary;
modifiers: ModifierSummary[];
input_markdown: string;
current_markdown: string | null;
changes: ChangeDetail[];
errors: ErrorDetail[];
residual_proposals: ResidualProposalDetail[];
}
export interface ModifierStageResponse {
schema_version: 1;
document_id: string;
modifier: ModifierSummary;
before_sha256: string;
after_sha256: string;
before_markdown: string;
after_markdown: string;
changes: ChangeDetail[];
}
export interface ApiErrorResponse {
error: {
code: string;
message: string;
};
}
+235
View File
@@ -0,0 +1,235 @@
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 {
CollectionSummaryResponse,
DocumentComparisonResponse,
ModifierStageResponse,
} 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 modifiers = [
{
modifier_position: 0,
modifier_id: "paper.rule",
modifier_version: "1.0.0",
parameters: [],
applicability: "替换测试单词。",
change_count: 1,
stage_available: true,
},
{
modifier_position: 1,
modifier_id: "paper.zero",
modifier_version: "1.0.0",
parameters: [],
applicability: "不修改当前测试文档。",
change_count: 0,
stage_available: true,
},
];
const documentSummary = {
document_id: "paper",
source_label: "paper.md",
status: "success" as const,
current_kind: "success_output" as const,
input_sha256: "1".repeat(64),
current_sha256: "2".repeat(64),
modifier_count: 2,
completed_stage_count: 2,
change_count: 1,
error_count: 0,
residual_proposal_count: 0,
};
const collectionResponse: CollectionSummaryResponse = {
schema_version: 1,
collection: { label: "合成评审", status: "success" },
documents: [documentSummary],
summary: {
document_count: 1,
success_count: 1,
failed_count: 0,
unstable_count: 0,
change_count: 1,
},
};
const change = {
modifier_position: 0,
modifier_id: "paper.rule",
modifier_version: "1.0.0",
proposal_index: 0,
edit_index: 0,
reason: "替换测试单词",
location: { line: 1, column: 1 },
span: { start: 0, end: 3 },
editor_range: { start: 0, end: 3 },
before: "old",
after: "new",
before_sha256: "1".repeat(64),
after_sha256: "2".repeat(64),
};
const documentResponse: DocumentComparisonResponse = {
schema_version: 1,
document: documentSummary,
modifiers,
input_markdown: "old",
current_markdown: "new",
changes: [change],
errors: [],
residual_proposals: [],
};
const stageResponse: ModifierStageResponse = {
schema_version: 1,
document_id: "paper",
modifier: modifiers[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 documents, Modifier order and the selected 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/collection") {
return Promise.resolve(response(collectionResponse));
}
if (pathname.endsWith("/modifiers/0")) {
return Promise.resolve(response(stageResponse));
}
return Promise.resolve(response(documentResponse));
});
vi.stubGlobal("fetch", fetchMock);
render(<App />);
expect(await screen.findByRole("heading", { name: "合成评审" })).toBeInTheDocument();
expect(await screen.findByTestId("diff-view")).toHaveTextContent("清洗前 / 清洗后");
expect(screen.getByText("paper.rule")).toBeInTheDocument();
expect(screen.getByText("paper.zero")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /paper\.rule/ }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("modifiers/0"), expect.anything());
});
expect(await screen.findByTestId("diff-view")).toHaveTextContent(
"Modifier 1 执行前 / Modifier 1 执行后",
);
});
it("clicks a change through its Modifier stage", async () => {
vi.stubGlobal(
"fetch",
vi.fn((input: string | URL | Request) => {
const pathname =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (pathname === "/api/v1/collection") {
return Promise.resolve(response(collectionResponse));
}
if (pathname.endsWith("/modifiers/0")) {
return Promise.resolve(response(stageResponse));
}
return Promise.resolve(response(documentResponse));
}),
);
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: /替换测试单词/ }));
expect(await screen.findByTestId("diff-view")).toHaveTextContent(
"Modifier 1 执行前 / Modifier 1 执行后",
);
});
it("shows failed diagnostics without a cleaned comparison", async () => {
const failedSummary = {
...documentSummary,
status: "failed" as const,
current_kind: "partial_output" as const,
current_sha256: documentSummary.input_sha256,
completed_stage_count: 0,
change_count: 0,
error_count: 1,
};
const failedCollection: CollectionSummaryResponse = {
...collectionResponse,
collection: { ...collectionResponse.collection, status: "failed" },
documents: [failedSummary],
summary: {
...collectionResponse.summary,
success_count: 0,
failed_count: 1,
change_count: 0,
},
};
const failedDocument: DocumentComparisonResponse = {
schema_version: 1,
document: failedSummary,
modifiers: modifiers.map((modifier) => ({
...modifier,
change_count: 0,
stage_available: false,
})),
input_markdown: "原文",
current_markdown: null,
changes: [],
errors: [
{
code: "run.transform_failed",
stage: "transform",
modifier_position: 0,
modifier_id: "paper.rule",
modifier_version: "1.0.0",
diagnostic_type: "SyntheticError",
message: "测试 Modifier 失败。",
},
],
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/collection" ? failedCollection : failedDocument,
),
);
}),
);
render(<App />);
expect(await screen.findByRole("heading", { name: "失败文档只展示审计证据" })).toBeInTheDocument();
expect(screen.getByText("测试 Modifier 失败。")).toBeInTheDocument();
expect(screen.queryByTestId("diff-view")).not.toBeInTheDocument();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { EditorView } from "@codemirror/view";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DiffView } from "../src/client/DiffView.js";
function leftPaneSelection(): { from: number; to: number } | null {
const pane = document.querySelector(".diff-host .cm-editor");
if (pane === null) {
return null;
}
const view = EditorView.findFromDOM(pane as HTMLElement);
if (view === null) {
return null;
}
const { from, to } = view.state.selection.main;
return { from, to };
}
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();
});
it("keeps long unchanged sections available in the full document view", () => {
const before = Array.from({ length: 30 }, (_, index) => `line ${index + 1}`);
const after = [...before];
after[14] = "changed line 15";
render(
<DiffView
before={before.join("\n")}
after={after.join("\n")}
beforeLabel="清洗前"
afterLabel="清洗后"
/>,
);
expect(document.querySelector(".cm-collapsedLines")).toBeNull();
expect(document.querySelector(".cm-mergeView")).toBeInTheDocument();
});
it("re-applies the focus selection after the compared texts change", () => {
const longText = (mark: string) =>
Array.from({ length: 30 }, (_, index) => (index === 14 ? mark : `line ${index + 1}`)).join("\n");
const { rerender } = render(
<DiffView before={longText("old")} after={longText("new")} beforeLabel="清洗前" afterLabel="清洗后" />,
);
rerender(
<DiffView
before={longText("old")}
after={longText("new")}
beforeLabel="Modifier 1 执行前"
afterLabel="Modifier 1 执行后"
focusRange={{ start: 58, end: 61 }}
/>,
);
rerender(
<DiffView
before={longText("stage before")}
after={longText("stage after")}
beforeLabel="Modifier 2 执行前"
afterLabel="Modifier 2 执行后"
focusRange={{ start: 58, end: 71 }}
/>,
);
expect(
screen.getByRole("region", { name: "Modifier 2 执行前与Modifier 2 执行后对比" }),
).toBeInTheDocument();
expect(leftPaneSelection()).toEqual({ from: 58, to: 71 });
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
import { fetchCollection } 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(fetchCollection()).rejects.toMatchObject({ code: "invalid_response" });
});
it("uses the server error instead of guessing a partial schema", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "unsupported_review_schema",
message: "reviewer 与 JSON schema 不匹配。",
},
}),
{ status: 409, headers: { "Content-Type": "application/json" } },
),
),
),
);
await expect(fetchCollection()).rejects.toMatchObject({
code: "unsupported_review_schema",
message: "reviewer 与 JSON schema 不匹配。",
});
});
});
+6
View File
@@ -0,0 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
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: "../src/mdpolish/_reviewer_static",
emptyOutDir: true,
},
test: {
environment: "jsdom",
setupFiles: "./tests/setup.ts",
css: true,
},
});
+786
View File
@@ -0,0 +1,786 @@
"""正式 review JSON 的只读解析与一致性校验。"""
from __future__ import annotations
from hashlib import sha256
from itertools import pairwise
from json import JSONDecodeError, loads
from math import isfinite
from typing import Never, TypeAlias, cast
from mdpolish.edits import _apply_validated_edits, _ordered_indexed_edits, validate_modifier_batch
from mdpolish.models import DocumentSnapshot, ProposedChange, TextEdit, TextSpan
JsonScalar: TypeAlias = str | int | float | bool | None
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
ReviewProjection: TypeAlias = dict[str, JsonValue]
_SCHEMA_NAME = "mdpolish.review"
_MAX_SAFE_JSON_INTEGER = 2**53 - 1
_DETAILS = frozenset({"summary", "changes", "full"})
_STATUSES = frozenset({"success", "failed", "unstable"})
_CURRENT_KINDS = frozenset({"success_output", "partial_output"})
_BODY_FIELD_NAMES = frozenset(
{
"applicability",
"expected_text",
"markdown",
"message",
"parameters",
"reason",
"replacement",
}
)
_ERROR_CODES = {
"preflight": "run.preflight_failed",
"transform": "run.transform_failed",
"final_review": "run.final_review_failed",
}
_HASH_CONTRACT: dict[str, object] = {
"algorithm": "sha256",
"encoding": "utf-8",
"normalization": "none",
}
_COORDINATE_CONTRACT: dict[str, object] = {
"offset_unit": "unicode_code_point",
"span_index_base": 0,
"span_end": "exclusive",
"location_index_base": 1,
"physical_line_endings": ["lf", "crlf", "cr"],
}
class ReviewParseError(ValueError):
"""机器投影 JSON 不能被安全读取。"""
def _fail(path: str, contract: str) -> Never:
raise ReviewParseError(f"review JSON parsing failed at {path}: {contract}")
def _reject_constant(_value: str) -> Never:
_fail("root", "non-finite numbers are not valid review JSON")
def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
_fail("root", "JSON object keys must be unique")
result[key] = value
return result
def _json_value(value: object, path: str) -> JsonValue:
if value is None or isinstance(value, bool):
return value
if isinstance(value, str):
try:
return value.encode("utf-8", errors="strict").decode("utf-8")
except UnicodeError:
_fail(path, "string must be valid Unicode encodable as UTF-8")
if type(value) is int:
if value < -_MAX_SAFE_JSON_INTEGER or value > _MAX_SAFE_JSON_INTEGER:
_fail(path, "integer is outside the interoperable JSON range")
return value
if isinstance(value, float):
if not isfinite(value):
_fail(path, "float must be finite")
return value
if isinstance(value, list):
return [_json_value(item, f"{path}[{index}]") for index, item in enumerate(value)]
if isinstance(value, dict) and all(isinstance(key, str) for key in value):
return {
cast(str, key): _json_value(item, f"{path}.{key}")
for key, item in value.items()
}
_fail(path, "value is not a supported JSON value")
def _object(value: object, path: str) -> dict[str, object]:
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
_fail(path, "value must be an object")
return cast(dict[str, object], value)
def _array(value: object, path: str) -> list[object]:
if not isinstance(value, list):
_fail(path, "value must be an array")
return cast(list[object], value)
def _string(value: object, path: str, *, nonempty: bool = False) -> str:
if not isinstance(value, str):
_fail(path, "value must be a string")
try:
text = value.encode("utf-8", errors="strict").decode("utf-8")
except UnicodeError:
_fail(path, "string must be valid Unicode encodable as UTF-8")
if nonempty and not text:
_fail(path, "string must not be empty")
return text
def _integer(value: object, path: str, *, minimum: int = 0) -> int:
if type(value) is not int:
_fail(path, "value must be an integer")
if value < minimum or value > _MAX_SAFE_JSON_INTEGER:
_fail(path, "integer is outside the interoperable JSON range")
return value
def _boolean(value: object, path: str) -> bool:
if type(value) is not bool:
_fail(path, "value must be a boolean")
return value
def _sha256(value: object, path: str) -> str:
digest = _string(value, path)
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
_fail(path, "value must be a lowercase SHA-256 digest")
return digest
def _schema_version(value: object) -> str:
version = _string(value, "schema_version", nonempty=True)
parts = version.split(".")
if len(parts) != 2 or parts[0] != "1" or not parts[1].isdigit():
_fail("schema_version", "schema major is not supported")
return version
def _detail(value: object, path: str = "detail") -> str:
detail = _string(value, path, nonempty=True)
if detail not in _DETAILS:
_fail(path, "value must be summary, changes, or full")
return detail
def _require_equal_object(value: object, expected: dict[str, object], path: str) -> None:
item = _object(value, path)
for name, expected_value in expected.items():
if item.get(name) != expected_value:
_fail(path, "contract is not supported")
def _parameter(value: object, path: str) -> JsonValue:
if value is None or isinstance(value, bool):
return value
if isinstance(value, str):
return _string(value, path)
if type(value) is int:
if value < -_MAX_SAFE_JSON_INTEGER or value > _MAX_SAFE_JSON_INTEGER:
_fail(path, "integer is outside the interoperable JSON range")
return value
if isinstance(value, float):
if not isfinite(value):
_fail(path, "float must be finite")
return value
if isinstance(value, list):
return [_parameter(item, f"{path}[{index}]") for index, item in enumerate(value)]
_fail(path, "parameter contains an unsupported value")
def _parameters(value: object, path: str) -> None:
seen: set[str] = set()
for index, pair_value in enumerate(_array(value, path)):
pair_path = f"{path}[{index}]"
pair = _array(pair_value, pair_path)
if len(pair) != 2:
_fail(pair_path, "parameter entry must contain exactly two values")
key = _string(pair[0], f"{pair_path}[0]", nonempty=True)
if key in seen:
_fail(pair_path, "parameter keys must be unique")
seen.add(key)
_parameter(pair[1], f"{pair_path}[1]")
def _text_summary(
value: object,
*,
path: str,
detail: str,
) -> tuple[str, int, str | None]:
item = _object(value, path)
digest = _sha256(item.get("sha256"), f"{path}.sha256")
length = _integer(item.get("code_point_length"), f"{path}.code_point_length")
if detail != "full":
if "markdown" in item:
_fail(f"{path}.markdown", "field is not allowed at this detail")
return digest, length, None
markdown = _string(item.get("markdown"), f"{path}.markdown")
if len(markdown) != length:
_fail(path, "code point length does not match markdown")
if sha256(markdown.encode("utf-8")).hexdigest() != digest:
_fail(path, "SHA-256 does not match markdown")
return digest, length, markdown
def _location(markdown: str, offset: int) -> tuple[int, int]:
line = 1
line_start = 0
index = 0
while index < offset:
character = markdown[index]
if character == "\r":
if index + 1 < len(markdown) and markdown[index + 1] == "\n":
if index + 2 <= offset:
line += 1
line_start = index + 2
index += 2
continue
else:
line += 1
line_start = index + 1
elif character == "\n":
line += 1
line_start = index + 1
index += 1
return line, offset - line_start + 1
def _span(value: object, *, path: str, markdown_length: int | None) -> tuple[int, int]:
item = _object(value, path)
start = _integer(item.get("start"), f"{path}.start")
end = _integer(item.get("end"), f"{path}.end")
if end < start:
_fail(path, "span end must not precede start")
if markdown_length is not None and end > markdown_length:
_fail(path, "span is outside its snapshot")
return start, end
def _modifier(value: object, *, position: int, detail: str) -> tuple[str, str]:
path = f"modifiers[{position}]"
item = _object(value, path)
actual_position = _integer(item.get("position"), f"{path}.position")
if actual_position != position:
_fail(path, "modifier positions must be contiguous")
modifier_id = _string(item.get("modifier_id"), f"{path}.modifier_id", nonempty=True)
version = _string(item.get("version"), f"{path}.version", nonempty=True)
if detail == "summary":
if "parameters" in item or "applicability" in item:
_fail(path, "body-bearing modifier fields are not allowed in summary")
else:
_parameters(item.get("parameters"), f"{path}.parameters")
_string(item.get("applicability"), f"{path}.applicability", nonempty=True)
return modifier_id, version
def _change(
value: object,
*,
path: str,
before_sha256: str,
after_sha256: str,
before_markdown: str | None,
) -> tuple[int, int, str, TextEdit | None]:
item = _object(value, path)
proposal_index = _integer(item.get("proposal_index"), f"{path}.proposal_index")
edit_index = _integer(item.get("edit_index"), f"{path}.edit_index")
reason = _string(item.get("reason"), f"{path}.reason", nonempty=True)
start, end = _span(
item.get("span"),
path=f"{path}.span",
markdown_length=None if before_markdown is None else len(before_markdown),
)
before = _string(item.get("before"), f"{path}.before")
after = _string(item.get("after"), f"{path}.after")
if len(before) != end - start or before == after:
_fail(path, "change text does not satisfy its span contract")
if _sha256(item.get("before_sha256"), f"{path}.before_sha256") != before_sha256:
_fail(path, "before_sha256 does not match its stage")
if _sha256(item.get("after_sha256"), f"{path}.after_sha256") != after_sha256:
_fail(path, "after_sha256 does not match its stage")
location = _object(item.get("location"), f"{path}.location")
line = _integer(location.get("line"), f"{path}.location.line", minimum=1)
column = _integer(location.get("column"), f"{path}.location.column", minimum=1)
if before_markdown is None:
return proposal_index, edit_index, reason, None
if before_markdown[start:end] != before:
_fail(path, "before text does not match its stage snapshot")
if (line, column) != _location(before_markdown, start):
_fail(path, "location does not match span.start")
try:
edit = TextEdit(
snapshot_sha256=before_sha256,
span=TextSpan(start, end),
expected_text=before,
replacement=after,
)
except (TypeError, ValueError):
_fail(path, "change cannot be reconstructed as an exact edit")
return proposal_index, edit_index, reason, edit
def _validate_stage_changes(
values: object,
*,
path: str,
before_sha256: str,
after_sha256: str,
before_markdown: str | None,
after_markdown: str | None,
) -> int:
change_values = _array(values, path)
records = [
_change(
value,
path=f"{path}[{index}]",
before_sha256=before_sha256,
after_sha256=after_sha256,
before_markdown=before_markdown,
)
for index, value in enumerate(change_values)
]
seen_refs: set[tuple[int, int]] = set()
reasons: dict[int, str] = {}
edits_by_proposal: dict[int, dict[int, TextEdit]] = {}
actual_order: list[tuple[int, int]] = []
for proposal_index, edit_index, reason, edit in records:
reference = (proposal_index, edit_index)
if reference in seen_refs:
_fail(path, "change proposal/edit references must be unique")
seen_refs.add(reference)
actual_order.append(reference)
previous_reason = reasons.setdefault(proposal_index, reason)
if previous_reason != reason:
_fail(path, "one proposal must use one reason")
if edit is not None:
edits_by_proposal.setdefault(proposal_index, {})[edit_index] = edit
proposal_indexes = sorted(reasons)
if proposal_indexes != list(range(len(proposal_indexes))):
_fail(path, "proposal indexes must be contiguous")
for proposal_index in proposal_indexes:
edit_indexes = sorted(edit_index for current, edit_index in seen_refs if current == proposal_index)
if edit_indexes != list(range(len(edit_indexes))):
_fail(path, "edit indexes must be contiguous within a proposal")
if before_markdown is None or after_markdown is None:
order_keys = []
for index, value in enumerate(change_values):
item = _object(value, f"{path}[{index}]")
span_item = _object(item.get("span"), f"{path}[{index}].span")
order_keys.append(
(
_integer(span_item.get("start"), f"{path}[{index}].span.start"),
_integer(span_item.get("end"), f"{path}[{index}].span.end"),
records[index][0],
records[index][1],
)
)
if order_keys != sorted(order_keys):
_fail(path, "changes are not in canonical report order")
return len(records)
if not records:
if before_markdown != after_markdown or before_sha256 != after_sha256:
_fail(path, "a zero-change stage must preserve its snapshot")
return 0
proposals: list[ProposedChange] = []
for proposal_index in proposal_indexes:
proposal_edits = edits_by_proposal[proposal_index]
try:
proposals.append(
ProposedChange(
snapshot_sha256=before_sha256,
reason=reasons[proposal_index],
edits=tuple(proposal_edits[index] for index in range(len(proposal_edits))),
)
)
except (TypeError, ValueError):
_fail(path, "stored proposal metadata is invalid")
snapshot = DocumentSnapshot(before_markdown)
try:
indexed_edits = validate_modifier_batch(snapshot, tuple(proposals))
except (TypeError, ValueError):
_fail(path, "stored change batch does not satisfy the edit contract")
expected_order = [
(item.proposal_index, item.edit_index)
for item in _ordered_indexed_edits(indexed_edits)
]
if actual_order != expected_order:
_fail(path, "changes are not in canonical report order")
updated = _apply_validated_edits(snapshot, indexed_edits)
if updated.markdown != after_markdown or updated.sha256 != after_sha256:
_fail(path, "replayed changes do not produce stage.after")
return len(records)
def _stage(
value: object,
*,
position: int,
modifier_count: int,
detail: str,
) -> tuple[str, int, str | None, str, int, str | None, int]:
path = f"stages[{position}]"
item = _object(value, path)
modifier_position = _integer(item.get("modifier_position"), f"{path}.modifier_position")
if modifier_position != position or modifier_position >= modifier_count:
_fail(path, "stage position does not reference the expected modifier")
before_sha256, before_length, before_markdown = _text_summary(
item.get("before"),
path=f"{path}.before",
detail=detail,
)
after_sha256, after_length, after_markdown = _text_summary(
item.get("after"),
path=f"{path}.after",
detail=detail,
)
declared_count = _integer(item.get("change_count"), f"{path}.change_count")
if declared_count == 0 and (
before_sha256 != after_sha256 or before_length != after_length
):
_fail(path, "a zero-change stage must preserve its snapshot")
if detail == "summary":
if "changes" in item:
_fail(f"{path}.changes", "field is not allowed in summary")
actual_count = declared_count
else:
actual_count = _validate_stage_changes(
item.get("changes"),
path=f"{path}.changes",
before_sha256=before_sha256,
after_sha256=after_sha256,
before_markdown=before_markdown,
after_markdown=after_markdown,
)
if declared_count != actual_count:
_fail(path, "change_count does not match changes")
return (
before_sha256,
before_length,
before_markdown,
after_sha256,
after_length,
after_markdown,
actual_count,
)
def _error(
value: object,
*,
index: int,
modifiers: list[tuple[str, str]],
detail: str,
) -> tuple[str, int]:
path = f"errors[{index}]"
item = _object(value, path)
stage = _string(item.get("stage"), f"{path}.stage", nonempty=True)
expected_code = _ERROR_CODES.get(stage)
if expected_code is None or _string(item.get("code"), f"{path}.code", nonempty=True) != expected_code:
_fail(path, "error code and stage are not supported")
position = _integer(item.get("modifier_position"), f"{path}.modifier_position")
modifier_id = _string(item.get("modifier_id"), f"{path}.modifier_id", nonempty=True)
version = _string(item.get("modifier_version"), f"{path}.modifier_version", nonempty=True)
if position < len(modifiers):
if (modifier_id, version) != modifiers[position]:
_fail(path, "error identity does not match its modifier")
elif position != len(modifiers) or stage != "preflight":
_fail(path, "error position is outside modifier metadata")
if detail == "summary":
if "diagnostic_type" in item or "message" in item:
_fail(path, "diagnostic fields are not allowed in summary")
else:
_string(item.get("diagnostic_type"), f"{path}.diagnostic_type", nonempty=True)
_string(item.get("message"), f"{path}.message", nonempty=True)
return stage, position
def _residual(
value: object,
*,
index: int,
modifiers: list[tuple[str, str]],
current_sha256: str,
current_markdown: str | None,
) -> tuple[int, int, ProposedChange | None]:
path = f"residual_proposals[{index}]"
item = _object(value, path)
position = _integer(item.get("modifier_position"), f"{path}.modifier_position")
if position >= len(modifiers):
_fail(path, "residual position is outside modifier metadata")
proposal_index = _integer(item.get("proposal_index"), f"{path}.proposal_index")
snapshot_sha256 = _sha256(item.get("snapshot_sha256"), f"{path}.snapshot_sha256")
if snapshot_sha256 != current_sha256:
_fail(path, "residual proposal does not target current")
reason = _string(item.get("reason"), f"{path}.reason", nonempty=True)
edits: list[TextEdit] = []
edit_values = _array(item.get("edits"), f"{path}.edits")
if not edit_values:
_fail(f"{path}.edits", "residual proposal must contain edits")
for edit_index, value in enumerate(edit_values):
edit_path = f"{path}.edits[{edit_index}]"
edit = _object(value, edit_path)
if _integer(edit.get("edit_index"), f"{edit_path}.edit_index") != edit_index:
_fail(edit_path, "edit indexes must be contiguous")
start, end = _span(
edit.get("span"),
path=f"{edit_path}.span",
markdown_length=None if current_markdown is None else len(current_markdown),
)
expected_text = _string(edit.get("expected_text"), f"{edit_path}.expected_text")
replacement = _string(edit.get("replacement"), f"{edit_path}.replacement")
if len(expected_text) != end - start or expected_text == replacement:
_fail(edit_path, "residual edit does not satisfy its span contract")
if current_markdown is not None and current_markdown[start:end] != expected_text:
_fail(edit_path, "expected_text does not match current")
try:
edits.append(
TextEdit(
snapshot_sha256=current_sha256,
span=TextSpan(start, end),
expected_text=expected_text,
replacement=replacement,
)
)
except (TypeError, ValueError):
_fail(edit_path, "residual edit is malformed")
if current_markdown is None:
return position, proposal_index, None
try:
proposal = ProposedChange(
snapshot_sha256=current_sha256,
reason=reason,
edits=tuple(edits),
)
except (TypeError, ValueError):
_fail(path, "residual proposal is malformed")
return position, proposal_index, proposal
def _require_no_body_fields(value: object, path: str = "root") -> None:
if isinstance(value, dict):
for key, nested in value.items():
if key in _BODY_FIELD_NAMES:
_fail(path, "summary contains a body-bearing field")
_require_no_body_fields(nested, f"{path}.{key}")
elif isinstance(value, list):
for index, nested in enumerate(value):
_require_no_body_fields(nested, f"{path}[{index}]")
def _require_no_markdown_fields(value: object, path: str = "root") -> None:
if isinstance(value, dict):
for key, nested in value.items():
if key == "markdown":
_fail(path, "changes detail contains a full markdown field")
_require_no_markdown_fields(nested, f"{path}.{key}")
elif isinstance(value, list):
for index, nested in enumerate(value):
_require_no_markdown_fields(nested, f"{path}[{index}]")
def _validate_projection(payload: dict[str, object], expected_detail: str | None) -> None:
if _string(payload.get("schema_name"), "schema_name", nonempty=True) != _SCHEMA_NAME:
_fail("schema_name", "schema name is not supported")
_schema_version(payload.get("schema_version"))
detail = _detail(payload.get("detail"))
if expected_detail is not None and detail != expected_detail:
_fail("detail", "detail does not match the requested value")
status = _string(payload.get("status"), "status", nonempty=True)
if status not in _STATUSES:
_fail("status", "status is not supported")
current_kind = _string(payload.get("current_kind"), "current_kind", nonempty=True)
if current_kind not in _CURRENT_KINDS:
_fail("current_kind", "current kind is not supported")
if (status == "success") != (current_kind == "success_output"):
_fail("current_kind", "current kind does not match status")
stages_complete = _boolean(payload.get("stages_complete"), "stages_complete")
_require_equal_object(payload.get("hash_contract"), _HASH_CONTRACT, "hash_contract")
_require_equal_object(
payload.get("coordinate_contract"),
_COORDINATE_CONTRACT,
"coordinate_contract",
)
input_sha256, input_length, input_markdown = _text_summary(
payload.get("input"),
path="input",
detail=detail,
)
current_sha256, current_length, current_markdown = _text_summary(
payload.get("current"),
path="current",
detail=detail,
)
modifiers = [
_modifier(value, position=position, detail=detail)
for position, value in enumerate(_array(payload.get("modifiers"), "modifiers"))
]
if len({modifier_id for modifier_id, _version in modifiers}) != len(modifiers):
_fail("modifiers", "modifier IDs must be unique")
stage_values = _array(payload.get("stages"), "stages")
previous_sha256 = input_sha256
previous_length = input_length
previous_markdown = input_markdown
change_count = 0
for position, value in enumerate(stage_values):
(
before_sha256,
before_length,
before_markdown,
after_sha256,
after_length,
after_markdown,
stage_change_count,
) = _stage(
value,
position=position,
modifier_count=len(modifiers),
detail=detail,
)
if before_sha256 != previous_sha256 or before_length != previous_length:
_fail(f"stages[{position}]", "stage does not start at the previous snapshot")
if detail == "full" and before_markdown != previous_markdown:
_fail(f"stages[{position}]", "stage markdown does not start at the previous snapshot")
previous_sha256 = after_sha256
previous_length = after_length
previous_markdown = after_markdown
change_count += stage_change_count
if previous_sha256 != current_sha256 or previous_length != current_length:
_fail("stages", "completed stages do not end at current")
if detail == "full" and previous_markdown != current_markdown:
_fail("stages", "completed stage markdown does not end at current")
if len(stage_values) > len(modifiers):
_fail("stages", "completed stage count exceeds modifier count")
if stages_complete and len(stage_values) != len(modifiers):
_fail("stages_complete", "complete stages must cover every modifier")
errors = [
_error(value, index=index, modifiers=modifiers, detail=detail)
for index, value in enumerate(_array(payload.get("errors"), "errors"))
]
residuals: list[tuple[int, int, ProposedChange | None]] = []
if detail == "summary":
if "residual_proposals" in payload:
_fail("residual_proposals", "field is not allowed in summary")
else:
residuals = [
_residual(
value,
index=index,
modifiers=modifiers,
current_sha256=current_sha256,
current_markdown=current_markdown,
)
for index, value in enumerate(
_array(payload.get("residual_proposals"), "residual_proposals")
)
]
orders = [(position, proposal_index) for position, proposal_index, _proposal in residuals]
if any(left >= right for left, right in pairwise(orders)):
_fail("residual_proposals", "residual proposals are not in deterministic order")
for position in {item[0] for item in residuals}:
indexes = [item[1] for item in residuals if item[0] == position]
if indexes != list(range(len(indexes))):
_fail("residual_proposals", "proposal indexes must be contiguous per modifier")
if current_markdown is not None:
snapshot = DocumentSnapshot(current_markdown)
for position in {item[0] for item in residuals}:
proposals = tuple(
cast(ProposedChange, proposal)
for current_position, _index, proposal in residuals
if current_position == position
)
try:
validate_modifier_batch(snapshot, proposals)
except (TypeError, ValueError):
_fail("residual_proposals", "residual edits do not satisfy the edit contract")
counts = _object(payload.get("counts"), "counts")
residual_count = (
len(residuals)
if detail != "summary"
else _integer(counts.get("residual_proposal_count"), "counts.residual_proposal_count")
)
expected_counts = {
"modifier_count": len(modifiers),
"completed_stage_count": len(stage_values),
"change_count": change_count,
"error_count": len(errors),
"residual_proposal_count": residual_count,
}
for name, expected in expected_counts.items():
if _integer(counts.get(name), f"counts.{name}") != expected:
_fail(f"counts.{name}", "count does not match its authoritative array")
error_stages = {stage for stage, _position in errors}
error_positions = [position for _stage_name, position in errors]
residual_positions = {position for position, _index, _proposal in residuals}
if status == "success":
if errors or residual_count != 0 or not stages_complete:
_fail("status", "success fields are inconsistent")
elif status == "unstable":
if errors or residual_count == 0 or not stages_complete:
_fail("status", "unstable fields are inconsistent")
else:
if not errors or len(error_stages) != 1:
_fail("status", "failed result must contain one error stage")
error_stage = next(iter(error_stages))
if error_stage == "preflight":
if len(errors) != 1 or stage_values or residual_count != 0 or stages_complete:
_fail("errors", "preflight failure fields are inconsistent")
if input_sha256 != current_sha256 or input_length != current_length:
_fail("current", "preflight failure must preserve input")
if detail == "full" and input_markdown != current_markdown:
_fail("current", "preflight failure must preserve input markdown")
elif error_stage == "transform":
if (
len(errors) != 1
or errors[0][1] != len(stage_values)
or errors[0][1] >= len(modifiers)
or residual_count != 0
or stages_complete
):
_fail("errors", "transform failure fields are inconsistent")
else:
if not stages_complete:
_fail("errors", "final review failure requires complete transform stages")
if any(left >= right for left, right in pairwise(error_positions)):
_fail("errors", "final review errors are not in modifier order")
if residual_positions.intersection(error_positions):
_fail("errors", "one modifier cannot have both an error and residual proposals")
if detail == "summary":
_require_no_body_fields(payload)
elif detail == "changes":
_require_no_markdown_fields(payload)
def parse_json_report(report: object, *, expected_detail: str | None = None) -> ReviewProjection:
"""解析并校验一份正式机器投影, 不恢复内部 ReviewDocument。"""
if not isinstance(report, str):
_fail("report", "value must be a string")
resolved_expected = None if expected_detail is None else _detail(expected_detail, "expected_detail")
try:
value = cast(
object,
loads(
report,
object_pairs_hook=_unique_object,
parse_constant=_reject_constant,
),
)
_json_value(value, "root")
payload = _object(value, "root")
_validate_projection(payload, resolved_expected)
return cast(ReviewProjection, payload)
except ReviewParseError:
raise
except (JSONDecodeError, RecursionError, UnicodeError, ValueError, TypeError):
raise ReviewParseError("review JSON parsing failed at root: malformed JSON") from None
@@ -0,0 +1,29 @@
mdpolish reviewer third-party notices
The bundled browser interface contains the following production dependencies.
Each dependency's complete license text is included at the referenced path.
@codemirror/autocomplete 6.20.3 — MIT — licenses/codemirror__autocomplete.txt
@codemirror/lang-css 6.3.1 — MIT — licenses/codemirror__lang-css.txt
@codemirror/lang-html 6.4.12 — MIT — licenses/codemirror__lang-html.txt
@codemirror/lang-javascript 6.2.5 — MIT — licenses/codemirror__lang-javascript.txt
@codemirror/lang-markdown 6.5.2 — MIT — licenses/codemirror__lang-markdown.txt
@codemirror/language 6.12.4 — MIT — licenses/codemirror__language.txt
@codemirror/lint 6.9.7 — MIT — licenses/codemirror__lint.txt
@codemirror/merge 6.12.2 — MIT — licenses/codemirror__merge.txt
@codemirror/state 6.7.1 — MIT — licenses/codemirror__state.txt
@codemirror/view 6.43.9 — MIT — licenses/codemirror__view.txt
@lezer/common 1.5.2 — MIT — licenses/lezer__common.txt
@lezer/css 1.3.6 — MIT — licenses/lezer__css.txt
@lezer/highlight 1.2.3 — MIT — licenses/lezer__highlight.txt
@lezer/html 1.3.13 — MIT — licenses/lezer__html.txt
@lezer/javascript 1.5.4 — MIT — licenses/lezer__javascript.txt
@lezer/lr 1.4.10 — MIT — licenses/lezer__lr.txt
@lezer/markdown 1.7.2 — MIT — licenses/lezer__markdown.txt
@marijn/find-cluster-break 1.0.4 — MIT — licenses/marijn__find-cluster-break.txt
crelt 1.0.7 — MIT — licenses/crelt.txt
style-mod 4.1.3 — MIT — licenses/style-mod.txt
w3c-keyname 2.2.8 — MIT — licenses/w3c-keyname.txt
react 19.2.8 — MIT — licenses/react.txt
react-dom 19.2.8 — MIT — licenses/react-dom.txt
scheduler 0.27.0 — MIT — licenses/scheduler.txt
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
<!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>
<script type="module" crossorigin src="/assets/index--AXWuvPQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ufmw6rrd.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2022 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,19 @@
Copyright (C) 2020 by Marijn Haverbeke <marijn@haverbeke.berlin>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2020 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2024 by Marijn Haverbeke <marijn@haverbeke.berlin>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,19 @@
Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,19 @@
Copyright (C) 2016 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+21
View File
@@ -10,6 +10,8 @@ from json import dumps
from math import isfinite
from typing import Never, TypeAlias
from mdpolish._review_json import ReviewParseError as ReviewParseError
from mdpolish._review_json import parse_json_report as _parse_json_report
from mdpolish.edits import _apply_validated_edits, _ordered_indexed_edits, validate_modifier_batch
from mdpolish.models import (
Change,
@@ -1030,6 +1032,25 @@ def render_json_report(
)
def parse_json_report(
report: str,
*,
expected_detail: ReviewDetail | str | None = None,
) -> ReviewProjection:
"""解析并校验正式 review JSON, 但不恢复内部 ReviewDocument。"""
if expected_detail is None:
resolved_detail = None
else:
try:
resolved_detail = ReviewDetail(expected_detail).value
except (TypeError, ValueError):
raise ReviewParseError(
"review JSON parsing failed at expected_detail: "
"value must be summary, changes, or full"
) from None
return _parse_json_report(report, expected_detail=resolved_detail)
def _maximum_run(text: str, character: str) -> int:
longest = 0
current = 0
+615
View File
@@ -0,0 +1,615 @@
"""把一个 full review JSON 目录作为本机只读评审页面提供。"""
from __future__ import annotations
import argparse
import json
import mimetypes
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Never, TypeAlias, cast
from urllib.parse import unquote, urlsplit
from mdpolish.review import ReviewParseError, parse_json_report
JsonValue: TypeAlias = bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] | None
JsonObject: TypeAlias = dict[str, JsonValue]
_REVIEW_SUFFIX = ".review.json"
_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 ReviewDataError(ValueError):
"""评审文件、路径或请求不满足本地 viewer 契约。"""
def __init__(self, code: str, message: str, http_status: int = 422) -> None:
super().__init__(message)
self.code = code
self.http_status = http_status
def _fail(code: str, message: str, http_status: int = 422) -> Never:
raise ReviewDataError(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_review", f"{label} 必须是 JSON 对象。")
return cast(dict[str, object], value)
def _array(value: object, label: str) -> list[object]:
if not isinstance(value, list):
_fail("invalid_review", f"{label} 必须是数组。")
return cast(list[object], value)
def _string(value: object, label: str, *, allow_empty: bool = False) -> str:
if not isinstance(value, str) or (not allow_empty and not value):
_fail("invalid_review", f"{label} 必须是字符串。")
return value
def _integer(value: object, label: str) -> int:
if type(value) is not int or value < 0:
_fail("invalid_review", f"{label} 必须是非负整数。")
return value
def _boolean(value: object, label: str) -> bool:
if type(value) is not bool:
_fail("invalid_review", f"{label} 必须是布尔值。")
return value
def _json_value(value: object, label: str) -> JsonValue:
if value is None or isinstance(value, str | bool):
return value
if isinstance(value, int | float) and not isinstance(value, 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_review", f"{label} 包含不支持的 JSON 值。")
def _utf16_offset(markdown: str, code_point_offset: int) -> int:
"""把 Python 码点下标转换为 CodeMirror 使用的 UTF-16 code unit。"""
if code_point_offset < 0 or code_point_offset > len(markdown):
_fail("invalid_review", "Change span 超出阶段文本。")
return len(markdown[:code_point_offset].encode("utf-16-le")) // 2
def _read_report(path: Path) -> JsonObject:
try:
content = path.read_bytes()
except OSError:
_fail("missing_review", "无法读取评审 JSON。")
if content.startswith(b"\xef\xbb\xbf"):
_fail("invalid_review", "评审 JSON 不能包含 UTF-8 BOM。")
try:
report = content.decode("utf-8", errors="strict")
except UnicodeDecodeError:
_fail("invalid_review", "评审 JSON 不是严格 UTF-8。")
try:
return parse_json_report(report, expected_detail="full")
except ReviewParseError as error:
raise ReviewDataError("invalid_review", str(error)) from error
def _modifier_view(value: object, expected_position: int) -> JsonObject:
item = _object(value, f"modifiers[{expected_position}]")
position = _integer(item.get("position"), "modifier.position")
if position != expected_position:
_fail("invalid_review", "Modifier 位置不连续。")
return {
"modifier_position": position,
"modifier_id": _string(item.get("modifier_id"), "modifier.modifier_id"),
"modifier_version": _string(item.get("version"), "modifier.version"),
"parameters": _json_value(item.get("parameters"), "modifier.parameters"),
"applicability": _string(item.get("applicability"), "modifier.applicability"),
}
def _change_view(
value: object,
*,
modifier: JsonObject,
before_markdown: str,
) -> JsonObject:
item = _object(value, "change")
span = _object(item.get("span"), "change.span")
start = _integer(span.get("start"), "change.span.start")
end = _integer(span.get("end"), "change.span.end")
location = _object(item.get("location"), "change.location")
return {
"modifier_position": modifier["modifier_position"],
"modifier_id": modifier["modifier_id"],
"modifier_version": modifier["modifier_version"],
"proposal_index": _integer(item.get("proposal_index"), "change.proposal_index"),
"edit_index": _integer(item.get("edit_index"), "change.edit_index"),
"reason": _string(item.get("reason"), "change.reason"),
"location": {
"line": _integer(location.get("line"), "change.location.line"),
"column": _integer(location.get("column"), "change.location.column"),
},
"span": {"start": start, "end": end},
"editor_range": {
"start": _utf16_offset(before_markdown, start),
"end": _utf16_offset(before_markdown, end),
},
"before": _string(item.get("before"), "change.before", allow_empty=True),
"after": _string(item.get("after"), "change.after", allow_empty=True),
"before_sha256": _string(item.get("before_sha256"), "change.before_sha256"),
"after_sha256": _string(item.get("after_sha256"), "change.after_sha256"),
}
def _residual_view(value: object, modifiers: tuple[JsonObject, ...]) -> JsonObject:
item = _object(value, "residual proposal")
position = _integer(item.get("modifier_position"), "residual.modifier_position")
if position >= len(modifiers):
_fail("invalid_review", "残留候选没有对应的 Modifier。")
modifier = modifiers[position]
return {
"modifier_position": position,
"modifier_id": modifier["modifier_id"],
"modifier_version": modifier["modifier_version"],
"proposal_index": _integer(item.get("proposal_index"), "residual.proposal_index"),
"snapshot_sha256": _string(item.get("snapshot_sha256"), "residual.snapshot_sha256"),
"reason": _string(item.get("reason"), "residual.reason"),
"edits": _json_value(item.get("edits"), "residual.edits"),
}
@dataclass(frozen=True, slots=True)
class ReviewRecord:
"""一篇已经验证且适合提供给页面的评审文档。"""
document_id: str
source_label: str
status: str
current_kind: str
input_sha256: str
current_sha256: str
input_markdown: str
current_markdown: str
modifiers: tuple[JsonObject, ...]
stages: tuple[JsonObject, ...]
stages_complete: bool
changes: tuple[JsonObject, ...]
errors: tuple[JsonObject, ...]
residual_proposals: tuple[JsonObject, ...]
@property
def summary(self) -> JsonObject:
return {
"document_id": self.document_id,
"source_label": self.source_label,
"status": self.status,
"current_kind": self.current_kind,
"input_sha256": self.input_sha256,
"current_sha256": self.current_sha256,
"modifier_count": len(self.modifiers),
"completed_stage_count": len(self.stages),
"change_count": len(self.changes),
"error_count": len(self.errors),
"residual_proposal_count": len(self.residual_proposals),
}
def modifier_summaries(self) -> list[JsonValue]:
stage_counts = {
cast(int, stage["modifier_position"]): cast(int, stage["change_count"])
for stage in self.stages
}
return [
{
**modifier,
"change_count": stage_counts.get(cast(int, modifier["modifier_position"]), 0),
"stage_available": cast(int, modifier["modifier_position"]) < len(self.stages),
}
for modifier in self.modifiers
]
def document_response(self) -> JsonObject:
return {
"schema_version": 1,
"document": self.summary,
"modifiers": self.modifier_summaries(),
"input_markdown": self.input_markdown,
# 非成功文档只有 partial output, 不能在页面上命名为清洗结果。
"current_markdown": self.current_markdown if self.status == "success" else None,
"changes": list(self.changes),
"errors": list(self.errors),
"residual_proposals": list(self.residual_proposals),
}
def stage_response(self, modifier_position: int) -> JsonObject:
if self.status != "success":
_fail("stage_unavailable", "只有 success 文档可以查看完整 Modifier 阶段。", 409)
if modifier_position < 0 or modifier_position >= len(self.stages):
_fail("unknown_modifier", "Modifier 位置不存在。", 404)
stage = self.stages[modifier_position]
return {
"schema_version": 1,
"document_id": self.document_id,
"modifier": self.modifier_summaries()[modifier_position],
"before_sha256": stage["before_sha256"],
"after_sha256": stage["after_sha256"],
"before_markdown": stage["before_markdown"],
"after_markdown": stage["after_markdown"],
"changes": stage["changes"],
}
def _review_record(path: Path) -> ReviewRecord:
payload = _read_report(path)
status = _string(payload.get("status"), "status")
current_kind = _string(payload.get("current_kind"), "current_kind")
stages_complete = _boolean(payload.get("stages_complete"), "stages_complete")
input_item = _object(payload.get("input"), "input")
current_item = _object(payload.get("current"), "current")
input_sha256 = _string(input_item.get("sha256"), "input.sha256")
current_sha256 = _string(current_item.get("sha256"), "current.sha256")
input_markdown = _string(input_item.get("markdown"), "input.markdown", allow_empty=True)
current_markdown = _string(current_item.get("markdown"), "current.markdown", allow_empty=True)
modifier_values = _array(payload.get("modifiers"), "modifiers")
modifiers = tuple(_modifier_view(value, position) for position, value in enumerate(modifier_values))
stages: list[JsonObject] = []
changes: list[JsonObject] = []
for position, value in enumerate(_array(payload.get("stages"), "stages")):
item = _object(value, f"stages[{position}]")
before = _object(item.get("before"), f"stages[{position}].before")
after = _object(item.get("after"), f"stages[{position}].after")
before_markdown = _string(
before.get("markdown"),
f"stages[{position}].before.markdown",
allow_empty=True,
)
after_markdown = _string(
after.get("markdown"),
f"stages[{position}].after.markdown",
allow_empty=True,
)
stage_changes = tuple(
_change_view(
change,
modifier=modifiers[position],
before_markdown=before_markdown,
)
for change in _array(item.get("changes"), f"stages[{position}].changes")
)
stage: JsonObject = {
"modifier_position": position,
"before_sha256": _string(before.get("sha256"), "stage.before.sha256"),
"after_sha256": _string(after.get("sha256"), "stage.after.sha256"),
"before_markdown": before_markdown,
"after_markdown": after_markdown,
"change_count": len(stage_changes),
"changes": list(stage_changes),
}
stages.append(stage)
changes.extend(stage_changes)
errors = tuple(
cast(JsonObject, _json_value(value, "error"))
for value in _array(payload.get("errors"), "errors")
)
residuals = tuple(
_residual_view(value, modifiers)
for value in _array(payload.get("residual_proposals"), "residual_proposals")
)
document_id = path.name[: -len(_REVIEW_SUFFIX)]
if not document_id:
_fail("invalid_review", "评审 JSON 文件名缺少文档标签。")
return ReviewRecord(
document_id=document_id,
source_label=document_id,
status=status,
current_kind=current_kind,
input_sha256=input_sha256,
current_sha256=current_sha256,
input_markdown=input_markdown,
current_markdown=current_markdown,
modifiers=modifiers,
stages=tuple(stages),
stages_complete=stages_complete,
changes=tuple(changes),
errors=errors,
residual_proposals=residuals,
)
class ReviewRepository:
"""只读取一个明确目录直属 full JSON 的内存索引。"""
def __init__(self, review_directory: str | Path) -> None:
supplied = Path(review_directory)
if supplied.is_symlink():
_fail("unsafe_path", "评审目录不能是符号链接。", 400)
try:
resolved = supplied.resolve(strict=True)
except OSError:
_fail("missing_review_directory", "评审目录不存在。", 400)
if not resolved.is_dir():
_fail("invalid_review_directory", "评审路径必须是目录。", 400)
paths = sorted(resolved.glob(f"*{_REVIEW_SUFFIX}"))
if not paths:
_fail("empty_review_directory", "评审目录没有直属 full review JSON。", 400)
for path in paths:
if path.is_symlink() or not path.is_file() or path.parent != resolved:
_fail("unsafe_path", "评审 JSON 必须是目录直属普通文件。", 400)
self.review_directory = resolved
self.collection_label = resolved.name
documents: list[ReviewRecord] = []
for path in paths:
try:
documents.append(_review_record(path))
except ReviewDataError as error:
raise ReviewDataError(
error.code,
f"{path.name}{error}",
error.http_status,
) from error
self.documents = tuple(documents)
self.documents_by_id = {item.document_id: item for item in self.documents}
if len(self.documents_by_id) != len(self.documents):
_fail("invalid_review", "评审目录包含重复文档标签。")
def collection_response(self) -> JsonObject:
statuses = [item.status for item in self.documents]
status = "failed" if "failed" in statuses else "unstable" if "unstable" in statuses else "success"
return {
"schema_version": 1,
"collection": {"label": self.collection_label, "status": status},
"documents": [item.summary for item in self.documents],
"summary": {
"document_count": len(self.documents),
"success_count": statuses.count("success"),
"failed_count": statuses.count("failed"),
"unstable_count": statuses.count("unstable"),
"change_count": sum(len(item.changes) for item in self.documents),
},
}
def document(self, document_id: str) -> ReviewRecord:
record = self.documents_by_id.get(document_id)
if record is None:
_fail("unknown_document", "文档不存在。", 404)
return record
class ReviewerHttpServer(ThreadingHTTPServer):
"""工作线程不会阻止本地服务退出。"""
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: ReviewRepository, path: str) -> JsonObject:
if path == "/api/v1/collection":
return repository.collection_response()
parts = [part for part in path.split("/") if part]
try:
if len(parts) == 4 and parts[:3] == ["api", "v1", "documents"]:
document_id = unquote(parts[3], encoding="utf-8", errors="strict")
return repository.document(document_id).document_response()
if len(parts) == 6 and parts[:3] == ["api", "v1", "documents"] and parts[4] == "modifiers":
document_id = unquote(parts[3], encoding="utf-8", errors="strict")
try:
position = int(parts[5])
except ValueError:
_fail("unknown_modifier", "Modifier 位置不存在。", 404)
return repository.document(document_id).stage_response(position)
except UnicodeDecodeError:
_fail("not_found", "请求的资源不存在。", 404)
_fail("not_found", "请求的资源不存在。", 404)
def _handler_factory(
repository: ReviewRepository,
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, ReviewDataError):
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:
try:
requested = "index.html" if path == "/" else unquote(path[1:], encoding="utf-8", errors="strict")
except UnicodeDecodeError:
_fail("not_found", "请求的资源不存在。", 404)
if "\0" in requested:
_fail("not_found", "请求的资源不存在。", 404)
candidate = static_root / requested
try:
if candidate.is_symlink():
_fail("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:
_fail("not_found", "请求的资源不存在。", 404)
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._json(
405,
{"error": {"code": "method_not_allowed", "message": "只允许 GET 和 HEAD。"}},
head_only=head_only,
)
return
try:
if not _valid_local_request(self):
_fail("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: # 不向浏览器泄露意外实现细节。
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: ReviewRepository,
static_root: Path | None = None,
*,
port: int = 0,
) -> ReviewerHttpServer:
"""创建但不启动只绑定回环地址的评审服务。"""
supplied_static = Path(__file__).with_name("_reviewer_static") if static_root is None else static_root
if supplied_static.is_symlink():
_fail("missing_build", "前端资源目录不能是符号链接。", 400)
try:
resolved_static = supplied_static.resolve(strict=True)
except OSError:
_fail("missing_build", "未找到 wheel 内的前端资源。", 400)
if not resolved_static.is_dir() or not (resolved_static / "index.html").is_file():
_fail("missing_build", "未找到 wheel 内的前端资源。", 400)
return ReviewerHttpServer(
("127.0.0.1", port),
_handler_factory(repository, resolved_static),
)
def _arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="只读查看一个目录中的 mdpolish full review JSON。")
parser.add_argument("--review-dir", required=True, help="只包含直属 *.review.json 的明确目录")
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 _serve(argv: list[str] | None = None) -> None:
arguments = _arguments(argv)
repository = ReviewRepository(arguments.review_dir)
try:
server = create_server(repository, port=arguments.port)
except OSError as error:
raise ReviewDataError("server_error", "无法启动本地评审服务。", 500) from error
port = server.server_address[1]
print(
f"mdpolish 评审器已启动:http://127.0.0.1:{port}{len(repository.documents)} 份文档)",
flush=True,
)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
def main(argv: list[str] | None = None) -> None:
"""运行命令行入口。预期启动失败转换成不带 traceback 的消息。"""
try:
_serve(argv)
except ReviewDataError as error:
raise SystemExit(f"评审器启动失败:{error}") from None
if __name__ == "__main__":
main()
+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"