Diffles Text-Anker: Wohin wandern Review-Kommentare, wenn ein Coding Agent den Code umbaut?

Ich hinterlasse an einer bestimmten Codezeile einen Review-Kommentar für einen Coding Agent: „Die Bedingung hier stimmt nicht, bitte korrigieren.“ Der Agent korrigiert sie, teilt nebenbei die Funktion auf und ergänzt ein paar Prüfungen. Wenn ich den Diff danach erneut öffne: Bezieht sich mein Kommentar, der ursprünglich an Zeile 18 hing, noch auf dieselbe Stelle?

Darum wollte ich mir Diffle genauer ansehen. Mit dem lokalen Git-Diff-Viewer lassen sich Zeilen kommentieren; die Rückmeldungen können als Prompt an einen Agent gehen oder in einem GitHub Pending Review landen. Aber nach mehreren Agent-Änderungen kann sich ein Kommentar nicht dauerhaft auf seine alte Zeilennummer verlassen.

Ich habe den Quellcode auf Commit a69274d festgelegt (Version 0.2.4). Der Mechanismus ist erstaunlich überschaubar: Originaltext speichern, exakt gleiche Stellen suchen, bei mehreren Treffern den zur alten Zeilennummer nächsten auswählen; Treffer außerhalb des sichtbaren Diffs als veraltet behandeln. Normale Zeilenverschiebungen lassen sich damit abfangen. Was passiert aber bei zwei identischen Codezeilen? Dafür habe ich vier Fälle in einem selbst geschriebenen, unabhängigen Node.js-Modell tatsächlich ausgeführt. Das ist eine Quellcodeanalyse mit synthetischem Modelltest, kein praktischer Test der Diffle-CLI oder der Browseroberfläche.

Ein Kommentar merkt sich den Text, nicht bloß Zeile 18

Diffle speichert Kommentare unter <git-dir>/diffle/ und schreibt sie nicht in die Dateien des Working Trees. In CommentStore.ts landen Threads und Anker in comments.json. Zu einem zeilenbezogenen Anker gehören Dateipfad, Diff-Seite, ursprüngliche Start- und Endzeile und der ausgewählte Text als quoted.

In anchor.ts sucht relocate() Zeile für Zeile nach diesem exakt übereinstimmenden Ausschnitt. Bei mehreren Kandidaten entscheidet folgender Teil:

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

Wandert der Code etwa von Zeile 18 nach Zeile 26, ohne dass sich der Text verändert, kann der Kommentar mitwandern. Durch dist < bestDist bleibt bei gleichem Abstand außerdem der zuerst gefundene Kandidat erhalten.

Ein Texttreffer allein reicht trotzdem nicht. CommentStore.relocateAll() prüft zusätzlich die Vergleichsseite und die aktuell sichtbaren Diff-Hunks. Wurde der zitierte Text gelöscht, fehlt die betreffende Seite oder liegt der neue Treffer außerhalb der sichtbaren Hunks, kann der Zeilenkommentar als stale markiert werden. Bei Dateikommentaren zählt dagegen, ob die Datei überhaupt noch Teil des Reviews ist.

Diese Absicherung gefällt mir. Dass dieselbe Zeichenfolge gefunden wurde, bedeutet noch lange nicht, dass auch dieselbe logische Stelle gemeint ist.

Was, wenn Zeile 18 einen Doppelgänger hat?

Zurück zum Beispiel am Anfang. In echten Projekten steht return false; oft an mehreren Stellen. Manchmal schreiben gleich mehrere Zweige logger.info('done'); ins Log. Stell dir vor, der Review-Kommentar bezieht sich auf den letzten Fallback-Zweig. Beim Refactoring rutscht dieser weit nach unten, während in der Nähe der alten Position eine identische Zeile stehen bleibt. Nur anhand von Text und Abstand lässt sich die beabsichtigte Stelle nicht erkennen.

Um genau diesen Teil zu testen, habe ich im unabhängigen Modell die Anweisung repeat(); verwendet. Von den vier tatsächlich ausgeführten Fällen ist dieser am interessantesten. Der vollständige, ausführbare Test steht im ausklappbaren Anhang; hier zunächst die entscheidende Assertion:

const contents = [
  'header();',   // Zeile 1
  'repeat();',   // Zeile 2: nähere Dublette
  'other();',    // Zeile 3: alte Kommentarposition
  'new();',
  'changed();',
  'gap();',
  'repeat();',   // Zeile 7: angenommenes semantisches Ziel
].join('\n') + '\n';

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

Die alte Kommentarposition ist Zeile 3. Danach gibt es repeat(); in Zeile 2 und 7. Der Abstand zur alten Position beträgt also eine beziehungsweise vier Zeilen. Das Modell entscheidet sich für Zeile 2 – obwohl ich für diesen Test Zeile 7 als das hypothetisch ursprünglich gemeinte logische Ziel festgelegt habe.

Der nächstgelegene Treffer ist nicht zwangsläufig dieselbe Code-Stelle. Das „eigentliche Ziel“ in Zeile 7 ist eine Annahme meines Testaufbaus, kein Wissen der Matching-Funktion. Die reale Anwendung prüft außerdem Pfad, Diff-Seite, Hunk und Thread-Status. Der Test zeigt deshalb die Mehrdeutigkeit der isolierten Regel, aber keinen nachgewiesenen Fehler in der Diffle-Oberfläche.

Die anderen drei Fälle habe ich ebenfalls ausgeführt. Alle vier Beobachtungen im Überblick:

Synthetischer FallTatsächliches ErgebnisBedeutung
Ein eindeutiger Textausschnitt wird verschobenZu Zeile 4 verlagertEinfache Zeilenverschiebungen lassen sich verfolgen
Zitierter Text wird gelöschtRückgabe nullEine verschwundene Stelle wird nicht erfunden
Identischer Text in Zeile 2 und 7Auswahl von Zeile 2Nähe ist keine semantische Identität
Text steht in Zeile 4, sichtbarer Hunk umfasst 1–2Rückgabe falseDer Treffer kann außerhalb des aktuellen Diffs liegen

Ausgeführt habe ich den Test am 12. Oktober 2026 mit Node.js v22.16.0: 4 von 4 Tests bestanden, Exit-Code 0, stderr leer. Das Modell verwendet nur die eingebauten Module node:test und node:assert/strict; das npm-Paket von Diffle wird dabei nicht geladen. Dessen Anforderung Node ≥24 gilt daher nicht für diesen eigenständigen Test. Der Befehl war TERM=dumb node --test --test-reporter=tap anchor-relocation-model.test.mjs. Vollständiger Testcode und unveränderte TAP-Ausgabe bleiben unten ausklappbar erhalten.

Man könnte zusätzliche Hinweise berücksichtigen: benachbarte Zeilen, Funktionsgrenzen oder einen Kontext-Fingerabdruck. Beim Refactoring können aber auch diese Merkmale verschwinden. Das ist eine Abwägung, keine Garantie durch ein weiteres Feld. Der hier untersuchte Matcher in Diffle 0.2.4 implementiert solche zusätzlichen semantischen Signale nicht.

Kommentare an einen Agent übergeben ist nicht dasselbe wie einen GitHub-Review absenden

Nach dem lokalen Review kann format.ts Dateipfad, Zeilennummer, zitierten Code und Kommentartext in einen Agent-Prompt verwandeln. Ein suggestion-Codeblock wird in ORIGINAL und SUGGESTED aufgeteilt; veraltete Kommentare tragen stale. Das ist wesentlich konkreter als „Bitte die Stelle von vorhin noch mal korrigieren“. Für die nächste Änderung ist klar, welcher Ausschnitt geprüft werden soll.

Für den Export in einen GitHub-PR gilt eine zusätzliche Grenze. review.ts lehnt es ausdrücklich ab, einen noch nicht committeten Worktree-Diff als GitHub-Kommentaranker zu verwenden:

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

Das leuchtet ein. Zeilen, die nur auf meinem Rechner existieren, sind im PR-Diff für andere gar nicht sichtbar. Kommentare an solche „Geister-Zeilennummern“ zu hängen, würde die Zusammenarbeit eher erschweren.

Wenn der Vergleich zum committeten PR-Diff passt, überspringt Diffle die stale-Threads und fügt geeignete Kommentare einem Pending Review hinzu. Der endgültige Review wird nicht automatisch abgeschickt. Das steht auch in der offiziellen README. Diese Trennung halte ich für sinnvoll: Der Agent darf Code ändern, das Werkzeug Feedback ordnen – aber bevor Kolleginnen und Kollegen meine Kommentare sehen, lese ich sie lieber noch einmal selbst.

Bevor ich es in meinen Arbeitsalltag übernehme

Ich würde mit einem temporären Repository ohne vertrauliche Daten anfangen, nur den Working-Tree-Diff ansehen und unnötige Language Server deaktivieren. Aus den dokumentierten Optionen ergibt sich zum Beispiel dieser Aufruf (nicht von mir als Diffle-Lauf ausgeführt):

diffle working --no-lsp

Das package.json des festgelegten Stands verlangt für das npm-Paket Node ≥24. Es gibt aber auch eigenständige Binärdateien ohne Node-Abhängigkeit. --no-lsp würde ich bewusst setzen: Automatisch gefundene Language Server dürfen Repository-Dateien im Rahmen der Rechte des angemeldeten Nutzers lesen. Nur für einen Diff müssen sie nicht starten.

Bei meinem früheren Blick auf OpenQodex und die pre-push-Review-Schranke ging es darum, ob unzureichend geprüfter Code trotzdem passieren kann. Hier stellt sich die Frage einen Schritt vorher: Zeigt menschliches Feedback nach der nächsten Agent-Änderung überhaupt noch auf den richtigen Code? Ob generierte Tests die relevanten Fehler erkennen, ist wiederum Thema von Tests und Mutation Testing.

Wenn ich das nächste Mal „Hier stimmt Zeile 18 nicht“ schreibe, werde ich auch den aktuellen Diff als Ganzes prüfen. Ein Tool kann einen neuen Platz für den Kommentar finden. Ob es noch der Code ist, den ich wirklich meinte, entscheidet keine Zeilennummer allein.

Ausklappen: vollständiger ausführbarer Node.js-Test mit vier Fällen
/**
 * 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);
});
Ausklappen: unveränderte TAP-Ausgabe vom 12. Oktober 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