fcis 0.1.0 → 0.2.1
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/.plans/003-code-cleanup-consolidation.md +242 -0
- package/.plans/004-directory-depth-rollup.md +408 -0
- package/.plans/005-code-refinements.md +210 -0
- package/.plans/006-minor-refinements.md +149 -0
- package/.plans/007-compositional-function-scoring.md +514 -0
- package/README.md +214 -132
- package/TECHNICAL.md +125 -2
- package/dist/cli.js +599 -328
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +15 -2
- package/dist/index.js +409 -240
- package/dist/index.js.map +1 -1
- package/docs/images/fc-is-submarine.webp +0 -0
- package/package.json +3 -1
- package/src/cli-utils.ts +201 -0
- package/src/cli.ts +105 -118
- package/src/detection/markers.ts +0 -222
- package/src/extraction/extract-functions.ts +106 -2
- package/src/extraction/extractor.ts +35 -74
- package/src/reporting/report-console.ts +188 -102
- package/src/reporting/report-json.ts +26 -3
- package/src/scoring/scorer.ts +425 -160
- package/src/types.ts +9 -2
- package/tests/classifier.test.ts +0 -1
- package/tests/cli.test.ts +356 -0
- package/tests/detect-markers.test.ts +1 -3
- package/tests/extractor.test.ts +95 -1
- package/tests/integration.test.ts +344 -0
- package/tests/report-console.test.ts +92 -0
- package/tests/scorer.test.ts +886 -0
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'module';
|
|
2
3
|
import { cli } from 'cleye';
|
|
3
|
-
import { z } from 'zod';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import * as fs3 from 'fs';
|
|
6
6
|
import * as path4 from 'path';
|
|
7
7
|
import { Project, SyntaxKind } from 'ts-morph';
|
|
8
|
+
import * as jsonc from 'jsonc-parser';
|
|
9
|
+
import { z } from 'zod';
|
|
8
10
|
|
|
9
11
|
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
10
12
|
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
@@ -18,6 +20,31 @@ var DEFAULT_QUALITY_THRESHOLDS = {
|
|
|
18
20
|
okThreshold: 70,
|
|
19
21
|
reviewThreshold: 40
|
|
20
22
|
};
|
|
23
|
+
var INLINE_CALLBACK_METHODS = /* @__PURE__ */ new Set([
|
|
24
|
+
// Array methods
|
|
25
|
+
"map",
|
|
26
|
+
"filter",
|
|
27
|
+
"reduce",
|
|
28
|
+
"forEach",
|
|
29
|
+
"find",
|
|
30
|
+
"findIndex",
|
|
31
|
+
"some",
|
|
32
|
+
"every",
|
|
33
|
+
"flatMap",
|
|
34
|
+
"sort",
|
|
35
|
+
"toSorted",
|
|
36
|
+
// Promise methods
|
|
37
|
+
"then",
|
|
38
|
+
"catch",
|
|
39
|
+
"finally",
|
|
40
|
+
// Timing
|
|
41
|
+
"setTimeout",
|
|
42
|
+
"setInterval",
|
|
43
|
+
// Events
|
|
44
|
+
"addEventListener",
|
|
45
|
+
"on",
|
|
46
|
+
"once"
|
|
47
|
+
]);
|
|
21
48
|
function extractFunctions(sourceFile) {
|
|
22
49
|
const functions = [];
|
|
23
50
|
const filePath = sourceFile.getFilePath();
|
|
@@ -105,25 +132,54 @@ function extractFunctions(sourceFile) {
|
|
|
105
132
|
if (node.getKind() === SyntaxKind.ArrowFunction) {
|
|
106
133
|
const arrowFn = node;
|
|
107
134
|
const parentContext = inferParentContext(arrowFn);
|
|
135
|
+
const isInlineCallback = isInlineCallbackToKnownHOF(parentContext);
|
|
136
|
+
const enclosingFunctionStartLine = isInlineCallback ? findEnclosingFunctionStartLine(arrowFn) : null;
|
|
108
137
|
addFunction(
|
|
109
|
-
extractFunctionData(
|
|
138
|
+
extractFunctionData(
|
|
139
|
+
arrowFn,
|
|
140
|
+
filePath,
|
|
141
|
+
"arrow",
|
|
142
|
+
parentContext,
|
|
143
|
+
false,
|
|
144
|
+
isInlineCallback,
|
|
145
|
+
enclosingFunctionStartLine
|
|
146
|
+
)
|
|
110
147
|
);
|
|
111
148
|
} else if (node.getKind() === SyntaxKind.FunctionExpression) {
|
|
112
149
|
const funcExpr = node;
|
|
113
150
|
const parentContext = inferParentContext(funcExpr);
|
|
151
|
+
const isInlineCallback = isInlineCallbackToKnownHOF(parentContext);
|
|
152
|
+
const enclosingFunctionStartLine = isInlineCallback ? findEnclosingFunctionStartLine(funcExpr) : null;
|
|
114
153
|
addFunction(
|
|
115
154
|
extractFunctionData(
|
|
116
155
|
funcExpr,
|
|
117
156
|
filePath,
|
|
118
157
|
"function-expression",
|
|
119
158
|
parentContext,
|
|
120
|
-
false
|
|
159
|
+
false,
|
|
160
|
+
isInlineCallback,
|
|
161
|
+
enclosingFunctionStartLine
|
|
121
162
|
)
|
|
122
163
|
);
|
|
123
164
|
}
|
|
124
165
|
});
|
|
125
166
|
return functions;
|
|
126
167
|
}
|
|
168
|
+
function isInlineCallbackToKnownHOF(parentContext) {
|
|
169
|
+
if (parentContext === null) return false;
|
|
170
|
+
return INLINE_CALLBACK_METHODS.has(parentContext);
|
|
171
|
+
}
|
|
172
|
+
function findEnclosingFunctionStartLine(node) {
|
|
173
|
+
let current = node.getParent();
|
|
174
|
+
while (current) {
|
|
175
|
+
const kind = current.getKind();
|
|
176
|
+
if (kind === SyntaxKind.FunctionDeclaration || kind === SyntaxKind.MethodDeclaration || kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.FunctionExpression || kind === SyntaxKind.GetAccessor || kind === SyntaxKind.SetAccessor) {
|
|
177
|
+
return current.getStartLineNumber();
|
|
178
|
+
}
|
|
179
|
+
current = current.getParent();
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
127
183
|
function inferParentContext(node) {
|
|
128
184
|
const parent = node.getParent();
|
|
129
185
|
if (!parent) return null;
|
|
@@ -148,7 +204,7 @@ function inferParentContext(node) {
|
|
|
148
204
|
}
|
|
149
205
|
return null;
|
|
150
206
|
}
|
|
151
|
-
function extractFunctionData(node, filePath, kind, parentContext = null, isExportedOverride) {
|
|
207
|
+
function extractFunctionData(node, filePath, kind, parentContext = null, isExportedOverride, isInlineCallback = false, enclosingFunctionStartLine = null) {
|
|
152
208
|
const startLine = node.getStartLineNumber();
|
|
153
209
|
const endLine = node.getEndLineNumber();
|
|
154
210
|
const bodyLineCount = endLine - startLine + 1;
|
|
@@ -184,7 +240,9 @@ function extractFunctionData(node, filePath, kind, parentContext = null, isExpor
|
|
|
184
240
|
callSites,
|
|
185
241
|
hasAwait,
|
|
186
242
|
propertyAccessChains,
|
|
187
|
-
kind
|
|
243
|
+
kind,
|
|
244
|
+
enclosingFunctionStartLine,
|
|
245
|
+
isInlineCallback
|
|
188
246
|
};
|
|
189
247
|
}
|
|
190
248
|
function getBodyNode(node) {
|
|
@@ -210,8 +268,12 @@ function isStatement(node) {
|
|
|
210
268
|
function checkForConditionals(body) {
|
|
211
269
|
if (!body) return false;
|
|
212
270
|
let hasConditionals = false;
|
|
213
|
-
body.forEachDescendant((node) => {
|
|
271
|
+
body.forEachDescendant((node, traversal) => {
|
|
214
272
|
const kind = node.getKind();
|
|
273
|
+
if (kind === SyntaxKind.FunctionDeclaration || kind === SyntaxKind.FunctionExpression || kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.MethodDeclaration) {
|
|
274
|
+
traversal.skip();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
215
277
|
if (kind === SyntaxKind.IfStatement || kind === SyntaxKind.ConditionalExpression || kind === SyntaxKind.SwitchStatement) {
|
|
216
278
|
hasConditionals = true;
|
|
217
279
|
}
|
|
@@ -341,73 +403,30 @@ function isTypeOnlyFile(sourceFile) {
|
|
|
341
403
|
}
|
|
342
404
|
return true;
|
|
343
405
|
}
|
|
344
|
-
function
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const nextChar = jsonString[i + 1];
|
|
353
|
-
if (!inLineComment && !inBlockComment && char === '"') {
|
|
354
|
-
let backslashCount = 0;
|
|
355
|
-
let j = i - 1;
|
|
356
|
-
while (j >= 0 && jsonString[j] === "\\") {
|
|
357
|
-
backslashCount++;
|
|
358
|
-
j--;
|
|
359
|
-
}
|
|
360
|
-
if (backslashCount % 2 === 0) {
|
|
361
|
-
inString = !inString;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
if (inString) {
|
|
365
|
-
result += char;
|
|
366
|
-
i++;
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
if (!inBlockComment && char === "/" && nextChar === "/") {
|
|
370
|
-
inLineComment = true;
|
|
371
|
-
i += 2;
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
if (inLineComment && (char === "\n" || char === "\r")) {
|
|
375
|
-
inLineComment = false;
|
|
376
|
-
result += char;
|
|
377
|
-
i++;
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
-
if (!inLineComment && char === "/" && nextChar === "*") {
|
|
381
|
-
inBlockComment = true;
|
|
382
|
-
i += 2;
|
|
383
|
-
continue;
|
|
384
|
-
}
|
|
385
|
-
if (inBlockComment && char === "*" && nextChar === "/") {
|
|
386
|
-
inBlockComment = false;
|
|
387
|
-
i += 2;
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
if (!inLineComment && !inBlockComment) {
|
|
391
|
-
result += char;
|
|
392
|
-
}
|
|
393
|
-
i++;
|
|
406
|
+
function validateTsconfigContent(content) {
|
|
407
|
+
const errors = [];
|
|
408
|
+
jsonc.parse(content, errors, { allowTrailingComma: true });
|
|
409
|
+
if (errors.length > 0 && errors[0]) {
|
|
410
|
+
return {
|
|
411
|
+
valid: false,
|
|
412
|
+
error: `Parse error at offset ${errors[0].offset}: ${jsonc.printParseErrorCode(errors[0].error)}`
|
|
413
|
+
};
|
|
394
414
|
}
|
|
395
|
-
return
|
|
415
|
+
return { valid: true };
|
|
396
416
|
}
|
|
397
417
|
function loadProject(options) {
|
|
398
418
|
const { tsconfigPath, filesGlob } = options;
|
|
399
419
|
const errors = [];
|
|
400
420
|
const absoluteTsconfigPath = path4.resolve(tsconfigPath);
|
|
421
|
+
const projectDir = path4.dirname(absoluteTsconfigPath);
|
|
401
422
|
if (!fs3.existsSync(absoluteTsconfigPath)) {
|
|
402
423
|
throw new Error(`tsconfig.json not found at: ${absoluteTsconfigPath}`);
|
|
403
424
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
JSON.parse(strippedContent);
|
|
408
|
-
} catch (e) {
|
|
425
|
+
const content = fs3.readFileSync(absoluteTsconfigPath, "utf-8");
|
|
426
|
+
const validationResult = validateTsconfigContent(content);
|
|
427
|
+
if (!validationResult.valid) {
|
|
409
428
|
throw new Error(
|
|
410
|
-
`Invalid tsconfig.json at ${absoluteTsconfigPath}: ${
|
|
429
|
+
`Invalid tsconfig.json at ${absoluteTsconfigPath}: ${validationResult.error}`
|
|
411
430
|
);
|
|
412
431
|
}
|
|
413
432
|
const project = new Project({
|
|
@@ -416,7 +435,7 @@ function loadProject(options) {
|
|
|
416
435
|
});
|
|
417
436
|
let sourceFiles = project.getSourceFiles().filter((sf) => {
|
|
418
437
|
const filePath = sf.getFilePath();
|
|
419
|
-
return filePath.endsWith(".ts") && !filePath.endsWith(".d.ts") && !filePath.endsWith(".test.ts") && !filePath.endsWith(".spec.ts") && !filePath.includes("/node_modules/") && !filePath.includes("/generated/");
|
|
438
|
+
return filePath.startsWith(projectDir + "/") && filePath.endsWith(".ts") && !filePath.endsWith(".d.ts") && !filePath.endsWith(".test.ts") && !filePath.endsWith(".spec.ts") && !filePath.includes("/node_modules/") && !filePath.includes("/generated/");
|
|
420
439
|
});
|
|
421
440
|
if (filesGlob) {
|
|
422
441
|
const globPattern = createGlobMatcher(filesGlob);
|
|
@@ -977,6 +996,122 @@ function deriveStatus(classification, qualityScore, thresholds = DEFAULT_QUALITY
|
|
|
977
996
|
}
|
|
978
997
|
|
|
979
998
|
// src/scoring/scorer.ts
|
|
999
|
+
function groupFunctionsByParent(functions) {
|
|
1000
|
+
const byStartLine = /* @__PURE__ */ new Map();
|
|
1001
|
+
for (const fn of functions) {
|
|
1002
|
+
byStartLine.set(fn.startLine, fn);
|
|
1003
|
+
}
|
|
1004
|
+
const topLevel = [];
|
|
1005
|
+
const childrenByParentLine = /* @__PURE__ */ new Map();
|
|
1006
|
+
for (const fn of functions) {
|
|
1007
|
+
const isTopLevel = !fn.isInlineCallback || fn.enclosingFunctionStartLine === null;
|
|
1008
|
+
if (isTopLevel) {
|
|
1009
|
+
topLevel.push({ fn, children: [] });
|
|
1010
|
+
} else if (fn.enclosingFunctionStartLine !== null) {
|
|
1011
|
+
const existing = childrenByParentLine.get(fn.enclosingFunctionStartLine);
|
|
1012
|
+
if (existing) {
|
|
1013
|
+
existing.push(fn);
|
|
1014
|
+
} else {
|
|
1015
|
+
childrenByParentLine.set(fn.enclosingFunctionStartLine, [fn]);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
for (const entry of topLevel) {
|
|
1020
|
+
const children = childrenByParentLine.get(entry.fn.startLine);
|
|
1021
|
+
if (children) {
|
|
1022
|
+
entry.children = children;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
return topLevel;
|
|
1026
|
+
}
|
|
1027
|
+
function isEffectivelyImpure(entry) {
|
|
1028
|
+
if (entry.fn.classification === "impure") return true;
|
|
1029
|
+
return entry.children.some((child) => child.classification === "impure");
|
|
1030
|
+
}
|
|
1031
|
+
function getEffectiveStatus(entry) {
|
|
1032
|
+
const statuses = [entry.fn.status, ...entry.children.map((c) => c.status)];
|
|
1033
|
+
if (statuses.includes("refactor")) return "refactor";
|
|
1034
|
+
if (statuses.includes("review")) return "review";
|
|
1035
|
+
return "ok";
|
|
1036
|
+
}
|
|
1037
|
+
function computeComposedQuality(entry) {
|
|
1038
|
+
const impureChildren = entry.children.filter(
|
|
1039
|
+
(c) => c.classification === "impure"
|
|
1040
|
+
);
|
|
1041
|
+
if (impureChildren.length === 0) {
|
|
1042
|
+
return entry.fn.qualityScore ?? 50;
|
|
1043
|
+
}
|
|
1044
|
+
const childTotalLines = entry.children.reduce(
|
|
1045
|
+
(sum, c) => sum + c.bodyLineCount,
|
|
1046
|
+
0
|
|
1047
|
+
);
|
|
1048
|
+
const parentOwnLines = Math.max(1, entry.fn.bodyLineCount - childTotalLines);
|
|
1049
|
+
const totalLines = parentOwnLines + childTotalLines;
|
|
1050
|
+
const parentQuality = entry.fn.qualityScore ?? 50;
|
|
1051
|
+
const parentContribution = parentQuality * parentOwnLines;
|
|
1052
|
+
const childContribution = impureChildren.reduce(
|
|
1053
|
+
(sum, c) => sum + (c.qualityScore ?? 50) * c.bodyLineCount,
|
|
1054
|
+
0
|
|
1055
|
+
);
|
|
1056
|
+
return (parentContribution + childContribution) / totalLines;
|
|
1057
|
+
}
|
|
1058
|
+
function aggregateMetrics(items) {
|
|
1059
|
+
const scorableItems = items.filter(
|
|
1060
|
+
(item) => item.pureCount + item.impureCount > 0
|
|
1061
|
+
);
|
|
1062
|
+
const totalPure = items.reduce((sum, item) => sum + item.pureCount, 0);
|
|
1063
|
+
const totalImpure = items.reduce((sum, item) => sum + item.impureCount, 0);
|
|
1064
|
+
const total = totalPure + totalImpure;
|
|
1065
|
+
if (total === 0) {
|
|
1066
|
+
return {
|
|
1067
|
+
purity: 100,
|
|
1068
|
+
impurityQuality: null,
|
|
1069
|
+
health: 100,
|
|
1070
|
+
pureCount: 0,
|
|
1071
|
+
impureCount: 0,
|
|
1072
|
+
excludedCount: items.reduce((sum, item) => sum + item.excludedCount, 0),
|
|
1073
|
+
statusBreakdown: { ok: 0, review: 0, refactor: 0 },
|
|
1074
|
+
pureLineCount: 0,
|
|
1075
|
+
impureLineCount: 0
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
const purity = totalPure / total * 100;
|
|
1079
|
+
let impurityQuality = null;
|
|
1080
|
+
if (totalImpure > 0) {
|
|
1081
|
+
const weightedQuality = scorableItems.reduce((sum, item) => {
|
|
1082
|
+
if (item.impurityQuality !== null && item.impureCount > 0) {
|
|
1083
|
+
return sum + item.impurityQuality * item.impureCount;
|
|
1084
|
+
}
|
|
1085
|
+
return sum;
|
|
1086
|
+
}, 0);
|
|
1087
|
+
impurityQuality = weightedQuality / totalImpure;
|
|
1088
|
+
}
|
|
1089
|
+
const statusBreakdown = {
|
|
1090
|
+
ok: items.reduce((sum, item) => sum + item.statusBreakdown.ok, 0),
|
|
1091
|
+
review: items.reduce((sum, item) => sum + item.statusBreakdown.review, 0),
|
|
1092
|
+
refactor: items.reduce(
|
|
1093
|
+
(sum, item) => sum + item.statusBreakdown.refactor,
|
|
1094
|
+
0
|
|
1095
|
+
)
|
|
1096
|
+
};
|
|
1097
|
+
const health = statusBreakdown.ok / total * 100;
|
|
1098
|
+
const pureLineCount = items.reduce((sum, item) => sum + item.pureLineCount, 0);
|
|
1099
|
+
const impureLineCount = items.reduce(
|
|
1100
|
+
(sum, item) => sum + item.impureLineCount,
|
|
1101
|
+
0
|
|
1102
|
+
);
|
|
1103
|
+
return {
|
|
1104
|
+
purity,
|
|
1105
|
+
impurityQuality,
|
|
1106
|
+
health,
|
|
1107
|
+
pureCount: totalPure,
|
|
1108
|
+
impureCount: totalImpure,
|
|
1109
|
+
excludedCount: items.reduce((sum, item) => sum + item.excludedCount, 0),
|
|
1110
|
+
statusBreakdown,
|
|
1111
|
+
pureLineCount,
|
|
1112
|
+
impureLineCount
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
980
1115
|
function scoreFile(filePath, functions, isTypeOnly = false) {
|
|
981
1116
|
if (isTypeOnly) {
|
|
982
1117
|
return {
|
|
@@ -995,12 +1130,8 @@ function scoreFile(filePath, functions, isTypeOnly = false) {
|
|
|
995
1130
|
typeOnly: true
|
|
996
1131
|
};
|
|
997
1132
|
}
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
const pureCount = pureFunctions.length;
|
|
1001
|
-
const impureCount = impureFunctions.length;
|
|
1002
|
-
const total = pureCount + impureCount;
|
|
1003
|
-
if (total === 0) {
|
|
1133
|
+
const grouped = groupFunctionsByParent(functions);
|
|
1134
|
+
if (grouped.length === 0) {
|
|
1004
1135
|
return {
|
|
1005
1136
|
filePath,
|
|
1006
1137
|
purity: 100,
|
|
@@ -1017,36 +1148,49 @@ function scoreFile(filePath, functions, isTypeOnly = false) {
|
|
|
1017
1148
|
allExcluded: true
|
|
1018
1149
|
};
|
|
1019
1150
|
}
|
|
1020
|
-
|
|
1151
|
+
let pureCount = 0;
|
|
1152
|
+
let impureCount = 0;
|
|
1153
|
+
let pureLineCount = 0;
|
|
1154
|
+
let impureLineCount = 0;
|
|
1155
|
+
const statusBreakdown = { ok: 0, review: 0, refactor: 0 };
|
|
1156
|
+
for (const entry of grouped) {
|
|
1157
|
+
const effectivelyImpure = isEffectivelyImpure(entry);
|
|
1158
|
+
const effectiveStatus = getEffectiveStatus(entry);
|
|
1159
|
+
if (effectivelyImpure) {
|
|
1160
|
+
impureCount++;
|
|
1161
|
+
impureLineCount += entry.fn.bodyLineCount;
|
|
1162
|
+
} else {
|
|
1163
|
+
pureCount++;
|
|
1164
|
+
pureLineCount += entry.fn.bodyLineCount;
|
|
1165
|
+
}
|
|
1166
|
+
statusBreakdown[effectiveStatus]++;
|
|
1167
|
+
}
|
|
1168
|
+
const total = pureCount + impureCount;
|
|
1169
|
+
const purity = total > 0 ? pureCount / total * 100 : 100;
|
|
1021
1170
|
let impurityQuality = null;
|
|
1022
1171
|
if (impureCount > 0) {
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1172
|
+
const impureEntries = grouped.filter((e) => isEffectivelyImpure(e));
|
|
1173
|
+
const totalQuality = impureEntries.reduce(
|
|
1174
|
+
(sum, e) => sum + computeComposedQuality(e),
|
|
1025
1175
|
0
|
|
1026
1176
|
);
|
|
1027
1177
|
impurityQuality = totalQuality / impureCount;
|
|
1028
1178
|
}
|
|
1029
|
-
const
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
name: f.name,
|
|
1045
|
-
startLine: f.startLine,
|
|
1046
|
-
bodyLineCount: f.bodyLineCount,
|
|
1047
|
-
qualityScore: f.qualityScore ?? 0,
|
|
1048
|
-
markers: f.markers.map((m) => m.type)
|
|
1049
|
-
})).sort((a, b) => {
|
|
1179
|
+
const health = total > 0 ? statusBreakdown.ok / total * 100 : 100;
|
|
1180
|
+
const refactoringCandidates = grouped.filter((e) => isEffectivelyImpure(e) && getEffectiveStatus(e) === "refactor").map((e) => {
|
|
1181
|
+
const allMarkers = [
|
|
1182
|
+
...e.fn.markers.map((m) => m.type),
|
|
1183
|
+
...e.children.flatMap((c) => c.markers.map((m) => m.type))
|
|
1184
|
+
];
|
|
1185
|
+
const uniqueMarkers = [...new Set(allMarkers)];
|
|
1186
|
+
return {
|
|
1187
|
+
name: e.fn.name,
|
|
1188
|
+
startLine: e.fn.startLine,
|
|
1189
|
+
bodyLineCount: e.fn.bodyLineCount,
|
|
1190
|
+
qualityScore: e.fn.qualityScore ?? 0,
|
|
1191
|
+
markers: uniqueMarkers
|
|
1192
|
+
};
|
|
1193
|
+
}).sort((a, b) => {
|
|
1050
1194
|
const impactA = a.bodyLineCount * (100 - a.qualityScore);
|
|
1051
1195
|
const impactB = b.bodyLineCount * (100 - b.qualityScore);
|
|
1052
1196
|
return impactB - impactA;
|
|
@@ -1063,6 +1207,7 @@ function scoreFile(filePath, functions, isTypeOnly = false) {
|
|
|
1063
1207
|
pureLineCount,
|
|
1064
1208
|
impureLineCount,
|
|
1065
1209
|
functions,
|
|
1210
|
+
// Keep all functions for drill-down (includes isInlineCallback flag)
|
|
1066
1211
|
refactoringCandidates
|
|
1067
1212
|
};
|
|
1068
1213
|
}
|
|
@@ -1071,62 +1216,19 @@ function scoreDirectory(dirPath, fileScores) {
|
|
|
1071
1216
|
(f) => !f.typeOnly && !f.allExcluded && f.pureCount + f.impureCount > 0
|
|
1072
1217
|
);
|
|
1073
1218
|
if (scorableFiles.length === 0) {
|
|
1219
|
+
const emptyMetrics = aggregateMetrics([]);
|
|
1074
1220
|
return {
|
|
1075
1221
|
dirPath,
|
|
1076
|
-
|
|
1077
|
-
impurityQuality: null,
|
|
1078
|
-
health: 100,
|
|
1079
|
-
pureCount: 0,
|
|
1080
|
-
impureCount: 0,
|
|
1222
|
+
...emptyMetrics,
|
|
1081
1223
|
excludedCount: fileScores.reduce((sum, f) => sum + f.excludedCount, 0),
|
|
1082
|
-
statusBreakdown: { ok: 0, review: 0, refactor: 0 },
|
|
1083
|
-
pureLineCount: 0,
|
|
1084
|
-
impureLineCount: 0,
|
|
1085
1224
|
fileScores
|
|
1086
1225
|
};
|
|
1087
1226
|
}
|
|
1088
|
-
const
|
|
1089
|
-
const totalImpure = scorableFiles.reduce((sum, f) => sum + f.impureCount, 0);
|
|
1090
|
-
const total = totalPure + totalImpure;
|
|
1091
|
-
const purity = total > 0 ? totalPure / total * 100 : 100;
|
|
1092
|
-
let impurityQuality = null;
|
|
1093
|
-
if (totalImpure > 0) {
|
|
1094
|
-
const weightedQuality = scorableFiles.reduce((sum, f) => {
|
|
1095
|
-
if (f.impurityQuality !== null && f.impureCount > 0) {
|
|
1096
|
-
return sum + f.impurityQuality * f.impureCount;
|
|
1097
|
-
}
|
|
1098
|
-
return sum;
|
|
1099
|
-
}, 0);
|
|
1100
|
-
impurityQuality = weightedQuality / totalImpure;
|
|
1101
|
-
}
|
|
1102
|
-
const statusBreakdown = {
|
|
1103
|
-
ok: scorableFiles.reduce((sum, f) => sum + f.statusBreakdown.ok, 0),
|
|
1104
|
-
review: scorableFiles.reduce((sum, f) => sum + f.statusBreakdown.review, 0),
|
|
1105
|
-
refactor: scorableFiles.reduce(
|
|
1106
|
-
(sum, f) => sum + f.statusBreakdown.refactor,
|
|
1107
|
-
0
|
|
1108
|
-
)
|
|
1109
|
-
};
|
|
1110
|
-
const health = total > 0 ? statusBreakdown.ok / total * 100 : 100;
|
|
1111
|
-
const pureLineCount = scorableFiles.reduce(
|
|
1112
|
-
(sum, f) => sum + f.pureLineCount,
|
|
1113
|
-
0
|
|
1114
|
-
);
|
|
1115
|
-
const impureLineCount = scorableFiles.reduce(
|
|
1116
|
-
(sum, f) => sum + f.impureLineCount,
|
|
1117
|
-
0
|
|
1118
|
-
);
|
|
1227
|
+
const aggregated = aggregateMetrics(scorableFiles);
|
|
1119
1228
|
return {
|
|
1120
1229
|
dirPath,
|
|
1121
|
-
|
|
1122
|
-
impurityQuality,
|
|
1123
|
-
health,
|
|
1124
|
-
pureCount: totalPure,
|
|
1125
|
-
impureCount: totalImpure,
|
|
1230
|
+
...aggregated,
|
|
1126
1231
|
excludedCount: fileScores.reduce((sum, f) => sum + f.excludedCount, 0),
|
|
1127
|
-
statusBreakdown,
|
|
1128
|
-
pureLineCount,
|
|
1129
|
-
impureLineCount,
|
|
1130
1232
|
fileScores
|
|
1131
1233
|
};
|
|
1132
1234
|
}
|
|
@@ -1142,19 +1244,13 @@ function scoreProject(directoryScores, options = {}) {
|
|
|
1142
1244
|
(d) => d.pureCount + d.impureCount > 0
|
|
1143
1245
|
);
|
|
1144
1246
|
if (scorableDirs.length === 0) {
|
|
1247
|
+
const emptyMetrics = aggregateMetrics([]);
|
|
1145
1248
|
return {
|
|
1146
|
-
|
|
1147
|
-
impurityQuality: null,
|
|
1148
|
-
health: 100,
|
|
1149
|
-
pureCount: 0,
|
|
1150
|
-
impureCount: 0,
|
|
1249
|
+
...emptyMetrics,
|
|
1151
1250
|
excludedCount: directoryScores.reduce(
|
|
1152
1251
|
(sum, d) => sum + d.excludedCount,
|
|
1153
1252
|
0
|
|
1154
1253
|
),
|
|
1155
|
-
statusBreakdown: { ok: 0, review: 0, refactor: 0 },
|
|
1156
|
-
pureLineCount: 0,
|
|
1157
|
-
impureLineCount: 0,
|
|
1158
1254
|
directoryScores,
|
|
1159
1255
|
timestamp,
|
|
1160
1256
|
commitHash,
|
|
@@ -1164,37 +1260,7 @@ function scoreProject(directoryScores, options = {}) {
|
|
|
1164
1260
|
...errors.length > 0 && { errors }
|
|
1165
1261
|
};
|
|
1166
1262
|
}
|
|
1167
|
-
const
|
|
1168
|
-
const totalImpure = scorableDirs.reduce((sum, d) => sum + d.impureCount, 0);
|
|
1169
|
-
const total = totalPure + totalImpure;
|
|
1170
|
-
const purity = total > 0 ? totalPure / total * 100 : 100;
|
|
1171
|
-
let impurityQuality = null;
|
|
1172
|
-
if (totalImpure > 0) {
|
|
1173
|
-
const weightedQuality = scorableDirs.reduce((sum, d) => {
|
|
1174
|
-
if (d.impurityQuality !== null && d.impureCount > 0) {
|
|
1175
|
-
return sum + d.impurityQuality * d.impureCount;
|
|
1176
|
-
}
|
|
1177
|
-
return sum;
|
|
1178
|
-
}, 0);
|
|
1179
|
-
impurityQuality = weightedQuality / totalImpure;
|
|
1180
|
-
}
|
|
1181
|
-
const statusBreakdown = {
|
|
1182
|
-
ok: scorableDirs.reduce((sum, d) => sum + d.statusBreakdown.ok, 0),
|
|
1183
|
-
review: scorableDirs.reduce((sum, d) => sum + d.statusBreakdown.review, 0),
|
|
1184
|
-
refactor: scorableDirs.reduce(
|
|
1185
|
-
(sum, d) => sum + d.statusBreakdown.refactor,
|
|
1186
|
-
0
|
|
1187
|
-
)
|
|
1188
|
-
};
|
|
1189
|
-
const health = total > 0 ? statusBreakdown.ok / total * 100 : 100;
|
|
1190
|
-
const pureLineCount = scorableDirs.reduce(
|
|
1191
|
-
(sum, d) => sum + d.pureLineCount,
|
|
1192
|
-
0
|
|
1193
|
-
);
|
|
1194
|
-
const impureLineCount = scorableDirs.reduce(
|
|
1195
|
-
(sum, d) => sum + d.impureLineCount,
|
|
1196
|
-
0
|
|
1197
|
-
);
|
|
1263
|
+
const aggregated = aggregateMetrics(scorableDirs);
|
|
1198
1264
|
const allCandidates = [];
|
|
1199
1265
|
for (const dir of directoryScores) {
|
|
1200
1266
|
for (const file of dir.fileScores) {
|
|
@@ -1212,15 +1278,8 @@ function scoreProject(directoryScores, options = {}) {
|
|
|
1212
1278
|
return impactB - impactA;
|
|
1213
1279
|
});
|
|
1214
1280
|
const result = {
|
|
1215
|
-
|
|
1216
|
-
impurityQuality,
|
|
1217
|
-
health,
|
|
1218
|
-
pureCount: totalPure,
|
|
1219
|
-
impureCount: totalImpure,
|
|
1281
|
+
...aggregated,
|
|
1220
1282
|
excludedCount: directoryScores.reduce((sum, d) => sum + d.excludedCount, 0),
|
|
1221
|
-
statusBreakdown,
|
|
1222
|
-
pureLineCount,
|
|
1223
|
-
impureLineCount,
|
|
1224
1283
|
directoryScores,
|
|
1225
1284
|
timestamp,
|
|
1226
1285
|
commitHash,
|
|
@@ -1253,6 +1312,56 @@ function getDirectoryPath(filePath) {
|
|
|
1253
1312
|
if (lastSlash === -1) return ".";
|
|
1254
1313
|
return filePath.slice(0, lastSlash);
|
|
1255
1314
|
}
|
|
1315
|
+
function getPathAtDepth(relativePath, depth) {
|
|
1316
|
+
const normalized = relativePath.replace(/\\/g, "/");
|
|
1317
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
1318
|
+
if (segments.length === 0) {
|
|
1319
|
+
return ".";
|
|
1320
|
+
}
|
|
1321
|
+
const segmentCount = depth + 1;
|
|
1322
|
+
if (segmentCount >= segments.length) {
|
|
1323
|
+
return segments.join("/");
|
|
1324
|
+
}
|
|
1325
|
+
return segments.slice(0, segmentCount).join("/");
|
|
1326
|
+
}
|
|
1327
|
+
function rollupDirectoriesByDepth(directories, depth) {
|
|
1328
|
+
if (directories.length === 0) {
|
|
1329
|
+
return [];
|
|
1330
|
+
}
|
|
1331
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1332
|
+
for (const dir of directories) {
|
|
1333
|
+
const truncatedPath = getPathAtDepth(dir.dirPath, depth);
|
|
1334
|
+
const existing = groups.get(truncatedPath);
|
|
1335
|
+
if (existing) {
|
|
1336
|
+
existing.push(dir);
|
|
1337
|
+
} else {
|
|
1338
|
+
groups.set(truncatedPath, [dir]);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
const rolledUp = [];
|
|
1342
|
+
for (const [groupPath, groupDirs] of groups) {
|
|
1343
|
+
const scorableDirs = groupDirs.filter((d) => d.pureCount + d.impureCount > 0);
|
|
1344
|
+
if (scorableDirs.length === 0) {
|
|
1345
|
+
const emptyMetrics = aggregateMetrics([]);
|
|
1346
|
+
rolledUp.push({
|
|
1347
|
+
dirPath: groupPath,
|
|
1348
|
+
...emptyMetrics,
|
|
1349
|
+
excludedCount: groupDirs.reduce((sum, d) => sum + d.excludedCount, 0),
|
|
1350
|
+
fileScores: []
|
|
1351
|
+
});
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
const aggregated = aggregateMetrics(scorableDirs);
|
|
1355
|
+
rolledUp.push({
|
|
1356
|
+
dirPath: groupPath,
|
|
1357
|
+
...aggregated,
|
|
1358
|
+
excludedCount: groupDirs.reduce((sum, d) => sum + d.excludedCount, 0),
|
|
1359
|
+
fileScores: []
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
rolledUp.sort((a, b) => a.dirPath.localeCompare(b.dirPath));
|
|
1363
|
+
return rolledUp;
|
|
1364
|
+
}
|
|
1256
1365
|
function getDiagnosticInsights(score) {
|
|
1257
1366
|
const insights = [];
|
|
1258
1367
|
if (score.purity >= 60 && score.impurityQuality !== null && score.impurityQuality < 50) {
|
|
@@ -1390,8 +1499,79 @@ function checkThresholds(score, config) {
|
|
|
1390
1499
|
failures
|
|
1391
1500
|
};
|
|
1392
1501
|
}
|
|
1502
|
+
function getMetricColor(value) {
|
|
1503
|
+
if (value >= 70) return chalk.green;
|
|
1504
|
+
if (value >= 50) return chalk.yellow;
|
|
1505
|
+
return chalk.red;
|
|
1506
|
+
}
|
|
1507
|
+
function getMetricColorNullable(value) {
|
|
1508
|
+
if (value === null) return chalk.gray;
|
|
1509
|
+
return getMetricColor(value);
|
|
1510
|
+
}
|
|
1511
|
+
function toRelativePath(absolutePath) {
|
|
1512
|
+
const cwd = process.cwd();
|
|
1513
|
+
if (absolutePath.startsWith(cwd)) {
|
|
1514
|
+
const relative3 = path4.relative(cwd, absolutePath);
|
|
1515
|
+
return relative3 || ".";
|
|
1516
|
+
}
|
|
1517
|
+
return absolutePath;
|
|
1518
|
+
}
|
|
1519
|
+
function relativizeDirectoryPaths(directories, projectRoot) {
|
|
1520
|
+
return directories.map((d) => ({
|
|
1521
|
+
...d,
|
|
1522
|
+
dirPath: path4.relative(projectRoot, d.dirPath).replace(/\\/g, "/")
|
|
1523
|
+
}));
|
|
1524
|
+
}
|
|
1525
|
+
function printDirectoryTable(directories, options) {
|
|
1526
|
+
const { title, includeQualityColumn, sortBy, emptyMessage } = options;
|
|
1527
|
+
let filtered = directories.filter((d) => d.pureCount + d.impureCount > 0);
|
|
1528
|
+
if (sortBy === "health-asc") {
|
|
1529
|
+
filtered = [...filtered].sort((a, b) => a.health - b.health);
|
|
1530
|
+
}
|
|
1531
|
+
console.log(chalk.bold(title));
|
|
1532
|
+
console.log(chalk.gray("\u2500".repeat(80)));
|
|
1533
|
+
if (includeQualityColumn) {
|
|
1534
|
+
console.log(
|
|
1535
|
+
chalk.gray(
|
|
1536
|
+
padEnd("Directory", 40) + padEnd("Health", 10) + padEnd("Purity", 10) + padEnd("Quality", 10) + padEnd("Functions", 10)
|
|
1537
|
+
)
|
|
1538
|
+
);
|
|
1539
|
+
} else {
|
|
1540
|
+
console.log(
|
|
1541
|
+
chalk.gray(
|
|
1542
|
+
padEnd("Directory", 45) + padEnd("Health", 10) + padEnd("Purity", 10) + padEnd("Functions", 15)
|
|
1543
|
+
)
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
console.log(chalk.gray("\u2500".repeat(80)));
|
|
1547
|
+
if (filtered.length === 0) {
|
|
1548
|
+
if (emptyMessage) {
|
|
1549
|
+
console.log(chalk.gray(` ${emptyMessage}`));
|
|
1550
|
+
}
|
|
1551
|
+
console.log();
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
for (const dir of filtered) {
|
|
1555
|
+
const healthColor = getMetricColor(dir.health);
|
|
1556
|
+
const purityColor = getMetricColor(dir.purity);
|
|
1557
|
+
const total = dir.pureCount + dir.impureCount;
|
|
1558
|
+
if (includeQualityColumn) {
|
|
1559
|
+
const qualityStr = dir.impurityQuality !== null ? dir.impurityQuality.toFixed(0) + "%" : "\u2014";
|
|
1560
|
+
const qualityColor = getMetricColorNullable(dir.impurityQuality);
|
|
1561
|
+
console.log(
|
|
1562
|
+
padEnd(dir.dirPath, 40) + healthColor(padEnd(dir.health.toFixed(0) + "%", 10)) + purityColor(padEnd(dir.purity.toFixed(0) + "%", 10)) + qualityColor(padEnd(qualityStr, 10)) + padEnd(`${dir.pureCount}/${total}`, 10)
|
|
1563
|
+
);
|
|
1564
|
+
} else {
|
|
1565
|
+
const relativePath = toRelativePath(dir.dirPath);
|
|
1566
|
+
console.log(
|
|
1567
|
+
padEnd(relativePath, 45) + healthColor(padEnd(dir.health.toFixed(0) + "%", 10)) + purityColor(padEnd(dir.purity.toFixed(0) + "%", 10)) + padEnd(`${dir.pureCount}/${total}`, 15)
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
console.log();
|
|
1572
|
+
}
|
|
1393
1573
|
function printConsoleReport(score, options = {}) {
|
|
1394
|
-
const { verbose = false } = options;
|
|
1574
|
+
const { verbose = false, dirDepth } = options;
|
|
1395
1575
|
printHeader();
|
|
1396
1576
|
printProjectSummary(score);
|
|
1397
1577
|
printStatusBreakdown(
|
|
@@ -1399,8 +1579,24 @@ function printConsoleReport(score, options = {}) {
|
|
|
1399
1579
|
score.pureCount + score.impureCount
|
|
1400
1580
|
);
|
|
1401
1581
|
printInsights(score);
|
|
1402
|
-
if (
|
|
1403
|
-
|
|
1582
|
+
if (dirDepth !== void 0) {
|
|
1583
|
+
const relativized = relativizeDirectoryPaths(
|
|
1584
|
+
score.directoryScores,
|
|
1585
|
+
process.cwd()
|
|
1586
|
+
);
|
|
1587
|
+
const rolledUp = rollupDirectoriesByDepth(relativized, dirDepth);
|
|
1588
|
+
printDirectoryTable(rolledUp, {
|
|
1589
|
+
title: `Directory Breakdown (depth=${dirDepth}):`,
|
|
1590
|
+
includeQualityColumn: true,
|
|
1591
|
+
sortBy: "preserve-order",
|
|
1592
|
+
emptyMessage: "No directories with functions found"
|
|
1593
|
+
});
|
|
1594
|
+
} else if (verbose) {
|
|
1595
|
+
printDirectoryTable(score.directoryScores, {
|
|
1596
|
+
title: "Directory Breakdown:",
|
|
1597
|
+
includeQualityColumn: false,
|
|
1598
|
+
sortBy: "health-asc"
|
|
1599
|
+
});
|
|
1404
1600
|
} else {
|
|
1405
1601
|
printWorstDirectories(score.directoryScores);
|
|
1406
1602
|
}
|
|
@@ -1420,8 +1616,8 @@ function printHeader() {
|
|
|
1420
1616
|
}
|
|
1421
1617
|
function printProjectSummary(score) {
|
|
1422
1618
|
const healthBar = createProgressBar(score.health, 25);
|
|
1423
|
-
const healthColor = score.health
|
|
1424
|
-
const purityColor = score.purity
|
|
1619
|
+
const healthColor = getMetricColor(score.health);
|
|
1620
|
+
const purityColor = getMetricColor(score.purity);
|
|
1425
1621
|
console.log(
|
|
1426
1622
|
`Project Health: ${healthColor(score.health.toFixed(0) + "%")} ${healthBar}`
|
|
1427
1623
|
);
|
|
@@ -1429,7 +1625,7 @@ function printProjectSummary(score) {
|
|
|
1429
1625
|
` Purity: ${purityColor(score.purity.toFixed(0) + "%")} (${score.pureCount} pure / ${score.pureCount + score.impureCount} total)`
|
|
1430
1626
|
);
|
|
1431
1627
|
if (score.impurityQuality !== null) {
|
|
1432
|
-
const qualityColor = score.impurityQuality
|
|
1628
|
+
const qualityColor = getMetricColor(score.impurityQuality);
|
|
1433
1629
|
console.log(
|
|
1434
1630
|
` Impurity Quality: ${qualityColor(score.impurityQuality.toFixed(0) + "%")} average`
|
|
1435
1631
|
);
|
|
@@ -1462,34 +1658,12 @@ function printInsights(score) {
|
|
|
1462
1658
|
console.log();
|
|
1463
1659
|
}
|
|
1464
1660
|
}
|
|
1465
|
-
function printDirectoryBreakdown(directories) {
|
|
1466
|
-
const sorted = [...directories].filter((d) => d.pureCount + d.impureCount > 0).sort((a, b) => a.health - b.health);
|
|
1467
|
-
if (sorted.length === 0) return;
|
|
1468
|
-
console.log(chalk.bold("Directory Breakdown:"));
|
|
1469
|
-
console.log(chalk.gray("\u2500".repeat(80)));
|
|
1470
|
-
console.log(
|
|
1471
|
-
chalk.gray(
|
|
1472
|
-
padEnd("Directory", 45) + padEnd("Health", 10) + padEnd("Purity", 10) + padEnd("Functions", 15)
|
|
1473
|
-
)
|
|
1474
|
-
);
|
|
1475
|
-
console.log(chalk.gray("\u2500".repeat(80)));
|
|
1476
|
-
for (const dir of sorted) {
|
|
1477
|
-
const healthColor = dir.health >= 70 ? chalk.green : dir.health >= 50 ? chalk.yellow : chalk.red;
|
|
1478
|
-
const purityColor = dir.purity >= 70 ? chalk.green : dir.purity >= 50 ? chalk.yellow : chalk.red;
|
|
1479
|
-
const relativePath = toRelativePath(dir.dirPath);
|
|
1480
|
-
const total = dir.pureCount + dir.impureCount;
|
|
1481
|
-
console.log(
|
|
1482
|
-
padEnd(relativePath, 45) + healthColor(padEnd(dir.health.toFixed(0) + "%", 10)) + purityColor(padEnd(dir.purity.toFixed(0) + "%", 10)) + padEnd(`${dir.pureCount}/${total}`, 15)
|
|
1483
|
-
);
|
|
1484
|
-
}
|
|
1485
|
-
console.log();
|
|
1486
|
-
}
|
|
1487
1661
|
function printWorstDirectories(directories) {
|
|
1488
1662
|
const sorted = [...directories].filter((d) => d.pureCount + d.impureCount > 0 && d.health < 70).sort((a, b) => a.health - b.health).slice(0, 5);
|
|
1489
1663
|
if (sorted.length === 0) return;
|
|
1490
1664
|
console.log(chalk.bold("Directories Needing Attention:"));
|
|
1491
1665
|
for (const dir of sorted) {
|
|
1492
|
-
const healthColor = dir.health
|
|
1666
|
+
const healthColor = getMetricColor(dir.health);
|
|
1493
1667
|
const total = dir.pureCount + dir.impureCount;
|
|
1494
1668
|
const relativePath = toRelativePath(dir.dirPath);
|
|
1495
1669
|
console.log(
|
|
@@ -1555,13 +1729,7 @@ function createProgressBar(percent, width) {
|
|
|
1555
1729
|
const filledChar = "\u2588";
|
|
1556
1730
|
const emptyChar = "\u2591";
|
|
1557
1731
|
const bar = filledChar.repeat(filled) + emptyChar.repeat(empty);
|
|
1558
|
-
|
|
1559
|
-
return chalk.green(bar);
|
|
1560
|
-
} else if (percent >= 50) {
|
|
1561
|
-
return chalk.yellow(bar);
|
|
1562
|
-
} else {
|
|
1563
|
-
return chalk.red(bar);
|
|
1564
|
-
}
|
|
1732
|
+
return getMetricColor(percent)(bar);
|
|
1565
1733
|
}
|
|
1566
1734
|
function generateSummaryLine(score) {
|
|
1567
1735
|
const parts = [
|
|
@@ -1573,21 +1741,29 @@ function generateSummaryLine(score) {
|
|
|
1573
1741
|
}
|
|
1574
1742
|
return parts.join(" | ");
|
|
1575
1743
|
}
|
|
1576
|
-
function toRelativePath(absolutePath) {
|
|
1577
|
-
const cwd = process.cwd();
|
|
1578
|
-
if (absolutePath.startsWith(cwd)) {
|
|
1579
|
-
const relative2 = path4.relative(cwd, absolutePath);
|
|
1580
|
-
return relative2 || ".";
|
|
1581
|
-
}
|
|
1582
|
-
return absolutePath;
|
|
1583
|
-
}
|
|
1584
1744
|
function padEnd(str, length) {
|
|
1585
1745
|
if (str.length >= length) return str.slice(0, length);
|
|
1586
1746
|
return str + " ".repeat(length - str.length);
|
|
1587
1747
|
}
|
|
1588
1748
|
function generateJsonReport(score, options = {}) {
|
|
1589
|
-
const {
|
|
1590
|
-
|
|
1749
|
+
const {
|
|
1750
|
+
pretty = true,
|
|
1751
|
+
includeFunction = true,
|
|
1752
|
+
dirDepth,
|
|
1753
|
+
projectRoot = process.cwd()
|
|
1754
|
+
} = options;
|
|
1755
|
+
let reportData = prepareReportData(score, includeFunction);
|
|
1756
|
+
if (dirDepth !== void 0) {
|
|
1757
|
+
const relativized = score.directoryScores.map((d) => ({
|
|
1758
|
+
...d,
|
|
1759
|
+
dirPath: path4.relative(projectRoot, d.dirPath).replace(/\\/g, "/")
|
|
1760
|
+
}));
|
|
1761
|
+
const rolledUp = rollupDirectoriesByDepth(relativized, dirDepth);
|
|
1762
|
+
reportData = {
|
|
1763
|
+
...reportData,
|
|
1764
|
+
rolledUpDirectories: rolledUp
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1591
1767
|
if (pretty) {
|
|
1592
1768
|
return JSON.stringify(reportData, null, 2);
|
|
1593
1769
|
}
|
|
@@ -1617,16 +1793,180 @@ function writeJsonReport(score, outputPath, options = {}) {
|
|
|
1617
1793
|
}
|
|
1618
1794
|
fs3.writeFileSync(outputPath, json, "utf-8");
|
|
1619
1795
|
}
|
|
1796
|
+
var VALID_FORMATS = ["console", "json", "summary"];
|
|
1797
|
+
var ThresholdSchema = z.number().min(0).max(100);
|
|
1798
|
+
var PositiveIntegerSchema = z.number().int().min(1);
|
|
1799
|
+
function validateCliFlags(flags) {
|
|
1800
|
+
if (flags.minHealth !== void 0) {
|
|
1801
|
+
const result = ThresholdSchema.safeParse(flags.minHealth);
|
|
1802
|
+
if (!result.success) {
|
|
1803
|
+
return {
|
|
1804
|
+
valid: false,
|
|
1805
|
+
error: "--min-health must be a number between 0 and 100"
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
if (flags.minPurity !== void 0) {
|
|
1810
|
+
const result = ThresholdSchema.safeParse(flags.minPurity);
|
|
1811
|
+
if (!result.success) {
|
|
1812
|
+
return {
|
|
1813
|
+
valid: false,
|
|
1814
|
+
error: "--min-purity must be a number between 0 and 100"
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
if (flags.minQuality !== void 0) {
|
|
1819
|
+
const result = ThresholdSchema.safeParse(flags.minQuality);
|
|
1820
|
+
if (!result.success) {
|
|
1821
|
+
return {
|
|
1822
|
+
valid: false,
|
|
1823
|
+
error: "--min-quality must be a number between 0 and 100"
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
if (!VALID_FORMATS.includes(flags.format)) {
|
|
1828
|
+
return {
|
|
1829
|
+
valid: false,
|
|
1830
|
+
error: `--format must be one of: ${VALID_FORMATS.join(", ")}`
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
if (flags.dirDepth !== void 0) {
|
|
1834
|
+
const result = PositiveIntegerSchema.safeParse(flags.dirDepth);
|
|
1835
|
+
if (!result.success) {
|
|
1836
|
+
return {
|
|
1837
|
+
valid: false,
|
|
1838
|
+
error: "--dir-depth must be a positive integer (1 or greater)"
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
return { valid: true };
|
|
1843
|
+
}
|
|
1844
|
+
function validateGlobPattern(pattern) {
|
|
1845
|
+
if (!pattern || pattern.trim() === "") {
|
|
1846
|
+
return { valid: false, error: "Glob pattern cannot be empty" };
|
|
1847
|
+
}
|
|
1848
|
+
if (pattern.startsWith("/")) {
|
|
1849
|
+
return {
|
|
1850
|
+
valid: false,
|
|
1851
|
+
error: "Glob pattern cannot be an absolute path (starting with /)"
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
let bracketDepth = 0;
|
|
1855
|
+
let braceDepth = 0;
|
|
1856
|
+
for (const char of pattern) {
|
|
1857
|
+
if (char === "[") bracketDepth++;
|
|
1858
|
+
if (char === "]") bracketDepth--;
|
|
1859
|
+
if (char === "{") braceDepth++;
|
|
1860
|
+
if (char === "}") braceDepth--;
|
|
1861
|
+
if (bracketDepth < 0 || braceDepth < 0) {
|
|
1862
|
+
return { valid: false, error: "Glob pattern has unbalanced brackets" };
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
if (bracketDepth !== 0 || braceDepth !== 0) {
|
|
1866
|
+
return { valid: false, error: "Glob pattern has unbalanced brackets" };
|
|
1867
|
+
}
|
|
1868
|
+
return { valid: true };
|
|
1869
|
+
}
|
|
1870
|
+
function buildAnalyzerConfig(tsconfigPath, flags) {
|
|
1871
|
+
const config = {
|
|
1872
|
+
tsconfigPath,
|
|
1873
|
+
format: flags.format,
|
|
1874
|
+
quiet: flags.quiet,
|
|
1875
|
+
verbose: flags.verbose
|
|
1876
|
+
};
|
|
1877
|
+
if (flags.files !== void 0) {
|
|
1878
|
+
config.filesGlob = flags.files;
|
|
1879
|
+
}
|
|
1880
|
+
if (flags.minHealth !== void 0) {
|
|
1881
|
+
config.minHealth = flags.minHealth;
|
|
1882
|
+
}
|
|
1883
|
+
if (flags.minPurity !== void 0) {
|
|
1884
|
+
config.minPurity = flags.minPurity;
|
|
1885
|
+
}
|
|
1886
|
+
if (flags.minQuality !== void 0) {
|
|
1887
|
+
config.minQuality = flags.minQuality;
|
|
1888
|
+
}
|
|
1889
|
+
if (flags.output !== void 0) {
|
|
1890
|
+
config.outputPath = flags.output;
|
|
1891
|
+
}
|
|
1892
|
+
if (flags.dirDepth !== void 0) {
|
|
1893
|
+
config.dirDepth = flags.dirDepth;
|
|
1894
|
+
}
|
|
1895
|
+
return config;
|
|
1896
|
+
}
|
|
1897
|
+
function buildThresholdConfig(flags) {
|
|
1898
|
+
const config = {};
|
|
1899
|
+
if (flags.minHealth !== void 0) {
|
|
1900
|
+
config.minHealth = flags.minHealth;
|
|
1901
|
+
}
|
|
1902
|
+
if (flags.minPurity !== void 0) {
|
|
1903
|
+
config.minPurity = flags.minPurity;
|
|
1904
|
+
}
|
|
1905
|
+
if (flags.minQuality !== void 0) {
|
|
1906
|
+
config.minQuality = flags.minQuality;
|
|
1907
|
+
}
|
|
1908
|
+
return config;
|
|
1909
|
+
}
|
|
1620
1910
|
|
|
1621
1911
|
// src/cli.ts
|
|
1622
1912
|
var EXIT_SUCCESS = 0;
|
|
1623
1913
|
var EXIT_THRESHOLD_FAILED = 1;
|
|
1624
1914
|
var EXIT_CONFIG_ERROR = 2;
|
|
1625
1915
|
var EXIT_ANALYSIS_ERROR = 3;
|
|
1626
|
-
var
|
|
1916
|
+
var require2 = createRequire(import.meta.url);
|
|
1917
|
+
var pkg = require2("../package.json");
|
|
1918
|
+
function handleAnalysisOutput(score, flags) {
|
|
1919
|
+
if (!flags.quiet) {
|
|
1920
|
+
if (flags.json || flags.format === "json") {
|
|
1921
|
+
const jsonOptions = {
|
|
1922
|
+
pretty: true
|
|
1923
|
+
};
|
|
1924
|
+
if (flags.dirDepth !== void 0) {
|
|
1925
|
+
jsonOptions.dirDepth = flags.dirDepth;
|
|
1926
|
+
}
|
|
1927
|
+
console.log(generateJsonReport(score, jsonOptions));
|
|
1928
|
+
} else if (flags.format === "summary") {
|
|
1929
|
+
console.log(generateSummaryLine(score));
|
|
1930
|
+
} else {
|
|
1931
|
+
const reportOptions = {
|
|
1932
|
+
verbose: flags.verbose
|
|
1933
|
+
};
|
|
1934
|
+
if (flags.dirDepth !== void 0) {
|
|
1935
|
+
reportOptions.dirDepth = flags.dirDepth;
|
|
1936
|
+
}
|
|
1937
|
+
printConsoleReport(score, reportOptions);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
if (flags.output) {
|
|
1941
|
+
const outputOptions = {
|
|
1942
|
+
pretty: true
|
|
1943
|
+
};
|
|
1944
|
+
if (flags.dirDepth !== void 0) {
|
|
1945
|
+
outputOptions.dirDepth = flags.dirDepth;
|
|
1946
|
+
}
|
|
1947
|
+
writeJsonReport(score, flags.output, outputOptions);
|
|
1948
|
+
if (!flags.quiet && flags.format !== "json") {
|
|
1949
|
+
console.log(chalk.green(`Report written to: ${flags.output}`));
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
function ensureOutputDirectory(outputPath) {
|
|
1954
|
+
const outputDir = path4.dirname(path4.resolve(outputPath));
|
|
1955
|
+
if (!fs3.existsSync(outputDir)) {
|
|
1956
|
+
try {
|
|
1957
|
+
fs3.mkdirSync(outputDir, { recursive: true });
|
|
1958
|
+
} catch {
|
|
1959
|
+
return {
|
|
1960
|
+
valid: false,
|
|
1961
|
+
error: `Cannot create output directory: ${outputDir}`
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
return { valid: true };
|
|
1966
|
+
}
|
|
1627
1967
|
var argv = cli({
|
|
1628
1968
|
name: "fcis",
|
|
1629
|
-
version:
|
|
1969
|
+
version: pkg.version,
|
|
1630
1970
|
flags: {
|
|
1631
1971
|
json: {
|
|
1632
1972
|
type: Boolean,
|
|
@@ -1671,6 +2011,10 @@ var argv = cli({
|
|
|
1671
2011
|
alias: "v",
|
|
1672
2012
|
description: "Show per-file scores and all classified functions",
|
|
1673
2013
|
default: false
|
|
2014
|
+
},
|
|
2015
|
+
dirDepth: {
|
|
2016
|
+
type: Number,
|
|
2017
|
+
description: "Roll up directory metrics to depth N (e.g., 1 for top-level)"
|
|
1674
2018
|
}
|
|
1675
2019
|
},
|
|
1676
2020
|
parameters: ["<tsconfig>"],
|
|
@@ -1699,115 +2043,42 @@ async function main() {
|
|
|
1699
2043
|
);
|
|
1700
2044
|
process.exit(EXIT_CONFIG_ERROR);
|
|
1701
2045
|
}
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
JSON.parse(strippedContent);
|
|
1706
|
-
} catch (e) {
|
|
2046
|
+
const tsconfigContent = fs3.readFileSync(absoluteTsconfigPath, "utf-8");
|
|
2047
|
+
const tsconfigValidation = validateTsconfigContent(tsconfigContent);
|
|
2048
|
+
if (!tsconfigValidation.valid) {
|
|
1707
2049
|
console.error(
|
|
1708
2050
|
chalk.red(`Error: Invalid tsconfig.json at ${absoluteTsconfigPath}`)
|
|
1709
2051
|
);
|
|
1710
|
-
console.error(
|
|
2052
|
+
console.error(tsconfigValidation.error);
|
|
1711
2053
|
process.exit(EXIT_CONFIG_ERROR);
|
|
1712
2054
|
}
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
chalk.red("Error: --min-health must be a number between 0 and 100")
|
|
1718
|
-
);
|
|
1719
|
-
process.exit(EXIT_CONFIG_ERROR);
|
|
1720
|
-
}
|
|
1721
|
-
}
|
|
1722
|
-
if (flags.minPurity !== void 0) {
|
|
1723
|
-
const result = ThresholdSchema.safeParse(flags.minPurity);
|
|
1724
|
-
if (!result.success) {
|
|
1725
|
-
console.error(
|
|
1726
|
-
chalk.red("Error: --min-purity must be a number between 0 and 100")
|
|
1727
|
-
);
|
|
1728
|
-
process.exit(EXIT_CONFIG_ERROR);
|
|
1729
|
-
}
|
|
2055
|
+
const flagsValidation = validateCliFlags(flags);
|
|
2056
|
+
if (!flagsValidation.valid) {
|
|
2057
|
+
console.error(chalk.red(`Error: ${flagsValidation.error}`));
|
|
2058
|
+
process.exit(EXIT_CONFIG_ERROR);
|
|
1730
2059
|
}
|
|
1731
|
-
if (flags.
|
|
1732
|
-
const
|
|
1733
|
-
if (!
|
|
1734
|
-
console.error(
|
|
1735
|
-
chalk.red("Error: --min-quality must be a number between 0 and 100")
|
|
1736
|
-
);
|
|
2060
|
+
if (flags.files !== void 0) {
|
|
2061
|
+
const globValidation = validateGlobPattern(flags.files);
|
|
2062
|
+
if (!globValidation.valid) {
|
|
2063
|
+
console.error(chalk.red(`Error: ${globValidation.error}`));
|
|
1737
2064
|
process.exit(EXIT_CONFIG_ERROR);
|
|
1738
2065
|
}
|
|
1739
2066
|
}
|
|
1740
|
-
const validFormats = ["console", "json", "summary"];
|
|
1741
|
-
if (!validFormats.includes(flags.format)) {
|
|
1742
|
-
console.error(
|
|
1743
|
-
chalk.red(`Error: --format must be one of: ${validFormats.join(", ")}`)
|
|
1744
|
-
);
|
|
1745
|
-
process.exit(EXIT_CONFIG_ERROR);
|
|
1746
|
-
}
|
|
1747
2067
|
if (flags.output) {
|
|
1748
|
-
const
|
|
1749
|
-
if (!
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
} catch (e) {
|
|
1753
|
-
console.error(
|
|
1754
|
-
chalk.red(`Error: Cannot create output directory: ${outputDir}`)
|
|
1755
|
-
);
|
|
1756
|
-
process.exit(EXIT_CONFIG_ERROR);
|
|
1757
|
-
}
|
|
2068
|
+
const outputValidation = ensureOutputDirectory(flags.output);
|
|
2069
|
+
if (!outputValidation.valid) {
|
|
2070
|
+
console.error(chalk.red(`Error: ${outputValidation.error}`));
|
|
2071
|
+
process.exit(EXIT_CONFIG_ERROR);
|
|
1758
2072
|
}
|
|
1759
2073
|
}
|
|
1760
2074
|
try {
|
|
1761
2075
|
if (!flags.quiet && flags.format === "console") {
|
|
1762
2076
|
console.log(chalk.gray(`Analyzing ${process.cwd()}...`));
|
|
1763
2077
|
}
|
|
1764
|
-
const config =
|
|
1765
|
-
tsconfigPath: absoluteTsconfigPath,
|
|
1766
|
-
format: flags.format,
|
|
1767
|
-
quiet: flags.quiet,
|
|
1768
|
-
verbose: flags.verbose
|
|
1769
|
-
};
|
|
1770
|
-
if (flags.files !== void 0) {
|
|
1771
|
-
config.filesGlob = flags.files;
|
|
1772
|
-
}
|
|
1773
|
-
if (flags.minHealth !== void 0) {
|
|
1774
|
-
config.minHealth = flags.minHealth;
|
|
1775
|
-
}
|
|
1776
|
-
if (flags.minPurity !== void 0) {
|
|
1777
|
-
config.minPurity = flags.minPurity;
|
|
1778
|
-
}
|
|
1779
|
-
if (flags.minQuality !== void 0) {
|
|
1780
|
-
config.minQuality = flags.minQuality;
|
|
1781
|
-
}
|
|
1782
|
-
if (flags.output !== void 0) {
|
|
1783
|
-
config.outputPath = flags.output;
|
|
1784
|
-
}
|
|
2078
|
+
const config = buildAnalyzerConfig(absoluteTsconfigPath, flags);
|
|
1785
2079
|
const score = await analyze(config);
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
console.log(generateJsonReport(score, { pretty: true }));
|
|
1789
|
-
} else if (flags.format === "summary") {
|
|
1790
|
-
console.log(generateSummaryLine(score));
|
|
1791
|
-
} else {
|
|
1792
|
-
printConsoleReport(score, { verbose: flags.verbose });
|
|
1793
|
-
}
|
|
1794
|
-
}
|
|
1795
|
-
if (flags.output) {
|
|
1796
|
-
writeJsonReport(score, flags.output, { pretty: true });
|
|
1797
|
-
if (!flags.quiet && flags.format !== "json") {
|
|
1798
|
-
console.log(chalk.green(`Report written to: ${flags.output}`));
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
const thresholdConfig = {};
|
|
1802
|
-
if (flags.minHealth !== void 0) {
|
|
1803
|
-
thresholdConfig.minHealth = flags.minHealth;
|
|
1804
|
-
}
|
|
1805
|
-
if (flags.minPurity !== void 0) {
|
|
1806
|
-
thresholdConfig.minPurity = flags.minPurity;
|
|
1807
|
-
}
|
|
1808
|
-
if (flags.minQuality !== void 0) {
|
|
1809
|
-
thresholdConfig.minQuality = flags.minQuality;
|
|
1810
|
-
}
|
|
2080
|
+
handleAnalysisOutput(score, flags);
|
|
2081
|
+
const thresholdConfig = buildThresholdConfig(flags);
|
|
1811
2082
|
const thresholdResult = checkThresholds(score, thresholdConfig);
|
|
1812
2083
|
if (!thresholdResult.passed) {
|
|
1813
2084
|
if (!flags.quiet) {
|