Files
mdpolish/research-wiki/design/0012-review-document-machine-projection.md
T

30 KiB
Raw Blame History

0012ReviewDocument 的正式机器投影

状态

已于 2026-08-28 获用户明确批准,按本文第 16 节实施。本文自批准起冻结;后续改变决策需新增 design 并使用 supersedes 指向本文。

supersedes: 0011(范围有限):本文只替代 0011 第 8 节“第一版不提供 JSON reporter”和其中把稳定序列化继续留给 调用项目的决定。0011 已批准并实现的可信重放、ReviewDocument、阶段坐标、Markdown reporter、无文件 I/O 和项目拥有 持久化决定权继续有效。

本文不改变 PipelineTransformResult、清洗语义或评审重放结果。它只定义怎样把已经建立并验证的 ReviewDocument 转成稳定的普通 Python 数据,再按同一结构编码为 JSON。

1. 问题与可观察现象

v0.4.0 已经可以在同一个 Python 进程中这样消费评审结果:

review = build_review_document(input_markdown, result)

if review.status is RunStatus.SUCCESS:
    print(review.current_sha256)

这时调用方直接使用不可变的 ReviewDocumentReviewStageReviewChange,不需要序列化。

一旦评审结果需要经过 Web、数据库、消息队列、JSON 文件或其他语言,内部 Python 对象就不能直接作为契约。当前项目端只能 自己决定怎样处理 dataclass、StrEnum、tuple、半开范围、阶段哈希和部分输出。最短的做法看似是:

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 把 JSON object 定义为无序名称/值集合,把 array 定义为有序序列; 对外语义不能依赖 object 成员顺序,但修改器、阶段、修改和残留候选的顺序必须用 array 保存;
  • RFC 8259 要求开放系统中的 JSON 文本使用 UTF-8,成员名应唯一,并指出超出 IEEE 754 binary64 精确整数范围的数字会降低 互操作性;本投影只发出唯一键、可 UTF-8 编码的字符串和安全范围整数;
  • Python json 文档 显示 allow_nan 默认允许非标准的 NaN / Infinity ensure_ascii 默认转义非 ASCII;官方 reporter 必须显式使用 allow_nan=Falseensure_ascii=False
  • JSON Schema 2020-12 区分 schema 版本和实例内容,并允许通过 schema 约束对象、数组和 enum;本轮先固定实例 schema 和兼容策略,不引入运行时 validator 或第三方 schema 依赖;
  • SARIF 2.1.0 区分稳定标识符与给人看的消息,并明确 不应从没有稳定标识的来源中猜造精细规则 ID;本投影只为现有 ErrorStage 提供粗粒度稳定代码,不从异常类名或文案 推断更细错误原因。

这些参考不表示 mdpolish 要实现 SARIF 或 JSON Schema validator。它们只帮助确定 JSON 互操作、顺序、版本和错误身份的 边界。

3. 目标与非目标

3.1 目标

  • 提供官方 ReviewDocument -> dict 投影,返回只含 JSON 基本值的全新普通数据结构;
  • 提供使用同一投影的确定性 JSON reporter,不建立第二套字段;
  • 每个结果都携带独立于包版本的 schema_version
  • 固定 enum、array、哈希和位置的外部表达,避免调用方解释 Python 内部对象;
  • 提供 summarychangesfull 三个单调增加的正文暴露等级,默认不暴露正文;
  • 给当前三个错误阶段提供粗粒度、稳定、可供程序判断的代码,同时保留诊断字段的非稳定身份;
  • 明确 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 格式;
  • 不改变 ReviewDocumentReviewStageChangeRunErrorTransformResult 的字段;
  • 不增加细粒度的 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. 职责与数据流

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__ 重新导出:

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 是有意的隐私边界:调用方必须显式选择 changesfull 才能得到 Markdown 正文或修改片段。

返回类型是普通可变 dict/list,因为目标就是 Python 和 JSON 生态通用的数据载体;权威 ReviewDocument 仍然不可变。 每次调用都返回新的递归容器,修改返回值不能反向改变 review,也不能影响下一次投影。

7. Schema 身份与顶层结构

第一版 schema 名称和版本固定为:

{
  "schema_name": "mdpolish.review",
  "schema_version": "1.0"
}

schema_version 是数据格式版本,不是 mdpolish 包版本,也不是 modifier 版本。第一版完整顶层结构为:

{
  "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 只在 changesfull 中出现;summary 通过 counts.residual_proposal_count 报告总数。

所有 object 键必须唯一。上面的成员排列是官方 renderer 的可读输出顺序,但 JSON object 本身无序,消费者不得根据键顺序 解释语义。所有 array 顺序都有意义,必须保持 ReviewDocument 的权威顺序,不按 ID、哈希或文本重新排序。

7.1 enum 表达

enum 一律投影为已批准的 .value 小写字符串,不输出 Python 类名、repr() 或整数序号:

Python enum 第一版允许值
RunStatus successfailedunstable
ReviewCurrentKind success_outputpartial_output
ErrorStage preflighttransformfinal_review
ReviewDetail summarychangesfull

投影遇到未知 enum 实例或未知值时失败,不把 str(value) 当作向前兼容。

7.2 哈希口径

所有 sha256 字段都是对对应精确 Markdown 字符串执行:

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. 三个正文暴露等级

三个等级必须满足单调关系:

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 只承诺不包含以下正文承载字段:markdownbeforeafterexpected_textreplacementreasonmessageparametersapplicability。它仍含 modifier identity、哈希、位置和计数,不能在不了解项目数据政策的情况下 称为匿名、脱敏或可公开日志。

changes 会暴露实际修改和未应用残留候选中的片段,也会暴露项目 modifier 配置和诊断消息。它可能足以还原敏感局部内容。

full 还会重复保存输入、当前全文及每个完整阶段的前后全文,内存和 JSON 大小可能随修改器数量线性增长。调用方必须显式 选择,库不截断、不脱敏,也不自动落盘。

9. 各对象的正式投影

以下字段名称、类型和层级属于 schema 1.0。示例中的省略号只为文档可读,正式输出不得包含省略号。

9.1 输入和当前文本

三个 detail 都输出:

"input": {
  "sha256": "...",
  "code_point_length": 123
},
"current": {
  "sha256": "...",
  "code_point_length": 120
}

full 分别增加:

"markdown": "完整文本"

current.markdown 的性质必须结合根字段 current_kind 判断。partial_output 永远不能因为进入 JSON 而改名为 cleaned、final 或 successful。

9.2 计数

"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 的稳定身份:

"modifiers": [
  {
    "position": 0,
    "modifier_id": "example.normalize",
    "version": "1.0.0"
  }
]

changesfull 增加:

"parameters": [
  ["pattern", " {2,}"],
  ["replacement", " "]
],
"applicability": "调用方声明的适用范围"

parameters 不投影成 JSON object。当前内部 ParameterValue 会把 mapping 和 sequence 都冻结成 tuple;某些嵌套值在运行时 无法可靠区分原来是 mapping 还是二元组 sequence。第一版忠实投影冻结后的结构:顶层及所有 tuple 都变成有序 array, 标量保持 strint、有限 floatboolnull。投影不得根据“看起来像键值对”猜成 object。

9.4 完整阶段

所有 detail 都输出:

"stages": [
  {
    "modifier_position": 0,
    "before": {
      "sha256": "...",
      "code_point_length": 123
    },
    "after": {
      "sha256": "...",
      "code_point_length": 120
    },
    "change_count": 2
  }
]

changesfull 增加 changes arrayfull 再给 beforeafter 增加 markdown。阶段通过 modifier_position 引用根 modifiers,不复制第二份 modifier 身份。

阶段顺序与 ReviewDocument.stages 相同。不得从 change_count 猜测阶段是否完整;完整性继续由根字段 stages_complete 和已有阶段边界表达。

9.5 已应用修改

只在 changesfull 中出现:

"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 都输出稳定身份和位置:

"errors": [
  {
    "code": "run.transform_failed",
    "stage": "transform",
    "modifier_position": 1,
    "modifier_id": "example.normalize",
    "modifier_version": "1.0.0"
  }
]

changesfull 增加:

"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 是给人排障的消息;两者都不属于稳定程序分支条件。消费者只能使用 codestage 做稳定判断。

这些代码有意保持粗粒度。当前 RunError 没有保存“元数据变化”“propose 失败”“批次验证失败”等稳定原因,投影不得解析 error_typemessage 猜出更细代码。未来要增加精细代码,必须先用另一份 design 改变 Pipeline 的错误事实来源。

9.7 残留候选

summary 只输出总数。changesfull 输出完整 residual proposal

"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_proposalsProposedChange.edits 的顺序。残留候选仍未应用;投影不能把它放进 stages 或 change count。

10. JSON 基本值和失败关闭

review_document_to_dict() 只能发出:

object / array / string / integer / finite number / boolean / null

它不能依赖 json.dumps(default=...) 临时处理未知对象。每种公共模型和 enum 都要显式转换;遇到未知类型立即抛出 ReviewProjectionError

投影时至少检查:

  • reviewReviewDocument
  • 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() 编码这个返回值。

固定编码行为:

dumps(
    projection,
    ensure_ascii=False,
    allow_nan=False,
    indent=2,
)

第一版不开放 indentsort_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,在 summarychanges 中新增更高等级正文也必须升 major,或新增一个 需要调用方显式选择的 detail。

第一版只提供生产,不提供兼容读取器。历史 JSON 的迁移、数据库 schema 和多版本读取由实际跨进程需求触发下一份 design。

13. 源码、文档与版本边界

批准后计划修改:

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.mdCLAUDE.mdsrc/mdpolish/regex.py 修改。后续实施必须继续保留并隔离这些改动, 不能把它们混入本功能的 diff 或提交。

14. 测试与验收

14.1 Schema 和 detail

合成测试至少覆盖:

  • 空文档、空流水线和零修改结果;
  • successfailedunstable,以及完整和不完整 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_typemessagechanges/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.pypy.typed,不包含 tests、Wiki、JSON 报告、真实数据或项目文件;
  • README 示例只处理内存对象和字符串,不暗示 JSON 已经保存;
  • AGENTS.mdCLAUDE.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 发布资产或反序列化器;
  • 读取、复制、修改或公开真实文档和外部数据。