chore(harness): Claude Code 하네스 구성 — 안전 가드·규칙 검사·배포 스킬
- .claude/settings.json (공유) 신설, hooks 를 settings.local.json 에서 이동 - bash_guard (PreToolUse): 로컬 스택 기동·운영 DB alembic·_test 아닌 pytest·main 강제 push·위험 rm 차단, main push/deploy.sh 는 장중 경고와 함께 확인 요청, backend 커밋 전 유닛 스위트 게이트 - rules_check (PostToolUse): py_compile + CLAUDE.md 규칙(json.dumps, except 후 logger.debug, except-pass, 실시간 계층 DB I/O) — diff 추가 라인만 - session_context (SessionStart): 브랜치·미커밋·장 상태·alembic head 주입 - stop_check (Stop): frontend 변경 시 tsc - auto-orchestrate: 서브에이전트 보고·시스템 알림·질문형 프롬프트 오발동 제거 - /deploy 스킬 + deploy.sh / prod_smoke.sh: 테스트→dev push→main 병합→재기동 감시→공개 API 스모크 - 팀 에이전트·orchestrate 스킬 추적 시작, settings.local.json 추적 해제(.gitignore) - CLAUDE.md §9 하네스 - 훅 테스트 69 passed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
161
.claude/hooks/rules_check.py
Executable file
161
.claude/hooks/rules_check.py
Executable file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PostToolUse(Edit|Write|MultiEdit) — 편집된 backend/**/*.py 만 검사.
|
||||
|
||||
1) py_compile 실패 → exit 2
|
||||
2) CLAUDE.md 정적 규칙: git diff(HEAD 대비) 추가 라인만 대상 → 기존 위반은 보고하지 않는다.
|
||||
(추적되지 않는 새 파일은 전체 라인이 추가로 간주됨)
|
||||
위반이 있으면 exit 2 + stderr 에 '파일:라인 규칙 — 근거(CLAUDE.md §)'. 경고성 항목은 stderr 로만 알리고 exit 0.
|
||||
"""
|
||||
import os
|
||||
import py_compile
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _lib import git, project_dir, read_stdin_json # noqa: E402
|
||||
|
||||
REALTIME_MARKERS = (
|
||||
"/app/realtime/",
|
||||
"/services/daytrading/",
|
||||
"/services/swing/scheduler.py",
|
||||
"/kis_realtime_stream.py",
|
||||
)
|
||||
DB_READ_FUNCS = {"close_stale_positions", "populate_daytrading_watchlist", "restore_from_db"}
|
||||
EXCEPT_RE = re.compile(r"^\s*except\b[^:]*:\s*(.*)$")
|
||||
|
||||
|
||||
def added_lines(root: str, rel: str, full_text: str) -> set[int]:
|
||||
rc, _ = git(["ls-files", "--error-unmatch", rel], root)
|
||||
if rc != 0: # 새 파일(미추적) → 전부 추가
|
||||
return set(range(1, full_text.count("\n") + 2))
|
||||
rc, out = git(["diff", "-U0", "HEAD", "--", rel], root)
|
||||
if rc != 0:
|
||||
return set()
|
||||
res: set[int] = set()
|
||||
for m in re.finditer(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", out, re.M):
|
||||
start, cnt = int(m.group(1)), int(m.group(2) or 1)
|
||||
res.update(range(start, start + cnt))
|
||||
return res
|
||||
|
||||
|
||||
def enclosing_def(lines: list[str], idx: int) -> tuple[str, int]:
|
||||
indent = len(lines[idx]) - len(lines[idx].lstrip())
|
||||
for j in range(idx, -1, -1):
|
||||
m = re.match(r"^(\s*)(?:async\s+)?def\s+(\w+)", lines[j])
|
||||
if m and len(m.group(1)) < indent:
|
||||
return m.group(2), j
|
||||
return "", -1
|
||||
|
||||
|
||||
def next_stmt(lines: list[str], i: int) -> int | None:
|
||||
for j in range(i + 1, min(i + 8, len(lines))):
|
||||
s = lines[j].strip()
|
||||
if s and not s.startswith("#"):
|
||||
return j
|
||||
return None
|
||||
|
||||
|
||||
def check_static(path: str, rel: str, lines: list[str], added: set[int]) -> list[str]:
|
||||
v: list[str] = []
|
||||
is_json_helper = rel.endswith("app/core/json_helpers.py")
|
||||
realtime = any(mk in "/" + rel for mk in REALTIME_MARKERS)
|
||||
for idx, raw in enumerate(lines):
|
||||
ln = idx + 1
|
||||
s = raw.strip()
|
||||
if s.startswith("#"):
|
||||
continue
|
||||
# json.dumps
|
||||
if ln in added and not is_json_helper and re.search(r"\bjson\.dumps\(", raw):
|
||||
v.append(f"{rel}:{ln} json.dumps 사용 — core.json_helpers._dumps() 를 쓸 것 (CLAUDE.md §4)")
|
||||
# except 블록 규칙
|
||||
m = EXCEPT_RE.match(raw)
|
||||
if m:
|
||||
nxt = next_stmt(lines, idx)
|
||||
inline = m.group(1).strip()
|
||||
first = inline if inline and not inline.startswith("#") else (lines[nxt].strip() if nxt is not None else "")
|
||||
first_ln = ln if inline and not inline.startswith("#") else (nxt + 1 if nxt is not None else ln)
|
||||
touched = ln in added or first_ln in added
|
||||
if touched and re.search(r"\blog(?:ger|ging)?\.debug\(", first):
|
||||
v.append(f"{rel}:{first_ln} except 블록의 첫 문장이 logger.debug — 최소 warning 이어야 한다 (CLAUDE.md §4, §8.1-10)")
|
||||
if touched and first == "pass" and re.match(r"^\s*except(\s+(Exception|BaseException)(\s+as\s+\w+)?)?\s*:", raw):
|
||||
v.append(f"{rel}:{ln} `except Exception: pass` — 예외 삼킴 금지, 최소 warning 로그 (CLAUDE.md §4, §8.1-10)")
|
||||
# 실시간 계층 DB I/O
|
||||
if realtime and ln in added:
|
||||
if ".commit(" in raw:
|
||||
v.append(f"{rel}:{ln} 실시간 계층에서 commit() — DB 쓰기는 db_task_queue 경유 (CLAUDE.md §3.1·§3.2)")
|
||||
if "AsyncSessionLocal(" in raw and not s.startswith(("def ", "class ", "from ", "import ")):
|
||||
name, di = enclosing_def(lines, idx)
|
||||
window = lines[max(0, idx - 3): idx + 1]
|
||||
if name in DB_READ_FUNCS:
|
||||
continue
|
||||
if any("DB-READ-EXCEPTION" in w for w in window) or (di >= 0 and "DB-READ-EXCEPTION" in lines[di]):
|
||||
continue
|
||||
if rel.endswith("daytrading/scheduler.py") and any(
|
||||
"restore_from_db" in w for w in lines[max(0, idx - 10): idx + 10]
|
||||
):
|
||||
continue
|
||||
v.append(f"{rel}:{ln} 실시간 계층에서 AsyncSessionLocal( — SELECT 전용 예외면 `# DB-READ-EXCEPTION` 마커를 달고 commit 금지 (CLAUDE.md §3.1·§3.2 ③)")
|
||||
return v
|
||||
|
||||
|
||||
def check_router_auth(root: str, rel: str, text: str) -> list[str]:
|
||||
if "/api/endpoints/" not in "/" + rel or not re.search(r"@router\.(post|put|delete|patch)\(", text):
|
||||
return []
|
||||
if "require_api_key" in text:
|
||||
return []
|
||||
init = os.path.join(root, "backend/app/api/__init__.py")
|
||||
mod = os.path.splitext(os.path.basename(rel))[0]
|
||||
try:
|
||||
body = open(init, encoding="utf-8").read()
|
||||
except OSError:
|
||||
return []
|
||||
for m in re.finditer(r"include_router\(\s*([\w.]+)\.router(.*?)\)\s*(?=\n\S|\Z|\napi_router)", body, re.S):
|
||||
if m.group(1).split(".")[-1] == mod and "require_api_key" in m.group(2):
|
||||
return []
|
||||
return [f"{rel}:1 [경고] 쓰기 엔드포인트가 있는데 require_api_key 가 라우터/api/__init__.py 등록에서 확인되지 않음 (CLAUDE.md §4)"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
data = read_stdin_json()
|
||||
fp = (data.get("tool_input") or {}).get("file_path")
|
||||
if not isinstance(fp, str) or not fp.endswith(".py"):
|
||||
return 0
|
||||
root = os.path.realpath(project_dir(data))
|
||||
fp = os.path.realpath(fp)
|
||||
if not fp.startswith(root + "/backend/") or not os.path.isfile(fp):
|
||||
return 0
|
||||
rel = os.path.relpath(fp, root)
|
||||
errors: list[str] = []
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
py_compile.compile(fp, cfile=os.path.join(td, "x.pyc"), doraise=True)
|
||||
except py_compile.PyCompileError as e:
|
||||
errors.append(f"{rel} py_compile 실패 — {str(e).strip().splitlines()[-1]}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
in_tests = "/backend/tests/" in fp or "/backend/alembic/" in fp
|
||||
warnings: list[str] = []
|
||||
if not errors and not in_tests:
|
||||
try:
|
||||
text = open(fp, encoding="utf-8").read()
|
||||
lines = text.split("\n")
|
||||
errors += check_static(fp, rel, lines, added_lines(root, rel, text))
|
||||
warnings += check_router_auth(root, rel, text)
|
||||
except Exception as e: # noqa: BLE001
|
||||
sys.stderr.write(f"[rules_check] internal {e!r}\n")
|
||||
return 0
|
||||
|
||||
if warnings and not errors:
|
||||
sys.stderr.write("\n".join(warnings) + "\n")
|
||||
return 0
|
||||
if errors:
|
||||
sys.stderr.write("[harness] CLAUDE.md 규칙 위반(이번 편집에서 추가된 라인):\n" + "\n".join(errors + warnings) + "\n")
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user