v4.3.27: coder_patch_file 매칭에 조건부 줄 앵커 적용 — 엉뚱한 줄 편집 차단

이전에는 순수 부분 문자열 매칭이라, 파일보다 얕게 들여쓴 SEARCH가 더 깊은 줄
안에서 매칭됐음("    deep()"가 "        deep()"에 적중). 한 줄을 노린 편집이
다른 줄에 적용될 수 있는 경로였고, 조용히 잘못된 바이트를 쓰는 fail-open이었음.

"무조건 줄 단위 일치"로 바꾸면 줄 안의 조각을 고치는 정당한 사용
("return 1" → "return 42")이 전부 깨지므로, SEARCH가 스스로 무엇을 주장하는지에
따라 앵커를 조건부로 적용:

- 선행 공백이 있거나 여러 줄  → 줄 시작 정렬 필수
  (들여쓰기/구조를 주장하고 있으므로 정렬돼야 함)
- 선행 공백 없는 한 줄        → 기존대로 부분 매칭
  (줄 안의 조각일 뿐 들여쓰기를 주장한 적 없음)

거부는 fail-closed — 찾지 못함으로 보고돼 모델이 더 정확한 컨텍스트로 재시도함.
앵커에 걸린 등장은 건너뛰고 뒤쪽의 정렬된 등장을 계속 찾으므로, 같은 텍스트가
줄 중간과 줄 시작에 모두 있으면 후자를 고침.

부수 수정 — 오프셋 치환:
빠른 경로와 폴백 경로 모두 String.replace()를 쓰고 있었는데, replace()는 위치 0
부터 다시 스캔하므로 방금 내린 앵커 판정을 무효화하고 앞쪽의 정렬 안 된 등장을
고칠 수 있었음. 이미 알고 있는 오프셋으로 splice하도록 양쪽 다 변경.

테스트 121개 통과(앵커 규칙 4개 신규). 실디스크 검증: 8칸 들여쓰기 안의
value = 1 → 99 수정 정상, 들여쓰기 불일치 케이스는 파일 무변경으로 거부.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-29 16:35:53 +09:00
co-authored by Claude Opus 5
parent abac4f7940
commit 159669ec04
2 changed files with 87 additions and 17 deletions
+36 -7
View File
@@ -71,16 +71,45 @@ describe('후행 공백 폴백 — 모델이 흔히 틀리는 지점', () => {
assert.equal(r.content, 'head \nTARGET \ntail \n');
});
test('⚠ 알려진 날카로운 모서리: SEARCH가 더 얕게 들여쓰기돼도 부분 문자열로 매칭된다', () => {
// 8칸 들여쓴 줄 안에 4칸 들여쓴 SEARCH가 부분 문자열로 들어있어 그대로 치환된다.
// 결과는 원래 들여쓰기가 유지되므로(4칸 + 치환문) 파이썬에서는 대개 무해하지만,
// "정확히 이 줄"을 노린 편집이 다른 줄에 적용될 수 있는 경로이기도 하다.
// 이는 추출 이전부터 있던 동작이며, 줄 단위 앵커로 바꾸는 것은 파일 편집 의미를
// 바꾸는 일이라 별도 판단이 필요하다. 지금은 동작을 문서화만 해둔다.
test('들여쓰기가 다르면 매칭을 거부한다 — fail closed', () => {
// 이전에는 4칸 SEARCH가 8칸 줄 안에서 부분 매칭돼 엉뚱한 줄을 고칠 수 있었다.
// 이제는 선행 공백이 있으면 줄 시작 정렬을 요구하므로 거부되고, 모델이 더 정확한
// 컨텍스트로 재시도하게 된다. 조용히 잘못 고치는 것보다 실패가 낫다.
const src = 'if x:\n deep()\n';
const r = applySearchReplaceEdits(src, block(' deep()', ' shallow()'));
assert.equal(r.editCount, 0);
assert.equal(r.content, src, '거부됐는데 파일이 변경됨');
assert.deepEqual(r.failedEdits, [' deep()']);
});
});
describe('줄 앵커 규칙 — SEARCH가 무엇을 주장하는지에 따라 달라진다', () => {
test('선행 공백 없는 한 줄은 부분 매칭 허용 — 들여쓰기를 주장하지 않았다', () => {
const r = applySearchReplaceEdits('def f():\n return 1\n', block('return 1', 'return 42'));
assert.equal(r.editCount, 1);
assert.equal(r.content, 'if x:\n shallow()\n');
assert.equal(r.content, 'def f():\n return 42\n', '들여쓰기가 보존돼야 한다');
});
test('선행 공백이 있으면 줄 시작에 정렬돼야 한다', () => {
const r = applySearchReplaceEdits('x = 1\n y = 2\n', block(' y = 2', ' y = 3'));
assert.equal(r.editCount, 1, '정확히 정렬된 경우는 통과해야 한다');
assert.equal(r.content, 'x = 1\n y = 3\n');
});
test('여러 줄 SEARCH는 선행 공백이 없어도 줄 시작을 요구한다', () => {
// 줄 중간에서 시작하는 다중 줄 매칭은 구조를 깨뜨린다.
const src = 'prefix a = 1\nb = 2\n';
const r = applySearchReplaceEdits(src, block('a = 1\nb = 2', 'REPLACED'));
assert.equal(r.editCount, 0);
assert.equal(r.content, src);
});
test('앵커 실패 후 뒤쪽의 올바른 위치를 계속 찾는다', () => {
// 첫 등장이 줄 중간이어도 포기하지 않고, 제대로 정렬된 다음 등장을 찾아야 한다.
const src = 'wrapper target()\n target()\n';
const r = applySearchReplaceEdits(src, block(' target()', ' fixed()'));
assert.equal(r.editCount, 1);
assert.equal(r.content, 'wrapper target()\n fixed()\n', '줄 시작에 정렬된 두 번째 등장을 고쳐야 한다');
});
});