Files
llm-wiki/docs/superpowers/specs/2026-06-02-obsidian-link-validation-hardening-design.md
T

98 lines
7.8 KiB
Markdown

# Design: 옵시디언 링크 검증 강화 (wiki-structure-lint P3 hardening)
- 날짜: 2026-06-02
- 대상: `.claude/hooks/wiki_structure_lint.py` (C2 링크 검사 + vault index)
- 선행: [[docs/superpowers/specs/2026-06-01-wiki-structure-lint-design]] (린터 최초 설계). 본 문서는 그 §6.1 "알려진 한계"를 정확성 방향으로 해소하는 hardening 패스.
- 목적: 옵시디언 그래프뷰의 회색 노드(unresolved link)가 누적되는 문제를, 린터를 **정확하게**(false positive 제거 + 옵시디언 문법 전 형태 커버) 만든 뒤 **zero-tolerance로 강제**하여 근절.
---
## 0. 배경 / 문제
사용자가 Obsidian 그래프뷰에서 회색 노드(타깃 파일이 없는 링크)가 많음을 관찰. 린터는 `BROKEN_LINK`*검출은 하지만* PostToolUse 훅이 non-blocking이라 누적됨. 추가로 두 종류의 **린터 false positive**가 신호를 오염시켜 강제를 불가능하게 함.
vault 전체 진단 (425개 문서):
- `BROKEN_LINK` 72건 = (a) 린터 오탐 `.drawio` 첨부 ~8 + (b) 진짜 회색 노드(미생성 daily-note 36 + 미작성 concept/official-doc ~22) + (c) 편집 실수(예시 텍스트가 링크화) ~3
- `BACKTICK_WRAPPED_LINK` 36건 = 다수가 표 셀 경계를 넘는 페어링 오탐
- `DANGLING_ANCHOR` 3, `NO_FRONTMATTER` 3
실사용 옵시디언 링크 형태(grep): embed `![[]]` 9, alias `[[a|b]]` 2, heading anchor `[[a#h]]` 27, 비-md 첨부(drawio 8 + svg 1). block-ref `[[a#^id]]`·heading-only `[[#h]]`는 0건.
## 1. 확정된 설계 결정 (사용자)
1. **정확성 먼저 → 그 위에 강제.** false positive 제거 + 문법 전 형태 커버가 선행.
2. **깨진 링크 = 무조건 에러 (zero-tolerance).** 의도적 forward-reference(아직 안 쓴 문서로의 링크)도 예외 없음 — 스텁 생성 또는 링크 제거로 해소. **→ 선행 설계 §6.1의 "예시 라인 스킵 규칙" 아이디어는 폐기**: 바 텍스트 `[[double bracket]]`은 Obsidian에서 실제 회색 노드이므로 *스킵이 아니라 flag*하고 code-span으로 고치는 것이 정합.
3. **강제 지점 = PostToolUse 훅(저장 즉시 경고, 정확도 강화) + 수동 `--all` 게이트(exit 1).** git pre-commit은 도입 안 함.
## 2. Part A — False positive 제거 (정확성 토대)
### A1. Vault index에 비-md 첨부 포함
현재 `build_vault_index``rglob("*.md")`만 인덱싱 → `[[…architecture-modules.drawio]]`가 실존하는데도 `BROKEN_LINK` 오탐(8건).
- 모든 파일을 인덱싱(`rglob("*")` 중 파일만; **숨김 디렉터리(`.git` 등) 및 경로에 `/.`이 포함된 항목 제외** — git 오브젝트/캐시 인덱싱 방지).
- **md**: 현행대로 `.md` strip → rel-path(확장자 없음) + basename(stem).
- **비-md**: 확장자 *포함* rel-path + 확장자 포함 basename으로 등록(Obsidian은 첨부를 확장자 포함으로 링크).
- C2 타깃 해석에서 `[[x.md]]`는 strip 후 md index 조회, 그 외 확장자(`.drawio`/`.svg`/…)는 확장자 포함으로 조회.
- C1 린트 대상(`iter_docs`)은 변경 없음 — md만 구조 검사. index 확장은 *링크 타깃 해석*에만 영향.
### A2. Backtick 셀 경계 페어링 버그 수정
현재 `BACKTICK_LINK = re.compile(r"`[^`\n]*\[\[[^\]]*\]\][^`\n]*`")`는 backtick을 좌→우 연속 페어링하지 않고, 임의의 두 backtick 사이에 낀 위키링크를 매칭 → 표의 *Decision 칸* 인라인코드와 *Supporting Claims 칸* 인라인코드 사이에 위치한 정상 위키링크를 오탐.
- **수정**: 위키링크가 *진짜 code span 내부*일 때만 `BACKTICK_WRAPPED_LINK`. 판정 = 위키링크 시작 위치 앞의 (fence 밖) backtick 개수가 **홀수**이면 code span 내부(CommonMark 연속 페어링과 일치).
- 구현: 라인에서 각 `[[…]]` 매치의 `start()` 이전 backtick 수를 세어 홀짝 판정. 짝수 → 정상 링크(검사 계속), 홀수 → wrapped(flag, BROKEN 검사 제외 — 이미 `bare` 제거 로직과 정합).
- 효과: 직전 두 노트의 8건 + vault 36건 중 다수 오탐 제거. 진짜 래핑 `` `[[x]]` ``만 남김.
## 3. Part B — 옵시디언 문법 전 형태 정확 처리
| 형태 | 현재 | 설계 |
|------|------|------|
| alias `[[t\|a]]` | ✓ split `\|` | 유지 |
| embed `![[t]]` | ✓ 정규식이 `[[]]` 포함 | A1로 이미지/첨부 embed도 resolve |
| heading anchor `[[t#h]]` | 느슨한 substring (`anchor.lower() in txt.lower()`) | **강화**: 대상 문서의 실제 heading 텍스트 집합(모든 `#`-레벨, 정규화: markdown 제거+lowercase+공백정리)과 매칭. substring 통과 false-negative 제거 |
| block-ref `[[t#^id]]` | substring | anchor가 `^`로 시작 → 대상에 `^id` 행말 토큰 존재 검사(사용 0건 — 최소 지원, 오탐 방지 우선) |
| heading-only `[[#h]]` | target 비어 skip | 같은 파일 heading 검사(사용 0건 — skip 유지, crash/오탐만 방지) |
| 비-md 첨부 | ✗ 오탐 | A1로 해소 |
| code-span 예시 `` `[[x]]` `` | A2 전엔 혼동 | A2로 *링크 아님* 정확 제외 |
heading anchor 정규화 규칙: 대상 문서에서 `^#{1,6}\s+(.+)$` 캡처 → 양끝 공백 strip → lowercase. 링크 anchor도 동일 정규화 후 집합 매칭. (Obsidian의 heading 링크 매칭에 보수적으로 근사 — 매칭 실패 시 `DANGLING_ANCHOR`.)
## 4. Part C — Zero-tolerance 강제 wiring
- **`--all` 게이트**: 깨진 링크/dangling anchor 1건이라도 있으면 exit 1 (현행 유지 — A1/A2로 *정확한* 목록이 됨). forward-reference 예외 없음.
- **PostToolUse 훅**: 유지 + A1/A2 정확도 강화 반영. 비-blocking 즉시 경고. (훅은 Claude의 도구 편집 시에만 발동 — Obsidian 직접 편집은 `--all`이 진실 소스.)
- **롤아웃**: A1/A2 적용 후 `--all --links-only` 재실행 → 오탐 제거된 *진짜* 깨진 링크 목록 산출. (정리=스텁 생성/링크 수정은 별도 단계, 본 설계는 린터까지.)
## 5. Part D — 문법 계약 문서화
린터 docstring의 C2 설명을 "지원 옵시디언 문법 + 위반 정의" 표로 명문화(Part B 표 기반). `rules/linking-rules.md`에서 "옵시디언 링크 문법의 결정론 집행기 = wiki_structure_lint.py C2"임을 1줄 참조. (별도 rules 파일 신설은 안 함 — YAGNI.)
## 6. 검증 / 수용 기준
- **단위 테스트**(pytest 또는 stdlib `unittest`, 의존성 0 유지):
- A2: 표 행 `| `code` | text [[link]] text | `code` |`(짝수 backtick)이 `BACKTICK_WRAPPED_LINK` 미발생, 진짜 `` `[[x]]` ``는 발생.
- A1: `[[path/foo.drawio]]`가 실존 시 `BROKEN_LINK` 미발생, 부재 시 발생.
- B(anchor): 실존 heading은 통과, 오타 heading은 `DANGLING_ANCHOR`.
- **회귀**: 직전 두 노트(`feature-operational-error-observability-foundation`, `feature-api-contract-baseline`)의 backtick 오탐(8건+6건)이 `--file`에서 사라짐.
- **vault-wide**: `--all --links-only`의 BACKTICK 36건과 BROKEN 72건이 *정확한* 수치로 감소(.drawio 8건 등 오탐 제거).
- 기존 C1/C3 동작 불변(링크 검사만 수정).
## 7. 범위 밖 (YAGNI)
- git pre-commit 게이트.
- block-ref / heading-only 엄격 검증 (사용 0건 — 오탐 방지만).
- 자동 스텁 생성 / 깨진 링크 자동 수정 (A2 backtick autofix는 기존 `--fix` 유지, 변경 없음).
- 멀티 CLI 전파(Codex/Gemini).
- 진짜 깨진 링크 ~60건의 실제 정리(별도 작업 — 본 설계 산출물인 정확한 목록을 입력으로).
## 8. 산출물
| 파일 | 작업 |
|---|---|
| `.claude/hooks/wiki_structure_lint.py` | `build_vault_index`(A1), `check_c2`/backtick 판정(A2), anchor 강화(B), docstring(D) |
| `.claude/hooks/test_wiki_structure_lint.py` (신규) | A1/A2/B 단위 테스트 |
| `rules/linking-rules.md` | C2 집행기 참조 1줄(D) |
> 비고: 현재 `.git`이 빈 디렉터리(미초기화)라 spec 커밋은 생략 — 디스크 파일이 기록.