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 new file mode 100644 index 0000000..49a1e77 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { deriveContainers } from "../containers"; +import type { GraphNode } from "@understand-anything/core/types"; + +function node(id: string, filePath?: string): GraphNode { + return { + id, + type: "file", + name: id, + filePath, + summary: "", + complexity: "simple", + } as GraphNode; +} + +describe("deriveContainers — folder strategy", () => { + it("groups nodes by first folder segment after LCP", () => { + const nodes = [ + node("a", "src/auth/login.go"), + node("b", "src/auth/oauth.go"), + node("c", "src/cart/cart.go"), + node("d", "src/cart/checkout.go"), + ]; + const { containers, ungrouped } = deriveContainers(nodes, []); + expect(ungrouped).toEqual([]); + expect(containers).toHaveLength(2); + const names = containers.map((c) => c.name).sort(); + expect(names).toEqual(["auth", "cart"]); + const auth = containers.find((c) => c.name === "auth")!; + expect(auth.strategy).toBe("folder"); + expect(auth.nodeIds.sort()).toEqual(["a", "b"]); + }); + + it("strips deep LCP", () => { + const nodes = [ + node("a", "monorepo/backend/src/auth/login.go"), + node("b", "monorepo/backend/src/cart/cart.go"), + ]; + const { containers } = deriveContainers(nodes, []); + const names = containers.map((c) => c.name).sort(); + expect(names).toEqual(["auth", "cart"]); + }); + + it("collapses nested folders into the first segment", () => { + const nodes = [ + node("a", "auth/handlers/oauth.go"), + node("b", "auth/services/token.go"), + node("c", "cart/cart.go"), + ]; + const { containers } = deriveContainers(nodes, []); + expect(containers.find((c) => c.name === "auth")?.nodeIds.sort()).toEqual(["a", "b"]); + }); + + it("places nodes without filePath in '~' container", () => { + const nodes = [ + node("a", "auth/login.go"), + node("b", "auth/oauth.go"), + node("c"), + node("d"), + ]; + const { containers } = deriveContainers(nodes, []); + expect(containers.find((c) => c.name === "~")?.nodeIds.sort()).toEqual(["c", "d"]); + }); + + it("suppresses single-child containers (single child becomes ungrouped)", () => { + const nodes = [ + node("a", "auth/login.go"), + node("b", "auth/oauth.go"), + node("c", "cart/cart.go"), + ]; + const { containers, ungrouped } = deriveContainers(nodes, []); + // 'cart' has only 1 child → suppressed + expect(containers.find((c) => c.name === "cart")).toBeUndefined(); + expect(ungrouped).toContain("c"); + // 'auth' kept + expect(containers.find((c) => c.name === "auth")?.nodeIds.sort()).toEqual(["a", "b"]); + }); + + it("returns flat (no containers) when total nodes < 8", () => { + const nodes = [ + node("a", "auth/x.go"), + node("b", "cart/y.go"), + node("c", "logs/z.go"), + ]; + const { containers, ungrouped } = deriveContainers(nodes, []); + expect(containers).toHaveLength(0); + expect(ungrouped.sort()).toEqual(["a", "b", "c"]); + }); +}); diff --git a/understand-anything-plugin/packages/dashboard/src/utils/containers.ts b/understand-anything-plugin/packages/dashboard/src/utils/containers.ts new file mode 100644 index 0000000..fc2a902 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/utils/containers.ts @@ -0,0 +1,153 @@ +import type { + GraphNode, + GraphEdge, +} from "@understand-anything/core/types"; +import { detectCommunities } from "./louvain"; + +export interface DerivedContainer { + id: string; + name: string; + nodeIds: string[]; + strategy: "folder" | "community"; +} + +export interface DeriveResult { + containers: DerivedContainer[]; + ungrouped: string[]; +} + +const MIN_BUCKET_COUNT = 2; +const MAX_CONCENTRATION = 0.7; +const MIN_NODES_FOR_SUPPRESSION = 3; +const ROOT_BUCKET = "~"; + +/** + * Longest common prefix of the *directory* portion of paths, trimmed to a + * `/` boundary. Using dirs (not full paths) avoids consuming the only + * folder segment when all paths sit directly under the same folder + * (e.g. `[auth/x, auth/y]` → LCP `""`, so we still group on `auth`). + */ +function commonPrefix(paths: string[]): string { + if (paths.length === 0) return ""; + const dirs = paths.map((p) => { + const slash = p.lastIndexOf("/"); + return slash >= 0 ? p.slice(0, slash) : ""; + }); + let prefix = dirs[0]; + for (const d of dirs) { + while (!d.startsWith(prefix)) { + prefix = prefix.slice(0, -1); + if (!prefix) return ""; + } + } + const lastSlash = prefix.lastIndexOf("/"); + return lastSlash >= 0 ? prefix.slice(0, lastSlash + 1) : ""; +} + +function firstSegment(path: string): string { + const slash = path.indexOf("/"); + return slash >= 0 ? path.slice(0, slash) : path; +} + +function groupByFolder( + nodes: GraphNode[], +): { groups: Map; rooted: string[] } { + const withPath = nodes.filter((n) => n.filePath); + const lcp = commonPrefix(withPath.map((n) => n.filePath!)); + const groups = new Map(); + const rooted: string[] = []; + for (const n of withPath) { + const stripped = n.filePath!.slice(lcp.length); + if (!stripped.includes("/")) { + rooted.push(n.id); + continue; + } + const seg = firstSegment(stripped); + const arr = groups.get(seg) ?? []; + arr.push(n.id); + groups.set(seg, arr); + } + for (const n of nodes) { + if (!n.filePath) rooted.push(n.id); + } + return { groups, rooted }; +} + +function shouldFallbackToCommunity( + groups: Map, + rooted: string[], + totalNodes: number, +): boolean { + const bucketCount = groups.size + (rooted.length > 0 ? 1 : 0); + if (bucketCount < MIN_BUCKET_COUNT) return true; + for (const ids of groups.values()) { + if (ids.length / totalNodes > MAX_CONCENTRATION) return true; + } + if (rooted.length / totalNodes > MAX_CONCENTRATION) return true; + return false; +} + +export function deriveContainers( + nodes: GraphNode[], + edges: GraphEdge[], +): DeriveResult { + if (nodes.length === 0) { + return { containers: [], ungrouped: [] }; + } + + const { groups, rooted } = groupByFolder(nodes); + + const useCommunity = shouldFallbackToCommunity(groups, rooted, nodes.length); + let containers: DerivedContainer[]; + + if (useCommunity) { + const communities = detectCommunities( + nodes.map((n) => n.id), + edges, + ); + const byCommunity = new Map(); + for (const [nodeId, cid] of communities) { + const arr = byCommunity.get(cid) ?? []; + arr.push(nodeId); + byCommunity.set(cid, arr); + } + const sorted = [...byCommunity.entries()].sort((a, b) => a[0] - b[0]); + containers = sorted.map(([cid, ids], i) => ({ + id: `container:cluster-${cid}`, + name: `Cluster ${String.fromCharCode(65 + i)}`, + nodeIds: ids, + strategy: "community" as const, + })); + } else { + containers = [...groups.entries()].map(([seg, ids]) => ({ + id: `container:${seg}`, + name: seg, + nodeIds: ids, + strategy: "folder" as const, + })); + if (rooted.length > 0) { + containers.push({ + id: `container:${ROOT_BUCKET}`, + name: ROOT_BUCKET, + nodeIds: rooted, + strategy: "folder" as const, + }); + } + } + + // Suppress single-child containers (their child becomes ungrouped). + // Skip suppression for tiny layers — with so few nodes, even single-item + // boxes carry useful folder context that shouldn't be discarded. + const ungrouped: string[] = []; + if (nodes.length >= MIN_NODES_FOR_SUPPRESSION) { + containers = containers.filter((c) => { + if (c.nodeIds.length === 1) { + ungrouped.push(c.nodeIds[0]); + return false; + } + return true; + }); + } + + return { containers, ungrouped }; +} diff --git a/understand-anything-plugin/packages/dashboard/src/utils/louvain.ts b/understand-anything-plugin/packages/dashboard/src/utils/louvain.ts new file mode 100644 index 0000000..5e06153 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/utils/louvain.ts @@ -0,0 +1,12 @@ +import type { GraphEdge } from "@understand-anything/core/types"; + +/** Returns [nodeId, communityId] for every node provided. */ +export function detectCommunities( + _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; +}