Compare commits
10 Commits
11349e007a
...
6c0dd5974b
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c0dd5974b | |||
| 10c026c7ad | |||
| 61b7ea7534 | |||
| 8b689eebe1 | |||
| 843952b194 | |||
| dd012f4905 | |||
| 668c139168 | |||
| 5b8acc3f4c | |||
| fb0d237d3a | |||
| ad1fb18137 |
@@ -5,7 +5,7 @@
|
||||
>
|
||||
> 1. 本仓库是实验室共用的 Markdown 清洗研究与基础工具库;已有实现不等于完整清洗工具或生产能力。
|
||||
> 2. 真实文档和外部数据默认只读,不修改、不复制、不提交。
|
||||
> 3. 面向用户的说明使用简体中文;代码、命令、路径和标识符使用英文。
|
||||
> 3. 面向用户的说明使用简体中文,代码注释使用中文;代码、命令、路径和标识符使用英文。
|
||||
> 4. `AGENTS.md` 与 `CLAUDE.md` 是同步镜像,除第一行标题外正文必须一致。
|
||||
|
||||
## 0. 事实权威
|
||||
@@ -37,6 +37,9 @@
|
||||
|
||||
本仓库的研究结论不会自动成为其他仓库的生产契约。跨仓落地必须在目标仓库重新评审并获得授权。
|
||||
|
||||
`/home/lihaoze/work/mdpolish-wheel-pilot` 是当前独立的项目端 wheel 消费测试仓库;除非用户明确授权,不要将其文件、
|
||||
测试数据或项目职责并入本仓库。
|
||||
|
||||
## 2. 数据与外部材料
|
||||
|
||||
已知外部真实材料位于 `/home/lihaoze/gov_test_data`。除非用户另行明确授权,执行以下边界:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
>
|
||||
> 1. 本仓库是实验室共用的 Markdown 清洗研究与基础工具库;已有实现不等于完整清洗工具或生产能力。
|
||||
> 2. 真实文档和外部数据默认只读,不修改、不复制、不提交。
|
||||
> 3. 面向用户的说明使用简体中文;代码、命令、路径和标识符使用英文。
|
||||
> 3. 面向用户的说明使用简体中文,代码注释使用中文;代码、命令、路径和标识符使用英文。
|
||||
> 4. `AGENTS.md` 与 `CLAUDE.md` 是同步镜像,除第一行标题外正文必须一致。
|
||||
|
||||
## 0. 事实权威
|
||||
@@ -37,6 +37,9 @@
|
||||
|
||||
本仓库的研究结论不会自动成为其他仓库的生产契约。跨仓落地必须在目标仓库重新评审并获得授权。
|
||||
|
||||
`/home/lihaoze/work/mdpolish-wheel-pilot` 是当前独立的项目端 wheel 消费测试仓库;除非用户明确授权,不要将其文件、
|
||||
测试数据或项目职责并入本仓库。
|
||||
|
||||
## 2. 数据与外部材料
|
||||
|
||||
已知外部真实材料位于 `/home/lihaoze/gov_test_data`。除非用户另行明确授权,执行以下边界:
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
`mdpolish` 是实验室共用的、项目无关的 Python Markdown 修改库。它提供函数式 `Modifier`、精确文本编辑执行器、
|
||||
有序 `Pipeline`、正则修改器工厂,以及少量可以用合成样例完整说明的通用修改器。
|
||||
|
||||
当前版本是 `0.2.0`。库只处理内存中的 Markdown 字符串,不读取或写入文件,不提供默认流水线,也不包含任何项目的
|
||||
规则集合、数据清单、实验脚本或评审工具。
|
||||
当前发布版本是 [`v0.7.0`](https://github.com/Bepr4/mdpolish/releases/tag/v0.7.0)。清洗核心只处理内存中的 Markdown
|
||||
字符串,不读取或写入文件,不提供默认流水线,也不包含任何项目的规则集合、数据清单或实验脚本。安装包另外提供一个
|
||||
必须显式启动的本地只读评审器,用来展示项目已经保存的正式 `full` review JSON;它不替项目运行清洗或管理产物。
|
||||
|
||||
## 当前能力
|
||||
|
||||
@@ -13,6 +14,13 @@
|
||||
| `Modifier` | 把不可变元数据与普通提议函数组合起来 | 函数只提议修改,不直接改字符串或文件 |
|
||||
| 精确编辑执行器 | 校验快照、范围、原文、重复和冲突后原子应用一个批次 | 不判断项目业务语义 |
|
||||
| `Pipeline` | 按调用方顺序运行修改器,并对最终快照做只读稳定性复查 | 不自动选规则、不重排、不循环执行 |
|
||||
| `build_review_document()` | 验证并重放已有结果,提供可信阶段、位置、全文和错误/残留证据 | 不重新运行修改器,不猜测损坏或不完整的结果 |
|
||||
| `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()` | 用精确、正则或可选本地词典规则合并跨行片段 | 无默认规则;代码、表格、未知结构和歧义失败关闭 |
|
||||
| HTML 表格修改器 | 处理严格表格子集的实体和单行布局 | 不是完整 HTML parser,也不是 HTML→GFM 转换器 |
|
||||
@@ -27,20 +35,31 @@
|
||||
|
||||
## 安装
|
||||
|
||||
项目仍在仓库内开发,使用项目可以从本地路径安装:
|
||||
正式版本只通过 GitHub tag 和 GitHub Release 交付,不发布到 PyPI 或其他 Python 包索引。使用项目可以固定
|
||||
不可移动的 tag:
|
||||
|
||||
```bash
|
||||
python -m pip install /path/to/mdpolish
|
||||
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.7.0/mdpolish-0.7.0-py3-none-any.whl'
|
||||
```
|
||||
|
||||
Release 页面同时提供 wheel 的 SHA-256 校验值。仓库或 Release 如果是私有的,调用方需要自行配置 GitHub 访问权限;
|
||||
库不会保存凭据。开发环境仍从本地工作树安装:
|
||||
|
||||
```bash
|
||||
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
|
||||
@@ -87,6 +106,52 @@ if result.status is not RunStatus.SUCCESS:
|
||||
assert result.output_markdown == "an example text"
|
||||
```
|
||||
|
||||
## 自动英文断词
|
||||
|
||||
普通英文断词不需要逐词维护映射。调用方安装 `lexical` extra 后,可以明确选择 `pyspellchecker` 的本地词典,
|
||||
并用 Pyphen 排除不合法的断词位置:
|
||||
|
||||
```python
|
||||
from mdpolish import Pipeline, RunStatus
|
||||
from mdpolish.modifiers import mapped_line_join
|
||||
from mdpolish.modifiers.mapped_line_join import (
|
||||
LexicalCandidateForm,
|
||||
LexicalLineJoinRule,
|
||||
LexiconBackend,
|
||||
)
|
||||
|
||||
join_english_wraps = mapped_line_join(
|
||||
(
|
||||
LexicalLineJoinRule(
|
||||
rule_id="english.dehyphenate",
|
||||
left_pattern=r"[A-Za-z]+",
|
||||
right_pattern=r"[a-z]+",
|
||||
separator="-",
|
||||
backend=LexiconBackend.SPELLCHECKER,
|
||||
language="en",
|
||||
candidate_forms=(
|
||||
LexicalCandidateForm.JOINED,
|
||||
LexicalCandidateForm.HYPHENATED,
|
||||
),
|
||||
minimum_score=1.0,
|
||||
minimum_score_margin=0.5,
|
||||
hyphenation_language="en_US",
|
||||
case_sensitive=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
result = Pipeline((join_english_wraps,)).transform("an exam-\nple")
|
||||
if result.status is not RunStatus.SUCCESS:
|
||||
raise RuntimeError(f"cleaning did not succeed: {result.status}")
|
||||
|
||||
assert result.output_markdown == "an example"
|
||||
```
|
||||
|
||||
`pyspellchecker` 的内置语言词典不支持大小写敏感查询,因此这个 backend 要求调用方明确写出
|
||||
`case_sensitive=False`;库不会替调用方改变该开关或切换 backend。上面的分数和 margin 只用于演示完整配置,
|
||||
不是适合所有项目的生产阈值。`JOINED` 与源 `HYPHENATED` 会共同参与候选选择;词典不能确认唯一结果时保持原文。
|
||||
|
||||
库不会保存 `output_markdown`。调用项目应先检查状态,再自行决定写入位置:
|
||||
|
||||
```python
|
||||
@@ -100,7 +165,121 @@ 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`。
|
||||
清洗输入发现、输出命名、覆盖策略和批处理仍属于调用项目。`mdpolish` 可以生成通用的内存评审视图、机器投影、JSON
|
||||
字符串与 Markdown 报告字符串,但不会自动保存它们,也不知道报告来自哪个文件。唯一通用 CLI 是下面的只读评审器;它只
|
||||
消费调用方已经保存的正式 JSON,不承担项目文件适配。
|
||||
|
||||
## 构建内存评审视图和报告
|
||||
|
||||
调用方保留原始 Markdown,并把它与 `TransformResult` 一起传给评审构建函数:
|
||||
|
||||
```python
|
||||
from mdpolish.review import (
|
||||
build_review_document,
|
||||
parse_json_report,
|
||||
render_json_report,
|
||||
render_markdown_report,
|
||||
review_document_to_dict,
|
||||
)
|
||||
|
||||
input_markdown = "an exam-\nple text"
|
||||
result = pipeline.transform(input_markdown)
|
||||
|
||||
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")
|
||||
```
|
||||
|
||||
`build_review_document()` 不调用 `Modifier.propose()`,而是从输入开始重放已经记录的实际修改,逐阶段验证修改器身份、快照
|
||||
哈希、范围、原文、冲突、排序和阶段结果。无法证明结果一致时会抛出 `ReviewBuildError`,不会生成近似报告。
|
||||
|
||||
成功结果的 `stages_complete` 为 `True`。`unstable` 和只有 final review 错误的 `failed` 也已经完成全部 transform 阶段,
|
||||
但其当前全文仍明确标记为部分输出;preflight 或 transform 失败只包含失败前能够证明完成的阶段。
|
||||
|
||||
Markdown reporter 会包含完整输入、当前全文、统一 diff 以及实际修改的 `before` / `after`,因此不是安全日志。它默认只列出
|
||||
前 20 条残留候选,并且不重复输出残留候选正文;调用项目如果保存报告,仍需负责路径、权限、脱敏和保留周期。每个完整
|
||||
阶段都会保留前后快照,当前版本没有承诺无限文档长度或修改器数量下的内存上限。
|
||||
|
||||
机器投影使用独立于包版本的 schema `1.0`,并提供三个显式 detail:
|
||||
|
||||
- `summary`:默认值;只含状态、哈希、计数、modifier 身份、阶段摘要和稳定错误代码,不含正文承载字段;
|
||||
- `changes`:再加入 modifier 参数、适用说明、实际修改片段、错误消息和未应用残留候选;
|
||||
- `full`:再加入完整输入、当前全文和每个阶段的完整前后全文。
|
||||
|
||||
高 detail 可能还原敏感内容。即使 `summary` 不含正文,它仍然携带项目元数据和哈希,不能自动视为匿名或适合公开传播。
|
||||
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 范围扫描:
|
||||
|
||||
```python
|
||||
from mdpolish.text_ranges import physical_lines
|
||||
|
||||
source = "first\r\n\r\nlast"
|
||||
lines = physical_lines(source)
|
||||
|
||||
assert lines[0].content(source) == "first"
|
||||
assert lines[0].line_ending(source) == "\r\n"
|
||||
assert lines[1].is_empty
|
||||
assert source[lines[2].content_start : lines[2].full_end] == "last"
|
||||
```
|
||||
|
||||
扫描只识别 LF、CR 和 CRLF,不规范化原文,也不判断段落、标题、列表、引用、代码或表格。它不会成为 Pipeline 的全局预处理;
|
||||
只有显式调用它的 Modifier 才会扫描当前阶段快照。精确空文档、尾换行、offset 和失败关闭口径见
|
||||
[`physical-line-ranges.md`](research-wiki/reference/physical-line-ranges.md)。
|
||||
|
||||
## 编写项目自己的修改器
|
||||
|
||||
@@ -158,7 +337,9 @@ remove_marker = Modifier(
|
||||
中间最多一个同风格空行,并可在一次提议内完成多行链式合并。保守词法扫描只正向识别段落、ATX 标题 continuation、
|
||||
列表 continuation 和同深度引用;代码块、GFM pipe table、raw HTML table、混合候选行尾及无法确认的容器保持原文。
|
||||
完整公共模型、选择流程和限制见
|
||||
[`0009-generalized-mapped-line-join.md`](research-wiki/design/0009-generalized-mapped-line-join.md)。
|
||||
[`0009-generalized-mapped-line-join.md`](research-wiki/design/0009-generalized-mapped-line-join.md),真实 backend 验收、
|
||||
版本身份和 GitHub 发布边界见
|
||||
[`0010-first-cross-project-library-delivery.md`](research-wiki/design/0010-first-cross-project-library-delivery.md)。
|
||||
|
||||
`html_table_entity_unescape()` 只在严格完整的 `<td>` / `<th>` 文本中处理 `&lt;`、`&gt;` 和
|
||||
`&amp;`。`html_table_layout()` 只调整严格单行表格的外层行布局,并保留标签、属性和单元格内容。
|
||||
@@ -174,8 +355,14 @@ src/mdpolish/
|
||||
├── modifier.py # 函数式 Modifier 契约
|
||||
├── edits.py # 批次验证与原子应用
|
||||
├── pipeline.py # 有序执行与最终稳定性复查
|
||||
├── 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/ # 已批准决策及被冻结的历史记录
|
||||
@@ -187,16 +374,23 @@ research-wiki/
|
||||
|
||||
## 当前不提供
|
||||
|
||||
- 文件适配器、公共 CLI、配置文件、profile 或批处理协议;
|
||||
- 清洗文件适配器、清洗 CLI、配置文件、profile 或批处理协议;
|
||||
- 自动规则发现、注册表或默认流水线;
|
||||
- Markdown AST、完整 HTML parser 或必装的第三方运行依赖;
|
||||
- artifact、报告、Web/桌面评审器;
|
||||
- artifact、自动保存的报告文件、正式 JSON Schema 文件、远程/桌面评审器或项目审核流程;
|
||||
- 任何业务项目的规则、固定参数、文档 ID、数据或验收统计。
|
||||
|
||||
公共边界与原因见
|
||||
[`0008-generic-functional-library-boundary.md`](research-wiki/design/0008-generic-functional-library-boundary.md),当前机制见
|
||||
[`functional-modifier-core.md`](research-wiki/explanation/functional-modifier-core.md)。旧 design 只保存历史决策,不代表当前
|
||||
交付能力。
|
||||
[`functional-modifier-core.md`](research-wiki/explanation/functional-modifier-core.md)。通用内存评审能力的批准边界见
|
||||
[`0011-generic-review-projection-and-reporting.md`](research-wiki/design/0011-generic-review-projection-and-reporting.md),当前机制见
|
||||
[`review-projection.md`](research-wiki/explanation/review-projection.md)。正式机器投影的批准边界见
|
||||
[`0012-review-document-machine-projection.md`](research-wiki/design/0012-review-document-machine-projection.md),schema `1.0` 的稳定
|
||||
查询口径见 [`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)。
|
||||
|
||||
## 当前可用检查
|
||||
|
||||
@@ -209,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
|
||||
@@ -216,8 +420,21 @@ git diff --check
|
||||
git status --short
|
||||
```
|
||||
|
||||
上述检查已于 2026-08-26 在 Python 3.13.11 环境实际运行:Ruff 通过,mypy 检查 21 个源码和测试文件无问题,
|
||||
pytest 共 168 项通过、2 项因本环境未安装可选词典 backend 而跳过,`mdpolish-0.2.0-py3-none-any.whl` 构建成功。
|
||||
wheel 内容已单独检查,只包含通用 Python 包、类型标记和包元数据,不包含项目规则、实验脚本、评审器或 Node.js 文件。
|
||||
上述检查已于 2026-08-28 对 `v0.7.0` 实际运行。Python 3.13.11 开发环境中 Ruff 和 mypy 通过;未安装可选 backend 时
|
||||
pytest 为 `330 passed, 3 skipped`,三个 skip 分别对应真实 lexical 和 frequency backend 路径。
|
||||
|
||||
`pyproject.toml` 声明的 Python 3.11 及以上为支持范围;本次结果不表示已经在每个受支持版本上完成兼容性验证。
|
||||
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。
|
||||
|
||||
`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`。
|
||||
|
||||
自动检查不能替代真实浏览器中的最终视觉、长文滚动和跨 Modifier 跳转人工确认。上述结果只证明当前版本可安装并按合成契约
|
||||
运行,不代表任意清洗规则已经在真实业务语料上达到生产准确率。
|
||||
|
||||
+4
-1
@@ -4,11 +4,14 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mdpolish"
|
||||
version = "0.2.0"
|
||||
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",
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
3. 目标与非目标;
|
||||
4. 候选方案;
|
||||
5. 决定与理由;
|
||||
6. 风险和边界;
|
||||
6. 施工范围目录;
|
||||
7. 实施与验收。
|
||||
|
||||
草稿可以在评审期间修改。批准后冻结;如果决策改变,新文档必须写明 `supersedes: NNNN`,并保留旧文档。
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
# 0010:第一版跨项目库交付
|
||||
|
||||
## 状态
|
||||
|
||||
已于 2026-08-27 经用户明确批准。本文自批准起冻结;后续若改变这里的 backend 契约、版本身份或发布渠道,
|
||||
应新增 design,不得回写本文。
|
||||
|
||||
`supersedes: 0009`(范围有限):本文只替代 `0009` 中没有被真实 optional dependency 验证覆盖的
|
||||
`SPELLCHECKER` 大小写适配、发布前验收和版本身份部分。`0009` 已批准的规则模型、候选比较、失败关闭、
|
||||
Markdown 块边界、无默认规则、无网络和函数式 `Modifier` 契约继续有效。
|
||||
|
||||
本文批准后只授权实现和验证,不自动授权提交、创建 tag、push、创建 GitHub Release、上传产物、修改其他仓库
|
||||
或读取真实材料。
|
||||
|
||||
## 1. 问题与可观察现象
|
||||
|
||||
提交 `11349e0` 已经实现通用 `mapped_line_join()`,仓库基础检查在 Python 3.13.11 下得到:
|
||||
|
||||
```text
|
||||
168 passed, 2 skipped
|
||||
```
|
||||
|
||||
两个 skip 分别对应没有安装的 `pyspellchecker` 和 `wordfreq`。这意味着核心契约和合成 backend 通过了测试,
|
||||
但最关键的真实词典适配器没有进入同一次验收。
|
||||
|
||||
2026-08-27 在临时干净 venv 中从 wheel 安装全部 extras 后,实际版本为:
|
||||
|
||||
| 包 | 实际版本 |
|
||||
| --- | --- |
|
||||
| `mdpolish` | `0.2.0` |
|
||||
| `pyspellchecker` | `0.9.0` |
|
||||
| `pyphen` | `0.18.1` |
|
||||
| `wordfreq` | `3.1.1` |
|
||||
|
||||
`wordfreq`、`Pyphen` 和显式 `case_sensitive=False` 的 `pyspellchecker` 都能把合成输入
|
||||
`an exam-\nple` 稳定处理成 `an example`。但是,当前 `LexicalLineJoinRule.case_sensitive` 默认是 `True`,
|
||||
实现会把它直接传给:
|
||||
|
||||
```python
|
||||
SpellChecker(language="en", case_sensitive=True)
|
||||
```
|
||||
|
||||
真实上游立即拒绝:
|
||||
|
||||
```text
|
||||
ValueError: case_sensitive can only be True when not using a language dictionary.
|
||||
```
|
||||
|
||||
`pyspellchecker` 的源码和官方 quickstart 都说明,大小写敏感模式只适用于不加载内置 language dictionary 的实例:
|
||||
|
||||
- <https://github.com/barrust/pyspellchecker/blob/master/spellchecker/spellchecker.py>
|
||||
- <https://github.com/barrust/pyspellchecker/blob/master/docs/source/quickstart.rst>
|
||||
|
||||
这不是词典无命中,而是构造阶段的能力冲突。当前实现把它包装成泛化的“无法加载语言”错误,调用方无法知道
|
||||
应该怎样改配置。
|
||||
|
||||
另一个交付问题是包版本仍为 `0.2.0`。这个版本已经用于 `0008` 后的函数式核心;如果新旧内容继续生成同名 wheel,
|
||||
其他项目、pip 缓存和问题报告都无法可靠区分实际安装的是哪一份代码。
|
||||
|
||||
因此,当前提交适合受控试接,不适合直接作为一个带稳定版本身份的跨项目交付。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
- 对 `SPELLCHECKER` 不支持的大小写组合给出构造期、可操作、不会泄露正文的错误;
|
||||
- 保留 `case_sensitive=True` 的公共默认值,不静默开启忽略大小写;
|
||||
- 用真实安装的 `pyspellchecker`、Pyphen 和 `wordfreq` 验证 wheel,而不是把 skip 当成通过;
|
||||
- 为这次新增公共能力分配唯一包版本 `0.3.0`;
|
||||
- 将修复后的 `mapped_line_join` 修改器版本更新为 `2.0.1`,使审计记录可以区分修复前后;
|
||||
- 在 README 提供其他项目可以直接复制的安装、导入和自动英文断词示例;
|
||||
- 将 GitHub tag 和 GitHub Release 确定为计划内的正式发布渠道,明确不使用 PyPI;
|
||||
- 明确第一版固定版本方式和消费者责任;
|
||||
- 保持核心安装零第三方运行依赖,extras 仍然只由调用方显式安装和启用。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不为 `pyspellchecker` 的内置语言词典自行实现大小写敏感查询;
|
||||
- 不读取、复制或改写 `pyspellchecker` 的内部压缩词典资源;
|
||||
- 不把 `case_sensitive` 默认值改成 `False`;
|
||||
- 不引入 Hunspell、Enchant、wordninja、在线词典、模型或 OCR 版面接口;
|
||||
- 不新增默认规则、默认流水线、项目 profile、配置文件或 CLI;
|
||||
- 不声称词典规则已经在真实业务语料上达到生产准确率;
|
||||
- 不在本轮定义标注指标、接受阈值或真实材料实验;
|
||||
- 不发布到 PyPI 或其他 Python 包索引,不提交 wheel 到 Git;
|
||||
- 不因批准本文就自动创建 GitHub Release 或上传产物;
|
||||
- 不修改其他 modifier、`_text_ranges.py`、真实数据或其他仓库。
|
||||
|
||||
## 3. `SPELLCHECKER` 大小写适配
|
||||
|
||||
### 3.1 候选方案
|
||||
|
||||
| 方案 | 优点 | 代价与问题 | 选择 |
|
||||
| --- | --- | --- | --- |
|
||||
| 把 `LexicalLineJoinRule.case_sensitive` 默认改成 `False` | 默认示例可以直接运行 | 违反“不默认忽略大小写”,并静默改变公共语义 | 否决 |
|
||||
| 忽略调用方的 `True`,内部总用大小写不敏感词典 | 代码最少 | 参数记录与实际行为不一致,属于静默降级 | 否决 |
|
||||
| 读取上游包内 JSON 频率表,自己实现大小写敏感词典 | 理论上可以支持 `True` | 绑定上游内部资源路径和格式,扩大维护面;第一版没有证据需要它 | 本轮否决 |
|
||||
| `SPELLCHECKER` 明确要求 `case_sensitive=False` | 行为诚实、改动小、调用方必须主动选择 | 该 backend 暂不提供大小写敏感语言词典 | 采用 |
|
||||
|
||||
### 3.2 决定
|
||||
|
||||
`case_sensitive` 继续是所有规则共有的显式开关,默认仍为 `True`。新增 backend 组合验证:
|
||||
|
||||
```text
|
||||
backend == SPELLCHECKER and case_sensitive is True
|
||||
```
|
||||
|
||||
在 `mapped_line_join()` 构造阶段立即抛出 `ModifierContractError`。错误消息必须说明:
|
||||
|
||||
- `pyspellchecker` 的内置 language dictionary 不支持大小写敏感模式;
|
||||
- 调用方若接受大小写不敏感匹配,需要显式设置 `case_sensitive=False`;
|
||||
- 不建议切换 backend,也不执行自动回退。
|
||||
|
||||
错误只包含 backend、`rule_id` 和配置字段,不包含输入正文。因为错误发生在构造修改器时,也不应等到
|
||||
`Pipeline.transform()` 才暴露。
|
||||
|
||||
`WORDFREQ` 保留现有 `case_sensitive=True/False` 行为。不能因为一个 backend 的限制,把共同字段的默认值改成
|
||||
另一个含义。
|
||||
|
||||
### 3.3 为什么第一版不复制词典
|
||||
|
||||
`pyspellchecker` 官方允许用 `language=None, case_sensitive=True` 加载调用方自己的词典,但当前规则只接受语言代码,
|
||||
设计也禁止读取任意路径。为了绕过限制而解析依赖包内部的 `resources/<language>.json.gz`,会新增一套未获保证的
|
||||
资源格式契约。
|
||||
|
||||
第一版更重要的是不误导调用方。明确拒绝不支持的组合,比复制上游实现、静默忽略参数或假装拥有大小写敏感语言词典
|
||||
更符合失败关闭原则。如果未来项目确实需要该能力,应以真实样本和新 design 决定是增加版本化自带词典、扩展 backend,
|
||||
还是接受自定义词典输入;不能回写本文。
|
||||
|
||||
## 4. 版本身份
|
||||
|
||||
### 4.1 包版本
|
||||
|
||||
实现本文时把 `pyproject.toml` 和 README 中的包版本从 `0.2.0` 更新为 `0.3.0`。
|
||||
|
||||
选择 `0.3.0` 而不是 `1.0.0`,原因是:
|
||||
|
||||
- 新能力是向现有函数式核心增加公共规则和 optional backend,属于 `0.x` 阶段的次版本变化;
|
||||
- 公共规则还没有经过多个调用项目验证;
|
||||
- Markdown 块扫描仍是保守词法子集;
|
||||
- 没有真实语料准确率、默认 profile、CLI 或生产写入协议。
|
||||
|
||||
`0.3.0` 表示“可以被其他项目固定版本试用”,不表示“自动词典规则已经适合任意文档生产启用”。
|
||||
|
||||
### 4.2 修改器版本
|
||||
|
||||
`mapped_line_join()` 生成的 `Modifier.version` 从 `2.0.0` 更新为 `2.0.1`。
|
||||
|
||||
规则 dataclass 和序列化参数格式不改变,因此不升到 `3.0.0`。补丁版本用于表示:
|
||||
|
||||
- 真实 `SPELLCHECKER` backend 的能力验证变得准确;
|
||||
- 不支持的组合从上游泛化异常变成稳定的构造期契约错误;
|
||||
- 已支持组合的候选选择语义不改变。
|
||||
|
||||
### 4.3 版本记录
|
||||
|
||||
词典规则继续在 `Modifier.parameters` 中记录 backend 包版本、语言、候选形式和阈值。调用项目还必须固定
|
||||
`mdpolish` 包版本;只记录 `Modifier.version` 不能替代安装依赖锁定。
|
||||
|
||||
## 5. 第一版交付渠道
|
||||
|
||||
### 5.1 采用范围
|
||||
|
||||
项目计划通过 GitHub 发布版本,不发布到 PyPI 或其他 Python 包索引。不可移动的 Git tag 是源码身份,GitHub Release
|
||||
是面向使用方的正式发布记录。第一版候选 tag 和 Release 名称为:
|
||||
|
||||
```text
|
||||
v0.3.0
|
||||
```
|
||||
|
||||
tag 必须指向通过本文全部验收的唯一提交,创建后不得移动;GitHub Release 必须绑定这个 tag,不能指向分支头。
|
||||
其他项目可以通过 GitHub Git 地址和 tag 固定依赖,也可以安装该 Release 附带的 wheel。Python Packaging 规范允许
|
||||
集成方使用 direct reference,但它不是包索引发布物:
|
||||
|
||||
- <https://packaging.python.org/en/latest/specifications/version-specifiers/#direct-references>
|
||||
- <https://packaging.python.org/en/latest/specifications/dependency-specifiers/>
|
||||
|
||||
README 应给出 GitHub tag direct reference 和 Release wheel 两种安装形式,但不把仓库凭据或某个调用项目配置写入库代码。
|
||||
仓库及 Release 是公开还是私有,由 GitHub 仓库权限决定;本文不授权改变仓库可见性。
|
||||
|
||||
### 5.2 wheel 的角色
|
||||
|
||||
wheel 在发布前首先是验证产物,不提交到 Git,也不在仓库内建立 artifact 目录。验收必须从最终源码构建 wheel,并在
|
||||
干净环境从这个 wheel 安装,而不是依赖当前仓库的 editable install。
|
||||
|
||||
实际创建 GitHub Release 时,只能上传通过第 7 节验收的同一个 wheel,并同时提供 SHA-256 校验值。若 tag 后重新构建,
|
||||
必须重新执行 wheel 元数据和消费者 smoke test,不能把不同构建物当作已经验收的产物。GitHub 自动生成的源码归档与
|
||||
Release wheel 共同保存在 GitHub;本仓库不另存一份二进制副本。
|
||||
|
||||
PyPI、GitHub Packages、内部 Python 包索引和其他 artifact 仓库均不在计划内。将来若要增加其他发布渠道,必须用新的
|
||||
design 改变本文,而不能只改发布脚本或 README。
|
||||
|
||||
### 5.3 tag 和 push 的确认门
|
||||
|
||||
本文批准后可以完成版本修改、测试、构建和提交前候选检查,但不能自动提交、创建或推送 `v0.3.0`,也不能创建
|
||||
GitHub Release 或上传 wheel。
|
||||
|
||||
只有在用户看到最终 diff、真实测试输出和 wheel 元数据后,才能明确授权:
|
||||
|
||||
1. 提交交付改动;
|
||||
|
||||
提交完成并向用户报告提交哈希和 tag 的准确目标后,才能再明确授权:
|
||||
|
||||
1. 创建 `v0.3.0` tag;
|
||||
2. push 提交和 tag;
|
||||
3. 创建绑定 `v0.3.0` 的 GitHub Release;
|
||||
4. 上传已验收的 wheel 和 SHA-256 校验值。
|
||||
|
||||
这些动作不因“设计已批准”而自动获得授权。
|
||||
|
||||
## 6. 公共调用契约与 README
|
||||
|
||||
### 6.1 支持的导入路径
|
||||
|
||||
保持 `0009` 的决定,不扩大聚合导出:
|
||||
|
||||
```python
|
||||
from mdpolish.modifiers import mapped_line_join
|
||||
from mdpolish.modifiers.mapped_line_join import (
|
||||
LexicalCandidateForm,
|
||||
LexicalLineJoinRule,
|
||||
LexiconBackend,
|
||||
)
|
||||
```
|
||||
|
||||
本轮不把全部枚举和 dataclass 重新导出到 `mdpolish.modifiers` 或包根。减少顶层公共表面积比缩短一行导入更重要。
|
||||
|
||||
### 6.2 README 示例
|
||||
|
||||
README 保留旧三元组示例,并新增一个最小自动英文断词示例。示例必须明确写出:
|
||||
|
||||
- 通过 GitHub tag direct reference 或 GitHub Release wheel 安装 `lexical` extra;
|
||||
- `backend=LexiconBackend.SPELLCHECKER`;
|
||||
- `case_sensitive=False`;
|
||||
- `JOINED` 与源 `HYPHENATED` 共同参与候选;
|
||||
- 分数阈值只是示例配置,不是库推荐的通用生产阈值;
|
||||
- 检查 `RunStatus` 后才能消费输出。
|
||||
|
||||
README 还要明确区分:
|
||||
|
||||
| 说法 | 当前是否成立 |
|
||||
| --- | --- |
|
||||
| wheel 可以安装,公共 API 可以运行 | 是,验收通过后成立 |
|
||||
| 自动词典不需要逐词维护映射 | 是 |
|
||||
| 库自带默认英文清洗规则 | 否 |
|
||||
| 某个阈值适合所有项目 | 否 |
|
||||
| 已在真实业务文档证明生产准确率 | 否 |
|
||||
|
||||
### 6.3 调用项目责任
|
||||
|
||||
调用项目必须:
|
||||
|
||||
- 固定 `mdpolish` 版本或不可移动 tag;
|
||||
- 把规则、顺序、backend、语言、阈值和例外作为项目配置评审;
|
||||
- 检查 `success`、`failed`、`unstable`,不能只读取可能为空的输出;
|
||||
- 自己负责文件读写、覆盖策略、批处理、日志和回滚;
|
||||
- 在自己的语料上验证误合并,不能把库的合成测试当作领域准确率。
|
||||
|
||||
## 7. 验证矩阵
|
||||
|
||||
### 7.1 核心环境
|
||||
|
||||
不安装 `lexical` 或 `frequency` extras,运行根 README 的全部基础检查,确认:
|
||||
|
||||
- 精确、正则、链式和 Markdown 失败关闭测试通过;
|
||||
- 导入模块不会加载词典;
|
||||
- 请求缺失 backend 时给出稳定错误;
|
||||
- optional backend 测试可以明确 skip,但 skip 不能计入真实 backend 验收。
|
||||
|
||||
### 7.2 extras 环境
|
||||
|
||||
另建干净环境,安装最终 wheel 的 `lexical` 和 `frequency` extras,并另行安装仓库测试工具,运行同一测试集。
|
||||
该环境的发布门要求:
|
||||
|
||||
- `pyspellchecker`、Pyphen 和 `wordfreq` 真实 adapter 测试全部执行,不得 skip;
|
||||
- `SPELLCHECKER + case_sensitive=True` 在构造期得到预期契约错误;
|
||||
- `SPELLCHECKER + case_sensitive=False` 的唯一拼接候选可以合并;
|
||||
- Pyphen 合法和非法断点分别影响 `JOINED`;
|
||||
- `WORDFREQ` 唯一胜者、margin 不足和歧义路径都符合 `0009`;
|
||||
- 包版本和 backend 版本进入审计参数;
|
||||
- 全套测试没有因安装 extras 而改变精确/正则规则结果。
|
||||
|
||||
测试不能只断言“构造成功”。至少一个真实 backend 用例必须通过 `Pipeline.transform()` 检查最终状态和完整输出。
|
||||
|
||||
### 7.3 wheel 消费者 smoke test
|
||||
|
||||
从最终提交构建 wheel 后,在不位于仓库源码目录的临时环境验证:
|
||||
|
||||
1. 只安装核心 wheel,运行精确和正则示例;
|
||||
2. 安装 `lexical` extra,运行 `SPELLCHECKER + Pyphen` 示例;
|
||||
3. 安装 `frequency` extra,运行 `WORDFREQ` 示例;
|
||||
4. 检查 `mdpolish`、修改器和 backend 版本记录;
|
||||
5. 检查 wheel 不包含 tests、Wiki、真实数据、项目规则或临时产物;
|
||||
6. 生成并记录待上传 wheel 的 SHA-256 校验值;
|
||||
7. 确认执行期间不访问网络、调用模型或读取任意文档路径。
|
||||
|
||||
依赖安装本身可以访问配置的包索引;“运行期间无网络”指安装完成后的库行为,不把安装包与执行清洗混为一谈。
|
||||
|
||||
### 7.4 Python 支持范围
|
||||
|
||||
`pyproject.toml` 当前声明 Python 3.11 及以上。第一版交付前至少验证:
|
||||
|
||||
- 最低支持版本 Python 3.11;
|
||||
- 当前开发版本 Python 3.13。
|
||||
|
||||
如果本地缺少其中一个解释器,不能把单版本结果描述成完整支持矩阵;应在可复现 CI 或受控环境补齐后再创建 tag。
|
||||
本文不新增 tox、nox、CI provider 或容器配置。若现有环境无法完成双版本验证,报告阻塞而不是降低声明。
|
||||
|
||||
## 8. 消费项目试用边界
|
||||
|
||||
通过第 7 节只证明“库可以被安装并按契约运行”,不证明“某组词典参数适合目标项目”。
|
||||
|
||||
第一个调用项目应先做只读或影子试用:保存提议和审计信息,由人复核后再决定是否应用。至少观察:
|
||||
|
||||
- 正确合并与错误合并;
|
||||
- 本应合并但被保留的候选;
|
||||
- `JOINED`、`HYPHENATED`、`SPACED` 的选择分布;
|
||||
- 按段落、标题、列表和引用拆分的行为;
|
||||
- 词典歧义、结构 `UNKNOWN`、代码和表格排除;
|
||||
- 项目专名和自然连字符是否需要 `KeepLineJoinRule`。
|
||||
|
||||
具体样本范围、标注方法、指标、接受阈值、输出目录和真实数据权限必须在调用项目或新的实验 design 中确认。
|
||||
本文不授权读取 `/home/lihaoze/gov_test_data`,也不授权修改任何调用项目。
|
||||
|
||||
## 9. 风险与代价
|
||||
|
||||
- **`SPELLCHECKER` 能力不对称:** 使用内置语言词典时必须显式忽略大小写;需要精确大小写的项目应使用其他 backend
|
||||
或等待新的词典设计。
|
||||
- **真实 extras 增加验收成本:** `wordfreq` wheel 和传递依赖较大,但不能为了节省安装时间继续跳过发布关键路径。
|
||||
- **GitHub 可用性与权限:** direct reference 需要 Git 和相应仓库权限;Release wheel 也受仓库可见性和 GitHub
|
||||
可用性约束。
|
||||
- **Release 产物一致性:** tag、Release 和 wheel 来自不同操作步骤,必须用提交哈希、版本元数据和 SHA-256 防止
|
||||
上传错误构建物。
|
||||
- **`0.3.0` 仍是预览契约:** 其他项目必须固定版本,不能跟随分支头自动升级。
|
||||
- **双 Python 版本可能受环境限制:** 缺少最低版本验证时,tag 会被阻塞。
|
||||
- **合成测试不代表领域准确率:** 即使全部 backend 测试通过,词典仍会漏掉专名、新词并误判自然连字符。
|
||||
- **保守失败关闭降低覆盖率:** 这是第一版为了正文保真接受的代价,不用放宽块扫描来追求漂亮数字。
|
||||
|
||||
## 10. 实施与验收范围
|
||||
|
||||
本文获批后授权:
|
||||
|
||||
1. 修改 `src/mdpolish/modifiers/mapped_line_join.py`,增加 `SPELLCHECKER` 组合验证并把修改器版本更新到 `2.0.1`;
|
||||
2. 扩展 `tests/test_mapped_line_join.py`,覆盖真实 backend、明确错误和版本记录;
|
||||
3. 把 `pyproject.toml` 包版本更新到 `0.3.0`,不改变已批准 extras 的依赖集合;
|
||||
4. 更新根 README 的当前版本、自动词典示例、交付边界和实际验证结果;
|
||||
5. 在临时目录构建和安装 wheel,运行第 7 节验证;
|
||||
6. 只读检查 `AGENTS.md` 与 `CLAUDE.md` 镜像、Git diff、工作区状态和最终提交候选范围。
|
||||
|
||||
本文获批后仍不授权:
|
||||
|
||||
- 修改已冻结的 `0009`;
|
||||
- 修改 `_text_ranges.py`、其他 modifier、其他 Wiki 文档或调用项目;
|
||||
- 读取或复制真实材料;
|
||||
- 新增默认规则、CLI、profile、artifact 目录、CI 配置或第三方 backend;
|
||||
- 提交、创建 `v0.3.0` tag、push、创建 GitHub Release、上传 wheel 或创建 PR。
|
||||
|
||||
实施完成的最终报告必须分别给出核心环境、extras 环境、Python 版本和 wheel smoke test 的真实输出。任何必需环境未验证时,
|
||||
明确写“未验证”或报告阻塞,不能用合成 adapter 测试替代真实 backend 结果。
|
||||
@@ -0,0 +1,503 @@
|
||||
# 0011:通用内存评审视图与报告
|
||||
|
||||
## 状态
|
||||
|
||||
已于 2026-08-27 获用户明确批准,按本文第 14 节实施。本文自批准起冻结;后续改变决策需新增 design 并使用
|
||||
`supersedes` 指向本文。
|
||||
|
||||
`supersedes: 0008`(范围有限):本文拟修改 `0008` 中“报告与评审工具全部留在项目端”的当前决定,把“从
|
||||
`TransformResult` 可信重放通用修改链,并生成不依赖项目界面的内存评审视图”重新纳入 `mdpolish`。`0008` 已确定的
|
||||
核心无文件 I/O、项目拥有持久化决定权、无默认流水线和无项目规则继续有效。
|
||||
|
||||
`0007` 仍是已被 `0008` 取代的历史记录,本文不让它重新生效。本文不恢复 `0005` 的 artifact schema、实验目录和文件
|
||||
写入,也不恢复 `0007` 的定位文件、HTTP 服务、React 页面、CodeMirror 或项目评审流程;只借鉴两份历史设计保存的
|
||||
中间快照重放经验。
|
||||
|
||||
## 1. 问题与可观察现象
|
||||
|
||||
`v0.3.0` 的 `Pipeline.transform()` 返回内存中的 `TransformResult`。成功结果已经包含完整清洗后 Markdown、实际修改、
|
||||
输入输出哈希和修改器元数据,但调用方仍不容易正确组织一份完整评审视图。
|
||||
|
||||
问题不只是“缺一个 HTML 页面”。每条 `Change.span` 都属于该修改器执行前的快照,不一定属于原始输入或最终输出。
|
||||
如果多个修改器依次改变同一段附近的文本,调用方把所有范围直接画在最终 Markdown 上,会产生错误跳转或错误归属。
|
||||
|
||||
历史上的 `0007` 已经观察到同一个问题。当时的可靠做法是:从输入开始,按修改器批次重放实际修改,逐批验证
|
||||
`before_sha256`、范围、原文和 `after_sha256`,再提供完整阶段文本。旧实现只服务 artifact 和同仓库评审器,已在
|
||||
`0008` 中移出当前库;但“怎样可信解释 `TransformResult`”并不是 ClinDB 专属问题。
|
||||
|
||||
当前调用方有三种不理想选择:
|
||||
|
||||
1. 只展示 `output_markdown`,看不到修改来源和过程;
|
||||
2. 自己重写修改排序、快照校验和中间阶段重放,形成第二套核心语义;
|
||||
3. 直接展示 `changes`,却把中间快照坐标误当成最终坐标。
|
||||
|
||||
因此,当前缺少的不是文件输出,而是位于核心结果和项目界面之间的、可复用的内存评审投影。
|
||||
|
||||
## 2. 成熟项目的做法
|
||||
|
||||
2026-08-27 查阅的官方接口显示,成熟工具通常把“处理结果”和“展示/持久化”分层:
|
||||
|
||||
| 项目 | 核心返回 | 展示与写入 |
|
||||
| --- | --- | --- |
|
||||
| [mdformat](https://mdformat.readthedocs.io/en/stable/users/installation_and_usage.html) | `mdformat.text()` 返回完整格式化字符串 | `mdformat.file()` 才原地写文件 |
|
||||
| [Prettier](https://prettier.io/docs/api/) | `format()` 返回完整字符串;`formatWithCursor()` 额外返回映射后光标 | CLI 或编辑器决定怎样保存和展示 |
|
||||
| [unified / VFile](https://unifiedjs.com/explore/package/vfile/) | `VFile` 保存最终内容、元数据和消息 | 独立 reporter 接收 `VFile[]` 并返回字符串 |
|
||||
| [markdownlint](https://github.com/DavidAnson/markdownlint) | lint 结果包含规则、范围、严重度和修复信息;`applyFixes()` 返回完整字符串 | CLI formatter 输出文本、JSON、SARIF、JUnit 等 |
|
||||
| [textlint](https://github.com/textlint/textlint/blob/master/docs/formatter.md) | fix 结果包含完整 `output`、已应用消息和剩余消息 | 独立 formatter 负责编排 |
|
||||
|
||||
这些项目没有要求底层处理函数直接生成某个业务页面。它们共同提供了两点参考:
|
||||
|
||||
- 核心或同一工具生态应提供足够准确的内容、位置和消息,使调用方不必重新解释修改语义;
|
||||
- reporter 可以属于通用工具,但文件路径、持久化和用户界面继续由外层决定。
|
||||
|
||||
`TransformResult` 已经比单纯返回字符串更接近 textlint 的 fix 结果和 unified 的 `VFile`。本文只补齐可信重放和通用
|
||||
reporter,不把它改造成文件或页面对象。
|
||||
|
||||
## 3. 目标与非目标
|
||||
|
||||
### 3.1 目标
|
||||
|
||||
- 接收原始 Markdown 和一个已有 `TransformResult`,先验证输入哈希再建立评审视图;
|
||||
- 按修改器批次重放实际修改,复用核心的范围、冲突、排序和哈希不变量;
|
||||
- 提供完整原始 Markdown、完整当前 Markdown、实际修改定位和可信的修改器阶段;
|
||||
- 明确区分成功输出与 `failed` / `unstable` 的部分或不稳定结果;
|
||||
- 为每条实际修改提供其修改前快照中的行列位置,不把它伪装成最终输出位置;
|
||||
- 提供一个确定、无文件 I/O 的 Markdown 源码报告 renderer;
|
||||
- 保持清洗结果、修改应用顺序和 `Modifier` 行为不变;为可靠区分尚未开始执行的预检失败,给 `ErrorStage` 增加
|
||||
`PREFLIGHT`;
|
||||
- 保持核心安装零第三方运行依赖;
|
||||
- 只用合成文本验证空文档、Unicode、不同换行、插入、删除、替换、多编辑和多修改器链。
|
||||
|
||||
### 3.2 非目标
|
||||
|
||||
- 不读取或写入文件,不接收文件路径,不决定文件名、输出目录或覆盖策略;
|
||||
- 不建立 artifact、JSON 文件 schema、数据库、批处理协议或保留周期;
|
||||
- 不提供 HTML renderer、Markdown 渲染预览、HTTP 服务、React 页面、编辑器或桌面应用;
|
||||
- 不执行或重新运行 `Modifier`,不应用 `residual_proposals`,不启动第二轮流水线;
|
||||
- 不增加默认流水线、项目 profile、业务文档 ID、审核状态、批准/拒绝动作或统计阈值;
|
||||
- 不把 Python 字符范围转换成 JavaScript UTF-16、LSP 或其他外部坐标;
|
||||
- 不把所有中间修改强行投影到最终 Markdown 的单一坐标系;
|
||||
- 不修改清洗语义、规则顺序、终态或修改器版本;除新增 `preflight` 错误阶段外,不改变已有错误阶段的含义;
|
||||
- 不读取、复制或修改 `data/`、`artifacts/`、消费测试仓或仓库外真实材料。
|
||||
|
||||
最终坐标投影不进入第一版,是因为后续修改器可能再次改写前一修改器生成的文本。此时“第一条修改在最终文本中的范围”
|
||||
可能缩短、分裂或彻底消失。第一版使用准确的修改器阶段坐标,不用启发式 diff 伪造一一对应关系。
|
||||
|
||||
## 4. 方案比较
|
||||
|
||||
| 方案 | 优点 | 问题 | 选择 |
|
||||
| --- | --- | --- | --- |
|
||||
| 所有展示继续由调用项目实现 | `mdpolish` 表面积最小 | 每个项目都要复制重放、排序和哈希校验,容易产生不同解释 | 不采用 |
|
||||
| 把原文、所有阶段和 renderer 直接塞进 `TransformResult` | 调用入口最短 | 流水线结果永久重复持有大文本;核心执行和展示投影耦合 | 不采用 |
|
||||
| 恢复 `0005`、`0007` 的完整报告与评审器 | 已有历史实现和界面经验 | 重新引入 artifact、路径、HTTP、Node.js 和项目工作流 | 不采用 |
|
||||
| 新建纯内存 `ReviewDocument` 和通用 Markdown reporter | 唯一可信重放留在 Python;项目仍控制 UI 和持久化 | 增加一组公共模型,并会额外占用中间快照内存 | 采用 |
|
||||
| 另建独立 Python 分发包 | 边界最物理独立 | 第一版逻辑很小且必须紧跟核心模型版本,增加发布协调 | 本轮不采用 |
|
||||
|
||||
## 5. 职责边界
|
||||
|
||||
采用后的依赖方向为:
|
||||
|
||||
```text
|
||||
项目选择 Modifier、参数和顺序
|
||||
│
|
||||
▼
|
||||
Pipeline.transform()
|
||||
│
|
||||
▼
|
||||
TransformResult
|
||||
│
|
||||
│ + 调用方持有的原始 Markdown
|
||||
▼
|
||||
build_review_document()
|
||||
│
|
||||
▼
|
||||
ReviewDocument
|
||||
│ │
|
||||
│ └── render_markdown_report() ──► 内存字符串
|
||||
▼
|
||||
项目 HTML / 终端 / 桌面界面
|
||||
│
|
||||
▼
|
||||
项目决定是否以及怎样保存
|
||||
```
|
||||
|
||||
| 层次 | 负责 | 不负责 |
|
||||
| --- | --- | --- |
|
||||
| `Pipeline` | 执行修改并返回当前权威结果 | 为展示重放历史 |
|
||||
| 评审投影 | 验证并解释已有结果,建立完整阶段 | 重新运行规则、读写文件 |
|
||||
| 通用 reporter | 把评审投影编排成确定的 Markdown 源码报告 | 业务样式、权限、保存 |
|
||||
| 使用项目 | 页面、文件名、目录、脱敏、审核流程 | 重写核心修改应用语义 |
|
||||
|
||||
`mdpolish` 仍然不知道“这份字符串来自哪个文件”。调用方可以把报告字符串写入文件,但该动作不属于本库保证。
|
||||
|
||||
## 6. 第一版公共接口
|
||||
|
||||
第一版拟提供受支持的模块路径:
|
||||
|
||||
```python
|
||||
from mdpolish.review import (
|
||||
ReviewBuildError,
|
||||
ReviewChange,
|
||||
ReviewCurrentKind,
|
||||
ReviewDocument,
|
||||
ReviewLocation,
|
||||
ReviewStage,
|
||||
build_review_document,
|
||||
render_markdown_report,
|
||||
)
|
||||
```
|
||||
|
||||
本轮不把这些名称重新导出到包根,避免继续扩大 `mdpolish.__init__`。精确字段类型在实现时可以按严格类型检查机械调整,
|
||||
但必须满足以下语义。
|
||||
|
||||
### 6.1 `ReviewLocation`
|
||||
|
||||
表示修改器执行前快照中的人类位置:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReviewLocation:
|
||||
line: int # 1-based
|
||||
column: int # 1-based,Python Unicode 码点口径
|
||||
```
|
||||
|
||||
权威修改范围仍是 `Change.span`。行列只用于人查看,不用于重新应用修改,也不转换成 UTF-16、字节偏移或终端显示宽度。
|
||||
位置口径与执行器的 `len()` 和字符串切片一致:BOM 和组合字符各占一个 Python Unicode 码点;补充平面字符占一个码点,
|
||||
不是两个 UTF-16 code unit。行列指向 `span.start`。LF、CRLF 和 CR 都作为物理换行;CRLF 是一个换行边界,但原始偏移中
|
||||
仍占两个码点。计算前不规范化正文或换行。
|
||||
|
||||
### 6.2 `ReviewChange`
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReviewChange:
|
||||
change: Change
|
||||
location: ReviewLocation
|
||||
```
|
||||
|
||||
它保留原 `Change`,不复制或改名其中的 modifier、proposal、reason、span、before、after 和哈希事实。
|
||||
|
||||
这里明确选择嵌入而不是复制字段:`Change` 是同一包内的权威已应用修改记录,另建一份近似字段会形成第二个事实来源。
|
||||
代价是两个公共模型存在有意耦合;今后增加、删除或改变 `Change` 字段及语义时,必须在 design 中同步评审
|
||||
`ReviewChange`、reporter 和兼容性,不能把它当作无关的内部改动。
|
||||
|
||||
### 6.3 `ReviewStage`
|
||||
|
||||
一个阶段表示某个修改器执行前后的可信快照:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReviewStage:
|
||||
modifier_position: int
|
||||
modifier: ModifierInfo
|
||||
before_sha256: str
|
||||
after_sha256: str
|
||||
before_markdown: str
|
||||
after_markdown: str
|
||||
changes: tuple[ReviewChange, ...]
|
||||
```
|
||||
|
||||
同一阶段的修改范围全部相对于 `before_markdown`。零修改阶段的前后字符串和哈希相同,不能伪造 `Change`。
|
||||
|
||||
### 6.4 `ReviewDocument`
|
||||
|
||||
```python
|
||||
class ReviewCurrentKind(StrEnum):
|
||||
SUCCESS_OUTPUT = "success_output"
|
||||
PARTIAL_OUTPUT = "partial_output"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReviewDocument:
|
||||
status: RunStatus
|
||||
current_kind: ReviewCurrentKind
|
||||
input_sha256: str
|
||||
current_sha256: str
|
||||
input_markdown: str
|
||||
current_markdown: str
|
||||
modifiers: tuple[ModifierInfo, ...]
|
||||
stages: tuple[ReviewStage, ...]
|
||||
stages_complete: bool
|
||||
errors: tuple[RunError, ...]
|
||||
residual_proposals: tuple[ResidualProposal, ...]
|
||||
```
|
||||
|
||||
- `success` 使用 `output_markdown`,`current_kind` 为 `success_output`;
|
||||
- `failed` 和 `unstable` 使用 `partial_markdown`,明确标记为 `partial_output`;
|
||||
- `unstable` 仍然不是成功输出,reporter 不能省略这一提示;
|
||||
- `residual_proposals` 只作为证据保留,绝不应用;
|
||||
- `ReviewDocument` 不含路径、标题、业务 ID、时间或保存状态。
|
||||
|
||||
`ReviewDocument` 不再提供一份平铺的 `changes`。公共且权威的遍历顺序是先遍历 `stages`,再遍历每个
|
||||
`ReviewStage.changes`;reporter 不得按 `Change.modifier_position` 自行二次分组。构建时必须验证每条嵌入 `Change` 的
|
||||
`modifier_position`、身份和版本与所属阶段一致。阶段内顺序与核心 `changes` 的报告顺序完全相同。
|
||||
|
||||
`stages_complete=True` 只在能够证明所有修改器都完成 transform 阶段时成立:`success`、`unstable`,以及错误全部发生在
|
||||
`final_review` 的 `failed`。它不表示 final review 成功,也不表示结果可作为正式输出。
|
||||
|
||||
### 6.5 可判定的错误阶段与不完整阶段
|
||||
|
||||
当前 `ErrorStage.TRANSFORM` 同时表示预检失败和修改器运行失败。仅凭空 `changes` 无法区分“所有修改器均未运行”、
|
||||
“首个修改器失败”和“前面已经完成了若干零修改器”。reporter 也不能解析可能变化的错误消息来猜测。因此本文给公共
|
||||
`ErrorStage` 增加:
|
||||
|
||||
```python
|
||||
class ErrorStage(StrEnum):
|
||||
PREFLIGHT = "preflight"
|
||||
TRANSFORM = "transform"
|
||||
FINAL_REVIEW = "final_review"
|
||||
```
|
||||
|
||||
`Pipeline._preflight_modifiers()` 产生的所有错误使用 `PREFLIGHT`。进入修改器循环后,元数据复核、`propose()`、批次验证和
|
||||
应用错误继续使用 `TRANSFORM`;final review 的既有错误继续使用 `FINAL_REVIEW`。这是错误分类的加法变更,不改变
|
||||
`RunStatus`、当前文本或清洗结果。
|
||||
|
||||
评审投影按以下规则处理,不从 `changes` 数量推测执行进度:
|
||||
|
||||
| 情形 | `stages` | `current_markdown` | `stages_complete` |
|
||||
| --- | --- | --- | --- |
|
||||
| `PREFLIGHT` 失败 | 空元组;即使预检已经读取了部分元数据,也没有修改器阶段 | 必须等于输入 | `False` |
|
||||
| 修改器位置 `p` 在执行期元数据复核或 `propose()` 失败 | 恰好包含已完成的 `0..p-1` 阶段,包括其中的零修改阶段 | 等于位置 `p` 的修改前快照 | `False` |
|
||||
| 位置 `p` 已返回 proposals,但再次元数据复核、批次验证或应用失败 | 同上;位置 `p` 不建立阶段,也不伪装成零修改阶段 | 等于位置 `p` 的修改前快照 | `False` |
|
||||
| 只有 `FINAL_REVIEW` 错误 | 包含全部 transform 阶段 | transform 后的当前文本 | `True` |
|
||||
| `success` 或 `unstable` | 包含全部 transform 阶段 | 对应状态的当前文本 | `True` |
|
||||
|
||||
只有一个批次通过验证、原子应用并得到可信的 `after_sha256`,所属修改器阶段才算完成。已经调用 `propose()` 不算完成;
|
||||
失败修改器已知的 before 快照通过 `ReviewDocument.current_markdown` 表达,错误通过 `RunError` 表达,不增加“半阶段”公共
|
||||
模型。失败位置之后的修改器一律不进入 `stages`。
|
||||
|
||||
### 6.6 构建函数
|
||||
|
||||
```python
|
||||
def build_review_document(
|
||||
input_markdown: str,
|
||||
result: TransformResult,
|
||||
) -> ReviewDocument:
|
||||
...
|
||||
```
|
||||
|
||||
原始全文由调用方显式传入。这样不会让每个 `TransformResult` 默认再保存一份输入,也能在构建评审视图时验证
|
||||
`input_sha256`。
|
||||
|
||||
## 7. 可信重放规则
|
||||
|
||||
`build_review_document()` 不调用 `Modifier.propose()`。它只解释已经存在的不可变结果,步骤固定为:
|
||||
|
||||
1. 验证 `input_markdown` 类型及 SHA-256 等于 `result.input_sha256`;
|
||||
2. 选择状态对应的当前文本,并验证其哈希等于 `result.current_sha256`;
|
||||
3. 根据明确的 `ErrorStage` 和错误位置确定已完成阶段的右开边界;preflight 或 transform 失败只能有一个同阶段错误,
|
||||
final review 可以有多个错误,混合阶段或越界位置直接拒绝;
|
||||
4. 验证 preflight 失败没有实际修改且当前文本等于输入;transform 失败的实际修改位置都严格小于失败位置;final review
|
||||
失败、`success` 和 `unstable` 的完成边界必须覆盖全部修改器;
|
||||
5. 验证 `changes` 的 `modifier_position` 不倒退,且身份、版本与 `result.modifiers` 对应;
|
||||
6. 按修改器位置收集一个批次;同批实际修改必须具有相同的 `before_sha256` 和 `after_sha256`;
|
||||
7. 验证当前快照哈希、`proposal_ref`、范围、`before` 原文、重复和冲突;
|
||||
8. 在修改前快照上计算每条修改的 1-based 行列;
|
||||
9. 调用与核心执行器共用的私有应用原语,从后向前原子应用批次;
|
||||
10. 验证新文本哈希等于批次 `after_sha256`;
|
||||
11. 对完成边界内没有实际修改的修改器建立前后相同的阶段;
|
||||
12. 全链结束后验证文本和值都等于 `result` 中的当前结果。
|
||||
|
||||
范围冲突、排序和字符串应用不得在 `review.py` 中维护一套稍有不同的实现。批准后允许从 `edits.py` 提取一个私有的、
|
||||
无审计副作用的应用原语,由正常执行和评审重放直接共用。规范排序键、冲突语义和字符串应用只在这个原语中存在;重放
|
||||
不能复制排序 tuple,也不能另写一个“等价”应用函数。核心 `apply_modifier_batch()` 仍负责从 proposals 验证并生成审计
|
||||
`Change`,重放则把已经校验的实际修改转换成该原语的严格输入。
|
||||
|
||||
重构前后 `validate_modifier_batch()` 和 `apply_modifier_batch()` 的公共签名、返回类型、异常类型及可观察行为必须保持不变。
|
||||
除现有回归测试外,必须把同一组合成输入和批次序列分别送入正常执行路径和评审重放路径,逐阶段比较完全相同的
|
||||
before/after 文本及 SHA-256,并比较最终文本及 SHA-256;只比较最终值不足以验收这次重构。
|
||||
|
||||
任一步验证失败都抛出 `ReviewBuildError`。错误消息只说明失败的阶段、修改器位置和契约类型,不包含完整 Markdown、
|
||||
`before`、`after` 或周边文本,不通过文本搜索、diff 猜测或跳过错误继续生成近似报告。
|
||||
|
||||
## 8. Markdown 报告
|
||||
|
||||
第一版提供:
|
||||
|
||||
```python
|
||||
def render_markdown_report(
|
||||
review: ReviewDocument,
|
||||
*,
|
||||
residual_limit: int = 20,
|
||||
) -> str:
|
||||
...
|
||||
```
|
||||
|
||||
它是纯函数,只返回一个 Markdown 字符串。固定内容包括:
|
||||
|
||||
1. 状态,以及当前内容是成功输出还是部分输出;
|
||||
2. 输入和当前 SHA-256;
|
||||
3. 修改器顺序、版本和实际修改数量;
|
||||
4. 完整原始 Markdown 的源码区块;
|
||||
5. 完整成功输出或明确标注的完整部分输出源码区块;
|
||||
6. 原始输入到当前文本的统一 diff;
|
||||
7. 按可信修改器阶段分组的实际修改详情,包括理由、位置、`before` 和 `after`;
|
||||
8. 错误和残留候选的安全摘要。
|
||||
|
||||
报告展示的是 Markdown 源码,不渲染输入中的 Markdown、HTML、图片或链接。源码围栏长度必须根据内容动态选择,不能因为
|
||||
正文包含三反引号或三波浪号而提前结束。空文档、无末尾换行和不同物理换行必须有明确、可测试的表示。
|
||||
|
||||
统一 diff 只是输入到当前文本的人工视图,不是修改权威,也不承担逐修改器归属。逐修改器归属只看 `ReviewStage` 和
|
||||
`ReviewChange`。
|
||||
|
||||
统一 diff 的固定文件标签用于直接暴露结果性质:成功为 `a/input.md` 与 `b/output.md`,`unstable` 为 `a/input.md` 与
|
||||
`b/unstable.partial.md`,`failed` 为 `a/input.md` 与 `b/failed.partial.md`。diff 之前还必须输出明确的 `status` 和
|
||||
`current_kind`,不能只依赖文件名提示。
|
||||
|
||||
`ReviewDocument.residual_proposals` 为调用方保留完整证据,但通用 reporter 不重复输出候选中的 `expected_text`、
|
||||
`replacement` 或正文摘要。它先输出残留总数,再按结果中的确定顺序最多列出 `residual_limit` 条;每条只显示
|
||||
modifier id/version/position、proposal index、reason、edit 数量,以及每个 edit 的 span 和
|
||||
`len(expected_text)` / `len(replacement)`。若有省略,必须输出省略数量。`residual_limit` 必须满足
|
||||
`type(residual_limit) is int` 且非负,非法值直接报错;`0` 表示只显示统计。这个限额只影响字符串展示,不截断
|
||||
`ReviewDocument` 中的证据。
|
||||
|
||||
第一版不提供 JSON reporter。稳定 JSON 会立即形成新的序列化 schema,而当前需求只要求内存模型和可读报告。调用项目若
|
||||
要定义自己的 JSON、HTML 或 API,可以从 `ReviewDocument` 转换,并在自己的仓库维护契约。
|
||||
|
||||
## 9. 状态与失败边界
|
||||
|
||||
| `RunStatus` | 可以展示的全文 | 阶段语义 |
|
||||
| --- | --- | --- |
|
||||
| `success` | 正式成功输出 | 所有修改器 transform 阶段完整 |
|
||||
| `unstable` | 明确标注的部分/不稳定当前文本 | transform 阶段完整;残留候选不应用 |
|
||||
| `failed`,仅 final review 错误 | 明确标注的部分当前文本 | transform 阶段完整,final review 失败 |
|
||||
| `failed`,preflight 错误 | 与输入相同的部分当前文本 | 没有修改器运行,阶段为空,`stages_complete=False` |
|
||||
| `failed`,含 transform 错误 | 明确标注的部分当前文本 | 只展示失败位置前已完成的阶段,`stages_complete=False` |
|
||||
|
||||
reporter 不把 `failed` 或 `unstable` 的 `partial_markdown` 命名为 cleaned、final、successful 或正式结果。
|
||||
|
||||
## 10. 隐私与资源边界
|
||||
|
||||
`ReviewDocument` 和 Markdown 报告有意包含完整输入、当前文本、`before`、`after` 和 diff,因此可能还原敏感文档。该能力
|
||||
只改变内存表示,不改变数据权限:
|
||||
|
||||
- 库不自动记录、打印、缓存、保存或上传评审内容;
|
||||
- 调用方决定是否生成 reporter 字符串,以及是否将它持久化;
|
||||
- 异常消息和测试失败说明不得泄露正文;
|
||||
- README 必须明确报告不是安全日志,保存时应遵守调用项目的数据边界;
|
||||
- 本轮测试只使用虚构小文本,不读取真实数据或历史 artifact。
|
||||
|
||||
每个 `ReviewStage` 保存完整前后字符串,内存可能随修改器数量增长。第一版优先保证可信和接口简单,不声称支持无限长度
|
||||
文档。实现验收应记录合成规模和内存风险;若真实消费出现瓶颈,再设计惰性阶段或紧凑编辑图,不在本轮提前增加两套模型。
|
||||
|
||||
## 11. 源码结构、版本与兼容
|
||||
|
||||
批准后拟新增:
|
||||
|
||||
```text
|
||||
src/mdpolish/
|
||||
├── review.py # 公共评审模型、构建函数和 Markdown reporter
|
||||
└── _review_replay.py # 必要时保存私有可信重放辅助
|
||||
tests/
|
||||
└── test_review.py
|
||||
research-wiki/explanation/
|
||||
└── review-projection.md # 实现后解释当前机制
|
||||
```
|
||||
|
||||
同时会修改现有 `models.py`(新增 `ErrorStage.PREFLIGHT`)、`pipeline.py`(预检错误归类)、`edits.py`(私有应用原语)及其
|
||||
对应现有测试。除这些已列明位置和新增评审模块外,不借本设计改动其他核心契约。
|
||||
|
||||
若共用编辑辅助可以清楚留在 `edits.py`,则不强制创建 `_review_replay.py`。实现时以单一可信应用逻辑和可读性为准,不能仅为
|
||||
匹配草图创建空壳模块。
|
||||
|
||||
本文增加公共模型和 reporter,并有限改变 `0008` 的职责边界,计划包版本为 `0.4.0`,不是 `0.3.1`。现有
|
||||
`TransformResult` 字段、顶层导出、修改器版本和清洗语义保持不变;`ErrorStage` 增加 `PREFLIGHT`,原有两个枚举值语义
|
||||
不变。调用方不使用 `mdpolish.review` 时,除预检错误获得更准确的阶段值外行为不变。
|
||||
|
||||
实现和验收完成后才更新根 README 的当前版本候选、能力边界和检查结果。能力表应增加
|
||||
`build_review_document()` / `render_markdown_report()`;“当前不提供”应改为不提供 artifact、文件/JSON/HTML 输出、
|
||||
Web 评审器和项目工作流,并明确这两个新函数只返回内存对象或字符串,不读取或写入文件。本文获批不自动授权 tag、
|
||||
Release 或发布。
|
||||
|
||||
## 12. 测试与验收
|
||||
|
||||
### 12.1 结果重放
|
||||
|
||||
合成测试至少覆盖:
|
||||
|
||||
- 空文档、零修改和空流水线;
|
||||
- 一个修改器的插入、删除、替换和多编辑原子批次;
|
||||
- 多修改器依次修改,后一个修改器读取并修改前一个输出;
|
||||
- 一个候选包含多条编辑,`proposal_ref` 关联保持不变;
|
||||
- 首个、中间和末尾零修改器阶段;
|
||||
- 中文、补充平面字符、组合字符、BOM、LF、CRLF、CR 和无末尾换行;
|
||||
- 对 BOM、组合字符和补充平面字符断言精确行列,确认它们分别按 Python 码点而不是显示宽度、UTF-16 或字节计算;
|
||||
- `success`、`unstable`、preflight failure、transform failure 和 final review failure;
|
||||
- preflight failure 的空阶段和原样当前文本;修改器执行期元数据、`propose()`、批次验证和应用分别失败时,失败位置之前的
|
||||
阶段边界与当前文本;
|
||||
- 批次验证失败时,即使 `propose()` 已经返回,失败修改器也不产生零修改阶段;
|
||||
- 残留候选只展示不应用;
|
||||
- 相同输入与结果得到值相等、顺序相同的评审对象;
|
||||
- 同一组合成输入和批次序列通过核心执行与评审重放得到完全相同的逐阶段及最终文本和 SHA-256。
|
||||
|
||||
### 12.2 篡改和失败关闭
|
||||
|
||||
至少拒绝:
|
||||
|
||||
- 输入哈希不符;
|
||||
- 状态对应文本或当前哈希不符;
|
||||
- 错误阶段混合、preflight/transform 多错误、错误位置越界或状态与错误阶段不相容;
|
||||
- preflight 失败却含实际修改或当前文本不等于输入;transform 失败却含失败位置或其后的实际修改;
|
||||
- 修改器位置倒退、越界或身份不符;
|
||||
- proposal 引用位置或快照不符;
|
||||
- 批次前后哈希不一致;
|
||||
- 范围越界、`before` 长度不符、原文不符、重复和冲突修改;
|
||||
- 重放完成后文本或哈希与结果不符。
|
||||
|
||||
失败消息测试必须确认不会包含合成正文片段。
|
||||
|
||||
### 12.3 Markdown reporter
|
||||
|
||||
至少覆盖:
|
||||
|
||||
- 完整输入和完整当前文本都能在报告中找到并区分;
|
||||
- success、failed、unstable 标签不会混淆;
|
||||
- 统一 diff 标签分别使用 `output.md`、`unstable.partial.md` 和 `failed.partial.md`;
|
||||
- 修改器顺序、版本、计数、理由和阶段位置正确;
|
||||
- reporter 只按 `ReviewStage` 遍历实际修改,不自行按 `modifier_position` 重组;
|
||||
- 残留候选总数、`residual_limit=0`、截断及省略计数正确,且报告不包含其 `expected_text`、`replacement` 或正文摘要;
|
||||
- 输入包含反引号围栏、波浪号围栏、HTML、链接和图片语法时只作为源码显示;
|
||||
- 空内容、无末尾换行和混合 Unicode 不被静默规范化;
|
||||
- 统一 diff 与输入和当前文本一致,但不被当作修改记录;
|
||||
- renderer 不读文件、不访问网络、不调用修改器。
|
||||
|
||||
### 12.4 基础回归与交付
|
||||
|
||||
实现完成后必须实际运行根 README 当时列出的全部检查,并额外确认:
|
||||
|
||||
- 现有核心和修改器测试结果不变;
|
||||
- Python 最低支持版本和当前支持版本都能构建评审视图;
|
||||
- wheel 包含公共 `review.py` 和类型标记,不包含 tests、Wiki、artifact、页面或真实数据;
|
||||
- 核心安装仍无第三方运行依赖;
|
||||
- README 示例只操作内存字符串,不暗示文件已经写入;
|
||||
- `AGENTS.md` 与 `CLAUDE.md` 除标题外正文一致;
|
||||
- Git diff 不混入用户已有改动、真实文本、大文件或生成产物。
|
||||
|
||||
## 13. 风险与代价
|
||||
|
||||
- **公共表面积增加:** 新 dataclass 和函数一旦发布就需要兼容管理;第一版只提供完成当前问题所需的窄接口。
|
||||
- **中间全文占用内存:** 完整阶段便于可信评审,但对大文档和长流水线有成本;README 必须诚实说明未定义极端规模保证。
|
||||
- **Markdown 报告会放大敏感内容:** 它包含原文和当前全文;库不保存并不能替代调用方的数据治理。
|
||||
- **错误枚举加值:** 精确区分 preflight 是公共契约变更;依赖方若对 `ErrorStage` 做穷举匹配,需要处理新值。相比让
|
||||
reporter 猜测执行进度,这个显式兼容成本更可控。
|
||||
- **报告可能被误当成权威:** 修改权威仍是 `TransformResult` 和重放校验;统一 diff 与 Markdown 排版只是派生视图。
|
||||
- **旧代码容易被直接搬回:** 历史 `_artifact_replay.py` 可作为测试经验,但旧 artifact 类型、UTF-16 坐标和文件契约不得
|
||||
复制进新的公共模型。
|
||||
|
||||
## 14. 批准后的实施边界
|
||||
|
||||
用户明确批准本文后,只授权:
|
||||
|
||||
1. 新增第 11 节所需的通用评审源码和合成测试;
|
||||
2. 为单一可信批次语义做必要的私有机械重构,不改变公共函数签名和清洗结果;
|
||||
3. 给 `ErrorStage` 增加 `PREFLIGHT` 并调整预检错误归类,再实现第 6 至 9 节的内存模型、可信重放和 Markdown reporter;
|
||||
4. 更新 README 当前能力、示例和真实验证结果;
|
||||
5. 新增实现后的 `explanation/review-projection.md`;
|
||||
6. 将包版本候选更新为 `0.4.0`,完成提交前 diff、测试和 wheel 检查。
|
||||
|
||||
批准本文不授权:
|
||||
|
||||
- 提交、push、创建 PR、创建 tag、GitHub Release 或发布 wheel;
|
||||
- 修改其他仓库、消费测试仓、外部系统或真实数据;
|
||||
- 创建 artifact、报告文件、HTML 页面、CLI、服务、前端、配置或默认流水线;
|
||||
- 改变清洗规则、误删容忍度、修改器顺序、`RunStatus` 或输入输出持久化协议。
|
||||
@@ -0,0 +1,703 @@
|
||||
# 0012:ReviewDocument 的正式机器投影
|
||||
|
||||
## 状态
|
||||
|
||||
已于 2026-08-28 获用户明确批准,按本文第 16 节实施。本文自批准起冻结;后续改变决策需新增 design 并使用
|
||||
`supersedes` 指向本文。
|
||||
|
||||
`supersedes: 0011`(范围有限):本文只替代 `0011` 第 8 节“第一版不提供 JSON reporter”和其中把稳定序列化继续留给
|
||||
调用项目的决定。`0011` 已批准并实现的可信重放、`ReviewDocument`、阶段坐标、Markdown reporter、无文件 I/O 和项目拥有
|
||||
持久化决定权继续有效。
|
||||
|
||||
本文不改变 `Pipeline`、`TransformResult`、清洗语义或评审重放结果。它只定义怎样把已经建立并验证的
|
||||
`ReviewDocument` 转成稳定的普通 Python 数据,再按同一结构编码为 JSON。
|
||||
|
||||
## 1. 问题与可观察现象
|
||||
|
||||
`v0.4.0` 已经可以在同一个 Python 进程中这样消费评审结果:
|
||||
|
||||
```python
|
||||
review = build_review_document(input_markdown, result)
|
||||
|
||||
if review.status is RunStatus.SUCCESS:
|
||||
print(review.current_sha256)
|
||||
```
|
||||
|
||||
这时调用方直接使用不可变的 `ReviewDocument`、`ReviewStage` 和 `ReviewChange`,不需要序列化。
|
||||
|
||||
一旦评审结果需要经过 Web、数据库、消息队列、JSON 文件或其他语言,内部 Python 对象就不能直接作为契约。当前项目端只能
|
||||
自己决定怎样处理 dataclass、`StrEnum`、tuple、半开范围、阶段哈希和部分输出。最短的做法看似是:
|
||||
|
||||
```python
|
||||
payload = dataclasses.asdict(review)
|
||||
```
|
||||
|
||||
但这会产生四类问题:
|
||||
|
||||
1. 内部 dataclass 字段会在未经评审的情况下变成外部协议;以后正常的 Python 重构也会破坏消费者;
|
||||
2. enum、tuple、递归参数值和位置单位没有正式 JSON 表达,消费者容易形成不同解释;
|
||||
3. `ReviewDocument` 包含完整输入、当前全文、每阶段全文和修改片段,机械展开会默认暴露全部正文;
|
||||
4. 当前 `RunError.error_type` 是诊断用 Python 异常类名,不能被项目端误当成稳定错误代码。
|
||||
|
||||
因此,需要由 `mdpolish` 自己提供一个经过选择和转换的对外视图,而不是让每个项目根据内部字段猜一个版本。
|
||||
|
||||
本文把这个视图称为“机器投影”:它是 `ReviewDocument` 的有损、单向、稳定表示,不是内部对象的镜像,也不是可用于重新
|
||||
应用修改的序列化快照。
|
||||
|
||||
## 2. 外部规范带来的约束
|
||||
|
||||
2026-08-28 查阅的官方规范给出以下直接约束:
|
||||
|
||||
- [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259.html) 把 JSON object 定义为无序名称/值集合,把 array 定义为有序序列;
|
||||
对外语义不能依赖 object 成员顺序,但修改器、阶段、修改和残留候选的顺序必须用 array 保存;
|
||||
- RFC 8259 要求开放系统中的 JSON 文本使用 UTF-8,成员名应唯一,并指出超出 IEEE 754 binary64 精确整数范围的数字会降低
|
||||
互操作性;本投影只发出唯一键、可 UTF-8 编码的字符串和安全范围整数;
|
||||
- [Python `json` 文档](https://docs.python.org/3/library/json.html) 显示 `allow_nan` 默认允许非标准的 `NaN` / `Infinity`,
|
||||
`ensure_ascii` 默认转义非 ASCII;官方 reporter 必须显式使用 `allow_nan=False` 和 `ensure_ascii=False`;
|
||||
- [JSON Schema 2020-12](https://json-schema.org/draft/2020-12/json-schema-core.html) 区分 schema 版本和实例内容,并允许通过
|
||||
schema 约束对象、数组和 enum;本轮先固定实例 schema 和兼容策略,不引入运行时 validator 或第三方 schema 依赖;
|
||||
- [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/sarif-v2.1.0-os.html) 区分稳定标识符与给人看的消息,并明确
|
||||
不应从没有稳定标识的来源中猜造精细规则 ID;本投影只为现有 `ErrorStage` 提供粗粒度稳定代码,不从异常类名或文案
|
||||
推断更细错误原因。
|
||||
|
||||
这些参考不表示 `mdpolish` 要实现 SARIF 或 JSON Schema validator。它们只帮助确定 JSON 互操作、顺序、版本和错误身份的
|
||||
边界。
|
||||
|
||||
## 3. 目标与非目标
|
||||
|
||||
### 3.1 目标
|
||||
|
||||
- 提供官方 `ReviewDocument -> dict` 投影,返回只含 JSON 基本值的全新普通数据结构;
|
||||
- 提供使用同一投影的确定性 JSON reporter,不建立第二套字段;
|
||||
- 每个结果都携带独立于包版本的 `schema_version`;
|
||||
- 固定 enum、array、哈希和位置的外部表达,避免调用方解释 Python 内部对象;
|
||||
- 提供 `summary`、`changes`、`full` 三个单调增加的正文暴露等级,默认不暴露正文;
|
||||
- 给当前三个错误阶段提供粗粒度、稳定、可供程序判断的代码,同时保留诊断字段的非稳定身份;
|
||||
- 明确 schema 的兼容升级规则,并让旧消费者可以安全忽略同一主版本新增的可选字段;
|
||||
- 保持纯函数、无文件 I/O、无网络、零第三方核心运行依赖;
|
||||
- 只用合成内容验证 Unicode、换行、部分输出、残留候选和内容泄漏边界。
|
||||
|
||||
### 3.2 非目标
|
||||
|
||||
- 不提供 `dict` / JSON 到 `ReviewDocument` 的反序列化或 round-trip;
|
||||
- 不把 JSON 投影作为重新应用 `Change`、恢复 Pipeline 或验证原始结果的权威输入;
|
||||
- 不生成或保存 `.json` 文件,不接收路径,不决定目录、权限、覆盖或保留周期;
|
||||
- 不提供 CLI、Web API、数据库模型、消息队列协议、OpenAPI、HTML 或项目页面;
|
||||
- 不发布 SARIF、JSON Lines、JSON Patch、JSON-LD 或项目 artifact 格式;
|
||||
- 不改变 `ReviewDocument`、`ReviewStage`、`Change`、`RunError` 或 `TransformResult` 的字段;
|
||||
- 不增加细粒度的 Pipeline 错误原因。当前结果没有保存足够的稳定原因身份,本轮不解析异常消息来猜;
|
||||
- 不把 Python 码点坐标转换成 UTF-16、UTF-8 byte offset、LSP 位置或终端显示宽度;
|
||||
- 不承诺 `summary` 是可以公开传播的“安全日志”。它不含正文,但 modifier id、版本、计数和哈希仍可能属于项目元数据;
|
||||
- 不读取、复制或修改项目端测试仓、外部真实文档、历史报告或数据库。
|
||||
|
||||
## 4. 方案比较
|
||||
|
||||
| 方案 | 优点 | 问题 | 选择 |
|
||||
| --- | --- | --- | --- |
|
||||
| 各项目继续 `dataclasses.asdict()` | 上游零工作 | 内部结构意外变成协议;enum、tuple、正文和兼容策略失控 | 不采用 |
|
||||
| 使用 Pydantic / Marshmallow 建模 | schema 和校验工具成熟 | 给零依赖核心增加运行依赖,并形成第二套评审模型 | 不采用 |
|
||||
| 只提供 JSON reporter | 调用入口短 | Web 或数据库仍要解析 JSON 才能得到 Python 基本值 | 不采用 |
|
||||
| 只提供 dict,不提供 reporter | 表面积最小 | 各项目会重复 JSON 编码选项,可能生成 NaN、ASCII 转义或不同格式 | 不采用 |
|
||||
| 手写窄投影,JSON reporter 复用它 | 字段和暴露级别可审计;零依赖;dict 与 JSON 只有一个语义来源 | 需要长期维护 schema 兼容 | 采用 |
|
||||
| 第一版同时发布 JSON Schema 文件 | 其他语言可直接验证 | 增加一份必须与代码同步的公共文件和打包契约;当前尚无独立 validator 需求 | 本轮不采用 |
|
||||
|
||||
如果真实消费者以后需要脱离 Python wheel 独立验证 payload,再新增 design 决定是否把 JSON Schema 文件作为 Release 资产或
|
||||
包资源发布,不能根据本文自动补一个未维护的 schema 文件。
|
||||
|
||||
## 5. 职责与数据流
|
||||
|
||||
```text
|
||||
Pipeline.transform()
|
||||
│
|
||||
▼
|
||||
TransformResult + 原始 Markdown
|
||||
│
|
||||
▼
|
||||
build_review_document() 0011:验证和可信重放
|
||||
│
|
||||
▼
|
||||
ReviewDocument
|
||||
│
|
||||
├── render_markdown_report() 人工完整评审
|
||||
│
|
||||
└── review_document_to_dict(detail=...)
|
||||
│
|
||||
├── 项目 Web / 数据库 / 其他语言
|
||||
│
|
||||
└── render_json_report() 内存 JSON 字符串
|
||||
```
|
||||
|
||||
`build_review_document()` 仍是建立可信评审对象的唯一入口。机器投影不重新运行 modifier,也不重新实现阶段重放、冲突检查或
|
||||
哈希证明。
|
||||
|
||||
投影函数负责:
|
||||
|
||||
- 检查输入确实是 `ReviewDocument`;
|
||||
- 把已知 enum 转成规定字符串;
|
||||
- 把 tuple 和内部值转成规定 array/object;
|
||||
- 选择当前 detail 允许的字段;
|
||||
- 拒绝不能安全进入标准 JSON 的值;
|
||||
- 返回一个与原对象不共享 dict/list 容器的全新结果。
|
||||
|
||||
投影函数不负责:
|
||||
|
||||
- 修复手工伪造或语义矛盾的 `ReviewDocument`;
|
||||
- 再次应用修改或重新计算阶段;
|
||||
- 对正文脱敏、截断或摘要生成;
|
||||
- 保存或发送结果。
|
||||
|
||||
调用方应把 `build_review_document()` 返回的对象传给投影。手工构造的对象即使恰好通过结构检查,也不获得可信重放保证。
|
||||
|
||||
## 6. 第一版公共接口
|
||||
|
||||
公共名称继续位于 `mdpolish.review`,不在包根 `mdpolish.__init__` 重新导出:
|
||||
|
||||
```python
|
||||
from enum import StrEnum
|
||||
from typing import TypeAlias
|
||||
|
||||
|
||||
JsonScalar: TypeAlias = str | int | float | bool | None
|
||||
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
||||
ReviewProjection: TypeAlias = dict[str, JsonValue]
|
||||
|
||||
|
||||
class ReviewDetail(StrEnum):
|
||||
SUMMARY = "summary"
|
||||
CHANGES = "changes"
|
||||
FULL = "full"
|
||||
|
||||
|
||||
class ReviewProjectionError(ValueError):
|
||||
"""ReviewDocument 不能按正式机器契约投影。"""
|
||||
|
||||
|
||||
def review_document_to_dict(
|
||||
review: ReviewDocument,
|
||||
*,
|
||||
detail: ReviewDetail | str = ReviewDetail.SUMMARY,
|
||||
) -> ReviewProjection:
|
||||
...
|
||||
|
||||
|
||||
def render_json_report(
|
||||
review: ReviewDocument,
|
||||
*,
|
||||
detail: ReviewDetail | str = ReviewDetail.SUMMARY,
|
||||
) -> str:
|
||||
...
|
||||
```
|
||||
|
||||
允许字符串形式是为了让项目配置和 Web 层直接传入 `"summary"`、`"changes"` 或 `"full"`。其他字符串、非字符串且非
|
||||
`ReviewDetail` 的值直接抛出 `ReviewProjectionError`,不能回退到默认值。
|
||||
|
||||
默认 `summary` 是有意的隐私边界:调用方必须显式选择 `changes` 或 `full` 才能得到 Markdown 正文或修改片段。
|
||||
|
||||
返回类型是普通可变 dict/list,因为目标就是 Python 和 JSON 生态通用的数据载体;权威 `ReviewDocument` 仍然不可变。
|
||||
每次调用都返回新的递归容器,修改返回值不能反向改变 review,也不能影响下一次投影。
|
||||
|
||||
## 7. Schema 身份与顶层结构
|
||||
|
||||
第一版 schema 名称和版本固定为:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_name": "mdpolish.review",
|
||||
"schema_version": "1.0"
|
||||
}
|
||||
```
|
||||
|
||||
`schema_version` 是数据格式版本,不是 `mdpolish` 包版本,也不是 modifier 版本。第一版完整顶层结构为:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_name": "mdpolish.review",
|
||||
"schema_version": "1.0",
|
||||
"detail": "summary",
|
||||
"status": "success",
|
||||
"current_kind": "success_output",
|
||||
"stages_complete": true,
|
||||
"hash_contract": {
|
||||
"algorithm": "sha256",
|
||||
"encoding": "utf-8",
|
||||
"normalization": "none"
|
||||
},
|
||||
"coordinate_contract": {
|
||||
"offset_unit": "unicode_code_point",
|
||||
"span_index_base": 0,
|
||||
"span_end": "exclusive",
|
||||
"location_index_base": 1,
|
||||
"physical_line_endings": ["lf", "crlf", "cr"]
|
||||
},
|
||||
"input": {},
|
||||
"current": {},
|
||||
"counts": {},
|
||||
"modifiers": [],
|
||||
"stages": [],
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
`residual_proposals` 只在 `changes` 和 `full` 中出现;`summary` 通过 `counts.residual_proposal_count` 报告总数。
|
||||
|
||||
所有 object 键必须唯一。上面的成员排列是官方 renderer 的可读输出顺序,但 JSON object 本身无序,消费者不得根据键顺序
|
||||
解释语义。所有 array 顺序都有意义,必须保持 `ReviewDocument` 的权威顺序,不按 ID、哈希或文本重新排序。
|
||||
|
||||
### 7.1 enum 表达
|
||||
|
||||
enum 一律投影为已批准的 `.value` 小写字符串,不输出 Python 类名、`repr()` 或整数序号:
|
||||
|
||||
| Python enum | 第一版允许值 |
|
||||
| --- | --- |
|
||||
| `RunStatus` | `success`、`failed`、`unstable` |
|
||||
| `ReviewCurrentKind` | `success_output`、`partial_output` |
|
||||
| `ErrorStage` | `preflight`、`transform`、`final_review` |
|
||||
| `ReviewDetail` | `summary`、`changes`、`full` |
|
||||
|
||||
投影遇到未知 enum 实例或未知值时失败,不把 `str(value)` 当作向前兼容。
|
||||
|
||||
### 7.2 哈希口径
|
||||
|
||||
所有 `sha256` 字段都是对对应精确 Markdown 字符串执行:
|
||||
|
||||
```python
|
||||
sha256(markdown.encode("utf-8")).hexdigest()
|
||||
```
|
||||
|
||||
结果为 64 位小写十六进制字符串。不规范化 Unicode,不统一 CR/LF/CRLF,不添加或删除 BOM、空白或末尾换行。
|
||||
`Change.before_sha256` / `after_sha256` 和阶段哈希指向完整阶段快照,不是片段哈希。
|
||||
|
||||
### 7.3 位置口径
|
||||
|
||||
- `span.start` / `span.end` 是所属 `ReviewStage.before_markdown` 中的 0-based Unicode code point 半开范围;
|
||||
- `location.line` / `location.column` 指向 `span.start`,是同一阶段修改前全文中的 1-based 人类位置;
|
||||
- LF、CRLF、CR 都形成一个物理换行;CRLF 在 offset 中仍占两个 code point;
|
||||
- 不提供最终全文坐标、UTF-16、byte offset 或显示列宽。
|
||||
|
||||
所有计数、位置和长度必须是 `0..2**53-1` 范围内的 JSON integer;超出时投影失败,避免其他语言使用 binary64 数字时静默
|
||||
丢失整数精度。现实内存文档远小于该上限,因此这不是实际文档规模承诺。
|
||||
|
||||
## 8. 三个正文暴露等级
|
||||
|
||||
三个等级必须满足单调关系:
|
||||
|
||||
```text
|
||||
summary 的字段 ⊂ changes 的字段 ⊂ full 的字段
|
||||
```
|
||||
|
||||
高等级只能增加正文相关字段,不能改变低等级已有字段的值、顺序或语义。
|
||||
|
||||
| 内容 | `summary` | `changes` | `full` |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 状态、哈希、坐标契约、计数 | 是 | 是 | 是 |
|
||||
| modifier id / version / position | 是 | 是 | 是 |
|
||||
| modifier parameters / applicability | 否 | 是 | 是 |
|
||||
| 阶段前后哈希和修改数 | 是 | 是 | 是 |
|
||||
| 实际修改位置、理由、`before` / `after` | 否 | 是 | 是 |
|
||||
| 稳定错误代码和错误位置 | 是 | 是 | 是 |
|
||||
| Python 诊断类型和错误消息 | 否 | 是 | 是 |
|
||||
| 残留候选理由、范围、`expected_text` / `replacement` | 否 | 是 | 是 |
|
||||
| 完整输入和当前 Markdown | 否 | 否 | 是 |
|
||||
| 每个阶段的完整 before / after Markdown | 否 | 否 | 是 |
|
||||
|
||||
低等级不允许用 `null` 或空字符串代替被隐藏的正文,而是完全省略对应键。这样消费者能够区分“字段因 detail 未暴露”和“原文
|
||||
本来就是空字符串”。`detail` 顶层字段说明当前投影使用的等级。
|
||||
|
||||
`summary` 只承诺不包含以下正文承载字段:`markdown`、`before`、`after`、`expected_text`、`replacement`、`reason`、
|
||||
`message`、`parameters`、`applicability`。它仍含 modifier identity、哈希、位置和计数,不能在不了解项目数据政策的情况下
|
||||
称为匿名、脱敏或可公开日志。
|
||||
|
||||
`changes` 会暴露实际修改和未应用残留候选中的片段,也会暴露项目 modifier 配置和诊断消息。它可能足以还原敏感局部内容。
|
||||
|
||||
`full` 还会重复保存输入、当前全文及每个完整阶段的前后全文,内存和 JSON 大小可能随修改器数量线性增长。调用方必须显式
|
||||
选择,库不截断、不脱敏,也不自动落盘。
|
||||
|
||||
## 9. 各对象的正式投影
|
||||
|
||||
以下字段名称、类型和层级属于 schema `1.0`。示例中的省略号只为文档可读,正式输出不得包含省略号。
|
||||
|
||||
### 9.1 输入和当前文本
|
||||
|
||||
三个 detail 都输出:
|
||||
|
||||
```json
|
||||
"input": {
|
||||
"sha256": "...",
|
||||
"code_point_length": 123
|
||||
},
|
||||
"current": {
|
||||
"sha256": "...",
|
||||
"code_point_length": 120
|
||||
}
|
||||
```
|
||||
|
||||
`full` 分别增加:
|
||||
|
||||
```json
|
||||
"markdown": "完整文本"
|
||||
```
|
||||
|
||||
`current.markdown` 的性质必须结合根字段 `current_kind` 判断。`partial_output` 永远不能因为进入 JSON 而改名为 cleaned、final
|
||||
或 successful。
|
||||
|
||||
### 9.2 计数
|
||||
|
||||
```json
|
||||
"counts": {
|
||||
"modifier_count": 3,
|
||||
"completed_stage_count": 2,
|
||||
"change_count": 5,
|
||||
"error_count": 1,
|
||||
"residual_proposal_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
这些值是投影时从权威 array 计算的派生摘要,必须与 `ReviewDocument` 一致。`change_count` 是所有完整阶段实际
|
||||
`ReviewChange` 的总数,不包含 residual proposal edit;零修改阶段仍计入 `completed_stage_count`。
|
||||
|
||||
### 9.3 Modifier
|
||||
|
||||
所有 detail 都输出所有 modifier 的稳定身份:
|
||||
|
||||
```json
|
||||
"modifiers": [
|
||||
{
|
||||
"position": 0,
|
||||
"modifier_id": "example.normalize",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`changes` 和 `full` 增加:
|
||||
|
||||
```json
|
||||
"parameters": [
|
||||
["pattern", " {2,}"],
|
||||
["replacement", " "]
|
||||
],
|
||||
"applicability": "调用方声明的适用范围"
|
||||
```
|
||||
|
||||
`parameters` 不投影成 JSON object。当前内部 `ParameterValue` 会把 mapping 和 sequence 都冻结成 tuple;某些嵌套值在运行时
|
||||
无法可靠区分原来是 mapping 还是二元组 sequence。第一版忠实投影冻结后的结构:顶层及所有 tuple 都变成有序 array,
|
||||
标量保持 `str`、`int`、有限 `float`、`bool` 或 `null`。投影不得根据“看起来像键值对”猜成 object。
|
||||
|
||||
### 9.4 完整阶段
|
||||
|
||||
所有 detail 都输出:
|
||||
|
||||
```json
|
||||
"stages": [
|
||||
{
|
||||
"modifier_position": 0,
|
||||
"before": {
|
||||
"sha256": "...",
|
||||
"code_point_length": 123
|
||||
},
|
||||
"after": {
|
||||
"sha256": "...",
|
||||
"code_point_length": 120
|
||||
},
|
||||
"change_count": 2
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`changes` 和 `full` 增加 `changes` array;`full` 再给 `before`、`after` 增加 `markdown`。阶段通过
|
||||
`modifier_position` 引用根 `modifiers`,不复制第二份 modifier 身份。
|
||||
|
||||
阶段顺序与 `ReviewDocument.stages` 相同。不得从 `change_count` 猜测阶段是否完整;完整性继续由根字段
|
||||
`stages_complete` 和已有阶段边界表达。
|
||||
|
||||
### 9.5 已应用修改
|
||||
|
||||
只在 `changes` 和 `full` 中出现:
|
||||
|
||||
```json
|
||||
"changes": [
|
||||
{
|
||||
"proposal_index": 0,
|
||||
"edit_index": 0,
|
||||
"reason": "应用调用方声明的替换",
|
||||
"location": {
|
||||
"line": 3,
|
||||
"column": 7
|
||||
},
|
||||
"span": {
|
||||
"start": 24,
|
||||
"end": 31
|
||||
},
|
||||
"before": "exam-\nple",
|
||||
"after": "example",
|
||||
"before_sha256": "...",
|
||||
"after_sha256": "..."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
修改所在的 modifier 由外层 stage 唯一确定,因此不重复输出 `modifier_id`、版本和位置。`proposal_index` / `edit_index` 保留
|
||||
原权威引用顺序;`before_sha256` / `after_sha256` 是完整阶段快照哈希。
|
||||
|
||||
### 9.6 错误
|
||||
|
||||
所有 detail 都输出稳定身份和位置:
|
||||
|
||||
```json
|
||||
"errors": [
|
||||
{
|
||||
"code": "run.transform_failed",
|
||||
"stage": "transform",
|
||||
"modifier_position": 1,
|
||||
"modifier_id": "example.normalize",
|
||||
"modifier_version": "1.0.0"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`changes` 和 `full` 增加:
|
||||
|
||||
```json
|
||||
"diagnostic_type": "ModifierContractError",
|
||||
"message": "modifier proposal failed"
|
||||
```
|
||||
|
||||
稳定代码只按已经存在的 `ErrorStage` 映射:
|
||||
|
||||
| `ErrorStage` | `code` |
|
||||
| --- | --- |
|
||||
| `preflight` | `run.preflight_failed` |
|
||||
| `transform` | `run.transform_failed` |
|
||||
| `final_review` | `run.final_review_failed` |
|
||||
|
||||
`diagnostic_type` 是当前 Python 异常类名,`message` 是给人排障的消息;两者都不属于稳定程序分支条件。消费者只能使用
|
||||
`code` 和 `stage` 做稳定判断。
|
||||
|
||||
这些代码有意保持粗粒度。当前 `RunError` 没有保存“元数据变化”“propose 失败”“批次验证失败”等稳定原因,投影不得解析
|
||||
`error_type` 或 `message` 猜出更细代码。未来要增加精细代码,必须先用另一份 design 改变 Pipeline 的错误事实来源。
|
||||
|
||||
### 9.7 残留候选
|
||||
|
||||
`summary` 只输出总数。`changes` 和 `full` 输出完整 residual proposal:
|
||||
|
||||
```json
|
||||
"residual_proposals": [
|
||||
{
|
||||
"modifier_position": 0,
|
||||
"proposal_index": 0,
|
||||
"snapshot_sha256": "...",
|
||||
"reason": "仍可应用的候选",
|
||||
"edits": [
|
||||
{
|
||||
"edit_index": 0,
|
||||
"span": {
|
||||
"start": 10,
|
||||
"end": 15
|
||||
},
|
||||
"expected_text": "exam-",
|
||||
"replacement": "example"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
modifier id 和版本通过 `modifier_position` 引用根 `modifiers`。array 顺序严格保持 `ReviewDocument.residual_proposals` 和
|
||||
`ProposedChange.edits` 的顺序。残留候选仍未应用;投影不能把它放进 stages 或 change count。
|
||||
|
||||
## 10. JSON 基本值和失败关闭
|
||||
|
||||
`review_document_to_dict()` 只能发出:
|
||||
|
||||
```text
|
||||
object / array / string / integer / finite number / boolean / null
|
||||
```
|
||||
|
||||
它不能依赖 `json.dumps(default=...)` 临时处理未知对象。每种公共模型和 enum 都要显式转换;遇到未知类型立即抛出
|
||||
`ReviewProjectionError`。
|
||||
|
||||
投影时至少检查:
|
||||
|
||||
- `review` 是 `ReviewDocument`;
|
||||
- detail 类型和值有效;
|
||||
- enum 是 schema `1.0` 明确支持的成员;
|
||||
- array 中的公共模型类型符合预期;
|
||||
- 字符串可以无损 UTF-8 编码,不含孤立 UTF-16 surrogate;
|
||||
- 整数不是 `bool` 且在安全范围内;
|
||||
- float 有限,不含 NaN 或正负 Infinity;
|
||||
- `modifier_position` 能引用根 `modifiers`;
|
||||
- summary 中没有任何正文承载键;
|
||||
- changes/full 的附加字段符合第 8、9 节。
|
||||
|
||||
这组检查保证序列化结构,不复制 `build_review_document()` 的哈希重放和修改契约。如果调用方绕过 builder 手工构造了
|
||||
语义矛盾但结构合法的 review,投影不声称能恢复可信性。
|
||||
|
||||
`ReviewProjectionError` 的消息只说明字段路径和契约类别,不拼入具体正文、modifier 参数、reason、error message 或周边
|
||||
文本。底层异常可以作为 `__cause__` 保留,但顶层消息不能泄漏被拒绝值。
|
||||
|
||||
## 11. JSON reporter
|
||||
|
||||
`render_json_report()` 必须只做两步:
|
||||
|
||||
1. 调用 `review_document_to_dict(review, detail=detail)`;
|
||||
2. 使用标准库 `json.dumps()` 编码这个返回值。
|
||||
|
||||
固定编码行为:
|
||||
|
||||
```python
|
||||
dumps(
|
||||
projection,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
indent=2,
|
||||
)
|
||||
```
|
||||
|
||||
第一版不开放 `indent`、`sort_keys`、encoder、`default` 或文件对象参数,避免把 JSON 编码器的全部表面积变成库契约。需要紧凑
|
||||
JSON 的项目可以对官方 dict 投影自行调用 `json.dumps()`,但不能改变字段语义。
|
||||
|
||||
reporter 返回 Python `str`,不写文件、不添加 UTF-8 BOM,也不在末尾额外添加换行。调用方通过文件、HTTP 或数据库发送时
|
||||
负责按 UTF-8 编码并设置正确媒体类型。
|
||||
|
||||
相同值的 `ReviewDocument` 和相同 detail 必须得到相等 dict 和完全相同的 JSON 字符串。官方实现会使用固定插入顺序方便
|
||||
diff 和测试,但消费者仍不得把 object 键顺序当成语义。
|
||||
|
||||
## 12. Schema 兼容策略
|
||||
|
||||
`schema_version` 使用 `MAJOR.MINOR`:
|
||||
|
||||
- `MAJOR` 改变表示现有消费者可能误读或无法读取;
|
||||
- `MINOR` 只允许旧消费者在忽略未知字段时仍能正确理解的加法变化;
|
||||
- 文案修正、实现重构和使输出重新符合既有契约的 bug fix 不改变 schema 版本;
|
||||
- schema 版本与 Python 包版本分别管理。`mdpolish 0.6.0` 可以继续输出 schema `1.0`。
|
||||
|
||||
下列变化必须提升 schema major:
|
||||
|
||||
- 删除或改名已有字段;
|
||||
- 改变字段类型、坐标、哈希或 array 顺序语义;
|
||||
- 改变已有 enum 或稳定错误代码的含义;
|
||||
- 删除 enum 值,或让生产者在既有字段中自动输出消费者不认识的新 enum 值;
|
||||
- 在相同 detail 下新增正文承载字段,导致原暴露等级泄漏更多内容;
|
||||
- 把可选字段改为必需,或改变字段缺失与空值的区别。
|
||||
|
||||
下列变化可以提升 schema minor:
|
||||
|
||||
- 增加不改变现有字段含义的非正文可选字段;
|
||||
- 增加只有调用方显式请求才会返回的新 detail;
|
||||
- 增加一个新的可选顶层摘要对象,同时保留既有对象。
|
||||
|
||||
同一 major 的消费者必须忽略未知 object 字段,但必须保留 array 顺序;不得接受未知 major。消费者应对自己依赖的 enum 值
|
||||
显式处理未知情况,不能把未知状态当成 `success`。
|
||||
|
||||
正文暴露是安全边界:即使新增字段通常属于 minor,在 `summary` 或 `changes` 中新增更高等级正文也必须升 major,或新增一个
|
||||
需要调用方显式选择的 detail。
|
||||
|
||||
第一版只提供生产,不提供兼容读取器。历史 JSON 的迁移、数据库 schema 和多版本读取由实际跨进程需求触发下一份 design。
|
||||
|
||||
## 13. 源码、文档与版本边界
|
||||
|
||||
批准后计划修改:
|
||||
|
||||
```text
|
||||
src/mdpolish/review.py
|
||||
tests/test_review_projection.py
|
||||
README.md
|
||||
research-wiki/explanation/review-projection.md
|
||||
research-wiki/reference/review-projection-schema-v1.md
|
||||
pyproject.toml
|
||||
```
|
||||
|
||||
- `review.py` 增加第 6 节的公共类型、投影和 JSON reporter,并更新模块 `__all__`;
|
||||
- 独立测试文件固定 schema 和内容暴露边界,避免继续扩大现有 700 行的 `test_review.py`;
|
||||
- explanation 只在实现完成后更新当前机制;
|
||||
- reference 记录代码难以完整表达的 schema `1.0`、兼容和正文暴露契约,不复制 README 的当前进度;
|
||||
- README 在实现完成并验证后才增加能力、示例和真实检查结果;
|
||||
- 不把新接口导出到包根,不新增依赖或源码包目录。
|
||||
|
||||
这是新的公共接口和跨进程数据契约,计划包版本为 `0.5.0`。批准 design 不自动改变当前 `v0.4.0` 事实,也不授权创建 tag、
|
||||
GitHub Release 或发布 wheel。
|
||||
|
||||
当前工作区已有不属于本文的 `AGENTS.md`、`CLAUDE.md` 和 `src/mdpolish/regex.py` 修改。后续实施必须继续保留并隔离这些改动,
|
||||
不能把它们混入本功能的 diff 或提交。
|
||||
|
||||
## 14. 测试与验收
|
||||
|
||||
### 14.1 Schema 和 detail
|
||||
|
||||
合成测试至少覆盖:
|
||||
|
||||
- 空文档、空流水线和零修改结果;
|
||||
- `success`、`failed`、`unstable`,以及完整和不完整 stages;
|
||||
- 三个 detail 的精确顶层键、嵌套键、enum 字符串和 array 顺序;
|
||||
- `summary` 的递归结果中不存在第 8 节列出的任何正文承载键,也找不到专门放入原文、reason、message 和参数的哨兵字符串;
|
||||
- `changes` 包含实际及残留修改片段,但不包含输入、当前和阶段完整 `markdown`;
|
||||
- `full` 包含完整输入、当前文本、阶段全文、修改片段和残留候选;
|
||||
- 空字符串正文通过 `markdown: ""` 与字段未暴露清楚区分;
|
||||
- detail 之间共同字段的值和 array 顺序完全一致;
|
||||
- 返回 dict/list 是新容器,修改一次投影不影响 review 或下一次投影。
|
||||
|
||||
### 14.2 坐标、哈希和参数
|
||||
|
||||
- 中文、补充平面字符、组合字符、BOM、LF、CRLF、CR 和无末尾换行;
|
||||
- span 的 0-based 半开码点范围和 location 的 1-based 码点行列保持现有口径;
|
||||
- 输入、当前、阶段和 change 哈希字段指向正确的精确全文;
|
||||
- tuple、顶层参数对、嵌套二元组、空 tuple、bool、null、int 和有限 float 都按第 9.3 节投影;
|
||||
- mapping 形状的 tuple 不被启发式改成 JSON object;
|
||||
- 计数与权威 array 一致,残留 edit 不计入实际 change count。
|
||||
|
||||
### 14.3 错误与失败关闭
|
||||
|
||||
- 三种 `ErrorStage` 分别得到固定稳定代码;
|
||||
- summary 不含 `diagnostic_type` 和 `message`,changes/full 原样包含;
|
||||
- 未知 detail、错误 review 类型、未知 enum、错误嵌套模型、孤立 surrogate、非有限 float、越界整数和无效引用都失败;
|
||||
- 失败异常为 `ReviewProjectionError`,消息不包含测试正文、参数、reason 或 error message 哨兵;
|
||||
- 投影不调用 modifier、不读文件、不访问网络、不修复非法值、不静默省略错误字段。
|
||||
|
||||
### 14.4 JSON reporter
|
||||
|
||||
- `json.loads(render_json_report(...))` 与同 detail 的官方 dict 投影值相等;
|
||||
- 相同输入重复调用得到逐字符相同的 JSON;
|
||||
- Unicode 正文不被强制写成 `\uXXXX`,控制字符仍由标准 JSON 正确转义;
|
||||
- 输出没有 BOM、没有尾随换行、没有 NaN / Infinity,也不依赖 object 键顺序解释;
|
||||
- summary JSON 中不存在正文哨兵,changes/full 的暴露边界与 dict 完全一致;
|
||||
- reporter 不接受自定义 encoder 或文件对象,不写入磁盘。
|
||||
|
||||
### 14.5 回归和交付检查
|
||||
|
||||
实施完成后实际运行根 README 当时列出的全部检查,并确认:
|
||||
|
||||
- 现有 `Pipeline`、编辑执行器、modifier、`ReviewDocument` 和 Markdown reporter 行为不变;
|
||||
- mypy strict、Ruff 和全部 pytest 通过;
|
||||
- 核心安装仍然没有第三方运行依赖;
|
||||
- wheel 包含更新后的 `review.py` 和 `py.typed`,不包含 tests、Wiki、JSON 报告、真实数据或项目文件;
|
||||
- README 示例只处理内存对象和字符串,不暗示 JSON 已经保存;
|
||||
- `AGENTS.md` 与 `CLAUDE.md` 除标题外正文一致;
|
||||
- Git diff 不混入用户现有改动、真实文本、大文件或生成产物。
|
||||
|
||||
## 15. 风险与代价
|
||||
|
||||
- **公共 schema 需要长期维护:** 内部模型以后可以重构,但 schema `1.x` 不能跟着任意改变;这是正式跨进程接口的必要成本。
|
||||
- **三个 detail 增加测试矩阵:** 每个字段都要证明在哪些等级出现;换来的是正文暴露由调用方显式决定。
|
||||
- **`changes` 仍可能泄漏大量内容:** 多条修改和 residual proposal 能覆盖文档大部分区域;它不是脱敏模式。
|
||||
- **`full` 重复全文:** `ReviewDocument` 已持有阶段快照,投影和 JSON 会再次分配;本轮不做流式或惰性序列化。
|
||||
- **错误代码较粗:** 它只能稳定表达失败阶段,不能区分具体原因;精细化必须先改善 `RunError` 的事实来源。
|
||||
- **参数 tuple 表达不够自然:** array-of-pairs 比 JSON object 更啰嗦,但不会猜错已经丢失的 mapping/sequence 身份。
|
||||
- **没有正式 JSON Schema 文件:** 第一版依靠代码、严格测试和 reference 契约;真正出现独立 validator 需求后再增加发布资产。
|
||||
- **summary 可能被误称为安全日志:** 它只排除正文承载字段,不替代项目的数据分类、访问控制和哈希治理。
|
||||
|
||||
## 16. 批准后的实施边界
|
||||
|
||||
用户明确批准本文后,只授权:
|
||||
|
||||
1. 在 `mdpolish.review` 实现第 6 至 12 节的公共类型、dict 投影和 JSON reporter;
|
||||
2. 新增合成测试并按第 14 节验证,不接触真实文档;
|
||||
3. 更新第 13 节列出的 README、explanation、reference 和包版本;
|
||||
4. 在功能 diff 中隔离并保留工作区已有的其他修改;
|
||||
5. 报告实际测试、wheel 内容和 Git diff,不把设计批准描述成已经发布。
|
||||
|
||||
批准本文不授权:
|
||||
|
||||
- 提交、push、创建 PR、tag、GitHub Release 或上传 wheel;
|
||||
- 修改 `Pipeline`、清洗规则、错误捕获语义、`ReviewDocument` 字段或其他仓库;
|
||||
- 创建 CLI、文件适配器、Web 服务、数据库表、JSON Schema 发布资产或反序列化器;
|
||||
- 读取、复制、修改或公开真实文档和外部数据。
|
||||
@@ -0,0 +1,451 @@
|
||||
# 0013:公开精确物理行范围接口
|
||||
|
||||
## 状态
|
||||
|
||||
已于 2026-08-28 获用户明确批准,按本文第 13 节实施。本文自批准起冻结;后续改变决策需新增 design 并使用
|
||||
`supersedes` 指向本文。
|
||||
|
||||
`extends: 0008`:继续使用函数式 `Modifier`、不可变值、精确原文范围、无文件 I/O 和项目规则外置的公共库边界;本文只把
|
||||
已经由多个通用 Modifier 共用的物理行范围工具变成受支持的扩展接口。
|
||||
|
||||
`extends: 0009`:`mapped_line_join()` 的 Markdown 块分类、代码/表格排除和跨行合并语义保持不变;本文只公开它已经使用的
|
||||
底层物理行扫描,不公开其保守块扫描器。
|
||||
|
||||
本文不改变 `0012` 的机器投影 schema、detail 或报告行为。
|
||||
|
||||
## 1. 问题与可观察现象
|
||||
|
||||
项目自定义 Modifier 最终要提交精确 `TextEdit`。只要规则依赖“完整独占一行”“删除自己的换行”“检查下一空行”或“聚合
|
||||
重复行”,项目就必须先准确回答:
|
||||
|
||||
- 这一行正文从哪里开始、在哪里结束;
|
||||
- 行尾是 LF、CR、CRLF,还是文档末尾没有换行;
|
||||
- 空行是否真的有零长度内容;
|
||||
- 删除范围是否包含行尾;
|
||||
- 下一条物理行在原始字符串中的准确位置是什么。
|
||||
|
||||
上游已经在私有模块 `mdpolish._text_ranges` 中实现:
|
||||
|
||||
```python
|
||||
PhysicalLine
|
||||
iter_physical_lines()
|
||||
physical_lines()
|
||||
line_ending_styles()
|
||||
```
|
||||
|
||||
`mapped_line_join()` 使用它建立跨行候选,`html_table_layout()` 使用它检查混合换行。独立 wheel-pilot 的论文清洗设计又需要
|
||||
同样的 LF、CRLF、CR、空行、无末尾换行和原文范围语义,但项目治理正确地禁止导入带下划线的私有模块,只能计划复制一份
|
||||
扫描器。
|
||||
|
||||
这已经不是“某个项目想少写几行代码”。同一个确定性边界算法出现在上游多个通用 Modifier,并被独立消费项目再次需要。
|
||||
如果项目复制实现,以下细节很容易漂移:
|
||||
|
||||
- 把 CRLF 错当成两个换行;
|
||||
- 在末尾换行之后制造一条不存在的空行;
|
||||
- 把只有空格的行和零长度空行混为一谈;
|
||||
- 删除内容却遗留 `\r`;
|
||||
- 用累计字符串长度重新计算 offset 时产生偏移;
|
||||
- 上游修复边界后,项目副本继续使用旧语义。
|
||||
|
||||
因此,缺少的是“项目 Modifier 作者可以合法复用的精确物理行范围”,不是新的清洗规则或 Markdown parser。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
- 提供受支持的公共模块 `mdpolish.text_ranges`;
|
||||
- 公开不可变 `PhysicalLine` 和惰性/急切两种物理行扫描入口;
|
||||
- 精确保留 LF、CRLF、CR 和无末尾换行,不规范化源字符串;
|
||||
- 公开已存在的行尾样式集合查询;
|
||||
- 在首次公开前修正空行判断的误导命名,并明确零长度与空白字符的区别;
|
||||
- 给公共值对象和函数增加显式类型、范围和来源切片校验;
|
||||
- 让上游现有 Modifier 使用同一公共实现,不维护私有/公共两套算法;
|
||||
- 用合成文本直接固定空文档、空行、Unicode、混合换行和范围语义;
|
||||
- 保持核心安装零第三方运行依赖,无文件 I/O、网络或模型调用。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不在 Pipeline 运行 Modifier 前自动扫描、分块或预处理 Markdown;
|
||||
- 不缓存或跨 Modifier 共享扫描结果;后一个 Modifier 仍读取前一阶段应用后的完整新快照;
|
||||
- 不解析段落、标题、列表、引用、代码块、表格、HTML 或 Markdown AST;
|
||||
- 不公开 `mapped_line_join()` 内部的 `_ScannedLine`、scope、结构排除或块分类器;
|
||||
- 不提供 `remove_matching_lines()`、行号删除器、页眉删除器、批注删除器或其他 Modifier 工厂;
|
||||
- 不定义“只含空格或 Tab 的行是否算 blank”,也不使用 Unicode `strip()` 暗中替项目作决定;
|
||||
- 不读取文件、不接收路径、不返回文件行号或改写文本;
|
||||
- 不把 range 自动转成 `TextEdit`,不绕过快照哈希、expected text 和批次验证;
|
||||
- 不修改 wheel-pilot、真实论文、外部数据或项目 design;
|
||||
- 不承诺通用文本编辑器、LSP、UTF-16 或 byte offset 接口。
|
||||
|
||||
## 3. 方案比较
|
||||
|
||||
| 方案 | 优点 | 问题 | 选择 |
|
||||
| --- | --- | --- | --- |
|
||||
| 每个项目复制扫描器 | 上游不增加接口 | CRLF、尾换行和空行语义会漂移;多个项目重复测试 | 不采用 |
|
||||
| 项目直接导入 `mdpolish._text_ranges` | 零上游改动 | 私有路径没有兼容承诺,wheel 消费方被迫依赖内部实现 | 不采用 |
|
||||
| 把当前私有模块原样改成公共路径 | 改动最小 | `is_blank(markdown)` 命名和参数误导;没有直接公共契约测试或范围校验 | 不直接采用 |
|
||||
| 公开经过收窄和加固的精确物理行范围 | 一个算法来源;项目规则保持外置;接口小而可测试 | 增加需要兼容维护的公共模块 | 采用 |
|
||||
| 引入统一 Markdown parser / AST | 可共享高级结构 | 远超当前需求,并会改变所有 Modifier 的共同边界 | 不采用 |
|
||||
| Pipeline 预扫描并把行列表传给所有 Modifier | 避免重复扫描 | 改变 `ModifierFunction` 签名;快照每阶段变化使缓存身份复杂 | 不采用 |
|
||||
|
||||
## 4. 职责边界
|
||||
|
||||
采用后的调用关系为:
|
||||
|
||||
```text
|
||||
完整 DocumentSnapshot
|
||||
│
|
||||
▼
|
||||
项目 Modifier.propose(snapshot)
|
||||
│
|
||||
│ 只有该 Modifier 需要时调用
|
||||
▼
|
||||
physical_lines(snapshot.markdown)
|
||||
│
|
||||
▼
|
||||
PhysicalLine 原文范围
|
||||
│
|
||||
│ 项目判断业务证据
|
||||
▼
|
||||
ProposedChange / TextEdit
|
||||
│
|
||||
▼
|
||||
公共执行器验证并原子应用
|
||||
```
|
||||
|
||||
| 层次 | 负责 | 不负责 |
|
||||
| --- | --- | --- |
|
||||
| `text_ranges` | 确定物理行内容与行尾范围 | 判断行的业务含义 |
|
||||
| 项目 Modifier | 根据项目证据选择范围和替换 | 重写 CR/LF 扫描算法 |
|
||||
| 编辑执行器 | 验证快照、范围、原文、冲突并应用 | 判断为什么要删除一行 |
|
||||
| Pipeline | 按顺序运行并稳定性复查 | 建立全局共享分块 |
|
||||
|
||||
公开接口不会在未调用时执行,也不会改变传给第一个 Modifier 的原始 Markdown。
|
||||
|
||||
## 5. 第一版公共模块
|
||||
|
||||
第一版受支持的导入路径是:
|
||||
|
||||
```python
|
||||
from mdpolish.text_ranges import (
|
||||
PhysicalLine,
|
||||
iter_physical_lines,
|
||||
line_ending_styles,
|
||||
physical_lines,
|
||||
)
|
||||
```
|
||||
|
||||
这些名称只从 `mdpolish.text_ranges` 导出。本轮不把它们再放进 `mdpolish.__init__`,避免包根继续堆积低层工具。
|
||||
|
||||
私有路径 `mdpolish._text_ranges` 从来不是公共契约。实现时把唯一算法移动到公共模块并更新上游内部导入,不保留另一份算法,
|
||||
也不为未受支持的私有导入建立长期兼容别名。
|
||||
|
||||
## 6. `PhysicalLine` 公共值对象
|
||||
|
||||
### 6.1 字段
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PhysicalLine:
|
||||
"""一个源字符串中物理行内容及可选行尾的精确范围。"""
|
||||
|
||||
content_start: int
|
||||
content_end: int
|
||||
full_end: int
|
||||
```
|
||||
|
||||
范围均使用 Python 字符串的 Unicode code point index:
|
||||
|
||||
```text
|
||||
content_start content_end full_end
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
one physical line content\r\n
|
||||
└──────── content ───────┘└ line ending ┘
|
||||
└──────────── complete physical line ────┘
|
||||
```
|
||||
|
||||
- 完整物理行范围是 `[content_start, full_end)`;
|
||||
- 内容范围是 `[content_start, content_end)`;
|
||||
- 行尾范围是 `[content_end, full_end)`;
|
||||
- 最后一行没有行尾时 `content_end == full_end`;
|
||||
- `content_start` 同时是完整物理行的开始,不另设重复字段;
|
||||
- offset 不是 UTF-8 byte、UTF-16 code unit、终端显示列或 1-based 人类行列。
|
||||
|
||||
### 6.2 构造校验
|
||||
|
||||
`__post_init__()` 必须显式拒绝:
|
||||
|
||||
- 字段不是严格 `int`,包括 `bool`;
|
||||
- 任一字段为负数;
|
||||
- 不满足 `content_start <= content_end <= full_end`;
|
||||
- `full_end - content_end > 2`,因为受支持的物理行尾最长是 CRLF 两个 code point。
|
||||
|
||||
这些检查不能证明手工构造的范围一定来自某个 source;它们只保证值对象局部自洽。权威正常用法是消费扫描函数返回的对象。
|
||||
|
||||
### 6.3 内容和行尾访问
|
||||
|
||||
```python
|
||||
def content(self, source: str) -> str:
|
||||
...
|
||||
|
||||
def line_ending(self, source: str) -> str:
|
||||
...
|
||||
```
|
||||
|
||||
两个方法都必须:
|
||||
|
||||
- 要求 `source` 是 `str`;
|
||||
- 确认 `full_end <= len(source)`;
|
||||
- 根据保存的半开范围返回精确切片,不规范化或复制其他字符。
|
||||
|
||||
`line_ending()` 还要确认切片严格属于:
|
||||
|
||||
```python
|
||||
""
|
||||
"\n"
|
||||
"\r"
|
||||
"\r\n"
|
||||
```
|
||||
|
||||
否则抛出 `ValueError`,不把手工构造的错误范围静默当作合法行尾。正常扫描结果不会触发这个错误。
|
||||
|
||||
范围对象不保存 source 或快照哈希。这样保留单个 `PhysicalLine` 不会隐式持有整篇文档,也不会在每条行对象中重复相同哈希。
|
||||
项目最终建立 `TextEdit` 时,现有 snapshot SHA-256 和 expected text 校验仍负责拒绝跨快照范围。
|
||||
|
||||
### 6.4 零长度行判断
|
||||
|
||||
当前私有方法:
|
||||
|
||||
```python
|
||||
line.is_blank(markdown)
|
||||
```
|
||||
|
||||
完全没有读取 `markdown`,实际只判断 `content_start == content_end`。公开前改成只读属性:
|
||||
|
||||
```python
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.content_start == self.content_end
|
||||
```
|
||||
|
||||
它的语义固定为“内容范围长度为零”:
|
||||
|
||||
| 原始物理行 | `is_empty` |
|
||||
| --- | ---: |
|
||||
| `"\n"` | `True` |
|
||||
| `"\r\n"` | `True` |
|
||||
| `""` | 不会由空文档产生 PhysicalLine |
|
||||
| `" \n"` | `False` |
|
||||
| `"\t\n"` | `False` |
|
||||
|
||||
本轮不保留或公开 `is_blank` 别名。它原来属于私有模块,没有公共兼容承诺;继续保留会同时留下误导名称和无用 source 参数。
|
||||
项目若要把 ASCII space / Tab 行定义为空白,应明确写出自己的条件,例如:
|
||||
|
||||
```python
|
||||
line.content(source).strip(" \t") == ""
|
||||
```
|
||||
|
||||
是否允许其他 Unicode whitespace 属于项目规则,不能由底层工具默认扩大。
|
||||
|
||||
## 7. 扫描函数
|
||||
|
||||
### 7.1 惰性扫描
|
||||
|
||||
```python
|
||||
def iter_physical_lines(source: str) -> Iterator[PhysicalLine]:
|
||||
...
|
||||
```
|
||||
|
||||
- 调用时先验证 `source` 是 `str`,非法类型立即抛出 `TypeError`,不等到第一次迭代才暴露;
|
||||
- 单次从左到右扫描,不预先复制 source;
|
||||
- 只把 `\n`、`\r`、`\r\n` 识别为行尾;
|
||||
- CRLF 是一个物理行尾,范围长度为两个 code point;
|
||||
- Unicode line separator、paragraph separator、vertical tab、form feed 和 NUL 都保留在内容中;
|
||||
- 产生顺序严格按 source offset 递增,范围不重叠且完整覆盖 source;
|
||||
- 返回迭代器,不承诺可以重复迭代同一对象。
|
||||
|
||||
为保证非法输入在函数调用时立即失败,实现可以使用普通包装函数返回私有 generator;公共签名和可观察行为以上述契约为准。
|
||||
|
||||
### 7.2 急切扫描
|
||||
|
||||
```python
|
||||
def physical_lines(source: str) -> tuple[PhysicalLine, ...]:
|
||||
...
|
||||
```
|
||||
|
||||
它返回 `iter_physical_lines()` 的完整不可变 tuple,不另写扫描逻辑。适合需要前后行、重复组或多次遍历的 Modifier。
|
||||
|
||||
### 7.3 行尾样式
|
||||
|
||||
```python
|
||||
def line_ending_styles(source: str) -> frozenset[str]:
|
||||
...
|
||||
```
|
||||
|
||||
它返回扫描结果中实际出现的非空行尾集合,只可能包含:
|
||||
|
||||
```python
|
||||
"\n"
|
||||
"\r"
|
||||
"\r\n"
|
||||
```
|
||||
|
||||
无换行或空文档返回空 `frozenset`。它复用 `iter_physical_lines()`,不维护另一份 `splitlines()` 逻辑。
|
||||
|
||||
## 8. 精确边界示例
|
||||
|
||||
以下 tuple 写作 `(content_start, content_end, full_end)`:
|
||||
|
||||
| source | 扫描结果 |
|
||||
| --- | --- |
|
||||
| `""` | `()` |
|
||||
| `"a"` | `((0, 1, 1),)` |
|
||||
| `"a\n"` | `((0, 1, 2),)` |
|
||||
| `"\n"` | `((0, 0, 1),)` |
|
||||
| `"\r\n"` | `((0, 0, 2),)` |
|
||||
| `"a\n\n"` | `((0, 1, 2), (2, 2, 3))` |
|
||||
| `"\ntext"` | `((0, 0, 1), (1, 5, 5))` |
|
||||
| `"a\r\nb\rc\n"` | `((0, 1, 3), (3, 4, 5), (5, 6, 7))` |
|
||||
| `"a\u2028b"` | `((0, 3, 3),)`,U+2028 属于内容 |
|
||||
|
||||
特别说明:
|
||||
|
||||
- 末尾有行尾不额外产生一条虚构空行;
|
||||
- `"a\n\n"` 的第二个 `\n` 是一条真实零长度物理行;
|
||||
- 空文档没有任何物理行;
|
||||
- 拼接所有 `[content_start, full_end)` 切片必须精确还原 source。
|
||||
|
||||
这些行为保持当前私有扫描算法的结果,不改变现有 Modifier 的换行语义。
|
||||
|
||||
## 9. 与 Markdown 和编辑契约的关系
|
||||
|
||||
`text_ranges` 不根据 Markdown 语义改变任何范围。例如:
|
||||
|
||||
````markdown
|
||||
# Heading
|
||||
|
||||
> quote
|
||||
|
||||
```text
|
||||
code
|
||||
```
|
||||
````
|
||||
|
||||
对它来说都只是物理行。标题、引用和围栏只有调用它的 Modifier 才能解释。
|
||||
|
||||
范围也不自动成为修改。项目仍必须构造:
|
||||
|
||||
```python
|
||||
TextEdit(
|
||||
snapshot_sha256=snapshot.sha256,
|
||||
span=TextSpan(line.content_start, line.full_end),
|
||||
expected_text=snapshot.markdown[line.content_start : line.full_end],
|
||||
replacement="",
|
||||
)
|
||||
```
|
||||
|
||||
执行器随后验证哈希、范围和原文。物理行接口不能跳过这层保护,也不承诺手工构造的 `PhysicalLine` 自动安全。
|
||||
|
||||
## 10. 源码、兼容与版本
|
||||
|
||||
批准后计划:
|
||||
|
||||
```text
|
||||
src/mdpolish/
|
||||
├── text_ranges.py # 公共唯一实现
|
||||
└── modifiers/
|
||||
├── mapped_line_join.py # 改用公共模块路径
|
||||
└── html_table_layout.py # 改用公共模块路径
|
||||
|
||||
tests/
|
||||
└── test_text_ranges.py # 公共接口直接测试
|
||||
|
||||
research-wiki/reference/
|
||||
└── physical-line-ranges.md # 稳定边界查询口径
|
||||
```
|
||||
|
||||
实现时删除私有源码文件 `_text_ranges.py`,不保留双份算法。`mdpolish._text_ranges` 从未受支持;本轮不为私有导入提供弃用期。
|
||||
现有公开的 `Modifier`、`Pipeline`、`TextSpan`、`mapped_line_join()` 和 HTML Modifier 签名、身份、参数、输出及审计行为不变。
|
||||
|
||||
这是新增公共扩展接口,计划包版本为 `0.6.0`。`v0.5.0` 的机器投影保持独立交付范围;如果 `v0.5.0` 尚未完成合并和发布,
|
||||
本功能仍使用单独提交与验证记录,不把两个功能写成同一项能力。
|
||||
|
||||
批准后实现完成并验证时才更新根 README 的当前能力、候选版本和实际检查结果。设计批准不自动授权 commit、push、tag、
|
||||
GitHub Release 或 wheel 发布。
|
||||
|
||||
## 11. 测试与验收
|
||||
|
||||
### 11.1 公共扫描契约
|
||||
|
||||
直接测试至少覆盖:
|
||||
|
||||
- 第 8 节所有精确范围;
|
||||
- 空文档、单行、首行为空、连续空行和无末尾换行;
|
||||
- 纯 LF、纯 CRLF、纯 CR 和混合行尾;
|
||||
- 中文、补充平面字符、组合字符、BOM、U+2028、NUL、Tab 和 form feed;
|
||||
- 每条 `content()`、`line_ending()` 与 source 精确切片一致;
|
||||
- 所有完整行切片按顺序拼接后逐 code point 等于原 source;
|
||||
- `physical_lines(source) == tuple(iter_physical_lines(source))`;
|
||||
- `line_ending_styles()` 只返回实际非空样式;
|
||||
- `is_empty` 只对零长度内容为真,space / Tab 不自动算空;
|
||||
- 相同 source 重复扫描得到值相等、顺序相同的不可变结果。
|
||||
|
||||
### 11.2 失败关闭
|
||||
|
||||
至少拒绝:
|
||||
|
||||
- 扫描函数接收非 `str`,包括 bytes、Path 和 `None`;
|
||||
- dataclass 字段为 `bool`、float、字符串或其他非严格 int;
|
||||
- 负数、逆序范围和超过两个 code point 的行尾范围;
|
||||
- `content()` / `line_ending()` 接收非字符串或比 `full_end` 更短的 source;
|
||||
- `line_ending()` 指向不是 `""` / LF / CR / CRLF 的切片。
|
||||
|
||||
错误使用 `TypeError` 或 `ValueError`,消息只说明字段和契约,不输出 source 内容。
|
||||
|
||||
### 11.3 现有行为回归
|
||||
|
||||
- `mapped_line_join()` 全部精确、正则、词典、结构、换行和链式测试结果不变;
|
||||
- `html_table_layout()` 的单/混合行尾行为不变;
|
||||
- 其他核心、评审和机器投影测试不受影响;
|
||||
- mypy strict 和 Ruff 通过;
|
||||
- 核心运行依赖仍为空。
|
||||
|
||||
### 11.4 Wheel 消费
|
||||
|
||||
构建候选 wheel 后在仓库外全新虚拟环境确认:
|
||||
|
||||
```python
|
||||
from mdpolish.text_ranges import PhysicalLine, iter_physical_lines, line_ending_styles, physical_lines
|
||||
```
|
||||
|
||||
可以导入并得到第 8 节结果。wheel 必须包含 `mdpolish/text_ranges.py` 和 `py.typed`,不包含 tests、Wiki、真实数据或报告。
|
||||
|
||||
本轮不修改 wheel-pilot。它升级正式 wheel、删除私有扫描计划和验证四个项目 Modifier,仍需在消费仓自己的未冻结 design 中
|
||||
调整并获得授权。
|
||||
|
||||
## 12. 风险与代价
|
||||
|
||||
- **公共表面积增加:** range 字段、空文档和尾换行语义发布后需要兼容维护;直接测试和 reference 用于锁定行为。
|
||||
- **低层工具可能被误认为 Markdown parser:** 模块、README 和 docstring 都要明确它只识别 CR/LF,不解释块结构。
|
||||
- **每个 Modifier 可能重复 O(n) 扫描:** 当前快照会逐阶段变化,简单重扫比跨阶段缓存更可靠;有真实性能证据后再设计共享索引。
|
||||
- **手工范围仍可能来自错误 source:** 访问器做局部验证,最终 `TextEdit` 的快照和 expected text 继续承担权威保护。
|
||||
- **删除私有路径可能影响越界使用者:** 下划线路径从未承诺兼容;已知正式消费项目明确没有导入它。
|
||||
- **`is_empty` 不等于 Markdown blank line:** 这是有意边界;项目必须显式定义 space、Tab 或 Unicode whitespace。
|
||||
- **新版本依赖发布顺序:** wheel-pilot 只有安装含公共模块的正式 wheel 后才能删除自己的扫描计划,不能从相邻源码树偷导入。
|
||||
|
||||
## 13. 批准后的实施边界
|
||||
|
||||
用户明确批准本文后,只授权:
|
||||
|
||||
1. 把私有物理行唯一实现迁移到 `mdpolish.text_ranges`,实现第 5 至 8 节公共契约;
|
||||
2. 更新两个现有通用 Modifier 的内部导入和 `is_empty` 调用,不改变其清洗语义;
|
||||
3. 增加第 11 节合成测试、reference 和 README 说明;
|
||||
4. 把包版本候选更新为 `0.6.0`,执行根 README 当时列出的全套检查和 wheel smoke test;
|
||||
5. 保留并隔离工作区已有的 `AGENTS.md`、`CLAUDE.md`、`src/mdpolish/regex.py` 等用户改动。
|
||||
|
||||
批准本文不授权:
|
||||
|
||||
- 提交、push、创建 PR、tag、GitHub Release 或上传 wheel;
|
||||
- 修改 wheel-pilot、真实文档、外部数据或其他仓库;
|
||||
- 新增 Markdown parser、全局预处理、共享扫描缓存、通用删除 Modifier、CLI 或文件适配器;
|
||||
- 改变现有清洗规则、Pipeline 顺序、机器投影 schema 或报告内容。
|
||||
@@ -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.js;Python 构建保持简单 | 源码与生成资源必须同步检查,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 支持的任一已知 detail;reviewer 必须显式要求 `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` 语义。
|
||||
@@ -83,8 +83,9 @@ Python 不能沙箱隔离任意调用方函数。外部函数若私下写文件
|
||||
|
||||
## 6. 当前通用能力与边界
|
||||
|
||||
除了核心,发布包只提供:
|
||||
除了核心,当前包只提供:
|
||||
|
||||
- `build_review_document()` / `render_markdown_report()`:验证已有结果并生成内存评审视图或 Markdown 报告字符串;
|
||||
- `regex_replace()`:把非空正则匹配转换为精确编辑;
|
||||
- `mapped_line_join()`:按调用方映射合并跨行片段,库不附带词表;
|
||||
- `html_table_entity_unescape()`:在严格表格单元格文本中解除一层受支持的实体转义;
|
||||
@@ -93,5 +94,6 @@ Python 不能沙箱隔离任意调用方函数。外部函数若私下写文件
|
||||
HTML 能力使用失败关闭的词法子集,不是完整 HTML parser,也不识别 Markdown 围栏。正则工厂只保证定位和执行契约,
|
||||
不保证调用方正则的业务语义正确。
|
||||
|
||||
当前没有默认流水线、文件适配器、CLI、profile、配置加载、批处理、artifact、评审器或项目规则集。安装或导入库不会
|
||||
自动修改任何文本。实际安装、示例和当前检查命令只以根目录 [`README.md`](../../README.md) 为准。
|
||||
当前没有默认流水线、文件适配器、CLI、profile、配置加载、批处理、artifact、报告文件、Web/桌面评审器或项目规则集。
|
||||
安装或导入库不会自动修改任何文本。评审投影的重放机制与边界见 [`review-projection.md`](review-projection.md);实际安装、
|
||||
示例和当前检查命令只以根目录 [`README.md`](../../README.md) 为准。
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# 通用内存评审视图怎样解释一次清洗结果
|
||||
|
||||
## 1. 为什么不能直接把所有修改画在最终全文上
|
||||
|
||||
`TransformResult.changes` 中的每条范围都属于对应修改器执行前的快照。前一个修改器插入或删除文本后,后一个修改器看到的
|
||||
位置已经不同;后续修改器也可能再次改写前一阶段生成的内容。因此,把所有 `Change.span` 直接当成最终全文坐标,会产生
|
||||
错误跳转和错误归属。
|
||||
|
||||
当前实现要求调用方同时提供原始 Markdown 和已有 `TransformResult`:
|
||||
|
||||
```text
|
||||
原始 Markdown + TransformResult
|
||||
│
|
||||
▼
|
||||
build_review_document()
|
||||
│ 验证并重放,不运行 Modifier
|
||||
▼
|
||||
ReviewDocument
|
||||
├── render_markdown_report() ─────────────► 内存 Markdown 字符串
|
||||
│
|
||||
└── review_document_to_dict(detail=...)
|
||||
├───────────────────────────► 项目自己的界面或转换层
|
||||
└── render_json_report() ──► 内存 JSON 字符串
|
||||
│
|
||||
▼
|
||||
parse_json_report()
|
||||
│
|
||||
▼
|
||||
本地只读 reviewer 页面
|
||||
```
|
||||
|
||||
清洗输入读取、报告保存位置、权限和审核流程仍由调用项目决定。项目可以把正式 `full` JSON 保存到自己的目录,再显式启动
|
||||
`mdpolish-reviewer`;通用页面不替项目生成、命名或清理这些文件。
|
||||
|
||||
## 2. 构建过程为什么可以失败关闭
|
||||
|
||||
`build_review_document()` 从调用方提供的原文开始,先验证输入和当前全文的 SHA-256,再按修改器位置重建批次。每个有实际
|
||||
修改的阶段都会检查:
|
||||
|
||||
- `modifier_id`、版本和位置与记录的修改器元数据一致;
|
||||
- `before_sha256`、`proposal_ref` 和每条编辑都指向当前阶段的修改前快照;
|
||||
- proposal index、edit index 和报告顺序没有缺失、重复或被重排;
|
||||
- 范围没有越界、重复或冲突,`before` 与原文精确一致;
|
||||
- 应用后的全文和 `after_sha256` 一致。
|
||||
|
||||
核心执行与评审重放调用 `edits.py` 中同一个私有应用原语。排序键和字符串应用没有在评审模块复制一份;现有
|
||||
`apply_modifier_batch()` 仍负责生成权威 `Change` 审计记录。
|
||||
|
||||
任一条件不成立都会抛出 `ReviewBuildError`。错误只说明契约类别和修改器位置,不包含输入、`before`、`after` 或上下文
|
||||
片段。构建函数不会通过搜索或 diff 猜测缺失信息,也不会重新调用修改器。
|
||||
|
||||
## 3. `stages_complete` 表示什么
|
||||
|
||||
`ErrorStage.PREFLIGHT` 用来区分“修改器尚未开始执行”和“已经进入 transform 后失败”。阶段边界如下:
|
||||
|
||||
| 结果 | `ReviewStage` 范围 | `stages_complete` |
|
||||
| --- | --- | --- |
|
||||
| `success` | 全部修改器,包括零修改阶段 | `True` |
|
||||
| `unstable` | 全部 transform 阶段;残留候选不应用 | `True` |
|
||||
| 只有 final review 错误的 `failed` | 全部 transform 阶段 | `True` |
|
||||
| preflight 失败 | 空 | `False` |
|
||||
| 修改器位置 `p` 的 transform 失败 | 只包含 `0..p-1` | `False` |
|
||||
|
||||
已经返回 proposals 但元数据复核、批次验证或应用失败的修改器,不会被伪装成零修改阶段。它的修改前全文就是
|
||||
`ReviewDocument.current_markdown`,失败原因保存在 `errors`;失败位置之后的修改器没有运行,也没有阶段。
|
||||
|
||||
`stages_complete=True` 只证明 transform 阶段完整,不表示 final review 成功,更不把 `failed` 或 `unstable` 提升成成功
|
||||
输出。
|
||||
|
||||
## 4. 位置口径
|
||||
|
||||
`ReviewLocation` 指向 `Change.span.start`,并相对于所属 `ReviewStage.before_markdown` 计算。行和列都是 1-based,列宽使用
|
||||
Python Unicode 码点:
|
||||
|
||||
- BOM 和组合字符各占一个码点;
|
||||
- 补充平面字符占一个码点,不按 UTF-16 的两个 code unit 计算;
|
||||
- LF、CRLF 和 CR 都是物理换行,CRLF 是一个换行边界但占两个原始码点;
|
||||
- 计算前不做 Unicode 或换行规范化。
|
||||
|
||||
权威范围仍然是 Python 半开区间 `Change.span`。`ReviewLocation` 只供人阅读,不用于重新应用修改,也不是 JavaScript、LSP
|
||||
或终端显示列坐标。
|
||||
|
||||
## 5. Markdown reporter 的边界
|
||||
|
||||
`render_markdown_report()` 是纯函数,只返回 Markdown 源码字符串。报告包含状态、哈希、修改器顺序、完整输入、完整当前
|
||||
全文、带物理换行标记的统一 diff,以及按 `ReviewStage` 排列的实际修改。`failed` 和 `unstable` 的 diff 文件标签带有
|
||||
`.partial.md`,避免把部分结果误认成正式输出。
|
||||
|
||||
完整 residual proposal 仍保留在 `ReviewDocument` 中。reporter 默认最多列出前 20 条,只显示修改器身份、位置、原因、
|
||||
span 和修改前后长度,不重复输出 `expected_text`、`replacement` 或正文摘要。调用方可以把限额设为非负整数,`0` 表示
|
||||
只显示总数。
|
||||
|
||||
报告有意包含完整文档、实际修改的 `before` / `after` 和 diff,可能还原敏感内容。库不会自动打印、保存、上传或缓存
|
||||
报告;持久化后的路径、访问权限、脱敏和保留周期属于调用项目。每个阶段保存完整前后快照,当前实现优先保证可复核性,
|
||||
没有声称适合无限长度文档或无限修改器链。
|
||||
|
||||
## 6. 机器投影为什么不是 `dataclasses.asdict()`
|
||||
|
||||
`review_document_to_dict()` 是 `ReviewDocument` 的单向公共视图,不是内部 dataclass 的机械展开。它显式转换 enum、tuple、
|
||||
modifier 参数、位置和哈希,并在根对象写入 `schema_name=mdpolish.review` 与独立的 `schema_version=1.0`。这样内部 Python
|
||||
结构可以在不改变 schema 的前提下重构,调用项目也不需要猜测 enum、数组和阶段坐标。
|
||||
|
||||
投影有三个内容等级:
|
||||
|
||||
| detail | 增加的内容 |
|
||||
| --- | --- |
|
||||
| `summary` | 状态、哈希、计数、modifier 身份、阶段摘要和稳定错误代码;默认不含正文承载字段 |
|
||||
| `changes` | modifier 参数和适用说明、实际修改片段、错误诊断、残留候选片段 |
|
||||
| `full` | 完整输入、当前全文和每个完整阶段的前后全文 |
|
||||
|
||||
高等级只增加字段,不改变低等级已有字段的值和顺序。被 detail 隐藏的字段直接不存在,不使用 `null` 或空字符串假装隐藏,
|
||||
因此空文档在 `full` 中仍能明确表示为 `markdown: ""`。
|
||||
|
||||
`summary` 不含正文,但仍包含 modifier identity、哈希和计数,只能称为“无正文投影”,不能称为脱敏或公开安全日志。
|
||||
`changes` 和 `full` 都可能还原敏感内容;库不会自动打印、保存或发送任何投影。
|
||||
|
||||
`render_json_report()` 只把同 detail 的正式 dict 投影用标准 JSON 编码,不维护第二套字段。它返回内存字符串,使用 Unicode、
|
||||
拒绝 NaN / Infinity,不写文件或添加 BOM。Markdown reporter 继续直接读取 `ReviewDocument`:它面向人类排版并包含 diff、
|
||||
动态围栏和 residual 展示限额,不依赖机器 schema。
|
||||
|
||||
`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:43127(1 份文档)
|
||||
```
|
||||
|
||||
在同一台机器的浏览器中打开实际打印的 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 可以通过回环服务读取;
|
||||
- 未读取或复制真实文档,也未写入调用项目目录。
|
||||
|
||||
这次验证覆盖安装、数据校验和服务路径,不替代真实浏览器中的最终视觉、长文滚动和交互人工确认。
|
||||
@@ -0,0 +1,137 @@
|
||||
# 精确物理行范围
|
||||
|
||||
本文记录 `mdpolish.text_ranges` 当前稳定的公共查询口径。实现和运行校验以
|
||||
`src/mdpolish/text_ranges.py` 与测试为准;设计理由、方案取舍和施工边界见
|
||||
[`0013-public-physical-line-ranges.md`](../design/0013-public-physical-line-ranges.md)。
|
||||
|
||||
## 1. 公共入口
|
||||
|
||||
```python
|
||||
from mdpolish.text_ranges import (
|
||||
PhysicalLine,
|
||||
iter_physical_lines,
|
||||
line_ending_styles,
|
||||
physical_lines,
|
||||
)
|
||||
```
|
||||
|
||||
这些名称只从 `mdpolish.text_ranges` 导出,不在包根重复导出。`mdpolish._text_ranges` 不是兼容路径。
|
||||
|
||||
模块只接收内存中的 `str`。它不读取文件、不改写文本、不建立 Markdown AST,也不会在 `Pipeline` 运行前自动执行。
|
||||
|
||||
## 2. 什么是物理行
|
||||
|
||||
物理行由以下任一边界结束:
|
||||
|
||||
| 原文边界 | 行尾字符串 | code point 长度 |
|
||||
| --- | --- | ---: |
|
||||
| LF | `"\n"` | 1 |
|
||||
| CR | `"\r"` | 1 |
|
||||
| CRLF | `"\r\n"` | 2 |
|
||||
| 文档末尾 | `""` | 0 |
|
||||
|
||||
CRLF 是一个物理行尾。U+2028、U+2029、vertical tab、form feed、NUL 以及其他字符都属于行内容,不会被当成物理行尾。
|
||||
扫描不做 Unicode 或换行规范化。
|
||||
|
||||
空文档产生零条物理行。原文以行尾结束时,不会在末尾虚构额外空行;连续行尾之间确实存在的零长度行仍会返回。
|
||||
|
||||
## 3. `PhysicalLine`
|
||||
|
||||
`PhysicalLine` 是 `dataclass(frozen=True, slots=True)`:
|
||||
|
||||
```python
|
||||
PhysicalLine(
|
||||
content_start=0,
|
||||
content_end=4,
|
||||
full_end=6,
|
||||
)
|
||||
```
|
||||
|
||||
三个 offset 都是相对于同一个 Python `str` 的 0-based Unicode code point index:
|
||||
|
||||
- 内容范围:`[content_start, content_end)`;
|
||||
- 行尾范围:`[content_end, full_end)`;
|
||||
- 完整物理行范围:`[content_start, full_end)`。
|
||||
|
||||
它们不是 UTF-8 byte、UTF-16 code unit、终端显示列或人类使用的 1-based 行列。
|
||||
|
||||
公共成员:
|
||||
|
||||
| 成员 | 结果 |
|
||||
| --- | --- |
|
||||
| `content(source)` | 返回内容范围的精确切片 |
|
||||
| `line_ending(source)` | 返回 `""`、LF、CR 或 CRLF |
|
||||
| `is_empty` | 内容范围长度是否为零 |
|
||||
|
||||
`is_empty` 不等于 Markdown blank line。`" \n"` 和 `"\t\n"` 的内容不是零长度,因此均为 `False`。项目若要把 space、Tab
|
||||
或其他 Unicode whitespace 当作空白,必须在自己的 Modifier 中显式定义。
|
||||
|
||||
手工构造时,offset 必须是非负的严格 `int`,不接受 `bool`,并满足:
|
||||
|
||||
```text
|
||||
content_start <= content_end <= full_end
|
||||
full_end - content_end <= 2
|
||||
```
|
||||
|
||||
访问器还会拒绝非字符串来源、超出来源长度的范围,以及不是 `""`、LF、CR、CRLF 的行尾切片。错误信息不包含原文。
|
||||
|
||||
## 4. 扫描函数
|
||||
|
||||
`iter_physical_lines(source)` 返回惰性迭代器,但会在函数调用当下检查 `source` 类型。它单次从左到右扫描,不预先复制原文。
|
||||
|
||||
`physical_lines(source)` 返回同一扫描结果的不可变 tuple,适合需要查看前后行或重复遍历的 Modifier。
|
||||
|
||||
`line_ending_styles(source)` 返回原文实际出现过的非空行尾集合,只可能包含 `"\n"`、`"\r"`、`"\r\n"`。空文档或
|
||||
没有换行的文档返回空 `frozenset`。
|
||||
|
||||
## 5. 精确示例
|
||||
|
||||
下表的结果写作 `(content_start, content_end, full_end)`:
|
||||
|
||||
| source | 结果 |
|
||||
| --- | --- |
|
||||
| `""` | `()` |
|
||||
| `"a"` | `((0, 1, 1),)` |
|
||||
| `"a\n"` | `((0, 1, 2),)` |
|
||||
| `"\n"` | `((0, 0, 1),)` |
|
||||
| `"\r\n"` | `((0, 0, 2),)` |
|
||||
| `"a\n\n"` | `((0, 1, 2), (2, 2, 3))` |
|
||||
| `"\ntext"` | `((0, 0, 1), (1, 5, 5))` |
|
||||
| `"a\r\nb\rc\n"` | `((0, 1, 3), (3, 4, 5), (5, 6, 7))` |
|
||||
| `"a\u2028b"` | `((0, 3, 3),)` |
|
||||
|
||||
按顺序拼接所有 `source[line.content_start:line.full_end]` 必须逐 code point 还原原文。
|
||||
|
||||
## 6. 在项目 Modifier 中使用
|
||||
|
||||
物理行范围只是定位工具,不会自动成为修改:
|
||||
|
||||
```python
|
||||
from mdpolish import DocumentSnapshot, ProposedChange, TextEdit, TextSpan
|
||||
from mdpolish.text_ranges import physical_lines
|
||||
|
||||
|
||||
def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
|
||||
lines = physical_lines(snapshot.markdown)
|
||||
if not lines or lines[0].content(snapshot.markdown) != "[REMOVE-ME]":
|
||||
return ()
|
||||
|
||||
line = lines[0]
|
||||
expected = snapshot.markdown[line.content_start : line.full_end]
|
||||
return (
|
||||
ProposedChange(
|
||||
snapshot_sha256=snapshot.sha256,
|
||||
reason="删除项目确认的独占行标记",
|
||||
edits=(
|
||||
TextEdit(
|
||||
snapshot_sha256=snapshot.sha256,
|
||||
span=TextSpan(line.content_start, line.full_end),
|
||||
expected_text=expected,
|
||||
replacement="",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
项目仍负责判断标题、列表、引用、代码、表格和业务语义。公共编辑执行器仍负责验证快照哈希、范围、预期原文与冲突。
|
||||
@@ -0,0 +1,287 @@
|
||||
# ReviewDocument 机器投影 schema 1.0
|
||||
|
||||
本文记录 `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 身份与入口
|
||||
|
||||
每个投影都包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_name": "mdpolish.review",
|
||||
"schema_version": "1.0"
|
||||
}
|
||||
```
|
||||
|
||||
schema 版本独立于 `mdpolish` 包版本和 modifier 版本。公共入口是:
|
||||
|
||||
```python
|
||||
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,不定义另一套字段,也不读写文件。
|
||||
reader 只接受内存字符串,返回新的普通 JSON 基本值容器;它不接收路径,也不恢复 `ReviewDocument`。
|
||||
|
||||
## 2. 顶层字段
|
||||
|
||||
| 字段 | JSON 类型 | 口径 |
|
||||
| --- | --- | --- |
|
||||
| `schema_name` | string | 固定为 `mdpolish.review` |
|
||||
| `schema_version` | string | 当前固定为 `1.0` |
|
||||
| `detail` | string | `summary`、`changes`、`full` |
|
||||
| `status` | string | `success`、`failed`、`unstable` |
|
||||
| `current_kind` | string | `success_output`、`partial_output` |
|
||||
| `stages_complete` | boolean | 是否能证明全部 modifier transform 阶段完成 |
|
||||
| `hash_contract` | object | 所有 Markdown 哈希的算法、编码和规范化口径 |
|
||||
| `coordinate_contract` | object | span、行列和物理换行口径 |
|
||||
| `input` | object | 输入文本摘要;`full` 增加正文 |
|
||||
| `current` | object | 当前文本摘要;`full` 增加正文 |
|
||||
| `counts` | object | modifier、完成阶段、实际修改、错误和残留候选数量 |
|
||||
| `modifiers` | array | 全部 modifier 的权威顺序 |
|
||||
| `stages` | array | 已完成 transform 阶段的权威顺序 |
|
||||
| `errors` | array | Pipeline 记录的错误顺序 |
|
||||
| `residual_proposals` | array | 只在 `changes` / `full` 出现;候选未应用 |
|
||||
|
||||
JSON object 成员顺序不构成语义。所有 array 顺序构成语义,消费者不得重新按 ID、哈希或文本排序后再解释位置。
|
||||
|
||||
## 3. 内容暴露等级
|
||||
|
||||
| 内容 | `summary` | `changes` | `full` |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 状态、哈希、位置口径和计数 | 是 | 是 | 是 |
|
||||
| modifier id、版本和位置 | 是 | 是 | 是 |
|
||||
| modifier 参数和 applicability | 否 | 是 | 是 |
|
||||
| 阶段前后哈希、长度和修改数 | 是 | 是 | 是 |
|
||||
| 实际修改的 reason、位置、`before` / `after` | 否 | 是 | 是 |
|
||||
| 稳定错误代码和错误位置 | 是 | 是 | 是 |
|
||||
| Python 诊断类型和错误消息 | 否 | 是 | 是 |
|
||||
| 残留候选的 reason、`expected_text` / `replacement` | 否 | 是 | 是 |
|
||||
| 输入、当前和阶段完整 Markdown | 否 | 否 | 是 |
|
||||
|
||||
默认是 `summary`。被隐藏的内容键不存在;`null` 和空字符串都不是“已隐藏”的替代值。`summary` 不含直接正文,但仍含
|
||||
modifier identity、哈希和计数,不自动等于匿名、脱敏或适合公开传播。
|
||||
|
||||
## 4. 固定口径
|
||||
|
||||
### 4.1 哈希
|
||||
|
||||
```json
|
||||
"hash_contract": {
|
||||
"algorithm": "sha256",
|
||||
"encoding": "utf-8",
|
||||
"normalization": "none"
|
||||
}
|
||||
```
|
||||
|
||||
哈希是精确 Markdown 的 `sha256(markdown.encode("utf-8")).hexdigest()`。不规范化 Unicode、BOM、空白、末尾换行或
|
||||
CR/LF/CRLF。`Change.before_sha256` / `after_sha256` 是完整阶段快照哈希,不是修改片段哈希。
|
||||
|
||||
### 4.2 坐标
|
||||
|
||||
```json
|
||||
"coordinate_contract": {
|
||||
"offset_unit": "unicode_code_point",
|
||||
"span_index_base": 0,
|
||||
"span_end": "exclusive",
|
||||
"location_index_base": 1,
|
||||
"physical_line_endings": ["lf", "crlf", "cr"]
|
||||
}
|
||||
```
|
||||
|
||||
- `span` 相对于所属 `ReviewStage.before_markdown`,使用 0-based Unicode code point 半开范围;
|
||||
- `location.line` / `column` 指向 `span.start`,使用同一阶段修改前文本中的 1-based Unicode code point 位置;
|
||||
- CRLF 形成一个物理换行,但在 offset 中占两个 code point;
|
||||
- 不提供最终全文坐标、UTF-16、byte offset 或终端显示宽度。
|
||||
|
||||
所有整数都在 `0..2**53-1`;参数中的有符号整数在 `-(2**53-1)..2**53-1`。所有 float 必须有限。
|
||||
|
||||
## 5. 嵌套对象
|
||||
|
||||
### 5.1 文本摘要
|
||||
|
||||
`input`、`current` 以及 stage 的 `before` / `after` 都至少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"sha256": "64 位小写十六进制",
|
||||
"code_point_length": 123
|
||||
}
|
||||
```
|
||||
|
||||
`full` 增加 `markdown`。`current.markdown` 是否为正式输出必须看根字段 `current_kind`;`partial_output` 不能当作成功结果。
|
||||
|
||||
### 5.2 Counts
|
||||
|
||||
```json
|
||||
"counts": {
|
||||
"modifier_count": 3,
|
||||
"completed_stage_count": 2,
|
||||
"change_count": 5,
|
||||
"error_count": 1,
|
||||
"residual_proposal_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
`change_count` 只统计已完成 stage 中的实际 `ReviewChange`,不含 residual proposal edit。零修改的完整 stage 仍计入
|
||||
`completed_stage_count`。
|
||||
|
||||
### 5.3 Modifier
|
||||
|
||||
所有 detail:
|
||||
|
||||
```json
|
||||
{
|
||||
"position": 0,
|
||||
"modifier_id": "example.normalize",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
`changes` / `full` 增加 `parameters` 和 `applicability`。`parameters` 是有序 array-of-pairs;所有内部 tuple 继续投影成
|
||||
array,不根据二元组外形猜成 JSON object:
|
||||
|
||||
```json
|
||||
"parameters": [
|
||||
["pattern", " {2,}"],
|
||||
["replacement", " "]
|
||||
]
|
||||
```
|
||||
|
||||
### 5.4 Stage
|
||||
|
||||
所有 detail:
|
||||
|
||||
```json
|
||||
{
|
||||
"modifier_position": 0,
|
||||
"before": {"sha256": "...", "code_point_length": 20},
|
||||
"after": {"sha256": "...", "code_point_length": 18},
|
||||
"change_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
`changes` / `full` 增加 `changes` array;`full` 还给两个文本摘要增加 `markdown`。`modifier_position` 引用根
|
||||
`modifiers[position]`,不复制 modifier 元数据。
|
||||
|
||||
### 5.5 实际 Change
|
||||
|
||||
只在 `changes` / `full` 出现:
|
||||
|
||||
```json
|
||||
{
|
||||
"proposal_index": 0,
|
||||
"edit_index": 0,
|
||||
"reason": "修改原因",
|
||||
"location": {"line": 3, "column": 7},
|
||||
"span": {"start": 24, "end": 31},
|
||||
"before": "原片段",
|
||||
"after": "新片段",
|
||||
"before_sha256": "修改前完整阶段哈希",
|
||||
"after_sha256": "修改后完整阶段哈希"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.6 Error
|
||||
|
||||
所有 detail 都含 `code`、`stage`、modifier 位置与身份。`changes` / `full` 再增加诊断用的 `diagnostic_type` 和
|
||||
`message`。
|
||||
|
||||
| `stage` | 稳定 `code` |
|
||||
| --- | --- |
|
||||
| `preflight` | `run.preflight_failed` |
|
||||
| `transform` | `run.transform_failed` |
|
||||
| `final_review` | `run.final_review_failed` |
|
||||
|
||||
代码只稳定表达失败阶段。`diagnostic_type` 是 Python 异常类名,`message` 是人类消息,不能作为稳定程序分支条件。当前
|
||||
`RunError` 没有保存更细的稳定原因;不能从类名或消息猜造更细代码。
|
||||
|
||||
### 5.7 Residual proposal
|
||||
|
||||
只在 `changes` / `full` 出现:
|
||||
|
||||
```json
|
||||
{
|
||||
"modifier_position": 0,
|
||||
"proposal_index": 0,
|
||||
"snapshot_sha256": "当前完整快照哈希",
|
||||
"reason": "候选原因",
|
||||
"edits": [
|
||||
{
|
||||
"edit_index": 0,
|
||||
"span": {"start": 10, "end": 15},
|
||||
"expected_text": "原片段",
|
||||
"replacement": "候选片段"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
它是 final review 证据,没有应用,不进入 stage 或实际 change count。
|
||||
|
||||
## 6. JSON 编码
|
||||
|
||||
`render_json_report()` 固定使用标准库 JSON 的以下语义:
|
||||
|
||||
- `ensure_ascii=False`;
|
||||
- `allow_nan=False`;
|
||||
- `indent=2`;
|
||||
- 无 UTF-8 BOM;
|
||||
- 返回字符串末尾不额外添加换行。
|
||||
|
||||
返回值是 Python `str`。调用方保存或发送时负责 UTF-8 编码、媒体类型、权限和保留周期。库不接收路径或文件对象。
|
||||
|
||||
## 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`:
|
||||
|
||||
- 删除、改名、改类型、改变位置/哈希/顺序语义、改变已有 enum 或错误代码含义,需要提升 major;
|
||||
- 在相同 detail 中新增更高敏感度的正文承载字段,需要提升 major,或增加必须显式请求的新 detail;
|
||||
- 不改变旧字段解释的非正文可选字段,可以提升 minor;
|
||||
- 实现修正为重新符合已有契约,不改变 schema 版本;
|
||||
- schema 版本不跟随 Python 包版本自动变化。
|
||||
|
||||
同一 major 的消费者必须忽略未知 object 字段,但必须保持 array 顺序;不得把未知状态当成 `success`。消费者应拒绝自己不
|
||||
支持的 schema major。
|
||||
|
||||
schema `1.0` 仍是单向生产契约。官方 reader 接受同一 major 的已知字段语义,并忽略未知 object 字段的语义;未知状态、
|
||||
detail、enum 或坐标契约仍会失败。当前没有 `ReviewDocument` 反序列化器、JSON Schema 文件、历史迁移器或数据库 schema。
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
Generated
+3578
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 不匹配。",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -175,13 +175,19 @@ def strict_html_tables(markdown: str) -> tuple[HtmlTable, ...]:
|
||||
if start < 0:
|
||||
break
|
||||
opening = _parse_tag(markdown, start)
|
||||
name_end = start + len("<table")
|
||||
if opening is None and (
|
||||
name_end == len(markdown) or markdown[name_end].isspace() or markdown[name_end] in "/>"
|
||||
):
|
||||
# 无法解析的 table 形开头同样会使后续嵌套关系不可信。
|
||||
break
|
||||
if opening is None or opening.closing or opening.name != "table":
|
||||
position = start + 1
|
||||
continue
|
||||
balanced_end = _balanced_table_end(markdown, opening)
|
||||
if balanced_end is None:
|
||||
position = opening.end
|
||||
continue
|
||||
# 未闭合外层之后无法证明后续 table 是否位于它内部, 停止发现以失败关闭。
|
||||
break
|
||||
table = _strict_table_at(markdown, start, balanced_end)
|
||||
if table is not None:
|
||||
tables.append(table)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Exact physical-line ranges shared by text cleaning modifiers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PhysicalLine:
|
||||
"""A content range and its optional line ending in one source string."""
|
||||
|
||||
content_start: int
|
||||
content_end: int
|
||||
full_end: int
|
||||
|
||||
def content(self, markdown: str) -> str:
|
||||
return markdown[self.content_start : self.content_end]
|
||||
|
||||
def line_ending(self, markdown: str) -> str:
|
||||
return markdown[self.content_end : self.full_end]
|
||||
|
||||
def is_blank(self, markdown: str) -> bool:
|
||||
return self.content_start == self.content_end
|
||||
|
||||
|
||||
def iter_physical_lines(markdown: str) -> Iterator[PhysicalLine]:
|
||||
"""Yield exact physical lines without normalizing CR or LF endings."""
|
||||
line_start = 0
|
||||
position = 0
|
||||
while position < len(markdown):
|
||||
character = markdown[position]
|
||||
if character == "\n":
|
||||
yield PhysicalLine(line_start, position, position + 1)
|
||||
position += 1
|
||||
line_start = position
|
||||
elif character == "\r":
|
||||
line_end = position + 2 if position + 1 < len(markdown) and markdown[position + 1] == "\n" else position + 1
|
||||
yield PhysicalLine(line_start, position, line_end)
|
||||
position = line_end
|
||||
line_start = position
|
||||
else:
|
||||
position += 1
|
||||
|
||||
if line_start < len(markdown):
|
||||
yield PhysicalLine(line_start, len(markdown), len(markdown))
|
||||
|
||||
|
||||
def physical_lines(markdown: str) -> tuple[PhysicalLine, ...]:
|
||||
"""Return all physical-line ranges as an immutable sequence."""
|
||||
return tuple(iter_physical_lines(markdown))
|
||||
|
||||
|
||||
def line_ending_styles(markdown: str) -> frozenset[str]:
|
||||
"""Return the distinct non-empty physical line endings in a string."""
|
||||
return frozenset(
|
||||
line.line_ending(markdown)
|
||||
for line in iter_physical_lines(markdown)
|
||||
if line.full_end > line.content_end
|
||||
)
|
||||
+31
-25
@@ -34,6 +34,35 @@ class _IndexedEdit:
|
||||
edit: TextEdit
|
||||
|
||||
|
||||
def _edit_order_key(item: _IndexedEdit) -> tuple[int, int, int, int]:
|
||||
edit = item.edit
|
||||
return (edit.span.start, edit.span.end, item.proposal_index, item.edit_index)
|
||||
|
||||
|
||||
def _ordered_indexed_edits(
|
||||
indexed_edits: tuple[_IndexedEdit, ...],
|
||||
*,
|
||||
reverse: bool = False,
|
||||
) -> tuple[_IndexedEdit, ...]:
|
||||
"""返回执行与评审重放共用的唯一编辑顺序。"""
|
||||
return tuple(sorted(indexed_edits, key=_edit_order_key, reverse=reverse))
|
||||
|
||||
|
||||
def _apply_validated_edits(
|
||||
snapshot: DocumentSnapshot,
|
||||
indexed_edits: tuple[_IndexedEdit, ...],
|
||||
) -> DocumentSnapshot:
|
||||
"""应用已经验证的批次, 但不生成审计记录。"""
|
||||
if not indexed_edits:
|
||||
return snapshot
|
||||
|
||||
markdown = snapshot.markdown
|
||||
for item in _ordered_indexed_edits(indexed_edits, reverse=True):
|
||||
edit = item.edit
|
||||
markdown = markdown[: edit.span.start] + edit.replacement + markdown[edit.span.end :]
|
||||
return DocumentSnapshot(markdown)
|
||||
|
||||
|
||||
def _edits_conflict(left: TextEdit, right: TextEdit) -> bool:
|
||||
left_span = left.span
|
||||
right_span = right.span
|
||||
@@ -102,31 +131,8 @@ def apply_modifier_batch(
|
||||
if not indexed_edits:
|
||||
return AppliedBatch(snapshot=snapshot, changes=())
|
||||
|
||||
markdown = snapshot.markdown
|
||||
application_order = sorted(
|
||||
indexed_edits,
|
||||
key=lambda item: (
|
||||
item.edit.span.start,
|
||||
item.edit.span.end,
|
||||
item.proposal_index,
|
||||
item.edit_index,
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
for item in application_order:
|
||||
edit = item.edit
|
||||
markdown = markdown[: edit.span.start] + edit.replacement + markdown[edit.span.end :]
|
||||
|
||||
updated_snapshot = DocumentSnapshot(markdown)
|
||||
report_order = sorted(
|
||||
indexed_edits,
|
||||
key=lambda item: (
|
||||
item.edit.span.start,
|
||||
item.edit.span.end,
|
||||
item.proposal_index,
|
||||
item.edit_index,
|
||||
),
|
||||
)
|
||||
updated_snapshot = _apply_validated_edits(snapshot, indexed_edits)
|
||||
report_order = _ordered_indexed_edits(indexed_edits)
|
||||
changes = tuple(
|
||||
Change(
|
||||
modifier_id=modifier.modifier_id,
|
||||
|
||||
@@ -177,6 +177,7 @@ class RunStatus(StrEnum):
|
||||
class ErrorStage(StrEnum):
|
||||
"""The pipeline phase in which an error occurred."""
|
||||
|
||||
PREFLIGHT = "preflight"
|
||||
TRANSFORM = "transform"
|
||||
FINAL_REVIEW = "final_review"
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ def html_table_entity_unescape() -> Modifier:
|
||||
|
||||
return Modifier(
|
||||
modifier_id="markdown.html_table_entity_unescape",
|
||||
version="1.0.0",
|
||||
version="1.0.1",
|
||||
parameters=(),
|
||||
applicability=_APPLICABILITY,
|
||||
propose=propose,
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from mdpolish._html_table import strict_html_tables
|
||||
from mdpolish._text_ranges import line_ending_styles
|
||||
from mdpolish.models import DocumentSnapshot, ProposedChange, TextEdit, TextSpan
|
||||
from mdpolish.modifier import Modifier
|
||||
from mdpolish.text_ranges import line_ending_styles
|
||||
|
||||
_REASON = "展开严格单行 HTML 表格的行布局"
|
||||
_APPLICABILITY = (
|
||||
@@ -50,7 +50,7 @@ def html_table_layout() -> Modifier:
|
||||
|
||||
return Modifier(
|
||||
modifier_id="markdown.html_table_layout",
|
||||
version="1.0.0",
|
||||
version="1.0.1",
|
||||
parameters=(),
|
||||
applicability=_APPLICABILITY,
|
||||
propose=propose,
|
||||
|
||||
@@ -14,6 +14,11 @@ word-splitting problem, while Enchant/Hunspell depend on system dictionaries,
|
||||
so those libraries are not runtime backends here. No backend is treated as
|
||||
ground truth or used for edit-distance correction.
|
||||
|
||||
The bundled ``pyspellchecker`` language dictionaries do not support
|
||||
case-sensitive lookup. A lexical rule selecting that backend must explicitly
|
||||
set ``case_sensitive=False``; the unsupported combination is rejected while
|
||||
the modifier is built, without switching backend or inspecting source text.
|
||||
|
||||
Public rules support exact fragments, named regular expressions, a right-side
|
||||
condition at a logical line end, explicit keep/veto rules, and local dictionary
|
||||
candidate selection. A join may preserve the physical boundary, delete it,
|
||||
@@ -43,9 +48,9 @@ from importlib.metadata import version as package_version
|
||||
from itertools import pairwise
|
||||
from typing import Protocol, TypeAlias
|
||||
|
||||
from mdpolish._text_ranges import PhysicalLine, physical_lines
|
||||
from mdpolish.models import DocumentSnapshot, ProposedChange, TextEdit, TextSpan
|
||||
from mdpolish.modifier import Modifier, ModifierContractError
|
||||
from mdpolish.text_ranges import PhysicalLine, physical_lines
|
||||
|
||||
try:
|
||||
from spellchecker import SpellChecker as _SpellChecker # type: ignore[import-not-found]
|
||||
@@ -538,7 +543,7 @@ def _scan_lines(markdown: str) -> tuple[_ScannedLine, ...]:
|
||||
blank_run = 0
|
||||
for index, line in enumerate(lines):
|
||||
raw = line.content(markdown)
|
||||
if line.is_blank(markdown):
|
||||
if line.is_empty:
|
||||
blank_run += 1
|
||||
active_heading = None
|
||||
if blank_run > 1:
|
||||
@@ -785,6 +790,11 @@ def _validate_rule(rule: LineJoinRule) -> tuple[re.Pattern[str] | None, re.Patte
|
||||
raise ModifierContractError("line join backend must be a LexiconBackend value")
|
||||
if not isinstance(rule.language, str) or not rule.language.strip():
|
||||
raise ModifierContractError("line join lexical language must be a non-empty string")
|
||||
if rule.backend is LexiconBackend.SPELLCHECKER and rule.case_sensitive:
|
||||
raise ModifierContractError(
|
||||
f"line join rule {rule.rule_id!r}: pyspellchecker bundled language dictionaries do not support "
|
||||
"case_sensitive=True; set case_sensitive=False explicitly to use this backend"
|
||||
)
|
||||
if rule.separator not in {"", "-"}:
|
||||
raise ModifierContractError("line join lexical separator must be '-' or empty")
|
||||
if not isinstance(rule.candidate_forms, tuple) or any(
|
||||
@@ -1133,7 +1143,7 @@ def _boundary_between(
|
||||
if right.physical_index <= left.physical_index:
|
||||
raise ModifierContractError("line join scanner produced non-monotonic physical lines")
|
||||
intervening = lines[left.physical_index + 1 : right.physical_index]
|
||||
if len(intervening) > max_blank_lines or any(not line.is_blank(markdown) for line in intervening):
|
||||
if len(intervening) > max_blank_lines or any(not line.is_empty for line in intervening):
|
||||
return None
|
||||
ending = left.physical.line_ending(markdown)
|
||||
if not ending or any(line.line_ending(markdown) != ending for line in intervening):
|
||||
@@ -1363,7 +1373,7 @@ def mapped_line_join(
|
||||
|
||||
return Modifier(
|
||||
modifier_id="markdown.mapped_line_join",
|
||||
version="2.0.0",
|
||||
version="2.0.1",
|
||||
parameters={
|
||||
"conflict_policy": conflict_policy.value,
|
||||
"max_intervening_blank_lines": max_intervening_blank_lines,
|
||||
|
||||
@@ -222,7 +222,7 @@ class Pipeline:
|
||||
return tuple(modifier_infos), self._run_error(
|
||||
modifier_info=None,
|
||||
position=position,
|
||||
stage=ErrorStage.TRANSFORM,
|
||||
stage=ErrorStage.PREFLIGHT,
|
||||
error=error,
|
||||
unexpected_message="a pipeline entry is not a Modifier instance",
|
||||
)
|
||||
@@ -232,7 +232,7 @@ class Pipeline:
|
||||
return tuple(modifier_infos), self._run_error(
|
||||
modifier_info=None,
|
||||
position=position,
|
||||
stage=ErrorStage.TRANSFORM,
|
||||
stage=ErrorStage.PREFLIGHT,
|
||||
error=error,
|
||||
unexpected_message="modifier metadata does not satisfy the modifier contract",
|
||||
)
|
||||
@@ -242,7 +242,7 @@ class Pipeline:
|
||||
return tuple(modifier_infos), self._run_error(
|
||||
modifier_info=modifier_info,
|
||||
position=position,
|
||||
stage=ErrorStage.TRANSFORM,
|
||||
stage=ErrorStage.PREFLIGHT,
|
||||
error=duplicate_error,
|
||||
unexpected_message="pipeline modifier_id values must be unique",
|
||||
)
|
||||
|
||||
+34
-2
@@ -1,4 +1,4 @@
|
||||
"""Safe factory for exact regular-expression replacements."""
|
||||
"""把非空正则匹配翻译成精确编辑的修改器工厂。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,7 +7,10 @@ import re
|
||||
from mdpolish.models import DocumentSnapshot, ProposedChange, TextEdit, TextSpan
|
||||
from mdpolish.modifier import Modifier, ModifierContractError
|
||||
|
||||
# 调用方未显式提供 applicability 时使用的默认值。
|
||||
# 文案明确两点: 只处理正则命中位置; 不解析文档结构, 也不自动启用。
|
||||
_DEFAULT_APPLICABILITY = "处理调用方正则表达式明确匹配的文本;不推断文档结构,也不自动启用。"
|
||||
# 所有由本工厂产出的候选修改共享同一个 reason, 便于审计和报告。
|
||||
_REASON = "应用调用方声明的正则表达式替换"
|
||||
|
||||
|
||||
@@ -20,38 +23,64 @@ def regex_replace(
|
||||
flags: int | re.RegexFlag = 0,
|
||||
applicability: str = _DEFAULT_APPLICABILITY,
|
||||
) -> Modifier:
|
||||
"""Create a modifier that turns non-empty regex matches into exact edits."""
|
||||
"""构造一个把正则非空匹配转成精确 TextEdit 的 Modifier。
|
||||
|
||||
本函数只负责"提议"——返回的 Modifier 不会自行改动字符串;
|
||||
真正的范围、原文、冲突和原子应用由核心执行器负责。
|
||||
"""
|
||||
# 1. 参数类型校验: 保证下游按字面值比较 parameters 时不会混入非字符串。
|
||||
if not isinstance(pattern, str):
|
||||
raise ModifierContractError("regex pattern must be a string")
|
||||
if not isinstance(replacement, str):
|
||||
raise ModifierContractError("regex replacement must be a string")
|
||||
if type(flags) is not int and not isinstance(flags, re.RegexFlag):
|
||||
raise ModifierContractError("regex flags must be an integer or RegexFlag")
|
||||
|
||||
# 2. 提前编译正则: 把 re.error 直接翻译成 ModifierContractError,
|
||||
# 让调用方在创建阶段就看到失败, 而不是运行 Pipeline 时才暴露。
|
||||
try:
|
||||
compiled = re.compile(pattern, flags)
|
||||
except (re.error, ValueError) as error:
|
||||
raise ModifierContractError("regex pattern and flags must compile successfully") from error
|
||||
|
||||
# 3. 零长度匹配守卫: search("") 是最小成本探测。
|
||||
# 零长度匹配会在 finditer 里产生无限推进或死循环, 必须在工厂阶段拒绝。
|
||||
empty_match = compiled.search("")
|
||||
if empty_match is not None and empty_match.start() == empty_match.end():
|
||||
raise ModifierContractError("regex patterns that produce zero-length matches are not supported")
|
||||
|
||||
def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
|
||||
"""把当前快照里所有非空正则匹配转换为一条 ProposedChange / 一个 TextEdit。"""
|
||||
proposals: list[ProposedChange] = []
|
||||
# 4. finditer 给出非重叠、按出现顺序的多匹配;
|
||||
# Python 标准库保证循环次数有限, 前提是模式不是零长度。
|
||||
for match in compiled.finditer(snapshot.markdown):
|
||||
# 5. 二次零长度守卫: 覆盖运行时才暴露的零长度匹配(例如某些 lookahead
|
||||
# 在不同前缀下行为不同), 任一条命中立刻让整批 Modifier 不执行。
|
||||
if match.start() == match.end():
|
||||
raise ModifierContractError("regex patterns that produce zero-length matches are not supported")
|
||||
|
||||
# 6. match.expand 让 replacement 支持 \1、\g<name> 这类反向引用,
|
||||
# 保持与 re.sub 相同的替换语义。
|
||||
expanded = match.expand(replacement)
|
||||
# 7. 命中原文; 执行器会用它与快照同位置实际文本二次比对。
|
||||
expected = match.group()
|
||||
|
||||
# 8. 替换后等于原文(典型场景: 把 "a" 替换成 "a")的命中不产出编辑。
|
||||
# 一来省去执行器的零修改拒绝路径, 二来让 result.changes 只含真改动。
|
||||
if expected == expanded:
|
||||
continue
|
||||
|
||||
proposals.append(
|
||||
ProposedChange(
|
||||
# 9. 把当前快照哈希绑进候选和每条编辑, 下游可据此拒绝跨快照复用。
|
||||
snapshot_sha256=snapshot.sha256,
|
||||
reason=_REASON,
|
||||
edits=(
|
||||
TextEdit(
|
||||
snapshot_sha256=snapshot.sha256,
|
||||
# 10. 半开区间 [start, end), 与 Python len/切片口径一致;
|
||||
# 不做 UTF-16、字节或显示列宽换算。
|
||||
span=TextSpan(match.start(), match.end()),
|
||||
expected_text=expected,
|
||||
replacement=expanded,
|
||||
@@ -59,11 +88,14 @@ def regex_replace(
|
||||
),
|
||||
)
|
||||
)
|
||||
# 11. 返回不可变 tuple, 便于上游 dataclass 字段直接持有。
|
||||
return tuple(proposals)
|
||||
|
||||
return Modifier(
|
||||
modifier_id=modifier_id,
|
||||
version=version,
|
||||
# 12. parameters 只放原始字面值, flags 强制转 int,
|
||||
# 保证 Modifier 的相等/哈希判断只看输入数据, 而不是 re.Pattern 内存地址。
|
||||
parameters={"flags": int(flags), "pattern": pattern, "replacement": replacement},
|
||||
applicability=applicability,
|
||||
propose=propose,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Exact physical-line ranges for project and library modifiers.
|
||||
|
||||
This module scans only CR, LF, and CRLF boundaries. It preserves the source
|
||||
string and reports Python Unicode code point offsets; it does not parse
|
||||
Markdown blocks, normalize line endings, read files, or propose edits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = [
|
||||
"PhysicalLine",
|
||||
"iter_physical_lines",
|
||||
"line_ending_styles",
|
||||
"physical_lines",
|
||||
]
|
||||
|
||||
_LINE_ENDINGS = frozenset(("", "\n", "\r", "\r\n"))
|
||||
|
||||
|
||||
def _require_source(source: object) -> str:
|
||||
if not isinstance(source, str):
|
||||
raise TypeError("source must be a string")
|
||||
return source
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PhysicalLine:
|
||||
"""A source string's exact content range and optional line-ending range."""
|
||||
|
||||
content_start: int
|
||||
content_end: int
|
||||
full_end: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in ("content_start", "content_end", "full_end"):
|
||||
if type(getattr(self, field_name)) is not int:
|
||||
raise TypeError(f"{field_name} must be an integer")
|
||||
if self.content_start < 0 or self.content_end < 0 or self.full_end < 0:
|
||||
raise ValueError("physical line offsets must be non-negative")
|
||||
if not self.content_start <= self.content_end <= self.full_end:
|
||||
raise ValueError("physical line offsets must be ordered")
|
||||
if self.full_end - self.content_end > 2:
|
||||
raise ValueError("physical line ending range must contain at most two code points")
|
||||
|
||||
def _validated_source(self, source: object) -> str:
|
||||
validated = _require_source(source)
|
||||
if self.full_end > len(validated):
|
||||
raise ValueError("physical line range exceeds source length")
|
||||
return validated
|
||||
|
||||
def content(self, source: str) -> str:
|
||||
"""Return this line's content without its physical line ending."""
|
||||
validated = self._validated_source(source)
|
||||
return validated[self.content_start : self.content_end]
|
||||
|
||||
def line_ending(self, source: str) -> str:
|
||||
"""Return this line's exact CR, LF, CRLF, or absent line ending."""
|
||||
validated = self._validated_source(source)
|
||||
ending = validated[self.content_end : self.full_end]
|
||||
if ending not in _LINE_ENDINGS:
|
||||
raise ValueError("physical line ending must be empty, LF, CR, or CRLF")
|
||||
return ending
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""Return whether the content range has zero length."""
|
||||
return self.content_start == self.content_end
|
||||
|
||||
|
||||
def _iter_physical_lines(source: str) -> Iterator[PhysicalLine]:
|
||||
line_start = 0
|
||||
position = 0
|
||||
while position < len(source):
|
||||
character = source[position]
|
||||
if character == "\n":
|
||||
yield PhysicalLine(line_start, position, position + 1)
|
||||
position += 1
|
||||
line_start = position
|
||||
elif character == "\r":
|
||||
line_end = position + 2 if position + 1 < len(source) and source[position + 1] == "\n" else position + 1
|
||||
yield PhysicalLine(line_start, position, line_end)
|
||||
position = line_end
|
||||
line_start = position
|
||||
else:
|
||||
position += 1
|
||||
|
||||
if line_start < len(source):
|
||||
yield PhysicalLine(line_start, len(source), len(source))
|
||||
|
||||
|
||||
def iter_physical_lines(source: str) -> Iterator[PhysicalLine]:
|
||||
"""Return a lazy iterator over exact physical lines in ``source``."""
|
||||
return _iter_physical_lines(_require_source(source))
|
||||
|
||||
|
||||
def physical_lines(source: str) -> tuple[PhysicalLine, ...]:
|
||||
"""Return all exact physical-line ranges as an immutable sequence."""
|
||||
return tuple(iter_physical_lines(source))
|
||||
|
||||
|
||||
def line_ending_styles(source: str) -> frozenset[str]:
|
||||
"""Return the distinct non-empty physical line endings in ``source``."""
|
||||
validated = _require_source(source)
|
||||
return frozenset(
|
||||
line.line_ending(validated)
|
||||
for line in iter_physical_lines(validated)
|
||||
if line.full_end > line.content_end
|
||||
)
|
||||
@@ -23,6 +23,7 @@ def test_unescapes_one_layer_only_in_strict_cell_text() -> None:
|
||||
"\noutside &lt;"
|
||||
)
|
||||
assert len(result.changes) == 3
|
||||
assert result.modifiers[0].version == "1.0.1"
|
||||
|
||||
|
||||
def test_multiple_tables_and_cells_report_source_order() -> None:
|
||||
@@ -47,6 +48,21 @@ def test_non_strict_or_outside_content_is_preserved(markdown: str) -> None:
|
||||
assert transform(markdown).output_markdown == markdown
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"markdown",
|
||||
[
|
||||
"<table>broken<table><tr><td>&lt;</td></tr></table>",
|
||||
"<table broken <table><tr><td>&lt;</td></tr></table>",
|
||||
],
|
||||
)
|
||||
def test_damaged_outer_table_does_not_expose_complete_inner_table(markdown: str) -> None:
|
||||
result = transform(markdown)
|
||||
|
||||
assert result.status is RunStatus.SUCCESS
|
||||
assert result.output_markdown == markdown
|
||||
assert result.changes == ()
|
||||
|
||||
|
||||
def test_fenced_table_is_not_protected_by_the_lexical_subset() -> None:
|
||||
markdown = "```html\n<table><tr><td>&lt;</td></tr></table>\n```"
|
||||
assert transform(markdown).output_markdown == "```html\n<table><tr><td><</td></tr></table>\n```"
|
||||
|
||||
@@ -23,6 +23,7 @@ def test_expands_rows_without_changing_tags_attributes_or_cells() -> None:
|
||||
"</table>\nafter"
|
||||
)
|
||||
assert len(result.changes) == 1
|
||||
assert result.modifiers[0].version == "1.0.1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("line_ending", ["\n", "\r\n", "\r"])
|
||||
@@ -56,6 +57,21 @@ def test_mixed_multiline_or_non_strict_tables_are_preserved(markdown: str) -> No
|
||||
assert transform(markdown).output_markdown == markdown
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"markdown",
|
||||
[
|
||||
"<table>broken<table><tr><td>A</td></tr></table>",
|
||||
"<table broken <table><tr><td>A</td></tr></table>",
|
||||
],
|
||||
)
|
||||
def test_damaged_outer_table_does_not_expose_complete_inner_table(markdown: str) -> None:
|
||||
result = transform(markdown)
|
||||
|
||||
assert result.status is RunStatus.SUCCESS
|
||||
assert result.output_markdown == markdown
|
||||
assert result.changes == ()
|
||||
|
||||
|
||||
def test_successful_output_is_idempotent() -> None:
|
||||
pipeline = Pipeline([html_table_layout()])
|
||||
first = pipeline.transform(TABLE)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user