Skip to content
Merged
60 changes: 60 additions & 0 deletions .claude/commands/review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
description: PR 을 올리기 전 로컬 리뷰. 기계 검사 + 영역별 리뷰 에이전트
---

PR 을 올리기 전에 **CodeRabbit 이 볼 것을 먼저 본다.** 원격에서 지적받고 고치는
왕복은 비싸고, 무엇보다 그 사이에 잘못된 코드가 브랜치에 남는다.

## 1. 기계 검사

```bash
.claude/hooks/review-branch.sh ${1:-origin/develop}
```

이 스크립트는 `check-java.sh`·`check-lua.sh` 를 **브랜치 변경 전체**에 태운다.
두 훅은 `Write|Edit` 도구로 쓴 파일만 보므로, 힙독이나 스크립트로 만든 파일은
그냥 지나간다 — 실제로 그렇게 들어간 위반이 CodeRabbit 까지 간 적이 있다.

위반이 나오면 **고치고 다시 돌린다.** 통과할 때까지 2번으로 넘어가지 않는다.

## 2. 빌드와 품질 임계

```bash
./gradlew build jacocoTestCoverageVerification pitest --no-daemon -q
```

도메인 분기 100%, 뮤테이션 생존 ≤10%. 생존 뮤턴트가 나오면 **숫자만 보지 말고
어디가 살아남았는지** 본다 — 임계를 통과해도 이미 버그가 났던 자리에 몰려
있으면 그건 통과가 아니다.

## 3. 영역별 리뷰 에이전트

변경 경로에 따라 `.claude/agents/` 에서 고른다. 1번 스크립트가 마지막에
어느 에이전트를 돌릴지 알려준다.

| 변경 | 에이전트 |
|---|---|
| `domain/**` | `domain-guardian` |
| `*.lua` · `redis/**` | `redis-cluster-checker` |
| 장애·회복·서킷·리트라이 | `resilience-auditor` |
| `src/test/**` · `src/testFixtures/**` | `test-quality-reviewer` |
| 전 영역 (항상) | `style-enforcer` |

각 에이전트에게 **변경 범위를 명시**해서 넘긴다 — 저장소 전체를 훑게 하면
이번 변경과 무관한 지적이 섞여 진짜 지적이 묻힌다.

## 4. 사람이 볼 것

기계도 에이전트도 못 보는 것이 남는다.

- **판정 순서를 바꿨다면** 계획서의 사다리 표도 함께 고쳤는가 (PK-A4)
- **계획서와 구현이 어긋난다면** 어느 쪽이 목표에 가까운가. 계획서가 스스로
모순인 경우가 있다 (AIJ-0012)
- **테스트 이름이 실제로 검증하는 것과 같은가.** 이름만 맞고 내용이 다른
테스트는 통과하면서 버그를 덮는다

## 5. 그다음

- 저널이 필요한 변경인가 — `src/**`·`*.gradle`·`.github/workflows/**`
- 커밋 제목이 50칸 이내 명사형인가, 푸터에 `Refs: CY-###` 가 있는가
- 푸시 전에 **무엇을 바꿨는지 요약**한다
28 changes: 24 additions & 4 deletions .claude/hooks/check-java.sh
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,30 @@ fi

# ── JS-14 중첩 클래스는 static ────────────────────────────────────────────────
# 들여쓰기된 class 선언 = 중첩. 수식어가 없는 경우도 잡는다.
report "JS-14" "중첩 클래스는 static — 바깥 인스턴스를 붙들어 누수를 만든다" \
"$(scan 'JS-14' \
'^[0-9]+:[[:space:]]+((public|protected|private|final|abstract)[[:space:]]+)*class[[:space:]]' \
'static')"
#
# **JUnit 5 의 @Nested 는 제외한다.** 그쪽은 static 이면 아예 실행되지 않는다 —
# 규칙과 프레임워크가 충돌하는 자리라 규칙이 진다. 여기서 오탐을 내면 사람은
# 훅을 고치는 대신 우회하고, 그러면 진짜 위반도 같이 지나간다.
# @Nested 이후 **선언까지의 연속 어노테이션 줄**을 전부 건너뛴다. 개수를 못
# 박으면 @Tag 하나 붙는 순간 오탐이 되고, 오탐이 나면 훅이 우회된다.
nested_class_lines=$(awk '
# `@Nested class Inner {` 처럼 한 줄에 같이 오면 그 줄이 곧 선언이다.
# pending 을 켠 채 넘어가면 **다음 중첩 클래스가 대신 면제된다.**
/^[[:space:]]*@Nested([[:space:]]|\(|$)/ && /class[[:space:]]/ { print NR; pending=0; next }
/^[[:space:]]*@Nested([[:space:]]|\(|$)/ { pending=1; next }
# 어노테이션 인자가 여러 줄에 걸치면 이어지는 줄은 @ 로 시작하지 않는다.
# 개수나 형태를 못 박지 말고 **선언 줄을 만날 때까지** 건너뛴다.
pending && /class[[:space:]]/ { print NR; pending=0; next }
pending { next }
' "$file")
js14=$(scan 'JS-14' \
'^[0-9]+:[[:space:]]+((public|protected|private|final|abstract)[[:space:]]+)*class[[:space:]]' \
'static')
for n in $nested_class_lines; do
js14=$(printf '%s\n' "$js14" | grep -vE "^[[:space:]]*$n:")
done
Comment thread
coderabbitai[bot] marked this conversation as resolved.
js14=$(printf '%s' "$js14" | grep -v '^[[:space:]]*$')
report "JS-14" "중첩 클래스는 static — 바깥 인스턴스를 붙들어 누수를 만든다" "$js14"

# ── JS-6 Javadoc 5줄 초과 (원본에서 검사한다) ─────────────────────────────────
hits=$(awk '
Expand Down
76 changes: 76 additions & 0 deletions .claude/hooks/guard-pr.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# PR 을 올리기 전에 로컬 리뷰를 강제한다.
#
# **왜 차단인가.** "올리기 전에 돌려라" 는 규범은 잊힌다. 실제로 잊었고,
# CodeRabbit 이 두 라운드에 걸쳐 14건을 지적했는데 그중 셋은 우리 자신의
# MUST 규칙 위반이었다 — 로컬에서 1초면 잡히는 것들이다.
#
# PreToolUse(Bash) 훅. `gh pr create` 를 만나면 기계 검사를 돌리고
# 위반이 있으면 exit 2 로 막는다.

set -uo pipefail

input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')

# PR 생성이 아니면 통과
[[ "$cmd" != *"gh pr create"* ]] && exit 0

# **검사를 못 돌리면 막는다.** 통과시키면 게이트가 인프라 오류 한 번에
# 조용히 사라진다 — 가드는 fail closed 여야 한다.
if ! ROOT=$(git rev-parse --show-toplevel 2>/dev/null); then
echo "git 저장소가 아니라 로컬 리뷰를 돌릴 수 없다. PR 은 저장소 안에서 연다." >&2
exit 2
fi
RUNNER="$ROOT/.claude/hooks/review-branch.sh"
if [[ ! -x "$RUNNER" ]]; then
echo "로컬 리뷰 러너를 실행할 수 없다: $RUNNER" >&2
echo " chmod +x .claude/hooks/*.sh" >&2
exit 2
fi

# base 를 명령에서 뽑는다. 없으면 develop
# **명령 문자열 전체를 훑지 않는다.** `--title "--base release"` 처럼 인용부호
# 안에 들어간 값을 옵션으로 착각한다. 인자를 토큰으로 쪼갠 뒤 옵션 자리만 본다.
#
# 실행하지 않고 쪼갠다 — `xargs` 는 셸 인용 규칙을 그대로 따르면서 명령을
# 부르지 않는다.
base=""
mapfile -t args < <(printf '%s' "$cmd" | xargs -n1 printf '%s\n' 2>/dev/null)
for ((i = 0; i < ${#args[@]}; i++)); do
case "${args[i]}" in
--base=*) base="${args[i]#--base=}"; break ;;
-B=*) base="${args[i]#-B=}"; break ;;
--base|-B)
base="${args[i + 1]:-}"
break ;;
esac
done
base="${base:-develop}"
# 이미 접두가 붙어 있으면 겹치지 않게 둔다. origin/origin/develop 이 되면
# 러너가 폴백을 타고, 폴백마저 없으면 브랜치 커밋을 하나도 안 보고 통과한다.
[[ "$base" != origin/* ]] && base="origin/$base"

out=$("$RUNNER" "$base" 2>&1)
status=$?

if ((status != 0)); then
{
echo "PR 을 올리기 전에 로컬 리뷰가 통과해야 한다."
echo
# 전체를 보여 준다. 걸러내면 정작 필요한 줄이 빠진다.
printf '%s\n' "$out"
echo
echo "고친 뒤 다시 시도한다. 수동 실행: .claude/hooks/review-branch.sh $base"
echo "전체 절차: /review"
} >&2
exit 2
fi

# 통과했어도 기계가 못 보는 것이 남는다 — 막지는 않고 알린다.
{
echo "로컬 기계 검사 통과. 아직 안 한 것이 있는지 본다:"
echo " · ./gradlew build jacocoTestCoverageVerification pitest"
printf '%s\n' "$out" | sed -n '/사람·에이전트가 볼 것/,$p' | sed 's/^/ /'
} >&2
exit 0
Loading
Loading