269 lines
11 KiB
Python
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)
|