tlc-claude-code 1.2.28 → 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.
Files changed (87) hide show
  1. package/README.md +9 -4
  2. package/dashboard/dist/components/UsagePane.d.ts +13 -0
  3. package/dashboard/dist/components/UsagePane.js +51 -0
  4. package/dashboard/dist/components/UsagePane.test.d.ts +1 -0
  5. package/dashboard/dist/components/UsagePane.test.js +142 -0
  6. package/dashboard/dist/components/WorkspaceDocsPane.d.ts +19 -0
  7. package/dashboard/dist/components/WorkspaceDocsPane.js +146 -0
  8. package/dashboard/dist/components/WorkspaceDocsPane.test.d.ts +1 -0
  9. package/dashboard/dist/components/WorkspaceDocsPane.test.js +242 -0
  10. package/dashboard/dist/components/WorkspacePane.d.ts +18 -0
  11. package/dashboard/dist/components/WorkspacePane.js +17 -0
  12. package/dashboard/dist/components/WorkspacePane.test.d.ts +1 -0
  13. package/dashboard/dist/components/WorkspacePane.test.js +84 -0
  14. package/package.json +15 -4
  15. package/scripts/capture-screenshots.js +170 -0
  16. package/scripts/docs-update.js +253 -0
  17. package/scripts/generate-screenshots.js +321 -0
  18. package/scripts/project-docs.js +377 -0
  19. package/scripts/vps-setup.sh +477 -0
  20. package/server/lib/architecture-command.js +450 -0
  21. package/server/lib/architecture-command.test.js +754 -0
  22. package/server/lib/ast-analyzer.js +324 -0
  23. package/server/lib/ast-analyzer.test.js +437 -0
  24. package/server/lib/auth-system.test.js +4 -1
  25. package/server/lib/boundary-detector.js +427 -0
  26. package/server/lib/boundary-detector.test.js +320 -0
  27. package/server/lib/budget-alerts.js +138 -0
  28. package/server/lib/budget-alerts.test.js +235 -0
  29. package/server/lib/candidates-tracker.js +210 -0
  30. package/server/lib/candidates-tracker.test.js +300 -0
  31. package/server/lib/checkpoint-manager.js +251 -0
  32. package/server/lib/checkpoint-manager.test.js +474 -0
  33. package/server/lib/circular-detector.js +337 -0
  34. package/server/lib/circular-detector.test.js +353 -0
  35. package/server/lib/cohesion-analyzer.js +310 -0
  36. package/server/lib/cohesion-analyzer.test.js +447 -0
  37. package/server/lib/contract-testing.js +625 -0
  38. package/server/lib/contract-testing.test.js +342 -0
  39. package/server/lib/conversion-planner.js +469 -0
  40. package/server/lib/conversion-planner.test.js +361 -0
  41. package/server/lib/convert-command.js +351 -0
  42. package/server/lib/convert-command.test.js +608 -0
  43. package/server/lib/coupling-calculator.js +189 -0
  44. package/server/lib/coupling-calculator.test.js +509 -0
  45. package/server/lib/dependency-graph.js +367 -0
  46. package/server/lib/dependency-graph.test.js +516 -0
  47. package/server/lib/duplication-detector.js +349 -0
  48. package/server/lib/duplication-detector.test.js +401 -0
  49. package/server/lib/example-service.js +616 -0
  50. package/server/lib/example-service.test.js +397 -0
  51. package/server/lib/impact-scorer.js +184 -0
  52. package/server/lib/impact-scorer.test.js +211 -0
  53. package/server/lib/mermaid-generator.js +358 -0
  54. package/server/lib/mermaid-generator.test.js +301 -0
  55. package/server/lib/messaging-patterns.js +750 -0
  56. package/server/lib/messaging-patterns.test.js +213 -0
  57. package/server/lib/microservice-template.js +386 -0
  58. package/server/lib/microservice-template.test.js +325 -0
  59. package/server/lib/new-project-microservice.js +450 -0
  60. package/server/lib/new-project-microservice.test.js +600 -0
  61. package/server/lib/refactor-command.js +326 -0
  62. package/server/lib/refactor-command.test.js +528 -0
  63. package/server/lib/refactor-executor.js +254 -0
  64. package/server/lib/refactor-executor.test.js +305 -0
  65. package/server/lib/refactor-observer.js +292 -0
  66. package/server/lib/refactor-observer.test.js +422 -0
  67. package/server/lib/refactor-progress.js +193 -0
  68. package/server/lib/refactor-progress.test.js +251 -0
  69. package/server/lib/refactor-reporter.js +237 -0
  70. package/server/lib/refactor-reporter.test.js +247 -0
  71. package/server/lib/semantic-analyzer.js +198 -0
  72. package/server/lib/semantic-analyzer.test.js +474 -0
  73. package/server/lib/service-scaffold.js +486 -0
  74. package/server/lib/service-scaffold.test.js +373 -0
  75. package/server/lib/shared-kernel.js +578 -0
  76. package/server/lib/shared-kernel.test.js +255 -0
  77. package/server/lib/traefik-config.js +282 -0
  78. package/server/lib/traefik-config.test.js +312 -0
  79. package/server/lib/usage-command.js +218 -0
  80. package/server/lib/usage-command.test.js +391 -0
  81. package/server/lib/usage-formatter.js +192 -0
  82. package/server/lib/usage-formatter.test.js +267 -0
  83. package/server/lib/usage-history.js +122 -0
  84. package/server/lib/usage-history.test.js +206 -0
  85. package/server/package-lock.json +14 -0
  86. package/server/package.json +1 -0
  87. package/templates/docs-sync.yml +91 -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
+ });