feat: 공식 문서 근거자료, 브랜치 기능 문서 작성
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Executable
+370
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user