Files

181 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""Reference linter for .claude/commands/*.md (WP-3, 결함 #3 재발 방지).
커맨드가 참조하는 세 종류의 대상이 실제로 존재하는지 검증한다:
(a) identity — concrete agent 이름은 `.claude/agents/<name>.md`, `fam-*`은 family registry metadata에 존재해야
(b) hook 스크립트 — `*.py` 토큰 → 경로면 repo 기준, basename이면 `.claude/hooks/` 기준으로 실존해야
(c) 파일 경로 — `.claude/` / `org-os/` / `docs/`로 시작하는 repo-tracked 경로 → 실존해야
추출은 **실용적**이다: 백틱 인용 토큰만 본다(산문 오탐 회피). 템플릿/글롭 문자(<>{}[]*)가
든 토큰은 건너뛴다(예: `.claude/agents/<role-id>.md`, `completion-records/<wf>/build-*.report.yaml`).
런타임/워크스페이스 산출 경로(reports/·completion-records/·slack-*/·deliverables/·src/ 등)는
repo-tracked 루트가 아니므로 검증 대상에서 제외한다(오탐 방지).
미해결 참조가 하나라도 있으면 목록을 출력하고 비영점 종료. doctor.py/CI에서 호출 가능.
Usage: python3 .claude/hooks/lint_refs.py
API: from lint_refs import check_refs; problems = check_refs() # -> list[str] (빈 리스트=통과)
"""
import glob
import os
import re
import sys
import yaml
ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
AGENTS_DIR = os.path.join(ROOT, ".claude", "agents")
HOOKS_DIR = os.path.join(ROOT, ".claude", "hooks")
COMMANDS_GLOB = os.path.join(ROOT, ".claude", "commands", "*.md")
# repo에 추적되는(=실존 검증 가능한) 경로 루트. 나머지(reports/·completion-records/ 등)는 런타임 산출물이라 제외.
REPO_ROOTS = (".claude/", "org-os/", "docs/")
TEMPLATE_CHARS = set("<>{}[]*")
BACKTICK = re.compile(r"`([^`]+)`")
FAM_NAME = re.compile(r"^fam-[a-z0-9][a-z0-9-]*$")
PY_TOKEN = re.compile(r"[\w./-]+\.py")
def known_agent_names():
if not os.path.isdir(AGENTS_DIR):
return set()
return {os.path.basename(p)[:-3] for p in glob.glob(os.path.join(AGENTS_DIR, "*.md"))}
def _has_template(tok):
return any(c in TEMPLATE_CHARS for c in tok)
def _iter_tokens(text):
"""백틱 인용 span을 내고, 명령형 토큰(공백 포함)은 단어로 분해해 함께 낸다."""
for span in BACKTICK.findall(text):
span = span.strip()
yield span
if " " in span: # e.g. `python3 .claude/hooks/new_report.py --workflow <wf>`
for word in span.split():
yield word.strip()
def check_refs(root=None):
"""미해결 참조 메시지 리스트를 반환(빈 리스트 = 통과). 예외를 던지지 않는다."""
base = root or ROOT
agents_dir = os.path.join(base, ".claude", "agents")
hooks_dir = os.path.join(base, ".claude", "hooks")
commands = sorted(glob.glob(os.path.join(base, ".claude", "commands", "*.md")))
known = ({os.path.basename(p)[:-3] for p in glob.glob(os.path.join(agents_dir, "*.md"))}
if os.path.isdir(agents_dir) else set())
try:
family_path = os.path.join(base, "org-os", "00-role-registry", "capability-families.yaml")
family_rows = (yaml.safe_load(open(family_path, encoding="utf-8")) or {})["capability-families"]["families"]
known_families = {str(row["family-id"]).lower() for row in family_rows}
except Exception:
known_families = set()
problems = []
for cmd in commands:
rel = os.path.relpath(cmd, base)
try:
text = open(cmd, encoding="utf-8").read()
except OSError as e:
problems.append(f"{rel}: 읽기 실패 ({e})")
continue
seen = set() # (kind, token) 중복 억제(파일 내)
for tok in _iter_tokens(text):
if not tok or _has_template(tok):
continue
# (a1) family 이름: metadata registry에서 해소한다. agent card를 요구하지 않는다.
if FAM_NAME.match(tok):
key = ("family", tok)
if key in seen:
continue
seen.add(key)
if tok not in known_families:
problems.append(f"{rel}: family metadata `{tok}` 미존재(capability-families.yaml)")
continue
# (a2) concrete agent 이름
if tok in known:
key = ("agent", tok)
if key in seen:
continue
seen.add(key)
if not os.path.exists(os.path.join(agents_dir, f"{tok}.md")):
problems.append(f"{rel}: agent `{tok}` 미존재 (.claude/agents/{tok}.md 없음)")
continue
# (b) hook 스크립트(*.py). 명령형 토큰에서 .py 부분만 뽑는다.
m = PY_TOKEN.search(tok)
if m:
pyref = m.group(0)
key = ("py", pyref)
if key in seen:
continue
seen.add(key)
cands = []
if "/" in pyref:
cands.append(os.path.join(base, pyref))
else:
cands.append(os.path.join(hooks_dir, pyref))
cands.append(os.path.join(base, pyref))
if not any(os.path.exists(c) for c in cands):
problems.append(f"{rel}: hook 스크립트 `{pyref}` 미존재 (.claude/hooks/{os.path.basename(pyref)} 없음)")
continue
# (c) repo-tracked 파일 경로(.claude/·org-os/·docs/). 런타임 경로는 제외.
if tok.startswith(REPO_ROOTS) and "/" in tok:
key = ("path", tok)
if key in seen:
continue
seen.add(key)
if not os.path.exists(os.path.join(base, tok)):
problems.append(f"{rel}: 파일 경로 `{tok}` 미존재")
continue
return problems
def check_skill_refs(root=None):
"""에이전트 카드 skills: frontmatter → 실존 SKILL.md 해소(빈 리스트=통과) (P3)."""
base = root or ROOT
if HOOKS_DIR not in sys.path:
sys.path.insert(0, HOOKS_DIR)
try:
import yaml
from skill_refs import known_skill_names, parse_skills
except Exception as e: # noqa: BLE001
return [f"skill_refs 로드 실패: {e}"]
known = known_skill_names(base)
problems = []
for a in sorted(glob.glob(os.path.join(base, ".claude", "agents", "*.md"))):
rel = os.path.relpath(a, base)
try:
fm = yaml.safe_load(open(a, encoding="utf-8").read().split("---\n")[1]) or {}
except Exception as e: # noqa: BLE001
problems.append(f"{rel}: frontmatter 파싱 실패 ({e})")
continue
for s in parse_skills(fm.get("skills")):
if s not in known:
problems.append(f"{rel}: skill `{s}` 미존재(.claude/skills/**/SKILL.md 없음)")
return problems
def main():
problems = check_refs() + check_skill_refs()
if problems:
print("REF-LINT FAIL: 미해결 참조 %d건" % len(problems))
for p in problems:
print(f" - {p}")
return 1
n = len(glob.glob(COMMANDS_GLOB))
a = len(glob.glob(os.path.join(AGENTS_DIR, "*.md")))
print(f"OK lint_refs: {n} command 참조 + {a} agent skills 참조 모두 해결됨")
return 0
if __name__ == "__main__":
sys.exit(main())