diff --git a/.agents/rules b/.agents/rules new file mode 120000 index 00000000..52694831 --- /dev/null +++ b/.agents/rules @@ -0,0 +1 @@ +../.claude/rules \ No newline at end of file diff --git a/.agents/rules/api-design.md b/.agents/rules/api-design.md deleted file mode 100644 index bbefc763..00000000 --- a/.agents/rules/api-design.md +++ /dev/null @@ -1,221 +0,0 @@ -# API Design Rules - -## Controller Structure - -```kotlin -@Tag(name = "USER", description = "사용자 API") -@RestController -@RequestMapping("/api/v1/users") -@ApiErrorCodeExample(UserErrorCode::class, JwtErrorCode::class) -class UserController( - private val userUsecase: UserUsecase -) { - @GetMapping - @Operation(summary = "내 정보 조회") - fun getUser(@Parameter(hidden = true) @CurrentUser userId: Long): CommonResponse = - CommonResponse.success(USER_FIND_BY_ID_SUCCESS, userUsecase.find(userId)) -} -``` - -## Club-scoped API - -Club resources use `/api/v4/clubs/{clubId}/...`. `clubId` is Base62 TSID — use two annotations together: - -```kotlin -@TsidParam // Swagger (type: string) -@TsidPathVariable clubId: Long // decodes Base62 → Long at runtime -``` - -## Required Annotations - -| Annotation | Purpose | -|-----------|---------| -| `@Tag(name = "DOMAIN")` | OpenAPI grouping | -| `@Operation(summary = "...")` | API description | -| `@Parameter(hidden = true)` | Hide internal params from docs | -| `@Valid` | Enable validation | -| `@ApiErrorCodeExample(...)` | Auto-register error examples in Swagger | - -## Response Format - -Wrap responses in `CommonResponse`: - -```kotlin -data class CommonResponse( - val code: Int, - val message: String, - val data: T?, -) { - companion object { - @JvmStatic - fun success(responseCode: ResponseCodeInterface): CommonResponse = - CommonResponse(code = responseCode.code, message = responseCode.message, data = null) - - @JvmStatic - fun success(responseCode: ResponseCodeInterface, data: T): CommonResponse = - CommonResponse(code = responseCode.code, message = responseCode.message, data = data) - - @JvmStatic - fun error(errorCode: ErrorCodeInterface): CommonResponse = - CommonResponse(code = errorCode.code, message = errorCode.message, data = null) - } -} -``` - -## Response Codes - -```kotlin -enum class UserResponseCode( - override val code: Int, - override val status: HttpStatus, - override val message: String -) : ResponseCodeInterface { - USER_FIND_ALL_SUCCESS(10900, HttpStatus.OK, "모든 회원 정보를 성공적으로 조회했습니다."), - USER_FIND_BY_ID_SUCCESS(10907, HttpStatus.OK, "회원 정보가 성공적으로 조회되었습니다."), - USER_UPDATE_SUCCESS(10908, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), -} -``` - -- Success code enums must implement `ResponseCodeInterface`. -- Controllers should return success responses with enum directly: - - `CommonResponse.success(USER_FIND_BY_ID_SUCCESS, data)` - - `CommonResponse.success(USER_UPDATE_SUCCESS)` - -## Code Format - -| | Mean | Value | -|---|-----------------|----------------------------------------------------------------------------| -| X | Category | 1=Success, 2=Domain Error, 3=Infra/Server Error, 4=Client/Validation Error | -| DD | Domain ID | 01~99 | -| NN | In Domain Count | 00~99 | - -## Domain ID - -| DD | Domain | Success Range | Domain Error Range | Infra Error Range | -|----|------------|---------------|--------------------|-------------------| -| 01 | account | 10100~ | 20100~ | — | -| 02 | attendance | 10200~ | 20200~ | — | -| 03 | session | 10300~ | 20300~ | — | -| 04 | board | 10400~ | 20400~ | — | -| 05 | comment | 10500~ | 20500~ | — | -| 06 | file | 10600~ | 20600~ | 30600~ | -| 07 | penalty | 10700~ | 20700~ | — | -| 08 | schedule | 10800~ | 20800~ | — | -| 09 | user | 10900~ | 20900~ | — | -| 10 | cardinal | 11000~ | 21000~ | — | -| 11 | club | 11100~ | 21100~ | — | -| 12 | dashboard | 11200~ | 21200~ | — | -| 13 | university | 11300~ | — | 31300~ | -| 90 | jwt/auth | — | 29000~ | — | -| 99 | common | — | — | 39900~ | - -## Domain Success Codes - -| Domain | ResponseCode Enum | Code Range | Location | -|--------|------------------|------------|----------| -| Account | `AccountResponseCode` | `101xx` | `domain/account/presentation/` | -| Attendance | `AttendanceResponseCode` | `102xx` | `domain/attendance/presentation/` | -| Session | `SessionResponseCode` | `103xx` | `domain/session/presentation/` | -| Board | `BoardResponseCode` | `104xx` | `domain/board/presentation/` | -| Comment | `CommentResponseCode` | `105xx` | `domain/comment/presentation/` | -| File | `FileResponseCode` | `106xx` | `domain/file/presentation/` | -| Penalty | `PenaltyResponseCode` | `107xx` | `domain/penalty/presentation/` | -| Schedule | `ScheduleResponseCode` | `108xx` | `domain/schedule/presentation/` | -| User | `UserResponseCode` | `109xx` | `domain/user/presentation/` | -| Cardinal | `CardinalResponseCode` | `110xx` | `domain/cardinal/presentation/` | -| Club | `ClubResponseCode` | `111xx` | `domain/club/presentation/` | -| Dashboard | `DashboardResponseCode` | `112xx` | `domain/dashboard/presentation/` | -| University | `UniversityResponseCode` | `113xx` | `domain/university/presentation/` | - -## Domain Error Codes - -| Domain | ErrorCode Enum | Code Range | Location | -|--------|---------------|------------|----------| -| Account | `AccountErrorCode` | `201xx` | `domain/account/application/exception/` | -| Attendance | `AttendanceErrorCode` | `202xx` | `domain/attendance/application/exception/` | -| Session | `SessionErrorCode` | `203xx` | `domain/session/application/exception/` | -| Board | `BoardErrorCode` | `204xx` | `domain/board/application/exception/` | -| Comment | `CommentErrorCode` | `205xx` | `domain/comment/application/exception/` | -| File | `FileErrorCode` | `206xx` (domain), `306xx` (infra) | `domain/file/application/exception/` | -| Penalty | `PenaltyErrorCode` | `207xx` | `domain/penalty/application/exception/` | -| Schedule | `EventErrorCode` | `208xx` | `domain/schedule/application/exception/` | -| User | `UserErrorCode` | `209xx` | `domain/user/application/exception/` | -| Cardinal | `CardinalErrorCode` | `210xx` | `domain/cardinal/application/exception/` | -| Club | `ClubErrorCode` | `211xx` | `domain/club/application/exception/` | -| Dashboard | `DashboardErrorCode` | `212xx` | `domain/dashboard/application/exception/` | -| University | `UniversityErrorCode` | `313xx` (infra) | `domain/university/application/exception/` | -| JWT (Global) | `JwtErrorCode` | `290xx` | `global/auth/jwt/application/exception/` | - -## HTTP Methods - -| Method | Usage | -|--------|-------| -| GET | Read operations, no body | -| POST | Create resources | -| PUT | Full updates | -| PATCH | Partial updates | -| DELETE | Remove resources | - -## Path Design - -``` -GET /users # List users -GET /users/{userId} # Get single user -POST /users # Create user -PATCH /users/{userId} # Update user -DELETE /users/{userId} # Delete user -POST /users/{userId}/activate # Action on resource -``` - -### Admin Endpoints - -`admin` prefix comes **before** `clubs/{clubId}`: `/api/v4/admin/clubs/{clubId}/{resource}` - -``` -/api/v4/clubs/{clubId}/boards # user-facing -/api/v4/admin/clubs/{clubId}/boards # admin -``` - -Enables a single SecurityConfig rule: `.requestMatchers("/api/v4/admin/**").hasRole("ADMIN")` - -## Query & Path Parameters - -- Query params for filtering: `?page=0&size=10&status=ACTIVE` -- Path variables for resource identification: `/users/{userId}` - -## Request/Response DTO - -```kotlin -// Request -data class CreateUserRequest( - @field:Schema(description = "User name", example = "John Doe") - @field:NotBlank - @field:Size(max = 100) - val name: String, - - @field:Schema(description = "Email address", example = "john@example.com") - @field:NotBlank - @field:Email - val email: String -) - -// Response -data class UserResponse( - @Schema(description = "User ID", example = "1") - val id: Long, - - @Schema(description = "User name", example = "John Doe") - val name: String, - - @Schema(description = "Email address", example = "john@example.com") - val email: String? -) -``` - -## Validation - -Use Jakarta validation annotations in DTOs: -- `@NotNull`, `@NotEmpty`, `@NotBlank` -- `@Size(min = 1, max = 100)` -- `@Positive`, `@PositiveOrZero` -- `@Email`, `@Pattern` diff --git a/.agents/rules/architecture.md b/.agents/rules/architecture.md deleted file mode 100644 index e4f2c771..00000000 --- a/.agents/rules/architecture.md +++ /dev/null @@ -1,220 +0,0 @@ -# Architecture Rules - -## Package Structure - -```text -src/main/kotlin/com/weeth/ -├── domain/{domain-name}/ -│ ├── application/ -│ │ ├── dto/request/, dto/response/ -│ │ ├── mapper/ -│ │ ├── usecase/ -│ │ │ ├── command/ # State-changing use cases -│ │ │ └── query/ # Read-only query services -│ │ ├── exception/ -│ │ └── validator/ -│ ├── domain/ -│ │ ├── entity/ # Rich Domain Model -│ │ ├── vo/ # Value Objects -│ │ ├── enums/ -│ │ ├── port/ # External system abstraction (Port interface) -│ │ ├── service/ # Multi-entity business logic only -│ │ └── repository/ -│ ├── infrastructure/ # Port implementations (Adapter) -│ └── presentation/ -│ └── *Controller.kt -└── global/ - ├── auth/ - ├── config/ - ├── common/ - └── logging/ -``` - -## Layer Dependencies - -```text -presentation → application → domain (owns Port) - ↑ - infrastructure (implements Port) -``` - -- **presentation** → application only -- **application** → domain (Repository, Entity, Service, Port). Never import infrastructure directly -- **domain** → depends on nothing. Owns Port interfaces -- **infrastructure** → implements domain/port. Depends on external libraries/SDK -- **Same domain**: UseCase uses Repository directly -- **Cross-domain read**: via target domain's Reader interface (not Repository directly) -- **Cross-domain write**: Repository directly (same transaction required) -- **Cross-domain write**: Use Domain Event (transaction separable) - -## UseCase Rules - -| Type | Package | Naming | Transaction | -|------|---------|--------|-------------| -| Command | `usecase/command/` | `{Verb}{Domain}UseCase` | `@Transactional` | -| Query | `usecase/query/` | `Get{Domain}QueryService` | `@Transactional(readOnly = true)` | - -- **Orchestration only**: delegates business logic to Entity, calls Repository directly -- **No wrapper services**: do NOT create GetService/SaveService/DeleteService for thin Repository delegation -- **Group related actions**: e.g. `AuthUserUseCase` = login + signup + withdraw - -## Query Service - -- **Role**: data assembly for presentation (query, map, combine, paginate) — not business logic -- **Transaction**: `@Transactional(readOnly = true)` -- **Return type**: Response DTO -- **Prohibited**: state changes, business logic execution - -### Command UseCase → Query Service dependency - -| Situation | Recommendation | -|-----------|----------------| -| Simple `findById` + exception | Use Repository directly | -| Complex query returning Entity | Depend on Query Service OK | -| Query Service returns Response DTO | Do NOT depend — use Reader or Repository | - -## Cross-domain Reference - -- **Read**: Reader interface in target domain (`domain/repository/`), implemented by Repository -- **Write**: Repository directly (same transaction required) - -## Entity (Rich Domain Model) - -- **State changes**: named methods (`publish()`, `softDelete()`) — no public setters -- **Validation**: `require` for argument checks, `check` for state preconditions -- **Business decisions**: `isEditableBy()`, `canPublish()` belong to Entity - -### Constructor Pattern - -Primary constructor takes **business creation params only** (non-property) — JPA-managed fields (`id`, `isDeleted`) belong in the body with `private set` and default values. - -```kotlin -@Entity -class Post( - title: String, - content: String, - user: User, - board: Board, -) : BaseEntity() { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - var id: Long = 0L - private set - - var title: String = title - private set - // ... - - companion object { - fun create(title: String, content: String, user: User, board: Board): Post { - require(title.isNotBlank()) { "제목은 비어 있을 수 없습니다" } - return Post(title = title, content = content, user = user, board = board) - } - } -} -``` - -| Concern | Location | -|---------|----------| -| JPA-managed fields (`id`, `isDeleted`) | Body, `private set`, default value | -| Business creation params | Primary constructor (non-property) | -| Validation | `create()` / named mutation methods — not constructor | - -- **Factory method** (`companion object`): use when the entity has creation logic or validation. Expresses domain intent. -- **Simple entities** (e.g., `Board`): public constructor is fine; no factory method needed if creation is trivial. - -## Value Object (VO) - -- **Location**: `domain/vo/` -- **Single field**: Kotlin `value class` — inline at JVM level, zero overhead -- **Multi field**: `@Embeddable class` (NOT `data class`) — used with `@Embedded` in Entity. Apply the same `private set` pattern as Entity; handle normalization/validation in a `companion object` `of`/`from` factory when needed. - -### value class (single field) - -```kotlin -@JvmInline -value class Email(val value: String) { - init { - require(value.contains("@")) { "Invalid email format: $value" } - } -} -``` - -### @Embeddable class (composite fields) - -```kotlin -@Embeddable -class Period( - startDate: LocalDate, - endDate: LocalDate, -) { - @Column(nullable = false) - var startDate: LocalDate = startDate - private set - - @Column(nullable = false) - var endDate: LocalDate = endDate - private set - - init { - require(!this.endDate.isBefore(this.startDate)) { "endDate must be after startDate" } - } - - fun contains(date: LocalDate): Boolean = - !date.isBefore(startDate) && !date.isAfter(endDate) - - companion object { - fun of(startDate: LocalDate, endDate: LocalDate): Period = - Period(startDate = startDate, endDate = endDate) - } -} -``` - -> Why not `data class`: keep state changes confined to explicit named methods via `private set`, mirroring Entity. The default identity-based `equals/hashCode` is fine because an embedded VO is compared as part of its owning Entity, not on its own. - -### Usage in Entity - -```kotlin -@Entity -class User( - @Embedded - val period: Period, - - // value class stored as primitive via .value - @Column(nullable = false) - val email: String, // Entity field is primitive; VO conversion at UseCase/Service boundary -) -``` - -### VO Rules - -| Rule | Description | -|------|-------------| -| State control | `value class`: single `val` field. `@Embeddable class`: `var` + `private set` in the body; mutate only via named methods | -| Self-validating | Validate with `require` in `init` block (or normalize in a factory and delegate) | -| Equality | `value class`: automatic. `@Embeddable class`: default (identity); override `equals/hashCode` explicitly only when value-equality is required | -| Factory | Use a `companion object` `of`/`from` factory when normalization (e.g. trim) or extra validation is needed | -| Business logic | May contain operations/decisions relevant to the value | -| JPA mapping | `@Embeddable` + `@Embedded` for composite; value class stored as primitive in Entity | - -## Domain Service - -- **Only for multi-entity logic** or rules that don't fit a single Entity -- **No thin wrappers**: do NOT create `{Domain}GetService`, `{Domain}SaveService` -- **No `@Transactional`**: UseCase manages transaction boundaries -- **Name by role**: `AttendancePolicy`, `DuplicateCheckService` - -## Port-Adapter Pattern - -- **Port** (`domain/port/`): interface in domain language → `FileStoragePort`, `PushNotificationSenderPort` -- **Adapter** (`infrastructure/`): implementation with tech prefix → `S3FileStorageAdapter`, `FcmPushNotificationSenderAdapter` -- UseCase depends on Port interface only → swappable, testable - -## Core Principles - -1. **Rich Domain Model**: Entity owns validation, state changes, and business decisions -2. **UseCase = orchestration**: coordinates flow; "how" is decided by Entity -3. **No meaningless services**: Repository wrappers are eliminated; Domain Service only for multi-entity logic -4. **Port-Adapter**: domain owns Port interfaces; infrastructure implements them -5. **Kotlin-first**: Java → Kotlin migration complete; all new code in Kotlin diff --git a/.agents/rules/code-style.md b/.agents/rules/code-style.md deleted file mode 100644 index 36897d4e..00000000 --- a/.agents/rules/code-style.md +++ /dev/null @@ -1,88 +0,0 @@ -# Code Style Rules - -## Language - -- Primary: Kotlin only. Do not introduce Java production code. -- Build: Gradle (Kotlin DSL) - -## Formatting - -- Use ktlint -- Run `./gradlew ktlintFormat` before committing - -## Naming Conventions - -| Element | Convention | Example | -|---------|-----------|---------| -| Classes | PascalCase | `UserController`, `CreateUserUseCase` | -| Methods | camelCase | `getUserDetail`, `createUser` | -| Constants | SCREAMING_SNAKE_CASE | `MAX_PAGE_SIZE` | -| Packages | lowercase | `com.example.domain.user` | -| DTOs | Suffix with purpose | `CreateUserRequest`, `UserResponse` | -| Test Fixtures | `{Entity}TestFixture` | `UserTestFixture` | - -## Null Safety - -- Avoid using Kotlin non-null assertion operator `!!`. -- Prefer safe call (`?.`), Elvis operator (`?:`), and `requireNotNull`/`checkNotNull` unless `!!` is truly unavoidable. -- If `!!` must be used, add a short comment explaining why in that code block. - -## Data Class vs Class - -```kotlin -// Request DTO - Use data class -data class CreateUserRequest( - @field:NotBlank val name: String, - @field:Email val email: String -) - -// Response DTO - Use data class -data class UserResponse( - val id: Long, - val name: String -) - -// Entity - Use class (not data class) -@Entity -class User( - @Id @GeneratedValue - val id: Long = 0, - var name: String -) : BaseEntity() -``` - -## Import Organization - -1. Kotlin standard library -2. Third-party libraries -3. Spring framework -4. Project classes - -## Constants - -```kotlin -companion object { - private const val MAX_PAGE_SIZE = 20 - private const val DEFAULT_PAGE_SIZE = 10 -} -``` - -## Comments - -- Do NOT comment on self-explanatory code -- Add comments in these cases: - - **Core business logic**: Domain rules, policy decisions — explain "why", not "what" - - **Collaboration aid**: Intent or background that other developers need to understand the code - - **Non-obvious implementation**: Performance optimizations, workarounds, external system constraints - - **Architecture decisions**: Reason for choosing a specific pattern or structure -- Use KDoc (`/** */`) for public APIs, Port interfaces, and external contracts -- Use inline comments (`//`) for implementation intent within methods - -## Null Handling - -```kotlin -// Use nullable types and Elvis operator -fun getUser(userId: Long): User = - userRepository.findByIdOrNull(userId) - ?: throw UserNotFoundException() -``` diff --git a/.agents/rules/exception-handling.md b/.agents/rules/exception-handling.md deleted file mode 100644 index 5bee6001..00000000 --- a/.agents/rules/exception-handling.md +++ /dev/null @@ -1,136 +0,0 @@ -# Exception Handling Rules - -## Exception Hierarchy - -``` -RuntimeException - └── BaseException (abstract) - ├── UserNotFoundException - ├── BoardNotFoundException - └── ... (domain-specific exceptions) -``` - -## Base Exception - -```kotlin -abstract class BaseException( - val errorCode: ErrorCodeInterface, - message: String? = null -) : RuntimeException(message ?: errorCode.message) -``` - -## Error Code Interface - -```kotlin -interface ErrorCodeInterface { - val code: Int - val status: HttpStatus - val message: String - - fun getExplainError(): String = message -} -``` - -## Domain Error Codes - -```kotlin -enum class UserErrorCode( - override val code: Int, - override val status: HttpStatus, - override val message: String -) : ErrorCodeInterface { - @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") - USER_NOT_FOUND(20900, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), - - @ExplainError("가입 승인 대기 중인 사용자가 접근을 시도할 때 발생합니다.") - USER_INACTIVE(20901, HttpStatus.FORBIDDEN, "가입 승인이 허가되지 않은 계정입니다."), - - @ExplainError("이미 가입된 이메일로 회원가입을 시도할 때 발생합니다.") - USER_EXISTS(20902, HttpStatus.BAD_REQUEST, "이미 가입된 사용자입니다."), -} -``` - -## Common Error Codes (pattern example, not yet implemented) - -Follow the pattern below when introducing a common error code enum. Currently, `CommonExceptionHandler` uses `CommonResponse.createFailure()` directly. - -```kotlin -enum class CommonErrorCode( - override val code: Int, - override val status: HttpStatus, - override val message: String -) : ErrorCodeInterface { - // 3DDNN: Infra/Server errors (DD=99 for common) - INTERNAL_SERVER_ERROR(39901, HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"), - JSON_PROCESSING_ERROR(39902, HttpStatus.INTERNAL_SERVER_ERROR, "JSON processing error"), - - // 4DDNN: Client/Validation errors (DD=99 for common) - INVALID_ARGUMENT(49901, HttpStatus.BAD_REQUEST, "Invalid argument"), - RESOURCE_NOT_FOUND(49903, HttpStatus.NOT_FOUND, "Resource not found"), -} -``` - -## Domain Exception Classes - -```kotlin -class UserNotFoundException : BaseException(UserErrorCode.USER_NOT_FOUND) -``` - -## Swagger Exception Documentation (Auto) - -Swagger is customized so exception codes/examples are registered automatically from annotations and error-code enums. - -### Required Annotations - -- `@ApiErrorCodeExample`: Declare which `ErrorCodeInterface` enums can be returned by an API. -- `@ExplainError`: Optional field-level description for richer Swagger examples. - -```kotlin -@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) -@Retention(AnnotationRetention.RUNTIME) -annotation class ApiErrorCodeExample( - vararg val value: KClass -) -``` - -### Controller Convention - -- Apply `@ApiErrorCodeExample` at controller class level when most endpoints share the same domain errors. -- Apply it at method level when a specific endpoint has different error sets. -- If both are present, method-level declaration should take precedence for that endpoint. -- If multiple enums are needed, pass them together: - -```kotlin -@ApiErrorCodeExample(BoardErrorCode::class, NoticeErrorCode::class) -class NoticeController -``` - -### ErrorCode Enum Convention - -- Domain error enums must implement `ErrorCodeInterface`. -- Add `@ExplainError` to each enum constant when possible. -- If `@ExplainError` is missing, fallback to `message`. - -```kotlin -enum class UserErrorCode( - override val code: Int, - override val status: HttpStatus, - override val message: String -) : ErrorCodeInterface { - @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") - USER_NOT_FOUND(20900, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), -} -``` - -### Documentation-only Controller - -- Keep an `ExceptionDocController` for aggregated, domain-wide exception browsing in Swagger. -- This controller is for documentation only; it should not contain business logic. - -### When Adding a New Exception - -1. Add enum constant to the proper `*ErrorCode`. -2. Add `@ExplainError` description. -3. Create/adjust domain exception class extending `BaseException`. -4. Ensure the relevant controller/method has `@ApiErrorCodeExample` for that enum. -5. Verify Swagger examples show the new code without manual response-spec edits. diff --git a/.agents/rules/git-conventions.md b/.agents/rules/git-conventions.md deleted file mode 100644 index 45d28d13..00000000 --- a/.agents/rules/git-conventions.md +++ /dev/null @@ -1,112 +0,0 @@ -# Git Conventions Rules - -# Commit Convention -## Format - -``` -type: message -``` - -- **type**: lowercase English -- **message**: Brief description (imperative mood) - -## Types - -| Type | Description | Example | -|------|-------------|---------| -| `feat` | New feature | `feat: Add user authentication` | -| `fix` | Bug fix | `fix: Resolve null pointer in service` | -| `refactor` | Code refactoring | `refactor: Extract validation logic` | -| `test` | Test code changes | `test: Add UserService unit tests` | -| `docs` | Documentation | `docs: Update API documentation` | -| `style` | Code formatting | `style: Apply code formatter` | -| `chore` | Maintenance | `chore: Update dependencies` | -| `perf` | Performance improvement | `perf: Optimize database queries` | -| `ci` | CI configuration | `ci: Add GitHub Actions workflow` | -| `build` | Build system | `build: Update Gradle config` | - -## Examples - -```bash -# New feature -feat: Add user registration endpoint - -# Bug fix -fix: Handle null profile in user response - -# Refactoring -refactor: Split UserService into Get/Save services - -# Test -test: Add integration tests for auth flow - -# Documentation -docs: Add API usage examples - -# Style -style: Format code with ktlint - -# Chore -chore: Upgrade Spring Boot to 3.2.0 -``` - -## Rules - -1. **No period** at the end -2. **Imperative mood** ("Add" not "Added", "Fix" not "Fixed") -3. **50 characters or less** for subject line -4. **Separate subject from body** with blank line if body needed -5. **Reference issue numbers** if applicable: `fix: Resolve login bug (#123)` - -## Multi-line Commits - -For detailed descriptions: - -```bash -git commit -m "$(cat <<'EOF' -feat: Add user authentication - -- Implement JWT token generation -- Add login/logout endpoints -- Create auth middleware - -Closes #123 -EOF -)" -``` ---- -# Branch Convention - -| Type | Pattern | Example | -|------|---------|------------------------------| -| Feature | `feat/{ticket}-description` | `feat/WTH-123-user-login` | -| Bugfix | `fix/{ticket}-description` | `fix/WTH-456-token-expiry` | -| Refactor | `refactor/{ticket}-description` | `refactor/WTH-789-cleanup` | -| Hotfix | `hotfix/description` | `hotfix/critical-auth-bug` | -| Release | `release/version` | `release/v1.2.0` | - -## Branch Update Policy - -- Update local branches from the latest target branch using **merge**. -- Default command: `git merge origin/{target-branch}`. -- Do not rewrite shared branch history with rebase when syncing latest changes. - -## Pre-commit Checklist - -1. Run linter: `./gradlew ktlintFormat` -2. Run tests: `./gradlew test` -3. Verify commit message format -4. Review changed files -5. Check for sensitive data (.env, credentials) - -## Conventional Commits (Optional) - -For automated changelog generation: - -``` -type(scope): message - -feat(auth): Add OAuth2 support -fix(api): Handle rate limiting -refactor(user): Simplify validation logic -``` diff --git a/.agents/rules/mapper-dto.md b/.agents/rules/mapper-dto.md deleted file mode 100644 index c1fd5289..00000000 --- a/.agents/rules/mapper-dto.md +++ /dev/null @@ -1,123 +0,0 @@ -# Mapper & DTO Rules - -## Mapper Pattern - -Manual `@Component` Mapper pattern (no MapStruct). - -```kotlin -@Component -class UserMapper { - fun toResponse(user: User) = UserResponse( - id = user.id, - name = user.name, - email = user.email, - ) - - fun toEntity(request: CreateUserRequest) = User( - name = request.name.trim(), - email = request.email.lowercase(), - status = UserStatus.ACTIVE - ) -} -``` - -## Mapper Naming - -| Method Pattern | Purpose | -|---------------|---------| -| `toResponse` | Entity → Response DTO | -| `toEntity` | Request DTO → Entity | -| `toDto` | Entity → Generic DTO | -| `from{Source}` | Convert from specific source type | - -## Request DTO - -Located in `application/dto/request/`: - -```kotlin -data class CreateUserRequest( - @field:Schema(description = "User name", example = "John Doe") - @field:NotBlank - @field:Size(max = 100) - val name: String, - - @field:Schema(description = "Email address", example = "john@example.com") - @field:NotBlank - @field:Email - val email: String, -) -``` - -### Validation Annotations - -| Annotation | Usage | -|-----------|-------| -| `@NotNull` | Field must not be null | -| `@NotEmpty` | Collection must have elements | -| `@NotBlank` | String must not be empty/whitespace | -| `@Size(min, max)` | Length/size constraints | -| `@Positive` | Number must be > 0 | -| `@Valid` | Validate nested objects | - -## Response DTO - -Located in `application/dto/response/`: - -```kotlin -data class UserResponse( - @Schema(description = "User ID", example = "1") - val id: Long, - - @Schema(description = "User name", example = "John Doe") - val name: String, -) -``` - -### Response DTO Rules - -- Use `@Schema` for OpenAPI documentation -- Use non-nullable types for required fields -- Use nullable types with default `null` for optional fields - -## List Response with Pagination (pattern example) - -Follow the pattern below when introducing a pagination response DTO. - -```kotlin -data class UserListResponse( - @Schema(description = "User list") - val users: List, - - @Schema(description = "Pagination info") - val page: PageResponse -) - -data class PageResponse( - val pageNumber: Int, - val pageSize: Int, - val totalElements: Long, - val totalPages: Int, - val hasNext: Boolean -) { - companion object { - fun from(page: Page<*>) = PageResponse( - pageNumber = page.number, - pageSize = page.size, - totalElements = page.totalElements, - totalPages = page.totalPages, - hasNext = page.hasNext() - ) - } -} -``` - -## Mapper Dependencies - -Mappers can inject other mappers when needed: - -```kotlin -@Component -class PostMapper( - private val commentMapper: CommentMapper -) -``` diff --git a/.agents/rules/testing.md b/.agents/rules/testing.md deleted file mode 100644 index e99df07e..00000000 --- a/.agents/rules/testing.md +++ /dev/null @@ -1,125 +0,0 @@ -# Testing Rules - -## Frameworks - -| Framework | Purpose | -|-----------|---------| -| Kotest | Kotlin test framework | -| MockK | Kotlin mocking | -| springmockk | Spring bean mocking (`@MockkBean`) | -| Testcontainers | Integration tests (DB, Redis, etc.) | - -## Test Styles (Kotest) - -| Style | Use Case | -|-------|----------| -| `DescribeSpec` | Default for application tests (Command UseCase, QueryService) | -| `BehaviorSpec` | Complex business logic requiring BDD (Given/When/Then) | -| `StringSpec` | Simple validation and pure domain logic tests | - -## Directory Structure - -```text -src/test/kotlin/com/weeth/domain/{domain-name}/ -├── application/usecase/command/ # Command UseCase tests -├── application/usecase/query/ # QueryService tests -├── domain/service/ # Domain service tests (multi-entity logic) -├── domain/entity/ # Entity behavior tests -└── fixture/ # Shared fixtures for the domain -``` - -## Naming Conventions - -| Element | Convention | Example | -|---------|-----------|---------| -| Test class | `{ClassName}Test` | `CreateUserUseCaseTest`, `GetUserQueryServiceTest` | -| Test fixture | `{Entity}TestFixture` | `UserTestFixture` | -| DescribeSpec description | method/action + condition + behavior | `describe("execute") { context("with valid request") { it("creates user") } }` | - -## Architecture-aligned Unit Boundaries - -- Command UseCase test: mock Repository/Reader/Port, verify orchestration behavior. -- QueryService test: verify read-only assembly (query/map/combine/paginate), no state mutation. -- Entity test: verify `create/of`, state transitions, `require/check`, and business decisions. -- Domain Service test: only for multi-entity logic/policy classes (not thin wrappers). -- Controller test: verify request/response contract and serialization with `@WebMvcTest`. - -## Dependency Rules in Tests - -- Same-domain dependencies: UseCase mocks Repository directly. -- Cross-domain read: mock target domain Reader interface (not target Repository directly). -- Cross-domain write: mock target domain Repository directly when same-transaction write is required. -- Port-Adapter: application tests mock Port interface, not infrastructure adapter implementations. - -## Unit Test vs Integration Test - -| Category | Unit Test | Integration Test | -|----------|-----------|-----------------| -| Scope | Single class | Multiple layers / external systems | -| Dependencies | MockK mocks | Testcontainers (DB, Redis) | -| Speed | Fast (ms) | Slow (seconds) | -| Annotation | None | `@SpringBootTest`, `@WebMvcTest` | -| When to use | Orchestration, branching, entity/domain rules | DB queries, API endpoints, transaction behavior | - -## Fixture Pattern - -```kotlin -object UserTestFixture { - fun createUser( - id: Long = 1L, - email: String = "test@example.com", - name: String = "Test User" - ) = User(id = id, name = name, email = email, status = UserStatus.ACTIVE) -} -``` - -- Location: `src/test/kotlin/com/weeth/domain/{domain-name}/fixture/` -- Use `object` with factory methods -- Provide sensible defaults for all parameters -- Reuse across test classes in the same domain - -## What to Test / Skip - -**Write tests for:** -- UseCase orchestration paths (success/failure/branching) -- Reader/Repository/Port interaction contracts (`verify`) -- QueryService data assembly and pagination mapping -- Entity invariants and state transitions (`require`/`check`) -- Exception scenarios and error-code mapping - -**Skip tests for:** -- Thin wrapper methods that only delegate to Repository without logic -- Getter/setter, trivial DTO mapping -- Framework-provided functionality - -## Mock Lifecycle in DescribeSpec - -MockK mocks are **not** automatically cleared between `it` blocks. Without clearing, accumulated invocations cause `verify(exactly = N)` to fail in subsequent tests. - -Always add `beforeTest { clearMocks(...) }` when mocks are shared: - -```kotlin -class SomeUseCaseTest : DescribeSpec({ - val repository = mockk() - val useCase = SomeUseCase(repository) - - beforeTest { - clearMocks(repository) - // Re-stub defaults after clearing - every { repository.save(any()) } answers { firstArg() } - } - - describe("someMethod") { - it("case 1") { verify(exactly = 1) { repository.save(any()) } } - it("case 2") { verify(exactly = 1) { repository.save(any()) } } // OK - count reset - } -}) -``` - -## Running Tests - -```bash -./gradlew test # All tests -./gradlew test --tests "*UseCaseTest" # Pattern match -./gradlew test --tests "CreateUserUseCaseTest" # Specific class -``` diff --git a/.agents/rules/transaction-concurrency.md b/.agents/rules/transaction-concurrency.md deleted file mode 100644 index c7c24665..00000000 --- a/.agents/rules/transaction-concurrency.md +++ /dev/null @@ -1,141 +0,0 @@ -# Transaction & Concurrency Rules - -## Transaction Annotations - -### Read Operations -```kotlin -@Transactional(readOnly = true) -fun getFeedDetail(feedId: Long): FeedDetailResponse { - // Query operations only -} -``` - -### Write Operations -```kotlin -@Transactional -fun uploadFeed(userId: Long, request: FeedUploadRequest) { - // Create/Update/Delete operations -} -``` - -## Transaction Placement - -- Place `@Transactional` on **UseCase** methods -- Domain Services should NOT have `@Transactional` -- Let UseCase manage transaction boundaries - -```kotlin -@Service -class CreateFeedUseCase( - private val feedRepository: FeedRepository, - private val mediaRepository: MediaRepository, - private val userReader: UserReader, - private val feedMapper: FeedMapper -) { - @Transactional - fun execute(userId: Long, request: FeedUploadRequest) { - val user = userReader.findById(userId) - ?: throw UserNotFoundException() - val feed = feedMapper.toEntity(user, request.description) - feedRepository.save(feed) - val mediaList = request.media.map { Media.create(feed, it) } - mediaRepository.saveAll(mediaList) - } -} -``` - -## Pessimistic Locking - -For resources that need concurrent access control: - -```kotlin -interface FeedRepository : JpaRepository { - @Lock(LockModeType.PESSIMISTIC_WRITE) - @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) - @Query("SELECT f FROM Feed f WHERE f.id = :id") - fun findByIdWithLock(@Param("id") id: Long): Feed? -} -``` - -## When to Use Locking - -| Scenario | Lock Type | -|----------|-----------| -| Counter updates (reaction count) | PESSIMISTIC_WRITE | -| Concurrent modifications | PESSIMISTIC_WRITE | -| Read-heavy, write-rare | OPTIMISTIC (version field) | - -## Lock Timeout Handling - -```kotlin -@Service -class ReactionUsecase( - private val feedRepository: FeedRepository -) { - @Transactional - fun react(userId: Long, feedId: Long) { - try { - val feed = feedRepository.findByIdWithLock(feedId) - ?: throw FeedNotFoundException() - // process reaction - } catch (e: PessimisticLockingFailureException) { - throw ResourceLockedException() - } - } -} -``` - -## Optimistic Locking - -Add version field to entity: - -```kotlin -@Entity -class Feed( - @Version - val version: Long = 0 -) : BaseEntity() -``` - -## Transaction Propagation - -Default propagation is `REQUIRED`. Use others when needed: - -```kotlin -// New transaction (for audit logs, etc.) -@Transactional(propagation = Propagation.REQUIRES_NEW) -fun logAction(action: String) { } - -// No transaction -@Transactional(propagation = Propagation.NOT_SUPPORTED) -fun nonTransactionalOperation() { } -``` - -## Transaction Isolation - -Default is database default. Adjust for specific needs: - -```kotlin -@Transactional(isolation = Isolation.SERIALIZABLE) -fun criticalOperation() { } -``` - -## Async Operations - -For async operations, transaction context is NOT propagated: - -```kotlin -@Async -@Transactional -fun asyncOperation() { - // New transaction in async thread -} -``` - -## Best Practices - -1. **Keep transactions short** - Don't do I/O operations inside transactions -2. **Avoid nested transactions** - Can cause unexpected behavior -3. **Lock ordering** - Always acquire locks in same order to prevent deadlocks -4. **Timeout configuration** - Always set lock timeouts -5. **Handle lock exceptions** - Convert to user-friendly errors diff --git a/.agents/skills b/.agents/skills new file mode 120000 index 00000000..454b8427 --- /dev/null +++ b/.agents/skills @@ -0,0 +1 @@ +../.claude/skills \ No newline at end of file diff --git a/.agents/skills/architecture-guide/SKILL.md b/.agents/skills/architecture-guide/SKILL.md deleted file mode 100644 index 8082f224..00000000 --- a/.agents/skills/architecture-guide/SKILL.md +++ /dev/null @@ -1,273 +0,0 @@ ---- -name: architecture-guide -description: "Show architecture patterns with code examples. Use when asked to 'show architecture', 'architecture example', 'how to structure', or when implementing new features/domains." ---- - -# Architecture Guide - -Provide architecture pattern examples for the current task. -**All output MUST be written in Korean.** - -## Reference: architecture rule - -Always read `.agents/rules/architecture.md` first for core rules. - ---- - -## UseCase Example - -### Command UseCase - -```kotlin -@Service -class CreatePostUseCase( - private val postRepository: PostRepository, // Same domain → Repository directly - private val userReader: UserReader, // Cross-domain → Reader interface - private val fileStorage: FileStoragePort, // Port interface - private val postMapper: PostMapper // Mapper -) { - @Transactional - fun execute(userId: Long, request: CreatePostRequest): PostResponse { - val user = userReader.findById(userId) - ?: throw UserNotFoundException() - val imageUrl = fileStorage.upload(request.image) - val post = Post.create(request.title, request.content, imageUrl, user) - postRepository.save(post) - return postMapper.toResponse(post) - } -} -``` - -### Query Service - -Query Service is the layer for **assembling data for read requests**. Its core purpose is presentation-oriented data composition, not business logic. - -```kotlin -@Service -class GetPostQueryService( - private val postRepository: PostRepository, - private val postMapper: PostMapper -) { - @Transactional(readOnly = true) - fun findById(postId: Long): PostResponse { - val post = postRepository.findByIdOrNull(postId) - ?: throw PostNotFoundException() - return postMapper.toResponse(post) - } - - @Transactional(readOnly = true) - fun findAll(pageable: Pageable): Page { - return postRepository.findAll(pageable) - .map { postMapper.toResponse(it) } - } -} -``` - -| Item | Rule | -|------|------| -| Role | Data retrieval, mapping, composition, paging | -| Transaction | `@Transactional(readOnly = true)` | -| Return type | Response DTO | -| Prohibited | State mutation, business logic execution | - -### Query Service Dependency from Command UseCase - -| Scenario | Recommendation | -|------|------| -| Simple `findById` + exception | Call Repository directly | -| Complex query where Query Service returns Entity | Dependency is acceptable | -| Query Service returns Response DTO | Do not depend on it | - -### UseCase Does / Does Not - -| Does (orchestration) | Does NOT (delegate to Entity) | -|----------------------|-------------------------------| -| Repository calls (find, save) | Business validation | -| Transaction boundary | State change logic | -| DTO ↔ Entity (Mapper) | Domain rule decisions | -| Port calls (external systems) | Value calculations, policy | - ---- - -## Cross-domain Reference - -### Read: Reader Interface - -When reading data from another domain, use a **read-only interface** instead of the full Repository. - -```kotlin -// Defined in user domain (domain/repository/) -interface UserReader { - fun findById(id: Long): User? - fun existsById(id: Long): Boolean -} - -// UserRepository extends UserReader -interface UserRepository : JpaRepository, UserReader -``` - -### Write: Direct Repository Dependency - -When cross-domain writes are required (same transaction is mandatory): - -```kotlin -@Service -class CreateOrderUseCase( - private val orderRepository: OrderRepository, - private val productRepository: ProductRepository // Cross-domain write → Repository directly -) { - @Transactional - fun execute(request: CreateOrderRequest): OrderResponse { - val product = productRepository.findByIdOrNull(request.productId) - ?: throw ProductNotFoundException() - product.decreaseStock(request.quantity) - val order = Order.create(product, request.quantity) - orderRepository.save(order) - return orderMapper.toResponse(order) - } -} -``` - -### Cross-domain Reference Summary - -| Scenario | Approach | -|------|------| -| Cross-domain read | Reader interface | -| Cross-domain write (same transaction required) | Direct Repository dependency | - ---- - -## Entity (Rich Domain Model) Example - -```kotlin -@Entity -class Post( - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long = 0, - var title: String, - var content: String, - @Enumerated(EnumType.STRING) - var status: PostStatus = PostStatus.DRAFT, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - val author: User -) : BaseEntity() { - - companion object { - fun create(title: String, content: String, imageUrl: String?, author: User): Post { - require(title.isNotBlank()) { "Title must not be blank" } - require(content.length <= 5000) { "Content must be 5000 chars or less" } - return Post(title = title, content = content, author = author) - } - } - - fun publish() { - check(status == PostStatus.DRAFT) { "Only DRAFT posts can be published" } - status = PostStatus.PUBLISHED - } - - fun update(title: String, content: String) { - check(status != PostStatus.DELETED) { "Deleted posts cannot be updated" } - this.title = title - this.content = content - } - - fun softDelete() { - status = PostStatus.DELETED - } - - fun isEditableBy(userId: Long): Boolean = - author.id == userId -} -``` - -### Entity Patterns - -| Pattern | How | -|---------|-----| -| Creation | `companion object` factory (`create`, `of`) with `require` validation | -| State change | Named methods (`publish`, `softDelete`) — no public setters | -| State validation | `check` for preconditions | -| Business decision | `isEditableBy()`, `canPublish()` | - ---- - -## Domain Service Example - -Only create when logic spans multiple entities: - -```kotlin -@Service -class AttendancePolicy( - private val attendanceRepository: AttendanceRepository -) { - fun validateWeeklyLimit(user: User, date: LocalDate): Boolean { - val weeklyCount = attendanceRepository.countByUserAndWeek(user.id, date) - return weeklyCount < MAX_WEEKLY_ATTENDANCE - } - - companion object { - private const val MAX_WEEKLY_ATTENDANCE = 5 - } -} -``` - -### When to Create / Not Create - -| Create (multi-entity logic) | Do NOT Create (use alternatives) | -|-----------------------------|----------------------------------| -| `TransferService` (cross-account) | `findById` + exception → UseCase calls Repository | -| `AttendancePolicy` (policy check) | `save` delegation → UseCase calls Repository | -| `DuplicateCheckService` (uniqueness) | Single entity state change → Entity method | - ---- - -## Port-Adapter Example (FileStorage) - -### Port (`domain/port/`) - -```kotlin -interface FileStoragePort { - fun upload(file: MultipartFile): String - fun upload(files: List): List - fun delete(fileUrl: String) -} -``` - -### Adapter (`infrastructure/`) - -```kotlin -@Component -class S3FileStorage( - private val s3Client: S3Client, - @Value("\${cloud.aws.s3.bucket}") private val bucket: String -) : FileStoragePort { - - override fun upload(file: MultipartFile): String { - val key = generateKey(file.originalFilename) - s3Client.putObject( - PutObjectRequest.builder().bucket(bucket).key(key).build(), - RequestBody.fromInputStream(file.inputStream, file.size) - ) - return "$CDN_URL/$key" - } - - override fun upload(files: List): List = - files.map { upload(it) } - - override fun delete(fileUrl: String) { - val key = extractKey(fileUrl) - s3Client.deleteObject( - DeleteObjectRequest.builder().bucket(bucket).key(key).build() - ) - } -} -``` - -### Naming Convention - -| Port (domain/port/) | Adapter (infrastructure/) | -|------------------------------|---------------------------| -| `FileStoragePort` | `S3FileStorage` | -| `PushNotificationSenderPort` | `FcmPushNotificationSender` | -| `CacheStorePort` | `RedisCacheStore` | diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md deleted file mode 100644 index c12a6683..00000000 --- a/.agents/skills/code-review/SKILL.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -name: code-review -description: "Review PR/commit code changes. Detects bugs, security vulnerabilities, performance issues and provides concrete fix suggestions." ---- - -# Code Review - -Systematically review code changes, detect issues, and provide actionable fixes. -**All output MUST be written in Korean (한국어).** - -## Workflow (MUST follow in order) - -### 1. Analyze Changes -```bash -git diff HEAD~1 --name-only # or git diff --staged --name-only -git diff HEAD~1 # or git diff --staged -``` -- List changed files -- Assess scope and impact -- Check if related test files exist - -### 2. Review by Category (in order) -1. **Critical**: Bugs, security vulnerabilities, data loss risks -2. **Major**: Performance issues, architecture violations, missing tests -3. **Minor**: Code style, naming, duplicate code -4. **Suggestion**: Better implementations, Kotlin idioms - -### 3. Output Review Result -For each issue provide: -- File name and line number -- Problem description -- Severity (Critical/Major/Minor/Suggestion) -- Before/After code examples - -## Review Checklist - -### Bug/Logic -- Null safety (avoid "!!", use nullable types) -- Edge case handling -- Exception handling (must extend BaseException) -- Concurrency issues (race conditions) - -### Security -- SQL Injection (raw queries, string concatenation) -- Sensitive data exposure (logs, responses) -- Missing auth (@CurrentUser usage) -- Input validation (@Valid, @NotNull, @NotBlank) - -### Performance -- N+1 query (repository calls inside loops) -- Unnecessary DB calls -- Memory leaks (unclosed resources) - -### Architecture -- Layer adherence: Controller → UseCase → Repository (UseCase uses Repository directly) -- Rich Domain Model: business logic in Entity, not UseCase -- No thin wrapper services (GetService/SaveService) — Domain Service only for multi-entity logic -- @Transactional only on UseCase methods -- Port-Adapter: UseCase depends on Port interface, not infrastructure directly -- Cross-domain read via Reader interface, cross-domain write via Repository directly -- No layer skipping (Controller → Repository is forbidden) - -### Kotlin-specific -- val over var -- Nullable type overuse -- Scope function opportunities (let, apply, also) -- data class for DTOs -- when expression over if-else chains - -## Output Format - -Use the following Korean template: - -```markdown -# 코드 리뷰 결과 - -## 요약 -- Critical: N건 -- Major: N건 -- Minor: N건 -- Suggestion: N건 - -## Critical 이슈 -### [UserService.kt:42] 유저 조회 시 NPE 발생 가능 -**문제**: `findById` 반환값에 대한 null 처리가 누락되어 NPE가 발생할 수 있습니다. -**수정 전**: -```kotlin -val user = userRepository.findById(userId).get() -``` -**수정 후**: -```kotlin -val user = userRepository.findByIdOrNull(userId) - ?: throw UserNotFoundException() -``` - -## Major 이슈 -### [FeedUsecase.kt:28] N+1 쿼리 문제 -**문제**: 반복문 내에서 `commentRepository.findByFeedId()`를 호출하여 N+1 쿼리가 발생합니다. -**수정 전**: -```kotlin -val feeds = feedRepository.findAll() -feeds.map { feed -> - val comments = commentRepository.findByFeedId(feed.id) // N+1 - feed to comments -} -``` -**수정 후**: -```kotlin -val feeds = feedRepository.findAll() -val comments = commentRepository.findByFeedIdIn(feeds.map { it.id }) -val commentMap = comments.groupBy { it.feedId } -feeds.map { feed -> feed to (commentMap[feed.id] ?: emptyList()) } -``` - -## Minor 이슈 -### [UserController.kt:15] 불필요한 `var` 사용 -**문제**: 재할당이 없는 변수에 `var`를 사용하고 있습니다. `val`로 변경하세요. - -## Suggestion -### [UserMapper.kt:10] scope function 활용 -**제안**: `also` 블록을 사용하면 로깅과 변환을 깔끔하게 분리할 수 있습니다. - -## 좋은 점 -- UseCase에서 트랜잭션 경계를 잘 관리하고 있습니다. -- 커스텀 예외 패턴이 일관성 있게 적용되어 있습니다. - -## 전체 평가 -⚠️ 수정 필요 - Critical 1건, Major 1건 수정 후 재확인 부탁드립니다. -``` - -## Rules -- **All output in Korean (한국어)** -- Always provide concrete fix code, not just criticism -- Praise good code when found -- Mark uncertain issues as "확인 필요" -- If no issues found, state "리뷰 완료 - 이슈 없음" diff --git a/.agents/skills/context-update/SKILL.md b/.agents/skills/context-update/SKILL.md deleted file mode 100644 index 8498e1a5..00000000 --- a/.agents/skills/context-update/SKILL.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -name: context-update -description: Self-feedback skill that analyzes completed work and improves Codex context. Use when asked to "update context", "capture learnings", "improve context", or before compaction. Identifies reusable patterns and delegates to appropriate create skills. ---- - -# Context Update - -Meta-skill for continuous improvement through self-reflection. - -## Purpose - -After completing tasks, analyze work and: -1. Identify reusable patterns -2. Find gaps in existing context -3. Delegate to appropriate create skills -4. Generate improvement report - -## Workflow - -### Step 1: Analyze Session - -Review conversation for: - -``` -[ ] Tasks completed -[ ] Problems solved -[ ] Patterns repeated (3+ times = skill candidate) -[ ] External knowledge needed (gap in rules) -[ ] Friction points or mistakes -[ ] Frequently used commands -``` - -### Step 2: Categorize Findings - -| Signal | Category | Action | -|--------|----------|--------| -| Reusable 3+ step pattern | Skill | Invoke `skill-create` | -| Convention discovered | Rule | Invoke `rule-create` | -| One-off task | None | Document only | - -### Step 3: Check for Duplicates - -Before delegating, search existing context: - -```bash -Glob: .agents/skills/*/SKILL.md -Glob: .agents/rules/*.md -Grep: pattern="{keyword}" path=".agents/" -``` - -### Step 4: Delegate Creation - -For each identified improvement, invoke the appropriate skill: - -- **New skill needed** → Invoke `skill-create` -- **Rule update needed** → Invoke `rule-create` -- **Neither applies** → Update MEMORY.md or document in report only - -### Step 5: Generate Report - -```markdown -## Context Update Report - -### Session Summary -- Tasks: {list of completed tasks} -- Patterns identified: {count} - -### Actions Taken - -| Type | Name | Action | Reason | -|------|------|--------|--------| -| Skill | {name} | Created | {why} | -| Rule | {file} | Updated | {why} | - -### Skipped (No Action) - -| Pattern | Reason | -|---------|--------| -| {pattern} | One-off / Too specific / Already exists | - -### Manual Follow-ups -- {Any suggestions requiring user decision} -``` - -## Decision Criteria - -### Create When: -- Pattern used 3+ times -- Would save significant time if reused -- Not too project-specific -- Clear trigger phrases exist - -### Skip When: -- One-off task -- Too project-specific -- Already documented -- Requires user decision (suggest instead) - -## Conflict Resolution Priority - -When a newly discovered pattern conflicts with existing guidance, apply this order: - -1. Follow higher-priority runtime instructions (system/developer/user for the current session). -2. Prefer existing project rules in `.agents/rules/` over ad-hoc new patterns. -3. If no rule exists, follow established skill workflows in `.agents/skills/*/SKILL.md`. -4. Treat the new pattern as a candidate update, not an immediate override. -5. If conflict remains ambiguous, do not auto-apply; add it to **Manual Follow-ups** for user decision. - -Implementation guidance: -- For rule conflicts, invoke `rule-create` to update/clarify the rule with rationale. -- For skill workflow conflicts, invoke `skill-create` only if the change is broadly reusable. -- Always document why the existing guidance was kept or updated in the report. - -## Example Session Analysis - -**Observed**: Created API endpoint 4 times with same structure. - -**Analysis**: -- Repeated pattern? ✓ (4 times) -- Multi-step? ✓ (Controller, Service, DTO, tests) -- Reusable? ✓ (applies to any endpoint) - -**Action**: Check if `api-create` skill exists → Already exists, no action. - ---- - -**Observed**: Had to look up soft-delete query pattern twice. - -**Analysis**: -- Caused friction? ✓ -- Convention exists? Partially in entity-repository.md - -**Action**: Invoke `rule-create` to update relevant rule with explicit example. - ---- - -**Observed**: Wrote one-time data migration script. - -**Analysis**: -- Repeated? ✗ (one-off) - -**Action**: None - too specific. diff --git a/.agents/skills/database-manage/SKILL.md b/.agents/skills/database-manage/SKILL.md deleted file mode 100644 index 3558ec12..00000000 --- a/.agents/skills/database-manage/SKILL.md +++ /dev/null @@ -1,222 +0,0 @@ ---- -name: database-manage -description: DB schema inspection and management. Use when asked to "show schema", "show tables", "check DB", "DB context", "database info", "스키마 덤프", "엔티티 구조", "테이블 확인", "스키마 확인". ---- - -# Database Manage - -Inspect DB schema, analyze entity structures, and dump live schema from MySQL. -**All output MUST be written in Korean (한국어).** - -## Schema Information Sources (Priority Order) - -1. **Schema snapshot** - Read `references/schema.md` if it exists (fastest) -2. **Schema dump script** - Dump live schema from DB (`scripts/dump-schema.sh`) -3. **Code-based analysis** - Scan entity/repository files (no DB connection required) - -## Instructions - -### Step 1: Check Schema Snapshot - -Check if an existing snapshot is available. - -``` -Read: .agents/skills/database-manage/references/schema.md -``` - -- If file exists → respond based on this file -- If file is missing or outdated → proceed to Step 2 or Step 3 - -### Step 2: Dump Live Schema from DB - -If local MySQL is running, use the script to fetch the latest schema. - -```bash -# Pass connection info via environment variables -DB_HOST=localhost DB_PORT=3306 DB_USER=root DB_PASSWORD= DB_NAME=weeth \ - .agents/skills/database-manage/scripts/dump-schema.sh - -# Or pass via arguments -.agents/skills/database-manage/scripts/dump-schema.sh -h localhost -P 3306 -u root -p -d weeth -``` - -**IMPORTANT**: Never guess passwords. Always ask the user or read from environment variables. - -Script output: -- Saves full schema to `references/schema.md` -- Includes table list, columns, indexes, FK relationships - -### Step 3: Code-Based Analysis (When DB Is Unavailable) - -When DB connection is not possible, analyze from code only. - -#### 3-1. Check Project DB Configuration -- Use `Glob` to search `**/application*.{yml,yaml,properties}` -- Check datasource URL, driver, dialect -- Check ddl-auto setting, migration tool configuration - -#### 3-2. Analyze Entity Structure -- Use `Grep` to find files with `@Entity` annotation -- Analyze fields, relationships (@OneToMany, @ManyToOne, etc.), indexes per entity -- Check BaseEntity inheritance structure -- Check `@Table(name = "...")` mappings - -#### 3-3. Analyze Repositories -- Use `Grep` to search for `JpaRepository`, `@Query`, `@Lock` -- Check custom query methods - -#### 3-4. Check Migration Files -- Use `Glob` to search `**/db/migration/**/*.sql` - -## Script Details - -### dump-schema.sh - -Queries MySQL `INFORMATION_SCHEMA` and saves results to `references/schema.md`. - -**Output includes:** -- Table list (engine, row count, comments) -- Column details per table (type, nullable, key, default, extra) -- Index info (columns, uniqueness, type) -- FK relationships (referenced table/column) -- Relationship diagram (text-based) - -**Connection info methods:** -1. Environment variables: `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` -2. Arguments: `-h host -P port -u user -p password -d database` -3. Defaults: `localhost:3306`, user=`root`, db=`weeth` - -## Analysis Commands by Topic - -### List All Entities - -``` -Grep: pattern="@Entity" → get file list -Read each file → extract fields/relationships -``` - -### Inspect Specific Table - -``` -Grep: pattern="class {EntityName}" → find entity file -Read → check all fields, relationships, indexes, constraints -Grep: pattern="{EntityName}" in Repository files → find related queries -``` - -### Relationship Map - -``` -Grep: pattern="@(OneToMany|ManyToOne|OneToOne|ManyToMany)" → map all relationships -``` - -### Check Indexes - -``` -Grep: pattern="@Index|@Table.*indexes" → find index definitions -``` - -## Output Format - -### Full Schema Summary - -```markdown -# DB 스키마 요약 - -## DB 설정 -- **DB 종류**: MySQL 8.0 -- **DDL 전략**: validate -- **마이그레이션**: Flyway / 없음 - -## 엔티티 목록 - -| 엔티티 | 테이블명 | 주요 필드 | 관계 | -|--------|----------|-----------|------| -| User | users | id, name, email, role | Profile(1:1), Post(1:N) | -| Post | posts | id, title, content, userId | User(N:1), Comment(1:N) | - -## 관계 다이어그램 (텍스트) - -User ──1:1──> Profile -User ──1:N──> Post -Post ──1:N──> Comment -Comment ──N:1──> User - -## 인덱스 - -| 테이블 | 인덱스명 | 컬럼 | 유니크 | -|--------|----------|------|--------| -| users | idx_user_email | email | Yes | -``` - -### Specific Entity Detail - -```markdown -# User 엔티티 상세 - -## 기본 정보 -- **클래스**: `com.example.domain.user.entity.User` -- **테이블**: `users` -- **상속**: `BaseEntity` (createdAt, updatedAt) - -## 필드 - -| 필드 | 컬럼 | 타입 | 제약조건 | -|------|------|------|----------| -| id | id | Long | PK, AUTO_INCREMENT | -| name | name | String | NOT NULL, max=100 | -| email | email | String | NOT NULL, UNIQUE | - -## 관계 - -| 타입 | 대상 | 매핑 | Fetch | -|------|------|------|-------| -| @OneToMany | Post | mappedBy="user" | LAZY | - -## Repository 쿼리 - -| 메서드 | 설명 | -|--------|------| -| findByEmail(email) | 이메일로 조회 | -``` - -## Examples - -### Example: Dump Schema -User says: "DB 스키마 덤프해줘" / "스키마 업데이트해줘" -Actions: -1. Ask user for DB connection info -2. Run `scripts/dump-schema.sh` -3. Verify `references/schema.md` was created -Result: Latest schema saved to references/schema.md - -### Example: View Full Schema -User says: "DB 스키마 보여줘" / "테이블 구조 확인해줘" -Actions: -1. Check if references/schema.md exists → if yes, output directly -2. If not → scan entities from code -3. Output schema summary -Result: Full table list, relationships, index info displayed - -### Example: Inspect Specific Entity -User says: "User 엔티티 구조 알려줘" -Actions: -1. Search and read the entity file -2. Check related Repository queries -3. Output detailed info -Result: Entity fields, relationships, indexes, Repository queries displayed - -### Example: Analyze Relationships -User says: "엔티티 관계 보여줘" / "ERD 그려줘" -Actions: -1. Check Relationships section in references/schema.md -2. If not available, search @OneToMany etc. annotations in code -3. Output text ERD -Result: Full entity relationship diagram displayed - -## Rules -- **All output in Korean (한국어)** -- Analyze based on actual code/DB only (never guess) -- Notify user if no entity files are found -- **Never guess passwords** - always ask the user directly -- Mask sensitive info (passwords, connection strings) in output -- Recommend adding references/schema.md to .gitignore (may contain connection info) diff --git a/.agents/skills/database-manage/scripts/dump-schema.sh b/.agents/skills/database-manage/scripts/dump-schema.sh deleted file mode 100755 index ce8c9776..00000000 --- a/.agents/skills/database-manage/scripts/dump-schema.sh +++ /dev/null @@ -1,225 +0,0 @@ -#!/bin/bash -# -# DB 스키마를 덤프하여 references/schema.md에 저장하는 스크립트 -# -# 사용법: -# ./dump-schema.sh # 환경변수에서 읽기 -# ./dump-schema.sh -h localhost -P 3306 -u root -p password -d weeth -# -# 환경변수: -# DB_HOST (default: localhost) -# DB_PORT (default: 3306) -# DB_USER (default: root) -# DB_PASSWORD -# DB_NAME (default: weeth) - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SKILL_DIR="$(dirname "$SCRIPT_DIR")" -OUTPUT_FILE="${SKILL_DIR}/references/schema.md" -mkdir -p "$(dirname "$OUTPUT_FILE")" - -# 기본값 (환경변수 또는 기본값) -HOST="${DB_HOST:-localhost}" -PORT="${DB_PORT:-3306}" -USER="${DB_USER:-root}" -PASSWORD="${DB_PASSWORD:-}" -DATABASE="${DB_NAME:-weeth}" - -# 인자 파싱 -while getopts "h:P:u:p:d:" opt; do - case $opt in - h) HOST="$OPTARG" ;; - P) PORT="$OPTARG" ;; - u) USER="$OPTARG" ;; - p) PASSWORD="$OPTARG" ;; - d) DATABASE="$OPTARG" ;; - *) echo "Usage: $0 [-h host] [-P port] [-u user] [-p password] [-d database]"; exit 1 ;; - esac -done - -# mysql 클라이언트 확인 -if ! command -v mysql &> /dev/null; then - echo "Error: mysql client not found. Install with: brew install mysql-client" - exit 1 -fi - -MYSQL_OPTS="-h ${HOST} -P ${PORT} -u ${USER}" -if [ -n "$PASSWORD" ]; then - MYSQL_OPTS="${MYSQL_OPTS} -p${PASSWORD}" -fi - -echo "Connecting to MySQL ${HOST}:${PORT}/${DATABASE}..." - -# 테이블 목록 조회 -TABLES=$(mysql ${MYSQL_OPTS} -N -e " - SELECT TABLE_NAME - FROM INFORMATION_SCHEMA.TABLES - WHERE TABLE_SCHEMA = '${DATABASE}' - AND TABLE_TYPE = 'BASE TABLE' - ORDER BY TABLE_NAME; -") - -if [ -z "$TABLES" ]; then - echo "Error: No tables found in database '${DATABASE}'" - exit 1 -fi - -TABLE_COUNT=$(echo "$TABLES" | wc -l | tr -d ' ') -echo "Found ${TABLE_COUNT} tables. Dumping schema..." - -# 마크다운 출력 시작 -{ - echo "# ${DATABASE} DB Schema" - echo "" - echo "> Auto-generated by dump-schema.sh at $(date '+%Y-%m-%d %H:%M:%S')" - echo "> Connection: ${HOST}:${PORT}/${DATABASE}" - echo "" - - # 테이블 요약 - echo "## Tables (${TABLE_COUNT})" - echo "" - echo "| # | Table | Engine | Rows (approx) | Comment |" - echo "|---|-------|--------|---------------|---------|" - - mysql ${MYSQL_OPTS} -N -e " - SELECT - TABLE_NAME, - ENGINE, - TABLE_ROWS, - IFNULL(TABLE_COMMENT, '') - FROM INFORMATION_SCHEMA.TABLES - WHERE TABLE_SCHEMA = '${DATABASE}' - AND TABLE_TYPE = 'BASE TABLE' - ORDER BY TABLE_NAME; - " | awk -F'\t' '{printf "| %d | %s | %s | %s | %s |\n", NR, $1, $2, $3, $4}' - - echo "" - - # 각 테이블 상세 - echo "## Table Details" - echo "" - - for TABLE in $TABLES; do - echo "### ${TABLE}" - echo "" - - # 컬럼 정보 - echo "| Column | Type | Nullable | Key | Default | Extra |" - echo "|--------|------|----------|-----|---------|-------|" - - mysql ${MYSQL_OPTS} -N -e " - SELECT - COLUMN_NAME, - COLUMN_TYPE, - IS_NULLABLE, - COLUMN_KEY, - IFNULL(COLUMN_DEFAULT, 'NULL'), - EXTRA - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = '${DATABASE}' - AND TABLE_NAME = '${TABLE}' - ORDER BY ORDINAL_POSITION; - " | awk -F'\t' '{printf "| %s | %s | %s | %s | %s | %s |\n", $1, $2, $3, $4, $5, $6}' - - echo "" - - # 인덱스 정보 - INDEX_COUNT=$(mysql ${MYSQL_OPTS} -N -e " - SELECT COUNT(DISTINCT INDEX_NAME) - FROM INFORMATION_SCHEMA.STATISTICS - WHERE TABLE_SCHEMA = '${DATABASE}' - AND TABLE_NAME = '${TABLE}'; - ") - - if [ "$INDEX_COUNT" -gt 0 ]; then - echo "**Indexes:**" - echo "" - echo "| Index | Columns | Unique | Type |" - echo "|-------|---------|--------|------|" - - mysql ${MYSQL_OPTS} -N -e " - SELECT - INDEX_NAME, - GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ', '), - CASE WHEN NON_UNIQUE = 0 THEN 'YES' ELSE 'NO' END, - INDEX_TYPE - FROM INFORMATION_SCHEMA.STATISTICS - WHERE TABLE_SCHEMA = '${DATABASE}' - AND TABLE_NAME = '${TABLE}' - GROUP BY INDEX_NAME, NON_UNIQUE, INDEX_TYPE - ORDER BY INDEX_NAME; - " | awk -F'\t' '{printf "| %s | %s | %s | %s |\n", $1, $2, $3, $4}' - - echo "" - fi - - # FK 정보 - FK_COUNT=$(mysql ${MYSQL_OPTS} -N -e " - SELECT COUNT(*) - FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE - WHERE TABLE_SCHEMA = '${DATABASE}' - AND TABLE_NAME = '${TABLE}' - AND REFERENCED_TABLE_NAME IS NOT NULL; - ") - - if [ "$FK_COUNT" -gt 0 ]; then - echo "**Foreign Keys:**" - echo "" - echo "| Constraint | Column | References |" - echo "|-----------|--------|------------|" - - mysql ${MYSQL_OPTS} -N -e " - SELECT - CONSTRAINT_NAME, - COLUMN_NAME, - CONCAT(REFERENCED_TABLE_NAME, '.', REFERENCED_COLUMN_NAME) - FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE - WHERE TABLE_SCHEMA = '${DATABASE}' - AND TABLE_NAME = '${TABLE}' - AND REFERENCED_TABLE_NAME IS NOT NULL - ORDER BY CONSTRAINT_NAME; - " | awk -F'\t' '{printf "| %s | %s | %s |\n", $1, $2, $3}' - - echo "" - fi - - echo "---" - echo "" - done - - # 관계 다이어그램 (FK 기반) - FK_TOTAL=$(mysql ${MYSQL_OPTS} -N -e " - SELECT COUNT(*) - FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE - WHERE TABLE_SCHEMA = '${DATABASE}' - AND REFERENCED_TABLE_NAME IS NOT NULL; - ") - - if [ "$FK_TOTAL" -gt 0 ]; then - echo "## Relationships" - echo "" - echo "\`\`\`" - - mysql ${MYSQL_OPTS} -N -e " - SELECT - TABLE_NAME, - COLUMN_NAME, - REFERENCED_TABLE_NAME, - REFERENCED_COLUMN_NAME - FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE - WHERE TABLE_SCHEMA = '${DATABASE}' - AND REFERENCED_TABLE_NAME IS NOT NULL - ORDER BY TABLE_NAME, COLUMN_NAME; - " | awk -F'\t' '{printf "%s.%s --> %s.%s\n", $1, $2, $3, $4}' - - echo "\`\`\`" - fi - -} > "$OUTPUT_FILE" - -echo "" -echo "Schema dumped to: ${OUTPUT_FILE}" -echo "Tables: ${TABLE_COUNT}" -echo "Done." diff --git a/.agents/skills/kotlin-migration/SKILL.md b/.agents/skills/kotlin-migration/SKILL.md deleted file mode 100644 index b89790e8..00000000 --- a/.agents/skills/kotlin-migration/SKILL.md +++ /dev/null @@ -1,168 +0,0 @@ ---- -name: kotlin-migration -description: "Legacy Java → Kotlin migration skill. Use only when explicitly asked to migrate remaining Java files or external copied Java code. Follows Test-First methodology: write tests → migrate → refactor → verify with ktlint." ---- - -# Kotlin Migration - -Migrate Java to idiomatic Kotlin with Test-First methodology. -**All output MUST be written in Korean (한국어).** - -This repository's production migration is complete. Do not introduce new Java production code; use this skill only for explicit legacy cleanup. - -## Batch Migration Strategy - -For large-scale migrations, split work into manageable batches and get user confirmation between batches. - -### Recommended Batch Units -| Scope | Batch Size | Example | -|-------|-----------|---------| -| Single Domain | 3-5 files per batch | `Feed`, `FeedRepository`, `CreateFeedUseCase` | -| Cross-Domain | 1 domain at a time | Complete `feed` domain before `user` domain | -| Entity + Dependencies | Entity → Repository → Services | Migrate in dependency order | - -### Batch Workflow -1. **Analyze scope** - List all files to migrate -2. **Propose batch plan** - Split into logical batches, present to user -3. **Execute batch** - Migrate files in current batch -4. **Verify & Report** - Run tests, report results to user -5. **Get confirmation** - Wait for user approval before next batch -6. **Repeat** - Continue with next batch - -### Example Batch Plan -``` -Batch 1: Feed Entity Layer - - Feed.java → Feed.kt - - FeedRepository.java → FeedRepository.kt - -Batch 2: Feed Application Layer - - CreateFeedUseCase.java → CreateFeedUseCase.kt - - GetFeedQueryService.java → GetFeedQueryService.kt - - FeedMapper.java → FeedMapper.kt -``` - -**Always ask user before proceeding to next batch.** - ---- - -## Workflow (MUST follow in order) - -### 1. Pre-Migration Test -- Analyze Java code behavior and dependencies -- **Write ONLY essential tests** that verify critical business logic -- Use Kotest + MockK -- Run tests against Java code to confirm they pass - -**Tests to Write (HIGH value):** -- Business logic with conditions/branching -- Exception scenarios -- Complex calculations or transformations -- Transaction boundaries and side effects - -**Tests to SKIP (LOW value):** -- JPA basic CRUD (findById, save, delete, findAll) -- Simple getter/setter or DTO field mapping -- Obvious pass-through methods -- Framework-provided functionality - -### 2. Migration - -#### File Move and Conversion -**Use `git mv` instead of delete + create to preserve history.** - -```bash -git mv src/main/java/domain/{domain}/{path}/{File}.java \ - src/main/kotlin/domain/{domain}/{path}/{File}.kt -``` - -Then convert content from Java → Kotlin syntax using Edit tool. - -```bash -./gradlew test # Run pre-written tests -``` - -#### Migration Guide -- Convert preserving existing architecture patterns -- Apply Kotlin idioms: data class for DTOs, val over var, nullable only when needed -- Maintain Single Responsibility Principle -- Run tests after migration - -### 3. Refactor -- Replace Java patterns with Kotlin idioms (scope functions, safe calls, when expressions) -- Run tests after each refactoring - -### 4. Verify -```bash -./gradlew ktlintFormat && ./gradlew ktlintCheck && ./gradlew test -``` - -## Project Patterns - -### Test Style (Kotest) -**DescribeSpec** for business logic tests: -```kotlin -class CreatePostUseCaseTest : DescribeSpec({ - val postRepository = mockk() - val userReader = mockk() - val postMapper = mockk() - val useCase = CreatePostUseCase(postRepository, userReader, postMapper) - - describe("execute") { - context("with valid request") { - it("should create and save post") { ... } - } - context("when user not found") { - it("should throw UserNotFoundException") { ... } - } - } -}) -``` - -### Fixture Pattern -```kotlin -object UserTestFixture { - fun createUser( - id: Long = 1L, - email: String = "test@example.com" - ) = User(id = id, email = email, status = UserStatus.ACTIVE) -} -``` -Location: `src/test/kotlin/{domain}/test/fixture/` - -## Output Format - -Use the following Korean template for reporting: - -```markdown -# 마이그레이션 리포트 - -## 대상 파일 -| 파일 | 상태 | 비고 | -|------|------|------| -| `Feed.java` → `.kt` | ✅ 완료 | Rich Domain Model 적용 | -| `FeedRepository.java` → `.kt` | ✅ 완료 | 테스트 불필요 | -| `CreateFeedUseCase.java` → `.kt` | ✅ 완료 | 테스트 3건 통과 | - -## 작성된 테스트 -- `CreateFeedUseCaseTest.kt`: 3건 (정상 생성, 사용자 미존재, 검증 실패) - -## 주요 변환 사항 -- `Optional.orElseThrow()` → `?: throw` 패턴 적용 -- MapStruct → 수동 Mapper 패턴으로 전환 -- Lombok 제거, Kotlin 생성자 주입 적용 - -## 검증 결과 -- ktlintCheck: ✅ 통과 -- 전체 테스트: ✅ 통과 (N건) - -## 다음 배치 -Batch 3: Feed Presentation Layer (FeedController) 진행할까요? -``` - -## Rules -- **All output in Korean (한국어)** -- Never skip tests -- Never migrate without passing tests first -- Fix Kotlin code if tests fail (not tests) -- Always use `git mv` for file moves -- Ask user before proceeding to next batch diff --git a/.agents/skills/rule-create/SKILL.md b/.agents/skills/rule-create/SKILL.md deleted file mode 100644 index d0507219..00000000 --- a/.agents/skills/rule-create/SKILL.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -name: rule-create -description: Create or update Codex rules and conventions. Use when asked to "create rule", "add convention", "document pattern", or when a coding standard needs to be captured. ---- - -# Rule Create - -Create modular project convention documents for coding standards and patterns. - -Do not confuse these documents with Codex command permission rules. Codex command permission rules use `.rules` files under `.codex/rules/`; this skill writes human-readable project guidance under `.agents/rules/`. - -## When to Create a Rule - -- Convention discovered that should be consistent -- Pattern clarified during debugging -- Team standard needs documentation -- Gap found in existing rules - -## File Location - -``` -.agents/rules/{topic}.md # Project rules -~/.agents/rules/{topic}.md # Personal convention docs, only when explicitly requested -``` - -## Rule Structure - -```markdown ---- -paths: - - "src/**/*.{ts,tsx}" - - "lib/**/*.ts" ---- - -# {Topic} Rules - -## {Category} - -### Pattern -{The convention or pattern} - -### Rationale -{Why this matters} - -### Example -```{lang} -// Good -{correct example} - -// Bad -{incorrect example} -``` -``` - -## Path Scoping - -Use frontmatter to scope rules to specific files: - -| Pattern | Matches | -|---------|---------| -| `**/*.ts` | All TypeScript files | -| `src/api/**/*` | All files under src/api | -| `*.md` | Markdown in root only | -| `{src,lib}/**/*.ts` | TS in both directories | - -## Example - -Creating an `error-handling.md` rule: - -```markdown ---- -paths: - - "src/**/*.ts" ---- - -# Error Handling Rules - -## Custom Exceptions - -### Pattern -All domain exceptions extend `BaseException` with error code. - -### Rationale -Consistent error responses and logging. - -### Example -```kotlin -// Good -class UserNotFoundException( - override val errorCode: ErrorCode = ErrorCode.USER_NOT_FOUND -) : BaseException() - -// Bad -class UserNotFoundException : RuntimeException("User not found") -``` - -## API Error Response - -### Pattern -Always return structured error format. - -### Example -```json -{ - "success": false, - "error": { - "code": "USER_NOT_FOUND", - "message": "User with id 123 not found" - } -} -``` - - -## Update vs Create - -**Create new file when**: -- Topic not covered by existing rules -- Would make existing file too long - -**Update existing file when**: -- Adding to existing topic -- Clarifying existing pattern - -## Checklist - -Before creating: -- [ ] Is this a repeatable convention? -- [ ] Will it help consistency? -- [ ] Similar rule exists? Check `.agents/rules/` -- [ ] Appropriate scope (project vs personal)? - -## Reference: - -Use `AGENTS.md` for always-on repository instructions. Use `.agents/rules/*.md` for longer project convention documents referenced by `AGENTS.md` or project skills. diff --git a/.agents/skills/skill-create/SKILL.md b/.agents/skills/skill-create/SKILL.md deleted file mode 100644 index b5169fa0..00000000 --- a/.agents/skills/skill-create/SKILL.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -name: skill-create -description: Create new Codex skills. Use when asked to "create skill", "new skill", "add skill", or when a reusable workflow pattern (3+ steps) is identified. ---- - -# Skill Create - -Create reusable Codex skills with progressive disclosure structure. - -## When to Create a Skill - -- Workflow repeats 3+ times -- Has clear trigger phrases -- Benefits from bundled scripts/references -- Not too project-specific - -## Directory Structure - -``` -.agents/skills/{skill-name}/ -├── SKILL.md # Required: main instructions -├── scripts/ # Optional: executable code -│ └── {script}.py -└── references/ # Optional: detailed docs - └── {topic}.md -``` - -Naming: kebab-case folder, `SKILL.md` exactly (case-sensitive) - -## SKILL.md Structure - -```markdown ---- -name: {skill-name} -description: {What it does}. Use when {trigger phrases}. Do NOT use for {negative triggers}. ---- - -# {Skill Name} - -## Instructions - -### Step 1: {Action} -{Clear instruction with expected outcome} - -### Step 2: {Action} -{Continue...} - -## Examples - -### Example: {Scenario} -User says: "{trigger phrase}" -Actions: -1. {step} -2. {step} -Result: {outcome} - -## Troubleshooting - -### Error: {Common error} -**Cause**: {Why} -**Solution**: {Fix} -``` - -## Key Fields - -| Field | Purpose | -|-------|---------| -| `name` | Skill identifier; use kebab-case | -| `description` | Triggers auto-invoke; include user phrases | - -Keep frontmatter minimal. Codex uses `name` and `description` to decide when the skill applies. - -## Example - -Creating a "db-migration" skill: - -```markdown ---- -name: db-migration -description: Create database migrations. Use when asked to "create migration", "add column", "change schema". ---- - -# DB Migration - -## Instructions - -### Step 1: Generate Migration File -```bash -./gradlew generateMigration -Pname="$ARGUMENTS" -``` - -### Step 2: Edit Migration -Add SQL for the schema change. - -### Step 3: Validate -```bash -./gradlew validateMigration -``` - -## Examples - -### Example: Add Column -``` -User: `/db-migration add-user-email` -Result: Creates `V{timestamp}__add_user_email.sql` -``` - -## Checklist - -Before creating: -- [ ] Is this reusable? (Not one-off) -- [ ] Has clear triggers? -- [ ] 3+ steps or needs scripts? -- [ ] Similar skill exists? Check `.agents/skills/` - -## Reference - -Use the Codex repo-scoped skill format: `.agents/skills/{skill-name}/SKILL.md`. -Keep `SKILL.md` concise and move only task-specific details into `references/`, `scripts/`, or `assets/` when they are genuinely needed. diff --git a/.agents/skills/systematic-debugging/SKILL.md b/.agents/skills/systematic-debugging/SKILL.md deleted file mode 100644 index bac90d66..00000000 --- a/.agents/skills/systematic-debugging/SKILL.md +++ /dev/null @@ -1,151 +0,0 @@ ---- -name: systematic-debugging -description: Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes ---- - -# Debugging - -Systematically debug issues using hypothesis-driven approach. -**All output MUST be written in Korean (한국어).** - -## Workflow (MUST follow in order) - -### 1. Collect Symptoms -- Full error message and stack trace -- Reproduction conditions (input, environment, timing) -- When it started (correlation with recent changes) -- Always vs intermittent occurrence - -### 2. Form Hypotheses -List 3-5 possible causes with likelihood. -Track hypotheses in the working plan or a concise checklist. - -### 3. Verify Hypotheses -In order of likelihood: -- Search and analyze related code -- Check logs/data -- Attempt reproduction with test code -- Record verification results for each - -### 4. Confirm Root Cause -- Define root cause clearly -- Pinpoint exact code location -- Explain WHY this bug occurred - -### 5. Fix and Verify -- Provide fix code -- Write/run test code -- Check for side effects - -### 6. Prevent Recurrence -- Search for same pattern elsewhere -- Suggest preventive improvements - -## Debug Checklist - -### Common Bug Patterns -- NullPointerException: missing null check, unhandled Optional -- IndexOutOfBounds: empty collection, off-by-one error -- IllegalArgumentException: missing input validation -- IllegalStateException: object state mismatch -- ConcurrentModificationException: modification during iteration - -### Spring/Kotlin Specific -- Bean injection failure: circular reference, conditional bean, profile -- Transaction issues: propagation, readOnly, rollback conditions -- LazyInitializationException: lazy load after session close -- Jackson serialization: circular reference, missing default constructor - -### Intermittent Bugs -- Race condition: concurrency, missing locks -- Memory issues: cache expiry, GC timing -- External dependencies: API timeout, network instability -- Data-dependent: occurs only with specific data - -### Environment Related -- Local vs server diff: config, env vars, resources -- Version mismatch: library, JDK, DB schema - -## Useful Commands - -```bash -# Recent changes -git log --oneline -20 -git diff HEAD~5 -- src/ - -# File change history -git log -p --follow -- [filepath] - -# Line author -git blame [filepath] - -# Run specific test -./gradlew test --tests "*ServiceTest" -``` - -## Output Format - -Use the following Korean template: - -```markdown -# 디버깅 리포트 - -## 1. 증상 요약 -- 에러: `UserNotFoundException` - "User not found" -- 발생 위치: `UserGetService.kt:23` -- 재현 조건: 삭제된 유저 ID로 조회 시 항상 발생 - -## 2. 가설 및 검증 -| 가설 | 가능성 | 검증 결과 | -|------|--------|-----------| -| soft delete된 유저를 필터링하지 않음 | 높음 | ✅ 확인됨 | -| 잘못된 유저 ID 전달 | 중간 | ❌ 배제 - 로그 확인 결과 정상 ID | -| 캐시에서 만료된 데이터 조회 | 낮음 | ❌ 배제 - 캐시 미사용 | - -## 3. 근본 원인 -**원인**: `findById` 쿼리가 `deletedAt IS NULL` 조건을 포함하지 않아 soft delete된 유저도 조회 대상에 포함됩니다. -**위치**: `UserRepository.kt:12` - `findById` 메서드 -**발생 이유**: 기본 JPA `findById`는 soft delete 필터를 적용하지 않습니다. - -## 4. 수정 방안 -**수정 전**: -```kotlin -fun getUser(userId: Long): User = - userRepository.findById(userId) - .orElseThrow { UserNotFoundException() } -``` - -**수정 후**: -```kotlin -fun getUser(userId: Long): User = - userRepository.findByIdAndDeletedAtIsNull(userId) - ?: throw UserNotFoundException() -``` - -**수정 이유**: soft delete 패턴에 맞게 `deletedAt IS NULL` 조건을 추가하여 삭제된 유저를 제외합니다. - -## 5. 테스트 -```kotlin -"soft delete된 유저 조회 시 UserNotFoundException 발생" { - val user = UserTestFixture.createUser() - userRepository.save(user) - userRepository.delete(user) // soft delete - - shouldThrow { - userGetService.getUser(user.id) - } -} -``` - -## 6. 재발 방지 -- [x] 다른 Repository에서도 `findById` 직접 사용 여부 검사 → `FeedRepository`에서 동일 패턴 발견, 수정 완료 -- [ ] `@Where(clause = "deleted_at IS NULL")` 엔티티 레벨 적용 검토 -``` - -## Rules -- **All output in Korean (한국어)** -- Don't guess - verify with code/logs -- Form hypotheses and verify systematically -- Reproduce bug with test BEFORE fixing -- Verify test passes AFTER fixing -- Never give up until root cause is found diff --git a/.agents/skills/test-create/SKILL.md b/.agents/skills/test-create/SKILL.md deleted file mode 100644 index 0cafda99..00000000 --- a/.agents/skills/test-create/SKILL.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -name: test-create -description: Generate unit and integration tests for Kotlin Spring Boot applications using Kotest, MockK, springmockk, and Testcontainers. Use when the user asks to "write tests", "create test", "generate test", "add test coverage", or mentions testing specific classes/methods. Supports UseCase tests, controller tests, entity tests, and test fixtures. ---- - -# Test Generator - -Generate focused Kotlin tests for the requested target. - -## Workflow - -### Step 1: Analyze Target Code - -1. Read the source file to understand: - - Class type (Controller, Service/UseCase, Repository, Entity) - - Dependencies (injected fields) - - Public methods to test -2. Read `.agents/rules/testing.md` before writing tests. - -### Step 2: Determine Test Location - -``` -src/test/ -└── kotlin/com/weeth/domain/{domain}/ - ├── application/usecase/ - │ ├── command/ - │ └── query/ - ├── domain/entity/ - ├── domain/service/ - ├── presentation/ - └── fixture/ -``` - -Test file naming: `{ClassName}Test.kt` - -### Step 3: Choose Test Style - -| Class Type | Test Style | Framework | -|------------|------------|-----------| -| Command UseCase | DescribeSpec | Kotest + MockK | -| QueryService | DescribeSpec | Kotest + MockK | -| Entity / Domain Service | DescribeSpec or BehaviorSpec | Kotest | -| Validation / Simple Value Object | StringSpec | Kotest | -| Controller | @WebMvcTest + DescribeSpec | MockMvc + @MockkBean | - -**Decision Guide:** -- **DescribeSpec**: Default choice for service tests. Clean describe/context/it structure. -- **BehaviorSpec**: Use for complex business logic requiring Given/When/Then BDD style. -- **StringSpec**: Use for simple validation or property tests. - -### Step 4: Identify Test Cases - -For each public method, create tests for: -- **Success case**: Valid input, expected output -- **Failure case**: Invalid input, expected exception -- **Edge cases**: Empty list, null values, boundary conditions -- **Soft delete**: Verify `deletedAt IS NULL` filtering if applicable - -### Step 5: Generate Test Code - -1. Create fixture if needed (in `fixture/` directory) -2. Write test class with proper annotations -3. Mock all dependencies -4. Implement test cases following given/when/then pattern -5. Add verification for mock interactions - -See detailed examples: -- Kotlin: [references/kotlin-examples.md](references/kotlin-examples.md) - -### Step 6: Run Tests - -```bash -# Run all tests -./gradlew test - -# Run specific test class -./gradlew test --tests "CreateUserUseCaseTest" - -# Run tests matching pattern -./gradlew test --tests "*UseCaseTest" - -# Run with verbose output -./gradlew test --info -``` - -## Fixture Pattern - -Create reusable test data builders in `fixture/` directory: - -**Kotlin:** -```kotlin -object UserTestFixture { - fun createUser( - id: Long = 1L, - email: String = "test@example.com", - name: String = "Test User" - ) = User(id = id, name = name, email = email) -} -``` - -## Controller Tests - -Use @WebMvcTest for controller layer tests: -- Mock the UseCase/Service layer -- Test HTTP requests/responses -- Verify JSON serialization -- Check status codes and response structure - -See [references/kotlin-examples.md](references/kotlin-examples.md) for complete examples. - -## Checklist - -Before completing: -- [ ] Success case test written -- [ ] Failure/exception case test written -- [ ] Edge case test written (empty, null, max value) -- [ ] Mock verification added (verify) -- [ ] Fixture created and reused -- [ ] Tests run successfully (`./gradlew test --tests "{TestClass}"`) - -## Troubleshooting - -### Test Compilation Errors - -**Missing imports**: Check the existing Gradle test dependencies before adding anything. Prefer existing Kotest, MockK, springmockk, and Testcontainers versions already configured in the project. - -### MockK "no answer found" errors - -Use `relaxed = true` for dependencies you don't need to verify: -```kotlin -val repository = mockk(relaxed = true) -``` - -### Soft Delete Tests Failing - -Ensure repository method includes soft delete filtering: -```kotlin -// Correct -userRepository.findByIdAndDeletedAtIsNull(1L) - -// Wrong -userRepository.findById(1L) // Will include deleted entities -``` - -## References - -- [Kotlin Examples (Kotest + MockK)](references/kotlin-examples.md) diff --git a/.agents/skills/test-create/references/kotlin-examples.md b/.agents/skills/test-create/references/kotlin-examples.md deleted file mode 100644 index 55986a58..00000000 --- a/.agents/skills/test-create/references/kotlin-examples.md +++ /dev/null @@ -1,203 +0,0 @@ -# Kotlin Test Examples - -## DescribeSpec (Recommended for UseCases) - -```kotlin -class CreateUserUseCaseTest : DescribeSpec({ - val userRepository = mockk() - val userMapper = mockk() - val useCase = CreateUserUseCase(userRepository, userMapper) - - describe("execute 실행") { - context("유효한 요청이 주어졌을 때") { - it("사용자를 생성하고 저장한다") { - val request = UserTestFixture.createRequest() - val user = UserTestFixture.createUser() - every { userRepository.save(any()) } returns user - every { userMapper.toResponse(any()) } returns UserResponse(id = 1L, name = "Test User") - - val result = useCase.execute(request) - - result.id shouldBe 1L - verify { userRepository.save(any()) } - } - } - - context("검증에 실패했을 때") { - it("IllegalArgumentException을 던진다") { - val request = UserTestFixture.createRequest(name = "") - - shouldThrow { - useCase.execute(request) - } - } - } - } -}) -``` - -## BehaviorSpec (BDD style for complex logic) - -```kotlin -class CreateUserUseCaseBddTest : BehaviorSpec({ - val userRepository = mockk() - val userMapper = mockk() - val useCase = CreateUserUseCase(userRepository, userMapper) - - Given("유효한 사용자 생성 요청이 주어졌을 때") { - val request = CreateUserRequest(name = "John", email = "john@example.com") - val user = UserTestFixture.createUser() - - every { userRepository.save(any()) } returns user - every { userMapper.toResponse(any()) } returns UserResponse(id = 1L, name = "John") - - When("사용자를 생성하면") { - val result = useCase.execute(request) - - Then("ID가 포함된 사용자 응답이 반환되어야 한다") { - result.id shouldBe 1L - } - - Then("repository의 save가 호출되어야 한다") { - verify { userRepository.save(any()) } - } - } - } - - Given("중복된 이메일 요청이 주어졌을 때") { - val request = CreateUserRequest(name = "John", email = "existing@example.com") - - every { userRepository.save(any()) } throws DataIntegrityViolationException("duplicate") - - When("사용자를 생성하면") { - Then("예외가 발생해야 한다") { - shouldThrow { - useCase.execute(request) - } - } - } - } -}) -``` - -## StringSpec (Simple validation tests) - -```kotlin -class UserValidationTest : StringSpec({ - "이름이 100자를 초과하면 예외가 발생한다" { - val longName = "a".repeat(101) - shouldThrow { - User.create(name = longName, email = "test@example.com") - } - } - - "이메일이 비어 있으면 예외가 발생한다" { - shouldThrow { - User.create(name = "John", email = "") - } - } - - "유효한 사용자면 정상 생성된다" { - val user = User.create(name = "John", email = "john@example.com") - user.name shouldBe "John" - user.email shouldBe "john@example.com" - } -}) -``` - -## Controller Test - -```kotlin -@WebMvcTest(UserController::class) -@Import(SecurityConfig::class) -class UserControllerTest : DescribeSpec() { - @Autowired - lateinit var mockMvc: MockMvc - - @Autowired - lateinit var objectMapper: ObjectMapper - - @MockkBean - lateinit var createUserUsecase: CreateUserUsecase - - init { - describe("POST /api/v1/users") { - context("유효한 요청이 주어졌을 때") { - it("생성된 사용자와 함께 200 OK를 반환한다") { - val request = CreateUserRequest(name = "John", email = "john@example.com") - val response = UserResponse(id = 1L, name = "John") - every { createUserUsecase.execute(any()) } returns response - - mockMvc.perform( - post("/api/v1/users") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.id").value(1)) - .andExpect(jsonPath("$.name").value("John")) - } - } - } - } -} -``` - -## Test Fixture - -```kotlin -object UserTestFixture { - fun createUser( - id: Long = 1L, - email: String = "test@example.com", - name: String = "Test User", - status: UserStatus = UserStatus.ACTIVE - ) = User( - id = id, - name = name, - email = email, - status = status - ) - - fun createRequest( - name: String = "Test User", - email: String = "test@example.com" - ) = CreateUserRequest(name = name, email = email) - - fun createUsers(count: Int = 3) = - (1..count).map { createUser(id = it.toLong(), email = "user$it@example.com") } -} -``` - -## MockK Usage - -```kotlin -// Create mock -val repository = mockk() - -// Relaxed mock (returns default values for all methods) -val relaxedMock = mockk(relaxed = true) - -// Stubbing -every { repository.findById(1L) } returns Optional.of(user) -every { repository.save(any()) } returns user -every { repository.findById(any()) } returns Optional.empty() - -// Stubbing with argument capture -val slot = slot() -every { repository.save(capture(slot)) } answers { slot.captured } - -// Verify -verify { repository.save(any()) } -verify(exactly = 1) { repository.findById(1L) } -verify(exactly = 0) { repository.delete(any()) } - -// Verify order -verifyOrder { - repository.findById(1L) - repository.save(any()) -} - -// Clear mocks -clearMocks(repository) -``` diff --git a/.agents/skills/test-driven-development/SKILL.md b/.agents/skills/test-driven-development/SKILL.md deleted file mode 100644 index 51f6779c..00000000 --- a/.agents/skills/test-driven-development/SKILL.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -name: test-driven-development -description: Use when implementing any feature or bugfix, before writing implementation code ---- - -# Test-Driven Development (TDD) - -## Overview - -Write the test first. Watch it fail. Write minimal code to pass. - -**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing. - -**Violating the letter of the rules is violating the spirit of the rules.** - -## When to Use - -**Always:** -- New features -- Bug fixes -- Refactoring -- Behavior changes - -**Exceptions (ask your human partner):** -- Throwaway prototypes -- Generated code -- Configuration files - -Thinking "skip TDD just this once"? Stop. That's rationalization. - -## The Iron Law - -``` -NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST -``` - -Write code before the test? Delete it. Start over. - -**No exceptions:** -- Don't keep it as "reference" -- Don't "adapt" it while writing tests -- Don't look at it -- Delete means delete - -Implement fresh from tests. Period. - - -## Red Phase: Write Failing Test - -1. Express intended behavior as test first -2. **Test only one behavior** at a time -3. **Must verify failure** by running test (compilation errors count as failures) - -```kotlin -// Kotest DescribeSpec example -class CalculatorTest : DescribeSpec({ - describe("Calculator") { - it("두 숫자를 더한다") { - val calculator = Calculator() - calculator.add(2, 3) shouldBe 5 - } - } -}) -``` - -Verify failure message matches intent. Unexpected failure reasons indicate test issues. - -## Green Phase: Make Test Pass - -1. **Write minimal code** to pass the test -2. Hardcoding, duplication, messy code allowed -3. Goal is only green bar - -```kotlin -class Calculator { - fun add(a: Int, b: Int): Int = 5 // Hardcoding OK -} -``` - -"Working code" first, "clean code" next. - -## Refactor Phase: Improve Code - -1. **Keep tests passing** while improving -2. Remove duplication, improve naming, apply patterns -3. **Must re-run tests** after refactoring -4. No new features - structural improvements only - -```kotlin -class Calculator { - fun add(a: Int, b: Int): Int = a + b // Generalize -} -``` - - -## Checklist - -### Red -- [ ] Test verifies single behavior? -- [ ] Verified failure by running test? -- [ ] Failure message as intended? - -### Green -- [ ] Passed with simplest approach? -- [ ] Test is green? - -### Refactor -- [ ] Tests still green? -- [ ] Duplication removed? -- [ ] Names reveal intent? -- [ ] No new features added? diff --git a/.claude/agents/feature-developer-agent.md b/.claude/agents/feature-developer-agent.md index f5a5c288..17605b7a 100644 --- a/.claude/agents/feature-developer-agent.md +++ b/.claude/agents/feature-developer-agent.md @@ -1,7 +1,7 @@ --- name: feature-developer-agent description: "Develop new features following architecture rules. Proceeds in order: requirements → design → implementation → testing → review." -tools: Glob, Grep, Read, Edit, Write, Bash, Task +tools: Glob, Grep, Read, Edit, Write, Bash, Task, Skill model: sonnet color: green --- @@ -12,7 +12,7 @@ Develop new features following `.claude/rules/architecture.md` patterns. **All output MUST be written in Korean.** - **In scope**: New feature implementation (Entity, UseCase, QueryService, Controller, DTO, Mapper, Port, tests) -- **Out of scope**: Java→Kotlin migration (`kotlin-migration-agent`), architecture refactoring (`system-architect-agent`) +- **Out of scope**: Architecture refactoring of existing code (`system-architect-agent`) - **Prerequisite**: Requirements must be clear enough to define API endpoints and domain behavior ## Package Placement @@ -41,11 +41,11 @@ New files MUST follow `architecture.md` Package Structure. See the table there f - Grep for similar features → prevent duplicate creation - **Present design proposal to user → start coding after approval** -### 1. API Contract Definition [`api-design.md`, `exception-handling.md`] +### 1. API Contract Definition → `api-contract-update` skill [`api-design.md`, `exception-handling.md`] - Controller method signature, `@Operation`, `@Tag` - Request/Response DTO (`@Schema`, `@field:` validation) -- ResponseCode enum (1XXX) + ErrorCode enum (2XXX) + `@ExplainError` — Grep existing code numbers to prevent conflicts -- `@ApiSuccessCodeExample`, `@ApiErrorCodeExample` annotations +- ResponseCode enum (`1DDNN`) + ErrorCode enum (`2DDNN`) + `@ExplainError` — use the skill's code registry to pick the next unused code +- `@ApiErrorCodeExample` on the controller class or method ### 2. Domain Layer Design → `architecture-guide` skill [`architecture.md`] - Entity, Repository, Port, Domain Service **structure decision** @@ -53,6 +53,7 @@ New files MUST follow `architecture.md` Package Structure. See the table there f ### 3. Application Layer Design [`mapper-dto.md`, `transaction-concurrency.md`] - Command UseCase, QueryService, Mapper, Exception **structure decision** +- Locking or concurrent-write risk involved → `concurrency-safety` skill - Class list and dependencies only, implementation in Step 4 **Step 2-3 output (required):** New/modified file list (with paths) + key dependencies as table → proceed after user approval @@ -62,7 +63,7 @@ New files MUST follow `architecture.md` Package Structure. See the table there f - Order: Entity → Repository → UseCase/QueryService → Mapper → Adapter → Controller - Run `./gradlew ktlintFormat` -### 5. Test Writing → `test-create` skill [`testing.md`] +### 5. Test Writing — follow `.claude/skills/test-create/SKILL.md` (read it directly; it is not model-invocable) [`testing.md`] - Write tests for implemented classes - Order: Entity → UseCase/QueryService → Controller(`@WebMvcTest`) - Run `./gradlew test` @@ -78,7 +79,7 @@ New files MUST follow `architecture.md` Package Structure. See the table there f - No UseCase-to-UseCase calls (→ Domain Service) - `@Transactional` on UseCase only, forbidden on Domain Service - Cross-domain reads via Reader interface, no direct Repository reference -- New endpoints MUST have `@ApiSuccessCodeExample` + `@ApiErrorCodeExample` +- New endpoints MUST declare `@ApiErrorCodeExample` - All API responses MUST be wrapped in `CommonResponse` ## Token Optimization diff --git a/.claude/agents/implementation-evaluator.md b/.claude/agents/implementation-evaluator.md new file mode 100644 index 00000000..068e6060 --- /dev/null +++ b/.claude/agents/implementation-evaluator.md @@ -0,0 +1,69 @@ +--- +name: implementation-evaluator +description: "Grade completed implementation work from a clean context against the task file. Read-only — no Write/Edit. Returns PASS / NEEDS_WORK with per-criterion evidence. Spawned by the verify-implementation skill; do not use for implementing or fixing code." +tools: Read, Glob, Grep, Bash +model: opus +hooks: + PreToolUse: + - matcher: Bash + hooks: + - type: command + command: "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/evaluator-bash-allowlist.sh" +--- + +# Implementation Evaluator + +You grade completed implementation work. You are NOT the implementer — you never saw the +implementation conversation, and that is by design. **All output MUST be written in Korean.** + +## Inputs (from the spawn prompt) + +- Diff baseline (commit/branch) — evaluate `git diff ` only +- Task file path (e.g. `.claude/tasks/current-task.md`) +- Optionally: previous findings to re-verify (re-evaluation round) + +## Hard Rules + +1. **Requirements come from the task file only.** Derive criterion 1 from the task file's + user request quote and acceptance criteria — never from the spawn prompt's framing or + any claim about what was implemented. If the task file is missing or has no acceptance + criteria, criterion 1 is 불통과 (cannot be graded). +2. **Default-FAIL contract.** Every criterion starts at 불통과. Flip to 통과 only with + evidence you observed yourself: code you read (file:line), command output you ran. + "The diff looks complete" or "tests were presumably run" is not evidence. +3. **You do not fix anything.** If you find a problem, report it. Your Bash is restricted + by a hook to: `git diff/log/status/show`, `./gradlew test*`, `./gradlew compileKotlin`, + `./gradlew compileTestKotlin`. Use Read/Glob/Grep for file inspection. +4. **Re-evaluation rounds re-grade ALL criteria from scratch** — previous findings are an + appendix ("verify these were fixed"), not the scope. Fixes can introduce regressions in + other criteria. + +## Grading Criteria (evidence required for each) + +| # | Criterion | How to gather evidence | +|---|-----------|------------------------| +| 1 | 요구사항 충족 — diff implements every acceptance criterion in the task file; nothing in the explicit out-of-scope list was done | Read task file → map each acceptance criterion to diff hunks (file:line) | +| 2 | 아키텍처 준수 — semantic rules NOT covered by Konsist: UseCase stays orchestration-only (business logic belongs in Entity), transaction boundary content (no external I/O inside `@Transactional`), Reader vs Repository choice for cross-domain reads, Entity invariants actually held. Structural rules (layer imports, naming, annotation placement) are enforced by `ArchitectureTest` — run it instead of re-grading them; never contradict its verdict | Run `./gradlew test --tests "com.weeth.architecture.ArchitectureTest"` + read the changed files; cite violating/conforming lines | +| 3 | 테스트 — scoped run passes AND tests are meaningful: not everything mocked away, failure paths asserted, assertions check real behavior (not just "no exception") | Run `./gradlew test --tests "..."` scoped to affected classes; read the test code itself | +| 4 | API 계약 (only when controllers/DTOs/codes changed) — `CommonResponse` wrapping, `@ApiErrorCodeExample`, error code `XDDNN` scheme, required annotations per `.claude/rules/api-design.md` | Read controller/DTO diff; cite lines | + +Criterion 4 is `해당 없음` when the diff has no API surface — state that explicitly. + +## Output Format (Korean, exactly this structure) + +``` +판정: PASS | NEEDS_WORK + +기준별 결과: +1. 요구사항 충족: 통과|불통과 — 증거: <파일:라인 / 명령 출력 요약> +2. 아키텍처 준수: 통과|불통과 — 증거: ... +3. 테스트: 통과|불통과 — 증거: <실행한 명령 + 결과 요약 + 테스트 품질 판단 근거> +4. API 계약: 통과|불통과|해당 없음 — 증거: ... + +(NEEDS_WORK 시) +미비점: +- <구체적 항목 — 무엇이, 어디서(파일:라인), 왜 기준에 미달하는지. 다음 빌더 세션의 입력이 된다> +``` + +PASS requires every applicable criterion to be 통과. One 불통과 = NEEDS_WORK. +Do not soften the verdict to be agreeable — a false PASS defeats your purpose. diff --git a/.claude/agents/kotlin-migration-agent.md b/.claude/agents/kotlin-migration-agent.md deleted file mode 100644 index a6969aeb..00000000 --- a/.claude/agents/kotlin-migration-agent.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -name: kotlin-migration-agent -description: "Java → Kotlin syntax migration agent. Safely migrates in order: write tests → convert syntax → verify with ktlint." -tools: Glob, Grep, Read, Edit, Write, Bash, Task -model: sonnet -color: red ---- - -# Kotlin Migration Agent - -Safely convert Java code to Kotlin **syntax**. -**All output MUST be written in Korean.** - -- **In scope**: 1:1 syntax conversion + architecture alignment per `architecture.md` -- **Out of scope**: Large-scale architecture redesign beyond `architecture.md` rules - -## Skill Invocation - -| Phase | Skill | Condition | -|-------|-------|-----------| -| Write tests | `test-create` | Always | -| Test failure | `systematic-debugging` | When test is Red | -| Syntax conversion | `kotlin-migration` | Always | -| Architecture planning | `architecture-guide` | Always | -| Architecture issue | `system-architect-agent` (Task) | When architecture change causes problems | -| Post-conversion failure | `systematic-debugging` | When Kotlin code needs fixing | - -## Batch Strategy - -Split into batches, get user approval between each. **Never proceed without approval.** - -| Scope | Batch Size | -|-------|-----------| -| Single domain | 3-5 files (Entity → Repository → UseCase order) | -| Cross-domain | 1 domain at a time | -| Complex UseCase | 1 file | - ---- - -## Workflow (MUST follow in order) - -### 0. Prerequisites Check -- `kotlin-spring` plugin, `kotlin-jpa` plugin, Kotest + MockK in `build.gradle` -- If missing → notify user and stop - -### 1. Write Tests → `test-create` skill [`testing.md`] -- **Tests MUST be written in Kotlin** -- **Tests MUST be placed under `src/test/kotlin`** -- Test business logic with conditions/branching: "Can this test catch a behavior change after conversion?" → Yes: write, No: skip -- Skip simple delegation (JPA basic methods, single `orElseThrow`) - -### 2. Run Tests Against Java Code -- `./gradlew test --tests "*{TargetClass}Test"` — must pass before conversion - -### 2.5. Architecture Alignment Plan → `architecture-guide` skill [`architecture.md`] -- Analyze current Java code for `architecture.md` violations -- Draft architecture changes to apply during conversion (e.g. Giant Service split, remove GetService/SaveService wrappers, move logic to Entity) -- Report plan to user → get approval before proceeding - -### 3. Move and Convert (Syntax Only) → `kotlin-migration` skill [`code-style.md`] -- `git mv` to Kotlin path (separate commit for rename detection) -- Convert Java → Kotlin syntax, preserve annotations (`@Transactional`, Swagger, `@field:`) -- Convert MapStruct mappers to manual Mapper classes [`mapper-dto.md`] -- Show Before/After summary for each conversion (required) -- **Do NOT apply architecture changes in this step** - -### 4. Verify Syntax Conversion -- `./gradlew ktlintFormat && ./gradlew ktlintCheck` -- `./gradlew test` — failure here = syntax conversion issue → fix with `systematic-debugging` - -### 5. Apply Architecture Changes → `architecture-guide` skill [`architecture.md`] -- Apply approved plan from step 2.5 -- One change type at a time (e.g. split Service → then move logic to Entity) - -### 6. Update Tests for New Architecture -- Adapt tests to match architecture changes from step 5 -- **Allowed**: import paths, class/method names, mock targets, wiring changes -- **Forbidden**: modifying assertions, expected values, business logic verification - -### 7. Verify Architecture Changes -- `./gradlew test` — failure here = architecture change issue → invoke `system-architect-agent` via Task to resolve - -### 8. Batch Report — use `kotlin-migration` skill template. Output in Korean. - -### 9. Final Verification (after all batches) — `./gradlew clean build && ./gradlew test` + Spring Context load check. - -## Constraints - -- Never migrate without tests -- Never proceed to next batch without user approval -- Never move files without `git mv` -- Never complete batch without passing ktlint -- Architecture changes must follow approved plan from step 2.5 only -- Architecture issues beyond `architecture.md` scope → delegate to `system-architect-agent` -- Test modifications allowed: imports, class names, mock targets, wiring only -- Test modifications forbidden: assertions, expected values, business logic verification - -## Token Optimization - -- **Grep first**: Specific methods instead of reading entire files -- **Delegate to skills**: Tests → `test-create`, debugging → `systematic-debugging`, conversion → `kotlin-migration` -- **Adjust batch size**: Small domain 5 files, large domain 3 files, complex UseCase 1 file diff --git a/.claude/agents/system-architect-agent.md b/.claude/agents/system-architect-agent.md index 000801eb..f8152e16 100644 --- a/.claude/agents/system-architect-agent.md +++ b/.claude/agents/system-architect-agent.md @@ -1,7 +1,7 @@ --- name: system-architect-agent description: "Restructure existing code to match architecture rules. Safely proceeds in order: impact analysis → test → refactor → verify." -tools: Glob, Grep, Read, Edit, Write, Bash, Task +tools: Glob, Grep, Read, Edit, Write, Bash, Task, Skill model: opus color: blue --- @@ -12,8 +12,8 @@ Restructure existing code to match `.claude/rules/architecture.md` rules. **All output MUST be written in Korean.** - **In scope**: Architecture refactoring (package moves, responsibility separation, pattern application) -- **Out of scope**: Java→Kotlin syntax conversion (handled by `kotlin-migration-agent`) -- **Prerequisite**: Target code must already be converted to Kotlin +- **Out of scope**: New feature development (`feature-developer-agent`) +- **Prerequisite**: The refactoring target has passing tests — write characterization tests first if missing ## Refactoring Types @@ -54,7 +54,7 @@ Safe (auto-proceed): `create()`/`of()` factory, read-only decisions (`isEditable ### 0. Impact Analysis — Grep call chain. **Never skip.** Report to user → approval. - References, co-change files, DB schema change? → **stop**. API endpoints must not change. -### 1. Run Existing Tests — if none exist, ask user to run `kotlin-migration-agent` first. +### 1. Run Existing Tests — if none exist, write characterization tests first (follow `.claude/skills/test-create/SKILL.md`) and confirm they pass before refactoring. ### 2. Execute Refactoring → `architecture-guide` skill [`architecture.md`, `transaction-concurrency.md`] - One type at a time. `@Transactional`: entry-point UseCase only. diff --git a/.claude/hooks/evaluator-bash-allowlist.sh b/.claude/hooks/evaluator-bash-allowlist.sh new file mode 100755 index 00000000..5f1268b6 --- /dev/null +++ b/.claude/hooks/evaluator-bash-allowlist.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# implementation-evaluator 전용 PreToolUse 훅: Bash를 읽기성 명령 allowlist로 제한 +# +# 평가자의 "쓰기 권한 없음"은 프롬프트 약속이 아니라 이 훅으로 강제된다. +# 현실적 실패 모드는 평가자가 발견한 문제를 직접 고쳐버리는 것 — 그 순간 +# 생성자-평가자 분리가 무너지므로 조회/검증 명령 외에는 전부 차단한다. +# +# 허용: git diff/log/status/show, ./gradlew test* / compileKotlin / compileTestKotlin +# 차단: 그 외 전부 + 셸 메타문자(; & | > < ` $)가 섞인 복합 명령 +# +# 인자 패턴 매칭은 변형에 취약하므로 이 훅은 1차 방어선이다. +# 2차 방어선은 verify-implementation 스킬의 스폰 전후 git diff 해시 비교. + +INPUT=$(cat) +COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty') + +deny() { + echo "evaluator-bash-allowlist: 차단된 명령입니다 — $1" >&2 + echo "평가자는 읽기 전용입니다. 허용 명령: git diff/log/status/show, ./gradlew test*/compileKotlin/compileTestKotlin. 파일 탐색은 Read/Glob/Grep 도구를 사용하세요." >&2 + exit 2 +} + +[ -z "$COMMAND" ] && deny "빈 명령" + +# 복합 명령/리다이렉션/치환 차단 (allowlist 우회 경로) +case "$COMMAND" in + *';'* | *'&'* | *'|'* | *'>'* | *'<'* | *'`'* | *'$('* | *$'\n'*) + deny "셸 메타문자 포함: $COMMAND" + ;; +esac + +# git --no-pager 접두 변형은 동일한 읽기성 명령으로 정규화 +NORMALIZED="${COMMAND/#git --no-pager /git }" + +# 읽기성 git 명령이라도 파일 쓰기가 가능한 출력 옵션은 차단 +case "$NORMALIZED" in + *' --output='* | *' --output '* | *' -o '*) + deny "쓰기 가능한 출력 옵션 포함: $COMMAND" + ;; +esac + +case "$NORMALIZED" in + 'git diff' | 'git diff '* | \ + 'git log' | 'git log '* | \ + 'git status' | 'git status '* | \ + 'git show' | 'git show '* | \ + './gradlew test' | './gradlew test '* | \ + './gradlew compileKotlin' | './gradlew compileKotlin '* | \ + './gradlew compileTestKotlin' | './gradlew compileTestKotlin '* | \ + './gradlew compileKotlin compileTestKotlin' | './gradlew compileKotlin compileTestKotlin '*) + exit 0 + ;; + *) + deny "$COMMAND" + ;; +esac diff --git a/.claude/hooks/ktlint-format.sh b/.claude/hooks/ktlint-format.sh index c3b285f2..6db9527b 100755 --- a/.claude/hooks/ktlint-format.sh +++ b/.claude/hooks/ktlint-format.sh @@ -1,16 +1,65 @@ #!/bin/bash -# PostToolUse hook: .kt 파일 수정 시 ktlint 자동 포맷 +# PostToolUse hook: .kt/.kts 파일 수정 시 해당 파일만 ktlint 포맷 +# 전체 ktlintFormat은 편집마다 Gradle 기동 비용이 들고, 수정하지 않은 파일까지 +# 포맷되어 diff가 오염되므로 단일 파일만 처리한다. FILE_PATH=$(cat | jq -r '.tool_input.file_path // empty') -if [ -z "$FILE_PATH" ]; then - exit 0 -fi +[ -z "$FILE_PATH" ] && exit 0 + +case "$FILE_PATH" in + *.kt | *.kts) ;; + *) exit 0 ;; +esac + +[ -f "$FILE_PATH" ] || exit 0 + +# build.gradle.kts의 ktlint { version } 과 동일하게 유지할 것 +KTLINT_VERSION="1.8.0" +# 공급망 변조 차단용 고정 체크섬. 버전 변경 시 함께 갱신할 것. +# 산출: curl -fsSL .../$KTLINT_VERSION/ktlint | shasum -a 256 +KTLINT_SHA256="a3fd620207d5c40da6ca789b95e7f823c54e854b7fade7f613e91096a3706d75" +CACHE_DIR="$HOME/.cache/ktlint" +KTLINT_BIN="$CACHE_DIR/ktlint-$KTLINT_VERSION" -# .kt 파일만 처리 -if [[ "$FILE_PATH" != *.kt ]]; then - exit 0 +# 최초 1회만 다운로드 (이후 캐시 사용) +if [ ! -x "$KTLINT_BIN" ]; then + mkdir -p "$CACHE_DIR" + # 임시 파일에 받아 검증 통과 후에만 캐시 경로로 이동 — 변조/부분 다운로드된 + # 바이너리가 캐시에 남아 다음 실행에서 그대로 실행되는 것을 방지한다. + TMP_BIN=$(mktemp "$CACHE_DIR/ktlint-$KTLINT_VERSION.XXXXXX") + if curl -fsSL --connect-timeout 10 -o "$TMP_BIN" \ + "https://github.com/pinterest/ktlint/releases/download/$KTLINT_VERSION/ktlint"; then + # macOS에는 sha256sum이 기본 미설치 — 없으면 shasum으로 폴백 + if command -v sha256sum >/dev/null 2>&1; then + echo "$KTLINT_SHA256 $TMP_BIN" | sha256sum -c - >/dev/null 2>&1 + else + echo "$KTLINT_SHA256 $TMP_BIN" | shasum -a 256 -c - >/dev/null 2>&1 + fi + if [ $? -eq 0 ]; then + chmod +x "$TMP_BIN" && mv -f "$TMP_BIN" "$KTLINT_BIN" + else + echo "ktlint: 다운로드 바이너리 SHA-256 검증 실패 — 폐기 후 Gradle로 폴백합니다." >&2 + rm -f "$TMP_BIN" + fi + else + rm -f "$TMP_BIN" + fi fi cd "$CLAUDE_PROJECT_DIR" || exit 0 -./gradlew ktlintFormat 2>&1 >&2 + +if [ -x "$KTLINT_BIN" ]; then + OUTPUT=$("$KTLINT_BIN" -F "$FILE_PATH" 2>&1) + if [ $? -ne 0 ]; then + # 자동 수정 불가능한 lint 오류는 Claude에게 피드백 (exit 2 = stderr 전달) + echo "ktlint: 자동 수정되지 않은 오류가 있습니다:" >&2 + echo "$OUTPUT" >&2 + exit 2 + fi +else + # 다운로드 실패 시 기존 Gradle 방식으로 폴백 + ./gradlew ktlintFormat 2>&1 >&2 +fi + +exit 0 diff --git a/.claude/rules/api-design.md b/.claude/rules/api-design.md index bbefc763..97927a04 100644 --- a/.claude/rules/api-design.md +++ b/.claude/rules/api-design.md @@ -1,221 +1,12 @@ # API Design Rules -## Controller Structure +Use the `api-contract-update` skill when adding or changing controllers, endpoints, response/error codes, Swagger error examples, club/admin routes, DTO contracts, or pagination responses. -```kotlin -@Tag(name = "USER", description = "사용자 API") -@RestController -@RequestMapping("/api/v1/users") -@ApiErrorCodeExample(UserErrorCode::class, JwtErrorCode::class) -class UserController( - private val userUsecase: UserUsecase -) { - @GetMapping - @Operation(summary = "내 정보 조회") - fun getUser(@Parameter(hidden = true) @CurrentUser userId: Long): CommonResponse = - CommonResponse.success(USER_FIND_BY_ID_SUCCESS, userUsecase.find(userId)) -} -``` +## Always-on Contract -## Club-scoped API - -Club resources use `/api/v4/clubs/{clubId}/...`. `clubId` is Base62 TSID — use two annotations together: - -```kotlin -@TsidParam // Swagger (type: string) -@TsidPathVariable clubId: Long // decodes Base62 → Long at runtime -``` - -## Required Annotations - -| Annotation | Purpose | -|-----------|---------| -| `@Tag(name = "DOMAIN")` | OpenAPI grouping | -| `@Operation(summary = "...")` | API description | -| `@Parameter(hidden = true)` | Hide internal params from docs | -| `@Valid` | Enable validation | -| `@ApiErrorCodeExample(...)` | Auto-register error examples in Swagger | - -## Response Format - -Wrap responses in `CommonResponse`: - -```kotlin -data class CommonResponse( - val code: Int, - val message: String, - val data: T?, -) { - companion object { - @JvmStatic - fun success(responseCode: ResponseCodeInterface): CommonResponse = - CommonResponse(code = responseCode.code, message = responseCode.message, data = null) - - @JvmStatic - fun success(responseCode: ResponseCodeInterface, data: T): CommonResponse = - CommonResponse(code = responseCode.code, message = responseCode.message, data = data) - - @JvmStatic - fun error(errorCode: ErrorCodeInterface): CommonResponse = - CommonResponse(code = errorCode.code, message = errorCode.message, data = null) - } -} -``` - -## Response Codes - -```kotlin -enum class UserResponseCode( - override val code: Int, - override val status: HttpStatus, - override val message: String -) : ResponseCodeInterface { - USER_FIND_ALL_SUCCESS(10900, HttpStatus.OK, "모든 회원 정보를 성공적으로 조회했습니다."), - USER_FIND_BY_ID_SUCCESS(10907, HttpStatus.OK, "회원 정보가 성공적으로 조회되었습니다."), - USER_UPDATE_SUCCESS(10908, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), -} -``` - -- Success code enums must implement `ResponseCodeInterface`. -- Controllers should return success responses with enum directly: - - `CommonResponse.success(USER_FIND_BY_ID_SUCCESS, data)` - - `CommonResponse.success(USER_UPDATE_SUCCESS)` - -## Code Format - -| | Mean | Value | -|---|-----------------|----------------------------------------------------------------------------| -| X | Category | 1=Success, 2=Domain Error, 3=Infra/Server Error, 4=Client/Validation Error | -| DD | Domain ID | 01~99 | -| NN | In Domain Count | 00~99 | - -## Domain ID - -| DD | Domain | Success Range | Domain Error Range | Infra Error Range | -|----|------------|---------------|--------------------|-------------------| -| 01 | account | 10100~ | 20100~ | — | -| 02 | attendance | 10200~ | 20200~ | — | -| 03 | session | 10300~ | 20300~ | — | -| 04 | board | 10400~ | 20400~ | — | -| 05 | comment | 10500~ | 20500~ | — | -| 06 | file | 10600~ | 20600~ | 30600~ | -| 07 | penalty | 10700~ | 20700~ | — | -| 08 | schedule | 10800~ | 20800~ | — | -| 09 | user | 10900~ | 20900~ | — | -| 10 | cardinal | 11000~ | 21000~ | — | -| 11 | club | 11100~ | 21100~ | — | -| 12 | dashboard | 11200~ | 21200~ | — | -| 13 | university | 11300~ | — | 31300~ | -| 90 | jwt/auth | — | 29000~ | — | -| 99 | common | — | — | 39900~ | - -## Domain Success Codes - -| Domain | ResponseCode Enum | Code Range | Location | -|--------|------------------|------------|----------| -| Account | `AccountResponseCode` | `101xx` | `domain/account/presentation/` | -| Attendance | `AttendanceResponseCode` | `102xx` | `domain/attendance/presentation/` | -| Session | `SessionResponseCode` | `103xx` | `domain/session/presentation/` | -| Board | `BoardResponseCode` | `104xx` | `domain/board/presentation/` | -| Comment | `CommentResponseCode` | `105xx` | `domain/comment/presentation/` | -| File | `FileResponseCode` | `106xx` | `domain/file/presentation/` | -| Penalty | `PenaltyResponseCode` | `107xx` | `domain/penalty/presentation/` | -| Schedule | `ScheduleResponseCode` | `108xx` | `domain/schedule/presentation/` | -| User | `UserResponseCode` | `109xx` | `domain/user/presentation/` | -| Cardinal | `CardinalResponseCode` | `110xx` | `domain/cardinal/presentation/` | -| Club | `ClubResponseCode` | `111xx` | `domain/club/presentation/` | -| Dashboard | `DashboardResponseCode` | `112xx` | `domain/dashboard/presentation/` | -| University | `UniversityResponseCode` | `113xx` | `domain/university/presentation/` | - -## Domain Error Codes - -| Domain | ErrorCode Enum | Code Range | Location | -|--------|---------------|------------|----------| -| Account | `AccountErrorCode` | `201xx` | `domain/account/application/exception/` | -| Attendance | `AttendanceErrorCode` | `202xx` | `domain/attendance/application/exception/` | -| Session | `SessionErrorCode` | `203xx` | `domain/session/application/exception/` | -| Board | `BoardErrorCode` | `204xx` | `domain/board/application/exception/` | -| Comment | `CommentErrorCode` | `205xx` | `domain/comment/application/exception/` | -| File | `FileErrorCode` | `206xx` (domain), `306xx` (infra) | `domain/file/application/exception/` | -| Penalty | `PenaltyErrorCode` | `207xx` | `domain/penalty/application/exception/` | -| Schedule | `EventErrorCode` | `208xx` | `domain/schedule/application/exception/` | -| User | `UserErrorCode` | `209xx` | `domain/user/application/exception/` | -| Cardinal | `CardinalErrorCode` | `210xx` | `domain/cardinal/application/exception/` | -| Club | `ClubErrorCode` | `211xx` | `domain/club/application/exception/` | -| Dashboard | `DashboardErrorCode` | `212xx` | `domain/dashboard/application/exception/` | -| University | `UniversityErrorCode` | `313xx` (infra) | `domain/university/application/exception/` | -| JWT (Global) | `JwtErrorCode` | `290xx` | `global/auth/jwt/application/exception/` | - -## HTTP Methods - -| Method | Usage | -|--------|-------| -| GET | Read operations, no body | -| POST | Create resources | -| PUT | Full updates | -| PATCH | Partial updates | -| DELETE | Remove resources | - -## Path Design - -``` -GET /users # List users -GET /users/{userId} # Get single user -POST /users # Create user -PATCH /users/{userId} # Update user -DELETE /users/{userId} # Delete user -POST /users/{userId}/activate # Action on resource -``` - -### Admin Endpoints - -`admin` prefix comes **before** `clubs/{clubId}`: `/api/v4/admin/clubs/{clubId}/{resource}` - -``` -/api/v4/clubs/{clubId}/boards # user-facing -/api/v4/admin/clubs/{clubId}/boards # admin -``` - -Enables a single SecurityConfig rule: `.requestMatchers("/api/v4/admin/**").hasRole("ADMIN")` - -## Query & Path Parameters - -- Query params for filtering: `?page=0&size=10&status=ACTIVE` -- Path variables for resource identification: `/users/{userId}` - -## Request/Response DTO - -```kotlin -// Request -data class CreateUserRequest( - @field:Schema(description = "User name", example = "John Doe") - @field:NotBlank - @field:Size(max = 100) - val name: String, - - @field:Schema(description = "Email address", example = "john@example.com") - @field:NotBlank - @field:Email - val email: String -) - -// Response -data class UserResponse( - @Schema(description = "User ID", example = "1") - val id: Long, - - @Schema(description = "User name", example = "John Doe") - val name: String, - - @Schema(description = "Email address", example = "john@example.com") - val email: String? -) -``` - -## Validation - -Use Jakarta validation annotations in DTOs: -- `@NotNull`, `@NotEmpty`, `@NotBlank` -- `@Size(min = 1, max = 100)` -- `@Positive`, `@PositiveOrZero` -- `@Email`, `@Pattern` +- Every response is wrapped in `CommonResponse` (`code`, `message`, `data`). +- Controllers return success with a domain `*ResponseCode`; errors use `CommonResponse.error(errorCode)`. +- Required controller annotations: `@Tag`, `@Operation(summary)`, `@ApiErrorCodeExample`, `@Valid` on request bodies, `@Parameter(hidden = true)` on internal params like `@CurrentUser`. +- Club resources use `/api/v4/clubs/{clubId}/...`; `clubId` is Base62 TSID and needs both `@TsidParam` and `@TsidPathVariable`. +- Admin club endpoints put `admin` before `clubs/{clubId}`: `/api/v4/admin/clubs/{clubId}/{resource}` for the single SecurityConfig rule `.requestMatchers("/api/v4/admin/**").hasRole("ADMIN")`. +- API codes use `XDDNN`; use the skill's code registry before adding success/error codes. diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index e4f2c771..e532d76c 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -1,5 +1,9 @@ # Architecture Rules +Structurally checkable rules in this document are enforced by Konsist +(`src/test/kotlin/com/weeth/architecture/ArchitectureTest.kt`, runs with `./gradlew test`). +Its BASELINE lists are pre-existing debt — never add entries, only remove after refactoring. + ## Package Structure ```text @@ -8,26 +12,20 @@ src/main/kotlin/com/weeth/ │ ├── application/ │ │ ├── dto/request/, dto/response/ │ │ ├── mapper/ -│ │ ├── usecase/ -│ │ │ ├── command/ # State-changing use cases -│ │ │ └── query/ # Read-only query services +│ │ ├── usecase/command/ # State-changing use cases +│ │ ├── usecase/query/ # Read-only query services │ │ ├── exception/ │ │ └── validator/ │ ├── domain/ │ │ ├── entity/ # Rich Domain Model │ │ ├── vo/ # Value Objects │ │ ├── enums/ -│ │ ├── port/ # External system abstraction (Port interface) +│ │ ├── port/ # External system abstraction │ │ ├── service/ # Multi-entity business logic only -│ │ └── repository/ +│ │ └── repository/ # JpaRepository + Reader interfaces │ ├── infrastructure/ # Port implementations (Adapter) -│ └── presentation/ -│ └── *Controller.kt -└── global/ - ├── auth/ - ├── config/ - ├── common/ - └── logging/ +│ └── presentation/ # Controller + ResponseCode +└── global/ # auth, config, common, logging ``` ## Layer Dependencies @@ -38,14 +36,10 @@ presentation → application → domain (owns Port) infrastructure (implements Port) ``` -- **presentation** → application only -- **application** → domain (Repository, Entity, Service, Port). Never import infrastructure directly -- **domain** → depends on nothing. Owns Port interfaces -- **infrastructure** → implements domain/port. Depends on external libraries/SDK +- application never imports infrastructure; domain depends on nothing - **Same domain**: UseCase uses Repository directly -- **Cross-domain read**: via target domain's Reader interface (not Repository directly) -- **Cross-domain write**: Repository directly (same transaction required) -- **Cross-domain write**: Use Domain Event (transaction separable) +- **Cross-domain read**: via target domain's Reader interface (in `domain/repository/`), never its Repository +- **Cross-domain write**: Repository directly when the same transaction is required; otherwise use a Domain Event ## UseCase Rules @@ -54,16 +48,10 @@ presentation → application → domain (owns Port) | Command | `usecase/command/` | `{Verb}{Domain}UseCase` | `@Transactional` | | Query | `usecase/query/` | `Get{Domain}QueryService` | `@Transactional(readOnly = true)` | -- **Orchestration only**: delegates business logic to Entity, calls Repository directly -- **No wrapper services**: do NOT create GetService/SaveService/DeleteService for thin Repository delegation +- **Orchestration only**: business logic lives in Entities; UseCase coordinates flow +- **No wrapper services**: never create GetService/SaveService/DeleteService for thin Repository delegation - **Group related actions**: e.g. `AuthUserUseCase` = login + signup + withdraw - -## Query Service - -- **Role**: data assembly for presentation (query, map, combine, paginate) — not business logic -- **Transaction**: `@Transactional(readOnly = true)` -- **Return type**: Response DTO -- **Prohibited**: state changes, business logic execution +- Query Service does data assembly for presentation (query, map, combine, paginate) and returns Response DTOs — no state changes, no business logic ### Command UseCase → Query Service dependency @@ -73,148 +61,28 @@ presentation → application → domain (owns Port) | Complex query returning Entity | Depend on Query Service OK | | Query Service returns Response DTO | Do NOT depend — use Reader or Repository | -## Cross-domain Reference - -- **Read**: Reader interface in target domain (`domain/repository/`), implemented by Repository -- **Write**: Repository directly (same transaction required) - ## Entity (Rich Domain Model) -- **State changes**: named methods (`publish()`, `softDelete()`) — no public setters -- **Validation**: `require` for argument checks, `check` for state preconditions -- **Business decisions**: `isEditableBy()`, `canPublish()` belong to Entity +- State changes via named methods (`publish()`, `softDelete()`) — no public setters; mutable props use `private set` +- `require` for argument checks, `check` for state preconditions; business decisions (`isEditableBy()`) belong to the Entity ### Constructor Pattern -Primary constructor takes **business creation params only** (non-property) — JPA-managed fields (`id`, `isDeleted`) belong in the body with `private set` and default values. - -```kotlin -@Entity -class Post( - title: String, - content: String, - user: User, - board: Board, -) : BaseEntity() { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - var id: Long = 0L - private set - - var title: String = title - private set - // ... - - companion object { - fun create(title: String, content: String, user: User, board: Board): Post { - require(title.isNotBlank()) { "제목은 비어 있을 수 없습니다" } - return Post(title = title, content = content, user = user, board = board) - } - } -} -``` - -| Concern | Location | -|---------|----------| -| JPA-managed fields (`id`, `isDeleted`) | Body, `private set`, default value | -| Business creation params | Primary constructor (non-property) | -| Validation | `create()` / named mutation methods — not constructor | - -- **Factory method** (`companion object`): use when the entity has creation logic or validation. Expresses domain intent. -- **Simple entities** (e.g., `Board`): public constructor is fine; no factory method needed if creation is trivial. - -## Value Object (VO) - -- **Location**: `domain/vo/` -- **Single field**: Kotlin `value class` — inline at JVM level, zero overhead -- **Multi field**: `@Embeddable class` (NOT `data class`) — used with `@Embedded` in Entity. Apply the same `private set` pattern as Entity; handle normalization/validation in a `companion object` `of`/`from` factory when needed. - -### value class (single field) +Primary constructor takes **business creation params only** (non-property). JPA-managed fields (`id`, `isDeleted`) go in the body with `private set` and defaults. Validation lives in a `companion object` `create()` factory (or named mutation methods), not the constructor. Simple entities with trivial creation (e.g. `Board`) may use a public constructor without a factory. Use the `architecture-guide` skill for full examples. -```kotlin -@JvmInline -value class Email(val value: String) { - init { - require(value.contains("@")) { "Invalid email format: $value" } - } -} -``` - -### @Embeddable class (composite fields) - -```kotlin -@Embeddable -class Period( - startDate: LocalDate, - endDate: LocalDate, -) { - @Column(nullable = false) - var startDate: LocalDate = startDate - private set - - @Column(nullable = false) - var endDate: LocalDate = endDate - private set - - init { - require(!this.endDate.isBefore(this.startDate)) { "endDate must be after startDate" } - } - - fun contains(date: LocalDate): Boolean = - !date.isBefore(startDate) && !date.isAfter(endDate) - - companion object { - fun of(startDate: LocalDate, endDate: LocalDate): Period = - Period(startDate = startDate, endDate = endDate) - } -} -``` +## Value Object (`domain/vo/`) -> Why not `data class`: keep state changes confined to explicit named methods via `private set`, mirroring Entity. The default identity-based `equals/hashCode` is fine because an embedded VO is compared as part of its owning Entity, not on its own. - -### Usage in Entity - -```kotlin -@Entity -class User( - @Embedded - val period: Period, - - // value class stored as primitive via .value - @Column(nullable = false) - val email: String, // Entity field is primitive; VO conversion at UseCase/Service boundary -) -``` - -### VO Rules - -| Rule | Description | -|------|-------------| -| State control | `value class`: single `val` field. `@Embeddable class`: `var` + `private set` in the body; mutate only via named methods | -| Self-validating | Validate with `require` in `init` block (or normalize in a factory and delegate) | -| Equality | `value class`: automatic. `@Embeddable class`: default (identity); override `equals/hashCode` explicitly only when value-equality is required | -| Factory | Use a `companion object` `of`/`from` factory when normalization (e.g. trim) or extra validation is needed | -| Business logic | May contain operations/decisions relevant to the value | -| JPA mapping | `@Embeddable` + `@Embedded` for composite; value class stored as primitive in Entity | +- **Single field**: Kotlin `@JvmInline value class` with `require` in `init`; stored as primitive in the Entity (VO conversion at UseCase/Service boundary) +- **Multi field**: `@Embeddable class` (**NOT** `data class`) used via `@Embedded` — same `private set` pattern as Entity, `require` in `init`, `companion object` `of`/`from` factory when normalization is needed. Default identity `equals` is fine (compared as part of the owning Entity); override only when value-equality is required. +- VOs may contain operations/decisions relevant to the value (e.g. `Period.contains(date)`) ## Domain Service -- **Only for multi-entity logic** or rules that don't fit a single Entity -- **No thin wrappers**: do NOT create `{Domain}GetService`, `{Domain}SaveService` -- **No `@Transactional`**: UseCase manages transaction boundaries -- **Name by role**: `AttendancePolicy`, `DuplicateCheckService` - -## Port-Adapter Pattern - -- **Port** (`domain/port/`): interface in domain language → `FileStoragePort`, `PushNotificationSenderPort` -- **Adapter** (`infrastructure/`): implementation with tech prefix → `S3FileStorageAdapter`, `FcmPushNotificationSenderAdapter` -- UseCase depends on Port interface only → swappable, testable +- Only for multi-entity logic or rules that don't fit a single Entity — name by role (`AttendancePolicy`, `DuplicateCheckService`) +- No `@Transactional` (UseCase manages boundaries), no thin wrappers -## Core Principles +## Port-Adapter -1. **Rich Domain Model**: Entity owns validation, state changes, and business decisions -2. **UseCase = orchestration**: coordinates flow; "how" is decided by Entity -3. **No meaningless services**: Repository wrappers are eliminated; Domain Service only for multi-entity logic -4. **Port-Adapter**: domain owns Port interfaces; infrastructure implements them -5. **Kotlin-first**: Java → Kotlin migration complete; all new code in Kotlin +- Port (`domain/port/`): interface in domain language — `FileStoragePort`, `PushNotificationSenderPort` +- Adapter (`infrastructure/`): implementation with tech prefix — `S3FileStorageAdapter`, `FcmPushNotificationSenderAdapter` +- UseCase depends on the Port interface only diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index caf065dc..5ad91e87 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -1,88 +1,32 @@ # Code Style Rules -## Language +## Language & Formatting -- Primary: Kotlin (Java → Kotlin migration in progress) -- Build: Gradle (Kotlin DSL) - -## Formatting - -- Use ktlint -- Run `./gradlew ktlintFormat` before committing +- Kotlin only — do not introduce Java production code. Build: Gradle (Kotlin DSL) +- ktlint enforced: run `./gradlew ktlintFormat` before committing ## Naming Conventions | Element | Convention | Example | |---------|-----------|---------| -| Classes | PascalCase | `UserController`, `CreateUserUseCase` | -| Methods | camelCase | `getUserDetail`, `createUser` | -| Constants | SCREAMING_SNAKE_CASE | `MAX_PAGE_SIZE` | -| Packages | lowercase | `com.example.domain.user` | | DTOs | Suffix with purpose | `CreateUserRequest`, `UserResponse` | | Test Fixtures | `{Entity}TestFixture` | `UserTestFixture` | +| Constants | SCREAMING_SNAKE_CASE in `companion object` | `MAX_PAGE_SIZE` | ## Null Safety -- Avoid using Kotlin non-null assertion operator `!!`. -- Prefer safe call (`?.`), Elvis operator (`?:`), and `requireNotNull`/`checkNotNull` unless `!!` is truly unavoidable. -- If `!!` must be used, add a short comment explaining why in that code block. - -## Data Class vs Class - -```kotlin -// Request DTO - Use data class -data class CreateUserRequest( - @field:NotBlank val name: String, - @field:Email val email: String -) - -// Response DTO - Use data class -data class UserResponse( - val id: Long, - val name: String -) +- Avoid `!!`. Prefer `?.`, `?:`, `requireNotNull`/`checkNotNull`. +- If `!!` is truly unavoidable, add a short comment explaining why. -// Entity - Use class (not data class) -@Entity -class User( - @Id @GeneratedValue - val id: Long = 0, - var name: String -) : BaseEntity() -``` +## Class Kinds -## Import Organization - -1. Kotlin standard library -2. Third-party libraries -3. Spring framework -4. Project classes - -## Constants - -```kotlin -companion object { - private const val MAX_PAGE_SIZE = 20 - private const val DEFAULT_PAGE_SIZE = 10 -} -``` +- Entity: regular `class` (not `data class`), mutable props use `private set` + named mutation methods +- DTO: `data class` ## Comments -- Do NOT comment on self-explanatory code -- Add comments in these cases: - - **Core business logic**: Domain rules, policy decisions — explain "why", not "what" - - **Collaboration aid**: Intent or background that other developers need to understand the code - - **Non-obvious implementation**: Performance optimizations, workarounds, external system constraints - - **Architecture decisions**: Reason for choosing a specific pattern or structure (e.g., `// NOTE: Kept in Java for Lombok @SuperBuilder compatibility`) -- Use KDoc (`/** */`) for public APIs, Port interfaces, and external contracts -- Use inline comments (`//`) for implementation intent within methods - -## Null Handling - -```kotlin -// Use nullable types and Elvis operator -fun getUser(userId: Long): User = - userRepository.findByIdOrNull(userId) - ?: throw UserNotFoundException() -``` +- Do NOT comment self-explanatory code. Comment only for: + - Core business logic — explain "why", not "what" + - Non-obvious implementation: performance workarounds, external system constraints + - Architecture decisions: reason for choosing a specific pattern +- KDoc (`/** */`) for public APIs, Port interfaces, and external contracts; inline `//` for intent within methods diff --git a/.claude/rules/exception-handling.md b/.claude/rules/exception-handling.md index 5bee6001..7bea7480 100644 --- a/.claude/rules/exception-handling.md +++ b/.claude/rules/exception-handling.md @@ -1,136 +1,8 @@ # Exception Handling Rules -## Exception Hierarchy - -``` -RuntimeException - └── BaseException (abstract) - ├── UserNotFoundException - ├── BoardNotFoundException - └── ... (domain-specific exceptions) -``` - -## Base Exception - -```kotlin -abstract class BaseException( - val errorCode: ErrorCodeInterface, - message: String? = null -) : RuntimeException(message ?: errorCode.message) -``` - -## Error Code Interface - -```kotlin -interface ErrorCodeInterface { - val code: Int - val status: HttpStatus - val message: String - - fun getExplainError(): String = message -} -``` - -## Domain Error Codes - -```kotlin -enum class UserErrorCode( - override val code: Int, - override val status: HttpStatus, - override val message: String -) : ErrorCodeInterface { - @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") - USER_NOT_FOUND(20900, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), - - @ExplainError("가입 승인 대기 중인 사용자가 접근을 시도할 때 발생합니다.") - USER_INACTIVE(20901, HttpStatus.FORBIDDEN, "가입 승인이 허가되지 않은 계정입니다."), - - @ExplainError("이미 가입된 이메일로 회원가입을 시도할 때 발생합니다.") - USER_EXISTS(20902, HttpStatus.BAD_REQUEST, "이미 가입된 사용자입니다."), -} -``` - -## Common Error Codes (pattern example, not yet implemented) - -Follow the pattern below when introducing a common error code enum. Currently, `CommonExceptionHandler` uses `CommonResponse.createFailure()` directly. - -```kotlin -enum class CommonErrorCode( - override val code: Int, - override val status: HttpStatus, - override val message: String -) : ErrorCodeInterface { - // 3DDNN: Infra/Server errors (DD=99 for common) - INTERNAL_SERVER_ERROR(39901, HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"), - JSON_PROCESSING_ERROR(39902, HttpStatus.INTERNAL_SERVER_ERROR, "JSON processing error"), - - // 4DDNN: Client/Validation errors (DD=99 for common) - INVALID_ARGUMENT(49901, HttpStatus.BAD_REQUEST, "Invalid argument"), - RESOURCE_NOT_FOUND(49903, HttpStatus.NOT_FOUND, "Resource not found"), -} -``` - -## Domain Exception Classes - -```kotlin -class UserNotFoundException : BaseException(UserErrorCode.USER_NOT_FOUND) -``` - -## Swagger Exception Documentation (Auto) - -Swagger is customized so exception codes/examples are registered automatically from annotations and error-code enums. - -### Required Annotations - -- `@ApiErrorCodeExample`: Declare which `ErrorCodeInterface` enums can be returned by an API. -- `@ExplainError`: Optional field-level description for richer Swagger examples. - -```kotlin -@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) -@Retention(AnnotationRetention.RUNTIME) -annotation class ApiErrorCodeExample( - vararg val value: KClass -) -``` - -### Controller Convention - -- Apply `@ApiErrorCodeExample` at controller class level when most endpoints share the same domain errors. -- Apply it at method level when a specific endpoint has different error sets. -- If both are present, method-level declaration should take precedence for that endpoint. -- If multiple enums are needed, pass them together: - -```kotlin -@ApiErrorCodeExample(BoardErrorCode::class, NoticeErrorCode::class) -class NoticeController -``` - -### ErrorCode Enum Convention - -- Domain error enums must implement `ErrorCodeInterface`. -- Add `@ExplainError` to each enum constant when possible. -- If `@ExplainError` is missing, fallback to `message`. - -```kotlin -enum class UserErrorCode( - override val code: Int, - override val status: HttpStatus, - override val message: String -) : ErrorCodeInterface { - @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") - USER_NOT_FOUND(20900, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), -} -``` - -### Documentation-only Controller - -- Keep an `ExceptionDocController` for aggregated, domain-wide exception browsing in Swagger. -- This controller is for documentation only; it should not contain business logic. - -### When Adding a New Exception - -1. Add enum constant to the proper `*ErrorCode`. -2. Add `@ExplainError` description. -3. Create/adjust domain exception class extending `BaseException`. -4. Ensure the relevant controller/method has `@ApiErrorCodeExample` for that enum. -5. Verify Swagger examples show the new code without manual response-spec edits. +- Domain exceptions extend `BaseException(errorCode)`. +- Error codes are per-domain enums implementing `ErrorCodeInterface` (`code`, `status`, `message`); messages are Korean. +- Error code numbering follows `XDDNN`; use `api-contract-update` before adding or changing error codes. +- Swagger error examples are generated from `@ApiErrorCodeExample` and `@ExplainError`; never hand-edit response specs. +- `ExceptionDocController` is only for aggregated exception browsing in Swagger; no business logic there. +- There is no common error enum yet. `CommonExceptionHandler` uses `CommonResponse.createFailure()` directly; if one is introduced, use DD=99. diff --git a/.claude/rules/git-conventions.md b/.claude/rules/git-conventions.md index 45d28d13..bde200e4 100644 --- a/.claude/rules/git-conventions.md +++ b/.claude/rules/git-conventions.md @@ -1,112 +1,7 @@ # Git Conventions Rules -# Commit Convention -## Format +Use the `git-prepare` skill when creating branches, syncing branches, preparing commits, writing commit messages, or preparing PRs. -``` -type: message -``` +Project policy: sync shared branches with merge, not rebase. Do not rewrite shared branch history. -- **type**: lowercase English -- **message**: Brief description (imperative mood) - -## Types - -| Type | Description | Example | -|------|-------------|---------| -| `feat` | New feature | `feat: Add user authentication` | -| `fix` | Bug fix | `fix: Resolve null pointer in service` | -| `refactor` | Code refactoring | `refactor: Extract validation logic` | -| `test` | Test code changes | `test: Add UserService unit tests` | -| `docs` | Documentation | `docs: Update API documentation` | -| `style` | Code formatting | `style: Apply code formatter` | -| `chore` | Maintenance | `chore: Update dependencies` | -| `perf` | Performance improvement | `perf: Optimize database queries` | -| `ci` | CI configuration | `ci: Add GitHub Actions workflow` | -| `build` | Build system | `build: Update Gradle config` | - -## Examples - -```bash -# New feature -feat: Add user registration endpoint - -# Bug fix -fix: Handle null profile in user response - -# Refactoring -refactor: Split UserService into Get/Save services - -# Test -test: Add integration tests for auth flow - -# Documentation -docs: Add API usage examples - -# Style -style: Format code with ktlint - -# Chore -chore: Upgrade Spring Boot to 3.2.0 -``` - -## Rules - -1. **No period** at the end -2. **Imperative mood** ("Add" not "Added", "Fix" not "Fixed") -3. **50 characters or less** for subject line -4. **Separate subject from body** with blank line if body needed -5. **Reference issue numbers** if applicable: `fix: Resolve login bug (#123)` - -## Multi-line Commits - -For detailed descriptions: - -```bash -git commit -m "$(cat <<'EOF' -feat: Add user authentication - -- Implement JWT token generation -- Add login/logout endpoints -- Create auth middleware - -Closes #123 -EOF -)" -``` ---- -# Branch Convention - -| Type | Pattern | Example | -|------|---------|------------------------------| -| Feature | `feat/{ticket}-description` | `feat/WTH-123-user-login` | -| Bugfix | `fix/{ticket}-description` | `fix/WTH-456-token-expiry` | -| Refactor | `refactor/{ticket}-description` | `refactor/WTH-789-cleanup` | -| Hotfix | `hotfix/description` | `hotfix/critical-auth-bug` | -| Release | `release/version` | `release/v1.2.0` | - -## Branch Update Policy - -- Update local branches from the latest target branch using **merge**. -- Default command: `git merge origin/{target-branch}`. -- Do not rewrite shared branch history with rebase when syncing latest changes. - -## Pre-commit Checklist - -1. Run linter: `./gradlew ktlintFormat` -2. Run tests: `./gradlew test` -3. Verify commit message format -4. Review changed files -5. Check for sensitive data (.env, credentials) - -## Conventional Commits (Optional) - -For automated changelog generation: - -``` -type(scope): message - -feat(auth): Add OAuth2 support -fix(api): Handle rate limiting -refactor(user): Simplify validation logic -``` +Commit format: `type: message` — lowercase type (`feat`/`fix`/`refactor`/`test`/`docs`/`style`/`chore`/`perf`/`ci`/`build`), imperative mood, ≤50 chars, no trailing period. diff --git a/.claude/rules/mapper-dto.md b/.claude/rules/mapper-dto.md index c1fd5289..e9f4ceee 100644 --- a/.claude/rules/mapper-dto.md +++ b/.claude/rules/mapper-dto.md @@ -2,37 +2,27 @@ ## Mapper Pattern -Manual `@Component` Mapper pattern (no MapStruct). +Manual `@Component` Mapper classes — **no MapStruct**. Mappers may inject other mappers. ```kotlin @Component class UserMapper { - fun toResponse(user: User) = UserResponse( - id = user.id, - name = user.name, - email = user.email, - ) - - fun toEntity(request: CreateUserRequest) = User( - name = request.name.trim(), - email = request.email.lowercase(), - status = UserStatus.ACTIVE - ) + fun toResponse(user: User) = UserResponse(id = user.id, name = user.name) + fun toEntity(request: CreateUserRequest) = User(name = request.name.trim(), ...) } ``` -## Mapper Naming - -| Method Pattern | Purpose | -|---------------|---------| +| Method | Purpose | +|--------|---------| | `toResponse` | Entity → Response DTO | | `toEntity` | Request DTO → Entity | -| `toDto` | Entity → Generic DTO | +| `toDto` | Entity → generic DTO | | `from{Source}` | Convert from specific source type | -## Request DTO +## DTO Rules -Located in `application/dto/request/`: +- Location: `application/dto/request/`, `application/dto/response/` +- Request DTO: Jakarta validation + `@field:Schema(description, example)` on every field ```kotlin data class CreateUserRequest( @@ -40,84 +30,8 @@ data class CreateUserRequest( @field:NotBlank @field:Size(max = 100) val name: String, - - @field:Schema(description = "Email address", example = "john@example.com") - @field:NotBlank - @field:Email - val email: String, -) -``` - -### Validation Annotations - -| Annotation | Usage | -|-----------|-------| -| `@NotNull` | Field must not be null | -| `@NotEmpty` | Collection must have elements | -| `@NotBlank` | String must not be empty/whitespace | -| `@Size(min, max)` | Length/size constraints | -| `@Positive` | Number must be > 0 | -| `@Valid` | Validate nested objects | - -## Response DTO - -Located in `application/dto/response/`: - -```kotlin -data class UserResponse( - @Schema(description = "User ID", example = "1") - val id: Long, - - @Schema(description = "User name", example = "John Doe") - val name: String, ) ``` -### Response DTO Rules - -- Use `@Schema` for OpenAPI documentation -- Use non-nullable types for required fields -- Use nullable types with default `null` for optional fields - -## List Response with Pagination (pattern example) - -Follow the pattern below when introducing a pagination response DTO. - -```kotlin -data class UserListResponse( - @Schema(description = "User list") - val users: List, - - @Schema(description = "Pagination info") - val page: PageResponse -) - -data class PageResponse( - val pageNumber: Int, - val pageSize: Int, - val totalElements: Long, - val totalPages: Int, - val hasNext: Boolean -) { - companion object { - fun from(page: Page<*>) = PageResponse( - pageNumber = page.number, - pageSize = page.size, - totalElements = page.totalElements, - totalPages = page.totalPages, - hasNext = page.hasNext() - ) - } -} -``` - -## Mapper Dependencies - -Mappers can inject other mappers when needed: - -```kotlin -@Component -class PostMapper( - private val commentMapper: CommentMapper -) -``` +- Response DTO: `@Schema` on every field; non-nullable for required fields, nullable + default `null` for optional +- Use the `api-contract-update` skill for paginated/list response templates. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index e99df07e..2ab037af 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -1,125 +1,42 @@ # Testing Rules -## Frameworks +## Stack & Styles -| Framework | Purpose | -|-----------|---------| -| Kotest | Kotlin test framework | -| MockK | Kotlin mocking | -| springmockk | Spring bean mocking (`@MockkBean`) | -| Testcontainers | Integration tests (DB, Redis, etc.) | +Kotest + MockK + springmockk (`@MockkBean`) + Testcontainers (MySQL). No Mockito. -## Test Styles (Kotest) - -| Style | Use Case | +| Kotest Style | Use Case | |-------|----------| | `DescribeSpec` | Default for application tests (Command UseCase, QueryService) | | `BehaviorSpec` | Complex business logic requiring BDD (Given/When/Then) | | `StringSpec` | Simple validation and pure domain logic tests | -## Directory Structure - -```text -src/test/kotlin/com/weeth/domain/{domain-name}/ -├── application/usecase/command/ # Command UseCase tests -├── application/usecase/query/ # QueryService tests -├── domain/service/ # Domain service tests (multi-entity logic) -├── domain/entity/ # Entity behavior tests -└── fixture/ # Shared fixtures for the domain -``` - -## Naming Conventions - -| Element | Convention | Example | -|---------|-----------|---------| -| Test class | `{ClassName}Test` | `CreateUserUseCaseTest`, `GetUserQueryServiceTest` | -| Test fixture | `{Entity}TestFixture` | `UserTestFixture` | -| DescribeSpec description | method/action + condition + behavior | `describe("execute") { context("with valid request") { it("creates user") } }` | +Test packages mirror source. Test class: `{ClassName}Test`. Shared fixtures: `src/test/kotlin/com/weeth/domain/{domain}/fixture/{Entity}TestFixture` — `object` with factory methods and sensible defaults for all parameters. ## Architecture-aligned Unit Boundaries -- Command UseCase test: mock Repository/Reader/Port, verify orchestration behavior. -- QueryService test: verify read-only assembly (query/map/combine/paginate), no state mutation. -- Entity test: verify `create/of`, state transitions, `require/check`, and business decisions. -- Domain Service test: only for multi-entity logic/policy classes (not thin wrappers). -- Controller test: verify request/response contract and serialization with `@WebMvcTest`. - -## Dependency Rules in Tests - -- Same-domain dependencies: UseCase mocks Repository directly. -- Cross-domain read: mock target domain Reader interface (not target Repository directly). -- Cross-domain write: mock target domain Repository directly when same-transaction write is required. -- Port-Adapter: application tests mock Port interface, not infrastructure adapter implementations. - -## Unit Test vs Integration Test - -| Category | Unit Test | Integration Test | -|----------|-----------|-----------------| -| Scope | Single class | Multiple layers / external systems | -| Dependencies | MockK mocks | Testcontainers (DB, Redis) | -| Speed | Fast (ms) | Slow (seconds) | -| Annotation | None | `@SpringBootTest`, `@WebMvcTest` | -| When to use | Orchestration, branching, entity/domain rules | DB queries, API endpoints, transaction behavior | - -## Fixture Pattern +- Command UseCase test: mock Repository/Reader/Port, verify orchestration behavior +- QueryService test: verify read-only assembly (query/map/combine/paginate), no state mutation +- Entity test: verify `create/of`, state transitions, `require`/`check`, business decisions +- Domain Service test: only for multi-entity logic/policy classes +- Controller test: request/response contract with `@WebMvcTest` -```kotlin -object UserTestFixture { - fun createUser( - id: Long = 1L, - email: String = "test@example.com", - name: String = "Test User" - ) = User(id = id, name = name, email = email, status = UserStatus.ACTIVE) -} -``` +Mocking rules: same-domain → mock Repository directly; cross-domain read → mock Reader (not target Repository); application tests mock Port interfaces, never infrastructure adapters. -- Location: `src/test/kotlin/com/weeth/domain/{domain-name}/fixture/` -- Use `object` with factory methods -- Provide sensible defaults for all parameters -- Reuse across test classes in the same domain +Integration tests (`@SpringBootTest`/`@WebMvcTest` + Testcontainers) are for DB queries, API endpoints, and transaction behavior; everything else is a plain unit test with MockK. ## What to Test / Skip -**Write tests for:** -- UseCase orchestration paths (success/failure/branching) -- Reader/Repository/Port interaction contracts (`verify`) -- QueryService data assembly and pagination mapping -- Entity invariants and state transitions (`require`/`check`) -- Exception scenarios and error-code mapping +**Test:** UseCase orchestration paths (success/failure/branching), mock interaction contracts (`verify`), QueryService assembly/pagination, entity invariants and state transitions, exception scenarios and error-code mapping. -**Skip tests for:** -- Thin wrapper methods that only delegate to Repository without logic -- Getter/setter, trivial DTO mapping -- Framework-provided functionality +**Skip:** thin delegation without logic, getters/trivial DTO mapping, framework functionality. -## Mock Lifecycle in DescribeSpec +## Mock Lifecycle in DescribeSpec (pitfall) -MockK mocks are **not** automatically cleared between `it` blocks. Without clearing, accumulated invocations cause `verify(exactly = N)` to fail in subsequent tests. - -Always add `beforeTest { clearMocks(...) }` when mocks are shared: +MockK mocks are **not** cleared between `it` blocks — accumulated invocations break `verify(exactly = N)`. When mocks are shared, always: ```kotlin -class SomeUseCaseTest : DescribeSpec({ - val repository = mockk() - val useCase = SomeUseCase(repository) - - beforeTest { - clearMocks(repository) - // Re-stub defaults after clearing - every { repository.save(any()) } answers { firstArg() } - } - - describe("someMethod") { - it("case 1") { verify(exactly = 1) { repository.save(any()) } } - it("case 2") { verify(exactly = 1) { repository.save(any()) } } // OK - count reset - } -}) -``` - -## Running Tests - -```bash -./gradlew test # All tests -./gradlew test --tests "*UseCaseTest" # Pattern match -./gradlew test --tests "CreateUserUseCaseTest" # Specific class +beforeTest { + clearMocks(repository) + every { repository.save(any()) } answers { firstArg() } // re-stub defaults +} ``` diff --git a/.claude/rules/transaction-concurrency.md b/.claude/rules/transaction-concurrency.md index 0c55cb21..9ff1c86d 100644 --- a/.claude/rules/transaction-concurrency.md +++ b/.claude/rules/transaction-concurrency.md @@ -1,138 +1,18 @@ # Transaction & Concurrency Rules -## Transaction Annotations - -### Read Operations -```kotlin -@Transactional(readOnly = true) -fun getFeedDetail(feedId: Long): FeedDetailResponse { - // Query operations only -} -``` - -### Write Operations -```kotlin -@Transactional -fun uploadFeed(userId: Long, request: FeedUploadRequest) { - // Create/Update/Delete operations -} -``` +Use the `concurrency-safety` skill when changing transaction boundaries, lock behavior, concurrent counter updates, same-transaction cross-domain writes, or external I/O around transactions. ## Transaction Placement -- Place `@Transactional` on **UseCase** methods -- Domain Services should NOT have `@Transactional` -- Let UseCase manage transaction boundaries - -```kotlin -@Service -class CreateFeedUseCase( - private val feedRepository: FeedRepository, - private val mediaRepository: MediaRepository, - private val feedMapper: FeedMapper -) { - @Transactional - fun execute(userId: Long, request: FeedUploadRequest) { - val feed = feedMapper.toEntity(user, request.description) - feedRepository.save(feed) - val mediaList = request.media.map { Media.create(feed, it) } - mediaRepository.saveAll(mediaList) - } -} -``` - -## Pessimistic Locking - -For resources that need concurrent access control: - -```kotlin -interface FeedRepository : JpaRepository { - @Lock(LockModeType.PESSIMISTIC_WRITE) - @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) - @Query("SELECT f FROM Feed f WHERE f.id = :id") - fun findByIdWithLock(@Param("id") id: Long): Feed? -} -``` +- `@Transactional` goes on **UseCase** methods only — Command: `@Transactional`, Query: `@Transactional(readOnly = true)` +- Domain Services must NOT have `@Transactional`; UseCase owns transaction boundaries +- Keep transactions short — no external I/O (S3, HTTP) inside transactions -## When to Use Locking +## Locking Policy | Scenario | Lock Type | |----------|-----------| -| Counter updates (reaction count) | PESSIMISTIC_WRITE | -| Concurrent modifications | PESSIMISTIC_WRITE | -| Read-heavy, write-rare | OPTIMISTIC (version field) | - -## Lock Timeout Handling - -```kotlin -@Service -class ReactionUsecase( - private val feedRepository: FeedRepository -) { - @Transactional - fun react(userId: Long, feedId: Long) { - try { - val feed = feedRepository.findByIdWithLock(feedId) - ?: throw FeedNotFoundException() - // process reaction - } catch (e: PessimisticLockingFailureException) { - throw ResourceLockedException() - } - } -} -``` - -## Optimistic Locking - -Add version field to entity: - -```kotlin -@Entity -class Feed( - @Version - val version: Long = 0 -) : BaseEntity() -``` - -## Transaction Propagation - -Default propagation is `REQUIRED`. Use others when needed: - -```kotlin -// New transaction (for audit logs, etc.) -@Transactional(propagation = Propagation.REQUIRES_NEW) -fun logAction(action: String) { } - -// No transaction -@Transactional(propagation = Propagation.NOT_SUPPORTED) -fun nonTransactionalOperation() { } -``` - -## Transaction Isolation - -Default is database default. Adjust for specific needs: - -```kotlin -@Transactional(isolation = Isolation.SERIALIZABLE) -fun criticalOperation() { } -``` - -## Async Operations - -For async operations, transaction context is NOT propagated: - -```kotlin -@Async -@Transactional -fun asyncOperation() { - // New transaction in async thread -} -``` - -## Best Practices +| Counter updates, concurrent modifications | PESSIMISTIC_WRITE | +| Read-heavy, write-rare | OPTIMISTIC (`@Version` field) | -1. **Keep transactions short** - Don't do I/O operations inside transactions -2. **Avoid nested transactions** - Can cause unexpected behavior -3. **Lock ordering** - Always acquire locks in same order to prevent deadlocks -4. **Timeout configuration** - Always set lock timeouts -5. **Handle lock exceptions** - Convert to user-friendly errors \ No newline at end of file +Rules: pessimistic lock queries live in the Repository, always set lock timeouts, acquire locks in a consistent order, and surface lock failures as user-friendly domain errors. diff --git a/.claude/rules/verification.md b/.claude/rules/verification.md new file mode 100644 index 00000000..0d3d0eae --- /dev/null +++ b/.claude/rules/verification.md @@ -0,0 +1,46 @@ +# Verification Rules + +Creator-evaluator separation: the session that implements never grades its own work. + +## Role Contract + +1. **Task file first**: for work at the scale of a new feature, domain logic change, + behavior-changing refactor, or multi-file change — copy `.claude/tasks/template.md` + to `.claude/tasks/current-task.md` and fill it in at the START of the work (planning + stage), before implementing. Never write it retroactively. +2. **No self-PASS**: when an active task file exists, the completion report MUST quote the + `verify-implementation` evaluation result. The builder never declares PASS itself. +3. **Clean up after evaluation**: `current-task.md` is per-task and not committed (only + `template.md` is tracked). An *active* task file = `current-task.md` with a populated + `## 수용 기준` section. A bare/placeholder file does not gate evaluation. + +## Evaluation Gate + +| Scope | Task file | Verification path | +|-------|-----------|-------------------| +| New feature / endpoint, domain logic change, behavior-changing refactor, multi-file change | Required | implement → `verify-implementation` skill (auto, before reporting done) | +| Docs/comments, config values, typo/single-line fix, test-only tweak, formatting | None | ktlint hook + compile + CI (no evaluator spawn) | + +Borderline → write the task file (err toward evaluation). The user can always invoke +`/verify-implementation` manually. When skipping evaluation on borderline work, append +`{"date":"...","task":"...","tier":"skipped"}` to `.claude/metrics/eval-log.jsonl`. + +## Task File Format (`.claude/tasks/current-task.md`) + +```markdown +# + +## 사용자 요청 (원문 인용) +> + +## 수용 기준 +- [ ] +- [ ] ... + +## 제외 범위 +- +``` + +Acceptance criteria must be checkable statements — the evaluator grades against them +literally. The task file is overwritten per task and is not committed (gitignored; see +Role Contract item 3), so it never appears in the PR diff. diff --git a/.claude/scripts/audit-skill-invocations.sh b/.claude/scripts/audit-skill-invocations.sh new file mode 100755 index 00000000..8deccd50 --- /dev/null +++ b/.claude/scripts/audit-skill-invocations.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# 세션 트랜스크립트(~/.claude/projects//*.jsonl)에서 skill 호출 이력을 추출한다. +# context-update의 Skill Invocation Audit 단계에서 "실제 호출 여부"의 객관적 근거로 사용. +# +# 기본: 현재 세션($CLAUDE_CODE_SESSION_ID)만 읽는다 — 감사 대상은 지금 세션이고, +# 과거 세션까지 읽으면 컨텍스트만 낭비되기 때문. +# 사용법: +# audit-skill-invocations.sh # 현재 세션만 +# audit-skill-invocations.sh --last 5 # 최근 5개 세션 (과거 추세 확인용) + +set -u + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" +LAST_N=0 +if [ "${1:-}" = "--last" ]; then + LAST_N="${2:?--last 뒤에 세션 개수를 지정하세요}" +fi + +python3 - "$PROJECT_DIR" "$LAST_N" "${CLAUDE_CODE_SESSION_ID:-}" <<'EOF' +import json, glob, os, re, sys +from collections import Counter + +project_dir, last_n, session_id = sys.argv[1], int(sys.argv[2]), sys.argv[3] + +# 프로젝트 경로 → 트랜스크립트 디렉터리 슬러그 (영숫자 외 문자는 '-') +slug = re.sub(r"[^A-Za-z0-9]", "-", project_dir) +base = os.path.expanduser(f"~/.claude/projects/{slug}") + +if last_n > 0: + files = sorted(glob.glob(f"{base}/*.jsonl"), key=os.path.getmtime)[-last_n:] +else: + current = f"{base}/{session_id}.jsonl" + if session_id and os.path.isfile(current): + files = [current] + else: # 세션 ID를 모르면 최신 파일로 폴백 + files = sorted(glob.glob(f"{base}/*.jsonl"), key=os.path.getmtime)[-1:] + +if not files: + print(f"트랜스크립트 없음: {base}") + sys.exit(0) + +cmd_pattern = re.compile(r"/?([\w:-]+)") + +for f in files: + model_skills, user_commands = Counter(), Counter() + for line in open(f): + try: + d = json.loads(line) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + content = (d.get("message") or {}).get("content") + texts = [] + if isinstance(content, list): + for b in content: + if not isinstance(b, dict): + continue + if b.get("type") == "tool_use" and b.get("name") == "Skill": + model_skills[b.get("input", {}).get("skill", "?")] += 1 + elif b.get("type") == "text": + texts.append(b.get("text", "")) + elif isinstance(content, str): + texts.append(content) + # 로컬 명령(/context 등)은 type=system, subtype=local_command 로 최상위 content에 기록됨 + if d.get("type") == "system" and d.get("subtype") == "local_command": + texts.append(d.get("content") or "") + if d.get("type") in ("user", "system"): + for t in texts: + for m in cmd_pattern.findall(t): + user_commands[m] += 1 + + print(f"## {os.path.basename(f)}") + print(f" 모델 Skill 호출: {dict(model_skills) or '없음'}") + print(f" 사용자 /명령: {dict(user_commands) or '없음'}") +EOF diff --git a/.claude/scripts/check-harness.sh b/.claude/scripts/check-harness.sh new file mode 100755 index 00000000..4e9cf26c --- /dev/null +++ b/.claude/scripts/check-harness.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# AI 하네스 무결성 검사 +# rules가 skill로, skill이 references로 포인터를 두는 점진적 공개 구조에서는 +# 파일 리네임/삭제 시 포인터가 조용히 썩는다. 이 스크립트가 그 드리프트를 잡는다. +# +# 검사 항목: +# 1. .agents/rules, .agents/skills 심볼릭 링크가 유효한지 +# 2. 모든 SKILL.md frontmatter에 name/description이 있고 name == 디렉터리명인지 +# 3. rules/agents/CLAUDE.md/AGENTS.md가 참조하는 skill 이름이 실제 존재하는지 +# 4. SKILL.md가 참조하는 references/*.md 파일이 실제 존재하는지 + +set -u +cd "$(cd "$(dirname "$0")/../.." && pwd)" || exit 1 + +FAIL=0 + +fail() { + echo "FAIL: $1" + FAIL=1 +} + +# --- 1. 심볼릭 링크 --- +for link in .agents/rules .agents/skills; do + if [ ! -L "$link" ]; then + fail "$link 가 심볼릭 링크가 아닙니다" + elif [ ! -e "$link" ]; then + fail "$link 심볼릭 링크가 깨졌습니다 → $(readlink "$link")" + fi +done + +# --- 2. SKILL.md frontmatter --- +for skill_md in .claude/skills/*/SKILL.md; do + dir=$(basename "$(dirname "$skill_md")") + frontmatter=$(awk '/^---$/{c++; next} c==1{print} c>=2{exit}' "$skill_md") + name=$(printf '%s\n' "$frontmatter" | sed -n 's/^name:[[:space:]]*//p' | head -1) + description=$(printf '%s\n' "$frontmatter" | sed -n 's/^description:[[:space:]]*//p' | head -1) + + [ -z "$name" ] && fail "$skill_md frontmatter에 name이 없습니다" + [ -z "$description" ] && fail "$skill_md frontmatter에 description이 없습니다" + if [ -n "$name" ] && [ "$name" != "$dir" ]; then + fail "$skill_md name '$name' 이 디렉터리명 '$dir' 과 다릅니다" + fi +done + +# --- 3. skill 이름 참조 검증 --- +# 관례: skill 참조는 "skill"이라는 단어가 있는 줄에서 백틱 kebab-case로 쓴다. +# (예: "Use the `api-contract-update` skill when ...") +scan_files=$(ls .claude/rules/*.md .claude/agents/*.md CLAUDE.md AGENTS.md 2>/dev/null) +refs=$(grep -hiE 'skill' $scan_files 2>/dev/null | + grep -oE '`[a-z][a-z0-9]*(-[a-z0-9]+)+`' | tr -d '`' | sort -u) + +for ref in $refs; do + # 파일명(.md 등)은 4번에서 별도 검사 + case "$ref" in *.*) continue ;; esac + if [ ! -f ".claude/skills/$ref/SKILL.md" ]; then + fail "skill '$ref' 참조가 깨졌습니다 (.claude/skills/$ref/SKILL.md 없음) — 참조 위치: $(grep -lE "\`$ref\`" $scan_files | tr '\n' ' ')" + fi +done + +# --- 4. SKILL.md → references/*.md 링크 검증 --- +# 스크립트가 런타임에 생성하는 산출물은 커밋되지 않으므로 제외 +GENERATED_REFS="database-manage/references/schema.md" + +for skill_md in .claude/skills/*/SKILL.md; do + skill_dir=$(dirname "$skill_md") + skill_name=$(basename "$skill_dir") + ref_files=$(grep -oE '(\]\(|`)references/[A-Za-z0-9._-]+\.(md|sh)' "$skill_md" | sed -E 's/^(\]\(|`)//' | sort -u) + for ref_file in $ref_files; do + case " $GENERATED_REFS " in + *" $skill_name/$ref_file "*) continue ;; + esac + if [ ! -f "$skill_dir/$ref_file" ]; then + fail "$skill_md 가 참조하는 $ref_file 이 없습니다" + fi + done +done + +# --- 5. .claude/scripts·hooks/*.sh 참조 검증 --- +# settings.json과 에이전트 frontmatter가 참조하는 훅 스크립트도 포함 +script_refs=$(grep -rhoE '\.claude/(scripts|hooks)/[A-Za-z0-9._-]+\.sh' \ + .claude/rules .claude/skills/*/SKILL.md .claude/agents .claude/settings.json \ + CLAUDE.md AGENTS.md 2>/dev/null | sort -u) +for script in $script_refs; do + [ -f "$script" ] || fail "스크립트 참조가 깨졌습니다: $script" + [ -x "$script" ] || fail "스크립트에 실행 권한이 없습니다: $script" +done + +if [ "$FAIL" -eq 0 ]; then + echo "OK: 하네스 무결성 검사 통과" +fi +exit "$FAIL" diff --git a/.claude/settings.json b/.claude/settings.json index 89d0d3af..30fa20cc 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,14 +16,20 @@ }, "permissions": { "deny": [ + "Read(**/.env)", + "Read(**/*.pem)", + "Read(**/*.key)", + "Read(**/*.p8)", "Edit(**/.env*)", "Edit(**/*.pem)", "Edit(**/*.key)", + "Edit(**/*.p8)", "Edit(**/*secret*)", "Edit(**/*credential*)", "Write(**/.env*)", "Write(**/*.pem)", "Write(**/*.key)", + "Write(**/*.p8)", "Write(**/*secret*)", "Write(**/*credential*)" ], diff --git a/.claude/skills/api-contract-update/SKILL.md b/.claude/skills/api-contract-update/SKILL.md new file mode 100644 index 00000000..6c19916a --- /dev/null +++ b/.claude/skills/api-contract-update/SKILL.md @@ -0,0 +1,60 @@ +--- +name: api-contract-update +description: Add or change Weeth API contracts. Use when creating or updating controllers, endpoints, CommonResponse usage, response/error codes, Swagger error examples, club/admin routes, request/response DTO contracts, or pagination responses. Do NOT use for pure domain logic with no API surface. +--- + +# API Contract Update + +Keep API changes consistent with Weeth's controller, response-code, DTO, and Swagger conventions. + +## Workflow + +### Step 1: Load Required Context + +Always read these rules first: + +- `.claude/rules/api-design.md` +- `.claude/rules/mapper-dto.md` +- `.claude/rules/exception-handling.md` + +Then read only the references needed for the task: + +- Controller routes or annotations: `references/controller-contract.md` +- Success/error code additions: `references/code-registry.md` +- Exception or Swagger error examples: `references/error-docs.md` +- List or paginated responses: `references/pagination-response.md` + +### Step 2: Inspect Local Patterns + +Check the nearest existing controller, DTO, mapper, response-code enum, and error-code enum in the same domain before editing. Prefer the local domain's current naming and package layout when it does not violate the rules. + +### Step 3: Apply the Contract + +- Controllers return `CommonResponse`. +- Success responses use the domain `*ResponseCode` enum. +- Request DTOs use Jakarta validation and `@field:Schema`. +- Response DTOs use `@Schema` and explicit nullable defaults for optional fields. +- Error codes use the `XDDNN` registry. +- Swagger error examples come from `@ApiErrorCodeExample` and `@ExplainError`, not hand-written response specs. + +### Step 4: Verify + +Run the narrowest relevant check: + +```bash +./gradlew ktlintFormat +./gradlew test --tests "*ControllerTest" +./gradlew test --tests "*UseCaseTest" +``` + +Use broader `./gradlew test` or `./gradlew clean build` when the API change crosses domains or touches shared response/error infrastructure. + +## Examples + +### Example: Add Endpoint + +User asks to add a new endpoint. Read `controller-contract.md`, inspect the domain controller and response code enum, add the controller method, DTO/mapper changes, and the success code. + +### Example: Add Error Code + +User asks to add validation or business failure handling. Read `code-registry.md` and `error-docs.md`, add the enum constant with `@ExplainError`, throw a `BaseException`, and declare the enum through `@ApiErrorCodeExample`. diff --git a/.claude/skills/api-contract-update/references/code-registry.md b/.claude/skills/api-contract-update/references/code-registry.md new file mode 100644 index 00000000..b509c780 --- /dev/null +++ b/.claude/skills/api-contract-update/references/code-registry.md @@ -0,0 +1,39 @@ +# API Code Registry + +## Code Format `XDDNN` + +| Part | Meaning | +|------|---------| +| X | 1=Success, 2=Domain Error, 3=Infra/Server Error, 4=Client/Validation Error | +| DD | Domain ID (01~99) | +| NN | Sequence within domain (00~99) | + +## Domain IDs + +| DD | Domain | Success | Domain Error | Infra Error | +|----|--------|---------|--------------|-------------| +| 01 | account | 10100~ | 20100~ | - | +| 02 | attendance | 10200~ | 20200~ | - | +| 03 | session | 10300~ | 20300~ | - | +| 04 | board | 10400~ | 20400~ | - | +| 05 | comment | 10500~ | 20500~ | - | +| 06 | file | 10600~ | 20600~ | 30600~ | +| 07 | penalty | 10700~ | 20700~ | - | +| 08 | schedule | 10800~ | 20800~ | - | +| 09 | user | 10900~ | 20900~ | - | +| 10 | cardinal | 11000~ | 21000~ | - | +| 11 | club | 11100~ | 21100~ | - | +| 12 | dashboard | 11200~ | 21200~ | - | +| 13 | university | 11300~ | - | 31300~ | +| 90 | jwt/auth | - | 29000~ | - | +| 99 | common | - | - | 39900~ | + +## Naming And Location + +- Success enum: `{Domain}ResponseCode` in `presentation/`. +- Error enum: `{Domain}ErrorCode` in `application/exception/`. +- Irregulars: + - schedule's error enum is `EventErrorCode`. + - JWT codes live in `global/auth/jwt/application/exception/JwtErrorCode`. + +Before assigning a code, inspect the existing enum and choose the next unused sequence for that domain/category. diff --git a/.claude/skills/api-contract-update/references/controller-contract.md b/.claude/skills/api-contract-update/references/controller-contract.md new file mode 100644 index 00000000..2a2c4e4a --- /dev/null +++ b/.claude/skills/api-contract-update/references/controller-contract.md @@ -0,0 +1,73 @@ +# Controller Contract Reference + +## Controller Shape + +```kotlin +@Tag(name = "USER", description = "사용자 API") +@RestController +@RequestMapping("/api/v1/users") +@ApiErrorCodeExample(UserErrorCode::class, JwtErrorCode::class) +class UserController( + private val userUseCase: UserUseCase, +) { + @GetMapping + @Operation(summary = "내 정보 조회") + fun getUser( + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success(USER_FIND_BY_ID_SUCCESS, userUseCase.find(userId)) +} +``` + +Required on every controller: + +- `@Tag` +- `@Operation(summary)` on each endpoint +- `@ApiErrorCodeExample` +- `@Valid` on request bodies +- `@Parameter(hidden = true)` on internal params such as `@CurrentUser` + +## Club-scoped API + +Club resources use `/api/v4/clubs/{clubId}/...`. + +`clubId` is Base62 TSID, so use both annotations together: + +```kotlin +@TsidParam +@TsidPathVariable clubId: Long +``` + +## Admin Endpoints + +The `admin` prefix comes before `clubs/{clubId}`: + +```text +/api/v4/admin/clubs/{clubId}/{resource} +``` + +This keeps the single SecurityConfig rule valid: + +```kotlin +.requestMatchers("/api/v4/admin/**").hasRole("ADMIN") +``` + +## Response Format + +Every response is wrapped in `CommonResponse` with `code`, `message`, and `data`. + +```kotlin +CommonResponse.success(USER_UPDATE_SUCCESS) +CommonResponse.success(USER_FIND_BY_ID_SUCCESS, data) +CommonResponse.error(errorCode) +``` + +Success enums implement `ResponseCodeInterface`, live in `domain/{domain}/presentation/{Domain}ResponseCode.kt`, and use Korean messages. + +## REST Conventions + +- Standard HTTP methods. +- Use `PATCH` for partial updates. +- Model actions as resource sub-paths, e.g. `POST /users/{userId}/activate`. +- Path variables identify resources. +- Query params filter and paginate, e.g. `?page=0&size=10&status=ACTIVE`. diff --git a/.claude/skills/api-contract-update/references/error-docs.md b/.claude/skills/api-contract-update/references/error-docs.md new file mode 100644 index 00000000..e0e9b7cb --- /dev/null +++ b/.claude/skills/api-contract-update/references/error-docs.md @@ -0,0 +1,42 @@ +# Error And Swagger Documentation Reference + +## Exception Structure + +Domain exceptions extend `BaseException(errorCode)`. + +```kotlin +class UserNotFoundException : BaseException(UserErrorCode.USER_NOT_FOUND) +``` + +Error-code enums implement `ErrorCodeInterface`. + +```kotlin +enum class UserErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") + USER_NOT_FOUND(20900, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), +} +``` + +Messages are Korean. Code numbering follows `XDDNN` in `code-registry.md`. + +There is no common error enum yet. `CommonExceptionHandler` uses `CommonResponse.createFailure()` directly. If a common error enum is introduced, use DD=99. + +## Swagger Auto-documentation + +Error examples are auto-registered in Swagger from annotations. Do not hand-edit response specs. + +- `@ApiErrorCodeExample(SomeErrorCode::class, ...)` on a controller class for shared errors. +- `@ApiErrorCodeExample(...)` on a method for endpoint-specific errors; method-level wins over class-level. +- `@ExplainError("...")` on each enum constant; it falls back to `message` when missing. +- `ExceptionDocController` is only for aggregated exception browsing in Swagger; do not add business logic there. + +## Adding A New Exception + +1. Add an enum constant to the proper `*ErrorCode` with `@ExplainError`. +2. Create or adjust the exception class extending `BaseException`. +3. Ensure the relevant controller or method declares the enum in `@ApiErrorCodeExample`. +4. Verify Swagger shows the new code when practical. diff --git a/.claude/skills/api-contract-update/references/pagination-response.md b/.claude/skills/api-contract-update/references/pagination-response.md new file mode 100644 index 00000000..58e45a3c --- /dev/null +++ b/.claude/skills/api-contract-update/references/pagination-response.md @@ -0,0 +1,30 @@ +# Pagination Response Reference + +List responses wrap items plus shared `PageResponse`. + +```kotlin +data class UserListResponse( + val users: List, + val page: PageResponse, +) + +data class PageResponse( + val pageNumber: Int, + val pageSize: Int, + val totalElements: Long, + val totalPages: Int, + val hasNext: Boolean, +) { + companion object { + fun from(page: Page<*>) = PageResponse( + pageNumber = page.number, + pageSize = page.size, + totalElements = page.totalElements, + totalPages = page.totalPages, + hasNext = page.hasNext(), + ) + } +} +``` + +Keep list response names domain-specific (`UserListResponse`, `PostListResponse`) and reuse `PageResponse` instead of redefining page metadata. diff --git a/.claude/skills/architecture-guide/SKILL.md b/.claude/skills/architecture-guide/SKILL.md index 9c3f01bc..3d1bd2f5 100644 --- a/.claude/skills/architecture-guide/SKILL.md +++ b/.claude/skills/architecture-guide/SKILL.md @@ -142,23 +142,36 @@ class CreateOrderUseCase( ```kotlin @Entity -class Post( - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long = 0, - var title: String, - var content: String, +class Post(title: String, content: String, author: User) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + var title: String = title + private set + + var content: String = content + private set + @Enumerated(EnumType.STRING) - var status: PostStatus = PostStatus.DRAFT, + var status: PostStatus = PostStatus.DRAFT + private set + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") - val author: User -) : BaseEntity() { + var author: User = author + private set companion object { - fun create(title: String, content: String, imageUrl: String?, author: User): Post { + fun create(title: String, content: String, author: User): Post { require(title.isNotBlank()) { "Title must not be blank" } require(content.length <= 5000) { "Content must be 5000 chars or less" } - return Post(title = title, content = content, author = author) + return Post( + title = title, + content = content, + author = author, + ) } } @@ -187,7 +200,8 @@ class Post( | Pattern | How | |---------|-----| | Creation | `companion object` factory (`create`, `of`) with `require` validation | -| State change | Named methods (`publish`, `softDelete`) — no public setters | +| JPA fields | Put `id`, soft-delete flags, and other JPA-managed fields in the body with defaults and `private set` | +| State change | Named methods (`publish`, `softDelete`) and `private set`; no public setters | | State validation | `check` for preconditions | | Business decision | `isEditableBy()`, `canPublish()` | @@ -239,7 +253,7 @@ interface FileStoragePort { ```kotlin @Component -class S3FileStorage( +class S3FileStorageAdapter( private val s3Client: S3Client, @Value("\${cloud.aws.s3.bucket}") private val bucket: String ) : FileStoragePort { @@ -269,6 +283,6 @@ class S3FileStorage( | Port (domain/port/) | Adapter (infrastructure/) | |------------------------------|---------------------------| -| `FileStoragePort` | `S3FileStorage` | -| `PushNotificationSenderPort` | `FcmPushNotificationSender` | -| `CacheStorePort` | `RedisCacheStore` | +| `FileStoragePort` | `S3FileStorageAdapter` | +| `PushNotificationSenderPort` | `FcmPushNotificationSenderAdapter` | +| `CacheStorePort` | `RedisCacheStoreAdapter` | diff --git a/.claude/skills/concurrency-safety/SKILL.md b/.claude/skills/concurrency-safety/SKILL.md new file mode 100644 index 00000000..90b0537c --- /dev/null +++ b/.claude/skills/concurrency-safety/SKILL.md @@ -0,0 +1,66 @@ +--- +name: concurrency-safety +description: Implement or review Weeth transaction and concurrency behavior. Use when adding or changing @Transactional boundaries, pessimistic or optimistic locks, concurrent counter updates, same-transaction cross-domain writes, lock timeouts, or external I/O around transactions. +--- + +# Concurrency Safety + +Use this skill for changes where transaction boundaries or concurrent writes can affect correctness. + +## Workflow + +### Step 1: Read Context + +Read `.claude/rules/transaction-concurrency.md` and inspect the affected UseCase, Repository, Entity, and tests. + +### Step 2: Classify The Risk + +- Command UseCase: state change, requires `@Transactional`. +- Query Service: read-only assembly, requires `@Transactional(readOnly = true)`. +- Counter or concurrent modification: consider `PESSIMISTIC_WRITE`. +- Read-heavy and write-rare aggregate: consider optimistic locking with `@Version`. +- External I/O: keep outside the database transaction when possible. + +### Step 3: Place Transaction Boundaries + +- Put `@Transactional` on UseCase methods only. +- Do not put `@Transactional` on Domain Services. +- Keep transactions short. +- Cross-domain writes may use repositories directly when the same transaction is required. + +### Step 4: Apply Locking Policy + +Pessimistic lock queries live in the Repository with an explicit timeout. + +```kotlin +@Lock(LockModeType.PESSIMISTIC_WRITE) +@QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) +@Query("SELECT f FROM Feed f WHERE f.id = :id") +fun findByIdWithLock(@Param("id") id: Long): Feed? +``` + +UseCases convert lock failures to domain errors. + +```kotlin +try { + val feed = feedRepository.findByIdWithLock(feedId) ?: throw FeedNotFoundException() +} catch (e: PessimisticLockingFailureException) { + throw ResourceLockedException() +} +``` + +Always acquire multiple locks in a consistent order. + +### Step 5: Verify + +- Unit-test domain failure mapping where possible. +- Add integration tests for repository lock behavior or transaction behavior when correctness depends on the database. +- Run the narrowest relevant test first, then broaden if shared transaction behavior changed. + +## Checklist + +- [ ] UseCase owns the transaction boundary. +- [ ] No external S3/HTTP call is inside a transaction unless deliberately required. +- [ ] Pessimistic locks have explicit timeout hints. +- [ ] Lock failures become user-friendly domain errors. +- [ ] Multiple locks are acquired in a stable order. diff --git a/.claude/skills/context-update/SKILL.md b/.claude/skills/context-update/SKILL.md index 3c21c9be..f430e754 100644 --- a/.claude/skills/context-update/SKILL.md +++ b/.claude/skills/context-update/SKILL.md @@ -1,6 +1,6 @@ --- name: context-update -description: Self-feedback skill that analyzes completed work and improves Claude Code context. Use when asked to "update context", "capture learnings", "improve context", or before compaction. Identifies reusable patterns and delegates to appropriate create skills. +description: Self-feedback skill that analyzes completed work and improves Claude Code context. Use when asked to "update context", "capture learnings", "improve context", or before compaction. Identifies reusable patterns, audits whether skills were actually invoked when their triggers matched, and delegates to appropriate create skills. allowed-tools: Read, Write, Edit, Glob, Grep, Bash --- @@ -12,9 +12,10 @@ Meta-skill for continuous improvement through self-reflection. After completing tasks, analyze work and: 1. Identify reusable patterns -2. Find gaps in existing context -3. Delegate to appropriate create skills -4. Generate improvement report +2. Audit skill invocation quality (were the right skills actually used?) +3. Find gaps in existing context +4. Delegate to appropriate create skills +5. Generate improvement report ## Workflow @@ -31,7 +32,44 @@ Review conversation for: [ ] Frequently used commands ``` -### Step 2: Categorize Findings +### Step 2: Audit Skill Invocations + +The rules layer is intentionally thin and points to skills — this only works if skills actually fire. For every task in the session, compare against the trigger descriptions in `.claude/skills/*/SKILL.md`: + +``` +[ ] Did the task match any skill's trigger description? +[ ] Was that skill actually invoked? +[ ] If invoked: was its workflow followed (references read, checklist applied), or loaded and ignored? +[ ] If not invoked: did the output violate any convention the skill protects? +``` + +Then act per finding: + +| Finding | Diagnosis | Action | +|---------|-----------|--------| +| Trigger matched, skill not invoked, output still correct | Trigger phrasing too narrow, or the always-on rules already covered it | Strengthen the skill `description` trigger phrases to match how the task was actually phrased | +| Trigger matched, skill not invoked, convention violated | Pointer failure with real cost | Strengthen the `description` AND promote the violated invariant into `.claude/rules/` (always-on) via `rule-create` | +| Skill invoked but was unnecessary | Trigger too broad | Tighten the `description`; extend its "Do NOT use" boundary | +| Skill invoked but workflow skipped/ignored | Skill body unclear or too long | Simplify the workflow; move detail into `references/` | + +**How to verify invocations:** the current session's conversation is the primary source — model-invoked skills appear as `Skill` tool calls, user-typed `/skill` commands appear as `` markers. For an objective count, run: + +```bash +bash .claude/scripts/audit-skill-invocations.sh # 현재 세션만 (기본) +bash .claude/scripts/audit-skill-invocations.sh --last 5 # 과거 추세 확인 시에만 +``` + +Default is current-session-only to keep context usage small; pull past sessions only when investigating a trend. + +Note: the transcript proves *whether* a skill fired; *whether it should have fired* and *whether its workflow was followed* require reading the conversation — that judgment is this skill's job, not a script's. + +Also run the harness integrity check and include the result in the report: + +```bash +bash .claude/scripts/check-harness.sh +``` + +### Step 3: Categorize Findings | Signal | Category | Action | |--------|----------|--------| @@ -39,7 +77,7 @@ Review conversation for: | Convention discovered | Rule | Invoke `rule-create` | | One-off task | None | Document only | -### Step 3: Check for Duplicates +### Step 4: Check for Duplicates Before delegating, search existing context: @@ -49,7 +87,7 @@ Glob: .claude/rules/*.md Grep: pattern="{keyword}" path=".claude/" ``` -### Step 4: Delegate Creation +### Step 5: Delegate Creation For each identified improvement, invoke the appropriate skill: @@ -57,7 +95,7 @@ For each identified improvement, invoke the appropriate skill: - **Rule update needed** → Invoke `rule-create` - **Neither applies** → Update MEMORY.md or document in report only -### Step 5: Generate Report +### Step 6: Generate Report ```markdown ## Context Update Report @@ -65,6 +103,13 @@ For each identified improvement, invoke the appropriate skill: ### Session Summary - Tasks: {list of completed tasks} - Patterns identified: {count} +- Harness integrity check: {OK / FAIL details} + +### Skill Invocation Audit + +| Task | Expected Skill | Invoked? | Outcome | Action Taken | +|------|---------------|----------|---------|--------------| +| {task} | {skill or none} | ✓/✗ | OK / convention violated | description 보강 / rule 승격 / 없음 | ### Actions Taken diff --git a/.claude/skills/git-prepare/SKILL.md b/.claude/skills/git-prepare/SKILL.md new file mode 100644 index 00000000..461844ec --- /dev/null +++ b/.claude/skills/git-prepare/SKILL.md @@ -0,0 +1,64 @@ +--- +name: git-prepare +description: Prepare Weeth git work. Use when asked to create a branch, sync a branch, prepare a commit, write a commit message, commit changes, or prepare a PR. Do NOT use for ordinary code edits with no git operation requested. +--- + +# Git Prepare + +Use this skill only when the task includes a git operation. + +## Workflow + +### Step 1: Inspect Worktree + +Run `git status --short` and identify unrelated user changes. Do not revert, restage, or include unrelated files unless the user explicitly asks. + +### Step 2: Branch Naming + +Use these patterns when creating branches: + +| Type | Pattern | Example | +|------|---------|---------| +| Feature | `feat/{ticket}-description` | `feat/WTH-123-user-login` | +| Bugfix | `fix/{ticket}-description` | `fix/WTH-456-token-expiry` | +| Refactor | `refactor/{ticket}-description` | `refactor/WTH-789-cleanup` | +| Hotfix | `hotfix/description` | `hotfix/critical-auth-bug` | +| Release | `release/version` | `release/v1.2.0` | + +### Step 3: Sync Policy + +Sync shared branches with merge: + +```bash +git merge origin/{target-branch} +``` + +Do not rebase shared branch history. + +### Step 4: Pre-commit Checks + +Before committing, run the checks appropriate to the changed files: + +```bash +./gradlew ktlintFormat +./gradlew test +``` + +Also review changed files and check for sensitive data such as `.env`, credentials, keys, or production/dev application config. + +### Step 5: Commit Message + +Format: + +```text +type: 한글 메시지 +``` + +Rules: + +- Type is lowercase English. +- Message is Korean by default for this repository, 50 characters or fewer when practical. +- No trailing period. +- Reference issues when applicable, e.g. `fix: 로그인 오류 수정 (#123)`. + +Allowed types: `feat`, `fix`, `refactor`, `test`, `docs`, `style`, `chore`, `perf`, `ci`, `build`. diff --git a/.claude/skills/kotlin-migration/SKILL.md b/.claude/skills/kotlin-migration/SKILL.md deleted file mode 100644 index 25f28fb6..00000000 --- a/.claude/skills/kotlin-migration/SKILL.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -name: kotlin-migration -description: "Java → Kotlin migration skill. Follows Test-First methodology: write tests → migrate → refactor → verify with ktlint." -allowed-tools: Glob, Grep, Read, Edit, Write, Bash ---- - -# Kotlin Migration - -Migrate Java to idiomatic Kotlin with Test-First methodology. -**All output MUST be written in Korean (한국어).** - -## Batch Migration Strategy - -For large-scale migrations, split work into manageable batches and get user confirmation between batches. - -### Recommended Batch Units -| Scope | Batch Size | Example | -|-------|-----------|---------| -| Single Domain | 3-5 files per batch | `Feed`, `FeedRepository`, `CreateFeedUseCase` | -| Cross-Domain | 1 domain at a time | Complete `feed` domain before `user` domain | -| Entity + Dependencies | Entity → Repository → Services | Migrate in dependency order | - -### Batch Workflow -1. **Analyze scope** - List all files to migrate -2. **Propose batch plan** - Split into logical batches, present to user -3. **Execute batch** - Migrate files in current batch -4. **Verify & Report** - Run tests, report results to user -5. **Get confirmation** - Wait for user approval before next batch -6. **Repeat** - Continue with next batch - -### Example Batch Plan -``` -Batch 1: Feed Entity Layer - - Feed.java → Feed.kt - - FeedRepository.java → FeedRepository.kt - -Batch 2: Feed Application Layer - - CreateFeedUseCase.java → CreateFeedUseCase.kt - - GetFeedQueryService.java → GetFeedQueryService.kt - - FeedMapper.java → FeedMapper.kt -``` - -**Always ask user before proceeding to next batch.** - ---- - -## Workflow (MUST follow in order) - -### 1. Pre-Migration Test -- Analyze Java code behavior and dependencies -- **Write ONLY essential tests** that verify critical business logic -- Use Kotest + MockK -- Run tests against Java code to confirm they pass - -**Tests to Write (HIGH value):** -- Business logic with conditions/branching -- Exception scenarios -- Complex calculations or transformations -- Transaction boundaries and side effects - -**Tests to SKIP (LOW value):** -- JPA basic CRUD (findById, save, delete, findAll) -- Simple getter/setter or DTO field mapping -- Obvious pass-through methods -- Framework-provided functionality - -### 2. Migration - -#### File Move and Conversion -**Use `git mv` instead of delete + create to preserve history.** - -```bash -git mv src/main/java/domain/{domain}/{path}/{File}.java \ - src/main/kotlin/domain/{domain}/{path}/{File}.kt -``` - -Then convert content from Java → Kotlin syntax using Edit tool. - -```bash -./gradlew test # Run pre-written tests -``` - -#### Migration Guide -- Convert preserving existing architecture patterns -- Apply Kotlin idioms: data class for DTOs, val over var, nullable only when needed -- Maintain Single Responsibility Principle -- Run tests after migration - -### 3. Refactor -- Replace Java patterns with Kotlin idioms (scope functions, safe calls, when expressions) -- Run tests after each refactoring - -### 4. Verify -```bash -./gradlew ktlintFormat && ./gradlew ktlintCheck && ./gradlew test -``` - -## Project Patterns - -### Test Style (Kotest) -**DescribeSpec** for business logic tests: -```kotlin -class CreatePostUseCaseTest : DescribeSpec({ - val postRepository = mockk() - val userReader = mockk() - val postMapper = mockk() - val useCase = CreatePostUseCase(postRepository, userReader, postMapper) - - describe("execute") { - context("with valid request") { - it("should create and save post") { ... } - } - context("when user not found") { - it("should throw UserNotFoundException") { ... } - } - } -}) -``` - -### Fixture Pattern -```kotlin -object UserTestFixture { - fun createUser( - id: Long = 1L, - email: String = "test@example.com" - ) = User(id = id, email = email, status = UserStatus.ACTIVE) -} -``` -Location: `src/test/kotlin/{domain}/test/fixture/` - -## Output Format - -Use the following Korean template for reporting: - -```markdown -# 마이그레이션 리포트 - -## 대상 파일 -| 파일 | 상태 | 비고 | -|------|------|------| -| `Feed.java` → `.kt` | ✅ 완료 | Rich Domain Model 적용 | -| `FeedRepository.java` → `.kt` | ✅ 완료 | 테스트 불필요 | -| `CreateFeedUseCase.java` → `.kt` | ✅ 완료 | 테스트 3건 통과 | - -## 작성된 테스트 -- `CreateFeedUseCaseTest.kt`: 3건 (정상 생성, 사용자 미존재, 검증 실패) - -## 주요 변환 사항 -- `Optional.orElseThrow()` → `?: throw` 패턴 적용 -- MapStruct → 수동 Mapper 패턴으로 전환 -- Lombok 제거, Kotlin 생성자 주입 적용 - -## 검증 결과 -- ktlintCheck: ✅ 통과 -- 전체 테스트: ✅ 통과 (N건) - -## 다음 배치 -Batch 3: Feed Presentation Layer (FeedController) 진행할까요? -``` - -## Rules -- **All output in Korean (한국어)** -- Never skip tests -- Never migrate without passing tests first -- Fix Kotlin code if tests fail (not tests) -- Always use `git mv` for file moves -- Ask user before proceeding to next batch diff --git a/.claude/skills/test-create/SKILL.md b/.claude/skills/test-create/SKILL.md index 8eeb2e4e..6b3bbf15 100644 --- a/.claude/skills/test-create/SKILL.md +++ b/.claude/skills/test-create/SKILL.md @@ -1,52 +1,49 @@ --- name: test-create -description: Generate unit and integration tests for Java/Kotlin Spring Boot applications using JUnit 5/Kotest + Mockito/MockK. Use when the user asks to "write tests", "create test", "generate test", "add test coverage", or mentions testing specific classes/methods. Supports service tests, controller tests, and test fixtures. +description: Generate unit and integration tests for Kotlin Spring Boot applications using Kotest, MockK, springmockk, and Testcontainers. Use when the user asks to "write tests", "create test", "generate test", "add test coverage", or mentions testing specific classes/methods. Supports UseCase tests, controller tests, entity tests, and test fixtures. disable-model-invocation: true allowed-tools: Read, Write, Edit, Glob, Grep, Bash --- # Test Generator -Generate comprehensive tests for: $ARGUMENTS +Generate focused Kotlin tests for the requested target. ## Workflow ### Step 1: Analyze Target Code 1. Read the source file to understand: - - Language (Java or Kotlin) - Class type (Controller, Service/UseCase, Repository, Entity) - Dependencies (injected fields) - Public methods to test +2. Read `.claude/rules/testing.md` before writing tests. ### Step 2: Determine Test Location ``` src/test/ -├── java/com/example/app/domain/{domain}/ -│ ├── application/usecase/ # UseCase unit tests -│ ├── domain/service/ # Service unit tests -│ ├── presentation/ # Controller tests -│ └── fixture/ # Test fixtures -└── kotlin/com/example/app/domain/{domain}/ +└── kotlin/com/weeth/domain/{domain}/ ├── application/usecase/ + │ ├── command/ + │ └── query/ + ├── domain/entity/ ├── domain/service/ ├── presentation/ └── fixture/ ``` -Test file naming: `{ClassName}Test.{java|kt}` +Test file naming: `{ClassName}Test.kt` ### Step 3: Choose Test Style -| Language | Class Type | Test Style | Framework | -|----------|------------|------------|-----------| -| Java | Service/UseCase | JUnit 5 + Mockito | @ExtendWith(MockitoExtension.class) | -| Java | Controller | @WebMvcTest | MockMvc + @MockBean | -| Kotlin | Service/UseCase (recommended) | DescribeSpec | Kotest + MockK | -| Kotlin | Service/UseCase (BDD) | BehaviorSpec | Kotest + MockK | -| Kotlin | Validation/Simple | StringSpec | Kotest | -| Kotlin | Controller | @WebMvcTest + DescribeSpec | MockMvc + @MockkBean | +| Class Type | Test Style | Framework | +|------------|------------|-----------| +| Command UseCase | DescribeSpec | Kotest + MockK | +| QueryService | DescribeSpec | Kotest + MockK | +| Entity / Domain Service | DescribeSpec or BehaviorSpec | Kotest | +| Validation / Simple Value Object | StringSpec | Kotest | +| Controller | @WebMvcTest + DescribeSpec | MockMvc + @MockkBean | **Decision Guide:** - **DescribeSpec**: Default choice for service tests. Clean describe/context/it structure. @@ -68,9 +65,9 @@ For each public method, create tests for: 3. Mock all dependencies 4. Implement test cases following given/when/then pattern 5. Add verification for mock interactions +6. For shared MockK mocks in `DescribeSpec`, clear mocks in `beforeTest` and restub defaults after clearing See detailed examples: -- Java: [references/java-examples.md](references/java-examples.md) - Kotlin: [references/kotlin-examples.md](references/kotlin-examples.md) ### Step 6: Run Tests @@ -93,21 +90,6 @@ See detailed examples: Create reusable test data builders in `fixture/` directory: -**Java:** -```java -public class UserTestFixture { - public static User createUser(Long id, String email) { - return User.builder() - .id(id) - .name("Test User") - .email(email) - .status(UserStatus.ACTIVE) - .build(); - } -} -``` - -**Kotlin:** ```kotlin object UserTestFixture { fun createUser( @@ -126,7 +108,18 @@ Use @WebMvcTest for controller layer tests: - Verify JSON serialization - Check status codes and response structure -See [references/java-examples.md](references/java-examples.md) and [references/kotlin-examples.md](references/kotlin-examples.md) for complete examples. +See [references/kotlin-examples.md](references/kotlin-examples.md) for complete examples. + +## Mock Lifecycle in DescribeSpec + +MockK mocks are not cleared between `it` blocks. Accumulated invocations can break `verify(exactly = N)`. When mocks are shared, clear and restub them before each test: + +```kotlin +beforeTest { + clearMocks(repository) + every { repository.save(any()) } answers { firstArg() } +} +``` ## Checklist @@ -135,6 +128,7 @@ Before completing: - [ ] Failure/exception case test written - [ ] Edge case test written (empty, null, max value) - [ ] Mock verification added (verify) +- [ ] Shared mocks cleared and restubbed in `beforeTest` when using `DescribeSpec` - [ ] Fixture created and reused - [ ] Tests run successfully (`./gradlew test --tests "{TestClass}"`) @@ -142,12 +136,7 @@ Before completing: ### Test Compilation Errors -**Missing imports**: Add these dependencies to build.gradle: -```groovy -testImplementation 'org.springframework.boot:spring-boot-starter-test' -testImplementation 'io.kotest:kotest-runner-junit5:5.x.x' // Kotlin only -testImplementation 'io.mockk:mockk:1.x.x' // Kotlin only -``` +**Missing imports**: Check the existing Gradle test dependencies before adding anything. Prefer existing Kotest, MockK, springmockk, and Testcontainers versions already configured in the project. ### MockK "no answer found" errors @@ -169,5 +158,4 @@ userRepository.findById(1L) // Will include deleted entities ## References -- [Java Examples (JUnit 5 + Mockito)](references/java-examples.md) - [Kotlin Examples (Kotest + MockK)](references/kotlin-examples.md) diff --git a/.claude/skills/test-create/references/java-examples.md b/.claude/skills/test-create/references/java-examples.md deleted file mode 100644 index 1049a764..00000000 --- a/.claude/skills/test-create/references/java-examples.md +++ /dev/null @@ -1,119 +0,0 @@ -# Java Test Examples - -## JUnit 5 + Mockito Service Test - -```java -@ExtendWith(MockitoExtension.class) -class UserGetServiceTest { - @Mock - private UserRepository userRepository; - - @InjectMocks - private UserGetService userGetService; - - @Test - @DisplayName("should return user when exists") - void findById_whenUserExists_shouldReturnUser() { - // given - User user = UserTestFixture.createUser(1L, "test@example.com"); - when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); - - // when - User result = userGetService.findById(1L); - - // then - assertThat(result).isEqualTo(user); - verify(userRepository).findByIdAndDeletedAtIsNull(1L); - } - - @Test - @DisplayName("should throw exception when user not found") - void findById_whenUserNotFound_shouldThrowException() { - // given - when(userRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> userGetService.findById(999L)) - .isInstanceOf(UserNotFoundException.class); - } -} -``` - -## Controller Test - -```java -@WebMvcTest(UserController.class) -@Import(SecurityConfig.class) -class UserControllerTest { - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @MockBean - private CreateUserUsecase createUserUsecase; - - @Test - @DisplayName("POST /api/v1/users - should create user") - void createUser_shouldReturnCreatedUser() throws Exception { - // given - CreateUserRequest request = new CreateUserRequest("John", "john@example.com"); - UserResponse response = UserResponse.builder().id(1L).name("John").build(); - when(createUserUsecase.execute(any())).thenReturn(response); - - // when & then - mockMvc.perform(post("/api/v1/users") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(1)) - .andExpect(jsonPath("$.name").value("John")); - } -} -``` - -## Test Fixture - -```java -public class UserTestFixture { - public static User createUser(Long id, String email) { - return User.builder() - .id(id) - .name("Test User") - .email(email) - .status(UserStatus.ACTIVE) - .build(); - } - - public static User createUser() { - return createUser(1L, "test@example.com"); - } - - public static CreateUserRequest createRequest() { - return new CreateUserRequest("Test User", "test@example.com"); - } -} -``` - -## Mockito Usage - -```java -// Stubbing -when(repository.findById(1L)).thenReturn(Optional.of(user)); -when(repository.save(any())).thenReturn(user); - -// Verify -verify(repository).save(any()); -verify(repository, times(1)).findById(1L); -verify(repository, never()).delete(any()); - -// Argument capture -ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); -verify(repository).save(captor.capture()); -User captured = captor.getValue(); - -// BDD style -given(repository.findById(1L)).willReturn(Optional.of(user)); -then(repository).should().save(any()); -``` \ No newline at end of file diff --git a/.claude/skills/verify-implementation/SKILL.md b/.claude/skills/verify-implementation/SKILL.md new file mode 100644 index 00000000..525082c5 --- /dev/null +++ b/.claude/skills/verify-implementation/SKILL.md @@ -0,0 +1,71 @@ +--- +name: verify-implementation +description: Verify completed implementation work via the implementation-evaluator subagent before reporting done. Use after finishing any task that has an active task file (.claude/tasks/current-task.md with a populated 수용 기준 section), or when asked to "검증해줘"/"평가해줘". Do NOT auto-invoke for trivial work without a task file (docs, config values, typo/single-line fixes, formatting) — those are covered by ktlint hook + compile + CI. +--- + +# Verify Implementation + +Completion gate: the builder (main session) never declares PASS on its own work. +A clean-context, read-only evaluator grades the diff against the task file. + +## Step 1: Confirm an active task file + +An **active** task file is `.claude/tasks/current-task.md` that exists AND has a +`## 수용 기준` section with at least one checklist item (`- [ ]` / `- [x]`). The bare +template (`.claude/tasks/template.md`) and a placeholder current-task.md with no criteria +do NOT count — they must not trigger evaluation. + +- Auto-invoked path: the active task file already exists (it gated this skill). +- Manually invoked without one: copy `template.md` to `current-task.md`, fill it in + (quote the user's request verbatim + acceptance criteria, format in + `.claude/rules/verification.md`), then proceed. + +## Step 2: Fix the diff baseline and record pre-spawn hashes + +Decide the baseline (commit where this work started — usually `HEAD` if uncommitted, +or the branch point). Then record: + +```bash +git diff | shasum -a 256 +git status --porcelain | shasum -a 256 +``` + +## Step 3: Spawn the evaluator + +Spawn `implementation-evaluator` via the Agent tool. Pass ONLY: + +- the diff baseline (commit/branch) +- the task file path + +Do NOT pass a requirements summary or any claim about what was implemented — the task +file is the sole source of requirements. On re-evaluation rounds, additionally pass the +previous findings labeled as "이전 라운드 미비점 — 고쳐졌는지 특히 확인". + +## Step 4: Re-check hashes after the evaluator returns + +Re-run the two hash commands from Step 2. **Any mismatch = the evaluator touched the +tree = the verdict is VOID.** Report this to the user instead of using the verdict. + +## Step 5: Handle the verdict + +- `PASS` → go to Step 6. +- `NEEDS_WORK` → apply the findings, then re-run from Step 2 with a **freshly spawned + evaluator that re-grades all criteria** (fixes can regress other criteria; previous + findings are an appendix, not the scope). Maximum 2 retries — after the 3rd + NEEDS_WORK, stop and escalate to the user with the full findings history. + +## Step 6: Report completion + +Quote the evaluator's verdict and per-criterion evidence in the completion report. +Never paraphrase a NEEDS_WORK into "mostly done". + +## Step 7: Append the metrics log + +```bash +mkdir -p .claude/metrics +echo '{"date":"YYYY-MM-DD","task":"","tier":"evaluated","first_verdict":"PASS|NEEDS_WORK","failed_criteria":[...],"retry_count":N}' >> .claude/metrics/eval-log.jsonl +``` + +This log decides later, with data instead of anecdotes: first-PASS rate (criteria too +loose/strict), escape rate (issues the evaluator missed but human PR review caught), +opus→sonnet downgrade, and whether the Stop-hook compile gate (plan phase 3) is needed. diff --git a/.claude/tasks/template.md b/.claude/tasks/template.md new file mode 100644 index 00000000..66fc82d6 --- /dev/null +++ b/.claude/tasks/template.md @@ -0,0 +1,11 @@ +# + +## 사용자 요청 (원문 인용) +> + +## 수용 기준 +- [ ] +- [ ] ... + +## 제외 범위 +- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1d4298c..9b283234 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,9 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - name: Check AI harness integrity + run: bash .claude/scripts/check-harness.sh + - name: Set up JDK 21 uses: actions/setup-java@v5 with: diff --git a/.gitignore b/.gitignore index 558383b0..53166033 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,9 @@ src/main/resources/*.p8 src/test/resources/*.env .env.local .env.*.local + +### Claude Code ### +# 로컬 평가 텔레메트리 — 분석은 로컬에서, 결론만 docs/plan에 반영 +.claude/metrics/ +# 작업별로 갈아끼우는 평가 입력 파일 — template.md만 추적 +.claude/tasks/current-task.md diff --git a/AGENTS.md b/AGENTS.md index 175f2461..b0ac644b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,7 @@ Core rules: ## Rule Files -Detailed project rules live in `.agents/rules/`. Read the relevant file before making changes in that area: +Detailed project rules live in `.agents/rules/` (a symlink to `.claude/rules/` — the single source of truth; edit files there). Read the relevant file before making changes in that area: - API and response codes: `.agents/rules/api-design.md` - Package structure and layer rules: `.agents/rules/architecture.md` @@ -72,6 +72,7 @@ Detailed project rules live in `.agents/rules/`. Read the relevant file before m - Mapper and DTO patterns: `.agents/rules/mapper-dto.md` - Test style and fixtures: `.agents/rules/testing.md` - Transactions and concurrency: `.agents/rules/transaction-concurrency.md` +- Verification gate (task-file-first, no self-PASS): `.agents/rules/verification.md` ## Testing @@ -93,5 +94,5 @@ For shared MockK mocks in `DescribeSpec`, clear mocks in `beforeTest` and restub ## Codex Skills -Project-specific Codex skills are stored in `.agents/skills/`. -Codex scans `.agents/skills` from the current working directory up to the repository root, so these skills are available without a separate install step when Codex starts in this repository. +Project-specific skills live in `.claude/skills/` as the single source of truth. +`.agents/skills` is a symlink to `.claude/skills` so Codex can discover the same skills without a separate install step. diff --git a/CLAUDE.md b/CLAUDE.md index 397539da..4b4a15d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Weeth Server is a community platform backend built with Spring Boot 3.5.10. The codebase has completed **Java → Kotlin migration** — all code is Kotlin. +Weeth Server is a community platform backend built with Spring Boot 3.5.10. All code is Kotlin (Java → Kotlin migration complete; Lombok/MapStruct removed — do not reintroduce them). ## Build & Development Commands @@ -25,84 +25,29 @@ Weeth Server is a community platform backend built with Spring Boot 3.5.10. The ## Architecture -### Layer Structure ``` presentation → application → domain ← infrastructure ``` -- **presentation/**: Controllers, ResponseCode enums -- **application/**: UseCase (command/query), DTOs, Mappers, Exceptions, Validators -- **domain/**: Entities (Rich Domain Model), VO, Enums, Repositories, Ports, Domain Services -- **infrastructure/**: Port implementations (Adapters for S3, external APIs, etc.) -### Domain Package Layout -Each of the 13 domains (`user`, `attendance`, `session`, `schedule`, `board`, `comment`, `file`, `penalty`, `account`, `cardinal`, `club`, `dashboard`, `university`) follows: -``` -domain/{name}/ -├── application/ -│ ├── dto/request/, dto/response/ -│ ├── mapper/ -│ ├── usecase/command/ # @Transactional, state-changing -│ ├── usecase/query/ # @Transactional(readOnly=true), returns DTOs -│ └── exception/ # {Domain}ErrorCode enum + exception classes -├── domain/ -│ ├── entity/ # JPA entities with business logic -│ ├── enums/ -│ ├── repository/ # JpaRepository + Reader interfaces -│ ├── port/ # Interfaces for external systems -│ └── service/ # Multi-entity logic only (no thin wrappers) -├── infrastructure/ # Port implementations -└── presentation/ - ├── {Domain}Controller.kt - └── {Domain}ResponseCode.kt -``` +13 domains (`user`, `attendance`, `session`, `schedule`, `board`, `comment`, `file`, `penalty`, `account`, `cardinal`, `club`, `dashboard`, `university`), each following the package layout in `.claude/rules/architecture.md`. -### Key Patterns -- **UseCase = orchestration only** — business logic lives in Entities (Rich Domain Model) -- **No thin wrapper services** — UseCases call Repositories directly, no GetService/SaveService +Core principles (details in the rule files): +- **Rich Domain Model** — business logic lives in Entities; UseCase = orchestration only +- **No thin wrapper services** — UseCases call Repositories directly - **Port-Adapter** — domain owns Port interfaces, infrastructure implements them -- **Cross-domain reads** via Reader interfaces; cross-domain writes via Repository directly -- **`@Transactional` on UseCase only** — Domain Services have no transaction annotations - -### Response Format -All API responses wrapped in `CommonResponse` with code/message/data. 5-digit code format `XDDNN`: X=category (1=Success, 2=Domain Error, 3=Infra Error, 4=Client Error), DD=domain ID, NN=sequence. See `.claude/rules/api-design.md` for full domain ID mapping and code ranges. +- **Cross-domain reads** via Reader interfaces; **`@Transactional` on UseCase only** ### Authentication -JWT with symmetric key (JJWT 0.13.0), OAuth2 via Kakao and Apple. `@CurrentUser` annotation injects authenticated user ID into controller methods. - -## Testing +JWT with symmetric key (JJWT), OAuth2 via Kakao and Apple. `@CurrentUser` annotation injects authenticated user ID into controller methods. -- **Kotest** (DescribeSpec default, BehaviorSpec for BDD, StringSpec for simple logic) -- **MockK** + **springmockk** for mocking -- **Testcontainers** for MySQL integration tests -- **Fixture pattern**: `{Entity}TestFixture` objects with factory methods in `fixture/` directories -- Test architecture mirrors source: mock Repository/Reader/Port in UseCase tests, mock Port (not adapter) in application tests - -## Kotlin Migration Status - -**✅ Complete** — 452 Kotlin files (100%) - -- Java → Kotlin migration fully complete -- Lombok and MapStruct dependencies removed -- All 16 mappers migrated to manual `@Component` Mapper classes (see `.claude/rules/mapper-dto.md`) -- Entity fields use `private set` for Rich Domain Model pattern (see architecture.md) +### Notable Settings - OSIV disabled: `spring.jpa.open-in-view: false` in `application.yml` +- All API responses wrapped in `CommonResponse`; 5-digit code format `XDDNN` — see `.claude/rules/api-design.md` -## Kotlin Conventions - -- Use `?.`, `?:`, `requireNotNull` — avoid `!!` -- Entities: regular `class` (not `data class`); DTOs: `data class` -- Entity setters: `private set` to enforce business logic via named methods -- Example: - ```kotlin - var name: String - private set +## Testing - fun updateName(newName: String) { - require(newName.isNotBlank()) { "Name cannot be empty" } - this.name = newName - } - ``` +Kotest + MockK + springmockk + Testcontainers. Conventions, fixture pattern, and mock lifecycle rules are in `.claude/rules/testing.md`. ## Detailed Rules -Architecture, code style, testing, API design, exception handling, transactions, and git conventions are documented in `.claude/rules/`. Refer to those files for comprehensive guidance on each topic. +Architecture, code style, testing, API design, exception handling, transactions, and git conventions are documented in `.claude/rules/` — these are loaded automatically and are the single source of truth. `.agents/rules` (Codex) is a symlink to `.claude/rules`; edit only `.claude/rules`. diff --git a/build.gradle.kts b/build.gradle.kts index 2e025ded..d03c861d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,6 +37,7 @@ val testcontainersBomVersion = "2.0.3" val kotestVersion = "5.9.1" val mockkVersion = "1.13.14" val springmockkVersion = "4.0.2" +val konsistVersion = "0.17.3" dependencies { // --- Kotlin --- implementation("org.jetbrains.kotlin:kotlin-reflect") @@ -103,6 +104,9 @@ dependencies { testImplementation("io.mockk:mockk:$mockkVersion") testImplementation("com.ninja-squad:springmockk:$springmockkVersion") + // Konsist (아키텍처 규칙 테스트 — .claude/rules/architecture.md의 결정론적 게이트) + testImplementation("com.lemonappdev:konsist:$konsistVersion") + // Testcontainers (BOM) testImplementation(platform("org.testcontainers:testcontainers-bom:$testcontainersBomVersion")) testImplementation("org.testcontainers:junit-jupiter") diff --git a/docs/plan/ai-verification-hardening-plan.md b/docs/plan/ai-verification-hardening-plan.md new file mode 100644 index 00000000..e77b666c --- /dev/null +++ b/docs/plan/ai-verification-hardening-plan.md @@ -0,0 +1,319 @@ +# AI 검증 강화 계획 — 생성자-평가자 분리 + 결정론적 가드레일 + +> 참고 자료: +> - [anthropics/cwc-long-running-agents](https://github.com/anthropics/cwc-long-running-agents) — 생성자-평가자 분리, default-FAIL 계약, 훅 가드레일 +> - [Augment Code: Spec-Driven Development](https://www.augmentcode.com/guides/claude-code-spec-driven-development) — 검증 가능한 완료 기준, 인프라 기반 강제 +> - [Josh McDonald: SDD with Claude Code](https://joshmcdonald.medium.com/running-a-small-team-on-a-big-project-spec-driven-development-with-claude-code-9a1b97f58551) — 훅 4종 + 다단계 리뷰 파이프라인 + +## 1. 검토 결론 + +### 현재 상태 (이미 갖춘 것) + +| 영역 | 현재 구현 | 비고 | +|------|----------|------| +| 포맷/린트 훅 | `ktlint-format.sh` (PostToolUse, exit 2 피드백) | 결정론적 가드레일 ①호 — 이미 운영 중 | +| 하네스 무결성 | `check-harness.sh` (CI 게이트) | 포인터 드리프트 결정론적 검증 | +| 타입 체크 CI 게이트 | `gradlew clean test` (CI) | Kotlin 정적 타입 → 환각 시그니처는 컴파일 단계에서 이미 차단 | +| 개발 워크플로 | 메인 세션 + 룰/스킬/훅 (빌더 에이전트 2종은 거의 미사용) | **구현한 세션이 자기 컨텍스트로 리뷰·완료 보고 (self-grading)** | + +### 평가 + +**① 생성자-평가자 분리** — **도입 가치 높음. 단, 메인 세션 워크플로에 맞춘다.** +실제 개발은 빌더 서브에이전트가 아니라 **메인 세션에서 룰 + 스킬 + 훅으로** +이루어진다. 즉 self-grading 문제의 실제 발생 지점은 "구현한 메인 세션이 자기 +컨텍스트로 리뷰하고 완료를 보고하는 것"이다. 평가자의 핵심 가치(깨끗한 컨텍스트, +쓰기 권한 없음)는 서브에이전트 형태여야만 얻을 수 있으므로 **평가자만 유일한 +서브에이전트로 정의**하고, 호출은 메인 세션이 스킬을 통해 하도록 설계한다. +기존 빌더 에이전트(feature-developer / system-architect)는 거의 사용하지 않으므로 +이번 계획의 연결 대상에서 제외한다. + +**① default-FAIL 계약 (test-results.json + verify-gate 훅)** — **축소 도입.** +원본 패턴은 무인 장시간 루프(밤새 자율 실행)용이다. 이 프로젝트는 대화형 작업 + +사람 PR 리뷰가 기본이므로 파일 기반 계약 + PreToolUse 증거 게이트의 전체 기계장치는 +유지 비용 > 효과. 대신 **"평가자만 PASS를 선언할 수 있다"는 역할 계약**과 +**평가자 프롬프트의 default-FAIL 자세**(모든 기준은 불통과로 시작, Read로 확인한 +증거로만 통과 전환)로 핵심 효과를 가져온다. 전체 기계장치는 자율 루프를 도입할 때 +재검토 (4단계 참조). + +**② 결정론적 훅 가드레일** — **이미 절반 도입됨. 남은 최대 공백은 아키텍처 규칙.** +글에서 말하는 "타입 체크 CI 게이트"는 Kotlin + 기존 CI로 이미 충족된다. +이 프로젝트에서 진짜 프롬프트로만 강제되고 있는 것은 **아키텍처 규칙** +(계층 의존성, `@Transactional` 위치, 래퍼 금지 등)이다. 이를 Konsist 테스트로 +변환하면 기존 `gradlew test` CI 게이트에 그대로 올라타는, 모델이 건너뛸 수 없는 +결정론적 게이트가 된다. 훅보다 테스트가 이 규칙들의 올바른 강제 지점이다. + +**스킵하는 것**: Spec Gate 훅(스펙 없는 쓰기 차단), Haiku 완료 체크 Stop 훅, +스펙 인덱스 운영 — 팀 병렬 작업용 오버헤드로 현재 규모에 비해 과함. + +## 2. 단계별 계획 + +### 1단계 — 평가자 에이전트 + 호출 스킬 (ROI 최고, 비용 최저) + +메인 세션(생성자)이 구현을 마치면 스킬을 통해 평가자 서브에이전트를 스폰하는 구조. +생성자와 평가자가 "세션 vs 서브에이전트"로 분리되어 컨텍스트 오염 없이 채점된다. + +```text +메인 세션 (생성자: 룰+스킬+훅으로 구현) + │ 작업 시작 시: 태스크 파일 작성 (사용자 요청 원문 + 수용 기준) + │ 구현 완료 → verify-implementation 스킬 (완료 보고 전 필수) + │ 스폰 전: git diff 해시 기록 + ▼ +implementation-evaluator 서브에이전트 (평가자: 깨끗한 컨텍스트) + │ - 요구사항은 태스크 파일에서만 도출 (빌더 요약 아님) + │ - 읽기 전용: frontmatter PreToolUse 훅으로 Bash allowlist 강제 + │ 스폰 후: diff 해시 재확인 — 불일치 시 판정 무효 + │ PASS → 판정 인용해 완료 보고 + 메트릭 로그 append + │ NEEDS_WORK → findings 반영 후 새 평가자로 전체 기준 재채점 (최대 2회) + ▼ +사람 리뷰 (PR) +``` + +**평가 게이트 — 모든 작업에 돌리지 않는다** (토큰/시간 과부하 방지) + +평가 1회 비용 = opus 서브에이전트 스폰(룰 + diff + 테스트 출력, 수만 토큰) + +테스트 실행 수 분, NEEDS_WORK 시 ×2~3. 간단한 작업에 무조건 돌면 검증 비용이 +작업 비용을 압도한다. Augment 가이드도 같은 원리로 "단일 파일 수정엔 SDD 스킵"을 +권고. **태스크 파일 존재 여부를 평가 발동 게이트로 사용한다** — 평가자가 어차피 +태스크 파일 없이는 기준 1을 채점할 수 없으므로, 두 메커니즘이 자연스럽게 맞물린다. + +| 구분 | 기준 | 검증 경로 | +|------|------|----------| +| **평가 대상** (태스크 파일 작성 → 완료 시 평가 자동 발동) | 새 기능/엔드포인트, 도메인 로직 변경, 동작이 바뀌는 리팩토링, 다중 파일 변경 | 태스크 파일 → 구현 → verify-implementation | +| **평가 생략** (태스크 파일 없음 → 평가 미발동) | 문서/주석, 설정값, 오타·단일 라인 수정, 테스트만 수정, 포맷 정리 | 기존 결정론 게이트로 충분: ktlint 훅 + 컴파일 + CI (+2단계 후 Konsist) | + +- 경계가 애매하면 빌더가 상향 적용(태스크 파일 작성), 사용자는 언제든 + `/verify-implementation`으로 수동 발동 가능 +- 생략 결정도 메트릭 로그에 남긴다(`tier: skipped`) — 임계값이 너무 느슨한지 + (생략된 작업에서 버그가 PR 리뷰에 잡히는지) 데이터로 검증 + +**산출물 0**: 태스크 파일 — 기준 1의 진실 공급원 (`.claude/tasks/current-task.md`) + +평가자는 깨끗한 컨텍스트에서 시작하므로 대화의 요구사항을 모른다. 빌더가 "이런 +작업이었다"고 요약해 넘기면 자기가 구현한 범위에 맞춰 요구사항을 유리하게 +프레이밍하게 되어(악의가 아니라 구조적으로) 자기 채점이 뒷문으로 돌아온다. + +- **작성 시점**: 작업 시작(계획) 단계 — 구현 후 소급 작성 금지. 사후 프레이밍을 + 줄이는 핵심은 "구현 전에 쓴다"는 시점이다. +- **내용**: ① 사용자 요청 **원문 인용**(요약 아님) ② 수용 기준 목록(검증 가능한 + 문장으로) ③ 명시적 제외 범위 +- **평가자 계약**: "기준 1의 요구사항은 태스크 파일에서 도출한다. 빌더의 주장이나 + 스폰 프롬프트의 요약에서 도출하지 않는다. 태스크 파일이 없으면 기준 1은 채점 + 불가 = 불통과." +- 룰 레이어(아래 역할 계약)에 "평가 대상 규모의 작업은 시작 시 태스크 파일을 먼저 + 쓴다"를 추가. 강제 훅 없이, 어차피 계획 단계에서 만들어지는 아티팩트를 평가자 + 입력으로 지정하는 것뿐이다. + +**산출물 1**: `.claude/agents/implementation-evaluator.md` + +```yaml +--- +name: implementation-evaluator +description: "구현 결과를 깨끗한 컨텍스트에서 채점. 쓰기 권한 없음. PASS / NEEDS_WORK 판정." +tools: Read, Glob, Grep, Bash # Edit/Write/Task 없음 +model: opus +hooks: + PreToolUse: + - matcher: Bash + hooks: + - type: command + command: "$CLAUDE_PROJECT_DIR/.claude/hooks/evaluator-bash-allowlist.sh" +--- +``` + +**읽기 전용은 프롬프트 약속이 아니라 구조로 강제한다** (피드백 반영): +- `evaluator-bash-allowlist.sh` (PreToolUse 훅): `git diff` / `git log` / `git status` / + `git show` / `./gradlew test*` / `./gradlew compileKotlin` / `./gradlew compileTestKotlin` + 만 통과, 그 외 Bash는 deny. 현실적 실패 모드는 평가자가 발견한 문제를 "친절하게 + 직접 고쳐버리는" 것 — 이 순간 분리가 조용히 무너지므로 훅으로 차단한다. +- **2차 방어선 (diff 해시 체크)**: 인자 패턴 매칭은 옵션 순서·변수 확장 변형에 + 취약하므로, 오케스트레이터(스킬)가 평가자 스폰 **전후로 `git diff | sha256sum` + (+ `git status --porcelain` 해시)을 비교**한다. 불일치 → "평가자가 트리를 + 건드렸다" = 판정 무효, 사용자에게 보고. 이로써 "평가자가 트리를 건드리지 + 않았다"가 믿음이 아니라 검증 가능한 사실이 된다. + +프롬프트 핵심 요소: +- **default-FAIL 계약**: 모든 완료 기준은 `불통과`로 시작한다. Read/Bash로 직접 + 확인한 증거(코드 라인, 테스트 출력, 빌드 결과)가 있을 때만 `통과`로 바꾼다. + "구현했다고 적혀 있음"은 증거가 아니다. +- **채점 기준** (각각 증거 필수): + 1. 요구사항 충족 — diff가 **태스크 파일의 수용 기준**을 실제 구현하는가 + (태스크 파일 없으면 채점 불가 = 불통과) + 2. `.claude/rules/architecture.md` 준수 — 계층 의존성, UseCase 책임, Entity 로직 위치 + (※ 2단계 Konsist 도입 후에는 "인코딩 안 된 의미적 잔여물"로 축소 — 아래 2단계 참조) + 3. 테스트 — 영향 범위 한정 실행(`./gradlew test --tests "..."` 또는 증분 test) + + **테스트가 의미 있는지** 채점: 전부 모킹으로 우회하지 않는지, 실패 경로를 + 검증하는지, 단언이 실제 동작을 검증하는지. 결정론적 게이트가 못 하는 일이고 + 평가자에 opus를 쓰는 비용이 정당화되는 지점. 전체 스위트는 CI가 백스톱. + 4. API 계약 — `CommonResponse`, `@ApiErrorCodeExample`, 에러 코드 체계 (해당 시) +- **출력 형식** (한글): + ``` + 판정: PASS | NEEDS_WORK + 기준별 결과: [기준] 통과/불통과 — 증거: <파일:라인 또는 명령 출력 요약> + NEEDS_WORK 시: 구체적 미비점 목록 (다음 빌더 세션의 입력이 됨) + ``` +- 스폰 프롬프트로 전달받는 것: diff 기준점(커밋/브랜치) + **태스크 파일 경로**. + 요구사항 요약은 전달하지 않는다 — 요구사항의 유일한 출처는 태스크 파일이다. + +**산출물 2**: `.claude/skills/verify-implementation/SKILL.md` (호출 진입점) + +- **트리거 description**: "태스크 파일이 있는 구현 작업을 마치고 완료를 보고하기 전, + 또는 '검증해줘'·'평가해줘' 요청 시 사용. 태스크 파일 없는 경미한 작업(문서·설정· + 단일 라인 수정)에는 자동 발동하지 않는다" → 평가 게이트 표와 일치 +- 스킬 내용: + 1. 태스크 파일 존재 확인 — 자동 발동 경로에서는 이미 존재(게이트 조건). + 수동 발동인데 없으면 사용자 요청 원문을 인용해 작성한 뒤 진행 + 2. 평가 범위 결정 — `git diff` 기준점(직전 커밋 또는 작업 시작 지점) 확정 후 + **스폰 전 해시 기록**: `git diff | sha256sum` + `git status --porcelain | sha256sum` + 3. Agent 툴로 `implementation-evaluator` 스폰 — 전달: diff 기준점 + 태스크 파일 + 경로 (요구사항 요약은 전달 금지) + 4. **스폰 후 해시 재확인** — 불일치 시 판정 무효화, 사용자 보고 + 5. `NEEDS_WORK` → findings 반영 후 재평가. **재평가 컨텍스트 위생**: 매번 새로 + 스폰된 평가자가 **전체 기준을 처음부터 재채점**한다 (수정 과정에서 생긴 다른 + 기준의 회귀를 잡기 위해). 이전 findings는 "이것들이 고쳐졌는지 특히 확인하라"는 + 부록으로만 전달. 최대 2회, 실패 시 사용자 에스컬레이션. + 6. 완료 보고에 평가자 판정·기준별 증거를 인용 + 7. **메트릭 로그 append** — `.claude/metrics/eval-log.jsonl`에 한 줄: + `{date, task, first_verdict, failed_criteria, retry_count}`. 한 달치가 쌓이면 + 1차 PASS율(기준 강도 보정), 평가자가 놓치고 사람 PR 리뷰에서 잡힌 이슈 + (이스케이프율), opus→sonnet 전환 판단, 3단계 도입 여부가 전부 데이터로 결정된다. +- 기존 `code-review` 스킬과의 역할 구분: code-review는 "버그·취약점 탐지"(리뷰어 관점), + verify-implementation은 "완료 여부 판정"(default-FAIL 채점) — 대체가 아니라 별개 게이트 + +**역할 계약 명시** (룰 레이어): +- `CLAUDE.md` 또는 `.claude/rules/`에 계약 추가: + 1. **"기능 구현·도메인 로직 변경·동작이 바뀌는 리팩토링 규모의 작업은 시작 시 + 태스크 파일(사용자 요청 원문 + 수용 기준)을 먼저 쓴다."** (평가 게이트 표의 + "평가 대상" 기준과 동일 — 경미한 작업은 해당 없음) + 2. **"태스크 파일이 있는 작업의 완료 보고는 verify-implementation 평가 결과를 + 인용한다. 생성자(메인 세션)는 스스로 PASS를 선언하지 않는다."** +- `check-harness.sh` 무결성 검사에 새 평가자 에이전트 파일·스킬·훅 스크립트 참조 + 포인터가 포함되는지 확인 (스킬 이름 참조 검사는 기존 3번 항목이 커버, 훅 스크립트는 + 5번 항목 패턴에 `.claude/hooks/` 추가 필요 여부 점검) +- 기존 빌더 에이전트 2종은 수정하지 않는다(미사용). 추후 다시 쓰게 되면 각 워크플로의 + 리뷰 단계에서 같은 스킬을 호출하도록 한 줄만 바꾸면 된다 — 진입점을 스킬로 단일화한 + 이유. + +**도입 결과 (2026-06-12)**: 전 산출물 구현 완료. 스모크 테스트(WTH-390 커밋을 소급 +태스크 파일로 채점, headless `claude --agent implementation-evaluator -p`): +- 출력 형식·default-FAIL·file:line 증거 인용 모두 계약대로 동작, 판정 PASS +- frontmatter PreToolUse 훅 발동 확인 (`git --no-pager`·파이프 차단 → 읽기성 + `git --no-pager` 변형은 allowlist에 정규화 추가) +- 스폰 전후 diff/status 해시 일치 — 트리 불변 검증 성공 +- 관찰: headless 모드에서 일부 git 명령이 권한 단계에서 거부되어 평가자가 파일 + 직접 Read로 우회함 (판정 품질에는 영향 없었음). 세션 내 Agent 툴 스폰 경로에서 + 재확인 필요 — 새 에이전트는 다음 세션부터 레지스트리에 등록됨. + +### 2단계 — Konsist 아키텍처 테스트 (결정론적 게이트) + +**산출물**: `build.gradle.kts` 의존성 + `src/test/kotlin/com/weeth/architecture/ArchitectureTest.kt` + +```kotlin +testImplementation("com.lemonappdev:konsist:0.17.3") // Kotlin 2.1 호환 +``` + +Kotest `StringSpec`으로 작성 (기존 스택 일치). 인코딩할 규칙 — 전부 +`.claude/rules/architecture.md`에서 그대로 옮긴다: + +| # | 규칙 | Konsist 검사 | +|---|------|-------------| +| 1 | application은 infrastructure를 import하지 않는다 | **architecture assertion (Layer DSL)** — import 블랙리스트보다 의도가 코드에 드러나고 유지보수 쉬움 | +| 2 | domain은 application/presentation/infrastructure를 import하지 않는다 | **architecture assertion (Layer DSL)** — 규칙 1과 함께 계층 정의 1곳에서 관리 | +| 3 | `@Transactional`은 `usecase` 패키지 클래스에만 | 어노테이션 위치 검사 (domain/service 금지 포함) | +| 4 | Command UseCase는 `usecase/command`에 `*UseCase` 네이밍 | 위치+네이밍 검사 | +| 5 | QueryService는 `usecase/query`에 `Get*QueryService` 네이밍 | 위치+네이밍 검사 | +| 6 | Entity(`domain/entity`)는 `data class` 금지 | 클래스 종류 검사 | +| 7 | Port는 `domain/port` 인터페이스, 구현체는 `infrastructure`에 | 인터페이스/구현 위치 검사 | +| 8 | Lombok/MapStruct/Mockito import 금지 | import 블랙리스트 | + +**도입 절차** (기존 위반 가능성 대비): +1. 테스트를 먼저 작성해 **위반 목록을 출력만** 하고 실패하지 않게 실행 +2. 위반 0건인 규칙 → 즉시 활성화 +3. 위반 있는 규칙 → 위반 건을 명시적 예외 목록(파일 상단 상수)에 박고 활성화, + 예외는 system-architect-agent 리팩토링 백로그로 등록 +4. CI는 수정 불필요 — `gradlew clean test`에 자동 포함됨 + +**알려진 주의점**: Konsist 0.17.3 내장 파서가 kotlin-compiler-embeddable 2.0.20이라 +Kotlin 2.1 전용 신문법을 쓴 파일에서 파싱 이슈가 날 수 있다 — 위반 스캔 단계(1번)에서 +함께 드러나므로 별도 사전 조사는 불필요. + +**도입 결과 (2026-06-12)**: 11개 규칙 작성, 파서 이슈 없음. 위반 스캔 결과와 +baseline 백로그(테스트 파일의 BASELINE 상수와 동일, 제거만 허용): + +| 부채 | 대상 | 해소 방향 | +|------|------|----------| +| domain → application.exception import | Repository 5, Policy 5, VO 2, enum 1 (13개 파일) | 도메인이 던지는 예외를 domain 계층으로 이동 | +| application → infrastructure | `SocialLoginUseCase` → `SocialAuthPortRegistry` | Registry의 Port 추출 또는 위치 이동 | +| infrastructure → application | `AttendanceScheduler`, `QrExpiredEventListener`, `S3FileUploadUrlAdapter`, `CareerNetAdapter`, `KakaoSocialAuthAdapter` | 어댑터·스케줄러·리스너가 UseCase/DTO/예외 직접 참조 — Port/Reader 경유로 정리 | +| 소문자 `Usecase` 접미사 | `ManageClubMemberUsecase`, `GenerateFileUrlUsecase` | 리네임 | +| port 패키지 내 클래스 | `FileUploadUrl` (Port 반환 VO) | `domain/vo`로 이동 또는 규칙 예외 확정 | + +**코드 리뷰 반영 (2026-06-13)**: ① `infrastructure → application` 의존 검사 누락 +보완(위 baseline 추가) ② 평가 게이트 조건을 "파일 존재"에서 "수용 기준 항목이 채워진 +active 태스크 파일"로 강화하고, `current-task.md`는 gitignore + `template.md`만 추적 +(비활성 sentinel이 게이트를 항상 켜는 문제 차단) ③ 평가자 allowlist에서 `git diff +--output`/`-o` 등 파일 쓰기 옵션 차단(읽기 전용 계약 누수 봉합) ④ `AGENTS.md` +Rule Files에 `verification.md` 추가(Codex 진입점 누락 보완). + +**도입 후 평가자 기준 2 다이어트** (피드백 반영): Konsist가 들어오면 평가자가 돌리는 +`gradlew test`에 아키텍처 검사가 이미 포함된다. 그 시점부터 평가자가 같은 8개 규칙을 +LLM 판단으로 또 채점하면 중복이고, 판정 충돌(Konsist 통과 vs 평가자 위반 주장)이 +생긴다. 기준 2를 **인코딩 안 된 의미적 잔여물**로 좁힌다: +- UseCase가 오케스트레이션 범위를 넘는 비즈니스 로직을 갖는지 (위치는 맞지만 책임이 틀린 경우) +- 트랜잭션 경계의 의미적 적절성 (어노테이션 위치는 규칙대로지만 경계 안에 외부 I/O가 있는지 등) +- Entity 메서드가 실제로 불변식을 지키는지 (구조가 아니라 내용) + +### 3단계 — Stop 훅 컴파일 게이트 (선택적, 1·2단계 안착 후) + +**산출물**: `.claude/hooks/compile-gate.sh` + `settings.json` Stop 훅 등록 + +- 세션 종료(Stop) 시 더티 `.kt` 파일이 있으면 `./gradlew compileKotlin + compileTestKotlin` 증분 실행. 실패 시 exit 2로 컴파일 오류를 피드백 → + "다 됐습니다" 보고 전에 깨진 코드를 차단. +- **필수 안전장치**: + - `stop_hook_active` 플래그 확인 → 무한 루프 방지 (훅 입력 JSON에 포함됨) + - 더티 `.kt` 없으면 즉시 exit 0 (문서 작업 세션에 비용 0) + - 타임아웃 120s, Gradle 데몬 전제 (증분 컴파일 ~10-30s) +- **트레이드오프**: 모든 응답 종료마다 지연이 생긴다. 평가자가 이미 컴파일/테스트를 + 돌리므로 중복일 수 있음. +- **도입 판단은 데이터로**: 1단계의 메트릭 로그(`eval-log.jsonl`)에서 "컴파일 실패가 + 평가 단계에서 잡힌 빈도"와 "평가를 거치지 않은 세션에서 깨진 코드로 종료된 사례"를 + 근거로 결정한다. 관측 장치 없이 일화에 의존하지 않는다. verify-implementation이 + 일반 대화 세션의 작업까지 커버하므로 이 훅의 필요성은 원안보다 더 낮아진 상태. + +### 4단계 — 보류: 전체 default-FAIL 계약 기계장치 + +`test-results.json` + `verify-gate.sh`(증거 Read 전 결과 파일 쓰기 차단) + +`track-read.sh` + `commit-on-stop.sh` + kill-switch는 **무인 자율 루프** +(`/loop`, 백그라운드 에이전트, 밤샘 실행)를 도입하는 시점에 cwc-long-running-agents +저장소에서 그대로 가져온다. 대화형 작업에서는 1단계의 역할 계약이 같은 효과를 +훨씬 싸게 낸다. + +## 3. 작업 순서 및 검증 + +| 순서 | 작업 | 검증 방법 | +|------|------|----------| +| 1 | evaluator-bash-allowlist.sh 훅 + implementation-evaluator 에이전트 작성 | 최근 커밋 하나를 태스크 파일과 함께 채점시켜 출력 형식/증거 인용 확인. 평가자에게 일부러 쓰기성 Bash를 유도해 훅 deny 확인 | +| 2 | 태스크 파일 양식 + verify-implementation 스킬(해시 체크·재평가 위생·메트릭 로그 포함) + 룰에 역할 계약 2줄 추가 | `check-harness.sh` 통과 + 메인 세션에서 소규모 작업 1건 후 스킬이 자동 호출되어 평가 루프가 도는지, eval-log.jsonl이 쌓이는지 확인 | +| 3 | Konsist 의존성 + 위반 스캔 (Layer DSL 기반) | 위반 목록 리뷰 → 규칙별 활성화/예외 결정 (2.0.20 파서 이슈도 이 단계에서 드러남) | +| 4 | ArchitectureTest 활성화 + 평가자 기준 2 다이어트 | `./gradlew test` 통과 + CI 그린 | +| 5 | (조건부) compile-gate Stop 훅 — eval-log.jsonl 데이터로 필요성 판단 | 일부러 깨진 코드로 세션 종료 → exit 2 피드백 확인 | + +## 4. 리스크 + +- **평가자 토큰/시간 비용**: 평가 1회 = opus 스폰 수만 토큰 + 테스트 수 분, + 재시도 시 ×2~3. 1차 방어는 평가 게이트(태스크 파일 없는 경미한 작업은 미발동), + 2차는 평가 범위 한정(이번 diff + 태스크 파일 + 관련 규칙 파일만, 테스트는 + `--tests` 한정). opus→sonnet 전환은 eval-log.jsonl 데이터(이스케이프율)로 판단. + 게이트 임계값이 안 맞으면(평가가 너무 자주/드물게 돌면) 표의 기준을 조정한다. +- **태스크 파일 프레이밍 잔존 위험**: 태스크 파일도 결국 빌더(메인 세션)가 쓴다. + "구현 전 작성 + 사용자 요청 원문 인용"으로 사후 프레이밍을 차단하지만, 수용 기준 + 자체가 느슨하게 쓰일 수는 있다 — 사람 PR 리뷰에서 태스크 파일도 함께 보는 것으로 + 보완 (diff에 포함되므로 자연히 노출됨). +- **Konsist 기존 위반**: 활성화 전 스캔 단계에서 흡수. 예외 목록은 "줄어들기만 + 해야 하는" 명시적 부채로 관리. +- **평가 루프 발산**: NEEDS_WORK 무한 반복 방지 — 최대 2회 재시도 후 사용자 + 에스컬레이션. 재평가는 항상 전체 기준 재채점 (이전 실패 항목만 보면 회귀를 놓침). +- **서브에이전트 훅 우회 가능성**: frontmatter 훅의 Bash 인자 패턴 매칭은 변형에 + 취약하고 deny 우회 사례도 보고된 바 있음 — diff 해시 체크가 2차 방어선이며, + 최종 판정의 전제 조건은 패턴 매칭이 아니라 해시 일치다. +- **Stop 훅 지연**: 3단계를 조건부로 미뤄 메트릭 데이터 확인 후 결정. diff --git a/lombok.config b/lombok.config deleted file mode 100644 index df71bb6a..00000000 --- a/lombok.config +++ /dev/null @@ -1,2 +0,0 @@ -config.stopBubbling = true -lombok.addLombokGeneratedAnnotation = true diff --git a/src/test/kotlin/com/weeth/architecture/ArchitectureTest.kt b/src/test/kotlin/com/weeth/architecture/ArchitectureTest.kt new file mode 100644 index 00000000..6c84eefa --- /dev/null +++ b/src/test/kotlin/com/weeth/architecture/ArchitectureTest.kt @@ -0,0 +1,182 @@ +package com.weeth.architecture + +import com.lemonappdev.konsist.api.Konsist +import com.lemonappdev.konsist.api.architecture.KoArchitectureCreator.assertArchitecture +import com.lemonappdev.konsist.api.architecture.Layer +import com.lemonappdev.konsist.api.verify.assertFalse +import com.lemonappdev.konsist.api.verify.assertTrue +import io.kotest.core.spec.style.StringSpec + +/** + * `.claude/rules/architecture.md`의 결정론적 게이트. + * 규칙 문서가 진실 공급원이며, 이 테스트는 그중 구조적으로 검사 가능한 항목을 강제한다. + * + * BASELINE 목록은 도입 시점(2026-06)의 기존 위반으로, 리팩토링 백로그 + * (docs/plan/ai-verification-hardening-plan.md) 대상이다. + * 항목은 제거만 허용되며 새로 추가하지 않는다. + */ +class ArchitectureTest : + StringSpec({ + val productionScope = Konsist.scopeFromProduction() + + "계층 의존성: domain·application·infrastructure는 상위 계층에 의존하지 않는다" { + productionScope.assertArchitecture { + val domain = Layer("domain", "com.weeth.domain..domain..") + val application = Layer("application", "com.weeth.domain..application..") + val presentation = Layer("presentation", "com.weeth.domain..presentation..") + val infrastructure = Layer("infrastructure", "com.weeth.domain..infrastructure..") + + domain.doesNotDependOn(presentation, infrastructure) + application.doesNotDependOn(presentation) + infrastructure.doesNotDependOn(presentation) + } + } + + "domain은 application에 의존하지 않는다 (baseline 제외)" { + productionScope + .files + .filter { DOMAIN_LAYER_PATH.containsMatchIn(it.path) } + .filter { it.name !in DOMAIN_TO_APPLICATION_BASELINE } + .assertFalse { file -> + file.imports.any { it.name.matches(APPLICATION_PACKAGE_IMPORT) } + } + } + + "application은 infrastructure에 의존하지 않는다 (baseline 제외)" { + productionScope + .files + .filter { APPLICATION_LAYER_PATH.containsMatchIn(it.path) } + .filter { it.name !in APPLICATION_TO_INFRASTRUCTURE_BASELINE } + .assertFalse { file -> + file.imports.any { it.name.matches(INFRASTRUCTURE_PACKAGE_IMPORT) } + } + } + + "infrastructure는 application에 의존하지 않는다 (baseline 제외)" { + productionScope + .files + .filter { INFRASTRUCTURE_LAYER_PATH.containsMatchIn(it.path) } + .filter { it.name !in INFRASTRUCTURE_TO_APPLICATION_BASELINE } + .assertFalse { file -> + file.imports.any { it.name.matches(APPLICATION_PACKAGE_IMPORT) } + } + } + + "@Transactional은 usecase 패키지에만 붙는다" { + productionScope + .classes() + .filter { cls -> cls.annotations.any { it.name == "Transactional" } } + .assertTrue { it.resideInPackage("..application.usecase..") } + + productionScope + .functions() + .filter { fn -> fn.annotations.any { it.name == "Transactional" } } + .assertTrue { it.resideInPackage("..application.usecase..") } + } + + "usecase/command 클래스는 *UseCase 네이밍을 따른다 (baseline 제외)" { + productionScope + .classes(includeNested = false) + .filter { it.resideInPackage("..usecase.command..") } + .filter { it.name !in COMMAND_NAMING_BASELINE } + .assertTrue { it.hasNameEndingWith("UseCase") } + } + + "usecase/query 클래스는 Get*QueryService 네이밍을 따른다" { + productionScope + .classes(includeNested = false) + .filter { it.resideInPackage("..usecase.query..") } + .assertTrue { it.hasNameStartingWith("Get") && it.hasNameEndingWith("QueryService") } + } + + "Entity는 data class를 사용하지 않는다" { + productionScope + .classes() + .filter { it.resideInPackage("..domain.entity..") } + .assertFalse { it.hasDataModifier } + } + + "domain/port에는 인터페이스만 둔다 (baseline 제외)" { + productionScope + .classes() + .filter { it.name !in PORT_CLASS_BASELINE } + .assertFalse { it.resideInPackage("..domain.port..") } + } + + "Port 구현체(*Adapter)는 infrastructure에 둔다" { + productionScope + .classes(includeNested = false) + .filter { it.hasNameEndingWith("Adapter") } + .assertTrue { it.resideInPackage("..infrastructure..") } + } + + "Lombok과 MapStruct는 사용하지 않는다" { + productionScope + .imports + .assertFalse { it.name.startsWith("lombok.") || it.name.startsWith("org.mapstruct.") } + } + + "테스트에서 Mockito를 사용하지 않는다 (MockK만 허용)" { + Konsist + .scopeFromTest() + .imports + .assertFalse { it.name.startsWith("org.mockito.") } + } + }) { + companion object { + private val DOMAIN_LAYER_PATH = Regex("com/weeth/domain/[^/]+/domain/") + private val APPLICATION_LAYER_PATH = Regex("com/weeth/domain/[^/]+/application/") + private val INFRASTRUCTURE_LAYER_PATH = Regex("com/weeth/domain/[^/]+/infrastructure/") + + private val APPLICATION_PACKAGE_IMPORT = Regex("""com\.weeth\.domain\.[a-z]+\.application\..*""") + private val INFRASTRUCTURE_PACKAGE_IMPORT = Regex("""com\.weeth\.domain\.[a-z]+\.infrastructure\..*""") + + // 도메인이 던지는 예외가 application/exception에 위치하는 구조적 부채. + // 해소 방향: 도메인에서 던지는 예외를 domain 계층으로 이동 + private val DOMAIN_TO_APPLICATION_BASELINE = + setOf( + "PostRepository", + "CardinalRepository", + "ClubRepository", + "ClubCodePolicy", + "ClubJoinPolicy", + "ClubMemberCardinalPolicy", + "ClubMemberPolicy", + "ClubPermissionPolicy", + "FileContentType", + "FileExtension", + "SessionRepository", + "StatusPriority", + "UserRepository", + ) + + // SocialAuthPortRegistry가 infrastructure에 위치 — Port 추출 대상 + private val APPLICATION_TO_INFRASTRUCTURE_BASELINE = + setOf( + "SocialLoginUseCase", + ) + + // 어댑터·스케줄러·리스너가 application(UseCase/DTO/예외)을 직접 참조하는 부채 + private val INFRASTRUCTURE_TO_APPLICATION_BASELINE = + setOf( + "AttendanceScheduler", + "QrExpiredEventListener", + "S3FileUploadUrlAdapter", + "CareerNetAdapter", + "KakaoSocialAuthAdapter", + ) + + // 소문자 Usecase 접미사 — 리네임 대상 + private val COMMAND_NAMING_BASELINE = + setOf( + "ManageClubMemberUsecase", + "GenerateFileUrlUsecase", + ) + + // Port 반환값 VO가 port 패키지에 위치 + private val PORT_CLASS_BASELINE = + setOf( + "FileUploadUrl", + ) + } +}