diff --git a/AGENTS.md b/AGENTS.md index 3dfba56..4017751 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,14 +37,15 @@ TCA + Domain Client + App live 조립 ## 2. 문서 우선순위 -1. docs/ARCHITECTURE.md -2. docs/CONVENTIONS.md -3. docs/AGENT_WORKFLOW.md -4. docs/skills.md -5. AGENTS.md -6. CLAUDE.md -> AGENTS.md +1. `docs/ARCHITECTURE.md` — 전역 구조 +2. `docs/CONVENTIONS.md` — 작성 규칙(주석/테스트/형식) +3. `docs/AGENT_WORKFLOW.md` — 실행/hard gate +4. `docs/skills.md` — 스킬 인덱스 +5. `AGENTS.md` — 진입 요약 +6. `CLAUDE.md` → `AGENTS.md` +7. `Projects/**/README.md` — 모듈 디테일 -## 3. 구조 +## 3. 구조 요약 + 금지 의존 ```text Projects/ @@ -72,41 +73,31 @@ MoziApp → CompositionRoot → RootFeature → AppCoordinator bootstrapping → main(Placeholder) ``` -Domain/Data 는 초기 placeholder 만 둔다. - -## 5. 규칙 +## 5. 핵심 규칙 요약 1. Feature 모듈 분리 금지 2. Scene 직접 참조 금지 3. live 조립 App only 4. Feature 는 Domain `*Client` 만 -5. scheme: Mozi-Debug / Mozi -6. bundle: com.teamMozi.debug / com.teamMozi.app - -## 6. Git / PR +5. scheme: `Mozi-Debug` / `Mozi`, bundle: `com.teamMozi.debug` / `com.teamMozi.app` -- 브랜치: `Type/짧은-설명` -- 커밋: `Type: 요약` -- PR 제목: 변경 내용만 -- PR 본문: `변경 요약` + 영역별 `변경 내용` - - 템플릿: `.github/pull_request_template.md` -- 워크플로 상세: [docs/AGENT_WORKFLOW.md](docs/AGENT_WORKFLOW.md) +## 6. Git / PR Hard Gate 요약 -Hard gates: +- `커밋해` 전 staging/commit/push 금지 +- 커밋 계획 전 로컬 린트 1회 +- 커밋은 계획(제목+의도+파일) 1회 승인 후 진행 +- `PR 초안` / `작성만` = 본문만 +- `PR 생성해` / `PR 올려` / `올려` 전 PR 생성 금지 +- PR용 push 전 `origin/dev` rebase, 충돌 시 중단 -1. `커밋해` 전 staging/commit/push 금지 -2. 커밋 계획 전 로컬 린트 1회: `mise exec -- swiftlint lint --strict` -3. 커밋은 계획(제목+의도+파일) 1회 승인 후 진행 -4. `PR 초안` / `작성만` = 본문만, 생성 금지 -5. `PR 생성해` / `PR 올려` / `올려` 전 PR 생성 금지 -6. PR용 push 전 `origin/dev` rebase, 충돌 시 중단 - -스킬: +상세 절차: [docs/AGENT_WORKFLOW.md](docs/AGENT_WORKFLOW.md) +형식 규칙: [docs/CONVENTIONS.md](docs/CONVENTIONS.md) +스킬: [docs/skills.md](docs/skills.md) - worktree: `$mozi-worktree-bootstrap` - 커밋: `$mozi-commit` - PR: `$mozi-pr` -- 스킬 목록: [docs/skills.md](docs/skills.md) +- cleanup: `$mozi-worktree-cleanup` ## 7. 명령 @@ -116,36 +107,3 @@ mise exec -- tuist generate --no-open open Mozi.xcworkspace xcodebuild -workspace Mozi.xcworkspace -scheme Mozi-Debug -destination 'generic/platform=iOS Simulator' build ``` - -## 8. Worktree - -상세 절차. 강제 규칙은 위 `0. Worktree Hard Gate` 를 따른다. -새 git worktree 로 작업할 때는 첫 진입 시 아래 스크립트를 실행한다. -일회성 수동 세팅이 아니라 이 경로를 기본으로 쓴다. - -```bash -./scripts/setup-worktree.sh -``` - -스크립트가 하는 일: - -1. detached HEAD 이면 로컬 브랜치 생성 또는 전환 - - `MOZI_WORKTREE_BRANCH` 가 있으면 그 이름 사용 - - 없으면 Codex worktree id 기준 `codex/` (예: `codex/f8d0`) - - 이미 브랜치에 있으면 유지 -2. 다른 worktree/main 에서 `Config/Debug.xcconfig`, `Config/Release.xcconfig` 복사 -3. `mise trust` + `mise install` -4. `tuist generate` -5. `Mozi-Debug` 빌드 검증 (로컬 `.derivedData`) - -옵션: - -- `--skip-build`: 빌드 검증 생략 -- `--force-generate`: workspace 가 있어도 generate 재실행 -- `MOZI_CONFIG_SOURCE=/path/to/main`: config 복사 소스 강제 지정 -- `MOZI_WORKTREE_BRANCH=feat/foo`: detached HEAD 일 때 생성/전환할 브랜치 지정 - -참고: - -- Codex worktree 세션은 기본적으로 detached HEAD 로 열린다. -- 이 스크립트가 임시 브랜치(`codex/`)를 만든 뒤, 작업 주제가 정해지면 `Type/짧은-설명` 으로 rename 하거나 새로 판다. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4017751 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,109 @@ +# AGENTS + +Mozi 작업 시 에이전트 진입점. + +## 0. Worktree Hard Gate + +Mozi worktree 세션에서는 **어떤 응답/작업보다 먼저** ready 여부를 확인한다. + +ready 가 아니면 즉시 실행: + +```bash +./scripts/setup-worktree.sh +``` + +ready 판정(모두 만족해야 ready): + +1. detached HEAD 가 아님 +2. `Config/Debug.xcconfig` 존재 +3. `Config/Release.xcconfig` 존재 +4. `Mozi.xcworkspace` 존재 + +규칙: + +- ready 가 아니면 setup 없이 본 요청을 시작하지 않는다 +- setup 후 한 줄로 결과(브랜치/workspace/config)를 보고한 뒤 본 요청을 처리한다 +- 이미 ready 면 setup 을 다시 돌리지 않는다 +- 사용자가 명시적으로 다른 옵션을 주면 그때만 `--skip-build` 등을 사용한다 +- 스킬: `$mozi-worktree-bootstrap` (`.codex/skills/mozi-worktree-bootstrap`) + +## 1. 프로젝트 한 줄 + +```text +Tuist multi-project iOS 앱 +세로 계층은 모듈, 가로 Feature는 폴더 +TCA + Domain Client + App live 조립 +``` + +## 2. 문서 우선순위 + +1. `docs/ARCHITECTURE.md` — 전역 구조 +2. `docs/CONVENTIONS.md` — 작성 규칙(주석/테스트/형식) +3. `docs/AGENT_WORKFLOW.md` — 실행/hard gate +4. `docs/skills.md` — 스킬 인덱스 +5. `AGENTS.md` — 진입 요약 +6. `CLAUDE.md` → `AGENTS.md` +7. `Projects/**/README.md` — 모듈 디테일 + +## 3. 구조 요약 + 금지 의존 + +```text +Projects/ + Shared/{Util,DesignSystem,Logger} + ThirdParty/{ThirdParty,ThirdPartyUI,ThirdPartyCore} + Domain/ + Core/{Network,Storage} + Data/ + Feature/ + App/ +``` + +금지: + +```text +Feature → Data / Core* / ThirdPartyCore +Domain → Data / Core* / Feature +Data → Feature +``` + +## 4. 현재 앱 흐름 + +```text +MoziApp → CompositionRoot → RootFeature → AppCoordinator + bootstrapping → main(Placeholder) +``` + +## 5. 핵심 규칙 요약 + +1. Feature 모듈 분리 금지 +2. Scene 직접 참조 금지 +3. live 조립 App only +4. Feature 는 Domain `*Client` 만 +5. scheme: `Mozi-Debug` / `Mozi`, bundle: `com.teamMozi.debug` / `com.teamMozi.app` + +## 6. Git / PR Hard Gate 요약 + +- `커밋해` 전 staging/commit/push 금지 +- 커밋 계획 전 로컬 린트 1회 +- 커밋은 계획(제목+의도+파일) 1회 승인 후 진행 +- `PR 초안` / `작성만` = 본문만 +- `PR 생성해` / `PR 올려` / `올려` 전 PR 생성 금지 +- PR용 push 전 `origin/dev` rebase, 충돌 시 중단 + +상세 절차: [docs/AGENT_WORKFLOW.md](docs/AGENT_WORKFLOW.md) +형식 규칙: [docs/CONVENTIONS.md](docs/CONVENTIONS.md) +스킬: [docs/skills.md](docs/skills.md) + +- worktree: `$mozi-worktree-bootstrap` +- 커밋: `$mozi-commit` +- PR: `$mozi-pr` +- cleanup: `$mozi-worktree-cleanup` + +## 7. 명령 + +```bash +mise install +mise exec -- tuist generate --no-open +open Mozi.xcworkspace +xcodebuild -workspace Mozi.xcworkspace -scheme Mozi-Debug -destination 'generic/platform=iOS Simulator' build +``` diff --git a/Projects/App/Sources/DI/Dependencies.swift b/Projects/App/Sources/DI/Dependencies.swift index 5b9cc0d..0f36c3b 100644 --- a/Projects/App/Sources/DI/Dependencies.swift +++ b/Projects/App/Sources/DI/Dependencies.swift @@ -5,7 +5,7 @@ enum Dependencies { _ values: inout DependencyValues, infra: InfraContainer ) { - // 초기 domain client 없음. 이후 factory 등록 지점. + // TODO: Domain Client factory 등록 _ = infra _ = values } diff --git a/Projects/Core/Network/README.md b/Projects/Core/Network/README.md new file mode 100644 index 0000000..48b70fa --- /dev/null +++ b/Projects/Core/Network/README.md @@ -0,0 +1,30 @@ +# CoreNetwork + +## 책임 +- HTTP 요청 파이프라인, endpoint 모델, 네트워크 에러, 인증 토큰 refresh 유틸 + +## 의존 +- 허용: SharedUtils, SharedLogger, ThirdPartyCore +- 금지: Domain/Feature service-specific flow + +## 내부 규칙 +- SharedLogger category: `.network` +- service-specific domain flow 금지 +- authed 요청의 401 refresh 는 single-flight +- refresh 성공 후 generation 이 바뀌었으면 중복 refresh 없이 재시도 + +## 주요 진입점 +- `DefaultNetworkClient` +- `APIEndpoint` +- `NetworkError` +- token provider/refresher 포트 + +## 테스트 포인트 +- request building +- 401 refresh/retry/single-flight +- error mapping +- URL sanitize logging + +## 관련 문서 +- [ARCHITECTURE.md](../../../docs/ARCHITECTURE.md) +- [CONVENTIONS.md](../../../docs/CONVENTIONS.md) diff --git a/Projects/Core/Network/Tests/DefaultNetworkClientReviewFixTests.swift b/Projects/Core/Network/Tests/DefaultNetworkClientReviewFixTests.swift index 00ec4b1..702d3e1 100644 --- a/Projects/Core/Network/Tests/DefaultNetworkClientReviewFixTests.swift +++ b/Projects/Core/Network/Tests/DefaultNetworkClientReviewFixTests.swift @@ -7,7 +7,7 @@ final class DefaultNetworkClientReviewFixTests: XCTestCase { super.tearDown() } - func test_authedHTTPURL_rejectsAuthorization() async { + func test_인증_HTTP_URL이면_authorization_거부() async { URLProtocolStub.requestHandler = { _ in XCTFail("request should not be sent") return .init(statusCode: 200, headers: [:], data: Data()) @@ -36,7 +36,7 @@ final class DefaultNetworkClientReviewFixTests: XCTestCase { } } - func test_delayed401_afterRefresh_doesNotRefreshTwice() async throws { + func test_refresh_이후_늦은_401은_재refresh하지_않음() async throws { final class RequestCounter: @unchecked Sendable { private let lock = NSLock() private var count = 0 @@ -60,7 +60,7 @@ final class DefaultNetworkClientReviewFixTests: XCTestCase { ) } if index == 2 { - // late 401 for a request that started with the old token generation + // 이전 토큰 generation 으로 시작한 요청의 늦은 401 Thread.sleep(forTimeInterval: 0.25) return .init( statusCode: 401, diff --git a/Projects/Core/Network/Tests/DefaultNetworkClientTests.swift b/Projects/Core/Network/Tests/DefaultNetworkClientTests.swift index f5ce18b..0ba71a7 100644 --- a/Projects/Core/Network/Tests/DefaultNetworkClientTests.swift +++ b/Projects/Core/Network/Tests/DefaultNetworkClientTests.swift @@ -7,7 +7,7 @@ final class DefaultNetworkClientTests: XCTestCase { super.tearDown() } - func test_request_buildsMethodPathQueryHeadersAndBody() async throws { + func test_요청이_메서드_경로_쿼리_헤더_바디를_구성() async throws { URLProtocolStub.requestHandler = { request in XCTAssertEqual(request.httpMethod, "POST") XCTAssertEqual(request.url?.path, "/api/auth/login/kakao") @@ -38,7 +38,7 @@ final class DefaultNetworkClientTests: XCTestCase { XCTAssertEqual(response, OkPayload(ok: true)) } - func test_voidRequest_succeedsOn2xxAndIgnoresBody() async throws { + func test_void_요청은_2xx면_성공하고_바디를_무시() async throws { URLProtocolStub.requestHandler = { _ in .init(statusCode: 204, headers: [:], data: Data(#"{"ignored":true}"#.utf8)) } @@ -50,7 +50,7 @@ final class DefaultNetworkClientTests: XCTestCase { try await client.request(TestEndpoint(path: "/api/auth/logout", method: .post)) } - func test_requestDecodable_emptyBody_throwsDecodingFailed() async { + func test_디코딩_요청_빈_바디면_decodingFailed() async { URLProtocolStub.requestHandler = { _ in .init(statusCode: 204, headers: [:], data: Data()) } @@ -73,7 +73,7 @@ final class DefaultNetworkClientTests: XCTestCase { } } - func test_authedRequest_attachesBearerToken() async throws { + func test_인증_요청에_Bearer_토큰_첨부() async throws { URLProtocolStub.requestHandler = { request in XCTAssertEqual( request.value(forHTTPHeaderField: "Authorization"), @@ -96,7 +96,7 @@ final class DefaultNetworkClientTests: XCTestCase { let _: OkPayload = try await client.request(TestEndpoint()) } - func test_authed401_refreshesOnceAndRetries() async throws { + func test_인증_401이면_한_번만_refresh_후_재시도() async throws { final class CodeQueue: @unchecked Sendable { private let lock = NSLock() private var codes = [401, 200] @@ -135,7 +135,7 @@ final class DefaultNetworkClientTests: XCTestCase { XCTAssertEqual(URLProtocolStub.requests.count, 2) } - func test_concurrent401_singleFlightRefresh() async throws { + func test_동시_401이면_refresh_single_flight() async throws { final class RequestCounter: @unchecked Sendable { private let lock = NSLock() private var requestIndex = 0 @@ -151,7 +151,7 @@ final class DefaultNetworkClientTests: XCTestCase { let counter = RequestCounter() URLProtocolStub.requestHandler = { _ in let requestIndex = counter.next() - // first 3 are original 401s, next 3 are retries 200 + // 앞 3개는 원래 401, 다음 3개는 재시도 200 if requestIndex <= 3 { return .init( statusCode: 401, @@ -166,7 +166,7 @@ final class DefaultNetworkClientTests: XCTestCase { let baseURL = try XCTUnwrap(URL(string: "https://api.example.invalid")) let provider = StubTokenProvider(token: "access-token") let refresher = StubTokenRefresher() - // Keep refresh in-flight long enough for concurrent 401s to join single-flight. + // 동시 401이 single-flight refresh 에 합류할 만큼 refresh 를 지연한다. await refresher.setDelayNanoseconds(200_000_000) let client = DefaultNetworkClient.authed( configuration: NetworkConfiguration(baseURL: baseURL), @@ -185,7 +185,7 @@ final class DefaultNetworkClientTests: XCTestCase { XCTAssertEqual(URLProtocolStub.requests.count, 6) } - func test_refreshFailure_mapsToUnauthorized() async { + func test_refresh_실패면_unauthorized_매핑() async { URLProtocolStub.requestHandler = { _ in .init(statusCode: 401, headers: [:], data: Data(#"{"message":"expired"}"#.utf8)) } @@ -216,7 +216,7 @@ final class DefaultNetworkClientTests: XCTestCase { } } - func test_retryStill401_mapsToUnauthorized() async { + func test_재시도해도_401이면_unauthorized_매핑() async { URLProtocolStub.requestHandler = { _ in .init(statusCode: 401, headers: [:], data: Data(#"{"message":"expired"}"#.utf8)) } diff --git a/Projects/Core/Network/Tests/NetworkErrorMappingTests.swift b/Projects/Core/Network/Tests/NetworkErrorMappingTests.swift index 6267048..f0ae7f2 100644 --- a/Projects/Core/Network/Tests/NetworkErrorMappingTests.swift +++ b/Projects/Core/Network/Tests/NetworkErrorMappingTests.swift @@ -7,7 +7,7 @@ final class NetworkErrorMappingTests: XCTestCase { super.tearDown() } - func test_statusCodeMapping() async { + func test_상태코드가_네트워크_에러로_매핑() async { let cases: [(Int, NetworkError)] = [ (400, .badRequest(message: "bad")), (401, .unauthorized), diff --git a/Projects/Core/Network/Tests/NetworkLogTests.swift b/Projects/Core/Network/Tests/NetworkLogTests.swift index 823920d..2be8c2a 100644 --- a/Projects/Core/Network/Tests/NetworkLogTests.swift +++ b/Projects/Core/Network/Tests/NetworkLogTests.swift @@ -2,7 +2,7 @@ import XCTest final class NetworkLogTests: XCTestCase { - func test_sanitizedURLString_removesQueryAndFragment() throws { + func test_로그용_URL에서_query와_fragment_제거() throws { let url = try XCTUnwrap( URL(string: "https://api.example.invalid/auth/callback?accessToken=secret&page=1#top") ) diff --git a/Projects/Core/Network/Tests/URLRequestBuildingTests.swift b/Projects/Core/Network/Tests/URLRequestBuildingTests.swift index 141b8b9..a05fa77 100644 --- a/Projects/Core/Network/Tests/URLRequestBuildingTests.swift +++ b/Projects/Core/Network/Tests/URLRequestBuildingTests.swift @@ -2,7 +2,7 @@ import CoreNetwork import XCTest final class URLRequestBuildingTests: XCTestCase { - func test_endpointDefaults_areEmpty() { + func test_엔드포인트_기본값이_비어_있음() { struct MinimalEndpoint: APIEndpoint { let path = "/ping" let method = HTTPMethod.get @@ -14,7 +14,7 @@ final class URLRequestBuildingTests: XCTestCase { XCTAssertNil(endpoint.body) } - func test_networkConfiguration_defaults() throws { + func test_네트워크_설정_기본값이_기대값() throws { let baseURL = try XCTUnwrap(URL(string: "https://api.example.com")) let configuration = NetworkConfiguration(baseURL: baseURL) XCTAssertEqual(configuration.baseURL, baseURL) @@ -23,7 +23,7 @@ final class URLRequestBuildingTests: XCTestCase { _ = configuration.jsonEncoder } - func test_tokenStubs_compile() async throws { + func test_토큰_스텁이_accessToken과_refresh를_제공() async throws { let provider = StubTokenProvider() let refresher = StubTokenRefresher() let token = try await provider.accessToken() diff --git a/Projects/Data/README.md b/Projects/Data/README.md new file mode 100644 index 0000000..9a64b93 --- /dev/null +++ b/Projects/Data/README.md @@ -0,0 +1,32 @@ +# Data + +## 책임 +- DTO, Datasource, `*RepositoryImpl`, `*ClientFactory` + +## 현재 상태 +- placeholder 골격 + +## 이후 패턴 +- `Data//{DTO,Datasource,Repository}` +- `*RepositoryImpl` +- `*ClientFactory` + +## 의존 +- 허용: Domain, Core/*, SharedLogger, SharedUtils +- 금지: Feature + +## 내부 규칙 +- Domain `*Client` 의 live 구현을 factory 로 제공 +- App 조립 지점에서 factory 등록 +- Feature 가 Data 를 직접 import 하지 않음 + +## 주요 진입점 +- (현재) `Sources/Placeholder.swift` +- (이후) `*ClientFactory` + +## 테스트 포인트 +- repository/factory 매핑 (구현 시) + +## 관련 문서 +- [ARCHITECTURE.md](../../docs/ARCHITECTURE.md) +- [CONVENTIONS.md](../../docs/CONVENTIONS.md) diff --git a/Projects/Domain/README.md b/Projects/Domain/README.md new file mode 100644 index 0000000..995e378 --- /dev/null +++ b/Projects/Domain/README.md @@ -0,0 +1,30 @@ +# Domain + +## 책임 +- Entity, Error, Domain port (`*Client`) + +## 현재 상태 +- placeholder 골격 + +## 이후 패턴 +- `Domain//{Model,Client,Error}` + +## 의존 +- 허용: SharedUtils, ThirdParty +- 금지: Data, Core*, Feature + +## 내부 규칙 +- 포트 이름은 `*Client` +- Feature 가 의존하는 유일한 도메인 경계 +- UseCase 층 없음 + +## 주요 진입점 +- (현재) `Sources/Placeholder.swift` +- (이후) `*Client` 정의 + +## 테스트 포인트 +- 순수 모델/ 에러 동작 (구현 시) + +## 관련 문서 +- [ARCHITECTURE.md](../../docs/ARCHITECTURE.md) +- [CONVENTIONS.md](../../docs/CONVENTIONS.md) diff --git a/Projects/Feature/README.md b/Projects/Feature/README.md new file mode 100644 index 0000000..6a297ef --- /dev/null +++ b/Projects/Feature/README.md @@ -0,0 +1,34 @@ +# Feature + +## 책임 +- 앱 화면 상태와 네비게이션 골격 +- Root / AppCoordinator / Scene 구성 + +## 현재 상태 +- Root + AppCoordinator + Placeholder scene +- 딥링크: `mozi://home` + +## 의존 +- 허용: Domain, SharedUtils, SharedDesignSystem, SharedLogger, ThirdParty, ThirdPartyUI +- 금지: Data, Core*, ThirdPartyCore + +## 내부 규칙 +- Feature 모듈 분리 금지, 기능 분리는 폴더 +- Scene 내부 flat (`*Feature`, `*View`) +- Scene 간 직접 참조 금지 +- 외부 요청은 delegate bubble-up +- Feature 는 Domain `*Client` 만 사용 +- 전역 전환/딥링크/overlay 는 AppCoordinator + +## 주요 진입점 +- `RootFeature`, `RootView` +- `AppCoordinatorFeature`, `AppCoordinatorView` +- `Scene/Placeholder` + +## 테스트 포인트 +- 부트스트랩 후 main placeholder 진입 +- 딥링크 파싱 (`mozi://home`, https home, unknown) + +## 관련 문서 +- [ARCHITECTURE.md](../../docs/ARCHITECTURE.md) +- [CONVENTIONS.md](../../docs/CONVENTIONS.md) diff --git a/Projects/Feature/Sources/AppCoordinator/AppCoordinatorFeature.swift b/Projects/Feature/Sources/AppCoordinator/AppCoordinatorFeature.swift index 53c9dc5..d1d055a 100644 --- a/Projects/Feature/Sources/AppCoordinator/AppCoordinatorFeature.swift +++ b/Projects/Feature/Sources/AppCoordinator/AppCoordinatorFeature.swift @@ -79,7 +79,7 @@ public struct AppCoordinatorFeature { state.pendingDeepLink = route return .none case .main: - // Placeholder scaffold: home deep link keeps current main scene. + // placeholder 골격: home 딥링크는 현재 main scene 유지 _ = route return .none } diff --git a/Projects/Feature/Tests/AppCoordinator/AppCoordinatorFeatureTests.swift b/Projects/Feature/Tests/AppCoordinator/AppCoordinatorFeatureTests.swift index 1388c15..6ee1c6e 100644 --- a/Projects/Feature/Tests/AppCoordinator/AppCoordinatorFeatureTests.swift +++ b/Projects/Feature/Tests/AppCoordinator/AppCoordinatorFeatureTests.swift @@ -4,7 +4,7 @@ import XCTest @MainActor final class AppCoordinatorFeatureTests: XCTestCase { - func test_부트스트랩_후_메인플레이스홀더_진입() async { + func test_부트스트랩이_끝나면_메인_플레이스홀더로_진입() async { let store = TestStore( initialState: AppCoordinatorFeature.State(phase: .bootstrapping) ) { diff --git a/Projects/Feature/Tests/AppCoordinator/DeepLinkRouterTests.swift b/Projects/Feature/Tests/AppCoordinator/DeepLinkRouterTests.swift index 34aab03..b2a4765 100644 --- a/Projects/Feature/Tests/AppCoordinator/DeepLinkRouterTests.swift +++ b/Projects/Feature/Tests/AppCoordinator/DeepLinkRouterTests.swift @@ -2,21 +2,21 @@ import Feature import XCTest final class DeepLinkRouterTests: XCTestCase { - func test_홈_커스텀스킴_파싱() { + func test_홈_커스텀스킴이면_홈으로_파싱() { guard let url = URL(string: "mozi://home") else { return XCTFail("유효한 URL을 만들지 못했습니다.") } XCTAssertEqual(DeepLinkRouter.parse(url), .home) } - func test_홈_경로_파싱() { + func test_홈_HTTPS_경로면_홈으로_파싱() { guard let url = URL(string: "https://mozi.app/home") else { return XCTFail("유효한 URL을 만들지 못했습니다.") } XCTAssertEqual(DeepLinkRouter.parse(url), .home) } - func test_알수없는_경로_무시() { + func test_알수없는_경로면_nil_반환() { guard let url = URL(string: "mozi://unknown") else { return XCTFail("유효한 URL을 만들지 못했습니다.") } diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButton.swift index 477e1c7..e9a4c13 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButton.swift @@ -47,8 +47,8 @@ public struct DesignButton: View { private var labelContent: some View { let textStyle = size.textStyle - // Width/height ownership is intentionally in DesignButtonChromeStyle - // so padding/frame order stays consistent for full-width layout. + // 너비/높이 ownership 은 DesignButtonChromeStyle 에 둔다. + // full-width 레이아웃에서 padding/frame 순서를 일관되게 유지하기 위함. return HStack(spacing: size.contentGap) { if let leadingIcon { leadingIcon diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButtonStyle.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButtonStyle.swift index 66dee3c..f10aa3e 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButtonStyle.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/DesignButtonStyle.swift @@ -159,8 +159,8 @@ struct DesignButtonChromeStyle: ButtonStyle { let isFullWidth: Bool func makeBody(configuration: Configuration) -> some View { - // Nested content reads @Environment(\.isEnabled) so parent .disabled - // and DesignButton(isEnabled:) both select disabled tokens. + // 중첩 content 가 `@Environment(\.isEnabled)` 를 읽어 + // 부모 `.disabled` 와 `DesignButton(isEnabled:)` 모두 disabled 토큰을 선택한다. ChromeContent( configuration: configuration, variant: variant, @@ -187,10 +187,9 @@ private struct ChromeContent: View { ) ) - // Layout ownership lives here: + // 레이아웃 ownership: // label → horizontal padding → expand/height → chrome. - // Padding must come before maxWidth expansion so full-width - // buttons do not request parent-width + padding. + // padding 이 maxWidth 확장보다 앞서야 full-width 버튼이 parent-width + padding 을 요청하지 않는다. configuration.label .foregroundStyle(style.content.color) .padding(.horizontal, size.horizontalPadding) diff --git a/Projects/Shared/DesignSystem/Sources/Components/Social/SocialLoginButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Social/SocialLoginButton.swift index 98b3534..04778ca 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Social/SocialLoginButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Social/SocialLoginButton.swift @@ -62,7 +62,7 @@ private struct SocialLoginButtonChromeStyle: ButtonStyle { let border: TokenColor? func makeBody(configuration: Configuration) -> some View { - // Full-width social CTAs expand first, then paint chrome. + // full-width 소셜 CTA 는 먼저 확장한 뒤 chrome 을 칠한다. configuration.label .foregroundStyle(content.color) .frame( diff --git a/Projects/Shared/DesignSystem/Sources/Foundation/FontRegistration.swift b/Projects/Shared/DesignSystem/Sources/Foundation/FontRegistration.swift index 082a7b7..fab8fd2 100644 --- a/Projects/Shared/DesignSystem/Sources/Foundation/FontRegistration.swift +++ b/Projects/Shared/DesignSystem/Sources/Foundation/FontRegistration.swift @@ -3,7 +3,7 @@ import Foundation public enum DesignSystemFontRegistration { private static let lock = NSLock() - // Protected by `lock`; Swift 6 requires an explicit escape hatch for mutable static state. + // `lock` 으로 보호한다. Swift 6 가변 static 상태용 명시적 escape hatch. nonisolated(unsafe) private static var didRegister = false private static let fileNames = [ @@ -53,7 +53,7 @@ public enum DesignSystemFontRegistration { } let nsError = cfError as Error as NSError - // Already present in the process is fine for retries / multi-entry bootstrap. + // 프로세스에 이미 등록된 폰트면 재시도/다중 진입 bootstrap 을 허용한다. return nsError.domain == (kCTFontManagerErrorDomain as String) && ( nsError.code == CTFontManagerError.alreadyRegistered.rawValue diff --git a/Projects/Shared/DesignSystem/Sources/Preview/DesignSystemGallery.swift b/Projects/Shared/DesignSystem/Sources/Preview/DesignSystemGallery.swift index 05fc8f1..211a3dc 100644 --- a/Projects/Shared/DesignSystem/Sources/Preview/DesignSystemGallery.swift +++ b/Projects/Shared/DesignSystem/Sources/Preview/DesignSystemGallery.swift @@ -1,7 +1,7 @@ #if DEBUG import SwiftUI -/// DEBUG-only visual catalog for SharedDesignSystem tokens/components. +/// SharedDesignSystem 토큰/컴포넌트용 DEBUG 전용 시각 카탈로그. struct DesignSystemGallery: View { @State private var selectedSection: GallerySection? = .colors @@ -286,7 +286,7 @@ private struct GallerySectionScaffold: View { DesignSystemGalleryPreviewHost() } -/// Preview-only host that registers fonts without making Gallery itself do bootstrap work. +/// Preview 전용 호스트. Gallery 자체가 bootstrap 하지 않도록 폰트 등록만 담당한다. private struct DesignSystemGalleryPreviewHost: View { var body: some View { DesignSystemGallery() diff --git a/Projects/Shared/DesignSystem/Sources/Tokens/Semantic/SemanticColor.swift b/Projects/Shared/DesignSystem/Sources/Tokens/Semantic/SemanticColor.swift index 1ca3163..767030b 100644 --- a/Projects/Shared/DesignSystem/Sources/Tokens/Semantic/SemanticColor.swift +++ b/Projects/Shared/DesignSystem/Sources/Tokens/Semantic/SemanticColor.swift @@ -1,7 +1,6 @@ import Foundation -/// Clear backgrounds for outlined/text default states are represented as -/// `TokenColor(hexRGB: 0x000000, alpha: 0)` so they stay testable as TokenColor. +/// outlined/text 기본 clear 배경은 alpha 0 TokenColor 로 표현해 테스트 가능하게 한다. enum SemanticColor { enum Button { enum Primary { diff --git a/Projects/Shared/DesignSystem/Tests/Components/DesignButtonMetricsTests.swift b/Projects/Shared/DesignSystem/Tests/Components/DesignButtonMetricsTests.swift index ebb8611..b6acc23 100644 --- a/Projects/Shared/DesignSystem/Tests/Components/DesignButtonMetricsTests.swift +++ b/Projects/Shared/DesignSystem/Tests/Components/DesignButtonMetricsTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import SharedDesignSystem final class DesignButtonMetricsTests: XCTestCase { - func test_size_metrics() { + func test_버튼_사이즈_메트릭스() { XCTAssertEqual(DesignButtonSize.sm.height, 32) XCTAssertEqual(DesignButtonSize.sm.horizontalPadding, 12) XCTAssertEqual(DesignButtonSize.sm.cornerRadius, 4) @@ -19,20 +19,20 @@ final class DesignButtonMetricsTests: XCTestCase { XCTAssertEqual(DesignButtonSize.lg.iconSize, 18) } - func test_primary_disabled_uses_semantic_tokens() { + func test_primary_disabled가_시맨틱_토큰_사용() { let style = DesignButtonStyleResolver.resolve(variant: .primary, state: .disabled) XCTAssertEqual(style.background.hexRGB, PrimitiveColor.Primary._50.hexRGB) XCTAssertEqual(style.content.hexRGB, PrimitiveColor.Neutral._500.hexRGB) } - func test_outlined_pressed_background() { + func test_outlined_pressed_배경_토큰() { let style = DesignButtonStyleResolver.resolve(variant: .outlined, state: .pressed) XCTAssertEqual(style.background.hexRGB, PrimitiveColor.Primary._900.hexRGB) XCTAssertEqual(style.border?.hexRGB, PrimitiveColor.Primary._300.hexRGB) } - func test_disabled_state_resolution() { - // Covers parent .disabled / DesignButton(isEnabled: false) → disabled tokens. + func test_disabled_상태_해석() { + // 부모 `.disabled` / `DesignButton(isEnabled: false)` 가 disabled 토큰을 쓰는지 검증한다. XCTAssertEqual( DesignButtonInteractionStateResolver.resolve(isEnabled: false, isPressed: false), .disabled diff --git a/Projects/Shared/DesignSystem/Tests/Components/SocialLoginProviderTests.swift b/Projects/Shared/DesignSystem/Tests/Components/SocialLoginProviderTests.swift index 31091ec..fe29519 100644 --- a/Projects/Shared/DesignSystem/Tests/Components/SocialLoginProviderTests.swift +++ b/Projects/Shared/DesignSystem/Tests/Components/SocialLoginProviderTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import SharedDesignSystem final class SocialLoginProviderTests: XCTestCase { - func test_kakao_copy_and_colors() { + func test_카카오_카피와_컬러() { XCTAssertEqual(SocialLoginProvider.kakao.title, "카카오로 시작하기") XCTAssertEqual(SocialLoginProvider.kakao.background.hexRGB, PrimitiveColor.Social.kakaoYellow.hexRGB) XCTAssertEqual(SocialLoginProvider.kakao.content.hexRGB, PrimitiveColor.Neutral._1000.hexRGB) @@ -10,7 +10,7 @@ final class SocialLoginProviderTests: XCTestCase { XCTAssertEqual(SocialLoginProvider.kakao.contentGap, 12) } - func test_apple_copy_and_colors() { + func test_애플_카피와_컬러() { XCTAssertEqual(SocialLoginProvider.apple.title, "Apple로 로그인") XCTAssertEqual(SocialLoginProvider.apple.background.hexRGB, PrimitiveColor.Neutral._0.hexRGB) XCTAssertEqual(SocialLoginProvider.apple.content.hexRGB, PrimitiveColor.Neutral._1000.hexRGB) diff --git a/Projects/Shared/DesignSystem/Tests/Foundation/FontRegistrationTests.swift b/Projects/Shared/DesignSystem/Tests/Foundation/FontRegistrationTests.swift index 9ef2ef7..f2e6a18 100644 --- a/Projects/Shared/DesignSystem/Tests/Foundation/FontRegistrationTests.swift +++ b/Projects/Shared/DesignSystem/Tests/Foundation/FontRegistrationTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import SharedDesignSystem final class FontRegistrationTests: XCTestCase { - func test_font_resources_exist() { + func test_폰트_리소스가_존재() { let names = [ "Pretendard-Regular", "Pretendard-Medium", @@ -20,7 +20,7 @@ final class FontRegistrationTests: XCTestCase { } } - func test_registerIfNeeded_is_idempotent() { + func test_registerIfNeeded가_멱등() { let first = DesignSystemFontRegistration.registerIfNeeded() let second = DesignSystemFontRegistration.registerIfNeeded() XCTAssertTrue(first) diff --git a/Projects/Shared/DesignSystem/Tests/Smoke/SharedDesignSystemSmokeTests.swift b/Projects/Shared/DesignSystem/Tests/Smoke/SharedDesignSystemSmokeTests.swift index a5529a0..d990784 100644 --- a/Projects/Shared/DesignSystem/Tests/Smoke/SharedDesignSystemSmokeTests.swift +++ b/Projects/Shared/DesignSystem/Tests/Smoke/SharedDesignSystemSmokeTests.swift @@ -3,7 +3,7 @@ import SwiftUI import XCTest final class SharedDesignSystemSmokeTests: XCTestCase { - func test_모듈_임포트() { + func test_SharedDesignSystem_모듈_임포트_성공() { XCTAssertNotNil(Color.red) } } diff --git a/Projects/Shared/DesignSystem/Tests/Tokens/PrimitiveColorTests.swift b/Projects/Shared/DesignSystem/Tests/Tokens/PrimitiveColorTests.swift index cd1e8a9..cca4642 100644 --- a/Projects/Shared/DesignSystem/Tests/Tokens/PrimitiveColorTests.swift +++ b/Projects/Shared/DesignSystem/Tests/Tokens/PrimitiveColorTests.swift @@ -2,15 +2,15 @@ import XCTest @testable import SharedDesignSystem final class PrimitiveColorTests: XCTestCase { - func test_primary300_hex() { + func test_primary300_hex가_기대값() { XCTAssertEqual(PrimitiveColor.Primary._300.hexRGB, 0xE9DE6F) } - func test_neutral1000_hex() { + func test_neutral1000_hex가_기대값() { XCTAssertEqual(PrimitiveColor.Neutral._1000.hexRGB, 0x000000) } - func test_brand_kakao_hex() { + func test_카카오_브랜드_hex가_기대값() { XCTAssertEqual(PrimitiveColor.Social.kakaoYellow.hexRGB, 0xFEE500) } } diff --git a/Projects/Shared/DesignSystem/Tests/Tokens/SemanticColorTests.swift b/Projects/Shared/DesignSystem/Tests/Tokens/SemanticColorTests.swift index 7861b48..1f78537 100644 --- a/Projects/Shared/DesignSystem/Tests/Tokens/SemanticColorTests.swift +++ b/Projects/Shared/DesignSystem/Tests/Tokens/SemanticColorTests.swift @@ -3,28 +3,28 @@ import XCTest @testable import SharedDesignSystem final class SemanticColorTests: XCTestCase { - func test_button_primary_default_maps_primary300() { + func test_버튼_primary_default가_primary300_매핑() { XCTAssertEqual( SemanticColor.Button.Primary.background.default.hexRGB, PrimitiveColor.Primary._300.hexRGB ) } - func test_button_primary_disabled_content_maps_neutral500() { + func test_버튼_primary_disabled_content가_neutral500_매핑() { XCTAssertEqual( SemanticColor.Button.Primary.content.disabled.hexRGB, PrimitiveColor.Neutral._500.hexRGB ) } - func test_text_neutral_white_maps_neutral0() { + func test_텍스트_neutral_white가_neutral0_매핑() { XCTAssertEqual( SemanticColor.Text.Neutral.white.hexRGB, PrimitiveColor.Neutral._0.hexRGB ) } - func test_brand_kakao_maps_brand_token() { + func test_카카오_시맨틱_컬러가_브랜드_토큰_매핑() { XCTAssertEqual( SemanticColor.Social.kakao.hexRGB, PrimitiveColor.Social.kakaoYellow.hexRGB @@ -39,47 +39,47 @@ final class SemanticColorTests: XCTestCase { ) } - func test_button_secondary_default_maps_secondary500() { + func test_버튼_secondary_default가_secondary500_매핑() { XCTAssertEqual( SemanticColor.Button.Secondary.background.default.hexRGB, PrimitiveColor.Secondary._500.hexRGB ) } - func test_button_outlined_default_is_clear() { + func test_버튼_outlined_default_배경이_clear() { let background = SemanticColor.Button.Outlined.background.default XCTAssertEqual(background.hexRGB, 0x000000) XCTAssertEqual(background.alpha, 0, accuracy: 0.0001) } - func test_button_text_pressed_background_maps_primary900() { + func test_버튼_text_pressed_배경이_primary900_매핑() { XCTAssertEqual( SemanticColor.Button.Text.background.pressed.hexRGB, PrimitiveColor.Primary._900.hexRGB ) } - func test_background_grayDarker_maps_neutral900() { + func test_background_grayDarker가_neutral900_매핑() { XCTAssertEqual( SemanticColor.Background.grayDarker.hexRGB, PrimitiveColor.Neutral._900.hexRGB ) } - func test_border_primary_basic_maps_primary300() { + func test_border_primary_basic이_primary300_매핑() { XCTAssertEqual( SemanticColor.Border.Primary.basic.hexRGB, PrimitiveColor.Primary._300.hexRGB ) } - func test_dim_default_is_black_40_percent() { + func test_dim_default가_검정_40퍼센트() { let dim = SemanticColor.Dim.default XCTAssertEqual(dim.hexRGB, 0x000000) XCTAssertEqual(dim.alpha, 0.4, accuracy: 0.0001) } - func test_public_color_ds_paths_resolve() { + func test_공개_Color_ds_경로가_해석됨() { XCTAssertNotNil(Color.ds.button.primary.background.default) XCTAssertNotNil(Color.ds.text.neutral.basic) XCTAssertNotNil(Color.ds.background.grayDarker) diff --git a/Projects/Shared/DesignSystem/Tests/Tokens/SemanticNumberTests.swift b/Projects/Shared/DesignSystem/Tests/Tokens/SemanticNumberTests.swift index f9bbdd7..3c1599b 100644 --- a/Projects/Shared/DesignSystem/Tests/Tokens/SemanticNumberTests.swift +++ b/Projects/Shared/DesignSystem/Tests/Tokens/SemanticNumberTests.swift @@ -2,14 +2,14 @@ import XCTest @testable import SharedDesignSystem final class SemanticNumberTests: XCTestCase { - func test_spacing_and_radius() { + func test_spacing과_radius_시맨틱_값() { XCTAssertEqual(SemanticNumber.Spacing.md, 12) XCTAssertEqual(SemanticNumber.Radius.xsm, 4) XCTAssertEqual(SemanticNumber.ControlHeight.lg, 48) XCTAssertEqual(SemanticNumber.Dim.default, 0.4, accuracy: 0.0001) } - func test_full_semantic_number_table() { + func test_시맨틱_넘버_전체_표가_기대값() { XCTAssertEqual(SemanticNumber.Radius.none, 0) XCTAssertEqual(SemanticNumber.Radius.sm, 8) XCTAssertEqual(SemanticNumber.Radius.md, 12) @@ -33,7 +33,7 @@ final class SemanticNumberTests: XCTestCase { XCTAssertEqual(SemanticNumber.Dim.heavy, 0.6, accuracy: 0.0001) } - func test_public_cgfloat_ds_paths_resolve() { + func test_공개_CGFloat_ds_경로가_해석됨() { XCTAssertEqual(CGFloat.ds.spacing.md, 12) XCTAssertEqual(CGFloat.ds.radius.xsm, 4) XCTAssertEqual(CGFloat.ds.controlHeight.lg, 48) diff --git a/Projects/Shared/DesignSystem/Tests/Tokens/TypographyTests.swift b/Projects/Shared/DesignSystem/Tests/Tokens/TypographyTests.swift index 3d65a2a..f102785 100644 --- a/Projects/Shared/DesignSystem/Tests/Tokens/TypographyTests.swift +++ b/Projects/Shared/DesignSystem/Tests/Tokens/TypographyTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import SharedDesignSystem final class TypographyTests: XCTestCase { - func test_body_small14_regular_metrics() { + func test_body_small14_regular_메트릭스() { let style = TextStyle.ds.body.small14Regular XCTAssertEqual(style.size, 14) XCTAssertEqual(style.lineHeight, 24) @@ -11,7 +11,7 @@ final class TypographyTests: XCTestCase { XCTAssertEqual(style.fontName, "Pretendard-Regular") } - func test_heading_large32_bold_metrics() { + func test_heading_large32_bold_메트릭스() { let style = TextStyle.ds.heading.large32Bold XCTAssertEqual(style.size, 32) XCTAssertEqual(style.lineHeight, 40) @@ -19,18 +19,18 @@ final class TypographyTests: XCTestCase { XCTAssertEqual(style.fontName, "Pretendard-Bold") } - func test_button_sm14_is_semibold() { + func test_button_sm14가_semibold() { let style = TextStyle.ds.button.sm14Semibold XCTAssertEqual(style.size, 14) XCTAssertEqual(style.lineHeight, 14) XCTAssertEqual(style.fontName, "Pretendard-SemiBold") } - func test_label_letter_spacing() { + func test_label_자간_값() { XCTAssertEqual(TextStyle.ds.label.small12Medium.letterSpacingEm, 0.02, accuracy: 0.0001) } - func test_heading_and_title_full_table() { + func test_heading_title_전체_표() { assertStyle( TextStyle.ds.heading.large32Bold, fontName: "Pretendard-Bold", @@ -118,7 +118,7 @@ final class TypographyTests: XCTestCase { ) } - func test_body_label_button_full_table() { + func test_body_label_button_전체_표() { assertStyle( TextStyle.ds.body.large18Semibold, fontName: "Pretendard-SemiBold", @@ -207,7 +207,7 @@ final class TypographyTests: XCTestCase { ) } - func test_derived_spacing_helpers() { + func test_파생_spacing_헬퍼_값() { let heading = TextStyle.ds.heading.large32Bold XCTAssertEqual(heading.letterSpacing, 32 * -0.01, accuracy: 0.0001) XCTAssertEqual(heading.additionalLineSpacing, 8) @@ -221,7 +221,7 @@ final class TypographyTests: XCTestCase { XCTAssertEqual(label.additionalLineSpacing, 4) } - func test_public_font_ds_paths_resolve() { + func test_공개_Font_ds_경로가_해석됨() { XCTAssertNotNil(Font.ds.heading.large32Bold) XCTAssertNotNil(Font.ds.title.xsmall18Semibold) XCTAssertNotNil(Font.ds.body.small14Regular) diff --git a/Projects/Shared/Util/Tests/Bundle/AppInfoTests.swift b/Projects/Shared/Util/Tests/Bundle/AppInfoTests.swift index 4ff52e9..ce4ad15 100644 --- a/Projects/Shared/Util/Tests/Bundle/AppInfoTests.swift +++ b/Projects/Shared/Util/Tests/Bundle/AppInfoTests.swift @@ -2,12 +2,12 @@ import SharedUtils import XCTest final class AppInfoTests: XCTestCase { - func test_infoPlistKeys() { + func test_InfoPlist_키_rawValue가_기대값() { XCTAssertEqual(InfoPlistKey.appVersion.rawValue, "CFBundleShortVersionString") XCTAssertEqual(InfoPlistKey.buildNumber.rawValue, "CFBundleVersion") } - func test_versionBuild_format_uses_current_values() { + func test_versionBuild_형식이_현재_값_사용() { let version = AppInfo.version let build = AppInfo.buildNumber XCTAssertEqual(AppInfo.versionBuild, "\(version) (\(build))") @@ -15,7 +15,7 @@ final class AppInfoTests: XCTestCase { XCTAssertFalse(build.isEmpty) } - func test_isDebugBuild_matches_compilation_flag() { + func test_isDebugBuild가_컴파일_플래그와_일치() { #if DEBUG XCTAssertTrue(AppInfo.isDebugBuild) #else diff --git a/Projects/Shared/Util/Tests/Concurrency/LockedTests.swift b/Projects/Shared/Util/Tests/Concurrency/LockedTests.swift index e7910f0..cfb98dc 100644 --- a/Projects/Shared/Util/Tests/Concurrency/LockedTests.swift +++ b/Projects/Shared/Util/Tests/Concurrency/LockedTests.swift @@ -2,7 +2,7 @@ import SharedUtils import XCTest final class LockedTests: XCTestCase { - func test_withLock_mutates() { + func test_withLock에서_상태_변경() { let locked = Locked(0) locked.withLock { value in value += 2 @@ -10,7 +10,7 @@ final class LockedTests: XCTestCase { XCTAssertEqual(locked.read { $0 }, 2) } - func test_concurrent_increments() { + func test_동시_증가해도_값이_정확() { let locked = Locked(0) let group = DispatchGroup() let queue = DispatchQueue(label: "locked.test", attributes: .concurrent) diff --git a/Projects/Shared/Util/Tests/Foundation/ArraySharedUtilsTests.swift b/Projects/Shared/Util/Tests/Foundation/ArraySharedUtilsTests.swift index c0db52a..14cd131 100644 --- a/Projects/Shared/Util/Tests/Foundation/ArraySharedUtilsTests.swift +++ b/Projects/Shared/Util/Tests/Foundation/ArraySharedUtilsTests.swift @@ -8,12 +8,12 @@ private struct Item: Equatable { final class ArraySharedUtilsTests: XCTestCase { - func test_chunked_into_intMax_doesNotTrap() { + func test_청크_크기가_intMax여도_트랩하지_않음() { let values = [1, 2, 3] XCTAssertEqual(values.chunked(into: Int.max), [[1, 2, 3]]) } - func test_unique_by() { + func test_키_기준_중복_제거() { let items = [ Item(id: 1, name: "a"), Item(id: 1, name: "b"), @@ -25,7 +25,7 @@ final class ArraySharedUtilsTests: XCTestCase { ]) } - func test_removingDuplicates() { + func test_동일_요소_중복_제거() { XCTAssertEqual([1, 1, 2, 3, 2].removingDuplicates(), [1, 2, 3]) } } diff --git a/Projects/Shared/Util/Tests/Foundation/CollectionSharedUtilsTests.swift b/Projects/Shared/Util/Tests/Foundation/CollectionSharedUtilsTests.swift index 379d6ae..8f952df 100644 --- a/Projects/Shared/Util/Tests/Foundation/CollectionSharedUtilsTests.swift +++ b/Projects/Shared/Util/Tests/Foundation/CollectionSharedUtilsTests.swift @@ -2,18 +2,18 @@ import SharedUtils import XCTest final class CollectionSharedUtilsTests: XCTestCase { - func test_safe_subscript() { + func test_안전한_서브스크립트가_범위_밖이면_nil() { let values = [10, 20] XCTAssertEqual(values[safe: 1], 20) XCTAssertNil(values[safe: 2]) } - func test_isNotEmpty() { + func test_비어있지_않으면_isNotEmpty_true() { XCTAssertTrue([1].isNotEmpty) XCTAssertFalse([Int]().isNotEmpty) } - func test_chunked() { + func test_청크_단위로_배열_분할() { XCTAssertEqual([1, 2, 3, 4, 5].chunked(into: 2), [[1, 2], [3, 4], [5]]) XCTAssertEqual([1, 2].chunked(into: 0), []) } diff --git a/Projects/Shared/Util/Tests/Foundation/StringSharedUtilsTests.swift b/Projects/Shared/Util/Tests/Foundation/StringSharedUtilsTests.swift index 8bae4ef..efffd03 100644 --- a/Projects/Shared/Util/Tests/Foundation/StringSharedUtilsTests.swift +++ b/Projects/Shared/Util/Tests/Foundation/StringSharedUtilsTests.swift @@ -2,32 +2,32 @@ import SharedUtils import XCTest final class StringSharedUtilsTests: XCTestCase { - func test_trimmed() { + func test_앞뒤_공백이면_제거된_문자열_반환() { XCTAssertEqual(" hi ".trimmed, "hi") } - func test_isBlank() { + func test_공백만_있으면_blank_판정() { XCTAssertTrue(" ".isBlank) XCTAssertFalse("a".isBlank) } - func test_nilIfEmpty() { + func test_빈_문자열이면_nil_반환() { XCTAssertNil("".nilIfEmpty) XCTAssertEqual("a".nilIfEmpty, "a") } - func test_nonEmpty_trims() { + func test_공백_문자열이면_nonEmpty가_nil() { XCTAssertNil(" ".nonEmpty) XCTAssertEqual(" a ".nonEmpty, "a") } - func test_case_insensitive_helpers() { + func test_대소문자_무시_검색_헬퍼가_동작() { XCTAssertTrue("Hello".containsIgnoringCase("ell")) XCTAssertTrue("Hello".hasPrefixIgnoringCase("he")) XCTAssertFalse("Hello".hasPrefixIgnoringCase("lo")) } - func test_removingWhitespacesAndNewlines() { + func test_공백과_개행을_제거한_문자열_반환() { XCTAssertEqual(" a\nb\t".removingWhitespacesAndNewlines, "ab") } } diff --git a/Projects/Shared/Util/Tests/Smoke/SharedUtilsSmokeTests.swift b/Projects/Shared/Util/Tests/Smoke/SharedUtilsSmokeTests.swift index b4a0e98..6cb55f3 100644 --- a/Projects/Shared/Util/Tests/Smoke/SharedUtilsSmokeTests.swift +++ b/Projects/Shared/Util/Tests/Smoke/SharedUtilsSmokeTests.swift @@ -2,7 +2,7 @@ import SharedUtils import XCTest final class SharedUtilsSmokeTests: XCTestCase { - func test_모듈_임포트() { + func test_SharedUtils_모듈_임포트_성공() { XCTAssertFalse(AppInfo.bundleID.isEmpty) } } diff --git a/Projects/ThirdParty/ThirdPartyCore/Sources/Exports.swift b/Projects/ThirdParty/ThirdPartyCore/Sources/Exports.swift index 95efa44..67d54cb 100644 --- a/Projects/ThirdParty/ThirdPartyCore/Sources/Exports.swift +++ b/Projects/ThirdParty/ThirdPartyCore/Sources/Exports.swift @@ -1,2 +1,2 @@ -// External non-UI package re-exports live here. +/// UI 가 아닌 외부 패키지 re-export 진입점 public enum ThirdPartyCorePlaceholder {} diff --git a/docs/AGENT_WORKFLOW.md b/docs/AGENT_WORKFLOW.md index 86a3096..f03c2be 100644 --- a/docs/AGENT_WORKFLOW.md +++ b/docs/AGENT_WORKFLOW.md @@ -1,101 +1,75 @@ # Agent Workflow -Mozi 작업 시 에이전트 Git/PR 하드 게이트. +Mozi 작업 시 에이전트 hard gate, 행동 원칙, runtime flow의 single source of truth. -기준 문서: +## 기준 문서 -- [skills.md](skills.md) -- [CONVENTIONS.md](CONVENTIONS.md) +- [CONVENTIONS.md](CONVENTIONS.md) — 형식/작성규칙 SSOT +- [ARCHITECTURE.md](ARCHITECTURE.md) — 구조/의존/흐름 SSOT +- [skills.md](skills.md) — skill 인덱스 - project skills: `.codex/skills/mozi-worktree-bootstrap`, `.codex/skills/mozi-commit`, `.codex/skills/mozi-pr`, `.codex/skills/mozi-worktree-cleanup` - -개인 취향 원본(설계실): - -- `~/MySpace/Projects/dev-journal/superpowers-friction/preferences/` +- [../AGENTS.md](../AGENTS.md) ## 1. Hard Gates ### worktree -1. Mozi worktree 세션에서는 ready 확인이 최우선 -2. ready 가 아니면 `./scripts/setup-worktree.sh` 먼저 실행 -3. ready 기준: 브랜치 checkout + `Config/Debug.xcconfig` + `Config/Release.xcconfig` + `Mozi.xcworkspace` -4. 이미 ready 면 setup 재실행 금지 +1. Mozi worktree 세션에서는 **어떤 응답/작업보다 먼저** ready 여부를 확인한다. +2. ready가 아니면 `./scripts/setup-worktree.sh`를 즉시 실행한다. +3. ready 기준 (모두 만족): detached HEAD 아님 + `Config/Debug.xcconfig` + `Config/Release.xcconfig` + `Mozi.xcworkspace` +4. ready면 setup을 재실행하지 않는다. -worktree 진입/미준비 시 `$mozi-worktree-bootstrap` 를 사용한다. +worktree 진입/미준비 시 `$mozi-worktree-bootstrap` 스킬을 사용한다. ### 커밋 -1. `커밋해` 전에는 staging/commit/push 시작 금지 -2. 계획 단계에서는 `git status` / `git diff` 읽기만 -3. 커밋 계획 제안 전 로컬 린트 1회 실행: `mise exec -- swiftlint lint --strict` -4. 린트 실패 시 계획 제안 중단, 위반 사항 보고 후 수정 방향 확인 -5. 커밋 계획(제목 + 의도 + 파일) 1회 승인 후 연속 진행 -6. 커밋 메시지: `Type: 요약`, 한국어, 제목만, body 금지 -7. 한 의도 = 한 커밋 (구현/테스트/문서/설정 분리) +1. `커밋해` 전에는 staging/commit/push 시작 금지. +2. 계획 단계에서는 `git status` / `git diff` 읽기만 허용. +3. 커밋 계획 제안 전 로컬 린트 1회: `mise exec -- swiftlint lint --strict` +4. 린트 실패 시 계획 제안 중단, 위반 사항 보고. +5. 커밋 계획(제목 + 의도 + 파일) 1회 승인 후 진행. +6. 커밋 메시지: `Type: 요약` (한국어, 제목만, body 금지). +7. 한 의도 = 한 커밋 (구현/테스트/문서/설정 분리). -커밋 요청 시 `$mozi-commit` 를 사용한다. +커밋 요청 시 `$mozi-commit` 스킬을 사용한다. ### PR -1. `PR 초안` / `초안 작성` / `작성만` = 본문 초안만 -2. 위 단계에서는 commit/push/PR 생성 금지 -3. 실제 생성 트리거: `PR 생성해` / `PR 올려` / `올려` -4. 생성 형태: open PR 만 (draft 해석 금지) -5. 커밋/푸시 전이면 중단하고 아래 문구 사용: - -```text -아직 커밋/푸시 전입니다. 커밋해 라고 하면 커밋 계획부터 시작할게요. -``` - -6. PR 용 push 직전: `origin/dev` fetch 후 rebase -7. rebase 충돌 시 자동 처리 금지, 즉시 중단 +1. `PR 초안` / `작성만` = 본문 초안만 제시. +2. 위 단계에서는 commit/push/PR 생성 금지. +3. 실제 생성 트리거: `PR 생성해` / `PR 올려` / `올려`. +4. push 직전: `origin/dev` rebase. 충돌 시 즉시 중단. +5. PR는 open PR만 (draft 해석 금지), label 1개, self assignee. -PR 초안/생성 요청 시 `$mozi-pr` 를 사용한다. +PR 요청 시 `$mozi-pr` 스킬을 사용한다. ### worktree cleanup -1. `워크트리 정리해` / `머지됐으니 정리해` / `로컬 정리해` 전에는 삭제 금지 -2. 관련 PR 이 **merged** 인지 먼저 확인 -3. dirty worktree 또는 unpushed commit 이 있으면 중단 -4. 기본은 안전 삭제 (`worktree remove`, `branch -d`, `fetch --prune`) -5. force 삭제는 명시 요청 시에만 -6. 메인 체크아웃 worktree 는 삭제하지 않음 - -머지 후 로컬 정리 요청 시 `$mozi-worktree-cleanup` 를 사용한다. - -## 2. Document Rules - -### 커밋 +1. `워크트리 정리해` / `머지됐으니 정리해` / `로컬 정리해` 전에는 삭제 금지. +2. 관련 PR이 **merged**인지 먼저 확인. +3. dirty worktree 또는 unpushed commit 있으면 중단. +4. 안전 삭제(`worktree remove`, `branch -d`). -- 형식: `Type: 요약` -- Type: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` -- 커밋 그래프만 봐도 변경 흐름이 읽혀야 함 +머지 후 정리 요청 시 `$mozi-worktree-cleanup` 스킬을 사용한다. -### PR 본문 +## 2. Agent Behavior -- 제목 / 변경 요약 / 변경 내용: 명사형 -- 변경 요약: 3줄 전후 -- 변경 내용: 모듈/영역 단위 -- 기타 참고 사항: 선택 섹션, 없으면 삭제 -- base/head/브랜치/커밋 해시 등 운영 메타 금지 - -### PR 메타 - -- Labels: 레포 라벨 목록 기준 **1개만** -- 애매하면 후보를 보여준 뒤 선택 -- Assignees: 현재 사용자 -- Reviewers/Milestone/Projects 는 명시 요청 시에만 - -### 언어 - -- 로컬 문서, 스크립트 주석/usage, 코드 주석, 에이전트 문서: 한국어 기본 -- API 이름, 옵션명, 식별자는 영어 유지 +1. 작업 시작 시 문서 우선순위와 관련 source of truth를 확인한다. +2. 규칙이 충돌하면 해당 주제의 source of truth 파일을 우선한다. + - 구조: ARCHITECTURE.md + - 작성 규칙: CONVENTIONS.md + - 실행/hard gate: AGENT_WORKFLOW.md + - 모듈 디테일: Projects/**/README.md +3. 전역 지도와 모듈 디테일을 구분해서 읽는다. +4. 요구가 모호하면 추측 구현하지 않고 질문한다. +5. 코드와 문서가 다르면 현재 코드 기준으로 보고하고, 문서 수정 필요 여부를 제안한다. +6. 문서 변경 시 중복 본문을 늘리지 않고 링크를 유지한다. ## 3. Runtime Flow ### 커밋 -```text +``` 커밋해 → 변경 확인 → 로컬 린트 (`mise exec -- swiftlint lint --strict`) @@ -107,7 +81,7 @@ PR 초안/생성 요청 시 `$mozi-pr` 를 사용한다. ### PR 초안 -```text +``` PR 초안 / 작성만 → 본문 초안 제시 → 종료 @@ -115,7 +89,7 @@ PR 초안 / 작성만 ### PR 생성 -```text +``` PR 생성해 / PR 올려 / 올려 → 커밋/푸시 상태 확인 → push 필요 시 origin/dev rebase @@ -126,7 +100,7 @@ PR 생성해 / PR 올려 / 올려 ### worktree cleanup -```text +``` 워크트리 정리해 / 머지됐으니 정리해 → PR merged 확인 → dirty/unpushed 검사 @@ -140,4 +114,4 @@ PR 생성해 / PR 올려 / 올려 - 전역 Superpowers plugin 수정 없음 - 도구 경로 고정 규칙은 아직 적용하지 않음 -- document rule 전부를 hard gate 로 격상하지 않음 +- document rule 전부를 hard gate로 격상하지 않음 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 665692d..a8527fa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -47,26 +47,6 @@ SharedLogger → SharedUtils, OSLog App → 조립 ``` -### SharedUtils - -- 포함: `AppInfo`, pure Foundation 헬퍼 -- 제외: UI, Network, Storage, Logger, Domain, TCA helper - -### SharedDesignSystem - -- 포함: semantic UI tokens, typography, `DesignText` / `DesignButton` / `SocialLoginButton` -- Feature public surface: `Color.ds`, `Font.ds`, `CGFloat.ds`, `TextStyle.ds` + 공통 컴포넌트 -- Primitive / Semantic 토큰 타입은 모듈 내부 전용. Feature는 `*.ds`와 컴포넌트만 사용 -- 컴포넌트는 Semantic 토큰을 통해 값을 해석한다 -- Pretendard 폰트 등록은 App bootstrap에서 1회 수행 -- `DesignText` lineHeight는 SwiftUI `.lineSpacing` 기반 근사 적용(고정 line box 미보장) - -### SharedLogger - -- 전역 `Logger.shared` OSLog facade -- `AppInfo.bundleID`를 subsystem으로 사용 -- category별 logger 캐시는 `Locked`로 보호 - ### 금지 ```text @@ -100,55 +80,7 @@ Auth / MainTab 샘플은 포함하지 않는다. --- -## 3. Feature - -```text -Feature/Sources/ - Root/ - AppCoordinator/ - DeepLink/ - Overlay/ - Scene/ - Placeholder/ -``` - -규칙: - -1. Scene 안은 flat (`*Feature`, `*View`) -2. Scene 간 직접 참조 금지 -3. 외부 요청은 `delegate` 로 상위 상승 -4. Feature 는 Domain `*Client` 만 사용 -5. 전역 전환/딥링크/overlay 는 AppCoordinator - -딥링크: - -```text -mozi://home -``` - ---- - -## 4. Domain / Data - -초기 상태: - -```text -Domain/Sources/Placeholder.swift -Data/Sources/Placeholder.swift -``` - -이후 패턴: - -```text -Domain//{Model,Client,Error} -Data//{DTO,Datasource,Repository} - *RepositoryImpl - *ClientFactory -``` - ---- - -## 5. App / Config +## 3. App / Config | Scheme | Config | Bundle ID | |---|---|---| @@ -159,7 +91,7 @@ storage namespace 는 Bundle ID 재사용. --- -## 6. 새 기능 +## 4. 새 기능 1. Domain `{Model,Error,Client}` 2. Data `{DTO,Datasource,RepositoryImpl,ClientFactory}` @@ -170,12 +102,21 @@ storage namespace 는 Bundle ID 재사용. --- -## 7. 관련 +## 5. 관련 - [CONVENTIONS.md](CONVENTIONS.md) - [../AGENTS.md](../AGENTS.md) - [../CLAUDE.md](../CLAUDE.md) +## 모듈 디테일 + +전역 지도만 이 문서에 둔다. 모듈 내부 규칙/진입점/테스트 포인트는 각 README 를 본다. + +- [Feature](../Projects/Feature/README.md) +- [Domain](../Projects/Domain/README.md) +- [Data](../Projects/Data/README.md) +- [CoreNetwork](../Projects/Core/Network/README.md) + --- ## 지원 기기 diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 9077e4e..6c5382e 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -1,21 +1,59 @@ # Conventions 구조/의존/흐름은 [ARCHITECTURE.md](ARCHITECTURE.md) 를 본다. +에이전트 실행 절차/hard gate 는 [AGENT_WORKFLOW.md](AGENT_WORKFLOW.md) 를 본다. ## Coding - - 들여쓰기 4 spaces - line length warning 100 / error 120 - force unwrap 금지 - Feature 단일 모듈, 기능 분리는 폴더 +- Scene 내부 flat (`*Feature`, `*View`) - Domain 포트는 `*Client` - Data 구현은 `*RepositoryImpl` + `*ClientFactory` - live 조립은 App only +- Feature 는 Domain `*Client` 만 사용 - Scene 통신은 delegate bubble-up -- SharedUtils는 pure Foundation / App metadata 만. UI·I/O·Domain 금지 -- CoreNetwork uses SharedLogger (`.network`) and never contains service-specific domain flows +- SharedUtils 는 pure Foundation / App metadata 만. UI·I/O·Domain 금지 +- CoreNetwork 는 SharedLogger (`.network`) 를 쓰고 service-specific domain flow 를 넣지 않는다 - SharedDesignSystem은 Feature에서 `Color.ds` / `Font.ds` / `CGFloat.ds` / `TextStyle.ds` 와 공통 컴포넌트만 사용. Primitive 직접 참조 금지 +## Comments +기본: 주석 최소화. 무분별한 주석 금지. + +허용: +1. 복잡한 로직의 의도/함정 +2. 설명이 필요한 객체 +3. 현재 구현 범위 밖 후속 구현 TODO + +역할: +- `//` : 복잡한 로직, 함정, TODO +- `///` : 타입/포트/공개 계약 + +TODO: +- 형식: `// TODO: ...` +- 지금 구현하지 않지만 추후 필요한 지점만 +- 이슈 번호/소유자 강제 없음 + +금지: +- 자명한 코드 설명 +- 진행 잡담형 주석 + +좋은 예: +```swift +// 이미 다른 요청이 refresh를 끝낸 뒤라면 중복 refresh 없이 재시도한다. +/// Domain 포트. Feature는 이 Client만 의존한다. +// TODO: AuthClient 연결 +``` + +## Tests +- 언어: 한국어 +- 형식: `test_<상황>_<기대결과>` +- 이름만 보고 상황과 기대 결과가 읽혀야 한다 +- 예: + - `test_홈_커스텀스킴이면_홈으로_파싱` + - `test_인증_401이면_한_번만_refresh_후_재시도` + ## Schemes | Scheme | Bundle ID | @@ -23,80 +61,67 @@ | `Mozi-Debug` | `com.teamMozi.debug` | | `Mozi` | `com.teamMozi.app` | -## Git - +## Git Format 커밋: - ```text Type: 요약 ``` - Type: feat, fix, docs, style, refactor, test, chore -추가 규칙: - +추가: - 커밋 본문 금지 (제목만) - 한 의도 = 한 커밋 - 구현/테스트/문서/설정 분리 +- 언어: 한국어 브랜치: - ```text Type/짧은-설명 ``` -예: - -```text -feat/placeholder-runtime -chore/setup-project -``` - PR 제목: - ```text 변경 내용 요약 ``` -예: +PR 본문 템플릿/작성 규칙: +- 제목/`변경 요약`/`변경 내용` 명사형 +- 운영 메타 금지 -```text -placeholder 런타임 추가 -placeholder 런타임 정리 -``` +## Language +- 로컬 문서, 스크립트 주석/usage, 코드 주석, 에이전트 문서: 한국어 기본 +- API 이름, 옵션명, 식별자: 영어 유지 + +## 금지 +- Feature 모듈 쪼개기 +- AppShell / AppRuntime / AppEnvironment / AppConstants +- Feature → Data / Core* / ThirdPartyCore +- Scene 간 직접 통신 +- UseCase 층 -PR 본문: +## PR 본문 템플릿 ```markdown ## 📌 변경 요약 -- 한 줄 요약 1 -- 한 줄 요약 2 -- 한 줄 요약 3 +- +- +- ## 📌 변경 내용 -#### 영역 A -- 구체 변경 1 -- 구체 변경 2 +#### +- -#### 영역 B -- 구체 변경 1 -``` +#### +- -작성 규칙: +## 📌 기타 참고 사항 +- +``` +**작성 규칙** - `변경 요약`은 PR 전체를 빠르게 이해하게 3줄 전후로 적는다. - `변경 내용`은 모듈/영역 단위로 나눈다. 예: Tuist, Domain, Feature, App, Docs -- 각 bullet은 무엇을 바꿨는지 파일/폴더/역할이 보이게 쓴다. -- 삭제/이동/분리도 명시한다. +- 각 bullet은 무엇을 바꿨는지 파일/폴더/역할이 보이게 쓴다. 삭제/이동/분리도 명시한다. - PR 제목/`변경 요약`/`변경 내용` bullet 은 명사형으로 끝낸다. - `기타 참고 사항` 은 선택 섹션이다. 내용이 없으면 섹션 자체를 삭제한다. - base/head/브랜치/커밋 해시 같은 운영 메타는 PR 본문에 넣지 않는다. - - -## 금지 - -- Feature 모듈 쪼개기 -- AppShell / AppRuntime / AppEnvironment / AppConstants -- Feature → Data / Core* / ThirdPartyCore -- Scene 간 직접 통신 -- UseCase 층 diff --git a/docs/skills.md b/docs/skills.md index f38630d..fda6596 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -23,16 +23,10 @@ Mozi workflow를 반복 실행하기 위한 Codex project skills. ## 기준 문서 -- [AGENT_WORKFLOW.md](AGENT_WORKFLOW.md) -- [CONVENTIONS.md](CONVENTIONS.md) +- [AGENT_WORKFLOW.md](AGENT_WORKFLOW.md) — hard gate, Agent Behavior, runtime flow +- [CONVENTIONS.md](CONVENTIONS.md) — 형식/작성규칙 SSOT +- [ARCHITECTURE.md](ARCHITECTURE.md) — 구조/의존/흐름 - [../AGENTS.md](../AGENTS.md) - [../.github/pull_request_template.md](../.github/pull_request_template.md) -## 기준 - -- worktree: ready 아니면 `./scripts/setup-worktree.sh` 먼저 -- 커밋: 계획 전 로컬 린트 1회, `Type: 요약`, 한국어, 제목만, 한 의도 = 한 커밋 -- PR 초안: 본문만 -- PR 생성: open PR, label 1개, assignee = 본인 -- PR push 전: `origin/dev` rebase, 충돌 시 중단 -- 머지 후 정리: merged 확인 후 worktree/local branch 안전 삭제 +자세한 hard gate와 Agent Behavior는 [AGENT_WORKFLOW.md](AGENT_WORKFLOW.md)를 참조.