# Gli ancoraggi testuali di Diffle: dove finiscono i commenti di review quando un Coding Agent riscrive il codice?

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

# Gli ancoraggi testuali di Diffle: dove finiscono i commenti di review quando un Coding Agent riscrive il codice?

Lascio un commento su una riga modificata da un Coding Agent: «Questa condizione è sbagliata, correggila». L'agente la corregge, ma intanto divide la funzione e aggiunge qualche controllo. Quando riapro il diff, il commento che avevo lasciato alla riga 18 si riferisce ancora allo stesso pezzo di codice?

È questa la domanda che mi ha portato a esaminare [Diffle](https://github.com/moritzwilksch/diffle). È un visualizzatore locale di Git diff: permette di commentare righe, passare il feedback a un agente o aggiungerlo a una Pending Review di GitHub. Se però l'agente modifica il file più volte, non possiamo pretendere che il vecchio numero di riga resti un riferimento affidabile.

Ho letto il sorgente fissato al commit [`a69274d`](https://github.com/moritzwilksch/diffle/tree/a69274d512a588dd86714bef103c8dbc2cadb2cf) (versione 0.2.4). L'idea è piuttosto lineare: **ricordare il testo selezionato, cercare corrispondenze esatte, scegliere quella più vicina alla vecchia riga quando ce ne sono diverse e segnare come superata una posizione che esce dal diff visibile.** Gli spostamenti normali si possono gestire. Ma se due righe sono identiche? Per capire questo compromesso ho eseguito quattro casi in un piccolo modello indipendente scritto con i moduli integrati di Node.js. Qui analizzo il codice fissato e i risultati del modello sintetico, non una prova pratica della CLI o dell'interfaccia di Diffle.

## Il commento ricorda il testo, non solo la riga 18

Diffle salva i commenti in `<git-dir>/diffle/`, senza toccare i file sorgente nel working tree. In [`CommentStore.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/CommentStore.ts), thread e ancoraggi vengono scritti in `comments.json`. Un ancoraggio di riga conserva il percorso del file, il lato del diff, la riga iniziale e finale originarie e il frammento selezionato come `quoted`.

In [`anchor.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/anchor.ts), `relocate()` cerca quel testo con un confronto esatto riga per riga. Se trova più candidati, la scelta passa da qui:

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

Se un blocco passa dalla riga 18 alla 26 senza cambiare contenuto, il commento può seguirlo. E dato che la condizione è `dist < bestDist`, a parità di distanza resta il primo candidato trovato.

Un testo uguale, tuttavia, non basta. [`CommentStore.relocateAll()`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/CommentStore.ts) verifica anche il lato del confronto e gli hunk del diff attualmente visibili. Se il testo citato sparisce, il lato non esiste più o il nuovo punto è fuori dagli hunk mostrati, il commento di riga può diventare `stale`. Per i commenti sull'intero file, invece, conta se il file fa ancora parte della review.

Questa cautela mi convince. Trovare la stessa sequenza di caratteri non significa avere trovato lo stesso punto logico.

## E se la riga 18 avesse un gemello?

Torniamo all'esempio iniziale. In un progetto reale ci sono spesso vari `return false;`, oppure più rami che eseguono `logger.info('done');`. Immagina di avere commentato l'ultimo ramo di fallback. Dopo il refactoring quel ramo si sposta molto più lontano, mentre vicino alla vecchia posizione rimane un'istruzione identica. Un algoritmo basato solo sul testo e sulla distanza non sa quale ramo volessi seguire.

Per isolare questo comportamento, nel modello indipendente ho usato `repeat();`. È il caso più interessante dei quattro test realmente eseguiti. Il codice completo e riproducibile è nelle sezioni espandibili in fondo; qui basta l'asserzione cruciale:

```js
const contents = [
  'header();',   // riga 1
  'repeat();',   // riga 2: duplicato più vicino
  'other();',    // riga 3: posizione precedente del commento
  'new();',
  'changed();',
  'gap();',
  'repeat();',   // riga 7: obiettivo semantico ipotetico
].join('\n') + '\n';

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

Il commento si trovava alla riga 3. Dopo la modifica, due `repeat();` compaiono alle righe 2 e 7. Il primo dista una riga dalla posizione precedente, il secondo quattro. Il modello sceglie la riga 2, anche se nel mio scenario ho designato la riga 7 come ipotetico obiettivo logico originale.

**Il punto più vicino non è necessariamente lo stesso codice.** Quell'obiettivo «vero» è un'ipotesi del test: la funzione di ricerca non comprende l'identità semantica. L'applicazione effettiva filtra anche per percorso, lato del diff, hunk e stato dei thread. Il risultato dimostra quindi un'ambiguità della regola isolata, non un bug di commenti spostati che avrei riprodotto nell'interfaccia di Diffle.

Ho eseguito anche gli altri tre casi. Ecco i quattro risultati osservati nel modello:

| Caso sintetico | Risultato reale | Cosa mostra |
| --- | --- | --- |
| Un frammento citato univoco si sposta | Viene trovato alla riga 4 | Il testo segue gli spostamenti ordinari |
| Il testo citato viene cancellato | Restituisce `null` | Non inventa una posizione quando il testo scompare |
| Testo identico alle righe 2 e 7 | Sceglie la riga 2 | Vicinanza non significa identità semantica |
| Testo alla riga 4, hunk visibile solo alle righe 1–2 | Restituisce `false` | Una corrispondenza può essere fuori dal diff visibile |

Ho eseguito i test il 12 ottobre 2026 con Node.js `v22.16.0`: **4 test superati su 4, codice di uscita 0 e stderr vuoto**. Lo script usa soltanto `node:test` e `node:assert/strict`, moduli integrati di Node, senza caricare il pacchetto npm di Diffle. Il requisito Node ≥24 di quel pacchetto quindi non si applica a questo modello. Il comando era `TERM=dumb node --test --test-reporter=tap anchor-relocation-model.test.mjs`. I quattro casi completi e l'output TAP originale, non modificato, sono consultabili negli allegati espandibili.

Per ridurre l'ambiguità si potrebbero usare righe di contesto, confini di funzione o un'impronta del contesto. Anche questi indizi, però, possono cambiare durante un refactoring. È un compromesso progettuale, non un problema risolto aggiungendo un campo. Il matcher di Diffle 0.2.4 qui esaminato non implementa queste ulteriori informazioni semantiche.

## Passare commenti a un Agent non equivale a inviare una review su GitHub

Dopo la revisione locale, [`format.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/format.ts) può preparare un prompt per l'agente con percorso del file, riga, codice citato e testo del commento. Un blocco `suggestion` viene trasformato in `ORIGINAL` / `SUGGESTED`; le osservazioni obsolete portano l'etichetta `stale`. È più utile di un vago «correggi ancora quella parte»: la prossima iterazione ha un frammento preciso da ricontrollare.

Esportare commenti in una PR GitHub, però, ha un limite diverso. [`review.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/github/review.ts) rifiuta esplicitamente di usare come ancoraggio GitHub le modifiche presenti solo in un worktree non ancora sottoposto a commit:

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

È ragionevole. Se quelle righe esistono soltanto sul mio computer, chi apre la PR non vede lo stesso diff. Agganciare commenti a «numeri di riga fantasma» renderebbe la discussione incomprensibile.

Quando il confronto corrisponde al diff già incluso nei commit della PR, Diffle salta i thread `stale` e aggiunge i commenti idonei a **Pending Review**. Non invia automaticamente la review definitiva: lo specifica anche il [README ufficiale](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/README.md). Apprezzo questa separazione: l'agente può cambiare il codice e il tool può organizzare il feedback, ma quello che i colleghi leggeranno voglio ricontrollarlo una volta personalmente.

## Prima di inserirlo nel mio flusso di lavoro

Comincerei da un repository temporaneo senza dati sensibili, guardando soltanto il diff del working tree e disattivando i language server non necessari. I comandi documentati si possono combinare così (**non è una prova di esecuzione di Diffle effettuata in questa analisi**):

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

Nel [`package.json`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/package.json) della versione fissata, il pacchetto npm richiede Node ≥24; il progetto distribuisce anche binari autonomi che non richiedono Node. Aggiungerei `--no-lsp` di proposito: un language server rilevato automaticamente può leggere i file del repository con i permessi dell'utente corrente. Per esaminare un solo diff non serve avviarli tutti.

Quando ho analizzato [il controllo pre-push di OpenQodex](/posts/openqodex-prepush-review-fail-open/), mi chiedevo se una revisione incompleta potesse lasciare passare il codice. Con Diffle la questione viene ancora prima: un commento umano punta ancora al codice previsto dopo la successiva modifica dell'agente? Verificare se i test generati rilevano davvero gli errori è un'altra domanda, legata a [test e Mutation Testing](/posts/ai-generated-tests-mutation-testing/).

La prossima volta che scriverò «c'è un problema alla riga 18», darò comunque un'occhiata al diff aggiornato per intero. Il tool può trovare un posto per il commento. Capire se è davvero il codice che volevo rivedere richiede più di un numero di riga.

<details>
<summary>Espandi: codice Node.js completo con quattro test eseguibili</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>Espandi: output TAP originale del 12 ottobre 2026</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>
