@aiready/context-analyzer 0.9.41 → 0.16.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.
Files changed (53) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/.turbo/turbo-test.log +21 -20
  3. package/dist/chunk-4SYIJ7CU.mjs +1538 -0
  4. package/dist/chunk-4XQVYYPC.mjs +1470 -0
  5. package/dist/chunk-5CLU3HYU.mjs +1475 -0
  6. package/dist/chunk-5K73Q3OQ.mjs +1520 -0
  7. package/dist/chunk-6AVS4KTM.mjs +1536 -0
  8. package/dist/chunk-6I4552YB.mjs +1467 -0
  9. package/dist/chunk-6LPITDKG.mjs +1539 -0
  10. package/dist/chunk-AECWO7NQ.mjs +1539 -0
  11. package/dist/chunk-AJC3FR6G.mjs +1509 -0
  12. package/dist/chunk-CVGIDSMN.mjs +1522 -0
  13. package/dist/chunk-DXG5NIYL.mjs +1527 -0
  14. package/dist/chunk-G3CCJCBI.mjs +1521 -0
  15. package/dist/chunk-GFADGYXZ.mjs +1752 -0
  16. package/dist/chunk-GTRIBVS6.mjs +1467 -0
  17. package/dist/chunk-H4HWBQU6.mjs +1530 -0
  18. package/dist/chunk-JH535NPP.mjs +1619 -0
  19. package/dist/chunk-KGFWKSGJ.mjs +1442 -0
  20. package/dist/chunk-N2GQWNFG.mjs +1527 -0
  21. package/dist/chunk-NQA3F2HJ.mjs +1532 -0
  22. package/dist/chunk-NXXQ2U73.mjs +1467 -0
  23. package/dist/chunk-QDGPR3L6.mjs +1518 -0
  24. package/dist/chunk-SAVOSPM3.mjs +1522 -0
  25. package/dist/chunk-SIX4KMF2.mjs +1468 -0
  26. package/dist/chunk-SPAM2YJE.mjs +1537 -0
  27. package/dist/chunk-UG7OPVHB.mjs +1521 -0
  28. package/dist/chunk-VIJTZPBI.mjs +1470 -0
  29. package/dist/chunk-W37E7MW5.mjs +1403 -0
  30. package/dist/chunk-W76FEISE.mjs +1538 -0
  31. package/dist/chunk-WCFQYXQA.mjs +1532 -0
  32. package/dist/chunk-XY77XABG.mjs +1545 -0
  33. package/dist/chunk-YCGDIGOG.mjs +1467 -0
  34. package/dist/cli.js +768 -1160
  35. package/dist/cli.mjs +1 -1
  36. package/dist/index.d.mts +196 -64
  37. package/dist/index.d.ts +196 -64
  38. package/dist/index.js +937 -1209
  39. package/dist/index.mjs +65 -3
  40. package/package.json +2 -2
  41. package/src/__tests__/contract.test.ts +38 -0
  42. package/src/analyzer.ts +143 -2177
  43. package/src/ast-utils.ts +94 -0
  44. package/src/classifier.ts +497 -0
  45. package/src/cluster-detector.ts +100 -0
  46. package/src/defaults.ts +59 -0
  47. package/src/graph-builder.ts +272 -0
  48. package/src/index.ts +30 -519
  49. package/src/metrics.ts +231 -0
  50. package/src/remediation.ts +139 -0
  51. package/src/scoring.ts +12 -34
  52. package/src/semantic-analysis.ts +192 -126
  53. package/src/summary.ts +168 -0
@@ -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
+ }