mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
// 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");
|
|
});
|
|
});
|
|
});
|