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

18 KiB

옵시디언 링크 검증 강화 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 신설
#!/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.pycheck_c2 본문(187-226)을 아래로 교체:

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 로직으로 대체:
            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에 클래스 추가

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("<xml/>")
            (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 교체
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: 실패 테스트 추가

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("<xml/>")
            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 위에 헬퍼 추가:

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에서 인라인한 부분)을 한 줄로 교체:

            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줄):

> **결정론 집행기**: 위 옵시디언 링크 문법(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 미초기화 → 커밋 대신 테스트 체크포인트(헤더 명시). ✓