feat(dashboard): deriveContainers folder strategy

This commit is contained in:
Lum1104
2026-05-03 10:53:46 +08:00
parent a11c692c99
commit 30cd640b1e
3 changed files with 254 additions and 0 deletions
@@ -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"]);
});
});
@@ -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<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[]>,
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<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 (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 };
}
@@ -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<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;
}