@m1kapp/kit 0.0.36 → 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/lib/project.mjs +130 -0
- package/bin/m1kkit.mjs +11 -2
- package/bin/new.mjs +162 -0
- package/bin/skills/m1kapp-clean.md +131 -36
- package/bin/skills/m1kapp-new.md +73 -0
- package/bin/skills.mjs +5 -1
- package/bin/stats.mjs +41 -616
- package/bin/track.mjs +55 -6
- package/bin/untrack.mjs +139 -0
- package/dist/index.d.mts +38 -9
- package/dist/index.d.ts +38 -9
- package/dist/index.js +4 -4
- package/dist/index.mjs +4 -4
- package/dist/meta.json +6 -6
- package/dist/pwa.js +2 -2
- package/dist/pwa.mjs +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -3
- package/templates/vite-app/_gitignore +5 -0
- package/templates/vite-app/_package.json +25 -0
- package/templates/vite-app/index.html +17 -0
- package/templates/vite-app/src/App.tsx +136 -0
- package/templates/vite-app/src/index.css +22 -0
- package/templates/vite-app/src/main.tsx +10 -0
- package/templates/vite-app/src/vite-env.d.ts +10 -0
- package/templates/vite-app/tsconfig.json +21 -0
- package/templates/vite-app/vite.config.ts +7 -0
package/bin/stats.mjs
CHANGED
|
@@ -1,633 +1,58 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* m1kkit stats —
|
|
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 --
|
|
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
|
|
13
|
-
import path from "path";
|
|
22
|
+
import { spawn } from "child_process";
|
|
14
23
|
import { createRequire } from "module";
|
|
15
|
-
import
|
|
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
|
-
}));
|
|
24
|
+
import path from "path";
|
|
289
25
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
});
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const passthrough = process.argv.slice(2);
|
|
28
|
+
const args = passthrough.includes("--kit") ? passthrough : [...passthrough, "--kit"];
|
|
304
29
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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]}`));
|
|
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
|
+
/* 다음 후보 */
|
|
320
38
|
}
|
|
321
39
|
}
|
|
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";
|
|
40
|
+
return null;
|
|
358
41
|
}
|
|
359
42
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
// import { X, Y } from "@m1kapp/kit" 또는 "@m1kapp/kit/..." 패턴
|
|
364
|
-
const importRegex = /import\s*\{([^}]+)\}\s*from\s*["']@m1kapp\/kit(?:\/[^"']*)?["']/g;
|
|
365
|
-
let match;
|
|
366
|
-
while ((match = importRegex.exec(content)) !== null) {
|
|
367
|
-
const names = match[1].split(",").map((s) => s.trim().split(" as ")[0].trim());
|
|
368
|
-
for (const name of names) {
|
|
369
|
-
if (name && !name.startsWith("type ")) {
|
|
370
|
-
found.add(name);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
// import type은 제외 — 타입만 쓰는 건 코드 절약 아님
|
|
375
|
-
return found;
|
|
43
|
+
const local = resolveLocalEngine();
|
|
44
|
+
if (!local) {
|
|
45
|
+
console.log(" fixearly 가 설치돼 있지 않아 npx 로 받아 실행합니다 (npm i -D fixearly 하면 빨라져요)\n");
|
|
376
46
|
}
|
|
377
47
|
|
|
378
|
-
|
|
379
|
-
|
|
48
|
+
const [cmd, cmdArgs] = local
|
|
49
|
+
? [process.execPath, [local, ...args]]
|
|
50
|
+
: ["npx", ["-y", "fixearly@latest", ...args]];
|
|
380
51
|
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
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");
|
|
384
57
|
process.exit(1);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
let totalLines = 0;
|
|
388
|
-
let codeLines = 0;
|
|
389
|
-
let totalBranches = 0;
|
|
390
|
-
let totalFunctions = 0;
|
|
391
|
-
let maxFile = { path: "", lines: 0 };
|
|
392
|
-
let longFiles = 0; // 200줄 초과 파일 수
|
|
393
|
-
let longFileSeverity = 0; // 200→400줄 0→1, 400→600줄 1→2로 완만하게 누적 (600줄+ 파일당 최대 2)
|
|
394
|
-
const allImports = new Set();
|
|
395
|
-
const ts = loadTypescript();
|
|
396
|
-
const allFns = []; // AST 모드: {name, cc, cog, line, file}
|
|
397
|
-
const fileContents = []; // 중복 감지용
|
|
398
|
-
const breakdown = {
|
|
399
|
-
frontend: { files: 0, codeLines: 0 },
|
|
400
|
-
backend: { files: 0, codeLines: 0 },
|
|
401
|
-
shared: { files: 0, codeLines: 0 },
|
|
402
|
-
};
|
|
403
|
-
|
|
404
|
-
for (const file of files) {
|
|
405
|
-
const content = fs.readFileSync(file, "utf-8");
|
|
406
|
-
const counts = countLines(content);
|
|
407
|
-
totalLines += counts.total;
|
|
408
|
-
codeLines += counts.code;
|
|
409
|
-
const bucket = breakdown[classifyFile(file, content)];
|
|
410
|
-
bucket.files++;
|
|
411
|
-
bucket.codeLines += counts.code;
|
|
412
|
-
const q = analyzeQuality(content);
|
|
413
|
-
totalBranches += q.branches;
|
|
414
|
-
totalFunctions += q.functions;
|
|
415
|
-
if (ts) {
|
|
416
|
-
const rel = path.relative(process.cwd(), file);
|
|
417
|
-
for (const fn of analyzeAstComplexity(ts, file, content)) allFns.push({ ...fn, file: rel });
|
|
418
|
-
fileContents.push({ file: rel, content });
|
|
419
|
-
}
|
|
420
|
-
if (counts.code > maxFile.lines) maxFile = { path: path.relative(process.cwd(), file), lines: counts.code };
|
|
421
|
-
if (counts.code > 200) {
|
|
422
|
-
longFiles++;
|
|
423
|
-
longFileSeverity += Math.min(2, (counts.code - 200) / 200);
|
|
424
|
-
}
|
|
425
|
-
const imports = detectKitImports(content);
|
|
426
|
-
for (const imp of imports) allImports.add(imp);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
const branchDensity = codeLines > 0 ? Math.round((totalBranches / codeLines) * 1000) / 10 : 0;
|
|
430
|
-
const avgFileLines = files.length > 0 ? Math.round(codeLines / files.length) : 0;
|
|
431
|
-
|
|
432
|
-
// 청결도 스코어 v2 (100점 만점)
|
|
433
|
-
// AST 모드: cognitive complexity(중첩 가중, SonarQube 함수당 15 권고) + 중복 밀도(SonarQube 3% 게이트)
|
|
434
|
-
// 프로젝트 크기 편향 없게 비율 기반 감점:
|
|
435
|
-
// - cog>15 함수 비율 ×3 (최대 25), cog>25 함수 비율 ×5 (최대 15)
|
|
436
|
-
// - 최악 함수: cog 20 초과분 ×1 (최대 15)
|
|
437
|
-
// - 중복 밀도: 3% 초과분 ×2.5 (최대 25) ← 새 축
|
|
438
|
-
// - 200줄 초과 파일: 초과분에 비례한 심각도(200→400줄 0→1, 400→600줄 1→2, 600줄+ 캡) 비율 ×1.5 (최대 10)
|
|
439
|
-
// ※ 파일 개수 기준 이진 카운트(200줄 넘으면 무조건 1) 대신 심각도 가중 — 살짝 넘는 파일과
|
|
440
|
-
// 터무니없이 큰 파일을 구분하고, 파일 수 적은 프로젝트가 파일 1개만으로 즉시 만점 감점 맞는 절벽 방지.
|
|
441
|
-
// / 평균 파일 길이 80줄 초과분 /5 (최대 10)
|
|
442
|
-
let qualityScore;
|
|
443
|
-
let cc = null;
|
|
444
|
-
let cognitive = null;
|
|
445
|
-
let duplication = null;
|
|
446
|
-
if (ts && allFns.length > 0) {
|
|
447
|
-
const byCc = [...allFns].sort((a, b) => b.cc - a.cc);
|
|
448
|
-
cc = {
|
|
449
|
-
functions: allFns.length,
|
|
450
|
-
avg: Math.round((allFns.reduce((s, f) => s + f.cc, 0) / allFns.length) * 10) / 10,
|
|
451
|
-
p90: byCc[Math.floor((byCc.length - 1) * 0.1)].cc,
|
|
452
|
-
max: byCc[0].cc,
|
|
453
|
-
over10: byCc.filter((f) => f.cc > 10).length,
|
|
454
|
-
over20: byCc.filter((f) => f.cc > 20).length,
|
|
455
|
-
worst: byCc.slice(0, 5).map(({ name, cc, file, line }) => ({ name, cc, file, line })),
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
const byCog = [...allFns].sort((a, b) => b.cog - a.cog);
|
|
459
|
-
const cogOver15 = byCog.filter((f) => f.cog > 15);
|
|
460
|
-
const cogOver25 = byCog.filter((f) => f.cog > 25);
|
|
461
|
-
const maxCog = byCog[0].cog;
|
|
462
|
-
cognitive = {
|
|
463
|
-
avg: Math.round((allFns.reduce((s, f) => s + f.cog, 0) / allFns.length) * 10) / 10,
|
|
464
|
-
p90: byCog[Math.floor((byCog.length - 1) * 0.1)].cog,
|
|
465
|
-
max: maxCog,
|
|
466
|
-
over15: cogOver15.length,
|
|
467
|
-
over25: cogOver25.length,
|
|
468
|
-
worst: byCog.slice(0, 5).map(({ name, cog, cc, file, line }) => ({ name, cog, cc, file, line })),
|
|
469
|
-
};
|
|
470
|
-
|
|
471
|
-
duplication = analyzeDuplication(ts, fileContents);
|
|
472
|
-
|
|
473
|
-
const over15Pct = (cogOver15.length / allFns.length) * 100;
|
|
474
|
-
const over25Pct = (cogOver25.length / allFns.length) * 100;
|
|
475
|
-
const longFileSeverityPct = (longFileSeverity / files.length) * 100;
|
|
476
|
-
qualityScore = Math.max(0, Math.round(
|
|
477
|
-
100
|
|
478
|
-
- Math.min(25, over15Pct * 3)
|
|
479
|
-
- Math.min(15, over25Pct * 5)
|
|
480
|
-
- Math.min(15, Math.max(0, maxCog - 20))
|
|
481
|
-
- Math.min(25, Math.max(0, duplication.percent - 3) * 2.5)
|
|
482
|
-
- Math.min(10, longFileSeverityPct * 1.5)
|
|
483
|
-
- Math.min(10, Math.max(0, avgFileLines - 80) / 5)
|
|
484
|
-
));
|
|
485
|
-
} else {
|
|
486
|
-
// regex 폴백 (typescript 미설치)
|
|
487
|
-
qualityScore = Math.max(0, Math.round(
|
|
488
|
-
100
|
|
489
|
-
- Math.min(40, Math.max(0, branchDensity - 10) * 2)
|
|
490
|
-
- Math.min(30, longFileSeverity * 2.5)
|
|
491
|
-
- Math.min(30, Math.max(0, avgFileLines - 80) / 4)
|
|
492
|
-
));
|
|
493
|
-
}
|
|
494
|
-
const qualityGrade = qualityScore >= 90 ? "A+" : qualityScore >= 80 ? "A" : qualityScore >= 70 ? "B" : qualityScore >= 60 ? "C" : "D";
|
|
495
|
-
|
|
496
|
-
// 절약량 계산
|
|
497
|
-
// 같은 소스 파일에서 여러 export를 쓰더라도 파일 LOC는 한 번만 카운트
|
|
498
|
-
const usedFeatures = [];
|
|
499
|
-
let savedLines = 0;
|
|
500
|
-
const usedByCategory = { component: 0, hook: 0, util: 0 };
|
|
501
|
-
const countedSources = new Set();
|
|
502
|
-
|
|
503
|
-
for (const name of allImports) {
|
|
504
|
-
const meta = KIT_FEATURES[name];
|
|
505
|
-
if (!meta) continue;
|
|
506
|
-
|
|
507
|
-
// 카테고리별 사용 수 (loc 0이어도 카운트 — "Tab"도 사용한 거니까)
|
|
508
|
-
usedByCategory[meta.category] = (usedByCategory[meta.category] || 0) + 1;
|
|
509
|
-
|
|
510
|
-
// LOC 절약은 소스 파일 단위로 1번만. 대표(loc>0)만 카운트하므로 import
|
|
511
|
-
// 순서와 무관 — 비대표(loc 0)가 먼저 와도 source를 선점하지 않는다.
|
|
512
|
-
if (meta.loc > 0 && !(meta.source && countedSources.has(meta.source))) {
|
|
513
|
-
if (meta.source) countedSources.add(meta.source);
|
|
514
|
-
usedFeatures.push({ name, loc: meta.loc, category: meta.category });
|
|
515
|
-
savedLines += meta.loc;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const estimatedKB = Math.round(savedLines * 40 / 1024);
|
|
520
|
-
const estimatedA4 = Math.round(savedLines / 80);
|
|
521
|
-
const savedPercent = codeLines > 0 ? Math.round((savedLines / (codeLines + savedLines)) * 100) : 0;
|
|
522
|
-
|
|
523
|
-
// 사용률: kit이 제공하는 전체 요소 중 몇 개를 쓰고 있는지
|
|
524
|
-
const usage = {
|
|
525
|
-
component: { used: usedByCategory.component, total: kitTotalFeatures.component, percent: kitTotalFeatures.component > 0 ? Math.round((usedByCategory.component / kitTotalFeatures.component) * 100) : 0 },
|
|
526
|
-
hook: { used: usedByCategory.hook, total: kitTotalFeatures.hook, percent: kitTotalFeatures.hook > 0 ? Math.round((usedByCategory.hook / kitTotalFeatures.hook) * 100) : 0 },
|
|
527
|
-
util: { used: usedByCategory.util, total: kitTotalFeatures.util, percent: kitTotalFeatures.util > 0 ? Math.round((usedByCategory.util / kitTotalFeatures.util) * 100) : 0 },
|
|
528
|
-
};
|
|
529
|
-
|
|
530
|
-
const stats = {
|
|
531
|
-
generatedAt: new Date().toISOString(),
|
|
532
|
-
kitVersion,
|
|
533
|
-
source: {
|
|
534
|
-
dir: path.relative(process.cwd(), srcDir),
|
|
535
|
-
files: files.length,
|
|
536
|
-
totalLines,
|
|
537
|
-
codeLines,
|
|
538
|
-
breakdown, // frontend(UI) / backend(API·서버) / shared(공용) 별 files·codeLines
|
|
539
|
-
},
|
|
540
|
-
quality: {
|
|
541
|
-
engine: cc ? "ast2" : "regex", // ast2 = cognitive complexity + 중복 감지
|
|
542
|
-
score: qualityScore,
|
|
543
|
-
grade: qualityGrade,
|
|
544
|
-
cognitive, // {avg, p90, max, over15, over25, worst[5]} — 중첩 가중 복잡도
|
|
545
|
-
duplication, // {percent, blocks, worstFiles, worstBlocks} — 토큰 중복 밀도
|
|
546
|
-
cc, // {functions, avg, p90, max, over10, over20, worst[5]} — McCabe (참고용)
|
|
547
|
-
branchDensity, // 100줄당 분기 수 (regex 근사, 참고용)
|
|
548
|
-
branches: totalBranches,
|
|
549
|
-
functions: totalFunctions,
|
|
550
|
-
avgFileLines,
|
|
551
|
-
longFiles, // 200줄 초과 파일 수
|
|
552
|
-
maxFile,
|
|
553
|
-
},
|
|
554
|
-
kit: {
|
|
555
|
-
features: usedFeatures.sort((a, b) => b.loc - a.loc),
|
|
556
|
-
savedLines,
|
|
557
|
-
savedKB: estimatedKB,
|
|
558
|
-
savedA4: estimatedA4,
|
|
559
|
-
savedPercent,
|
|
560
|
-
usage,
|
|
561
|
-
},
|
|
562
|
-
};
|
|
563
|
-
|
|
564
|
-
// 출력
|
|
565
|
-
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
566
|
-
const outPath = path.join(outDir, "kit-stats.json");
|
|
567
|
-
fs.writeFileSync(outPath, JSON.stringify(stats, null, 2));
|
|
568
|
-
|
|
569
|
-
console.log(` 파일: ${files.length}개`);
|
|
570
|
-
console.log(` 코드: ${codeLines.toLocaleString()}줄 (전체 ${totalLines.toLocaleString()}줄)`);
|
|
571
|
-
console.log(` 프론트: ${breakdown.frontend.files}개 파일, ${breakdown.frontend.codeLines.toLocaleString()}줄`);
|
|
572
|
-
console.log(` 백엔드: ${breakdown.backend.files}개 파일, ${breakdown.backend.codeLines.toLocaleString()}줄`);
|
|
573
|
-
console.log(` 공용: ${breakdown.shared.files}개 파일, ${breakdown.shared.codeLines.toLocaleString()}줄`);
|
|
574
|
-
console.log(` kit 사용: ${usedFeatures.length}개 요소`);
|
|
575
|
-
console.log(` 컴포넌트: ${usage.component.used}/${usage.component.total}개 (${usage.component.percent}%)`);
|
|
576
|
-
console.log(` 훅: ${usage.hook.used}/${usage.hook.total}개 (${usage.hook.percent}%)`);
|
|
577
|
-
console.log(` 유틸리티: ${usage.util.used}/${usage.util.total}개 (${usage.util.percent}%)`);
|
|
578
|
-
console.log(` 절약량: 약 ${savedLines.toLocaleString()}줄, ${estimatedKB}KB (A4 ${estimatedA4}장)`);
|
|
579
|
-
console.log(` 비율: 전체의 약 ${savedPercent}%를 kit이 대신 처리`);
|
|
580
|
-
if (cc) {
|
|
581
|
-
console.log(` 청결도: ${qualityGrade} (${qualityScore}점) — 함수 ${cc.functions}개, cognitive 평균 ${cognitive.avg}·최대 ${cognitive.max}, cog15+ ${cognitive.over15}개·cog25+ ${cognitive.over25}개, 중복 ${duplication.percent}%, 200줄+ ${longFiles}개`);
|
|
582
|
-
for (const w of cognitive.worst.filter((f) => f.cog > 15)) {
|
|
583
|
-
console.log(` 복잡: ${w.name} cog ${w.cog} (CC ${w.cc}) — ${w.file}:${w.line}`);
|
|
584
|
-
}
|
|
585
|
-
if (duplication.percent > 3) {
|
|
586
|
-
console.log(` 중복 상위 파일: ${duplication.worstFiles.map((f) => `${f.file}(${f.dupTokens}tok)`).join(", ")}`);
|
|
587
|
-
for (const ex of duplication.worstBlocks) {
|
|
588
|
-
console.log(` 중복 블록: ${ex.join(" ≒ ")}`);
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
} else {
|
|
592
|
-
console.log(` 청결도: ${qualityGrade} (${qualityScore}점) — 분기밀도 ${branchDensity}/100줄, 평균 ${avgFileLines}줄/파일, 200줄+ ${longFiles}개 (regex 폴백 — typescript 설치 시 AST 정밀 분석)`);
|
|
593
|
-
}
|
|
594
|
-
// ── (선택) LLM 자문 — 정적 지표가 못 보는 네이밍·응집도. 점수엔 미반영, 자문만 ──
|
|
595
|
-
if (args.includes("--llm") && cognitive) {
|
|
596
|
-
try {
|
|
597
|
-
console.log(` LLM 자문 요청 중... (claude haiku)`);
|
|
598
|
-
// cognitive 최악 3개 함수 소스 발췌 (각 최대 60줄)
|
|
599
|
-
const snippets = cognitive.worst.slice(0, 3).map((w) => {
|
|
600
|
-
const abs = path.resolve(process.cwd(), w.file);
|
|
601
|
-
const src = fs.readFileSync(abs, "utf-8").split("\n");
|
|
602
|
-
const from = Math.max(0, w.line - 1);
|
|
603
|
-
return `// ${w.file}:${w.line} — ${w.name} (cognitive ${w.cog})\n` + src.slice(from, from + 60).join("\n");
|
|
604
|
-
}).join("\n\n---\n\n");
|
|
605
|
-
|
|
606
|
-
const prompt = `다음은 한 프로젝트에서 cognitive complexity가 가장 높은 함수들이다. 정적 지표로 못 보는 관점만 평가하라: 네이밍 명확성, 함수 응집도(한 가지 일만 하는가), 본질적 복잡성인지 정리 가능한 복잡성인지. 반드시 아래 JSON 한 줄로만 답하라:
|
|
607
|
-
{"naming": 0-100, "cohesion": 0-100, "essential": true|false, "advice": "한국어 한 문장"}
|
|
608
|
-
|
|
609
|
-
${snippets}`;
|
|
610
|
-
|
|
611
|
-
const out = execFileSync("claude", ["-p", prompt, "--model", "haiku"], {
|
|
612
|
-
encoding: "utf-8",
|
|
613
|
-
timeout: 90_000,
|
|
614
|
-
maxBuffer: 1024 * 1024,
|
|
615
|
-
});
|
|
616
|
-
// 중첩 없는 JSON 오브젝트 후보들 중 파싱되는 첫 번째 사용
|
|
617
|
-
const candidates = out.match(/\{[^{}]*\}/g) || [];
|
|
618
|
-
let llm = null;
|
|
619
|
-
for (const c of candidates) {
|
|
620
|
-
try { llm = JSON.parse(c); break; } catch { /* 다음 후보 */ }
|
|
621
|
-
}
|
|
622
|
-
if (llm) {
|
|
623
|
-
stats.quality.llm = { model: "haiku", ...llm };
|
|
624
|
-
fs.writeFileSync(outPath, JSON.stringify(stats, null, 2));
|
|
625
|
-
console.log(` LLM 자문: 네이밍 ${llm.naming} · 응집도 ${llm.cohesion} · 본질적 복잡성 ${llm.essential ? "예" : "아니오"}`);
|
|
626
|
-
console.log(` → ${llm.advice}`);
|
|
627
|
-
}
|
|
628
|
-
} catch (e) {
|
|
629
|
-
console.log(` LLM 자문 실패 (claude CLI 필요): ${e.message?.slice(0, 60)}`);
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
console.log(`\n 저장됨 → ${path.relative(process.cwd(), outPath)}\n`);
|
|
58
|
+
});
|