From 74342da070f2916212d7fc03148ed0566e4ef1d7 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sun, 3 May 2026 15:54:24 +0800 Subject: [PATCH] feat(dashboard): deriveContainers community fallback via Louvain --- .../src/utils/__tests__/containers.test.ts | 38 ++++++++++++++++- .../packages/dashboard/src/utils/louvain.ts | 41 +++++++++++++++---- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts index 49a1e77..058039d 100644 --- a/understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts +++ b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { deriveContainers } from "../containers"; -import type { GraphNode } from "@understand-anything/core/types"; +import type { GraphNode, GraphEdge } from "@understand-anything/core/types"; function node(id: string, filePath?: string): GraphNode { return { @@ -87,3 +87,39 @@ describe("deriveContainers — folder strategy", () => { expect(ungrouped.sort()).toEqual(["a", "b", "c"]); }); }); + +describe("deriveContainers — community fallback", () => { + it("falls back to communities when only one folder present", () => { + const nodes = Array.from({ length: 10 }, (_, i) => + node(`n${i}`, `services/n${i}.go`), + ); + // Two clusters of 5 nodes; densely connected within, no edges between + const edges: GraphEdge[] = []; + for (const i of [0, 1, 2, 3, 4]) { + for (const j of [0, 1, 2, 3, 4]) { + if (i !== j) edges.push({ source: `n${i}`, target: `n${j}`, type: "calls" } as GraphEdge); + } + } + for (const i of [5, 6, 7, 8, 9]) { + for (const j of [5, 6, 7, 8, 9]) { + if (i !== j) edges.push({ source: `n${i}`, target: `n${j}`, type: "calls" } as GraphEdge); + } + } + const { containers } = deriveContainers(nodes, edges); + expect(containers.length).toBeGreaterThanOrEqual(2); + for (const c of containers) { + expect(c.strategy).toBe("community"); + expect(c.name).toMatch(/^Cluster [A-Z]$/); + } + }); + + it("falls back when one folder holds > 60%", () => { + const nodes = [ + ...Array.from({ length: 8 }, (_, i) => node(`big${i}`, `big/file${i}.go`)), + node("a", "small1/a.go"), + node("b", "small2/b.go"), + ]; + const { containers } = deriveContainers(nodes, []); + expect(containers.every((c) => c.strategy === "community")).toBe(true); + }); +}); diff --git a/understand-anything-plugin/packages/dashboard/src/utils/louvain.ts b/understand-anything-plugin/packages/dashboard/src/utils/louvain.ts index 5e06153..e865fec 100644 --- a/understand-anything-plugin/packages/dashboard/src/utils/louvain.ts +++ b/understand-anything-plugin/packages/dashboard/src/utils/louvain.ts @@ -1,12 +1,39 @@ +import Graph from "graphology"; +import louvain from "graphology-communities-louvain"; import type { GraphEdge } from "@understand-anything/core/types"; -/** Returns [nodeId, communityId] for every node provided. */ +/** + * Run Louvain community detection over the provided node set and the + * subset of edges whose endpoints are both in the set. Returns a map of + * nodeId → communityId. Disconnected nodes get unique community ids so + * they don't collapse into a single cluster. + */ export function detectCommunities( - _nodeIds: string[], - _edges: GraphEdge[], + nodeIds: string[], + edges: GraphEdge[], ): Map { - // Real implementation arrives in Task 3. Stub: every node in community 0. - const m = new Map(); - for (const id of _nodeIds) m.set(id, 0); - return m; + const ids = new Set(nodeIds); + const g = new Graph({ type: "undirected", multi: false }); + for (const id of nodeIds) g.addNode(id); + for (const e of edges) { + if (!ids.has(e.source) || !ids.has(e.target)) continue; + if (e.source === e.target) continue; + if (g.hasEdge(e.source, e.target)) continue; + g.addEdge(e.source, e.target); + } + // graphology-communities-louvain returns Record + const result = louvain(g) as Record; + const map = new Map(); + for (const id of nodeIds) { + map.set(id, result[id] ?? -1); + } + // Reassign disconnected nodes (community -1) to unique ids past the max + let next = + Math.max(...Array.from(map.values()).filter((v) => v >= 0), -1) + 1; + for (const [id, c] of map) { + if (c === -1) { + map.set(id, next++); + } + } + return map; }