@girardelli/architect 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +140 -0
- package/LICENSE +21 -0
- package/PROJECT_STRUCTURE.txt +168 -0
- package/README.md +269 -0
- package/dist/analyzer.d.ts +17 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzer.js +254 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/anti-patterns.d.ts +17 -0
- package/dist/anti-patterns.d.ts.map +1 -0
- package/dist/anti-patterns.js +211 -0
- package/dist/anti-patterns.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +164 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +73 -0
- package/dist/config.js.map +1 -0
- package/dist/diagram.d.ts +9 -0
- package/dist/diagram.d.ts.map +1 -0
- package/dist/diagram.js +116 -0
- package/dist/diagram.js.map +1 -0
- package/dist/html-reporter.d.ts +23 -0
- package/dist/html-reporter.d.ts.map +1 -0
- package/dist/html-reporter.js +454 -0
- package/dist/html-reporter.js.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/reporter.d.ts +13 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +135 -0
- package/dist/reporter.js.map +1 -0
- package/dist/scanner.d.ts +25 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +288 -0
- package/dist/scanner.js.map +1 -0
- package/dist/scorer.d.ts +15 -0
- package/dist/scorer.d.ts.map +1 -0
- package/dist/scorer.js +172 -0
- package/dist/scorer.js.map +1 -0
- package/dist/types.d.ts +106 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/examples/sample-report.md +207 -0
- package/jest.config.js +18 -0
- package/package.json +70 -0
- package/src/analyzer.ts +310 -0
- package/src/anti-patterns.ts +264 -0
- package/src/cli.ts +183 -0
- package/src/config.ts +82 -0
- package/src/diagram.ts +144 -0
- package/src/html-reporter.ts +485 -0
- package/src/index.ts +212 -0
- package/src/reporter.ts +166 -0
- package/src/scanner.ts +298 -0
- package/src/scorer.ts +193 -0
- package/src/types.ts +114 -0
- package/tests/anti-patterns.test.ts +94 -0
- package/tests/scanner.test.ts +55 -0
- package/tests/scorer.test.ts +80 -0
- package/tsconfig.json +24 -0
package/src/analyzer.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { extname, relative, dirname } from 'path';
|
|
3
|
+
import { DependencyEdge, Layer, FileNode } from './types.js';
|
|
4
|
+
|
|
5
|
+
export class ArchitectureAnalyzer {
|
|
6
|
+
private projectPath: string;
|
|
7
|
+
private dependencyGraph: Map<string, Set<string>> = new Map();
|
|
8
|
+
private fileExtensions: Map<string, string> = new Map();
|
|
9
|
+
|
|
10
|
+
constructor(projectPath: string) {
|
|
11
|
+
this.projectPath = projectPath;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
analyzeDependencies(fileTree: FileNode): DependencyEdge[] {
|
|
15
|
+
this.buildDependencyGraph(fileTree);
|
|
16
|
+
return this.buildEdgeList();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
detectLayers(fileTree: FileNode): Layer[] {
|
|
20
|
+
const layers: Layer[] = [];
|
|
21
|
+
const apiFiles: string[] = [];
|
|
22
|
+
const serviceFiles: string[] = [];
|
|
23
|
+
const dataFiles: string[] = [];
|
|
24
|
+
const uiFiles: string[] = [];
|
|
25
|
+
const infraFiles: string[] = [];
|
|
26
|
+
|
|
27
|
+
this.categorizeFiles(fileTree, apiFiles, serviceFiles, dataFiles, uiFiles, infraFiles);
|
|
28
|
+
|
|
29
|
+
if (apiFiles.length > 0) {
|
|
30
|
+
layers.push({
|
|
31
|
+
name: 'API',
|
|
32
|
+
files: apiFiles,
|
|
33
|
+
description: 'API layer - handles external interfaces and routing',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (serviceFiles.length > 0) {
|
|
38
|
+
layers.push({
|
|
39
|
+
name: 'Service',
|
|
40
|
+
files: serviceFiles,
|
|
41
|
+
description: 'Service layer - business logic and orchestration',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (dataFiles.length > 0) {
|
|
46
|
+
layers.push({
|
|
47
|
+
name: 'Data',
|
|
48
|
+
files: dataFiles,
|
|
49
|
+
description: 'Data layer - database access and persistence',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (uiFiles.length > 0) {
|
|
54
|
+
layers.push({
|
|
55
|
+
name: 'UI',
|
|
56
|
+
files: uiFiles,
|
|
57
|
+
description: 'UI layer - user interface components and views',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (infraFiles.length > 0) {
|
|
62
|
+
layers.push({
|
|
63
|
+
name: 'Infrastructure',
|
|
64
|
+
files: infraFiles,
|
|
65
|
+
description: 'Infrastructure layer - configuration and setup',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return layers;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private buildDependencyGraph(node: FileNode): void {
|
|
73
|
+
if (node.type === 'file') {
|
|
74
|
+
const imports = this.parseImports(node.path);
|
|
75
|
+
this.dependencyGraph.set(node.path, new Set(imports));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (node.children) {
|
|
79
|
+
for (const child of node.children) {
|
|
80
|
+
this.buildDependencyGraph(child);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private parseImports(filePath: string): string[] {
|
|
86
|
+
try {
|
|
87
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
88
|
+
const ext = extname(filePath);
|
|
89
|
+
const imports: string[] = [];
|
|
90
|
+
|
|
91
|
+
if (ext === '.ts' || ext === '.tsx' || ext === '.js' || ext === '.jsx') {
|
|
92
|
+
const importRegex =
|
|
93
|
+
/(?:import|require)\s*(?:\{[^}]+\}|[^\s]+)\s*from\s*['"]([^'"]+)['"]/g;
|
|
94
|
+
let match;
|
|
95
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
96
|
+
imports.push(match[1]);
|
|
97
|
+
}
|
|
98
|
+
} else if (ext === '.py') {
|
|
99
|
+
const importRegex =
|
|
100
|
+
/(?:from|import)\s+(?:[^\s]+)\s*(?:import\s+)?([^\n]+)?/g;
|
|
101
|
+
let match;
|
|
102
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
103
|
+
if (match[1]) {
|
|
104
|
+
imports.push(match[1].trim());
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} else if (ext === '.java') {
|
|
108
|
+
const importRegex = /import\s+([^\s;]+);/g;
|
|
109
|
+
let match;
|
|
110
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
111
|
+
imports.push(match[1]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return imports;
|
|
116
|
+
} catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private buildEdgeList(): DependencyEdge[] {
|
|
122
|
+
const edges: DependencyEdge[] = [];
|
|
123
|
+
const seenEdges = new Set<string>();
|
|
124
|
+
|
|
125
|
+
for (const [from, toSet] of this.dependencyGraph.entries()) {
|
|
126
|
+
for (const to of toSet) {
|
|
127
|
+
const edgeKey = `${from}->${to}`;
|
|
128
|
+
if (!seenEdges.has(edgeKey)) {
|
|
129
|
+
edges.push({
|
|
130
|
+
from,
|
|
131
|
+
to,
|
|
132
|
+
type: 'import',
|
|
133
|
+
weight: 1,
|
|
134
|
+
});
|
|
135
|
+
seenEdges.add(edgeKey);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return edges;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private categorizeFiles(
|
|
144
|
+
node: FileNode,
|
|
145
|
+
apiFiles: string[],
|
|
146
|
+
serviceFiles: string[],
|
|
147
|
+
dataFiles: string[],
|
|
148
|
+
uiFiles: string[],
|
|
149
|
+
infraFiles: string[]
|
|
150
|
+
): void {
|
|
151
|
+
if (node.type === 'file') {
|
|
152
|
+
const path = node.path.toLowerCase();
|
|
153
|
+
const name = node.name.toLowerCase();
|
|
154
|
+
|
|
155
|
+
// Data layer — check first (more specific patterns)
|
|
156
|
+
if (
|
|
157
|
+
path.includes('/entities/') ||
|
|
158
|
+
path.includes('/entity/') ||
|
|
159
|
+
path.includes('/migrations/') ||
|
|
160
|
+
path.includes('/migration/') ||
|
|
161
|
+
path.includes('/seeds/') ||
|
|
162
|
+
path.includes('/seeders/') ||
|
|
163
|
+
path.includes('/data/') ||
|
|
164
|
+
path.includes('/db/') ||
|
|
165
|
+
path.includes('/database/') ||
|
|
166
|
+
path.includes('/models/') ||
|
|
167
|
+
path.includes('/schema/') ||
|
|
168
|
+
path.includes('/subscribers/') ||
|
|
169
|
+
name.endsWith('.entity.ts') ||
|
|
170
|
+
name.endsWith('.entity.js') ||
|
|
171
|
+
name.endsWith('.model.ts') ||
|
|
172
|
+
name.endsWith('.model.js') ||
|
|
173
|
+
name.includes('repository') ||
|
|
174
|
+
name.includes('dao') ||
|
|
175
|
+
name.includes('mapper') ||
|
|
176
|
+
name.includes('migration') ||
|
|
177
|
+
name.includes('seed') ||
|
|
178
|
+
name.includes('subscriber')
|
|
179
|
+
) {
|
|
180
|
+
dataFiles.push(node.path);
|
|
181
|
+
}
|
|
182
|
+
// Infrastructure layer
|
|
183
|
+
else if (
|
|
184
|
+
path.includes('/config/') ||
|
|
185
|
+
path.includes('/infra/') ||
|
|
186
|
+
path.includes('/infrastructure/') ||
|
|
187
|
+
path.includes('/setup/') ||
|
|
188
|
+
path.includes('/guards/') ||
|
|
189
|
+
path.includes('/pipes/') ||
|
|
190
|
+
path.includes('/interceptors/') ||
|
|
191
|
+
path.includes('/filters/') ||
|
|
192
|
+
path.includes('/decorators/') ||
|
|
193
|
+
path.includes('/middleware/') ||
|
|
194
|
+
path.includes('/middlewares/') ||
|
|
195
|
+
path.includes('/common/') ||
|
|
196
|
+
path.includes('/shared/') ||
|
|
197
|
+
path.includes('docker') ||
|
|
198
|
+
path.includes('kubernetes') ||
|
|
199
|
+
name.endsWith('.guard.ts') ||
|
|
200
|
+
name.endsWith('.pipe.ts') ||
|
|
201
|
+
name.endsWith('.interceptor.ts') ||
|
|
202
|
+
name.endsWith('.filter.ts') ||
|
|
203
|
+
name.endsWith('.decorator.ts') ||
|
|
204
|
+
name.endsWith('.middleware.ts') ||
|
|
205
|
+
name.includes('.config.') ||
|
|
206
|
+
name.includes('.module.')
|
|
207
|
+
) {
|
|
208
|
+
infraFiles.push(node.path);
|
|
209
|
+
}
|
|
210
|
+
// API layer
|
|
211
|
+
else if (
|
|
212
|
+
path.includes('/api/') ||
|
|
213
|
+
path.includes('/routes/') ||
|
|
214
|
+
path.includes('/controllers/') ||
|
|
215
|
+
name.endsWith('.controller.ts') ||
|
|
216
|
+
name.endsWith('.controller.js') ||
|
|
217
|
+
name.includes('route') ||
|
|
218
|
+
name.includes('controller') ||
|
|
219
|
+
name.includes('handler') ||
|
|
220
|
+
name.endsWith('.dto.ts') ||
|
|
221
|
+
name.endsWith('.dto.js')
|
|
222
|
+
) {
|
|
223
|
+
apiFiles.push(node.path);
|
|
224
|
+
}
|
|
225
|
+
// Service layer
|
|
226
|
+
else if (
|
|
227
|
+
path.includes('/service') ||
|
|
228
|
+
path.includes('/business') ||
|
|
229
|
+
path.includes('/logic') ||
|
|
230
|
+
path.includes('/use-cases/') ||
|
|
231
|
+
path.includes('/usecases/') ||
|
|
232
|
+
name.endsWith('.service.ts') ||
|
|
233
|
+
name.endsWith('.service.js') ||
|
|
234
|
+
name.includes('service') ||
|
|
235
|
+
name.includes('manager') ||
|
|
236
|
+
name.includes('facade') ||
|
|
237
|
+
name.includes('usecase')
|
|
238
|
+
) {
|
|
239
|
+
serviceFiles.push(node.path);
|
|
240
|
+
}
|
|
241
|
+
// UI layer
|
|
242
|
+
else if (
|
|
243
|
+
path.includes('/ui/') ||
|
|
244
|
+
path.includes('/components/') ||
|
|
245
|
+
path.includes('/pages/') ||
|
|
246
|
+
path.includes('/views/') ||
|
|
247
|
+
path.includes('/screens/') ||
|
|
248
|
+
path.includes('/templates/') ||
|
|
249
|
+
node.extension === '.tsx' ||
|
|
250
|
+
node.extension === '.jsx' ||
|
|
251
|
+
node.extension === '.vue' ||
|
|
252
|
+
node.extension === '.html'
|
|
253
|
+
) {
|
|
254
|
+
uiFiles.push(node.path);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (node.children) {
|
|
259
|
+
for (const child of node.children) {
|
|
260
|
+
this.categorizeFiles(
|
|
261
|
+
child,
|
|
262
|
+
apiFiles,
|
|
263
|
+
serviceFiles,
|
|
264
|
+
dataFiles,
|
|
265
|
+
uiFiles,
|
|
266
|
+
infraFiles
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
getModuleBoundaries(fileTree: FileNode): Map<string, string[]> {
|
|
273
|
+
const modules = new Map<string, string[]>();
|
|
274
|
+
this.identifyModules(fileTree, '', modules);
|
|
275
|
+
return modules;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private identifyModules(
|
|
279
|
+
node: FileNode,
|
|
280
|
+
parentPath: string,
|
|
281
|
+
modules: Map<string, string[]>
|
|
282
|
+
): void {
|
|
283
|
+
if (node.type === 'directory') {
|
|
284
|
+
const moduleFiles: string[] = [];
|
|
285
|
+
this.collectFilesInModule(node, moduleFiles);
|
|
286
|
+
|
|
287
|
+
if (moduleFiles.length > 0) {
|
|
288
|
+
modules.set(node.name, moduleFiles);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (node.children) {
|
|
293
|
+
for (const child of node.children) {
|
|
294
|
+
this.identifyModules(child, parentPath + '/' + node.name, modules);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private collectFilesInModule(node: FileNode, files: string[]): void {
|
|
300
|
+
if (node.type === 'file') {
|
|
301
|
+
files.push(node.path);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (node.children) {
|
|
305
|
+
for (const child of node.children) {
|
|
306
|
+
this.collectFilesInModule(child, files);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { AntiPattern, FileNode, ArchitectConfig } from './types.js';
|
|
3
|
+
|
|
4
|
+
export class AntiPatternDetector {
|
|
5
|
+
private config: ArchitectConfig;
|
|
6
|
+
private dependencyGraph: Map<string, Set<string>>;
|
|
7
|
+
|
|
8
|
+
constructor(config: ArchitectConfig) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
this.dependencyGraph = new Map();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
detect(
|
|
14
|
+
fileTree: FileNode,
|
|
15
|
+
dependencies: Map<string, Set<string>>
|
|
16
|
+
): AntiPattern[] {
|
|
17
|
+
this.dependencyGraph = dependencies;
|
|
18
|
+
const patterns: AntiPattern[] = [];
|
|
19
|
+
|
|
20
|
+
patterns.push(...this.detectGodClasses(fileTree));
|
|
21
|
+
patterns.push(...this.detectCircularDependencies());
|
|
22
|
+
patterns.push(...this.detectLeakyAbstractions(fileTree));
|
|
23
|
+
patterns.push(...this.detectFeatureEnvy(fileTree, dependencies));
|
|
24
|
+
patterns.push(...this.detectShotgunSurgery(dependencies));
|
|
25
|
+
|
|
26
|
+
return patterns.sort((a, b) => {
|
|
27
|
+
const severityOrder: Record<string, number> = {
|
|
28
|
+
CRITICAL: 0,
|
|
29
|
+
HIGH: 1,
|
|
30
|
+
MEDIUM: 2,
|
|
31
|
+
LOW: 3,
|
|
32
|
+
};
|
|
33
|
+
return severityOrder[a.severity] - severityOrder[b.severity];
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private detectGodClasses(node: FileNode): AntiPattern[] {
|
|
38
|
+
const patterns: AntiPattern[] = [];
|
|
39
|
+
const threshold =
|
|
40
|
+
this.config.antiPatterns?.godClass?.linesThreshold || 500;
|
|
41
|
+
const methodThreshold =
|
|
42
|
+
this.config.antiPatterns?.godClass?.methodsThreshold || 10;
|
|
43
|
+
|
|
44
|
+
this.walkFileTree(node, (file) => {
|
|
45
|
+
if (file.type === 'file' && (file.lines || 0) > threshold) {
|
|
46
|
+
const methods = this.countMethods(file.path);
|
|
47
|
+
if (methods > methodThreshold) {
|
|
48
|
+
patterns.push({
|
|
49
|
+
name: 'God Class',
|
|
50
|
+
severity: 'CRITICAL',
|
|
51
|
+
location: file.path,
|
|
52
|
+
description: `Class with ${file.lines} lines and ${methods} methods violates single responsibility principle`,
|
|
53
|
+
suggestion:
|
|
54
|
+
'Consider splitting into smaller, focused classes with specific responsibilities',
|
|
55
|
+
metrics: {
|
|
56
|
+
lines: file.lines || 0,
|
|
57
|
+
methods,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return patterns;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private detectCircularDependencies(): AntiPattern[] {
|
|
68
|
+
const patterns: AntiPattern[] = [];
|
|
69
|
+
const visited = new Set<string>();
|
|
70
|
+
const recursionStack = new Set<string>();
|
|
71
|
+
|
|
72
|
+
for (const file of this.dependencyGraph.keys()) {
|
|
73
|
+
if (!visited.has(file)) {
|
|
74
|
+
const cycle = this.findCycle(file, visited, recursionStack);
|
|
75
|
+
if (cycle) {
|
|
76
|
+
patterns.push({
|
|
77
|
+
name: 'Circular Dependency',
|
|
78
|
+
severity: 'HIGH',
|
|
79
|
+
location: cycle.join(' -> '),
|
|
80
|
+
description: `Circular dependency detected: ${cycle.join(' -> ')}`,
|
|
81
|
+
suggestion:
|
|
82
|
+
'Refactor code to break the circular dependency using dependency injection or intermediate abstractions',
|
|
83
|
+
affectedFiles: cycle,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return patterns;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private findCycle(
|
|
93
|
+
node: string,
|
|
94
|
+
visited: Set<string>,
|
|
95
|
+
recursionStack: Set<string>
|
|
96
|
+
): string[] | null {
|
|
97
|
+
visited.add(node);
|
|
98
|
+
recursionStack.add(node);
|
|
99
|
+
|
|
100
|
+
const neighbors = this.dependencyGraph.get(node) || new Set();
|
|
101
|
+
for (const neighbor of neighbors) {
|
|
102
|
+
if (!visited.has(neighbor)) {
|
|
103
|
+
const cycle = this.findCycle(neighbor, visited, recursionStack);
|
|
104
|
+
if (cycle) {
|
|
105
|
+
cycle.unshift(node);
|
|
106
|
+
return cycle;
|
|
107
|
+
}
|
|
108
|
+
} else if (recursionStack.has(neighbor)) {
|
|
109
|
+
return [node, neighbor];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
recursionStack.delete(node);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private detectLeakyAbstractions(node: FileNode): AntiPattern[] {
|
|
118
|
+
const patterns: AntiPattern[] = [];
|
|
119
|
+
|
|
120
|
+
this.walkFileTree(node, (file) => {
|
|
121
|
+
if (file.type === 'file') {
|
|
122
|
+
const internalExports = this.countInternalExports(file.path);
|
|
123
|
+
if (internalExports > 5) {
|
|
124
|
+
patterns.push({
|
|
125
|
+
name: 'Leaky Abstraction',
|
|
126
|
+
severity: 'MEDIUM',
|
|
127
|
+
location: file.path,
|
|
128
|
+
description: `Exports ${internalExports} internal types that should be private`,
|
|
129
|
+
suggestion:
|
|
130
|
+
'Use private/internal access modifiers and facade patterns to hide implementation details',
|
|
131
|
+
metrics: {
|
|
132
|
+
exportedInternalTypes: internalExports,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return patterns;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private detectFeatureEnvy(
|
|
143
|
+
node: FileNode,
|
|
144
|
+
dependencies: Map<string, Set<string>>
|
|
145
|
+
): AntiPattern[] {
|
|
146
|
+
const patterns: AntiPattern[] = [];
|
|
147
|
+
|
|
148
|
+
this.walkFileTree(node, (file) => {
|
|
149
|
+
if (file.type === 'file') {
|
|
150
|
+
const externalMethodCalls = (dependencies.get(file.path) || new Set())
|
|
151
|
+
.size;
|
|
152
|
+
const internalMethods = this.countMethods(file.path);
|
|
153
|
+
const name = file.name.toLowerCase();
|
|
154
|
+
|
|
155
|
+
// Skip NestJS infrastructure files where external deps are by design
|
|
156
|
+
const isInfraFile =
|
|
157
|
+
name.endsWith('.module.ts') ||
|
|
158
|
+
name.endsWith('.dto.ts') ||
|
|
159
|
+
name.endsWith('.entity.ts') ||
|
|
160
|
+
name.endsWith('.guard.ts') ||
|
|
161
|
+
name.endsWith('.pipe.ts') ||
|
|
162
|
+
name.endsWith('.interceptor.ts') ||
|
|
163
|
+
name.endsWith('.filter.ts') ||
|
|
164
|
+
name.endsWith('.decorator.ts') ||
|
|
165
|
+
name.endsWith('.spec.ts') ||
|
|
166
|
+
name.endsWith('.test.ts');
|
|
167
|
+
|
|
168
|
+
if (!isInfraFile && internalMethods > 0 && externalMethodCalls > internalMethods * 3) {
|
|
169
|
+
patterns.push({
|
|
170
|
+
name: 'Feature Envy',
|
|
171
|
+
severity: 'MEDIUM',
|
|
172
|
+
location: file.path,
|
|
173
|
+
description: `Uses more external methods (${externalMethodCalls}) than internal methods (${internalMethods})`,
|
|
174
|
+
suggestion:
|
|
175
|
+
'Move functionality closer to where it is used or extract to shared utility',
|
|
176
|
+
metrics: {
|
|
177
|
+
externalMethodCalls,
|
|
178
|
+
internalMethods,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return patterns;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private detectShotgunSurgery(
|
|
189
|
+
dependencies: Map<string, Set<string>>
|
|
190
|
+
): AntiPattern[] {
|
|
191
|
+
const patterns: AntiPattern[] = [];
|
|
192
|
+
const threshold =
|
|
193
|
+
this.config.antiPatterns?.shotgunSurgery
|
|
194
|
+
?.changePropagationThreshold || 8;
|
|
195
|
+
|
|
196
|
+
for (const [file, dependents] of dependencies) {
|
|
197
|
+
if (dependents.size >= threshold) {
|
|
198
|
+
patterns.push({
|
|
199
|
+
name: 'Shotgun Surgery',
|
|
200
|
+
severity: 'HIGH',
|
|
201
|
+
location: file,
|
|
202
|
+
description: `Changes to this file likely require modifications in ${dependents.size} other files`,
|
|
203
|
+
suggestion:
|
|
204
|
+
'Refactor to reduce coupling and consolidate related functionality into modules',
|
|
205
|
+
affectedFiles: Array.from(dependents),
|
|
206
|
+
metrics: {
|
|
207
|
+
dependentFileCount: dependents.size,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return patterns;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private countMethods(filePath: string): number {
|
|
217
|
+
try {
|
|
218
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
219
|
+
const methodRegex = /(?:async\s+)?(?:function|public|private|protected|static)\s+\w+\s*\(/g;
|
|
220
|
+
const arrowMethodRegex = /(?:readonly\s+)?\w+\s*=\s*(?:async\s+)?\(/g;
|
|
221
|
+
const matches = content.match(methodRegex);
|
|
222
|
+
const arrowMatches = content.match(arrowMethodRegex);
|
|
223
|
+
return (matches ? matches.length : 0) + (arrowMatches ? arrowMatches.length : 0);
|
|
224
|
+
} catch {
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private countInternalExports(filePath: string): number {
|
|
230
|
+
try {
|
|
231
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
232
|
+
const internalTypes = [
|
|
233
|
+
'_',
|
|
234
|
+
'Internal',
|
|
235
|
+
'Private',
|
|
236
|
+
'Impl',
|
|
237
|
+
'Detail',
|
|
238
|
+
];
|
|
239
|
+
let count = 0;
|
|
240
|
+
|
|
241
|
+
for (const type of internalTypes) {
|
|
242
|
+
const regex = new RegExp(`export\\s+\\w*${type}\\w*`, 'g');
|
|
243
|
+
const matches = content.match(regex);
|
|
244
|
+
count += matches ? matches.length : 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return count;
|
|
248
|
+
} catch {
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private walkFileTree(
|
|
254
|
+
node: FileNode,
|
|
255
|
+
callback: (node: FileNode) => void
|
|
256
|
+
): void {
|
|
257
|
+
callback(node);
|
|
258
|
+
if (node.children) {
|
|
259
|
+
for (const child of node.children) {
|
|
260
|
+
this.walkFileTree(child, callback);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|