#!/usr/bin/env python3 """Reference linter for .claude/commands/*.md (WP-3, 결함 #3 재발 방지). 커맨드가 참조하는 세 종류의 대상이 실제로 존재하는지 검증한다: (a) identity — concrete agent 이름은 `.claude/agents/.md`, `fam-*`은 family registry metadata에 존재해야 (b) hook 스크립트 — `*.py` 토큰 → 경로면 repo 기준, basename이면 `.claude/hooks/` 기준으로 실존해야 (c) 파일 경로 — `.claude/` / `org-os/` / `docs/`로 시작하는 repo-tracked 경로 → 실존해야 추출은 **실용적**이다: 백틱 인용 토큰만 본다(산문 오탐 회피). 템플릿/글롭 문자(<>{}[]*)가 든 토큰은 건너뛴다(예: `.claude/agents/.md`, `completion-records//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 ` 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())