| // 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 · ${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, '"'); | ||
| } | ||
| function escapeHtml(str) { | ||
| if (!str) return ''; | ||
| return str | ||
| .replace(/&/g, '&') | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>') | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, '''); | ||
| } | ||
| 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) { |
+1
-1
| { | ||
| "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 · ${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, '"'); | ||
| } | ||
| 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 | ||
| }; |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 3 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
5947882
0.04%9462
0.66%1
-50%28
3.7%