feat: 增加评审文档机器投影与 JSON 报告

This commit is contained in:
2026-08-28 14:23:30 +08:00
parent dd012f4905
commit 843952b194
7 changed files with 2096 additions and 16 deletions
@@ -0,0 +1,703 @@
# 0012ReviewDocument 的正式机器投影
## 状态
已于 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 发布资产或反序列化器;
- 读取、复制、修改或公开真实文档和外部数据。