# Los anclajes de texto de Diffle: ¿adónde van los comentarios cuando un Coding Agent modifica el código?

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

# Los anclajes de texto de Diffle: ¿adónde van los comentarios cuando un Coding Agent modifica el código?

Dejas un comentario de revisión en una línea que ha tocado un Coding Agent: «Esta condición está mal; corrígela». El agente la corrige, pero de paso divide la función y añade unas cuantas comprobaciones. Cuando vuelves a abrir el diff, ¿el comentario que estaba en la línea 18 sigue señalando el código que querías revisar?

Esa pregunta me llevó a [Diffle](https://github.com/moritzwilksch/diffle). Es un visor local de diferencias de Git que permite comentar líneas y pasar esas observaciones al agente o agregarlas a una revisión pendiente de GitHub. El problema es que un agente puede reescribir el mismo archivo varias veces. El número de línea original no basta para seguir una observación.

Leí el código fuente fijado en el commit [`a69274d`](https://github.com/moritzwilksch/diffle/tree/a69274d512a588dd86714bef103c8dbc2cadb2cf) (versión 0.2.4). La estrategia es bastante sencilla: **guardar el texto citado, buscar coincidencias exactas, elegir la más cercana a la línea anterior si hay varias y marcar como obsoleta la ubicación que sale del diff visible.** Funciona como heurística para los desplazamientos normales. ¿Pero qué sucede si dos fragmentos son idénticos? Ejecuté cuatro casos en un modelo mínimo independiente escrito con las herramientas integradas de Node.js. Aquí analizo el código fijado y ese modelo sintético: no he probado la interfaz ni la CLI de Diffle en funcionamiento.

## El comentario recuerda el texto, no solo la línea 18

Diffle guarda sus comentarios en `<git-dir>/diffle/`, fuera de los archivos del árbol de trabajo. En [`CommentStore.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/CommentStore.ts), los hilos y sus anclajes se almacenan en `comments.json`. Un comentario de línea conserva la ruta del archivo, el lado del diff, las líneas de inicio y fin originales y el fragmento seleccionado como `quoted`.

En [`anchor.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/anchor.ts), `relocate()` busca ese texto exacto línea por línea. Si encuentra varias coincidencias, decide así:

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

Si un fragmento pasa de la línea 18 a la 26 sin que cambie su contenido, el comentario puede seguirlo. Como la comparación usa `dist < bestDist`, si dos candidatos están igual de lejos, se mantiene el primero encontrado.

La coincidencia textual no lo decide todo. [`CommentStore.relocateAll()`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/CommentStore.ts) también comprueba el lado de la comparación y los hunks visibles. Si el texto desaparece, ese lado ya no existe o la nueva posición queda fuera de los hunks mostrados, un comentario de línea puede pasar a `stale`. Los comentarios de archivo completo se validan según si el archivo sigue formando parte de la revisión.

Me gusta esa salvaguarda. Encontrar la misma cadena de caracteres no significa necesariamente haber encontrado el mismo lugar lógico.

## ¿Y si la línea 18 tiene una copia idéntica?

Volvamos al ejemplo inicial. En código real abundan los `return false;` repetidos y las ramas que escriben `logger.info('done');`. Imagina que comentaste la última rama de reserva. Tras refactorizar, esa rama se mueve lejos, pero cerca de la posición antigua queda una instrucción idéntica. El texto y la distancia no revelan cuál de las dos querías seguir.

Para aislar ese comportamiento, usé `repeat();` en el modelo independiente. Es el caso que más me interesaba de las cuatro pruebas ejecutadas. El script íntegro y reproducible está plegado al final; aquí basta con ver la aserción clave:

```js
const contents = [
  'header();',   // línea 1
  'repeat();',   // línea 2: duplicado más próximo
  'other();',    // línea 3: ubicación anterior del comentario
  'new();',
  'changed();',
  'gap();',
  'repeat();',   // línea 7: objetivo semántico hipotético
].join('\n') + '\n';

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

La posición anterior del comentario es la línea 3. Tras el cambio, `repeat();` aparece en las líneas 2 y 7. Una está a una línea de distancia; la otra, a cuatro. El modelo elige la línea 2, aunque en este experimento establecí la línea 7 como el supuesto objetivo lógico original.

**La coincidencia más cercana no siempre es la misma pieza de código.** Ese objetivo «verdadero» es una hipótesis elegida al preparar el caso; la función no entiende la intención del desarrollador. La aplicación real también filtra por ruta, lado, hunk y estado del hilo. Por tanto, esta observación muestra la ambigüedad de la regla aislada, no un fallo de comentarios desplazados que yo haya reproducido en la interfaz de Diffle.

También ejecuté los otros tres casos. Estos son los cuatro resultados observados en el modelo:

| Caso sintético | Resultado real | Interpretación |
| --- | --- | --- |
| Se mueve un texto citado único | Se localiza en la línea 4 | Sigue desplazamientos normales de líneas |
| Se elimina el texto citado | Devuelve `null` | No inventa una ubicación al desaparecer el texto |
| Hay el mismo texto en las líneas 2 y 7 | Elige la línea 2 | Cercanía no equivale a identidad semántica |
| El texto está en la línea 4 y el hunk visible solo incluye 1–2 | Devuelve `false` | Puede encontrarse el texto fuera del diff mostrado |

Ejecuté la prueba el 12 de octubre de 2026 con Node.js `v22.16.0`. **Pasaron los 4 casos, con código de salida 0 y stderr vacío.** Solo utilicé `node:test` y `node:assert/strict`, módulos integrados de Node; no cargué el paquete npm de Diffle. Por eso el requisito Node ≥24 de ese paquete no afecta al modelo. El comando fue `TERM=dumb node --test --test-reporter=tap anchor-relocation-model.test.mjs`. El código completo y la salida TAP sin editar están disponibles en las secciones desplegables del final.

Para diferenciar mejor los fragmentos duplicados podrían usarse líneas vecinas, límites de función o huellas de contexto. Pero una refactorización también puede cambiar esas pistas. Es un compromiso de diseño, no una solución garantizada por añadir otro campo. El matcher de Diffle 0.2.4 analizado aquí no implementa esas señales semánticas adicionales.

## Entregar comentarios a un Agent y enviar una revisión a GitHub son cosas distintas

Después de revisar localmente, [`format.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/format.ts) puede construir un prompt para el agente con la ruta del archivo, el número de línea, el código citado y el cuerpo del comentario. Un bloque `suggestion` se convierte en `ORIGINAL` / `SUGGESTED`, y los comentarios caducados quedan marcados con `stale`. Es mucho más útil que decir «corrige otra vez aquella parte»: la siguiente iteración incluye un fragmento concreto que revisar.

En cambio, exportar los comentarios a un PR de GitHub tiene otra restricción. [`review.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/github/review.ts) rechaza explícitamente anclar comentarios de GitHub a cambios que solo existen en un worktree sin confirmar:

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

Tiene sentido. Si las líneas solo existen en mi equipo porque aún no hice commit, nadie que abra el PR verá ese diff. Asociar un comentario a esos «números de línea fantasma» solo crearía confusión.

Cuando la comparación coincide con el diff confirmado en el PR, Diffle omite los hilos `stale` y añade los comentarios válidos a **Pending Review**. No envía la revisión definitiva por su cuenta: la [README oficial](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/README.md) establece expresamente ese límite. Esa división del trabajo me parece acertada. El agente puede editar, la herramienta puede organizar mis observaciones, pero antes de que las vean mis compañeros prefiero leerlas una última vez.

## Antes de incorporarlo a mi rutina

Empezaría por un repositorio temporal sin datos sensibles, revisando solo el diff del árbol de trabajo y sin arrancar servidores de lenguaje innecesarios. Esta combinación de opciones aparece en la documentación (**no es un comando que haya ejecutado en Diffle para este artículo**):

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

El [`package.json`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/package.json) de la versión fijada exige Node ≥24 para su paquete npm; el proyecto también ofrece binarios independientes que no necesitan Node. Añadiría `--no-lsp` deliberadamente: un servidor de lenguaje detectado automáticamente puede leer archivos del repositorio con los permisos del usuario actual. Para mirar un diff no hace falta arrancarlos todos.

Cuando analicé [la puerta de revisión pre-push de OpenQodex](/posts/openqodex-prepush-review-fail-open/), mi preocupación era si podía pasar código sin una revisión completa. Con Diffle la pregunta es anterior: ¿siguen señalando al código correcto las observaciones humanas después de otra edición del agente? Que los tests generados detecten realmente errores es un asunto distinto, tratado al hablar de [pruebas y Mutation Testing](/posts/ai-generated-tests-mutation-testing/).

La próxima vez que escriba «aquí hay un problema en la línea 18», revisaré también el diff actualizado completo. La herramienta puede buscar un sitio donde colocar el comentario. Decidir si sigue siendo el código que quería revisar requiere algo más que un número de línea.

<details>
<summary>Desplegar: script completo de Node.js con las cuatro pruebas ejecutables</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>Desplegar: salida TAP original del 12 de octubre de 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>
