fcis 0.1.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/001-fcis-analyzer.md +832 -0
- package/.plans/002-fcis-analyzer-improvements.md +205 -0
- package/README.md +272 -0
- package/TECHNICAL.md +386 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1836 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +709 -0
- package/dist/index.js +1845 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/pnpm-workspace.yaml +0 -0
- package/src/analyzer.ts +266 -0
- package/src/classification/classifier.ts +156 -0
- package/src/classification/derive-status.ts +171 -0
- package/src/classification/quality-scorer.ts +481 -0
- package/src/cli.ts +286 -0
- package/src/detection/detect-markers.ts +480 -0
- package/src/detection/markers.ts +332 -0
- package/src/extraction/extract-functions.ts +570 -0
- package/src/extraction/extractor.ts +188 -0
- package/src/index.ts +111 -0
- package/src/reporting/report-console.ts +416 -0
- package/src/reporting/report-json.ts +232 -0
- package/src/scoring/scorer.ts +504 -0
- package/src/types.ts +248 -0
- package/tests/classifier.test.ts +480 -0
- package/tests/derive-status.test.ts +464 -0
- package/tests/detect-markers.test.ts +639 -0
- package/tests/extractor.test.ts +155 -0
- package/tests/integration.test.ts +706 -0
- package/tests/quality-scorer.test.ts +650 -0
- package/tests/scorer.test.ts +768 -0
- package/tsconfig.json +34 -0
- package/tsup.config.ts +17 -0
- package/vendor/ts-morph/.editorconfig +10 -0
- package/vendor/ts-morph/.gitattributes +11 -0
- package/vendor/ts-morph/.github/CODE_OF_CONDUCT.md +77 -0
- package/vendor/ts-morph/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
- package/vendor/ts-morph/.github/ISSUE_TEMPLATE/custom.md +4 -0
- package/vendor/ts-morph/.github/ISSUE_TEMPLATE/feature_request.md +18 -0
- package/vendor/ts-morph/.github/workflows/ci.yml +50 -0
- package/vendor/ts-morph/.github/workflows/publish.yml +53 -0
- package/vendor/ts-morph/.vscode/settings.json +10 -0
- package/vendor/ts-morph/CONTRIBUTING.md +23 -0
- package/vendor/ts-morph/DEVELOPMENT.md +32 -0
- package/vendor/ts-morph/LICENSE +21 -0
- package/vendor/ts-morph/deno.json +8 -0
- package/vendor/ts-morph/deno.lock +1233 -0
- package/vendor/ts-morph/docs/CNAME +1 -0
- package/vendor/ts-morph/docs/Gemfile +2 -0
- package/vendor/ts-morph/docs/_config.yml +5 -0
- package/vendor/ts-morph/docs/_layouts/default.html +159 -0
- package/vendor/ts-morph/docs/_script-templates/main.ts +116 -0
- package/vendor/ts-morph/docs/assets/css/style.scss +212 -0
- package/vendor/ts-morph/docs/details/ambient.md +38 -0
- package/vendor/ts-morph/docs/details/async.md +31 -0
- package/vendor/ts-morph/docs/details/classes.md +314 -0
- package/vendor/ts-morph/docs/details/comment-ranges.md +7 -0
- package/vendor/ts-morph/docs/details/comments.md +122 -0
- package/vendor/ts-morph/docs/details/decorators.md +119 -0
- package/vendor/ts-morph/docs/details/documentation.md +73 -0
- package/vendor/ts-morph/docs/details/enums.md +117 -0
- package/vendor/ts-morph/docs/details/exports.md +308 -0
- package/vendor/ts-morph/docs/details/expressions.md +46 -0
- package/vendor/ts-morph/docs/details/functions.md +150 -0
- package/vendor/ts-morph/docs/details/generators.md +27 -0
- package/vendor/ts-morph/docs/details/identifiers.md +79 -0
- package/vendor/ts-morph/docs/details/imports.md +191 -0
- package/vendor/ts-morph/docs/details/index.md +52 -0
- package/vendor/ts-morph/docs/details/initializers.md +40 -0
- package/vendor/ts-morph/docs/details/interfaces.md +218 -0
- package/vendor/ts-morph/docs/details/literals.md +20 -0
- package/vendor/ts-morph/docs/details/modifiers.md +38 -0
- package/vendor/ts-morph/docs/details/modules.md +113 -0
- package/vendor/ts-morph/docs/details/namespaces.md +7 -0
- package/vendor/ts-morph/docs/details/object-literal-expressions.md +106 -0
- package/vendor/ts-morph/docs/details/parameters.md +64 -0
- package/vendor/ts-morph/docs/details/signatures.md +41 -0
- package/vendor/ts-morph/docs/details/source-files.md +292 -0
- package/vendor/ts-morph/docs/details/type-aliases.md +34 -0
- package/vendor/ts-morph/docs/details/type-parameters.md +72 -0
- package/vendor/ts-morph/docs/details/types.md +254 -0
- package/vendor/ts-morph/docs/details/variables.md +110 -0
- package/vendor/ts-morph/docs/emitting.md +151 -0
- package/vendor/ts-morph/docs/index.md +25 -0
- package/vendor/ts-morph/docs/manipulation/code-writer.md +20 -0
- package/vendor/ts-morph/docs/manipulation/formatting.md +76 -0
- package/vendor/ts-morph/docs/manipulation/index.md +136 -0
- package/vendor/ts-morph/docs/manipulation/order.md +14 -0
- package/vendor/ts-morph/docs/manipulation/performance.md +222 -0
- package/vendor/ts-morph/docs/manipulation/removing.md +31 -0
- package/vendor/ts-morph/docs/manipulation/renaming.md +106 -0
- package/vendor/ts-morph/docs/manipulation/settings.md +76 -0
- package/vendor/ts-morph/docs/manipulation/structures.md +117 -0
- package/vendor/ts-morph/docs/manipulation/transforms.md +84 -0
- package/vendor/ts-morph/docs/metrics/performance.json +4 -0
- package/vendor/ts-morph/docs/navigation/ambient-modules.md +22 -0
- package/vendor/ts-morph/docs/navigation/compiler-nodes.md +82 -0
- package/vendor/ts-morph/docs/navigation/directories.md +287 -0
- package/vendor/ts-morph/docs/navigation/example.md +50 -0
- package/vendor/ts-morph/docs/navigation/finding-references.md +53 -0
- package/vendor/ts-morph/docs/navigation/getting-source-files.md +59 -0
- package/vendor/ts-morph/docs/navigation/images/getChildrenVsForEachChild.gif +0 -0
- package/vendor/ts-morph/docs/navigation/index.md +94 -0
- package/vendor/ts-morph/docs/navigation/language-service.md +23 -0
- package/vendor/ts-morph/docs/navigation/program.md +25 -0
- package/vendor/ts-morph/docs/navigation/type-checker.md +33 -0
- package/vendor/ts-morph/docs/setup/adding-source-files.md +145 -0
- package/vendor/ts-morph/docs/setup/ast-viewers.md +46 -0
- package/vendor/ts-morph/docs/setup/diagnostics.md +109 -0
- package/vendor/ts-morph/docs/setup/file-system.md +106 -0
- package/vendor/ts-morph/docs/setup/images/atom-ast.png +0 -0
- package/vendor/ts-morph/docs/setup/images/atom-ast_small.png +0 -0
- package/vendor/ts-morph/docs/setup/images/atom-command-palette.png +0 -0
- package/vendor/ts-morph/docs/setup/images/atom-file.png +0 -0
- package/vendor/ts-morph/docs/setup/images/ts-ast-viewer.png +0 -0
- package/vendor/ts-morph/docs/setup/index.md +94 -0
- package/vendor/ts-morph/docs/utilities.md +55 -0
- package/vendor/ts-morph/dprint.json +23 -0
- package/vendor/ts-morph/package.json +30 -0
- package/vendor/ts-morph/packages/bootstrap/LICENSE +21 -0
- package/vendor/ts-morph/packages/bootstrap/lib/ts-morph-bootstrap.d.ts +397 -0
- package/vendor/ts-morph/packages/bootstrap/package.json +46 -0
- package/vendor/ts-morph/packages/bootstrap/readme.md +200 -0
- package/vendor/ts-morph/packages/common/LICENSE +21 -0
- package/vendor/ts-morph/packages/common/lib/ts-morph-common.d.ts +1082 -0
- package/vendor/ts-morph/packages/common/lib/typescript.d.ts +11439 -0
- package/vendor/ts-morph/packages/common/package.json +65 -0
- package/vendor/ts-morph/packages/common/readme.md +5 -0
- package/vendor/ts-morph/packages/scripts/changeTypeScriptVersion.ts +28 -0
- package/vendor/ts-morph/packages/scripts/createDeclarationProject.ts +47 -0
- package/vendor/ts-morph/packages/scripts/deps.ts +2 -0
- package/vendor/ts-morph/packages/scripts/execScript.ts +31 -0
- package/vendor/ts-morph/packages/scripts/folders.ts +11 -0
- package/vendor/ts-morph/packages/scripts/getDevCompilerVersions.ts +19 -0
- package/vendor/ts-morph/packages/scripts/mod.ts +7 -0
- package/vendor/ts-morph/packages/scripts/utils/Memoize.ts +36 -0
- package/vendor/ts-morph/packages/scripts/utils/forEachTypeText.ts +23 -0
- package/vendor/ts-morph/packages/scripts/utils/makeConstructorsPrivate.ts +26 -0
- package/vendor/ts-morph/packages/scripts/utils/mod.ts +4 -0
- package/vendor/ts-morph/packages/scripts/utils/printDiagnostics.ts +10 -0
- package/vendor/ts-morph/packages/ts-morph/LICENSE +21 -0
- package/vendor/ts-morph/packages/ts-morph/lib/ts-morph.d.ts +11198 -0
- package/vendor/ts-morph/packages/ts-morph/package.json +78 -0
- package/vendor/ts-morph/packages/ts-morph/readme.md +111 -0
- package/vendor/ts-morph/readme.md +14 -0
- package/vendor/ts-morph/rfcs/README.md +13 -0
- package/vendor/ts-morph/rfcs/RFC-0001 - Inserting Into Statements Handling Comments.md +181 -0
- package/vendor/ts-morph/tsconfig.common.json +17 -0
- package/vitest.config.ts +16 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1836 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cli } from 'cleye';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import * as fs3 from 'fs';
|
|
6
|
+
import * as path4 from 'path';
|
|
7
|
+
import { Project, SyntaxKind } from 'ts-morph';
|
|
8
|
+
|
|
9
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
10
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
11
|
+
}) : x)(function(x) {
|
|
12
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
13
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// src/types.ts
|
|
17
|
+
var DEFAULT_QUALITY_THRESHOLDS = {
|
|
18
|
+
okThreshold: 70,
|
|
19
|
+
reviewThreshold: 40
|
|
20
|
+
};
|
|
21
|
+
function extractFunctions(sourceFile) {
|
|
22
|
+
const functions = [];
|
|
23
|
+
const filePath = sourceFile.getFilePath();
|
|
24
|
+
const addedPositions = /* @__PURE__ */ new Set();
|
|
25
|
+
const addFunction = (fn) => {
|
|
26
|
+
const posKey = `${fn.startLine}:${fn.endLine}`;
|
|
27
|
+
if (!addedPositions.has(posKey)) {
|
|
28
|
+
addedPositions.add(posKey);
|
|
29
|
+
functions.push(fn);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
for (const func of sourceFile.getFunctions()) {
|
|
33
|
+
addFunction(extractFunctionData(func, filePath, "function"));
|
|
34
|
+
}
|
|
35
|
+
for (const classDecl of sourceFile.getClasses()) {
|
|
36
|
+
const className = classDecl.getName() ?? null;
|
|
37
|
+
for (const method of classDecl.getMethods()) {
|
|
38
|
+
addFunction(extractFunctionData(method, filePath, "method", className));
|
|
39
|
+
}
|
|
40
|
+
for (const getter of classDecl.getGetAccessors()) {
|
|
41
|
+
addFunction(extractFunctionData(getter, filePath, "getter", className));
|
|
42
|
+
}
|
|
43
|
+
for (const setter of classDecl.getSetAccessors()) {
|
|
44
|
+
addFunction(extractFunctionData(setter, filePath, "setter", className));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const varStatement of sourceFile.getVariableStatements()) {
|
|
48
|
+
const isExported = varStatement.isExported();
|
|
49
|
+
for (const decl of varStatement.getDeclarations()) {
|
|
50
|
+
const initializer = decl.getInitializer();
|
|
51
|
+
const varName = decl.getName();
|
|
52
|
+
if (initializer) {
|
|
53
|
+
if (initializer.getKind() === SyntaxKind.ArrowFunction) {
|
|
54
|
+
addFunction(
|
|
55
|
+
extractFunctionData(
|
|
56
|
+
initializer,
|
|
57
|
+
filePath,
|
|
58
|
+
"arrow",
|
|
59
|
+
varName,
|
|
60
|
+
isExported
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
} else if (initializer.getKind() === SyntaxKind.FunctionExpression) {
|
|
64
|
+
addFunction(
|
|
65
|
+
extractFunctionData(
|
|
66
|
+
initializer,
|
|
67
|
+
filePath,
|
|
68
|
+
"function-expression",
|
|
69
|
+
varName,
|
|
70
|
+
isExported
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const defaultExport = sourceFile.getDefaultExportSymbol();
|
|
78
|
+
if (defaultExport) {
|
|
79
|
+
const declarations = defaultExport.getDeclarations();
|
|
80
|
+
for (const decl of declarations) {
|
|
81
|
+
if (decl.getKind() === SyntaxKind.ArrowFunction) {
|
|
82
|
+
addFunction(
|
|
83
|
+
extractFunctionData(
|
|
84
|
+
decl,
|
|
85
|
+
filePath,
|
|
86
|
+
"arrow",
|
|
87
|
+
"default",
|
|
88
|
+
true
|
|
89
|
+
)
|
|
90
|
+
);
|
|
91
|
+
} else if (decl.getKind() === SyntaxKind.FunctionExpression) {
|
|
92
|
+
addFunction(
|
|
93
|
+
extractFunctionData(
|
|
94
|
+
decl,
|
|
95
|
+
filePath,
|
|
96
|
+
"function-expression",
|
|
97
|
+
"default",
|
|
98
|
+
true
|
|
99
|
+
)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
sourceFile.forEachDescendant((node) => {
|
|
105
|
+
if (node.getKind() === SyntaxKind.ArrowFunction) {
|
|
106
|
+
const arrowFn = node;
|
|
107
|
+
const parentContext = inferParentContext(arrowFn);
|
|
108
|
+
addFunction(
|
|
109
|
+
extractFunctionData(arrowFn, filePath, "arrow", parentContext, false)
|
|
110
|
+
);
|
|
111
|
+
} else if (node.getKind() === SyntaxKind.FunctionExpression) {
|
|
112
|
+
const funcExpr = node;
|
|
113
|
+
const parentContext = inferParentContext(funcExpr);
|
|
114
|
+
addFunction(
|
|
115
|
+
extractFunctionData(
|
|
116
|
+
funcExpr,
|
|
117
|
+
filePath,
|
|
118
|
+
"function-expression",
|
|
119
|
+
parentContext,
|
|
120
|
+
false
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return functions;
|
|
126
|
+
}
|
|
127
|
+
function inferParentContext(node) {
|
|
128
|
+
const parent = node.getParent();
|
|
129
|
+
if (!parent) return null;
|
|
130
|
+
if (parent.getKind() === SyntaxKind.CallExpression) {
|
|
131
|
+
const callExpr = parent;
|
|
132
|
+
const expression = callExpr.getExpression();
|
|
133
|
+
if (expression.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
134
|
+
const propAccess = expression;
|
|
135
|
+
return propAccess.getName();
|
|
136
|
+
}
|
|
137
|
+
if (expression.getKind() === SyntaxKind.Identifier) {
|
|
138
|
+
return expression.getText();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (parent.getKind() === SyntaxKind.PropertyAssignment) {
|
|
142
|
+
const propAssignment = parent;
|
|
143
|
+
return propAssignment.getName();
|
|
144
|
+
}
|
|
145
|
+
if (parent.getKind() === SyntaxKind.ShorthandPropertyAssignment) {
|
|
146
|
+
const shorthand = parent;
|
|
147
|
+
return shorthand.getName();
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
function extractFunctionData(node, filePath, kind, parentContext = null, isExportedOverride) {
|
|
152
|
+
const startLine = node.getStartLineNumber();
|
|
153
|
+
const endLine = node.getEndLineNumber();
|
|
154
|
+
const bodyLineCount = endLine - startLine + 1;
|
|
155
|
+
let name = null;
|
|
156
|
+
if ("getName" in node && typeof node.getName === "function") {
|
|
157
|
+
name = node.getName() ?? null;
|
|
158
|
+
}
|
|
159
|
+
if (name === null && parentContext !== null) {
|
|
160
|
+
name = parentContext;
|
|
161
|
+
}
|
|
162
|
+
const isAsync = "isAsync" in node ? node.isAsync?.() ?? false : false;
|
|
163
|
+
let isExported = isExportedOverride ?? false;
|
|
164
|
+
if (!isExported && "isExported" in node) {
|
|
165
|
+
isExported = node.isExported?.() ?? false;
|
|
166
|
+
}
|
|
167
|
+
const body = getBodyNode(node);
|
|
168
|
+
const statementCount = countStatements(body);
|
|
169
|
+
const hasConditionals = checkForConditionals(body);
|
|
170
|
+
const callSites = extractCallSites(node, startLine);
|
|
171
|
+
const hasAwait = callSites.some((cs) => cs.isAwaited) || checkForAwait(node);
|
|
172
|
+
const propertyAccessChains = extractPropertyAccessChains(node);
|
|
173
|
+
return {
|
|
174
|
+
name,
|
|
175
|
+
filePath,
|
|
176
|
+
startLine,
|
|
177
|
+
endLine,
|
|
178
|
+
isAsync,
|
|
179
|
+
isExported,
|
|
180
|
+
bodyLineCount,
|
|
181
|
+
statementCount,
|
|
182
|
+
hasConditionals,
|
|
183
|
+
parentContext,
|
|
184
|
+
callSites,
|
|
185
|
+
hasAwait,
|
|
186
|
+
propertyAccessChains,
|
|
187
|
+
kind
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function getBodyNode(node) {
|
|
191
|
+
if ("getBody" in node) {
|
|
192
|
+
return node.getBody();
|
|
193
|
+
}
|
|
194
|
+
return void 0;
|
|
195
|
+
}
|
|
196
|
+
function countStatements(body) {
|
|
197
|
+
if (!body) return 0;
|
|
198
|
+
if (body.getKind() === SyntaxKind.Block) {
|
|
199
|
+
const statements = body.getChildrenOfKind(SyntaxKind.SyntaxList);
|
|
200
|
+
if (statements.length > 0 && statements[0]) {
|
|
201
|
+
return statements[0].getChildren().filter(isStatement).length;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return 1;
|
|
205
|
+
}
|
|
206
|
+
function isStatement(node) {
|
|
207
|
+
const kind = node.getKind();
|
|
208
|
+
return kind === SyntaxKind.VariableStatement || kind === SyntaxKind.ExpressionStatement || kind === SyntaxKind.ReturnStatement || kind === SyntaxKind.IfStatement || kind === SyntaxKind.ForStatement || kind === SyntaxKind.ForInStatement || kind === SyntaxKind.ForOfStatement || kind === SyntaxKind.WhileStatement || kind === SyntaxKind.DoStatement || kind === SyntaxKind.SwitchStatement || kind === SyntaxKind.TryStatement || kind === SyntaxKind.ThrowStatement || kind === SyntaxKind.BreakStatement || kind === SyntaxKind.ContinueStatement;
|
|
209
|
+
}
|
|
210
|
+
function checkForConditionals(body) {
|
|
211
|
+
if (!body) return false;
|
|
212
|
+
let hasConditionals = false;
|
|
213
|
+
body.forEachDescendant((node) => {
|
|
214
|
+
const kind = node.getKind();
|
|
215
|
+
if (kind === SyntaxKind.IfStatement || kind === SyntaxKind.ConditionalExpression || kind === SyntaxKind.SwitchStatement) {
|
|
216
|
+
hasConditionals = true;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
return hasConditionals;
|
|
220
|
+
}
|
|
221
|
+
function checkForAwait(node) {
|
|
222
|
+
let hasAwait = false;
|
|
223
|
+
node.forEachDescendant((descendant, traversal) => {
|
|
224
|
+
const kind = descendant.getKind();
|
|
225
|
+
if (kind === SyntaxKind.FunctionDeclaration || kind === SyntaxKind.FunctionExpression || kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.MethodDeclaration) {
|
|
226
|
+
traversal.skip();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (kind === SyntaxKind.AwaitExpression) {
|
|
230
|
+
hasAwait = true;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
return hasAwait;
|
|
234
|
+
}
|
|
235
|
+
function extractCallSites(node, functionStartLine) {
|
|
236
|
+
const callSites = [];
|
|
237
|
+
node.forEachDescendant((descendant, traversal) => {
|
|
238
|
+
const kind = descendant.getKind();
|
|
239
|
+
if (kind === SyntaxKind.FunctionDeclaration || kind === SyntaxKind.FunctionExpression || kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.MethodDeclaration) {
|
|
240
|
+
traversal.skip();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (kind === SyntaxKind.CallExpression) {
|
|
244
|
+
const callExpr = descendant;
|
|
245
|
+
const expression = getCallExpressionText(callExpr);
|
|
246
|
+
const line = callExpr.getStartLineNumber() - functionStartLine;
|
|
247
|
+
const parent = callExpr.getParent();
|
|
248
|
+
const isAwaited = parent?.getKind() === SyntaxKind.AwaitExpression;
|
|
249
|
+
callSites.push({
|
|
250
|
+
expression,
|
|
251
|
+
line,
|
|
252
|
+
isAwaited
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
return callSites;
|
|
257
|
+
}
|
|
258
|
+
function getCallExpressionText(callExpr) {
|
|
259
|
+
const expression = callExpr.getExpression();
|
|
260
|
+
if (expression.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
261
|
+
return getPropertyAccessChainText(expression);
|
|
262
|
+
}
|
|
263
|
+
return expression.getText();
|
|
264
|
+
}
|
|
265
|
+
function getPropertyAccessChainText(node) {
|
|
266
|
+
const parts = [];
|
|
267
|
+
let current = node;
|
|
268
|
+
while (current.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
269
|
+
const propAccess = current;
|
|
270
|
+
parts.unshift(propAccess.getName());
|
|
271
|
+
current = propAccess.getExpression();
|
|
272
|
+
}
|
|
273
|
+
parts.unshift(current.getText());
|
|
274
|
+
return parts.join(".");
|
|
275
|
+
}
|
|
276
|
+
function extractPropertyAccessChains(node) {
|
|
277
|
+
const chains = /* @__PURE__ */ new Set();
|
|
278
|
+
node.forEachDescendant((descendant, traversal) => {
|
|
279
|
+
const kind = descendant.getKind();
|
|
280
|
+
if (kind === SyntaxKind.FunctionDeclaration || kind === SyntaxKind.FunctionExpression || kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.MethodDeclaration) {
|
|
281
|
+
traversal.skip();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (kind === SyntaxKind.PropertyAccessExpression) {
|
|
285
|
+
const propAccess = descendant;
|
|
286
|
+
const parent = propAccess.getParent();
|
|
287
|
+
if (parent?.getKind() !== SyntaxKind.PropertyAccessExpression) {
|
|
288
|
+
const chain = getPropertyAccessChainText(propAccess);
|
|
289
|
+
if (chain.includes(".")) {
|
|
290
|
+
chains.add(chain);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
return Array.from(chains);
|
|
296
|
+
}
|
|
297
|
+
function extractImports(sourceFile) {
|
|
298
|
+
const filePath = sourceFile.getFilePath();
|
|
299
|
+
const imports = [];
|
|
300
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
301
|
+
const moduleSpecifier = importDecl.getModuleSpecifierValue();
|
|
302
|
+
const namedImports = [];
|
|
303
|
+
for (const namedImport of importDecl.getNamedImports()) {
|
|
304
|
+
namedImports.push(namedImport.getName());
|
|
305
|
+
}
|
|
306
|
+
const defaultImport = importDecl.getDefaultImport();
|
|
307
|
+
if (defaultImport) {
|
|
308
|
+
namedImports.push(defaultImport.getText());
|
|
309
|
+
}
|
|
310
|
+
const namespaceImport = importDecl.getNamespaceImport();
|
|
311
|
+
if (namespaceImport) {
|
|
312
|
+
namedImports.push(namespaceImport.getText());
|
|
313
|
+
}
|
|
314
|
+
imports.push({
|
|
315
|
+
moduleSpecifier,
|
|
316
|
+
namedImports
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
filePath,
|
|
321
|
+
imports
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function isTypeOnlyFile(sourceFile) {
|
|
325
|
+
if (sourceFile.getFunctions().length > 0) return false;
|
|
326
|
+
for (const classDecl of sourceFile.getClasses()) {
|
|
327
|
+
if (classDecl.getMethods().length > 0) return false;
|
|
328
|
+
if (classDecl.getGetAccessors().length > 0) return false;
|
|
329
|
+
if (classDecl.getSetAccessors().length > 0) return false;
|
|
330
|
+
}
|
|
331
|
+
for (const varStatement of sourceFile.getVariableStatements()) {
|
|
332
|
+
for (const decl of varStatement.getDeclarations()) {
|
|
333
|
+
const initializer = decl.getInitializer();
|
|
334
|
+
if (initializer) {
|
|
335
|
+
const kind = initializer.getKind();
|
|
336
|
+
if (kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.FunctionExpression) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
function stripJsonComments(jsonString) {
|
|
345
|
+
let result = "";
|
|
346
|
+
let inString = false;
|
|
347
|
+
let inLineComment = false;
|
|
348
|
+
let inBlockComment = false;
|
|
349
|
+
let i = 0;
|
|
350
|
+
while (i < jsonString.length) {
|
|
351
|
+
const char = jsonString[i];
|
|
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++;
|
|
394
|
+
}
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
function loadProject(options) {
|
|
398
|
+
const { tsconfigPath, filesGlob } = options;
|
|
399
|
+
const errors = [];
|
|
400
|
+
const absoluteTsconfigPath = path4.resolve(tsconfigPath);
|
|
401
|
+
if (!fs3.existsSync(absoluteTsconfigPath)) {
|
|
402
|
+
throw new Error(`tsconfig.json not found at: ${absoluteTsconfigPath}`);
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
const content = fs3.readFileSync(absoluteTsconfigPath, "utf-8");
|
|
406
|
+
const strippedContent = stripJsonComments(content);
|
|
407
|
+
JSON.parse(strippedContent);
|
|
408
|
+
} catch (e) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`Invalid tsconfig.json at ${absoluteTsconfigPath}: ${e instanceof Error ? e.message : String(e)}`
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
const project = new Project({
|
|
414
|
+
tsConfigFilePath: absoluteTsconfigPath,
|
|
415
|
+
skipAddingFilesFromTsConfig: false
|
|
416
|
+
});
|
|
417
|
+
let sourceFiles = project.getSourceFiles().filter((sf) => {
|
|
418
|
+
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/");
|
|
420
|
+
});
|
|
421
|
+
if (filesGlob) {
|
|
422
|
+
const globPattern = createGlobMatcher(filesGlob);
|
|
423
|
+
sourceFiles = sourceFiles.filter((sf) => globPattern(sf.getFilePath()));
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
project,
|
|
427
|
+
sourceFiles,
|
|
428
|
+
errors
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
function createGlobMatcher(glob) {
|
|
432
|
+
const regexStr = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "{{DOUBLE_STAR}}").replace(/\*/g, "[^/]*").replace(/\?/g, ".").replace(/\{\{DOUBLE_STAR\}\}/g, ".*");
|
|
433
|
+
const regex = new RegExp(regexStr);
|
|
434
|
+
return (filePath) => regex.test(filePath);
|
|
435
|
+
}
|
|
436
|
+
function getCommitHash() {
|
|
437
|
+
try {
|
|
438
|
+
const { execSync } = __require("child_process");
|
|
439
|
+
const hash = execSync("git rev-parse HEAD", { encoding: "utf-8" }).trim();
|
|
440
|
+
return hash;
|
|
441
|
+
} catch {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// src/detection/detect-markers.ts
|
|
447
|
+
var DATABASE_OPERATIONS = /* @__PURE__ */ new Set([
|
|
448
|
+
"findFirst",
|
|
449
|
+
"findMany",
|
|
450
|
+
"findUnique",
|
|
451
|
+
"findUniqueOrThrow",
|
|
452
|
+
"findFirstOrThrow",
|
|
453
|
+
"create",
|
|
454
|
+
"createMany",
|
|
455
|
+
"update",
|
|
456
|
+
"updateMany",
|
|
457
|
+
"delete",
|
|
458
|
+
"deleteMany",
|
|
459
|
+
"upsert",
|
|
460
|
+
"aggregate",
|
|
461
|
+
"count",
|
|
462
|
+
"groupBy",
|
|
463
|
+
"$transaction",
|
|
464
|
+
"$queryRaw",
|
|
465
|
+
"$executeRaw"
|
|
466
|
+
]);
|
|
467
|
+
var TELEMETRY_PATTERNS = [
|
|
468
|
+
/^track[A-Z]/,
|
|
469
|
+
/^analytics\./,
|
|
470
|
+
/^segment\./,
|
|
471
|
+
/^mixpanel\./,
|
|
472
|
+
/^amplitude\./,
|
|
473
|
+
/^posthog\./,
|
|
474
|
+
/^gtag\(/,
|
|
475
|
+
/^ga\(/
|
|
476
|
+
];
|
|
477
|
+
function detectMarkers(fn, context) {
|
|
478
|
+
const markers = [];
|
|
479
|
+
detectAwaitMarkers(fn, markers);
|
|
480
|
+
detectDatabaseMarkers(fn, markers);
|
|
481
|
+
detectNetworkMarkers(fn, context, markers);
|
|
482
|
+
detectFileSystemMarkers(fn, context, markers);
|
|
483
|
+
detectEnvMarkers(fn, markers);
|
|
484
|
+
detectLoggingMarkers(fn, context, markers);
|
|
485
|
+
detectTelemetryMarkers(fn, markers);
|
|
486
|
+
detectQueueMarkers(fn, markers);
|
|
487
|
+
detectEventMarkers(fn, markers);
|
|
488
|
+
return markers;
|
|
489
|
+
}
|
|
490
|
+
function detectAwaitMarkers(fn, markers) {
|
|
491
|
+
if (fn.hasAwait) {
|
|
492
|
+
const awaitedCalls = fn.callSites.filter((cs) => cs.isAwaited);
|
|
493
|
+
if (awaitedCalls.length > 0) {
|
|
494
|
+
for (const call of awaitedCalls) {
|
|
495
|
+
markers.push({
|
|
496
|
+
type: "await-expression",
|
|
497
|
+
detail: `await ${call.expression}`,
|
|
498
|
+
line: call.line
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
markers.push({
|
|
503
|
+
type: "await-expression",
|
|
504
|
+
detail: "await expression"
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function detectDatabaseMarkers(fn, markers) {
|
|
510
|
+
for (const callSite of fn.callSites) {
|
|
511
|
+
if (isDatabaseCall(callSite.expression)) {
|
|
512
|
+
markers.push({
|
|
513
|
+
type: "database-call",
|
|
514
|
+
detail: callSite.expression,
|
|
515
|
+
line: callSite.line
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function isDatabaseCall(expression) {
|
|
521
|
+
const parts = expression.split(".");
|
|
522
|
+
if (parts.length < 3) {
|
|
523
|
+
if (parts.length === 2) {
|
|
524
|
+
const [base, operation] = parts;
|
|
525
|
+
if ((base === "db" || base === "prisma") && operation && DATABASE_OPERATIONS.has(operation)) {
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
const lastPart = parts[parts.length - 1];
|
|
532
|
+
if (!lastPart || !DATABASE_OPERATIONS.has(lastPart)) {
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
return parts.some(
|
|
536
|
+
(part) => part === "db" || part === "prisma" || part === "database"
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
function detectNetworkMarkers(fn, _context, markers) {
|
|
540
|
+
for (const callSite of fn.callSites) {
|
|
541
|
+
if (callSite.expression === "fetch" || callSite.expression.endsWith(".fetch")) {
|
|
542
|
+
markers.push({
|
|
543
|
+
type: "network-fetch",
|
|
544
|
+
detail: callSite.expression,
|
|
545
|
+
line: callSite.line
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
if (callSite.expression.startsWith("axios") || callSite.expression === "axios") {
|
|
549
|
+
markers.push({
|
|
550
|
+
type: "network-http",
|
|
551
|
+
detail: callSite.expression,
|
|
552
|
+
line: callSite.line
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
function detectFileSystemMarkers(fn, _context, markers) {
|
|
558
|
+
for (const callSite of fn.callSites) {
|
|
559
|
+
if (isFsCall(callSite.expression)) {
|
|
560
|
+
markers.push({
|
|
561
|
+
type: "fs-call",
|
|
562
|
+
detail: callSite.expression,
|
|
563
|
+
line: callSite.line
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function isFsCall(expression) {
|
|
569
|
+
const parts = expression.split(".");
|
|
570
|
+
return parts.some(
|
|
571
|
+
(part) => part === "fs" || part === "readFile" || part === "writeFile"
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
function detectEnvMarkers(fn, markers) {
|
|
575
|
+
for (const chain of fn.propertyAccessChains) {
|
|
576
|
+
if (chain.startsWith("process.env")) {
|
|
577
|
+
markers.push({
|
|
578
|
+
type: "env-access",
|
|
579
|
+
detail: chain
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function detectLoggingMarkers(fn, _context, markers) {
|
|
585
|
+
for (const callSite of fn.callSites) {
|
|
586
|
+
if (isConsoleLog(callSite.expression)) {
|
|
587
|
+
markers.push({
|
|
588
|
+
type: "console-log",
|
|
589
|
+
detail: callSite.expression,
|
|
590
|
+
line: callSite.line
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
if (isLoggerCall(callSite.expression)) {
|
|
594
|
+
markers.push({
|
|
595
|
+
type: "logging",
|
|
596
|
+
detail: callSite.expression,
|
|
597
|
+
line: callSite.line
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
function isConsoleLog(expression) {
|
|
603
|
+
return expression === "console.log" || expression === "console.error" || expression === "console.warn" || expression === "console.info" || expression === "console.debug";
|
|
604
|
+
}
|
|
605
|
+
function isLoggerCall(expression) {
|
|
606
|
+
const parts = expression.split(".");
|
|
607
|
+
if (parts.length < 2) {
|
|
608
|
+
return expression === "log";
|
|
609
|
+
}
|
|
610
|
+
const base = parts[0];
|
|
611
|
+
const method = parts[parts.length - 1];
|
|
612
|
+
if (base === "logger" || base === "log") {
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
const logMethods = /* @__PURE__ */ new Set([
|
|
616
|
+
"info",
|
|
617
|
+
"warn",
|
|
618
|
+
"error",
|
|
619
|
+
"debug",
|
|
620
|
+
"trace",
|
|
621
|
+
"fatal"
|
|
622
|
+
]);
|
|
623
|
+
return (parts.includes("logger") || parts.includes("log")) && method !== void 0 && logMethods.has(method);
|
|
624
|
+
}
|
|
625
|
+
function detectTelemetryMarkers(fn, markers) {
|
|
626
|
+
for (const callSite of fn.callSites) {
|
|
627
|
+
if (isTelemetryCall(callSite.expression)) {
|
|
628
|
+
markers.push({
|
|
629
|
+
type: "telemetry",
|
|
630
|
+
detail: callSite.expression,
|
|
631
|
+
line: callSite.line
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
function isTelemetryCall(expression) {
|
|
637
|
+
return TELEMETRY_PATTERNS.some((pattern) => pattern.test(expression));
|
|
638
|
+
}
|
|
639
|
+
function detectQueueMarkers(fn, markers) {
|
|
640
|
+
for (const callSite of fn.callSites) {
|
|
641
|
+
if (isQueueCall(callSite.expression)) {
|
|
642
|
+
markers.push({
|
|
643
|
+
type: "queue-enqueue",
|
|
644
|
+
detail: callSite.expression,
|
|
645
|
+
line: callSite.line
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
function isQueueCall(expression) {
|
|
651
|
+
return expression.endsWith(".enqueue") || expression.endsWith(".add") || expression.endsWith(".publish") || expression.includes("queue.") || expression.includes("Queue.");
|
|
652
|
+
}
|
|
653
|
+
function detectEventMarkers(fn, markers) {
|
|
654
|
+
for (const callSite of fn.callSites) {
|
|
655
|
+
if (isEventEmit(callSite.expression)) {
|
|
656
|
+
markers.push({
|
|
657
|
+
type: "event-emit",
|
|
658
|
+
detail: callSite.expression,
|
|
659
|
+
line: callSite.line
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
function isEventEmit(expression) {
|
|
665
|
+
return expression.endsWith(".emit") || expression.endsWith(".dispatch") || expression.endsWith(".trigger") || expression.endsWith(".fire");
|
|
666
|
+
}
|
|
667
|
+
function createDetectionContext(imports) {
|
|
668
|
+
const pureFileImports = /* @__PURE__ */ new Set();
|
|
669
|
+
for (const imp of imports.imports) {
|
|
670
|
+
if (imp.moduleSpecifier.endsWith(".pure") || imp.moduleSpecifier.includes(".pure/")) {
|
|
671
|
+
pureFileImports.add(imp.moduleSpecifier);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
imports,
|
|
676
|
+
pureFileImports
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// src/classification/classifier.ts
|
|
681
|
+
var TRIVIAL_STATEMENT_THRESHOLD = 3;
|
|
682
|
+
function classifyFunction(_fn, markers) {
|
|
683
|
+
if (markers.length === 0) {
|
|
684
|
+
return "pure";
|
|
685
|
+
}
|
|
686
|
+
return "impure";
|
|
687
|
+
}
|
|
688
|
+
function shouldExcludeFunction(fn) {
|
|
689
|
+
if (fn.statementCount < TRIVIAL_STATEMENT_THRESHOLD && !fn.hasConditionals) {
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
function createClassifiedFunction(fn, markers, qualityScore, status) {
|
|
695
|
+
const classification = classifyFunction(fn, markers);
|
|
696
|
+
return {
|
|
697
|
+
...fn,
|
|
698
|
+
markers,
|
|
699
|
+
classification,
|
|
700
|
+
qualityScore,
|
|
701
|
+
status
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/classification/quality-scorer.ts
|
|
706
|
+
var QUALITY_WEIGHTS = {
|
|
707
|
+
// Positive signals (additive)
|
|
708
|
+
callsPureFile: 30,
|
|
709
|
+
// Calls a function from .pure.ts file
|
|
710
|
+
callsPureNamingConvention: 20,
|
|
711
|
+
// Calls plan*/derive*/compute*/calculate*/transform*
|
|
712
|
+
ioConcentratedAtStart: 15,
|
|
713
|
+
// I/O calls concentrated at start (GATHER pattern)
|
|
714
|
+
ioConcentratedAtEnd: 15,
|
|
715
|
+
// I/O calls concentrated at end (EXECUTE pattern)
|
|
716
|
+
lowComplexity: 10,
|
|
717
|
+
// Low cyclomatic complexity (≤ 5)
|
|
718
|
+
shellNamingConvention: 5,
|
|
719
|
+
// Function name matches shell conventions
|
|
720
|
+
callsPredicateFunctions: 5,
|
|
721
|
+
// Calls is*/has*/should* functions
|
|
722
|
+
// Negative signals (penalties)
|
|
723
|
+
ioInterleaved: -20,
|
|
724
|
+
// I/O calls interleaved throughout
|
|
725
|
+
highComplexity: -15,
|
|
726
|
+
// High cyclomatic complexity (> 10)
|
|
727
|
+
multipleIoTypes: -10,
|
|
728
|
+
// Multiple I/O types in same function
|
|
729
|
+
noPureFunctionCalls: -10,
|
|
730
|
+
// No pure function calls at all
|
|
731
|
+
veryLongFunction: -10
|
|
732
|
+
// > 100 lines
|
|
733
|
+
};
|
|
734
|
+
var PURE_FUNCTION_PATTERNS = [
|
|
735
|
+
/^plan[A-Z]/,
|
|
736
|
+
/^derive[A-Z]/,
|
|
737
|
+
/^compute[A-Z]/,
|
|
738
|
+
/^calculate[A-Z]/,
|
|
739
|
+
/^transform[A-Z]/,
|
|
740
|
+
/^build[A-Z]/,
|
|
741
|
+
/^create[A-Z](?!.*Service)/,
|
|
742
|
+
// createX but not createXService
|
|
743
|
+
/^parse[A-Z]/,
|
|
744
|
+
/^format[A-Z]/,
|
|
745
|
+
/^validate[A-Z]/,
|
|
746
|
+
/^normalize[A-Z]/,
|
|
747
|
+
/^convert[A-Z]/,
|
|
748
|
+
/^map[A-Z]/,
|
|
749
|
+
/^filter[A-Z]/,
|
|
750
|
+
/^reduce[A-Z]/,
|
|
751
|
+
/^merge[A-Z]/,
|
|
752
|
+
/^extract[A-Z]/,
|
|
753
|
+
/^to[A-Z]/
|
|
754
|
+
// toPublicX, toDTO, etc.
|
|
755
|
+
];
|
|
756
|
+
var PREDICATE_PATTERNS = [
|
|
757
|
+
/^is[A-Z]/,
|
|
758
|
+
/^has[A-Z]/,
|
|
759
|
+
/^should[A-Z]/,
|
|
760
|
+
/^can[A-Z]/,
|
|
761
|
+
/^will[A-Z]/,
|
|
762
|
+
/^was[A-Z]/,
|
|
763
|
+
/^did[A-Z]/,
|
|
764
|
+
/^are[A-Z]/,
|
|
765
|
+
/^does[A-Z]/,
|
|
766
|
+
/^needs[A-Z]/
|
|
767
|
+
];
|
|
768
|
+
var SHELL_FUNCTION_PATTERNS = [
|
|
769
|
+
/^handle[A-Z]/,
|
|
770
|
+
/^fetch[A-Z]/,
|
|
771
|
+
/^save[A-Z]/,
|
|
772
|
+
/^send[A-Z]/,
|
|
773
|
+
/^load[A-Z]/,
|
|
774
|
+
/^get[A-Z]/,
|
|
775
|
+
// getData, getUser, etc.
|
|
776
|
+
/^set[A-Z]/,
|
|
777
|
+
/^update[A-Z]/,
|
|
778
|
+
/^delete[A-Z]/,
|
|
779
|
+
/^remove[A-Z]/,
|
|
780
|
+
/^create[A-Z].*Service/,
|
|
781
|
+
// createXService
|
|
782
|
+
/^init[A-Z]/,
|
|
783
|
+
/^process[A-Z]/,
|
|
784
|
+
/^execute[A-Z]/,
|
|
785
|
+
/^run[A-Z]/,
|
|
786
|
+
/^perform[A-Z]/
|
|
787
|
+
];
|
|
788
|
+
var IO_MARKER_TYPES = /* @__PURE__ */ new Set([
|
|
789
|
+
"await-expression",
|
|
790
|
+
"database-call",
|
|
791
|
+
"network-fetch",
|
|
792
|
+
"network-http",
|
|
793
|
+
"fs-call"
|
|
794
|
+
]);
|
|
795
|
+
function computeQualityScore(fn, markers, context) {
|
|
796
|
+
let score = 50;
|
|
797
|
+
const analysis = analyzeFunction(fn, markers, context);
|
|
798
|
+
if (analysis.callsPureFile) {
|
|
799
|
+
score += QUALITY_WEIGHTS.callsPureFile;
|
|
800
|
+
}
|
|
801
|
+
if (analysis.callsPureNamingConvention) {
|
|
802
|
+
score += QUALITY_WEIGHTS.callsPureNamingConvention;
|
|
803
|
+
}
|
|
804
|
+
if (analysis.ioConcentratedAtStart) {
|
|
805
|
+
score += QUALITY_WEIGHTS.ioConcentratedAtStart;
|
|
806
|
+
}
|
|
807
|
+
if (analysis.ioConcentratedAtEnd) {
|
|
808
|
+
score += QUALITY_WEIGHTS.ioConcentratedAtEnd;
|
|
809
|
+
}
|
|
810
|
+
if (analysis.lowComplexity) {
|
|
811
|
+
score += QUALITY_WEIGHTS.lowComplexity;
|
|
812
|
+
}
|
|
813
|
+
if (analysis.shellNamingConvention) {
|
|
814
|
+
score += QUALITY_WEIGHTS.shellNamingConvention;
|
|
815
|
+
}
|
|
816
|
+
if (analysis.callsPredicateFunctions) {
|
|
817
|
+
score += QUALITY_WEIGHTS.callsPredicateFunctions;
|
|
818
|
+
}
|
|
819
|
+
if (analysis.ioInterleaved) {
|
|
820
|
+
score += QUALITY_WEIGHTS.ioInterleaved;
|
|
821
|
+
}
|
|
822
|
+
if (analysis.highComplexity) {
|
|
823
|
+
score += QUALITY_WEIGHTS.highComplexity;
|
|
824
|
+
}
|
|
825
|
+
if (analysis.multipleIoTypes) {
|
|
826
|
+
score += QUALITY_WEIGHTS.multipleIoTypes;
|
|
827
|
+
}
|
|
828
|
+
if (analysis.noPureFunctionCalls) {
|
|
829
|
+
score += QUALITY_WEIGHTS.noPureFunctionCalls;
|
|
830
|
+
}
|
|
831
|
+
if (analysis.veryLongFunction) {
|
|
832
|
+
score += QUALITY_WEIGHTS.veryLongFunction;
|
|
833
|
+
}
|
|
834
|
+
return Math.max(0, Math.min(100, score));
|
|
835
|
+
}
|
|
836
|
+
function analyzeFunction(fn, markers, context) {
|
|
837
|
+
const ioMarkers = markers.filter(
|
|
838
|
+
(m) => IO_MARKER_TYPES.has(m.type) && m.line !== void 0
|
|
839
|
+
);
|
|
840
|
+
const uniqueIoTypes = new Set(markers.map((m) => m.type));
|
|
841
|
+
const callsPureFile = checkCallsPureFile(fn, context);
|
|
842
|
+
const { callsPureNaming, pureCallCount } = checkCallsPureNamingConvention(
|
|
843
|
+
fn.callSites
|
|
844
|
+
);
|
|
845
|
+
const { callsPredicates, predicateCallCount } = checkCallsPredicateFunctions(
|
|
846
|
+
fn.callSites
|
|
847
|
+
);
|
|
848
|
+
const { concentratedAtStart, concentratedAtEnd, interleaved } = analyzeIoConcentration(fn, ioMarkers);
|
|
849
|
+
const estimatedComplexity = estimateCyclomaticComplexity(fn);
|
|
850
|
+
const shellNamingConvention = checkShellNamingConvention(fn.name);
|
|
851
|
+
return {
|
|
852
|
+
callsPureFile,
|
|
853
|
+
callsPureNamingConvention: callsPureNaming,
|
|
854
|
+
ioConcentratedAtStart: concentratedAtStart,
|
|
855
|
+
ioConcentratedAtEnd: concentratedAtEnd,
|
|
856
|
+
lowComplexity: estimatedComplexity <= 5,
|
|
857
|
+
shellNamingConvention,
|
|
858
|
+
callsPredicateFunctions: callsPredicates,
|
|
859
|
+
ioInterleaved: interleaved,
|
|
860
|
+
highComplexity: estimatedComplexity > 10,
|
|
861
|
+
multipleIoTypes: uniqueIoTypes.size >= 3,
|
|
862
|
+
noPureFunctionCalls: pureCallCount === 0 && predicateCallCount === 0,
|
|
863
|
+
veryLongFunction: fn.bodyLineCount > 100,
|
|
864
|
+
estimatedComplexity,
|
|
865
|
+
ioMarkerCount: ioMarkers.length,
|
|
866
|
+
uniqueIoTypes,
|
|
867
|
+
pureCallCount,
|
|
868
|
+
predicateCallCount
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
function checkCallsPureFile(fn, context) {
|
|
872
|
+
const pureImports = /* @__PURE__ */ new Set();
|
|
873
|
+
for (const imp of context.imports.imports) {
|
|
874
|
+
if (context.pureFileImports.has(imp.moduleSpecifier)) {
|
|
875
|
+
for (const name of imp.namedImports) {
|
|
876
|
+
pureImports.add(name);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
for (const callSite of fn.callSites) {
|
|
881
|
+
const calledFn = extractFunctionName(callSite.expression);
|
|
882
|
+
if (pureImports.has(calledFn)) {
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
function checkCallsPureNamingConvention(callSites) {
|
|
889
|
+
let pureCallCount = 0;
|
|
890
|
+
for (const callSite of callSites) {
|
|
891
|
+
const calledFn = extractFunctionName(callSite.expression);
|
|
892
|
+
if (matchesAnyPattern(calledFn, PURE_FUNCTION_PATTERNS)) {
|
|
893
|
+
pureCallCount++;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
callsPureNaming: pureCallCount > 0,
|
|
898
|
+
pureCallCount
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function checkCallsPredicateFunctions(callSites) {
|
|
902
|
+
let predicateCallCount = 0;
|
|
903
|
+
for (const callSite of callSites) {
|
|
904
|
+
const calledFn = extractFunctionName(callSite.expression);
|
|
905
|
+
if (matchesAnyPattern(calledFn, PREDICATE_PATTERNS)) {
|
|
906
|
+
predicateCallCount++;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return {
|
|
910
|
+
callsPredicates: predicateCallCount > 0,
|
|
911
|
+
predicateCallCount
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
function analyzeIoConcentration(fn, ioMarkers) {
|
|
915
|
+
if (ioMarkers.length === 0) {
|
|
916
|
+
return {
|
|
917
|
+
concentratedAtStart: false,
|
|
918
|
+
concentratedAtEnd: false,
|
|
919
|
+
interleaved: false
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
const positions = ioMarkers.map((m) => (m.line ?? 0) / Math.max(fn.bodyLineCount, 1)).sort((a, b) => a - b);
|
|
923
|
+
const startThreshold = 0.33;
|
|
924
|
+
const endThreshold = 0.67;
|
|
925
|
+
const atStart = positions.filter((p) => p <= startThreshold).length;
|
|
926
|
+
const atEnd = positions.filter((p) => p >= endThreshold).length;
|
|
927
|
+
const inMiddle = positions.filter(
|
|
928
|
+
(p) => p > startThreshold && p < endThreshold
|
|
929
|
+
).length;
|
|
930
|
+
const total = positions.length;
|
|
931
|
+
const concentratedAtStart = atStart >= total * 0.6 && inMiddle <= 1;
|
|
932
|
+
const concentratedAtEnd = atEnd >= total * 0.6 && inMiddle <= 1;
|
|
933
|
+
const interleaved = inMiddle >= 2 || atStart > 0 && atEnd > 0 && inMiddle > 0 || total >= 4 && !concentratedAtStart && !concentratedAtEnd;
|
|
934
|
+
return {
|
|
935
|
+
concentratedAtStart,
|
|
936
|
+
concentratedAtEnd,
|
|
937
|
+
interleaved
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
function estimateCyclomaticComplexity(fn) {
|
|
941
|
+
let complexity = 1;
|
|
942
|
+
if (fn.hasConditionals) {
|
|
943
|
+
complexity += Math.ceil(fn.statementCount / 10);
|
|
944
|
+
}
|
|
945
|
+
if (fn.bodyLineCount > 50) {
|
|
946
|
+
complexity += Math.floor((fn.bodyLineCount - 50) / 25);
|
|
947
|
+
}
|
|
948
|
+
return complexity;
|
|
949
|
+
}
|
|
950
|
+
function checkShellNamingConvention(name) {
|
|
951
|
+
if (!name) return false;
|
|
952
|
+
return matchesAnyPattern(name, SHELL_FUNCTION_PATTERNS);
|
|
953
|
+
}
|
|
954
|
+
function extractFunctionName(expression) {
|
|
955
|
+
const parts = expression.split(".");
|
|
956
|
+
return parts[parts.length - 1] ?? expression;
|
|
957
|
+
}
|
|
958
|
+
function matchesAnyPattern(str, patterns) {
|
|
959
|
+
return patterns.some((pattern) => pattern.test(str));
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// src/classification/derive-status.ts
|
|
963
|
+
function deriveStatus(classification, qualityScore, thresholds = DEFAULT_QUALITY_THRESHOLDS) {
|
|
964
|
+
if (classification === "pure") {
|
|
965
|
+
return "ok";
|
|
966
|
+
}
|
|
967
|
+
if (qualityScore === null) {
|
|
968
|
+
return "review";
|
|
969
|
+
}
|
|
970
|
+
if (qualityScore >= thresholds.okThreshold) {
|
|
971
|
+
return "ok";
|
|
972
|
+
}
|
|
973
|
+
if (qualityScore >= thresholds.reviewThreshold) {
|
|
974
|
+
return "review";
|
|
975
|
+
}
|
|
976
|
+
return "refactor";
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// src/scoring/scorer.ts
|
|
980
|
+
function scoreFile(filePath, functions, isTypeOnly = false) {
|
|
981
|
+
if (isTypeOnly) {
|
|
982
|
+
return {
|
|
983
|
+
filePath,
|
|
984
|
+
purity: 100,
|
|
985
|
+
impurityQuality: null,
|
|
986
|
+
health: 100,
|
|
987
|
+
pureCount: 0,
|
|
988
|
+
impureCount: 0,
|
|
989
|
+
excludedCount: 0,
|
|
990
|
+
statusBreakdown: { ok: 0, review: 0, refactor: 0 },
|
|
991
|
+
pureLineCount: 0,
|
|
992
|
+
impureLineCount: 0,
|
|
993
|
+
functions: [],
|
|
994
|
+
refactoringCandidates: [],
|
|
995
|
+
typeOnly: true
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
const pureFunctions = functions.filter((f) => f.classification === "pure");
|
|
999
|
+
const impureFunctions = functions.filter((f) => f.classification === "impure");
|
|
1000
|
+
const pureCount = pureFunctions.length;
|
|
1001
|
+
const impureCount = impureFunctions.length;
|
|
1002
|
+
const total = pureCount + impureCount;
|
|
1003
|
+
if (total === 0) {
|
|
1004
|
+
return {
|
|
1005
|
+
filePath,
|
|
1006
|
+
purity: 100,
|
|
1007
|
+
impurityQuality: null,
|
|
1008
|
+
health: 100,
|
|
1009
|
+
pureCount: 0,
|
|
1010
|
+
impureCount: 0,
|
|
1011
|
+
excludedCount: 0,
|
|
1012
|
+
statusBreakdown: { ok: 0, review: 0, refactor: 0 },
|
|
1013
|
+
pureLineCount: 0,
|
|
1014
|
+
impureLineCount: 0,
|
|
1015
|
+
functions: [],
|
|
1016
|
+
refactoringCandidates: [],
|
|
1017
|
+
allExcluded: true
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
const purity = pureCount / total * 100;
|
|
1021
|
+
let impurityQuality = null;
|
|
1022
|
+
if (impureCount > 0) {
|
|
1023
|
+
const totalQuality = impureFunctions.reduce(
|
|
1024
|
+
(sum, f) => sum + (f.qualityScore ?? 0),
|
|
1025
|
+
0
|
|
1026
|
+
);
|
|
1027
|
+
impurityQuality = totalQuality / impureCount;
|
|
1028
|
+
}
|
|
1029
|
+
const statusBreakdown = {
|
|
1030
|
+
ok: functions.filter((f) => f.status === "ok").length,
|
|
1031
|
+
review: functions.filter((f) => f.status === "review").length,
|
|
1032
|
+
refactor: functions.filter((f) => f.status === "refactor").length
|
|
1033
|
+
};
|
|
1034
|
+
const health = statusBreakdown.ok / total * 100;
|
|
1035
|
+
const pureLineCount = pureFunctions.reduce(
|
|
1036
|
+
(sum, f) => sum + f.bodyLineCount,
|
|
1037
|
+
0
|
|
1038
|
+
);
|
|
1039
|
+
const impureLineCount = impureFunctions.reduce(
|
|
1040
|
+
(sum, f) => sum + f.bodyLineCount,
|
|
1041
|
+
0
|
|
1042
|
+
);
|
|
1043
|
+
const refactoringCandidates = impureFunctions.filter((f) => f.status === "refactor").map((f) => ({
|
|
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) => {
|
|
1050
|
+
const impactA = a.bodyLineCount * (100 - a.qualityScore);
|
|
1051
|
+
const impactB = b.bodyLineCount * (100 - b.qualityScore);
|
|
1052
|
+
return impactB - impactA;
|
|
1053
|
+
});
|
|
1054
|
+
return {
|
|
1055
|
+
filePath,
|
|
1056
|
+
purity,
|
|
1057
|
+
impurityQuality,
|
|
1058
|
+
health,
|
|
1059
|
+
pureCount,
|
|
1060
|
+
impureCount,
|
|
1061
|
+
excludedCount: 0,
|
|
1062
|
+
statusBreakdown,
|
|
1063
|
+
pureLineCount,
|
|
1064
|
+
impureLineCount,
|
|
1065
|
+
functions,
|
|
1066
|
+
refactoringCandidates
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
function scoreDirectory(dirPath, fileScores) {
|
|
1070
|
+
const scorableFiles = fileScores.filter(
|
|
1071
|
+
(f) => !f.typeOnly && !f.allExcluded && f.pureCount + f.impureCount > 0
|
|
1072
|
+
);
|
|
1073
|
+
if (scorableFiles.length === 0) {
|
|
1074
|
+
return {
|
|
1075
|
+
dirPath,
|
|
1076
|
+
purity: 100,
|
|
1077
|
+
impurityQuality: null,
|
|
1078
|
+
health: 100,
|
|
1079
|
+
pureCount: 0,
|
|
1080
|
+
impureCount: 0,
|
|
1081
|
+
excludedCount: fileScores.reduce((sum, f) => sum + f.excludedCount, 0),
|
|
1082
|
+
statusBreakdown: { ok: 0, review: 0, refactor: 0 },
|
|
1083
|
+
pureLineCount: 0,
|
|
1084
|
+
impureLineCount: 0,
|
|
1085
|
+
fileScores
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
const totalPure = scorableFiles.reduce((sum, f) => sum + f.pureCount, 0);
|
|
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
|
+
);
|
|
1119
|
+
return {
|
|
1120
|
+
dirPath,
|
|
1121
|
+
purity,
|
|
1122
|
+
impurityQuality,
|
|
1123
|
+
health,
|
|
1124
|
+
pureCount: totalPure,
|
|
1125
|
+
impureCount: totalImpure,
|
|
1126
|
+
excludedCount: fileScores.reduce((sum, f) => sum + f.excludedCount, 0),
|
|
1127
|
+
statusBreakdown,
|
|
1128
|
+
pureLineCount,
|
|
1129
|
+
impureLineCount,
|
|
1130
|
+
fileScores
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
function scoreProject(directoryScores, options = {}) {
|
|
1134
|
+
const {
|
|
1135
|
+
timestamp = (/* @__PURE__ */ new Date()).toISOString(),
|
|
1136
|
+
commitHash = null,
|
|
1137
|
+
subset = false,
|
|
1138
|
+
filesGlob,
|
|
1139
|
+
errors = []
|
|
1140
|
+
} = options;
|
|
1141
|
+
const scorableDirs = directoryScores.filter(
|
|
1142
|
+
(d) => d.pureCount + d.impureCount > 0
|
|
1143
|
+
);
|
|
1144
|
+
if (scorableDirs.length === 0) {
|
|
1145
|
+
return {
|
|
1146
|
+
purity: 100,
|
|
1147
|
+
impurityQuality: null,
|
|
1148
|
+
health: 100,
|
|
1149
|
+
pureCount: 0,
|
|
1150
|
+
impureCount: 0,
|
|
1151
|
+
excludedCount: directoryScores.reduce(
|
|
1152
|
+
(sum, d) => sum + d.excludedCount,
|
|
1153
|
+
0
|
|
1154
|
+
),
|
|
1155
|
+
statusBreakdown: { ok: 0, review: 0, refactor: 0 },
|
|
1156
|
+
pureLineCount: 0,
|
|
1157
|
+
impureLineCount: 0,
|
|
1158
|
+
directoryScores,
|
|
1159
|
+
timestamp,
|
|
1160
|
+
commitHash,
|
|
1161
|
+
refactoringCandidates: [],
|
|
1162
|
+
allExcluded: true,
|
|
1163
|
+
...subset && { subset: true, filesGlob },
|
|
1164
|
+
...errors.length > 0 && { errors }
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
const totalPure = scorableDirs.reduce((sum, d) => sum + d.pureCount, 0);
|
|
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
|
+
);
|
|
1198
|
+
const allCandidates = [];
|
|
1199
|
+
for (const dir of directoryScores) {
|
|
1200
|
+
for (const file of dir.fileScores) {
|
|
1201
|
+
for (const candidate of file.refactoringCandidates) {
|
|
1202
|
+
allCandidates.push({
|
|
1203
|
+
...candidate,
|
|
1204
|
+
filePath: file.filePath
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
const refactoringCandidates = allCandidates.sort((a, b) => {
|
|
1210
|
+
const impactA = a.bodyLineCount * (100 - a.qualityScore);
|
|
1211
|
+
const impactB = b.bodyLineCount * (100 - b.qualityScore);
|
|
1212
|
+
return impactB - impactA;
|
|
1213
|
+
});
|
|
1214
|
+
const result = {
|
|
1215
|
+
purity,
|
|
1216
|
+
impurityQuality,
|
|
1217
|
+
health,
|
|
1218
|
+
pureCount: totalPure,
|
|
1219
|
+
impureCount: totalImpure,
|
|
1220
|
+
excludedCount: directoryScores.reduce((sum, d) => sum + d.excludedCount, 0),
|
|
1221
|
+
statusBreakdown,
|
|
1222
|
+
pureLineCount,
|
|
1223
|
+
impureLineCount,
|
|
1224
|
+
directoryScores,
|
|
1225
|
+
timestamp,
|
|
1226
|
+
commitHash,
|
|
1227
|
+
refactoringCandidates
|
|
1228
|
+
};
|
|
1229
|
+
if (subset && filesGlob !== void 0) {
|
|
1230
|
+
result.subset = true;
|
|
1231
|
+
result.filesGlob = filesGlob;
|
|
1232
|
+
}
|
|
1233
|
+
if (errors.length > 0) {
|
|
1234
|
+
result.errors = errors;
|
|
1235
|
+
}
|
|
1236
|
+
return result;
|
|
1237
|
+
}
|
|
1238
|
+
function groupFilesByDirectory(fileScores) {
|
|
1239
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1240
|
+
for (const fileScore of fileScores) {
|
|
1241
|
+
const dirPath = getDirectoryPath(fileScore.filePath);
|
|
1242
|
+
const existing = groups.get(dirPath);
|
|
1243
|
+
if (existing) {
|
|
1244
|
+
existing.push(fileScore);
|
|
1245
|
+
} else {
|
|
1246
|
+
groups.set(dirPath, [fileScore]);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return groups;
|
|
1250
|
+
}
|
|
1251
|
+
function getDirectoryPath(filePath) {
|
|
1252
|
+
const lastSlash = filePath.lastIndexOf("/");
|
|
1253
|
+
if (lastSlash === -1) return ".";
|
|
1254
|
+
return filePath.slice(0, lastSlash);
|
|
1255
|
+
}
|
|
1256
|
+
function getDiagnosticInsights(score) {
|
|
1257
|
+
const insights = [];
|
|
1258
|
+
if (score.purity >= 60 && score.impurityQuality !== null && score.impurityQuality < 50) {
|
|
1259
|
+
insights.push(
|
|
1260
|
+
"Most code is pure, but the impure code is tangled. Focus on improving the structure of impure functions."
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
if (score.purity < 40 && score.impurityQuality !== null && score.impurityQuality >= 70) {
|
|
1264
|
+
insights.push(
|
|
1265
|
+
"Lots of I/O code, but it's well-structured. Consider extracting more pure business logic."
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
if (score.purity < 40 && score.impurityQuality !== null && score.impurityQuality < 50) {
|
|
1269
|
+
insights.push(
|
|
1270
|
+
"Significant technical debt in I/O code. Both purity and structure need improvement."
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
if (score.health >= 80 && score.purity < 50) {
|
|
1274
|
+
insights.push(
|
|
1275
|
+
"Despite low purity, impure functions are well-structured. Good FCIS patterns in shell code."
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
if (score.health < 50) {
|
|
1279
|
+
insights.push(
|
|
1280
|
+
"More than half of functions need attention. Prioritize refactoring candidates."
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
return insights;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// src/analyzer.ts
|
|
1287
|
+
async function analyze(config) {
|
|
1288
|
+
const errors = [];
|
|
1289
|
+
const extractorOptions = {
|
|
1290
|
+
tsconfigPath: config.tsconfigPath
|
|
1291
|
+
};
|
|
1292
|
+
if (config.filesGlob !== void 0) {
|
|
1293
|
+
extractorOptions.filesGlob = config.filesGlob;
|
|
1294
|
+
}
|
|
1295
|
+
const { sourceFiles } = loadProject(extractorOptions);
|
|
1296
|
+
if (sourceFiles.length === 0) {
|
|
1297
|
+
throw new Error("No source files found to analyze");
|
|
1298
|
+
}
|
|
1299
|
+
const fileScores = [];
|
|
1300
|
+
for (const sourceFile of sourceFiles) {
|
|
1301
|
+
try {
|
|
1302
|
+
const fileScore = analyzeFile(sourceFile);
|
|
1303
|
+
fileScores.push(fileScore);
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
errors.push({
|
|
1306
|
+
filePath: sourceFile.getFilePath(),
|
|
1307
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
if (fileScores.length === 0 && errors.length > 0) {
|
|
1312
|
+
throw new Error(
|
|
1313
|
+
`Failed to analyze any files. ${errors.length} files had errors.`
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
const directoryGroups = groupFilesByDirectory(fileScores);
|
|
1317
|
+
const directoryScores = [];
|
|
1318
|
+
for (const [dirPath, files] of directoryGroups) {
|
|
1319
|
+
const dirScore = scoreDirectory(dirPath, files);
|
|
1320
|
+
directoryScores.push(dirScore);
|
|
1321
|
+
}
|
|
1322
|
+
directoryScores.sort((a, b) => a.dirPath.localeCompare(b.dirPath));
|
|
1323
|
+
const projectOptions = {
|
|
1324
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1325
|
+
commitHash: getCommitHash()
|
|
1326
|
+
};
|
|
1327
|
+
if (config.filesGlob !== void 0) {
|
|
1328
|
+
projectOptions.subset = true;
|
|
1329
|
+
projectOptions.filesGlob = config.filesGlob;
|
|
1330
|
+
}
|
|
1331
|
+
if (errors.length > 0) {
|
|
1332
|
+
projectOptions.errors = errors;
|
|
1333
|
+
}
|
|
1334
|
+
const projectScore = scoreProject(directoryScores, projectOptions);
|
|
1335
|
+
return projectScore;
|
|
1336
|
+
}
|
|
1337
|
+
function analyzeFile(sourceFile) {
|
|
1338
|
+
const filePath = sourceFile.getFilePath();
|
|
1339
|
+
if (isTypeOnlyFile(sourceFile)) {
|
|
1340
|
+
return scoreFile(filePath, [], true);
|
|
1341
|
+
}
|
|
1342
|
+
const functions = extractFunctions(sourceFile);
|
|
1343
|
+
const imports = extractImports(sourceFile);
|
|
1344
|
+
const context = createDetectionContext(imports);
|
|
1345
|
+
const classifiedFunctions = [];
|
|
1346
|
+
for (const fn of functions) {
|
|
1347
|
+
if (shouldExcludeFunction(fn)) {
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
const markers = detectMarkers(fn, context);
|
|
1351
|
+
const classification = classifyFunction(fn, markers);
|
|
1352
|
+
let qualityScore = null;
|
|
1353
|
+
if (classification === "impure") {
|
|
1354
|
+
qualityScore = computeQualityScore(fn, markers, context);
|
|
1355
|
+
}
|
|
1356
|
+
const status = deriveStatus(
|
|
1357
|
+
classification,
|
|
1358
|
+
qualityScore,
|
|
1359
|
+
DEFAULT_QUALITY_THRESHOLDS
|
|
1360
|
+
);
|
|
1361
|
+
const classifiedFn = createClassifiedFunction(
|
|
1362
|
+
fn,
|
|
1363
|
+
markers,
|
|
1364
|
+
qualityScore,
|
|
1365
|
+
status
|
|
1366
|
+
);
|
|
1367
|
+
classifiedFunctions.push(classifiedFn);
|
|
1368
|
+
}
|
|
1369
|
+
return scoreFile(filePath, classifiedFunctions, false);
|
|
1370
|
+
}
|
|
1371
|
+
function checkThresholds(score, config) {
|
|
1372
|
+
const failures = [];
|
|
1373
|
+
if (config.minHealth !== void 0 && score.health < config.minHealth) {
|
|
1374
|
+
failures.push(
|
|
1375
|
+
`Health ${score.health.toFixed(1)}% is below minimum ${config.minHealth}%`
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
if (config.minPurity !== void 0 && score.purity < config.minPurity) {
|
|
1379
|
+
failures.push(
|
|
1380
|
+
`Purity ${score.purity.toFixed(1)}% is below minimum ${config.minPurity}%`
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
if (config.minQuality !== void 0 && score.impurityQuality !== null && score.impurityQuality < config.minQuality) {
|
|
1384
|
+
failures.push(
|
|
1385
|
+
`Impurity Quality ${score.impurityQuality.toFixed(1)}% is below minimum ${config.minQuality}%`
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
return {
|
|
1389
|
+
passed: failures.length === 0,
|
|
1390
|
+
failures
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
function printConsoleReport(score, options = {}) {
|
|
1394
|
+
const { verbose = false } = options;
|
|
1395
|
+
printHeader();
|
|
1396
|
+
printProjectSummary(score);
|
|
1397
|
+
printStatusBreakdown(
|
|
1398
|
+
score.statusBreakdown,
|
|
1399
|
+
score.pureCount + score.impureCount
|
|
1400
|
+
);
|
|
1401
|
+
printInsights(score);
|
|
1402
|
+
if (verbose) {
|
|
1403
|
+
printDirectoryBreakdown(score.directoryScores);
|
|
1404
|
+
} else {
|
|
1405
|
+
printWorstDirectories(score.directoryScores);
|
|
1406
|
+
}
|
|
1407
|
+
printRefactoringCandidates(score.refactoringCandidates.slice(0, 10));
|
|
1408
|
+
if (score.errors && score.errors.length > 0) {
|
|
1409
|
+
printErrors(score.errors);
|
|
1410
|
+
}
|
|
1411
|
+
printFooter(score);
|
|
1412
|
+
}
|
|
1413
|
+
function printHeader() {
|
|
1414
|
+
console.log();
|
|
1415
|
+
console.log(chalk.bold("FCIS Analysis"));
|
|
1416
|
+
console.log(
|
|
1417
|
+
chalk.gray("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550")
|
|
1418
|
+
);
|
|
1419
|
+
console.log();
|
|
1420
|
+
}
|
|
1421
|
+
function printProjectSummary(score) {
|
|
1422
|
+
const healthBar = createProgressBar(score.health, 25);
|
|
1423
|
+
const healthColor = score.health >= 70 ? chalk.green : score.health >= 50 ? chalk.yellow : chalk.red;
|
|
1424
|
+
const purityColor = score.purity >= 70 ? chalk.green : score.purity >= 50 ? chalk.yellow : chalk.red;
|
|
1425
|
+
console.log(
|
|
1426
|
+
`Project Health: ${healthColor(score.health.toFixed(0) + "%")} ${healthBar}`
|
|
1427
|
+
);
|
|
1428
|
+
console.log(
|
|
1429
|
+
` Purity: ${purityColor(score.purity.toFixed(0) + "%")} (${score.pureCount} pure / ${score.pureCount + score.impureCount} total)`
|
|
1430
|
+
);
|
|
1431
|
+
if (score.impurityQuality !== null) {
|
|
1432
|
+
const qualityColor = score.impurityQuality >= 70 ? chalk.green : score.impurityQuality >= 50 ? chalk.yellow : chalk.red;
|
|
1433
|
+
console.log(
|
|
1434
|
+
` Impurity Quality: ${qualityColor(score.impurityQuality.toFixed(0) + "%")} average`
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
console.log();
|
|
1438
|
+
}
|
|
1439
|
+
function printStatusBreakdown(breakdown, total) {
|
|
1440
|
+
console.log("Status Breakdown:");
|
|
1441
|
+
const okPercent = total > 0 ? (breakdown.ok / total * 100).toFixed(0) : "0";
|
|
1442
|
+
const reviewPercent = total > 0 ? (breakdown.review / total * 100).toFixed(0) : "0";
|
|
1443
|
+
const refactorPercent = total > 0 ? (breakdown.refactor / total * 100).toFixed(0) : "0";
|
|
1444
|
+
console.log(
|
|
1445
|
+
` ${chalk.green("\u2713 OK:")} ${String(breakdown.ok).padStart(4)} functions (${okPercent}%) \u2014 no action needed`
|
|
1446
|
+
);
|
|
1447
|
+
console.log(
|
|
1448
|
+
` ${chalk.yellow("\u25D0 Review:")} ${String(breakdown.review).padStart(4)} functions (${reviewPercent}%) \u2014 could be improved`
|
|
1449
|
+
);
|
|
1450
|
+
console.log(
|
|
1451
|
+
` ${chalk.red("\u2717 Refactor:")} ${String(breakdown.refactor).padStart(4)} functions (${refactorPercent}%) \u2014 tangled, needs work`
|
|
1452
|
+
);
|
|
1453
|
+
console.log();
|
|
1454
|
+
}
|
|
1455
|
+
function printInsights(score) {
|
|
1456
|
+
const insights = getDiagnosticInsights(score);
|
|
1457
|
+
if (insights.length > 0) {
|
|
1458
|
+
console.log(chalk.bold("Insights:"));
|
|
1459
|
+
for (const insight of insights) {
|
|
1460
|
+
console.log(` ${chalk.cyan("\u2192")} ${insight}`);
|
|
1461
|
+
}
|
|
1462
|
+
console.log();
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
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
|
+
function printWorstDirectories(directories) {
|
|
1488
|
+
const sorted = [...directories].filter((d) => d.pureCount + d.impureCount > 0 && d.health < 70).sort((a, b) => a.health - b.health).slice(0, 5);
|
|
1489
|
+
if (sorted.length === 0) return;
|
|
1490
|
+
console.log(chalk.bold("Directories Needing Attention:"));
|
|
1491
|
+
for (const dir of sorted) {
|
|
1492
|
+
const healthColor = dir.health >= 50 ? chalk.yellow : chalk.red;
|
|
1493
|
+
const total = dir.pureCount + dir.impureCount;
|
|
1494
|
+
const relativePath = toRelativePath(dir.dirPath);
|
|
1495
|
+
console.log(
|
|
1496
|
+
` ${healthColor(dir.health.toFixed(0).padStart(3) + "%")} ` + chalk.gray(`(${dir.pureCount}/${total} pure)`) + ` ${relativePath}`
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
console.log();
|
|
1500
|
+
}
|
|
1501
|
+
function printRefactoringCandidates(candidates) {
|
|
1502
|
+
if (candidates.length === 0) {
|
|
1503
|
+
console.log(chalk.green("No refactoring candidates found. Great job!"));
|
|
1504
|
+
console.log();
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
console.log(chalk.bold("Top Refactoring Candidates:"));
|
|
1508
|
+
console.log(chalk.gray("(Sorted by impact: size \xD7 complexity)"));
|
|
1509
|
+
console.log();
|
|
1510
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
1511
|
+
const candidate = candidates[i];
|
|
1512
|
+
if (!candidate) continue;
|
|
1513
|
+
const qualityColor = candidate.qualityScore >= 40 ? chalk.yellow : chalk.red;
|
|
1514
|
+
const relativePath = toRelativePath(candidate.filePath);
|
|
1515
|
+
console.log(
|
|
1516
|
+
chalk.gray(`${(i + 1).toString().padStart(2)}.`) + ` ${qualityColor(candidate.qualityScore.toFixed(0).padStart(3))}` + chalk.gray("/100") + ` ${chalk.cyan(candidate.name ?? "<anonymous>")}` + chalk.gray(` (${candidate.bodyLineCount} lines)`)
|
|
1517
|
+
);
|
|
1518
|
+
console.log(chalk.gray(` ${relativePath}:${candidate.startLine}`));
|
|
1519
|
+
if (candidate.markers.length > 0) {
|
|
1520
|
+
const markerStr = candidate.markers.slice(0, 3).join(", ");
|
|
1521
|
+
const more = candidate.markers.length > 3 ? ` +${candidate.markers.length - 3} more` : "";
|
|
1522
|
+
console.log(chalk.gray(` Markers: ${markerStr}${more}`));
|
|
1523
|
+
}
|
|
1524
|
+
console.log();
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
function printErrors(errors) {
|
|
1528
|
+
console.log(chalk.yellow.bold(`Errors (${errors.length} files skipped):`));
|
|
1529
|
+
for (const err of errors.slice(0, 5)) {
|
|
1530
|
+
console.log(` ${chalk.yellow("!")} ${err.filePath}`);
|
|
1531
|
+
console.log(chalk.gray(` ${err.error}`));
|
|
1532
|
+
}
|
|
1533
|
+
if (errors.length > 5) {
|
|
1534
|
+
console.log(chalk.gray(` ... and ${errors.length - 5} more`));
|
|
1535
|
+
}
|
|
1536
|
+
console.log();
|
|
1537
|
+
}
|
|
1538
|
+
function printFooter(score) {
|
|
1539
|
+
console.log(
|
|
1540
|
+
chalk.gray("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550")
|
|
1541
|
+
);
|
|
1542
|
+
const timestamp = new Date(score.timestamp).toLocaleString();
|
|
1543
|
+
console.log(chalk.gray(`Generated: ${timestamp}`));
|
|
1544
|
+
if (score.commitHash) {
|
|
1545
|
+
console.log(chalk.gray(`Commit: ${score.commitHash.slice(0, 8)}`));
|
|
1546
|
+
}
|
|
1547
|
+
if (score.subset) {
|
|
1548
|
+
console.log(chalk.yellow(`Subset analysis: ${score.filesGlob}`));
|
|
1549
|
+
}
|
|
1550
|
+
console.log();
|
|
1551
|
+
}
|
|
1552
|
+
function createProgressBar(percent, width) {
|
|
1553
|
+
const filled = Math.round(percent / 100 * width);
|
|
1554
|
+
const empty = width - filled;
|
|
1555
|
+
const filledChar = "\u2588";
|
|
1556
|
+
const emptyChar = "\u2591";
|
|
1557
|
+
const bar = filledChar.repeat(filled) + emptyChar.repeat(empty);
|
|
1558
|
+
if (percent >= 70) {
|
|
1559
|
+
return chalk.green(bar);
|
|
1560
|
+
} else if (percent >= 50) {
|
|
1561
|
+
return chalk.yellow(bar);
|
|
1562
|
+
} else {
|
|
1563
|
+
return chalk.red(bar);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
function generateSummaryLine(score) {
|
|
1567
|
+
const parts = [
|
|
1568
|
+
`FCIS Health: ${score.health.toFixed(0)}%`,
|
|
1569
|
+
`Purity: ${score.purity.toFixed(0)}%`
|
|
1570
|
+
];
|
|
1571
|
+
if (score.impurityQuality !== null) {
|
|
1572
|
+
parts.push(`Impurity Quality: ${score.impurityQuality.toFixed(0)}%`);
|
|
1573
|
+
}
|
|
1574
|
+
return parts.join(" | ");
|
|
1575
|
+
}
|
|
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
|
+
function padEnd(str, length) {
|
|
1585
|
+
if (str.length >= length) return str.slice(0, length);
|
|
1586
|
+
return str + " ".repeat(length - str.length);
|
|
1587
|
+
}
|
|
1588
|
+
function generateJsonReport(score, options = {}) {
|
|
1589
|
+
const { pretty = true, includeFunction = false } = options;
|
|
1590
|
+
const reportData = prepareReportData(score, includeFunction);
|
|
1591
|
+
if (pretty) {
|
|
1592
|
+
return JSON.stringify(reportData, null, 2);
|
|
1593
|
+
}
|
|
1594
|
+
return JSON.stringify(reportData);
|
|
1595
|
+
}
|
|
1596
|
+
function prepareReportData(score, includeFunctions) {
|
|
1597
|
+
if (includeFunctions) {
|
|
1598
|
+
return score;
|
|
1599
|
+
}
|
|
1600
|
+
return {
|
|
1601
|
+
...score,
|
|
1602
|
+
directoryScores: score.directoryScores.map((dir) => ({
|
|
1603
|
+
...dir,
|
|
1604
|
+
fileScores: dir.fileScores.map((file) => ({
|
|
1605
|
+
...file,
|
|
1606
|
+
functions: []
|
|
1607
|
+
// Strip function details to reduce size
|
|
1608
|
+
}))
|
|
1609
|
+
}))
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
function writeJsonReport(score, outputPath, options = {}) {
|
|
1613
|
+
const json = generateJsonReport(score, options);
|
|
1614
|
+
const dir = path4.dirname(outputPath);
|
|
1615
|
+
if (!fs3.existsSync(dir)) {
|
|
1616
|
+
fs3.mkdirSync(dir, { recursive: true });
|
|
1617
|
+
}
|
|
1618
|
+
fs3.writeFileSync(outputPath, json, "utf-8");
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// src/cli.ts
|
|
1622
|
+
var EXIT_SUCCESS = 0;
|
|
1623
|
+
var EXIT_THRESHOLD_FAILED = 1;
|
|
1624
|
+
var EXIT_CONFIG_ERROR = 2;
|
|
1625
|
+
var EXIT_ANALYSIS_ERROR = 3;
|
|
1626
|
+
var ThresholdSchema = z.number().min(0).max(100);
|
|
1627
|
+
var argv = cli({
|
|
1628
|
+
name: "fcis",
|
|
1629
|
+
version: "0.1.0",
|
|
1630
|
+
flags: {
|
|
1631
|
+
json: {
|
|
1632
|
+
type: Boolean,
|
|
1633
|
+
description: "Output JSON to stdout (for piping/parsing)",
|
|
1634
|
+
default: false
|
|
1635
|
+
},
|
|
1636
|
+
output: {
|
|
1637
|
+
type: String,
|
|
1638
|
+
alias: "o",
|
|
1639
|
+
description: "Write JSON report to file"
|
|
1640
|
+
},
|
|
1641
|
+
minHealth: {
|
|
1642
|
+
type: Number,
|
|
1643
|
+
description: "Exit with code 1 if project health < N (0-100)"
|
|
1644
|
+
},
|
|
1645
|
+
minPurity: {
|
|
1646
|
+
type: Number,
|
|
1647
|
+
description: "Exit with code 1 if purity < N (0-100)"
|
|
1648
|
+
},
|
|
1649
|
+
minQuality: {
|
|
1650
|
+
type: Number,
|
|
1651
|
+
description: "Exit with code 1 if impurity quality < N (0-100)"
|
|
1652
|
+
},
|
|
1653
|
+
files: {
|
|
1654
|
+
type: String,
|
|
1655
|
+
alias: "f",
|
|
1656
|
+
description: "Analyze only files matching glob (for incremental/pre-commit)"
|
|
1657
|
+
},
|
|
1658
|
+
format: {
|
|
1659
|
+
type: String,
|
|
1660
|
+
description: "Output format: console (default), json, summary",
|
|
1661
|
+
default: "console"
|
|
1662
|
+
},
|
|
1663
|
+
quiet: {
|
|
1664
|
+
type: Boolean,
|
|
1665
|
+
alias: "q",
|
|
1666
|
+
description: "Suppress all output except errors; rely on exit code",
|
|
1667
|
+
default: false
|
|
1668
|
+
},
|
|
1669
|
+
verbose: {
|
|
1670
|
+
type: Boolean,
|
|
1671
|
+
alias: "v",
|
|
1672
|
+
description: "Show per-file scores and all classified functions",
|
|
1673
|
+
default: false
|
|
1674
|
+
}
|
|
1675
|
+
},
|
|
1676
|
+
parameters: ["<tsconfig>"],
|
|
1677
|
+
help: {
|
|
1678
|
+
description: "Analyze TypeScript code for Functional Core, Imperative Shell patterns",
|
|
1679
|
+
examples: [
|
|
1680
|
+
"fcis analyze tsconfig.json",
|
|
1681
|
+
"fcis analyze tsconfig.json --min-health 70",
|
|
1682
|
+
"fcis analyze tsconfig.json --format json --output report.json",
|
|
1683
|
+
'fcis analyze tsconfig.json --files "src/services/**/*.ts"'
|
|
1684
|
+
]
|
|
1685
|
+
}
|
|
1686
|
+
});
|
|
1687
|
+
async function main() {
|
|
1688
|
+
const { flags, _: args } = argv;
|
|
1689
|
+
const tsconfigPath = args.tsconfig;
|
|
1690
|
+
if (!tsconfigPath) {
|
|
1691
|
+
console.error(chalk.red("Error: tsconfig path is required"));
|
|
1692
|
+
console.error("Usage: fcis <tsconfig> [options]");
|
|
1693
|
+
process.exit(EXIT_CONFIG_ERROR);
|
|
1694
|
+
}
|
|
1695
|
+
const absoluteTsconfigPath = path4.resolve(tsconfigPath);
|
|
1696
|
+
if (!fs3.existsSync(absoluteTsconfigPath)) {
|
|
1697
|
+
console.error(
|
|
1698
|
+
chalk.red(`Error: tsconfig.json not found at: ${absoluteTsconfigPath}`)
|
|
1699
|
+
);
|
|
1700
|
+
process.exit(EXIT_CONFIG_ERROR);
|
|
1701
|
+
}
|
|
1702
|
+
try {
|
|
1703
|
+
const content = fs3.readFileSync(absoluteTsconfigPath, "utf-8");
|
|
1704
|
+
const strippedContent = stripJsonComments(content);
|
|
1705
|
+
JSON.parse(strippedContent);
|
|
1706
|
+
} catch (e) {
|
|
1707
|
+
console.error(
|
|
1708
|
+
chalk.red(`Error: Invalid tsconfig.json at ${absoluteTsconfigPath}`)
|
|
1709
|
+
);
|
|
1710
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
1711
|
+
process.exit(EXIT_CONFIG_ERROR);
|
|
1712
|
+
}
|
|
1713
|
+
if (flags.minHealth !== void 0) {
|
|
1714
|
+
const result = ThresholdSchema.safeParse(flags.minHealth);
|
|
1715
|
+
if (!result.success) {
|
|
1716
|
+
console.error(
|
|
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
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
if (flags.minQuality !== void 0) {
|
|
1732
|
+
const result = ThresholdSchema.safeParse(flags.minQuality);
|
|
1733
|
+
if (!result.success) {
|
|
1734
|
+
console.error(
|
|
1735
|
+
chalk.red("Error: --min-quality must be a number between 0 and 100")
|
|
1736
|
+
);
|
|
1737
|
+
process.exit(EXIT_CONFIG_ERROR);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
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
|
+
if (flags.output) {
|
|
1748
|
+
const outputDir = path4.dirname(path4.resolve(flags.output));
|
|
1749
|
+
if (!fs3.existsSync(outputDir)) {
|
|
1750
|
+
try {
|
|
1751
|
+
fs3.mkdirSync(outputDir, { recursive: true });
|
|
1752
|
+
} catch (e) {
|
|
1753
|
+
console.error(
|
|
1754
|
+
chalk.red(`Error: Cannot create output directory: ${outputDir}`)
|
|
1755
|
+
);
|
|
1756
|
+
process.exit(EXIT_CONFIG_ERROR);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
try {
|
|
1761
|
+
if (!flags.quiet && flags.format === "console") {
|
|
1762
|
+
console.log(chalk.gray(`Analyzing ${process.cwd()}...`));
|
|
1763
|
+
}
|
|
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
|
+
}
|
|
1785
|
+
const score = await analyze(config);
|
|
1786
|
+
if (!flags.quiet) {
|
|
1787
|
+
if (flags.json || flags.format === "json") {
|
|
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
|
+
}
|
|
1811
|
+
const thresholdResult = checkThresholds(score, thresholdConfig);
|
|
1812
|
+
if (!thresholdResult.passed) {
|
|
1813
|
+
if (!flags.quiet) {
|
|
1814
|
+
console.log();
|
|
1815
|
+
console.log(chalk.red.bold("Threshold check failed:"));
|
|
1816
|
+
for (const failure of thresholdResult.failures) {
|
|
1817
|
+
console.log(chalk.red(` \u2717 ${failure}`));
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
process.exit(EXIT_THRESHOLD_FAILED);
|
|
1821
|
+
}
|
|
1822
|
+
process.exit(EXIT_SUCCESS);
|
|
1823
|
+
} catch (error) {
|
|
1824
|
+
if (!flags.quiet) {
|
|
1825
|
+
console.error(chalk.red("Analysis failed:"));
|
|
1826
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1827
|
+
}
|
|
1828
|
+
if (error instanceof Error && error.message.includes("No source files")) {
|
|
1829
|
+
process.exit(EXIT_ANALYSIS_ERROR);
|
|
1830
|
+
}
|
|
1831
|
+
process.exit(EXIT_ANALYSIS_ERROR);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
main();
|
|
1835
|
+
//# sourceMappingURL=cli.js.map
|
|
1836
|
+
//# sourceMappingURL=cli.js.map
|