471 lines
21 KiB
Python
471 lines
21 KiB
Python
#!/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("<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))
|
|
|
|
|
|
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)
|
|
|
|
|
|
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)
|