# 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 发布资产或反序列化器; - 读取、复制、修改或公开真实文档和外部数据。