Markdown 幂等节段合并
在自动化过程中维护 Markdown 文档中一个规范 ## Heading 节段的程序:当节段存在时替换它,不存在时追加它,并且确保应用两次后输出字节完全一致。非显而易见的部分是节段结束的位置——经典错误是信任简单的 ^## 正则表达式来找到该边界。
何时使用
- 一个智能体或脚本在一个它不完全拥有的文档中维护一个规范节段——比如
README.md、AGENTS.md、CLAUDE.md、手册、变更日志前言——遵循“如果存在则替换,如果缺失则追加”的规则。 - 你观察到任何以下症状:
- 每次运行后,被维护的节段会重复出现。
- 合并操作在围栏代码块的中间切断了节段。
- 代码围栏内部的
## ...行被当作下一个节段处理。 - 一个
###子标题过早地结束了替换范围。 - 更新程序永不收敛:第二次运行仍然更改文件。
- 合并后,某个点以下的所有内容都渲染成一个巨大的代码块(一个残留的围栏分隔符重新打开成一个新围栏)。
- 你读取或计数节段时使用了
^##风格的模式,而不仅仅是写入它们——提取和验证也会遇到同样的陷阱。
陷阱:代码围栏内的标题
朴素实现将替换范围视为“从 ## X 行到下一个 ^## 匹配之前的那一行”。它在两个方面失效。
陷阱 1——围栏字面量。 节段主体在围栏代码块内部合法地包含 ## ... 行:一份报告模板、一个示例文档、一个引用的差异。一个不感知围栏的扫描会误将第一个这样的行当作下一个节段边界,并在此处截断范围:
## 自动化说明
机器人会刷新本部分。
```text
## 周报 <- 朴素的 ^## 扫描在此处停止,位于围栏内部
- 要点:
- 风险:
```
保持模板被围栏包围,以免它变成真正的标题。
## 许可证 <- 真正的下一个节段
替换到围栏行会留下陈旧的尾部。测量到的损坏(见经过测试的陷阱装置)比简单的重复更糟:
- 围栏文字
## 周报被留在任何围栏外部,并渲染成一个真实的、看起来重复的标题。 - 旧块的关闭围栏分隔符(即裸露的
```行)也被留下,重新打开成一个新围栏,并吞噬它之后的所有内容——接下来的## 许可证节段完全不再是一个标题。 - 合并不是幂等的:每次运行都会再次匹配标题,再次在围栏行处截断,并再次增长文件。
陷阱 2——### 子标题。 一个纯粹的 ^## 正则表达式也会匹配 ###、#### 等。如果节段包含子标题,范围会在第一个 ### ... 行结束,旧的子节段会在新的子节段之后幸存下来——合并点下方的内容重复。
同样的两个陷阱也适用于读取一个节段(提取其内容)和验证一个节段(grep -c '^## X' 既可能过度计数围栏字面量,也可能被它们迷惑)。
安全边界:两种方法
方法 1——字面区域感知的标题扫描
在扫描行时跟踪字面区域状态,并且仅在行位于前置元数据、围栏和支持的原始 HTML 块外部时,才将其视为标题或边界:
- 当一行的第一个非空格字符(最多 3 个前导空格)是连续 3 个或更多反引号或连续 3 个或更多波浪号时,围栏打开。
- 它仅在一行具有相同字符的连续序列、至少一样长、并且该行没有其他内容时才关闭。未关闭的围栏会一直延伸到文档末尾(CommonMark 行为)。
- 在围栏内部时,没有任何内容是标题,也没有任何内容是边界。
- 在围栏扫描之前,一个前导的精确行前置元数据块被排除:YAML 以
---开头并以精确的---或...结束;TOML 以精确的+++开头和结束。该块内看起来像标题的行是元数据/注释内容,不是 Markdown 节段边界。一个没有精确匹配关闭符的开放符会在任何写入之前失败关闭。 - 在顶层,第 0–3 列的 CommonMark 0.31.2 原始 HTML 块(类型 1–7)也被排除:字面内容标签(
pre/script/style/textarea)、注释、处理指令、声明、CDATA、列出的块标签以及完整的仅标签行。类型 6/7 在空行前或文件末尾结束;类型 7 不能中断段落。标签和属性语法在大小写不敏感的匹配下仍保持纯 ASCII;Unicode 大小写形式相似的字符是普通的 Markdown。围栏和 HTML 状态共享一个独占的扫描器,因此一个区域内的分隔符保持在另一个区域内为字面数据。 - 这个可变引用在模糊的 EOF 处故意比 CommonMark 更严格:一个识别出的类型 1–5 开放符如果没有显式的结束条件则失败关闭。一个可能的类型 7 开放符在不受支持的容器/缩进上下文中也失败关闭,而不是猜测。同一规则涵盖了一个可能的链接引用定义后跟
===的情况,其中部分解析器无法证明 setext 是否结束了段落。可能的链接标签通过转义感知的括号处理在行间跟踪;一个未转义的]如果没有紧随一个冒号,则会释放可能的定义状态。简单的无空白定义加标签形式仍然是内联 HTML。 - 节段边界是下一个级别为 1 或 2 的标题。常见形式是
^##[^#](排除###);参考实现将其加固为^ {0,3}#{1,2}([ \t]|$),这额外将 H1 视为边界(部分边界绝不能吞并到替换中),接受 CommonMark 允许的 1–3 个空格缩进,并应用 CommonMark 的空格规则,以便##hashtag风格的段落行不是虚假边界。无论哪种方式,###仍然不是边界。
当文档格式不是你能改变的,或者工具必须在任意 Markdown 上工作时,使用此方法。
方法 2——固定标记
将维护的节段包裹在唯一的哨兵行中——HTML 注释在渲染的 Markdown 中是不可见的:
<!-- managed-section:begin automation-notes -->
## 自动化说明
...主体,围栏模板欢迎...
<!-- managed-section:end automation-notes -->
在精确的起始/结束标记行之间替换所有内容;当起始标记不存在时追加整个标记块。标题扫描完全不再是范围查找的一部分。
当你拥有文档格式时使用此方法:它是最健壮的边界(它能承受标题重命名和任何主体内容),代价是植入标记。保持每个节段的标记字符串唯一,并保持下面的单 H2 不变量——标记保护的是范围,而不是文档的标题结构。一个诚实说明:代码围栏内标记行的字面副本仍然会混淆不感知围栏的标记搜索,因此要么将标记文本排除在围栏示例之外,要么使用相同的围栏感知扫描来查找标记。
不变量
在每次合并之前和之后强制执行这些规则;它们使得“替换或追加”定义明确:
- 块以其自己的普通
## Heading行开头——在开始的##和非空内容之间使用恰好一个 ASCII 空格(对于空 H2 使用精确的##)。额外的分隔空格/制表符和 CommonMark 的结束哈希序列在此不规范。标题是合并内容的一部分,不是可以与之漂移的配置。 - 块内恰好一个 H1/H2 级别标题——其自身的第一个 ATX 行(字面区域感知计数)。一个包含第二个 ATX 或 setext H1/H2 的块会在后续运行中静默地吸收或分割一个节段。在第一次写入之前拒绝可能的 setext 标题,以便追加和替换使用相同的验证边界。块内被围栏或原始 HTML 包围的标题字面量是允许的——它们不计数。
- 目标中(字面区域外)标题的明确副本最多一个。如果文档已经包含重复项或所管理 H2 的一个分隔符/1–3 空格/结束哈希别名,则停止并报告,而不是“修复”一种形式而留下另一种。
- 规范的换行形状。 存储的块没有尾部空行;在块和其后的节段之间写入恰好一个空行。读取范围和写入形状于是收敛到相同字节,这正是第二次运行成为空操作的原因。
- 没有未关闭的围栏,无论是在目标中还是在块中。 CommonMark 将未关闭的围栏延伸到文档末尾,因此在目标中,它会静默地将节段扩展到 EOF(替换会重写整个视觉上被吞噬的尾部),而在块中,它会吞噬合并节段之后的所有内容。两者都是格式错误的输入:停止并报告。
- 规范块或替换跨度内没有 setext 标题。 段落文本正下方的
===或---下划线是一个真正的标题,逐行的^#扫描无法将其视为边界。在新追加的块中接受一个这样的标题会使下一次运行失败而不是收敛;跨越一个进行替换会删除一个节段边界。在两种情况下都在写入前停止(将标题转换为 ATX 形式,或使用固定标记)。参见docs/setext-block-heading-contract.md。 - 没有未关闭的前导前置元数据。 当第一行是精确的
---(YAML)或+++(TOML)时,仅通过第一个精确匹配的关闭符忽略其看起来像标题的行。如果不存在关闭符,则停止,而不是猜测开放符是元数据还是普通的 Markdown 主题分隔线。 - 没有模糊的显式结束原始 HTML。 CommonMark 允许类型 1–5 在缺少结束条件时运行到 EOF,但在那里追加一个 H2 会将其隐藏在原始 HTML 内部并破坏收敛。在目标和块中都要求显式的结束条件。对于类型 7,保留其不中断段落的规则,并在不受支持的容器/缩进或可能的引用加 setext 上下文(包括多行链接标签)使得该决策模棱两可时失败关闭。
- 仅 ASCII 块空白。 空行、ATX 标题修剪和结束序列、setext 上下文和围栏关闭符完全按照 CommonMark 0.31.2 的规定使用 ASCII 空格/制表符。NBSP、EM SPACE、换页符、垂直制表符和其他 Unicode 空白保持为内容,而不是被静默去除。参见
docs/commonmark-ascii-whitespace-contract.md。
验证方案
合并后运行所有三个检查;它们共同捕获边界错误、重复节段和附带编辑:
-
应用两次差异为零。 应用相同的合并两次;第二次运行后
git diff必须为空(或比较前后的字节)。对于一个位于文件末尾的节段,一个等效的检查是块等于文件的整个尾部节段。python scripts/merge_section.py target.md section.md python scripts/merge_section.py target.md section.md # 第二次运行 git diff --exit-code target.md # 提交第一次运行后:为空参考实现的
--check模式使用类似 diff 的退出代码(0 = 规范,1 = 合并会更改文件)编码了相同的思想,CI 可以直接调用。 -
标题出现次数 = 1。 使用围栏感知扫描计算等于标题的行。一个简单的
grep -c '^## 自动化说明$'会受到此技能所讲陷阱的影响——仅当你知道主体不包含确切标题行的围栏副本时才可接受。 -
git diff --stat恰好触及一个文件——目标。任何其他情况意味着自动化写入了不应写入的地方。
参考实现
scripts/merge_section.py 在无依赖的 Python 3(仅标准库)中实现了方法 1 以及上述所有不变量。选择 Python 而非 shell 的原因在此很重要:对换行符和 BOM 的显式字节级控制使得应用两次差异为零可证明,而 shell 文本管道往往会在后台规范化行尾。该算法可以直接移植到任何语言。
python scripts/merge_section.py TARGET.md SECTION.md # 原地合并
python scripts/merge_section.py TARGET.md SECTION.md --check # 漂移检查
SECTION.md 是规范块:第一行是精确的 ## Heading,非空内容前有一个 ASCII 空格,或空 H2 使用精确的 ##。目标的 LF/CRLF 风格和 UTF-8 BOM 被保留;一个缺失的目标会被创建(追加到一个空文档中)。更改的字节被刷新到一个独占的同目录临时文件,并通过一次原子替换进行安装。临时文件以 POSIX 模式 0600 或受保护的 Windows SYSTEM/Owner-Rights-only DACL 开始,并且其标识、字节、元数据和 Windows DACL 在提交前会被重新检查。现有的 POSIX 所有者/组/模式/有界扩展属性被保留。对于现有的 Windows 目标,ReplaceFileW 会向前传递其记录的 DACL、文件属性和命名流;确切的所有者/组/SACL 保留不被承诺。缺失的目标保留私有权限。
目标和规范块都被读取为无跟随的普通文件快照。符号链接、非常规文件和多个硬链接输入被拒绝,因为替换会改变它们的语义;Windows 重解析点和 EFS 加密的 Windows 目标在内容被读取前被拒绝。目标标识、元数据和字节在提交前立即被重新检查。在该检查之前完成的更改会被检测到,但检查和替换是分开的操作;当现有目标不能承受丢失的更新时,请外部序列化每个写入器。缺失目标的创建使用无替换提交,并且永远不会覆盖并发创建。在 Windows 上,ReplaceFileW 使用一个...