@aiready/context-analyzer 0.21.10 → 0.21.11
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/.turbo/turbo-build.log +25 -26
- package/.turbo/turbo-test.log +39 -41
- package/dist/chunk-BW463GQB.mjs +1767 -0
- package/dist/chunk-CAX2MOUZ.mjs +1801 -0
- package/dist/chunk-J5TA3AZU.mjs +1795 -0
- package/dist/chunk-UXC6QUZ7.mjs +1801 -0
- package/dist/chunk-WTQJNY4U.mjs +1785 -0
- package/dist/chunk-XBFM2Z4O.mjs +1792 -0
- package/dist/cli.js +282 -220
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +3 -5
- package/dist/index.d.ts +3 -5
- package/dist/index.js +286 -224
- package/dist/index.mjs +1 -1
- package/package.json +2 -2
- package/src/__tests__/cluster-detector.test.ts +5 -8
- package/src/__tests__/fragmentation-coupling.test.ts +6 -3
- package/src/analyzer.ts +1 -224
- package/src/classifier.ts +11 -0
- package/src/cluster-detector.ts +22 -5
- package/src/heuristics.ts +150 -81
- package/src/mapper.ts +118 -0
- package/src/metrics.ts +13 -12
- package/src/orchestrator.ts +136 -0
- package/src/remediation.ts +5 -0
- package/src/types.ts +1 -0
package/src/heuristics.ts
CHANGED
|
@@ -1,10 +1,75 @@
|
|
|
1
1
|
import { DependencyNode } from './types';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Heuristic patterns for file classification.
|
|
5
|
+
*/
|
|
6
|
+
const BARREL_EXPORT_MIN_EXPORTS = 5;
|
|
7
|
+
const BARREL_EXPORT_TOKEN_LIMIT = 1000;
|
|
8
|
+
|
|
9
|
+
const HANDLER_NAME_PATTERNS = [
|
|
10
|
+
'handler',
|
|
11
|
+
'.handler.',
|
|
12
|
+
'-handler.',
|
|
13
|
+
'lambda',
|
|
14
|
+
'.lambda.',
|
|
15
|
+
'-lambda.',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const SERVICE_NAME_PATTERNS = [
|
|
19
|
+
'service',
|
|
20
|
+
'.service.',
|
|
21
|
+
'-service.',
|
|
22
|
+
'_service.',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const EMAIL_NAME_PATTERNS = [
|
|
26
|
+
'-email-',
|
|
27
|
+
'.email.',
|
|
28
|
+
'_email_',
|
|
29
|
+
'-template',
|
|
30
|
+
'.template.',
|
|
31
|
+
'_template',
|
|
32
|
+
'-mail.',
|
|
33
|
+
'.mail.',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const PARSER_NAME_PATTERNS = [
|
|
37
|
+
'parser',
|
|
38
|
+
'.parser.',
|
|
39
|
+
'-parser.',
|
|
40
|
+
'_parser.',
|
|
41
|
+
'transform',
|
|
42
|
+
'converter',
|
|
43
|
+
'mapper',
|
|
44
|
+
'serializer',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const SESSION_NAME_PATTERNS = ['session', 'state', 'context', 'store'];
|
|
48
|
+
|
|
49
|
+
const NEXTJS_METADATA_EXPORTS = [
|
|
50
|
+
'metadata',
|
|
51
|
+
'generatemetadata',
|
|
52
|
+
'faqjsonld',
|
|
53
|
+
'jsonld',
|
|
54
|
+
'icon',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const CONFIG_NAME_PATTERNS = [
|
|
58
|
+
'.config.',
|
|
59
|
+
'tsconfig',
|
|
60
|
+
'jest.config',
|
|
61
|
+
'package.json',
|
|
62
|
+
'aiready.json',
|
|
63
|
+
'next.config',
|
|
64
|
+
'sst.config',
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Detect if a file is a barrel export (index.ts).
|
|
5
69
|
*
|
|
6
70
|
* @param node - The dependency node to analyze.
|
|
7
71
|
* @returns True if the file matches barrel export patterns.
|
|
72
|
+
* @lastUpdated 2026-03-18
|
|
8
73
|
*/
|
|
9
74
|
export function isBarrelExport(node: DependencyNode): boolean {
|
|
10
75
|
const { file, exports } = node;
|
|
@@ -12,22 +77,24 @@ export function isBarrelExport(node: DependencyNode): boolean {
|
|
|
12
77
|
|
|
13
78
|
const isIndexFile = fileName === 'index.ts' || fileName === 'index.js';
|
|
14
79
|
const isSmallAndManyExports =
|
|
15
|
-
node.tokenCost <
|
|
80
|
+
node.tokenCost < BARREL_EXPORT_TOKEN_LIMIT &&
|
|
81
|
+
(exports || []).length > BARREL_EXPORT_MIN_EXPORTS;
|
|
16
82
|
|
|
17
83
|
const isReexportPattern =
|
|
18
|
-
(exports || []).length >=
|
|
19
|
-
(exports || []).every((
|
|
20
|
-
['const', 'function', 'type', 'interface'].includes(
|
|
84
|
+
(exports || []).length >= BARREL_EXPORT_MIN_EXPORTS &&
|
|
85
|
+
(exports || []).every((exp: any) =>
|
|
86
|
+
['const', 'function', 'type', 'interface'].includes(exp.type)
|
|
21
87
|
);
|
|
22
88
|
|
|
23
89
|
return !!isIndexFile || !!isSmallAndManyExports || !!isReexportPattern;
|
|
24
90
|
}
|
|
25
91
|
|
|
26
92
|
/**
|
|
27
|
-
* Detect if a file is primarily type definitions
|
|
93
|
+
* Detect if a file is primarily type definitions.
|
|
28
94
|
*
|
|
29
95
|
* @param node - The dependency node to analyze.
|
|
30
96
|
* @returns True if the file contains primarily types or matches type paths.
|
|
97
|
+
* @lastUpdated 2026-03-18
|
|
31
98
|
*/
|
|
32
99
|
export function isTypeDefinition(node: DependencyNode): boolean {
|
|
33
100
|
const { file } = node;
|
|
@@ -37,7 +104,9 @@ export function isTypeDefinition(node: DependencyNode): boolean {
|
|
|
37
104
|
const hasExports = nodeExports.length > 0;
|
|
38
105
|
const areAllTypes =
|
|
39
106
|
hasExports &&
|
|
40
|
-
nodeExports.every(
|
|
107
|
+
nodeExports.every(
|
|
108
|
+
(exp: any) => exp.type === 'type' || exp.type === 'interface'
|
|
109
|
+
);
|
|
41
110
|
|
|
42
111
|
const isTypePath = /\/(types|interfaces|models)\//i.test(file);
|
|
43
112
|
return !!areAllTypes || (isTypePath && hasExports);
|
|
@@ -48,6 +117,7 @@ export function isTypeDefinition(node: DependencyNode): boolean {
|
|
|
48
117
|
*
|
|
49
118
|
* @param node - The dependency node to analyze.
|
|
50
119
|
* @returns True if the file path or name suggests a utility/helper role.
|
|
120
|
+
* @lastUpdated 2026-03-18
|
|
51
121
|
*/
|
|
52
122
|
export function isUtilityModule(node: DependencyNode): boolean {
|
|
53
123
|
const { file } = node;
|
|
@@ -62,24 +132,20 @@ export function isUtilityModule(node: DependencyNode): boolean {
|
|
|
62
132
|
*
|
|
63
133
|
* @param node - The dependency node to analyze.
|
|
64
134
|
* @returns True if the file serves as a coordination point for requests/lambdas.
|
|
135
|
+
* @lastUpdated 2026-03-18
|
|
65
136
|
*/
|
|
66
137
|
export function isLambdaHandler(node: DependencyNode): boolean {
|
|
67
138
|
const { file, exports } = node;
|
|
68
139
|
const fileName = file.split('/').pop()?.toLowerCase() || '';
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
'lambda',
|
|
74
|
-
'.lambda.',
|
|
75
|
-
'-lambda.',
|
|
76
|
-
];
|
|
77
|
-
const isHandlerName = handlerPatterns.some((p) => fileName.includes(p));
|
|
140
|
+
|
|
141
|
+
const isHandlerName = HANDLER_NAME_PATTERNS.some((pattern: string) =>
|
|
142
|
+
fileName.includes(pattern)
|
|
143
|
+
);
|
|
78
144
|
const isHandlerPath = /\/(handlers|lambdas|lambda|functions)\//i.test(file);
|
|
79
145
|
const hasHandlerExport = (exports || []).some(
|
|
80
|
-
(
|
|
81
|
-
['handler', 'main', 'lambdahandler'].includes(
|
|
82
|
-
|
|
146
|
+
(exp: any) =>
|
|
147
|
+
['handler', 'main', 'lambdahandler'].includes(exp.name.toLowerCase()) ||
|
|
148
|
+
exp.name.toLowerCase().endsWith('handler')
|
|
83
149
|
);
|
|
84
150
|
return isHandlerName || isHandlerPath || hasHandlerExport;
|
|
85
151
|
}
|
|
@@ -89,17 +155,22 @@ export function isLambdaHandler(node: DependencyNode): boolean {
|
|
|
89
155
|
*
|
|
90
156
|
* @param node - The dependency node to analyze.
|
|
91
157
|
* @returns True if the file orchestrates logic or matches service patterns.
|
|
158
|
+
* @lastUpdated 2026-03-18
|
|
92
159
|
*/
|
|
93
160
|
export function isServiceFile(node: DependencyNode): boolean {
|
|
94
161
|
const { file, exports } = node;
|
|
95
162
|
const fileName = file.split('/').pop()?.toLowerCase() || '';
|
|
96
|
-
|
|
97
|
-
const isServiceName =
|
|
163
|
+
|
|
164
|
+
const isServiceName = SERVICE_NAME_PATTERNS.some((pattern: string) =>
|
|
165
|
+
fileName.includes(pattern)
|
|
166
|
+
);
|
|
98
167
|
const isServicePath = file.toLowerCase().includes('/services/');
|
|
99
|
-
const hasServiceNamedExport = (exports || []).some((
|
|
100
|
-
|
|
168
|
+
const hasServiceNamedExport = (exports || []).some((exp: any) =>
|
|
169
|
+
exp.name.toLowerCase().includes('service')
|
|
170
|
+
);
|
|
171
|
+
const hasClassExport = (exports || []).some(
|
|
172
|
+
(exp: any) => exp.type === 'class'
|
|
101
173
|
);
|
|
102
|
-
const hasClassExport = (exports || []).some((e) => e.type === 'class');
|
|
103
174
|
return (
|
|
104
175
|
isServiceName || isServicePath || (hasServiceNamedExport && hasClassExport)
|
|
105
176
|
);
|
|
@@ -110,27 +181,21 @@ export function isServiceFile(node: DependencyNode): boolean {
|
|
|
110
181
|
*
|
|
111
182
|
* @param node - The dependency node to analyze.
|
|
112
183
|
* @returns True if the file is used for rendering notifications or emails.
|
|
184
|
+
* @lastUpdated 2026-03-18
|
|
113
185
|
*/
|
|
114
186
|
export function isEmailTemplate(node: DependencyNode): boolean {
|
|
115
187
|
const { file, exports } = node;
|
|
116
188
|
const fileName = file.split('/').pop()?.toLowerCase() || '';
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
'-template',
|
|
122
|
-
'.template.',
|
|
123
|
-
'_template',
|
|
124
|
-
'-mail.',
|
|
125
|
-
'.mail.',
|
|
126
|
-
];
|
|
127
|
-
const isEmailName = emailPatterns.some((p) => fileName.includes(p));
|
|
189
|
+
|
|
190
|
+
const isEmailName = EMAIL_NAME_PATTERNS.some((pattern: string) =>
|
|
191
|
+
fileName.includes(pattern)
|
|
192
|
+
);
|
|
128
193
|
const isEmailPath = /\/(emails|mail|notifications)\//i.test(file);
|
|
129
194
|
const hasTemplateFunction = (exports || []).some(
|
|
130
|
-
(
|
|
131
|
-
|
|
132
|
-
(
|
|
133
|
-
|
|
195
|
+
(exp: any) =>
|
|
196
|
+
exp.type === 'function' &&
|
|
197
|
+
(exp.name.toLowerCase().startsWith('render') ||
|
|
198
|
+
exp.name.toLowerCase().startsWith('generate'))
|
|
134
199
|
);
|
|
135
200
|
return isEmailPath || isEmailName || hasTemplateFunction;
|
|
136
201
|
}
|
|
@@ -140,27 +205,21 @@ export function isEmailTemplate(node: DependencyNode): boolean {
|
|
|
140
205
|
*
|
|
141
206
|
* @param node - The dependency node to analyze.
|
|
142
207
|
* @returns True if the file handles data conversion or serialization.
|
|
208
|
+
* @lastUpdated 2026-03-18
|
|
143
209
|
*/
|
|
144
210
|
export function isParserFile(node: DependencyNode): boolean {
|
|
145
211
|
const { file, exports } = node;
|
|
146
212
|
const fileName = file.split('/').pop()?.toLowerCase() || '';
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
'_parser.',
|
|
152
|
-
'transform',
|
|
153
|
-
'converter',
|
|
154
|
-
'mapper',
|
|
155
|
-
'serializer',
|
|
156
|
-
];
|
|
157
|
-
const isParserName = parserPatterns.some((p) => fileName.includes(p));
|
|
213
|
+
|
|
214
|
+
const isParserName = PARSER_NAME_PATTERNS.some((pattern: string) =>
|
|
215
|
+
fileName.includes(pattern)
|
|
216
|
+
);
|
|
158
217
|
const isParserPath = /\/(parsers|transformers)\//i.test(file);
|
|
159
218
|
const hasParseFunction = (exports || []).some(
|
|
160
|
-
(
|
|
161
|
-
|
|
162
|
-
(
|
|
163
|
-
|
|
219
|
+
(exp: any) =>
|
|
220
|
+
exp.type === 'function' &&
|
|
221
|
+
(exp.name.toLowerCase().startsWith('parse') ||
|
|
222
|
+
exp.name.toLowerCase().startsWith('transform'))
|
|
164
223
|
);
|
|
165
224
|
return isParserName || isParserPath || hasParseFunction;
|
|
166
225
|
}
|
|
@@ -170,15 +229,20 @@ export function isParserFile(node: DependencyNode): boolean {
|
|
|
170
229
|
*
|
|
171
230
|
* @param node - The dependency node to analyze.
|
|
172
231
|
* @returns True if the file manages application state or sessions.
|
|
232
|
+
* @lastUpdated 2026-03-18
|
|
173
233
|
*/
|
|
174
234
|
export function isSessionFile(node: DependencyNode): boolean {
|
|
175
235
|
const { file, exports } = node;
|
|
176
236
|
const fileName = file.split('/').pop()?.toLowerCase() || '';
|
|
177
|
-
|
|
178
|
-
const isSessionName =
|
|
237
|
+
|
|
238
|
+
const isSessionName = SESSION_NAME_PATTERNS.some((pattern: string) =>
|
|
239
|
+
fileName.includes(pattern)
|
|
240
|
+
);
|
|
179
241
|
const isSessionPath = /\/(sessions|state)\//i.test(file);
|
|
180
|
-
const hasSessionExport = (exports || []).some((
|
|
181
|
-
['session', 'state', 'store'].some((
|
|
242
|
+
const hasSessionExport = (exports || []).some((exp: any) =>
|
|
243
|
+
['session', 'state', 'store'].some((pattern: string) =>
|
|
244
|
+
exp.name.toLowerCase().includes(pattern)
|
|
245
|
+
)
|
|
182
246
|
);
|
|
183
247
|
return isSessionName || isSessionPath || hasSessionExport;
|
|
184
248
|
}
|
|
@@ -188,6 +252,7 @@ export function isSessionFile(node: DependencyNode): boolean {
|
|
|
188
252
|
*
|
|
189
253
|
* @param node - The dependency node to analyze.
|
|
190
254
|
* @returns True if the file is a Next.js page or metadata entry.
|
|
255
|
+
* @lastUpdated 2026-03-18
|
|
191
256
|
*/
|
|
192
257
|
export function isNextJsPage(node: DependencyNode): boolean {
|
|
193
258
|
const { file, exports } = node;
|
|
@@ -199,16 +264,12 @@ export function isNextJsPage(node: DependencyNode): boolean {
|
|
|
199
264
|
if (!isInAppDir || (fileName !== 'page.tsx' && fileName !== 'page.ts'))
|
|
200
265
|
return false;
|
|
201
266
|
|
|
202
|
-
const hasDefaultExport = (exports || []).some(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
'icon',
|
|
209
|
-
];
|
|
210
|
-
const hasNextJsExport = (exports || []).some((e) =>
|
|
211
|
-
nextJsExports.includes(e.name.toLowerCase())
|
|
267
|
+
const hasDefaultExport = (exports || []).some(
|
|
268
|
+
(exp: any) => exp.type === 'default'
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const hasNextJsExport = (exports || []).some((exp: any) =>
|
|
272
|
+
NEXTJS_METADATA_EXPORTS.includes(exp.name.toLowerCase())
|
|
212
273
|
);
|
|
213
274
|
|
|
214
275
|
return hasDefaultExport || hasNextJsExport;
|
|
@@ -219,28 +280,36 @@ export function isNextJsPage(node: DependencyNode): boolean {
|
|
|
219
280
|
*
|
|
220
281
|
* @param node - The dependency node to analyze.
|
|
221
282
|
* @returns True if the file matches configuration, setting, or schema patterns.
|
|
283
|
+
* @lastUpdated 2026-03-18
|
|
222
284
|
*/
|
|
223
285
|
export function isConfigFile(node: DependencyNode): boolean {
|
|
224
286
|
const { file, exports } = node;
|
|
225
287
|
const lowerPath = file.toLowerCase();
|
|
226
288
|
const fileName = file.split('/').pop()?.toLowerCase() || '';
|
|
227
289
|
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
'jest.config',
|
|
232
|
-
'package.json',
|
|
233
|
-
'aiready.json',
|
|
234
|
-
'next.config',
|
|
235
|
-
'sst.config',
|
|
236
|
-
];
|
|
237
|
-
const isConfigName = configPatterns.some((p) => fileName.includes(p));
|
|
290
|
+
const isConfigName = CONFIG_NAME_PATTERNS.some((pattern: string) =>
|
|
291
|
+
fileName.includes(pattern)
|
|
292
|
+
);
|
|
238
293
|
const isConfigPath = /\/(config|settings|schemas)\//i.test(lowerPath);
|
|
239
|
-
const hasSchemaExport = (exports || []).some((
|
|
240
|
-
['schema', 'config', 'setting'].some((
|
|
241
|
-
|
|
294
|
+
const hasSchemaExport = (exports || []).some((exp: any) =>
|
|
295
|
+
['schema', 'config', 'setting'].some((pattern: string) =>
|
|
296
|
+
exp.name.toLowerCase().includes(pattern)
|
|
242
297
|
)
|
|
243
298
|
);
|
|
244
299
|
|
|
245
300
|
return isConfigName || isConfigPath || hasSchemaExport;
|
|
246
301
|
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Detect if a file is part of a hub-and-spoke monorepo architecture.
|
|
305
|
+
*
|
|
306
|
+
* Many files spread across multiple packages (spokes) is intentional in
|
|
307
|
+
* AIReady and shouldn't be penalized as heavily for fragmentation.
|
|
308
|
+
*
|
|
309
|
+
* @param node - The dependency node to analyze.
|
|
310
|
+
* @returns True if the file path suggests it belongs to a spoke package.
|
|
311
|
+
*/
|
|
312
|
+
export function isHubAndSpokeFile(node: DependencyNode): boolean {
|
|
313
|
+
const { file } = node;
|
|
314
|
+
return /\/packages\/[a-zA-Z0-9-]+\/src\//.test(file);
|
|
315
|
+
}
|
package/src/mapper.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContextAnalysisResult,
|
|
3
|
+
DependencyGraph,
|
|
4
|
+
DependencyNode,
|
|
5
|
+
ModuleCluster,
|
|
6
|
+
} from './types';
|
|
7
|
+
import { calculateEnhancedCohesion } from './metrics';
|
|
8
|
+
import { analyzeIssues } from './issue-analyzer';
|
|
9
|
+
import {
|
|
10
|
+
calculateImportDepth,
|
|
11
|
+
getTransitiveDependencies,
|
|
12
|
+
calculateContextBudget,
|
|
13
|
+
} from './graph-builder';
|
|
14
|
+
import {
|
|
15
|
+
classifyFile,
|
|
16
|
+
adjustCohesionForClassification,
|
|
17
|
+
adjustFragmentationForClassification,
|
|
18
|
+
} from './classifier';
|
|
19
|
+
import { getClassificationRecommendations } from './remediation';
|
|
20
|
+
|
|
21
|
+
export interface MappingOptions {
|
|
22
|
+
maxDepth: number;
|
|
23
|
+
maxContextBudget: number;
|
|
24
|
+
minCohesion: number;
|
|
25
|
+
maxFragmentation: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Maps a single dependency node to a comprehensive ContextAnalysisResult.
|
|
30
|
+
*/
|
|
31
|
+
export function mapNodeToResult(
|
|
32
|
+
node: DependencyNode,
|
|
33
|
+
graph: DependencyGraph,
|
|
34
|
+
clusters: ModuleCluster[],
|
|
35
|
+
allCircularDeps: string[][],
|
|
36
|
+
options: MappingOptions
|
|
37
|
+
): ContextAnalysisResult {
|
|
38
|
+
const file = node.file;
|
|
39
|
+
const tokenCost = node.tokenCost;
|
|
40
|
+
const importDepth = calculateImportDepth(file, graph);
|
|
41
|
+
const transitiveDeps = getTransitiveDependencies(file, graph);
|
|
42
|
+
const contextBudget = calculateContextBudget(file, graph);
|
|
43
|
+
const circularDeps = allCircularDeps.filter((cycle) => cycle.includes(file));
|
|
44
|
+
|
|
45
|
+
// Find cluster for this file
|
|
46
|
+
const cluster = clusters.find((c) => c.files.includes(file));
|
|
47
|
+
const rawFragmentationScore = cluster ? cluster.fragmentationScore : 0;
|
|
48
|
+
|
|
49
|
+
// Cohesion
|
|
50
|
+
const rawCohesionScore = calculateEnhancedCohesion(
|
|
51
|
+
node.exports,
|
|
52
|
+
file,
|
|
53
|
+
options as any
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Initial classification
|
|
57
|
+
const fileClassification = classifyFile(node, rawCohesionScore);
|
|
58
|
+
|
|
59
|
+
// Adjust scores based on classification
|
|
60
|
+
const cohesionScore = adjustCohesionForClassification(
|
|
61
|
+
rawCohesionScore,
|
|
62
|
+
fileClassification
|
|
63
|
+
);
|
|
64
|
+
const fragmentationScore = adjustFragmentationForClassification(
|
|
65
|
+
rawFragmentationScore,
|
|
66
|
+
fileClassification
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const { severity, issues, recommendations, potentialSavings } = analyzeIssues(
|
|
70
|
+
{
|
|
71
|
+
file,
|
|
72
|
+
importDepth,
|
|
73
|
+
contextBudget,
|
|
74
|
+
cohesionScore,
|
|
75
|
+
fragmentationScore,
|
|
76
|
+
maxDepth: options.maxDepth,
|
|
77
|
+
maxContextBudget: options.maxContextBudget,
|
|
78
|
+
minCohesion: options.minCohesion,
|
|
79
|
+
maxFragmentation: options.maxFragmentation,
|
|
80
|
+
circularDeps,
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Add classification-specific recommendations
|
|
85
|
+
const classRecs = getClassificationRecommendations(
|
|
86
|
+
fileClassification,
|
|
87
|
+
file,
|
|
88
|
+
issues
|
|
89
|
+
);
|
|
90
|
+
const allRecommendations = Array.from(
|
|
91
|
+
new Set([...recommendations, ...classRecs])
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
file,
|
|
96
|
+
tokenCost,
|
|
97
|
+
linesOfCode: node.linesOfCode,
|
|
98
|
+
importDepth,
|
|
99
|
+
dependencyCount: transitiveDeps.length,
|
|
100
|
+
dependencyList: transitiveDeps,
|
|
101
|
+
circularDeps,
|
|
102
|
+
cohesionScore,
|
|
103
|
+
domains: Array.from(
|
|
104
|
+
new Set(
|
|
105
|
+
node.exports.flatMap((e) => e.domains?.map((d) => d.domain) || [])
|
|
106
|
+
)
|
|
107
|
+
),
|
|
108
|
+
exportCount: node.exports.length,
|
|
109
|
+
contextBudget,
|
|
110
|
+
fragmentationScore,
|
|
111
|
+
relatedFiles: cluster ? cluster.files : [],
|
|
112
|
+
fileClassification,
|
|
113
|
+
severity,
|
|
114
|
+
issues,
|
|
115
|
+
recommendations: allRecommendations,
|
|
116
|
+
potentialSavings,
|
|
117
|
+
};
|
|
118
|
+
}
|
package/src/metrics.ts
CHANGED
|
@@ -32,7 +32,8 @@ export function calculateEnhancedCohesion(
|
|
|
32
32
|
// 1. Domain-based cohesion using entropy
|
|
33
33
|
const domains = exports.map((e) => e.inferredDomain || 'unknown');
|
|
34
34
|
const domainCounts = new Map<string, number>();
|
|
35
|
-
for (const
|
|
35
|
+
for (const domain of domains)
|
|
36
|
+
domainCounts.set(domain, (domainCounts.get(domain) || 0) + 1);
|
|
36
37
|
|
|
37
38
|
// IF ALL DOMAINS MATCH, RETURN 1.0 IMMEDIATELY (Legacy test compatibility)
|
|
38
39
|
if (domainCounts.size === 1 && domains[0] !== 'unknown') {
|
|
@@ -40,11 +41,11 @@ export function calculateEnhancedCohesion(
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
const probs = Array.from(domainCounts.values()).map(
|
|
43
|
-
(
|
|
44
|
+
(count) => count / exports.length
|
|
44
45
|
);
|
|
45
46
|
let domainEntropy = 0;
|
|
46
|
-
for (const
|
|
47
|
-
if (
|
|
47
|
+
for (const prob of probs) {
|
|
48
|
+
if (prob > 0) domainEntropy -= prob * Math.log2(prob);
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
const maxEntropy = Math.log2(Math.max(2, domainCounts.size));
|
|
@@ -158,7 +159,7 @@ export function calculateFragmentation(
|
|
|
158
159
|
if (files.length <= 1) return 0;
|
|
159
160
|
|
|
160
161
|
const directories = new Set(
|
|
161
|
-
files.map((
|
|
162
|
+
files.map((file) => file.split('/').slice(0, -1).join('/'))
|
|
162
163
|
);
|
|
163
164
|
const uniqueDirs = directories.size;
|
|
164
165
|
|
|
@@ -189,15 +190,15 @@ export function calculatePathEntropy(files: string[]): number {
|
|
|
189
190
|
if (!files || files.length === 0) return 0;
|
|
190
191
|
|
|
191
192
|
const dirCounts = new Map<string, number>();
|
|
192
|
-
for (const
|
|
193
|
-
const dir =
|
|
193
|
+
for (const file of files) {
|
|
194
|
+
const dir = file.split('/').slice(0, -1).join('/') || '.';
|
|
194
195
|
dirCounts.set(dir, (dirCounts.get(dir) || 0) + 1);
|
|
195
196
|
}
|
|
196
197
|
|
|
197
198
|
const counts = Array.from(dirCounts.values());
|
|
198
199
|
if (counts.length <= 1) return 0;
|
|
199
200
|
|
|
200
|
-
const total = counts.reduce((
|
|
201
|
+
const total = counts.reduce((sum, value) => sum + value, 0);
|
|
201
202
|
let entropy = 0;
|
|
202
203
|
for (const count of counts) {
|
|
203
204
|
const prob = count / total;
|
|
@@ -217,11 +218,11 @@ export function calculatePathEntropy(files: string[]): number {
|
|
|
217
218
|
export function calculateDirectoryDistance(files: string[]): number {
|
|
218
219
|
if (!files || files.length <= 1) return 0;
|
|
219
220
|
|
|
220
|
-
const pathSegments = (
|
|
221
|
-
const commonAncestorDepth = (
|
|
222
|
-
const minLen = Math.min(
|
|
221
|
+
const pathSegments = (pathStr: string) => pathStr.split('/').filter(Boolean);
|
|
222
|
+
const commonAncestorDepth = (pathA: string[], pathB: string[]) => {
|
|
223
|
+
const minLen = Math.min(pathA.length, pathB.length);
|
|
223
224
|
let i = 0;
|
|
224
|
-
while (i < minLen &&
|
|
225
|
+
while (i < minLen && pathA[i] === pathB[i]) i++;
|
|
225
226
|
return i;
|
|
226
227
|
};
|
|
227
228
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { scanFiles, readFileContent } from '@aiready/core';
|
|
2
|
+
import type {
|
|
3
|
+
ContextAnalysisResult,
|
|
4
|
+
ContextAnalyzerOptions,
|
|
5
|
+
FileClassification,
|
|
6
|
+
} from './types';
|
|
7
|
+
import { calculateEnhancedCohesion } from './metrics';
|
|
8
|
+
import { analyzeIssues } from './issue-analyzer';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
buildDependencyGraph,
|
|
12
|
+
detectCircularDependencies,
|
|
13
|
+
} from './graph-builder';
|
|
14
|
+
import { detectModuleClusters } from './cluster-detector';
|
|
15
|
+
import { mapNodeToResult } from './mapper';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Calculate cohesion score (how related are exports in a file).
|
|
19
|
+
* Legacy wrapper for backward compatibility with exact test expectations.
|
|
20
|
+
*
|
|
21
|
+
* @param exports - List of exported symbols
|
|
22
|
+
* @param filePath - Path to the file being analyzed
|
|
23
|
+
* @param options - Additional options for cohesion calculation
|
|
24
|
+
* @returns Cohesion score between 0 and 1
|
|
25
|
+
*/
|
|
26
|
+
export function calculateCohesion(
|
|
27
|
+
exports: any[],
|
|
28
|
+
filePath?: string,
|
|
29
|
+
options?: any
|
|
30
|
+
): number {
|
|
31
|
+
return calculateEnhancedCohesion(exports, filePath, options);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Performs deep context analysis of a project.
|
|
36
|
+
* Scans files, builds a dependency graph, calculates context budgets,
|
|
37
|
+
* and identifies structural issues like high fragmentation or depth.
|
|
38
|
+
*
|
|
39
|
+
* @param options - Analysis parameters including root directory and focus areas
|
|
40
|
+
* @returns Comprehensive analysis results with metrics and identified issues
|
|
41
|
+
*/
|
|
42
|
+
export async function analyzeContext(
|
|
43
|
+
options: ContextAnalyzerOptions
|
|
44
|
+
): Promise<ContextAnalysisResult[]> {
|
|
45
|
+
const {
|
|
46
|
+
maxDepth = 5,
|
|
47
|
+
maxContextBudget = 25000,
|
|
48
|
+
minCohesion = 0.6,
|
|
49
|
+
maxFragmentation = 0.5,
|
|
50
|
+
includeNodeModules = false,
|
|
51
|
+
...scanOptions
|
|
52
|
+
} = options;
|
|
53
|
+
|
|
54
|
+
const files = await scanFiles({
|
|
55
|
+
...scanOptions,
|
|
56
|
+
exclude:
|
|
57
|
+
includeNodeModules && scanOptions.exclude
|
|
58
|
+
? scanOptions.exclude.filter(
|
|
59
|
+
(pattern) => pattern !== '**/node_modules/**'
|
|
60
|
+
)
|
|
61
|
+
: scanOptions.exclude,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const pythonFiles = files.filter((f) => f.toLowerCase().endsWith('.py'));
|
|
65
|
+
const fileContents = await Promise.all(
|
|
66
|
+
files.map(async (file) => ({
|
|
67
|
+
file,
|
|
68
|
+
content: await readFileContent(file),
|
|
69
|
+
}))
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const graph = buildDependencyGraph(
|
|
73
|
+
fileContents.filter((f) => !f.file.toLowerCase().endsWith('.py'))
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
let pythonResults: ContextAnalysisResult[] = [];
|
|
77
|
+
if (pythonFiles.length > 0) {
|
|
78
|
+
const { analyzePythonContext } = await import('./analyzers/python-context');
|
|
79
|
+
const pythonMetrics = await analyzePythonContext(
|
|
80
|
+
pythonFiles,
|
|
81
|
+
scanOptions.rootDir || options.rootDir || '.'
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
pythonResults = pythonMetrics.map((metric) => {
|
|
85
|
+
const { severity, issues, recommendations, potentialSavings } =
|
|
86
|
+
analyzeIssues({
|
|
87
|
+
file: metric.file,
|
|
88
|
+
importDepth: metric.importDepth,
|
|
89
|
+
contextBudget: metric.contextBudget,
|
|
90
|
+
cohesionScore: metric.cohesion,
|
|
91
|
+
fragmentationScore: 0,
|
|
92
|
+
maxDepth,
|
|
93
|
+
maxContextBudget,
|
|
94
|
+
minCohesion,
|
|
95
|
+
maxFragmentation,
|
|
96
|
+
circularDeps: [],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
file: metric.file,
|
|
101
|
+
tokenCost: 0,
|
|
102
|
+
linesOfCode: 0,
|
|
103
|
+
importDepth: metric.importDepth,
|
|
104
|
+
dependencyCount: 0,
|
|
105
|
+
dependencyList: [],
|
|
106
|
+
circularDeps: [],
|
|
107
|
+
cohesionScore: metric.cohesion,
|
|
108
|
+
domains: [],
|
|
109
|
+
exportCount: 0,
|
|
110
|
+
contextBudget: metric.contextBudget,
|
|
111
|
+
fragmentationScore: 0,
|
|
112
|
+
relatedFiles: [],
|
|
113
|
+
fileClassification: 'unknown' as FileClassification,
|
|
114
|
+
severity,
|
|
115
|
+
issues,
|
|
116
|
+
recommendations,
|
|
117
|
+
potentialSavings,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const clusters = detectModuleClusters(graph);
|
|
123
|
+
const allCircularDeps = detectCircularDependencies(graph);
|
|
124
|
+
|
|
125
|
+
const results: ContextAnalysisResult[] = Array.from(graph.nodes.values()).map(
|
|
126
|
+
(node) =>
|
|
127
|
+
mapNodeToResult(node, graph, clusters, allCircularDeps, {
|
|
128
|
+
maxDepth,
|
|
129
|
+
maxContextBudget,
|
|
130
|
+
minCohesion,
|
|
131
|
+
maxFragmentation,
|
|
132
|
+
})
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return [...results, ...pythonResults];
|
|
136
|
+
}
|