@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/reporter.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { AnalysisReport } from './types.js';
|
|
2
|
+
|
|
3
|
+
export class ReportGenerator {
|
|
4
|
+
generateMarkdownReport(report: AnalysisReport): string {
|
|
5
|
+
let markdown = '';
|
|
6
|
+
|
|
7
|
+
markdown += this.generateHeader(report);
|
|
8
|
+
markdown += this.generateProjectSummary(report);
|
|
9
|
+
markdown += this.generateScoreSection(report);
|
|
10
|
+
markdown += this.generateAntiPatternsSection(report);
|
|
11
|
+
markdown += this.generateLayersSection(report);
|
|
12
|
+
markdown += this.generateDiagramSection(report);
|
|
13
|
+
markdown += this.generateSuggestionsSection(report);
|
|
14
|
+
|
|
15
|
+
return markdown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private generateHeader(report: AnalysisReport): string {
|
|
19
|
+
const timestamp = new Date(report.timestamp).toLocaleString();
|
|
20
|
+
let header = '# Architecture Analysis Report\n\n';
|
|
21
|
+
header += `Generated: ${timestamp}\n`;
|
|
22
|
+
header += `Project: ${report.projectInfo.name}\n`;
|
|
23
|
+
header += `Path: ${report.projectInfo.path}\n\n`;
|
|
24
|
+
return header;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private generateProjectSummary(report: AnalysisReport): string {
|
|
28
|
+
let summary = '## Project Summary\n\n';
|
|
29
|
+
summary += `| Metric | Value |\n`;
|
|
30
|
+
summary += `|--------|-------|\n`;
|
|
31
|
+
summary += `| Total Files | ${report.projectInfo.totalFiles} |\n`;
|
|
32
|
+
summary += `| Lines of Code | ${report.projectInfo.totalLines.toLocaleString()} |\n`;
|
|
33
|
+
summary += `| Primary Languages | ${report.projectInfo.primaryLanguages.join(', ') || 'N/A'} |\n`;
|
|
34
|
+
summary += `| Frameworks | ${report.projectInfo.frameworks.join(', ') || 'None detected'} |\n\n`;
|
|
35
|
+
return summary;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private generateScoreSection(report: AnalysisReport): string {
|
|
39
|
+
let section = '## Architecture Quality Score\n\n';
|
|
40
|
+
section += `### Overall Score: **${report.score.overall}/100**\n\n`;
|
|
41
|
+
|
|
42
|
+
section += 'Component Breakdown:\n\n';
|
|
43
|
+
for (const component of report.score.components) {
|
|
44
|
+
section += `- **${component.name}**: ${component.score}/100 (weight: ${(component.weight * 100).toFixed(0)}%)\n`;
|
|
45
|
+
section += ` ${component.explanation}\n\n`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return section;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private generateAntiPatternsSection(report: AnalysisReport): string {
|
|
52
|
+
let section = '## Anti-Patterns Detected\n\n';
|
|
53
|
+
|
|
54
|
+
if (report.antiPatterns.length === 0) {
|
|
55
|
+
section += 'No significant anti-patterns detected. Excellent architecture!\n\n';
|
|
56
|
+
return section;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
section += `Found **${report.antiPatterns.length}** anti-pattern(s):\n\n`;
|
|
60
|
+
|
|
61
|
+
for (const pattern of report.antiPatterns) {
|
|
62
|
+
const severityEmoji: Record<string, string> = {
|
|
63
|
+
CRITICAL: 'X',
|
|
64
|
+
HIGH: 'W',
|
|
65
|
+
MEDIUM: 'o',
|
|
66
|
+
LOW: '-',
|
|
67
|
+
};
|
|
68
|
+
const emoji = severityEmoji[pattern.severity] || 'o';
|
|
69
|
+
|
|
70
|
+
section += `### ${emoji} ${pattern.name} [${pattern.severity}]\n\n`;
|
|
71
|
+
section += `**Location**: \`${pattern.location}\`\n\n`;
|
|
72
|
+
section += `**Description**: ${pattern.description}\n\n`;
|
|
73
|
+
section += `**Suggestion**: ${pattern.suggestion}\n\n`;
|
|
74
|
+
|
|
75
|
+
if (pattern.metrics && Object.keys(pattern.metrics).length > 0) {
|
|
76
|
+
section += '**Metrics**:\n';
|
|
77
|
+
for (const [key, value] of Object.entries(pattern.metrics)) {
|
|
78
|
+
section += `- ${key}: ${value}\n`;
|
|
79
|
+
}
|
|
80
|
+
section += '\n';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return section;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private generateLayersSection(report: AnalysisReport): string {
|
|
88
|
+
let section = '## Architectural Layers\n\n';
|
|
89
|
+
|
|
90
|
+
if (report.layers.length === 0) {
|
|
91
|
+
section += 'No layers detected.\n\n';
|
|
92
|
+
return section;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const layer of report.layers) {
|
|
96
|
+
section += `### ${layer.name} Layer\n\n`;
|
|
97
|
+
section += `${layer.description}\n\n`;
|
|
98
|
+
section += `**Files**: ${layer.files.length}\n`;
|
|
99
|
+
section += '```\n';
|
|
100
|
+
for (const file of layer.files.slice(0, 5)) {
|
|
101
|
+
section += `${file}\n`;
|
|
102
|
+
}
|
|
103
|
+
if (layer.files.length > 5) {
|
|
104
|
+
section += `... and ${layer.files.length - 5} more files\n`;
|
|
105
|
+
}
|
|
106
|
+
section += '```\n\n';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return section;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private generateDiagramSection(report: AnalysisReport): string {
|
|
113
|
+
let section = '## Architecture Diagram\n\n';
|
|
114
|
+
section += `Type: ${report.diagram.type}\n\n`;
|
|
115
|
+
section += '```mermaid\n';
|
|
116
|
+
section += report.diagram.mermaid;
|
|
117
|
+
section += '\n```\n\n';
|
|
118
|
+
return section;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private generateSuggestionsSection(report: AnalysisReport): string {
|
|
122
|
+
let section = '## Refactoring Suggestions\n\n';
|
|
123
|
+
|
|
124
|
+
if (report.suggestions.length === 0) {
|
|
125
|
+
section += 'No immediate refactoring suggestions.\n\n';
|
|
126
|
+
return section;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const bySeverity = this.groupBySeverity(report.suggestions);
|
|
130
|
+
|
|
131
|
+
for (const severity of ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']) {
|
|
132
|
+
const suggestions = bySeverity[severity];
|
|
133
|
+
if (!suggestions || suggestions.length === 0) continue;
|
|
134
|
+
|
|
135
|
+
section += `### ${severity} Priority\n\n`;
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < suggestions.length; i++) {
|
|
138
|
+
const suggestion = suggestions[i];
|
|
139
|
+
section += `${i + 1}. **${suggestion.title}**\n`;
|
|
140
|
+
section += ` ${suggestion.description}\n`;
|
|
141
|
+
section += ` Impact: ${suggestion.impact}\n\n`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return section;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private groupBySeverity(
|
|
149
|
+
suggestions: Array<{ priority: string; title: string; description: string; impact: string }>
|
|
150
|
+
): Record<string, typeof suggestions> {
|
|
151
|
+
const grouped: Record<string, typeof suggestions> = {
|
|
152
|
+
CRITICAL: [],
|
|
153
|
+
HIGH: [],
|
|
154
|
+
MEDIUM: [],
|
|
155
|
+
LOW: [],
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
for (const suggestion of suggestions) {
|
|
159
|
+
if (grouped[suggestion.priority]) {
|
|
160
|
+
grouped[suggestion.priority].push(suggestion);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return grouped;
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { globSync } from 'glob';
|
|
2
|
+
import { readFileSync, lstatSync } from 'fs';
|
|
3
|
+
import { join, relative, extname } from 'path';
|
|
4
|
+
import { FileNode, ProjectInfo, ArchitectConfig } from './types.js';
|
|
5
|
+
|
|
6
|
+
export class ProjectScanner {
|
|
7
|
+
private projectPath: string;
|
|
8
|
+
private config: ArchitectConfig;
|
|
9
|
+
private frameworks: Set<string> = new Set();
|
|
10
|
+
|
|
11
|
+
constructor(projectPath: string, config: ArchitectConfig) {
|
|
12
|
+
this.projectPath = projectPath;
|
|
13
|
+
this.config = config;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
scan(): ProjectInfo {
|
|
17
|
+
const files = this.scanDirectory();
|
|
18
|
+
const fileTree = this.buildFileTree(files);
|
|
19
|
+
|
|
20
|
+
// Detect frameworks from scanned files AND parent package.json
|
|
21
|
+
const parentPackageJsons = this.findParentPackageJsons();
|
|
22
|
+
const allFilesForDetection = [...files, ...parentPackageJsons];
|
|
23
|
+
const frameworks = this.detectFrameworks(allFilesForDetection);
|
|
24
|
+
|
|
25
|
+
const languages = this.detectLanguages(files);
|
|
26
|
+
const totalLines = this.countTotalLines(files);
|
|
27
|
+
const projectName = this.resolveProjectName(parentPackageJsons);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
path: this.projectPath,
|
|
31
|
+
name: projectName,
|
|
32
|
+
frameworks: Array.from(frameworks),
|
|
33
|
+
totalFiles: files.length,
|
|
34
|
+
totalLines,
|
|
35
|
+
primaryLanguages: languages,
|
|
36
|
+
fileTree,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Walk up directory tree to find package.json files for project name and framework detection
|
|
42
|
+
*/
|
|
43
|
+
private findParentPackageJsons(): string[] {
|
|
44
|
+
const found: string[] = [];
|
|
45
|
+
let dir = this.projectPath;
|
|
46
|
+
const root = '/';
|
|
47
|
+
let depth = 0;
|
|
48
|
+
|
|
49
|
+
while (dir !== root && depth < 5) {
|
|
50
|
+
const pkgPath = join(dir, 'package.json');
|
|
51
|
+
try {
|
|
52
|
+
readFileSync(pkgPath, 'utf-8');
|
|
53
|
+
found.push(pkgPath);
|
|
54
|
+
} catch {
|
|
55
|
+
// no package.json here
|
|
56
|
+
}
|
|
57
|
+
dir = join(dir, '..');
|
|
58
|
+
depth++;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve project name from nearest package.json or directory name
|
|
66
|
+
*/
|
|
67
|
+
private resolveProjectName(packageJsonPaths: string[]): string {
|
|
68
|
+
for (const pkgPath of packageJsonPaths) {
|
|
69
|
+
try {
|
|
70
|
+
const content = readFileSync(pkgPath, 'utf-8');
|
|
71
|
+
const parsed = JSON.parse(content);
|
|
72
|
+
if (parsed.name) {
|
|
73
|
+
return parsed.name;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// skip
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return this.projectPath.split('/').pop() || 'project';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private scanDirectory(): string[] {
|
|
83
|
+
const ignorePatterns = this.config.ignore || [];
|
|
84
|
+
const negatedPatterns = ignorePatterns.map((p) => `!**/${p}/**`);
|
|
85
|
+
|
|
86
|
+
const files = globSync('**/*', {
|
|
87
|
+
cwd: this.projectPath,
|
|
88
|
+
ignore: ignorePatterns,
|
|
89
|
+
absolute: true,
|
|
90
|
+
nodir: true,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return files.filter(
|
|
94
|
+
(f) =>
|
|
95
|
+
!lstatSync(f).isDirectory() && this.isSourceFile(f)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private isSourceFile(filePath: string): boolean {
|
|
100
|
+
const ext = extname(filePath).toLowerCase();
|
|
101
|
+
const sourceExtensions = [
|
|
102
|
+
'.js',
|
|
103
|
+
'.ts',
|
|
104
|
+
'.tsx',
|
|
105
|
+
'.jsx',
|
|
106
|
+
'.py',
|
|
107
|
+
'.java',
|
|
108
|
+
'.go',
|
|
109
|
+
'.rb',
|
|
110
|
+
'.php',
|
|
111
|
+
'.cs',
|
|
112
|
+
'.cpp',
|
|
113
|
+
'.c',
|
|
114
|
+
'.h',
|
|
115
|
+
'.rs',
|
|
116
|
+
'.kt',
|
|
117
|
+
'.scala',
|
|
118
|
+
'.groovy',
|
|
119
|
+
'.sql',
|
|
120
|
+
'.graphql',
|
|
121
|
+
'.json',
|
|
122
|
+
'.yaml',
|
|
123
|
+
'.yml',
|
|
124
|
+
'.xml',
|
|
125
|
+
];
|
|
126
|
+
return sourceExtensions.includes(ext);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private buildFileTree(files: string[]): FileNode {
|
|
130
|
+
const root: FileNode = {
|
|
131
|
+
path: this.projectPath,
|
|
132
|
+
name: this.projectPath.split('/').pop() || 'root',
|
|
133
|
+
type: 'directory',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
const relativePath = relative(this.projectPath, file);
|
|
138
|
+
const parts = relativePath.split('/');
|
|
139
|
+
let current = root;
|
|
140
|
+
|
|
141
|
+
for (let i = 0; i < parts.length; i++) {
|
|
142
|
+
const part = parts[i];
|
|
143
|
+
const isFile = i === parts.length - 1;
|
|
144
|
+
|
|
145
|
+
let child = (current.children || []).find((c) => c.name === part);
|
|
146
|
+
if (!child) {
|
|
147
|
+
child = {
|
|
148
|
+
path: join(current.path, part),
|
|
149
|
+
name: part,
|
|
150
|
+
type: isFile ? 'file' : 'directory',
|
|
151
|
+
extension: isFile ? extname(part) : undefined,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (isFile) {
|
|
155
|
+
child.lines = this.countLines(file);
|
|
156
|
+
child.language = this.detectLanguage(file);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!current.children) current.children = [];
|
|
160
|
+
current.children.push(child);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
current = child;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return root;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private detectFrameworks(files: string[]): Set<string> {
|
|
171
|
+
const frameworks = new Set<string>();
|
|
172
|
+
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
if (file.endsWith('package.json')) {
|
|
175
|
+
try {
|
|
176
|
+
const content = readFileSync(file, 'utf-8');
|
|
177
|
+
const parsed = JSON.parse(content);
|
|
178
|
+
const allDeps = {
|
|
179
|
+
...parsed.dependencies,
|
|
180
|
+
...parsed.devDependencies,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Detect from actual dependency keys
|
|
184
|
+
if (allDeps['@nestjs/core'] || allDeps['@nestjs/common']) frameworks.add('NestJS');
|
|
185
|
+
if (allDeps['react'] || allDeps['react-dom']) frameworks.add('React');
|
|
186
|
+
if (allDeps['@angular/core']) frameworks.add('Angular');
|
|
187
|
+
if (allDeps['vue'] || allDeps['@vue/core']) frameworks.add('Vue.js');
|
|
188
|
+
if (allDeps['express']) frameworks.add('Express.js');
|
|
189
|
+
if (allDeps['next']) frameworks.add('Next.js');
|
|
190
|
+
if (allDeps['fastify']) frameworks.add('Fastify');
|
|
191
|
+
if (allDeps['typeorm']) frameworks.add('TypeORM');
|
|
192
|
+
if (allDeps['prisma'] || allDeps['@prisma/client']) frameworks.add('Prisma');
|
|
193
|
+
if (allDeps['sequelize']) frameworks.add('Sequelize');
|
|
194
|
+
if (allDeps['mongoose']) frameworks.add('Mongoose');
|
|
195
|
+
} catch {
|
|
196
|
+
// Fallback: simple string matching
|
|
197
|
+
try {
|
|
198
|
+
const content = readFileSync(file, 'utf-8');
|
|
199
|
+
if (content.includes('@nestjs')) frameworks.add('NestJS');
|
|
200
|
+
if (content.includes('react')) frameworks.add('React');
|
|
201
|
+
if (content.includes('angular')) frameworks.add('Angular');
|
|
202
|
+
if (content.includes('vue')) frameworks.add('Vue.js');
|
|
203
|
+
if (content.includes('express')) frameworks.add('Express.js');
|
|
204
|
+
if (content.includes('next')) frameworks.add('Next.js');
|
|
205
|
+
} catch {
|
|
206
|
+
// skip
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (file.includes('pom.xml')) {
|
|
212
|
+
try {
|
|
213
|
+
const content = readFileSync(file, 'utf-8');
|
|
214
|
+
if (content.includes('spring-boot')) frameworks.add('Spring Boot');
|
|
215
|
+
if (content.includes('spring')) frameworks.add('Spring');
|
|
216
|
+
} catch {
|
|
217
|
+
// skip
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (file.includes('requirements.txt')) {
|
|
222
|
+
try {
|
|
223
|
+
const content = readFileSync(file, 'utf-8');
|
|
224
|
+
if (content.includes('django')) frameworks.add('Django');
|
|
225
|
+
if (content.includes('flask')) frameworks.add('Flask');
|
|
226
|
+
if (content.includes('fastapi')) frameworks.add('FastAPI');
|
|
227
|
+
} catch {
|
|
228
|
+
// skip
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (file.includes('Gemfile')) {
|
|
233
|
+
try {
|
|
234
|
+
const content = readFileSync(file, 'utf-8');
|
|
235
|
+
if (content.includes('rails')) frameworks.add('Ruby on Rails');
|
|
236
|
+
} catch {
|
|
237
|
+
// skip
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (file.includes('go.mod')) {
|
|
242
|
+
frameworks.add('Go');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return frameworks;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private detectLanguages(files: string[]): string[] {
|
|
250
|
+
const languages = new Set<string>();
|
|
251
|
+
|
|
252
|
+
for (const file of files) {
|
|
253
|
+
const lang = this.detectLanguage(file);
|
|
254
|
+
if (lang && lang !== 'Unknown') languages.add(lang);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return Array.from(languages);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private detectLanguage(filePath: string): string {
|
|
261
|
+
const ext = extname(filePath).toLowerCase();
|
|
262
|
+
|
|
263
|
+
const languageMap: Record<string, string> = {
|
|
264
|
+
'.js': 'JavaScript',
|
|
265
|
+
'.ts': 'TypeScript',
|
|
266
|
+
'.tsx': 'TypeScript',
|
|
267
|
+
'.jsx': 'JavaScript',
|
|
268
|
+
'.py': 'Python',
|
|
269
|
+
'.java': 'Java',
|
|
270
|
+
'.go': 'Go',
|
|
271
|
+
'.rb': 'Ruby',
|
|
272
|
+
'.php': 'PHP',
|
|
273
|
+
'.cs': 'C#',
|
|
274
|
+
'.cpp': 'C++',
|
|
275
|
+
'.c': 'C',
|
|
276
|
+
'.rs': 'Rust',
|
|
277
|
+
'.kt': 'Kotlin',
|
|
278
|
+
'.scala': 'Scala',
|
|
279
|
+
'.sql': 'SQL',
|
|
280
|
+
'.graphql': 'GraphQL',
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
return languageMap[ext] || 'Unknown';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private countLines(filePath: string): number {
|
|
287
|
+
try {
|
|
288
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
289
|
+
return content.split('\n').length;
|
|
290
|
+
} catch {
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private countTotalLines(files: string[]): number {
|
|
296
|
+
return files.reduce((total, file) => total + this.countLines(file), 0);
|
|
297
|
+
}
|
|
298
|
+
}
|
package/src/scorer.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { ArchitectureScore, DependencyEdge, AntiPattern, ScoreComponent } from './types.js';
|
|
2
|
+
|
|
3
|
+
export class ArchitectureScorer {
|
|
4
|
+
private modularity: number = 0;
|
|
5
|
+
private coupling: number = 0;
|
|
6
|
+
private cohesion: number = 0;
|
|
7
|
+
private layering: number = 0;
|
|
8
|
+
|
|
9
|
+
score(
|
|
10
|
+
edges: DependencyEdge[],
|
|
11
|
+
antiPatterns: AntiPattern[],
|
|
12
|
+
totalFiles: number
|
|
13
|
+
): ArchitectureScore {
|
|
14
|
+
this.calculateModularity(edges, totalFiles);
|
|
15
|
+
this.calculateCoupling(edges, totalFiles);
|
|
16
|
+
this.calculateCohesion(edges);
|
|
17
|
+
this.calculateLayering(antiPatterns);
|
|
18
|
+
|
|
19
|
+
const components = [
|
|
20
|
+
{
|
|
21
|
+
name: 'Modularity',
|
|
22
|
+
score: Math.round(this.modularity),
|
|
23
|
+
maxScore: 100,
|
|
24
|
+
weight: 0.4,
|
|
25
|
+
explanation:
|
|
26
|
+
'Measures appropriate module boundaries and single responsibility principle adherence',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'Coupling',
|
|
30
|
+
score: Math.round(this.coupling),
|
|
31
|
+
maxScore: 100,
|
|
32
|
+
weight: 0.25,
|
|
33
|
+
explanation:
|
|
34
|
+
'Evaluates interdependencies between modules; lower coupling is better',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'Cohesion',
|
|
38
|
+
score: Math.round(this.cohesion),
|
|
39
|
+
maxScore: 100,
|
|
40
|
+
weight: 0.2,
|
|
41
|
+
explanation:
|
|
42
|
+
'Assesses how closely related functionality is grouped together',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'Layering',
|
|
46
|
+
score: Math.round(this.layering),
|
|
47
|
+
maxScore: 100,
|
|
48
|
+
weight: 0.15,
|
|
49
|
+
explanation: 'Checks adherence to architectural layer separation',
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const overall = Math.round(
|
|
54
|
+
components[0].score * components[0].weight +
|
|
55
|
+
components[1].score * components[1].weight +
|
|
56
|
+
components[2].score * components[2].weight +
|
|
57
|
+
components[3].score * components[3].weight
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
overall: Math.min(100, Math.max(0, overall)),
|
|
62
|
+
components,
|
|
63
|
+
breakdown: {
|
|
64
|
+
modularity: Math.round(this.modularity),
|
|
65
|
+
coupling: Math.round(this.coupling),
|
|
66
|
+
cohesion: Math.round(this.cohesion),
|
|
67
|
+
layering: Math.round(this.layering),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private calculateModularity(edges: DependencyEdge[], totalFiles: number): void {
|
|
73
|
+
if (totalFiles === 0) {
|
|
74
|
+
this.modularity = 50;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const avgEdgesPerFile = edges.length / totalFiles;
|
|
79
|
+
|
|
80
|
+
if (avgEdgesPerFile < 2) {
|
|
81
|
+
this.modularity = 95;
|
|
82
|
+
} else if (avgEdgesPerFile < 4) {
|
|
83
|
+
this.modularity = 85;
|
|
84
|
+
} else if (avgEdgesPerFile < 6) {
|
|
85
|
+
this.modularity = 70;
|
|
86
|
+
} else if (avgEdgesPerFile < 10) {
|
|
87
|
+
this.modularity = 50;
|
|
88
|
+
} else {
|
|
89
|
+
this.modularity = 30;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private calculateCoupling(edges: DependencyEdge[], totalFiles: number): void {
|
|
94
|
+
if (totalFiles === 0) {
|
|
95
|
+
this.coupling = 50;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const nodeWithMaxEdges = this.findNodeWithMaxEdges(edges);
|
|
100
|
+
const maxEdgeCount = nodeWithMaxEdges ? nodeWithMaxEdges.count : 0;
|
|
101
|
+
|
|
102
|
+
const couplingRatio = maxEdgeCount / (totalFiles - 1);
|
|
103
|
+
|
|
104
|
+
if (couplingRatio < 0.2) {
|
|
105
|
+
this.coupling = 95;
|
|
106
|
+
} else if (couplingRatio < 0.4) {
|
|
107
|
+
this.coupling = 85;
|
|
108
|
+
} else if (couplingRatio < 0.6) {
|
|
109
|
+
this.coupling = 70;
|
|
110
|
+
} else if (couplingRatio < 0.8) {
|
|
111
|
+
this.coupling = 50;
|
|
112
|
+
} else {
|
|
113
|
+
this.coupling = 30;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private findNodeWithMaxEdges(
|
|
118
|
+
edges: DependencyEdge[]
|
|
119
|
+
): { node: string; count: number } | null {
|
|
120
|
+
const nodeEdgeCount: Record<string, number> = {};
|
|
121
|
+
|
|
122
|
+
for (const edge of edges) {
|
|
123
|
+
nodeEdgeCount[edge.from] = (nodeEdgeCount[edge.from] || 0) + 1;
|
|
124
|
+
nodeEdgeCount[edge.to] = (nodeEdgeCount[edge.to] || 0) + 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let maxNode: string | null = null;
|
|
128
|
+
let maxCount = 0;
|
|
129
|
+
|
|
130
|
+
for (const [node, count] of Object.entries(nodeEdgeCount)) {
|
|
131
|
+
if (count > maxCount) {
|
|
132
|
+
maxCount = count;
|
|
133
|
+
maxNode = node;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return maxNode ? { node: maxNode, count: maxCount } : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private calculateCohesion(edges: DependencyEdge[]): void {
|
|
141
|
+
if (edges.length === 0) {
|
|
142
|
+
this.cohesion = 50;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const internalEdges = edges.filter((e) =>
|
|
147
|
+
this.isInternalDependency(e.from, e.to)
|
|
148
|
+
).length;
|
|
149
|
+
|
|
150
|
+
const cohesionRatio = internalEdges / edges.length;
|
|
151
|
+
|
|
152
|
+
if (cohesionRatio > 0.7) {
|
|
153
|
+
this.cohesion = 95;
|
|
154
|
+
} else if (cohesionRatio > 0.5) {
|
|
155
|
+
this.cohesion = 80;
|
|
156
|
+
} else if (cohesionRatio > 0.3) {
|
|
157
|
+
this.cohesion = 65;
|
|
158
|
+
} else if (cohesionRatio > 0.1) {
|
|
159
|
+
this.cohesion = 45;
|
|
160
|
+
} else {
|
|
161
|
+
this.cohesion = 30;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private isInternalDependency(from: string, to: string): boolean {
|
|
166
|
+
const fromModule = from.split('/').slice(0, -1).join('/');
|
|
167
|
+
const toModule = to.split('/').slice(0, -1).join('/');
|
|
168
|
+
return fromModule === toModule;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private calculateLayering(antiPatterns: AntiPattern[]): void {
|
|
172
|
+
const layeringViolations = antiPatterns.filter(
|
|
173
|
+
(p) =>
|
|
174
|
+
p.name === 'Leaky Abstraction' ||
|
|
175
|
+
p.name === 'Shotgun Surgery' ||
|
|
176
|
+
p.name === 'Circular Dependency'
|
|
177
|
+
).length;
|
|
178
|
+
|
|
179
|
+
if (layeringViolations === 0) {
|
|
180
|
+
this.layering = 95;
|
|
181
|
+
} else if (layeringViolations === 1) {
|
|
182
|
+
this.layering = 85;
|
|
183
|
+
} else if (layeringViolations === 2) {
|
|
184
|
+
this.layering = 70;
|
|
185
|
+
} else if (layeringViolations === 3) {
|
|
186
|
+
this.layering = 55;
|
|
187
|
+
} else if (layeringViolations <= 5) {
|
|
188
|
+
this.layering = 40;
|
|
189
|
+
} else {
|
|
190
|
+
this.layering = 25;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|