diff --git a/README.md b/README.md index 3077905..48c938a 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ `mdpolish` 是实验室共用的、项目无关的 Python Markdown 修改库。它提供函数式 `Modifier`、精确文本编辑执行器、 有序 `Pipeline`、正则修改器工厂,以及少量可以用合成样例完整说明的通用修改器。 -当前发布版本是 [`v0.3.0`](https://github.com/Bepr4/mdpolish/releases/tag/v0.3.0)。库只处理内存中的 Markdown -字符串,不读取或写入文件,不提供默认流水线,也不包含任何项目的规则集合、数据清单、实验脚本或评审工具。 +当前发布版本是 [`v0.4.0`](https://github.com/Bepr4/mdpolish/releases/tag/v0.4.0)。库只处理内存中的 Markdown 字符串, +不读取或写入文件,不提供默认流水线,也不包含任何项目的规则集合、数据清单、实验脚本或评审界面。 ## 当前能力 @@ -13,6 +13,8 @@ | `Modifier` | 把不可变元数据与普通提议函数组合起来 | 函数只提议修改,不直接改字符串或文件 | | 精确编辑执行器 | 校验快照、范围、原文、重复和冲突后原子应用一个批次 | 不判断项目业务语义 | | `Pipeline` | 按调用方顺序运行修改器,并对最终快照做只读稳定性复查 | 不自动选规则、不重排、不循环执行 | +| `build_review_document()` | 验证并重放已有结果,提供可信阶段、位置、全文和错误/残留证据 | 不重新运行修改器,不猜测损坏或不完整的结果 | +| `render_markdown_report()` | 把评审视图编排成完整 Markdown 源码报告字符串 | 只返回内存字符串,不创建文件或业务页面 | | `regex_replace()` | 把非空正则匹配转换为精确编辑 | 不提供规则注册表、配置加载或默认模式 | | `mapped_line_join()` | 用精确、正则或可选本地词典规则合并跨行片段 | 无默认规则;代码、表格、未知结构和歧义失败关闭 | | HTML 表格修改器 | 处理严格表格子集的实体和单行布局 | 不是完整 HTML parser,也不是 HTML→GFM 转换器 | @@ -31,15 +33,15 @@ 不可移动的 tag: ```bash -python -m pip install 'mdpolish @ git+https://github.com/Bepr4/mdpolish.git@v0.3.0' -python -m pip install 'mdpolish[lexical] @ git+https://github.com/Bepr4/mdpolish.git@v0.3.0' -python -m pip install 'mdpolish[frequency] @ git+https://github.com/Bepr4/mdpolish.git@v0.3.0' +python -m pip install 'mdpolish @ git+https://github.com/Bepr4/mdpolish.git@v0.4.0' +python -m pip install 'mdpolish[lexical] @ git+https://github.com/Bepr4/mdpolish.git@v0.4.0' +python -m pip install 'mdpolish[frequency] @ git+https://github.com/Bepr4/mdpolish.git@v0.4.0' ``` 也可以安装同一 GitHub Release 附带的 wheel: ```bash -python -m pip install 'mdpolish[lexical] @ https://github.com/Bepr4/mdpolish/releases/download/v0.3.0/mdpolish-0.3.0-py3-none-any.whl' +python -m pip install 'mdpolish[lexical] @ https://github.com/Bepr4/mdpolish/releases/download/v0.4.0/mdpolish-0.4.0-py3-none-any.whl' ``` Release 页面同时提供 wheel 的 SHA-256 校验值。仓库或 Release 如果是私有的,调用方需要自行配置 GitHub 访问权限; @@ -156,7 +158,35 @@ 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`。 +文件读取、输出命名、覆盖策略、批处理和 CLI 都属于调用项目。`mdpolish` 可以生成通用的内存评审视图与 Markdown 报告 +字符串,但不会自动保存它们,也不知道报告来自哪个文件。 + +## 构建内存评审视图和报告 + +调用方保留原始 Markdown,并把它与 `TransformResult` 一起传给评审构建函数: + +```python +from mdpolish.review import build_review_document, render_markdown_report + +input_markdown = "an exam-\nple text" +result = pipeline.transform(input_markdown) + +review = build_review_document(input_markdown, result) +report_markdown = render_markdown_report(review) + +assert review.current_markdown == "an example text" +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 条残留候选,并且不重复输出残留候选正文;调用项目如果保存报告,仍需负责路径、权限、脱敏和保留周期。每个完整 +阶段都会保留前后快照,当前版本没有承诺无限文档长度或修改器数量下的内存上限。 ## 编写项目自己的修改器 @@ -232,6 +262,7 @@ src/mdpolish/ ├── modifier.py # 函数式 Modifier 契约 ├── edits.py # 批次验证与原子应用 ├── pipeline.py # 有序执行与最终稳定性复查 +├── review.py # 可信评审投影与内存 Markdown reporter ├── regex.py # 正则修改器工厂 └── modifiers/ # 少量项目无关的通用修改器 tests/ # 只使用虚构文本的核心与通用修改器测试 @@ -248,13 +279,14 @@ research-wiki/ - 文件适配器、公共 CLI、配置文件、profile 或批处理协议; - 自动规则发现、注册表或默认流水线; - Markdown AST、完整 HTML parser 或必装的第三方运行依赖; -- artifact、报告、Web/桌面评审器; +- artifact、报告文件、JSON/HTML reporter、Web/桌面评审器或项目审核流程; - 任何业务项目的规则、固定参数、文档 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)。旧 design 只保存历史决策,不代表当前交付能力。 ## 当前可用检查 @@ -275,12 +307,12 @@ git status --short ``` 上述检查已于 2026-08-27 实际运行。Python 3.13.11 核心开发环境中 Ruff 和 mypy 通过,pytest 为 -`170 passed, 3 skipped`;三个 skip 是该环境没有安装的真实 optional backend 路径,不计入发布验收。 +`211 passed, 3 skipped`;三个 skip 是该环境没有安装的真实 optional backend 路径,不计入发布验收。 -从 `mdpolish-0.3.0-py3-none-any.whl` 安装全部 extras 后,Python 3.11.16 和 Python 3.13.11 环境分别得到 -`173 passed`,没有 skip。仓库外消费者 smoke test 已覆盖核心精确/正则规则、`pyspellchecker + Pyphen` 和 -`wordfreq` 的最终输出与版本审计。wheel 共 16 个文件,只包含通用 Python 包、类型标记和包元数据;不包含 tests、 -Wiki、真实数据或项目规则。`v0.3.0` Release wheel 的 SHA-256 是 -`a510ae1755c281f7b40262e242441882f3ccbc3fbc2174c6f5db3e8a8ae85060`。 +`mdpolish-0.4.0-py3-none-any.whl` 共 17 个文件,包含 `review.py` 和 `py.typed`,不包含 tests、Wiki、artifact、页面或 +真实数据。安装全部 extras 后,Python 3.11.16 和 Python 3.13.11 环境分别得到 `214 passed`,没有 skip;仓库外消费者 +smoke test 已覆盖核心正则、`pyspellchecker + Pyphen`、`wordfreq`、评审视图和 Markdown reporter。核心运行依赖仍只有 +Python 标准库。Release wheel 的 SHA-256 是 +`12e24863314958130ed082f78e89ab8bc0dad39a3848f2ae42e273d19a409693`。 上述结果证明当前版本可安装并按合成契约运行,不代表任意词典阈值已经在真实业务语料上达到生产准确率。 diff --git a/pyproject.toml b/pyproject.toml index 81fe85a..0813a72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mdpolish" -version = "0.3.0" +version = "0.4.0" description = "Deterministic functional core for composing exact Markdown modifiers" requires-python = ">=3.11" dependencies = [] diff --git a/research-wiki/design/0011-generic-review-projection-and-reporting.md b/research-wiki/design/0011-generic-review-projection-and-reporting.md new file mode 100644 index 0000000..69e2c4c --- /dev/null +++ b/research-wiki/design/0011-generic-review-projection-and-reporting.md @@ -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` 或输入输出持久化协议。 diff --git a/research-wiki/explanation/functional-modifier-core.md b/research-wiki/explanation/functional-modifier-core.md index f166b51..16bbd42 100644 --- a/research-wiki/explanation/functional-modifier-core.md +++ b/research-wiki/explanation/functional-modifier-core.md @@ -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) 为准。 diff --git a/research-wiki/explanation/review-projection.md b/research-wiki/explanation/review-projection.md new file mode 100644 index 0000000..f40a993 --- /dev/null +++ b/research-wiki/explanation/review-projection.md @@ -0,0 +1,87 @@ +# 通用内存评审视图怎样解释一次清洗结果 + +## 1. 为什么不能直接把所有修改画在最终全文上 + +`TransformResult.changes` 中的每条范围都属于对应修改器执行前的快照。前一个修改器插入或删除文本后,后一个修改器看到的 +位置已经不同;后续修改器也可能再次改写前一阶段生成的内容。因此,把所有 `Change.span` 直接当成最终全文坐标,会产生 +错误跳转和错误归属。 + +当前实现要求调用方同时提供原始 Markdown 和已有 `TransformResult`: + +```text +原始 Markdown + TransformResult + │ + ▼ + build_review_document() + │ 验证并重放,不运行 Modifier + ▼ + ReviewDocument + │ │ + │ └── render_markdown_report() ──► 内存 Markdown 字符串 + ▼ + 项目自己的界面或转换层 +``` + +文件读取、保存位置、HTML 页面、权限和审核流程仍由调用项目决定。 + +## 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,可能还原敏感内容。库不会自动打印、保存、上传或缓存 +报告;持久化后的路径、访问权限、脱敏和保留周期属于调用项目。每个阶段保存完整前后快照,当前实现优先保证可复核性, +没有声称适合无限长度文档或无限修改器链。 diff --git a/src/mdpolish/edits.py b/src/mdpolish/edits.py index 3e87d75..33943e5 100644 --- a/src/mdpolish/edits.py +++ b/src/mdpolish/edits.py @@ -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, diff --git a/src/mdpolish/models.py b/src/mdpolish/models.py index f63c4a4..30e71d3 100644 --- a/src/mdpolish/models.py +++ b/src/mdpolish/models.py @@ -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" diff --git a/src/mdpolish/pipeline.py b/src/mdpolish/pipeline.py index ee6e3ad..905872b 100644 --- a/src/mdpolish/pipeline.py +++ b/src/mdpolish/pipeline.py @@ -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", ) diff --git a/src/mdpolish/review.py b/src/mdpolish/review.py new file mode 100644 index 0000000..46b955d --- /dev/null +++ b/src/mdpolish/review.py @@ -0,0 +1,725 @@ +"""可信解释 TransformResult 的内存评审视图与 Markdown 报告。""" + +from __future__ import annotations + +from dataclasses import dataclass +from difflib import unified_diff +from enum import StrEnum +from itertools import pairwise +from json import dumps +from typing import Never + +from mdpolish.edits import _apply_validated_edits, _ordered_indexed_edits, validate_modifier_batch +from mdpolish.models import ( + Change, + DocumentSnapshot, + ErrorStage, + ModifierInfo, + ProposedChange, + ResidualProposal, + RunError, + RunStatus, + TextEdit, + TransformResult, + markdown_sha256, +) + + +class ReviewBuildError(ValueError): + """TransformResult 不能被安全地解释为评审视图。""" + + +class ReviewCurrentKind(StrEnum): + """评审视图中当前全文的结果性质。""" + + SUCCESS_OUTPUT = "success_output" + PARTIAL_OUTPUT = "partial_output" + + +@dataclass(frozen=True, slots=True) +class ReviewLocation: + """修改前快照中的 1-based Python 码点位置。""" + + line: int + column: int + + +@dataclass(frozen=True, slots=True) +class ReviewChange: + """一条已应用修改及其修改前快照位置。""" + + change: Change + location: ReviewLocation + + +@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, ...] + + +@dataclass(frozen=True, slots=True) +class ReviewDocument: + """从一个 TransformResult 可信重放得到的内存评审视图。""" + + 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, ...] + + +def _fail(contract: str, modifier_position: int | None = None) -> Never: + location = "" if modifier_position is None else f" at modifier position {modifier_position}" + raise ReviewBuildError(f"review build failed{location}: {contract}") + + +def _validate_modifier_infos(values: object) -> tuple[ModifierInfo, ...]: + if not isinstance(values, tuple): + _fail("modifiers must be a tuple") + for position, modifier in enumerate(values): + if not isinstance(modifier, ModifierInfo): + _fail("modifier metadata has an invalid type", position) + if ( + not isinstance(modifier.modifier_id, str) + or not modifier.modifier_id + or not isinstance(modifier.version, str) + or not modifier.version + or not isinstance(modifier.parameters, tuple) + or not isinstance(modifier.applicability, str) + or not modifier.applicability.strip() + ): + _fail("modifier metadata is malformed", position) + return values + + +def _validate_error(error: object) -> RunError: + if not isinstance(error, RunError): + _fail("errors must contain only RunError values") + if ( + not isinstance(error.modifier_id, str) + or not isinstance(error.modifier_version, str) + or type(error.modifier_position) is not int + or error.modifier_position < 0 + or not isinstance(error.stage, ErrorStage) + or not isinstance(error.error_type, str) + or not error.error_type + or not isinstance(error.message, str) + or not error.message + ): + _fail("an error entry is malformed") + return error + + +def _validate_error_identity(error: RunError, modifiers: tuple[ModifierInfo, ...]) -> None: + position = error.modifier_position + if position >= len(modifiers): + if error.stage is not ErrorStage.PREFLIGHT or position != len(modifiers): + _fail("error position is outside modifier metadata", position) + return + modifier = modifiers[position] + if error.modifier_id != modifier.modifier_id or error.modifier_version != modifier.version: + _fail("error identity does not match modifier metadata", position) + + +def _require_unique_modifier_ids(modifiers: tuple[ModifierInfo, ...], stop: int | None = None) -> None: + seen: set[str] = set() + for position, modifier in enumerate(modifiers[:stop]): + if modifier.modifier_id in seen: + _fail("completed preflight metadata contains a duplicate modifier_id", position) + seen.add(modifier.modifier_id) + + +def _completed_stage_count( + result: TransformResult, + modifiers: tuple[ModifierInfo, ...], + input_markdown: str, + current_markdown: str, +) -> tuple[int, bool]: + if not isinstance(result.errors, tuple): + _fail("errors must be a tuple") + errors = tuple(_validate_error(error) for error in result.errors) + for error in errors: + _validate_error_identity(error, modifiers) + + if result.status is RunStatus.SUCCESS: + if errors or result.residual_proposals or result.partial_markdown is not None: + _fail("success result fields are inconsistent") + _require_unique_modifier_ids(modifiers) + return len(modifiers), True + + if result.status is RunStatus.UNSTABLE: + if errors or not result.residual_proposals or result.output_markdown is not None: + _fail("unstable result fields are inconsistent") + _require_unique_modifier_ids(modifiers) + return len(modifiers), True + + if result.status is not RunStatus.FAILED: + _fail("status is not a RunStatus") + if not errors or result.output_markdown is not None: + _fail("failed result fields are inconsistent") + + stages = {error.stage for error in errors} + if len(stages) != 1: + _fail("failed result mixes error stages") + stage = next(iter(stages)) + + if stage is ErrorStage.PREFLIGHT: + if len(errors) != 1: + _fail("preflight failure must contain exactly one error") + error_position = errors[0].modifier_position + _require_unique_modifier_ids(modifiers, stop=min(error_position, len(modifiers))) + if result.changes or result.residual_proposals or current_markdown != input_markdown: + _fail("preflight failure contains transform output") + return 0, False + + _require_unique_modifier_ids(modifiers) + if stage is ErrorStage.TRANSFORM: + if len(errors) != 1: + _fail("transform failure must contain exactly one error") + error_position = errors[0].modifier_position + if error_position >= len(modifiers): + _fail("transform error position is outside modifiers", error_position) + if result.residual_proposals: + _fail("transform failure cannot contain residual proposals", error_position) + return error_position, False + + if stage is not ErrorStage.FINAL_REVIEW: + _fail("failed result contains an unsupported error stage") + positions = tuple(error.modifier_position for error in errors) + if any(position >= len(modifiers) for position in positions): + _fail("final review error position is outside modifiers") + if any(left >= right for left, right in pairwise(positions)): + _fail("final review errors are not in modifier order") + return len(modifiers), True + + +def _location(markdown: str, offset: int) -> ReviewLocation: + 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 ReviewLocation(line=line, column=offset - line_start + 1) + + +def _replay_stage( + snapshot: DocumentSnapshot, + modifier: ModifierInfo, + modifier_position: int, + changes: tuple[Change, ...], +) -> tuple[DocumentSnapshot, tuple[ReviewChange, ...]]: + if not changes: + return snapshot, () + + edits_by_proposal: dict[int, dict[int, TextEdit]] = {} + reasons_by_proposal: dict[int, str] = {} + actual_order: list[tuple[int, int]] = [] + review_changes: list[ReviewChange] = [] + after_sha256: str | None = None + + for change in changes: + if ( + change.modifier_position != modifier_position + or change.modifier_id != modifier.modifier_id + or change.modifier_version != modifier.version + ): + _fail("change identity does not match its stage", modifier_position) + if change.before_sha256 != snapshot.sha256: + _fail("change before_sha256 does not match the stage snapshot", modifier_position) + if after_sha256 is None: + after_sha256 = change.after_sha256 + elif change.after_sha256 != after_sha256: + _fail("change batch has inconsistent after_sha256 values", modifier_position) + + proposal_ref = change.proposal_ref + if ( + proposal_ref.modifier_position != modifier_position + or proposal_ref.snapshot_sha256 != snapshot.sha256 + ): + _fail("proposal reference does not match the stage snapshot", modifier_position) + if type(change.edit_index) is not int or change.edit_index < 0: + _fail("change edit_index is invalid", modifier_position) + + try: + edit = TextEdit( + snapshot_sha256=snapshot.sha256, + span=change.span, + expected_text=change.before, + replacement=change.after, + ) + except (TypeError, ValueError): + _fail("change cannot be reconstructed as a text edit", modifier_position) + + proposal_index = proposal_ref.proposal_index + proposal_edits = edits_by_proposal.setdefault(proposal_index, {}) + if change.edit_index in proposal_edits: + _fail("change batch repeats a proposal edit index", modifier_position) + proposal_edits[change.edit_index] = edit + previous_reason = reasons_by_proposal.setdefault(proposal_index, change.reason) + if previous_reason != change.reason: + _fail("one proposal has inconsistent reasons", modifier_position) + actual_order.append((proposal_index, change.edit_index)) + review_changes.append(ReviewChange(change=change, location=_location(snapshot.markdown, change.span.start))) + + proposal_indexes = sorted(edits_by_proposal) + if proposal_indexes != list(range(len(proposal_indexes))): + _fail("proposal indexes are not contiguous", modifier_position) + + proposals: list[ProposedChange] = [] + for proposal_index in proposal_indexes: + proposal_edits = edits_by_proposal[proposal_index] + edit_indexes = sorted(proposal_edits) + if edit_indexes != list(range(len(edit_indexes))): + _fail("edit indexes are not contiguous", modifier_position) + try: + proposals.append( + ProposedChange( + snapshot_sha256=snapshot.sha256, + reason=reasons_by_proposal[proposal_index], + edits=tuple(proposal_edits[index] for index in edit_indexes), + ) + ) + except (TypeError, ValueError): + _fail("stored proposal metadata is invalid", modifier_position) + + try: + indexed_edits = validate_modifier_batch(snapshot, tuple(proposals)) + except (TypeError, ValueError): + _fail("stored change batch does not satisfy the edit contract", modifier_position) + + expected_order = tuple( + (item.proposal_index, item.edit_index) for item in _ordered_indexed_edits(indexed_edits) + ) + if tuple(actual_order) != expected_order: + _fail("changes are not in canonical report order", modifier_position) + + updated_snapshot = _apply_validated_edits(snapshot, indexed_edits) + if updated_snapshot.sha256 != after_sha256: + _fail("replayed stage does not match after_sha256", modifier_position) + return updated_snapshot, tuple(review_changes) + + +def _validate_residual_proposals( + snapshot: DocumentSnapshot, + modifiers: tuple[ModifierInfo, ...], + errors: tuple[RunError, ...], + values: object, +) -> tuple[ResidualProposal, ...]: + if not isinstance(values, tuple): + _fail("residual_proposals must be a tuple") + + proposals_by_modifier: dict[int, list[ProposedChange]] = {} + previous_order: tuple[int, int] | None = None + error_positions = {error.modifier_position for error in errors if error.stage is ErrorStage.FINAL_REVIEW} + for residual in values: + if not isinstance(residual, ResidualProposal): + _fail("residual_proposals must contain only ResidualProposal values") + position = residual.modifier_position + if type(position) is not int or position < 0 or position >= len(modifiers): + _fail("residual proposal position is outside modifiers") + modifier = modifiers[position] + if residual.modifier_id != modifier.modifier_id or residual.modifier_version != modifier.version: + _fail("residual proposal identity does not match modifier metadata", position) + if position in error_positions: + _fail("one modifier has both a final review error and residual proposals", position) + + proposal_ref = residual.proposal_ref + proposal = residual.proposal + if not isinstance(proposal, ProposedChange): + _fail("residual proposal payload has an invalid type", position) + if ( + proposal_ref.modifier_position != position + or proposal_ref.snapshot_sha256 != snapshot.sha256 + or proposal.snapshot_sha256 != snapshot.sha256 + ): + _fail("residual proposal does not target the current snapshot", position) + + order = (position, proposal_ref.proposal_index) + if previous_order is not None and order <= previous_order: + _fail("residual proposals are not in deterministic order", position) + previous_order = order + proposals_by_modifier.setdefault(position, []).append(proposal) + + for position, proposals in proposals_by_modifier.items(): + proposal_indexes = tuple( + residual.proposal_ref.proposal_index + for residual in values + if residual.modifier_position == position + ) + if proposal_indexes != tuple(range(len(proposals))): + _fail("residual proposal indexes are not contiguous", position) + try: + validate_modifier_batch(snapshot, tuple(proposals)) + except (TypeError, ValueError): + _fail("residual proposals do not satisfy the edit contract", position) + return values + + +def _build_review_document(input_markdown: str, result: TransformResult) -> ReviewDocument: + if markdown_sha256(input_markdown) != result.input_sha256: + _fail("input Markdown does not match input_sha256") + + if result.status is RunStatus.SUCCESS: + current_markdown = result.output_markdown + current_kind = ReviewCurrentKind.SUCCESS_OUTPUT + elif result.status in (RunStatus.FAILED, RunStatus.UNSTABLE): + current_markdown = result.partial_markdown + current_kind = ReviewCurrentKind.PARTIAL_OUTPUT + else: + _fail("status is not a RunStatus") + if not isinstance(current_markdown, str) or markdown_sha256(current_markdown) != result.current_sha256: + _fail("current Markdown does not match current_sha256") + + modifiers = _validate_modifier_infos(result.modifiers) + if not isinstance(result.changes, tuple): + _fail("changes must be a tuple") + if not isinstance(result.residual_proposals, tuple): + _fail("residual_proposals must be a tuple") + completed_count, stages_complete = _completed_stage_count( + result, + modifiers, + input_markdown, + current_markdown, + ) + + changes_by_position: list[list[Change]] = [[] for _ in range(completed_count)] + previous_position = -1 + for value in result.changes: + if not isinstance(value, Change): + _fail("changes must contain only Change values") + position = value.modifier_position + if type(position) is not int or position < 0 or position < previous_position: + _fail("changes are not in modifier order") + if position >= completed_count: + _fail("change belongs to an incomplete modifier stage", position) + previous_position = position + changes_by_position[position].append(value) + + snapshot = DocumentSnapshot(input_markdown) + stages: list[ReviewStage] = [] + for position in range(completed_count): + modifier = modifiers[position] + before_snapshot = snapshot + snapshot, stage_changes = _replay_stage( + snapshot, + modifier, + position, + tuple(changes_by_position[position]), + ) + stages.append( + ReviewStage( + modifier_position=position, + modifier=modifier, + before_sha256=before_snapshot.sha256, + after_sha256=snapshot.sha256, + before_markdown=before_snapshot.markdown, + after_markdown=snapshot.markdown, + changes=stage_changes, + ) + ) + + if snapshot.markdown != current_markdown or snapshot.sha256 != result.current_sha256: + _fail("replayed transform stages do not match the current result") + + errors = tuple(_validate_error(error) for error in result.errors) + residual_proposals = _validate_residual_proposals( + snapshot, + modifiers, + errors, + result.residual_proposals, + ) + return ReviewDocument( + status=result.status, + current_kind=current_kind, + input_sha256=result.input_sha256, + current_sha256=result.current_sha256, + input_markdown=input_markdown, + current_markdown=current_markdown, + modifiers=modifiers, + stages=tuple(stages), + stages_complete=stages_complete, + errors=errors, + residual_proposals=residual_proposals, + ) + + +def build_review_document(input_markdown: str, result: TransformResult) -> ReviewDocument: + """验证并重放一个 TransformResult, 不重新运行任何修改器。""" + if not isinstance(input_markdown, str): + _fail("input_markdown must be a string") + if not isinstance(result, TransformResult): + _fail("result must be a TransformResult") + try: + return _build_review_document(input_markdown, result) + except ReviewBuildError: + raise + except Exception: + raise ReviewBuildError("review build failed: result structure is malformed") from None + + +def _maximum_run(text: str, character: str) -> int: + longest = 0 + current = 0 + for value in text: + if value == character: + current += 1 + longest = max(longest, current) + else: + current = 0 + return longest + + +def _fenced_block(text: str, language: str) -> str: + fence = "`" * max(3, _maximum_run(text, "`") + 1) + separator = "" if not text or text.endswith(("\n", "\r")) else "\n" + return f"{fence}{language}\n{text}{separator}{fence}" + + +def _inline_code(value: object) -> str: + text = str(value) + fence = "`" * max(1, _maximum_run(text, "`") + 1) + padding = " " if text.startswith(("`", " ")) or text.endswith(("`", " ")) else "" + return f"{fence}{padding}{text}{padding}{fence}" + + +def _quoted(value: str) -> str: + return _inline_code(dumps(value, ensure_ascii=False)) + + +def _ending_name(markdown: str) -> str: + if not markdown: + return "empty" + if markdown.endswith("\r\n"): + return "CRLF" + if markdown.endswith("\n"): + return "LF" + if markdown.endswith("\r"): + return "CR" + return "none" + + +def _diff_record(record: str, header: bool) -> str: + if header: + return record + if record.endswith("\r\n"): + return f"{record[:-2]} ⟦CRLF⟧" + if record.endswith("\n"): + return f"{record[:-1]} ⟦LF⟧" + if record.endswith("\r"): + return f"{record[:-1]} ⟦CR⟧" + return f"{record} ⟦NO EOL⟧" + + +def _render_unified_diff(review: ReviewDocument) -> str: + if review.status is RunStatus.SUCCESS: + tofile = "b/output.md" + elif review.status is RunStatus.UNSTABLE: + tofile = "b/unstable.partial.md" + else: + tofile = "b/failed.partial.md" + records = tuple( + unified_diff( + review.input_markdown.splitlines(keepends=True), + review.current_markdown.splitlines(keepends=True), + fromfile="a/input.md", + tofile=tofile, + lineterm="", + ) + ) + if not records: + return f"--- a/input.md\n+++ {tofile}\n(no textual difference)" + rendered = [ + _diff_record(record, header=index < 2 or record.startswith("@@")) + for index, record in enumerate(records) + ] + return "\n".join(rendered) + + +def _append_source_section(parts: list[str], heading: str, markdown: str) -> None: + parts.extend( + ( + heading, + "", + f"- Python code points: {_inline_code(len(markdown))}", + f"- Final line ending: {_inline_code(_ending_name(markdown))}", + "", + _fenced_block(markdown, "markdown"), + "", + ) + ) + + +def render_markdown_report(review: ReviewDocument, *, residual_limit: int = 20) -> str: + """把评审视图确定地编排成 Markdown 源码字符串。""" + if not isinstance(review, ReviewDocument): + raise TypeError("review must be a ReviewDocument") + if type(residual_limit) is not int: + raise TypeError("residual_limit must be an integer") + if residual_limit < 0: + raise ValueError("residual_limit must be non-negative") + + total_changes = sum(len(stage.changes) for stage in review.stages) + parts = [ + "# mdpolish review report", + "", + f"- status: {_inline_code(review.status.value)}", + f"- current_kind: {_inline_code(review.current_kind.value)}", + f"- stages_complete: {_inline_code(str(review.stages_complete).lower())}", + f"- input_sha256: {_inline_code(review.input_sha256)}", + f"- current_sha256: {_inline_code(review.current_sha256)}", + f"- completed_stages: {_inline_code(len(review.stages))}", + f"- actual_changes: {_inline_code(total_changes)}", + "", + "## Modifiers", + "", + ] + + if review.modifiers: + parts.extend( + ( + "| position | modifier | version | transform stage | changes |", + "| ---: | --- | --- | --- | ---: |", + ) + ) + stages_by_position = {stage.modifier_position: stage for stage in review.stages} + for position, modifier in enumerate(review.modifiers): + stage = stages_by_position.get(position) + stage_status = "completed" if stage is not None else "not completed" + change_count = len(stage.changes) if stage is not None else 0 + parts.append( + f"| {position} | {_inline_code(modifier.modifier_id)} | {_inline_code(modifier.version)} " + f"| {stage_status} | {change_count} |" + ) + else: + parts.append("No modifier metadata was recorded.") + parts.append("") + + _append_source_section(parts, "## Input Markdown", review.input_markdown) + current_label = "successful output" if review.current_kind is ReviewCurrentKind.SUCCESS_OUTPUT else "partial output" + _append_source_section(parts, f"## Current Markdown ({current_label})", review.current_markdown) + + parts.extend(("## Unified diff", "", _fenced_block(_render_unified_diff(review), "diff"), "")) + parts.extend(("## Completed transform stages", "")) + if not review.stages: + parts.extend(("No transform stage can be proven complete.", "")) + for stage in review.stages: + parts.extend( + ( + f"### Stage {stage.modifier_position}: {_inline_code(stage.modifier.modifier_id)}", + "", + f"- version: {_inline_code(stage.modifier.version)}", + f"- before_sha256: {_inline_code(stage.before_sha256)}", + f"- after_sha256: {_inline_code(stage.after_sha256)}", + f"- changes: {_inline_code(len(stage.changes))}", + "", + ) + ) + if not stage.changes: + parts.extend(("This modifier completed without applying a change.", "")) + for change_index, review_change in enumerate(stage.changes, start=1): + change = review_change.change + parts.extend( + ( + f"#### Change {change_index}", + "", + f"- proposal_index: {_inline_code(change.proposal_ref.proposal_index)}", + f"- edit_index: {_inline_code(change.edit_index)}", + f"- reason: {_quoted(change.reason)}", + f"- location: line {_inline_code(review_change.location.line)}, " + f"column {_inline_code(review_change.location.column)}", + f"- span: [{_inline_code(change.span.start)}, {_inline_code(change.span.end)})", + "", + "##### Before", + "", + _fenced_block(change.before, "text"), + "", + "##### After", + "", + _fenced_block(change.after, "text"), + "", + ) + ) + + parts.extend(("## Errors", "")) + if not review.errors: + parts.extend(("No errors.", "")) + else: + for error in review.errors: + parts.extend( + ( + f"- position {_inline_code(error.modifier_position)}, modifier {_inline_code(error.modifier_id)}, " + f"stage {_inline_code(error.stage.value)}, type {_inline_code(error.error_type)}: " + f"{_quoted(error.message)}", + ) + ) + parts.append("") + + residuals = review.residual_proposals + parts.extend(("## Residual proposals", "", f"- total: {_inline_code(len(residuals))}", "")) + for residual_index, residual in enumerate(residuals[:residual_limit], start=1): + proposal = residual.proposal + parts.extend( + ( + f"### Residual {residual_index}", + "", + f"- modifier: {_inline_code(residual.modifier_id)}", + f"- version: {_inline_code(residual.modifier_version)}", + f"- modifier_position: {_inline_code(residual.modifier_position)}", + f"- proposal_index: {_inline_code(residual.proposal_ref.proposal_index)}", + f"- reason: {_quoted(proposal.reason)}", + f"- edits: {_inline_code(len(proposal.edits))}", + "", + ) + ) + for edit_index, edit in enumerate(proposal.edits): + parts.append( + f" - edit {_inline_code(edit_index)}: span [{_inline_code(edit.span.start)}, " + f"{_inline_code(edit.span.end)}), expected length {_inline_code(len(edit.expected_text))}, " + f"replacement length {_inline_code(len(edit.replacement))}" + ) + parts.append("") + omitted = len(residuals) - min(len(residuals), residual_limit) + if omitted: + parts.extend((f"Omitted residual proposals: {_inline_code(omitted)}.", "")) + + return "\n".join(parts) + + +__all__ = [ + "ReviewBuildError", + "ReviewChange", + "ReviewCurrentKind", + "ReviewDocument", + "ReviewLocation", + "ReviewStage", + "build_review_document", + "render_markdown_report", +] diff --git a/tests/test_mapped_line_join.py b/tests/test_mapped_line_join.py index dca8af4..037db9d 100644 --- a/tests/test_mapped_line_join.py +++ b/tests/test_mapped_line_join.py @@ -362,7 +362,7 @@ def test_empty_document_and_empty_rule_set_are_no_ops() -> None: def test_installed_package_version_matches_delivery_candidate() -> None: - assert distribution_version("mdpolish") == "0.3.0" + assert distribution_version("mdpolish") == "0.4.0" def test_parameters_preserve_rule_order_and_record_all_options() -> None: diff --git a/tests/test_models.py b/tests/test_models.py index 96d14af..17a8ae0 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -146,3 +146,9 @@ def test_transform_result_enforces_status_specific_output_fields() -> None: current_sha256=snapshot.sha256, partial_markdown=snapshot.markdown, ) + + +def test_error_stage_distinguishes_preflight_from_modifier_execution() -> None: + assert ErrorStage.PREFLIGHT.value == "preflight" + assert ErrorStage.TRANSFORM.value == "transform" + assert ErrorStage.FINAL_REVIEW.value == "final_review" diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 0fcfb91..7dba0af 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -219,6 +219,7 @@ def test_duplicate_modifier_ids_fail_during_preflight_before_modification() -> N assert result.partial_markdown == "a" assert result.changes == () assert result.errors[0].error_type == "PipelineContractError" + assert result.errors[0].stage is ErrorStage.PREFLIGHT def test_non_modifier_entry_fails_during_preflight() -> None: @@ -229,6 +230,7 @@ def test_non_modifier_entry_fails_during_preflight() -> None: assert result.status is RunStatus.FAILED assert result.partial_markdown == "a" assert result.errors[0].error_type == "ModifierContractError" + assert result.errors[0].stage is ErrorStage.PREFLIGHT def test_metadata_changed_by_proposal_function_fails_before_application() -> None: diff --git a/tests/test_review.py b/tests/test_review.py new file mode 100644 index 0000000..baa1197 --- /dev/null +++ b/tests/test_review.py @@ -0,0 +1,720 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import replace + +import pytest + +import mdpolish.pipeline as pipeline_module +from mdpolish import ( + AppliedBatch, + Change, + DocumentSnapshot, + ErrorStage, + Modifier, + ModifierInfo, + Pipeline, + ProposedChange, + RunError, + RunStatus, + TextEdit, + TextSpan, + TransformResult, + apply_modifier_batch, + markdown_sha256, +) +from mdpolish.review import ( + ReviewBuildError, + ReviewCurrentKind, + build_review_document, + render_markdown_report, +) + + +def replace_first( + needle: str, + replacement: str, + *, + modifier_id: str, + reason: str | None = None, +) -> Modifier: + def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + position = snapshot.markdown.find(needle) + if position < 0: + return () + edit = TextEdit( + snapshot_sha256=snapshot.sha256, + span=TextSpan(position, position + len(needle)), + expected_text=needle, + replacement=replacement, + ) + return ( + ProposedChange( + snapshot_sha256=snapshot.sha256, + reason=reason or f"replace token for {modifier_id}", + edits=(edit,), + ), + ) + + return Modifier( + modifier_id=modifier_id, + version="1.0.0", + parameters={"needle": needle, "replacement": replacement}, + applicability="只处理虚构测试标记。", + propose=propose, + ) + + +def empty_modifier(modifier_id: str) -> Modifier: + return Modifier( + modifier_id=modifier_id, + version="1.0.0", + parameters=(), + applicability="测试零修改阶段。", + propose=lambda snapshot: (), + ) + + +def exploding_modifier(modifier_id: str, *, trigger: str | None = None) -> Modifier: + def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + if trigger is None or snapshot.markdown == trigger: + raise RuntimeError(f"secret failure: {snapshot.markdown}") + return () + + return Modifier( + modifier_id=modifier_id, + version="1.0.0", + parameters={"trigger": trigger}, + applicability="只测试失败路径。", + propose=propose, + ) + + +def stale_modifier(modifier_id: str = "test.stale") -> Modifier: + def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + stale = DocumentSnapshot(snapshot.markdown + "!") + edit = TextEdit(stale.sha256, TextSpan(0, 1), stale.markdown[0], "X") + return (ProposedChange(stale.sha256, "stale proposal", (edit,)),) + + return Modifier( + modifier_id=modifier_id, + version="1.0.0", + parameters=(), + applicability="只测试批次验证失败。", + propose=propose, + ) + + +def final_review_failure_result(*, error_count: int = 1) -> TransformResult: + modifiers = [ + exploding_modifier(f"test.review-error-{index}", trigger="done") for index in range(error_count) + ] + modifiers.append(replace_first("start", "done", modifier_id="test.producer")) + return Pipeline(modifiers).transform("start") + + +def residual_result() -> TransformResult: + def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + proposals: list[ProposedChange] = [] + for position, character in enumerate(snapshot.markdown): + if character != "x": + continue + edit = TextEdit( + snapshot_sha256=snapshot.sha256, + span=TextSpan(position, position + 1), + expected_text="x", + replacement="DO-NOT-PRINT-RESIDUAL-TEXT", + ) + proposals.append(ProposedChange(snapshot.sha256, "residual candidate", (edit,))) + return tuple(proposals) + + residual = Modifier( + modifier_id="test.residual", + version="1.0.0", + parameters=(), + applicability="只测试残留候选。", + propose=propose, + ) + producer = replace_first("a", "xxx", modifier_id="test.producer") + return Pipeline((residual, producer)).transform("a") + + +def test_empty_pipeline_builds_complete_unchanged_review() -> None: + result = Pipeline(()).transform("") + + review = build_review_document("", result) + + assert review.status is RunStatus.SUCCESS + assert review.current_kind is ReviewCurrentKind.SUCCESS_OUTPUT + assert review.input_markdown == review.current_markdown == "" + assert review.stages == () + assert review.stages_complete is True + + +def test_replay_preserves_zero_stages_and_exact_intermediate_snapshots() -> None: + pipeline = Pipeline( + ( + empty_modifier("test.zero-first"), + replace_first("a", "b", modifier_id="test.first"), + replace_first("b", "c", modifier_id="test.second"), + empty_modifier("test.zero-last"), + ) + ) + result = pipeline.transform("a") + + review = build_review_document("a", result) + + assert [stage.modifier_position for stage in review.stages] == [0, 1, 2, 3] + assert [(stage.before_markdown, stage.after_markdown) for stage in review.stages] == [ + ("a", "a"), + ("a", "b"), + ("b", "c"), + ("c", "c"), + ] + assert [len(stage.changes) for stage in review.stages] == [0, 1, 1, 0] + assert [stage.before_sha256 for stage in review.stages[1:]] == [ + result.changes[0].before_sha256, + result.changes[1].before_sha256, + result.changes[1].after_sha256, + ] + assert review.current_markdown == "c" + assert review.current_sha256 == markdown_sha256("c") + + +@pytest.mark.parametrize( + ("markdown", "start", "end", "replacement", "expected"), + [ + ("ab", 1, 1, "X", "aXb"), + ("abc", 1, 2, "", "ac"), + ("abc", 1, 2, "X", "aXc"), + ], +) +def test_replay_covers_insert_delete_and_replace( + markdown: str, + start: int, + end: int, + replacement: str, + expected: str, +) -> None: + def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + if snapshot.markdown != markdown: + return () + edit = TextEdit( + snapshot.sha256, + TextSpan(start, end), + snapshot.markdown[start:end], + replacement, + ) + return (ProposedChange(snapshot.sha256, "one exact edit", (edit,)),) + + modifier = Modifier( + modifier_id="test.edit-kind", + version="1.0.0", + parameters=(), + applicability="只测试插入、删除和替换。", + propose=propose, + ) + + review = build_review_document(markdown, Pipeline((modifier,)).transform(markdown)) + + assert review.stages[0].after_markdown == expected + assert review.current_markdown == expected + + +def test_one_proposal_with_multiple_edits_keeps_references_and_core_order() -> None: + def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + if snapshot.markdown != "abcdef": + return () + edits = ( + TextEdit(snapshot.sha256, TextSpan(4, 6), "ef", "E"), + TextEdit(snapshot.sha256, TextSpan(0, 1), "a", "A"), + TextEdit(snapshot.sha256, TextSpan(2, 3), "c", ""), + ) + return (ProposedChange(snapshot.sha256, "three exact edits", edits),) + + modifier = Modifier( + modifier_id="test.multi-edit", + version="1.0.0", + parameters=(), + applicability="只测试一个候选中的多个编辑。", + propose=propose, + ) + result = Pipeline((modifier,)).transform("abcdef") + + review = build_review_document("abcdef", result) + stage = review.stages[0] + + assert stage.after_markdown == "AbdE" + assert [change.change.span.start for change in stage.changes] == [0, 2, 4] + assert [change.change.edit_index for change in stage.changes] == [1, 2, 0] + assert {change.change.proposal_ref for change in stage.changes} == { + stage.changes[0].change.proposal_ref, + } + + +def test_locations_use_python_code_points_and_physical_newlines() -> None: + markdown = "\ufeffA\r\n中e\u0301🙂X\rY\nZ" + positions = (1, 6, 8, 10, 12) + + def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + if snapshot.markdown != markdown: + return () + edits = tuple( + TextEdit( + snapshot.sha256, + TextSpan(position, position + 1), + snapshot.markdown[position], + f"<{position}>", + ) + for position in positions + ) + return (ProposedChange(snapshot.sha256, "unicode locations", edits),) + + modifier = Modifier( + modifier_id="test.locations", + version="1.0.0", + parameters=(), + applicability="只测试 Python 码点位置。", + propose=propose, + ) + review = build_review_document(markdown, Pipeline((modifier,)).transform(markdown)) + + assert [(change.location.line, change.location.column) for change in review.stages[0].changes] == [ + (1, 2), + (2, 3), + (2, 5), + (3, 1), + (4, 1), + ] + + +def test_preflight_failure_has_no_stage_and_unchanged_partial_text() -> None: + duplicate = replace_first("a", "b", modifier_id="test.duplicate") + result = Pipeline((duplicate, duplicate)).transform("a") + + review = build_review_document("a", result) + + assert result.errors[0].stage is ErrorStage.PREFLIGHT + assert review.current_markdown == "a" + assert review.stages == () + assert review.stages_complete is False + + +def _assert_only_first_stage_completed(result: TransformResult) -> None: + review = build_review_document("a", result) + assert result.status is RunStatus.FAILED + assert result.errors[0].stage is ErrorStage.TRANSFORM + assert review.current_markdown == "b" + assert [stage.modifier_position for stage in review.stages] == [0] + assert review.stages[0].after_markdown == "b" + assert review.stages_complete is False + + +def test_propose_failure_stops_before_failing_stage() -> None: + result = Pipeline( + ( + replace_first("a", "b", modifier_id="test.first"), + exploding_modifier("test.exploding"), + replace_first("b", "c", modifier_id="test.never"), + ) + ).transform("a") + + _assert_only_first_stage_completed(result) + + +def test_metadata_change_after_proposal_stops_before_failing_stage() -> None: + mutating: Modifier + + def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + object.__setattr__(mutating, "version", "2.0.0") + edit = TextEdit(snapshot.sha256, TextSpan(0, 1), "b", "B") + return (ProposedChange(snapshot.sha256, "mutate metadata", (edit,)),) + + mutating = Modifier( + modifier_id="test.mutating", + version="1.0.0", + parameters=(), + applicability="只测试运行期元数据变化。", + propose=propose, + ) + result = Pipeline( + (replace_first("a", "b", modifier_id="test.first"), mutating, empty_modifier("test.never")) + ).transform("a") + + _assert_only_first_stage_completed(result) + + +def test_batch_validation_failure_does_not_create_a_zero_change_stage() -> None: + result = Pipeline( + (replace_first("a", "b", modifier_id="test.first"), stale_modifier(), empty_modifier("test.never")) + ).transform("a") + + _assert_only_first_stage_completed(result) + assert result.errors[0].error_type == "EditValidationError" + + +def test_batch_application_failure_does_not_create_a_stage(monkeypatch: pytest.MonkeyPatch) -> None: + calls = 0 + + def fail_second_application( + snapshot: DocumentSnapshot, + proposals: tuple[ProposedChange, ...], + modifier: ModifierInfo, + modifier_position: int, + ) -> AppliedBatch: + nonlocal calls + calls += 1 + if calls == 2: + raise RuntimeError("application failure") + return apply_modifier_batch(snapshot, proposals, modifier, modifier_position) + + monkeypatch.setattr(pipeline_module, "apply_modifier_batch", fail_second_application) + result = Pipeline( + ( + replace_first("a", "b", modifier_id="test.first"), + replace_first("b", "c", modifier_id="test.failing"), + empty_modifier("test.never"), + ) + ).transform("a") + + _assert_only_first_stage_completed(result) + assert result.errors[0].error_type == "RuntimeError" + + +def test_final_review_failures_keep_all_transform_stages_complete() -> None: + result = final_review_failure_result(error_count=2) + + review = build_review_document("start", result) + + assert result.status is RunStatus.FAILED + assert [error.stage for error in result.errors] == [ErrorStage.FINAL_REVIEW, ErrorStage.FINAL_REVIEW] + assert len(review.stages) == 3 + assert review.stages[-1].after_markdown == "done" + assert review.stages_complete is True + + +def test_unstable_result_keeps_complete_stages_and_unapplied_residuals() -> None: + result = residual_result() + + review = build_review_document("a", result) + + assert result.status is RunStatus.UNSTABLE + assert review.current_kind is ReviewCurrentKind.PARTIAL_OUTPUT + assert review.current_markdown == "xxx" + assert review.stages_complete is True + assert len(review.residual_proposals) == 3 + assert "DO-NOT-PRINT" not in review.current_markdown + + +def test_builder_does_not_run_modifiers_again() -> None: + calls = 0 + + def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + nonlocal calls + calls += 1 + return () + + modifier = Modifier( + modifier_id="test.call-count", + version="1.0.0", + parameters=(), + applicability="只测试调用次数。", + propose=propose, + ) + result = Pipeline((modifier,)).transform("a") + calls_after_pipeline = calls + + review = build_review_document("a", result) + render_markdown_report(review) + + assert calls_after_pipeline == 2 + assert calls == calls_after_pipeline + + +def test_builder_rejects_input_and_replay_mismatches_without_source_text() -> None: + result = Pipeline((replace_first("secret", "public", modifier_id="test.replace"),)).transform("secret") + + with pytest.raises(ReviewBuildError) as input_error: + build_review_document("different secret", result) + assert "secret" not in str(input_error.value) + + mismatched = replace( + result, + current_sha256=markdown_sha256("unrelated"), + output_markdown="unrelated", + ) + with pytest.raises(ReviewBuildError) as replay_error: + build_review_document("secret", mismatched) + assert "secret" not in str(replay_error.value) + assert "public" not in str(replay_error.value) + + +def test_builder_rejects_mixed_error_stages() -> None: + result = final_review_failure_result() + transform_error = RunError( + modifier_id=result.modifiers[0].modifier_id, + modifier_version=result.modifiers[0].version, + modifier_position=0, + stage=ErrorStage.TRANSFORM, + error_type="ExampleError", + message="safe", + ) + malformed = replace(result, errors=(*result.errors, transform_error)) + + with pytest.raises(ReviewBuildError, match="mixes error stages"): + build_review_document("start", malformed) + + +def test_builder_rejects_preflight_result_with_transform_output() -> None: + duplicate = replace_first("a", "b", modifier_id="test.duplicate") + preflight = Pipeline((duplicate, duplicate)).transform("a") + successful = Pipeline((replace_first("a", "b", modifier_id="test.success"),)).transform("a") + malformed = replace( + preflight, + current_sha256=markdown_sha256("b"), + changes=successful.changes, + partial_markdown="b", + ) + + with pytest.raises(ReviewBuildError, match="preflight failure contains transform output"): + build_review_document("a", malformed) + + +def test_builder_rejects_change_at_transform_failure_position() -> None: + successful = Pipeline((replace_first("a", "b", modifier_id="test.replace"),)).transform("a") + error = RunError( + modifier_id=successful.modifiers[0].modifier_id, + modifier_version=successful.modifiers[0].version, + modifier_position=0, + stage=ErrorStage.TRANSFORM, + error_type="ExampleError", + message="safe", + ) + malformed = TransformResult( + status=RunStatus.FAILED, + input_sha256=successful.input_sha256, + current_sha256=successful.current_sha256, + modifiers=successful.modifiers, + changes=successful.changes, + errors=(error,), + partial_markdown=successful.output_markdown, + ) + + with pytest.raises(ReviewBuildError, match="incomplete modifier stage"): + build_review_document("a", malformed) + + +@pytest.mark.parametrize( + "tamper", + [ + lambda change: replace(change, modifier_id="test.wrong"), + lambda change: replace(change, before="z"), + lambda change: replace(change, span=TextSpan(1, 2), before="z"), + lambda change: replace(change, after_sha256=change.before_sha256), + lambda change: replace( + change, + proposal_ref=replace(change.proposal_ref, snapshot_sha256=change.after_sha256), + ), + lambda change: replace( + change, + proposal_ref=replace(change.proposal_ref, proposal_index=1), + ), + ], +) +def test_builder_rejects_tampered_change_contract(tamper: Callable[[Change], Change]) -> None: + result = Pipeline((replace_first("a", "b", modifier_id="test.replace"),)).transform("a") + change = result.changes[0] + tampered_change = tamper(change) + malformed = replace(result, changes=(tampered_change,)) + + with pytest.raises(ReviewBuildError): + build_review_document("a", malformed) + + +def test_builder_rejects_duplicate_and_conflicting_changes() -> None: + one_change = Pipeline((replace_first("a", "b", modifier_id="test.replace"),)).transform("a") + duplicate = replace(one_change, changes=(one_change.changes[0], one_change.changes[0])) + with pytest.raises(ReviewBuildError, match="repeats a proposal edit index"): + build_review_document("a", duplicate) + + def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + if snapshot.markdown != "abc": + return () + edits = ( + TextEdit(snapshot.sha256, TextSpan(0, 1), "a", "A"), + TextEdit(snapshot.sha256, TextSpan(2, 3), "c", "C"), + ) + return (ProposedChange(snapshot.sha256, "two edits", edits),) + + modifier = Modifier( + modifier_id="test.conflict", + version="1.0.0", + parameters=(), + applicability="只测试篡改后的冲突范围。", + propose=propose, + ) + result = Pipeline((modifier,)).transform("abc") + conflicting_second = replace(result.changes[1], span=TextSpan(0, 2), before="ab", after="X") + conflicting = replace(result, changes=(result.changes[0], conflicting_second)) + + with pytest.raises(ReviewBuildError, match="edit contract"): + build_review_document("abc", conflicting) + + +def test_builder_rejects_noncanonical_change_order() -> None: + def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + if snapshot.markdown != "abc": + return () + edits = ( + TextEdit(snapshot.sha256, TextSpan(0, 1), "a", "A"), + TextEdit(snapshot.sha256, TextSpan(2, 3), "c", "C"), + ) + return (ProposedChange(snapshot.sha256, "two edits", edits),) + + modifier = Modifier( + modifier_id="test.order", + version="1.0.0", + parameters=(), + applicability="只测试报告顺序。", + propose=propose, + ) + result = Pipeline((modifier,)).transform("abc") + malformed = replace(result, changes=tuple(reversed(result.changes))) + + with pytest.raises(ReviewBuildError, match="canonical report order"): + build_review_document("abc", malformed) + + +def test_builder_rejects_tampered_residual_snapshot_and_error_position() -> None: + unstable = residual_result() + first_residual = unstable.residual_proposals[0] + stale_ref = replace(first_residual.proposal_ref, snapshot_sha256=unstable.input_sha256) + malformed_residual = replace( + unstable, + residual_proposals=(replace(first_residual, proposal_ref=stale_ref), *unstable.residual_proposals[1:]), + ) + with pytest.raises(ReviewBuildError, match="current snapshot"): + build_review_document("a", malformed_residual) + + failed = final_review_failure_result() + out_of_range_error = replace( + failed.errors[0], + modifier_id="", + modifier_version="", + modifier_position=len(failed.modifiers), + ) + malformed_error = replace(failed, errors=(out_of_range_error,)) + with pytest.raises(ReviewBuildError, match="outside modifier metadata"): + build_review_document("start", malformed_error) + + +def test_report_contains_complete_sources_stages_and_success_diff_label() -> None: + markdown = "before\n```md\n
x
\n[link](https://example.test)\n![img](image.png)" + result = Pipeline( + (replace_first("before", "after", modifier_id="test.report", reason="render exact source"),) + ).transform(markdown) + review = build_review_document(markdown, result) + + report = render_markdown_report(review) + + assert "status: `success`" in report + assert "current_kind: `success_output`" in report + assert "--- a/input.md" in report + assert "+++ b/output.md" in report + assert "````markdown" in report + assert markdown in report + assert review.current_markdown in report + assert "render exact source" in report + assert "location: line `1`, column `1`" in report + assert "
x
" in report + + +@pytest.mark.parametrize( + ("result", "input_markdown", "expected_label", "expected_status"), + [ + ( + Pipeline( + ( + replace_first("a", "b", modifier_id="test.first"), + exploding_modifier("test.error"), + ) + ).transform("a"), + "a", + "+++ b/failed.partial.md", + "status: `failed`", + ), + (residual_result(), "a", "+++ b/unstable.partial.md", "status: `unstable`"), + ], +) +def test_partial_report_labels_cannot_be_mistaken_for_success( + result: TransformResult, + input_markdown: str, + expected_label: str, + expected_status: str, +) -> None: + report = render_markdown_report(build_review_document(input_markdown, result)) + + assert expected_label in report + assert expected_status in report + assert "current_kind: `partial_output`" in report + + +def test_unchanged_failed_diff_still_has_partial_label() -> None: + duplicate = replace_first("a", "b", modifier_id="test.duplicate") + review = build_review_document("a", Pipeline((duplicate, duplicate)).transform("a")) + + report = render_markdown_report(review) + + assert "+++ b/failed.partial.md" in report + assert "(no textual difference)" in report + + +def test_diff_marks_physical_line_endings_and_missing_final_newline() -> None: + result = Pipeline((replace_first("a", "A", modifier_id="test.replace"),)).transform("a\r\nb") + report = render_markdown_report(build_review_document("a\r\nb", result)) + + assert "-a ⟦CRLF⟧" in report + assert "+A ⟦CRLF⟧" in report + assert " b ⟦NO EOL⟧" in report + assert "Final line ending: `none`" in report + + +def test_report_limits_residuals_without_printing_replacement_text() -> None: + review = build_review_document("a", residual_result()) + + report = render_markdown_report(review, residual_limit=1) + count_only = render_markdown_report(review, residual_limit=0) + + assert "- total: `3`" in report + assert report.count("### Residual ") == 1 + assert "Omitted residual proposals: `2`." in report + assert "expected length `1`" in report + assert "replacement length `26`" in report + assert "DO-NOT-PRINT-RESIDUAL-TEXT" not in report + assert "### Residual 1" not in count_only + assert "Omitted residual proposals: `3`." in count_only + + +@pytest.mark.parametrize("value", [True, 1.5, "1", None]) +def test_report_rejects_non_integer_residual_limit(value: object) -> None: + review = build_review_document("", Pipeline(()).transform("")) + + with pytest.raises(TypeError, match="integer"): + render_markdown_report(review, residual_limit=value) # type: ignore[arg-type] + + +def test_report_rejects_negative_residual_limit() -> None: + review = build_review_document("", Pipeline(()).transform("")) + + with pytest.raises(ValueError, match="non-negative"): + render_markdown_report(review, residual_limit=-1) + + +def test_same_result_builds_equal_review_and_report() -> None: + result = Pipeline((replace_first("a", "b", modifier_id="test.replace"),)).transform("a") + + first = build_review_document("a", result) + second = build_review_document("a", result) + + assert first == second + assert render_markdown_report(first) == render_markdown_report(second)