# Diffle 的文字錨點：Coding Agent 改動程式後，審查留言還會指向哪一行？

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

# Diffle 的文字錨點：Coding Agent 改動程式後，審查留言還會指向哪一行？

我在 Coding Agent 修改的程式旁留下一條逐行 Review：這裡的判斷條件不對，請改一下。Agent 修完，順手拆開函式、加了幾行檢查。等我再打開 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.js 內建測試工具跑了四組獨立撰寫的最小模型案例，看看這個取捨。本文是固定原始碼的分析與合成模型測試，不是 Diffle CLI 或瀏覽器的實機評測。

## 留言記住的是文字，不只是第 18 行

Diffle 把留言存在儲存庫的 `<git-dir>/diffle/`，不會直接寫入工作樹的原始碼。[`CommentStore.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/CommentStore.ts) 會將討論串和錨點保存在 `comments.json`。逐行錨點不只有檔案路徑、diff 左右側與原先的起訖行號，還包含當時選取的 `quoted` 文字。

在 [`anchor.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/anchor.ts) 裡，`relocate()` 逐行搜尋完全相同的引用文字。遇到多個結果時，靠這段判斷選擇：

```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。引用內容消失、比較側不存在，或新位置已不在目前可見範圍內，逐行留言就可能變成 `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 行：前者只差一行，後者差四行。模型選擇了第 2 行；但我在這個測試情境中，刻意把第 7 行指定為原先真正想追蹤的邏輯目標。

**距離最近，不等於邏輯上是同一處。** 那個「真正的目標」只是測試者設下的假設，文字比對函式並不理解語意。實際產品還會檢查檔案路徑、比較側、hunk 和討論串狀態，因此這是抽取出來的規則所存在的歧義，不能當成已重現的 Diffle UI 缺陷。

另外三組也跑過了，四組獨立模型的實際結果如下：

| 合成測試情境 | 實際回傳 | 代表什麼 |
| --- | --- | --- |
| 唯一的引用文字移動 | 定位到第 4 行 | 一般行號位移能跟隨原文 |
| 被引用的文字刪除 | 回傳 `null` | 找不到就不強行指回舊行 |
| 第 2、7 行有相同文字 | 選第 2 行 | 距離不是語意身分 |
| 文字在第 4 行，hunk 只顯示第 1–2 行 | 回傳 `false` | 找到原文也可能離開本次 diff |

測試於 2026 年 10 月 12 日在 Node.js `v22.16.0` 執行，**四組全數通過，退出碼 0，stderr 為空**。用的只有 Node 內建 `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 Review 是兩回事

在本機整理完意見後，[`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 仍由人來提交。[官方 README](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/README.md) 也清楚說明它不會代替使用者送出。我喜歡這個分工：Agent 能修改，工具能整理，但真的要讓同事看見的回饋，還是值得自己再讀一次。

## 真要放進日常工作流程之前

我會先找個沒有敏感資料的暫存儲存庫，只看工作樹 diff，並關閉不必要的語言伺服器。官方命令與選項可以這樣組合（**不是**這次已執行的 Diffle 實測輸出）：

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

固定版本 [`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>
