683 lines
30 KiB
Markdown
683 lines
30 KiB
Markdown
# Spec A — Deterministic Backbone Gate Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
> **Commits excluded** per user instruction ("커밋은 제외") — no `git commit` steps. Each task ends with a test-green checkpoint instead.
|
|
|
|
**Goal:** Turn the existing (non-blocking) `wiki_structure_lint.py` into a real hybrid gate — PreToolUse blocks ghost-creating links, PostToolUse becomes an exit-2 fix-up gate for completeness findings on completion-declared docs — while deduping the claim-rule SSOT into a shared `wiki_rules.py` and adding the first tests for the blocking `wiki_claim_gate.py`.
|
|
|
|
**Architecture:** New `wiki_rules.py` holds shared event/IO mechanism + the claim-requirement SSOT data + severity-tier constants. `wiki_claim_gate.py` imports it and drives its table checks from the shared data (behavior-preserving refactor). `wiki_structure_lint.py` gains a `--pre` mode (PreToolUse, blocks `BROKEN_LINK`/`BROKEN_MD_LINK` on projected content) and a tiered `--hook` mode (PostToolUse exit-2 fix-up for `FIXUP_CODES` when the doc declares completion). Link logic stays in structure_lint; claim logic stays in claim_gate; only mechanism + reference data is shared.
|
|
|
|
**Tech Stack:** Python 3 stdlib only (`unittest`, `importlib`, `re`, `pathlib`, `json`). No third-party deps. Claude Code hooks (`settings.json`).
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-06-06-spec-a-deterministic-backbone-gate-design.md`
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- **Create** `.claude/hooks/wiki_rules.py` — shared mechanism (moved verbatim from claim_gate) + `CLAIM_REQUIREMENTS` SSOT data + `CRITICAL_CODES`/`FIXUP_CODES`.
|
|
- **Create** `.claude/hooks/test_wiki_rules.py` — unit tests for moved `projected_content` + data integrity.
|
|
- **Modify** `.claude/hooks/wiki_claim_gate.py` — `import wiki_rules`; drive `check_markdown_write` table checks from `CLAIM_REQUIREMENTS`; keep two semantic special-cases inline.
|
|
- **Create** `.claude/hooks/test_wiki_claim_gate.py` — regression-lock the 5-prefix block/pass behavior.
|
|
- **Modify** `.claude/hooks/wiki_structure_lint.py` — `import wiki_rules`; add `run_pre()` + `run_hook()` testable functions; wire `--pre`; tier `--hook`; add suppressed-count line.
|
|
- **Modify** `.claude/hooks/test_wiki_structure_lint.py` — add `--pre`/`--hook` fix-up cases.
|
|
- **Modify** `.claude/settings.json` — add `structure_lint.py --pre` to PreToolUse.
|
|
|
|
**Sibling-import rule (applies to both hooks):** the first executable lines (after `from __future__`) must be:
|
|
```python
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import wiki_rules
|
|
```
|
|
This makes `import wiki_rules` resolve whether the file is run as a script (hook) or loaded via `importlib.spec_from_file_location` (tests).
|
|
|
|
---
|
|
|
|
## Task 1: Create `wiki_rules.py` shared module
|
|
|
|
**Files:**
|
|
- Create: `.claude/hooks/wiki_rules.py`
|
|
- Test: `.claude/hooks/test_wiki_rules.py`
|
|
|
|
The moved helpers are **verbatim copies** of `wiki_claim_gate.py` current functions: `read_event` (42-47), `tool_name` (50-56), `tool_input` (59-76), `target_path` (79-85), `write_content` (88-94), `projected_content` (97-135), `command_string` (138-145), `rel_to_root` (148-154), `has_table` (157-163). `ROOT` is the same `Path(__file__).resolve().parents[2]`.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `.claude/hooks/test_wiki_rules.py`:
|
|
```python
|
|
#!/usr/bin/env python3
|
|
"""wiki_rules.py 단위 테스트 (stdlib unittest)."""
|
|
import importlib.util
|
|
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(
|
|
"wiki_rules", str(Path(__file__).with_name("wiki_rules.py")))
|
|
wr = importlib.util.module_from_spec(_SPEC)
|
|
_SPEC.loader.exec_module(wr)
|
|
|
|
|
|
class TestProjectedContent(unittest.TestCase):
|
|
def test_write_full_content(self):
|
|
# Write 스타일: content 키가 있으면 그대로 반환
|
|
inp = {"content": "FULL BODY"}
|
|
self.assertEqual(wr.projected_content(None, inp), "FULL BODY")
|
|
|
|
def test_edit_applies_old_new(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
p = Path(d) / "f.md"
|
|
p.write_text("alpha BETA gamma")
|
|
inp = {"old_string": "BETA", "new_string": "DELTA"}
|
|
self.assertEqual(wr.projected_content(p, inp), "alpha DELTA gamma")
|
|
|
|
|
|
class TestSeverityData(unittest.TestCase):
|
|
def test_critical_codes_are_links(self):
|
|
self.assertIn("BROKEN_LINK", wr.CRITICAL_CODES)
|
|
self.assertIn("BROKEN_MD_LINK", wr.CRITICAL_CODES)
|
|
self.assertNotIn("MISSING_SECTION", wr.CRITICAL_CODES)
|
|
|
|
def test_fixup_codes_are_completeness(self):
|
|
for c in ("MISSING_SECTION", "MISSING_FRONTMATTER",
|
|
"EMPTY_SELECTION_CRITERION", "DANGLING_ANCHOR",
|
|
"PROJECT_NO_DIAGRAM", "PROJECT_NO_BRANCH_TABLE",
|
|
"UNMAPPED_SOURCE_TYPE"):
|
|
self.assertIn(c, wr.FIXUP_CODES)
|
|
self.assertNotIn("BROKEN_LINK", wr.FIXUP_CODES)
|
|
|
|
def test_claim_requirements_cover_five_prefixes(self):
|
|
prefixes = {p for req in wr.CLAIM_REQUIREMENTS for p in req["prefix"]}
|
|
for p in ("raw/official-docs/", "raw/company-tech-blogs/",
|
|
"raw/branch-notes/", "wiki/concepts/"):
|
|
self.assertIn(p, prefixes)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `python3 .claude/hooks/test_wiki_rules.py`
|
|
Expected: FAIL — `FileNotFoundError`/`ModuleNotFoundError` (wiki_rules.py does not exist yet).
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
Create `.claude/hooks/wiki_rules.py`. Copy the 9 helper functions **verbatim** from `wiki_claim_gate.py` (current line ranges noted in the Task header), then append the SSOT data and severity constants:
|
|
```python
|
|
#!/usr/bin/env python3
|
|
"""wiki_rules.py — claim_gate / structure_lint 공유 기계장치 + SSOT 데이터 (stdlib only).
|
|
|
|
여기엔 *정책*이 아니라 *공유 메커니즘*과 *참조 데이터*만 둔다:
|
|
- 이벤트/IO 헬퍼 (claim_gate 에서 이관, 두 훅이 공유)
|
|
- CLAIM_REQUIREMENTS : claim 테이블/섹션 요구 SSOT (이전엔 claim_gate inline 하드코딩 — G5 dedup)
|
|
- 심각도 티어 상수 : structure_lint 의 게이트 결정(차단 vs fix-up vs warn)이 소비
|
|
정책(block/warn 적용)은 각 훅에 남는다.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shlex
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
# ---------- 이벤트/IO 헬퍼 (claim_gate 에서 verbatim 이관) ----------
|
|
# read_event / tool_name / tool_input / target_path / write_content /
|
|
# projected_content / command_string / rel_to_root / has_table
|
|
# (wiki_claim_gate.py 의 동일 함수 본문을 그대로 복사. target_path/rel_to_root 는 ROOT 사용.)
|
|
|
|
# ... (verbatim copies here) ...
|
|
|
|
# ---------- claim 요구 SSOT (G5 dedup 대상) ----------
|
|
CLAIM_REQUIREMENTS = [
|
|
{"prefix": ("raw/official-docs/", "raw/company-tech-blogs/"),
|
|
"tables": [("## Claims Extracted",
|
|
["Claim ID", "Claim", "Evidence quote", "Strength", "Applies to", "Does not prove"])],
|
|
"sections": ["## Usage Boundaries"]},
|
|
{"prefix": ("raw/branch-notes/",),
|
|
"tables": [("## Decision Evidence Map",
|
|
["Decision ID", "Decision", "Supporting Claims", "Evidence Strength", "Open Risk"])],
|
|
"section_regex": [r"^## .*\bClaims To Verify\b"]},
|
|
{"prefix": ("wiki/concepts/",),
|
|
"tables": [("## Claim-backed Knowledge",
|
|
["Knowledge Point", "Supporting Claims", "Confidence", "Notes"])]},
|
|
]
|
|
|
|
# ---------- 심각도 티어 (structure_lint 소비) ----------
|
|
CRITICAL_CODES = frozenset({"BROKEN_LINK", "BROKEN_MD_LINK"}) # PreToolUse block
|
|
FIXUP_CODES = frozenset({
|
|
"MISSING_SECTION", "MISSING_FRONTMATTER", "EMPTY_SELECTION_CRITERION",
|
|
"DANGLING_ANCHOR", "PROJECT_NO_DIAGRAM", "PROJECT_NO_BRANCH_TABLE",
|
|
"UNMAPPED_SOURCE_TYPE",
|
|
}) # PostToolUse exit-2 (완성 선언 시)
|
|
```
|
|
Replace the `# ... (verbatim copies here) ...` comment with the 9 functions copied exactly from `wiki_claim_gate.py`. Keep their bodies unchanged except `target_path`/`rel_to_root`, which already reference module-level `ROOT` — that now resolves to `wiki_rules.ROOT` (same value).
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `python3 .claude/hooks/test_wiki_rules.py`
|
|
Expected: PASS (5 tests OK).
|
|
|
|
- [ ] **Step 5: Checkpoint (no commit)**
|
|
|
|
Run: `python3 .claude/hooks/test_wiki_rules.py` → all green. Do NOT commit.
|
|
|
|
---
|
|
|
|
## Task 2: Refactor `wiki_claim_gate.py` to consume `wiki_rules` (behavior-preserving)
|
|
|
|
**Files:**
|
|
- Modify: `.claude/hooks/wiki_claim_gate.py` (replace lines 42-163 helpers with import; rewrite `check_markdown_write` 166-220 to drive table/section checks from `wiki_rules.CLAIM_REQUIREMENTS`)
|
|
- Test: `.claude/hooks/test_wiki_claim_gate.py` (new — written FIRST to lock current behavior)
|
|
|
|
This is a refactor: tests are written against **current** behavior and must stay green through the change.
|
|
|
|
- [ ] **Step 1: Write the regression-lock test**
|
|
|
|
Create `.claude/hooks/test_wiki_claim_gate.py`:
|
|
```python
|
|
#!/usr/bin/env python3
|
|
"""wiki_claim_gate.py 회귀 고정 테스트 — check_markdown_write 행동 동치 (refactor 전후 동일)."""
|
|
import importlib.util
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
_SPEC = importlib.util.spec_from_file_location(
|
|
"wcg", str(Path(__file__).with_name("wiki_claim_gate.py")))
|
|
wcg = importlib.util.module_from_spec(_SPEC)
|
|
_SPEC.loader.exec_module(wcg)
|
|
|
|
CLAIMS_TABLE = (
|
|
"## Claims Extracted\n"
|
|
"| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |\n"
|
|
"|---|---|---|---|---|---|\n"
|
|
"| C1 | x | q | company-case-study | a | b |\n"
|
|
)
|
|
USAGE = "## Usage Boundaries\n- x\n"
|
|
DEM = (
|
|
"## Decision Evidence Map\n"
|
|
"| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |\n"
|
|
"|---|---|---|---|---|\n"
|
|
"| D1 | x | C1 | company-case-study | none |\n"
|
|
)
|
|
CTV = "## 검증해야 할 주장 / Claims To Verify\n- v\n"
|
|
|
|
|
|
class TestSourceNote(unittest.TestCase):
|
|
def test_missing_claims_table_blocks(self):
|
|
f = wcg.check_markdown_write("raw/official-docs/x.md", "# t\n" + USAGE)
|
|
self.assertTrue(any("Claims Extracted" in m for m in f))
|
|
|
|
def test_complete_source_note_passes(self):
|
|
f = wcg.check_markdown_write("raw/official-docs/x.md", "# t\n" + CLAIMS_TABLE + USAGE)
|
|
self.assertEqual(f, [])
|
|
|
|
|
|
class TestBranchNote(unittest.TestCase):
|
|
def test_missing_dem_blocks(self):
|
|
f = wcg.check_markdown_write("raw/branch-notes/feature-x.md", "# t\n" + CTV)
|
|
self.assertTrue(any("Decision Evidence Map" in m for m in f))
|
|
|
|
def test_complete_branch_note_passes(self):
|
|
f = wcg.check_markdown_write("raw/branch-notes/feature-x.md", "# t\n" + DEM + CTV)
|
|
self.assertEqual(f, [])
|
|
|
|
def test_officially_supported_without_strength_blocks(self):
|
|
body = "# t\n" + DEM + CTV + "\n이 기능은 officially supported 된다.\n"
|
|
f = wcg.check_markdown_write("raw/branch-notes/feature-x.md", body)
|
|
self.assertTrue(any("official" in m.lower() for m in f))
|
|
|
|
|
|
class TestConceptNote(unittest.TestCase):
|
|
def test_missing_claim_backed_blocks(self):
|
|
f = wcg.check_markdown_write("wiki/concepts/x.md", "# t\n본문")
|
|
self.assertTrue(any("Claim-backed Knowledge" in m for m in f))
|
|
|
|
|
|
class TestUnrelatedPath(unittest.TestCase):
|
|
def test_non_gated_path_passes(self):
|
|
# 게이트 대상 아닌 경로 → 통과(빈 failures)
|
|
f = wcg.check_markdown_write("wiki/projects/x.md", "# anything\n")
|
|
self.assertEqual(f, [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it passes against CURRENT code**
|
|
|
|
Run: `python3 .claude/hooks/test_wiki_claim_gate.py`
|
|
Expected: PASS — this locks the current behavior as the regression baseline (claim_gate already implements all these checks). If any test fails now, the test encodes a wrong expectation — fix the test to match current behavior before refactoring.
|
|
|
|
- [ ] **Step 3: Refactor `wiki_claim_gate.py`**
|
|
|
|
(a) Replace the helper block (current lines ~12-163: the `from __future__` through `has_table`) so the top reads:
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import wiki_rules
|
|
from wiki_rules import (
|
|
ROOT, read_event, tool_name, tool_input, target_path, write_content,
|
|
projected_content, command_string, rel_to_root, has_table,
|
|
)
|
|
```
|
|
Keep `emit_allow`, `emit_block` as they are (they `print`/`sys.exit`, hook-specific). Delete the now-moved 9 functions from claim_gate.
|
|
|
|
(b) Rewrite `check_markdown_write(rel, text)` so the official-docs / company-tech-blogs / branch-notes / concepts table+section checks are produced by iterating `wiki_rules.CLAIM_REQUIREMENTS`, while the two **semantic** special-cases stay inline:
|
|
```python
|
|
def check_markdown_write(rel: str, text: str) -> list[str]:
|
|
failures: list[str] = []
|
|
if not rel.endswith(".md") or not text:
|
|
return failures
|
|
|
|
for req in wiki_rules.CLAIM_REQUIREMENTS:
|
|
if not rel.startswith(req["prefix"]): # tuple-of-prefixes → str.startswith accepts tuple
|
|
continue
|
|
for section, cols in req.get("tables", []):
|
|
if not has_table(text, section, cols):
|
|
failures.append(
|
|
f"{req['prefix'][0]} 류 문서는 `{section}` 표(열: {' | '.join(cols)})를 가져야 한다."
|
|
)
|
|
for sec in req.get("sections", []):
|
|
if sec not in text:
|
|
failures.append(f"문서는 `{sec}` 섹션을 가져야 한다.")
|
|
for rx in req.get("section_regex", []):
|
|
if not re.search(rx, text, re.MULTILINE):
|
|
failures.append("branch-note must include `## Claims To Verify` "
|
|
"(bilingual `## 검증해야 할 주장 / Claims To Verify` 도 허용).")
|
|
|
|
# 의미 규칙 1: branch-note 의 'officially supported' 주장은 official 강도 필요 (정책 — 인라인 유지)
|
|
if rel.startswith("raw/branch-notes/"):
|
|
if re.search(r"(?i)\bofficial(?:ly)? supported\b|공식(?:적으로)?\s*지원", text):
|
|
if not re.search(r"official-(standard|vendor-doc|reference)", text):
|
|
failures.append(
|
|
"`officially supported` style claim requires an official claim strength "
|
|
"(`official-standard`, `official-vendor-doc`, or `official-reference`)."
|
|
)
|
|
|
|
# 의미 규칙 2: 감사 리포트가 COMPLETE 주장 시 traceability 검증 포함 (정책 — 인라인 유지)
|
|
if rel.startswith("docs/superpowers/specs/") and rel.endswith("-report.md"):
|
|
if re.search(r"Verdict:\s*COMPLETE|\*\*Verdict:?\*\*\s*COMPLETE", text):
|
|
required = ["Decision Evidence Map", "Claims Extracted", "UNSUPPORTED_DECISION"]
|
|
missing = [item for item in required if item not in text]
|
|
if missing:
|
|
failures.append(
|
|
"audit report cannot claim COMPLETE unless it verifies claim traceability. "
|
|
f"Missing references: {', '.join(missing)}."
|
|
)
|
|
|
|
return failures
|
|
```
|
|
Note: `str.startswith` accepts a tuple, so `rel.startswith(req["prefix"])` works directly with the `prefix` tuples. `main()` and the rest of the file (subagent gates, bash gate) are unchanged except they now reference the imported helpers.
|
|
|
|
- [ ] **Step 4: Run tests to verify they still pass**
|
|
|
|
Run: `python3 .claude/hooks/test_wiki_claim_gate.py`
|
|
Expected: PASS — behavior unchanged after refactor.
|
|
|
|
- [ ] **Step 5: Checkpoint (no commit)**
|
|
|
|
Run both: `python3 .claude/hooks/test_wiki_rules.py && python3 .claude/hooks/test_wiki_claim_gate.py` → green. No commit.
|
|
|
|
---
|
|
|
|
## Task 3: Add `--pre` mode to `wiki_structure_lint.py` (PreToolUse link block)
|
|
|
|
**Files:**
|
|
- Modify: `.claude/hooks/wiki_structure_lint.py` (add `import wiki_rules`; add `run_pre()`; wire `--pre` in `main`)
|
|
- Test: `.claude/hooks/test_wiki_structure_lint.py` (append `TestPreMode`)
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `.claude/hooks/test_wiki_structure_lint.py` (the module is loaded as `wsl`; add a `sys.path` insert near the top of the file if not present, mirroring Task 1):
|
|
```python
|
|
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/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/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/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)
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `python3 .claude/hooks/test_wiki_structure_lint.py -k Pre`
|
|
Expected: FAIL — `AttributeError: module 'wsl' has no attribute 'run_pre'`.
|
|
|
|
- [ ] **Step 3: Implement `run_pre`**
|
|
|
|
In `wiki_structure_lint.py`, after the `from __future__ import annotations` line add the sibling import:
|
|
```python
|
|
import sys
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import wiki_rules
|
|
```
|
|
(Path is already imported below; move the `from pathlib import Path` above this insert, or use a local import — keep it stdlib-clean.) Then add, near `main()`:
|
|
```python
|
|
def run_pre(event, root):
|
|
"""PreToolUse: projected 본문의 C2 깨진링크(CRITICAL)만 차단. 반환 exit code."""
|
|
inp = wiki_rules.tool_input(event)
|
|
p = wiki_rules.target_path(inp)
|
|
if p is None or not str(p).endswith(".md"):
|
|
return 0
|
|
try:
|
|
rel = p.resolve().relative_to(root).as_posix()
|
|
except Exception:
|
|
return 0
|
|
if not (rel.startswith("raw/") or rel.startswith("wiki/")):
|
|
return 0
|
|
text = wiki_rules.projected_content(p, inp)
|
|
# 위키링크/마크다운링크가 전혀 없으면 vault 인덱스 빌드 스킵 (성능).
|
|
if "[[" not in text and "](" not in text:
|
|
return 0
|
|
vp, vb = build_vault_index(root)
|
|
doc = {"lines": text.splitlines()}
|
|
findings = check_c2(doc, vp, vb, root, {}, rel)
|
|
critical = [(c, ln, m) for (c, ln, m) in findings if c in wiki_rules.CRITICAL_CODES]
|
|
if critical:
|
|
print(f"✗ wiki-structure-lint (pre): {rel} — 깨진 링크 {len(critical)}건 → 쓰기 차단",
|
|
file=sys.stderr)
|
|
for code, ln, msg in critical[:10]:
|
|
loc = f":{ln}" if ln else ""
|
|
print(f" [{code}]{loc} {msg}", file=sys.stderr)
|
|
if len(critical) > 10:
|
|
print(f" … 외 {len(critical) - 10}건 (suppressed)", file=sys.stderr)
|
|
print(" 미존재 타깃은 백틱 코드(`[[slug]]`)로 표기하거나 타깃 파일을 먼저 생성하세요.",
|
|
file=sys.stderr)
|
|
return 2
|
|
return 0
|
|
```
|
|
Wire it in `main()` — add the arg and dispatch **before** the existing `--hook` block:
|
|
```python
|
|
ap.add_argument("--pre", action="store_true",
|
|
help="PreToolUse hook — projected 본문 C2 깨진링크 차단 (blocking)")
|
|
...
|
|
if args.pre:
|
|
import json as _json
|
|
try:
|
|
event = _json.loads(sys.stdin.read() or "{}")
|
|
except Exception:
|
|
sys.exit(0)
|
|
sys.exit(run_pre(event, root))
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `python3 .claude/hooks/test_wiki_structure_lint.py -k Pre`
|
|
Expected: PASS (4 tests).
|
|
|
|
- [ ] **Step 5: Checkpoint (no commit)**
|
|
|
|
Run: `python3 .claude/hooks/test_wiki_structure_lint.py` → all green (old + new).
|
|
|
|
---
|
|
|
|
## Task 4: Tier `--hook` into fix-up gate + suppressed-count line
|
|
|
|
**Files:**
|
|
- Modify: `.claude/hooks/wiki_structure_lint.py` (extract existing `--hook` body 486-531 into `run_hook(event, root)`; add exit-2 on `FIXUP_CODES` when `is_completeness_checkable`; add suppressed line)
|
|
- Test: `.claude/hooks/test_wiki_structure_lint.py` (append `TestHookTiering`)
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append:
|
|
```python
|
|
class TestHookTiering(unittest.TestCase):
|
|
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 test_completed_missing_section_blocks(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
root = Path(d)
|
|
# 완성 선언(status: verified) + 필수 섹션 누락 → fix-up exit 2
|
|
p = self._write(root, "wiki/concepts/x.md",
|
|
"title: x\nsource_type: concept\nstatus: verified\ntags: [a]", "본문만\n")
|
|
self.assertEqual(wsl.run_hook(self._event(p), root), 2)
|
|
|
|
def test_draft_missing_section_warns_only(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
root = Path(d)
|
|
# draft → 완성 선언 아님 → 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 = Path(d)
|
|
p = self._write(root, "docs/x.md", "title: x", "본문\n")
|
|
self.assertEqual(wsl.run_hook(self._event(p), root), 0)
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `python3 .claude/hooks/test_wiki_structure_lint.py -k HookTiering`
|
|
Expected: FAIL — `AttributeError: ... has no attribute 'run_hook'`.
|
|
|
|
- [ ] **Step 3: Refactor `--hook` into `run_hook`**
|
|
|
|
Extract the current `if args.hook:` body (lines ~486-531) into a function returning an exit code, adding the tier decision + suppressed line:
|
|
```python
|
|
def run_hook(event, root):
|
|
"""PostToolUse: 완성 선언 문서의 C1/C3/DANGLING(FIXUP) → exit 2 fix-up. 그 외 WARN(0)."""
|
|
inp = event.get("tool_input") or {}
|
|
fp = next((inp[k] for k in ("file_path", "path", "absolute_path", "TargetFile", "target_file")
|
|
if isinstance(inp.get(k), str)), None)
|
|
if not fp or not fp.endswith(".md"):
|
|
return 0
|
|
p = Path(fp)
|
|
if not p.is_absolute():
|
|
p = (root / fp)
|
|
try:
|
|
rel = p.resolve().relative_to(root).as_posix()
|
|
except Exception:
|
|
return 0
|
|
if not (rel.startswith("raw/") or rel.startswith("wiki/")) or not p.exists():
|
|
return 0
|
|
vp, vb = build_vault_index(root)
|
|
doc = parse_doc(p)
|
|
findings = check_c2(doc, vp, vb, root, {}, rel) # C2 항상
|
|
if is_completeness_checkable(doc):
|
|
by_st, by_file = build_template_index(root)
|
|
tmpl = resolve_template(doc["fm"], by_st, by_file)
|
|
if rel.startswith("raw/project-notes/"):
|
|
fm_findings = []
|
|
if tmpl is not None:
|
|
for k in tmpl["fm_keys"]:
|
|
if k not in doc["fm_keys"]:
|
|
fm_findings.append(("MISSING_FRONTMATTER", 0, f"frontmatter 키 누락: '{k}'"))
|
|
findings = fm_findings + check_project_proxies(doc) + findings
|
|
else:
|
|
findings = check_c1(doc, tmpl) + findings + check_c3(doc)
|
|
if not findings:
|
|
return 0
|
|
fixup = [f for f in findings if f[0] in wiki_rules.FIXUP_CODES]
|
|
block = bool(fixup) and is_completeness_checkable(doc)
|
|
sigil = "✗" if block else "⚠"
|
|
print(f"{sigil} wiki-structure-lint: {rel} — 구조/링크 이슈 {len(findings)}건"
|
|
+ (" → fix 필요" if block else ""), file=sys.stderr)
|
|
for code, ln, msg in findings[:10]:
|
|
loc = f":{ln}" if ln else ""
|
|
print(f" [{code}]{loc} {msg}", file=sys.stderr)
|
|
if len(findings) > 10:
|
|
print(f" … 외 {len(findings) - 10}건 (suppressed)", file=sys.stderr)
|
|
print(" 깨진 링크는 타깃 생성/수정(placeholder 는 `백틱 코드경로`). "
|
|
"섹션/선택조건은 완성 선언 문서에만 검사됨.", file=sys.stderr)
|
|
return 2 if block else 0
|
|
```
|
|
Replace the old `if args.hook:` block body with:
|
|
```python
|
|
if args.hook:
|
|
import json as _json
|
|
try:
|
|
event = _json.loads(sys.stdin.read() or "{}")
|
|
except Exception:
|
|
sys.exit(0)
|
|
sys.exit(run_hook(event, root))
|
|
```
|
|
Keep `build_vault_index`/`parse_doc`/`check_c1`/`check_c3`/`is_completeness_checkable`/`check_project_proxies`/`resolve_template`/`build_template_index` as-is (already module-level).
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `python3 .claude/hooks/test_wiki_structure_lint.py -k HookTiering`
|
|
Expected: PASS (3 tests).
|
|
|
|
- [ ] **Step 5: Checkpoint (no commit)**
|
|
|
|
Run: `python3 .claude/hooks/test_wiki_structure_lint.py` → all green.
|
|
|
|
---
|
|
|
|
## Task 5: Wire `--pre` into `settings.json`
|
|
|
|
**Files:**
|
|
- Modify: `.claude/settings.json` (add a second PreToolUse entry)
|
|
|
|
- [ ] **Step 1: Edit `settings.json`**
|
|
|
|
Change the `PreToolUse` array from one matcher to two (leave PostToolUse / Subagent blocks untouched):
|
|
```json
|
|
"PreToolUse": [
|
|
{
|
|
"matcher": "*",
|
|
"hooks": [
|
|
{ "type": "command",
|
|
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/wiki_claim_gate.py",
|
|
"timeout": 30 }
|
|
]
|
|
},
|
|
{
|
|
"matcher": "Write|Edit|MultiEdit",
|
|
"hooks": [
|
|
{ "type": "command",
|
|
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/wiki_structure_lint.py --pre",
|
|
"timeout": 30 }
|
|
]
|
|
}
|
|
],
|
|
```
|
|
|
|
- [ ] **Step 2: Validate JSON**
|
|
|
|
Run: `python3 -m json.tool .claude/settings.json > /dev/null && echo OK`
|
|
Expected: `OK`.
|
|
|
|
- [ ] **Step 3: Checkpoint (no commit)** — JSON valid.
|
|
|
|
---
|
|
|
|
## Task 6: Full regression + acceptance smoke (spec §6)
|
|
|
|
**Files:** none (verification only)
|
|
|
|
- [ ] **Step 1: Run the full hook test suite**
|
|
|
|
Run:
|
|
```bash
|
|
python3 .claude/hooks/test_wiki_rules.py && \
|
|
python3 .claude/hooks/test_wiki_claim_gate.py && \
|
|
python3 .claude/hooks/test_wiki_structure_lint.py
|
|
```
|
|
Expected: all suites OK, zero failures.
|
|
|
|
- [ ] **Step 2: `--all` regression (gate wiring must not change findings)**
|
|
|
|
Run: `python3 .claude/hooks/wiki_structure_lint.py --all`
|
|
Expected: a summary line `== 요약: N개 중 FAIL f / PASS p ==`. Compare `f` against a pre-change baseline (run the same on `git stash` of the hooks if unsure) — the FAIL set must be unchanged (the `--all` path is untouched by this spec).
|
|
|
|
- [ ] **Step 3: Acceptance criterion 1 — `--pre` blocks ghost, passes backtick (live stdin)**
|
|
|
|
Run:
|
|
```bash
|
|
echo '{"tool_name":"Write","tool_input":{"file_path":"raw/branch-notes/_smoke.md","content":"# t\nsee [[raw/nonexistent/ghost]]\n"}}' \
|
|
| python3 .claude/hooks/wiki_structure_lint.py --pre; echo "exit=$?"
|
|
echo '{"tool_name":"Write","tool_input":{"file_path":"raw/branch-notes/_smoke.md","content":"# t\nfuture: `[[raw/nonexistent/ghost]]`\n"}}' \
|
|
| python3 .claude/hooks/wiki_structure_lint.py --pre; echo "exit=$?"
|
|
```
|
|
Expected: first `exit=2` with a stderr `[BROKEN_LINK]` line; second `exit=0`.
|
|
|
|
- [ ] **Step 4: Acceptance criterion 3 — `--hook` fix-up only on completion**
|
|
|
|
Create a temp completed concept doc missing required sections, pipe an Edit event, expect exit 2; flip `status: verified` → `status: draft`, expect exit 0. (Use a path under `raw/` or `wiki/` in the real repo or a temp `--root`.)
|
|
```bash
|
|
python3 - <<'PY'
|
|
import json, subprocess, tempfile, os
|
|
from pathlib import Path
|
|
d = tempfile.mkdtemp()
|
|
root = Path(d); (root/"wiki"/"concepts").mkdir(parents=True)
|
|
# copy templates so resolve_template works
|
|
import shutil; shutil.copytree(".claude/hooks", root/".claude"/"hooks"); shutil.copytree("templates", root/"templates")
|
|
p = root/"wiki"/"concepts"/"x.md"
|
|
def run(status):
|
|
p.write_text(f"---\ntitle: x\nsource_type: concept\nstatus: {status}\ntags: [a]\n---\n본문만\n")
|
|
ev = json.dumps({"tool_name":"Edit","tool_input":{"file_path":str(p)}})
|
|
r = subprocess.run(["python3", str(root/".claude"/"hooks"/"wiki_structure_lint.py"),
|
|
"--hook", "--root", str(root)], input=ev, text=True, capture_output=True)
|
|
print(status, "exit", r.returncode)
|
|
run("verified"); run("draft")
|
|
PY
|
|
```
|
|
Expected: `verified exit 2`, `draft exit 0`.
|
|
|
|
- [ ] **Step 5: Acceptance criterion 8 — happy path passes both gates**
|
|
|
|
Take a real completed wiki doc with valid links; run it through `--pre` (Write event) and `--hook` (Edit event). Both must `exit=0`. Confirms the gate doesn't block legitimate writes.
|
|
|
|
- [ ] **Step 6: Final checkpoint (no commit)**
|
|
|
|
All §6 acceptance criteria (1, 3, 4, 7, 8) demonstrated green. Report results to the user. Do NOT commit (per user instruction) — leave changes staged in the working tree for the user to review.
|
|
|
|
---
|
|
|
|
## Self-Review (completed by plan author)
|
|
|
|
- **Spec coverage:** §3 DD1 hybrid gate → Tasks 3+4+5. §3 DD2 lean SSOT → Tasks 1+2. §4.1 wiki_rules → Task 1. §4.2 `--pre` → Task 3. §4.3 `--hook` tiering + suppressed → Task 4. §4.4 settings → Task 5. §6 acceptance 1-8 → Task 6. G6 (claim_gate tests) → Task 2. No spec requirement left unmapped.
|
|
- **Placeholder scan:** the only `...` is the explicit "copy these 9 functions verbatim from claim_gate lines X-Y" instruction with exact source line ranges — not a content gap. All test/impl steps carry runnable code.
|
|
- **Type/name consistency:** `run_pre(event, root)` / `run_hook(event, root)` signatures match between Tasks 3/4 impl and their tests; `CRITICAL_CODES`/`FIXUP_CODES`/`CLAIM_REQUIREMENTS` names match between Task 1 (def) and Tasks 2/3/4 (use); `check_markdown_write(rel, text)` signature unchanged (Task 2 tests call it as today).
|