#!/usr/bin/env python3 """wiki_structure_lint.py 단위 테스트 (stdlib unittest).""" import importlib.util import sys import tempfile import unittest from pathlib import Path # wsl 이 sibling wiki_rules 를 import 하므로 hooks 디렉터리를 path 에 추가. sys.path.insert(0, str(Path(__file__).resolve().parent)) # 하이픈 모듈명이 아니라 언더스코어 — 직접 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_codespan_link_ignored(self): # 인라인 code span 내부 링크 → 의도적 비활성 표기(템플릿/rules 예시/로그), 위반 아님 → 무시 line = "예시 문법: `[[foo]]` 처럼 씁니다" f = wsl.check_c2(_doc(line), self.vp, self.vb, self.root, {}) self.assertEqual(f, []) def test_codespan_broken_target_also_ignored(self): # code span 내부면 타깃이 없어도 무시(그래프 ghost 안 생김) line = "rules 예시: `[[raw/nonexistent/foo]]`" f = wsl.check_c2(_doc(line), self.vp, self.vb, self.root, {}) self.assertEqual(f, []) def test_double_backtick_codespan_ignored(self): # 이중 백틱 code span(로그에서 `[[X]]` 리터럴 표기) → 무시(오탐 아님) line = "이전엔 `` [[X]] `` 였다가 unwrap" f = wsl.check_c2(_doc(line), self.vp, self.vb, self.root, {}) self.assertEqual(f, []) def test_bare_link_after_codespan_still_flagged(self): # 같은 줄에 code span 뒤 *맨* 위키링크는 여전히 검출 line = "`` [[X]] `` → [[raw/nonexistent/y]] 적용" f = wsl.check_c2(_doc(line), self.vp, self.vb, self.root, {}) self.assertIn("BROKEN_LINK", _codes(f)) 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("") 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)) 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("") (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) class TestClassify(unittest.TestCase): def test_rules_is_links_only(self): self.assertEqual(wsl.classify("rules/branch-depth-gate.md"), "links") def test_log_and_moc_links_only(self): self.assertEqual(wsl.classify("wiki/log.md"), "links") self.assertEqual(wsl.classify("wiki/llm-wiki.md"), "links") # layer 최상위 직속 def test_normal_doc_full(self): self.assertEqual(wsl.classify("wiki/concepts/foo.md"), "full") self.assertEqual(wsl.classify("raw/branch-notes/feature-x.md"), "full") def test_docs_and_toplevel_links_only(self): self.assertEqual(wsl.classify("docs/superpowers/specs/x.md"), "links") self.assertEqual(wsl.classify("CLAUDE.md"), "links") self.assertEqual(wsl.classify("templates/concept-template.md"), "links") class TestEscapedPipeInTable(unittest.TestCase): def test_escaped_pipe_alias_resolves(self): # 마크다운 표의 [[path\|alias]] — escaped pipe 를 split 으로 잘못 잘라 오탐하면 안 됨 vp = {"raw/x/foo"} vb = {"foo": ["raw/x/foo"]} line = "| 2026 | [[raw/x/foo\\|alias-text]] | note |" f = wsl.check_c2(_doc(line), vp, vb, Path("/nonexistent"), {}) self.assertNotIn("BROKEN_LINK", _codes(f)) class TestMarkdownLink(unittest.TestCase): def test_external_url_ok(self): f = wsl.check_c2(_doc("- [doc](https://example.com) ref"), set(), {}, Path("/x"), {}, "raw/a.md") self.assertNotIn("BROKEN_MD_LINK", _codes(f)) def test_missing_outside_vault_flagged(self): f = wsl.check_c2(_doc("- [code](../../outside/X.java#L1) ref"), set(), {}, Path("/nonexistent"), {}, "raw/branch-notes/feature-b.md") self.assertIn("BROKEN_MD_LINK", _codes(f)) def test_placeholder_flagged(self): f = wsl.check_c2(_doc("- [title](URL) ref"), set(), {}, Path("/nonexistent"), {}, "templates/t.md") self.assertIn("BROKEN_MD_LINK", _codes(f)) def test_resolving_relative_ok(self): with tempfile.TemporaryDirectory() as d: root = Path(d) (root / "rules").mkdir() (root / "rules" / "x.md").write_text("x") vp, vb = wsl.build_vault_index(root) f = wsl.check_c2(_doc("- [x](rules/x.md)"), vp, vb, root, {}, "AGENTS.md") self.assertNotIn("BROKEN_MD_LINK", _codes(f)) def test_codespan_md_link_ignored(self): f = wsl.check_c2(_doc("- `[title](URL)` 는 예시"), set(), {}, Path("/x"), {}, "templates/t.md") self.assertEqual(f, []) class TestProjectMode(unittest.TestCase): def test_classify_project_note(self): # raw/project-notes/*.md → 'project' 모드 (root=None 이어도 동작) self.assertEqual(wsl.classify("raw/project-notes/foo.md"), "project") # 일반 raw 콘텐츠는 여전히 full self.assertEqual(wsl.classify("raw/branch-notes/feature-x.md"), "full") def test_proxy_flags_missing_diagram_and_table(self): doc = {"text": "# P\n\n본문에 다이어그램도 표도 없음.\n", "lines": ["# P", "", "본문에 다이어그램도 표도 없음.", ""]} codes = _codes(wsl.check_project_proxies(doc)) self.assertIn("PROJECT_NO_DIAGRAM", codes) self.assertIn("PROJECT_NO_BRANCH_TABLE", codes) def test_proxy_satisfied_by_mermaid_and_branch_table(self): text = ( "# P\n\n" "## 4. 시퀀스\n\n" "```mermaid\nsequenceDiagram\n A->>B: x\n```\n\n" "## 8.0 Branch 분해\n\n" "| branch slug | 달성 목표 조건 | 우선순위 |\n" "|---|---|---|\n" "| `feature-x` | 조건 | P1 |\n" ) doc = {"text": text, "lines": text.splitlines()} codes = _codes(wsl.check_project_proxies(doc)) self.assertNotIn("PROJECT_NO_DIAGRAM", codes) self.assertNotIn("PROJECT_NO_BRANCH_TABLE", codes) def test_proxy_satisfied_by_drawio_embed(self): text = "# P\n\n![[raw/diagrams/p/architecture-overview-2026-06-05.drawio.svg]]\n" doc = {"text": text, "lines": text.splitlines()} codes = _codes(wsl.check_project_proxies(doc)) self.assertNotIn("PROJECT_NO_DIAGRAM", codes) class TestPreMode(unittest.TestCase): def _event(self, root, rel, content): return {"tool_name": "Write", "tool_input": {"file_path": str(root / rel), "content": content}} def test_ghost_wikilink_blocks(self): with tempfile.TemporaryDirectory() as d: root = Path(d) (root / "raw" / "branch-notes").mkdir(parents=True) ev = self._event(root, "raw/branch-notes/feature-b.md", "# t\nsee [[raw/nonexistent/ghost]]\n") self.assertEqual(wsl.run_pre(ev, root), 2) def test_backtick_placeholder_passes(self): with tempfile.TemporaryDirectory() as d: root = Path(d) (root / "raw" / "branch-notes").mkdir(parents=True) ev = self._event(root, "raw/branch-notes/feature-b.md", "# t\nfuture: `[[raw/nonexistent/ghost]]`\n") self.assertEqual(wsl.run_pre(ev, root), 0) def test_no_links_skips_and_passes(self): with tempfile.TemporaryDirectory() as d: root = Path(d) (root / "raw" / "branch-notes").mkdir(parents=True) ev = self._event(root, "raw/branch-notes/feature-b.md", "# t\n링크 없는 본문\n") self.assertEqual(wsl.run_pre(ev, root), 0) def test_non_wiki_path_passes(self): with tempfile.TemporaryDirectory() as d: root = Path(d) (root / "docs").mkdir() ev = self._event(root, "docs/x.md", "see [[raw/nonexistent/ghost]]\n") self.assertEqual(wsl.run_pre(ev, root), 0) def test_valid_link_passes(self): with tempfile.TemporaryDirectory() as d: root = Path(d) (root / "raw" / "branch-notes").mkdir(parents=True) (root / "raw" / "x").mkdir(parents=True) (root / "raw" / "x" / "foo.md").write_text("# foo\n") ev = self._event(root, "raw/branch-notes/feature-b.md", "# t\nsee [[raw/x/foo]]\n") self.assertEqual(wsl.run_pre(ev, root), 0) class TestBranchNaming(unittest.TestCase): """P1-11: branch-note 파일명 규칙 — 신규 생성만 차단, 기존 파일 편집은 통과.""" def _event(self, root, rel, content="# t\n본문\n"): return {"tool_name": "Write", "tool_input": {"file_path": str(root / rel), "content": content}} def test_bad_prefix_creation_blocks(self): with tempfile.TemporaryDirectory() as d: root = Path(d) (root / "raw" / "branch-notes").mkdir(parents=True) ev = self._event(root, "raw/branch-notes/develop-x.md") self.assertEqual(wsl.run_pre(ev, root), 2) def test_numbered_hierarchy_creation_blocks(self): with tempfile.TemporaryDirectory() as d: root = Path(d) (root / "raw" / "branch-notes").mkdir(parents=True) ev = self._event(root, "raw/branch-notes/feature-keycloak-1-2.md") self.assertEqual(wsl.run_pre(ev, root), 2) def test_valid_slug_creation_passes(self): with tempfile.TemporaryDirectory() as d: root = Path(d) (root / "raw" / "branch-notes").mkdir(parents=True) ev = self._event(root, "raw/branch-notes/feature-oauth2-token-flow.md") self.assertEqual(wsl.run_pre(ev, root), 0) def test_existing_bad_name_edit_passes(self): # 기존 위반 파일의 편집은 차단하지 않는다 (마이그레이션 가능해야 함). with tempfile.TemporaryDirectory() as d: root = Path(d) (root / "raw" / "branch-notes").mkdir(parents=True) (root / "raw" / "branch-notes" / "develop-x.md").write_text("# old\n") ev = self._event(root, "raw/branch-notes/develop-x.md") self.assertEqual(wsl.run_pre(ev, root), 0) def test_violations_helper(self): self.assertTrue(wsl.branch_naming_violations("raw/branch-notes/develop-x.md")) self.assertTrue(wsl.branch_naming_violations("raw/branch-notes/feature-x-1.md")) self.assertEqual(wsl.branch_naming_violations("raw/branch-notes/feature-x.md"), []) self.assertEqual(wsl.branch_naming_violations("raw/errors/whatever-1.md"), []) self.assertEqual(wsl.branch_naming_violations("raw/branch-notes/README.md"), []) class TestCoveragePre(unittest.TestCase): """P1-11: --coverage-pre 결정론 사전검사 (0 PASS / 1 FAIL / 3 EXEMPT).""" def _note(self, root, fm_extra, body="# t\n## Coverage / 관심사\n"): (root / "raw" / "branch-notes").mkdir(parents=True, exist_ok=True) p = root / "raw" / "branch-notes" / "feature-x.md" p.write_text(f"---\ntitle: x\n{fm_extra}\n---\n{body}", encoding="utf-8") return p def test_exempt(self): with tempfile.TemporaryDirectory() as d: root = Path(d) p = self._note(root, "related_projects: [keycloak-study]") self.assertEqual(wsl.run_coverage_pre(str(p), root), 3) def test_governing_missing_file_fails(self): with tempfile.TemporaryDirectory() as d: root = Path(d) p = self._note(root, "governing_docs: [wiki/projects/ca-tmpl/nonexistent]") self.assertEqual(wsl.run_coverage_pre(str(p), root), 1) def test_pass_with_existing_governing(self): with tempfile.TemporaryDirectory() as d: root = Path(d) g = root / "wiki" / "projects" / "ca-tmpl" g.mkdir(parents=True) (g / "layout.md").write_text("# g\n") p = self._note(root, "governing_docs: [wiki/projects/ca-tmpl/layout]") self.assertEqual(wsl.run_coverage_pre(str(p), root), 0) def test_related_ca_but_no_governing_fails(self): with tempfile.TemporaryDirectory() as d: root = Path(d) p = self._note(root, "related_projects: [ca-skeleton]") self.assertEqual(wsl.run_coverage_pre(str(p), root), 1) class TestStaleMode(unittest.TestCase): """P1-10: --stale 결정론 집계.""" def _doc(self, root, rel, fm): p = root / rel p.parent.mkdir(parents=True, exist_ok=True) p.write_text(f"---\ntitle: x\n{fm}\n---\n# t\n", encoding="utf-8") def test_stale_90(self): import datetime as dt with tempfile.TemporaryDirectory() as d: root = Path(d) old = (dt.date.today() - dt.timedelta(days=120)).isoformat() self._doc(root, "wiki/concepts/a.md", f"status: reviewed\nlast_reviewed: {old}") self.assertEqual(wsl.run_stale(root), 1) def test_fresh_passes(self): import datetime as dt with tempfile.TemporaryDirectory() as d: root = Path(d) today = dt.date.today().isoformat() self._doc(root, "wiki/concepts/a.md", f"status: reviewed\nlast_reviewed: {today}") self.assertEqual(wsl.run_stale(root), 0) class TestHookTiering(unittest.TestCase): # 실제 templates/ 를 임시 vault 로 복사해 resolve_template 이 동작 → 진짜 MISSING_SECTION. _REPO_TEMPLATES = Path(__file__).resolve().parents[2] / "templates" def _vault(self, d): root = Path(d) import shutil shutil.copytree(self._REPO_TEMPLATES, root / "templates") return root def _write(self, root, rel, fm, body): p = root / rel p.parent.mkdir(parents=True, exist_ok=True) p.write_text("---\n" + fm + "\n---\n" + body) return p def _event(self, p): return {"tool_name": "Edit", "tool_input": {"file_path": str(p)}} def _run_capture(self, event, root): """run_hook 의 exit code 와 stderr 출력을 함께 캡처 — *어떤* finding 인지 검증용.""" import contextlib import io buf = io.StringIO() with contextlib.redirect_stderr(buf): code = wsl.run_hook(event, root) return code, buf.getvalue() def test_completed_resolving_type_missing_section(self): # 템플릿이 실제 선언하는 source_type(llm-generated → concept-template)을 써서 # 진짜 MISSING_SECTION 경로를 검증한다(UNMAPPED 가 아니라). with tempfile.TemporaryDirectory() as d: root = self._vault(d) p = self._write(root, "wiki/concepts/x.md", "title: x\nsource_type: llm-generated\nstatus: verified\ntags: [a]", "본문만\n") code, err = self._run_capture(self._event(p), root) self.assertEqual(code, 2) self.assertIn("MISSING_SECTION", err) self.assertNotIn("UNMAPPED_SOURCE_TYPE", err) def test_completed_unmapped_concept_blocks_via_unmapped(self): # 드리프트 기록(외부 리뷰 Finding 2a): CLAUDE.md 는 concept-template→source_type: concept # 라 하지만 templates/concept-template.md 는 source_type: llm-generated 를 선언한다. # 따라서 source_type: concept 문서는 MISSING_SECTION 이 아니라 UNMAPPED_SOURCE_TYPE 로 막힌다. # 둘 다 FIXUP_CODES 라 게이트 동작(exit 2)은 같지만, 원인은 다르다 — 테스트로 명시. with tempfile.TemporaryDirectory() as d: root = self._vault(d) p = self._write(root, "wiki/concepts/x.md", "title: x\nsource_type: concept\nstatus: verified\ntags: [a]", "본문만\n") code, err = self._run_capture(self._event(p), root) self.assertEqual(code, 2) self.assertIn("UNMAPPED_SOURCE_TYPE", err) def test_draft_missing_section_warns_only(self): with tempfile.TemporaryDirectory() as d: root = self._vault(d) # draft → 완성 선언 아님 → C1 미실행 → exit 0 (WARN) p = self._write(root, "wiki/concepts/x.md", "title: x\nsource_type: concept\nstatus: draft\ntags: [a]", "본문만\n") self.assertEqual(wsl.run_hook(self._event(p), root), 0) def test_non_wiki_path_passes(self): with tempfile.TemporaryDirectory() as d: root = self._vault(d) p = self._write(root, "docs/x.md", "title: x", "본문\n") self.assertEqual(wsl.run_hook(self._event(p), root), 0) class TestAntigravityMode(unittest.TestCase): import json as _json import subprocess as _sp _LINT = str(Path(__file__).with_name("wiki_structure_lint.py")) def _run(self, content, *extra): ev = {"hook_event_name": "PreToolUse", "tool_name": "Write", "tool_input": {"file_path": "raw/branch-notes/feature-ag.md", "content": content}} return self._sp.run(["python3", self._LINT, "--pre", *extra], input=self._json.dumps(ev), capture_output=True, text=True) def test_pre_ghost_deny_decision_json_exit0(self): r = self._run("# t\nsee [[raw/nonexistent/ghost-xyz999]]\n", "--antigravity") self.assertEqual(r.returncode, 0) self.assertEqual(self._json.loads(r.stdout)["decision"], "deny") def test_pre_backtick_allow_decision_json(self): r = self._run("# t\n`[[raw/nonexistent/ghost-xyz999]]`\n", "--antigravity") self.assertEqual(r.returncode, 0) self.assertEqual(self._json.loads(r.stdout)["decision"], "allow") def test_pre_ghost_non_antigravity_exit2(self): # 회귀: --antigravity 없으면 Claude exit-code 규약 r = self._run("# t\nsee [[raw/nonexistent/ghost-xyz999]]\n") self.assertEqual(r.returncode, 2) if __name__ == "__main__": unittest.main(verbosity=2)