🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

geraph

Package Overview
Dependencies
Maintainers
1
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

geraph - npm Package Compare versions

Comparing version
1.2.0
to
1.2.1
+611
dist/mcp-2ODHZITE.js
// src/core/mcp.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema
} from "@modelcontextprotocol/sdk/types.js";
import fs from "fs";
import path from "path";
async function runMcpServer(graph, targetDir) {
const server = new Server(
{
name: "geraph",
version: "1.2.1"
},
{
capabilities: {
tools: {},
resources: {}
}
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "search_graph",
description: "Search for nodes in the knowledge graph by partial name. Useful to find exact node IDs. Supports pagination. You can also search for a file by its path (e.g., 'src/auth.ts') using type 'file', because node IDs contain file paths. (CLI Alternative: 'geraph search <term>')",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "The partial name to search for (e.g. 'auth')"
},
type: {
type: "string",
description: "Optional filter by node type (e.g. 'function', 'class')"
},
page: {
type: "number",
description: "Page number for pagination (default: 1)"
},
limit: {
type: "number",
description: "Number of results per page (default: 20)"
}
},
required: ["name"]
}
},
{
name: "get_node",
description: "Get detailed metadata for a specific node by its exact ID or fuzzy symbol name. (CLI Alternative: 'geraph node <symbol>')",
inputSchema: {
type: "object",
properties: {
symbol: {
type: "string",
description: "The exact node ID or symbol name"
},
type: {
type: "string",
description: "Optional filter by node type (e.g., 'interface', 'function')"
},
source: {
type: "string",
description: "Optional filter by source file path (e.g., 'auth.ts')"
}
},
required: ["symbol"]
}
},
{
name: "get_neighbors",
description: "Get all incoming and outgoing edges for a specific node to trace its direct dependencies. Supports pagination. (CLI Alternative: 'geraph neighbors <symbol>')",
inputSchema: {
type: "object",
properties: {
symbol: {
type: "string",
description: "The exact node ID or symbol name"
},
type: {
type: "string",
description: "Optional filter by node type (e.g., 'interface', 'function')"
},
source: {
type: "string",
description: "Optional filter by source file path (e.g., 'auth.ts')"
},
page: {
type: "number",
description: "Page number for pagination (default: 1)"
},
limit: {
type: "number",
description: "Number of edges per direction per page (default: 20)"
}
},
required: ["symbol"]
}
},
{
name: "shortest_path",
description: "Find the shortest sequence of edges connecting two nodes using fuzzy symbol/ID lookup. (CLI Alternative: 'geraph path <source> <target>')",
inputSchema: {
type: "object",
properties: {
source: {
type: "string",
description: "The fuzzy starting node ID or symbol name"
},
target: {
type: "string",
description: "The fuzzy destination node ID or symbol name"
},
max_hops: {
type: "number",
description: "Maximum hops to consider (default: 8)"
}
},
required: ["source", "target"]
}
},
{
name: "god_nodes",
description: "Return the most connected nodes \u2014 the core architectural pillars of the codebase. Supports pagination. (CLI Alternative: 'geraph god')",
inputSchema: {
type: "object",
properties: {
page: {
type: "number",
description: "Page number for pagination (default: 1)"
},
limit: {
type: "number",
description: "Number of results per page (default: 10)"
}
}
}
},
{
name: "get_community",
description: "Get all nodes in a community by community ID. Supports pagination. (CLI Alternative: 'geraph community <id>')",
inputSchema: {
type: "object",
properties: {
community_id: {
type: "number",
description: "Community ID (0-indexed by size)"
},
page: {
type: "number",
description: "Page number for pagination (default: 1)"
},
limit: {
type: "number",
description: "Number of results per page (default: 20)"
}
},
required: ["community_id"]
}
},
{
name: "get_surprises",
description: "Discover surprising cross-community couplings that link otherwise independent modules. Supports pagination. (CLI Alternative: 'geraph surprises')",
inputSchema: {
type: "object",
properties: {
page: {
type: "number",
description: "Page number for pagination (default: 1)"
},
limit: {
type: "number",
description: "Number of results per page (default: 20)"
}
}
}
},
{
name: "query_graph",
description: "Search the AST graph using BFS or DFS traversal. Returns a compact context representation. Supports natural language questions or keywords. (CLI Alternative: 'geraph query <symbol-or-question>')",
inputSchema: {
type: "object",
properties: {
symbol: {
type: "string",
description: "Fuzzy starting symbol or node ID, or natural language question"
},
question: {
type: "string",
description: "Natural language question or keywords (for Graphify parity)"
},
mode: {
type: "string",
enum: ["bfs", "dfs"],
default: "bfs",
description: "Traversal mode: bfs (breadth) or dfs (depth)"
},
depth: {
type: "number",
default: 3,
description: "Traversal depth limit"
},
token_budget: {
type: "number",
default: 2e3,
description: "Estimated output token limit"
}
}
}
},
{
name: "graph_stats",
description: "Return summary statistics of the graph: node count, edge count, community count, and extraction confidence percentage breakdown. (CLI Alternative: 'geraph stats')",
inputSchema: {
type: "object",
properties: {}
}
},
{
name: "scan_graph",
description: "Triggers a full rebuild of the Geraph AST graph. Use this after making significant code modifications or pushing git commits to ensure your structural memory is up-to-date. (CLI Alternative: 'geraph scan')",
inputSchema: {
type: "object",
properties: {
force: {
type: "boolean",
description: "If true, fully ignore and rebuild all cache files (doing a clean scan from scratch)"
}
}
}
}
]
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (!args) {
throw new Error("Arguments are required");
}
try {
if (name === "search_graph") {
const queryName = args.name;
const typeFilter = args.type;
const page = args.page;
const limit = args.limit;
const { searchGraph } = await import("./query-6TPQWVAH.js");
const matches = await searchGraph(
graph,
queryName,
typeFilter,
page,
limit
);
return {
content: [{ type: "text", text: JSON.stringify(matches, null, 2) }]
};
}
if (name === "get_node") {
const symbol = args.symbol;
const typeFilter = args.type;
const sourceFilter = args.source;
const { getNode } = await import("./query-6TPQWVAH.js");
try {
const result = await getNode(graph, symbol, typeFilter, sourceFilter);
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
} catch (error) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: error instanceof Error ? error.message : String(error)
})
}
],
isError: true
};
}
}
if (name === "get_neighbors") {
const symbol = args.symbol;
const typeFilter = args.type;
const sourceFilter = args.source;
const page = args.page;
const limit = args.limit;
const { getNeighbors } = await import("./query-6TPQWVAH.js");
try {
const result = await getNeighbors(
graph,
symbol,
typeFilter,
sourceFilter,
page,
limit
);
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
} catch (error) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: error instanceof Error ? error.message : String(error)
})
}
],
isError: true
};
}
}
if (name === "shortest_path") {
const source = args.source;
const target = args.target;
const maxHops = args.max_hops !== void 0 ? Number(args.max_hops) : 8;
const { shortestPath } = await import("./query-6TPQWVAH.js");
try {
const result = await shortestPath(graph, source, target, maxHops);
return {
content: [
{
type: "text",
text: result
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: error instanceof Error ? error.message : String(error)
})
}
],
isError: true
};
}
}
if (name === "god_nodes") {
const page = args.page || 1;
const limit = args.limit || 10;
const { getGodNodes } = await import("./query-6TPQWVAH.js");
try {
const result = await getGodNodes(graph, page, limit);
return {
content: [{ type: "text", text: result }]
};
} catch (error) {
return {
content: [{ type: "text", text: String(error) }],
isError: true
};
}
}
if (name === "get_community") {
const communityId = Number(args.community_id);
const page = args.page || 1;
const limit = args.limit || 20;
const { getCommunityNodes } = await import("./query-6TPQWVAH.js");
try {
const result = await getCommunityNodes(
graph,
communityId,
page,
limit
);
return {
content: [{ type: "text", text: result }]
};
} catch (error) {
return {
content: [{ type: "text", text: String(error) }],
isError: true
};
}
}
if (name === "get_surprises") {
const page = args.page || 1;
const limit = args.limit || 20;
const { getSurprisingConnections } = await import("./query-6TPQWVAH.js");
try {
const result = await getSurprisingConnections(graph, page, limit);
return {
content: [{ type: "text", text: result }]
};
} catch (error) {
return {
content: [{ type: "text", text: String(error) }],
isError: true
};
}
}
if (name === "query_graph") {
const symbol = args.symbol || args.question;
if (!symbol) {
throw new Error("Either 'symbol' or 'question' parameter is required");
}
const mode = args.mode || "bfs";
const depth = Number(args.depth ?? 3);
const tokenBudget = Number(args.token_budget ?? 2e3);
const { queryGraph } = await import("./query-6TPQWVAH.js");
try {
const result = await queryGraph(
graph,
symbol,
mode,
depth,
tokenBudget
);
return {
content: [{ type: "text", text: result }]
};
} catch (error) {
return {
content: [{ type: "text", text: String(error) }],
isError: true
};
}
}
if (name === "graph_stats") {
const { getGraphStats } = await import("./query-6TPQWVAH.js");
try {
const result = await getGraphStats(graph);
return {
content: [{ type: "text", text: result }]
};
} catch (error) {
return {
content: [{ type: "text", text: String(error) }],
isError: true
};
}
}
if (name === "scan_graph") {
try {
const { exec } = await import("child_process");
const { promisify } = await import("util");
const execAsync = promisify(exec);
const force = !!args.force;
const cmd = force ? "geraph scan --force" : "geraph scan";
const { stdout, stderr } = await execAsync(cmd, { cwd: targetDir });
const { loadGraph } = await import("./query-6TPQWVAH.js");
const newGraph = loadGraph(targetDir);
graph.clear();
newGraph.forEachNode((node, attr) => graph.addNode(node, attr));
newGraph.forEachEdge(
(edge, attr, source, target) => graph.addEdgeWithKey(edge, source, target, attr)
);
const stripAnsi = (str) => str.replace(
// eslint-disable-next-line no-control-regex
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
""
);
const cleanStdout = stripAnsi(stdout);
const cleanStderr = stripAnsi(stderr);
const lines = cleanStderr.split("\n").map((l) => l.trim()).filter(Boolean);
let filesParsedLine = "";
const realWarnings = [];
const SPINNER_KEYWORDS = [
"Scanning codebase in",
"Initializing Knowledge Graph",
"Resolving call graph",
"Extracting Temporal Facts",
"Analyzing graph structure",
"Compressing graph into Caveman"
];
for (const line of lines) {
if (line.includes("Successfully scanned and parsed")) {
filesParsedLine = line.replace(/^[^\w]+/, "").trim();
} else if (SPINNER_KEYWORDS.some((kw) => line.includes(kw))) {
} else {
realWarnings.push(line);
}
}
let outputText = "";
if (filesParsedLine) {
const displayLine = filesParsedLine.startsWith("Successfully") ? filesParsedLine : `Successfully ${filesParsedLine}`;
outputText += `${displayLine}
`;
}
outputText += cleanStdout.trim();
if (realWarnings.length > 0) {
outputText += `
Warnings/Errors:
${realWarnings.join("\n")}`;
}
return {
content: [
{
type: "text",
text: outputText.trim() || "Graph successfully scanned and memory updated."
}
]
};
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `Error scanning graph: ${msg}` }],
isError: true
};
}
}
throw new Error(`Unknown tool: ${name}`);
} catch (error) {
const err = error;
return {
content: [
{ type: "text", text: JSON.stringify({ error: err.message }) }
],
isError: true
};
}
});
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: "geraph://report",
name: "Graph Report",
description: "Full GRAPH_REPORT.md",
mimeType: "text/markdown"
},
{
uri: "geraph://stats",
name: "Graph Stats",
description: "Node/edge/community counts and confidence breakdown",
mimeType: "text/plain"
},
{
uri: "geraph://god-nodes",
name: "God Nodes",
description: "Top 10 most-connected nodes",
mimeType: "text/plain"
},
{
uri: "geraph://surprises",
name: "Surprising Connections",
description: "Cross-community surprising connections",
mimeType: "text/plain"
}
]
};
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
if (uri === "geraph://report") {
const reportPath = path.join(targetDir, ".geraph", "GRAPH_REPORT.md");
if (fs.existsSync(reportPath)) {
return {
contents: [
{
uri,
mimeType: "text/markdown",
text: fs.readFileSync(reportPath, "utf-8")
}
]
};
}
return {
contents: [
{
uri,
mimeType: "text/markdown",
text: "GRAPH_REPORT.md not found. Run geraph scan first."
}
]
};
}
if (uri === "geraph://stats") {
const { getGraphStats } = await import("./query-6TPQWVAH.js");
const text = await getGraphStats(graph);
return {
contents: [{ uri, mimeType: "text/plain", text }]
};
}
if (uri === "geraph://god-nodes") {
const { getGodNodes } = await import("./query-6TPQWVAH.js");
const text = await getGodNodes(graph, 1, 10);
return {
contents: [{ uri, mimeType: "text/plain", text }]
};
}
if (uri === "geraph://surprises") {
const { getSurprisingConnections } = await import("./query-6TPQWVAH.js");
const text = await getSurprisingConnections(graph, 1, 10);
return {
contents: [{ uri, mimeType: "text/plain", text }]
};
}
throw new Error(`Unknown resource: ${uri}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Geraph MCP Server is running over stdio");
}
export {
runMcpServer
};
import {
createKnowledgeGraph
} from "./chunk-5V42YYJT.js";
// src/core/query.ts
import fs from "fs";
import path from "path";
function normalizeId(id) {
return id.replace(/\\/g, "/");
}
function loadGraph(targetDir) {
const graphPath = path.join(targetDir, ".geraph", "graph.json");
if (!fs.existsSync(graphPath)) {
throw new Error(
`Graph data not found in ${targetDir}. Run 'geraph scan' first.`
);
}
const rawData = JSON.parse(fs.readFileSync(graphPath, "utf-8"));
const graph = createKnowledgeGraph();
const nodes = rawData.nodes || [];
const edges = rawData.edges || [];
const communities = rawData.analysis?.communities || [];
const nodeToCommunity = /* @__PURE__ */ new Map();
communities.forEach((c) => {
const members = c.members || c.nodes || [];
members.forEach((nodeId) => {
nodeToCommunity.set(normalizeId(nodeId), Number(c.id));
});
});
nodes.forEach((n) => {
const nid = normalizeId(n.id);
if (!graph.hasNode(nid)) {
const { id, name, type, file, startLine, ...metadata } = n;
const communityId = nodeToCommunity.get(nid);
graph.addNode(nid, {
name: name || "",
type,
file: normalizeId(file || ""),
startLine: startLine || 0,
metadata: {
...metadata,
...communityId !== void 0 ? { community: communityId } : {}
}
});
}
});
edges.forEach((e) => {
const source = normalizeId(e.source);
const target = normalizeId(e.target);
if (source === target) return;
if (graph.hasNode(source) && graph.hasNode(target)) {
graph.addEdge(source, target, {
type: e.relation || e.type,
confidence: e.confidence,
metadata: e.metadata || {}
});
}
});
return graph;
}
async function searchGraph(targetDirOrGraph, term, targetType, page = 1, limit = 20) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const lowerTerm = term.toLowerCase();
const results = [];
graph.forEachNode((nodeId, attr) => {
if (targetType && attr.type !== targetType) return;
if (nodeId.toLowerCase().includes(lowerTerm) || attr.name && attr.name.toLowerCase().includes(lowerTerm)) {
results.push({
id: nodeId,
name: attr.name,
type: attr.type,
file: attr.file,
links: graph.degree(nodeId)
});
}
});
results.sort((a, b) => b.links - a.links);
const total = results.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
const end = start + limit;
return {
data: results.slice(start, end),
meta: {
page,
limit,
total,
totalPages
}
};
}
function resolveTargetNodeId(graph, symbol, targetType, targetSource) {
const normSymbol = normalizeId(symbol);
let targetNodeId = graph.hasNode(normSymbol) ? normSymbol : null;
if (targetNodeId && (targetType || targetSource)) {
const attr = graph.getNodeAttributes(targetNodeId);
if (targetType && attr.type !== targetType) {
targetNodeId = null;
}
if (targetNodeId && targetSource) {
const normSource = normalizeId(targetSource);
if (!attr.file.toLowerCase().endsWith(normSource.toLowerCase())) {
targetNodeId = null;
}
}
}
if (!targetNodeId) {
targetNodeId = graph.findNode((nodeId, attr) => {
if (targetType && attr.type !== targetType) return false;
if (targetSource) {
const normSource = normalizeId(targetSource);
if (!attr.file.endsWith(normSource)) return false;
}
return attr && attr.name && attr.name === symbol || nodeId === normSymbol || nodeId.endsWith("/" + normSymbol) || nodeId.endsWith("::" + normSymbol);
}) ?? null;
}
if (!targetNodeId) {
targetNodeId = graph.findNode((nodeId, attr) => {
if (targetType && attr.type !== targetType) return false;
if (targetSource) {
const normSource = normalizeId(targetSource);
if (!attr.file.toLowerCase().endsWith(normSource.toLowerCase()))
return false;
}
return attr && attr.name && attr.name.toLowerCase() === symbol.toLowerCase() || nodeId.toLowerCase() === normSymbol.toLowerCase() || nodeId.toLowerCase().endsWith("/" + normSymbol.toLowerCase()) || nodeId.toLowerCase().endsWith("::" + normSymbol.toLowerCase());
}) ?? null;
}
if (!targetNodeId) {
const partialMatches = [];
graph.forEachNode((nodeId, attr) => {
if (targetType && attr.type !== targetType) return;
if (targetSource) {
const normSource = normalizeId(targetSource);
if (!attr.file.toLowerCase().endsWith(normSource.toLowerCase())) return;
}
const attrName = attr.name || "";
if (nodeId.includes(symbol) || attrName.includes(symbol)) {
partialMatches.push(nodeId);
}
});
if (partialMatches.length === 0) {
const lowerSymbol = symbol.toLowerCase();
graph.forEachNode((nodeId, attr) => {
if (targetType && attr.type !== targetType) return;
if (targetSource) {
const normSource = normalizeId(targetSource);
if (!attr.file.toLowerCase().endsWith(normSource.toLowerCase())) return;
}
const nodeIdLower = nodeId.toLowerCase();
const attrNameLower = (attr.name || "").toLowerCase();
if (nodeIdLower.includes(lowerSymbol) || attrNameLower.includes(lowerSymbol)) {
partialMatches.push(nodeId);
}
});
}
if (partialMatches.length === 1) {
targetNodeId = partialMatches[0];
} else if (partialMatches.length > 1) {
const lowerSymbol = symbol.toLowerCase();
const commitMatch = partialMatches.find((id) => {
const parts = id.split("::");
return parts[0] === "commit" && parts[1]?.toLowerCase().startsWith(lowerSymbol);
});
if (commitMatch) {
targetNodeId = commitMatch;
} else {
partialMatches.sort((a, b) => graph.degree(b) - graph.degree(a));
targetNodeId = partialMatches[0];
}
}
}
if (!targetNodeId) {
const typeMsg = targetType ? ` of type '${targetType}'` : "";
const sourceMsg = targetSource ? ` in source '${targetSource}'` : "";
throw new Error(
`Symbol '${symbol}'${typeMsg}${sourceMsg} not found in the graph.`
);
}
return targetNodeId;
}
async function getNode(targetDirOrGraph, symbol, targetType, targetSource) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const targetNodeId = resolveTargetNodeId(
graph,
symbol,
targetType,
targetSource
);
const targetAttr = graph.getNodeAttributes(targetNodeId);
return {
id: targetNodeId,
name: targetAttr.name,
type: targetAttr.type,
file: targetAttr.file,
line: targetAttr.startLine,
metadata: targetAttr.metadata,
links: {
incoming: graph.inDegree(targetNodeId),
outgoing: graph.outDegree(targetNodeId)
}
};
}
async function getNeighbors(targetDirOrGraph, symbol, targetType, targetSource, page = 1, limit = 20) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const targetNodeId = resolveTargetNodeId(
graph,
symbol,
targetType,
targetSource
);
const targetAttr = graph.getNodeAttributes(targetNodeId);
const result = {
target: {
id: targetNodeId,
name: targetAttr.name,
type: targetAttr.type,
file: targetAttr.file,
line: targetAttr.startLine
},
incoming: [],
outgoing: [],
meta: {
page,
limit,
totalIncoming: 0,
totalOutgoing: 0,
totalPages: 1
}
};
const collectEdges = (nodeId, isOutgoing, seenKeys) => {
const iterator = isOutgoing ? graph.forEachOutEdge.bind(graph) : graph.forEachInEdge.bind(graph);
iterator(nodeId, (edge, attr, source, target) => {
const neighborId = isOutgoing ? target : source;
if (neighborId === targetNodeId) return;
const key = `${neighborId}:${attr.type}`;
if (seenKeys.has(key)) return;
seenKeys.add(key);
const neighborAttr = graph.getNodeAttributes(neighborId);
const nodeInfo = {
id: neighborId,
name: neighborAttr.name,
type: neighborAttr.type,
file: neighborAttr.file,
line: neighborAttr.startLine
};
if (isOutgoing) {
result.outgoing.push({
relation: attr.type,
confidence: attr.confidence,
target: nodeInfo
});
} else {
result.incoming.push({
relation: attr.type,
confidence: attr.confidence,
source: nodeInfo
});
}
});
};
const seenIn = /* @__PURE__ */ new Set();
const seenOut = /* @__PURE__ */ new Set();
collectEdges(targetNodeId, false, seenIn);
collectEdges(targetNodeId, true, seenOut);
const sortEdges = (a, b) => {
const nodeA = a.source || a.target;
const nodeB = b.source || b.target;
if (nodeA.type === "intent" && nodeB.type !== "intent") return -1;
if (nodeB.type === "intent" && nodeA.type !== "intent") return 1;
const degreeA = graph.degree(nodeA.id) || 0;
const degreeB = graph.degree(nodeB.id) || 0;
return degreeB - degreeA;
};
result.incoming.sort(sortEdges);
result.outgoing.sort(sortEdges);
const totalIncoming = result.incoming.length;
const totalOutgoing = result.outgoing.length;
const maxTotal = Math.max(totalIncoming, totalOutgoing);
const totalPages = Math.ceil(maxTotal / limit) || 1;
const start = (page - 1) * limit;
const end = start + limit;
result.incoming = result.incoming.slice(start, end);
result.outgoing = result.outgoing.slice(start, end);
result.meta = {
page,
limit,
totalIncoming,
totalOutgoing,
totalPages
};
return result;
}
function undirectedShortestPath(graph, source, target) {
if (source === target) return [source];
const queue = [source];
const visited = /* @__PURE__ */ new Set([source]);
const parent = /* @__PURE__ */ new Map();
while (queue.length > 0) {
const current = queue.shift();
if (current === target) break;
graph.forEachNeighbor(current, (neighbor) => {
if (!visited.has(neighbor)) {
visited.add(neighbor);
parent.set(neighbor, current);
queue.push(neighbor);
}
});
}
if (!parent.has(target)) return null;
const path2 = [];
let curr = target;
while (curr !== source) {
path2.unshift(curr);
curr = parent.get(curr);
}
path2.unshift(source);
return path2;
}
async function shortestPath(targetDirOrGraph, sourceSymbol, targetSymbol, maxHops = 8) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const normSource = resolveTargetNodeId(graph, sourceSymbol);
const normTarget = resolveTargetNodeId(graph, targetSymbol);
if (normSource === normTarget) {
throw new Error(`Source and target nodes are identical: '${sourceSymbol}'`);
}
const path2 = undirectedShortestPath(graph, normSource, normTarget);
if (!path2) {
throw new Error("No path exists between the given nodes");
}
const hops = path2.length - 1;
if (hops > maxHops) {
return `Path exceeds max_hops=${maxHops} (${hops} hops found).`;
}
const segments = [];
for (let i = 0; i < path2.length - 1; i++) {
const u = path2[i];
const v = path2[i + 1];
let forward = true;
let edgeId;
if (graph.hasDirectedEdge(u, v)) {
edgeId = graph.edges(u, v)[0];
} else if (graph.hasDirectedEdge(v, u)) {
edgeId = graph.edges(v, u)[0];
forward = false;
}
const edata = edgeId ? graph.getEdgeAttributes(edgeId) : void 0;
const rel = edata?.type || "";
const conf = edata?.confidence || "";
const confStr = conf ? ` [${conf}]` : "";
const uLabel = String(graph.getNodeAttribute(u, "name") || u);
const vLabel = String(graph.getNodeAttribute(v, "name") || v);
if (i === 0) {
segments.push(uLabel);
}
if (forward) {
segments.push(`--${rel}${confStr}--> ${vLabel}`);
} else {
segments.push(`<--${rel}${confStr}-- ${vLabel}`);
}
}
return `Shortest path (${hops} hops):
` + segments.join(" ");
}
async function getGodNodes(targetDirOrGraph, page = 1, limit = 10) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const { findGodNodes } = await import("./analyze-6FZL63EZ.js");
const allGods = findGodNodes(graph, graph.order);
const total = allGods.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
const end = start + limit;
const paginated = allGods.slice(start, end);
const lines = ["God nodes (most connected):"];
paginated.forEach((n, idx) => {
const globalIdx = start + idx + 1;
lines.push(` ${globalIdx}. ${n.name} [id: ${n.id}] - ${n.degree} edges`);
});
if (totalPages > 1) {
lines.push(`
[Page ${page} of ${totalPages} | Total: ${total} nodes]`);
}
return lines.join("\n");
}
async function getCommunityNodes(targetDirOrGraph, communityId, page = 1, limit = 20) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const { detectCommunities } = await import("./analyze-6FZL63EZ.js");
const communities = detectCommunities(graph);
const targetCommunity = communities.find((c) => c.id === communityId);
if (!targetCommunity) {
throw new Error(`Community ${communityId} not found.`);
}
const total = targetCommunity.nodes.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
const end = start + limit;
const paginatedNodes = targetCommunity.nodes.slice(start, end);
const lines = [`Community ${communityId} (${total} nodes):`];
paginatedNodes.forEach((nodeId) => {
const attr = graph.getNodeAttributes(nodeId);
const label = attr.name || nodeId;
lines.push(` ${label} (type: ${attr.type}) [id: ${nodeId}]`);
});
if (totalPages > 1) {
lines.push(`
[Page ${page} of ${totalPages} | Total: ${total} nodes]`);
}
return lines.join("\n");
}
async function getSurprisingConnections(targetDirOrGraph, page = 1, limit = 20) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const { detectCommunities, findSurprisingConnections } = await import("./analyze-6FZL63EZ.js");
const communities = detectCommunities(graph);
const surprises = findSurprisingConnections(graph, communities, graph.size);
const total = surprises.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
const end = start + limit;
const paginated = surprises.slice(start, end);
if (total === 0) {
return "No surprising connections found.";
}
const lines = ["Surprising cross-community connections:"];
paginated.forEach((s) => {
lines.push(
` ${s.sourceName} <-> ${s.targetName} [${s.edgeType}] - ${s.why}`
);
});
if (totalPages > 1) {
lines.push(
`
[Page ${page} of ${totalPages} | Total: ${total} connections]`
);
}
return lines.join("\n");
}
function scoreNodes(graph, terms) {
const EXACT_MATCH_BONUS = 1e3;
const PREFIX_MATCH_BONUS = 100;
const SUBSTRING_MATCH_BONUS = 1;
const SOURCE_MATCH_BONUS = 0.5;
const scored = [];
graph.forEachNode((nodeId, attr) => {
const name = (attr.name || "").toLowerCase();
const source = (attr.file || "").toLowerCase();
const nidLower = nodeId.toLowerCase();
let score = 0;
for (const t of terms) {
if (t === name || t === nidLower) {
score += EXACT_MATCH_BONUS;
} else if (name.startsWith(t) || nidLower.startsWith(t)) {
score += PREFIX_MATCH_BONUS;
} else if (name.includes(t) || nidLower.includes(t)) {
score += SUBSTRING_MATCH_BONUS;
}
if (source.includes(t)) {
score += SOURCE_MATCH_BONUS;
}
}
if (score > 0) {
scored.push([score, nodeId]);
}
});
return scored.sort((a, b) => b[0] - a[0]);
}
async function queryGraph(targetDirOrGraph, symbol, mode = "bfs", depth = 3, tokenBudget = 2e3) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const terms = symbol.split(/\s+/).map((t) => t.replace(/[?,.:;!]/g, "").toLowerCase().trim()).filter((t) => t.length > 2);
let startNodes = [];
if (terms.length > 0) {
const scored = scoreNodes(graph, terms);
startNodes = scored.slice(0, 3).map((item) => item[1]);
}
if (startNodes.length === 0) {
try {
const fallbackId = resolveTargetNodeId(graph, symbol);
startNodes = [fallbackId];
} catch {
throw new Error(`No matching nodes found for query: '${symbol}'`);
}
}
const degrees = [];
graph.forEachNode((nodeId) => {
degrees.push(graph.degree(nodeId));
});
let hubThreshold = 50;
if (degrees.length > 0) {
const sorted = [...degrees].sort((a, b) => a - b);
const p99Idx = Math.floor(sorted.length * 0.99);
hubThreshold = Math.max(50, sorted[p99Idx] || 50);
}
const seedSet = new Set(startNodes);
const visited = new Set(startNodes);
const edges = [];
if (mode === "bfs") {
const queue = startNodes.map((n) => [n, 0]);
while (queue.length > 0) {
const [curr, d] = queue.shift();
if (d >= depth) continue;
if (!seedSet.has(curr) && graph.degree(curr) >= hubThreshold) {
continue;
}
graph.forEachNeighbor(curr, (neighbor) => {
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push([neighbor, d + 1]);
}
edges.push([curr, neighbor]);
});
}
} else {
const stack = [...startNodes].reverse().map((n) => [n, 0]);
while (stack.length > 0) {
const [curr, d] = stack.pop();
if (d >= depth) continue;
if (!seedSet.has(curr) && graph.degree(curr) >= hubThreshold) {
continue;
}
graph.forEachNeighbor(curr, (neighbor) => {
if (!visited.has(neighbor)) {
visited.add(neighbor);
stack.push([neighbor, d + 1]);
}
edges.push([curr, neighbor]);
});
}
}
const traversedNodes = Array.from(visited);
const remainingNodes = traversedNodes.filter((n) => !seedSet.has(n));
remainingNodes.sort((a, b) => graph.degree(b) - graph.degree(a));
const orderedNodes = startNodes.filter((n) => visited.has(n)).concat(remainingNodes);
const seenEdges = /* @__PURE__ */ new Set();
const finalEdges = [];
edges.forEach(([u, v]) => {
if (visited.has(u) && visited.has(v)) {
let edgeId;
let forward = true;
if (graph.hasDirectedEdge(u, v)) {
edgeId = graph.edges(u, v)[0];
} else if (graph.hasDirectedEdge(v, u)) {
edgeId = graph.edges(v, u)[0];
forward = false;
}
if (edgeId) {
const key = forward ? `${u}->${v}` : `${v}->${u}`;
if (!seenEdges.has(key)) {
seenEdges.add(key);
const attr = graph.getEdgeAttributes(edgeId);
finalEdges.push({
u: forward ? u : v,
v: forward ? v : u,
rel: attr.type || "",
conf: attr.confidence || ""
});
}
}
}
});
const startNames = startNodes.map(
(n) => graph.getNodeAttribute(n, "name") || n
);
const header = `Traversal: ${mode.toUpperCase()} depth=${depth} | Start: [${startNames.join(", ")}] | ${orderedNodes.length} nodes found
`;
const charBudget = tokenBudget * 3;
const lines = [];
orderedNodes.forEach((nid) => {
const attr = graph.getNodeAttributes(nid);
const name = attr.name || nid;
const file = attr.file || "";
const loc = attr.startLine || 0;
const community = attr.metadata?.community !== void 0 ? attr.metadata.community : "";
lines.push(`NODE ${name} [src=${file} loc=${loc} community=${community}]`);
});
finalEdges.forEach(({ u, v, rel, conf }) => {
const uLabel = graph.getNodeAttribute(u, "name") || u;
const vLabel = graph.getNodeAttribute(v, "name") || v;
const confStr = conf ? ` [${conf}]` : "";
lines.push(`EDGE ${uLabel} --${rel}${confStr}--> ${vLabel}`);
});
let output = header + lines.join("\n");
if (output.length > charBudget) {
output = output.slice(0, charBudget) + `
... (truncated to ~${tokenBudget} token budget)`;
}
return output;
}
async function getGraphStats(targetDirOrGraph) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
let extractedCount = 0;
let inferredCount = 0;
let ambiguousCount = 0;
graph.forEachEdge((edgeId, attr) => {
if (attr.confidence === "EXTRACTED") extractedCount++;
else if (attr.confidence === "INFERRED") inferredCount++;
else if (attr.confidence === "AMBIGUOUS") ambiguousCount++;
});
const total = graph.size || 1;
const extPct = (extractedCount / total * 100).toFixed(1);
const infPct = (inferredCount / total * 100).toFixed(1);
const ambPct = (ambiguousCount / total * 100).toFixed(1);
let communitiesCount = 0;
try {
const { detectCommunities } = await import("./analyze-6FZL63EZ.js");
communitiesCount = detectCommunities(graph).length;
} catch {
}
return [
`Nodes: ${graph.order}`,
`Edges: ${graph.size}`,
`Communities: ${communitiesCount}`,
`EXTRACTED: ${extractedCount} (${extPct}%)`,
`INFERRED: ${inferredCount} (${infPct}%)`,
`AMBIGUOUS: ${ambiguousCount} (${ambPct}%)`
].join("\n");
}
export {
getCommunityNodes,
getGodNodes,
getGraphStats,
getNeighbors,
getNode,
getSurprisingConnections,
loadGraph,
queryGraph,
searchGraph,
shortestPath
};
// src/core/serializer.ts
import fs from "fs";
import path from "path";
function exportGraphJson(graph, outDir, analysis) {
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
const nodes = graph.nodes().map((nodeId) => {
const data = graph.getNodeAttributes(nodeId);
return {
id: nodeId,
type: data.type,
name: data.name,
file: data.file,
startLine: data.startLine,
...data.metadata
};
});
const edges = graph.edges().map((edgeId) => {
const source = graph.source(edgeId);
const target = graph.target(edgeId);
const data = graph.getEdgeAttributes(edgeId);
return {
source,
target,
relation: data.type,
confidence: data.confidence,
...data.metadata
};
});
const payload = {
version: "1.0.0",
nodes,
edges
};
if (analysis) {
payload.analysis = {
godNodes: analysis.godNodes,
communities: analysis.communities.map((c) => ({
id: c.id,
nodeCount: c.nodes.length,
cohesion: c.cohesion,
members: c.nodes
})),
surprisingConnections: analysis.surprisingConnections
};
}
const jsonPath = path.join(outDir, "graph.json");
fs.writeFileSync(jsonPath, JSON.stringify(payload, null, 2), "utf-8");
return jsonPath;
}
function exportReportMarkdown(graph, outDir, analysis) {
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
let md = `# Geraph Codebase Report
`;
md += `## Architecture Structure
`;
md += `*Note: Only the first 100 files and their primary members are listed here. For full data, use the \`search_graph\` or \`query_graph\` MCP tools (recommended) or corresponding CLI commands.*
`;
const allFileNodes = graph.nodes().filter((n) => graph.getNodeAttribute(n, "type") === "file");
const fileNodes = allFileNodes.slice(0, 100);
for (const file of fileNodes) {
const data = graph.getNodeAttributes(file);
if (data.metadata?.external) continue;
md += `- **${data.name}** [id: \`${file}\`]
`;
const definesEdges = graph.outEdges(file).filter((edgeId) => {
return graph.getEdgeAttribute(edgeId, "type") === "defines";
});
const membersToShow = definesEdges.slice(0, 5);
for (const edgeId of membersToShow) {
const target = graph.target(edgeId);
const targetData = graph.getNodeAttributes(target);
md += ` - \`${targetData.type} ${targetData.name}\` [id: \`${target}\`]
`;
}
if (definesEdges.length > 5) {
md += ` - ... and ${definesEdges.length - 5} more
`;
}
}
if (allFileNodes.length > 100) {
md += `
- *... and ${allFileNodes.length - 100} more file nodes. Use \`search_graph\` or \`query_graph\` MCP tools (recommended) to explore them.*
`;
}
if (analysis && analysis.godNodes.length > 0) {
const realNodesCount = graph.nodes().filter((n) => {
const type = graph.getNodeAttribute(n, "type");
return type !== "intent" && !n.startsWith("import::") && !n.startsWith("unresolved_");
}).length;
const title = realNodesCount > 10 ? "Top 10 God Nodes (Architectural Pillars)" : "God Nodes (Architectural Pillars)";
md += `
## ${title}
`;
md += `These are the most-connected entities in the codebase. Changes to these nodes have the largest ripple effect. For full data, use the \`god_nodes\` MCP tool (recommended) or \`geraph god\` CLI command.
`;
const godsToShow = analysis.godNodes.slice(0, 10);
for (const god of godsToShow) {
md += `- **${god.name}** (type: \`${god.type}\`, id: \`${god.id}\`, ${god.degree} connections)
`;
}
}
if (analysis && analysis.communities.length > 0) {
md += `
## Communities
`;
md += `The codebase clusters into ${analysis.communities.length} communities of related code. For full community membership, use the \`get_community\` MCP tool (recommended) or \`geraph community <id>\` CLI command.
`;
for (const comm of analysis.communities) {
const realNodes = comm.nodes.filter(
(n) => !n.startsWith("commit::") && !n.startsWith("import::") && !n.startsWith("unresolved_") && graph.hasNode(n)
);
realNodes.sort((a, b) => graph.degree(b) - graph.degree(a));
const top5 = realNodes.slice(0, 5);
const memberStrings = top5.map((n) => {
const data = graph.getNodeAttributes(n);
const name = data ? data.name : n;
return `**${name}** [id: \`${n}\`]`;
});
let communityLine = `- **Community (ID: \`${comm.id}\`)** (${comm.nodes.length} nodes, cohesion: ${comm.cohesion}) \u2014 ${memberStrings.join(", ")}`;
if (realNodes.length > 5) {
communityLine += `, and ${realNodes.length - 5} more.`;
}
communityLine += `
*(To view all members: run \`geraph community ${comm.id}\` or use the \`get_community\` MCP tool with community_id=${comm.id})*
`;
md += communityLine;
}
}
if (analysis && analysis.surprisingConnections.length > 0) {
const totalSurprises = analysis.surprisingConnections.length;
const title = totalSurprises > 10 ? "Top 10 Surprising Connections" : "Surprising Connections";
md += `
## ${title}
`;
md += `Non-obvious couplings that bridge different parts of the architecture. For full data, use the \`get_surprises\` MCP tool (recommended) or \`geraph surprises\` CLI command.
`;
const surprisesToShow = analysis.surprisingConnections.slice(0, 10);
for (const s of surprisesToShow) {
md += `- **${s.sourceName}** [id: \`${s.source}\`] \u2194 **${s.targetName}** [id: \`${s.target}\`] (\`${s.edgeType}\`): ${s.why}
`;
}
}
if (analysis && analysis.knowledgeGaps && analysis.knowledgeGaps.length > 0) {
md += `
## Knowledge Gaps
`;
md += `These are isolated or nearly-isolated entities that may be missing documentation or architectural integration.
`;
for (const gap of analysis.knowledgeGaps) {
const data = graph.getNodeAttributes(gap);
md += `- \`${data.type} ${data.name}\` (isolated in ${path.basename(gap.split("::")[0] || gap)})
`;
}
}
if (analysis && analysis.suggestedQuestions && analysis.suggestedQuestions.length > 0) {
md += `
## Suggested Questions for AI
`;
md += `_Questions this graph is uniquely positioned to answer:_
`;
for (const q of analysis.suggestedQuestions) {
md += `- **${q}**
`;
}
}
const intentNodes = graph.nodes().filter((n) => graph.getNodeAttribute(n, "type") === "intent").sort((a, b) => {
const metaA = graph.getNodeAttribute(a, "metadata");
const metaB = graph.getNodeAttribute(b, "metadata");
const dateA = metaA?.date || "";
const dateB = metaB?.date || "";
return dateB.localeCompare(dateA);
}).slice(0, 50);
if (intentNodes.length > 0) {
md += `
## Recent Architectural Changes & Intent
`;
md += `*Showing the 50 most recent architectural commits. Use 'query' on a specific symbol to see its full history.*
`;
for (const intent of intentNodes) {
const data = graph.getNodeAttributes(intent);
const msg = (data.metadata?.message || "").split("\n")[0];
md += `- **${data.name}**: ${msg}
`;
}
}
md += `
---
*Generated by Geraph at ${(/* @__PURE__ */ new Date()).toISOString()}*
`;
const mdPath = path.join(outDir, "GRAPH_REPORT.md");
fs.writeFileSync(mdPath, md, "utf-8");
return mdPath;
}
function exportGraphHtml(graph, outDir, analysis) {
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
const counts = {
file: 0,
media: 0,
class: 0,
function: 0,
intent: 0,
type: 0,
interface: 0,
enum: 0,
struct: 0,
trait: 0,
macro: 0
};
const nodeDegrees = /* @__PURE__ */ new Map();
graph.nodes().forEach((n) => {
nodeDegrees.set(n, graph.degree(n));
});
const sortedByDegree = [...graph.nodes()].sort(
(a, b) => (nodeDegrees.get(b) || 0) - (nodeDegrees.get(a) || 0)
);
const topHubs = new Set(sortedByDegree.slice(0, 50));
const maxDegree = Math.max(...Array.from(nodeDegrees.values()), 1);
const COLORS = {
default: "#64748b",
file: "#479af3ff",
class: "#F28E2B",
function: "#ee272bff",
intent: "#35d86eff",
media: "#843b11ff",
type: "#B07AA1",
interface: "#e134b0ff",
enum: "#59A14F",
struct: "#E15759",
trait: "#76B7B2",
macro: "#EDC948"
};
const nodeCommunities = /* @__PURE__ */ new Map();
if (analysis) {
analysis.communities.forEach((comm) => {
comm.nodes.forEach((nodeId) => {
nodeCommunities.set(nodeId, String(comm.id));
});
});
}
const RAW_COMMUNITIES = analysis ? analysis.communities.map((c, idx) => {
const hue = idx * 137.5 % 360;
const color = `hsl(${hue}, 70%, 50%)`;
return {
id: String(c.id),
name: `Community ${c.id}`,
nodeCount: c.nodes.length,
cohesion: c.cohesion,
color,
members: c.nodes
};
}) : [];
const RAW_NODES = graph.nodes().map((n) => {
const data = graph.getNodeAttributes(n);
const degree = nodeDegrees.get(n) || 0;
const scaledSize = 10 + 30 * (degree / maxDegree);
const color = COLORS[data.type] ?? COLORS.default;
if (data.type && counts[data.type] !== void 0) {
counts[data.type] = (counts[data.type] || 0) + 1;
}
const showLabel = graph.order <= 50 || topHubs.has(n);
const fontSize = showLabel ? 12 : 0;
let sourceFile = "";
if (n.includes("::")) {
sourceFile = n.split("::")[0];
} else if (data.type === "file") {
sourceFile = n;
}
return {
id: n,
label: data.name || n,
title: data.name || n,
color: {
background: color,
border: color,
highlight: { background: "#ffffff", border: color },
hover: { background: color, border: "#ffffff" }
},
size: scaledSize,
font: {
size: fontSize,
color: "#ffffff",
strokeWidth: 2,
strokeColor: "#0f0f1a"
},
node_type: data.type,
source_file: sourceFile,
degree,
startLine: data.startLine,
community: nodeCommunities.get(n) ?? "none",
...data.metadata
};
});
const RAW_EDGES = graph.edges().map((id) => {
const attr = graph.getEdgeAttributes(id);
return {
from: graph.source(id),
to: graph.target(id),
relation: attr.type,
confidence: attr.confidence,
color: { color: "rgba(255,255,255,0.15)", highlight: "#ffffff" },
arrows: { to: { enabled: true, scaleFactor: 0.5 } },
smooth: { type: "continuous", roundness: 0.2 },
width: 1
};
});
const htmlContent = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Geraph Knowledge Graph</title>
<script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
<style>
body, html {
margin: 0; padding: 0; width: 100%; height: 100%;
background: #0f0f1a; color: #fff; overflow: hidden;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#container { width: 100%; height: 100%; }
#loading-overlay {
position: absolute; inset: 0; background: #0f0f1a;
display: flex; align-items: center; justify-content: center;
z-index: 200; font-size: 1rem; color: #3b82f6;
transition: opacity 0.5s ease;
}
#sidebar {
position: absolute; left: 20px; top: 20px; bottom: 20px;
width: 320px; background: rgb(12.941% 12.941% 12.941%);
border-radius: 30px;
display: flex; flex-direction: column; z-index: 100;
}
#sidebar-header { padding: 20px; border-bottom: 1px solid rgba(255,255,255,0.1); }
#sidebar-header h1 { margin: 0; font-size: 1.2rem; color: #fff; }
#sidebar-header p { margin: 5px 0 0; font-size: 0.8rem; color: #888; }
#search-box { padding: 15px; position: relative; }
#search {
width: 100%; padding: 10px; background: rgb(17.255% 17.255% 17.255%);
border: none; border-radius: 15px;
color: #fff; box-sizing: border-box; font-family: inherit;
}
#search-suggestions {
position: absolute;
left: 15px;
right: 15px;
top: calc(100% - 5px);
background: rgb(17.255% 17.255% 17.255%);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 15px;
max-height: 250px;
overflow-y: auto;
z-index: 150;
box-shadow: 0 10px 30px rgba(0,0,0,0.6);
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.1) transparent;
}
.suggestion-item {
padding: 10px 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background 0.15s ease;
font-size: 0.85rem;
}
.suggestion-item:last-child {
border-bottom: none;
}
.suggestion-item:hover, .suggestion-item.active {
background: rgba(83, 83, 83, 0.25);
}
.suggestion-name {
color: #fff;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 190px;
}
.suggestion-type {
font-size: 0.65rem;
font-weight: 600;
color: #b8b8b8;
text-transform: uppercase;
background: rgba(255, 255, 255, 0.08);
padding: 2px 6px;
border-radius: 4px;
}
#info-panel { flex: 1; padding: 20px; overflow-y: auto; font-size: 0.9rem; scrollbar-color: rgba(255,255,255,0.1) transparent; scrollbar-width: thin }
#info-panel h3 { margin-top: 0; font-size: 1rem; color: #3b82f6; }
#info-content { word-break: break-word; }
.field { margin-bottom: 12px; }
.field b { color: #888; display: block; font-size: 0.75rem; text-transform: uppercase; margin-bottom: 2px; }
.empty { color: #555; font-style: italic; }
#legend { padding: 20px; border-top: 1px solid rgba(255,255,255,0.1); display: flex; flex-direction: column; gap: 0.3rem; max-height: 100px; overflow-y: auto; scrollbar-color: rgba(255,255,255,0.1) transparent; scrollbar-width: thin }
.legend-item { display: flex; align-items: center; justify-content: space-between; font-size: 0.8rem; cursor: pointer; transition: opacity 0.2s; user-select: none; }
.legend-item:hover { opacity: 0.8; }
.legend-item.inactive { opacity: 0.35; }
.legend-item-left { display: flex; align-items: center; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; }
.legend-count { color: #666; font-weight: 600; }
#stats-container {
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0 0 30px 30px;
}
#view-tabs {
display: flex;
width: 100%;
}
#view-tabs.no-communities button[data-mode="communities"] {
display: none;
}
#view-tabs.no-communities button[data-mode="types"] {
width: 100%;
cursor: default;
background: transparent;
border-radius: 0 0 30px 30px;
}
.tab-btn {
flex: 1;
background: transparent;
border: none;
color: #666;
padding: 12px 10px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, color 0.2s;
outline: none;
text-align: center;
border-right: 1px solid rgba(255, 255, 255, 0.05);
}
.tab-btn:last-child {
border-right: none;
border-radius: 0 0 30px 0px !important;
}
.tab-btn.active {
background: rgba(255, 255, 255, 0.05);
color: #fff;
border-radius: 0 0 0px 30px;
}
.tab-btn:hover:not(.active) {
background: rgba(255, 255, 255, 0.02);
color: #aaa;
border-radius: 0 0 0px 30px;
}
.neighbors-section {
margin-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 12px;
}
.neighbors-header {
font-size: 0.8rem;
font-weight: 600;
color: #888;
text-transform: uppercase;
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.neighbor-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.neighbor-item {
padding: 6px 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 4px;
cursor: pointer;
display: flex;
flex-direction: column;
transition: background 0.2s, border-color 0.2s;
}
.neighbor-item:hover {
background: rgba(168, 168, 168, 0.15);
border-color: rgba(146, 146, 146, 0.3);
}
.neighbor-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.7rem;
margin-bottom: 3px;
}
.neighbor-relation {
color: #3b82f6;
font-weight: 600;
text-transform: uppercase;
}
.neighbor-confidence {
color: #888;
background: rgba(255, 255, 255, 0.05);
padding: 1px 4px;
border-radius: 3px;
}
.neighbor-main {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8rem;
}
.neighbor-main-left {
display: flex;
align-items: center;
gap: 8px;
}
.neighbor-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.neighbor-name {
color: #fff;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.neighbor-type {
font-size: 0.65rem;
color: #888;
text-transform: uppercase;
white-space: nowrap;
}
.show-more-btn {
background: none;
border: none;
color: #3b82f6;
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
padding: 6px 0;
text-align: left;
transition: color 0.2s;
outline: none;
width: 100%;
}
.show-more-btn:hover {
color: #60a5fa;
}
.hidden { display: none !important; }
</style>
</head>
<body>
<div id="loading-overlay">Loading Knowledge Graph...</div>
<div id="sidebar">
<div id="sidebar-header">
<h1>Geraph Map</h1>
<p>Codebase Knowledge Graph</p>
</div>
<div id="search-box">
<input type="text" id="search" placeholder="Search nodes..." autocomplete="off">
<div id="search-suggestions" class="hidden"></div>
</div>
<div id="info-panel">
<div id="info-content">
<span class="empty">Select a node to view its properties</span>
</div>
</div>
<div id="legend">
<div class="legend-item ${counts.file ? "" : "hidden"}" data-type="file"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.file};"></div> File</div><span class="legend-count">${counts.file || 0}</span></div>
<div class="legend-item ${counts.media ? "" : "hidden"}" data-type="media"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.media};"></div> Media</div><span class="legend-count">${counts.media || 0}</span></div>
<div class="legend-item ${counts.class ? "" : "hidden"}" data-type="class"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.class};"></div> Class</div><span class="legend-count">${counts.class || 0}</span></div>
<div class="legend-item ${counts.struct ? "" : "hidden"}" data-type="struct"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.struct};"></div> Struct</div><span class="legend-count">${counts.struct || 0}</span></div>
<div class="legend-item ${counts.trait ? "" : "hidden"}" data-type="trait"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.trait};"></div> Trait</div><span class="legend-count">${counts.trait || 0}</span></div>
<div class="legend-item ${counts.macro ? "" : "hidden"}" data-type="macro"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.macro};"></div> Macro</div><span class="legend-count">${counts.macro || 0}</span></div>
<div class="legend-item ${counts.function ? "" : "hidden"}" data-type="function"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.function};"></div> Function</div><span class="legend-count">${counts.function || 0}</span></div>
<div class="legend-item ${counts.type || counts.interface ? "" : "hidden"}" data-type="type,interface"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.type};"></div> Type/Interface</div><span class="legend-count">${(counts.type || 0) + (counts.interface || 0)}</span></div>
<div class="legend-item ${counts.enum ? "" : "hidden"}" data-type="enum"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.enum};"></div> Enum</div><span class="legend-count">${counts.enum || 0}</span></div>
<div class="legend-item ${counts.intent ? "" : "hidden"}" data-type="intent"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.intent};"></div> Intent</div><span class="legend-count">${counts.intent || 0}</span></div>
</div>
<div id="stats-container">
<div id="view-tabs" class="${RAW_COMMUNITIES.length > 0 ? "" : "no-communities"}">
<button class="tab-btn active" data-mode="types">${RAW_NODES.length} nodes &middot; ${RAW_EDGES.length} edges</button>
<button class="tab-btn" data-mode="communities">${RAW_COMMUNITIES.length} communities</button>
</div>
</div>
</div>
<div id="container"></div>
<script type="text/javascript">
const RAW_NODES = ${JSON.stringify(RAW_NODES).replace(/</g, "\\u003c")};
const RAW_EDGES = ${JSON.stringify(RAW_EDGES).replace(/</g, "\\u003c")};
const RAW_COMMUNITIES = ${JSON.stringify(RAW_COMMUNITIES).replace(/</g, "\\u003c")};
function escapeHtmlAttr(str) {
return (str || '').replace(/"/g, '&quot;');
}
function escapeHtml(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
const hiddenNodeTypes = new Set();
const hiddenCommunities = new Set();
let currentViewMode = 'types';
const legendTypesHtml = document.getElementById('legend') ? document.getElementById('legend').innerHTML : '';
function renderLegend(mode) {
const legendContainer = document.getElementById('legend');
if (!legendContainer) return;
if (mode === 'types') {
legendContainer.innerHTML = legendTypesHtml;
const items = legendContainer.querySelectorAll('.legend-item');
items.forEach(item => {
const typesStr = item.getAttribute('data-type');
if (typesStr) {
const types = typesStr.split(',');
if (hiddenNodeTypes.has(types[0])) {
item.classList.add('inactive');
} else {
item.classList.remove('inactive');
}
}
});
} else {
const sortedCommunities = [...RAW_COMMUNITIES].sort((a, b) => parseInt(a.id) - parseInt(b.id));
legendContainer.innerHTML = sortedCommunities.map(comm => {
const isInactive = hiddenCommunities.has(comm.id) ? 'inactive' : '';
return \`<div class="legend-item \${isInactive}" data-community="\${comm.id}">
<div class="legend-item-left">
<div class="legend-dot" style="background: \${comm.color};"></div>
\${comm.name}
</div>
<span class="legend-count">\${comm.nodeCount}</span>
</div>\`;
}).join('');
}
}
function switchViewMode(mode) {
currentViewMode = mode;
const tabs = document.querySelectorAll('.tab-btn');
tabs.forEach(t => {
if (t.getAttribute('data-mode') === mode) {
t.classList.add('active');
} else {
t.classList.remove('active');
}
});
const updates = [];
for (const n of RAW_NODES) {
let color;
if (mode === 'communities') {
const comm = RAW_COMMUNITIES.find(c => c.id === n.community);
color = comm ? comm.color : '#64748b';
} else {
color = n.color.background;
}
updates.push({
id: n.id,
color: {
background: color,
border: color,
highlight: { background: '#ffffff', border: color },
hover: { background: color, border: '#ffffff' }
}
});
}
nodes.update(updates);
renderLegend(mode);
}
const viewTabsEl = document.getElementById('view-tabs');
if (viewTabsEl) {
viewTabsEl.addEventListener('click', (e) => {
const btn = e.target.closest('.tab-btn');
if (btn) {
const mode = btn.getAttribute('data-mode');
if (mode && mode !== currentViewMode) {
switchViewMode(mode);
}
}
});
}
function renderNeighborItem(edge, direction) {
const neighborId = direction === 'outgoing' ? edge.to : edge.from;
const neighbor = RAW_NODES.find(n => n.id === neighborId);
if (!neighbor) return '';
return \`<div class="neighbor-item" data-neighbor-id="\${escapeHtmlAttr(neighborId)}">
<div class="neighbor-meta">
<span class="neighbor-relation">\${escapeHtml(edge.relation || 'calls')}</span>
<span class="neighbor-confidence">\${escapeHtml(edge.confidence || 'EXTRACTED')}</span>
</div>
<div class="neighbor-main">
<div class="neighbor-main-left">
<div class="neighbor-dot" style="background: \${neighbor.color.background};"></div>
<span class="neighbor-name">\${escapeHtml(neighbor.label || neighborId)}</span>
</div>
<span class="neighbor-type">\${escapeHtml(neighbor.node_type)}</span>
</div>
</div>\`;
}
function toggleNeighbors(nodeId, direction, btn) {
const isExpanded = btn.getAttribute('data-state') === 'expanded';
const container = document.getElementById(direction + '-list-container');
if (!container) return;
const edges = RAW_EDGES.filter(e => {
if (direction === 'outgoing') {
if (e.from !== nodeId) return false;
const neighbor = RAW_NODES.find(n => n.id === e.to);
return neighbor && !hiddenNodeTypes.has(neighbor.node_type) && !hiddenCommunities.has(neighbor.community);
} else {
if (e.to !== nodeId) return false;
const neighbor = RAW_NODES.find(n => n.id === e.from);
return neighbor && !hiddenNodeTypes.has(neighbor.node_type) && !hiddenCommunities.has(neighbor.community);
}
});
const sortEdges = (a, b) => {
const neighborIdA = direction === 'outgoing' ? a.to : a.from;
const neighborIdB = direction === 'outgoing' ? b.to : b.from;
const nodeA = RAW_NODES.find(n => n.id === neighborIdA);
const nodeB = RAW_NODES.find(n => n.id === neighborIdB);
if (!nodeA || !nodeB) return 0;
if (nodeA.node_type === 'intent' && nodeB.node_type !== 'intent') return -1;
if (nodeB.node_type === 'intent' && nodeA.node_type !== 'intent') return 1;
return (nodeB.degree || 0) - (nodeA.degree || 0);
};
edges.sort(sortEdges);
if (isExpanded) {
const visible = edges.slice(0, 5);
container.innerHTML = visible.map(e => renderNeighborItem(e, direction)).join('');
btn.innerText = 'Show all';
btn.setAttribute('data-state', 'collapsed');
} else {
container.innerHTML = edges.map(e => renderNeighborItem(e, direction)).join('');
btn.innerText = 'Show less';
btn.setAttribute('data-state', 'expanded');
}
}
const container = document.getElementById('container');
const nodes = new vis.DataSet(RAW_NODES);
const edges = new vis.DataSet(RAW_EDGES);
const data = { nodes, edges };
const options = {
nodes: { shape: 'dot' },
edges: {
arrows: { to: { enabled: true, scaleFactor: 0.5 } },
color: { inherit: 'from' },
smooth: { type: 'continuous', roundness: 0.2 }
},
physics: {
enabled: true,
solver: 'forceAtlas2Based',
forceAtlas2Based: {
gravitationalConstant: -60,
centralGravity: 0.005,
springLength: 120,
springConstant: 0.08,
damping: 0.4,
avoidOverlap: 0.8
},
stabilization: {
enabled: true,
iterations: 200,
updateInterval: 25,
fit: true
}
},
interaction: {
hover: true,
tooltipDelay: 100,
hideEdgesOnDrag: true,
hideEdgesOnZoom: true,
multiselect: true,
navigationButtons: false,
keyboard: true
}
};
const network = new vis.Network(container, data, options);
network.on("stabilizationIterationsDone", function() {
document.getElementById('loading-overlay').style.opacity = '0';
setTimeout(() => {
document.getElementById('loading-overlay').style.display = 'none';
}, 500);
network.setOptions({ physics: { enabled: false } });
});
network.on("hoverNode", () => container.style.cursor = 'pointer');
network.on("blurNode", () => container.style.cursor = 'default');
network.on("hoverEdge", () => container.style.cursor = 'default');
network.on("blurEdge", () => container.style.cursor = 'default');
function showNodeInfo(nodeId) {
const infoContent = document.getElementById('info-content');
if (!nodeId) {
infoContent.innerHTML = '<span class="empty">Select a node to view its properties</span>';
return;
}
const nodeData = RAW_NODES.find(n => n.id === nodeId);
if (!nodeData) return;
let html = \`<div class="field"><b>ID</b> \${escapeHtml(nodeData.id)}</div>\`;
html += \`
<div class="field"><b>Type</b> \${escapeHtml(nodeData.node_type)}</div>\`;
html += \`
<div class="field"><b>Name</b> \${escapeHtml(nodeData.label)}</div>\`;
html += \`
<div class="field"><b>Links</b> \${nodeData.degree}</div>\`;
if (nodeData.community !== undefined && nodeData.community !== 'none') {
html += \`
<div class="field"><b>Community</b> Community \${escapeHtml(nodeData.community)}</div>\`;
}
if (nodeData.source_file && !nodeData.source_file.startsWith('unresolved_') && nodeData.source_file !== 'import') {
html += \`
<div class="field"><b>Source</b> \${escapeHtml(nodeData.source_file)}</div>\`;
}
if (nodeData.startLine) {
const lineText = nodeData.startLine === nodeData.endLine ? nodeData.startLine : \`\${nodeData.startLine} - \${nodeData.endLine}\`;
html += \`
<div class="field"><b>Lines</b> \${lineText}</div>\`;
}
if (nodeData.message) {
html += \`
<div class="field"><b>Message</b> \${escapeHtml(nodeData.message).replace(/\\n/g, '<br>')}</div>\`;
}
if (nodeData.unresolved) {
html += \`
<div class="field"><b>Status</b> <span style="color:#EDC948">Unresolved</span></div>\`;
if (nodeData.doc) html += \`
<div class="field"><b>Reason</b> \${escapeHtml(nodeData.doc)}</div>\`;
}
const incomingEdges = RAW_EDGES.filter(e => {
if (e.to !== nodeId) return false;
const neighbor = RAW_NODES.find(n => n.id === e.from);
return neighbor && !hiddenNodeTypes.has(neighbor.node_type) && !hiddenCommunities.has(neighbor.community);
});
const outgoingEdges = RAW_EDGES.filter(e => {
if (e.from !== nodeId) return false;
const neighbor = RAW_NODES.find(n => n.id === e.to);
return neighbor && !hiddenNodeTypes.has(neighbor.node_type) && !hiddenCommunities.has(neighbor.community);
});
const sortEdges = (a, b, direction) => {
const neighborIdA = direction === 'outgoing' ? a.to : a.from;
const neighborIdB = direction === 'outgoing' ? b.to : b.from;
const nodeA = RAW_NODES.find(n => n.id === neighborIdA);
const nodeB = RAW_NODES.find(n => n.id === neighborIdB);
if (!nodeA || !nodeB) return 0;
if (nodeA.node_type === 'intent' && nodeB.node_type !== 'intent') return -1;
if (nodeB.node_type === 'intent' && nodeA.node_type !== 'intent') return 1;
return (nodeB.degree || 0) - (nodeA.degree || 0);
};
incomingEdges.sort((a, b) => sortEdges(a, b, 'incoming'));
outgoingEdges.sort((a, b) => sortEdges(a, b, 'outgoing'));
if (incomingEdges.length > 0) {
html += \`<div class="neighbors-section">
<div class="neighbors-header">Incoming Connections (\${incomingEdges.length})</div>
<div id="incoming-list-container" class="neighbor-list">\`;
const visibleIncoming = incomingEdges.slice(0, 5);
html += visibleIncoming.map(e => renderNeighborItem(e, 'incoming')).join('');
html += \`</div>\`;
if (incomingEdges.length > 5) {
html += \`<button class="show-more-btn" data-action="toggle-neighbors" data-node-id="\${escapeHtmlAttr(nodeId)}" data-direction="incoming" data-state="collapsed">Show all</button>\`;
}
html += \`</div>\`;
}
if (outgoingEdges.length > 0) {
html += \`<div class="neighbors-section">
<div class="neighbors-header">Outgoing Connections (\${outgoingEdges.length})</div>
<div id="outgoing-list-container" class="neighbor-list">\`;
const visibleOutgoing = outgoingEdges.slice(0, 5);
html += visibleOutgoing.map(e => renderNeighborItem(e, 'outgoing')).join('');
html += \`</div>\`;
if (outgoingEdges.length > 5) {
html += \`<button class="show-more-btn" data-action="toggle-neighbors" data-node-id="\${escapeHtmlAttr(nodeId)}" data-direction="outgoing" data-state="collapsed">Show all</button>\`;
}
html += \`</div>\`;
}
infoContent.innerHTML = html;
}
network.on("selectNode", (params) => showNodeInfo(params.nodes[0]));
network.on("deselectNode", () => showNodeInfo(null));
network.on("animationFinished", () => {
network.setOptions({ edges: { hidden: false } });
});
const suggestionsBox = document.getElementById('search-suggestions');
const searchInput = document.getElementById('search');
searchInput.addEventListener('focus', () => {
network.setOptions({ interaction: { keyboard: false } });
});
searchInput.addEventListener('blur', () => {
network.setOptions({ interaction: { keyboard: true } });
});
let searchTimer;
let activeSuggestionIndex = -1;
let currentSuggestions = [];
function hideSuggestions() {
suggestionsBox.classList.add('hidden');
suggestionsBox.innerHTML = '';
activeSuggestionIndex = -1;
currentSuggestions = [];
}
function showSuggestions(matched) {
currentSuggestions = matched.slice(0, 10);
if (currentSuggestions.length === 0) {
hideSuggestions();
return;
}
suggestionsBox.innerHTML = currentSuggestions.map((n, idx) => {
const activeClass = idx === 0 ? 'active' : '';
return \`<div class="suggestion-item \${activeClass}" data-id="\${escapeHtmlAttr(n.id)}" data-idx="\${idx}">
<span class="suggestion-name">\${escapeHtml(n.name || n.id)}</span>
<span class="suggestion-type">\${escapeHtml(n.node_type)}</span>
</div>\`;
}).join('');
suggestionsBox.classList.remove('hidden');
activeSuggestionIndex = 0;
}
function updateActiveSuggestion(index) {
const items = suggestionsBox.getElementsByClassName('suggestion-item');
for (let i = 0; i < items.length; i++) {
if (i === index) {
items[i].classList.add('active');
items[i].scrollIntoView({ block: 'nearest' });
} else {
items[i].classList.remove('active');
}
}
activeSuggestionIndex = index;
}
function selectSuggestion(nodeId) {
const nodeData = RAW_NODES.find(n => n.id === nodeId);
if (!nodeData) return;
network.selectNodes([nodeId]);
network.setOptions({ edges: { hidden: true } });
network.focus(nodeId, { scale: 0.5, animation: { duration: 1000, easingFunction: 'easeInOutQuad' }});
showNodeInfo(nodeId);
searchInput.value = nodeData.label || nodeId;
hideSuggestions();
}
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimer);
const val = e.target.value.trim().toLowerCase();
if (!val) {
network.selectNodes([]);
showNodeInfo(null);
hideSuggestions();
return;
}
searchTimer = setTimeout(() => {
const terms = val.split(/\\s+/).filter(t => t.length > 0);
if (terms.length === 0) return;
const scored = [];
for (const n of RAW_NODES) {
if (hiddenNodeTypes.has(n.node_type)) continue;
const name = (n.label || "").toLowerCase();
const id = n.id.toLowerCase();
const file = (n.source_file || "").toLowerCase();
let score = 0;
for (const t of terms) {
if (t === name || t === id) {
score += 1000;
} else if (name.startsWith(t) || id.startsWith(t)) {
score += 100;
} else if (name.includes(t) || id.includes(t)) {
score += 1;
} else if (file.includes(t)) {
score += 0.5;
}
}
if (score > 0) {
score += (n.degree || 0) * 0.01;
scored.push({ id: n.id, name: n.label || n.id, node_type: n.node_type, score });
}
}
scored.sort((a, b) => b.score - a.score);
showSuggestions(scored);
}, 400);
});
searchInput.addEventListener('keydown', (e) => {
if (suggestionsBox.classList.contains('hidden')) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
const nextIndex = (activeSuggestionIndex + 1) % currentSuggestions.length;
updateActiveSuggestion(nextIndex);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex = (activeSuggestionIndex - 1 + currentSuggestions.length) % currentSuggestions.length;
updateActiveSuggestion(prevIndex);
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeSuggestionIndex >= 0 && activeSuggestionIndex < currentSuggestions.length) {
selectSuggestion(currentSuggestions[activeSuggestionIndex].id);
searchInput.blur();
}
} else if (e.key === 'Escape') {
e.preventDefault();
hideSuggestions();
}
});
suggestionsBox.addEventListener('click', (e) => {
const item = e.target.closest('.suggestion-item');
if (item) {
const id = item.getAttribute('data-id');
selectSuggestion(id);
}
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#search-box')) {
hideSuggestions();
}
});
document.getElementById('info-content').addEventListener('click', (e) => {
const neighborItem = e.target.closest('.neighbor-item');
if (neighborItem) {
const neighborId = neighborItem.getAttribute('data-neighbor-id');
if (neighborId) {
selectSuggestion(neighborId);
return;
}
}
const showMoreBtn = e.target.closest('.show-more-btn');
if (showMoreBtn && showMoreBtn.getAttribute('data-action') === 'toggle-neighbors') {
const nodeId = showMoreBtn.getAttribute('data-node-id');
const direction = showMoreBtn.getAttribute('data-direction');
if (nodeId && direction) {
toggleNeighbors(nodeId, direction, showMoreBtn);
}
}
});
function updateNodeVisibilities() {
const updates = [];
let selectedHidden = false;
const selectedIds = network.getSelectedNodes();
for (const n of RAW_NODES) {
const shouldHide = hiddenNodeTypes.has(n.node_type) || hiddenCommunities.has(n.community);
updates.push({
id: n.id,
hidden: shouldHide
});
if (shouldHide && selectedIds.includes(n.id)) {
selectedHidden = true;
}
}
nodes.update(updates);
if (selectedHidden) {
network.selectNodes([]);
showNodeInfo(null);
}
}
document.getElementById('legend').addEventListener('click', (e) => {
const item = e.target.closest('.legend-item');
if (!item) return;
if (currentViewMode === 'types') {
const typesStr = item.getAttribute('data-type');
if (typesStr) {
const types = typesStr.split(',');
const isCurrentlyHidden = hiddenNodeTypes.has(types[0]);
for (const t of types) {
if (isCurrentlyHidden) {
hiddenNodeTypes.delete(t);
} else {
hiddenNodeTypes.add(t);
}
}
if (isCurrentlyHidden) {
item.classList.remove('inactive');
} else {
item.classList.add('inactive');
}
updateNodeVisibilities();
}
} else {
const commId = item.getAttribute('data-community');
if (commId) {
const isCurrentlyHidden = hiddenCommunities.has(commId);
if (isCurrentlyHidden) {
hiddenCommunities.delete(commId);
item.classList.remove('inactive');
} else {
hiddenCommunities.add(commId);
item.classList.add('inactive');
}
updateNodeVisibilities();
}
}
});
</script>
</body>
</html>`;
const htmlPath = path.join(outDir, "graph.html");
fs.writeFileSync(htmlPath, htmlContent, "utf-8");
return htmlPath;
}
export {
exportGraphHtml,
exportGraphJson,
exportReportMarkdown
};
+13
-13

@@ -9,3 +9,3 @@ #!/usr/bin/env node

var program = new Command();
program.name("geraph").description(pc.blue("Geraph: Structural memory for AI agents")).version("1.2.0", "-v, --version", "output the current version");
program.name("geraph").description(pc.blue("Geraph: Structural memory for AI agents")).version("1.2.1", "-v, --version", "output the current version");
program.command("scan").description("Scan the current directory and build the Knowledge Graph").option(

@@ -27,3 +27,3 @@ "-f, --force",

exportGraphHtml
} = await import("./serializer-JCEFEF3D.js");
} = await import("./serializer-RE3W7UUE.js");
const spinner = ora({

@@ -205,3 +205,3 @@ text: pc.gray(`Scanning codebase in ${targetDir}...`),

try {
const { searchGraph } = await import("./query-D3OHFKXD.js");
const { searchGraph } = await import("./query-6TPQWVAH.js");
const results = await searchGraph(

@@ -253,3 +253,3 @@ process.cwd(),

try {
const { getNode } = await import("./query-D3OHFKXD.js");
const { getNode } = await import("./query-6TPQWVAH.js");
const result = await getNode(

@@ -286,3 +286,3 @@ process.cwd(),

try {
const { getNeighbors } = await import("./query-D3OHFKXD.js");
const { getNeighbors } = await import("./query-6TPQWVAH.js");
const result = await getNeighbors(

@@ -317,3 +317,3 @@ process.cwd(),

try {
const { shortestPath } = await import("./query-D3OHFKXD.js");
const { shortestPath } = await import("./query-6TPQWVAH.js");
const maxHops = Number(options.maxHops || 8);

@@ -342,3 +342,3 @@ const result = await shortestPath(process.cwd(), source, target, maxHops);

try {
const { getGodNodes } = await import("./query-D3OHFKXD.js");
const { getGodNodes } = await import("./query-6TPQWVAH.js");
const result = await getGodNodes(

@@ -368,3 +368,3 @@ process.cwd(),

try {
const { getCommunityNodes } = await import("./query-D3OHFKXD.js");
const { getCommunityNodes } = await import("./query-6TPQWVAH.js");
const result = await getCommunityNodes(

@@ -397,3 +397,3 @@ process.cwd(),

try {
const { getSurprisingConnections } = await import("./query-D3OHFKXD.js");
const { getSurprisingConnections } = await import("./query-6TPQWVAH.js");
const result = await getSurprisingConnections(

@@ -425,3 +425,3 @@ process.cwd(),

try {
const { queryGraph } = await import("./query-D3OHFKXD.js");
const { queryGraph } = await import("./query-6TPQWVAH.js");
const result = await queryGraph(

@@ -455,3 +455,3 @@ process.cwd(),

try {
const { getGraphStats } = await import("./query-D3OHFKXD.js");
const { getGraphStats } = await import("./query-6TPQWVAH.js");
const result = await getGraphStats(process.cwd());

@@ -473,6 +473,6 @@ spinner.stop();

try {
const { loadGraph } = await import("./query-D3OHFKXD.js");
const { loadGraph } = await import("./query-6TPQWVAH.js");
const targetDir = dir ? path.resolve(process.cwd(), dir) : process.cwd();
const graph = loadGraph(targetDir);
const { runMcpServer } = await import("./mcp-D7Y5SMZ4.js");
const { runMcpServer } = await import("./mcp-2ODHZITE.js");
await runMcpServer(graph, targetDir);

@@ -479,0 +479,0 @@ } catch (err) {

{
"name": "geraph",
"version": "1.2.0",
"version": "1.2.1",
"description": "Structural memory for AI agents. Build semantic knowledge graphs from your codebase for surgical code modifications.",

@@ -5,0 +5,0 @@ "main": "dist/index.js",

// src/core/mcp.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema
} from "@modelcontextprotocol/sdk/types.js";
import fs from "fs";
import path from "path";
async function runMcpServer(graph, targetDir) {
const server = new Server(
{
name: "geraph",
version: "1.2.0"
},
{
capabilities: {
tools: {},
resources: {}
}
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "search_graph",
description: "Search for nodes in the knowledge graph by partial name. Useful to find exact node IDs. Supports pagination. You can also search for a file by its path (e.g., 'src/auth.ts') using type 'file', because node IDs contain file paths. (CLI Alternative: 'geraph search <term>')",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "The partial name to search for (e.g. 'auth')"
},
type: {
type: "string",
description: "Optional filter by node type (e.g. 'function', 'class')"
},
page: {
type: "number",
description: "Page number for pagination (default: 1)"
},
limit: {
type: "number",
description: "Number of results per page (default: 20)"
}
},
required: ["name"]
}
},
{
name: "get_node",
description: "Get detailed metadata for a specific node by its exact ID or fuzzy symbol name. (CLI Alternative: 'geraph node <symbol>')",
inputSchema: {
type: "object",
properties: {
symbol: {
type: "string",
description: "The exact node ID or symbol name"
},
type: {
type: "string",
description: "Optional filter by node type (e.g., 'interface', 'function')"
},
source: {
type: "string",
description: "Optional filter by source file path (e.g., 'auth.ts')"
}
},
required: ["symbol"]
}
},
{
name: "get_neighbors",
description: "Get all incoming and outgoing edges for a specific node to trace its direct dependencies. Supports pagination. (CLI Alternative: 'geraph neighbors <symbol>')",
inputSchema: {
type: "object",
properties: {
symbol: {
type: "string",
description: "The exact node ID or symbol name"
},
type: {
type: "string",
description: "Optional filter by node type (e.g., 'interface', 'function')"
},
source: {
type: "string",
description: "Optional filter by source file path (e.g., 'auth.ts')"
},
page: {
type: "number",
description: "Page number for pagination (default: 1)"
},
limit: {
type: "number",
description: "Number of edges per direction per page (default: 20)"
}
},
required: ["symbol"]
}
},
{
name: "shortest_path",
description: "Find the shortest sequence of edges connecting two nodes using fuzzy symbol/ID lookup. (CLI Alternative: 'geraph path <source> <target>')",
inputSchema: {
type: "object",
properties: {
source: {
type: "string",
description: "The fuzzy starting node ID or symbol name"
},
target: {
type: "string",
description: "The fuzzy destination node ID or symbol name"
},
max_hops: {
type: "number",
description: "Maximum hops to consider (default: 8)"
}
},
required: ["source", "target"]
}
},
{
name: "god_nodes",
description: "Return the most connected nodes \u2014 the core architectural pillars of the codebase. Supports pagination. (CLI Alternative: 'geraph god')",
inputSchema: {
type: "object",
properties: {
page: {
type: "number",
description: "Page number for pagination (default: 1)"
},
limit: {
type: "number",
description: "Number of results per page (default: 10)"
}
}
}
},
{
name: "get_community",
description: "Get all nodes in a community by community ID. Supports pagination. (CLI Alternative: 'geraph community <id>')",
inputSchema: {
type: "object",
properties: {
community_id: {
type: "number",
description: "Community ID (0-indexed by size)"
},
page: {
type: "number",
description: "Page number for pagination (default: 1)"
},
limit: {
type: "number",
description: "Number of results per page (default: 20)"
}
},
required: ["community_id"]
}
},
{
name: "get_surprises",
description: "Discover surprising cross-community couplings that link otherwise independent modules. Supports pagination. (CLI Alternative: 'geraph surprises')",
inputSchema: {
type: "object",
properties: {
page: {
type: "number",
description: "Page number for pagination (default: 1)"
},
limit: {
type: "number",
description: "Number of results per page (default: 20)"
}
}
}
},
{
name: "query_graph",
description: "Search the AST graph using BFS or DFS traversal. Returns a compact context representation. Supports natural language questions or keywords. (CLI Alternative: 'geraph query <symbol-or-question>')",
inputSchema: {
type: "object",
properties: {
symbol: {
type: "string",
description: "Fuzzy starting symbol or node ID, or natural language question"
},
question: {
type: "string",
description: "Natural language question or keywords (for Graphify parity)"
},
mode: {
type: "string",
enum: ["bfs", "dfs"],
default: "bfs",
description: "Traversal mode: bfs (breadth) or dfs (depth)"
},
depth: {
type: "number",
default: 3,
description: "Traversal depth limit"
},
token_budget: {
type: "number",
default: 2e3,
description: "Estimated output token limit"
}
}
}
},
{
name: "graph_stats",
description: "Return summary statistics of the graph: node count, edge count, community count, and extraction confidence percentage breakdown. (CLI Alternative: 'geraph stats')",
inputSchema: {
type: "object",
properties: {}
}
},
{
name: "scan_graph",
description: "Triggers a full rebuild of the Geraph AST graph. Use this after making significant code modifications or pushing git commits to ensure your structural memory is up-to-date. (CLI Alternative: 'geraph scan')",
inputSchema: {
type: "object",
properties: {
force: {
type: "boolean",
description: "If true, fully ignore and rebuild all cache files (doing a clean scan from scratch)"
}
}
}
}
]
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (!args) {
throw new Error("Arguments are required");
}
try {
if (name === "search_graph") {
const queryName = args.name;
const typeFilter = args.type;
const page = args.page;
const limit = args.limit;
const { searchGraph } = await import("./query-D3OHFKXD.js");
const matches = await searchGraph(
graph,
queryName,
typeFilter,
page,
limit
);
return {
content: [{ type: "text", text: JSON.stringify(matches, null, 2) }]
};
}
if (name === "get_node") {
const symbol = args.symbol;
const typeFilter = args.type;
const sourceFilter = args.source;
const { getNode } = await import("./query-D3OHFKXD.js");
try {
const result = await getNode(graph, symbol, typeFilter, sourceFilter);
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
} catch (error) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: error instanceof Error ? error.message : String(error)
})
}
],
isError: true
};
}
}
if (name === "get_neighbors") {
const symbol = args.symbol;
const typeFilter = args.type;
const sourceFilter = args.source;
const page = args.page;
const limit = args.limit;
const { getNeighbors } = await import("./query-D3OHFKXD.js");
try {
const result = await getNeighbors(
graph,
symbol,
typeFilter,
sourceFilter,
page,
limit
);
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
} catch (error) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: error instanceof Error ? error.message : String(error)
})
}
],
isError: true
};
}
}
if (name === "shortest_path") {
const source = args.source;
const target = args.target;
const maxHops = args.max_hops !== void 0 ? Number(args.max_hops) : 8;
const { shortestPath } = await import("./query-D3OHFKXD.js");
try {
const result = await shortestPath(graph, source, target, maxHops);
return {
content: [
{
type: "text",
text: result
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: error instanceof Error ? error.message : String(error)
})
}
],
isError: true
};
}
}
if (name === "god_nodes") {
const page = args.page || 1;
const limit = args.limit || 10;
const { getGodNodes } = await import("./query-D3OHFKXD.js");
try {
const result = await getGodNodes(graph, page, limit);
return {
content: [{ type: "text", text: result }]
};
} catch (error) {
return {
content: [{ type: "text", text: String(error) }],
isError: true
};
}
}
if (name === "get_community") {
const communityId = Number(args.community_id);
const page = args.page || 1;
const limit = args.limit || 20;
const { getCommunityNodes } = await import("./query-D3OHFKXD.js");
try {
const result = await getCommunityNodes(
graph,
communityId,
page,
limit
);
return {
content: [{ type: "text", text: result }]
};
} catch (error) {
return {
content: [{ type: "text", text: String(error) }],
isError: true
};
}
}
if (name === "get_surprises") {
const page = args.page || 1;
const limit = args.limit || 20;
const { getSurprisingConnections } = await import("./query-D3OHFKXD.js");
try {
const result = await getSurprisingConnections(graph, page, limit);
return {
content: [{ type: "text", text: result }]
};
} catch (error) {
return {
content: [{ type: "text", text: String(error) }],
isError: true
};
}
}
if (name === "query_graph") {
const symbol = args.symbol || args.question;
if (!symbol) {
throw new Error("Either 'symbol' or 'question' parameter is required");
}
const mode = args.mode || "bfs";
const depth = Number(args.depth ?? 3);
const tokenBudget = Number(args.token_budget ?? 2e3);
const { queryGraph } = await import("./query-D3OHFKXD.js");
try {
const result = await queryGraph(
graph,
symbol,
mode,
depth,
tokenBudget
);
return {
content: [{ type: "text", text: result }]
};
} catch (error) {
return {
content: [{ type: "text", text: String(error) }],
isError: true
};
}
}
if (name === "graph_stats") {
const { getGraphStats } = await import("./query-D3OHFKXD.js");
try {
const result = await getGraphStats(graph);
return {
content: [{ type: "text", text: result }]
};
} catch (error) {
return {
content: [{ type: "text", text: String(error) }],
isError: true
};
}
}
if (name === "scan_graph") {
try {
const { exec } = await import("child_process");
const { promisify } = await import("util");
const execAsync = promisify(exec);
const force = !!args.force;
const cmd = force ? "geraph scan --force" : "geraph scan";
const { stdout, stderr } = await execAsync(cmd, { cwd: targetDir });
const { loadGraph } = await import("./query-D3OHFKXD.js");
const newGraph = loadGraph(targetDir);
graph.clear();
newGraph.forEachNode((node, attr) => graph.addNode(node, attr));
newGraph.forEachEdge(
(edge, attr, source, target) => graph.addEdgeWithKey(edge, source, target, attr)
);
const stripAnsi = (str) => str.replace(
// eslint-disable-next-line no-control-regex
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
""
);
const cleanStdout = stripAnsi(stdout);
const cleanStderr = stripAnsi(stderr);
const lines = cleanStderr.split("\n").map((l) => l.trim()).filter(Boolean);
let filesParsedLine = "";
const realWarnings = [];
const SPINNER_KEYWORDS = [
"Scanning codebase in",
"Initializing Knowledge Graph",
"Resolving call graph",
"Extracting Temporal Facts",
"Analyzing graph structure",
"Compressing graph into Caveman"
];
for (const line of lines) {
if (line.includes("Successfully scanned and parsed")) {
filesParsedLine = line.replace(/^[^\w]+/, "").trim();
} else if (SPINNER_KEYWORDS.some((kw) => line.includes(kw))) {
} else {
realWarnings.push(line);
}
}
let outputText = "";
if (filesParsedLine) {
const displayLine = filesParsedLine.startsWith("Successfully") ? filesParsedLine : `Successfully ${filesParsedLine}`;
outputText += `${displayLine}
`;
}
outputText += cleanStdout.trim();
if (realWarnings.length > 0) {
outputText += `
Warnings/Errors:
${realWarnings.join("\n")}`;
}
return {
content: [
{
type: "text",
text: outputText.trim() || "Graph successfully scanned and memory updated."
}
]
};
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `Error scanning graph: ${msg}` }],
isError: true
};
}
}
throw new Error(`Unknown tool: ${name}`);
} catch (error) {
const err = error;
return {
content: [
{ type: "text", text: JSON.stringify({ error: err.message }) }
],
isError: true
};
}
});
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: "geraph://report",
name: "Graph Report",
description: "Full GRAPH_REPORT.md",
mimeType: "text/markdown"
},
{
uri: "geraph://stats",
name: "Graph Stats",
description: "Node/edge/community counts and confidence breakdown",
mimeType: "text/plain"
},
{
uri: "geraph://god-nodes",
name: "God Nodes",
description: "Top 10 most-connected nodes",
mimeType: "text/plain"
},
{
uri: "geraph://surprises",
name: "Surprising Connections",
description: "Cross-community surprising connections",
mimeType: "text/plain"
}
]
};
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
if (uri === "geraph://report") {
const reportPath = path.join(targetDir, ".geraph", "GRAPH_REPORT.md");
if (fs.existsSync(reportPath)) {
return {
contents: [
{
uri,
mimeType: "text/markdown",
text: fs.readFileSync(reportPath, "utf-8")
}
]
};
}
return {
contents: [
{
uri,
mimeType: "text/markdown",
text: "GRAPH_REPORT.md not found. Run geraph scan first."
}
]
};
}
if (uri === "geraph://stats") {
const { getGraphStats } = await import("./query-D3OHFKXD.js");
const text = await getGraphStats(graph);
return {
contents: [{ uri, mimeType: "text/plain", text }]
};
}
if (uri === "geraph://god-nodes") {
const { getGodNodes } = await import("./query-D3OHFKXD.js");
const text = await getGodNodes(graph, 1, 10);
return {
contents: [{ uri, mimeType: "text/plain", text }]
};
}
if (uri === "geraph://surprises") {
const { getSurprisingConnections } = await import("./query-D3OHFKXD.js");
const text = await getSurprisingConnections(graph, 1, 10);
return {
contents: [{ uri, mimeType: "text/plain", text }]
};
}
throw new Error(`Unknown resource: ${uri}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Geraph MCP Server is running over stdio");
}
export {
runMcpServer
};
import {
createKnowledgeGraph
} from "./chunk-5V42YYJT.js";
// src/core/query.ts
import fs from "fs";
import path from "path";
function normalizeId(id) {
return id.replace(/\\/g, "/");
}
function loadGraph(targetDir) {
const graphPath = path.join(targetDir, ".geraph", "graph.json");
if (!fs.existsSync(graphPath)) {
throw new Error(
`Graph data not found in ${targetDir}. Run 'geraph scan' first.`
);
}
const rawData = JSON.parse(fs.readFileSync(graphPath, "utf-8"));
const graph = createKnowledgeGraph();
const nodes = rawData.nodes || [];
const edges = rawData.edges || [];
const communities = rawData.analysis?.communities || [];
const nodeToCommunity = /* @__PURE__ */ new Map();
communities.forEach((c) => {
const members = c.members || c.nodes || [];
members.forEach((nodeId) => {
nodeToCommunity.set(normalizeId(nodeId), Number(c.id));
});
});
nodes.forEach((n) => {
const nid = normalizeId(n.id);
if (!graph.hasNode(nid)) {
const { id, name, type, file, startLine, ...metadata } = n;
const communityId = nodeToCommunity.get(nid);
graph.addNode(nid, {
name: name || "",
type,
file: normalizeId(file || ""),
startLine: startLine || 0,
metadata: {
...metadata,
...communityId !== void 0 ? { community: communityId } : {}
}
});
}
});
edges.forEach((e) => {
const source = normalizeId(e.source);
const target = normalizeId(e.target);
if (source === target) return;
if (graph.hasNode(source) && graph.hasNode(target)) {
graph.addEdge(source, target, {
type: e.relation || e.type,
confidence: e.confidence,
metadata: e.metadata || {}
});
}
});
return graph;
}
async function searchGraph(targetDirOrGraph, term, targetType, page = 1, limit = 20) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const lowerTerm = term.toLowerCase();
const results = [];
graph.forEachNode((nodeId, attr) => {
if (targetType && attr.type !== targetType) return;
if (nodeId.toLowerCase().includes(lowerTerm) || attr.name && attr.name.toLowerCase().includes(lowerTerm)) {
results.push({
id: nodeId,
name: attr.name,
type: attr.type,
file: attr.file,
links: graph.degree(nodeId)
});
}
});
results.sort((a, b) => b.links - a.links);
const total = results.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
const end = start + limit;
return {
data: results.slice(start, end),
meta: {
page,
limit,
total,
totalPages
}
};
}
function resolveTargetNodeId(graph, symbol, targetType, targetSource) {
const normSymbol = normalizeId(symbol);
let targetNodeId = graph.hasNode(normSymbol) ? normSymbol : null;
if (targetNodeId && (targetType || targetSource)) {
const attr = graph.getNodeAttributes(targetNodeId);
if (targetType && attr.type !== targetType) {
targetNodeId = null;
}
if (targetNodeId && targetSource) {
const normSource = normalizeId(targetSource);
if (!attr.file.toLowerCase().endsWith(normSource.toLowerCase())) {
targetNodeId = null;
}
}
}
if (!targetNodeId) {
targetNodeId = graph.findNode((nodeId, attr) => {
if (targetType && attr.type !== targetType) return false;
if (targetSource) {
const normSource = normalizeId(targetSource);
if (!attr.file.endsWith(normSource)) return false;
}
return attr && attr.name && attr.name === symbol || nodeId === normSymbol || nodeId.endsWith("/" + normSymbol) || nodeId.endsWith("::" + normSymbol);
}) ?? null;
}
if (!targetNodeId) {
targetNodeId = graph.findNode((nodeId, attr) => {
if (targetType && attr.type !== targetType) return false;
if (targetSource) {
const normSource = normalizeId(targetSource);
if (!attr.file.toLowerCase().endsWith(normSource.toLowerCase()))
return false;
}
return attr && attr.name && attr.name.toLowerCase() === symbol.toLowerCase() || nodeId.toLowerCase() === normSymbol.toLowerCase() || nodeId.toLowerCase().endsWith("/" + normSymbol.toLowerCase()) || nodeId.toLowerCase().endsWith("::" + normSymbol.toLowerCase());
}) ?? null;
}
if (!targetNodeId) {
const typeMsg = targetType ? ` of type '${targetType}'` : "";
const sourceMsg = targetSource ? ` in source '${targetSource}'` : "";
throw new Error(
`Symbol '${symbol}'${typeMsg}${sourceMsg} not found in the graph.`
);
}
return targetNodeId;
}
async function getNode(targetDirOrGraph, symbol, targetType, targetSource) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const targetNodeId = resolveTargetNodeId(
graph,
symbol,
targetType,
targetSource
);
const targetAttr = graph.getNodeAttributes(targetNodeId);
return {
id: targetNodeId,
name: targetAttr.name,
type: targetAttr.type,
file: targetAttr.file,
line: targetAttr.startLine,
metadata: targetAttr.metadata,
links: {
incoming: graph.inDegree(targetNodeId),
outgoing: graph.outDegree(targetNodeId)
}
};
}
async function getNeighbors(targetDirOrGraph, symbol, targetType, targetSource, page = 1, limit = 20) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const targetNodeId = resolveTargetNodeId(
graph,
symbol,
targetType,
targetSource
);
const targetAttr = graph.getNodeAttributes(targetNodeId);
const result = {
target: {
id: targetNodeId,
name: targetAttr.name,
type: targetAttr.type,
file: targetAttr.file,
line: targetAttr.startLine
},
incoming: [],
outgoing: [],
meta: {
page,
limit,
totalIncoming: 0,
totalOutgoing: 0,
totalPages: 1
}
};
const collectEdges = (nodeId, isOutgoing, seenKeys) => {
const iterator = isOutgoing ? graph.forEachOutEdge.bind(graph) : graph.forEachInEdge.bind(graph);
iterator(nodeId, (edge, attr, source, target) => {
const neighborId = isOutgoing ? target : source;
if (neighborId === targetNodeId) return;
const key = `${neighborId}:${attr.type}`;
if (seenKeys.has(key)) return;
seenKeys.add(key);
const neighborAttr = graph.getNodeAttributes(neighborId);
const nodeInfo = {
id: neighborId,
name: neighborAttr.name,
type: neighborAttr.type,
file: neighborAttr.file,
line: neighborAttr.startLine
};
if (isOutgoing) {
result.outgoing.push({
relation: attr.type,
confidence: attr.confidence,
target: nodeInfo
});
} else {
result.incoming.push({
relation: attr.type,
confidence: attr.confidence,
source: nodeInfo
});
}
});
};
const seenIn = /* @__PURE__ */ new Set();
const seenOut = /* @__PURE__ */ new Set();
collectEdges(targetNodeId, false, seenIn);
collectEdges(targetNodeId, true, seenOut);
const sortEdges = (a, b) => {
const nodeA = a.source || a.target;
const nodeB = b.source || b.target;
if (nodeA.type === "intent" && nodeB.type !== "intent") return -1;
if (nodeB.type === "intent" && nodeA.type !== "intent") return 1;
const degreeA = graph.degree(nodeA.id) || 0;
const degreeB = graph.degree(nodeB.id) || 0;
return degreeB - degreeA;
};
result.incoming.sort(sortEdges);
result.outgoing.sort(sortEdges);
const totalIncoming = result.incoming.length;
const totalOutgoing = result.outgoing.length;
const maxTotal = Math.max(totalIncoming, totalOutgoing);
const totalPages = Math.ceil(maxTotal / limit) || 1;
const start = (page - 1) * limit;
const end = start + limit;
result.incoming = result.incoming.slice(start, end);
result.outgoing = result.outgoing.slice(start, end);
result.meta = {
page,
limit,
totalIncoming,
totalOutgoing,
totalPages
};
return result;
}
function undirectedShortestPath(graph, source, target) {
if (source === target) return [source];
const queue = [source];
const visited = /* @__PURE__ */ new Set([source]);
const parent = /* @__PURE__ */ new Map();
while (queue.length > 0) {
const current = queue.shift();
if (current === target) break;
graph.forEachNeighbor(current, (neighbor) => {
if (!visited.has(neighbor)) {
visited.add(neighbor);
parent.set(neighbor, current);
queue.push(neighbor);
}
});
}
if (!parent.has(target)) return null;
const path2 = [];
let curr = target;
while (curr !== source) {
path2.unshift(curr);
curr = parent.get(curr);
}
path2.unshift(source);
return path2;
}
async function shortestPath(targetDirOrGraph, sourceSymbol, targetSymbol, maxHops = 8) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const normSource = resolveTargetNodeId(graph, sourceSymbol);
const normTarget = resolveTargetNodeId(graph, targetSymbol);
if (normSource === normTarget) {
throw new Error(`Source and target nodes are identical: '${sourceSymbol}'`);
}
const path2 = undirectedShortestPath(graph, normSource, normTarget);
if (!path2) {
throw new Error("No path exists between the given nodes");
}
const hops = path2.length - 1;
if (hops > maxHops) {
return `Path exceeds max_hops=${maxHops} (${hops} hops found).`;
}
const segments = [];
for (let i = 0; i < path2.length - 1; i++) {
const u = path2[i];
const v = path2[i + 1];
let forward = true;
let edgeId;
if (graph.hasDirectedEdge(u, v)) {
edgeId = graph.edges(u, v)[0];
} else if (graph.hasDirectedEdge(v, u)) {
edgeId = graph.edges(v, u)[0];
forward = false;
}
const edata = edgeId ? graph.getEdgeAttributes(edgeId) : void 0;
const rel = edata?.type || "";
const conf = edata?.confidence || "";
const confStr = conf ? ` [${conf}]` : "";
const uLabel = String(graph.getNodeAttribute(u, "name") || u);
const vLabel = String(graph.getNodeAttribute(v, "name") || v);
if (i === 0) {
segments.push(uLabel);
}
if (forward) {
segments.push(`--${rel}${confStr}--> ${vLabel}`);
} else {
segments.push(`<--${rel}${confStr}-- ${vLabel}`);
}
}
return `Shortest path (${hops} hops):
` + segments.join(" ");
}
async function getGodNodes(targetDirOrGraph, page = 1, limit = 10) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const { findGodNodes } = await import("./analyze-6FZL63EZ.js");
const allGods = findGodNodes(graph, graph.order);
const total = allGods.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
const end = start + limit;
const paginated = allGods.slice(start, end);
const lines = ["God nodes (most connected):"];
paginated.forEach((n, idx) => {
const globalIdx = start + idx + 1;
lines.push(` ${globalIdx}. ${n.name} [id: ${n.id}] - ${n.degree} edges`);
});
if (totalPages > 1) {
lines.push(`
[Page ${page} of ${totalPages} | Total: ${total} nodes]`);
}
return lines.join("\n");
}
async function getCommunityNodes(targetDirOrGraph, communityId, page = 1, limit = 20) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const { detectCommunities } = await import("./analyze-6FZL63EZ.js");
const communities = detectCommunities(graph);
const targetCommunity = communities.find((c) => c.id === communityId);
if (!targetCommunity) {
throw new Error(`Community ${communityId} not found.`);
}
const total = targetCommunity.nodes.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
const end = start + limit;
const paginatedNodes = targetCommunity.nodes.slice(start, end);
const lines = [`Community ${communityId} (${total} nodes):`];
paginatedNodes.forEach((nodeId) => {
const attr = graph.getNodeAttributes(nodeId);
const label = attr.name || nodeId;
lines.push(` ${label} (type: ${attr.type}) [id: ${nodeId}]`);
});
if (totalPages > 1) {
lines.push(`
[Page ${page} of ${totalPages} | Total: ${total} nodes]`);
}
return lines.join("\n");
}
async function getSurprisingConnections(targetDirOrGraph, page = 1, limit = 20) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const { detectCommunities, findSurprisingConnections } = await import("./analyze-6FZL63EZ.js");
const communities = detectCommunities(graph);
const surprises = findSurprisingConnections(graph, communities, graph.size);
const total = surprises.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
const end = start + limit;
const paginated = surprises.slice(start, end);
if (total === 0) {
return "No surprising connections found.";
}
const lines = ["Surprising cross-community connections:"];
paginated.forEach((s) => {
lines.push(
` ${s.sourceName} <-> ${s.targetName} [${s.edgeType}] - ${s.why}`
);
});
if (totalPages > 1) {
lines.push(
`
[Page ${page} of ${totalPages} | Total: ${total} connections]`
);
}
return lines.join("\n");
}
function scoreNodes(graph, terms) {
const EXACT_MATCH_BONUS = 1e3;
const PREFIX_MATCH_BONUS = 100;
const SUBSTRING_MATCH_BONUS = 1;
const SOURCE_MATCH_BONUS = 0.5;
const scored = [];
graph.forEachNode((nodeId, attr) => {
const name = (attr.name || "").toLowerCase();
const source = (attr.file || "").toLowerCase();
const nidLower = nodeId.toLowerCase();
let score = 0;
for (const t of terms) {
if (t === name || t === nidLower) {
score += EXACT_MATCH_BONUS;
} else if (name.startsWith(t) || nidLower.startsWith(t)) {
score += PREFIX_MATCH_BONUS;
} else if (name.includes(t) || nidLower.includes(t)) {
score += SUBSTRING_MATCH_BONUS;
}
if (source.includes(t)) {
score += SOURCE_MATCH_BONUS;
}
}
if (score > 0) {
scored.push([score, nodeId]);
}
});
return scored.sort((a, b) => b[0] - a[0]);
}
async function queryGraph(targetDirOrGraph, symbol, mode = "bfs", depth = 3, tokenBudget = 2e3) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
const terms = symbol.split(/\s+/).map((t) => t.replace(/[?,.:;!]/g, "").toLowerCase().trim()).filter((t) => t.length > 2);
let startNodes = [];
if (terms.length > 0) {
const scored = scoreNodes(graph, terms);
startNodes = scored.slice(0, 3).map((item) => item[1]);
}
if (startNodes.length === 0) {
try {
const fallbackId = resolveTargetNodeId(graph, symbol);
startNodes = [fallbackId];
} catch {
throw new Error(`No matching nodes found for query: '${symbol}'`);
}
}
const degrees = [];
graph.forEachNode((nodeId) => {
degrees.push(graph.degree(nodeId));
});
let hubThreshold = 50;
if (degrees.length > 0) {
const sorted = [...degrees].sort((a, b) => a - b);
const p99Idx = Math.floor(sorted.length * 0.99);
hubThreshold = Math.max(50, sorted[p99Idx] || 50);
}
const seedSet = new Set(startNodes);
const visited = new Set(startNodes);
const edges = [];
if (mode === "bfs") {
const queue = startNodes.map((n) => [n, 0]);
while (queue.length > 0) {
const [curr, d] = queue.shift();
if (d >= depth) continue;
if (!seedSet.has(curr) && graph.degree(curr) >= hubThreshold) {
continue;
}
graph.forEachNeighbor(curr, (neighbor) => {
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push([neighbor, d + 1]);
}
edges.push([curr, neighbor]);
});
}
} else {
const stack = [...startNodes].reverse().map((n) => [n, 0]);
while (stack.length > 0) {
const [curr, d] = stack.pop();
if (d >= depth) continue;
if (!seedSet.has(curr) && graph.degree(curr) >= hubThreshold) {
continue;
}
graph.forEachNeighbor(curr, (neighbor) => {
if (!visited.has(neighbor)) {
visited.add(neighbor);
stack.push([neighbor, d + 1]);
}
edges.push([curr, neighbor]);
});
}
}
const traversedNodes = Array.from(visited);
const remainingNodes = traversedNodes.filter((n) => !seedSet.has(n));
remainingNodes.sort((a, b) => graph.degree(b) - graph.degree(a));
const orderedNodes = startNodes.filter((n) => visited.has(n)).concat(remainingNodes);
const seenEdges = /* @__PURE__ */ new Set();
const finalEdges = [];
edges.forEach(([u, v]) => {
if (visited.has(u) && visited.has(v)) {
let edgeId;
let forward = true;
if (graph.hasDirectedEdge(u, v)) {
edgeId = graph.edges(u, v)[0];
} else if (graph.hasDirectedEdge(v, u)) {
edgeId = graph.edges(v, u)[0];
forward = false;
}
if (edgeId) {
const key = forward ? `${u}->${v}` : `${v}->${u}`;
if (!seenEdges.has(key)) {
seenEdges.add(key);
const attr = graph.getEdgeAttributes(edgeId);
finalEdges.push({
u: forward ? u : v,
v: forward ? v : u,
rel: attr.type || "",
conf: attr.confidence || ""
});
}
}
}
});
const startNames = startNodes.map(
(n) => graph.getNodeAttribute(n, "name") || n
);
const header = `Traversal: ${mode.toUpperCase()} depth=${depth} | Start: [${startNames.join(", ")}] | ${orderedNodes.length} nodes found
`;
const charBudget = tokenBudget * 3;
const lines = [];
orderedNodes.forEach((nid) => {
const attr = graph.getNodeAttributes(nid);
const name = attr.name || nid;
const file = attr.file || "";
const loc = attr.startLine || 0;
const community = attr.metadata?.community !== void 0 ? attr.metadata.community : "";
lines.push(`NODE ${name} [src=${file} loc=${loc} community=${community}]`);
});
finalEdges.forEach(({ u, v, rel, conf }) => {
const uLabel = graph.getNodeAttribute(u, "name") || u;
const vLabel = graph.getNodeAttribute(v, "name") || v;
const confStr = conf ? ` [${conf}]` : "";
lines.push(`EDGE ${uLabel} --${rel}${confStr}--> ${vLabel}`);
});
let output = header + lines.join("\n");
if (output.length > charBudget) {
output = output.slice(0, charBudget) + `
... (truncated to ~${tokenBudget} token budget)`;
}
return output;
}
async function getGraphStats(targetDirOrGraph) {
const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph;
let extractedCount = 0;
let inferredCount = 0;
let ambiguousCount = 0;
graph.forEachEdge((edgeId, attr) => {
if (attr.confidence === "EXTRACTED") extractedCount++;
else if (attr.confidence === "INFERRED") inferredCount++;
else if (attr.confidence === "AMBIGUOUS") ambiguousCount++;
});
const total = graph.size || 1;
const extPct = (extractedCount / total * 100).toFixed(1);
const infPct = (inferredCount / total * 100).toFixed(1);
const ambPct = (ambiguousCount / total * 100).toFixed(1);
let communitiesCount = 0;
try {
const { detectCommunities } = await import("./analyze-6FZL63EZ.js");
communitiesCount = detectCommunities(graph).length;
} catch {
}
return [
`Nodes: ${graph.order}`,
`Edges: ${graph.size}`,
`Communities: ${communitiesCount}`,
`EXTRACTED: ${extractedCount} (${extPct}%)`,
`INFERRED: ${inferredCount} (${infPct}%)`,
`AMBIGUOUS: ${ambiguousCount} (${ambPct}%)`
].join("\n");
}
export {
getCommunityNodes,
getGodNodes,
getGraphStats,
getNeighbors,
getNode,
getSurprisingConnections,
loadGraph,
queryGraph,
searchGraph,
shortestPath
};
// src/core/serializer.ts
import fs from "fs";
import path from "path";
function exportGraphJson(graph, outDir, analysis) {
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
const nodes = graph.nodes().map((nodeId) => {
const data = graph.getNodeAttributes(nodeId);
return {
id: nodeId,
type: data.type,
name: data.name,
file: data.file,
startLine: data.startLine,
...data.metadata
};
});
const edges = graph.edges().map((edgeId) => {
const source = graph.source(edgeId);
const target = graph.target(edgeId);
const data = graph.getEdgeAttributes(edgeId);
return {
source,
target,
relation: data.type,
confidence: data.confidence,
...data.metadata
};
});
const payload = {
version: "1.0.0",
nodes,
edges
};
if (analysis) {
payload.analysis = {
godNodes: analysis.godNodes,
communities: analysis.communities.map((c) => ({
id: c.id,
nodeCount: c.nodes.length,
cohesion: c.cohesion,
members: c.nodes
})),
surprisingConnections: analysis.surprisingConnections
};
}
const jsonPath = path.join(outDir, "graph.json");
fs.writeFileSync(jsonPath, JSON.stringify(payload, null, 2), "utf-8");
return jsonPath;
}
function exportReportMarkdown(graph, outDir, analysis) {
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
let md = `# Geraph Codebase Report
`;
md += `## Architecture Structure
`;
md += `*Note: Only the first 100 files and their primary members are listed here. For full data, use the \`search_graph\` or \`query_graph\` MCP tools (recommended) or corresponding CLI commands.*
`;
const allFileNodes = graph.nodes().filter((n) => graph.getNodeAttribute(n, "type") === "file");
const fileNodes = allFileNodes.slice(0, 100);
for (const file of fileNodes) {
const data = graph.getNodeAttributes(file);
if (data.metadata?.external) continue;
md += `- **${data.name}** [id: \`${file}\`]
`;
const definesEdges = graph.outEdges(file).filter((edgeId) => {
return graph.getEdgeAttribute(edgeId, "type") === "defines";
});
const membersToShow = definesEdges.slice(0, 5);
for (const edgeId of membersToShow) {
const target = graph.target(edgeId);
const targetData = graph.getNodeAttributes(target);
md += ` - \`${targetData.type} ${targetData.name}\` [id: \`${target}\`]
`;
}
if (definesEdges.length > 5) {
md += ` - ... and ${definesEdges.length - 5} more
`;
}
}
if (allFileNodes.length > 100) {
md += `
- *... and ${allFileNodes.length - 100} more file nodes. Use \`search_graph\` or \`query_graph\` MCP tools (recommended) to explore them.*
`;
}
if (analysis && analysis.godNodes.length > 0) {
const realNodesCount = graph.nodes().filter((n) => {
const type = graph.getNodeAttribute(n, "type");
return type !== "intent" && !n.startsWith("import::") && !n.startsWith("unresolved_");
}).length;
const title = realNodesCount > 10 ? "Top 10 God Nodes (Architectural Pillars)" : "God Nodes (Architectural Pillars)";
md += `
## ${title}
`;
md += `These are the most-connected entities in the codebase. Changes to these nodes have the largest ripple effect. For full data, use the \`god_nodes\` MCP tool (recommended) or \`geraph god\` CLI command.
`;
const godsToShow = analysis.godNodes.slice(0, 10);
for (const god of godsToShow) {
md += `- **${god.name}** (type: \`${god.type}\`, id: \`${god.id}\`, ${god.degree} connections)
`;
}
}
if (analysis && analysis.communities.length > 0) {
md += `
## Communities
`;
md += `The codebase clusters into ${analysis.communities.length} communities of related code. For full community membership, use the \`get_community\` MCP tool (recommended) or \`geraph community <id>\` CLI command.
`;
for (const comm of analysis.communities) {
const realNodes = comm.nodes.filter(
(n) => !n.startsWith("commit::") && !n.startsWith("import::") && !n.startsWith("unresolved_") && graph.hasNode(n)
);
realNodes.sort((a, b) => graph.degree(b) - graph.degree(a));
const top5 = realNodes.slice(0, 5);
const memberStrings = top5.map((n) => {
const data = graph.getNodeAttributes(n);
const name = data ? data.name : n;
return `**${name}** [id: \`${n}\`]`;
});
let communityLine = `- **Community (ID: \`${comm.id}\`)** (${comm.nodes.length} nodes, cohesion: ${comm.cohesion}) \u2014 ${memberStrings.join(", ")}`;
if (realNodes.length > 5) {
communityLine += `, and ${realNodes.length - 5} more.`;
}
communityLine += `
*(To view all members: run \`geraph community ${comm.id}\` or use the \`get_community\` MCP tool with community_id=${comm.id})*
`;
md += communityLine;
}
}
if (analysis && analysis.surprisingConnections.length > 0) {
const totalSurprises = analysis.surprisingConnections.length;
const title = totalSurprises > 10 ? "Top 10 Surprising Connections" : "Surprising Connections";
md += `
## ${title}
`;
md += `Non-obvious couplings that bridge different parts of the architecture. For full data, use the \`get_surprises\` MCP tool (recommended) or \`geraph surprises\` CLI command.
`;
const surprisesToShow = analysis.surprisingConnections.slice(0, 10);
for (const s of surprisesToShow) {
md += `- **${s.sourceName}** [id: \`${s.source}\`] \u2194 **${s.targetName}** [id: \`${s.target}\`] (\`${s.edgeType}\`): ${s.why}
`;
}
}
if (analysis && analysis.knowledgeGaps && analysis.knowledgeGaps.length > 0) {
md += `
## Knowledge Gaps
`;
md += `These are isolated or nearly-isolated entities that may be missing documentation or architectural integration.
`;
for (const gap of analysis.knowledgeGaps) {
const data = graph.getNodeAttributes(gap);
md += `- \`${data.type} ${data.name}\` (isolated in ${path.basename(gap.split("::")[0] || gap)})
`;
}
}
if (analysis && analysis.suggestedQuestions && analysis.suggestedQuestions.length > 0) {
md += `
## Suggested Questions for AI
`;
md += `_Questions this graph is uniquely positioned to answer:_
`;
for (const q of analysis.suggestedQuestions) {
md += `- **${q}**
`;
}
}
const intentNodes = graph.nodes().filter((n) => graph.getNodeAttribute(n, "type") === "intent").sort((a, b) => {
const metaA = graph.getNodeAttribute(a, "metadata");
const metaB = graph.getNodeAttribute(b, "metadata");
const dateA = metaA?.date || "";
const dateB = metaB?.date || "";
return dateB.localeCompare(dateA);
}).slice(0, 50);
if (intentNodes.length > 0) {
md += `
## Recent Architectural Changes & Intent
`;
md += `*Showing the 50 most recent architectural commits. Use 'query' on a specific symbol to see its full history.*
`;
for (const intent of intentNodes) {
const data = graph.getNodeAttributes(intent);
const msg = (data.metadata?.message || "").split("\n")[0];
md += `- **${data.name}**: ${msg}
`;
}
}
md += `
---
*Generated by Geraph at ${(/* @__PURE__ */ new Date()).toISOString()}*
`;
const mdPath = path.join(outDir, "GRAPH_REPORT.md");
fs.writeFileSync(mdPath, md, "utf-8");
return mdPath;
}
function exportGraphHtml(graph, outDir, analysis) {
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
const counts = {
file: 0,
media: 0,
class: 0,
function: 0,
intent: 0,
type: 0,
interface: 0,
enum: 0,
struct: 0,
trait: 0,
macro: 0
};
const nodeDegrees = /* @__PURE__ */ new Map();
graph.nodes().forEach((n) => {
nodeDegrees.set(n, graph.degree(n));
});
const sortedByDegree = [...graph.nodes()].sort(
(a, b) => (nodeDegrees.get(b) || 0) - (nodeDegrees.get(a) || 0)
);
const topHubs = new Set(sortedByDegree.slice(0, 50));
const maxDegree = Math.max(...Array.from(nodeDegrees.values()), 1);
const COLORS = {
default: "#64748b",
file: "#479af3ff",
class: "#F28E2B",
function: "#ee272bff",
intent: "#35d86eff",
media: "#843b11ff",
type: "#B07AA1",
interface: "#e134b0ff",
enum: "#59A14F",
struct: "#E15759",
trait: "#76B7B2",
macro: "#EDC948"
};
const nodeCommunities = /* @__PURE__ */ new Map();
if (analysis) {
analysis.communities.forEach((comm) => {
comm.nodes.forEach((nodeId) => {
nodeCommunities.set(nodeId, String(comm.id));
});
});
}
const RAW_COMMUNITIES = analysis ? analysis.communities.map((c, idx) => {
const hue = idx * 137.5 % 360;
const color = `hsl(${hue}, 70%, 50%)`;
return {
id: String(c.id),
name: `Community ${c.id}`,
nodeCount: c.nodes.length,
cohesion: c.cohesion,
color,
members: c.nodes
};
}) : [];
const RAW_NODES = graph.nodes().map((n) => {
const data = graph.getNodeAttributes(n);
const degree = nodeDegrees.get(n) || 0;
const scaledSize = 10 + 30 * (degree / maxDegree);
const color = COLORS[data.type] ?? COLORS.default;
if (data.type && counts[data.type] !== void 0) {
counts[data.type] = (counts[data.type] || 0) + 1;
}
const showLabel = graph.order <= 50 || topHubs.has(n);
const fontSize = showLabel ? 12 : 0;
let sourceFile = "";
if (n.includes("::")) {
sourceFile = n.split("::")[0];
} else if (data.type === "file") {
sourceFile = n;
}
return {
id: n,
label: data.name || n,
title: data.name || n,
color: {
background: color,
border: color,
highlight: { background: "#ffffff", border: color },
hover: { background: color, border: "#ffffff" }
},
size: scaledSize,
font: {
size: fontSize,
color: "#ffffff",
strokeWidth: 2,
strokeColor: "#0f0f1a"
},
node_type: data.type,
source_file: sourceFile,
degree,
startLine: data.startLine,
community: nodeCommunities.get(n) ?? "none",
...data.metadata
};
});
const RAW_EDGES = graph.edges().map((id) => {
const attr = graph.getEdgeAttributes(id);
return {
from: graph.source(id),
to: graph.target(id),
relation: attr.type,
confidence: attr.confidence,
color: { color: "rgba(255,255,255,0.15)", highlight: "#ffffff" },
arrows: { to: { enabled: true, scaleFactor: 0.5 } },
smooth: { type: "continuous", roundness: 0.2 },
width: 1
};
});
const htmlContent = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Geraph Knowledge Graph</title>
<script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
<style>
body, html {
margin: 0; padding: 0; width: 100%; height: 100%;
background: #0f0f1a; color: #fff; overflow: hidden;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#container { width: 100%; height: 100%; }
#loading-overlay {
position: absolute; inset: 0; background: #0f0f1a;
display: flex; align-items: center; justify-content: center;
z-index: 200; font-size: 1rem; color: #3b82f6;
transition: opacity 0.5s ease;
}
#sidebar {
position: absolute; left: 20px; top: 20px; bottom: 20px;
width: 320px; background: rgb(12.941% 12.941% 12.941%);
border-radius: 30px;
display: flex; flex-direction: column; z-index: 100;
}
#sidebar-header { padding: 20px; border-bottom: 1px solid rgba(255,255,255,0.1); }
#sidebar-header h1 { margin: 0; font-size: 1.2rem; color: #fff; }
#sidebar-header p { margin: 5px 0 0; font-size: 0.8rem; color: #888; }
#search-box { padding: 15px; position: relative; }
#search {
width: 100%; padding: 10px; background: rgb(17.255% 17.255% 17.255%);
border: none; border-radius: 15px;
color: #fff; box-sizing: border-box; font-family: inherit;
}
#search-suggestions {
position: absolute;
left: 15px;
right: 15px;
top: calc(100% - 5px);
background: rgb(17.255% 17.255% 17.255%);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 15px;
max-height: 250px;
overflow-y: auto;
z-index: 150;
box-shadow: 0 10px 30px rgba(0,0,0,0.6);
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.1) transparent;
}
.suggestion-item {
padding: 10px 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background 0.15s ease;
font-size: 0.85rem;
}
.suggestion-item:last-child {
border-bottom: none;
}
.suggestion-item:hover, .suggestion-item.active {
background: rgba(83, 83, 83, 0.25);
}
.suggestion-name {
color: #fff;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 190px;
}
.suggestion-type {
font-size: 0.65rem;
font-weight: 600;
color: #b8b8b8;
text-transform: uppercase;
background: rgba(255, 255, 255, 0.08);
padding: 2px 6px;
border-radius: 4px;
}
#info-panel { flex: 1; padding: 20px; overflow-y: auto; font-size: 0.9rem; scrollbar-color: rgba(255,255,255,0.1) transparent; scrollbar-width: thin }
#info-panel h3 { margin-top: 0; font-size: 1rem; color: #3b82f6; }
#info-content { word-break: break-word; }
.field { margin-bottom: 12px; }
.field b { color: #888; display: block; font-size: 0.75rem; text-transform: uppercase; margin-bottom: 2px; }
.empty { color: #555; font-style: italic; }
#legend { padding: 20px; border-top: 1px solid rgba(255,255,255,0.1); display: flex; flex-direction: column; gap: 0.3rem; max-height: 100px; overflow-y: auto; scrollbar-color: rgba(255,255,255,0.1) transparent; scrollbar-width: thin }
.legend-item { display: flex; align-items: center; justify-content: space-between; font-size: 0.8rem; cursor: pointer; transition: opacity 0.2s; user-select: none; }
.legend-item:hover { opacity: 0.8; }
.legend-item.inactive { opacity: 0.35; }
.legend-item-left { display: flex; align-items: center; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; }
.legend-count { color: #666; font-weight: 600; }
#stats-container {
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0 0 30px 30px;
}
#view-tabs {
display: flex;
width: 100%;
}
#view-tabs.no-communities button[data-mode="communities"] {
display: none;
}
#view-tabs.no-communities button[data-mode="types"] {
width: 100%;
cursor: default;
background: transparent;
border-radius: 0 0 30px 30px;
}
.tab-btn {
flex: 1;
background: transparent;
border: none;
color: #666;
padding: 12px 10px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, color 0.2s;
outline: none;
text-align: center;
border-right: 1px solid rgba(255, 255, 255, 0.05);
}
.tab-btn:last-child {
border-right: none;
border-radius: 0 0 30px 0px !important;
}
.tab-btn.active {
background: rgba(255, 255, 255, 0.05);
color: #fff;
border-radius: 0 0 0px 30px;
}
.tab-btn:hover:not(.active) {
background: rgba(255, 255, 255, 0.02);
color: #aaa;
border-radius: 0 0 0px 30px;
}
.neighbors-section {
margin-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 12px;
}
.neighbors-header {
font-size: 0.8rem;
font-weight: 600;
color: #888;
text-transform: uppercase;
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.neighbor-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.neighbor-item {
padding: 6px 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 4px;
cursor: pointer;
display: flex;
flex-direction: column;
transition: background 0.2s, border-color 0.2s;
}
.neighbor-item:hover {
background: rgba(168, 168, 168, 0.15);
border-color: rgba(146, 146, 146, 0.3);
}
.neighbor-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.7rem;
margin-bottom: 3px;
}
.neighbor-relation {
color: #3b82f6;
font-weight: 600;
text-transform: uppercase;
}
.neighbor-confidence {
color: #888;
background: rgba(255, 255, 255, 0.05);
padding: 1px 4px;
border-radius: 3px;
}
.neighbor-main {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8rem;
}
.neighbor-main-left {
display: flex;
align-items: center;
gap: 8px;
}
.neighbor-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.neighbor-name {
color: #fff;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.neighbor-type {
font-size: 0.65rem;
color: #888;
text-transform: uppercase;
white-space: nowrap;
}
.show-more-btn {
background: none;
border: none;
color: #3b82f6;
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
padding: 6px 0;
text-align: left;
transition: color 0.2s;
outline: none;
width: 100%;
}
.show-more-btn:hover {
color: #60a5fa;
}
.hidden { display: none !important; }
</style>
</head>
<body>
<div id="loading-overlay">Loading Knowledge Graph...</div>
<div id="sidebar">
<div id="sidebar-header">
<h1>Geraph Map</h1>
<p>Codebase Knowledge Graph</p>
</div>
<div id="search-box">
<input type="text" id="search" placeholder="Search nodes..." autocomplete="off">
<div id="search-suggestions" class="hidden"></div>
</div>
<div id="info-panel">
<div id="info-content">
<span class="empty">Select a node to view its properties</span>
</div>
</div>
<div id="legend">
<div class="legend-item ${counts.file ? "" : "hidden"}" data-type="file"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.file};"></div> File</div><span class="legend-count">${counts.file || 0}</span></div>
<div class="legend-item ${counts.media ? "" : "hidden"}" data-type="media"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.media};"></div> Media</div><span class="legend-count">${counts.media || 0}</span></div>
<div class="legend-item ${counts.class ? "" : "hidden"}" data-type="class"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.class};"></div> Class</div><span class="legend-count">${counts.class || 0}</span></div>
<div class="legend-item ${counts.struct ? "" : "hidden"}" data-type="struct"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.struct};"></div> Struct</div><span class="legend-count">${counts.struct || 0}</span></div>
<div class="legend-item ${counts.trait ? "" : "hidden"}" data-type="trait"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.trait};"></div> Trait</div><span class="legend-count">${counts.trait || 0}</span></div>
<div class="legend-item ${counts.macro ? "" : "hidden"}" data-type="macro"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.macro};"></div> Macro</div><span class="legend-count">${counts.macro || 0}</span></div>
<div class="legend-item ${counts.function ? "" : "hidden"}" data-type="function"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.function};"></div> Function</div><span class="legend-count">${counts.function || 0}</span></div>
<div class="legend-item ${counts.type || counts.interface ? "" : "hidden"}" data-type="type,interface"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.type};"></div> Type/Interface</div><span class="legend-count">${(counts.type || 0) + (counts.interface || 0)}</span></div>
<div class="legend-item ${counts.enum ? "" : "hidden"}" data-type="enum"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.enum};"></div> Enum</div><span class="legend-count">${counts.enum || 0}</span></div>
<div class="legend-item ${counts.intent ? "" : "hidden"}" data-type="intent"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.intent};"></div> Intent</div><span class="legend-count">${counts.intent || 0}</span></div>
</div>
<div id="stats-container">
<div id="view-tabs" class="${RAW_COMMUNITIES.length > 0 ? "" : "no-communities"}">
<button class="tab-btn active" data-mode="types">${RAW_NODES.length} nodes &middot; ${RAW_EDGES.length} edges</button>
<button class="tab-btn" data-mode="communities">${RAW_COMMUNITIES.length} communities</button>
</div>
</div>
</div>
<div id="container"></div>
<script type="text/javascript">
const RAW_NODES = ${JSON.stringify(RAW_NODES)};
const RAW_EDGES = ${JSON.stringify(RAW_EDGES)};
const RAW_COMMUNITIES = ${JSON.stringify(RAW_COMMUNITIES)};
function escapeHtmlAttr(str) {
return (str || '').replace(/"/g, '&quot;');
}
const hiddenNodeTypes = new Set();
const hiddenCommunities = new Set();
let currentViewMode = 'types';
const legendTypesHtml = document.getElementById('legend') ? document.getElementById('legend').innerHTML : '';
function renderLegend(mode) {
const legendContainer = document.getElementById('legend');
if (!legendContainer) return;
if (mode === 'types') {
legendContainer.innerHTML = legendTypesHtml;
const items = legendContainer.querySelectorAll('.legend-item');
items.forEach(item => {
const typesStr = item.getAttribute('data-type');
if (typesStr) {
const types = typesStr.split(',');
if (hiddenNodeTypes.has(types[0])) {
item.classList.add('inactive');
} else {
item.classList.remove('inactive');
}
}
});
} else {
const sortedCommunities = [...RAW_COMMUNITIES].sort((a, b) => parseInt(a.id) - parseInt(b.id));
legendContainer.innerHTML = sortedCommunities.map(comm => {
const isInactive = hiddenCommunities.has(comm.id) ? 'inactive' : '';
return \`<div class="legend-item \${isInactive}" data-community="\${comm.id}">
<div class="legend-item-left">
<div class="legend-dot" style="background: \${comm.color};"></div>
\${comm.name}
</div>
<span class="legend-count">\${comm.nodeCount}</span>
</div>\`;
}).join('');
}
}
function switchViewMode(mode) {
currentViewMode = mode;
const tabs = document.querySelectorAll('.tab-btn');
tabs.forEach(t => {
if (t.getAttribute('data-mode') === mode) {
t.classList.add('active');
} else {
t.classList.remove('active');
}
});
const updates = [];
for (const n of RAW_NODES) {
let color;
if (mode === 'communities') {
const comm = RAW_COMMUNITIES.find(c => c.id === n.community);
color = comm ? comm.color : '#64748b';
} else {
color = n.color.background;
}
updates.push({
id: n.id,
color: {
background: color,
border: color,
highlight: { background: '#ffffff', border: color },
hover: { background: color, border: '#ffffff' }
}
});
}
nodes.update(updates);
renderLegend(mode);
}
const viewTabsEl = document.getElementById('view-tabs');
if (viewTabsEl) {
viewTabsEl.addEventListener('click', (e) => {
const btn = e.target.closest('.tab-btn');
if (btn) {
const mode = btn.getAttribute('data-mode');
if (mode && mode !== currentViewMode) {
switchViewMode(mode);
}
}
});
}
function renderNeighborItem(edge, direction) {
const neighborId = direction === 'outgoing' ? edge.to : edge.from;
const neighbor = RAW_NODES.find(n => n.id === neighborId);
if (!neighbor) return '';
return \`<div class="neighbor-item" data-neighbor-id="\${escapeHtmlAttr(neighborId)}">
<div class="neighbor-meta">
<span class="neighbor-relation">\${edge.relation || 'calls'}</span>
<span class="neighbor-confidence">\${edge.confidence || 'EXTRACTED'}</span>
</div>
<div class="neighbor-main">
<div class="neighbor-main-left">
<div class="neighbor-dot" style="background: \${neighbor.color.background};"></div>
<span class="neighbor-name">\${neighbor.label || neighborId}</span>
</div>
<span class="neighbor-type">\${neighbor.node_type}</span>
</div>
</div>\`;
}
function toggleNeighbors(nodeId, direction, btn) {
const isExpanded = btn.getAttribute('data-state') === 'expanded';
const container = document.getElementById(direction + '-list-container');
if (!container) return;
const edges = RAW_EDGES.filter(e => {
if (direction === 'outgoing') {
if (e.from !== nodeId) return false;
const neighbor = RAW_NODES.find(n => n.id === e.to);
return neighbor && !hiddenNodeTypes.has(neighbor.node_type) && !hiddenCommunities.has(neighbor.community);
} else {
if (e.to !== nodeId) return false;
const neighbor = RAW_NODES.find(n => n.id === e.from);
return neighbor && !hiddenNodeTypes.has(neighbor.node_type) && !hiddenCommunities.has(neighbor.community);
}
});
const sortEdges = (a, b) => {
const neighborIdA = direction === 'outgoing' ? a.to : a.from;
const neighborIdB = direction === 'outgoing' ? b.to : b.from;
const nodeA = RAW_NODES.find(n => n.id === neighborIdA);
const nodeB = RAW_NODES.find(n => n.id === neighborIdB);
if (!nodeA || !nodeB) return 0;
if (nodeA.node_type === 'intent' && nodeB.node_type !== 'intent') return -1;
if (nodeB.node_type === 'intent' && nodeA.node_type !== 'intent') return 1;
return (nodeB.degree || 0) - (nodeA.degree || 0);
};
edges.sort(sortEdges);
if (isExpanded) {
const visible = edges.slice(0, 5);
container.innerHTML = visible.map(e => renderNeighborItem(e, direction)).join('');
btn.innerText = 'Show all';
btn.setAttribute('data-state', 'collapsed');
} else {
container.innerHTML = edges.map(e => renderNeighborItem(e, direction)).join('');
btn.innerText = 'Show less';
btn.setAttribute('data-state', 'expanded');
}
}
const container = document.getElementById('container');
const nodes = new vis.DataSet(RAW_NODES);
const edges = new vis.DataSet(RAW_EDGES);
const data = { nodes, edges };
const options = {
nodes: { shape: 'dot' },
edges: {
arrows: { to: { enabled: true, scaleFactor: 0.5 } },
color: { inherit: 'from' },
smooth: { type: 'continuous', roundness: 0.2 }
},
physics: {
enabled: true,
solver: 'forceAtlas2Based',
forceAtlas2Based: {
gravitationalConstant: -60,
centralGravity: 0.005,
springLength: 120,
springConstant: 0.08,
damping: 0.4,
avoidOverlap: 0.8
},
stabilization: {
enabled: true,
iterations: 200,
updateInterval: 25,
fit: true
}
},
interaction: {
hover: true,
tooltipDelay: 100,
hideEdgesOnDrag: true,
hideEdgesOnZoom: true,
multiselect: true,
navigationButtons: false,
keyboard: true
}
};
const network = new vis.Network(container, data, options);
network.on("stabilizationIterationsDone", function() {
document.getElementById('loading-overlay').style.opacity = '0';
setTimeout(() => {
document.getElementById('loading-overlay').style.display = 'none';
}, 500);
network.setOptions({ physics: { enabled: false } });
});
network.on("hoverNode", () => container.style.cursor = 'pointer');
network.on("blurNode", () => container.style.cursor = 'default');
network.on("hoverEdge", () => container.style.cursor = 'default');
network.on("blurEdge", () => container.style.cursor = 'default');
function showNodeInfo(nodeId) {
const infoContent = document.getElementById('info-content');
if (!nodeId) {
infoContent.innerHTML = '<span class="empty">Select a node to view its properties</span>';
return;
}
const nodeData = RAW_NODES.find(n => n.id === nodeId);
if (!nodeData) return;
let html = \`<div class="field"><b>ID</b> \${nodeData.id}</div>\`;
html += \`<div class="field"><b>Type</b> \${nodeData.node_type}</div>\`;
html += \`<div class="field"><b>Name</b> \${nodeData.label}</div>\`;
html += \`<div class="field"><b>Links</b> \${nodeData.degree}</div>\`;
if (nodeData.community !== undefined && nodeData.community !== 'none') {
html += \`<div class="field"><b>Community</b> Community \${nodeData.community}</div>\`;
}
if (nodeData.source_file && !nodeData.source_file.startsWith('unresolved_') && nodeData.source_file !== 'import') {
html += \`<div class="field"><b>Source</b> \${nodeData.source_file}</div>\`;
}
if (nodeData.startLine) {
const lineText = nodeData.startLine === nodeData.endLine ? nodeData.startLine : \`\${nodeData.startLine} - \${nodeData.endLine}\`;
html += \`<div class="field"><b>Lines</b> \${lineText}</div>\`;
}
if (nodeData.message) {
html += \`<div class="field"><b>Message</b> \${nodeData.message.replace(/\\n/g, '<br>')}</div>\`;
}
if (nodeData.unresolved) {
html += \`<div class="field"><b>Status</b> <span style="color:#EDC948">Unresolved</span></div>\`;
if (nodeData.doc) html += \`<div class="field"><b>Reason</b> \${nodeData.doc}</div>\`;
}
const incomingEdges = RAW_EDGES.filter(e => {
if (e.to !== nodeId) return false;
const neighbor = RAW_NODES.find(n => n.id === e.from);
return neighbor && !hiddenNodeTypes.has(neighbor.node_type) && !hiddenCommunities.has(neighbor.community);
});
const outgoingEdges = RAW_EDGES.filter(e => {
if (e.from !== nodeId) return false;
const neighbor = RAW_NODES.find(n => n.id === e.to);
return neighbor && !hiddenNodeTypes.has(neighbor.node_type) && !hiddenCommunities.has(neighbor.community);
});
const sortEdges = (a, b, direction) => {
const neighborIdA = direction === 'outgoing' ? a.to : a.from;
const neighborIdB = direction === 'outgoing' ? b.to : b.from;
const nodeA = RAW_NODES.find(n => n.id === neighborIdA);
const nodeB = RAW_NODES.find(n => n.id === neighborIdB);
if (!nodeA || !nodeB) return 0;
if (nodeA.node_type === 'intent' && nodeB.node_type !== 'intent') return -1;
if (nodeB.node_type === 'intent' && nodeA.node_type !== 'intent') return 1;
return (nodeB.degree || 0) - (nodeA.degree || 0);
};
incomingEdges.sort((a, b) => sortEdges(a, b, 'incoming'));
outgoingEdges.sort((a, b) => sortEdges(a, b, 'outgoing'));
if (incomingEdges.length > 0) {
html += \`<div class="neighbors-section">
<div class="neighbors-header">Incoming Connections (\${incomingEdges.length})</div>
<div id="incoming-list-container" class="neighbor-list">\`;
const visibleIncoming = incomingEdges.slice(0, 5);
html += visibleIncoming.map(e => renderNeighborItem(e, 'incoming')).join('');
html += \`</div>\`;
if (incomingEdges.length > 5) {
html += \`<button class="show-more-btn" data-action="toggle-neighbors" data-node-id="\${escapeHtmlAttr(nodeId)}" data-direction="incoming" data-state="collapsed">Show all</button>\`;
}
html += \`</div>\`;
}
if (outgoingEdges.length > 0) {
html += \`<div class="neighbors-section">
<div class="neighbors-header">Outgoing Connections (\${outgoingEdges.length})</div>
<div id="outgoing-list-container" class="neighbor-list">\`;
const visibleOutgoing = outgoingEdges.slice(0, 5);
html += visibleOutgoing.map(e => renderNeighborItem(e, 'outgoing')).join('');
html += \`</div>\`;
if (outgoingEdges.length > 5) {
html += \`<button class="show-more-btn" data-action="toggle-neighbors" data-node-id="\${escapeHtmlAttr(nodeId)}" data-direction="outgoing" data-state="collapsed">Show all</button>\`;
}
html += \`</div>\`;
}
infoContent.innerHTML = html;
}
network.on("selectNode", (params) => showNodeInfo(params.nodes[0]));
network.on("deselectNode", () => showNodeInfo(null));
network.on("animationFinished", () => {
network.setOptions({ edges: { hidden: false } });
});
const suggestionsBox = document.getElementById('search-suggestions');
const searchInput = document.getElementById('search');
searchInput.addEventListener('focus', () => {
network.setOptions({ interaction: { keyboard: false } });
});
searchInput.addEventListener('blur', () => {
network.setOptions({ interaction: { keyboard: true } });
});
let searchTimer;
let activeSuggestionIndex = -1;
let currentSuggestions = [];
function hideSuggestions() {
suggestionsBox.classList.add('hidden');
suggestionsBox.innerHTML = '';
activeSuggestionIndex = -1;
currentSuggestions = [];
}
function showSuggestions(matched) {
currentSuggestions = matched.slice(0, 10);
if (currentSuggestions.length === 0) {
hideSuggestions();
return;
}
suggestionsBox.innerHTML = currentSuggestions.map((n, idx) => {
const activeClass = idx === 0 ? 'active' : '';
return \`<div class="suggestion-item \${activeClass}" data-id="\${escapeHtmlAttr(n.id)}" data-idx="\${idx}">
<span class="suggestion-name">\${n.name || n.id}</span>
<span class="suggestion-type">\${n.node_type}</span>
</div>\`;
}).join('');
suggestionsBox.classList.remove('hidden');
activeSuggestionIndex = 0;
}
function updateActiveSuggestion(index) {
const items = suggestionsBox.getElementsByClassName('suggestion-item');
for (let i = 0; i < items.length; i++) {
if (i === index) {
items[i].classList.add('active');
items[i].scrollIntoView({ block: 'nearest' });
} else {
items[i].classList.remove('active');
}
}
activeSuggestionIndex = index;
}
function selectSuggestion(nodeId) {
const nodeData = RAW_NODES.find(n => n.id === nodeId);
if (!nodeData) return;
network.selectNodes([nodeId]);
network.setOptions({ edges: { hidden: true } });
network.focus(nodeId, { scale: 0.5, animation: { duration: 1000, easingFunction: 'easeInOutQuad' }});
showNodeInfo(nodeId);
searchInput.value = nodeData.label || nodeId;
hideSuggestions();
}
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimer);
const val = e.target.value.trim().toLowerCase();
if (!val) {
network.selectNodes([]);
showNodeInfo(null);
hideSuggestions();
return;
}
searchTimer = setTimeout(() => {
const terms = val.split(/\\s+/).filter(t => t.length > 0);
if (terms.length === 0) return;
const scored = [];
for (const n of RAW_NODES) {
if (hiddenNodeTypes.has(n.node_type)) continue;
const name = (n.label || "").toLowerCase();
const id = n.id.toLowerCase();
const file = (n.source_file || "").toLowerCase();
let score = 0;
for (const t of terms) {
if (t === name || t === id) {
score += 1000;
} else if (name.startsWith(t) || id.startsWith(t)) {
score += 100;
} else if (name.includes(t) || id.includes(t)) {
score += 1;
} else if (file.includes(t)) {
score += 0.5;
}
}
if (score > 0) {
score += (n.degree || 0) * 0.01;
scored.push({ id: n.id, name: n.label || n.id, node_type: n.node_type, score });
}
}
scored.sort((a, b) => b.score - a.score);
showSuggestions(scored);
}, 400);
});
searchInput.addEventListener('keydown', (e) => {
if (suggestionsBox.classList.contains('hidden')) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
const nextIndex = (activeSuggestionIndex + 1) % currentSuggestions.length;
updateActiveSuggestion(nextIndex);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex = (activeSuggestionIndex - 1 + currentSuggestions.length) % currentSuggestions.length;
updateActiveSuggestion(prevIndex);
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeSuggestionIndex >= 0 && activeSuggestionIndex < currentSuggestions.length) {
selectSuggestion(currentSuggestions[activeSuggestionIndex].id);
searchInput.blur();
}
} else if (e.key === 'Escape') {
e.preventDefault();
hideSuggestions();
}
});
suggestionsBox.addEventListener('click', (e) => {
const item = e.target.closest('.suggestion-item');
if (item) {
const id = item.getAttribute('data-id');
selectSuggestion(id);
}
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#search-box')) {
hideSuggestions();
}
});
document.getElementById('info-content').addEventListener('click', (e) => {
const neighborItem = e.target.closest('.neighbor-item');
if (neighborItem) {
const neighborId = neighborItem.getAttribute('data-neighbor-id');
if (neighborId) {
selectSuggestion(neighborId);
return;
}
}
const showMoreBtn = e.target.closest('.show-more-btn');
if (showMoreBtn && showMoreBtn.getAttribute('data-action') === 'toggle-neighbors') {
const nodeId = showMoreBtn.getAttribute('data-node-id');
const direction = showMoreBtn.getAttribute('data-direction');
if (nodeId && direction) {
toggleNeighbors(nodeId, direction, showMoreBtn);
}
}
});
function updateNodeVisibilities() {
const updates = [];
let selectedHidden = false;
const selectedIds = network.getSelectedNodes();
for (const n of RAW_NODES) {
const shouldHide = hiddenNodeTypes.has(n.node_type) || hiddenCommunities.has(n.community);
updates.push({
id: n.id,
hidden: shouldHide
});
if (shouldHide && selectedIds.includes(n.id)) {
selectedHidden = true;
}
}
nodes.update(updates);
if (selectedHidden) {
network.selectNodes([]);
showNodeInfo(null);
}
}
document.getElementById('legend').addEventListener('click', (e) => {
const item = e.target.closest('.legend-item');
if (!item) return;
if (currentViewMode === 'types') {
const typesStr = item.getAttribute('data-type');
if (typesStr) {
const types = typesStr.split(',');
const isCurrentlyHidden = hiddenNodeTypes.has(types[0]);
for (const t of types) {
if (isCurrentlyHidden) {
hiddenNodeTypes.delete(t);
} else {
hiddenNodeTypes.add(t);
}
}
if (isCurrentlyHidden) {
item.classList.remove('inactive');
} else {
item.classList.add('inactive');
}
updateNodeVisibilities();
}
} else {
const commId = item.getAttribute('data-community');
if (commId) {
const isCurrentlyHidden = hiddenCommunities.has(commId);
if (isCurrentlyHidden) {
hiddenCommunities.delete(commId);
item.classList.remove('inactive');
} else {
hiddenCommunities.add(commId);
item.classList.add('inactive');
}
updateNodeVisibilities();
}
}
});
</script>
</body>
</html>`;
const htmlPath = path.join(outDir, "graph.html");
fs.writeFileSync(htmlPath, htmlContent, "utf-8");
return htmlPath;
}
export {
exportGraphHtml,
exportGraphJson,
exportReportMarkdown
};