// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import { describe, expect, it } from "vitest"; import { findCircularDependencies, generateMermaidDiagram, getFileDependencies, getGraphStats, } from "../../src/services/graph-analysis.js"; import type { CodeGraph, CodeGraphEdge, CodeGraphNode } from "../../src/types.js"; // ── Helper to build mock graphs ──────────────────────────────────────── function makeNode( relativePath: string, deps: string[] = [], dependents: string[] = [], ): CodeGraphNode { return { filePath: `/project/${relativePath}`, relativePath, imports: deps.map((d) => `./${d}`), exports: [], dependencies: deps, dependents, }; } function makeEdge( source: string, target: string, type: "import" | "re-export" | "dynamic-import" = "import", ): CodeGraphEdge { return { source, target, type }; } function makeGraph(nodes: CodeGraphNode[], edges: CodeGraphEdge[]): CodeGraph { return { nodes, edges }; } // ── A realistic small graph ────────────────────────────────────────────── // index.ts → utils.ts → helpers.ts // index.ts → types.ts // types.ts (orphan-ish: no imports) function createSampleGraph(): CodeGraph { const nodes: CodeGraphNode[] = [ makeNode("src/index.ts", ["src/utils.ts", "src/types.ts"], []), makeNode("src/utils.ts", ["src/helpers.ts"], ["src/index.ts"]), makeNode("src/helpers.ts", [], ["src/utils.ts"]), makeNode("src/types.ts", [], ["src/index.ts"]), ]; const edges: CodeGraphEdge[] = [ makeEdge("src/index.ts", "src/utils.ts"), makeEdge("src/index.ts", "src/types.ts"), makeEdge("src/utils.ts", "src/helpers.ts"), ]; return makeGraph(nodes, edges); } // ── A graph with circular dependencies ──────────────────────────────── function createCircularGraph(): CodeGraph { // A → B → C → A (cycle) const nodes: CodeGraphNode[] = [ makeNode("a.ts", ["b.ts"], ["c.ts"]), makeNode("b.ts", ["c.ts"], ["a.ts"]), makeNode("c.ts", ["a.ts"], ["b.ts"]), ]; const edges: CodeGraphEdge[] = [ makeEdge("a.ts", "b.ts"), makeEdge("b.ts", "c.ts"), makeEdge("c.ts", "a.ts"), ]; return makeGraph(nodes, edges); } describe("graph-analysis", () => { describe("getFileDependencies", () => { it("returns imports and importedBy for a file", () => { const graph = createSampleGraph(); const deps = getFileDependencies(graph, "src/index.ts"); expect(deps.imports).toContain("src/utils.ts"); expect(deps.imports).toContain("src/types.ts"); expect(deps.importedBy).toHaveLength(0); // nothing imports index.ts }); it("returns dependents correctly", () => { const graph = createSampleGraph(); const deps = getFileDependencies(graph, "src/utils.ts"); expect(deps.imports).toContain("src/helpers.ts"); expect(deps.importedBy).toContain("src/index.ts"); }); it("returns empty arrays for leaf files with no dependents", () => { const graph = createSampleGraph(); const deps = getFileDependencies(graph, "src/helpers.ts"); expect(deps.imports).toHaveLength(0); expect(deps.importedBy).toContain("src/utils.ts"); }); it("returns empty arrays for non-existent file", () => { const graph = createSampleGraph(); const deps = getFileDependencies(graph, "nonexistent.ts"); expect(deps.imports).toHaveLength(0); expect(deps.importedBy).toHaveLength(0); }); }); describe("findCircularDependencies", () => { it("returns empty array when no cycles exist", () => { const graph = createSampleGraph(); const cycles = findCircularDependencies(graph); expect(cycles).toHaveLength(0); }); it("detects circular dependencies", () => { const graph = createCircularGraph(); const cycles = findCircularDependencies(graph); expect(cycles.length).toBeGreaterThan(0); }); it("cycle path starts and ends with the same file", () => { const graph = createCircularGraph(); const cycles = findCircularDependencies(graph); for (const cycle of cycles) { expect(cycle[0]).toBe(cycle[cycle.length - 1]); } }); it("handles empty graph", () => { const graph = makeGraph([], []); const cycles = findCircularDependencies(graph); expect(cycles).toHaveLength(0); }); it("handles single-node graph", () => { const graph = makeGraph([makeNode("solo.ts")], []); const cycles = findCircularDependencies(graph); expect(cycles).toHaveLength(0); }); it("detects self-import cycle", () => { const node = makeNode("self.ts", ["self.ts"], ["self.ts"]); const graph = makeGraph([node], [makeEdge("self.ts", "self.ts")]); const cycles = findCircularDependencies(graph); expect(cycles.length).toBeGreaterThan(0); }); }); describe("getGraphStats", () => { it("returns correct total files", () => { const graph = createSampleGraph(); const stats = getGraphStats(graph); expect(stats.totalFiles).toBe(4); }); it("returns correct total edges", () => { const graph = createSampleGraph(); const stats = getGraphStats(graph); expect(stats.totalEdges).toBe(3); }); it("calculates average dependencies", () => { const graph = createSampleGraph(); const stats = getGraphStats(graph); expect(stats.avgDependencies).toBe(3 / 4); }); it("identifies most connected files", () => { const graph = createSampleGraph(); const stats = getGraphStats(graph); expect(stats.mostConnected.length).toBeGreaterThan(0); // index.ts has 2 deps + 0 dependents = 2 connections // utils.ts has 1 dep + 1 dependent = 2 connections const indexEntry = stats.mostConnected.find((f) => f.file === "src/index.ts"); expect(indexEntry).toBeDefined(); expect(indexEntry?.connections).toBe(2); }); it("identifies orphan files (no deps and no dependents)", () => { // Add a true orphan to the graph const graph = createSampleGraph(); graph.nodes.push(makeNode("orphan.ts")); const stats = getGraphStats(graph); expect(stats.orphans).toContain("orphan.ts"); }); it("counts circular dependencies", () => { const graph = createCircularGraph(); const stats = getGraphStats(graph); expect(stats.circularDeps).toBeGreaterThan(0); }); it("returns language breakdown", () => { const graph = createSampleGraph(); const stats = getGraphStats(graph); expect(stats.languageBreakdown).toBeDefined(); expect(stats.languageBreakdown.typescript).toBe(4); }); it("handles empty graph", () => { const graph = makeGraph([], []); const stats = getGraphStats(graph); expect(stats.totalFiles).toBe(0); expect(stats.totalEdges).toBe(0); expect(stats.avgDependencies).toBe(0); expect(stats.mostConnected).toHaveLength(0); expect(stats.orphans).toHaveLength(0); expect(stats.circularDeps).toBe(0); }); it("limits mostConnected to top 10", () => { // Create a graph with more than 10 nodes const nodes: CodeGraphNode[] = []; const edges: CodeGraphEdge[] = []; for (let i = 0; i < 15; i++) { nodes.push(makeNode(`file${i}.ts`)); } const graph = makeGraph(nodes, edges); const stats = getGraphStats(graph); expect(stats.mostConnected.length).toBeLessThanOrEqual(10); }); }); describe("generateMermaidDiagram", () => { it("generates a valid Mermaid diagram", () => { const graph = createSampleGraph(); const mermaid = generateMermaidDiagram(graph); expect(mermaid).toContain("graph LR"); }); it("includes node declarations with file basenames", () => { const graph = createSampleGraph(); const mermaid = generateMermaidDiagram(graph); expect(mermaid).toContain('"index.ts"'); expect(mermaid).toContain('"utils.ts"'); expect(mermaid).toContain('"helpers.ts"'); expect(mermaid).toContain('"types.ts"'); }); it("includes edge arrows", () => { const graph = createSampleGraph(); const mermaid = generateMermaidDiagram(graph); // Should have arrow notation expect(mermaid).toContain("-->"); }); it("handles empty graph", () => { const graph = makeGraph([], []); const mermaid = generateMermaidDiagram(graph); expect(mermaid).toContain("No files found"); }); it("marks circular dependency edges specially", () => { const graph = createCircularGraph(); const mermaid = generateMermaidDiagram(graph); // Circular edges should use dotted lines with |cycle| label expect(mermaid).toContain("-.->|cycle|"); }); it("includes style directives with colors", () => { const graph = createSampleGraph(); const mermaid = generateMermaidDiagram(graph); // TypeScript nodes should have the TypeScript color fill expect(mermaid).toContain("style"); expect(mermaid).toContain("fill:"); }); it("handles dynamic-import edges with dotted lines", () => { const graph = makeGraph( [makeNode("a.ts", ["b.ts"], []), makeNode("b.ts", [], ["a.ts"])], [makeEdge("a.ts", "b.ts", "dynamic-import")], ); const mermaid = generateMermaidDiagram(graph); // Dynamic imports use dotted lines (-.->) expect(mermaid).toContain("-.->"); }); it("includes a legend for multi-language graphs", () => { const nodes: CodeGraphNode[] = [ makeNode("app.ts", [], []), makeNode("lib.py", [], []), ]; // Fix the filePath to have proper extension recognition nodes[1].filePath = "/project/lib.py"; const graph = makeGraph(nodes, []); const mermaid = generateMermaidDiagram(graph); expect(mermaid).toContain("subgraph Legend"); }); }); });