Diffleのテキストアンカー:Coding Agentがコードを書き換えた後、レビューコメントはどこへ行くのか
Diffleのテキストアンカー:Coding Agentがコードを書き換えた後、レビューコメントはどこへ行くのか
Coding Agentが編集したコードに、行単位のレビューコメントを残す。「この条件分岐が違うので直してほしい」。エージェントはそこを直し、ついでに関数を分割してチェックを何行か増やした。もう一度diffを開いたとき、元の18行目に付けたコメントは、まだ意図したコードを指しているだろうか。
この疑問からDiffleを調べた。ローカルのGit diffを見ながらコメントを書き、Coding Agent向けのプロンプトにしたり、GitHubのPending Reviewに追加したりできるツールだ。ただ、Agentに修正を何度も頼むなら、コメントを古い行番号だけで管理するわけにはいかない。
今回読んだのはコミットa69274dに固定したソースコード(0.2.4)。方針は意外と素直だ。元のテキストを記録し、完全一致する箇所を探し、複数あれば旧行番号に近い方を選ぶ。現在のdiffに表示されなくなった箇所は古いコメントとして扱う。 行の単純な移動には対応できる。では同じコードが2か所にあったら? その判断の限界を確かめるため、独自に書いた小さなNode.jsモデルで4ケースを実行した。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扱いになり得る。ファイル全体へのコメントは、そのファイルが今回のレビューに残っているかで判定される。
この防御線は良いと思う。同じ文字列を発見したからといって、同じ意味の場所が見つかったとは限らないからだ。
18行目と同じコードがもう一つあったら
最初の場面に戻ろう。実際のコードではreturn false;が何か所にも出てきたり、複数の分岐でlogger.info('done');を呼んでいたりする。コメントを付けたのは最後のフォールバック処理なのに、リファクタリングでその処理が遠くに移され、以前の行に近いところに同じ文が残っていたらどうなるか。文字と行番号だけでは、どちらが本来の対象か分からない。
この点だけを確かめるため、独立モデルでは同じ文をrepeat();にした。4件のうち中心になるテストがこれだ。実行したスクリプトの全文は文末の折りたたみに残し、ここでは核心のアサーションだけ示す。
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行目だ。
一番近い場所が、同じ処理とは限らない。 7行目を対象としたのはテスト側の設定であり、マッチング関数がコードの意味を理解したわけではない。実際のDiffleにはパス、比較側、hunk、スレッド状態の判定もある。したがって、これは抽出したマッチング規則の曖昧さを示す結果であって、製品のUIでコメントずれが起きたという実測ではない。
ほかの3ケースも含め、独立モデルで観測した結果は次の通り。
| 合成ケース | 実際の結果 | 分かること |
|---|---|---|
| 一意の引用テキストが移動 | 4行目を返す | 通常の行移動はテキストで追える |
| 引用されたテキストが削除 | nullを返す | 見つからない位置を無理に作らない |
| 2行目と7行目に同じ文 | 2行目を選ぶ | 近さは意味上の同一性ではない |
| テキストは4行目、表示hunkは1〜2行目 | falseを返す | 一致しても現在のdiff外なら見えない |
実行日は2026年10月12日、環境はNode.js v22.16.0。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');
これは自然な制約だ。自分のPCにしか存在しない未コミットの数行を、同僚がPRで開いても表示できない。存在しない「幽霊の行番号」にレビューを付けても、会話が噛み合わなくなるだけだろう。
PRのコミット済み差分と比較内容が一致する場合、Diffleはstaleなスレッドを飛ばして、対象のコメントをPending Reviewに追加する。最終的なReviewの送信までは行わない。公式READMEにもその境界が記載されている。Agentには修正を任せ、ツールにはコメントを整理してもらう。それでもチームに見せるレビューは、自分で最後に読み直したい。
日常の作業に入れるなら
まず機密情報を含まない使い捨てのGitリポジトリで試す。作業ツリーのdiffを表示し、不要なLanguage Serverは起動しない。公式のコマンドとオプションを組み合わせると次の形になる(今回はDiffle自体で実行していない)。
diffle working --no-lsp
固定版のpackage.jsonではnpm版にNode ≥24が必要だが、プロジェクトにはNode不要のスタンドアロンバイナリもある。--no-lspを付けるのは意図的だ。自動検出されたLanguage Serverは現在のユーザー権限でリポジトリのファイルを読める。diffを見るだけなら起動する必要はない。
以前OpenQodexのpre-pushレビューゲートを調べたときは、レビューが不十分なのに変更が通過しないかが気になった。今回はそれより前の話で、人間のコメントが次のAgent編集後にも正しいコードを指せるかという問題だ。作成されたテストが不具合を検出できるかは、テストとMutation Testingで考える別の論点になる。
次に「18行目がおかしい」と書くときも、更新後のdiff全体を確認するつもりだ。コメントを置く場所はツールが探せる。それが本当にレビューしたい処理かどうかは、行番号だけで決められない。
展開:Node.jsで実行できる4つのテスト全文
/**
* 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