@girardelli/architect 1.2.0 → 1.3.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/README.md +3 -3
- package/dist/html-reporter.d.ts +18 -2
- package/dist/html-reporter.d.ts.map +1 -1
- package/dist/html-reporter.js +366 -32
- package/dist/html-reporter.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +60 -7
- package/dist/index.js.map +1 -1
- package/dist/scorer.d.ts +12 -0
- package/dist/scorer.d.ts.map +1 -1
- package/dist/scorer.js +61 -17
- package/dist/scorer.js.map +1 -1
- package/package.json +1 -1
- package/src/html-reporter.ts +380 -33
- package/src/index.ts +69 -8
- package/src/scorer.ts +63 -17
package/src/html-reporter.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { AnalysisReport, AntiPattern } from './types.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Generates premium visual HTML reports from AnalysisReport.
|
|
5
|
+
* Features: D3.js force graph, bubble charts, radar chart, animated counters.
|
|
5
6
|
*/
|
|
6
7
|
export class HtmlReportGenerator {
|
|
7
8
|
generateHtml(report: AnalysisReport): string {
|
|
@@ -15,22 +16,22 @@ export class HtmlReportGenerator {
|
|
|
15
16
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
16
17
|
<title>Architect Report — ${this.escapeHtml(report.projectInfo.name)}</title>
|
|
17
18
|
${this.getStyles()}
|
|
18
|
-
<script src="https://cdn.jsdelivr.net/npm/
|
|
19
|
+
<script src="https://cdn.jsdelivr.net/npm/d3@7"><\/script>
|
|
19
20
|
</head>
|
|
20
21
|
<body>
|
|
21
22
|
${this.renderHeader(report)}
|
|
22
23
|
<div class="container">
|
|
23
24
|
${this.renderScoreHero(report)}
|
|
25
|
+
${this.renderRadarChart(report)}
|
|
24
26
|
${this.renderStats(report)}
|
|
25
27
|
${this.renderLayers(report)}
|
|
28
|
+
${this.renderDependencyGraph(report)}
|
|
29
|
+
${this.renderAntiPatternBubbles(report, grouped)}
|
|
26
30
|
${this.renderAntiPatterns(report, grouped)}
|
|
27
|
-
${this.renderDiagram(report)}
|
|
28
31
|
${this.renderSuggestions(sugGrouped)}
|
|
29
32
|
</div>
|
|
30
33
|
${this.renderFooter()}
|
|
31
|
-
|
|
32
|
-
mermaid.initialize({ theme: 'default', startOnLoad: true });
|
|
33
|
-
<\/script>
|
|
34
|
+
${this.getScripts(report)}
|
|
34
35
|
</body>
|
|
35
36
|
</html>`;
|
|
36
37
|
}
|
|
@@ -143,7 +144,7 @@ ${this.renderFooter()}
|
|
|
143
144
|
stroke-dashoffset="${offset}" />
|
|
144
145
|
</svg>
|
|
145
146
|
<div class="score-value">
|
|
146
|
-
<div class="number" style="color: ${this.scoreColor(overall)}"
|
|
147
|
+
<div class="number score-counter" data-target="${overall}" style="color: ${this.scoreColor(overall)}">0</div>
|
|
147
148
|
<div class="label">/ 100</div>
|
|
148
149
|
<div class="grade">${this.scoreLabel(overall)}</div>
|
|
149
150
|
</div>
|
|
@@ -154,23 +155,35 @@ ${this.renderFooter()}
|
|
|
154
155
|
</div>`;
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Radar chart for the 4 score components
|
|
160
|
+
*/
|
|
161
|
+
private renderRadarChart(report: AnalysisReport): string {
|
|
162
|
+
const entries = Object.entries(report.score.breakdown);
|
|
163
|
+
return `
|
|
164
|
+
<h2 class="section-title">🎯 Health Radar</h2>
|
|
165
|
+
<div class="card" style="display: flex; justify-content: center;">
|
|
166
|
+
<svg id="radar-chart" width="350" height="350" viewBox="0 0 350 350"></svg>
|
|
167
|
+
</div>`;
|
|
168
|
+
}
|
|
169
|
+
|
|
157
170
|
private renderStats(report: AnalysisReport): string {
|
|
158
171
|
return `
|
|
159
172
|
<div class="stats-grid">
|
|
160
173
|
<div class="stat-card">
|
|
161
|
-
<div class="value"
|
|
174
|
+
<div class="value stat-counter" data-target="${report.projectInfo.totalFiles}">0</div>
|
|
162
175
|
<div class="label">Files Scanned</div>
|
|
163
176
|
</div>
|
|
164
177
|
<div class="stat-card">
|
|
165
|
-
<div class="value"
|
|
178
|
+
<div class="value stat-counter" data-target="${report.projectInfo.totalLines}">0</div>
|
|
166
179
|
<div class="label">Lines of Code</div>
|
|
167
180
|
</div>
|
|
168
181
|
<div class="stat-card">
|
|
169
|
-
<div class="value"
|
|
182
|
+
<div class="value stat-counter" data-target="${report.antiPatterns.length}">0</div>
|
|
170
183
|
<div class="label">Anti-Patterns</div>
|
|
171
184
|
</div>
|
|
172
185
|
<div class="stat-card">
|
|
173
|
-
<div class="value"
|
|
186
|
+
<div class="value stat-counter" data-target="${report.dependencyGraph.edges.length}">0</div>
|
|
174
187
|
<div class="label">Dependencies</div>
|
|
175
188
|
</div>
|
|
176
189
|
</div>`;
|
|
@@ -204,7 +217,60 @@ ${this.renderFooter()}
|
|
|
204
217
|
<div class="layers-grid">${cards}</div>`;
|
|
205
218
|
}
|
|
206
219
|
|
|
207
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Interactive D3.js force-directed dependency graph
|
|
222
|
+
*/
|
|
223
|
+
private renderDependencyGraph(report: AnalysisReport): string {
|
|
224
|
+
if (report.dependencyGraph.edges.length === 0) return '';
|
|
225
|
+
|
|
226
|
+
// Build node data with connection counts
|
|
227
|
+
const connectionCount: Record<string, number> = {};
|
|
228
|
+
for (const edge of report.dependencyGraph.edges) {
|
|
229
|
+
connectionCount[edge.from] = (connectionCount[edge.from] || 0) + 1;
|
|
230
|
+
connectionCount[edge.to] = (connectionCount[edge.to] || 0) + 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const layerMap: Record<string, string> = {};
|
|
234
|
+
for (const layer of report.layers) {
|
|
235
|
+
for (const file of layer.files) {
|
|
236
|
+
layerMap[file] = layer.name;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const nodes = report.dependencyGraph.nodes.map(n => ({
|
|
241
|
+
id: n,
|
|
242
|
+
name: n.split('/').pop() || n,
|
|
243
|
+
connections: connectionCount[n] || 0,
|
|
244
|
+
layer: layerMap[n] || 'Other',
|
|
245
|
+
}));
|
|
246
|
+
|
|
247
|
+
const links = report.dependencyGraph.edges.map(e => ({
|
|
248
|
+
source: e.from,
|
|
249
|
+
target: e.to,
|
|
250
|
+
}));
|
|
251
|
+
|
|
252
|
+
return `
|
|
253
|
+
<h2 class="section-title">🔗 Dependency Graph</h2>
|
|
254
|
+
<div class="card graph-card">
|
|
255
|
+
<div class="graph-legend">
|
|
256
|
+
<span class="legend-item"><span class="legend-dot" style="background: #ec4899"></span> API</span>
|
|
257
|
+
<span class="legend-item"><span class="legend-dot" style="background: #3b82f6"></span> Service</span>
|
|
258
|
+
<span class="legend-item"><span class="legend-dot" style="background: #10b981"></span> Data</span>
|
|
259
|
+
<span class="legend-item"><span class="legend-dot" style="background: #f59e0b"></span> UI</span>
|
|
260
|
+
<span class="legend-item"><span class="legend-dot" style="background: #8b5cf6"></span> Infra</span>
|
|
261
|
+
<span class="legend-item"><span class="legend-dot" style="background: #64748b"></span> Other</span>
|
|
262
|
+
</div>
|
|
263
|
+
<div id="dep-graph" style="width:100%; min-height:400px;"></div>
|
|
264
|
+
<div class="graph-hint">🖱️ Drag nodes to explore • Node size = number of connections</div>
|
|
265
|
+
</div>
|
|
266
|
+
<script type="application/json" id="graph-nodes">${JSON.stringify(nodes)}<\/script>
|
|
267
|
+
<script type="application/json" id="graph-links">${JSON.stringify(links)}<\/script>`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Bubble chart for anti-patterns — bigger = more severe
|
|
272
|
+
*/
|
|
273
|
+
private renderAntiPatternBubbles(
|
|
208
274
|
report: AnalysisReport,
|
|
209
275
|
grouped: Record<string, { count: number; severity: string; locations: string[]; suggestion: string }>
|
|
210
276
|
): string {
|
|
@@ -216,6 +282,36 @@ ${this.renderFooter()}
|
|
|
216
282
|
</div>`;
|
|
217
283
|
}
|
|
218
284
|
|
|
285
|
+
const severityWeight: Record<string, number> = {
|
|
286
|
+
CRITICAL: 80, HIGH: 60, MEDIUM: 40, LOW: 25,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const severityColor: Record<string, string> = {
|
|
290
|
+
CRITICAL: '#ef4444', HIGH: '#f59e0b', MEDIUM: '#60a5fa', LOW: '#22c55e',
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const bubbles = Object.entries(grouped).map(([name, data]) => ({
|
|
294
|
+
name,
|
|
295
|
+
count: data.count,
|
|
296
|
+
severity: data.severity,
|
|
297
|
+
radius: (severityWeight[data.severity] || 30) + data.count * 8,
|
|
298
|
+
color: severityColor[data.severity] || '#64748b',
|
|
299
|
+
}));
|
|
300
|
+
|
|
301
|
+
return `
|
|
302
|
+
<h2 class="section-title">🫧 Anti-Pattern Impact Map</h2>
|
|
303
|
+
<div class="card" style="display:flex; justify-content:center;">
|
|
304
|
+
<div id="bubble-chart" style="width:100%; min-height:300px;"></div>
|
|
305
|
+
</div>
|
|
306
|
+
<script type="application/json" id="bubble-data">${JSON.stringify(bubbles)}<\/script>`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private renderAntiPatterns(
|
|
310
|
+
report: AnalysisReport,
|
|
311
|
+
grouped: Record<string, { count: number; severity: string; locations: string[]; suggestion: string }>
|
|
312
|
+
): string {
|
|
313
|
+
if (report.antiPatterns.length === 0) return '';
|
|
314
|
+
|
|
219
315
|
const rows = Object.entries(grouped)
|
|
220
316
|
.sort((a, b) => b[1].count - a[1].count)
|
|
221
317
|
.map(
|
|
@@ -234,7 +330,7 @@ ${this.renderFooter()}
|
|
|
234
330
|
.join('');
|
|
235
331
|
|
|
236
332
|
return `
|
|
237
|
-
<h2 class="section-title">⚠️ Anti-
|
|
333
|
+
<h2 class="section-title">⚠️ Anti-Pattern Details (${report.antiPatterns.length})</h2>
|
|
238
334
|
<div class="card">
|
|
239
335
|
<table>
|
|
240
336
|
<thead>
|
|
@@ -251,18 +347,6 @@ ${this.renderFooter()}
|
|
|
251
347
|
</div>`;
|
|
252
348
|
}
|
|
253
349
|
|
|
254
|
-
private renderDiagram(report: AnalysisReport): string {
|
|
255
|
-
if (!report.diagram.mermaid) return '';
|
|
256
|
-
|
|
257
|
-
return `
|
|
258
|
-
<h2 class="section-title">📊 Architecture Diagram</h2>
|
|
259
|
-
<div class="card">
|
|
260
|
-
<div class="mermaid-container">
|
|
261
|
-
<pre class="mermaid">${this.escapeHtml(report.diagram.mermaid)}</pre>
|
|
262
|
-
</div>
|
|
263
|
-
</div>`;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
350
|
private renderSuggestions(
|
|
267
351
|
suggestions: Array<{ priority: string; title: string; description: string; impact: string; count: number }>
|
|
268
352
|
): string {
|
|
@@ -304,11 +388,266 @@ ${this.renderFooter()}
|
|
|
304
388
|
private renderFooter(): string {
|
|
305
389
|
return `
|
|
306
390
|
<div class="footer">
|
|
307
|
-
<p>Generated by <a href="https://github.com/
|
|
308
|
-
<p>By <strong>Camilo Girardelli</strong> · <a href="https://
|
|
391
|
+
<p>Generated by <a href="https://github.com/camilooscargbaptista/architect">🏗️ Architect</a> — AI-powered architecture analysis</p>
|
|
392
|
+
<p>By <strong>Camilo Girardelli</strong> · <a href="https://www.girardellitecnologia.com">Girardelli Tecnologia</a></p>
|
|
309
393
|
</div>`;
|
|
310
394
|
}
|
|
311
395
|
|
|
396
|
+
/**
|
|
397
|
+
* All JavaScript for D3.js visualizations, animated counters, and radar chart
|
|
398
|
+
*/
|
|
399
|
+
private getScripts(report: AnalysisReport): string {
|
|
400
|
+
const breakdown = report.score.breakdown;
|
|
401
|
+
return `<script>
|
|
402
|
+
// ── Animated Counters ──
|
|
403
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
404
|
+
const counters = document.querySelectorAll('.score-counter, .stat-counter');
|
|
405
|
+
const observer = new IntersectionObserver((entries) => {
|
|
406
|
+
entries.forEach(entry => {
|
|
407
|
+
if (entry.isIntersecting) {
|
|
408
|
+
const el = entry.target;
|
|
409
|
+
const target = parseInt(el.dataset.target || '0');
|
|
410
|
+
animateCounter(el, target);
|
|
411
|
+
observer.unobserve(el);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
}, { threshold: 0.5 });
|
|
415
|
+
|
|
416
|
+
counters.forEach(c => observer.observe(c));
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
function animateCounter(el, target) {
|
|
420
|
+
const duration = 1500;
|
|
421
|
+
const start = performance.now();
|
|
422
|
+
const update = (now) => {
|
|
423
|
+
const elapsed = now - start;
|
|
424
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
425
|
+
const ease = 1 - Math.pow(1 - progress, 3);
|
|
426
|
+
el.textContent = Math.round(target * ease).toLocaleString();
|
|
427
|
+
if (progress < 1) requestAnimationFrame(update);
|
|
428
|
+
};
|
|
429
|
+
requestAnimationFrame(update);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── Radar Chart ──
|
|
433
|
+
(function() {
|
|
434
|
+
const data = [
|
|
435
|
+
{ axis: 'Modularity', value: ${breakdown.modularity} },
|
|
436
|
+
{ axis: 'Coupling', value: ${breakdown.coupling} },
|
|
437
|
+
{ axis: 'Cohesion', value: ${breakdown.cohesion} },
|
|
438
|
+
{ axis: 'Layering', value: ${breakdown.layering} },
|
|
439
|
+
];
|
|
440
|
+
|
|
441
|
+
const svg = d3.select('#radar-chart');
|
|
442
|
+
const w = 350, h = 350, cx = w/2, cy = h/2, maxR = 120;
|
|
443
|
+
const levels = 5;
|
|
444
|
+
const total = data.length;
|
|
445
|
+
const angleSlice = (Math.PI * 2) / total;
|
|
446
|
+
|
|
447
|
+
// Grid circles
|
|
448
|
+
for (let i = 1; i <= levels; i++) {
|
|
449
|
+
const r = (maxR / levels) * i;
|
|
450
|
+
svg.append('circle')
|
|
451
|
+
.attr('cx', cx).attr('cy', cy).attr('r', r)
|
|
452
|
+
.attr('fill', 'none').attr('stroke', '#334155').attr('stroke-width', 0.5)
|
|
453
|
+
.attr('stroke-dasharray', '4,4');
|
|
454
|
+
|
|
455
|
+
svg.append('text')
|
|
456
|
+
.attr('x', cx + 4).attr('y', cy - r + 4)
|
|
457
|
+
.text(Math.round(100 / levels * i))
|
|
458
|
+
.attr('fill', '#475569').attr('font-size', '10px');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Axis lines
|
|
462
|
+
data.forEach((d, i) => {
|
|
463
|
+
const angle = angleSlice * i - Math.PI/2;
|
|
464
|
+
const x = cx + Math.cos(angle) * (maxR + 20);
|
|
465
|
+
const y = cy + Math.sin(angle) * (maxR + 20);
|
|
466
|
+
|
|
467
|
+
svg.append('line')
|
|
468
|
+
.attr('x1', cx).attr('y1', cy).attr('x2', cx + Math.cos(angle) * maxR).attr('y2', cy + Math.sin(angle) * maxR)
|
|
469
|
+
.attr('stroke', '#334155').attr('stroke-width', 1);
|
|
470
|
+
|
|
471
|
+
svg.append('text')
|
|
472
|
+
.attr('x', x).attr('y', y)
|
|
473
|
+
.attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
|
|
474
|
+
.attr('fill', '#94a3b8').attr('font-size', '12px').attr('font-weight', '600')
|
|
475
|
+
.text(d.axis);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Data polygon
|
|
479
|
+
const points = data.map((d, i) => {
|
|
480
|
+
const angle = angleSlice * i - Math.PI/2;
|
|
481
|
+
const r = (d.value / 100) * maxR;
|
|
482
|
+
return [cx + Math.cos(angle) * r, cy + Math.sin(angle) * r];
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const pointsStr = points.map(p => p.join(',')).join(' ');
|
|
486
|
+
|
|
487
|
+
svg.append('polygon')
|
|
488
|
+
.attr('points', pointsStr)
|
|
489
|
+
.attr('fill', 'rgba(129, 140, 248, 0.15)')
|
|
490
|
+
.attr('stroke', '#818cf8').attr('stroke-width', 2);
|
|
491
|
+
|
|
492
|
+
// Data dots
|
|
493
|
+
points.forEach((p, i) => {
|
|
494
|
+
const color = data[i].value >= 70 ? '#22c55e' : data[i].value >= 50 ? '#f59e0b' : '#ef4444';
|
|
495
|
+
svg.append('circle')
|
|
496
|
+
.attr('cx', p[0]).attr('cy', p[1]).attr('r', 5)
|
|
497
|
+
.attr('fill', color).attr('stroke', '#0f172a').attr('stroke-width', 2);
|
|
498
|
+
|
|
499
|
+
svg.append('text')
|
|
500
|
+
.attr('x', p[0]).attr('y', p[1] - 12)
|
|
501
|
+
.attr('text-anchor', 'middle')
|
|
502
|
+
.attr('fill', color).attr('font-size', '12px').attr('font-weight', '700')
|
|
503
|
+
.text(data[i].value);
|
|
504
|
+
});
|
|
505
|
+
})();
|
|
506
|
+
|
|
507
|
+
// ── D3 Force Dependency Graph ──
|
|
508
|
+
(function() {
|
|
509
|
+
const nodesEl = document.getElementById('graph-nodes');
|
|
510
|
+
const linksEl = document.getElementById('graph-links');
|
|
511
|
+
if (!nodesEl || !linksEl) return;
|
|
512
|
+
|
|
513
|
+
const nodes = JSON.parse(nodesEl.textContent || '[]');
|
|
514
|
+
const links = JSON.parse(linksEl.textContent || '[]');
|
|
515
|
+
if (nodes.length === 0) return;
|
|
516
|
+
|
|
517
|
+
const container = document.getElementById('dep-graph');
|
|
518
|
+
const width = container.clientWidth || 800;
|
|
519
|
+
const height = Math.max(400, nodes.length * 25);
|
|
520
|
+
container.style.height = height + 'px';
|
|
521
|
+
|
|
522
|
+
const layerColors = {
|
|
523
|
+
API: '#ec4899', Service: '#3b82f6', Data: '#10b981',
|
|
524
|
+
UI: '#f59e0b', Infrastructure: '#8b5cf6', Other: '#64748b',
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const svg = d3.select('#dep-graph').append('svg')
|
|
528
|
+
.attr('width', width).attr('height', height)
|
|
529
|
+
.attr('viewBox', [0, 0, width, height]);
|
|
530
|
+
|
|
531
|
+
// Arrow marker
|
|
532
|
+
svg.append('defs').append('marker')
|
|
533
|
+
.attr('id', 'arrowhead').attr('viewBox', '-0 -5 10 10')
|
|
534
|
+
.attr('refX', 20).attr('refY', 0).attr('orient', 'auto')
|
|
535
|
+
.attr('markerWidth', 6).attr('markerHeight', 6)
|
|
536
|
+
.append('path').attr('d', 'M 0,-5 L 10,0 L 0,5')
|
|
537
|
+
.attr('fill', '#475569');
|
|
538
|
+
|
|
539
|
+
const simulation = d3.forceSimulation(nodes)
|
|
540
|
+
.force('link', d3.forceLink(links).id(d => d.id).distance(80))
|
|
541
|
+
.force('charge', d3.forceManyBody().strength(-200))
|
|
542
|
+
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
543
|
+
.force('collision', d3.forceCollide().radius(d => Math.max(d.connections * 3 + 12, 15)));
|
|
544
|
+
|
|
545
|
+
const link = svg.append('g')
|
|
546
|
+
.selectAll('line').data(links).join('line')
|
|
547
|
+
.attr('stroke', '#334155').attr('stroke-width', 1.5)
|
|
548
|
+
.attr('stroke-opacity', 0.6).attr('marker-end', 'url(#arrowhead)');
|
|
549
|
+
|
|
550
|
+
const node = svg.append('g')
|
|
551
|
+
.selectAll('g').data(nodes).join('g')
|
|
552
|
+
.call(d3.drag()
|
|
553
|
+
.on('start', (e, d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
|
|
554
|
+
.on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
|
|
555
|
+
.on('end', (e, d) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; })
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
// Node circles — size based on connections
|
|
559
|
+
node.append('circle')
|
|
560
|
+
.attr('r', d => Math.max(d.connections * 3 + 6, 8))
|
|
561
|
+
.attr('fill', d => layerColors[d.layer] || '#64748b')
|
|
562
|
+
.attr('stroke', '#0f172a').attr('stroke-width', 2)
|
|
563
|
+
.attr('opacity', 0.85);
|
|
564
|
+
|
|
565
|
+
// Node labels
|
|
566
|
+
node.append('text')
|
|
567
|
+
.text(d => d.name.replace(/\\.[^.]+$/, ''))
|
|
568
|
+
.attr('x', 0).attr('y', d => -(Math.max(d.connections * 3 + 6, 8) + 6))
|
|
569
|
+
.attr('text-anchor', 'middle')
|
|
570
|
+
.attr('fill', '#94a3b8').attr('font-size', '10px').attr('font-weight', '500');
|
|
571
|
+
|
|
572
|
+
// Tooltip on hover
|
|
573
|
+
node.append('title')
|
|
574
|
+
.text(d => d.id + '\\nConnections: ' + d.connections + '\\nLayer: ' + d.layer);
|
|
575
|
+
|
|
576
|
+
simulation.on('tick', () => {
|
|
577
|
+
link
|
|
578
|
+
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
|
579
|
+
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
|
580
|
+
node.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
|
|
581
|
+
});
|
|
582
|
+
})();
|
|
583
|
+
|
|
584
|
+
// ── Bubble Chart ──
|
|
585
|
+
(function() {
|
|
586
|
+
const dataEl = document.getElementById('bubble-data');
|
|
587
|
+
if (!dataEl) return;
|
|
588
|
+
|
|
589
|
+
const bubbles = JSON.parse(dataEl.textContent || '[]');
|
|
590
|
+
if (bubbles.length === 0) return;
|
|
591
|
+
|
|
592
|
+
const container = document.getElementById('bubble-chart');
|
|
593
|
+
const width = container.clientWidth || 600;
|
|
594
|
+
const height = 300;
|
|
595
|
+
|
|
596
|
+
const svg = d3.select('#bubble-chart').append('svg')
|
|
597
|
+
.attr('width', width).attr('height', height)
|
|
598
|
+
.attr('viewBox', [0, 0, width, height]);
|
|
599
|
+
|
|
600
|
+
const simulation = d3.forceSimulation(bubbles)
|
|
601
|
+
.force('charge', d3.forceManyBody().strength(5))
|
|
602
|
+
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
603
|
+
.force('collision', d3.forceCollide().radius(d => d.radius + 4))
|
|
604
|
+
.stop();
|
|
605
|
+
|
|
606
|
+
for (let i = 0; i < 120; i++) simulation.tick();
|
|
607
|
+
|
|
608
|
+
const g = svg.selectAll('g').data(bubbles).join('g')
|
|
609
|
+
.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
|
|
610
|
+
|
|
611
|
+
// Glow effect
|
|
612
|
+
g.append('circle')
|
|
613
|
+
.attr('r', d => d.radius)
|
|
614
|
+
.attr('fill', d => d.color + '20')
|
|
615
|
+
.attr('stroke', d => d.color).attr('stroke-width', 2)
|
|
616
|
+
.attr('opacity', 0)
|
|
617
|
+
.transition().duration(800).delay((d, i) => i * 200)
|
|
618
|
+
.attr('opacity', 1);
|
|
619
|
+
|
|
620
|
+
// Inner circle
|
|
621
|
+
g.append('circle')
|
|
622
|
+
.attr('r', d => d.radius * 0.7)
|
|
623
|
+
.attr('fill', d => d.color + '30')
|
|
624
|
+
.attr('opacity', 0)
|
|
625
|
+
.transition().duration(800).delay((d, i) => i * 200)
|
|
626
|
+
.attr('opacity', 1);
|
|
627
|
+
|
|
628
|
+
// Name
|
|
629
|
+
g.append('text')
|
|
630
|
+
.text(d => d.name)
|
|
631
|
+
.attr('text-anchor', 'middle').attr('dy', '-0.3em')
|
|
632
|
+
.attr('fill', '#e2e8f0').attr('font-size', d => Math.max(d.radius / 4, 10) + 'px')
|
|
633
|
+
.attr('font-weight', '700');
|
|
634
|
+
|
|
635
|
+
// Count badge
|
|
636
|
+
g.append('text')
|
|
637
|
+
.text(d => '×' + d.count)
|
|
638
|
+
.attr('text-anchor', 'middle').attr('dy', '1.2em')
|
|
639
|
+
.attr('fill', d => d.color).attr('font-size', d => Math.max(d.radius / 5, 9) + 'px')
|
|
640
|
+
.attr('font-weight', '600');
|
|
641
|
+
|
|
642
|
+
// Severity label
|
|
643
|
+
g.append('text')
|
|
644
|
+
.text(d => d.severity)
|
|
645
|
+
.attr('text-anchor', 'middle').attr('dy', '2.5em')
|
|
646
|
+
.attr('fill', '#64748b').attr('font-size', '9px').attr('text-transform', 'uppercase');
|
|
647
|
+
})();
|
|
648
|
+
<\/script>`;
|
|
649
|
+
}
|
|
650
|
+
|
|
312
651
|
private getStyles(): string {
|
|
313
652
|
return `<style>
|
|
314
653
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
|
@@ -409,6 +748,20 @@ ${this.renderFooter()}
|
|
|
409
748
|
}
|
|
410
749
|
.success-card { border-color: #22c55e40; color: #22c55e; text-align: center; padding: 2rem; font-size: 1.1rem; }
|
|
411
750
|
|
|
751
|
+
/* ── Graph ── */
|
|
752
|
+
.graph-card { padding: 1rem; }
|
|
753
|
+
.graph-legend {
|
|
754
|
+
display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 0.5rem;
|
|
755
|
+
justify-content: center;
|
|
756
|
+
}
|
|
757
|
+
.legend-item { display: flex; align-items: center; gap: 4px; font-size: 0.75rem; color: #94a3b8; }
|
|
758
|
+
.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
|
759
|
+
.graph-hint {
|
|
760
|
+
text-align: center; font-size: 0.75rem; color: #475569; margin-top: 0.5rem;
|
|
761
|
+
font-style: italic;
|
|
762
|
+
}
|
|
763
|
+
#dep-graph svg { background: rgba(0,0,0,0.2); border-radius: 12px; }
|
|
764
|
+
|
|
412
765
|
/* ── Layers Grid ── */
|
|
413
766
|
.layers-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; }
|
|
414
767
|
.layer-card {
|
|
@@ -449,11 +802,6 @@ ${this.renderFooter()}
|
|
|
449
802
|
.locations { font-size: 0.75rem; color: #64748b; }
|
|
450
803
|
.locations code { background: #0f172a; padding: 1px 4px; border-radius: 3px; font-size: 0.7rem; }
|
|
451
804
|
|
|
452
|
-
/* ── Mermaid ── */
|
|
453
|
-
.mermaid-container {
|
|
454
|
-
background: #f8fafc; border-radius: 12px; padding: 2rem; text-align: center; color: #0f172a;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
805
|
/* ── Footer ── */
|
|
458
806
|
.footer {
|
|
459
807
|
text-align: center; padding: 2rem; color: #475569; font-size: 0.85rem;
|
|
@@ -478,7 +826,6 @@ ${this.renderFooter()}
|
|
|
478
826
|
.card, .stat-card, .score-hero, .layer-card, .score-item {
|
|
479
827
|
background: white; border-color: #e2e8f0;
|
|
480
828
|
}
|
|
481
|
-
.mermaid-container { border: 1px solid #e2e8f0; }
|
|
482
829
|
}
|
|
483
830
|
</style>`;
|
|
484
831
|
}
|
package/src/index.ts
CHANGED
|
@@ -59,7 +59,7 @@ class Architect implements ArchitectCommand {
|
|
|
59
59
|
const diagramGenerator = new DiagramGenerator();
|
|
60
60
|
const layerDiagram = diagramGenerator.generateLayerDiagram(layers);
|
|
61
61
|
|
|
62
|
-
const suggestions = this.generateSuggestions(antiPatterns, score);
|
|
62
|
+
const suggestions = this.generateSuggestions(antiPatterns, score, edges);
|
|
63
63
|
|
|
64
64
|
const report: AnalysisReport = {
|
|
65
65
|
timestamp: new Date().toISOString(),
|
|
@@ -160,35 +160,96 @@ class Architect implements ArchitectCommand {
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
private generateSuggestions(
|
|
163
|
-
antiPatterns: Array<{ name: string; severity: string; description: string; suggestion: string }>,
|
|
164
|
-
score: { overall: number; breakdown: Record<string, number> }
|
|
163
|
+
antiPatterns: Array<{ name: string; severity: string; description: string; suggestion: string; location?: string; affectedFiles?: string[] }>,
|
|
164
|
+
score: { overall: number; breakdown: Record<string, number> },
|
|
165
|
+
edges?: { from: string; to: string }[]
|
|
165
166
|
) {
|
|
166
167
|
const suggestions: Array<{ priority: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'; title: string; description: string; impact: string }> = [];
|
|
167
168
|
|
|
169
|
+
// 1. Anti-pattern specific suggestions with file context
|
|
168
170
|
for (const pattern of antiPatterns) {
|
|
169
171
|
const priority = pattern.severity === 'CRITICAL' ? 'CRITICAL' as const : 'HIGH' as const;
|
|
172
|
+
const location = pattern.location ? ` in \`${pattern.location}\`` : '';
|
|
173
|
+
const affected = pattern.affectedFiles?.length
|
|
174
|
+
? ` Affected files: ${pattern.affectedFiles.slice(0, 3).map(f => `\`${f}\``).join(', ')}${pattern.affectedFiles.length > 3 ? ` (+${pattern.affectedFiles.length - 3} more)` : ''}.`
|
|
175
|
+
: '';
|
|
176
|
+
|
|
170
177
|
suggestions.push({
|
|
171
178
|
priority,
|
|
172
179
|
title: pattern.name,
|
|
173
|
-
description: pattern.suggestion
|
|
180
|
+
description: `${pattern.suggestion}${location}.${affected}`,
|
|
174
181
|
impact: `Addressing this ${pattern.name} will improve overall architecture score`,
|
|
175
182
|
});
|
|
176
183
|
}
|
|
177
184
|
|
|
178
|
-
|
|
185
|
+
// 2. Hub Detection — find files with many connections
|
|
186
|
+
if (edges && edges.length > 0) {
|
|
187
|
+
const connectionCount: Record<string, number> = {};
|
|
188
|
+
for (const edge of edges) {
|
|
189
|
+
connectionCount[edge.from] = (connectionCount[edge.from] || 0) + 1;
|
|
190
|
+
connectionCount[edge.to] = (connectionCount[edge.to] || 0) + 1;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const hubThreshold = 5;
|
|
194
|
+
const hubs = Object.entries(connectionCount)
|
|
195
|
+
.filter(([_, count]) => count >= hubThreshold)
|
|
196
|
+
.sort((a, b) => b[1] - a[1]);
|
|
197
|
+
|
|
198
|
+
for (const [file, count] of hubs.slice(0, 3)) {
|
|
199
|
+
const fileName = file.split('/').pop() || file;
|
|
200
|
+
const isBarrel = ['__init__.py', 'index.ts', 'index.js'].includes(fileName);
|
|
201
|
+
|
|
202
|
+
if (!isBarrel) {
|
|
203
|
+
suggestions.push({
|
|
204
|
+
priority: 'HIGH',
|
|
205
|
+
title: `Hub File: ${fileName}`,
|
|
206
|
+
description: `\`${file}\` has ${count} connections. Consider extracting a facade or splitting responsibilities to reduce coupling.`,
|
|
207
|
+
impact: `Reducing connections in \`${fileName}\` can improve coupling score by 10-15 points`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 3. Cross-boundary imports — files importing from many different directories
|
|
213
|
+
const crossBoundary: Record<string, Set<string>> = {};
|
|
214
|
+
for (const edge of edges) {
|
|
215
|
+
const fromDir = edge.from.split('/').slice(0, -1).join('/');
|
|
216
|
+
const toDir = edge.to.split('/').slice(0, -1).join('/');
|
|
217
|
+
if (fromDir !== toDir) {
|
|
218
|
+
if (!crossBoundary[edge.from]) crossBoundary[edge.from] = new Set();
|
|
219
|
+
crossBoundary[edge.from].add(toDir);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const crossViolators = Object.entries(crossBoundary)
|
|
224
|
+
.filter(([_, dirs]) => dirs.size >= 3)
|
|
225
|
+
.sort((a, b) => b[1].size - a[1].size);
|
|
226
|
+
|
|
227
|
+
for (const [file, dirs] of crossViolators.slice(0, 2)) {
|
|
228
|
+
const fileName = file.split('/').pop() || file;
|
|
229
|
+
suggestions.push({
|
|
230
|
+
priority: 'MEDIUM',
|
|
231
|
+
title: `Cross-boundary: ${fileName}`,
|
|
232
|
+
description: `\`${file}\` imports from ${dirs.size} different modules (${Array.from(dirs).slice(0, 3).join(', ')}). Consider dependency injection or a mediator pattern.`,
|
|
233
|
+
impact: `Reducing cross-boundary imports can improve cohesion by 5-10 points`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 4. Score-based suggestions (only if no specific suggestions cover it)
|
|
239
|
+
if (score.breakdown.coupling < 70 && !suggestions.some(s => s.title.startsWith('Hub File'))) {
|
|
179
240
|
suggestions.push({
|
|
180
241
|
priority: 'HIGH',
|
|
181
242
|
title: 'Reduce Coupling',
|
|
182
|
-
description: 'Use dependency injection and invert control to reduce module interdependencies',
|
|
243
|
+
description: 'Use dependency injection and invert control to reduce module interdependencies. Consider the Strategy or Observer pattern for loose coupling.',
|
|
183
244
|
impact: 'Can improve coupling score by 15-20 points',
|
|
184
245
|
});
|
|
185
246
|
}
|
|
186
247
|
|
|
187
|
-
if (score.breakdown.cohesion < 70) {
|
|
248
|
+
if (score.breakdown.cohesion < 70 && !suggestions.some(s => s.title.startsWith('Cross-boundary'))) {
|
|
188
249
|
suggestions.push({
|
|
189
250
|
priority: 'MEDIUM',
|
|
190
251
|
title: 'Improve Cohesion',
|
|
191
|
-
description: 'Group related functionality closer together
|
|
252
|
+
description: 'Group related functionality closer together. Files that use each other frequently should be in the same module/package.',
|
|
192
253
|
impact: 'Can improve cohesion score by 10-15 points',
|
|
193
254
|
});
|
|
194
255
|
}
|