byuckchon-frontend-cli 1.6.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -1
- package/bin/index.js +1 -1
- package/package.json +2 -1
- package/src/ai/tools.js +87 -1
- package/src/commands/adopt.js +24 -1
- package/src/commands/chat.js +44 -10
- package/src/config/index.js +6 -0
- package/src/context/conventions.js +114 -0
- package/src/generators/apiConventionDoc.js +49 -0
- package/src/generators/createBcConfig.js +4 -0
- package/src/generators/createProject.js +3 -0
- package/src/openapi/lookup.js +135 -0
- package/src/openapi/summary.js +26 -30
- package/src/ui/ChatApp.js +11 -1
- package/templates/conventions/api-codegen.md +400 -0
package/README.md
CHANGED
|
@@ -74,8 +74,11 @@ bc init
|
|
|
74
74
|
| `read_file` | 프로젝트 내 파일/디렉터리 내용 읽기 |
|
|
75
75
|
| `list_files` | 글롭 패턴으로 파일 나열 |
|
|
76
76
|
| `search_code` | RAG 인덱스 의미 기반 검색 (인덱스 있어야 함) |
|
|
77
|
+
| `search_openapi` | OpenAPI 스펙에서 엔드포인트 검색 (path/summary/tag) — 큰 스펙도 OK |
|
|
78
|
+
| `get_openapi_endpoint` | 특정 엔드포인트 상세 (params/requestBody/responses, `$ref` 인라인) |
|
|
77
79
|
| `write_file` | 새 파일 생성 또는 통째 덮어쓰기 |
|
|
78
80
|
| `edit_file` | 유일한 `old_string → new_string` 으로 부분 수정 (안전) |
|
|
81
|
+
| `fetch_figma` / `fetch_figma_image` / `fetch_figma_styles` | Figma 디자인/이미지/토큰 |
|
|
79
82
|
|
|
80
83
|
모델은 한 턴 안에서 **최대 12 step** 까지 툴을 자유롭게 호출합니다. 일반적인 흐름:
|
|
81
84
|
1. `list_files` 로 `src/api/` 구조 파악
|
|
@@ -153,6 +156,36 @@ bc › Auto layout 이 row 였고 padding 12/16 이었어요. MemberCard 만들
|
|
|
153
156
|
> Figma 응답은 자동으로 압축됩니다 (자식 60개, 깊이 8 까지). 너무 큰 프레임은 더 작은
|
|
154
157
|
> 자식 frame URL 을 줘서 분할 정복하세요.
|
|
155
158
|
|
|
159
|
+
### 팀 컨벤션 문서(.md) 자동 주입 (v1.8+)
|
|
160
|
+
|
|
161
|
+
FE 전반의 규칙(폴더 구조, 네이밍, 스웨거 → 코드 변환 규칙 등)을 `.md` 로 적어두면
|
|
162
|
+
**매 chat 세션에 시스템 프롬프트로 자동 주입**됩니다. AI 는 기존 코드 패턴보다 이 문서를 우선합니다.
|
|
163
|
+
|
|
164
|
+
**파일명은 고정이 아닙니다.** 아무 경로나 `bc.config.json` 의 `docs` 에 적으면 됩니다.
|
|
165
|
+
적지 않으면 관례 파일명(`bc.md`, `.bc/conventions.md`, `AGENTS.md`, `FRONTEND.md`, `docs/frontend.md`)을 자동 탐지합니다.
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"docs": ["docs/fe-conventions.md", "docs/api-guide.md"]
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- 문서당 최대 24KB, 전체 48KB 까지 (토큰 폭발 방지). 헤더에 `docs` 줄로 로드된 파일이 표시됩니다.
|
|
174
|
+
- (고급) 항목을 `{ "path": "...", "when": { "framework": "next" } }` 형태로 적으면 프레임워크별 조건부 주입도 가능합니다.
|
|
175
|
+
|
|
176
|
+
#### API 코드 컨벤션 .md 자동 포함
|
|
177
|
+
|
|
178
|
+
`bc init` / `bc adopt` 를 실행하면 **API 코드 생성 가이드(`api-codegen.md`)가 프레임워크에 맞는 위치에 자동으로 깔립니다.**
|
|
179
|
+
|
|
180
|
+
| 프레임워크 | 위치 |
|
|
181
|
+
| --- | --- |
|
|
182
|
+
| React (Vite/CRA 등) | `src/api/api-codegen.md` |
|
|
183
|
+
| Next.js | `lib/api/api-codegen.md` |
|
|
184
|
+
|
|
185
|
+
- 이 파일은 `bc.config.json` 의 `docs` 에 자동 등록되어 **chat 시작 시 주입**됩니다.
|
|
186
|
+
- 하나의 .md 로 React/Next 를 모두 다루며(차이는 위치뿐), 팀 규칙에 맞게 직접 다듬어 쓰면 됩니다.
|
|
187
|
+
- 이미 파일이 있으면 덮어쓰지 않습니다.
|
|
188
|
+
|
|
156
189
|
### OpenAPI / 코드 컨텍스트 — 자동 주입 (v1.4+)
|
|
157
190
|
|
|
158
191
|
`bc.config.json` 의 `api.openapi` 와 코드 인덱스는 **chat 시작할 때 알아서 준비됩니다.**
|
|
@@ -351,7 +384,8 @@ bc config set-ui ink # 풀 TUI 복귀
|
|
|
351
384
|
- [x] v1.5.0 — 에이전트 모드 (read/list/search/write/edit 툴) — AI 가 실제 파일을 만든다
|
|
352
385
|
- [x] v1.6.0 — Figma 툴 (fetch_figma / image / styles), 한글 IME 안정 plain 모드 (`bc config set-ui plain`)
|
|
353
386
|
- [x] v1.6.1 — 툴 스키마 `jsonSchema()` 래핑 (`schema is not a function` 수정), plain 모드 이미지 첨부(`/image`·`/paste`) + iTerm2/kitty 인라인 썸네일
|
|
354
|
-
- [
|
|
387
|
+
- [x] v1.7.0 — OpenAPI 검색 툴 (`search_openapi` / `get_openapi_endpoint`) — 큰 스펙(수백 엔드포인트)에서도 정확한 경로/스키마 조회. 요약도 path 당 1줄로 압축 + 한도 상향
|
|
388
|
+
- [ ] v1.8.0 — write/edit 승인 게이트 (`y/n/v/q`), diff 미리보기
|
|
355
389
|
- [ ] Phase 3c-2: Figma 실 fetch (URL → 노드 트리 → 컴포넌트 인텐트)
|
|
356
390
|
- [ ] Phase 4: `bc gen component/page` (AST 편집 + 검증 루프), `/apply` diff 미리보기
|
|
357
391
|
|
package/bin/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "byuckchon-frontend-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Byuckchon Frontend Workbench — project starter + AI chat + codebase RAG + OpenAPI codegen",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"files": [
|
|
35
35
|
"bin",
|
|
36
36
|
"src",
|
|
37
|
+
"templates",
|
|
37
38
|
"README.md"
|
|
38
39
|
],
|
|
39
40
|
"keywords": [
|
package/src/ai/tools.js
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
} from '../figma/api.js';
|
|
13
13
|
import { simplifyFetchNodes } from '../figma/simplify.js';
|
|
14
14
|
import { parseFigmaUrl } from '../figma/url.js';
|
|
15
|
+
import { getCachedOpenApi } from '../openapi/cache.js';
|
|
16
|
+
import { searchEndpoints, getEndpoint } from '../openapi/lookup.js';
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Agentic chat 용 툴 정의.
|
|
@@ -27,9 +29,21 @@ import { parseFigmaUrl } from '../figma/url.js';
|
|
|
27
29
|
* const tools = buildTools({ projectRoot, effective, onEvent });
|
|
28
30
|
* streamText({ tools, stopWhen: stepCountIs(12), ... });
|
|
29
31
|
*/
|
|
30
|
-
export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
32
|
+
export function buildTools({ projectRoot, effective, onEvent = () => {}, openapiSource = null }) {
|
|
31
33
|
const root = path.resolve(projectRoot);
|
|
32
34
|
|
|
35
|
+
// OpenAPI 스펙은 한 번만 로드해서 캐시 (큰 파일이라 반복 파싱 비쌈).
|
|
36
|
+
let _openapiDocPromise = null;
|
|
37
|
+
function loadOpenApiDoc() {
|
|
38
|
+
if (!openapiSource) return Promise.resolve(null);
|
|
39
|
+
if (!_openapiDocPromise) {
|
|
40
|
+
_openapiDocPromise = getCachedOpenApi(openapiSource)
|
|
41
|
+
.then((res) => res.doc ?? null)
|
|
42
|
+
.catch(() => null);
|
|
43
|
+
}
|
|
44
|
+
return _openapiDocPromise;
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
function safePath(p) {
|
|
34
48
|
if (!p || typeof p !== 'string') {
|
|
35
49
|
throw new Error('path 가 비어있습니다');
|
|
@@ -171,6 +185,44 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
171
185
|
return { ok: true, path: rel, action: 'edited' };
|
|
172
186
|
}
|
|
173
187
|
|
|
188
|
+
// ─────────── OpenAPI 툴 ───────────
|
|
189
|
+
|
|
190
|
+
async function searchOpenApi({ query, limit = 40 }) {
|
|
191
|
+
const doc = await loadOpenApiDoc();
|
|
192
|
+
if (!doc) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
error:
|
|
196
|
+
'OpenAPI 스펙을 불러올 수 없습니다. bc.config.json 의 api.openapi 가 올바른 JSON 스펙 URL 인지 확인하세요 ' +
|
|
197
|
+
'(NestJS 는 보통 /api/docs 가 아니라 /api/docs-json).',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const hits = searchEndpoints(doc, query, { limit });
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
query,
|
|
204
|
+
count: hits.length,
|
|
205
|
+
endpoints: hits.map((e) => ({
|
|
206
|
+
method: e.method,
|
|
207
|
+
path: e.path,
|
|
208
|
+
summary: e.summary,
|
|
209
|
+
tags: e.tags,
|
|
210
|
+
})),
|
|
211
|
+
hint:
|
|
212
|
+
hits.length === 0
|
|
213
|
+
? '매치 없음. 다른 키워드로 재시도하거나, query 를 비워 전체 목록을 받아 path 를 직접 고르세요.'
|
|
214
|
+
: '상세 스키마가 필요하면 get_openapi_endpoint(path, method) 를 호출하세요.',
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function getOpenApiEndpoint({ path: epPath, method }) {
|
|
219
|
+
const doc = await loadOpenApiDoc();
|
|
220
|
+
if (!doc) {
|
|
221
|
+
return { ok: false, error: 'OpenAPI 스펙을 불러올 수 없습니다.' };
|
|
222
|
+
}
|
|
223
|
+
return getEndpoint(doc, epPath, method);
|
|
224
|
+
}
|
|
225
|
+
|
|
174
226
|
// ─────────── Figma 툴 ───────────
|
|
175
227
|
|
|
176
228
|
async function fetchFigma({ url, depth = 4 }) {
|
|
@@ -295,6 +347,40 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
295
347
|
}),
|
|
296
348
|
execute: searchCode,
|
|
297
349
|
}),
|
|
350
|
+
search_openapi: tool({
|
|
351
|
+
description:
|
|
352
|
+
'연결된 OpenAPI(Swagger) 스펙에서 엔드포인트를 검색한다. path 일부("admin/inquiries"), ' +
|
|
353
|
+
'한글 summary("문의"), tag, operationId 어느 걸로도 검색 가능. ' +
|
|
354
|
+
'API 코드를 짜기 전에 반드시 이 툴로 정확한 경로/메서드를 먼저 확인할 것. ' +
|
|
355
|
+
'시스템 프롬프트의 요약은 잘려 있을 수 있으므로 "엔드포인트가 안 보인다" 싶으면 이 툴로 찾는다.',
|
|
356
|
+
inputSchema: jsonSchema({
|
|
357
|
+
type: 'object',
|
|
358
|
+
properties: {
|
|
359
|
+
query: { type: 'string', description: '검색어 (path 일부/summary/tag). 비우면 전체 목록' },
|
|
360
|
+
limit: { type: 'number', default: 40 },
|
|
361
|
+
},
|
|
362
|
+
additionalProperties: false,
|
|
363
|
+
}),
|
|
364
|
+
execute: searchOpenApi,
|
|
365
|
+
}),
|
|
366
|
+
get_openapi_endpoint: tool({
|
|
367
|
+
description:
|
|
368
|
+
'특정 엔드포인트의 상세(parameters / requestBody / responses 스키마, $ref 인라인됨) 를 가져온다. ' +
|
|
369
|
+
'search_openapi 로 찾은 정확한 path 와 method 를 넘긴다. 이 결과로 zod 스키마/타입/요청 함수를 정확히 생성.',
|
|
370
|
+
inputSchema: jsonSchema({
|
|
371
|
+
type: 'object',
|
|
372
|
+
properties: {
|
|
373
|
+
path: { type: 'string', description: '정확한 경로. 예: /api/admin/inquiries' },
|
|
374
|
+
method: {
|
|
375
|
+
type: 'string',
|
|
376
|
+
description: 'GET/POST/... 생략 시 해당 path 의 모든 메서드',
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
required: ['path'],
|
|
380
|
+
additionalProperties: false,
|
|
381
|
+
}),
|
|
382
|
+
execute: getOpenApiEndpoint,
|
|
383
|
+
}),
|
|
298
384
|
write_file: tool({
|
|
299
385
|
description:
|
|
300
386
|
'새 파일을 만들거나 기존 파일을 통째로 덮어쓴다. 새 파일을 만들기 전에 반드시 1) 비슷한 기존 파일을 read_file 로 보고 2) 같은 폴더 컨벤션(barrel 파일, 네이밍, import 순서) 을 따른다.',
|
package/src/commands/adopt.js
CHANGED
|
@@ -7,6 +7,10 @@ import inquirer from 'inquirer';
|
|
|
7
7
|
import { detectProjectContext, summarizeContext } from '../context/detect.js';
|
|
8
8
|
import { CONFIG_PATHS } from '../config/index.js';
|
|
9
9
|
import { modelChoices, DEFAULT_MODEL_ID } from '../ai/models.js';
|
|
10
|
+
import {
|
|
11
|
+
apiRootForFramework,
|
|
12
|
+
scaffoldApiConventionDoc,
|
|
13
|
+
} from '../generators/apiConventionDoc.js';
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* `bc adopt`
|
|
@@ -119,6 +123,9 @@ export async function adoptCommand(opts = {}) {
|
|
|
119
123
|
exclude: ['**/*.test.*', '**/__mocks__/**', 'node_modules/**', 'dist/**', '.next/**'],
|
|
120
124
|
maxFiles: 20,
|
|
121
125
|
},
|
|
126
|
+
docs: existing?.docs ?? [
|
|
127
|
+
path.posix.join(apiRootForFramework(ctx.framework), 'api-codegen.md'),
|
|
128
|
+
],
|
|
122
129
|
framework: ctx.framework,
|
|
123
130
|
detected: {
|
|
124
131
|
language: ctx.language,
|
|
@@ -136,7 +143,23 @@ export async function adoptCommand(opts = {}) {
|
|
|
136
143
|
await fs.writeFile(targetFile, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
137
144
|
|
|
138
145
|
console.log(chalk.green(`\n ✓ ${CONFIG_PATHS.projectFileName} 작성 완료.`));
|
|
139
|
-
console.log(chalk.dim(` ${targetFile}
|
|
146
|
+
console.log(chalk.dim(` ${targetFile}`));
|
|
147
|
+
|
|
148
|
+
// API 코드 컨벤션 .md 를 API 루트(src/api | lib/api)에 깐다 (이미 있으면 유지).
|
|
149
|
+
try {
|
|
150
|
+
const { relPath, written } = await scaffoldApiConventionDoc({
|
|
151
|
+
projectRoot: cwd,
|
|
152
|
+
framework: ctx.framework,
|
|
153
|
+
});
|
|
154
|
+
if (written) {
|
|
155
|
+
console.log(chalk.green(` ✓ API 코드 컨벤션 문서 생성: ${relPath}`));
|
|
156
|
+
} else {
|
|
157
|
+
console.log(chalk.dim(` API 코드 컨벤션 문서 유지: ${relPath} (이미 존재)`));
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
/* 문서 스캐폴드 실패는 치명적이지 않음 */
|
|
161
|
+
}
|
|
162
|
+
console.log();
|
|
140
163
|
console.log(chalk.dim(' 다음:'));
|
|
141
164
|
if (!process.env.ANTHROPIC_API_KEY) {
|
|
142
165
|
console.log(chalk.dim(' bc config set-key anthropic # API 키 등록'));
|
package/src/commands/chat.js
CHANGED
|
@@ -63,12 +63,37 @@ export async function chatCommand(opts = {}) {
|
|
|
63
63
|
process.exit(1);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
let baseSystem = buildSystemPrompt({
|
|
67
67
|
effective: cfg.effective,
|
|
68
68
|
paths: cfg.paths,
|
|
69
69
|
project: cfg.project,
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
// 컨벤션 문서(.md) 자동 주입 — FE 전반 규칙, 스웨거→코드 변환 규칙 등.
|
|
73
|
+
// bc.config.json 의 docs:[...] 또는 bc.md/AGENTS.md 등 관례 파일을 읽는다.
|
|
74
|
+
let conventionFiles = [];
|
|
75
|
+
if (cfg.paths.projectFile) {
|
|
76
|
+
try {
|
|
77
|
+
const { loadConventionDocs } = await import('../context/conventions.js');
|
|
78
|
+
const projectRoot = path.dirname(cfg.paths.projectFile);
|
|
79
|
+
const conv = await loadConventionDocs({
|
|
80
|
+
projectRoot,
|
|
81
|
+
docs: cfg.effective.docs,
|
|
82
|
+
framework: cfg.project?.framework ?? cfg.project?.detected?.framework,
|
|
83
|
+
});
|
|
84
|
+
if (conv.text) {
|
|
85
|
+
conventionFiles = conv.files;
|
|
86
|
+
baseSystem +=
|
|
87
|
+
'\n\n---\n## 팀 컨벤션 문서 (반드시 우선 준수)\n' +
|
|
88
|
+
'아래는 이 프로젝트/팀의 프론트엔드 컨벤션이다. 코드 생성·수정 시 여기 규칙을 ' +
|
|
89
|
+
'기존 코드 패턴보다 우선 적용한다. 충돌하면 이 문서를 따른다.\n\n' +
|
|
90
|
+
conv.text;
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
/* 문서 로딩 실패는 무시 */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
72
97
|
// OpenAPI 자동 주입 — bc.config.json 의 api.openapi 가 있으면 fetch 후 요약을
|
|
73
98
|
// 시스템 프롬프트에 박는다. 1시간 캐시. 실패해도 chat 은 그대로 동작.
|
|
74
99
|
let openapiInfo = null;
|
|
@@ -86,11 +111,15 @@ export async function chatCommand(opts = {}) {
|
|
|
86
111
|
};
|
|
87
112
|
system =
|
|
88
113
|
baseSystem +
|
|
89
|
-
'\n\n---\nOpenAPI 스펙 (
|
|
114
|
+
'\n\n---\nOpenAPI 스펙 개요 (아래 목록은 길면 잘려 있을 수 있음):\n' +
|
|
90
115
|
summary +
|
|
91
|
-
'\n\n
|
|
92
|
-
'
|
|
93
|
-
'
|
|
116
|
+
'\n\n**중요**: 위 목록은 일부만 보일 수 있다. 사용자가 특정 엔드포인트를 말하거나 ' +
|
|
117
|
+
'위 목록에서 안 보이면, 추측하지 말고 반드시 `search_openapi(query)` 로 정확한 ' +
|
|
118
|
+
'path/method 를 검색한 뒤 `get_openapi_endpoint(path, method)` 로 상세 스키마를 ' +
|
|
119
|
+
'가져와서 zod/타입/요청 함수를 만든다. ' +
|
|
120
|
+
'예: 사용자가 "inquiries" 라고 하면 search_openapi("inquiries") 로 ' +
|
|
121
|
+
'`/api/admin/inquiries` 같은 실제 경로를 찾아낸다. ' +
|
|
122
|
+
'이미 `*.gen.ts` 가 있으면 그걸 import 해서 쓰는 것도 좋다.';
|
|
94
123
|
}
|
|
95
124
|
} catch {
|
|
96
125
|
/* 비정상 URL/네트워크 실패 — 무시하고 계속. */
|
|
@@ -141,10 +170,10 @@ export async function chatCommand(opts = {}) {
|
|
|
141
170
|
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
142
171
|
const wantPlain = opts.plain || cfg.global?.ui?.mode === 'plain';
|
|
143
172
|
if (!isTTY || wantPlain) {
|
|
144
|
-
return runReadlineFallback({ cfg, resolved, system, session, openapiInfo });
|
|
173
|
+
return runReadlineFallback({ cfg, resolved, system, session, openapiInfo, conventionFiles });
|
|
145
174
|
}
|
|
146
175
|
|
|
147
|
-
return runInkApp({ cfg, resolved, system, session, openapiInfo });
|
|
176
|
+
return runInkApp({ cfg, resolved, system, session, openapiInfo, conventionFiles });
|
|
148
177
|
}
|
|
149
178
|
|
|
150
179
|
async function printHistoryList() {
|
|
@@ -168,14 +197,14 @@ async function printHistoryList() {
|
|
|
168
197
|
|
|
169
198
|
/* ───────────────────────── ink 모드 ───────────────────────── */
|
|
170
199
|
|
|
171
|
-
async function runInkApp({ cfg, resolved, system, session, openapiInfo }) {
|
|
200
|
+
async function runInkApp({ cfg, resolved, system, session, openapiInfo, conventionFiles = [] }) {
|
|
172
201
|
// ink/React 는 무겁고 비-TTY 환경에서 import 만으로도 종종 문제 일으키므로
|
|
173
202
|
// 여기서 늦게 import 한다 (--once / pipe 모드에 영향 없도록).
|
|
174
203
|
const { render } = await import('ink');
|
|
175
204
|
const { ChatApp } = await import('../ui/ChatApp.js');
|
|
176
205
|
const React = (await import('react')).default;
|
|
177
206
|
|
|
178
|
-
const initialConfig = { ...cfg, system, openapiInfo };
|
|
207
|
+
const initialConfig = { ...cfg, system, openapiInfo, conventionFiles };
|
|
179
208
|
|
|
180
209
|
const onSessionUpdate = async (messages) => {
|
|
181
210
|
session.messages = messages;
|
|
@@ -204,6 +233,7 @@ async function runOnce({ cfg, resolved, system, prompt }) {
|
|
|
204
233
|
const tools = buildTools({
|
|
205
234
|
projectRoot,
|
|
206
235
|
effective: cfg.effective,
|
|
236
|
+
openapiSource: cfg.effective.api?.openapi ?? null,
|
|
207
237
|
onEvent: (ev) => {
|
|
208
238
|
const label =
|
|
209
239
|
ev.kind === 'write_created'
|
|
@@ -242,7 +272,7 @@ async function runOnce({ cfg, resolved, system, prompt }) {
|
|
|
242
272
|
|
|
243
273
|
/* ──────────────────── 비-TTY / --plain 폴백 ──────────────────── */
|
|
244
274
|
|
|
245
|
-
async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo }) {
|
|
275
|
+
async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo, conventionFiles = [] }) {
|
|
246
276
|
const readline = await import('node:readline');
|
|
247
277
|
const meter = new TokenMeter(resolved.meta, cfg.effective.limits);
|
|
248
278
|
|
|
@@ -254,6 +284,9 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
254
284
|
chalk.dim(' openapi: ' + openapiInfo.source + (openapiInfo.cached ? ' (cached)' : ' (live)')),
|
|
255
285
|
);
|
|
256
286
|
}
|
|
287
|
+
if (conventionFiles.length) {
|
|
288
|
+
console.log(chalk.dim(' 컨벤션 문서: ' + conventionFiles.join(', ')));
|
|
289
|
+
}
|
|
257
290
|
if (session?.messages?.length) {
|
|
258
291
|
console.log(chalk.dim(` 세션: ${session.id} (${session.messages.length} turns 이어가기)`));
|
|
259
292
|
}
|
|
@@ -378,6 +411,7 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
378
411
|
const tools = buildTools({
|
|
379
412
|
projectRoot,
|
|
380
413
|
effective: cfg.effective,
|
|
414
|
+
openapiSource: cfg.effective.api?.openapi ?? null,
|
|
381
415
|
onEvent: (ev) => {
|
|
382
416
|
const label =
|
|
383
417
|
ev.kind === 'write_created'
|
package/src/config/index.js
CHANGED
|
@@ -65,6 +65,11 @@ const DEFAULT_PROJECT = {
|
|
|
65
65
|
exclude: ['**/*.test.*', '**/__mocks__/**', 'node_modules/**', 'dist/**'],
|
|
66
66
|
maxFiles: 20,
|
|
67
67
|
},
|
|
68
|
+
/**
|
|
69
|
+
* FE 전반 컨벤션을 적은 .md 경로들. 매 chat 세션에 시스템 프롬프트로 주입된다.
|
|
70
|
+
* 비우면 bc.md / .bc/conventions.md / AGENTS.md 등을 자동 탐지.
|
|
71
|
+
*/
|
|
72
|
+
docs: [],
|
|
68
73
|
/** bc adopt 가 채워준다. systemPrompt 가 읽어 모델에 알린다. */
|
|
69
74
|
framework: null,
|
|
70
75
|
detected: null,
|
|
@@ -181,6 +186,7 @@ export async function loadEffectiveConfig(startDir = process.cwd()) {
|
|
|
181
186
|
design: project.design,
|
|
182
187
|
api: project.api,
|
|
183
188
|
context: project.context,
|
|
189
|
+
docs: project.docs ?? [],
|
|
184
190
|
},
|
|
185
191
|
};
|
|
186
192
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 프로젝트의 "컨벤션 문서(.md)" 를 찾아 읽어서 시스템 프롬프트에 주입할 텍스트로 만든다.
|
|
6
|
+
*
|
|
7
|
+
* 우선순위:
|
|
8
|
+
* 1) bc.config.json 의 `docs: [...]` 에 명시된 항목 (순서대로)
|
|
9
|
+
* - 항목은 문자열(경로) 이거나, 조건부 객체일 수 있다:
|
|
10
|
+
* "docs/common.md"
|
|
11
|
+
* { "path": "docs/api-codegen.md", "when": { "frameworkNot": "next" } }
|
|
12
|
+
* { "path": "docs/api-next.md", "when": { "framework": "next" } }
|
|
13
|
+
* - when 의 framework / frameworkNot 는 문자열 또는 문자열 배열.
|
|
14
|
+
* 2) 명시가 없으면 프로젝트 루트의 관례적 파일명 자동 탐지
|
|
15
|
+
* - bc.md, .bc/conventions.md, AGENTS.md, FRONTEND.md, docs/frontend.md
|
|
16
|
+
*
|
|
17
|
+
* 토큰 폭발 방지: 문서당 최대 bytes, 전체 합계 최대 bytes 로 컷.
|
|
18
|
+
*/
|
|
19
|
+
const AUTO_NAMES = [
|
|
20
|
+
'bc.md',
|
|
21
|
+
'.bc/conventions.md',
|
|
22
|
+
'AGENTS.md',
|
|
23
|
+
'FRONTEND.md',
|
|
24
|
+
'docs/frontend.md',
|
|
25
|
+
'docs/FRONTEND.md',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const PER_DOC_MAX = 24 * 1024;
|
|
29
|
+
const TOTAL_MAX = 48 * 1024;
|
|
30
|
+
|
|
31
|
+
async function readIfExists(abs) {
|
|
32
|
+
try {
|
|
33
|
+
const stat = await fs.stat(abs);
|
|
34
|
+
if (!stat.isFile()) return null;
|
|
35
|
+
let text = await fs.readFile(abs, 'utf8');
|
|
36
|
+
if (Buffer.byteLength(text, 'utf8') > PER_DOC_MAX) {
|
|
37
|
+
text = text.slice(0, PER_DOC_MAX) + '\n... (이하 생략 — 문서가 너무 깁니다)';
|
|
38
|
+
}
|
|
39
|
+
return text;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function asArray(v) {
|
|
46
|
+
if (v == null) return [];
|
|
47
|
+
return Array.isArray(v) ? v : [v];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** docs 항목의 when 조건이 현재 framework 에 맞는지. */
|
|
51
|
+
function matchesFramework(when, framework) {
|
|
52
|
+
if (!when || typeof when !== 'object') return true;
|
|
53
|
+
const fw = framework || 'unknown';
|
|
54
|
+
|
|
55
|
+
const only = asArray(when.framework);
|
|
56
|
+
if (only.length && !only.includes(fw)) return false;
|
|
57
|
+
|
|
58
|
+
const not = asArray(when.frameworkNot);
|
|
59
|
+
if (not.length && not.includes(fw)) return false;
|
|
60
|
+
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** docs 항목(문자열 | 객체)을 { path, when } 로 정규화. */
|
|
65
|
+
function normalizeEntry(entry) {
|
|
66
|
+
if (typeof entry === 'string') return { path: entry, when: null };
|
|
67
|
+
if (entry && typeof entry === 'object' && typeof entry.path === 'string') {
|
|
68
|
+
return { path: entry.path, when: entry.when ?? null };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {object} args
|
|
75
|
+
* @param {string} args.projectRoot bc.config.json 이 있는 디렉터리
|
|
76
|
+
* @param {Array<string|object>} [args.docs] config 의 docs (문자열 또는 조건부 객체)
|
|
77
|
+
* @param {string} [args.framework] detect.js 의 framework (조건부 docs 판별용)
|
|
78
|
+
* @returns {Promise<{ text: string|null, files: string[] }>}
|
|
79
|
+
*/
|
|
80
|
+
export async function loadConventionDocs({ projectRoot, docs, framework }) {
|
|
81
|
+
if (!projectRoot) return { text: null, files: [] };
|
|
82
|
+
|
|
83
|
+
const explicit = Array.isArray(docs) && docs.length > 0;
|
|
84
|
+
const rawEntries = explicit ? docs : AUTO_NAMES;
|
|
85
|
+
|
|
86
|
+
const entries = rawEntries
|
|
87
|
+
.map(normalizeEntry)
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.filter((e) => matchesFramework(e.when, framework));
|
|
90
|
+
|
|
91
|
+
const collected = [];
|
|
92
|
+
const files = [];
|
|
93
|
+
let total = 0;
|
|
94
|
+
|
|
95
|
+
for (const { path: rel } of entries) {
|
|
96
|
+
const abs = path.resolve(projectRoot, rel);
|
|
97
|
+
// 루트 밖 경로는 무시 (안전)
|
|
98
|
+
const within = !path.relative(projectRoot, abs).startsWith('..');
|
|
99
|
+
if (!within) continue;
|
|
100
|
+
|
|
101
|
+
const content = await readIfExists(abs);
|
|
102
|
+
if (!content) continue;
|
|
103
|
+
|
|
104
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
105
|
+
if (total + bytes > TOTAL_MAX) break;
|
|
106
|
+
total += bytes;
|
|
107
|
+
|
|
108
|
+
files.push(rel);
|
|
109
|
+
collected.push(`### 문서: ${rel}\n${content}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (collected.length === 0) return { text: null, files: [] };
|
|
113
|
+
return { text: collected.join('\n\n'), files };
|
|
114
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const TEMPLATE_PATH = path.resolve(
|
|
6
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
7
|
+
'../../templates/conventions/api-codegen.md',
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 프레임워크에 맞는 API 루트 폴더.
|
|
12
|
+
* - Next.js → lib/api
|
|
13
|
+
* - 그 외 React 계열 → src/api
|
|
14
|
+
*/
|
|
15
|
+
export function apiRootForFramework(framework) {
|
|
16
|
+
return framework === 'next' ? 'lib/api' : 'src/api';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* API 코드 컨벤션 .md 를 프로젝트의 API 루트(`src/api` 또는 `lib/api`)에 깐다.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} args
|
|
23
|
+
* @param {string} args.projectRoot
|
|
24
|
+
* @param {string} args.framework 'react' | 'next' | detect.js 의 framework 값
|
|
25
|
+
* @param {boolean} [args.force] 이미 있으면 덮어쓸지
|
|
26
|
+
* @returns {Promise<{ relPath: string, written: boolean }>}
|
|
27
|
+
*/
|
|
28
|
+
export async function scaffoldApiConventionDoc({ projectRoot, framework, force = false }) {
|
|
29
|
+
const apiRoot = apiRootForFramework(framework);
|
|
30
|
+
const relPath = path.join(apiRoot, 'api-codegen.md');
|
|
31
|
+
const absPath = path.join(projectRoot, relPath);
|
|
32
|
+
|
|
33
|
+
let exists = false;
|
|
34
|
+
try {
|
|
35
|
+
await fs.access(absPath);
|
|
36
|
+
exists = true;
|
|
37
|
+
} catch {
|
|
38
|
+
/* not there */
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (exists && !force) {
|
|
42
|
+
return { relPath, written: false };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const template = await fs.readFile(TEMPLATE_PATH, 'utf8');
|
|
46
|
+
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
|
47
|
+
await fs.writeFile(absPath, template, 'utf8');
|
|
48
|
+
return { relPath, written: true };
|
|
49
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
+
import { apiRootForFramework } from './apiConventionDoc.js';
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* 새 프로젝트 루트에 `bc.config.json` 을 만든다.
|
|
6
8
|
*
|
|
@@ -28,6 +30,8 @@ export async function createBcConfig(rootDir, config) {
|
|
|
28
30
|
exclude: ['**/*.test.*', '**/__mocks__/**', 'node_modules/**', 'dist/**'],
|
|
29
31
|
maxFiles: 20,
|
|
30
32
|
},
|
|
33
|
+
// chat 시작 시 자동 주입되는 팀 컨벤션 문서. API 코드 가이드가 기본 포함.
|
|
34
|
+
docs: [path.posix.join(apiRootForFramework(config.framework), 'api-codegen.md')],
|
|
31
35
|
framework: config.framework,
|
|
32
36
|
// init 단계에선 사용자가 React/Next 중 골랐고 TS/Tailwind 가 항상 들어가니
|
|
33
37
|
// 감지 결과를 미리 채워둔다 (bc adopt 의 detected 와 같은 모양).
|
|
@@ -8,6 +8,7 @@ import { createBcConfig } from './createBcConfig.js';
|
|
|
8
8
|
import { createFolders } from './createFolders.js';
|
|
9
9
|
import { createPackageJson } from './createPackageJson.js';
|
|
10
10
|
import { createReadme } from './createReadme.js';
|
|
11
|
+
import { scaffoldApiConventionDoc } from './apiConventionDoc.js';
|
|
11
12
|
|
|
12
13
|
const exec = promisify(execCallback);
|
|
13
14
|
const BYUCKCHON_PACKAGES = [
|
|
@@ -25,6 +26,8 @@ export async function createProject(config) {
|
|
|
25
26
|
await createPackageJson(rootDir, config);
|
|
26
27
|
await createBaseFiles(rootDir, config);
|
|
27
28
|
await createReadme(rootDir, config);
|
|
29
|
+
// API 코드 컨벤션 .md 를 프레임워크에 맞는 API 루트(src/api | lib/api)에 깐다.
|
|
30
|
+
await scaffoldApiConventionDoc({ projectRoot: rootDir, framework: config.framework });
|
|
28
31
|
await createBcConfig(rootDir, config);
|
|
29
32
|
|
|
30
33
|
// 최신 버전(latest 포함) 의존성을 실제로 설치해 lockfile까지 생성
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI 스펙을 "쿼리" 하는 헬퍼.
|
|
3
|
+
*
|
|
4
|
+
* 큰 스펙(수백 개 엔드포인트, 수백 KB) 은 시스템 프롬프트 요약에 다 담을 수도 없고,
|
|
5
|
+
* 캐시 파일을 통째로 모델에 읽힐 수도 없다(토큰 폭발). 그래서 모델이 필요한 부분만
|
|
6
|
+
* 골라 가져갈 수 있게 검색/상세조회 함수를 제공한다.
|
|
7
|
+
*
|
|
8
|
+
* - searchEndpoints: path/summary/tag/operationId 부분일치로 후보 나열
|
|
9
|
+
* - getEndpoint: 특정 path(+method) 의 상세를 $ref 해석해서 반환
|
|
10
|
+
*/
|
|
11
|
+
const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
12
|
+
|
|
13
|
+
/** "#/components/schemas/Foo" → doc.components.schemas.Foo */
|
|
14
|
+
function resolveRef(doc, ref, seen = new Set()) {
|
|
15
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/')) return null;
|
|
16
|
+
const parts = ref.slice(2).split('/');
|
|
17
|
+
let cur = doc;
|
|
18
|
+
for (const p of parts) {
|
|
19
|
+
if (cur == null) return null;
|
|
20
|
+
cur = cur[p];
|
|
21
|
+
}
|
|
22
|
+
return cur ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 객체 안의 $ref 를 재귀적으로 해석해 인라인한다.
|
|
27
|
+
* 깊이/순환 방지: maxDepth, seen(ref 경로) 로 컷.
|
|
28
|
+
*/
|
|
29
|
+
function deref(doc, node, { maxDepth = 6, depth = 0, seen = new Set() } = {}) {
|
|
30
|
+
if (node == null || typeof node !== 'object') return node;
|
|
31
|
+
if (depth > maxDepth) return node;
|
|
32
|
+
|
|
33
|
+
if (node.$ref) {
|
|
34
|
+
if (seen.has(node.$ref)) return { $ref: node.$ref, note: '(순환 참조 생략)' };
|
|
35
|
+
const target = resolveRef(doc, node.$ref);
|
|
36
|
+
if (!target) return node;
|
|
37
|
+
const nextSeen = new Set(seen);
|
|
38
|
+
nextSeen.add(node.$ref);
|
|
39
|
+
return deref(doc, target, { maxDepth, depth: depth + 1, seen: nextSeen });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (Array.isArray(node)) {
|
|
43
|
+
return node.map((n) => deref(doc, n, { maxDepth, depth: depth + 1, seen }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const [k, v] of Object.entries(node)) {
|
|
48
|
+
out[k] = deref(doc, v, { maxDepth, depth: depth + 1, seen });
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 모든 엔드포인트의 가벼운 인덱스 (path, method, summary, tags, operationId). */
|
|
54
|
+
export function listEndpoints(doc) {
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const [pathStr, methods] of Object.entries(doc?.paths ?? {})) {
|
|
57
|
+
if (!methods || typeof methods !== 'object') continue;
|
|
58
|
+
for (const m of METHODS) {
|
|
59
|
+
const op = methods[m];
|
|
60
|
+
if (!op || typeof op !== 'object') continue;
|
|
61
|
+
out.push({
|
|
62
|
+
method: m.toUpperCase(),
|
|
63
|
+
path: pathStr,
|
|
64
|
+
summary: op.summary ?? '',
|
|
65
|
+
operationId: op.operationId ?? '',
|
|
66
|
+
tags: op.tags ?? [],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 부분일치 검색. query 의 각 토큰이 path/summary/operationId/tag 어딘가에
|
|
75
|
+
* (대소문자 무시) 들어가면 매치. 점수 = 매치한 필드 수 + path 정확 포함 가산점.
|
|
76
|
+
*/
|
|
77
|
+
export function searchEndpoints(doc, query, { limit = 40 } = {}) {
|
|
78
|
+
const all = listEndpoints(doc);
|
|
79
|
+
if (!query || !query.trim()) return all.slice(0, limit);
|
|
80
|
+
|
|
81
|
+
const tokens = query.toLowerCase().split(/[\s/]+/).filter(Boolean);
|
|
82
|
+
const scored = [];
|
|
83
|
+
for (const e of all) {
|
|
84
|
+
const hay = [
|
|
85
|
+
e.path.toLowerCase(),
|
|
86
|
+
e.summary.toLowerCase(),
|
|
87
|
+
e.operationId.toLowerCase(),
|
|
88
|
+
e.tags.join(' ').toLowerCase(),
|
|
89
|
+
];
|
|
90
|
+
let score = 0;
|
|
91
|
+
for (const tok of tokens) {
|
|
92
|
+
if (hay[0].includes(tok)) score += 3; // path 매치 가중
|
|
93
|
+
else if (hay.some((h) => h.includes(tok))) score += 1;
|
|
94
|
+
}
|
|
95
|
+
if (e.path.toLowerCase().includes(query.toLowerCase())) score += 2;
|
|
96
|
+
if (score > 0) scored.push({ ...e, score });
|
|
97
|
+
}
|
|
98
|
+
scored.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
|
99
|
+
return scored.slice(0, limit);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 특정 path(+method) 의 상세. $ref 를 인라인해서 request/response 스키마까지 본다.
|
|
104
|
+
* method 생략 시 그 path 의 모든 메서드 반환.
|
|
105
|
+
*/
|
|
106
|
+
export function getEndpoint(doc, targetPath, method) {
|
|
107
|
+
const methods = doc?.paths?.[targetPath];
|
|
108
|
+
if (!methods) {
|
|
109
|
+
// 끝 슬래시/대소문자 보정 한 번 시도
|
|
110
|
+
const found = Object.keys(doc?.paths ?? {}).find(
|
|
111
|
+
(p) => p.toLowerCase() === String(targetPath).toLowerCase(),
|
|
112
|
+
);
|
|
113
|
+
if (!found) return { ok: false, error: `해당 path 없음: ${targetPath}` };
|
|
114
|
+
targetPath = found;
|
|
115
|
+
}
|
|
116
|
+
const pathItem = doc.paths[targetPath];
|
|
117
|
+
const wanted = method ? [method.toLowerCase()] : METHODS;
|
|
118
|
+
const operations = {};
|
|
119
|
+
for (const m of wanted) {
|
|
120
|
+
const op = pathItem[m];
|
|
121
|
+
if (!op) continue;
|
|
122
|
+
operations[m.toUpperCase()] = {
|
|
123
|
+
summary: op.summary,
|
|
124
|
+
operationId: op.operationId,
|
|
125
|
+
tags: op.tags,
|
|
126
|
+
parameters: deref(doc, op.parameters),
|
|
127
|
+
requestBody: deref(doc, op.requestBody),
|
|
128
|
+
responses: deref(doc, op.responses),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (Object.keys(operations).length === 0) {
|
|
132
|
+
return { ok: false, error: `${targetPath} 에 ${method ?? ''} 메서드 없음` };
|
|
133
|
+
}
|
|
134
|
+
return { ok: true, path: targetPath, operations };
|
|
135
|
+
}
|
package/src/openapi/summary.js
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* ...
|
|
15
15
|
*/
|
|
16
16
|
const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
17
|
-
const
|
|
18
|
-
const MAX_BYTES =
|
|
17
|
+
const MAX_PATHS = 400;
|
|
18
|
+
const MAX_BYTES = 16 * 1024;
|
|
19
19
|
|
|
20
20
|
export function summarizeOpenApi(doc) {
|
|
21
21
|
if (!doc || typeof doc !== 'object') return null;
|
|
@@ -30,46 +30,42 @@ export function summarizeOpenApi(doc) {
|
|
|
30
30
|
lines.push(`base: ${servers.slice(0, 3).join(', ')}`);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// path 하나당 한 줄로 메서드를 합친다 (줄 수 절반 + 모델이 path 단위로 보기 쉬움).
|
|
34
|
+
const byPath = [];
|
|
34
35
|
for (const [pathStr, methods] of Object.entries(doc.paths ?? {})) {
|
|
35
36
|
if (!methods || typeof methods !== 'object') continue;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
method: m.toUpperCase(),
|
|
42
|
-
path: pathStr,
|
|
43
|
-
desc: desc.split('\n')[0].slice(0, 90),
|
|
44
|
-
});
|
|
45
|
-
}
|
|
37
|
+
const verbs = METHODS.filter((m) => methods[m] && typeof methods[m] === 'object').map((m) =>
|
|
38
|
+
m.toUpperCase(),
|
|
39
|
+
);
|
|
40
|
+
if (verbs.length === 0) continue;
|
|
41
|
+
byPath.push({ path: pathStr, verbs });
|
|
46
42
|
}
|
|
43
|
+
byPath.sort((a, b) => a.path.localeCompare(b.path));
|
|
47
44
|
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const shown = endpoints.slice(0, MAX_ENDPOINTS);
|
|
45
|
+
const total = byPath.length;
|
|
46
|
+
const shown = byPath.slice(0, MAX_PATHS);
|
|
51
47
|
|
|
52
|
-
lines.push(`
|
|
48
|
+
lines.push(`paths: (${shown.length}/${total})`);
|
|
53
49
|
for (const e of shown) {
|
|
54
|
-
lines.push(` ${e.
|
|
50
|
+
lines.push(` ${e.verbs.join(',').padEnd(20)} ${e.path}`);
|
|
55
51
|
}
|
|
56
52
|
if (shown.length < total) {
|
|
57
|
-
lines.push(` ... and ${total - shown.length} more`);
|
|
53
|
+
lines.push(` ... and ${total - shown.length} more (search_openapi 로 검색하세요)`);
|
|
58
54
|
}
|
|
59
55
|
|
|
60
56
|
let result = lines.join('\n');
|
|
57
|
+
// 바이트 초과 시 뒤에서부터 잘라낸다 (search_openapi 가 있으니 전부 못 담아도 안전).
|
|
61
58
|
if (Buffer.byteLength(result, 'utf8') > MAX_BYTES) {
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
`
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
result = compact;
|
|
59
|
+
const header = [lines[0], servers.length ? lines[1] : null].filter(Boolean);
|
|
60
|
+
const out = [...header, `paths: (truncated/${total}) — 전체는 search_openapi 로 검색`];
|
|
61
|
+
let bytes = Buffer.byteLength(out.join('\n'), 'utf8');
|
|
62
|
+
for (const e of shown) {
|
|
63
|
+
const line = ` ${e.verbs.join(',').padEnd(20)} ${e.path}`;
|
|
64
|
+
bytes += Buffer.byteLength(line + '\n', 'utf8');
|
|
65
|
+
if (bytes > MAX_BYTES) break;
|
|
66
|
+
out.push(line);
|
|
67
|
+
}
|
|
68
|
+
result = out.join('\n');
|
|
73
69
|
}
|
|
74
70
|
return result;
|
|
75
71
|
}
|
package/src/ui/ChatApp.js
CHANGED
|
@@ -115,7 +115,7 @@ async function pasteClipboardImage() {
|
|
|
115
115
|
* 슬래시 명령은 Input 컴포넌트의 onSubmit 에서 가로채서 처리.
|
|
116
116
|
*/
|
|
117
117
|
|
|
118
|
-
function Header({ modelMeta, projectFile, gateway, ragOn, hasIndex, openapiInfo }) {
|
|
118
|
+
function Header({ modelMeta, projectFile, gateway, ragOn, hasIndex, openapiInfo, conventionFiles }) {
|
|
119
119
|
let ragLabel;
|
|
120
120
|
if (!hasIndex) ragLabel = '(준비 중 — 자동 빌드 또는 /index)';
|
|
121
121
|
else if (ragOn) ragLabel = 'on (관련 코드 자동 주입)';
|
|
@@ -167,6 +167,14 @@ function Header({ modelMeta, projectFile, gateway, ragOn, hasIndex, openapiInfo
|
|
|
167
167
|
h(Text, null, openapiLabel),
|
|
168
168
|
)
|
|
169
169
|
: null,
|
|
170
|
+
conventionFiles?.length
|
|
171
|
+
? h(
|
|
172
|
+
Box,
|
|
173
|
+
null,
|
|
174
|
+
h(Text, { dimColor: true }, 'docs '),
|
|
175
|
+
h(Text, null, conventionFiles.join(', ')),
|
|
176
|
+
)
|
|
177
|
+
: null,
|
|
170
178
|
gateway
|
|
171
179
|
? h(
|
|
172
180
|
Box,
|
|
@@ -731,6 +739,7 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
731
739
|
const tools = buildTools({
|
|
732
740
|
projectRoot,
|
|
733
741
|
effective: cfg.effective,
|
|
742
|
+
openapiSource: cfg.effective.api?.openapi ?? null,
|
|
734
743
|
onEvent: onToolEvent,
|
|
735
744
|
});
|
|
736
745
|
|
|
@@ -869,6 +878,7 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
869
878
|
ragOn: ragEnabled,
|
|
870
879
|
hasIndex,
|
|
871
880
|
openapiInfo: cfg.openapiInfo,
|
|
881
|
+
conventionFiles: cfg.conventionFiles,
|
|
872
882
|
}),
|
|
873
883
|
h(
|
|
874
884
|
Box,
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# 📑 API 코드 생성 가이드 (for AI)
|
|
2
|
+
|
|
3
|
+
> 이 문서는 **Swagger(OpenAPI) JSON 을 기반으로 API 코드를 생성**할 때 따라야 하는 컨벤션이다.
|
|
4
|
+
> 사용자가 Swagger JSON(또는 엔드포인트)을 제공하면, AI 는 이 문서의 규칙에 맞춰
|
|
5
|
+
> `api / zod / type / service / index` 파일을 생성한다.
|
|
6
|
+
>
|
|
7
|
+
> 스택: `@tanstack/react-query` + `axios`. **React / Next.js 공용**이며, 차이는 §0 의 "위치"뿐이다.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## ⛳ 사전 규칙 — 코드 짜기 전에
|
|
12
|
+
|
|
13
|
+
1. **추측 금지.** Swagger 에 없는 필드/엔드포인트를 임의로 만들지 않는다.
|
|
14
|
+
스키마가 모호하면 기존 리소스 폴더(예: `user/`)를 먼저 `read_file` 로 확인하고,
|
|
15
|
+
그래도 불명확하면 사용자에게 한 번 묻는다.
|
|
16
|
+
2. **기존 코드 우선.** 공용 유틸(`cacheConfig`, `queryKey`, `captureSentryError`, `metaSchema`,
|
|
17
|
+
`PaginationParams` 등)은 새로 만들지 말고 그대로 가져다 쓴다. import 경로가 확실치 않으면
|
|
18
|
+
`search_code` 로 실제 export 위치를 확인한다.
|
|
19
|
+
3. **5파일 세트는 항상 함께.** `api / zod / type / service / index` 를 한 번에 생성한다.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 0. 위치 & 폴더 구조 ⚠️ (React vs Next 차이는 여기뿐)
|
|
24
|
+
|
|
25
|
+
API 코드의 **루트 위치는 프레임워크마다 다르다.**
|
|
26
|
+
|
|
27
|
+
| 프레임워크 | API 루트 |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| **React** (Vite/CRA 등) | `src/api` |
|
|
30
|
+
| **Next.js** | `lib/api` |
|
|
31
|
+
|
|
32
|
+
> 그 외 폴더/파일 구조와 규칙은 **완전히 동일**하다. 아래 예시는 `src/api` 기준이며,
|
|
33
|
+
> Next 면 `src/api` 를 `lib/api` 로 바꿔 읽으면 된다.
|
|
34
|
+
|
|
35
|
+
리소스(도메인) 하나당 폴더 하나. 폴더명은 **소문자**(여러 단어는 kebab-case: `favorite-stores`).
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
src/api/ # (Next: lib/api/)
|
|
39
|
+
├── instance.ts # axios 인스턴스 (interceptor 포함)
|
|
40
|
+
├── index.ts # 모든 API 모듈 export
|
|
41
|
+
└── user/ # 리소스 폴더 (소문자)
|
|
42
|
+
├── user.api.ts # axios 호출 함수 (순수 함수)
|
|
43
|
+
├── user.zod.ts # 응답/요청 zod 스키마
|
|
44
|
+
├── user.type.ts # zod 로부터 추론한 타입 + 입력 타입
|
|
45
|
+
├── user.service.ts # react-query 훅 (use~) — 비즈니스 로직
|
|
46
|
+
└── index.ts # 외부 노출 (service, type 만)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- 파일 prefix 는 폴더명과 같다 (`user/` → `user.api.ts`, `user.zod.ts` ...).
|
|
50
|
+
- `RESOURCE` 상수는 `/api/<path>` 형태로 `.api.ts` 상단에 둔다.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 1. 가장 먼저 — "next(무한스크롤)" 인지 판단하라
|
|
55
|
+
|
|
56
|
+
> 여기서 말하는 "next" 는 **프레임워크 Next.js 가 아니라**, **커서 기반 무한스크롤 패턴**을 가리킨다.
|
|
57
|
+
|
|
58
|
+
코드를 짜기 전에 **해당 엔드포인트가 커서 기반 무한스크롤 API 인지** 먼저 판단한다.
|
|
59
|
+
|
|
60
|
+
### "next" 로 판단하는 기준 (아래 중 하나라도 해당하면 next)
|
|
61
|
+
|
|
62
|
+
- 요청 파라미터에 `cursor`, `limit` (또는 `page`, `size` 등 페이지네이션 파라미터) 가 있다.
|
|
63
|
+
- 응답 본문에 `items`(배열) + `meta`(`hasNextPage`, `nextCursor`) 구조가 있다.
|
|
64
|
+
- "목록을 스크롤하며 더 불러오는" 리스트 조회 엔드포인트다.
|
|
65
|
+
|
|
66
|
+
| 판단 | 사용 훅 | 참고 |
|
|
67
|
+
| --- | --- | --- |
|
|
68
|
+
| **next O** (무한스크롤) | `useInfiniteQuery` | [§5](#5-next무한스크롤-템플릿) |
|
|
69
|
+
| **next X** (일반) | `useQuery` / `useMutation` | [§4](#4-일반next-x-템플릿) |
|
|
70
|
+
|
|
71
|
+
> 단건 조회·생성·수정·삭제, 페이지네이션 없는 전체 목록은 모두 **next X (일반)**.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 2. 각 파일 작성 규칙
|
|
76
|
+
|
|
77
|
+
### 2-1. `user.api.ts` — 순수 호출 함수
|
|
78
|
+
|
|
79
|
+
- `import baseInstance from '../instance';` 사용 (axios 인스턴스).
|
|
80
|
+
- 함수는 `async`, 내부에서 `const { data } = await baseInstance.X(...)` 후 `return data;`.
|
|
81
|
+
- 응답 본문이 없는 경우(`204 No Content`) 는 `return data` 생략 가능.
|
|
82
|
+
- 쿼리스트링은 `{ params }`, path 파라미터는 템플릿 리터럴.
|
|
83
|
+
- **여기서는 zod 파싱을 하지 않는다.** (파싱은 service 의 queryFn 책임)
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import baseInstance from '../instance';
|
|
87
|
+
|
|
88
|
+
const RESOURCE = '/api/user';
|
|
89
|
+
|
|
90
|
+
export const getUserList = async () => {
|
|
91
|
+
const { data } = await baseInstance.get(RESOURCE);
|
|
92
|
+
|
|
93
|
+
return data;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const getUser = async (userId: string) => {
|
|
97
|
+
const { data } = await baseInstance.get(`${RESOURCE}/${userId}`);
|
|
98
|
+
|
|
99
|
+
return data;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const deleteUser = async (userId: string) => {
|
|
103
|
+
const { data } = await baseInstance.delete(`${RESOURCE}/${userId}`);
|
|
104
|
+
|
|
105
|
+
return data;
|
|
106
|
+
};
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 2-2. `user.zod.ts` — 스키마
|
|
110
|
+
|
|
111
|
+
- `import { z } from 'zod';`
|
|
112
|
+
- **단일 아이템 스키마**(`userItemSchema`)를 먼저 정의하고, 리스트는 `z.array(...)` 로 조합한다.
|
|
113
|
+
- 재사용 가능한 작은 스키마는 별도 `const` 로 분리.
|
|
114
|
+
- Swagger 타입 → zod 매핑:
|
|
115
|
+
- `string` → `z.string()`, `integer/number` → `z.number()`, `boolean` → `z.boolean()`
|
|
116
|
+
- `nullable: true` → `.nullable()` / `required` 에 없으면 `.optional()` (둘 다면 `.nullable().optional()`)
|
|
117
|
+
- `enum` → `z.enum([...] as const)` (숫자 enum 은 `z.union([z.literal(1), ...])`)
|
|
118
|
+
- `format: date-time` → `z.string().datetime()` (값은 ISO 문자열 유지)
|
|
119
|
+
- 제약(min/max/length) 이 명시되면 반영
|
|
120
|
+
- **`$ref` / `allOf` / `oneOf`**:
|
|
121
|
+
- `$ref` → 참조 대상 스키마를 먼저 정의 후 재사용
|
|
122
|
+
- `allOf` → `baseSchema.merge(extraSchema)` 또는 `.and(...)`
|
|
123
|
+
- `oneOf`/`anyOf` → `z.union([...])`, discriminator 있으면 `z.discriminatedUnion(...)`
|
|
124
|
+
- **next 응답**은 공통 `metaSchema` 사용: `import { metaSchema } from '@/lib';`
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { z } from 'zod';
|
|
128
|
+
|
|
129
|
+
export const userItemSchema = z.object({
|
|
130
|
+
userId: z.string(),
|
|
131
|
+
name: z.string(),
|
|
132
|
+
email: z.string().nullable(),
|
|
133
|
+
isActive: z.boolean(),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const userListSchema = z.array(userItemSchema);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 2-3. `user.type.ts` — 타입
|
|
140
|
+
|
|
141
|
+
- 응답 타입은 **zod 스키마에서 추론**: `export type X = z.infer<typeof xSchema>;`
|
|
142
|
+
- 입력(요청) 타입은 직접 정의. 인자가 2개 이상인 변경은 객체 입력 타입으로 묶는다.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { z } from 'zod';
|
|
146
|
+
import { userItemSchema } from './user.zod';
|
|
147
|
+
|
|
148
|
+
export type UserItem = z.infer<typeof userItemSchema>;
|
|
149
|
+
|
|
150
|
+
export type UpdateUser = {
|
|
151
|
+
userId: string;
|
|
152
|
+
name: string;
|
|
153
|
+
};
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### 2-4. `user.service.ts` — react-query 훅 (비즈니스 로직)
|
|
157
|
+
|
|
158
|
+
공통 import:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { cacheConfig, captureSentryError, queryKey } from '@/lib';
|
|
162
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
규칙:
|
|
166
|
+
|
|
167
|
+
- 훅 이름: 조회 `useGetUser` / `useGetUserList`, 변경 `useCreateUser` / `useUpdateUser` / `useDeleteUser`.
|
|
168
|
+
- **조회(useQuery)** 의 `queryFn` 에서 zod `safeParse` 로 검증.
|
|
169
|
+
- 실패 시 `console.error(...)` 후 **원본 `data` 그대로 반환**(throw 금지).
|
|
170
|
+
- 성공 시 `parsed.data` 반환.
|
|
171
|
+
- **변경(useMutation)** 은 `onSuccess` 에서 관련 `queryKey` 를 `invalidateQueries`.
|
|
172
|
+
- 모든 훅의 `onError` 에서 `captureSentryError(error, { location, action })`.
|
|
173
|
+
- `location` = 훅 이름(`'useDeleteUser'`), `action` = 호출 함수명(`'deleteUser'`).
|
|
174
|
+
- 조회 훅은 마지막에 `...cacheConfig.<tier>` + `...options` 펼침.
|
|
175
|
+
- 캐시 tier: 자주 바뀜 `realtime`/`shortLived`, 보통 `mediumLived`, 잘 안 바뀜 `longLived`, 불변 `immutable`.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
export const useGetUserList = (options?: Record<string, any>) => {
|
|
179
|
+
return useQuery({
|
|
180
|
+
queryKey: queryKey.user.list,
|
|
181
|
+
queryFn: async () => {
|
|
182
|
+
const data = await getUserList();
|
|
183
|
+
const parsed = userListSchema.safeParse(data);
|
|
184
|
+
|
|
185
|
+
if (!parsed.success) {
|
|
186
|
+
console.error('User list validation error:', parsed.error);
|
|
187
|
+
|
|
188
|
+
return data;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return parsed.data;
|
|
192
|
+
},
|
|
193
|
+
...cacheConfig.longLived,
|
|
194
|
+
...options,
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const useDeleteUser = () => {
|
|
199
|
+
const queryClient = useQueryClient();
|
|
200
|
+
|
|
201
|
+
return useMutation({
|
|
202
|
+
mutationFn: (userId: string) => deleteUser(userId),
|
|
203
|
+
onSuccess: () => {
|
|
204
|
+
queryClient.invalidateQueries({ queryKey: queryKey.user.all });
|
|
205
|
+
},
|
|
206
|
+
onError: (error) => {
|
|
207
|
+
captureSentryError(error, { location: 'useDeleteUser', action: 'deleteUser' });
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### 2-5. `index.ts` (리소스) — 노출
|
|
214
|
+
|
|
215
|
+
- **`service` 와 `type` 만** 재노출 (`api`, `zod` 는 노출하지 않음).
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
export * from './user.service';
|
|
219
|
+
export * from './user.type';
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### 2-6. `src/api/index.ts` (루트) — 모든 모듈 export
|
|
223
|
+
|
|
224
|
+
- 새 리소스를 추가하면 한 줄 추가한다.
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
export * from './user';
|
|
228
|
+
// export * from './order';
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### 2-7. `src/api/instance.ts` — axios 인스턴스
|
|
232
|
+
|
|
233
|
+
- 이미 있으면 **건드리지 않는다.** baseURL/interceptor 설정이 여기 모여 있다.
|
|
234
|
+
- 새 리소스는 항상 이 `baseInstance` 를 import 해서 쓴다.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## 3. queryKey 등록 규칙
|
|
239
|
+
|
|
240
|
+
공용 `queryKey` 객체에 리소스 항목을 추가한다.
|
|
241
|
+
|
|
242
|
+
- `all` 은 무효화(invalidate) 기준 최상위 키. 변경 훅은 보통 `queryKey.<resource>.all` 무효화.
|
|
243
|
+
- 하위 키는 `['<resource>', '<scope>']`. 파라미터가 들어가면 함수형으로.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
user: Object.freeze({
|
|
247
|
+
all: ['user'],
|
|
248
|
+
list: ['user', 'list'],
|
|
249
|
+
detail: (id: string) => ['user', 'detail', id],
|
|
250
|
+
}),
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## 4. 일반(next X) 템플릿
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
// api
|
|
259
|
+
export const getUser = async (params?: SomeParams) => {
|
|
260
|
+
const { data } = await baseInstance.get(RESOURCE, { params });
|
|
261
|
+
return data;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// service
|
|
265
|
+
export const useGetUser = (options?: Record<string, any>) => {
|
|
266
|
+
return useQuery({
|
|
267
|
+
queryKey: queryKey.user.list,
|
|
268
|
+
queryFn: async () => {
|
|
269
|
+
const data = await getUser();
|
|
270
|
+
const parsed = userSchema.safeParse(data);
|
|
271
|
+
if (!parsed.success) {
|
|
272
|
+
console.error('User validation error:', parsed.error);
|
|
273
|
+
return data;
|
|
274
|
+
}
|
|
275
|
+
return parsed.data;
|
|
276
|
+
},
|
|
277
|
+
...cacheConfig.mediumLived,
|
|
278
|
+
...options,
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// 변경
|
|
283
|
+
export const useCreateUser = () => {
|
|
284
|
+
const queryClient = useQueryClient();
|
|
285
|
+
|
|
286
|
+
return useMutation({
|
|
287
|
+
mutationFn: (payload: CreateUserPayload) => createUser(payload),
|
|
288
|
+
onSuccess: () => {
|
|
289
|
+
queryClient.invalidateQueries({ queryKey: queryKey.user.all });
|
|
290
|
+
},
|
|
291
|
+
onError: (error) => {
|
|
292
|
+
captureSentryError(error, { location: 'useCreateUser', action: 'createUser' });
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
};
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
> 인자가 2개 이상이면 객체로 묶어 `*.type.ts` 에 입력 타입을 정의하고 구조분해로 받는다.
|
|
299
|
+
> 예: `mutationFn: ({ userId, name }: UpdateUser) => updateUser(userId, name)`
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## 5. next(무한스크롤) 템플릿
|
|
304
|
+
|
|
305
|
+
판단 결과가 **next** 일 때만 사용. 핵심은 `useInfiniteQuery` + 공통 `metaSchema`.
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
// api (cursor 기반)
|
|
309
|
+
import { PaginationParams } from '@/lib';
|
|
310
|
+
|
|
311
|
+
export const getUser = async (params: PaginationParams) => {
|
|
312
|
+
const { data } = await baseInstance.get(RESOURCE, { params });
|
|
313
|
+
return data;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// zod (items + meta)
|
|
317
|
+
import { metaSchema } from '@/lib';
|
|
318
|
+
|
|
319
|
+
export const userItemSchema = z.object({ /* ... */ });
|
|
320
|
+
export const getUserResponseSchema = z.object({
|
|
321
|
+
items: z.array(userItemSchema),
|
|
322
|
+
meta: metaSchema, // { hasNextPage, nextCursor }
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// type
|
|
326
|
+
export type GetUserResponse = z.infer<typeof getUserResponseSchema>;
|
|
327
|
+
|
|
328
|
+
// service
|
|
329
|
+
import { useInfiniteQuery } from '@tanstack/react-query';
|
|
330
|
+
|
|
331
|
+
export const useGetUser = (options?: Record<string, any>) => {
|
|
332
|
+
const defaultParams: PaginationParams = { limit: 10, cursor: undefined };
|
|
333
|
+
|
|
334
|
+
return useInfiniteQuery<GetUserResponse, Error, GetUserResponse['items']>({
|
|
335
|
+
queryKey: queryKey.user.list,
|
|
336
|
+
queryFn: async ({ pageParam }) => {
|
|
337
|
+
const params: PaginationParams = { ...defaultParams, ...(pageParam || {}) };
|
|
338
|
+
const data = await getUser(params);
|
|
339
|
+
const parsed = getUserResponseSchema.safeParse(data);
|
|
340
|
+
if (!parsed.success) {
|
|
341
|
+
console.error('User validation error:', parsed.error);
|
|
342
|
+
return data;
|
|
343
|
+
}
|
|
344
|
+
return parsed.data;
|
|
345
|
+
},
|
|
346
|
+
getNextPageParam: (lastPage) =>
|
|
347
|
+
lastPage?.meta?.hasNextPage
|
|
348
|
+
? { ...defaultParams, cursor: lastPage.meta.nextCursor }
|
|
349
|
+
: undefined,
|
|
350
|
+
select: (data) => data.pages.flatMap((page) => page?.items ?? []),
|
|
351
|
+
initialPageParam: defaultParams,
|
|
352
|
+
...cacheConfig.longLived,
|
|
353
|
+
...options,
|
|
354
|
+
});
|
|
355
|
+
};
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
- `select` 로 `pages` 를 평탄화해 컴포넌트는 평평한 배열만 받는다.
|
|
359
|
+
- `meta.hasNextPage` 가 falsy 면 `getNextPageParam` 은 `undefined`(다음 페이지 없음).
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## 6. 네이밍 & 스타일 요약
|
|
364
|
+
|
|
365
|
+
- 폴더: 소문자 / kebab-case (`user`, `favorite-stores`). 파일 prefix = 폴더명.
|
|
366
|
+
- 함수: 동사 + 리소스 (`getUserList`, `createUser`, `updateUser`).
|
|
367
|
+
- 훅: `use` + 함수 의미 (`useGetUserList`, `useCreateUser`).
|
|
368
|
+
- `RESOURCE` 상수로 baseURL 경로 관리, 동적 경로는 템플릿 리터럴.
|
|
369
|
+
- 조회는 zod 검증(실패 시 원본 반환), 변경은 invalidate + Sentry.
|
|
370
|
+
- `index.ts` 는 service/type 만 노출, 루트 `index.ts` 에 리소스 한 줄 추가.
|
|
371
|
+
- 들여쓰기 2칸, 세미콜론 사용, import 그룹: 외부 → `@/...` → 상대경로.
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
## 7. 생성 시 체크리스트 ✅
|
|
376
|
+
|
|
377
|
+
1. [ ] 위치를 맞췄다 (React `src/api` / Next `lib/api`).
|
|
378
|
+
2. [ ] 엔드포인트가 **next(무한스크롤)** 인지 판단했다. (§1)
|
|
379
|
+
3. [ ] `api / zod / type / service / index` 5파일을 모두 만들었다.
|
|
380
|
+
4. [ ] Swagger 응답을 zod 로 정확히 매핑(nullable/optional/enum/date/$ref).
|
|
381
|
+
5. [ ] 타입은 `z.infer` 로 추론.
|
|
382
|
+
6. [ ] 조회 훅 queryFn 에서 `safeParse` 후 실패 시 원본 반환.
|
|
383
|
+
7. [ ] 변경 훅에 `invalidateQueries` + `captureSentryError`.
|
|
384
|
+
8. [ ] `queryKey` 에 리소스 키(`all` 포함) 추가.
|
|
385
|
+
9. [ ] 리소스 `index.ts` + 루트 `index.ts` 노출 추가.
|
|
386
|
+
10. [ ] next 면 `useInfiniteQuery` + `metaSchema` + `select` 평탄화 적용.
|
|
387
|
+
11. [ ] 공용 유틸을 재사용하고, Swagger 에 없는 필드를 추측으로 만들지 않았다.
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
391
|
+
## 8. 자주 하는 실수 (하지 말 것) 🚫
|
|
392
|
+
|
|
393
|
+
- ❌ `*.api.ts` 에서 zod 파싱 (파싱은 service 의 queryFn 책임).
|
|
394
|
+
- ❌ `index.ts` 에서 `api`/`zod` 노출 (service/type 만).
|
|
395
|
+
- ❌ 조회 훅에서 검증 실패 시 throw (원본 반환이 규칙).
|
|
396
|
+
- ❌ `captureSentryError` / `invalidateQueries` 누락.
|
|
397
|
+
- ❌ Swagger 의 `nullable` 무시하고 필수로 선언.
|
|
398
|
+
- ❌ 일반 목록인데 `useInfiniteQuery` 사용 (또는 그 반대).
|
|
399
|
+
- ❌ 공용 타입/유틸(`metaSchema`, `PaginationParams`)을 중복 재정의.
|
|
400
|
+
- ❌ React 인데 `lib/api`, Next 인데 `src/api` 에 만드는 위치 실수.
|