Diffle’s Text Anchors: Where Do Review Comments Go After a Coding Agent Rewrites the Code?

I leave a line-level review comment for a coding agent: the condition here is wrong; please fix it. The agent fixes it, then also splits the function and adds a few checks. When I reopen the diff, is the comment that used to sit on line 18 still talking about the same code?

That question drew me to Diffle. It’s a local Git diff viewer where you can leave comments and hand them to an agent or put them into a GitHub Pending Review. But when the agent keeps editing, a comment can’t rely on its old line number forever.

I read Diffle’s source at pinned commit a69274d (version 0.2.4). Its approach is intentionally simple: remember the quoted code, find an exact match, prefer the one nearest the old line, then mark a location stale if it no longer belongs to the visible diff. That handles ordinary line shifts. What happens when two pieces of code are identical? I ran four cases against a small, independently written Node.js model to examine the trade-off. This is a source-code analysis and a synthetic model test, not a hands-on Diffle CLI or browser benchmark.

A comment remembers the text, not just line 18

Diffle stores comments under <git-dir>/diffle/ rather than writing them into your working-tree files. Its CommentStore.ts saves threads and anchors in comments.json. An anchor contains the file path, diff side, old start and end lines, and the selected snippet as quoted text.

In anchor.ts, relocate() searches line by line for that exact quoted text. When there are several matches, this is the part that chooses between them:

const start = i + 1;
const dist = Math.abs(start - anchor.startLine);
if (dist < bestDist) {
  best = start;
  bestDist = dist;
  if (dist === 0) break;
}

If a block moves from line 18 to line 26 without changing its text, the comment may move with it. And because the comparison is dist < bestDist, a tie leaves the first candidate in place.

There is a second check. CommentStore.relocateAll() checks the comparison side and currently visible diff hunks. A deleted quote, missing side, or newly matched location outside those hunks can leave a line comment marked stale. A file-level comment instead depends on whether the file remains part of the review.

I like that safeguard. Merely finding the same string shouldn’t automatically mean the same logical place was found.

What if line 18 has a twin?

Back to the opening example. Real projects have plenty of repeated return false; lines, or branches that all log logger.info('done');. Imagine the comment originally belongs to the final fallback branch. A refactor moves that branch far away, but another identical line now sits close to the previous comment location. The matching algorithm cannot infer which one I meant.

To isolate the behavior, my model uses repeat();. This is the revealing case out of the four I actually ran. The complete runnable script is in the expandable section below; first, look at the key assertion:

const contents = [
  'header();',   // line 1
  'repeat();',   // line 2: nearer duplicate
  'other();',    // line 3: old comment position
  'new();',
  'changed();',
  'gap();',
  'repeat();',   // line 7: hypothetical semantic target
].join('\n') + '\n';

const r = relocateModel(
  { startLine: 3, quoted: 'repeat();' }, contents
);
assert.deepEqual(r, { startLine: 2, endLine: 2 });
assert.notEqual(r.startLine, 7);

The old anchor is at line 3. After the edit, identical repeat(); calls sit on lines 2 and 7. One is one line away; the other is four lines away. The model chooses line 2, even though I deliberately designated line 7 as the hypothetical original logical target.

Nearest is not necessarily the same thing. That logical target exists only in my test setup; the matching function cannot recognize semantic identity. The actual application also checks paths, sides, hunks and thread state, so this observation does not establish a Diffle UI bug.

I ran three other cases alongside it. Here is what all four independent tests actually returned:

Synthetic caseObserved resultWhat it tells us
A unique quote movesRelocates to line 4Text matching can follow ordinary shifts
The quoted text is deletedReturns nullNo invented location when the text disappears
Identical text on lines 2 and 7Chooses line 2Proximity is not semantic identity
Text is on line 4 but the shown hunk spans 1–2Returns falseAn exact text match can still be outside the diff

I ran the test on October 12, 2026, with Node.js v22.16.0: 4/4 passed, exit code 0, empty stderr. The script uses only the built-in node:test and node:assert/strict, not Diffle’s npm package, so the package’s Node ≥24 engine requirement does not apply to this fixture. The command was TERM=dumb node --test --test-reporter=tap anchor-relocation-model.test.mjs. The exact four-case source and unedited TAP output are available in the two expandable sections at the end.

You could try to disambiguate duplicates with neighboring lines, function boundaries or a context fingerprint. But refactors can also invalidate those signals. It’s a trade-off, not a matter of adding a field and declaring the problem solved; the pinned Diffle 0.2.4 matcher does not implement those extra semantic signals.

Sending a comment to an agent isn’t the same as posting a GitHub review

After a local review, format.ts can turn a thread into a prompt containing the file path, line, quoted code and comment body. A suggestion fence becomes an ORIGINAL / SUGGESTED pair, and stale comments are labeled. That’s a better handoff than asking an agent to “fix that bit from last time”: at least there is specific code to check on the next iteration.

Sending those comments to a GitHub PR is a different operation. review.ts explicitly rejects a worktree-only diff as a GitHub comment anchor:

if (snap.newSha === 'worktree')
  throw new GithubError('GitHub cannot anchor comments to the worktree');

That makes sense. If the changed lines exist only in my uncommitted worktree, collaborators opening the PR don’t have that diff. Attaching comments to those ghost line numbers would be a confusing review experience.

When the comparison matches a committed PR diff, Diffle skips stale threads and adds eligible comments to Pending Review. Submitting that review remains a human action; the official README says the tool never submits it on your behalf. I appreciate that separation: an agent can revise the code and a tool can organize my feedback, but the comments my colleagues actually see deserve one final read from me.

Before I’d add it to my routine

I’d start with a disposable repository that contains no sensitive data. I’d inspect the working-tree diff and turn off unnecessary language servers. The following combines the documented command and option; it is not output from a Diffle run in this study:

diffle working --no-lsp

The pinned package.json requires Node ≥24 for the npm package, while the project also offers standalone binaries that do not require Node. I would keep --no-lsp initially because auto-discovered language servers can read repository files under the current user’s permissions. There’s no need to start them just to inspect one diff.

When I previously looked at OpenQodex’s pre-push review gate, the question was whether incomplete review could let unreviewed code through. With Diffle the question comes earlier: can human feedback still point at the intended code after another agent edit? Whether generated tests catch the right failures belongs to the separate discussion of testing and Mutation Testing.

The next time I leave a note saying “line 18 has a problem,” I’ll also look at the whole updated diff. A tool can help a comment find somewhere to land. Deciding whether that is still the code I meant to review takes more than a line number.

Show the full runnable Node.js test script (four cases)
/**
 * 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);
});
Show the original TAP output from October 12, 2026
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