mock実行をできるように修正
This commit is contained in:
parent
665b5187be
commit
ccc19e83ff
@ -159,7 +159,68 @@ Prohibited patterns:
|
|||||||
| Hidden Dependencies | Child components implicitly calling APIs etc. |
|
| Hidden Dependencies | Child components implicitly calling APIs etc. |
|
||||||
| Non-idiomatic | Custom implementation ignoring language/FW conventions |
|
| Non-idiomatic | Custom implementation ignoring language/FW conventions |
|
||||||
|
|
||||||
### 6. Unnecessary Backward Compatibility Code Detection
|
### 6. Abstraction Level Evaluation
|
||||||
|
|
||||||
|
**Conditional Branch Proliferation Detection:**
|
||||||
|
|
||||||
|
| Pattern | Judgment |
|
||||||
|
|---------|----------|
|
||||||
|
| Same if-else pattern in 3+ places | Abstract with polymorphism → **REJECT** |
|
||||||
|
| switch/case with 5+ branches | Consider Strategy/Map pattern |
|
||||||
|
| Flag arguments changing behavior | Split into separate functions → **REJECT** |
|
||||||
|
| Type-based branching (instanceof/typeof) | Replace with polymorphism → **REJECT** |
|
||||||
|
| Nested conditionals (3+ levels) | Early return or extract → **REJECT** |
|
||||||
|
|
||||||
|
**Abstraction Level Mismatch Detection:**
|
||||||
|
|
||||||
|
| Pattern | Problem | Fix |
|
||||||
|
|---------|---------|-----|
|
||||||
|
| Low-level details in high-level processing | Hard to read | Extract details to functions |
|
||||||
|
| Mixed abstraction levels in one function | Cognitive load | Align to same granularity |
|
||||||
|
| DB operations mixed with business logic | Responsibility violation | Separate to Repository layer |
|
||||||
|
| Config values mixed with processing logic | Hard to change | Externalize configuration |
|
||||||
|
|
||||||
|
**Good Abstraction Examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Proliferating conditionals
|
||||||
|
function process(type: string) {
|
||||||
|
if (type === 'A') { /* process A */ }
|
||||||
|
else if (type === 'B') { /* process B */ }
|
||||||
|
else if (type === 'C') { /* process C */ }
|
||||||
|
// ...continues
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Abstract with Map pattern
|
||||||
|
const processors: Record<string, () => void> = {
|
||||||
|
A: processA,
|
||||||
|
B: processB,
|
||||||
|
C: processC,
|
||||||
|
};
|
||||||
|
function process(type: string) {
|
||||||
|
processors[type]?.();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Mixed abstraction levels
|
||||||
|
function createUser(data: UserData) {
|
||||||
|
// High level: business logic
|
||||||
|
validateUser(data);
|
||||||
|
// Low level: DB operation details
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
await conn.query('INSERT INTO users...');
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Aligned abstraction levels
|
||||||
|
function createUser(data: UserData) {
|
||||||
|
validateUser(data);
|
||||||
|
await userRepository.save(data); // Details hidden
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Unnecessary Backward Compatibility Code Detection
|
||||||
|
|
||||||
**AI tends to leave unnecessary code "for backward compatibility." Don't overlook this.**
|
**AI tends to leave unnecessary code "for backward compatibility." Don't overlook this.**
|
||||||
|
|
||||||
@ -188,7 +249,7 @@ Code that should be kept:
|
|||||||
|
|
||||||
**Be suspicious when AI says "for backward compatibility."** Verify if it's really needed.
|
**Be suspicious when AI says "for backward compatibility."** Verify if it's really needed.
|
||||||
|
|
||||||
### 7. Workaround Detection
|
### 8. Workaround Detection
|
||||||
|
|
||||||
**Don't overlook compromises made to "just make it work."**
|
**Don't overlook compromises made to "just make it work."**
|
||||||
|
|
||||||
@ -203,7 +264,7 @@ Code that should be kept:
|
|||||||
|
|
||||||
**Always point these out.** Temporary fixes become permanent.
|
**Always point these out.** Temporary fixes become permanent.
|
||||||
|
|
||||||
### 8. Quality Attributes
|
### 9. Quality Attributes
|
||||||
|
|
||||||
| Attribute | Review Point |
|
| Attribute | Review Point |
|
||||||
|-----------|--------------|
|
|-----------|--------------|
|
||||||
@ -211,7 +272,7 @@ Code that should be kept:
|
|||||||
| Maintainability | Easy to modify and fix |
|
| Maintainability | Easy to modify and fix |
|
||||||
| Observability | Logging and monitoring enabled |
|
| Observability | Logging and monitoring enabled |
|
||||||
|
|
||||||
### 9. Big Picture
|
### 10. Big Picture
|
||||||
|
|
||||||
**Caution**: Don't get lost in minor "clean code" nitpicks.
|
**Caution**: Don't get lost in minor "clean code" nitpicks.
|
||||||
|
|
||||||
@ -222,7 +283,7 @@ Verify:
|
|||||||
- Does it align with business requirements
|
- Does it align with business requirements
|
||||||
- Is naming consistent with the domain
|
- Is naming consistent with the domain
|
||||||
|
|
||||||
### 10. Change Scope Assessment
|
### 11. Change Scope Assessment
|
||||||
|
|
||||||
**Check change scope and include in report (non-blocking).**
|
**Check change scope and include in report (non-blocking).**
|
||||||
|
|
||||||
@ -241,7 +302,7 @@ Verify:
|
|||||||
**Include as suggestions (non-blocking):**
|
**Include as suggestions (non-blocking):**
|
||||||
- If splittable, present splitting proposal
|
- If splittable, present splitting proposal
|
||||||
|
|
||||||
### 11. Circular Review Detection
|
### 12. Circular Review Detection
|
||||||
|
|
||||||
When review count is provided (e.g., "Review count: 3rd"), adjust judgment accordingly.
|
When review count is provided (e.g., "Review count: 3rd"), adjust judgment accordingly.
|
||||||
|
|
||||||
|
|||||||
@ -105,7 +105,45 @@ Perform self-check after implementation.
|
|||||||
| Boy Scout | Leave touched areas slightly improved |
|
| Boy Scout | Leave touched areas slightly improved |
|
||||||
| Fail Fast | Detect errors early. Don't swallow them |
|
| Fail Fast | Detect errors early. Don't swallow them |
|
||||||
|
|
||||||
**When in doubt**: Choose Simple. Abstraction can come later.
|
**When in doubt**: Choose Simple.
|
||||||
|
|
||||||
|
## Abstraction Principles
|
||||||
|
|
||||||
|
**Before adding conditional branches, consider:**
|
||||||
|
- Does this condition exist elsewhere? → Abstract with a pattern
|
||||||
|
- Will more branches be added? → Use Strategy/Map pattern
|
||||||
|
- Branching on type? → Replace with polymorphism
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Adding more conditionals
|
||||||
|
if (type === 'A') { ... }
|
||||||
|
else if (type === 'B') { ... }
|
||||||
|
else if (type === 'C') { ... } // Yet another one
|
||||||
|
|
||||||
|
// ✅ Abstract with Map
|
||||||
|
const handlers = { A: handleA, B: handleB, C: handleC };
|
||||||
|
handlers[type]?.();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Align abstraction levels:**
|
||||||
|
- Keep same granularity of operations within one function
|
||||||
|
- Extract detailed processing to separate functions
|
||||||
|
- Don't mix "what to do" with "how to do it"
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Mixed abstraction levels
|
||||||
|
function processOrder(order) {
|
||||||
|
validateOrder(order); // High level
|
||||||
|
const conn = pool.getConnection(); // Low level detail
|
||||||
|
conn.query('INSERT...'); // Low level detail
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Aligned abstraction levels
|
||||||
|
function processOrder(order) {
|
||||||
|
validateOrder(order);
|
||||||
|
saveOrder(order); // Details hidden
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**Follow language/framework conventions:**
|
**Follow language/framework conventions:**
|
||||||
- Be Pythonic in Python, Kotlin-like in Kotlin
|
- Be Pythonic in Python, Kotlin-like in Kotlin
|
||||||
@ -134,6 +172,121 @@ Perform self-check after implementation.
|
|||||||
- Children don't modify state directly (notify parent via events)
|
- Children don't modify state directly (notify parent via events)
|
||||||
- State flows in one direction
|
- State flows in one direction
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**Principle: Centralize error handling. Don't scatter try-catch everywhere.**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Try-catch everywhere
|
||||||
|
async function createUser(data) {
|
||||||
|
try {
|
||||||
|
const user = await userService.create(data)
|
||||||
|
return user
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
throw new Error('Failed to create user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Centralized handling at upper layer
|
||||||
|
// Catch at Controller/Handler layer
|
||||||
|
// Or use @ControllerAdvice / ErrorBoundary
|
||||||
|
async function createUser(data) {
|
||||||
|
return await userService.create(data) // Let exceptions propagate
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error handling placement:**
|
||||||
|
|
||||||
|
| Layer | Responsibility |
|
||||||
|
|-------|----------------|
|
||||||
|
| Domain/Service layer | Throw exceptions on business rule violations |
|
||||||
|
| Controller/Handler layer | Catch exceptions and convert to response |
|
||||||
|
| Global handler | Handle common exceptions (NotFound, auth errors, etc.) |
|
||||||
|
|
||||||
|
## Transformation Placement
|
||||||
|
|
||||||
|
**Principle: Put conversion methods on DTOs.**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Request/Response DTOs have conversion methods
|
||||||
|
interface CreateUserRequest {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUseCaseInput(req: CreateUserRequest): CreateUserInput {
|
||||||
|
return { name: req.name, email: req.email }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller
|
||||||
|
const input = toUseCaseInput(request)
|
||||||
|
const output = await useCase.execute(input)
|
||||||
|
return UserResponse.from(output)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Conversion direction:**
|
||||||
|
```
|
||||||
|
Request → toInput() → UseCase/Service → Output → Response.from()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extraction Decisions
|
||||||
|
|
||||||
|
**Rule of Three:**
|
||||||
|
- 1st time: Write it inline
|
||||||
|
- 2nd time: Don't extract yet (wait and see)
|
||||||
|
- 3rd time: Consider extraction
|
||||||
|
|
||||||
|
**Should extract:**
|
||||||
|
- Same logic in 3+ places
|
||||||
|
- Same style/UI pattern
|
||||||
|
- Same validation logic
|
||||||
|
- Same formatting logic
|
||||||
|
|
||||||
|
**Should NOT extract:**
|
||||||
|
- Similar but slightly different (forced generalization adds complexity)
|
||||||
|
- Used in only 1-2 places
|
||||||
|
- Based on "might use later" predictions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Over-generalization
|
||||||
|
function formatValue(value, type, options) {
|
||||||
|
if (type === 'currency') { ... }
|
||||||
|
else if (type === 'date') { ... }
|
||||||
|
else if (type === 'percentage') { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Separate functions by purpose
|
||||||
|
function formatCurrency(amount: number): string { ... }
|
||||||
|
function formatDate(date: Date): string { ... }
|
||||||
|
function formatPercentage(value: number): string { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing Tests
|
||||||
|
|
||||||
|
**Principle: Structure tests with "Given-When-Then".**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('returns NotFound error when user does not exist', async () => {
|
||||||
|
// Given: non-existent user ID
|
||||||
|
const nonExistentId = 'non-existent-id'
|
||||||
|
|
||||||
|
// When: attempt to get user
|
||||||
|
const result = await getUser(nonExistentId)
|
||||||
|
|
||||||
|
// Then: NotFound error is returned
|
||||||
|
expect(result.error).toBe('NOT_FOUND')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test priority:**
|
||||||
|
|
||||||
|
| Priority | Target |
|
||||||
|
|----------|--------|
|
||||||
|
| High | Business logic, state transitions |
|
||||||
|
| Medium | Edge cases, error handling |
|
||||||
|
| Low | Simple CRUD, UI appearance |
|
||||||
|
|
||||||
## Prohibited
|
## Prohibited
|
||||||
|
|
||||||
- **Fallback value overuse** - Don't hide problems with `?? 'unknown'`, `|| 'default'`
|
- **Fallback value overuse** - Don't hide problems with `?? 'unknown'`, `|| 'default'`
|
||||||
@ -143,4 +296,5 @@ Perform self-check after implementation.
|
|||||||
- **Direct object/array mutation** - Create new with spread operator
|
- **Direct object/array mutation** - Create new with spread operator
|
||||||
- **console.log** - Don't leave in production code
|
- **console.log** - Don't leave in production code
|
||||||
- **Hardcoded secrets**
|
- **Hardcoded secrets**
|
||||||
|
- **Scattered try-catch** - Centralize error handling at upper layer
|
||||||
|
|
||||||
|
|||||||
@ -631,7 +631,7 @@ steps:
|
|||||||
pass_previous_response: true
|
pass_previous_response: true
|
||||||
transitions:
|
transitions:
|
||||||
- condition: done
|
- condition: done
|
||||||
next_step: cqrs_es_review
|
next_step: ai_review
|
||||||
- condition: blocked
|
- condition: blocked
|
||||||
next_step: plan
|
next_step: plan
|
||||||
|
|
||||||
|
|||||||
@ -159,7 +159,68 @@ Vertical Slice の判定基準:
|
|||||||
| 隠れた依存 | 子コンポーネントが暗黙的にAPIを呼ぶ等 |
|
| 隠れた依存 | 子コンポーネントが暗黙的にAPIを呼ぶ等 |
|
||||||
| 非イディオマティック | 言語・FWの作法を無視した独自実装 |
|
| 非イディオマティック | 言語・FWの作法を無視した独自実装 |
|
||||||
|
|
||||||
### 6. 不要な後方互換コードの検出
|
### 6. 抽象化レベルの評価
|
||||||
|
|
||||||
|
**条件分岐の肥大化検出:**
|
||||||
|
|
||||||
|
| パターン | 判定 |
|
||||||
|
|---------|------|
|
||||||
|
| 同じif-elseパターンが3箇所以上 | ポリモーフィズムで抽象化 → **REJECT** |
|
||||||
|
| switch/caseが5分岐以上 | Strategy/Mapパターンを検討 |
|
||||||
|
| フラグ引数で挙動を変える | 別関数に分割 → **REJECT** |
|
||||||
|
| 型による分岐(instanceof/typeof) | ポリモーフィズムに置換 → **REJECT** |
|
||||||
|
| ネストした条件分岐(3段以上) | 早期リターンまたは抽出 → **REJECT** |
|
||||||
|
|
||||||
|
**抽象度の不一致検出:**
|
||||||
|
|
||||||
|
| パターン | 問題 | 修正案 |
|
||||||
|
|---------|------|--------|
|
||||||
|
| 高レベル処理の中に低レベル詳細 | 読みにくい | 詳細を関数に抽出 |
|
||||||
|
| 1関数内で抽象度が混在 | 認知負荷 | 同じ粒度に揃える |
|
||||||
|
| ビジネスロジックにDB操作が混在 | 責務違反 | Repository層に分離 |
|
||||||
|
| 設定値と処理ロジックが混在 | 変更困難 | 設定を外部化 |
|
||||||
|
|
||||||
|
**良い抽象化の例:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 条件分岐の肥大化
|
||||||
|
function process(type: string) {
|
||||||
|
if (type === 'A') { /* 処理A */ }
|
||||||
|
else if (type === 'B') { /* 処理B */ }
|
||||||
|
else if (type === 'C') { /* 処理C */ }
|
||||||
|
// ...続く
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Mapパターンで抽象化
|
||||||
|
const processors: Record<string, () => void> = {
|
||||||
|
A: processA,
|
||||||
|
B: processB,
|
||||||
|
C: processC,
|
||||||
|
};
|
||||||
|
function process(type: string) {
|
||||||
|
processors[type]?.();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 抽象度の混在
|
||||||
|
function createUser(data: UserData) {
|
||||||
|
// 高レベル: ビジネスロジック
|
||||||
|
validateUser(data);
|
||||||
|
// 低レベル: DB操作の詳細
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
await conn.query('INSERT INTO users...');
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 抽象度を揃える
|
||||||
|
function createUser(data: UserData) {
|
||||||
|
validateUser(data);
|
||||||
|
await userRepository.save(data); // 詳細は隠蔽
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 不要な後方互換コードの検出
|
||||||
|
|
||||||
**AIは「後方互換のために」不要なコードを残しがちである。これを見逃さない。**
|
**AIは「後方互換のために」不要なコードを残しがちである。これを見逃さない。**
|
||||||
|
|
||||||
|
|||||||
@ -105,7 +105,45 @@
|
|||||||
| ボーイスカウト | 触った箇所は少し改善して去る |
|
| ボーイスカウト | 触った箇所は少し改善して去る |
|
||||||
| Fail Fast | エラーは早期に検出。握りつぶさない |
|
| Fail Fast | エラーは早期に検出。握りつぶさない |
|
||||||
|
|
||||||
**迷ったら**: Simple を選ぶ。抽象化は後からでもできる。
|
**迷ったら**: Simple を選ぶ。
|
||||||
|
|
||||||
|
## 抽象化の原則
|
||||||
|
|
||||||
|
**条件分岐を追加する前に考える:**
|
||||||
|
- 同じ条件が他にもあるか → あればパターンで抽象化
|
||||||
|
- 今後も分岐が増えそうか → Strategy/Mapパターンを使う
|
||||||
|
- 型で分岐しているか → ポリモーフィズムで置換
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 条件分岐を増やす
|
||||||
|
if (type === 'A') { ... }
|
||||||
|
else if (type === 'B') { ... }
|
||||||
|
else if (type === 'C') { ... } // また増えた
|
||||||
|
|
||||||
|
// ✅ Mapで抽象化
|
||||||
|
const handlers = { A: handleA, B: handleB, C: handleC };
|
||||||
|
handlers[type]?.();
|
||||||
|
```
|
||||||
|
|
||||||
|
**抽象度を揃える:**
|
||||||
|
- 1つの関数内では同じ粒度の処理を並べる
|
||||||
|
- 詳細な処理は別関数に切り出す
|
||||||
|
- 「何をするか」と「どうやるか」を混ぜない
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 抽象度が混在
|
||||||
|
function processOrder(order) {
|
||||||
|
validateOrder(order); // 高レベル
|
||||||
|
const conn = pool.getConnection(); // 低レベル詳細
|
||||||
|
conn.query('INSERT...'); // 低レベル詳細
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 抽象度を揃える
|
||||||
|
function processOrder(order) {
|
||||||
|
validateOrder(order);
|
||||||
|
saveOrder(order); // 詳細は隠蔽
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**言語・フレームワークの作法に従う:**
|
**言語・フレームワークの作法に従う:**
|
||||||
- Pythonなら Pythonic に、KotlinならKotlinらしく
|
- Pythonなら Pythonic に、KotlinならKotlinらしく
|
||||||
@ -134,6 +172,121 @@
|
|||||||
- 子は状態を直接変更しない(イベントを親に通知)
|
- 子は状態を直接変更しない(イベントを親に通知)
|
||||||
- 状態の流れは単方向
|
- 状態の流れは単方向
|
||||||
|
|
||||||
|
## エラーハンドリング
|
||||||
|
|
||||||
|
**原則: エラーは一元管理する。各所でtry-catchしない。**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 各所でtry-catch
|
||||||
|
async function createUser(data) {
|
||||||
|
try {
|
||||||
|
const user = await userService.create(data)
|
||||||
|
return user
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
throw new Error('ユーザー作成に失敗しました')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 上位層で一元処理
|
||||||
|
// Controller/Handler層でまとめてキャッチ
|
||||||
|
// または @ControllerAdvice / ErrorBoundary で処理
|
||||||
|
async function createUser(data) {
|
||||||
|
return await userService.create(data) // 例外はそのまま上に投げる
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**エラー処理の配置:**
|
||||||
|
|
||||||
|
| 層 | 責務 |
|
||||||
|
|----|------|
|
||||||
|
| ドメイン/サービス層 | ビジネスルール違反時に例外をスロー |
|
||||||
|
| Controller/Handler層 | 例外をキャッチしてレスポンスに変換 |
|
||||||
|
| グローバルハンドラ | 共通例外(NotFound, 認証エラー等)を処理 |
|
||||||
|
|
||||||
|
## 変換処理の配置
|
||||||
|
|
||||||
|
**原則: 変換メソッドはDTO側に持たせる。**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Request/Response DTOに変換メソッド
|
||||||
|
interface CreateUserRequest {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUseCaseInput(req: CreateUserRequest): CreateUserInput {
|
||||||
|
return { name: req.name, email: req.email }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller
|
||||||
|
const input = toUseCaseInput(request)
|
||||||
|
const output = await useCase.execute(input)
|
||||||
|
return UserResponse.from(output)
|
||||||
|
```
|
||||||
|
|
||||||
|
**変換の方向:**
|
||||||
|
```
|
||||||
|
Request → toInput() → UseCase/Service → Output → Response.from()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 共通化の判断
|
||||||
|
|
||||||
|
**3回ルール:**
|
||||||
|
- 1回目: そのまま書く
|
||||||
|
- 2回目: まだ共通化しない(様子見)
|
||||||
|
- 3回目: 共通化を検討
|
||||||
|
|
||||||
|
**共通化すべきもの:**
|
||||||
|
- 同じ処理が3箇所以上
|
||||||
|
- 同じスタイル/UIパターン
|
||||||
|
- 同じバリデーションロジック
|
||||||
|
- 同じフォーマット処理
|
||||||
|
|
||||||
|
**共通化すべきでないもの:**
|
||||||
|
- 似ているが微妙に違うもの(無理に汎用化すると複雑化)
|
||||||
|
- 1-2箇所しか使わないもの
|
||||||
|
- 「将来使うかも」という予測に基づくもの
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 過度な汎用化
|
||||||
|
function formatValue(value, type, options) {
|
||||||
|
if (type === 'currency') { ... }
|
||||||
|
else if (type === 'date') { ... }
|
||||||
|
else if (type === 'percentage') { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 用途別に関数を分ける
|
||||||
|
function formatCurrency(amount: number): string { ... }
|
||||||
|
function formatDate(date: Date): string { ... }
|
||||||
|
function formatPercentage(value: number): string { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## テストの書き方
|
||||||
|
|
||||||
|
**原則: テストは「Given-When-Then」で構造化する。**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('ユーザーが存在しない場合、NotFoundエラーを返す', async () => {
|
||||||
|
// Given: 存在しないユーザーID
|
||||||
|
const nonExistentId = 'non-existent-id'
|
||||||
|
|
||||||
|
// When: ユーザー取得を試みる
|
||||||
|
const result = await getUser(nonExistentId)
|
||||||
|
|
||||||
|
// Then: NotFoundエラーが返る
|
||||||
|
expect(result.error).toBe('NOT_FOUND')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**テストの優先度:**
|
||||||
|
|
||||||
|
| 優先度 | 対象 |
|
||||||
|
|--------|------|
|
||||||
|
| 高 | ビジネスロジック、状態遷移 |
|
||||||
|
| 中 | エッジケース、エラーハンドリング |
|
||||||
|
| 低 | 単純なCRUD、UIの見た目 |
|
||||||
|
|
||||||
## 禁止事項
|
## 禁止事項
|
||||||
|
|
||||||
- **フォールバック値の乱用** - `?? 'unknown'`、`|| 'default'` で問題を隠さない
|
- **フォールバック値の乱用** - `?? 'unknown'`、`|| 'default'` で問題を隠さない
|
||||||
@ -142,4 +295,5 @@
|
|||||||
- **any型** - 型安全を破壊しない
|
- **any型** - 型安全を破壊しない
|
||||||
- **オブジェクト/配列の直接変更** - スプレッド演算子で新規作成
|
- **オブジェクト/配列の直接変更** - スプレッド演算子で新規作成
|
||||||
- **console.log** - 本番コードに残さない
|
- **console.log** - 本番コードに残さない
|
||||||
- **機密情報のハードコーディング**
|
- **機密情報のハードコーディング**
|
||||||
|
- **各所でのtry-catch** - エラーは上位層で一元処理
|
||||||
@ -32,6 +32,15 @@
|
|||||||
|
|
||||||
### 1. Aggregate設計
|
### 1. Aggregate設計
|
||||||
|
|
||||||
|
**原則: Aggregateは判断に必要なフィールドのみ保持する**
|
||||||
|
|
||||||
|
Command Model(Aggregate)の役割は「コマンドを受けて判断し、イベントを発行する」こと。
|
||||||
|
クエリ用データはRead Model(Projection)が担当する。
|
||||||
|
|
||||||
|
**「判断に必要」とは:**
|
||||||
|
- `if`/`require`の条件分岐に使う
|
||||||
|
- インスタンスメソッドでイベント発行時にフィールド値を参照する
|
||||||
|
|
||||||
**必須チェック:**
|
**必須チェック:**
|
||||||
|
|
||||||
| 基準 | 判定 |
|
| 基準 | 判定 |
|
||||||
@ -40,12 +49,49 @@
|
|||||||
| Aggregate間の直接参照(ID参照でない) | REJECT |
|
| Aggregate間の直接参照(ID参照でない) | REJECT |
|
||||||
| Aggregateが100行を超える | 分割を検討 |
|
| Aggregateが100行を超える | 分割を検討 |
|
||||||
| ビジネス不変条件がAggregate外にある | REJECT |
|
| ビジネス不変条件がAggregate外にある | REJECT |
|
||||||
|
| 判断に使わないフィールドを保持 | REJECT |
|
||||||
|
|
||||||
**良いAggregate:**
|
**良いAggregate:**
|
||||||
- 整合性境界が明確
|
```kotlin
|
||||||
- ID参照で他Aggregateを参照
|
// ✅ 判断に必要なフィールドのみ
|
||||||
- コマンドを受け取り、イベントを発行
|
data class Order(
|
||||||
- 不変条件を内部で保護
|
val orderId: String, // イベント発行時に使用
|
||||||
|
val status: OrderStatus // 状態チェックに使用
|
||||||
|
) {
|
||||||
|
fun confirm(confirmedBy: String): OrderConfirmedEvent {
|
||||||
|
require(status == OrderStatus.PENDING) { "確定できる状態ではありません" }
|
||||||
|
return OrderConfirmedEvent(
|
||||||
|
orderId = orderId,
|
||||||
|
confirmedBy = confirmedBy,
|
||||||
|
confirmedAt = LocalDateTime.now()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ 判断に使わないフィールドを保持
|
||||||
|
data class Order(
|
||||||
|
val orderId: String,
|
||||||
|
val customerId: String, // 判断に未使用
|
||||||
|
val shippingAddress: Address, // 判断に未使用
|
||||||
|
val status: OrderStatus
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**追加操作がないAggregateはIDのみ:**
|
||||||
|
```kotlin
|
||||||
|
// ✅ 作成のみで追加操作がない場合
|
||||||
|
data class Notification(val notificationId: String) {
|
||||||
|
companion object {
|
||||||
|
fun create(customerId: String, message: String): NotificationCreatedEvent {
|
||||||
|
return NotificationCreatedEvent(
|
||||||
|
notificationId = UUID.randomUUID().toString(),
|
||||||
|
customerId = customerId,
|
||||||
|
message = message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### 2. イベント設計
|
### 2. イベント設計
|
||||||
|
|
||||||
@ -60,7 +106,7 @@
|
|||||||
| CRUDスタイルのイベント(Updated, Deleted) | 要検討 |
|
| CRUDスタイルのイベント(Updated, Deleted) | 要検討 |
|
||||||
|
|
||||||
**良いイベント:**
|
**良いイベント:**
|
||||||
```
|
```kotlin
|
||||||
// Good: ドメインの意図が明確
|
// Good: ドメインの意図が明確
|
||||||
OrderPlaced, PaymentReceived, ItemShipped
|
OrderPlaced, PaymentReceived, ItemShipped
|
||||||
|
|
||||||
@ -108,7 +154,57 @@ OrderUpdated, OrderDeleted
|
|||||||
- イベントから冪等に再構築可能
|
- イベントから冪等に再構築可能
|
||||||
- Writeモデルから完全に独立
|
- Writeモデルから完全に独立
|
||||||
|
|
||||||
### 5. 結果整合性
|
### 5. Query側の設計
|
||||||
|
|
||||||
|
**原則: ControllerはQueryGatewayを使う。Repositoryを直接使わない。**
|
||||||
|
|
||||||
|
**レイヤー間の型:**
|
||||||
|
- `application/query/` - Query結果の型(例: `OrderDetail`)
|
||||||
|
- `adapter/protocol/` - RESTレスポンスの型(例: `OrderDetailResponse`)
|
||||||
|
- QueryHandlerはapplication層の型を返し、Controllerがadapter層の型に変換
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// application/query/OrderDetail.kt
|
||||||
|
data class OrderDetail(
|
||||||
|
val orderId: String,
|
||||||
|
val customerName: String,
|
||||||
|
val totalAmount: Money
|
||||||
|
)
|
||||||
|
|
||||||
|
// adapter/protocol/OrderDetailResponse.kt
|
||||||
|
data class OrderDetailResponse(...) {
|
||||||
|
companion object {
|
||||||
|
fun from(detail: OrderDetail) = OrderDetailResponse(...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryHandler - application層の型を返す
|
||||||
|
@QueryHandler
|
||||||
|
fun handle(query: GetOrderDetailQuery): OrderDetail? {
|
||||||
|
val entity = repository.findById(query.id) ?: return null
|
||||||
|
return OrderDetail(...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller - adapter層の型に変換
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun getById(@PathVariable id: String): ResponseEntity<OrderDetailResponse> {
|
||||||
|
val detail = queryGateway.query(
|
||||||
|
GetOrderDetailQuery(id),
|
||||||
|
OrderDetail::class.java
|
||||||
|
).join() ?: throw NotFoundException("...")
|
||||||
|
|
||||||
|
return ResponseEntity.ok(OrderDetailResponse.from(detail))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**構成:**
|
||||||
|
```
|
||||||
|
Controller (adapter) → QueryGateway → QueryHandler (application) → Repository
|
||||||
|
↓ ↓
|
||||||
|
Response.from(detail) OrderDetail
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 結果整合性
|
||||||
|
|
||||||
**必須チェック:**
|
**必須チェック:**
|
||||||
|
|
||||||
@ -118,7 +214,176 @@ OrderUpdated, OrderDeleted
|
|||||||
| 整合性遅延が許容範囲を超える | アーキテクチャ再検討 |
|
| 整合性遅延が許容範囲を超える | アーキテクチャ再検討 |
|
||||||
| 補償トランザクションが未定義 | 障害シナリオの検討を要求 |
|
| 補償トランザクションが未定義 | 障害シナリオの検討を要求 |
|
||||||
|
|
||||||
### 6. アンチパターン検出
|
### 7. Saga vs EventHandler
|
||||||
|
|
||||||
|
**原則: Sagaは「競合が発生する複数アグリゲート間の操作」にのみ使用する**
|
||||||
|
|
||||||
|
**Sagaが必要なケース:**
|
||||||
|
```
|
||||||
|
複数のアクターが同じリソースを取り合う場合
|
||||||
|
例: 在庫確保(10人が同時に同じ商品を注文)
|
||||||
|
|
||||||
|
OrderPlacedEvent
|
||||||
|
↓ InventoryReservationSaga
|
||||||
|
ReserveInventoryCommand → Inventory集約(同時実行を直列化)
|
||||||
|
↓
|
||||||
|
InventoryReservedEvent → ConfirmOrderCommand
|
||||||
|
InventoryReservationFailedEvent → CancelOrderCommand
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sagaが不要なケース:**
|
||||||
|
```
|
||||||
|
競合が発生しない操作
|
||||||
|
例: 注文キャンセル時の在庫解放
|
||||||
|
|
||||||
|
OrderCancelledEvent
|
||||||
|
↓ InventoryReleaseHandler(単純なEventHandler)
|
||||||
|
ReleaseInventoryCommand
|
||||||
|
↓
|
||||||
|
InventoryReleasedEvent
|
||||||
|
```
|
||||||
|
|
||||||
|
**判断基準:**
|
||||||
|
|
||||||
|
| 状況 | Saga | EventHandler |
|
||||||
|
|------|------|--------------|
|
||||||
|
| リソースの取り合いがある | ✅ | - |
|
||||||
|
| 補償トランザクションが必要 | ✅ | - |
|
||||||
|
| 競合しない単純な連携 | - | ✅ |
|
||||||
|
| 失敗時は再試行で十分 | - | ✅ |
|
||||||
|
|
||||||
|
**アンチパターン:**
|
||||||
|
```kotlin
|
||||||
|
// ❌ ライフサイクル管理のためにSagaを使う
|
||||||
|
@Saga
|
||||||
|
class OrderLifecycleSaga {
|
||||||
|
// 注文の全状態遷移をSagaで追跡
|
||||||
|
// PLACED → CONFIRMED → SHIPPED → DELIVERED
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 結果整合性が必要な操作だけをSagaで処理
|
||||||
|
@Saga
|
||||||
|
class InventoryReservationSaga {
|
||||||
|
// 在庫確保の同時実行制御のみ
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sagaはライフサイクル管理ツールではない。** 結果整合性が必要な「操作」単位で作成する。
|
||||||
|
|
||||||
|
### 8. 例外 vs イベント(失敗時の選択)
|
||||||
|
|
||||||
|
**原則: 監査不要な失敗は例外、監査が必要な失敗はイベント**
|
||||||
|
|
||||||
|
**例外アプローチ(推奨:ほとんどのケース):**
|
||||||
|
```kotlin
|
||||||
|
// ドメインモデル: バリデーション失敗時に例外をスロー
|
||||||
|
fun reserveInventory(orderId: String, quantity: Int): InventoryReservedEvent {
|
||||||
|
if (availableQuantity < quantity) {
|
||||||
|
throw InsufficientInventoryException("在庫が不足しています")
|
||||||
|
}
|
||||||
|
return InventoryReservedEvent(productId, orderId, quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saga: exceptionally でキャッチして補償アクション
|
||||||
|
commandGateway.send<Any>(command)
|
||||||
|
.exceptionally { ex ->
|
||||||
|
commandGateway.send<Any>(CancelOrderCommand(
|
||||||
|
orderId = orderId,
|
||||||
|
reason = ex.cause?.message ?: "在庫確保に失敗しました"
|
||||||
|
))
|
||||||
|
null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**イベントアプローチ(稀なケース):**
|
||||||
|
```kotlin
|
||||||
|
// 監査が必要な場合のみ
|
||||||
|
data class PaymentFailedEvent(
|
||||||
|
val paymentId: String,
|
||||||
|
val reason: String,
|
||||||
|
val attemptedAmount: Money
|
||||||
|
) : PaymentEvent
|
||||||
|
```
|
||||||
|
|
||||||
|
**判断基準:**
|
||||||
|
|
||||||
|
| 質問 | 例外 | イベント |
|
||||||
|
|------|------|----------|
|
||||||
|
| この失敗を後で確認する必要があるか? | No | Yes |
|
||||||
|
| 規制やコンプライアンスで記録が必要か? | No | Yes |
|
||||||
|
| Sagaだけが失敗を気にするか? | Yes | No |
|
||||||
|
| Event Storeに残すと価値があるか? | No | Yes |
|
||||||
|
|
||||||
|
**デフォルトは例外アプローチ。** 監査要件がある場合のみイベントを検討する。
|
||||||
|
|
||||||
|
### 9. 抽象化レベルの評価
|
||||||
|
|
||||||
|
**条件分岐の肥大化検出:**
|
||||||
|
|
||||||
|
| パターン | 判定 |
|
||||||
|
|---------|------|
|
||||||
|
| 同じif-elseパターンが3箇所以上 | ポリモーフィズムで抽象化 → **REJECT** |
|
||||||
|
| switch/caseが5分岐以上 | Strategy/Mapパターンを検討 |
|
||||||
|
| イベント種別による分岐が増殖 | イベントハンドラを分離 → **REJECT** |
|
||||||
|
| Aggregate内の状態分岐が複雑 | State Patternを検討 |
|
||||||
|
|
||||||
|
**抽象度の不一致検出:**
|
||||||
|
|
||||||
|
| パターン | 問題 | 修正案 |
|
||||||
|
|---------|------|--------|
|
||||||
|
| CommandHandlerにDB操作詳細 | 責務違反 | Repository層に分離 |
|
||||||
|
| EventHandlerにビジネスロジック | 責務違反 | ドメインサービスに抽出 |
|
||||||
|
| Aggregateに永続化処理 | レイヤー違反 | EventStore経由に変更 |
|
||||||
|
| Projectionに計算ロジック | 保守困難 | 専用サービスに抽出 |
|
||||||
|
|
||||||
|
**良い抽象化の例:**
|
||||||
|
```kotlin
|
||||||
|
// ❌ イベント種別による分岐の増殖
|
||||||
|
@EventHandler
|
||||||
|
fun on(event: DomainEvent) {
|
||||||
|
when (event) {
|
||||||
|
is OrderPlacedEvent -> handleOrderPlaced(event)
|
||||||
|
is OrderConfirmedEvent -> handleOrderConfirmed(event)
|
||||||
|
is OrderShippedEvent -> handleOrderShipped(event)
|
||||||
|
// ...どんどん増える
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ イベントごとにハンドラを分離
|
||||||
|
@EventHandler
|
||||||
|
fun on(event: OrderPlacedEvent) { ... }
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
fun on(event: OrderConfirmedEvent) { ... }
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
fun on(event: OrderShippedEvent) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ❌ 状態による分岐が複雑
|
||||||
|
fun process(command: ProcessCommand) {
|
||||||
|
when (status) {
|
||||||
|
PENDING -> if (command.type == "approve") { ... } else if (command.type == "reject") { ... }
|
||||||
|
APPROVED -> if (command.type == "ship") { ... }
|
||||||
|
// ...複雑化
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ State Patternで抽象化
|
||||||
|
sealed class OrderState {
|
||||||
|
abstract fun handle(command: ProcessCommand): List<DomainEvent>
|
||||||
|
}
|
||||||
|
class PendingState : OrderState() {
|
||||||
|
override fun handle(command: ProcessCommand) = when (command) {
|
||||||
|
is ApproveCommand -> listOf(OrderApprovedEvent(...))
|
||||||
|
is RejectCommand -> listOf(OrderRejectedEvent(...))
|
||||||
|
else -> throw InvalidCommandException()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. アンチパターン検出
|
||||||
|
|
||||||
以下を見つけたら **REJECT**:
|
以下を見つけたら **REJECT**:
|
||||||
|
|
||||||
@ -131,7 +396,59 @@ OrderUpdated, OrderDeleted
|
|||||||
| Missing Events | 重要なドメインイベントが欠落 |
|
| Missing Events | 重要なドメインイベントが欠落 |
|
||||||
| God Aggregate | 1つのAggregateに全責務が集中 |
|
| God Aggregate | 1つのAggregateに全責務が集中 |
|
||||||
|
|
||||||
### 7. インフラ層
|
### 11. テスト戦略
|
||||||
|
|
||||||
|
**原則: レイヤーごとにテスト方針を分ける**
|
||||||
|
|
||||||
|
**テストピラミッド:**
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ E2E Test │ ← 少数:全体フロー確認
|
||||||
|
├─────────────┤
|
||||||
|
│ Integration │ ← Command→Event→Projection→Query の連携確認
|
||||||
|
├─────────────┤
|
||||||
|
│ Unit Test │ ← 多数:各レイヤー独立テスト
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Command側(Aggregate):**
|
||||||
|
```kotlin
|
||||||
|
// AggregateTestFixture使用
|
||||||
|
@Test
|
||||||
|
fun `確定コマンドでイベントが発行される`() {
|
||||||
|
fixture
|
||||||
|
.given(OrderPlacedEvent(...))
|
||||||
|
.`when`(ConfirmOrderCommand(orderId, confirmedBy))
|
||||||
|
.expectSuccessfulHandlerExecution()
|
||||||
|
.expectEvents(OrderConfirmedEvent(...))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query側:**
|
||||||
|
```kotlin
|
||||||
|
// Read Model直接セットアップ + QueryGateway
|
||||||
|
@Test
|
||||||
|
fun `注文詳細が取得できる`() {
|
||||||
|
// Given: Read Modelを直接セットアップ
|
||||||
|
orderRepository.save(OrderEntity(...))
|
||||||
|
|
||||||
|
// When: QueryGateway経由でクエリ実行
|
||||||
|
val detail = queryGateway.query(GetOrderDetailQuery(orderId), ...).join()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertEquals(expectedDetail, detail)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**チェック項目:**
|
||||||
|
|
||||||
|
| 観点 | 判定 |
|
||||||
|
|------|------|
|
||||||
|
| Aggregateテストが状態ではなくイベントを検証している | 必須 |
|
||||||
|
| Query側テストがCommand経由でデータを作っていない | 推奨 |
|
||||||
|
| 統合テストでAxonの非同期処理を考慮している | 必須 |
|
||||||
|
|
||||||
|
### 12. インフラ層
|
||||||
|
|
||||||
**確認事項:**
|
**確認事項:**
|
||||||
- イベントストアの選択は適切か
|
- イベントストアの選択は適切か
|
||||||
@ -147,6 +464,7 @@ OrderUpdated, OrderDeleted
|
|||||||
| Aggregate設計に問題 | REJECT |
|
| Aggregate設計に問題 | REJECT |
|
||||||
| イベント設計が不適切 | REJECT |
|
| イベント設計が不適切 | REJECT |
|
||||||
| 結果整合性の考慮不足 | REJECT |
|
| 結果整合性の考慮不足 | REJECT |
|
||||||
|
| 抽象化レベルの不一致 | REJECT |
|
||||||
| 軽微な改善点のみ | APPROVE(改善提案は付記) |
|
| 軽微な改善点のみ | APPROVE(改善提案は付記) |
|
||||||
|
|
||||||
## 口調の特徴
|
## 口調の特徴
|
||||||
@ -162,3 +480,5 @@ OrderUpdated, OrderDeleted
|
|||||||
- **イベントの質にこだわる**: イベントはドメインの歴史書である
|
- **イベントの質にこだわる**: イベントはドメインの歴史書である
|
||||||
- **結果整合性を恐れない**: 正しく設計されたESは強整合性より堅牢
|
- **結果整合性を恐れない**: 正しく設計されたESは強整合性より堅牢
|
||||||
- **過度な複雑さを警戒**: シンプルなCRUDで十分なケースにCQRS+ESを強制しない
|
- **過度な複雑さを警戒**: シンプルなCRUDで十分なケースにCQRS+ESを強制しない
|
||||||
|
- **Aggregateは軽く保つ**: 判断に不要なフィールドは持たない
|
||||||
|
- **Sagaを乱用しない**: 競合制御が必要な操作にのみ使用する
|
||||||
|
|||||||
@ -36,6 +36,15 @@
|
|||||||
|
|
||||||
### 1. コンポーネント設計
|
### 1. コンポーネント設計
|
||||||
|
|
||||||
|
**原則: 1ファイルにベタ書きしない。必ずコンポーネント分割する。**
|
||||||
|
|
||||||
|
**分離が必須なケース:**
|
||||||
|
- 独自のstateを持つ → 必ず分離
|
||||||
|
- 50行超のJSX → 分離
|
||||||
|
- 再利用可能 → 分離
|
||||||
|
- 責務が複数 → 分離
|
||||||
|
- ページ内の独立したセクション → 分離
|
||||||
|
|
||||||
**必須チェック:**
|
**必須チェック:**
|
||||||
|
|
||||||
| 基準 | 判定 |
|
| 基準 | 判定 |
|
||||||
@ -60,8 +69,44 @@
|
|||||||
| Layout | 配置・構造 | `PageLayout`, `Grid` |
|
| Layout | 配置・構造 | `PageLayout`, `Grid` |
|
||||||
| Utility | 共通機能 | `ErrorBoundary`, `Portal` |
|
| Utility | 共通機能 | `ErrorBoundary`, `Portal` |
|
||||||
|
|
||||||
|
**ディレクトリ構成:**
|
||||||
|
```
|
||||||
|
features/{feature-name}/
|
||||||
|
├── components/
|
||||||
|
│ ├── {feature}-view.tsx # メインビュー(子を組み合わせる)
|
||||||
|
│ ├── {sub-component}.tsx # サブコンポーネント
|
||||||
|
│ └── index.ts
|
||||||
|
├── hooks/
|
||||||
|
├── types.ts
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
### 2. 状態管理
|
### 2. 状態管理
|
||||||
|
|
||||||
|
**原則: 子コンポーネントは自身で状態を変更しない。イベントを親にバブリングし、親が状態を操作する。**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 子が自分で状態を変更
|
||||||
|
const ChildBad = ({ initialValue }: { initialValue: string }) => {
|
||||||
|
const [value, setValue] = useState(initialValue)
|
||||||
|
return <input value={value} onChange={e => setValue(e.target.value)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 親が状態を管理、子はコールバックで通知
|
||||||
|
const ChildGood = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => {
|
||||||
|
return <input value={value} onChange={e => onChange(e.target.value)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const Parent = () => {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
return <ChildGood value={value} onChange={setValue} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**例外(子がローカルstate持ってOK):**
|
||||||
|
- UI専用の一時状態(ホバー、フォーカス、アニメーション)
|
||||||
|
- 親に伝える必要がない完全にローカルな状態
|
||||||
|
|
||||||
**必須チェック:**
|
**必須チェック:**
|
||||||
|
|
||||||
| 基準 | 判定 |
|
| 基準 | 判定 |
|
||||||
@ -81,7 +126,252 @@
|
|||||||
| 複数コンポーネントで共有 | Context or 状態管理ライブラリ |
|
| 複数コンポーネントで共有 | Context or 状態管理ライブラリ |
|
||||||
| サーバーデータのキャッシュ | TanStack Query等のデータフェッチライブラリ |
|
| サーバーデータのキャッシュ | TanStack Query等のデータフェッチライブラリ |
|
||||||
|
|
||||||
### 3. パフォーマンス
|
### 3. データ取得
|
||||||
|
|
||||||
|
**原則: API呼び出しはルート(View)コンポーネントで行い、子コンポーネントにはpropsで渡す。**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ CORRECT - ルートでデータ取得、子に渡す
|
||||||
|
const OrderDetailView = () => {
|
||||||
|
const { data: order, isLoading, error } = useGetOrder(orderId)
|
||||||
|
const { data: items } = useListOrderItems(orderId)
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton />
|
||||||
|
if (error) return <ErrorDisplay error={error} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OrderSummary
|
||||||
|
order={order}
|
||||||
|
items={items}
|
||||||
|
onItemSelect={handleItemSelect}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ WRONG - 子コンポーネントが自分でデータ取得
|
||||||
|
const OrderSummary = ({ orderId }) => {
|
||||||
|
const { data: order } = useGetOrder(orderId)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由:**
|
||||||
|
- データフローが明示的で追跡しやすい
|
||||||
|
- 子コンポーネントは純粋なプレゼンテーション(テストしやすい)
|
||||||
|
- 子コンポーネントに隠れた依存関係がなくなる
|
||||||
|
|
||||||
|
**UIの状態変更でパラメータが変わる場合(週切り替え、フィルタ等):**
|
||||||
|
|
||||||
|
状態もViewレベルで管理し、コンポーネントにはコールバックを渡す。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ CORRECT - 状態もViewで管理
|
||||||
|
const ScheduleView = () => {
|
||||||
|
const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date()))
|
||||||
|
const { data } = useListSchedules({
|
||||||
|
from: format(currentWeek, 'yyyy-MM-dd'),
|
||||||
|
to: format(endOfWeek(currentWeek), 'yyyy-MM-dd'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WeeklyCalendar
|
||||||
|
schedules={data?.items ?? []}
|
||||||
|
currentWeek={currentWeek}
|
||||||
|
onWeekChange={setCurrentWeek}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ WRONG - コンポーネント内で状態管理+データ取得
|
||||||
|
const WeeklyCalendar = ({ facilityId }) => {
|
||||||
|
const [currentWeek, setCurrentWeek] = useState(...)
|
||||||
|
const { data } = useListSchedules({ facilityId, from, to })
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**例外(コンポーネント内フェッチが許容されるケース):**
|
||||||
|
|
||||||
|
| ケース | 理由 |
|
||||||
|
|--------|------|
|
||||||
|
| 無限スクロール | スクロール位置というUI内部状態に依存 |
|
||||||
|
| 検索オートコンプリート | 入力値に依存したリアルタイム検索 |
|
||||||
|
| 独立したウィジェット | 通知バッジ、天気等。親のデータと完全に無関係 |
|
||||||
|
| リアルタイム更新 | WebSocket/Pollingでの自動更新 |
|
||||||
|
| モーダル内の詳細取得 | 開いたときだけ追加データを取得 |
|
||||||
|
|
||||||
|
**判断基準: 「親が管理する意味がない / 親に影響を与えない」ケースのみ許容。**
|
||||||
|
|
||||||
|
**必須チェック:**
|
||||||
|
|
||||||
|
| 基準 | 判定 |
|
||||||
|
|------|------|
|
||||||
|
| コンポーネント内で直接fetch | Container層に分離 |
|
||||||
|
| エラーハンドリングなし | REJECT |
|
||||||
|
| ローディング状態の未処理 | REJECT |
|
||||||
|
| キャンセル処理なし | 警告 |
|
||||||
|
| N+1クエリ的なフェッチ | REJECT |
|
||||||
|
|
||||||
|
### 4. 共有コンポーネントと抽象化
|
||||||
|
|
||||||
|
**原則: 同じパターンのUIは共有コンポーネント化する。インラインスタイルのコピペは禁止。**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ WRONG - インラインスタイルのコピペ
|
||||||
|
<button className="p-2 text-[var(--text-secondary)] hover:...">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// ✅ CORRECT - 共有コンポーネント使用
|
||||||
|
<IconButton onClick={onClose} aria-label="閉じる">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</IconButton>
|
||||||
|
```
|
||||||
|
|
||||||
|
**共有コンポーネント化すべきパターン:**
|
||||||
|
- アイコンボタン(閉じる、編集、削除等)
|
||||||
|
- ローディング/エラー表示
|
||||||
|
- ステータスバッジ
|
||||||
|
- タブ切り替え
|
||||||
|
- ラベル+値の表示(詳細画面)
|
||||||
|
- 検索入力
|
||||||
|
- カラー凡例
|
||||||
|
|
||||||
|
**過度な汎用化を避ける:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ WRONG - IconButtonに無理やりステッパー用バリアントを追加
|
||||||
|
export const iconButtonVariants = cva('...', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: '...',
|
||||||
|
outlined: '...', // ← ステッパー専用、他で使わない
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
medium: 'p-2',
|
||||||
|
stepper: 'w-8 h-8', // ← outlinedとセットでしか使わない
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ✅ CORRECT - 用途別に専用コンポーネント
|
||||||
|
export function StepperButton(props) {
|
||||||
|
return (
|
||||||
|
<button className="w-8 h-8 rounded-full border ..." {...props}>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**別コンポーネントにすべきサイン:**
|
||||||
|
- 「このvariantはこのsizeとセット」のような暗黙の制約がある
|
||||||
|
- 追加したvariantが元のコンポーネントの用途と明らかに違う
|
||||||
|
- 使う側のprops指定が複雑になる
|
||||||
|
|
||||||
|
### 5. 抽象化レベルの評価
|
||||||
|
|
||||||
|
**条件分岐の肥大化検出:**
|
||||||
|
|
||||||
|
| パターン | 判定 |
|
||||||
|
|---------|------|
|
||||||
|
| 同じ条件分岐が3箇所以上 | 共通コンポーネントに抽出 → **REJECT** |
|
||||||
|
| propsによる分岐が5種類以上 | コンポーネント分割を検討 |
|
||||||
|
| render内の三項演算子のネスト | 早期リターンまたはコンポーネント分離 → **REJECT** |
|
||||||
|
| 型による分岐レンダリング | ポリモーフィックコンポーネントを検討 |
|
||||||
|
|
||||||
|
**抽象度の不一致検出:**
|
||||||
|
|
||||||
|
| パターン | 問題 | 修正案 |
|
||||||
|
|---------|------|--------|
|
||||||
|
| データ取得ロジックがJSXに混在 | 読みにくい | カスタムフックに抽出 |
|
||||||
|
| ビジネスロジックがコンポーネントに混在 | 責務違反 | hooks/utilsに分離 |
|
||||||
|
| スタイル計算ロジックが散在 | 保守困難 | ユーティリティ関数に抽出 |
|
||||||
|
| 同じ変換処理が複数箇所に | DRY違反 | 共通関数に抽出 |
|
||||||
|
|
||||||
|
**良い抽象化の例:**
|
||||||
|
```tsx
|
||||||
|
// ❌ 条件分岐が肥大化
|
||||||
|
function UserBadge({ user }) {
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
return <span className="bg-red-500">管理者</span>
|
||||||
|
} else if (user.role === 'moderator') {
|
||||||
|
return <span className="bg-yellow-500">モデレーター</span>
|
||||||
|
} else if (user.role === 'premium') {
|
||||||
|
return <span className="bg-purple-500">プレミアム</span>
|
||||||
|
} else {
|
||||||
|
return <span className="bg-gray-500">一般</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Mapで抽象化
|
||||||
|
const ROLE_CONFIG = {
|
||||||
|
admin: { label: '管理者', className: 'bg-red-500' },
|
||||||
|
moderator: { label: 'モデレーター', className: 'bg-yellow-500' },
|
||||||
|
premium: { label: 'プレミアム', className: 'bg-purple-500' },
|
||||||
|
default: { label: '一般', className: 'bg-gray-500' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserBadge({ user }) {
|
||||||
|
const config = ROLE_CONFIG[user.role] ?? ROLE_CONFIG.default
|
||||||
|
return <span className={config.className}>{config.label}</span>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 抽象度が混在
|
||||||
|
function OrderList() {
|
||||||
|
const [orders, setOrders] = useState([])
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/orders')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setOrders(data))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return orders.map(order => (
|
||||||
|
<div>{order.total.toLocaleString()}円</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 抽象度を揃える
|
||||||
|
function OrderList() {
|
||||||
|
const { data: orders } = useOrders() // データ取得を隠蔽
|
||||||
|
|
||||||
|
return orders.map(order => (
|
||||||
|
<OrderItem key={order.id} order={order} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. データと表示形式の責務分離
|
||||||
|
|
||||||
|
**原則: バックエンドは「データ」を返し、フロントエンドが「表示形式」に変換する。**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ フロントエンド: 表示形式に変換
|
||||||
|
export function formatPrice(amount: number): string {
|
||||||
|
return `¥${amount.toLocaleString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: Date): string {
|
||||||
|
return format(date, 'yyyy年M月d日')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由:**
|
||||||
|
- 表示形式はUI要件であり、バックエンドの責務ではない
|
||||||
|
- 国際化対応が容易
|
||||||
|
- フロントエンドが柔軟に表示を変更できる
|
||||||
|
|
||||||
|
**必須チェック:**
|
||||||
|
|
||||||
|
| 基準 | 判定 |
|
||||||
|
|------|------|
|
||||||
|
| バックエンドが表示用文字列を返している | 設計見直しを提案 |
|
||||||
|
| 同じフォーマット処理が複数箇所にコピペ | ユーティリティ関数に統一 |
|
||||||
|
| コンポーネント内でインラインフォーマット | 関数に抽出 |
|
||||||
|
|
||||||
|
### 7. パフォーマンス
|
||||||
|
|
||||||
**必須チェック:**
|
**必須チェック:**
|
||||||
|
|
||||||
@ -102,40 +392,15 @@
|
|||||||
**アンチパターン:**
|
**アンチパターン:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Bad: レンダリングごとに新しいオブジェクト
|
// ❌ レンダリングごとに新しいオブジェクト
|
||||||
<Child style={{ color: 'red' }} />
|
<Child style={{ color: 'red' }} />
|
||||||
|
|
||||||
// Good: 定数化 or useMemo
|
// ✅ 定数化 or useMemo
|
||||||
const style = useMemo(() => ({ color: 'red' }), []);
|
const style = useMemo(() => ({ color: 'red' }), []);
|
||||||
<Child style={style} />
|
<Child style={style} />
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. データフェッチ
|
### 8. アクセシビリティ
|
||||||
|
|
||||||
**必須チェック:**
|
|
||||||
|
|
||||||
| 基準 | 判定 |
|
|
||||||
|------|------|
|
|
||||||
| コンポーネント内で直接fetch | Container層に分離 |
|
|
||||||
| エラーハンドリングなし | REJECT |
|
|
||||||
| ローディング状態の未処理 | REJECT |
|
|
||||||
| キャンセル処理なし | 警告 |
|
|
||||||
| N+1クエリ的なフェッチ | REJECT |
|
|
||||||
|
|
||||||
**推奨パターン:**
|
|
||||||
```tsx
|
|
||||||
// Good: データフェッチはルートで
|
|
||||||
function UserPage() {
|
|
||||||
const { data, isLoading, error } = useQuery(['user', id], fetchUser);
|
|
||||||
|
|
||||||
if (isLoading) return <Skeleton />;
|
|
||||||
if (error) return <ErrorDisplay error={error} />;
|
|
||||||
|
|
||||||
return <UserProfile user={data} />;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. アクセシビリティ
|
|
||||||
|
|
||||||
**必須チェック:**
|
**必須チェック:**
|
||||||
|
|
||||||
@ -154,7 +419,7 @@ function UserPage() {
|
|||||||
- [ ] スクリーンリーダーで意味が通じるか
|
- [ ] スクリーンリーダーで意味が通じるか
|
||||||
- [ ] カラーコントラストは十分か
|
- [ ] カラーコントラストは十分か
|
||||||
|
|
||||||
### 6. TypeScript/型安全性
|
### 9. TypeScript/型安全性
|
||||||
|
|
||||||
**必須チェック:**
|
**必須チェック:**
|
||||||
|
|
||||||
@ -165,7 +430,7 @@ function UserPage() {
|
|||||||
| Props型定義なし | REJECT |
|
| Props型定義なし | REJECT |
|
||||||
| イベントハンドラの型が不適切 | 修正が必要 |
|
| イベントハンドラの型が不適切 | 修正が必要 |
|
||||||
|
|
||||||
### 7. フロントエンドセキュリティ
|
### 10. フロントエンドセキュリティ
|
||||||
|
|
||||||
**必須チェック:**
|
**必須チェック:**
|
||||||
|
|
||||||
@ -176,7 +441,7 @@ function UserPage() {
|
|||||||
| 機密情報のフロントエンド保存 | REJECT |
|
| 機密情報のフロントエンド保存 | REJECT |
|
||||||
| CSRFトークンの未使用 | 要確認 |
|
| CSRFトークンの未使用 | 要確認 |
|
||||||
|
|
||||||
### 8. テスタビリティ
|
### 11. テスタビリティ
|
||||||
|
|
||||||
**必須チェック:**
|
**必須チェック:**
|
||||||
|
|
||||||
@ -186,7 +451,7 @@ function UserPage() {
|
|||||||
| テスト困難な構造 | 分離を検討 |
|
| テスト困難な構造 | 分離を検討 |
|
||||||
| ビジネスロジックのUIへの埋め込み | REJECT |
|
| ビジネスロジックのUIへの埋め込み | REJECT |
|
||||||
|
|
||||||
### 9. アンチパターン検出
|
### 12. アンチパターン検出
|
||||||
|
|
||||||
以下を見つけたら **REJECT**:
|
以下を見つけたら **REJECT**:
|
||||||
|
|
||||||
@ -198,6 +463,8 @@ function UserPage() {
|
|||||||
| useEffect地獄 | 依存関係が複雑すぎる |
|
| useEffect地獄 | 依存関係が複雑すぎる |
|
||||||
| Premature Optimization | 不要なメモ化 |
|
| Premature Optimization | 不要なメモ化 |
|
||||||
| Magic Strings | ハードコードされた文字列 |
|
| Magic Strings | ハードコードされた文字列 |
|
||||||
|
| Hidden Dependencies | 子コンポーネントの隠れたAPI呼び出し |
|
||||||
|
| Over-generalization | 無理やり汎用化したコンポーネント |
|
||||||
|
|
||||||
## 判定基準
|
## 判定基準
|
||||||
|
|
||||||
@ -206,6 +473,7 @@ function UserPage() {
|
|||||||
| コンポーネント設計に問題 | REJECT |
|
| コンポーネント設計に問題 | REJECT |
|
||||||
| 状態管理に問題 | REJECT |
|
| 状態管理に問題 | REJECT |
|
||||||
| アクセシビリティ違反 | REJECT |
|
| アクセシビリティ違反 | REJECT |
|
||||||
|
| 抽象化レベルの不一致 | REJECT |
|
||||||
| パフォーマンス問題 | REJECT(重大な場合) |
|
| パフォーマンス問題 | REJECT(重大な場合) |
|
||||||
| 軽微な改善点のみ | APPROVE(改善提案は付記) |
|
| 軽微な改善点のみ | APPROVE(改善提案は付記) |
|
||||||
|
|
||||||
@ -223,3 +491,5 @@ function UserPage() {
|
|||||||
- **アクセシビリティは後付け困難**: 最初から組み込む
|
- **アクセシビリティは後付け困難**: 最初から組み込む
|
||||||
- **過度な抽象化を警戒**: シンプルに保つ
|
- **過度な抽象化を警戒**: シンプルに保つ
|
||||||
- **フレームワークの作法に従う**: 独自パターンより標準的なアプローチ
|
- **フレームワークの作法に従う**: 独自パターンより標準的なアプローチ
|
||||||
|
- **データ取得はルートで**: 子コンポーネントに隠れた依存を作らない
|
||||||
|
- **制御されたコンポーネント**: 状態の流れは単方向
|
||||||
|
|||||||
@ -631,7 +631,7 @@ steps:
|
|||||||
pass_previous_response: true
|
pass_previous_response: true
|
||||||
transitions:
|
transitions:
|
||||||
- condition: done
|
- condition: done
|
||||||
next_step: cqrs_es_review
|
next_step: ai_review
|
||||||
- condition: blocked
|
- condition: blocked
|
||||||
next_step: plan
|
next_step: plan
|
||||||
|
|
||||||
|
|||||||
@ -6,17 +6,15 @@ import { execSync } from 'node:child_process';
|
|||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { basename, dirname } from 'node:path';
|
import { basename, dirname } from 'node:path';
|
||||||
import {
|
import {
|
||||||
callClaude,
|
|
||||||
callClaudeCustom,
|
|
||||||
callClaudeAgent,
|
callClaudeAgent,
|
||||||
callClaudeSkill,
|
callClaudeSkill,
|
||||||
ClaudeCallOptions,
|
type ClaudeCallOptions,
|
||||||
} from '../claude/client.js';
|
} from '../claude/client.js';
|
||||||
import { type StreamCallback, type PermissionHandler, type AskUserQuestionHandler } from '../claude/process.js';
|
import { type StreamCallback, type PermissionHandler, type AskUserQuestionHandler } from '../claude/process.js';
|
||||||
import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/client.js';
|
|
||||||
import { loadCustomAgents, loadAgentPrompt } from '../config/loader.js';
|
import { loadCustomAgents, loadAgentPrompt } from '../config/loader.js';
|
||||||
import { loadGlobalConfig } from '../config/globalConfig.js';
|
import { loadGlobalConfig } from '../config/globalConfig.js';
|
||||||
import { loadProjectConfig } from '../config/projectConfig.js';
|
import { loadProjectConfig } from '../config/projectConfig.js';
|
||||||
|
import { getProvider, type ProviderType, type ProviderCallOptions } from '../providers/index.js';
|
||||||
import type { AgentResponse, CustomAgentConfig } from '../models/types.js';
|
import type { AgentResponse, CustomAgentConfig } from '../models/types.js';
|
||||||
|
|
||||||
export type { StreamCallback };
|
export type { StreamCallback };
|
||||||
@ -26,7 +24,7 @@ export interface RunAgentOptions {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
provider?: 'claude' | 'codex';
|
provider?: 'claude' | 'codex' | 'mock';
|
||||||
/** Resolved path to agent prompt file */
|
/** Resolved path to agent prompt file */
|
||||||
agentPath?: string;
|
agentPath?: string;
|
||||||
/** Allowed tools for this agent run */
|
/** Allowed tools for this agent run */
|
||||||
@ -40,9 +38,8 @@ export interface RunAgentOptions {
|
|||||||
bypassPermissions?: boolean;
|
bypassPermissions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentProvider = 'claude' | 'codex';
|
function resolveProvider(cwd: string, options?: RunAgentOptions, agentConfig?: CustomAgentConfig): ProviderType {
|
||||||
|
// Mock provider must be explicitly specified (no fallback)
|
||||||
function resolveProvider(cwd: string, options?: RunAgentOptions, agentConfig?: CustomAgentConfig): AgentProvider {
|
|
||||||
if (options?.provider) return options.provider;
|
if (options?.provider) return options.provider;
|
||||||
if (agentConfig?.provider) return agentConfig.provider;
|
if (agentConfig?.provider) return agentConfig.provider;
|
||||||
const projectConfig = loadProjectConfig(cwd);
|
const projectConfig = loadProjectConfig(cwd);
|
||||||
@ -137,33 +134,22 @@ export async function runCustomAgent(
|
|||||||
systemPrompt = `${systemPrompt}\n\n${options.statusRulesPrompt}`;
|
systemPrompt = `${systemPrompt}\n\n${options.statusRulesPrompt}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools = allowedTools;
|
const providerType = resolveProvider(options.cwd, options, agentConfig);
|
||||||
const provider = resolveProvider(options.cwd, options, agentConfig);
|
const provider = getProvider(providerType);
|
||||||
const model = resolveModel(options.cwd, options, agentConfig);
|
|
||||||
if (provider === 'codex') {
|
|
||||||
const callOptions: CodexCallOptions = {
|
|
||||||
cwd: options.cwd,
|
|
||||||
sessionId: options.sessionId,
|
|
||||||
model,
|
|
||||||
statusPatterns: agentConfig.statusPatterns,
|
|
||||||
onStream: options.onStream,
|
|
||||||
};
|
|
||||||
return callCodexCustom(agentConfig.name, task, systemPrompt, callOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
const callOptions: ClaudeCallOptions = {
|
const callOptions: ProviderCallOptions = {
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
sessionId: options.sessionId,
|
sessionId: options.sessionId,
|
||||||
allowedTools: tools,
|
allowedTools,
|
||||||
model,
|
model: resolveModel(options.cwd, options, agentConfig),
|
||||||
statusPatterns: agentConfig.statusPatterns,
|
statusPatterns: agentConfig.statusPatterns,
|
||||||
onStream: options.onStream,
|
onStream: options.onStream,
|
||||||
onPermissionRequest: options.onPermissionRequest,
|
onPermissionRequest: options.onPermissionRequest,
|
||||||
onAskUserQuestion: options.onAskUserQuestion,
|
onAskUserQuestion: options.onAskUserQuestion,
|
||||||
bypassPermissions: options.bypassPermissions,
|
bypassPermissions: options.bypassPermissions,
|
||||||
};
|
};
|
||||||
|
|
||||||
return callClaudeCustom(agentConfig.name, task, systemPrompt, callOptions);
|
return provider.callCustom(agentConfig.name, task, systemPrompt, callOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -221,26 +207,14 @@ export async function runAgent(
|
|||||||
systemPrompt = `${systemPrompt}\n\n${options.statusRulesPrompt}`;
|
systemPrompt = `${systemPrompt}\n\n${options.statusRulesPrompt}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools = options.allowedTools;
|
const providerType = resolveProvider(options.cwd, options);
|
||||||
const provider = resolveProvider(options.cwd, options);
|
const provider = getProvider(providerType);
|
||||||
const model = resolveModel(options.cwd, options);
|
|
||||||
|
|
||||||
if (provider === 'codex') {
|
const callOptions: ProviderCallOptions = {
|
||||||
const callOptions: CodexCallOptions = {
|
|
||||||
cwd: options.cwd,
|
|
||||||
sessionId: options.sessionId,
|
|
||||||
model,
|
|
||||||
systemPrompt,
|
|
||||||
onStream: options.onStream,
|
|
||||||
};
|
|
||||||
return callCodex(agentName, task, callOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
const callOptions: ClaudeCallOptions = {
|
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
sessionId: options.sessionId,
|
sessionId: options.sessionId,
|
||||||
allowedTools: tools,
|
allowedTools: options.allowedTools,
|
||||||
model,
|
model: resolveModel(options.cwd, options),
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
onStream: options.onStream,
|
onStream: options.onStream,
|
||||||
onPermissionRequest: options.onPermissionRequest,
|
onPermissionRequest: options.onPermissionRequest,
|
||||||
@ -248,7 +222,7 @@ export async function runAgent(
|
|||||||
bypassPermissions: options.bypassPermissions,
|
bypassPermissions: options.bypassPermissions,
|
||||||
};
|
};
|
||||||
|
|
||||||
return callClaude(agentName, task, callOptions);
|
return provider.call(agentName, task, callOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Look for custom agent by name
|
// Fallback: Look for custom agent by name
|
||||||
|
|||||||
85
src/mock/client.ts
Normal file
85
src/mock/client.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Mock agent client for testing
|
||||||
|
*
|
||||||
|
* Returns immediate fixed responses without any API calls.
|
||||||
|
* Useful for testing workflows without incurring costs or latency.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { StreamCallback, StreamEvent } from '../claude/process.js';
|
||||||
|
import type { AgentResponse } from '../models/types.js';
|
||||||
|
|
||||||
|
/** Options for mock calls */
|
||||||
|
export interface MockCallOptions {
|
||||||
|
cwd: string;
|
||||||
|
sessionId?: string;
|
||||||
|
onStream?: StreamCallback;
|
||||||
|
/** Fixed response content (optional, defaults to generic mock response) */
|
||||||
|
mockResponse?: string;
|
||||||
|
/** Fixed status to return (optional, defaults to 'done') */
|
||||||
|
mockStatus?: 'done' | 'blocked' | 'approved' | 'rejected' | 'improve';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a mock session ID
|
||||||
|
*/
|
||||||
|
function generateMockSessionId(): string {
|
||||||
|
return `mock-session-${randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call mock agent - returns immediate fixed response
|
||||||
|
*/
|
||||||
|
export async function callMock(
|
||||||
|
agentName: string,
|
||||||
|
prompt: string,
|
||||||
|
options: MockCallOptions
|
||||||
|
): Promise<AgentResponse> {
|
||||||
|
const sessionId = options.sessionId ?? generateMockSessionId();
|
||||||
|
const status = options.mockStatus ?? 'done';
|
||||||
|
const statusMarker = `[MOCK:${status.toUpperCase()}]`;
|
||||||
|
const content = options.mockResponse ??
|
||||||
|
`${statusMarker}\n\nMock response for agent "${agentName}".\nPrompt: ${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}`;
|
||||||
|
|
||||||
|
// Emit stream events if callback is provided
|
||||||
|
if (options.onStream) {
|
||||||
|
const initEvent: StreamEvent = {
|
||||||
|
type: 'init',
|
||||||
|
data: { model: 'mock-model', sessionId },
|
||||||
|
};
|
||||||
|
options.onStream(initEvent);
|
||||||
|
|
||||||
|
const textEvent: StreamEvent = {
|
||||||
|
type: 'text',
|
||||||
|
data: { text: content },
|
||||||
|
};
|
||||||
|
options.onStream(textEvent);
|
||||||
|
|
||||||
|
const resultEvent: StreamEvent = {
|
||||||
|
type: 'result',
|
||||||
|
data: { success: true, result: content, sessionId },
|
||||||
|
};
|
||||||
|
options.onStream(resultEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent: agentName,
|
||||||
|
status,
|
||||||
|
content,
|
||||||
|
timestamp: new Date(),
|
||||||
|
sessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call mock agent with custom system prompt (same as callMock for mock provider)
|
||||||
|
*/
|
||||||
|
export async function callMockCustom(
|
||||||
|
agentName: string,
|
||||||
|
prompt: string,
|
||||||
|
_systemPrompt: string,
|
||||||
|
options: MockCallOptions
|
||||||
|
): Promise<AgentResponse> {
|
||||||
|
// For mock, system prompt is ignored - just return fixed response
|
||||||
|
return callMock(agentName, prompt, options);
|
||||||
|
}
|
||||||
@ -60,7 +60,7 @@ export const WorkflowStepRawSchema = z.object({
|
|||||||
/** Display name for the agent (shown in output). Falls back to agent basename if not specified */
|
/** Display name for the agent (shown in output). Falls back to agent basename if not specified */
|
||||||
agent_name: z.string().optional(),
|
agent_name: z.string().optional(),
|
||||||
allowed_tools: z.array(z.string()).optional(),
|
allowed_tools: z.array(z.string()).optional(),
|
||||||
provider: z.enum(['claude', 'codex']).optional(),
|
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
instruction: z.string().optional(),
|
instruction: z.string().optional(),
|
||||||
instruction_template: z.string().optional(),
|
instruction_template: z.string().optional(),
|
||||||
@ -94,7 +94,7 @@ export const CustomAgentConfigSchema = z.object({
|
|||||||
status_patterns: z.record(z.string(), z.string()).optional(),
|
status_patterns: z.record(z.string(), z.string()).optional(),
|
||||||
claude_agent: z.string().optional(),
|
claude_agent: z.string().optional(),
|
||||||
claude_skill: z.string().optional(),
|
claude_skill: z.string().optional(),
|
||||||
provider: z.enum(['claude', 'codex']).optional(),
|
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
}).refine(
|
}).refine(
|
||||||
(data) => data.prompt_file || data.prompt || data.claude_agent || data.claude_skill,
|
(data) => data.prompt_file || data.prompt || data.claude_agent || data.claude_skill,
|
||||||
@ -116,7 +116,7 @@ export const GlobalConfigSchema = z.object({
|
|||||||
trusted_directories: z.array(z.string()).optional().default([]),
|
trusted_directories: z.array(z.string()).optional().default([]),
|
||||||
default_workflow: z.string().optional().default('default'),
|
default_workflow: z.string().optional().default('default'),
|
||||||
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
|
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
|
||||||
provider: z.enum(['claude', 'codex']).optional().default('claude'),
|
provider: z.enum(['claude', 'codex', 'mock']).optional().default('claude'),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
debug: DebugConfigSchema.optional(),
|
debug: DebugConfigSchema.optional(),
|
||||||
});
|
});
|
||||||
@ -125,7 +125,7 @@ export const GlobalConfigSchema = z.object({
|
|||||||
export const ProjectConfigSchema = z.object({
|
export const ProjectConfigSchema = z.object({
|
||||||
workflow: z.string().optional(),
|
workflow: z.string().optional(),
|
||||||
agents: z.array(CustomAgentConfigSchema).optional(),
|
agents: z.array(CustomAgentConfigSchema).optional(),
|
||||||
provider: z.enum(['claude', 'codex']).optional(),
|
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -65,7 +65,7 @@ export interface WorkflowStep {
|
|||||||
/** Resolved absolute path to agent prompt file (set by loader) */
|
/** Resolved absolute path to agent prompt file (set by loader) */
|
||||||
agentPath?: string;
|
agentPath?: string;
|
||||||
/** Provider override for this step */
|
/** Provider override for this step */
|
||||||
provider?: 'claude' | 'codex';
|
provider?: 'claude' | 'codex' | 'mock';
|
||||||
/** Model override for this step */
|
/** Model override for this step */
|
||||||
model?: string;
|
model?: string;
|
||||||
instructionTemplate: string;
|
instructionTemplate: string;
|
||||||
@ -129,7 +129,7 @@ export interface CustomAgentConfig {
|
|||||||
statusPatterns?: Record<string, string>;
|
statusPatterns?: Record<string, string>;
|
||||||
claudeAgent?: string;
|
claudeAgent?: string;
|
||||||
claudeSkill?: string;
|
claudeSkill?: string;
|
||||||
provider?: 'claude' | 'codex';
|
provider?: 'claude' | 'codex' | 'mock';
|
||||||
model?: string;
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,7 +148,7 @@ export interface GlobalConfig {
|
|||||||
trustedDirectories: string[];
|
trustedDirectories: string[];
|
||||||
defaultWorkflow: string;
|
defaultWorkflow: string;
|
||||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||||
provider?: 'claude' | 'codex';
|
provider?: 'claude' | 'codex' | 'mock';
|
||||||
model?: string;
|
model?: string;
|
||||||
debug?: DebugConfig;
|
debug?: DebugConfig;
|
||||||
}
|
}
|
||||||
@ -157,5 +157,5 @@ export interface GlobalConfig {
|
|||||||
export interface ProjectConfig {
|
export interface ProjectConfig {
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
agents?: CustomAgentConfig[];
|
agents?: CustomAgentConfig[];
|
||||||
provider?: 'claude' | 'codex';
|
provider?: 'claude' | 'codex' | 'mock';
|
||||||
}
|
}
|
||||||
|
|||||||
43
src/providers/claude.ts
Normal file
43
src/providers/claude.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Claude provider implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { callClaude, callClaudeCustom, type ClaudeCallOptions } from '../claude/client.js';
|
||||||
|
import type { AgentResponse } from '../models/types.js';
|
||||||
|
import type { Provider, ProviderCallOptions } from './index.js';
|
||||||
|
|
||||||
|
/** Claude provider - wraps existing Claude client */
|
||||||
|
export class ClaudeProvider implements Provider {
|
||||||
|
async call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse> {
|
||||||
|
const callOptions: ClaudeCallOptions = {
|
||||||
|
cwd: options.cwd,
|
||||||
|
sessionId: options.sessionId,
|
||||||
|
allowedTools: options.allowedTools,
|
||||||
|
model: options.model,
|
||||||
|
systemPrompt: options.systemPrompt,
|
||||||
|
statusPatterns: options.statusPatterns,
|
||||||
|
onStream: options.onStream,
|
||||||
|
onPermissionRequest: options.onPermissionRequest,
|
||||||
|
onAskUserQuestion: options.onAskUserQuestion,
|
||||||
|
bypassPermissions: options.bypassPermissions,
|
||||||
|
};
|
||||||
|
|
||||||
|
return callClaude(agentName, prompt, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse> {
|
||||||
|
const callOptions: ClaudeCallOptions = {
|
||||||
|
cwd: options.cwd,
|
||||||
|
sessionId: options.sessionId,
|
||||||
|
allowedTools: options.allowedTools,
|
||||||
|
model: options.model,
|
||||||
|
statusPatterns: options.statusPatterns,
|
||||||
|
onStream: options.onStream,
|
||||||
|
onPermissionRequest: options.onPermissionRequest,
|
||||||
|
onAskUserQuestion: options.onAskUserQuestion,
|
||||||
|
bypassPermissions: options.bypassPermissions,
|
||||||
|
};
|
||||||
|
|
||||||
|
return callClaudeCustom(agentName, prompt, systemPrompt, callOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/providers/codex.ts
Normal file
35
src/providers/codex.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Codex provider implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/client.js';
|
||||||
|
import type { AgentResponse } from '../models/types.js';
|
||||||
|
import type { Provider, ProviderCallOptions } from './index.js';
|
||||||
|
|
||||||
|
/** Codex provider - wraps existing Codex client */
|
||||||
|
export class CodexProvider implements Provider {
|
||||||
|
async call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse> {
|
||||||
|
const callOptions: CodexCallOptions = {
|
||||||
|
cwd: options.cwd,
|
||||||
|
sessionId: options.sessionId,
|
||||||
|
model: options.model,
|
||||||
|
systemPrompt: options.systemPrompt,
|
||||||
|
statusPatterns: options.statusPatterns,
|
||||||
|
onStream: options.onStream,
|
||||||
|
};
|
||||||
|
|
||||||
|
return callCodex(agentName, prompt, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse> {
|
||||||
|
const callOptions: CodexCallOptions = {
|
||||||
|
cwd: options.cwd,
|
||||||
|
sessionId: options.sessionId,
|
||||||
|
model: options.model,
|
||||||
|
statusPatterns: options.statusPatterns,
|
||||||
|
onStream: options.onStream,
|
||||||
|
};
|
||||||
|
|
||||||
|
return callCodexCustom(agentName, prompt, systemPrompt, callOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/providers/index.ts
Normal file
63
src/providers/index.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Provider abstraction layer
|
||||||
|
*
|
||||||
|
* Provides a unified interface for different agent providers (Claude, Codex, Mock).
|
||||||
|
* This enables adding new providers without modifying the runner logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/process.js';
|
||||||
|
import type { AgentResponse } from '../models/types.js';
|
||||||
|
import { ClaudeProvider } from './claude.js';
|
||||||
|
import { CodexProvider } from './codex.js';
|
||||||
|
import { MockProvider } from './mock.js';
|
||||||
|
|
||||||
|
/** Common options for all providers */
|
||||||
|
export interface ProviderCallOptions {
|
||||||
|
cwd: string;
|
||||||
|
sessionId?: string;
|
||||||
|
model?: string;
|
||||||
|
systemPrompt?: string;
|
||||||
|
allowedTools?: string[];
|
||||||
|
statusPatterns?: Record<string, string>;
|
||||||
|
onStream?: StreamCallback;
|
||||||
|
onPermissionRequest?: PermissionHandler;
|
||||||
|
onAskUserQuestion?: AskUserQuestionHandler;
|
||||||
|
bypassPermissions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider interface - all providers must implement this */
|
||||||
|
export interface Provider {
|
||||||
|
/** Call the provider with a prompt (using systemPrompt from options if provided) */
|
||||||
|
call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse>;
|
||||||
|
|
||||||
|
/** Call the provider with explicit system prompt */
|
||||||
|
callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider type */
|
||||||
|
export type ProviderType = 'claude' | 'codex' | 'mock';
|
||||||
|
|
||||||
|
/** Provider registry */
|
||||||
|
const providers: Record<ProviderType, Provider> = {
|
||||||
|
claude: new ClaudeProvider(),
|
||||||
|
codex: new CodexProvider(),
|
||||||
|
mock: new MockProvider(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a provider instance by type
|
||||||
|
*/
|
||||||
|
export function getProvider(type: ProviderType): Provider {
|
||||||
|
const provider = providers[type];
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(`Unknown provider type: ${type}`);
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom provider
|
||||||
|
*/
|
||||||
|
export function registerProvider(type: string, provider: Provider): void {
|
||||||
|
(providers as Record<string, Provider>)[type] = provider;
|
||||||
|
}
|
||||||
30
src/providers/mock.ts
Normal file
30
src/providers/mock.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Mock provider implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { callMock, callMockCustom, type MockCallOptions } from '../mock/client.js';
|
||||||
|
import type { AgentResponse } from '../models/types.js';
|
||||||
|
import type { Provider, ProviderCallOptions } from './index.js';
|
||||||
|
|
||||||
|
/** Mock provider - wraps existing Mock client */
|
||||||
|
export class MockProvider implements Provider {
|
||||||
|
async call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse> {
|
||||||
|
const callOptions: MockCallOptions = {
|
||||||
|
cwd: options.cwd,
|
||||||
|
sessionId: options.sessionId,
|
||||||
|
onStream: options.onStream,
|
||||||
|
};
|
||||||
|
|
||||||
|
return callMock(agentName, prompt, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async callCustom(agentName: string, prompt: string, _systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse> {
|
||||||
|
const callOptions: MockCallOptions = {
|
||||||
|
cwd: options.cwd,
|
||||||
|
sessionId: options.sessionId,
|
||||||
|
onStream: options.onStream,
|
||||||
|
};
|
||||||
|
|
||||||
|
return callMockCustom(agentName, prompt, _systemPrompt, callOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user