427 lines
18 KiB
Markdown
427 lines
18 KiB
Markdown
# 옵시디언 링크 검증 강화 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("<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` 교체**
|
|
|
|
```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("<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` 위에 헬퍼 추가:
|
|
|
|
```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 미초기화 → 커밋 대신 테스트 체크포인트(헤더 명시). ✓
|