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 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
- - [ ] v1.7.0 — write/edit 승인 게이트 (`y/n/v/q`), diff 미리보기
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
@@ -28,7 +28,7 @@ const program = new Command();
28
28
  program
29
29
  .name('bc')
30
30
  .description('Byuckchon Frontend Workbench — 프로젝트 스타터 + AI 어시스턴트')
31
- .version('1.6.1');
31
+ .version('1.9.0');
32
32
 
33
33
  program
34
34
  .command('init')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "byuckchon-frontend-cli",
3
- "version": "1.6.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 순서) 을 따른다.',
@@ -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}\n`));
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 키 등록'));
@@ -63,12 +63,37 @@ export async function chatCommand(opts = {}) {
63
63
  process.exit(1);
64
64
  }
65
65
 
66
- const baseSystem = buildSystemPrompt({
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 스펙 (자동 주입됨, 사용자가 별도로 명령 없이 알아서 사용 가능):\n' +
114
+ '\n\n---\nOpenAPI 스펙 개요 (아래 목록은 길면 잘려 있을 있음):\n' +
90
115
  summary +
91
- '\n\n사용자가 API 관련 코드를 요청하면 스펙을 우선 참조하라. ' +
92
- '응답 타입이 필요하면 \`bc gen api-types\` 실행을 권하거나, ' +
93
- '이미 \`*.gen.ts\` 있으면 그걸 import 해서 쓰라고 안내한다.';
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'
@@ -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
+ }
@@ -14,8 +14,8 @@
14
14
  * ...
15
15
  */
16
16
  const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
17
- const MAX_ENDPOINTS = 200;
18
- const MAX_BYTES = 8 * 1024;
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
- const endpoints = [];
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
- for (const m of METHODS) {
37
- const op = methods[m];
38
- if (!op || typeof op !== 'object') continue;
39
- const desc = op.summary || op.operationId || op.description || '';
40
- endpoints.push({
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 total = endpoints.length;
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(`endpoints: (${shown.length}/${total})`);
48
+ lines.push(`paths: (${shown.length}/${total})`);
53
49
  for (const e of shown) {
54
- lines.push(` ${e.method.padEnd(6)} ${e.path}${e.desc ? ' -- ' + e.desc : ''}`);
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
- // 안전 장치 desc 자르고 다시.
63
- const compact = [
64
- lines[0],
65
- servers.length ? lines[1] : null,
66
- `endpoints: (${shown.length}/${total})`,
67
- ...shown.map((e) => ` ${e.method.padEnd(6)} ${e.path}`),
68
- shown.length < total ? ` ... and ${total - shown.length} more` : null,
69
- ]
70
- .filter(Boolean)
71
- .join('\n');
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` 에 만드는 위치 실수.