| import { | ||
| createKnowledgeGraph | ||
| } from "./chunk-5V42YYJT.js"; | ||
| // 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; | ||
| }; | ||
| const deterministicGraph = createKnowledgeGraph(); | ||
| const sortedNodeIds = [...graph.nodes()].sort(); | ||
| sortedNodeIds.forEach((nodeId) => { | ||
| deterministicGraph.addNode(nodeId, graph.getNodeAttributes(nodeId)); | ||
| }); | ||
| const sortedEdges = graph.edges().map((edgeId) => { | ||
| return { | ||
| id: edgeId, | ||
| source: graph.source(edgeId), | ||
| target: graph.target(edgeId), | ||
| attributes: graph.getEdgeAttributes(edgeId) | ||
| }; | ||
| }); | ||
| sortedEdges.sort((a, b) => { | ||
| const compareSource = a.source.localeCompare(b.source); | ||
| if (compareSource !== 0) return compareSource; | ||
| const compareTarget = a.target.localeCompare(b.target); | ||
| if (compareTarget !== 0) return compareTarget; | ||
| return a.id.localeCompare(b.id); | ||
| }); | ||
| sortedEdges.forEach((edge) => { | ||
| if (!deterministicGraph.hasEdge(edge.source, edge.target)) { | ||
| deterministicGraph.addEdge(edge.source, edge.target, edge.attributes); | ||
| } | ||
| }); | ||
| try { | ||
| communityMap = louvainAlgorithm(deterministicGraph, { 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 { | ||
| analyzeGraph, | ||
| detectCommunities, | ||
| findGodNodes, | ||
| findSurprisingConnections | ||
| }; |
| // src/core/ast.ts | ||
| import { availableParallelism } from "os"; | ||
| import path2 from "path"; | ||
| import fs2 from "fs"; | ||
| import { fileURLToPath } from "url"; | ||
| import { Worker } from "worker_threads"; | ||
| import pc from "picocolors"; | ||
| // src/core/alias.ts | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| function parseTsConfig(filePath, visited = /* @__PURE__ */ new Set()) { | ||
| if (visited.has(filePath)) return {}; | ||
| visited.add(filePath); | ||
| if (!fs.existsSync(filePath)) return {}; | ||
| try { | ||
| const content = fs.readFileSync(filePath, "utf-8"); | ||
| const stripped = content.replace( | ||
| /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, | ||
| (m, g) => g ? "" : m | ||
| ); | ||
| const cleanJson = stripped.replace(/,\s*([\]}])/g, "$1"); | ||
| const json = JSON.parse(cleanJson); | ||
| let base = {}; | ||
| if (json.extends) { | ||
| const extendsPaths = Array.isArray(json.extends) ? json.extends : [json.extends]; | ||
| for (const extPath of extendsPaths) { | ||
| let resolvedExtPath = path.resolve(path.dirname(filePath), extPath); | ||
| if (!fs.existsSync(resolvedExtPath) && !resolvedExtPath.endsWith(".json")) { | ||
| resolvedExtPath += ".json"; | ||
| } | ||
| const extConfig = parseTsConfig(resolvedExtPath, visited); | ||
| base = mergeConfigs(base, extConfig); | ||
| } | ||
| } | ||
| return mergeConfigs(base, json); | ||
| } catch { | ||
| return {}; | ||
| } | ||
| } | ||
| function mergeConfigs(base, child) { | ||
| return { | ||
| ...base, | ||
| ...child, | ||
| compilerOptions: { | ||
| ...base.compilerOptions || {}, | ||
| ...child.compilerOptions || {} | ||
| } | ||
| }; | ||
| } | ||
| function buildAliasMap(files) { | ||
| const map = {}; | ||
| const configFiles = files.filter( | ||
| (f) => f.endsWith("tsconfig.json") || f.endsWith("jsconfig.json") | ||
| ); | ||
| for (const file of configFiles) { | ||
| const config = parseTsConfig(file); | ||
| const compilerOptions = config.compilerOptions; | ||
| if (compilerOptions && compilerOptions.paths) { | ||
| const baseUrl = compilerOptions.baseUrl || "."; | ||
| const dir = path.dirname(file); | ||
| const aliases = []; | ||
| for (const [key, targets] of Object.entries(compilerOptions.paths)) { | ||
| const prefix = key.replace(/\*$/, ""); | ||
| const resolvedTargets = targets.map( | ||
| (t) => path.join(dir, baseUrl, t.replace(/\*$/, "")) | ||
| ); | ||
| aliases.push({ prefix, targets: resolvedTargets }); | ||
| } | ||
| aliases.sort((a, b) => b.prefix.length - a.prefix.length); | ||
| if (aliases.length > 0) { | ||
| map[dir] = aliases; | ||
| } | ||
| } | ||
| } | ||
| return map; | ||
| } | ||
| // src/core/ast.ts | ||
| var STAT_INDEX_VERSION = "2"; | ||
| async function extractAst(files, graph, targetDir, spinner, force = false) { | ||
| const aliasMap = buildAliasMap(files); | ||
| const astCacheDir = path2.join(targetDir, ".geraph", "cache", "ast"); | ||
| if (!fs2.existsSync(astCacheDir)) | ||
| fs2.mkdirSync(astCacheDir, { recursive: true }); | ||
| const statIndexFile = path2.join(targetDir, ".geraph", "cache", "stat-index.json"); | ||
| let statIndex = {}; | ||
| let statIndexDirty = false; | ||
| if (!force && fs2.existsSync(statIndexFile)) { | ||
| try { | ||
| const parsed = JSON.parse(fs2.readFileSync(statIndexFile, "utf-8")); | ||
| if (parsed.__version__ === STAT_INDEX_VERSION) { | ||
| statIndex = parsed; | ||
| } else { | ||
| statIndex = { __version__: STAT_INDEX_VERSION }; | ||
| statIndexDirty = true; | ||
| } | ||
| } catch { | ||
| statIndex = { __version__: STAT_INDEX_VERSION }; | ||
| statIndexDirty = true; | ||
| } | ||
| } else { | ||
| statIndex = { __version__: STAT_INDEX_VERSION }; | ||
| statIndexDirty = true; | ||
| } | ||
| let cachedCount = 0; | ||
| const resultsMap = /* @__PURE__ */ new Map(); | ||
| const queue = []; | ||
| for (const file of files) { | ||
| try { | ||
| const stat = fs2.statSync(file); | ||
| const entry = statIndex[file]; | ||
| if (!force && entry && typeof entry === "object" && entry.size === stat.size && entry.mtimeMs === stat.mtimeMs) { | ||
| const cachePath = path2.join(astCacheDir, `${entry.hash}.json`); | ||
| if (fs2.existsSync(cachePath)) { | ||
| queue.push({ | ||
| file, | ||
| action: "load-cache", | ||
| cachePath | ||
| }); | ||
| cachedCount++; | ||
| continue; | ||
| } | ||
| } | ||
| queue.push({ | ||
| file, | ||
| action: "parse", | ||
| expectedStat: { size: stat.size, mtimeMs: stat.mtimeMs } | ||
| }); | ||
| } catch { | ||
| queue.push({ | ||
| file, | ||
| action: "parse" | ||
| }); | ||
| } | ||
| } | ||
| if (cachedCount === files.length) { | ||
| spinner.stop(); | ||
| console.log( | ||
| pc.green(`\u26A1 Fully cached! No files needed re-parsing.`) | ||
| ); | ||
| spinner.start(); | ||
| } else { | ||
| spinner.text = pc.gray( | ||
| `Parsing AST for ${files.length - cachedCount} files (${cachedCount} cached)...` | ||
| ); | ||
| } | ||
| if (queue.length > 0) { | ||
| const numWorkers = Math.min(availableParallelism(), queue.length, 4); | ||
| const workerPath = path2.join( | ||
| path2.dirname(fileURLToPath(import.meta.url)), | ||
| "core", | ||
| "worker.js" | ||
| ); | ||
| let completedCount = 0; | ||
| const workers = Array.from( | ||
| { length: numWorkers }, | ||
| () => new Worker(workerPath) | ||
| ); | ||
| await Promise.all( | ||
| workers.map(async (worker) => { | ||
| while (queue.length > 0) { | ||
| const item = queue.shift(); | ||
| if (!item) break; | ||
| const { file, action, cachePath, expectedStat } = item; | ||
| await new Promise((resolve) => { | ||
| const onMessage = (msg) => { | ||
| if (msg.error) { | ||
| console.error( | ||
| pc.yellow( | ||
| ` | ||
| \u26A0\u3000Worker error for ${file}: ${msg.error}` | ||
| ) | ||
| ); | ||
| } else { | ||
| resultsMap.set(file, { nodes: msg.nodes, edges: msg.edges }); | ||
| if (action === "parse" && msg.hash && expectedStat) { | ||
| statIndex[file] = { | ||
| size: expectedStat.size, | ||
| mtimeMs: expectedStat.mtimeMs, | ||
| hash: msg.hash | ||
| }; | ||
| statIndexDirty = true; | ||
| } | ||
| } | ||
| completedCount++; | ||
| spinner.text = pc.gray( | ||
| `Parsing AST: ${completedCount}/${files.length} files...` | ||
| ); | ||
| worker.off("message", onMessage); | ||
| worker.off("error", onError); | ||
| resolve(); | ||
| }; | ||
| const onError = (err) => { | ||
| console.error( | ||
| pc.red(`Worker error on ${file}: ${err.message}`) | ||
| ); | ||
| completedCount++; | ||
| worker.off("message", onMessage); | ||
| worker.off("error", onError); | ||
| resolve(); | ||
| }; | ||
| worker.on("message", onMessage); | ||
| worker.on("error", onError); | ||
| worker.postMessage({ | ||
| filePath: file, | ||
| projectRoot: targetDir, | ||
| aliasMap, | ||
| action, | ||
| cachePath | ||
| }); | ||
| }); | ||
| } | ||
| await worker.terminate(); | ||
| }) | ||
| ); | ||
| } | ||
| if (statIndexDirty) { | ||
| try { | ||
| const tempPath = path2.join(targetDir, ".geraph", "cache", `stat-index.${Date.now()}.${Math.random().toString(36).substring(2)}.tmp`); | ||
| fs2.writeFileSync(tempPath, JSON.stringify(statIndex, null, 2), "utf-8"); | ||
| try { | ||
| fs2.renameSync(tempPath, statIndexFile); | ||
| } catch { | ||
| try { | ||
| fs2.copyFileSync(tempPath, statIndexFile); | ||
| fs2.unlinkSync(tempPath); | ||
| } catch { | ||
| } | ||
| } | ||
| } catch { | ||
| } | ||
| } | ||
| for (const file of files) { | ||
| const res = resultsMap.get(file); | ||
| if (!res) continue; | ||
| res.nodes?.forEach((n) => { | ||
| if (!graph.hasNode(n.id)) { | ||
| graph.addNode(n.id, n.attr); | ||
| } else if (n.attr.type !== "file") { | ||
| graph.mergeNodeAttributes(n.id, n.attr); | ||
| } | ||
| }); | ||
| res.edges?.forEach((e) => { | ||
| if (e.source === e.target) return; | ||
| if (!graph.hasEdge(e.source, e.target)) { | ||
| graph.addEdge(e.source, e.target, e.attr); | ||
| } | ||
| }); | ||
| } | ||
| } | ||
| export { | ||
| extractAst | ||
| }; |
| // src/core/graph.ts | ||
| import { MultiDirectedGraph } from "graphology"; | ||
| import path from "path"; | ||
| function createKnowledgeGraph() { | ||
| const graph = new MultiDirectedGraph(); | ||
| const g = graph; | ||
| const originalAddEdge = graph.addEdge.bind(graph); | ||
| g.addEdge = (source, target, attributes) => { | ||
| if (source === target) return ""; | ||
| return originalAddEdge(source, target, attributes); | ||
| }; | ||
| const originalAddEdgeWithKey = graph.addEdgeWithKey.bind(graph); | ||
| g.addEdgeWithKey = (key, source, target, attributes) => { | ||
| if (source === target) return ""; | ||
| return originalAddEdgeWithKey(key, source, target, attributes); | ||
| }; | ||
| const originalMergeEdge = graph.mergeEdge.bind(graph); | ||
| g.mergeEdge = (source, target, attributes) => { | ||
| if (source === target) return ["", false]; | ||
| return originalMergeEdge(source, target, attributes); | ||
| }; | ||
| const originalMergeEdgeWithKey = graph.mergeEdgeWithKey.bind(graph); | ||
| g.mergeEdgeWithKey = (key, source, target, attributes) => { | ||
| if (source === target) return ["", false]; | ||
| return originalMergeEdgeWithKey(key, source, target, attributes); | ||
| }; | ||
| return graph; | ||
| } | ||
| function resolveCallGraph(graph) { | ||
| const realDefs = /* @__PURE__ */ new Map(); | ||
| for (const nodeId of graph.nodes()) { | ||
| const data = graph.getNodeAttributes(nodeId); | ||
| if (!nodeId.startsWith("unresolved::") && (data.type === "function" || data.type === "class" || data.type === "type" || data.type === "interface" || data.type === "enum" || data.type === "struct" || data.type === "trait" || data.type === "macro")) { | ||
| const name = data.name; | ||
| if (!realDefs.has(name)) { | ||
| realDefs.set(name, []); | ||
| } | ||
| realDefs.get(name).push(nodeId); | ||
| } | ||
| } | ||
| const ghosts = graph.nodes().filter((n) => n.startsWith("unresolved::")); | ||
| for (const ghostId of ghosts) { | ||
| const ghostData = graph.getNodeAttributes(ghostId); | ||
| let name = ghostData.name; | ||
| if (name.includes(".")) { | ||
| name = name.split(".").pop() || name; | ||
| } | ||
| if (name.includes("::")) { | ||
| name = name.split("::").pop() || name; | ||
| } | ||
| const candidates = realDefs.get(name); | ||
| if (candidates && candidates.length > 0) { | ||
| let realId = candidates[0]; | ||
| let bestScore = -1; | ||
| for (const candidateId of candidates) { | ||
| const candidateData = graph.getNodeAttributes(candidateId); | ||
| let score = 0; | ||
| if (path.dirname(candidateData.file) === path.dirname(ghostData.file)) { | ||
| score += 10; | ||
| } | ||
| if (path.extname(candidateData.file) === path.extname(ghostData.file)) { | ||
| score += 5; | ||
| } | ||
| if (score > bestScore) { | ||
| bestScore = score; | ||
| realId = candidateId; | ||
| } | ||
| } | ||
| for (const edgeId of graph.inEdges(ghostId)) { | ||
| const src = graph.source(edgeId); | ||
| const edgeData = graph.getEdgeAttributes(edgeId); | ||
| if (src === realId) continue; | ||
| if (!graph.hasEdge(src, realId)) { | ||
| graph.addEdge(src, realId, edgeData); | ||
| } | ||
| } | ||
| graph.dropNode(ghostId); | ||
| } | ||
| } | ||
| } | ||
| export { | ||
| createKnowledgeGraph, | ||
| resolveCallGraph | ||
| }; |
| // src/parsers/cpp.ts | ||
| import Parser from "web-tree-sitter"; | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| var CPP_STDLIB_HEADERS = /* @__PURE__ */ new Set([ | ||
| // C++ standard library headers (angle-bracket includes) | ||
| "algorithm", | ||
| "any", | ||
| "array", | ||
| "atomic", | ||
| "barrier", | ||
| "bit", | ||
| "bitset", | ||
| "cassert", | ||
| "cctype", | ||
| "cerrno", | ||
| "cfenv", | ||
| "cfloat", | ||
| "charconv", | ||
| "chrono", | ||
| "cinttypes", | ||
| "climits", | ||
| "clocale", | ||
| "cmath", | ||
| "codecvt", | ||
| "compare", | ||
| "complex", | ||
| "concepts", | ||
| "condition_variable", | ||
| "coroutine", | ||
| "csetjmp", | ||
| "csignal", | ||
| "cstdarg", | ||
| "cstddef", | ||
| "cstdint", | ||
| "cstdio", | ||
| "cstdlib", | ||
| "cstring", | ||
| "ctime", | ||
| "cuchar", | ||
| "cwchar", | ||
| "cwctype", | ||
| "deque", | ||
| "exception", | ||
| "execution", | ||
| "expected", | ||
| "filesystem", | ||
| "format", | ||
| "forward_list", | ||
| "fstream", | ||
| "functional", | ||
| "future", | ||
| "generator", | ||
| "initializer_list", | ||
| "iomanip", | ||
| "ios", | ||
| "iosfwd", | ||
| "iostream", | ||
| "istream", | ||
| "iterator", | ||
| "latch", | ||
| "limits", | ||
| "list", | ||
| "locale", | ||
| "map", | ||
| "mdspan", | ||
| "memory", | ||
| "memory_resource", | ||
| "mutex", | ||
| "new", | ||
| "numbers", | ||
| "numeric", | ||
| "optional", | ||
| "ostream", | ||
| "print", | ||
| "queue", | ||
| "random", | ||
| "ranges", | ||
| "ratio", | ||
| "regex", | ||
| "scoped_allocator", | ||
| "semaphore", | ||
| "set", | ||
| "shared_mutex", | ||
| "source_location", | ||
| "span", | ||
| "spanstream", | ||
| "sstream", | ||
| "stack", | ||
| "stacktrace", | ||
| "stdexcept", | ||
| "stdfloat", | ||
| "stop_token", | ||
| "streambuf", | ||
| "string", | ||
| "string_view", | ||
| "strstream", | ||
| "syncstream", | ||
| "system_error", | ||
| "thread", | ||
| "tuple", | ||
| "type_traits", | ||
| "typeindex", | ||
| "typeinfo", | ||
| "unordered_map", | ||
| "unordered_set", | ||
| "utility", | ||
| "valarray", | ||
| "variant", | ||
| "vector", | ||
| "version", | ||
| // C standard library headers | ||
| "assert.h", | ||
| "complex.h", | ||
| "ctype.h", | ||
| "errno.h", | ||
| "fenv.h", | ||
| "float.h", | ||
| "inttypes.h", | ||
| "iso646.h", | ||
| "limits.h", | ||
| "locale.h", | ||
| "math.h", | ||
| "setjmp.h", | ||
| "signal.h", | ||
| "stdalign.h", | ||
| "stdarg.h", | ||
| "stdatomic.h", | ||
| "stdbool.h", | ||
| "stddef.h", | ||
| "stdint.h", | ||
| "stdio.h", | ||
| "stdlib.h", | ||
| "stdnoreturn.h", | ||
| "string.h", | ||
| "tgmath.h", | ||
| "threads.h", | ||
| "time.h", | ||
| "uchar.h", | ||
| "wchar.h", | ||
| "wctype.h", | ||
| // POSIX headers | ||
| "unistd.h", | ||
| "fcntl.h", | ||
| "sys/types.h", | ||
| "sys/stat.h", | ||
| "sys/socket.h", | ||
| "netinet/in.h", | ||
| "arpa/inet.h", | ||
| "pthread.h", | ||
| "dirent.h", | ||
| "dlfcn.h" | ||
| ]); | ||
| function isStdlibInclude(includePath) { | ||
| if (includePath.startsWith("<") && includePath.endsWith(">")) return true; | ||
| const cleanPath = includePath.replace(/^[<"]|[>"]$/g, ""); | ||
| return CPP_STDLIB_HEADERS.has(cleanPath); | ||
| } | ||
| var CPP_BUILT_INS = /* @__PURE__ */ new Set([ | ||
| // Primitive types | ||
| "int", | ||
| "float", | ||
| "double", | ||
| "char", | ||
| "bool", | ||
| "void", | ||
| "size_t", | ||
| "nullptr", | ||
| "true", | ||
| "false", | ||
| "this", | ||
| "auto", | ||
| "const", | ||
| "static", | ||
| "extern", | ||
| "volatile", | ||
| "mutable", | ||
| "register", | ||
| "inline", | ||
| "virtual", | ||
| // Standard library containers & smart pointers (class names) | ||
| "vector", | ||
| "string", | ||
| "map", | ||
| "set", | ||
| "list", | ||
| "unordered_map", | ||
| "unordered_set", | ||
| "shared_ptr", | ||
| "unique_ptr", | ||
| "make_shared", | ||
| "make_unique", | ||
| "weak_ptr", | ||
| "array", | ||
| "deque", | ||
| "stack", | ||
| "queue", | ||
| "priority_queue", | ||
| "pair", | ||
| "tuple", | ||
| "optional", | ||
| "variant", | ||
| "any", | ||
| "span", | ||
| "string_view", | ||
| "bitset", | ||
| "forward_list", | ||
| "multimap", | ||
| "multiset", | ||
| // I/O & utility (always skip) | ||
| "cout", | ||
| "cin", | ||
| "cerr", | ||
| "clog", | ||
| "endl", | ||
| "printf", | ||
| "scanf", | ||
| "std", | ||
| "main", | ||
| "getline", | ||
| "ignore", | ||
| "peek", | ||
| "putback", | ||
| "get", | ||
| "put", | ||
| // Common methods that should never be standalone graph nodes | ||
| "size", | ||
| "length", | ||
| "empty", | ||
| "push_back", | ||
| "pop_back", | ||
| "push_front", | ||
| "pop_front", | ||
| "begin", | ||
| "end", | ||
| "cbegin", | ||
| "cend", | ||
| "rbegin", | ||
| "rend", | ||
| "front", | ||
| "back", | ||
| "at", | ||
| "data", | ||
| "find", | ||
| "count", | ||
| "contains", | ||
| "erase", | ||
| "insert", | ||
| "emplace", | ||
| "emplace_back", | ||
| "sort", | ||
| "min", | ||
| "max", | ||
| "abs", | ||
| "swap", | ||
| "move", | ||
| "forward", | ||
| "to_string", | ||
| "stoi", | ||
| "stod", | ||
| "stof", | ||
| "stol", | ||
| "stoll", | ||
| "stoul", | ||
| "stoull", | ||
| "reserve", | ||
| "resize", | ||
| "capacity", | ||
| "shrink_to_fit", | ||
| "clear", | ||
| "assign", | ||
| "substr", | ||
| "c_str", | ||
| "append", | ||
| "compare", | ||
| "replace", | ||
| "rfind", | ||
| "find_first_of", | ||
| "find_last_of", | ||
| "open", | ||
| "close", | ||
| "read", | ||
| "write", | ||
| "flush", | ||
| "good", | ||
| "eof", | ||
| "fail", | ||
| "bad", | ||
| "lock", | ||
| "unlock", | ||
| "try_lock", | ||
| "join", | ||
| "detach", | ||
| "reset", | ||
| "release", | ||
| "use_count", | ||
| "expired", | ||
| // Casts and keywords | ||
| "sizeof", | ||
| "alignof", | ||
| "decltype", | ||
| "typeid", | ||
| "static_cast", | ||
| "dynamic_cast", | ||
| "const_cast", | ||
| "reinterpret_cast", | ||
| "static_assert", | ||
| "throw", | ||
| "new", | ||
| "delete" | ||
| ]); | ||
| function findNearestCppDescriptor(dir) { | ||
| let current = dir; | ||
| while (true) { | ||
| for (const name of ["CMakeLists.txt", "Makefile", "configure.ac", "meson.build"]) { | ||
| const candidate = path.join(current, name); | ||
| if (fs.existsSync(candidate)) return candidate; | ||
| } | ||
| const parent = path.dirname(current); | ||
| if (parent === current) break; | ||
| current = parent; | ||
| } | ||
| return ""; | ||
| } | ||
| function resolveCppInclude(includeText, sourceFilePath) { | ||
| const cleanPath = includeText.replace(/^[<"]|[>"]$/g, ""); | ||
| const sourceDir = path.dirname(sourceFilePath); | ||
| const relativePath = path.resolve(sourceDir, cleanPath); | ||
| if (fs.existsSync(relativePath)) return relativePath; | ||
| const workspaceRoot = process.cwd(); | ||
| const candidates = [ | ||
| path.resolve(workspaceRoot, cleanPath), | ||
| path.resolve(workspaceRoot, "include", cleanPath), | ||
| path.resolve(workspaceRoot, "src", cleanPath) | ||
| ]; | ||
| for (const candidate of candidates) { | ||
| if (fs.existsSync(candidate)) return candidate; | ||
| } | ||
| return null; | ||
| } | ||
| function extractCppComment(node) { | ||
| const comments = []; | ||
| let prev = node.previousSibling; | ||
| while (prev && (prev.type === "template_parameter_list" || prev.type === "storage_class_specifier")) { | ||
| prev = prev.previousSibling; | ||
| } | ||
| while (prev && (prev.type === "comment" || prev.type === "line_comment")) { | ||
| comments.unshift(prev.text.replace(/^\/\/|^\/\*|\*\/$/g, "").trim()); | ||
| prev = prev.previousSibling; | ||
| } | ||
| return comments.length > 0 ? comments.join("\n") : void 0; | ||
| } | ||
| function isLocalDeclaration(startNode, name) { | ||
| let current = startNode; | ||
| const checkPattern = (patternNode) => { | ||
| if (patternNode.type === "identifier" || patternNode.type === "field_identifier") { | ||
| return patternNode.text.trim() === name; | ||
| } | ||
| for (let i = 0; i < patternNode.namedChildCount; i++) { | ||
| const child = patternNode.namedChild(i); | ||
| if (child && checkPattern(child)) return true; | ||
| } | ||
| return false; | ||
| }; | ||
| while (current) { | ||
| if (current.type === "function_definition" || current.type === "lambda_expression") { | ||
| const findParams = (n) => { | ||
| if (n.type === "parameter_list") { | ||
| return checkPattern(n); | ||
| } | ||
| for (let i = 0; i < n.namedChildCount; i++) { | ||
| const child = n.namedChild(i); | ||
| if (child && findParams(child)) return true; | ||
| } | ||
| return false; | ||
| }; | ||
| if (findParams(current)) return true; | ||
| } | ||
| if (current.type === "compound_statement") { | ||
| for (let i = 0; i < current.namedChildCount; i++) { | ||
| const statement = current.namedChild(i); | ||
| if (!statement) continue; | ||
| if (statement.type === "declaration") { | ||
| const findVars = (n) => { | ||
| if (n.type === "init_declarator") { | ||
| const declarator = n.childForFieldName("declarator") || n.namedChild(0); | ||
| if (declarator && checkPattern(declarator)) return true; | ||
| } else if (n.type === "identifier" || n.type === "field_identifier") { | ||
| if (n.text.trim() === name) return true; | ||
| } | ||
| for (let j = 0; j < n.namedChildCount; j++) { | ||
| const child = n.namedChild(j); | ||
| if (child && findVars(child)) return true; | ||
| } | ||
| return false; | ||
| }; | ||
| if (findVars(statement)) { | ||
| if (statement.startIndex <= (startNode?.startIndex ?? 0)) return true; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| current = current.parent; | ||
| } | ||
| return false; | ||
| } | ||
| function readSourceFile(filePath) { | ||
| const buffer = fs.readFileSync(filePath); | ||
| if (buffer.length >= 2 && buffer[0] === 255 && buffer[1] === 254) { | ||
| return buffer.toString("utf16le").replace(/^\uFEFF/, ""); | ||
| } | ||
| if (buffer.length >= 2 && buffer[0] === 254 && buffer[1] === 255) { | ||
| const swapped = Buffer.alloc(buffer.length); | ||
| for (let i = 0; i < buffer.length - 1; i += 2) { | ||
| swapped[i] = buffer[i + 1]; | ||
| swapped[i + 1] = buffer[i]; | ||
| } | ||
| return swapped.toString("utf16le").replace(/^\uFEFF/, ""); | ||
| } | ||
| let content = buffer.toString("utf8"); | ||
| if (content.startsWith("\uFEFF")) { | ||
| content = content.slice(1); | ||
| } | ||
| return content.replace(/\0/g, ""); | ||
| } | ||
| function parseCpp(filePath, graph, language) { | ||
| const parser = new Parser(); | ||
| try { | ||
| parser.setLanguage(language); | ||
| } catch { | ||
| return; | ||
| } | ||
| let sourceCode; | ||
| try { | ||
| sourceCode = readSourceFile(filePath); | ||
| } catch { | ||
| return; | ||
| } | ||
| const tree = parser.parse(sourceCode); | ||
| if (!tree) return; | ||
| const localDefinitions = /* @__PURE__ */ new Set(); | ||
| const localMethodMap = /* @__PURE__ */ new Map(); | ||
| const queryString = ` | ||
| (preproc_include path: (_) @include_path) | ||
| (class_specifier name: (type_identifier) @class_decl) | ||
| (struct_specifier name: (type_identifier) @struct_decl) | ||
| (function_declarator declarator: (identifier) @func_decl) | ||
| (function_declarator declarator: (field_identifier) @func_decl) | ||
| (function_declarator declarator: (qualified_identifier) @qualified_func_decl) | ||
| (call_expression function: (identifier) @call_name) | ||
| (call_expression function: (field_expression field: (field_identifier) @method_call)) | ||
| (type_identifier) @type_reference | ||
| `; | ||
| const query = language.query(queryString); | ||
| const matches = query.matches(tree.rootNode); | ||
| const getEnclosingScopePath = (startNode) => { | ||
| const pathParts = []; | ||
| let current = startNode; | ||
| while (current) { | ||
| if (current.type === "class_specifier" || current.type === "struct_specifier" || current.type === "namespace_definition") { | ||
| const nameNode = current.childForFieldName("name"); | ||
| if (nameNode) { | ||
| pathParts.unshift(nameNode.text.trim()); | ||
| } | ||
| } else if (current.type === "function_definition") { | ||
| const decl = current.childForFieldName("declarator"); | ||
| if (decl) { | ||
| const nameNode = decl.childForFieldName("declarator"); | ||
| if (nameNode) { | ||
| pathParts.unshift(nameNode.text.trim()); | ||
| } | ||
| } | ||
| } | ||
| current = current.parent; | ||
| } | ||
| return pathParts.join("."); | ||
| }; | ||
| for (const match of matches) { | ||
| for (const capture of match.captures) { | ||
| const captureName = capture.name; | ||
| if (captureName === "class_decl" || captureName === "struct_decl" || captureName === "func_decl") { | ||
| const symName = capture.node.text.trim(); | ||
| if (symName) { | ||
| localDefinitions.add(symName); | ||
| const scopePrefix = getEnclosingScopePath(capture.node.parent?.parent?.parent); | ||
| const symId = scopePrefix ? `${filePath}::${scopePrefix}.${symName}` : `${filePath}::${symName}`; | ||
| if (captureName === "func_decl") { | ||
| if (!localMethodMap.has(symName)) { | ||
| localMethodMap.set(symName, []); | ||
| } | ||
| localMethodMap.get(symName).push(symId); | ||
| } | ||
| } | ||
| } else if (captureName === "qualified_func_decl") { | ||
| const fullName = capture.node.text.trim(); | ||
| const parts = fullName.split("::"); | ||
| const methodName = parts.pop() || fullName; | ||
| const className = parts.join("::"); | ||
| if (methodName) localDefinitions.add(methodName); | ||
| if (className) localDefinitions.add(className); | ||
| const scopePrefix = className ? className + "." : ""; | ||
| const symId = `${filePath}::${scopePrefix}${methodName}`; | ||
| if (!localMethodMap.has(methodName)) { | ||
| localMethodMap.set(methodName, []); | ||
| } | ||
| localMethodMap.get(methodName).push(symId); | ||
| } | ||
| } | ||
| } | ||
| for (const match of matches) { | ||
| for (const capture of match.captures) { | ||
| const node = capture.node; | ||
| const captureName = capture.name; | ||
| if (captureName === "class_decl" || captureName === "struct_decl" || captureName === "func_decl") { | ||
| const symName = node.text.trim(); | ||
| if (!symName) continue; | ||
| const scopePrefix = getEnclosingScopePath(node.parent?.parent?.parent); | ||
| const symId = scopePrefix ? `${filePath}::${scopePrefix}.${symName}` : `${filePath}::${symName}`; | ||
| const parentId = scopePrefix ? `${filePath}::${scopePrefix}` : filePath; | ||
| let nodeType = "function"; | ||
| if (captureName === "class_decl") nodeType = "class"; | ||
| else if (captureName === "struct_decl") nodeType = "struct"; | ||
| let decl = node.parent || node; | ||
| if (decl.type === "function_declarator" && decl.parent && decl.parent.type === "function_definition") { | ||
| decl = decl.parent; | ||
| } | ||
| const doc = extractCppComment(decl); | ||
| const nodeAttrs = { | ||
| type: nodeType, | ||
| name: symName, | ||
| file: filePath, | ||
| startLine: decl.startPosition.row + 1, | ||
| metadata: { | ||
| endLine: decl.endPosition.row + 1, | ||
| doc, | ||
| external: false, | ||
| unresolved: false | ||
| } | ||
| }; | ||
| if (!graph.hasNode(symId)) { | ||
| graph.addNode(symId, nodeAttrs); | ||
| graph.addEdge(parentId, symId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } else { | ||
| graph.mergeNodeAttributes(symId, nodeAttrs); | ||
| if (!graph.hasEdge(parentId, symId)) { | ||
| graph.addEdge(parentId, symId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } | ||
| } | ||
| } else if (captureName === "qualified_func_decl") { | ||
| const fullName = node.text.trim(); | ||
| if (!fullName) continue; | ||
| const parts = fullName.split("::"); | ||
| const methodName = parts.pop() || fullName; | ||
| const className = parts.join("::"); | ||
| if (!methodName) continue; | ||
| const scopePrefix = className ? className + "." : ""; | ||
| const symId = `${filePath}::${scopePrefix}${methodName}`; | ||
| const parentId = filePath; | ||
| let decl = node.parent || node; | ||
| if (decl.type === "function_declarator" && decl.parent && decl.parent.type === "function_definition") { | ||
| decl = decl.parent; | ||
| } | ||
| const doc = extractCppComment(decl); | ||
| const nodeAttrs = { | ||
| type: "function", | ||
| name: methodName, | ||
| file: filePath, | ||
| startLine: decl.startPosition.row + 1, | ||
| metadata: { | ||
| endLine: decl.endPosition.row + 1, | ||
| doc, | ||
| external: false, | ||
| unresolved: false | ||
| } | ||
| }; | ||
| if (!graph.hasNode(symId)) { | ||
| graph.addNode(symId, nodeAttrs); | ||
| graph.addEdge(parentId, symId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } else { | ||
| graph.mergeNodeAttributes(symId, nodeAttrs); | ||
| if (!graph.hasEdge(parentId, symId)) { | ||
| graph.addEdge(parentId, symId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } | ||
| } | ||
| } else if (captureName === "include_path") { | ||
| const includeText = node.text.trim(); | ||
| if (includeText === "" || includeText === '""' || includeText === "<>") continue; | ||
| const resolvedPath = resolveCppInclude(includeText, filePath); | ||
| if (!resolvedPath && isStdlibInclude(includeText)) continue; | ||
| const cleanPath = includeText.replace(/^[<"]|[>"]$/g, ""); | ||
| const importNodeId = resolvedPath || `import::${cleanPath}`; | ||
| if (importNodeId === "import::" || importNodeId.trim() === "") continue; | ||
| if (!graph.hasNode(importNodeId)) { | ||
| const nearestDescriptor = findNearestCppDescriptor(path.dirname(filePath)); | ||
| graph.addNode(importNodeId, { | ||
| type: "file", | ||
| name: resolvedPath ? path.basename(resolvedPath) : cleanPath, | ||
| file: resolvedPath || nearestDescriptor || filePath, | ||
| startLine: 0, | ||
| metadata: resolvedPath ? {} : { external: true } | ||
| }); | ||
| } | ||
| if (!graph.hasEdge(filePath, importNodeId)) { | ||
| graph.addEdge(filePath, importNodeId, { type: "imports", confidence: "EXTRACTED" }); | ||
| } | ||
| } else if (captureName === "call_name") { | ||
| const calledName = node.text.trim(); | ||
| if (!calledName || CPP_BUILT_INS.has(calledName)) continue; | ||
| if (isLocalDeclaration(node, calledName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| const targets = []; | ||
| if (localDefinitions.has(calledName)) { | ||
| const localMatches = localMethodMap.get(calledName) || []; | ||
| if (localMatches.length === 1 && localMatches[0]) { | ||
| targets.push({ | ||
| id: localMatches[0], | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (localMatches.length > 1) { | ||
| const callerScope2 = getEnclosingScopePath(node.parent); | ||
| let bestMatch = localMatches[0]; | ||
| let maxCommon = -1; | ||
| for (const matchId of localMatches) { | ||
| const matchScope = matchId.split("::")[1] || ""; | ||
| let common = 0; | ||
| const matchParts = matchScope.split("."); | ||
| const callerParts = callerScope2.split("."); | ||
| for (let i = 0; i < Math.min(matchParts.length, callerParts.length); i++) { | ||
| if (matchParts[i] === callerParts[i]) common++; | ||
| else break; | ||
| } | ||
| if (common > maxCommon) { | ||
| maxCommon = common; | ||
| bestMatch = matchId; | ||
| } | ||
| } | ||
| targets.push({ | ||
| id: bestMatch, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else { | ||
| const scopePrefix = getEnclosingScopePath(node.parent?.parent?.parent); | ||
| targets.push({ | ||
| id: scopePrefix ? `${filePath}::${scopePrefix}.${calledName}` : `${filePath}::${calledName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } | ||
| } else { | ||
| targets.push({ | ||
| id: `unresolved::${calledName}`, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| for (const target of targets) { | ||
| const isUnresolved = target.id.startsWith("unresolved::"); | ||
| const isExternal = target.id.startsWith("import::") || !isUnresolved && !target.id.startsWith(filePath); | ||
| const callLine = node.startPosition.row + 1; | ||
| if (!graph.hasNode(target.id)) { | ||
| let fileAttr = filePath; | ||
| if (isUnresolved) { | ||
| fileAttr = filePath; | ||
| } else if (target.id.includes("::")) { | ||
| fileAttr = target.id.split("::")[0] || filePath; | ||
| } | ||
| graph.addNode(target.id, { | ||
| type: "function", | ||
| name: calledName, | ||
| file: fileAttr, | ||
| startLine: isExternal ? 0 : callLine, | ||
| metadata: { | ||
| external: isExternal, | ||
| unresolved: isUnresolved, | ||
| endLine: isExternal ? 0 : callLine, | ||
| callerFile: isUnresolved ? filePath : void 0, | ||
| callerLine: isUnresolved ? callLine : void 0 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === target.id) continue; | ||
| if (!graph.hasEdge(callerId, target.id)) { | ||
| graph.addEdge(callerId, target.id, { type: "calls", confidence: target.confidence }); | ||
| } | ||
| } | ||
| } else if (captureName === "method_call") { | ||
| const methodName = node.text.trim(); | ||
| if (!methodName || CPP_BUILT_INS.has(methodName)) continue; | ||
| let objectName = ""; | ||
| if (node.parent && node.parent.type === "field_expression") { | ||
| const objectNode = node.parent.childForFieldName("object"); | ||
| if (objectNode) objectName = objectNode.text.trim(); | ||
| } | ||
| if (isLocalDeclaration(node, objectName ? `${objectName}.${methodName}` : methodName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| const targets = []; | ||
| const localMatches = localMethodMap.get(methodName) || []; | ||
| if (localMatches.length === 1 && localMatches[0]) { | ||
| targets.push({ | ||
| id: localMatches[0], | ||
| confidence: "INFERRED" | ||
| }); | ||
| } else if (localMatches.length > 1) { | ||
| for (const matchId of localMatches) { | ||
| targets.push({ | ||
| id: matchId, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } else { | ||
| targets.push({ | ||
| id: `unresolved::${methodName}`, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| for (const target of targets) { | ||
| const isUnresolved = target.id.startsWith("unresolved::"); | ||
| const isExternal = target.id.startsWith("import::") || !isUnresolved && !target.id.startsWith(filePath); | ||
| const callLine = node.startPosition.row + 1; | ||
| if (!graph.hasNode(target.id)) { | ||
| let fileAttr = filePath; | ||
| if (isUnresolved) { | ||
| fileAttr = filePath; | ||
| } else if (target.id.includes("::")) { | ||
| fileAttr = target.id.split("::")[0] || filePath; | ||
| } | ||
| graph.addNode(target.id, { | ||
| type: "function", | ||
| name: methodName, | ||
| file: fileAttr, | ||
| startLine: isExternal ? 0 : callLine, | ||
| metadata: { | ||
| external: isExternal, | ||
| unresolved: isUnresolved, | ||
| endLine: isExternal ? 0 : callLine, | ||
| callerFile: isUnresolved ? filePath : void 0, | ||
| callerLine: isUnresolved ? callLine : void 0 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === target.id) continue; | ||
| if (!graph.hasEdge(callerId, target.id)) { | ||
| graph.addEdge(callerId, target.id, { type: "calls", confidence: target.confidence }); | ||
| } | ||
| } | ||
| } else if (captureName === "type_reference") { | ||
| if (node.parent && ["class_specifier", "struct_specifier", "base_class_specifier"].includes(node.parent.type)) { | ||
| continue; | ||
| } | ||
| const referencedTypeName = node.text.trim(); | ||
| if (!referencedTypeName || CPP_BUILT_INS.has(referencedTypeName)) continue; | ||
| if (isLocalDeclaration(node, referencedTypeName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| if (!graph.hasNode(callerId)) continue; | ||
| let targetId; | ||
| if (localDefinitions.has(referencedTypeName)) { | ||
| targetId = `${filePath}::${referencedTypeName}`; | ||
| } else { | ||
| targetId = `unresolved::${referencedTypeName}`; | ||
| } | ||
| const isUnresolved = !localDefinitions.has(referencedTypeName); | ||
| if (!graph.hasNode(targetId)) { | ||
| graph.addNode(targetId, { | ||
| type: "class", | ||
| name: referencedTypeName, | ||
| file: filePath, | ||
| startLine: node.startPosition.row + 1, | ||
| metadata: { | ||
| external: false, | ||
| unresolved: isUnresolved, | ||
| endLine: node.startPosition.row + 1, | ||
| callerFile: filePath, | ||
| callerLine: node.startPosition.row + 1 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === targetId) continue; | ||
| if (!graph.hasEdge(callerId, targetId)) { | ||
| graph.addEdge(callerId, targetId, { type: "references", confidence: isUnresolved ? "AMBIGUOUS" : "EXTRACTED" }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| export { | ||
| parseCpp | ||
| }; |
| // src/core/git.ts | ||
| import { simpleGit } from "simple-git"; | ||
| import path from "path"; | ||
| import fs from "fs"; | ||
| import pc from "picocolors"; | ||
| var CACHE_VERSION = "1"; | ||
| var GIT_ENRICH_SKIP = /* @__PURE__ */ new Set([ | ||
| "package-lock.json", | ||
| "yarn.lock", | ||
| "pnpm-lock.yaml", | ||
| "bun.lockb", | ||
| "shrinkwrap.json", | ||
| "npm-shrinkwrap.json", | ||
| "Gemfile.lock", | ||
| "Cargo.lock", | ||
| "Pipfile.lock", | ||
| "poetry.lock", | ||
| "composer.lock", | ||
| "packages.lock.json", | ||
| "CHANGELOG.md", | ||
| "CHANGELOG.txt", | ||
| "go.sum", | ||
| "uv.lock", | ||
| "gradle.lockfile" | ||
| ]); | ||
| async function loadCache(cacheFile) { | ||
| try { | ||
| const raw = await fs.promises.readFile(cacheFile, "utf-8"); | ||
| const parsed = JSON.parse(raw); | ||
| if (parsed.version !== CACHE_VERSION) return null; | ||
| return parsed; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| async function saveCache(cacheFile, cache) { | ||
| try { | ||
| const serialized = JSON.stringify(cache); | ||
| const dir = path.dirname(cacheFile); | ||
| const tempFile = path.join(dir, `git-cache.${Date.now()}.${Math.random().toString(36).substring(2)}.tmp`); | ||
| await fs.promises.writeFile(tempFile, serialized, "utf-8"); | ||
| try { | ||
| await fs.promises.rename(tempFile, cacheFile); | ||
| } catch { | ||
| try { | ||
| await fs.promises.copyFile(tempFile, cacheFile); | ||
| await fs.promises.unlink(tempFile); | ||
| } catch { | ||
| } | ||
| } | ||
| } catch { | ||
| } | ||
| } | ||
| async function enrichWithGit(graph, targetDir, force = false) { | ||
| let git; | ||
| try { | ||
| git = simpleGit(targetDir); | ||
| const isRepo = await git.checkIsRepo(); | ||
| if (!isRepo) return; | ||
| const isShallow = (await git.raw(["rev-parse", "--is-shallow-repository"])).trim() === "true"; | ||
| if (isShallow) { | ||
| console.warn(pc.yellow("\n\u26A0\u3000Shallow Git repository detected. Skipping Git enrichment to save time.")); | ||
| return; | ||
| } | ||
| } catch { | ||
| return; | ||
| } | ||
| const outDir = path.join(targetDir, ".geraph"); | ||
| const cacheDir = path.join(outDir, "cache"); | ||
| if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true }); | ||
| const cacheFile = path.join(cacheDir, "git-cache.json"); | ||
| const cache = (force ? null : await loadCache(cacheFile)) ?? { | ||
| version: CACHE_VERSION, | ||
| commits: {}, | ||
| blame: {} | ||
| }; | ||
| if (force) { | ||
| cache.blame = {}; | ||
| } | ||
| const allDiscoveredHashes = /* @__PURE__ */ new Set(); | ||
| const globalNodeCommits = /* @__PURE__ */ new Map(); | ||
| const nodesByFile = /* @__PURE__ */ new Map(); | ||
| for (const nodeId of graph.nodes()) { | ||
| const data = graph.getNodeAttributes(nodeId); | ||
| if (data.metadata?.external) continue; | ||
| if (!["file", "media", "intent"].includes(data.type)) { | ||
| const filePath = nodeId.split("::")[0]; | ||
| if (!filePath) continue; | ||
| if (!nodesByFile.has(filePath)) nodesByFile.set(filePath, []); | ||
| nodesByFile.get(filePath).push(nodeId); | ||
| } | ||
| } | ||
| for (const nodeId of graph.nodes()) { | ||
| const data = graph.getNodeAttributes(nodeId); | ||
| if ((data.type === "file" || data.type === "media") && !data.metadata?.external) { | ||
| const basename = path.basename(nodeId); | ||
| if (GIT_ENRICH_SKIP.has(basename)) continue; | ||
| if (!nodesByFile.has(nodeId)) { | ||
| nodesByFile.set(nodeId, [nodeId]); | ||
| } | ||
| } | ||
| } | ||
| let trackedFiles = null; | ||
| try { | ||
| const trackedRaw = await git.raw(["ls-files"]); | ||
| trackedFiles = new Set( | ||
| trackedRaw.split("\n").map((f) => f.trim()).filter(Boolean).map((f) => path.normalize(path.resolve(targetDir, f))) | ||
| ); | ||
| } catch { | ||
| trackedFiles = null; | ||
| } | ||
| const filePaths = Array.from(nodesByFile.keys()); | ||
| const BATCH_SIZE = 8; | ||
| for (let i = 0; i < filePaths.length; i += BATCH_SIZE) { | ||
| const batch = filePaths.slice(i, i + BATCH_SIZE); | ||
| await Promise.all( | ||
| batch.map(async (filePath) => { | ||
| const nodeIds = nodesByFile.get(filePath); | ||
| try { | ||
| const normalizedPath = path.normalize(filePath); | ||
| if (trackedFiles && !trackedFiles.has(normalizedPath)) { | ||
| return; | ||
| } | ||
| const stat = fs.statSync(filePath); | ||
| const currentMtime = stat.mtimeMs; | ||
| const cachedBlame = cache.blame[normalizedPath]; | ||
| let nodeCommits = {}; | ||
| if (!force && cachedBlame && cachedBlame.mtime === currentMtime) { | ||
| nodeCommits = cachedBlame.nodeToCommits; | ||
| } else { | ||
| const blameOut = await git.raw(["blame", "--line-porcelain", filePath]); | ||
| const allLineToCommit = /* @__PURE__ */ new Map(); | ||
| let maxLine = 0; | ||
| let pos = 0; | ||
| while (pos < blameOut.length) { | ||
| const nextNewline = blameOut.indexOf("\n", pos); | ||
| const end = nextNewline === -1 ? blameOut.length : nextNewline; | ||
| const line = blameOut.substring(pos, end); | ||
| pos = end + 1; | ||
| if (line.length >= 45 && line.charCodeAt(40) === 32) { | ||
| const hash = line.substring(0, 40); | ||
| let isHex = true; | ||
| for (let j = 0; j < 40; j++) { | ||
| const c = hash.charCodeAt(j); | ||
| if (!(c >= 48 && c <= 57 || c >= 97 && c <= 102 || c >= 65 && c <= 70)) { | ||
| isHex = false; | ||
| break; | ||
| } | ||
| } | ||
| if (isHex) { | ||
| const secondSpace = line.indexOf(" ", 41); | ||
| if (secondSpace !== -1) { | ||
| const thirdSpace = line.indexOf(" ", secondSpace + 1); | ||
| const endOfLineNum = thirdSpace === -1 ? line.length : thirdSpace; | ||
| const lineNumStr = line.substring(secondSpace + 1, endOfLineNum); | ||
| const lineNum = parseInt(lineNumStr, 10); | ||
| if (!isNaN(lineNum)) { | ||
| allLineToCommit.set(lineNum, hash); | ||
| if (lineNum > maxLine) maxLine = lineNum; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| for (const nodeId of nodeIds) { | ||
| let startLine = graph.getNodeAttributes(nodeId).startLine; | ||
| let endLine = graph.getNodeAttributes(nodeId).metadata?.endLine; | ||
| if (!startLine) { | ||
| const nodeType = graph.getNodeAttributes(nodeId).type; | ||
| if (nodeType === "file" || nodeType === "media") { | ||
| startLine = 1; | ||
| endLine = maxLine; | ||
| } else { | ||
| continue; | ||
| } | ||
| } | ||
| const finalEnd = endLine || startLine; | ||
| const uniqueCommits = /* @__PURE__ */ new Set(); | ||
| for (let i2 = startLine; i2 <= finalEnd; i2++) { | ||
| const hash = allLineToCommit.get(i2); | ||
| if (hash && !hash.startsWith("00000000")) { | ||
| uniqueCommits.add(hash); | ||
| } | ||
| } | ||
| nodeCommits[nodeId] = Array.from(uniqueCommits); | ||
| } | ||
| cache.blame[normalizedPath] = { | ||
| mtime: currentMtime, | ||
| nodeToCommits: nodeCommits | ||
| }; | ||
| } | ||
| for (const nodeId of nodeIds) { | ||
| const commits = nodeCommits[nodeId] || []; | ||
| globalNodeCommits.set(nodeId, commits); | ||
| for (const hash of commits) { | ||
| allDiscoveredHashes.add(hash); | ||
| } | ||
| } | ||
| } catch { | ||
| } | ||
| }) | ||
| ); | ||
| } | ||
| const missingHashes = Array.from(allDiscoveredHashes).filter((hash) => !cache.commits[hash]); | ||
| if (missingHashes.length > 0) { | ||
| const CHUNK_SIZE = 50; | ||
| for (let i = 0; i < missingHashes.length; i += CHUNK_SIZE) { | ||
| const chunk = missingHashes.slice(i, i + CHUNK_SIZE); | ||
| try { | ||
| const rawOut = await git.raw([ | ||
| "show", | ||
| "--no-patch", | ||
| "--format=%H===GERAPH===%an===GERAPH===%aI===GERAPH===%B", | ||
| ...chunk | ||
| ]); | ||
| const blocks = rawOut.split(/(?=[0-9a-f]{40}===GERAPH===)/); | ||
| for (const block of blocks) { | ||
| if (!block.trim()) continue; | ||
| const parts = block.split("===GERAPH==="); | ||
| if (parts.length >= 4) { | ||
| const hash = parts[0].trim(); | ||
| cache.commits[hash] = { | ||
| author: parts[1].trim(), | ||
| date: parts[2].trim(), | ||
| message: parts.slice(3).join("===GERAPH===").trim() | ||
| }; | ||
| } | ||
| } | ||
| } catch { | ||
| console.warn(pc.yellow("\n\u26A0\u3000Warning: Failed to bulk-fetch metadata for some commits.")); | ||
| } | ||
| } | ||
| } | ||
| for (const [nodeId, commits] of globalNodeCommits.entries()) { | ||
| for (const hash of commits) { | ||
| const meta = cache.commits[hash]; | ||
| if (!meta) continue; | ||
| const intentNodeId = `commit::${hash}`; | ||
| if (!graph.hasNode(intentNodeId)) { | ||
| graph.addNode(intentNodeId, { | ||
| type: "intent", | ||
| name: `Commit ${hash.substring(0, 7)}`, | ||
| file: "", | ||
| // Intent nodes are global to the commit | ||
| startLine: 0, | ||
| metadata: { | ||
| message: meta.message, | ||
| author: meta.author, | ||
| date: meta.date | ||
| } | ||
| }); | ||
| } | ||
| if (!graph.hasEdge(intentNodeId, nodeId)) { | ||
| graph.addEdge(intentNodeId, nodeId, { | ||
| type: "explains", | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| await saveCache(cacheFile, cache); | ||
| } | ||
| export { | ||
| enrichWithGit | ||
| }; |
| // src/parsers/go.ts | ||
| import Parser from "web-tree-sitter"; | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| var GO_STDLIB_PACKAGES = /* @__PURE__ */ new Set([ | ||
| "archive", | ||
| "bufio", | ||
| "builtin", | ||
| "bytes", | ||
| "cmp", | ||
| "compress", | ||
| "container", | ||
| "context", | ||
| "crypto", | ||
| "database", | ||
| "debug", | ||
| "embed", | ||
| "encoding", | ||
| "errors", | ||
| "expvar", | ||
| "flag", | ||
| "fmt", | ||
| "go", | ||
| "hash", | ||
| "html", | ||
| "image", | ||
| "index", | ||
| "internal", | ||
| "io", | ||
| "iter", | ||
| "log", | ||
| "maps", | ||
| "math", | ||
| "mime", | ||
| "net", | ||
| "os", | ||
| "path", | ||
| "plugin", | ||
| "reflect", | ||
| "regexp", | ||
| "runtime", | ||
| "slices", | ||
| "sort", | ||
| "strconv", | ||
| "strings", | ||
| "structs", | ||
| "sync", | ||
| "syscall", | ||
| "testing", | ||
| "text", | ||
| "time", | ||
| "unicode", | ||
| "unique", | ||
| "unsafe" | ||
| ]); | ||
| var GO_BUILT_INS = /* @__PURE__ */ new Set([ | ||
| // Built-in functions and types | ||
| "true", | ||
| "false", | ||
| "iota", | ||
| "nil", | ||
| "append", | ||
| "cap", | ||
| "close", | ||
| "complex", | ||
| "copy", | ||
| "delete", | ||
| "imag", | ||
| "len", | ||
| "make", | ||
| "new", | ||
| "panic", | ||
| "print", | ||
| "println", | ||
| "real", | ||
| "recover", | ||
| "complex64", | ||
| "complex128", | ||
| "uint8", | ||
| "uint16", | ||
| "uint32", | ||
| "uint64", | ||
| "int8", | ||
| "int16", | ||
| "int32", | ||
| "int64", | ||
| "float32", | ||
| "float64", | ||
| "byte", | ||
| "rune", | ||
| "uint", | ||
| "int", | ||
| "uintptr", | ||
| "string", | ||
| "bool", | ||
| "error", | ||
| "any", | ||
| "comparable", | ||
| "min", | ||
| "max", | ||
| "clear", | ||
| // Common method names that should never become standalone graph nodes | ||
| "Error", | ||
| "String", | ||
| "Len", | ||
| "Less", | ||
| "Swap", | ||
| "Close", | ||
| "Read", | ||
| "Write", | ||
| "Seek", | ||
| "Flush", | ||
| "Lock", | ||
| "Unlock", | ||
| "RLock", | ||
| "RUnlock", | ||
| "Wait", | ||
| "Signal", | ||
| "Broadcast", | ||
| "Done", | ||
| "Err", | ||
| "Add", | ||
| "Load", | ||
| "Store", | ||
| "CompareAndSwap", | ||
| "Value", | ||
| "Reset", | ||
| "Stop", | ||
| "Marshal", | ||
| "Unmarshal", | ||
| "Encode", | ||
| "Decode", | ||
| "ServeHTTP", | ||
| "Header", | ||
| "Body" | ||
| ]); | ||
| var goModulesCache = null; | ||
| function findProjectGoModules(workspaceRoot) { | ||
| if (goModulesCache) return goModulesCache; | ||
| const modules = []; | ||
| const scanDir = (dir, depth) => { | ||
| if (depth > 4) return; | ||
| try { | ||
| const entries = fs.readdirSync(dir, { withFileTypes: true }); | ||
| const hasMod = entries.find((e) => e.isFile() && e.name === "go.mod"); | ||
| if (hasMod) { | ||
| const modPath = path.join(dir, "go.mod"); | ||
| const content = fs.readFileSync(modPath, "utf-8"); | ||
| const match = content.match(/^\s*module\s+([^\s]+)/m); | ||
| if (match && match[1]) { | ||
| modules.push({ filePath: modPath, moduleName: match[1].trim() }); | ||
| } | ||
| } | ||
| for (const entry of entries) { | ||
| if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") { | ||
| scanDir(path.join(dir, entry.name), depth + 1); | ||
| } | ||
| } | ||
| } catch { | ||
| } | ||
| }; | ||
| scanDir(workspaceRoot, 0); | ||
| goModulesCache = modules; | ||
| return modules; | ||
| } | ||
| function findGoModAndModule(dir) { | ||
| let current = dir; | ||
| while (true) { | ||
| const candidate = path.join(current, "go.mod"); | ||
| if (fs.existsSync(candidate)) { | ||
| try { | ||
| const content = fs.readFileSync(candidate, "utf-8"); | ||
| const match = content.match(/^\s*module\s+([^\s]+)/m); | ||
| if (match && match[1]) { | ||
| return { filePath: candidate, moduleName: match[1].trim() }; | ||
| } | ||
| } catch { | ||
| } | ||
| return { filePath: candidate, moduleName: "" }; | ||
| } | ||
| const parent = path.dirname(current); | ||
| if (parent === current) break; | ||
| current = parent; | ||
| } | ||
| return null; | ||
| } | ||
| function resolveGoImport(importPath, sourceFilePath, currentGoMod) { | ||
| const workspaceRoot = process.cwd(); | ||
| const projectModules = findProjectGoModules(workspaceRoot); | ||
| for (const mod of projectModules) { | ||
| if (importPath === mod.moduleName || importPath.startsWith(mod.moduleName + "/")) { | ||
| const relativePart = importPath.slice(mod.moduleName.length); | ||
| const resolvedDir = path.join(path.dirname(mod.filePath), relativePart); | ||
| if (fs.existsSync(resolvedDir)) { | ||
| try { | ||
| const files = fs.readdirSync(resolvedDir); | ||
| const goFile = files.find((f) => f.endsWith(".go") && !f.endsWith("_test.go")); | ||
| if (goFile) return path.join(resolvedDir, goFile); | ||
| } catch { | ||
| } | ||
| return resolvedDir; | ||
| } | ||
| } | ||
| } | ||
| if (currentGoMod && currentGoMod.moduleName && importPath.startsWith(currentGoMod.moduleName)) { | ||
| const relativePart = importPath.slice(currentGoMod.moduleName.length); | ||
| const resolvedDir = path.join(path.dirname(currentGoMod.filePath), relativePart); | ||
| if (fs.existsSync(resolvedDir)) { | ||
| try { | ||
| const files = fs.readdirSync(resolvedDir); | ||
| const goFile = files.find((f) => f.endsWith(".go") && !f.endsWith("_test.go")); | ||
| if (goFile) return path.join(resolvedDir, goFile); | ||
| } catch { | ||
| } | ||
| return resolvedDir; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| function isGoStdlib(importPath) { | ||
| const firstPart = importPath.split("/")[0] || importPath; | ||
| return GO_STDLIB_PACKAGES.has(firstPart) || !firstPart.includes("."); | ||
| } | ||
| function extractGoComment(node) { | ||
| const comments = []; | ||
| let prev = node.previousSibling; | ||
| while (prev && (prev.type === "comment" || prev.type === "line_comment")) { | ||
| comments.unshift(prev.text.replace(/^\/\/|^\/\*|\*\/$/g, "").trim()); | ||
| prev = prev.previousSibling; | ||
| } | ||
| return comments.length > 0 ? comments.join("\n") : void 0; | ||
| } | ||
| var getGoReceiverType = (node) => { | ||
| if (node.type !== "method_declaration") return ""; | ||
| const receiver = node.childForFieldName("receiver"); | ||
| if (!receiver) return ""; | ||
| let typeName = ""; | ||
| const findType = (n) => { | ||
| if (typeName) return; | ||
| if (n.type === "type_identifier") { | ||
| typeName = n.text.trim(); | ||
| return; | ||
| } | ||
| for (let i = 0; i < n.namedChildCount; i++) { | ||
| const child = n.namedChild(i); | ||
| if (child) findType(child); | ||
| } | ||
| }; | ||
| findType(receiver); | ||
| return typeName; | ||
| }; | ||
| function isLocalDeclaration(startNode, name) { | ||
| let current = startNode; | ||
| const checkPattern = (patternNode) => { | ||
| if (patternNode.type === "identifier") { | ||
| return patternNode.text.trim() === name; | ||
| } | ||
| for (let i = 0; i < patternNode.namedChildCount; i++) { | ||
| const child = patternNode.namedChild(i); | ||
| if (child && checkPattern(child)) return true; | ||
| } | ||
| return false; | ||
| }; | ||
| while (current) { | ||
| if (current.type === "function_declaration" || current.type === "method_declaration" || current.type === "func_literal") { | ||
| const signature = current.childForFieldName("signature"); | ||
| if (signature) { | ||
| const params = signature.childForFieldName("parameters"); | ||
| if (params && checkPattern(params)) return true; | ||
| const results = signature.childForFieldName("results"); | ||
| if (results && checkPattern(results)) return true; | ||
| } | ||
| if (current.type === "method_declaration") { | ||
| const receiver = current.childForFieldName("receiver"); | ||
| if (receiver && checkPattern(receiver)) return true; | ||
| } | ||
| } | ||
| if (current.type === "for_statement") { | ||
| const findLoopVars = (n) => { | ||
| if (n.type === "range_clause" || n.type === "short_var_declaration") { | ||
| const leftNode = n.childForFieldName("left"); | ||
| if (leftNode && checkPattern(leftNode)) return true; | ||
| } | ||
| for (let i = 0; i < n.namedChildCount; i++) { | ||
| const child = n.namedChild(i); | ||
| if (child && findLoopVars(child)) return true; | ||
| } | ||
| return false; | ||
| }; | ||
| if (findLoopVars(current)) return true; | ||
| } | ||
| if (current.type === "block" || current.type === "source_file") { | ||
| for (let i = 0; i < current.namedChildCount; i++) { | ||
| const statement = current.namedChild(i); | ||
| if (!statement) continue; | ||
| if (statement.type === "short_var_declaration") { | ||
| const left = statement.childForFieldName("left"); | ||
| if (left && checkPattern(left)) { | ||
| if (statement.startIndex <= (startNode?.startIndex ?? 0)) return true; | ||
| } | ||
| } else if (statement.type === "var_declaration" || statement.type === "const_declaration") { | ||
| const findVars = (n) => { | ||
| if (n.type === "var_spec" || n.type === "const_spec") { | ||
| const nameNode = n.childForFieldName("name") || n.namedChild(0); | ||
| if (nameNode && checkPattern(nameNode)) return true; | ||
| } | ||
| for (let j = 0; j < n.namedChildCount; j++) { | ||
| const child = n.namedChild(j); | ||
| if (child && findVars(child)) return true; | ||
| } | ||
| return false; | ||
| }; | ||
| if (findVars(statement)) { | ||
| if (statement.startIndex <= (startNode?.startIndex ?? 0)) return true; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| current = current.parent; | ||
| } | ||
| return false; | ||
| } | ||
| function readSourceFile(filePath) { | ||
| const buffer = fs.readFileSync(filePath); | ||
| if (buffer.length >= 2 && buffer[0] === 255 && buffer[1] === 254) { | ||
| return buffer.toString("utf16le").replace(/^\uFEFF/, ""); | ||
| } | ||
| if (buffer.length >= 2 && buffer[0] === 254 && buffer[1] === 255) { | ||
| const swapped = Buffer.alloc(buffer.length); | ||
| for (let i = 0; i < buffer.length - 1; i += 2) { | ||
| swapped[i] = buffer[i + 1]; | ||
| swapped[i + 1] = buffer[i]; | ||
| } | ||
| return swapped.toString("utf16le").replace(/^\uFEFF/, ""); | ||
| } | ||
| let content = buffer.toString("utf8"); | ||
| if (content.startsWith("\uFEFF")) { | ||
| content = content.slice(1); | ||
| } | ||
| return content.replace(/\0/g, ""); | ||
| } | ||
| function parseGo(filePath, graph, language) { | ||
| const parser = new Parser(); | ||
| try { | ||
| parser.setLanguage(language); | ||
| } catch { | ||
| return; | ||
| } | ||
| let sourceCode; | ||
| try { | ||
| sourceCode = readSourceFile(filePath); | ||
| } catch { | ||
| return; | ||
| } | ||
| const tree = parser.parse(sourceCode); | ||
| if (!tree) return; | ||
| const localDefinitions = /* @__PURE__ */ new Set(); | ||
| const importMap = /* @__PURE__ */ new Map(); | ||
| const localMethodMap = /* @__PURE__ */ new Map(); | ||
| const goMod = findGoModAndModule(path.dirname(filePath)); | ||
| const queryString = ` | ||
| (import_spec path: (interpreted_string_literal) @import_path) | ||
| (import_spec name: (package_identifier) @import_alias path: (interpreted_string_literal) @import_path) | ||
| (type_spec name: (type_identifier) @struct_decl type: (struct_type)) | ||
| (type_spec name: (type_identifier) @interface_decl type: (interface_type)) | ||
| (type_spec name: (type_identifier) @type_decl) | ||
| (function_declaration name: (identifier) @func_decl) | ||
| (method_declaration name: (field_identifier) @method_decl) | ||
| (call_expression function: (identifier) @call_name) | ||
| (call_expression function: (selector_expression operand: (identifier) @call_operand field: (field_identifier) @call_selector)) | ||
| (composite_literal (type_identifier) @type_reference) | ||
| (pointer_type (type_identifier) @type_reference) | ||
| (parameter_declaration (type_identifier) @type_reference) | ||
| (var_spec (type_identifier) @type_reference) | ||
| (field_declaration (type_identifier) @type_reference) | ||
| `; | ||
| const query = language.query(queryString); | ||
| const matches = query.matches(tree.rootNode); | ||
| const getEnclosingScopePath = (startNode) => { | ||
| const pathParts = []; | ||
| let current = startNode; | ||
| while (current) { | ||
| if (current.type === "function_declaration") { | ||
| const nameNode = current.childForFieldName("name"); | ||
| if (nameNode) pathParts.unshift(nameNode.text.trim()); | ||
| } else if (current.type === "method_declaration") { | ||
| const nameNode = current.childForFieldName("name"); | ||
| const rcvr = getGoReceiverType(current); | ||
| if (nameNode) { | ||
| const fullName = rcvr ? rcvr + "." + nameNode.text.trim() : nameNode.text.trim(); | ||
| pathParts.unshift(fullName); | ||
| } | ||
| } else if (current.type === "type_spec") { | ||
| const nameNode = current.childForFieldName("name"); | ||
| if (nameNode) pathParts.unshift(nameNode.text.trim()); | ||
| } | ||
| current = current.parent; | ||
| } | ||
| return pathParts.join("."); | ||
| }; | ||
| for (const match of matches) { | ||
| let lastImportPath = ""; | ||
| for (const capture of match.captures) { | ||
| const node = capture.node; | ||
| const captureName = capture.name; | ||
| if (captureName === "import_path") { | ||
| const importPath = node.text.replace(/"/g, "").trim(); | ||
| lastImportPath = importPath; | ||
| const pkgName = importPath.split("/").pop() || importPath; | ||
| if (pkgName && pkgName !== "") { | ||
| importMap.set(pkgName, importPath); | ||
| } | ||
| } else if (captureName === "import_alias") { | ||
| const alias = node.text.trim(); | ||
| if (lastImportPath && alias && alias !== "") { | ||
| importMap.set(alias, lastImportPath); | ||
| } | ||
| } else if (captureName === "struct_decl" || captureName === "interface_decl" || captureName === "type_decl" || captureName === "func_decl" || captureName === "method_decl") { | ||
| const symName = node.text.trim(); | ||
| if (symName) { | ||
| localDefinitions.add(symName); | ||
| let scopePrefix = ""; | ||
| const decl = node.parent || node; | ||
| if (captureName === "method_decl") { | ||
| const receiverType = getGoReceiverType(decl); | ||
| if (receiverType) { | ||
| scopePrefix = receiverType + "."; | ||
| } | ||
| } | ||
| const symId = `${filePath}::${scopePrefix}${symName}`; | ||
| if (captureName === "func_decl" || captureName === "method_decl") { | ||
| if (!localMethodMap.has(symName)) { | ||
| localMethodMap.set(symName, []); | ||
| } | ||
| localMethodMap.get(symName).push(symId); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| for (const match of matches) { | ||
| for (const capture of match.captures) { | ||
| const node = capture.node; | ||
| const captureName = capture.name; | ||
| if (captureName === "struct_decl" || captureName === "interface_decl" || captureName === "type_decl" || captureName === "func_decl" || captureName === "method_decl") { | ||
| const symName = node.text.trim(); | ||
| if (!symName) continue; | ||
| let scopePrefix = ""; | ||
| let declCandidate = node.parent || node; | ||
| if (declCandidate.type === "type_spec" && declCandidate.parent && declCandidate.parent.type === "type_declaration") { | ||
| declCandidate = declCandidate.parent; | ||
| } | ||
| const decl = declCandidate; | ||
| if (captureName === "method_decl") { | ||
| const receiverType = getGoReceiverType(node.parent || node); | ||
| if (receiverType) { | ||
| scopePrefix = receiverType + "."; | ||
| } | ||
| } | ||
| const symId = `${filePath}::${scopePrefix}${symName}`; | ||
| const parentId = scopePrefix ? `${filePath}::${scopePrefix.slice(0, -1)}` : filePath; | ||
| let nodeType = "function"; | ||
| if (captureName === "struct_decl") nodeType = "struct"; | ||
| else if (captureName === "interface_decl") nodeType = "interface"; | ||
| else if (captureName === "type_decl") nodeType = "type"; | ||
| const doc = extractGoComment(decl); | ||
| const nodeAttrs = { | ||
| type: nodeType, | ||
| name: symName, | ||
| file: filePath, | ||
| startLine: decl.startPosition.row + 1, | ||
| metadata: { | ||
| endLine: decl.endPosition.row + 1, | ||
| doc, | ||
| external: false, | ||
| unresolved: false | ||
| } | ||
| }; | ||
| if (!graph.hasNode(symId)) { | ||
| graph.addNode(symId, nodeAttrs); | ||
| graph.addEdge(parentId, symId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } else { | ||
| graph.mergeNodeAttributes(symId, nodeAttrs); | ||
| if (!graph.hasEdge(parentId, symId)) { | ||
| graph.addEdge(parentId, symId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } | ||
| } | ||
| } else if (captureName === "import_path") { | ||
| const importPath = node.text.replace(/"/g, "").trim(); | ||
| if (importPath === "" || importPath === "." || importPath === "/") continue; | ||
| const resolvedPath = resolveGoImport(importPath, filePath, goMod); | ||
| if (!resolvedPath && isGoStdlib(importPath)) continue; | ||
| const importNodeId = resolvedPath || `import::${importPath}`; | ||
| if (importNodeId === "import::" || importNodeId.trim() === "") continue; | ||
| if (!graph.hasNode(importNodeId)) { | ||
| const nearestLog = goMod && goMod.filePath ? goMod.filePath : filePath; | ||
| graph.addNode(importNodeId, { | ||
| type: "file", | ||
| name: resolvedPath ? path.basename(resolvedPath) : importPath, | ||
| file: resolvedPath || nearestLog || filePath, | ||
| startLine: 0, | ||
| metadata: resolvedPath ? {} : { external: true } | ||
| }); | ||
| } | ||
| if (!graph.hasEdge(filePath, importNodeId)) { | ||
| graph.addEdge(filePath, importNodeId, { type: "imports", confidence: "EXTRACTED" }); | ||
| } | ||
| } else if (captureName === "call_name") { | ||
| const calledName = node.text.trim(); | ||
| if (!calledName || GO_BUILT_INS.has(calledName)) continue; | ||
| if (isLocalDeclaration(node, calledName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| const targets = []; | ||
| if (localDefinitions.has(calledName)) { | ||
| targets.push({ | ||
| id: `${filePath}::${calledName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else { | ||
| targets.push({ | ||
| id: `unresolved::${calledName}`, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| for (const target of targets) { | ||
| const isUnresolved = target.id.startsWith("unresolved::"); | ||
| const isExternal = target.id.startsWith("import::") || !isUnresolved && !target.id.startsWith(filePath); | ||
| const callLine = node.startPosition.row + 1; | ||
| if (!graph.hasNode(target.id)) { | ||
| let fileAttr = filePath; | ||
| if (isUnresolved) { | ||
| fileAttr = filePath; | ||
| } else if (target.id.includes("::")) { | ||
| fileAttr = target.id.split("::")[0] || filePath; | ||
| } | ||
| graph.addNode(target.id, { | ||
| type: "function", | ||
| name: calledName, | ||
| file: fileAttr, | ||
| startLine: isExternal ? 0 : callLine, | ||
| metadata: { | ||
| external: isExternal, | ||
| unresolved: isUnresolved, | ||
| endLine: isExternal ? 0 : callLine, | ||
| callerFile: isUnresolved ? filePath : void 0, | ||
| callerLine: isUnresolved ? callLine : void 0 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === target.id) continue; | ||
| if (!graph.hasEdge(callerId, target.id)) { | ||
| graph.addEdge(callerId, target.id, { type: "calls", confidence: target.confidence }); | ||
| } | ||
| } | ||
| } else if (captureName === "call_selector") { | ||
| const methodName = node.text.trim(); | ||
| if (!methodName || GO_BUILT_INS.has(methodName)) continue; | ||
| let operandName = ""; | ||
| if (node.parent && node.parent.type === "selector_expression") { | ||
| const operandNode = node.parent.childForFieldName("operand"); | ||
| if (operandNode) operandName = operandNode.text.trim(); | ||
| } | ||
| if (operandName && GO_STDLIB_PACKAGES.has(operandName)) { | ||
| continue; | ||
| } | ||
| if (isLocalDeclaration(node, operandName ? `${operandName}.${methodName}` : methodName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| const targets = []; | ||
| const importSource = importMap.get(operandName); | ||
| if (importSource) { | ||
| const resolvedSource = resolveGoImport(importSource, filePath, goMod); | ||
| if (!resolvedSource && isGoStdlib(importSource)) continue; | ||
| const targetBase = resolvedSource || `import::${importSource}`; | ||
| targets.push({ | ||
| id: `${targetBase}::${methodName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else { | ||
| const localMatches = localMethodMap.get(methodName) || []; | ||
| if (localMatches.length === 1 && localMatches[0]) { | ||
| targets.push({ | ||
| id: localMatches[0], | ||
| confidence: "INFERRED" | ||
| }); | ||
| } else if (localMatches.length > 1) { | ||
| for (const matchId of localMatches) { | ||
| targets.push({ | ||
| id: matchId, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } else { | ||
| targets.push({ | ||
| id: `unresolved::${methodName}`, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } | ||
| for (const target of targets) { | ||
| const isUnresolved = target.id.startsWith("unresolved::"); | ||
| const isExternal = target.id.startsWith("import::") || !isUnresolved && !target.id.startsWith(filePath); | ||
| const callLine = node.startPosition.row + 1; | ||
| if (!graph.hasNode(target.id)) { | ||
| let fileAttr = filePath; | ||
| if (isUnresolved) { | ||
| fileAttr = filePath; | ||
| } else if (target.id.includes("::")) { | ||
| fileAttr = target.id.split("::")[0] || filePath; | ||
| } | ||
| graph.addNode(target.id, { | ||
| type: "function", | ||
| name: operandName ? `${operandName}.${methodName}` : methodName, | ||
| file: fileAttr, | ||
| startLine: isExternal ? 0 : callLine, | ||
| metadata: { | ||
| external: isExternal, | ||
| unresolved: isUnresolved, | ||
| endLine: isExternal ? 0 : callLine, | ||
| callerFile: isUnresolved ? filePath : void 0, | ||
| callerLine: isUnresolved ? callLine : void 0 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === target.id) continue; | ||
| if (!graph.hasEdge(callerId, target.id)) { | ||
| graph.addEdge(callerId, target.id, { type: "calls", confidence: target.confidence }); | ||
| } | ||
| } | ||
| } else if (captureName === "type_reference") { | ||
| const referencedTypeName = node.text.trim(); | ||
| if (!referencedTypeName || GO_BUILT_INS.has(referencedTypeName)) continue; | ||
| if (isLocalDeclaration(node, referencedTypeName)) continue; | ||
| let inReceiver = false; | ||
| let p = node.parent; | ||
| while (p) { | ||
| if (p.type === "parameter_list" && p.parent?.type === "method_declaration") { | ||
| inReceiver = true; | ||
| break; | ||
| } | ||
| p = p.parent; | ||
| } | ||
| if (inReceiver) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| if (!graph.hasNode(callerId)) continue; | ||
| let targetId; | ||
| if (localDefinitions.has(referencedTypeName)) { | ||
| targetId = `${filePath}::${referencedTypeName}`; | ||
| } else { | ||
| targetId = `unresolved::${referencedTypeName}`; | ||
| } | ||
| const isUnresolved = !localDefinitions.has(referencedTypeName); | ||
| if (!graph.hasNode(targetId)) { | ||
| graph.addNode(targetId, { | ||
| type: "struct", | ||
| name: referencedTypeName, | ||
| file: filePath, | ||
| startLine: node.startPosition.row + 1, | ||
| metadata: { | ||
| external: false, | ||
| unresolved: isUnresolved, | ||
| endLine: node.startPosition.row + 1, | ||
| callerFile: filePath, | ||
| callerLine: node.startPosition.row + 1 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === targetId) continue; | ||
| if (!graph.hasEdge(callerId, targetId)) { | ||
| graph.addEdge(callerId, targetId, { type: "references", confidence: isUnresolved ? "AMBIGUOUS" : "EXTRACTED" }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| export { | ||
| parseGo | ||
| }; |
| import { | ||
| createKnowledgeGraph, | ||
| resolveCallGraph | ||
| } from "./chunk-5V42YYJT.js"; | ||
| export { | ||
| createKnowledgeGraph, | ||
| resolveCallGraph | ||
| }; |
| // src/core/install.ts | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| import os from "os"; | ||
| import pc from "picocolors"; | ||
| var START_MARKER = "<!-- GERAPH_START -->"; | ||
| var END_MARKER = "<!-- GERAPH_END -->"; | ||
| var SHARED_RULES = `## geraph | ||
| 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, 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\`, and \`geraph://surprises\`. | ||
| - 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. | ||
| [GLOBAL_POINTER] | ||
| `; | ||
| var COPILOT_RULES = `## geraph | ||
| For any question about this repo's architecture, components, or dependency structure, your **first action** must be to consult Geraph. | ||
| **CRITICAL:** Before answering, 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, 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\`, and \`geraph://surprises\`. | ||
| - 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. | ||
| [GLOBAL_POINTER] | ||
| `; | ||
| var ANTIGRAVITY_WORKFLOW = `--- | ||
| name: geraph | ||
| description: Navigate codebase architecture and dependencies | ||
| --- | ||
| # Workflow: geraph | ||
| [GLOBAL_POINTER] | ||
| `; | ||
| var PLATFORMS = { | ||
| claude: { | ||
| name: "Claude Code", | ||
| localFiles: [{ path: "CLAUDE.md", content: SHARED_RULES, inject: true }], | ||
| globalPath: path.join( | ||
| os.homedir(), | ||
| ".claude", | ||
| "skills", | ||
| "geraph", | ||
| "SKILL.md" | ||
| ) | ||
| }, | ||
| cursor: { | ||
| name: "Cursor", | ||
| localFiles: [ | ||
| { | ||
| path: ".cursor/rules/geraph.mdc", | ||
| content: `--- | ||
| description: geraph codebase knowledge graph | ||
| alwaysApply: true | ||
| --- | ||
| [GERAPH_SKILL_CONTENT_PLACEHOLDER]`, | ||
| inject: false | ||
| } | ||
| ] | ||
| }, | ||
| antigravity: { | ||
| name: "Antigravity", | ||
| localFiles: [ | ||
| { | ||
| path: ".agent/rules/geraph.md", | ||
| content: SHARED_RULES, | ||
| inject: false | ||
| }, | ||
| { | ||
| path: ".agent/workflows/geraph.md", | ||
| content: ANTIGRAVITY_WORKFLOW, | ||
| inject: false | ||
| } | ||
| ], | ||
| globalPath: path.join( | ||
| os.homedir(), | ||
| ".agent", | ||
| "skills", | ||
| "geraph", | ||
| "SKILL.md" | ||
| ) | ||
| }, | ||
| agents: { | ||
| name: "Generic Agent (Fallback)", | ||
| localFiles: [{ path: "AGENTS.md", content: SHARED_RULES, inject: true }] | ||
| }, | ||
| vscode: { | ||
| name: "VS Code / Copilot", | ||
| localFiles: [ | ||
| { | ||
| path: ".github/copilot-instructions.md", | ||
| content: COPILOT_RULES, | ||
| inject: true | ||
| } | ||
| ], | ||
| globalPath: path.join( | ||
| os.homedir(), | ||
| ".copilot", | ||
| "skills", | ||
| "geraph", | ||
| "SKILL.md" | ||
| ) | ||
| }, | ||
| copilot: { | ||
| name: "GitHub Copilot", | ||
| localFiles: [ | ||
| { | ||
| path: ".github/copilot-instructions.md", | ||
| content: COPILOT_RULES, | ||
| inject: true | ||
| } | ||
| ], | ||
| globalPath: path.join( | ||
| os.homedir(), | ||
| ".copilot", | ||
| "skills", | ||
| "geraph", | ||
| "SKILL.md" | ||
| ) | ||
| } | ||
| }; | ||
| async function installGeraph(targetDir, platformName) { | ||
| const results = []; | ||
| const platform = PLATFORMS[platformName]; | ||
| if (!platform) { | ||
| throw new Error(`Unsupported platform: ${platformName}`); | ||
| } | ||
| const currentFilePath = new URL(import.meta.url).pathname; | ||
| const normalizedPath = process.platform === "win32" ? currentFilePath.substring(1) : currentFilePath; | ||
| const templatePath = path.resolve( | ||
| path.dirname(normalizedPath), | ||
| ".", | ||
| "templates", | ||
| "skill.md" | ||
| ); | ||
| let skillContent = ""; | ||
| if (fs.existsSync(templatePath)) { | ||
| skillContent = fs.readFileSync(templatePath, "utf-8"); | ||
| } | ||
| for (const localFile of platform.localFiles) { | ||
| const fullPath = path.join(targetDir, localFile.path); | ||
| const dir = path.dirname(fullPath); | ||
| if (!fs.existsSync(dir)) { | ||
| fs.mkdirSync(dir, { recursive: true }); | ||
| } | ||
| let fileContentToInject = localFile.content || skillContent; | ||
| fileContentToInject = fileContentToInject.replace( | ||
| "[GERAPH_SKILL_CONTENT_PLACEHOLDER]", | ||
| skillContent | ||
| ); | ||
| if (platform.globalPath) { | ||
| const homeRelPath = platform.globalPath.replace(os.homedir(), "~").replace(/\\/g, "/"); | ||
| fileContentToInject = fileContentToInject.replace( | ||
| "[GLOBAL_POINTER]", | ||
| `Before answering, you MUST read the [Geraph Skill](${homeRelPath}) operational manual.` | ||
| ); | ||
| } else { | ||
| fileContentToInject = fileContentToInject.replace("[GLOBAL_POINTER]", ""); | ||
| } | ||
| if (localFile.inject) { | ||
| const injection = ` | ||
| ${START_MARKER} | ||
| ${fileContentToInject.trim()} | ||
| ${END_MARKER} | ||
| `; | ||
| if (fs.existsSync(fullPath)) { | ||
| let content = fs.readFileSync(fullPath, "utf-8"); | ||
| const startIndex = content.indexOf(START_MARKER); | ||
| const endIndex = content.indexOf(END_MARKER); | ||
| if (startIndex !== -1 && endIndex !== -1) { | ||
| content = content.substring(0, startIndex) + injection.trim() + content.substring(endIndex + END_MARKER.length); | ||
| fs.writeFileSync(fullPath, content); | ||
| results.push(`${localFile.path} updated (existing section replaced)`); | ||
| } else { | ||
| fs.writeFileSync(fullPath, content.trim() + "\n" + injection); | ||
| results.push(`${localFile.path} updated (section appended)`); | ||
| } | ||
| } else { | ||
| fs.writeFileSync(fullPath, injection); | ||
| results.push(`${localFile.path} created`); | ||
| } | ||
| } else { | ||
| fs.writeFileSync(fullPath, fileContentToInject); | ||
| results.push(`${localFile.path} created/updated`); | ||
| } | ||
| } | ||
| if (skillContent && platform.globalPath) { | ||
| const globalDir = path.dirname(platform.globalPath); | ||
| if (!fs.existsSync(globalDir)) { | ||
| fs.mkdirSync(globalDir, { recursive: true }); | ||
| } | ||
| fs.writeFileSync(platform.globalPath, skillContent); | ||
| results.push(`Global skill installed at ${platform.globalPath}`); | ||
| } else if (!skillContent && platform.globalPath) { | ||
| console.log( | ||
| pc.red(`Failed to install global skill at ${platform.globalPath}.`) | ||
| ); | ||
| } | ||
| return results; | ||
| } | ||
| async function uninstallGeraph(targetDir, platformName) { | ||
| const results = []; | ||
| let platformsToUninstall = []; | ||
| if (platformName) { | ||
| const p = PLATFORMS[platformName]; | ||
| if (p) platformsToUninstall.push(p); | ||
| } else { | ||
| platformsToUninstall = Object.values(PLATFORMS); | ||
| } | ||
| if (platformName && platformsToUninstall.length === 0) { | ||
| throw new Error(`Unsupported platform: ${platformName}`); | ||
| } | ||
| for (const platform of platformsToUninstall) { | ||
| for (const localFile of platform.localFiles) { | ||
| const fullPath = path.join(targetDir, localFile.path); | ||
| if (fs.existsSync(fullPath)) { | ||
| if (localFile.inject) { | ||
| const content = fs.readFileSync(fullPath, "utf-8"); | ||
| const startIndex = content.indexOf(START_MARKER); | ||
| const endIndex = content.indexOf(END_MARKER); | ||
| if (startIndex !== -1 && endIndex !== -1) { | ||
| const before = content.substring(0, startIndex).trim(); | ||
| const after = content.substring(endIndex + END_MARKER.length).trim(); | ||
| const cleaned = (before + "\n\n" + after).trim(); | ||
| if (cleaned === "") { | ||
| fs.unlinkSync(fullPath); | ||
| results.push(`${localFile.path} deleted (empty after cleanup)`); | ||
| } else { | ||
| fs.writeFileSync(fullPath, cleaned + "\n"); | ||
| results.push( | ||
| `${localFile.path} cleaned (geraph section removed)` | ||
| ); | ||
| } | ||
| } | ||
| } else { | ||
| fs.unlinkSync(fullPath); | ||
| results.push(`${localFile.path} deleted`); | ||
| } | ||
| } | ||
| } | ||
| if (platform.globalPath && fs.existsSync(platform.globalPath)) { | ||
| fs.unlinkSync(platform.globalPath); | ||
| results.push(`Global skill removed from ${platform.globalPath}`); | ||
| let currentDir = path.dirname(platform.globalPath); | ||
| while (currentDir !== os.homedir()) { | ||
| if (path.basename(currentDir) !== "geraph") { | ||
| break; | ||
| } | ||
| try { | ||
| if (fs.readdirSync(currentDir).length === 0) { | ||
| fs.rmdirSync(currentDir); | ||
| currentDir = path.dirname(currentDir); | ||
| } else { | ||
| break; | ||
| } | ||
| } catch { | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return results; | ||
| } | ||
| export { | ||
| PLATFORMS, | ||
| installGeraph, | ||
| uninstallGeraph | ||
| }; |
| // src/parsers/java.ts | ||
| import Parser from "web-tree-sitter"; | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| var JAVA_STDLIB_PACKAGES = /* @__PURE__ */ new Set([ | ||
| "java", | ||
| "javax", | ||
| "jdk", | ||
| "sun", | ||
| "com.sun", | ||
| "org.xml", | ||
| "org.w3c", | ||
| // java.lang is auto-imported so we cover the most common System/String/etc references | ||
| "System", | ||
| "Runtime", | ||
| "Process", | ||
| "ProcessBuilder", | ||
| // java.util | ||
| "Scanner" | ||
| ]); | ||
| function isJavaStdlib(importPath) { | ||
| const firstPart = importPath.split(".")[0] || importPath; | ||
| if (JAVA_STDLIB_PACKAGES.has(firstPart)) return true; | ||
| if (importPath.startsWith("java.") || importPath.startsWith("javax.") || importPath.startsWith("jdk.") || importPath.startsWith("sun.") || importPath.startsWith("com.sun.") || importPath.startsWith("org.xml.") || importPath.startsWith("org.w3c.")) return true; | ||
| return false; | ||
| } | ||
| var JAVA_BUILT_INS = /* @__PURE__ */ new Set([ | ||
| // Primitives and keywords | ||
| "void", | ||
| "int", | ||
| "double", | ||
| "float", | ||
| "boolean", | ||
| "long", | ||
| "char", | ||
| "byte", | ||
| "short", | ||
| "null", | ||
| "true", | ||
| "false", | ||
| "this", | ||
| "super", | ||
| // java.lang classes (auto-imported) | ||
| "Object", | ||
| "Class", | ||
| "String", | ||
| "CharSequence", | ||
| "StringBuilder", | ||
| "StringBuffer", | ||
| "System", | ||
| "Runtime", | ||
| "Process", | ||
| "Thread", | ||
| "ThreadGroup", | ||
| "Runnable", | ||
| "ThreadLocal", | ||
| "Math", | ||
| "StrictMath", | ||
| "Number", | ||
| "Byte", | ||
| "Short", | ||
| "Integer", | ||
| "Long", | ||
| "Float", | ||
| "Double", | ||
| "Boolean", | ||
| "Character", | ||
| "Void", | ||
| "Throwable", | ||
| "Error", | ||
| "Exception", | ||
| "RuntimeException", | ||
| "IllegalArgumentException", | ||
| "NullPointerException", | ||
| "IndexOutOfBoundsException", | ||
| "UnsupportedOperationException", | ||
| "IllegalStateException", | ||
| "ClassCastException", | ||
| "ArithmeticException", | ||
| "ArrayIndexOutOfBoundsException", | ||
| "StackOverflowError", | ||
| "OutOfMemoryError", | ||
| "ClassNotFoundException", | ||
| "NoSuchMethodException", | ||
| "SecurityException", | ||
| "ConcurrentModificationException", | ||
| "Iterable", | ||
| "Cloneable", | ||
| "Comparable", | ||
| "AutoCloseable", | ||
| "Serializable", | ||
| "Enum", | ||
| "Annotation", | ||
| "Override", | ||
| "Deprecated", | ||
| "SuppressWarnings", | ||
| "FunctionalInterface", | ||
| // java.util classes & collections (universally known) | ||
| "List", | ||
| "ArrayList", | ||
| "LinkedList", | ||
| "Map", | ||
| "HashMap", | ||
| "TreeMap", | ||
| "LinkedHashMap", | ||
| "Set", | ||
| "HashSet", | ||
| "TreeSet", | ||
| "LinkedHashSet", | ||
| "Queue", | ||
| "Deque", | ||
| "ArrayDeque", | ||
| "PriorityQueue", | ||
| "Iterator", | ||
| "ListIterator", | ||
| "Collections", | ||
| "Arrays", | ||
| "Optional", | ||
| "UUID", | ||
| "Objects", | ||
| "Date", | ||
| "Calendar", | ||
| "Locale", | ||
| "Scanner", | ||
| "Formatter", | ||
| "Properties", | ||
| "ConcurrentHashMap", | ||
| "CopyOnWriteArrayList", | ||
| "Vector", | ||
| "Stack", | ||
| "Hashtable", | ||
| "Stream", | ||
| "Collectors", | ||
| "Predicate", | ||
| "Function", | ||
| "Consumer", | ||
| "Supplier", | ||
| "BiFunction", | ||
| "BiConsumer", | ||
| // Common instance methods that should never be graph nodes | ||
| "print", | ||
| "println", | ||
| "printf", | ||
| "format", | ||
| "equals", | ||
| "hashCode", | ||
| "toString", | ||
| "clone", | ||
| "finalize", | ||
| "wait", | ||
| "notify", | ||
| "notifyAll", | ||
| "getClass", | ||
| "compareTo", | ||
| "iterator", | ||
| "size", | ||
| "isEmpty", | ||
| "contains", | ||
| "containsKey", | ||
| "containsValue", | ||
| "get", | ||
| "put", | ||
| "remove", | ||
| "clear", | ||
| "add", | ||
| "addAll", | ||
| "removeAll", | ||
| "retainAll", | ||
| "toArray", | ||
| "stream", | ||
| "of", | ||
| "values", | ||
| "keySet", | ||
| "entrySet", | ||
| "length", | ||
| "charAt", | ||
| "substring", | ||
| "indexOf", | ||
| "lastIndexOf", | ||
| "trim", | ||
| "split", | ||
| "replace", | ||
| "replaceAll", | ||
| "startsWith", | ||
| "endsWith", | ||
| "contains", | ||
| "toLowerCase", | ||
| "toUpperCase", | ||
| "matches", | ||
| "join", | ||
| "append", | ||
| "insert", | ||
| "delete", | ||
| "reverse", | ||
| "parseInt", | ||
| "parseDouble", | ||
| "parseLong", | ||
| "parseFloat", | ||
| "valueOf", | ||
| "abs", | ||
| "min", | ||
| "max", | ||
| "round", | ||
| "ceil", | ||
| "floor", | ||
| "sqrt", | ||
| "pow", | ||
| "random", | ||
| "close", | ||
| "read", | ||
| "write", | ||
| "flush", | ||
| "available", | ||
| "run", | ||
| "start", | ||
| "interrupt", | ||
| "sleep", | ||
| "yield", | ||
| "join", | ||
| "currentTimeMillis", | ||
| "nanoTime", | ||
| "gc", | ||
| "exit", | ||
| "getProperty", | ||
| "setProperty", | ||
| "getName", | ||
| "setName", | ||
| "getId", | ||
| "getType", | ||
| "getValue", | ||
| "setValue" | ||
| ]); | ||
| var javaSourceDirsCache = null; | ||
| function findJavaSourceDirs(workspaceRoot) { | ||
| if (javaSourceDirsCache) return javaSourceDirsCache; | ||
| const sourceDirs = []; | ||
| const scanDir = (dir, depth) => { | ||
| if (depth > 4) return; | ||
| try { | ||
| const entries = fs.readdirSync(dir, { withFileTypes: true }); | ||
| const hasJavaSrc = entries.find((e) => e.isDirectory() && e.name === "src"); | ||
| if (hasJavaSrc) { | ||
| const mainJava = path.join(dir, "src", "main", "java"); | ||
| if (fs.existsSync(mainJava)) { | ||
| sourceDirs.push(mainJava); | ||
| } | ||
| const src = path.join(dir, "src"); | ||
| if (fs.existsSync(src)) { | ||
| sourceDirs.push(src); | ||
| } | ||
| } | ||
| for (const entry of entries) { | ||
| if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules" && entry.name !== "build" && entry.name !== "target") { | ||
| scanDir(path.join(dir, entry.name), depth + 1); | ||
| } | ||
| } | ||
| } catch { | ||
| } | ||
| }; | ||
| scanDir(workspaceRoot, 0); | ||
| sourceDirs.push(path.join(workspaceRoot, "src", "main", "java")); | ||
| sourceDirs.push(path.join(workspaceRoot, "src")); | ||
| sourceDirs.push(workspaceRoot); | ||
| javaSourceDirsCache = [...new Set(sourceDirs)]; | ||
| return javaSourceDirsCache; | ||
| } | ||
| function findNearestJavaDescriptor(dir) { | ||
| let current = dir; | ||
| while (true) { | ||
| for (const name of ["pom.xml", "build.gradle", "settings.gradle"]) { | ||
| const candidate = path.join(current, name); | ||
| if (fs.existsSync(candidate)) return candidate; | ||
| } | ||
| const parent = path.dirname(current); | ||
| if (parent === current) break; | ||
| current = parent; | ||
| } | ||
| return ""; | ||
| } | ||
| function resolveJavaImport(importPath) { | ||
| const relativePart = importPath.replace(/\./g, "/"); | ||
| const workspaceRoot = process.cwd(); | ||
| const sourceDirs = findJavaSourceDirs(workspaceRoot); | ||
| for (const srcDir of sourceDirs) { | ||
| const candidate = path.join(srcDir, relativePart + ".java"); | ||
| if (fs.existsSync(candidate)) return candidate; | ||
| } | ||
| const className = importPath.split(".").pop() || importPath; | ||
| if (className && className !== "") { | ||
| const findClassFile = (dir, depth) => { | ||
| if (depth > 4) return null; | ||
| try { | ||
| const entries = fs.readdirSync(dir, { withFileTypes: true }); | ||
| for (const entry of entries) { | ||
| if (entry.isFile() && entry.name === className + ".java") { | ||
| return path.join(dir, entry.name); | ||
| } | ||
| if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules" && entry.name !== "build" && entry.name !== "target") { | ||
| const found = findClassFile(path.join(dir, entry.name), depth + 1); | ||
| if (found) return found; | ||
| } | ||
| } | ||
| } catch { | ||
| } | ||
| return null; | ||
| }; | ||
| return findClassFile(workspaceRoot, 0); | ||
| } | ||
| return null; | ||
| } | ||
| function extractJavadoc(node) { | ||
| let prev = node.previousSibling; | ||
| while (prev && (prev.type === "modifiers" || prev.type === "annotation" || prev.type === "marker_annotation")) { | ||
| prev = prev.previousSibling; | ||
| } | ||
| if (prev && prev.type === "block_comment" && prev.text.startsWith("/**")) { | ||
| return prev.text.replace(/^\/\*\*|\*\/$/g, "").split("\n").map((l) => l.replace(/^\s\*\/|^\s*\*\s?|^\s*/, "").trim()).filter((l) => l).join("\n"); | ||
| } | ||
| return void 0; | ||
| } | ||
| function readSourceFile(filePath) { | ||
| const buffer = fs.readFileSync(filePath); | ||
| if (buffer.length >= 2 && buffer[0] === 255 && buffer[1] === 254) { | ||
| return buffer.toString("utf16le").replace(/^\uFEFF/, ""); | ||
| } | ||
| if (buffer.length >= 2 && buffer[0] === 254 && buffer[1] === 255) { | ||
| const swapped = Buffer.alloc(buffer.length); | ||
| for (let i = 0; i < buffer.length - 1; i += 2) { | ||
| swapped[i] = buffer[i + 1]; | ||
| swapped[i + 1] = buffer[i]; | ||
| } | ||
| return swapped.toString("utf16le").replace(/^\uFEFF/, ""); | ||
| } | ||
| let content = buffer.toString("utf8"); | ||
| if (content.startsWith("\uFEFF")) { | ||
| content = content.slice(1); | ||
| } | ||
| return content.replace(/\0/g, ""); | ||
| } | ||
| function isLocalDeclaration(startNode, name) { | ||
| let current = startNode; | ||
| const checkPattern = (patternNode) => { | ||
| if (patternNode.type === "identifier") { | ||
| return patternNode.text.trim() === name; | ||
| } | ||
| for (let i = 0; i < patternNode.namedChildCount; i++) { | ||
| const child = patternNode.namedChild(i); | ||
| if (child && checkPattern(child)) return true; | ||
| } | ||
| return false; | ||
| }; | ||
| while (current) { | ||
| if (current.type === "method_declaration" || current.type === "constructor_declaration" || current.type === "lambda_expression") { | ||
| const params = current.childForFieldName("parameters") || current.namedChildren.find((c) => c.type === "formal_parameters"); | ||
| if (params && checkPattern(params)) return true; | ||
| } | ||
| if (current.type === "catch_clause") { | ||
| const param = current.childForFieldName("parameter") || current.namedChildren.find((c) => c.type === "catch_formal_parameter"); | ||
| if (param && checkPattern(param)) return true; | ||
| } | ||
| if (current.type === "for_statement" || current.type === "enhanced_for_statement") { | ||
| const findLoopVars = (n) => { | ||
| if (n.type === "local_variable_declaration" || n.type === "variable_declarator") { | ||
| const nameNode = n.childForFieldName("name") || n.namedChild(0); | ||
| if (nameNode && checkPattern(nameNode)) return true; | ||
| } | ||
| for (let i = 0; i < n.namedChildCount; i++) { | ||
| const child = n.namedChild(i); | ||
| if (child && findLoopVars(child)) return true; | ||
| } | ||
| return false; | ||
| }; | ||
| if (findLoopVars(current)) return true; | ||
| } | ||
| if (current.type === "block") { | ||
| for (let i = 0; i < current.namedChildCount; i++) { | ||
| const statement = current.namedChild(i); | ||
| if (!statement) continue; | ||
| if (statement.type === "local_variable_declaration") { | ||
| const findVars = (n) => { | ||
| if (n.type === "variable_declarator") { | ||
| const nameNode = n.childForFieldName("name") || n.namedChild(0); | ||
| if (nameNode && checkPattern(nameNode)) return true; | ||
| } | ||
| for (let j = 0; j < n.namedChildCount; j++) { | ||
| const child = n.namedChild(j); | ||
| if (child && findVars(child)) return true; | ||
| } | ||
| return false; | ||
| }; | ||
| if (findVars(statement)) { | ||
| if (statement.startIndex <= (startNode?.startIndex ?? 0)) return true; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| current = current.parent; | ||
| } | ||
| return false; | ||
| } | ||
| function parseJava(filePath, graph, language) { | ||
| const parser = new Parser(); | ||
| try { | ||
| parser.setLanguage(language); | ||
| } catch { | ||
| return; | ||
| } | ||
| let sourceCode; | ||
| try { | ||
| sourceCode = readSourceFile(filePath); | ||
| } catch { | ||
| return; | ||
| } | ||
| const tree = parser.parse(sourceCode); | ||
| if (!tree) return; | ||
| const localDefinitions = /* @__PURE__ */ new Set(); | ||
| const importMap = /* @__PURE__ */ new Map(); | ||
| const localMethodMap = /* @__PURE__ */ new Map(); | ||
| const queryString = ` | ||
| (import_declaration (scoped_identifier) @import_source) | ||
| (import_declaration (identifier) @import_source) | ||
| (class_declaration name: (identifier) @class_decl) | ||
| (interface_declaration name: (identifier) @interface_decl) | ||
| (enum_declaration name: (identifier) @enum_decl) | ||
| (annotation_type_declaration name: (identifier) @annotation_decl) | ||
| (method_declaration name: (identifier) @func_decl) | ||
| (constructor_declaration name: (identifier) @func_decl) | ||
| (method_invocation object: (identifier) @method_object name: (identifier) @method_call) | ||
| (method_invocation name: (identifier) @bare_method_call) | ||
| (object_creation_expression type: (type_identifier) @constructor_call) | ||
| (field_declaration type: (type_identifier) @type_reference) | ||
| (formal_parameter type: (type_identifier) @type_reference) | ||
| (local_variable_declaration type: (type_identifier) @type_reference) | ||
| `; | ||
| const query = language.query(queryString); | ||
| const matches = query.matches(tree.rootNode); | ||
| const getEnclosingScopePath = (startNode) => { | ||
| const pathParts = []; | ||
| let current = startNode; | ||
| while (current) { | ||
| if (current.type === "class_declaration" || current.type === "interface_declaration" || current.type === "enum_declaration" || current.type === "annotation_type_declaration" || current.type === "method_declaration" || current.type === "constructor_declaration") { | ||
| const nameNode = current.childForFieldName("name"); | ||
| if (nameNode) { | ||
| pathParts.unshift(nameNode.text.trim()); | ||
| } | ||
| } | ||
| current = current.parent; | ||
| } | ||
| return pathParts.join("."); | ||
| }; | ||
| for (const match of matches) { | ||
| for (const capture of match.captures) { | ||
| const node = capture.node; | ||
| const captureName = capture.name; | ||
| if (captureName === "import_source") { | ||
| const importPath = node.text.trim(); | ||
| const className = importPath.split(".").pop() || importPath; | ||
| if (className && className !== "") { | ||
| importMap.set(className, importPath); | ||
| } | ||
| } else if (captureName === "class_decl" || captureName === "interface_decl" || captureName === "enum_decl" || captureName === "annotation_decl" || captureName === "func_decl") { | ||
| const symName = node.text.trim(); | ||
| if (symName) { | ||
| localDefinitions.add(symName); | ||
| const scopePrefix = getEnclosingScopePath(node.parent?.parent); | ||
| const symId = scopePrefix ? `${filePath}::${scopePrefix}.${symName}` : `${filePath}::${symName}`; | ||
| if (captureName === "func_decl") { | ||
| if (!localMethodMap.has(symName)) { | ||
| localMethodMap.set(symName, []); | ||
| } | ||
| localMethodMap.get(symName).push(symId); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| for (const match of matches) { | ||
| for (const capture of match.captures) { | ||
| const node = capture.node; | ||
| const captureName = capture.name; | ||
| if (captureName === "class_decl" || captureName === "interface_decl" || captureName === "enum_decl" || captureName === "annotation_decl" || captureName === "func_decl") { | ||
| const symName = node.text.trim(); | ||
| if (!symName) continue; | ||
| const scopePrefix = getEnclosingScopePath(node.parent?.parent); | ||
| const symId = scopePrefix ? `${filePath}::${scopePrefix}.${symName}` : `${filePath}::${symName}`; | ||
| const parentId = scopePrefix ? `${filePath}::${scopePrefix}` : filePath; | ||
| let nodeType = "function"; | ||
| if (captureName === "class_decl") nodeType = "class"; | ||
| else if (captureName === "interface_decl") nodeType = "interface"; | ||
| else if (captureName === "enum_decl") nodeType = "enum"; | ||
| const decl = node.parent || node; | ||
| const doc = extractJavadoc(decl); | ||
| const nodeAttrs = { | ||
| type: nodeType, | ||
| name: symName, | ||
| file: filePath, | ||
| startLine: decl.startPosition.row + 1, | ||
| metadata: { | ||
| endLine: decl.endPosition.row + 1, | ||
| doc, | ||
| external: false, | ||
| unresolved: false | ||
| } | ||
| }; | ||
| if (!graph.hasNode(symId)) { | ||
| graph.addNode(symId, nodeAttrs); | ||
| graph.addEdge(parentId, symId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } else { | ||
| graph.mergeNodeAttributes(symId, nodeAttrs); | ||
| if (!graph.hasEdge(parentId, symId)) { | ||
| graph.addEdge(parentId, symId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } | ||
| } | ||
| } else if (captureName === "import_source") { | ||
| const importPath = node.text.trim(); | ||
| if (importPath === "" || importPath === "." || importPath === "*") continue; | ||
| const resolvedPath = resolveJavaImport(importPath); | ||
| if (!resolvedPath && isJavaStdlib(importPath)) continue; | ||
| const importNodeId = resolvedPath || `import::${importPath}`; | ||
| if (importNodeId === "import::" || importNodeId.trim() === "") continue; | ||
| if (!graph.hasNode(importNodeId)) { | ||
| const nearestDescriptor = findNearestJavaDescriptor(path.dirname(filePath)); | ||
| graph.addNode(importNodeId, { | ||
| type: "file", | ||
| name: resolvedPath ? path.basename(resolvedPath) : importPath, | ||
| file: resolvedPath || nearestDescriptor || filePath, | ||
| startLine: 0, | ||
| metadata: resolvedPath ? {} : { external: true } | ||
| }); | ||
| } | ||
| if (!graph.hasEdge(filePath, importNodeId)) { | ||
| graph.addEdge(filePath, importNodeId, { type: "imports", confidence: "EXTRACTED" }); | ||
| } | ||
| } else if (captureName === "bare_method_call" || captureName === "constructor_call") { | ||
| const calledName = node.text.trim(); | ||
| if (!calledName || JAVA_BUILT_INS.has(calledName)) continue; | ||
| if (isLocalDeclaration(node, calledName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| const targets = []; | ||
| const importSource = importMap.get(calledName); | ||
| if (importSource) { | ||
| const resolvedSource = resolveJavaImport(importSource); | ||
| const targetBase = resolvedSource || `import::${importSource}`; | ||
| targets.push({ | ||
| id: `${targetBase}::${calledName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (localDefinitions.has(calledName)) { | ||
| const scopePrefix = getEnclosingScopePath(node.parent?.parent); | ||
| targets.push({ | ||
| id: scopePrefix ? `${filePath}::${scopePrefix}.${calledName}` : `${filePath}::${calledName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else { | ||
| targets.push({ | ||
| id: `unresolved::${calledName}`, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| for (const target of targets) { | ||
| const isUnresolved = target.id.startsWith("unresolved::"); | ||
| const isExternal = target.id.startsWith("import::") || !isUnresolved && !target.id.startsWith(filePath); | ||
| const callLine = node.startPosition.row + 1; | ||
| if (!graph.hasNode(target.id)) { | ||
| let fileAttr = filePath; | ||
| if (isUnresolved) { | ||
| fileAttr = filePath; | ||
| } else if (target.id.includes("::")) { | ||
| fileAttr = target.id.split("::")[0] || filePath; | ||
| } | ||
| graph.addNode(target.id, { | ||
| type: captureName === "constructor_call" ? "class" : "function", | ||
| name: calledName, | ||
| file: fileAttr, | ||
| startLine: isExternal ? 0 : callLine, | ||
| metadata: { | ||
| external: isExternal, | ||
| unresolved: isUnresolved, | ||
| endLine: isExternal ? 0 : callLine, | ||
| callerFile: isUnresolved ? filePath : void 0, | ||
| callerLine: isUnresolved ? callLine : void 0 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === target.id) continue; | ||
| if (!graph.hasEdge(callerId, target.id)) { | ||
| graph.addEdge(callerId, target.id, { type: "calls", confidence: target.confidence }); | ||
| } | ||
| } | ||
| } else if (captureName === "method_call") { | ||
| const methodName = node.text.trim(); | ||
| if (!methodName || JAVA_BUILT_INS.has(methodName)) continue; | ||
| let objectName = ""; | ||
| if (node.parent && node.parent.type === "method_invocation") { | ||
| const objectNode = node.parent.childForFieldName("object"); | ||
| if (objectNode) objectName = objectNode.text.trim(); | ||
| } | ||
| if (objectName && JAVA_STDLIB_PACKAGES.has(objectName)) continue; | ||
| if (isLocalDeclaration(node, objectName ? `${objectName}.${methodName}` : methodName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| const targets = []; | ||
| const importSource = importMap.get(objectName); | ||
| if (importSource) { | ||
| const resolvedSource = resolveJavaImport(importSource); | ||
| if (!resolvedSource && isJavaStdlib(importSource)) continue; | ||
| const targetBase = resolvedSource || `import::${importSource}`; | ||
| targets.push({ | ||
| id: `${targetBase}::${methodName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else { | ||
| const localMatches = localMethodMap.get(methodName) || []; | ||
| if (localMatches.length === 1 && localMatches[0]) { | ||
| targets.push({ | ||
| id: localMatches[0], | ||
| confidence: "INFERRED" | ||
| }); | ||
| } else if (localMatches.length > 1) { | ||
| for (const matchId of localMatches) { | ||
| targets.push({ | ||
| id: matchId, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } else { | ||
| targets.push({ | ||
| id: `unresolved::${methodName}`, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } | ||
| for (const target of targets) { | ||
| const isUnresolved = target.id.startsWith("unresolved::"); | ||
| const isExternal = target.id.startsWith("import::") || !isUnresolved && !target.id.startsWith(filePath); | ||
| const callLine = node.startPosition.row + 1; | ||
| if (!graph.hasNode(target.id)) { | ||
| let fileAttr = filePath; | ||
| if (isUnresolved) { | ||
| fileAttr = filePath; | ||
| } else if (target.id.includes("::")) { | ||
| fileAttr = target.id.split("::")[0] || filePath; | ||
| } | ||
| graph.addNode(target.id, { | ||
| type: "function", | ||
| name: objectName ? `${objectName}.${methodName}` : methodName, | ||
| file: fileAttr, | ||
| startLine: isExternal ? 0 : callLine, | ||
| metadata: { | ||
| external: isExternal, | ||
| unresolved: isUnresolved, | ||
| endLine: isExternal ? 0 : callLine, | ||
| callerFile: isUnresolved ? filePath : void 0, | ||
| callerLine: isUnresolved ? callLine : void 0 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === target.id) continue; | ||
| if (!graph.hasEdge(callerId, target.id)) { | ||
| graph.addEdge(callerId, target.id, { type: "calls", confidence: target.confidence }); | ||
| } | ||
| } | ||
| } else if (captureName === "type_reference") { | ||
| const referencedTypeName = node.text.trim(); | ||
| if (!referencedTypeName || JAVA_BUILT_INS.has(referencedTypeName)) continue; | ||
| if (isLocalDeclaration(node, referencedTypeName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| if (!graph.hasNode(callerId)) continue; | ||
| let targetId; | ||
| const importSource = importMap.get(referencedTypeName); | ||
| if (localDefinitions.has(referencedTypeName)) { | ||
| targetId = `${filePath}::${referencedTypeName}`; | ||
| } else if (importSource) { | ||
| const resolvedSource = resolveJavaImport(importSource) || importSource; | ||
| if (!resolvedSource && isJavaStdlib(importSource)) continue; | ||
| targetId = `${resolvedSource}::${referencedTypeName}`; | ||
| } else { | ||
| targetId = `unresolved::${referencedTypeName}`; | ||
| } | ||
| const isUnresolved = !localDefinitions.has(referencedTypeName) && !importSource; | ||
| if (!graph.hasNode(targetId)) { | ||
| graph.addNode(targetId, { | ||
| type: "class", | ||
| name: referencedTypeName, | ||
| file: filePath, | ||
| startLine: node.startPosition.row + 1, | ||
| metadata: { | ||
| external: !!importSource, | ||
| unresolved: isUnresolved, | ||
| endLine: node.startPosition.row + 1, | ||
| callerFile: filePath, | ||
| callerLine: node.startPosition.row + 1 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === targetId) continue; | ||
| if (!graph.hasEdge(callerId, targetId)) { | ||
| graph.addEdge(callerId, targetId, { type: "references", confidence: isUnresolved ? "AMBIGUOUS" : "EXTRACTED" }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| export { | ||
| parseJava | ||
| }; |
| // src/parsers/json.ts | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| function parseJson(filePath, graph) { | ||
| const content = fs.readFileSync(filePath, "utf8"); | ||
| let keyCount = 0; | ||
| const fileName = path.basename(filePath); | ||
| let deps = {}; | ||
| try { | ||
| const data = JSON.parse(content); | ||
| if (typeof data === "object" && data !== null) { | ||
| keyCount = Object.keys(data).length; | ||
| if (fileName === "package.json") { | ||
| deps = { ...data.dependencies || {}, ...data.devDependencies || {} }; | ||
| } | ||
| } | ||
| } catch { | ||
| } | ||
| graph.mergeNodeAttributes(filePath, { | ||
| metadata: { | ||
| extension: ".json", | ||
| keyCount | ||
| } | ||
| }); | ||
| for (const dep of Object.keys(deps)) { | ||
| const depNodeId = `import::${dep}`; | ||
| if (!graph.hasNode(depNodeId)) { | ||
| graph.addNode(depNodeId, { | ||
| type: "file", | ||
| name: dep, | ||
| file: filePath, | ||
| startLine: 0, | ||
| metadata: { external: true, extension: ".json" } | ||
| }); | ||
| } | ||
| graph.addEdge(filePath, depNodeId, { | ||
| type: "imports", | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } | ||
| } | ||
| export { | ||
| parseJson | ||
| }; |
| // src/parsers/markdown.ts | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| function parseMarkdown(filePath, graph) { | ||
| const content = fs.readFileSync(filePath, "utf8"); | ||
| const wordCount = content.split(/\s+/).filter((w) => w.length > 0).length; | ||
| graph.mergeNodeAttributes(filePath, { | ||
| metadata: { | ||
| extension: ".md", | ||
| wordCount | ||
| } | ||
| }); | ||
| const fileRefs = /* @__PURE__ */ new Set(); | ||
| const linkRegex = /\[.*?\]\(([^)]+)\)/g; | ||
| let match; | ||
| while ((match = linkRegex.exec(content)) !== null) { | ||
| if (match[1]) fileRefs.add(match[1]); | ||
| } | ||
| const codeRegex = /`([^`]+)`/g; | ||
| while ((match = codeRegex.exec(content)) !== null) { | ||
| if (match[1] && match[1].includes(".") && !match[1].includes(" ")) { | ||
| fileRefs.add(match[1]); | ||
| } | ||
| } | ||
| const dir = path.dirname(filePath); | ||
| for (const ref of fileRefs) { | ||
| let resolvedPath = ""; | ||
| if (ref.startsWith("./") || ref.startsWith("../")) { | ||
| resolvedPath = path.resolve(dir, ref); | ||
| } else { | ||
| resolvedPath = path.resolve(process.cwd(), ref); | ||
| } | ||
| if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isFile()) { | ||
| if (!graph.hasNode(resolvedPath)) { | ||
| graph.addNode(resolvedPath, { | ||
| type: "file", | ||
| name: path.basename(resolvedPath), | ||
| file: resolvedPath, | ||
| startLine: 0, | ||
| metadata: { extension: path.extname(resolvedPath) } | ||
| }); | ||
| } | ||
| graph.addEdge(filePath, resolvedPath, { | ||
| type: "explains", | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| export { | ||
| parseMarkdown | ||
| }; |
| // src/core/mcp.ts | ||
| import { Server } from "@modelcontextprotocol/sdk/server/index.js"; | ||
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| import { | ||
| CallToolRequestSchema, | ||
| ListToolsRequestSchema, | ||
| ListResourcesRequestSchema, | ||
| ReadResourceRequestSchema | ||
| } from "@modelcontextprotocol/sdk/types.js"; | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| async function runMcpServer(graph, targetDir) { | ||
| const server = new Server( | ||
| { | ||
| name: "geraph", | ||
| version: "1.2.0" | ||
| }, | ||
| { | ||
| capabilities: { | ||
| tools: {}, | ||
| resources: {} | ||
| } | ||
| } | ||
| ); | ||
| server.setRequestHandler(ListToolsRequestSchema, async () => { | ||
| return { | ||
| tools: [ | ||
| { | ||
| name: "search_graph", | ||
| description: "Search for nodes in the knowledge graph by partial name. Useful to find exact node IDs. Supports pagination. You can also search for a file by its path (e.g., 'src/auth.ts') using type 'file', because node IDs contain file paths. (CLI Alternative: 'geraph search <term>')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| name: { | ||
| type: "string", | ||
| description: "The partial name to search for (e.g. 'auth')" | ||
| }, | ||
| type: { | ||
| type: "string", | ||
| description: "Optional filter by node type (e.g. 'function', 'class')" | ||
| }, | ||
| page: { | ||
| type: "number", | ||
| description: "Page number for pagination (default: 1)" | ||
| }, | ||
| limit: { | ||
| type: "number", | ||
| description: "Number of results per page (default: 20)" | ||
| } | ||
| }, | ||
| required: ["name"] | ||
| } | ||
| }, | ||
| { | ||
| name: "get_node", | ||
| description: "Get detailed metadata for a specific node by its exact ID or fuzzy symbol name. (CLI Alternative: 'geraph node <symbol>')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| symbol: { | ||
| type: "string", | ||
| description: "The exact node ID or symbol name" | ||
| }, | ||
| type: { | ||
| type: "string", | ||
| description: "Optional filter by node type (e.g., 'interface', 'function')" | ||
| }, | ||
| source: { | ||
| type: "string", | ||
| description: "Optional filter by source file path (e.g., 'auth.ts')" | ||
| } | ||
| }, | ||
| required: ["symbol"] | ||
| } | ||
| }, | ||
| { | ||
| name: "get_neighbors", | ||
| description: "Get all incoming and outgoing edges for a specific node to trace its direct dependencies. Supports pagination. (CLI Alternative: 'geraph neighbors <symbol>')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| symbol: { | ||
| type: "string", | ||
| description: "The exact node ID or symbol name" | ||
| }, | ||
| type: { | ||
| type: "string", | ||
| description: "Optional filter by node type (e.g., 'interface', 'function')" | ||
| }, | ||
| source: { | ||
| type: "string", | ||
| description: "Optional filter by source file path (e.g., 'auth.ts')" | ||
| }, | ||
| page: { | ||
| type: "number", | ||
| description: "Page number for pagination (default: 1)" | ||
| }, | ||
| limit: { | ||
| type: "number", | ||
| description: "Number of edges per direction per page (default: 20)" | ||
| } | ||
| }, | ||
| required: ["symbol"] | ||
| } | ||
| }, | ||
| { | ||
| name: "shortest_path", | ||
| description: "Find the shortest sequence of edges connecting two nodes using fuzzy symbol/ID lookup. (CLI Alternative: 'geraph path <source> <target>')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| source: { | ||
| type: "string", | ||
| description: "The fuzzy starting node ID or symbol name" | ||
| }, | ||
| target: { | ||
| type: "string", | ||
| description: "The fuzzy destination node ID or symbol name" | ||
| }, | ||
| max_hops: { | ||
| type: "number", | ||
| description: "Maximum hops to consider (default: 8)" | ||
| } | ||
| }, | ||
| required: ["source", "target"] | ||
| } | ||
| }, | ||
| { | ||
| name: "god_nodes", | ||
| description: "Return the most connected nodes \u2014 the core architectural pillars of the codebase. Supports pagination. (CLI Alternative: 'geraph god')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| page: { | ||
| type: "number", | ||
| description: "Page number for pagination (default: 1)" | ||
| }, | ||
| limit: { | ||
| type: "number", | ||
| description: "Number of results per page (default: 10)" | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| { | ||
| name: "get_community", | ||
| description: "Get all nodes in a community by community ID. Supports pagination. (CLI Alternative: 'geraph community <id>')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| community_id: { | ||
| type: "number", | ||
| description: "Community ID (0-indexed by size)" | ||
| }, | ||
| page: { | ||
| type: "number", | ||
| description: "Page number for pagination (default: 1)" | ||
| }, | ||
| limit: { | ||
| type: "number", | ||
| description: "Number of results per page (default: 20)" | ||
| } | ||
| }, | ||
| required: ["community_id"] | ||
| } | ||
| }, | ||
| { | ||
| name: "get_surprises", | ||
| description: "Discover surprising cross-community couplings that link otherwise independent modules. Supports pagination. (CLI Alternative: 'geraph surprises')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| page: { | ||
| type: "number", | ||
| description: "Page number for pagination (default: 1)" | ||
| }, | ||
| limit: { | ||
| type: "number", | ||
| description: "Number of results per page (default: 20)" | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| { | ||
| name: "query_graph", | ||
| description: "Search the AST graph using BFS or DFS traversal. Returns a compact context representation. Supports natural language questions or keywords. (CLI Alternative: 'geraph query <symbol-or-question>')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| symbol: { | ||
| type: "string", | ||
| description: "Fuzzy starting symbol or node ID, or natural language question" | ||
| }, | ||
| question: { | ||
| type: "string", | ||
| description: "Natural language question or keywords (for Graphify parity)" | ||
| }, | ||
| mode: { | ||
| type: "string", | ||
| enum: ["bfs", "dfs"], | ||
| default: "bfs", | ||
| description: "Traversal mode: bfs (breadth) or dfs (depth)" | ||
| }, | ||
| depth: { | ||
| type: "number", | ||
| default: 3, | ||
| description: "Traversal depth limit" | ||
| }, | ||
| token_budget: { | ||
| type: "number", | ||
| default: 2e3, | ||
| description: "Estimated output token limit" | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| { | ||
| name: "graph_stats", | ||
| description: "Return summary statistics of the graph: node count, edge count, community count, and extraction confidence percentage breakdown. (CLI Alternative: 'geraph stats')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: {} | ||
| } | ||
| }, | ||
| { | ||
| name: "scan_graph", | ||
| description: "Triggers a full rebuild of the Geraph AST graph. Use this after making significant code modifications or pushing git commits to ensure your structural memory is up-to-date. (CLI Alternative: 'geraph scan')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| force: { | ||
| type: "boolean", | ||
| description: "If true, fully ignore and rebuild all cache files (doing a clean scan from scratch)" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ] | ||
| }; | ||
| }); | ||
| server.setRequestHandler(CallToolRequestSchema, async (request) => { | ||
| const { name, arguments: args } = request.params; | ||
| if (!args) { | ||
| throw new Error("Arguments are required"); | ||
| } | ||
| try { | ||
| if (name === "search_graph") { | ||
| const queryName = args.name; | ||
| const typeFilter = args.type; | ||
| const page = args.page; | ||
| const limit = args.limit; | ||
| const { searchGraph } = await import("./query-D3OHFKXD.js"); | ||
| const matches = await searchGraph( | ||
| graph, | ||
| queryName, | ||
| typeFilter, | ||
| page, | ||
| limit | ||
| ); | ||
| return { | ||
| content: [{ type: "text", text: JSON.stringify(matches, null, 2) }] | ||
| }; | ||
| } | ||
| if (name === "get_node") { | ||
| const symbol = args.symbol; | ||
| const typeFilter = args.type; | ||
| const sourceFilter = args.source; | ||
| const { getNode } = await import("./query-D3OHFKXD.js"); | ||
| try { | ||
| const result = await getNode(graph, symbol, typeFilter, sourceFilter); | ||
| return { | ||
| content: [{ type: "text", text: JSON.stringify(result, null, 2) }] | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: JSON.stringify({ | ||
| error: error instanceof Error ? error.message : String(error) | ||
| }) | ||
| } | ||
| ], | ||
| isError: true | ||
| }; | ||
| } | ||
| } | ||
| if (name === "get_neighbors") { | ||
| const symbol = args.symbol; | ||
| const typeFilter = args.type; | ||
| const sourceFilter = args.source; | ||
| const page = args.page; | ||
| const limit = args.limit; | ||
| const { getNeighbors } = await import("./query-D3OHFKXD.js"); | ||
| try { | ||
| const result = await getNeighbors( | ||
| graph, | ||
| symbol, | ||
| typeFilter, | ||
| sourceFilter, | ||
| page, | ||
| limit | ||
| ); | ||
| return { | ||
| content: [{ type: "text", text: JSON.stringify(result, null, 2) }] | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: JSON.stringify({ | ||
| error: error instanceof Error ? error.message : String(error) | ||
| }) | ||
| } | ||
| ], | ||
| isError: true | ||
| }; | ||
| } | ||
| } | ||
| if (name === "shortest_path") { | ||
| const source = args.source; | ||
| const target = args.target; | ||
| const maxHops = args.max_hops !== void 0 ? Number(args.max_hops) : 8; | ||
| const { shortestPath } = await import("./query-D3OHFKXD.js"); | ||
| try { | ||
| const result = await shortestPath(graph, source, target, maxHops); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: result | ||
| } | ||
| ] | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: JSON.stringify({ | ||
| error: error instanceof Error ? error.message : String(error) | ||
| }) | ||
| } | ||
| ], | ||
| isError: true | ||
| }; | ||
| } | ||
| } | ||
| if (name === "god_nodes") { | ||
| const page = args.page || 1; | ||
| const limit = args.limit || 10; | ||
| const { getGodNodes } = await import("./query-D3OHFKXD.js"); | ||
| try { | ||
| const result = await getGodNodes(graph, page, limit); | ||
| return { | ||
| content: [{ type: "text", text: result }] | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| content: [{ type: "text", text: String(error) }], | ||
| isError: true | ||
| }; | ||
| } | ||
| } | ||
| if (name === "get_community") { | ||
| const communityId = Number(args.community_id); | ||
| const page = args.page || 1; | ||
| const limit = args.limit || 20; | ||
| const { getCommunityNodes } = await import("./query-D3OHFKXD.js"); | ||
| try { | ||
| const result = await getCommunityNodes( | ||
| graph, | ||
| communityId, | ||
| page, | ||
| limit | ||
| ); | ||
| return { | ||
| content: [{ type: "text", text: result }] | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| content: [{ type: "text", text: String(error) }], | ||
| isError: true | ||
| }; | ||
| } | ||
| } | ||
| if (name === "get_surprises") { | ||
| const page = args.page || 1; | ||
| const limit = args.limit || 20; | ||
| const { getSurprisingConnections } = await import("./query-D3OHFKXD.js"); | ||
| try { | ||
| const result = await getSurprisingConnections(graph, page, limit); | ||
| return { | ||
| content: [{ type: "text", text: result }] | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| content: [{ type: "text", text: String(error) }], | ||
| isError: true | ||
| }; | ||
| } | ||
| } | ||
| if (name === "query_graph") { | ||
| const symbol = args.symbol || args.question; | ||
| if (!symbol) { | ||
| throw new Error("Either 'symbol' or 'question' parameter is required"); | ||
| } | ||
| const mode = args.mode || "bfs"; | ||
| const depth = Number(args.depth ?? 3); | ||
| const tokenBudget = Number(args.token_budget ?? 2e3); | ||
| const { queryGraph } = await import("./query-D3OHFKXD.js"); | ||
| try { | ||
| const result = await queryGraph( | ||
| graph, | ||
| symbol, | ||
| mode, | ||
| depth, | ||
| tokenBudget | ||
| ); | ||
| return { | ||
| content: [{ type: "text", text: result }] | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| content: [{ type: "text", text: String(error) }], | ||
| isError: true | ||
| }; | ||
| } | ||
| } | ||
| if (name === "graph_stats") { | ||
| const { getGraphStats } = await import("./query-D3OHFKXD.js"); | ||
| try { | ||
| const result = await getGraphStats(graph); | ||
| return { | ||
| content: [{ type: "text", text: result }] | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| content: [{ type: "text", text: String(error) }], | ||
| isError: true | ||
| }; | ||
| } | ||
| } | ||
| if (name === "scan_graph") { | ||
| try { | ||
| const { exec } = await import("child_process"); | ||
| const { promisify } = await import("util"); | ||
| const execAsync = promisify(exec); | ||
| const force = !!args.force; | ||
| const cmd = force ? "geraph scan --force" : "geraph scan"; | ||
| const { stdout, stderr } = await execAsync(cmd, { cwd: targetDir }); | ||
| const { loadGraph } = await import("./query-D3OHFKXD.js"); | ||
| const newGraph = loadGraph(targetDir); | ||
| graph.clear(); | ||
| newGraph.forEachNode((node, attr) => graph.addNode(node, attr)); | ||
| newGraph.forEachEdge( | ||
| (edge, attr, source, target) => graph.addEdgeWithKey(edge, source, target, attr) | ||
| ); | ||
| const stripAnsi = (str) => str.replace( | ||
| // eslint-disable-next-line no-control-regex | ||
| /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, | ||
| "" | ||
| ); | ||
| const cleanStdout = stripAnsi(stdout); | ||
| const cleanStderr = stripAnsi(stderr); | ||
| const lines = cleanStderr.split("\n").map((l) => l.trim()).filter(Boolean); | ||
| let filesParsedLine = ""; | ||
| const realWarnings = []; | ||
| const SPINNER_KEYWORDS = [ | ||
| "Scanning codebase in", | ||
| "Initializing Knowledge Graph", | ||
| "Resolving call graph", | ||
| "Extracting Temporal Facts", | ||
| "Analyzing graph structure", | ||
| "Compressing graph into Caveman" | ||
| ]; | ||
| for (const line of lines) { | ||
| if (line.includes("Successfully scanned and parsed")) { | ||
| filesParsedLine = line.replace(/^[^\w]+/, "").trim(); | ||
| } else if (SPINNER_KEYWORDS.some((kw) => line.includes(kw))) { | ||
| } else { | ||
| realWarnings.push(line); | ||
| } | ||
| } | ||
| let outputText = ""; | ||
| if (filesParsedLine) { | ||
| const displayLine = filesParsedLine.startsWith("Successfully") ? filesParsedLine : `Successfully ${filesParsedLine}`; | ||
| outputText += `${displayLine} | ||
| `; | ||
| } | ||
| outputText += cleanStdout.trim(); | ||
| if (realWarnings.length > 0) { | ||
| outputText += ` | ||
| Warnings/Errors: | ||
| ${realWarnings.join("\n")}`; | ||
| } | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: outputText.trim() || "Graph successfully scanned and memory updated." | ||
| } | ||
| ] | ||
| }; | ||
| } catch (error) { | ||
| const msg = error instanceof Error ? error.message : String(error); | ||
| return { | ||
| content: [{ type: "text", text: `Error scanning graph: ${msg}` }], | ||
| isError: true | ||
| }; | ||
| } | ||
| } | ||
| throw new Error(`Unknown tool: ${name}`); | ||
| } catch (error) { | ||
| const err = error; | ||
| return { | ||
| content: [ | ||
| { type: "text", text: JSON.stringify({ error: err.message }) } | ||
| ], | ||
| isError: true | ||
| }; | ||
| } | ||
| }); | ||
| server.setRequestHandler(ListResourcesRequestSchema, async () => { | ||
| return { | ||
| resources: [ | ||
| { | ||
| uri: "geraph://report", | ||
| name: "Graph Report", | ||
| description: "Full GRAPH_REPORT.md", | ||
| mimeType: "text/markdown" | ||
| }, | ||
| { | ||
| uri: "geraph://stats", | ||
| name: "Graph Stats", | ||
| description: "Node/edge/community counts and confidence breakdown", | ||
| mimeType: "text/plain" | ||
| }, | ||
| { | ||
| uri: "geraph://god-nodes", | ||
| name: "God Nodes", | ||
| description: "Top 10 most-connected nodes", | ||
| mimeType: "text/plain" | ||
| }, | ||
| { | ||
| uri: "geraph://surprises", | ||
| name: "Surprising Connections", | ||
| description: "Cross-community surprising connections", | ||
| mimeType: "text/plain" | ||
| } | ||
| ] | ||
| }; | ||
| }); | ||
| server.setRequestHandler(ReadResourceRequestSchema, async (request) => { | ||
| const { uri } = request.params; | ||
| if (uri === "geraph://report") { | ||
| const reportPath = path.join(targetDir, ".geraph", "GRAPH_REPORT.md"); | ||
| if (fs.existsSync(reportPath)) { | ||
| return { | ||
| contents: [ | ||
| { | ||
| uri, | ||
| mimeType: "text/markdown", | ||
| text: fs.readFileSync(reportPath, "utf-8") | ||
| } | ||
| ] | ||
| }; | ||
| } | ||
| return { | ||
| contents: [ | ||
| { | ||
| uri, | ||
| mimeType: "text/markdown", | ||
| text: "GRAPH_REPORT.md not found. Run geraph scan first." | ||
| } | ||
| ] | ||
| }; | ||
| } | ||
| if (uri === "geraph://stats") { | ||
| const { getGraphStats } = await import("./query-D3OHFKXD.js"); | ||
| const text = await getGraphStats(graph); | ||
| return { | ||
| contents: [{ uri, mimeType: "text/plain", text }] | ||
| }; | ||
| } | ||
| if (uri === "geraph://god-nodes") { | ||
| const { getGodNodes } = await import("./query-D3OHFKXD.js"); | ||
| const text = await getGodNodes(graph, 1, 10); | ||
| return { | ||
| contents: [{ uri, mimeType: "text/plain", text }] | ||
| }; | ||
| } | ||
| if (uri === "geraph://surprises") { | ||
| const { getSurprisingConnections } = await import("./query-D3OHFKXD.js"); | ||
| const text = await getSurprisingConnections(graph, 1, 10); | ||
| return { | ||
| contents: [{ uri, mimeType: "text/plain", text }] | ||
| }; | ||
| } | ||
| throw new Error(`Unknown resource: ${uri}`); | ||
| }); | ||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); | ||
| console.error("Geraph MCP Server is running over stdio"); | ||
| } | ||
| export { | ||
| runMcpServer | ||
| }; |
| // src/parsers/media.ts | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| function parseMedia(filePath, graph) { | ||
| const stats = fs.statSync(filePath); | ||
| graph.mergeNodeAttributes(filePath, { | ||
| type: "media", | ||
| metadata: { | ||
| extension: path.extname(filePath), | ||
| sizeBytes: stats.size, | ||
| createdAt: stats.birthtime.toISOString(), | ||
| modifiedAt: stats.mtime.toISOString() | ||
| } | ||
| }); | ||
| } | ||
| export { | ||
| parseMedia | ||
| }; |
| // src/parsers/python.ts | ||
| import Parser from "web-tree-sitter"; | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| var PYTHON_STDLIB_MODULES = /* @__PURE__ */ new Set([ | ||
| "abc", | ||
| "aifc", | ||
| "argparse", | ||
| "array", | ||
| "ast", | ||
| "asynchat", | ||
| "asyncio", | ||
| "asyncore", | ||
| "atexit", | ||
| "base64", | ||
| "bdb", | ||
| "binascii", | ||
| "binhex", | ||
| "bisect", | ||
| "builtins", | ||
| "bz2", | ||
| "calendar", | ||
| "cgi", | ||
| "cgitb", | ||
| "chunk", | ||
| "cmath", | ||
| "cmd", | ||
| "code", | ||
| "codecs", | ||
| "codeop", | ||
| "collections", | ||
| "colorsys", | ||
| "compileall", | ||
| "concurrent", | ||
| "configparser", | ||
| "contextlib", | ||
| "contextvars", | ||
| "copy", | ||
| "copyreg", | ||
| "cProfile", | ||
| "crypt", | ||
| "csv", | ||
| "ctypes", | ||
| "curses", | ||
| "dataclasses", | ||
| "datetime", | ||
| "dbm", | ||
| "decimal", | ||
| "difflib", | ||
| "dis", | ||
| "distutils", | ||
| "doctest", | ||
| "email", | ||
| "encodings", | ||
| "enum", | ||
| "errno", | ||
| "faulthandler", | ||
| "fcntl", | ||
| "filecmp", | ||
| "fileinput", | ||
| "fnmatch", | ||
| "fractions", | ||
| "ftplib", | ||
| "functools", | ||
| "gc", | ||
| "getopt", | ||
| "getpass", | ||
| "gettext", | ||
| "glob", | ||
| "grp", | ||
| "gzip", | ||
| "hashlib", | ||
| "heapq", | ||
| "hmac", | ||
| "html", | ||
| "http", | ||
| "idlelib", | ||
| "imaplib", | ||
| "imghdr", | ||
| "imp", | ||
| "importlib", | ||
| "inspect", | ||
| "io", | ||
| "ipaddress", | ||
| "itertools", | ||
| "json", | ||
| "keyword", | ||
| "lib2to3", | ||
| "linecache", | ||
| "locale", | ||
| "logging", | ||
| "lzma", | ||
| "mailbox", | ||
| "mailcap", | ||
| "marshal", | ||
| "math", | ||
| "mimetypes", | ||
| "mmap", | ||
| "modulefinder", | ||
| "multiprocessing", | ||
| "netrc", | ||
| "nis", | ||
| "nntplib", | ||
| "numbers", | ||
| "operator", | ||
| "optparse", | ||
| "os", | ||
| "ossaudiodev", | ||
| "pathlib", | ||
| "pdb", | ||
| "pickle", | ||
| "pickletools", | ||
| "pipes", | ||
| "pkgutil", | ||
| "platform", | ||
| "plistlib", | ||
| "poplib", | ||
| "posix", | ||
| "posixpath", | ||
| "pprint", | ||
| "profile", | ||
| "pstats", | ||
| "pty", | ||
| "pwd", | ||
| "py_compile", | ||
| "pyclbr", | ||
| "pydoc", | ||
| "queue", | ||
| "quopri", | ||
| "random", | ||
| "re", | ||
| "readline", | ||
| "reprlib", | ||
| "resource", | ||
| "rlcompleter", | ||
| "runpy", | ||
| "sched", | ||
| "secrets", | ||
| "select", | ||
| "selectors", | ||
| "shelve", | ||
| "shlex", | ||
| "shutil", | ||
| "signal", | ||
| "site", | ||
| "smtpd", | ||
| "smtplib", | ||
| "sndhdr", | ||
| "socket", | ||
| "socketserver", | ||
| "sqlite3", | ||
| "ssl", | ||
| "stat", | ||
| "statistics", | ||
| "string", | ||
| "stringprep", | ||
| "struct", | ||
| "subprocess", | ||
| "sunau", | ||
| "symtable", | ||
| "sys", | ||
| "sysconfig", | ||
| "syslog", | ||
| "tabnanny", | ||
| "tarfile", | ||
| "telnetlib", | ||
| "tempfile", | ||
| "termios", | ||
| "test", | ||
| "textwrap", | ||
| "threading", | ||
| "time", | ||
| "timeit", | ||
| "tkinter", | ||
| "token", | ||
| "tokenize", | ||
| "tomllib", | ||
| "trace", | ||
| "traceback", | ||
| "tracemalloc", | ||
| "tty", | ||
| "turtle", | ||
| "turtledemo", | ||
| "types", | ||
| "typing", | ||
| "unicodedata", | ||
| "unittest", | ||
| "urllib", | ||
| "uu", | ||
| "uuid", | ||
| "venv", | ||
| "warnings", | ||
| "wave", | ||
| "weakref", | ||
| "webbrowser", | ||
| "winreg", | ||
| "winsound", | ||
| "wsgiref", | ||
| "xdrlib", | ||
| "xml", | ||
| "xmlrpc", | ||
| "zipapp", | ||
| "zipfile", | ||
| "zipimport", | ||
| "zlib", | ||
| // Common third-party but universally recognized | ||
| "_thread", | ||
| "__future__", | ||
| "__main__" | ||
| ]); | ||
| var PYTHON_BUILT_INS = /* @__PURE__ */ new Set([ | ||
| // Built-in functions & primitives | ||
| "print", | ||
| "len", | ||
| "range", | ||
| "dict", | ||
| "list", | ||
| "set", | ||
| "tuple", | ||
| "open", | ||
| "sum", | ||
| "max", | ||
| "min", | ||
| "abs", | ||
| "str", | ||
| "int", | ||
| "float", | ||
| "enumerate", | ||
| "zip", | ||
| "any", | ||
| "all", | ||
| "map", | ||
| "filter", | ||
| "sorted", | ||
| "repr", | ||
| "isinstance", | ||
| "type", | ||
| "self", | ||
| "cls", | ||
| "__init__", | ||
| "bool", | ||
| "bytes", | ||
| "chr", | ||
| "ord", | ||
| "dir", | ||
| "id", | ||
| "hash", | ||
| "input", | ||
| "pow", | ||
| "round", | ||
| "globals", | ||
| "locals", | ||
| "vars", | ||
| "super", | ||
| "property", | ||
| "staticmethod", | ||
| "classmethod", | ||
| "next", | ||
| "iter", | ||
| "getattr", | ||
| "setattr", | ||
| "hasattr", | ||
| "delattr", | ||
| "callable", | ||
| "eval", | ||
| "exec", | ||
| "format", | ||
| "slice", | ||
| "reversed", | ||
| "complex", | ||
| "frozenset", | ||
| "memoryview", | ||
| "bytearray", | ||
| "object", | ||
| "breakpoint", | ||
| "compile", | ||
| "help", | ||
| "ascii", | ||
| "bin", | ||
| "hex", | ||
| "oct", | ||
| "issubclass", | ||
| // Magic methods | ||
| "__str__", | ||
| "__repr__", | ||
| "__len__", | ||
| "__getitem__", | ||
| "__setitem__", | ||
| "__delitem__", | ||
| "__iter__", | ||
| "__next__", | ||
| "__call__", | ||
| "__enter__", | ||
| "__exit__", | ||
| "__new__", | ||
| "__del__", | ||
| "__getattr__", | ||
| "__getattribute__", | ||
| "__setattr__", | ||
| "__delattr__", | ||
| "__dir__", | ||
| "__eq__", | ||
| "__ne__", | ||
| "__lt__", | ||
| "__le__", | ||
| "__gt__", | ||
| "__ge__", | ||
| "__add__", | ||
| "__sub__", | ||
| "__mul__", | ||
| "__truediv__", | ||
| "__floordiv__", | ||
| "__mod__", | ||
| "__pow__", | ||
| "__contains__", | ||
| "__hash__", | ||
| "__bool__", | ||
| "__format__", | ||
| "__init_subclass__", | ||
| "__class_getitem__", | ||
| // Common instance methods that should never be graph nodes | ||
| "append", | ||
| "extend", | ||
| "insert", | ||
| "remove", | ||
| "pop", | ||
| "clear", | ||
| "index", | ||
| "count", | ||
| "sort", | ||
| "reverse", | ||
| "copy", | ||
| "update", | ||
| "get", | ||
| "keys", | ||
| "values", | ||
| "items", | ||
| "setdefault", | ||
| "join", | ||
| "split", | ||
| "strip", | ||
| "lstrip", | ||
| "rstrip", | ||
| "startswith", | ||
| "endswith", | ||
| "replace", | ||
| "find", | ||
| "rfind", | ||
| "upper", | ||
| "lower", | ||
| "title", | ||
| "capitalize", | ||
| "encode", | ||
| "decode", | ||
| "format_map", | ||
| "center", | ||
| "ljust", | ||
| "rjust", | ||
| "zfill", | ||
| "expandtabs", | ||
| "isdigit", | ||
| "isalpha", | ||
| "isalnum", | ||
| "isspace", | ||
| "isupper", | ||
| "islower", | ||
| "istitle", | ||
| "read", | ||
| "write", | ||
| "close", | ||
| "flush", | ||
| "seek", | ||
| "tell", | ||
| "readline", | ||
| "readlines", | ||
| "writelines", | ||
| // Exceptions | ||
| "Exception", | ||
| "ValueError", | ||
| "TypeError", | ||
| "KeyError", | ||
| "IndexError", | ||
| "AttributeError", | ||
| "ImportError", | ||
| "RuntimeError", | ||
| "SystemExit", | ||
| "StopIteration", | ||
| "KeyboardInterrupt", | ||
| "AssertionError", | ||
| "FileNotFoundError", | ||
| "IOError", | ||
| "OSError", | ||
| "PermissionError", | ||
| "NotImplementedError", | ||
| "ZeroDivisionError", | ||
| "OverflowError", | ||
| "RecursionError", | ||
| // Typing module types | ||
| "List", | ||
| "Dict", | ||
| "Tuple", | ||
| "Set", | ||
| "Optional", | ||
| "Union", | ||
| "Any", | ||
| "Callable", | ||
| "Sequence", | ||
| "Mapping", | ||
| "Iterator", | ||
| "Generator", | ||
| "Coroutine", | ||
| "Awaitable", | ||
| "AsyncIterator", | ||
| "AsyncGenerator", | ||
| "ClassVar", | ||
| "Final", | ||
| "Literal", | ||
| "TypeVar", | ||
| "Generic", | ||
| "Protocol", | ||
| "TypedDict", | ||
| "NamedTuple" | ||
| ]); | ||
| function findNearestPythonDescriptor(dir) { | ||
| let current = dir; | ||
| while (true) { | ||
| for (const name of ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"]) { | ||
| const candidate = path.join(current, name); | ||
| if (fs.existsSync(candidate)) return candidate; | ||
| } | ||
| const parent = path.dirname(current); | ||
| if (parent === current) break; | ||
| current = parent; | ||
| } | ||
| return ""; | ||
| } | ||
| function resolvePythonImport(importName, sourceFilePath) { | ||
| const parts = importName.split("."); | ||
| const sourceDir = path.dirname(sourceFilePath); | ||
| const candidates = [ | ||
| path.resolve(sourceDir, ...parts) + ".py", | ||
| path.resolve(sourceDir, ...parts, "__init__.py") | ||
| ]; | ||
| const workspaceRoot = process.cwd(); | ||
| candidates.push( | ||
| path.resolve(workspaceRoot, ...parts) + ".py", | ||
| path.resolve(workspaceRoot, ...parts, "__init__.py") | ||
| ); | ||
| let current = sourceDir; | ||
| while (current) { | ||
| for (const desc of ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"]) { | ||
| if (fs.existsSync(path.join(current, desc))) { | ||
| candidates.push( | ||
| path.resolve(current, ...parts) + ".py", | ||
| path.resolve(current, ...parts, "__init__.py") | ||
| ); | ||
| } | ||
| } | ||
| const parent = path.dirname(current); | ||
| if (parent === current) break; | ||
| current = parent; | ||
| } | ||
| for (const candidate of candidates) { | ||
| if (fs.existsSync(candidate)) { | ||
| if (path.resolve(candidate) === path.resolve(sourceFilePath)) continue; | ||
| return candidate; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| function extractDocstring(node) { | ||
| const body = node.childForFieldName("body"); | ||
| if (!body) return void 0; | ||
| const block = body.type === "block" ? body : null; | ||
| if (!block || block.childCount === 0) return void 0; | ||
| const firstExpr = block.child(0); | ||
| if (firstExpr && firstExpr.type === "expression_statement") { | ||
| const stringNode = firstExpr.child(0); | ||
| if (stringNode && stringNode.type === "string") { | ||
| return stringNode.text.replace(/^"""|"""$/g, "").replace(/^'''|'''$/g, "").trim(); | ||
| } | ||
| } | ||
| return void 0; | ||
| } | ||
| function isLocalDeclaration(startNode, name) { | ||
| let current = startNode; | ||
| const checkPattern = (patternNode) => { | ||
| if (patternNode.type === "identifier") { | ||
| return patternNode.text.trim() === name; | ||
| } | ||
| if (patternNode.type === "attribute") { | ||
| return false; | ||
| } | ||
| for (let i = 0; i < patternNode.namedChildCount; i++) { | ||
| const child = patternNode.namedChild(i); | ||
| if (child && checkPattern(child)) return true; | ||
| } | ||
| return false; | ||
| }; | ||
| while (current) { | ||
| if (current.type === "function_definition" || current.type === "lambda") { | ||
| const paramsNode = current.childForFieldName("parameters") || current.namedChild(0); | ||
| if (paramsNode && checkPattern(paramsNode)) { | ||
| return true; | ||
| } | ||
| } | ||
| if (current.type === "for_statement") { | ||
| const leftNode = current.childForFieldName("left") || current.namedChild(0); | ||
| if (leftNode && checkPattern(leftNode)) { | ||
| return true; | ||
| } | ||
| } | ||
| if (current.type === "with_statement") { | ||
| const findAsPatternTarget = (n) => { | ||
| if (n.type === "as_pattern_target") { | ||
| return checkPattern(n); | ||
| } | ||
| for (let i = 0; i < n.namedChildCount; i++) { | ||
| const child = n.namedChild(i); | ||
| if (child && findAsPatternTarget(child)) return true; | ||
| } | ||
| return false; | ||
| }; | ||
| if (findAsPatternTarget(current)) { | ||
| return true; | ||
| } | ||
| } | ||
| if (current.type === "block") { | ||
| for (let i = 0; i < current.namedChildCount; i++) { | ||
| const statement = current.namedChild(i); | ||
| if (!statement) continue; | ||
| let assignNode = null; | ||
| if (statement.type === "assignment") { | ||
| assignNode = statement; | ||
| } else if (statement.type === "expression_statement") { | ||
| const firstChild = statement.namedChild(0); | ||
| if (firstChild && firstChild.type === "assignment") { | ||
| assignNode = firstChild; | ||
| } | ||
| } | ||
| if (assignNode) { | ||
| const leftNode = assignNode.childForFieldName("left") || assignNode.namedChild(0); | ||
| if (leftNode && checkPattern(leftNode)) { | ||
| if (statement.startIndex <= (startNode?.startIndex ?? 0)) { | ||
| return true; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| current = current.parent; | ||
| } | ||
| return false; | ||
| } | ||
| function readSourceFile(filePath) { | ||
| const buffer = fs.readFileSync(filePath); | ||
| if (buffer.length >= 2 && buffer[0] === 255 && buffer[1] === 254) { | ||
| return buffer.toString("utf16le").replace(/^\uFEFF/, ""); | ||
| } | ||
| if (buffer.length >= 2 && buffer[0] === 254 && buffer[1] === 255) { | ||
| const swapped = Buffer.alloc(buffer.length); | ||
| for (let i = 0; i < buffer.length - 1; i += 2) { | ||
| swapped[i] = buffer[i + 1]; | ||
| swapped[i + 1] = buffer[i]; | ||
| } | ||
| return swapped.toString("utf16le").replace(/^\uFEFF/, ""); | ||
| } | ||
| let content = buffer.toString("utf8"); | ||
| if (content.startsWith("\uFEFF")) { | ||
| content = content.slice(1); | ||
| } | ||
| return content.replace(/\0/g, ""); | ||
| } | ||
| function parsePython(filePath, graph, language) { | ||
| const parser = new Parser(); | ||
| try { | ||
| parser.setLanguage(language); | ||
| } catch { | ||
| return; | ||
| } | ||
| let sourceCode; | ||
| try { | ||
| sourceCode = readSourceFile(filePath); | ||
| } catch { | ||
| return; | ||
| } | ||
| const tree = parser.parse(sourceCode); | ||
| if (!tree) return; | ||
| const localDefinitions = /* @__PURE__ */ new Set(); | ||
| const importMap = /* @__PURE__ */ new Map(); | ||
| const importOriginalNameMap = /* @__PURE__ */ new Map(); | ||
| const localMethodMap = /* @__PURE__ */ new Map(); | ||
| const getRootIdentifier = (node) => { | ||
| if (!node) return ""; | ||
| if (node.type === "identifier") return node.text.trim(); | ||
| if (node.type === "attribute") { | ||
| return getRootIdentifier(node.childForFieldName("object")); | ||
| } | ||
| if (node.type === "call") { | ||
| return getRootIdentifier(node.childForFieldName("function")); | ||
| } | ||
| if (node.type === "await" || node.type === "parenthesized_expression") { | ||
| const valueNode = node.namedChild(0); | ||
| return getRootIdentifier(valueNode); | ||
| } | ||
| return ""; | ||
| }; | ||
| const queryString = ` | ||
| (import_statement [ | ||
| (dotted_name) | ||
| (aliased_import) | ||
| ] @import_direct) | ||
| (import_from_statement | ||
| module_name: [ | ||
| (dotted_name) | ||
| (relative_import) | ||
| ] @import_from | ||
| ) | ||
| (import_from_statement | ||
| module_name: [ | ||
| (dotted_name) | ||
| (relative_import) | ||
| ] | ||
| name: [ | ||
| (dotted_name) | ||
| (aliased_import) | ||
| ] @import_symbol | ||
| ) | ||
| (class_definition name: (identifier) @class_decl) | ||
| (function_definition name: (identifier) @func_decl) | ||
| (call function: (identifier) @call_name) | ||
| (call function: (attribute attribute: (identifier) @call_attr_name) ) | ||
| (call function: (attribute object: (identifier) @call_attr_object attribute: (identifier) @call_attr_name)) | ||
| (type (identifier) @type_reference) | ||
| `; | ||
| const query = language.query(queryString); | ||
| const matches = query.matches(tree.rootNode); | ||
| const getEnclosingScopePath = (startNode) => { | ||
| const pathParts = []; | ||
| let current = startNode; | ||
| while (current) { | ||
| if (current.type === "class_definition" || current.type === "function_definition") { | ||
| const nameNode = current.childForFieldName("name"); | ||
| if (nameNode) { | ||
| pathParts.unshift(nameNode.text.trim()); | ||
| } | ||
| } | ||
| current = current.parent; | ||
| } | ||
| return pathParts.join("."); | ||
| }; | ||
| for (const match of matches) { | ||
| let activeImportSource = ""; | ||
| for (const capture of match.captures) { | ||
| const node = capture.node; | ||
| const captureName = capture.name; | ||
| if (captureName === "import_direct") { | ||
| if (node.type === "dotted_name") { | ||
| const importPath = node.text.trim(); | ||
| if (importPath && importPath !== "") { | ||
| importMap.set(importPath, importPath); | ||
| } | ||
| } else if (node.type === "aliased_import") { | ||
| const originalNode = node.namedChild(0); | ||
| const aliasNode = node.namedChild(1); | ||
| if (originalNode && aliasNode) { | ||
| const originalPath = originalNode.text.trim(); | ||
| const alias = aliasNode.text.trim(); | ||
| importMap.set(alias, originalPath); | ||
| const originalName = originalPath.split(".").pop() || originalPath; | ||
| if (originalName && originalName !== alias) { | ||
| importOriginalNameMap.set(alias, originalName); | ||
| } | ||
| } | ||
| } | ||
| } else if (captureName === "import_from") { | ||
| activeImportSource = node.text.trim(); | ||
| } else if (captureName === "import_symbol" && activeImportSource) { | ||
| if (node.type === "dotted_name") { | ||
| const symName = node.text.trim(); | ||
| if (symName && symName !== "") { | ||
| importMap.set(symName, activeImportSource); | ||
| } | ||
| } else if (node.type === "aliased_import") { | ||
| const originalNode = node.namedChild(0); | ||
| const aliasNode = node.namedChild(1); | ||
| if (originalNode && aliasNode) { | ||
| const originalName = originalNode.text.trim(); | ||
| const alias = aliasNode.text.trim(); | ||
| importMap.set(alias, activeImportSource); | ||
| if (originalName && originalName !== alias) { | ||
| importOriginalNameMap.set(alias, originalName); | ||
| } | ||
| } | ||
| } | ||
| } else if (captureName === "class_decl" || captureName === "func_decl") { | ||
| const symName = node.text.trim(); | ||
| if (symName) { | ||
| localDefinitions.add(symName); | ||
| const scopePrefix = getEnclosingScopePath(node.parent?.parent); | ||
| const symId = scopePrefix ? `${filePath}::${scopePrefix}.${symName}` : `${filePath}::${symName}`; | ||
| if (captureName === "func_decl") { | ||
| if (!localMethodMap.has(symName)) { | ||
| localMethodMap.set(symName, []); | ||
| } | ||
| localMethodMap.get(symName).push(symId); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| for (const match of matches) { | ||
| for (const capture of match.captures) { | ||
| const node = capture.node; | ||
| const captureName = capture.name; | ||
| if (captureName === "class_decl" || captureName === "func_decl") { | ||
| const symName = node.text.trim(); | ||
| if (!symName) continue; | ||
| const scopePrefix = getEnclosingScopePath(node.parent?.parent); | ||
| const symId = scopePrefix ? `${filePath}::${scopePrefix}.${symName}` : `${filePath}::${symName}`; | ||
| const parentId = scopePrefix ? `${filePath}::${scopePrefix}` : filePath; | ||
| const isClass = captureName === "class_decl"; | ||
| const decl = node.parent || node; | ||
| const doc = extractDocstring(decl); | ||
| const nodeAttrs = { | ||
| type: isClass ? "class" : "function", | ||
| name: symName, | ||
| file: filePath, | ||
| startLine: decl.startPosition.row + 1, | ||
| metadata: { | ||
| endLine: decl.endPosition.row + 1, | ||
| doc, | ||
| external: false, | ||
| unresolved: false | ||
| } | ||
| }; | ||
| if (!graph.hasNode(symId)) { | ||
| graph.addNode(symId, nodeAttrs); | ||
| graph.addEdge(parentId, symId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } else { | ||
| graph.mergeNodeAttributes(symId, nodeAttrs); | ||
| if (!graph.hasEdge(parentId, symId)) { | ||
| graph.addEdge(parentId, symId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } | ||
| } | ||
| } else if (captureName === "import_direct" || captureName === "import_from") { | ||
| let importPath = node.text.trim(); | ||
| if (node.type === "aliased_import") { | ||
| const originalNode = node.namedChild(0); | ||
| if (originalNode) { | ||
| importPath = originalNode.text.trim(); | ||
| } | ||
| } | ||
| if (importPath === "" || importPath === "." || importPath === "*") continue; | ||
| const resolvedPath = resolvePythonImport(importPath, filePath); | ||
| const rootModule = importPath.split(".")[0] || importPath; | ||
| if (!resolvedPath && PYTHON_STDLIB_MODULES.has(rootModule)) continue; | ||
| const importNodeId = resolvedPath || `import::${importPath}`; | ||
| if (importNodeId === "import::" || importNodeId.trim() === "") continue; | ||
| if (importNodeId === filePath) continue; | ||
| if (!graph.hasNode(importNodeId)) { | ||
| const nearestDescriptor = findNearestPythonDescriptor(path.dirname(filePath)); | ||
| graph.addNode(importNodeId, { | ||
| type: "file", | ||
| name: resolvedPath ? path.basename(resolvedPath) : importPath, | ||
| file: resolvedPath || nearestDescriptor || filePath, | ||
| startLine: 0, | ||
| metadata: resolvedPath ? {} : { external: true } | ||
| }); | ||
| } | ||
| if (!graph.hasEdge(filePath, importNodeId)) { | ||
| graph.addEdge(filePath, importNodeId, { type: "imports", confidence: "EXTRACTED" }); | ||
| } | ||
| } else if (captureName === "call_name") { | ||
| const calledName = node.text.trim(); | ||
| if (!calledName || PYTHON_BUILT_INS.has(calledName)) continue; | ||
| if (isLocalDeclaration(node, calledName)) continue; | ||
| const importSource = importMap.get(calledName); | ||
| if (importSource) { | ||
| const resolvedImport2 = resolvePythonImport(importSource, filePath); | ||
| const importRoot = importSource.split(".")[0] || importSource; | ||
| if (!resolvedImport2 && PYTHON_STDLIB_MODULES.has(importRoot)) continue; | ||
| } | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| const targets = []; | ||
| const resolvedImport = importSource ? resolvePythonImport(importSource, filePath) : null; | ||
| if (localDefinitions.has(calledName)) { | ||
| const localMatches = localMethodMap.get(calledName) || []; | ||
| if (localMatches.length === 1 && localMatches[0]) { | ||
| targets.push({ | ||
| id: localMatches[0], | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (localMatches.length > 1) { | ||
| const callerScope2 = getEnclosingScopePath(node.parent); | ||
| let bestMatch = localMatches[0]; | ||
| let maxCommon = -1; | ||
| for (const matchId of localMatches) { | ||
| const matchScope = matchId.split("::")[1] || ""; | ||
| let common = 0; | ||
| const matchParts = matchScope.split("."); | ||
| const callerParts = callerScope2.split("."); | ||
| for (let i = 0; i < Math.min(matchParts.length, callerParts.length); i++) { | ||
| if (matchParts[i] === callerParts[i]) common++; | ||
| else break; | ||
| } | ||
| if (common > maxCommon) { | ||
| maxCommon = common; | ||
| bestMatch = matchId; | ||
| } | ||
| } | ||
| targets.push({ | ||
| id: bestMatch, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else { | ||
| const scopePrefix = getEnclosingScopePath(node.parent?.parent); | ||
| targets.push({ | ||
| id: scopePrefix ? `${filePath}::${scopePrefix}.${calledName}` : `${filePath}::${calledName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } | ||
| } else if (resolvedImport) { | ||
| const originalName = importOriginalNameMap.get(calledName) || calledName; | ||
| targets.push({ | ||
| id: `${resolvedImport}::${originalName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (importSource) { | ||
| const originalName = importOriginalNameMap.get(calledName) || calledName; | ||
| targets.push({ | ||
| id: `import::${importSource}::${originalName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else { | ||
| targets.push({ | ||
| id: `unresolved::${calledName}`, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| for (const target of targets) { | ||
| const isUnresolved = target.id.startsWith("unresolved::"); | ||
| const isExternal = target.id.startsWith("import::") || !isUnresolved && !target.id.startsWith(filePath); | ||
| const callLine = node.startPosition.row + 1; | ||
| if (!graph.hasNode(target.id)) { | ||
| let fileAttr = filePath; | ||
| if (isUnresolved) { | ||
| fileAttr = filePath; | ||
| } else if (target.id.includes("::")) { | ||
| fileAttr = target.id.split("::")[0] || filePath; | ||
| } | ||
| graph.addNode(target.id, { | ||
| type: "function", | ||
| name: calledName, | ||
| file: fileAttr, | ||
| startLine: isExternal ? 0 : callLine, | ||
| metadata: { | ||
| external: isExternal, | ||
| unresolved: isUnresolved, | ||
| endLine: isExternal ? 0 : callLine, | ||
| callerFile: isUnresolved ? filePath : void 0, | ||
| callerLine: isUnresolved ? callLine : void 0, | ||
| doc: isUnresolved ? "Called/Instantiated but not defined in any scanned file." : void 0 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === target.id) continue; | ||
| if (!graph.hasEdge(callerId, target.id)) { | ||
| graph.addEdge(callerId, target.id, { type: "calls", confidence: target.confidence }); | ||
| } | ||
| } | ||
| if (importSource && !resolvedImport) { | ||
| const importNodeId = `import::${importSource}`; | ||
| if (importNodeId !== "import::" && !PYTHON_STDLIB_MODULES.has(importSource.split(".")[0] || importSource)) { | ||
| const nearestDescriptor = findNearestPythonDescriptor(path.dirname(filePath)); | ||
| if (!graph.hasNode(importNodeId)) { | ||
| graph.addNode(importNodeId, { | ||
| type: "file", | ||
| name: importSource, | ||
| file: nearestDescriptor || filePath, | ||
| startLine: 0, | ||
| metadata: { external: true } | ||
| }); | ||
| } | ||
| for (const target of targets) { | ||
| if (!graph.hasEdge(importNodeId, target.id)) { | ||
| graph.addEdge(importNodeId, target.id, { type: "defines", confidence: "EXTRACTED" }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } else if (captureName === "call_attr_name") { | ||
| const methodName = node.text.trim(); | ||
| if (!methodName || PYTHON_BUILT_INS.has(methodName)) continue; | ||
| let objectName = ""; | ||
| if (node.parent?.type === "attribute") { | ||
| const objectNode = node.parent.childForFieldName("object"); | ||
| if (objectNode) { | ||
| objectName = getRootIdentifier(objectNode); | ||
| } | ||
| } | ||
| const resolvedObject = resolvePythonImport(objectName, filePath); | ||
| if (objectName && !resolvedObject && PYTHON_STDLIB_MODULES.has(objectName)) { | ||
| continue; | ||
| } | ||
| if (isLocalDeclaration(node, objectName ? `${objectName}.${methodName}` : methodName)) continue; | ||
| const importSource = importMap.get(objectName); | ||
| if (importSource) { | ||
| const resolvedSource = resolvePythonImport(importSource, filePath); | ||
| const importRoot = importSource.split(".")[0] || importSource; | ||
| if (!resolvedSource && PYTHON_STDLIB_MODULES.has(importRoot)) continue; | ||
| } | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| const targets = []; | ||
| if (objectName) { | ||
| if (importSource) { | ||
| const resolvedSource = resolvePythonImport(importSource, filePath); | ||
| const targetBase = resolvedSource || `import::${importSource}`; | ||
| targets.push({ | ||
| id: `${targetBase}::${methodName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (localDefinitions.has(objectName)) { | ||
| const localMatches = localMethodMap.get(methodName) || []; | ||
| if (localMatches.length === 1 && localMatches[0]) { | ||
| targets.push({ | ||
| id: localMatches[0], | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (localMatches.length > 1) { | ||
| for (const matchId of localMatches) { | ||
| targets.push({ | ||
| id: matchId, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } else { | ||
| targets.push({ | ||
| id: `${filePath}::${objectName}.${methodName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } | ||
| } else { | ||
| const localMatches = localMethodMap.get(methodName) || []; | ||
| if (localMatches.length === 1 && localMatches[0]) { | ||
| targets.push({ | ||
| id: localMatches[0], | ||
| confidence: "INFERRED" | ||
| }); | ||
| } else if (localMatches.length > 1) { | ||
| for (const matchId of localMatches) { | ||
| targets.push({ | ||
| id: matchId, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| } else { | ||
| const localMatches = localMethodMap.get(methodName) || []; | ||
| if (localMatches.length === 1 && localMatches[0]) { | ||
| targets.push({ | ||
| id: localMatches[0], | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (localMatches.length > 1) { | ||
| for (const matchId of localMatches) { | ||
| targets.push({ | ||
| id: matchId, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } else { | ||
| targets.push({ | ||
| id: `unresolved::${methodName}`, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } | ||
| for (const target of targets) { | ||
| const isUnresolved = target.id.startsWith("unresolved::"); | ||
| const isExternal = target.id.startsWith("import::") || !isUnresolved && !target.id.startsWith(filePath); | ||
| const callLine = node.startPosition.row + 1; | ||
| if (!graph.hasNode(target.id)) { | ||
| let fileAttr = filePath; | ||
| if (isUnresolved) { | ||
| fileAttr = filePath; | ||
| } else if (target.id.includes("::")) { | ||
| fileAttr = target.id.split("::")[0] || filePath; | ||
| } | ||
| graph.addNode(target.id, { | ||
| type: "function", | ||
| name: objectName ? `${objectName}.${methodName}` : methodName, | ||
| file: fileAttr, | ||
| startLine: isExternal ? 0 : callLine, | ||
| metadata: { | ||
| external: isExternal, | ||
| unresolved: isUnresolved, | ||
| endLine: isExternal ? 0 : callLine, | ||
| callerFile: isUnresolved ? filePath : void 0, | ||
| callerLine: isUnresolved ? callLine : void 0 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === target.id) continue; | ||
| if (!graph.hasEdge(callerId, target.id)) { | ||
| graph.addEdge(callerId, target.id, { type: "calls", confidence: target.confidence }); | ||
| } | ||
| } | ||
| } else if (captureName === "type_reference") { | ||
| const referencedTypeName = node.text.trim(); | ||
| if (!referencedTypeName || PYTHON_BUILT_INS.has(referencedTypeName)) continue; | ||
| if (isLocalDeclaration(node, referencedTypeName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| if (!graph.hasNode(callerId)) continue; | ||
| let targetId; | ||
| const importSource = importMap.get(referencedTypeName); | ||
| if (localDefinitions.has(referencedTypeName)) { | ||
| targetId = `${filePath}::${referencedTypeName}`; | ||
| } else if (importSource) { | ||
| const resolvedSource = resolvePythonImport(importSource, filePath); | ||
| const importRoot = importSource.split(".")[0] || importSource; | ||
| if (!resolvedSource && PYTHON_STDLIB_MODULES.has(importRoot)) continue; | ||
| const originalName = importOriginalNameMap.get(referencedTypeName) || referencedTypeName; | ||
| targetId = `${resolvedSource || `import::${importSource}`}::${originalName}`; | ||
| } else { | ||
| targetId = `unresolved::${referencedTypeName}`; | ||
| } | ||
| const isUnresolved = !localDefinitions.has(referencedTypeName) && !importSource; | ||
| if (!graph.hasNode(targetId)) { | ||
| graph.addNode(targetId, { | ||
| type: "class", | ||
| name: referencedTypeName, | ||
| file: filePath, | ||
| startLine: node.startPosition.row + 1, | ||
| metadata: { | ||
| external: !!importSource, | ||
| unresolved: isUnresolved, | ||
| endLine: node.startPosition.row + 1, | ||
| callerFile: filePath, | ||
| callerLine: node.startPosition.row + 1 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === targetId) continue; | ||
| if (!graph.hasEdge(callerId, targetId)) { | ||
| graph.addEdge(callerId, targetId, { type: "references", confidence: isUnresolved ? "AMBIGUOUS" : "EXTRACTED" }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| export { | ||
| parsePython | ||
| }; |
| import { | ||
| createKnowledgeGraph | ||
| } from "./chunk-5V42YYJT.js"; | ||
| // src/core/query.ts | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| function normalizeId(id) { | ||
| return id.replace(/\\/g, "/"); | ||
| } | ||
| function loadGraph(targetDir) { | ||
| const graphPath = path.join(targetDir, ".geraph", "graph.json"); | ||
| if (!fs.existsSync(graphPath)) { | ||
| throw new Error( | ||
| `Graph data not found in ${targetDir}. Run 'geraph scan' first.` | ||
| ); | ||
| } | ||
| const rawData = JSON.parse(fs.readFileSync(graphPath, "utf-8")); | ||
| const graph = createKnowledgeGraph(); | ||
| const nodes = rawData.nodes || []; | ||
| const edges = rawData.edges || []; | ||
| const communities = rawData.analysis?.communities || []; | ||
| const nodeToCommunity = /* @__PURE__ */ new Map(); | ||
| communities.forEach((c) => { | ||
| const members = c.members || c.nodes || []; | ||
| members.forEach((nodeId) => { | ||
| nodeToCommunity.set(normalizeId(nodeId), Number(c.id)); | ||
| }); | ||
| }); | ||
| nodes.forEach((n) => { | ||
| const nid = normalizeId(n.id); | ||
| if (!graph.hasNode(nid)) { | ||
| const { id, name, type, file, startLine, ...metadata } = n; | ||
| const communityId = nodeToCommunity.get(nid); | ||
| graph.addNode(nid, { | ||
| name: name || "", | ||
| type, | ||
| file: normalizeId(file || ""), | ||
| startLine: startLine || 0, | ||
| metadata: { | ||
| ...metadata, | ||
| ...communityId !== void 0 ? { community: communityId } : {} | ||
| } | ||
| }); | ||
| } | ||
| }); | ||
| edges.forEach((e) => { | ||
| const source = normalizeId(e.source); | ||
| const target = normalizeId(e.target); | ||
| if (source === target) return; | ||
| if (graph.hasNode(source) && graph.hasNode(target)) { | ||
| graph.addEdge(source, target, { | ||
| type: e.relation || e.type, | ||
| confidence: e.confidence, | ||
| metadata: e.metadata || {} | ||
| }); | ||
| } | ||
| }); | ||
| return graph; | ||
| } | ||
| async function searchGraph(targetDirOrGraph, term, targetType, page = 1, limit = 20) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| const lowerTerm = term.toLowerCase(); | ||
| const results = []; | ||
| graph.forEachNode((nodeId, attr) => { | ||
| if (targetType && attr.type !== targetType) return; | ||
| if (nodeId.toLowerCase().includes(lowerTerm) || attr.name && attr.name.toLowerCase().includes(lowerTerm)) { | ||
| results.push({ | ||
| id: nodeId, | ||
| name: attr.name, | ||
| type: attr.type, | ||
| file: attr.file, | ||
| links: graph.degree(nodeId) | ||
| }); | ||
| } | ||
| }); | ||
| results.sort((a, b) => b.links - a.links); | ||
| const total = results.length; | ||
| const totalPages = Math.ceil(total / limit) || 1; | ||
| const start = (page - 1) * limit; | ||
| const end = start + limit; | ||
| return { | ||
| data: results.slice(start, end), | ||
| meta: { | ||
| page, | ||
| limit, | ||
| total, | ||
| totalPages | ||
| } | ||
| }; | ||
| } | ||
| function resolveTargetNodeId(graph, symbol, targetType, targetSource) { | ||
| const normSymbol = normalizeId(symbol); | ||
| let targetNodeId = graph.hasNode(normSymbol) ? normSymbol : null; | ||
| if (targetNodeId && (targetType || targetSource)) { | ||
| const attr = graph.getNodeAttributes(targetNodeId); | ||
| if (targetType && attr.type !== targetType) { | ||
| targetNodeId = null; | ||
| } | ||
| if (targetNodeId && targetSource) { | ||
| const normSource = normalizeId(targetSource); | ||
| if (!attr.file.toLowerCase().endsWith(normSource.toLowerCase())) { | ||
| targetNodeId = null; | ||
| } | ||
| } | ||
| } | ||
| if (!targetNodeId) { | ||
| targetNodeId = graph.findNode((nodeId, attr) => { | ||
| if (targetType && attr.type !== targetType) return false; | ||
| if (targetSource) { | ||
| const normSource = normalizeId(targetSource); | ||
| if (!attr.file.endsWith(normSource)) return false; | ||
| } | ||
| return attr && attr.name && attr.name === symbol || nodeId === normSymbol || nodeId.endsWith("/" + normSymbol) || nodeId.endsWith("::" + normSymbol); | ||
| }) ?? null; | ||
| } | ||
| if (!targetNodeId) { | ||
| targetNodeId = graph.findNode((nodeId, attr) => { | ||
| if (targetType && attr.type !== targetType) return false; | ||
| if (targetSource) { | ||
| const normSource = normalizeId(targetSource); | ||
| if (!attr.file.toLowerCase().endsWith(normSource.toLowerCase())) | ||
| return false; | ||
| } | ||
| return attr && attr.name && attr.name.toLowerCase() === symbol.toLowerCase() || nodeId.toLowerCase() === normSymbol.toLowerCase() || nodeId.toLowerCase().endsWith("/" + normSymbol.toLowerCase()) || nodeId.toLowerCase().endsWith("::" + normSymbol.toLowerCase()); | ||
| }) ?? null; | ||
| } | ||
| if (!targetNodeId) { | ||
| const typeMsg = targetType ? ` of type '${targetType}'` : ""; | ||
| const sourceMsg = targetSource ? ` in source '${targetSource}'` : ""; | ||
| throw new Error( | ||
| `Symbol '${symbol}'${typeMsg}${sourceMsg} not found in the graph.` | ||
| ); | ||
| } | ||
| return targetNodeId; | ||
| } | ||
| async function getNode(targetDirOrGraph, symbol, targetType, targetSource) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| const targetNodeId = resolveTargetNodeId( | ||
| graph, | ||
| symbol, | ||
| targetType, | ||
| targetSource | ||
| ); | ||
| const targetAttr = graph.getNodeAttributes(targetNodeId); | ||
| return { | ||
| id: targetNodeId, | ||
| name: targetAttr.name, | ||
| type: targetAttr.type, | ||
| file: targetAttr.file, | ||
| line: targetAttr.startLine, | ||
| metadata: targetAttr.metadata, | ||
| links: { | ||
| incoming: graph.inDegree(targetNodeId), | ||
| outgoing: graph.outDegree(targetNodeId) | ||
| } | ||
| }; | ||
| } | ||
| async function getNeighbors(targetDirOrGraph, symbol, targetType, targetSource, page = 1, limit = 20) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| const targetNodeId = resolveTargetNodeId( | ||
| graph, | ||
| symbol, | ||
| targetType, | ||
| targetSource | ||
| ); | ||
| const targetAttr = graph.getNodeAttributes(targetNodeId); | ||
| const result = { | ||
| target: { | ||
| id: targetNodeId, | ||
| name: targetAttr.name, | ||
| type: targetAttr.type, | ||
| file: targetAttr.file, | ||
| line: targetAttr.startLine | ||
| }, | ||
| incoming: [], | ||
| outgoing: [], | ||
| meta: { | ||
| page, | ||
| limit, | ||
| totalIncoming: 0, | ||
| totalOutgoing: 0, | ||
| totalPages: 1 | ||
| } | ||
| }; | ||
| const collectEdges = (nodeId, isOutgoing, seenKeys) => { | ||
| const iterator = isOutgoing ? graph.forEachOutEdge.bind(graph) : graph.forEachInEdge.bind(graph); | ||
| iterator(nodeId, (edge, attr, source, target) => { | ||
| const neighborId = isOutgoing ? target : source; | ||
| if (neighborId === targetNodeId) return; | ||
| const key = `${neighborId}:${attr.type}`; | ||
| if (seenKeys.has(key)) return; | ||
| seenKeys.add(key); | ||
| const neighborAttr = graph.getNodeAttributes(neighborId); | ||
| const nodeInfo = { | ||
| id: neighborId, | ||
| name: neighborAttr.name, | ||
| type: neighborAttr.type, | ||
| file: neighborAttr.file, | ||
| line: neighborAttr.startLine | ||
| }; | ||
| if (isOutgoing) { | ||
| result.outgoing.push({ | ||
| relation: attr.type, | ||
| confidence: attr.confidence, | ||
| target: nodeInfo | ||
| }); | ||
| } else { | ||
| result.incoming.push({ | ||
| relation: attr.type, | ||
| confidence: attr.confidence, | ||
| source: nodeInfo | ||
| }); | ||
| } | ||
| }); | ||
| }; | ||
| const seenIn = /* @__PURE__ */ new Set(); | ||
| const seenOut = /* @__PURE__ */ new Set(); | ||
| collectEdges(targetNodeId, false, seenIn); | ||
| collectEdges(targetNodeId, true, seenOut); | ||
| const sortEdges = (a, b) => { | ||
| const nodeA = a.source || a.target; | ||
| const nodeB = b.source || b.target; | ||
| if (nodeA.type === "intent" && nodeB.type !== "intent") return -1; | ||
| if (nodeB.type === "intent" && nodeA.type !== "intent") return 1; | ||
| const degreeA = graph.degree(nodeA.id) || 0; | ||
| const degreeB = graph.degree(nodeB.id) || 0; | ||
| return degreeB - degreeA; | ||
| }; | ||
| result.incoming.sort(sortEdges); | ||
| result.outgoing.sort(sortEdges); | ||
| const totalIncoming = result.incoming.length; | ||
| const totalOutgoing = result.outgoing.length; | ||
| const maxTotal = Math.max(totalIncoming, totalOutgoing); | ||
| const totalPages = Math.ceil(maxTotal / limit) || 1; | ||
| const start = (page - 1) * limit; | ||
| const end = start + limit; | ||
| result.incoming = result.incoming.slice(start, end); | ||
| result.outgoing = result.outgoing.slice(start, end); | ||
| result.meta = { | ||
| page, | ||
| limit, | ||
| totalIncoming, | ||
| totalOutgoing, | ||
| totalPages | ||
| }; | ||
| return result; | ||
| } | ||
| function undirectedShortestPath(graph, source, target) { | ||
| if (source === target) return [source]; | ||
| const queue = [source]; | ||
| const visited = /* @__PURE__ */ new Set([source]); | ||
| const parent = /* @__PURE__ */ new Map(); | ||
| while (queue.length > 0) { | ||
| const current = queue.shift(); | ||
| if (current === target) break; | ||
| graph.forEachNeighbor(current, (neighbor) => { | ||
| if (!visited.has(neighbor)) { | ||
| visited.add(neighbor); | ||
| parent.set(neighbor, current); | ||
| queue.push(neighbor); | ||
| } | ||
| }); | ||
| } | ||
| if (!parent.has(target)) return null; | ||
| const path2 = []; | ||
| let curr = target; | ||
| while (curr !== source) { | ||
| path2.unshift(curr); | ||
| curr = parent.get(curr); | ||
| } | ||
| path2.unshift(source); | ||
| return path2; | ||
| } | ||
| async function shortestPath(targetDirOrGraph, sourceSymbol, targetSymbol, maxHops = 8) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| const normSource = resolveTargetNodeId(graph, sourceSymbol); | ||
| const normTarget = resolveTargetNodeId(graph, targetSymbol); | ||
| if (normSource === normTarget) { | ||
| throw new Error(`Source and target nodes are identical: '${sourceSymbol}'`); | ||
| } | ||
| const path2 = undirectedShortestPath(graph, normSource, normTarget); | ||
| if (!path2) { | ||
| throw new Error("No path exists between the given nodes"); | ||
| } | ||
| const hops = path2.length - 1; | ||
| if (hops > maxHops) { | ||
| return `Path exceeds max_hops=${maxHops} (${hops} hops found).`; | ||
| } | ||
| const segments = []; | ||
| for (let i = 0; i < path2.length - 1; i++) { | ||
| const u = path2[i]; | ||
| const v = path2[i + 1]; | ||
| let forward = true; | ||
| let edgeId; | ||
| if (graph.hasDirectedEdge(u, v)) { | ||
| edgeId = graph.edges(u, v)[0]; | ||
| } else if (graph.hasDirectedEdge(v, u)) { | ||
| edgeId = graph.edges(v, u)[0]; | ||
| forward = false; | ||
| } | ||
| const edata = edgeId ? graph.getEdgeAttributes(edgeId) : void 0; | ||
| const rel = edata?.type || ""; | ||
| const conf = edata?.confidence || ""; | ||
| const confStr = conf ? ` [${conf}]` : ""; | ||
| const uLabel = String(graph.getNodeAttribute(u, "name") || u); | ||
| const vLabel = String(graph.getNodeAttribute(v, "name") || v); | ||
| if (i === 0) { | ||
| segments.push(uLabel); | ||
| } | ||
| if (forward) { | ||
| segments.push(`--${rel}${confStr}--> ${vLabel}`); | ||
| } else { | ||
| segments.push(`<--${rel}${confStr}-- ${vLabel}`); | ||
| } | ||
| } | ||
| return `Shortest path (${hops} hops): | ||
| ` + segments.join(" "); | ||
| } | ||
| async function getGodNodes(targetDirOrGraph, page = 1, limit = 10) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| const { findGodNodes } = await import("./analyze-6FZL63EZ.js"); | ||
| const allGods = findGodNodes(graph, graph.order); | ||
| const total = allGods.length; | ||
| const totalPages = Math.ceil(total / limit) || 1; | ||
| const start = (page - 1) * limit; | ||
| const end = start + limit; | ||
| const paginated = allGods.slice(start, end); | ||
| const lines = ["God nodes (most connected):"]; | ||
| paginated.forEach((n, idx) => { | ||
| const globalIdx = start + idx + 1; | ||
| lines.push(` ${globalIdx}. ${n.name} [id: ${n.id}] - ${n.degree} edges`); | ||
| }); | ||
| if (totalPages > 1) { | ||
| lines.push(` | ||
| [Page ${page} of ${totalPages} | Total: ${total} nodes]`); | ||
| } | ||
| return lines.join("\n"); | ||
| } | ||
| async function getCommunityNodes(targetDirOrGraph, communityId, page = 1, limit = 20) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| const { detectCommunities } = await import("./analyze-6FZL63EZ.js"); | ||
| const communities = detectCommunities(graph); | ||
| const targetCommunity = communities.find((c) => c.id === communityId); | ||
| if (!targetCommunity) { | ||
| throw new Error(`Community ${communityId} not found.`); | ||
| } | ||
| const total = targetCommunity.nodes.length; | ||
| const totalPages = Math.ceil(total / limit) || 1; | ||
| const start = (page - 1) * limit; | ||
| const end = start + limit; | ||
| const paginatedNodes = targetCommunity.nodes.slice(start, end); | ||
| const lines = [`Community ${communityId} (${total} nodes):`]; | ||
| paginatedNodes.forEach((nodeId) => { | ||
| const attr = graph.getNodeAttributes(nodeId); | ||
| const label = attr.name || nodeId; | ||
| lines.push(` ${label} (type: ${attr.type}) [id: ${nodeId}]`); | ||
| }); | ||
| if (totalPages > 1) { | ||
| lines.push(` | ||
| [Page ${page} of ${totalPages} | Total: ${total} nodes]`); | ||
| } | ||
| return lines.join("\n"); | ||
| } | ||
| async function getSurprisingConnections(targetDirOrGraph, page = 1, limit = 20) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| const { detectCommunities, findSurprisingConnections } = await import("./analyze-6FZL63EZ.js"); | ||
| const communities = detectCommunities(graph); | ||
| const surprises = findSurprisingConnections(graph, communities, graph.size); | ||
| const total = surprises.length; | ||
| const totalPages = Math.ceil(total / limit) || 1; | ||
| const start = (page - 1) * limit; | ||
| const end = start + limit; | ||
| const paginated = surprises.slice(start, end); | ||
| if (total === 0) { | ||
| return "No surprising connections found."; | ||
| } | ||
| const lines = ["Surprising cross-community connections:"]; | ||
| paginated.forEach((s) => { | ||
| lines.push( | ||
| ` ${s.sourceName} <-> ${s.targetName} [${s.edgeType}] - ${s.why}` | ||
| ); | ||
| }); | ||
| if (totalPages > 1) { | ||
| lines.push( | ||
| ` | ||
| [Page ${page} of ${totalPages} | Total: ${total} connections]` | ||
| ); | ||
| } | ||
| return lines.join("\n"); | ||
| } | ||
| function scoreNodes(graph, terms) { | ||
| const EXACT_MATCH_BONUS = 1e3; | ||
| const PREFIX_MATCH_BONUS = 100; | ||
| const SUBSTRING_MATCH_BONUS = 1; | ||
| const SOURCE_MATCH_BONUS = 0.5; | ||
| const scored = []; | ||
| graph.forEachNode((nodeId, attr) => { | ||
| const name = (attr.name || "").toLowerCase(); | ||
| const source = (attr.file || "").toLowerCase(); | ||
| const nidLower = nodeId.toLowerCase(); | ||
| let score = 0; | ||
| for (const t of terms) { | ||
| if (t === name || t === nidLower) { | ||
| score += EXACT_MATCH_BONUS; | ||
| } else if (name.startsWith(t) || nidLower.startsWith(t)) { | ||
| score += PREFIX_MATCH_BONUS; | ||
| } else if (name.includes(t) || nidLower.includes(t)) { | ||
| score += SUBSTRING_MATCH_BONUS; | ||
| } | ||
| if (source.includes(t)) { | ||
| score += SOURCE_MATCH_BONUS; | ||
| } | ||
| } | ||
| if (score > 0) { | ||
| scored.push([score, nodeId]); | ||
| } | ||
| }); | ||
| return scored.sort((a, b) => b[0] - a[0]); | ||
| } | ||
| async function queryGraph(targetDirOrGraph, symbol, mode = "bfs", depth = 3, tokenBudget = 2e3) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| const terms = symbol.split(/\s+/).map((t) => t.replace(/[?,.:;!]/g, "").toLowerCase().trim()).filter((t) => t.length > 2); | ||
| let startNodes = []; | ||
| if (terms.length > 0) { | ||
| const scored = scoreNodes(graph, terms); | ||
| startNodes = scored.slice(0, 3).map((item) => item[1]); | ||
| } | ||
| if (startNodes.length === 0) { | ||
| try { | ||
| const fallbackId = resolveTargetNodeId(graph, symbol); | ||
| startNodes = [fallbackId]; | ||
| } catch { | ||
| throw new Error(`No matching nodes found for query: '${symbol}'`); | ||
| } | ||
| } | ||
| const degrees = []; | ||
| graph.forEachNode((nodeId) => { | ||
| degrees.push(graph.degree(nodeId)); | ||
| }); | ||
| let hubThreshold = 50; | ||
| if (degrees.length > 0) { | ||
| const sorted = [...degrees].sort((a, b) => a - b); | ||
| const p99Idx = Math.floor(sorted.length * 0.99); | ||
| hubThreshold = Math.max(50, sorted[p99Idx] || 50); | ||
| } | ||
| const seedSet = new Set(startNodes); | ||
| const visited = new Set(startNodes); | ||
| const edges = []; | ||
| if (mode === "bfs") { | ||
| const queue = startNodes.map((n) => [n, 0]); | ||
| while (queue.length > 0) { | ||
| const [curr, d] = queue.shift(); | ||
| if (d >= depth) continue; | ||
| if (!seedSet.has(curr) && graph.degree(curr) >= hubThreshold) { | ||
| continue; | ||
| } | ||
| graph.forEachNeighbor(curr, (neighbor) => { | ||
| if (!visited.has(neighbor)) { | ||
| visited.add(neighbor); | ||
| queue.push([neighbor, d + 1]); | ||
| } | ||
| edges.push([curr, neighbor]); | ||
| }); | ||
| } | ||
| } else { | ||
| const stack = [...startNodes].reverse().map((n) => [n, 0]); | ||
| while (stack.length > 0) { | ||
| const [curr, d] = stack.pop(); | ||
| if (d >= depth) continue; | ||
| if (!seedSet.has(curr) && graph.degree(curr) >= hubThreshold) { | ||
| continue; | ||
| } | ||
| graph.forEachNeighbor(curr, (neighbor) => { | ||
| if (!visited.has(neighbor)) { | ||
| visited.add(neighbor); | ||
| stack.push([neighbor, d + 1]); | ||
| } | ||
| edges.push([curr, neighbor]); | ||
| }); | ||
| } | ||
| } | ||
| const traversedNodes = Array.from(visited); | ||
| const remainingNodes = traversedNodes.filter((n) => !seedSet.has(n)); | ||
| remainingNodes.sort((a, b) => graph.degree(b) - graph.degree(a)); | ||
| const orderedNodes = startNodes.filter((n) => visited.has(n)).concat(remainingNodes); | ||
| const seenEdges = /* @__PURE__ */ new Set(); | ||
| const finalEdges = []; | ||
| edges.forEach(([u, v]) => { | ||
| if (visited.has(u) && visited.has(v)) { | ||
| let edgeId; | ||
| let forward = true; | ||
| if (graph.hasDirectedEdge(u, v)) { | ||
| edgeId = graph.edges(u, v)[0]; | ||
| } else if (graph.hasDirectedEdge(v, u)) { | ||
| edgeId = graph.edges(v, u)[0]; | ||
| forward = false; | ||
| } | ||
| if (edgeId) { | ||
| const key = forward ? `${u}->${v}` : `${v}->${u}`; | ||
| if (!seenEdges.has(key)) { | ||
| seenEdges.add(key); | ||
| const attr = graph.getEdgeAttributes(edgeId); | ||
| finalEdges.push({ | ||
| u: forward ? u : v, | ||
| v: forward ? v : u, | ||
| rel: attr.type || "", | ||
| conf: attr.confidence || "" | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| }); | ||
| const startNames = startNodes.map( | ||
| (n) => graph.getNodeAttribute(n, "name") || n | ||
| ); | ||
| const header = `Traversal: ${mode.toUpperCase()} depth=${depth} | Start: [${startNames.join(", ")}] | ${orderedNodes.length} nodes found | ||
| `; | ||
| const charBudget = tokenBudget * 3; | ||
| const lines = []; | ||
| orderedNodes.forEach((nid) => { | ||
| const attr = graph.getNodeAttributes(nid); | ||
| const name = attr.name || nid; | ||
| const file = attr.file || ""; | ||
| const loc = attr.startLine || 0; | ||
| const community = attr.metadata?.community !== void 0 ? attr.metadata.community : ""; | ||
| lines.push(`NODE ${name} [src=${file} loc=${loc} community=${community}]`); | ||
| }); | ||
| finalEdges.forEach(({ u, v, rel, conf }) => { | ||
| const uLabel = graph.getNodeAttribute(u, "name") || u; | ||
| const vLabel = graph.getNodeAttribute(v, "name") || v; | ||
| const confStr = conf ? ` [${conf}]` : ""; | ||
| lines.push(`EDGE ${uLabel} --${rel}${confStr}--> ${vLabel}`); | ||
| }); | ||
| let output = header + lines.join("\n"); | ||
| if (output.length > charBudget) { | ||
| output = output.slice(0, charBudget) + ` | ||
| ... (truncated to ~${tokenBudget} token budget)`; | ||
| } | ||
| return output; | ||
| } | ||
| async function getGraphStats(targetDirOrGraph) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| let extractedCount = 0; | ||
| let inferredCount = 0; | ||
| let ambiguousCount = 0; | ||
| graph.forEachEdge((edgeId, attr) => { | ||
| if (attr.confidence === "EXTRACTED") extractedCount++; | ||
| else if (attr.confidence === "INFERRED") inferredCount++; | ||
| else if (attr.confidence === "AMBIGUOUS") ambiguousCount++; | ||
| }); | ||
| const total = graph.size || 1; | ||
| const extPct = (extractedCount / total * 100).toFixed(1); | ||
| const infPct = (inferredCount / total * 100).toFixed(1); | ||
| const ambPct = (ambiguousCount / total * 100).toFixed(1); | ||
| let communitiesCount = 0; | ||
| try { | ||
| const { detectCommunities } = await import("./analyze-6FZL63EZ.js"); | ||
| communitiesCount = detectCommunities(graph).length; | ||
| } catch { | ||
| } | ||
| return [ | ||
| `Nodes: ${graph.order}`, | ||
| `Edges: ${graph.size}`, | ||
| `Communities: ${communitiesCount}`, | ||
| `EXTRACTED: ${extractedCount} (${extPct}%)`, | ||
| `INFERRED: ${inferredCount} (${infPct}%)`, | ||
| `AMBIGUOUS: ${ambiguousCount} (${ambPct}%)` | ||
| ].join("\n"); | ||
| } | ||
| export { | ||
| getCommunityNodes, | ||
| getGodNodes, | ||
| getGraphStats, | ||
| getNeighbors, | ||
| getNode, | ||
| getSurprisingConnections, | ||
| loadGraph, | ||
| queryGraph, | ||
| searchGraph, | ||
| shortestPath | ||
| }; |
| // src/parsers/rust.ts | ||
| import Parser from "web-tree-sitter"; | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| var RUST_STDLIB_CRATES = /* @__PURE__ */ new Set([ | ||
| "std", | ||
| "core", | ||
| "alloc", | ||
| "proc_macro", | ||
| "test", | ||
| // Common std sub-crates people import directly | ||
| "collections", | ||
| "env", | ||
| "fmt", | ||
| "fs", | ||
| "io", | ||
| "iter", | ||
| "mem", | ||
| "net", | ||
| "num", | ||
| "ops", | ||
| "os", | ||
| "path", | ||
| "process", | ||
| "ptr", | ||
| "rc", | ||
| "result", | ||
| "slice", | ||
| "str", | ||
| "string", | ||
| "sync", | ||
| "thread", | ||
| "time", | ||
| "vec", | ||
| "any", | ||
| "borrow", | ||
| "boxed", | ||
| "cell", | ||
| "clone", | ||
| "cmp", | ||
| "convert", | ||
| "default", | ||
| "error", | ||
| "ffi", | ||
| "future", | ||
| "hash", | ||
| "marker", | ||
| "option", | ||
| "panic", | ||
| "pin", | ||
| "prelude", | ||
| "task" | ||
| ]); | ||
| var RUST_BUILT_INS = /* @__PURE__ */ new Set([ | ||
| // Prelude types, structs, traits, macros | ||
| "Option", | ||
| "Result", | ||
| "Some", | ||
| "None", | ||
| "Ok", | ||
| "Err", | ||
| "Vec", | ||
| "String", | ||
| "Box", | ||
| "Rc", | ||
| "Arc", | ||
| "Cell", | ||
| "RefCell", | ||
| "Cow", | ||
| "BTreeMap", | ||
| "BTreeSet", | ||
| "HashMap", | ||
| "HashSet", | ||
| "Default", | ||
| "Clone", | ||
| "Copy", | ||
| "Send", | ||
| "Sync", | ||
| "Fn", | ||
| "FnMut", | ||
| "FnOnce", | ||
| "Drop", | ||
| "AsRef", | ||
| "AsMut", | ||
| "Into", | ||
| "From", | ||
| "ToString", | ||
| "Iterator", | ||
| "IntoIterator", | ||
| "DoubleEndedIterator", | ||
| "ExactSizeIterator", | ||
| "Display", | ||
| "Debug", | ||
| "Eq", | ||
| "PartialEq", | ||
| "Ord", | ||
| "PartialOrd", | ||
| "Hash", | ||
| "Sized", | ||
| "Unpin", | ||
| "Add", | ||
| "Sub", | ||
| "Mul", | ||
| "Div", | ||
| "Rem", | ||
| "Neg", | ||
| "Not", | ||
| "BitAnd", | ||
| "BitOr", | ||
| "BitXor", | ||
| "Shl", | ||
| "Shr", | ||
| "Index", | ||
| "IndexMut", | ||
| "Deref", | ||
| "DerefMut", | ||
| // Standard lowercase built-in methods — these are instance methods that never define graph nodes | ||
| "to_string", | ||
| "clone", | ||
| "as_str", | ||
| "len", | ||
| "is_empty", | ||
| "push", | ||
| "insert", | ||
| "remove", | ||
| "unwrap", | ||
| "expect", | ||
| "map", | ||
| "and_then", | ||
| "unwrap_or", | ||
| "unwrap_or_else", | ||
| "iter", | ||
| "iter_mut", | ||
| "into_iter", | ||
| "collect", | ||
| "contains", | ||
| "get", | ||
| "set", | ||
| "as_ref", | ||
| "as_mut", | ||
| "into", | ||
| "from", | ||
| "try_from", | ||
| "try_into", | ||
| "ok", | ||
| "err", | ||
| "is_ok", | ||
| "is_err", | ||
| "is_some", | ||
| "is_none", | ||
| "to_owned", | ||
| "to_vec", | ||
| "as_slice", | ||
| "as_bytes", | ||
| "chars", | ||
| "bytes", | ||
| "lines", | ||
| "split", | ||
| "trim", | ||
| "starts_with", | ||
| "ends_with", | ||
| "contains", | ||
| "replace", | ||
| "find", | ||
| "rfind", | ||
| "push_str", | ||
| "pop", | ||
| "clear", | ||
| "extend", | ||
| "drain", | ||
| "retain", | ||
| "sort", | ||
| "sort_by", | ||
| "reverse", | ||
| "filter", | ||
| "filter_map", | ||
| "flat_map", | ||
| "fold", | ||
| "for_each", | ||
| "any", | ||
| "all", | ||
| "count", | ||
| "enumerate", | ||
| "zip", | ||
| "take", | ||
| "skip", | ||
| "chain", | ||
| "peekable", | ||
| "fuse", | ||
| "read", | ||
| "write", | ||
| "flush", | ||
| "seek", | ||
| "read_to_string", | ||
| "write_all", | ||
| "lock", | ||
| "try_lock", | ||
| "join", | ||
| "spawn", | ||
| "with_capacity", | ||
| "capacity", | ||
| "reserve", | ||
| "shrink_to_fit", | ||
| "entry", | ||
| "or_insert", | ||
| "or_insert_with", | ||
| "and_modify", | ||
| "key", | ||
| "keys", | ||
| "values", | ||
| "min", | ||
| "max", | ||
| "clamp", | ||
| "abs", | ||
| "pow", | ||
| "sqrt", | ||
| "parse", | ||
| "format", | ||
| "display", | ||
| // Primitive types | ||
| "u8", | ||
| "u16", | ||
| "u32", | ||
| "u64", | ||
| "u128", | ||
| "usize", | ||
| "i8", | ||
| "i16", | ||
| "i32", | ||
| "i64", | ||
| "i128", | ||
| "isize", | ||
| "f32", | ||
| "f64", | ||
| "bool", | ||
| "char", | ||
| "str", | ||
| // Macros | ||
| "println", | ||
| "print", | ||
| "format", | ||
| "panic", | ||
| "vec", | ||
| "write", | ||
| "writeln", | ||
| "dbg", | ||
| "todo", | ||
| "unimplemented", | ||
| "assert", | ||
| "assert_eq", | ||
| "assert_ne", | ||
| "cfg", | ||
| "env", | ||
| "option_env", | ||
| "file", | ||
| "line", | ||
| "column", | ||
| "module_path", | ||
| "stringify", | ||
| "concat", | ||
| "include", | ||
| "include_str", | ||
| "include_bytes", | ||
| "eprintln", | ||
| "eprint", | ||
| // Keywords & Namespaces | ||
| "self", | ||
| "Self", | ||
| "super", | ||
| "crate", | ||
| "std", | ||
| "core", | ||
| "alloc", | ||
| "new" | ||
| ]); | ||
| var rustCratesCache = null; | ||
| function findProjectRustCrates(workspaceRoot) { | ||
| if (rustCratesCache) return rustCratesCache; | ||
| const crates = []; | ||
| const scanDir = (dir, depth) => { | ||
| if (depth > 4) return; | ||
| try { | ||
| const entries = fs.readdirSync(dir, { withFileTypes: true }); | ||
| const hasToml = entries.find((e) => e.isFile() && e.name === "Cargo.toml"); | ||
| if (hasToml) { | ||
| const tomlPath = path.join(dir, "Cargo.toml"); | ||
| const content = fs.readFileSync(tomlPath, "utf-8"); | ||
| const match = content.match(/^\s*\[package\][^]*?name\s*=\s*"([^"]+)"/m) || content.match(/^\s*\[package\][^]*?name\s*=\s*([^\s]+)/m); | ||
| if (match && match[1]) { | ||
| crates.push({ name: match[1].replace(/"/g, "").trim(), rootDir: dir }); | ||
| } | ||
| } | ||
| for (const entry of entries) { | ||
| if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules" && entry.name !== "target") { | ||
| scanDir(path.join(dir, entry.name), depth + 1); | ||
| } | ||
| } | ||
| } catch { | ||
| } | ||
| }; | ||
| scanDir(workspaceRoot, 0); | ||
| rustCratesCache = crates; | ||
| return crates; | ||
| } | ||
| function findNearestRustDescriptor(dir) { | ||
| let current = dir; | ||
| while (true) { | ||
| const candidate = path.join(current, "Cargo.toml"); | ||
| if (fs.existsSync(candidate)) return candidate; | ||
| const parent = path.dirname(current); | ||
| if (parent === current) break; | ||
| current = parent; | ||
| } | ||
| return ""; | ||
| } | ||
| function resolveRustImport(usePathText, sourceFilePath) { | ||
| const sourceDir = path.dirname(sourceFilePath); | ||
| const parts = usePathText.split("::"); | ||
| const firstPart = parts[0] || ""; | ||
| if (firstPart === "crate") { | ||
| let current = sourceDir; | ||
| let crateRoot = sourceDir; | ||
| while (current) { | ||
| if (fs.existsSync(path.join(current, "Cargo.toml")) || fs.existsSync(path.join(current, "src", "main.rs")) || fs.existsSync(path.join(current, "src", "lib.rs"))) { | ||
| crateRoot = fs.existsSync(path.join(current, "src")) ? path.join(current, "src") : current; | ||
| break; | ||
| } | ||
| const parent = path.dirname(current); | ||
| if (parent === current) break; | ||
| current = parent; | ||
| } | ||
| const relativeParts = parts.slice(1); | ||
| const candidates = [ | ||
| path.join(crateRoot, ...relativeParts) + ".rs", | ||
| path.join(crateRoot, ...relativeParts, "mod.rs") | ||
| ]; | ||
| for (const candidate of candidates) { | ||
| if (fs.existsSync(candidate)) return candidate; | ||
| } | ||
| } | ||
| const localModuleFile = path.join(sourceDir, firstPart) + ".rs"; | ||
| if (fs.existsSync(localModuleFile) && path.resolve(localModuleFile) !== path.resolve(sourceFilePath)) { | ||
| return localModuleFile; | ||
| } | ||
| const localModFile = path.join(sourceDir, firstPart, "mod.rs"); | ||
| if (fs.existsSync(localModFile)) { | ||
| return localModFile; | ||
| } | ||
| const workspaceRoot = process.cwd(); | ||
| const crates = findProjectRustCrates(workspaceRoot); | ||
| const matchingCrate = crates.find((c) => c.name === firstPart); | ||
| if (matchingCrate) { | ||
| const relativeParts = parts.slice(1); | ||
| const candidates = [ | ||
| path.join(matchingCrate.rootDir, "src", ...relativeParts) + ".rs", | ||
| path.join(matchingCrate.rootDir, "src", ...relativeParts, "mod.rs"), | ||
| path.join(matchingCrate.rootDir, ...relativeParts) + ".rs", | ||
| path.join(matchingCrate.rootDir, ...relativeParts, "mod.rs") | ||
| ]; | ||
| for (const candidate of candidates) { | ||
| if (fs.existsSync(candidate)) return candidate; | ||
| } | ||
| } | ||
| if (firstPart === "self" || firstPart === "super") { | ||
| const relativeParts = firstPart === "super" ? ["..", ...parts.slice(1)] : parts.slice(1); | ||
| const candidates = [ | ||
| path.resolve(sourceDir, ...relativeParts) + ".rs", | ||
| path.resolve(sourceDir, ...relativeParts, "mod.rs") | ||
| ]; | ||
| for (const candidate of candidates) { | ||
| if (fs.existsSync(candidate)) return candidate; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| function isRustStdlib(usePath) { | ||
| const firstCrate = usePath.split("::")[0] || usePath; | ||
| return RUST_STDLIB_CRATES.has(firstCrate); | ||
| } | ||
| function extractRustDoc(node) { | ||
| const comments = []; | ||
| let prev = node.previousSibling; | ||
| while (prev && (prev.type === "attribute_item" || prev.type === "inner_attribute_item")) { | ||
| prev = prev.previousSibling; | ||
| } | ||
| while (prev && prev.type === "line_comment" && (prev.text.startsWith("///") || prev.text.startsWith("//!"))) { | ||
| comments.unshift(prev.text.replace(/^\/\/\/|^\/\/!/g, "").trim()); | ||
| prev = prev.previousSibling; | ||
| } | ||
| return comments.length > 0 ? comments.join("\n") : void 0; | ||
| } | ||
| var getRustImplType = (node) => { | ||
| if (node.type !== "impl_item") return ""; | ||
| const typeNode = node.childForFieldName("type"); | ||
| if (!typeNode) return ""; | ||
| let typeName = ""; | ||
| const findType = (n) => { | ||
| if (typeName) return; | ||
| if (n.type === "type_identifier") { | ||
| typeName = n.text.trim(); | ||
| return; | ||
| } | ||
| for (let i = 0; i < n.namedChildCount; i++) { | ||
| const child = n.namedChild(i); | ||
| if (child) findType(child); | ||
| } | ||
| }; | ||
| findType(typeNode); | ||
| return typeName; | ||
| }; | ||
| function extractRustImports(node, prefix = "") { | ||
| const imports = []; | ||
| const recurse = (n, currentPrefix) => { | ||
| if (n.type === "identifier") { | ||
| const name = n.text.trim(); | ||
| imports.push({ | ||
| localName: name, | ||
| originalName: name, | ||
| usePath: currentPrefix ? `${currentPrefix}::${name}` : name | ||
| }); | ||
| } else if (n.type === "scoped_identifier") { | ||
| const fullPath = n.text.trim(); | ||
| const name = fullPath.split("::").pop() || fullPath; | ||
| imports.push({ | ||
| localName: name, | ||
| originalName: name, | ||
| usePath: currentPrefix ? `${currentPrefix}::${fullPath}` : fullPath | ||
| }); | ||
| } else if (n.type === "use_as_clause") { | ||
| const origNode = n.namedChild(0); | ||
| const aliasNode = n.namedChild(1); | ||
| if (origNode && aliasNode) { | ||
| const origPath = origNode.text.trim(); | ||
| const origName = origPath.split("::").pop() || origPath; | ||
| const alias = aliasNode.text.trim(); | ||
| imports.push({ | ||
| localName: alias, | ||
| originalName: origName, | ||
| usePath: currentPrefix ? `${currentPrefix}::${origPath}` : origPath | ||
| }); | ||
| } | ||
| } else if (n.type === "scoped_use_list") { | ||
| const pathNode = n.namedChild(0); | ||
| const listNode = n.namedChild(1); | ||
| if (pathNode && listNode) { | ||
| const pathText = pathNode.text.trim(); | ||
| const nextPrefix = currentPrefix ? `${currentPrefix}::${pathText}` : pathText; | ||
| recurse(listNode, nextPrefix); | ||
| } | ||
| } else if (n.type === "use_list") { | ||
| for (let i = 0; i < n.namedChildCount; i++) { | ||
| const child = n.namedChild(i); | ||
| if (!child) continue; | ||
| if (child.type === "self") { | ||
| const lastPart = currentPrefix.split("::").pop() || currentPrefix; | ||
| imports.push({ | ||
| localName: lastPart, | ||
| originalName: lastPart, | ||
| usePath: currentPrefix | ||
| }); | ||
| } else { | ||
| recurse(child, currentPrefix); | ||
| } | ||
| } | ||
| } | ||
| }; | ||
| recurse(node, prefix); | ||
| return imports; | ||
| } | ||
| function isLocalDeclaration(startNode, name) { | ||
| let current = startNode; | ||
| const checkPattern = (patternNode) => { | ||
| if (patternNode.type === "identifier" || patternNode.type === "type_identifier") { | ||
| return patternNode.text.trim() === name; | ||
| } | ||
| for (let i = 0; i < patternNode.namedChildCount; i++) { | ||
| const child = patternNode.namedChild(i); | ||
| if (child && checkPattern(child)) return true; | ||
| } | ||
| return false; | ||
| }; | ||
| while (current) { | ||
| if (current.type === "function_item" || current.type === "closure_expression") { | ||
| const paramsNode = current.childForFieldName("parameters"); | ||
| if (paramsNode && checkPattern(paramsNode)) { | ||
| return true; | ||
| } | ||
| const typeParamsNode = current.childForFieldName("type_parameters"); | ||
| if (typeParamsNode && checkPattern(typeParamsNode)) { | ||
| return true; | ||
| } | ||
| } | ||
| if (current.type === "for_expression") { | ||
| const patternNode = current.childForFieldName("pattern"); | ||
| if (patternNode && checkPattern(patternNode)) { | ||
| return true; | ||
| } | ||
| } | ||
| if (current.type === "match_arm") { | ||
| const patternNode = current.childForFieldName("pattern"); | ||
| if (patternNode && checkPattern(patternNode)) { | ||
| return true; | ||
| } | ||
| } | ||
| if (current.type === "if_let_expression" || current.type === "while_let_expression") { | ||
| const patternNode = current.childForFieldName("pattern"); | ||
| if (patternNode && checkPattern(patternNode)) { | ||
| return true; | ||
| } | ||
| } | ||
| if (current.type === "block") { | ||
| for (let i = 0; i < current.namedChildCount; i++) { | ||
| const statement = current.namedChild(i); | ||
| if (!statement) continue; | ||
| if (statement.type === "let_declaration") { | ||
| const patternNode = statement.childForFieldName("pattern") || statement.namedChild(0); | ||
| if (patternNode && checkPattern(patternNode)) { | ||
| if (statement.startIndex <= (startNode?.startIndex ?? 0)) { | ||
| return true; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| current = current.parent; | ||
| } | ||
| return false; | ||
| } | ||
| function readSourceFile(filePath) { | ||
| const buffer = fs.readFileSync(filePath); | ||
| if (buffer.length >= 2 && buffer[0] === 255 && buffer[1] === 254) { | ||
| return buffer.toString("utf16le").replace(/^\uFEFF/, ""); | ||
| } | ||
| if (buffer.length >= 2 && buffer[0] === 254 && buffer[1] === 255) { | ||
| const swapped = Buffer.alloc(buffer.length); | ||
| for (let i = 0; i < buffer.length - 1; i += 2) { | ||
| swapped[i] = buffer[i + 1]; | ||
| swapped[i + 1] = buffer[i]; | ||
| } | ||
| return swapped.toString("utf16le").replace(/^\uFEFF/, ""); | ||
| } | ||
| let content = buffer.toString("utf8"); | ||
| if (content.startsWith("\uFEFF")) { | ||
| content = content.slice(1); | ||
| } | ||
| return content.replace(/\0/g, ""); | ||
| } | ||
| function parseRust(filePath, graph, language) { | ||
| const parser = new Parser(); | ||
| try { | ||
| parser.setLanguage(language); | ||
| } catch { | ||
| return; | ||
| } | ||
| let sourceCode; | ||
| try { | ||
| sourceCode = readSourceFile(filePath); | ||
| } catch { | ||
| return; | ||
| } | ||
| const tree = parser.parse(sourceCode); | ||
| if (!tree) return; | ||
| const localDefinitions = /* @__PURE__ */ new Set(); | ||
| const importMap = /* @__PURE__ */ new Map(); | ||
| const importOriginalNameMap = /* @__PURE__ */ new Map(); | ||
| const localMethodMap = /* @__PURE__ */ new Map(); | ||
| const getRootIdentifier = (node) => { | ||
| if (!node) return ""; | ||
| if (node.type === "identifier" || node.type === "self" || node.type === "Self" || node.type === "super" || node.type === "crate") { | ||
| return node.text.trim(); | ||
| } | ||
| if (node.type === "field_expression") { | ||
| return getRootIdentifier(node.childForFieldName("value")); | ||
| } | ||
| if (node.type === "call_expression") { | ||
| return getRootIdentifier(node.childForFieldName("function")); | ||
| } | ||
| if (node.type === "await_expression" || node.type === "try_expression" || node.type === "parenthesized_expression" || node.type === "type_cast_expression") { | ||
| const valueNode = node.childForFieldName("value") || node.namedChild(0); | ||
| return getRootIdentifier(valueNode); | ||
| } | ||
| return ""; | ||
| }; | ||
| const queryString = ` | ||
| (use_declaration argument: (_) @use_source) | ||
| (struct_item name: (type_identifier) @struct_decl) | ||
| (enum_item name: (type_identifier) @enum_decl) | ||
| (trait_item name: (type_identifier) @trait_decl) | ||
| (function_item name: (identifier) @func_decl) | ||
| (macro_definition name: (identifier) @macro_decl) | ||
| (call_expression function: (identifier) @call_name) | ||
| (call_expression function: (field_expression field: (field_identifier) @method_call)) | ||
| (call_expression function: (scoped_identifier name: (identifier) @scoped_call) path: (_)? @scoped_path) | ||
| (macro_invocation macro: (identifier) @macro_name) | ||
| (type_identifier) @type_reference | ||
| `; | ||
| const query = language.query(queryString); | ||
| const matches = query.matches(tree.rootNode); | ||
| const getEnclosingScopePath = (startNode) => { | ||
| const pathParts = []; | ||
| let current = startNode; | ||
| while (current) { | ||
| if (current.type === "impl_item") { | ||
| const implType = getRustImplType(current); | ||
| if (implType) pathParts.unshift(implType); | ||
| } else if (current.type === "struct_item" || current.type === "enum_item" || current.type === "trait_item" || current.type === "function_item") { | ||
| const nameNode = current.childForFieldName("name"); | ||
| if (nameNode) pathParts.unshift(nameNode.text.trim()); | ||
| } | ||
| current = current.parent; | ||
| } | ||
| return pathParts.join("."); | ||
| }; | ||
| for (const match of matches) { | ||
| for (const capture of match.captures) { | ||
| const node = capture.node; | ||
| const captureName = capture.name; | ||
| if (captureName === "use_source") { | ||
| const extracted = extractRustImports(node); | ||
| for (const imp of extracted) { | ||
| if (imp.localName && imp.usePath) { | ||
| importMap.set(imp.localName, imp.usePath); | ||
| if (imp.originalName && imp.originalName !== imp.localName) { | ||
| importOriginalNameMap.set(imp.localName, imp.originalName); | ||
| } | ||
| } | ||
| } | ||
| } else if (captureName === "struct_decl" || captureName === "enum_decl" || captureName === "trait_decl" || captureName === "func_decl" || captureName === "macro_decl") { | ||
| const symName = node.text.trim(); | ||
| if (symName) { | ||
| localDefinitions.add(symName); | ||
| const scopePrefix = getEnclosingScopePath(node.parent?.parent); | ||
| const symId = scopePrefix ? `${filePath}::${scopePrefix}.${symName}` : `${filePath}::${symName}`; | ||
| if (captureName === "func_decl") { | ||
| if (!localMethodMap.has(symName)) { | ||
| localMethodMap.set(symName, []); | ||
| } | ||
| localMethodMap.get(symName).push(symId); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| for (const match of matches) { | ||
| for (const capture of match.captures) { | ||
| const node = capture.node; | ||
| const captureName = capture.name; | ||
| if (captureName === "struct_decl" || captureName === "enum_decl" || captureName === "trait_decl" || captureName === "func_decl" || captureName === "macro_decl") { | ||
| const symName = node.text.trim(); | ||
| if (!symName) continue; | ||
| const scopePrefix = getEnclosingScopePath(node.parent?.parent); | ||
| const symId = scopePrefix ? `${filePath}::${scopePrefix}.${symName}` : `${filePath}::${symName}`; | ||
| const parentId = scopePrefix ? `${filePath}::${scopePrefix}` : filePath; | ||
| let nodeType = "function"; | ||
| if (captureName === "struct_decl") nodeType = "struct"; | ||
| else if (captureName === "enum_decl") nodeType = "enum"; | ||
| else if (captureName === "trait_decl") nodeType = "trait"; | ||
| else if (captureName === "macro_decl") nodeType = "macro"; | ||
| const decl = node.parent || node; | ||
| const doc = extractRustDoc(decl); | ||
| const nodeAttrs = { | ||
| type: nodeType, | ||
| name: symName, | ||
| file: filePath, | ||
| startLine: decl.startPosition.row + 1, | ||
| metadata: { | ||
| endLine: decl.endPosition.row + 1, | ||
| doc, | ||
| external: false, | ||
| unresolved: false | ||
| } | ||
| }; | ||
| if (!graph.hasNode(symId)) { | ||
| graph.addNode(symId, nodeAttrs); | ||
| graph.addEdge(parentId, symId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } else { | ||
| graph.mergeNodeAttributes(symId, nodeAttrs); | ||
| if (!graph.hasEdge(parentId, symId)) { | ||
| graph.addEdge(parentId, symId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } | ||
| } | ||
| } else if (captureName === "use_source") { | ||
| const extracted = extractRustImports(node); | ||
| for (const imp of extracted) { | ||
| const usePath = imp.usePath; | ||
| if (usePath === "" || usePath === "*" || usePath === "self" || usePath === "super") continue; | ||
| const resolvedPath = resolveRustImport(usePath, filePath); | ||
| if (!resolvedPath && isRustStdlib(usePath)) continue; | ||
| const importNodeId = resolvedPath || `import::${usePath.split("::")[0] || usePath}`; | ||
| if (importNodeId === "import::" || importNodeId.trim() === "") continue; | ||
| if (!graph.hasNode(importNodeId)) { | ||
| const nearestDescriptor = findNearestRustDescriptor(path.dirname(filePath)); | ||
| graph.addNode(importNodeId, { | ||
| type: "file", | ||
| name: resolvedPath ? path.basename(resolvedPath) : usePath.split("::")[0] || usePath, | ||
| file: resolvedPath || nearestDescriptor || filePath, | ||
| startLine: 0, | ||
| metadata: resolvedPath ? {} : { external: true } | ||
| }); | ||
| } | ||
| if (!graph.hasEdge(filePath, importNodeId)) { | ||
| graph.addEdge(filePath, importNodeId, { type: "imports", confidence: "EXTRACTED" }); | ||
| } | ||
| } | ||
| } else if (captureName === "call_name" || captureName === "macro_name") { | ||
| const calledName = node.text.trim(); | ||
| if (!calledName || RUST_BUILT_INS.has(calledName)) continue; | ||
| if (isLocalDeclaration(node, calledName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| const targets = []; | ||
| const importSource = importMap.get(calledName); | ||
| if (importSource) { | ||
| const resolvedSource = resolveRustImport(importSource, filePath); | ||
| const targetBase = resolvedSource || `import::${importSource}`; | ||
| const originalName = importOriginalNameMap.get(calledName) || calledName; | ||
| targets.push({ | ||
| id: `${targetBase}::${originalName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (localDefinitions.has(calledName)) { | ||
| const localMatches = localMethodMap.get(calledName) || []; | ||
| if (localMatches.length === 1 && localMatches[0]) { | ||
| targets.push({ | ||
| id: localMatches[0], | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (localMatches.length > 1) { | ||
| const callerScope2 = getEnclosingScopePath(node.parent); | ||
| let bestMatch = localMatches[0]; | ||
| let maxCommon = -1; | ||
| for (const matchId of localMatches) { | ||
| const matchScope = matchId.split("::")[1] || ""; | ||
| let common = 0; | ||
| const matchParts = matchScope.split("."); | ||
| const callerParts = callerScope2.split("."); | ||
| for (let i = 0; i < Math.min(matchParts.length, callerParts.length); i++) { | ||
| if (matchParts[i] === callerParts[i]) common++; | ||
| else break; | ||
| } | ||
| if (common > maxCommon) { | ||
| maxCommon = common; | ||
| bestMatch = matchId; | ||
| } | ||
| } | ||
| targets.push({ | ||
| id: bestMatch, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else { | ||
| const scopePrefix = getEnclosingScopePath(node.parent?.parent); | ||
| targets.push({ | ||
| id: scopePrefix ? `${filePath}::${scopePrefix}.${calledName}` : `${filePath}::${calledName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } | ||
| } else { | ||
| targets.push({ | ||
| id: `unresolved::${calledName}`, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| for (const target of targets) { | ||
| const isUnresolved = target.id.startsWith("unresolved::"); | ||
| const isExternal = target.id.startsWith("import::") || !isUnresolved && !target.id.startsWith(filePath); | ||
| const callLine = node.startPosition.row + 1; | ||
| if (!graph.hasNode(target.id)) { | ||
| let fileAttr = filePath; | ||
| if (isUnresolved) { | ||
| fileAttr = filePath; | ||
| } else if (target.id.includes("::")) { | ||
| fileAttr = target.id.split("::")[0] || filePath; | ||
| } | ||
| graph.addNode(target.id, { | ||
| type: captureName === "macro_name" ? "macro" : "function", | ||
| name: calledName, | ||
| file: fileAttr, | ||
| startLine: isExternal ? 0 : callLine, | ||
| metadata: { | ||
| external: isExternal, | ||
| unresolved: isUnresolved, | ||
| endLine: isExternal ? 0 : callLine, | ||
| callerFile: isUnresolved ? filePath : void 0, | ||
| callerLine: isUnresolved ? callLine : void 0 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === target.id) continue; | ||
| if (!graph.hasEdge(callerId, target.id)) { | ||
| graph.addEdge(callerId, target.id, { type: "calls", confidence: target.confidence }); | ||
| } | ||
| } | ||
| } else if (captureName === "method_call") { | ||
| const methodName = node.text.trim(); | ||
| if (!methodName || RUST_BUILT_INS.has(methodName)) continue; | ||
| let objectName = ""; | ||
| if (node.parent && node.parent.type === "field_expression") { | ||
| const valueNode = node.parent.childForFieldName("value"); | ||
| if (valueNode) objectName = getRootIdentifier(valueNode); | ||
| } | ||
| if (objectName && RUST_STDLIB_CRATES.has(objectName)) continue; | ||
| if (isLocalDeclaration(node, objectName ? `${objectName}.${methodName}` : methodName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| const targets = []; | ||
| const importSource = importMap.get(objectName); | ||
| if (importSource) { | ||
| const resolvedSource = resolveRustImport(importSource, filePath); | ||
| if (!resolvedSource && isRustStdlib(importSource)) continue; | ||
| const targetBase = resolvedSource || `import::${importSource}`; | ||
| targets.push({ | ||
| id: `${targetBase}::${methodName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else { | ||
| const localMatches = localMethodMap.get(methodName) || []; | ||
| if (localMatches.length === 1 && localMatches[0]) { | ||
| targets.push({ | ||
| id: localMatches[0], | ||
| confidence: "INFERRED" | ||
| }); | ||
| } else if (localMatches.length > 1) { | ||
| for (const matchId of localMatches) { | ||
| targets.push({ | ||
| id: matchId, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } else { | ||
| targets.push({ | ||
| id: `unresolved::${methodName}`, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } | ||
| for (const target of targets) { | ||
| const isUnresolved = target.id.startsWith("unresolved::"); | ||
| const isExternal = target.id.startsWith("import::") || !isUnresolved && !target.id.startsWith(filePath); | ||
| const callLine = node.startPosition.row + 1; | ||
| if (!graph.hasNode(target.id)) { | ||
| let fileAttr = filePath; | ||
| if (isUnresolved) { | ||
| fileAttr = filePath; | ||
| } else if (target.id.includes("::")) { | ||
| fileAttr = target.id.split("::")[0] || filePath; | ||
| } | ||
| graph.addNode(target.id, { | ||
| type: "function", | ||
| name: objectName ? `${objectName}.${methodName}` : methodName, | ||
| file: fileAttr, | ||
| startLine: isExternal ? 0 : callLine, | ||
| metadata: { | ||
| external: isExternal, | ||
| unresolved: isUnresolved, | ||
| endLine: isExternal ? 0 : callLine, | ||
| callerFile: isUnresolved ? filePath : void 0, | ||
| callerLine: isUnresolved ? callLine : void 0 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === target.id) continue; | ||
| if (!graph.hasEdge(callerId, target.id)) { | ||
| graph.addEdge(callerId, target.id, { type: "calls", confidence: target.confidence }); | ||
| } | ||
| } | ||
| } else if (captureName === "scoped_call") { | ||
| const calledName = node.text.trim(); | ||
| if (!calledName || RUST_BUILT_INS.has(calledName)) continue; | ||
| let pathPrefix = ""; | ||
| if (node.parent && node.parent.type === "scoped_identifier") { | ||
| const pathNode = node.parent.childForFieldName("path"); | ||
| if (pathNode) pathPrefix = pathNode.text.trim(); | ||
| } | ||
| if (pathPrefix && RUST_STDLIB_CRATES.has(pathPrefix)) continue; | ||
| if (isLocalDeclaration(node, pathPrefix ? `${pathPrefix}::${calledName}` : calledName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| const targets = []; | ||
| const importSource = importMap.get(pathPrefix); | ||
| if (importSource) { | ||
| const resolvedSource = resolveRustImport(importSource, filePath); | ||
| if (!resolvedSource && isRustStdlib(importSource)) continue; | ||
| const targetBase = resolvedSource || `import::${importSource}`; | ||
| targets.push({ | ||
| id: `${targetBase}::${calledName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (localDefinitions.has(pathPrefix)) { | ||
| targets.push({ | ||
| id: `${filePath}::${pathPrefix}.${calledName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else { | ||
| const localMatches = localMethodMap.get(calledName) || []; | ||
| if (localMatches.length === 1 && localMatches[0]) { | ||
| targets.push({ | ||
| id: localMatches[0], | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (localMatches.length > 1) { | ||
| for (const matchId of localMatches) { | ||
| targets.push({ | ||
| id: matchId, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } else { | ||
| targets.push({ | ||
| id: `unresolved::${pathPrefix ? pathPrefix + "::" : ""}${calledName}`, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } | ||
| for (const target of targets) { | ||
| const isUnresolved = target.id.startsWith("unresolved::"); | ||
| const isExternal = target.id.startsWith("import::") || !isUnresolved && !target.id.startsWith(filePath); | ||
| const callLine = node.startPosition.row + 1; | ||
| if (!graph.hasNode(target.id)) { | ||
| let fileAttr = filePath; | ||
| if (isUnresolved) { | ||
| fileAttr = filePath; | ||
| } else if (target.id.includes("::")) { | ||
| fileAttr = target.id.split("::")[0] || filePath; | ||
| } | ||
| graph.addNode(target.id, { | ||
| type: "function", | ||
| name: pathPrefix ? `${pathPrefix}::${calledName}` : calledName, | ||
| file: fileAttr, | ||
| startLine: isExternal ? 0 : callLine, | ||
| metadata: { | ||
| external: isExternal, | ||
| unresolved: isUnresolved, | ||
| endLine: isExternal ? 0 : callLine, | ||
| callerFile: isUnresolved ? filePath : void 0, | ||
| callerLine: isUnresolved ? callLine : void 0 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === target.id) continue; | ||
| if (!graph.hasEdge(callerId, target.id)) { | ||
| graph.addEdge(callerId, target.id, { type: "calls", confidence: target.confidence }); | ||
| } | ||
| } | ||
| } else if (captureName === "type_reference") { | ||
| if (node.parent && ["struct_item", "enum_item", "trait_item", "impl_item", "type_bound", "bounded_type"].includes(node.parent.type)) { | ||
| continue; | ||
| } | ||
| const referencedTypeName = node.text.trim(); | ||
| if (!referencedTypeName || RUST_BUILT_INS.has(referencedTypeName)) continue; | ||
| if (isLocalDeclaration(node, referencedTypeName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| if (!graph.hasNode(callerId)) continue; | ||
| let targetId; | ||
| const importSource = importMap.get(referencedTypeName); | ||
| if (localDefinitions.has(referencedTypeName)) { | ||
| targetId = `${filePath}::${referencedTypeName}`; | ||
| } else if (importSource) { | ||
| const resolvedSource = resolveRustImport(importSource, filePath); | ||
| if (!resolvedSource && isRustStdlib(importSource)) continue; | ||
| const originalName = importOriginalNameMap.get(referencedTypeName) || referencedTypeName; | ||
| targetId = `${resolvedSource || `import::${importSource}`}::${originalName}`; | ||
| } else { | ||
| targetId = `unresolved::${referencedTypeName}`; | ||
| } | ||
| const isUnresolved = !localDefinitions.has(referencedTypeName) && !importSource; | ||
| if (!graph.hasNode(targetId)) { | ||
| graph.addNode(targetId, { | ||
| type: "struct", | ||
| name: referencedTypeName, | ||
| file: filePath, | ||
| startLine: node.startPosition.row + 1, | ||
| metadata: { | ||
| external: !!importSource, | ||
| unresolved: isUnresolved, | ||
| endLine: node.startPosition.row + 1, | ||
| callerFile: filePath, | ||
| callerLine: node.startPosition.row + 1 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === targetId) continue; | ||
| if (!graph.hasEdge(callerId, targetId)) { | ||
| graph.addEdge(callerId, targetId, { type: "references", confidence: isUnresolved ? "AMBIGUOUS" : "EXTRACTED" }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| export { | ||
| parseRust | ||
| }; |
| // src/core/scanner.ts | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| import fg from "fast-glob"; | ||
| import ignorePkg from "ignore"; | ||
| var createIgnore = typeof ignorePkg === "function" ? ignorePkg : ignorePkg.default; | ||
| var SUPPORTED_EXTENSIONS = [ | ||
| // Code & Data | ||
| "ts", | ||
| "js", | ||
| "tsx", | ||
| "jsx", | ||
| "json", | ||
| "md", | ||
| "py", | ||
| "java", | ||
| "go", | ||
| "rs", | ||
| "cpp", | ||
| "cc", | ||
| "cxx", | ||
| "h", | ||
| "hpp", | ||
| // Images | ||
| "png", | ||
| "jpg", | ||
| "jpeg", | ||
| "svg", | ||
| "gif", | ||
| "webp", | ||
| // Video & Audio | ||
| "mp4", | ||
| "webm", | ||
| "mp3", | ||
| "wav" | ||
| ]; | ||
| async function scanDirectory(targetDir) { | ||
| const ig = createIgnore(); | ||
| const gitignorePath = path.resolve(targetDir, ".gitignore"); | ||
| if (fs.existsSync(gitignorePath)) { | ||
| ig.add(fs.readFileSync(gitignorePath, "utf8")); | ||
| } | ||
| const geraphignorePath = path.resolve(targetDir, ".geraphignore"); | ||
| if (fs.existsSync(geraphignorePath)) { | ||
| ig.add(fs.readFileSync(geraphignorePath, "utf8")); | ||
| } | ||
| ig.add(["node_modules", "dist", ".git", ".turbo", "build", ".geraph"]); | ||
| const globPatterns = SUPPORTED_EXTENSIONS.map((ext) => `**/*.${ext}`); | ||
| const normalizedCwd = targetDir.split(path.sep).join("/"); | ||
| const allFiles = await fg(globPatterns, { | ||
| cwd: normalizedCwd, | ||
| dot: true, | ||
| absolute: false | ||
| }); | ||
| const validRelativeFiles = ig.filter(allFiles); | ||
| return validRelativeFiles.sort().map((file) => path.resolve(targetDir, file)); | ||
| } | ||
| export { | ||
| scanDirectory | ||
| }; |
| // src/core/serializer.ts | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| function exportGraphJson(graph, outDir, analysis) { | ||
| if (!fs.existsSync(outDir)) { | ||
| fs.mkdirSync(outDir, { recursive: true }); | ||
| } | ||
| const nodes = graph.nodes().map((nodeId) => { | ||
| const data = graph.getNodeAttributes(nodeId); | ||
| return { | ||
| id: nodeId, | ||
| type: data.type, | ||
| name: data.name, | ||
| file: data.file, | ||
| startLine: data.startLine, | ||
| ...data.metadata | ||
| }; | ||
| }); | ||
| const edges = graph.edges().map((edgeId) => { | ||
| const source = graph.source(edgeId); | ||
| const target = graph.target(edgeId); | ||
| const data = graph.getEdgeAttributes(edgeId); | ||
| return { | ||
| source, | ||
| target, | ||
| relation: data.type, | ||
| confidence: data.confidence, | ||
| ...data.metadata | ||
| }; | ||
| }); | ||
| const payload = { | ||
| version: "1.0.0", | ||
| nodes, | ||
| edges | ||
| }; | ||
| if (analysis) { | ||
| payload.analysis = { | ||
| godNodes: analysis.godNodes, | ||
| communities: analysis.communities.map((c) => ({ | ||
| id: c.id, | ||
| nodeCount: c.nodes.length, | ||
| cohesion: c.cohesion, | ||
| members: c.nodes | ||
| })), | ||
| surprisingConnections: analysis.surprisingConnections | ||
| }; | ||
| } | ||
| const jsonPath = path.join(outDir, "graph.json"); | ||
| fs.writeFileSync(jsonPath, JSON.stringify(payload, null, 2), "utf-8"); | ||
| return jsonPath; | ||
| } | ||
| function exportReportMarkdown(graph, outDir, analysis) { | ||
| if (!fs.existsSync(outDir)) { | ||
| fs.mkdirSync(outDir, { recursive: true }); | ||
| } | ||
| let md = `# Geraph Codebase Report | ||
| `; | ||
| md += `## Architecture Structure | ||
| `; | ||
| md += `*Note: Only the first 100 files and their primary members are listed here. For full data, use the \`search_graph\` or \`query_graph\` MCP tools (recommended) or corresponding CLI commands.* | ||
| `; | ||
| const allFileNodes = graph.nodes().filter((n) => graph.getNodeAttribute(n, "type") === "file"); | ||
| const fileNodes = allFileNodes.slice(0, 100); | ||
| for (const file of fileNodes) { | ||
| const data = graph.getNodeAttributes(file); | ||
| if (data.metadata?.external) continue; | ||
| md += `- **${data.name}** [id: \`${file}\`] | ||
| `; | ||
| const definesEdges = graph.outEdges(file).filter((edgeId) => { | ||
| return graph.getEdgeAttribute(edgeId, "type") === "defines"; | ||
| }); | ||
| const membersToShow = definesEdges.slice(0, 5); | ||
| for (const edgeId of membersToShow) { | ||
| const target = graph.target(edgeId); | ||
| const targetData = graph.getNodeAttributes(target); | ||
| md += ` - \`${targetData.type} ${targetData.name}\` [id: \`${target}\`] | ||
| `; | ||
| } | ||
| if (definesEdges.length > 5) { | ||
| md += ` - ... and ${definesEdges.length - 5} more | ||
| `; | ||
| } | ||
| } | ||
| if (allFileNodes.length > 100) { | ||
| md += ` | ||
| - *... and ${allFileNodes.length - 100} more file nodes. Use \`search_graph\` or \`query_graph\` MCP tools (recommended) to explore them.* | ||
| `; | ||
| } | ||
| if (analysis && analysis.godNodes.length > 0) { | ||
| const realNodesCount = graph.nodes().filter((n) => { | ||
| const type = graph.getNodeAttribute(n, "type"); | ||
| return type !== "intent" && !n.startsWith("import::") && !n.startsWith("unresolved_"); | ||
| }).length; | ||
| const title = realNodesCount > 10 ? "Top 10 God Nodes (Architectural Pillars)" : "God Nodes (Architectural Pillars)"; | ||
| md += ` | ||
| ## ${title} | ||
| `; | ||
| md += `These are the most-connected entities in the codebase. Changes to these nodes have the largest ripple effect. For full data, use the \`god_nodes\` MCP tool (recommended) or \`geraph god\` CLI command. | ||
| `; | ||
| const godsToShow = analysis.godNodes.slice(0, 10); | ||
| for (const god of godsToShow) { | ||
| md += `- **${god.name}** (type: \`${god.type}\`, id: \`${god.id}\`, ${god.degree} connections) | ||
| `; | ||
| } | ||
| } | ||
| if (analysis && analysis.communities.length > 0) { | ||
| md += ` | ||
| ## Communities | ||
| `; | ||
| md += `The codebase clusters into ${analysis.communities.length} communities of related code. For full community membership, use the \`get_community\` MCP tool (recommended) or \`geraph community <id>\` CLI command. | ||
| `; | ||
| for (const comm of analysis.communities) { | ||
| const realNodes = comm.nodes.filter( | ||
| (n) => !n.startsWith("commit::") && !n.startsWith("import::") && !n.startsWith("unresolved_") && graph.hasNode(n) | ||
| ); | ||
| realNodes.sort((a, b) => graph.degree(b) - graph.degree(a)); | ||
| const top5 = realNodes.slice(0, 5); | ||
| const memberStrings = top5.map((n) => { | ||
| const data = graph.getNodeAttributes(n); | ||
| const name = data ? data.name : n; | ||
| return `**${name}** [id: \`${n}\`]`; | ||
| }); | ||
| let communityLine = `- **Community (ID: \`${comm.id}\`)** (${comm.nodes.length} nodes, cohesion: ${comm.cohesion}) \u2014 ${memberStrings.join(", ")}`; | ||
| if (realNodes.length > 5) { | ||
| communityLine += `, and ${realNodes.length - 5} more.`; | ||
| } | ||
| communityLine += ` | ||
| *(To view all members: run \`geraph community ${comm.id}\` or use the \`get_community\` MCP tool with community_id=${comm.id})* | ||
| `; | ||
| md += communityLine; | ||
| } | ||
| } | ||
| if (analysis && analysis.surprisingConnections.length > 0) { | ||
| const totalSurprises = analysis.surprisingConnections.length; | ||
| const title = totalSurprises > 10 ? "Top 10 Surprising Connections" : "Surprising Connections"; | ||
| md += ` | ||
| ## ${title} | ||
| `; | ||
| md += `Non-obvious couplings that bridge different parts of the architecture. For full data, use the \`get_surprises\` MCP tool (recommended) or \`geraph surprises\` CLI command. | ||
| `; | ||
| const surprisesToShow = analysis.surprisingConnections.slice(0, 10); | ||
| for (const s of surprisesToShow) { | ||
| md += `- **${s.sourceName}** [id: \`${s.source}\`] \u2194 **${s.targetName}** [id: \`${s.target}\`] (\`${s.edgeType}\`): ${s.why} | ||
| `; | ||
| } | ||
| } | ||
| if (analysis && analysis.knowledgeGaps && analysis.knowledgeGaps.length > 0) { | ||
| md += ` | ||
| ## Knowledge Gaps | ||
| `; | ||
| md += `These are isolated or nearly-isolated entities that may be missing documentation or architectural integration. | ||
| `; | ||
| for (const gap of analysis.knowledgeGaps) { | ||
| const data = graph.getNodeAttributes(gap); | ||
| md += `- \`${data.type} ${data.name}\` (isolated in ${path.basename(gap.split("::")[0] || gap)}) | ||
| `; | ||
| } | ||
| } | ||
| if (analysis && analysis.suggestedQuestions && analysis.suggestedQuestions.length > 0) { | ||
| md += ` | ||
| ## Suggested Questions for AI | ||
| `; | ||
| md += `_Questions this graph is uniquely positioned to answer:_ | ||
| `; | ||
| for (const q of analysis.suggestedQuestions) { | ||
| md += `- **${q}** | ||
| `; | ||
| } | ||
| } | ||
| const intentNodes = graph.nodes().filter((n) => graph.getNodeAttribute(n, "type") === "intent").sort((a, b) => { | ||
| const metaA = graph.getNodeAttribute(a, "metadata"); | ||
| const metaB = graph.getNodeAttribute(b, "metadata"); | ||
| const dateA = metaA?.date || ""; | ||
| const dateB = metaB?.date || ""; | ||
| return dateB.localeCompare(dateA); | ||
| }).slice(0, 50); | ||
| if (intentNodes.length > 0) { | ||
| md += ` | ||
| ## Recent Architectural Changes & Intent | ||
| `; | ||
| md += `*Showing the 50 most recent architectural commits. Use 'query' on a specific symbol to see its full history.* | ||
| `; | ||
| for (const intent of intentNodes) { | ||
| const data = graph.getNodeAttributes(intent); | ||
| const msg = (data.metadata?.message || "").split("\n")[0]; | ||
| md += `- **${data.name}**: ${msg} | ||
| `; | ||
| } | ||
| } | ||
| md += ` | ||
| --- | ||
| *Generated by Geraph at ${(/* @__PURE__ */ new Date()).toISOString()}* | ||
| `; | ||
| const mdPath = path.join(outDir, "GRAPH_REPORT.md"); | ||
| fs.writeFileSync(mdPath, md, "utf-8"); | ||
| return mdPath; | ||
| } | ||
| function exportGraphHtml(graph, outDir, analysis) { | ||
| if (!fs.existsSync(outDir)) { | ||
| fs.mkdirSync(outDir, { recursive: true }); | ||
| } | ||
| const counts = { | ||
| file: 0, | ||
| media: 0, | ||
| class: 0, | ||
| function: 0, | ||
| intent: 0, | ||
| type: 0, | ||
| interface: 0, | ||
| enum: 0, | ||
| struct: 0, | ||
| trait: 0, | ||
| macro: 0 | ||
| }; | ||
| const nodeDegrees = /* @__PURE__ */ new Map(); | ||
| graph.nodes().forEach((n) => { | ||
| nodeDegrees.set(n, graph.degree(n)); | ||
| }); | ||
| const sortedByDegree = [...graph.nodes()].sort( | ||
| (a, b) => (nodeDegrees.get(b) || 0) - (nodeDegrees.get(a) || 0) | ||
| ); | ||
| const topHubs = new Set(sortedByDegree.slice(0, 50)); | ||
| const maxDegree = Math.max(...Array.from(nodeDegrees.values()), 1); | ||
| const COLORS = { | ||
| default: "#64748b", | ||
| file: "#479af3ff", | ||
| class: "#F28E2B", | ||
| function: "#ee272bff", | ||
| intent: "#35d86eff", | ||
| media: "#843b11ff", | ||
| type: "#B07AA1", | ||
| interface: "#e134b0ff", | ||
| enum: "#59A14F", | ||
| struct: "#E15759", | ||
| trait: "#76B7B2", | ||
| macro: "#EDC948" | ||
| }; | ||
| const nodeCommunities = /* @__PURE__ */ new Map(); | ||
| if (analysis) { | ||
| analysis.communities.forEach((comm) => { | ||
| comm.nodes.forEach((nodeId) => { | ||
| nodeCommunities.set(nodeId, String(comm.id)); | ||
| }); | ||
| }); | ||
| } | ||
| const RAW_COMMUNITIES = analysis ? analysis.communities.map((c, idx) => { | ||
| const hue = idx * 137.5 % 360; | ||
| const color = `hsl(${hue}, 70%, 50%)`; | ||
| return { | ||
| id: String(c.id), | ||
| name: `Community ${c.id}`, | ||
| nodeCount: c.nodes.length, | ||
| cohesion: c.cohesion, | ||
| color, | ||
| members: c.nodes | ||
| }; | ||
| }) : []; | ||
| const RAW_NODES = graph.nodes().map((n) => { | ||
| const data = graph.getNodeAttributes(n); | ||
| const degree = nodeDegrees.get(n) || 0; | ||
| const scaledSize = 10 + 30 * (degree / maxDegree); | ||
| const color = COLORS[data.type] ?? COLORS.default; | ||
| if (data.type && counts[data.type] !== void 0) { | ||
| counts[data.type] = (counts[data.type] || 0) + 1; | ||
| } | ||
| const showLabel = graph.order <= 50 || topHubs.has(n); | ||
| const fontSize = showLabel ? 12 : 0; | ||
| let sourceFile = ""; | ||
| if (n.includes("::")) { | ||
| sourceFile = n.split("::")[0]; | ||
| } else if (data.type === "file") { | ||
| sourceFile = n; | ||
| } | ||
| return { | ||
| id: n, | ||
| label: data.name || n, | ||
| title: data.name || n, | ||
| color: { | ||
| background: color, | ||
| border: color, | ||
| highlight: { background: "#ffffff", border: color }, | ||
| hover: { background: color, border: "#ffffff" } | ||
| }, | ||
| size: scaledSize, | ||
| font: { | ||
| size: fontSize, | ||
| color: "#ffffff", | ||
| strokeWidth: 2, | ||
| strokeColor: "#0f0f1a" | ||
| }, | ||
| node_type: data.type, | ||
| source_file: sourceFile, | ||
| degree, | ||
| startLine: data.startLine, | ||
| community: nodeCommunities.get(n) ?? "none", | ||
| ...data.metadata | ||
| }; | ||
| }); | ||
| const RAW_EDGES = graph.edges().map((id) => { | ||
| const attr = graph.getEdgeAttributes(id); | ||
| return { | ||
| from: graph.source(id), | ||
| to: graph.target(id), | ||
| relation: attr.type, | ||
| confidence: attr.confidence, | ||
| color: { color: "rgba(255,255,255,0.15)", highlight: "#ffffff" }, | ||
| arrows: { to: { enabled: true, scaleFactor: 0.5 } }, | ||
| smooth: { type: "continuous", roundness: 0.2 }, | ||
| width: 1 | ||
| }; | ||
| }); | ||
| const htmlContent = `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <title>Geraph Knowledge Graph</title> | ||
| <script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script> | ||
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet"> | ||
| <style> | ||
| body, html { | ||
| margin: 0; padding: 0; width: 100%; height: 100%; | ||
| background: #0f0f1a; color: #fff; overflow: hidden; | ||
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | ||
| } | ||
| #container { width: 100%; height: 100%; } | ||
| #loading-overlay { | ||
| position: absolute; inset: 0; background: #0f0f1a; | ||
| display: flex; align-items: center; justify-content: center; | ||
| z-index: 200; font-size: 1rem; color: #3b82f6; | ||
| transition: opacity 0.5s ease; | ||
| } | ||
| #sidebar { | ||
| position: absolute; left: 20px; top: 20px; bottom: 20px; | ||
| width: 320px; background: rgb(12.941% 12.941% 12.941%); | ||
| border-radius: 30px; | ||
| display: flex; flex-direction: column; z-index: 100; | ||
| } | ||
| #sidebar-header { padding: 20px; border-bottom: 1px solid rgba(255,255,255,0.1); } | ||
| #sidebar-header h1 { margin: 0; font-size: 1.2rem; color: #fff; } | ||
| #sidebar-header p { margin: 5px 0 0; font-size: 0.8rem; color: #888; } | ||
| #search-box { padding: 15px; position: relative; } | ||
| #search { | ||
| width: 100%; padding: 10px; background: rgb(17.255% 17.255% 17.255%); | ||
| border: none; border-radius: 15px; | ||
| color: #fff; box-sizing: border-box; font-family: inherit; | ||
| } | ||
| #search-suggestions { | ||
| position: absolute; | ||
| left: 15px; | ||
| right: 15px; | ||
| top: calc(100% - 5px); | ||
| background: rgb(17.255% 17.255% 17.255%); | ||
| border: 1px solid rgba(255,255,255,0.1); | ||
| border-radius: 15px; | ||
| max-height: 250px; | ||
| overflow-y: auto; | ||
| z-index: 150; | ||
| box-shadow: 0 10px 30px rgba(0,0,0,0.6); | ||
| scrollbar-width: thin; | ||
| scrollbar-color: rgba(255,255,255,0.1) transparent; | ||
| } | ||
| .suggestion-item { | ||
| padding: 10px 14px; | ||
| cursor: pointer; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| border-bottom: 1px solid rgba(255, 255, 255, 0.05); | ||
| transition: background 0.15s ease; | ||
| font-size: 0.85rem; | ||
| } | ||
| .suggestion-item:last-child { | ||
| border-bottom: none; | ||
| } | ||
| .suggestion-item:hover, .suggestion-item.active { | ||
| background: rgba(83, 83, 83, 0.25); | ||
| } | ||
| .suggestion-name { | ||
| color: #fff; | ||
| font-weight: 500; | ||
| white-space: nowrap; | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| max-width: 190px; | ||
| } | ||
| .suggestion-type { | ||
| font-size: 0.65rem; | ||
| font-weight: 600; | ||
| color: #b8b8b8; | ||
| text-transform: uppercase; | ||
| background: rgba(255, 255, 255, 0.08); | ||
| padding: 2px 6px; | ||
| border-radius: 4px; | ||
| } | ||
| #info-panel { flex: 1; padding: 20px; overflow-y: auto; font-size: 0.9rem; scrollbar-color: rgba(255,255,255,0.1) transparent; scrollbar-width: thin } | ||
| #info-panel h3 { margin-top: 0; font-size: 1rem; color: #3b82f6; } | ||
| #info-content { word-break: break-word; } | ||
| .field { margin-bottom: 12px; } | ||
| .field b { color: #888; display: block; font-size: 0.75rem; text-transform: uppercase; margin-bottom: 2px; } | ||
| .empty { color: #555; font-style: italic; } | ||
| #legend { padding: 20px; border-top: 1px solid rgba(255,255,255,0.1); display: flex; flex-direction: column; gap: 0.3rem; max-height: 100px; overflow-y: auto; scrollbar-color: rgba(255,255,255,0.1) transparent; scrollbar-width: thin } | ||
| .legend-item { display: flex; align-items: center; justify-content: space-between; font-size: 0.8rem; cursor: pointer; transition: opacity 0.2s; user-select: none; } | ||
| .legend-item:hover { opacity: 0.8; } | ||
| .legend-item.inactive { opacity: 0.35; } | ||
| .legend-item-left { display: flex; align-items: center; } | ||
| .legend-dot { width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; } | ||
| .legend-count { color: #666; font-weight: 600; } | ||
| #stats-container { | ||
| border-top: 1px solid rgba(255, 255, 255, 0.1); | ||
| border-radius: 0 0 30px 30px; | ||
| } | ||
| #view-tabs { | ||
| display: flex; | ||
| width: 100%; | ||
| } | ||
| #view-tabs.no-communities button[data-mode="communities"] { | ||
| display: none; | ||
| } | ||
| #view-tabs.no-communities button[data-mode="types"] { | ||
| width: 100%; | ||
| cursor: default; | ||
| background: transparent; | ||
| border-radius: 0 0 30px 30px; | ||
| } | ||
| .tab-btn { | ||
| flex: 1; | ||
| background: transparent; | ||
| border: none; | ||
| color: #666; | ||
| padding: 12px 10px; | ||
| font-size: 11px; | ||
| font-weight: 600; | ||
| cursor: pointer; | ||
| transition: background 0.2s, color 0.2s; | ||
| outline: none; | ||
| text-align: center; | ||
| border-right: 1px solid rgba(255, 255, 255, 0.05); | ||
| } | ||
| .tab-btn:last-child { | ||
| border-right: none; | ||
| border-radius: 0 0 30px 0px !important; | ||
| } | ||
| .tab-btn.active { | ||
| background: rgba(255, 255, 255, 0.05); | ||
| color: #fff; | ||
| border-radius: 0 0 0px 30px; | ||
| } | ||
| .tab-btn:hover:not(.active) { | ||
| background: rgba(255, 255, 255, 0.02); | ||
| color: #aaa; | ||
| border-radius: 0 0 0px 30px; | ||
| } | ||
| .neighbors-section { | ||
| margin-top: 15px; | ||
| border-top: 1px solid rgba(255, 255, 255, 0.1); | ||
| padding-top: 12px; | ||
| } | ||
| .neighbors-header { | ||
| font-size: 0.8rem; | ||
| font-weight: 600; | ||
| color: #888; | ||
| text-transform: uppercase; | ||
| margin-bottom: 8px; | ||
| letter-spacing: 0.5px; | ||
| } | ||
| .neighbor-list { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 6px; | ||
| } | ||
| .neighbor-item { | ||
| padding: 6px 10px; | ||
| background: rgba(255, 255, 255, 0.03); | ||
| border: 1px solid rgba(255, 255, 255, 0.05); | ||
| border-radius: 4px; | ||
| cursor: pointer; | ||
| display: flex; | ||
| flex-direction: column; | ||
| transition: background 0.2s, border-color 0.2s; | ||
| } | ||
| .neighbor-item:hover { | ||
| background: rgba(168, 168, 168, 0.15); | ||
| border-color: rgba(146, 146, 146, 0.3); | ||
| } | ||
| .neighbor-meta { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 6px; | ||
| font-size: 0.7rem; | ||
| margin-bottom: 3px; | ||
| } | ||
| .neighbor-relation { | ||
| color: #3b82f6; | ||
| font-weight: 600; | ||
| text-transform: uppercase; | ||
| } | ||
| .neighbor-confidence { | ||
| color: #888; | ||
| background: rgba(255, 255, 255, 0.05); | ||
| padding: 1px 4px; | ||
| border-radius: 3px; | ||
| } | ||
| .neighbor-main { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| font-size: 0.8rem; | ||
| } | ||
| .neighbor-main-left { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 8px; | ||
| } | ||
| .neighbor-dot { | ||
| width: 8px; | ||
| height: 8px; | ||
| border-radius: 50%; | ||
| flex-shrink: 0; | ||
| } | ||
| .neighbor-name { | ||
| color: #fff; | ||
| font-weight: 500; | ||
| white-space: nowrap; | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| max-width: 180px; | ||
| } | ||
| .neighbor-type { | ||
| font-size: 0.65rem; | ||
| color: #888; | ||
| text-transform: uppercase; | ||
| white-space: nowrap; | ||
| } | ||
| .show-more-btn { | ||
| background: none; | ||
| border: none; | ||
| color: #3b82f6; | ||
| cursor: pointer; | ||
| font-size: 0.75rem; | ||
| font-weight: 600; | ||
| padding: 6px 0; | ||
| text-align: left; | ||
| transition: color 0.2s; | ||
| outline: none; | ||
| width: 100%; | ||
| } | ||
| .show-more-btn:hover { | ||
| color: #60a5fa; | ||
| } | ||
| .hidden { display: none !important; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div id="loading-overlay">Loading Knowledge Graph...</div> | ||
| <div id="sidebar"> | ||
| <div id="sidebar-header"> | ||
| <h1>Geraph Map</h1> | ||
| <p>Codebase Knowledge Graph</p> | ||
| </div> | ||
| <div id="search-box"> | ||
| <input type="text" id="search" placeholder="Search nodes..." autocomplete="off"> | ||
| <div id="search-suggestions" class="hidden"></div> | ||
| </div> | ||
| <div id="info-panel"> | ||
| <div id="info-content"> | ||
| <span class="empty">Select a node to view its properties</span> | ||
| </div> | ||
| </div> | ||
| <div id="legend"> | ||
| <div class="legend-item ${counts.file ? "" : "hidden"}" data-type="file"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.file};"></div> File</div><span class="legend-count">${counts.file || 0}</span></div> | ||
| <div class="legend-item ${counts.media ? "" : "hidden"}" data-type="media"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.media};"></div> Media</div><span class="legend-count">${counts.media || 0}</span></div> | ||
| <div class="legend-item ${counts.class ? "" : "hidden"}" data-type="class"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.class};"></div> Class</div><span class="legend-count">${counts.class || 0}</span></div> | ||
| <div class="legend-item ${counts.struct ? "" : "hidden"}" data-type="struct"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.struct};"></div> Struct</div><span class="legend-count">${counts.struct || 0}</span></div> | ||
| <div class="legend-item ${counts.trait ? "" : "hidden"}" data-type="trait"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.trait};"></div> Trait</div><span class="legend-count">${counts.trait || 0}</span></div> | ||
| <div class="legend-item ${counts.macro ? "" : "hidden"}" data-type="macro"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.macro};"></div> Macro</div><span class="legend-count">${counts.macro || 0}</span></div> | ||
| <div class="legend-item ${counts.function ? "" : "hidden"}" data-type="function"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.function};"></div> Function</div><span class="legend-count">${counts.function || 0}</span></div> | ||
| <div class="legend-item ${counts.type || counts.interface ? "" : "hidden"}" data-type="type,interface"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.type};"></div> Type/Interface</div><span class="legend-count">${(counts.type || 0) + (counts.interface || 0)}</span></div> | ||
| <div class="legend-item ${counts.enum ? "" : "hidden"}" data-type="enum"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.enum};"></div> Enum</div><span class="legend-count">${counts.enum || 0}</span></div> | ||
| <div class="legend-item ${counts.intent ? "" : "hidden"}" data-type="intent"><div class="legend-item-left"><div class="legend-dot" style="background: ${COLORS.intent};"></div> Intent</div><span class="legend-count">${counts.intent || 0}</span></div> | ||
| </div> | ||
| <div id="stats-container"> | ||
| <div id="view-tabs" class="${RAW_COMMUNITIES.length > 0 ? "" : "no-communities"}"> | ||
| <button class="tab-btn active" data-mode="types">${RAW_NODES.length} nodes · ${RAW_EDGES.length} edges</button> | ||
| <button class="tab-btn" data-mode="communities">${RAW_COMMUNITIES.length} communities</button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <div id="container"></div> | ||
| <script type="text/javascript"> | ||
| const RAW_NODES = ${JSON.stringify(RAW_NODES)}; | ||
| const RAW_EDGES = ${JSON.stringify(RAW_EDGES)}; | ||
| const RAW_COMMUNITIES = ${JSON.stringify(RAW_COMMUNITIES)}; | ||
| function escapeHtmlAttr(str) { | ||
| return (str || '').replace(/"/g, '"'); | ||
| } | ||
| const hiddenNodeTypes = new Set(); | ||
| const hiddenCommunities = new Set(); | ||
| let currentViewMode = 'types'; | ||
| const legendTypesHtml = document.getElementById('legend') ? document.getElementById('legend').innerHTML : ''; | ||
| function renderLegend(mode) { | ||
| const legendContainer = document.getElementById('legend'); | ||
| if (!legendContainer) return; | ||
| if (mode === 'types') { | ||
| legendContainer.innerHTML = legendTypesHtml; | ||
| const items = legendContainer.querySelectorAll('.legend-item'); | ||
| items.forEach(item => { | ||
| const typesStr = item.getAttribute('data-type'); | ||
| if (typesStr) { | ||
| const types = typesStr.split(','); | ||
| if (hiddenNodeTypes.has(types[0])) { | ||
| item.classList.add('inactive'); | ||
| } else { | ||
| item.classList.remove('inactive'); | ||
| } | ||
| } | ||
| }); | ||
| } else { | ||
| const sortedCommunities = [...RAW_COMMUNITIES].sort((a, b) => parseInt(a.id) - parseInt(b.id)); | ||
| legendContainer.innerHTML = sortedCommunities.map(comm => { | ||
| const isInactive = hiddenCommunities.has(comm.id) ? 'inactive' : ''; | ||
| return \`<div class="legend-item \${isInactive}" data-community="\${comm.id}"> | ||
| <div class="legend-item-left"> | ||
| <div class="legend-dot" style="background: \${comm.color};"></div> | ||
| \${comm.name} | ||
| </div> | ||
| <span class="legend-count">\${comm.nodeCount}</span> | ||
| </div>\`; | ||
| }).join(''); | ||
| } | ||
| } | ||
| function switchViewMode(mode) { | ||
| currentViewMode = mode; | ||
| const tabs = document.querySelectorAll('.tab-btn'); | ||
| tabs.forEach(t => { | ||
| if (t.getAttribute('data-mode') === mode) { | ||
| t.classList.add('active'); | ||
| } else { | ||
| t.classList.remove('active'); | ||
| } | ||
| }); | ||
| const updates = []; | ||
| for (const n of RAW_NODES) { | ||
| let color; | ||
| if (mode === 'communities') { | ||
| const comm = RAW_COMMUNITIES.find(c => c.id === n.community); | ||
| color = comm ? comm.color : '#64748b'; | ||
| } else { | ||
| color = n.color.background; | ||
| } | ||
| updates.push({ | ||
| id: n.id, | ||
| color: { | ||
| background: color, | ||
| border: color, | ||
| highlight: { background: '#ffffff', border: color }, | ||
| hover: { background: color, border: '#ffffff' } | ||
| } | ||
| }); | ||
| } | ||
| nodes.update(updates); | ||
| renderLegend(mode); | ||
| } | ||
| const viewTabsEl = document.getElementById('view-tabs'); | ||
| if (viewTabsEl) { | ||
| viewTabsEl.addEventListener('click', (e) => { | ||
| const btn = e.target.closest('.tab-btn'); | ||
| if (btn) { | ||
| const mode = btn.getAttribute('data-mode'); | ||
| if (mode && mode !== currentViewMode) { | ||
| switchViewMode(mode); | ||
| } | ||
| } | ||
| }); | ||
| } | ||
| function renderNeighborItem(edge, direction) { | ||
| const neighborId = direction === 'outgoing' ? edge.to : edge.from; | ||
| const neighbor = RAW_NODES.find(n => n.id === neighborId); | ||
| if (!neighbor) return ''; | ||
| return \`<div class="neighbor-item" data-neighbor-id="\${escapeHtmlAttr(neighborId)}"> | ||
| <div class="neighbor-meta"> | ||
| <span class="neighbor-relation">\${edge.relation || 'calls'}</span> | ||
| <span class="neighbor-confidence">\${edge.confidence || 'EXTRACTED'}</span> | ||
| </div> | ||
| <div class="neighbor-main"> | ||
| <div class="neighbor-main-left"> | ||
| <div class="neighbor-dot" style="background: \${neighbor.color.background};"></div> | ||
| <span class="neighbor-name">\${neighbor.label || neighborId}</span> | ||
| </div> | ||
| <span class="neighbor-type">\${neighbor.node_type}</span> | ||
| </div> | ||
| </div>\`; | ||
| } | ||
| function toggleNeighbors(nodeId, direction, btn) { | ||
| const isExpanded = btn.getAttribute('data-state') === 'expanded'; | ||
| const container = document.getElementById(direction + '-list-container'); | ||
| if (!container) return; | ||
| const edges = RAW_EDGES.filter(e => { | ||
| if (direction === 'outgoing') { | ||
| if (e.from !== nodeId) return false; | ||
| const neighbor = RAW_NODES.find(n => n.id === e.to); | ||
| return neighbor && !hiddenNodeTypes.has(neighbor.node_type) && !hiddenCommunities.has(neighbor.community); | ||
| } else { | ||
| if (e.to !== nodeId) return false; | ||
| const neighbor = RAW_NODES.find(n => n.id === e.from); | ||
| return neighbor && !hiddenNodeTypes.has(neighbor.node_type) && !hiddenCommunities.has(neighbor.community); | ||
| } | ||
| }); | ||
| const sortEdges = (a, b) => { | ||
| const neighborIdA = direction === 'outgoing' ? a.to : a.from; | ||
| const neighborIdB = direction === 'outgoing' ? b.to : b.from; | ||
| const nodeA = RAW_NODES.find(n => n.id === neighborIdA); | ||
| const nodeB = RAW_NODES.find(n => n.id === neighborIdB); | ||
| if (!nodeA || !nodeB) return 0; | ||
| if (nodeA.node_type === 'intent' && nodeB.node_type !== 'intent') return -1; | ||
| if (nodeB.node_type === 'intent' && nodeA.node_type !== 'intent') return 1; | ||
| return (nodeB.degree || 0) - (nodeA.degree || 0); | ||
| }; | ||
| edges.sort(sortEdges); | ||
| if (isExpanded) { | ||
| const visible = edges.slice(0, 5); | ||
| container.innerHTML = visible.map(e => renderNeighborItem(e, direction)).join(''); | ||
| btn.innerText = 'Show all'; | ||
| btn.setAttribute('data-state', 'collapsed'); | ||
| } else { | ||
| container.innerHTML = edges.map(e => renderNeighborItem(e, direction)).join(''); | ||
| btn.innerText = 'Show less'; | ||
| btn.setAttribute('data-state', 'expanded'); | ||
| } | ||
| } | ||
| const container = document.getElementById('container'); | ||
| const nodes = new vis.DataSet(RAW_NODES); | ||
| const edges = new vis.DataSet(RAW_EDGES); | ||
| const data = { nodes, edges }; | ||
| const options = { | ||
| nodes: { shape: 'dot' }, | ||
| edges: { | ||
| arrows: { to: { enabled: true, scaleFactor: 0.5 } }, | ||
| color: { inherit: 'from' }, | ||
| smooth: { type: 'continuous', roundness: 0.2 } | ||
| }, | ||
| physics: { | ||
| enabled: true, | ||
| solver: 'forceAtlas2Based', | ||
| forceAtlas2Based: { | ||
| gravitationalConstant: -60, | ||
| centralGravity: 0.005, | ||
| springLength: 120, | ||
| springConstant: 0.08, | ||
| damping: 0.4, | ||
| avoidOverlap: 0.8 | ||
| }, | ||
| stabilization: { | ||
| enabled: true, | ||
| iterations: 200, | ||
| updateInterval: 25, | ||
| fit: true | ||
| } | ||
| }, | ||
| interaction: { | ||
| hover: true, | ||
| tooltipDelay: 100, | ||
| hideEdgesOnDrag: true, | ||
| hideEdgesOnZoom: true, | ||
| multiselect: true, | ||
| navigationButtons: false, | ||
| keyboard: true | ||
| } | ||
| }; | ||
| const network = new vis.Network(container, data, options); | ||
| network.on("stabilizationIterationsDone", function() { | ||
| document.getElementById('loading-overlay').style.opacity = '0'; | ||
| setTimeout(() => { | ||
| document.getElementById('loading-overlay').style.display = 'none'; | ||
| }, 500); | ||
| network.setOptions({ physics: { enabled: false } }); | ||
| }); | ||
| network.on("hoverNode", () => container.style.cursor = 'pointer'); | ||
| network.on("blurNode", () => container.style.cursor = 'default'); | ||
| network.on("hoverEdge", () => container.style.cursor = 'default'); | ||
| network.on("blurEdge", () => container.style.cursor = 'default'); | ||
| function showNodeInfo(nodeId) { | ||
| const infoContent = document.getElementById('info-content'); | ||
| if (!nodeId) { | ||
| infoContent.innerHTML = '<span class="empty">Select a node to view its properties</span>'; | ||
| return; | ||
| } | ||
| const nodeData = RAW_NODES.find(n => n.id === nodeId); | ||
| if (!nodeData) return; | ||
| let html = \`<div class="field"><b>ID</b> \${nodeData.id}</div>\`; | ||
| html += \`<div class="field"><b>Type</b> \${nodeData.node_type}</div>\`; | ||
| html += \`<div class="field"><b>Name</b> \${nodeData.label}</div>\`; | ||
| html += \`<div class="field"><b>Links</b> \${nodeData.degree}</div>\`; | ||
| if (nodeData.community !== undefined && nodeData.community !== 'none') { | ||
| html += \`<div class="field"><b>Community</b> Community \${nodeData.community}</div>\`; | ||
| } | ||
| if (nodeData.source_file && !nodeData.source_file.startsWith('unresolved_') && nodeData.source_file !== 'import') { | ||
| html += \`<div class="field"><b>Source</b> \${nodeData.source_file}</div>\`; | ||
| } | ||
| if (nodeData.startLine) { | ||
| const lineText = nodeData.startLine === nodeData.endLine ? nodeData.startLine : \`\${nodeData.startLine} - \${nodeData.endLine}\`; | ||
| html += \`<div class="field"><b>Lines</b> \${lineText}</div>\`; | ||
| } | ||
| if (nodeData.message) { | ||
| html += \`<div class="field"><b>Message</b> \${nodeData.message.replace(/\\n/g, '<br>')}</div>\`; | ||
| } | ||
| if (nodeData.unresolved) { | ||
| html += \`<div class="field"><b>Status</b> <span style="color:#EDC948">Unresolved</span></div>\`; | ||
| if (nodeData.doc) html += \`<div class="field"><b>Reason</b> \${nodeData.doc}</div>\`; | ||
| } | ||
| const incomingEdges = RAW_EDGES.filter(e => { | ||
| if (e.to !== nodeId) return false; | ||
| const neighbor = RAW_NODES.find(n => n.id === e.from); | ||
| return neighbor && !hiddenNodeTypes.has(neighbor.node_type) && !hiddenCommunities.has(neighbor.community); | ||
| }); | ||
| const outgoingEdges = RAW_EDGES.filter(e => { | ||
| if (e.from !== nodeId) return false; | ||
| const neighbor = RAW_NODES.find(n => n.id === e.to); | ||
| return neighbor && !hiddenNodeTypes.has(neighbor.node_type) && !hiddenCommunities.has(neighbor.community); | ||
| }); | ||
| const sortEdges = (a, b, direction) => { | ||
| const neighborIdA = direction === 'outgoing' ? a.to : a.from; | ||
| const neighborIdB = direction === 'outgoing' ? b.to : b.from; | ||
| const nodeA = RAW_NODES.find(n => n.id === neighborIdA); | ||
| const nodeB = RAW_NODES.find(n => n.id === neighborIdB); | ||
| if (!nodeA || !nodeB) return 0; | ||
| if (nodeA.node_type === 'intent' && nodeB.node_type !== 'intent') return -1; | ||
| if (nodeB.node_type === 'intent' && nodeA.node_type !== 'intent') return 1; | ||
| return (nodeB.degree || 0) - (nodeA.degree || 0); | ||
| }; | ||
| incomingEdges.sort((a, b) => sortEdges(a, b, 'incoming')); | ||
| outgoingEdges.sort((a, b) => sortEdges(a, b, 'outgoing')); | ||
| if (incomingEdges.length > 0) { | ||
| html += \`<div class="neighbors-section"> | ||
| <div class="neighbors-header">Incoming Connections (\${incomingEdges.length})</div> | ||
| <div id="incoming-list-container" class="neighbor-list">\`; | ||
| const visibleIncoming = incomingEdges.slice(0, 5); | ||
| html += visibleIncoming.map(e => renderNeighborItem(e, 'incoming')).join(''); | ||
| html += \`</div>\`; | ||
| if (incomingEdges.length > 5) { | ||
| html += \`<button class="show-more-btn" data-action="toggle-neighbors" data-node-id="\${escapeHtmlAttr(nodeId)}" data-direction="incoming" data-state="collapsed">Show all</button>\`; | ||
| } | ||
| html += \`</div>\`; | ||
| } | ||
| if (outgoingEdges.length > 0) { | ||
| html += \`<div class="neighbors-section"> | ||
| <div class="neighbors-header">Outgoing Connections (\${outgoingEdges.length})</div> | ||
| <div id="outgoing-list-container" class="neighbor-list">\`; | ||
| const visibleOutgoing = outgoingEdges.slice(0, 5); | ||
| html += visibleOutgoing.map(e => renderNeighborItem(e, 'outgoing')).join(''); | ||
| html += \`</div>\`; | ||
| if (outgoingEdges.length > 5) { | ||
| html += \`<button class="show-more-btn" data-action="toggle-neighbors" data-node-id="\${escapeHtmlAttr(nodeId)}" data-direction="outgoing" data-state="collapsed">Show all</button>\`; | ||
| } | ||
| html += \`</div>\`; | ||
| } | ||
| infoContent.innerHTML = html; | ||
| } | ||
| network.on("selectNode", (params) => showNodeInfo(params.nodes[0])); | ||
| network.on("deselectNode", () => showNodeInfo(null)); | ||
| network.on("animationFinished", () => { | ||
| network.setOptions({ edges: { hidden: false } }); | ||
| }); | ||
| const suggestionsBox = document.getElementById('search-suggestions'); | ||
| const searchInput = document.getElementById('search'); | ||
| searchInput.addEventListener('focus', () => { | ||
| network.setOptions({ interaction: { keyboard: false } }); | ||
| }); | ||
| searchInput.addEventListener('blur', () => { | ||
| network.setOptions({ interaction: { keyboard: true } }); | ||
| }); | ||
| let searchTimer; | ||
| let activeSuggestionIndex = -1; | ||
| let currentSuggestions = []; | ||
| function hideSuggestions() { | ||
| suggestionsBox.classList.add('hidden'); | ||
| suggestionsBox.innerHTML = ''; | ||
| activeSuggestionIndex = -1; | ||
| currentSuggestions = []; | ||
| } | ||
| function showSuggestions(matched) { | ||
| currentSuggestions = matched.slice(0, 10); | ||
| if (currentSuggestions.length === 0) { | ||
| hideSuggestions(); | ||
| return; | ||
| } | ||
| suggestionsBox.innerHTML = currentSuggestions.map((n, idx) => { | ||
| const activeClass = idx === 0 ? 'active' : ''; | ||
| return \`<div class="suggestion-item \${activeClass}" data-id="\${escapeHtmlAttr(n.id)}" data-idx="\${idx}"> | ||
| <span class="suggestion-name">\${n.name || n.id}</span> | ||
| <span class="suggestion-type">\${n.node_type}</span> | ||
| </div>\`; | ||
| }).join(''); | ||
| suggestionsBox.classList.remove('hidden'); | ||
| activeSuggestionIndex = 0; | ||
| } | ||
| function updateActiveSuggestion(index) { | ||
| const items = suggestionsBox.getElementsByClassName('suggestion-item'); | ||
| for (let i = 0; i < items.length; i++) { | ||
| if (i === index) { | ||
| items[i].classList.add('active'); | ||
| items[i].scrollIntoView({ block: 'nearest' }); | ||
| } else { | ||
| items[i].classList.remove('active'); | ||
| } | ||
| } | ||
| activeSuggestionIndex = index; | ||
| } | ||
| function selectSuggestion(nodeId) { | ||
| const nodeData = RAW_NODES.find(n => n.id === nodeId); | ||
| if (!nodeData) return; | ||
| network.selectNodes([nodeId]); | ||
| network.setOptions({ edges: { hidden: true } }); | ||
| network.focus(nodeId, { scale: 0.5, animation: { duration: 1000, easingFunction: 'easeInOutQuad' }}); | ||
| showNodeInfo(nodeId); | ||
| searchInput.value = nodeData.label || nodeId; | ||
| hideSuggestions(); | ||
| } | ||
| searchInput.addEventListener('input', (e) => { | ||
| clearTimeout(searchTimer); | ||
| const val = e.target.value.trim().toLowerCase(); | ||
| if (!val) { | ||
| network.selectNodes([]); | ||
| showNodeInfo(null); | ||
| hideSuggestions(); | ||
| return; | ||
| } | ||
| searchTimer = setTimeout(() => { | ||
| const terms = val.split(/\\s+/).filter(t => t.length > 0); | ||
| if (terms.length === 0) return; | ||
| const scored = []; | ||
| for (const n of RAW_NODES) { | ||
| if (hiddenNodeTypes.has(n.node_type)) continue; | ||
| const name = (n.label || "").toLowerCase(); | ||
| const id = n.id.toLowerCase(); | ||
| const file = (n.source_file || "").toLowerCase(); | ||
| let score = 0; | ||
| for (const t of terms) { | ||
| if (t === name || t === id) { | ||
| score += 1000; | ||
| } else if (name.startsWith(t) || id.startsWith(t)) { | ||
| score += 100; | ||
| } else if (name.includes(t) || id.includes(t)) { | ||
| score += 1; | ||
| } else if (file.includes(t)) { | ||
| score += 0.5; | ||
| } | ||
| } | ||
| if (score > 0) { | ||
| score += (n.degree || 0) * 0.01; | ||
| scored.push({ id: n.id, name: n.label || n.id, node_type: n.node_type, score }); | ||
| } | ||
| } | ||
| scored.sort((a, b) => b.score - a.score); | ||
| showSuggestions(scored); | ||
| }, 400); | ||
| }); | ||
| searchInput.addEventListener('keydown', (e) => { | ||
| if (suggestionsBox.classList.contains('hidden')) return; | ||
| if (e.key === 'ArrowDown') { | ||
| e.preventDefault(); | ||
| const nextIndex = (activeSuggestionIndex + 1) % currentSuggestions.length; | ||
| updateActiveSuggestion(nextIndex); | ||
| } else if (e.key === 'ArrowUp') { | ||
| e.preventDefault(); | ||
| const prevIndex = (activeSuggestionIndex - 1 + currentSuggestions.length) % currentSuggestions.length; | ||
| updateActiveSuggestion(prevIndex); | ||
| } else if (e.key === 'Enter') { | ||
| e.preventDefault(); | ||
| if (activeSuggestionIndex >= 0 && activeSuggestionIndex < currentSuggestions.length) { | ||
| selectSuggestion(currentSuggestions[activeSuggestionIndex].id); | ||
| searchInput.blur(); | ||
| } | ||
| } else if (e.key === 'Escape') { | ||
| e.preventDefault(); | ||
| hideSuggestions(); | ||
| } | ||
| }); | ||
| suggestionsBox.addEventListener('click', (e) => { | ||
| const item = e.target.closest('.suggestion-item'); | ||
| if (item) { | ||
| const id = item.getAttribute('data-id'); | ||
| selectSuggestion(id); | ||
| } | ||
| }); | ||
| document.addEventListener('click', (e) => { | ||
| if (!e.target.closest('#search-box')) { | ||
| hideSuggestions(); | ||
| } | ||
| }); | ||
| document.getElementById('info-content').addEventListener('click', (e) => { | ||
| const neighborItem = e.target.closest('.neighbor-item'); | ||
| if (neighborItem) { | ||
| const neighborId = neighborItem.getAttribute('data-neighbor-id'); | ||
| if (neighborId) { | ||
| selectSuggestion(neighborId); | ||
| return; | ||
| } | ||
| } | ||
| const showMoreBtn = e.target.closest('.show-more-btn'); | ||
| if (showMoreBtn && showMoreBtn.getAttribute('data-action') === 'toggle-neighbors') { | ||
| const nodeId = showMoreBtn.getAttribute('data-node-id'); | ||
| const direction = showMoreBtn.getAttribute('data-direction'); | ||
| if (nodeId && direction) { | ||
| toggleNeighbors(nodeId, direction, showMoreBtn); | ||
| } | ||
| } | ||
| }); | ||
| function updateNodeVisibilities() { | ||
| const updates = []; | ||
| let selectedHidden = false; | ||
| const selectedIds = network.getSelectedNodes(); | ||
| for (const n of RAW_NODES) { | ||
| const shouldHide = hiddenNodeTypes.has(n.node_type) || hiddenCommunities.has(n.community); | ||
| updates.push({ | ||
| id: n.id, | ||
| hidden: shouldHide | ||
| }); | ||
| if (shouldHide && selectedIds.includes(n.id)) { | ||
| selectedHidden = true; | ||
| } | ||
| } | ||
| nodes.update(updates); | ||
| if (selectedHidden) { | ||
| network.selectNodes([]); | ||
| showNodeInfo(null); | ||
| } | ||
| } | ||
| document.getElementById('legend').addEventListener('click', (e) => { | ||
| const item = e.target.closest('.legend-item'); | ||
| if (!item) return; | ||
| if (currentViewMode === 'types') { | ||
| const typesStr = item.getAttribute('data-type'); | ||
| if (typesStr) { | ||
| const types = typesStr.split(','); | ||
| const isCurrentlyHidden = hiddenNodeTypes.has(types[0]); | ||
| for (const t of types) { | ||
| if (isCurrentlyHidden) { | ||
| hiddenNodeTypes.delete(t); | ||
| } else { | ||
| hiddenNodeTypes.add(t); | ||
| } | ||
| } | ||
| if (isCurrentlyHidden) { | ||
| item.classList.remove('inactive'); | ||
| } else { | ||
| item.classList.add('inactive'); | ||
| } | ||
| updateNodeVisibilities(); | ||
| } | ||
| } else { | ||
| const commId = item.getAttribute('data-community'); | ||
| if (commId) { | ||
| const isCurrentlyHidden = hiddenCommunities.has(commId); | ||
| if (isCurrentlyHidden) { | ||
| hiddenCommunities.delete(commId); | ||
| item.classList.remove('inactive'); | ||
| } else { | ||
| hiddenCommunities.add(commId); | ||
| item.classList.add('inactive'); | ||
| } | ||
| updateNodeVisibilities(); | ||
| } | ||
| } | ||
| }); | ||
| </script> | ||
| </body> | ||
| </html>`; | ||
| const htmlPath = path.join(outDir, "graph.html"); | ||
| fs.writeFileSync(htmlPath, htmlContent, "utf-8"); | ||
| return htmlPath; | ||
| } | ||
| export { | ||
| exportGraphHtml, | ||
| exportGraphJson, | ||
| exportReportMarkdown | ||
| }; |
| // src/parsers/typescript.ts | ||
| import Parser from "web-tree-sitter"; | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| import { builtinModules } from "module"; | ||
| var NODE_CORE_MODULES = new Set(builtinModules); | ||
| var BUILT_INS = /* @__PURE__ */ new Set(); | ||
| Object.getOwnPropertyNames(globalThis).forEach((p) => BUILT_INS.add(p)); | ||
| var CORE_CLASSES = [ | ||
| Object, | ||
| Array, | ||
| String, | ||
| Number, | ||
| Boolean, | ||
| Symbol, | ||
| Promise, | ||
| Date, | ||
| RegExp, | ||
| Map, | ||
| Set, | ||
| Error, | ||
| ArrayBuffer, | ||
| DataView, | ||
| Function | ||
| ]; | ||
| if (typeof Intl !== "undefined") { | ||
| CORE_CLASSES.push(Intl); | ||
| } | ||
| for (const cls of CORE_CLASSES) { | ||
| if (cls && (typeof cls === "function" || typeof cls === "object")) { | ||
| Object.getOwnPropertyNames(cls).forEach((p) => BUILT_INS.add(p)); | ||
| const proto = cls.prototype; | ||
| if (proto && (typeof proto === "object" || typeof proto === "function")) { | ||
| Object.getOwnPropertyNames(proto).forEach((p) => BUILT_INS.add(p)); | ||
| } | ||
| } | ||
| } | ||
| var BROWSER_DOM_BUILT_INS = [ | ||
| // Browser Globals | ||
| "window", | ||
| "document", | ||
| "navigator", | ||
| "location", | ||
| "history", | ||
| "screen", | ||
| "localStorage", | ||
| "sessionStorage", | ||
| "fetch", | ||
| "Headers", | ||
| "Request", | ||
| "Response", | ||
| "setTimeout", | ||
| "clearTimeout", | ||
| "setInterval", | ||
| "clearInterval", | ||
| "requestAnimationFrame", | ||
| "cancelAnimationFrame", | ||
| "alert", | ||
| "confirm", | ||
| "prompt", | ||
| // DOM & HTML Interfaces/Types | ||
| "Event", | ||
| "CustomEvent", | ||
| "Node", | ||
| "Element", | ||
| "HTMLElement", | ||
| "CSSProperties", | ||
| "HTMLDivElement", | ||
| "HTMLSpanElement", | ||
| "HTMLInputElement", | ||
| "HTMLButtonElement", | ||
| "HTMLCanvasElement", | ||
| "HTMLVideoElement", | ||
| "HTMLAudioElement", | ||
| "HTMLImageElement", | ||
| "HTMLAnchorElement", | ||
| "HTMLFormElement", | ||
| "SVGElement", | ||
| "Blob", | ||
| "File", | ||
| "FileList", | ||
| "FileReader", | ||
| "FormData", | ||
| "Audio", | ||
| "AudioContext", | ||
| "webkitAudioContext", | ||
| "BroadcastChannel", | ||
| "IntersectionObserver", | ||
| "ResizeObserver", | ||
| "MutationObserver", | ||
| "Timeout", | ||
| "CanvasTextBaseline", | ||
| // Common DOM Methods & Properties | ||
| "closest", | ||
| "blur", | ||
| "focus", | ||
| "click", | ||
| "select", | ||
| "querySelector", | ||
| "querySelectorAll", | ||
| "getElementById", | ||
| "getElementsByClassName", | ||
| "getElementsByTagName", | ||
| "createElement", | ||
| "appendChild", | ||
| "removeChild", | ||
| "replaceChild", | ||
| "insertBefore", | ||
| "cloneNode", | ||
| "contains", | ||
| "getAttribute", | ||
| "setAttribute", | ||
| "removeAttribute", | ||
| "classList", | ||
| "addEventListener", | ||
| "removeEventListener", | ||
| "dispatchEvent", | ||
| "preventDefault", | ||
| "stopPropagation", | ||
| "getBoundingClientRect", | ||
| "postMessage" | ||
| ]; | ||
| BROWSER_DOM_BUILT_INS.forEach((p) => BUILT_INS.add(p)); | ||
| var TYPESCRIPT_UTILITY_TYPES = [ | ||
| "Partial", | ||
| "Required", | ||
| "Readonly", | ||
| "Record", | ||
| "Pick", | ||
| "Omit", | ||
| "Exclude", | ||
| "Extract", | ||
| "NonNullable", | ||
| "Parameters", | ||
| "ConstructorParameters", | ||
| "ReturnType", | ||
| "InstanceType", | ||
| "ThisParameterType", | ||
| "OmitThisParameter", | ||
| "ThisType", | ||
| "Awaited", | ||
| "NodeJS" | ||
| ]; | ||
| TYPESCRIPT_UTILITY_TYPES.forEach((p) => BUILT_INS.add(p)); | ||
| 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 = []) { | ||
| const checkCandidates = (resolvedBase) => { | ||
| const candidates = [ | ||
| resolvedBase + ".ts", | ||
| resolvedBase + ".tsx", | ||
| resolvedBase + "/index.ts", | ||
| resolvedBase + "/index.tsx", | ||
| resolvedBase + ".js", | ||
| resolvedBase + ".jsx", | ||
| resolvedBase + "/index.js", | ||
| resolvedBase + "/index.jsx" | ||
| ]; | ||
| for (const candidate of candidates) { | ||
| if (fs.existsSync(candidate)) return candidate; | ||
| } | ||
| return null; | ||
| }; | ||
| const normalizedImport = importPath.replace(/\.js$/, ""); | ||
| if (importPath.startsWith(".")) { | ||
| const sourceDir = path.dirname(sourceFilePath); | ||
| const resolvedBase = path.resolve(sourceDir, normalizedImport); | ||
| return checkCandidates(resolvedBase); | ||
| } | ||
| if (aliases && aliases.length > 0) { | ||
| for (const alias of aliases) { | ||
| if (importPath.startsWith(alias.prefix)) { | ||
| const remainder = normalizedImport.slice(alias.prefix.length); | ||
| for (const target of alias.targets) { | ||
| const resolvedBase = path.join(target, remainder); | ||
| const found = checkCandidates(resolvedBase); | ||
| if (found) return found; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| function readSourceFile(filePath) { | ||
| const buffer = fs.readFileSync(filePath); | ||
| if (buffer.length >= 2 && buffer[0] === 255 && buffer[1] === 254) { | ||
| return buffer.toString("utf16le").replace(/^\uFEFF/, ""); | ||
| } | ||
| if (buffer.length >= 2 && buffer[0] === 254 && buffer[1] === 255) { | ||
| const swapped = Buffer.alloc(buffer.length); | ||
| for (let i = 0; i < buffer.length - 1; i += 2) { | ||
| swapped[i] = buffer[i + 1]; | ||
| swapped[i + 1] = buffer[i]; | ||
| } | ||
| return swapped.toString("utf16le").replace(/^\uFEFF/, ""); | ||
| } | ||
| let content = buffer.toString("utf8"); | ||
| if (content.startsWith("\uFEFF")) { | ||
| content = content.slice(1); | ||
| } | ||
| return content.replace(/\0/g, ""); | ||
| } | ||
| function isLocalDeclaration(startNode, name) { | ||
| let current = startNode; | ||
| const checkPattern = (patternNode) => { | ||
| if (patternNode.type === "identifier" || patternNode.type === "shorthand_property_identifier" || patternNode.type === "shorthand_property_identifier_pattern" || patternNode.type === "type_identifier") { | ||
| return patternNode.text.trim() === name; | ||
| } | ||
| for (let i = 0; i < patternNode.namedChildCount; i++) { | ||
| const child = patternNode.namedChild(i); | ||
| if (child && checkPattern(child)) return true; | ||
| } | ||
| return false; | ||
| }; | ||
| while (current) { | ||
| if (current.type === "function_declaration" || current.type === "arrow_function" || current.type === "function_expression" || current.type === "method_definition" || current.type === "class_declaration" || current.type === "abstract_class_declaration" || current.type === "interface_declaration" || current.type === "type_alias_declaration") { | ||
| const paramsNode = current.childForFieldName("parameters") || current.namedChildren.find((c) => c.type === "formal_parameters" || c.type === "parameter_list"); | ||
| if (paramsNode && checkPattern(paramsNode)) { | ||
| return true; | ||
| } | ||
| if (current.type === "arrow_function") { | ||
| const firstChild = current.namedChild(0); | ||
| if (firstChild && firstChild.type === "identifier" && firstChild.text.trim() === name) { | ||
| return true; | ||
| } | ||
| } | ||
| const typeParamsNode = current.childForFieldName("type_parameters") || current.namedChildren.find((c) => c.type === "type_parameters"); | ||
| if (typeParamsNode && checkPattern(typeParamsNode)) { | ||
| return true; | ||
| } | ||
| } | ||
| if (current.type === "mapped_type_clause") { | ||
| const typeParamsNode = current.childForFieldName("parameter") || current.namedChildren.find((c) => c.type === "type_identifier"); | ||
| if (typeParamsNode && typeParamsNode.text.trim() === name) { | ||
| return true; | ||
| } | ||
| } | ||
| if (current.type === "statement_block" || current.type === "program") { | ||
| for (let i = 0; i < current.namedChildCount; i++) { | ||
| const statement = current.namedChild(i); | ||
| if (!statement) continue; | ||
| if (statement.type === "lexical_declaration" || statement.type === "variable_declaration") { | ||
| for (let j = 0; j < statement.namedChildCount; j++) { | ||
| const declarator = statement.namedChild(j); | ||
| if (declarator && declarator.type === "variable_declarator") { | ||
| const nameNode = declarator.childForFieldName("name"); | ||
| if (nameNode && checkPattern(nameNode)) { | ||
| return true; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| current = current.parent; | ||
| } | ||
| return false; | ||
| } | ||
| function parseTypeScript(filePath, graph, languageMap, aliases = []) { | ||
| const isTs = filePath.endsWith(".ts") || filePath.endsWith(".tsx"); | ||
| const isTsx = filePath.endsWith(".tsx"); | ||
| let language; | ||
| if (isTs) { | ||
| language = isTsx ? languageMap.tsx : languageMap.typescript; | ||
| } else { | ||
| language = languageMap.javascript; | ||
| } | ||
| const parser = new Parser(); | ||
| try { | ||
| parser.setLanguage(language); | ||
| } catch { | ||
| return; | ||
| } | ||
| let sourceCode; | ||
| try { | ||
| sourceCode = readSourceFile(filePath); | ||
| } catch { | ||
| return; | ||
| } | ||
| const tree = parser.parse(sourceCode); | ||
| if (!tree) return; | ||
| const importMap = /* @__PURE__ */ new Map(); | ||
| const importOriginalNameMap = /* @__PURE__ */ new Map(); | ||
| const localDefinitions = /* @__PURE__ */ new Set(); | ||
| const localMethodMap = /* @__PURE__ */ new Map(); | ||
| const baseQueryString = ` | ||
| (import_statement (import_clause (identifier) @default_import) source: (string) @import_source) | ||
| (import_statement (import_clause (namespace_import (identifier) @namespace_import)) source: (string) @import_source) | ||
| (import_statement (import_clause (named_imports (import_specifier) @named_import_specifier)) source: (string) @import_source) | ||
| (import_statement source: (string) @import_source) | ||
| (import) @import_source | ||
| (call_expression | ||
| function: (identifier) @req_fn | ||
| arguments: (arguments (string) @require_source) | ||
| (#eq? @req_fn "require") | ||
| ) | ||
| ${isTs ? "[(class_declaration) (abstract_class_declaration)] @class_decl" : "(class_declaration) @class_decl"} | ||
| ${isTs ? ` | ||
| (interface_declaration name: (type_identifier) @interface_name) | ||
| (type_alias_declaration name: (type_identifier) @type_name) | ||
| (enum_declaration name: (identifier) @enum_name) | ||
| (type_identifier) @type_reference | ||
| ` : ""} | ||
| [ | ||
| (function_declaration name: (_) @func_name) | ||
| (method_definition name: (_) @method_name) | ||
| (variable_declarator | ||
| name: (identifier) @var_func_name | ||
| value: [(arrow_function) (function_expression)] | ||
| ) | ||
| ] | ||
| (call_expression function: (identifier) @call_name) | ||
| (call_expression | ||
| function: (member_expression | ||
| object: (_) @call_method_object | ||
| property: (property_identifier) @call_method_name | ||
| ) | ||
| ) | ||
| (new_expression constructor: [ | ||
| (identifier) | ||
| (member_expression) | ||
| ] @constructor_name) | ||
| `; | ||
| const query = language.query(baseQueryString); | ||
| const matches = query.matches(tree.rootNode); | ||
| const getEnclosingScopePath = (startNode) => { | ||
| const pathParts = []; | ||
| let current = startNode; | ||
| while (current) { | ||
| if (current.type === "class_declaration" || current.type === "abstract_class_declaration" || current.type === "interface_declaration" || current.type === "type_alias_declaration" || current.type === "enum_declaration" || current.type === "function_declaration" || current.type === "method_definition") { | ||
| const nameNode = current.childForFieldName("name"); | ||
| if (nameNode) { | ||
| pathParts.unshift(nameNode.text.trim()); | ||
| } | ||
| } else if (current.type === "variable_declarator") { | ||
| const valNode = current.childForFieldName("value"); | ||
| if (valNode && (valNode.type === "arrow_function" || valNode.type === "function_expression")) { | ||
| if (startNode && startNode.startIndex >= valNode.startIndex && startNode.endIndex <= valNode.endIndex) { | ||
| const nameNode = current.childForFieldName("name"); | ||
| if (nameNode) { | ||
| pathParts.unshift(nameNode.text.trim()); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| current = current.parent; | ||
| } | ||
| return pathParts.join("."); | ||
| }; | ||
| for (const match of matches) { | ||
| let currentSource = ""; | ||
| for (const capture of match.captures) { | ||
| if (capture.name === "import_source") { | ||
| 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); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| if (currentSource) { | ||
| for (const capture of match.captures) { | ||
| if (capture.name === "default_import" || capture.name === "namespace_import") { | ||
| importMap.set(capture.node.text.trim(), currentSource); | ||
| } else if (capture.name === "named_import_specifier") { | ||
| const aliasNode = capture.node.childForFieldName("alias"); | ||
| const nameNode = capture.node.childForFieldName("name"); | ||
| const localName = aliasNode ? aliasNode.text.trim() : nameNode ? nameNode.text.trim() : ""; | ||
| const originalName = nameNode ? nameNode.text.trim() : ""; | ||
| if (localName) { | ||
| importMap.set(localName, currentSource); | ||
| if (originalName && originalName !== localName) { | ||
| importOriginalNameMap.set(localName, originalName); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| for (const capture of match.captures) { | ||
| if (["func_name", "method_name", "var_func_name", "class_decl", "interface_name", "type_name", "enum_name"].includes(capture.name)) { | ||
| let finalSymName = capture.node.text.trim(); | ||
| if (capture.name === "class_decl" && (capture.node.type === "class_declaration" || capture.node.type === "abstract_class_declaration")) { | ||
| const nameNode = capture.node.childForFieldName("name"); | ||
| if (nameNode) finalSymName = nameNode.text.trim(); | ||
| } | ||
| localDefinitions.add(finalSymName); | ||
| const scopePrefix = getEnclosingScopePath(capture.node.parent?.parent); | ||
| const symId = scopePrefix ? `${filePath}::${scopePrefix}.${finalSymName}` : `${filePath}::${finalSymName}`; | ||
| if (["func_name", "method_name", "var_func_name"].includes(capture.name)) { | ||
| if (!localMethodMap.has(finalSymName)) { | ||
| localMethodMap.set(finalSymName, []); | ||
| } | ||
| localMethodMap.get(finalSymName).push(symId); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| const getRootIdentifier = (node) => { | ||
| if (!node) return ""; | ||
| if (node.type === "identifier" || node.type === "this") return node.text.trim(); | ||
| if (node.type === "member_expression") { | ||
| return getRootIdentifier(node.childForFieldName("object")); | ||
| } | ||
| if (node.type === "call_expression") { | ||
| return getRootIdentifier(node.childForFieldName("function")); | ||
| } | ||
| if (node.type === "await_expression" || node.type === "parenthesized_expression" || node.type === "non_null_expression" || node.type === "as_expression") { | ||
| return getRootIdentifier(node.namedChild(0)); | ||
| } | ||
| return ""; | ||
| }; | ||
| for (const match of matches) { | ||
| for (const capture of match.captures) { | ||
| const name = capture.name; | ||
| const node = capture.node; | ||
| if (name === "import_source" || name === "require_source") { | ||
| const importPath = node.text.replace(/['"]/g, ""); | ||
| const importLine = node.startPosition.row + 1; | ||
| const normalizedPath = importPath.replace(/^node:/, ""); | ||
| const rootModule = normalizedPath.split("/")[0]; | ||
| if (!importPath.startsWith(".") && !importPath.startsWith("/") && (NODE_CORE_MODULES.has(normalizedPath) || NODE_CORE_MODULES.has(rootModule))) continue; | ||
| const resolvedId = resolveImportToNode(importPath, filePath, aliases); | ||
| 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) : basePackage, | ||
| file: resolvedId || nearestPkgJson || filePath, | ||
| startLine: 0, | ||
| metadata: resolvedId ? {} : { external: true, callerFile: filePath, callerLine: importLine } | ||
| }); | ||
| } | ||
| graph.addEdge(filePath, targetNodeId, { type: "imports", confidence: "EXTRACTED" }); | ||
| } else if (["class_decl", "interface_name", "type_name", "enum_name", "func_name", "method_name", "var_func_name"].includes(name)) { | ||
| let decl = node; | ||
| if (decl.parent && ["function_declaration", "method_definition", "interface_declaration", "type_alias_declaration", "enum_declaration", "class_declaration", "abstract_class_declaration", "variable_declarator"].includes(decl.parent.type)) { | ||
| decl = decl.parent; | ||
| } | ||
| if (decl.parent && ["export_statement", "lexical_declaration", "variable_declaration"].includes(decl.parent.type)) { | ||
| decl = decl.parent; | ||
| } | ||
| if (decl.parent && ["export_statement"].includes(decl.parent.type)) { | ||
| decl = decl.parent; | ||
| } | ||
| const comments = []; | ||
| let prev = decl.previousNamedSibling; | ||
| while (prev && prev.type === "comment") { | ||
| comments.push(prev.text); | ||
| prev = prev.previousNamedSibling; | ||
| } | ||
| let jsdoc = null; | ||
| if (comments.length > 0) { | ||
| comments.reverse(); | ||
| const docText = comments.join("\n"); | ||
| const links = []; | ||
| const seeMatches = [...docText.matchAll(/@see\s+([^\s}]+)/g)]; | ||
| for (const m of seeMatches) if (m[1]) links.push(m[1].replace(/['"]/g, "")); | ||
| const linkMatches = [...docText.matchAll(/{@link\s+([^\s}]+)/g)]; | ||
| for (const m of linkMatches) if (m[1]) links.push(m[1].replace(/['"]/g, "")); | ||
| jsdoc = { doc: docText, deprecated: docText.includes("@deprecated"), links }; | ||
| } | ||
| const symName = node.text.trim(); | ||
| let finalSymName = symName; | ||
| if (name === "class_decl" && (node.type === "class_declaration" || node.type === "abstract_class_declaration")) { | ||
| const nameNode = node.childForFieldName("name"); | ||
| if (nameNode) finalSymName = nameNode.text.trim(); | ||
| } | ||
| const scopePrefix = getEnclosingScopePath(node.parent?.parent); | ||
| const symId = scopePrefix ? `${filePath}::${scopePrefix}.${finalSymName}` : `${filePath}::${finalSymName}`; | ||
| const parentId = scopePrefix ? `${filePath}::${scopePrefix}` : filePath; | ||
| const typeMap = { | ||
| class_decl: "class", | ||
| interface_name: "interface", | ||
| type_name: "type", | ||
| enum_name: "enum", | ||
| func_name: "function", | ||
| method_name: "function", | ||
| var_func_name: "function" | ||
| }; | ||
| const nodeAttrs = { | ||
| type: typeMap[name] || "function", | ||
| name: finalSymName, | ||
| file: filePath, | ||
| startLine: decl.startPosition.row + 1, | ||
| metadata: { | ||
| endLine: decl.endPosition.row + 1, | ||
| doc: jsdoc?.doc, | ||
| deprecated: jsdoc?.deprecated || false, | ||
| external: false, | ||
| unresolved: false | ||
| } | ||
| }; | ||
| if (!graph.hasNode(symId)) { | ||
| graph.addNode(symId, nodeAttrs); | ||
| graph.addEdge(parentId, symId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } else { | ||
| graph.mergeNodeAttributes(symId, nodeAttrs); | ||
| if (!graph.hasEdge(parentId, symId)) { | ||
| graph.addEdge(parentId, symId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } | ||
| } | ||
| if (jsdoc && jsdoc.links.length > 0) { | ||
| for (const link of jsdoc.links) { | ||
| let targetPath = link; | ||
| if (link.startsWith(".")) { | ||
| targetPath = resolveImportToNode(link, filePath, aliases) || link; | ||
| } | ||
| if (!graph.hasNode(targetPath)) { | ||
| graph.addNode(targetPath, { type: "file", name: path.basename(targetPath), file: targetPath, startLine: 0, metadata: { external: true } }); | ||
| } | ||
| if (!graph.hasEdge(symId, targetPath)) { | ||
| graph.addEdge(symId, targetPath, { type: "explains", confidence: "EXTRACTED" }); | ||
| } | ||
| } | ||
| } | ||
| } else if (name === "type_reference") { | ||
| const referencedTypeName = node.text.trim(); | ||
| let moduleName = ""; | ||
| if (node.parent?.type === "nested_type_identifier") { | ||
| const moduleNode = node.parent.childForFieldName("module"); | ||
| if (moduleNode) { | ||
| moduleName = moduleNode.text.trim(); | ||
| } | ||
| } | ||
| if (BUILT_INS.has(referencedTypeName) && !localDefinitions.has(referencedTypeName) && !importMap.has(referencedTypeName) && (!moduleName || BUILT_INS.has(moduleName))) continue; | ||
| if (["interface_declaration", "type_alias_declaration", "import_specifier", "class_declaration", "function_declaration", "variable_declarator"].includes(node.parent?.type || "")) continue; | ||
| if (isLocalDeclaration(node, referencedTypeName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| const importSource = importMap.get(moduleName || referencedTypeName); | ||
| const isCoreModule = importSource && (NODE_CORE_MODULES.has(importSource) || NODE_CORE_MODULES.has(importSource.replace(/^node:/, ""))); | ||
| if (isCoreModule) continue; | ||
| let targetId; | ||
| if (importSource) { | ||
| const resolvedSource = resolveImportToNode(importSource, filePath, aliases) || importSource; | ||
| const originalName = importOriginalNameMap.get(moduleName || referencedTypeName) || referencedTypeName; | ||
| targetId = `${resolvedSource}::${originalName}`; | ||
| } else if (localDefinitions.has(referencedTypeName)) { | ||
| targetId = `${filePath}::${referencedTypeName}`; | ||
| } else { | ||
| targetId = `unresolved::${referencedTypeName}`; | ||
| } | ||
| const isUnresolved = !importSource && !localDefinitions.has(referencedTypeName); | ||
| if (callerId === targetId) continue; | ||
| if (!graph.hasNode(targetId)) { | ||
| graph.addNode(targetId, { | ||
| type: "interface", | ||
| name: referencedTypeName, | ||
| file: importSource && resolveImportToNode(importSource, filePath, aliases) || importSource || filePath, | ||
| startLine: 0, | ||
| metadata: { | ||
| external: !!importSource, | ||
| unresolved: isUnresolved, | ||
| endLine: 0, | ||
| callerFile: isUnresolved ? filePath : void 0, | ||
| callerLine: isUnresolved ? node.startPosition.row + 1 : void 0, | ||
| doc: isUnresolved ? "Called/Instantiated but not defined in any scanned file. Likely from an external package or a dynamic import." : void 0 | ||
| } | ||
| }); | ||
| } | ||
| if (!graph.hasEdge(callerId, targetId)) { | ||
| graph.addEdge(callerId, targetId, { type: "references", confidence: isUnresolved ? "AMBIGUOUS" : "EXTRACTED" }); | ||
| } | ||
| if (importSource) { | ||
| const baseImportSource = getBasePackageName(importSource); | ||
| const resolvedSource = resolveImportToNode(importSource, filePath, aliases) || 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: resolvedSource ? path.basename(resolvedSource) : baseImportSource, | ||
| file: resolvedSource || nearestPkgJson || filePath, | ||
| startLine: 0, | ||
| metadata: { external: true } | ||
| }); | ||
| } | ||
| if (!graph.hasEdge(importNodeId, targetId)) { | ||
| graph.addEdge(importNodeId, targetId, { type: "defines", confidence: "EXTRACTED" }); | ||
| } | ||
| } | ||
| } else if (name === "call_name" || name === "constructor_name" || name === "call_method_name") { | ||
| const methodName = node.text.trim(); | ||
| let calledName = methodName; | ||
| let objectName = ""; | ||
| if (name === "call_method_name" && node.parent?.type === "member_expression") { | ||
| const objectNode = node.parent.childForFieldName("object"); | ||
| if (objectNode) { | ||
| objectName = getRootIdentifier(objectNode); | ||
| if (objectName) { | ||
| calledName = `${objectName}.${calledName}`; | ||
| } | ||
| } | ||
| } | ||
| if (name === "call_method_name") { | ||
| if (BUILT_INS.has(methodName) && !localDefinitions.has(methodName) && !importMap.has(methodName)) { | ||
| continue; | ||
| } | ||
| } | ||
| const baseName = objectName || calledName.split(".")[0] || ""; | ||
| if (BUILT_INS.has(baseName) && !localDefinitions.has(baseName) && !importMap.has(baseName)) continue; | ||
| if (isLocalDeclaration(node, calledName)) continue; | ||
| const callerScope = getEnclosingScopePath(node.parent); | ||
| const callerId = callerScope ? `${filePath}::${callerScope}` : filePath; | ||
| const targets = []; | ||
| if (objectName) { | ||
| const importSource2 = importMap.get(objectName); | ||
| if (importSource2) { | ||
| const normalizedSource = importSource2.replace(/^node:/, ""); | ||
| const rootModule = normalizedSource.split("/")[0]; | ||
| const isCoreModule = !importSource2.startsWith(".") && !importSource2.startsWith("/") && (NODE_CORE_MODULES.has(normalizedSource) || NODE_CORE_MODULES.has(rootModule)); | ||
| if (isCoreModule) continue; | ||
| const resolvedSource = resolveImportToNode(importSource2, filePath, aliases) || importSource2; | ||
| targets.push({ | ||
| id: `${resolvedSource}::${methodName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (localDefinitions.has(objectName)) { | ||
| const localMatches = localMethodMap.get(methodName) || []; | ||
| if (localMatches.length === 1 && localMatches[0]) { | ||
| targets.push({ | ||
| id: localMatches[0], | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (localMatches.length > 1) { | ||
| for (const matchId of localMatches) { | ||
| targets.push({ | ||
| id: matchId, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } else { | ||
| targets.push({ | ||
| id: `${filePath}::${objectName}.${methodName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } | ||
| } else { | ||
| const localMatches = localMethodMap.get(methodName) || []; | ||
| if (localMatches.length === 1 && localMatches[0]) { | ||
| targets.push({ | ||
| id: localMatches[0], | ||
| confidence: "INFERRED" | ||
| }); | ||
| } else if (localMatches.length > 1) { | ||
| for (const matchId of localMatches) { | ||
| targets.push({ | ||
| id: matchId, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| } else { | ||
| const importSource2 = importMap.get(calledName); | ||
| if (importSource2) { | ||
| const normalizedSource = importSource2.replace(/^node:/, ""); | ||
| const rootModule = normalizedSource.split("/")[0]; | ||
| const isCoreModule = !importSource2.startsWith(".") && !importSource2.startsWith("/") && (NODE_CORE_MODULES.has(normalizedSource) || NODE_CORE_MODULES.has(rootModule)); | ||
| if (isCoreModule) continue; | ||
| const resolvedSource = resolveImportToNode(importSource2, filePath, aliases) || importSource2; | ||
| const originalName = importOriginalNameMap.get(calledName) || calledName; | ||
| targets.push({ | ||
| id: `${resolvedSource}::${originalName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (localDefinitions.has(calledName)) { | ||
| const localMatches = localMethodMap.get(calledName) || []; | ||
| if (localMatches.length === 1 && localMatches[0]) { | ||
| targets.push({ | ||
| id: localMatches[0], | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else if (localMatches.length > 1) { | ||
| const callerScope2 = getEnclosingScopePath(node.parent); | ||
| let bestMatch = localMatches[0]; | ||
| let maxCommon = -1; | ||
| for (const matchId of localMatches) { | ||
| const matchScope = matchId.split("::")[1] || ""; | ||
| let common = 0; | ||
| const matchParts = matchScope.split("."); | ||
| const callerParts = callerScope2.split("."); | ||
| for (let i = 0; i < Math.min(matchParts.length, callerParts.length); i++) { | ||
| if (matchParts[i] === callerParts[i]) common++; | ||
| else break; | ||
| } | ||
| if (common > maxCommon) { | ||
| maxCommon = common; | ||
| bestMatch = matchId; | ||
| } | ||
| } | ||
| targets.push({ | ||
| id: bestMatch, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } else { | ||
| const scopePrefix = getEnclosingScopePath(node.parent?.parent); | ||
| targets.push({ | ||
| id: scopePrefix ? `${filePath}::${scopePrefix}.${calledName}` : `${filePath}::${calledName}`, | ||
| confidence: "EXTRACTED" | ||
| }); | ||
| } | ||
| } else { | ||
| targets.push({ | ||
| id: `unresolved::${calledName}`, | ||
| confidence: "AMBIGUOUS" | ||
| }); | ||
| } | ||
| } | ||
| for (const target of targets) { | ||
| const isUnresolved = target.id.startsWith("unresolved::"); | ||
| const isExternal = target.id.startsWith("import::") || !isUnresolved && !target.id.startsWith(filePath); | ||
| const callLine = node.startPosition.row + 1; | ||
| if (!graph.hasNode(target.id)) { | ||
| let fileAttr = filePath; | ||
| if (isUnresolved) { | ||
| fileAttr = filePath; | ||
| } else if (target.id.includes("::")) { | ||
| fileAttr = target.id.split("::")[0] || filePath; | ||
| } | ||
| graph.addNode(target.id, { | ||
| type: name === "constructor_name" ? "class" : "function", | ||
| name: calledName, | ||
| file: fileAttr, | ||
| startLine: isExternal ? 0 : callLine, | ||
| metadata: { | ||
| external: isExternal, | ||
| unresolved: isUnresolved, | ||
| endLine: isExternal ? 0 : callLine, | ||
| callerFile: isUnresolved ? filePath : void 0, | ||
| callerLine: isUnresolved ? callLine : void 0, | ||
| doc: isUnresolved ? "Called/Instantiated but not defined in any scanned file. Likely from an external package or a dynamic import." : void 0 | ||
| } | ||
| }); | ||
| } | ||
| if (callerId === target.id) continue; | ||
| if (!graph.hasEdge(callerId, target.id)) { | ||
| graph.addEdge(callerId, target.id, { type: "calls", confidence: target.confidence }); | ||
| } | ||
| } | ||
| const importSource = importMap.get(baseName); | ||
| if (importSource) { | ||
| const baseImportSource = getBasePackageName(importSource); | ||
| const resolvedSource = resolveImportToNode(importSource, filePath, aliases) || 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: resolvedSource ? path.basename(resolvedSource) : baseImportSource, | ||
| file: resolvedSource || nearestPkgJson || filePath, | ||
| startLine: 0, | ||
| metadata: { external: true } | ||
| }); | ||
| } | ||
| for (const target of targets) { | ||
| if (!graph.hasEdge(importNodeId, target.id)) { | ||
| graph.addEdge(importNodeId, target.id, { type: "defines", confidence: "EXTRACTED" }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| export { | ||
| parseTypeScript | ||
| }; |
@@ -46,2 +46,5 @@ --- | ||
| > [!TIP] | ||
| > **Use Geraph for Navigation and Structure:** Before reading a source code file, always use Geraph tools/commands (like `search_graph`, `get_node`, or `get_neighbors`) to inspect what classes, functions, or imports are inside the file and how they are connected. You MUST only read a raw file from the filesystem when you actually need to see or edit its source code implementation details. | ||
| --- | ||
@@ -75,3 +78,2 @@ | ||
| * `geraph://surprises` : Top 10 surprising cross-community couplings. | ||
| * `geraph://audit` : Extraction confidence audit counts. | ||
@@ -254,3 +256,3 @@ --- | ||
| * `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. | ||
| * `INFERRED`: Heuristic structural mapping (e.g. matching a method call on a local instance variable to a unique local function definition of the same name). | ||
| * `AMBIGUOUS`: Uncertain or unresolved connection (e.g. multiple matching local method candidates exist, or the symbol is completely unresolved). |
+2
-16
| { | ||
| "name": "geraph", | ||
| "version": "1.1.0", | ||
| "version": "1.2.0", | ||
| "description": "Structural memory for AI agents. Build semantic knowledge graphs from your codebase for surgical code modifications.", | ||
@@ -11,7 +11,2 @@ "main": "dist/index.js", | ||
| }, | ||
| "scripts": { | ||
| "build": "tsup src/index.ts src/core/worker.ts --format esm --clean --dts && node scripts/prepare-assets.js", | ||
| "check-types": "tsc --noEmit", | ||
| "lint": "eslint ." | ||
| }, | ||
| "keywords": [ | ||
@@ -52,12 +47,4 @@ "graph", | ||
| "type": "module", | ||
| "devDependencies": { | ||
| "@repo/eslint-config": "*", | ||
| "@repo/typescript-config": "*", | ||
| "@types/node": "^25.6.0", | ||
| "tsup": "^8.5.1", | ||
| "typescript": "^5.9.2" | ||
| }, | ||
| "dependencies": { | ||
| "@modelcontextprotocol/sdk": "^1.29.0", | ||
| "chalk": "^4.1.2", | ||
| "commander": "^4.1.1", | ||
@@ -67,6 +54,5 @@ "fast-glob": "^3.3.1", | ||
| "graphology-communities-louvain": "^2.0.2", | ||
| "graphology-metrics": "^2.4.0", | ||
| "graphology-shortest-path": "^2.1.0", | ||
| "ignore": "^5.3.2", | ||
| "ora": "^9.4.0", | ||
| "picocolors": "^1.1.1", | ||
| "simple-git": "^3.36.0", | ||
@@ -73,0 +59,0 @@ "web-tree-sitter": "^0.20.8" |
+1
-1
@@ -29,3 +29,3 @@ # Geraph | ||
| - **AST Precision**: Static analysis (tree-sitter) for absolute accuracy. | ||
| - **History Aware**: Integrates Git history to explain the "Why" behind the code. | ||
| - **History & Context Aware**: Integrates Git history and inline comments (like JSDoc or docstrings) to explain the "Why" and context behind the code. | ||
| - **100% Local & Private**: No cloud, no telemetry, no code leaves your machine. | ||
@@ -32,0 +32,0 @@ |
| import { | ||
| analyzeGraph, | ||
| detectCommunities, | ||
| findGodNodes, | ||
| findSurprisingConnections | ||
| } from "./chunk-6OHPWTVS.js"; | ||
| import "./chunk-R64RHEAI.js"; | ||
| export { | ||
| analyzeGraph, | ||
| detectCommunities, | ||
| findGodNodes, | ||
| findSurprisingConnections | ||
| }; |
| import { | ||
| createKnowledgeGraph | ||
| } from "./chunk-R64RHEAI.js"; | ||
| // 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; | ||
| }; | ||
| const deterministicGraph = createKnowledgeGraph(); | ||
| const sortedNodeIds = [...graph.nodes()].sort(); | ||
| sortedNodeIds.forEach((nodeId) => { | ||
| deterministicGraph.addNode(nodeId, graph.getNodeAttributes(nodeId)); | ||
| }); | ||
| const sortedEdges = graph.edges().map((edgeId) => { | ||
| return { | ||
| id: edgeId, | ||
| source: graph.source(edgeId), | ||
| target: graph.target(edgeId), | ||
| attributes: graph.getEdgeAttributes(edgeId) | ||
| }; | ||
| }); | ||
| sortedEdges.sort((a, b) => { | ||
| const compareSource = a.source.localeCompare(b.source); | ||
| if (compareSource !== 0) return compareSource; | ||
| const compareTarget = a.target.localeCompare(b.target); | ||
| if (compareTarget !== 0) return compareTarget; | ||
| return a.id.localeCompare(b.id); | ||
| }); | ||
| sortedEdges.forEach((edge) => { | ||
| if (!deterministicGraph.hasEdge(edge.source, edge.target)) { | ||
| deterministicGraph.addEdge(edge.source, edge.target, edge.attributes); | ||
| } | ||
| }); | ||
| try { | ||
| communityMap = louvainAlgorithm(deterministicGraph, { 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/graph.ts | ||
| import { MultiDirectedGraph } from "graphology"; | ||
| import path from "path"; | ||
| function createKnowledgeGraph() { | ||
| const graph = new MultiDirectedGraph(); | ||
| const g = graph; | ||
| const originalAddEdge = graph.addEdge.bind(graph); | ||
| g.addEdge = (source, target, attributes) => { | ||
| if (source === target) return ""; | ||
| return originalAddEdge(source, target, attributes); | ||
| }; | ||
| const originalAddEdgeWithKey = graph.addEdgeWithKey.bind(graph); | ||
| g.addEdgeWithKey = (key, source, target, attributes) => { | ||
| if (source === target) return ""; | ||
| return originalAddEdgeWithKey(key, source, target, attributes); | ||
| }; | ||
| const originalMergeEdge = graph.mergeEdge.bind(graph); | ||
| g.mergeEdge = (source, target, attributes) => { | ||
| if (source === target) return ["", false]; | ||
| return originalMergeEdge(source, target, attributes); | ||
| }; | ||
| const originalMergeEdgeWithKey = graph.mergeEdgeWithKey.bind(graph); | ||
| g.mergeEdgeWithKey = (key, source, target, attributes) => { | ||
| if (source === target) return ["", false]; | ||
| return originalMergeEdgeWithKey(key, source, target, attributes); | ||
| }; | ||
| return graph; | ||
| } | ||
| function resolveCallGraph(graph) { | ||
| const realDefs = /* @__PURE__ */ new Map(); | ||
| for (const nodeId of graph.nodes()) { | ||
| const data = graph.getNodeAttributes(nodeId); | ||
| if (!nodeId.startsWith("unresolved::") && (data.type === "function" || data.type === "class" || data.type === "type" || data.type === "interface" || data.type === "enum" || data.type === "struct" || data.type === "trait" || data.type === "macro")) { | ||
| const name = data.name; | ||
| if (!realDefs.has(name)) { | ||
| realDefs.set(name, []); | ||
| } | ||
| realDefs.get(name).push(nodeId); | ||
| } | ||
| } | ||
| const ghosts = graph.nodes().filter((n) => n.startsWith("unresolved::")); | ||
| for (const ghostId of ghosts) { | ||
| const ghostData = graph.getNodeAttributes(ghostId); | ||
| let name = ghostData.name; | ||
| if (name.includes(".")) { | ||
| name = name.split(".").pop() || name; | ||
| } | ||
| if (name.includes("::")) { | ||
| name = name.split("::").pop() || name; | ||
| } | ||
| const candidates = realDefs.get(name); | ||
| if (candidates && candidates.length > 0) { | ||
| let realId = candidates[0]; | ||
| let bestScore = -1; | ||
| for (const candidateId of candidates) { | ||
| const candidateData = graph.getNodeAttributes(candidateId); | ||
| let score = 0; | ||
| if (path.dirname(candidateData.file) === path.dirname(ghostData.file)) { | ||
| score += 10; | ||
| } | ||
| if (path.extname(candidateData.file) === path.extname(ghostData.file)) { | ||
| score += 5; | ||
| } | ||
| if (score > bestScore) { | ||
| bestScore = score; | ||
| realId = candidateId; | ||
| } | ||
| } | ||
| for (const edgeId of graph.inEdges(ghostId)) { | ||
| const src = graph.source(edgeId); | ||
| const edgeData = graph.getEdgeAttributes(edgeId); | ||
| if (src === realId) continue; | ||
| if (!graph.hasEdge(src, realId)) { | ||
| graph.addEdge(src, realId, edgeData); | ||
| } | ||
| } | ||
| graph.dropNode(ghostId); | ||
| } | ||
| } | ||
| } | ||
| export { | ||
| createKnowledgeGraph, | ||
| resolveCallGraph | ||
| }; |
| // src/core/mcp.ts | ||
| import { Server } from "@modelcontextprotocol/sdk/server/index.js"; | ||
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| import { | ||
| CallToolRequestSchema, | ||
| ListToolsRequestSchema, | ||
| ListResourcesRequestSchema, | ||
| ReadResourceRequestSchema | ||
| } from "@modelcontextprotocol/sdk/types.js"; | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| async function runMcpServer(graph, targetDir) { | ||
| const server = new Server( | ||
| { | ||
| name: "geraph", | ||
| version: "1.1.0" | ||
| }, | ||
| { | ||
| capabilities: { | ||
| tools: {}, | ||
| resources: {} | ||
| } | ||
| } | ||
| ); | ||
| server.setRequestHandler(ListToolsRequestSchema, async () => { | ||
| return { | ||
| tools: [ | ||
| { | ||
| name: "search_graph", | ||
| description: "Search for nodes in the knowledge graph by partial name. Useful to find exact node IDs. Supports pagination. You can also search for a file by its path (e.g., 'src/auth.ts') using type 'file', because node IDs contain file paths. (CLI Alternative: 'geraph search <term>')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| name: { | ||
| type: "string", | ||
| description: "The partial name to search for (e.g. 'auth')" | ||
| }, | ||
| type: { | ||
| type: "string", | ||
| description: "Optional filter by node type (e.g. 'function', 'class')" | ||
| }, | ||
| page: { | ||
| type: "number", | ||
| description: "Page number for pagination (default: 1)" | ||
| }, | ||
| limit: { | ||
| type: "number", | ||
| description: "Number of results per page (default: 20)" | ||
| } | ||
| }, | ||
| required: ["name"] | ||
| } | ||
| }, | ||
| { | ||
| name: "get_node", | ||
| description: "Get detailed metadata for a specific node by its exact ID or fuzzy symbol name. (CLI Alternative: 'geraph node <symbol>')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| symbol: { | ||
| type: "string", | ||
| description: "The exact node ID or symbol name" | ||
| }, | ||
| type: { | ||
| type: "string", | ||
| description: "Optional filter by node type (e.g., 'interface', 'function')" | ||
| }, | ||
| source: { | ||
| type: "string", | ||
| description: "Optional filter by source file path (e.g., 'auth.ts')" | ||
| } | ||
| }, | ||
| required: ["symbol"] | ||
| } | ||
| }, | ||
| { | ||
| name: "get_neighbors", | ||
| description: "Get all incoming and outgoing edges for a specific node to trace its direct dependencies. Supports pagination. (CLI Alternative: 'geraph neighbors <symbol>')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| symbol: { | ||
| type: "string", | ||
| description: "The exact node ID or symbol name" | ||
| }, | ||
| type: { | ||
| type: "string", | ||
| description: "Optional filter by node type (e.g., 'interface', 'function')" | ||
| }, | ||
| source: { | ||
| type: "string", | ||
| description: "Optional filter by source file path (e.g., 'auth.ts')" | ||
| }, | ||
| page: { | ||
| type: "number", | ||
| description: "Page number for pagination (default: 1)" | ||
| }, | ||
| limit: { | ||
| type: "number", | ||
| description: "Number of edges per direction per page (default: 20)" | ||
| } | ||
| }, | ||
| required: ["symbol"] | ||
| } | ||
| }, | ||
| { | ||
| name: "shortest_path", | ||
| description: "Find the shortest sequence of edges connecting two nodes using fuzzy symbol/ID lookup. (CLI Alternative: 'geraph path <source> <target>')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| source: { | ||
| type: "string", | ||
| description: "The fuzzy starting node ID or symbol name" | ||
| }, | ||
| target: { | ||
| type: "string", | ||
| description: "The fuzzy destination node ID or symbol name" | ||
| }, | ||
| max_hops: { | ||
| type: "number", | ||
| description: "Maximum hops to consider (default: 8)" | ||
| } | ||
| }, | ||
| required: ["source", "target"] | ||
| } | ||
| }, | ||
| { | ||
| name: "god_nodes", | ||
| description: "Return the most connected nodes \u2014 the core architectural pillars of the codebase. Supports pagination. (CLI Alternative: 'geraph god')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| page: { | ||
| type: "number", | ||
| description: "Page number for pagination (default: 1)" | ||
| }, | ||
| limit: { | ||
| type: "number", | ||
| description: "Number of results per page (default: 10)" | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| { | ||
| name: "get_community", | ||
| description: "Get all nodes in a community by community ID. Supports pagination. (CLI Alternative: 'geraph community <id>')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| community_id: { | ||
| type: "number", | ||
| description: "Community ID (0-indexed by size)" | ||
| }, | ||
| page: { | ||
| type: "number", | ||
| description: "Page number for pagination (default: 1)" | ||
| }, | ||
| limit: { | ||
| type: "number", | ||
| description: "Number of results per page (default: 20)" | ||
| } | ||
| }, | ||
| required: ["community_id"] | ||
| } | ||
| }, | ||
| { | ||
| name: "get_surprises", | ||
| description: "Discover surprising cross-community couplings that link otherwise independent modules. Supports pagination. (CLI Alternative: 'geraph surprises')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| page: { | ||
| type: "number", | ||
| description: "Page number for pagination (default: 1)" | ||
| }, | ||
| limit: { | ||
| type: "number", | ||
| description: "Number of results per page (default: 20)" | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| { | ||
| name: "query_graph", | ||
| description: "Search the AST graph using BFS or DFS traversal. Returns a compact context representation. Supports natural language questions or keywords. (CLI Alternative: 'geraph query <symbol-or-question>')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| symbol: { | ||
| type: "string", | ||
| description: "Fuzzy starting symbol or node ID, or natural language question" | ||
| }, | ||
| question: { | ||
| type: "string", | ||
| description: "Natural language question or keywords (for Graphify parity)" | ||
| }, | ||
| mode: { | ||
| type: "string", | ||
| enum: ["bfs", "dfs"], | ||
| default: "bfs", | ||
| description: "Traversal mode: bfs (breadth) or dfs (depth)" | ||
| }, | ||
| depth: { | ||
| type: "number", | ||
| default: 3, | ||
| description: "Traversal depth limit" | ||
| }, | ||
| token_budget: { | ||
| type: "number", | ||
| default: 2e3, | ||
| description: "Estimated output token limit" | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| { | ||
| name: "graph_stats", | ||
| description: "Return summary statistics of the graph: node count, edge count, community count, and extraction confidence percentage breakdown. (CLI Alternative: 'geraph stats')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: {} | ||
| } | ||
| }, | ||
| { | ||
| name: "scan_graph", | ||
| description: "Triggers a full rebuild of the Geraph AST graph. Use this after making significant code modifications or pushing git commits to ensure your structural memory is up-to-date. (CLI Alternative: 'geraph scan')", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| force: { | ||
| type: "boolean", | ||
| description: "If true, fully ignore and rebuild all cache files (doing a clean scan from scratch)" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ] | ||
| }; | ||
| }); | ||
| server.setRequestHandler(CallToolRequestSchema, async (request) => { | ||
| const { name, arguments: args } = request.params; | ||
| if (!args) { | ||
| throw new Error("Arguments are required"); | ||
| } | ||
| try { | ||
| if (name === "search_graph") { | ||
| const queryName = args.name; | ||
| const typeFilter = args.type; | ||
| const page = args.page; | ||
| const limit = args.limit; | ||
| const { searchGraph } = await import("./query-WKUL7OQK.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-WKUL7OQK.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-WKUL7OQK.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-WKUL7OQK.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-WKUL7OQK.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-WKUL7OQK.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-WKUL7OQK.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-WKUL7OQK.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-WKUL7OQK.js"); | ||
| try { | ||
| const result = await getGraphStats(graph); | ||
| return { | ||
| content: [{ type: "text", text: result }] | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| content: [{ type: "text", text: String(error) }], | ||
| isError: true | ||
| }; | ||
| } | ||
| } | ||
| if (name === "scan_graph") { | ||
| try { | ||
| const { exec } = await import("child_process"); | ||
| const { promisify } = await import("util"); | ||
| const execAsync = promisify(exec); | ||
| const force = !!args.force; | ||
| const cmd = force ? "geraph scan --force" : "geraph scan"; | ||
| const { stdout, stderr } = await execAsync(cmd, { cwd: targetDir }); | ||
| const { loadGraph } = await import("./query-WKUL7OQK.js"); | ||
| const newGraph = loadGraph(targetDir); | ||
| graph.clear(); | ||
| newGraph.forEachNode((node, attr) => graph.addNode(node, attr)); | ||
| newGraph.forEachEdge( | ||
| (edge, attr, source, target) => graph.addEdgeWithKey(edge, source, target, attr) | ||
| ); | ||
| const stripAnsi = (str) => str.replace( | ||
| // eslint-disable-next-line no-control-regex | ||
| /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, | ||
| "" | ||
| ); | ||
| const cleanStdout = stripAnsi(stdout); | ||
| const cleanStderr = stripAnsi(stderr); | ||
| const lines = cleanStderr.split("\n").map((l) => l.trim()).filter(Boolean); | ||
| let filesParsedLine = ""; | ||
| const realWarnings = []; | ||
| const SPINNER_KEYWORDS = [ | ||
| "Scanning codebase in", | ||
| "Initializing Knowledge Graph", | ||
| "Resolving call graph", | ||
| "Extracting Temporal Facts", | ||
| "Analyzing graph structure", | ||
| "Compressing graph into Caveman" | ||
| ]; | ||
| for (const line of lines) { | ||
| if (line.includes("Successfully scanned and parsed")) { | ||
| filesParsedLine = line.replace(/^[^\w]+/, "").trim(); | ||
| } else if (SPINNER_KEYWORDS.some((kw) => line.includes(kw))) { | ||
| } else { | ||
| realWarnings.push(line); | ||
| } | ||
| } | ||
| let outputText = ""; | ||
| if (filesParsedLine) { | ||
| const displayLine = filesParsedLine.startsWith("Successfully") ? filesParsedLine : `Successfully ${filesParsedLine}`; | ||
| outputText += `${displayLine} | ||
| `; | ||
| } | ||
| outputText += cleanStdout.trim(); | ||
| if (realWarnings.length > 0) { | ||
| outputText += ` | ||
| Warnings/Errors: | ||
| ${realWarnings.join("\n")}`; | ||
| } | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: outputText.trim() || "Graph successfully scanned and memory updated." | ||
| } | ||
| ] | ||
| }; | ||
| } catch (error) { | ||
| const msg = error instanceof Error ? error.message : String(error); | ||
| return { | ||
| content: [{ type: "text", text: `Error scanning graph: ${msg}` }], | ||
| isError: true | ||
| }; | ||
| } | ||
| } | ||
| throw new Error(`Unknown tool: ${name}`); | ||
| } catch (error) { | ||
| const err = error; | ||
| return { | ||
| content: [ | ||
| { type: "text", text: JSON.stringify({ error: err.message }) } | ||
| ], | ||
| isError: true | ||
| }; | ||
| } | ||
| }); | ||
| server.setRequestHandler(ListResourcesRequestSchema, async () => { | ||
| return { | ||
| resources: [ | ||
| { | ||
| uri: "geraph://report", | ||
| name: "Graph Report", | ||
| description: "Full GRAPH_REPORT.md", | ||
| mimeType: "text/markdown" | ||
| }, | ||
| { | ||
| uri: "geraph://stats", | ||
| name: "Graph Stats", | ||
| description: "Node/edge/community counts and confidence breakdown", | ||
| mimeType: "text/plain" | ||
| }, | ||
| { | ||
| uri: "geraph://god-nodes", | ||
| name: "God Nodes", | ||
| description: "Top 10 most-connected nodes", | ||
| mimeType: "text/plain" | ||
| }, | ||
| { | ||
| uri: "geraph://surprises", | ||
| name: "Surprising Connections", | ||
| description: "Cross-community surprising connections", | ||
| mimeType: "text/plain" | ||
| }, | ||
| { | ||
| 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-WKUL7OQK.js"); | ||
| const text = await getGraphStats(graph); | ||
| return { | ||
| contents: [{ uri, mimeType: "text/plain", text }] | ||
| }; | ||
| } | ||
| if (uri === "geraph://god-nodes") { | ||
| const { getGodNodes } = await import("./query-WKUL7OQK.js"); | ||
| const text = await getGodNodes(graph, 1, 10); | ||
| return { | ||
| contents: [{ uri, mimeType: "text/plain", text }] | ||
| }; | ||
| } | ||
| if (uri === "geraph://surprises") { | ||
| const { getSurprisingConnections } = await import("./query-WKUL7OQK.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 | ||
| }; |
| import { | ||
| createKnowledgeGraph | ||
| } from "./chunk-R64RHEAI.js"; | ||
| // src/core/query.ts | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| function normalizeId(id) { | ||
| return id.replace(/\\/g, "/"); | ||
| } | ||
| function loadGraph(targetDir) { | ||
| const graphPath = path.join(targetDir, ".geraph", "graph.json"); | ||
| if (!fs.existsSync(graphPath)) { | ||
| throw new Error( | ||
| `Graph data not found in ${targetDir}. Run 'geraph scan' first.` | ||
| ); | ||
| } | ||
| const rawData = JSON.parse(fs.readFileSync(graphPath, "utf-8")); | ||
| const graph = createKnowledgeGraph(); | ||
| const nodes = rawData.nodes || []; | ||
| const edges = rawData.edges || []; | ||
| const communities = rawData.analysis?.communities || []; | ||
| const nodeToCommunity = /* @__PURE__ */ new Map(); | ||
| communities.forEach((c) => { | ||
| const members = c.members || c.nodes || []; | ||
| members.forEach((nodeId) => { | ||
| nodeToCommunity.set(normalizeId(nodeId), Number(c.id)); | ||
| }); | ||
| }); | ||
| nodes.forEach((n) => { | ||
| const nid = normalizeId(n.id); | ||
| if (!graph.hasNode(nid)) { | ||
| const { id, name, type, file, startLine, ...metadata } = n; | ||
| const communityId = nodeToCommunity.get(nid); | ||
| graph.addNode(nid, { | ||
| name: name || "", | ||
| type, | ||
| file: normalizeId(file || ""), | ||
| startLine: startLine || 0, | ||
| metadata: { | ||
| ...metadata, | ||
| ...communityId !== void 0 ? { community: communityId } : {} | ||
| } | ||
| }); | ||
| } | ||
| }); | ||
| edges.forEach((e) => { | ||
| const source = normalizeId(e.source); | ||
| const target = normalizeId(e.target); | ||
| if (source === target) return; | ||
| if (graph.hasNode(source) && graph.hasNode(target)) { | ||
| graph.addEdge(source, target, { | ||
| type: e.relation || e.type, | ||
| confidence: e.confidence, | ||
| metadata: e.metadata || {} | ||
| }); | ||
| } | ||
| }); | ||
| return graph; | ||
| } | ||
| async function searchGraph(targetDirOrGraph, term, targetType, page = 1, limit = 20) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| const lowerTerm = term.toLowerCase(); | ||
| const results = []; | ||
| graph.forEachNode((nodeId, attr) => { | ||
| if (targetType && attr.type !== targetType) return; | ||
| if (nodeId.toLowerCase().includes(lowerTerm) || attr.name && attr.name.toLowerCase().includes(lowerTerm)) { | ||
| results.push({ | ||
| id: nodeId, | ||
| name: attr.name, | ||
| type: attr.type, | ||
| file: attr.file, | ||
| links: graph.degree(nodeId) | ||
| }); | ||
| } | ||
| }); | ||
| results.sort((a, b) => b.links - a.links); | ||
| const total = results.length; | ||
| const totalPages = Math.ceil(total / limit) || 1; | ||
| const start = (page - 1) * limit; | ||
| const end = start + limit; | ||
| return { | ||
| data: results.slice(start, end), | ||
| meta: { | ||
| page, | ||
| limit, | ||
| total, | ||
| totalPages | ||
| } | ||
| }; | ||
| } | ||
| function resolveTargetNodeId(graph, symbol, targetType, targetSource) { | ||
| const normSymbol = normalizeId(symbol); | ||
| let targetNodeId = graph.hasNode(normSymbol) ? normSymbol : null; | ||
| if (targetNodeId && (targetType || targetSource)) { | ||
| const attr = graph.getNodeAttributes(targetNodeId); | ||
| if (targetType && attr.type !== targetType) { | ||
| targetNodeId = null; | ||
| } | ||
| if (targetNodeId && targetSource) { | ||
| const normSource = normalizeId(targetSource); | ||
| if (!attr.file.toLowerCase().endsWith(normSource.toLowerCase())) { | ||
| targetNodeId = null; | ||
| } | ||
| } | ||
| } | ||
| if (!targetNodeId) { | ||
| targetNodeId = graph.findNode((nodeId, attr) => { | ||
| if (targetType && attr.type !== targetType) return false; | ||
| if (targetSource) { | ||
| const normSource = normalizeId(targetSource); | ||
| if (!attr.file.endsWith(normSource)) return false; | ||
| } | ||
| return attr && attr.name && attr.name === symbol || nodeId === normSymbol || nodeId.endsWith("/" + normSymbol) || nodeId.endsWith("::" + normSymbol); | ||
| }) ?? null; | ||
| } | ||
| if (!targetNodeId) { | ||
| targetNodeId = graph.findNode((nodeId, attr) => { | ||
| if (targetType && attr.type !== targetType) return false; | ||
| if (targetSource) { | ||
| const normSource = normalizeId(targetSource); | ||
| if (!attr.file.toLowerCase().endsWith(normSource.toLowerCase())) | ||
| return false; | ||
| } | ||
| return attr && attr.name && attr.name.toLowerCase() === symbol.toLowerCase() || nodeId.toLowerCase() === normSymbol.toLowerCase() || nodeId.toLowerCase().endsWith("/" + normSymbol.toLowerCase()) || nodeId.toLowerCase().endsWith("::" + normSymbol.toLowerCase()); | ||
| }) ?? null; | ||
| } | ||
| if (!targetNodeId) { | ||
| const typeMsg = targetType ? ` of type '${targetType}'` : ""; | ||
| const sourceMsg = targetSource ? ` in source '${targetSource}'` : ""; | ||
| throw new Error( | ||
| `Symbol '${symbol}'${typeMsg}${sourceMsg} not found in the graph.` | ||
| ); | ||
| } | ||
| return targetNodeId; | ||
| } | ||
| async function getNode(targetDirOrGraph, symbol, targetType, targetSource) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| const targetNodeId = resolveTargetNodeId( | ||
| graph, | ||
| symbol, | ||
| targetType, | ||
| targetSource | ||
| ); | ||
| const targetAttr = graph.getNodeAttributes(targetNodeId); | ||
| return { | ||
| id: targetNodeId, | ||
| name: targetAttr.name, | ||
| type: targetAttr.type, | ||
| file: targetAttr.file, | ||
| line: targetAttr.startLine, | ||
| metadata: targetAttr.metadata, | ||
| links: { | ||
| incoming: graph.inDegree(targetNodeId), | ||
| outgoing: graph.outDegree(targetNodeId) | ||
| } | ||
| }; | ||
| } | ||
| async function getNeighbors(targetDirOrGraph, symbol, targetType, targetSource, page = 1, limit = 20) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| const targetNodeId = resolveTargetNodeId( | ||
| graph, | ||
| symbol, | ||
| targetType, | ||
| targetSource | ||
| ); | ||
| const targetAttr = graph.getNodeAttributes(targetNodeId); | ||
| const result = { | ||
| target: { | ||
| id: targetNodeId, | ||
| name: targetAttr.name, | ||
| type: targetAttr.type, | ||
| file: targetAttr.file, | ||
| line: targetAttr.startLine | ||
| }, | ||
| incoming: [], | ||
| outgoing: [], | ||
| meta: { | ||
| page, | ||
| limit, | ||
| totalIncoming: 0, | ||
| totalOutgoing: 0, | ||
| totalPages: 1 | ||
| } | ||
| }; | ||
| const collectEdges = (nodeId, isOutgoing, seenKeys) => { | ||
| const iterator = isOutgoing ? graph.forEachOutEdge.bind(graph) : graph.forEachInEdge.bind(graph); | ||
| iterator(nodeId, (edge, attr, source, target) => { | ||
| const neighborId = isOutgoing ? target : source; | ||
| if (neighborId === targetNodeId) return; | ||
| const key = `${neighborId}:${attr.type}`; | ||
| if (seenKeys.has(key)) return; | ||
| seenKeys.add(key); | ||
| const neighborAttr = graph.getNodeAttributes(neighborId); | ||
| const nodeInfo = { | ||
| id: neighborId, | ||
| name: neighborAttr.name, | ||
| type: neighborAttr.type, | ||
| file: neighborAttr.file, | ||
| line: neighborAttr.startLine | ||
| }; | ||
| if (isOutgoing) { | ||
| result.outgoing.push({ | ||
| relation: attr.type, | ||
| confidence: attr.confidence, | ||
| target: nodeInfo | ||
| }); | ||
| } else { | ||
| result.incoming.push({ | ||
| relation: attr.type, | ||
| confidence: attr.confidence, | ||
| source: nodeInfo | ||
| }); | ||
| } | ||
| }); | ||
| }; | ||
| const seenIn = /* @__PURE__ */ new Set(); | ||
| const seenOut = /* @__PURE__ */ new Set(); | ||
| collectEdges(targetNodeId, false, seenIn); | ||
| collectEdges(targetNodeId, true, seenOut); | ||
| const sortEdges = (a, b) => { | ||
| const nodeA = a.source || a.target; | ||
| const nodeB = b.source || b.target; | ||
| if (nodeA.type === "intent" && nodeB.type !== "intent") return -1; | ||
| if (nodeB.type === "intent" && nodeA.type !== "intent") return 1; | ||
| const degreeA = graph.degree(nodeA.id) || 0; | ||
| const degreeB = graph.degree(nodeB.id) || 0; | ||
| return degreeB - degreeA; | ||
| }; | ||
| result.incoming.sort(sortEdges); | ||
| result.outgoing.sort(sortEdges); | ||
| const totalIncoming = result.incoming.length; | ||
| const totalOutgoing = result.outgoing.length; | ||
| const maxTotal = Math.max(totalIncoming, totalOutgoing); | ||
| const totalPages = Math.ceil(maxTotal / limit) || 1; | ||
| const start = (page - 1) * limit; | ||
| const end = start + limit; | ||
| result.incoming = result.incoming.slice(start, end); | ||
| result.outgoing = result.outgoing.slice(start, end); | ||
| result.meta = { | ||
| page, | ||
| limit, | ||
| totalIncoming, | ||
| totalOutgoing, | ||
| totalPages | ||
| }; | ||
| return result; | ||
| } | ||
| function undirectedShortestPath(graph, source, target) { | ||
| if (source === target) return [source]; | ||
| const queue = [source]; | ||
| const visited = /* @__PURE__ */ new Set([source]); | ||
| const parent = /* @__PURE__ */ new Map(); | ||
| while (queue.length > 0) { | ||
| const current = queue.shift(); | ||
| if (current === target) break; | ||
| graph.forEachNeighbor(current, (neighbor) => { | ||
| if (!visited.has(neighbor)) { | ||
| visited.add(neighbor); | ||
| parent.set(neighbor, current); | ||
| queue.push(neighbor); | ||
| } | ||
| }); | ||
| } | ||
| if (!parent.has(target)) return null; | ||
| const path2 = []; | ||
| let curr = target; | ||
| while (curr !== source) { | ||
| path2.unshift(curr); | ||
| curr = parent.get(curr); | ||
| } | ||
| path2.unshift(source); | ||
| return path2; | ||
| } | ||
| async function shortestPath(targetDirOrGraph, sourceSymbol, targetSymbol, maxHops = 8) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| const normSource = resolveTargetNodeId(graph, sourceSymbol); | ||
| const normTarget = resolveTargetNodeId(graph, targetSymbol); | ||
| if (normSource === normTarget) { | ||
| throw new Error(`Source and target nodes are identical: '${sourceSymbol}'`); | ||
| } | ||
| const path2 = undirectedShortestPath(graph, normSource, normTarget); | ||
| if (!path2) { | ||
| throw new Error("No path exists between the given nodes"); | ||
| } | ||
| const hops = path2.length - 1; | ||
| if (hops > maxHops) { | ||
| return `Path exceeds max_hops=${maxHops} (${hops} hops found).`; | ||
| } | ||
| const segments = []; | ||
| for (let i = 0; i < path2.length - 1; i++) { | ||
| const u = path2[i]; | ||
| const v = path2[i + 1]; | ||
| let forward = true; | ||
| let edgeId; | ||
| if (graph.hasDirectedEdge(u, v)) { | ||
| edgeId = graph.edges(u, v)[0]; | ||
| } else if (graph.hasDirectedEdge(v, u)) { | ||
| edgeId = graph.edges(v, u)[0]; | ||
| forward = false; | ||
| } | ||
| const edata = edgeId ? graph.getEdgeAttributes(edgeId) : void 0; | ||
| const rel = edata?.type || ""; | ||
| const conf = edata?.confidence || ""; | ||
| const confStr = conf ? ` [${conf}]` : ""; | ||
| const uLabel = String(graph.getNodeAttribute(u, "name") || u); | ||
| const vLabel = String(graph.getNodeAttribute(v, "name") || v); | ||
| if (i === 0) { | ||
| segments.push(uLabel); | ||
| } | ||
| if (forward) { | ||
| segments.push(`--${rel}${confStr}--> ${vLabel}`); | ||
| } else { | ||
| segments.push(`<--${rel}${confStr}-- ${vLabel}`); | ||
| } | ||
| } | ||
| return `Shortest path (${hops} hops): | ||
| ` + segments.join(" "); | ||
| } | ||
| async function getGodNodes(targetDirOrGraph, page = 1, limit = 10) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| const { findGodNodes } = await import("./analyze-HGCIEDCE.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-HGCIEDCE.js"); | ||
| const communities = detectCommunities(graph); | ||
| const targetCommunity = communities.find((c) => c.id === communityId); | ||
| if (!targetCommunity) { | ||
| throw new Error(`Community ${communityId} not found.`); | ||
| } | ||
| const total = targetCommunity.nodes.length; | ||
| const totalPages = Math.ceil(total / limit) || 1; | ||
| const start = (page - 1) * limit; | ||
| const end = start + limit; | ||
| const paginatedNodes = targetCommunity.nodes.slice(start, end); | ||
| const lines = [`Community ${communityId} (${total} nodes):`]; | ||
| paginatedNodes.forEach((nodeId) => { | ||
| const attr = graph.getNodeAttributes(nodeId); | ||
| const label = attr.name || nodeId; | ||
| lines.push(` ${label} (type: ${attr.type}) [id: ${nodeId}]`); | ||
| }); | ||
| if (totalPages > 1) { | ||
| lines.push(` | ||
| [Page ${page} of ${totalPages} | Total: ${total} nodes]`); | ||
| } | ||
| return lines.join("\n"); | ||
| } | ||
| async function getSurprisingConnections(targetDirOrGraph, page = 1, limit = 20) { | ||
| const graph = typeof targetDirOrGraph === "string" ? loadGraph(targetDirOrGraph) : targetDirOrGraph; | ||
| const { detectCommunities, findSurprisingConnections } = await import("./analyze-HGCIEDCE.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-HGCIEDCE.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 | ||
| }; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
5945727
0.36%10
-16.67%0
-100%31
82.35%9400
7.24%27
8%2
100%+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed