@m1kapp/kit 0.0.28 → 0.0.31

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.
@@ -0,0 +1,71 @@
1
+ `m1kkit stats`의 청결도 점수(quality)를 근거로 코드를 **동작 변화 없이** 정리해 등급을 올린다. 목표: 복잡 함수(CC 10+)를 줄이고, 200줄+ 파일을 쪼개고, 재실행으로 개선을 수치로 증명한다.
2
+
3
+ 점수 구조 v2 (AST 모드, 100점 감점제):
4
+ - cognitive>15 함수 비율 ×3 (최대 -25) / cognitive>25 함수 비율 ×5 (최대 -15)
5
+ - 최악 함수 cognitive 20 초과분 ×1 (최대 -15)
6
+ - **중복 밀도 3% 초과분 ×2.5 (최대 -25)** — 토큰 단위 복붙 검출 (식별자 정규화, 테스트 제외)
7
+ - 200줄 초과 파일 비율 ×2 (최대 -10) / 평균 파일 길이 80줄 초과분 ÷5 (최대 -10)
8
+
9
+ cognitive = 중첩 깊이 가중 복잡도 (SonarQube 방식): `a && b && c`는 1, 5중 중첩 if는 flat if 5개보다 훨씬 비쌈. `??`는 미카운트. 즉 **평평하게 펴는 것(조기 반환)이 진짜 점수가 됨** — 함수 쪼개기만으론 안 오름.
10
+
11
+ 즉 **효율 순서: ① 중복 블록 제거 (kit-stats.json `quality.duplication.worstBlocks`가 위치 지목) ② 최악 함수 중첩 평탄화 ③ cog15+ 함수들 ④ 긴 파일 분할**.
12
+
13
+ `--llm` 플래그(선택): cognitive 최악 3개를 Claude(haiku)가 네이밍·응집도·본질적 복잡성 여부 자문 — 점수 미반영, `quality.llm`에 기록. "essential: true"로 나온 함수는 억지로 쪼개지 말 것.
14
+
15
+ ---
16
+
17
+ ## Step 0: 진단
18
+
19
+ ```bash
20
+ npx m1kkit stats # 또는 프로젝트에 맞는 --dir/--out (package.json scripts.stats 참고)
21
+ ```
22
+
23
+ - 콘솔의 `복잡: <함수명> CC <n> — <파일>:<줄>` 목록 = 작업 대상 우선순위.
24
+ - `public/kit-stats.json`의 `quality.cc.worst`(상위 5개), `over10`/`over20`, `longFiles`, `maxFile`도 확인.
25
+ - `quality.engine`이 `"regex"`면 typescript가 devDependencies에 없는 것 — 설치하면 AST 정밀 분석으로 전환된다.
26
+ - 시작 점수·등급을 기록해 둔다 (개선 전후 비교용).
27
+
28
+ ## Step 1: 리팩토링 — CC 낮추는 표준 기법만
29
+
30
+ 대상 함수를 열고 아래 순서로 적용. **기능·출력·API 시그니처 불변**이 절대 조건.
31
+
32
+ 1. **조기 반환(guard clause)**: 중첩 if를 `if (!x) return`으로 평탄화 — CC는 그대로여도 가독성↑, 보통 else 제거로 CC↓
33
+ 2. **분기 → 데이터**: if/else if 체인·switch를 lookup 객체/Map/배열로. `조건 ? a : b` 체인도 테이블화. CC 감소 효과 최대
34
+ 3. **헬퍼 추출**: 한 함수 안의 독립 단계(파싱/검증/변환/포맷)를 이름 있는 함수로 분리 — 함수당 CC가 나뉜다. 단, 한 곳에서만 쓰는 3줄짜리를 억지로 쪼개지 말 것
35
+ 4. **`&&`/`||` 체인 → 이름 있는 boolean**: `const isStale = a && b || c` 처럼 의미 단위로 묶기 (CC 총량은 유지되지만 함수 분리와 병행 시 분산)
36
+ 5. **긴 컴포넌트 분할**: 200줄+ 파일은 렌더 섹션별 서브컴포넌트/훅으로. props drilling이 3단계 넘게 생기면 무리한 분할이니 중단
37
+ 6. **kit 활용**: 직접 짠 로직이 kit 컴포넌트/유틸(`AsyncList`, `fillDateSeries`, `formatKoreanNumber` 등)로 대체되면 코드 자체가 사라진다 — 청결도와 kit 반영율 동시 상승
38
+
39
+ **금지**: 과추상화(1회용 제네릭 헬퍼, 설정 객체 패턴 남발), 동작 바뀌는 "개선", 테스트 없는 공개 API 시그니처 변경, CC 점수만 노린 의미 없는 함수 쪼개기.
40
+
41
+ ## Step 2: 검증
42
+
43
+ ```bash
44
+ npx tsc --noEmit && npm run build
45
+ ```
46
+
47
+ - 둘 다 통과해야 다음 함수로. 테스트 있으면 테스트도.
48
+ - 리팩토링 전후 해당 화면/API 동작을 실제로 확인 (dev 서버·curl).
49
+
50
+ ## Step 3: 재측정 · 반복
51
+
52
+ ```bash
53
+ npx m1kkit stats
54
+ ```
55
+
56
+ - 점수·등급 변화를 확인. 오르지 않았으면 감점 구조를 다시 보고 다른 항목 공략.
57
+ - 기본 목표: **B(70) 이상**, 여유 되면 A(80). 사용자가 목표 등급을 줬으면 그걸 따른다.
58
+ - 한 사이클(함수 1~3개)마다 커밋 — 대량 일괄 리팩토링 금지 (리뷰·롤백 불가능해짐).
59
+
60
+ ## Step 4: 마무리
61
+
62
+ - 갱신된 `public/kit-stats.json` 커밋 (배포된 사이트·m1k.app dev 모드에 새 등급 반영).
63
+ - 최종 보고: 전/후 점수·등급, 처리한 함수 목록(CC 전→후), 남은 CC10+ 함수와 이유(예: "정당한 복잡도 — 상태 머신").
64
+
65
+ ---
66
+
67
+ ## 주의
68
+
69
+ - CC가 높다고 전부 나쁜 코드는 아니다. 본질적으로 분기 많은 도메인(뱃지 SVG 분기, cron 집계)은 **데이터 테이블화가 안 되면 그대로 두고 보고에 사유 명시**.
70
+ - `(anonymous)` 함수는 콜백/핸들러 — 이름 있는 함수로 빼는 것만으로 추적 가능해진다.
71
+ - 점수는 상대 비교용 휴리스틱. 100점 만들기가 목적이 아니라 최악 지점 제거가 목적.
@@ -21,19 +21,23 @@ description: "@m1kapp/kit 기반 Next.js 프로젝트 초기 설정을 인터랙
21
21
  2. **앱 화면은 반드시 이 트리로 감싼다.** `AppShell`만 쓰면 화면 좌측에 붙고 휑하다. 중앙정렬·풀화면 배경·powered-by 풋터는 `Watermark`가 담당한다.
22
22
  ```tsx
23
23
  "use client";
24
- import { Watermark, AppShell, AppShellHeader, AppShellContent, TabBar, Tab } from "@m1kapp/kit";
24
+ import { Watermark, AppShell, AppShellHeader, AppShellContent, FetchProgress, TabBar, Tab } from "@m1kapp/kit";
25
+ import Link from "next/link";
25
26
 
26
27
  <Watermark color="#0a0d16" text="앱이름" maxWidth={460}>
27
28
  <AppShell maxWidth={460}>
28
29
  <AppShellHeader>…헤더…</AppShellHeader>
30
+ <FetchProgress top={56} color="[테마컬러]" /> {/* 백그라운드 갱신 표시 — useFetch 쓰면 자동 */}
29
31
  <AppShellContent>…스크롤 본문…</AppShellContent>
30
32
  <TabBar>
31
- <Tab active={…} onClick={…} label="홈" icon={<span>🏠</span>} activeColor="[테마컬러]" />
33
+ {/* Next 라우트 탭이면 render+Link — 프리페치로 탭 전환이 즉시가 된다 */}
34
+ <Tab active={…} render={(p) => <Link href="/home" prefetch {...p} />} label="홈" icon={<span>🏠</span>} activeColor="[테마컬러]" />
35
+ {/* 라우터 없는 useState 탭 전환이면 기존처럼 onClick */}
32
36
  </TabBar>
33
37
  </AppShell>
34
38
  </Watermark>
35
39
  ```
36
- - `AppShell`/`TabBar`는 인터랙티브 → 이 트리는 **`"use client"` 컴포넌트**에 둔다. 데이터는 서버 컴포넌트(page.tsx)에서 계산해 props로 내려준다.
40
+ - `AppShell`/`TabBar`는 인터랙티브 → 이 트리는 **`"use client"` 컴포넌트**에 둔다.
37
41
  - `maxWidth`는 Watermark와 AppShell을 **같은 값**으로 맞춘다(기본 430~460).
38
42
 
39
43
  3. **이모지는 토스페이스로 통일한다.** 장식용 이모지는 남발하지 말고, 쓰는 이모지는 토스 스타일로 폴백시킨다.
@@ -42,7 +46,16 @@ description: "@m1kapp/kit 기반 Next.js 프로젝트 초기 설정을 인터랙
42
46
 
43
47
  4. **🚫 `node_modules/@m1kapp/kit` 디렉토리 안에서 `npm run build`/스크립트를 절대 돌리지 마라.** kit 자체 빌드가 돌아 `dist/`를 날린다. 깨지면 `rm -rf node_modules/@m1kapp/kit && npm install @m1kapp/kit`로 복구. 빌드는 항상 프로젝트 루트에서.
44
48
 
45
- 5. **kit 컴포넌트 카탈로그**(필요 시 골라 쓰기): 레이아웃 `AppShell/AppShellHeader/AppShellContent`, 내비 `TabBar/Tab/Fab`, 표시 `Section/SectionHeader/StatChip/Badge/Avatar/EmptyState/Divider`, 브랜딩 `Watermark`, 입력 `Button/EmojiButton`, 테마 `ThemeButton/ThemeDialog`, 모션 `Typewriter`. SEO는 `@m1kapp/kit/seo`, OG는 `@m1kapp/kit/ogimage`, PWA는 `@m1kapp/kit/pwa`.
49
+ 5. **데이터는 `useFetch`로 가져온다 — 스피너로 콘텐츠를 갈아끼우지 마라.** v0.0.30부터 useFetch는 SWR(stale-while-revalidate): 캐시를 즉시 보여주고 백그라운드에서 갱신한다. 로딩 표시는 `FetchProgress` 상단 바 하나면 끝.
50
+ ```tsx
51
+ const { data, status } = useFetch<Item[]>("/api/items"); // GET
52
+ const { data } = useFetch(`/api/list#${id}`, { fetcher: postJson({ id }) }); // POST 조회 — 캐시 키는 경로#식별자
53
+ // 뮤테이션 후: await fetch(...POST...); invalidateFetch("/api/items");
54
+ ```
55
+ - 목록 렌더는 `AsyncList`(로딩/에러/빈/성공 4상태 자동)에 `data`/`status`를 그대로 꽂는다.
56
+ - `loading`은 캐시가 전혀 없는 첫 로드에만 true — 스켈레톤은 그때만 보인다. 재검증 감지는 `revalidating`.
57
+
58
+ 6. **kit 컴포넌트 카탈로그**(필요 시 골라 쓰기): 레이아웃 `AppShell/AppShellHeader/AppShellContent`, 내비 `TabBar/Tab/Fab`, 데이터 `useFetch/usePolling/invalidateFetch/postJson/AsyncList/FetchProgress`, 표시 `Section/SectionHeader/StatChip/Badge/Avatar/EmptyState/Divider/Skeleton/BarList/ProgressRing/GrassMap`, 오버레이 `Dialog/InAppSheet/ToastProvider·useToast/Tooltip`, 브랜딩 `Watermark/PoweredByKit`, 입력 `Button/IconButton/Input/Textarea/Field/Select/Switch/SegmentedControl/EmojiButton/InlineEdit/ColorPicker`, 테마 `ThemeButton/ThemeDialog`, 모션 `Typewriter/Countdown/Carousel`. SEO는 `@m1kapp/kit/seo`, OG는 `@m1kapp/kit/ogimage`, PWA는 `@m1kapp/kit/pwa`.
46
59
 
47
60
  ---
48
61
 
@@ -130,9 +143,9 @@ export const viewport = mobileViewport;
130
143
 
131
144
  ### 앱쉘 레이아웃 — (1) 선택 시
132
145
  - `"use client"` 컴포넌트(예: `components/AppRoot.tsx`)에 위 **핵심 규칙 2번** 트리를 그대로 생성.
133
- - `page.tsx`(서버 컴포넌트)는 데이터만 계산해서 `<AppRoot data={…} />`로 내려준다.
134
- - 탭이 여러 개면 `useState`로 탭 전환, 각 탭은 별도 패널 컴포넌트로 분리.
135
- - `AppShellContent` 안의 섹션은 `Section`/`SectionHeader`/`StatChip` 등 kit 컴포넌트를 우선 사용.
146
+ - 데이터: 자주 갱신되거나 탭 전환마다 보이는 데이터는 **클라이언트에서 `useFetch`**(핵심 규칙 5번 — SWR 캐시로 탭 전환이 즉시). SEO가 중요한 정적 콘텐츠만 서버 컴포넌트에서 props로.
147
+ - 탭이 라우트면 `Tab render+Link prefetch`, 단일 페이지 내 전환이면 `useState`. 각 탭은 별도 패널 컴포넌트로 분리.
148
+ - `AppShellContent` 안의 섹션은 `Section`/`SectionHeader`/`StatChip` 등 kit 컴포넌트를 우선 사용. 목록은 `AsyncList`.
136
149
 
137
150
  ### Tossface 이모지 — (2) 선택 시
138
151
  - layout `<head>`: `<link rel="preconnect" href="https://cdn.jsdelivr.net" />` + tossface css `<link>`.
package/bin/skills.mjs CHANGED
@@ -32,6 +32,10 @@ const SKILLS = [
32
32
  name: "m1kapp-stats",
33
33
  desc: "/m1kapp-stats — 코드 분석(stats) 켜기 · kit-stats.json 생성/갱신 · PoweredByKit 패널 데이터 채우기",
34
34
  },
35
+ {
36
+ name: "m1kapp-clean",
37
+ desc: "/m1kapp-clean — 청결도 올리기 · CC 높은 함수 리팩토링 → stats 재측정 루프",
38
+ },
35
39
  ];
36
40
 
37
41
  const args = process.argv.slice(2);
package/bin/stats.mjs CHANGED
@@ -6,11 +6,13 @@
6
6
  * m1kkit stats # src/ 기준 분석
7
7
  * m1kkit stats --dir=app # 특정 디렉토리 기준
8
8
  * m1kkit stats --out=public # 출력 위치 지정
9
+ * m1kkit stats --llm # (선택) Claude로 네이밍·응집도 자문 — 점수엔 미반영
9
10
  */
10
11
 
11
12
  import fs from "fs";
12
13
  import path from "path";
13
14
  import { createRequire } from "module";
15
+ import { execFileSync } from "child_process";
14
16
 
15
17
  const args = process.argv.slice(2);
16
18
  const getFlag = (name) => {
@@ -77,9 +79,9 @@ function collectFiles(dir, exts = [".ts", ".tsx", ".js", ".jsx"]) {
77
79
  for (const entry of entries) {
78
80
  const fullPath = path.join(dir, entry.name);
79
81
  if (entry.isDirectory()) {
80
- if (entry.name === "node_modules" || entry.name === ".next" || entry.name === "dist") continue;
82
+ if (entry.name === "node_modules" || entry.name === ".next" || entry.name === "dist" || entry.name.startsWith(".")) continue;
81
83
  results.push(...collectFiles(fullPath, exts));
82
- } else if (exts.some((ext) => entry.name.endsWith(ext))) {
84
+ } else if (exts.some((ext) => entry.name.endsWith(ext)) && !entry.name.endsWith(".d.ts")) {
83
85
  results.push(fullPath);
84
86
  }
85
87
  }
@@ -101,6 +103,260 @@ function countLines(content) {
101
103
  return { total, code };
102
104
  }
103
105
 
106
+ // 코드 청결도 분석 — 분기 밀도·파일 크기 기반 휴리스틱 (typescript 미설치 시 폴백)
107
+ // 주석/문자열 안까지 세는 러프한 근사지만, 프로젝트 간 상대 비교엔 충분
108
+ function analyzeQuality(content) {
109
+ const branchTokens = content.match(/\bif\s*\(|\belse\b|\bcase\s|\bcatch\s*[({]|\?\s*[^.:]|&&|\|\|/g);
110
+ const fnTokens = content.match(/\bfunction\b|=>/g);
111
+ return { branches: branchTokens?.length || 0, functions: fnTokens?.length || 0 };
112
+ }
113
+
114
+ // 프로젝트의 typescript 패키지 로드 (AST 기반 정밀 분석용)
115
+ function loadTypescript() {
116
+ try {
117
+ const require = createRequire(path.resolve(process.cwd(), "package.json"));
118
+ return require("typescript");
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ // AST 기반 함수별 복잡도 — cyclomatic(McCabe) + cognitive(SonarQube 근사)
125
+ // cognitive: 중첩 깊이 가중(+1+depth), 같은 논리 연산자 연쇄(a && b && c)는 1회만,
126
+ // ??는 카운트 제외(null 정규화는 복잡성이 아님). 중첩 함수는 별도 함수로 분리 집계
127
+ function analyzeAstComplexity(ts, filePath, content) {
128
+ const kind = /\.(tsx|jsx)$/.test(filePath) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
129
+ const sf = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, kind);
130
+
131
+ const isFnLike = (n) =>
132
+ ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n) || ts.isArrowFunction(n) ||
133
+ ts.isMethodDeclaration(n) || ts.isGetAccessor(n) || ts.isSetAccessor(n) || ts.isConstructorDeclaration(n);
134
+
135
+ const fnName = (n) => {
136
+ if (n.name) return n.name.getText(sf);
137
+ const p = n.parent;
138
+ if (p && ts.isVariableDeclaration(p)) return p.name.getText(sf);
139
+ if (p && ts.isPropertyAssignment(p)) return p.name.getText(sf);
140
+ return "(anonymous)";
141
+ };
142
+
143
+ const ccOf = (fn) => {
144
+ let cc = 1;
145
+ const walk = (n) => {
146
+ if (n !== fn && isFnLike(n)) return; // 중첩 함수는 자기 항목에서 계산
147
+ switch (n.kind) {
148
+ case ts.SyntaxKind.IfStatement:
149
+ case ts.SyntaxKind.ConditionalExpression:
150
+ case ts.SyntaxKind.CaseClause:
151
+ case ts.SyntaxKind.CatchClause:
152
+ case ts.SyntaxKind.ForStatement:
153
+ case ts.SyntaxKind.ForInStatement:
154
+ case ts.SyntaxKind.ForOfStatement:
155
+ case ts.SyntaxKind.WhileStatement:
156
+ case ts.SyntaxKind.DoStatement:
157
+ cc++;
158
+ break;
159
+ case ts.SyntaxKind.BinaryExpression: {
160
+ const op = n.operatorToken.kind;
161
+ if (
162
+ op === ts.SyntaxKind.AmpersandAmpersandToken ||
163
+ op === ts.SyntaxKind.BarBarToken ||
164
+ op === ts.SyntaxKind.QuestionQuestionToken
165
+ ) cc++;
166
+ break;
167
+ }
168
+ }
169
+ ts.forEachChild(n, walk);
170
+ };
171
+ ts.forEachChild(fn, walk);
172
+ return cc;
173
+ };
174
+
175
+ // cognitive complexity (SonarQube 근사)
176
+ // - 제어 구조: +1 + 현재 중첩 깊이, 내부는 깊이+1
177
+ // - 논리 연산자: 같은 연산자 연쇄당 1회 (&&→|| 전환 시 +1), ?? 제외
178
+ // - 삼항: +1+depth, else(if 아닌): +1
179
+ const cognitiveOf = (fn) => {
180
+ let score = 0;
181
+ const LOGICAL = new Set([ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken]);
182
+ const walk = (n, depth, parentLogicalOp) => {
183
+ if (n !== fn && isFnLike(n)) return; // 중첩 함수는 자기 항목에서 계산
184
+ switch (n.kind) {
185
+ case ts.SyntaxKind.IfStatement: {
186
+ score += 1 + depth;
187
+ walk(n.expression, depth, null);
188
+ walk(n.thenStatement, depth + 1, null);
189
+ if (n.elseStatement) {
190
+ if (n.elseStatement.kind === ts.SyntaxKind.IfStatement) {
191
+ // else if — if 쪽에서 +1+depth 처리되므로 여기선 그대로 위임 (깊이 유지)
192
+ walk(n.elseStatement, depth, null);
193
+ } else {
194
+ score += 1; // else 자체
195
+ walk(n.elseStatement, depth + 1, null);
196
+ }
197
+ }
198
+ return;
199
+ }
200
+ case ts.SyntaxKind.ForStatement:
201
+ case ts.SyntaxKind.ForInStatement:
202
+ case ts.SyntaxKind.ForOfStatement:
203
+ case ts.SyntaxKind.WhileStatement:
204
+ case ts.SyntaxKind.DoStatement:
205
+ case ts.SyntaxKind.CatchClause:
206
+ case ts.SyntaxKind.ConditionalExpression: {
207
+ score += 1 + depth;
208
+ ts.forEachChild(n, (c) => walk(c, depth + 1, null));
209
+ return;
210
+ }
211
+ case ts.SyntaxKind.SwitchStatement: {
212
+ score += 1 + depth; // switch 전체 1회 (case별 아님)
213
+ ts.forEachChild(n, (c) => walk(c, depth + 1, null));
214
+ return;
215
+ }
216
+ case ts.SyntaxKind.BinaryExpression: {
217
+ const op = n.operatorToken.kind;
218
+ if (LOGICAL.has(op)) {
219
+ if (op !== parentLogicalOp) score += 1; // 연쇄의 시작에서만
220
+ walk(n.left, depth, op);
221
+ walk(n.right, depth, op);
222
+ return;
223
+ }
224
+ break;
225
+ }
226
+ }
227
+ ts.forEachChild(n, (c) => walk(c, depth, null));
228
+ };
229
+ ts.forEachChild(fn, (c) => walk(c, 0, null));
230
+ return score;
231
+ };
232
+
233
+ const fns = [];
234
+ const collect = (n) => {
235
+ if (isFnLike(n) && (n.body || ts.isArrowFunction(n))) {
236
+ fns.push({
237
+ name: fnName(n),
238
+ cc: ccOf(n),
239
+ cog: cognitiveOf(n),
240
+ line: sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1,
241
+ });
242
+ }
243
+ ts.forEachChild(n, collect);
244
+ };
245
+ collect(sf);
246
+ return fns;
247
+ }
248
+
249
+ // 중복 코드 감지 — 토큰 정규화(식별자/리터럴 치환) + 슬라이딩 윈도우 해시 (jscpd 라이트)
250
+ // import 줄은 제외 (정규화하면 모든 import가 동일해져 가짜 중복 발생)
251
+ const DUP_WINDOW = 50; // 토큰 수 ≈ 코드 5~8줄
252
+
253
+ function tokenizeNormalized(ts, filePath, content) {
254
+ // import/export-from 줄 제거
255
+ const stripped = content
256
+ .split("\n")
257
+ .map((l) => (/^\s*(import\s|export\s+(\{|\*).*\sfrom\s)/.test(l) ? "" : l))
258
+ .join("\n");
259
+ const kind = /\.(tsx|jsx)$/.test(filePath) ? ts.LanguageVariant.JSX : ts.LanguageVariant.Standard;
260
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, /*skipTrivia*/ true, kind, stripped);
261
+ const tokens = [];
262
+ const lines = [];
263
+ let tok = scanner.scan();
264
+ while (tok !== ts.SyntaxKind.EndOfFileToken) {
265
+ let norm;
266
+ // 식별자만 정규화(이름 바꾼 복붙 검출), 리터럴은 원문 유지 —
267
+ // 데이터 테이블(좌표·팔레트 행)이 전부 동일해지는 오탐 방지
268
+ if (tok === ts.SyntaxKind.Identifier) norm = "I";
269
+ else if (tok === ts.SyntaxKind.JsxText) { tok = scanner.scan(); continue; } // 마크업 텍스트 제외
270
+ else if (tok === ts.SyntaxKind.StringLiteral || tok === ts.SyntaxKind.NoSubstitutionTemplateLiteral || tok === ts.SyntaxKind.NumericLiteral) norm = scanner.getTokenText();
271
+ else norm = String(tok);
272
+ tokens.push(norm);
273
+ // 토큰 시작 위치의 줄 번호 (원본과 줄 구조 동일)
274
+ const upto = stripped.slice(0, scanner.getTokenStart());
275
+ lines.push(upto.split("\n").length);
276
+ tok = scanner.scan();
277
+ }
278
+ return { tokens, lines };
279
+ }
280
+
281
+ function analyzeDuplication(ts, fileContents) {
282
+ // 테스트 파일은 제외 — 반복 구조(케이스 나열)가 본질이라 중복 밀도를 왜곡
283
+ const perFile = fileContents
284
+ .filter(({ file }) => !/\.(test|spec)\.[tj]sx?$/.test(file))
285
+ .map(({ file, content }) => ({
286
+ file,
287
+ ...tokenizeNormalized(ts, file, content),
288
+ }));
289
+
290
+ // 윈도우 해시 → 등장 위치 목록
291
+ const seen = new Map(); // hash → [{fi, idx}]
292
+ perFile.forEach((f, fi) => {
293
+ for (let i = 0; i + DUP_WINDOW <= f.tokens.length; i++) {
294
+ let h = 5381;
295
+ for (let k = 0; k < DUP_WINDOW; k++) {
296
+ const s = f.tokens[i + k];
297
+ for (let c = 0; c < s.length; c++) h = ((h * 33) ^ s.charCodeAt(c)) >>> 0;
298
+ }
299
+ const arr = seen.get(h);
300
+ if (arr) arr.push({ fi, idx: i });
301
+ else seen.set(h, [{ fi, idx: i }]);
302
+ }
303
+ });
304
+
305
+ // 2회 이상 등장한 윈도우가 덮는 토큰 마킹
306
+ const dupMask = perFile.map((f) => new Uint8Array(f.tokens.length));
307
+ const blockKeys = new Set(); // 대표 위치 수집용
308
+ const examples = new Map(); // hash → [위치 문자열]
309
+ for (const [h, locs] of seen) {
310
+ if (locs.length < 2) continue;
311
+ // 같은 파일 안 인접 중첩(자기 자신과 1토큰 시프트) 제외: 서로 다른 위치 그룹만
312
+ const distinct = locs.filter((a, i) => locs.findIndex((b) => b.fi === a.fi && Math.abs(b.idx - a.idx) < DUP_WINDOW) === i);
313
+ if (distinct.length < 2) continue;
314
+ for (const { fi, idx } of distinct) {
315
+ dupMask[fi].fill(1, idx, idx + DUP_WINDOW);
316
+ }
317
+ blockKeys.add(h);
318
+ if (examples.size < 200 && !examples.has(h)) {
319
+ examples.set(h, distinct.slice(0, 3).map(({ fi, idx }) => `${perFile[fi].file}:${perFile[fi].lines[idx]}`));
320
+ }
321
+ }
322
+
323
+ let dupTokens = 0;
324
+ let totalTokens = 0;
325
+ const byFile = new Map();
326
+ perFile.forEach((f, fi) => {
327
+ totalTokens += f.tokens.length;
328
+ let d = 0;
329
+ for (let i = 0; i < dupMask[fi].length; i++) d += dupMask[fi][i];
330
+ dupTokens += d;
331
+ if (d > 0) byFile.set(f.file, d);
332
+ });
333
+
334
+ const percent = totalTokens > 0 ? Math.round((dupTokens / totalTokens) * 1000) / 10 : 0;
335
+ const worstFiles = [...byFile.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3)
336
+ .map(([file, d]) => ({ file, dupTokens: d }));
337
+ // 대표 중복 블록 예시 (여러 위치에 나타나는 것 우선)
338
+ const worstBlocks = [...examples.values()].sort((a, b) => b.length - a.length).slice(0, 3);
339
+
340
+ return { percent, dupTokens, totalTokens, blocks: blockKeys.size, worstFiles, worstBlocks };
341
+ }
342
+
343
+ // 파일 분류 — frontend(UI) / backend(API·서버) / shared(공용 유틸)
344
+ function classifyFile(filePath, content) {
345
+ const rel = filePath.replace(/\\/g, "/");
346
+ const head = content.slice(0, 300);
347
+ if (
348
+ /\/(api|server)\//.test(rel) ||
349
+ /(^|\/)(route|middleware|instrumentation)\.(ts|js|mjs)$/.test(rel) ||
350
+ /^\s*["']use server["']/.test(head)
351
+ ) {
352
+ return "backend";
353
+ }
354
+ if (/\.(tsx|jsx)$/.test(rel) || /^\s*["']use client["']/.test(head)) {
355
+ return "frontend";
356
+ }
357
+ return "shared";
358
+ }
359
+
104
360
  // kit import 감지
105
361
  function detectKitImports(content) {
106
362
  const found = new Set();
@@ -130,17 +386,106 @@ if (files.length === 0) {
130
386
 
131
387
  let totalLines = 0;
132
388
  let codeLines = 0;
389
+ let totalBranches = 0;
390
+ let totalFunctions = 0;
391
+ let maxFile = { path: "", lines: 0 };
392
+ let longFiles = 0; // 200줄 초과 파일 수
133
393
  const allImports = new Set();
394
+ const ts = loadTypescript();
395
+ const allFns = []; // AST 모드: {name, cc, cog, line, file}
396
+ const fileContents = []; // 중복 감지용
397
+ const breakdown = {
398
+ frontend: { files: 0, codeLines: 0 },
399
+ backend: { files: 0, codeLines: 0 },
400
+ shared: { files: 0, codeLines: 0 },
401
+ };
134
402
 
135
403
  for (const file of files) {
136
404
  const content = fs.readFileSync(file, "utf-8");
137
405
  const counts = countLines(content);
138
406
  totalLines += counts.total;
139
407
  codeLines += counts.code;
408
+ const bucket = breakdown[classifyFile(file, content)];
409
+ bucket.files++;
410
+ bucket.codeLines += counts.code;
411
+ const q = analyzeQuality(content);
412
+ totalBranches += q.branches;
413
+ totalFunctions += q.functions;
414
+ if (ts) {
415
+ const rel = path.relative(process.cwd(), file);
416
+ for (const fn of analyzeAstComplexity(ts, file, content)) allFns.push({ ...fn, file: rel });
417
+ fileContents.push({ file: rel, content });
418
+ }
419
+ if (counts.code > maxFile.lines) maxFile = { path: path.relative(process.cwd(), file), lines: counts.code };
420
+ if (counts.code > 200) longFiles++;
140
421
  const imports = detectKitImports(content);
141
422
  for (const imp of imports) allImports.add(imp);
142
423
  }
143
424
 
425
+ const branchDensity = codeLines > 0 ? Math.round((totalBranches / codeLines) * 1000) / 10 : 0;
426
+ const avgFileLines = files.length > 0 ? Math.round(codeLines / files.length) : 0;
427
+
428
+ // 청결도 스코어 v2 (100점 만점)
429
+ // AST 모드: cognitive complexity(중첩 가중, SonarQube 함수당 15 권고) + 중복 밀도(SonarQube 3% 게이트)
430
+ // 프로젝트 크기 편향 없게 비율 기반 감점:
431
+ // - cog>15 함수 비율 ×3 (최대 25), cog>25 함수 비율 ×5 (최대 15)
432
+ // - 최악 함수: cog 20 초과분 ×1 (최대 15)
433
+ // - 중복 밀도: 3% 초과분 ×2.5 (최대 25) ← 새 축
434
+ // - 200줄 초과 파일 비율 ×2 (최대 10) / 평균 파일 길이 80줄 초과분 /5 (최대 10)
435
+ let qualityScore;
436
+ let cc = null;
437
+ let cognitive = null;
438
+ let duplication = null;
439
+ if (ts && allFns.length > 0) {
440
+ const byCc = [...allFns].sort((a, b) => b.cc - a.cc);
441
+ cc = {
442
+ functions: allFns.length,
443
+ avg: Math.round((allFns.reduce((s, f) => s + f.cc, 0) / allFns.length) * 10) / 10,
444
+ p90: byCc[Math.floor((byCc.length - 1) * 0.1)].cc,
445
+ max: byCc[0].cc,
446
+ over10: byCc.filter((f) => f.cc > 10).length,
447
+ over20: byCc.filter((f) => f.cc > 20).length,
448
+ worst: byCc.slice(0, 5).map(({ name, cc, file, line }) => ({ name, cc, file, line })),
449
+ };
450
+
451
+ const byCog = [...allFns].sort((a, b) => b.cog - a.cog);
452
+ const cogOver15 = byCog.filter((f) => f.cog > 15);
453
+ const cogOver25 = byCog.filter((f) => f.cog > 25);
454
+ const maxCog = byCog[0].cog;
455
+ cognitive = {
456
+ avg: Math.round((allFns.reduce((s, f) => s + f.cog, 0) / allFns.length) * 10) / 10,
457
+ p90: byCog[Math.floor((byCog.length - 1) * 0.1)].cog,
458
+ max: maxCog,
459
+ over15: cogOver15.length,
460
+ over25: cogOver25.length,
461
+ worst: byCog.slice(0, 5).map(({ name, cog, cc, file, line }) => ({ name, cog, cc, file, line })),
462
+ };
463
+
464
+ duplication = analyzeDuplication(ts, fileContents);
465
+
466
+ const over15Pct = (cogOver15.length / allFns.length) * 100;
467
+ const over25Pct = (cogOver25.length / allFns.length) * 100;
468
+ const longFilesPct = (longFiles / files.length) * 100;
469
+ qualityScore = Math.max(0, Math.round(
470
+ 100
471
+ - Math.min(25, over15Pct * 3)
472
+ - Math.min(15, over25Pct * 5)
473
+ - Math.min(15, Math.max(0, maxCog - 20))
474
+ - Math.min(25, Math.max(0, duplication.percent - 3) * 2.5)
475
+ - Math.min(10, longFilesPct * 2)
476
+ - Math.min(10, Math.max(0, avgFileLines - 80) / 5)
477
+ ));
478
+ } else {
479
+ // regex 폴백 (typescript 미설치)
480
+ qualityScore = Math.max(0, Math.round(
481
+ 100
482
+ - Math.min(40, Math.max(0, branchDensity - 10) * 2)
483
+ - Math.min(30, longFiles * 5)
484
+ - Math.min(30, Math.max(0, avgFileLines - 80) / 4)
485
+ ));
486
+ }
487
+ const qualityGrade = qualityScore >= 90 ? "A+" : qualityScore >= 80 ? "A" : qualityScore >= 70 ? "B" : qualityScore >= 60 ? "C" : "D";
488
+
144
489
  // 절약량 계산
145
490
  // 같은 소스 파일에서 여러 export를 쓰더라도 파일 LOC는 한 번만 카운트
146
491
  const usedFeatures = [];
@@ -183,6 +528,21 @@ const stats = {
183
528
  files: files.length,
184
529
  totalLines,
185
530
  codeLines,
531
+ breakdown, // frontend(UI) / backend(API·서버) / shared(공용) 별 files·codeLines
532
+ },
533
+ quality: {
534
+ engine: cc ? "ast2" : "regex", // ast2 = cognitive complexity + 중복 감지
535
+ score: qualityScore,
536
+ grade: qualityGrade,
537
+ cognitive, // {avg, p90, max, over15, over25, worst[5]} — 중첩 가중 복잡도
538
+ duplication, // {percent, blocks, worstFiles, worstBlocks} — 토큰 중복 밀도
539
+ cc, // {functions, avg, p90, max, over10, over20, worst[5]} — McCabe (참고용)
540
+ branchDensity, // 100줄당 분기 수 (regex 근사, 참고용)
541
+ branches: totalBranches,
542
+ functions: totalFunctions,
543
+ avgFileLines,
544
+ longFiles, // 200줄 초과 파일 수
545
+ maxFile,
186
546
  },
187
547
  kit: {
188
548
  features: usedFeatures.sort((a, b) => b.loc - a.loc),
@@ -201,10 +561,66 @@ fs.writeFileSync(outPath, JSON.stringify(stats, null, 2));
201
561
 
202
562
  console.log(` 파일: ${files.length}개`);
203
563
  console.log(` 코드: ${codeLines.toLocaleString()}줄 (전체 ${totalLines.toLocaleString()}줄)`);
564
+ console.log(` 프론트: ${breakdown.frontend.files}개 파일, ${breakdown.frontend.codeLines.toLocaleString()}줄`);
565
+ console.log(` 백엔드: ${breakdown.backend.files}개 파일, ${breakdown.backend.codeLines.toLocaleString()}줄`);
566
+ console.log(` 공용: ${breakdown.shared.files}개 파일, ${breakdown.shared.codeLines.toLocaleString()}줄`);
204
567
  console.log(` kit 사용: ${usedFeatures.length}개 요소`);
205
568
  console.log(` 컴포넌트: ${usage.component.used}/${usage.component.total}개 (${usage.component.percent}%)`);
206
569
  console.log(` 훅: ${usage.hook.used}/${usage.hook.total}개 (${usage.hook.percent}%)`);
207
570
  console.log(` 유틸리티: ${usage.util.used}/${usage.util.total}개 (${usage.util.percent}%)`);
208
571
  console.log(` 절약량: 약 ${savedLines.toLocaleString()}줄, ${estimatedKB}KB (A4 ${estimatedA4}장)`);
209
572
  console.log(` 비율: 전체의 약 ${savedPercent}%를 kit이 대신 처리`);
573
+ if (cc) {
574
+ console.log(` 청결도: ${qualityGrade} (${qualityScore}점) — 함수 ${cc.functions}개, cognitive 평균 ${cognitive.avg}·최대 ${cognitive.max}, cog15+ ${cognitive.over15}개·cog25+ ${cognitive.over25}개, 중복 ${duplication.percent}%, 200줄+ ${longFiles}개`);
575
+ for (const w of cognitive.worst.filter((f) => f.cog > 15)) {
576
+ console.log(` 복잡: ${w.name} cog ${w.cog} (CC ${w.cc}) — ${w.file}:${w.line}`);
577
+ }
578
+ if (duplication.percent > 3) {
579
+ console.log(` 중복 상위 파일: ${duplication.worstFiles.map((f) => `${f.file}(${f.dupTokens}tok)`).join(", ")}`);
580
+ for (const ex of duplication.worstBlocks) {
581
+ console.log(` 중복 블록: ${ex.join(" ≒ ")}`);
582
+ }
583
+ }
584
+ } else {
585
+ console.log(` 청결도: ${qualityGrade} (${qualityScore}점) — 분기밀도 ${branchDensity}/100줄, 평균 ${avgFileLines}줄/파일, 200줄+ ${longFiles}개 (regex 폴백 — typescript 설치 시 AST 정밀 분석)`);
586
+ }
587
+ // ── (선택) LLM 자문 — 정적 지표가 못 보는 네이밍·응집도. 점수엔 미반영, 자문만 ──
588
+ if (args.includes("--llm") && cognitive) {
589
+ try {
590
+ console.log(` LLM 자문 요청 중... (claude haiku)`);
591
+ // cognitive 최악 3개 함수 소스 발췌 (각 최대 60줄)
592
+ const snippets = cognitive.worst.slice(0, 3).map((w) => {
593
+ const abs = path.resolve(process.cwd(), w.file);
594
+ const src = fs.readFileSync(abs, "utf-8").split("\n");
595
+ const from = Math.max(0, w.line - 1);
596
+ return `// ${w.file}:${w.line} — ${w.name} (cognitive ${w.cog})\n` + src.slice(from, from + 60).join("\n");
597
+ }).join("\n\n---\n\n");
598
+
599
+ const prompt = `다음은 한 프로젝트에서 cognitive complexity가 가장 높은 함수들이다. 정적 지표로 못 보는 관점만 평가하라: 네이밍 명확성, 함수 응집도(한 가지 일만 하는가), 본질적 복잡성인지 정리 가능한 복잡성인지. 반드시 아래 JSON 한 줄로만 답하라:
600
+ {"naming": 0-100, "cohesion": 0-100, "essential": true|false, "advice": "한국어 한 문장"}
601
+
602
+ ${snippets}`;
603
+
604
+ const out = execFileSync("claude", ["-p", prompt, "--model", "haiku"], {
605
+ encoding: "utf-8",
606
+ timeout: 90_000,
607
+ maxBuffer: 1024 * 1024,
608
+ });
609
+ // 중첩 없는 JSON 오브젝트 후보들 중 파싱되는 첫 번째 사용
610
+ const candidates = out.match(/\{[^{}]*\}/g) || [];
611
+ let llm = null;
612
+ for (const c of candidates) {
613
+ try { llm = JSON.parse(c); break; } catch { /* 다음 후보 */ }
614
+ }
615
+ if (llm) {
616
+ stats.quality.llm = { model: "haiku", ...llm };
617
+ fs.writeFileSync(outPath, JSON.stringify(stats, null, 2));
618
+ console.log(` LLM 자문: 네이밍 ${llm.naming} · 응집도 ${llm.cohesion} · 본질적 복잡성 ${llm.essential ? "예" : "아니오"}`);
619
+ console.log(` → ${llm.advice}`);
620
+ }
621
+ } catch (e) {
622
+ console.log(` LLM 자문 실패 (claude CLI 필요): ${e.message?.slice(0, 60)}`);
623
+ }
624
+ }
625
+
210
626
  console.log(`\n 저장됨 → ${path.relative(process.cwd(), outPath)}\n`);