feat: 공식 문서 근거자료, 브랜치 기능 문서 작성

This commit is contained in:
DongHyeonka
2026-07-29 18:05:17 +09:00
parent cfd84875bf
commit 58515ab0f3
251 changed files with 31470 additions and 109 deletions
Binary file not shown.
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""invest_ledger_check.py — 매매 원장(raw/invest-ledger/ledger.md) 결정론 검사 CLI (stdlib only).
실돈 경로의 LLM 산술·자기신고를 기계 검증으로 대체한다 (하네스 감사 P2-17, OUT-7/OUT-12).
모드:
--check /invest-decide 가 행 기록 *후* 호출. 검사:
1) Trade Log 행 스키마 (11열, 날짜/매수매도/수량/단가 형식)
2) 근거(링크) 셀 — 링크 존재 + 타깃 파일 실존
3) 최신 행 staleness — 근거가 invest-daily 면 >24h, invest-research 면 >90d
4) 주간 거래 수 집계 (--weekly-cap N 주면 초과 플래그)
exit: 0 clean / 1 flags.
--report /invest-review 가 호출. 원장 산술을 기계 재계산해 '손익 요약' 갱신 input 출력
(매수/매도 합·수수료 합·종목별 순수량·매수가중 평균단가·주간 거래 수).
평가금액·환차손익은 현재가 필요 → 본 스크립트 범위 밖(명시 출력). exit 0.
임계값 SSOT: 규칙 자체는 wiki/invest-strategy/strategy.md (①~⑤). 본 스크립트는
구조·산술·날짜만 검사하고, 임계 기본값(24h/90d)은 Spec F C4 의 기록된 기본값이며
플래그로 조정 가능(사용자 위험감내 재량).
"""
from __future__ import annotations
import argparse
import datetime as dt
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
LEDGER_DEFAULT = "raw/invest-ledger/ledger.md"
TRADE_COLS = ["날짜", "매수/매도", "종목", "수량", "단가", "수수료",
"체결환율", "금액(원)", "계좌", "근거(링크)", "규칙체크"]
DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})")
LINK_RE = re.compile(r"\[\[([^\]|#]+)")
NUM_RE = re.compile(r"-?[\d,]+(?:\.\d+)?")
def read_text(p: Path) -> str:
return p.read_text(encoding="utf-8", errors="replace")
def section_lines(text: str, header_prefix: str) -> list[str]:
i = text.find(header_prefix)
if i == -1:
return []
j = text.find("\n## ", i + len(header_prefix))
return text[i: j if j != -1 else len(text)].splitlines()
def table_rows(lines: list[str]) -> tuple[list[str], list[list[str]]]:
"""(헤더 셀, 데이터 행 목록). 구분선(|---|) 은 제외."""
header, rows = [], []
for line in lines:
s = line.strip()
if not s.startswith("|"):
continue
cells = [c.strip() for c in s.strip("|").split("|")]
if not header:
header = cells
continue
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells if c):
continue
rows.append(cells)
return header, rows
def parse_num(cell: str) -> float | None:
m = NUM_RE.search(cell.replace(",", ""))
return float(m.group(0)) if m else None
def doc_date(rel: str, root: Path) -> dt.date | None:
"""근거 문서의 기준 날짜 — 파일명 YYYY-MM-DD 우선, 없으면 frontmatter date/created."""
m = DATE_RE.search(Path(rel).name)
if m:
try:
return dt.date.fromisoformat(m.group(1))
except ValueError:
pass
p = root / (rel if rel.endswith(".md") else rel + ".md")
if not p.exists():
return None
head = read_text(p)[:600]
for key in ("date", "created", "last_reviewed"):
m = re.search(rf"^{key}:\s*(\d{{4}}-\d{{2}}-\d{{2}})", head, re.M)
if m:
try:
return dt.date.fromisoformat(m.group(1))
except ValueError:
continue
return None
def evidence_targets(cell: str) -> list[str]:
"""근거 셀에서 위키링크/경로 추출 ([[...]] 또는 bare raw/·wiki/ 경로)."""
out = [t.strip() for t in LINK_RE.findall(cell)]
out += re.findall(r"(?:raw|wiki)/[\w./-]+", cell)
seen, uniq = set(), []
for t in out:
t = t[:-3] if t.endswith(".md") else t
if t not in seen:
seen.add(t)
uniq.append(t)
return uniq
def iso_week(d: dt.date) -> tuple[int, int]:
c = d.isocalendar()
return (c[0], c[1])
def load_trades(text: str) -> tuple[list[str], list[list[str]], list[str]]:
flags = []
lines = section_lines(text, "## 거래 내역")
if not lines:
flags.append("LEDGER_STRUCTURE: `## 거래 내역` 섹션 부재")
return [], [], flags
header, rows = table_rows(lines)
if [h.strip() for h in header] != TRADE_COLS:
flags.append(f"TRADE_SCHEMA: Trade Log 헤더가 템플릿 11열과 불일치 — 기대 {TRADE_COLS}, 실제 {header}")
return header, rows, flags
def check_rows(rows: list[list[str]], root: Path, daily_max_h: int,
research_max_d: int, today: dt.date) -> list[str]:
flags = []
for i, cells in enumerate(rows):
label = f"{i + 1} ({cells[0] if cells else '?'})"
if len(cells) != len(TRADE_COLS):
flags.append(f"ROW_SCHEMA: {label} — 셀 {len(cells)}개 (기대 {len(TRADE_COLS)})")
continue
if not DATE_RE.fullmatch(cells[0]):
flags.append(f"ROW_DATE: {label} — 날짜가 YYYY-MM-DD 아님: '{cells[0]}'")
if cells[1] not in ("매수", "매도"):
flags.append(f"ROW_SIDE: {label} — 매수/매도 아님: '{cells[1]}'")
for col, idx in (("수량", 3), ("단가", 4)):
if parse_num(cells[idx]) is None:
flags.append(f"ROW_NUM: {label}{col} 숫자 아님: '{cells[idx]}'")
targets = evidence_targets(cells[9])
if not targets:
flags.append(f"NO_EVIDENCE: {label} — 근거(링크) 셀에 문서 링크 없음 (선근거 원칙 위반)")
else:
for t in targets:
if not (root / (t + ".md")).exists() and not (root / t).exists():
flags.append(f"EVIDENCE_MISSING: {label} — 근거 타깃 부재: {t}")
# 최신 행(시간 역순 최상단) staleness — 결정 시점 기준.
if rows and len(rows[0]) == len(TRADE_COLS):
for t in evidence_targets(rows[0][9]):
d = doc_date(t, root)
if d is None:
continue
age_d = (today - d).days
if t.startswith("raw/invest-daily/") and age_d * 24 > daily_max_h:
flags.append(f"STALE_EVIDENCE: 최신 행 근거 {t} — 일일노트 {age_d}d 경과 (기준 {daily_max_h}h). "
"조사시점 수치는 실시간 아님 → 재조사/현재가 재확인 먼저")
elif t.startswith("raw/invest-research/") and age_d > research_max_d:
flags.append(f"STALE_EVIDENCE: 최신 행 근거 {t} — 조사노트 {age_d}d 경과 (기준 {research_max_d}d)")
return flags
def weekly_count(rows: list[list[str]], today: dt.date) -> int:
n = 0
for cells in rows:
if cells and DATE_RE.fullmatch(cells[0] if cells else ""):
if iso_week(dt.date.fromisoformat(cells[0])) == iso_week(today):
n += 1
return n
def run_check(text: str, root: Path, daily_max_h: int, research_max_d: int,
weekly_cap: int | None, today: dt.date) -> int:
header, rows, flags = load_trades(text)
flags += check_rows(rows, root, daily_max_h, research_max_d, today)
wk = weekly_count(rows, today)
print(f"INFO 이번 주(ISO) 거래 수: {wk}" + (f" / 상한 {weekly_cap}" if weekly_cap else " (상한 미지정 — strategy ③ 주간상한과 대조하세요)"))
if weekly_cap is not None and wk > weekly_cap:
flags.append(f"WEEKLY_CAP: 이번 주 거래 {wk}회 > 상한 {weekly_cap} (strategy ③)")
for f in flags:
print(f"FLAG {f}")
print(f"\n== invest-ledger check: 거래 {len(rows)}행 / 플래그 {len(flags)}건 ==")
return 1 if flags else 0
def run_report(text: str, today: dt.date) -> int:
_, rows, flags = load_trades(text)
buy = sell = fees = 0.0
pos: dict[str, dict] = {}
for cells in rows:
if len(cells) != len(TRADE_COLS):
continue
qty, price = parse_num(cells[3]) or 0, parse_num(cells[4]) or 0
amt = parse_num(cells[7]) or 0
fees += parse_num(cells[5]) or 0
p = pos.setdefault(cells[2], {"qty": 0.0, "cost": 0.0})
if cells[1] == "매수":
buy += amt
p["qty"] += qty
p["cost"] += qty * price
elif cells[1] == "매도":
sell += amt
p["qty"] -= qty
print("== 손익 요약 갱신 input (기계 재계산 — LLM 산술 금지) ==")
print(f"- 거래 수: {len(rows)} (이번 주 {weekly_count(rows, today)})")
print(f"- 매수 합(원): {buy:,.0f} / 매도 합(원): {sell:,.0f} / 순투입: {buy - sell:,.0f}")
print(f"- 누적 수수료: {fees:,.0f}")
for name, p in pos.items():
avg = (p["cost"] / p["qty"]) if p["qty"] else 0
print(f"- {name}: 순수량 {p['qty']:g} / 매수가중 평균단가 {avg:,.2f}")
print("- 평가금액·환차손익·세후 추정: 현재가/환율 필요 — 본 스크립트 범위 밖 (출처 있는 시세로 별도 계산)")
for f in flags:
print(f"FLAG {f}")
return 0
def main() -> None:
ap = argparse.ArgumentParser(description="매매 원장 결정론 검사")
ap.add_argument("--check", action="store_true", help="행 스키마·근거 실존·staleness·주간 거래 수 검사")
ap.add_argument("--report", action="store_true", help="손익 요약 갱신 input 기계 재계산")
ap.add_argument("--ledger", default=LEDGER_DEFAULT)
ap.add_argument("--root", default=str(ROOT))
ap.add_argument("--daily-max-h", type=int, default=24, help="일일노트 근거 staleness 임계 (Spec F C4)")
ap.add_argument("--research-max-d", type=int, default=90, help="조사노트 근거 staleness 임계")
ap.add_argument("--weekly-cap", type=int, default=None, help="주간 거래 상한 (strategy ③의 N)")
ap.add_argument("--today", default=None, help="기준일 YYYY-MM-DD (테스트용; 기본 오늘)")
args = ap.parse_args()
root = Path(args.root).resolve()
today = dt.date.fromisoformat(args.today) if args.today else dt.date.today()
p = Path(args.ledger)
if not p.is_absolute():
p = root / args.ledger
if not p.exists():
print(f"FLAG LEDGER_MISSING: {args.ledger}")
sys.exit(1)
text = read_text(p)
if args.check:
sys.exit(run_check(text, root, args.daily_max_h, args.research_max_d,
args.weekly_cap, today))
if args.report:
sys.exit(run_report(text, today))
ap.error("--check 또는 --report 필요")
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""invest_ledger_check.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(
"ilc", str(Path(__file__).with_name("invest_ledger_check.py")))
ilc = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(ilc)
import datetime as dt
HEADER = ("| 날짜 | 매수/매도 | 종목 | 수량 | 단가 | 수수료 | 체결환율 | 금액(원) | 계좌 | 근거(링크) | 규칙체크 |\n"
"|---|---|---|---|---|---|---|---|---|---|---|\n")
def ledger(rows: str) -> str:
return ("# 원장\n## 현재 포지션 / Open Positions\n| a |\n|---|\n"
"## 거래 내역 / Trade Log\n" + HEADER + rows +
"\n## 규칙 위반 이력 / Rule-check Findings\n## 손익 요약 / P&L Summary\n")
TODAY = dt.date(2026, 6, 10)
def mk_evidence(root: Path, rel: str, date_str: str):
p = root / (rel + ".md")
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(f"---\ntitle: x\ndate: {date_str}\n---\n# t\n", encoding="utf-8")
class TestCheck(unittest.TestCase):
def _run(self, rows, root, **kw):
return ilc.run_check(ledger(rows), root, kw.get("daily_max_h", 24),
kw.get("research_max_d", 90), kw.get("weekly_cap"), TODAY)
def test_clean_row_passes(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
mk_evidence(root, "raw/invest-research/2026-06-08-x", "2026-06-08")
row = "| 2026-06-10 | 매수 | SCHD | 2 | 27.5 | 0.1 | 1380 | 76000 | ISA | [[raw/invest-research/2026-06-08-x]] | ✅ |"
self.assertEqual(self._run(row, root), 0)
def test_no_evidence_flags(self):
with tempfile.TemporaryDirectory() as d:
row = "| 2026-06-10 | 매수 | SCHD | 2 | 27.5 | 0.1 | 1380 | 76000 | ISA | 감으로 | ✅ |"
self.assertEqual(self._run(row, Path(d)), 1)
def test_missing_evidence_target_flags(self):
with tempfile.TemporaryDirectory() as d:
row = "| 2026-06-10 | 매수 | SCHD | 2 | 27.5 | 0.1 | 1380 | 76000 | ISA | [[raw/invest-research/ghost]] | ✅ |"
self.assertEqual(self._run(row, Path(d)), 1)
def test_stale_daily_evidence_flags(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
mk_evidence(root, "raw/invest-daily/2026-06-06", "2026-06-06") # 4d > 24h
row = "| 2026-06-10 | 매도 | SCHD | 1 | 27.5 | 0.1 | 1380 | 38000 | ISA | [[raw/invest-daily/2026-06-06]] | ✅ |"
self.assertEqual(self._run(row, root), 1)
def test_fresh_research_passes_but_old_flags(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
mk_evidence(root, "raw/invest-research/2026-01-01-x", "2026-01-01") # 160d > 90d
row = "| 2026-06-10 | 매수 | SCHD | 2 | 27.5 | 0.1 | 1380 | 76000 | ISA | [[raw/invest-research/2026-01-01-x]] | ✅ |"
self.assertEqual(self._run(row, root), 1)
def test_bad_schema_flags(self):
with tempfile.TemporaryDirectory() as d:
row = "| 2026-06-10 | 매수 | SCHD | 2 |"
self.assertEqual(self._run(row, Path(d)), 1)
def test_weekly_cap(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
mk_evidence(root, "raw/invest-research/2026-06-08-x", "2026-06-08")
ev = "[[raw/invest-research/2026-06-08-x]]"
rows = "\n".join(
f"| 2026-06-{day} | 매수 | SCHD | 1 | 27.5 | 0.1 | 1380 | 38000 | ISA | {ev} | ✅ |"
for day in ("08", "09", "10")) # 같은 ISO 주 3건
self.assertEqual(self._run(rows, root, weekly_cap=2), 1)
self.assertEqual(self._run(rows, root, weekly_cap=3), 0)
class TestReport(unittest.TestCase):
def test_aggregates(self):
import io
import contextlib
rows = ("| 2026-06-10 | 매수 | SCHD | 2 | 27.5 | 100 | 1380 | 76000 | ISA | [[raw/invest-research/x]] | ✅ |\n"
"| 2026-06-09 | 매수 | SCHD | 1 | 30 | 50 | 1380 | 41400 | ISA | [[raw/invest-research/x]] | ✅ |")
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = ilc.run_report(ledger(rows), TODAY)
out = buf.getvalue()
self.assertEqual(rc, 0)
self.assertIn("매수 합(원): 117,400", out)
self.assertIn("누적 수수료: 150", out)
self.assertIn("순수량 3", out) # SCHD 2+1
self.assertIn("28.33", out) # (2*27.5 + 1*30)/3 매수가중
if __name__ == "__main__":
unittest.main(verbosity=2)
+428
View File
@@ -0,0 +1,428 @@
#!/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, [])
def test_company_blog_same_rule(self):
f = wcg.check_markdown_write("raw/company-tech-blogs/x.md", "# t\n본문")
self.assertTrue(any("Claims Extracted" in m for m in 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))
def test_officially_supported_with_strength_passes(self):
body = "# t\n" + DEM + CTV + "\nofficially supported (official-vendor-doc).\n"
f = wcg.check_markdown_write("raw/branch-notes/feature-x.md", body)
self.assertEqual(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 TestSpecReport(unittest.TestCase):
def test_complete_verdict_without_traceability_blocks(self):
f = wcg.check_markdown_write("docs/superpowers/specs/2026-01-01-x-report.md",
"Verdict: COMPLETE\n근거 없음")
self.assertTrue(any("traceability" in m.lower() for m in f))
INVEST_SOURCES = (
"## 출처 / Sources\n"
"| # | 제목 | 출처 등급 | URL | 발행/조사일 |\n"
"|---|---|---|---|---|\n"
"| S1 | x | official | url | 2025 |\n"
)
INVEST_QUOTES = '## 핵심 인용 / Key quotes (verbatim)\n> [S1] "원문"\n'
INVEST_CLAIMS = (
"## Claims Extracted / 추출된 주장\n"
"| Claim ID | Claim | Evidence quote | Strength | 적용 조건 | 증명 못 하는 것 |\n"
"|---|---|---|---|---|---|\n"
'| C1 | x | [S1] "q" | official | a | b |\n'
)
class TestInvestResearch(unittest.TestCase):
def test_missing_claims_blocks(self):
f = wcg.check_markdown_write("raw/invest-research/2026-06-08-x.md",
"# t\n" + INVEST_SOURCES + INVEST_QUOTES)
self.assertTrue(any("Claims Extracted" in m for m in f))
def test_missing_sources_blocks(self):
f = wcg.check_markdown_write("raw/invest-research/2026-06-08-x.md",
"# t\n" + INVEST_QUOTES + INVEST_CLAIMS)
self.assertTrue(any("출처" in m or "Sources" in m for m in f))
def test_missing_verbatim_blocks(self):
f = wcg.check_markdown_write("raw/invest-research/2026-06-08-x.md",
"# t\n" + INVEST_SOURCES + INVEST_CLAIMS)
self.assertTrue(any("핵심 인용" in m for m in f))
def test_complete_passes(self):
f = wcg.check_markdown_write("raw/invest-research/2026-06-08-x.md",
"# t\n" + INVEST_SOURCES + INVEST_QUOTES + INVEST_CLAIMS)
self.assertEqual(f, [])
class TestUnrelatedPath(unittest.TestCase):
def test_non_gated_path_passes(self):
f = wcg.check_markdown_write("raw/lectures/x.md", "# anything\n")
self.assertEqual(f, [])
def test_non_md_passes(self):
f = wcg.check_markdown_write("raw/official-docs/x.txt", "anything")
self.assertEqual(f, [])
import tempfile as _tf
PROJ_OK = "# t\n## 실제 구현 내용 (`actually-implemented`)\n- x\n## Sources\n- `[[raw/x]]`\n"
class TestWikiProjectsGate(unittest.TestCase):
"""P1-7: wiki/projects 증거 구조 게이트 + named-hub 면제."""
def test_missing_sections_blocks(self):
f = wcg.check_markdown_write("wiki/projects/ca-tmpl/x.md", "# t\n본문만")
self.assertTrue(any("실제 구현 내용" in m for m in f))
self.assertTrue(any("Sources" in m for m in f))
def test_complete_passes(self):
f = wcg.check_markdown_write("wiki/projects/ca-tmpl/x.md", PROJ_OK)
self.assertEqual(f, [])
def test_named_hub_exempt(self):
with _tf.TemporaryDirectory() as d:
root = Path(d)
(root / "wiki" / "projects" / "myproj").mkdir(parents=True)
f = wcg.check_markdown_write("wiki/projects/myproj.md", "# hub\nMOC만", root=root)
self.assertEqual(f, [])
def _mk_canonical(root, rel, status):
p = root / (rel + ".md")
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(f"---\ntitle: x\nstatus: {status}\n---\n# t\n", encoding="utf-8")
class TestDerivedGate(unittest.TestCase):
"""P1-7/8: 파생 산출물 canonical 경유 + 원천 status 게이트."""
def test_no_sources_section_blocks(self):
f = wcg.check_markdown_write("wiki/blog/x.md", "# t\n본문")
self.assertTrue(any("Sources" in m for m in f))
def test_no_canonical_link_blocks(self):
f = wcg.check_markdown_write("wiki/interview/x.md", "# t\n## Sources\n- 외부 링크만\n")
self.assertTrue(any("canonical wikilink" in m for m in f))
def test_portfolio_requires_projects_link(self):
f = wcg.check_markdown_write(
"wiki/portfolio/x.md", "# t\n## Sources\n- [[wiki/concepts/a]]\n")
self.assertTrue(any("wiki/projects" in m for m in f))
def test_draft_canonical_source_blocks(self):
with _tf.TemporaryDirectory() as d:
root = Path(d)
_mk_canonical(root, "wiki/concepts/a", "draft")
f = wcg.check_markdown_write(
"wiki/blog/x.md", "# t\n## Sources\n- [[wiki/concepts/a]]\n", root=root)
self.assertTrue(any("status 게이트" in m for m in f))
def test_reviewed_canonical_source_passes(self):
with _tf.TemporaryDirectory() as d:
root = Path(d)
_mk_canonical(root, "wiki/concepts/a", "reviewed")
f = wcg.check_markdown_write(
"wiki/blog/x.md", "# t\n## Sources\n- [[wiki/concepts/a]]\n", root=root)
self.assertEqual(f, [])
def test_mixed_one_draft_blocks(self):
with _tf.TemporaryDirectory() as d:
root = Path(d)
_mk_canonical(root, "wiki/concepts/a", "verified")
_mk_canonical(root, "wiki/projects/b", "draft")
f = wcg.check_markdown_write(
"wiki/interview/x.md",
"# t\n## Sources\n- [[wiki/concepts/a]]\n- [[wiki/projects/b]]\n", root=root)
self.assertTrue(any("wiki/projects/b" in m for m in f))
def test_explainer_status_exempt(self):
with _tf.TemporaryDirectory() as d:
root = Path(d)
_mk_canonical(root, "wiki/concepts/a", "draft")
f = wcg.check_markdown_write(
"wiki/explainer/x.md", "# t\n## Sources\n- [[wiki/concepts/a]]\n", root=root)
self.assertEqual(f, []) # explainer 는 canonical 경유만, status 면제
INVEST_DAILY_BASE = (
"# t\n## 고정 체크리스트 (매일 동일)\n"
"| 자산군 | 핵심 지표 | 값 / 방향 | 출처 | 조사시점 |\n"
"|---|---|---|---|---|\n"
"{rows}\n"
"## 출처 / Sources (deep-research 조사 기록)\n- x\n"
)
class TestInvestDailyGate(unittest.TestCase):
"""P1-7: invest-daily 수치행 출처/조사시점 강제 (빈 행은 허용)."""
def test_value_without_source_blocks(self):
text = INVEST_DAILY_BASE.format(rows="| 금리 | 미 10Y | 4.4% ↑ | | |")
f = wcg.check_markdown_write("raw/invest-daily/2026-06-10.md", text)
self.assertTrue(any("출처 비어있음" in m for m in f))
def test_value_without_time_blocks(self):
text = INVEST_DAILY_BASE.format(rows="| 금리 | 미 10Y | 4.4% ↑ | [x](https://a) | |")
f = wcg.check_markdown_write("raw/invest-daily/2026-06-10.md", text)
self.assertTrue(any("조사시점 비어있음" in m for m in f))
def test_empty_row_allowed(self):
text = INVEST_DAILY_BASE.format(rows="| 금리 | 미 10Y | | | |")
f = wcg.check_markdown_write("raw/invest-daily/2026-06-10.md", text)
self.assertEqual(f, [])
def test_complete_row_passes(self):
text = INVEST_DAILY_BASE.format(
rows="| 금리 | 미 10Y | 4.4% ↑ | [FRED](https://a) | 2026-06-10 09:00 KST |")
f = wcg.check_markdown_write("raw/invest-daily/2026-06-10.md", text)
self.assertEqual(f, [])
def test_missing_sections_blocks(self):
f = wcg.check_markdown_write("raw/invest-daily/2026-06-10.md", "# t\n본문만")
self.assertTrue(any("고정 체크리스트" in m for m in f))
def _run_main_stop(message):
ev = {"hook_event_name": "Stop", "last_assistant_message": message}
return _sp.run(["python3", _GATE, "--main-stop"], input=_json.dumps(ev),
capture_output=True, text=True)
class TestMainStopGate(unittest.TestCase):
"""P1-9: Claude main agent Stop — fenced wiki-stats 만 검증 (COMPLETE trap 미적용)."""
def test_imbalanced_stats_blocks(self):
msg = "끝.\n```wiki-stats\nagent: branch-spec\nfound: 9\nprocessed: 7\ndropped: 0\n```"
self.assertEqual(_run_main_stop(msg).returncode, 2)
def test_balanced_stats_allows(self):
msg = "끝.\n```wiki-stats\nagent: branch-spec\nfound: 9\nprocessed: 7\ndropped: 2\ndropped_reason: 2 deferred\n```"
self.assertEqual(_run_main_stop(msg).returncode, 0)
def test_complete_without_traceability_allows(self):
# main agent 의 메타 대화 ("Verdict: COMPLETE" 인용) 는 차단하지 않는다.
self.assertEqual(_run_main_stop("훅이 Verdict: COMPLETE 를 검사한다").returncode, 0)
def test_malformed_verdict_block_allows(self):
# wiki-verdict 는 main-stop 검증 범위 밖 (judge 는 항상 subagent).
msg = "예시:\n```wiki-verdict\nagent: x\nverdict: ready\nblocking: 3\nshould_fix: 0\nadvisory: 0\n```"
self.assertEqual(_run_main_stop(msg).returncode, 0)
def test_no_marker_allows(self):
self.assertEqual(_run_main_stop("일반 응답").returncode, 0)
import json as _json
import subprocess as _sp
_GATE = str(Path(__file__).with_name("wiki_claim_gate.py"))
def _run_stop(message):
ev = {"hook_event_name": "SubagentStop", "last_assistant_message": message}
return _sp.run(["python3", _GATE], input=_json.dumps(ev), capture_output=True, text=True)
class TestSubagentStopVerdict(unittest.TestCase):
def test_malformed_verdict_blocks(self):
msg = "리뷰 끝.\n```wiki-verdict\nagent: branch-depth-auditor\nverdict: ready\nblocking: 3\nshould_fix: 0\nadvisory: 0\n```"
self.assertEqual(_run_stop(msg).returncode, 2)
def test_valid_verdict_allows(self):
msg = "리뷰 끝.\n```wiki-verdict\nagent: branch-depth-auditor\nverdict: ready\nblocking: 0\nshould_fix: 0\nadvisory: 0\n```"
self.assertEqual(_run_stop(msg).returncode, 0)
def test_no_marker_allows(self):
self.assertEqual(_run_stop("그냥 일반 subagent 출력, 마커 없음").returncode, 0)
def test_worker_status_done_allows(self):
# wiki-source-summarizer 성공 출력은 `**Status:** DONE` — branch-traceability 와 무관.
# bare DONE 으로 Decision Evidence Map / UNSUPPORTED_DECISION 을 요구하면 안 됨.
msg = "raw 자료 생성 완료.\n## Claims Extracted\n| Claim ID | ... |\n\n**Status:** DONE"
self.assertEqual(_run_stop(msg).returncode, 0)
def test_audit_verdict_complete_without_traceability_blocks(self):
# 감사/리뷰 완료 주장(Verdict: COMPLETE)은 여전히 traceability 누락 시 차단.
msg = "# Audit\n**Verdict:** COMPLETE\n근거 없음"
self.assertEqual(_run_stop(msg).returncode, 2)
def _run_stop_as(agent_type, message):
ev = {"hook_event_name": "SubagentStop", "agent_type": agent_type,
"last_assistant_message": message}
return _sp.run(["python3", _GATE], input=_json.dumps(ev), capture_output=True, text=True)
class TestSubagentStopAgentTypeScoping(unittest.TestCase):
"""P0-1: 위키 출력 계약은 WIKI_AGENT_TYPES 에만 적용 — 범용 subagent 오차단 방지.
(실측 재현 2026-06-10: Explore 가 'Verdict: COMPLETE' 한 마디로 차단 → 이탈 재시도)"""
def test_non_wiki_agent_complete_allows(self):
r = _run_stop_as("Explore", "PROBE OK — Verdict: COMPLETE")
self.assertEqual(r.returncode, 0)
def test_non_wiki_agent_malformed_verdict_block_allows(self):
# 보고서에 인용된 (모순된) 예시 블록도 범용 에이전트에선 차단 사유가 아님.
msg = "감사 예시:\n```wiki-verdict\nagent: branch-depth-auditor\nverdict: ready\nblocking: 3\nshould_fix: 0\nadvisory: 0\n```"
self.assertEqual(_run_stop_as("general-purpose", msg).returncode, 0)
def test_wiki_agent_complete_without_traceability_blocks(self):
msg = "# Audit\n**Verdict:** COMPLETE\n근거 없음"
self.assertEqual(_run_stop_as("wiki-research-lane", msg).returncode, 2)
def test_wiki_agent_malformed_verdict_blocks(self):
msg = "리뷰 끝.\n```wiki-verdict\nagent: branch-depth-auditor\nverdict: ready\nblocking: 3\nshould_fix: 0\nadvisory: 0\n```"
self.assertEqual(_run_stop_as("branch-depth-auditor", msg).returncode, 2)
def test_missing_agent_type_still_validates(self):
# 타 플랫폼(Gemini AfterAgent 등) — agent_type 부재 시 기존 보수적 검증 유지.
msg = "# Audit\n**Verdict:** COMPLETE\n근거 없음"
self.assertEqual(_run_stop(msg).returncode, 2)
class TestRetryRevalidation(unittest.TestCase):
"""P2-22: stop_hook_active(재시도)에도 위키 에이전트 스키마 위반은 계속 차단."""
def _run_retry(self, agent_type, message):
ev = {"hook_event_name": "SubagentStop", "agent_type": agent_type,
"stop_hook_active": True, "last_assistant_message": message}
return _sp.run(["python3", _GATE], input=_json.dumps(ev),
capture_output=True, text=True)
def test_retry_malformed_verdict_still_blocks(self):
msg = "리뷰.\n```wiki-verdict\nagent: branch-depth-auditor\nverdict: ready\nblocking: 3\nshould_fix: 0\nadvisory: 0\n```"
self.assertEqual(self._run_retry("branch-depth-auditor", msg).returncode, 2)
def test_retry_valid_passes(self):
msg = "리뷰.\n```wiki-verdict\nagent: branch-depth-auditor\nverdict: not-ready\nblocking: 3\nshould_fix: 0\nadvisory: 0\n```"
self.assertEqual(self._run_retry("branch-depth-auditor", msg).returncode, 0)
def test_retry_non_wiki_agent_still_scoped_out(self):
self.assertEqual(self._run_retry("Explore", "Verdict: COMPLETE").returncode, 0)
def test_main_stop_retry_keeps_one_retry(self):
# main_stop_gate 는 one-retry 유지 (대화 흐름 보호).
ev = {"hook_event_name": "Stop", "stop_hook_active": True,
"last_assistant_message": "x\n```wiki-stats\nagent: a\nfound: 2\nprocessed: 1\ndropped: 0\n```"}
r = _sp.run(["python3", _GATE, "--main-stop"], input=_json.dumps(ev),
capture_output=True, text=True)
self.assertEqual(r.returncode, 0)
def _run_gate(event, *extra_args):
return _sp.run(["python3", _GATE, *extra_args], input=_json.dumps(event),
capture_output=True, text=True)
class TestAntigravityMode(unittest.TestCase):
def _write_event(self, rel, content):
return {"hook_event_name": "PreToolUse", "tool_name": "Write",
"tool_input": {"file_path": rel, "content": content}}
def test_deny_emits_decision_json_exit0(self):
ev = self._write_event("raw/branch-notes/x.md", "# t\n본문만, DEM 없음")
r = _run_gate(ev, "--antigravity")
self.assertEqual(r.returncode, 0) # antigravity: exit 0, deny via JSON
out = _json.loads(r.stdout)
self.assertEqual(out["decision"], "deny")
self.assertIn("reason", out)
def test_allow_emits_decision_json(self):
body = ("# t\n## Decision Evidence Map\n"
"| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |\n"
"|---|---|---|---|---|\n| D1 | x | C1 | company-case-study | none |\n"
"## 검증해야 할 주장 / Claims To Verify\n- v\n")
ev = self._write_event("raw/branch-notes/x.md", body)
r = _run_gate(ev, "--antigravity")
self.assertEqual(r.returncode, 0)
self.assertEqual(_json.loads(r.stdout)["decision"], "allow")
def test_afteragent_prompt_response_verdict_deny(self):
# Gemini AfterAgent: 에이전트 출력은 prompt_response, 이벤트명 AfterAgent
ev = {"hook_event_name": "AfterAgent",
"prompt_response": "x\n```wiki-verdict\nagent: branch-depth-auditor\nverdict: ready\nblocking: 3\nshould_fix: 0\nadvisory: 0\n```"}
r = _run_gate(ev, "--antigravity")
self.assertEqual(r.returncode, 0)
self.assertEqual(_json.loads(r.stdout)["decision"], "deny")
def test_non_antigravity_still_exit2(self):
# 회귀: --antigravity 없으면 Claude exit-code 규약 그대로
ev = self._write_event("raw/branch-notes/x.md", "# t\n본문만, DEM 없음")
self.assertEqual(_run_gate(ev).returncode, 2)
class TestSubagentStopStats(unittest.TestCase):
def test_imbalanced_stats_blocks(self):
msg = "x\n```wiki-stats\nagent: coverage-auditor\nfound: 12\nprocessed: 10\ndropped: 0\n```"
self.assertEqual(_run_stop(msg).returncode, 2)
def test_balanced_stats_allows(self):
msg = "x\n```wiki-stats\nagent: coverage-auditor\nfound: 10\nprocessed: 10\ndropped: 0\n```"
self.assertEqual(_run_stop(msg).returncode, 0)
def test_dropped_without_reason_blocks(self):
msg = "x\n```wiki-stats\nagent: coverage-auditor\nfound: 12\nprocessed: 10\ndropped: 2\n```"
self.assertEqual(_run_stop(msg).returncode, 2)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,268 @@
#!/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)
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""wiki_quorum.py CLI 통합 테스트."""
import subprocess
import tempfile
import unittest
from pathlib import Path
CLI = str(Path(__file__).with_name("wiki_quorum.py"))
def _adv(*pairs):
lines = "\n".join(f"finding: {fid} action: {act}" for fid, act in pairs)
return f"```wiki-verdict\nagent: wiki-adversarial-reviewer\n{lines}\n```"
class TestQuorumCLI(unittest.TestCase):
def _files(self, d, *texts):
paths = []
for i, t in enumerate(texts):
p = Path(d) / f"v{i}.md"
p.write_text(t)
paths.append(str(p))
return paths
def test_kill_exits_1(self):
with tempfile.TemporaryDirectory() as d:
paths = self._files(d, _adv(("A", "REJECT")), _adv(("A", "REJECT")), _adv(("A", "KEEP")))
r = subprocess.run(["python3", CLI] + paths, capture_output=True, text=True)
self.assertEqual(r.returncode, 1)
self.assertIn("KILL", r.stdout)
def test_all_keep_exits_0(self):
with tempfile.TemporaryDirectory() as d:
paths = self._files(d, _adv(("A", "KEEP")), _adv(("A", "KEEP")), _adv(("A", "KEEP")))
r = subprocess.run(["python3", CLI] + paths, capture_output=True, text=True)
self.assertEqual(r.returncode, 0)
self.assertIn("KEEP", r.stdout)
if __name__ == "__main__":
unittest.main(verbosity=2)
+150
View File
@@ -0,0 +1,150 @@
#!/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)
STD_OK = "리포트...\n```wiki-verdict\nagent: branch-depth-auditor\nverdict: not-ready\nblocking: 2\nshould_fix: 1\nadvisory: 0\n```\n"
STD_CONTRADICT = "```wiki-verdict\nagent: branch-depth-auditor\nverdict: ready\nblocking: 2\nshould_fix: 0\nadvisory: 0\n```"
STD_BADVERDICT = "```wiki-verdict\nagent: x\nverdict: foo\nblocking: 0\nshould_fix: 0\nadvisory: 0\n```"
ADV_OK = "```wiki-verdict\nagent: wiki-adversarial-reviewer\nfinding: 4.1.1 action: KEEP\nfinding: 4.2.1 action: REJECT\n```"
ADV_BADACTION = "```wiki-verdict\nagent: wiki-adversarial-reviewer\nfinding: 4.1.1 action: NOPE\n```"
ADV_EMPTY = "```wiki-verdict\nagent: wiki-adversarial-reviewer\n```"
class TestVerdictBlock(unittest.TestCase):
def test_no_marker_returns_none(self):
parsed, errors = wr.validate_verdict_block("그냥 산문, 마커 없음")
self.assertIsNone(parsed)
self.assertEqual(errors, [])
def test_standard_valid(self):
parsed, errors = wr.validate_verdict_block(STD_OK)
self.assertEqual(errors, [])
self.assertEqual(parsed["agent"], "branch-depth-auditor")
self.assertEqual(parsed["kv"]["verdict"], "not-ready")
def test_standard_contradiction_flagged(self):
_, errors = wr.validate_verdict_block(STD_CONTRADICT)
self.assertTrue(any("blocking" in e for e in errors))
def test_standard_bad_verdict_flagged(self):
_, errors = wr.validate_verdict_block(STD_BADVERDICT)
self.assertTrue(any("verdict" in e for e in errors))
def test_adversarial_valid(self):
parsed, errors = wr.validate_verdict_block(ADV_OK)
self.assertEqual(errors, [])
self.assertEqual(len(parsed["findings"]), 2)
def test_adversarial_bad_action_flagged(self):
_, errors = wr.validate_verdict_block(ADV_BADACTION)
self.assertTrue(any("action" in e for e in errors))
def test_adversarial_empty_findings_flagged(self):
_, errors = wr.validate_verdict_block(ADV_EMPTY)
self.assertTrue(any("finding" in e for e in errors))
def _adv(*pairs):
lines = "\n".join(f"finding: {fid} action: {act}" for fid, act in pairs)
return f"```wiki-verdict\nagent: wiki-adversarial-reviewer\n{lines}\n```"
class TestTallyQuorum(unittest.TestCase):
def test_two_rejects_kill(self):
blocks = [_adv(("A", "REJECT")), _adv(("A", "REJECT")), _adv(("A", "KEEP"))]
self.assertEqual(wr.tally_quorum(blocks)["A"]["decision"], "KILL")
def test_unanimous_keep(self):
blocks = [_adv(("A", "KEEP")), _adv(("A", "KEEP")), _adv(("A", "KEEP"))]
self.assertEqual(wr.tally_quorum(blocks)["A"]["decision"], "KEEP")
def test_reject_plus_downgrade_is_downgrade(self):
blocks = [_adv(("A", "REJECT")), _adv(("A", "DOWNGRADE")), _adv(("A", "KEEP"))]
self.assertEqual(wr.tally_quorum(blocks)["A"]["decision"], "DOWNGRADE")
def test_abstain_not_pass(self):
# 한 블록만 A=KEEP, 나머지 둘은 A 누락(abstain) → 정족수 미달 → UNVERIFIED
blocks = [_adv(("A", "KEEP")), _adv(("B", "KEEP")), _adv(("C", "KEEP"))]
self.assertEqual(wr.tally_quorum(blocks)["A"]["decision"], "UNVERIFIED")
def _stats(found, processed, dropped, reason=None):
body = f"agent: coverage-auditor\nfound: {found}\nprocessed: {processed}\ndropped: {dropped}"
if reason is not None:
body += f"\ndropped_reason: {reason}"
return f"```wiki-stats\n{body}\n```"
class TestStatsBlock(unittest.TestCase):
def test_no_marker_returns_none(self):
parsed, errors = wr.validate_stats_block("산문, 마커 없음")
self.assertIsNone(parsed)
self.assertEqual(errors, [])
def test_balanced_ok(self):
parsed, errors = wr.validate_stats_block(_stats(12, 10, 2, "2 out-of-scope"))
self.assertEqual(errors, [])
self.assertEqual(parsed["agent"], "coverage-auditor")
def test_imbalance_flagged(self):
_, errors = wr.validate_stats_block(_stats(12, 10, 0))
self.assertTrue(any("불균형" in e for e in errors))
def test_dropped_without_reason_flagged(self):
_, errors = wr.validate_stats_block(_stats(12, 10, 2))
self.assertTrue(any("dropped_reason" in e for e in errors))
def test_non_integer_flagged(self):
block = "```wiki-stats\nagent: x\nfound: many\nprocessed: 1\ndropped: 0\n```"
_, errors = wr.validate_stats_block(block)
self.assertTrue(any("정수" in e for e in errors))
if __name__ == "__main__":
unittest.main(verbosity=2)
+470
View File
@@ -0,0 +1,470 @@
#!/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)
+370
View File
@@ -0,0 +1,370 @@
#!/usr/bin/env python3
"""Claude Code hook for LLM Wiki claim traceability.
This hook is intentionally narrow. It does not try to judge whether a claim is
true; it blocks writes that bypass the repository's required evidence structure:
- raw source notes must extract source claims.
- branch notes must map decisions to supporting claims.
- wiki concept notes must keep claim-backed knowledge separate from inference.
- report-like outputs must not claim completion while missing those artifacts.
공유 메커니즘(이벤트 파싱/projected_content)과 claim 요구 SSOT(CLAIM_REQUIREMENTS)는
wiki_rules.py 로 이관됨(감사 G5 dedup). 정책(block 적용)만 본 파일에 남는다.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
# 공유 메커니즘/데이터는 wiki_rules 로 이관. sibling import 가 스크립트 실행/spec 로드
# 양쪽에서 해석되도록 이 파일 디렉터리를 sys.path 에 추가.
sys.path.insert(0, str(Path(__file__).resolve().parent))
import wiki_rules
from wiki_rules import (
read_event, tool_name, tool_input, target_path,
projected_content, command_string, rel_to_root, has_table,
)
# Antigravity hook 은 exit-code 가 아니라 {decision} JSON(exit 0)을 기대
# (geminicli.com/docs/hooks/reference). 검사 로직은 동일, 출력 봉투만 분기.
# 플래그로 명시 활성 — Claude/Codex 는 기존 exit-code 규약 그대로.
ANTIGRAVITY = "--antigravity" in sys.argv
# Claude main agent 의 Stop 이벤트 전용 모드 (P1-9). Antigravity native `Stop` 은
# subagent 의미라 subagent_stop_gate 로 가지만, Claude 의 Stop 은 *메인 에이전트*
# 최종 메시지 — COMPLETE trap/wiki-verdict 를 적용하면 하네스 자체를 논의하는
# 메타 대화가 오차단된다. 따라서 main-stop 은 fenced wiki-stats funnel 만 검증
# (명령 최종 보고의 no-silent-truncation backstop).
MAIN_STOP = "--main-stop" in sys.argv
def emit_allow(extra: dict | None = None) -> None:
if ANTIGRAVITY:
# Antigravity/Gemini: strict {decision} JSON, exit 0, fail-open.
print(json.dumps({"decision": "allow"}))
sys.exit(0)
# Claude Code hooks: allow = exit 0 with no stdout. Structured JSON is only
# valid for specific hook events such as SubagentStart additionalContext.
if extra:
print(json.dumps(extra, ensure_ascii=False))
sys.exit(0)
def emit_block(reason: str) -> None:
if ANTIGRAVITY:
# Antigravity deny: {decision:deny, reason} on stdout, exit 0.
print(json.dumps({"decision": "deny", "reason": reason}, ensure_ascii=False))
sys.exit(0)
# Claude Code blocking convention: write reason to stderr and exit 2.
# Returning Antigravity/Gemini-style JSON from PreToolUse causes
# "Hook JSON output validation failed — Invalid input".
print(reason, file=sys.stderr)
sys.exit(2)
def _is_named_hub(rel: str, root: Path) -> bool:
"""named-hub folder-note (<cat>/<slug>.md + 형제 폴더 <slug>/, linking-rules §12)
는 MOC 구조 문서 — claim 구조 요구 면제 (structure lint classify 와 동일 판정)."""
parts = rel.split("/")
if len(parts) != 3 or parts[0] not in ("raw", "wiki") or not parts[2].endswith(".md"):
return False
return (root / parts[0] / parts[1] / parts[2][:-3]).is_dir()
def _section_body(text: str, header_prefix: str) -> str:
"""header_prefix 로 시작하는 ## 섹션의 본문 (다음 ## 까지). 없으면 ''."""
i = text.find(header_prefix)
if i == -1:
return ""
j = text.find("\n## ", i + len(header_prefix))
return text[i: j if j != -1 else len(text)]
def derived_source_status_failures(rel: str, text: str, root: Path) -> list[str]:
"""파생 산출물(P1-8) status 게이트: ## Sources 의 canonical 링크가 전부
status ∈ CANONICAL_OK_STATUS 여야 함 (CLAUDE.md §15). explainer 는 면제.
링크 부재는 content_regex 가, 깨진 타깃은 structure_lint --pre 가 잡으므로 여기선 skip."""
if not rel.startswith(wiki_rules.DERIVED_STATUS_PREFIXES):
return []
body = _section_body(text, "## Sources")
targets = []
for m in re.finditer(r"\[\[([^\]]+)\]\]", body):
t = m.group(1).replace("\\|", "|").split("|")[0].split("#")[0].strip()
if t.endswith(".md"):
t = t[:-3]
if t.startswith(("wiki/concepts/", "wiki/projects/")):
targets.append(t)
bad = []
for t in sorted(set(targets)):
try:
head = (root / (t + ".md")).read_text(encoding="utf-8")
except OSError:
continue # 타깃 부재 → BROKEN_LINK 는 structure lint 몫
status = ""
if head.startswith("---"):
end = head.find("\n---", 3)
m = re.search(r"^status:\s*(\S+)", head[: end if end != -1 else len(head)], re.M)
status = m.group(1).strip() if m else ""
if status not in wiki_rules.CANONICAL_OK_STATUS:
bad.append(f"`[[{t}]]` (status: {status or '없음'})")
if bad:
return [
"파생 산출물 원천 status 게이트 (CLAUDE.md §15): `## Sources` 의 canonical 문서는 "
"모두 status ∈ {reviewed, verified, published-ready} 여야 함. 미달: " + ", ".join(bad)
]
return []
def invest_daily_numeric_failures(text: str) -> list[str]:
"""invest-daily 고정 체크리스트: 값이 있는 행은 출처·조사시점 필수 (수치 환각 차단).
빈 값 행은 허용 (템플릿: '모르면 비우되 추측 금지')."""
body = _section_body(text, "## 고정 체크리스트")
if not body:
return []
lines = body.splitlines()
header_idx = val_i = src_i = time_i = None
for i, line in enumerate(lines):
if "|" in line and "출처" in line and ("" in line or "수치" in line):
cols = [c.strip() for c in line.strip().strip("|").split("|")]
for k, c in enumerate(cols):
if "" in c or "수치" in c:
val_i = k
elif "출처" in c:
src_i = k
elif "시점" in c:
time_i = k
header_idx = i
break
if header_idx is None or val_i is None or src_i is None:
return []
fails: list[str] = []
j = header_idx + 2 # 헤더 + 구분선 다음부터 데이터 행
while j < len(lines) and lines[j].lstrip().startswith("|"):
cells = [c.strip() for c in lines[j].strip().strip("|").split("|")]
val = cells[val_i] if val_i < len(cells) else ""
src = cells[src_i] if src_i < len(cells) else ""
tim = cells[time_i] if (time_i is not None and time_i < len(cells)) else ""
label = cells[0] if cells else "?"
if val and not re.fullmatch(r"<[^>]*>", val):
if not src:
fails.append(f"고정 체크리스트 '{label}' 행: 값이 있는데 출처 비어있음 (수치마다 출처+조사시점 필수)")
elif time_i is not None and not tim:
fails.append(f"고정 체크리스트 '{label}' 행: 값이 있는데 조사시점 비어있음")
j += 1
return fails
def check_markdown_write(rel: str, text: str, root: Path | None = None) -> list[str]:
"""raw/wiki 문서 쓰기의 claim 구조 게이트.
테이블/섹션 요구는 wiki_rules.CLAIM_REQUIREMENTS(SSOT 데이터)에서 도출하고,
의미 규칙(officially-supported 강도, 감사리포트 COMPLETE traceability,
파생 status 게이트, invest-daily 수치행 출처)은 정책이므로 본 함수에 남긴다.
"""
root = root or wiki_rules.ROOT
failures: list[str] = []
if not rel.endswith(".md") or not text:
return failures
if _is_named_hub(rel, root):
return failures # named-hub MOC — claim 구조 요구 면제
for req in wiki_rules.CLAIM_REQUIREMENTS:
if not rel.startswith(req["prefix"]): # str.startswith 는 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` 도 허용)."
)
for rx, msg in req.get("content_regex", []):
if not re.search(rx, text, re.MULTILINE):
failures.append(msg)
# 의미 규칙 (P1-8): 파생 산출물 원천 status 게이트.
failures += derived_source_status_failures(rel, text, root)
# 의미 규칙 (P1-7): invest-daily 수치행 출처/조사시점.
if rel.startswith("raw/invest-daily/"):
failures += invest_daily_numeric_failures(text)
# 의미 규칙 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 주장 시 claim 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
def command_writes_wiki_docs(command: str) -> bool:
if not command:
return False
doc_path = r"(raw/|wiki/|docs/superpowers/specs/|\.claude/)"
if not re.search(doc_path, command):
return False
# Shell redirection is write only when followed by a non-space target.
if re.search(r"(?:^|\s)(?:>|>>)\s*[^&\s]", command):
return True
write_signal = (
r"(\btee\b|\bcp\b|\bmv\b|\btouch\b|\btruncate\b|"
r"\bsed\s+-i\b|\bperl\s+-pi\b|\bcat\s+<<|"
r"write_text\s*\(|write_bytes\s*\(|\.write\s*\(|fs\.writeFile|"
r"open\s*\([^)]*,\s*['\"][wax]['\"]|Path\s*\([^)]*\)\.write_)"
)
return bool(re.search(write_signal, command))
def subagent_context(event: dict) -> None:
context = (
"LLM Wiki claim traceability is mandatory. For raw official-doc/company-tech-blog notes, "
"extract `## Claims Extracted` rows with Claim IDs and Usage Boundaries. For branch-notes, "
"write `## Decision Evidence Map` and map every Decision ID to Supporting Claims. "
"Do not call company tech-blog evidence an official best practice unless corroborated by "
"official-standard, official-vendor-doc, or official-reference claims. If evidence is absent, "
"label it UNSUPPORTED_DECISION instead of presenting it as fact."
)
# SubagentStart supports context injection via hookSpecificOutput.
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "SubagentStart",
"additionalContext": context,
}
}, ensure_ascii=False))
sys.exit(0)
def subagent_stop_gate(event: dict) -> None:
# agent_type 스코핑 (P0-1): 위키 출력 계약은 위키 에이전트(WIKI_AGENT_TYPES)에만
# 적용한다. 범용 subagent(Explore/general-purpose 등)는 'Verdict: COMPLETE' 한 마디
# 또는 보고서에 인용한 예시 블록만으로 차단되어 본래 임무에서 이탈한 재시도 출력을
# 내는 오차단이 실측 재현됨(감사 보고 §1). agent_type 부재(Gemini AfterAgent 등
# 타 플랫폼 이벤트)는 기존 보수적 검증을 유지한다.
agent_type = event.get("agent_type")
if isinstance(agent_type, str) and agent_type and agent_type not in wiki_rules.WIKI_AGENT_TYPES:
emit_allow()
# Claude: last_assistant_message. Gemini/Antigravity AfterAgent: prompt_response
# ("The final text generated by the agent").
message = event.get("last_assistant_message") or event.get("prompt_response") or ""
if not isinstance(message, str):
emit_allow()
# 감사/리뷰 *리포트* 완료 주장(Verdict: COMPLETE)에만 traceability 를 요구한다.
# bare `DONE`/`완료` 는 worker(예: wiki-source-summarizer `**Status:** DONE`)의 성공
# 표기이며 branch-traceability(Decision Evidence Map 등)와 무관 — 요구하면 정상 worker 가
# 잘못 차단된다(외부 리뷰 Finding 1). check_markdown_write(:88) 와 동일 패턴으로 정렬.
# P2-22 (사용자 승인 2026-06-10): stop_hook_active 무검증 통과(one-retry) 폐지.
# P0-1 agent_type 스코핑 + 출력 계약 정비로 오차단 원인이 제거됐으므로, 위키
# 에이전트의 스키마 위반은 재시도에도 계속 차단한다. 무한루프 없음 — Claude Code
# 가 연속 8회 차단 시 강제 통과시킴 (main_stop_gate 는 one-retry 유지 — 대화 보호).
if re.search(r"Verdict:\s*COMPLETE|\*\*Verdict:?\*\*\s*COMPLETE", message):
missing = []
for term in ("Claim ID", "Decision Evidence Map", "UNSUPPORTED_DECISION"):
if term not in message:
missing.append(term)
if missing:
emit_block(
"Subagent output claims completion but does not report claim-traceability checks: "
+ ", ".join(missing)
)
# judge 출력에 wiki-verdict 마커가 있으면 스키마 검증(없으면 judge 아님 → 통과).
parsed, verr = wiki_rules.validate_verdict_block(message)
if parsed is not None and verr:
emit_block("judge verdict 블록 스키마 오류:\n- " + "\n- ".join(verr))
# wiki-stats 마커가 있으면 funnel 검증(균형·dropped_reason). 없으면 통과.
sparsed, serr = wiki_rules.validate_stats_block(message)
if sparsed is not None and serr:
emit_block("wiki-stats 블록 오류:\n- " + "\n- ".join(serr))
emit_allow()
def main_stop_gate(event: dict) -> None:
"""Claude main agent Stop (P1-9): fenced wiki-stats 만 검증. 필드 부재 fail-open."""
message = event.get("last_assistant_message") or ""
if not isinstance(message, str) or event.get("stop_hook_active"):
emit_allow()
sparsed, serr = wiki_rules.validate_stats_block(message)
if sparsed is not None and serr:
emit_block("wiki-stats 블록 오류 (main agent 최종 보고):\n- " + "\n- ".join(serr))
emit_allow()
def main() -> None:
event = read_event()
hook_event = event.get("hook_event_name") or ""
if hook_event == "SubagentStart":
subagent_context(event)
# Claude main agent Stop (--main-stop 플래그로 명시) — stats-only 게이트.
if hook_event == "Stop" and MAIN_STOP:
main_stop_gate(event)
# Claude: SubagentStop. Antigravity native: Stop. Gemini CLI: AfterAgent
# (the variant that exposes prompt_response for content inspection).
if hook_event in ("SubagentStop", "Stop", "AfterAgent"):
subagent_stop_gate(event)
name = tool_name(event)
inp = tool_input(event)
if name == "Bash" or "bash" in name.lower() or "command" in name.lower():
cmd = command_string(inp)
if command_writes_wiki_docs(cmd):
emit_block(
"Direct shell/script writes to wiki docs are blocked. Use Claude Code Write/Edit/MultiEdit "
"so claim-traceability gates can inspect the target content."
)
emit_allow()
path = target_path(inp)
rel = rel_to_root(path)
# Only inspect write-like tools. Read/Skill/Glob/Grep/List must never be
# blocked just because the existing file is not migrated yet.
write_like_name = name in {"Write", "Edit", "MultiEdit", "NotebookEdit"} or any(
token in name.lower() for token in ["write", "edit", "multiedit", "notebookedit"]
)
write_like_input = any(k in inp for k in [
"content", "CodeContent", "CodeEdit", "new_string", "newString", "edits", "text"
])
if not (write_like_name or write_like_input):
emit_allow()
text = projected_content(path, inp)
if not rel:
emit_allow()
failures = check_markdown_write(rel, text)
if failures:
emit_block("LLM Wiki Claim Gate failed for `" + rel + "`:\n- " + "\n- ".join(failures))
emit_allow()
if __name__ == "__main__":
main()
+450
View File
@@ -0,0 +1,450 @@
#!/usr/bin/env python3
"""wiki_consistency_check.py — 문서 간 모순·동기화 결정론 검사기 (stdlib only).
consistency-contract (Single-Owner + Reference-Only) 의 결정론 레이어.
모순의 근원은 재진술(복제)이며, 다수의 모순은 'owner 문서의 정당한 진화 +
참조자의 무통보 낡음'으로 생긴다 — 따라서 (a) 참조의 기계 검증과
(b) 변경 시 역참조 전파 알림이 본 스크립트의 책임이다. 의미 대조
(요약 stale / 내용 모순)는 `wiki-consistency-auditor` (Layer 2) 의 몫.
검사 (전부 이진):
DANGLING_DECISION_REF [[feature-B]] D17 인데 B 의 결정 표에 D17 부재 (B 실존 시에만 — 부재는 BROKEN_LINK 몫)
BARE_DECISION_REF wikilink 없는 bare 슬러그 + D<n> 참조 (기계 추적 불가 — wikilink 화 필요)
BARE_OWNER_REF Coverage delegated 행의 owner 셀에 wikilink 없음
DUAL_OWNERSHIP 같은 관심사(정규화 exact)를 두 branch 가 covered-here 주장
DANGLING_SECTION_REF [[project-note]] §34 인데 해당 § 헤더 부재
모드:
--all vault 전수 검사 리포트 (exit 1 if findings)
--impact <slug|path> 해당 노트의 결정을 참조하는 문서 목록 (역참조 충격 분석)
--pre PreToolUse — projected 본문의 DANGLING_DECISION_REF 차단 (exit 2)
--post PostToolUse — DEM 행 편집 감지 시 참조자 목록 비차단 알림 (exit 2 = 모델에 정보 전달, 쓰기는 이미 완료)
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import wiki_rules
DEFAULT_ROOT = Path(__file__).resolve().parents[2]
BRANCH_DIR = "raw/branch-notes"
PROJECT_DIR = "raw/project-notes"
# 결정 ID 정의 = 표 행의 첫 셀이 D<n> 로 시작 (DEM 등).
# 실코퍼스 변형 수용: `**D1**`, `D9 (2026-05-31 보강)`, `D3/D4` (복수 정의 셀).
DEM_ROW_RE = re.compile(r"^\|\s*\*{0,2}D\d+\b")
DEM_CELL_IDS_RE = re.compile(r"\bD(\d+)\b")
# 참조 윈도: 위키링크 종료 후 같은 줄 100자 내의 D<n> 토큰 (대문자만 — d2.naver 류 오탐 방지)
D_TOKEN_RE = re.compile(r"\bD(\d+)\b")
WIKILINK_RE = re.compile(r"\[\[([^\]|#]+?)(?:\.md)?(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]")
BARE_SLUG_RE = re.compile(r"(?<![\[/\w-])((?:feature|fix|chore|experiment)-[a-z0-9-]{4,})")
SECTION_REF_RE = re.compile(r"§\s*(\d+)")
SECTION_DEF_RE = re.compile(r"^#{2,3}\s+(\d+)[.\s]")
COVERAGE_HEADER_RE = re.compile(r"^##+\s+.*coverage", re.I)
CONCERN_ID_PREFIX_RE = re.compile(r"^C\d+[a-z]?\s*:\s*")
REF_WINDOW = 100
def read(p: Path) -> str:
try:
return p.read_text(encoding="utf-8")
except Exception:
return ""
def iter_lines_outside_fences(text: str):
"""(lineno, line) — fenced code block 내부(예시/템플릿) 제외."""
in_fence = False
for i, line in enumerate(text.splitlines(), start=1):
s = line.lstrip()
if s.startswith("```") or s.startswith("~~~"):
info = s.lstrip("`~").strip()
if in_fence:
if not info:
in_fence = False
else:
in_fence = True
continue
if not in_fence:
yield i, line
def dem_ids_from_text(text: str) -> set[str]:
"""표 행 첫 셀에서 정의된 D<n> 집합."""
ids: set[str] = set()
for _, line in iter_lines_outside_fences(text):
s = line.strip()
if not DEM_ROW_RE.match(s):
continue
first_cell = s.strip("|").split("|", 1)[0]
for m in DEM_CELL_IDS_RE.finditer(first_cell):
ids.add(f"D{m.group(1)}")
return ids
def decision_registry(root: Path) -> dict[str, set[str]]:
"""slug → {D1, D3, ...} (표 행 첫 셀 정의 기준). branch + project 노트."""
reg: dict[str, set[str]] = {}
for d in (BRANCH_DIR, PROJECT_DIR):
base = root / d
if not base.exists():
continue
for p in sorted(base.glob("*.md")):
reg[p.stem] = dem_ids_from_text(read(p))
return reg
def section_registry(root: Path) -> dict[str, set[str]]:
"""project-note slug → {§번호}."""
reg: dict[str, set[str]] = {}
base = root / PROJECT_DIR
if not base.exists():
return reg
for p in sorted(base.glob("*.md")):
nums = set()
for _, line in iter_lines_outside_fences(read(p)):
m = SECTION_DEF_RE.match(line)
if m:
nums.add(m.group(1))
reg[p.stem] = nums
return reg
def extract_refs(text: str, self_slug: str):
"""[(lineno, target_slug, d_id|None, kind)] — kind ∈ {wikilink, bare, section}."""
refs = []
for lineno, line in iter_lines_outside_fences(text):
spans = [] # wikilink 가 점유한 (start, end) — bare 매칭에서 제외
for m in WIKILINK_RE.finditer(line):
target = m.group(1).strip()
slug = target.rsplit("/", 1)[-1]
spans.append((m.start(), m.end()))
window = line[m.end(): m.end() + REF_WINDOW]
nxt = WIKILINK_RE.search(window)
if nxt:
window = window[: nxt.start()]
if slug == self_slug:
continue
is_branch = target.startswith(f"{BRANCH_DIR}/") or slug.startswith(
("feature-", "fix-", "chore-", "experiment-"))
is_project = target.startswith(f"{PROJECT_DIR}/")
if is_branch:
for d in D_TOKEN_RE.finditer(window):
refs.append((lineno, slug, f"D{d.group(1)}", "wikilink"))
if is_project:
for s in SECTION_REF_RE.finditer(window):
refs.append((lineno, slug, f"§{s.group(1)}", "section"))
for m in BARE_SLUG_RE.finditer(line):
if any(a <= m.start() < b for a, b in spans):
continue
slug = m.group(1)
if slug == self_slug:
continue
window = line[m.end(): m.end() + REF_WINDOW]
for d in D_TOKEN_RE.finditer(window):
refs.append((lineno, slug, f"D{d.group(1)}", "bare"))
break # bare 는 행당 1건만 보고 (노이즈 억제)
return refs
def coverage_rows(text: str):
"""Coverage 류 섹션의 표 행 → [(lineno, concern, status_cell, owner_cell)]."""
rows = []
lines = text.splitlines()
in_cov = False
for i, line in enumerate(lines, start=1):
if line.startswith("##"):
in_cov = bool(COVERAGE_HEADER_RE.match(line))
continue
if not in_cov or not line.strip().startswith("|"):
continue
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if len(cells) < 3 or all(re.fullmatch(r":?-{3,}:?", c) for c in cells if c):
continue
if any(h in cells[0] for h in ("관심사", "concern", "Concern")):
continue # 헤더
rows.append((i, cells[0], cells[1] if len(cells) > 1 else "",
cells[2] if len(cells) > 2 else ""))
return rows
def normalize_concern(c: str) -> str:
c = CONCERN_ID_PREFIX_RE.sub("", c.strip())
return re.sub(r"[\s`*\-_/():]+", "", c).lower()
def check_file_refs(rel: str, text: str, dreg: dict, sreg: dict) -> list[tuple]:
"""(code, lineno, msg) — 참조 무결성 검사 (파일 단위).
귀속 모호성 규칙: 외부 링크 후방 윈도의 D<n> 이 *인용자 자신의* 결정 표에도
존재하면 자기-결정 언급일 수 있다 (실코퍼스: "X 에 의존 — 우회(D13)" 의 D13 이
인용자 자신의 D13). 모호 → 침묵 (의미 귀속은 Layer 2 wiki-consistency-auditor 몫).
"""
out = []
self_slug = Path(rel).stem
own_ids = dreg.get(self_slug, set()) | dem_ids_from_text(text)
for lineno, slug, ref_id, kind in extract_refs(text, self_slug):
if kind == "bare":
out.append(("BARE_DECISION_REF", lineno,
f"bare 참조 `{slug}` {ref_id} — 기계 추적을 위해 `[[{BRANCH_DIR}/{slug}]] {ref_id}` 로"))
continue
if kind == "section":
if slug in sreg and ref_id.lstrip("§") not in sreg[slug]:
out.append(("DANGLING_SECTION_REF", lineno,
f"[[{slug}]] {ref_id} — 해당 § 헤더 부재"))
continue
if slug in dreg and ref_id not in dreg[slug] and ref_id not in own_ids:
out.append(("DANGLING_DECISION_REF", lineno,
f"[[{slug}]] {ref_id} — `{slug}` 의 결정 표에 {ref_id} 없음"
f" (보유: {', '.join(sorted(dreg[slug])[:8]) or '없음'}…)"))
# slug not in dreg → 노트 부재: structure lint 의 BROKEN_LINK 몫 (중복 보고 안 함)
return out
def check_coverage(rel: str, text: str) -> list[tuple]:
out = []
for lineno, concern, status, owner in coverage_rows(text):
if "delegated" in status and owner and "" not in owner[:2]:
if "[[" not in owner and BARE_SLUG_RE.search(owner):
out.append(("BARE_OWNER_REF", lineno,
f"delegated 행 '{concern[:40]}' 의 owner 가 bare 이름 — wikilink 필요"))
return out
def check_dual_ownership(root: Path) -> list[tuple]:
"""covered-here 관심사 정규화 exact 중복 → (code, 0, msg)."""
owners: dict[str, list[str]] = {}
base = root / BRANCH_DIR
if not base.exists():
return []
for p in sorted(base.glob("*.md")):
for _, concern, status, _ in coverage_rows(read(p)):
if "covered-here" in status:
key = normalize_concern(concern)
if key:
owners.setdefault(key, []).append(p.stem)
out = []
for key, who in sorted(owners.items()):
uniq = sorted(set(who))
if len(uniq) > 1:
out.append(("DUAL_OWNERSHIP", 0,
f"관심사 '{key[:50]}'{len(uniq)}개 branch 가 covered-here 주장: {', '.join(uniq)}"))
return out
def referrers_of(root: Path, slug: str) -> list[tuple]:
"""slug 의 결정을 참조하는 문서 목록 [(rel, lineno, d_id)]."""
out = []
for d in (BRANCH_DIR, PROJECT_DIR, "wiki"):
base = root / d
if not base.exists():
continue
for p in sorted(base.rglob("*.md")):
rel = p.relative_to(root).as_posix()
if p.stem == slug:
continue
for lineno, tgt, ref_id, kind in extract_refs(read(p), p.stem):
if tgt == slug:
out.append((rel, lineno, ref_id))
return out
def _rel(p: Path | None, root: Path) -> str:
if p is None:
return ""
try:
return p.resolve().relative_to(root.resolve()).as_posix()
except Exception:
return ""
# ---------- 실행 모드 ----------
def run_all(root: Path) -> int:
dreg = decision_registry(root)
sreg = section_registry(root)
findings = check_dual_ownership(root)
by_code: dict[str, int] = {}
n_files = 0
for d in (BRANCH_DIR, PROJECT_DIR):
base = root / d
if not base.exists():
continue
for p in sorted(base.glob("*.md")):
n_files += 1
rel = p.relative_to(root).as_posix()
text = read(p)
fs = check_file_refs(rel, text, dreg, sreg) + check_coverage(rel, text)
for code, ln, msg in fs:
findings.append((code, ln, f"{rel}:{ln} {msg}" if ln else f"{rel} {msg}"))
for code, _, msg in findings:
by_code[code] = by_code.get(code, 0) + 1
print(f"[{code}] {msg}")
print(f"\n== consistency: 파일 {n_files} / findings {len(findings)} ==")
for code, n in sorted(by_code.items(), key=lambda x: -x[1]):
print(f" {n:4d} {code}")
return 1 if findings else 0
def find_dem_row(path: Path, d_id: str) -> tuple[int, str] | None:
"""owner 노트에서 d_id 를 정의하는 표 행 (lineno, line)."""
for lineno, line in iter_lines_outside_fences(read(path)):
s = line.strip()
if DEM_ROW_RE.match(s):
first_cell = s.strip("|").split("|", 1)[0]
if d_id in {f"D{m.group(1)}" for m in DEM_CELL_IDS_RE.finditer(first_cell)}:
return lineno, line
return None
def run_packets(root: Path, target: str | None) -> int:
"""T0 발췌 (P-tiering): 참조 엣지 양쪽의 ±맥락 줄을 결정론 추출 — 모델 토큰 0.
/sync 의 의미 판정(auditor/opus)이 corpus 대신 이 팩킷만 소비한다."""
files = []
for d in (BRANCH_DIR, PROJECT_DIR):
base = root / d
if base.exists():
files += sorted(base.glob("*.md"))
if target:
slug = Path(target).stem
files = [p for p in files if p.stem == slug] or files # citing 파일 스코프
files = [p for p in files if p.stem == slug]
n = 0
print("# Consistency Edge Packets (결정론 추출 — 의미 판정 입력)")
for p in files:
rel = p.relative_to(root).as_posix()
text = read(p)
lines = text.splitlines()
for lineno, slug, ref_id, kind in extract_refs(text, p.stem):
if kind != "wikilink":
continue
owner = root / BRANCH_DIR / f"{slug}.md"
if not owner.exists():
owner = root / PROJECT_DIR / f"{slug}.md"
if not owner.exists():
continue
n += 1
print(f"\n## Edge {n}: {rel}:{lineno} → [[{slug}]] {ref_id}")
print("### citing 측 (±2줄)")
for i in range(max(1, lineno - 2), min(len(lines), lineno + 2) + 1):
print(f" {rel}:{i}: {lines[i - 1][:300]}")
if ref_id.startswith("D"):
row = find_dem_row(owner, ref_id)
print("### owner 측 (D-row)")
if row:
print(f" {owner.relative_to(root).as_posix()}:{row[0]}: {row[1][:500]}")
else:
print(f" (D-row 미발견 — DANGLING 후보, --all 로 확인)")
print(f"\n== packets: {n} edges ==")
return 0
def run_impact(root: Path, target: str) -> int:
slug = Path(target).stem
refs = referrers_of(root, slug)
if not refs:
print(f"참조자 없음: {slug} 의 결정을 인용하는 문서가 없다")
return 0
print(f"== `{slug}` 의 결정을 참조하는 문서 {len(set(r[0] for r in refs))}개 / 참조 {len(refs)}건 ==")
for rel, ln, d in refs:
print(f" {rel}:{ln}{d}")
return 0
def run_pre(event: dict, root: Path) -> int:
"""PreToolUse: 새로 쓰는 본문의 DANGLING 참조 차단 (타깃 노트 실존 시에만)."""
inp = wiki_rules.tool_input(event)
p = wiki_rules.target_path(inp)
rel = _rel(p, root)
if not rel or not (rel.startswith(BRANCH_DIR) or rel.startswith(PROJECT_DIR)):
return 0
text = wiki_rules.projected_content(p, inp)
if "[[" not in text:
return 0
dreg = decision_registry(root)
sreg = section_registry(root)
# 자기 자신의 projected 결정 표를 registry 에 반영 (자기 D 정의 동시 추가 케이스)
dreg[Path(rel).stem] = dem_ids_from_text(text)
bad = [f for f in check_file_refs(rel, text, dreg, sreg)
if f[0] in ("DANGLING_DECISION_REF", "DANGLING_SECTION_REF")]
if bad:
print(f"✗ wiki-consistency (pre): {rel} — 깨진 결정 참조 {len(bad)}건 → 쓰기 차단",
file=sys.stderr)
for code, ln, msg in bad[:8]:
print(f" [{code}]:{ln} {msg}", file=sys.stderr)
print(" owner 노트의 실제 Decision ID 를 확인하거나, 결정이 아직 없으면 owner 노트에 먼저 기록하세요"
" (rules/consistency-contract.md).", file=sys.stderr)
return 2
return 0
DEM_EDIT_SIGNAL_RE = re.compile(r"\|\s*\*{0,2}D\d+\*{0,2}\s*\||Decision Evidence Map")
def run_post(event: dict, root: Path) -> int:
"""PostToolUse: 결정 표를 건드린 편집이면 역참조 충격 알림 (비차단 — 쓰기는 완료됨)."""
inp = wiki_rules.tool_input(event)
p = wiki_rules.target_path(inp)
rel = _rel(p, root)
if not rel or not rel.startswith(BRANCH_DIR):
return 0
touched = ""
for k in ("old_string", "new_string", "content"):
v = inp.get(k)
if isinstance(v, str):
touched += v + "\n"
if not DEM_EDIT_SIGNAL_RE.search(touched):
return 0
slug = Path(rel).stem
refs = referrers_of(root, slug)
if not refs:
return 0
docs = sorted(set(r[0] for r in refs))
print(f"⚠ 역참조 충격 알림 (비차단 — 쓰기 완료됨): `{slug}` 의 결정 표를 수정했고, "
f"이 노트의 결정을 참조하는 문서 {len(docs)}개가 있다:", file=sys.stderr)
for rel2, ln, d in refs[:10]:
print(f" {rel2}:{ln}{d}", file=sys.stderr)
if len(refs) > 10:
print(f" … 외 {len(refs) - 10}", file=sys.stderr)
print(" 변경이 D-row 의 의미를 바꿨다면 참조 요약이 낡았을 수 있다 — 같은 세션에서 갱신하거나 `/sync` 로 대조"
" (rules/consistency-contract.md §전파).", file=sys.stderr)
return 2 # PostToolUse exit 2 = 모델에 stderr 전달 (이미 완료된 쓰기를 막지 않음)
def main() -> None:
ap = argparse.ArgumentParser(description="문서 간 모순·동기화 결정론 검사기")
ap.add_argument("--all", action="store_true")
ap.add_argument("--impact", metavar="SLUG_OR_PATH")
ap.add_argument("--packets", nargs="?", const="", metavar="SLUG",
help="참조 엣지 양쪽 ±맥락 결정론 추출 (T0 발췌 — /sync 의미 판정 입력). SLUG 생략 시 전체")
ap.add_argument("--pre", action="store_true")
ap.add_argument("--post", action="store_true")
ap.add_argument("--root", default=str(DEFAULT_ROOT))
args = ap.parse_args()
root = Path(args.root).resolve()
if args.packets is not None:
sys.exit(run_packets(root, args.packets or None))
if args.pre or args.post:
import json
try:
raw = sys.stdin.read()
event = json.loads(raw) if raw.strip() else {}
except Exception:
sys.exit(0)
sys.exit(run_pre(event, root) if args.pre else run_post(event, root))
if args.impact:
sys.exit(run_impact(root, args.impact))
if args.all:
sys.exit(run_all(root))
ap.error("--all / --impact / --pre / --post 중 하나 필요")
if __name__ == "__main__":
main()
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""wiki_quorum.py — N개 adversarial verdict 블록의 결정론 quorum tally CLI.
사용:
python3 wiki_quorum.py vote1.md vote2.md vote3.md
cat votes.md | python3 wiki_quorum.py --stdin # '---' 구분 멀티블록
exit: 1 if any KILL/UNVERIFIED, else 0.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import wiki_rules
def main():
args = sys.argv[1:]
if "--stdin" in args:
blob = sys.stdin.read()
blocks = [b for b in blob.split("\n---\n") if "wiki-verdict" in b]
else:
blocks = []
for a in args:
try:
blocks.append(Path(a).read_text(encoding="utf-8"))
except Exception as e:
print(f"파일 읽기 실패: {a}{e}", file=sys.stderr)
if not blocks:
print("verdict 블록 입력 없음", file=sys.stderr)
sys.exit(2)
per = wiki_rules.tally_quorum(blocks)
print(f"== Quorum tally: N={len(blocks)} votes, {len(per)} findings ==")
print("| finding | keep | down | reject | abstain | decision |")
print("|---|---|---|---|---|---|")
bad = 0
for fid in sorted(per):
r = per[fid]
if r["decision"] in ("KILL", "UNVERIFIED"):
bad += 1
print(f"| {fid} | {r['keep']} | {r['downgrade']} | {r['reject']} | {r['abstain']} | {r['decision']} |")
print(f"\nKILL/UNVERIFIED: {bad} / {len(per)}")
sys.exit(1 if bad else 0)
if __name__ == "__main__":
main()
+396
View File
@@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""wiki_rules.py — claim_gate / structure_lint 공유 기계장치 + SSOT 데이터 (stdlib only).
여기엔 *정책*이 아니라 *공유 메커니즘*과 *참조 데이터*만 둔다:
- 이벤트/IO 헬퍼 (wiki_claim_gate.py 에서 verbatim 이관, 두 훅이 공유)
- 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 re
import shlex
import sys
from pathlib import Path
# 두 훅과 동일하게 이 스크립트 위치 기준으로 repo 루트 해석 (.claude/hooks/<this> -> root).
ROOT = Path(__file__).resolve().parents[2]
# ---------- 이벤트/IO 헬퍼 (wiki_claim_gate.py 에서 verbatim 이관) ----------
def read_event() -> dict:
try:
raw = sys.stdin.read()
return json.loads(raw) if raw.strip() else {}
except Exception:
return {}
def tool_name(event: dict) -> str:
if isinstance(event.get("tool_name"), str):
return event["tool_name"]
tc = event.get("tool_call") or event.get("toolCall") or {}
if isinstance(tc, dict):
return tc.get("name") or tc.get("tool_name") or ""
return ""
def tool_input(event: dict) -> dict:
if isinstance(event.get("tool_input"), dict):
return event["tool_input"]
tc = event.get("tool_call") or event.get("toolCall") or {}
if not isinstance(tc, dict):
return {}
for key in ("input", "arguments", "args"):
value = tc.get(key)
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
return {}
def target_path(inp: dict) -> Path | None:
for key in ("file_path", "path", "absolute_path", "TargetFile", "target_file"):
value = inp.get(key)
if isinstance(value, str) and value:
p = Path(value)
return p if p.is_absolute() else ROOT / p
return None
def write_content(inp: dict) -> str:
for key in ("content", "CodeContent", "CodeEdit", "text"):
value = inp.get(key)
if isinstance(value, str):
return value
value = inp.get("new_string") or inp.get("newString")
return value if isinstance(value, str) else ""
def projected_content(path: Path | None, inp: dict) -> str:
"""Return the file content after a Write/Edit/MultiEdit-style operation.
Claude Code Edit inputs often contain only old_string/new_string. If we
inspect the snippet alone, legitimate migrations get blocked because the
snippet does not include every required section. This function checks the
projected final file instead whenever enough information is available.
"""
full = write_content(inp)
if path is None:
return full
# Write-style calls usually provide full content.
if isinstance(inp.get("content"), str) or isinstance(inp.get("CodeContent"), str):
return full
try:
current = path.read_text(encoding="utf-8") if path.exists() else ""
except Exception:
current = ""
old = inp.get("old_string") or inp.get("oldString")
new = inp.get("new_string") or inp.get("newString")
if isinstance(old, str) and isinstance(new, str) and old in current:
return current.replace(old, new, 1)
edits = inp.get("edits")
if isinstance(edits, list):
projected = current
for edit in edits:
if not isinstance(edit, dict):
continue
old = edit.get("old_string") or edit.get("oldString")
new = edit.get("new_string") or edit.get("newString")
if isinstance(old, str) and isinstance(new, str) and old in projected:
projected = projected.replace(old, new, 1)
return projected
return full or current
def command_string(inp: dict) -> str:
for key in ("command", "cmd", "CommandLine", "Command", "args"):
value = inp.get(key)
if isinstance(value, str):
return value
if isinstance(value, list):
return " ".join(shlex.quote(str(x)) for x in value)
return ""
def rel_to_root(path: Path | None) -> str:
if path is None:
return ""
try:
return str(path.resolve().relative_to(ROOT.resolve()))
except Exception:
return ""
def has_table(text: str, section: str, columns: list[str]) -> bool:
if section not in text:
return False
start = text.find(section)
next_section = text.find("\n## ", start + len(section))
body = text[start: next_section if next_section != -1 else len(text)]
return all(col in body for col in columns)
# ---------- claim 요구 SSOT (감사 G5 dedup 대상) ----------
# claim_gate 의 table/section 요구를 *데이터*로 표현. 정책(block) 은 claim_gate 에 남는다.
# prefix 는 tuple — str.startswith(tuple) 로 매칭.
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"])]},
# 투자 조사 노트 — 실제 돈 결정의 증거층. 환각된 금융 claim 이 근거표/출처/verbatim
# 없이 들어오는 걸 쓰기 시점에 차단 (Spec F V1). invest 명령은 Claude 전용이나
# hook 은 경로 기반이라 3-플랫폼 모두 적용.
{"prefix": ("raw/invest-research/",),
"tables": [("## Claims Extracted",
["Claim ID", "Claim", "Evidence quote", "Strength", "적용 조건", "증명 못 하는 것"])],
"sections": ["## 출처 / Sources", "## 핵심 인용"]},
# ---- 2026-06-10 하네스 감사 P1-7 확장 (RC4 경로 공백 해소) ----
# wiki/projects 실무 적용 문서 — canonical 의 절반이자 파생(interview/portfolio)이
# 인용하는 층. 증거 등급 구조(실제 구현 내용 + Sources) 쓰기 시점 강제.
# named-hub(wiki/projects/<slug>.md + 형제 폴더 <slug>/)는 claim_gate 정책에서 면제.
{"prefix": ("wiki/projects/",),
"sections": ["## 실제 구현 내용", "## Sources"]},
# 파생 산출물 — canonical 경유 강제 (CLAUDE.md §11·§15 최대 금지의 결정론 backstop).
# content_regex: [pattern, message] 쌍 — 본문 전체에 1회 이상 매칭 필요.
{"prefix": ("wiki/interview/", "wiki/blog/", "wiki/explainer/"),
"sections": ["## Sources"],
"content_regex": [
[r"\[\[wiki/(concepts|projects)/",
"파생 산출물은 `## Sources` 에 canonical wikilink(`[[wiki/concepts/...]]` 또는 "
"`[[wiki/projects/...]]`) ≥1 필수 (CLAUDE.md §15 — canonical 경유 강제)."]]},
{"prefix": ("wiki/portfolio/",),
"sections": ["## Sources"],
"content_regex": [
[r"\[\[wiki/projects/",
"portfolio 는 `[[wiki/projects/...]]` 링크 필수 (CLAUDE.md §15 — projects 중심 파생)."]]},
# invest-daily — 실돈 경로의 최대 환각 위험면. 섹션 강제 + 수치행 출처/조사시점
# 정책은 claim_gate 의 invest_daily_numeric_failures 가 담당.
{"prefix": ("raw/invest-daily/",),
"sections": ["## 고정 체크리스트", "## 출처 / Sources"]},
# invest-ledger — 실돈 사실 기록. 4섹션 구조 강제 (행 스키마·근거 실존·산술은
# invest_ledger_check.py CLI 가 담당 — P2-17).
{"prefix": ("raw/invest-ledger/",),
"sections": ["## 현재 포지션", "## 거래 내역", "## 규칙 위반 이력", "## 손익 요약"]},
]
# 파생 산출물 status 게이트 (claim_gate 소비): Sources 의 canonical 링크가 전부
# 이 status 여야 파생 가능 (CLAUDE.md §15). explainer 는 status 면제(개인 이해용).
CANONICAL_OK_STATUS = frozenset({"reviewed", "verified", "published-ready"})
DERIVED_STATUS_PREFIXES = ("wiki/interview/", "wiki/blog/", "wiki/portfolio/")
# ---------- 심각도 티어 (structure_lint 소비) ----------
# 항상-틀린(ghost) 검사 → PreToolUse 차단.
CRITICAL_CODES = frozenset({"BROKEN_LINK", "BROKEN_MD_LINK"})
# 완성 선언 문서에서만 의미 있는 완성도 검사 → PostToolUse exit-2 fix-up.
FIXUP_CODES = frozenset({
"MISSING_SECTION", "MISSING_FRONTMATTER", "EMPTY_SELECTION_CRITERION",
"DANGLING_ANCHOR", "PROJECT_NO_DIAGRAM", "PROJECT_NO_BRANCH_TABLE",
"UNMAPPED_SOURCE_TYPE",
})
# ---------- 위키 에이전트 레지스트리 (SubagentStop 스코핑 SSOT) ----------
# .claude/agents/*.md 의 name: 과 1:1. SubagentStop 출력 계약(COMPLETE trap /
# wiki-verdict / wiki-stats)은 이 에이전트들의 출력에만 적용한다 — 범용 subagent
# (Explore/Plan/general-purpose 등)가 'Verdict: COMPLETE' 류 문구나 인용된 예시
# 블록 때문에 오차단되는 것을 방지 (하네스 감사 P0-1, 실측 재현 2026-06-10:
# docs/superpowers/specs/2026-06-10-claude-harness-audit-report.md §1).
WIKI_AGENT_TYPES = frozenset({
"branch-depth-auditor",
"coverage-auditor",
"extraction-broker",
"project-readiness-auditor",
"wiki-adversarial-reviewer",
"wiki-consistency-auditor",
"wiki-decision-researcher",
"wiki-diagram-reviewer",
"wiki-doc-author",
"wiki-link-verifier",
"wiki-research-lane",
"wiki-source-summarizer",
})
# ---------- judge verdict 스키마 + quorum tally (Spec B) ----------
# 정책 아님 — *기계장치*. judge 출력의 기계 파싱 가능한 wiki-verdict 블록을 검증/집계.
VERDICT_FENCE_RE = re.compile(r"```wiki-verdict\s*\n(.*?)\n```", re.S)
VALID_VERDICT = {"ready", "not-ready", "blocked"}
VALID_ACTION = {"KEEP", "DOWNGRADE", "REJECT"}
REFUTATIONS_REQUIRED = 2 # ≥2 REJECT → kill (deep-research 기본값)
def parse_verdict_block(text):
"""본문에서 wiki-verdict fenced 블록을 찾아 dict 로 파싱. 없으면 None."""
m = VERDICT_FENCE_RE.search(text or "")
if not m:
return None
out = {"agent": None, "kv": {}, "findings": []}
for line in m.group(1).splitlines():
line = line.strip()
if not line:
continue
fm = re.match(r"finding:\s*(\S+)\s+action:\s*(\S+)", line)
if fm:
out["findings"].append((fm.group(1), fm.group(2)))
continue
kv = re.match(r"([a-z_]+):\s*(.+)$", line)
if kv:
k, v = kv.group(1), kv.group(2).strip()
if k == "agent":
out["agent"] = v
else:
out["kv"][k] = v
return out
def validate_verdict_block(text):
"""(parsed, errors). parsed None → 마커 없음(judge 아님, caller 통과).
errors 비어있지 않으면 스키마 위반 → SubagentStop 차단."""
parsed = parse_verdict_block(text)
if parsed is None:
return None, []
errors = []
if not parsed["agent"]:
errors.append("wiki-verdict 블록에 `agent:` 누락")
if parsed["agent"] == "wiki-adversarial-reviewer":
if not parsed["findings"]:
errors.append("adversarial verdict 블록에 `finding: <id> action: <act>` 행 ≥1 필요")
for fid, act in parsed["findings"]:
if act not in VALID_ACTION:
errors.append(f"finding {fid}: action '{act}' 비허용(KEEP|DOWNGRADE|REJECT)")
else:
v = parsed["kv"].get("verdict")
if v not in VALID_VERDICT:
errors.append(f"verdict '{v}' 비허용(ready|not-ready|blocked)")
blocking = None
try:
blocking = int(parsed["kv"].get("blocking", ""))
int(parsed["kv"].get("should_fix", ""))
int(parsed["kv"].get("advisory", ""))
except ValueError:
errors.append("blocking/should_fix/advisory 는 정수여야 함")
if blocking is not None and v == "ready" and blocking != 0:
errors.append("verdict=ready 인데 blocking≠0 (모순)")
if blocking is not None and v == "not-ready" and blocking < 1:
errors.append("verdict=not-ready 인데 blocking<1 (모순)")
return parsed, errors
def tally_quorum(block_texts, refutations_required=REFUTATIONS_REQUIRED):
"""N개 adversarial verdict 블록 → per-finding 결정론 판정.
refute = DOWNGRADE 또는 REJECT (원 severity 반박).
default-refute: 어떤 pass 가 finding 을 누락/malformed → abstain(non-KEEP).
결정: reject≥req → KILL · (reject+downgrade)≥req → DOWNGRADE ·
keep≥req → KEEP · 그 외(정족수 미달) → UNVERIFIED(통과 금지).
"""
parsed_all = [parse_verdict_block(t) for t in block_texts]
all_fids = set()
for p in parsed_all:
if p:
for fid, _ in p["findings"]:
all_fids.add(fid)
per = {}
for fid in all_fids:
keep = downgrade = reject = abstain = 0
for p in parsed_all:
act = None
if p:
for f, a in p["findings"]:
if f == fid:
act = a
break
if act == "KEEP":
keep += 1
elif act == "DOWNGRADE":
downgrade += 1
elif act == "REJECT":
reject += 1
else:
abstain += 1
if reject >= refutations_required:
decision = "KILL"
elif (reject + downgrade) >= refutations_required:
decision = "DOWNGRADE"
elif keep >= refutations_required:
decision = "KEEP"
else:
decision = "UNVERIFIED"
per[fid] = {"keep": keep, "downgrade": downgrade, "reject": reject,
"abstain": abstain, "n": len(block_texts), "decision": decision}
return per
# ---------- funnel stats 블록 (Spec C, no-silent-truncation) ----------
STATS_FENCE_RE = re.compile(r"```wiki-stats\s*\n(.*?)\n```", re.S)
def parse_stats_block(text):
"""본문에서 wiki-stats fenced 블록을 찾아 dict 로 파싱. 없으면 None."""
m = STATS_FENCE_RE.search(text or "")
if not m:
return None
out = {"agent": None, "kv": {}}
for line in m.group(1).splitlines():
line = line.strip()
if not line:
continue
kv = re.match(r"([a-z_]+):\s*(.+)$", line)
if kv:
k, v = kv.group(1), kv.group(2).strip()
if k == "agent":
out["agent"] = v
else:
out["kv"][k] = v
return out
def validate_stats_block(text):
"""(parsed, errors). parsed None → 마커 없음(통과). errors → SubagentStop 차단.
funnel 균형(found=processed+dropped) + dropped>0 시 dropped_reason 필수 (no-silent-truncation)."""
parsed = parse_stats_block(text)
if parsed is None:
return None, []
errors = []
if not parsed["agent"]:
errors.append("wiki-stats 블록에 `agent:` 누락")
nums = {}
for k in ("found", "processed", "dropped"):
try:
nums[k] = int(parsed["kv"].get(k, ""))
except ValueError:
errors.append(f"wiki-stats `{k}` 는 정수여야 함 (funnel 필수 필드)")
if len(nums) == 3:
if nums["found"] != nums["processed"] + nums["dropped"]:
errors.append(
f"funnel 불균형: found({nums['found']}) ≠ processed({nums['processed']}) "
f"+ dropped({nums['dropped']}) — 조용한 누락 의심"
)
if nums["dropped"] > 0 and not parsed["kv"].get("dropped_reason", "").strip():
errors.append("dropped>0 인데 `dropped_reason` 누락 (no-silent-truncation 위반)")
return parsed, errors
+786
View File
@@ -0,0 +1,786 @@
#!/usr/bin/env python3
"""wiki_structure_lint.py — 결정론적 위키 문서 구조 린터 (stdlib only).
검사 3군 (전부 이진 PASS/FAIL):
C1 템플릿 적합성 — source_type 템플릿의 필수 섹션 + frontmatter 키 보유
C2 옵시디언 링크 문법 — 살아있는 위키링크만 검사 (그래프 ghost 노드 방지):
[[t]] / [[t.md]] / [[t|alias]] → t 실존 검사 (md=확장자strip, 첨부=확장자포함)
![[t]] → embed, 동일 타깃 검사 → 부재 시 BROKEN_LINK
[[t#heading]] → t의 실제 heading 매칭 (DANGLING_ANCHOR)
[[t#^blockid]] → t의 ^blockid 행말 토큰 (DANGLING_ANCHOR)
`[[t]]` (인라인 code span 내부) → 의도적 비활성 표기(템플릿/rules 예시/로그) → 무시(위반 아님)
``` fenced ``` 내부 [[t]] → 예시로 간주, 스킵
판정은 위치기반 backtick 연속 페어링 — 표 셀 경계 오탐 없음.
C3 depth 사전체크 — (branch-note) Decision Evidence Map '선택 조건'
매핑 SSOT 는 templates/ 안에서 자동 도출:
1) 템플릿 frontmatter source_type (concrete)
2) raw-source-template 의 '## source_type 허용값' 섹션 파싱
3) daily-task 는 문서 track(develop/infra) 으로 분기
4) 소형 fallback 상수 (템플릿이 자기선언 안 하는 것)
5) 그 외 → UNMAPPED_SOURCE_TYPE (불통)
사용:
python3 wiki_structure_lint.py --file raw/branch-notes/x.md
python3 wiki_structure_lint.py --all
python3 wiki_structure_lint.py --all --root /path/to/wiki
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
# sibling wiki_rules (공유 메커니즘 + 심각도 티어). 스크립트 실행/spec 로드 양쪽 호환.
sys.path.insert(0, str(Path(__file__).resolve().parent))
import wiki_rules
SCRIPT = Path(__file__).resolve()
DEFAULT_ROOT = SCRIPT.parents[2] # .claude/hooks/<this> → wiki root
OPTIONAL_MARKERS = re.compile(r"(있다면|있을\s*때|있으면|전용|optional)")
REQUIRED_MARKER = re.compile(r"필수")
PAREN = re.compile(r"\([^)]*\)")
HEADER_RE = re.compile(r"^##\s+(.*\S)\s*$")
FM_KEY_RE = re.compile(r"^([A-Za-z_][\w-]*):\s?(.*)$")
WIKILINK = re.compile(r"\[\[([^\]]+)\]\]")
# 마크다운 링크 [text](target ...) — 이미지(![..]) 제외, target 은 첫 공백 전까지
MDLINK = re.compile(r"(?<!\!)\[[^\]]+\]\(\s*([^)\s]+)[^)]*\)")
# 외부 스킴 / 그래프 노드 안 만드는 타깃 → 검사 제외
MD_EXTERNAL = re.compile(r"^(?:https?|ftp|mailto|tel|file|data|obsidian):", re.I)
# hub/log/MOC/README — 템플릿(C1)·depth(C3) 구조 검사는 면제하되 링크(C2)는 검사
LINK_ONLY_BASENAMES = {"README.md", "log.md", "index.md"}
def classify(rel, root=None):
"""문서를 검사 모드로 분류: 'full'(C1+C2+C3) | 'links'(C2만).
- raw/wiki 의 *콘텐츠* 문서(2단계 이상, hub/log 아님) : 전체.
- named-hub (wiki/<cat>/<slug>.md + 형제 폴더 <slug>/ 존재, linking-rules §12) : 링크만 (C1/C3 면제).
- 그 외 전부 (rules/ · templates/ · docs/ · 최상위 CLAUDE.md 등 · hub/MOC/log/README) : 링크만.
(템플릿 구조가 없거나 메타 문서이므로 C1/C3 면제, 그래프 ghost 방지용 C2 만.)
"""
parts = rel.split("/")
base = parts[-1]
# named-hub folder-note: <cat>/<slug>.md 에 형제 폴더 <slug>/ 가 있으면 MOC → 링크만
if root is not None and len(parts) == 3 and parts[0] in ("raw", "wiki") and base.endswith(".md"):
slug = base[:-3]
if (root / parts[0] / parts[1] / slug).is_dir():
return "links"
# raw/project-notes/*.md → project 모드 (구조-불가지 proxy + 링크).
# exemplar 가 project-template 섹션명을 안 따르므로 C1 섹션 매칭 면제.
if (parts[0] == "raw" and len(parts) == 3 and parts[1] == "project-notes"
and base.endswith(".md") and base not in LINK_ONLY_BASENAMES):
return "project"
if parts[0] in ("raw", "wiki") and len(parts) > 2 and base not in LINK_ONLY_BASENAMES:
return "full"
return "links"
# 모든 템플릿이 frontmatter source_type 를 직접 선언하므로 fallback 불필요(비움).
FALLBACK_SOURCE_TYPE_TO_TEMPLATE = {}
# ---------- 파싱 유틸 ----------
def split_frontmatter(text):
"""(fm_dict, fm_keys_in_order, body_lines) 반환."""
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return {}, [], lines
fm, keys = {}, []
i = 1
while i < len(lines) and lines[i].strip() != "---":
m = FM_KEY_RE.match(lines[i])
if m:
fm[m.group(1)] = m.group(2).strip()
keys.append(m.group(1))
i += 1
body = lines[i + 1:] if i < len(lines) else []
return fm, keys, body
def header_tokens(htext):
"""헤더를 정규화한 토큰 집합. '## Parent / 부모 (필수)'{parent, 부모}."""
t = PAREN.sub("", htext)
parts = [p.strip().lower() for p in t.split("/")]
return frozenset(p for p in parts if p)
def is_optional(htext):
return bool(OPTIONAL_MARKERS.search(htext)) and not REQUIRED_MARKER.search(htext)
def read_text(path):
try:
return path.read_text(encoding="utf-8")
except Exception:
try:
return path.read_text(encoding="utf-8", errors="replace")
except Exception:
return ""
def parse_doc(path):
text = read_text(path)
fm, fm_keys, _ = split_frontmatter(text)
headers = []
for idx, line in enumerate(text.splitlines(), start=1):
m = HEADER_RE.match(line)
if m:
headers.append((idx, m.group(1)))
return {"text": text, "fm": fm, "fm_keys": fm_keys,
"headers": headers, "lines": text.splitlines()}
def parse_allowed_source_types(text):
"""'## source_type 허용값' 섹션에서 백틱 토큰(`official-doc` 등) 수집."""
vals = set()
m = re.search(r"^##\s*source_type\s*허용값.*?$(.*?)(^##\s|\Z)", text, re.S | re.M)
if m:
for bt in re.findall(r"`([a-z][a-z0-9-]+)`", m.group(1)):
vals.add(bt)
return vals
def build_template_index(root):
by_st, by_file = {}, {}
tdir = root / "templates"
if not tdir.exists():
return by_st, by_file
for tpath in sorted(tdir.glob("*-template.md")):
text = read_text(tpath)
fm, fm_keys, _ = split_frontmatter(text)
req, opt = [], []
for h in re.findall(r"^##\s+(.*\S)\s*$", text, re.M):
if "허용값" in h or h.lower().startswith("source_type"):
continue # 템플릿 안내용 섹션 — 문서 필수 아님
(opt if is_optional(h) else req).append((h, header_tokens(h)))
rec = {"file": tpath.name, "required": req, "optional": opt,
"fm_keys": list(fm_keys), "track": fm.get("track", "").strip()}
by_file[tpath.name] = rec
st_raw = fm.get("source_type", "").strip()
for st in (s.strip() for s in re.split(r"[|,]", st_raw)): # 다중값 'a | b' 지원
if st and not st.startswith("{"):
by_st.setdefault(st, rec)
for av in parse_allowed_source_types(text):
by_st.setdefault(av, rec)
return by_st, by_file
def resolve_template(fm, by_st, by_file):
st = fm.get("source_type", "").strip()
track = fm.get("track", "").strip()
if st == "daily-task":
fn = f"daily-task-{track}-template.md" if track in ("develop", "infra") else None
return by_file.get(fn) if fn else None
if st in by_st:
return by_st[st]
if st in FALLBACK_SOURCE_TYPE_TO_TEMPLATE:
return by_file.get(FALLBACK_SOURCE_TYPE_TO_TEMPLATE[st])
return None
def build_vault_index(root):
"""링크 타깃 확인용. md는 .md strip, 비-md 첨부는 확장자 포함으로 등록.
숨김 디렉터리(.git 등)는 제외. (paths, bases=basename→rel목록)."""
paths, bases = set(), {}
for p in root.rglob("*"):
if not p.is_file():
continue
rel_posix = p.relative_to(root).as_posix()
if rel_posix.startswith(".") or "/." in rel_posix:
continue # .git / .obsidian 등 숨김 경로 제외
if p.suffix == ".md":
rel = rel_posix[:-3]
paths.add(rel)
bases.setdefault(p.stem, []).append(rel)
else:
paths.add(rel_posix) # 확장자 포함 full path
bases.setdefault(p.name, []).append(rel_posix) # 확장자 포함 basename
return paths, bases
# ---------- 검사 ----------
def present(token_set, doc_sets):
return any(token_set & d for d in doc_sets)
def check_c1(doc, tmpl):
out = []
if tmpl is None:
out.append(("UNMAPPED_SOURCE_TYPE", 0,
f"source_type='{doc['fm'].get('source_type', '')}' 가 어느 템플릿과도 매칭 안 됨"))
return out
doc_sets = [header_tokens(h) for (_, h) in doc["headers"]]
for orig, ts in tmpl["required"]:
if not present(ts, doc_sets):
out.append(("MISSING_SECTION", 0, f"필수 섹션 누락: '## {orig}'"))
for k in tmpl["fm_keys"]:
if k not in doc["fm_keys"]:
out.append(("MISSING_FRONTMATTER", 0, f"frontmatter 키 누락: '{k}'"))
return out
HEADING_RE = re.compile(r"^#{1,6}\s+(.+?)\s*$", re.M)
def _heading_set(txt):
return {h.strip().lower() for h in HEADING_RE.findall(txt)}
def _check_anchor(out, lineno, target, anchor, vault_paths, vault_bases, root, cache):
rels = [target] if target in vault_paths else vault_bases.get(target, [])
md_rels = [r for r in rels if (root / (r + ".md")).exists()]
if not md_rels:
return # 비-md 첨부 등 — anchor 검사 무의미, skip
is_block = anchor.startswith("^")
norm = anchor[1:].strip() if is_block else anchor.strip().lower()
for rel in md_rels:
fp = root / (rel + ".md")
txt = cache.get(fp)
if txt is None:
txt = read_text(fp)
cache[fp] = txt
if is_block:
if re.search(r"\^" + re.escape(norm) + r"\s*$", txt, re.M):
return
else:
if norm in _heading_set(txt):
return
out.append(("DANGLING_ANCHOR", lineno, f"앵커 부재: [[{target}#{anchor}]]"))
def _code_spans(line):
"""CommonMark 인라인 code span 범위 [(start, end), ...].
길이 N 백틱 런으로 열고 *정확히* 길이 N 런으로 닫음 → 단일/이중/삼중 백틱 모두 처리
(`` `[[X]]` `` · ``` `` [[X]] `` ``` 등 다중 백틱 코드도 정확히 인식해 오탐 방지)."""
spans, i, n = [], 0, len(line)
while i < n:
if line[i] != "`":
i += 1
continue
j = i
while j < n and line[j] == "`":
j += 1
run = j - i # 여는 백틱 런 길이
k = j
closed = False
while k < n:
if line[k] == "`":
m = k
while m < n and line[m] == "`":
m += 1
if m - k == run: # 정확히 같은 길이 → 닫힘
spans.append((i, m))
i = m
closed = True
break
k = m
else:
k += 1
if not closed:
i = j # 닫는 런 없음 → code span 아님, 여는 런 뒤로 진행
return spans
def _md_link_ok(tgt, doc_rel, root, vault_paths, vault_bases):
"""마크다운 링크 [text](tgt) 의 타깃이 그래프 ghost 를 안 만드는지.
외부 스킴/순수 앵커 → ok. 내부/상대 경로는 파일 디렉터리 기준으로 resolve 해 실존 확인."""
import posixpath
tgt = tgt.strip().strip("<>")
if not tgt or tgt.startswith("#") or MD_EXTERNAL.match(tgt):
return True
path = tgt.split("#", 1)[0].split("?", 1)[0].strip()
if not path:
return True
if path.startswith("/"):
cand = path.lstrip("/")
else:
base = posixpath.dirname(doc_rel)
cand = posixpath.normpath(posixpath.join(base, path) if base else path)
if cand.startswith(".."): # vault 밖으로 탈출 → ghost
return False
# Obsidian 은 점(.)으로 시작하는 폴더(.claude/.agents/.obsidian 등)를 graph 에 색인하지 않는다.
# 그런 경로로 가는 마크다운 링크는 *파일이 실제로 존재해도* graph ghost 노드를 만든다.
# (build_vault_index 도 동일하게 숨김 경로를 제외하므로 위키링크는 이미 BROKEN_LINK 로 잡힘.
# 마크다운 링크는 아래 exists() 검사를 통과해 버리므로 여기서 먼저 차단한다.)
if any(part.startswith(".") for part in cand.split("/") if part):
return False
if (root / cand).exists() or (root / (cand + ".md")).exists():
return True
slug = cand[:-3] if cand.endswith(".md") else cand
return slug in vault_paths or posixpath.basename(slug) in vault_bases
def check_c2(doc, vault_paths, vault_bases, root, cache, doc_rel=""):
out = []
in_fence = False
for lineno, line in enumerate(doc["lines"], start=1):
s = line.lstrip()
if s.startswith("```") or s.startswith("~~~"):
# CommonMark: 여는 fence 는 info string(```bash/```text) 허용,
# 닫는 fence 는 info string 없는 bare ```/~~~ 만. info 있는 ``` 가
# 블록 내부에 나와도 닫지 않음(잘못된 토글로 이후 전체가 뒤집히는 것 방지).
info = s.lstrip("`~").strip()
if in_fence:
if not info:
in_fence = False
else:
in_fence = True
continue
if in_fence:
continue
code_spans = _code_spans(line)
for m in WIKILINK.finditer(line):
# 인라인 code span 내부 `[[X]]` 는 Obsidian 에서 링크로 렌더되지 않음(그래프 노드 미생성).
# 템플릿 placeholder / rules 문법 예시 / 로그 언급 등 *의도적 비활성 표기* → 위반 아님, 건너뜀.
if any(a <= m.start() < b for a, b in code_spans):
continue
# 마크다운 표 안에서는 alias 구분자가 `\|`(escaped) 로 쓰임 → 정규화 후 split.
raw = m.group(1).replace("\\|", "|").split("|")[0].strip()
target, _, anchor = raw.partition("#")
target, anchor = target.strip(), anchor.strip()
if target.endswith(".md"): # 옵시디언은 [[x.md]] 도 유효
target = target[:-3]
if not target:
continue
if target not in vault_paths and target not in vault_bases:
out.append(("BROKEN_LINK", lineno, f"타깃 부재: [[{target}]]"))
continue
if anchor:
_check_anchor(out, lineno, target, anchor,
vault_paths, vault_bases, root, cache)
# 마크다운 링크 [text](target) — 내부/상대 타깃이 vault 에서 resolve 안 되면 ghost.
for m in MDLINK.finditer(line):
if any(a <= m.start() < b for a, b in code_spans):
continue
tgt = m.group(1)
if not _md_link_ok(tgt, doc_rel, root, vault_paths, vault_bases):
out.append(("BROKEN_MD_LINK", lineno,
f"마크다운 링크 타깃 부재 또는 graph 색인 제외 경로"
f"(.claude/.obsidian 등은 백틱 코드로 표기): ({tgt[:60]})"))
return out
def check_c3(doc):
out = []
if doc["fm"].get("source_type", "").strip() != "branch-note":
return out
lines = doc["lines"]
for i, line in enumerate(lines):
if "|" in line and "선택 조건" in line:
cols = [c.strip() for c in line.strip().strip("|").split("|")]
cidx = next((k for k, c in enumerate(cols) if "선택 조건" in c), None)
if cidx is None:
continue
j = i + 2 # 헤더 + 구분선(|---|) 다음부터 데이터 행
while j < len(lines) and lines[j].lstrip().startswith("|"):
cells = [c.strip() for c in lines[j].strip().strip("|").split("|")]
if cidx < len(cells):
cell = cells[cidx]
if cell == "" or re.fullmatch(r"<.*>", cell):
out.append(("EMPTY_SELECTION_CRITERION", j + 1,
"Decision Evidence Map '선택 조건' 셀 비어있음(또는 placeholder)"))
j += 1
break
return out
# ---------- branch-note 파일명 규칙 (P1-11 — §11 numbered-hierarchy 금지의 결정론화) ----------
BRANCH_PREFIX_RE = re.compile(r"^(feature|fix|chore|experiment)-[a-z0-9][a-z0-9-]*\.md$")
NUMBERED_SUFFIX_RE = re.compile(r"-\d+(-\d+)*\.md$")
def branch_naming_violations(rel):
"""raw/branch-notes/ 파일명: prefix 4종 + kebab-case, numbered hierarchy 금지.
(naming-conventions §2.1 / CLAUDE.md §11). hub/README 류는 면제."""
parts = rel.split("/")
base = parts[-1]
if not rel.startswith("raw/branch-notes/") or not base.endswith(".md"):
return []
if base in LINK_ONLY_BASENAMES:
return []
out = []
if not BRANCH_PREFIX_RE.match(base):
out.append(("NAMING_VIOLATION", 0,
f"branch-note 파일명 규칙 위반: '{base}' — prefix 4종(feature|fix|chore|experiment)- "
"+ 영문 kebab-case 필요 (rules/naming-conventions.md §2.1)"))
elif NUMBERED_SUFFIX_RE.search(base):
out.append(("NAMING_VIOLATION", 0,
f"branch-note 슬러그에 numbered hierarchy 금지: '{base}' — 계층은 "
"frontmatter `parent_branch:` 로만 (CLAUDE.md §11)"))
return out
DIAGRAM_DRAWIO_RE = re.compile(r"!\[\[[^\]]*\.drawio")
DIAGRAM_MERMAID_RE = re.compile(r"^\s*```+\s*mermaid", re.M)
PROJECT_HEADER_RE = re.compile(r"^#{1,6}\s")
BRANCH_HEADER_RE = re.compile(r"branch|브랜치", re.I)
TABLE_SEP_RE = re.compile(r"-{3,}")
def _has_branch_table(doc):
"""heading 토큰에 branch/브랜치 포함 섹션 아래 markdown 표(구분선)가 있는가."""
lines = doc["lines"]
for i, line in enumerate(lines):
if PROJECT_HEADER_RE.match(line) and BRANCH_HEADER_RE.search(line):
j = i + 1
while j < len(lines) and not PROJECT_HEADER_RE.match(lines[j]):
if "|" in lines[j] and TABLE_SEP_RE.search(lines[j]):
return True
j += 1
return False
def check_project_proxies(doc):
"""project-note 구조-불가지 proxy: 존재만 검사(깊이는 auditor)."""
out = []
text = doc.get("text", "\n".join(doc.get("lines", [])))
if not (DIAGRAM_DRAWIO_RE.search(text) or DIAGRAM_MERMAID_RE.search(text)):
out.append(("PROJECT_NO_DIAGRAM", 0,
"임베디드 다이어그램 없음 (`![[...drawio` 또는 ```mermaid 블록). R2 proxy"))
if not _has_branch_table(doc):
out.append(("PROJECT_NO_BRANCH_TABLE", 0,
"Branch 분해표 없음 (heading 'branch/브랜치' 아래 표). R4 proxy"))
return out
def is_completeness_checkable(doc):
"""C1/C3(완성도 검사)를 hook 에서 켤지 판정 — 문서가 *초안 단계를 지났다고 선언* 했는가.
링크(C2)는 항상 검사하지만, 섹션 누락(C1)·빈 선택조건(C3) 은 작성 중간엔 당연히 비어
있어 false-positive 노이즈가 되므로 '완성 선언' 시에만 켠다(사용자 결정 DD: 완성 선언 시에만).
- branch-note: status_label 이 review/merged 등일 때. in-progress/abandoned/빈값은 제외
(abandoned 는 의도된 미완성이므로 완성 선언 아님).
- 그 외: frontmatter status 가 reviewed/verified/published-ready 일 때. raw/draft/빈값 제외.
"""
fm = doc.get("fm", {})
if fm.get("source_type", "").strip() == "branch-note":
return fm.get("status_label", "").strip() not in ("", "in-progress", "abandoned")
return fm.get("status", "").strip() not in ("", "raw", "draft")
# ---------- 실행 ----------
def lint_file(path, root, by_st, by_file, vault_paths, vault_bases, cache, mode="full"):
"""mode: 'full'(C1+C2+C3) | 'links'(C2만) | 'project'(proxy+C2)."""
doc = parse_doc(path)
try:
doc_rel = path.relative_to(root).as_posix()
except ValueError:
doc_rel = ""
findings = []
if mode in ("full", "project"):
if not doc["fm"]:
return [("NO_FRONTMATTER", 0, "frontmatter 없음 — 스텁/미작성 문서(템플릿 미적용)")], "(none)"
if mode == "full":
tmpl = resolve_template(doc["fm"], by_st, by_file)
findings += check_c1(doc, tmpl)
elif mode == "project":
tmpl = resolve_template(doc["fm"], by_st, by_file)
# 섹션 매칭은 면제하되 frontmatter 키 누락은 검사(MISSING_FRONTMATTER 재사용).
if tmpl is not None:
for k in tmpl["fm_keys"]:
if k not in doc["fm_keys"]:
findings.append(("MISSING_FRONTMATTER", 0, f"frontmatter 키 누락: '{k}'"))
findings += check_project_proxies(doc)
findings += check_c2(doc, vault_paths, vault_bases, root, cache, doc_rel)
if mode == "full":
findings += check_c3(doc)
findings += branch_naming_violations(doc_rel)
return findings, doc["fm"].get("source_type", "").strip() or "(none)"
def run_coverage_pre(file_arg, root):
"""/coverage 1차 결정론 사전검사 (P1-11 — 기존 인라인 narrative 체크 기계화).
exit: 0 PASS(주의 포함) / 1 FAIL(차단 사유) / 3 EXEMPT(면제)."""
p = Path(file_arg)
if not p.is_absolute():
p = root / file_arg
if not p.exists():
print(f"FAIL 파일 없음: {file_arg}")
return 1
doc = parse_doc(p)
fm = doc["fm"]
governing_raw = fm.get("governing_docs", "").strip()
related = fm.get("related_projects", "")
# 면제: governing_docs 부재 + related_projects 에 ca-* 없음 (예: 학습 노트)
if not governing_raw.strip("[] ") and not re.search(r"ca-(skeleton|tmpl)", related):
print("EXEMPT coverage 면제 — governing_docs 부재 + related_projects 에 ca-* 없음")
return 3
fails, warns = [], []
if not governing_raw.strip("[] "):
fails.append("NO_GOVERNING_DOC: frontmatter `governing_docs:` 부재 — "
"`governing_docs: [wiki/projects/ca-tmpl/<cluster>]` 지정 필요")
else:
targets = [t.strip().strip("'\"") for t in governing_raw.strip("[]").split(",") if t.strip()]
for t in targets:
slug = t[:-3] if t.endswith(".md") else t
if not (root / (slug + ".md")).exists():
fails.append(f"GOVERNING_DOC_MISSING: `{t}` 가 가리키는 파일 부재")
if not re.search(r"^##\s+Coverage\b", doc["text"], re.M):
warns.append("NO_COVERAGE_SECTION: `## Coverage` 섹션 부재 — 2차(coverage-auditor)가 채울 칸")
for f in fails:
print(f"FAIL {f}")
for w in warns:
print(f"WARN {w}")
if not fails:
print(f"PASS coverage 1차 사전검사 통과 (WARN {len(warns)})")
return 1 if fails else 0
def run_stale(root):
"""CLAUDE.md §8 stale 판정의 결정론화 (P1-10 — LLM 날짜 암산 금지).
exit: 1 if 후보 ≥1, else 0."""
import datetime as dt
today = dt.date.today()
n = 0
for p in iter_docs(root):
rel = p.relative_to(root).as_posix()
if not (rel.startswith("wiki/") or rel.startswith("raw/")):
continue
fm, _, _ = split_frontmatter(read_text(p))
m = re.match(r"(\d{4}-\d{2}-\d{2})", fm.get("last_reviewed", "").strip())
if not m:
continue
try:
days = (today - dt.date.fromisoformat(m.group(1))).days
except ValueError:
continue
status = fm.get("status", "").strip()
conf = fm.get("confidence", "").strip()
if status == "needs-confirmation" and days > 14:
print(f"NEEDS_CONFIRMATION_14 {rel} ({days}d) — 14일 이상 방치")
n += 1
if days > 90 and status != "stale":
print(f"STALE_90 {rel} ({days}d) — `status: stale` 후보")
n += 1
elif days > 30 and conf == "low":
print(f"RECHECK_30 {rel} ({days}d) — confidence:low 재검토 필요")
n += 1
print(f"\n== stale 후보: {n}건 ==")
return 1 if n else 0
def iter_docs(root):
# vault 전체 .md 스캔 (raw/wiki/rules/templates/docs/ + 최상위). 분류는 classify() 가 결정.
# 숨김 디렉터리(.git/.obsidian/.claude/.agents) 는 제외 — Obsidian 그래프 밖이므로 ghost 없음.
for p in sorted(root.rglob("*.md")):
rel = p.relative_to(root).as_posix()
if rel.startswith(".") or "/." in rel:
continue
yield p
def run_pre(event, root):
"""PreToolUse: projected 본문의 C2 깨진링크(CRITICAL) + 신규 branch-note 파일명 위반 차단.
반환 exit code (0 통과 / 2 차단)."""
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
# 파일명 검사는 *신규 생성*만 차단 — 기존 위반 파일의 편집까지 막으면
# 마이그레이션 자체가 불가능해진다 (기존 파일은 --all 이 WARN 으로 보고).
if not p.exists():
viol = branch_naming_violations(rel)
if viol:
print(f"✗ wiki-structure-lint (pre): {rel} — 파일명 규칙 위반 → 생성 차단",
file=sys.stderr)
for code, _, msg in viol:
print(f" [{code}] {msg}", file=sys.stderr)
return 2
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
def run_hook(event, root):
"""PostToolUse: 완성 선언 문서의 C1/C3/DANGLING(FIXUP) → exit 2 fix-up. 그 외 WARN(0).
C2 깨진링크(CRITICAL)는 이미 --pre 가 쓰기 전 차단하므로 여기서는 fix-up 대상이 아니다
(출력은 하되 exit 코드엔 미반영 — Edge: 외부 파일 삭제로 사후 깨진 경우 등 방어적 경고).
"""
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)
# C2(링크)는 항상 검사 — 깨진 링크는 작성 중이든 아니든 항상 잘못된 것.
findings = check_c2(doc, vp, vb, root, {}, rel)
# C1(섹션)·C3(선택조건)은 '완성 선언' 시에만 — 작성 중간 false-positive 방지.
# project-note 는 섹션명 매칭 면제 — proxy + frontmatter 만(exemplar 비순응).
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 코드가 있으면 exit-2 fix-up (모델이 고치게). 그 외 WARN(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
def _dispatch_hook(fn, event, root, antigravity):
"""fn=run_pre|run_hook. Claude/Codex: exit code. Antigravity: 같은 로직의
stderr 를 캡처해 {decision} JSON(exit 0)으로 변환 — 검사 로직 불변, 출력만 분기."""
if not antigravity:
sys.exit(fn(event, root))
import io as _io
import contextlib as _cl
import json as _json2
buf = _io.StringIO()
with _cl.redirect_stderr(buf):
code = fn(event, root)
if code == 2:
print(_json2.dumps({"decision": "deny", "reason": buf.getvalue().strip()}, ensure_ascii=False))
else:
print(_json2.dumps({"decision": "allow"}))
sys.exit(0)
def main():
ap = argparse.ArgumentParser(description="결정론적 위키 문서 구조 린터")
ap.add_argument("--file", help="단일 문서 경로")
ap.add_argument("--all", action="store_true", help="raw/ + wiki/ 전수 검사")
ap.add_argument("--root", default=str(DEFAULT_ROOT), help="위키 루트")
ap.add_argument("--links-only", action="store_true", help="C2(링크 문법)만 검사")
ap.add_argument("--hook", action="store_true",
help="PostToolUse hook 모드 — stdin JSON 에서 file_path 추출, 완성선언 문서 fix-up gate")
ap.add_argument("--pre", action="store_true",
help="PreToolUse hook 모드 — projected 본문 C2 깨진링크 차단 (blocking)")
ap.add_argument("--antigravity", action="store_true",
help="Antigravity 출력 모드 — exit-code 대신 {decision} JSON (exit 0)")
ap.add_argument("--coverage-pre", metavar="FILE",
help="/coverage 1차 결정론 사전검사 — governing_docs·## Coverage·링크 실재 (0 PASS / 1 FAIL / 3 EXEMPT)")
ap.add_argument("--stale", action="store_true",
help="last_reviewed 기반 stale 후보 결정론 집계 (90/30/14일, CLAUDE.md §8)")
args = ap.parse_args()
root = Path(args.root).resolve()
if args.coverage_pre:
sys.exit(run_coverage_pre(args.coverage_pre, root))
if args.stale:
sys.exit(run_stale(root))
# --- PreToolUse hook 모드 (쓰기 전 projected 본문 C2 깨진링크 차단) ---
if args.pre:
import json as _json
try:
event = _json.loads(sys.stdin.read() or "{}")
except Exception:
sys.exit(0)
_dispatch_hook(run_pre, event, root, args.antigravity)
# --- PostToolUse hook 모드 (완성선언 문서 fix-up gate, 그 외 non-blocking warn) ---
if args.hook:
import json as _json
try:
event = _json.loads(sys.stdin.read() or "{}")
except Exception:
sys.exit(0)
_dispatch_hook(run_hook, event, root, args.antigravity)
by_st, by_file = build_template_index(root)
vault_paths, vault_bases = build_vault_index(root)
cache = {}
if args.file:
targets = [Path(args.file).resolve()]
elif args.all:
targets = list(iter_docs(root))
else:
ap.error("--file 또는 --all 중 하나 필요")
total = fails = 0
fail_by_type = {}
fail_by_rule = {}
for p in targets:
try:
rel = p.relative_to(root).as_posix()
except ValueError:
rel = str(p)
mode = "links" if args.links_only else classify(rel, root)
total += 1
findings, st = lint_file(p, root, by_st, by_file, vault_paths, vault_bases, cache,
mode=mode)
if findings:
fails += 1
fail_by_type[st] = fail_by_type.get(st, 0) + 1
print(f"FAIL {rel}")
for code, ln, msg in findings:
fail_by_rule[code] = fail_by_rule.get(code, 0) + 1
loc = f":{ln}" if ln else ""
print(f" [{code}]{loc} {msg}")
elif args.file:
print(f"PASS {rel}")
if args.all:
print(f"\n== 요약: {total}개 중 FAIL {fails} / PASS {total - fails} ==")
if fail_by_type:
print("source_type별 FAIL:")
for st, n in sorted(fail_by_type.items(), key=lambda x: -x[1]):
print(f" {n:4d} {st}")
if fail_by_rule:
print("규칙별 위반 건수:")
for code, n in sorted(fail_by_rule.items(), key=lambda x: -x[1]):
print(f" {n:4d} {code}")
sys.exit(1 if fails else 0)
if __name__ == "__main__":
main()