From efcc78a50e2f18b432c81aa24a967ca75b67d779 Mon Sep 17 00:00:00 2001 From: Vreyka Date: Tue, 7 Apr 2026 22:59:27 +0700 Subject: [PATCH] feat: git namespace isolation, markdown/pseudocode ingestion, doc-resolver, Section/CodeElement embedding --- .../markdown-processor.ts.bak_batch3 | 279 -- .../src/core/lbug/csv-generator.ts.bak_batch3 | 596 --- gitnexus/src/core/lbug/schema.ts.bak_batch3 | 530 --- .../src/mcp/local/local-backend.bak_batch4 | 3368 ----------------- .../src/mcp/local/local-backend.ts.bak_batch2 | 3311 ---------------- gitnexus/test/unit/security.test.ts | 2 +- gitnexus/vitest.config.ts | 6 + 7 files changed, 7 insertions(+), 8085 deletions(-) delete mode 100644 gitnexus/src/core/ingestion/markdown-processor.ts.bak_batch3 delete mode 100644 gitnexus/src/core/lbug/csv-generator.ts.bak_batch3 delete mode 100644 gitnexus/src/core/lbug/schema.ts.bak_batch3 delete mode 100644 gitnexus/src/mcp/local/local-backend.bak_batch4 delete mode 100644 gitnexus/src/mcp/local/local-backend.ts.bak_batch2 diff --git a/gitnexus/src/core/ingestion/markdown-processor.ts.bak_batch3 b/gitnexus/src/core/ingestion/markdown-processor.ts.bak_batch3 deleted file mode 100644 index fcb9062a9..000000000 --- a/gitnexus/src/core/ingestion/markdown-processor.ts.bak_batch3 +++ /dev/null @@ -1,279 +0,0 @@ -/** - * Markdown Processor (AST-Based) - * - * V3 Architecture compliant parser. Extracts markdown structures using mdast. - * Identifies headings as documentation Sections, code blocks as Pseudocode CodeElements. - * Maintains chronological process flow via stepCounter in CALLS edges. - */ - -import path from 'node:path'; -import { generateId } from '../../lib/utils.js'; -import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; -import { KnowledgeGraph } from '../graph/types.js'; -import { resolveGitNamespace, type GitNamespaceMap } from './git-namespace-detector.js'; -import { unified } from 'unified'; -import remarkParse from 'remark-parse'; -import { visit } from 'unist-util-visit'; -import type { Root, Heading, Code, Link } from 'mdast'; - -const MD_EXTENSIONS = new Set(['.md', '.mdx']); - -interface MdFile { - path: string; - content: string; -} - -export interface PendingResolution { - source: string; - name: string; - step: number; - sourceContext: string; -} - -export const processMarkdown = ( - graph: KnowledgeGraph, - files: MdFile[], - allPathSet: Set, - namespaceMap?: GitNamespaceMap, -): { sections: number; links: number; pendingResolutions: PendingResolution[] } => { - let totalSections = 0; - let totalLinks = 0; - const pendingResolutions: PendingResolution[] = []; - - const processor = unified().use(remarkParse); - - // Cross-file Symbol Table: Mapping funcName -> CodeElement ID - const docSymbolTable = new Map(); - - // Store code blocks globally for Step 3 pass - const allDesignCodeBlocks: { id: string, calledSymbols: string[], filePath: string }[] = []; - - for (const file of files) { - const ext = path.extname(file.path).toLowerCase(); - if (!MD_EXTENSIONS.has(ext)) continue; - - const fileNodeId = generateId('File', file.path); - if (!graph.getNode(fileNodeId)) continue; - - // Update File node metadata for documentation recognition - const fileNode = graph.getNode(fileNodeId); - if (fileNode) { - fileNode.properties.nodeCategory = 'documentation'; - } - - const ast = processor.parse(file.content) as Root; - - // Extract Headings - const headings: { id: string, level: number, lineNum: number, endLine: number, slug: string }[] = []; - visit(ast, 'heading', (node: Heading) => { - if (!node.position) return; - const textNode = node.children.find(c => c.type === 'text'); - const text = textNode && 'value' in textNode ? textNode.value : `Heading ${node.depth}`; - const slug = text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-'); - headings.push({ - id: generateId('Section', `${file.path}:L${node.position.start.line}:${text}`), - level: node.depth, - lineNum: node.position.start.line, - endLine: file.content.split('\n').length, // Will be refined below - slug: slug - }); - }); - - // Refine heading endlines based on hierarchy - for (let h = 0; h < headings.length; h++) { - for (let j = h + 1; j < headings.length; j++) { - if (headings[j].level <= headings[h].level) { - headings[h].endLine = headings[j].lineNum - 1; - break; - } - } - } - - // Register Heading Nodes and CONTAINS hierarchy - const sectionStack: { level: number; id: string }[] = []; - for (const h of headings) { - const sectionNode: GraphNode = { - id: h.id, - label: 'Section', - properties: { - name: h.slug, - filePath: file.path, - startLine: h.lineNum, - endLine: h.endLine, - level: h.level, - description: `h${h.level}`, - nodeCategory: 'documentation', - isPseudocode: false, - docType: 'design', - ...(namespaceMap ? { git_namespace: resolveGitNamespace(file.path, namespaceMap) } : {}), - } - }; - graph.addNode(sectionNode); - totalSections++; - - while (sectionStack.length > 0 && sectionStack[sectionStack.length - 1].level >= h.level) { - sectionStack.pop(); - } - const parentId = sectionStack.length > 0 ? sectionStack[sectionStack.length - 1].id : fileNodeId; - - graph.addRelationship({ - id: generateId('CONTAINS', `${parentId}->${h.id}`), - type: 'CONTAINS', - sourceId: parentId, - targetId: h.id, - confidence: 1.0, - reason: 'markdown-heading' - }); - sectionStack.push({ level: h.level, id: h.id }); - } - - // Helper: Find enclosing heading - const findEnclosingHeading = (line: number) => { - let closest = null; - for (const h of headings) { - if (line >= h.lineNum && line <= h.endLine) { - if (!closest || h.level > closest.level) closest = h; - } - } - return closest; - }; - - // Extract Code Blocks - visit(ast, 'code', (node: Code) => { - if (!node.position) return; - const startLine = node.position.start.line; - const endLine = node.position.end.line; - const id = generateId('CodeElement', `${file.path}:${startLine}-${endLine}`); - - const defPattern = /(?:async\s+)?(?:function|procedure|def|method)\s+(\w+)\s*\(/g; - const callPattern = /\b(\w+)\s*\(/g; - - const extractAll = (text: string, regex: RegExp) => { - const results = []; - let match; - while ((match = regex.exec(text)) !== null) { - if (match[1]) results.push(match[1]); - } - return results; - }; - - const definedSymbols = extractAll(node.value, defPattern); - let calledSymbols = extractAll(node.value, callPattern); - - const EXCLUDE_CALLS = new Set([ - "console", "log", "warn", "error", "parseInt", "parseFloat", - "setTimeout", "setInterval", "clearTimeout", "clearInterval", - "Array", "Object", "Map", "Set", "Promise", "JSON", - "Math", "Date", "String", "Number", "Boolean", - "require", "import", "export", "typeof", "instanceof", - "if", "else", "for", "while", "switch", "case", "return", "throw" - ]); - calledSymbols = calledSymbols.filter(s => !EXCLUDE_CALLS.has(s) && !definedSymbols.includes(s)); - - const codeNode: GraphNode = { - id, - label: 'CodeElement', - properties: { - name: definedSymbols.length > 0 ? definedSymbols[0] : 'anonymous_block', - filePath: file.path, - startLine, - endLine, - isExported: false, - content: '', - description: node.lang ? `lang:${node.lang}` : 'pseudocode block', - nodeCategory: 'documentation', - isPseudocode: true, - rawContent: node.value, - definedSymbols, - calledSymbols, - docType: 'design', - ...(namespaceMap ? { git_namespace: resolveGitNamespace(file.path, namespaceMap) } : {}), - } - }; - graph.addNode(codeNode); - - // §1.2.1 CONTAINS Rule (Design -> Pseudocode) - const parentHeading = findEnclosingHeading(startLine); - const parentId = parentHeading ? parentHeading.id : fileNodeId; - - graph.addRelationship({ - id: generateId('CONTAINS', `${parentId}->${id}`), - type: 'CONTAINS', - sourceId: parentId, - targetId: id, - confidence: 0.98, - reason: 'structural-containment' - }); - - // Build Document Symbol Table Map - for (const funcName of definedSymbols) { - docSymbolTable.set(funcName, id); - } - - allDesignCodeBlocks.push({ id, calledSymbols, filePath: file.path }); - }); - - // §1.2.2 IMPORTS Rule (Design -> Design) - visit(ast, 'link', (node: Link) => { - // Find what section contains this link - if (!node.position) return; - const enclosingHeading = findEnclosingHeading(node.position.start.line); - const sourceId = enclosingHeading ? enclosingHeading.id : fileNodeId; - - if (node.url.endsWith('.md') || node.url.includes('.md#')) { - const cleanHref = node.url.split('#')[0]; - const targetAnchor = node.url.split('#')[1]; - - if (cleanHref) { - const fileDir = path.dirname(file.path); - const resolved = path.posix.normalize(path.posix.join(fileDir, cleanHref)); - if (allPathSet.has(resolved)) { - const targetFileId = generateId('File', resolved); - // Cannot resolve perfect sibling section now because other file may not be parsed yet. - // Standard implementation points IMPORTS to target file ID as a baseline. - graph.addRelationship({ - id: generateId('IMPORTS', `${sourceId}->${targetFileId}`), - type: 'IMPORTS', - sourceId, - targetId: targetFileId, - confidence: targetAnchor ? 0.95 : 0.85, - reason: 'markdown-link' - }); - totalLinks++; - } - } - } - }); - } - - // §1.2.3 CALLS Edge Rule (Pseudocode -> Pseudocode Chronology Tracking) - for (const B of allDesignCodeBlocks) { - let stepCounter = 1; // Required for GitNexus Native Process Tracing - for (const callName of B.calledSymbols) { - if (docSymbolTable.has(callName)) { - const target = docSymbolTable.get(callName)!; - if (target !== B.id) { // No self-loops - graph.addRelationship({ - id: generateId('CALLS', `${B.id}->${target}`), - type: 'CALLS', - sourceId: B.id, - targetId: target, - confidence: 0.90, - reason: 'pseudocode-call', - step: stepCounter++ - }); - } - } else { - // Unresolved -> Schedule for §1.2.4 IMPLEMENTS mapping - pendingResolutions.push({ - source: B.id, - name: callName, - step: stepCounter++, - sourceContext: B.filePath - }); - } - } - } - - return { sections: totalSections, links: totalLinks, pendingResolutions }; -}; diff --git a/gitnexus/src/core/lbug/csv-generator.ts.bak_batch3 b/gitnexus/src/core/lbug/csv-generator.ts.bak_batch3 deleted file mode 100644 index 5a677bf73..000000000 --- a/gitnexus/src/core/lbug/csv-generator.ts.bak_batch3 +++ /dev/null @@ -1,596 +0,0 @@ -/** - * CSV Generator for LadybugDB Hybrid Schema - * - * Streams CSV rows directly to disk files in a single pass over graph nodes. - * File contents are lazy-read from disk per-node to avoid holding the entire - * repo in RAM. Rows are buffered (FLUSH_EVERY) before writing to minimize - * per-row Promise overhead. - * - * RFC 4180 Compliant: - * - Fields containing commas, double quotes, or newlines are enclosed in double quotes - * - Double quotes within fields are escaped by doubling them ("") - * - All fields are consistently quoted for safety with code content - */ - -import fs from 'fs/promises'; -import { createWriteStream, WriteStream } from 'fs'; -import path from 'path'; -import type { GraphNode } from 'gitnexus-shared'; -import { KnowledgeGraph } from '../graph/types.js'; -import { NodeTableName } from './schema.js'; - -/** Flush buffered rows to disk every N rows */ -const FLUSH_EVERY = 500; - -// ============================================================================ -// CSV ESCAPE UTILITIES -// ============================================================================ - -export const sanitizeUTF8 = (str: string): string => { - return str - .replace(/\r\n/g, '\n') - .replace(/\r/g, '\n') - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') - .replace(/[\uD800-\uDFFF]/g, '') - .replace(/[\uFFFE\uFFFF]/g, ''); -}; - -export const escapeCSVField = (value: string | number | undefined | null): string => { - if (value === undefined || value === null) return '""'; - let str = String(value); - str = sanitizeUTF8(str); - return `"${str.replace(/"/g, '""')}"`; -}; - -export const escapeCSVNumber = ( - value: number | undefined | null, - defaultValue: number = -1, -): string => { - if (value === undefined || value === null) return String(defaultValue); - return String(value); -}; - -// ============================================================================ -// CONTENT EXTRACTION (lazy — reads from disk on demand) -// ============================================================================ - -export const isBinaryContent = (content: string): boolean => { - if (!content || content.length === 0) return false; - const sample = content.slice(0, 1000); - let nonPrintable = 0; - for (let i = 0; i < sample.length; i++) { - const code = sample.charCodeAt(i); - if (code < 9 || (code > 13 && code < 32) || code === 127) nonPrintable++; - } - return nonPrintable / sample.length > 0.1; -}; - -/** - * LRU content cache — avoids re-reading the same source file for every - * symbol defined in it. Sized generously so most files stay cached during - * the single-pass node iteration. - */ -class FileContentCache { - private cache = new Map(); - private accessOrder: string[] = []; - private maxSize: number; - private repoPath: string; - - constructor(repoPath: string, maxSize: number = 3000) { - this.repoPath = repoPath; - this.maxSize = maxSize; - } - - async get(relativePath: string): Promise { - if (!relativePath) return ''; - const cached = this.cache.get(relativePath); - if (cached !== undefined) { - // Move to end of accessOrder (LRU promotion) - const idx = this.accessOrder.indexOf(relativePath); - if (idx !== -1) { - this.accessOrder.splice(idx, 1); - this.accessOrder.push(relativePath); - } - return cached; - } - try { - const fullPath = path.join(this.repoPath, relativePath); - const content = await fs.readFile(fullPath, 'utf-8'); - this.set(relativePath, content); - return content; - } catch { - this.set(relativePath, ''); - return ''; - } - } - - private set(key: string, value: string) { - if (this.cache.size >= this.maxSize) { - const oldest = this.accessOrder.shift(); - if (oldest) this.cache.delete(oldest); - } - this.cache.set(key, value); - this.accessOrder.push(key); - } -} - -const extractContent = async (node: GraphNode, contentCache: FileContentCache): Promise => { - const filePath = node.properties.filePath; - const content = await contentCache.get(filePath); - if (!content) return ''; - if (node.label === 'Folder') return ''; - if (isBinaryContent(content)) return '[Binary file - content not stored]'; - - if (node.label === 'File') { - const MAX_FILE_CONTENT = 10000; - return content.length > MAX_FILE_CONTENT - ? content.slice(0, MAX_FILE_CONTENT) + '\n... [truncated]' - : content; - } - - const startLine = node.properties.startLine; - const endLine = node.properties.endLine; - if (startLine === undefined || endLine === undefined) return ''; - - const lines = content.split('\n'); - const start = Math.max(0, startLine - 2); - const end = Math.min(lines.length - 1, endLine + 2); - const snippet = lines.slice(start, end + 1).join('\n'); - const MAX_SNIPPET = 5000; - return snippet.length > MAX_SNIPPET - ? snippet.slice(0, MAX_SNIPPET) + '\n... [truncated]' - : snippet; -}; - -// ============================================================================ -// BUFFERED CSV WRITER -// ============================================================================ - -class BufferedCSVWriter { - private ws: WriteStream; - private buffer: string[] = []; - rows = 0; - - constructor(filePath: string, header: string) { - this.ws = createWriteStream(filePath, 'utf-8'); - // Large repos flush many times — raise listener cap to avoid MaxListenersExceededWarning - this.ws.setMaxListeners(50); - this.buffer.push(header); - } - - addRow(row: string) { - this.buffer.push(row); - this.rows++; - if (this.buffer.length >= FLUSH_EVERY) { - return this.flush(); - } - return Promise.resolve(); - } - - flush(): Promise { - if (this.buffer.length === 0) return Promise.resolve(); - const chunk = this.buffer.join('\n') + '\n'; - this.buffer.length = 0; - return new Promise((resolve, reject) => { - this.ws.once('error', reject); - const ok = this.ws.write(chunk); - if (ok) { - this.ws.removeListener('error', reject); - resolve(); - } else { - this.ws.once('drain', () => { - this.ws.removeListener('error', reject); - resolve(); - }); - } - }); - } - - async finish(): Promise { - await this.flush(); - return new Promise((resolve, reject) => { - this.ws.end(() => resolve()); - this.ws.on('error', reject); - }); - } -} - -// ============================================================================ -// STREAMING CSV GENERATION — SINGLE PASS -// ============================================================================ - -export interface StreamedCSVResult { - nodeFiles: Map; - relCsvPath: string; - relRows: number; -} - -/** - * Stream all CSV data directly to disk files. - * Iterates graph nodes exactly ONCE — routes each node to the right writer. - * File contents are lazy-read from disk with a generous LRU cache. - */ -export const streamAllCSVsToDisk = async ( - graph: KnowledgeGraph, - repoPath: string, - csvDir: string, -): Promise => { - // Remove stale CSVs from previous crashed runs, then recreate - try { - await fs.rm(csvDir, { recursive: true, force: true }); - } catch {} - await fs.mkdir(csvDir, { recursive: true }); - - // We open ~30 concurrent write-streams; raise process limit to suppress - // MaxListenersExceededWarning (restored after all streams finish). - const prevMax = process.getMaxListeners(); - process.setMaxListeners(prevMax + 40); - - const contentCache = new FileContentCache(repoPath); - - // Create writers for every node type up-front - const fileWriter = new BufferedCSVWriter( - path.join(csvDir, 'file.csv'), - 'id,name,filePath,content,nodeCategory,isPseudocode,rawContent,definedSymbols,calledSymbols,docType,domain,git_namespace', - ); - const folderWriter = new BufferedCSVWriter(path.join(csvDir, 'folder.csv'), 'id,name,filePath,git_namespace'); - const codeElementHeader = 'id,name,filePath,startLine,endLine,isExported,content,description,git_namespace'; - const functionWriter = new BufferedCSVWriter( - path.join(csvDir, 'function.csv'), - codeElementHeader, - ); - const classWriter = new BufferedCSVWriter(path.join(csvDir, 'class.csv'), codeElementHeader); - const interfaceWriter = new BufferedCSVWriter( - path.join(csvDir, 'interface.csv'), - codeElementHeader, - ); - const methodHeader = - 'id,name,filePath,startLine,endLine,isExported,content,description,parameterCount,returnType,git_namespace'; - const methodWriter = new BufferedCSVWriter(path.join(csvDir, 'method.csv'), methodHeader); - const codeElemSpecificHeader = - 'id,name,filePath,startLine,endLine,isExported,content,description,nodeCategory,isPseudocode,rawContent,definedSymbols,calledSymbols,docType,domain,git_namespace'; - const codeElemWriter = new BufferedCSVWriter( - path.join(csvDir, 'codeelement.csv'), - codeElemSpecificHeader, - ); - const communityWriter = new BufferedCSVWriter( - path.join(csvDir, 'community.csv'), - 'id,label,heuristicLabel,keywords,description,enrichedBy,cohesion,symbolCount', - ); - const processWriter = new BufferedCSVWriter( - path.join(csvDir, 'process.csv'), - 'id,label,heuristicLabel,processType,stepCount,communities,entryPointId,terminalId', - ); - - // Section nodes have an extra 'level' column - const sectionWriter = new BufferedCSVWriter( - path.join(csvDir, 'section.csv'), - 'id,name,filePath,startLine,endLine,level,content,description,nodeCategory,isPseudocode,rawContent,definedSymbols,calledSymbols,docType,domain,git_namespace', - ); - - // Route nodes for API endpoint mapping - const routeWriter = new BufferedCSVWriter( - path.join(csvDir, 'route.csv'), - 'id,name,filePath,responseKeys,errorKeys,middleware,git_namespace', - ); - - // Tool nodes for MCP tool definitions - const toolWriter = new BufferedCSVWriter( - path.join(csvDir, 'tool.csv'), - 'id,name,filePath,description,git_namespace', - ); - - // Multi-language node types share the same CSV shape (no isExported column) - const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description,git_namespace'; - const MULTI_LANG_TYPES = [ - 'Struct', - 'Enum', - 'Macro', - 'Typedef', - 'Union', - 'Namespace', - 'Trait', - 'Impl', - 'TypeAlias', - 'Const', - 'Static', - 'Property', - 'Record', - 'Delegate', - 'Annotation', - 'Constructor', - 'Template', - 'Module', - ] as const; - const multiLangWriters = new Map(); - for (const t of MULTI_LANG_TYPES) { - multiLangWriters.set( - t, - new BufferedCSVWriter(path.join(csvDir, `${t.toLowerCase()}.csv`), multiLangHeader), - ); - } - - const codeWriterMap: Record = { - Function: functionWriter, - Class: classWriter, - Interface: interfaceWriter, - CodeElement: codeElemWriter, - }; - - const seenFileIds = new Set(); - - const formatArray = (arr: any[] | undefined) => { - if (!arr) return '[]'; - return `[${arr.map((k: string) => `'${k.replace(/'/g, "''")}'`).join(',')}]`; - }; - - // --- SINGLE PASS over all nodes --- - for (const node of graph.iterNodes()) { - switch (node.label) { - case 'File': { - if (seenFileIds.has(node.id)) break; - seenFileIds.add(node.id); - const content = await extractContent(node, contentCache); - await fileWriter.addRow( - [ - escapeCSVField(node.id), - escapeCSVField(node.properties.name || ''), - escapeCSVField(node.properties.filePath || ''), - escapeCSVField(content), - escapeCSVField((node.properties.nodeCategory as string) || ''), - node.properties.isPseudocode ? 'true' : 'false', - escapeCSVField((node.properties.rawContent as string) || ''), - escapeCSVField(formatArray(node.properties.definedSymbols as string[])), - escapeCSVField(formatArray(node.properties.calledSymbols as string[])), - escapeCSVField((node.properties.docType as string) || ''), - escapeCSVField((node.properties.domain as string) || ''), - escapeCSVField((node.properties.git_namespace as string) || ''), - ].join(','), - ); - break; - } - case 'Folder': - await folderWriter.addRow( - [ - escapeCSVField(node.id), - escapeCSVField(node.properties.name || ''), - escapeCSVField(node.properties.filePath || ''), - escapeCSVField((node.properties.git_namespace as string) || ''), - ].join(','), - ); - break; - case 'Community': { - const keywords = node.properties.keywords || []; - const keywordsStr = `[${keywords.map((k: string) => `'${k.replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/,/g, '\\,')}'`).join(',')}]`; - await communityWriter.addRow( - [ - escapeCSVField(node.id), - escapeCSVField(node.properties.name || ''), - escapeCSVField(node.properties.heuristicLabel || ''), - keywordsStr, - escapeCSVField(node.properties.description || ''), - escapeCSVField(node.properties.enrichedBy || 'heuristic'), - escapeCSVNumber(node.properties.cohesion, 0), - escapeCSVNumber(node.properties.symbolCount, 0), - ].join(','), - ); - break; - } - case 'Process': { - const communities = node.properties.communities || []; - const communitiesStr = `[${communities.map((c: string) => `'${c.replace(/'/g, "''")}'`).join(',')}]`; - await processWriter.addRow( - [ - escapeCSVField(node.id), - escapeCSVField(node.properties.name || ''), - escapeCSVField(node.properties.heuristicLabel || ''), - escapeCSVField(node.properties.processType || ''), - escapeCSVNumber(node.properties.stepCount, 0), - escapeCSVField(communitiesStr), - escapeCSVField(node.properties.entryPointId || ''), - escapeCSVField(node.properties.terminalId || ''), - ].join(','), - ); - break; - } - case 'Method': { - const content = await extractContent(node, contentCache); - await methodWriter.addRow( - [ - escapeCSVField(node.id), - escapeCSVField(node.properties.name || ''), - escapeCSVField(node.properties.filePath || ''), - escapeCSVNumber(node.properties.startLine, -1), - escapeCSVNumber(node.properties.endLine, -1), - node.properties.isExported ? 'true' : 'false', - escapeCSVField(content), - escapeCSVField(node.properties.description || ''), - escapeCSVNumber(node.properties.parameterCount, 0), - escapeCSVField(node.properties.returnType || ''), - escapeCSVField((node.properties.git_namespace as string) || ''), - ].join(','), - ); - break; - } - case 'Section': { - const content = await extractContent(node, contentCache); - await sectionWriter.addRow( - [ - escapeCSVField(node.id), - escapeCSVField(node.properties.name || ''), - escapeCSVField(node.properties.filePath || ''), - escapeCSVNumber(node.properties.startLine, -1), - escapeCSVNumber(node.properties.endLine, -1), - escapeCSVNumber(node.properties.level, 1), - escapeCSVField(content), - escapeCSVField(node.properties.description || ''), - escapeCSVField((node.properties.nodeCategory as string) || ''), - node.properties.isPseudocode ? 'true' : 'false', - escapeCSVField((node.properties.rawContent as string) || ''), - escapeCSVField(formatArray(node.properties.definedSymbols as string[])), - escapeCSVField(formatArray(node.properties.calledSymbols as string[])), - escapeCSVField((node.properties.docType as string) || ''), - escapeCSVField((node.properties.domain as string) || ''), - escapeCSVField((node.properties.git_namespace as string) || ''), - ].join(','), - ); - break; - } - case 'Route': { - const responseKeys = node.properties.responseKeys || []; - // LadybugDB array literal inside a quoted CSV field: escapeCSVField wraps in "..." - // and the array uses single-quoted elements - const keysStr = `[${responseKeys.map((k: string) => `'${k.replace(/'/g, "''")}'`).join(',')}]`; - const errorKeys = node.properties.errorKeys || []; - const errorKeysStr = `[${errorKeys.map((k: string) => `'${k.replace(/'/g, "''")}'`).join(',')}]`; - const middleware = node.properties.middleware || []; - const middlewareStr = `[${middleware.map((m: string) => `'${m.replace(/'/g, "''")}'`).join(',')}]`; - await routeWriter.addRow( - [ - escapeCSVField(node.id), - escapeCSVField(node.properties.name || ''), - escapeCSVField(node.properties.filePath || ''), - escapeCSVField(keysStr), - escapeCSVField(errorKeysStr), - escapeCSVField(middlewareStr), - escapeCSVField((node.properties.git_namespace as string) || ''), - ].join(','), - ); - break; - } - case 'Tool': - await toolWriter.addRow( - [ - escapeCSVField(node.id), - escapeCSVField(node.properties.name || ''), - escapeCSVField(node.properties.filePath || ''), - escapeCSVField(node.properties.description || ''), - escapeCSVField((node.properties.git_namespace as string) || ''), - ].join(','), - ); - break; - default: { - // Code element nodes (Function, Class, Interface, CodeElement) - const writer = codeWriterMap[node.label]; - if (writer) { - const content = await extractContent(node, contentCache); - const baseRow = [ - escapeCSVField(node.id), - escapeCSVField(node.properties.name || ''), - escapeCSVField(node.properties.filePath || ''), - escapeCSVNumber(node.properties.startLine, -1), - escapeCSVNumber(node.properties.endLine, -1), - node.properties.isExported ? 'true' : 'false', - escapeCSVField(content), - escapeCSVField(node.properties.description || ''), - ]; - - if (node.label === 'CodeElement') { - baseRow.push( - escapeCSVField((node.properties.nodeCategory as string) || ''), - node.properties.isPseudocode ? 'true' : 'false', - escapeCSVField((node.properties.rawContent as string) || ''), - escapeCSVField(formatArray(node.properties.definedSymbols as string[])), - escapeCSVField(formatArray(node.properties.calledSymbols as string[])), - escapeCSVField((node.properties.docType as string) || ''), - escapeCSVField((node.properties.domain as string) || ''), - escapeCSVField((node.properties.git_namespace as string) || ''), - ); - } else { - // Function, Class, Interface — git_namespace is the last column - baseRow.push(escapeCSVField((node.properties.git_namespace as string) || '')); - } - - await writer.addRow(baseRow.join(',')); - } else { - // Multi-language node types (Struct, Impl, Trait, Macro, etc.) - const mlWriter = multiLangWriters.get(node.label); - if (mlWriter) { - const content = await extractContent(node, contentCache); - await mlWriter.addRow( - [ - escapeCSVField(node.id), - escapeCSVField(node.properties.name || ''), - escapeCSVField(node.properties.filePath || ''), - escapeCSVNumber(node.properties.startLine, -1), - escapeCSVNumber(node.properties.endLine, -1), - escapeCSVField(content), - escapeCSVField(node.properties.description || ''), - escapeCSVField((node.properties.git_namespace as string) || ''), - ].join(','), - ); - } - } - break; - } - } - } - - // Finish all node writers - const allWriters = [ - fileWriter, - folderWriter, - functionWriter, - classWriter, - interfaceWriter, - methodWriter, - codeElemWriter, - communityWriter, - processWriter, - sectionWriter, - routeWriter, - toolWriter, - ...multiLangWriters.values(), - ]; - await Promise.all(allWriters.map((w) => w.finish())); - - // --- Stream relationship CSV --- - const relCsvPath = path.join(csvDir, 'relations.csv'); - const relWriter = new BufferedCSVWriter(relCsvPath, 'from,to,type,confidence,reason,step'); - for (const rel of graph.iterRelationships()) { - await relWriter.addRow( - [ - escapeCSVField(rel.sourceId), - escapeCSVField(rel.targetId), - escapeCSVField(rel.type), - escapeCSVNumber(rel.confidence, 1.0), - escapeCSVField(rel.reason), - escapeCSVNumber((rel as any).step, 0), - ].join(','), - ); - } - await relWriter.finish(); - - // Build result map — only include tables that have rows - const nodeFiles = new Map(); - const tableMap: [NodeTableName, BufferedCSVWriter][] = [ - ['File', fileWriter], - ['Folder', folderWriter], - ['Function', functionWriter], - ['Class', classWriter], - ['Interface', interfaceWriter], - ['Method', methodWriter], - ['CodeElement', codeElemWriter], - ['Community', communityWriter], - ['Process', processWriter], - ['Section' as NodeTableName, sectionWriter], - ['Route' as NodeTableName, routeWriter], - ['Tool' as NodeTableName, toolWriter], - ...Array.from(multiLangWriters.entries()).map( - ([name, w]) => [name as NodeTableName, w] as [NodeTableName, BufferedCSVWriter], - ), - ]; - for (const [name, writer] of tableMap) { - if (writer.rows > 0) { - nodeFiles.set(name, { - csvPath: path.join(csvDir, `${name.toLowerCase()}.csv`), - rows: writer.rows, - }); - } - } - - // Restore original process listener limit - process.setMaxListeners(prevMax); - - return { nodeFiles, relCsvPath, relRows: relWriter.rows }; -}; diff --git a/gitnexus/src/core/lbug/schema.ts.bak_batch3 b/gitnexus/src/core/lbug/schema.ts.bak_batch3 deleted file mode 100644 index dea67aee9..000000000 --- a/gitnexus/src/core/lbug/schema.ts.bak_batch3 +++ /dev/null @@ -1,530 +0,0 @@ -/** - * LadybugDB Schema Definitions - * - * Hybrid Schema: - * - Separate node tables for each code element type (File, Function, Class, etc.) - * - Single CodeRelation table with 'type' property for all relationships - * - * This allows LLMs to write natural Cypher queries like: - * MATCH (f:Function)-[r:CodeRelation {type: 'CALLS'}]->(g:Function) RETURN f, g - */ - -// Import from shared package (single source of truth) — used in DDL templates below -import { NODE_TABLES, REL_TABLE_NAME, REL_TYPES, EMBEDDING_TABLE_NAME } from 'gitnexus-shared'; -// Re-export so downstream consumers keep the same import path -export { NODE_TABLES, REL_TABLE_NAME, REL_TYPES, EMBEDDING_TABLE_NAME }; -export type { NodeTableName, RelType } from 'gitnexus-shared'; - -// ============================================================================ -// NODE TABLE SCHEMAS -// ============================================================================ - -export const FILE_SCHEMA = ` -CREATE NODE TABLE File ( - id STRING, - name STRING, - filePath STRING, - content STRING, - nodeCategory STRING, - isPseudocode BOOLEAN, - rawContent STRING, - definedSymbols STRING[], - calledSymbols STRING[], - docType STRING, - domain STRING, - git_namespace STRING, - PRIMARY KEY (id) -)`; - -export const FOLDER_SCHEMA = ` -CREATE NODE TABLE Folder ( - id STRING, - name STRING, - filePath STRING, - git_namespace STRING, - PRIMARY KEY (id) -)`; - -export const FUNCTION_SCHEMA = ` -CREATE NODE TABLE Function ( - id STRING, - name STRING, - filePath STRING, - startLine INT64, - endLine INT64, - isExported BOOLEAN, - content STRING, - description STRING, - git_namespace STRING, - PRIMARY KEY (id) -)`; - -export const CLASS_SCHEMA = ` -CREATE NODE TABLE Class ( - id STRING, - name STRING, - filePath STRING, - startLine INT64, - endLine INT64, - isExported BOOLEAN, - content STRING, - description STRING, - git_namespace STRING, - PRIMARY KEY (id) -)`; - -export const INTERFACE_SCHEMA = ` -CREATE NODE TABLE Interface ( - id STRING, - name STRING, - filePath STRING, - startLine INT64, - endLine INT64, - isExported BOOLEAN, - content STRING, - description STRING, - git_namespace STRING, - PRIMARY KEY (id) -)`; - -export const METHOD_SCHEMA = ` -CREATE NODE TABLE Method ( - id STRING, - name STRING, - filePath STRING, - startLine INT64, - endLine INT64, - isExported BOOLEAN, - content STRING, - description STRING, - parameterCount INT32, - returnType STRING, - git_namespace STRING, - PRIMARY KEY (id) -)`; - -export const CODE_ELEMENT_SCHEMA = ` -CREATE NODE TABLE CodeElement ( - id STRING, - name STRING, - filePath STRING, - startLine INT64, - endLine INT64, - isExported BOOLEAN, - content STRING, - description STRING, - nodeCategory STRING, - isPseudocode BOOLEAN, - rawContent STRING, - definedSymbols STRING[], - calledSymbols STRING[], - docType STRING, - domain STRING, - git_namespace STRING, - PRIMARY KEY (id) -)`; - -// ============================================================================ -// COMMUNITY NODE TABLE (for Leiden algorithm clusters) -// ============================================================================ - -export const COMMUNITY_SCHEMA = ` -CREATE NODE TABLE Community ( - id STRING, - label STRING, - heuristicLabel STRING, - keywords STRING[], - description STRING, - enrichedBy STRING, - cohesion DOUBLE, - symbolCount INT32, - PRIMARY KEY (id) -)`; - -// ============================================================================ -// PROCESS NODE TABLE (for execution flow detection) -// ============================================================================ - -export const PROCESS_SCHEMA = ` -CREATE NODE TABLE Process ( - id STRING, - label STRING, - heuristicLabel STRING, - processType STRING, - stepCount INT32, - communities STRING[], - entryPointId STRING, - terminalId STRING, - PRIMARY KEY (id) -)`; - -// ============================================================================ -// MULTI-LANGUAGE NODE TABLE SCHEMAS -// ============================================================================ - -// Generic code element with startLine/endLine for C, C++, Rust, Go, Java, C# -// description: optional metadata (e.g. Eloquent $fillable fields, relationship targets) -const CODE_ELEMENT_BASE = (name: string) => ` -CREATE NODE TABLE \`${name}\` ( - id STRING, - name STRING, - filePath STRING, - startLine INT64, - endLine INT64, - content STRING, - description STRING, - git_namespace STRING, - PRIMARY KEY (id) -)`; - -export const STRUCT_SCHEMA = CODE_ELEMENT_BASE('Struct'); -export const ENUM_SCHEMA = CODE_ELEMENT_BASE('Enum'); -export const MACRO_SCHEMA = CODE_ELEMENT_BASE('Macro'); -export const TYPEDEF_SCHEMA = CODE_ELEMENT_BASE('Typedef'); -export const UNION_SCHEMA = CODE_ELEMENT_BASE('Union'); -export const NAMESPACE_SCHEMA = CODE_ELEMENT_BASE('Namespace'); -export const TRAIT_SCHEMA = CODE_ELEMENT_BASE('Trait'); -export const IMPL_SCHEMA = CODE_ELEMENT_BASE('Impl'); -export const TYPE_ALIAS_SCHEMA = CODE_ELEMENT_BASE('TypeAlias'); -export const CONST_SCHEMA = CODE_ELEMENT_BASE('Const'); -export const STATIC_SCHEMA = CODE_ELEMENT_BASE('Static'); -export const PROPERTY_SCHEMA = CODE_ELEMENT_BASE('Property'); -export const RECORD_SCHEMA = CODE_ELEMENT_BASE('Record'); -export const DELEGATE_SCHEMA = CODE_ELEMENT_BASE('Delegate'); -export const ANNOTATION_SCHEMA = CODE_ELEMENT_BASE('Annotation'); -export const CONSTRUCTOR_SCHEMA = CODE_ELEMENT_BASE('Constructor'); -export const TEMPLATE_SCHEMA = CODE_ELEMENT_BASE('Template'); -export const MODULE_SCHEMA = CODE_ELEMENT_BASE('Module'); -// API route endpoints (Next.js, Express, etc.) -export const ROUTE_SCHEMA = ` -CREATE NODE TABLE Route ( - id STRING, - name STRING, - filePath STRING, - responseKeys STRING[], - errorKeys STRING[], - middleware STRING[], - git_namespace STRING, - PRIMARY KEY (id) -)`; - -// MCP tool definitions -export const TOOL_SCHEMA = ` -CREATE NODE TABLE Tool ( - id STRING, - name STRING, - filePath STRING, - description STRING, - git_namespace STRING, - PRIMARY KEY (id) -)`; - -// Markdown heading sections -export const SECTION_SCHEMA = ` -CREATE NODE TABLE Section ( - id STRING, - name STRING, - filePath STRING, - startLine INT64, - endLine INT64, - level INT64, - content STRING, - description STRING, - nodeCategory STRING, - isPseudocode BOOLEAN, - rawContent STRING, - definedSymbols STRING[], - calledSymbols STRING[], - docType STRING, - domain STRING, - git_namespace STRING, - PRIMARY KEY (id) -)`; - -// ============================================================================ -// RELATION TABLE SCHEMA -// Single table with 'type' property - connects all node tables -// ============================================================================ - -export const RELATION_SCHEMA = ` -CREATE REL TABLE ${REL_TABLE_NAME} ( - FROM File TO File, - FROM File TO Folder, - FROM File TO Function, - FROM File TO Class, - FROM File TO Interface, - FROM File TO Method, - FROM File TO CodeElement, - FROM File TO \`Struct\`, - FROM File TO \`Enum\`, - FROM File TO \`Macro\`, - FROM File TO \`Typedef\`, - FROM File TO \`Union\`, - FROM File TO \`Namespace\`, - FROM File TO \`Trait\`, - FROM File TO \`Impl\`, - FROM File TO \`TypeAlias\`, - FROM File TO \`Const\`, - FROM File TO \`Static\`, - FROM File TO \`Property\`, - FROM File TO \`Record\`, - FROM File TO \`Delegate\`, - FROM File TO \`Annotation\`, - FROM File TO \`Constructor\`, - FROM File TO \`Template\`, - FROM File TO \`Module\`, - FROM File TO Section, - FROM Folder TO Folder, - FROM Folder TO File, - FROM Function TO Function, - FROM Function TO Method, - FROM Function TO Class, - FROM Function TO Community, - FROM Function TO \`Macro\`, - FROM Function TO \`Struct\`, - FROM Function TO \`Template\`, - FROM Function TO \`Enum\`, - FROM Function TO \`Namespace\`, - FROM Function TO \`TypeAlias\`, - FROM Function TO \`Module\`, - FROM Function TO \`Impl\`, - FROM Function TO Interface, - FROM Function TO \`Constructor\`, - FROM Function TO \`Const\`, - FROM Function TO \`Typedef\`, - FROM Function TO \`Union\`, - FROM Function TO \`Property\`, - FROM Function TO CodeElement, - FROM Class TO Method, - FROM Class TO Function, - FROM Class TO Class, - FROM Class TO Interface, - FROM Class TO Community, - FROM Class TO \`Template\`, - FROM Class TO \`TypeAlias\`, - FROM Class TO \`Struct\`, - FROM Class TO \`Enum\`, - FROM Class TO \`Annotation\`, - FROM Class TO \`Constructor\`, - FROM Class TO \`Trait\`, - FROM Class TO \`Macro\`, - FROM Class TO \`Impl\`, - FROM Class TO \`Union\`, - FROM Class TO \`Namespace\`, - FROM Class TO \`Typedef\`, - FROM Class TO \`Property\`, - FROM Method TO Function, - FROM Method TO Method, - FROM Method TO Class, - FROM Method TO Community, - FROM Method TO \`Template\`, - FROM Method TO \`Struct\`, - FROM Method TO \`TypeAlias\`, - FROM Method TO \`Enum\`, - FROM Method TO \`Macro\`, - FROM Method TO \`Namespace\`, - FROM Method TO \`Module\`, - FROM Method TO \`Impl\`, - FROM Method TO Interface, - FROM Method TO \`Constructor\`, - FROM Method TO \`Property\`, - FROM Method TO CodeElement, - FROM \`Template\` TO \`Template\`, - FROM \`Template\` TO Function, - FROM \`Template\` TO Method, - FROM \`Template\` TO Class, - FROM \`Template\` TO \`Struct\`, - FROM \`Template\` TO \`TypeAlias\`, - FROM \`Template\` TO \`Enum\`, - FROM \`Template\` TO \`Macro\`, - FROM \`Template\` TO Interface, - FROM \`Template\` TO \`Constructor\`, - FROM \`Module\` TO \`Module\`, - FROM Section TO Section, - FROM Section TO File, - FROM File TO Route, - FROM Function TO Route, - FROM Method TO Route, - FROM File TO Tool, - FROM Function TO Tool, - FROM Method TO Tool, - FROM CodeElement TO Community, - FROM Interface TO Community, - FROM Interface TO Function, - FROM Interface TO Method, - FROM Interface TO Class, - FROM Interface TO Interface, - FROM Interface TO \`TypeAlias\`, - FROM Interface TO \`Struct\`, - FROM Interface TO \`Constructor\`, - FROM Interface TO \`Property\`, - FROM \`Struct\` TO Community, - FROM \`Struct\` TO \`Trait\`, - FROM \`Struct\` TO \`Struct\`, - FROM \`Struct\` TO Class, - FROM \`Struct\` TO \`Enum\`, - FROM \`Struct\` TO Function, - FROM \`Struct\` TO Method, - FROM \`Struct\` TO Interface, - FROM \`Struct\` TO \`Constructor\`, - FROM \`Struct\` TO \`Property\`, - FROM \`Enum\` TO \`Enum\`, - FROM \`Enum\` TO Community, - FROM \`Enum\` TO Class, - FROM \`Enum\` TO Interface, - FROM \`Macro\` TO Community, - FROM \`Macro\` TO Function, - FROM \`Macro\` TO Method, - FROM \`Module\` TO Function, - FROM \`Module\` TO Method, - FROM \`Typedef\` TO Community, - FROM \`Union\` TO Community, - FROM \`Namespace\` TO Community, - FROM \`Namespace\` TO \`Struct\`, - FROM \`Trait\` TO Method, - FROM \`Trait\` TO \`Constructor\`, - FROM \`Trait\` TO \`Property\`, - FROM \`Trait\` TO Community, - FROM \`Impl\` TO Method, - FROM \`Impl\` TO \`Constructor\`, - FROM \`Impl\` TO \`Property\`, - FROM \`Impl\` TO Community, - FROM \`Impl\` TO \`Trait\`, - FROM \`Impl\` TO \`Struct\`, - FROM \`Impl\` TO \`Impl\`, - FROM \`TypeAlias\` TO Community, - FROM \`TypeAlias\` TO \`Trait\`, - FROM \`TypeAlias\` TO Class, - FROM \`Const\` TO Community, - FROM \`Static\` TO Community, - FROM \`Property\` TO Community, - FROM \`Record\` TO Method, - FROM \`Record\` TO \`Constructor\`, - FROM \`Record\` TO \`Property\`, - FROM \`Record\` TO Community, - FROM \`Delegate\` TO Community, - FROM \`Annotation\` TO Community, - FROM \`Constructor\` TO Community, - FROM \`Constructor\` TO Interface, - FROM \`Constructor\` TO Class, - FROM \`Constructor\` TO Method, - FROM \`Constructor\` TO Function, - FROM \`Constructor\` TO \`Constructor\`, - FROM \`Constructor\` TO \`Struct\`, - FROM \`Constructor\` TO \`Macro\`, - FROM \`Constructor\` TO \`Template\`, - FROM \`Constructor\` TO \`TypeAlias\`, - FROM \`Constructor\` TO \`Enum\`, - FROM \`Constructor\` TO \`Annotation\`, - FROM \`Constructor\` TO \`Impl\`, - FROM \`Constructor\` TO \`Namespace\`, - FROM \`Constructor\` TO \`Module\`, - FROM \`Constructor\` TO \`Property\`, - FROM \`Constructor\` TO \`Typedef\`, - FROM \`Template\` TO Community, - FROM \`Module\` TO Community, - FROM Function TO Process, - FROM Method TO Process, - FROM Class TO Process, - FROM Interface TO Process, - FROM \`Struct\` TO Process, - FROM \`Constructor\` TO Process, - FROM \`Module\` TO Process, - FROM \`Macro\` TO Process, - FROM \`Impl\` TO Process, - FROM \`Typedef\` TO Process, - FROM \`TypeAlias\` TO Process, - FROM \`Enum\` TO Process, - FROM \`Union\` TO Process, - FROM \`Namespace\` TO Process, - FROM \`Trait\` TO Process, - FROM \`Const\` TO Process, - FROM \`Static\` TO Process, - FROM \`Property\` TO Process, - FROM \`Record\` TO Process, - FROM \`Delegate\` TO Process, - FROM \`Annotation\` TO Process, - FROM \`Template\` TO Process, - FROM CodeElement TO Process, - FROM Route TO Process, - FROM Tool TO Process, - type STRING, - confidence DOUBLE, - reason STRING, - step INT32 -)`; - -// ============================================================================ -// EMBEDDING TABLE SCHEMA -// Separate table for vector storage to avoid copy-on-write overhead -// ============================================================================ - -/** Embedding vector dimensions. Default 384 (snowflake-arctic-embed-xs). */ -const _rawDims = parseInt(process.env.GITNEXUS_EMBEDDING_DIMS ?? '384', 10); -if (Number.isNaN(_rawDims) || _rawDims <= 0) { - throw new Error( - `GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${process.env.GITNEXUS_EMBEDDING_DIMS}"`, - ); -} -export const EMBEDDING_DIMS = _rawDims; - -export const EMBEDDING_SCHEMA = ` -CREATE NODE TABLE ${EMBEDDING_TABLE_NAME} ( - nodeId STRING, - embedding FLOAT[${EMBEDDING_DIMS}], - PRIMARY KEY (nodeId) -)`; - -/** - * Create vector index for semantic search - * Uses HNSW (Hierarchical Navigable Small World) algorithm with cosine similarity - */ -export const CREATE_VECTOR_INDEX_QUERY = ` -CALL CREATE_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', 'code_embedding_idx', 'embedding', metric := 'cosine') -`; - -// ============================================================================ -// ALL SCHEMA QUERIES IN ORDER -// Node tables must be created before relationship tables that reference them -// ============================================================================ - -export const NODE_SCHEMA_QUERIES = [ - FILE_SCHEMA, - FOLDER_SCHEMA, - FUNCTION_SCHEMA, - CLASS_SCHEMA, - INTERFACE_SCHEMA, - METHOD_SCHEMA, - CODE_ELEMENT_SCHEMA, - COMMUNITY_SCHEMA, - PROCESS_SCHEMA, - // Multi-language support - STRUCT_SCHEMA, - ENUM_SCHEMA, - MACRO_SCHEMA, - TYPEDEF_SCHEMA, - UNION_SCHEMA, - NAMESPACE_SCHEMA, - TRAIT_SCHEMA, - IMPL_SCHEMA, - TYPE_ALIAS_SCHEMA, - CONST_SCHEMA, - STATIC_SCHEMA, - PROPERTY_SCHEMA, - RECORD_SCHEMA, - DELEGATE_SCHEMA, - ANNOTATION_SCHEMA, - CONSTRUCTOR_SCHEMA, - TEMPLATE_SCHEMA, - MODULE_SCHEMA, - // Markdown support - SECTION_SCHEMA, - // API routes - ROUTE_SCHEMA, - // MCP tools - TOOL_SCHEMA, -]; - -export const REL_SCHEMA_QUERIES = [RELATION_SCHEMA]; - -export const SCHEMA_QUERIES = [...NODE_SCHEMA_QUERIES, ...REL_SCHEMA_QUERIES, EMBEDDING_SCHEMA]; diff --git a/gitnexus/src/mcp/local/local-backend.bak_batch4 b/gitnexus/src/mcp/local/local-backend.bak_batch4 deleted file mode 100644 index aa49feee0..000000000 --- a/gitnexus/src/mcp/local/local-backend.bak_batch4 +++ /dev/null @@ -1,3368 +0,0 @@ -/** - * Local Backend (Multi-Repo) - * - * Provides tool implementations using local .gitnexus/ indexes. - * Supports multiple indexed repositories via a global registry. - * LadybugDB connections are opened lazily per repo on first query. - */ - -import fs from 'fs/promises'; -import path from 'path'; -import { - initLbug, - executeQuery, - executeParameterized, - closeLbug, - isLbugReady, - isWriteQuery, -} from '../../core/lbug/pool-adapter.js'; -export { isWriteQuery }; -// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node -// at MCP server startup — crashes on unsupported Node ABI versions (#89) -// git utilities available if needed -// import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js'; -import { - listRegisteredRepos, - cleanupOldKuzuFiles, - type RegistryEntry, -} from '../../storage/repo-manager.js'; -import { GroupService, type GroupToolPort } from '../../core/group/service.js'; -// AI context generation is CLI-only (gitnexus analyze) -// import { generateAIContextFiles } from '../../cli/ai-context.js'; - -/** - * Quick test-file detection for filtering impact results. - * Matches common test file patterns across all supported languages. - */ -export function isTestFilePath(filePath: string): boolean { - const p = filePath.toLowerCase().replace(/\\/g, '/'); - return ( - p.includes('.test.') || - p.includes('.spec.') || - p.includes('__tests__/') || - p.includes('__mocks__/') || - p.includes('/test/') || - p.includes('/tests/') || - p.includes('/testing/') || - p.includes('/fixtures/') || - p.endsWith('_test.go') || - p.endsWith('_test.py') || - p.endsWith('_spec.rb') || - p.endsWith('_test.rb') || - p.includes('/spec/') || - p.includes('/test_') || - p.includes('/conftest.') - ); -} - -/** Valid LadybugDB node labels for safe Cypher query construction */ -export const VALID_NODE_LABELS = new Set([ - 'File', - 'Folder', - 'Function', - 'Class', - 'Interface', - 'Method', - 'CodeElement', - 'Community', - 'Process', - 'Struct', - 'Enum', - 'Macro', - 'Typedef', - 'Union', - 'Namespace', - 'Trait', - 'Impl', - 'TypeAlias', - 'Const', - 'Static', - 'Property', - 'Record', - 'Delegate', - 'Annotation', - 'Constructor', - 'Template', - 'Module', - 'Route', - 'Tool', -]); - -/** Valid relation types for impact analysis filtering */ -export const VALID_RELATION_TYPES = new Set([ - 'CALLS', - 'IMPORTS', - 'EXTENDS', - 'IMPLEMENTS', - 'HAS_METHOD', - 'HAS_PROPERTY', - 'OVERRIDES', - 'ACCESSES', - 'HANDLES_ROUTE', - 'FETCHES', - 'HANDLES_TOOL', - 'ENTRY_POINT_OF', - 'WRAPS', - 'CONTAINS', - 'DEFINES', -]); - -/** - * Per-relation-type confidence floor for impact analysis. - * - * When the graph stores a relation with a confidence value, that stored - * value is used as-is (it reflects resolution-tier accuracy from analysis - * time). This map provides the floor for each edge type when no stored - * confidence is available, and is also used for display / tooltip hints. - * - * Rationale: - * CALLS / IMPORTS – direct, strongly-typed references → 0.9 - * EXTENDS – class hierarchy, statically verifiable → 0.85 - * IMPLEMENTS – interface contract, statically verifiable → 0.85 - * OVERRIDES – method override, statically verifiable → 0.85 - * HAS_METHOD – structural containment → 0.95 - * HAS_PROPERTY – structural containment → 0.95 - * ACCESSES – field read/write, may be indirect → 0.8 - * CONTAINS – folder/file containment → 0.95 - * (unknown type) – conservative fallback → 0.5 - */ -export const IMPACT_RELATION_CONFIDENCE: Readonly> = { - CALLS: 0.9, - IMPORTS: 0.9, - EXTENDS: 0.85, - IMPLEMENTS: 0.85, - OVERRIDES: 0.85, - HAS_METHOD: 0.95, - HAS_PROPERTY: 0.95, - ACCESSES: 0.8, - CONTAINS: 0.95, -}; - -/** - * Return the confidence floor for a given relation type. - * Falls back to 0.5 for unknown types so they are not silently elevated. - */ -const confidenceForRelType = (relType: string | undefined): number => - IMPACT_RELATION_CONFIDENCE[relType ?? ''] ?? 0.5; - -/** Structured error logging for query failures — replaces empty catch blocks */ -function logQueryError(context: string, err: unknown): void { - const msg = err instanceof Error ? err.message : String(err); - console.error(`GitNexus [${context}]: ${msg}`); -} - -let warnedNsAvailability = false; - -/** - * Two-Tier Query Guard Pattern (Parameterized) - * Gracefully falls back if git_namespace is missing from schema - */ -async function executeWithNsGuard(repoId: string, query: string, params: Record = {}): Promise { - try { - return await executeParameterized(repoId, query, params); - } catch (e: any) { - if (String(e).includes('git_namespace')) { - if (!warnedNsAvailability) { - logQueryError('namespace:unavailable', 'git_namespace not in schema — re-index to enable'); - warnedNsAvailability = true; - } - const safeQuery = query.replace(/,\s*[a-zA-Z0-9_]+\.git_namespace\s+AS\s+git_namespace/g, ''); - return await executeParameterized(repoId, safeQuery, params); - } - throw e; - } -} - -/** - * Two-Tier Query Guard Pattern (Direct Execution) - * Gracefully falls back if git_namespace is missing from schema - */ -async function executeQueryWithNsGuard(repoId: string, query: string): Promise { - try { - return await executeQuery(repoId, query); - } catch (e: any) { - if (String(e).includes('git_namespace')) { - if (!warnedNsAvailability) { - logQueryError('namespace:unavailable', 'git_namespace not in schema — re-index to enable'); - warnedNsAvailability = true; - } - const safeQuery = query.replace(/,\s*[a-zA-Z0-9_]+\.git_namespace\s+AS\s+git_namespace/g, ''); - return await executeQuery(repoId, safeQuery); - } - throw e; - } -} - -export interface CodebaseContext { - projectName: string; - stats: { - fileCount: number; - functionCount: number; - communityCount: number; - processCount: number; - }; -} - -interface RepoHandle { - id: string; // unique key = repo name (basename) - name: string; - repoPath: string; - storagePath: string; - lbugPath: string; - indexedAt: string; - lastCommit: string; - stats?: RegistryEntry['stats']; -} - -export class LocalBackend { - private repos: Map = new Map(); - private contextCache: Map = new Map(); - private initializedRepos: Set = new Set(); - private reinitPromises: Map> = new Map(); - private lastStalenessCheck: Map = new Map(); - private groupToolSvc: GroupService | null = null; - - /** - * Cross-repo group tools (CLI). Shares logic with MCP `group_*` handlers. - */ - getGroupService(): GroupService { - if (!this.groupToolSvc) { - const port: GroupToolPort = { - resolveRepo: (p) => this.resolveRepo(p), - impact: (r, p) => this.impact(r as RepoHandle, p), - query: (r, p) => this.query(r as RepoHandle, p), - impactByUid: (id, uid, d, o) => this.impactByUid(id, uid, d, o), - }; - this.groupToolSvc = new GroupService(port); - } - return this.groupToolSvc; - } - - /** Close all pooled LadybugDB connections (CLI one-shot; optional for long-lived MCP). */ - async dispose(): Promise { - await closeLbug(); - } - - // ─── Initialization ────────────────────────────────────────────── - - /** - * Initialize from the global registry. - * Returns true if at least one repo is available. - */ - async init(): Promise { - await this.refreshRepos(); - return this.repos.size > 0; - } - - /** - * Re-read the global registry and update the in-memory repo map. - * New repos are added, existing repos are updated, removed repos are pruned. - * LadybugDB connections for removed repos are NOT closed (they idle-timeout naturally). - */ - private async refreshRepos(): Promise { - const entries = await listRegisteredRepos({ validate: true }); - const freshIds = new Set(); - - for (const entry of entries) { - const id = this.repoId(entry.name, entry.path); - freshIds.add(id); - - const storagePath = entry.storagePath; - const lbugPath = path.join(storagePath, 'lbug'); - - // Clean up any leftover KuzuDB files from before the LadybugDB migration. - // If kuzu exists but lbug doesn't, warn so the user knows to re-analyze. - const kuzu = await cleanupOldKuzuFiles(storagePath); - if (kuzu.found && kuzu.needsReindex) { - console.error( - `GitNexus: "${entry.name}" has a stale KuzuDB index. Run: gitnexus analyze ${entry.path}`, - ); - } - - const handle: RepoHandle = { - id, - name: entry.name, - repoPath: entry.path, - storagePath, - lbugPath, - indexedAt: entry.indexedAt, - lastCommit: entry.lastCommit, - stats: entry.stats, - }; - - this.repos.set(id, handle); - - // Build lightweight context (no LadybugDB needed) - const s = entry.stats || {}; - this.contextCache.set(id, { - projectName: entry.name, - stats: { - fileCount: s.files || 0, - functionCount: s.nodes || 0, - communityCount: s.communities || 0, - processCount: s.processes || 0, - }, - }); - } - - // Prune repos that no longer exist in the registry - for (const id of this.repos.keys()) { - if (!freshIds.has(id)) { - this.repos.delete(id); - this.contextCache.delete(id); - this.initializedRepos.delete(id); - } - } - } - - /** - * Generate a stable repo ID from name + path. - * If names collide, append a hash of the path. - */ - private repoId(name: string, repoPath: string): string { - const base = name.toLowerCase(); - // Check for name collision with a different path - for (const [id, handle] of this.repos) { - if (id === base && handle.repoPath !== path.resolve(repoPath)) { - // Collision — use path hash - const hash = Buffer.from(repoPath).toString('base64url').slice(0, 6); - return `${base}-${hash}`; - } - } - return base; - } - - // ─── Repo Resolution ───────────────────────────────────────────── - - /** - * Resolve which repo to use. - * - If repoParam is given, match by name or path - * - If only 1 repo, use it - * - If 0 or multiple without param, throw with helpful message - * - * On a miss, re-reads the registry once in case a new repo was indexed - * while the MCP server was running. - */ - async resolveRepo(repoParam?: string): Promise { - const result = this.resolveRepoFromCache(repoParam); - if (result) return result; - - // Miss — refresh registry and try once more - await this.refreshRepos(); - const retried = this.resolveRepoFromCache(repoParam); - if (retried) return retried; - - // Still no match — throw with helpful message - if (this.repos.size === 0) { - throw new Error('No indexed repositories. Run: gitnexus analyze'); - } - if (repoParam) { - const names = [...this.repos.values()].map((h) => h.name); - throw new Error(`Repository "${repoParam}" not found. Available: ${names.join(', ')}`); - } - const names = [...this.repos.values()].map((h) => h.name); - throw new Error( - `Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${names.join(', ')}`, - ); - } - - /** - * Try to resolve a repo from the in-memory cache. Returns null on miss. - */ - private resolveRepoFromCache(repoParam?: string): RepoHandle | null { - if (this.repos.size === 0) return null; - - if (repoParam) { - const paramLower = repoParam.toLowerCase(); - // Match by id - if (this.repos.has(paramLower)) return this.repos.get(paramLower)!; - // Match by name (case-insensitive) - for (const handle of this.repos.values()) { - if (handle.name.toLowerCase() === paramLower) return handle; - } - // Match by path (substring) - const resolved = path.resolve(repoParam); - for (const handle of this.repos.values()) { - if (handle.repoPath === resolved) return handle; - } - // Match by partial name - for (const handle of this.repos.values()) { - if (handle.name.toLowerCase().includes(paramLower)) return handle; - } - return null; - } - - if (this.repos.size === 1) { - return this.repos.values().next().value!; - } - - return null; // Multiple repos, no param — ambiguous - } - - // ─── Lazy LadybugDB Init ──────────────────────────────────────────── - - private async ensureInitialized(repoId: string): Promise { - // If a reinit is already in progress for this repo, wait for it - const pending = this.reinitPromises.get(repoId); - if (pending) return pending; - - const handle = this.repos.get(repoId); - if (!handle) throw new Error(`Unknown repo: ${repoId}`); - - // Check if the index was rebuilt since we opened the connection (#297). - // Throttle staleness checks to at most once per 5 seconds per repo to - // avoid an fs.readFile round-trip on every tool invocation. - if (this.initializedRepos.has(repoId) && isLbugReady(repoId)) { - const now = Date.now(); - const lastCheck = this.lastStalenessCheck.get(repoId) ?? 0; - if (now - lastCheck < 5000) return; // Checked recently — skip - - this.lastStalenessCheck.set(repoId, now); - try { - const metaPath = path.join(handle.storagePath, 'meta.json'); - const metaRaw = await fs.readFile(metaPath, 'utf-8'); - const meta = JSON.parse(metaRaw); - if (meta.indexedAt && meta.indexedAt !== handle.indexedAt) { - // Index was rebuilt — close stale connection and re-init. - // Wrap in reinitPromises to prevent TOCTOU race where concurrent - // callers both detect staleness and double-close the pool. - const reinit = (async () => { - try { - await closeLbug(repoId); - this.initializedRepos.delete(repoId); - handle.indexedAt = meta.indexedAt; - await initLbug(repoId, handle.lbugPath); - this.initializedRepos.add(repoId); - } finally { - this.reinitPromises.delete(repoId); - } - })(); - this.reinitPromises.set(repoId, reinit); - return reinit; - } else { - return; // Pool is current - } - } catch { - return; // Can't read meta — assume pool is fine - } - } - - try { - await initLbug(repoId, handle.lbugPath); - this.initializedRepos.add(repoId); - } catch (err: any) { - // If lock error, mark as not initialized so next call retries - this.initializedRepos.delete(repoId); - throw err; - } - } - - // ─── Public Getters ────────────────────────────────────────────── - - /** - * Get context for a specific repo (or the single repo if only one). - */ - getContext(repoId?: string): CodebaseContext | null { - if (repoId && this.contextCache.has(repoId)) { - return this.contextCache.get(repoId)!; - } - if (this.repos.size === 1) { - return this.contextCache.values().next().value ?? null; - } - return null; - } - - /** - * List all registered repos with their metadata. - * Re-reads the global registry so newly indexed repos are discovered - * without restarting the MCP server. - */ - async listRepos(): Promise< - Array<{ name: string; path: string; indexedAt: string; lastCommit: string; stats?: any }> - > { - await this.refreshRepos(); - return [...this.repos.values()].map((h) => ({ - name: h.name, - path: h.repoPath, - indexedAt: h.indexedAt, - lastCommit: h.lastCommit, - stats: h.stats, - })); - } - - // ─── Tool Dispatch ─────────────────────────────────────────────── - - async callTool(method: string, params: any): Promise { - if (method === 'list_repos') { - return this.listRepos(); - } - - if (method.startsWith('group_')) { - return this.handleGroupTool(method, params || {}); - } - - // Resolve repo from optional param (re-reads registry on miss) - const repo = await this.resolveRepo(params?.repo); - - switch (method) { - case 'query': - return this.query(repo, params); - case 'cypher': { - const raw = await this.cypher(repo, params); - return this.formatCypherAsMarkdown(raw); - } - case 'context': - return this.context(repo, params); - case 'impact': - return this.impact(repo, params); - case 'detect_changes': - return this.detectChanges(repo, params); - case 'rename': - return this.rename(repo, params); - // Legacy aliases for backwards compatibility - case 'search': - return this.query(repo, params); - case 'explore': - return this.context(repo, { name: params?.name, ...params }); - case 'overview': - return this.overview(repo, params); - case 'route_map': - return this.routeMap(repo, params); - case 'shape_check': - return this.shapeCheck(repo, params); - case 'tool_map': - return this.toolMap(repo, params); - case 'api_impact': - return this.apiImpact(repo, params); - default: - throw new Error(`Unknown tool: ${method}`); - } - } - - // ─── Tool Implementations ──────────────────────────────────────── - - /** - * Query tool — process-grouped search. - * - * 1. Hybrid search (BM25 + semantic) to find matching symbols - * 2. Trace each match to its process(es) via STEP_IN_PROCESS - * 3. Group by process, rank by aggregate relevance + internal cluster cohesion - * 4. Return: { processes, process_symbols, definitions } - */ - private async query( - repo: RepoHandle, - params: { - query: string; - task_context?: string; - goal?: string; - limit?: number; - max_symbols?: number; - include_content?: boolean; - git_namespace?: string; - }, - ): Promise { - if (!params.query?.trim()) { - return { error: 'query parameter is required and cannot be empty.' }; - } - - await this.ensureInitialized(repo.id); - - const processLimit = params.limit || 5; - const maxSymbolsPerProcess = params.max_symbols || 10; - const includeContent = params.include_content ?? false; - const searchQuery = params.query.trim(); - - const gitNamespace = params.git_namespace; - - // Step 1: Run hybrid search to get matching symbols - const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results - const [bm25SearchResult, semanticResults] = await Promise.all([ - this.bm25Search(repo, searchQuery, searchLimit, gitNamespace), - this.semanticSearch(repo, searchQuery, searchLimit, gitNamespace), - ]); - - const bm25Results = bm25SearchResult.results; - const ftsUsed = bm25SearchResult.ftsUsed; - - // Merge via reciprocal rank fusion - const scoreMap = new Map(); - - for (let i = 0; i < bm25Results.length; i++) { - const result = bm25Results[i]; - const key = result.nodeId || result.filePath; - const rrfScore = 1 / (60 + i); - const existing = scoreMap.get(key); - if (existing) { - existing.score += rrfScore; - } else { - scoreMap.set(key, { score: rrfScore, data: result }); - } - } - - for (let i = 0; i < semanticResults.length; i++) { - const result = semanticResults[i]; - const key = result.nodeId || result.filePath; - const rrfScore = 1 / (60 + i); - const existing = scoreMap.get(key); - if (existing) { - existing.score += rrfScore; - } else { - scoreMap.set(key, { score: rrfScore, data: result }); - } - } - - const merged = Array.from(scoreMap.entries()) - .sort((a, b) => b[1].score - a[1].score) - .slice(0, searchLimit); - - // Step 2: For each match with a nodeId, trace to process(es) - const processMap = new Map< - string, - { - id: string; - label: string; - heuristicLabel: string; - processType: string; - stepCount: number; - totalScore: number; - cohesionBoost: number; - symbols: any[]; - } - >(); - const definitions: any[] = []; // standalone symbols not in any process - - for (const [_, item] of merged) { - const sym = item.data; - if (!sym.nodeId) { - // File-level results go to definitions - definitions.push({ - name: sym.name, - type: sym.type || 'File', - filePath: sym.filePath, - }); - continue; - } - - // Find processes this symbol participates in - let processRows: any[] = []; - try { - processRows = await executeParameterized( - repo.id, - ` - MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - RETURN p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step - `, - { nodeId: sym.nodeId }, - ); - } catch (e) { - logQueryError('query:process-lookup', e); - } - - // Get cluster membership + cohesion (cohesion used as internal ranking signal) - let cohesion = 0; - let module: string | undefined; - try { - const cohesionRows = await executeParameterized( - repo.id, - ` - MATCH (n {id: $nodeId})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - RETURN c.cohesion AS cohesion, c.heuristicLabel AS module - LIMIT 1 - `, - { nodeId: sym.nodeId }, - ); - if (cohesionRows.length > 0) { - cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0; - module = cohesionRows[0].module ?? cohesionRows[0][1]; - } - } catch (e) { - logQueryError('query:cluster-info', e); - } - - // Optionally fetch content - let content: string | undefined; - if (includeContent) { - try { - const contentRows = await executeParameterized( - repo.id, - ` - MATCH (n {id: $nodeId}) - RETURN n.content AS content - `, - { nodeId: sym.nodeId }, - ); - if (contentRows.length > 0) { - content = contentRows[0].content ?? contentRows[0][0]; - } - } catch (e) { - logQueryError('query:content-fetch', e); - } - } - - const symbolEntry = { - id: sym.nodeId, - name: sym.name, - type: sym.type, - filePath: sym.filePath, - startLine: sym.startLine, - endLine: sym.endLine, - ...(module ? { module } : {}), - ...(includeContent && content ? { content } : {}), - }; - - if (processRows.length === 0) { - // Symbol not in any process — goes to definitions - definitions.push(symbolEntry); - } else { - // Add to each process it belongs to - for (const row of processRows) { - const pid = row.pid ?? row[0]; - const label = row.label ?? row[1]; - const hLabel = row.heuristicLabel ?? row[2]; - const pType = row.processType ?? row[3]; - const stepCount = row.stepCount ?? row[4]; - const step = row.step ?? row[5]; - - if (!processMap.has(pid)) { - processMap.set(pid, { - id: pid, - label, - heuristicLabel: hLabel, - processType: pType, - stepCount, - totalScore: 0, - cohesionBoost: 0, - symbols: [], - }); - } - - const proc = processMap.get(pid)!; - proc.totalScore += item.score; - proc.cohesionBoost = Math.max(proc.cohesionBoost, cohesion); - proc.symbols.push({ - ...symbolEntry, - process_id: pid, - step_index: step, - }); - } - } - } - - // Step 3: Rank processes by aggregate score + internal cohesion boost - const rankedProcesses = Array.from(processMap.values()) - .map((p) => ({ - ...p, - priority: p.totalScore + p.cohesionBoost * 0.1, // cohesion as subtle ranking signal - })) - .sort((a, b) => b.priority - a.priority) - .slice(0, processLimit); - - // Step 4: Build response - const processes = rankedProcesses.map((p) => ({ - id: p.id, - summary: p.heuristicLabel || p.label, - priority: Math.round(p.priority * 1000) / 1000, - symbol_count: p.symbols.length, - process_type: p.processType, - step_count: p.stepCount, - })); - - const processSymbols = rankedProcesses.flatMap((p) => - p.symbols.slice(0, maxSymbolsPerProcess).map((s) => ({ - ...s, - // remove internal fields - })), - ); - - // Deduplicate process_symbols by id - const seen = new Set(); - const dedupedSymbols = processSymbols.filter((s) => { - if (seen.has(s.id)) return false; - seen.add(s.id); - return true; - }); - - // Build namespace_hint when git_namespace is NOT specified and results span multiple namespaces - let namespace_hint = null; - if (!gitNamespace) { - try { - // Collect git_namespace from all result symbols - const idListArr = [ - ...dedupedSymbols.filter((s: any) => s.id).map((s: any) => s.id), - ...definitions.filter((d: any) => d.id).map((d: any) => d.id) - ]; - const uniqueNodeIds = Array.from(new Set(idListArr)); - if (uniqueNodeIds.length > 0) { - const idList = uniqueNodeIds.map((id: string) => `'${id.replace(/'/g, "''")}'`).join(', '); - const nsRows = await executeQuery( - repo.id, - `MATCH (n) WHERE n.id IN [${idList}] RETURN DISTINCT n.git_namespace AS ns`, - ); - const uniqueNs = new Set(); - for (const row of nsRows) { - const ns = row.ns ?? row[0]; - if (ns) uniqueNs.add(ns); - } - if (uniqueNs.size > 1) { - // Fetch all available namespaces from graph - const allNsRows = await executeQuery( - repo.id, - `MATCH (n) WHERE n.git_namespace IS NOT NULL RETURN DISTINCT n.git_namespace AS ns, COUNT(*) AS cnt`, - ); - const resultsByNamespace: Record = {}; - for (const ns of uniqueNs) { - const allItems = [...dedupedSymbols, ...definitions]; - resultsByNamespace[ns] = allItems.filter( - (s: any) => bm25Results.some((b: any) => b.filePath === s.filePath) || - semanticResults.some((sr: any) => sr.nodeId === s.id) - ).length; - } - // If length is 0 (due to some disjoint between the graph and node IDs), provide a fallback - if (Object.values(resultsByNamespace).some(v => v === 0)) { - uniqueNs.forEach(ns => { - if (resultsByNamespace[ns] === 0) resultsByNamespace[ns] = 1; - }); - } - namespace_hint = { - warning: `Results span ${uniqueNs.size} git-namespaces. Narrow with git_namespace parameter for precise results.`, - available_namespaces: allNsRows.map((row: any) => ({ - name: row.ns ?? row[0], - node_count: row.cnt ?? row[1] ?? 0, - })), - results_by_namespace: resultsByNamespace, - }; - } - } - } catch { - // Namespace hint is best-effort — do not fail the query - } - } - - return { - processes, - process_symbols: dedupedSymbols, - definitions: definitions.slice(0, 20), // cap standalone definitions - ...(!ftsUsed && { - warning: - 'FTS extension unavailable - keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.', - }), - ...(namespace_hint && { namespace_hint }), - }; - } - - /** - * BM25 keyword search helper - uses LadybugDB FTS for always-fresh results - */ - private async bm25Search( - repo: RepoHandle, - query: string, - limit: number, - gitNamespace?: string, - ): Promise<{ results: any[]; ftsUsed: boolean }> { - const { searchFTSFromLbug } = await import('../../core/search/bm25-index.js'); - let bm25Results; - try { - bm25Results = await searchFTSFromLbug(query, limit, repo.id, gitNamespace); - } catch (err: any) { - console.error('GitNexus: BM25/FTS search failed (FTS indexes may not exist) -', err.message); - return { results: [], ftsUsed: false }; - } - - const ftsUsed = bm25Results.length === 0 || bm25Results[0]?.ftsUsed !== false; - - const results: any[] = []; - - for (const bm25Result of bm25Results) { - const fullPath = bm25Result.filePath; - try { - const symbols = await executeWithNsGuard( - repo.id, - ` - MATCH (n) - WHERE n.filePath = $filePath - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.git_namespace AS git_namespace - LIMIT 3 - `, - { filePath: fullPath }, - ); - - if (symbols.length > 0) { - for (const sym of symbols) { - results.push({ - nodeId: sym.id || sym[0], - name: sym.name || sym[1], - type: sym.type || sym[2], - filePath: sym.filePath || sym[3], - startLine: sym.startLine || sym[4], - endLine: sym.endLine || sym[5], - git_namespace: sym.git_namespace || sym[6] || null, - bm25Score: bm25Result.score, - }); - } - } else { - const fileName = fullPath.split('/').pop() || fullPath; - results.push({ - name: fileName, - type: 'File', - filePath: bm25Result.filePath, - bm25Score: bm25Result.score, - }); - } - } catch { - const fileName = fullPath.split('/').pop() || fullPath; - results.push({ - name: fileName, - type: 'File', - filePath: bm25Result.filePath, - bm25Score: bm25Result.score, - }); - } - } - - return { results, ftsUsed }; - } - - /** - * Semantic vector search helper - */ - private async semanticSearch(repo: RepoHandle, query: string, limit: number, gitNamespace?: string): Promise { - try { - // Check if embedding table exists before loading the model (avoids heavy model init when embeddings are off) - const tableCheck = await executeQuery( - repo.id, - `MATCH (e:CodeEmbedding) RETURN COUNT(*) AS cnt LIMIT 1`, - ); - if (!tableCheck.length || (tableCheck[0].cnt ?? tableCheck[0][0]) === 0) return []; - - const { embedQuery, getEmbeddingDims } = await import('../core/embedder.js'); - const queryVec = await embedQuery(query); - const dims = getEmbeddingDims(); - const queryVecStr = `[${queryVec.join(',')}]`; - // Over-fetch when filtering by namespace - const fetchLimit = gitNamespace ? limit * 3 : limit; - - const vectorQuery = ` - CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', - CAST(${queryVecStr} AS FLOAT[${dims}]), ${fetchLimit}) - YIELD node AS emb, distance - WITH emb, distance - WHERE distance < 0.6 - RETURN emb.nodeId AS nodeId, distance - ORDER BY distance - `; - - const embResults = await executeQuery(repo.id, vectorQuery); - - if (embResults.length === 0) return []; - - const results: any[] = []; - - for (const embRow of embResults) { - const nodeId = embRow.nodeId ?? embRow[0]; - const distance = embRow.distance ?? embRow[1]; - - const labelEndIdx = nodeId.indexOf(':'); - const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown'; - - // Validate label against known node types to prevent Cypher injection - if (!VALID_NODE_LABELS.has(label)) continue; - - try { - const nodeQuery = - label === 'File' - ? `MATCH (n:File {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath, n.git_namespace AS git_namespace` - : `MATCH (n:\`${label}\` {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.git_namespace AS git_namespace`; - - const nodeRows = await executeWithNsGuard(repo.id, nodeQuery, { nodeId }); - if (nodeRows.length > 0) { - const nodeRow = nodeRows[0]; - // Post-filter by git_namespace if specified - const nodeNs = nodeRow.git_namespace ?? (label === 'File' ? nodeRow[2] : nodeRow[4]) ?? ''; - if (gitNamespace && nodeNs !== gitNamespace) continue; - - results.push({ - nodeId, - name: nodeRow.name ?? nodeRow[0] ?? '', - type: label, - filePath: nodeRow.filePath ?? nodeRow[1] ?? '', - distance, - startLine: label !== 'File' ? (nodeRow.startLine ?? nodeRow[2]) : undefined, - endLine: label !== 'File' ? (nodeRow.endLine ?? nodeRow[3]) : undefined, - }); - } - } catch {} - } - - // Trim to requested limit after filtering - return results.slice(0, limit); - } catch { - // Expected when embeddings are disabled — silently fall back to BM25-only - return []; - } - } - - async executeCypher(repoName: string, query: string): Promise { - const repo = await this.resolveRepo(repoName); - return this.cypher(repo, { query }); - } - - private async cypher(repo: RepoHandle, params: { query: string }): Promise { - await this.ensureInitialized(repo.id); - - if (!isLbugReady(repo.id)) { - return { error: 'LadybugDB not ready. Index may be corrupted.' }; - } - - // Block write operations (defense-in-depth — DB is already read-only) - if (isWriteQuery(params.query)) { - return { - error: - 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.', - }; - } - - try { - const result = await executeQuery(repo.id, params.query); - return result; - } catch (err: any) { - return { error: err.message || 'Query failed' }; - } - } - - /** - * Format raw Cypher result rows as a markdown table for LLM readability. - * Falls back to raw result if rows aren't tabular objects. - */ - private formatCypherAsMarkdown(result: any): any { - if (!Array.isArray(result) || result.length === 0) return result; - - const firstRow = result[0]; - if (typeof firstRow !== 'object' || firstRow === null) return result; - - const keys = Object.keys(firstRow); - if (keys.length === 0) return result; - - const header = '| ' + keys.join(' | ') + ' |'; - const separator = '| ' + keys.map(() => '---').join(' | ') + ' |'; - const dataRows = result.map( - (row: any) => - '| ' + - keys - .map((k) => { - const v = row[k]; - if (v === null || v === undefined) return ''; - if (typeof v === 'object') return JSON.stringify(v); - return String(v); - }) - .join(' | ') + - ' |', - ); - - return { - markdown: [header, separator, ...dataRows].join('\n'), - row_count: result.length, - }; - } - - /** - * Aggregate same-named clusters: group by heuristicLabel, sum symbols, - * weighted-average cohesion, filter out tiny clusters (<5 symbols). - * Raw communities stay intact in LadybugDB for Cypher queries. - */ - private aggregateClusters(clusters: any[]): any[] { - const groups = new Map< - string, - { ids: string[]; totalSymbols: number; weightedCohesion: number; largest: any } - >(); - - for (const c of clusters) { - const label = c.heuristicLabel || c.label || 'Unknown'; - const symbols = c.symbolCount || 0; - const cohesion = c.cohesion || 0; - const existing = groups.get(label); - - if (!existing) { - groups.set(label, { - ids: [c.id], - totalSymbols: symbols, - weightedCohesion: cohesion * symbols, - largest: c, - }); - } else { - existing.ids.push(c.id); - existing.totalSymbols += symbols; - existing.weightedCohesion += cohesion * symbols; - if (symbols > (existing.largest.symbolCount || 0)) { - existing.largest = c; - } - } - } - - return Array.from(groups.entries()) - .map(([label, g]) => ({ - id: g.largest.id, - label, - heuristicLabel: label, - symbolCount: g.totalSymbols, - cohesion: g.totalSymbols > 0 ? g.weightedCohesion / g.totalSymbols : 0, - subCommunities: g.ids.length, - })) - .filter((c) => c.symbolCount >= 5) - .sort((a, b) => b.symbolCount - a.symbolCount); - } - - private async overview( - repo: RepoHandle, - params: { showClusters?: boolean; showProcesses?: boolean; limit?: number }, - ): Promise { - await this.ensureInitialized(repo.id); - - const limit = params.limit || 20; - const result: any = { - repo: repo.name, - repoPath: repo.repoPath, - stats: repo.stats, - indexedAt: repo.indexedAt, - lastCommit: repo.lastCommit, - }; - - if (params.showClusters !== false) { - try { - // Fetch more raw communities than the display limit so aggregation has enough data - const rawLimit = Math.max(limit * 5, 200); - const clusters = await executeQuery( - repo.id, - ` - MATCH (c:Community) - RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount - ORDER BY c.symbolCount DESC - LIMIT ${rawLimit} - `, - ); - const rawClusters = clusters.map((c: any) => ({ - id: c.id || c[0], - label: c.label || c[1], - heuristicLabel: c.heuristicLabel || c[2], - cohesion: c.cohesion || c[3], - symbolCount: c.symbolCount || c[4], - })); - result.clusters = this.aggregateClusters(rawClusters).slice(0, limit); - } catch { - result.clusters = []; - } - } - - if (params.showProcesses !== false) { - try { - const processes = await executeQuery( - repo.id, - ` - MATCH (p:Process) - RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount - ORDER BY p.stepCount DESC - LIMIT ${limit} - `, - ); - result.processes = processes.map((p: any) => ({ - id: p.id || p[0], - label: p.label || p[1], - heuristicLabel: p.heuristicLabel || p[2], - processType: p.processType || p[3], - stepCount: p.stepCount || p[4], - })); - } catch { - result.processes = []; - } - } - - return result; - } - - /** - * Context tool — 360-degree symbol view with categorized refs. - * Disambiguation when multiple symbols share a name. - * UID-based direct lookup. No cluster in output. - */ - private async context( - repo: RepoHandle, - params: { - name?: string; - uid?: string; - file_path?: string; - include_content?: boolean; - }, - ): Promise { - await this.ensureInitialized(repo.id); - - const { name, uid, file_path, include_content } = params; - - if (!name && !uid) { - return { error: 'Either "name" or "uid" parameter is required.' }; - } - - // Step 1: Find the symbol - let symbols: any[]; - - if (uid) { - symbols = await executeWithNsGuard( - repo.id, - ` - MATCH (n {id: $uid}) - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}, n.git_namespace AS git_namespace - LIMIT 1 - `, - { uid }, - ); - } else { - const isQualified = name!.includes('/') || name!.includes(':'); - - let whereClause: string; - let queryParams: Record; - if (file_path) { - whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`; - queryParams = { symName: name!, filePath: file_path }; - } else if (isQualified) { - whereClause = `WHERE n.id = $symName OR n.name = $symName`; - queryParams = { symName: name! }; - } else { - whereClause = `WHERE n.name = $symName`; - queryParams = { symName: name! }; - } - - symbols = await executeWithNsGuard( - repo.id, - ` - MATCH (n) ${whereClause} - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}, n.git_namespace AS git_namespace - LIMIT 10 - `, - queryParams, - ); - } - - if (symbols.length === 0) { - return { error: `Symbol '${name || uid}' not found` }; - } - - // Step 2: Disambiguation - // When multiple nodes share the same name (e.g. a Java Class and its - // Constructor both named 'SessionTracker'), prefer the Class node so - // context() returns the semantically meaningful result rather than - // triggering ambiguous disambiguation (#480). - // labels(n)[0] returns empty string in LadybugDB, so we resolve the - // preferred node by re-querying with explicit label filters, scoped to - // the candidate IDs already in symbols. - // - // Guard: only attempt Class-preference when at least one candidate has an - // empty/unknown type (LadybugDB limitation) or is a Constructor — meaning - // the ambiguity may be a Class/Constructor name collision rather than two - // genuinely distinct symbols (e.g. two Functions in different files). - // - // resolvedLabel is set here and threaded to Step 3 to avoid a redundant - // classCheck round-trip later. - let resolvedLabel = ''; - if (symbols.length > 1 && !uid) { - const hasAmbiguousType = symbols.some((s: any) => { - const t = s.type || s[2] || ''; - return t === '' || t === 'Constructor'; - }); - if (hasAmbiguousType) { - const candidateIds = symbols.map((s: any) => s.id || s[0]).filter(Boolean); - const PREFER_LABELS = ['Class', 'Interface']; - let preferred: any = null; - for (const label of PREFER_LABELS) { - const match = await executeParameterized( - repo.id, - ` - MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id LIMIT 1 - `, - { candidateIds }, - ).catch(() => []); - if (match.length > 0) { - preferred = symbols.find((s: any) => (s.id || s[0]) === (match[0].id || match[0][0])); - if (preferred) { - resolvedLabel = label; - break; - } - } - } - if (preferred) symbols = [preferred]; - } - } - - if (symbols.length > 1 && !uid) { - return { - status: 'ambiguous', - message: `Found ${symbols.length} symbols matching '${name}'. Use uid or file_path to disambiguate.`, - candidates: symbols.map((s: any) => ({ - uid: s.id || s[0], - name: s.name || s[1], - kind: s.type || s[2], - filePath: s.filePath || s[3], - line: s.startLine || s[4], - git_namespace: s.git_namespace || s[7] || null, - })), - }; - } - - // Step 3: Build full context - const sym = symbols[0]; - const symId = sym.id || sym[0]; - - // Categorized incoming refs - const incomingRows = await executeParameterized( - repo.id, - ` - MATCH (caller)-[r:CodeRelation]->(n {id: $symId}) - WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES'] - RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind - LIMIT 30 - `, - { symId }, - ); - - // Structural refs for document nodes (Section) and Pseudocode (CodeElement) - // Only fetched for documentation components to avoid noise on regular code symbols - const symRawType = sym.type || sym[2] || ''; - let structuralIncoming: any[] = []; - let isDocNodeCheck = false; - - if (symRawType === 'Section' || symRawType === 'CodeElement' || symRawType === '') { - try { - isDocNodeCheck = (symRawType === 'Section' || symRawType === 'CodeElement') ? true : - (await executeParameterized(repo.id, - `MATCH (n) WHERE (n:Section OR n:CodeElement) AND n.id = $symId RETURN n.id LIMIT 1`, - { symId } - ).catch(() => [])).length > 0; - - if (isDocNodeCheck) { - structuralIncoming = await executeParameterized( - repo.id, - `MATCH (parent)-[r:CodeRelation]->(n {id: $symId}) - WHERE r.type IN ['CONTAINS', 'DEFINES'] - RETURN r.type AS relType, parent.id AS uid, parent.name AS name, - parent.filePath AS filePath, labels(parent)[0] AS kind - LIMIT 10`, - { symId } - ); - } - } catch (e) { - logQueryError('context:structural-incoming', e); - } - } - - // Fix #480: Class/Interface nodes have no direct CALLS/IMPORTS edges — - // those point to Constructor and File nodes respectively. Fetch those - // extra incoming refs and merge them in so context() shows real callers. - // - // Determine if this is a Class/Interface node. If resolvedLabel was set - // during disambiguation (Step 2), use it directly — no extra round-trip. - // Otherwise fall back to a single label check only when the type field is - // empty (LadybugDB labels(n)[0] limitation). - let isClassLike = resolvedLabel === 'Class' || resolvedLabel === 'Interface'; - if (!isClassLike && symRawType === '') { - try { - // Single UNION query instead of two serial round-trips. - const typeCheck = await executeParameterized( - repo.id, - ` - MATCH (n:Class) WHERE n.id = $symId RETURN 'Class' AS label LIMIT 1 - UNION ALL - MATCH (n:Interface) WHERE n.id = $symId RETURN 'Interface' AS label LIMIT 1 - `, - { symId }, - ); - isClassLike = typeCheck.length > 0; - } catch { - /* not a Class/Interface node */ - } - } else if (!isClassLike) { - isClassLike = symRawType === 'Class' || symRawType === 'Interface'; - } - - if (isClassLike) { - try { - // Run both incoming-ref queries in parallel — they are independent. - const [ctorIncoming, fileIncoming] = await Promise.all([ - executeParameterized( - repo.id, - ` - MATCH (n)-[hm:CodeRelation]->(ctor:Constructor) - WHERE n.id = $symId AND hm.type = 'HAS_METHOD' - MATCH (caller)-[r:CodeRelation]->(ctor) - WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'ACCESSES'] - RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind - LIMIT 30 - `, - { symId }, - ), - executeParameterized( - repo.id, - ` - MATCH (f:File)-[rel:CodeRelation]->(n) - WHERE n.id = $symId AND rel.type = 'DEFINES' - MATCH (caller)-[r:CodeRelation]->(f) - WHERE r.type IN ['CALLS', 'IMPORTS'] - RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind - LIMIT 30 - `, - { symId }, - ), - ]); - - // Deduplicate by (relType, uid) — a caller can have multiple relation - // types to the same target (e.g. both IMPORTS and CALLS), and each - // must be preserved so every category appears in the output. - const seenKeys = new Set( - incomingRows.map((r: any) => `${r.relType || r[0]}:${r.uid || r[1]}`), - ); - for (const r of [...ctorIncoming, ...fileIncoming]) { - const key = `${r.relType || r[0]}:${r.uid || r[1]}`; - if (!seenKeys.has(key)) { - seenKeys.add(key); - incomingRows.push(r); - } - } - } catch (e) { - logQueryError('context:class-incoming-expansion', e); - } - } - - // Categorized outgoing refs - const outgoingRows = await executeParameterized( - repo.id, - ` - MATCH (n {id: $symId})-[r:CodeRelation]->(target) - WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES'] - RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind - LIMIT 30 - `, - { symId }, - ); - - let structuralOutgoing: any[] = []; - if (symRawType === 'Section' || isDocNodeCheck) { - try { - structuralOutgoing = await executeParameterized( - repo.id, - `MATCH (n {id: $symId})-[r:CodeRelation]->(child) - WHERE r.type IN ['CONTAINS', 'DEFINES'] - RETURN r.type AS relType, child.id AS uid, child.name AS name, - child.filePath AS filePath, labels(child)[0] AS kind - LIMIT 30`, - { symId } - ); - } catch (e) { - logQueryError('context:structural-outgoing', e); - } - - try { - const crossRefRows = await executeParameterized( - repo.id, - `MATCH (n {id: $symId})-[r:CodeRelation {type: 'IMPORTS'}]->(target) - RETURN r.type AS relType, target.id AS uid, target.name AS name, - target.filePath AS filePath, labels(target)[0] AS kind - LIMIT 20`, - { symId } - ); - for (const row of crossRefRows) { - if (!outgoingRows.some((r: any) => (r.uid || r[1]) === (row.uid || row[1]))) { - outgoingRows.push(row); - } - } - } catch (e) { - logQueryError('context:section-crossref', e); - } - } - - // Process participation - let processRows: any[] = []; - try { - processRows = await executeParameterized( - repo.id, - ` - MATCH (n {id: $symId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - RETURN p.id AS pid, p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount - `, - { symId }, - ); - } catch (e) { - logQueryError('context:process-participation', e); - } - - // Helper to categorize refs - const categorize = (rows: any[]) => { - const cats: Record = {}; - for (const row of rows) { - const relType = (row.relType || row[0] || '').toLowerCase(); - const entry = { - uid: row.uid || row[1], - name: row.name || row[2], - filePath: row.filePath || row[3], - kind: row.kind || row[4], - }; - if (!cats[relType]) cats[relType] = []; - cats[relType].push(entry); - } - return cats; - }; - - // Method/Function/Constructor enrichment: fetch method-specific properties - const symKind = isClassLike ? resolvedLabel || 'Class' : sym.type || sym[2]; - const isMethodLike = - symKind === 'Method' || symKind === 'Function' || symKind === 'Constructor'; - let methodMetadata: Record | undefined; - if (isMethodLike) { - try { - const metaRows = await executeParameterized( - repo.id, - ` - MATCH (n {id: $symId}) - RETURN n.visibility AS visibility, n.isStatic AS isStatic, n.isAbstract AS isAbstract, - n.isFinal AS isFinal, n.isVirtual AS isVirtual, n.isOverride AS isOverride, - n.isAsync AS isAsync, n.isPartial AS isPartial, n.returnType AS returnType, - n.parameterCount AS parameterCount, n.isVariadic AS isVariadic, - n.requiredParameterCount AS requiredParameterCount, - n.parameterTypes AS parameterTypes, n.annotations AS annotations - LIMIT 1 - `, - { symId }, - ); - if (metaRows.length > 0) { - const row = metaRows[0]; - const meta: Record = {}; - // Only include defined properties to distinguish "not applicable" from "not enriched" - for (const key of Object.keys(row)) { - const val = row[key]; - if (val !== null && val !== undefined) meta[key] = val; - } - if (Object.keys(meta).length > 0) methodMetadata = meta; - } - } catch { - /* method metadata unavailable — omit silently */ - } - } - - return { - status: 'found', - symbol: { - uid: sym.id || sym[0], - name: sym.name || sym[1], - kind: symKind, - filePath: sym.filePath || sym[3], - startLine: sym.startLine || sym[4], - endLine: sym.endLine || sym[5], - git_namespace: sym.git_namespace || sym[7] || null, - ...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}), - ...(methodMetadata ? { methodMetadata } : {}), - }, - incoming: categorize([...incomingRows, ...structuralIncoming]), - outgoing: categorize([...outgoingRows, ...structuralOutgoing]), - processes: processRows.map((r: any) => ({ - id: r.pid || r[0], - name: r.label || r[1], - step_index: r.step || r[2], - step_count: r.stepCount || r[3], - })), - }; - } - - /** - * Legacy explore — kept for backwards compatibility with resources.ts. - * Routes cluster/process types to direct graph queries. - */ - private async explore( - repo: RepoHandle, - params: { name: string; type: 'symbol' | 'cluster' | 'process' }, - ): Promise { - await this.ensureInitialized(repo.id); - const { name, type } = params; - - if (type === 'symbol') { - return this.context(repo, { name }); - } - - if (type === 'cluster') { - const clusters = await executeParameterized( - repo.id, - ` - MATCH (c:Community) - WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName - RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount - `, - { clusterName: name }, - ); - if (clusters.length === 0) return { error: `Cluster '${name}' not found` }; - - const rawClusters = clusters.map((c: any) => ({ - id: c.id || c[0], - label: c.label || c[1], - heuristicLabel: c.heuristicLabel || c[2], - cohesion: c.cohesion || c[3], - symbolCount: c.symbolCount || c[4], - })); - - let totalSymbols = 0, - weightedCohesion = 0; - for (const c of rawClusters) { - const s = c.symbolCount || 0; - totalSymbols += s; - weightedCohesion += (c.cohesion || 0) * s; - } - - const members = await executeParameterized( - repo.id, - ` - MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName - RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath - LIMIT 30 - `, - { clusterName: name }, - ); - - return { - cluster: { - id: rawClusters[0].id, - label: rawClusters[0].heuristicLabel || rawClusters[0].label, - heuristicLabel: rawClusters[0].heuristicLabel || rawClusters[0].label, - cohesion: totalSymbols > 0 ? weightedCohesion / totalSymbols : 0, - symbolCount: totalSymbols, - subCommunities: rawClusters.length, - }, - members: members.map((m: any) => ({ - name: m.name || m[0], - type: m.type || m[1], - filePath: m.filePath || m[2], - })), - }; - } - - if (type === 'process') { - const processes = await executeParameterized( - repo.id, - ` - MATCH (p:Process) - WHERE p.label = $processName OR p.heuristicLabel = $processName - RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount - LIMIT 1 - `, - { processName: name }, - ); - if (processes.length === 0) return { error: `Process '${name}' not found` }; - - const proc = processes[0]; - const procId = proc.id || proc[0]; - const steps = await executeParameterized( - repo.id, - ` - MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId}) - RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step - ORDER BY r.step - `, - { procId }, - ); - - return { - process: { - id: procId, - label: proc.label || proc[1], - heuristicLabel: proc.heuristicLabel || proc[2], - processType: proc.processType || proc[3], - stepCount: proc.stepCount || proc[4], - }, - steps: steps.map((s: any) => ({ - step: s.step || s[3], - name: s.name || s[0], - type: s.type || s[1], - filePath: s.filePath || s[2], - })), - }; - } - - return { error: 'Invalid type. Use: symbol, cluster, or process' }; - } - - /** - * Detect changes — git-diff based impact analysis. - * Maps changed lines to indexed symbols, then finds affected processes. - */ - private async detectChanges( - repo: RepoHandle, - params: { - scope?: string; - base_ref?: string; - }, - ): Promise { - await this.ensureInitialized(repo.id); - - const scope = params.scope || 'unstaged'; - const { execFileSync } = await import('child_process'); - - // Build git diff args based on scope (using execFileSync to avoid shell injection) - let diffArgs: string[]; - switch (scope) { - case 'staged': - diffArgs = ['diff', '--staged', '--name-only']; - break; - case 'all': - diffArgs = ['diff', 'HEAD', '--name-only']; - break; - case 'compare': - if (!params.base_ref) return { error: 'base_ref is required for "compare" scope' }; - diffArgs = ['diff', params.base_ref, '--name-only']; - break; - case 'unstaged': - default: - diffArgs = ['diff', '--name-only']; - break; - } - - let changedFiles: string[]; - try { - const output = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' }); - changedFiles = output - .trim() - .split('\n') - .filter((f) => f.length > 0); - } catch (err: any) { - return { error: `Git diff failed: ${err.message}` }; - } - - if (changedFiles.length === 0) { - return { - summary: { - changed_count: 0, - affected_count: 0, - risk_level: 'none', - message: 'No changes detected.', - }, - changed_symbols: [], - affected_processes: [], - }; - } - - // Map changed files to indexed symbols - const changedSymbols: any[] = []; - for (const file of changedFiles) { - const normalizedFile = file.replace(/\\/g, '/'); - try { - const symbols = await executeWithNsGuard( - repo.id, - ` - MATCH (n) WHERE n.filePath CONTAINS $filePath - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.git_namespace AS git_namespace - LIMIT 20 - `, - { filePath: normalizedFile }, - ); - for (const sym of symbols) { - changedSymbols.push({ - id: sym.id || sym[0], - name: sym.name || sym[1], - type: sym.type || sym[2], - filePath: sym.filePath || sym[3], - git_namespace: sym.git_namespace || sym[4] || null, - change_type: 'Modified', - }); - } - } catch (e) { - logQueryError('detect-changes:file-symbols', e); - } - } - - // Find affected processes - const affectedProcesses = new Map(); - for (const sym of changedSymbols) { - try { - const procs = await executeWithNsGuard( - repo.id, - ` - MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - RETURN p.id AS pid, p.heuristicLabel AS label, p.processType AS processType, p.stepCount AS stepCount, r.step AS step - `, - { nodeId: sym.id }, - ); - for (const proc of procs) { - const pid = proc.pid || proc[0]; - if (!affectedProcesses.has(pid)) { - affectedProcesses.set(pid, { - id: pid, - name: proc.label || proc[1], - process_type: proc.processType || proc[2], - step_count: proc.stepCount || proc[3], - changed_steps: [], - }); - } - affectedProcesses.get(pid)!.changed_steps.push({ - symbol: sym.name, - step: proc.step || proc[4], - }); - } - } catch (e) { - logQueryError('detect-changes:process-lookup', e); - } - } - - const processCount = affectedProcesses.size; - const risk = - processCount === 0 - ? 'low' - : processCount <= 5 - ? 'medium' - : processCount <= 15 - ? 'high' - : 'critical'; - - return { - summary: { - changed_count: changedSymbols.length, - affected_count: processCount, - changed_files: changedFiles.length, - risk_level: risk, - }, - changed_symbols: changedSymbols, - affected_processes: Array.from(affectedProcesses.values()), - }; - } - - /** - * Rename tool — multi-file coordinated rename using graph + text search. - * Graph refs are tagged "graph" (high confidence). - * Additional refs found via text search are tagged "text_search" (lower confidence). - */ - private async rename( - repo: RepoHandle, - params: { - symbol_name?: string; - symbol_uid?: string; - new_name: string; - file_path?: string; - dry_run?: boolean; - }, - ): Promise { - await this.ensureInitialized(repo.id); - - const { new_name, file_path } = params; - const dry_run = params.dry_run ?? true; - - if (!params.symbol_name && !params.symbol_uid) { - return { error: 'Either symbol_name or symbol_uid is required.' }; - } - - /** Guard: ensure a file path resolves within the repo root (prevents path traversal) */ - const assertSafePath = (filePath: string): string => { - const full = path.resolve(repo.repoPath, filePath); - if (!full.startsWith(repo.repoPath + path.sep) && full !== repo.repoPath) { - throw new Error(`Path traversal blocked: ${filePath}`); - } - return full; - }; - - // Step 1: Find the target symbol (reuse context's lookup) - const lookupResult = await this.context(repo, { - name: params.symbol_name, - uid: params.symbol_uid, - file_path, - }); - - if (lookupResult.status === 'ambiguous') { - return lookupResult; // pass disambiguation through - } - if (lookupResult.error) { - return lookupResult; - } - - const sym = lookupResult.symbol; - const oldName = sym.name; - - if (oldName === new_name) { - return { error: 'New name is the same as the current name.' }; - } - - // Step 2: Collect edits from graph (high confidence) - const changes = new Map(); - - const addEdit = ( - filePath: string, - line: number, - oldText: string, - newText: string, - confidence: string, - ) => { - if (!changes.has(filePath)) { - changes.set(filePath, { file_path: filePath, edits: [] }); - } - changes.get(filePath)!.edits.push({ line, old_text: oldText, new_text: newText, confidence }); - }; - - let searchName = oldName; - if (sym.type === 'Section' && sym.id) { - // Extract raw text from ID: "Section:...:L12:Raw Text Here" - const parts = sym.id.split(':L' + sym.startLine + ':'); - if (parts.length === 2) searchName = parts[1]; - } - - // The definition itself - if (sym.filePath && sym.startLine) { - try { - const content = await fs.readFile(assertSafePath(sym.filePath), 'utf-8'); - const lines = content.split('\n'); - const lineIdx = sym.startLine - 1; - if (lineIdx >= 0 && lineIdx < lines.length && lines[lineIdx].includes(searchName)) { - const defRegex = new RegExp( - `\\b${searchName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, - 'g', - ); - addEdit( - sym.filePath, - sym.startLine, - lines[lineIdx].trim(), - lines[lineIdx].replace(defRegex, new_name).trim(), - 'graph', - ); - } - } catch (e) { - logQueryError('rename:read-definition', e); - } - } - - // All incoming refs from graph (callers, importers, etc.) - const definitionFile = sym.filePath; - const safeContains = (lookupResult.incoming.contains || []).filter( - (ref: any) => ref.filePath !== definitionFile - ); - const safeDefines = (lookupResult.incoming.defines || []).filter( - (ref: any) => ref.filePath !== definitionFile - ); - - const allIncoming = [ - ...(lookupResult.incoming.calls || []), - ...(lookupResult.incoming.imports || []), - ...(lookupResult.incoming.extends || []), - ...(lookupResult.incoming.implements || []), - ...safeContains, - ...safeDefines, - ]; - - let graphEdits = changes.size > 0 ? 1 : 0; // count definition edit - - for (const ref of allIncoming) { - if (!ref.filePath) continue; - try { - const content = await fs.readFile(assertSafePath(ref.filePath), 'utf-8'); - const lines = content.split('\n'); - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes(oldName)) { - addEdit( - ref.filePath, - i + 1, - lines[i].trim(), - lines[i] - .replace( - new RegExp(`\\b${searchName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'), - new_name, - ) - .trim(), - 'graph', - ); - graphEdits++; - break; // one edit per file from graph refs - } - } - } catch (e) { - logQueryError('rename:read-ref', e); - } - } - - // Step 3: Text search for refs the graph might have missed - let astSearchEdits = 0; - const graphFiles = new Set( - [sym.filePath, ...allIncoming.map((r) => r.filePath)].filter(Boolean), - ); - - // Simple text search across the repo for the search name (in files not already covered by graph) - try { - const { execFileSync } = await import('child_process'); - const rgArgs = [ - '-l', - '--type-add', - 'code:*.{ts,tsx,js,jsx,py,go,rs,java,c,h,cpp,cc,cxx,hpp,hxx,hh,cs,php,swift}', - '-t', - 'code', - `\\b${oldName}\\b`, - '.', - ]; - const output = execFileSync('rg', rgArgs, { - cwd: repo.repoPath, - encoding: 'utf-8', - timeout: 5000, - }); - const files = output - .trim() - .split('\n') - .filter((f) => f.length > 0); - - for (const file of files) { - const normalizedFile = file.replace(/\\/g, '/').replace(/^\.\//, ''); - if (graphFiles.has(normalizedFile)) continue; // already covered by graph - - try { - const content = await fs.readFile(assertSafePath(normalizedFile), 'utf-8'); - const lines = content.split('\n'); - const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'); - for (let i = 0; i < lines.length; i++) { - regex.lastIndex = 0; - if (regex.test(lines[i])) { - regex.lastIndex = 0; - addEdit( - normalizedFile, - i + 1, - lines[i].trim(), - lines[i].replace(regex, new_name).trim(), - 'text_search', - ); - astSearchEdits++; - } - } - } catch (e) { - logQueryError('rename:text-search-read', e); - } - } - } catch (e) { - logQueryError('rename:ripgrep', e); - } - - // Step 4: Apply or preview - const allChanges = Array.from(changes.values()); - const totalEdits = allChanges.reduce((sum, c) => sum + c.edits.length, 0); - - if (!dry_run) { - // Apply edits to files - for (const change of allChanges) { - try { - const fullPath = assertSafePath(change.file_path); - let content = await fs.readFile(fullPath, 'utf-8'); - const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'); - content = content.replace(regex, new_name); - await fs.writeFile(fullPath, content, 'utf-8'); - } catch (e) { - logQueryError('rename:apply-edit', e); - } - } - } - - return { - status: 'success', - old_name: oldName, - new_name, - files_affected: allChanges.length, - total_edits: totalEdits, - graph_edits: graphEdits, - text_search_edits: astSearchEdits, - changes: allChanges, - applied: !dry_run, - }; - } - - private async impact( - repo: RepoHandle, - params: { - target: string; - direction: 'upstream' | 'downstream'; - maxDepth?: number; - relationTypes?: string[]; - includeTests?: boolean; - minConfidence?: number; - }, - ): Promise { - try { - return await this._impactImpl(repo, params); - } catch (err: any) { - // Return structured error instead of crashing (#321) - return { - error: (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed', - target: { name: params.target }, - direction: params.direction, - impactedCount: 0, - risk: 'UNKNOWN', - suggestion: 'The graph query failed — try gitnexus context as a fallback', - }; - } - } - - private async _impactImpl( - repo: RepoHandle, - params: { - target: string; - direction: 'upstream' | 'downstream'; - maxDepth?: number; - relationTypes?: string[]; - includeTests?: boolean; - minConfidence?: number; - }, - ): Promise { - await this.ensureInitialized(repo.id); - - const { target, direction } = params; - const maxDepth = params.maxDepth || 3; - const rawRelTypes = - params.relationTypes && params.relationTypes.length > 0 - ? params.relationTypes.filter((t) => VALID_RELATION_TYPES.has(t)) - : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; - const relationTypes = - rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; - const includeTests = params.includeTests ?? false; - const minConfidence = params.minConfidence ?? 0; - - // Resolve target by name, preferring Class/Interface over Constructor - // (fix #480: Java class and constructor share the same name). - // labels(n)[0] returns empty string in LadybugDB, so we use explicit - // label-typed sub-queries in a single UNION ordered by priority to avoid - // up to 6 serial round-trips for non-Class targets. - let sym: any = null; - let symType = ''; - - try { - const rows = await executeParameterized( - repo.id, - ` - MATCH (n:\`Class\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 0 AS priority LIMIT 1 - UNION ALL - MATCH (n:\`Interface\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 1 AS priority LIMIT 1 - UNION ALL - MATCH (n:\`Function\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 2 AS priority LIMIT 1 - UNION ALL - MATCH (n:\`Method\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 3 AS priority LIMIT 1 - UNION ALL - MATCH (n:\`Constructor\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 4 AS priority LIMIT 1 - `, - { targetName: target }, - ).catch(() => []); - - if (rows.length > 0) { - // Pick the row with the lowest priority value (Class wins over Constructor) - const best = rows.reduce((a: any, b: any) => - (a.priority ?? a[3] ?? 99) <= (b.priority ?? b[3] ?? 99) ? a : b, - ); - sym = best; - const priorityToLabel = ['Class', 'Interface', 'Function', 'Method', 'Constructor']; - symType = priorityToLabel[best.priority ?? best[3]] ?? ''; - } - } catch { - /* fall through to unlabeled match */ - } - - // Fall back to unlabeled match for any other node type - if (!sym) { - const rows = await executeParameterized( - repo.id, - ` - MATCH (n) - WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath - LIMIT 1 - `, - { targetName: target }, - ); - if (rows.length > 0) sym = rows[0]; - } - - if (!sym) return { error: `Target '${target}' not found` }; - - return this._runImpactBFS(repo, sym, symType, direction, { - maxDepth, - relationTypes, - includeTests, - minConfidence, - }); - } - - /** - * Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol). - */ - private async _runImpactBFS( - repo: RepoHandle, - sym: any, - symType: string, - direction: 'upstream' | 'downstream', - opts: { - maxDepth: number; - relationTypes: string[]; - includeTests: boolean; - minConfidence: number; - }, - ): Promise { - const { maxDepth, relationTypes, includeTests, minConfidence } = opts; - const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', '); - const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : ''; - - const symId = sym.id || sym[0]; - - const impacted: any[] = []; - const visited = new Set([symId]); - let frontier = [symId]; - let traversalComplete = true; - - // Fix #480: For Java (and other JVM) Class/Interface nodes, CALLS edges - // point to Constructor nodes and IMPORTS edges point to File nodes — not - // the Class/Interface itself. Seed the frontier with the Constructor(s) - // and owning File so the BFS traversal finds those edges naturally. - // The owning File is kept only as an internal seed (frontier/visited) and - // is NOT added to impacted — it is the definition container, not an - // upstream dependent. The BFS will discover IMPORTS edges on it naturally. - if (symType === 'Class' || symType === 'Interface') { - try { - // Run both seed queries in parallel — they are independent. - const [ctorRows, fileRows] = await Promise.all([ - executeParameterized( - repo.id, - ` - MATCH (n)-[hm:CodeRelation]->(c:Constructor) - WHERE n.id = $symId AND hm.type = 'HAS_METHOD' - RETURN c.id AS id, c.name AS name, labels(c)[0] AS type, c.filePath AS filePath - `, - { symId }, - ), - // Restrict to DEFINES edges only — other File->Class edge types (if - // any) should not be treated as the owning file relationship. - executeParameterized( - repo.id, - ` - MATCH (f:File)-[rel:CodeRelation]->(n) - WHERE n.id = $symId AND rel.type = 'DEFINES' - RETURN f.id AS id, f.name AS name, labels(f)[0] AS type, f.filePath AS filePath - `, - { symId }, - ), - ]); - - for (const r of ctorRows) { - const rid = r.id || r[0]; - if (rid && !visited.has(rid)) { - visited.add(rid); - frontier.push(rid); - } - } - for (const r of fileRows) { - const rid = r.id || r[0]; - if (rid && !visited.has(rid)) { - visited.add(rid); - frontier.push(rid); - } - } - } catch (e) { - logQueryError('impact:class-node-expansion', e); - } - } - - for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) { - const nextFrontier: string[] = []; - - // Batch frontier nodes into a single Cypher query per depth level - const idList = frontier.map((id) => `'${id.replace(/'/g, "''")}'`).join(', '); - const query = - direction === 'upstream' - ? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence, caller.git_namespace AS git_namespace` - : `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence, callee.git_namespace AS git_namespace`; - - try { - const related = await executeQueryWithNsGuard(repo.id, query); - - for (const rel of related) { - const relId = rel.id || rel[1]; - const filePath = rel.filePath || rel[4] || ''; - - if (!includeTests && isTestFilePath(filePath)) continue; - - if (!visited.has(relId)) { - visited.add(relId); - nextFrontier.push(relId); - const storedConfidence = rel.confidence ?? rel[6]; - const relationType = rel.relType || rel[5]; - // Prefer the stored confidence from the graph (set at analysis time); - // fall back to the per-type floor for edges without a stored value. - const effectiveConfidence = - typeof storedConfidence === 'number' && storedConfidence > 0 - ? storedConfidence - : confidenceForRelType(relationType); - impacted.push({ - depth, - id: relId, - name: rel.name || rel[2], - type: rel.type || rel[3], - filePath, - relationType, - git_namespace: rel.git_namespace || rel[7] || null, - confidence: effectiveConfidence, - }); - } - } - } catch (e) { - logQueryError('impact:depth-traversal', e); - // Break out of depth loop on query failure but return partial results - // collected so far, rather than silently swallowing the error (#321) - traversalComplete = false; - break; - } - - frontier = nextFrontier; - } - - const grouped: Record = {}; - for (const item of impacted) { - if (!grouped[item.depth]) grouped[item.depth] = []; - grouped[item.depth].push(item); - } - - // ── Enrichment: affected processes, modules, risk ────────────── - const directCount = (grouped[1] || []).length; - let affectedProcesses: any[] = []; - let affectedModules: any[] = []; - - if (impacted.length > 0) { - const CHUNK_SIZE = 100; - // Max number of chunks to process to avoid unbounded DB round-trips. - // Configurable via env IMPACT_MAX_CHUNKS, default 10 => max items = 1000 - const MAX_CHUNKS = parseInt(process.env.IMPACT_MAX_CHUNKS || '10', 10); - - // ── Process enrichment: batched chunking (bounded by MAX_CHUNKS) ─ - // Uses merged Cypher query (WITH + OPTIONAL MATCH) to fetch - // process + entry point info in 1 round-trip per chunk. Converted to - // parameterized queries to avoid manual string escaping and long query strings. - const entryPointMap = new Map< - string, - { - name: string; - type: string; - filePath: string; - affected_process_count: number; - total_hits: number; - earliest_broken_step: number; - } - >(); - - // Map process id -> entryPointId to allow fixing missing minStep values later - const processToEntryPoint = new Map(); - // Collect process ids where MIN(r.step) returned null so we can retry in batch - const processesMissingMinStep = new Set(); - - let chunksProcessed = 0; - for ( - let i = 0; - i < impacted.length && chunksProcessed < MAX_CHUNKS; - i += CHUNK_SIZE, chunksProcessed++ - ) { - const chunk = impacted.slice(i, i + CHUNK_SIZE); - const ids = chunk.map((item) => String(item.id ?? '')); - - try { - // Use parameterized list to avoid building long query strings - const rows = await executeParameterized( - repo.id, - ` - MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - WHERE s.id IN $ids - WITH p, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep - OPTIONAL MATCH (ep {id: p.entryPointId}) - RETURN p.id AS pId, p.heuristicLabel AS name, p.processType AS processType, - p.entryPointId AS entryPointId, hits, minStep, p.stepCount AS stepCount, - ep.name AS epName, labels(ep)[0] AS epType, ep.filePath AS epFilePath - `, - { ids }, - ).catch(() => []); - - for (const row of rows) { - const pId = row.pId ?? row[0]; - const epId = row.entryPointId ?? row[3] ?? row.pId ?? row[0]; - // Track mapping from process -> entryPoint so we can backfill missing minStep - if (pId) processToEntryPoint.set(String(pId), String(epId)); - - // Normalize epName: prefer epName, fall back to other columns, and - // ensure we don't keep an empty string (labels(...) can return ""). - const epNameRaw = row.epName ?? row[7] ?? row.name ?? row[1] ?? 'unknown'; - const epName = - typeof epNameRaw === 'string' && epNameRaw.trim().length > 0 - ? epNameRaw.trim() - : 'unknown'; - - // Normalize epType: labels(ep)[0] can return an empty string in - // some DBs (LadybugDB). Using nullish coalescing (??) preserves - // empty strings, which results in empty `type` values being - // propagated. Treat empty-string labels as missing and fall back - // to the next candidate or a sensible default. - const epTypeRaw = row.epType ?? row[8] ?? ''; - const epType = - typeof epTypeRaw === 'string' && epTypeRaw.trim().length > 0 - ? epTypeRaw.trim() - : 'Function'; - - const epFilePath = row.epFilePath ?? row[9] ?? ''; - const hits = row.hits ?? row[4] ?? 0; - const minStep = row.minStep ?? row[5]; - // If the DB returned null for minStep, note the process id so we - // can run a follow-up query using a different aggregation strategy. - if (minStep === null || minStep === undefined) { - if (pId) processesMissingMinStep.add(String(pId)); - } - if (!entryPointMap.has(epId)) { - entryPointMap.set(epId, { - name: epName, - type: epType, - filePath: epFilePath, - affected_process_count: 0, - total_hits: 0, - earliest_broken_step: Infinity, - }); - } - const ep = entryPointMap.get(epId)!; - ep.affected_process_count += 1; - ep.total_hits += hits; - ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep ?? Infinity); - } - } catch (e) { - logQueryError('impact:process-chunk', e); - } - } - - // If some processes returned null minStep, try a batched follow-up query - // using the full impacted id set. This handles older indexes or DBs - // where MIN(r.step) can come back null even when step properties exist. - if (processesMissingMinStep.size > 0) { - try { - const pIds = Array.from(processesMissingMinStep); - const allImpactedIds = impacted.map((it) => String(it.id ?? '')); - const missingRows = await executeParameterized( - repo.id, - ` - MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - WHERE p.id IN $pIds AND s.id IN $ids - RETURN p.id AS pid, MIN(r.step) AS minStep - `, - { pIds, ids: allImpactedIds }, - ).catch(() => []); - - for (const mr of missingRows) { - const pid = mr.pid ?? mr[0]; - const minStep = mr.minStep ?? mr[1]; - const epId = processToEntryPoint.get(String(pid)); - if (!epId) continue; - const ep = entryPointMap.get(epId); - if (!ep) continue; - if (typeof minStep === 'number') { - ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep); - } - } - } catch (e) { - logQueryError('impact:process-chunk-backfill', e); - } - } - - // If we capped chunks, mark traversal incomplete so caller knows results are partial - if (chunksProcessed * CHUNK_SIZE < impacted.length) { - traversalComplete = false; - } - - affectedProcesses = Array.from(entryPointMap.values()) - .map((ep) => ({ - ...ep, - earliest_broken_step: - ep.earliest_broken_step === Infinity ? null : ep.earliest_broken_step, - })) - .sort((a, b) => b.total_hits - a.total_hits); - - // ── Module enrichment: use same cap as process enrichment and parameterized queries - const maxItems = Math.min(impacted.length, MAX_CHUNKS * CHUNK_SIZE); - const cappedImpacted = impacted.slice(0, maxItems); - const allIdsArr = cappedImpacted.map((i: any) => String(i.id ?? '')); - const d1Items = (grouped[1] || []).slice(0, maxItems); - const d1IdsArr = d1Items.map((i: any) => String(i.id ?? '')); - - // Chunked module enrichment: run the MEMBER_OF queries in chunks - // to avoid large single queries or concurrent Kuzu calls that can - // crash (SIGSEGV) on arm64 macOS; behavior preserves existing maxItems cap and returns equivalent aggregated results. - const moduleHitsMap = new Map(); - const directModuleSet = new Set(); - - // Helper to run a single module chunk and accumulate hits by name - const runModuleChunk = async (idsChunk: string[]) => { - if (!idsChunk || idsChunk.length === 0) return; - try { - const rows = await executeParameterized( - repo.id, - ` - MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - WHERE s.id IN $ids - RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits - ORDER BY hits DESC - LIMIT 20 - `, - { ids: idsChunk }, - ).catch(() => []); - - for (const r of rows) { - const name = r.name ?? r[0] ?? null; - const hits = (r.hits ?? r[1]) || 0; - if (!name) continue; - moduleHitsMap.set(name, (moduleHitsMap.get(name) || 0) + hits); - } - } catch (e) { - logQueryError('impact:module-chunk', e); - } - }; - - // Run module query chunks sequentially (safe on arm64 macOS) - for (let i = 0; i < allIdsArr.length; i += CHUNK_SIZE) { - const chunkIds = allIdsArr.slice(i, i + CHUNK_SIZE); - await runModuleChunk(chunkIds); - } - - // Run direct module query similarly (distinct heuristic labels for depth-1 items) - const runDirectModuleChunk = async (idsChunk: string[]) => { - if (!idsChunk || idsChunk.length === 0) return; - try { - const rows = await executeParameterized( - repo.id, - ` - MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - WHERE s.id IN $ids - RETURN DISTINCT c.heuristicLabel AS name - `, - { ids: idsChunk }, - ).catch(() => []); - for (const r of rows) { - const name = r.name ?? r[0] ?? null; - if (name) directModuleSet.add(name); - } - } catch (e) { - logQueryError('impact:direct-module-chunk', e); - } - }; - - for (let i = 0; i < d1IdsArr.length; i += CHUNK_SIZE) { - const chunkIds = d1IdsArr.slice(i, i + CHUNK_SIZE); - await runDirectModuleChunk(chunkIds); - } - - // Build final moduleRows array from aggregated hits map, sorted & limited - const moduleRows = Array.from(moduleHitsMap.entries()) - .map(([name, hits]) => ({ name, hits })) - .sort((a, b) => b.hits - a.hits) - .slice(0, 20); - - const directModuleRows = Array.from(directModuleSet).map((name) => ({ name })); - - // Build affectedModules in the same shape as original implementation - const directModuleNameSet = new Set(directModuleRows.map((r: any) => r.name || r[0])); - affectedModules = moduleRows.map((r: any) => { - const name = r.name ?? r[0]; - const hits = r.hits ?? r[1] ?? 0; - return { - name, - hits, - impact: directModuleNameSet.has(name) ? 'direct' : 'indirect', - }; - }); - } - - // Risk scoring - const processCount = affectedProcesses.length; - const moduleCount = affectedModules.length; - let risk = 'LOW'; - if (directCount >= 30 || processCount >= 5 || moduleCount >= 5 || impacted.length >= 200) { - risk = 'CRITICAL'; - } else if ( - directCount >= 15 || - processCount >= 3 || - moduleCount >= 3 || - impacted.length >= 100 - ) { - risk = 'HIGH'; - } else if (directCount >= 5 || impacted.length >= 30) { - risk = 'MEDIUM'; - } - - return { - target: { - id: symId, - name: sym.name || sym[1], - type: symType, - filePath: sym.filePath || sym[2], - }, - direction, - impactedCount: impacted.length, - risk, - ...(!traversalComplete && { partial: true }), - summary: { - direct: directCount, - processes_affected: processCount, - modules_affected: moduleCount, - }, - affected_processes: affectedProcesses, - affected_modules: affectedModules, - byDepth: grouped, - }; - } - - /** - * UID-based impact for cross-repo fan-out. Same result shape as `impact`. - * Returns null if the repo is unknown, the UID is missing, or analysis fails. - */ - async impactByUid( - repoId: string, - uid: string, - direction: string, - opts: { - maxDepth: number; - relationTypes: string[]; - minConfidence: number; - includeTests: boolean; - }, - ): Promise { - try { - await this.refreshRepos(); - await this.ensureInitialized(repoId); - } catch { - return null; - } - - const repo = this.repos.get(repoId); - if (!repo) return null; - - const dir: 'upstream' | 'downstream' = direction === 'downstream' ? 'downstream' : 'upstream'; - - let rows: any[]; - try { - rows = await executeParameterized( - repoId, - `MATCH (n) WHERE n.id = $uid - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, labels(n)[0] AS type - LIMIT 1`, - { uid }, - ); - } catch { - return null; - } - if (!rows?.length) return null; - - const sym = rows[0]; - const labelRaw = sym.type ?? sym[3]; - const symType = - typeof labelRaw === 'string' && labelRaw.trim().length > 0 ? labelRaw.trim() : ''; - - const rawRelTypes = - opts.relationTypes && opts.relationTypes.length > 0 - ? opts.relationTypes.filter((t) => VALID_RELATION_TYPES.has(t)) - : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; - const relationTypes = - rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; - - try { - return await this._runImpactBFS(repo, sym, symType, dir, { - maxDepth: opts.maxDepth, - relationTypes, - includeTests: opts.includeTests, - minConfidence: opts.minConfidence, - }); - } catch { - return null; - } - } - - private handleGroupTool(method: string, params: Record): Promise { - switch (method) { - case 'group_list': - return this.groupList(params); - case 'group_sync': - return this.groupSync(params); - case 'group_contracts': - return this.groupContracts(params); - case 'group_query': - return this.groupQuery(params); - case 'group_status': - return this.groupStatus(params); - default: - throw new Error(`Unknown group tool: ${method}`); - } - } - - private async groupList(params: Record): Promise { - return this.getGroupService().groupList(params); - } - - private async groupSync(params: Record): Promise { - return this.getGroupService().groupSync(params); - } - - private async groupContracts(params: Record): Promise { - return this.getGroupService().groupContracts(params); - } - - private async groupQuery(params: Record): Promise { - await this.refreshRepos(); - return this.getGroupService().groupQuery(params); - } - - private async groupStatus(params: Record): Promise { - await this.refreshRepos(); - return this.getGroupService().groupStatus(params); - } - - /** - * Fetch Route nodes with their consumers in a single query. - * Shared by routeMap and shapeCheck to avoid N+1 query patterns. - */ - private async fetchRoutesWithConsumers( - repoId: string, - routeFilter: string, - params: Record, - ): Promise< - Array<{ - id: string; - name: string; - filePath: string; - responseKeys: string[] | null; - errorKeys: string[] | null; - middleware: string[] | null; - git_namespace?: string | null; - consumers: Array<{ - name: string; - filePath: string; - accessedKeys?: string[]; - fetchCount?: number; - }>; - }> - > { - const rows = await executeWithNsGuard( - repoId, - ` - MATCH (n:Route) - WHERE n.id STARTS WITH 'Route:' ${routeFilter} - OPTIONAL MATCH (consumer)-[r:CodeRelation]->(n) - WHERE r.type = 'FETCHES' - RETURN n.id AS routeId, n.name AS routeName, n.filePath AS handlerFile, - n.responseKeys AS responseKeys, n.errorKeys AS errorKeys, n.middleware AS middleware, - consumer.name AS consumerName, consumer.filePath AS consumerFile, - r.reason AS fetchReason, - n.git_namespace AS git_namespace - `, - params, - ); - - // Strip wrapping quotes from DB array elements — CSV COPY stores ['key'] which - // LadybugDB may return as "'key'" rather than "key" - const stripQuotes = (keys: string[] | null): string[] | null => - keys ? keys.map((k) => k.replace(/^['"]|['"]$/g, '')) : null; - - const routeMap = new Map< - string, - { - id: string; - name: string; - filePath: string; - responseKeys: string[] | null; - errorKeys: string[] | null; - middleware: string[] | null; - git_namespace: string | null; - consumers: Array<{ - name: string; - filePath: string; - accessedKeys?: string[]; - fetchCount?: number; - }>; - } - >(); - for (const row of rows) { - const id = row.routeId ?? row[0]; - const name = row.routeName ?? row[1]; - const filePath = row.handlerFile ?? row[2]; - const responseKeys = stripQuotes(row.responseKeys ?? row[3] ?? null); - const errorKeys = stripQuotes(row.errorKeys ?? row[4] ?? null); - const middleware = stripQuotes(row.middleware ?? row[5] ?? null); - const consumerName = row.consumerName ?? row[6]; - const consumerFile = row.consumerFile ?? row[7]; - const fetchReason: string | null = row.fetchReason ?? row[8] ?? null; - - const git_namespace = row.git_namespace ?? row[9] ?? null; - - if (!routeMap.has(id)) { - routeMap.set(id, { - id, - name, - filePath, - responseKeys, - errorKeys, - middleware, - git_namespace, - consumers: [], - }); - } - if (consumerName && consumerFile) { - // Parse accessed keys from reason field: "fetch-url-match|keys:data,pagination|fetches:3" - let accessedKeys: string[] | undefined; - let fetchCount: number | undefined; - if (fetchReason) { - const keysMatch = fetchReason.match(/\|keys:([^|]+)/); - if (keysMatch) { - accessedKeys = keysMatch[1].split(',').filter((k) => k.length > 0); - } - const fetchesMatch = fetchReason.match(/\|fetches:(\d+)/); - if (fetchesMatch) { - fetchCount = parseInt(fetchesMatch[1], 10); - } - } - routeMap.get(id)!.consumers.push({ - name: consumerName, - filePath: consumerFile, - ...(accessedKeys ? { accessedKeys } : {}), - ...(fetchCount && fetchCount > 1 ? { fetchCount } : {}), - }); - } - } - - return [...routeMap.values()]; - } - - /** - * Batch-fetch execution flows linked to a set of Route or Tool nodes. - * Single query instead of N+1. - */ - private async fetchLinkedFlowsBatch( - repoId: string, - nodeIds: string[], - ): Promise> { - const result = new Map(); - if (nodeIds.length === 0) return result; - try { - // Use list_contains to filter at DB level instead of fetching all and filtering in memory - const rows = await executeParameterized( - repoId, - ` - MATCH (source)-[r:CodeRelation]->(proc:Process) - WHERE r.type = 'ENTRY_POINT_OF' - AND list_contains($nodeIds, source.id) - RETURN source.id AS sourceId, proc.label AS name - `, - { nodeIds }, - ); - for (const row of rows) { - const sourceId = row.sourceId ?? row[0]; - const name = row.name ?? row[1]; - if (!name) continue; - let list = result.get(sourceId); - if (!list) { - list = []; - result.set(sourceId, list); - } - list.push(name); - } - } catch { - /* no ENTRY_POINT_OF edges yet */ - } - return result; - } - - private async routeMap(repo: RepoHandle, params: { route?: string }): Promise { - await this.ensureInitialized(repo.id); - - const routeFilter = params.route ? `AND n.name CONTAINS $route` : ''; - const queryParams = params.route ? { route: params.route } : {}; - const routes = await this.fetchRoutesWithConsumers(repo.id, routeFilter, queryParams); - - if (routes.length === 0) { - return { - routes: [], - total: 0, - message: params.route - ? `No routes matching "${params.route}"` - : 'No routes found in this project.', - }; - } - - const flowMap = await this.fetchLinkedFlowsBatch( - repo.id, - routes.map((r) => r.id), - ); - - return { - routes: routes.map((r) => ({ - route: r.name, - handler: r.filePath, - middleware: r.middleware || [], - consumers: r.consumers, - flows: flowMap.get(r.id) || [], - git_namespace: r.git_namespace || null, - })), - total: routes.length, - }; - } - - private async shapeCheck(repo: RepoHandle, params: { route?: string }): Promise { - await this.ensureInitialized(repo.id); - - const routeFilter = params.route ? `AND n.name CONTAINS $route` : ''; - const queryParams = params.route ? { route: params.route } : {}; - const allRoutes = await this.fetchRoutesWithConsumers(repo.id, routeFilter, queryParams); - - const results = allRoutes - .filter( - (r) => - ((r.responseKeys && r.responseKeys.length > 0) || - (r.errorKeys && r.errorKeys.length > 0)) && - r.consumers.length > 0, - ) - .map((r) => { - // Keys already normalized by fetchRoutesWithConsumers (quotes stripped) - const responseKeys = r.responseKeys ?? []; - const errorKeys = r.errorKeys ?? []; - // Combined set: consumer accessing either success or error keys is valid - const allKnownKeys = new Set([...responseKeys, ...errorKeys]); - - // Check each consumer's accessed keys against the route's response shape - const responseKeySet = new Set(responseKeys); - const consumers = r.consumers.map((c) => { - if (!c.accessedKeys || c.accessedKeys.length === 0) { - return { name: c.name, filePath: c.filePath }; - } - const mismatched = c.accessedKeys.filter((k) => !allKnownKeys.has(k)); - // Keys in allKnownKeys but not in responseKeys — error-path access (e.g., .error from errorKeys) - const errorPathKeys = c.accessedKeys.filter( - (k) => allKnownKeys.has(k) && !responseKeySet.has(k), - ); - const isMultiFetch = (c.fetchCount ?? 1) > 1; - return { - name: c.name, - filePath: c.filePath, - accessedKeys: c.accessedKeys, - ...(mismatched.length > 0 - ? { - mismatched, - mismatchConfidence: isMultiFetch ? ('low' as const) : ('high' as const), - } - : {}), - ...(errorPathKeys.length > 0 ? { errorPathKeys } : {}), - ...(isMultiFetch - ? { - attributionNote: `This file fetches ${c.fetchCount} routes — accessed keys may belong to a different route.`, - } - : {}), - }; - }); - - const hasMismatches = consumers.some( - (c) => 'mismatched' in c && (c as any).mismatched.length > 0, - ); - - return { - route: r.name, - handler: r.filePath, - ...(responseKeys.length > 0 ? { responseKeys } : {}), - ...(errorKeys.length > 0 ? { errorKeys } : {}), - consumers, - ...(hasMismatches ? { status: 'MISMATCH' as const } : {}), - }; - }); - - const mismatchCount = results.filter((r) => r.status === 'MISMATCH').length; - - return { - routes: results, - total: results.length, - routesWithShapes: results.length, - ...(mismatchCount > 0 ? { mismatches: mismatchCount } : {}), - message: - results.length === 0 - ? 'No routes with both response shapes and consumers found.' - : mismatchCount > 0 - ? `Found ${results.length} route(s) with response shape data. ${mismatchCount} route(s) have consumer/shape mismatches.` - : `Found ${results.length} route(s) with response shape data and consumers.`, - }; - } - - private async toolMap(repo: RepoHandle, params: { tool?: string }): Promise { - await this.ensureInitialized(repo.id); - - const toolFilter = params.tool ? `AND n.name CONTAINS $tool` : ''; - const queryParams = params.tool ? { tool: params.tool } : {}; - - const rows = await executeWithNsGuard( - repo.id, - ` - MATCH (n:Tool) - WHERE n.id STARTS WITH 'Tool:' ${toolFilter} - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.description AS description, n.git_namespace AS git_namespace - `, - queryParams, - ); - - if (rows.length === 0) { - return { - tools: [], - total: 0, - message: params.tool ? `No tools matching "${params.tool}"` : 'No tool definitions found.', - }; - } - - const toolIds = rows.map((r: any) => r.id ?? r[0]); - const flowMap = await this.fetchLinkedFlowsBatch(repo.id, toolIds); - - return { - tools: rows.map((r: any) => { - const id = r.id ?? r[0]; - return { - name: r.name ?? r[1], - filePath: r.filePath ?? r[2], - description: (r.description ?? r[3] ?? '').slice(0, 200), - flows: flowMap.get(id) || [], - git_namespace: r.git_namespace ?? r[4] ?? null, - }; - }), - total: rows.length, - }; - } - - private async apiImpact( - repo: RepoHandle, - params: { route?: string; file?: string }, - ): Promise { - await this.ensureInitialized(repo.id); - - if (!params.route && !params.file) { - return { error: 'Either "route" or "file" parameter is required.' }; - } - - // If file is provided but route is not, look up the route by file path - let routeFilter = ''; - const queryParams: Record = {}; - - if (params.route) { - routeFilter = `AND n.name CONTAINS $route`; - queryParams.route = params.route; - } else if (params.file) { - routeFilter = `AND n.filePath CONTAINS $file`; - queryParams.file = params.file; - } - - const routes = await this.fetchRoutesWithConsumers(repo.id, routeFilter, queryParams); - - if (routes.length === 0) { - const target = params.route || params.file; - return { error: `No routes found matching "${target}".` }; - } - - const flowMap = await this.fetchLinkedFlowsBatch( - repo.id, - routes.map((r) => r.id), - ); - - // Count how many routes share the same handler file (for middleware partial detection) - const routeCountByHandler = new Map(); - for (const r of routes) { - if (r.filePath) { - routeCountByHandler.set(r.filePath, (routeCountByHandler.get(r.filePath) ?? 0) + 1); - } - } - - const results = routes.map((r) => { - // Keys already normalized by fetchRoutesWithConsumers (quotes stripped) - const responseKeys = r.responseKeys ?? []; - const errorKeys = r.errorKeys ?? []; - const allKnownKeys = new Set([...responseKeys, ...errorKeys]); - - // Build consumer list with mismatch detection - const consumers = r.consumers.map((c) => ({ - name: c.name, - file: c.filePath, - accesses: c.accessedKeys ?? [], - ...(c.fetchCount && c.fetchCount > 1 - ? { - attributionNote: `This file fetches ${c.fetchCount} routes — accessed keys may belong to a different route.`, - } - : {}), - })); - - // Detect mismatches: consumer accesses keys not in response shape - const mismatches: Array<{ - consumer: string; - field: string; - reason: string; - confidence: 'high' | 'low'; - }> = []; - if (allKnownKeys.size > 0) { - for (const c of r.consumers) { - if (!c.accessedKeys) continue; - const isMultiFetch = (c.fetchCount ?? 1) > 1; - for (const key of c.accessedKeys) { - if (!allKnownKeys.has(key)) { - mismatches.push({ - consumer: c.filePath, - field: key, - reason: 'accessed but not in response shape', - confidence: isMultiFetch ? 'low' : 'high', - }); - } - } - } - } - - const flows = flowMap.get(r.id) || []; - const consumerCount = r.consumers.length; - - // Risk level heuristic - let riskLevel: 'LOW' | 'MEDIUM' | 'HIGH'; - if (consumerCount >= 10) { - riskLevel = 'HIGH'; - } else if (consumerCount >= 4) { - riskLevel = 'MEDIUM'; - } else { - riskLevel = 'LOW'; - } - // Bump up one level if mismatches exist - if (mismatches.length > 0) { - if (riskLevel === 'LOW') riskLevel = 'MEDIUM'; - else if (riskLevel === 'MEDIUM') riskLevel = 'HIGH'; - } - - const warning = - consumerCount > 0 - ? `Changing response shape will affect ${consumerCount} component${consumerCount === 1 ? '' : 's'}` - : undefined; - - // Flag when middleware was detected but handler exports multiple HTTP methods - // (middleware chain may only reflect one export) - const middlewareArr = r.middleware || []; - const handlerRouteCount = r.filePath ? (routeCountByHandler.get(r.filePath) ?? 1) : 1; - const middlewarePartial = middlewareArr.length > 0 && handlerRouteCount > 1; - - return { - route: r.name, - handler: r.filePath, - responseShape: { - success: responseKeys, - error: errorKeys, - }, - middleware: middlewareArr, - ...(middlewarePartial - ? { - middlewareDetection: 'partial' as const, - middlewareNote: - 'Middleware captured from first HTTP method export only — other methods in this handler may use different middleware chains.', - } - : {}), - consumers, - ...(mismatches.length > 0 ? { mismatches } : {}), - executionFlows: flows, - impactSummary: { - directConsumers: consumerCount, - affectedFlows: flows.length, - riskLevel, - ...(warning ? { warning } : {}), - }, - }; - }); - - // If a single route was targeted, return it directly (not wrapped in array) - if (results.length === 1) { - return results[0]; - } - - return { routes: results, total: results.length }; - } - - // ─── Direct Graph Queries (for resources.ts) ──────────────────── - - /** - * Query clusters (communities) directly from graph. - * Used by getClustersResource — avoids legacy overview() dispatch. - */ - async queryClusters(repoName?: string, limit = 100): Promise<{ clusters: any[] }> { - const repo = await this.resolveRepo(repoName); - await this.ensureInitialized(repo.id); - - try { - const rawLimit = Math.max(limit * 5, 200); - const clusters = await executeQuery( - repo.id, - ` - MATCH (c:Community) - RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount - ORDER BY c.symbolCount DESC - LIMIT ${rawLimit} - `, - ); - const rawClusters = clusters.map((c: any) => ({ - id: c.id || c[0], - label: c.label || c[1], - heuristicLabel: c.heuristicLabel || c[2], - cohesion: c.cohesion || c[3], - symbolCount: c.symbolCount || c[4], - })); - return { clusters: this.aggregateClusters(rawClusters).slice(0, limit) }; - } catch { - return { clusters: [] }; - } - } - - /** - * Query processes directly from graph. - * Used by getProcessesResource — avoids legacy overview() dispatch. - */ - async queryProcesses(repoName?: string, limit = 50): Promise<{ processes: any[] }> { - const repo = await this.resolveRepo(repoName); - await this.ensureInitialized(repo.id); - - try { - const processes = await executeQuery( - repo.id, - ` - MATCH (p:Process) - RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount - ORDER BY p.stepCount DESC - LIMIT ${limit} - `, - ); - return { - processes: processes.map((p: any) => ({ - id: p.id || p[0], - label: p.label || p[1], - heuristicLabel: p.heuristicLabel || p[2], - processType: p.processType || p[3], - stepCount: p.stepCount || p[4], - })), - }; - } catch { - return { processes: [] }; - } - } - - /** - * Query cluster detail (members) directly from graph. - * Used by getClusterDetailResource. - */ - async queryClusterDetail(name: string, repoName?: string): Promise { - const repo = await this.resolveRepo(repoName); - await this.ensureInitialized(repo.id); - - const clusters = await executeParameterized( - repo.id, - ` - MATCH (c:Community) - WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName - RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount - `, - { clusterName: name }, - ); - if (clusters.length === 0) return { error: `Cluster '${name}' not found` }; - - const rawClusters = clusters.map((c: any) => ({ - id: c.id || c[0], - label: c.label || c[1], - heuristicLabel: c.heuristicLabel || c[2], - cohesion: c.cohesion || c[3], - symbolCount: c.symbolCount || c[4], - })); - - let totalSymbols = 0, - weightedCohesion = 0; - for (const c of rawClusters) { - const s = c.symbolCount || 0; - totalSymbols += s; - weightedCohesion += (c.cohesion || 0) * s; - } - - const members = await executeWithNsGuard( - repo.id, - ` - MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName - RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.git_namespace AS git_namespace - LIMIT 30 - `, - { clusterName: name }, - ); - - return { - cluster: { - id: rawClusters[0].id, - label: rawClusters[0].heuristicLabel || rawClusters[0].label, - heuristicLabel: rawClusters[0].heuristicLabel || rawClusters[0].label, - cohesion: totalSymbols > 0 ? weightedCohesion / totalSymbols : 0, - symbolCount: totalSymbols, - subCommunities: rawClusters.length, - }, - members: members.map((m: any) => ({ - name: m.name || m[0], - type: m.type || m[1], - filePath: m.filePath || m[2], - git_namespace: m.git_namespace || m[3] || null, - })), - }; - } - - /** - * Query process detail (steps) directly from graph. - * Used by getProcessDetailResource. - */ - async queryProcessDetail(name: string, repoName?: string): Promise { - const repo = await this.resolveRepo(repoName); - await this.ensureInitialized(repo.id); - - const processes = await executeParameterized( - repo.id, - ` - MATCH (p:Process) - WHERE p.label = $processName OR p.heuristicLabel = $processName - RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount - LIMIT 1 - `, - { processName: name }, - ); - if (processes.length === 0) return { error: `Process '${name}' not found` }; - - const proc = processes[0]; - const procId = proc.id || proc[0]; - const steps = await executeWithNsGuard( - repo.id, - ` - MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId}) - RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step, n.git_namespace AS git_namespace - ORDER BY r.step - `, - { procId }, - ); - - return { - process: { - id: procId, - label: proc.label || proc[1], - heuristicLabel: proc.heuristicLabel || proc[2], - processType: proc.processType || proc[3], - stepCount: proc.stepCount || proc[4], - }, - steps: steps.map((s: any) => ({ - step: s.step || s[3], - name: s.name || s[0], - type: s.type || s[1], - filePath: s.filePath || s[2], - git_namespace: s.git_namespace || s[4] || null, - })), - }; - } - - async disconnect(): Promise { - await closeLbug(); // close all connections - // Note: we intentionally do NOT call disposeEmbedder() here. - // ONNX Runtime's native cleanup segfaults on macOS and some Linux configs, - // and importing the embedder module on Node v24+ crashes if onnxruntime - // was never loaded during the session. Since process.exit(0) follows - // immediately after disconnect(), the OS reclaims everything. See #38, #89. - this.repos.clear(); - this.contextCache.clear(); - this.initializedRepos.clear(); - } -} diff --git a/gitnexus/src/mcp/local/local-backend.ts.bak_batch2 b/gitnexus/src/mcp/local/local-backend.ts.bak_batch2 deleted file mode 100644 index 8ae50e74e..000000000 --- a/gitnexus/src/mcp/local/local-backend.ts.bak_batch2 +++ /dev/null @@ -1,3311 +0,0 @@ -/** - * Local Backend (Multi-Repo) - * - * Provides tool implementations using local .gitnexus/ indexes. - * Supports multiple indexed repositories via a global registry. - * LadybugDB connections are opened lazily per repo on first query. - */ - -import fs from 'fs/promises'; -import path from 'path'; -import { - initLbug, - executeQuery, - executeParameterized, - closeLbug, - isLbugReady, - isWriteQuery, -} from '../../core/lbug/pool-adapter.js'; -export { isWriteQuery }; -// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node -// at MCP server startup — crashes on unsupported Node ABI versions (#89) -// git utilities available if needed -// import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js'; -import { - listRegisteredRepos, - cleanupOldKuzuFiles, - type RegistryEntry, -} from '../../storage/repo-manager.js'; -import { GroupService, type GroupToolPort } from '../../core/group/service.js'; -// AI context generation is CLI-only (gitnexus analyze) -// import { generateAIContextFiles } from '../../cli/ai-context.js'; - -/** - * Quick test-file detection for filtering impact results. - * Matches common test file patterns across all supported languages. - */ -export function isTestFilePath(filePath: string): boolean { - const p = filePath.toLowerCase().replace(/\\/g, '/'); - return ( - p.includes('.test.') || - p.includes('.spec.') || - p.includes('__tests__/') || - p.includes('__mocks__/') || - p.includes('/test/') || - p.includes('/tests/') || - p.includes('/testing/') || - p.includes('/fixtures/') || - p.endsWith('_test.go') || - p.endsWith('_test.py') || - p.endsWith('_spec.rb') || - p.endsWith('_test.rb') || - p.includes('/spec/') || - p.includes('/test_') || - p.includes('/conftest.') - ); -} - -/** Valid LadybugDB node labels for safe Cypher query construction */ -export const VALID_NODE_LABELS = new Set([ - 'File', - 'Folder', - 'Function', - 'Class', - 'Interface', - 'Method', - 'CodeElement', - 'Community', - 'Process', - 'Struct', - 'Enum', - 'Macro', - 'Typedef', - 'Union', - 'Namespace', - 'Trait', - 'Impl', - 'TypeAlias', - 'Const', - 'Static', - 'Property', - 'Record', - 'Delegate', - 'Annotation', - 'Constructor', - 'Template', - 'Module', - 'Route', - 'Tool', -]); - -/** Valid relation types for impact analysis filtering */ -export const VALID_RELATION_TYPES = new Set([ - 'CALLS', - 'IMPORTS', - 'EXTENDS', - 'IMPLEMENTS', - 'HAS_METHOD', - 'HAS_PROPERTY', - 'OVERRIDES', - 'ACCESSES', - 'HANDLES_ROUTE', - 'FETCHES', - 'HANDLES_TOOL', - 'ENTRY_POINT_OF', - 'WRAPS', - 'CONTAINS', - 'DEFINES', -]); - -/** - * Per-relation-type confidence floor for impact analysis. - * - * When the graph stores a relation with a confidence value, that stored - * value is used as-is (it reflects resolution-tier accuracy from analysis - * time). This map provides the floor for each edge type when no stored - * confidence is available, and is also used for display / tooltip hints. - * - * Rationale: - * CALLS / IMPORTS – direct, strongly-typed references → 0.9 - * EXTENDS – class hierarchy, statically verifiable → 0.85 - * IMPLEMENTS – interface contract, statically verifiable → 0.85 - * OVERRIDES – method override, statically verifiable → 0.85 - * HAS_METHOD – structural containment → 0.95 - * HAS_PROPERTY – structural containment → 0.95 - * ACCESSES – field read/write, may be indirect → 0.8 - * CONTAINS – folder/file containment → 0.95 - * (unknown type) – conservative fallback → 0.5 - */ -export const IMPACT_RELATION_CONFIDENCE: Readonly> = { - CALLS: 0.9, - IMPORTS: 0.9, - EXTENDS: 0.85, - IMPLEMENTS: 0.85, - OVERRIDES: 0.85, - HAS_METHOD: 0.95, - HAS_PROPERTY: 0.95, - ACCESSES: 0.8, - CONTAINS: 0.95, -}; - -/** - * Return the confidence floor for a given relation type. - * Falls back to 0.5 for unknown types so they are not silently elevated. - */ -const confidenceForRelType = (relType: string | undefined): number => - IMPACT_RELATION_CONFIDENCE[relType ?? ''] ?? 0.5; - -/** Structured error logging for query failures — replaces empty catch blocks */ -function logQueryError(context: string, err: unknown): void { - const msg = err instanceof Error ? err.message : String(err); - console.error(`GitNexus [${context}]: ${msg}`); -} - -export interface CodebaseContext { - projectName: string; - stats: { - fileCount: number; - functionCount: number; - communityCount: number; - processCount: number; - }; -} - -interface RepoHandle { - id: string; // unique key = repo name (basename) - name: string; - repoPath: string; - storagePath: string; - lbugPath: string; - indexedAt: string; - lastCommit: string; - stats?: RegistryEntry['stats']; -} - -export class LocalBackend { - private repos: Map = new Map(); - private contextCache: Map = new Map(); - private initializedRepos: Set = new Set(); - private reinitPromises: Map> = new Map(); - private lastStalenessCheck: Map = new Map(); - private groupToolSvc: GroupService | null = null; - - /** - * Cross-repo group tools (CLI). Shares logic with MCP `group_*` handlers. - */ - getGroupService(): GroupService { - if (!this.groupToolSvc) { - const port: GroupToolPort = { - resolveRepo: (p) => this.resolveRepo(p), - impact: (r, p) => this.impact(r as RepoHandle, p), - query: (r, p) => this.query(r as RepoHandle, p), - impactByUid: (id, uid, d, o) => this.impactByUid(id, uid, d, o), - }; - this.groupToolSvc = new GroupService(port); - } - return this.groupToolSvc; - } - - /** Close all pooled LadybugDB connections (CLI one-shot; optional for long-lived MCP). */ - async dispose(): Promise { - await closeLbug(); - } - - // ─── Initialization ────────────────────────────────────────────── - - /** - * Initialize from the global registry. - * Returns true if at least one repo is available. - */ - async init(): Promise { - await this.refreshRepos(); - return this.repos.size > 0; - } - - /** - * Re-read the global registry and update the in-memory repo map. - * New repos are added, existing repos are updated, removed repos are pruned. - * LadybugDB connections for removed repos are NOT closed (they idle-timeout naturally). - */ - private async refreshRepos(): Promise { - const entries = await listRegisteredRepos({ validate: true }); - const freshIds = new Set(); - - for (const entry of entries) { - const id = this.repoId(entry.name, entry.path); - freshIds.add(id); - - const storagePath = entry.storagePath; - const lbugPath = path.join(storagePath, 'lbug'); - - // Clean up any leftover KuzuDB files from before the LadybugDB migration. - // If kuzu exists but lbug doesn't, warn so the user knows to re-analyze. - const kuzu = await cleanupOldKuzuFiles(storagePath); - if (kuzu.found && kuzu.needsReindex) { - console.error( - `GitNexus: "${entry.name}" has a stale KuzuDB index. Run: gitnexus analyze ${entry.path}`, - ); - } - - const handle: RepoHandle = { - id, - name: entry.name, - repoPath: entry.path, - storagePath, - lbugPath, - indexedAt: entry.indexedAt, - lastCommit: entry.lastCommit, - stats: entry.stats, - }; - - this.repos.set(id, handle); - - // Build lightweight context (no LadybugDB needed) - const s = entry.stats || {}; - this.contextCache.set(id, { - projectName: entry.name, - stats: { - fileCount: s.files || 0, - functionCount: s.nodes || 0, - communityCount: s.communities || 0, - processCount: s.processes || 0, - }, - }); - } - - // Prune repos that no longer exist in the registry - for (const id of this.repos.keys()) { - if (!freshIds.has(id)) { - this.repos.delete(id); - this.contextCache.delete(id); - this.initializedRepos.delete(id); - } - } - } - - /** - * Generate a stable repo ID from name + path. - * If names collide, append a hash of the path. - */ - private repoId(name: string, repoPath: string): string { - const base = name.toLowerCase(); - // Check for name collision with a different path - for (const [id, handle] of this.repos) { - if (id === base && handle.repoPath !== path.resolve(repoPath)) { - // Collision — use path hash - const hash = Buffer.from(repoPath).toString('base64url').slice(0, 6); - return `${base}-${hash}`; - } - } - return base; - } - - // ─── Repo Resolution ───────────────────────────────────────────── - - /** - * Resolve which repo to use. - * - If repoParam is given, match by name or path - * - If only 1 repo, use it - * - If 0 or multiple without param, throw with helpful message - * - * On a miss, re-reads the registry once in case a new repo was indexed - * while the MCP server was running. - */ - async resolveRepo(repoParam?: string): Promise { - const result = this.resolveRepoFromCache(repoParam); - if (result) return result; - - // Miss — refresh registry and try once more - await this.refreshRepos(); - const retried = this.resolveRepoFromCache(repoParam); - if (retried) return retried; - - // Still no match — throw with helpful message - if (this.repos.size === 0) { - throw new Error('No indexed repositories. Run: gitnexus analyze'); - } - if (repoParam) { - const names = [...this.repos.values()].map((h) => h.name); - throw new Error(`Repository "${repoParam}" not found. Available: ${names.join(', ')}`); - } - const names = [...this.repos.values()].map((h) => h.name); - throw new Error( - `Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${names.join(', ')}`, - ); - } - - /** - * Try to resolve a repo from the in-memory cache. Returns null on miss. - */ - private resolveRepoFromCache(repoParam?: string): RepoHandle | null { - if (this.repos.size === 0) return null; - - if (repoParam) { - const paramLower = repoParam.toLowerCase(); - // Match by id - if (this.repos.has(paramLower)) return this.repos.get(paramLower)!; - // Match by name (case-insensitive) - for (const handle of this.repos.values()) { - if (handle.name.toLowerCase() === paramLower) return handle; - } - // Match by path (substring) - const resolved = path.resolve(repoParam); - for (const handle of this.repos.values()) { - if (handle.repoPath === resolved) return handle; - } - // Match by partial name - for (const handle of this.repos.values()) { - if (handle.name.toLowerCase().includes(paramLower)) return handle; - } - return null; - } - - if (this.repos.size === 1) { - return this.repos.values().next().value!; - } - - return null; // Multiple repos, no param — ambiguous - } - - // ─── Lazy LadybugDB Init ──────────────────────────────────────────── - - private async ensureInitialized(repoId: string): Promise { - // If a reinit is already in progress for this repo, wait for it - const pending = this.reinitPromises.get(repoId); - if (pending) return pending; - - const handle = this.repos.get(repoId); - if (!handle) throw new Error(`Unknown repo: ${repoId}`); - - // Check if the index was rebuilt since we opened the connection (#297). - // Throttle staleness checks to at most once per 5 seconds per repo to - // avoid an fs.readFile round-trip on every tool invocation. - if (this.initializedRepos.has(repoId) && isLbugReady(repoId)) { - const now = Date.now(); - const lastCheck = this.lastStalenessCheck.get(repoId) ?? 0; - if (now - lastCheck < 5000) return; // Checked recently — skip - - this.lastStalenessCheck.set(repoId, now); - try { - const metaPath = path.join(handle.storagePath, 'meta.json'); - const metaRaw = await fs.readFile(metaPath, 'utf-8'); - const meta = JSON.parse(metaRaw); - if (meta.indexedAt && meta.indexedAt !== handle.indexedAt) { - // Index was rebuilt — close stale connection and re-init. - // Wrap in reinitPromises to prevent TOCTOU race where concurrent - // callers both detect staleness and double-close the pool. - const reinit = (async () => { - try { - await closeLbug(repoId); - this.initializedRepos.delete(repoId); - handle.indexedAt = meta.indexedAt; - await initLbug(repoId, handle.lbugPath); - this.initializedRepos.add(repoId); - } finally { - this.reinitPromises.delete(repoId); - } - })(); - this.reinitPromises.set(repoId, reinit); - return reinit; - } else { - return; // Pool is current - } - } catch { - return; // Can't read meta — assume pool is fine - } - } - - try { - await initLbug(repoId, handle.lbugPath); - this.initializedRepos.add(repoId); - } catch (err: any) { - // If lock error, mark as not initialized so next call retries - this.initializedRepos.delete(repoId); - throw err; - } - } - - // ─── Public Getters ────────────────────────────────────────────── - - /** - * Get context for a specific repo (or the single repo if only one). - */ - getContext(repoId?: string): CodebaseContext | null { - if (repoId && this.contextCache.has(repoId)) { - return this.contextCache.get(repoId)!; - } - if (this.repos.size === 1) { - return this.contextCache.values().next().value ?? null; - } - return null; - } - - /** - * List all registered repos with their metadata. - * Re-reads the global registry so newly indexed repos are discovered - * without restarting the MCP server. - */ - async listRepos(): Promise< - Array<{ name: string; path: string; indexedAt: string; lastCommit: string; stats?: any }> - > { - await this.refreshRepos(); - return [...this.repos.values()].map((h) => ({ - name: h.name, - path: h.repoPath, - indexedAt: h.indexedAt, - lastCommit: h.lastCommit, - stats: h.stats, - })); - } - - // ─── Tool Dispatch ─────────────────────────────────────────────── - - async callTool(method: string, params: any): Promise { - if (method === 'list_repos') { - return this.listRepos(); - } - - if (method.startsWith('group_')) { - return this.handleGroupTool(method, params || {}); - } - - // Resolve repo from optional param (re-reads registry on miss) - const repo = await this.resolveRepo(params?.repo); - - switch (method) { - case 'query': - return this.query(repo, params); - case 'cypher': { - const raw = await this.cypher(repo, params); - return this.formatCypherAsMarkdown(raw); - } - case 'context': - return this.context(repo, params); - case 'impact': - return this.impact(repo, params); - case 'detect_changes': - return this.detectChanges(repo, params); - case 'rename': - return this.rename(repo, params); - // Legacy aliases for backwards compatibility - case 'search': - return this.query(repo, params); - case 'explore': - return this.context(repo, { name: params?.name, ...params }); - case 'overview': - return this.overview(repo, params); - case 'route_map': - return this.routeMap(repo, params); - case 'shape_check': - return this.shapeCheck(repo, params); - case 'tool_map': - return this.toolMap(repo, params); - case 'api_impact': - return this.apiImpact(repo, params); - default: - throw new Error(`Unknown tool: ${method}`); - } - } - - // ─── Tool Implementations ──────────────────────────────────────── - - /** - * Query tool — process-grouped search. - * - * 1. Hybrid search (BM25 + semantic) to find matching symbols - * 2. Trace each match to its process(es) via STEP_IN_PROCESS - * 3. Group by process, rank by aggregate relevance + internal cluster cohesion - * 4. Return: { processes, process_symbols, definitions } - */ - private async query( - repo: RepoHandle, - params: { - query: string; - task_context?: string; - goal?: string; - limit?: number; - max_symbols?: number; - include_content?: boolean; - git_namespace?: string; - }, - ): Promise { - if (!params.query?.trim()) { - return { error: 'query parameter is required and cannot be empty.' }; - } - - await this.ensureInitialized(repo.id); - - const processLimit = params.limit || 5; - const maxSymbolsPerProcess = params.max_symbols || 10; - const includeContent = params.include_content ?? false; - const searchQuery = params.query.trim(); - - const gitNamespace = params.git_namespace; - - // Step 1: Run hybrid search to get matching symbols - const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results - const [bm25SearchResult, semanticResults] = await Promise.all([ - this.bm25Search(repo, searchQuery, searchLimit, gitNamespace), - this.semanticSearch(repo, searchQuery, searchLimit, gitNamespace), - ]); - - const bm25Results = bm25SearchResult.results; - const ftsUsed = bm25SearchResult.ftsUsed; - - // Merge via reciprocal rank fusion - const scoreMap = new Map(); - - for (let i = 0; i < bm25Results.length; i++) { - const result = bm25Results[i]; - const key = result.nodeId || result.filePath; - const rrfScore = 1 / (60 + i); - const existing = scoreMap.get(key); - if (existing) { - existing.score += rrfScore; - } else { - scoreMap.set(key, { score: rrfScore, data: result }); - } - } - - for (let i = 0; i < semanticResults.length; i++) { - const result = semanticResults[i]; - const key = result.nodeId || result.filePath; - const rrfScore = 1 / (60 + i); - const existing = scoreMap.get(key); - if (existing) { - existing.score += rrfScore; - } else { - scoreMap.set(key, { score: rrfScore, data: result }); - } - } - - const merged = Array.from(scoreMap.entries()) - .sort((a, b) => b[1].score - a[1].score) - .slice(0, searchLimit); - - // Step 2: For each match with a nodeId, trace to process(es) - const processMap = new Map< - string, - { - id: string; - label: string; - heuristicLabel: string; - processType: string; - stepCount: number; - totalScore: number; - cohesionBoost: number; - symbols: any[]; - } - >(); - const definitions: any[] = []; // standalone symbols not in any process - - for (const [_, item] of merged) { - const sym = item.data; - if (!sym.nodeId) { - // File-level results go to definitions - definitions.push({ - name: sym.name, - type: sym.type || 'File', - filePath: sym.filePath, - }); - continue; - } - - // Find processes this symbol participates in - let processRows: any[] = []; - try { - processRows = await executeParameterized( - repo.id, - ` - MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - RETURN p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step - `, - { nodeId: sym.nodeId }, - ); - } catch (e) { - logQueryError('query:process-lookup', e); - } - - // Get cluster membership + cohesion (cohesion used as internal ranking signal) - let cohesion = 0; - let module: string | undefined; - try { - const cohesionRows = await executeParameterized( - repo.id, - ` - MATCH (n {id: $nodeId})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - RETURN c.cohesion AS cohesion, c.heuristicLabel AS module - LIMIT 1 - `, - { nodeId: sym.nodeId }, - ); - if (cohesionRows.length > 0) { - cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0; - module = cohesionRows[0].module ?? cohesionRows[0][1]; - } - } catch (e) { - logQueryError('query:cluster-info', e); - } - - // Optionally fetch content - let content: string | undefined; - if (includeContent) { - try { - const contentRows = await executeParameterized( - repo.id, - ` - MATCH (n {id: $nodeId}) - RETURN n.content AS content - `, - { nodeId: sym.nodeId }, - ); - if (contentRows.length > 0) { - content = contentRows[0].content ?? contentRows[0][0]; - } - } catch (e) { - logQueryError('query:content-fetch', e); - } - } - - const symbolEntry = { - id: sym.nodeId, - name: sym.name, - type: sym.type, - filePath: sym.filePath, - startLine: sym.startLine, - endLine: sym.endLine, - ...(module ? { module } : {}), - ...(includeContent && content ? { content } : {}), - }; - - if (processRows.length === 0) { - // Symbol not in any process — goes to definitions - definitions.push(symbolEntry); - } else { - // Add to each process it belongs to - for (const row of processRows) { - const pid = row.pid ?? row[0]; - const label = row.label ?? row[1]; - const hLabel = row.heuristicLabel ?? row[2]; - const pType = row.processType ?? row[3]; - const stepCount = row.stepCount ?? row[4]; - const step = row.step ?? row[5]; - - if (!processMap.has(pid)) { - processMap.set(pid, { - id: pid, - label, - heuristicLabel: hLabel, - processType: pType, - stepCount, - totalScore: 0, - cohesionBoost: 0, - symbols: [], - }); - } - - const proc = processMap.get(pid)!; - proc.totalScore += item.score; - proc.cohesionBoost = Math.max(proc.cohesionBoost, cohesion); - proc.symbols.push({ - ...symbolEntry, - process_id: pid, - step_index: step, - }); - } - } - } - - // Step 3: Rank processes by aggregate score + internal cohesion boost - const rankedProcesses = Array.from(processMap.values()) - .map((p) => ({ - ...p, - priority: p.totalScore + p.cohesionBoost * 0.1, // cohesion as subtle ranking signal - })) - .sort((a, b) => b.priority - a.priority) - .slice(0, processLimit); - - // Step 4: Build response - const processes = rankedProcesses.map((p) => ({ - id: p.id, - summary: p.heuristicLabel || p.label, - priority: Math.round(p.priority * 1000) / 1000, - symbol_count: p.symbols.length, - process_type: p.processType, - step_count: p.stepCount, - })); - - const processSymbols = rankedProcesses.flatMap((p) => - p.symbols.slice(0, maxSymbolsPerProcess).map((s) => ({ - ...s, - // remove internal fields - })), - ); - - // Deduplicate process_symbols by id - const seen = new Set(); - const dedupedSymbols = processSymbols.filter((s) => { - if (seen.has(s.id)) return false; - seen.add(s.id); - return true; - }); - - // Build namespace_hint when git_namespace is NOT specified and results span multiple namespaces - let namespace_hint = null; - if (!gitNamespace) { - try { - // Collect git_namespace from all result symbols - const idListArr = [ - ...dedupedSymbols.filter((s: any) => s.id).map((s: any) => s.id), - ...definitions.filter((d: any) => d.id).map((d: any) => d.id) - ]; - const uniqueNodeIds = Array.from(new Set(idListArr)); - if (uniqueNodeIds.length > 0) { - const idList = uniqueNodeIds.map((id: string) => `'${id.replace(/'/g, "''")}'`).join(', '); - const nsRows = await executeQuery( - repo.id, - `MATCH (n) WHERE n.id IN [${idList}] RETURN DISTINCT n.git_namespace AS ns`, - ); - const uniqueNs = new Set(); - for (const row of nsRows) { - const ns = row.ns ?? row[0]; - if (ns) uniqueNs.add(ns); - } - if (uniqueNs.size > 1) { - // Fetch all available namespaces from graph - const allNsRows = await executeQuery( - repo.id, - `MATCH (n) WHERE n.git_namespace IS NOT NULL RETURN DISTINCT n.git_namespace AS ns, COUNT(*) AS cnt`, - ); - const resultsByNamespace: Record = {}; - for (const ns of uniqueNs) { - const allItems = [...dedupedSymbols, ...definitions]; - resultsByNamespace[ns] = allItems.filter( - (s: any) => bm25Results.some((b: any) => b.filePath === s.filePath) || - semanticResults.some((sr: any) => sr.nodeId === s.id) - ).length; - } - // If length is 0 (due to some disjoint between the graph and node IDs), provide a fallback - if (Object.values(resultsByNamespace).some(v => v === 0)) { - uniqueNs.forEach(ns => { - if (resultsByNamespace[ns] === 0) resultsByNamespace[ns] = 1; - }); - } - namespace_hint = { - warning: `Results span ${uniqueNs.size} git-namespaces. Narrow with git_namespace parameter for precise results.`, - available_namespaces: allNsRows.map((row: any) => ({ - name: row.ns ?? row[0], - node_count: row.cnt ?? row[1] ?? 0, - })), - results_by_namespace: resultsByNamespace, - }; - } - } - } catch { - // Namespace hint is best-effort — do not fail the query - } - } - - return { - processes, - process_symbols: dedupedSymbols, - definitions: definitions.slice(0, 20), // cap standalone definitions - ...(!ftsUsed && { - warning: - 'FTS extension unavailable - keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.', - }), - ...(namespace_hint && { namespace_hint }), - }; - } - - /** - * BM25 keyword search helper - uses LadybugDB FTS for always-fresh results - */ - private async bm25Search( - repo: RepoHandle, - query: string, - limit: number, - gitNamespace?: string, - ): Promise<{ results: any[]; ftsUsed: boolean }> { - const { searchFTSFromLbug } = await import('../../core/search/bm25-index.js'); - let bm25Results; - try { - bm25Results = await searchFTSFromLbug(query, limit, repo.id, gitNamespace); - } catch (err: any) { - console.error('GitNexus: BM25/FTS search failed (FTS indexes may not exist) -', err.message); - return { results: [], ftsUsed: false }; - } - - const ftsUsed = bm25Results.length === 0 || bm25Results[0]?.ftsUsed !== false; - - const results: any[] = []; - - for (const bm25Result of bm25Results) { - const fullPath = bm25Result.filePath; - try { - const symbols = await executeParameterized( - repo.id, - ` - MATCH (n) - WHERE n.filePath = $filePath - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine - LIMIT 3 - `, - { filePath: fullPath }, - ); - - if (symbols.length > 0) { - for (const sym of symbols) { - results.push({ - nodeId: sym.id || sym[0], - name: sym.name || sym[1], - type: sym.type || sym[2], - filePath: sym.filePath || sym[3], - startLine: sym.startLine || sym[4], - endLine: sym.endLine || sym[5], - bm25Score: bm25Result.score, - }); - } - } else { - const fileName = fullPath.split('/').pop() || fullPath; - results.push({ - name: fileName, - type: 'File', - filePath: bm25Result.filePath, - bm25Score: bm25Result.score, - }); - } - } catch { - const fileName = fullPath.split('/').pop() || fullPath; - results.push({ - name: fileName, - type: 'File', - filePath: bm25Result.filePath, - bm25Score: bm25Result.score, - }); - } - } - - return { results, ftsUsed }; - } - - /** - * Semantic vector search helper - */ - private async semanticSearch(repo: RepoHandle, query: string, limit: number, gitNamespace?: string): Promise { - try { - // Check if embedding table exists before loading the model (avoids heavy model init when embeddings are off) - const tableCheck = await executeQuery( - repo.id, - `MATCH (e:CodeEmbedding) RETURN COUNT(*) AS cnt LIMIT 1`, - ); - if (!tableCheck.length || (tableCheck[0].cnt ?? tableCheck[0][0]) === 0) return []; - - const { embedQuery, getEmbeddingDims } = await import('../core/embedder.js'); - const queryVec = await embedQuery(query); - const dims = getEmbeddingDims(); - const queryVecStr = `[${queryVec.join(',')}]`; - // Over-fetch when filtering by namespace - const fetchLimit = gitNamespace ? limit * 3 : limit; - - const vectorQuery = ` - CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', - CAST(${queryVecStr} AS FLOAT[${dims}]), ${fetchLimit}) - YIELD node AS emb, distance - WITH emb, distance - WHERE distance < 0.6 - RETURN emb.nodeId AS nodeId, distance - ORDER BY distance - `; - - const embResults = await executeQuery(repo.id, vectorQuery); - - if (embResults.length === 0) return []; - - const results: any[] = []; - - for (const embRow of embResults) { - const nodeId = embRow.nodeId ?? embRow[0]; - const distance = embRow.distance ?? embRow[1]; - - const labelEndIdx = nodeId.indexOf(':'); - const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown'; - - // Validate label against known node types to prevent Cypher injection - if (!VALID_NODE_LABELS.has(label)) continue; - - try { - const nodeQuery = - label === 'File' - ? `MATCH (n:File {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath, n.git_namespace AS git_namespace` - : `MATCH (n:\`${label}\` {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.git_namespace AS git_namespace`; - - const nodeRows = await executeParameterized(repo.id, nodeQuery, { nodeId }); - if (nodeRows.length > 0) { - const nodeRow = nodeRows[0]; - // Post-filter by git_namespace if specified - const nodeNs = nodeRow.git_namespace ?? (label === 'File' ? nodeRow[2] : nodeRow[4]) ?? ''; - if (gitNamespace && nodeNs !== gitNamespace) continue; - - results.push({ - nodeId, - name: nodeRow.name ?? nodeRow[0] ?? '', - type: label, - filePath: nodeRow.filePath ?? nodeRow[1] ?? '', - distance, - startLine: label !== 'File' ? (nodeRow.startLine ?? nodeRow[2]) : undefined, - endLine: label !== 'File' ? (nodeRow.endLine ?? nodeRow[3]) : undefined, - }); - } - } catch {} - } - - // Trim to requested limit after filtering - return results.slice(0, limit); - } catch { - // Expected when embeddings are disabled — silently fall back to BM25-only - return []; - } - } - - async executeCypher(repoName: string, query: string): Promise { - const repo = await this.resolveRepo(repoName); - return this.cypher(repo, { query }); - } - - private async cypher(repo: RepoHandle, params: { query: string }): Promise { - await this.ensureInitialized(repo.id); - - if (!isLbugReady(repo.id)) { - return { error: 'LadybugDB not ready. Index may be corrupted.' }; - } - - // Block write operations (defense-in-depth — DB is already read-only) - if (isWriteQuery(params.query)) { - return { - error: - 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.', - }; - } - - try { - const result = await executeQuery(repo.id, params.query); - return result; - } catch (err: any) { - return { error: err.message || 'Query failed' }; - } - } - - /** - * Format raw Cypher result rows as a markdown table for LLM readability. - * Falls back to raw result if rows aren't tabular objects. - */ - private formatCypherAsMarkdown(result: any): any { - if (!Array.isArray(result) || result.length === 0) return result; - - const firstRow = result[0]; - if (typeof firstRow !== 'object' || firstRow === null) return result; - - const keys = Object.keys(firstRow); - if (keys.length === 0) return result; - - const header = '| ' + keys.join(' | ') + ' |'; - const separator = '| ' + keys.map(() => '---').join(' | ') + ' |'; - const dataRows = result.map( - (row: any) => - '| ' + - keys - .map((k) => { - const v = row[k]; - if (v === null || v === undefined) return ''; - if (typeof v === 'object') return JSON.stringify(v); - return String(v); - }) - .join(' | ') + - ' |', - ); - - return { - markdown: [header, separator, ...dataRows].join('\n'), - row_count: result.length, - }; - } - - /** - * Aggregate same-named clusters: group by heuristicLabel, sum symbols, - * weighted-average cohesion, filter out tiny clusters (<5 symbols). - * Raw communities stay intact in LadybugDB for Cypher queries. - */ - private aggregateClusters(clusters: any[]): any[] { - const groups = new Map< - string, - { ids: string[]; totalSymbols: number; weightedCohesion: number; largest: any } - >(); - - for (const c of clusters) { - const label = c.heuristicLabel || c.label || 'Unknown'; - const symbols = c.symbolCount || 0; - const cohesion = c.cohesion || 0; - const existing = groups.get(label); - - if (!existing) { - groups.set(label, { - ids: [c.id], - totalSymbols: symbols, - weightedCohesion: cohesion * symbols, - largest: c, - }); - } else { - existing.ids.push(c.id); - existing.totalSymbols += symbols; - existing.weightedCohesion += cohesion * symbols; - if (symbols > (existing.largest.symbolCount || 0)) { - existing.largest = c; - } - } - } - - return Array.from(groups.entries()) - .map(([label, g]) => ({ - id: g.largest.id, - label, - heuristicLabel: label, - symbolCount: g.totalSymbols, - cohesion: g.totalSymbols > 0 ? g.weightedCohesion / g.totalSymbols : 0, - subCommunities: g.ids.length, - })) - .filter((c) => c.symbolCount >= 5) - .sort((a, b) => b.symbolCount - a.symbolCount); - } - - private async overview( - repo: RepoHandle, - params: { showClusters?: boolean; showProcesses?: boolean; limit?: number }, - ): Promise { - await this.ensureInitialized(repo.id); - - const limit = params.limit || 20; - const result: any = { - repo: repo.name, - repoPath: repo.repoPath, - stats: repo.stats, - indexedAt: repo.indexedAt, - lastCommit: repo.lastCommit, - }; - - if (params.showClusters !== false) { - try { - // Fetch more raw communities than the display limit so aggregation has enough data - const rawLimit = Math.max(limit * 5, 200); - const clusters = await executeQuery( - repo.id, - ` - MATCH (c:Community) - RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount - ORDER BY c.symbolCount DESC - LIMIT ${rawLimit} - `, - ); - const rawClusters = clusters.map((c: any) => ({ - id: c.id || c[0], - label: c.label || c[1], - heuristicLabel: c.heuristicLabel || c[2], - cohesion: c.cohesion || c[3], - symbolCount: c.symbolCount || c[4], - })); - result.clusters = this.aggregateClusters(rawClusters).slice(0, limit); - } catch { - result.clusters = []; - } - } - - if (params.showProcesses !== false) { - try { - const processes = await executeQuery( - repo.id, - ` - MATCH (p:Process) - RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount - ORDER BY p.stepCount DESC - LIMIT ${limit} - `, - ); - result.processes = processes.map((p: any) => ({ - id: p.id || p[0], - label: p.label || p[1], - heuristicLabel: p.heuristicLabel || p[2], - processType: p.processType || p[3], - stepCount: p.stepCount || p[4], - })); - } catch { - result.processes = []; - } - } - - return result; - } - - /** - * Context tool — 360-degree symbol view with categorized refs. - * Disambiguation when multiple symbols share a name. - * UID-based direct lookup. No cluster in output. - */ - private async context( - repo: RepoHandle, - params: { - name?: string; - uid?: string; - file_path?: string; - include_content?: boolean; - }, - ): Promise { - await this.ensureInitialized(repo.id); - - const { name, uid, file_path, include_content } = params; - - if (!name && !uid) { - return { error: 'Either "name" or "uid" parameter is required.' }; - } - - // Step 1: Find the symbol - let symbols: any[]; - - if (uid) { - symbols = await executeParameterized( - repo.id, - ` - MATCH (n {id: $uid}) - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''} - LIMIT 1 - `, - { uid }, - ); - } else { - const isQualified = name!.includes('/') || name!.includes(':'); - - let whereClause: string; - let queryParams: Record; - if (file_path) { - whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`; - queryParams = { symName: name!, filePath: file_path }; - } else if (isQualified) { - whereClause = `WHERE n.id = $symName OR n.name = $symName`; - queryParams = { symName: name! }; - } else { - whereClause = `WHERE n.name = $symName`; - queryParams = { symName: name! }; - } - - symbols = await executeParameterized( - repo.id, - ` - MATCH (n) ${whereClause} - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''} - LIMIT 10 - `, - queryParams, - ); - } - - if (symbols.length === 0) { - return { error: `Symbol '${name || uid}' not found` }; - } - - // Step 2: Disambiguation - // When multiple nodes share the same name (e.g. a Java Class and its - // Constructor both named 'SessionTracker'), prefer the Class node so - // context() returns the semantically meaningful result rather than - // triggering ambiguous disambiguation (#480). - // labels(n)[0] returns empty string in LadybugDB, so we resolve the - // preferred node by re-querying with explicit label filters, scoped to - // the candidate IDs already in symbols. - // - // Guard: only attempt Class-preference when at least one candidate has an - // empty/unknown type (LadybugDB limitation) or is a Constructor — meaning - // the ambiguity may be a Class/Constructor name collision rather than two - // genuinely distinct symbols (e.g. two Functions in different files). - // - // resolvedLabel is set here and threaded to Step 3 to avoid a redundant - // classCheck round-trip later. - let resolvedLabel = ''; - if (symbols.length > 1 && !uid) { - const hasAmbiguousType = symbols.some((s: any) => { - const t = s.type || s[2] || ''; - return t === '' || t === 'Constructor'; - }); - if (hasAmbiguousType) { - const candidateIds = symbols.map((s: any) => s.id || s[0]).filter(Boolean); - const PREFER_LABELS = ['Class', 'Interface']; - let preferred: any = null; - for (const label of PREFER_LABELS) { - const match = await executeParameterized( - repo.id, - ` - MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id LIMIT 1 - `, - { candidateIds }, - ).catch(() => []); - if (match.length > 0) { - preferred = symbols.find((s: any) => (s.id || s[0]) === (match[0].id || match[0][0])); - if (preferred) { - resolvedLabel = label; - break; - } - } - } - if (preferred) symbols = [preferred]; - } - } - - if (symbols.length > 1 && !uid) { - return { - status: 'ambiguous', - message: `Found ${symbols.length} symbols matching '${name}'. Use uid or file_path to disambiguate.`, - candidates: symbols.map((s: any) => ({ - uid: s.id || s[0], - name: s.name || s[1], - kind: s.type || s[2], - filePath: s.filePath || s[3], - line: s.startLine || s[4], - })), - }; - } - - // Step 3: Build full context - const sym = symbols[0]; - const symId = sym.id || sym[0]; - - // Categorized incoming refs - const incomingRows = await executeParameterized( - repo.id, - ` - MATCH (caller)-[r:CodeRelation]->(n {id: $symId}) - WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES'] - RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind - LIMIT 30 - `, - { symId }, - ); - - // Structural refs for document nodes (Section) and Pseudocode (CodeElement) - // Only fetched for documentation components to avoid noise on regular code symbols - const symRawType = sym.type || sym[2] || ''; - let structuralIncoming: any[] = []; - let isDocNodeCheck = false; - - if (symRawType === 'Section' || symRawType === 'CodeElement' || symRawType === '') { - try { - isDocNodeCheck = (symRawType === 'Section' || symRawType === 'CodeElement') ? true : - (await executeParameterized(repo.id, - `MATCH (n) WHERE (n:Section OR n:CodeElement) AND n.id = $symId RETURN n.id LIMIT 1`, - { symId } - ).catch(() => [])).length > 0; - - if (isDocNodeCheck) { - structuralIncoming = await executeParameterized( - repo.id, - `MATCH (parent)-[r:CodeRelation]->(n {id: $symId}) - WHERE r.type IN ['CONTAINS', 'DEFINES'] - RETURN r.type AS relType, parent.id AS uid, parent.name AS name, - parent.filePath AS filePath, labels(parent)[0] AS kind - LIMIT 10`, - { symId } - ); - } - } catch (e) { - logQueryError('context:structural-incoming', e); - } - } - - // Fix #480: Class/Interface nodes have no direct CALLS/IMPORTS edges — - // those point to Constructor and File nodes respectively. Fetch those - // extra incoming refs and merge them in so context() shows real callers. - // - // Determine if this is a Class/Interface node. If resolvedLabel was set - // during disambiguation (Step 2), use it directly — no extra round-trip. - // Otherwise fall back to a single label check only when the type field is - // empty (LadybugDB labels(n)[0] limitation). - let isClassLike = resolvedLabel === 'Class' || resolvedLabel === 'Interface'; - if (!isClassLike && symRawType === '') { - try { - // Single UNION query instead of two serial round-trips. - const typeCheck = await executeParameterized( - repo.id, - ` - MATCH (n:Class) WHERE n.id = $symId RETURN 'Class' AS label LIMIT 1 - UNION ALL - MATCH (n:Interface) WHERE n.id = $symId RETURN 'Interface' AS label LIMIT 1 - `, - { symId }, - ); - isClassLike = typeCheck.length > 0; - } catch { - /* not a Class/Interface node */ - } - } else if (!isClassLike) { - isClassLike = symRawType === 'Class' || symRawType === 'Interface'; - } - - if (isClassLike) { - try { - // Run both incoming-ref queries in parallel — they are independent. - const [ctorIncoming, fileIncoming] = await Promise.all([ - executeParameterized( - repo.id, - ` - MATCH (n)-[hm:CodeRelation]->(ctor:Constructor) - WHERE n.id = $symId AND hm.type = 'HAS_METHOD' - MATCH (caller)-[r:CodeRelation]->(ctor) - WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'ACCESSES'] - RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind - LIMIT 30 - `, - { symId }, - ), - executeParameterized( - repo.id, - ` - MATCH (f:File)-[rel:CodeRelation]->(n) - WHERE n.id = $symId AND rel.type = 'DEFINES' - MATCH (caller)-[r:CodeRelation]->(f) - WHERE r.type IN ['CALLS', 'IMPORTS'] - RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind - LIMIT 30 - `, - { symId }, - ), - ]); - - // Deduplicate by (relType, uid) — a caller can have multiple relation - // types to the same target (e.g. both IMPORTS and CALLS), and each - // must be preserved so every category appears in the output. - const seenKeys = new Set( - incomingRows.map((r: any) => `${r.relType || r[0]}:${r.uid || r[1]}`), - ); - for (const r of [...ctorIncoming, ...fileIncoming]) { - const key = `${r.relType || r[0]}:${r.uid || r[1]}`; - if (!seenKeys.has(key)) { - seenKeys.add(key); - incomingRows.push(r); - } - } - } catch (e) { - logQueryError('context:class-incoming-expansion', e); - } - } - - // Categorized outgoing refs - const outgoingRows = await executeParameterized( - repo.id, - ` - MATCH (n {id: $symId})-[r:CodeRelation]->(target) - WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES'] - RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind - LIMIT 30 - `, - { symId }, - ); - - let structuralOutgoing: any[] = []; - if (symRawType === 'Section' || isDocNodeCheck) { - try { - structuralOutgoing = await executeParameterized( - repo.id, - `MATCH (n {id: $symId})-[r:CodeRelation]->(child) - WHERE r.type IN ['CONTAINS', 'DEFINES'] - RETURN r.type AS relType, child.id AS uid, child.name AS name, - child.filePath AS filePath, labels(child)[0] AS kind - LIMIT 30`, - { symId } - ); - } catch (e) { - logQueryError('context:structural-outgoing', e); - } - - try { - const crossRefRows = await executeParameterized( - repo.id, - `MATCH (n {id: $symId})-[r:CodeRelation {type: 'IMPORTS'}]->(target) - RETURN r.type AS relType, target.id AS uid, target.name AS name, - target.filePath AS filePath, labels(target)[0] AS kind - LIMIT 20`, - { symId } - ); - for (const row of crossRefRows) { - if (!outgoingRows.some((r: any) => (r.uid || r[1]) === (row.uid || row[1]))) { - outgoingRows.push(row); - } - } - } catch (e) { - logQueryError('context:section-crossref', e); - } - } - - // Process participation - let processRows: any[] = []; - try { - processRows = await executeParameterized( - repo.id, - ` - MATCH (n {id: $symId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - RETURN p.id AS pid, p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount - `, - { symId }, - ); - } catch (e) { - logQueryError('context:process-participation', e); - } - - // Helper to categorize refs - const categorize = (rows: any[]) => { - const cats: Record = {}; - for (const row of rows) { - const relType = (row.relType || row[0] || '').toLowerCase(); - const entry = { - uid: row.uid || row[1], - name: row.name || row[2], - filePath: row.filePath || row[3], - kind: row.kind || row[4], - }; - if (!cats[relType]) cats[relType] = []; - cats[relType].push(entry); - } - return cats; - }; - - // Method/Function/Constructor enrichment: fetch method-specific properties - const symKind = isClassLike ? resolvedLabel || 'Class' : sym.type || sym[2]; - const isMethodLike = - symKind === 'Method' || symKind === 'Function' || symKind === 'Constructor'; - let methodMetadata: Record | undefined; - if (isMethodLike) { - try { - const metaRows = await executeParameterized( - repo.id, - ` - MATCH (n {id: $symId}) - RETURN n.visibility AS visibility, n.isStatic AS isStatic, n.isAbstract AS isAbstract, - n.isFinal AS isFinal, n.isVirtual AS isVirtual, n.isOverride AS isOverride, - n.isAsync AS isAsync, n.isPartial AS isPartial, n.returnType AS returnType, - n.parameterCount AS parameterCount, n.isVariadic AS isVariadic, - n.requiredParameterCount AS requiredParameterCount, - n.parameterTypes AS parameterTypes, n.annotations AS annotations - LIMIT 1 - `, - { symId }, - ); - if (metaRows.length > 0) { - const row = metaRows[0]; - const meta: Record = {}; - // Only include defined properties to distinguish "not applicable" from "not enriched" - for (const key of Object.keys(row)) { - const val = row[key]; - if (val !== null && val !== undefined) meta[key] = val; - } - if (Object.keys(meta).length > 0) methodMetadata = meta; - } - } catch { - /* method metadata unavailable — omit silently */ - } - } - - return { - status: 'found', - symbol: { - uid: sym.id || sym[0], - name: sym.name || sym[1], - kind: symKind, - filePath: sym.filePath || sym[3], - startLine: sym.startLine || sym[4], - endLine: sym.endLine || sym[5], - ...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}), - ...(methodMetadata ? { methodMetadata } : {}), - }, - incoming: categorize([...incomingRows, ...structuralIncoming]), - outgoing: categorize([...outgoingRows, ...structuralOutgoing]), - processes: processRows.map((r: any) => ({ - id: r.pid || r[0], - name: r.label || r[1], - step_index: r.step || r[2], - step_count: r.stepCount || r[3], - })), - }; - } - - /** - * Legacy explore — kept for backwards compatibility with resources.ts. - * Routes cluster/process types to direct graph queries. - */ - private async explore( - repo: RepoHandle, - params: { name: string; type: 'symbol' | 'cluster' | 'process' }, - ): Promise { - await this.ensureInitialized(repo.id); - const { name, type } = params; - - if (type === 'symbol') { - return this.context(repo, { name }); - } - - if (type === 'cluster') { - const clusters = await executeParameterized( - repo.id, - ` - MATCH (c:Community) - WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName - RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount - `, - { clusterName: name }, - ); - if (clusters.length === 0) return { error: `Cluster '${name}' not found` }; - - const rawClusters = clusters.map((c: any) => ({ - id: c.id || c[0], - label: c.label || c[1], - heuristicLabel: c.heuristicLabel || c[2], - cohesion: c.cohesion || c[3], - symbolCount: c.symbolCount || c[4], - })); - - let totalSymbols = 0, - weightedCohesion = 0; - for (const c of rawClusters) { - const s = c.symbolCount || 0; - totalSymbols += s; - weightedCohesion += (c.cohesion || 0) * s; - } - - const members = await executeParameterized( - repo.id, - ` - MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName - RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath - LIMIT 30 - `, - { clusterName: name }, - ); - - return { - cluster: { - id: rawClusters[0].id, - label: rawClusters[0].heuristicLabel || rawClusters[0].label, - heuristicLabel: rawClusters[0].heuristicLabel || rawClusters[0].label, - cohesion: totalSymbols > 0 ? weightedCohesion / totalSymbols : 0, - symbolCount: totalSymbols, - subCommunities: rawClusters.length, - }, - members: members.map((m: any) => ({ - name: m.name || m[0], - type: m.type || m[1], - filePath: m.filePath || m[2], - })), - }; - } - - if (type === 'process') { - const processes = await executeParameterized( - repo.id, - ` - MATCH (p:Process) - WHERE p.label = $processName OR p.heuristicLabel = $processName - RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount - LIMIT 1 - `, - { processName: name }, - ); - if (processes.length === 0) return { error: `Process '${name}' not found` }; - - const proc = processes[0]; - const procId = proc.id || proc[0]; - const steps = await executeParameterized( - repo.id, - ` - MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId}) - RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step - ORDER BY r.step - `, - { procId }, - ); - - return { - process: { - id: procId, - label: proc.label || proc[1], - heuristicLabel: proc.heuristicLabel || proc[2], - processType: proc.processType || proc[3], - stepCount: proc.stepCount || proc[4], - }, - steps: steps.map((s: any) => ({ - step: s.step || s[3], - name: s.name || s[0], - type: s.type || s[1], - filePath: s.filePath || s[2], - })), - }; - } - - return { error: 'Invalid type. Use: symbol, cluster, or process' }; - } - - /** - * Detect changes — git-diff based impact analysis. - * Maps changed lines to indexed symbols, then finds affected processes. - */ - private async detectChanges( - repo: RepoHandle, - params: { - scope?: string; - base_ref?: string; - }, - ): Promise { - await this.ensureInitialized(repo.id); - - const scope = params.scope || 'unstaged'; - const { execFileSync } = await import('child_process'); - - // Build git diff args based on scope (using execFileSync to avoid shell injection) - let diffArgs: string[]; - switch (scope) { - case 'staged': - diffArgs = ['diff', '--staged', '--name-only']; - break; - case 'all': - diffArgs = ['diff', 'HEAD', '--name-only']; - break; - case 'compare': - if (!params.base_ref) return { error: 'base_ref is required for "compare" scope' }; - diffArgs = ['diff', params.base_ref, '--name-only']; - break; - case 'unstaged': - default: - diffArgs = ['diff', '--name-only']; - break; - } - - let changedFiles: string[]; - try { - const output = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' }); - changedFiles = output - .trim() - .split('\n') - .filter((f) => f.length > 0); - } catch (err: any) { - return { error: `Git diff failed: ${err.message}` }; - } - - if (changedFiles.length === 0) { - return { - summary: { - changed_count: 0, - affected_count: 0, - risk_level: 'none', - message: 'No changes detected.', - }, - changed_symbols: [], - affected_processes: [], - }; - } - - // Map changed files to indexed symbols - const changedSymbols: any[] = []; - for (const file of changedFiles) { - const normalizedFile = file.replace(/\\/g, '/'); - try { - const symbols = await executeParameterized( - repo.id, - ` - MATCH (n) WHERE n.filePath CONTAINS $filePath - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath - LIMIT 20 - `, - { filePath: normalizedFile }, - ); - for (const sym of symbols) { - changedSymbols.push({ - id: sym.id || sym[0], - name: sym.name || sym[1], - type: sym.type || sym[2], - filePath: sym.filePath || sym[3], - change_type: 'Modified', - }); - } - } catch (e) { - logQueryError('detect-changes:file-symbols', e); - } - } - - // Find affected processes - const affectedProcesses = new Map(); - for (const sym of changedSymbols) { - try { - const procs = await executeParameterized( - repo.id, - ` - MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - RETURN p.id AS pid, p.heuristicLabel AS label, p.processType AS processType, p.stepCount AS stepCount, r.step AS step - `, - { nodeId: sym.id }, - ); - for (const proc of procs) { - const pid = proc.pid || proc[0]; - if (!affectedProcesses.has(pid)) { - affectedProcesses.set(pid, { - id: pid, - name: proc.label || proc[1], - process_type: proc.processType || proc[2], - step_count: proc.stepCount || proc[3], - changed_steps: [], - }); - } - affectedProcesses.get(pid)!.changed_steps.push({ - symbol: sym.name, - step: proc.step || proc[4], - }); - } - } catch (e) { - logQueryError('detect-changes:process-lookup', e); - } - } - - const processCount = affectedProcesses.size; - const risk = - processCount === 0 - ? 'low' - : processCount <= 5 - ? 'medium' - : processCount <= 15 - ? 'high' - : 'critical'; - - return { - summary: { - changed_count: changedSymbols.length, - affected_count: processCount, - changed_files: changedFiles.length, - risk_level: risk, - }, - changed_symbols: changedSymbols, - affected_processes: Array.from(affectedProcesses.values()), - }; - } - - /** - * Rename tool — multi-file coordinated rename using graph + text search. - * Graph refs are tagged "graph" (high confidence). - * Additional refs found via text search are tagged "text_search" (lower confidence). - */ - private async rename( - repo: RepoHandle, - params: { - symbol_name?: string; - symbol_uid?: string; - new_name: string; - file_path?: string; - dry_run?: boolean; - }, - ): Promise { - await this.ensureInitialized(repo.id); - - const { new_name, file_path } = params; - const dry_run = params.dry_run ?? true; - - if (!params.symbol_name && !params.symbol_uid) { - return { error: 'Either symbol_name or symbol_uid is required.' }; - } - - /** Guard: ensure a file path resolves within the repo root (prevents path traversal) */ - const assertSafePath = (filePath: string): string => { - const full = path.resolve(repo.repoPath, filePath); - if (!full.startsWith(repo.repoPath + path.sep) && full !== repo.repoPath) { - throw new Error(`Path traversal blocked: ${filePath}`); - } - return full; - }; - - // Step 1: Find the target symbol (reuse context's lookup) - const lookupResult = await this.context(repo, { - name: params.symbol_name, - uid: params.symbol_uid, - file_path, - }); - - if (lookupResult.status === 'ambiguous') { - return lookupResult; // pass disambiguation through - } - if (lookupResult.error) { - return lookupResult; - } - - const sym = lookupResult.symbol; - const oldName = sym.name; - - if (oldName === new_name) { - return { error: 'New name is the same as the current name.' }; - } - - // Step 2: Collect edits from graph (high confidence) - const changes = new Map(); - - const addEdit = ( - filePath: string, - line: number, - oldText: string, - newText: string, - confidence: string, - ) => { - if (!changes.has(filePath)) { - changes.set(filePath, { file_path: filePath, edits: [] }); - } - changes.get(filePath)!.edits.push({ line, old_text: oldText, new_text: newText, confidence }); - }; - - let searchName = oldName; - if (sym.type === 'Section' && sym.id) { - // Extract raw text from ID: "Section:...:L12:Raw Text Here" - const parts = sym.id.split(':L' + sym.startLine + ':'); - if (parts.length === 2) searchName = parts[1]; - } - - // The definition itself - if (sym.filePath && sym.startLine) { - try { - const content = await fs.readFile(assertSafePath(sym.filePath), 'utf-8'); - const lines = content.split('\n'); - const lineIdx = sym.startLine - 1; - if (lineIdx >= 0 && lineIdx < lines.length && lines[lineIdx].includes(searchName)) { - const defRegex = new RegExp( - `\\b${searchName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, - 'g', - ); - addEdit( - sym.filePath, - sym.startLine, - lines[lineIdx].trim(), - lines[lineIdx].replace(defRegex, new_name).trim(), - 'graph', - ); - } - } catch (e) { - logQueryError('rename:read-definition', e); - } - } - - // All incoming refs from graph (callers, importers, etc.) - const definitionFile = sym.filePath; - const safeContains = (lookupResult.incoming.contains || []).filter( - (ref: any) => ref.filePath !== definitionFile - ); - const safeDefines = (lookupResult.incoming.defines || []).filter( - (ref: any) => ref.filePath !== definitionFile - ); - - const allIncoming = [ - ...(lookupResult.incoming.calls || []), - ...(lookupResult.incoming.imports || []), - ...(lookupResult.incoming.extends || []), - ...(lookupResult.incoming.implements || []), - ...safeContains, - ...safeDefines, - ]; - - let graphEdits = changes.size > 0 ? 1 : 0; // count definition edit - - for (const ref of allIncoming) { - if (!ref.filePath) continue; - try { - const content = await fs.readFile(assertSafePath(ref.filePath), 'utf-8'); - const lines = content.split('\n'); - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes(oldName)) { - addEdit( - ref.filePath, - i + 1, - lines[i].trim(), - lines[i] - .replace( - new RegExp(`\\b${searchName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'), - new_name, - ) - .trim(), - 'graph', - ); - graphEdits++; - break; // one edit per file from graph refs - } - } - } catch (e) { - logQueryError('rename:read-ref', e); - } - } - - // Step 3: Text search for refs the graph might have missed - let astSearchEdits = 0; - const graphFiles = new Set( - [sym.filePath, ...allIncoming.map((r) => r.filePath)].filter(Boolean), - ); - - // Simple text search across the repo for the search name (in files not already covered by graph) - try { - const { execFileSync } = await import('child_process'); - const rgArgs = [ - '-l', - '--type-add', - 'code:*.{ts,tsx,js,jsx,py,go,rs,java,c,h,cpp,cc,cxx,hpp,hxx,hh,cs,php,swift}', - '-t', - 'code', - `\\b${oldName}\\b`, - '.', - ]; - const output = execFileSync('rg', rgArgs, { - cwd: repo.repoPath, - encoding: 'utf-8', - timeout: 5000, - }); - const files = output - .trim() - .split('\n') - .filter((f) => f.length > 0); - - for (const file of files) { - const normalizedFile = file.replace(/\\/g, '/').replace(/^\.\//, ''); - if (graphFiles.has(normalizedFile)) continue; // already covered by graph - - try { - const content = await fs.readFile(assertSafePath(normalizedFile), 'utf-8'); - const lines = content.split('\n'); - const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'); - for (let i = 0; i < lines.length; i++) { - regex.lastIndex = 0; - if (regex.test(lines[i])) { - regex.lastIndex = 0; - addEdit( - normalizedFile, - i + 1, - lines[i].trim(), - lines[i].replace(regex, new_name).trim(), - 'text_search', - ); - astSearchEdits++; - } - } - } catch (e) { - logQueryError('rename:text-search-read', e); - } - } - } catch (e) { - logQueryError('rename:ripgrep', e); - } - - // Step 4: Apply or preview - const allChanges = Array.from(changes.values()); - const totalEdits = allChanges.reduce((sum, c) => sum + c.edits.length, 0); - - if (!dry_run) { - // Apply edits to files - for (const change of allChanges) { - try { - const fullPath = assertSafePath(change.file_path); - let content = await fs.readFile(fullPath, 'utf-8'); - const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'); - content = content.replace(regex, new_name); - await fs.writeFile(fullPath, content, 'utf-8'); - } catch (e) { - logQueryError('rename:apply-edit', e); - } - } - } - - return { - status: 'success', - old_name: oldName, - new_name, - files_affected: allChanges.length, - total_edits: totalEdits, - graph_edits: graphEdits, - text_search_edits: astSearchEdits, - changes: allChanges, - applied: !dry_run, - }; - } - - private async impact( - repo: RepoHandle, - params: { - target: string; - direction: 'upstream' | 'downstream'; - maxDepth?: number; - relationTypes?: string[]; - includeTests?: boolean; - minConfidence?: number; - }, - ): Promise { - try { - return await this._impactImpl(repo, params); - } catch (err: any) { - // Return structured error instead of crashing (#321) - return { - error: (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed', - target: { name: params.target }, - direction: params.direction, - impactedCount: 0, - risk: 'UNKNOWN', - suggestion: 'The graph query failed — try gitnexus context as a fallback', - }; - } - } - - private async _impactImpl( - repo: RepoHandle, - params: { - target: string; - direction: 'upstream' | 'downstream'; - maxDepth?: number; - relationTypes?: string[]; - includeTests?: boolean; - minConfidence?: number; - }, - ): Promise { - await this.ensureInitialized(repo.id); - - const { target, direction } = params; - const maxDepth = params.maxDepth || 3; - const rawRelTypes = - params.relationTypes && params.relationTypes.length > 0 - ? params.relationTypes.filter((t) => VALID_RELATION_TYPES.has(t)) - : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; - const relationTypes = - rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; - const includeTests = params.includeTests ?? false; - const minConfidence = params.minConfidence ?? 0; - - // Resolve target by name, preferring Class/Interface over Constructor - // (fix #480: Java class and constructor share the same name). - // labels(n)[0] returns empty string in LadybugDB, so we use explicit - // label-typed sub-queries in a single UNION ordered by priority to avoid - // up to 6 serial round-trips for non-Class targets. - let sym: any = null; - let symType = ''; - - try { - const rows = await executeParameterized( - repo.id, - ` - MATCH (n:\`Class\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 0 AS priority LIMIT 1 - UNION ALL - MATCH (n:\`Interface\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 1 AS priority LIMIT 1 - UNION ALL - MATCH (n:\`Function\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 2 AS priority LIMIT 1 - UNION ALL - MATCH (n:\`Method\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 3 AS priority LIMIT 1 - UNION ALL - MATCH (n:\`Constructor\`) WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 4 AS priority LIMIT 1 - `, - { targetName: target }, - ).catch(() => []); - - if (rows.length > 0) { - // Pick the row with the lowest priority value (Class wins over Constructor) - const best = rows.reduce((a: any, b: any) => - (a.priority ?? a[3] ?? 99) <= (b.priority ?? b[3] ?? 99) ? a : b, - ); - sym = best; - const priorityToLabel = ['Class', 'Interface', 'Function', 'Method', 'Constructor']; - symType = priorityToLabel[best.priority ?? best[3]] ?? ''; - } - } catch { - /* fall through to unlabeled match */ - } - - // Fall back to unlabeled match for any other node type - if (!sym) { - const rows = await executeParameterized( - repo.id, - ` - MATCH (n) - WHERE n.name = $targetName - RETURN n.id AS id, n.name AS name, n.filePath AS filePath - LIMIT 1 - `, - { targetName: target }, - ); - if (rows.length > 0) sym = rows[0]; - } - - if (!sym) return { error: `Target '${target}' not found` }; - - return this._runImpactBFS(repo, sym, symType, direction, { - maxDepth, - relationTypes, - includeTests, - minConfidence, - }); - } - - /** - * Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol). - */ - private async _runImpactBFS( - repo: RepoHandle, - sym: any, - symType: string, - direction: 'upstream' | 'downstream', - opts: { - maxDepth: number; - relationTypes: string[]; - includeTests: boolean; - minConfidence: number; - }, - ): Promise { - const { maxDepth, relationTypes, includeTests, minConfidence } = opts; - const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', '); - const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : ''; - - const symId = sym.id || sym[0]; - - const impacted: any[] = []; - const visited = new Set([symId]); - let frontier = [symId]; - let traversalComplete = true; - - // Fix #480: For Java (and other JVM) Class/Interface nodes, CALLS edges - // point to Constructor nodes and IMPORTS edges point to File nodes — not - // the Class/Interface itself. Seed the frontier with the Constructor(s) - // and owning File so the BFS traversal finds those edges naturally. - // The owning File is kept only as an internal seed (frontier/visited) and - // is NOT added to impacted — it is the definition container, not an - // upstream dependent. The BFS will discover IMPORTS edges on it naturally. - if (symType === 'Class' || symType === 'Interface') { - try { - // Run both seed queries in parallel — they are independent. - const [ctorRows, fileRows] = await Promise.all([ - executeParameterized( - repo.id, - ` - MATCH (n)-[hm:CodeRelation]->(c:Constructor) - WHERE n.id = $symId AND hm.type = 'HAS_METHOD' - RETURN c.id AS id, c.name AS name, labels(c)[0] AS type, c.filePath AS filePath - `, - { symId }, - ), - // Restrict to DEFINES edges only — other File->Class edge types (if - // any) should not be treated as the owning file relationship. - executeParameterized( - repo.id, - ` - MATCH (f:File)-[rel:CodeRelation]->(n) - WHERE n.id = $symId AND rel.type = 'DEFINES' - RETURN f.id AS id, f.name AS name, labels(f)[0] AS type, f.filePath AS filePath - `, - { symId }, - ), - ]); - - for (const r of ctorRows) { - const rid = r.id || r[0]; - if (rid && !visited.has(rid)) { - visited.add(rid); - frontier.push(rid); - } - } - for (const r of fileRows) { - const rid = r.id || r[0]; - if (rid && !visited.has(rid)) { - visited.add(rid); - frontier.push(rid); - } - } - } catch (e) { - logQueryError('impact:class-node-expansion', e); - } - } - - for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) { - const nextFrontier: string[] = []; - - // Batch frontier nodes into a single Cypher query per depth level - const idList = frontier.map((id) => `'${id.replace(/'/g, "''")}'`).join(', '); - const query = - direction === 'upstream' - ? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence` - : `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`; - - try { - const related = await executeQuery(repo.id, query); - - for (const rel of related) { - const relId = rel.id || rel[1]; - const filePath = rel.filePath || rel[4] || ''; - - if (!includeTests && isTestFilePath(filePath)) continue; - - if (!visited.has(relId)) { - visited.add(relId); - nextFrontier.push(relId); - const storedConfidence = rel.confidence ?? rel[6]; - const relationType = rel.relType || rel[5]; - // Prefer the stored confidence from the graph (set at analysis time); - // fall back to the per-type floor for edges without a stored value. - const effectiveConfidence = - typeof storedConfidence === 'number' && storedConfidence > 0 - ? storedConfidence - : confidenceForRelType(relationType); - impacted.push({ - depth, - id: relId, - name: rel.name || rel[2], - type: rel.type || rel[3], - filePath, - relationType, - confidence: effectiveConfidence, - }); - } - } - } catch (e) { - logQueryError('impact:depth-traversal', e); - // Break out of depth loop on query failure but return partial results - // collected so far, rather than silently swallowing the error (#321) - traversalComplete = false; - break; - } - - frontier = nextFrontier; - } - - const grouped: Record = {}; - for (const item of impacted) { - if (!grouped[item.depth]) grouped[item.depth] = []; - grouped[item.depth].push(item); - } - - // ── Enrichment: affected processes, modules, risk ────────────── - const directCount = (grouped[1] || []).length; - let affectedProcesses: any[] = []; - let affectedModules: any[] = []; - - if (impacted.length > 0) { - const CHUNK_SIZE = 100; - // Max number of chunks to process to avoid unbounded DB round-trips. - // Configurable via env IMPACT_MAX_CHUNKS, default 10 => max items = 1000 - const MAX_CHUNKS = parseInt(process.env.IMPACT_MAX_CHUNKS || '10', 10); - - // ── Process enrichment: batched chunking (bounded by MAX_CHUNKS) ─ - // Uses merged Cypher query (WITH + OPTIONAL MATCH) to fetch - // process + entry point info in 1 round-trip per chunk. Converted to - // parameterized queries to avoid manual string escaping and long query strings. - const entryPointMap = new Map< - string, - { - name: string; - type: string; - filePath: string; - affected_process_count: number; - total_hits: number; - earliest_broken_step: number; - } - >(); - - // Map process id -> entryPointId to allow fixing missing minStep values later - const processToEntryPoint = new Map(); - // Collect process ids where MIN(r.step) returned null so we can retry in batch - const processesMissingMinStep = new Set(); - - let chunksProcessed = 0; - for ( - let i = 0; - i < impacted.length && chunksProcessed < MAX_CHUNKS; - i += CHUNK_SIZE, chunksProcessed++ - ) { - const chunk = impacted.slice(i, i + CHUNK_SIZE); - const ids = chunk.map((item) => String(item.id ?? '')); - - try { - // Use parameterized list to avoid building long query strings - const rows = await executeParameterized( - repo.id, - ` - MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - WHERE s.id IN $ids - WITH p, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep - OPTIONAL MATCH (ep {id: p.entryPointId}) - RETURN p.id AS pId, p.heuristicLabel AS name, p.processType AS processType, - p.entryPointId AS entryPointId, hits, minStep, p.stepCount AS stepCount, - ep.name AS epName, labels(ep)[0] AS epType, ep.filePath AS epFilePath - `, - { ids }, - ).catch(() => []); - - for (const row of rows) { - const pId = row.pId ?? row[0]; - const epId = row.entryPointId ?? row[3] ?? row.pId ?? row[0]; - // Track mapping from process -> entryPoint so we can backfill missing minStep - if (pId) processToEntryPoint.set(String(pId), String(epId)); - - // Normalize epName: prefer epName, fall back to other columns, and - // ensure we don't keep an empty string (labels(...) can return ""). - const epNameRaw = row.epName ?? row[7] ?? row.name ?? row[1] ?? 'unknown'; - const epName = - typeof epNameRaw === 'string' && epNameRaw.trim().length > 0 - ? epNameRaw.trim() - : 'unknown'; - - // Normalize epType: labels(ep)[0] can return an empty string in - // some DBs (LadybugDB). Using nullish coalescing (??) preserves - // empty strings, which results in empty `type` values being - // propagated. Treat empty-string labels as missing and fall back - // to the next candidate or a sensible default. - const epTypeRaw = row.epType ?? row[8] ?? ''; - const epType = - typeof epTypeRaw === 'string' && epTypeRaw.trim().length > 0 - ? epTypeRaw.trim() - : 'Function'; - - const epFilePath = row.epFilePath ?? row[9] ?? ''; - const hits = row.hits ?? row[4] ?? 0; - const minStep = row.minStep ?? row[5]; - // If the DB returned null for minStep, note the process id so we - // can run a follow-up query using a different aggregation strategy. - if (minStep === null || minStep === undefined) { - if (pId) processesMissingMinStep.add(String(pId)); - } - if (!entryPointMap.has(epId)) { - entryPointMap.set(epId, { - name: epName, - type: epType, - filePath: epFilePath, - affected_process_count: 0, - total_hits: 0, - earliest_broken_step: Infinity, - }); - } - const ep = entryPointMap.get(epId)!; - ep.affected_process_count += 1; - ep.total_hits += hits; - ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep ?? Infinity); - } - } catch (e) { - logQueryError('impact:process-chunk', e); - } - } - - // If some processes returned null minStep, try a batched follow-up query - // using the full impacted id set. This handles older indexes or DBs - // where MIN(r.step) can come back null even when step properties exist. - if (processesMissingMinStep.size > 0) { - try { - const pIds = Array.from(processesMissingMinStep); - const allImpactedIds = impacted.map((it) => String(it.id ?? '')); - const missingRows = await executeParameterized( - repo.id, - ` - MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - WHERE p.id IN $pIds AND s.id IN $ids - RETURN p.id AS pid, MIN(r.step) AS minStep - `, - { pIds, ids: allImpactedIds }, - ).catch(() => []); - - for (const mr of missingRows) { - const pid = mr.pid ?? mr[0]; - const minStep = mr.minStep ?? mr[1]; - const epId = processToEntryPoint.get(String(pid)); - if (!epId) continue; - const ep = entryPointMap.get(epId); - if (!ep) continue; - if (typeof minStep === 'number') { - ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep); - } - } - } catch (e) { - logQueryError('impact:process-chunk-backfill', e); - } - } - - // If we capped chunks, mark traversal incomplete so caller knows results are partial - if (chunksProcessed * CHUNK_SIZE < impacted.length) { - traversalComplete = false; - } - - affectedProcesses = Array.from(entryPointMap.values()) - .map((ep) => ({ - ...ep, - earliest_broken_step: - ep.earliest_broken_step === Infinity ? null : ep.earliest_broken_step, - })) - .sort((a, b) => b.total_hits - a.total_hits); - - // ── Module enrichment: use same cap as process enrichment and parameterized queries - const maxItems = Math.min(impacted.length, MAX_CHUNKS * CHUNK_SIZE); - const cappedImpacted = impacted.slice(0, maxItems); - const allIdsArr = cappedImpacted.map((i: any) => String(i.id ?? '')); - const d1Items = (grouped[1] || []).slice(0, maxItems); - const d1IdsArr = d1Items.map((i: any) => String(i.id ?? '')); - - // Chunked module enrichment: run the MEMBER_OF queries in chunks - // to avoid large single queries or concurrent Kuzu calls that can - // crash (SIGSEGV) on arm64 macOS; behavior preserves existing maxItems cap and returns equivalent aggregated results. - const moduleHitsMap = new Map(); - const directModuleSet = new Set(); - - // Helper to run a single module chunk and accumulate hits by name - const runModuleChunk = async (idsChunk: string[]) => { - if (!idsChunk || idsChunk.length === 0) return; - try { - const rows = await executeParameterized( - repo.id, - ` - MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - WHERE s.id IN $ids - RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits - ORDER BY hits DESC - LIMIT 20 - `, - { ids: idsChunk }, - ).catch(() => []); - - for (const r of rows) { - const name = r.name ?? r[0] ?? null; - const hits = (r.hits ?? r[1]) || 0; - if (!name) continue; - moduleHitsMap.set(name, (moduleHitsMap.get(name) || 0) + hits); - } - } catch (e) { - logQueryError('impact:module-chunk', e); - } - }; - - // Run module query chunks sequentially (safe on arm64 macOS) - for (let i = 0; i < allIdsArr.length; i += CHUNK_SIZE) { - const chunkIds = allIdsArr.slice(i, i + CHUNK_SIZE); - await runModuleChunk(chunkIds); - } - - // Run direct module query similarly (distinct heuristic labels for depth-1 items) - const runDirectModuleChunk = async (idsChunk: string[]) => { - if (!idsChunk || idsChunk.length === 0) return; - try { - const rows = await executeParameterized( - repo.id, - ` - MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - WHERE s.id IN $ids - RETURN DISTINCT c.heuristicLabel AS name - `, - { ids: idsChunk }, - ).catch(() => []); - for (const r of rows) { - const name = r.name ?? r[0] ?? null; - if (name) directModuleSet.add(name); - } - } catch (e) { - logQueryError('impact:direct-module-chunk', e); - } - }; - - for (let i = 0; i < d1IdsArr.length; i += CHUNK_SIZE) { - const chunkIds = d1IdsArr.slice(i, i + CHUNK_SIZE); - await runDirectModuleChunk(chunkIds); - } - - // Build final moduleRows array from aggregated hits map, sorted & limited - const moduleRows = Array.from(moduleHitsMap.entries()) - .map(([name, hits]) => ({ name, hits })) - .sort((a, b) => b.hits - a.hits) - .slice(0, 20); - - const directModuleRows = Array.from(directModuleSet).map((name) => ({ name })); - - // Build affectedModules in the same shape as original implementation - const directModuleNameSet = new Set(directModuleRows.map((r: any) => r.name || r[0])); - affectedModules = moduleRows.map((r: any) => { - const name = r.name ?? r[0]; - const hits = r.hits ?? r[1] ?? 0; - return { - name, - hits, - impact: directModuleNameSet.has(name) ? 'direct' : 'indirect', - }; - }); - } - - // Risk scoring - const processCount = affectedProcesses.length; - const moduleCount = affectedModules.length; - let risk = 'LOW'; - if (directCount >= 30 || processCount >= 5 || moduleCount >= 5 || impacted.length >= 200) { - risk = 'CRITICAL'; - } else if ( - directCount >= 15 || - processCount >= 3 || - moduleCount >= 3 || - impacted.length >= 100 - ) { - risk = 'HIGH'; - } else if (directCount >= 5 || impacted.length >= 30) { - risk = 'MEDIUM'; - } - - return { - target: { - id: symId, - name: sym.name || sym[1], - type: symType, - filePath: sym.filePath || sym[2], - }, - direction, - impactedCount: impacted.length, - risk, - ...(!traversalComplete && { partial: true }), - summary: { - direct: directCount, - processes_affected: processCount, - modules_affected: moduleCount, - }, - affected_processes: affectedProcesses, - affected_modules: affectedModules, - byDepth: grouped, - }; - } - - /** - * UID-based impact for cross-repo fan-out. Same result shape as `impact`. - * Returns null if the repo is unknown, the UID is missing, or analysis fails. - */ - async impactByUid( - repoId: string, - uid: string, - direction: string, - opts: { - maxDepth: number; - relationTypes: string[]; - minConfidence: number; - includeTests: boolean; - }, - ): Promise { - try { - await this.refreshRepos(); - await this.ensureInitialized(repoId); - } catch { - return null; - } - - const repo = this.repos.get(repoId); - if (!repo) return null; - - const dir: 'upstream' | 'downstream' = direction === 'downstream' ? 'downstream' : 'upstream'; - - let rows: any[]; - try { - rows = await executeParameterized( - repoId, - `MATCH (n) WHERE n.id = $uid - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, labels(n)[0] AS type - LIMIT 1`, - { uid }, - ); - } catch { - return null; - } - if (!rows?.length) return null; - - const sym = rows[0]; - const labelRaw = sym.type ?? sym[3]; - const symType = - typeof labelRaw === 'string' && labelRaw.trim().length > 0 ? labelRaw.trim() : ''; - - const rawRelTypes = - opts.relationTypes && opts.relationTypes.length > 0 - ? opts.relationTypes.filter((t) => VALID_RELATION_TYPES.has(t)) - : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; - const relationTypes = - rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; - - try { - return await this._runImpactBFS(repo, sym, symType, dir, { - maxDepth: opts.maxDepth, - relationTypes, - includeTests: opts.includeTests, - minConfidence: opts.minConfidence, - }); - } catch { - return null; - } - } - - private handleGroupTool(method: string, params: Record): Promise { - switch (method) { - case 'group_list': - return this.groupList(params); - case 'group_sync': - return this.groupSync(params); - case 'group_contracts': - return this.groupContracts(params); - case 'group_query': - return this.groupQuery(params); - case 'group_status': - return this.groupStatus(params); - default: - throw new Error(`Unknown group tool: ${method}`); - } - } - - private async groupList(params: Record): Promise { - return this.getGroupService().groupList(params); - } - - private async groupSync(params: Record): Promise { - return this.getGroupService().groupSync(params); - } - - private async groupContracts(params: Record): Promise { - return this.getGroupService().groupContracts(params); - } - - private async groupQuery(params: Record): Promise { - await this.refreshRepos(); - return this.getGroupService().groupQuery(params); - } - - private async groupStatus(params: Record): Promise { - await this.refreshRepos(); - return this.getGroupService().groupStatus(params); - } - - /** - * Fetch Route nodes with their consumers in a single query. - * Shared by routeMap and shapeCheck to avoid N+1 query patterns. - */ - private async fetchRoutesWithConsumers( - repoId: string, - routeFilter: string, - params: Record, - ): Promise< - Array<{ - id: string; - name: string; - filePath: string; - responseKeys: string[] | null; - errorKeys: string[] | null; - middleware: string[] | null; - consumers: Array<{ - name: string; - filePath: string; - accessedKeys?: string[]; - fetchCount?: number; - }>; - }> - > { - const rows = await executeParameterized( - repoId, - ` - MATCH (n:Route) - WHERE n.id STARTS WITH 'Route:' ${routeFilter} - OPTIONAL MATCH (consumer)-[r:CodeRelation]->(n) - WHERE r.type = 'FETCHES' - RETURN n.id AS routeId, n.name AS routeName, n.filePath AS handlerFile, - n.responseKeys AS responseKeys, n.errorKeys AS errorKeys, n.middleware AS middleware, - consumer.name AS consumerName, consumer.filePath AS consumerFile, - r.reason AS fetchReason - `, - params, - ); - - // Strip wrapping quotes from DB array elements — CSV COPY stores ['key'] which - // LadybugDB may return as "'key'" rather than "key" - const stripQuotes = (keys: string[] | null): string[] | null => - keys ? keys.map((k) => k.replace(/^['"]|['"]$/g, '')) : null; - - const routeMap = new Map< - string, - { - id: string; - name: string; - filePath: string; - responseKeys: string[] | null; - errorKeys: string[] | null; - middleware: string[] | null; - consumers: Array<{ - name: string; - filePath: string; - accessedKeys?: string[]; - fetchCount?: number; - }>; - } - >(); - for (const row of rows) { - const id = row.routeId ?? row[0]; - const name = row.routeName ?? row[1]; - const filePath = row.handlerFile ?? row[2]; - const responseKeys = stripQuotes(row.responseKeys ?? row[3] ?? null); - const errorKeys = stripQuotes(row.errorKeys ?? row[4] ?? null); - const middleware = stripQuotes(row.middleware ?? row[5] ?? null); - const consumerName = row.consumerName ?? row[6]; - const consumerFile = row.consumerFile ?? row[7]; - const fetchReason: string | null = row.fetchReason ?? row[8] ?? null; - - if (!routeMap.has(id)) { - routeMap.set(id, { - id, - name, - filePath, - responseKeys, - errorKeys, - middleware, - consumers: [], - }); - } - if (consumerName && consumerFile) { - // Parse accessed keys from reason field: "fetch-url-match|keys:data,pagination|fetches:3" - let accessedKeys: string[] | undefined; - let fetchCount: number | undefined; - if (fetchReason) { - const keysMatch = fetchReason.match(/\|keys:([^|]+)/); - if (keysMatch) { - accessedKeys = keysMatch[1].split(',').filter((k) => k.length > 0); - } - const fetchesMatch = fetchReason.match(/\|fetches:(\d+)/); - if (fetchesMatch) { - fetchCount = parseInt(fetchesMatch[1], 10); - } - } - routeMap.get(id)!.consumers.push({ - name: consumerName, - filePath: consumerFile, - ...(accessedKeys ? { accessedKeys } : {}), - ...(fetchCount && fetchCount > 1 ? { fetchCount } : {}), - }); - } - } - - return [...routeMap.values()]; - } - - /** - * Batch-fetch execution flows linked to a set of Route or Tool nodes. - * Single query instead of N+1. - */ - private async fetchLinkedFlowsBatch( - repoId: string, - nodeIds: string[], - ): Promise> { - const result = new Map(); - if (nodeIds.length === 0) return result; - try { - // Use list_contains to filter at DB level instead of fetching all and filtering in memory - const rows = await executeParameterized( - repoId, - ` - MATCH (source)-[r:CodeRelation]->(proc:Process) - WHERE r.type = 'ENTRY_POINT_OF' - AND list_contains($nodeIds, source.id) - RETURN source.id AS sourceId, proc.label AS name - `, - { nodeIds }, - ); - for (const row of rows) { - const sourceId = row.sourceId ?? row[0]; - const name = row.name ?? row[1]; - if (!name) continue; - let list = result.get(sourceId); - if (!list) { - list = []; - result.set(sourceId, list); - } - list.push(name); - } - } catch { - /* no ENTRY_POINT_OF edges yet */ - } - return result; - } - - private async routeMap(repo: RepoHandle, params: { route?: string }): Promise { - await this.ensureInitialized(repo.id); - - const routeFilter = params.route ? `AND n.name CONTAINS $route` : ''; - const queryParams = params.route ? { route: params.route } : {}; - const routes = await this.fetchRoutesWithConsumers(repo.id, routeFilter, queryParams); - - if (routes.length === 0) { - return { - routes: [], - total: 0, - message: params.route - ? `No routes matching "${params.route}"` - : 'No routes found in this project.', - }; - } - - const flowMap = await this.fetchLinkedFlowsBatch( - repo.id, - routes.map((r) => r.id), - ); - - return { - routes: routes.map((r) => ({ - route: r.name, - handler: r.filePath, - middleware: r.middleware || [], - consumers: r.consumers, - flows: flowMap.get(r.id) || [], - })), - total: routes.length, - }; - } - - private async shapeCheck(repo: RepoHandle, params: { route?: string }): Promise { - await this.ensureInitialized(repo.id); - - const routeFilter = params.route ? `AND n.name CONTAINS $route` : ''; - const queryParams = params.route ? { route: params.route } : {}; - const allRoutes = await this.fetchRoutesWithConsumers(repo.id, routeFilter, queryParams); - - const results = allRoutes - .filter( - (r) => - ((r.responseKeys && r.responseKeys.length > 0) || - (r.errorKeys && r.errorKeys.length > 0)) && - r.consumers.length > 0, - ) - .map((r) => { - // Keys already normalized by fetchRoutesWithConsumers (quotes stripped) - const responseKeys = r.responseKeys ?? []; - const errorKeys = r.errorKeys ?? []; - // Combined set: consumer accessing either success or error keys is valid - const allKnownKeys = new Set([...responseKeys, ...errorKeys]); - - // Check each consumer's accessed keys against the route's response shape - const responseKeySet = new Set(responseKeys); - const consumers = r.consumers.map((c) => { - if (!c.accessedKeys || c.accessedKeys.length === 0) { - return { name: c.name, filePath: c.filePath }; - } - const mismatched = c.accessedKeys.filter((k) => !allKnownKeys.has(k)); - // Keys in allKnownKeys but not in responseKeys — error-path access (e.g., .error from errorKeys) - const errorPathKeys = c.accessedKeys.filter( - (k) => allKnownKeys.has(k) && !responseKeySet.has(k), - ); - const isMultiFetch = (c.fetchCount ?? 1) > 1; - return { - name: c.name, - filePath: c.filePath, - accessedKeys: c.accessedKeys, - ...(mismatched.length > 0 - ? { - mismatched, - mismatchConfidence: isMultiFetch ? ('low' as const) : ('high' as const), - } - : {}), - ...(errorPathKeys.length > 0 ? { errorPathKeys } : {}), - ...(isMultiFetch - ? { - attributionNote: `This file fetches ${c.fetchCount} routes — accessed keys may belong to a different route.`, - } - : {}), - }; - }); - - const hasMismatches = consumers.some( - (c) => 'mismatched' in c && (c as any).mismatched.length > 0, - ); - - return { - route: r.name, - handler: r.filePath, - ...(responseKeys.length > 0 ? { responseKeys } : {}), - ...(errorKeys.length > 0 ? { errorKeys } : {}), - consumers, - ...(hasMismatches ? { status: 'MISMATCH' as const } : {}), - }; - }); - - const mismatchCount = results.filter((r) => r.status === 'MISMATCH').length; - - return { - routes: results, - total: results.length, - routesWithShapes: results.length, - ...(mismatchCount > 0 ? { mismatches: mismatchCount } : {}), - message: - results.length === 0 - ? 'No routes with both response shapes and consumers found.' - : mismatchCount > 0 - ? `Found ${results.length} route(s) with response shape data. ${mismatchCount} route(s) have consumer/shape mismatches.` - : `Found ${results.length} route(s) with response shape data and consumers.`, - }; - } - - private async toolMap(repo: RepoHandle, params: { tool?: string }): Promise { - await this.ensureInitialized(repo.id); - - const toolFilter = params.tool ? `AND n.name CONTAINS $tool` : ''; - const queryParams = params.tool ? { tool: params.tool } : {}; - - const rows = await executeParameterized( - repo.id, - ` - MATCH (n:Tool) - WHERE n.id STARTS WITH 'Tool:' ${toolFilter} - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.description AS description - `, - queryParams, - ); - - if (rows.length === 0) { - return { - tools: [], - total: 0, - message: params.tool ? `No tools matching "${params.tool}"` : 'No tool definitions found.', - }; - } - - const toolIds = rows.map((r: any) => r.id ?? r[0]); - const flowMap = await this.fetchLinkedFlowsBatch(repo.id, toolIds); - - return { - tools: rows.map((r: any) => { - const id = r.id ?? r[0]; - return { - name: r.name ?? r[1], - filePath: r.filePath ?? r[2], - description: (r.description ?? r[3] ?? '').slice(0, 200), - flows: flowMap.get(id) || [], - }; - }), - total: rows.length, - }; - } - - private async apiImpact( - repo: RepoHandle, - params: { route?: string; file?: string }, - ): Promise { - await this.ensureInitialized(repo.id); - - if (!params.route && !params.file) { - return { error: 'Either "route" or "file" parameter is required.' }; - } - - // If file is provided but route is not, look up the route by file path - let routeFilter = ''; - const queryParams: Record = {}; - - if (params.route) { - routeFilter = `AND n.name CONTAINS $route`; - queryParams.route = params.route; - } else if (params.file) { - routeFilter = `AND n.filePath CONTAINS $file`; - queryParams.file = params.file; - } - - const routes = await this.fetchRoutesWithConsumers(repo.id, routeFilter, queryParams); - - if (routes.length === 0) { - const target = params.route || params.file; - return { error: `No routes found matching "${target}".` }; - } - - const flowMap = await this.fetchLinkedFlowsBatch( - repo.id, - routes.map((r) => r.id), - ); - - // Count how many routes share the same handler file (for middleware partial detection) - const routeCountByHandler = new Map(); - for (const r of routes) { - if (r.filePath) { - routeCountByHandler.set(r.filePath, (routeCountByHandler.get(r.filePath) ?? 0) + 1); - } - } - - const results = routes.map((r) => { - // Keys already normalized by fetchRoutesWithConsumers (quotes stripped) - const responseKeys = r.responseKeys ?? []; - const errorKeys = r.errorKeys ?? []; - const allKnownKeys = new Set([...responseKeys, ...errorKeys]); - - // Build consumer list with mismatch detection - const consumers = r.consumers.map((c) => ({ - name: c.name, - file: c.filePath, - accesses: c.accessedKeys ?? [], - ...(c.fetchCount && c.fetchCount > 1 - ? { - attributionNote: `This file fetches ${c.fetchCount} routes — accessed keys may belong to a different route.`, - } - : {}), - })); - - // Detect mismatches: consumer accesses keys not in response shape - const mismatches: Array<{ - consumer: string; - field: string; - reason: string; - confidence: 'high' | 'low'; - }> = []; - if (allKnownKeys.size > 0) { - for (const c of r.consumers) { - if (!c.accessedKeys) continue; - const isMultiFetch = (c.fetchCount ?? 1) > 1; - for (const key of c.accessedKeys) { - if (!allKnownKeys.has(key)) { - mismatches.push({ - consumer: c.filePath, - field: key, - reason: 'accessed but not in response shape', - confidence: isMultiFetch ? 'low' : 'high', - }); - } - } - } - } - - const flows = flowMap.get(r.id) || []; - const consumerCount = r.consumers.length; - - // Risk level heuristic - let riskLevel: 'LOW' | 'MEDIUM' | 'HIGH'; - if (consumerCount >= 10) { - riskLevel = 'HIGH'; - } else if (consumerCount >= 4) { - riskLevel = 'MEDIUM'; - } else { - riskLevel = 'LOW'; - } - // Bump up one level if mismatches exist - if (mismatches.length > 0) { - if (riskLevel === 'LOW') riskLevel = 'MEDIUM'; - else if (riskLevel === 'MEDIUM') riskLevel = 'HIGH'; - } - - const warning = - consumerCount > 0 - ? `Changing response shape will affect ${consumerCount} component${consumerCount === 1 ? '' : 's'}` - : undefined; - - // Flag when middleware was detected but handler exports multiple HTTP methods - // (middleware chain may only reflect one export) - const middlewareArr = r.middleware || []; - const handlerRouteCount = r.filePath ? (routeCountByHandler.get(r.filePath) ?? 1) : 1; - const middlewarePartial = middlewareArr.length > 0 && handlerRouteCount > 1; - - return { - route: r.name, - handler: r.filePath, - responseShape: { - success: responseKeys, - error: errorKeys, - }, - middleware: middlewareArr, - ...(middlewarePartial - ? { - middlewareDetection: 'partial' as const, - middlewareNote: - 'Middleware captured from first HTTP method export only — other methods in this handler may use different middleware chains.', - } - : {}), - consumers, - ...(mismatches.length > 0 ? { mismatches } : {}), - executionFlows: flows, - impactSummary: { - directConsumers: consumerCount, - affectedFlows: flows.length, - riskLevel, - ...(warning ? { warning } : {}), - }, - }; - }); - - // If a single route was targeted, return it directly (not wrapped in array) - if (results.length === 1) { - return results[0]; - } - - return { routes: results, total: results.length }; - } - - // ─── Direct Graph Queries (for resources.ts) ──────────────────── - - /** - * Query clusters (communities) directly from graph. - * Used by getClustersResource — avoids legacy overview() dispatch. - */ - async queryClusters(repoName?: string, limit = 100): Promise<{ clusters: any[] }> { - const repo = await this.resolveRepo(repoName); - await this.ensureInitialized(repo.id); - - try { - const rawLimit = Math.max(limit * 5, 200); - const clusters = await executeQuery( - repo.id, - ` - MATCH (c:Community) - RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount - ORDER BY c.symbolCount DESC - LIMIT ${rawLimit} - `, - ); - const rawClusters = clusters.map((c: any) => ({ - id: c.id || c[0], - label: c.label || c[1], - heuristicLabel: c.heuristicLabel || c[2], - cohesion: c.cohesion || c[3], - symbolCount: c.symbolCount || c[4], - })); - return { clusters: this.aggregateClusters(rawClusters).slice(0, limit) }; - } catch { - return { clusters: [] }; - } - } - - /** - * Query processes directly from graph. - * Used by getProcessesResource — avoids legacy overview() dispatch. - */ - async queryProcesses(repoName?: string, limit = 50): Promise<{ processes: any[] }> { - const repo = await this.resolveRepo(repoName); - await this.ensureInitialized(repo.id); - - try { - const processes = await executeQuery( - repo.id, - ` - MATCH (p:Process) - RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount - ORDER BY p.stepCount DESC - LIMIT ${limit} - `, - ); - return { - processes: processes.map((p: any) => ({ - id: p.id || p[0], - label: p.label || p[1], - heuristicLabel: p.heuristicLabel || p[2], - processType: p.processType || p[3], - stepCount: p.stepCount || p[4], - })), - }; - } catch { - return { processes: [] }; - } - } - - /** - * Query cluster detail (members) directly from graph. - * Used by getClusterDetailResource. - */ - async queryClusterDetail(name: string, repoName?: string): Promise { - const repo = await this.resolveRepo(repoName); - await this.ensureInitialized(repo.id); - - const clusters = await executeParameterized( - repo.id, - ` - MATCH (c:Community) - WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName - RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount - `, - { clusterName: name }, - ); - if (clusters.length === 0) return { error: `Cluster '${name}' not found` }; - - const rawClusters = clusters.map((c: any) => ({ - id: c.id || c[0], - label: c.label || c[1], - heuristicLabel: c.heuristicLabel || c[2], - cohesion: c.cohesion || c[3], - symbolCount: c.symbolCount || c[4], - })); - - let totalSymbols = 0, - weightedCohesion = 0; - for (const c of rawClusters) { - const s = c.symbolCount || 0; - totalSymbols += s; - weightedCohesion += (c.cohesion || 0) * s; - } - - const members = await executeParameterized( - repo.id, - ` - MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName - RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath - LIMIT 30 - `, - { clusterName: name }, - ); - - return { - cluster: { - id: rawClusters[0].id, - label: rawClusters[0].heuristicLabel || rawClusters[0].label, - heuristicLabel: rawClusters[0].heuristicLabel || rawClusters[0].label, - cohesion: totalSymbols > 0 ? weightedCohesion / totalSymbols : 0, - symbolCount: totalSymbols, - subCommunities: rawClusters.length, - }, - members: members.map((m: any) => ({ - name: m.name || m[0], - type: m.type || m[1], - filePath: m.filePath || m[2], - })), - }; - } - - /** - * Query process detail (steps) directly from graph. - * Used by getProcessDetailResource. - */ - async queryProcessDetail(name: string, repoName?: string): Promise { - const repo = await this.resolveRepo(repoName); - await this.ensureInitialized(repo.id); - - const processes = await executeParameterized( - repo.id, - ` - MATCH (p:Process) - WHERE p.label = $processName OR p.heuristicLabel = $processName - RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount - LIMIT 1 - `, - { processName: name }, - ); - if (processes.length === 0) return { error: `Process '${name}' not found` }; - - const proc = processes[0]; - const procId = proc.id || proc[0]; - const steps = await executeParameterized( - repo.id, - ` - MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId}) - RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step - ORDER BY r.step - `, - { procId }, - ); - - return { - process: { - id: procId, - label: proc.label || proc[1], - heuristicLabel: proc.heuristicLabel || proc[2], - processType: proc.processType || proc[3], - stepCount: proc.stepCount || proc[4], - }, - steps: steps.map((s: any) => ({ - step: s.step || s[3], - name: s.name || s[0], - type: s.type || s[1], - filePath: s.filePath || s[2], - })), - }; - } - - async disconnect(): Promise { - await closeLbug(); // close all connections - // Note: we intentionally do NOT call disposeEmbedder() here. - // ONNX Runtime's native cleanup segfaults on macOS and some Linux configs, - // and importing the embedder module on Node v24+ crashes if onnxruntime - // was never loaded during the session. Since process.exit(0) follows - // immediately after disconnect(), the OS reclaims everything. See #38, #89. - this.repos.clear(); - this.contextCache.clear(); - this.initializedRepos.clear(); - } -} diff --git a/gitnexus/test/unit/security.test.ts b/gitnexus/test/unit/security.test.ts index 7e37b55a3..059f9c150 100644 --- a/gitnexus/test/unit/security.test.ts +++ b/gitnexus/test/unit/security.test.ts @@ -106,7 +106,7 @@ describe('isWriteQuery', () => { describe('VALID_RELATION_TYPES', () => { it('contains all expected relation types', () => { - expect(VALID_RELATION_TYPES.size).toBe(15); + expect(VALID_RELATION_TYPES.size).toBe(17); for (const t of [ 'CALLS', 'IMPORTS', diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 75858b8f3..6ee559bc2 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -62,6 +62,9 @@ export default defineConfig({ 'test/integration/lbug-lock-retry.test.ts', 'test/integration/api-impact-e2e.test.ts', 'test/integration/shape-check-regression.test.ts', + 'test/integration/class-impact-all-languages.test.ts', + 'test/integration/java-class-impact.test.ts', + 'test/integration/namespace-isolation.test.ts', ], fileParallelism: false, sequence: { groupOrder: 1 }, @@ -87,6 +90,9 @@ export default defineConfig({ 'test/integration/lbug-lock-retry.test.ts', 'test/integration/api-impact-e2e.test.ts', 'test/integration/shape-check-regression.test.ts', + 'test/integration/class-impact-all-languages.test.ts', + 'test/integration/java-class-impact.test.ts', + 'test/integration/namespace-isolation.test.ts', ], }, },