@aiready/context-analyzer 0.9.34 → 0.9.36

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/src/analyzer.ts CHANGED
@@ -1,13 +1,20 @@
1
- import { estimateTokens, parseFileExports, calculateImportSimilarity, type ExportWithImports } from '@aiready/core';
1
+ import {
2
+ estimateTokens,
3
+ parseFileExports,
4
+ calculateImportSimilarity,
5
+ } from '@aiready/core';
2
6
  import type {
3
- ContextAnalysisResult,
4
7
  DependencyGraph,
5
8
  DependencyNode,
6
9
  ExportInfo,
7
10
  ModuleCluster,
8
11
  FileClassification,
9
12
  } from './types';
10
- import { buildCoUsageMatrix, buildTypeGraph, inferDomainFromSemantics } from './semantic-analysis';
13
+ import {
14
+ buildCoUsageMatrix,
15
+ buildTypeGraph,
16
+ inferDomainFromSemantics,
17
+ } from './semantic-analysis';
11
18
 
12
19
  interface FileContent {
13
20
  file: string;
@@ -20,22 +27,45 @@ interface FileContent {
20
27
  */
21
28
  function extractDomainKeywordsFromPaths(files: FileContent[]): string[] {
22
29
  const folderNames = new Set<string>();
23
-
30
+
24
31
  for (const { file } of files) {
25
32
  const segments = file.split('/');
26
33
  // Extract meaningful folder names (skip common infrastructure folders)
27
- const skipFolders = new Set(['src', 'lib', 'dist', 'build', 'node_modules', 'test', 'tests', '__tests__', 'spec', 'e2e', 'scripts', 'components', 'utils', 'helpers', 'util', 'helper', 'api', 'apis']);
28
-
34
+ const skipFolders = new Set([
35
+ 'src',
36
+ 'lib',
37
+ 'dist',
38
+ 'build',
39
+ 'node_modules',
40
+ 'test',
41
+ 'tests',
42
+ '__tests__',
43
+ 'spec',
44
+ 'e2e',
45
+ 'scripts',
46
+ 'components',
47
+ 'utils',
48
+ 'helpers',
49
+ 'util',
50
+ 'helper',
51
+ 'api',
52
+ 'apis',
53
+ ]);
54
+
29
55
  for (const segment of segments) {
30
56
  const normalized = segment.toLowerCase();
31
- if (normalized && !skipFolders.has(normalized) && !normalized.includes('.')) {
57
+ if (
58
+ normalized &&
59
+ !skipFolders.has(normalized) &&
60
+ !normalized.includes('.')
61
+ ) {
32
62
  // Singularize common plural forms for better matching
33
63
  const singular = singularize(normalized);
34
64
  folderNames.add(singular);
35
65
  }
36
66
  }
37
67
  }
38
-
68
+
39
69
  return Array.from(folderNames);
40
70
  }
41
71
 
@@ -50,11 +80,11 @@ function singularize(word: string): string {
50
80
  men: 'man',
51
81
  women: 'woman',
52
82
  };
53
-
83
+
54
84
  if (irregulars[word]) {
55
85
  return irregulars[word];
56
86
  }
57
-
87
+
58
88
  // Common plural patterns
59
89
  if (word.endsWith('ies')) {
60
90
  return word.slice(0, -3) + 'y'; // categories -> category
@@ -65,29 +95,35 @@ function singularize(word: string): string {
65
95
  if (word.endsWith('s') && word.length > 3) {
66
96
  return word.slice(0, -1); // orders -> order
67
97
  }
68
-
98
+
69
99
  return word;
70
100
  }
71
101
 
72
102
  /**
73
103
  * Build a dependency graph from file contents
74
104
  */
75
- export function buildDependencyGraph(
76
- files: FileContent[],
77
- ): DependencyGraph {
105
+ export function buildDependencyGraph(files: FileContent[]): DependencyGraph {
78
106
  const nodes = new Map<string, DependencyNode>();
79
107
  const edges = new Map<string, Set<string>>();
80
108
 
81
109
  // Auto-detect domain keywords from workspace folder structure
82
110
  const autoDetectedKeywords = extractDomainKeywordsFromPaths(files);
83
111
 
112
+ // Some imported helpers are optional for future features; reference to avoid lint warnings
113
+ void calculateImportSimilarity;
114
+
84
115
  // First pass: Create nodes with folder-based domain inference
85
116
  for (const { file, content } of files) {
86
117
  const imports = extractImportsFromContent(content);
87
-
118
+
88
119
  // Use AST-based extraction for better accuracy, fallback to regex
89
- const exports = extractExportsWithAST(content, file, { domainKeywords: autoDetectedKeywords }, imports);
90
-
120
+ const exports = extractExportsWithAST(
121
+ content,
122
+ file,
123
+ { domainKeywords: autoDetectedKeywords },
124
+ imports
125
+ );
126
+
91
127
  const tokenCost = estimateTokens(content);
92
128
  const linesOfCode = content.split('\n').length;
93
129
 
@@ -106,7 +142,7 @@ export function buildDependencyGraph(
106
142
  const graph: DependencyGraph = { nodes, edges };
107
143
  const coUsageMatrix = buildCoUsageMatrix(graph);
108
144
  const typeGraph = buildTypeGraph(graph);
109
-
145
+
110
146
  // Add semantic data to graph
111
147
  graph.coUsageMatrix = coUsageMatrix;
112
148
  graph.typeGraph = typeGraph;
@@ -123,10 +159,10 @@ export function buildDependencyGraph(
123
159
  typeGraph,
124
160
  exp.typeReferences
125
161
  );
126
-
162
+
127
163
  // Add multi-domain assignments with confidence scores
128
164
  exp.domains = semanticAssignments;
129
-
165
+
130
166
  // Keep inferredDomain for backwards compatibility (use highest confidence)
131
167
  if (semanticAssignments.length > 0) {
132
168
  exp.inferredDomain = semanticAssignments[0].domain;
@@ -248,9 +284,7 @@ export function calculateContextBudget(
248
284
  /**
249
285
  * Detect circular dependencies
250
286
  */
251
- export function detectCircularDependencies(
252
- graph: DependencyGraph
253
- ): string[][] {
287
+ export function detectCircularDependencies(graph: DependencyGraph): string[][] {
254
288
  const cycles: string[][] = [];
255
289
  const visited = new Set<string>();
256
290
  const recursionStack = new Set<string>();
@@ -301,7 +335,14 @@ export function detectCircularDependencies(
301
335
  export function calculateCohesion(
302
336
  exports: ExportInfo[],
303
337
  filePath?: string,
304
- options?: { coUsageMatrix?: Map<string, Map<string, number>>; weights?: { importBased?: number; structural?: number; domainBased?: number } }
338
+ options?: {
339
+ coUsageMatrix?: Map<string, Map<string, number>>;
340
+ weights?: {
341
+ importBased?: number;
342
+ structural?: number;
343
+ domainBased?: number;
344
+ };
345
+ }
305
346
  ): number {
306
347
  return calculateEnhancedCohesion(exports, filePath, options);
307
348
  }
@@ -333,7 +374,9 @@ export function calculateFragmentation(
333
374
  if (files.length <= 1) return 0; // Single file = no fragmentation
334
375
 
335
376
  // Calculate how many different directories contain these files
336
- const directories = new Set(files.map((f) => f.split('/').slice(0, -1).join('/')));
377
+ const directories = new Set(
378
+ files.map((f) => f.split('/').slice(0, -1).join('/'))
379
+ );
337
380
  const uniqueDirs = directories.size;
338
381
 
339
382
  // If log-scaling requested, normalize using logarithms so that
@@ -450,7 +493,9 @@ export function detectModuleClusters(
450
493
  return sum + (node?.tokenCost || 0);
451
494
  }, 0);
452
495
 
453
- const baseFragmentation = calculateFragmentation(files, domain, { useLogScale: !!options?.useLogScale });
496
+ const baseFragmentation = calculateFragmentation(files, domain, {
497
+ useLogScale: !!options?.useLogScale,
498
+ });
454
499
 
455
500
  // Compute import-based cohesion across files in this domain cluster.
456
501
  // This measures how much the files actually "talk" to each other.
@@ -466,16 +511,18 @@ export function detectModuleClusters(
466
511
  const n2 = graph.nodes.get(f2)?.imports || [];
467
512
 
468
513
  // Treat two empty import lists as not coupled (similarity 0)
469
- const similarity = (n1.length === 0 && n2.length === 0)
470
- ? 0
471
- : calculateJaccardSimilarity(n1, n2);
514
+ const similarity =
515
+ n1.length === 0 && n2.length === 0
516
+ ? 0
517
+ : calculateJaccardSimilarity(n1, n2);
472
518
 
473
519
  importSimilarityTotal += similarity;
474
520
  importComparisons++;
475
521
  }
476
522
  }
477
523
 
478
- const importCohesion = importComparisons > 0 ? importSimilarityTotal / importComparisons : 0;
524
+ const importCohesion =
525
+ importComparisons > 0 ? importSimilarityTotal / importComparisons : 0;
479
526
 
480
527
  // Coupling discount: if files are heavily importing each other, reduce fragmentation penalty.
481
528
  // Following recommendation: up to 20% discount proportional to import cohesion.
@@ -490,7 +537,14 @@ export function detectModuleClusters(
490
537
  const avgCohesion =
491
538
  files.reduce((sum, file) => {
492
539
  const node = graph.nodes.get(file);
493
- return sum + (node ? calculateCohesion(node.exports, file, { coUsageMatrix: graph.coUsageMatrix }) : 0);
540
+ return (
541
+ sum +
542
+ (node
543
+ ? calculateCohesion(node.exports, file, {
544
+ coUsageMatrix: graph.coUsageMatrix,
545
+ })
546
+ : 0)
547
+ );
494
548
  }, 0) / files.length;
495
549
 
496
550
  // Generate consolidation plan
@@ -528,7 +582,11 @@ export function detectModuleClusters(
528
582
  function extractExports(
529
583
  content: string,
530
584
  filePath?: string,
531
- domainOptions?: { domainKeywords?: string[]; domainPatterns?: string[]; pathDomainMap?: Record<string, string> },
585
+ domainOptions?: {
586
+ domainKeywords?: string[];
587
+ domainPatterns?: string[];
588
+ pathDomainMap?: Record<string, string>;
589
+ },
532
590
  fileImports?: string[]
533
591
  ): ExportInfo[] {
534
592
  const exports: ExportInfo[] = [];
@@ -557,7 +615,12 @@ function extractExports(
557
615
  while ((match = pattern.exec(content)) !== null) {
558
616
  const name = match[1] || 'default';
559
617
  const type = types[index];
560
- const inferredDomain = inferDomain(name, filePath, domainOptions, fileImports);
618
+ const inferredDomain = inferDomain(
619
+ name,
620
+ filePath,
621
+ domainOptions,
622
+ fileImports
623
+ );
561
624
 
562
625
  exports.push({ name, type, inferredDomain });
563
626
  }
@@ -612,9 +675,10 @@ function inferDomain(
612
675
  'auth',
613
676
  ];
614
677
 
615
- const domainKeywords = domainOptions?.domainKeywords && domainOptions.domainKeywords.length
616
- ? [...domainOptions.domainKeywords, ...defaultKeywords]
617
- : defaultKeywords;
678
+ const domainKeywords =
679
+ domainOptions?.domainKeywords && domainOptions.domainKeywords.length
680
+ ? [...domainOptions.domainKeywords, ...defaultKeywords]
681
+ : defaultKeywords;
618
682
 
619
683
  // Try word boundary matching first for more accurate detection
620
684
  for (const keyword of domainKeywords) {
@@ -637,23 +701,29 @@ function inferDomain(
637
701
  // e.g., '@/orders/service' -> ['orders', 'service']
638
702
  // '../payments/processor' -> ['payments', 'processor']
639
703
  const allSegments = importPath.split('/');
640
- const relevantSegments = allSegments.filter(s => {
641
- if (!s) return false;
642
- // Skip '.' and '..' but keep everything else
643
- if (s === '.' || s === '..') return false;
644
- // Skip '@' prefix but keep the path after it
645
- if (s.startsWith('@') && s.length === 1) return false;
646
- // Remove '@' prefix from scoped imports like '@/orders'
647
- return true;
648
- }).map(s => s.startsWith('@') ? s.slice(1) : s);
649
-
704
+ const relevantSegments = allSegments
705
+ .filter((s) => {
706
+ if (!s) return false;
707
+ // Skip '.' and '..' but keep everything else
708
+ if (s === '.' || s === '..') return false;
709
+ // Skip '@' prefix but keep the path after it
710
+ if (s.startsWith('@') && s.length === 1) return false;
711
+ // Remove '@' prefix from scoped imports like '@/orders'
712
+ return true;
713
+ })
714
+ .map((s) => (s.startsWith('@') ? s.slice(1) : s));
715
+
650
716
  for (const segment of relevantSegments) {
651
717
  const segLower = segment.toLowerCase();
652
718
  const singularSegment = singularize(segLower);
653
-
719
+
654
720
  // Check if any domain keyword matches the import path segment (with singularization)
655
721
  for (const keyword of domainKeywords) {
656
- if (singularSegment === keyword || segLower === keyword || segLower.includes(keyword)) {
722
+ if (
723
+ singularSegment === keyword ||
724
+ segLower === keyword ||
725
+ segLower.includes(keyword)
726
+ ) {
657
727
  return keyword;
658
728
  }
659
729
  }
@@ -667,9 +737,13 @@ function inferDomain(
667
737
  const pathSegments = filePath.toLowerCase().split('/');
668
738
  for (const segment of pathSegments) {
669
739
  const singularSegment = singularize(segment);
670
-
740
+
671
741
  for (const keyword of domainKeywords) {
672
- if (singularSegment === keyword || segment === keyword || segment.includes(keyword)) {
742
+ if (
743
+ singularSegment === keyword ||
744
+ segment === keyword ||
745
+ segment.includes(keyword)
746
+ ) {
673
747
  return keyword;
674
748
  }
675
749
  }
@@ -731,15 +805,22 @@ export function extractExportsWithAST(
731
805
  ): ExportInfo[] {
732
806
  try {
733
807
  const { exports: astExports } = parseFileExports(content, filePath);
734
-
735
- return astExports.map(exp => ({
808
+
809
+ return astExports.map((exp) => ({
736
810
  name: exp.name,
737
811
  type: exp.type,
738
- inferredDomain: inferDomain(exp.name, filePath, domainOptions, fileImports),
812
+ inferredDomain: inferDomain(
813
+ exp.name,
814
+ filePath,
815
+ domainOptions,
816
+ fileImports
817
+ ),
739
818
  imports: exp.imports,
740
819
  dependencies: exp.dependencies,
741
820
  }));
742
821
  } catch (error) {
822
+ // Avoid unused variable lint
823
+ void error;
743
824
  // Fallback to regex-based extraction
744
825
  return extractExports(content, filePath, domainOptions, fileImports);
745
826
  }
@@ -747,17 +828,24 @@ export function extractExportsWithAST(
747
828
 
748
829
  /**
749
830
  * Calculate enhanced cohesion score using both domain inference and import similarity
750
- *
831
+ *
751
832
  * This combines:
752
833
  * 1. Domain-based cohesion (entropy of inferred domains)
753
834
  * 2. Import-based cohesion (Jaccard similarity of shared imports)
754
- *
835
+ *
755
836
  * Weight: 60% import-based, 40% domain-based (import analysis is more reliable)
756
837
  */
757
838
  export function calculateEnhancedCohesion(
758
839
  exports: ExportInfo[],
759
840
  filePath?: string,
760
- options?: { coUsageMatrix?: Map<string, Map<string, number>>; weights?: { importBased?: number; structural?: number; domainBased?: number } }
841
+ options?: {
842
+ coUsageMatrix?: Map<string, Map<string, number>>;
843
+ weights?: {
844
+ importBased?: number;
845
+ structural?: number;
846
+ domainBased?: number;
847
+ };
848
+ }
761
849
  ): number {
762
850
  if (exports.length === 0) return 1;
763
851
  if (exports.length === 1) return 1;
@@ -772,27 +860,41 @@ export function calculateEnhancedCohesion(
772
860
 
773
861
  // Calculate import-based cohesion if imports are available
774
862
  const hasImportData = exports.some((e) => e.imports && e.imports.length > 0);
775
- const importCohesion = hasImportData ? calculateImportBasedCohesion(exports) : undefined;
863
+ const importCohesion = hasImportData
864
+ ? calculateImportBasedCohesion(exports)
865
+ : undefined;
776
866
 
777
867
  // Calculate structural cohesion (co-usage) if coUsageMatrix and filePath available
778
868
  const coUsageMatrix = options?.coUsageMatrix;
779
- const structuralCohesion = filePath && coUsageMatrix ? calculateStructuralCohesionFromCoUsage(filePath, coUsageMatrix) : undefined;
869
+ const structuralCohesion =
870
+ filePath && coUsageMatrix
871
+ ? calculateStructuralCohesionFromCoUsage(filePath, coUsageMatrix)
872
+ : undefined;
780
873
 
781
874
  // Default weights (can be overridden via options)
782
- const defaultWeights = { importBased: 0.5, structural: 0.3, domainBased: 0.2 };
875
+ const defaultWeights = {
876
+ importBased: 0.5,
877
+ structural: 0.3,
878
+ domainBased: 0.2,
879
+ };
783
880
  const weights = { ...defaultWeights, ...(options?.weights || {}) };
784
881
 
785
882
  // Collect available signals and normalize weights
786
883
  const signals: Array<{ score: number; weight: number }> = [];
787
- if (importCohesion !== undefined) signals.push({ score: importCohesion, weight: weights.importBased });
788
- if (structuralCohesion !== undefined) signals.push({ score: structuralCohesion, weight: weights.structural });
884
+ if (importCohesion !== undefined)
885
+ signals.push({ score: importCohesion, weight: weights.importBased });
886
+ if (structuralCohesion !== undefined)
887
+ signals.push({ score: structuralCohesion, weight: weights.structural });
789
888
  // domain cohesion is always available
790
889
  signals.push({ score: domainCohesion, weight: weights.domainBased });
791
890
 
792
891
  const totalWeight = signals.reduce((s, el) => s + el.weight, 0);
793
892
  if (totalWeight === 0) return domainCohesion;
794
893
 
795
- const combined = signals.reduce((sum, el) => sum + el.score * (el.weight / totalWeight), 0);
894
+ const combined = signals.reduce(
895
+ (sum, el) => sum + el.score * (el.weight / totalWeight),
896
+ 0
897
+ );
796
898
  return combined;
797
899
  }
798
900
 
@@ -838,8 +940,10 @@ export function calculateStructuralCohesionFromCoUsage(
838
940
  * Calculate cohesion based on shared imports (Jaccard similarity)
839
941
  */
840
942
  function calculateImportBasedCohesion(exports: ExportInfo[]): number {
841
- const exportsWithImports = exports.filter(e => e.imports && e.imports.length > 0);
842
-
943
+ const exportsWithImports = exports.filter(
944
+ (e) => e.imports && e.imports.length > 0
945
+ );
946
+
843
947
  if (exportsWithImports.length < 2) {
844
948
  return 1; // Not enough data
845
949
  }
@@ -852,7 +956,7 @@ function calculateImportBasedCohesion(exports: ExportInfo[]): number {
852
956
  for (let j = i + 1; j < exportsWithImports.length; j++) {
853
957
  const exp1 = exportsWithImports[i] as ExportInfo & { imports: string[] };
854
958
  const exp2 = exportsWithImports[j] as ExportInfo & { imports: string[] };
855
-
959
+
856
960
  const similarity = calculateJaccardSimilarity(exp1.imports, exp2.imports);
857
961
  totalSimilarity += similarity;
858
962
  comparisons++;
@@ -871,10 +975,10 @@ function calculateJaccardSimilarity(arr1: string[], arr2: string[]): number {
871
975
 
872
976
  const set1 = new Set(arr1);
873
977
  const set2 = new Set(arr2);
874
-
875
- const intersection = new Set([...set1].filter(x => set2.has(x)));
978
+
979
+ const intersection = new Set([...set1].filter((x) => set2.has(x)));
876
980
  const union = new Set([...set1, ...set2]);
877
-
981
+
878
982
  return intersection.size / union.size;
879
983
  }
880
984
 
@@ -906,7 +1010,7 @@ function calculateDomainCohesion(exports: ExportInfo[]): number {
906
1010
  /**
907
1011
  * Classify a file based on its characteristics to help distinguish
908
1012
  * real issues from false positives.
909
- *
1013
+ *
910
1014
  * Classification types:
911
1015
  * - barrel-export: Re-exports from other modules (index.ts files)
912
1016
  * - type-definition: Primarily type/interface definitions
@@ -925,57 +1029,61 @@ export function classifyFile(
925
1029
  domains: string[]
926
1030
  ): FileClassification {
927
1031
  const { exports, imports, linesOfCode, file } = node;
928
-
1032
+
1033
+ // Some node fields are inspected by heuristics later; reference to avoid lint warnings
1034
+ void imports;
1035
+ void linesOfCode;
1036
+
929
1037
  // 1. Check for barrel export (index file that re-exports)
930
1038
  if (isBarrelExport(node)) {
931
1039
  return 'barrel-export';
932
1040
  }
933
-
1041
+
934
1042
  // 2. Check for type definition file
935
1043
  if (isTypeDefinitionFile(node)) {
936
1044
  return 'type-definition';
937
1045
  }
938
-
1046
+
939
1047
  // 3. Check for config/schema file (special case - acceptable multi-domain)
940
1048
  if (isConfigOrSchemaFile(node)) {
941
1049
  return 'cohesive-module'; // Treat as cohesive since it's intentional
942
1050
  }
943
-
1051
+
944
1052
  // 4. Check for lambda handlers FIRST (they often look like mixed concerns)
945
1053
  if (isLambdaHandler(node)) {
946
1054
  return 'lambda-handler';
947
1055
  }
948
-
1056
+
949
1057
  // 4b. Check for data access layer (DAL) files
950
1058
  if (isDataAccessFile(node)) {
951
1059
  return 'cohesive-module';
952
1060
  }
953
-
1061
+
954
1062
  // 5. Check for email templates (they reference multiple domains but serve one purpose)
955
1063
  if (isEmailTemplate(node)) {
956
1064
  return 'email-template';
957
1065
  }
958
-
1066
+
959
1067
  // 6. Check for parser/transformer files
960
1068
  if (isParserFile(node)) {
961
1069
  return 'parser-file';
962
1070
  }
963
-
1071
+
964
1072
  // 7. Check for service files
965
1073
  if (isServiceFile(node)) {
966
1074
  return 'service-file';
967
1075
  }
968
-
1076
+
969
1077
  // 8. Check for session/state management files
970
1078
  if (isSessionFile(node)) {
971
1079
  return 'cohesive-module'; // Session files manage state cohesively
972
1080
  }
973
-
1081
+
974
1082
  // 9. Check for Next.js App Router pages (metadata + faqJsonLd + default export)
975
1083
  if (isNextJsPage(node)) {
976
1084
  return 'nextjs-page';
977
1085
  }
978
-
1086
+
979
1087
  // 10. Check for utility file pattern (multiple domains but utility purpose)
980
1088
  if (isUtilityFile(node)) {
981
1089
  return 'utility-module';
@@ -985,14 +1093,17 @@ export function classifyFile(
985
1093
  // should be classified as utility-module regardless of domain count.
986
1094
  // This ensures common helper modules (e.g., src/utils/dynamodb-utils.ts)
987
1095
  // are treated as utility modules in tests and analysis.
988
- if (file.toLowerCase().includes('/utils/') || file.toLowerCase().includes('/helpers/')) {
1096
+ if (
1097
+ file.toLowerCase().includes('/utils/') ||
1098
+ file.toLowerCase().includes('/helpers/')
1099
+ ) {
989
1100
  return 'utility-module';
990
1101
  }
991
-
1102
+
992
1103
  // 10. Check for cohesive module (single domain + reasonable cohesion)
993
- const uniqueDomains = domains.filter(d => d !== 'unknown');
1104
+ const uniqueDomains = domains.filter((d) => d !== 'unknown');
994
1105
  const hasSingleDomain = uniqueDomains.length <= 1;
995
-
1106
+
996
1107
  // Single domain files are almost always cohesive (even with lower cohesion score)
997
1108
  if (hasSingleDomain) {
998
1109
  return 'cohesive-module';
@@ -1003,27 +1114,27 @@ export function classifyFile(
1003
1114
  if (allExportsShareEntityNoun(exports)) {
1004
1115
  return 'cohesive-module';
1005
1116
  }
1006
-
1117
+
1007
1118
  // 11. Check for mixed concerns (multiple domains + low cohesion)
1008
1119
  const hasMultipleDomains = uniqueDomains.length > 1;
1009
1120
  const hasLowCohesion = cohesionScore < 0.4; // Lowered threshold
1010
-
1121
+
1011
1122
  if (hasMultipleDomains && hasLowCohesion) {
1012
1123
  return 'mixed-concerns';
1013
1124
  }
1014
-
1125
+
1015
1126
  // 12. Default to cohesive-module for files with reasonable cohesion
1016
1127
  // This reduces false positives for legitimate files
1017
1128
  if (cohesionScore >= 0.5) {
1018
1129
  return 'cohesive-module';
1019
1130
  }
1020
-
1131
+
1021
1132
  return 'unknown';
1022
1133
  }
1023
1134
 
1024
1135
  /**
1025
1136
  * Detect if a file is a barrel export (re-exports from other modules)
1026
- *
1137
+ *
1027
1138
  * Characteristics of barrel exports:
1028
1139
  * - Named "index.ts" or "index.js"
1029
1140
  * - Many re-export statements (export * from, export { x } from)
@@ -1032,42 +1143,46 @@ export function classifyFile(
1032
1143
  */
1033
1144
  function isBarrelExport(node: DependencyNode): boolean {
1034
1145
  const { file, exports, imports, linesOfCode } = node;
1035
-
1146
+
1036
1147
  // Check filename pattern
1037
1148
  const fileName = file.split('/').pop()?.toLowerCase();
1038
- const isIndexFile = fileName === 'index.ts' || fileName === 'index.js' ||
1039
- fileName === 'index.tsx' || fileName === 'index.jsx';
1040
-
1149
+ const isIndexFile =
1150
+ fileName === 'index.ts' ||
1151
+ fileName === 'index.js' ||
1152
+ fileName === 'index.tsx' ||
1153
+ fileName === 'index.jsx';
1154
+
1041
1155
  // Calculate re-export ratio
1042
1156
  // Re-exports typically have form: export { x } from 'module' or export * from 'module'
1043
1157
  // They have imports AND exports, with exports coming from those imports
1044
1158
  const hasReExports = exports.length > 0 && imports.length > 0;
1045
- const highExportToLinesRatio = exports.length > 3 && linesOfCode < exports.length * 5;
1046
-
1159
+ const highExportToLinesRatio =
1160
+ exports.length > 3 && linesOfCode < exports.length * 5;
1161
+
1047
1162
  // Little actual code (mostly import/export statements)
1048
1163
  const sparseCode = linesOfCode > 0 && linesOfCode < 50 && exports.length >= 2;
1049
-
1164
+
1050
1165
  // Index files with re-export patterns
1051
1166
  if (isIndexFile && hasReExports) {
1052
1167
  return true;
1053
1168
  }
1054
-
1169
+
1055
1170
  // Non-index files that are clearly barrel exports
1056
1171
  if (highExportToLinesRatio && imports.length >= exports.length * 0.5) {
1057
1172
  return true;
1058
1173
  }
1059
-
1174
+
1060
1175
  // Very sparse files with multiple re-exports
1061
1176
  if (sparseCode && imports.length > 0) {
1062
1177
  return true;
1063
1178
  }
1064
-
1179
+
1065
1180
  return false;
1066
1181
  }
1067
1182
 
1068
1183
  /**
1069
1184
  * Detect if a file is primarily a type definition file
1070
- *
1185
+ *
1071
1186
  * Characteristics:
1072
1187
  * - Mostly type/interface exports
1073
1188
  * - Little to no runtime code
@@ -1076,41 +1191,57 @@ function isBarrelExport(node: DependencyNode): boolean {
1076
1191
  */
1077
1192
  function isTypeDefinitionFile(node: DependencyNode): boolean {
1078
1193
  const { file, exports } = node;
1079
-
1194
+
1080
1195
  // Check filename pattern
1081
1196
  const fileName = file.split('/').pop()?.toLowerCase();
1082
- const isTypesFile = fileName?.includes('types') || fileName?.includes('.d.ts') ||
1083
- fileName === 'types.ts' || fileName === 'interfaces.ts';
1084
-
1197
+ const isTypesFile =
1198
+ fileName?.includes('types') ||
1199
+ fileName?.includes('.d.ts') ||
1200
+ fileName === 'types.ts' ||
1201
+ fileName === 'interfaces.ts';
1202
+
1085
1203
  // Check if file is in a types directory (path-based detection)
1086
1204
  const lowerPath = file.toLowerCase();
1087
- const isTypesPath = lowerPath.includes('/types/') ||
1088
- lowerPath.includes('/typings/') ||
1089
- lowerPath.includes('/@types/') ||
1090
- lowerPath.startsWith('types/') ||
1091
- lowerPath.startsWith('typings/');
1092
-
1205
+ const isTypesPath =
1206
+ lowerPath.includes('/types/') ||
1207
+ lowerPath.includes('/typings/') ||
1208
+ lowerPath.includes('/@types/') ||
1209
+ lowerPath.startsWith('types/') ||
1210
+ lowerPath.startsWith('typings/');
1211
+
1093
1212
  // Count type exports vs other exports
1094
- const typeExports = exports.filter(e => e.type === 'type' || e.type === 'interface');
1095
- const runtimeExports = exports.filter(e => e.type === 'function' || e.type === 'class' || e.type === 'const');
1096
-
1213
+ const typeExports = exports.filter(
1214
+ (e) => e.type === 'type' || e.type === 'interface'
1215
+ );
1216
+ const runtimeExports = exports.filter(
1217
+ (e) => e.type === 'function' || e.type === 'class' || e.type === 'const'
1218
+ );
1219
+
1097
1220
  // High ratio of type exports
1098
- const mostlyTypes = exports.length > 0 &&
1099
- typeExports.length > runtimeExports.length &&
1100
- typeExports.length / exports.length > 0.7;
1101
-
1221
+ const mostlyTypes =
1222
+ exports.length > 0 &&
1223
+ typeExports.length > runtimeExports.length &&
1224
+ typeExports.length / exports.length > 0.7;
1225
+
1102
1226
  // Pure type files (only type/interface exports, no runtime code)
1103
- const pureTypeFile = exports.length > 0 && typeExports.length === exports.length;
1104
-
1227
+ const pureTypeFile =
1228
+ exports.length > 0 && typeExports.length === exports.length;
1229
+
1105
1230
  // Empty export file in types directory (might just be re-exports)
1106
1231
  const emptyOrReExportInTypesDir = isTypesPath && exports.length === 0;
1107
-
1108
- return isTypesFile || isTypesPath || mostlyTypes || pureTypeFile || emptyOrReExportInTypesDir;
1232
+
1233
+ return (
1234
+ isTypesFile ||
1235
+ isTypesPath ||
1236
+ mostlyTypes ||
1237
+ pureTypeFile ||
1238
+ emptyOrReExportInTypesDir
1239
+ );
1109
1240
  }
1110
1241
 
1111
1242
  /**
1112
1243
  * Detect if a file is a config/schema file
1113
- *
1244
+ *
1114
1245
  * Characteristics:
1115
1246
  * - Named with config, schema, or settings patterns
1116
1247
  * - Often defines database schemas, configuration objects
@@ -1118,38 +1249,51 @@ function isTypeDefinitionFile(node: DependencyNode): boolean {
1118
1249
  */
1119
1250
  function isConfigOrSchemaFile(node: DependencyNode): boolean {
1120
1251
  const { file, exports } = node;
1121
-
1252
+
1122
1253
  const fileName = file.split('/').pop()?.toLowerCase();
1123
-
1254
+
1124
1255
  // Check filename patterns for config/schema files
1125
1256
  const configPatterns = [
1126
- 'config', 'schema', 'settings', 'options', 'constants',
1127
- 'env', 'environment', '.config.', '-config.', '_config.',
1257
+ 'config',
1258
+ 'schema',
1259
+ 'settings',
1260
+ 'options',
1261
+ 'constants',
1262
+ 'env',
1263
+ 'environment',
1264
+ '.config.',
1265
+ '-config.',
1266
+ '_config.',
1128
1267
  ];
1129
-
1130
- const isConfigName = configPatterns.some(pattern =>
1131
- fileName?.includes(pattern) || fileName?.startsWith(pattern) || fileName?.endsWith(`${pattern}.ts`)
1268
+
1269
+ const isConfigName = configPatterns.some(
1270
+ (pattern) =>
1271
+ fileName?.includes(pattern) ||
1272
+ fileName?.startsWith(pattern) ||
1273
+ fileName?.endsWith(`${pattern}.ts`)
1132
1274
  );
1133
-
1275
+
1134
1276
  // Check if file is in a config/settings directory
1135
- const isConfigPath = file.toLowerCase().includes('/config/') ||
1136
- file.toLowerCase().includes('/schemas/') ||
1137
- file.toLowerCase().includes('/settings/');
1138
-
1277
+ const isConfigPath =
1278
+ file.toLowerCase().includes('/config/') ||
1279
+ file.toLowerCase().includes('/schemas/') ||
1280
+ file.toLowerCase().includes('/settings/');
1281
+
1139
1282
  // Check for schema-like exports (often have table/model definitions)
1140
- const hasSchemaExports = exports.some(e =>
1141
- e.name.toLowerCase().includes('table') ||
1142
- e.name.toLowerCase().includes('schema') ||
1143
- e.name.toLowerCase().includes('config') ||
1144
- e.name.toLowerCase().includes('setting')
1283
+ const hasSchemaExports = exports.some(
1284
+ (e) =>
1285
+ e.name.toLowerCase().includes('table') ||
1286
+ e.name.toLowerCase().includes('schema') ||
1287
+ e.name.toLowerCase().includes('config') ||
1288
+ e.name.toLowerCase().includes('setting')
1145
1289
  );
1146
-
1290
+
1147
1291
  return isConfigName || isConfigPath || hasSchemaExports;
1148
1292
  }
1149
1293
 
1150
1294
  /**
1151
1295
  * Detect if a file is a utility/helper file
1152
- *
1296
+ *
1153
1297
  * Characteristics:
1154
1298
  * - Named with util, helper, or utility patterns
1155
1299
  * - Often contains mixed helper functions by design
@@ -1157,35 +1301,48 @@ function isConfigOrSchemaFile(node: DependencyNode): boolean {
1157
1301
  */
1158
1302
  function isUtilityFile(node: DependencyNode): boolean {
1159
1303
  const { file, exports } = node;
1160
-
1304
+
1161
1305
  const fileName = file.split('/').pop()?.toLowerCase();
1162
-
1306
+
1163
1307
  // Check filename patterns for utility files
1164
1308
  const utilityPatterns = [
1165
- 'util', 'utility', 'utilities', 'helper', 'helpers',
1166
- 'common', 'shared', 'toolbox', 'toolkit',
1167
- '.util.', '-util.', '_util.', '-utils.', '.utils.',
1309
+ 'util',
1310
+ 'utility',
1311
+ 'utilities',
1312
+ 'helper',
1313
+ 'helpers',
1314
+ 'common',
1315
+ 'shared',
1316
+ 'toolbox',
1317
+ 'toolkit',
1318
+ '.util.',
1319
+ '-util.',
1320
+ '_util.',
1321
+ '-utils.',
1322
+ '.utils.',
1168
1323
  ];
1169
-
1170
- const isUtilityName = utilityPatterns.some(pattern =>
1324
+
1325
+ const isUtilityName = utilityPatterns.some((pattern) =>
1171
1326
  fileName?.includes(pattern)
1172
1327
  );
1173
-
1328
+
1174
1329
  // Check if file is in a utils/helpers directory
1175
- const isUtilityPath = file.toLowerCase().includes('/utils/') ||
1176
- file.toLowerCase().includes('/helpers/') ||
1177
- file.toLowerCase().includes('/common/') ||
1178
- file.toLowerCase().endsWith('-utils.ts') ||
1179
- file.toLowerCase().endsWith('-util.ts') ||
1180
- file.toLowerCase().endsWith('-helper.ts') ||
1181
- file.toLowerCase().endsWith('-helpers.ts');
1182
-
1330
+ const isUtilityPath =
1331
+ file.toLowerCase().includes('/utils/') ||
1332
+ file.toLowerCase().includes('/helpers/') ||
1333
+ file.toLowerCase().includes('/common/') ||
1334
+ file.toLowerCase().endsWith('-utils.ts') ||
1335
+ file.toLowerCase().endsWith('-util.ts') ||
1336
+ file.toLowerCase().endsWith('-helper.ts') ||
1337
+ file.toLowerCase().endsWith('-helpers.ts');
1338
+
1183
1339
  // Only consider many small exports as utility pattern if also in utility-like path
1184
1340
  // This prevents false positives for regular modules with many functions
1185
- const hasManySmallExportsInUtilityContext = exports.length >= 3 &&
1186
- exports.every(e => e.type === 'function' || e.type === 'const') &&
1341
+ const hasManySmallExportsInUtilityContext =
1342
+ exports.length >= 3 &&
1343
+ exports.every((e) => e.type === 'function' || e.type === 'const') &&
1187
1344
  (isUtilityName || isUtilityPath);
1188
-
1345
+
1189
1346
  return isUtilityName || isUtilityPath || hasManySmallExportsInUtilityContext;
1190
1347
  }
1191
1348
 
@@ -1204,13 +1361,59 @@ function splitCamelCase(name: string): string[] {
1204
1361
 
1205
1362
  /** Common English verbs and adjectives to ignore when extracting entity nouns */
1206
1363
  const SKIP_WORDS = new Set([
1207
- 'get', 'set', 'create', 'update', 'delete', 'fetch', 'save', 'load',
1208
- 'parse', 'format', 'validate', 'convert', 'transform', 'build',
1209
- 'generate', 'render', 'send', 'receive', 'find', 'list', 'add',
1210
- 'remove', 'insert', 'upsert', 'put', 'read', 'write', 'check',
1211
- 'handle', 'process', 'compute', 'calculate', 'init', 'reset', 'clear',
1212
- 'pending', 'active', 'current', 'new', 'old', 'all', 'by', 'with',
1213
- 'from', 'to', 'and', 'or', 'is', 'has', 'in', 'on', 'of', 'the',
1364
+ 'get',
1365
+ 'set',
1366
+ 'create',
1367
+ 'update',
1368
+ 'delete',
1369
+ 'fetch',
1370
+ 'save',
1371
+ 'load',
1372
+ 'parse',
1373
+ 'format',
1374
+ 'validate',
1375
+ 'convert',
1376
+ 'transform',
1377
+ 'build',
1378
+ 'generate',
1379
+ 'render',
1380
+ 'send',
1381
+ 'receive',
1382
+ 'find',
1383
+ 'list',
1384
+ 'add',
1385
+ 'remove',
1386
+ 'insert',
1387
+ 'upsert',
1388
+ 'put',
1389
+ 'read',
1390
+ 'write',
1391
+ 'check',
1392
+ 'handle',
1393
+ 'process',
1394
+ 'compute',
1395
+ 'calculate',
1396
+ 'init',
1397
+ 'reset',
1398
+ 'clear',
1399
+ 'pending',
1400
+ 'active',
1401
+ 'current',
1402
+ 'new',
1403
+ 'old',
1404
+ 'all',
1405
+ 'by',
1406
+ 'with',
1407
+ 'from',
1408
+ 'to',
1409
+ 'and',
1410
+ 'or',
1411
+ 'is',
1412
+ 'has',
1413
+ 'in',
1414
+ 'on',
1415
+ 'of',
1416
+ 'the',
1214
1417
  ]);
1215
1418
 
1216
1419
  /** Singularize a word simply (strip trailing 's') */
@@ -1227,7 +1430,7 @@ function simpleSingularize(word: string): string {
1227
1430
  */
1228
1431
  function extractEntityNouns(name: string): string[] {
1229
1432
  return splitCamelCase(name)
1230
- .filter(token => !SKIP_WORDS.has(token) && token.length > 2)
1433
+ .filter((token) => !SKIP_WORDS.has(token) && token.length > 2)
1231
1434
  .map(simpleSingularize);
1232
1435
  }
1233
1436
 
@@ -1237,16 +1440,16 @@ function extractEntityNouns(name: string): string[] {
1237
1440
  */
1238
1441
  function allExportsShareEntityNoun(exports: ExportInfo[]): boolean {
1239
1442
  if (exports.length < 2 || exports.length > 30) return false;
1240
-
1241
- const nounSets = exports.map(e => new Set(extractEntityNouns(e.name)));
1242
- if (nounSets.some(s => s.size === 0)) return false;
1243
-
1443
+
1444
+ const nounSets = exports.map((e) => new Set(extractEntityNouns(e.name)));
1445
+ if (nounSets.some((s) => s.size === 0)) return false;
1446
+
1244
1447
  // Find nouns that appear in ALL exports
1245
1448
  const [first, ...rest] = nounSets;
1246
- const commonNouns = Array.from(first).filter(noun =>
1247
- rest.every(s => s.has(noun))
1449
+ const commonNouns = Array.from(first).filter((noun) =>
1450
+ rest.every((s) => s.has(noun))
1248
1451
  );
1249
-
1452
+
1250
1453
  return commonNouns.length > 0;
1251
1454
  }
1252
1455
 
@@ -1261,26 +1464,40 @@ function allExportsShareEntityNoun(exports: ExportInfo[]): boolean {
1261
1464
  function isDataAccessFile(node: DependencyNode): boolean {
1262
1465
  const { file, exports } = node;
1263
1466
  const fileName = file.split('/').pop()?.toLowerCase();
1264
-
1467
+
1265
1468
  const dalPatterns = [
1266
- 'dynamo', 'database', 'repository', 'repo', 'dao',
1267
- 'firestore', 'postgres', 'mysql', 'mongo', 'redis',
1268
- 'sqlite', 'supabase', 'prisma',
1469
+ 'dynamo',
1470
+ 'database',
1471
+ 'repository',
1472
+ 'repo',
1473
+ 'dao',
1474
+ 'firestore',
1475
+ 'postgres',
1476
+ 'mysql',
1477
+ 'mongo',
1478
+ 'redis',
1479
+ 'sqlite',
1480
+ 'supabase',
1481
+ 'prisma',
1269
1482
  ];
1270
-
1271
- const isDalName = dalPatterns.some(p => fileName?.includes(p));
1272
-
1273
- const isDalPath = file.toLowerCase().includes('/repositories/') ||
1274
- file.toLowerCase().includes('/dao/') ||
1275
- file.toLowerCase().includes('/data/');
1276
-
1483
+
1484
+ const isDalName = dalPatterns.some((p) => fileName?.includes(p));
1485
+
1486
+ const isDalPath =
1487
+ file.toLowerCase().includes('/repositories/') ||
1488
+ file.toLowerCase().includes('/dao/') ||
1489
+ file.toLowerCase().includes('/data/');
1490
+
1277
1491
  // File with few exports (≤10) that all share a common entity noun
1278
- const hasDalExportPattern = exports.length >= 1 &&
1492
+ const hasDalExportPattern =
1493
+ exports.length >= 1 &&
1279
1494
  exports.length <= 10 &&
1280
1495
  allExportsShareEntityNoun(exports);
1281
-
1496
+
1282
1497
  // Exclude obvious utility paths from DAL detection (e.g., src/utils/)
1283
- const isUtilityPathLocal = file.toLowerCase().includes('/utils/') || file.toLowerCase().includes('/helpers/');
1498
+ const isUtilityPathLocal =
1499
+ file.toLowerCase().includes('/utils/') ||
1500
+ file.toLowerCase().includes('/helpers/');
1284
1501
 
1285
1502
  // Only treat as DAL when the file is in a DAL path, or when the name/pattern
1286
1503
  // indicates a data access module AND exports follow a DAL-like pattern.
@@ -1290,7 +1507,7 @@ function isDataAccessFile(node: DependencyNode): boolean {
1290
1507
 
1291
1508
  /**
1292
1509
  * Detect if a file is a Lambda/API handler
1293
- *
1510
+ *
1294
1511
  * Characteristics:
1295
1512
  * - Named with handler patterns or in handler directories
1296
1513
  * - Single entry point (handler function)
@@ -1298,46 +1515,58 @@ function isDataAccessFile(node: DependencyNode): boolean {
1298
1515
  */
1299
1516
  function isLambdaHandler(node: DependencyNode): boolean {
1300
1517
  const { file, exports } = node;
1301
-
1518
+
1302
1519
  const fileName = file.split('/').pop()?.toLowerCase();
1303
-
1520
+
1304
1521
  // Check filename patterns for lambda handlers
1305
1522
  const handlerPatterns = [
1306
- 'handler', '.handler.', '-handler.',
1307
- 'lambda', '.lambda.', '-lambda.',
1523
+ 'handler',
1524
+ '.handler.',
1525
+ '-handler.',
1526
+ 'lambda',
1527
+ '.lambda.',
1528
+ '-lambda.',
1308
1529
  ];
1309
-
1310
- const isHandlerName = handlerPatterns.some(pattern =>
1530
+
1531
+ const isHandlerName = handlerPatterns.some((pattern) =>
1311
1532
  fileName?.includes(pattern)
1312
1533
  );
1313
-
1534
+
1314
1535
  // Check if file is in a handlers/lambdas/functions/lambda directory
1315
1536
  // Exclude /api/ unless it has handler-specific naming
1316
- const isHandlerPath = file.toLowerCase().includes('/handlers/') ||
1317
- file.toLowerCase().includes('/lambdas/') ||
1318
- file.toLowerCase().includes('/lambda/') ||
1319
- file.toLowerCase().includes('/functions/');
1320
-
1537
+ const isHandlerPath =
1538
+ file.toLowerCase().includes('/handlers/') ||
1539
+ file.toLowerCase().includes('/lambdas/') ||
1540
+ file.toLowerCase().includes('/lambda/') ||
1541
+ file.toLowerCase().includes('/functions/');
1542
+
1321
1543
  // Check for typical lambda handler exports (handler, main, etc.)
1322
- const hasHandlerExport = exports.some(e =>
1323
- e.name.toLowerCase() === 'handler' ||
1324
- e.name.toLowerCase() === 'main' ||
1325
- e.name.toLowerCase() === 'lambdahandler' ||
1326
- e.name.toLowerCase().endsWith('handler')
1544
+ const hasHandlerExport = exports.some(
1545
+ (e) =>
1546
+ e.name.toLowerCase() === 'handler' ||
1547
+ e.name.toLowerCase() === 'main' ||
1548
+ e.name.toLowerCase() === 'lambdahandler' ||
1549
+ e.name.toLowerCase().endsWith('handler')
1327
1550
  );
1328
-
1551
+
1329
1552
  // Only consider single export as lambda handler if it's in a handler-like context
1330
1553
  // (either in handler directory OR has handler naming)
1331
- const hasSingleEntryInHandlerContext = exports.length === 1 &&
1554
+ const hasSingleEntryInHandlerContext =
1555
+ exports.length === 1 &&
1332
1556
  (exports[0].type === 'function' || exports[0].name === 'default') &&
1333
1557
  (isHandlerPath || isHandlerName);
1334
-
1335
- return isHandlerName || isHandlerPath || hasHandlerExport || hasSingleEntryInHandlerContext;
1558
+
1559
+ return (
1560
+ isHandlerName ||
1561
+ isHandlerPath ||
1562
+ hasHandlerExport ||
1563
+ hasSingleEntryInHandlerContext
1564
+ );
1336
1565
  }
1337
1566
 
1338
1567
  /**
1339
1568
  * Detect if a file is a service file
1340
- *
1569
+ *
1341
1570
  * Characteristics:
1342
1571
  * - Named with service pattern
1343
1572
  * - Often a class or object with multiple methods
@@ -1345,39 +1574,40 @@ function isLambdaHandler(node: DependencyNode): boolean {
1345
1574
  */
1346
1575
  function isServiceFile(node: DependencyNode): boolean {
1347
1576
  const { file, exports } = node;
1348
-
1577
+
1349
1578
  const fileName = file.split('/').pop()?.toLowerCase();
1350
-
1579
+
1351
1580
  // Check filename patterns for service files
1352
- const servicePatterns = [
1353
- 'service', '.service.', '-service.', '_service.',
1354
- ];
1355
-
1356
- const isServiceName = servicePatterns.some(pattern =>
1581
+ const servicePatterns = ['service', '.service.', '-service.', '_service.'];
1582
+
1583
+ const isServiceName = servicePatterns.some((pattern) =>
1357
1584
  fileName?.includes(pattern)
1358
1585
  );
1359
-
1586
+
1360
1587
  // Check if file is in a services directory
1361
1588
  const isServicePath = file.toLowerCase().includes('/services/');
1362
-
1589
+
1363
1590
  // Check for service-like exports (class with "Service" in the name)
1364
- const hasServiceNamedExport = exports.some(e =>
1365
- e.name.toLowerCase().includes('service') ||
1366
- e.name.toLowerCase().endsWith('service')
1591
+ const hasServiceNamedExport = exports.some(
1592
+ (e) =>
1593
+ e.name.toLowerCase().includes('service') ||
1594
+ e.name.toLowerCase().endsWith('service')
1367
1595
  );
1368
-
1596
+
1369
1597
  // Check for typical service pattern (class export with service in name)
1370
- const hasClassExport = exports.some(e => e.type === 'class');
1371
-
1598
+ const hasClassExport = exports.some((e) => e.type === 'class');
1599
+
1372
1600
  // Service files need either:
1373
1601
  // 1. Service in filename/path, OR
1374
1602
  // 2. Class with "Service" in the class name
1375
- return isServiceName || isServicePath || (hasServiceNamedExport && hasClassExport);
1603
+ return (
1604
+ isServiceName || isServicePath || (hasServiceNamedExport && hasClassExport)
1605
+ );
1376
1606
  }
1377
1607
 
1378
1608
  /**
1379
1609
  * Detect if a file is an email template/layout
1380
- *
1610
+ *
1381
1611
  * Characteristics:
1382
1612
  * - Named with email/template patterns
1383
1613
  * - Contains render/template logic
@@ -1385,58 +1615,70 @@ function isServiceFile(node: DependencyNode): boolean {
1385
1615
  */
1386
1616
  function isEmailTemplate(node: DependencyNode): boolean {
1387
1617
  const { file, exports } = node;
1388
-
1618
+
1389
1619
  const fileName = file.split('/').pop()?.toLowerCase();
1390
-
1620
+
1391
1621
  // Check filename patterns for email templates (more specific patterns)
1392
1622
  const emailTemplatePatterns = [
1393
- '-email-', '.email.', '_email_',
1394
- '-template', '.template.', '_template',
1395
- '-mail.', '.mail.',
1623
+ '-email-',
1624
+ '.email.',
1625
+ '_email_',
1626
+ '-template',
1627
+ '.template.',
1628
+ '_template',
1629
+ '-mail.',
1630
+ '.mail.',
1396
1631
  ];
1397
-
1398
- const isEmailTemplateName = emailTemplatePatterns.some(pattern =>
1632
+
1633
+ const isEmailTemplateName = emailTemplatePatterns.some((pattern) =>
1399
1634
  fileName?.includes(pattern)
1400
1635
  );
1401
-
1636
+
1402
1637
  // Specific template file names
1403
- const isSpecificTemplateName =
1638
+ const isSpecificTemplateName =
1404
1639
  fileName?.includes('receipt') ||
1405
1640
  fileName?.includes('invoice-email') ||
1406
1641
  fileName?.includes('welcome-email') ||
1407
1642
  fileName?.includes('notification-email') ||
1408
- fileName?.includes('writer') && fileName.includes('receipt');
1409
-
1643
+ (fileName?.includes('writer') && fileName.includes('receipt'));
1644
+
1410
1645
  // Check if file is in emails/templates directory (high confidence)
1411
- const isEmailPath = file.toLowerCase().includes('/emails/') ||
1412
- file.toLowerCase().includes('/mail/') ||
1413
- file.toLowerCase().includes('/notifications/');
1414
-
1646
+ const isEmailPath =
1647
+ file.toLowerCase().includes('/emails/') ||
1648
+ file.toLowerCase().includes('/mail/') ||
1649
+ file.toLowerCase().includes('/notifications/');
1650
+
1415
1651
  // Check for template patterns (function that returns string/HTML)
1416
1652
  // More specific: must have render/generate in the function name
1417
- const hasTemplateFunction = exports.some(e =>
1418
- e.type === 'function' && (
1419
- e.name.toLowerCase().startsWith('render') ||
1420
- e.name.toLowerCase().startsWith('generate') ||
1421
- (e.name.toLowerCase().includes('template') && e.name.toLowerCase().includes('email'))
1422
- )
1653
+ const hasTemplateFunction = exports.some(
1654
+ (e) =>
1655
+ e.type === 'function' &&
1656
+ (e.name.toLowerCase().startsWith('render') ||
1657
+ e.name.toLowerCase().startsWith('generate') ||
1658
+ (e.name.toLowerCase().includes('template') &&
1659
+ e.name.toLowerCase().includes('email')))
1423
1660
  );
1424
-
1661
+
1425
1662
  // Check for email-related exports (but not service classes)
1426
- const hasEmailExport = exports.some(e =>
1427
- (e.name.toLowerCase().includes('template') && e.type === 'function') ||
1428
- (e.name.toLowerCase().includes('render') && e.type === 'function') ||
1429
- (e.name.toLowerCase().includes('email') && e.type !== 'class')
1663
+ const hasEmailExport = exports.some(
1664
+ (e) =>
1665
+ (e.name.toLowerCase().includes('template') && e.type === 'function') ||
1666
+ (e.name.toLowerCase().includes('render') && e.type === 'function') ||
1667
+ (e.name.toLowerCase().includes('email') && e.type !== 'class')
1430
1668
  );
1431
-
1669
+
1432
1670
  // Require path-based match OR combination of name and export patterns
1433
- return isEmailPath || isEmailTemplateName || isSpecificTemplateName ||
1434
- (hasTemplateFunction && hasEmailExport);
1671
+ return (
1672
+ isEmailPath ||
1673
+ isEmailTemplateName ||
1674
+ isSpecificTemplateName ||
1675
+ (hasTemplateFunction && hasEmailExport)
1676
+ );
1435
1677
  }
1436
1678
 
1437
1679
  /**
1438
1680
  * Detect if a file is a parser/transformer
1439
- *
1681
+ *
1440
1682
  * Characteristics:
1441
1683
  * - Named with parser/transform patterns
1442
1684
  * - Contains parse/transform logic
@@ -1444,56 +1686,68 @@ function isEmailTemplate(node: DependencyNode): boolean {
1444
1686
  */
1445
1687
  function isParserFile(node: DependencyNode): boolean {
1446
1688
  const { file, exports } = node;
1447
-
1689
+
1448
1690
  const fileName = file.split('/').pop()?.toLowerCase();
1449
-
1691
+
1450
1692
  // Check filename patterns for parser files
1451
1693
  const parserPatterns = [
1452
- 'parser', '.parser.', '-parser.', '_parser.',
1453
- 'transform', '.transform.', '-transform.',
1454
- 'converter', '.converter.', '-converter.',
1455
- 'mapper', '.mapper.', '-mapper.',
1456
- 'serializer', '.serializer.',
1694
+ 'parser',
1695
+ '.parser.',
1696
+ '-parser.',
1697
+ '_parser.',
1698
+ 'transform',
1699
+ '.transform.',
1700
+ '-transform.',
1701
+ 'converter',
1702
+ '.converter.',
1703
+ '-converter.',
1704
+ 'mapper',
1705
+ '.mapper.',
1706
+ '-mapper.',
1707
+ 'serializer',
1708
+ '.serializer.',
1457
1709
  'deterministic', // For base-parser-deterministic.ts pattern
1458
1710
  ];
1459
-
1460
- const isParserName = parserPatterns.some(pattern =>
1711
+
1712
+ const isParserName = parserPatterns.some((pattern) =>
1461
1713
  fileName?.includes(pattern)
1462
1714
  );
1463
-
1715
+
1464
1716
  // Check if file is in parsers/transformers directory
1465
- const isParserPath = file.toLowerCase().includes('/parsers/') ||
1466
- file.toLowerCase().includes('/transformers/') ||
1467
- file.toLowerCase().includes('/converters/') ||
1468
- file.toLowerCase().includes('/mappers/');
1469
-
1717
+ const isParserPath =
1718
+ file.toLowerCase().includes('/parsers/') ||
1719
+ file.toLowerCase().includes('/transformers/') ||
1720
+ file.toLowerCase().includes('/converters/') ||
1721
+ file.toLowerCase().includes('/mappers/');
1722
+
1470
1723
  // Check for parser-related exports
1471
- const hasParserExport = exports.some(e =>
1472
- e.name.toLowerCase().includes('parse') ||
1473
- e.name.toLowerCase().includes('transform') ||
1474
- e.name.toLowerCase().includes('convert') ||
1475
- e.name.toLowerCase().includes('map') ||
1476
- e.name.toLowerCase().includes('serialize') ||
1477
- e.name.toLowerCase().includes('deserialize')
1724
+ const hasParserExport = exports.some(
1725
+ (e) =>
1726
+ e.name.toLowerCase().includes('parse') ||
1727
+ e.name.toLowerCase().includes('transform') ||
1728
+ e.name.toLowerCase().includes('convert') ||
1729
+ e.name.toLowerCase().includes('map') ||
1730
+ e.name.toLowerCase().includes('serialize') ||
1731
+ e.name.toLowerCase().includes('deserialize')
1478
1732
  );
1479
-
1733
+
1480
1734
  // Check for function patterns typical of parsers
1481
- const hasParseFunction = exports.some(e =>
1482
- e.type === 'function' && (
1483
- e.name.toLowerCase().startsWith('parse') ||
1484
- e.name.toLowerCase().startsWith('transform') ||
1485
- e.name.toLowerCase().startsWith('convert') ||
1486
- e.name.toLowerCase().startsWith('map') ||
1487
- e.name.toLowerCase().startsWith('extract')
1488
- )
1735
+ const hasParseFunction = exports.some(
1736
+ (e) =>
1737
+ e.type === 'function' &&
1738
+ (e.name.toLowerCase().startsWith('parse') ||
1739
+ e.name.toLowerCase().startsWith('transform') ||
1740
+ e.name.toLowerCase().startsWith('convert') ||
1741
+ e.name.toLowerCase().startsWith('map') ||
1742
+ e.name.toLowerCase().startsWith('extract'))
1489
1743
  );
1490
-
1744
+
1491
1745
  return isParserName || isParserPath || hasParserExport || hasParseFunction;
1492
1746
  }
1493
1747
 
1494
1748
  /**
1495
1749
  * Detect if a file is a session/state management file
1496
- *
1750
+ *
1497
1751
  * Characteristics:
1498
1752
  * - Named with session/state patterns
1499
1753
  * - Manages state across operations
@@ -1501,42 +1755,52 @@ function isParserFile(node: DependencyNode): boolean {
1501
1755
  */
1502
1756
  function isSessionFile(node: DependencyNode): boolean {
1503
1757
  const { file, exports } = node;
1504
-
1758
+
1505
1759
  const fileName = file.split('/').pop()?.toLowerCase();
1506
-
1760
+
1507
1761
  // Check filename patterns for session files
1508
1762
  const sessionPatterns = [
1509
- 'session', '.session.', '-session.',
1510
- 'state', '.state.', '-state.',
1511
- 'context', '.context.', '-context.',
1512
- 'store', '.store.', '-store.',
1763
+ 'session',
1764
+ '.session.',
1765
+ '-session.',
1766
+ 'state',
1767
+ '.state.',
1768
+ '-state.',
1769
+ 'context',
1770
+ '.context.',
1771
+ '-context.',
1772
+ 'store',
1773
+ '.store.',
1774
+ '-store.',
1513
1775
  ];
1514
-
1515
- const isSessionName = sessionPatterns.some(pattern =>
1776
+
1777
+ const isSessionName = sessionPatterns.some((pattern) =>
1516
1778
  fileName?.includes(pattern)
1517
1779
  );
1518
-
1780
+
1519
1781
  // Check if file is in sessions/state directory
1520
- const isSessionPath = file.toLowerCase().includes('/sessions/') ||
1521
- file.toLowerCase().includes('/state/') ||
1522
- file.toLowerCase().includes('/context/') ||
1523
- file.toLowerCase().includes('/store/');
1524
-
1782
+ const isSessionPath =
1783
+ file.toLowerCase().includes('/sessions/') ||
1784
+ file.toLowerCase().includes('/state/') ||
1785
+ file.toLowerCase().includes('/context/') ||
1786
+ file.toLowerCase().includes('/store/');
1787
+
1525
1788
  // Check for session-related exports
1526
- const hasSessionExport = exports.some(e =>
1527
- e.name.toLowerCase().includes('session') ||
1528
- e.name.toLowerCase().includes('state') ||
1529
- e.name.toLowerCase().includes('context') ||
1530
- e.name.toLowerCase().includes('manager') ||
1531
- e.name.toLowerCase().includes('store')
1789
+ const hasSessionExport = exports.some(
1790
+ (e) =>
1791
+ e.name.toLowerCase().includes('session') ||
1792
+ e.name.toLowerCase().includes('state') ||
1793
+ e.name.toLowerCase().includes('context') ||
1794
+ e.name.toLowerCase().includes('manager') ||
1795
+ e.name.toLowerCase().includes('store')
1532
1796
  );
1533
-
1797
+
1534
1798
  return isSessionName || isSessionPath || hasSessionExport;
1535
1799
  }
1536
1800
 
1537
1801
  /**
1538
1802
  * Detect if a file is a Next.js App Router page
1539
- *
1803
+ *
1540
1804
  * Characteristics:
1541
1805
  * - Located in /app/ directory (Next.js App Router)
1542
1806
  * - Named page.tsx or page.ts
@@ -1544,38 +1808,47 @@ function isSessionFile(node: DependencyNode): boolean {
1544
1808
  * - faqJsonLd, jsonLd (structured data)
1545
1809
  * - icon (for tool cards)
1546
1810
  * - generateMetadata (dynamic SEO)
1547
- *
1811
+ *
1548
1812
  * This is the canonical Next.js pattern for SEO-optimized pages.
1549
1813
  * Multiple exports are COHESIVE - they all serve the page's purpose.
1550
1814
  */
1551
1815
  function isNextJsPage(node: DependencyNode): boolean {
1552
1816
  const { file, exports } = node;
1553
-
1817
+
1554
1818
  const lowerPath = file.toLowerCase();
1555
1819
  const fileName = file.split('/').pop()?.toLowerCase();
1556
-
1820
+
1557
1821
  // Must be in /app/ directory (Next.js App Router)
1558
- const isInAppDir = lowerPath.includes('/app/') || lowerPath.startsWith('app/');
1559
-
1822
+ const isInAppDir =
1823
+ lowerPath.includes('/app/') || lowerPath.startsWith('app/');
1824
+
1560
1825
  // Must be named page.tsx or page.ts
1561
1826
  const isPageFile = fileName === 'page.tsx' || fileName === 'page.ts';
1562
-
1827
+
1563
1828
  if (!isInAppDir || !isPageFile) {
1564
1829
  return false;
1565
1830
  }
1566
-
1831
+
1567
1832
  // Check for Next.js page export patterns
1568
- const exportNames = exports.map(e => e.name.toLowerCase());
1569
-
1833
+ const exportNames = exports.map((e) => e.name.toLowerCase());
1834
+
1570
1835
  // Must have default export (the page component)
1571
- const hasDefaultExport = exports.some(e => e.type === 'default');
1572
-
1836
+ const hasDefaultExport = exports.some((e) => e.type === 'default');
1837
+
1573
1838
  // Common Next.js page exports
1574
- const nextJsExports = ['metadata', 'generatemetadata', 'faqjsonld', 'jsonld', 'icon', 'viewport', 'dynamic'];
1575
- const hasNextJsExports = exportNames.some(name =>
1576
- nextJsExports.includes(name) || name.includes('jsonld')
1839
+ const nextJsExports = [
1840
+ 'metadata',
1841
+ 'generatemetadata',
1842
+ 'faqjsonld',
1843
+ 'jsonld',
1844
+ 'icon',
1845
+ 'viewport',
1846
+ 'dynamic',
1847
+ ];
1848
+ const hasNextJsExports = exportNames.some(
1849
+ (name) => nextJsExports.includes(name) || name.includes('jsonld')
1577
1850
  );
1578
-
1851
+
1579
1852
  // A Next.js page typically has:
1580
1853
  // 1. Default export (page component) - required
1581
1854
  // 2. Metadata or other Next.js-specific exports - optional but indicative
@@ -1584,7 +1857,7 @@ function isNextJsPage(node: DependencyNode): boolean {
1584
1857
 
1585
1858
  /**
1586
1859
  * Adjust cohesion score based on file classification.
1587
- *
1860
+ *
1588
1861
  * This reduces false positives by recognizing that certain file types
1589
1862
  * have inherently different cohesion patterns:
1590
1863
  * - Utility modules may touch multiple domains but serve one purpose
@@ -1592,7 +1865,7 @@ function isNextJsPage(node: DependencyNode): boolean {
1592
1865
  * - Lambda handlers coordinate multiple services
1593
1866
  * - Email templates reference multiple domains for rendering
1594
1867
  * - Parser files transform data across domains
1595
- *
1868
+ *
1596
1869
  * @param baseCohesion - The calculated cohesion score (0-1)
1597
1870
  * @param classification - The file classification
1598
1871
  * @param node - Optional node for additional heuristics
@@ -1614,10 +1887,10 @@ export function adjustCohesionForClassification(
1614
1887
  // Utility modules serve a functional purpose despite multi-domain.
1615
1888
  // Use a floor of 0.75 so related utilities never appear as low-cohesion.
1616
1889
  if (node) {
1617
- const exportNames = node.exports.map(e => e.name.toLowerCase());
1890
+ const exportNames = node.exports.map((e) => e.name.toLowerCase());
1618
1891
  const hasRelatedNames = hasRelatedExportNames(exportNames);
1619
1892
  if (hasRelatedNames) {
1620
- return Math.max(0.80, Math.min(1, baseCohesion + 0.45));
1893
+ return Math.max(0.8, Math.min(1, baseCohesion + 0.45));
1621
1894
  }
1622
1895
  }
1623
1896
  return Math.max(0.75, Math.min(1, baseCohesion + 0.35));
@@ -1625,18 +1898,19 @@ export function adjustCohesionForClassification(
1625
1898
  case 'service-file': {
1626
1899
  // Services orchestrate dependencies by design.
1627
1900
  // Floor at 0.72 so service files are never flagged as low-cohesion.
1628
- if (node?.exports.some(e => e.type === 'class')) {
1629
- return Math.max(0.78, Math.min(1, baseCohesion + 0.40));
1901
+ if (node?.exports.some((e) => e.type === 'class')) {
1902
+ return Math.max(0.78, Math.min(1, baseCohesion + 0.4));
1630
1903
  }
1631
- return Math.max(0.72, Math.min(1, baseCohesion + 0.30));
1904
+ return Math.max(0.72, Math.min(1, baseCohesion + 0.3));
1632
1905
  }
1633
1906
  case 'lambda-handler': {
1634
1907
  // Lambda handlers have single business purpose; floor at 0.75.
1635
1908
  if (node) {
1636
- const hasSingleEntry = node.exports.length === 1 ||
1637
- node.exports.some(e => e.name.toLowerCase() === 'handler');
1909
+ const hasSingleEntry =
1910
+ node.exports.length === 1 ||
1911
+ node.exports.some((e) => e.name.toLowerCase() === 'handler');
1638
1912
  if (hasSingleEntry) {
1639
- return Math.max(0.80, Math.min(1, baseCohesion + 0.45));
1913
+ return Math.max(0.8, Math.min(1, baseCohesion + 0.45));
1640
1914
  }
1641
1915
  }
1642
1916
  return Math.max(0.75, Math.min(1, baseCohesion + 0.35));
@@ -1644,31 +1918,33 @@ export function adjustCohesionForClassification(
1644
1918
  case 'email-template': {
1645
1919
  // Email templates are structurally cohesive (single rendering purpose); floor at 0.72.
1646
1920
  if (node) {
1647
- const hasTemplateFunc = node.exports.some(e =>
1648
- e.name.toLowerCase().includes('render') ||
1649
- e.name.toLowerCase().includes('generate') ||
1650
- e.name.toLowerCase().includes('template')
1921
+ const hasTemplateFunc = node.exports.some(
1922
+ (e) =>
1923
+ e.name.toLowerCase().includes('render') ||
1924
+ e.name.toLowerCase().includes('generate') ||
1925
+ e.name.toLowerCase().includes('template')
1651
1926
  );
1652
1927
  if (hasTemplateFunc) {
1653
- return Math.max(0.75, Math.min(1, baseCohesion + 0.40));
1928
+ return Math.max(0.75, Math.min(1, baseCohesion + 0.4));
1654
1929
  }
1655
1930
  }
1656
- return Math.max(0.72, Math.min(1, baseCohesion + 0.30));
1931
+ return Math.max(0.72, Math.min(1, baseCohesion + 0.3));
1657
1932
  }
1658
1933
  case 'parser-file': {
1659
1934
  // Parsers transform data - single transformation purpose
1660
1935
  if (node) {
1661
1936
  // Check for parse/transform functions
1662
- const hasParseFunc = node.exports.some(e =>
1663
- e.name.toLowerCase().startsWith('parse') ||
1664
- e.name.toLowerCase().startsWith('transform') ||
1665
- e.name.toLowerCase().startsWith('convert')
1937
+ const hasParseFunc = node.exports.some(
1938
+ (e) =>
1939
+ e.name.toLowerCase().startsWith('parse') ||
1940
+ e.name.toLowerCase().startsWith('transform') ||
1941
+ e.name.toLowerCase().startsWith('convert')
1666
1942
  );
1667
1943
  if (hasParseFunc) {
1668
- return Math.max(0.75, Math.min(1, baseCohesion + 0.40));
1944
+ return Math.max(0.75, Math.min(1, baseCohesion + 0.4));
1669
1945
  }
1670
1946
  }
1671
- return Math.max(0.70, Math.min(1, baseCohesion + 0.30));
1947
+ return Math.max(0.7, Math.min(1, baseCohesion + 0.3));
1672
1948
  }
1673
1949
  case 'nextjs-page':
1674
1950
  // Next.js pages have multiple exports by design (metadata, jsonLd, page component)
@@ -1682,13 +1958,13 @@ export function adjustCohesionForClassification(
1682
1958
  return baseCohesion;
1683
1959
  default:
1684
1960
  // Unknown - give benefit of doubt with small boost
1685
- return Math.min(1, baseCohesion + 0.10);
1961
+ return Math.min(1, baseCohesion + 0.1);
1686
1962
  }
1687
1963
  }
1688
1964
 
1689
1965
  /**
1690
1966
  * Check if export names suggest related functionality
1691
- *
1967
+ *
1692
1968
  * Examples of related patterns:
1693
1969
  * - formatDate, parseDate, validateDate (date utilities)
1694
1970
  * - getUser, saveUser, deleteUser (user utilities)
@@ -1696,40 +1972,73 @@ export function adjustCohesionForClassification(
1696
1972
  */
1697
1973
  function hasRelatedExportNames(exportNames: string[]): boolean {
1698
1974
  if (exportNames.length < 2) return true;
1699
-
1975
+
1700
1976
  // Extract common prefixes/suffixes
1701
1977
  const stems = new Set<string>();
1702
1978
  const domains = new Set<string>();
1703
-
1979
+
1704
1980
  for (const name of exportNames) {
1705
1981
  // Check for common verb prefixes
1706
- const verbs = ['get', 'set', 'create', 'update', 'delete', 'fetch', 'save', 'load', 'parse', 'format', 'validate', 'convert', 'transform', 'build', 'generate', 'render', 'send', 'receive'];
1982
+ const verbs = [
1983
+ 'get',
1984
+ 'set',
1985
+ 'create',
1986
+ 'update',
1987
+ 'delete',
1988
+ 'fetch',
1989
+ 'save',
1990
+ 'load',
1991
+ 'parse',
1992
+ 'format',
1993
+ 'validate',
1994
+ 'convert',
1995
+ 'transform',
1996
+ 'build',
1997
+ 'generate',
1998
+ 'render',
1999
+ 'send',
2000
+ 'receive',
2001
+ ];
1707
2002
  for (const verb of verbs) {
1708
2003
  if (name.startsWith(verb) && name.length > verb.length) {
1709
2004
  stems.add(name.slice(verb.length).toLowerCase());
1710
2005
  }
1711
2006
  }
1712
-
2007
+
1713
2008
  // Check for domain suffixes (User, Order, etc.)
1714
- const domainPatterns = ['user', 'order', 'product', 'session', 'email', 'file', 'db', 's3', 'dynamo', 'api', 'config'];
2009
+ const domainPatterns = [
2010
+ 'user',
2011
+ 'order',
2012
+ 'product',
2013
+ 'session',
2014
+ 'email',
2015
+ 'file',
2016
+ 'db',
2017
+ 's3',
2018
+ 'dynamo',
2019
+ 'api',
2020
+ 'config',
2021
+ ];
1715
2022
  for (const domain of domainPatterns) {
1716
2023
  if (name.includes(domain)) {
1717
2024
  domains.add(domain);
1718
2025
  }
1719
2026
  }
1720
2027
  }
1721
-
2028
+
1722
2029
  // If exports share common stems or domains, they're related
1723
2030
  if (stems.size === 1 && exportNames.length >= 2) return true;
1724
2031
  if (domains.size === 1 && exportNames.length >= 2) return true;
1725
-
2032
+
1726
2033
  // Check for utilities with same service prefix (e.g., dynamodbGet, dynamodbPut)
1727
- const prefixes = exportNames.map(name => {
1728
- // Extract prefix before first capital letter or common separator
1729
- const match = name.match(/^([a-z]+)/);
1730
- return match ? match[1] : '';
1731
- }).filter(p => p.length >= 3);
1732
-
2034
+ const prefixes = exportNames
2035
+ .map((name) => {
2036
+ // Extract prefix before first capital letter or common separator
2037
+ const match = name.match(/^([a-z]+)/);
2038
+ return match ? match[1] : '';
2039
+ })
2040
+ .filter((p) => p.length >= 3);
2041
+
1733
2042
  if (prefixes.length >= 2) {
1734
2043
  const uniquePrefixes = new Set(prefixes);
1735
2044
  if (uniquePrefixes.size === 1) return true;
@@ -1737,23 +2046,62 @@ function hasRelatedExportNames(exportNames: string[]): boolean {
1737
2046
 
1738
2047
  // Check for shared entity noun across all exports using camelCase token splitting
1739
2048
  // e.g. getUserReceipts + createPendingReceipt both contain 'receipt'
1740
- const nounSets = exportNames.map(name => {
2049
+ const nounSets = exportNames.map((name) => {
1741
2050
  const tokens = name
1742
2051
  .replace(/([A-Z])/g, ' $1')
1743
2052
  .trim()
1744
2053
  .toLowerCase()
1745
2054
  .split(/[\s_-]+/)
1746
2055
  .filter(Boolean);
1747
- const skip = new Set(['get','set','create','update','delete','fetch','save','load',
1748
- 'parse','format','validate','convert','transform','build','generate','render',
1749
- 'send','receive','find','list','add','remove','insert','upsert','put','read',
1750
- 'write','check','handle','process','pending','active','current','new','old','all']);
1751
- const singularize = (w: string) => w.endsWith('s') && w.length > 3 ? w.slice(0,-1) : w;
1752
- return new Set(tokens.filter(t => !skip.has(t) && t.length > 2).map(singularize));
2056
+ const skip = new Set([
2057
+ 'get',
2058
+ 'set',
2059
+ 'create',
2060
+ 'update',
2061
+ 'delete',
2062
+ 'fetch',
2063
+ 'save',
2064
+ 'load',
2065
+ 'parse',
2066
+ 'format',
2067
+ 'validate',
2068
+ 'convert',
2069
+ 'transform',
2070
+ 'build',
2071
+ 'generate',
2072
+ 'render',
2073
+ 'send',
2074
+ 'receive',
2075
+ 'find',
2076
+ 'list',
2077
+ 'add',
2078
+ 'remove',
2079
+ 'insert',
2080
+ 'upsert',
2081
+ 'put',
2082
+ 'read',
2083
+ 'write',
2084
+ 'check',
2085
+ 'handle',
2086
+ 'process',
2087
+ 'pending',
2088
+ 'active',
2089
+ 'current',
2090
+ 'new',
2091
+ 'old',
2092
+ 'all',
2093
+ ]);
2094
+ const singularize = (w: string) =>
2095
+ w.endsWith('s') && w.length > 3 ? w.slice(0, -1) : w;
2096
+ return new Set(
2097
+ tokens.filter((t) => !skip.has(t) && t.length > 2).map(singularize)
2098
+ );
1753
2099
  });
1754
- if (nounSets.length >= 2 && nounSets.every(s => s.size > 0)) {
2100
+ if (nounSets.length >= 2 && nounSets.every((s) => s.size > 0)) {
1755
2101
  const [first, ...rest] = nounSets;
1756
- const commonNouns = Array.from(first).filter(n => rest.every(s => s.has(n)));
2102
+ const commonNouns = Array.from(first).filter((n) =>
2103
+ rest.every((s) => s.has(n))
2104
+ );
1757
2105
  if (commonNouns.length > 0) return true;
1758
2106
  }
1759
2107
 
@@ -1762,7 +2110,7 @@ function hasRelatedExportNames(exportNames: string[]): boolean {
1762
2110
 
1763
2111
  /**
1764
2112
  * Adjust fragmentation score based on file classification
1765
- *
2113
+ *
1766
2114
  * This reduces false positives by:
1767
2115
  * - Ignoring fragmentation for barrel exports (they're meant to aggregate)
1768
2116
  * - Ignoring fragmentation for type definitions (centralized types are good)