@vibecodeqa/cli 0.26.0 → 0.28.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/dist/history.js +1 -1
- package/dist/report/html.js +1 -1
- package/dist/report/pages.js +15 -28
- package/dist/report/styles.d.ts +1 -1
- package/dist/report/styles.js +3 -6
- package/dist/runners/architecture.d.ts +1 -7
- package/dist/runners/architecture.js +4 -571
- package/dist/runners/diagrams.d.ts +7 -0
- package/dist/runners/diagrams.js +580 -0
- package/dist/runners/duplication.js +6 -3
- package/dist/runners/testing.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/** Architecture diagram generators — SVG visualizations. */
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { basename, extname, join } from "node:path";
|
|
4
|
+
// ── SVG Architecture Diagram ──
|
|
5
|
+
export function generateArchSVG(details) {
|
|
6
|
+
const graph = details.graph;
|
|
7
|
+
if (!graph || Object.keys(graph).length === 0)
|
|
8
|
+
return "";
|
|
9
|
+
const nodes = Object.entries(graph);
|
|
10
|
+
const nodeCount = nodes.length;
|
|
11
|
+
if (nodeCount > 50)
|
|
12
|
+
return `<div style="color:#6b7280;font-size:0.75rem">${nodeCount} modules — too many to render. Consider splitting into smaller packages.</div>`;
|
|
13
|
+
// Detect cycles for highlighting
|
|
14
|
+
const cycleEdges = new Set();
|
|
15
|
+
const cycles = details.circularDeps;
|
|
16
|
+
if (cycles > 0) {
|
|
17
|
+
// Mark edges that participate in cycles (simplified: mutual imports)
|
|
18
|
+
for (const [path, info] of nodes) {
|
|
19
|
+
for (const imp of info.imports) {
|
|
20
|
+
if (graph[imp]?.imports.includes(path)) {
|
|
21
|
+
cycleEdges.add(`${path}->${imp}`);
|
|
22
|
+
cycleEdges.add(`${imp}->${path}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Group by directory
|
|
28
|
+
const dirs = new Map();
|
|
29
|
+
for (const [path, info] of nodes) {
|
|
30
|
+
const dir = info.dir || ".";
|
|
31
|
+
const arr = dirs.get(dir) || [];
|
|
32
|
+
arr.push(path);
|
|
33
|
+
dirs.set(dir, arr);
|
|
34
|
+
}
|
|
35
|
+
const W = 800, padding = 50;
|
|
36
|
+
const dirEntries = [...dirs.entries()];
|
|
37
|
+
const dirWidth = (W - padding * 2) / Math.max(dirEntries.length, 1);
|
|
38
|
+
const nodeSpacing = 38;
|
|
39
|
+
// Position nodes
|
|
40
|
+
const positions = new Map();
|
|
41
|
+
let dirIdx = 0;
|
|
42
|
+
for (const [, paths] of dirEntries) {
|
|
43
|
+
const x0 = padding + dirIdx * dirWidth + dirWidth / 2;
|
|
44
|
+
for (let i = 0; i < paths.length; i++) {
|
|
45
|
+
const y = padding + 55 + i * nodeSpacing;
|
|
46
|
+
positions.set(paths[i], { x: x0, y });
|
|
47
|
+
}
|
|
48
|
+
dirIdx++;
|
|
49
|
+
}
|
|
50
|
+
const maxGroupLen = Math.max(...[...dirs.values()].map((p) => p.length));
|
|
51
|
+
const H = Math.max(320, padding * 2 + 55 + maxGroupLen * nodeSpacing + 50);
|
|
52
|
+
// ── Defs: arrowhead marker, glow filter ──
|
|
53
|
+
const defs = `<defs>
|
|
54
|
+
<marker id="ah" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#818cf850"/></marker>
|
|
55
|
+
<marker id="ah-cross" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#ef444460"/></marker>
|
|
56
|
+
<marker id="ah-cycle" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#f97316"/></marker>
|
|
57
|
+
</defs>`;
|
|
58
|
+
// ── Background — transparent, inherits page dark bg ──
|
|
59
|
+
const bg = `<rect width="${W}" height="${H}" rx="8" fill="none"/>`;
|
|
60
|
+
// ── Draw edges (curved bezier paths with arrows) ──
|
|
61
|
+
let edgesSvg = "";
|
|
62
|
+
for (const [path, info] of nodes) {
|
|
63
|
+
const from = positions.get(path);
|
|
64
|
+
if (!from)
|
|
65
|
+
continue;
|
|
66
|
+
for (const imp of info.imports) {
|
|
67
|
+
const to = positions.get(imp);
|
|
68
|
+
if (!to)
|
|
69
|
+
continue;
|
|
70
|
+
const isCycle = cycleEdges.has(`${path}->${imp}`);
|
|
71
|
+
const isCross = info.dir !== graph[imp]?.dir;
|
|
72
|
+
const color = isCycle ? "#f9731680" : isCross ? "#ef444435" : "#818cf820";
|
|
73
|
+
const marker = isCycle ? "url(#ah-cycle)" : isCross ? "url(#ah-cross)" : "url(#ah)";
|
|
74
|
+
const width = isCycle ? "2" : "1.2";
|
|
75
|
+
const dash = isCycle ? ' stroke-dasharray="5,3"' : "";
|
|
76
|
+
// Bezier curve: offset control point sideways to avoid straight-line overlap
|
|
77
|
+
const dx = to.x - from.x;
|
|
78
|
+
const dy = to.y - from.y;
|
|
79
|
+
const cx1 = from.x + dx * 0.3 + (dy === 0 ? 0 : Math.sign(dx) * 15);
|
|
80
|
+
const cy1 = from.y + dy * 0.3;
|
|
81
|
+
const cx2 = to.x - dx * 0.3 + (dy === 0 ? 0 : Math.sign(dx) * 15);
|
|
82
|
+
const cy2 = to.y - dy * 0.3;
|
|
83
|
+
edgesSvg += `<path d="M${from.x},${from.y} C${cx1},${cy1} ${cx2},${cy2} ${to.x},${to.y}" fill="none" stroke="${color}" stroke-width="${width}" marker-end="${marker}"${dash}/>`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ── Draw directory groups ──
|
|
87
|
+
let groupsSvg = "";
|
|
88
|
+
dirIdx = 0;
|
|
89
|
+
for (const [dName, paths] of dirEntries) {
|
|
90
|
+
const x = padding + dirIdx * dirWidth;
|
|
91
|
+
const h = paths.length * nodeSpacing + 24;
|
|
92
|
+
groupsSvg += `<rect x="${x + 5}" y="${padding + 32}" width="${dirWidth - 10}" height="${h}" rx="8" fill="#ffffff06" stroke="#ffffff10"/>`;
|
|
93
|
+
const label = dName === "." ? "root" : dName.split("/").pop();
|
|
94
|
+
groupsSvg += `<text x="${x + dirWidth / 2}" y="${padding + 24}" text-anchor="middle" fill="#6b7280" font-size="10" font-weight="700" letter-spacing="0.03em">${label}</text>`;
|
|
95
|
+
dirIdx++;
|
|
96
|
+
}
|
|
97
|
+
// ── Draw nodes ──
|
|
98
|
+
let nodesSvg = "";
|
|
99
|
+
const godThreshold = Math.max(3, Math.floor(nodeCount * 0.5));
|
|
100
|
+
for (const [path] of nodes) {
|
|
101
|
+
const pos = positions.get(path);
|
|
102
|
+
if (!pos)
|
|
103
|
+
continue;
|
|
104
|
+
const name = basename(path, extname(path));
|
|
105
|
+
const info = graph[path];
|
|
106
|
+
const fanIn = info.importedBy.length;
|
|
107
|
+
const fanOut = info.imports.length;
|
|
108
|
+
// Node color based on health
|
|
109
|
+
const isGod = fanIn >= godThreshold;
|
|
110
|
+
const isOrphan = fanIn === 0 && !["index", "main", "cli", "App"].includes(name);
|
|
111
|
+
const isHighFanOut = fanOut > 10;
|
|
112
|
+
const isInCycle = [...cycleEdges].some((e) => e.startsWith(`${path}->`) || e.endsWith(`->${path}`));
|
|
113
|
+
let nodeColor = "#6d78d0"; // default: softer accent
|
|
114
|
+
if (isInCycle)
|
|
115
|
+
nodeColor = "#d97706"; // amber for cycle participant
|
|
116
|
+
else if (isGod)
|
|
117
|
+
nodeColor = "#dc2626"; // red for god module
|
|
118
|
+
else if (isOrphan)
|
|
119
|
+
nodeColor = "#4b5563"; // dim for orphan
|
|
120
|
+
else if (isHighFanOut)
|
|
121
|
+
nodeColor = "#ca8a04"; // yellow for high fan-out
|
|
122
|
+
const size = Math.min(9, 3 + Math.floor(fanIn * 0.8));
|
|
123
|
+
// Node circle with subtle glow for important nodes
|
|
124
|
+
if (isGod || isInCycle) {
|
|
125
|
+
nodesSvg += `<circle cx="${pos.x}" cy="${pos.y}" r="${size + 4}" fill="${nodeColor}" opacity="0.15"/>`;
|
|
126
|
+
}
|
|
127
|
+
nodesSvg += `<circle cx="${pos.x}" cy="${pos.y}" r="${size}" fill="${nodeColor}"/>`;
|
|
128
|
+
// Label
|
|
129
|
+
const labelColor = isOrphan ? "#4b5563" : "#9ca3af";
|
|
130
|
+
nodesSvg += `<text x="${pos.x + size + 5}" y="${pos.y + 3}" fill="${labelColor}" font-size="9" font-weight="${isGod ? "700" : "400"}">${name}</text>`;
|
|
131
|
+
// Fan-in/fan-out badge (only for notable nodes)
|
|
132
|
+
if (fanIn > 2 || fanOut > 5) {
|
|
133
|
+
nodesSvg += `<text x="${pos.x + size + 5}" y="${pos.y + 13}" fill="#555" font-size="7">${fanIn}\u2190 ${fanOut}\u2192</text>`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// ── Legend ──
|
|
137
|
+
const legendY = H - 30;
|
|
138
|
+
const legend = `<g transform="translate(${padding}, ${legendY})" font-size="8" fill="#6b7280">
|
|
139
|
+
<circle cx="0" cy="0" r="4" fill="#6d78d0"/><text x="8" y="3">module</text>
|
|
140
|
+
<circle cx="60" cy="0" r="4" fill="#dc2626"/><text x="68" y="3">god module</text>
|
|
141
|
+
<circle cx="140" cy="0" r="4" fill="#d97706"/><text x="148" y="3">in cycle</text>
|
|
142
|
+
<circle cx="200" cy="0" r="4" fill="#ca8a04"/><text x="208" y="3">high fan-out</text>
|
|
143
|
+
<circle cx="280" cy="0" r="4" fill="#4b5563"/><text x="288" y="3">orphan</text>
|
|
144
|
+
<line x1="330" y1="0" x2="350" y2="0" stroke="#ef444440" stroke-width="1.2"/><text x="354" y="3">cross-dir</text>
|
|
145
|
+
<line x1="410" y1="0" x2="430" y2="0" stroke="#d97706" stroke-width="1.5" stroke-dasharray="5,3"/><text x="434" y="3">circular</text>
|
|
146
|
+
</g>`;
|
|
147
|
+
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:${W}px">${defs}${bg}${groupsSvg}${edgesSvg}${nodesSvg}${legend}</svg>`;
|
|
148
|
+
}
|
|
149
|
+
// ── Dependency Matrix (DSM) ──────────────────────────────────────────
|
|
150
|
+
// Standard software architecture visualization. Rows and columns are modules,
|
|
151
|
+
// cells show import relationships. Clusters on the diagonal = well-structured packages.
|
|
152
|
+
export function generateDSM(details) {
|
|
153
|
+
const graph = details.graph;
|
|
154
|
+
if (!graph || Object.keys(graph).length === 0)
|
|
155
|
+
return "";
|
|
156
|
+
const entries = Object.entries(graph);
|
|
157
|
+
if (entries.length > 40)
|
|
158
|
+
return `<div style="color:#6b7280;font-size:0.75rem">${entries.length} modules — too many for matrix view.</div>`;
|
|
159
|
+
if (entries.length < 3)
|
|
160
|
+
return "";
|
|
161
|
+
// Sort by directory then name for clustering
|
|
162
|
+
entries.sort((a, b) => `${a[1].dir}/${a[0]}`.localeCompare(`${b[1].dir}/${b[0]}`));
|
|
163
|
+
const paths = entries.map(([p]) => p);
|
|
164
|
+
const idx = new Map(paths.map((p, i) => [p, i]));
|
|
165
|
+
const n = paths.length;
|
|
166
|
+
const cell = 14;
|
|
167
|
+
const labelW = 110;
|
|
168
|
+
const W = labelW + n * cell + 10;
|
|
169
|
+
const H = labelW + n * cell + 10;
|
|
170
|
+
// Build adjacency
|
|
171
|
+
const matrix = Array.from({ length: n }, () => Array(n).fill(false));
|
|
172
|
+
for (const [path, info] of entries) {
|
|
173
|
+
const from = idx.get(path);
|
|
174
|
+
for (const imp of info.imports) {
|
|
175
|
+
const to = idx.get(imp);
|
|
176
|
+
if (to !== undefined)
|
|
177
|
+
matrix[from][to] = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
let svg = "";
|
|
181
|
+
const ox = labelW, oy = labelW;
|
|
182
|
+
// Grid
|
|
183
|
+
for (let i = 0; i <= n; i++) {
|
|
184
|
+
svg += `<line x1="${ox}" y1="${oy + i * cell}" x2="${ox + n * cell}" y2="${oy + i * cell}" stroke="#1e1e24" stroke-width="0.5"/>`;
|
|
185
|
+
svg += `<line x1="${ox + i * cell}" y1="${oy}" x2="${ox + i * cell}" y2="${oy + n * cell}" stroke="#1e1e24" stroke-width="0.5"/>`;
|
|
186
|
+
}
|
|
187
|
+
// Cells — row imports col
|
|
188
|
+
for (let r = 0; r < n; r++) {
|
|
189
|
+
for (let c = 0; c < n; c++) {
|
|
190
|
+
if (r === c) {
|
|
191
|
+
// Diagonal — highlight
|
|
192
|
+
svg += `<rect x="${ox + c * cell}" y="${oy + r * cell}" width="${cell}" height="${cell}" fill="#818cf808"/>`;
|
|
193
|
+
}
|
|
194
|
+
else if (matrix[r][c]) {
|
|
195
|
+
const mutual = matrix[c][r]; // circular?
|
|
196
|
+
const color = mutual ? "#d97706" : "#6d78d0";
|
|
197
|
+
svg += `<rect x="${ox + c * cell + 2}" y="${oy + r * cell + 2}" width="${cell - 4}" height="${cell - 4}" rx="2" fill="${color}" opacity="0.7"/>`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Directory bands (background stripe per dir group)
|
|
202
|
+
let prevDir = "";
|
|
203
|
+
let bandStart = 0;
|
|
204
|
+
const dirColors = ["#ffffff04", "#ffffff08"];
|
|
205
|
+
let dirIdx2 = 0;
|
|
206
|
+
for (let i = 0; i <= n; i++) {
|
|
207
|
+
const dir = i < n ? entries[i][1].dir : "__end__";
|
|
208
|
+
if (dir !== prevDir && i > 0) {
|
|
209
|
+
const fill = dirColors[dirIdx2 % 2];
|
|
210
|
+
svg += `<rect x="${ox}" y="${oy + bandStart * cell}" width="${n * cell}" height="${(i - bandStart) * cell}" fill="${fill}"/>`;
|
|
211
|
+
svg += `<rect x="${ox + bandStart * cell}" y="${oy}" width="${(i - bandStart) * cell}" height="${n * cell}" fill="${fill}"/>`;
|
|
212
|
+
dirIdx2++;
|
|
213
|
+
bandStart = i;
|
|
214
|
+
}
|
|
215
|
+
prevDir = dir;
|
|
216
|
+
}
|
|
217
|
+
// Row labels (left) and column labels (top, rotated)
|
|
218
|
+
for (let i = 0; i < n; i++) {
|
|
219
|
+
const name = basename(paths[i], extname(paths[i]));
|
|
220
|
+
svg += `<text x="${ox - 4}" y="${oy + i * cell + cell / 2 + 3}" text-anchor="end" fill="#9ca3af" font-size="7">${name}</text>`;
|
|
221
|
+
svg += `<text x="${ox + i * cell + cell / 2}" y="${oy - 4}" text-anchor="start" fill="#9ca3af" font-size="7" transform="rotate(-60 ${ox + i * cell + cell / 2} ${oy - 4})">${name}</text>`;
|
|
222
|
+
}
|
|
223
|
+
// Legend
|
|
224
|
+
svg += `<g transform="translate(${ox}, ${oy + n * cell + 16})" font-size="7" fill="#6b7280">`;
|
|
225
|
+
svg += `<rect x="0" y="-4" width="8" height="8" rx="2" fill="#6d78d0" opacity="0.7"/><text x="12" y="3">imports</text>`;
|
|
226
|
+
svg += `<rect x="60" y="-4" width="8" height="8" rx="2" fill="#d97706" opacity="0.7"/><text x="72" y="3">mutual (cycle)</text>`;
|
|
227
|
+
svg += `</g>`;
|
|
228
|
+
return `<svg viewBox="0 0 ${W} ${H + 30}" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:${W}px">${svg}</svg>`;
|
|
229
|
+
}
|
|
230
|
+
// ── Package Nesting Diagram ──────────────────────────────────────────
|
|
231
|
+
// UML-style Package diagram: directories as nested boxes, files as items inside.
|
|
232
|
+
export function generatePackageDiagram(details) {
|
|
233
|
+
const graph = details.graph;
|
|
234
|
+
if (!graph || Object.keys(graph).length === 0)
|
|
235
|
+
return "";
|
|
236
|
+
const entries = Object.entries(graph);
|
|
237
|
+
if (entries.length > 50)
|
|
238
|
+
return `<div style="color:#6b7280;font-size:0.75rem">${entries.length} modules — too many for package view.</div>`;
|
|
239
|
+
// Group by directory
|
|
240
|
+
const dirs = new Map();
|
|
241
|
+
for (const [path, info] of entries) {
|
|
242
|
+
const dir = info.dir || ".";
|
|
243
|
+
const arr = dirs.get(dir) || [];
|
|
244
|
+
arr.push({ path, fanIn: info.importedBy.length, fanOut: info.imports.length });
|
|
245
|
+
dirs.set(dir, arr);
|
|
246
|
+
}
|
|
247
|
+
const dirEntries = [...dirs.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
248
|
+
const boxW = 180;
|
|
249
|
+
const fileH = 18;
|
|
250
|
+
const headerH = 24;
|
|
251
|
+
const gap = 16;
|
|
252
|
+
const cols = Math.min(dirEntries.length, 4);
|
|
253
|
+
const colW = boxW + gap;
|
|
254
|
+
let svg = "";
|
|
255
|
+
let maxH = 0;
|
|
256
|
+
for (let i = 0; i < dirEntries.length; i++) {
|
|
257
|
+
const [dir, files] = dirEntries[i];
|
|
258
|
+
const col = i % cols;
|
|
259
|
+
const row = Math.floor(i / cols);
|
|
260
|
+
const prevRowsH = row * 300; // rough estimate, will adjust
|
|
261
|
+
const x = gap + col * colW;
|
|
262
|
+
let y = gap + prevRowsH;
|
|
263
|
+
const boxH = headerH + files.length * fileH + 8;
|
|
264
|
+
// Package box
|
|
265
|
+
svg += `<rect x="${x}" y="${y}" width="${boxW}" height="${boxH}" rx="6" fill="#ffffff04" stroke="#ffffff10"/>`;
|
|
266
|
+
// Package tab (UML-style)
|
|
267
|
+
svg += `<rect x="${x}" y="${y}" width="${Math.min(boxW * 0.6, 100)}" height="${headerH}" rx="4" fill="#ffffff08" stroke="#ffffff10"/>`;
|
|
268
|
+
const label = dir === "." ? "root" : dir.replace(/^src\//, "");
|
|
269
|
+
svg += `<text x="${x + 8}" y="${y + 16}" fill="#9ca3af" font-size="10" font-weight="700">${label}/</text>`;
|
|
270
|
+
svg += `<text x="${x + boxW - 8}" y="${y + 16}" text-anchor="end" fill="#4b5563" font-size="8">${files.length}</text>`;
|
|
271
|
+
y += headerH + 4;
|
|
272
|
+
// Files inside package
|
|
273
|
+
for (const f of files) {
|
|
274
|
+
const name = basename(f.path, extname(f.path));
|
|
275
|
+
const health = f.fanIn > 5 ? "#d97706" : f.fanOut > 8 ? "#ca8a04" : "#6d78d0";
|
|
276
|
+
svg += `<circle cx="${x + 12}" cy="${y + 7}" r="3" fill="${health}"/>`;
|
|
277
|
+
svg += `<text x="${x + 20}" y="${y + 10}" fill="#9ca3af" font-size="8">${name}</text>`;
|
|
278
|
+
svg += `<text x="${x + boxW - 8}" y="${y + 10}" text-anchor="end" fill="#4b5563" font-size="7">${f.fanIn}\u2190 ${f.fanOut}\u2192</text>`;
|
|
279
|
+
y += fileH;
|
|
280
|
+
}
|
|
281
|
+
maxH = Math.max(maxH, y + 8);
|
|
282
|
+
}
|
|
283
|
+
const W = gap + cols * colW;
|
|
284
|
+
const H = maxH + gap;
|
|
285
|
+
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:${W}px">${svg}</svg>`;
|
|
286
|
+
}
|
|
287
|
+
// ── Sequence Diagram ─────────────────────────────────────────────────
|
|
288
|
+
// Shows the RUNTIME FLOW of the application — what calls what in order.
|
|
289
|
+
// Detected by analyzing the entry point's exported function calls and
|
|
290
|
+
// which modules they invoke. NOT just import chains.
|
|
291
|
+
//
|
|
292
|
+
// Participants = architectural roles (Entry, Detect, Runners, Score, Report, Output)
|
|
293
|
+
// Messages = actual operations that happen at runtime
|
|
294
|
+
export function generateSequenceDiagram(details) {
|
|
295
|
+
const graph = details.graph;
|
|
296
|
+
if (!graph || Object.keys(graph).length < 3)
|
|
297
|
+
return "";
|
|
298
|
+
// Find the entry point
|
|
299
|
+
const entries = Object.entries(graph);
|
|
300
|
+
const entryPoint = entries.find(([path, info]) => {
|
|
301
|
+
const name = basename(path, extname(path));
|
|
302
|
+
return info.importedBy.length === 0 && ["index", "main", "cli", "App", "app", "server"].includes(name);
|
|
303
|
+
});
|
|
304
|
+
if (!entryPoint)
|
|
305
|
+
return "";
|
|
306
|
+
// Determine architectural roles from directory structure
|
|
307
|
+
const roles = [];
|
|
308
|
+
const dirs = new Map();
|
|
309
|
+
for (const [, info] of entries) {
|
|
310
|
+
const dir = info.dir || ".";
|
|
311
|
+
dirs.set(dir, (dirs.get(dir) || 0) + 1);
|
|
312
|
+
}
|
|
313
|
+
// Build role list from actual structure
|
|
314
|
+
const entryName = basename(entryPoint[0], extname(entryPoint[0]));
|
|
315
|
+
roles.push({ name: entryName, dir: "entry", modules: 1 });
|
|
316
|
+
// Add directories as participants (sorted by dependency order)
|
|
317
|
+
const dirArr = [...dirs.entries()]
|
|
318
|
+
.filter(([d]) => d !== (entryPoint[1].dir || "."))
|
|
319
|
+
.sort((a, b) => {
|
|
320
|
+
// Sort by average fan-in (more depended-upon = earlier in flow)
|
|
321
|
+
const aFanIn = entries.filter(([, i]) => i.dir === a[0]).reduce((s, [, i]) => s + i.importedBy.length, 0) / a[1];
|
|
322
|
+
const bFanIn = entries.filter(([, i]) => i.dir === b[0]).reduce((s, [, i]) => s + i.importedBy.length, 0) / b[1];
|
|
323
|
+
return bFanIn - aFanIn; // most depended-on first
|
|
324
|
+
});
|
|
325
|
+
for (const [dir, count] of dirArr) {
|
|
326
|
+
const label = dir.replace("src/", "").replace("lib/", "") || "core";
|
|
327
|
+
roles.push({ name: label, dir, modules: count });
|
|
328
|
+
}
|
|
329
|
+
if (roles.length < 3)
|
|
330
|
+
return "";
|
|
331
|
+
const maxRoles = Math.min(roles.length, 6);
|
|
332
|
+
const displayRoles = roles.slice(0, maxRoles);
|
|
333
|
+
// Build messages: entry calls each role in order
|
|
334
|
+
// Detect what the entry imports from each directory
|
|
335
|
+
const messages = [];
|
|
336
|
+
const entryImports = entryPoint[1].imports;
|
|
337
|
+
for (let i = 1; i < displayRoles.length; i++) {
|
|
338
|
+
const role = displayRoles[i];
|
|
339
|
+
const importsFromRole = entryImports.filter((imp) => {
|
|
340
|
+
const impInfo = graph[imp];
|
|
341
|
+
return impInfo && (impInfo.dir || ".") === role.dir;
|
|
342
|
+
});
|
|
343
|
+
if (importsFromRole.length > 0) {
|
|
344
|
+
const funcNames = importsFromRole
|
|
345
|
+
.map((p) => basename(p, extname(p)))
|
|
346
|
+
.slice(0, 2)
|
|
347
|
+
.join(", ");
|
|
348
|
+
messages.push({ from: 0, to: i, label: funcNames });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Also show inter-role calls (report imports from runners, etc.)
|
|
352
|
+
for (let i = 1; i < displayRoles.length; i++) {
|
|
353
|
+
for (let j = 1; j < displayRoles.length; j++) {
|
|
354
|
+
if (i === j)
|
|
355
|
+
continue;
|
|
356
|
+
const fromDir = displayRoles[i].dir;
|
|
357
|
+
const toDir = displayRoles[j].dir;
|
|
358
|
+
const crossImports = entries.filter(([, info]) => (info.dir || ".") === fromDir && info.imports.some((imp) => graph[imp] && (graph[imp].dir || ".") === toDir));
|
|
359
|
+
if (crossImports.length > 0 && messages.length < 10) {
|
|
360
|
+
messages.push({ from: i, to: j, label: `${crossImports.length} calls` });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (messages.length < 2)
|
|
365
|
+
return "";
|
|
366
|
+
// Draw UML sequence diagram
|
|
367
|
+
const lifelineSpacing = 130;
|
|
368
|
+
const W = displayRoles.length * lifelineSpacing + 40;
|
|
369
|
+
const messageH = 40;
|
|
370
|
+
const headerH = 55;
|
|
371
|
+
const H = headerH + messages.length * messageH + 30;
|
|
372
|
+
let svg = "";
|
|
373
|
+
// Participant boxes
|
|
374
|
+
for (let i = 0; i < displayRoles.length; i++) {
|
|
375
|
+
const x = 20 + i * lifelineSpacing + lifelineSpacing / 2;
|
|
376
|
+
const role = displayRoles[i];
|
|
377
|
+
const label = role.name;
|
|
378
|
+
const subtitle = role.modules > 1 ? `(${role.modules})` : "";
|
|
379
|
+
const boxW = Math.max(70, label.length * 7 + 20);
|
|
380
|
+
svg += `<rect x="${x - boxW / 2}" y="6" width="${boxW}" height="${subtitle ? 30 : 22}" rx="4" fill="#ffffff08" stroke="#ffffff15"/>`;
|
|
381
|
+
svg += `<text x="${x}" y="20" text-anchor="middle" fill="#e5e5e5" font-size="9" font-weight="700">${label}</text>`;
|
|
382
|
+
if (subtitle)
|
|
383
|
+
svg += `<text x="${x}" y="31" text-anchor="middle" fill="#4b5563" font-size="7">${subtitle}</text>`;
|
|
384
|
+
svg += `<line x1="${x}" y1="${subtitle ? 36 : 28}" x2="${x}" y2="${H - 10}" stroke="#ffffff10" stroke-width="1" stroke-dasharray="4,3"/>`;
|
|
385
|
+
}
|
|
386
|
+
// Messages
|
|
387
|
+
for (let i = 0; i < messages.length; i++) {
|
|
388
|
+
const msg = messages[i];
|
|
389
|
+
const fromX = 20 + msg.from * lifelineSpacing + lifelineSpacing / 2;
|
|
390
|
+
const toX = 20 + msg.to * lifelineSpacing + lifelineSpacing / 2;
|
|
391
|
+
const y = headerH + i * messageH;
|
|
392
|
+
const isReturn = msg.to < msg.from;
|
|
393
|
+
const color = isReturn ? "#4b5563" : "#6d78d0";
|
|
394
|
+
const dash = isReturn ? ' stroke-dasharray="4,2"' : "";
|
|
395
|
+
svg += `<line x1="${fromX}" y1="${y}" x2="${toX + (toX > fromX ? -6 : 6)}" y2="${y}" stroke="${color}" stroke-width="1.5" marker-end="url(#seq-arrow)"${dash}/>`;
|
|
396
|
+
svg += `<text x="${(fromX + toX) / 2}" y="${y - 6}" text-anchor="middle" fill="#6b7280" font-size="7">${msg.label}</text>`;
|
|
397
|
+
}
|
|
398
|
+
const defs = `<defs><marker id="seq-arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="7" markerHeight="5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#6d78d0"/></marker></defs>`;
|
|
399
|
+
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:${W}px">${defs}${svg}</svg>`;
|
|
400
|
+
}
|
|
401
|
+
// ── Layer Diagram ────────────────────────────────────────────────────
|
|
402
|
+
// Detects application layers (MVC, Clean Architecture, etc.) from module behavior.
|
|
403
|
+
// Layers are determined by fan-in/fan-out patterns + naming conventions.
|
|
404
|
+
export function generateLayerDiagram(details) {
|
|
405
|
+
const graph = details.graph;
|
|
406
|
+
if (!graph || Object.keys(graph).length < 5)
|
|
407
|
+
return "";
|
|
408
|
+
const entries = Object.entries(graph);
|
|
409
|
+
const layerDefs = [
|
|
410
|
+
{ id: "entry", label: "Entry / Controller", color: "#6d78d0" },
|
|
411
|
+
{ id: "view", label: "View / Output", color: "#06b6d4" },
|
|
412
|
+
{ id: "service", label: "Service / Logic", color: "#22c55e" },
|
|
413
|
+
{ id: "data", label: "Data / IO", color: "#d97706" },
|
|
414
|
+
{ id: "model", label: "Model / Types", color: "#8b5cf6" },
|
|
415
|
+
];
|
|
416
|
+
const moduleLayer = new Map();
|
|
417
|
+
for (const [path, info] of entries) {
|
|
418
|
+
const name = basename(path, extname(path));
|
|
419
|
+
const fanIn = info.importedBy.length;
|
|
420
|
+
const fanOut = info.imports.length;
|
|
421
|
+
let layer = "service";
|
|
422
|
+
if (fanIn === 0 && fanOut > 5)
|
|
423
|
+
layer = "entry";
|
|
424
|
+
else if (fanIn > 10 && fanOut === 0)
|
|
425
|
+
layer = "model";
|
|
426
|
+
else if (fanIn > 5 && fanOut <= 1)
|
|
427
|
+
layer = "model";
|
|
428
|
+
else if (path.includes("report") ||
|
|
429
|
+
path.includes("html") ||
|
|
430
|
+
path.includes("svg") ||
|
|
431
|
+
path.includes("page") ||
|
|
432
|
+
path.includes("style") ||
|
|
433
|
+
path.includes("component"))
|
|
434
|
+
layer = "view";
|
|
435
|
+
else if (name === "types" || name === "check-meta" || path.includes("types"))
|
|
436
|
+
layer = "model";
|
|
437
|
+
else if (name === "exec" || name === "detect" || name.includes("fs-") || path.includes("history"))
|
|
438
|
+
layer = "data";
|
|
439
|
+
else if (path.includes("runner") || path.includes("check"))
|
|
440
|
+
layer = "service";
|
|
441
|
+
else if (fanOut > fanIn * 2)
|
|
442
|
+
layer = "entry";
|
|
443
|
+
moduleLayer.set(path, layer);
|
|
444
|
+
}
|
|
445
|
+
// Count modules per layer
|
|
446
|
+
const layerCounts = new Map();
|
|
447
|
+
for (const [path, layer] of moduleLayer) {
|
|
448
|
+
const arr = layerCounts.get(layer) || [];
|
|
449
|
+
arr.push(basename(path, extname(path)));
|
|
450
|
+
layerCounts.set(layer, arr);
|
|
451
|
+
}
|
|
452
|
+
// Count violations (imports going UP the stack)
|
|
453
|
+
const layerOrder = ["entry", "view", "service", "data", "model"];
|
|
454
|
+
let violations = 0;
|
|
455
|
+
let _totalCrossLayer = 0;
|
|
456
|
+
for (const [path, info] of entries) {
|
|
457
|
+
const myLayer = moduleLayer.get(path);
|
|
458
|
+
const myIdx = layerOrder.indexOf(myLayer);
|
|
459
|
+
for (const imp of info.imports) {
|
|
460
|
+
const impLayer = moduleLayer.get(imp);
|
|
461
|
+
if (impLayer && impLayer !== myLayer) {
|
|
462
|
+
_totalCrossLayer++;
|
|
463
|
+
const impIdx = layerOrder.indexOf(impLayer);
|
|
464
|
+
if (impIdx < myIdx)
|
|
465
|
+
violations++; // importing from layer ABOVE = violation
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Draw
|
|
470
|
+
const W = 600;
|
|
471
|
+
const layerH = 50;
|
|
472
|
+
const gap = 6;
|
|
473
|
+
const padding = 20;
|
|
474
|
+
const activeLayers = layerDefs.filter((l) => (layerCounts.get(l.id)?.length || 0) > 0);
|
|
475
|
+
const H = padding * 2 + activeLayers.length * (layerH + gap) + 40;
|
|
476
|
+
let svg = "";
|
|
477
|
+
let y = padding;
|
|
478
|
+
// Title
|
|
479
|
+
svg += `<text x="${W / 2}" y="${y}" text-anchor="middle" fill="#9ca3af" font-size="10" font-weight="700">Application Layers</text>`;
|
|
480
|
+
y += 20;
|
|
481
|
+
for (const layer of activeLayers) {
|
|
482
|
+
const modules = layerCounts.get(layer.id) || [];
|
|
483
|
+
const moduleList = modules.slice(0, 8).join(", ") + (modules.length > 8 ? ` +${modules.length - 8}` : "");
|
|
484
|
+
// Layer band
|
|
485
|
+
svg += `<rect x="${padding}" y="${y}" width="${W - padding * 2}" height="${layerH}" rx="6" fill="${layer.color}10" stroke="${layer.color}40"/>`;
|
|
486
|
+
svg += `<text x="${padding + 12}" y="${y + 20}" fill="${layer.color}" font-size="10" font-weight="700">${layer.label}</text>`;
|
|
487
|
+
svg += `<text x="${padding + 12}" y="${y + 36}" fill="#6b7280" font-size="8">${moduleList}</text>`;
|
|
488
|
+
svg += `<text x="${W - padding - 12}" y="${y + 20}" text-anchor="end" fill="#4b5563" font-size="9">${modules.length}</text>`;
|
|
489
|
+
// Arrow down to next layer
|
|
490
|
+
if (activeLayers.indexOf(layer) < activeLayers.length - 1) {
|
|
491
|
+
const arrowY = y + layerH + gap / 2;
|
|
492
|
+
svg += `<line x1="${W / 2}" y1="${y + layerH}" x2="${W / 2}" y2="${arrowY + gap / 2}" stroke="#ffffff15" stroke-width="1" marker-end="url(#layer-arrow)"/>`;
|
|
493
|
+
}
|
|
494
|
+
y += layerH + gap;
|
|
495
|
+
}
|
|
496
|
+
// Violation indicator
|
|
497
|
+
if (violations > 0) {
|
|
498
|
+
svg += `<text x="${W / 2}" y="${y + 10}" text-anchor="middle" fill="var(--warn)" font-size="8">${violations} layer violation${violations > 1 ? "s" : ""} (imports going UP the stack)</text>`;
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
svg += `<text x="${W / 2}" y="${y + 10}" text-anchor="middle" fill="var(--pass)" font-size="8">Clean layering — all dependencies flow downward</text>`;
|
|
502
|
+
}
|
|
503
|
+
const defs = `<defs><marker id="layer-arrow" viewBox="0 0 10 7" refX="5" refY="3.5" markerWidth="6" markerHeight="4" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#ffffff30"/></marker></defs>`;
|
|
504
|
+
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:${W}px">${defs}${svg}</svg>`;
|
|
505
|
+
}
|
|
506
|
+
// ── Container Diagram ────────────────────────────────────────────────
|
|
507
|
+
// Auto-detects high-level system containers from config files:
|
|
508
|
+
// frontend, backend/API, database, worker, static site, etc.
|
|
509
|
+
export function generateContainerDiagram(cwd) {
|
|
510
|
+
const has = (f) => existsSync(join(cwd, f));
|
|
511
|
+
const containers = [];
|
|
512
|
+
// Detect containers from config files
|
|
513
|
+
if (has("src/App.tsx") || has("src/App.vue") || has("src/App.svelte") || has("web/src/App.tsx")) {
|
|
514
|
+
const tech = has("src/App.tsx") ? "React" : has("src/App.vue") ? "Vue" : "Svelte";
|
|
515
|
+
containers.push({ name: "Frontend", type: "webapp", tech });
|
|
516
|
+
}
|
|
517
|
+
if (has("wrangler.toml") || has("wrangler.json")) {
|
|
518
|
+
containers.push({ name: "Worker", type: "worker", tech: "Cloudflare Workers" });
|
|
519
|
+
}
|
|
520
|
+
if (has("Dockerfile") || has("server.ts") || has("src/server.ts") || has("src/index.ts")) {
|
|
521
|
+
if (!containers.some((c) => c.name === "Frontend")) {
|
|
522
|
+
containers.push({ name: "API Server", type: "api", tech: "Node.js" });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (has("prisma/schema.prisma") || has("drizzle.config.ts")) {
|
|
526
|
+
const tech = has("prisma/schema.prisma") ? "Prisma" : "Drizzle";
|
|
527
|
+
containers.push({ name: "Database", type: "db", tech });
|
|
528
|
+
}
|
|
529
|
+
if (has("firebase.json") || has(".firebaserc")) {
|
|
530
|
+
containers.push({ name: "Firebase", type: "baas", tech: "Firebase" });
|
|
531
|
+
}
|
|
532
|
+
if (has("supabase/config.toml") || has(".supabase")) {
|
|
533
|
+
containers.push({ name: "Supabase", type: "baas", tech: "Supabase" });
|
|
534
|
+
}
|
|
535
|
+
if (has("pubspec.yaml")) {
|
|
536
|
+
containers.push({ name: "Mobile App", type: "mobile", tech: "Flutter" });
|
|
537
|
+
}
|
|
538
|
+
if (has("package.json") && !containers.length) {
|
|
539
|
+
containers.push({ name: "Application", type: "app", tech: "Node.js" });
|
|
540
|
+
}
|
|
541
|
+
if (containers.length < 2)
|
|
542
|
+
return ""; // Only interesting with 2+ containers
|
|
543
|
+
// Layout: horizontal boxes with connecting lines
|
|
544
|
+
const boxW = 140;
|
|
545
|
+
const boxH = 60;
|
|
546
|
+
const gap = 30;
|
|
547
|
+
const W = containers.length * (boxW + gap) + gap;
|
|
548
|
+
const H = 120;
|
|
549
|
+
const typeColors = {
|
|
550
|
+
webapp: "#6d78d0",
|
|
551
|
+
worker: "#d97706",
|
|
552
|
+
api: "#22c55e",
|
|
553
|
+
db: "#8b5cf6",
|
|
554
|
+
baas: "#ec4899",
|
|
555
|
+
mobile: "#06b6d4",
|
|
556
|
+
app: "#6d78d0",
|
|
557
|
+
};
|
|
558
|
+
let svg = "";
|
|
559
|
+
for (let i = 0; i < containers.length; i++) {
|
|
560
|
+
const c = containers[i];
|
|
561
|
+
const x = gap + i * (boxW + gap);
|
|
562
|
+
const y = (H - boxH) / 2;
|
|
563
|
+
const color = typeColors[c.type] || "#6d78d0";
|
|
564
|
+
// Box
|
|
565
|
+
svg += `<rect x="${x}" y="${y}" width="${boxW}" height="${boxH}" rx="8" fill="${color}15" stroke="${color}50"/>`;
|
|
566
|
+
// Name
|
|
567
|
+
svg += `<text x="${x + boxW / 2}" y="${y + 24}" text-anchor="middle" fill="#e5e5e5" font-size="10" font-weight="700">${c.name}</text>`;
|
|
568
|
+
// Tech
|
|
569
|
+
svg += `<text x="${x + boxW / 2}" y="${y + 40}" text-anchor="middle" fill="#6b7280" font-size="8">[${c.tech}]</text>`;
|
|
570
|
+
// Connection to next
|
|
571
|
+
if (i < containers.length - 1) {
|
|
572
|
+
const ax = x + boxW;
|
|
573
|
+
const bx = ax + gap;
|
|
574
|
+
const ay = H / 2;
|
|
575
|
+
svg += `<line x1="${ax}" y1="${ay}" x2="${bx}" y2="${ay}" stroke="#ffffff20" stroke-width="1.5" marker-end="url(#cont-arrow)"/>`;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const defs = `<defs><marker id="cont-arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="7" markerHeight="5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#ffffff40"/></marker></defs>`;
|
|
579
|
+
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:${W}px">${defs}${svg}</svg>`;
|
|
580
|
+
}
|
|
@@ -49,7 +49,7 @@ export function runDuplication(cwd) {
|
|
|
49
49
|
// Find blocks that appear in 2+ locations
|
|
50
50
|
const duplicates = [];
|
|
51
51
|
const seen = new Set();
|
|
52
|
-
for (const [
|
|
52
|
+
for (const [key, locs] of lineMap) {
|
|
53
53
|
if (locs.length < 2)
|
|
54
54
|
continue;
|
|
55
55
|
// Deduplicate: same file, adjacent lines are the same block
|
|
@@ -64,13 +64,16 @@ export function runDuplication(cwd) {
|
|
|
64
64
|
if (seen.has(pairKey))
|
|
65
65
|
continue;
|
|
66
66
|
seen.add(pairKey);
|
|
67
|
-
duplicates.push({ fileA: a.file, lineA: a.line, fileB: b.file, lineB: b.line, lines: MIN_LINES });
|
|
67
|
+
duplicates.push({ fileA: a.file, lineA: a.line, fileB: b.file, lineB: b.line, lines: MIN_LINES, content: key });
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
for (const d of duplicates.slice(0, 20)) {
|
|
71
|
+
// Show first 2 lines of the duplicated content as preview
|
|
72
|
+
const preview = d.content.split("\n").slice(0, 2).join(" | ");
|
|
73
|
+
const truncated = preview.length > 80 ? `${preview.slice(0, 80)}...` : preview;
|
|
71
74
|
issues.push({
|
|
72
75
|
severity: "warning",
|
|
73
|
-
message:
|
|
76
|
+
message: `Duplicate (${d.lines} lines): ${truncated}`,
|
|
74
77
|
file: `${d.fileA}:${d.lineA} ↔ ${d.fileB}:${d.lineB}`,
|
|
75
78
|
rule: "duplicate-code",
|
|
76
79
|
});
|
package/dist/runners/testing.js
CHANGED
|
@@ -197,7 +197,7 @@ function analyzeQuality(testFiles) {
|
|
|
197
197
|
testsWithNoAssertions,
|
|
198
198
|
mockRatio: totalAssertions > 0 ? Math.round((totalMocks / totalAssertions) * 100) / 100 : 0,
|
|
199
199
|
snapshotRatio: totalAssertions > 0 ? Math.round((totalSnapshots / totalAssertions) * 100) / 100 : 0,
|
|
200
|
-
emptyDescribes: 0,
|
|
200
|
+
emptyDescribes: 0,
|
|
201
201
|
wellNamedTests: totalIts, // simplified for v0.2
|
|
202
202
|
totalTests: totalIts,
|
|
203
203
|
};
|