tlc-claude-code 1.2.29 → 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/dashboard/dist/components/UsagePane.d.ts +13 -0
- package/dashboard/dist/components/UsagePane.js +51 -0
- package/dashboard/dist/components/UsagePane.test.d.ts +1 -0
- package/dashboard/dist/components/UsagePane.test.js +142 -0
- package/dashboard/dist/components/WorkspaceDocsPane.d.ts +19 -0
- package/dashboard/dist/components/WorkspaceDocsPane.js +146 -0
- package/dashboard/dist/components/WorkspaceDocsPane.test.d.ts +1 -0
- package/dashboard/dist/components/WorkspaceDocsPane.test.js +242 -0
- package/dashboard/dist/components/WorkspacePane.d.ts +18 -0
- package/dashboard/dist/components/WorkspacePane.js +17 -0
- package/dashboard/dist/components/WorkspacePane.test.d.ts +1 -0
- package/dashboard/dist/components/WorkspacePane.test.js +84 -0
- package/package.json +1 -1
- package/server/lib/architecture-command.js +450 -0
- package/server/lib/architecture-command.test.js +754 -0
- package/server/lib/ast-analyzer.js +324 -0
- package/server/lib/ast-analyzer.test.js +437 -0
- package/server/lib/auth-system.test.js +4 -1
- package/server/lib/boundary-detector.js +427 -0
- package/server/lib/boundary-detector.test.js +320 -0
- package/server/lib/budget-alerts.js +138 -0
- package/server/lib/budget-alerts.test.js +235 -0
- package/server/lib/candidates-tracker.js +210 -0
- package/server/lib/candidates-tracker.test.js +300 -0
- package/server/lib/checkpoint-manager.js +251 -0
- package/server/lib/checkpoint-manager.test.js +474 -0
- package/server/lib/circular-detector.js +337 -0
- package/server/lib/circular-detector.test.js +353 -0
- package/server/lib/cohesion-analyzer.js +310 -0
- package/server/lib/cohesion-analyzer.test.js +447 -0
- package/server/lib/contract-testing.js +625 -0
- package/server/lib/contract-testing.test.js +342 -0
- package/server/lib/conversion-planner.js +469 -0
- package/server/lib/conversion-planner.test.js +361 -0
- package/server/lib/convert-command.js +351 -0
- package/server/lib/convert-command.test.js +608 -0
- package/server/lib/coupling-calculator.js +189 -0
- package/server/lib/coupling-calculator.test.js +509 -0
- package/server/lib/dependency-graph.js +367 -0
- package/server/lib/dependency-graph.test.js +516 -0
- package/server/lib/duplication-detector.js +349 -0
- package/server/lib/duplication-detector.test.js +401 -0
- package/server/lib/example-service.js +616 -0
- package/server/lib/example-service.test.js +397 -0
- package/server/lib/impact-scorer.js +184 -0
- package/server/lib/impact-scorer.test.js +211 -0
- package/server/lib/mermaid-generator.js +358 -0
- package/server/lib/mermaid-generator.test.js +301 -0
- package/server/lib/messaging-patterns.js +750 -0
- package/server/lib/messaging-patterns.test.js +213 -0
- package/server/lib/microservice-template.js +386 -0
- package/server/lib/microservice-template.test.js +325 -0
- package/server/lib/new-project-microservice.js +450 -0
- package/server/lib/new-project-microservice.test.js +600 -0
- package/server/lib/refactor-command.js +326 -0
- package/server/lib/refactor-command.test.js +528 -0
- package/server/lib/refactor-executor.js +254 -0
- package/server/lib/refactor-executor.test.js +305 -0
- package/server/lib/refactor-observer.js +292 -0
- package/server/lib/refactor-observer.test.js +422 -0
- package/server/lib/refactor-progress.js +193 -0
- package/server/lib/refactor-progress.test.js +251 -0
- package/server/lib/refactor-reporter.js +237 -0
- package/server/lib/refactor-reporter.test.js +247 -0
- package/server/lib/semantic-analyzer.js +198 -0
- package/server/lib/semantic-analyzer.test.js +474 -0
- package/server/lib/service-scaffold.js +486 -0
- package/server/lib/service-scaffold.test.js +373 -0
- package/server/lib/shared-kernel.js +578 -0
- package/server/lib/shared-kernel.test.js +255 -0
- package/server/lib/traefik-config.js +282 -0
- package/server/lib/traefik-config.test.js +312 -0
- package/server/lib/usage-command.js +218 -0
- package/server/lib/usage-command.test.js +391 -0
- package/server/lib/usage-formatter.js +192 -0
- package/server/lib/usage-formatter.test.js +267 -0
- package/server/lib/usage-history.js +122 -0
- package/server/lib/usage-history.test.js +206 -0
- package/server/package-lock.json +14 -0
- package/server/package.json +1 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circular Dependency Detector
|
|
3
|
+
* Detects and reports circular dependencies in dependency graphs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
class CircularDetector {
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
this.basePath = options.basePath || process.cwd();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Detect all circular dependencies in a dependency graph
|
|
16
|
+
* @param {Object} graphOrInstance - Either a DependencyGraph instance or graph data { nodes, edges }
|
|
17
|
+
* @returns {Object} Detection results with cycles, suggestions, and visualization
|
|
18
|
+
*/
|
|
19
|
+
detect(graphOrInstance) {
|
|
20
|
+
// Support both DependencyGraph instance and raw graph data
|
|
21
|
+
const graphData = graphOrInstance.getGraph
|
|
22
|
+
? graphOrInstance.getGraph()
|
|
23
|
+
: graphOrInstance;
|
|
24
|
+
|
|
25
|
+
const { nodes, edges } = graphData;
|
|
26
|
+
|
|
27
|
+
// Build adjacency list for cycle detection
|
|
28
|
+
const adjacency = this.buildAdjacencyList(nodes, edges);
|
|
29
|
+
|
|
30
|
+
// Find all cycles using Tarjan's algorithm variant
|
|
31
|
+
const cycles = this.findAllCycles(adjacency);
|
|
32
|
+
|
|
33
|
+
// Generate suggestions for breaking cycles
|
|
34
|
+
const suggestions = this.generateSuggestions(cycles, adjacency);
|
|
35
|
+
|
|
36
|
+
// Create visualization
|
|
37
|
+
const visualization = this.visualize(cycles);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
hasCycles: cycles.length > 0,
|
|
41
|
+
cycleCount: cycles.length,
|
|
42
|
+
cycles: cycles.map(cycle => ({
|
|
43
|
+
path: cycle,
|
|
44
|
+
pathNames: cycle.map(f => this.relativePath(f)),
|
|
45
|
+
length: cycle.length,
|
|
46
|
+
})),
|
|
47
|
+
suggestions,
|
|
48
|
+
visualization,
|
|
49
|
+
stats: {
|
|
50
|
+
totalNodes: nodes.length,
|
|
51
|
+
totalEdges: edges.length,
|
|
52
|
+
nodesInCycles: new Set(cycles.flat()).size,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build adjacency list from nodes and edges
|
|
59
|
+
*/
|
|
60
|
+
buildAdjacencyList(nodes, edges) {
|
|
61
|
+
const adjacency = new Map();
|
|
62
|
+
|
|
63
|
+
// Initialize all nodes
|
|
64
|
+
for (const node of nodes) {
|
|
65
|
+
adjacency.set(node.id, { imports: [], importedBy: [] });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Add edges
|
|
69
|
+
for (const edge of edges) {
|
|
70
|
+
if (adjacency.has(edge.from)) {
|
|
71
|
+
adjacency.get(edge.from).imports.push(edge.to);
|
|
72
|
+
}
|
|
73
|
+
if (adjacency.has(edge.to)) {
|
|
74
|
+
adjacency.get(edge.to).importedBy.push(edge.from);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return adjacency;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Find all cycles using Johnson's algorithm variant
|
|
83
|
+
* Returns unique cycles (no duplicates, no rotations)
|
|
84
|
+
*/
|
|
85
|
+
findAllCycles(adjacency) {
|
|
86
|
+
const cycles = [];
|
|
87
|
+
const nodes = Array.from(adjacency.keys());
|
|
88
|
+
|
|
89
|
+
for (const startNode of nodes) {
|
|
90
|
+
const visited = new Set();
|
|
91
|
+
const stack = [];
|
|
92
|
+
|
|
93
|
+
this.dfs(startNode, startNode, adjacency, visited, stack, cycles);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Remove duplicate cycles (same cycle starting from different nodes)
|
|
97
|
+
return this.deduplicateCycles(cycles);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* DFS to find cycles starting from a specific node
|
|
102
|
+
*/
|
|
103
|
+
dfs(node, startNode, adjacency, visited, stack, cycles) {
|
|
104
|
+
if (stack.includes(node)) {
|
|
105
|
+
// Found a cycle
|
|
106
|
+
const cycleStart = stack.indexOf(node);
|
|
107
|
+
const cycle = stack.slice(cycleStart);
|
|
108
|
+
cycles.push([...cycle, node]); // Include the starting node at end to show cycle
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (visited.has(node)) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
visited.add(node);
|
|
117
|
+
stack.push(node);
|
|
118
|
+
|
|
119
|
+
const nodeData = adjacency.get(node);
|
|
120
|
+
if (nodeData) {
|
|
121
|
+
for (const imp of nodeData.imports) {
|
|
122
|
+
// Only look for cycles that include the start node
|
|
123
|
+
// to avoid finding the same cycle from multiple starting points
|
|
124
|
+
if (adjacency.has(imp)) {
|
|
125
|
+
this.dfs(imp, startNode, adjacency, visited, stack, cycles);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
stack.pop();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Remove duplicate cycles (rotations of the same cycle)
|
|
135
|
+
*/
|
|
136
|
+
deduplicateCycles(cycles) {
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
const unique = [];
|
|
139
|
+
|
|
140
|
+
for (const cycle of cycles) {
|
|
141
|
+
// Normalize: find minimum rotation
|
|
142
|
+
const normalized = this.normalizeCycle(cycle);
|
|
143
|
+
const key = normalized.join('|');
|
|
144
|
+
|
|
145
|
+
if (!seen.has(key)) {
|
|
146
|
+
seen.add(key);
|
|
147
|
+
unique.push(normalized);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return unique;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Normalize a cycle to its canonical form (minimum rotation)
|
|
156
|
+
*/
|
|
157
|
+
normalizeCycle(cycle) {
|
|
158
|
+
// Remove the duplicate end node if present (A -> B -> A becomes [A, B])
|
|
159
|
+
const cleanCycle = cycle.slice(0, -1);
|
|
160
|
+
if (cleanCycle.length === 0) return cycle;
|
|
161
|
+
|
|
162
|
+
// Find minimum rotation
|
|
163
|
+
let min = cleanCycle;
|
|
164
|
+
for (let i = 1; i < cleanCycle.length; i++) {
|
|
165
|
+
const rotated = [...cleanCycle.slice(i), ...cleanCycle.slice(0, i)];
|
|
166
|
+
if (rotated.join('|') < min.join('|')) {
|
|
167
|
+
min = rotated;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return min;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Generate suggestions for breaking cycles
|
|
176
|
+
*/
|
|
177
|
+
generateSuggestions(cycles, adjacency) {
|
|
178
|
+
const suggestions = [];
|
|
179
|
+
|
|
180
|
+
for (let i = 0; i < cycles.length; i++) {
|
|
181
|
+
const cycle = cycles[i];
|
|
182
|
+
const suggestion = this.suggestBreakPoint(cycle, adjacency);
|
|
183
|
+
suggestions.push({
|
|
184
|
+
cycleIndex: i,
|
|
185
|
+
...suggestion,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return suggestions;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Suggest the best point to break a cycle
|
|
194
|
+
* Prefers breaking at the node with:
|
|
195
|
+
* 1. Fewest importers (less impact)
|
|
196
|
+
* 2. Most imports (likely a "leaf" that should not be importing its parents)
|
|
197
|
+
*/
|
|
198
|
+
suggestBreakPoint(cycle, adjacency) {
|
|
199
|
+
let bestNode = cycle[0];
|
|
200
|
+
let bestScore = Infinity;
|
|
201
|
+
let bestEdge = null;
|
|
202
|
+
|
|
203
|
+
for (let i = 0; i < cycle.length; i++) {
|
|
204
|
+
const from = cycle[i];
|
|
205
|
+
const to = cycle[(i + 1) % cycle.length];
|
|
206
|
+
const fromData = adjacency.get(from);
|
|
207
|
+
|
|
208
|
+
if (!fromData) continue;
|
|
209
|
+
|
|
210
|
+
// Score: number of importers (lower is better to break)
|
|
211
|
+
// We want to break edges where the "from" node has few importers
|
|
212
|
+
const score = fromData.importedBy.length;
|
|
213
|
+
|
|
214
|
+
if (score < bestScore) {
|
|
215
|
+
bestScore = score;
|
|
216
|
+
bestNode = from;
|
|
217
|
+
bestEdge = { from, to };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
breakAt: bestNode,
|
|
223
|
+
breakAtName: this.relativePath(bestNode),
|
|
224
|
+
removeImport: bestEdge ? {
|
|
225
|
+
from: bestEdge.from,
|
|
226
|
+
fromName: this.relativePath(bestEdge.from),
|
|
227
|
+
to: bestEdge.to,
|
|
228
|
+
toName: this.relativePath(bestEdge.to),
|
|
229
|
+
} : null,
|
|
230
|
+
reason: `${this.relativePath(bestNode)} has fewest dependents (${bestScore}), making it safer to refactor`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Create ASCII visualization of cycles
|
|
236
|
+
*/
|
|
237
|
+
visualize(cycles) {
|
|
238
|
+
if (cycles.length === 0) {
|
|
239
|
+
return 'No circular dependencies detected.';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const lines = [
|
|
243
|
+
'='.repeat(50),
|
|
244
|
+
'CIRCULAR DEPENDENCIES DETECTED',
|
|
245
|
+
'='.repeat(50),
|
|
246
|
+
'',
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
for (let i = 0; i < cycles.length; i++) {
|
|
250
|
+
const cycle = cycles[i];
|
|
251
|
+
lines.push(`Cycle ${i + 1}:`);
|
|
252
|
+
lines.push(this.visualizeCycle(cycle));
|
|
253
|
+
lines.push('');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return lines.join('\n');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Visualize a single cycle
|
|
261
|
+
*/
|
|
262
|
+
visualizeCycle(cycle) {
|
|
263
|
+
const names = cycle.map(f => this.relativePath(f));
|
|
264
|
+
const lines = [];
|
|
265
|
+
|
|
266
|
+
// Show chain: A -> B -> C -> A
|
|
267
|
+
const chain = [...names, names[0]].join(' -> ');
|
|
268
|
+
lines.push(` ${chain}`);
|
|
269
|
+
|
|
270
|
+
// ASCII box visualization
|
|
271
|
+
lines.push('');
|
|
272
|
+
const maxLen = Math.max(...names.map(n => n.length));
|
|
273
|
+
|
|
274
|
+
for (let i = 0; i < names.length; i++) {
|
|
275
|
+
const name = names[i].padEnd(maxLen);
|
|
276
|
+
const arrow = i < names.length - 1 ? '|' : '|';
|
|
277
|
+
const connector = i < names.length - 1 ? 'v' : '^-- (back to start)';
|
|
278
|
+
|
|
279
|
+
lines.push(` +${'-'.repeat(maxLen + 2)}+`);
|
|
280
|
+
lines.push(` | ${name} |`);
|
|
281
|
+
lines.push(` +${'-'.repeat(maxLen + 2)}+`);
|
|
282
|
+
|
|
283
|
+
if (i < names.length - 1) {
|
|
284
|
+
lines.push(` ${arrow}`);
|
|
285
|
+
lines.push(` ${connector}`);
|
|
286
|
+
} else {
|
|
287
|
+
lines.push(` ${arrow}`);
|
|
288
|
+
lines.push(` ^-- (back to ${names[0]})`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return lines.join('\n');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Get relative path for display
|
|
297
|
+
*/
|
|
298
|
+
relativePath(filePath) {
|
|
299
|
+
if (!filePath) return '';
|
|
300
|
+
return path.isAbsolute(filePath)
|
|
301
|
+
? path.relative(this.basePath, filePath)
|
|
302
|
+
: filePath;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Quick check if graph has any cycles (fast boolean check)
|
|
307
|
+
* @param {Object} graphOrInstance - DependencyGraph instance or graph data
|
|
308
|
+
* @returns {boolean}
|
|
309
|
+
*/
|
|
310
|
+
hasCycles(graphOrInstance) {
|
|
311
|
+
// If it's a DependencyGraph instance with hasCircular method, use it
|
|
312
|
+
if (graphOrInstance.hasCircular) {
|
|
313
|
+
return graphOrInstance.hasCircular();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Otherwise do our own detection
|
|
317
|
+
const result = this.detect(graphOrInstance);
|
|
318
|
+
return result.hasCycles;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Get just the cycle paths without suggestions (lightweight)
|
|
323
|
+
* @param {Object} graphOrInstance - DependencyGraph instance or graph data
|
|
324
|
+
* @returns {Array} Array of cycle paths
|
|
325
|
+
*/
|
|
326
|
+
getCycles(graphOrInstance) {
|
|
327
|
+
const graphData = graphOrInstance.getGraph
|
|
328
|
+
? graphOrInstance.getGraph()
|
|
329
|
+
: graphOrInstance;
|
|
330
|
+
|
|
331
|
+
const { nodes, edges } = graphData;
|
|
332
|
+
const adjacency = this.buildAdjacencyList(nodes, edges);
|
|
333
|
+
return this.findAllCycles(adjacency);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = { CircularDetector };
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circular Dependency Detector Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
|
|
7
|
+
describe('CircularDetector', () => {
|
|
8
|
+
describe('direct cycles', () => {
|
|
9
|
+
it('detects A → B → A cycle', async () => {
|
|
10
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
11
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
12
|
+
|
|
13
|
+
const graph = {
|
|
14
|
+
nodes: [
|
|
15
|
+
{ id: '/project/a.js', name: 'a.js' },
|
|
16
|
+
{ id: '/project/b.js', name: 'b.js' },
|
|
17
|
+
],
|
|
18
|
+
edges: [
|
|
19
|
+
{ from: '/project/a.js', to: '/project/b.js' },
|
|
20
|
+
{ from: '/project/b.js', to: '/project/a.js' },
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const result = detector.detect(graph);
|
|
25
|
+
|
|
26
|
+
expect(result.hasCycles).toBe(true);
|
|
27
|
+
expect(result.cycleCount).toBe(1);
|
|
28
|
+
expect(result.cycles[0].length).toBe(2);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('detects self-import cycle', async () => {
|
|
32
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
33
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
34
|
+
|
|
35
|
+
const graph = {
|
|
36
|
+
nodes: [{ id: '/project/a.js', name: 'a.js' }],
|
|
37
|
+
edges: [{ from: '/project/a.js', to: '/project/a.js' }],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const result = detector.detect(graph);
|
|
41
|
+
|
|
42
|
+
expect(result.hasCycles).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('indirect cycles', () => {
|
|
47
|
+
it('detects A → B → C → A cycle', async () => {
|
|
48
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
49
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
50
|
+
|
|
51
|
+
const graph = {
|
|
52
|
+
nodes: [
|
|
53
|
+
{ id: '/project/a.js', name: 'a.js' },
|
|
54
|
+
{ id: '/project/b.js', name: 'b.js' },
|
|
55
|
+
{ id: '/project/c.js', name: 'c.js' },
|
|
56
|
+
],
|
|
57
|
+
edges: [
|
|
58
|
+
{ from: '/project/a.js', to: '/project/b.js' },
|
|
59
|
+
{ from: '/project/b.js', to: '/project/c.js' },
|
|
60
|
+
{ from: '/project/c.js', to: '/project/a.js' },
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const result = detector.detect(graph);
|
|
65
|
+
|
|
66
|
+
expect(result.hasCycles).toBe(true);
|
|
67
|
+
expect(result.cycleCount).toBe(1);
|
|
68
|
+
expect(result.cycles[0].length).toBe(3);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('detects longer chain cycle', async () => {
|
|
72
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
73
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
74
|
+
|
|
75
|
+
const graph = {
|
|
76
|
+
nodes: [
|
|
77
|
+
{ id: '/project/a.js', name: 'a.js' },
|
|
78
|
+
{ id: '/project/b.js', name: 'b.js' },
|
|
79
|
+
{ id: '/project/c.js', name: 'c.js' },
|
|
80
|
+
{ id: '/project/d.js', name: 'd.js' },
|
|
81
|
+
{ id: '/project/e.js', name: 'e.js' },
|
|
82
|
+
],
|
|
83
|
+
edges: [
|
|
84
|
+
{ from: '/project/a.js', to: '/project/b.js' },
|
|
85
|
+
{ from: '/project/b.js', to: '/project/c.js' },
|
|
86
|
+
{ from: '/project/c.js', to: '/project/d.js' },
|
|
87
|
+
{ from: '/project/d.js', to: '/project/e.js' },
|
|
88
|
+
{ from: '/project/e.js', to: '/project/a.js' },
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const result = detector.detect(graph);
|
|
93
|
+
|
|
94
|
+
expect(result.hasCycles).toBe(true);
|
|
95
|
+
expect(result.cycles[0].length).toBe(5);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('multiple cycles', () => {
|
|
100
|
+
it('finds multiple independent cycles', async () => {
|
|
101
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
102
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
103
|
+
|
|
104
|
+
const graph = {
|
|
105
|
+
nodes: [
|
|
106
|
+
{ id: '/project/a.js', name: 'a.js' },
|
|
107
|
+
{ id: '/project/b.js', name: 'b.js' },
|
|
108
|
+
{ id: '/project/x.js', name: 'x.js' },
|
|
109
|
+
{ id: '/project/y.js', name: 'y.js' },
|
|
110
|
+
],
|
|
111
|
+
edges: [
|
|
112
|
+
{ from: '/project/a.js', to: '/project/b.js' },
|
|
113
|
+
{ from: '/project/b.js', to: '/project/a.js' },
|
|
114
|
+
{ from: '/project/x.js', to: '/project/y.js' },
|
|
115
|
+
{ from: '/project/y.js', to: '/project/x.js' },
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const result = detector.detect(graph);
|
|
120
|
+
|
|
121
|
+
expect(result.hasCycles).toBe(true);
|
|
122
|
+
expect(result.cycleCount).toBe(2);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('no cycles', () => {
|
|
127
|
+
it('returns false for DAG (no cycles)', async () => {
|
|
128
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
129
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
130
|
+
|
|
131
|
+
const graph = {
|
|
132
|
+
nodes: [
|
|
133
|
+
{ id: '/project/a.js', name: 'a.js' },
|
|
134
|
+
{ id: '/project/b.js', name: 'b.js' },
|
|
135
|
+
{ id: '/project/c.js', name: 'c.js' },
|
|
136
|
+
],
|
|
137
|
+
edges: [
|
|
138
|
+
{ from: '/project/a.js', to: '/project/b.js' },
|
|
139
|
+
{ from: '/project/a.js', to: '/project/c.js' },
|
|
140
|
+
{ from: '/project/b.js', to: '/project/c.js' },
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const result = detector.detect(graph);
|
|
145
|
+
|
|
146
|
+
expect(result.hasCycles).toBe(false);
|
|
147
|
+
expect(result.cycleCount).toBe(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('handles isolated nodes', async () => {
|
|
151
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
152
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
153
|
+
|
|
154
|
+
const graph = {
|
|
155
|
+
nodes: [
|
|
156
|
+
{ id: '/project/a.js', name: 'a.js' },
|
|
157
|
+
{ id: '/project/b.js', name: 'b.js' },
|
|
158
|
+
],
|
|
159
|
+
edges: [],
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const result = detector.detect(graph);
|
|
163
|
+
|
|
164
|
+
expect(result.hasCycles).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('handles empty graph', async () => {
|
|
168
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
169
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
170
|
+
|
|
171
|
+
const graph = { nodes: [], edges: [] };
|
|
172
|
+
const result = detector.detect(graph);
|
|
173
|
+
|
|
174
|
+
expect(result.hasCycles).toBe(false);
|
|
175
|
+
expect(result.cycleCount).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('suggestions', () => {
|
|
180
|
+
it('suggests file to break cycle', async () => {
|
|
181
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
182
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
183
|
+
|
|
184
|
+
const graph = {
|
|
185
|
+
nodes: [
|
|
186
|
+
{ id: '/project/a.js', name: 'a.js' },
|
|
187
|
+
{ id: '/project/b.js', name: 'b.js' },
|
|
188
|
+
],
|
|
189
|
+
edges: [
|
|
190
|
+
{ from: '/project/a.js', to: '/project/b.js' },
|
|
191
|
+
{ from: '/project/b.js', to: '/project/a.js' },
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const result = detector.detect(graph);
|
|
196
|
+
|
|
197
|
+
expect(result.suggestions).toBeDefined();
|
|
198
|
+
expect(result.suggestions.length).toBe(1);
|
|
199
|
+
expect(result.suggestions[0].breakAt).toBeDefined();
|
|
200
|
+
expect(result.suggestions[0].reason).toContain('fewest dependents');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('suggests removing specific import', async () => {
|
|
204
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
205
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
206
|
+
|
|
207
|
+
const graph = {
|
|
208
|
+
nodes: [
|
|
209
|
+
{ id: '/project/a.js', name: 'a.js' },
|
|
210
|
+
{ id: '/project/b.js', name: 'b.js' },
|
|
211
|
+
{ id: '/project/c.js', name: 'c.js' },
|
|
212
|
+
],
|
|
213
|
+
edges: [
|
|
214
|
+
{ from: '/project/a.js', to: '/project/b.js' },
|
|
215
|
+
{ from: '/project/b.js', to: '/project/c.js' },
|
|
216
|
+
{ from: '/project/c.js', to: '/project/a.js' },
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const result = detector.detect(graph);
|
|
221
|
+
|
|
222
|
+
expect(result.suggestions[0].removeImport).toBeDefined();
|
|
223
|
+
expect(result.suggestions[0].removeImport.from).toBeDefined();
|
|
224
|
+
expect(result.suggestions[0].removeImport.to).toBeDefined();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('visualization', () => {
|
|
229
|
+
it('generates ASCII visualization', async () => {
|
|
230
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
231
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
232
|
+
|
|
233
|
+
const graph = {
|
|
234
|
+
nodes: [
|
|
235
|
+
{ id: '/project/a.js', name: 'a.js' },
|
|
236
|
+
{ id: '/project/b.js', name: 'b.js' },
|
|
237
|
+
],
|
|
238
|
+
edges: [
|
|
239
|
+
{ from: '/project/a.js', to: '/project/b.js' },
|
|
240
|
+
{ from: '/project/b.js', to: '/project/a.js' },
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const result = detector.detect(graph);
|
|
245
|
+
|
|
246
|
+
expect(result.visualization).toContain('CIRCULAR DEPENDENCIES');
|
|
247
|
+
expect(result.visualization).toContain('Cycle 1');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('shows no cycles message when clean', async () => {
|
|
251
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
252
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
253
|
+
|
|
254
|
+
const graph = {
|
|
255
|
+
nodes: [{ id: '/project/a.js', name: 'a.js' }],
|
|
256
|
+
edges: [],
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const result = detector.detect(graph);
|
|
260
|
+
|
|
261
|
+
expect(result.visualization).toContain('No circular dependencies');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('path output', () => {
|
|
266
|
+
it('reports cycle paths clearly', async () => {
|
|
267
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
268
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
269
|
+
|
|
270
|
+
const graph = {
|
|
271
|
+
nodes: [
|
|
272
|
+
{ id: '/project/src/a.js', name: 'src/a.js' },
|
|
273
|
+
{ id: '/project/src/b.js', name: 'src/b.js' },
|
|
274
|
+
],
|
|
275
|
+
edges: [
|
|
276
|
+
{ from: '/project/src/a.js', to: '/project/src/b.js' },
|
|
277
|
+
{ from: '/project/src/b.js', to: '/project/src/a.js' },
|
|
278
|
+
],
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const result = detector.detect(graph);
|
|
282
|
+
|
|
283
|
+
expect(result.cycles[0].pathNames).toContain('src/a.js');
|
|
284
|
+
expect(result.cycles[0].pathNames).toContain('src/b.js');
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('stats', () => {
|
|
289
|
+
it('provides useful stats', async () => {
|
|
290
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
291
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
292
|
+
|
|
293
|
+
const graph = {
|
|
294
|
+
nodes: [
|
|
295
|
+
{ id: '/project/a.js', name: 'a.js' },
|
|
296
|
+
{ id: '/project/b.js', name: 'b.js' },
|
|
297
|
+
{ id: '/project/c.js', name: 'c.js' },
|
|
298
|
+
],
|
|
299
|
+
edges: [
|
|
300
|
+
{ from: '/project/a.js', to: '/project/b.js' },
|
|
301
|
+
{ from: '/project/b.js', to: '/project/a.js' },
|
|
302
|
+
],
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const result = detector.detect(graph);
|
|
306
|
+
|
|
307
|
+
expect(result.stats).toBeDefined();
|
|
308
|
+
expect(result.stats.totalNodes).toBe(3);
|
|
309
|
+
expect(result.stats.totalEdges).toBe(2);
|
|
310
|
+
expect(result.stats.nodesInCycles).toBe(2);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe('helper methods', () => {
|
|
315
|
+
it('hasCycles returns boolean quickly', async () => {
|
|
316
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
317
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
318
|
+
|
|
319
|
+
const graph = {
|
|
320
|
+
nodes: [
|
|
321
|
+
{ id: '/project/a.js', name: 'a.js' },
|
|
322
|
+
{ id: '/project/b.js', name: 'b.js' },
|
|
323
|
+
],
|
|
324
|
+
edges: [
|
|
325
|
+
{ from: '/project/a.js', to: '/project/b.js' },
|
|
326
|
+
{ from: '/project/b.js', to: '/project/a.js' },
|
|
327
|
+
],
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
expect(detector.hasCycles(graph)).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('getCycles returns just paths', async () => {
|
|
334
|
+
const { CircularDetector } = await import('./circular-detector.js');
|
|
335
|
+
const detector = new CircularDetector({ basePath: '/project' });
|
|
336
|
+
|
|
337
|
+
const graph = {
|
|
338
|
+
nodes: [
|
|
339
|
+
{ id: '/project/a.js', name: 'a.js' },
|
|
340
|
+
{ id: '/project/b.js', name: 'b.js' },
|
|
341
|
+
],
|
|
342
|
+
edges: [
|
|
343
|
+
{ from: '/project/a.js', to: '/project/b.js' },
|
|
344
|
+
{ from: '/project/b.js', to: '/project/a.js' },
|
|
345
|
+
],
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const cycles = detector.getCycles(graph);
|
|
349
|
+
expect(Array.isArray(cycles)).toBe(true);
|
|
350
|
+
expect(cycles.length).toBe(1);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
});
|