@aiready/context-analyzer 0.9.40 → 0.9.42
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 +11 -11
- package/.turbo/turbo-test.log +20 -23
- package/dist/chunk-4SYIJ7CU.mjs +1538 -0
- package/dist/chunk-4XQVYYPC.mjs +1470 -0
- package/dist/chunk-5CLU3HYU.mjs +1475 -0
- package/dist/chunk-5K73Q3OQ.mjs +1520 -0
- package/dist/chunk-6AVS4KTM.mjs +1536 -0
- package/dist/chunk-6I4552YB.mjs +1467 -0
- package/dist/chunk-6LPITDKG.mjs +1539 -0
- package/dist/chunk-AECWO7NQ.mjs +1539 -0
- package/dist/chunk-AJC3FR6G.mjs +1509 -0
- package/dist/chunk-CVGIDSMN.mjs +1522 -0
- package/dist/chunk-DXG5NIYL.mjs +1527 -0
- package/dist/chunk-G3CCJCBI.mjs +1521 -0
- package/dist/chunk-GFADGYXZ.mjs +1752 -0
- package/dist/chunk-GTRIBVS6.mjs +1467 -0
- package/dist/chunk-H4HWBQU6.mjs +1530 -0
- package/dist/chunk-JH535NPP.mjs +1619 -0
- package/dist/chunk-KGFWKSGJ.mjs +1442 -0
- package/dist/chunk-N2GQWNFG.mjs +1527 -0
- package/dist/chunk-NQA3F2HJ.mjs +1532 -0
- package/dist/chunk-NXXQ2U73.mjs +1467 -0
- package/dist/chunk-QDGPR3L6.mjs +1518 -0
- package/dist/chunk-SAVOSPM3.mjs +1522 -0
- package/dist/chunk-SIX4KMF2.mjs +1468 -0
- package/dist/chunk-SPAM2YJE.mjs +1537 -0
- package/dist/chunk-UG7OPVHB.mjs +1521 -0
- package/dist/chunk-VIJTZPBI.mjs +1470 -0
- package/dist/chunk-W37E7MW5.mjs +1403 -0
- package/dist/chunk-W76FEISE.mjs +1538 -0
- package/dist/chunk-WCFQYXQA.mjs +1532 -0
- package/dist/chunk-XY77XABG.mjs +1545 -0
- package/dist/chunk-YCGDIGOG.mjs +1467 -0
- package/dist/cli.js +768 -1160
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +196 -64
- package/dist/index.d.ts +196 -64
- package/dist/index.js +937 -1209
- package/dist/index.mjs +65 -3
- package/package.json +2 -2
- package/src/analyzer.ts +143 -2177
- package/src/ast-utils.ts +94 -0
- package/src/classifier.ts +497 -0
- package/src/cluster-detector.ts +100 -0
- package/src/defaults.ts +59 -0
- package/src/graph-builder.ts +272 -0
- package/src/index.ts +30 -519
- package/src/metrics.ts +231 -0
- package/src/remediation.ts +139 -0
- package/src/scoring.ts +12 -34
- package/src/semantic-analysis.ts +192 -126
- package/src/summary.ts +168 -0
package/src/ast-utils.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { parseFileExports } from '@aiready/core';
|
|
2
|
+
import type { ExportInfo, DependencyNode, FileClassification } from './types';
|
|
3
|
+
import { inferDomain, extractExports } from './semantic-analysis';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extract exports using AST parsing with fallback to regex
|
|
7
|
+
*/
|
|
8
|
+
export function extractExportsWithAST(
|
|
9
|
+
content: string,
|
|
10
|
+
filePath: string,
|
|
11
|
+
domainOptions?: { domainKeywords?: string[] },
|
|
12
|
+
fileImports?: string[]
|
|
13
|
+
): ExportInfo[] {
|
|
14
|
+
try {
|
|
15
|
+
const { exports: astExports } = parseFileExports(content, filePath);
|
|
16
|
+
|
|
17
|
+
return astExports.map((exp) => ({
|
|
18
|
+
name: exp.name,
|
|
19
|
+
type: exp.type as any,
|
|
20
|
+
inferredDomain: inferDomain(
|
|
21
|
+
exp.name,
|
|
22
|
+
filePath,
|
|
23
|
+
domainOptions,
|
|
24
|
+
fileImports
|
|
25
|
+
),
|
|
26
|
+
imports: exp.imports,
|
|
27
|
+
dependencies: exp.dependencies,
|
|
28
|
+
typeReferences: (exp as any).typeReferences,
|
|
29
|
+
}));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
void error;
|
|
32
|
+
// Fallback to regex-based extraction
|
|
33
|
+
return extractExports(content, filePath, domainOptions, fileImports);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if a file is a test, mock, or fixture file
|
|
39
|
+
*/
|
|
40
|
+
export function isTestFile(filePath: string): boolean {
|
|
41
|
+
const lower = filePath.toLowerCase();
|
|
42
|
+
return (
|
|
43
|
+
lower.includes('.test.') ||
|
|
44
|
+
lower.includes('.spec.') ||
|
|
45
|
+
lower.includes('/__tests__/') ||
|
|
46
|
+
lower.includes('/tests/') ||
|
|
47
|
+
lower.includes('/test/') ||
|
|
48
|
+
lower.includes('test-') ||
|
|
49
|
+
lower.includes('-test') ||
|
|
50
|
+
lower.includes('/__mocks__/') ||
|
|
51
|
+
lower.includes('/mocks/') ||
|
|
52
|
+
lower.includes('/fixtures/') ||
|
|
53
|
+
lower.includes('.mock.') ||
|
|
54
|
+
lower.includes('.fixture.') ||
|
|
55
|
+
lower.includes('/test-utils/')
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Heuristic to check if all exports share a common entity noun
|
|
61
|
+
*/
|
|
62
|
+
export function allExportsShareEntityNoun(exports: ExportInfo[]): boolean {
|
|
63
|
+
if (exports.length < 2) return true;
|
|
64
|
+
|
|
65
|
+
const getEntityNoun = (name: string): string | null => {
|
|
66
|
+
// Basic heuristic: last part of camelCase name often is the entity
|
|
67
|
+
// e.g. createOrder -> order, getUserProfile -> profile
|
|
68
|
+
// But we also look for common domain nouns in the middle
|
|
69
|
+
const commonNouns = [
|
|
70
|
+
'user',
|
|
71
|
+
'order',
|
|
72
|
+
'product',
|
|
73
|
+
'session',
|
|
74
|
+
'account',
|
|
75
|
+
'receipt',
|
|
76
|
+
'token',
|
|
77
|
+
];
|
|
78
|
+
const lower = name.toLowerCase();
|
|
79
|
+
|
|
80
|
+
for (const noun of commonNouns) {
|
|
81
|
+
if (lower.includes(noun)) return noun;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Fallback: split by capital letters and take the last part
|
|
85
|
+
const parts = name.split(/(?=[A-Z])/);
|
|
86
|
+
return parts[parts.length - 1].toLowerCase();
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const nouns = exports.map((e) => getEntityNoun(e.name)).filter(Boolean);
|
|
90
|
+
if (nouns.length < exports.length * 0.7) return false;
|
|
91
|
+
|
|
92
|
+
const firstNoun = nouns[0];
|
|
93
|
+
return nouns.every((n) => n === firstNoun);
|
|
94
|
+
}
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import type { DependencyNode, FileClassification } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Classify a file into a specific type for better analysis context
|
|
5
|
+
*/
|
|
6
|
+
export function classifyFile(
|
|
7
|
+
node: DependencyNode,
|
|
8
|
+
cohesionScore: number = 1,
|
|
9
|
+
domains: string[] = []
|
|
10
|
+
): FileClassification {
|
|
11
|
+
// 1. Detect barrel exports (primarily re-exports)
|
|
12
|
+
if (isBarrelExport(node)) {
|
|
13
|
+
return 'barrel-export';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 2. Detect type definition files
|
|
17
|
+
if (isTypeDefinition(node)) {
|
|
18
|
+
return 'type-definition';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 3. Detect Next.js App Router pages
|
|
22
|
+
if (isNextJsPage(node)) {
|
|
23
|
+
return 'nextjs-page';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 4. Detect Lambda handlers
|
|
27
|
+
if (isLambdaHandler(node)) {
|
|
28
|
+
return 'lambda-handler';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 5. Detect Service files
|
|
32
|
+
if (isServiceFile(node)) {
|
|
33
|
+
return 'service-file';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 6. Detect Email templates
|
|
37
|
+
if (isEmailTemplate(node)) {
|
|
38
|
+
return 'email-template';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 7. Detect Parser/Transformer files
|
|
42
|
+
if (isParserFile(node)) {
|
|
43
|
+
return 'parser-file';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 8. Detect Session/State management files
|
|
47
|
+
if (isSessionFile(node)) {
|
|
48
|
+
// If it has high cohesion, it's a cohesive module
|
|
49
|
+
if (cohesionScore >= 0.25 && domains.length <= 1) return 'cohesive-module';
|
|
50
|
+
return 'utility-module'; // Group with utility for now
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 9. Detect Utility modules (multi-domain but functional purpose)
|
|
54
|
+
if (isUtilityModule(node)) {
|
|
55
|
+
return 'utility-module';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 10. Detect Config/Schema files
|
|
59
|
+
if (isConfigFile(node)) {
|
|
60
|
+
return 'cohesive-module';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Cohesion and Domain heuristics
|
|
64
|
+
if (domains.length <= 1 && domains[0] !== 'unknown') {
|
|
65
|
+
return 'cohesive-module';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (domains.length > 1 && cohesionScore < 0.4) {
|
|
69
|
+
return 'mixed-concerns';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (cohesionScore >= 0.7) {
|
|
73
|
+
return 'cohesive-module';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return 'unknown';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Detect if a file is a barrel export (index.ts)
|
|
81
|
+
*/
|
|
82
|
+
export function isBarrelExport(node: DependencyNode): boolean {
|
|
83
|
+
const { file, exports } = node;
|
|
84
|
+
const fileName = file.split('/').pop()?.toLowerCase();
|
|
85
|
+
|
|
86
|
+
// Barrel files are typically named index.ts or index.js
|
|
87
|
+
const isIndexFile = fileName === 'index.ts' || fileName === 'index.js';
|
|
88
|
+
|
|
89
|
+
// Small file with many exports is likely a barrel
|
|
90
|
+
const isSmallAndManyExports =
|
|
91
|
+
node.tokenCost < 1000 && (exports || []).length > 5;
|
|
92
|
+
|
|
93
|
+
// RE-EXPORT HEURISTIC for non-index files
|
|
94
|
+
const isReexportPattern =
|
|
95
|
+
(exports || []).length >= 5 &&
|
|
96
|
+
(exports || []).every(
|
|
97
|
+
(e) =>
|
|
98
|
+
e.type === 'const' ||
|
|
99
|
+
e.type === 'function' ||
|
|
100
|
+
e.type === 'type' ||
|
|
101
|
+
e.type === 'interface'
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return !!isIndexFile || !!isSmallAndManyExports || !!isReexportPattern;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Detect if a file is primarily type definitions
|
|
109
|
+
*/
|
|
110
|
+
export function isTypeDefinition(node: DependencyNode): boolean {
|
|
111
|
+
const { file } = node;
|
|
112
|
+
|
|
113
|
+
// Check file extension
|
|
114
|
+
if (file.endsWith('.d.ts')) return true;
|
|
115
|
+
|
|
116
|
+
// Check if all exports are types or interfaces
|
|
117
|
+
const nodeExports = node.exports || [];
|
|
118
|
+
const hasExports = nodeExports.length > 0;
|
|
119
|
+
const areAllTypes =
|
|
120
|
+
hasExports &&
|
|
121
|
+
nodeExports.every((e) => e.type === 'type' || e.type === 'interface');
|
|
122
|
+
const allTypes: boolean = !!areAllTypes;
|
|
123
|
+
|
|
124
|
+
// Check if path includes 'types' or 'interfaces'
|
|
125
|
+
const isTypePath =
|
|
126
|
+
file.toLowerCase().includes('/types/') ||
|
|
127
|
+
file.toLowerCase().includes('/interfaces/') ||
|
|
128
|
+
file.toLowerCase().includes('/models/');
|
|
129
|
+
|
|
130
|
+
return allTypes || (isTypePath && hasExports);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Detect if a file is a utility module
|
|
135
|
+
*/
|
|
136
|
+
export function isUtilityModule(node: DependencyNode): boolean {
|
|
137
|
+
const { file } = node;
|
|
138
|
+
|
|
139
|
+
// Check if path includes 'utils', 'helpers', etc.
|
|
140
|
+
const isUtilPath =
|
|
141
|
+
file.toLowerCase().includes('/utils/') ||
|
|
142
|
+
file.toLowerCase().includes('/helpers/') ||
|
|
143
|
+
file.toLowerCase().includes('/util/') ||
|
|
144
|
+
file.toLowerCase().includes('/helper/');
|
|
145
|
+
|
|
146
|
+
const fileName = file.split('/').pop()?.toLowerCase();
|
|
147
|
+
const isUtilName =
|
|
148
|
+
fileName?.includes('utils.') ||
|
|
149
|
+
fileName?.includes('helpers.') ||
|
|
150
|
+
fileName?.includes('util.') ||
|
|
151
|
+
fileName?.includes('helper.');
|
|
152
|
+
|
|
153
|
+
return !!isUtilPath || !!isUtilName;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Detect if a file is a Lambda/API handler
|
|
158
|
+
*/
|
|
159
|
+
export function isLambdaHandler(node: DependencyNode): boolean {
|
|
160
|
+
const { file, exports } = node;
|
|
161
|
+
const fileName = file.split('/').pop()?.toLowerCase();
|
|
162
|
+
|
|
163
|
+
const handlerPatterns = [
|
|
164
|
+
'handler',
|
|
165
|
+
'.handler.',
|
|
166
|
+
'-handler.',
|
|
167
|
+
'lambda',
|
|
168
|
+
'.lambda.',
|
|
169
|
+
'-lambda.',
|
|
170
|
+
];
|
|
171
|
+
const isHandlerName = handlerPatterns.some((pattern) =>
|
|
172
|
+
fileName?.includes(pattern)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const isHandlerPath =
|
|
176
|
+
file.toLowerCase().includes('/handlers/') ||
|
|
177
|
+
file.toLowerCase().includes('/lambdas/') ||
|
|
178
|
+
file.toLowerCase().includes('/lambda/') ||
|
|
179
|
+
file.toLowerCase().includes('/functions/');
|
|
180
|
+
|
|
181
|
+
const hasHandlerExport = (exports || []).some(
|
|
182
|
+
(e) =>
|
|
183
|
+
e.name.toLowerCase() === 'handler' ||
|
|
184
|
+
e.name.toLowerCase() === 'main' ||
|
|
185
|
+
e.name.toLowerCase() === 'lambdahandler' ||
|
|
186
|
+
e.name.toLowerCase().endsWith('handler')
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return !!isHandlerName || !!isHandlerPath || !!hasHandlerExport;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Detect if a file is a service file
|
|
194
|
+
*/
|
|
195
|
+
export function isServiceFile(node: DependencyNode): boolean {
|
|
196
|
+
const { file, exports } = node;
|
|
197
|
+
const fileName = file.split('/').pop()?.toLowerCase();
|
|
198
|
+
|
|
199
|
+
const servicePatterns = ['service', '.service.', '-service.', '_service.'];
|
|
200
|
+
const isServiceName = servicePatterns.some((pattern) =>
|
|
201
|
+
fileName?.includes(pattern)
|
|
202
|
+
);
|
|
203
|
+
const isServicePath = file.toLowerCase().includes('/services/');
|
|
204
|
+
const hasServiceNamedExport = (exports || []).some(
|
|
205
|
+
(e) =>
|
|
206
|
+
e.name.toLowerCase().includes('service') ||
|
|
207
|
+
e.name.toLowerCase().endsWith('service')
|
|
208
|
+
);
|
|
209
|
+
const hasClassExport = (exports || []).some((e) => e.type === 'class');
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
!!isServiceName ||
|
|
213
|
+
!!isServicePath ||
|
|
214
|
+
(!!hasServiceNamedExport && !!hasClassExport)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Detect if a file is an email template/layout
|
|
220
|
+
*/
|
|
221
|
+
export function isEmailTemplate(node: DependencyNode): boolean {
|
|
222
|
+
const { file, exports } = node;
|
|
223
|
+
const fileName = file.split('/').pop()?.toLowerCase();
|
|
224
|
+
|
|
225
|
+
const emailTemplatePatterns = [
|
|
226
|
+
'-email-',
|
|
227
|
+
'.email.',
|
|
228
|
+
'_email_',
|
|
229
|
+
'-template',
|
|
230
|
+
'.template.',
|
|
231
|
+
'_template',
|
|
232
|
+
'-mail.',
|
|
233
|
+
'.mail.',
|
|
234
|
+
];
|
|
235
|
+
const isEmailTemplateName = emailTemplatePatterns.some((pattern) =>
|
|
236
|
+
fileName?.includes(pattern)
|
|
237
|
+
);
|
|
238
|
+
const isEmailPath =
|
|
239
|
+
file.toLowerCase().includes('/emails/') ||
|
|
240
|
+
file.toLowerCase().includes('/mail/') ||
|
|
241
|
+
file.toLowerCase().includes('/notifications/');
|
|
242
|
+
|
|
243
|
+
const hasTemplateFunction = (exports || []).some(
|
|
244
|
+
(e) =>
|
|
245
|
+
e.type === 'function' &&
|
|
246
|
+
(e.name.toLowerCase().startsWith('render') ||
|
|
247
|
+
e.name.toLowerCase().startsWith('generate') ||
|
|
248
|
+
(e.name.toLowerCase().includes('template') &&
|
|
249
|
+
e.name.toLowerCase().includes('email')))
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
return !!isEmailPath || !!isEmailTemplateName || !!hasTemplateFunction;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Detect if a file is a parser/transformer
|
|
257
|
+
*/
|
|
258
|
+
export function isParserFile(node: DependencyNode): boolean {
|
|
259
|
+
const { file, exports } = node;
|
|
260
|
+
const fileName = file.split('/').pop()?.toLowerCase();
|
|
261
|
+
|
|
262
|
+
const parserPatterns = [
|
|
263
|
+
'parser',
|
|
264
|
+
'.parser.',
|
|
265
|
+
'-parser.',
|
|
266
|
+
'_parser.',
|
|
267
|
+
'transform',
|
|
268
|
+
'.transform.',
|
|
269
|
+
'converter',
|
|
270
|
+
'mapper',
|
|
271
|
+
'serializer',
|
|
272
|
+
];
|
|
273
|
+
const isParserName = parserPatterns.some((pattern) =>
|
|
274
|
+
fileName?.includes(pattern)
|
|
275
|
+
);
|
|
276
|
+
const isParserPath =
|
|
277
|
+
file.toLowerCase().includes('/parsers/') ||
|
|
278
|
+
file.toLowerCase().includes('/transformers/');
|
|
279
|
+
|
|
280
|
+
const hasParseFunction = (exports || []).some(
|
|
281
|
+
(e) =>
|
|
282
|
+
e.type === 'function' &&
|
|
283
|
+
(e.name.toLowerCase().startsWith('parse') ||
|
|
284
|
+
e.name.toLowerCase().startsWith('transform') ||
|
|
285
|
+
e.name.toLowerCase().startsWith('extract'))
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
return !!isParserName || !!isParserPath || !!hasParseFunction;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Detect if a file is a session/state management file
|
|
293
|
+
*/
|
|
294
|
+
export function isSessionFile(node: DependencyNode): boolean {
|
|
295
|
+
const { file, exports } = node;
|
|
296
|
+
const fileName = file.split('/').pop()?.toLowerCase();
|
|
297
|
+
|
|
298
|
+
const sessionPatterns = ['session', 'state', 'context', 'store'];
|
|
299
|
+
const isSessionName = sessionPatterns.some((pattern) =>
|
|
300
|
+
fileName?.includes(pattern)
|
|
301
|
+
);
|
|
302
|
+
const isSessionPath =
|
|
303
|
+
file.toLowerCase().includes('/sessions/') ||
|
|
304
|
+
file.toLowerCase().includes('/state/');
|
|
305
|
+
|
|
306
|
+
const hasSessionExport = (exports || []).some(
|
|
307
|
+
(e) =>
|
|
308
|
+
e.name.toLowerCase().includes('session') ||
|
|
309
|
+
e.name.toLowerCase().includes('state') ||
|
|
310
|
+
e.name.toLowerCase().includes('store')
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
return !!isSessionName || !!isSessionPath || !!hasSessionExport;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Detect if a file is a configuration or schema file
|
|
318
|
+
*/
|
|
319
|
+
export function isConfigFile(node: DependencyNode): boolean {
|
|
320
|
+
const { file, exports } = node;
|
|
321
|
+
const lowerPath = file.toLowerCase();
|
|
322
|
+
const fileName = file.split('/').pop()?.toLowerCase();
|
|
323
|
+
|
|
324
|
+
const configPatterns = [
|
|
325
|
+
'.config.',
|
|
326
|
+
'tsconfig',
|
|
327
|
+
'jest.config',
|
|
328
|
+
'package.json',
|
|
329
|
+
'aiready.json',
|
|
330
|
+
'next.config',
|
|
331
|
+
'sst.config',
|
|
332
|
+
];
|
|
333
|
+
const isConfigName = configPatterns.some((p) => fileName?.includes(p));
|
|
334
|
+
const isConfigPath =
|
|
335
|
+
lowerPath.includes('/config/') ||
|
|
336
|
+
lowerPath.includes('/settings/') ||
|
|
337
|
+
lowerPath.includes('/schemas/');
|
|
338
|
+
|
|
339
|
+
const hasSchemaExports = (exports || []).some(
|
|
340
|
+
(e) =>
|
|
341
|
+
e.name.toLowerCase().includes('schema') ||
|
|
342
|
+
e.name.toLowerCase().includes('config') ||
|
|
343
|
+
e.name.toLowerCase().includes('setting')
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
return !!isConfigName || !!isConfigPath || !!hasSchemaExports;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Detect if a file is a Next.js App Router page
|
|
351
|
+
*/
|
|
352
|
+
export function isNextJsPage(node: DependencyNode): boolean {
|
|
353
|
+
const { file, exports } = node;
|
|
354
|
+
const lowerPath = file.toLowerCase();
|
|
355
|
+
const fileName = file.split('/').pop()?.toLowerCase();
|
|
356
|
+
|
|
357
|
+
const isInAppDir =
|
|
358
|
+
lowerPath.includes('/app/') || lowerPath.startsWith('app/');
|
|
359
|
+
const isPageFile = fileName === 'page.tsx' || fileName === 'page.ts';
|
|
360
|
+
|
|
361
|
+
if (!isInAppDir || !isPageFile) return false;
|
|
362
|
+
|
|
363
|
+
const hasDefaultExport = (exports || []).some((e) => e.type === 'default');
|
|
364
|
+
const nextJsExports = [
|
|
365
|
+
'metadata',
|
|
366
|
+
'generatemetadata',
|
|
367
|
+
'faqjsonld',
|
|
368
|
+
'jsonld',
|
|
369
|
+
'icon',
|
|
370
|
+
];
|
|
371
|
+
const hasNextJsExports = (exports || []).some((e) =>
|
|
372
|
+
nextJsExports.includes(e.name.toLowerCase())
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
return !!hasDefaultExport || !!hasNextJsExports;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Adjust cohesion score based on file classification
|
|
380
|
+
*/
|
|
381
|
+
export function adjustCohesionForClassification(
|
|
382
|
+
baseCohesion: number,
|
|
383
|
+
classification: FileClassification,
|
|
384
|
+
node?: DependencyNode
|
|
385
|
+
): number {
|
|
386
|
+
switch (classification) {
|
|
387
|
+
case 'barrel-export':
|
|
388
|
+
return 1;
|
|
389
|
+
case 'type-definition':
|
|
390
|
+
return 1;
|
|
391
|
+
case 'nextjs-page':
|
|
392
|
+
return 1;
|
|
393
|
+
case 'utility-module': {
|
|
394
|
+
if (
|
|
395
|
+
node &&
|
|
396
|
+
hasRelatedExportNames(
|
|
397
|
+
(node.exports || []).map((e) => e.name.toLowerCase())
|
|
398
|
+
)
|
|
399
|
+
) {
|
|
400
|
+
return Math.max(0.8, Math.min(1, baseCohesion + 0.45));
|
|
401
|
+
}
|
|
402
|
+
return Math.max(0.75, Math.min(1, baseCohesion + 0.35));
|
|
403
|
+
}
|
|
404
|
+
case 'service-file':
|
|
405
|
+
return Math.max(0.72, Math.min(1, baseCohesion + 0.3));
|
|
406
|
+
case 'lambda-handler':
|
|
407
|
+
return Math.max(0.75, Math.min(1, baseCohesion + 0.35));
|
|
408
|
+
case 'email-template':
|
|
409
|
+
return Math.max(0.72, Math.min(1, baseCohesion + 0.3));
|
|
410
|
+
case 'parser-file':
|
|
411
|
+
return Math.max(0.7, Math.min(1, baseCohesion + 0.3));
|
|
412
|
+
case 'cohesive-module':
|
|
413
|
+
return Math.max(baseCohesion, 0.7);
|
|
414
|
+
case 'mixed-concerns':
|
|
415
|
+
return baseCohesion;
|
|
416
|
+
default:
|
|
417
|
+
return Math.min(1, baseCohesion + 0.1);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Check if export names suggest related functionality
|
|
423
|
+
*/
|
|
424
|
+
function hasRelatedExportNames(exportNames: string[]): boolean {
|
|
425
|
+
if (exportNames.length < 2) return true;
|
|
426
|
+
|
|
427
|
+
const stems = new Set<string>();
|
|
428
|
+
const domains = new Set<string>();
|
|
429
|
+
|
|
430
|
+
const verbs = [
|
|
431
|
+
'get',
|
|
432
|
+
'set',
|
|
433
|
+
'create',
|
|
434
|
+
'update',
|
|
435
|
+
'delete',
|
|
436
|
+
'fetch',
|
|
437
|
+
'save',
|
|
438
|
+
'load',
|
|
439
|
+
'parse',
|
|
440
|
+
'format',
|
|
441
|
+
'validate',
|
|
442
|
+
];
|
|
443
|
+
const domainPatterns = [
|
|
444
|
+
'user',
|
|
445
|
+
'order',
|
|
446
|
+
'product',
|
|
447
|
+
'session',
|
|
448
|
+
'email',
|
|
449
|
+
'file',
|
|
450
|
+
'db',
|
|
451
|
+
'api',
|
|
452
|
+
'config',
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
for (const name of exportNames) {
|
|
456
|
+
for (const verb of verbs) {
|
|
457
|
+
if (name.startsWith(verb) && name.length > verb.length) {
|
|
458
|
+
stems.add(name.slice(verb.length).toLowerCase());
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
for (const domain of domainPatterns) {
|
|
462
|
+
if (name.includes(domain)) domains.add(domain);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (stems.size === 1 || domains.size === 1) return true;
|
|
467
|
+
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Adjust fragmentation score based on file classification
|
|
473
|
+
*/
|
|
474
|
+
export function adjustFragmentationForClassification(
|
|
475
|
+
baseFragmentation: number,
|
|
476
|
+
classification: FileClassification
|
|
477
|
+
): number {
|
|
478
|
+
switch (classification) {
|
|
479
|
+
case 'barrel-export':
|
|
480
|
+
return 0;
|
|
481
|
+
case 'type-definition':
|
|
482
|
+
return 0;
|
|
483
|
+
case 'utility-module':
|
|
484
|
+
case 'service-file':
|
|
485
|
+
case 'lambda-handler':
|
|
486
|
+
case 'email-template':
|
|
487
|
+
case 'parser-file':
|
|
488
|
+
case 'nextjs-page':
|
|
489
|
+
return baseFragmentation * 0.2;
|
|
490
|
+
case 'cohesive-module':
|
|
491
|
+
return baseFragmentation * 0.3;
|
|
492
|
+
case 'mixed-concerns':
|
|
493
|
+
return baseFragmentation;
|
|
494
|
+
default:
|
|
495
|
+
return baseFragmentation * 0.7;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { DependencyGraph, ModuleCluster } from './types';
|
|
2
|
+
import { calculateFragmentation, calculateEnhancedCohesion } from './metrics';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Group files by domain to detect module clusters
|
|
6
|
+
*/
|
|
7
|
+
export function detectModuleClusters(
|
|
8
|
+
graph: DependencyGraph,
|
|
9
|
+
options?: { useLogScale?: boolean }
|
|
10
|
+
): ModuleCluster[] {
|
|
11
|
+
const domainMap = new Map<string, string[]>();
|
|
12
|
+
|
|
13
|
+
for (const [file, node] of graph.nodes.entries()) {
|
|
14
|
+
const primaryDomain = node.exports[0]?.inferredDomain || 'unknown';
|
|
15
|
+
if (!domainMap.has(primaryDomain)) {
|
|
16
|
+
domainMap.set(primaryDomain, []);
|
|
17
|
+
}
|
|
18
|
+
domainMap.get(primaryDomain)!.push(file);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const clusters: ModuleCluster[] = [];
|
|
22
|
+
|
|
23
|
+
for (const [domain, files] of domainMap.entries()) {
|
|
24
|
+
if (files.length < 2 || domain === 'unknown') continue;
|
|
25
|
+
|
|
26
|
+
const totalTokens = files.reduce((sum, file) => {
|
|
27
|
+
const node = graph.nodes.get(file);
|
|
28
|
+
return sum + (node?.tokenCost || 0);
|
|
29
|
+
}, 0);
|
|
30
|
+
|
|
31
|
+
// Calculate shared import ratio for coupling discount
|
|
32
|
+
let sharedImportRatio = 0;
|
|
33
|
+
if (files.length >= 2) {
|
|
34
|
+
const allImportSets = files.map(
|
|
35
|
+
(f) => new Set(graph.nodes.get(f)?.imports || [])
|
|
36
|
+
);
|
|
37
|
+
let intersection = new Set(allImportSets[0]);
|
|
38
|
+
let union = new Set(allImportSets[0]);
|
|
39
|
+
|
|
40
|
+
for (let i = 1; i < allImportSets.length; i++) {
|
|
41
|
+
const nextSet = allImportSets[i];
|
|
42
|
+
intersection = new Set([...intersection].filter((x) => nextSet.has(x)));
|
|
43
|
+
for (const x of nextSet) union.add(x);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
sharedImportRatio = union.size > 0 ? intersection.size / union.size : 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const fragmentation = calculateFragmentation(files, domain, {
|
|
50
|
+
...options,
|
|
51
|
+
sharedImportRatio,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Average cohesion for the cluster
|
|
55
|
+
let totalCohesion = 0;
|
|
56
|
+
files.forEach((f) => {
|
|
57
|
+
const node = graph.nodes.get(f);
|
|
58
|
+
if (node) totalCohesion += calculateEnhancedCohesion(node.exports);
|
|
59
|
+
});
|
|
60
|
+
const avgCohesion = totalCohesion / files.length;
|
|
61
|
+
|
|
62
|
+
clusters.push({
|
|
63
|
+
domain,
|
|
64
|
+
files,
|
|
65
|
+
totalTokens,
|
|
66
|
+
fragmentationScore: fragmentation,
|
|
67
|
+
avgCohesion,
|
|
68
|
+
suggestedStructure: generateSuggestedStructure(
|
|
69
|
+
files,
|
|
70
|
+
totalTokens,
|
|
71
|
+
fragmentation
|
|
72
|
+
),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return clusters;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function generateSuggestedStructure(
|
|
80
|
+
files: string[],
|
|
81
|
+
tokens: number,
|
|
82
|
+
fragmentation: number
|
|
83
|
+
) {
|
|
84
|
+
const targetFiles = Math.max(1, Math.ceil(tokens / 10000));
|
|
85
|
+
const plan: string[] = [];
|
|
86
|
+
|
|
87
|
+
if (fragmentation > 0.5) {
|
|
88
|
+
plan.push(
|
|
89
|
+
`Consolidate ${files.length} files scattered across multiple directories into ${targetFiles} core module(s)`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (tokens > 20000) {
|
|
94
|
+
plan.push(
|
|
95
|
+
`Domain logic is very large (${Math.round(tokens / 1000)}k tokens). Ensure clear sub-domain boundaries.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { targetFiles, consolidationPlan: plan };
|
|
100
|
+
}
|