Diffle의 텍스트 앵커: Coding Agent가 코드를 고친 뒤 리뷰 댓글은 어디를 가리킬까?
Diffle의 텍스트 앵커: Coding Agent가 코드를 고친 뒤 리뷰 댓글은 어디를 가리킬까?
Coding Agent가 수정한 코드의 한 줄에 리뷰를 남겼다고 해 보자. “여기 조건이 잘못됐으니 고쳐 줘.” 에이전트는 조건을 고친 뒤 함수까지 나누고 검사를 몇 줄 추가했다. 다시 diff를 열어 보면, 원래 18번째 줄에 남긴 댓글은 여전히 내가 지적한 코드를 가리키고 있을까?
Diffle에 관심을 갖게 된 이유가 바로 이 문제다. 로컬 Git diff를 살펴보며 줄 단위로 댓글을 남기고, 이를 에이전트에게 전달하거나 GitHub Pending Review에 추가할 수 있는 도구다. 하지만 에이전트가 여러 차례 코드를 바꾸는데, 댓글이 이전 줄 번호에만 의존할 수는 없다.
이번에는 커밋 a69274d로 고정한 Diffle 0.2.4 소스를 읽었다. 접근 방식은 의외로 단순하다. 댓글이 가리킨 원문을 기억하고, 완전히 같은 텍스트를 찾는다. 후보가 여러 개면 옛 줄 번호와 가장 가까운 곳을 고른다. 현재 diff에 표시되지 않는 위치라면 stale 상태로 처리한다. 평범한 줄 이동에는 꽤 잘 맞는 방법이다. 그렇다면 동일한 코드가 두 군데 있다면? 이 선택의 한계를 보기 위해 Node.js 내장 테스트 기능만 이용한 독립 모델에서 네 가지 사례를 실제 실행했다. Diffle CLI나 브라우저를 실행한 제품 평가가 아니라, 고정된 소스 분석과 별도의 합성 모델 테스트다.
댓글은 18이라는 숫자만 기억하지 않는다
Diffle은 댓글을 작업 트리의 소스 파일에 쓰지 않고 저장소의 <git-dir>/diffle/ 아래에 저장한다. CommentStore.ts에서는 스레드와 앵커를 comments.json에 보관한다. 줄 단위 앵커에는 파일 경로, diff의 어느 쪽인지, 원래 시작과 끝 줄 번호, 그리고 선택했던 코드 문자열 quoted가 들어 있다.
anchor.ts의 relocate()는 이 인용문과 줄 단위로 완전히 일치하는 곳을 찾는다. 후보가 여럿일 때 위치를 결정하는 부분은 다음과 같다.
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()는 비교하는 쪽과 현재 보이는 diff hunk까지 확인한다. 인용문이 삭제됐거나, 대상 쪽이 없거나, 찾은 위치가 이번에 표시되는 hunk 밖이라면 줄 댓글은 stale 상태가 될 수 있다. 파일 전체에 남긴 댓글은 그 파일이 이번 리뷰에 남아 있는지를 기준으로 판단한다.
이 보호 장치는 마음에 든다. 같은 문자열을 찾았다고 해서 정말 같은 의미의 코드라고 장담할 수는 없으니까.
18번째 줄과 똑같이 생긴 코드가 또 있다면
처음 상황으로 돌아가 보자. 실제 저장소에는 return false;가 여러 번 나오거나, 여러 분기에서 logger.info('done');을 호출하기도 한다. 댓글을 단 곳은 마지막 예외 처리 분기인데 리팩터링으로 그 분기가 멀리 이동하고, 예전 위치 가까이 같은 문장이 남아 있다면 어떨까? 텍스트와 줄 번호만으로는 원래 어느 분기를 지적했는지 알아낼 수 없다.
이 문제만 따로 보기 위해 독립 모델에서는 그 문장을 repeat();로 바꿨다. 실제 실행한 네 테스트 중 가장 중요한 예다. 전체 실행 코드는 글 끝의 접기 영역에 남겨 두고, 여기서는 핵심 검증 부분만 보자.
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번째 줄이 진짜 대상이라는 설정은 테스트를 만든 사람의 가정이다. 문자열 매칭 함수 자체가 코드의 의미를 이해한 것은 아니다. 실제 제품에는 파일 경로, 비교 방향, hunk, 스레드 상태에 대한 검사도 있으므로 이 결과를 Diffle UI에서 댓글이 잘못 이동했다는 실측 버그로 해석해서는 안 된다.
다른 세 사례까지 포함해 독립 모델이 반환한 결과는 다음과 같다.
| 합성 테스트 | 실제 결과 | 확인한 점 |
|---|---|---|
| 유일한 인용 텍스트가 이동 | 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라는 Node 내장 모듈만 사용했으며 Diffle npm 패키지는 로드하지 않았다. 따라서 패키지 자체의 Node ≥24 요구 사항은 이 독립 테스트에 적용되지 않는다. 실행 명령은 TERM=dumb node --test --test-reporter=tap anchor-relocation-model.test.mjs이다. 네 테스트의 전체 코드와 원본 TAP 출력은 끝부분에서 펼쳐 볼 수 있다.
같은 문자열을 더 잘 구분하려면 앞뒤 문맥, 함수 범위, 문맥 지문(context fingerprint)을 추가 단서로 고려할 수 있다. 다만 리팩터링 과정에서 그런 단서도 바뀐다. 정보를 하나 더 저장한다고 해결되는 문제가 아니라 설계상의 절충이다. 고정한 Diffle 0.2.4 매처는 이런 추가적인 의미 분석을 구현하지 않았다.
Agent에게 댓글을 주는 것과 GitHub Review를 보내는 것은 다르다
로컬 리뷰가 끝나면 format.ts가 파일 경로, 줄 번호, 인용한 코드와 댓글 본문을 에이전트용 프롬프트로 묶어 준다. suggestion 코드 펜스는 ORIGINAL / SUGGESTED 형태로 바뀌고 오래된 댓글에는 stale 표시가 붙는다. “아까 그 부분 다시 고쳐 줘”라고 말하는 것보다 훨씬 구체적이다. 다음 수정에서 무엇을 확인해야 하는지 남기 때문이다.
하지만 GitHub PR에 전달하는 단계에는 별도의 제약이 있다. review.ts는 아직 커밋하지 않은 worktree 차이를 GitHub 댓글의 앵커로 사용하지 못하도록 한다.
if (snap.newSha === 'worktree')
throw new GithubError('GitHub cannot anchor comments to the worktree');
당연한 제약이다. 내 컴퓨터에서만 존재하는 수정 사항이라면 동료가 PR을 열어도 같은 diff를 볼 수 없다. 존재하지 않는 ‘유령 줄 번호’에 댓글을 걸어 봐야 혼란만 생긴다.
PR에 커밋된 diff와 비교 내용이 맞으면 Diffle은 stale 스레드를 건너뛰고 적격 댓글을 Pending Review에 추가한다. 최종 Submit review는 사람이 해야 한다. 공식 README에도 도구가 사용자를 대신해 리뷰를 제출하지 않는다고 적혀 있다. 이런 역할 분리는 좋은 선택이라고 본다. 에이전트는 코드를 고치고 도구는 피드백을 정리할 수 있다. 그래도 팀에 실제로 전달되는 댓글은 내가 마지막으로 읽어 보고 싶다.
일상 작업에 넣기 전에
먼저 민감한 자료가 없는 임시 Git 저장소에서 시작하겠다. 작업 트리 diff만 보고 불필요한 언어 서버는 끈다. 공식 명령과 옵션을 조합하면 다음과 같다. 이 명령은 이번에 실제 Diffle을 실행한 기록은 아니다.
diffle working --no-lsp
고정 버전 package.json은 npm 패키지에 Node ≥24를 요구하지만 프로젝트에는 Node가 필요 없는 독립 실행 파일도 있다. --no-lsp를 넣은 이유도 있다. 자동으로 발견된 언어 서버는 현재 사용자 권한으로 저장소 파일을 읽을 수 있다. diff 한 번 보려는 일에 전부 실행할 필요는 없다.
이전에 OpenQodex의 pre-push 리뷰 게이트를 살펴볼 때는 불완전한 리뷰가 코드 변경을 그대로 통과시키는지가 관심사였다. Diffle에서는 더 앞 단계가 문제다. 사람이 남긴 피드백이 에이전트의 다음 수정 후에도 올바른 코드를 가리킬 수 있는가. 생성한 테스트가 버그를 실제로 잡아내는지는 테스트와 Mutation Testing에서 다룬 다른 질문이다.
다음에 “18번째 줄이 잘못됐어”라고 적더라도 수정된 전체 diff를 다시 확인할 생각이다. 댓글의 위치는 도구가 찾아 줄 수 있다. 그 위치가 내가 검토하려던 로직인지는 줄 번호만으로 정할 수 없다.
펼쳐 보기: 실제 실행 가능한 Node.js 테스트 4개 전체 코드
/**
* 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);
});
펼쳐 보기: 2026년 10월 12일의 원본 TAP 출력
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