mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
feat(dashboard): deriveContainers folder strategy
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user