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:
52
.claude/hooks/_lib.py
Executable file
52
.claude/hooks/_lib.py
Executable file
@@ -0,0 +1,52 @@
|
||||
"""하네스 훅 공용 유틸 (표준 라이브러리 전용)."""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
def kst_now() -> datetime:
|
||||
override = os.environ.get("HARNESS_NOW_KST") # 테스트용: 2026-10-02T10:00
|
||||
if override:
|
||||
try:
|
||||
return datetime.fromisoformat(override).replace(tzinfo=KST)
|
||||
except ValueError:
|
||||
pass
|
||||
return datetime.now(KST)
|
||||
|
||||
|
||||
def market_state(now: datetime) -> str:
|
||||
"""'휴장-주말' | '장전' | '장중' | '장후'. 공휴일은 판정하지 않는다(market_schedule 과 동일 한계)."""
|
||||
if now.weekday() >= 5:
|
||||
return "휴장-주말"
|
||||
hm = now.hour * 60 + now.minute
|
||||
if hm < 8 * 60 + 30:
|
||||
return "장전"
|
||||
if hm <= 15 * 60 + 40:
|
||||
return "장중"
|
||||
return "장후"
|
||||
|
||||
|
||||
def read_stdin_json() -> dict:
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def project_dir(data: dict | None = None) -> str:
|
||||
return os.environ.get("CLAUDE_PROJECT_DIR") or (data or {}).get("cwd") or os.getcwd()
|
||||
|
||||
|
||||
def git(args: list[str], cwd: str, timeout: float = 5.0) -> tuple[int, str]:
|
||||
try:
|
||||
p = subprocess.run(
|
||||
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
return p.returncode, p.stdout
|
||||
except Exception:
|
||||
return 1, ""
|
||||
82
.claude/hooks/auto-orchestrate.py
Executable file
82
.claude/hooks/auto-orchestrate.py
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""UserPromptSubmit hook: 작업성 요청이면 오케스트레이션 모드 지침을 컨텍스트로 주입한다.
|
||||
|
||||
- 화이트리스트(작업 동사) 방식: 확실한 작업 요청에만 반응해 오탐을 줄인다.
|
||||
- 이스케이프: 프롬프트에 #직접 또는 #no-orch 가 있으면 주입하지 않는다.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
ACTION = re.compile(
|
||||
r"(추가|수정|구현|개발|만들|생성|작성|고쳐|고치|리팩|변경|바꿔|바꾸|적용|삭제|제거|"
|
||||
r"최적화|개선|버그|디버그|테스트|백테스트|검증|배포|마이그|리뷰|점검|분석|튜닝|"
|
||||
r"보완|정리|통합|연동|설계|스캔|리팩터링|해결|처리해|작업|고도화|"
|
||||
r"\badd\b|\bimplement\b|\bfix\b|\brefactor\b|\bcreate\b|\bbuild\b|\bupdate\b|"
|
||||
r"\bremove\b|\bdelete\b|\boptimi[sz]e\b|\bmigrat|\bdebug\b|\btest\b|\bdeploy\b|"
|
||||
r"\breview\b|\banaly[sz]e\b)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
SKIP = re.compile(r"(#직접|#no-orch)", re.IGNORECASE)
|
||||
|
||||
# 서브에이전트 보고·시스템 알림에는 주입하지 않는다.
|
||||
NOTIFY_PREFIX = "Another Claude session sent a message"
|
||||
NOTIFY_MARKERS = ("<agent-message", "[Subagent hand-back]", "<task-notification>", "[SYSTEM NOTIFICATION")
|
||||
|
||||
# 강한 작업 동사(분석·점검 같은 약한 명사는 제외)
|
||||
STRONG = re.compile(
|
||||
r"(추가|수정|구현|개발|만들|생성|작성|고쳐|고치|리팩|변경|바꿔|바꾸|적용|삭제|제거|최적화|개선|"
|
||||
r"마이그|튜닝|보완|통합|연동|설계|해결|고도화|배포|"
|
||||
r"\badd\b|\bimplement\b|\bfix\b|\brefactor\b|\bcreate\b|\bbuild\b|\bupdate\b|\bremove\b|"
|
||||
r"\bdelete\b|\boptimi[sz]e\b|\bmigrat|\bdeploy\b)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# 질문형: 물음표 종결 / 의문 어미 / 정보 요청
|
||||
QUESTION = re.compile(
|
||||
r"(\?\s*$|(했어|됐어|됬어|했나|했니|했죠|했지|되었어|돼\s*있어|있어|없어|인가|일까|인지|나요|어요|까요|죠|니)\??\s*$|"
|
||||
r"(알려줘|알려주세요|알려줄래|보여줘|보여주세요|설명해줘?|설명해주세요|궁금\S*)\s*[.!]?\s*$)",
|
||||
)
|
||||
# 명시적 작업 요청 어미
|
||||
REQUEST = re.compile(r"(해줘|해\s*주세요|해주라|하세요|해라|합시다|부탁|진행해|시작해)")
|
||||
|
||||
CONTEXT = """[자동 오케스트레이션 모드]
|
||||
이 프롬프트는 작업성 요청으로 감지되었습니다. 답변/작업을 시작하기 전에 Skill 툴로 `orchestrate` 스킬을 호출하고, 그 지침에 따라 처리하십시오 (작업 분해 → 팀 에이전트 병렬/순차 분배 → code-verifier 검증 게이트 → 최종 보고). args 에는 사용자 요청 원문을 그대로 전달합니다.
|
||||
단, 스킬 지침 4번대로 하위 작업이 1개뿐이고 단순하면 오케스트레이션 오버헤드 없이 담당 에이전트 하나만 호출하거나 직접 처리해도 됩니다.
|
||||
사용자가 프롬프트에 #직접 을 넣으면 이 주입은 발생하지 않습니다."""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
prompt = (data.get("prompt") or data.get("user_prompt") or "").strip()
|
||||
|
||||
if not prompt or prompt.startswith("/") or len(prompt) < 6:
|
||||
return 0
|
||||
if prompt.startswith(NOTIFY_PREFIX) or any(m in prompt for m in NOTIFY_MARKERS):
|
||||
return 0
|
||||
if SKIP.search(prompt) or not ACTION.search(prompt):
|
||||
return 0
|
||||
if QUESTION.search(prompt) and not REQUEST.search(prompt.rstrip("?? ")) :
|
||||
# 질문형: 강한 작업 동사가 없거나 짧은 상태 확인("수정했어?")이면 주입하지 않는다.
|
||||
if not STRONG.search(prompt) or len(prompt) <= 40:
|
||||
return 0
|
||||
|
||||
json.dump(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": CONTEXT,
|
||||
},
|
||||
"suppressOutput": True,
|
||||
},
|
||||
sys.stdout,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
364
.claude/hooks/bash_guard.py
Executable file
364
.claude/hooks/bash_guard.py
Executable file
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PreToolUse(Bash) 가드 — 운영 안전장치를 사람이 아니라 훅이 강제한다.
|
||||
|
||||
deny : 로컬 스택 기동, 운영 DB 대상 alembic, _test 아닌 DB 로 pytest, --allow-network,
|
||||
main 강제 푸시, 레포 핵심 경로 rm -rf, 유닛 스위트 실패 상태의 backend 커밋
|
||||
ask : main 갱신 푸시 / deploy.sh 실행 (KST 시각·장중 경고 포함)
|
||||
파싱 실패는 조용히 통과(exit 0). 인용부호 안의 문자열은 명령으로 보지 않는다.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _lib import git, kst_now, market_state, project_dir, read_stdin_json # noqa: E402
|
||||
|
||||
WRAPPERS = {"sudo", "env", "time", "nohup", "command", "exec", "nice", "builtin"}
|
||||
SHELLS = {"bash", "sh", "zsh", "dash"}
|
||||
PYTEST_CMD_DEFAULT = (
|
||||
'cd backend && DATABASE_URL="postgresql+asyncpg://x:x@localhost/autostock_test" '
|
||||
"../.venv/bin/python -m pytest tests/ --ignore=tests/integration -q -x -p no:cacheprovider"
|
||||
)
|
||||
|
||||
|
||||
# ───────────────────────── 파싱 ─────────────────────────
|
||||
def _strip_heredocs(cmd: str) -> str:
|
||||
out, lines, delim = [], cmd.split("\n"), None
|
||||
for ln in lines:
|
||||
if delim is not None:
|
||||
if ln.strip() == delim:
|
||||
delim = None
|
||||
continue
|
||||
out.append(ln)
|
||||
m = re.search(r"<<-?\s*['\"]?(\w+)['\"]?", ln)
|
||||
if m:
|
||||
delim = m.group(1)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def split_segments(cmd: str) -> list[str]:
|
||||
"""따옴표를 존중하며 ; && || | & 줄바꿈 ( ) ` 로 분리."""
|
||||
segs, cur, q, i, n = [], [], None, 0, len(cmd)
|
||||
while i < n:
|
||||
c = cmd[i]
|
||||
if q:
|
||||
cur.append(c)
|
||||
if c == "\\" and q == '"' and i + 1 < n:
|
||||
cur.append(cmd[i + 1])
|
||||
i += 1
|
||||
elif c == q:
|
||||
q = None
|
||||
elif c == "\\" and i + 1 < n:
|
||||
cur.append(c + cmd[i + 1])
|
||||
i += 1
|
||||
elif c in "'\"":
|
||||
q = c
|
||||
cur.append(c)
|
||||
elif c in ";|&\n()`":
|
||||
segs.append("".join(cur))
|
||||
cur = []
|
||||
else:
|
||||
cur.append(c)
|
||||
i += 1
|
||||
segs.append("".join(cur))
|
||||
return [s.strip() for s in segs if s.strip()]
|
||||
|
||||
|
||||
def tokenize(seg: str) -> list[str]:
|
||||
try:
|
||||
return shlex.split(seg, posix=True)
|
||||
except ValueError:
|
||||
return seg.split()
|
||||
|
||||
|
||||
def split_assignments(argv: list[str]) -> tuple[dict, list[str]]:
|
||||
env, i = {}, 0
|
||||
while i < len(argv):
|
||||
t = argv[i]
|
||||
m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$", t)
|
||||
if m:
|
||||
env[m.group(1)] = m.group(2)
|
||||
i += 1
|
||||
elif t in WRAPPERS:
|
||||
i += 1
|
||||
elif t == "export" and i + 1 < len(argv):
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
return env, argv[i:]
|
||||
|
||||
|
||||
def base(tok: str) -> str:
|
||||
return os.path.basename(tok)
|
||||
|
||||
|
||||
# ───────────────────────── 판정 ─────────────────────────
|
||||
def deny(reason: str) -> dict:
|
||||
return {"decision": "deny", "reason": reason}
|
||||
|
||||
|
||||
def ask(reason: str) -> dict:
|
||||
return {"decision": "ask", "reason": reason}
|
||||
|
||||
|
||||
def is_pytest(argv: list[str]) -> bool:
|
||||
return any(base(t) in ("pytest", "py.test") for t in argv[:4]) or (
|
||||
"-m" in argv and "pytest" in argv
|
||||
)
|
||||
|
||||
|
||||
def is_prod_url(url: str) -> bool:
|
||||
low = url.lower()
|
||||
return any(k in low for k in ("nas", "15433", "autostock")) and not url.split("?")[0].endswith("_test")
|
||||
|
||||
|
||||
def check_local_stack(argv: list[str]) -> dict | None:
|
||||
b0 = base(argv[0]) if argv else ""
|
||||
names = [base(t) for t in argv]
|
||||
uvicorn_main = ("uvicorn" in names or b0 == "gunicorn") and any(
|
||||
t.startswith("app.main") or t.startswith("app:") for t in argv
|
||||
)
|
||||
py_dash_m = "-m" in argv and argv.index("-m") + 1 < len(argv) and argv[argv.index("-m") + 1].startswith(
|
||||
("app.main", "uvicorn", "gunicorn")
|
||||
)
|
||||
if uvicorn_main or py_dash_m:
|
||||
return deny(
|
||||
"로컬 백엔드 기동 금지: NAS 와 KIS 앱키를 공유하므로 토큰이 무효화된다"
|
||||
"(local-docker-removed-kis-token-conflict 메모리). 검증은 격리 pytest(_test DB)로만 하라."
|
||||
)
|
||||
if b0 == "fastapi" and any(t in ("run", "dev") for t in argv[1:3]):
|
||||
return deny("로컬 fastapi 기동 금지(KIS 토큰 충돌).")
|
||||
if b0 == "docker-compose" or (b0 == "docker" and "compose" in argv):
|
||||
start = argv.index("compose") if "compose" in argv else 0
|
||||
if any(t in ("up", "start", "restart") for t in argv[start + 1:]):
|
||||
return deny(
|
||||
"로컬 docker compose up/start/restart 금지: KIS 앱키 공유로 NAS 토큰이 무효화된다. "
|
||||
"배포는 /deploy 스킬(Jenkins), 검증은 격리 pytest."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def check_alembic(argv: list[str], env: dict) -> dict | None:
|
||||
if not (any(base(t) == "alembic" for t in argv) or ("-m" in argv and "alembic" in argv)):
|
||||
return None
|
||||
if not any(t in ("upgrade", "downgrade") for t in argv):
|
||||
return None
|
||||
if base(argv[0]) == "ssh":
|
||||
return None
|
||||
url = env.get("DATABASE_URL", os.environ.get("DATABASE_URL", ""))
|
||||
if url and is_prod_url(url):
|
||||
return deny("운영 DB(nas/15433/autostock, _test 아님)를 대상으로 로컬 alembic upgrade/downgrade 금지. 마이그레이션은 배포 파이프라인(Jenkins)이 앱 기동 전에 실행한다.")
|
||||
if not url:
|
||||
return ask("DATABASE_URL 미지정 alembic upgrade/downgrade — .env 가 운영 DB 를 가리킬 수 있다. 대상 DB 가 *_test 인지 확인했는가?")
|
||||
return None
|
||||
|
||||
|
||||
def check_pytest(argv: list[str], env: dict) -> dict | None:
|
||||
if not is_pytest(argv):
|
||||
return None
|
||||
if "--allow-network" in argv:
|
||||
return deny("--allow-network 금지: KIS 호출 차단을 끄면 실주문이 나갈 수 있다(CLAUDE.md §4.1, 2026-09-27 실주문 2회 사고).")
|
||||
if any(".claude/hooks" in t for t in argv):
|
||||
return None # 하네스 훅 자체 테스트는 DB 무관
|
||||
url = env.get("DATABASE_URL", os.environ.get("DATABASE_URL", ""))
|
||||
if not url.split("?")[0].endswith("_test"):
|
||||
return deny(
|
||||
"pytest 는 DATABASE_URL 이 *_test 로 끝나야 한다(CLAUDE.md §4.1). 예: "
|
||||
'cd backend && DATABASE_URL="postgresql+asyncpg://x:x@localhost/autostock_test" '
|
||||
"../.venv/bin/python -m pytest tests/ --ignore=tests/integration"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def check_git_push(argv: list[str], cwd: str) -> dict | None:
|
||||
if base(argv[0]) != "git" or "push" not in argv:
|
||||
return None
|
||||
args = argv[argv.index("push") + 1:]
|
||||
force = any(a in ("-f", "--force", "--force-with-lease") or a.startswith("--force-with-lease=") for a in args) \
|
||||
or any(re.match(r"^-[a-zA-Z]*f[a-zA-Z]*$", a) for a in args)
|
||||
pos = [a for a in args if not a.startswith("-")]
|
||||
refspecs = pos[1:] if len(pos) > 1 else []
|
||||
to_main = False
|
||||
for r in refspecs:
|
||||
r2 = r.lstrip("+")
|
||||
dst = r2.split(":")[-1]
|
||||
if dst in ("main", "refs/heads/main"):
|
||||
to_main = True
|
||||
if r.startswith("+") and dst in ("main", "refs/heads/main"):
|
||||
force = True
|
||||
if "--delete" in args or "-d" in args:
|
||||
if any(r in ("main", "refs/heads/main") for r in refspecs):
|
||||
return deny("main 브랜치 삭제 푸시 금지.")
|
||||
if not refspecs: # git push / git push origin → 현재 브랜치
|
||||
_, br = git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)
|
||||
if br.strip() == "main":
|
||||
to_main = True
|
||||
if force and to_main:
|
||||
return deny("main 강제 푸시(--force/-f/+ref) 금지.")
|
||||
if to_main:
|
||||
return ask(deploy_reason("main 푸시 = 운영 배포(Jenkins 재기동)"))
|
||||
return None
|
||||
|
||||
|
||||
def deploy_reason(prefix: str) -> str:
|
||||
now = kst_now()
|
||||
st = market_state(now)
|
||||
msg = f"{prefix}. 현재 KST {now:%Y-%m-%d %H:%M} ({'월화수목금토일'[now.weekday()]}) {st}."
|
||||
if st == "장중":
|
||||
msg += " ⚠ 장중 배포 — 재기동이 매매에 영향(DT 포지션·스케줄러 상태 유실, 토큰 재발급)."
|
||||
return msg
|
||||
|
||||
|
||||
def check_deploy_script(argv: list[str]) -> dict | None:
|
||||
if any(t.endswith("deploy.sh") for t in argv[:3]) and "--watch-only" not in argv:
|
||||
return ask(deploy_reason("deploy.sh 실행(원격 main 푸시 포함)"))
|
||||
return None
|
||||
|
||||
|
||||
def _resolve(path: str, cwd: str) -> str:
|
||||
p = os.path.expandvars(os.path.expanduser(path))
|
||||
if p.startswith("$"):
|
||||
p = cwd
|
||||
return os.path.realpath(p if os.path.isabs(p) else os.path.join(cwd, p))
|
||||
|
||||
|
||||
def check_rm(argv: list[str], cwd: str, root: str) -> dict | None:
|
||||
if base(argv[0]) != "rm":
|
||||
return None
|
||||
flags = [a for a in argv[1:] if a.startswith("-")]
|
||||
recursive = any(a in ("--recursive",) or re.match(r"^-[a-zA-Z]*[rR][a-zA-Z]*$", a) for a in flags)
|
||||
if not recursive:
|
||||
return None
|
||||
protected = {os.path.realpath(root), os.path.realpath(os.path.join(root, "backend")),
|
||||
os.path.realpath(os.path.join(root, ".git"))}
|
||||
for t in argv[1:]:
|
||||
if t.startswith("-"):
|
||||
continue
|
||||
has_glob = any(ch in os.path.basename(t) for ch in "*?[")
|
||||
target = _resolve(os.path.dirname(t) or ".", cwd) if has_glob else _resolve(t, cwd)
|
||||
anc = os.path.realpath(root).startswith(target.rstrip("/") + "/") or target == "/"
|
||||
if target in protected or anc:
|
||||
return deny(f"rm -rf 대상 '{t}' 가 레포 루트/backend/.git(또는 그 상위)이다. 삭제 금지.")
|
||||
return None
|
||||
|
||||
|
||||
def commit_gate(cmd_segments: list[tuple[list[str], int]], cwd: str) -> dict | None:
|
||||
commit_idx = None
|
||||
for idx, (argv, _) in enumerate(cmd_segments):
|
||||
if base(argv[0]) == "git" and "commit" in argv[1:]:
|
||||
commit_idx = idx
|
||||
break
|
||||
if commit_idx is None:
|
||||
return None
|
||||
argv = cmd_segments[commit_idx][0]
|
||||
rc, top = git(["rev-parse", "--show-toplevel"], cwd)
|
||||
if rc != 0:
|
||||
return None
|
||||
root = top.strip()
|
||||
preceded_by_add = any(base(a[0]) == "git" and "add" in a[1:] for a, _ in cmd_segments[:commit_idx])
|
||||
commit_all = any(
|
||||
a == "--all" or (a.startswith("-") and not a.startswith("--") and "a" in a[1:])
|
||||
for a in argv[argv.index("commit") + 1:]
|
||||
)
|
||||
if preceded_by_add or commit_all:
|
||||
_, out = git(["status", "--porcelain", "--", "backend"], root)
|
||||
touched = bool(out.strip())
|
||||
else:
|
||||
_, out = git(["diff", "--cached", "--name-only"], root)
|
||||
touched = any(l.startswith("backend/") for l in out.splitlines())
|
||||
if not touched:
|
||||
return None
|
||||
cmd = os.environ.get("HARNESS_PYTEST_CMD")
|
||||
if not cmd:
|
||||
if not os.path.exists(os.path.join(root, ".venv", "bin", "python")):
|
||||
sys.stderr.write("[bash_guard] .venv 없음 — 커밋 게이트(유닛 스위트) 생략\n")
|
||||
return {"decision": "warn", "reason": ".venv 가 없어 커밋 전 유닛 스위트를 건너뛰었다. 직접 검증하라."}
|
||||
cmd = PYTEST_CMD_DEFAULT
|
||||
try:
|
||||
p = subprocess.run(["bash", "-c", cmd], cwd=root, capture_output=True, text=True, timeout=120)
|
||||
except subprocess.TimeoutExpired:
|
||||
return deny("커밋 게이트: 유닛 스위트가 120초 내 끝나지 않았다(실 KIS 호출 의심, CLAUDE.md §4.1). 커밋 보류.")
|
||||
except Exception as e: # noqa: BLE001
|
||||
sys.stderr.write(f"[bash_guard] 게이트 실행 오류 {e!r} — 통과\n")
|
||||
return None
|
||||
if p.returncode != 0:
|
||||
tail = "\n".join((p.stdout + "\n" + p.stderr).strip().splitlines()[-25:])
|
||||
return deny(f"커밋 게이트: backend 변경이 스테이징돼 있는데 유닛 스위트 실패(rc={p.returncode}). 고친 뒤 다시 커밋.\n--- tail ---\n{tail}")
|
||||
return None
|
||||
|
||||
|
||||
# ───────────────────────── 메인 ─────────────────────────
|
||||
def evaluate(cmd: str, cwd: str, root: str) -> dict | None:
|
||||
cmd = _strip_heredocs(cmd)
|
||||
results: list[dict] = []
|
||||
env_acc: dict = {}
|
||||
parsed: list[tuple[list[str], int]] = []
|
||||
|
||||
def handle(argv_all: list[str], depth: int = 0) -> None:
|
||||
env, argv = split_assignments(argv_all)
|
||||
if not argv:
|
||||
env_acc.update(env) # 'A=b' 단독 또는 export A=b
|
||||
return
|
||||
eff = {**env_acc, **env}
|
||||
if base(argv[0]) in SHELLS and "-c" in argv[1:] and depth < 3:
|
||||
i = argv.index("-c")
|
||||
if i + 1 < len(argv):
|
||||
for s in split_segments(argv[i + 1]):
|
||||
handle(tokenize(s), depth + 1)
|
||||
return
|
||||
if base(argv[0]) == "ssh":
|
||||
return # 원격 실행은 로컬 가드 대상 아님
|
||||
parsed.append((argv, depth))
|
||||
for fn in (check_local_stack, ):
|
||||
r = fn(argv)
|
||||
if r:
|
||||
results.append(r)
|
||||
for r in (check_alembic(argv, eff), check_pytest(argv, eff), check_git_push(argv, cwd),
|
||||
check_rm(argv, cwd, root), check_deploy_script(argv)):
|
||||
if r:
|
||||
results.append(r)
|
||||
|
||||
for seg in split_segments(cmd):
|
||||
handle(tokenize(seg))
|
||||
|
||||
g = commit_gate(parsed, cwd)
|
||||
if g:
|
||||
results.append(g)
|
||||
for kind in ("deny", "ask", "warn"):
|
||||
hit = [r for r in results if r["decision"] == kind]
|
||||
if hit:
|
||||
return {"decision": kind, "reason": " | ".join(r["reason"] for r in hit)}
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
data = read_stdin_json()
|
||||
if data.get("tool_name") not in (None, "Bash"):
|
||||
return 0
|
||||
cmd = (data.get("tool_input") or {}).get("command")
|
||||
if not isinstance(cmd, str) or not cmd.strip():
|
||||
return 0
|
||||
cwd = data.get("cwd") or os.getcwd()
|
||||
try:
|
||||
res = evaluate(cmd, cwd, project_dir(data))
|
||||
except Exception as e: # noqa: BLE001 훅 자체 오류는 통과
|
||||
sys.stderr.write(f"[bash_guard] internal error {e!r}\n")
|
||||
return 0
|
||||
if not res:
|
||||
return 0
|
||||
if res["decision"] == "warn":
|
||||
json.dump({"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": res["reason"]}}, sys.stdout, ensure_ascii=False)
|
||||
return 0
|
||||
json.dump(
|
||||
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": res["decision"],
|
||||
"permissionDecisionReason": f"[harness] {res['reason']}"}},
|
||||
sys.stdout, ensure_ascii=False,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
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())
|
||||
55
.claude/hooks/session_context.py
Executable file
55
.claude/hooks/session_context.py
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SessionStart — 브랜치·미커밋·origin/main 대비·KST 장 상태·alembic head·3줄 요약 주입."""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _lib import git, kst_now, market_state, project_dir, read_stdin_json # noqa: E402
|
||||
|
||||
|
||||
def alembic_head(root: str) -> str:
|
||||
revs: dict[str, str | None] = {}
|
||||
for f in glob.glob(os.path.join(root, "backend/alembic/versions/*.py")):
|
||||
try:
|
||||
t = open(f, encoding="utf-8").read(4000)
|
||||
except OSError:
|
||||
continue
|
||||
r = re.search(r"^revision\s*(?::[^=]+)?=\s*['\"]([^'\"]+)['\"]", t, re.M)
|
||||
d = re.search(r"^down_revision\s*(?::[^=]+)?=\s*(.+)$", t, re.M)
|
||||
if r:
|
||||
revs[r.group(1)] = d.group(1) if d else None
|
||||
downs = set()
|
||||
for d in revs.values():
|
||||
if d:
|
||||
downs.update(re.findall(r"['\"]([^'\"]+)['\"]", d))
|
||||
heads = [r for r in revs if r not in downs]
|
||||
return ", ".join(sorted(heads)) or "?"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
data = read_stdin_json()
|
||||
root = project_dir(data)
|
||||
_, br = git(["rev-parse", "--abbrev-ref", "HEAD"], root)
|
||||
_, st = git(["status", "--porcelain"], root)
|
||||
dirty = len([l for l in st.splitlines() if l.strip()])
|
||||
rc, ahead = git(["rev-list", "--count", "origin/main..HEAD"], root)
|
||||
rc2, behind = git(["rev-list", "--count", "HEAD..origin/main"], root)
|
||||
now = kst_now()
|
||||
ctx = (
|
||||
f"[auto-stock 세션 상태] 브랜치 {br.strip() or '?'} / 미커밋 {dirty}개 / "
|
||||
f"origin/main 대비 앞섬 {ahead.strip() if rc == 0 else '?'}·뒤처짐 {behind.strip() if rc2 == 0 else '?'}(로컬 ref 기준) / "
|
||||
f"KST {now:%Y-%m-%d %H:%M} {'월화수목금토일'[now.weekday()]}요일 {market_state(now)} / alembic head {alembic_head(root)}\n"
|
||||
"1) 로컬 백엔드·docker compose up 기동 금지(NAS 와 KIS 앱키 공유 → 토큰 무효화). 검증은 격리 pytest 만.\n"
|
||||
"2) 배포는 /deploy 스킬(.claude/scripts/deploy.sh) — 장중(평일 08:30~15:40)은 재기동이 매매에 영향.\n"
|
||||
'3) 테스트: cd backend && DATABASE_URL="postgresql+asyncpg://x:x@localhost/autostock_test" '
|
||||
"../.venv/bin/python -m pytest tests/ --ignore=tests/integration -q (DB 이름 *_test 필수)"
|
||||
)
|
||||
json.dump({"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": ctx}}, sys.stdout, ensure_ascii=False)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
57
.claude/hooks/stop_check.py
Executable file
57
.claude/hooks/stop_check.py
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stop — frontend/src 미커밋 변경이 직전 tsc 통과 이후 바뀌었으면 npx tsc --noEmit (90초)."""
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _lib import git, project_dir, read_stdin_json # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
data = read_stdin_json()
|
||||
if data.get("stop_hook_active"):
|
||||
return 0
|
||||
root = project_dir(data)
|
||||
_, st = git(["status", "--porcelain", "--", "frontend/src"], root)
|
||||
files = [l[3:].strip() for l in st.splitlines() if l.strip()]
|
||||
if not files:
|
||||
return 0
|
||||
h = hashlib.sha1()
|
||||
for f in sorted(files):
|
||||
p = os.path.join(root, f)
|
||||
try:
|
||||
h.update(f.encode() + str(os.stat(p).st_mtime_ns).encode())
|
||||
except OSError:
|
||||
h.update(f.encode() + b"deleted")
|
||||
digest = h.hexdigest()
|
||||
stamp = os.path.join(os.environ.get("HARNESS_STATE_DIR", os.path.join(root, ".claude")), ".tsc_stamp")
|
||||
try:
|
||||
if open(stamp).read().strip() == digest:
|
||||
return 0
|
||||
except OSError:
|
||||
pass
|
||||
fe = os.path.join(root, "frontend")
|
||||
cmd = os.environ.get("HARNESS_TSC_CMD")
|
||||
argv = ["bash", "-c", cmd] if cmd else ["npx", "--no-install", "tsc", "--noEmit"]
|
||||
try:
|
||||
p = subprocess.run(argv, cwd=fe, capture_output=True, text=True, timeout=90)
|
||||
except subprocess.TimeoutExpired:
|
||||
sys.stderr.write("[harness] tsc 90초 초과 — 건너뜀\n")
|
||||
return 0
|
||||
except Exception:
|
||||
return 0
|
||||
if p.returncode != 0:
|
||||
tail = "\n".join((p.stdout + p.stderr).strip().splitlines()[:30])
|
||||
sys.stderr.write(f"[harness] frontend 타입 체크 실패(tsc --noEmit). 수정 후 마치라:\n{tail}\n")
|
||||
return 2
|
||||
try:
|
||||
open(stamp, "w").write(digest)
|
||||
except OSError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
282
.claude/hooks/tests/test_hooks.py
Normal file
282
.claude/hooks/tests/test_hooks.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""하네스 훅 테스트 — 샘플 stdin JSON 으로 deny/ask/allow/exit2 를 검증한다. DB·네트워크 불필요."""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HOOKS = Path(__file__).resolve().parents[1]
|
||||
ROOT = HOOKS.parents[1]
|
||||
|
||||
|
||||
def run(script, payload, env=None, cwd=None):
|
||||
e = {**os.environ, "CLAUDE_PROJECT_DIR": str(ROOT), **(env or {})}
|
||||
p = subprocess.run([sys.executable, str(HOOKS / script)], input=json.dumps(payload) if not isinstance(payload, str) else payload,
|
||||
capture_output=True, text=True, env=e, cwd=cwd or ROOT, timeout=60)
|
||||
return p
|
||||
|
||||
|
||||
def bash(cmd, env=None):
|
||||
p = run("bash_guard.py", {"tool_name": "Bash", "tool_input": {"command": cmd}, "cwd": str(ROOT)},
|
||||
env={"DATABASE_URL": "", **(env or {})})
|
||||
assert p.returncode == 0, p.stderr
|
||||
if not p.stdout.strip():
|
||||
return None, ""
|
||||
h = json.loads(p.stdout)["hookSpecificOutput"]
|
||||
return h.get("permissionDecision"), h.get("permissionDecisionReason", h.get("additionalContext", ""))
|
||||
|
||||
|
||||
TEST_URL = "postgresql+asyncpg://x:x@localhost/autostock_test"
|
||||
|
||||
|
||||
# ── bash_guard: deny ──
|
||||
@pytest.mark.parametrize("cmd", [
|
||||
"docker compose up -d --build",
|
||||
"docker-compose up",
|
||||
"docker compose -f docker-compose.dev.yml up backend",
|
||||
"cd backend && uvicorn app.main:app --reload",
|
||||
"python -m app.main",
|
||||
"python3 -m uvicorn app.main:app",
|
||||
"DATABASE_URL=postgresql://u:p@nas.shium.net:15433/autostock alembic upgrade head",
|
||||
"export DATABASE_URL=postgresql://u:p@localhost:15433/autostock && alembic downgrade -1",
|
||||
"cd backend && python -m pytest tests/",
|
||||
"DATABASE_URL=postgresql://u:p@h/autostock python -m pytest tests/",
|
||||
f"DATABASE_URL={TEST_URL} python -m pytest tests/ --allow-network",
|
||||
"git push --force origin main",
|
||||
"git push -f origin HEAD:main",
|
||||
"git push origin +dev:main",
|
||||
"rm -rf backend",
|
||||
"rm -rf .git",
|
||||
"rm -rf ./*",
|
||||
"sudo rm -fr /Users/commylife/myproject/java/workspace/auto-stock",
|
||||
"bash -c 'docker compose up'",
|
||||
])
|
||||
def test_deny(cmd):
|
||||
d, reason = bash(cmd)
|
||||
assert d == "deny", (cmd, d, reason)
|
||||
|
||||
|
||||
# ── bash_guard: 통과 ──
|
||||
@pytest.mark.parametrize("cmd", [
|
||||
"git status",
|
||||
"docker compose build",
|
||||
"docker compose logs -f backend",
|
||||
'git commit -m "docker compose up 금지 문서 추가"',
|
||||
"echo 'uvicorn app.main:app'",
|
||||
"grep -rn 'docker compose up' CLAUDE.md",
|
||||
f"DATABASE_URL={TEST_URL} python -m pytest tests/ -q",
|
||||
"python3 -m pytest .claude/hooks/tests -q",
|
||||
"git push origin dev",
|
||||
"git push --force origin dev",
|
||||
"rm -rf /tmp/some-dir",
|
||||
"rm -rf backend/__pycache__",
|
||||
"ssh -p 2082 nas 'docker compose up -d'",
|
||||
"cat <<'EOF'\ndocker compose up\nEOF",
|
||||
"alembic current",
|
||||
])
|
||||
def test_allow(cmd):
|
||||
d, _ = bash(cmd)
|
||||
assert d is None, (cmd, d)
|
||||
|
||||
|
||||
def test_alembic_test_db_ok_and_unset_asks():
|
||||
assert bash(f"DATABASE_URL={TEST_URL} alembic upgrade head")[0] is None
|
||||
assert bash("alembic upgrade head")[0] == "ask"
|
||||
|
||||
|
||||
# ── bash_guard: ask (main 푸시) ──
|
||||
@pytest.mark.parametrize("cmd", ["git push origin HEAD:main", "git push origin main", "bash .claude/scripts/deploy.sh -m x"])
|
||||
def test_ask_main_push(cmd):
|
||||
d, reason = bash(cmd, env={"HARNESS_NOW_KST": "2026-10-02T10:00"}) # 금요일 장중
|
||||
assert d == "ask"
|
||||
assert "장중 배포" in reason and "10:00" in reason
|
||||
|
||||
|
||||
def test_ask_off_hours_no_warning():
|
||||
d, reason = bash("git push origin HEAD:main", env={"HARNESS_NOW_KST": "2026-10-03T10:00"}) # 토요일
|
||||
assert d == "ask" and "장중 배포" not in reason and "휴장-주말" in reason
|
||||
|
||||
|
||||
def test_watch_only_not_asked():
|
||||
assert bash("bash .claude/scripts/deploy.sh --watch-only")[0] is None
|
||||
|
||||
|
||||
# ── 커밋 게이트 (pytest 를 HARNESS_PYTEST_CMD 로 모킹) ──
|
||||
@pytest.fixture
|
||||
def repo(tmp_path):
|
||||
def g(*a):
|
||||
subprocess.run(["git", *a], cwd=tmp_path, check=True, capture_output=True,
|
||||
env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"})
|
||||
g("init", "-q")
|
||||
(tmp_path / "backend").mkdir()
|
||||
(tmp_path / "backend/a.py").write_text("x=1\n")
|
||||
(tmp_path / "README.md").write_text("r\n")
|
||||
g("add", "."); g("commit", "-qm", "init")
|
||||
return tmp_path, g
|
||||
|
||||
|
||||
def commit_hook(repo_dir, env):
|
||||
p = run("bash_guard.py", {"tool_name": "Bash", "tool_input": {"command": 'git commit -m "x"'}, "cwd": str(repo_dir)},
|
||||
env={"CLAUDE_PROJECT_DIR": str(repo_dir), "DATABASE_URL": "", **env}, cwd=repo_dir)
|
||||
assert p.returncode == 0, p.stderr
|
||||
return json.loads(p.stdout)["hookSpecificOutput"] if p.stdout.strip() else None
|
||||
|
||||
|
||||
def test_commit_gate_fail_denies_with_tail(repo):
|
||||
d, g = repo
|
||||
(d / "backend/a.py").write_text("x=2\n"); g("add", "backend/a.py")
|
||||
out = commit_hook(d, {"HARNESS_PYTEST_CMD": "echo 'FAILED tests/test_x.py::t - boom'; exit 1"})
|
||||
assert out["permissionDecision"] == "deny" and "boom" in out["permissionDecisionReason"]
|
||||
|
||||
|
||||
def test_commit_gate_pass(repo):
|
||||
d, g = repo
|
||||
(d / "backend/a.py").write_text("x=2\n"); g("add", "backend/a.py")
|
||||
assert commit_hook(d, {"HARNESS_PYTEST_CMD": "exit 0"}) is None
|
||||
|
||||
|
||||
def test_commit_gate_skips_non_backend(repo):
|
||||
d, g = repo
|
||||
(d / "README.md").write_text("changed\n"); g("add", "README.md")
|
||||
assert commit_hook(d, {"HARNESS_PYTEST_CMD": "exit 1"}) is None # 실행됐다면 deny 였을 것
|
||||
|
||||
|
||||
def test_commit_gate_no_venv_warns(repo):
|
||||
d, g = repo
|
||||
(d / "backend/a.py").write_text("x=3\n"); g("add", "backend/a.py")
|
||||
out = commit_hook(d, {}) # .venv 없음
|
||||
assert out and "permissionDecision" not in out and ".venv" in out["additionalContext"]
|
||||
|
||||
|
||||
# ── rules_check ──
|
||||
@pytest.fixture
|
||||
def proj(tmp_path):
|
||||
def g(*a):
|
||||
subprocess.run(["git", *a], cwd=tmp_path, check=True, capture_output=True,
|
||||
env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"})
|
||||
g("init", "-q")
|
||||
for sub in ("backend/app/services/daytrading", "backend/app/services", "backend/tests"):
|
||||
(tmp_path / sub).mkdir(parents=True, exist_ok=True)
|
||||
old = tmp_path / "backend/app/services/old.py"
|
||||
old.write_text("import json\nx = json.dumps({})\n") # 기존 위반
|
||||
rt = tmp_path / "backend/app/services/daytrading/exit_service.py"
|
||||
rt.write_text("async def f():\n pass\n")
|
||||
g("add", "."); g("commit", "-qm", "init")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def rules(proj, rel, content):
|
||||
f = proj / rel
|
||||
f.write_text(content)
|
||||
return run("rules_check.py", {"tool_name": "Edit", "tool_input": {"file_path": str(f)}, "cwd": str(proj)},
|
||||
env={"CLAUDE_PROJECT_DIR": str(proj)}, cwd=proj)
|
||||
|
||||
|
||||
def test_rules_pre_existing_not_reported(proj):
|
||||
p = rules(proj, "backend/app/services/old.py", "import json\nx = json.dumps({})\ny = 1\n")
|
||||
assert p.returncode == 0, p.stderr
|
||||
|
||||
|
||||
def test_rules_new_json_dumps(proj):
|
||||
p = rules(proj, "backend/app/services/old.py", "import json\nx = json.dumps({})\ny = json.dumps([])\n")
|
||||
assert p.returncode == 2 and "old.py:3" in p.stderr and "§4" in p.stderr
|
||||
|
||||
|
||||
def test_rules_compile_error(proj):
|
||||
p = rules(proj, "backend/app/services/new.py", "def (:\n")
|
||||
assert p.returncode == 2 and "py_compile" in p.stderr
|
||||
|
||||
|
||||
def test_rules_except_debug_and_pass(proj):
|
||||
src = "import logging\nlogger = logging.getLogger()\ndef f():\n try:\n 1\n except ValueError as e:\n logger.debug('x')\n try:\n 1\n except Exception:\n pass\n"
|
||||
p = rules(proj, "backend/app/services/new.py", src)
|
||||
assert p.returncode == 2 and "logger.debug" in p.stderr and "except Exception: pass" in p.stderr
|
||||
assert "new.py:7" in p.stderr and "new.py:10" in p.stderr
|
||||
|
||||
|
||||
def test_rules_warning_level_ok(proj):
|
||||
src = "import logging\nlogger = logging.getLogger()\ndef f():\n try:\n 1\n except ValueError as e:\n logger.warning('x')\n"
|
||||
assert rules(proj, "backend/app/services/new.py", src).returncode == 0
|
||||
|
||||
|
||||
def test_rules_realtime_db(proj):
|
||||
rel = "backend/app/services/daytrading/exit_service.py"
|
||||
bad = "async def f():\n async with AsyncSessionLocal() as s:\n await s.commit()\n"
|
||||
p = rules(proj, rel, bad)
|
||||
assert p.returncode == 2 and "AsyncSessionLocal(" in p.stderr and "commit()" in p.stderr
|
||||
ok = "async def close_stale_positions():\n async with AsyncSessionLocal() as s:\n pass\n"
|
||||
assert rules(proj, rel, ok).returncode == 0
|
||||
marker = "async def g():\n # DB-READ-EXCEPTION: SELECT 전용\n async with AsyncSessionLocal() as s:\n pass\n"
|
||||
assert rules(proj, rel, marker).returncode == 0
|
||||
|
||||
|
||||
def test_rules_ignores_tests_and_non_backend(proj):
|
||||
assert rules(proj, "backend/tests/test_a.py", "import json\njson.dumps(1)\n").returncode == 0
|
||||
(proj / "x.py").write_text("json.dumps(1)\n")
|
||||
p = run("rules_check.py", {"tool_input": {"file_path": str(proj / "x.py")}}, env={"CLAUDE_PROJECT_DIR": str(proj)})
|
||||
assert p.returncode == 0
|
||||
|
||||
|
||||
def test_hooks_tolerate_garbage_stdin():
|
||||
for s in ("bash_guard.py", "rules_check.py", "stop_check.py", "auto-orchestrate.py", "session_context.py"):
|
||||
assert run(s, "not json{{{").returncode == 0
|
||||
|
||||
|
||||
# ── session_context ──
|
||||
def test_session_context():
|
||||
p = run("session_context.py", {}, env={"HARNESS_NOW_KST": "2026-10-02T10:00"})
|
||||
assert p.returncode == 0
|
||||
ctx = json.loads(p.stdout)["hookSpecificOutput"]["additionalContext"]
|
||||
assert "장중" in ctx and "alembic head" in ctx and "금지" in ctx and "/deploy" in ctx and "pytest" in ctx
|
||||
|
||||
|
||||
# ── stop_check ──
|
||||
def test_stop_active_passes():
|
||||
assert run("stop_check.py", {"stop_hook_active": True}).returncode == 0
|
||||
|
||||
|
||||
def test_stop_tsc_fail_then_stamp(proj, tmp_path):
|
||||
(proj / "frontend/src").mkdir(parents=True)
|
||||
(proj / "frontend/src/a.ts").write_text("x\n")
|
||||
st = tmp_path / "state"; st.mkdir()
|
||||
env = {"CLAUDE_PROJECT_DIR": str(proj), "HARNESS_STATE_DIR": str(st), "HARNESS_TSC_CMD": "echo 'a.ts(1,1): error TS1'; exit 2"}
|
||||
p = run("stop_check.py", {}, env=env, cwd=proj)
|
||||
assert p.returncode == 2 and "TS1" in p.stderr
|
||||
env["HARNESS_TSC_CMD"] = "exit 0"
|
||||
assert run("stop_check.py", {}, env=env, cwd=proj).returncode == 0
|
||||
env["HARNESS_TSC_CMD"] = "exit 2" # stamp 일치 → tsc 재실행 안 함
|
||||
assert run("stop_check.py", {}, env=env, cwd=proj).returncode == 0
|
||||
|
||||
|
||||
# ── auto-orchestrate ──
|
||||
def orch(prompt):
|
||||
p = run("auto-orchestrate.py", {"prompt": prompt})
|
||||
assert p.returncode == 0
|
||||
return bool(p.stdout.strip())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt,expected", [
|
||||
("데이트레이딩 갭 필터 추가해줘", True),
|
||||
("스윙 청산 버그 고쳐줘", True),
|
||||
("손절 로직 수정 #직접", False),
|
||||
("Another Claude session sent a message: 수정 완료", False),
|
||||
("<agent-message>구현 끝 수정</agent-message>", False),
|
||||
("[Subagent hand-back] 변경 파일 목록 수정", False),
|
||||
("<task-notification>build 수정</task-notification>", False),
|
||||
("[SYSTEM NOTIFICATION] 배포 완료", False),
|
||||
("BULL 진입 RSI 수정했어?", False),
|
||||
("이 로그 분석해서 알려줘", False),
|
||||
("배포 상태 알려줘", False),
|
||||
("/deploy", False),
|
||||
])
|
||||
def test_orchestrate(prompt, expected):
|
||||
assert orch(prompt) is expected
|
||||
|
||||
|
||||
# ── 스크립트 문법 ──
|
||||
@pytest.mark.parametrize("sh", ["deploy.sh", "prod_smoke.sh"])
|
||||
def test_scripts_syntax(sh):
|
||||
assert subprocess.run(["bash", "-n", str(ROOT / ".claude/scripts" / sh)]).returncode == 0
|
||||
Reference in New Issue
Block a user