diff --git a/README.md b/README.md index 4678a05..e482b6b 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ 格式噪声、结构损坏、内容异常、修改追踪和多用途派生问题。各项目共享通用清洗能力,再通过独立配置或 profile 表达论文、政务文档、RAG、文档对比等不同需求。 -仓库当前仍处于研究和方案设计阶段:用于澄清问题、记录设计选择、积累可复核证据,并在方案获得确认后 -再建立实现。它目前不是可安装的 Python 包,也不提供命令行工具或生产接口。 +仓库当前已从纯文档治理进入第一版核心实现阶段:已经提供可安装的 Python 内存处理包和测试,用于验证 +组件组合、精确修改和审计协议。仓库仍不提供真实清洗规则、命令行工具、文件读写适配器或生产接口, +因此目前还不是拿来即可清洗文档的成品工具。 ## 当前阶段 @@ -25,8 +26,10 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。 - 2026-08-21 明确本项目定位为实验室共用库;GovDoc 和论文清洗都是使用场景,不是核心边界。 - 2026-08-21 批准并冻结 `research-wiki/design/0002-composable-cleaning-pipeline.md`,确定只接收 Markdown、 项目显式组装组件、单轮修改加最终只读复查的总体组织方式。 -- 2026-08-21 建立 `research-wiki/design/0003-first-executable-core-architecture.md` 草稿,等待评审第一版 - Python 内存核心、精确修改协议、错误语义和测试边界。 +- 2026-08-22 批准并冻结 `research-wiki/design/0003-first-executable-core-architecture.md`,确定第一版只建设 + Python 内存自动清洗核心:组件只提出确定可执行的精确修改,不同时建设独立检查、人工建议或真实清洗规则。 +- 2026-08-22 按 `0003` 实现第一版内存核心和测试:包括不可变数据契约、组件基类、原子修改执行器、 + 顺序流水线和最终稳定性复查;58 项测试以及 Ruff、mypy 检查均通过。 - 2026-08-21 完成 HTML 表格清洗专题调研 (`research-wiki/scratch/html-table-cleaning-ecosystem-research-2026-08-21.md`):核实 Pandoc 表格 方言能力边界、Turndown 不处理合并单元格、Docling Markdown 导出重复合并单元格内容、MinerU 全 @@ -34,9 +37,10 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。 - 2026-08-21 仓库由 `govdoc-md-cleaner` 更名为 `mdpolish`,GitHub 远程仓库与本地目录同步改名; 冻结的 design 记录和带日期的 scratch 笔记保留当时的旧名。 -当前没有清洗算法、可执行命令、运行依赖、测试套件或函数级输入输出契约。`0002` 只批准了总体组织方式; -调研报告中的解析器、内部 IR、具体 profile 和实施路线仍是候选方案,尚未批准。 -目录存在只代表文档落点已经建立,不代表相应能力已经完成。 +当前已有只处理内存字符串的底层执行机制和函数级契约,运行时只依赖 Python 标准库。组件可以针对当前 +Markdown 快照提出精确修改,流水线负责原子应用、审计记录、失败隔离和最终稳定性复查。仓库尚无任何正式 +清洗组件,因此不能把测试专用假组件或核心执行成功理解为已经具备论文、GovDoc、表格或图片清洗能力。 +调研报告中的解析器、内部 IR、具体 profile 和真实清洗规则仍是候选方案,尚未批准。 ## 服务对象与复用目标 @@ -51,10 +55,10 @@ GovDoc 目录、具体客户名称或某一转换器的固定输出路径。 ## 面向复用的设计原则 -- **通用核心**:只接收 Markdown,提供结构检查、异常检测和可审计变换,不读取 PDF、图片或转换器 JSON; +- **通用核心**:只接收 Markdown;第一版只执行确定、可审计的精确修改,不读取 PDF、图片或转换器 JSON; - **输入边界**:PDF/OCR/DOCX/HTML 转换和外部材料核验由使用项目或上游流程负责,不写入共用组件契约; - **项目 profile**:论文、GovDoc、对比、RAG、公开脱敏等规则独立组合,不互相污染默认行为; -- **保真优先**:不确定内容默认保留或进入人工确认,不能为了格式整齐改写业务或学术内容; +- **保真优先**:不确定内容默认保留;当前核心不猜测修改,也不承担人工确认流程; - **可复现**:规则、配置、输入哈希、输出和每次变更都可以追踪; - **可扩展**:新增项目在自身边界处理上游适配,并主要组合或补充组件和 profile,而不是复制一套清洗器。 @@ -82,6 +86,9 @@ mdpolish/ ├── AGENTS.md ├── CLAUDE.md ├── README.md +├── pyproject.toml # Python 包、构建和开发检查的唯一配置 +├── src/mdpolish/ # 第一版内存核心;不含真实清洗组件 +├── tests/ # 核心契约和组合行为测试 ├── data/ # 本地项目数据;Git 忽略,未来按项目分区 └── research-wiki/ ├── README.md @@ -101,12 +108,23 @@ mdpolish/ 3. `research-wiki/README.md`,确认文档应放在哪里; 4. 与任务直接相关的 `research-wiki/design/` 记录。 -下一项实质工作开始前,应评审并批准 `research-wiki/design/0003-first-executable-core-architecture.md`; -草稿尚不授权创建源码、测试、依赖或公共接口。 +第一版核心的当前机制见 `research-wiki/explanation/first-executable-core.md`。下一项实质工作应从已经审计的问题中 +选择一个边界明确、能够唯一修复的真实清洗规则,新增 design 说明其语义、适用范围、误改风险和验收样例, +经批准后再实现。解析器、CLI、文件适配器、profile 格式和独立检查能力仍需分别设计,不能从当前核心存在推导为 +已经获批。 ## 当前可用检查 ```bash +# 建立隔离环境并安装包与开发检查工具 +python -m venv .venv +.venv/bin/python -m pip install -e '.[dev]' + +# 第一版核心的基础验收 +.venv/bin/ruff check . +.venv/bin/mypy src tests +.venv/bin/pytest + # 两份 Agent 入口除标题外必须一致;无输出且退出码为 0 表示通过 diff -u <(tail -n +2 AGENTS.md) <(tail -n +2 CLAUDE.md) @@ -117,4 +135,6 @@ find research-wiki -maxdepth 2 -type f | sort git status --short ``` -当前没有测试命令;在真实实现和测试体系获批并落地前,不应声明测试通过。 +上述安装和三项基础验收已于 2026-08-22 在 Python 3.13.11 环境实际运行:Ruff 通过,mypy 检查 9 个源码与 +测试文件无问题,pytest 共 58 项测试通过。`requires-python` 仍以 `pyproject.toml` 声明的 Python 3.11 及以上为准; +本次结果不等于已经在每个受支持版本上完成兼容性验证。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..198fb91 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling>=1.27,<2"] +build-backend = "hatchling.build" + +[project] +name = "mdpolish" +version = "0.1.0" +description = "Deterministic in-memory core for composing Markdown cleaning components" +requires-python = ">=3.11" +dependencies = [] + +[project.optional-dependencies] +dev = [ + "mypy>=1.15,<2", + "pytest>=8.3,<10", + "ruff>=0.11,<1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/mdpolish"] + +[tool.pytest.ini_options] +addopts = "-ra" +testpaths = ["tests"] + +[tool.ruff] +target-version = "py311" +line-length = 120 +extend-exclude = ["data", "reference"] + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "RUF", "UP"] +ignore = ["RUF001"] + +[tool.mypy] +python_version = "3.11" +strict = true diff --git a/research-wiki/design/0003-first-executable-core-architecture.md b/research-wiki/design/0003-first-executable-core-architecture.md index d84add5..a8da29d 100644 --- a/research-wiki/design/0003-first-executable-core-architecture.md +++ b/research-wiki/design/0003-first-executable-core-architecture.md @@ -2,41 +2,53 @@ ## 状态 -草稿,待批准。本设计细化已冻结的 `0002`,不替代或修改其中的选择。 +已批准并冻结(2026-08-22)。 -本设计批准后,才授权创建这里列出的 Python 包、测试和工程配置,并实现不含真实清洗规则的最小核心。 -在批准前,本仓库仍然没有可运行的清洗工具。 +`supersedes: 0002`(范围有限):本设计只替代 `0002` 中“所有组件必须提供公共 `check`”、 +`detect_only` / `suggestion` / `auto_fix` 三种处理状态,以及第一版同时建设检查流程的选择。 +`0002` 已确定的 Markdown 单一输入、项目显式组装、组件顺序执行、精确修改、单轮清洗、最终只读复查、 +文件适配层与项目 profile 边界继续有效。 + +本次批准授权创建这里列出的 Python 包、测试和工程配置,并实现不含真实清洗规则的最小核心。 +批准本设计不等于实现已经存在;在对应代码和测试实际落地前,本仓库仍然没有可运行的清洗工具。 ## 1. 问题 -`0002` 已经确定:项目显式组合组件,流水线只接收 Markdown,组件先检查再提出精确修改,清洗只执行一轮, -最后进行只读复查。 +`0002` 已经确定总体组织方式,但把检查和清洗同时放进第一版:组件先返回三类问题,检查流程报告全部问题, +清洗流程只应用其中的 `auto_fix`。 -这些总体原则还不足以开始实现。目前尚未确定: +真实材料中确实存在幻觉、截断、损坏表格和断链图片等问题,但第一版核心只有 Markdown,没有原文、图片、 +人工审核或回源能力。此时报告“确认异常但无法处理”的内容不能形成闭环;`suggestion` 也没有批准、拒绝、 +快照复核和安全应用协议。提前把这些能力放入核心,会引入暂时没有消费者的 `Issue`、`CheckResult` 和状态分支。 -- 组件通过什么 Python 接口报告问题; +第一版应先回答更小的问题:具体组件如何针对当前 Markdown 提出唯一、安全的精确修改,公共执行器如何保证 +这些修改没有过期、冲突或部分执行,流水线又如何确认一次清洗已经稳定。 + +尚需确定: + +- 组件通过什么 Python 接口提出确定修改; - 中文文本的位置如何表示; -- 候选修改怎样绑定当前 Markdown,避免旧位置误改新文本; +- 修改怎样绑定当前 Markdown,避免旧位置误改新文本; - 多项修改如何保证全部成功或全部不执行; -- 检查错误、清洗错误和最终未稳定如何返回; +- 插入与替换在边界接触时如何判定冲突; +- 组件错误、修改失败和最终未稳定如何返回; - 第一版源码、测试和依赖边界是什么。 -如果这些问题留给实现时临时决定,组件很容易各自返回不同格式,或者直接生成整篇新 Markdown,最终无法统一 -验证和追踪修改。 - ## 2. 目标与非目标 目标: -- 建立只处理内存字符串的 Python 核心; -- 确定快照、问题、候选修改、实际改动和运行结果的职责; -- 让具体组件只负责定位问题和提出精确修改,不直接改写整篇 Markdown; -- 让公共修改执行器统一验证范围、冲突、原子性和实际改动记录; -- 实现组件独立调用、流水线检查、单轮清洗和最终只读复查; +- 建立只处理内存字符串的 Python 自动清洗核心; +- 让组件只提出能够立即自动执行的精确修改,不报告无法处理的疑似问题; +- 确定快照、候选修改、文本编辑、实际改动、错误和运行结果的职责; +- 让公共修改执行器统一验证范围、原文、冲突、原子性和实际改动记录; +- 实现项目显式组装、组件顺序执行、单轮清洗和最终稳定性复查; - 使用测试专用组件验证组合机制,不把真实清洗语义混入架构实现。 非目标: +- 不实现独立文档检查、`detect_only`、人工建议或审核流程; +- 不提供公共 `check`、`inspect`、预览或 dry-run 接口; - 不实现任何面向论文、GovDoc 或其他项目的真实清洗组件; - 不引入 Markdown parser、AST、HTML parser 或其他运行依赖; - 不读取或写入 Markdown 文件,不提供 CLI、批处理或服务接口; @@ -47,7 +59,7 @@ ## 3. 名称与技术边界 -- 项目展示名暂定为 `mdpolish`; +- 项目展示名为 `mdpolish`; - Python 分发名和导入名使用小写 `mdpolish`; - 源码包位于 `src/mdpolish/`; - 第一版支持 Python 3.11 及以上版本; @@ -60,24 +72,31 @@ ## 4. 方案比较与决定 -### 4.1 组件直接返回整篇新 Markdown +### 4.1 同时建设检查、建议和自动修改 -接口最简单,但流水线无法确认组件实际改了哪里,也无法统一检查过期位置、范围冲突和部分失败。组件的检查逻辑 -还可能与修改逻辑逐渐分离。 +这种方案能够描述长期可能需要的文档体检和人工确认,但第一版没有外部材料、审核入口和建议应用协议。 +不可执行的问题不会参与清洗,候选建议又会在快照变化后过期。 + +第一版不采用。以后确有独立检查需求时,新增平行的 Inspector 设计,不在本轮预留三段状态。 + +### 4.2 组件直接返回整篇新 Markdown + +接口简单,但流水线无法确认组件实际改了哪里,也无法统一检查过期位置、范围冲突和部分失败。 +组件还可能顺带改写没有被选中的内容。 不采用。 -### 4.2 组件返回 AST,由流水线重新输出全文 +### 4.3 组件返回 AST,由流水线重新输出全文 适合格式化器和结构化编译,但会让没有被组件选中的 Markdown 也发生书写形式变化。第一版还需要先选择 parser、 扩展方言和 renderer,超出了当前最小闭环。 不采用。 -### 4.3 组件报告问题及精确文本修改 +### 4.4 组件只提出能够自动执行的精确修改 -组件只读取当前快照,返回问题和可选候选修改;公共执行器验证并应用修改。这样可以保留原文、统一审计, -也可以拒绝过期或重叠修改。 +组件读取当前快照,只返回前置条件严格、处理方式唯一的 `ProposedChange`。公共执行器统一验证并应用修改。 +如果某种输入存在歧义,组件直接忽略,不修改,也不在第一版中额外报告。 采用此方案。 @@ -90,24 +109,23 @@ Markdown 字符串 DocumentSnapshot │ ▼ -Component._check_snapshot() +Component._propose_changes() │ - ├── Issue - │ └── 可选 ProposedChange - │ └── 一个或多个 TextEdit + └── ProposedChange + └── 一个或多个 TextEdit │ ▼ 公共修改执行器 │ ├── 验证快照、范围、原文和冲突 - ├── 原子应用当前组件的全部自动修改 + ├── 原子应用当前组件的全部修改 └── 生成 Change 和新 DocumentSnapshot │ ▼ 下一个 Component │ ▼ -最终快照只读复查 +最终快照重新提议但不应用 │ ▼ TransformResult @@ -115,12 +133,12 @@ TransformResult 核心分为四层: -- **数据模型:** 不可变地表达快照、范围、问题、修改、错误和结果; -- **组件基类:** 提供统一的 `check` 和 `transform` 行为,只把问题定位留给具体组件; +- **数据模型:** 不可变地表达快照、范围、候选修改、实际改动、错误和结果; +- **组件基类:** 只定义组件身份、适用边界和 `_propose_changes()` 扩展点; - **修改执行器:** 是唯一能够把 `TextEdit` 应用到 Markdown 的位置; -- **流水线:** 负责组件顺序、检查错误汇总、清洗失败停止和最终只读复查。 +- **流水线:** 负责组件顺序、快照刷新、错误、失败停止和最终稳定性复查。 -文件读写、终端输出和未来配置不进入这四层。 +文件读写、终端输出、未来检查能力和配置不进入这四层。 ## 6. 快照与位置 @@ -147,37 +165,25 @@ TransformResult Python 字符串下标是第一版唯一权威位置。行号和列号由快照与下标计算,只用于显示,不作为修改依据。 这里的位置按 Unicode 码点工作,不按 UTF-8 字节或用户看到的字形数量工作。 -## 7. 问题与候选修改 +## 7. 候选修改与文本编辑 -### 7.1 处理能力 +### 7.1 `ProposedChange` -问题的处理能力固定为三种: +一个候选修改表示组件确认能够自动执行的一次完整动作。它至少包含: -- `detect_only`:只报告,不包含候选修改; -- `suggestion`:包含候选修改,但自动清洗不应用; -- `auto_fix`:包含能够自动应用的候选修改。 +- 目标快照哈希; +- 非空的修改理由; +- 一个或多个 `TextEdit`。 -`detect_only` 如果携带候选修改,或者 `auto_fix` 没有候选修改,都属于组件契约错误。 +同一候选修改中的编辑必须全部应用或全部不应用。组件必须以确定顺序返回候选修改;通常按首个编辑在原文中的 +位置从前到后排列。流水线根据组件执行位置、目标快照哈希和候选修改序号分配当前结果内的确定性引用, +组件不生成随机 ID。 -### 7.2 `Issue` +第一版没有“不自动应用的候选修改”。不能确认唯一正确改法时,组件不返回 `ProposedChange`。 -一条问题至少包含: +### 7.2 `TextEdit` -- 产生问题的快照哈希; -- 组件标识和组件版本; -- 问题范围;文档级问题可以没有具体范围; -- 简明说明和可复核证据; -- 处理能力; -- 可选的候选修改。 - -问题只描述某个快照中的事实。快照变化后,它可以继续作为历史记录,但不能直接用于修改新快照。 - -### 7.3 `ProposedChange` 与 `TextEdit` - -一个候选修改表示解决一条问题所需的完整动作,可以包含一个或多个 `TextEdit`。同一候选修改中的编辑必须 -全部应用或全部不应用。 - -每个 `TextEdit` 至少包含: +每个文本编辑至少包含: - 目标快照哈希; - `TextSpan`; @@ -187,50 +193,65 @@ Python 字符串下标是第一版唯一权威位置。行号和列号由快照 插入使用 `start == end` 和空 `expected_text`;删除使用空 `replacement`。`replacement` 与 `expected_text` 完全相同的无效修改视为组件契约错误,不生成虚假的改动记录。 -## 8. 组件接口 +## 8. 组件接口与契约 -`Component` 使用抽象基类,而不是仅使用结构化 `Protocol`。公共基类负责保持独立调用与流水线调用的语义一致。 +`Component` 使用抽象基类。每个具体组件只实现以下扩展点: -每个具体组件只实现以下扩展点: - -- 组件标识、组件版本和当前参数; -- `_check_snapshot(snapshot)`:读取快照并返回问题,不产生副作用。 +- 稳定的组件标识; +- 组件版本; +- 当前参数; +- 非空的适用边界说明; +- `_propose_changes(snapshot)`:读取快照并返回能够自动执行的候选修改,不产生副作用。 组件标识使用稳定的小写字符串;同一语义不能因为改了 Python 类名就更换标识。组件版本使用 `MAJOR.MINOR.PATCH` 形式。组件参数必须能表示为确定的只读基础数据,流水线将实际参数记录到结果中。 -公共基类提供: +适用边界至少说明组件处理的结构、严格前置条件和明确排除项。组件只对满足全部前置条件的内容提出修改; +相似但有歧义的内容直接忽略。 -- `check(markdown)`:建立快照,执行该组件检查并返回检查结果; -- `transform(markdown)`:等价于只包含该组件的流水线清洗。 +所有组件必须满足以下不变量: -具体组件不得重写公共 `check`、`transform` 或修改执行器。类型声明使用 `final` 标记这些入口;代码评审和测试 -同时检查组件只实现规定扩展点。 +- 相同 Markdown、组件版本和参数产生相同顺序的候选修改; +- 成功执行一次后再次执行,不产生新的实际改动; +- 不读取文件、网络、环境变量、当前时间或随机数; +- 不修改传入对象或外部状态; +- 不直接生成或改写整篇 Markdown,只提交精确 `TextEdit`。 -组件必须是确定性的:相同 Markdown、组件版本和参数必须产生相同问题及候选修改。组件不能读取文件、网络、 -环境变量、当前时间或随机数,也不能修改传入对象和外部状态。 +第一版组件没有公共 `check()` 或 `transform()`。单个组件通过只包含它的 `Pipeline` 独立运行: + +```python +result = Pipeline([component]).transform(markdown) +``` + +这样避免 `component.py` 反向依赖 `pipeline.py`,也避免组件和流水线维护两套执行逻辑。 第一版流水线禁止出现两个相同组件标识的实例。需要用不同参数运行同一组件两次时,应由项目重新考虑组件边界, 不能依靠重复 ID 制造含义不清的执行记录。 ## 9. 公共修改执行器 -流水线不会把多个组件在旧快照上产生的修改集中到最后再应用。每个组件都针对当前快照检查;该组件结束后, -它的自动修改作为一个批次交给公共执行器。 +每个组件都针对当前快照提出修改;该组件结束后,它的全部候选修改作为一个批次交给公共执行器。 执行器按以下顺序验证当前组件的整个批次: -1. 问题和编辑的快照哈希都等于当前快照哈希; -2. 所有范围合法; -3. `markdown[start:end]` 与 `expected_text` 完全一致; -4. 不存在重复编辑、范围重叠或同一位置的多个插入; -5. 所有编辑都会实际改变内容。 +1. 候选修改和编辑的快照哈希都等于当前快照哈希; +2. 每个候选修改理由非空并至少包含一个编辑; +3. 所有范围合法; +4. `markdown[start:end]` 与 `expected_text` 完全一致; +5. 不存在重复编辑或下述范围冲突; +6. 所有编辑都会实际改变内容。 -相邻但不重叠的范围可以同时修改。验证全部通过后,执行器按位置从后向前应用编辑,避免前面的修改使后面的 -下标失效。任意一项验证失败,当前组件的整个批次都不应用。 +范围冲突使用以下保守规则: -这里的“当前组件整个批次”包括该组件本次检查产生的所有 `auto_fix` 候选修改。`suggestion` 和 `detect_only` -问题永远不进入自动修改批次。 +- 两个非空范围真正重叠时冲突;相邻的 `[a, b)` 与 `[b, c)` 可以同时修改; +- 两个插入位于同一位置时冲突,不同位置可以同时插入; +- 插入点位于另一个非空范围内部,或等于该范围的起点、终点时,均视为冲突。 + +最后一条有意比半开区间的数学重叠更严格,避免相同起点的执行顺序和边界插入语义不明确。组件如果确实需要 +替换一段文字并在边界追加内容,应合并为一个 `TextEdit.replacement`。 + +验证全部通过后,执行器按位置从后向前应用编辑,避免前面的修改使后面的下标失效。任意一项验证失败, +当前组件的整个批次都不应用。内部应用顺序不决定报告顺序;结果中的实际改动按原文位置从前到后排列。 ### 9.1 `Change` @@ -238,24 +259,40 @@ Python 字符串下标是第一版唯一权威位置。行号和列号由快照 - 组件标识和版本; - 组件在流水线中的执行位置; +- 候选修改在本次组件结果中的引用和编辑序号; - 修改理由; - 修改前范围、`before` 和 `after`; -- 修改前后的快照哈希; -- 所属候选修改,使一次多位置动作可以整体追踪。 +- 修改前后的快照哈希。 -修改记录描述实际发生的变化,不复制未执行的建议,也不把问题记录冒充改动记录。 +同一个 `ProposedChange` 产生的多条 `Change` 使用相同引用,使一次多位置动作可以整体追踪。同一组件批次中的 +所有 `Change` 共享该批次修改前后的快照哈希,不制造并不存在的中间公开快照。 -## 10. 检查流程 +修改记录只描述实际发生的变化,不把未执行或验证失败的候选修改冒充实际改动。 -`Pipeline.check(markdown)` 建立一个输入快照。所有组件按照项目给出的顺序检查同一个快照,Markdown 全程不变。 +## 10. 模块依赖方向 -- 一个组件正常完成后,流水线按原顺序收集问题; -- 一个组件抛出异常或返回违反契约的数据时,流水线记录结构化组件错误,然后继续检查后续组件; -- 检查结果记录输入哈希、实际组件顺序、版本、参数、问题和错误; -- 只要存在组件错误,检查结果就不能表示为完整成功,但已经获得的问题仍然保留。 +第一版保持以下单向依赖: -组件异常不能被静默忽略。错误至少记录组件身份、错误阶段、异常类型和安全的错误说明。是否保存 traceback -留在内存实现中决定,不向未来报告格式作承诺。 +```text +models.py + ▲ ▲ + │ │ +component.py edits.py + ▲ ▲ + \ / + pipeline.py +``` + +- `models.py` 只依赖 Python 标准库; +- `component.py` 只依赖数据模型,不导入流水线或修改执行器; +- `edits.py` 只依赖数据模型,不调用组件或流水线; +- `pipeline.py` 可以依赖组件、修改执行器和数据模型; +- `__init__.py` 只导出批准的公共对象,不实现第二套逻辑。 + +修改执行器不理解 arXiv、表格、HTML 或其他业务语义,也不决定组件顺序和最终状态。组件不应用编辑, +不刷新快照,也不知道文件、CLI 或未来报告格式。 + +如果实现中发现这些边界导致机械性的循环依赖,可以拆分数据模型文件,但不能让底层模块反向导入流水线。 ## 11. 清洗流程与运行状态 @@ -264,40 +301,57 @@ Python 字符串下标是第一版唯一权威位置。行号和列号由快照 `Pipeline.transform(markdown)` 按以下步骤运行: 1. 建立输入快照; -2. 按顺序让当前组件检查当前快照; -3. 收集该组件的 `auto_fix` 候选修改; +2. 按项目给出的顺序,让当前组件针对当前快照提出修改; +3. 验证组件元数据和候选修改契约; 4. 原子验证并应用当前组件的整个修改批次; 5. 有修改时建立新快照,下一个组件只能读取新快照; -6. 所有组件各执行一次后,对最终快照运行最终只读复查。 +6. 所有组件各执行一次后,对最终快照运行最终稳定性复查。 + +组件没有提出修改是正常情况,不产生空批次或虚假 `Change`。 +空组件列表也是合法输入:流水线对原输入建立快照后直接完成空的最终复查,返回零改动的 `success`。 清洗阶段如果组件异常、返回无效数据或修改批次冲突,流水线立即停止,不继续运行后面的组件,也不进行最终复查。 之前组件已经完成的内存修改和 `Change` 保留在失败结果中,但不能作为成功输出。 -### 11.2 最终只读复查 +### 11.2 最终稳定性复查 -最终复查让所有已选组件按照原顺序检查最终快照,不应用任何修改。 +最终复查让所有已选组件按照原顺序针对最终快照重新提出修改,但不应用任何修改。 -- 发现仍可由已选组件 `auto_fix` 的问题:状态为 `unstable`; -- 只剩 `detect_only` 或 `suggestion` 问题:保留为未解决问题,不妨碍核心流水线成为 `success`; -- 最终复查发生组件错误:状态为 `failed`;复查继续检查其余组件,以汇总只读阶段的错误和问题。 +复查阶段仍然验证组件元数据、候选修改、原文和整个组件批次的冲突;区别只是验证通过后不应用编辑。 +每条有效残留修改连同组件标识、版本、执行位置和确定性引用一起记录,使调用方知道由哪个组件提出。 -是否因为未解决的 `detect_only` 或 `suggestion` 问题阻止下游使用,由使用项目决定,不写死在共用核心中。 +- 所有组件都不再提出修改:状态可以是 `success`; +- 任一组件仍提出有效修改:状态为 `unstable`,并保留这些 `residual_proposals`; +- 复查发生组件错误或返回无效数据:状态为 `failed`;复查继续调用其余组件,以汇总只读阶段的错误。 -### 11.3 状态与文本字段 +如果最终复查同时出现错误和其他组件的有效残留修改,最终状态以 `failed` 为准,但已经获得的 +`residual_proposals` 仍然保留,不能因为另一个组件失败而丢失。 + +最终复查只回答“选中的自动清洗组件是否已经稳定”,不声称 Markdown 没有截断、幻觉、损坏表格或其他 +第一版不处理的问题。 + +### 11.3 状态与结果字段 清洗状态至少包括: -- `success`:单轮清洗和最终复查完整完成,没有仍可自动修复的问题; -- `failed`:组件执行、数据契约或修改验证失败; -- `unstable`:修改阶段没有错误,但最终复查仍发现已选组件可自动修复的问题。 +- `success`:单轮清洗和最终复查完整完成,选中组件不再提出修改; +- `failed`:组件执行、组件契约或修改验证失败; +- `unstable`:修改阶段没有错误,但最终复查仍产生有效候选修改。 为避免调用方忽略状态并误用半成品: - `success` 只提供 `output_markdown`,`partial_markdown` 为空; - `failed` 和 `unstable` 不提供 `output_markdown`,只提供诊断用的 `partial_markdown`; -- 三种状态都记录输入哈希、当前内容哈希、组件清单、实际改动、未解决问题和错误。 +- 三种状态都记录输入哈希、当前内容哈希、组件清单、实际改动和错误; +- `residual_proposals` 记录最终复查已经验证有效的残留修改;它可以出现在 `unstable` 或最终复查阶段产生的 + `failed` 结果中,在 `success` 和清洗阶段直接失败的结果中为空。 + +错误至少记录组件标识和版本、发生阶段(`transform` 或 `final_review`)、异常或契约错误类型,以及不泄露 +额外原文的简明说明。组件异常、契约错误和修改验证错误都不能被静默忽略。 输入本身从不被原地覆盖。即使输出内容与输入完全相同,只要最终复查通过,也可以是零改动的 `success`。 +由于核心只返回内存结果、不写文件,第一版不再提供额外预览接口;调用方可以先审查 `TransformResult`, +再由未来适配层决定是否保存成功输出。 ## 12. 源码与测试结构 @@ -321,35 +375,41 @@ tests/ ``` - `models.py` 只放不可变的数据模型和状态枚举; -- `component.py` 放组件基类和组件契约验证; +- `component.py` 放组件基类和组件元数据契约; - `edits.py` 放纯文本修改验证与应用; -- `pipeline.py` 放检查、单轮清洗和最终复查编排; +- `pipeline.py` 放单轮清洗和最终稳定性复查编排; - `__init__.py` 只导出第一版公共对象; - `py.typed` 声明分发包提供类型信息; - 测试辅助组件只存在于 `tests/`,不发布成示例清洗能力。 -如果实现中发现这些边界导致循环依赖,可以在不改变公共职责的前提下机械拆分模块;新增新的业务层、运行依赖 -或公共入口仍需要重新评审。 +新增新的业务层、运行依赖或公共入口仍需要重新评审。 ## 13. 测试专用组件与验收 -第一版不借真实文档验证,也不把测试字符串包装成正式清洗规则。测试中建立最小假组件,分别产生固定问题、 -建议修改、精确替换、组件异常和连锁影响。 +第一版不借真实文档验证,也不把测试字符串包装成正式清洗规则。测试中建立最小假组件,分别产生零修改、 +固定修改、多位置修改、组件异常、无效候选和连锁影响。 至少覆盖: - 空 Markdown、中文、换行和 Unicode 组合字符; -- 插入、删除、替换以及一次问题包含多个编辑; -- 相邻范围可以应用,重叠范围、重复编辑和同点插入明确失败; -- 哈希过期、范围越界、`expected_text` 不符和无效修改明确失败; -- 当前组件批次全部成功或全部不应用; -- 检查流程记录错误后继续其他组件; -- 清洗流程遇错立即停止,并区分成功输出与部分文本; -- 后一个组件制造前一个组件的新问题时,最终复查返回 `unstable`,且不自动开始第二轮; -- `suggestion` 和 `detect_only` 不被自动应用; -- 组件独立运行与单组件流水线结果一致; +- 空组件列表返回原文不变、零改动的 `success`; +- 插入、删除、替换以及一次候选修改包含多个编辑; +- 相邻非空范围可以应用,重叠范围和重复编辑明确失败; +- 不同位置插入可以应用,同点插入明确失败; +- 插入位于非空范围内部、起点或终点时明确失败; +- 哈希过期、范围越界、`expected_text` 不符、空候选、空理由和无效修改明确失败; +- 当前组件批次全部成功或全部不应用,包括不同候选修改之间发生冲突; +- 组件异常或契约错误使清洗立即停止,并区分成功输出与部分文本; +- 后一个组件读取前一个组件修改后的新快照; +- 后一个组件制造前一个组件的新问题时,最终复查返回 `unstable`,保留 `residual_proposals`,且不开始第二轮; +- 最终复查发生错误时继续调用其余组件并最终返回 `failed`; +- 最终复查同时出现错误和有效残留修改时,两者都保留,状态为 `failed`; +- 错误记录能够区分 `transform` 和 `final_review` 阶段; +- 同一个候选修改的多条 `Change` 共享引用、理由和批次前后哈希; +- 单个组件通过单组件 `Pipeline` 正常运行; - 成功流水线再次运行不产生实际改动; -- 输入字符串不被修改,相同输入、组件和参数产生相同结果。 +- 输入字符串不被修改,相同输入、组件和参数产生相同顺序的结果; +- 重复组件标识、无效版本、不可表示的参数和空适用边界明确失败。 批准并实现后,基础验证至少包括: @@ -361,28 +421,45 @@ pytest README 届时记录实际可用命令。只有这些命令真实运行成功后,才能报告对应检查通过。 -## 14. 风险与代价 +## 14. 未来检查能力如何扩展 -- **没有公共 AST:** 结构复杂的组件以后可能需要重复解析;先保持解析细节为组件内部实现,等真实规则证明需要 - 共享分析后再设计。 +第一版不为未来检查功能预留空枚举或可空候选修改。以后真实项目证明只读检查有独立消费者时,通过新 design +增加平行接口,例如: + +```text +Inspector.inspect(DocumentSnapshot) -> Finding +InspectionPipeline.inspect(markdown) -> InspectionResult +``` + +未来检查能力可以复用 `DocumentSnapshot`、`TextSpan` 和组件身份规则,但不修改 `TextEdit`、 +`ProposedChange`、公共修改执行器、`Pipeline.transform()` 或 `TransformResult`。某项能力同时需要检查和清洗时, +对应 Inspector 与 Component 可以在自身实现中复用定位函数,不需要让两个产品流程共享同一种结果模型。 + +人工建议以后还需要批准、拒绝、快照复核和应用协议,不能只增加一个 `suggestion` 枚举就视为完成。 + +## 15. 风险与代价 + +- **第一版不报告不可处理问题:** `success` 只表示选中组件执行稳定,不表示文档整体正确;README 和未来 API + 文档必须明确这一点。 +- **没有公共 AST:** 结构复杂的组件以后可能需要重复解析;等真实规则证明需要共享分析后再设计。 - **Python 字符位置不是跨语言协议:** 第一版只承诺 Python API;以后输出机器可读跨语言格式时,需要单独定义 - 坐标语义,不能直接假设 JavaScript UTF-16 或 UTF-8 byte offset 与之相同。 -- **最终复查增加检查成本:** 选中的组件最多检查两次,但换来对组合连锁影响的明确判断,第一版接受此代价。 + 坐标语义,不能假设 JavaScript UTF-16 或 UTF-8 byte offset 与之相同。 +- **最终复查增加检查成本:** 选中的组件最多提出两次修改,但换来对组合连锁影响的明确判断,第一版接受此代价。 - **组件级原子批次可能放弃部分正确修改:** 这是有意的保真选择;组件应修复自己的冲突,而不是让流水线猜测。 - **失败结果仍含部分 Markdown:** 使用独立字段并让成功输出为空,降低误用风险;未来文件适配层不得默认写出 `partial_markdown`。 - **结果可能包含原文片段:** 第一版结果只存在内存,不建设日志和持久化;以后新增 reporter 时必须单独评审 脱敏和保存边界。 -- **组件版本需要维护:** 组件语义、定位或修改行为变化时必须更新版本,不能只改代码而保留相同审计身份。 +- **组件版本需要维护:** 组件语义、适用边界、定位或修改行为变化时必须更新版本,不能只改代码而保留相同身份。 -## 15. 批准后的实施边界 +## 16. 批准后的实施边界 批准本设计只授权: 1. 创建第 12 节列出的工程与测试文件; -2. 实现第 5 至 11 节描述的内存核心; +2. 实现第 5 至 11 节描述的内存自动清洗核心; 3. 创建测试专用假组件并完成第 13 节验证; 4. 根据真实实现更新 README 当前阶段和基础检查; 5. 实现完成后新增 `research-wiki/explanation/` 文档,解释当前实际架构。 -批准本设计不授权实现真实清洗规则,不授权读取真实数据,不授权文件覆盖、CLI、发布、提交或推送。 +批准本设计不授权实现真实清洗规则,不授权读取真实数据,不授权文件覆盖、独立检查能力、CLI、发布、提交或推送。 diff --git a/research-wiki/explanation/.gitkeep b/research-wiki/explanation/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/research-wiki/explanation/first-executable-core.md b/research-wiki/explanation/first-executable-core.md new file mode 100644 index 0000000..d55caee --- /dev/null +++ b/research-wiki/explanation/first-executable-core.md @@ -0,0 +1,149 @@ +# 第一版内存清洗核心如何工作 + +## 1. 它解决什么问题 + +清洗组件如果直接返回一整篇新 Markdown,调用方只能看到修改后的结果,很难确认它实际改了哪里。组件保存的 +旧位置还可能在文本变化后误中另一段内容;同一批修改发生重叠时,按不同顺序执行也可能得到不同结果。 + +当前核心把“判断应该改什么”和“安全地执行修改”分开:组件只描述绑定当前文本的精确修改,公共执行器统一 +验证并应用。这样可以在不引入真实清洗规则、文件读写或 Markdown parser 的情况下,先让组合与审计协议可运行。 + +已经实现的范围来自已批准的 +[`0003-first-executable-core-architecture.md`](../design/0003-first-executable-core-architecture.md)。精确类名、字段和 +函数签名以 [`src/mdpolish/`](../../src/mdpolish/) 中的代码和测试为准,本文不维护第二份 API 清单。 + +## 2. 当前数据流 + +```text +输入 Markdown 字符串 + │ + ▼ +带内容哈希的当前快照 + │ + ▼ +组件提出精确修改 ──► 整批验证 ──► 整批应用 ──► 新快照 + │ │ + └──────── 按组件顺序重复 ◄──────────────┘ + │ + ▼ + 最终只重新提议,不再应用 + │ + ┌────────────────┼────────────────┐ + ▼ ▼ ▼ + success unstable failed +``` + +核心只有四层: + +| 层次 | 当前职责 | 明确不负责 | +| --- | --- | --- | +| 数据模型 | 保存快照、范围、候选修改、实际改动、错误和结果 | 业务规则和文件路径 | +| 组件基类 | 声明身份、版本、参数、适用边界并提出修改 | 应用修改和组织流水线 | +| 修改执行器 | 统一验证并原子应用一个组件批次 | 判断 Markdown 业务语义 | +| 流水线 | 排列组件、刷新快照、处理失败并做最终复查 | 读取文件、选择项目 profile | + +依赖保持单向:组件基类和修改执行器只依赖数据模型,流水线可以调用前三者,底层模块不反向调用流水线。 + +## 3. 为什么修改必须绑定快照 + +每个 Markdown 快照都带有根据完整字符串计算的 SHA-256。候选修改和其中每条文本编辑都必须指向这个哈希, +还要同时提供原文范围和该范围预期出现的文字。 + +执行时会再次检查: + +1. 哈希仍然对应当前快照; +2. 范围没有越过字符串边界; +3. 当前位置的文字与组件声明的预期原文完全一致; +4. 替换后确实会改变内容。 + +任何一项不满足,当前组件的整个批次都不会执行。这使位置只在其产生时的快照内有效,不允许把旧候选修改悄悄 +套到后来变化的 Markdown 上。 + +范围使用 Python 字符串下标,而不是 UTF-8 字节位置。核心保留输入的换行、Unicode 形式和末尾换行,不做隐式 +规范化。 + +## 4. 组件为什么只提出自动修改 + +第一版组件只返回能够立即、唯一执行的候选修改。遇到不知道正确修法的截断、损坏表格、疑似幻觉或无法读取的 +图片时,组件应忽略,不猜测修复,也不额外生成“仅检查”结果。 + +每个组件必须提供稳定标识、`MAJOR.MINOR.PATCH` 版本、可冻结的参数和非空适用边界。适用边界需要由组件作者说明 +它处理什么结构、依赖哪些严格前置条件、明确排除什么。组件还必须满足确定、无副作用和幂等约束;不能读取文件、 +网络、环境变量、当前时间或随机数。 + +最终复查能发现多个组件组合后仍会继续提出修改,但不能从有限输入证明一个组件对所有文本都幂等。因此,幂等性 +既由最终复查保护当前运行,也必须由组件自己的针对性测试证明其适用范围内的行为。 + +独立检查和人工建议目前没有实现。以后只有出现明确消费者和闭环时,才通过新 design 增加平行接口,不在当前 +组件结果中补可空字段或状态枚举。 + +## 5. 一个组件批次如何保证原子性 + +一个组件可以提出多个候选修改,每个候选修改又可以包含多个文本编辑。执行器先验证该组件本次提出的全部编辑, +只有整批通过才从后向前应用;任意一条失败,整批保持原样。这里的原子边界是“当前组件本次执行的全部修改”, +不是单独一条编辑。 + +当前冲突规则有意保守: + +| 两项编辑的关系 | 结果 | +| --- | --- | +| 两个非空范围真正重叠 | 冲突 | +| 两个非空范围只相邻 | 允许 | +| 两次插入位于同一点 | 冲突 | +| 两次插入位于不同点 | 允许 | +| 插入点位于非空范围内部、起点或终点 | 冲突 | + +如果业务动作需要替换一段文字并在边界追加内容,组件应把它表达成同一条替换,而不是依赖编辑执行顺序。 + +## 6. 流水线状态代表什么 + +流水线先对所有组件做元数据预检,避免运行到一半才发现重复标识或无效版本。之后每个组件只执行一次,后一个组件 +只能读取前一个组件产生的新快照。清洗阶段出现异常、契约错误或编辑验证错误时会立即停止,且不再进行最终复查。 + +所有组件完成后,流水线让它们针对最终快照重新提出一次修改,但这一阶段只验证、不应用: + +| 状态 | 含义 | Markdown 字段 | +| --- | --- | --- | +| `success` | 清洗和最终复查均完成,所选组件不再提出修改 | 只提供成功输出 | +| `unstable` | 清洗无错误,但最终复查仍有有效候选修改 | 只提供诊断用部分文本和残留候选 | +| `failed` | 清洗或最终复查发生错误 | 只提供诊断用部分文本和错误 | + +最终复查是只读阶段,因此某个组件失败后仍会继续复查其余组件。错误与其他组件的有效残留修改可以同时保留,最终 +状态以 `failed` 为准。流水线不会因为 `unstable` 自动开始第二轮。 + +`success` 只表示本次选中的自动清洗组件已经稳定,不表示文档没有截断、幻觉、表格损坏、图片断链或其他未实现 +规则能够发现的问题。 + +## 7. 审计记录能回答什么 + +每条实际执行的文本编辑都会生成一条改动记录,说明: + +- 是哪个组件、哪个版本和流水线位置执行的; +- 属于哪个候选修改,以及在该候选修改中的编辑序号; +- 组件给出的修改理由; +- 修改前范围、原文和替换内容; +- 当前组件批次修改前后的快照哈希。 + +同一候选修改中的多条记录共享候选引用,同一组件批次中的所有记录共享批次前后哈希。记录只描述已经发生的修改; +验证失败或最终复查中没有执行的候选不会冒充实际改动。 + +这些内容当前只存在于内存返回值中。仓库没有 reporter、审计文件格式或日志持久化,调用方也不能默认把失败结果中 +的部分文本写回原文件。 + +## 8. 当前验证和剩余边界 + +核心测试使用短小的假组件,不包含或复制真实文档。测试已经覆盖空文本、中文和组合 Unicode、插入/删除/替换、 +范围冲突、过期哈希、批次原子性、组件连锁影响、错误阶段、审计关联和成功结果再次运行等行为。 + +实际可用的安装与验收命令、最近一次验证日期和结果只在根目录 +[`README.md`](../../README.md#当前可用检查) 维护。 + +当前仍然没有: + +- 论文、GovDoc、HTML 表格或其他真实清洗组件; +- 独立文档检查、人工建议或审核流程; +- Markdown parser、AST 或共享业务中间表示; +- 文件读写、CLI、批处理、项目 profile 格式和生产集成; +- 审计结果的长期存储或脱敏输出协议。 + +这些边界中的任何一项要进入实现,都需要先用新的 design 明确语义、代价和验收方式。 diff --git a/src/mdpolish/__init__.py b/src/mdpolish/__init__.py new file mode 100644 index 0000000..b3f9730 --- /dev/null +++ b/src/mdpolish/__init__.py @@ -0,0 +1,44 @@ +"""Approved public API for the first mdpolish in-memory core.""" + +from mdpolish.component import Component, ComponentContractError +from mdpolish.edits import AppliedBatch, EditValidationError, apply_component_batch, validate_component_batch +from mdpolish.models import ( + Change, + ComponentInfo, + DocumentSnapshot, + ErrorStage, + ProposalReference, + ProposedChange, + ResidualProposal, + RunError, + RunStatus, + TextEdit, + TextSpan, + TransformResult, + markdown_sha256, +) +from mdpolish.pipeline import Pipeline, PipelineContractError + +__all__ = [ + "AppliedBatch", + "Change", + "Component", + "ComponentContractError", + "ComponentInfo", + "DocumentSnapshot", + "EditValidationError", + "ErrorStage", + "Pipeline", + "PipelineContractError", + "ProposalReference", + "ProposedChange", + "ResidualProposal", + "RunError", + "RunStatus", + "TextEdit", + "TextSpan", + "TransformResult", + "apply_component_batch", + "markdown_sha256", + "validate_component_batch", +] diff --git a/src/mdpolish/component.py b/src/mdpolish/component.py new file mode 100644 index 0000000..cda1e21 --- /dev/null +++ b/src/mdpolish/component.py @@ -0,0 +1,105 @@ +"""Component extension contract for the mdpolish core.""" + +from __future__ import annotations + +import math +import re +from abc import ABC, abstractmethod +from collections.abc import Mapping +from typing import cast, final + +from mdpolish.models import ComponentInfo, DocumentSnapshot, Parameters, ParameterValue, ProposedChange + +_COMPONENT_ID_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$") +_SEMVER_PATTERN = re.compile(r"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$") + + +class ComponentContractError(ValueError): + """A component does not satisfy the approved extension contract.""" + + +def _freeze_parameter(value: object) -> ParameterValue: + if value is None or isinstance(value, (str, bool)) or type(value) is int: + return value + if isinstance(value, float): + if not math.isfinite(value): + raise ComponentContractError("component parameters cannot contain non-finite floats") + return value + if isinstance(value, Mapping): + pairs: list[tuple[str, ParameterValue]] = [] + for key, nested_value in value.items(): + if not isinstance(key, str) or not key: + raise ComponentContractError("component parameter mapping keys must be non-empty strings") + pairs.append((key, _freeze_parameter(nested_value))) + return tuple(sorted(pairs, key=lambda pair: pair[0])) + if isinstance(value, (list, tuple)): + return tuple(_freeze_parameter(item) for item in value) + raise ComponentContractError("component parameters must contain only deterministic basic data") + + +def _freeze_parameters(parameters: object) -> Parameters: + if not isinstance(parameters, Mapping): + raise ComponentContractError("component parameters must be a mapping") + frozen = _freeze_parameter(parameters) + if not isinstance(frozen, tuple): + raise AssertionError("a mapping must normalize to a tuple") + return cast(Parameters, frozen) + + +class Component(ABC): + """Base class for deterministic, side-effect-free cleaning components.""" + + @property + @abstractmethod + def component_id(self) -> str: + """Return the stable lowercase identity of this component.""" + + @property + @abstractmethod + def version(self) -> str: + """Return this component's MAJOR.MINOR.PATCH version.""" + + @property + @abstractmethod + def parameters(self) -> Mapping[str, object]: + """Return the current deterministic parameter values.""" + + @property + @abstractmethod + def applicability(self) -> str: + """Describe handled structures, strict preconditions, and exclusions.""" + + @abstractmethod + def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + """Return exact, automatically applicable changes for this snapshot.""" + + @final + def _component_info(self) -> ComponentInfo: + component_id = self.component_id + version = self.version + applicability = self.applicability + + if not isinstance(component_id, str) or _COMPONENT_ID_PATTERN.fullmatch(component_id) is None: + raise ComponentContractError("component_id must be a stable lowercase identifier") + if not isinstance(version, str) or _SEMVER_PATTERN.fullmatch(version) is None: + raise ComponentContractError("component version must use MAJOR.MINOR.PATCH") + if not isinstance(applicability, str) or not applicability.strip(): + raise ComponentContractError("component applicability must be a non-empty string") + + return ComponentInfo( + component_id=component_id, + version=version, + parameters=_freeze_parameters(self.parameters), + applicability=applicability, + ) + + @final + def _collect_proposals(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + if not isinstance(snapshot, DocumentSnapshot): + raise ComponentContractError("components require a DocumentSnapshot") + proposals = self._propose_changes(snapshot) + if not isinstance(proposals, tuple): + raise ComponentContractError("_propose_changes must return a tuple") + if any(not isinstance(proposal, ProposedChange) for proposal in proposals): + raise ComponentContractError("_propose_changes must return only ProposedChange values") + return proposals diff --git a/src/mdpolish/edits.py b/src/mdpolish/edits.py new file mode 100644 index 0000000..73c922b --- /dev/null +++ b/src/mdpolish/edits.py @@ -0,0 +1,150 @@ +"""Pure validation and atomic application of exact text edits.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from mdpolish.models import ( + Change, + ComponentInfo, + DocumentSnapshot, + ProposalReference, + ProposedChange, + TextEdit, +) + + +class EditValidationError(ValueError): + """A component edit batch cannot be applied safely.""" + + +@dataclass(frozen=True, slots=True) +class AppliedBatch: + """The new snapshot and audit entries from one atomic component batch.""" + + snapshot: DocumentSnapshot + changes: tuple[Change, ...] + + +@dataclass(frozen=True, slots=True) +class _IndexedEdit: + proposal_index: int + edit_index: int + reason: str + edit: TextEdit + + +def _edits_conflict(left: TextEdit, right: TextEdit) -> bool: + left_span = left.span + right_span = right.span + + if left_span.is_empty and right_span.is_empty: + return left_span.start == right_span.start + if left_span.is_empty: + return right_span.start <= left_span.start <= right_span.end + if right_span.is_empty: + return left_span.start <= right_span.start <= left_span.end + return max(left_span.start, right_span.start) < min(left_span.end, right_span.end) + + +def validate_component_batch( + snapshot: DocumentSnapshot, + proposals: tuple[ProposedChange, ...], +) -> tuple[_IndexedEdit, ...]: + """Validate a complete component batch without changing the snapshot.""" + if not isinstance(snapshot, DocumentSnapshot): + raise TypeError("snapshot must be a DocumentSnapshot") + if not isinstance(proposals, tuple): + raise EditValidationError("component proposals must be a tuple") + + indexed_edits: list[_IndexedEdit] = [] + seen_edits: set[TextEdit] = set() + for proposal_index, proposal in enumerate(proposals): + if not isinstance(proposal, ProposedChange): + raise EditValidationError("a component batch must contain only ProposedChange values") + if proposal.snapshot_sha256 != snapshot.sha256: + raise EditValidationError("a proposal targets a stale document snapshot") + for edit_index, edit in enumerate(proposal.edits): + if edit.snapshot_sha256 != snapshot.sha256: + raise EditValidationError("an edit targets a stale document snapshot") + if edit.span.end > len(snapshot.markdown): + raise EditValidationError("an edit span is outside the document snapshot") + if snapshot.markdown[edit.span.start : edit.span.end] != edit.expected_text: + raise EditValidationError("an edit's expected_text does not match the document snapshot") + if edit in seen_edits: + raise EditValidationError("a component batch contains a duplicate edit") + seen_edits.add(edit) + indexed_edits.append( + _IndexedEdit( + proposal_index=proposal_index, + edit_index=edit_index, + reason=proposal.reason, + edit=edit, + ) + ) + + for left_index, left in enumerate(indexed_edits): + for right in indexed_edits[left_index + 1 :]: + if _edits_conflict(left.edit, right.edit): + raise EditValidationError("a component batch contains conflicting edit ranges") + + return tuple(indexed_edits) + + +def apply_component_batch( + snapshot: DocumentSnapshot, + proposals: tuple[ProposedChange, ...], + component: ComponentInfo, + component_position: int, +) -> AppliedBatch: + """Atomically apply one fully validated component batch.""" + indexed_edits = validate_component_batch(snapshot, proposals) + 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, + ), + ) + changes = tuple( + Change( + component_id=component.component_id, + component_version=component.version, + component_position=component_position, + proposal_ref=ProposalReference( + component_position=component_position, + snapshot_sha256=snapshot.sha256, + proposal_index=item.proposal_index, + ), + edit_index=item.edit_index, + reason=item.reason, + span=item.edit.span, + before=item.edit.expected_text, + after=item.edit.replacement, + before_sha256=snapshot.sha256, + after_sha256=updated_snapshot.sha256, + ) + for item in report_order + ) + return AppliedBatch(snapshot=updated_snapshot, changes=changes) diff --git a/src/mdpolish/models.py b/src/mdpolish/models.py new file mode 100644 index 0000000..a7baf40 --- /dev/null +++ b/src/mdpolish/models.py @@ -0,0 +1,231 @@ +"""Immutable values shared by the mdpolish core.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from hashlib import sha256 +from string import hexdigits +from typing import TypeAlias + +ParameterValue: TypeAlias = ( + str + | int + | float + | bool + | tuple["ParameterValue", ...] + | tuple[tuple[str, "ParameterValue"], ...] + | None +) +Parameters: TypeAlias = tuple[tuple[str, ParameterValue], ...] + + +def markdown_sha256(markdown: str) -> str: + """Return the authoritative digest for a Markdown string.""" + if not isinstance(markdown, str): + raise TypeError("markdown must be a string") + return sha256(markdown.encode("utf-8")).hexdigest() + + +def _require_sha256(value: str, field_name: str) -> None: + if ( + not isinstance(value, str) + or len(value) != 64 + or any(character not in hexdigits for character in value) + or value.lower() != value + ): + raise ValueError(f"{field_name} must be a lowercase SHA-256 digest") + + +def _require_nonempty(value: str, field_name: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + +@dataclass(frozen=True, slots=True) +class DocumentSnapshot: + """An exact Markdown string and its library-computed digest.""" + + markdown: str + sha256: str = field(init=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "sha256", markdown_sha256(self.markdown)) + + +@dataclass(frozen=True, slots=True, order=True) +class TextSpan: + """A half-open range using Python string indexes.""" + + start: int + end: int + + def __post_init__(self) -> None: + if type(self.start) is not int or type(self.end) is not int: + raise TypeError("span indexes must be integers") + if self.start < 0 or self.end < self.start: + raise ValueError("span must satisfy 0 <= start <= end") + + @property + def is_empty(self) -> bool: + return self.start == self.end + + +@dataclass(frozen=True, slots=True) +class TextEdit: + """An exact replacement bound to one document snapshot.""" + + snapshot_sha256: str + span: TextSpan + expected_text: str + replacement: str + + def __post_init__(self) -> None: + _require_sha256(self.snapshot_sha256, "snapshot_sha256") + if not isinstance(self.span, TextSpan): + raise TypeError("span must be a TextSpan") + if not isinstance(self.expected_text, str) or not isinstance(self.replacement, str): + raise TypeError("expected_text and replacement must be strings") + if len(self.expected_text) != self.span.end - self.span.start: + raise ValueError("expected_text length must equal the span length") + if self.expected_text == self.replacement: + raise ValueError("a text edit must change the content") + + +@dataclass(frozen=True, slots=True) +class ProposedChange: + """One atomic, automatically applicable action proposed by a component.""" + + snapshot_sha256: str + reason: str + edits: tuple[TextEdit, ...] + + def __post_init__(self) -> None: + _require_sha256(self.snapshot_sha256, "snapshot_sha256") + _require_nonempty(self.reason, "reason") + if not isinstance(self.edits, tuple) or not self.edits: + raise ValueError("edits must be a non-empty tuple") + for edit in self.edits: + if not isinstance(edit, TextEdit): + raise TypeError("edits must contain only TextEdit values") + if edit.snapshot_sha256 != self.snapshot_sha256: + raise ValueError("an edit digest must match its proposal digest") + + +@dataclass(frozen=True, slots=True) +class ComponentInfo: + """Validated, immutable component metadata recorded in a run.""" + + component_id: str + version: str + parameters: Parameters + applicability: str + + +@dataclass(frozen=True, slots=True) +class ProposalReference: + """A deterministic reference scoped to one transform result.""" + + component_position: int + snapshot_sha256: str + proposal_index: int + + def __post_init__(self) -> None: + if type(self.component_position) is not int or self.component_position < 0: + raise ValueError("component_position must be a non-negative integer") + if type(self.proposal_index) is not int or self.proposal_index < 0: + raise ValueError("proposal_index must be a non-negative integer") + _require_sha256(self.snapshot_sha256, "snapshot_sha256") + + +@dataclass(frozen=True, slots=True) +class Change: + """One text edit that was actually applied.""" + + component_id: str + component_version: str + component_position: int + proposal_ref: ProposalReference + edit_index: int + reason: str + span: TextSpan + before: str + after: str + before_sha256: str + after_sha256: str + + +@dataclass(frozen=True, slots=True) +class ResidualProposal: + """A valid proposal found by the final read-only stability review.""" + + component_id: str + component_version: str + component_position: int + proposal_ref: ProposalReference + proposal: ProposedChange + + +class RunStatus(StrEnum): + """The terminal status of a pipeline transform.""" + + SUCCESS = "success" + FAILED = "failed" + UNSTABLE = "unstable" + + +class ErrorStage(StrEnum): + """The pipeline phase in which an error occurred.""" + + TRANSFORM = "transform" + FINAL_REVIEW = "final_review" + + +@dataclass(frozen=True, slots=True) +class RunError: + """A source-safe component, contract, or edit error.""" + + component_id: str + component_version: str + component_position: int + stage: ErrorStage + error_type: str + message: str + + +@dataclass(frozen=True, slots=True) +class TransformResult: + """The immutable result of one transform and its final review.""" + + status: RunStatus + input_sha256: str + current_sha256: str + components: tuple[ComponentInfo, ...] = () + changes: tuple[Change, ...] = () + errors: tuple[RunError, ...] = () + residual_proposals: tuple[ResidualProposal, ...] = () + output_markdown: str | None = None + partial_markdown: str | None = None + + def __post_init__(self) -> None: + _require_sha256(self.input_sha256, "input_sha256") + _require_sha256(self.current_sha256, "current_sha256") + if not isinstance(self.status, RunStatus): + raise TypeError("status must be a RunStatus") + for field_name in ("components", "changes", "errors", "residual_proposals"): + if not isinstance(getattr(self, field_name), tuple): + raise TypeError(f"{field_name} must be a tuple") + + current_markdown = self.output_markdown if self.status is RunStatus.SUCCESS else self.partial_markdown + if current_markdown is None or markdown_sha256(current_markdown) != self.current_sha256: + raise ValueError("the current Markdown must match current_sha256") + + if self.status is RunStatus.SUCCESS: + if self.partial_markdown is not None or self.errors or self.residual_proposals: + raise ValueError("a successful result cannot contain partial output, errors, or residual proposals") + elif self.status is RunStatus.FAILED: + if self.output_markdown is not None or not self.errors: + raise ValueError("a failed result requires errors and cannot contain successful output") + elif self.status is RunStatus.UNSTABLE: + if self.output_markdown is not None or self.errors or not self.residual_proposals: + raise ValueError("an unstable result requires residual proposals and cannot contain output or errors") diff --git a/src/mdpolish/pipeline.py b/src/mdpolish/pipeline.py new file mode 100644 index 0000000..2c25103 --- /dev/null +++ b/src/mdpolish/pipeline.py @@ -0,0 +1,303 @@ +"""Sequential orchestration and final stability review.""" + +from __future__ import annotations + +from collections.abc import Iterable + +from mdpolish.component import Component, ComponentContractError +from mdpolish.edits import apply_component_batch, validate_component_batch +from mdpolish.models import ( + Change, + ComponentInfo, + DocumentSnapshot, + ErrorStage, + ProposalReference, + ProposedChange, + ResidualProposal, + RunError, + RunStatus, + TransformResult, +) + + +class PipelineContractError(ValueError): + """A pipeline composition does not satisfy the approved contract.""" + + +class Pipeline: + """Run selected components once, then review the final snapshot for stability.""" + + def __init__(self, components: Iterable[Component]) -> None: + self._components = tuple(components) + + @property + def components(self) -> tuple[Component, ...]: + return self._components + + def transform(self, markdown: str) -> TransformResult: + input_snapshot = DocumentSnapshot(markdown) + component_infos, preflight_error = self._preflight_components() + if preflight_error is not None: + return self._failed_result( + input_snapshot=input_snapshot, + current_snapshot=input_snapshot, + component_infos=component_infos, + changes=(), + errors=(preflight_error,), + ) + + current_snapshot = input_snapshot + changes: list[Change] = [] + for position, (component, expected_info) in enumerate(zip(self._components, component_infos, strict=True)): + current_info, metadata_error = self._current_component_info( + component=component, + expected_info=expected_info, + position=position, + stage=ErrorStage.TRANSFORM, + ) + if metadata_error is not None: + return self._failed_result( + input_snapshot=input_snapshot, + current_snapshot=current_snapshot, + component_infos=component_infos, + changes=tuple(changes), + errors=(metadata_error,), + ) + + proposals, proposal_error = self._proposals( + component=component, + component_info=current_info, + position=position, + stage=ErrorStage.TRANSFORM, + snapshot=current_snapshot, + ) + if proposal_error is not None: + return self._failed_result( + input_snapshot=input_snapshot, + current_snapshot=current_snapshot, + component_infos=component_infos, + changes=tuple(changes), + errors=(proposal_error,), + ) + + try: + applied_batch = apply_component_batch( + snapshot=current_snapshot, + proposals=proposals, + component=current_info, + component_position=position, + ) + except Exception as error: + return self._failed_result( + input_snapshot=input_snapshot, + current_snapshot=current_snapshot, + component_infos=component_infos, + changes=tuple(changes), + errors=( + self._run_error( + component_info=current_info, + position=position, + stage=ErrorStage.TRANSFORM, + error=error, + unexpected_message="component edit batch does not satisfy the edit contract", + ), + ), + ) + + current_snapshot = applied_batch.snapshot + changes.extend(applied_batch.changes) + + review_errors: list[RunError] = [] + residual_proposals: list[ResidualProposal] = [] + for position, (component, expected_info) in enumerate(zip(self._components, component_infos, strict=True)): + current_info, metadata_error = self._current_component_info( + component=component, + expected_info=expected_info, + position=position, + stage=ErrorStage.FINAL_REVIEW, + ) + if metadata_error is not None: + review_errors.append(metadata_error) + continue + + proposals, proposal_error = self._proposals( + component=component, + component_info=current_info, + position=position, + stage=ErrorStage.FINAL_REVIEW, + snapshot=current_snapshot, + ) + if proposal_error is not None: + review_errors.append(proposal_error) + continue + + try: + validate_component_batch(current_snapshot, proposals) + except Exception as error: + review_errors.append( + self._run_error( + component_info=current_info, + position=position, + stage=ErrorStage.FINAL_REVIEW, + error=error, + unexpected_message="component edit batch does not satisfy the edit contract", + ) + ) + continue + + residual_proposals.extend( + ResidualProposal( + component_id=current_info.component_id, + component_version=current_info.version, + component_position=position, + proposal_ref=ProposalReference( + component_position=position, + snapshot_sha256=current_snapshot.sha256, + proposal_index=proposal_index, + ), + proposal=proposal, + ) + for proposal_index, proposal in enumerate(proposals) + ) + + if review_errors: + return self._failed_result( + input_snapshot=input_snapshot, + current_snapshot=current_snapshot, + component_infos=component_infos, + changes=tuple(changes), + errors=tuple(review_errors), + residual_proposals=tuple(residual_proposals), + ) + if residual_proposals: + return TransformResult( + status=RunStatus.UNSTABLE, + input_sha256=input_snapshot.sha256, + current_sha256=current_snapshot.sha256, + components=component_infos, + changes=tuple(changes), + residual_proposals=tuple(residual_proposals), + partial_markdown=current_snapshot.markdown, + ) + return TransformResult( + status=RunStatus.SUCCESS, + input_sha256=input_snapshot.sha256, + current_sha256=current_snapshot.sha256, + components=component_infos, + changes=tuple(changes), + output_markdown=current_snapshot.markdown, + ) + + def _preflight_components(self) -> tuple[tuple[ComponentInfo, ...], RunError | None]: + component_infos: list[ComponentInfo] = [] + seen_ids: set[str] = set() + for position, component in enumerate(self._components): + if not isinstance(component, Component): + error = ComponentContractError("pipeline entries must be Component instances") + return tuple(component_infos), self._run_error( + component_info=None, + position=position, + stage=ErrorStage.TRANSFORM, + error=error, + unexpected_message="a pipeline entry is not a Component instance", + ) + try: + component_info = component._component_info() + except Exception as error: + return tuple(component_infos), self._run_error( + component_info=None, + position=position, + stage=ErrorStage.TRANSFORM, + error=error, + unexpected_message="component metadata does not satisfy the component contract", + ) + component_infos.append(component_info) + if component_info.component_id in seen_ids: + duplicate_error = PipelineContractError("pipeline component_id values must be unique") + return tuple(component_infos), self._run_error( + component_info=component_info, + position=position, + stage=ErrorStage.TRANSFORM, + error=duplicate_error, + unexpected_message="pipeline component_id values must be unique", + ) + seen_ids.add(component_info.component_id) + return tuple(component_infos), None + + def _current_component_info( + self, + component: Component, + expected_info: ComponentInfo, + position: int, + stage: ErrorStage, + ) -> tuple[ComponentInfo, RunError | None]: + try: + current_info = component._component_info() + if current_info != expected_info: + raise ComponentContractError("component metadata changed during pipeline execution") + except Exception as error: + return expected_info, self._run_error( + component_info=expected_info, + position=position, + stage=stage, + error=error, + unexpected_message="component metadata does not satisfy the component contract", + ) + return current_info, None + + def _proposals( + self, + component: Component, + component_info: ComponentInfo, + position: int, + stage: ErrorStage, + snapshot: DocumentSnapshot, + ) -> tuple[tuple[ProposedChange, ...], RunError | None]: + try: + proposals = component._collect_proposals(snapshot) + except Exception as error: + return (), self._run_error( + component_info=component_info, + position=position, + stage=stage, + error=error, + unexpected_message="component could not produce contract-valid proposals", + ) + return proposals, None + + @staticmethod + def _run_error( + component_info: ComponentInfo | None, + position: int, + stage: ErrorStage, + error: Exception, + unexpected_message: str, + ) -> RunError: + return RunError( + component_id=component_info.component_id if component_info is not None else "", + component_version=component_info.version if component_info is not None else "", + component_position=position, + stage=stage, + error_type=type(error).__name__, + message=unexpected_message, + ) + + @staticmethod + def _failed_result( + input_snapshot: DocumentSnapshot, + current_snapshot: DocumentSnapshot, + component_infos: tuple[ComponentInfo, ...], + changes: tuple[Change, ...], + errors: tuple[RunError, ...], + residual_proposals: tuple[ResidualProposal, ...] = (), + ) -> TransformResult: + return TransformResult( + status=RunStatus.FAILED, + input_sha256=input_snapshot.sha256, + current_sha256=current_snapshot.sha256, + components=component_infos, + changes=changes, + errors=errors, + residual_proposals=residual_proposals, + partial_markdown=current_snapshot.markdown, + ) diff --git a/src/mdpolish/py.typed b/src/mdpolish/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/mdpolish/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_component.py b/tests/test_component.py new file mode 100644 index 0000000..35ac51d --- /dev/null +++ b/tests/test_component.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import cast + +import pytest + +from mdpolish import Component, ComponentContractError, DocumentSnapshot, ProposedChange, TextEdit, TextSpan + + +class ExampleComponent(Component): + def __init__( + self, + *, + component_id: object = "test.example", + version: object = "1.2.3", + parameters: object = None, + applicability: object = "处理测试标记,要求精确匹配,排除所有其他内容。", + ) -> None: + self._component_id = component_id + self._version = version + self._parameters = {} if parameters is None else parameters + self._applicability = applicability + + @property + def component_id(self) -> str: + return cast(str, self._component_id) + + @property + def version(self) -> str: + return cast(str, self._version) + + @property + def parameters(self) -> Mapping[str, object]: + return cast(Mapping[str, object], self._parameters) + + @property + def applicability(self) -> str: + return cast(str, self._applicability) + + def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + if not snapshot.markdown: + return () + edit = TextEdit(snapshot.sha256, TextSpan(0, 1), snapshot.markdown[0], "X") + return (ProposedChange(snapshot.sha256, "replace first character", (edit,)),) + + +class ListReturningComponent(ExampleComponent): + def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + return cast(tuple[ProposedChange, ...], []) + + +class WrongValueComponent(ExampleComponent): + def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + return cast(tuple[ProposedChange, ...], ("wrong",)) + + +def test_component_metadata_is_validated_and_parameters_are_frozen_deterministically() -> None: + component = ExampleComponent( + parameters={ + "z": [1, {"b": False, "a": None}], + "a": "value", + } + ) + + info = component._component_info() + + assert info.component_id == "test.example" + assert info.version == "1.2.3" + assert info.parameters == ( + ("a", "value"), + ("z", (1, (("a", None), ("b", False)))), + ) + + +@pytest.mark.parametrize("component_id", ["", "Uppercase", "has space", "two..dots", "_leading"]) +def test_invalid_component_id_is_a_contract_error(component_id: str) -> None: + with pytest.raises(ComponentContractError, match="component_id"): + ExampleComponent(component_id=component_id)._component_info() + + +@pytest.mark.parametrize("version", ["1", "1.2", "v1.2.3", "01.2.3", "1.2.3-alpha"]) +def test_invalid_version_is_a_contract_error(version: str) -> None: + with pytest.raises(ComponentContractError, match=r"MAJOR.MINOR.PATCH"): + ExampleComponent(version=version)._component_info() + + +def test_empty_applicability_is_a_contract_error() -> None: + with pytest.raises(ComponentContractError, match="applicability"): + ExampleComponent(applicability=" \n")._component_info() + + +@pytest.mark.parametrize( + "parameters", + [ + {"bad": {1, 2}}, + {"bad": float("inf")}, + {"bad": float("nan")}, + {1: "non-string key"}, + ["not", "a", "mapping"], + ], +) +def test_unrepresentable_parameters_are_contract_errors(parameters: object) -> None: + with pytest.raises(ComponentContractError, match="parameter"): + ExampleComponent(parameters=parameters)._component_info() + + +def test_collect_proposals_requires_a_tuple_of_proposed_changes() -> None: + snapshot = DocumentSnapshot("abc") + + with pytest.raises(ComponentContractError, match="return a tuple"): + ListReturningComponent()._collect_proposals(snapshot) + with pytest.raises(ComponentContractError, match="only ProposedChange"): + WrongValueComponent()._collect_proposals(snapshot) + + +def test_component_exposes_no_public_check_or_transform_shortcut() -> None: + component = ExampleComponent() + + assert not hasattr(component, "check") + assert not hasattr(component, "transform") diff --git a/tests/test_edits.py b/tests/test_edits.py new file mode 100644 index 0000000..9f2558a --- /dev/null +++ b/tests/test_edits.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import pytest + +from mdpolish import ( + ComponentInfo, + DocumentSnapshot, + EditValidationError, + ProposedChange, + TextEdit, + TextSpan, + apply_component_batch, +) + +COMPONENT = ComponentInfo( + component_id="test.component", + version="1.2.3", + parameters=(), + applicability="测试精确文本编辑,只处理测试字符串,排除其他输入。", +) + + +def make_edit(snapshot: DocumentSnapshot, start: int, end: int, replacement: str) -> TextEdit: + return TextEdit( + snapshot_sha256=snapshot.sha256, + span=TextSpan(start, end), + expected_text=snapshot.markdown[start:end], + replacement=replacement, + ) + + +def make_proposal(snapshot: DocumentSnapshot, *edits: TextEdit, reason: str = "test reason") -> ProposedChange: + return ProposedChange(snapshot_sha256=snapshot.sha256, reason=reason, edits=edits) + + +def test_applies_insert_delete_and_replace() -> None: + insert_snapshot = DocumentSnapshot("ab") + delete_snapshot = DocumentSnapshot("abc") + replace_snapshot = DocumentSnapshot("abc") + + inserted = apply_component_batch( + insert_snapshot, + (make_proposal(insert_snapshot, make_edit(insert_snapshot, 1, 1, "X")),), + COMPONENT, + 0, + ) + deleted = apply_component_batch( + delete_snapshot, + (make_proposal(delete_snapshot, make_edit(delete_snapshot, 1, 2, "")),), + COMPONENT, + 0, + ) + replaced = apply_component_batch( + replace_snapshot, + (make_proposal(replace_snapshot, make_edit(replace_snapshot, 1, 2, "X")),), + COMPONENT, + 0, + ) + + assert inserted.snapshot.markdown == "aXb" + assert deleted.snapshot.markdown == "ac" + assert replaced.snapshot.markdown == "aXc" + + +def test_multiple_edits_apply_backwards_but_report_in_source_order() -> None: + snapshot = DocumentSnapshot("abcdef") + proposal = make_proposal( + snapshot, + make_edit(snapshot, 4, 6, "F"), + make_edit(snapshot, 0, 1, "A"), + reason="normalize two locations", + ) + + applied = apply_component_batch(snapshot, (proposal,), COMPONENT, 3) + + assert applied.snapshot.markdown == "AbcdF" + assert [change.span.start for change in applied.changes] == [0, 4] + assert {change.before_sha256 for change in applied.changes} == {snapshot.sha256} + assert {change.after_sha256 for change in applied.changes} == {applied.snapshot.sha256} + assert {change.proposal_ref for change in applied.changes} == { + applied.changes[0].proposal_ref, + } + assert {change.reason for change in applied.changes} == {"normalize two locations"} + assert [change.edit_index for change in applied.changes] == [1, 0] + assert all(change.component_position == 3 for change in applied.changes) + + +def test_adjacent_nonempty_ranges_are_allowed() -> None: + snapshot = DocumentSnapshot("abcd") + proposal = make_proposal( + snapshot, + make_edit(snapshot, 0, 2, "A"), + make_edit(snapshot, 2, 4, "D"), + ) + + applied = apply_component_batch(snapshot, (proposal,), COMPONENT, 0) + + assert applied.snapshot.markdown == "AD" + + +def test_overlapping_ranges_fail_without_changing_snapshot() -> None: + snapshot = DocumentSnapshot("abcdef") + proposal = make_proposal( + snapshot, + make_edit(snapshot, 1, 4, "X"), + make_edit(snapshot, 3, 5, "Y"), + ) + + with pytest.raises(EditValidationError, match="conflicting"): + apply_component_batch(snapshot, (proposal,), COMPONENT, 0) + + assert snapshot.markdown == "abcdef" + assert snapshot.sha256 == DocumentSnapshot("abcdef").sha256 + + +def test_duplicate_edits_fail_explicitly() -> None: + snapshot = DocumentSnapshot("abc") + edit = make_edit(snapshot, 0, 1, "A") + proposal = make_proposal(snapshot, edit, edit) + + with pytest.raises(EditValidationError, match="duplicate"): + apply_component_batch(snapshot, (proposal,), COMPONENT, 0) + + +def test_distinct_insert_points_are_allowed() -> None: + snapshot = DocumentSnapshot("abcd") + proposal = make_proposal( + snapshot, + make_edit(snapshot, 1, 1, "X"), + make_edit(snapshot, 3, 3, "Y"), + ) + + applied = apply_component_batch(snapshot, (proposal,), COMPONENT, 0) + + assert applied.snapshot.markdown == "aXbcYd" + + +def test_same_insert_point_conflicts() -> None: + snapshot = DocumentSnapshot("abc") + proposal = make_proposal( + snapshot, + make_edit(snapshot, 1, 1, "X"), + make_edit(snapshot, 1, 1, "Y"), + ) + + with pytest.raises(EditValidationError, match="conflicting"): + apply_component_batch(snapshot, (proposal,), COMPONENT, 0) + + +@pytest.mark.parametrize("insert_position", [1, 2, 3]) +def test_insert_at_start_inside_or_end_of_nonempty_range_conflicts(insert_position: int) -> None: + snapshot = DocumentSnapshot("abcd") + proposal = make_proposal( + snapshot, + make_edit(snapshot, 1, 3, "X"), + make_edit(snapshot, insert_position, insert_position, "Y"), + ) + + with pytest.raises(EditValidationError, match="conflicting"): + apply_component_batch(snapshot, (proposal,), COMPONENT, 0) + + +def test_stale_hash_out_of_range_and_expected_text_mismatch_fail() -> None: + original = DocumentSnapshot("abc") + current = DocumentSnapshot("abd") + stale = make_proposal(original, make_edit(original, 0, 1, "A")) + + with pytest.raises(EditValidationError, match="stale"): + apply_component_batch(current, (stale,), COMPONENT, 0) + + out_of_range_edit = TextEdit(current.sha256, TextSpan(2, 5), "dxx", "D") + out_of_range = make_proposal(current, out_of_range_edit) + with pytest.raises(EditValidationError, match="outside"): + apply_component_batch(current, (out_of_range,), COMPONENT, 0) + + mismatch_edit = TextEdit(current.sha256, TextSpan(0, 1), "z", "A") + mismatch = make_proposal(current, mismatch_edit) + with pytest.raises(EditValidationError, match="expected_text"): + apply_component_batch(current, (mismatch,), COMPONENT, 0) + + +def test_conflict_across_proposals_rejects_whole_component_batch() -> None: + snapshot = DocumentSnapshot("abcdef") + first = make_proposal(snapshot, make_edit(snapshot, 0, 3, "X"), reason="first") + second = make_proposal(snapshot, make_edit(snapshot, 2, 4, "Y"), reason="second") + + with pytest.raises(EditValidationError, match="conflicting"): + apply_component_batch(snapshot, (first, second), COMPONENT, 0) + + assert snapshot.markdown == "abcdef" + + +def test_empty_component_batch_keeps_same_snapshot_and_records_nothing() -> None: + snapshot = DocumentSnapshot("abc") + + applied = apply_component_batch(snapshot, (), COMPONENT, 0) + + assert applied.snapshot is snapshot + assert applied.changes == () diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..d63b23d --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from hashlib import sha256 + +import pytest + +from mdpolish import ( + DocumentSnapshot, + ErrorStage, + ProposedChange, + ResidualProposal, + RunError, + RunStatus, + TextEdit, + TextSpan, + TransformResult, +) +from mdpolish.models import ProposalReference + + +def test_snapshot_preserves_exact_markdown_and_computes_hash() -> None: + markdown = "标题\r\nCafe\u0301\n🙂\n" + + snapshot = DocumentSnapshot(markdown) + + assert snapshot.markdown == markdown + assert snapshot.sha256 == sha256(markdown.encode("utf-8")).hexdigest() + + +def test_empty_snapshot_is_valid_and_hash_cannot_be_supplied() -> None: + snapshot = DocumentSnapshot("") + + assert snapshot.sha256 == sha256(b"").hexdigest() + with pytest.raises(TypeError): + DocumentSnapshot("", sha256="0" * 64) # type: ignore[call-arg] + + +def test_snapshot_is_frozen() -> None: + snapshot = DocumentSnapshot("original") + + with pytest.raises(FrozenInstanceError): + snapshot.markdown = "changed" # type: ignore[misc] + + +@pytest.mark.parametrize( + ("start", "end", "error_type"), + [ + (-1, 0, ValueError), + (2, 1, ValueError), + (True, 1, TypeError), + ], +) +def test_span_rejects_invalid_indexes(start: int, end: int, error_type: type[Exception]) -> None: + with pytest.raises(error_type): + TextSpan(start, end) + + +def test_text_edit_supports_insert_delete_and_replace() -> None: + snapshot = DocumentSnapshot("中文abc") + + insertion = TextEdit(snapshot.sha256, TextSpan(2, 2), "", "!") + deletion = TextEdit(snapshot.sha256, TextSpan(2, 3), "a", "") + replacement = TextEdit(snapshot.sha256, TextSpan(3, 5), "bc", "BC") + + assert insertion.span.is_empty + assert deletion.replacement == "" + assert replacement.expected_text == "bc" + + +def test_text_edit_rejects_bad_digest_length_mismatch_and_no_op() -> None: + digest = DocumentSnapshot("abc").sha256 + + with pytest.raises(ValueError, match="SHA-256"): + TextEdit("bad", TextSpan(0, 1), "a", "b") + with pytest.raises(ValueError, match="length"): + TextEdit(digest, TextSpan(0, 2), "a", "b") + with pytest.raises(ValueError, match="must change"): + TextEdit(digest, TextSpan(0, 1), "a", "a") + + +def test_proposal_requires_reason_edits_and_one_matching_digest() -> None: + snapshot = DocumentSnapshot("abc") + other = DocumentSnapshot("xyz") + edit = TextEdit(snapshot.sha256, TextSpan(0, 1), "a", "A") + other_edit = TextEdit(other.sha256, TextSpan(0, 1), "x", "X") + + with pytest.raises(ValueError, match="reason"): + ProposedChange(snapshot.sha256, " ", (edit,)) + with pytest.raises(ValueError, match="non-empty tuple"): + ProposedChange(snapshot.sha256, "reason", ()) + with pytest.raises(ValueError, match="proposal digest"): + ProposedChange(snapshot.sha256, "reason", (other_edit,)) + + +def test_transform_result_enforces_status_specific_output_fields() -> None: + snapshot = DocumentSnapshot("abc") + error = RunError("component", "1.0.0", 0, ErrorStage.TRANSFORM, "ExampleError", "safe") + edit = TextEdit(snapshot.sha256, TextSpan(0, 1), "a", "A") + proposal = ProposedChange(snapshot.sha256, "reason", (edit,)) + residual = ResidualProposal( + component_id="component", + component_version="1.0.0", + component_position=0, + proposal_ref=ProposalReference(0, snapshot.sha256, 0), + proposal=proposal, + ) + + success = TransformResult( + status=RunStatus.SUCCESS, + input_sha256=snapshot.sha256, + current_sha256=snapshot.sha256, + output_markdown=snapshot.markdown, + ) + failed = TransformResult( + status=RunStatus.FAILED, + input_sha256=snapshot.sha256, + current_sha256=snapshot.sha256, + errors=(error,), + partial_markdown=snapshot.markdown, + ) + unstable = TransformResult( + status=RunStatus.UNSTABLE, + input_sha256=snapshot.sha256, + current_sha256=snapshot.sha256, + residual_proposals=(residual,), + partial_markdown=snapshot.markdown, + ) + + assert success.output_markdown == "abc" + assert failed.partial_markdown == "abc" + assert unstable.residual_proposals == (residual,) + + with pytest.raises(ValueError, match="successful result"): + TransformResult( + status=RunStatus.SUCCESS, + input_sha256=snapshot.sha256, + current_sha256=snapshot.sha256, + errors=(error,), + output_markdown=snapshot.markdown, + ) + with pytest.raises(ValueError, match="failed result"): + TransformResult( + status=RunStatus.FAILED, + input_sha256=snapshot.sha256, + current_sha256=snapshot.sha256, + partial_markdown=snapshot.markdown, + ) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..8919ef3 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import cast + +import pytest + +from mdpolish import ( + Component, + DocumentSnapshot, + ErrorStage, + Pipeline, + ProposedChange, + RunStatus, + TextEdit, + TextSpan, +) + + +class ReplaceComponent(Component): + def __init__( + self, + needle: str, + replacement: str, + *, + component_id: str, + version: str = "1.0.0", + parameters: object = None, + applicability: str = "处理精确测试字符串,要求完整匹配,排除其他内容。", + ) -> None: + self.needle = needle + self.replacement = replacement + self._component_id = component_id + self._version = version + self._parameters = {"needle": needle, "replacement": replacement} if parameters is None else parameters + self._applicability = applicability + + @property + def component_id(self) -> str: + return self._component_id + + @property + def version(self) -> str: + return self._version + + @property + def parameters(self) -> Mapping[str, object]: + return cast(Mapping[str, object], self._parameters) + + @property + def applicability(self) -> str: + return self._applicability + + def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + position = snapshot.markdown.find(self.needle) + if position < 0: + return () + edit = TextEdit( + snapshot_sha256=snapshot.sha256, + span=TextSpan(position, position + len(self.needle)), + expected_text=self.needle, + replacement=self.replacement, + ) + return ( + ProposedChange( + snapshot_sha256=snapshot.sha256, + reason=f"replace test token for {self.component_id}", + edits=(edit,), + ), + ) + + +class ExplodingComponent(ReplaceComponent): + def __init__(self, *, trigger: str | None = None, component_id: str = "test.exploding") -> None: + super().__init__("unused", "unused-replacement", component_id=component_id) + self.trigger = trigger + + def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + if self.trigger is None or snapshot.markdown == self.trigger: + raise RuntimeError(f"SECRET source: {snapshot.markdown}") + return () + + +class InvalidReturnComponent(ReplaceComponent): + def __init__(self) -> None: + super().__init__("a", "A", component_id="test.invalid-return") + + def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + return cast(tuple[ProposedChange, ...], []) + + +class StaleProposalComponent(ReplaceComponent): + def __init__(self) -> None: + super().__init__("a", "A", component_id="test.stale") + + def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + stale_snapshot = DocumentSnapshot(snapshot.markdown + "!") + edit = TextEdit(stale_snapshot.sha256, TextSpan(0, 1), stale_snapshot.markdown[0], "X") + return (ProposedChange(stale_snapshot.sha256, "stale test proposal", (edit,)),) + + +class OverlapComponent(ReplaceComponent): + def __init__(self) -> None: + super().__init__("a", "A", component_id="test.overlap") + + def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + first = TextEdit(snapshot.sha256, TextSpan(0, 3), snapshot.markdown[0:3], "X") + second = TextEdit(snapshot.sha256, TextSpan(2, 4), snapshot.markdown[2:4], "Y") + return (ProposedChange(snapshot.sha256, "overlapping test proposal", (first, second)),) + + +def test_empty_pipeline_returns_unchanged_success_for_empty_unicode_text() -> None: + for markdown in ("", "中文\nCafe\u0301\n🙂"): + result = Pipeline([]).transform(markdown) + + assert result.status is RunStatus.SUCCESS + assert result.output_markdown == markdown + assert result.partial_markdown is None + assert result.changes == () + assert result.components == () + + +def test_later_component_reads_snapshot_produced_by_earlier_component() -> None: + pipeline = Pipeline( + [ + ReplaceComponent("初", "中", component_id="test.first"), + ReplaceComponent("中", "终", component_id="test.second"), + ] + ) + + result = pipeline.transform("初") + + assert result.status is RunStatus.SUCCESS + assert result.output_markdown == "终" + assert [change.component_id for change in result.changes] == ["test.first", "test.second"] + assert result.changes[1].before_sha256 == result.changes[0].after_sha256 + + +def test_same_input_components_and_parameters_produce_same_ordered_result() -> None: + pipeline = Pipeline( + [ + ReplaceComponent("a", "b", component_id="test.first"), + ReplaceComponent("b", "c", component_id="test.second"), + ] + ) + + assert pipeline.transform("a") == pipeline.transform("a") + + +def test_successful_pipeline_is_idempotent_on_its_output() -> None: + pipeline = Pipeline([ReplaceComponent("old", "new", component_id="test.replace")]) + + first = pipeline.transform("old value") + assert first.status is RunStatus.SUCCESS + assert first.output_markdown is not None + + second = pipeline.transform(first.output_markdown) + + assert second.status is RunStatus.SUCCESS + assert second.output_markdown == "new value" + assert second.changes == () + + +def test_single_component_runs_through_pipeline_without_shortcut() -> None: + component = ReplaceComponent("a", "A", component_id="test.single") + + result = Pipeline([component]).transform("a") + + assert result.status is RunStatus.SUCCESS + assert result.output_markdown == "A" + assert len(result.changes) == 1 + + +def test_transform_error_stops_later_components_and_keeps_only_partial_text() -> None: + pipeline = Pipeline( + [ + ReplaceComponent("a", "b", component_id="test.first"), + ExplodingComponent(), + ReplaceComponent("b", "c", component_id="test.never-runs"), + ] + ) + + result = pipeline.transform("a") + + assert result.status is RunStatus.FAILED + assert result.output_markdown is None + assert result.partial_markdown == "b" + assert [change.component_id for change in result.changes] == ["test.first"] + assert len(result.errors) == 1 + assert result.errors[0].stage is ErrorStage.TRANSFORM + assert result.residual_proposals == () + + +def test_unexpected_component_error_does_not_leak_source_or_exception_message() -> None: + result = Pipeline([ExplodingComponent()]).transform("private markdown") + + assert result.status is RunStatus.FAILED + assert result.errors[0].error_type == "RuntimeError" + assert "SECRET" not in result.errors[0].message + assert "private markdown" not in result.errors[0].message + + +def test_invalid_proposal_return_is_a_transform_contract_failure() -> None: + result = Pipeline([InvalidReturnComponent()]).transform("abc") + + assert result.status is RunStatus.FAILED + assert result.partial_markdown == "abc" + assert result.errors[0].error_type == "ComponentContractError" + assert result.errors[0].stage is ErrorStage.TRANSFORM + + +@pytest.mark.parametrize("component", [StaleProposalComponent(), OverlapComponent()]) +def test_invalid_edit_batch_fails_atomically(component: Component) -> None: + result = Pipeline([component]).transform("abcd") + + assert result.status is RunStatus.FAILED + assert result.partial_markdown == "abcd" + assert result.changes == () + assert result.errors[0].error_type == "EditValidationError" + + +def test_duplicate_component_ids_fail_during_preflight_before_modification() -> None: + pipeline = Pipeline( + [ + ReplaceComponent("a", "b", component_id="test.duplicate"), + ReplaceComponent("b", "c", component_id="test.duplicate"), + ] + ) + + result = pipeline.transform("a") + + assert result.status is RunStatus.FAILED + assert result.partial_markdown == "a" + assert result.changes == () + assert result.errors[0].error_type == "PipelineContractError" + + +@pytest.mark.parametrize( + "component", + [ + ReplaceComponent("a", "b", component_id="test.bad-version", version="1.0"), + ReplaceComponent("a", "b", component_id="test.bad-parameters", parameters={"bad": {1}}), + ReplaceComponent("a", "b", component_id="test.bad-applicability", applicability=""), + ], +) +def test_invalid_component_metadata_fails_before_modification(component: Component) -> None: + result = Pipeline([component]).transform("a") + + assert result.status is RunStatus.FAILED + assert result.partial_markdown == "a" + assert result.changes == () + assert result.errors[0].stage is ErrorStage.TRANSFORM + + +def test_cross_component_chain_is_reported_unstable_without_a_second_round() -> None: + pipeline = Pipeline( + [ + ReplaceComponent("bad", "good", component_id="test.to-good"), + ReplaceComponent("good", "bad", component_id="test.to-bad"), + ] + ) + + result = pipeline.transform("bad") + + assert result.status is RunStatus.UNSTABLE + assert result.output_markdown is None + assert result.partial_markdown == "bad" + assert len(result.changes) == 2 + assert len(result.residual_proposals) == 1 + assert result.residual_proposals[0].component_id == "test.to-good" + assert result.errors == () + + +def test_final_review_continues_after_error_and_keeps_valid_residual_proposal() -> None: + pipeline = Pipeline( + [ + ExplodingComponent(trigger="done", component_id="test.review-error"), + ReplaceComponent("done", "clean", component_id="test.residual"), + ReplaceComponent("start", "done", component_id="test.producer"), + ] + ) + + result = pipeline.transform("start") + + assert result.status is RunStatus.FAILED + assert result.output_markdown is None + assert result.partial_markdown == "done" + assert len(result.errors) == 1 + assert result.errors[0].stage is ErrorStage.FINAL_REVIEW + assert result.errors[0].component_id == "test.review-error" + assert len(result.residual_proposals) == 1 + assert result.residual_proposals[0].component_id == "test.residual" + assert result.residual_proposals[0].proposal_ref.snapshot_sha256 == result.current_sha256