Diffle 的文字錨點:Coding Agent 改動程式後,審查留言還會指向哪一行?
Diffle 的文字錨點:Coding Agent 改動程式後,審查留言還會指向哪一行?
我在 Coding Agent 修改的程式旁留下一條逐行 Review:這裡的判斷條件不對,請改一下。Agent 修完,順手拆開函式、加了幾行檢查。等我再打開 diff,原本放在第 18 行的留言,還是在說同一段程式嗎?
這是我對 Diffle 感興趣的原因。它能在本機檢視 Git diff、留下逐行留言,再把回饋交給 Agent,或加入 GitHub 的 Pending Review。但 Agent 一輪又一輪修改,留言總不能永遠只靠舊行號找位置。
我讀的是固定在 a69274d 的原始碼(0.2.4)。做法其實很直接:記住當時的原文、尋找完全相同的文字;如果有多個候選,就選距離舊行號最近的,再依目前 diff 的可見範圍標示過時位置。 單純的行號移動可以處理,但兩段程式文字完全一樣時呢?我用 Node.js 內建測試工具跑了四組獨立撰寫的最小模型案例,看看這個取捨。本文是固定原始碼的分析與合成模型測試,不是 Diffle CLI 或瀏覽器的實機評測。
留言記住的是文字,不只是第 18 行
Diffle 把留言存在儲存庫的 <git-dir>/diffle/,不會直接寫入工作樹的原始碼。CommentStore.ts 會將討論串和錨點保存在 comments.json。逐行錨點不只有檔案路徑、diff 左右側與原先的起訖行號,還包含當時選取的 quoted 文字。
在 anchor.ts 裡,relocate() 逐行搜尋完全相同的引用文字。遇到多個結果時,靠這段判斷選擇:
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。引用內容消失、比較側不存在,或新位置已不在目前可見範圍內,逐行留言就可能變成 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 行:前者只差一行,後者差四行。模型選擇了第 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 能將檔案路徑、行號、引用原文和留言內容轉成給 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 仍由人來提交。官方 README 也清楚說明它不會代替使用者送出。我喜歡這個分工:Agent 能修改,工具能整理,但真的要讓同事看見的回饋,還是值得自己再讀一次。
真要放進日常工作流程之前
我會先找個沒有敏感資料的暫存儲存庫,只看工作樹 diff,並關閉不必要的語言伺服器。官方命令與選項可以這樣組合(不是這次已執行的 Diffle 實測輸出):
diffle working --no-lsp
固定版本 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