- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
438 lines
19 KiB
Python
438 lines
19 KiB
Python
"""하네스 훅 테스트 — 샘플 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'",
|
|
"C=docker; $C compose up -d",
|
|
"C=docker; ${C} compose restart",
|
|
"python -c \"import uvicorn; uvicorn.run('app.main:app')\"",
|
|
"cd backend && ../.venv/bin/python -c 'from app.main import app; import uvicorn; uvicorn.run(app)'",
|
|
"eval 'docker compose up -d'",
|
|
'bash -lc "cd backend && uvicorn app.main:app --port 8000"',
|
|
"zsh -ic 'docker-compose up'",
|
|
"sh -ec 'docker compose start'",
|
|
"docker compose run --rm backend alembic upgrade head",
|
|
"docker compose exec backend bash",
|
|
"make up", "make dev", "make deploy", "make restart",
|
|
"./scripts/dev.sh", "bash scripts/restart.sh", "./scripts/start_all.sh", "./scripts/deploy.sh",
|
|
"git push origin :main",
|
|
"find . -delete",
|
|
"find backend -name '*.py' -delete",
|
|
"find . -name x -exec rm -rf {} +",
|
|
"find .git -delete",
|
|
"git push origin --delete main",
|
|
"git push --delete origin main",
|
|
])
|
|
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",
|
|
"docker compose ps",
|
|
"find backend/__pycache__ -delete",
|
|
"find node_modules -name '*.tmp' -delete",
|
|
"find . -name '*.py'",
|
|
"make help", "make logs", "make logs-backend", "make status",
|
|
"bash .claude/scripts/deploy.sh --watch-only",
|
|
"python -c 'print(1)'",
|
|
"ssh nas 'docker compose run --rm backend alembic upgrade head'",
|
|
])
|
|
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_reason_filters_noise(repo):
|
|
d, g = repo
|
|
(d / "backend/a.py").write_text("x=5\n"); g("add", "backend/a.py")
|
|
cmd = "for i in $(seq 1 40); do echo 'DeprecationWarning noise'; done; echo 'FAILED t.py::t - boom'; echo '1 failed, 3 passed in 0.1s'; exit 1"
|
|
out = commit_hook(d, {"HARNESS_PYTEST_CMD": cmd})
|
|
r = out["permissionDecisionReason"]
|
|
assert "FAILED t.py::t" in r and "1 failed, 3 passed" in r and "DeprecationWarning" not in r
|
|
|
|
|
|
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_allow_markers(proj):
|
|
rel = "backend/app/services/new.py"
|
|
base = "import json, logging\nlogger = logging.getLogger()\n"
|
|
# json.dumps: 같은 줄 / 직전 줄 마커 허용, 다른 종류 마커·무마커는 위반
|
|
assert rules(proj, rel, base + "x = json.dumps({}) # harness: allow-json-dumps DLQ 파일 기록\n").returncode == 0
|
|
assert rules(proj, rel, base + "# harness: allow-json-dumps DLQ 파일 기록\nx = json.dumps({})\n").returncode == 0
|
|
assert rules(proj, rel, base + "# harness: allow-except-pass 사유\nx = json.dumps({})\n").returncode == 2
|
|
assert rules(proj, rel, base + "x = json.dumps({})\n").returncode == 2
|
|
# except-pass: except 직전 줄 / pass 줄 마커 허용
|
|
body = "def f():\n try:\n 1\n"
|
|
assert rules(proj, rel, base + body + " # harness: allow-except-pass 종료 정리\n except Exception:\n pass\n").returncode == 0
|
|
assert rules(proj, rel, base + body + " except Exception:\n pass # harness: allow-except-pass 종료 정리\n").returncode == 0
|
|
assert rules(proj, rel, base + body + " except Exception:\n pass\n").returncode == 2
|
|
# except-pass 마커는 logger.debug 위반을 면제하지 않는다
|
|
assert rules(proj, rel, base + body + " except Exception: # harness: allow-except-pass x\n logger.debug('x')\n").returncode == 2
|
|
|
|
|
|
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
|
|
|
|
|
|
# ── 운영 스모크: 인증 필수 엔드포인트는 401 이 정상, 키 없이 200 은 노출 회귀 (2026-10-02 H-1) ──
|
|
def _smoke_against(status_by_path, health=None):
|
|
"""로컬 스텁 HTTP 서버를 PROD_BASE_URL 로 두고 prod_smoke.sh 실행 (외부 네트워크 0)."""
|
|
import http.server
|
|
import threading
|
|
|
|
class H(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
if self.path == "/health" and health is not None:
|
|
import json as _j
|
|
code, body = 200, _j.dumps(health).encode()
|
|
else:
|
|
code = status_by_path.get(self.path, 404)
|
|
body = b'{"ok": 1}' if code == 200 else b'{"detail": "x"}'
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
|
|
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
t.start()
|
|
try:
|
|
env = {**os.environ, "PROD_BASE_URL": f"http://127.0.0.1:{srv.server_address[1]}"}
|
|
p = subprocess.run(["bash", str(ROOT / ".claude/scripts/prod_smoke.sh")],
|
|
capture_output=True, text=True, env=env, timeout=60)
|
|
finally:
|
|
srv.shutdown()
|
|
return p
|
|
|
|
|
|
_AUTH_PATHS = ("/api/v1/journals/stats", "/api/v1/analysis/performance", "/api/v1/market-intelligence/dashboard")
|
|
|
|
|
|
def test_prod_smoke_passes_when_auth_enforced():
|
|
p = _smoke_against({"/api/v1/market/indices": 200, **{a: 401 for a in _AUTH_PATHS}})
|
|
assert p.returncode == 0, p.stdout + p.stderr
|
|
|
|
|
|
def test_prod_smoke_fails_on_unauthenticated_leak():
|
|
p = _smoke_against({"/api/v1/market/indices": 200, **{a: 200 for a in _AUTH_PATHS}})
|
|
assert p.returncode == 1 and "LEAK" in p.stdout
|
|
|
|
|
|
_OK_PATHS = {"/api/v1/market/indices": 200, **{a: 401 for a in _AUTH_PATHS}}
|
|
_SCHED_OK = {"swing": {"alive": True, "stale": False}}
|
|
|
|
|
|
def test_prod_smoke_degraded_reasons_only_warn():
|
|
h = {"status": "degraded", "db": "ok", "redis": "ok", "schedulers": _SCHED_OK,
|
|
"db_worker": {"alive": True}, "degraded_reasons": ["krx_data_incomplete", "dlq_manual"]}
|
|
p = _smoke_against(_OK_PATHS, h)
|
|
assert p.returncode == 0, p.stdout + p.stderr
|
|
assert "WARN HEALTH" in p.stdout and "krx_data_incomplete" in p.stdout
|
|
|
|
|
|
def test_prod_smoke_fails_on_scheduler_dead_even_if_degraded():
|
|
h = {"status": "degraded", "db": "ok", "redis": "ok",
|
|
"schedulers": {"swing": {"alive": False}}, "degraded_reasons": ["x"]}
|
|
p = _smoke_against(_OK_PATHS, h)
|
|
assert p.returncode == 1 and "FAIL HEALTH" in p.stdout
|
|
|
|
|
|
def test_prod_smoke_fails_on_db_error():
|
|
h = {"status": "degraded", "db": "error", "redis": "ok", "schedulers": _SCHED_OK, "degraded_reasons": []}
|
|
p = _smoke_against(_OK_PATHS, h)
|
|
assert p.returncode == 1 and "db error" in p.stdout
|
|
|
|
|
|
def test_prod_smoke_fails_when_down():
|
|
p = _smoke_against({"/api/v1/market/indices": 502, **{a: 502 for a in _AUTH_PATHS}})
|
|
assert p.returncode == 1
|
|
|
|
|
|
def test_deploy_watch_treats_401_as_up():
|
|
src = (ROOT / ".claude/scripts/deploy.sh").read_text()
|
|
assert 'is_up() { [ "$1" = "200" ] || [ "$1" = "401" ]; }' in src
|
|
assert "analysis/performance" not in src.split("WATCH_URL=")[1].splitlines()[0]
|
|
|
|
|
|
def test_rules_py311_fstring_pep701_detector():
|
|
"""운영 이미지 python:3.11 에서 SyntaxError 인 PEP 701 f-string 을 잡는다(2026-10-02 mdd_manager 사고)."""
|
|
import sys as _sys
|
|
_sys.path.insert(0, str(HOOKS)) if "HOOKS" in globals() else None
|
|
import importlib
|
|
import pathlib as _pl
|
|
_sys.path.insert(0, str(_pl.Path(__file__).resolve().parents[1]))
|
|
rc = importlib.import_module("rules_check")
|
|
if _sys.version_info < (3, 12):
|
|
assert rc.check_py311_fstring("x.py", 'x=f"{"a"}"\n') == []
|
|
return
|
|
assert len(rc.check_py311_fstring("x.py", 'x=f"{"[MDD]"} a"\n')) == 1
|
|
assert len(rc.check_py311_fstring("x.py", 'u=1\nx=f"{f"[v2] {u}"} a"\n')) == 1
|
|
assert rc.check_py311_fstring("x.py", "x=f\"{'[MDD]'} a\"\n") == []
|
|
assert rc.check_py311_fstring("x.py", 'x=f"""{"a"} b"""\n') == []
|
|
assert rc.check_py311_fstring("x.py", "d={'k':1}\nx=f\"{d['k']}\"\n") == []
|