2305 lines
72 KiB
Markdown
2305 lines
72 KiB
Markdown
# Graph Layout Scaling Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace dagre with ELK across structural-style dashboard views, add folder/community-based containers in the layer-detail view, and compute layout in two lazy stages so layers with many nodes are readable and large graphs stay performant.
|
||
|
||
**Architecture:** Three views (overview, DomainGraphView, layer-detail) call a new `applyElkLayout` instead of `applyDagreLayout`. The layer-detail view gains a `deriveContainers` step (folder strategy with Louvain fallback), aggregated cross-container edges, lazy two-stage ELK calls (Stage 1 = containers; Stage 2 = a container's children on demand), a new `ContainerNode` React Flow node type, and store extensions for expand state and layout caches.
|
||
|
||
**Tech Stack:** TypeScript, React 19, Vite, React Flow (`@xyflow/react`), Zustand, Vitest, ELK.js (`elkjs`), `graphology` + `graphology-communities-louvain`.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-05-03-graph-layout-scaling-design.md`
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
```
|
||
packages/dashboard/
|
||
├── package.json [modify] add elkjs, graphology, graphology-communities-louvain, vitest
|
||
├── vite.config.ts [modify] add vitest test config
|
||
├── src/
|
||
│ ├── utils/
|
||
│ │ ├── layout.ts [modify] export applyElkLayout
|
||
│ │ ├── elk-layout.ts [new] runElk + repairElkInput + GraphIssue mapping
|
||
│ │ ├── containers.ts [new] deriveContainers (folder + community fallback)
|
||
│ │ ├── louvain.ts [new] thin wrapper around graphology-communities-louvain
|
||
│ │ ├── edgeAggregation.ts [modify] add aggregateContainerEdges
|
||
│ │ └── __tests__/
|
||
│ │ ├── containers.test.ts [new]
|
||
│ │ ├── edgeAggregation.test.ts [new]
|
||
│ │ └── elk-layout.test.ts [new]
|
||
│ ├── components/
|
||
│ │ ├── ContainerNode.tsx [new]
|
||
│ │ ├── GraphView.tsx [modify] Stage 1 / Stage 2 wiring, expand state, auto-expand
|
||
│ │ └── DomainGraphView.tsx [modify] dagre → ELK
|
||
│ └── store.ts [modify] expandedContainers, containerLayoutCache, containerSizeMemory
|
||
└── scripts/
|
||
└── benchmark-layout.mjs [new] perf benchmark (uses scripts/generate-large-graph.mjs)
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: Dependencies + Vitest setup
|
||
|
||
**Files:**
|
||
- Modify: `understand-anything-plugin/packages/dashboard/package.json`
|
||
- Create: `understand-anything-plugin/packages/dashboard/src/utils/__tests__/smoke.test.ts`
|
||
- Modify: `understand-anything-plugin/packages/dashboard/vite.config.ts`
|
||
|
||
- [ ] **Step 1: Add deps and devDeps to package.json**
|
||
|
||
Edit `understand-anything-plugin/packages/dashboard/package.json`. Add to `dependencies`:
|
||
|
||
```json
|
||
"elkjs": "^0.9.3",
|
||
"graphology": "^0.25.4",
|
||
"graphology-communities-louvain": "^2.0.1",
|
||
```
|
||
|
||
Add to `devDependencies`:
|
||
|
||
```json
|
||
"vitest": "^3.1.0",
|
||
"@vitest/coverage-v8": "^3.2.4",
|
||
```
|
||
|
||
Add to `scripts`:
|
||
|
||
```json
|
||
"test": "vitest run",
|
||
"test:watch": "vitest"
|
||
```
|
||
|
||
- [ ] **Step 2: Update vite.config.ts to register vitest**
|
||
|
||
In `understand-anything-plugin/packages/dashboard/vite.config.ts` add a triple-slash reference at the top and a `test` block. Open the file, then at the very top add:
|
||
|
||
```ts
|
||
/// <reference types="vitest" />
|
||
```
|
||
|
||
Inside the `defineConfig({ ... })` object add:
|
||
|
||
```ts
|
||
test: {
|
||
environment: "node",
|
||
include: ["src/**/__tests__/**/*.test.ts"],
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 3: Install deps**
|
||
|
||
Run from the repo root:
|
||
|
||
```bash
|
||
pnpm install
|
||
```
|
||
|
||
Expected: pnpm resolves and installs without errors.
|
||
|
||
- [ ] **Step 4: Write smoke test**
|
||
|
||
Create `understand-anything-plugin/packages/dashboard/src/utils/__tests__/smoke.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from "vitest";
|
||
import ELK from "elkjs/lib/elk.bundled.js";
|
||
import Graph from "graphology";
|
||
import louvain from "graphology-communities-louvain";
|
||
|
||
describe("dependency smoke test", () => {
|
||
it("imports elkjs", () => {
|
||
expect(typeof ELK).toBe("function");
|
||
});
|
||
|
||
it("imports graphology", () => {
|
||
const g = new Graph();
|
||
g.addNode("a");
|
||
expect(g.order).toBe(1);
|
||
});
|
||
|
||
it("imports graphology-communities-louvain", () => {
|
||
expect(typeof louvain).toBe("function");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Run smoke test**
|
||
|
||
```bash
|
||
pnpm --filter @understand-anything/dashboard test
|
||
```
|
||
|
||
Expected: 3 tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add understand-anything-plugin/packages/dashboard/package.json \
|
||
understand-anything-plugin/packages/dashboard/vite.config.ts \
|
||
understand-anything-plugin/packages/dashboard/src/utils/__tests__/smoke.test.ts \
|
||
pnpm-lock.yaml
|
||
git commit -m "chore(dashboard): add elkjs, graphology, vitest"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: deriveContainers — folder strategy + edge cases
|
||
|
||
**Files:**
|
||
- Create: `understand-anything-plugin/packages/dashboard/src/utils/containers.ts`
|
||
- Create: `understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from "vitest";
|
||
import { deriveContainers } from "../containers";
|
||
import type { GraphNode, GraphEdge } 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"]);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
pnpm --filter @understand-anything/dashboard test containers
|
||
```
|
||
|
||
Expected: import error — `Cannot find module '../containers'`.
|
||
|
||
- [ ] **Step 3: Implement `containers.ts`**
|
||
|
||
Create `understand-anything-plugin/packages/dashboard/src/utils/containers.ts`:
|
||
|
||
```ts
|
||
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_LAYER_SIZE_FOR_GROUPING = 8;
|
||
const MIN_FOLDER_COUNT = 3;
|
||
const MAX_CONCENTRATION = 0.6;
|
||
const ROOT_BUCKET = "~";
|
||
|
||
function commonPrefix(paths: string[]): string {
|
||
if (paths.length === 0) return "";
|
||
let prefix = paths[0];
|
||
for (const p of paths) {
|
||
while (!p.startsWith(prefix)) {
|
||
prefix = prefix.slice(0, -1);
|
||
if (!prefix) return "";
|
||
}
|
||
}
|
||
// Trim back to a directory boundary
|
||
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<string, string[]>; rooted: string[] } {
|
||
const withPath = nodes.filter((n) => n.filePath);
|
||
const lcp = commonPrefix(withPath.map((n) => n.filePath!));
|
||
const groups = new Map<string, string[]>();
|
||
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<string, string[]>,
|
||
totalNodes: number,
|
||
): boolean {
|
||
if (groups.size < MIN_FOLDER_COUNT) return true;
|
||
for (const ids of groups.values()) {
|
||
if (ids.length / totalNodes > MAX_CONCENTRATION) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export function deriveContainers(
|
||
nodes: GraphNode[],
|
||
edges: GraphEdge[],
|
||
): DeriveResult {
|
||
if (nodes.length < MIN_LAYER_SIZE_FOR_GROUPING) {
|
||
return { containers: [], ungrouped: nodes.map((n) => n.id) };
|
||
}
|
||
|
||
const { groups, rooted } = groupByFolder(nodes);
|
||
|
||
const useCommunity = shouldFallbackToCommunity(groups, nodes.length);
|
||
let containers: DerivedContainer[];
|
||
|
||
if (useCommunity) {
|
||
const communities = detectCommunities(
|
||
nodes.map((n) => n.id),
|
||
edges,
|
||
);
|
||
const byCommunity = new Map<number, string[]>();
|
||
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
|
||
const ungrouped: string[] = [];
|
||
containers = containers.filter((c) => {
|
||
if (c.nodeIds.length === 1) {
|
||
ungrouped.push(c.nodeIds[0]);
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
return { containers, ungrouped };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Stub `louvain.ts` (real impl in Task 3)**
|
||
|
||
Create `understand-anything-plugin/packages/dashboard/src/utils/louvain.ts`:
|
||
|
||
```ts
|
||
import type { GraphEdge } from "@understand-anything/core/types";
|
||
|
||
/** Returns [nodeId, communityId] for every node provided. */
|
||
export function detectCommunities(
|
||
_nodeIds: string[],
|
||
_edges: GraphEdge[],
|
||
): Map<string, number> {
|
||
// Real implementation arrives in Task 3. Stub: every node in community 0.
|
||
const m = new Map<string, number>();
|
||
for (const id of _nodeIds) m.set(id, 0);
|
||
return m;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests**
|
||
|
||
```bash
|
||
pnpm --filter @understand-anything/dashboard test containers
|
||
```
|
||
|
||
Expected: 6 tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add understand-anything-plugin/packages/dashboard/src/utils/containers.ts \
|
||
understand-anything-plugin/packages/dashboard/src/utils/louvain.ts \
|
||
understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts
|
||
git commit -m "feat(dashboard): deriveContainers folder strategy"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: deriveContainers — community fallback (Louvain)
|
||
|
||
**Files:**
|
||
- Modify: `understand-anything-plugin/packages/dashboard/src/utils/louvain.ts`
|
||
- Modify: `understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts`
|
||
|
||
- [ ] **Step 1: Add failing test for community fallback**
|
||
|
||
Append to `containers.test.ts`:
|
||
|
||
```ts
|
||
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);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, expect failure**
|
||
|
||
```bash
|
||
pnpm --filter @understand-anything/dashboard test containers
|
||
```
|
||
|
||
Expected: the new tests fail because the louvain stub puts every node in community 0 (only 1 container after suppression).
|
||
|
||
- [ ] **Step 3: Replace louvain stub with real implementation**
|
||
|
||
Overwrite `understand-anything-plugin/packages/dashboard/src/utils/louvain.ts`:
|
||
|
||
```ts
|
||
import Graph from "graphology";
|
||
import louvain from "graphology-communities-louvain";
|
||
import type { GraphEdge } from "@understand-anything/core/types";
|
||
|
||
/**
|
||
* 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[],
|
||
): Map<string, number> {
|
||
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<nodeId, communityId>
|
||
const result = louvain(g) as Record<string, number>;
|
||
const map = new Map<string, number>();
|
||
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;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
pnpm --filter @understand-anything/dashboard test containers
|
||
```
|
||
|
||
Expected: all 8 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add understand-anything-plugin/packages/dashboard/src/utils/louvain.ts \
|
||
understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts
|
||
git commit -m "feat(dashboard): deriveContainers community fallback via Louvain"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: aggregateContainerEdges
|
||
|
||
**Files:**
|
||
- Modify: `understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts`
|
||
- Create: `understand-anything-plugin/packages/dashboard/src/utils/__tests__/edgeAggregation.test.ts`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `understand-anything-plugin/packages/dashboard/src/utils/__tests__/edgeAggregation.test.ts`:
|
||
|
||
```ts
|
||
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([]);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, expect failure**
|
||
|
||
```bash
|
||
pnpm --filter @understand-anything/dashboard test edgeAggregation
|
||
```
|
||
|
||
Expected: import error — `aggregateContainerEdges` not exported.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
Append to `understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts`:
|
||
|
||
```ts
|
||
import type { GraphEdge } from "@understand-anything/core/types";
|
||
|
||
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<string, string>,
|
||
): ContainerEdgeBuckets {
|
||
const intra: GraphEdge[] = [];
|
||
const interMap = new Map<
|
||
string,
|
||
{
|
||
sourceContainerId: string;
|
||
targetContainerId: string;
|
||
count: number;
|
||
types: Set<string>;
|
||
}
|
||
>();
|
||
|
||
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} |