@m1kapp/kit 0.0.30 → 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점 만들기가 목적이 아니라 최악 지점 제거가 목적.
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`);