# 옵시디언 링크 검증 강화 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 미초기화 → 커밋 대신 테스트 체크포인트(헤더 명시). ✓