| import { | ||
| analyzeGraph, | ||
| detectCommunities, | ||
| findGodNodes, | ||
| findSurprisingConnections | ||
| } from "./chunk-JK3K5KL7.js"; | ||
| export { | ||
| analyzeGraph, | ||
| detectCommunities, | ||
| findGodNodes, | ||
| findSurprisingConnections | ||
| }; |
| // src/core/analyze.ts | ||
| import louvain from "graphology-communities-louvain"; | ||
| var louvainAlgorithm = louvain; | ||
| function isStructuralNoise(graph, nodeId) { | ||
| const data = graph.getNodeAttributes(nodeId); | ||
| if (data.type === "intent") return true; | ||
| if (nodeId.startsWith("import::")) return true; | ||
| if (nodeId.startsWith("unresolved_")) return true; | ||
| return false; | ||
| } | ||
| function findGodNodes(graph, topN = 10) { | ||
| const ranked = []; | ||
| graph.forEachNode((nodeId, data) => { | ||
| if (isStructuralNoise(graph, nodeId)) return; | ||
| ranked.push({ | ||
| id: nodeId, | ||
| name: data.name, | ||
| type: data.type, | ||
| degree: graph.degree(nodeId) | ||
| }); | ||
| }); | ||
| ranked.sort((a, b) => b.degree - a.degree); | ||
| return ranked.slice(0, topN); | ||
| } | ||
| function detectCommunities(graph) { | ||
| if (graph.order === 0) return []; | ||
| let communityMap = {}; | ||
| let seed = 123456789; | ||
| const rng = () => { | ||
| seed = (seed * 9301 + 49297) % 233280; | ||
| return seed / 233280; | ||
| }; | ||
| try { | ||
| communityMap = louvainAlgorithm(graph, { rng }); | ||
| } catch { | ||
| graph.forEachNode((nodeId) => { | ||
| communityMap[nodeId] = 0; | ||
| }); | ||
| } | ||
| const groups = /* @__PURE__ */ new Map(); | ||
| for (const [nodeId, cid] of Object.entries(communityMap)) { | ||
| if (!groups.has(cid)) groups.set(cid, []); | ||
| groups.get(cid).push(nodeId); | ||
| } | ||
| const communities = []; | ||
| for (const [cid, nodes] of groups.entries()) { | ||
| communities.push({ | ||
| id: cid, | ||
| nodes, | ||
| cohesion: cohesionScore(graph, nodes) | ||
| }); | ||
| } | ||
| communities.sort((a, b) => b.nodes.length - a.nodes.length); | ||
| return communities; | ||
| } | ||
| function cohesionScore(graph, communityNodes) { | ||
| const n = communityNodes.length; | ||
| if (n <= 1) return 1; | ||
| const nodeSet = new Set(communityNodes); | ||
| let internalEdges = 0; | ||
| graph.forEachEdge((_edgeId, _data, source, target) => { | ||
| if (nodeSet.has(source) && nodeSet.has(target)) { | ||
| internalEdges++; | ||
| } | ||
| }); | ||
| const possible = n * (n - 1); | ||
| return possible > 0 ? Math.round(internalEdges / possible * 100) / 100 : 0; | ||
| } | ||
| function findSurprisingConnections(graph, communities, topN = 5) { | ||
| const nodeCommunity = /* @__PURE__ */ new Map(); | ||
| for (const comm of communities) { | ||
| for (const nodeId of comm.nodes) { | ||
| nodeCommunity.set(nodeId, comm.id); | ||
| } | ||
| } | ||
| const candidates = []; | ||
| graph.forEachEdge((_edgeId, edgeData, source, target) => { | ||
| const srcComm = nodeCommunity.get(source); | ||
| const tgtComm = nodeCommunity.get(target); | ||
| if (srcComm === void 0 || tgtComm === void 0) return; | ||
| if (srcComm === tgtComm) return; | ||
| if (isStructuralNoise(graph, source) || isStructuralNoise(graph, target)) | ||
| return; | ||
| if (edgeData.type === "imports" || edgeData.type === "defines") return; | ||
| const srcData = graph.getNodeAttributes(source); | ||
| const tgtData = graph.getNodeAttributes(target); | ||
| let score = 1; | ||
| const reasons = []; | ||
| reasons.push(`bridges community ${srcComm} \u2192 community ${tgtComm}`); | ||
| if (edgeData.type === "references") { | ||
| score += 2; | ||
| reasons.push(`${edgeData.type} relationship (deep AST coupling)`); | ||
| } | ||
| const srcDeg = graph.degree(source); | ||
| const tgtDeg = graph.degree(target); | ||
| if (Math.min(srcDeg, tgtDeg) <= 2 && Math.max(srcDeg, tgtDeg) >= 5) { | ||
| score += 1; | ||
| const peripheral = srcDeg <= 2 ? srcData.name : tgtData.name; | ||
| const hub = srcDeg <= 2 ? tgtData.name : srcData.name; | ||
| reasons.push( | ||
| `peripheral node "${peripheral}" unexpectedly reaches hub "${hub}"` | ||
| ); | ||
| } | ||
| if (edgeData.confidence === "AMBIGUOUS") { | ||
| score += 3; | ||
| reasons.push("ambiguous connection \u2014 needs verification"); | ||
| } else if (edgeData.confidence === "INFERRED") { | ||
| score += 2; | ||
| reasons.push("inferred connection \u2014 not explicitly stated in source"); | ||
| } | ||
| candidates.push({ | ||
| source, | ||
| sourceName: srcData.name, | ||
| target, | ||
| targetName: tgtData.name, | ||
| edgeType: edgeData.type, | ||
| confidence: edgeData.confidence, | ||
| sourceCommunity: srcComm, | ||
| targetCommunity: tgtComm, | ||
| why: reasons.join("; "), | ||
| _score: score | ||
| }); | ||
| }); | ||
| candidates.sort((a, b) => b._score - a._score); | ||
| const seenPairs = /* @__PURE__ */ new Set(); | ||
| const results = []; | ||
| for (const c of candidates) { | ||
| const pair = [c.sourceCommunity, c.targetCommunity].sort().join("-"); | ||
| if (seenPairs.has(pair)) continue; | ||
| seenPairs.add(pair); | ||
| const { _score, ...connection } = c; | ||
| results.push(connection); | ||
| if (results.length >= topN) break; | ||
| } | ||
| return results; | ||
| } | ||
| function analyzeGraph(graph) { | ||
| const godNodes = findGodNodes(graph); | ||
| const communities = detectCommunities(graph); | ||
| const surprisingConnections = findSurprisingConnections( | ||
| graph, | ||
| communities | ||
| ); | ||
| const knowledgeGaps = []; | ||
| graph.forEachNode((nodeId) => { | ||
| if (graph.degree(nodeId) <= 1 && !isStructuralNoise(graph, nodeId)) { | ||
| knowledgeGaps.push(nodeId); | ||
| } | ||
| }); | ||
| const questions = []; | ||
| if (surprisingConnections.length > 0) { | ||
| questions.push("Why are these distinct communities connected via Surprising Connections?"); | ||
| } | ||
| if (godNodes.length > 0) { | ||
| questions.push(`How would the system react if the core logic in '${godNodes[0]?.name}' was refactored?`); | ||
| } | ||
| if (knowledgeGaps.length > 5) { | ||
| questions.push("There are several isolated modules; are these dead code or missing integration tests?"); | ||
| } | ||
| if (communities.length > 1) { | ||
| questions.push("Are the boundaries between these communities enforced, or is there hidden leakage?"); | ||
| } | ||
| return { | ||
| godNodes, | ||
| communities, | ||
| surprisingConnections, | ||
| nodeCount: graph.order, | ||
| edgeCount: graph.size, | ||
| knowledgeGaps: knowledgeGaps.slice(0, 10), | ||
| // Limit to top 10 | ||
| suggestedQuestions: questions | ||
| }; | ||
| } | ||
| export { | ||
| findGodNodes, | ||
| detectCommunities, | ||
| findSurprisingConnections, | ||
| analyzeGraph | ||
| }; |
| // 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: "0.4.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: {} | ||
| } | ||
| } | ||
| ] | ||
| }; | ||
| }); | ||
| 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-FGSLUNHR.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-FGSLUNHR.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-FGSLUNHR.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-FGSLUNHR.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-FGSLUNHR.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-FGSLUNHR.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-FGSLUNHR.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-FGSLUNHR.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-FGSLUNHR.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); | ||
| await execAsync("geraph scan", { cwd: targetDir }); | ||
| const { loadGraph } = await import("./query-FGSLUNHR.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) | ||
| ); | ||
| let communitiesCount = 0; | ||
| try { | ||
| const { readFileSync } = await import("fs"); | ||
| const { join } = await import("path"); | ||
| const graphJsonPath = join(targetDir, ".geraph", "graph.json"); | ||
| const raw = readFileSync(graphJsonPath, "utf8"); | ||
| const data = JSON.parse(raw); | ||
| communitiesCount = data.analysis?.communities?.length || 0; | ||
| } catch { | ||
| } | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Graph successfully scanned and memory updated. Discovered ${graph.order} nodes, ${graph.size} edges, and ${communitiesCount} communities.` | ||
| } | ||
| ] | ||
| }; | ||
| } 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" | ||
| }, | ||
| { | ||
| uri: "geraph://audit", | ||
| name: "Confidence Audit", | ||
| description: "EXTRACTED/INFERRED/AMBIGUOUS edge breakdown", | ||
| 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-FGSLUNHR.js"); | ||
| const text = await getGraphStats(graph); | ||
| return { | ||
| contents: [{ uri, mimeType: "text/plain", text }] | ||
| }; | ||
| } | ||
| if (uri === "geraph://god-nodes") { | ||
| const { getGodNodes } = await import("./query-FGSLUNHR.js"); | ||
| const text = await getGodNodes(graph, 1, 10); | ||
| return { | ||
| contents: [{ uri, mimeType: "text/plain", text }] | ||
| }; | ||
| } | ||
| if (uri === "geraph://surprises") { | ||
| const { getSurprisingConnections } = await import("./query-FGSLUNHR.js"); | ||
| const text = await getSurprisingConnections(graph, 1, 10); | ||
| return { | ||
| contents: [{ uri, mimeType: "text/plain", text }] | ||
| }; | ||
| } | ||
| if (uri === "geraph://audit") { | ||
| 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 = Math.round(extractedCount / total * 100); | ||
| const infPct = Math.round(inferredCount / total * 100); | ||
| const ambPct = Math.round(ambiguousCount / total * 100); | ||
| const text = [ | ||
| `Total edges: ${total}`, | ||
| `EXTRACTED: ${extractedCount} (${extPct}%)`, | ||
| `INFERRED: ${inferredCount} (${infPct}%)`, | ||
| `AMBIGUOUS: ${ambiguousCount} (${ambPct}%)` | ||
| ].join("\n"); | ||
| 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 | ||
| }; |
| // src/core/query.ts | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| import { MultiDirectedGraph } from "graphology"; | ||
| 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 = new MultiDirectedGraph(); | ||
| 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 (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-V3KBFH2R.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-V3KBFH2R.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; | ||
| const sourceFile = attr.file || ""; | ||
| lines.push(` ${label} [${sourceFile}]`); | ||
| }); | ||
| 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-V3KBFH2R.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 = Math.round(extractedCount / total * 100); | ||
| const infPct = Math.round(inferredCount / total * 100); | ||
| const ambPct = Math.round(ambiguousCount / total * 100); | ||
| let communitiesCount = 0; | ||
| try { | ||
| const { detectCommunities } = await import("./analyze-V3KBFH2R.js"); | ||
| communitiesCount = detectCommunities(graph).length; | ||
| } catch { | ||
| } | ||
| return [ | ||
| `Nodes: ${graph.order}`, | ||
| `Edges: ${graph.size}`, | ||
| `Communities: ${communitiesCount}`, | ||
| `EXTRACTED: ${extPct}%`, | ||
| `INFERRED: ${infPct}%`, | ||
| `AMBIGUOUS: ${ambPct}%` | ||
| ].join("\n"); | ||
| } | ||
| export { | ||
| getCommunityNodes, | ||
| getGodNodes, | ||
| getGraphStats, | ||
| getNeighbors, | ||
| getNode, | ||
| getSurprisingConnections, | ||
| loadGraph, | ||
| queryGraph, | ||
| searchGraph, | ||
| shortestPath | ||
| }; |
+21
| MIT License | ||
| Copyright (c) 2026 Rupam Mondal | ||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
+94
-9
@@ -232,2 +232,30 @@ // src/core/worker.ts | ||
| ]); | ||
| function findNearestPackageJson(dir) { | ||
| let current = dir; | ||
| while (true) { | ||
| const candidate = path.join(current, "package.json"); | ||
| if (fs.existsSync(candidate)) return candidate; | ||
| const parent = path.dirname(current); | ||
| if (parent === current) break; | ||
| current = parent; | ||
| } | ||
| return ""; | ||
| } | ||
| function getBasePackageName(importPath) { | ||
| if (importPath.startsWith(".") || importPath.startsWith("/")) { | ||
| return importPath; | ||
| } | ||
| const normalized = importPath.replace(/^node:/, ""); | ||
| const parts = normalized.split("/"); | ||
| if (normalized.startsWith("@")) { | ||
| if (parts.length >= 2) { | ||
| return `${parts[0]}/${parts[1]}`; | ||
| } | ||
| } else { | ||
| if (parts.length >= 1) { | ||
| return parts[0]; | ||
| } | ||
| } | ||
| return importPath; | ||
| } | ||
| function resolveImportToNode(importPath, sourceFilePath, aliases = []) { | ||
@@ -302,2 +330,3 @@ const checkCandidates = (resolvedBase) => { | ||
| (import_statement source: (string) @import_source) | ||
| (import) @import_source | ||
@@ -387,2 +416,43 @@ (call_expression | ||
| currentSource = capture.node.text.replace(/['"]/g, ""); | ||
| if (capture.node.parent?.type === "import_expression" || capture.node.parent?.type === "call_expression") { | ||
| let parent = capture.node.parent; | ||
| while (parent && parent.type !== "variable_declarator") { | ||
| parent = parent.parent; | ||
| } | ||
| if (parent) { | ||
| const nameNode = parent.childForFieldName("name"); | ||
| if (nameNode) { | ||
| if (nameNode.type === "identifier") { | ||
| importMap.set(nameNode.text, currentSource); | ||
| } else if (nameNode.type === "object_pattern") { | ||
| const identifiers = []; | ||
| const extractIds = (n) => { | ||
| if (n.type === "identifier" || n.type === "shorthand_property_identifier") { | ||
| identifiers.push(n.text); | ||
| } | ||
| for (let i = 0; i < n.namedChildCount; i++) { | ||
| const child = n.namedChild(i); | ||
| if (child) extractIds(child); | ||
| } | ||
| }; | ||
| extractIds(nameNode); | ||
| for (const id of identifiers) { | ||
| importMap.set(id, currentSource); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } else if (capture.name === "require_source") { | ||
| currentSource = capture.node.text.replace(/['"]/g, ""); | ||
| let parent = capture.node.parent; | ||
| while (parent && parent.type !== "variable_declarator") { | ||
| parent = parent.parent; | ||
| } | ||
| if (parent) { | ||
| const nameNode = parent.childForFieldName("name"); | ||
| if (nameNode && nameNode.type === "identifier") { | ||
| importMap.set(nameNode.text, currentSource); | ||
| } | ||
| } | ||
| } | ||
@@ -425,9 +495,11 @@ } | ||
| const resolvedId = resolveImportToNode(importPath, filePath, aliases); | ||
| const targetNodeId = resolvedId || `import::${importPath}`; | ||
| const basePackage = getBasePackageName(importPath); | ||
| const targetNodeId = resolvedId || `import::${basePackage}`; | ||
| if (!graph.hasNode(targetNodeId)) { | ||
| const nearestPkgJson = findNearestPackageJson(path.dirname(filePath)); | ||
| graph.addNode(targetNodeId, { | ||
| type: "file", | ||
| name: resolvedId ? path.basename(resolvedId) : importPath, | ||
| file: resolvedId || filePath, | ||
| startLine: resolvedId ? 0 : importLine, | ||
| name: resolvedId ? path.basename(resolvedId) : basePackage, | ||
| file: resolvedId || nearestPkgJson || filePath, | ||
| startLine: 0, | ||
| metadata: resolvedId ? {} : { external: true, callerFile: filePath, callerLine: importLine } | ||
@@ -562,6 +634,14 @@ }); | ||
| if (importSource) { | ||
| const baseImportSource = getBasePackageName(importSource); | ||
| const resolvedSource = resolveImportToNode(importSource, filePath, aliases) || importSource; | ||
| const importNodeId = resolveImportToNode(importSource, filePath, aliases) || `import::${importSource}`; | ||
| const importNodeId = resolveImportToNode(importSource, filePath, aliases) || `import::${baseImportSource}`; | ||
| if (!graph.hasNode(importNodeId)) { | ||
| graph.addNode(importNodeId, { type: "file", name: importSource, file: resolvedSource, startLine: 0, metadata: { external: true } }); | ||
| const nearestPkgJson = findNearestPackageJson(path.dirname(filePath)); | ||
| graph.addNode(importNodeId, { | ||
| type: "file", | ||
| name: resolvedSource ? path.basename(resolvedSource) : baseImportSource, | ||
| file: resolvedSource || nearestPkgJson || filePath, | ||
| startLine: 0, | ||
| metadata: { external: true } | ||
| }); | ||
| } | ||
@@ -591,2 +671,5 @@ if (!graph.hasEdge(importNodeId, targetId)) { | ||
| const baseName = objectName || calledName; | ||
| if (objectName && !importMap.has(objectName) && !localDefinitions.has(objectName)) { | ||
| continue; | ||
| } | ||
| if (BUILT_INS.has(baseName) && !localDefinitions.has(baseName) && !importMap.has(baseName)) continue; | ||
@@ -629,9 +712,11 @@ const callerId = ensureScopeNode(node); | ||
| if (importSource) { | ||
| const baseImportSource = getBasePackageName(importSource); | ||
| const resolvedSource = resolveImportToNode(importSource, filePath, aliases) || importSource; | ||
| const importNodeId = resolveImportToNode(importSource, filePath, aliases) || `import::${importSource}`; | ||
| const importNodeId = resolveImportToNode(importSource, filePath, aliases) || `import::${baseImportSource}`; | ||
| if (!graph.hasNode(importNodeId)) { | ||
| const nearestPkgJson = findNearestPackageJson(path.dirname(filePath)); | ||
| graph.addNode(importNodeId, { | ||
| type: "file", | ||
| name: importSource, | ||
| file: resolvedSource, | ||
| name: resolvedSource ? path.basename(resolvedSource) : baseImportSource, | ||
| file: resolvedSource || nearestPkgJson || filePath, | ||
| startLine: 0, | ||
@@ -638,0 +723,0 @@ metadata: { external: true } |
+284
-227
| #!/usr/bin/env node | ||
| import { | ||
| analyzeGraph | ||
| } from "./chunk-JK3K5KL7.js"; | ||
@@ -440,176 +443,2 @@ // src/cli.ts | ||
| // src/core/analyze.ts | ||
| import louvain from "graphology-communities-louvain"; | ||
| var louvainAlgorithm = louvain; | ||
| function isStructuralNoise(graph, nodeId) { | ||
| const data = graph.getNodeAttributes(nodeId); | ||
| if (data.type === "intent") return true; | ||
| if (nodeId.startsWith("import::")) return true; | ||
| if (nodeId.startsWith("unresolved_")) return true; | ||
| return false; | ||
| } | ||
| function findGodNodes(graph, topN = 10) { | ||
| const ranked = []; | ||
| graph.forEachNode((nodeId, data) => { | ||
| if (isStructuralNoise(graph, nodeId)) return; | ||
| ranked.push({ | ||
| id: nodeId, | ||
| name: data.name, | ||
| type: data.type, | ||
| degree: graph.degree(nodeId) | ||
| }); | ||
| }); | ||
| ranked.sort((a, b) => b.degree - a.degree); | ||
| return ranked.slice(0, topN); | ||
| } | ||
| function detectCommunities(graph) { | ||
| if (graph.order === 0) return []; | ||
| let communityMap = {}; | ||
| let seed = 123456789; | ||
| const rng = () => { | ||
| seed = (seed * 9301 + 49297) % 233280; | ||
| return seed / 233280; | ||
| }; | ||
| try { | ||
| communityMap = louvainAlgorithm(graph, { rng }); | ||
| } catch { | ||
| graph.forEachNode((nodeId) => { | ||
| communityMap[nodeId] = 0; | ||
| }); | ||
| } | ||
| const groups = /* @__PURE__ */ new Map(); | ||
| for (const [nodeId, cid] of Object.entries(communityMap)) { | ||
| if (!groups.has(cid)) groups.set(cid, []); | ||
| groups.get(cid).push(nodeId); | ||
| } | ||
| const communities = []; | ||
| for (const [cid, nodes] of groups.entries()) { | ||
| communities.push({ | ||
| id: cid, | ||
| nodes, | ||
| cohesion: cohesionScore(graph, nodes) | ||
| }); | ||
| } | ||
| communities.sort((a, b) => b.nodes.length - a.nodes.length); | ||
| return communities; | ||
| } | ||
| function cohesionScore(graph, communityNodes) { | ||
| const n = communityNodes.length; | ||
| if (n <= 1) return 1; | ||
| const nodeSet = new Set(communityNodes); | ||
| let internalEdges = 0; | ||
| graph.forEachEdge((_edgeId, _data, source, target) => { | ||
| if (nodeSet.has(source) && nodeSet.has(target)) { | ||
| internalEdges++; | ||
| } | ||
| }); | ||
| const possible = n * (n - 1); | ||
| return possible > 0 ? Math.round(internalEdges / possible * 100) / 100 : 0; | ||
| } | ||
| function findSurprisingConnections(graph, communities, topN = 5) { | ||
| const nodeCommunity = /* @__PURE__ */ new Map(); | ||
| for (const comm of communities) { | ||
| for (const nodeId of comm.nodes) { | ||
| nodeCommunity.set(nodeId, comm.id); | ||
| } | ||
| } | ||
| const candidates = []; | ||
| graph.forEachEdge((_edgeId, edgeData, source, target) => { | ||
| const srcComm = nodeCommunity.get(source); | ||
| const tgtComm = nodeCommunity.get(target); | ||
| if (srcComm === void 0 || tgtComm === void 0) return; | ||
| if (srcComm === tgtComm) return; | ||
| if (isStructuralNoise(graph, source) || isStructuralNoise(graph, target)) | ||
| return; | ||
| if (edgeData.type === "imports" || edgeData.type === "defines") return; | ||
| const srcData = graph.getNodeAttributes(source); | ||
| const tgtData = graph.getNodeAttributes(target); | ||
| let score = 1; | ||
| const reasons = []; | ||
| reasons.push(`bridges community ${srcComm} \u2192 community ${tgtComm}`); | ||
| if (edgeData.type === "references" || edgeData.type === "extends" || edgeData.type === "implements") { | ||
| score += 2; | ||
| reasons.push(`${edgeData.type} relationship (deep OOP coupling)`); | ||
| } | ||
| const srcDeg = graph.degree(source); | ||
| const tgtDeg = graph.degree(target); | ||
| if (Math.min(srcDeg, tgtDeg) <= 2 && Math.max(srcDeg, tgtDeg) >= 5) { | ||
| score += 1; | ||
| const peripheral = srcDeg <= 2 ? srcData.name : tgtData.name; | ||
| const hub = srcDeg <= 2 ? tgtData.name : srcData.name; | ||
| reasons.push( | ||
| `peripheral node "${peripheral}" unexpectedly reaches hub "${hub}"` | ||
| ); | ||
| } | ||
| if (edgeData.confidence === "AMBIGUOUS") { | ||
| score += 3; | ||
| reasons.push("ambiguous connection \u2014 needs verification"); | ||
| } else if (edgeData.confidence === "INFERRED") { | ||
| score += 2; | ||
| reasons.push("inferred connection \u2014 not explicitly stated in source"); | ||
| } | ||
| candidates.push({ | ||
| source, | ||
| sourceName: srcData.name, | ||
| target, | ||
| targetName: tgtData.name, | ||
| edgeType: edgeData.type, | ||
| confidence: edgeData.confidence, | ||
| sourceCommunity: srcComm, | ||
| targetCommunity: tgtComm, | ||
| why: reasons.join("; "), | ||
| _score: score | ||
| }); | ||
| }); | ||
| candidates.sort((a, b) => b._score - a._score); | ||
| const seenPairs = /* @__PURE__ */ new Set(); | ||
| const results = []; | ||
| for (const c of candidates) { | ||
| const pair = [c.sourceCommunity, c.targetCommunity].sort().join("-"); | ||
| if (seenPairs.has(pair)) continue; | ||
| seenPairs.add(pair); | ||
| const { _score, ...connection } = c; | ||
| results.push(connection); | ||
| if (results.length >= topN) break; | ||
| } | ||
| return results; | ||
| } | ||
| function analyzeGraph(graph) { | ||
| const godNodes = findGodNodes(graph); | ||
| const communities = detectCommunities(graph); | ||
| const surprisingConnections = findSurprisingConnections( | ||
| graph, | ||
| communities | ||
| ); | ||
| const knowledgeGaps = []; | ||
| graph.forEachNode((nodeId) => { | ||
| if (graph.degree(nodeId) <= 1 && !isStructuralNoise(graph, nodeId)) { | ||
| knowledgeGaps.push(nodeId); | ||
| } | ||
| }); | ||
| const questions = []; | ||
| if (surprisingConnections.length > 0) { | ||
| questions.push("Why are these distinct communities connected via Surprising Connections?"); | ||
| } | ||
| if (godNodes.length > 0) { | ||
| questions.push(`How would the system react if the core logic in '${godNodes[0]?.name}' was refactored?`); | ||
| } | ||
| if (knowledgeGaps.length > 5) { | ||
| questions.push("There are several isolated modules; are these dead code or missing integration tests?"); | ||
| } | ||
| if (communities.length > 1) { | ||
| questions.push("Are the boundaries between these communities enforced, or is there hidden leakage?"); | ||
| } | ||
| return { | ||
| godNodes, | ||
| communities, | ||
| surprisingConnections, | ||
| nodeCount: graph.order, | ||
| edgeCount: graph.size, | ||
| knowledgeGaps: knowledgeGaps.slice(0, 10), | ||
| // Limit to top 10 | ||
| suggestedQuestions: questions | ||
| }; | ||
| } | ||
| // src/core/alias.ts | ||
@@ -745,10 +574,11 @@ import fs4 from "fs"; | ||
| `; | ||
| md += `*Note: Only the first 100 files and their primary members are listed here. Use 'query' for full details.* | ||
| 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 fileNodes = graph.nodes().filter((n) => graph.getNodeAttribute(n, "type") === "file").slice(0, 100); | ||
| 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}** | ||
| md += `- **${data.name}** [id: \`${file}\`] | ||
| `; | ||
@@ -762,3 +592,3 @@ const definesEdges = graph.outEdges(file).filter((edgeId) => { | ||
| const targetData = graph.getNodeAttributes(target); | ||
| md += ` - \`${targetData.type} ${targetData.name}\` | ||
| md += ` - \`${targetData.type} ${targetData.name}\` [id: \`${target}\`] | ||
| `; | ||
@@ -771,11 +601,22 @@ } | ||
| } | ||
| 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 += ` | ||
| ## God Nodes (Architectural Pillars) | ||
| ## ${title} | ||
| `; | ||
| md += `These are the most-connected entities in the codebase. Changes to these nodes have the largest ripple effect. | ||
| 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. | ||
| `; | ||
| for (const god of analysis.godNodes) { | ||
| md += `- **${god.name}** (\`${god.type}\`, ${god.degree} connections) | ||
| const godsToShow = analysis.godNodes.slice(0, 10); | ||
| for (const god of godsToShow) { | ||
| md += `- **${god.name}** (type: \`${god.type}\`, id: \`${god.id}\`, ${god.degree} connections) | ||
| `; | ||
@@ -788,23 +629,35 @@ } | ||
| `; | ||
| md += `The codebase clusters into ${analysis.communities.length} communities of related code. | ||
| 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 topMembers = comm.nodes.filter((n) => !n.startsWith("commit::") && !n.startsWith("import::") && !n.startsWith("unresolved_")).slice(0, 5).map((n) => { | ||
| 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); | ||
| return data ? data.name : n; | ||
| const name = data ? data.name : n; | ||
| return `**${name}** [id: \`${n}\`]`; | ||
| }); | ||
| md += `- **Community ${comm.id}** (${comm.nodes.length} nodes, cohesion: ${comm.cohesion}) \u2014 ${topMembers.join(", ")} | ||
| `; | ||
| let communityLine = `- **Community ${comm.id}** (${comm.nodes.length} nodes, cohesion: ${comm.cohesion}) \u2014 ${memberStrings.join(", ")}`; | ||
| if (realNodes.length > 5) { | ||
| communityLine += `, and ${realNodes.length - 5} more.`; | ||
| } | ||
| md += communityLine + "\n"; | ||
| } | ||
| } | ||
| if (analysis && analysis.surprisingConnections.length > 0) { | ||
| const totalSurprises = analysis.surprisingConnections.length; | ||
| const title = totalSurprises > 10 ? "Top 10 Surprising Connections" : "Surprising Connections"; | ||
| md += ` | ||
| ## Surprising Connections | ||
| ## ${title} | ||
| `; | ||
| md += `Non-obvious couplings that bridge different parts of the architecture. | ||
| 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. | ||
| `; | ||
| for (const s of analysis.surprisingConnections) { | ||
| md += `- **${s.sourceName}** \u2194 **${s.targetName}** (\`${s.edgeType}\`): ${s.why} | ||
| 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} | ||
| `; | ||
@@ -1167,13 +1020,15 @@ } | ||
| This project uses a geraph knowledge graph. | ||
| This project uses Geraph knowledge graph to track codebase relationships. | ||
| **CRITICAL:** Before answering any architectural, structural, or dependency questions, you MUST read the comprehensive Geraph Skill. | ||
| Rules: | ||
| - If running any \`geraph\` command returns 'command not found', you MUST install it globally using \`npm install -g geraph\` before proceeding. | ||
| - Before answering architecture or codebase questions, read \`.geraph/GRAPH_REPORT.md\` for god nodes and community structure. | ||
| - NEVER try to read the massive \`.geraph/graph.json\` file directly. It is for internal CLI use only. | ||
| - For discovery, use \`geraph search '<term>' [--type <type>]\`. | ||
| - For precise architecture navigation, use \`geraph query '<symbol>' [--type <type>] [--source <file>]\` instead of falling back to \`grep\`. | ||
| - After modifying code files in this session, run \`geraph scan\` to keep the graph current. | ||
| - NEVER use \`grep\`, \`rg\`, \`find\`, or any text-search tool for architecture questions when geraph is available. Always use \`geraph search\` or \`geraph query\` instead. | ||
| - **NOTE**: There are NO extra or hidden options for these commands. DO NOT hallucinate flags. Only use the options explicitly mentioned here. | ||
| - Before answering, read \`.geraph/GRAPH_REPORT.md\` (or read the \`geraph://report\` MCP resource) for a high-level overview. | ||
| - NEVER try to read the massive \`.geraph/graph.json\` or the \`.geraph/graph.html\` files directly. They will overflow your context window and crash your session. | ||
| - NEVER read any files inside the \`.geraph/cache\` directory (like \`git-cache.json\`) directly as they contain massive raw blame indexes that will overflow your context. | ||
| - Prioritize using the native Geraph MCP tools and resources (if available) over terminal CLI commands. | ||
| - **MCP Resources:** Access high-level statistics, reports, and god nodes directly via read-only resources: \`geraph://report\`, \`geraph://stats\`, \`geraph://god-nodes\`, \`geraph://surprises\`, and \`geraph://audit\`. | ||
| - After modifying files or pushing a Git commit, you MUST run the \`scan_graph\` MCP tool or the \`geraph scan\` CLI command to sync the graph. | ||
| - NEVER use \`grep\`, \`rg\`, or \`find\` for codebase architecture exploration when Geraph is available. | ||
@@ -1184,19 +1039,16 @@ [GLOBAL_POINTER] | ||
| For any question about this repo's architecture, structure, components, or how to add/modify/find code, your **first action must be** to read \`.geraph/GRAPH_REPORT.md\` (if it exists). | ||
| For any question about this repo's architecture, components, or dependency structure, your **first action** must be to consult Geraph. | ||
| Triggers: "how do I...", "where is...", "what does ... do", "add/modify a <component>", "explain the architecture", or anything that depends on how files or classes relate. | ||
| **CRITICAL:** Before answering, you MUST read the comprehensive Geraph Skill. | ||
| After reading the report, answer using the graph context. Only read source files when (a) modifying/debugging specific code, (b) the graph lacks the needed detail, or (c) the graph is missing or stale. | ||
| Rules: | ||
| - If running any \`geraph\` command returns 'command not found', you MUST install it globally using \`npm install -g geraph\` before proceeding. | ||
| - Before answering, read \`.geraph/GRAPH_REPORT.md\` (or read the \`geraph://report\` MCP resource) for a high-level overview. | ||
| - NEVER try to read the massive \`.geraph/graph.json\` or the \`.geraph/graph.html\` files directly. They will overflow your context window. | ||
| - NEVER read any files inside the \`.geraph/cache\` directory (like \`git-cache.json\`) directly as they contain massive raw blame indexes that will overflow your context. | ||
| - Prioritize using the native Geraph MCP tools and resources (if available) over terminal CLI commands. | ||
| - **MCP Resources:** Access high-level statistics, reports, and god nodes directly via read-only resources: \`geraph://report\`, \`geraph://stats\`, \`geraph://god-nodes\`, \`geraph://surprises\`, and \`geraph://audit\`. | ||
| - After modifying files or pushing a Git commit, you MUST run the \`scan_graph\` MCP tool or the \`geraph scan\` CLI command to sync the graph. | ||
| - NEVER use \`grep\`, \`rg\`, or \`find\` for codebase architecture exploration when Geraph is available. | ||
| If running any \`geraph\` command returns a 'command not found' error, you MUST install it globally using \`npm install -g geraph\` before proceeding. | ||
| NEVER attempt to read the massive \`.geraph/graph.json\` file directly. Use the CLI instead: | ||
| - **Discover nodes**: \`geraph search '<term>' [--type <type>]\` | ||
| - **Trace dependencies**: \`geraph query '<symbol>' [--type <type>] [--source <file>]\` | ||
| - **Update graph**: \`geraph scan\` | ||
| NEVER use \`grep\`, \`rg\`, \`find\`, or any text-search tool for architecture questions when geraph is available. Always use \`geraph search\` or \`geraph query\` instead. | ||
| **NOTE**: There are NO extra or hidden options for these commands. DO NOT hallucinate flags. Only use the options explicitly mentioned here. | ||
| [GLOBAL_POINTER] | ||
@@ -1447,3 +1299,3 @@ `; | ||
| var program = new Command(); | ||
| program.name("geraph").description(chalk4.blue("Geraph: Structural memory for AI agents")).version("0.3.0", "-v, --version", "output the current version"); | ||
| program.name("geraph").description(chalk4.blue("Geraph: Structural memory for AI agents")).version("0.4.0", "-v, --version", "output the current version"); | ||
| program.command("scan").description("Scan the current directory and build the Knowledge Graph").action(async () => { | ||
@@ -1617,3 +1469,3 @@ const targetDir = process.cwd(); | ||
| "Filter results by node type (e.g., 'interface', 'class', 'function', 'file')" | ||
| ).action(async (term, options) => { | ||
| ).option("-p, --page <number>", "Page number for pagination", "1").option("-l, --limit <number>", "Number of results per page", "20").action(async (term, options) => { | ||
| const spinner = ora({ | ||
@@ -1625,7 +1477,17 @@ text: chalk4.gray(`Searching graph for: ${term}...`), | ||
| try { | ||
| const { searchGraph } = await import("./query-ALVJVDRK.js"); | ||
| const results = await searchGraph(process.cwd(), term, options.type); | ||
| const { searchGraph } = await import("./query-FGSLUNHR.js"); | ||
| const results = await searchGraph( | ||
| process.cwd(), | ||
| term, | ||
| options.type, | ||
| Number(options.page), | ||
| Number(options.limit) | ||
| ); | ||
| spinner.stop(); | ||
| if (results.length === 0) { | ||
| console.error(chalk4.yellow(`No nodes found matching '${term}'`)); | ||
| if (results.data.length === 0) { | ||
| console.error( | ||
| chalk4.yellow( | ||
| `No nodes found matching '${term}' on page ${options.page}` | ||
| ) | ||
| ); | ||
| } else { | ||
@@ -1636,3 +1498,3 @@ console.log(JSON.stringify(results, null, 2)); | ||
| ` | ||
| Found ${results.length} nodes. Use 'geraph query <id>' to inspect a specific node.` | ||
| Found ${results.meta.total} nodes across ${results.meta.totalPages} pages. Displaying page ${results.meta.page}.` | ||
| ) | ||
@@ -1644,3 +1506,3 @@ ); | ||
| console.error( | ||
| chalk4.red("?? Search failed:"), | ||
| chalk4.red("\u274C Search failed:"), | ||
| error instanceof Error ? error.message : String(error) | ||
@@ -1651,5 +1513,3 @@ ); | ||
| }); | ||
| program.command("query <symbol>").description( | ||
| "Query the knowledge graph for a specific symbol's relationships" | ||
| ).option( | ||
| program.command("node <symbol>").description("Fetch detailed metadata for a specific node").option( | ||
| "-t, --type <type>", | ||
@@ -1662,3 +1522,3 @@ "Filter results by node type (e.g., 'interface', 'class', 'function', 'file')" | ||
| const spinner = ora({ | ||
| text: chalk4.gray(`Querying relationships for: ${symbol}...`), | ||
| text: chalk4.gray(`Querying node: ${symbol}...`), | ||
| color: "blue", | ||
@@ -1668,4 +1528,4 @@ spinner: "dots" | ||
| try { | ||
| const { queryGraph } = await import("./query-ALVJVDRK.js"); | ||
| const result = await queryGraph( | ||
| const { getNode } = await import("./query-FGSLUNHR.js"); | ||
| const result = await getNode( | ||
| process.cwd(), | ||
@@ -1687,4 +1547,201 @@ symbol, | ||
| }); | ||
| program.command("neighbors <symbol>").description("Fetch all incoming and outgoing edges for a specific node").option( | ||
| "-t, --type <type>", | ||
| "Filter results by node type (e.g., 'interface', 'class', 'function', 'file')" | ||
| ).option( | ||
| "-s, --source <path>", | ||
| "Filter results by source file path (e.g., 'auth.ts')" | ||
| ).option("-p, --page <number>", "Page number for pagination", "1").option("-l, --limit <number>", "Number of edges per page", "20").action(async (symbol, options) => { | ||
| const spinner = ora({ | ||
| text: chalk4.gray(`Querying neighbors for: ${symbol}...`), | ||
| color: "blue", | ||
| spinner: "dots" | ||
| }).start(); | ||
| try { | ||
| const { getNeighbors } = await import("./query-FGSLUNHR.js"); | ||
| const result = await getNeighbors( | ||
| process.cwd(), | ||
| symbol, | ||
| options.type, | ||
| options.source, | ||
| Number(options.page), | ||
| Number(options.limit) | ||
| ); | ||
| spinner.stop(); | ||
| console.log(JSON.stringify(result, null, 2)); | ||
| } catch (error) { | ||
| spinner.fail( | ||
| chalk4.red( | ||
| `Query failed: ${error instanceof Error ? error.message : String(error)}` | ||
| ) | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| program.command("path <source> <target>").description( | ||
| "Find the shortest path between two nodes using fuzzy symbol/ID lookup" | ||
| ).option("-m, --max-hops <number>", "Maximum hops to consider", "8").action(async (source, target, options) => { | ||
| const spinner = ora({ | ||
| text: chalk4.gray(`Finding shortest path...`), | ||
| color: "blue", | ||
| spinner: "dots" | ||
| }).start(); | ||
| try { | ||
| const { shortestPath } = await import("./query-FGSLUNHR.js"); | ||
| const maxHops = Number(options.maxHops || 8); | ||
| const result = await shortestPath(process.cwd(), source, target, maxHops); | ||
| spinner.stop(); | ||
| console.log(result); | ||
| } catch (error) { | ||
| spinner.fail( | ||
| chalk4.red( | ||
| `Path failed: ${error instanceof Error ? error.message : String(error)}` | ||
| ) | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| program.command("god").description( | ||
| "Return the most connected nodes \u2014 the core architectural pillars of the codebase" | ||
| ).option("-p, --page <number>", "Page number for pagination", "1").option("-l, --limit <number>", "Number of results per page", "10").action(async (options) => { | ||
| const spinner = ora({ | ||
| text: chalk4.gray(`Fetching core nodes...`), | ||
| color: "blue", | ||
| spinner: "dots" | ||
| }).start(); | ||
| try { | ||
| const { getGodNodes } = await import("./query-FGSLUNHR.js"); | ||
| const result = await getGodNodes( | ||
| process.cwd(), | ||
| Number(options.page), | ||
| Number(options.limit) | ||
| ); | ||
| spinner.stop(); | ||
| console.log(result); | ||
| } catch (error) { | ||
| spinner.fail( | ||
| chalk4.red( | ||
| `Failed to fetch god nodes: ${error instanceof Error ? error.message : String(error)}` | ||
| ) | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| program.command("community <id>").description("Get all nodes in a community by community ID").option("-p, --page <number>", "Page number for pagination", "1").option("-l, --limit <number>", "Number of results per page", "20").action(async (id, options) => { | ||
| const spinner = ora({ | ||
| text: chalk4.gray(`Fetching nodes in community ${id}...`), | ||
| color: "blue", | ||
| spinner: "dots" | ||
| }).start(); | ||
| try { | ||
| const { getCommunityNodes } = await import("./query-FGSLUNHR.js"); | ||
| const result = await getCommunityNodes( | ||
| process.cwd(), | ||
| Number(id), | ||
| Number(options.page), | ||
| Number(options.limit) | ||
| ); | ||
| spinner.stop(); | ||
| console.log(result); | ||
| } catch (error) { | ||
| spinner.fail( | ||
| chalk4.red( | ||
| `Failed to fetch community: ${error instanceof Error ? error.message : String(error)}` | ||
| ) | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| program.command("surprises").description( | ||
| "Discover surprising cross-community couplings that link otherwise independent modules" | ||
| ).option("-p, --page <number>", "Page number for pagination", "1").option("-l, --limit <number>", "Number of results per page", "20").action(async (options) => { | ||
| const spinner = ora({ | ||
| text: chalk4.gray(`Querying surprising connections...`), | ||
| color: "blue", | ||
| spinner: "dots" | ||
| }).start(); | ||
| try { | ||
| const { getSurprisingConnections } = await import("./query-FGSLUNHR.js"); | ||
| const result = await getSurprisingConnections( | ||
| process.cwd(), | ||
| Number(options.page), | ||
| Number(options.limit) | ||
| ); | ||
| spinner.stop(); | ||
| console.log(result); | ||
| } catch (error) { | ||
| spinner.fail( | ||
| chalk4.red( | ||
| `Failed to fetch surprises: ${error instanceof Error ? error.message : String(error)}` | ||
| ) | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| program.command("query <symbol-or-question>").description( | ||
| "Search the AST graph using BFS or DFS traversal with keyword or natural language support, returning a compact context representation" | ||
| ).option("-m, --mode <mode>", "Traversal mode (bfs or dfs)", "bfs").option("-d, --depth <number>", "Traversal depth limit", "3").option("-b, --budget <number>", "Estimated output token limit", "2000").action(async (symbolOrQuestion, options) => { | ||
| const spinner = ora({ | ||
| text: chalk4.gray(`Traversing AST graph from ${symbolOrQuestion}...`), | ||
| color: "blue", | ||
| spinner: "dots" | ||
| }).start(); | ||
| try { | ||
| const { queryGraph } = await import("./query-FGSLUNHR.js"); | ||
| const result = await queryGraph( | ||
| process.cwd(), | ||
| symbolOrQuestion, | ||
| options.mode, | ||
| Number(options.depth), | ||
| Number(options.budget) | ||
| ); | ||
| spinner.stop(); | ||
| console.log(result); | ||
| } catch (error) { | ||
| spinner.fail( | ||
| chalk4.red( | ||
| `Query failed: ${error instanceof Error ? error.message : String(error)}` | ||
| ) | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| program.command("stats").description( | ||
| "Return summary statistics of the graph: node count, edge count, community count, and extraction confidence percentage breakdown" | ||
| ).action(async () => { | ||
| const spinner = ora({ | ||
| text: chalk4.gray(`Querying graph statistics...`), | ||
| color: "blue", | ||
| spinner: "dots" | ||
| }).start(); | ||
| try { | ||
| const { getGraphStats } = await import("./query-FGSLUNHR.js"); | ||
| const result = await getGraphStats(process.cwd()); | ||
| spinner.stop(); | ||
| console.log(result); | ||
| } catch (error) { | ||
| spinner.fail( | ||
| chalk4.red( | ||
| `Failed to fetch stats: ${error instanceof Error ? error.message : String(error)}` | ||
| ) | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| program.command("mcp [dir]").description( | ||
| "Starts the JSON-RPC Model Context Protocol (MCP) server over stdio" | ||
| ).action(async (dir) => { | ||
| try { | ||
| const { loadGraph } = await import("./query-FGSLUNHR.js"); | ||
| const targetDir = dir ? path7.resolve(process.cwd(), dir) : process.cwd(); | ||
| const graph = loadGraph(targetDir); | ||
| const { runMcpServer } = await import("./mcp-HCUAGMFP.js"); | ||
| await runMcpServer(graph, targetDir); | ||
| } catch (err) { | ||
| console.error(err instanceof Error ? err.message : String(err)); | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| // src/index.ts | ||
| program.parse(process.argv); |
+218
-84
| --- | ||
| name: geraph | ||
| description: "Structural Memory for AI Agents. Navigate the codebase via AST graph." | ||
| description: "Structural Memory for AI Agents. Navigate and query the codebase via AST-based semantic knowledge graph." | ||
| trigger: /geraph | ||
@@ -9,110 +9,244 @@ --- | ||
| Geraph is a structural memory engine that tracks dependencies, function calls, imports, and historical context (Git commits) across the codebase. It builds a graph mapping all relationships without executing any code. | ||
| Geraph is a structural memory engine that tracks codebase abstractions, function calls, module imports, and historical context (Git commits). It constructs a semantic knowledge graph mapping all dependencies without executing any code. | ||
| ## What You Must Do When Invoked | ||
| As an AI agent, you MUST follow this operational manual to navigate the codebase using Geraph. | ||
| When you need to understand the architecture, find where a component is, or trace dependencies, follow this strict protocol: | ||
| --- | ||
| ### Step 1: Read the Global Architecture Report | ||
| Before answering architecture or codebase questions, **always read `.geraph/GRAPH_REPORT.md` first**. It contains project stats, core architectural pillars ("God Nodes"), and community clustering. | ||
| ## 1. Primary Directives & Workflow | ||
| ### Step 2: Use `search` for Fuzzy Discovery | ||
| If you don't know the exact ID or exact name of a symbol, you MUST use `search` first. | ||
| ```bash | ||
| geraph search '<term>' [--type <type>] | ||
| ``` | ||
| - **Inputs**: Use a broad concept, a partial filename, or a partial function name. For example: `search 'auth'`, `search 'database'`, `search 'User'`. | ||
| - **Options**: You can filter by `--type file` or `--type function` if you know what you are looking for. | ||
| - **Output**: Returns a lightweight list of matching Node IDs, sorted by significance. | ||
| When you need to understand the architecture, locate code definitions, trace dependencies, or perform impact analysis, execute these steps: | ||
| ### Step 3: Use `query` for Deep Inspection | ||
| Once you have found the exact Node ID or exact Symbol Name from Step 2 (or if the user explicitly provided one), use `query` to fetch its full dependencies. | ||
| ```bash | ||
| geraph query '<symbol_or_id>' [--type <type>] [--source <file>] | ||
| ``` | ||
| - **Inputs**: The exact Node ID (e.g., `src/auth/session.ts::ValidateToken`) or the exact symbol name (e.g., `ValidateToken`). If you are exploring a specific file, you can query its filepath directly (e.g., `geraph query 'src/app.ts' --type file`). | ||
| - **Options**: | ||
| - `--type <type>` (e.g., `file`, `function`, `class`, `interface`, `intent`). Use this to resolve naming conflicts (e.g., if there is a function and an interface both named "User"). | ||
| - `--source <file>` (e.g., `src/auth/session.ts`). Use this if you only know the symbol name, to ensure Geraph finds the definition in the correct file. | ||
| - **Why use options?**: Always use `--type` and `--source` if you know them. They strictly reduce token bloat and guarantee you get the exact node you want. | ||
| ### Step 0: MCP First, CLI as Fallback | ||
| * **MCP Server (Recommended):** If the Geraph MCP server is active, always prioritize Geraph MCP tools and resources over terminal commands. | ||
| * **CLI Wrapper:** If MCP is not available, execute the equivalent terminal command: `geraph <command>`. | ||
| * **Global Install Rule:** If running any Geraph CLI command returns a 'command not found' error, you MUST first install it globally using: | ||
| ```bash | ||
| npm install -g geraph | ||
| ``` | ||
| * **Standardized Terminal Bug Fallback:** If the Geraph CLI commands continuously fail to run (e.g. CLI crashes, execution environments fail — **not** "symbol not found" errors): | ||
| 1. Immediately check if Geraph MCP is configured. If it is, switch entirely to MCP tools. | ||
| 2. If Geraph MCP is **not** configured, immediately stop your work and ask the user to set up the MCP server by following the official documentation at: [https://github.com/rupam2232/geraph#mcp-server-recommended](https://github.com/rupam2232/geraph#mcp-server-recommended) | ||
| 3. **CRITICAL:** Do NOT fallback to default general search tools like `grep`, `rg`, or `find`. Halt execution until MCP is set up. | ||
| ### Step 4: Trace the Context ("The Why") | ||
| When you run `query`, you will see `incoming` and `outgoing` edges. | ||
| - **Impact Analysis**: Use `incoming` edges to see who depends on this node. | ||
| - **Dependencies**: Use `outgoing` edges to see what this node uses. | ||
| - **Historical Context ("The Why")**: If you see an `intent` type node connected to your target, **query that `intent` node**. Look at its `metadata.message`. This is a Git commit message that explains *why* the code exists or was changed. | ||
| ### Step 1: Read the Global Architecture Report / Load Resources | ||
| Before answering architecture or codebase questions, **always load the Geraph Report first**. It provides a compact structural overview, including the top files, communities, god nodes, surprising couplings, and temporal commit history. | ||
| * **Via MCP:** Read the `geraph://report` resource. | ||
| * **Via CLI:** Read the `.geraph/GRAPH_REPORT.md` file directly. | ||
| ### Step 5: Keep the Graph Current | ||
| Geraph must stay in sync with the codebase. | ||
| - You MUST run `geraph scan` after you modify any code files. | ||
| - You MUST run `geraph scan` after committing a change to the repository. | ||
| ### Critical Rules | ||
| > [!IMPORTANT] | ||
| > **NEVER Read Raw Graph Files:** You MUST never attempt to view or read the massive `.geraph/graph.json` or `.geraph/graph.html` files directly. Doing so will instantly overflow your context window, bloat token consumption, and freeze or crash your session. Use Geraph's tools or resources to inspect the graph. | ||
| > [!IMPORTANT] | ||
| > **NEVER Read Cache Files:** You MUST never try to read any files inside the `.geraph/cache` directory (such as `git-cache.json`). These files store massive raw metadata indexes (e.g. Git blame history) that will instantly blow up your context window, waste millions of tokens, and crash your session. | ||
| > [!IMPORTANT] | ||
| > **Scan on Modification:** Immediately after making any code changes, creating/deleting files, or committing code, you MUST run the `scan_graph` MCP tool or the `geraph scan` CLI command to rebuild the AST graph and sync Geraph's memory with the active state of the code. | ||
| --- | ||
| ## CRITICAL RULES | ||
| ## 2. Fuzzy Search & Node ID Resolution Mechanics | ||
| 1. **NEVER read `.geraph/graph.json` directly.** It is massive and will overflow your context window. Always use the CLI. | ||
| 2. **NO EXTRA FLAGS**: There are NO extra or hidden options for `search` or `query`. DO NOT hallucinate flags like `--limit` or `--dfs`. Only use `--type` and `--source`. | ||
| 3. **COMMAND NOT FOUND**: If the terminal returns a 'command not found' error, you MUST install it globally using `npm install -g geraph` before proceeding. | ||
| 4. **TERMINAL OUTPUT BUG**: If your terminal fails to capture stdout (e.g., it hangs or returns "No output"), DO NOT GIVE UP. Redirect the output to a file: `geraph query 'Symbol' > .geraph/out.json`, then read that file. | ||
| 5. **NEVER use `grep`, `rg`, or `find`** for architecture questions when Geraph is available. | ||
| To choose the correct search terms and resolve nodes successfully, you must understand how Geraph names nodes and resolves fuzzy inputs: | ||
| ### A. Deterministic Node ID Format | ||
| Every node in Geraph has a unique, deterministic ID generated as follows: | ||
| * **For a File Node:** The normalized absolute or relative workspace path to the file. | ||
| - *Example:* `services/billing/invoicing.ts` (or `E:\coding\project\services\billing\invoicing.ts`) | ||
| * **For a Symbol/Entity Node (class, function, interface, etc.):** The containing file path followed by `::` and the symbol name. | ||
| - *Format:* `{containing_file_path}::{symbolName}` | ||
| - *Example:* `services/billing/invoicing.ts::InvoiceProcessor` | ||
| ### B. Fuzzy Resolution Protocol | ||
| When you pass a search string to `shortest_path` (or `geraph path`), `get_node` (or `geraph node`), `get_neighbors` (or `geraph neighbors`), or `query_graph` (or `geraph query`), Geraph resolves it using a multi-tiered fuzzy search: | ||
| 1. **Exact ID Match:** Matches the exact qualified ID first (e.g. `services/billing/invoicing.ts::InvoiceProcessor`). | ||
| 2. **Exact Symbol Attribute Match:** Matches the raw name attribute exactly (e.g. matching `InvoiceProcessor` to `attr.name === "InvoiceProcessor"`). | ||
| 3. **Suffix Matching:** Resolves inputs by checking if the Node ID ends with `/{symbol}` (for files) or `::{symbol}` (for members), e.g. querying `invoicing.ts::InvoiceProcessor` or `invoicing.ts/InvoiceProcessor` will resolve successfully. | ||
| 4. **Case-Insensitive Match:** Repeats all matching tiers case-insensitively (e.g. matching `invoiceprocessor` to `InvoiceProcessor`). | ||
| 5. **Optional Filters:** In all matching phases, Geraph optionally filters results by AST node `type` (e.g., `function`) and/or containing `source` file path segment if those parameters are explicitly provided. | ||
| ### C. Geraph MCP Resource Pointers | ||
| If MCP is active, you can load these read-only URIs directly as resources to quickly get high-level overviews without running tools: | ||
| * `geraph://report` : The complete `.geraph/GRAPH_REPORT.md` file. | ||
| * `geraph://stats` : General stats (node/edge/community counts and confidence breakdown). | ||
| * `geraph://god-nodes` : Top 10 most-connected core abstractions. | ||
| * `geraph://surprises` : Top 10 surprising cross-community couplings. | ||
| * `geraph://audit` : Extraction confidence audit counts. | ||
| --- | ||
| ## JSON Response Interpretation | ||
| ## 3. Navigating with Pagination | ||
| **`search` output**: Returns an array of matching node objects. | ||
| - `id`: The unique node identifier. Use this exact string for subsequent `query` calls. | ||
| - `name`: The human-readable name. | ||
| - `type`: The node type (see Glossary below). | ||
| - `file`: The source file. | ||
| - `links`: Total connections. Higher means more architecturally significant. | ||
| To avoid context-window bloat, Geraph enforces strict pagination on all list-based queries. Every paginated tool/command accepts **two optional parameters**: | ||
| * `page`: The page number to retrieve (Default: `1`). | ||
| * `limit`: The number of items to show per page (Default: `20` for neighbors/searches, `10` for god nodes). | ||
| **`query` output**: Returns a detailed object with `target`, `incoming`, and `outgoing`: | ||
| - `target`: The queried node's full details: | ||
| - `id`, `name`, `type`, `file`, `line`: Identity and location. | ||
| - `metadata.doc`: Contains extracted JSDoc/comments. Read this to understand the purpose and intent of the symbol. | ||
| - `metadata.deprecated`: Boolean flag. If `true`, this symbol is marked `@deprecated`. | ||
| - `metadata.message`: (*Only on `intent` type nodes*) The Git commit message explaining why this node was created or changed. | ||
| - `metadata.author`, `metadata.date`: (*Only on `intent` type nodes*) Commit author and timestamp. | ||
| - `links.incoming` / `links.outgoing`: Count of connections in each direction. | ||
| - `incoming`: Array of edges pointing **to** this node. Each entry has `source` (the neighbor node), `relation` (edge type), and `confidence`. Use this for **Impact Analysis** — these are the entities that depend on and will break if you change the target. | ||
| - `outgoing`: Array of edges pointing **out** from this node. Each entry has `target` (the neighbor node), `relation`, and `confidence`. Use this to see what the node **depends on** — what it calls, imports, or references. | ||
| ### Understanding the Pagination Payload | ||
| When a query returns, inspect the metadata to determine if you need to fetch more pages: | ||
| ### Query Resolution Priority | ||
| When you `query` a symbol name (e.g., `geraph query 'userState'`), Geraph resolves it in this strict order: | ||
| 1. **Exact ID Match**: Perfect match on the unique Node ID. | ||
| 2. **Case-Sensitive Match**: Matches the exact capitalization (finds the variable `userState` but ignores the interface `UserState`). | ||
| 3. **Case-Insensitive Fallback**: If no exact case match exists, it returns the case-insensitive match (returns the interface `UserState`). | ||
| #### For JSON Responses (e.g., `search_graph` or `get_neighbors` MCP/CLI): | ||
| Look at the `meta` block: | ||
| ```json | ||
| "meta": { | ||
| "page": 1, | ||
| "limit": 20, | ||
| "total": 97, | ||
| "totalPages": 5 | ||
| } | ||
| ``` | ||
| * **`page`:** The current page you are viewing. | ||
| * **`totalPages`:** The total pages available. If `page < totalPages`, you must make subsequent queries with `page: page + 1` to read the remaining data. | ||
| #### For Formatted Text Responses (e.g. `god_nodes`, `get_community`, `get_surprises`): | ||
| Inspect the bracketed line at the very bottom: | ||
| `[Page 1 of 5 | Total: 97 nodes]` | ||
| * If the page count is greater than 1, you must increment the `page` argument in your next tool call to view the next chunk of nodes. | ||
| --- | ||
| ## Geraph Glossary | ||
| ## 4. Tool & CLI Command Reference | ||
| Use this glossary to understand the types of nodes and edges in the graph, and to accurately choose your `--type` flag for queries. | ||
| All query parameters are optional and have robust defaults. If you omit an argument, the server resolves it automatically. | ||
| ### Node Types | ||
| | Type | Description | | ||
| |---|---| | ||
| | `file` | A source code file. | | ||
| | `function` | A standard function, method, or arrow function. | | ||
| | `class` | A class definition. | | ||
| | `interface`/`type`/`enum`| TypeScript type definitions. | | ||
| | `intent` | A Git commit explaining why a node exists. | | ||
| ### A. Core Discovery & Abstractions | ||
| ### Edge Types (`relation`) | ||
| | Relation | Description | | ||
| |---|---| | ||
| | `imports` | File A depends on File B. | | ||
| | `calls` | Function A executes Function B. | | ||
| | `defines` | A file contains a function/class. | | ||
| | `references` | A function uses a specific type or interface. | | ||
| | `explains` | A Git commit (`intent` node) provides historical context for a specific code node. | | ||
| #### 1. Fuzzy Search | ||
| Search for nodes by partial symbol name or file path. | ||
| * **MCP Tool:** `search_graph` | ||
| - *Parameters:* `name` (Required), `type` (Optional), `page` (Optional), `limit` (Optional) | ||
| * **CLI Command:** `geraph search <term>` | ||
| - *Syntax:* `geraph search <term> [--type <type>] [--page <number>] [--limit <number>]` | ||
| - *Example:* `geraph search Invoice --type class --page 1 --limit 10` | ||
| * **Returned Fields & Meaning:** | ||
| - `id`: The unique absolute node ID (use this for subsequent deep inspection calls). | ||
| - `name`: Raw symbol name. | ||
| - `type`: Node category (e.g., `class`, `function`). | ||
| - `file`: Containing file path. | ||
| - `links`: Connection count (degree). High degree = high architectural importance. | ||
| #### 2. God Nodes | ||
| Find the most connected real nodes in the codebase (excluding noise). | ||
| * **MCP Tool:** `god_nodes` | ||
| - *Parameters:* `page` (Optional), `limit` (Optional, Default: 10) | ||
| * **CLI Command:** `geraph god` | ||
| - *Syntax:* `geraph god [--page <number>] [--limit <number>]` | ||
| * **Output Format:** | ||
| ` {index}. {symbol_name} [id: {node_id}] - {degree} edges` | ||
| #### 3. Community Nodes | ||
| Fetch all nodes clustered within a specific Louvain community ID. | ||
| * **MCP Tool:** `get_community` | ||
| - *Parameters:* `community_id` (Required), `page` (Optional), `limit` (Optional, Default: 20) | ||
| * **CLI Command:** `geraph community <id>` | ||
| - *Syntax:* `geraph community <id> [--page <number>] [--limit <number>]` | ||
| * **Output Format:** | ||
| ` {symbol_name} [{containing_file_path}]` | ||
| #### 4. Surprising Connections | ||
| Fetch surprising cross-community couplings that link independent subsystems. | ||
| * **MCP Tool:** `get_surprises` | ||
| - *Parameters:* `page` (Optional), `limit` (Optional, Default: 20) | ||
| * **CLI Command:** `geraph surprises` | ||
| - *Syntax:* `geraph surprises [--page <number>] [--limit <number>]` | ||
| * **Output Format:** | ||
| ` {source_name} <-> {target_name} [{edge_type}] - {explanation}` | ||
| --- | ||
| ### B. Deep Inspection & Traversal | ||
| #### 5. Get Node Detail | ||
| Fetch metadata for a single specific symbol or file path. | ||
| * **MCP Tool:** `get_node` | ||
| - *Parameters:* `symbol` (Required), `type` (Optional), `source` (Optional) | ||
| * **CLI Command:** `geraph node <symbol>` | ||
| - *Syntax:* `geraph node <symbol> [--type <type>] [--source <path>]` | ||
| - *Example:* `geraph node InvoiceProcessor --type class --source invoicing.ts` | ||
| * **Returned Fields & Meaning:** | ||
| - `id`: Unique absolute identifier. | ||
| - `name`: Symbol name. | ||
| - `type`: Node category. | ||
| - `file`: Containing file path. | ||
| - `line`: Starting line number in the source file. | ||
| - `links.incoming` / `links.outgoing`: Direct dependency counts. | ||
| - `metadata.doc`: Extracted JSDoc/comments (contains design rationale). | ||
| - `metadata.community`: Louvain community ID (cluster subsystem). | ||
| #### 6. Get Neighbors | ||
| Trace all incoming and outgoing dependencies of a symbol. | ||
| * **MCP Tool:** `get_neighbors` | ||
| - *Parameters:* `symbol` (Required), `type` (Optional), `source` (Optional), `page` (Optional), `limit` (Optional, Default: 20) | ||
| * **CLI Command:** `geraph neighbors <symbol>` | ||
| - *Syntax:* `geraph neighbors <symbol> [--type <type>] [--source <path>] [--page <number>] [--limit <number>]` | ||
| * **Returned Fields & Meaning:** | ||
| - `incoming[]`: Symbols that call, import, or extend this target. Use this for **Impact Analysis** (what will break if you modify this node). | ||
| - `outgoing[]`: Symbols this target calls, imports, or references. Use this to trace **dependency requirements**. | ||
| - `relation`: Nature of connection (e.g. `calls`, `imports`, `defines`). | ||
| - `confidence`: Confidence levels (`EXTRACTED` = 100% AST certain; `INFERRED` = high structural heuristic probability; `AMBIGUOUS` = requires review). | ||
| #### 7. Shortest Path | ||
| Find the shortest chain of code relationships linking two nodes. | ||
| * **MCP Tool:** `shortest_path` | ||
| - *Parameters:* `source` (Required), `target` (Required), `max_hops` (Optional, Default: 8) | ||
| * **CLI Command:** `geraph path <source> <target>` | ||
| - *Syntax:* `geraph path <source> <target> [--max-hops <number>]` | ||
| - *Example:* `geraph path InvoiceProcessor DatabaseClient --max-hops 5` | ||
| * **Output Format:** Shows hops and directions between nodes: | ||
| `Shortest path (H hops): SourceSymbol --imports--> Middleware <--calls-- DestinationSymbol` | ||
| #### 8. Compact Graph Traversal | ||
| Runs a localized BFS/DFS crawl fanning out from the most relevant seed nodes to return a compact context map. It supports fuzzy symbol names, lists of keywords, or full natural language questions. | ||
| * **MCP Tool:** `query_graph` | ||
| - *Parameters:* `symbol` or `question` (Required), `mode` (Optional, Default: 'bfs'), `depth` (Optional, Default: 3), `token_budget` (Optional, Default: 2000) | ||
| * **CLI Command:** `geraph query <symbol-or-question>` | ||
| - *Syntax:* `geraph query <symbol-or-question> [--mode <bfs|dfs>] [--depth <number>] [--budget <number>]` | ||
| - *Example:* `geraph query "how does InvoiceProcessor write to the database" --depth 2 --budget 1500` | ||
| * **Output Format:** Compact text map listing traversed nodes (`NODE`) and edges (`EDGE`): | ||
| `NODE server.js [src=packages/services/server.js loc=0 community=2]` | ||
| `EDGE server.js --imports--> invoicing.py` | ||
| #### 9. Graph Statistics | ||
| Get summary statistics of the graph (node/edge/community counts and confidence breakdown). | ||
| * **MCP Tool:** `graph_stats` | ||
| - *Parameters:* None | ||
| * **CLI Command:** `geraph stats` | ||
| - *Syntax:* `geraph stats` | ||
| * **Output Format:** | ||
| `Nodes: 256` | ||
| `Edges: 798` | ||
| `Communities: 9` | ||
| `EXTRACTED: 80%` | ||
| `INFERRED: 15%` | ||
| `AMBIGUOUS: 5%` | ||
| #### 10. Rebuild Graph | ||
| Triggers a full scan of the directory to rebuild the knowledge graph. | ||
| * **MCP Tool:** `scan_graph` | ||
| - *Parameters:* None | ||
| * **CLI Command:** `geraph scan` | ||
| - *Syntax:* `geraph scan` | ||
| --- | ||
| ## 5. Geraph Glossary | ||
| ### AST Node Types | ||
| * `file`: A source code file. | ||
| * `function`: A function, method, or arrow function definition. | ||
| * `class`: A class definition. | ||
| * `interface` / `type` / `enum`: TypeScript definition declarations. | ||
| * `intent`: A Git commit explaining why a node exists (query its `metadata.message` for history). | ||
| ### AST Edge Types (`relation`) | ||
| * `imports`: File A depends on File B. | ||
| * `calls`: Function/Method A invokes Function/Method B. | ||
| * `defines`: File A contains or defines symbol B. | ||
| * `references`: A symbol uses a type, interface, or variable. | ||
| * `explains`: A Git commit (`intent` node) provides historical context for a specific code node. | ||
| ### Confidence Scores | ||
| Every edge has a `confidence` level: | ||
| | Confidence | Description | | ||
| |---|---| | ||
| | `EXTRACTED` | 100% deterministic. Found directly by the AST parser (e.g., an explicit function call). | | ||
| | `INFERRED` | High probability. Deduced via structural heuristics or indirect relationships. | | ||
| | `AMBIGUOUS` | Uncertain connection. Requires human/agent verification. | | ||
| * `EXTRACTED`: 100% deterministic AST parser extraction (e.g. direct function call or explicit import statement). | ||
| * `INFERRED`: High probability structural heuristic coupling. | ||
| * `AMBIGUOUS`: Uncertain connection that needs manual verification. |
+11
-1
| { | ||
| "name": "geraph", | ||
| "version": "0.3.0", | ||
| "version": "0.4.0", | ||
| "description": "Structural memory for AI agents. Build semantic knowledge graphs from your codebase for surgical code modifications.", | ||
@@ -24,2 +24,10 @@ "main": "dist/index.js", | ||
| "cli", | ||
| "agentic-coding", | ||
| "tree-sitter", | ||
| "skills", | ||
| "cursor", | ||
| "claude", | ||
| "ai-agents", | ||
| "semantic-knowledge-graph", | ||
| "structural-memory", | ||
| "agentic-coding" | ||
@@ -52,2 +60,3 @@ ], | ||
| "dependencies": { | ||
| "@modelcontextprotocol/sdk": "^1.29.0", | ||
| "chalk": "^4.1.2", | ||
@@ -59,2 +68,3 @@ "commander": "^4.1.1", | ||
| "graphology-metrics": "^2.4.0", | ||
| "graphology-shortest-path": "^2.1.0", | ||
| "ignore": "^5.3.2", | ||
@@ -61,0 +71,0 @@ "ora": "^9.4.0", |
+49
-0
@@ -32,4 +32,53 @@ # Geraph | ||
| ## MCP Server (Recommended) | ||
| Geraph features a fully local Model Context Protocol (MCP) server that operates completely over `stdio`. **Using the MCP server is highly recommended** over running terminal CLI commands for LLMs. | ||
| **For a project-level local setup:** | ||
| Add the following configuration to your MCP-compatible client (e.g. Cursor or Antigravity IDE): | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "geraph": { | ||
| "command": "geraph", | ||
| "args": ["mcp"] | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| **For a global setup:** | ||
| If you configure the MCP server globally for your IDE, you must tell the server where your project is located. You can do this by setting the `cwd` field to your project path. If your IDE/platform doesn't support the `cwd` field, you can pass the project path as an argument instead: | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "geraph": { | ||
| "command": "geraph", | ||
| "args": [ | ||
| "mcp" | ||
| ], | ||
| "cwd": "<path-to-your-project>" | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| If cwd is not supported: | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "geraph": { | ||
| "command": "geraph", | ||
| "args": [ | ||
| "mcp", | ||
| "<path-to-your-project>" | ||
| ] | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| ## Detailed Documentation | ||
| For a full guide on workflows, agent integration, and advanced features, visit the **[Main Project Documentation](https://github.com/rupam2232/geraph)**. |
| // src/core/query.ts | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| import { MultiDirectedGraph } from "graphology"; | ||
| 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. Run 'geraph scan' first."); | ||
| } | ||
| const rawData = JSON.parse(fs.readFileSync(graphPath, "utf-8")); | ||
| const graph = new MultiDirectedGraph(); | ||
| const nodes = rawData.nodes || []; | ||
| const edges = rawData.edges || []; | ||
| nodes.forEach((n) => { | ||
| const nid = normalizeId(n.id); | ||
| if (!graph.hasNode(nid)) { | ||
| const { id, name, type, file, startLine, ...metadata } = n; | ||
| graph.addNode(nid, { | ||
| name: name || "", | ||
| type, | ||
| file: normalizeId(file || ""), | ||
| startLine: startLine || 0, | ||
| metadata: metadata || {} | ||
| }); | ||
| } | ||
| }); | ||
| edges.forEach((e) => { | ||
| const source = normalizeId(e.source); | ||
| const target = normalizeId(e.target); | ||
| 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(targetDir, term, targetType) { | ||
| const graph = loadGraph(targetDir); | ||
| 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) | ||
| }); | ||
| } | ||
| }); | ||
| return results.sort((a, b) => b.links - a.links); | ||
| } | ||
| async function queryGraph(targetDir, symbol, targetType, targetSource) { | ||
| const graph = loadGraph(targetDir); | ||
| 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.`); | ||
| } | ||
| const targetAttr = graph.getNodeAttributes(targetNodeId); | ||
| const result = { | ||
| target: { | ||
| 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) | ||
| } | ||
| }, | ||
| incoming: [], | ||
| outgoing: [] | ||
| }; | ||
| 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); | ||
| return result; | ||
| } | ||
| export { | ||
| queryGraph, | ||
| searchGraph | ||
| }; |
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
155873
56.61%12
50%3964
52.23%84
140%14
16.67%19
5.56%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added