@m1kapp/kit 0.0.36 → 0.0.37
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/skills/m1kapp-clean.md +15 -0
- package/bin/stats.mjs +323 -0
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/meta.json +2 -2
- package/package.json +1 -1
|
@@ -5,9 +5,24 @@
|
|
|
5
5
|
- 최악 함수 cognitive 20 초과분 ×1 (최대 -15)
|
|
6
6
|
- **중복 밀도 3% 초과분 ×2.5 (최대 -25)** — 토큰 단위 복붙 검출 (식별자 정규화, 테스트 제외)
|
|
7
7
|
- 200줄 초과 파일 비율 ×2 (최대 -10) / 평균 파일 길이 80줄 초과분 ÷5 (최대 -10)
|
|
8
|
+
- **루프 안 무캐시 파일읽기 ×4 (최대 -20)** — 호출 1번이 파일을 N번 읽는 구조 (`quality.io`)
|
|
9
|
+
- **렌더 인질 ×3 (최대 -10)** — fetch 하나가 무관한 UI까지 막는 자리 (`quality.renderGates`)
|
|
8
10
|
|
|
9
11
|
cognitive = 중첩 깊이 가중 복잡도 (SonarQube 방식): `a && b && c`는 1, 5중 중첩 if는 flat if 5개보다 훨씬 비쌈. `??`는 미카운트. 즉 **평평하게 펴는 것(조기 반환)이 진짜 점수가 됨** — 함수 쪼개기만으론 안 오름.
|
|
10
12
|
|
|
13
|
+
`io` 축은 나머지와 성격이 다르다. cognitive·중복은 "코드가 얼마나 꼬였나"라 5줄짜리
|
|
14
|
+
흠 없는 리더가 루프에서 120번 불리는 종류는 **원리상 못 잡는다** — 실제로 요청당 파일을
|
|
15
|
+
492번 읽던 코드가 A+ 100점을 통과한 적이 있다. 그래서 파일을 읽는 함수(한 다리 건너
|
|
16
|
+
읽는 얇은 래퍼 포함)를 찾아 루프·`map` 안에서 불리는 자리를 센다. 그 리더가 캐시를
|
|
17
|
+
끼고 있으면(모듈 Map을 get/set) 반복 호출돼도 실제 읽기는 한 번이라 감점하지 않는다.
|
|
18
|
+
**고치는 법은 리팩토링이 아니라 캐시를 끼우거나 한 번 읽어 재사용하도록 구조를 바꾸는 것** —
|
|
19
|
+
`quality.io.worst`가 위치를 지목한다.
|
|
20
|
+
|
|
21
|
+
`renderGates`도 같은 성격이다. `{data && <Nav a={local} b={data.x} onChange={fn} />}`처럼
|
|
22
|
+
프롭 3개 중 1개만 그 데이터에 의존하는데 전체가 대기하면, 사용자에겐 이미 아는 UI까지
|
|
23
|
+
매번 로딩으로 보인다. 데이터에 의존하는 부분만 늦게 채우고 나머지는 먼저 그리는 게 답이다
|
|
24
|
+
(서버가 아는 값이면 아예 서버에서 내려주면 된다).
|
|
25
|
+
|
|
11
26
|
즉 **효율 순서: ① 중복 블록 제거 (kit-stats.json `quality.duplication.worstBlocks`가 위치 지목) ② 최악 함수 중첩 평탄화 ③ cog15+ 함수들 ④ 긴 파일 분할**.
|
|
12
27
|
|
|
13
28
|
`--llm` 플래그(선택): cognitive 최악 3개를 Claude(haiku)가 네이밍·응집도·본질적 복잡성 여부 자문 — 점수 미반영, `quality.llm`에 기록. "essential: true"로 나온 함수는 억지로 쪼개지 말 것.
|
package/bin/stats.mjs
CHANGED
|
@@ -340,6 +340,306 @@ function analyzeDuplication(ts, fileContents) {
|
|
|
340
340
|
return { percent, dupTokens, totalTokens, blocks: blockKeys.size, worstFiles, worstBlocks };
|
|
341
341
|
}
|
|
342
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
|
+
}
|
|
390
|
+
|
|
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
|
+
}
|
|
437
|
+
|
|
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;
|
|
458
|
+
}
|
|
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
|
+
}
|
|
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
|
+
|
|
343
643
|
// 파일 분류 — frontend(UI) / backend(API·서버) / shared(공용 유틸)
|
|
344
644
|
function classifyFile(filePath, content) {
|
|
345
645
|
const rel = filePath.replace(/\\/g, "/");
|
|
@@ -439,10 +739,15 @@ const avgFileLines = files.length > 0 ? Math.round(codeLines / files.length) : 0
|
|
|
439
739
|
// ※ 파일 개수 기준 이진 카운트(200줄 넘으면 무조건 1) 대신 심각도 가중 — 살짝 넘는 파일과
|
|
440
740
|
// 터무니없이 큰 파일을 구분하고, 파일 수 적은 프로젝트가 파일 1개만으로 즉시 만점 감점 맞는 절벽 방지.
|
|
441
741
|
// / 평균 파일 길이 80줄 초과분 /5 (최대 10)
|
|
742
|
+
// - 렌더 인질(fetch 하나가 무관한 UI까지 막는 자리) ×3 (최대 10)
|
|
743
|
+
// - 루프 안 무캐시 파일읽기 사이트 ×4 (최대 20) ← 새 축. 코드 모양이 아무리 깔끔해도
|
|
744
|
+
// 호출 1번이 파일 N번을 읽는 구조는 여기서만 드러난다(cognitive·중복으론 원리상 안 잡힘).
|
|
442
745
|
let qualityScore;
|
|
443
746
|
let cc = null;
|
|
444
747
|
let cognitive = null;
|
|
445
748
|
let duplication = null;
|
|
749
|
+
let io = null;
|
|
750
|
+
let renderGates = null;
|
|
446
751
|
if (ts && allFns.length > 0) {
|
|
447
752
|
const byCc = [...allFns].sort((a, b) => b.cc - a.cc);
|
|
448
753
|
cc = {
|
|
@@ -469,6 +774,8 @@ if (ts && allFns.length > 0) {
|
|
|
469
774
|
};
|
|
470
775
|
|
|
471
776
|
duplication = analyzeDuplication(ts, fileContents);
|
|
777
|
+
io = analyzeIoDensity(ts, fileContents);
|
|
778
|
+
renderGates = analyzeRenderGates(ts, fileContents);
|
|
472
779
|
|
|
473
780
|
const over15Pct = (cogOver15.length / allFns.length) * 100;
|
|
474
781
|
const over25Pct = (cogOver25.length / allFns.length) * 100;
|
|
@@ -481,6 +788,8 @@ if (ts && allFns.length > 0) {
|
|
|
481
788
|
- Math.min(25, Math.max(0, duplication.percent - 3) * 2.5)
|
|
482
789
|
- Math.min(10, longFileSeverityPct * 1.5)
|
|
483
790
|
- Math.min(10, Math.max(0, avgFileLines - 80) / 5)
|
|
791
|
+
- Math.min(20, io.uncachedLoopSites * 4)
|
|
792
|
+
- Math.min(10, renderGates.hostages * 3)
|
|
484
793
|
));
|
|
485
794
|
} else {
|
|
486
795
|
// regex 폴백 (typescript 미설치)
|
|
@@ -543,6 +852,8 @@ const stats = {
|
|
|
543
852
|
grade: qualityGrade,
|
|
544
853
|
cognitive, // {avg, p90, max, over15, over25, worst[5]} — 중첩 가중 복잡도
|
|
545
854
|
duplication, // {percent, blocks, worstFiles, worstBlocks} — 토큰 중복 밀도
|
|
855
|
+
io, // {readers, uncachedReaders, loopSites, uncachedLoopSites, worst} — 루프 안 파일 읽기
|
|
856
|
+
renderGates, // {hostages, worst} — fetch 하나가 무관한 UI까지 막고 있는 자리
|
|
546
857
|
cc, // {functions, avg, p90, max, over10, over20, worst[5]} — McCabe (참고용)
|
|
547
858
|
branchDensity, // 100줄당 분기 수 (regex 근사, 참고용)
|
|
548
859
|
branches: totalBranches,
|
|
@@ -588,6 +899,18 @@ if (cc) {
|
|
|
588
899
|
console.log(` 중복 블록: ${ex.join(" ≒ ")}`);
|
|
589
900
|
}
|
|
590
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
|
+
}
|
|
591
914
|
} else {
|
|
592
915
|
console.log(` 청결도: ${qualityGrade} (${qualityScore}점) — 분기밀도 ${branchDensity}/100줄, 평균 ${avgFileLines}줄/파일, 200줄+ ${longFiles}개 (regex 폴백 — typescript 설치 시 AST 정밀 분석)`);
|
|
593
916
|
}
|