diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e4194f9 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git branch:*)", + "Bash(git ls-files:*)", + "Bash(git check-ignore:*)", + "Bash(xcodebuild:*)", + "Bash(xcrun swift-format:*)", + "Bash(gh pr view:*)", + "Bash(gh pr list:*)", + "Bash(gh run list:*)", + "Bash(gh run view:*)", + "Bash(plutil -lint:*)" + ], + "deny": [ + "Read(./Paxo/Secrets.swift)", + "Read(./.env)", + "Read(./**/.env.*)", + "Read(./proxy-vercel/.vercel/**)", + "Read(./proxy/.wrangler/**)", + "Read(./docs/hansung/**)", + "Bash(git push --force:*)", + "Bash(git push -f:*)", + "Bash(gh pr merge:*)", + "Bash(npx vercel --prod:*)" + ] + } +} diff --git a/.claude/skills/proxy-change/SKILL.md b/.claude/skills/proxy-change/SKILL.md new file mode 100644 index 0000000..61fa683 --- /dev/null +++ b/.claude/skills/proxy-change/SKILL.md @@ -0,0 +1,48 @@ +--- +name: proxy-change +description: GeminiService의 요청 형태나 프록시 API를 바꿀 때의 절차. 클라이언트와 서버를 같은 PR에서 함께 고치도록 강제한다. 모델 변경, 프롬프트 파라미터 추가, 스트리밍 전환에 쓴다. +--- + +# 클라이언트 · 프록시 동시 변경 + +## 왜 이 스킬이 있나 + +프록시는 요청 본문을 통과시키지 않고 **화이트리스트로 재조립한다.** +클라이언트만 고치면 조용히 400이 돌아온다. 로컬에서는 DEBUG 직접 호출 경로 때문에 +동작하는 것처럼 보일 수도 있어 더 위험하다. + +## 절차 + +### 1. 양쪽을 먼저 읽는다 + +- `Paxo/AI/GeminiService.swift` — 요청 조립, 응답 파싱, 에러 매핑 +- `proxy-vercel/api/generate.js` — `buildUpstreamBody`의 화이트리스트 +- `proxy-vercel/AGENTS.md` — 계약 요약 + +### 2. 무엇이 막히는지 확인한다 + +현재 통과 가능한 것은 `text` 파트 1개와 `inline_data` 파트 1개뿐이다. +`generationConfig`, `systemInstruction`, `safetySettings`, `tools`, 추가 파트는 전달되지 않는다. + +추가하려는 필드가 여기 걸리면 **프록시를 먼저 고쳐야 한다.** + +### 3. 순서대로 바꾼다 + +1. `proxy-vercel/api/generate.js`의 화이트리스트를 넓힌다 +2. 로컬에서 프록시를 돌려 확인하거나, 프리뷰 배포로 확인한다 +3. `GeminiService.swift`를 바꾼다 +4. 앱을 빌드해 실제 호출이 200을 받는지 확인한다 + +### 4. 배포 순서를 지킨다 + +**프록시를 먼저 배포하고 앱을 나중에 배포한다.** 반대로 하면 구버전 프록시가 새 요청을 거부한다. + +### 5. 같은 PR에 넣는다 + +두 변경을 나누면 중간 상태에서 앱이 깨진다. PR 템플릿의 해당 체크박스를 확인한다. + +## 스트리밍으로 바꾸려면 + +지금은 `:generateContent` 일회성 요청이다. `:streamGenerateContent`로 바꾸려면 +프록시의 응답 전달 방식과 클라이언트의 파싱을 **둘 다** 다시 써야 한다. +"정답 먼저, 해설 나중"은 스트리밍이 아니라 요청 2회로 구현돼 있다는 점을 먼저 이해할 것. diff --git a/.claude/skills/release-check/SKILL.md b/.claude/skills/release-check/SKILL.md new file mode 100644 index 0000000..d9ce0a0 --- /dev/null +++ b/.claude/skills/release-check/SKILL.md @@ -0,0 +1,69 @@ +--- +name: release-check +description: App Store 제출 전 검사. 버전 번호, 서명 팀, StoreKit 설정, APP_TOKEN, 번들 내용물을 순서대로 확인한다. 제출·아카이브·릴리스 준비를 말할 때 쓴다. +--- + +# 제출 전 검사 + +전체 절차는 비공개 저장소의 `docs/private/app-store-submission.md`다 (`paxo-app/internal`). 이 스킬은 **코드에서 기계적으로 확인 가능한 것**만 본다. + +## 1. 버전 + +```sh +grep -n "MARKETING_VERSION\|CURRENT_PROJECT_VERSION" Paxo.xcodeproj/project.pbxproj +``` + +`MARKETING_VERSION`이 `0.1.0`이면 아직 출시 버전이 아니다. 사용자에게 올릴 값을 물어본다. +재제출이라면 `CURRENT_PROJECT_VERSION`도 올려야 한다. + +## 2. StoreKit 설정 — 가장 흔한 사고 + +```sh +grep -n "StoreKitConfigurationFileReference" Paxo.xcodeproj/xcshareddata/xcschemes/Paxo.xcscheme +``` + +**출시 아카이브 전에는 반드시 제거하거나 None으로 바꿔야 한다.** 남아 있으면 실제 결제가 동작하지 않는다. +공유 스킴이라 팀 전체에 영향을 준다. + +## 3. 서명 팀 + +```sh +grep -n "DEVELOPMENT_TEAM" Paxo.xcodeproj/project.pbxproj +security find-identity -v -p codesigning +``` + +`DEVELOPMENT_TEAM` 값과 배포 인증서의 팀 ID가 다르면 아카이브가 실패한다. +배포용(Apple Distribution) 인증서가 아예 없으면 그것부터 만들어야 한다. + +## 4. 프록시 토큰 + +```sh +npx vercel env ls +``` + +`APP_TOKEN`이 프로덕션에 설정돼 있는지 확인한다. 설정 순서를 지키지 않으면 기존 앱이 401을 맞는다. +순서는 `proxy-vercel/AGENTS.md` 참고. + +## 5. 번들 내용물 + +```sh +xcodebuild -project Paxo.xcodeproj -scheme Paxo -configuration Release \ + -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build +``` + +빌드된 `Paxo.app`에 내부 문서나 시크릿이 들어가지 않았는지 확인한다. + +```sh +find <경로>/Paxo.app -iname '*.md' -o -iname '*.swift' +``` + +## 6. 빌드와 테스트 + +```sh +xcodebuild -project Paxo.xcodeproj -scheme Paxo -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO test +``` + +## 보고 형식 + +각 항목을 통과/실패로 표시하고, **실패한 것만** 어떻게 고치는지 설명한다. +사용자 승인 없이 버전 번호나 스킴을 고치지 않는다. diff --git a/.claude/skills/weekly-report/SKILL.md b/.claude/skills/weekly-report/SKILL.md new file mode 100644 index 0000000..7cde1a6 --- /dev/null +++ b/.claude/skills/weekly-report/SKILL.md @@ -0,0 +1,40 @@ +--- +name: weekly-report +description: 커밋과 PR 기록으로 주간 보고서 초안을 만든다. 주간 회고, 진행 상황 공유, 팀 보고가 필요할 때 쓴다. +--- + +# 주간 보고서 초안 + +## 자료 수집 + +```sh +git log --since='7 days ago' --format='%h %an %s' --no-merges +git log --since='7 days ago' --format='%b' --no-merges +gh pr list --state merged --limit 30 --json number,title,mergedAt,author +gh run list --limit 20 --json conclusion,headBranch,createdAt +``` + +## 작성 형식 + +``` +## 이번 주 한 일 +(사람이 아니라 성과 단위로 묶는다. 커밋 나열 금지) + +## 지표 +- 머지된 PR: N건 +- CI 통과율: N% +- develop 빌드 실패: N회 + +## 다음 주 +(README 로드맵의 미완료 항목과 대조해서 제안한다) + +## 막힌 것 +(커밋 메시지나 PR 본문에서 드러난 것만. 없으면 "없음") +``` + +## 규칙 + +- **커밋을 그대로 나열하지 않는다.** 여러 커밋을 하나의 성과로 묶는다 +- 숫자는 위 명령으로 실제 집계한 값만 쓴다. 추정하지 않는다 +- "막힌 것"을 지어내지 않는다. 근거가 없으면 없다고 쓴다 +- 한국어로 쓴다 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..fe846e1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## 무엇을 바꿨나 + + + +## 어떻게 확인했나 + + + +## 체크리스트 + +- [ ] 빌드와 테스트를 **직접 돌려** 통과를 확인했다 +- [ ] 새 유틸리티를 만들기 전에 **기존 것을 검색**했다 +- [ ] 코드에 들어간 숫자·문구·외부 정보를 **직접 검증**했다 (AI 초안을 그대로 믿지 않았다) +- [ ] 프록시 계약(`GeminiService` ↔ `api/generate.js`)을 건드렸다면 **양쪽 다** 고쳤다 +- [ ] 시크릿이나 비공개 문서가 섞이지 않았다 (저장소는 **공개**) + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..406d5e4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI + +on: + pull_request: + push: + branches: [develop, main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + XCODEBUILD_FLAGS: >- + -project Paxo.xcodeproj + -scheme Paxo + -destination platform=macOS + CODE_SIGNING_ALLOWED=NO + +jobs: + guard: + name: 커밋 금지 파일 검사 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: 시크릿·비공개 파일이 추적되는지 검사 + run: | + fail=0 + check() { + if git ls-files --error-unmatch "$1" >/dev/null 2>&1; then + echo "::error file=$1::$1 은(는) 커밋하면 안 되는 파일입니다" + fail=1 + fi + } + check Paxo/Secrets.swift + for p in $(git ls-files 'docs/hansung/*' 'docs/private/*' '*/node_modules/*' 'node_modules/*' '*.vercel/*' '*.wrangler/*'); do + echo "::error file=$p::$p 은(는) 커밋하면 안 되는 경로입니다" + fail=1 + done + if [ "$fail" -ne 0 ]; then + echo "저장소가 공개입니다. .gitignore 를 확인하고 git rm --cached 로 제거하세요." + exit 1 + fi + echo "✓ 금지 파일 없음" + + - name: 템플릿이 Paxo/ 안에 복사돼 있지 않은지 검사 + run: | + if git ls-files 'Paxo/Secrets*.swift' | grep -q 'example'; then + echo "::error::Secrets 템플릿이 Paxo/ 안에 있으면 중복 선언으로 빌드가 깨집니다" + exit 1 + fi + echo "✓ 템플릿 위치 정상" + + format: + name: swift-format + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: 포맷 검사 + run: xcrun swift-format lint --recursive --strict --configuration .swift-format Paxo PaxoTests + + build: + name: 빌드 및 테스트 + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: 시크릿 템플릿 생성 + run: cp Config/Secrets.example.swift Paxo/Secrets.swift + + - name: 빌드 + run: xcodebuild $XCODEBUILD_FLAGS -configuration Debug build + + - name: 테스트 + run: xcodebuild $XCODEBUILD_FLAGS test + + - name: 앱 번들에 내부 문서가 섞이지 않았는지 검사 + run: | + app=$(find ~/Library/Developer/Xcode/DerivedData -name 'Paxo.app' -type d | head -1) + if [ -z "$app" ]; then echo "::error::빌드 산출물을 찾지 못했습니다"; exit 1; fi + if find "$app" -iname '*.md' | grep .; then + echo "::error::내부 마크다운이 앱 번들에 포함됐습니다" + exit 1 + fi + echo "✓ 번들 정상" diff --git a/.gitignore b/.gitignore index cb959e0..f87574a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,8 +29,13 @@ node_modules/ # Vercel .vercel -# 로컬 시크릿 +# 로컬 시크릿 — Config/Secrets.example.swift를 복사해서 만든다 Paxo/Secrets.swift +# 비공개 문서 — 사업계획서·신청서, 비공개 저장소 paxo-app/internal의 clone docs/hansung docs/private + +# AI 도구 로컬 설정 (팀 공유는 .claude/settings.json) +.claude/settings.local.json +CLAUDE.local.md diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..b80e25b --- /dev/null +++ b/.swift-format @@ -0,0 +1,26 @@ +{ + "version": 1, + "lineLength": 120, + "indentation": { "spaces": 4 }, + "respectsExistingLineBreaks": true, + "maximumBlankLines": 1, + "lineBreakBeforeEachArgument": false, + "lineBreakBeforeControlFlowKeywords": false, + "prioritizeKeepingFunctionOutputTogether": true, + "indentConditionalCompilationBlocks": false, + "rules": { + "AlwaysUseLowerCamelCase": true, + "AmbiguousTrailingClosureOverload": true, + "NeverForceUnwrap": true, + "NeverUseImplicitlyUnwrappedOptionals": true, + "OrderedImports": true, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "NoLeadingUnderscores": false, + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": false, + "NoBlockComments": false + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1db1fef --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,96 @@ +# Paxo — AI 에이전트 작업 규칙 + +> **작업 시작 전 반드시 `git pull`.** 규칙 문서가 자주 바뀐다. + +Paxo는 화면 속 문제를 단축키로 캡처하면 AI가 정답과 해설을 알려주는 **macOS 메뉴바 앱**이다. +저장소 이름이 `iOS`지만 iOS 앱이 아니다. 배포 타깃은 macOS 14+. + +이 파일은 저장소 전역 규칙이다. 하위 디렉토리에 더 구체적인 `AGENTS.md`가 있고, +에이전트는 트리에서 **가장 가까운 파일**을 읽는다. + +- `Paxo/AGENTS.md` — Swift 앱 코드 (밟기 쉬운 지뢰 모음) +- `proxy-vercel/AGENTS.md` — API 프록시 계약 + +공통 문서: + +- `docs/architecture.md` — 앱 ↔ 프록시 ↔ Gemini ↔ StoreKit 연결 +- `docs/domain.md` — 기능 · 과금 용어 +- `docs/decisions.md` — 팀 결정 기록 (날짜순) + +## 셋업 + +클론 직후 반드시 1회. **이 파일이 없으면 빌드가 실패한다.** + +```sh +cp Config/Secrets.example.swift Paxo/Secrets.swift +``` + +`Paxo/Secrets.swift`는 gitignore 대상이다. 절대 커밋하지 않는다. +템플릿을 `Paxo/` 안에 복사본으로 남기면 `Secrets` 중복 선언으로 빌드가 깨진다. + +## 명령 + +빌드: + +```sh +xcodebuild -project Paxo.xcodeproj -scheme Paxo -configuration Debug \ + -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build +``` + +테스트: + +```sh +xcodebuild -project Paxo.xcodeproj -scheme Paxo \ + -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO test +``` + +포맷 검사와 적용: + +```sh +xcrun swift-format lint --recursive --strict Paxo PaxoTests +xcrun swift-format format --in-place --recursive Paxo PaxoTests +``` + +## 코드 스타일 + +- **식별자는 영어, 주석과 사용자 노출 문구는 한국어.** 예외 없다. +- 주석은 *왜*를 적는다. API를 재진술하지 않는다. 밀도는 코드 20줄당 1줄 정도. +- import는 알파벳순. `swift-format`의 `OrderedImports` 규칙으로 CI에서 강제된다. +- 접근 제어는 `private`만 명시하고 `internal`은 생략한다. `public`/`fileprivate`은 쓰지 않는다. +- 모든 참조 타입은 `final`. +- 강제 언래핑(`!`) 금지. 현재 코드베이스에 0개다. +- `print`/`os_log`/`Logger` 추가 금지. 로깅 인프라가 없고, 도입은 별도 논의 대상이다. +- 상태 없는 유틸리티는 case 없는 `enum` 네임스페이스로 만든다 (`Prompts`, `KeychainHelper`). + +## 중복을 만들지 않는다 + +새 유틸리티나 헬퍼를 만들기 전에 **기존 것을 먼저 검색한다.** +비슷한 함수가 이미 있으면 새로 쓰지 말고 그것을 쓰거나 확장한다. +AI 생성 코드가 조용히 중복을 쌓는 것이 이 저장소의 가장 큰 품질 리스크다. + +## 커밋과 PR + +- 브랜치 접두사: `feat/`, `fix/`, `docs/`, `chore/`, `style/`, `test/` +- 커밋: Conventional Commits 접두사 + **한국어 제목** + `-` 불릿 본문 +- **AI 사용 여부를 커밋에 표기하지 않는다.** 초안을 누가 썼든 커밋한 사람이 전적으로 책임진다 +- 기능 브랜치는 `develop`에서 만들고 PR도 `develop`으로 보낸다 (Squash 머지). `main`은 릴리즈 PR만 받는다 (Merge 머지) +- `develop` · `main` 직접 푸시 금지. PR과 CI 통과가 필수다 +- PR을 올리기 전에 빌드와 테스트를 **실제로 돌려서** 통과를 확인한다 + +## 절대 하지 말 것 + +- `Paxo.xcodeproj/project.pbxproj`의 파일 목록 수동 편집 — `Paxo/`는 파일시스템 동기화 그룹이라 손댈 필요가 없다 +- `Paxo/Secrets.swift` 커밋 +- `docs/hansung/` 커밋 — 사업계획서와 신청서, 비공개다 +- `docs/private/` 커밋 — 비공개 저장소 `paxo-app/internal`의 clone이다. 내용을 공개 저장소로 옮기지 않는다 +- `node_modules/` 커밋 + +**이 저장소는 공개다.** 커밋 전에 시크릿이 섞였는지 확인한다. + +## 릴리스 + +전체 절차는 비공개 저장소 `paxo-app/internal`의 `app-store-submission.md`(로컬에선 `docs/private/`)에 있다. 제출 전 함정 3가지만 여기 적는다. + +1. `MARKETING_VERSION`이 아직 `0.1.0`이다 — 출시 빌드는 `1.0.0` +2. 공유 스킴의 StoreKit Configuration을 **None**으로 되돌려야 한다. 안 그러면 실제 결제가 동작하지 않는다 +3. `DEVELOPMENT_TEAM = L3JLLU88WG`가 실제 배포 인증서의 팀과 일치하는지 확인한다 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3b62afb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,18 @@ +@AGENTS.md + +## Claude Code 전용 + +위 `AGENTS.md`가 팀 공통 규칙이다. Codex도 같은 파일을 읽는다. +아래는 Claude Code에만 해당하는 내용이다. + +### 스킬 + +- `/release-check` — App Store 제출 전 검사 항목을 순서대로 확인 +- `/proxy-change` — 클라이언트와 프록시를 동시에 고쳐야 할 때의 절차 +- `/weekly-report` — 커밋 기록으로 주간 보고서 초안 생성 + +### 작업 방식 + +- 파일 3개 이상을 건드리거나 프록시 계약을 바꾸는 변경은 **플랜 모드로 시작한다.** +- 빌드를 실제로 돌리기 전에 "고쳤다"고 보고하지 않는다. +- 프롬프트 템플릿은 `docs/prompts/`에 있다. 잘 된 프롬프트는 개인 것이 아니라 여기 쌓는다. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d7089df --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# 기여 가이드 + +AI 에이전트용 규칙은 [`AGENTS.md`](AGENTS.md)에 있다. 사람이 처음 합류할 때 필요한 것만 여기 적는다. + +## 처음 한 번 + +```sh +git clone https://github.com/paxo-app/iOS.git +cd iOS +cp Config/Secrets.example.swift Paxo/Secrets.swift # 없으면 빌드 실패 +# 만든 파일의 appToken 에 팀에서 받은 토큰 값을 채운다 (비우면 AI 호출이 401) +open Paxo.xcodeproj +``` + +Xcode에서 Signing & Capabilities → Team을 본인 계정으로 바꾸고 Run. +첫 캡처 시 **화면 기록 권한**을 허용한 뒤 앱을 재실행해야 한다 (macOS TCC 특성). + +⚠️ **프록시에는 이미 `APP_TOKEN` 검증이 켜져 있다.** `Paxo/Secrets.swift`의 `appToken`을 +비워두면 빌드·실행은 되지만 AI 호출이 전부 401로 실패한다. 토큰 값은 팀에 요청한다. +팀 테스트용 토큰은 프로덕션과 분리된 `APP_TOKEN_PREV` 슬롯을 쓴다. + +팀원이라면 운영 규칙과 온보딩 문서가 있는 비공개 저장소도 받는다: `git clone https://github.com/paxo-app/internal.git docs/private` → `docs/private/README.md`부터 본다. + +## 작업 흐름 + +```sh +git fetch origin +git switch -c feat/무엇을-한다 origin/develop +# 작업 +xcodebuild -project Paxo.xcodeproj -scheme Paxo -destination 'platform=macOS' \ + CODE_SIGNING_ALLOWED=NO test +xcrun swift-format lint --recursive --strict --configuration .swift-format Paxo PaxoTests +git push -u origin HEAD +gh pr create --base develop +``` + +`develop`과 `main`은 보호돼 있다. 직접 푸시할 수 없고 PR이 필요하다. + +- **`develop`** — 기능 PR을 **Squash**로 머지한다. 리뷰어 승인은 필수가 아니므로 CI가 초록이면 본인이 머지한다. 단 과금 · 프록시 · 릴리즈 설정 파일을 건드리면 CODEOWNERS 승인이 필요하다 +- **`main`** — 릴리즈 PR(develop → main)만 **Merge**로 머지한다. 승인 1명이 필요하다. Squash하면 다음 릴리즈 PR에 이미 나간 변경이 다시 뜬다 + +## 커밋 + +Conventional Commits 접두사 + 한국어 제목. + +``` +feat: 캡처 영역을 마지막 선택으로 기억 + +- 재캡처 시 이전 영역을 기본값으로 제시 +- 화면이 바뀌면 무시하고 전체 화면으로 폴백 +``` + +**AI가 초안을 썼는지는 적지 않는다.** 커밋한 사람이 전적으로 책임진다. +자세한 내용은 [`docs/ai-usage-rules.md`](docs/ai-usage-rules.md). + +## 자주 걸리는 것 + +| 증상 | 원인 | +|---|---| +| `cannot find 'Secrets' in scope` | `Paxo/Secrets.swift`를 안 만들었다 | +| AI 호출이 401 | `Paxo/Secrets.swift`의 `appToken`이 비었거나 값이 틀렸다 | +| `Secrets` 중복 선언 | 템플릿을 `Paxo/` 안에 복사했다. `Config/`에 둬야 한다 | +| 캡처 시 권한 오류 반복 | 권한 허용 후 앱을 재실행해야 한다 | +| 결과 패널이 캡처 이미지에 찍힘 | `ScreenCapturer`의 PID 필터나 sleep을 건드렸다 | +| 뷰에서 런타임 크래시 | 주입받지 않는 `@EnvironmentObject`를 선언했다 — [`Paxo/AGENTS.md`](Paxo/AGENTS.md) 표 확인 | + +## 지표 + +```sh +scripts/ax-metrics.sh 30 +``` + +PR 리드타임과 CI 통과율을 집계한다. 체감이 아니라 숫자로 본다. + +## 라이선스 + +기여한 코드는 저장소의 [MIT 라이선스](LICENSE)를 따른다. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bf5ec28 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Paxo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Paxo.xcodeproj/project.pbxproj b/Paxo.xcodeproj/project.pbxproj index 630dca0..329f2aa 100644 --- a/Paxo.xcodeproj/project.pbxproj +++ b/Paxo.xcodeproj/project.pbxproj @@ -6,9 +6,20 @@ objectVersion = 77; objects = { +/* Begin PBXContainerItemProxy section */ + D0BE000000000000000000BA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D0BE000000000000000000AA /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0BE000000000000000000A6; + remoteInfo = Paxo; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ B729AD56300E6FFC0012D210 /* Paxo.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; name = Paxo.storekit; path = Config/Paxo.storekit; sourceTree = ""; }; D0BE000000000000000000A2 /* Paxo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Paxo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D0BE000000000000000000B0 /* PaxoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PaxoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -17,6 +28,11 @@ path = Paxo; sourceTree = ""; }; + D0BE000000000000000000B1 /* PaxoTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = PaxoTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -27,6 +43,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D0BE000000000000000000B4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -35,6 +58,7 @@ children = ( B729AD56300E6FFC0012D210 /* Paxo.storekit */, D0BE000000000000000000A1 /* Paxo */, + D0BE000000000000000000B1 /* PaxoTests */, D0BE000000000000000000A5 /* Products */, ); sourceTree = ""; @@ -43,6 +67,7 @@ isa = PBXGroup; children = ( D0BE000000000000000000A2 /* Paxo.app */, + D0BE000000000000000000B0 /* PaxoTests.xctest */, ); name = Products; sourceTree = ""; @@ -72,6 +97,29 @@ productReference = D0BE000000000000000000A2 /* Paxo.app */; productType = "com.apple.product-type.application"; }; + D0BE000000000000000000B2 /* PaxoTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0BE000000000000000000B6 /* Build configuration list for PBXNativeTarget "PaxoTests" */; + buildPhases = ( + D0BE000000000000000000B3 /* Sources */, + D0BE000000000000000000B4 /* Frameworks */, + D0BE000000000000000000B5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D0BE000000000000000000B9 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + D0BE000000000000000000B1 /* PaxoTests */, + ); + name = PaxoTests; + packageProductDependencies = ( + ); + productName = PaxoTests; + productReference = D0BE000000000000000000B0 /* PaxoTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -85,6 +133,10 @@ D0BE000000000000000000A6 = { CreatedOnToolsVersion = 16.0; }; + D0BE000000000000000000B2 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = D0BE000000000000000000A6; + }; }; }; buildConfigurationList = D0BE000000000000000000AB /* Build configuration list for PBXProject "Paxo" */; @@ -103,6 +155,7 @@ projectRoot = ""; targets = ( D0BE000000000000000000A6 /* Paxo */, + D0BE000000000000000000B2 /* PaxoTests */, ); }; /* End PBXProject section */ @@ -115,6 +168,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D0BE000000000000000000B5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -125,8 +185,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D0BE000000000000000000B3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + D0BE000000000000000000B9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0BE000000000000000000A6 /* Paxo */; + targetProxy = D0BE000000000000000000BA /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ D0BE000000000000000000AC /* Debug */ = { isa = XCBuildConfiguration; @@ -180,6 +255,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L3JLLU88WG; ENABLE_HARDENED_RUNTIME = YES; + EXCLUDED_SOURCE_FILE_NAMES = "*.md"; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Config/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -203,6 +279,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L3JLLU88WG; ENABLE_HARDENED_RUNTIME = YES; + EXCLUDED_SOURCE_FILE_NAMES = "*.md"; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Config/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -216,6 +293,38 @@ }; name = Release; }; + D0BE000000000000000000B7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = L3JLLU88WG; + EXCLUDED_SOURCE_FILE_NAMES = "*.md"; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.hyeseong.PaxoTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Paxo.app/Contents/MacOS/Paxo"; + }; + name = Debug; + }; + D0BE000000000000000000B8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = L3JLLU88WG; + EXCLUDED_SOURCE_FILE_NAMES = "*.md"; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.hyeseong.PaxoTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Paxo.app/Contents/MacOS/Paxo"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -237,6 +346,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D0BE000000000000000000B6 /* Build configuration list for PBXNativeTarget "PaxoTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0BE000000000000000000B7 /* Debug */, + D0BE000000000000000000B8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = D0BE000000000000000000AA /* Project object */; diff --git a/Paxo.xcodeproj/xcshareddata/xcschemes/Paxo.xcscheme b/Paxo.xcodeproj/xcshareddata/xcschemes/Paxo.xcscheme index e665c7b..8f4b64c 100644 --- a/Paxo.xcodeproj/xcshareddata/xcschemes/Paxo.xcscheme +++ b/Paxo.xcodeproj/xcshareddata/xcschemes/Paxo.xcscheme @@ -28,6 +28,18 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + Int { - let defaults = UserDefaults.standard guard defaults.string(forKey: dayKey) == today else { return 0 } return defaults.integer(forKey: countKey) } @@ -29,7 +35,6 @@ struct UsageTracker { } func recordUse() { - let defaults = UserDefaults.standard if defaults.string(forKey: dayKey) != today { defaults.set(today, forKey: dayKey) defaults.set(0, forKey: countKey) diff --git a/Paxo/UI/HotkeyRecorderField.swift b/Paxo/UI/HotkeyRecorderField.swift index 89ce259..0c46e9a 100644 --- a/Paxo/UI/HotkeyRecorderField.swift +++ b/Paxo/UI/HotkeyRecorderField.swift @@ -52,7 +52,7 @@ struct HotkeyRecorderField: View { } private func handle(_ event: NSEvent) { - if event.keyCode == 53 { // Escape + if event.keyCode == 53 { // Escape stopRecording() return } diff --git a/Paxo/UI/MarkdownView.swift b/Paxo/UI/MarkdownView.swift index 7a0b5c3..aa93fad 100644 --- a/Paxo/UI/MarkdownView.swift +++ b/Paxo/UI/MarkdownView.swift @@ -76,7 +76,8 @@ struct MarkdownBlocksView: View { index = line.index(after: index) } guard !digits.isEmpty, index < line.endIndex, - line[index] == "." || line[index] == ")" else { return nil } + line[index] == "." || line[index] == ")" + else { return nil } let marker = digits + String(line[index]) let rest = line[line.index(after: index)...].trimmingCharacters(in: .whitespaces) guard !rest.isEmpty else { return nil } diff --git a/Paxo/UI/MenuContentView.swift b/Paxo/UI/MenuContentView.swift index d740136..187cfd7 100644 --- a/Paxo/UI/MenuContentView.swift +++ b/Paxo/UI/MenuContentView.swift @@ -75,9 +75,10 @@ struct MenuContentView: View { SettingsLink { Text("설정…") } - .simultaneousGesture(TapGesture().onEnded { - NSApp.activate(ignoringOtherApps: true) - }) + .simultaneousGesture( + TapGesture().onEnded { + NSApp.activate(ignoringOtherApps: true) + }) Spacer() Button("종료") { NSApp.terminate(nil) diff --git a/Paxo/UI/OnboardingView.swift b/Paxo/UI/OnboardingView.swift index 572e1e0..c808f51 100644 --- a/Paxo/UI/OnboardingView.swift +++ b/Paxo/UI/OnboardingView.swift @@ -62,15 +62,18 @@ struct OnboardingView: View { Text("Paxo에 오신 것을 환영해요") .font(.title2.bold()) VStack(alignment: .leading, spacing: 10) { - featureRow(icon: "camera.viewfinder", - title: "단축키 한 번으로 캡처", - detail: "\(appState.hotkey.display) 를 누르면 화면 속 문제를 바로 캡처해요.") - featureRow(icon: "text.book.closed", - title: "정답과 해설을 함께", - detail: "AI가 정답과 함께 왜 그런지 풀이 과정을 설명해요.") - featureRow(icon: "checkmark.circle", - title: "빠른 채점 모드", - detail: "문제집 셀프 채점엔 정답 먼저, 해설은 필요할 때만.") + featureRow( + icon: "camera.viewfinder", + title: "단축키 한 번으로 캡처", + detail: "\(appState.hotkey.display) 를 누르면 화면 속 문제를 바로 캡처해요.") + featureRow( + icon: "text.book.closed", + title: "정답과 해설을 함께", + detail: "AI가 정답과 함께 왜 그런지 풀이 과정을 설명해요.") + featureRow( + icon: "checkmark.circle", + title: "빠른 채점 모드", + detail: "문제집 셀프 채점엔 정답 먼저, 해설은 필요할 때만.") } .padding(.top, 4) } @@ -115,7 +118,9 @@ struct OnboardingView: View { } .buttonStyle(.borderedProminent) Button("시스템 설정 열기") { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { + if let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") + { NSWorkspace.shared.open(url) } } @@ -137,15 +142,18 @@ struct OnboardingView: View { Text("메뉴바에서 만나요") .font(.title2.bold()) VStack(alignment: .leading, spacing: 10) { - featureRow(icon: "text.viewfinder", - title: "Dock에는 보이지 않아요", - detail: "Paxo는 화면 위 메뉴바에 상주하는 앱이에요.") - featureRow(icon: "keyboard", - title: "언제든 \(appState.hotkey.display)", - detail: "어떤 앱을 쓰고 있어도 단축키로 바로 풀이를 시작해요. 단축키는 설정에서 바꿀 수 있어요.") - featureRow(icon: "gearshape", - title: "설정에서 맞춤 조정", - detail: "캡처 방식(영역/전체 화면), 빠른 채점 모드, 과목 프리셋을 바꿀 수 있어요.") + featureRow( + icon: "text.viewfinder", + title: "Dock에는 보이지 않아요", + detail: "Paxo는 화면 위 메뉴바에 상주하는 앱이에요.") + featureRow( + icon: "keyboard", + title: "언제든 \(appState.hotkey.display)", + detail: "어떤 앱을 쓰고 있어도 단축키로 바로 풀이를 시작해요. 단축키는 설정에서 바꿀 수 있어요.") + featureRow( + icon: "gearshape", + title: "설정에서 맞춤 조정", + detail: "캡처 방식(영역/전체 화면), 빠른 채점 모드, 과목 프리셋을 바꿀 수 있어요.") } .padding(.top, 4) } diff --git a/Paxo/UI/ResultPanelController.swift b/Paxo/UI/ResultPanelController.swift index 1aa9287..216eb46 100644 --- a/Paxo/UI/ResultPanelController.swift +++ b/Paxo/UI/ResultPanelController.swift @@ -34,7 +34,7 @@ final class ResultPanelController { func hide() { panel?.orderOut(nil) - userMoved = false // 다음 표시 때는 설정 위치에서 다시 시작 + userMoved = false // 다음 표시 때는 설정 위치에서 다시 시작 } private func position(using panelPosition: PanelPosition, on screen: NSScreen?) { diff --git a/Paxo/UI/SettingsView.swift b/Paxo/UI/SettingsView.swift index b134a21..313dd1b 100644 --- a/Paxo/UI/SettingsView.swift +++ b/Paxo/UI/SettingsView.swift @@ -12,11 +12,13 @@ struct SettingsView: View { Text(mode.displayName).tag(mode) } } - Text(appState.resultDisplayMode == .toast - ? "정답만 잠깐 떴다 사라집니다. 해설은 생성하지 않아요." - : "정답과 해설을 창으로 보여줍니다.") - .font(.caption) - .foregroundStyle(.secondary) + Text( + appState.resultDisplayMode == .toast + ? "정답만 잠깐 떴다 사라집니다. 해설은 생성하지 않아요." + : "정답과 해설을 창으로 보여줍니다." + ) + .font(.caption) + .foregroundStyle(.secondary) Picker("표시 위치", selection: $appState.panelPosition) { ForEach(PanelPosition.allCases) { position in @@ -39,9 +41,11 @@ struct SettingsView: View { if appState.resultDisplayMode == .panel { Section("풀이") { Toggle("빠른 채점 모드", isOn: $appState.quickCheckMode) - Text("문제집을 풀고 스스로 채점할 때를 위한 모드입니다. 정답을 먼저 크게 표시하고, 해설은 '해설 보기'를 눌렀을 때 생성합니다. 끄면 항상 정답과 해설이 함께 표시됩니다.") - .font(.caption) - .foregroundStyle(.secondary) + Text( + "문제집을 풀고 스스로 채점할 때를 위한 모드입니다. 정답을 먼저 크게 표시하고, 해설은 '해설 보기'를 눌렀을 때 생성합니다. 끄면 항상 정답과 해설이 함께 표시됩니다." + ) + .font(.caption) + .foregroundStyle(.secondary) } } diff --git a/Paxo/UI/ToastController.swift b/Paxo/UI/ToastController.swift index 27e2044..052f117 100644 --- a/Paxo/UI/ToastController.swift +++ b/Paxo/UI/ToastController.swift @@ -40,14 +40,16 @@ final class ToastController { func dismiss() { guard let panel, isVisible else { return } isVisible = false - NSAnimationContext.runAnimationGroup({ ctx in - ctx.duration = 0.3 - panel.animator().alphaValue = 0 - }, completionHandler: { [weak self, weak panel] in - // 페이드 중 show()가 다시 호출돼 isVisible=true가 됐으면 이 낡은 완료는 무시. - guard let self, !self.isVisible else { return } - panel?.orderOut(nil) - }) + NSAnimationContext.runAnimationGroup( + { ctx in + ctx.duration = 0.3 + panel.animator().alphaValue = 0 + }, + completionHandler: { [weak self, weak panel] in + // 페이드 중 show()가 다시 호출돼 isVisible=true가 됐으면 이 낡은 완료는 무시. + guard let self, !self.isVisible else { return } + panel?.orderOut(nil) + }) } private func ensurePanel(appState: AppState) -> NSHostingView { diff --git a/PaxoTests/CodableRoundTripTests.swift b/PaxoTests/CodableRoundTripTests.swift new file mode 100644 index 0000000..becb281 --- /dev/null +++ b/PaxoTests/CodableRoundTripTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing + +@testable import Paxo + +/// 히스토리는 JSON으로 디스크에 남는다. 필드 이름이나 타입이 바뀌면 +/// 기존 사용자의 기록이 조용히 사라진다. +struct CodableRoundTripTests { + @Test func solveResult가_왕복해도_동일하다() throws { + var original = SolveResult(preset: .math) + original.answer = "③" + original.explanation = "**핵심 개념**\n- 첫째\n- 둘째" + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(SolveResult.self, from: data) + + #expect(decoded == original) + #expect(decoded.id == original.id) + #expect(decoded.answer == "③") + } + + /// 정답만 있고 해설이 없는 상태는 정상적인 중간 상태다 (빠른 채점·토스트 모드). + @Test func 해설이_없는_기록도_왕복한다() throws { + var original = SolveResult(preset: .licenseExam) + original.answer = "가나다" + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(SolveResult.self, from: data) + + #expect(decoded == original) + #expect(decoded.explanation == nil) + } + + @Test func 기록_배열이_왕복한다() throws { + let items = SubjectPreset.allCases.map { SolveResult(preset: $0) } + let data = try JSONEncoder().encode(items) + let decoded = try JSONDecoder().decode([SolveResult].self, from: data) + #expect(decoded == items) + } + + @Test func hotkeySpec이_왕복해도_동일하다() throws { + let original = HotkeySpec.default + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(HotkeySpec.self, from: data) + + #expect(decoded == original) + #expect(decoded.display == "⌥⌘S") + } + + /// rawValue가 바뀌면 저장된 설정을 못 읽는다. + @Test func 설정_열거형의_rawValue가_고정돼_있다() { + #expect(SubjectPreset.general.rawValue == "general") + #expect(CaptureMode.region.rawValue == "region") + #expect(ResultDisplayMode.panel.rawValue == "panel") + #expect(PanelPosition.topRight.rawValue == "topRight") + } +} diff --git a/PaxoTests/PanelPositionTests.swift b/PaxoTests/PanelPositionTests.swift new file mode 100644 index 0000000..cbfbef0 --- /dev/null +++ b/PaxoTests/PanelPositionTests.swift @@ -0,0 +1,66 @@ +import AppKit +import Testing + +@testable import Paxo + +/// `PanelPosition.origin`은 AppKit 좌하단 원점을 가정한다. +/// 다중 모니터에서 창이 엉뚱한 곳에 뜨는 회귀를 막기 위한 테스트다. +@MainActor +struct PanelPositionTests { + private let size = CGSize(width: 400, height: 300) + private let margin: CGFloat = 16 + + /// 주 화면이 없는 환경에서는 검증을 건너뛴다 (헤드리스 CI 대비). + private var screen: NSScreen? { NSScreen.main } + + @Test func 좌측_위치는_왼쪽_여백에_붙는다() throws { + let screen = try #require(screen) + let frame = screen.visibleFrame + for position in [PanelPosition.topLeft, .bottomLeft] { + let origin = position.origin(for: size, on: screen, margin: margin) + #expect(origin.x == frame.minX + margin) + } + } + + @Test func 우측_위치는_오른쪽_여백에_붙는다() throws { + let screen = try #require(screen) + let frame = screen.visibleFrame + for position in [PanelPosition.topRight, .bottomRight] { + let origin = position.origin(for: size, on: screen, margin: margin) + #expect(origin.x == frame.maxX - size.width - margin) + } + } + + @Test func 중앙_위치는_가로_중심에_놓인다() throws { + let screen = try #require(screen) + let frame = screen.visibleFrame + for position in [PanelPosition.topCenter, .center, .bottomCenter] { + let origin = position.origin(for: size, on: screen, margin: margin) + #expect(origin.x == frame.midX - size.width / 2) + } + } + + /// 상단은 y가 크고 하단은 y가 작다 — 좌표계를 뒤집으면 이 테스트가 깨진다. + @Test func 상단이_하단보다_y가_크다() throws { + let screen = try #require(screen) + let top = PanelPosition.topCenter.origin(for: size, on: screen, margin: margin) + let bottom = PanelPosition.bottomCenter.origin(for: size, on: screen, margin: margin) + #expect(top.y > bottom.y) + } + + @Test func 모든_위치가_화면_안에_들어온다() throws { + let screen = try #require(screen) + let frame = screen.visibleFrame + for position in PanelPosition.allCases { + let origin = position.origin(for: size, on: screen, margin: margin) + #expect(origin.x >= frame.minX, "\(position) 가 왼쪽으로 벗어남") + #expect(origin.y >= frame.minY, "\(position) 가 아래로 벗어남") + #expect(origin.x + size.width <= frame.maxX, "\(position) 가 오른쪽으로 벗어남") + #expect(origin.y + size.height <= frame.maxY, "\(position) 가 위로 벗어남") + } + } + + @Test func 일곱_가지_위치가_모두_정의돼_있다() { + #expect(PanelPosition.allCases.count == 7) + } +} diff --git a/PaxoTests/PromptsTests.swift b/PaxoTests/PromptsTests.swift new file mode 100644 index 0000000..ad41b11 --- /dev/null +++ b/PaxoTests/PromptsTests.swift @@ -0,0 +1,41 @@ +import Testing + +@testable import Paxo + +/// 프롬프트가 조용히 깨지면 앱은 정상 동작하는데 답만 이상해진다. +/// 프록시는 text 파트를 8000자로 제한하므로 길이도 함께 지킨다. +struct PromptsTests { + /// `.general`의 promptHint는 의도적으로 비어 있다. Swift에서 contains("")는 false이므로 + /// 빈 힌트는 삽입 여부를 검사할 수 없다. + @Test(arguments: SubjectPreset.allCases) + func 정답_프롬프트에_프리셋_힌트가_들어간다(preset: SubjectPreset) { + let prompt = Prompts.answer(preset: preset) + #expect(!prompt.isEmpty) + if !preset.promptHint.isEmpty { + #expect(prompt.contains(preset.promptHint)) + } + } + + @Test(arguments: SubjectPreset.allCases) + func 해설_프롬프트에_정답이_삽입된다(preset: SubjectPreset) { + let answer = "③" + let prompt = Prompts.explanation(preset: preset, answer: answer) + #expect(prompt.contains(answer)) + if !preset.promptHint.isEmpty { + #expect(prompt.contains(preset.promptHint)) + } + } + + /// 1단계는 정답만 받는다. 여기서 풀이까지 요구하면 2단계 구조가 무의미해진다. + @Test func 정답_프롬프트는_한_줄_출력을_요구한다() { + let prompt = Prompts.answer(preset: .general) + #expect(prompt.contains("한 줄")) + } + + @Test(arguments: SubjectPreset.allCases) + func 프롬프트가_프록시_길이_제한_안에_들어온다(preset: SubjectPreset) { + let limit = 8000 + #expect(Prompts.answer(preset: preset).count < limit) + #expect(Prompts.explanation(preset: preset, answer: String(repeating: "가", count: 200)).count < limit) + } +} diff --git a/PaxoTests/UsageTrackerTests.swift b/PaxoTests/UsageTrackerTests.swift new file mode 100644 index 0000000..82d231b --- /dev/null +++ b/PaxoTests/UsageTrackerTests.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing + +@testable import Paxo + +/// 무료 사용량 차감은 매출에 직결된다. 날짜 롤오버가 깨지면 무제한으로 쓰이거나 +/// 반대로 결제한 적 없는 사용자가 조기에 막힌다. +struct UsageTrackerTests { + /// 매 테스트마다 격리된 UserDefaults. suite 이름이 겹치면 서로 간섭한다. + private func makeDefaults(_ name: String) throws -> UserDefaults { + let defaults = try #require(UserDefaults(suiteName: "PaxoTests.\(name).\(UUID().uuidString)")) + defaults.removePersistentDomain(forName: defaults.description) + return defaults + } + + private var today: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: Date()) + } + + @Test func 초기_상태는_한도_전체가_남는다() throws { + let tracker = UsageTracker(defaults: try makeDefaults("fresh")) + #expect(tracker.usedToday() == 0) + #expect(tracker.remainingToday() == UsageTracker.dailyFreeLimit) + } + + @Test func 사용할수록_남은_횟수가_줄어든다() throws { + let tracker = UsageTracker(defaults: try makeDefaults("decrement")) + for used in 1...UsageTracker.dailyFreeLimit { + tracker.recordUse() + #expect(tracker.usedToday() == used) + #expect(tracker.remainingToday() == UsageTracker.dailyFreeLimit - used) + } + } + + /// 한도를 넘겨도 remainingToday가 음수가 되면 안 된다. + @Test func 한도를_넘겨도_남은_횟수는_0_아래로_안_간다() throws { + let tracker = UsageTracker(defaults: try makeDefaults("overflow")) + for _ in 0..<(UsageTracker.dailyFreeLimit + 5) { + tracker.recordUse() + } + #expect(tracker.remainingToday() == 0) + } + + /// 저장된 날짜가 오늘이 아니면 사용량은 0으로 보여야 한다. + @Test func 날짜가_바뀌면_사용량이_리셋된다() throws { + let defaults = try makeDefaults("rollover") + defaults.set("2020-01-01", forKey: "usage.day") + defaults.set(UsageTracker.dailyFreeLimit, forKey: "usage.count") + + let tracker = UsageTracker(defaults: defaults) + #expect(tracker.usedToday() == 0) + #expect(tracker.remainingToday() == UsageTracker.dailyFreeLimit) + } + + /// 롤오버 후 첫 사용은 1회여야 한다. 이전 카운트가 이어지면 안 된다. + @Test func 롤오버_후_첫_사용은_1회로_기록된다() throws { + let defaults = try makeDefaults("rollover-record") + defaults.set("2020-01-01", forKey: "usage.day") + defaults.set(99, forKey: "usage.count") + + let tracker = UsageTracker(defaults: defaults) + tracker.recordUse() + #expect(tracker.usedToday() == 1) + #expect(defaults.string(forKey: "usage.day") == today) + } + + @Test func 무료_한도는_하루_3회다() { + #expect(UsageTracker.dailyFreeLimit == 3) + } +} diff --git a/README.md b/README.md index 7d15c99..a91aee7 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ ## 개발 셋업 +> 팀 작업 흐름과 기여 방법은 [CONTRIBUTING.md](CONTRIBUTING.md), +> AI 에이전트 규칙은 [AGENTS.md](AGENTS.md), AI 사용 원칙은 [docs/ai-usage-rules.md](docs/ai-usage-rules.md) 참고. + - 요구사항: macOS 14+, Xcode 16+ - **시크릿 파일 생성 (클론 직후 1회 필수)** — `Paxo/Secrets.swift`는 Git에 올라가지 않으므로 직접 만들어야 한다: @@ -58,9 +61,13 @@ Paxo/ UI/ 메뉴바 팝업 / 결과 패널 / 토스트 / 설정 / 페이월 / 마크다운 렌더러 Store/ StoreKit 2 구독 + 무료 사용량 추적 Storage/ 히스토리 JSON, 키체인 +PaxoTests/ 순수 로직 테스트 (Swift Testing) Config/ Info.plist, entitlements (샌드박스), StoreKit 테스트 설정, 시크릿 템플릿 proxy-vercel/ Vercel API 프록시 (기본 — 키 보호 + 토큰·본문 검증 + 사용량 제한) proxy/ Cloudflare Worker 프록시 (백업 — Gemini 지역 차단 이슈로 강등) +docs/ 아키텍처 · 도메인 용어 · 결정 기록 · AI 사용 원칙 · 프롬프트 라이브러리 +.claude/skills/ Claude Code 스킬 (release-check, proxy-change, weekly-report) +.github/workflows/ CI — 빌드·테스트·포맷·금지파일 검사 ``` ## 로드맵 @@ -72,6 +79,7 @@ proxy/ Cloudflare Worker 프록시 (백업 — Gemini 지역 - [ ] App Store Connect에 구독 상품 생성 (product ID 동일하게) - [ ] 서버 측 구독 검증 — StoreKit 영수증(JWS)을 프록시에서 확인, 클라이언트 카운트 대체 - [x] 앱 아이콘 (Assets.xcassets — 원본 스크립트로 재생성 가능) + 온보딩(환영·권한·시작 3단계) +- [x] AX 인프라 — AGENTS.md/CLAUDE.md, CI 게이트, 테스트 타깃, 프롬프트 라이브러리 - [ ] App Store 제출 (제출 전 `vercel env add APP_TOKEN production` 강제화 — 아래 '프록시 보안' 참고) - [ ] v2 — 오답노트 자동 축적, 복습 알림(SRS) @@ -101,3 +109,12 @@ proxy/ Cloudflare Worker 프록시 (백업 — Gemini 지역 - 화면 캡처(ScreenCaptureKit + TCC)와 전역 단축키(RegisterEventHotKey)는 MAS 심사 허용 범위 - 구독은 Apple IAP 의무 (Small Business Program 수수료 15%) - 포지셔닝: "풀이·해설 학습 도우미" — 해설은 항상 접근 가능해야 함 (빠른 채점 모드도 해설을 숨기지 않고 접어둘 뿐) + +## 브랜치 + +- `develop` — 기본 브랜치. 기능 PR은 여기로 보내고 Squash로 머지한다 +- `main` — 출시본. 릴리즈 PR(develop → main)만 Merge로 머지한다 + +## 라이선스 + +[MIT](LICENSE) diff --git a/docs/ai-usage-rules.md b/docs/ai-usage-rules.md new file mode 100644 index 0000000..f491a88 --- /dev/null +++ b/docs/ai-usage-rules.md @@ -0,0 +1,56 @@ +# AI 사용 규칙 + +4인이 10인처럼 움직이는 것이 목표고, 수단은 각자 AI를 잘 쓰는 것이 아니라 +**팀의 작업 흐름 자체를 AI 전제로 설계하는 것**이다. + +## 3원칙 + +### 1. 초안은 AI가 + +백지에서 시작하지 않는다. 사람은 검토하고 결정한다. +PR 설명, 커밋 메시지, 릴리스 노트, 버그 재현 코드, 회의록 요약은 전부 초안부터 AI가 만든다. + +### 2. 프롬프트는 자산 + +잘 된 프롬프트는 개인 것이 아니다. + +- 개발용 → `docs/prompts/` (버전 관리 대상) +- 마케팅·리서치·회의록 → 노션 프롬프트 DB + +두 번 이상 같은 프롬프트를 입력했다면 그때가 저장할 시점이다. + +### 3. 책임은 사람 + +**커밋한 사람이 전적으로 책임진다.** 초안을 누가 썼는지는 묻지 않고, 커밋에 표기하지도 않는다. +"AI가 그렇게 썼다"는 해명이 되지 않는다. + +숫자, 출처, 외부 API 동작, 법적 문구는 **반드시 담당자가 직접 확인한 뒤** 반영한다. + +## 결과물 검토 체크리스트 + +머지 전에 스스로 확인한다. PR 템플릿에도 같은 항목이 있다. + +- [ ] 빌드와 테스트를 **직접 돌렸다.** AI가 "통과할 것"이라고 말한 것을 믿지 않았다 +- [ ] 코드에 들어간 **숫자와 문자열을 직접 확인**했다 (한도, 가격, URL, 제품 ID) +- [ ] **기존 유틸리티를 검색**했다. 비슷한 게 이미 있는데 새로 만들지 않았다 +- [ ] 외부 문서를 인용했다면 **원문을 열어봤다** +- [ ] 프록시 계약을 건드렸다면 **클라이언트와 서버 양쪽**을 고쳤다 +- [ ] 시크릿이나 비공개 문서가 섞이지 않았다 (저장소는 **공개**) + +## 알아둘 함정 + +이 규칙은 이론이 아니라 실제 실패 사례에서 나왔다. + +| 사례 | 무슨 일이 있었나 | 우리의 대응 | +|---|---|---| +| METR | AI 쓴 개발자가 19% 느려졌는데 본인은 빨라졌다고 느꼈다 | 체감 대신 PR 리드타임을 실측한다 | +| GitClear | 중복 코드 8배, 리팩터링 39.9% 감소 | "기존 유틸 검색"을 체크리스트에 넣었다 | +| DORA 2025 | AI 도입률 25%↑마다 배포 안정성 7.2%↓ | 속도보다 CI 게이트와 테스트를 먼저 깔았다 | +| 여기어때 | 에이전트마다 코드 스타일이 파편화됐다 | AGENTS.md 단일 원본 + swift-format CI 강제 | +| AWS 현장 실험 | 도구만 바꾼 팀은 성과가 없었다 (50개 중 25개만 개선) | 흐름을 바꿨다 — main 직접 커밋에서 PR 필수로 | + +## 도구별 설정 + +- **Claude Code** — `CLAUDE.md`를 읽는다. `@AGENTS.md`로 공통 규칙을 가져온 뒤 Claude 전용 항목을 덧붙였다 +- **Codex** — `AGENTS.md`를 직접 읽는다. 하위 디렉토리의 `AGENTS.md`는 그쪽 파일을 만질 때 적용된다 +- 규칙을 바꿀 때는 **`AGENTS.md`를 고친다.** `CLAUDE.md`는 파생 파일이다 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a7cf8a1 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,55 @@ +# 아키텍처 + +> 상태: 초안 — 킥오프 후 전원 검증. AI 리뷰가 읽는 문서다. 코드와 어긋나면 기록성 이슈로 바로 고친다. + +## 구성 요소 + +| 구성 요소 | 위치 | 역할 | +| --- | --- | --- | +| 메뉴바 앱 | `Paxo/` | 전역 단축키 → 화면 캡처 → 프록시 호출 → 결과 표시. macOS 14+, SwiftUI + AppKit | +| API 프록시 | `proxy-vercel/` | Gemini API 키를 서버에 두고 요청을 대신 보낸다. Vercel `iad1` 리전 | +| Gemini API | 외부 | 정답 · 해설 생성. 모델은 프록시가 정한다 | +| StoreKit 2 · App Store | 외부 | Pro 구독(월간 · 연간) 결제와 권한 확인 | +| 레거시 프록시 | `proxy/` | Cloudflare Worker 백업. 신규 작업하지 않는다 | + +## 한 번의 풀이 흐름 + +``` +단축키 ⌥⌘S + → AppState.beginSolve 무료 사용량 · Pro 확인 (부족하면 캡처 전에 페이월) + → SelectionOverlay 영역 선택 (전체 화면 모드면 생략) + → ScreenCapturer ScreenCaptureKit, JPEG q0.82, 최대 2000px + → GeminiService POST {프록시}/generate — 정답 요청 + → proxy-vercel/api/generate 헤더 검증 → 본문을 허용 형태로 재조립 → Gemini + → 결과 패널 · 토스트 정답 표시 + → (필요 시) 해설 요청 같은 경로로 한 번 더 +``` + +- 정답과 해설은 따로 요청한다. 정답을 먼저 보여주기 위해서다 +- 무료 사용량은 정답 요청이 성공한 뒤에만 차감한다. 해설 실패는 다시 차감하지 않는다 +- 토스트 모드는 해설을 요청하지 않는다 + +## 앱 ↔ 프록시 계약 + +요약만 적는다. 정본은 `proxy-vercel/AGENTS.md`. + +- `POST /generate`, 헤더 `x-paxo-device`(UUID) · `x-paxo-token` +- 본문은 텍스트 파트 1개 + 이미지 파트 1개만 허용한다. 프록시가 허용 목록으로 다시 조립하므로 다른 필드는 전달되지 않는다 +- 계약을 바꾸면 `Paxo/AI/GeminiService.swift`와 `proxy-vercel/api/generate.js`를 **같은 PR**에서 고치고, 배포는 프록시가 먼저다 + +## 데이터가 있는 곳 + +| 데이터 | 위치 | +| --- | --- | +| 설정 · 단축키 · 무료 사용량 | UserDefaults | +| 풀이 기록 (최대 100개, 이미지 제외) | 앱 컨테이너의 `history.json` | +| 캡처 이미지 | 메모리에만 최근 8개. 디스크에 쓰지 않는다 | +| 구독 상태 | StoreKit 2 `Transaction.currentEntitlements` | +| Gemini API 키 | 프록시 환경변수. 앱에는 없다 | + +## 배포 + +| 대상 | 방법 | 실행 | +| --- | --- | --- | +| 프록시 | Vercel. 프리뷰는 자동, 프로덕션은 사람이 실행 | 과금 · 프록시 가디언 | +| 앱 | develop → main 릴리즈 PR, 태그, Xcode Archive → App Store Connect | 사람만 | diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..de91249 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,14 @@ +# 결정 기록 + +회의에서 정한 것(경로 1)과 작업하다 발견해 합의한 것(경로 2)이 모두 여기 도착한다. 한 줄이면 충분하다. +**여기로 옮기지 않은 결정은 아직 결정이 아니다.** AI 리뷰도 이 파일을 읽는다. + +| 날짜 | 결정 | 이유 | 상태 | +| --- | --- | --- | --- | +| 2026-09-07 | 라이선스는 MIT로 한다 | 코드 공개 범위를 명확히 한다. 앱 이름 · 아이콘 · App Store 등록 · 프록시 키는 라이선스와 별개다 | 채택 | +| 2026-09-07 | git history는 정리하지 않고 공개한다 | 삭제된 문서의 민감도가 낮고, 재작성은 전원 재클론 비용이 크다 | 채택 | +| 2026-09-09 | 컨텍스트 정본은 저장소다. Notion은 논의 · 회의록용 | AI 리뷰와 로컬 AI가 읽을 수 있는 곳은 저장소뿐이다 | 채택 | +| 2026-09-09 | 리뷰는 혼합으로 한다. 과금 · 프록시 · 릴리즈 설정만 머지 전 승인, 나머지는 CI · AI 리뷰 통과 후 머지하고 24시간 안에 사후 리뷰 | 유료 앱이라 과금 · 프록시 계약 실수는 매출과 API 비용에 바로 이어진다. 그 밖의 경로에서는 리뷰 대기를 없앤다 | 채택 | +| 2026-09-13 | 저장소를 공개로 전환했다 | GitHub Actions 무료 · 보호 규칙 · 시크릿 스캔을 쓰기 위해 | 완료 | +| 2026-09-13 | 브랜치는 develop(기본, Squash 머지)에서 통합하고 main은 릴리즈 PR(Merge 머지)만 받는다 | 출시본과 개발 중인 코드를 분리한다. 릴리즈 PR을 squash하면 다음 릴리즈에 이미 나간 변경이 다시 뜨므로 main은 Merge만 쓴다 | 채택 | +| 2026-09-13 | 공개할 수 없는 문서(운영안 · 제출 절차)는 비공개 저장소 `paxo-app/internal`에 두고 `docs/private/`에 clone한다 | 공개 저장소에 둘 수 없는 내용도 이력 관리가 필요하다 | 채택 | diff --git a/docs/domain.md b/docs/domain.md new file mode 100644 index 0000000..11353b6 --- /dev/null +++ b/docs/domain.md @@ -0,0 +1,39 @@ +# 도메인 용어 · 규칙 + +> 상태: 뼈대 — 킥오프에서 전원 보강. AI 리뷰가 읽는 문서다. 새 용어가 생기면 기록성 이슈로 한 줄 추가한다. + +## 사용자에게 보이는 기능 + +| 용어 | 뜻 | 코드 | +| --- | --- | --- | +| 캡처 | 화면 일부(영역) 또는 전체를 찍는 것. 전역 단축키 기본값 ⌥⌘S | `Paxo/Capture/` | +| 정답 | 캡처한 문제의 답. 먼저 빠르게 보여준다 | `Paxo/AI/` | +| 해설 | 풀이 과정. 정답 뒤에 이어서 만든다. **접을 수는 있어도 없앨 수는 없다** (App Store 심사 포지셔닝) | `Paxo/AI/` | +| 빠른 채점 모드 | 정답만 크게 보여주고, 해설은 "해설 보기"를 누를 때 만든다 | 설정 | +| 토스트 모드 | 정답만 몇 초 떴다 사라진다. 해설을 만들지 않는다 | `ToastController` | +| 결과 위치 | 결과 창이 뜨는 위치. 7곳 중 하나 | `PanelPosition` | +| 히스토리 | 지난 풀이 기록. 최대 100개, 이미지는 저장하지 않는다 | `HistoryStore` | +| 온보딩 | 첫 실행 3단계 (환영 · 화면 기록 권한 · 시작) | `OnboardingView` | + +## 과금 + +| 용어 | 뜻 | +| --- | --- | +| 무료 사용량 | 하루 3회. 정답 요청이 성공했을 때만 1회 차감한다 | +| Pro | 월간 · 연간 구독, 무제한. 7일 무료 체험 | +| 페이월 | 무료 사용량을 다 쓰면 **캡처 전에** 뜬다 | + +## 개발 용어 + +| 용어 | 뜻 | +| --- | --- | +| 계약 | 앱과 프록시가 주고받는 요청 형태. 양쪽을 같은 PR에서 고친다 | +| 위험 경로 | 과금 · 프록시 · 릴리즈 설정 파일. 머지 전에 가디언 승인이 필요하다 | +| 기록성 / 규칙성 | 문서 변경의 두 종류. 기록성(사실 · 경로)은 바로 머지, 규칙성(남을 구속)은 가디언 승인 | +| 참조 구현 | "새로 만들 때 이걸 보고 만든다"로 지정한 기존 코드. 각 AGENTS.md에 경로를 적는다 | + +## 킥오프에서 채울 것 + +- [ ] 주요 사용자 (수험생 · 대학생 · 자격증 준비생 등)와 대표 사용 장면 +- [ ] 잘 푸는 문제 유형과 한계 (손글씨 · 도형 · 긴 지문 등) +- [ ] 오답 · 실패 · 한도 초과 때 사용자에게 보여주는 문구 원칙 diff --git a/docs/prompts/README.md b/docs/prompts/README.md new file mode 100644 index 0000000..a1b93ba --- /dev/null +++ b/docs/prompts/README.md @@ -0,0 +1,29 @@ +# 프롬프트 라이브러리 + +두 번 이상 같은 프롬프트를 입력했다면 여기 저장한다. 개인 것이 아니라 팀 자산이다. + +## 개발용 (이 디렉토리, 버전 관리) + +| 파일 | 언제 쓰나 | +|---|---| +| [pr-description.md](pr-description.md) | PR 설명 초안 | +| [bug-to-repro.md](bug-to-repro.md) | 버그 리포트를 재현 코드·테스트로 | +| [refactor-review.md](refactor-review.md) | 머지 전 셀프 리뷰 | +| [release-notes.md](release-notes.md) | 커밋 기록으로 릴리스 노트 | + +## 실행형 워크플로우 (Claude Code 스킬) + +`/` 로 바로 호출한다. Codex를 쓴다면 아래 문서 경로를 직접 참조하면 된다. + +| 명령 | 하는 일 | 근거 문서 | +|---|---|---| +| `/release-check` | App Store 제출 전 검사 | `docs/private/app-store-submission.md` (비공개) | +| `/proxy-change` | 클라이언트·프록시 동시 변경 절차 | `proxy-vercel/AGENTS.md` | +| `/weekly-report` | 커밋 기반 주간 보고서 | — | + +## 비개발용 (노션) + +마케팅 문구, 인터뷰 분석, 회의록 요약, 설문 문항은 노션 프롬프트 DB에 둔다. +코드와 함께 버전 관리할 필요가 없고, 비개발 맥락에서 더 자주 손대기 때문이다. + +> 노션 DB 링크: _(팀 워크스페이스 생성 후 여기 채울 것)_ diff --git a/docs/prompts/bug-to-repro.md b/docs/prompts/bug-to-repro.md new file mode 100644 index 0000000..2a48b86 --- /dev/null +++ b/docs/prompts/bug-to-repro.md @@ -0,0 +1,23 @@ +# 버그 리포트 → 재현 코드 + +## 프롬프트 + +``` +아래 버그 리포트를 재현하는 최소 코드나 테스트를 만들어줘. + +[버그 리포트 붙여넣기] + +순서: +1. Paxo/AGENTS.md 를 먼저 읽어. 이 코드베이스에는 밟기 쉬운 함정이 있다 +2. 관련 코드를 읽고 어디서 깨지는지 가설을 세워 +3. 그 가설을 PaxoTests/ 에 실패하는 테스트로 표현해 +4. 고치기 전에, 그 테스트가 실제로 실패하는 걸 보여줘 + +주의: +- 지금은 순수 로직만 테스트한다. 네트워크가 필요하면 그렇다고 말하고 멈춰 +- 추측으로 고치지 마. 재현이 먼저다 +``` + +## 왜 이렇게 쓰나 + +재현 없이 고치면 고쳤는지 알 수 없다. 실패하는 테스트를 먼저 보는 것이 유일한 증거다. diff --git a/docs/prompts/pr-description.md b/docs/prompts/pr-description.md new file mode 100644 index 0000000..64fbac1 --- /dev/null +++ b/docs/prompts/pr-description.md @@ -0,0 +1,21 @@ +# PR 설명 초안 + +## 프롬프트 + +``` +현재 브랜치와 develop의 차이를 보고 PR 설명을 작성해줘. + +git diff origin/develop...HEAD 와 git log origin/develop..HEAD 를 먼저 읽어. + +형식은 .github/pull_request_template.md 를 따르되: +- "무엇을 바꿨나"는 한두 줄. 파일 나열이 아니라 의도를 쓸 것 +- "어떻게 확인했나"는 내가 실제로 돌린 명령을 쓸 것. 추측해서 채우지 말고, + 모르면 나에게 물어봐 +- 체크리스트는 채우지 마. 내가 직접 확인하고 체크한다 + +한국어로 쓰고, 커밋 메시지를 그대로 복사하지 마. +``` + +## 왜 이렇게 쓰나 + +체크리스트를 AI가 채우면 검토 자체가 형해화된다. 확인은 사람이 한다. diff --git a/docs/prompts/refactor-review.md b/docs/prompts/refactor-review.md new file mode 100644 index 0000000..a1e9bfe --- /dev/null +++ b/docs/prompts/refactor-review.md @@ -0,0 +1,21 @@ +# 머지 전 셀프 리뷰 + +## 프롬프트 + +``` +이 브랜치의 변경사항을 리뷰해줘. git diff origin/develop...HEAD 부터 읽어. + +특히 이것들을 봐: +1. 중복 — 새로 만든 함수가 기존에 이미 있는 것과 겹치지 않는지 검색해서 확인해 +2. 계약 — GeminiService 의 요청 형태를 바꿨다면 proxy-vercel/api/generate.js 도 + 같이 바뀌었는지 확인해. 한쪽만 바뀌었으면 그게 버그다 +3. 주입 — @EnvironmentObject 를 추가했다면 그 뷰가 실제로 그 객체를 주입받는지 + Paxo/AGENTS.md 의 표로 확인해. 아니면 런타임 크래시다 +4. 과금 — 사용량 차감 로직을 건드렸다면 비대칭 규칙이 유지되는지 확인해 + +발견한 것만 말해. 없으면 없다고 해. 칭찬은 필요 없다. +``` + +## 왜 이렇게 쓰나 + +AI 리뷰는 "좋아 보인다"로 흐르기 쉽다. 검사 항목을 명시하고 칭찬을 막아야 쓸모가 생긴다. diff --git a/docs/prompts/release-notes.md b/docs/prompts/release-notes.md new file mode 100644 index 0000000..daa2e27 --- /dev/null +++ b/docs/prompts/release-notes.md @@ -0,0 +1,16 @@ +# 릴리스 노트 + +## 프롬프트 + +``` +지난 릴리스 이후의 커밋으로 릴리스 노트를 써줘. + +git log <직전_태그>..HEAD --format='%s%n%b' 를 읽어. + +두 벌을 만들어: +1. 사용자용 — App Store "새로운 기능" 칸에 넣을 것. 한국어, 4줄 이내, + 기능 이름과 사용자가 체감하는 변화만. 내부 구조 얘기는 빼 +2. 팀 내부용 — 무엇이 왜 바뀌었는지. 주의할 마이그레이션이 있으면 명시 + +버전 번호는 내가 알려줄 때까지 추측하지 마. +``` diff --git a/proxy-vercel/AGENTS.md b/proxy-vercel/AGENTS.md new file mode 100644 index 0000000..db5fb5c --- /dev/null +++ b/proxy-vercel/AGENTS.md @@ -0,0 +1,69 @@ +# proxy-vercel/ — API 프록시 (기본 경로) + +전역 규칙은 저장소 루트의 `AGENTS.md`를 따른다. + +앱은 Gemini를 직접 호출하지 않는다. 이 프록시가 API 키를 쥐고 대신 호출한다. +릴리스 빌드에는 직접 호출 경로가 아예 컴파일되지 않는다. + +## 가장 중요한 규칙 — 계약은 양쪽을 동시에 고친다 + +**프록시는 요청 본문을 통과시키지 않고 화이트리스트로 재조립한다** (`api/generate.js`의 `buildUpstreamBody`). + +허용되는 것은 정확히 이것뿐이다. + +- `text` 파트 1개 (8000자 이하) +- `inline_data` 파트 1개 (`image/jpeg` 또는 `image/png`, base64 6,000,000자 이하) + +`generationConfig`, `systemInstruction`, `safetySettings`, `tools`, 추가 파트, 두 번째 텍스트 파트는 +**구조적으로 전달이 불가능하다.** 클라이언트에서 넣으면 400이 돌아온다. + +따라서 `Paxo/AI/GeminiService.swift`의 요청 형태를 바꾸려면 `api/generate.js`를 **같은 PR에서 함께** 고쳐야 한다. +스트리밍 전환도 마찬가지로 양쪽 변경이 필요하다. + +## 요청·응답 계약 + +``` +POST {proxyURL}/generate + content-type: application/json + x-paxo-device: + x-paxo-token: <공유 시크릿> + +{"contents":[{"parts":[{"text":"..."}, + {"inline_data":{"mime_type":"image/jpeg","data":""}}]}]} +``` + +응답: 200과 429만 그대로 전달하고, 나머지 상태는 전부 502로 정규화한다. +업스트림 에러 본문은 클라이언트에 노출하지 않는다. +에러 형태는 Gemini와 동일한 `{error:{code,message}}` — 클라이언트가 파서 하나로 처리하기 위해서다. + +## APP_TOKEN 배포 순서 + +순서를 어기면 기존 사용자 앱이 401을 맞는다. + +1. `APP_TOKEN` 없이 프록시 배포 (검증 생략 = 무중단) +2. 토큰이 들어간 앱 빌드 배포 +3. `vercel env add APP_TOKEN production` +4. `npx vercel --prod` 재배포 — 이때부터 토큰 없는 요청이 401 + +로테이션은 새 토큰을 `APP_TOKEN`, 기존 것을 `APP_TOKEN_PREV`에 두면 구버전 앱도 한동안 동작한다. +값은 `Paxo/Secrets.swift`의 `appToken`과 정확히 일치해야 한다. + +## 환경변수 + +| 이름 | 필수 | 기본값 | +|---|---|---| +| `GEMINI_API_KEY` | **필수** — 없으면 500 | — | +| `APP_TOKEN` | 선택 — 설정 시에만 검증 | 없음 | +| `APP_TOKEN_PREV` | 선택 — 로테이션용 | 없음 | +| `MODEL` | 선택 | `gemini-2.5-flash` | + +## 코드 스타일 + +ESM, 큰따옴표, 2칸 들여쓰기, 세미콜론 항상. 모듈 상수는 SCREAMING_SNAKE. +헬퍼는 호이스팅되는 `function` 선언으로, 작은 지역 함수만 화살표 함수로. +주석은 한국어로 *왜*를 적는다. TypeScript가 아니다. + +## proxy/ (Cloudflare Worker)는 폐기된 백업이다 + +Gemini가 Workers 요청을 지역 차단하는 문제로 강등됐다. Vercel은 `iad1` 리전에 고정해 이를 피한다. +신규 작업은 이 디렉토리에만 한다. `proxy/`는 본문 검증이 없고 에러 형태도 다르다. diff --git a/proxy-vercel/CLAUDE.md b/proxy-vercel/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/proxy-vercel/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/scripts/ax-metrics.sh b/scripts/ax-metrics.sh new file mode 100755 index 0000000..3347adb --- /dev/null +++ b/scripts/ax-metrics.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# AX 도입 효과를 체감이 아니라 실측으로 확인하기 위한 지표 수집. +# METR 연구에서 개발자들은 19% 느려졌는데도 빨라졌다고 느꼈다. 그래서 숫자를 남긴다. +# +# 사용법: scripts/ax-metrics.sh [기간(일), 기본 30] +set -euo pipefail + +DAYS="${1:-30}" +SINCE="$(date -v-"${DAYS}"d +%Y-%m-%d 2>/dev/null || date -d "${DAYS} days ago" +%Y-%m-%d)" + +echo "# AX 지표 — 최근 ${DAYS}일 (${SINCE} 이후)" +echo "# 수집 시각: $(date '+%Y-%m-%d %H:%M')" +echo + +echo "## 커밋" +echo "총 커밋: $(git log --since="$SINCE" --no-merges --oneline | wc -l | tr -d ' ')" +echo "기여자:" +git shortlog -sn --since="$SINCE" --no-merges HEAD | sed 's/^/ /' || echo " (없음)" +echo + +if ! gh auth status >/dev/null 2>&1; then + echo "## PR / CI" + echo " gh 로그인이 필요합니다: gh auth login" + exit 0 +fi + +echo "## PR 리드타임 (생성 → 머지)" +gh pr list --state merged --limit 100 \ + --json number,title,createdAt,mergedAt \ + --jq "[.[] | select(.mergedAt > \"${SINCE}\")]" > /tmp/ax-prs.json + +count=$(python3 -c "import json;print(len(json.load(open('/tmp/ax-prs.json'))))") +if [ "$count" -eq 0 ]; then + echo " 머지된 PR 없음 — 기준선 측정 시점입니다" +else + python3 - <<'PY' +import json, statistics +from datetime import datetime +prs = json.load(open('/tmp/ax-prs.json')) +def hrs(p): + f = "%Y-%m-%dT%H:%M:%SZ" + return (datetime.strptime(p["mergedAt"], f) - datetime.strptime(p["createdAt"], f)).total_seconds() / 3600 +d = sorted(hrs(p) for p in prs) +print(f" 머지된 PR: {len(d)}건") +print(f" 중앙값: {statistics.median(d):.1f}시간") +print(f" 평균: {statistics.mean(d):.1f}시간") +print(f" 최대: {d[-1]:.1f}시간") +PY +fi +echo + +echo "## CI 통과율" +gh run list --limit 100 --json conclusion,createdAt \ + --jq "[.[] | select(.createdAt > \"${SINCE}\")] | group_by(.conclusion) | map({(.[0].conclusion // \"진행중\"): length}) | add" \ + || echo " (워크플로우 실행 기록 없음)"