# 옵시디언 링크 검증 강화 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `wiki_structure_lint.py`의 C2 링크 검사를 정확하게 만들어(비-md 첨부 오탐 + backtick 셀경계 오탐 제거 + heading anchor 정확화) zero-tolerance 강제의 신뢰 토대를 세운다.
**Architecture:** 단일 파일(`.claude/hooks/wiki_structure_lint.py`)의 `build_vault_index`(A1)·`check_c2`(A2/B)·docstring(D)을 수정하고, stdlib `unittest` 테스트 파일을 신설한다. C1/C3는 불변.
**Tech Stack:** Python 3 stdlib only (re, pathlib, unittest, tempfile). 외부 의존성 0.
> **환경 비고:** 이 repo는 `.git`이 빈 디렉터리(git 미초기화). **커밋 단계는 생략하고, 각 Task의 체크포인트 = 전체 테스트 스위트 실행 통과**로 대체한다. 명령: `python3 .claude/hooks/test_wiki_structure_lint.py -v`.
---
## File Structure
- **Modify** `.claude/hooks/wiki_structure_lint.py`
- `build_vault_index` (현재 154-162): 비-md 첨부 인덱싱 (A1)
- `check_c2` (현재 187-226): backtick 위치기반 판정 (A2) + anchor 정확화 (B)
- module docstring (현재 1-20): 지원 문법 계약 표 (D)
- **Create** `.claude/hooks/test_wiki_structure_lint.py` — A1/A2/B 단위 테스트
- **Modify** `rules/linking-rules.md` — C2 집행기 참조 1줄 (D)
---
## Task 1: 테스트 스캐폴드 + A2 backtick 셀경계 오탐 수정
**Files:**
- Create: `.claude/hooks/test_wiki_structure_lint.py`
- Modify: `.claude/hooks/wiki_structure_lint.py` (`check_c2`, 187-226)
핵심 버그: `check_c2`가 두 패스(① `BACKTICK_LINK.search(line)` 경고 ② `bare`에서 BROKEN 검사)로 나뉘는데, ①의 정규식이 backtick을 좌→우 연속 페어링하지 않아 표의 서로 다른 칸 인라인코드 사이에 낀 정상 위키링크를 오탐. → **위치 기반 단일 패스**로 통합: `INLINE_CODE.finditer(line)`로 code span 범위를 구해, 위키링크 시작이 그 범위 안일 때만 `BACKTICK_WRAPPED_LINK`.
- [ ] **Step 1: 실패 테스트 작성** — `test_wiki_structure_lint.py` 신설
```python
#!/usr/bin/env python3
"""wiki_structure_lint.py 단위 테스트 (stdlib unittest)."""
import importlib.util
import tempfile
import unittest
from pathlib import Path
# 하이픈 모듈명이 아니라 언더스코어 — 직접 spec 로드
_SPEC = importlib.util.spec_from_file_location(
"wsl", str(Path(__file__).with_name("wiki_structure_lint.py")))
wsl = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(wsl)
def _doc(*lines):
"""check_c2 입력용 최소 doc dict."""
return {"lines": list(lines)}
def _codes(findings):
return [c for (c, _ln, _msg) in findings]
class TestBacktickPairing(unittest.TestCase):
def setUp(self):
# 타깃 존재로 BROKEN_LINK 격리 — 'foo'는 vault에 있다고 가정
self.vp = {"raw/x/foo"}
self.vb = {"foo": ["raw/x/foo"]}
self.root = Path("/nonexistent")
def test_cross_cell_codespans_not_flagged(self):
# 서로 다른 칸의 인라인코드 사이 정상 위키링크 (짝수 backtick) → 오탐 아님
line = "| D1 | `AUTH` 응답 | [[foo]] (`note` 보강) | `strength` |"
f = wsl.check_c2(_doc(line), self.vp, self.vb, self.root, {})
self.assertNotIn("BACKTICK_WRAPPED_LINK", _codes(f))
def test_true_wrapped_link_flagged(self):
# 진짜 code span 내부 링크 → 검출
line = "예시 문법: `[[foo]]` 처럼 씁니다"
f = wsl.check_c2(_doc(line), self.vp, self.vb, self.root, {})
self.assertIn("BACKTICK_WRAPPED_LINK", _codes(f))
if __name__ == "__main__":
unittest.main(verbosity=2)
```
- [ ] **Step 2: 실패 확인**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py -v`
Expected: `test_cross_cell_codespans_not_flagged` FAIL (현재 오탐으로 BACKTICK_WRAPPED_LINK 발생). `test_true_wrapped_link_flagged` PASS.
- [ ] **Step 3: `check_c2` 위치기반 단일 패스로 교체**
`wiki_structure_lint.py`의 `check_c2` 본문(187-226)을 아래로 교체:
```python
def check_c2(doc, vault_paths, vault_bases, root, cache):
out = []
in_fence = False
for lineno, line in enumerate(doc["lines"], start=1):
s = line.lstrip()
if s.startswith("```") or s.startswith("~~~"):
in_fence = not in_fence
continue
if in_fence:
continue
# 인라인 code span 범위 (좌→우 연속 페어링; `[^`]*` 가 backtick 못 넘음)
code_spans = [(m.start(), m.end()) for m in INLINE_CODE.finditer(line)]
for m in WIKILINK.finditer(line):
if any(a <= m.start() < b for a, b in code_spans):
out.append(("BACKTICK_WRAPPED_LINK", lineno,
f"백틱/인라인코드에 싸인 위키링크 — 옵시디언 링크 미작동: {line.strip()[:80]}"))
continue
raw = m.group(1).split("|")[0].strip()
target, _, anchor = raw.partition("#")
target, anchor = target.strip(), anchor.strip()
if target.endswith(".md"): # 옵시디언은 [[x.md]] 도 유효
target = target[:-3]
if not target:
continue
if target not in vault_paths and target not in vault_bases:
out.append(("BROKEN_LINK", lineno, f"타깃 부재: [[{target}]]"))
continue
if anchor:
_check_anchor(out, lineno, target, anchor,
vault_paths, vault_bases, root, cache)
return out
```
> 비고: 이 Task에서는 `_check_anchor`를 아직 정의하지 않으므로, **임시로** 기존 anchor 로직을 인라인 유지한다. 아래 Step 3b 참조 (Task 3에서 `_check_anchor`로 추출).
- [ ] **Step 3b: anchor 로직 임시 인라인** — 위 `_check_anchor(...)` 호출을 Task 3 전까지 기존 substring 로직으로 대체:
```python
if anchor:
rels = [target] if target in vault_paths else vault_bases.get(target, [])
found = False
for rel in rels:
fp = root / (rel + ".md")
txt = cache.get(fp)
if txt is None:
txt = read_text(fp)
cache[fp] = txt
if anchor.lower() in txt.lower():
found = True
break
if not found:
out.append(("DANGLING_ANCHOR", lineno, f"앵커 부재: [[{target}#{anchor}]]"))
```
(즉 Step 3의 `_check_anchor(...)` 한 줄을 이 블록으로 치환해서 작성. `BACKTICK_LINK`·`bare` 변수는 더 이상 사용 안 함.)
- [ ] **Step 4: 통과 확인**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py -v`
Expected: 두 테스트 모두 PASS.
- [ ] **Step 5: 회귀 — 직전 두 노트의 backtick 오탐 소멸 확인**
Run: `python3 .claude/hooks/wiki_structure_lint.py --file raw/branch-notes/feature-api-contract-baseline.md --links-only`
Expected: `BACKTICK_WRAPPED_LINK` 라인(122/125/151/183/188/315) 출력에서 사라짐.
- [ ] **Step 6: 체크포인트** — 전체 테스트 통과 확인 (git 미사용)
Run: `python3 .claude/hooks/test_wiki_structure_lint.py`
Expected: `OK`.
---
## Task 2: A1 — 비-md 첨부 인덱싱 (`.drawio` 오탐 제거)
**Files:**
- Modify: `.claude/hooks/wiki_structure_lint.py` (`build_vault_index`, 154-162)
- Modify: `.claude/hooks/test_wiki_structure_lint.py`
- [ ] **Step 1: 실패 테스트 추가** — `test_wiki_structure_lint.py`에 클래스 추가
```python
class TestNonMdAttachment(unittest.TestCase):
def test_drawio_target_resolves(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "raw" / "diagrams").mkdir(parents=True)
(root / "raw" / "diagrams" / "arch.drawio").write_text("")
(root / "raw" / "notes").mkdir(parents=True)
note = root / "raw" / "notes" / "n.md"
note.write_text("see [[raw/diagrams/arch.drawio]]\n")
vp, vb = wsl.build_vault_index(root)
f = wsl.check_c2(_doc("see [[raw/diagrams/arch.drawio]]"),
vp, vb, root, {})
self.assertNotIn("BROKEN_LINK", _codes(f))
def test_missing_drawio_still_broken(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "raw").mkdir()
vp, vb = wsl.build_vault_index(root)
f = wsl.check_c2(_doc("see [[raw/diagrams/ghost.drawio]]"),
vp, vb, root, {})
self.assertIn("BROKEN_LINK", _codes(f))
def test_git_dir_excluded(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / ".git").mkdir()
(root / ".git" / "obj.drawio").write_text("x")
vp, vb = wsl.build_vault_index(root)
self.assertNotIn(".git/obj.drawio", vp)
```
- [ ] **Step 2: 실패 확인**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py -v`
Expected: `test_drawio_target_resolves` FAIL (BROKEN_LINK 발생 — 비-md 미인덱싱).
- [ ] **Step 3: `build_vault_index` 교체**
```python
def build_vault_index(root):
"""링크 타깃 확인용. md는 .md strip, 비-md 첨부는 확장자 포함으로 등록.
숨김 디렉터리(.git 등)는 제외. (paths, bases=basename→rel목록)."""
paths, bases = set(), {}
for p in root.rglob("*"):
if not p.is_file():
continue
rel_posix = p.relative_to(root).as_posix()
if rel_posix.startswith(".") or "/." in rel_posix:
continue # .git / .obsidian 등 숨김 경로 제외
if p.suffix == ".md":
rel = rel_posix[:-3]
paths.add(rel)
bases.setdefault(p.stem, []).append(rel)
else:
paths.add(rel_posix) # 확장자 포함 full path
bases.setdefault(p.name, []).append(rel_posix) # 확장자 포함 basename
return paths, bases
```
- [ ] **Step 4: 통과 확인**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py -v`
Expected: 3개 신규 테스트 + Task 1 테스트 모두 PASS.
- [ ] **Step 5: 회귀 — vault에서 .drawio 오탐 소멸**
Run: `python3 .claude/hooks/wiki_structure_lint.py --all --links-only 2>&1 | grep -c 'drawio'`
Expected: `0` (이전엔 8).
- [ ] **Step 6: 체크포인트**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py`
Expected: `OK`.
---
## Task 3: B — heading anchor 정확화 (substring → 실제 heading 매칭)
**Files:**
- Modify: `.claude/hooks/wiki_structure_lint.py` (`check_c2` anchor 블록 → `_check_anchor` 추출 + 헬퍼)
- Modify: `.claude/hooks/test_wiki_structure_lint.py`
- [ ] **Step 1: 실패 테스트 추가**
```python
class TestHeadingAnchor(unittest.TestCase):
def _vault(self, d):
root = Path(d)
(root / "wiki").mkdir()
tgt = root / "wiki" / "t.md"
tgt.write_text("# Title\n\n## Real Heading\n\nbody real heading mention\n")
return root
def test_existing_heading_passes(self):
with tempfile.TemporaryDirectory() as d:
root = self._vault(d)
vp, vb = wsl.build_vault_index(root)
f = wsl.check_c2(_doc("[[wiki/t#Real Heading]]"), vp, vb, root, {})
self.assertNotIn("DANGLING_ANCHOR", _codes(f))
def test_substring_only_match_now_dangling(self):
# 'body'는 본문에만 있고 heading 아님 → 강화 후 DANGLING
with tempfile.TemporaryDirectory() as d:
root = self._vault(d)
vp, vb = wsl.build_vault_index(root)
f = wsl.check_c2(_doc("[[wiki/t#body]]"), vp, vb, root, {})
self.assertIn("DANGLING_ANCHOR", _codes(f))
def test_nonmd_anchor_skipped(self):
# 비-md 타깃 + anchor → anchor 검사 skip (DANGLING 아님)
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "raw").mkdir()
(root / "raw" / "a.drawio").write_text("")
vp, vb = wsl.build_vault_index(root)
f = wsl.check_c2(_doc("[[raw/a.drawio#x]]"), vp, vb, root, {})
self.assertNotIn("DANGLING_ANCHOR", _codes(f))
```
- [ ] **Step 2: 실패 확인**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py -v`
Expected: `test_substring_only_match_now_dangling` FAIL (현재 substring으로 'body' 통과).
- [ ] **Step 3: 헬퍼 + `_check_anchor` 추가, anchor 블록 교체**
`check_c2` 위에 헬퍼 추가:
```python
HEADING_RE = re.compile(r"^#{1,6}\s+(.+?)\s*$", re.M)
def _heading_set(txt):
return {h.strip().lower() for h in HEADING_RE.findall(txt)}
def _check_anchor(out, lineno, target, anchor, vault_paths, vault_bases, root, cache):
rels = [target] if target in vault_paths else vault_bases.get(target, [])
md_rels = [r for r in rels if (root / (r + ".md")).exists()]
if not md_rels:
return # 비-md 첨부 등 — anchor 검사 무의미, skip
is_block = anchor.startswith("^")
norm = anchor[1:].strip() if is_block else anchor.strip().lower()
for rel in md_rels:
fp = root / (rel + ".md")
txt = cache.get(fp)
if txt is None:
txt = read_text(fp)
cache[fp] = txt
if is_block:
if re.search(r"\^" + re.escape(norm) + r"\s*$", txt, re.M):
return
else:
if norm in _heading_set(txt):
return
out.append(("DANGLING_ANCHOR", lineno, f"앵커 부재: [[{target}#{anchor}]]"))
```
그리고 `check_c2`의 anchor 블록(Task 1 Step 3b에서 인라인한 부분)을 한 줄로 교체:
```python
if anchor:
_check_anchor(out, lineno, target, anchor,
vault_paths, vault_bases, root, cache)
```
- [ ] **Step 4: 통과 확인**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py -v`
Expected: 전체 테스트 PASS.
- [ ] **Step 5: 회귀 — vault DANGLING_ANCHOR 수치 확인**
Run: `python3 .claude/hooks/wiki_structure_lint.py --all --links-only 2>&1 | grep -c DANGLING_ANCHOR`
Expected: 정수 출력(이전 3건 대비 변동 가능 — 강화로 증가할 수 있음, 정상).
- [ ] **Step 6: 체크포인트**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py`
Expected: `OK`.
---
## Task 4: D — 문법 계약 문서화 (docstring + linking-rules 참조)
**Files:**
- Modify: `.claude/hooks/wiki_structure_lint.py` (module docstring, 1-20)
- Modify: `rules/linking-rules.md`
- [ ] **Step 1: docstring의 C2 설명 교체**
`wiki_structure_lint.py` 상단 docstring에서 ` C2 옵시디언 링크 문법 — 백틱 래핑 / 깨진 타깃 / 부재 앵커` 줄을 아래로 확장:
```
C2 옵시디언 링크 문법 — 지원 형태 + 위반 정의:
[[t]] / [[t.md]] / [[t|alias]] → t 실존 검사 (md=확장자strip, 첨부=확장자포함)
![[t]] → embed, 동일 타깃 검사
[[t#heading]] → t의 실제 heading 매칭 (DANGLING_ANCHOR)
[[t#^blockid]] → t의 ^blockid 행말 토큰 (DANGLING_ANCHOR)
`[[t]]` (인라인 code span 내부) → BACKTICK_WRAPPED_LINK (옵시디언 링크 미렌더)
``` fenced ``` 내부 [[t]] → 예시로 간주, 스킵
판정은 위치기반 backtick 연속 페어링 — 표 셀 경계 오탐 없음.
```
- [ ] **Step 2: docstring 유효성 확인 (구문 깨짐 없음)**
Run: `python3 -c "import importlib.util,pathlib; s=importlib.util.spec_from_file_location('w','.claude/hooks/wiki_structure_lint.py'); m=importlib.util.module_from_spec(s); s.loader.exec_module(m); print('ok')"`
Expected: `ok`.
- [ ] **Step 3: `rules/linking-rules.md`에 집행기 참조 1줄 추가**
`rules/linking-rules.md`의 검증 체크리스트 관련 섹션 끝에 추가 (적절한 위치에 1줄):
```markdown
> **결정론 집행기**: 위 옵시디언 링크 문법(broken target / dangling anchor / backtick 래핑)은 `.claude/hooks/wiki_structure_lint.py`의 C2 검사가 기계적으로 강제한다 (`--all --links-only`로 vault 전수, zero-tolerance).
```
- [ ] **Step 4: 체크포인트**
Run: `python3 .claude/hooks/test_wiki_structure_lint.py && python3 .claude/hooks/wiki_structure_lint.py --file raw/branch-notes/feature-api-contract-baseline.md --links-only`
Expected: 테스트 `OK` + 파일 검사 정상 출력.
---
## Task 5: 롤아웃 — 정확한 깨진 링크 목록 산출
**Files:** 없음 (측정만)
- [ ] **Step 1: vault 전수 재실행**
Run: `python3 .claude/hooks/wiki_structure_lint.py --all --links-only 2>&1 | tail -15`
Expected: 요약에서 `BACKTICK_WRAPPED_LINK` 대폭 감소(오탐 제거), `BROKEN_LINK`는 .drawio 8건 제거 후 *진짜* 회색 노드만 남음.
- [ ] **Step 2: 진짜 BROKEN_LINK 타깃 빈도표 산출**
Run: `python3 .claude/hooks/wiki_structure_lint.py --all --links-only 2>&1 | grep BROKEN_LINK | sed -E 's/.*\[\[([^]]*)\]\].*/\1/' | sort | uniq -c | sort -rn`
Expected: 미생성 daily-note(`raw/daily-notes/2026-05-25` 등) + 미작성 concept 목록. 이 목록이 후속 정리(스텁/링크수정)의 입력.
- [ ] **Step 3: 사용자에게 정리 목록 보고**
산출된 진짜 깨진 링크 목록을 사용자에게 제시하고, 정리(스텁 생성 vs 링크 제거)는 별도 작업으로 진행 여부 확인. (본 plan 범위는 린터까지.)
---
## Self-Review (작성자 점검 완료)
- **Spec coverage:** A1(Task2)·A2(Task1)·B(Task3)·C(Task5 강제 측정)·D(Task4) 전부 task 존재. ✓
- **Placeholder scan:** 모든 code step에 실제 코드 포함. "TBD"/"적절히" 없음. ✓
- **Type consistency:** `_check_anchor`/`_heading_set`/`HEADING_RE` Task3에서 정의 후 Task1 호출부와 시그니처 일치. Task1 Step3b가 임시 인라인 → Task3가 추출로 대체(순서 명시). `build_vault_index` 반환 `(paths, bases)` 불변. ✓
- **환경:** git 미초기화 → 커밋 대신 테스트 체크포인트(헤더 명시). ✓