diff --git a/understand-anything-plugin/packages/dashboard/src/utils/__tests__/edgeAggregation.test.ts b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/edgeAggregation.test.ts new file mode 100644 index 0000000..54b6323 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/edgeAggregation.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { aggregateContainerEdges } from "../edgeAggregation"; +import type { GraphEdge } from "@understand-anything/core/types"; + +const ce = (source: string, target: string, type: string = "calls"): GraphEdge => + ({ source, target, type }) as GraphEdge; + +describe("aggregateContainerEdges", () => { + it("returns empty arrays for empty input", () => { + const r = aggregateContainerEdges([], new Map()); + expect(r.intraContainer).toEqual([]); + expect(r.interContainerAggregated).toEqual([]); + }); + + it("preserves intra-container edges as-is", () => { + const m = new Map([ + ["a", "auth"], + ["b", "auth"], + ]); + const r = aggregateContainerEdges([ce("a", "b")], m); + expect(r.intraContainer).toHaveLength(1); + expect(r.interContainerAggregated).toEqual([]); + }); + + it("merges multiple same-direction inter edges into one", () => { + const m = new Map([ + ["a", "auth"], + ["b", "auth"], + ["c", "cart"], + ["d", "cart"], + ]); + const edges = [ce("a", "c"), ce("a", "d"), ce("b", "c", "imports")]; + const r = aggregateContainerEdges(edges, m); + expect(r.interContainerAggregated).toHaveLength(1); + const agg = r.interContainerAggregated[0]; + expect(agg.sourceContainerId).toBe("auth"); + expect(agg.targetContainerId).toBe("cart"); + expect(agg.count).toBe(3); + expect(agg.types.sort()).toEqual(["calls", "imports"]); + }); + + it("treats opposite directions as separate aggregated edges", () => { + const m = new Map([ + ["a", "auth"], + ["c", "cart"], + ]); + const r = aggregateContainerEdges([ce("a", "c"), ce("c", "a")], m); + expect(r.interContainerAggregated).toHaveLength(2); + const dirs = r.interContainerAggregated.map( + (e) => `${e.sourceContainerId}→${e.targetContainerId}`, + ); + expect(dirs.sort()).toEqual(["auth→cart", "cart→auth"]); + }); + + it("ignores edges whose endpoints have no container mapping", () => { + const m = new Map([["a", "auth"]]); + const r = aggregateContainerEdges([ce("a", "z")], m); + expect(r.intraContainer).toEqual([]); + expect(r.interContainerAggregated).toEqual([]); + }); +}); diff --git a/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts b/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts index 29b72d5..5bbeee8 100644 --- a/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts +++ b/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts @@ -1,4 +1,4 @@ -import type { KnowledgeGraph } from "@understand-anything/core/types"; +import type { GraphEdge, KnowledgeGraph } from "@understand-anything/core/types"; export interface LayerEdgeAggregation { sourceLayerId: string; @@ -133,3 +133,73 @@ export function findCrossLayerFileNodes( } return result; } + +export interface AggregatedContainerEdge { + sourceContainerId: string; + targetContainerId: string; + count: number; + types: string[]; +} + +export interface ContainerEdgeBuckets { + intraContainer: GraphEdge[]; + interContainerAggregated: AggregatedContainerEdge[]; +} + +/** + * Bucket edges into intra-container (preserved) and inter-container + * (aggregated by directed (source,target) container pair). + * + * Direction is significant: A→B and B→A produce two independent + * aggregated edges. Edges whose endpoints have no container mapping + * are dropped (treat them as pre-filtered). + */ +export function aggregateContainerEdges( + edges: GraphEdge[], + nodeToContainer: Map, +): ContainerEdgeBuckets { + const intra: GraphEdge[] = []; + const interMap = new Map< + string, + { + sourceContainerId: string; + targetContainerId: string; + count: number; + types: Set; + } + >(); + + for (const e of edges) { + const sc = nodeToContainer.get(e.source); + const tc = nodeToContainer.get(e.target); + if (!sc || !tc) continue; + if (sc === tc) { + intra.push(e); + continue; + } + const key = `${sc} ${tc}`; + const existing = interMap.get(key); + if (existing) { + existing.count++; + existing.types.add(e.type); + } else { + interMap.set(key, { + sourceContainerId: sc, + targetContainerId: tc, + count: 1, + types: new Set([e.type]), + }); + } + } + + const interContainerAggregated: AggregatedContainerEdge[] = [...interMap.values()].map( + (v) => ({ + sourceContainerId: v.sourceContainerId, + targetContainerId: v.targetContainerId, + count: v.count, + types: [...v.types], + }), + ); + + return { intraContainer: intra, interContainerAggregated }; +}