#!/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/ → 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"(?/.md + 형제 폴더 / 존재, 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: /.md 에 형제 폴더 / 가 있으면 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/]` 지정 필요") 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()