Skip to content

fix: stop heading pass from splitting last character off heading lines - #47

Merged
XiaoCow666 merged 3 commits into
XiaoCow666:mainfrom
linxi123-A:fix/heading-blank-line-corruption
Sep 17, 2026
Merged

XiaoCow666 merged 3 commits into
XiaoCow666:mainfrom
linxi123-A:fix/heading-blank-line-corruption

Conversation

@linxi123-A

@linxi123-A linxi123-A commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

一、问题/目标

用户现象(不完整描述):AI 建议里的独立 Markdown 标题末字符丢失并被拆成独立段落,# Title 显示为标题 Titl + 段落 e。目标:修复标题后置空行 pass 的字符搬移缺陷,恢复两条数据一致性不变量——规范化只允许插入空白字符、不得搬移既有字符;对同一输入重复增强结果稳定(幂等)。

二、复现与证据

探针(D:\Python311 直跑,模块纯标准库,零依赖):

python -c "import sys; sys.path.insert(0,'.'); from utils.markdown_formatter import MarkdownFormatter as M; print(repr(M.enhance('# Title'))); print(repr(M.enhance('# Title\nBody text'))); print(repr(M.render_html('# Title')))"

修复前实际输出:

'# Titl\n\ne'
'# Titl\n\ne\nBody text'        ← 带正文同样损坏,不是边缘情况
'<h1>Titl</h1>\n<p>e</p>'       ← 渲染层可见,与用户描述一致

对输出再跑一次 enhance(),标题继续碎裂(Titl+e → Tit+l+e),证明不幂等。修复后同命令输出 '# Title' / '# Title\n\nBody text' / '<h1>Title</h1>',二次增强结果不变。

RED 基线(首个提交 6f188b5):当时新增的 6 个回归测试全部失败(6 failed, 41 passed),失败 diff 与上述探针逐字一致;追加提交 5312434 增加第 7 项(见"计划改动")。

三、排查顺序与排除过的假设

  1. 读 enhance() 管线 → 唯一嫌疑:旧正则 (#{1,6}[^\n]+)([^\n])(当时位于 L54,修复后新正则在 L57),其第二捕获组 [^\n] 结构上永远匹配不到换行符,回溯机制被迫吞掉标题行内最后一个字符
  2. 探针逐条裁决:H1 孤立标题损坏 ✓;H2 带正文同样损坏 ✓;H3 render 层可见 ✓;H4 非幂等 ✓
  3. 排除:换行归一化、L50"#"后补空格、列表、围栏各 pass 无吞字符;L53"标题前补空行"不吞字符但同样缺行首锚定(探针实证 text # foo\nbar 被劈开为 text \n\n# foo\nbar),同类缺陷按复审非阻塞意见移交、本轮不扩 scope(见"学习总结")

四、计划改动(已完成)

utils/markdown_formatter.py 的 enhance() 一处正则:

# 旧:第二分组 [^\n] 结构上匹配不到换行,回溯被迫吞掉标题末字符
re.sub(r'(#{1,6}[^\n]+)([^\n])', r'\1\n\n\2', text)
# 新:(?m)^ 锚定真实行首;捕获组以字面 \n 结尾(回溯无法侵入标题文本);
#     前瞻保证下一行非空才插入,已有空行不重复插(幂等);替换只插入一个换行
re.sub(r'(?m)^(#{1,6}[^\n]+\n)(?=[^\n])', r'\1\n', text)

tests/test_markdown_formatter.py 新增 7 项回归测试(6f188b5 六项 + 5312434 追加一项),并同步更新模块 docstring(原"标题路径有缺陷、故意不锁定"表述已失效):

  1. 孤立标题保真:enhance('# Title') == '# Title'
  2. 标题-正文插空行:'# Title\nBody text' → '# Title\n\nBody text'
  3. 重复增强幂等:enhance(enhance(x)) == enhance(x)
  4. 已有空行不动:'# Title\n\nBody text' 原样
  5. 末尾换行不动:'# Title\n' 原样
  6. HTML 输出标题完整:含 <h1>Title</h1>,不含 <p>e</p>
  7. 行中井号不当标题(5312434 追加):'a #1 fan\nnext' 原样——旧无锚定 pass 会在行中位置命中 #1、拆成 a #1 fa\n\nn\nnext,该用例锁定 (?m)^ 行首锚定

五、学习总结(含 AI 辅助边界)

  • 自研部分:现象刻画、H1–H4 假设集与探针设计、修复方案、红绿验证均先于检索完成
  • AI 辅助部分:检索官方 re 模块文档确认 (?m)^ 与 [^\n] 语义 → 采纳行首锚定惯用法
  • 对替代写法 (#{1,6}[^\n]+)\n([^\n]) 的复核(采纳复审指正并经探针验证):其中字面 \n 同样阻止回溯以标题末字符充当第二组,已有空行时不会拆字符——"换一种方式坏"的初判有误。未采用的真实原因:①缺 (?m)^ 行首锚定,会命中段落行中的 # 片段(探针实测 a #1 fan\nnext 被拆为 a #1 fa\n\nn\nnext);②第二捕获组消耗下一行首字符、替换必须回填,非纯插入。采用前瞻正是为了零宽消耗、纯插入
  • 测试边界(回应理解抽查):a #1 fan\nnext 只证明新 pass(L57)的锚定行为,不能证明所有行中井号安全——#1 中 # 后无空格,恰好绕过了同样未做行首锚定的 L53"标题前补空行"pass;探针证实 text # foo\nbar 仍会被 L53 劈开。一条用例只锁定它覆盖的那条路径
  • 移交下位维护者:① L53 ([^\n])(#{1,6}\s) 同样缺 (?m)^ 行首锚定,行中"# 后带空格"会被劈开,建议参照本次修法统一加锚定,并补"围栏内 # 保持不变"的用例;② 代码围栏内的 # 行仍会被标题 pass 插入空行(不吞字符但语义不对),建议参照 _fix_lists 的围栏感知做行扫描统一处理
  • 操作说明:复现只需一条 python -c 探针(见第二节);单文件 python -m pytest tests/test_markdown_formatter.py -q;全量 pytest tests -q

验证结果

干净环境:D:\Python311\python.exe -m venv 新建隔离环境,pip install -r requirements-test.txt,PYTHONDONTWRITEBYTECODE=1,不连数据库/Redis、不启动应用。

  • 红绿基线(修复前代码):6 failed, 41 passed——六项 heading integrity 用例在旧正则下全红,失败内容与第二节探针一致

  • 当前 head 5312434 单文件(真实重跑):

    python -m pytest tests/test_markdown_formatter.py -q
    ................................................ [100%]
    48 passed in 0.11s
    pytest 退出码:0

  • 当前 head 5312434 全量回归:

    pytest tests -q
    577 passed, 1360 warnings in 530.37s (0:08:50)
    PYTEST_EXIT=0

    577 = 6f188b5 全量 576(当时 main 基线 529 + test: characterize MarkdownFormatter normalization and rendering #44 的 41 + 首轮 6)+ 5312434 追加 1;两提交结果分开对应、不混算。1360 条 warning 均为既有 SQLAlchemy LegacyAPIWarning,与本改动无关

  • 退出状态区分:回放中一次后台任务 exit 2 系 PIP_EXIT=2(依赖下载中断)导致 41 个 collection error,前台重装 PIP_EXIT=0 后重跑全绿;另一次任务壳 exit 1 系沙箱拦截 D:\Python311\Lib\__pycache__ 字节码写入。两类均为环境/外层噪音,pytest 自身退出码均为 0,以 pytest 汇总行为准

风险与回滚

一处正则 + 测试新增,无接口/数据库/权限/安全/部署改动。回滚:revert 6f188b5、5312434 两个提交,或恢复旧正则并删除"heading integrity"测试段。

The "ensure blank line after heading" regex matched (heading)([^\n]),
whose second group can never be the newline that follows the heading
line, so backtracking consumed the heading's own last character instead:
"# Title" became "# Titl\n\ne", render_html emitted <h1>Titl</h1><p>e</p>,
and re-running enhance() kept shredding the tail (non-idempotent).

Anchor the pass to true line starts ((?m)^), require the literal
trailing newline, and insert a newline only when the next line is
non-empty (lookahead), which makes the pass idempotent and purely
insertive. Adds 6 heading-integrity regression tests (all failed on the
previous implementation, all pass now).

@XiaoCow666 XiaoCow666 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

代码修复方向合理;本次必要修改项是阶段六任务台缺少必需证据,不是 PR 描述不足。

  1. P2:补齐任务台交付记录。 TASK CONTEXT 中 problem_goal、reproduction_evidence、planned_changes、learning_summary、verification_result 均为空,无法从任务台核对本阶段的故障收口和移交结果。请打开“阶段六:CodeSense 接管演练与故障收口(飞书用户5386EE)”任务记录,将这些空字段分别补为:标题末字符拆分现象与目标、具体输入及修复前后输出、utils/markdown_formatter.py 的正则改动和六项测试、排查结论及围栏处理遗留问题、实际执行命令与结果。然后运行 python -m pytest tests/test_markdown_formatter.py -q,将真实输出和对应提交 SHA 记录到 verification_result;全量测试结果请附可核对日志,并区分 pytest 与外层任务的退出状态。PR 正文已有具体材料,可据实整理到相应字段。

已检查范围:仅静态分析所提供的 diff,未运行任何命令。utils/markdown_formatter.py 的 enhance() 改用字面换行和前瞻,替换保留完整捕获内容,消除了旧正则将标题末字符拆出的机制;tests/test_markdown_formatter.py 新增六项回归测试,覆盖孤立标题、正文间距、幂等、已有空行、末尾换行及 HTML 输出。满足阶段二以后要求的真实行为改动与回归测试交付,未发现代码层面的必要修改项。

非阻塞建议:修正 PR“学习总结”中对替代正则 (#{1,6}[^\n]+)\n([^\n]) 的解释:其中字面换行也阻止以标题末字符充当第二组,已有空行时不会按正文所述拆字符。可以说明采用前瞻是为了不消耗下一行字符。首次贡献的验证说明请继续保留具体复现输入、期望输出、测试命令,并明确区分已运行结果与推断。

Follow-up to review on XiaoCow666#47: the unanchored pre-fix heading pass also
split an inline "#" line ("a #1 fan\nnext" -> "a #1 fa\n\nn\nnext"),
the same data-loss mechanism as the standalone-heading symptom. Lock
the (?m)^ line-start anchor behavior with a regression test so an
inline hash stays byte-for-byte untouched.

@XiaoCow666 XiaoCow666 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

结论:通过。仅根据提供的元数据与 diff 静态复审,未发现必须修改的问题;未调用工具或运行测试。

已检查范围:

  • utils/markdown_formatter.py 的 enhance() 标题后空行处理:新正则以 (?m)^ 锚定行首,捕获内容以字面换行结尾,替换保留捕获内容并追加换行,消除了旧写法回溯拆走标题末字符的问题。前瞻使已有空行时不再插入,支持该处理步骤的幂等性。
  • tests/test_markdown_formatter.py 新增了 7 项回归测试,覆盖独立标题、标题接正文、重复增强、已有空行、末尾换行、HTML 渲染和行中井号,包含真实行为改动及对应回归覆盖。
  • 任务台提供了具体复现输出、根因、学习总结和红绿测试记录,符合阶段六交付方向。测试结果属于贡献者提供的记录,本次未独立验证。

后续建议(不阻塞):

  • 当前 head 为 5312434,任务台 verification_result 仍对应 6f188b5,且 planned_changes、PR 正文写新增 6 项测试,diff 实际为 7 项。请在当前 head 运行 python -m pytest tests/test_markdown_formatter.py -q,将提交号、实际数量、输出和退出码更新到上述字段;全量结果保留其实际对应提交,避免混为当前提交验证。
  • 后续可为围栏内标题样式文本单独建立修复任务,加入围栏内容保持不变的测试。该限制已在移交说明中明确,本次不阻塞。
  • 首次贡献的 PR 描述可沿用“输入与实际输出 → 修改后的行为 → 验证命令及对应提交”的结构;当前内容已具体,更新测试记录即可。

理解抽查:

  1. 对 # Title\nBody text,新正则的捕获组和前瞻分别匹配什么?为什么第二次处理不会继续插入空行?
  2. 新增的 a #1 fan\nnext 用例如何验证行首锚定?为什么它不能证明所有包含行中井号的文本都不会被前一个标题处理步骤修改?

合并前隔离验证

  • 结果:未通过
  • 命令:gh repo clone
  • 耗时:9169ms
  • 摘要:Cloning into 'E:\CodeSense\pr-review-relay\data\work\premerge-0pci6J\repo'...
    fatal: unable to access 'https://github.com/XiaoCow666/CodeSense.git/': schannel: failed to receive handshake, SSL/TLS connection failed
    failed to run git: exit status 128

合并前验证未通过,本次不合并。请按上面摘要逐条处理:打开摘要中点名的文件和位置,把当前错误改成命令要求的行为;修复后重新运行同一条命令,再提交新的 head。

@linxi123-A

Copy link
Copy Markdown
Contributor Author

感谢复审,回应以下两点:

理解抽查

第 1 题:对 # Title\nBody text,新正则的捕获组和前瞻分别匹配什么?为什么第二次处理不会继续插入空行?

第一次处理:

  • 捕获组 1 (#{1,6}[^\n]+\n) 匹配 # Title\n(从行首开始,[^\n]+ 贪婪吃掉 Title,以字面换行结束——回溯无法突破这个字面 \n 去吃标题字符)
  • 前瞻 (?=[^\n]) 零宽检查换行之后那个字符是 B(非换行),断言成功但不消耗 B
  • 替换 \1\n → 原标题行 # Title\n 后追加一个换行 → # Title\n\nBody text

第二次处理(输入已含空行):

  • 捕获组同样匹配 # Title\n
  • 前瞻检查它后面的字符——这次是 \n(空行),[^\n] 断言失败 → 整个正则不匹配 → 不插入。这就是幂等:前瞻是零宽条件判断,已有空行时条件不成立。

第 2 题:a #1 fan\nnext 如何验证行首锚定?为什么它不能证明所有行中井号都不被前一个标题处理步骤修改?

探针证实(旧正则下):

  • a #1 fan\nnext → a #1 fa\n\nn\nnext(旧正则无行首锚定,在行中位置命中 #1,拆走末字符)
  • text # foo\nbar → text \n\n# foo\nbar(更关键的遗留:L53"标题前补空行"pass 同样无行首锚定,text 后紧跟的 # foo 带空格、满足 #{1,6}\s,被劈开)

a #1 fan\nnext 用例验证的是新 pass(L57)的 (?m)^ 行首锚定——行中位置不再被匹配。但它不能证明"所有行中井号普遍安全",原因有二:

  1. #1 中 # 后是数字、不满足 L53 正则 (#{1,6}\s) 里的 \s,恰好绕过了 L53;若换成 text # foo\nbar(# 后带空格),L53 先命中、在 L57 之前就把段落劈开了
  2. 一条用例只锁定它覆盖的那条路径(L57 的锚定行为),不能延伸到未覆盖的同类问题(L53 同样缺锚定)

该 L53 同类缺陷已按复审非阻塞意见作为遗留点移交:utils/markdown_formatter.py 第 53 行 ([^\n])(#{1,6}\s) 也缺 (?m)^,后续可参照本次修法单独建 PR 处理,并补围栏内 # 注释保持不变的用例。

关于 premerge 隔离验证未通过

失败发生在 gh repo clone 阶段(schannel: failed to receive handshake, SSL/TLS connection failed, git exit 128,耗时 9169ms),即机器人运行环境到 github.com 的 HTTPS TLS 握手失败,仓库尚未克隆完成,未执行任何测试,与本 PR 代码无关。

贡献方隔离验证(干净 venv,D:\Python311 新建,pip install -r requirements-test.txt,PYTHONDONTWRITEBYTECODE=1):

  • python -m pytest tests/test_markdown_formatter.py -q → 48 passed in 0.11s, pytest exit 0
  • pytest tests -q → 577 passed, 0 failed in 530.37s, pytest exit 0

均在当前 head 5312434 上运行,日志与退出码已记录于任务台 verification_result。请网络恢复后重跑 premerge clone;若持续握手失败,建议检查机器人环境的出网代理/TLS 配置。

@XiaoCow666 XiaoCow666 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeSense 自动评审

修复了 H1 标题末字符被后置空行规则吞掉的问题,但现有的“标题前空行”规则仍会把标准 H2–H6 标题拆成两个标题,导致本 PR 所称的标题完整性修复未覆盖全部 #{1,6} 标题级别,属于可复现的用户可见功能错误。

需要先处理的问题

  • utils/markdown_formatter.py:53 的 re.sub(r'([^\n])(#{1,6}\s)', ...) 未限制 # 必须位于行首。对于 ## Title\nBody text,该规则会把第一个 # 当作前置字符、把第二个 # 当作标题开始,先生成 #\n\n# Title\nBody text;随后本 PR 新增的后置规则再生成 #\n\n# Title\n\nBody text。这将原本的 H2 标题拆为一个空 H1 和一个 H1,H3–H6 同样受影响。PR 修改的后置规则本身支持 #{1,6},但测试仅覆盖 H1,当前实现未完成标题行完整性目标。

请按下面的步骤修改

  • 文件:utils/markdown_formatter.py,位置:enhance() 中“确保标题前后有空行”的第一条规则(当前约 L53)。当前问题:([^\n])(#{1,6}\s) 可在同一行内把一个 # 作为前置文本、把紧随其后的 # 当标题开头,从而拆坏 H2–H6。应改成只识别真实行首的 ATX 标题,并通过匹配“上一非空行结尾 + 换行 + 行首标题”来仅插入空行,例如采用等价于 re.sub(r'(?m)([^\n])\n(#{1,6}[ \t])', r'\1\n\n\2', text) 的实现;同时确认该规则与标题后置空行规则组合后不搬移任何标题字符。AI 操作提示:重写标题前空行 pass,使标题标记只能在物理行首匹配,禁止用相邻 # 充当上一段文本;保留既有输入输出的换行风格和幂等行为。
  • 文件:tests/test_markdown_formatter.py,位置:新增的 heading integrity 回归测试段。当前问题:测试只验证了 H1,未覆盖会被 L53 拆坏的 H2–H6,因此当前测试全绿仍遗漏可复现的标题损坏。应新增至少一个 ## Title\nBody text 的完整性断言,并最好参数化覆盖 ## 至 ######,断言增强结果保留原始标题级别和标题文本、仅在标题后插入一个空行,且二次 enhance 结果相同。AI 操作提示:添加 H2–H6 回归用例;对每级输入 '<marks> Title\nBody text',断言输出为 '<marks> Title\n\nBody text',并断言 enhance(enhance(input)) == enhance(input)。

已有验证信息

  • 事件提供的 PR 描述声称当前 head 上执行 python -m pytest tests/test_markdown_formatter.py -q 得到 48 passed in 0.11s、执行 pytest tests -q 得到 577 passed, 1360 warnings 且 pytest 退出码为 0。
  • 提供的 diff 显示新增的 7 个回归测试覆盖孤立 H1、H1 后正文空行、幂等、已有空行、末尾换行、HTML 输出和行内 #1,但未覆盖 H2–H6。

评审事件:2929aeba-b262-11f1-98ef-3a2e94352202

@XiaoCow666 XiaoCow666 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeSense 自动评审

修复将标题前后空行处理限制到真实行边界,并避免通过回溯搬移标题末字符;新增回归测试覆盖 H1-H6、幂等性、HTML 渲染和行内井号场景。未发现必须阻塞合并的安全、数据损失、明显回归或任务未完成问题。

合并后可以继续改进的地方

  • 代码围栏内以 # 开头的内容仍可能被标题空行处理修改;PR 描述已明确这是既有范围外问题。后续可在 utils/markdown_formatter.py 的 enhance() 标题空行处理前后引入围栏感知,或将标题处理并入现有围栏感知的行扫描流程,并补充围栏内 # 行保持原样的测试。

已有验证信息

  • PR 提供的当前 head 验证证据:python -m pytest tests/test_markdown_formatter.py -q 报告 48 passed,退出码 0。
  • PR 提供的当前 head 全量验证证据:pytest tests -q 报告 577 passed、1360 warnings,pytest 退出码 0。
  • Diff 中新增测试覆盖孤立标题、标题与正文间空行、已有空行、尾随换行、H2-H6 标题级别、重复 enhance() 幂等性、渲染 HTML 完整性及行内 # 不被识别为标题。

评审事件:df5f5810-b2ad-11f1-94ae-c4efff5eea59

@XiaoCow666
XiaoCow666 merged commit 240784d into XiaoCow666:main Sep 17, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants