# Diffleのテキストアンカー：Coding Agentがコードを書き換えた後、レビューコメントはどこへ行くのか

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

# Diffleのテキストアンカー：Coding Agentがコードを書き換えた後、レビューコメントはどこへ行くのか

Coding Agentが編集したコードに、行単位のレビューコメントを残す。「この条件分岐が違うので直してほしい」。エージェントはそこを直し、ついでに関数を分割してチェックを何行か増やした。もう一度diffを開いたとき、元の18行目に付けたコメントは、まだ意図したコードを指しているだろうか。

この疑問から[Diffle](https://github.com/moritzwilksch/diffle)を調べた。ローカルのGit diffを見ながらコメントを書き、Coding Agent向けのプロンプトにしたり、GitHubのPending Reviewに追加したりできるツールだ。ただ、Agentに修正を何度も頼むなら、コメントを古い行番号だけで管理するわけにはいかない。

今回読んだのはコミット[`a69274d`](https://github.com/moritzwilksch/diffle/tree/a69274d512a588dd86714bef103c8dbc2cadb2cf)に固定したソースコード（0.2.4）。方針は意外と素直だ。**元のテキストを記録し、完全一致する箇所を探し、複数あれば旧行番号に近い方を選ぶ。現在のdiffに表示されなくなった箇所は古いコメントとして扱う。** 行の単純な移動には対応できる。では同じコードが2か所にあったら？ その判断の限界を確かめるため、独自に書いた小さなNode.jsモデルで4ケースを実行した。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`扱いになり得る。ファイル全体へのコメントは、そのファイルが今回のレビューに残っているかで判定される。

この防御線は良いと思う。同じ文字列を発見したからといって、同じ意味の場所が見つかったとは限らないからだ。

## 18行目と同じコードがもう一つあったら

最初の場面に戻ろう。実際のコードでは`return false;`が何か所にも出てきたり、複数の分岐で`logger.info('done');`を呼んでいたりする。コメントを付けたのは最後のフォールバック処理なのに、リファクタリングでその処理が遠くに移され、以前の行に近いところに同じ文が残っていたらどうなるか。文字と行番号だけでは、どちらが本来の対象か分からない。

この点だけを確かめるため、独立モデルでは同じ文を`repeat();`にした。4件のうち中心になるテストがこれだ。実行したスクリプトの全文は文末の折りたたみに残し、ここでは核心のアサーションだけ示す。

```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行目だ。

**一番近い場所が、同じ処理とは限らない。** 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`](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');
```

これは自然な制約だ。自分のPCにしか存在しない未コミットの数行を、同僚がPRで開いても表示できない。存在しない「幽霊の行番号」にレビューを付けても、会話が噛み合わなくなるだけだろう。

PRのコミット済み差分と比較内容が一致する場合、Diffleは`stale`なスレッドを飛ばして、対象のコメントを**Pending Review**に追加する。最終的なReviewの送信までは行わない。[公式README](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/README.md)にもその境界が記載されている。Agentには修正を任せ、ツールにはコメントを整理してもらう。それでもチームに見せるレビューは、自分で最後に読み直したい。

## 日常の作業に入れるなら

まず機密情報を含まない使い捨てのGitリポジトリで試す。作業ツリーのdiffを表示し、不要なLanguage Serverは起動しない。公式のコマンドとオプションを組み合わせると次の形になる（**今回はDiffle自体で実行していない**）。

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

固定版の[`package.json`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/package.json)ではnpm版にNode ≥24が必要だが、プロジェクトにはNode不要のスタンドアロンバイナリもある。`--no-lsp`を付けるのは意図的だ。自動検出されたLanguage Serverは現在のユーザー権限でリポジトリのファイルを読める。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で実行できる4つのテスト全文</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>
