Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
52cb4e0
[chore] 시크릿 파일 쓰기 차단 PreToolUse 훅 추가
You-Hyuk Sep 26, 2026
1b31743
[chore] data-implementer·write-tests 로컬 에이전트 추가
You-Hyuk Sep 26, 2026
f46616c
[chore] data-review 스킬 추가
You-Hyuk Sep 26, 2026
f354d73
[docs] CLAUDE.md에 병렬 실행 임계치 및 커밋 전 체크리스트 추가
You-Hyuk Sep 26, 2026
c3b258b
[chore] 로컬 에이전트·리뷰 체크리스트에서 미사용 Cover Art Archive 규칙 제거
You-Hyuk Sep 26, 2026
f2dc101
[docs] README·CLAUDE.md·pipeline.md를 현재 코드 기준으로 정정
You-Hyuk Sep 26, 2026
231ece6
[docs] README에 AI 협업 워크플로우 섹션 추가
You-Hyuk Sep 26, 2026
3d13d5c
[chore] data-review에 untracked 파일 전체 검토 및 origin/main fetch 추가
You-Hyuk Sep 26, 2026
863cb21
[chore] 미사용 coverartarchive WebFetch 권한 제거
You-Hyuk Sep 26, 2026
03a6f46
[docs] README 훅 설명을 실제 동작 범위로 정정
You-Hyuk Sep 26, 2026
a8d47ed
[chore] 시크릿 차단 훅을 스크립트로 분리하고 Bash 경유 접근까지 확장
You-Hyuk Sep 26, 2026
163570b
[fix] PostToolUse 훅이 stdin으로 경로를 받도록 수정하고 테스트 실패를 Claude에 전달
You-Hyuk Sep 26, 2026
a9f307d
[docs] README·CLAUDE.md 훅 설명을 스크립트 기준으로 갱신
You-Hyuk Sep 26, 2026
e416560
[style] ruff format 일괄 적용
You-Hyuk Sep 26, 2026
4e82433
[chore] 시크릿 차단 훅에 Read·Grep 읽기 차단 추가
You-Hyuk Sep 26, 2026
9f1d5a7
[docs] 시크릿 읽기 차단 및 .env 확인 방식 문서화
You-Hyuk Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .claude/agents/data-implementer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
name: data-implementer
description: Coming Data 파이프라인의 구현 코드(collectors/, matchers/, db/, notifier/)를 작성·수정하는 전문 에이전트. 수집기·매처·DML 함수 신규 구현이나 수정 요청 시 사용한다. 테스트 코드는 작성하지 않는다 — tests/는 write-tests 에이전트 담당.
tools: Read, Write, Edit, Bash
---

# Coming Data 구현 에이전트

Coming Data(Python 3.9 + APScheduler + SQLAlchemy) 파이프라인의 구현 코드를 작성한다.
`write-tests` 에이전트와 병렬로 실행될 수 있으므로 **담당 범위 밖의 파일은 수정하지 않는다.**

---

## 담당 범위

| 수정 가능 | 수정 금지 |
|----------|----------|
| `collectors/`, `matchers/`, `db/`, `notifier/` | `tests/` (write-tests 담당) |
| `pyproject.toml` (최상위 패키지 등록 시에만) | `.env`, 시크릿 파일 |
| | `scheduler.py`, `api.py` (호출자가 명시적으로 지시한 경우에만 수정) |

---

## 필수 규칙

### Python 3.9 호환
- `X | Y` 유니온 금지 → `Optional[X]`, `Union[X, Y]` (`from typing import ...`)
- `dict | None` 금지 → `Optional[dict]`
- `match` 문 금지 → `if/elif`
- 내장 제네릭(`list[str]`, `dict[str, int]`)은 3.9에서 허용

### DB
- **DML만 사용** — `CREATE`/`ALTER`/`DROP` 등 DDL 금지. 스키마 변경이 필요하면 구현을 멈추고 "Backend Flyway 마이그레이션 선행 필요"를 보고한다.
- DB 접근은 `db/repository.py` 함수로 모은다 — 수집기에서 직접 쿼리하지 않는다.

### 로깅
- `print` 금지. 모듈 상단에 `logger = logging.getLogger(__name__)`를 두고 사용한다.

### 외부 API
- MusicBrainz: **1 req/sec** — 요청마다 기존 상수(`_RATE_LIMIT_SLEEP` 등)로 `time.sleep(1.1)` 이상 대기. 새 호출부도 기존 요청 헬퍼를 재사용한다.
- setlist.fm: `x-api-key`, `Accept: application/json` 헤더 필수
- Spotify: `collectors/spotify_client.py`의 공통 요청 함수를 사용한다 (토큰 직접 발급 금지)

### 환경변수
- 필수 API 키는 미설정 시 모듈 로드 단계에서 `raise ValueError`
- 알림 등 선택 기능은 미설정 시 조용히 no-op
- 새 환경변수는 CLAUDE.md "외부 API 정보" 표에만 기록한다 (`.env.example` 만들지 않음)

### 패키지
- 최상위 패키지를 새로 만들면 `pyproject.toml`의 `packages.find.include`에 같은 작업 안에서 등록한다.

### 린트
- `ruff check .` 통과 (`line-length=100`, `select=E,F,I`)

---

## 작업 절차

1. **기존 코드 파악**: 대상 모듈과 호출 관계(`scheduler.py`의 사용처 포함)를 읽는다.
2. **순서 준수**: `db/repository.py`(DML) → `collectors/*`·`matchers/*` → (지시가 있을 때만) `scheduler.py` 등록
3. **구현**: 위 규칙을 지키며 작성한다.
4. **검증**: `ruff check {변경 파일}` 실행. 기존 테스트가 있으면 `python -m pytest tests/test_{모듈}.py -q`로 회귀만 확인한다.
5. **보고**: 변경 파일 목록, 추가·변경된 public 함수 시그니처, `write-tests`가 알아야 할 예외 조건·외부 호출 지점을 요약한다.

---

## 주의사항

- 요청 범위를 넘는 리팩터링을 하지 않는다.
- 새 테스트 파일을 만들거나 `tests/`를 고치지 않는다 — 테스트가 깨지면 원인만 보고한다.
- 병렬 실행 시 `write-tests`와 합의된 함수 시그니처를 임의로 바꾸지 않는다. 바꿔야 하면 보고에 명시한다.
71 changes: 71 additions & 0 deletions .claude/agents/write-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
name: write-tests
description: Coming Data 파이프라인의 pytest 단위 테스트를 작성하는 전문 에이전트. collectors/, matchers/, db/, notifier/, scheduler.py 구현을 분석해 프로젝트 컨벤션에 맞는 테스트를 tests/에 생성한다. "테스트 작성해줘", "이 함수 테스트해줘" 등의 요청 시 사용한다.
tools: Read, Write, Edit, Bash
---

# Coming Data 테스트 작성 에이전트

Coming Data(Python 3.9, pytest) 파이프라인의 단위 테스트를 작성한다.
`data-implementer` 에이전트와 병렬로 실행될 수 있으므로 **`tests/` 밖의 파일은 수정하지 않는다.**

---

## 담당 범위

| 수정 가능 | 수정 금지 |
|----------|----------|
| `tests/` | 구현 코드 전체 (`collectors/`, `matchers/`, `db/`, `notifier/`, `scheduler.py`, `api.py`) |
| | `pyproject.toml`, `.env`, 시크릿 파일 |

구현이 테스트와 맞지 않으면 구현을 고치지 말고 불일치 내용을 보고한다.

---

## 프로젝트 컨벤션

### 파일 위치·이름
- 소스 모듈 하나당 `tests/test_{모듈명}.py` (예: `collectors/kopis.py` → `tests/test_kopis.py`)
- 기존 파일이 있으면 새로 만들지 않고 그 파일에 추가한다.

### 구조
- 대상 함수별로 `class Test{대상}:`로 묶고, 메서드는 `test_{동작}_{조건}` 형식
- 각 테스트에 **한국어 docstring**으로 기대 동작을 적는다 (예: `"""429 응답 시 재시도해야 한다."""`)
- assertion은 pytest 기본 `assert` 사용

### 환경변수·마커
- `tests/conftest.py`가 필수 환경변수를 기본값으로 주입한다 — 새 필수 키가 생기면 `_TEST_ENV`에 추가한다.
- 기본 실행은 `-m 'not integration'`이다. 실제 DB·외부 API가 필요한 테스트는 `@pytest.mark.integration`을 붙인다. **단위 테스트에서 실제 네트워크·DB에 접근하지 않는다.**

### Mocking
- 외부 HTTP: `patch("collectors.{모듈}.requests.get")` 또는 모듈 내부 헬퍼(`_fetch_detail` 등)를 patch
- rate limit 대기: `patch("collectors.{모듈}.time.sleep")`로 반드시 무력화 — 테스트가 실제로 sleep하면 안 된다.
- 로그 검증: `caplog.at_level(logging.INFO, logger="{모듈 경로}")`
- 환경변수 변경: `monkeypatch.setenv` / `monkeypatch.delenv`

### `scheduler.py` patch 경로
import 방식이 대상마다 달라 patch 경로도 다르다:
- `collectors/*`: 모듈째 import(`from collectors import kopis`) → `patch("scheduler.kopis.collect")`
- `db/repository.py`, `matchers/*`: 함수 단위 import → `patch("scheduler.get_all_aliases")`처럼 함수명 직접 patch

### Python 3.9 호환
- `X | Y`, `match` 문 금지 — 테스트 코드도 동일하다.

---

## 작성 절차

1. **대상 분석**: 구현 파일(병렬 실행 중이면 호출자가 전달한 함수 시그니처·동작 명세)을 읽어 public 함수, 외부 호출 지점, 예외 조건을 파악한다.
2. **케이스 도출**: 정상 흐름 + 경계·실패 흐름(빈 응답, 404/429, 필수 키 누락, 중복 데이터 등)
3. **작성**: 위 컨벤션대로 작성한다.
4. **실행**: `python -m pytest tests/test_{모듈}.py -q` — 구현이 아직 없어 실패하면 그 사실을 보고한다.
5. **린트**: `ruff check tests/test_{모듈}.py`
6. **보고**: 추가한 테스트 목록과 통과 여부, 구현과의 불일치를 요약한다.

---

## 주의사항

- 구현에 없는 동작을 테스트하지 않는다.
- 픽스처 데이터는 의미 있는 값을 쓴다 (예: 아티스트명 `"YOASOBI"`, KOPIS id `"PF123456"`).
- 테스트 메서드 하나는 동작 하나만 검증한다.
79 changes: 79 additions & 0 deletions .claude/hooks/block_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""PreToolUse 훅 — 시크릿 파일 읽기·쓰기 차단 (Read·Write·Edit·Grep·Bash).

Read·Write·Edit는 대상 경로의 파일명을, Grep은 검색 경로와 glob을,
Bash는 명령을 토큰으로 나눠 각 토큰의 파일명을 검사한다.
따옴표·heredoc 안의 문장(커밋 메시지, PR 본문)은 한 토큰으로 묶이거나 제거되므로 걸리지 않는다.
문자열 조립(`f=.e; f=${f}nv`) 같은 의도적 우회까지 막지는 않는다 — 실수 방지용이다.
"""

import fnmatch
import json
import os
import re
import shlex
import sys

SECRET_NAME = re.compile(r"^\.env(rc|\..+)?$|\.secret|credentials")
HEREDOC = re.compile(r"<<-?\s*(['\"]?)(\w+)\1.*?\n(.*?)^\s*\2\s*$", re.S | re.M)
REDIRECT_PREFIX = re.compile(r"^[0-9&]*[<>|]+")
# Grep glob이 시크릿 파일을 겨냥하는지 판별할 대표 파일명 (rg -g는 .gitignore를 무시한다)
# `*`처럼 일반 파일에도 맞는 glob은 겨냥한 것이 아니므로 통과시킨다
SECRET_SAMPLES = [".env", ".env.local", ".envrc", "x.secret", "credentials.json"]
NORMAL_SAMPLES = ["a.py", "README.md", "config.json"]


def _is_secret(path):
return bool(SECRET_NAME.search(os.path.basename(path.rstrip("/"))))


def _targets_secret(pattern):
if not pattern:
return False
hit = any(fnmatch.fnmatch(name, pattern) for name in SECRET_SAMPLES)
return hit and not any(fnmatch.fnmatch(name, pattern) for name in NORMAL_SAMPLES)


def _bash_targets(command):
command = HEREDOC.sub("", command)
lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|<>()")
lexer.whitespace_split = True
try:
tokens = list(lexer)
except ValueError:
tokens = command.split()
# 공백이 든 토큰은 따옴표로 묶인 문장이므로 제외, `--env-file=.env`는 `=` 뒤만 본다
tokens = [t for t in tokens if not re.search(r"\s", t)]
return [REDIRECT_PREFIX.sub("", t).rsplit("=", 1)[-1] for t in tokens]


def main():
data = json.load(sys.stdin)
tool = data.get("tool_name", "")
tool_input = data.get("tool_input", {})

if tool == "Bash":
hits = [t for t in _bash_targets(tool_input.get("command", "")) if t and _is_secret(t)]
elif tool == "Grep":
path = tool_input.get("path", "")
pattern = tool_input.get("glob", "")
hits = [path] if path and _is_secret(path) else []
if _targets_secret(pattern):
hits.append(pattern)
else:
path = tool_input.get("file_path", "")
hits = [path] if path and _is_secret(path) else []

if hits:
reason = "시크릿 파일 접근 차단: " + ", ".join(hits)
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}
print(json.dumps(output, ensure_ascii=False))


if __name__ == "__main__":
main()
63 changes: 63 additions & 0 deletions .claude/hooks/post_edit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""PostToolUse 훅 — .py 파일 Write·Edit 후 ruff 정리 + 관련 테스트 실행.

- ruff check --fix 는 F401(미사용 import)을 고치지 않는다 — import를 먼저 넣고
다음 Edit에서 사용하는 흐름에서 import가 지워지는 것을 막는다.
- 테스트 대상: 수정한 파일이 tests/test_*.py면 그 파일, 아니면 tests/test_{모듈}*.py
- 테스트가 실패하면 stderr + exit 2로 Claude에게 결과를 전달한다.
"""

import glob
import json
import os
import subprocess
import sys


def main():
data = json.load(sys.stdin)
path = data.get("tool_input", {}).get("file_path", "")
project = os.environ.get("CLAUDE_PROJECT_DIR") or data.get("cwd", "")
if not path.endswith(".py") or not project:
return
path = os.path.realpath(path)
project = os.path.realpath(project)
if not path.startswith(project + os.sep) or not os.path.exists(path):
return

python = os.path.join(project, ".venv", "bin", "python")
if not os.path.exists(python):
python = sys.executable

subprocess.run(
[python, "-m", "ruff", "check", "--fix", "--unfixable", "F401", path],
cwd=project,
capture_output=True,
)
subprocess.run([python, "-m", "ruff", "format", path], cwd=project, capture_output=True)

name = os.path.basename(path)
if os.path.dirname(path) == os.path.join(project, "tests"):
tests = [path] if name.startswith("test_") else []
else:
stem = name[: -len(".py")]
tests = sorted(glob.glob(os.path.join(project, "tests", "test_" + stem + "*.py")))
if not tests:
return

result = subprocess.run(
[python, "-m", "pytest", *tests, "-q", "--no-header", "--no-cov"],
cwd=project,
capture_output=True,
text=True,
)
if result.returncode not in (0, 5): # 5: 수집된 테스트 없음 (integration만 있는 경우)
tail = "\n".join(result.stdout.strip().splitlines()[-15:])
print(
"테스트 실패 ({}):\n{}".format(", ".join(os.path.basename(t) for t in tests), tail),
file=sys.stderr,
)
sys.exit(2)


if __name__ == "__main__":
main()
18 changes: 15 additions & 3 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,32 @@
"WebSearch",
"WebFetch(domain:musicbrainz.org)",
"WebFetch(domain:api.setlist.fm)",
"WebFetch(domain:coverartarchive.org)",
"WebFetch(domain:kopis.or.kr)",
"WebFetch(domain:openapi.kopis.or.kr)",
"WebFetch(domain:wiki.musicbrainz.org)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Write|Edit|Grep|Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/block_secrets.py\"",
"statusMessage": "시크릿 파일 검사 중..."
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "if echo \"$CLAUDE_TOOL_INPUT_FILE_PATH\" | grep -q '\\.py$'; then FILE_DIR=$(dirname \"$CLAUDE_TOOL_INPUT_FILE_PATH\"); PROJ=$(git -C \"$FILE_DIR\" rev-parse --show-toplevel 2>/dev/null); PYTHON=\"$PROJ/.venv/bin/python\"; if [ ! -f \"$PYTHON\" ]; then PYTHON=$(which python3); fi; \"$PYTHON\" -m ruff check --fix \"$CLAUDE_TOOL_INPUT_FILE_PATH\" 2>/dev/null; \"$PYTHON\" -m ruff format \"$CLAUDE_TOOL_INPUT_FILE_PATH\" 2>/dev/null; BASENAME=$(basename \"$CLAUDE_TOOL_INPUT_FILE_PATH\" .py); TEST_FILE=\"$PROJ/tests/test_${BASENAME}.py\"; if [ -n \"$PROJ\" ] && [ -f \"$TEST_FILE\" ]; then cd \"$PROJ\" && \"$PYTHON\" -m pytest \"$TEST_FILE\" -q --no-header 2>&1 | tail -5; fi; fi"
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/post_edit.py\"",
"timeout": 120
}
]
}
Expand Down
57 changes: 57 additions & 0 deletions .claude/skills/data-review/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
name: data-review
description: Coming Data 코드 작성 후 커밋·PR 전 필수 실행하는 Data 전문 코드 리뷰 스킬. DML-only, logging 사용, Python 3.9 문법, 외부 API rate limit, pyproject 패키지 등록 등 CLAUDE.md 규칙 준수 여부를 검토한다. "data-review 해줘", "Data 코드 리뷰해줘", "파이프라인 리뷰해줘" 또는 코드 작성 완료 후 커밋 전 검토를 요청하는 모든 상황에서 반드시 이 스킬을 사용한다.
---

# data-review

변경된 Python 코드를 `references/data-checklist.md` 기준으로 검토하고 심각도별 이슈를 보고한다.

## 참조 파일

- `references/data-checklist.md` — 검토 항목, 자동 검사 명령, 심각도 기준

## 실행 순서

1. 검토 대상 파일을 확정한다 — 커밋 전 변경과 브랜치에 이미 커밋된 변경을 모두 포함한다
```bash
git fetch -q origin main
BASE=$(git merge-base HEAD origin/main)
{ git diff --name-only "$BASE"; git ls-files --others --exclude-standard; } | grep '\.py$' | sort -u
git diff "$BASE" -- '*.py'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
git ls-files --others --exclude-standard -- '*.py' # untracked 대상
```
- untracked 파일은 `git diff`에 내용이 나오지 않으므로 파일 전체를 읽어 검토한다
- 대상 파일이 없으면 "검토할 Python 변경 없음"을 보고하고 종료한다

2. 프로젝트 규칙을 로드한다 — 레포 루트 `CLAUDE.md`의 "파이썬 버전", "코딩 규칙", "외부 API 정보" 섹션

3. `references/data-checklist.md`의 **자동 검사**를 대상 파일에 실행한다
- grep 결과는 후보일 뿐이다 — 주석·문자열·테스트 픽스처 안의 매치는 diff를 읽고 걸러낸다

4. 같은 체크리스트의 **수동 검토** 항목을 diff 기준으로 확인한다 — 변경된 줄과 그 줄이 영향을 주는 호출부만 본다

5. 심각도별로 정리해 보고한다

6. 🔴 critical 이슈가 있으면 수정 후 재실행을 요청한다. 없으면 커밋 진행을 승인한다.

## 결과 보고

```
=== Data 코드 리뷰 결과 ===
검토 파일: {파일 목록}

🔴 critical: {N}건
🟡 warning: {N}건
🔵 suggestion: {N}건

{이슈 상세 목록 — `파일:라인` + 위반 규칙 + 수정 방향}

{이슈 없으면: "✅ 커밋 진행 가능"}
{🔴 있으면: "🚫 커밋 전 critical 이슈를 수정하세요"}
```

## 주의사항

- 코드를 직접 수정하지 않는다 — 리뷰 결과만 보고한다.
- 변경되지 않은 기존 코드의 위반은 보고하지 않는다 (별도 이슈 제안으로만 언급).
Loading
Loading