Diffle 的文本锚点:Coding Agent 改动代码后,审阅评论如何重新定位
Diffle 的文本锚点:Coding Agent 改动代码后,审阅评论如何重新定位
给 Coding Agent 留了一条行级 Review:这里的判断条件不对,改一下。它改完,又顺手拆了函数、补了几行检查。等我回来看 diff,原本标在第 18 行的评论还在说同一段代码吗?
这正是我对 Diffle 感兴趣的地方。它能在本地审阅 Git diff、留下行级评论,再把意见交给 Agent 或放进 GitHub 的 Pending Review。但 Agent 一次次改代码,评论总不能永远靠旧行号找位置。
我把源码固定在 a69274d(0.2.4)读了一遍。它的办法很直接:记住原文,找精确匹配,多个候选就选离旧行号最近的;超出本轮 diff 的位置再标记过时。 简单的行号挪动可以处理,但两段代码长得一模一样时呢?我用 Node 内置测试跑了四组独立的匹配模型样本,想弄清这层取舍。本文核对的是固定源码与小模型,不是 Diffle CLI 或浏览器的实机评测。
评论记住的是原文,不只是第 18 行
Diffle 把评论放在仓库的 <git-dir>/diffle/,不会写进工作区源码。它的 CommentStore.ts 用 comments.json 保存线程和锚点。一条行级评论不仅有路径、左右比较侧和起止行号,还留着当时选中的文本 quoted。
再看 anchor.ts 的 relocate():它逐行寻找与 quoted 相同的文本。找到多处时,决定选哪处的代码是:
const start = i + 1;
const dist = Math.abs(start - anchor.startLine);
if (dist < bestDist) {
best = start;
bestDist = dist;
if (dist === 0) break;
}
比如代码从第 18 行整体挪到第 26 行,只要引用内容没变,就有机会跟过去;如果有两个一样近的匹配,dist < bestDist 还意味着先找到的候选会保留下来。
匹配到了也不一定算有效。CommentStore.relocateAll() 还会检查比较侧和当前展示的 diff hunk:引用文本消失、比较侧不存在,或新位置不在可见 hunk 中,行级评论就可能变成 stale。文件级评论则按文件是否还在本轮 review 判断。
我喜欢这层保护。它并不假装“只要找到同一串字符,评论就一定找对了地方”。
第 18 行遇到两段一模一样的代码
回到开头。真实代码里常常不缺重复的 return false;,或者好几个分支都在打 logger.info('done');。假设原评论盯的是最后那个兜底分支,Agent 重构后它跑远了,旧位置旁边又有一句长得一样的代码。文本匹配看不出哪一句才是我想追踪的逻辑。
为了把问题缩到可以检验的程度,我在独立模型里把它们换成 repeat();。这是四组测试中最有意思的一组,完整的可执行脚本放在下方折叠区;这里先看关键断言:
const contents = [
'header();', // 第 1 行
'repeat();', // 第 2 行:距离旧位置更近
'other();', // 第 3 行:旧评论的位置
'new();',
'changed();',
'gap();',
'repeat();', // 第 7 行:假设的原语义目标
].join('\n') + '\n';
const r = relocateModel(
{ startLine: 3, quoted: 'repeat();' }, contents
);
assert.deepEqual(r, { startLine: 2, endLine: 2 });
assert.notEqual(r.startLine, 7);
旧评论位置是第 3 行。修改后,两个 repeat(); 分别在第 2 行和第 7 行:前者只差 1 行,后者差 4 行。算法选了第 2 行。
这就是症结:最近的,不一定是原来那一处。 第 7 行的“语义目标”是我为了构造测试而指定的,匹配函数根本不知道业务逻辑属于谁。现实工具还会经过文件、比较侧和 hunk 检查;因此这证明的是所抽取规则的歧义,不是已经在 Diffle UI 里复现了错位 Bug。
其余三组我也跑了,四个实际观察如下:
| 合成场景 | 实际结果 | 看到了什么 |
|---|---|---|
| 唯一的引用文本移动 | 定位到第 4 行 | 普通行号偏移可以跟随文本 |
| 引用文本被删除 | 返回 null | 找不到就不强行挂在原行号 |
| 第 2、7 行出现同样的文本 | 选第 2 行 | 距离并不等于语义身份 |
| 文本在第 4 行,hunk 只显示 1–2 行 | 返回 false | 找到原文不等于仍在本轮 diff 内 |
这次测试于 2026 年 10 月 12 日在 Node.js v22.16.0 上完成,4/4 通过,退出码 0,stderr 为空。使用的是内置 node:test 与 node:assert/strict,没有加载 Diffle 的 npm 包,所以不受那个包的 Node ≥24 要求影响。运行命令为 TERM=dumb node --test --test-reporter=tap anchor-relocation-model.test.mjs;文末保留完整代码及原始 TAP 输出,方便对照。
如果希望在设计上进一步区分两处相同文本,可以考虑参考前后文、函数范围或上下文指纹。但重构本身也会打散这些线索,这是一个权衡,而不是给它多加几个字段就万无一失;固定的 Diffle 0.2.4 并没有采用上述额外的语义识别方案。
评论交给 Agent,和交给 GitHub,是两回事
本地评论审完以后,format.ts 可以把路径、行号、引用内容和评论整理成给 Agent 的提示词。带有 suggestion 围栏的内容还会被拆成 ORIGINAL / SUGGESTED,过时评论会带上 stale 标记。这比只发一句“上次那里改得不对”清楚得多:下一轮修改至少有具体代码可核对。
但把评论交给 GitHub PR 是另一回事。review.ts 明确拒绝把尚未提交的 worktree 当成 GitHub 评论锚点:
if (snap.newSha === 'worktree')
throw new GithubError('GitHub cannot anchor comments to the worktree');
想想也合理。你本地还没 commit 的那几行,协作者打开 PR 页面时根本没有对应的 diff;评论如果硬塞过去,就像在讨论一组只有自己电脑上存在的“幽灵行号”。
对能匹配 PR 已提交差异的评论,Diffle 会跳过 stale 线程,把意见加入 Pending Review,最后提交 Review 仍由人完成。这个边界我挺认可:Agent 可以帮我改,工具可以帮我整理反馈,但给同事看的那份审阅意见,最好仍由我最后过目。官方 README 也明确说明它不会替用户提交 Review。
放进日常工作流之前
如果要试,我会先找一个没有敏感数据的临时仓库,从工作区 diff 开始,并关闭没必要启动的语言服务器。按官方命令选项可以这样组合(这条是准备使用的命令,不是本次实机输出):
diffle working --no-lsp
Diffle 固定版本的 package.json 对 npm 安装要求 Node ≥24;官方也提供不依赖 Node 的独立二进制。--no-lsp 是有意加上的:自动发现的语言服务器会在当前用户权限下读取仓库文件,没有必要为了看一次纯 diff 就把它们全部拉起来。
之前写 OpenQodex 的 pre-push 审阅门禁 时,我更关心的是不完整的审阅会不会错误放行;这次关心的是更早的一步——一条人工反馈在下一轮 Agent 改动后,还能不能正确指向代码。至于改完的测试是否真正能抓住错误,那是测试与 Mutation Testing要回答的问题。
以后再遇到“第 18 行这里有问题”,我会多看一眼这一轮完整的 diff。工具可以努力帮评论找到落点,但哪段代码才是我真正要审的,不能只交给一个行号决定。
展开:完整可运行的 Node.js 四组测试
/**
* Independent source-adapted synthetic model; consult verification-results.json for actual execution status.
* An independent stdlib model of the source-visible anchor decisions in:
* https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/anchor.ts
* https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/hunks.ts
* This does NOT import or run Diffle and cannot establish CLI/UI behavior.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
function lines(s) {
const a = s.split('\n');
if (a.at(-1) === '') a.pop();
return a;
}
function relocateModel({ startLine, quoted }, contents) {
const haystack = lines(contents);
const needle = lines(quoted);
if (needle.length === 0) return { startLine, endLine: startLine };
const candidates = [];
for (let i = 0; i + needle.length <= haystack.length; i++) {
if (needle.every((l, j) => haystack[i + j] === l)) candidates.push(i + 1);
}
if (candidates.length === 0) return null;
const winner = candidates.reduce((best, p) =>
Math.abs(p - startLine) < Math.abs(best - startLine) ? p : best);
return { startLine: winner, endLine: winner + needle.length - 1 };
}
function isShownModel(ranges, a) {
return ranges.some(([start, end]) => a.startLine >= start && a.endLine <= end);
}
test('unique quote moves', () => {
assert.deepEqual(relocateModel({startLine:2,quoted:'target();'},'a();\nb();\nc();\ntarget();\n'),{startLine:4,endLine:4});
});
test('deleted quote has no text match', () => {
assert.equal(relocateModel({startLine:2,quoted:'target();'},'a();\nb();\nc();\n'),null);
});
test('duplicate quote can select a nearer occurrence than the intended one', () => {
const contents = [
'header();', // line 1
'repeat();', // line 2: lexical duplicate, nearer
'other();', // line 3: original anchor position before edit
'new();',
'changed();',
'gap();',
'repeat();', // line 7: hypothetical semantic target after refactor
].join('\n') + '\n';
const r = relocateModel({startLine:3,quoted:'repeat();'}, contents);
assert.deepEqual(r,{startLine:2,endLine:2});
assert.notEqual(r.startLine,7); // heuristic ambiguity, NOT an observed Diffle UI defect
});
test('a matching line beyond shown hunks is not visible', () => {
const r = relocateModel({startLine:2,quoted:'target();'},'a();\nb();\nc();\ntarget();\n');
assert.equal(isShownModel([[1,2]],r),false);
});
展开:2026 年 10 月 12 日的原始 TAP 测试输出
TAP version 13
# Subtest: unique quote moves
ok 1 - unique quote moves
---
duration_ms: 7.325889
type: 'test'
...
# Subtest: deleted quote has no text match
ok 2 - deleted quote has no text match
---
duration_ms: 0.916348
type: 'test'
...
# Subtest: duplicate quote can select a nearer occurrence than the intended one
ok 3 - duplicate quote can select a nearer occurrence than the intended one
---
duration_ms: 4.069714
type: 'test'
...
# Subtest: a matching line beyond shown hunks is not visible
ok 4 - a matching line beyond shown hunks is not visible
---
duration_ms: 2.261519
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1124.334836