From 8b689eebe10b1bd83ddb0280b42051cd73aa64bf Mon Sep 17 00:00:00 2001 From: Bepr4 <63661977@qq.com> Date: Fri, 28 Aug 2026 14:42:11 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=E5=8D=8F=E4=BD=9C?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=B9=B6=E6=89=B9=E5=87=86=E7=89=A9=E7=90=86?= =?UTF-8?q?=E8=A1=8C=E6=8E=A5=E5=8F=A3=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 + CLAUDE.md | 3 + research-wiki/README.md | 2 +- .../0013-public-physical-line-ranges.md | 451 ++++++++++++++++++ src/mdpolish/regex.py | 36 +- 5 files changed, 492 insertions(+), 3 deletions(-) create mode 100644 research-wiki/design/0013-public-physical-line-ranges.md diff --git a/AGENTS.md b/AGENTS.md index 9d355be..22d61a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,9 @@ 本仓库的研究结论不会自动成为其他仓库的生产契约。跨仓落地必须在目标仓库重新评审并获得授权。 +`/home/lihaoze/work/mdpolish-wheel-pilot` 是当前独立的项目端 wheel 消费测试仓库;除非用户明确授权,不要将其文件、 +测试数据或项目职责并入本仓库。 + ## 2. 数据与外部材料 已知外部真实材料位于 `/home/lihaoze/gov_test_data`。除非用户另行明确授权,执行以下边界: diff --git a/CLAUDE.md b/CLAUDE.md index e858e15..ac6d860 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,9 @@ 本仓库的研究结论不会自动成为其他仓库的生产契约。跨仓落地必须在目标仓库重新评审并获得授权。 +`/home/lihaoze/work/mdpolish-wheel-pilot` 是当前独立的项目端 wheel 消费测试仓库;除非用户明确授权,不要将其文件、 +测试数据或项目职责并入本仓库。 + ## 2. 数据与外部材料 已知外部真实材料位于 `/home/lihaoze/gov_test_data`。除非用户另行明确授权,执行以下边界: diff --git a/research-wiki/README.md b/research-wiki/README.md index 67925c7..6e6353c 100644 --- a/research-wiki/README.md +++ b/research-wiki/README.md @@ -49,7 +49,7 @@ 3. 目标与非目标; 4. 候选方案; 5. 决定与理由; -6. 风险和边界; +6. 施工范围目录; 7. 实施与验收。 草稿可以在评审期间修改。批准后冻结;如果决策改变,新文档必须写明 `supersedes: NNNN`,并保留旧文档。 diff --git a/research-wiki/design/0013-public-physical-line-ranges.md b/research-wiki/design/0013-public-physical-line-ranges.md new file mode 100644 index 0000000..5709694 --- /dev/null +++ b/research-wiki/design/0013-public-physical-line-ranges.md @@ -0,0 +1,451 @@ +# 0013:公开精确物理行范围接口 + +## 状态 + +已于 2026-08-28 获用户明确批准,按本文第 13 节实施。本文自批准起冻结;后续改变决策需新增 design 并使用 +`supersedes` 指向本文。 + +`extends: 0008`:继续使用函数式 `Modifier`、不可变值、精确原文范围、无文件 I/O 和项目规则外置的公共库边界;本文只把 +已经由多个通用 Modifier 共用的物理行范围工具变成受支持的扩展接口。 + +`extends: 0009`:`mapped_line_join()` 的 Markdown 块分类、代码/表格排除和跨行合并语义保持不变;本文只公开它已经使用的 +底层物理行扫描,不公开其保守块扫描器。 + +本文不改变 `0012` 的机器投影 schema、detail 或报告行为。 + +## 1. 问题与可观察现象 + +项目自定义 Modifier 最终要提交精确 `TextEdit`。只要规则依赖“完整独占一行”“删除自己的换行”“检查下一空行”或“聚合 +重复行”,项目就必须先准确回答: + +- 这一行正文从哪里开始、在哪里结束; +- 行尾是 LF、CR、CRLF,还是文档末尾没有换行; +- 空行是否真的有零长度内容; +- 删除范围是否包含行尾; +- 下一条物理行在原始字符串中的准确位置是什么。 + +上游已经在私有模块 `mdpolish._text_ranges` 中实现: + +```python +PhysicalLine +iter_physical_lines() +physical_lines() +line_ending_styles() +``` + +`mapped_line_join()` 使用它建立跨行候选,`html_table_layout()` 使用它检查混合换行。独立 wheel-pilot 的论文清洗设计又需要 +同样的 LF、CRLF、CR、空行、无末尾换行和原文范围语义,但项目治理正确地禁止导入带下划线的私有模块,只能计划复制一份 +扫描器。 + +这已经不是“某个项目想少写几行代码”。同一个确定性边界算法出现在上游多个通用 Modifier,并被独立消费项目再次需要。 +如果项目复制实现,以下细节很容易漂移: + +- 把 CRLF 错当成两个换行; +- 在末尾换行之后制造一条不存在的空行; +- 把只有空格的行和零长度空行混为一谈; +- 删除内容却遗留 `\r`; +- 用累计字符串长度重新计算 offset 时产生偏移; +- 上游修复边界后,项目副本继续使用旧语义。 + +因此,缺少的是“项目 Modifier 作者可以合法复用的精确物理行范围”,不是新的清洗规则或 Markdown parser。 + +## 2. 目标与非目标 + +### 2.1 目标 + +- 提供受支持的公共模块 `mdpolish.text_ranges`; +- 公开不可变 `PhysicalLine` 和惰性/急切两种物理行扫描入口; +- 精确保留 LF、CRLF、CR 和无末尾换行,不规范化源字符串; +- 公开已存在的行尾样式集合查询; +- 在首次公开前修正空行判断的误导命名,并明确零长度与空白字符的区别; +- 给公共值对象和函数增加显式类型、范围和来源切片校验; +- 让上游现有 Modifier 使用同一公共实现,不维护私有/公共两套算法; +- 用合成文本直接固定空文档、空行、Unicode、混合换行和范围语义; +- 保持核心安装零第三方运行依赖,无文件 I/O、网络或模型调用。 + +### 2.2 非目标 + +- 不在 Pipeline 运行 Modifier 前自动扫描、分块或预处理 Markdown; +- 不缓存或跨 Modifier 共享扫描结果;后一个 Modifier 仍读取前一阶段应用后的完整新快照; +- 不解析段落、标题、列表、引用、代码块、表格、HTML 或 Markdown AST; +- 不公开 `mapped_line_join()` 内部的 `_ScannedLine`、scope、结构排除或块分类器; +- 不提供 `remove_matching_lines()`、行号删除器、页眉删除器、批注删除器或其他 Modifier 工厂; +- 不定义“只含空格或 Tab 的行是否算 blank”,也不使用 Unicode `strip()` 暗中替项目作决定; +- 不读取文件、不接收路径、不返回文件行号或改写文本; +- 不把 range 自动转成 `TextEdit`,不绕过快照哈希、expected text 和批次验证; +- 不修改 wheel-pilot、真实论文、外部数据或项目 design; +- 不承诺通用文本编辑器、LSP、UTF-16 或 byte offset 接口。 + +## 3. 方案比较 + +| 方案 | 优点 | 问题 | 选择 | +| --- | --- | --- | --- | +| 每个项目复制扫描器 | 上游不增加接口 | CRLF、尾换行和空行语义会漂移;多个项目重复测试 | 不采用 | +| 项目直接导入 `mdpolish._text_ranges` | 零上游改动 | 私有路径没有兼容承诺,wheel 消费方被迫依赖内部实现 | 不采用 | +| 把当前私有模块原样改成公共路径 | 改动最小 | `is_blank(markdown)` 命名和参数误导;没有直接公共契约测试或范围校验 | 不直接采用 | +| 公开经过收窄和加固的精确物理行范围 | 一个算法来源;项目规则保持外置;接口小而可测试 | 增加需要兼容维护的公共模块 | 采用 | +| 引入统一 Markdown parser / AST | 可共享高级结构 | 远超当前需求,并会改变所有 Modifier 的共同边界 | 不采用 | +| Pipeline 预扫描并把行列表传给所有 Modifier | 避免重复扫描 | 改变 `ModifierFunction` 签名;快照每阶段变化使缓存身份复杂 | 不采用 | + +## 4. 职责边界 + +采用后的调用关系为: + +```text +完整 DocumentSnapshot + │ + ▼ +项目 Modifier.propose(snapshot) + │ + │ 只有该 Modifier 需要时调用 + ▼ +physical_lines(snapshot.markdown) + │ + ▼ +PhysicalLine 原文范围 + │ + │ 项目判断业务证据 + ▼ +ProposedChange / TextEdit + │ + ▼ +公共执行器验证并原子应用 +``` + +| 层次 | 负责 | 不负责 | +| --- | --- | --- | +| `text_ranges` | 确定物理行内容与行尾范围 | 判断行的业务含义 | +| 项目 Modifier | 根据项目证据选择范围和替换 | 重写 CR/LF 扫描算法 | +| 编辑执行器 | 验证快照、范围、原文、冲突并应用 | 判断为什么要删除一行 | +| Pipeline | 按顺序运行并稳定性复查 | 建立全局共享分块 | + +公开接口不会在未调用时执行,也不会改变传给第一个 Modifier 的原始 Markdown。 + +## 5. 第一版公共模块 + +第一版受支持的导入路径是: + +```python +from mdpolish.text_ranges import ( + PhysicalLine, + iter_physical_lines, + line_ending_styles, + physical_lines, +) +``` + +这些名称只从 `mdpolish.text_ranges` 导出。本轮不把它们再放进 `mdpolish.__init__`,避免包根继续堆积低层工具。 + +私有路径 `mdpolish._text_ranges` 从来不是公共契约。实现时把唯一算法移动到公共模块并更新上游内部导入,不保留另一份算法, +也不为未受支持的私有导入建立长期兼容别名。 + +## 6. `PhysicalLine` 公共值对象 + +### 6.1 字段 + +```python +@dataclass(frozen=True, slots=True) +class PhysicalLine: + """一个源字符串中物理行内容及可选行尾的精确范围。""" + + content_start: int + content_end: int + full_end: int +``` + +范围均使用 Python 字符串的 Unicode code point index: + +```text +content_start content_end full_end + │ │ │ + ▼ ▼ ▼ + one physical line content\r\n + └──────── content ───────┘└ line ending ┘ + └──────────── complete physical line ────┘ +``` + +- 完整物理行范围是 `[content_start, full_end)`; +- 内容范围是 `[content_start, content_end)`; +- 行尾范围是 `[content_end, full_end)`; +- 最后一行没有行尾时 `content_end == full_end`; +- `content_start` 同时是完整物理行的开始,不另设重复字段; +- offset 不是 UTF-8 byte、UTF-16 code unit、终端显示列或 1-based 人类行列。 + +### 6.2 构造校验 + +`__post_init__()` 必须显式拒绝: + +- 字段不是严格 `int`,包括 `bool`; +- 任一字段为负数; +- 不满足 `content_start <= content_end <= full_end`; +- `full_end - content_end > 2`,因为受支持的物理行尾最长是 CRLF 两个 code point。 + +这些检查不能证明手工构造的范围一定来自某个 source;它们只保证值对象局部自洽。权威正常用法是消费扫描函数返回的对象。 + +### 6.3 内容和行尾访问 + +```python +def content(self, source: str) -> str: + ... + +def line_ending(self, source: str) -> str: + ... +``` + +两个方法都必须: + +- 要求 `source` 是 `str`; +- 确认 `full_end <= len(source)`; +- 根据保存的半开范围返回精确切片,不规范化或复制其他字符。 + +`line_ending()` 还要确认切片严格属于: + +```python +"" +"\n" +"\r" +"\r\n" +``` + +否则抛出 `ValueError`,不把手工构造的错误范围静默当作合法行尾。正常扫描结果不会触发这个错误。 + +范围对象不保存 source 或快照哈希。这样保留单个 `PhysicalLine` 不会隐式持有整篇文档,也不会在每条行对象中重复相同哈希。 +项目最终建立 `TextEdit` 时,现有 snapshot SHA-256 和 expected text 校验仍负责拒绝跨快照范围。 + +### 6.4 零长度行判断 + +当前私有方法: + +```python +line.is_blank(markdown) +``` + +完全没有读取 `markdown`,实际只判断 `content_start == content_end`。公开前改成只读属性: + +```python +@property +def is_empty(self) -> bool: + return self.content_start == self.content_end +``` + +它的语义固定为“内容范围长度为零”: + +| 原始物理行 | `is_empty` | +| --- | ---: | +| `"\n"` | `True` | +| `"\r\n"` | `True` | +| `""` | 不会由空文档产生 PhysicalLine | +| `" \n"` | `False` | +| `"\t\n"` | `False` | + +本轮不保留或公开 `is_blank` 别名。它原来属于私有模块,没有公共兼容承诺;继续保留会同时留下误导名称和无用 source 参数。 +项目若要把 ASCII space / Tab 行定义为空白,应明确写出自己的条件,例如: + +```python +line.content(source).strip(" \t") == "" +``` + +是否允许其他 Unicode whitespace 属于项目规则,不能由底层工具默认扩大。 + +## 7. 扫描函数 + +### 7.1 惰性扫描 + +```python +def iter_physical_lines(source: str) -> Iterator[PhysicalLine]: + ... +``` + +- 调用时先验证 `source` 是 `str`,非法类型立即抛出 `TypeError`,不等到第一次迭代才暴露; +- 单次从左到右扫描,不预先复制 source; +- 只把 `\n`、`\r`、`\r\n` 识别为行尾; +- CRLF 是一个物理行尾,范围长度为两个 code point; +- Unicode line separator、paragraph separator、vertical tab、form feed 和 NUL 都保留在内容中; +- 产生顺序严格按 source offset 递增,范围不重叠且完整覆盖 source; +- 返回迭代器,不承诺可以重复迭代同一对象。 + +为保证非法输入在函数调用时立即失败,实现可以使用普通包装函数返回私有 generator;公共签名和可观察行为以上述契约为准。 + +### 7.2 急切扫描 + +```python +def physical_lines(source: str) -> tuple[PhysicalLine, ...]: + ... +``` + +它返回 `iter_physical_lines()` 的完整不可变 tuple,不另写扫描逻辑。适合需要前后行、重复组或多次遍历的 Modifier。 + +### 7.3 行尾样式 + +```python +def line_ending_styles(source: str) -> frozenset[str]: + ... +``` + +它返回扫描结果中实际出现的非空行尾集合,只可能包含: + +```python +"\n" +"\r" +"\r\n" +``` + +无换行或空文档返回空 `frozenset`。它复用 `iter_physical_lines()`,不维护另一份 `splitlines()` 逻辑。 + +## 8. 精确边界示例 + +以下 tuple 写作 `(content_start, content_end, full_end)`: + +| source | 扫描结果 | +| --- | --- | +| `""` | `()` | +| `"a"` | `((0, 1, 1),)` | +| `"a\n"` | `((0, 1, 2),)` | +| `"\n"` | `((0, 0, 1),)` | +| `"\r\n"` | `((0, 0, 2),)` | +| `"a\n\n"` | `((0, 1, 2), (2, 2, 3))` | +| `"\ntext"` | `((0, 0, 1), (1, 5, 5))` | +| `"a\r\nb\rc\n"` | `((0, 1, 3), (3, 4, 5), (5, 6, 7))` | +| `"a\u2028b"` | `((0, 3, 3),)`,U+2028 属于内容 | + +特别说明: + +- 末尾有行尾不额外产生一条虚构空行; +- `"a\n\n"` 的第二个 `\n` 是一条真实零长度物理行; +- 空文档没有任何物理行; +- 拼接所有 `[content_start, full_end)` 切片必须精确还原 source。 + +这些行为保持当前私有扫描算法的结果,不改变现有 Modifier 的换行语义。 + +## 9. 与 Markdown 和编辑契约的关系 + +`text_ranges` 不根据 Markdown 语义改变任何范围。例如: + +````markdown +# Heading + +> quote + +```text +code +``` +```` + +对它来说都只是物理行。标题、引用和围栏只有调用它的 Modifier 才能解释。 + +范围也不自动成为修改。项目仍必须构造: + +```python +TextEdit( + snapshot_sha256=snapshot.sha256, + span=TextSpan(line.content_start, line.full_end), + expected_text=snapshot.markdown[line.content_start : line.full_end], + replacement="", +) +``` + +执行器随后验证哈希、范围和原文。物理行接口不能跳过这层保护,也不承诺手工构造的 `PhysicalLine` 自动安全。 + +## 10. 源码、兼容与版本 + +批准后计划: + +```text +src/mdpolish/ +├── text_ranges.py # 公共唯一实现 +└── modifiers/ + ├── mapped_line_join.py # 改用公共模块路径 + └── html_table_layout.py # 改用公共模块路径 + +tests/ +└── test_text_ranges.py # 公共接口直接测试 + +research-wiki/reference/ +└── physical-line-ranges.md # 稳定边界查询口径 +``` + +实现时删除私有源码文件 `_text_ranges.py`,不保留双份算法。`mdpolish._text_ranges` 从未受支持;本轮不为私有导入提供弃用期。 +现有公开的 `Modifier`、`Pipeline`、`TextSpan`、`mapped_line_join()` 和 HTML Modifier 签名、身份、参数、输出及审计行为不变。 + +这是新增公共扩展接口,计划包版本为 `0.6.0`。`v0.5.0` 的机器投影保持独立交付范围;如果 `v0.5.0` 尚未完成合并和发布, +本功能仍使用单独提交与验证记录,不把两个功能写成同一项能力。 + +批准后实现完成并验证时才更新根 README 的当前能力、候选版本和实际检查结果。设计批准不自动授权 commit、push、tag、 +GitHub Release 或 wheel 发布。 + +## 11. 测试与验收 + +### 11.1 公共扫描契约 + +直接测试至少覆盖: + +- 第 8 节所有精确范围; +- 空文档、单行、首行为空、连续空行和无末尾换行; +- 纯 LF、纯 CRLF、纯 CR 和混合行尾; +- 中文、补充平面字符、组合字符、BOM、U+2028、NUL、Tab 和 form feed; +- 每条 `content()`、`line_ending()` 与 source 精确切片一致; +- 所有完整行切片按顺序拼接后逐 code point 等于原 source; +- `physical_lines(source) == tuple(iter_physical_lines(source))`; +- `line_ending_styles()` 只返回实际非空样式; +- `is_empty` 只对零长度内容为真,space / Tab 不自动算空; +- 相同 source 重复扫描得到值相等、顺序相同的不可变结果。 + +### 11.2 失败关闭 + +至少拒绝: + +- 扫描函数接收非 `str`,包括 bytes、Path 和 `None`; +- dataclass 字段为 `bool`、float、字符串或其他非严格 int; +- 负数、逆序范围和超过两个 code point 的行尾范围; +- `content()` / `line_ending()` 接收非字符串或比 `full_end` 更短的 source; +- `line_ending()` 指向不是 `""` / LF / CR / CRLF 的切片。 + +错误使用 `TypeError` 或 `ValueError`,消息只说明字段和契约,不输出 source 内容。 + +### 11.3 现有行为回归 + +- `mapped_line_join()` 全部精确、正则、词典、结构、换行和链式测试结果不变; +- `html_table_layout()` 的单/混合行尾行为不变; +- 其他核心、评审和机器投影测试不受影响; +- mypy strict 和 Ruff 通过; +- 核心运行依赖仍为空。 + +### 11.4 Wheel 消费 + +构建候选 wheel 后在仓库外全新虚拟环境确认: + +```python +from mdpolish.text_ranges import PhysicalLine, iter_physical_lines, line_ending_styles, physical_lines +``` + +可以导入并得到第 8 节结果。wheel 必须包含 `mdpolish/text_ranges.py` 和 `py.typed`,不包含 tests、Wiki、真实数据或报告。 + +本轮不修改 wheel-pilot。它升级正式 wheel、删除私有扫描计划和验证四个项目 Modifier,仍需在消费仓自己的未冻结 design 中 +调整并获得授权。 + +## 12. 风险与代价 + +- **公共表面积增加:** range 字段、空文档和尾换行语义发布后需要兼容维护;直接测试和 reference 用于锁定行为。 +- **低层工具可能被误认为 Markdown parser:** 模块、README 和 docstring 都要明确它只识别 CR/LF,不解释块结构。 +- **每个 Modifier 可能重复 O(n) 扫描:** 当前快照会逐阶段变化,简单重扫比跨阶段缓存更可靠;有真实性能证据后再设计共享索引。 +- **手工范围仍可能来自错误 source:** 访问器做局部验证,最终 `TextEdit` 的快照和 expected text 继续承担权威保护。 +- **删除私有路径可能影响越界使用者:** 下划线路径从未承诺兼容;已知正式消费项目明确没有导入它。 +- **`is_empty` 不等于 Markdown blank line:** 这是有意边界;项目必须显式定义 space、Tab 或 Unicode whitespace。 +- **新版本依赖发布顺序:** wheel-pilot 只有安装含公共模块的正式 wheel 后才能删除自己的扫描计划,不能从相邻源码树偷导入。 + +## 13. 批准后的实施边界 + +用户明确批准本文后,只授权: + +1. 把私有物理行唯一实现迁移到 `mdpolish.text_ranges`,实现第 5 至 8 节公共契约; +2. 更新两个现有通用 Modifier 的内部导入和 `is_empty` 调用,不改变其清洗语义; +3. 增加第 11 节合成测试、reference 和 README 说明; +4. 把包版本候选更新为 `0.6.0`,执行根 README 当时列出的全套检查和 wheel smoke test; +5. 保留并隔离工作区已有的 `AGENTS.md`、`CLAUDE.md`、`src/mdpolish/regex.py` 等用户改动。 + +批准本文不授权: + +- 提交、push、创建 PR、tag、GitHub Release 或上传 wheel; +- 修改 wheel-pilot、真实文档、外部数据或其他仓库; +- 新增 Markdown parser、全局预处理、共享扫描缓存、通用删除 Modifier、CLI 或文件适配器; +- 改变现有清洗规则、Pipeline 顺序、机器投影 schema 或报告内容。 diff --git a/src/mdpolish/regex.py b/src/mdpolish/regex.py index c5261ad..3420a14 100644 --- a/src/mdpolish/regex.py +++ b/src/mdpolish/regex.py @@ -1,4 +1,4 @@ -"""Safe factory for exact regular-expression replacements.""" +"""把非空正则匹配翻译成精确编辑的修改器工厂。""" from __future__ import annotations @@ -7,7 +7,10 @@ import re from mdpolish.models import DocumentSnapshot, ProposedChange, TextEdit, TextSpan from mdpolish.modifier import Modifier, ModifierContractError +# 调用方未显式提供 applicability 时使用的默认值。 +# 文案明确两点: 只处理正则命中位置; 不解析文档结构, 也不自动启用。 _DEFAULT_APPLICABILITY = "处理调用方正则表达式明确匹配的文本;不推断文档结构,也不自动启用。" +# 所有由本工厂产出的候选修改共享同一个 reason, 便于审计和报告。 _REASON = "应用调用方声明的正则表达式替换" @@ -20,38 +23,64 @@ def regex_replace( flags: int | re.RegexFlag = 0, applicability: str = _DEFAULT_APPLICABILITY, ) -> Modifier: - """Create a modifier that turns non-empty regex matches into exact edits.""" + """构造一个把正则非空匹配转成精确 TextEdit 的 Modifier。 + + 本函数只负责"提议"——返回的 Modifier 不会自行改动字符串; + 真正的范围、原文、冲突和原子应用由核心执行器负责。 + """ + # 1. 参数类型校验: 保证下游按字面值比较 parameters 时不会混入非字符串。 if not isinstance(pattern, str): raise ModifierContractError("regex pattern must be a string") if not isinstance(replacement, str): raise ModifierContractError("regex replacement must be a string") if type(flags) is not int and not isinstance(flags, re.RegexFlag): raise ModifierContractError("regex flags must be an integer or RegexFlag") + + # 2. 提前编译正则: 把 re.error 直接翻译成 ModifierContractError, + # 让调用方在创建阶段就看到失败, 而不是运行 Pipeline 时才暴露。 try: compiled = re.compile(pattern, flags) except (re.error, ValueError) as error: raise ModifierContractError("regex pattern and flags must compile successfully") from error + # 3. 零长度匹配守卫: search("") 是最小成本探测。 + # 零长度匹配会在 finditer 里产生无限推进或死循环, 必须在工厂阶段拒绝。 empty_match = compiled.search("") if empty_match is not None and empty_match.start() == empty_match.end(): raise ModifierContractError("regex patterns that produce zero-length matches are not supported") def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + """把当前快照里所有非空正则匹配转换为一条 ProposedChange / 一个 TextEdit。""" proposals: list[ProposedChange] = [] + # 4. finditer 给出非重叠、按出现顺序的多匹配; + # Python 标准库保证循环次数有限, 前提是模式不是零长度。 for match in compiled.finditer(snapshot.markdown): + # 5. 二次零长度守卫: 覆盖运行时才暴露的零长度匹配(例如某些 lookahead + # 在不同前缀下行为不同), 任一条命中立刻让整批 Modifier 不执行。 if match.start() == match.end(): raise ModifierContractError("regex patterns that produce zero-length matches are not supported") + + # 6. match.expand 让 replacement 支持 \1、\g 这类反向引用, + # 保持与 re.sub 相同的替换语义。 expanded = match.expand(replacement) + # 7. 命中原文; 执行器会用它与快照同位置实际文本二次比对。 expected = match.group() + + # 8. 替换后等于原文(典型场景: 把 "a" 替换成 "a")的命中不产出编辑。 + # 一来省去执行器的零修改拒绝路径, 二来让 result.changes 只含真改动。 if expected == expanded: continue + proposals.append( ProposedChange( + # 9. 把当前快照哈希绑进候选和每条编辑, 下游可据此拒绝跨快照复用。 snapshot_sha256=snapshot.sha256, reason=_REASON, edits=( TextEdit( snapshot_sha256=snapshot.sha256, + # 10. 半开区间 [start, end), 与 Python len/切片口径一致; + # 不做 UTF-16、字节或显示列宽换算。 span=TextSpan(match.start(), match.end()), expected_text=expected, replacement=expanded, @@ -59,11 +88,14 @@ def regex_replace( ), ) ) + # 11. 返回不可变 tuple, 便于上游 dataclass 字段直接持有。 return tuple(proposals) return Modifier( modifier_id=modifier_id, version=version, + # 12. parameters 只放原始字面值, flags 强制转 int, + # 保证 Modifier 的相等/哈希判断只看输入数据, 而不是 re.Pattern 内存地址。 parameters={"flags": int(flags), "pattern": pattern, "replacement": replacement}, applicability=applicability, propose=propose,