Files
llm-wiki/.claude/hooks/test_wiki_consistency_check.py

269 lines
11 KiB
Python

#!/usr/bin/env python3
"""wiki_consistency_check.py 단위 테스트 (stdlib unittest)."""
import importlib.util
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
_SPEC = importlib.util.spec_from_file_location(
"wcc", str(Path(__file__).with_name("wiki_consistency_check.py")))
wcc = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(wcc)
_CHK = str(Path(__file__).with_name("wiki_consistency_check.py"))
DEM_B = (
"## Decision Evidence Map\n"
"| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |\n"
"|---|---|---|---|---|\n"
"| D1 | x | C1 | official | none |\n"
"| **D17** | y | C2 | official | none |\n"
)
def mk_vault(root: Path, files: dict[str, str]):
for rel, text in files.items():
p = root / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(text, encoding="utf-8")
class TestRegistry(unittest.TestCase):
def test_dem_first_cell_definitions(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
mk_vault(root, {"raw/branch-notes/feature-b.md": "# b\n" + DEM_B})
reg = wcc.decision_registry(root)
self.assertEqual(reg["feature-b"], {"D1", "D17"})
def test_fenced_examples_excluded(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
mk_vault(root, {"raw/branch-notes/feature-b.md":
"# b\n```\n| D99 | 예시 |\n```\n" + DEM_B})
self.assertNotIn("D99", wcc.decision_registry(root)["feature-b"])
class TestRefChecks(unittest.TestCase):
def _vault(self, root):
mk_vault(root, {
"raw/branch-notes/feature-b.md": "# b\n" + DEM_B,
"raw/project-notes/proj.md": "# p\n## 6. Error Category\n본문\n## 34. Stack\n본문\n",
})
def _check(self, root, rel, text):
dreg = wcc.decision_registry(root)
sreg = wcc.section_registry(root)
return wcc.check_file_refs(rel, text, dreg, sreg)
def test_valid_ref_passes(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._vault(root)
f = self._check(root, "raw/branch-notes/feature-a.md",
"# a\ncross-cite [[raw/branch-notes/feature-b]] D17 의 rule\n")
self.assertEqual(f, [])
def test_dangling_decision_ref(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._vault(root)
f = self._check(root, "raw/branch-notes/feature-a.md",
"# a\n[[raw/branch-notes/feature-b]] D99 consume\n")
self.assertTrue(any(c == "DANGLING_DECISION_REF" for c, _, _ in f))
def test_missing_note_not_reported_here(self):
# 노트 자체 부재는 structure lint 의 BROKEN_LINK 몫 — 중복 보고 금지.
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._vault(root)
f = self._check(root, "raw/branch-notes/feature-a.md",
"# a\n[[raw/branch-notes/feature-ghost]] D1\n")
self.assertEqual(f, [])
def test_bare_decision_ref(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._vault(root)
f = self._check(root, "raw/branch-notes/feature-a.md",
"# a\nSSOT 는 feature-b-contract 의 (D3, D4)\n")
self.assertTrue(any(c == "BARE_DECISION_REF" for c, _, _ in f))
def test_self_ref_excluded(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._vault(root)
f = self._check(root, "raw/branch-notes/feature-b.md",
"# b\n본 branch [[raw/branch-notes/feature-b]] D17 자기 참조\n" + DEM_B)
self.assertEqual(f, [])
def test_section_ref(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._vault(root)
ok = self._check(root, "raw/branch-notes/feature-a.md",
"# a\n[[raw/project-notes/proj]] §34 Stack 의존\n")
self.assertEqual(ok, [])
bad = self._check(root, "raw/branch-notes/feature-a.md",
"# a\n[[raw/project-notes/proj]] §99 의존\n")
self.assertTrue(any(c == "DANGLING_SECTION_REF" for c, _, _ in bad))
def test_lowercase_d2_not_matched(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._vault(root)
f = self._check(root, "raw/branch-notes/feature-a.md",
"# a\n[[raw/branch-notes/feature-b]] 참고: d2.naver.com 사례\n")
self.assertEqual(f, [])
COV_A = (
"## Coverage / 관심사 커버리지\n"
"| 관심사 | 상태 | owner | 심각도 | 근거 |\n"
"|---|---|---|---|---|\n"
"| idempotency dedup | delegated | [[raw/branch-notes/feature-b]] | OK | D1 |\n"
"| pool metric 이름 | delegated | feature-metrics-contract | OK | x |\n"
"| C1: 상태 머신 | covered-here | — | — | D2 |\n"
)
COV_B = (
"## Coverage / 관심사 커버리지\n"
"| 관심사 | 상태 | owner | 심각도 | 근거 |\n"
"|---|---|---|---|---|\n"
"| 상태 머신 | covered-here | — | — | D5 |\n"
)
class TestCoverage(unittest.TestCase):
def test_bare_owner_ref(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
f = wcc.check_coverage("raw/branch-notes/feature-a.md", "# a\n" + COV_A)
self.assertEqual(sum(1 for c, _, _ in f if c == "BARE_OWNER_REF"), 1)
def test_dual_ownership(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
mk_vault(root, {
"raw/branch-notes/feature-a.md": "# a\n" + COV_A,
"raw/branch-notes/feature-b.md": "# b\n" + COV_B,
})
f = wcc.check_dual_ownership(root)
self.assertTrue(any(c == "DUAL_OWNERSHIP" for c, _, _ in f))
class TestImpact(unittest.TestCase):
def test_referrers(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
mk_vault(root, {
"raw/branch-notes/feature-b.md": "# b\n" + DEM_B,
"raw/branch-notes/feature-a.md": "# a\n[[raw/branch-notes/feature-b]] D17 consume\n",
"raw/branch-notes/feature-c.md": "# c\n무관\n",
})
refs = wcc.referrers_of(root, "feature-b")
self.assertEqual(len(refs), 1)
self.assertEqual(refs[0][0], "raw/branch-notes/feature-a.md")
def _run(args, event=None, root=None):
cmd = ["python3", _CHK] + args + (["--root", str(root)] if root else [])
return subprocess.run(cmd, input=json.dumps(event) if event else "",
capture_output=True, text=True)
class TestHookModes(unittest.TestCase):
def _vault(self, root):
mk_vault(root, {"raw/branch-notes/feature-b.md": "# b\n" + DEM_B})
def test_pre_dangling_blocks(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._vault(root)
ev = {"hook_event_name": "PreToolUse", "tool_name": "Write",
"tool_input": {"file_path": str(root / "raw/branch-notes/feature-a.md"),
"content": "# a\n[[raw/branch-notes/feature-b]] D99\n"}}
r = _run(["--pre"], ev, root)
self.assertEqual(r.returncode, 2)
self.assertIn("DANGLING_DECISION_REF", r.stderr)
def test_pre_valid_passes(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._vault(root)
ev = {"hook_event_name": "PreToolUse", "tool_name": "Write",
"tool_input": {"file_path": str(root / "raw/branch-notes/feature-a.md"),
"content": "# a\n[[raw/branch-notes/feature-b]] D17\n"}}
self.assertEqual(_run(["--pre"], ev, root).returncode, 0)
def test_pre_self_definition_in_projected(self):
# 자기 노트에 D5 를 정의하면서 동시에 자기-참조하는 쓰기 — 차단 금지
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._vault(root)
content = "# a\n| D5 | x | C1 | o | n |\n[[raw/branch-notes/feature-b]] D1\n"
ev = {"hook_event_name": "PreToolUse", "tool_name": "Write",
"tool_input": {"file_path": str(root / "raw/branch-notes/feature-a.md"),
"content": content}}
self.assertEqual(_run(["--pre"], ev, root).returncode, 0)
def test_post_dem_edit_warns_referrers(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
mk_vault(root, {
"raw/branch-notes/feature-b.md": "# b\n" + DEM_B,
"raw/branch-notes/feature-a.md": "# a\n[[raw/branch-notes/feature-b]] D17\n",
})
ev = {"hook_event_name": "PostToolUse", "tool_name": "Edit",
"tool_input": {"file_path": str(root / "raw/branch-notes/feature-b.md"),
"old_string": "| **D17** | y | C2 | official | none |",
"new_string": "| **D17** | y-개정 | C2 | official | none |"}}
r = _run(["--post"], ev, root)
self.assertEqual(r.returncode, 2)
self.assertIn("역참조 충격", r.stderr)
self.assertIn("feature-a", r.stderr)
def test_post_non_dem_edit_silent(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
mk_vault(root, {
"raw/branch-notes/feature-b.md": "# b\n" + DEM_B,
"raw/branch-notes/feature-a.md": "# a\n[[raw/branch-notes/feature-b]] D17\n",
})
ev = {"hook_event_name": "PostToolUse", "tool_name": "Edit",
"tool_input": {"file_path": str(root / "raw/branch-notes/feature-b.md"),
"old_string": "본문 한 줄", "new_string": "본문 두 줄"}}
self.assertEqual(_run(["--post"], ev, root).returncode, 0)
def test_packets_mode(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
mk_vault(root, {
"raw/branch-notes/feature-b.md": "# b\n" + DEM_B,
"raw/branch-notes/feature-a.md": "# a\n맥락 위\n[[raw/branch-notes/feature-b]] D17 consume\n맥락 아래\n",
})
r = _run(["--packets"], root=root)
self.assertEqual(r.returncode, 0)
self.assertIn("Edge 1", r.stdout)
self.assertIn("feature-a.md:3", r.stdout) # citing 줄
self.assertIn("**D17**", r.stdout) # owner D-row 원문
self.assertIn("packets: 1 edges", r.stdout)
def test_all_mode(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
mk_vault(root, {
"raw/branch-notes/feature-b.md": "# b\n" + DEM_B,
"raw/branch-notes/feature-a.md": "# a\n[[raw/branch-notes/feature-b]] D99\n",
})
r = _run(["--all"], root=root)
self.assertEqual(r.returncode, 1)
self.assertIn("DANGLING_DECISION_REF", r.stdout)
if __name__ == "__main__":
unittest.main(verbosity=2)