Files
homeclaw/tests/link-validator.test.ts
T
kimandClaude Opus 5 6a4e333822 fix: 죽은 링크 취소선 처리가 마크다운 경계를 넘어 텍스트를 뒤섞던 버그
실사용 사고(2026-08-10, "16호 태풍" 답변): 모델이 URL을 그 자체로 링크
텍스트로 쓰는 흔한 패턴 `[https://x.com](https://x.com)`에서, 죽은 링크
탐지 정규식(BARE_RE)이 `]`/`[`에서 멈추지 않아 라벨의 `]`, href의 `(`를
넘어 마크다운 링크 전체를 하나의 "URL"로 삼켜버렸다. 그 결과 사용자가
실제로 본 출력은 `~~[~~url](url~~ ⚠️ (링크 끊김)))~~ ⚠️ (링크 끊김)`
같은 뒤섞인 텍스트였다.

BARE_RE의 부정 lookbehind와 문자클래스 양쪽에 `]`/`[` 제외를 추가해
마크다운 링크 경계를 넘지 못하게 했다. LINK_RE(먼저 실행됨)가 이미
"라벨이 URL 자체인" 경우를 포함해 [label](url) 쌍 전체를 처리하므로
BARE_RE는 그 경계 안으로 들어갈 필요가 없다.

rewriteDeadLinks()를 validateLinksInText()에서 분리해 순수 문자열
변환만 네트워크 모킹 없이 테스트 가능하게 했다 — 회귀 테스트 8개
추가, 실제 사고 사례를 그대로 재현해 고정.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:34:42 +09:00

72 lines
4.0 KiB
TypeScript

/**
* link-validator.test.ts
*
* rewriteDeadLinks was extracted from validateLinksInText (2026-08-10) specifically so this
* bug could be pinned: models very commonly cite a URL using the URL itself as the link label,
* [https://x.com](https://x.com). The old BARE_RE's character class didn't stop at `]`/`[`, so
* a bare-URL match starting right after the label's `[` ran straight through the label's `]`,
* the href's `(`, and the whole href — merging two markdown tokens into one match. Wrapping
* that in ~~...~~ produced garbage the user actually saw in production:
* "~~[~~https://stormatlasx.com/ko](https://stormatlasx.com/ko~~ ⚠️ (링크 끊김)))~~ ⚠️ ...".
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { rewriteDeadLinks } from '../src/gateway/guards/link-validator';
describe('rewriteDeadLinks', () => {
test('링크 텍스트가 없으면 원문 그대로', () => {
const text = '아무 링크도 없는 평범한 답변입니다.';
assert.equal(rewriteDeadLinks(text, new Map()), text);
});
test('전부 살아있으면 원문 그대로', () => {
const text = '자세한 내용은 [기상청](https://weather.go.kr)에서 확인하세요.';
assert.equal(rewriteDeadLinks(text, new Map([['https://weather.go.kr', true]])), text);
});
test('일반 마크다운 링크 — 라벨이 URL과 다르면 정상적으로 취소선 처리', () => {
const text = '자세한 내용은 [여기](https://dead.example.com)에서 확인하세요.';
const out = rewriteDeadLinks(text, new Map([['https://dead.example.com', false]]));
assert.equal(out, '자세한 내용은 ~~[여기](https://dead.example.com)~~ ⚠️ (링크 끊김)에서 확인하세요.');
});
test('죽은 이미지는 경고 문구로 치환', () => {
const text = '![낙원 사진](https://dead.example.com/x.jpg)';
const out = rewriteDeadLinks(text, new Map([['https://dead.example.com/x.jpg', false]]));
assert.equal(out, '⚠️ 이미지 불러올 수 없음: 낙원 사진');
});
test('평문 URL은 취소선으로 치환', () => {
const text = '참고: https://dead.example.com/page 여기 있습니다.';
const out = rewriteDeadLinks(text, new Map([['https://dead.example.com/page', false]]));
assert.equal(out, '참고: ~~https://dead.example.com/page~~ ⚠️ (링크 끊김) 여기 있습니다.');
});
// 실제 프로덕션 사고 재현 (2026-08-10, 세션 papa, "16호 태풍" 질문 답변).
test('라벨이 URL 자체인 죽은 링크도 깨지지 않고 정상 처리된다', () => {
const url = 'https://stormatlasx.com/ko';
const text = `Storm Atlas (실시간 경로 추적): [${url}](${url})`;
const out = rewriteDeadLinks(text, new Map([[url, false]]));
assert.equal(out, `Storm Atlas (실시간 경로 추적): ~~[${url}](${url})~~ ⚠️ (링크 끊김)`);
// 회귀 방지: 예전 버그의 특징적 증상 — 마크다운 링크 경계를 넘어 뒤섞인 취소선/괄호.
assert.ok(!out.includes(')~~ ⚠️ (링크 끊김))'), '마크다운 경계를 넘어 뒤섞이면 안 됨');
assert.equal((out.match(/⚠️/g) || []).length, 1, '경고 문구가 중복되면 안 됨');
});
test('URL을 포함한 괄호가 있는 일반 URL은 여전히 매칭된다(회귀 확인)', () => {
const text = '위키백과: https://en.wikipedia.org/wiki/Foo_bar 참고';
const out = rewriteDeadLinks(text, new Map([['https://en.wikipedia.org/wiki/Foo_bar', false]]));
assert.equal(out, '위키백과: ~~https://en.wikipedia.org/wiki/Foo_bar~~ ⚠️ (링크 끊김) 참고');
});
test('여러 링크가 연달아 있어도 서로 침범하지 않는다', () => {
const text = '[A](https://a.example.com) [https://b.example.com](https://b.example.com)';
const out = rewriteDeadLinks(text, new Map([
['https://a.example.com', true],
['https://b.example.com', false],
]));
assert.equal(out, '[A](https://a.example.com) ~~[https://b.example.com](https://b.example.com)~~ ⚠️ (링크 끊김)');
});
});