Files
llm-wiki/.claude/hooks/wiki_structure_lint.py

787 lines
34 KiB
Python

#!/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()