docs: 同步协作说明并批准物理行接口设计
This commit is contained in:
@@ -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 或报告内容。
|
||||
Reference in New Issue
Block a user