#!/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)