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