181 lines
7.4 KiB
Python
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())
|