@m1kapp/kit 0.0.37 → 0.0.39

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/bin/stats.mjs CHANGED
@@ -1,956 +1,58 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * m1kkit stats — 프로젝트의 코드량과 kit 사용 현황을 분석해서 .kit-stats.json 생성
3
+ * m1kkit stats — 코드 분석을 fixearly 에 위임한다.
4
+ *
5
+ * 예전엔 이 파일이 분석 엔진 956줄을 직접 들고 있었다. 같은 엔진이 fixearly 로 분리되면서
6
+ * 두 벌이 각자 늙기 시작했다(kit 사본은 등급 A+~D 시절에 멈춰 있었다). 엔진은 한 벌만 둔다.
7
+ *
8
+ * 해석 순서:
9
+ * ① 프로젝트에 설치된 fixearly (node_modules)
10
+ * ② 없으면 npx 로 최신판 실행 (첫 실행만 네트워크)
11
+ *
12
+ * kit 사용 현황은 --kit 로 켜서 넘긴다 — m1kkit stats 의 존재 이유가 그 집계다.
4
13
  *
5
14
  * Usage:
6
- * m1kkit stats # src/ 기준 분석
7
- * m1kkit stats --dir=app # 특정 디렉토리 기준
8
- * m1kkit stats --out=public # 출력 위치 지정
9
- * m1kkit stats --llm # (선택) Claude로 네이밍·응집도 자문 — 점수엔 미반영
15
+ * m1kkit stats # src/ 기준 분석 + kit 사용 현황
16
+ * m1kkit stats --dir=app # 특정 디렉토리
17
+ * m1kkit stats --out=public # 출력 위치
18
+ * m1kkit stats --report # 한 장짜리 HTML 리포트
19
+ * m1kkit stats --hotspots # 복잡도 × 변경빈도 = 먼저 고칠 파일
10
20
  */
11
21
 
12
- import fs from "fs";
13
- import path from "path";
22
+ import { spawn } from "child_process";
14
23
  import { createRequire } from "module";
15
- import { execFileSync } from "child_process";
16
-
17
- const args = process.argv.slice(2);
18
- const getFlag = (name) => {
19
- const found = args.find((a) => a.startsWith(`--${name}=`));
20
- return found ? found.split("=")[1] : undefined;
21
- };
22
-
23
- const srcDir = path.resolve(process.cwd(), getFlag("dir") || "src");
24
- const outDir = path.resolve(process.cwd(), getFlag("out") || "public");
25
-
26
- // kit의 meta.json에서 실제 측정된 LOC 로드
27
- let KIT_FEATURES = {};
28
- let kitVersion = "unknown";
29
- let kitTotalFeatures = { component: 0, hook: 0, util: 0 };
30
-
31
- // meta.json 탐색: require.resolve → node_modules 직접 탐색 → 상위 디렉토리
32
- function findMeta() {
33
- // 1. require.resolve
34
- try {
35
- const require = createRequire(path.resolve(process.cwd(), "package.json"));
36
- return require.resolve("@m1kapp/kit/dist/meta.json");
37
- } catch {}
38
-
39
- // 2. node_modules에서 직접 탐색
40
- let dir = process.cwd();
41
- while (dir !== path.dirname(dir)) {
42
- const candidate = path.join(dir, "node_modules", "@m1kapp", "kit", "dist", "meta.json");
43
- if (fs.existsSync(candidate)) return candidate;
44
- dir = path.dirname(dir);
45
- }
46
-
47
- // 3. 이 스크립트가 kit 안에 있으면 형제 dist/ 탐색
48
- const scriptDir = path.dirname(new URL(import.meta.url).pathname);
49
- const siblingMeta = path.join(scriptDir, "..", "dist", "meta.json");
50
- if (fs.existsSync(siblingMeta)) return siblingMeta;
51
-
52
- return null;
53
- }
54
-
55
- const metaPath = findMeta();
56
- if (!metaPath) {
57
- console.error(" @m1kapp/kit/dist/meta.json을 찾을 수 없습니다.");
58
- console.error(" kit을 먼저 빌드하거나 npm install 후 다시 시도하세요.\n");
59
- process.exit(1);
60
- }
61
-
62
- const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
63
- KIT_FEATURES = meta.features;
64
- kitVersion = meta.version;
65
-
66
- // kit이 제공하는 전체 요소 수 카운트
67
- for (const f of Object.values(meta.features)) {
68
- kitTotalFeatures[f.category] = (kitTotalFeatures[f.category] || 0) + 1;
69
- }
70
-
71
- console.log(` meta.json 로드 완료 (v${kitVersion}, ${Object.keys(KIT_FEATURES).length}개 요소)\n`);
72
-
73
- // 소스 파일 수집
74
- function collectFiles(dir, exts = [".ts", ".tsx", ".js", ".jsx"]) {
75
- const results = [];
76
- if (!fs.existsSync(dir)) return results;
77
-
78
- const entries = fs.readdirSync(dir, { withFileTypes: true });
79
- for (const entry of entries) {
80
- const fullPath = path.join(dir, entry.name);
81
- if (entry.isDirectory()) {
82
- if (entry.name === "node_modules" || entry.name === ".next" || entry.name === "dist" || entry.name.startsWith(".")) continue;
83
- results.push(...collectFiles(fullPath, exts));
84
- } else if (exts.some((ext) => entry.name.endsWith(ext)) && !entry.name.endsWith(".d.ts")) {
85
- results.push(fullPath);
86
- }
87
- }
88
- return results;
89
- }
90
-
91
- // 줄 수 카운트 (빈 줄, 주석만 있는 줄 제외)
92
- function countLines(content) {
93
- const lines = content.split("\n");
94
- let total = 0;
95
- let code = 0;
96
- for (const line of lines) {
97
- total++;
98
- const trimmed = line.trim();
99
- if (trimmed && !trimmed.startsWith("//") && !trimmed.startsWith("*") && !trimmed.startsWith("/*")) {
100
- code++;
101
- }
102
- }
103
- return { total, code };
104
- }
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
- // 파일 I/O 밀도 — "한 번 처리하는 데 파일을 몇 번이나 읽게 되는 구조인가".
344
- // cognitive·중복은 코드 모양만 보므로, 5줄짜리 흠 없는 리더가 루프에서 120번 불리는
345
- // 종류는 원리상 못 잡는다(그래서 요청당 492회 읽던 코드가 A+ 100점을 통과했다).
346
- // 여기선 리더 함수를 찾고, 그게 루프 안에서 불리는 자리를 센다. 리더가 캐시를 끼고
347
- // 있으면 반복 호출돼도 실제 읽기는 한 번이라 감점하지 않고 참고로만 남긴다.
348
- const FILE_READ_CALLS = new Set(["readFileSync", "readFile", "readdirSync"]);
349
- const ITERATING_METHODS = new Set(["map", "forEach", "flatMap", "filter", "reduce", "some", "every", "find"]);
350
-
351
- function analyzeIoDensity(ts, fileContents) {
352
- const parsed = fileContents.map(({ file, content }) => ({
353
- file,
354
- sf: ts.createSourceFile(
355
- file,
356
- content,
357
- ts.ScriptTarget.Latest,
358
- true,
359
- /\.(tsx|jsx)$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
360
- ),
361
- }));
362
-
363
- const isFnLike = (n) =>
364
- ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n) || ts.isArrowFunction(n) ||
365
- ts.isMethodDeclaration(n);
366
-
367
- const nameOf = (sf, n) => {
368
- if (n.name) return n.name.getText(sf);
369
- const p = n.parent;
370
- if (p && ts.isVariableDeclaration(p)) return p.name.getText(sf);
371
- return null;
372
- };
373
-
374
- const lineOf = (sf, n) => sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1;
375
-
376
- // 1단계: 함수마다 "직접 파일을 읽는가 / 자기 캐시를 쓰는가 / 누구를 부르는가"를 모은다
377
- const fns = new Map(); // 이름 -> { file, line, readsDirectly, hasOwnCache, calls:Set }
378
- for (const { file, sf } of parsed) {
379
- const moduleHasMap = /new (Map|WeakMap)\s*[<(]/.test(sf.text);
380
- // 모듈 스코프 변수 이름 — "읽고 또 대입하는" 변수를 쓰면 그게 메모이제이션이다
381
- // (globalThis에 물린 캐시나 `let memo = null` 패턴. Map만 캐시로 보면 이런 걸 다 놓친다)
382
- const moduleVars = new Set();
383
- for (const stmt of sf.statements) {
384
- if (ts.isVariableStatement(stmt)) {
385
- for (const decl of stmt.declarationList.declarations) {
386
- if (ts.isIdentifier(decl.name)) moduleVars.add(decl.name.getText(sf));
387
- }
388
- }
389
- }
24
+ import path from "path";
390
25
 
391
- const collect = (node) => {
392
- if (isFnLike(node) && node.body) {
393
- let readsDirectly = false;
394
- let mapCacheOps = 0;
395
- const calls = new Set();
396
- const readVars = new Set();
397
- const writtenVars = new Set();
398
- const rootName = (expr) => {
399
- let cur = expr;
400
- while (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur)) cur = cur.expression;
401
- return ts.isIdentifier(cur) ? cur.getText(sf) : null;
402
- };
403
- const scan = (n) => {
404
- if (n !== node && isFnLike(n)) return; // 중첩 함수는 자기 항목에서 본다
405
- if (ts.isCallExpression(n)) {
406
- const callee = n.expression;
407
- const called = ts.isPropertyAccessExpression(callee) ? callee.name.getText(sf) : callee.getText(sf);
408
- if (FILE_READ_CALLS.has(called)) readsDirectly = true;
409
- if (called === "get" || called === "set") mapCacheOps++;
410
- calls.add(called);
411
- }
412
- if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
413
- const target = rootName(n.left);
414
- if (target && moduleVars.has(target)) writtenVars.add(target);
415
- }
416
- if (ts.isIdentifier(n) && moduleVars.has(n.getText(sf))) readVars.add(n.getText(sf));
417
- ts.forEachChild(n, scan);
418
- };
419
- ts.forEachChild(node, scan);
420
- const memoizesByVar = [...writtenVars].some((v) => readVars.has(v));
421
- const name = nameOf(sf, node);
422
- if (name && !fns.has(name)) {
423
- fns.set(name, {
424
- file,
425
- line: lineOf(sf, node),
426
- readsDirectly,
427
- // 캐시로 보는 두 형태: 모듈 Map을 get/set 하거나, 모듈 변수를 읽고 다시 대입하거나
428
- hasOwnCache: (moduleHasMap && mapCacheOps >= 2) || memoizesByVar,
429
- calls,
430
- });
431
- }
432
- }
433
- ts.forEachChild(node, collect);
434
- };
435
- ts.forEachChild(sf, collect);
436
- }
26
+ const require = createRequire(import.meta.url);
27
+ const passthrough = process.argv.slice(2);
28
+ const args = passthrough.includes("--kit") ? passthrough : [...passthrough, "--kit"];
437
29
 
438
- // 2단계: 호출 그래프로 "리더"를 전파한다 — getThemeComments → readJson → readFileSync 처럼
439
- // 직접 읽지 않고 한 다리 건너 읽는 함수가 실제 N+1의 주인공이라 이게 핵심이다.
440
- // 읽기 경로가 전부 캐시를 거치면 그 함수도 캐시된 리더로 본다(반복 호출돼도 읽기는 한 번).
441
- const readers = new Map(); // 이름 -> { file, line, cached }
442
- for (const [name, fn] of fns) {
443
- if (fn.readsDirectly) readers.set(name, { file: fn.file, line: fn.line, cached: fn.hasOwnCache });
444
- }
445
- const MAX_HOPS = 2; // 얇은 래퍼(getThemeComments → readJson)까지만. 더 깊으면 전부 리더가 된다
446
- for (let pass = 0; pass < MAX_HOPS; pass++) {
447
- let changed = false;
448
- for (const [name, fn] of fns) {
449
- if (readers.has(name)) continue;
450
- const calledReaders = [...fn.calls].map((c) => readers.get(c)).filter(Boolean);
451
- if (calledReaders.length === 0) continue;
452
- readers.set(name, {
453
- file: fn.file,
454
- line: fn.line,
455
- cached: fn.hasOwnCache || calledReaders.every((r) => r.cached),
456
- });
457
- changed = true;
30
+ /** node_modules 에 설치된 fixearly 진입점. 없으면 null. */
31
+ function resolveLocalEngine() {
32
+ for (const spec of ["fixearly/bin/fixearly.mjs", "fixearly"]) {
33
+ try {
34
+ const p = require.resolve(spec, { paths: [process.cwd()] });
35
+ return p.endsWith(".mjs") ? p : path.join(path.dirname(p), "bin", "fixearly.mjs");
36
+ } catch {
37
+ /* 다음 후보 */
458
38
  }
459
- if (!changed) break;
460
- }
461
-
462
- // 3단계: 그 리더(또는 fs 읽기 자체)가 루프 안에서 불리는 자리를 찾는다.
463
- // for/while뿐 아니라 map·forEach 같은 순회 콜백 안도 루프로 본다.
464
- const sites = [];
465
- for (const { file, sf } of parsed) {
466
- // 캐시를 직접 관리하는 함수 안에서의 읽기는 캐시 미스일 때만 나간다(2단 캐시의 디스크 티어 등)
467
- const walk = (node, inLoop, inCachingFn) => {
468
- let childInLoop = inLoop;
469
- let childInCachingFn = inCachingFn;
470
- if (isFnLike(node)) {
471
- const self = fns.get(nameOf(sf, node));
472
- if (self && self.hasOwnCache) childInCachingFn = true;
473
- }
474
- switch (node.kind) {
475
- case ts.SyntaxKind.ForStatement:
476
- case ts.SyntaxKind.ForInStatement:
477
- case ts.SyntaxKind.ForOfStatement:
478
- case ts.SyntaxKind.WhileStatement:
479
- case ts.SyntaxKind.DoStatement:
480
- childInLoop = true;
481
- break;
482
- }
483
- if (ts.isCallExpression(node)) {
484
- const callee = node.expression;
485
- const called = ts.isPropertyAccessExpression(callee) ? callee.name.getText(sf) : callee.getText(sf);
486
- const reader = readers.get(called);
487
- if (inLoop && (FILE_READ_CALLS.has(called) || reader)) {
488
- const cached = inCachingFn || (reader ? reader.cached : false);
489
- sites.push({ file, line: lineOf(sf, node), callee: called, cached });
490
- }
491
- // 순회 메서드에 넘긴 콜백 본문은 루프 안으로 취급
492
- if (ts.isPropertyAccessExpression(callee) && ITERATING_METHODS.has(callee.name.getText(sf))) {
493
- for (const arg of node.arguments) {
494
- if (isFnLike(arg)) walk(arg, true, childInCachingFn);
495
- }
496
- }
497
- }
498
- ts.forEachChild(node, (child) => {
499
- const alreadyWalked =
500
- ts.isCallExpression(node) &&
501
- ts.isPropertyAccessExpression(node.expression) &&
502
- ITERATING_METHODS.has(node.expression.name.getText(sf)) &&
503
- isFnLike(child);
504
- if (!alreadyWalked) walk(child, childInLoop, childInCachingFn);
505
- });
506
- };
507
- ts.forEachChild(sf, (n) => walk(n, false, false));
508
39
  }
509
-
510
- const uncached = sites.filter((s) => !s.cached);
511
- const readerList = [...readers.entries()].map(([name, r]) => ({ name, ...r }));
512
- return {
513
- readers: readerList.length,
514
- uncachedReaders: readerList.filter((r) => !r.cached).length,
515
- loopSites: sites.length,
516
- uncachedLoopSites: uncached.length,
517
- worst: uncached.slice(0, 5).map(({ file, line, callee }) => ({ file, line, callee })),
518
- };
519
- }
520
-
521
- // 렌더 인질 — 데이터 하나를 기다리느라 그와 무관한 UI까지 통째로 못 그리는 자리.
522
- // `{data && <Nav a={local} b={data.x} onChange={fn} />}` 처럼 프롭 대부분이 이미 아는
523
- // 값인데 fetch 하나 때문에 전체가 대기하면, 사용자에겐 매번 로딩으로 보인다.
524
- // 게이트에 걸린 요소가 그 데이터에 실제로 의존하는 비율로 판정한다.
525
- const HOSTAGE_DEP_RATIO = 0.5; // 프롭 절반도 안 쓰면서 전체를 막고 있으면 인질
526
-
527
- function analyzeRenderGates(ts, fileContents) {
528
- const findings = [];
529
-
530
- for (const { file, content } of fileContents) {
531
- if (!/\.(tsx|jsx)$/.test(file)) continue;
532
- const sf = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
533
-
534
- // 훅이 물어다 주는 값(로딩이 있는 값)을 찾는다: const { data: x } = useSomething()
535
- const fetched = new Set();
536
- const findHookVars = (n) => {
537
- if (
538
- ts.isVariableDeclaration(n) &&
539
- n.initializer &&
540
- ts.isCallExpression(n.initializer) &&
541
- /^use[A-Z]/.test(
542
- ts.isPropertyAccessExpression(n.initializer.expression)
543
- ? n.initializer.expression.name.getText(sf)
544
- : n.initializer.expression.getText(sf),
545
- ) &&
546
- ts.isObjectBindingPattern(n.name)
547
- ) {
548
- for (const el of n.name.elements) {
549
- const source = (el.propertyName || el.name).getText(sf);
550
- if (source === "data") fetched.add(el.name.getText(sf));
551
- }
552
- }
553
- ts.forEachChild(n, findHookVars);
554
- };
555
- ts.forEachChild(sf, findHookVars);
556
- if (fetched.size === 0) continue;
557
-
558
- const rootName = (expr) => {
559
- let cur = expr;
560
- while (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur)) cur = cur.expression;
561
- return ts.isIdentifier(cur) ? cur.getText(sf) : null;
562
- };
563
-
564
- /** 이 노드가 그 변수를 실제로 참조하는가 */
565
- const references = (node, name) => {
566
- let found = false;
567
- const scan = (n) => {
568
- if (found) return;
569
- if (ts.isIdentifier(n) && n.getText(sf) === name) found = true;
570
- else ts.forEachChild(n, scan);
571
- };
572
- scan(node);
573
- return found;
574
- };
575
-
576
- /** 게이트에 쓰인 fetch 변수들 */
577
- const gateVars = (expr) => {
578
- const names = new Set();
579
- const scan = (n) => {
580
- if (ts.isIdentifier(n) && fetched.has(n.getText(sf))) names.add(n.getText(sf));
581
- ts.forEachChild(n, scan);
582
- };
583
- scan(expr);
584
- return [...names];
585
- };
586
-
587
- const inspectGate = (testExpr, element) => {
588
- const vars = gateVars(testExpr);
589
- if (vars.length === 0) return;
590
- const el = ts.isJsxElement(element) ? element.openingElement : element;
591
- if (!el.attributes) return;
592
- const props = el.attributes.properties;
593
- if (props.length < 2) return; // 프롭이 거의 없으면 판단할 근거가 없다
594
-
595
- const dependent = props.filter((p) => vars.some((v) => references(p, v))).length;
596
- const ratio = dependent / props.length;
597
- if (ratio < HOSTAGE_DEP_RATIO) {
598
- findings.push({
599
- file,
600
- line: sf.getLineAndCharacterOfPosition(el.getStart(sf)).line + 1,
601
- element: el.tagName ? el.tagName.getText(sf) : "?",
602
- gate: vars.join(", "),
603
- dependentProps: dependent,
604
- totalProps: props.length,
605
- });
606
- }
607
- };
608
-
609
- const walk = (n) => {
610
- // {data && <El .../>}
611
- if (
612
- ts.isBinaryExpression(n) &&
613
- n.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken &&
614
- (ts.isJsxElement(n.right) || ts.isJsxSelfClosingElement(n.right))
615
- ) {
616
- inspectGate(n.left, n.right);
617
- }
618
- // {data ? <El .../> : null}
619
- if (ts.isConditionalExpression(n) && (ts.isJsxElement(n.whenTrue) || ts.isJsxSelfClosingElement(n.whenTrue))) {
620
- inspectGate(n.condition, n.whenTrue);
621
- }
622
- // 괄호로 감싼 JSX도 같은 취급
623
- if (
624
- ts.isBinaryExpression(n) &&
625
- n.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken &&
626
- ts.isParenthesizedExpression(n.right) &&
627
- (ts.isJsxElement(n.right.expression) || ts.isJsxSelfClosingElement(n.right.expression))
628
- ) {
629
- inspectGate(n.left, n.right.expression);
630
- }
631
- ts.forEachChild(n, walk);
632
- };
633
- ts.forEachChild(sf, walk);
634
- void rootName;
635
- }
636
-
637
- return {
638
- hostages: findings.length,
639
- worst: findings.slice(0, 5),
640
- };
641
- }
642
-
643
- // 파일 분류 — frontend(UI) / backend(API·서버) / shared(공용 유틸)
644
- function classifyFile(filePath, content) {
645
- const rel = filePath.replace(/\\/g, "/");
646
- const head = content.slice(0, 300);
647
- if (
648
- /\/(api|server)\//.test(rel) ||
649
- /(^|\/)(route|middleware|instrumentation)\.(ts|js|mjs)$/.test(rel) ||
650
- /^\s*["']use server["']/.test(head)
651
- ) {
652
- return "backend";
653
- }
654
- if (/\.(tsx|jsx)$/.test(rel) || /^\s*["']use client["']/.test(head)) {
655
- return "frontend";
656
- }
657
- return "shared";
40
+ return null;
658
41
  }
659
42
 
660
- // kit import 감지
661
- function detectKitImports(content) {
662
- const found = new Set();
663
- // import { X, Y } from "@m1kapp/kit" 또는 "@m1kapp/kit/..." 패턴
664
- const importRegex = /import\s*\{([^}]+)\}\s*from\s*["']@m1kapp\/kit(?:\/[^"']*)?["']/g;
665
- let match;
666
- while ((match = importRegex.exec(content)) !== null) {
667
- const names = match[1].split(",").map((s) => s.trim().split(" as ")[0].trim());
668
- for (const name of names) {
669
- if (name && !name.startsWith("type ")) {
670
- found.add(name);
671
- }
672
- }
673
- }
674
- // import type은 제외 — 타입만 쓰는 건 코드 절약 아님
675
- return found;
43
+ const local = resolveLocalEngine();
44
+ if (!local) {
45
+ console.log(" fixearly 가 설치돼 있지 않아 npx 로 받아 실행합니다 (npm i -D fixearly 하면 빨라져요)\n");
676
46
  }
677
47
 
678
- // 실행
679
- console.log(`\n 분석 중... ${srcDir}\n`);
48
+ const [cmd, cmdArgs] = local
49
+ ? [process.execPath, [local, ...args]]
50
+ : ["npx", ["-y", "fixearly@latest", ...args]];
680
51
 
681
- const files = collectFiles(srcDir);
682
- if (files.length === 0) {
683
- console.error(` 파일을 찾을 수 없습니다: ${srcDir}`);
52
+ const child = spawn(cmd, cmdArgs, { stdio: "inherit", shell: process.platform === "win32" });
53
+ child.on("exit", (code) => process.exit(code ?? 0));
54
+ child.on("error", (err) => {
55
+ console.error(` 분석 실행 실패: ${err.message}`);
56
+ console.error(" 수동 실행: npx fixearly --dir=src --kit");
684
57
  process.exit(1);
685
- }
686
-
687
- let totalLines = 0;
688
- let codeLines = 0;
689
- let totalBranches = 0;
690
- let totalFunctions = 0;
691
- let maxFile = { path: "", lines: 0 };
692
- let longFiles = 0; // 200줄 초과 파일 수
693
- let longFileSeverity = 0; // 200→400줄 0→1, 400→600줄 1→2로 완만하게 누적 (600줄+ 파일당 최대 2)
694
- const allImports = new Set();
695
- const ts = loadTypescript();
696
- const allFns = []; // AST 모드: {name, cc, cog, line, file}
697
- const fileContents = []; // 중복 감지용
698
- const breakdown = {
699
- frontend: { files: 0, codeLines: 0 },
700
- backend: { files: 0, codeLines: 0 },
701
- shared: { files: 0, codeLines: 0 },
702
- };
703
-
704
- for (const file of files) {
705
- const content = fs.readFileSync(file, "utf-8");
706
- const counts = countLines(content);
707
- totalLines += counts.total;
708
- codeLines += counts.code;
709
- const bucket = breakdown[classifyFile(file, content)];
710
- bucket.files++;
711
- bucket.codeLines += counts.code;
712
- const q = analyzeQuality(content);
713
- totalBranches += q.branches;
714
- totalFunctions += q.functions;
715
- if (ts) {
716
- const rel = path.relative(process.cwd(), file);
717
- for (const fn of analyzeAstComplexity(ts, file, content)) allFns.push({ ...fn, file: rel });
718
- fileContents.push({ file: rel, content });
719
- }
720
- if (counts.code > maxFile.lines) maxFile = { path: path.relative(process.cwd(), file), lines: counts.code };
721
- if (counts.code > 200) {
722
- longFiles++;
723
- longFileSeverity += Math.min(2, (counts.code - 200) / 200);
724
- }
725
- const imports = detectKitImports(content);
726
- for (const imp of imports) allImports.add(imp);
727
- }
728
-
729
- const branchDensity = codeLines > 0 ? Math.round((totalBranches / codeLines) * 1000) / 10 : 0;
730
- const avgFileLines = files.length > 0 ? Math.round(codeLines / files.length) : 0;
731
-
732
- // 청결도 스코어 v2 (100점 만점)
733
- // AST 모드: cognitive complexity(중첩 가중, SonarQube 함수당 15 권고) + 중복 밀도(SonarQube 3% 게이트)
734
- // 프로젝트 크기 편향 없게 비율 기반 감점:
735
- // - cog>15 함수 비율 ×3 (최대 25), cog>25 함수 비율 ×5 (최대 15)
736
- // - 최악 함수: cog 20 초과분 ×1 (최대 15)
737
- // - 중복 밀도: 3% 초과분 ×2.5 (최대 25) ← 새 축
738
- // - 200줄 초과 파일: 초과분에 비례한 심각도(200→400줄 0→1, 400→600줄 1→2, 600줄+ 캡) 비율 ×1.5 (최대 10)
739
- // ※ 파일 개수 기준 이진 카운트(200줄 넘으면 무조건 1) 대신 심각도 가중 — 살짝 넘는 파일과
740
- // 터무니없이 큰 파일을 구분하고, 파일 수 적은 프로젝트가 파일 1개만으로 즉시 만점 감점 맞는 절벽 방지.
741
- // / 평균 파일 길이 80줄 초과분 /5 (최대 10)
742
- // - 렌더 인질(fetch 하나가 무관한 UI까지 막는 자리) ×3 (최대 10)
743
- // - 루프 안 무캐시 파일읽기 사이트 ×4 (최대 20) ← 새 축. 코드 모양이 아무리 깔끔해도
744
- // 호출 1번이 파일 N번을 읽는 구조는 여기서만 드러난다(cognitive·중복으론 원리상 안 잡힘).
745
- let qualityScore;
746
- let cc = null;
747
- let cognitive = null;
748
- let duplication = null;
749
- let io = null;
750
- let renderGates = null;
751
- if (ts && allFns.length > 0) {
752
- const byCc = [...allFns].sort((a, b) => b.cc - a.cc);
753
- cc = {
754
- functions: allFns.length,
755
- avg: Math.round((allFns.reduce((s, f) => s + f.cc, 0) / allFns.length) * 10) / 10,
756
- p90: byCc[Math.floor((byCc.length - 1) * 0.1)].cc,
757
- max: byCc[0].cc,
758
- over10: byCc.filter((f) => f.cc > 10).length,
759
- over20: byCc.filter((f) => f.cc > 20).length,
760
- worst: byCc.slice(0, 5).map(({ name, cc, file, line }) => ({ name, cc, file, line })),
761
- };
762
-
763
- const byCog = [...allFns].sort((a, b) => b.cog - a.cog);
764
- const cogOver15 = byCog.filter((f) => f.cog > 15);
765
- const cogOver25 = byCog.filter((f) => f.cog > 25);
766
- const maxCog = byCog[0].cog;
767
- cognitive = {
768
- avg: Math.round((allFns.reduce((s, f) => s + f.cog, 0) / allFns.length) * 10) / 10,
769
- p90: byCog[Math.floor((byCog.length - 1) * 0.1)].cog,
770
- max: maxCog,
771
- over15: cogOver15.length,
772
- over25: cogOver25.length,
773
- worst: byCog.slice(0, 5).map(({ name, cog, cc, file, line }) => ({ name, cog, cc, file, line })),
774
- };
775
-
776
- duplication = analyzeDuplication(ts, fileContents);
777
- io = analyzeIoDensity(ts, fileContents);
778
- renderGates = analyzeRenderGates(ts, fileContents);
779
-
780
- const over15Pct = (cogOver15.length / allFns.length) * 100;
781
- const over25Pct = (cogOver25.length / allFns.length) * 100;
782
- const longFileSeverityPct = (longFileSeverity / files.length) * 100;
783
- qualityScore = Math.max(0, Math.round(
784
- 100
785
- - Math.min(25, over15Pct * 3)
786
- - Math.min(15, over25Pct * 5)
787
- - Math.min(15, Math.max(0, maxCog - 20))
788
- - Math.min(25, Math.max(0, duplication.percent - 3) * 2.5)
789
- - Math.min(10, longFileSeverityPct * 1.5)
790
- - Math.min(10, Math.max(0, avgFileLines - 80) / 5)
791
- - Math.min(20, io.uncachedLoopSites * 4)
792
- - Math.min(10, renderGates.hostages * 3)
793
- ));
794
- } else {
795
- // regex 폴백 (typescript 미설치)
796
- qualityScore = Math.max(0, Math.round(
797
- 100
798
- - Math.min(40, Math.max(0, branchDensity - 10) * 2)
799
- - Math.min(30, longFileSeverity * 2.5)
800
- - Math.min(30, Math.max(0, avgFileLines - 80) / 4)
801
- ));
802
- }
803
- const qualityGrade = qualityScore >= 90 ? "A+" : qualityScore >= 80 ? "A" : qualityScore >= 70 ? "B" : qualityScore >= 60 ? "C" : "D";
804
-
805
- // 절약량 계산
806
- // 같은 소스 파일에서 여러 export를 쓰더라도 파일 LOC는 한 번만 카운트
807
- const usedFeatures = [];
808
- let savedLines = 0;
809
- const usedByCategory = { component: 0, hook: 0, util: 0 };
810
- const countedSources = new Set();
811
-
812
- for (const name of allImports) {
813
- const meta = KIT_FEATURES[name];
814
- if (!meta) continue;
815
-
816
- // 카테고리별 사용 수 (loc 0이어도 카운트 — "Tab"도 사용한 거니까)
817
- usedByCategory[meta.category] = (usedByCategory[meta.category] || 0) + 1;
818
-
819
- // LOC 절약은 소스 파일 단위로 1번만. 대표(loc>0)만 카운트하므로 import
820
- // 순서와 무관 — 비대표(loc 0)가 먼저 와도 source를 선점하지 않는다.
821
- if (meta.loc > 0 && !(meta.source && countedSources.has(meta.source))) {
822
- if (meta.source) countedSources.add(meta.source);
823
- usedFeatures.push({ name, loc: meta.loc, category: meta.category });
824
- savedLines += meta.loc;
825
- }
826
- }
827
-
828
- const estimatedKB = Math.round(savedLines * 40 / 1024);
829
- const estimatedA4 = Math.round(savedLines / 80);
830
- const savedPercent = codeLines > 0 ? Math.round((savedLines / (codeLines + savedLines)) * 100) : 0;
831
-
832
- // 사용률: kit이 제공하는 전체 요소 중 몇 개를 쓰고 있는지
833
- const usage = {
834
- component: { used: usedByCategory.component, total: kitTotalFeatures.component, percent: kitTotalFeatures.component > 0 ? Math.round((usedByCategory.component / kitTotalFeatures.component) * 100) : 0 },
835
- hook: { used: usedByCategory.hook, total: kitTotalFeatures.hook, percent: kitTotalFeatures.hook > 0 ? Math.round((usedByCategory.hook / kitTotalFeatures.hook) * 100) : 0 },
836
- util: { used: usedByCategory.util, total: kitTotalFeatures.util, percent: kitTotalFeatures.util > 0 ? Math.round((usedByCategory.util / kitTotalFeatures.util) * 100) : 0 },
837
- };
838
-
839
- const stats = {
840
- generatedAt: new Date().toISOString(),
841
- kitVersion,
842
- source: {
843
- dir: path.relative(process.cwd(), srcDir),
844
- files: files.length,
845
- totalLines,
846
- codeLines,
847
- breakdown, // frontend(UI) / backend(API·서버) / shared(공용) 별 files·codeLines
848
- },
849
- quality: {
850
- engine: cc ? "ast2" : "regex", // ast2 = cognitive complexity + 중복 감지
851
- score: qualityScore,
852
- grade: qualityGrade,
853
- cognitive, // {avg, p90, max, over15, over25, worst[5]} — 중첩 가중 복잡도
854
- duplication, // {percent, blocks, worstFiles, worstBlocks} — 토큰 중복 밀도
855
- io, // {readers, uncachedReaders, loopSites, uncachedLoopSites, worst} — 루프 안 파일 읽기
856
- renderGates, // {hostages, worst} — fetch 하나가 무관한 UI까지 막고 있는 자리
857
- cc, // {functions, avg, p90, max, over10, over20, worst[5]} — McCabe (참고용)
858
- branchDensity, // 100줄당 분기 수 (regex 근사, 참고용)
859
- branches: totalBranches,
860
- functions: totalFunctions,
861
- avgFileLines,
862
- longFiles, // 200줄 초과 파일 수
863
- maxFile,
864
- },
865
- kit: {
866
- features: usedFeatures.sort((a, b) => b.loc - a.loc),
867
- savedLines,
868
- savedKB: estimatedKB,
869
- savedA4: estimatedA4,
870
- savedPercent,
871
- usage,
872
- },
873
- };
874
-
875
- // 출력
876
- if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
877
- const outPath = path.join(outDir, "kit-stats.json");
878
- fs.writeFileSync(outPath, JSON.stringify(stats, null, 2));
879
-
880
- console.log(` 파일: ${files.length}개`);
881
- console.log(` 코드: ${codeLines.toLocaleString()}줄 (전체 ${totalLines.toLocaleString()}줄)`);
882
- console.log(` 프론트: ${breakdown.frontend.files}개 파일, ${breakdown.frontend.codeLines.toLocaleString()}줄`);
883
- console.log(` 백엔드: ${breakdown.backend.files}개 파일, ${breakdown.backend.codeLines.toLocaleString()}줄`);
884
- console.log(` 공용: ${breakdown.shared.files}개 파일, ${breakdown.shared.codeLines.toLocaleString()}줄`);
885
- console.log(` kit 사용: ${usedFeatures.length}개 요소`);
886
- console.log(` 컴포넌트: ${usage.component.used}/${usage.component.total}개 (${usage.component.percent}%)`);
887
- console.log(` 훅: ${usage.hook.used}/${usage.hook.total}개 (${usage.hook.percent}%)`);
888
- console.log(` 유틸리티: ${usage.util.used}/${usage.util.total}개 (${usage.util.percent}%)`);
889
- console.log(` 절약량: 약 ${savedLines.toLocaleString()}줄, ${estimatedKB}KB (A4 ${estimatedA4}장)`);
890
- console.log(` 비율: 전체의 약 ${savedPercent}%를 kit이 대신 처리`);
891
- if (cc) {
892
- console.log(` 청결도: ${qualityGrade} (${qualityScore}점) — 함수 ${cc.functions}개, cognitive 평균 ${cognitive.avg}·최대 ${cognitive.max}, cog15+ ${cognitive.over15}개·cog25+ ${cognitive.over25}개, 중복 ${duplication.percent}%, 200줄+ ${longFiles}개`);
893
- for (const w of cognitive.worst.filter((f) => f.cog > 15)) {
894
- console.log(` 복잡: ${w.name} cog ${w.cog} (CC ${w.cc}) — ${w.file}:${w.line}`);
895
- }
896
- if (duplication.percent > 3) {
897
- console.log(` 중복 상위 파일: ${duplication.worstFiles.map((f) => `${f.file}(${f.dupTokens}tok)`).join(", ")}`);
898
- for (const ex of duplication.worstBlocks) {
899
- console.log(` 중복 블록: ${ex.join(" ≒ ")}`);
900
- }
901
- }
902
- if (renderGates.hostages > 0) {
903
- console.log(` 렌더 인질: ${renderGates.hostages}곳 — 데이터를 기다리느라 이미 아는 UI까지 못 그립니다`);
904
- for (const w of renderGates.worst) {
905
- console.log(` 인질: <${w.element}> ${w.gate} 대기 (의존 프롭 ${w.dependentProps}/${w.totalProps}) — ${w.file}:${w.line}`);
906
- }
907
- }
908
- if (io.uncachedLoopSites > 0) {
909
- console.log(` 루프 안 파일읽기: ${io.uncachedLoopSites}곳 (캐시 없는 리더 ${io.uncachedReaders}/${io.readers}개) — 호출 1번이 파일 N번 읽습니다`);
910
- for (const w of io.worst) {
911
- console.log(` 반복 읽기: ${w.callee}() — ${w.file}:${w.line}`);
912
- }
913
- }
914
- } else {
915
- console.log(` 청결도: ${qualityGrade} (${qualityScore}점) — 분기밀도 ${branchDensity}/100줄, 평균 ${avgFileLines}줄/파일, 200줄+ ${longFiles}개 (regex 폴백 — typescript 설치 시 AST 정밀 분석)`);
916
- }
917
- // ── (선택) LLM 자문 — 정적 지표가 못 보는 네이밍·응집도. 점수엔 미반영, 자문만 ──
918
- if (args.includes("--llm") && cognitive) {
919
- try {
920
- console.log(` LLM 자문 요청 중... (claude haiku)`);
921
- // cognitive 최악 3개 함수 소스 발췌 (각 최대 60줄)
922
- const snippets = cognitive.worst.slice(0, 3).map((w) => {
923
- const abs = path.resolve(process.cwd(), w.file);
924
- const src = fs.readFileSync(abs, "utf-8").split("\n");
925
- const from = Math.max(0, w.line - 1);
926
- return `// ${w.file}:${w.line} — ${w.name} (cognitive ${w.cog})\n` + src.slice(from, from + 60).join("\n");
927
- }).join("\n\n---\n\n");
928
-
929
- const prompt = `다음은 한 프로젝트에서 cognitive complexity가 가장 높은 함수들이다. 정적 지표로 못 보는 관점만 평가하라: 네이밍 명확성, 함수 응집도(한 가지 일만 하는가), 본질적 복잡성인지 정리 가능한 복잡성인지. 반드시 아래 JSON 한 줄로만 답하라:
930
- {"naming": 0-100, "cohesion": 0-100, "essential": true|false, "advice": "한국어 한 문장"}
931
-
932
- ${snippets}`;
933
-
934
- const out = execFileSync("claude", ["-p", prompt, "--model", "haiku"], {
935
- encoding: "utf-8",
936
- timeout: 90_000,
937
- maxBuffer: 1024 * 1024,
938
- });
939
- // 중첩 없는 JSON 오브젝트 후보들 중 파싱되는 첫 번째 사용
940
- const candidates = out.match(/\{[^{}]*\}/g) || [];
941
- let llm = null;
942
- for (const c of candidates) {
943
- try { llm = JSON.parse(c); break; } catch { /* 다음 후보 */ }
944
- }
945
- if (llm) {
946
- stats.quality.llm = { model: "haiku", ...llm };
947
- fs.writeFileSync(outPath, JSON.stringify(stats, null, 2));
948
- console.log(` LLM 자문: 네이밍 ${llm.naming} · 응집도 ${llm.cohesion} · 본질적 복잡성 ${llm.essential ? "예" : "아니오"}`);
949
- console.log(` → ${llm.advice}`);
950
- }
951
- } catch (e) {
952
- console.log(` LLM 자문 실패 (claude CLI 필요): ${e.message?.slice(0, 60)}`);
953
- }
954
- }
955
-
956
- console.log(`\n 저장됨 → ${path.relative(process.cwd(), outPath)}\n`);
58
+ });