docs: 同步协作说明并批准物理行接口设计

This commit is contained in:
2026-08-28 14:42:11 +08:00
parent 843952b194
commit 8b689eebe1
5 changed files with 492 additions and 3 deletions
@@ -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 或报告内容。