# Diffle 的文本锚点：Coding Agent 改动代码后，审阅评论如何重新定位

> Canonical: https://vxi.me/posts/diffle-git-diff-comment-anchoring/
> Locale: zh-cn
> Published: 2026-10-13
> Updated: 2026-10-13
> Markdown: https://vxi.me/posts/diffle-git-diff-comment-anchoring.md

# Diffle 的文本锚点：Coding Agent 改动代码后，审阅评论如何重新定位

给 Coding Agent 留了一条行级 Review：这里的判断条件不对，改一下。它改完，又顺手拆了函数、补了几行检查。等我回来看 diff，原本标在第 18 行的评论还在说同一段代码吗？

这正是我对 [Diffle](https://github.com/moritzwilksch/diffle) 感兴趣的地方。它能在本地审阅 Git diff、留下行级评论，再把意见交给 Agent 或放进 GitHub 的 Pending Review。但 Agent 一次次改代码，评论总不能永远靠旧行号找位置。

我把源码固定在 [`a69274d`](https://github.com/moritzwilksch/diffle/tree/a69274d512a588dd86714bef103c8dbc2cadb2cf)（0.2.4）读了一遍。它的办法很直接：**记住原文，找精确匹配，多个候选就选离旧行号最近的；超出本轮 diff 的位置再标记过时。** 简单的行号挪动可以处理，但两段代码长得一模一样时呢？我用 Node 内置测试跑了四组独立的匹配模型样本，想弄清这层取舍。本文核对的是固定源码与小模型，不是 Diffle CLI 或浏览器的实机评测。

## 评论记住的是原文，不只是第 18 行

Diffle 把评论放在仓库的 `<git-dir>/diffle/`，不会写进工作区源码。它的 [`CommentStore.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/CommentStore.ts) 用 `comments.json` 保存线程和锚点。一条行级评论不仅有路径、左右比较侧和起止行号，还留着当时选中的文本 `quoted`。

再看 [`anchor.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/anchor.ts) 的 `relocate()`：它逐行寻找与 `quoted` 相同的文本。找到多处时，决定选哪处的代码是：

```ts
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()`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/CommentStore.ts) 还会检查比较侧和当前展示的 diff hunk：引用文本消失、比较侧不存在，或新位置不在可见 hunk 中，行级评论就可能变成 `stale`。文件级评论则按文件是否还在本轮 review 判断。

我喜欢这层保护。它并不假装“只要找到同一串字符，评论就一定找对了地方”。

## 第 18 行遇到两段一模一样的代码

回到开头。真实代码里常常不缺重复的 `return false;`，或者好几个分支都在打 `logger.info('done');`。假设原评论盯的是最后那个兜底分支，Agent 重构后它跑远了，旧位置旁边又有一句长得一样的代码。文本匹配看不出哪一句才是我想追踪的逻辑。

为了把问题缩到可以检验的程度，我在独立模型里把它们换成 `repeat();`。这是四组测试中最有意思的一组，完整的可执行脚本放在下方折叠区；这里先看关键断言：

```js
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`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/format.ts) 可以把路径、行号、引用内容和评论整理成给 Agent 的提示词。带有 `suggestion` 围栏的内容还会被拆成 `ORIGINAL` / `SUGGESTED`，过时评论会带上 `stale` 标记。这比只发一句“上次那里改得不对”清楚得多：下一轮修改至少有具体代码可核对。

但把评论交给 GitHub PR 是另一回事。[`review.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/github/review.ts) 明确拒绝把尚未提交的 worktree 当成 GitHub 评论锚点：

```ts
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](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/README.md) 也明确说明它不会替用户提交 Review。

## 放进日常工作流之前

如果要试，我会先找一个没有敏感数据的临时仓库，从工作区 diff 开始，并关闭没必要启动的语言服务器。按官方命令选项可以这样组合（这条是准备使用的命令，不是本次实机输出）：

```sh
diffle working --no-lsp
```

Diffle 固定版本的 [`package.json`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/package.json) 对 npm 安装要求 Node ≥24；官方也提供不依赖 Node 的独立二进制。`--no-lsp` 是有意加上的：自动发现的语言服务器会在当前用户权限下读取仓库文件，没有必要为了看一次纯 diff 就把它们全部拉起来。

之前写 [OpenQodex 的 pre-push 审阅门禁](/posts/openqodex-prepush-review-fail-open/) 时，我更关心的是不完整的审阅会不会错误放行；这次关心的是更早的一步——一条人工反馈在下一轮 Agent 改动后，还能不能正确指向代码。至于改完的测试是否真正能抓住错误，那是[测试与 Mutation Testing](/posts/ai-generated-tests-mutation-testing/)要回答的问题。

以后再遇到“第 18 行这里有问题”，我会多看一眼这一轮完整的 diff。工具可以努力帮评论找到落点，但哪段代码才是我真正要审的，不能只交给一个行号决定。

<details>
<summary>展开：完整可运行的 Node.js 四组测试</summary>

```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);
});
```

</details>

<details>
<summary>展开：2026 年 10 月 12 日的原始 TAP 测试输出</summary>

```text
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
```

</details>
