diff --git a/README.md b/README.md index 2a1213b77..3061e1958 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas ### Supported Languages -TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Rust, PHP, Swift +TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Ruby, Rust, PHP, Swift --- diff --git a/gitnexus-web/public/wasm/ruby/tree-sitter-ruby.wasm b/gitnexus-web/public/wasm/ruby/tree-sitter-ruby.wasm new file mode 100644 index 000000000..c7a619c96 Binary files /dev/null and b/gitnexus-web/public/wasm/ruby/tree-sitter-ruby.wasm differ diff --git a/gitnexus-web/src/components/CodeReferencesPanel.tsx b/gitnexus-web/src/components/CodeReferencesPanel.tsx index 4bda5cca0..021ccd902 100644 --- a/gitnexus-web/src/components/CodeReferencesPanel.tsx +++ b/gitnexus-web/src/components/CodeReferencesPanel.tsx @@ -5,6 +5,42 @@ import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { useAppState } from '../hooks/useAppState'; import { NODE_COLORS } from '../lib/constants'; +/** Map file extension to Prism syntax highlighter language identifier */ +const getSyntaxLanguage = (filePath: string | undefined): string => { + if (!filePath) return 'text'; + const ext = filePath.split('.').pop()?.toLowerCase(); + switch (ext) { + case 'js': case 'jsx': case 'mjs': case 'cjs': return 'javascript'; + case 'ts': case 'tsx': case 'mts': case 'cts': return 'typescript'; + case 'py': case 'pyw': return 'python'; + case 'rb': case 'rake': case 'gemspec': return 'ruby'; + case 'java': return 'java'; + case 'go': return 'go'; + case 'rs': return 'rust'; + case 'c': case 'h': return 'c'; + case 'cpp': case 'cc': case 'cxx': case 'hpp': case 'hxx': case 'hh': return 'cpp'; + case 'cs': return 'csharp'; + case 'php': return 'php'; + case 'kt': case 'kts': return 'kotlin'; + case 'swift': return 'swift'; + case 'json': return 'json'; + case 'yaml': case 'yml': return 'yaml'; + case 'md': case 'mdx': return 'markdown'; + case 'html': case 'htm': case 'erb': return 'markup'; + case 'css': case 'scss': case 'sass': return 'css'; + case 'sh': case 'bash': case 'zsh': return 'bash'; + case 'sql': return 'sql'; + case 'xml': return 'xml'; + default: break; + } + // Handle extensionless Ruby files + const basename = filePath.split('/').pop() || ''; + if (['Rakefile', 'Gemfile', 'Guardfile', 'Vagrantfile', 'Brewfile'].includes(basename)) return 'ruby'; + if (['Makefile'].includes(basename)) return 'makefile'; + if (['Dockerfile'].includes(basename)) return 'docker'; + return 'text'; +}; + // Match the code theme used elsewhere in the app const customTheme = { ...vscDarkPlus, @@ -267,12 +303,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
{selectedFileContent ? ( c.type === 'identifier'); funcName = nameNode?.text; label = 'Method'; // Treat constructors as methods for process detection + } else if (current.type === 'method') { + // Ruby instance method: def foo + const nameNode = current.childForFieldName?.('name') || + current.children?.find((c: any) => c.type === 'identifier'); + funcName = nameNode?.text; + label = 'Method'; + } else if (current.type === 'singleton_method') { + // Ruby class method: def self.foo + const nameNode = current.childForFieldName?.('name') || + current.children?.find((c: any) => c.type === 'identifier'); + funcName = nameNode?.text; + label = 'Function'; } else if (current.type === 'arrow_function' || current.type === 'function_expression') { // Arrow/expression: const foo = () => {} - check parent variable declarator const parent = current.parent; @@ -184,6 +201,62 @@ export const processCalls = async ( const calledName = nameNode.text; + // Ruby: route special calls to heritage or properties (imports handled by import-processor) + if (language === SupportedLanguages.Ruby) { + const callNode = captureMap['call']; + const routed = routeRubyCall(calledName, callNode); + + switch (routed.kind) { + case 'skip': + case 'import': // handled by import-processor + return; + + case 'heritage': + for (const item of routed.items) { + const childId = symbolTable.lookupExact(file.path, item.enclosingClass) || + symbolTable.lookupFuzzy(item.enclosingClass)[0]?.nodeId || + generateId('Class', `${file.path}:${item.enclosingClass}`); + const parentId = symbolTable.lookupFuzzy(item.mixinName)[0]?.nodeId || + generateId('Module', `${item.mixinName}`); + if (childId && parentId) { + const relId = generateId('IMPLEMENTS', `${childId}->${parentId}`); + graph.addRelationship({ + id: relId, sourceId: childId, targetId: parentId, + type: 'IMPLEMENTS', confidence: 1.0, reason: 'trait-impl', + }); + } + } + return; + + case 'properties': { + const fileId = generateId('File', file.path); + for (const item of routed.items) { + const nodeId = generateId('Property', `${file.path}:${item.propName}`); + graph.addNode({ + id: nodeId, + label: 'Property' as any, // TODO: add 'Property' to graph node label union + properties: { + name: item.propName, filePath: file.path, + startLine: item.startLine, endLine: item.endLine, + language: SupportedLanguages.Ruby, isExported: true, + description: item.accessorType, + }, + }); + symbolTable.add(file.path, item.propName, nodeId, 'Property'); + const relId = generateId('DEFINES', `${fileId}->${nodeId}`); + graph.addRelationship({ + id: relId, sourceId: fileId, targetId: nodeId, + type: 'DEFINES', confidence: 1.0, reason: '', + }); + } + return; + } + + case 'call': + break; // fall through to normal call processing below + } + } + // Skip common built-ins and noise if (isBuiltInOrNoise(calledName)) return; @@ -200,10 +273,10 @@ export const processCalls = async ( // 5. Find the enclosing function (caller) const callNode = captureMap['call']; const enclosingFuncId = findEnclosingFunction(callNode, file.path, symbolTable); - + // Use enclosing function as source, fallback to file for top-level calls const sourceId = enclosingFuncId || generateId('File', file.path); - + const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`); graph.addRelationship({ @@ -711,37 +784,72 @@ const resolveCallTarget = ( * Filter out common built-in functions and noise * that shouldn't be tracked as calls */ -const isBuiltInOrNoise = (name: string): boolean => { - const builtIns = new Set([ - // JavaScript/TypeScript built-ins - 'console', 'log', 'warn', 'error', 'info', 'debug', - 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', - 'parseInt', 'parseFloat', 'isNaN', 'isFinite', - 'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent', - 'JSON', 'parse', 'stringify', - 'Object', 'Array', 'String', 'Number', 'Boolean', 'Symbol', 'BigInt', - 'Map', 'Set', 'WeakMap', 'WeakSet', - 'Promise', 'resolve', 'reject', 'then', 'catch', 'finally', - 'Math', 'Date', 'RegExp', 'Error', - 'require', 'import', 'export', - 'fetch', 'Response', 'Request', - // React hooks and common functions - 'useState', 'useEffect', 'useCallback', 'useMemo', 'useRef', 'useContext', - 'useReducer', 'useLayoutEffect', 'useImperativeHandle', 'useDebugValue', - 'createElement', 'createContext', 'createRef', 'forwardRef', 'memo', 'lazy', - // Common array/object methods - 'map', 'filter', 'reduce', 'forEach', 'find', 'findIndex', 'some', 'every', - 'includes', 'indexOf', 'slice', 'splice', 'concat', 'join', 'split', - 'push', 'pop', 'shift', 'unshift', 'sort', 'reverse', - 'keys', 'values', 'entries', 'assign', 'freeze', 'seal', - 'hasOwnProperty', 'toString', 'valueOf', - // Python built-ins - 'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple', - 'open', 'read', 'write', 'close', 'append', 'extend', 'update', - 'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr', - 'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs', - ]); +/** Pre-built set (module-level singleton) to avoid re-creating per call */ +const BUILT_IN_NAMES = new Set([ + // JavaScript/TypeScript built-ins + 'console', 'log', 'warn', 'error', 'info', 'debug', + 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', + 'parseInt', 'parseFloat', 'isNaN', 'isFinite', + 'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent', + 'JSON', 'parse', 'stringify', + 'Object', 'Array', 'String', 'Number', 'Boolean', 'Symbol', 'BigInt', + 'Map', 'Set', 'WeakMap', 'WeakSet', + 'Promise', 'resolve', 'reject', 'then', 'catch', 'finally', + 'Math', 'Date', 'RegExp', 'Error', + 'require', 'import', 'export', + 'fetch', 'Response', 'Request', + // React hooks and common functions + 'useState', 'useEffect', 'useCallback', 'useMemo', 'useRef', 'useContext', + 'useReducer', 'useLayoutEffect', 'useImperativeHandle', 'useDebugValue', + 'createElement', 'createContext', 'createRef', 'forwardRef', 'memo', 'lazy', + // Common array/object methods + 'map', 'filter', 'reduce', 'forEach', 'find', 'findIndex', 'some', 'every', + 'includes', 'indexOf', 'slice', 'splice', 'concat', 'join', 'split', + 'push', 'pop', 'shift', 'unshift', 'sort', 'reverse', + 'keys', 'values', 'entries', 'assign', 'freeze', 'seal', + 'hasOwnProperty', 'toString', 'valueOf', + // Python built-ins + 'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple', + 'open', 'read', 'write', 'close', 'append', 'extend', 'update', + 'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr', + 'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs', + // C/C++ standard library and common kernel helpers + 'printf', 'fprintf', 'sprintf', 'snprintf', 'vprintf', 'vfprintf', 'vsprintf', 'vsnprintf', + 'scanf', 'fscanf', 'sscanf', + 'malloc', 'calloc', 'realloc', 'free', 'memcpy', 'memmove', 'memset', 'memcmp', + 'strlen', 'strcpy', 'strncpy', 'strcat', 'strncat', 'strcmp', 'strncmp', 'strstr', 'strchr', 'strrchr', + 'atoi', 'atol', 'atof', 'strtol', 'strtoul', 'strtoll', 'strtoull', 'strtod', + 'sizeof', 'offsetof', 'typeof', + 'assert', 'abort', 'exit', '_exit', + 'fopen', 'fclose', 'fread', 'fwrite', 'fseek', 'ftell', 'rewind', 'fflush', 'fgets', 'fputs', + // Linux kernel common macros/helpers (not real call targets) + 'likely', 'unlikely', 'BUG', 'BUG_ON', 'WARN', 'WARN_ON', 'WARN_ONCE', + 'IS_ERR', 'PTR_ERR', 'ERR_PTR', 'IS_ERR_OR_NULL', + 'ARRAY_SIZE', 'container_of', 'list_for_each_entry', 'list_for_each_entry_safe', + 'min', 'max', 'clamp', 'abs', 'swap', + 'pr_info', 'pr_warn', 'pr_err', 'pr_debug', 'pr_notice', 'pr_crit', 'pr_emerg', + 'printk', 'dev_info', 'dev_warn', 'dev_err', 'dev_dbg', + 'GFP_KERNEL', 'GFP_ATOMIC', + 'spin_lock', 'spin_unlock', 'spin_lock_irqsave', 'spin_unlock_irqrestore', + 'mutex_lock', 'mutex_unlock', 'mutex_init', + 'kfree', 'kmalloc', 'kzalloc', 'kcalloc', 'krealloc', 'kvmalloc', 'kvfree', + 'get', 'put', + // Ruby built-ins and Kernel methods + 'puts', 'print', 'p', 'pp', 'warn', 'raise', 'fail', + 'require', 'require_relative', 'load', 'autoload', + 'include', 'extend', 'prepend', + 'attr_accessor', 'attr_reader', 'attr_writer', + 'public', 'private', 'protected', 'module_function', + 'lambda', 'proc', 'block_given?', + 'nil?', 'is_a?', 'kind_of?', 'instance_of?', 'respond_to?', + 'freeze', 'frozen?', 'dup', 'clone', 'tap', 'then', 'yield_self', + // Ruby enumerables + 'each', 'map', 'select', 'reject', 'find', 'detect', 'collect', + 'inject', 'reduce', 'flat_map', 'each_with_object', 'each_with_index', + 'any?', 'all?', 'none?', 'count', 'first', 'last', + 'sort', 'sort_by', 'min', 'max', 'min_by', 'max_by', + 'group_by', 'partition', 'zip', 'compact', 'flatten', 'uniq', +]); - return builtIns.has(name); -}; +const isBuiltInOrNoise = (name: string): boolean => BUILT_IN_NAMES.has(name); diff --git a/gitnexus-web/src/core/ingestion/entry-point-scoring.ts b/gitnexus-web/src/core/ingestion/entry-point-scoring.ts index 89645769e..f53ce06d6 100644 --- a/gitnexus-web/src/core/ingestion/entry-point-scoring.ts +++ b/gitnexus-web/src/core/ingestion/entry-point-scoring.ts @@ -13,7 +13,7 @@ import { detectFrameworkFromPath } from './framework-detection'; // ============================================================================ -// NAME PATTERNS - All 9 supported languages +// NAME PATTERNS - All 11 supported languages // ============================================================================ /** @@ -143,6 +143,13 @@ const ENTRY_POINT_PATTERNS: Record = { /^save$/, // Repository::save() /^delete$/, // Repository::delete() ], + + // Ruby + 'ruby': [ + /^call$/, // Service objects (MyService.call) + /^perform$/, // Background jobs (Sidekiq, ActiveJob) + /^execute$/, // Command pattern + ], }; // ============================================================================ @@ -302,7 +309,12 @@ export function isTestFile(filePath: string): boolean { p.endsWith('test.php') || p.endsWith('spec.php') || p.includes('/tests/feature/') || - p.includes('/tests/unit/') + p.includes('/tests/unit/') || + // Ruby test patterns + p.endsWith('_spec.rb') || + p.endsWith('_test.rb') || + p.includes('/spec/') || + p.includes('/test/fixtures/') ); } diff --git a/gitnexus-web/src/core/ingestion/framework-detection.ts b/gitnexus-web/src/core/ingestion/framework-detection.ts index 15f681fad..b190e4baf 100644 --- a/gitnexus-web/src/core/ingestion/framework-detection.ts +++ b/gitnexus-web/src/core/ingestion/framework-detection.ts @@ -257,6 +257,17 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-repository' }; } + // ========== RUBY ========== + + // Ruby: bin/ or exe/ (CLI entry points) + if ((p.includes('/bin/') || p.includes('/exe/')) && p.endsWith('.rb')) { + return { framework: 'ruby', entryPointMultiplier: 2.5, reason: 'ruby-executable' }; + } + + // Ruby: Rakefile or *.rake (task definitions) + if (p.endsWith('/rakefile') || p.endsWith('.rake')) { + return { framework: 'ruby', entryPointMultiplier: 1.5, reason: 'ruby-rake' }; + } // ========== SWIFT / iOS ========== // iOS App entry points (highest priority) diff --git a/gitnexus-web/src/core/ingestion/import-processor.ts b/gitnexus-web/src/core/ingestion/import-processor.ts index c0cb6bd68..e49ab7aa9 100644 --- a/gitnexus-web/src/core/ingestion/import-processor.ts +++ b/gitnexus-web/src/core/ingestion/import-processor.ts @@ -4,6 +4,7 @@ import { loadParser, loadLanguage } from '../tree-sitter/parser-loader'; import { LANGUAGE_QUERIES } from './tree-sitter-queries'; import { generateId } from '../../lib/utils'; import { getLanguageFromFilename } from './utils'; +import { routeRubyCall } from './ruby-call-routing'; // Type: Map> // Stores all files that a given file imports from @@ -53,7 +54,9 @@ const resolveImportPath = ( // Go '.go', // Rust - '.rs', '/mod.rs' + '.rs', '/mod.rs', + // Ruby + '.rb', '.rake', ]; if (importPath.startsWith('.')) { @@ -220,6 +223,34 @@ export const processImports = async ( importMap.get(file.path)!.add(resolvedPath); } } + + // ---- Ruby: require/require_relative come through @call, not @import ---- + if (language === 'ruby' && captureMap['call']) { + const callNameNode = captureMap['call.name']; + if (callNameNode) { + const routed = routeRubyCall(callNameNode.text, captureMap['call']); + if (routed.kind === 'import') { + totalImportsFound++; + const resolvedPath = resolveImportPath( + file.path, routed.importPath, allFilePaths, allFileList, resolveCache + ); + if (resolvedPath) { + const sourceId = generateId('File', file.path); + const targetId = generateId('File', resolvedPath); + const relId = generateId('IMPORTS', `${file.path}->${resolvedPath}`); + totalImportsResolved++; + graph.addRelationship({ + id: relId, sourceId, targetId, + type: 'IMPORTS', confidence: 1.0, reason: '', + }); + if (!importMap.has(file.path)) { + importMap.set(file.path, new Set()); + } + importMap.get(file.path)!.add(resolvedPath); + } + } + } + } }); // If re-parsed just for this, delete the tree to save memory diff --git a/gitnexus-web/src/core/ingestion/parsing-processor.ts b/gitnexus-web/src/core/ingestion/parsing-processor.ts index 807bcf581..a063db6cc 100644 --- a/gitnexus-web/src/core/ingestion/parsing-processor.ts +++ b/gitnexus-web/src/core/ingestion/parsing-processor.ts @@ -14,7 +14,7 @@ export type FileProgressCallback = (current: number, total: number, filePath: st /** * Check if a symbol (function, class, etc.) is exported/public - * Handles all 9 supported languages with explicit logic + * Handles all 11 supported languages with explicit logic * * @param node - The AST node for the symbol name * @param name - The symbol name @@ -104,7 +104,11 @@ const isNodeExported = (node: any, name: string, language: string): boolean => { case 'c': case 'cpp': return false; - + + // Ruby: All top-level definitions are public by default + case 'ruby': + return true; + default: return false; } diff --git a/gitnexus-web/src/core/ingestion/ruby-call-routing.ts b/gitnexus-web/src/core/ingestion/ruby-call-routing.ts new file mode 100644 index 000000000..c634742d3 --- /dev/null +++ b/gitnexus-web/src/core/ingestion/ruby-call-routing.ts @@ -0,0 +1,116 @@ +/** + * Shared Ruby call routing logic. + * + * Ruby expresses imports, heritage (mixins), and property definitions as + * method calls rather than syntax-level constructs. This module provides a + * routing function used by the CLI call-processor, CLI parse-worker, and + * the web call-processor so that the classification logic lives in one place. + * + * NOTE: This file is intentionally duplicated in gitnexus-web/ because the + * two packages have separate build targets (Node native vs WASM/browser). + * Keep both copies in sync until a shared package is introduced. + */ + +// ── Result types ──────────────────────────────────────────────────────────── + +export type RubyCallRouting = + | { kind: 'import'; importPath: string; isRelative: boolean } + | { kind: 'heritage'; items: RubyHeritageItem[] } + | { kind: 'properties'; items: RubyPropertyItem[] } + | { kind: 'call' } + | { kind: 'skip' }; + +export interface RubyHeritageItem { + enclosingClass: string; + mixinName: string; +} + +export type RubyAccessorType = 'attr_accessor' | 'attr_reader' | 'attr_writer'; + +export interface RubyPropertyItem { + propName: string; + accessorType: RubyAccessorType; + startLine: number; + endLine: number; +} + +// ── Pre-allocated singletons for common return values ──────────────────────── +const CALL_RESULT: RubyCallRouting = { kind: 'call' }; +const SKIP_RESULT: RubyCallRouting = { kind: 'skip' }; + +/** Max depth for parent-walking loops to prevent pathological AST traversals */ +const MAX_PARENT_DEPTH = 50; + +// ── Routing function ──────────────────────────────────────────────────────── + +/** + * Classify a Ruby call node and extract its semantic payload. + * + * @param calledName - The method name (e.g. 'require', 'include', 'attr_accessor') + * @param callNode - The tree-sitter `call` AST node + * @returns A discriminated union describing the call's semantic role + */ +export function routeRubyCall(calledName: string, callNode: any): RubyCallRouting { + // ── require / require_relative → import ───────────────────────────────── + if (calledName === 'require' || calledName === 'require_relative') { + const argList = callNode.childForFieldName?.('arguments'); + const stringNode = argList?.children?.find((c: any) => c.type === 'string'); + const contentNode = stringNode?.children?.find((c: any) => c.type === 'string_content'); + if (!contentNode) return SKIP_RESULT; + + let importPath: string = contentNode.text; + // Validate: reject null bytes, control chars, excessively long paths + if (!importPath || importPath.length > 1024 || /[\x00-\x1f]/.test(importPath)) { + return SKIP_RESULT; + } + const isRelative = calledName === 'require_relative'; + if (isRelative && !importPath.startsWith('.')) { + importPath = './' + importPath; + } + return { kind: 'import', importPath, isRelative }; + } + + // ── include / extend / prepend → heritage (mixin) ────────────────────── + if (calledName === 'include' || calledName === 'extend' || calledName === 'prepend') { + let enclosingClass: string | null = null; + let current = callNode.parent; + let depth = 0; + while (current && ++depth <= MAX_PARENT_DEPTH) { + if (current.type === 'class' || current.type === 'module') { + const nameNode = current.childForFieldName?.('name'); + if (nameNode) { enclosingClass = nameNode.text; break; } + } + current = current.parent; + } + if (!enclosingClass) return SKIP_RESULT; + + const items: RubyHeritageItem[] = []; + const argList = callNode.childForFieldName?.('arguments'); + for (const arg of (argList?.children ?? [])) { + if (arg.type === 'constant' || arg.type === 'scope_resolution') { + items.push({ enclosingClass, mixinName: arg.text }); + } + } + return items.length > 0 ? { kind: 'heritage', items } : SKIP_RESULT; + } + + // ── attr_accessor / attr_reader / attr_writer → property definitions ─── + if (calledName === 'attr_accessor' || calledName === 'attr_reader' || calledName === 'attr_writer') { + const items: RubyPropertyItem[] = []; + const argList = callNode.childForFieldName?.('arguments'); + for (const arg of (argList?.children ?? [])) { + if (arg.type === 'simple_symbol') { + items.push({ + propName: arg.text.startsWith(':') ? arg.text.slice(1) : arg.text, + accessorType: calledName as RubyAccessorType, + startLine: arg.startPosition.row, + endLine: arg.endPosition.row, + }); + } + } + return items.length > 0 ? { kind: 'properties', items } : SKIP_RESULT; + } + + // ── Everything else → regular call ───────────────────────────────────── + return CALL_RESULT; +} diff --git a/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts b/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts index 3ba476ba3..acef63c85 100644 --- a/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts @@ -396,6 +396,40 @@ export const PHP_QUERIES = ` [(name) (qualified_name)] @heritage.trait))) @heritage `; +// Ruby queries - works with tree-sitter-ruby +// NOTE: Ruby uses `call` for require, include, extend, prepend, attr_* etc. +// These are all captured as @call and routed in JS post-processing: +// - require/require_relative → import extraction +// - include/extend/prepend → heritage (mixin) extraction +// - attr_accessor/attr_reader/attr_writer → property definition extraction +// - everything else → regular call extraction +export const RUBY_QUERIES = ` +; ── Modules ────────────────────────────────────────────────────────────────── +(module + name: (constant) @name) @definition.module + +; ── Classes ────────────────────────────────────────────────────────────────── +(class + name: (constant) @name) @definition.class + +; ── Instance methods ───────────────────────────────────────────────────────── +(method + name: (identifier) @name) @definition.method + +; ── Singleton (class-level) methods ────────────────────────────────────────── +(singleton_method + name: (identifier) @name) @definition.function + +; ── All calls (require, include, attr_*, and regular calls routed in JS) ───── +(call + method: (identifier) @call.name) @call + +; ── Heritage: class < SuperClass ───────────────────────────────────────────── +(class + name: (constant) @heritage.class + superclass: (superclass + (constant) @heritage.extends)) @heritage`; + // Swift queries - works with tree-sitter-swift export const SWIFT_QUERIES = ` ; Classes @@ -460,6 +494,7 @@ export const LANGUAGE_QUERIES: Record = { [SupportedLanguages.CSharp]: CSHARP_QUERIES, [SupportedLanguages.Rust]: RUST_QUERIES, [SupportedLanguages.PHP]: PHP_QUERIES, + [SupportedLanguages.Ruby]: RUBY_QUERIES, [SupportedLanguages.Swift]: SWIFT_QUERIES, }; \ No newline at end of file diff --git a/gitnexus-web/src/core/ingestion/utils.ts b/gitnexus-web/src/core/ingestion/utils.ts index c7479aaa6..22bb6d72e 100644 --- a/gitnexus-web/src/core/ingestion/utils.ts +++ b/gitnexus-web/src/core/ingestion/utils.ts @@ -1,5 +1,8 @@ import { SupportedLanguages } from '../../config/supported-languages'; +/** Ruby extensionless filenames recognised as Ruby source */ +const RUBY_EXTENSIONLESS_FILES = new Set(['Rakefile', 'Gemfile', 'Guardfile', 'Vagrantfile', 'Brewfile']); + /** * Map file extension to SupportedLanguage enum */ @@ -31,6 +34,15 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages | filename.endsWith('.php5') || filename.endsWith('.php8')) { return SupportedLanguages.PHP; } + // Ruby (extensions) + if (filename.endsWith('.rb') || filename.endsWith('.rake') || filename.endsWith('.gemspec')) { + return SupportedLanguages.Ruby; + } + // Ruby (extensionless files) + const basename = filename.split('/').pop() || filename; + if (RUBY_EXTENSIONLESS_FILES.has(basename)) { + return SupportedLanguages.Ruby; + } // Swift if (filename.endsWith('.swift')) return SupportedLanguages.Swift; return null; diff --git a/gitnexus-web/src/core/tree-sitter/parser-loader.ts b/gitnexus-web/src/core/tree-sitter/parser-loader.ts index e434874c4..7a4d6a85d 100644 --- a/gitnexus-web/src/core/tree-sitter/parser-loader.ts +++ b/gitnexus-web/src/core/tree-sitter/parser-loader.ts @@ -40,6 +40,7 @@ const getWasmPath = (language: SupportedLanguages, filePath?: string): string => [SupportedLanguages.Go]: '/wasm/go/tree-sitter-go.wasm', [SupportedLanguages.Rust]: '/wasm/rust/tree-sitter-rust.wasm', [SupportedLanguages.PHP]: '/wasm/php/tree-sitter-php.wasm', + [SupportedLanguages.Ruby]: '/wasm/ruby/tree-sitter-ruby.wasm', [SupportedLanguages.Swift]: '/wasm/swift/tree-sitter-swift.wasm', }; diff --git a/gitnexus/README.md b/gitnexus/README.md index 957da11b9..b637735f6 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -157,7 +157,7 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist ## Supported Languages -TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift +TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby ### Language Feature Matrix @@ -173,6 +173,7 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift | Rust | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | | PHP | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | | Swift | — | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | +| Ruby | ✓ | ✓ | ✓ | — | — | ✓ | ✓ | ✓ | | C | — | ✓ | ✓ | — | — | ✓ | ✓ | ✓ | | C++ | — | ✓ | ✓ | — | — | ✓ | ✓ | ✓ | diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 6d8c28e15..392c0eac3 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -31,9 +31,9 @@ "tree-sitter-go": "^0.21.0", "tree-sitter-java": "^0.21.0", "tree-sitter-javascript": "^0.21.0", - "tree-sitter-kotlin": "^0.3.8", "tree-sitter-php": "^0.23.12", "tree-sitter-python": "^0.21.0", + "tree-sitter-ruby": "^0.23.1", "tree-sitter-rust": "^0.21.0", "tree-sitter-typescript": "^0.21.0", "uuid": "^13.0.0" @@ -56,6 +56,7 @@ "node": ">=18.0.0" }, "optionalDependencies": { + "tree-sitter-kotlin": "^0.3.8", "tree-sitter-swift": "^0.6.0" } }, @@ -5415,6 +5416,7 @@ "integrity": "sha512-A4obq6bjzmYrA+F0JLLoheFPcofFkctNaZSpnDd+GPn1SfVZLY4/GG4C0cYVBTOShuPBGGAOPLM1JWLZQV4m1g==", "hasInstallScript": true, "license": "MIT", + "optional": true, "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" @@ -5432,7 +5434,8 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/tree-sitter-php": { "version": "0.23.12", @@ -5487,6 +5490,34 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, + "node_modules/tree-sitter-ruby": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/tree-sitter-ruby/-/tree-sitter-ruby-0.23.1.tgz", + "integrity": "sha512-d9/RXgWjR6HanN7wTYhS5bpBQLz1VkH048Vm3CodPGyJVnamXMGb8oEhDypVCBq4QnHui9sTXuJBBP3WtCw5RA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-ruby/node_modules/node-addon-api": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", + "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/tree-sitter-rust": { "version": "0.21.0", "resolved": "https://registry.npmjs.org/tree-sitter-rust/-/tree-sitter-rust-0.21.0.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index 4a9ad4c34..44281ba4b 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -72,11 +72,13 @@ "tree-sitter-kotlin": "^0.3.8", "tree-sitter-php": "^0.23.12", "tree-sitter-python": "^0.21.0", + "tree-sitter-ruby": "^0.23.1", "tree-sitter-rust": "^0.21.0", "tree-sitter-typescript": "^0.21.0", "uuid": "^13.0.0" }, "optionalDependencies": { + "tree-sitter-kotlin": "^0.3.8", "tree-sitter-swift": "^0.6.0" }, "devDependencies": { diff --git a/gitnexus/src/config/supported-languages.ts b/gitnexus/src/config/supported-languages.ts index 9d67eaf05..48562033a 100644 --- a/gitnexus/src/config/supported-languages.ts +++ b/gitnexus/src/config/supported-languages.ts @@ -7,9 +7,9 @@ export enum SupportedLanguages { CPlusPlus = 'cpp', CSharp = 'csharp', Go = 'go', + Ruby = 'ruby', Rust = 'rust', PHP = 'php', Kotlin = 'kotlin', - // Ruby = 'ruby', Swift = 'swift', } \ No newline at end of file diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 6718faba8..81dc96318 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -2,12 +2,13 @@ import { KnowledgeGraph } from '../graph/types.js'; import { ASTCache } from './ast-cache.js'; import type { SymbolDefinition, SymbolTable } from './symbol-table.js'; import { ImportMap, PackageMap, NamedImportMap, isFileInPackageDir } from './import-processor.js'; -import { resolveSymbol, resolveSymbolInternal } from './symbol-resolver.js'; +import { resolveSymbolInternal } from './symbol-resolver.js'; import { walkBindingChain } from './named-binding-extraction.js'; import Parser from 'tree-sitter'; import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { LANGUAGE_QUERIES } from './tree-sitter-queries.js'; import { generateId } from '../../lib/utils.js'; +import { SupportedLanguages } from '../../config/supported-languages.js'; import { getLanguageFromFilename, isVerboseIngestionEnabled, @@ -21,7 +22,8 @@ import { } from './utils.js'; import { buildTypeEnv, lookupTypeEnv } from './type-env.js'; import { getTreeSitterBufferSize } from './constants.js'; -import type { ExtractedCall, ExtractedRoute } from './workers/parse-worker.js'; +import type { ExtractedCall, ExtractedHeritage, ExtractedRoute } from './workers/parse-worker.js'; +import { routeRubyCall } from './ruby-call-routing.js'; /** * Walk up the AST from a node to find the enclosing function/method. @@ -60,8 +62,9 @@ export const processCalls = async ( packageMap?: PackageMap, onProgress?: (current: number, total: number) => void, namedImportMap?: NamedImportMap, -) => { +): Promise => { const parser = await loadParser(); + const collectedHeritage: ExtractedHeritage[] = []; const logSkipped = isVerboseIngestionEnabled(); const skippedByLang = logSkipped ? new Map() : null; @@ -132,6 +135,56 @@ export const processCalls = async ( const calledName = nameNode.text; + // Ruby: route special calls to heritage or properties (imports handled by import-processor) + if (language === SupportedLanguages.Ruby) { + const callNode = captureMap['call']; + const routed = routeRubyCall(calledName, callNode); + + switch (routed.kind) { + case 'skip': + case 'import': // handled by import-processor + return; + + case 'heritage': + for (const item of routed.items) { + collectedHeritage.push({ + filePath: file.path, + className: item.enclosingClass, + parentName: item.mixinName, + kind: 'trait-impl', + }); + } + return; + + case 'properties': { + const fileId = generateId('File', file.path); + for (const item of routed.items) { + const nodeId = generateId('Property', `${file.path}:${item.propName}`); + graph.addNode({ + id: nodeId, + label: 'Property' as any, // TODO: add 'Property' to graph node label union + properties: { + name: item.propName, filePath: file.path, + startLine: item.startLine, endLine: item.endLine, + language: SupportedLanguages.Ruby, isExported: true, + description: item.accessorType, + }, + }); + symbolTable.add(file.path, item.propName, nodeId, 'Property'); + const relId = generateId('DEFINES', `${fileId}->${nodeId}`); + graph.addRelationship({ + id: relId, sourceId: fileId, targetId: nodeId, + type: 'DEFINES', confidence: 1.0, reason: '', + }); + } + return; + } + + case 'call': + break; // fall through to normal call processing below + } + } + // Skip common built-ins and noise if (isBuiltInOrNoise(calledName)) return; @@ -152,10 +205,10 @@ export const processCalls = async ( // 5. Find the enclosing function (caller) const enclosingFuncId = findEnclosingFunction(callNode, file.path, symbolTable); - + // Use enclosing function as source, fallback to file for top-level calls const sourceId = enclosingFuncId || generateId('File', file.path); - + const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`); graph.addRelationship({ @@ -178,6 +231,8 @@ export const processCalls = async ( ); } } + + return collectedHeritage; }; /** diff --git a/gitnexus/src/core/ingestion/entry-point-scoring.ts b/gitnexus/src/core/ingestion/entry-point-scoring.ts index b05be85e4..b58bbe33d 100644 --- a/gitnexus/src/core/ingestion/entry-point-scoring.ts +++ b/gitnexus/src/core/ingestion/entry-point-scoring.ts @@ -14,7 +14,7 @@ import { detectFrameworkFromPath } from './framework-detection.js'; import { SupportedLanguages } from '../../config/supported-languages.js'; // ============================================================================ -// NAME PATTERNS - All 9 supported languages +// NAME PATTERNS - All 11 supported languages // ============================================================================ /** @@ -191,6 +191,13 @@ const ENTRY_POINT_PATTERNS: Record = { /^save$/, // Repository::save() /^delete$/, // Repository::delete() ], + + // Ruby + [SupportedLanguages.Ruby]: [ + /^call$/, // Service objects (MyService.call) + /^perform$/, // Background jobs (Sidekiq, ActiveJob) + /^execute$/, // Command pattern + ], }; /** Pre-computed merged patterns (universal + language-specific) to avoid per-call array allocation. */ @@ -361,7 +368,12 @@ export function isTestFile(filePath: string): boolean { p.endsWith('test.php') || p.endsWith('spec.php') || p.includes('/tests/feature/') || - p.includes('/tests/unit/') + p.includes('/tests/unit/') || + // Ruby test patterns + p.endsWith('_spec.rb') || + p.endsWith('_test.rb') || + p.includes('/spec/') || + p.includes('/test/fixtures/') ); } diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index 822b297d2..e6c6e89df 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -222,6 +222,7 @@ const exportCheckers = { [SupportedLanguages.CPlusPlus]: cCppExportChecker, [SupportedLanguages.PHP]: phpExportChecker, [SupportedLanguages.Swift]: swiftExportChecker, + [SupportedLanguages.Ruby]: (_node, _name) => true, } satisfies Record; // ============================================================================ diff --git a/gitnexus/src/core/ingestion/framework-detection.ts b/gitnexus/src/core/ingestion/framework-detection.ts index eb392a762..f17d93b36 100644 --- a/gitnexus/src/core/ingestion/framework-detection.ts +++ b/gitnexus/src/core/ingestion/framework-detection.ts @@ -330,6 +330,18 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-repository' }; } + // ========== RUBY ========== + + // Ruby: bin/ or exe/ (CLI entry points) + if ((p.includes('/bin/') || p.includes('/exe/')) && p.endsWith('.rb')) { + return { framework: 'ruby', entryPointMultiplier: 2.5, reason: 'ruby-executable' }; + } + + // Ruby: Rakefile or *.rake (task definitions) + if (p.endsWith('/rakefile') || p.endsWith('.rake')) { + return { framework: 'ruby', entryPointMultiplier: 1.5, reason: 'ruby-rake' }; + } + // ========== SWIFT / iOS ========== // iOS App entry points (highest priority) diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 7533d8f4e..447bd98a5 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -31,6 +31,7 @@ import { resolvePhpImport, resolveRustImport, } from './resolvers/index.js'; +import { routeRubyCall } from './ruby-call-routing.js'; import type { SuffixIndex, TsconfigPaths, @@ -444,6 +445,24 @@ export const processImports = async ( const bindings = namedImportMap ? extractNamedBindings(captureMap['import'], language) : undefined; applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge, bindings, namedImportMap); } + + // ---- Ruby: require/require_relative come through @call, not @import ---- + if (language === SupportedLanguages.Ruby && captureMap['call']) { + const callNameNode = captureMap['call.name']; + if (callNameNode) { + const routed = routeRubyCall(callNameNode.text, captureMap['call']); + if (routed.kind === 'import') { + totalImportsFound++; + const resolvedPath = resolveImportPath( + file.path, routed.importPath, allFilePaths, allFileList, + normalizedFileList, resolveCache, language, configs.tsconfigPaths, index, + ); + if (resolvedPath) { + addImportEdge(file.path, resolvedPath); + } + } + } + } }); // Tree is now owned by the LRU cache — no manual delete needed diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 489e18ccf..e3cacd78c 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -273,8 +273,11 @@ export const runPipelineFromRepo = async ( .filter(p => chunkContents.has(p)) .map(p => ({ path: p, content: chunkContents.get(p)! })); astCache = createASTCache(chunkFiles.length); - await processCalls(graph, chunkFiles, astCache, symbolTable, importMap, packageMap, undefined, namedImportMap); + const rubyHeritage = await processCalls(graph, chunkFiles, astCache, symbolTable, importMap, packageMap, undefined, namedImportMap); await processHeritage(graph, chunkFiles, astCache, symbolTable, importMap, packageMap); + if (rubyHeritage.length > 0) { + await processHeritageFromExtracted(graph, rubyHeritage, symbolTable, importMap, packageMap); + } astCache.clear(); } diff --git a/gitnexus/src/core/ingestion/resolvers/index.ts b/gitnexus/src/core/ingestion/resolvers/index.ts index 693ad09a2..b1d6cabf1 100644 --- a/gitnexus/src/core/ingestion/resolvers/index.ts +++ b/gitnexus/src/core/ingestion/resolvers/index.ts @@ -19,5 +19,7 @@ export type { ComposerConfig } from './php.js'; export { resolveRustImport, tryRustModulePath } from './rust.js'; +export { resolveRubyImport } from './ruby.js'; + export { resolveImportPath, RESOLVE_CACHE_CAP } from './standard.js'; export type { TsconfigPaths } from './standard.js'; diff --git a/gitnexus/src/core/ingestion/resolvers/ruby.ts b/gitnexus/src/core/ingestion/resolvers/ruby.ts new file mode 100644 index 000000000..e0ae8e690 --- /dev/null +++ b/gitnexus/src/core/ingestion/resolvers/ruby.ts @@ -0,0 +1,23 @@ +/** + * Ruby require/require_relative import resolution. + * Handles path resolution for Ruby's require and require_relative calls. + */ + +import type { SuffixIndex } from './utils.js'; +import { suffixResolve } from './utils.js'; + +/** + * Resolve a Ruby require/require_relative path to a matching .rb file. + * + * require_relative paths are pre-normalized to './' prefix by the caller. + * require paths use suffix matching (gem-style paths like 'json', 'net/http'). + */ +export function resolveRubyImport( + importPath: string, + normalizedFileList: string[], + allFileList: string[], + index?: SuffixIndex, +): string | null { + const pathParts = importPath.replace(/^\.\//, '').split('/').filter(Boolean); + return suffixResolve(pathParts, normalizedFileList, allFileList, index); +} diff --git a/gitnexus/src/core/ingestion/resolvers/utils.ts b/gitnexus/src/core/ingestion/resolvers/utils.ts index e68116e2d..3c7a1eb2e 100644 --- a/gitnexus/src/core/ingestion/resolvers/utils.ts +++ b/gitnexus/src/core/ingestion/resolvers/utils.ts @@ -26,6 +26,8 @@ export const EXTENSIONS = [ '.php', '.phtml', // Swift '.swift', + // Ruby + '.rb', ]; /** diff --git a/gitnexus/src/core/ingestion/ruby-call-routing.ts b/gitnexus/src/core/ingestion/ruby-call-routing.ts new file mode 100644 index 000000000..c634742d3 --- /dev/null +++ b/gitnexus/src/core/ingestion/ruby-call-routing.ts @@ -0,0 +1,116 @@ +/** + * Shared Ruby call routing logic. + * + * Ruby expresses imports, heritage (mixins), and property definitions as + * method calls rather than syntax-level constructs. This module provides a + * routing function used by the CLI call-processor, CLI parse-worker, and + * the web call-processor so that the classification logic lives in one place. + * + * NOTE: This file is intentionally duplicated in gitnexus-web/ because the + * two packages have separate build targets (Node native vs WASM/browser). + * Keep both copies in sync until a shared package is introduced. + */ + +// ── Result types ──────────────────────────────────────────────────────────── + +export type RubyCallRouting = + | { kind: 'import'; importPath: string; isRelative: boolean } + | { kind: 'heritage'; items: RubyHeritageItem[] } + | { kind: 'properties'; items: RubyPropertyItem[] } + | { kind: 'call' } + | { kind: 'skip' }; + +export interface RubyHeritageItem { + enclosingClass: string; + mixinName: string; +} + +export type RubyAccessorType = 'attr_accessor' | 'attr_reader' | 'attr_writer'; + +export interface RubyPropertyItem { + propName: string; + accessorType: RubyAccessorType; + startLine: number; + endLine: number; +} + +// ── Pre-allocated singletons for common return values ──────────────────────── +const CALL_RESULT: RubyCallRouting = { kind: 'call' }; +const SKIP_RESULT: RubyCallRouting = { kind: 'skip' }; + +/** Max depth for parent-walking loops to prevent pathological AST traversals */ +const MAX_PARENT_DEPTH = 50; + +// ── Routing function ──────────────────────────────────────────────────────── + +/** + * Classify a Ruby call node and extract its semantic payload. + * + * @param calledName - The method name (e.g. 'require', 'include', 'attr_accessor') + * @param callNode - The tree-sitter `call` AST node + * @returns A discriminated union describing the call's semantic role + */ +export function routeRubyCall(calledName: string, callNode: any): RubyCallRouting { + // ── require / require_relative → import ───────────────────────────────── + if (calledName === 'require' || calledName === 'require_relative') { + const argList = callNode.childForFieldName?.('arguments'); + const stringNode = argList?.children?.find((c: any) => c.type === 'string'); + const contentNode = stringNode?.children?.find((c: any) => c.type === 'string_content'); + if (!contentNode) return SKIP_RESULT; + + let importPath: string = contentNode.text; + // Validate: reject null bytes, control chars, excessively long paths + if (!importPath || importPath.length > 1024 || /[\x00-\x1f]/.test(importPath)) { + return SKIP_RESULT; + } + const isRelative = calledName === 'require_relative'; + if (isRelative && !importPath.startsWith('.')) { + importPath = './' + importPath; + } + return { kind: 'import', importPath, isRelative }; + } + + // ── include / extend / prepend → heritage (mixin) ────────────────────── + if (calledName === 'include' || calledName === 'extend' || calledName === 'prepend') { + let enclosingClass: string | null = null; + let current = callNode.parent; + let depth = 0; + while (current && ++depth <= MAX_PARENT_DEPTH) { + if (current.type === 'class' || current.type === 'module') { + const nameNode = current.childForFieldName?.('name'); + if (nameNode) { enclosingClass = nameNode.text; break; } + } + current = current.parent; + } + if (!enclosingClass) return SKIP_RESULT; + + const items: RubyHeritageItem[] = []; + const argList = callNode.childForFieldName?.('arguments'); + for (const arg of (argList?.children ?? [])) { + if (arg.type === 'constant' || arg.type === 'scope_resolution') { + items.push({ enclosingClass, mixinName: arg.text }); + } + } + return items.length > 0 ? { kind: 'heritage', items } : SKIP_RESULT; + } + + // ── attr_accessor / attr_reader / attr_writer → property definitions ─── + if (calledName === 'attr_accessor' || calledName === 'attr_reader' || calledName === 'attr_writer') { + const items: RubyPropertyItem[] = []; + const argList = callNode.childForFieldName?.('arguments'); + for (const arg of (argList?.children ?? [])) { + if (arg.type === 'simple_symbol') { + items.push({ + propName: arg.text.startsWith(':') ? arg.text.slice(1) : arg.text, + accessorType: calledName as RubyAccessorType, + startLine: arg.startPosition.row, + endLine: arg.endPosition.row, + }); + } + } + return items.length > 0 ? { kind: 'properties', items } : SKIP_RESULT; + } + + // ── Everything else → regular call ───────────────────────────────────── + return CALL_RESULT; +} diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 4a8d18707..6f40b0d78 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -496,6 +496,41 @@ export const PHP_QUERIES = ` [(name) (qualified_name)] @heritage.trait))) @heritage `; +// Ruby queries - works with tree-sitter-ruby +// NOTE: Ruby uses `call` for require, include, extend, prepend, attr_* etc. +// These are all captured as @call and routed in JS post-processing: +// - require/require_relative → import extraction +// - include/extend/prepend → heritage (mixin) extraction +// - attr_accessor/attr_reader/attr_writer → property definition extraction +// - everything else → regular call extraction +export const RUBY_QUERIES = ` +; ── Modules ────────────────────────────────────────────────────────────────── +(module + name: (constant) @name) @definition.module + +; ── Classes ────────────────────────────────────────────────────────────────── +(class + name: (constant) @name) @definition.class + +; ── Instance methods ───────────────────────────────────────────────────────── +(method + name: (identifier) @name) @definition.method + +; ── Singleton (class-level) methods ────────────────────────────────────────── +(singleton_method + name: (identifier) @name) @definition.function + +; ── All calls (require, include, attr_*, and regular calls routed in JS) ───── +(call + method: (identifier) @call.name) @call + +; ── Heritage: class < SuperClass ───────────────────────────────────────────── +(class + name: (constant) @heritage.class + superclass: (superclass + (constant) @heritage.extends)) @heritage +`; + // Kotlin queries - works with tree-sitter-kotlin (fwcd/tree-sitter-kotlin) // Based on official tags.scm; functions use simple_identifier, classes use type_identifier export const KOTLIN_QUERIES = ` @@ -643,6 +678,7 @@ export const LANGUAGE_QUERIES: Record = { [SupportedLanguages.Go]: GO_QUERIES, [SupportedLanguages.CPlusPlus]: CPP_QUERIES, [SupportedLanguages.CSharp]: CSHARP_QUERIES, + [SupportedLanguages.Ruby]: RUBY_QUERIES, [SupportedLanguages.Rust]: RUST_QUERIES, [SupportedLanguages.PHP]: PHP_QUERIES, [SupportedLanguages.Kotlin]: KOTLIN_QUERIES, diff --git a/gitnexus/src/core/ingestion/type-extractors/index.ts b/gitnexus/src/core/ingestion/type-extractors/index.ts index f0d6468d3..50e35a7e7 100644 --- a/gitnexus/src/core/ingestion/type-extractors/index.ts +++ b/gitnexus/src/core/ingestion/type-extractors/index.ts @@ -29,6 +29,11 @@ export const typeConfigs = { [SupportedLanguages.C]: cCppConfig, [SupportedLanguages.CPlusPlus]: cCppConfig, [SupportedLanguages.PHP]: phpConfig, + [SupportedLanguages.Ruby]: { + declarationNodeTypes: new Set(), + extractDeclaration: () => {}, + extractParameter: () => {}, + }, } satisfies Record; export type { LanguageTypeConfig, TypeBindingExtractor, ParameterExtractor } from './types.js'; diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index bbab77012..a180166dc 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -236,6 +236,21 @@ export const BUILT_IN_NAMES = new Set([ 'lock', 'read', 'write', 'try_lock', 'spawn', 'join', 'sleep', 'Some', 'None', 'Ok', 'Err', + // Ruby built-ins and Kernel methods + 'puts', 'p', 'pp', 'raise', 'fail', + 'require', 'require_relative', 'load', 'autoload', + 'include', 'extend', 'prepend', + 'attr_accessor', 'attr_reader', 'attr_writer', + 'public', 'private', 'protected', 'module_function', + 'lambda', 'proc', 'block_given?', + 'nil?', 'is_a?', 'kind_of?', 'instance_of?', 'respond_to?', + 'freeze', 'frozen?', 'dup', 'tap', 'yield_self', + // Ruby enumerables + 'each', 'select', 'reject', 'detect', 'collect', + 'inject', 'flat_map', 'each_with_object', 'each_with_index', + 'any?', 'all?', 'none?', 'count', 'first', 'last', + 'sort_by', 'min_by', 'max_by', + 'group_by', 'partition', 'compact', 'flatten', 'uniq', ]); /** Check if a name is a built-in function or common noise that should be filtered out */ @@ -417,6 +432,9 @@ export const extractFunctionName = (node: any): { funcName: string | null; label */ export const yieldToEventLoop = (): Promise => new Promise(resolve => setImmediate(resolve)); +/** Ruby extensionless filenames recognised as Ruby source */ +const RUBY_EXTENSIONLESS_FILES = new Set(['Rakefile', 'Gemfile', 'Guardfile', 'Vagrantfile', 'Brewfile']); + /** * Find a child of `childType` within a sibling node of `siblingType`. * Used for Kotlin AST traversal where visibility_modifier lives inside a modifiers sibling. @@ -469,6 +487,16 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages | filename.endsWith('.php5') || filename.endsWith('.php8')) { return SupportedLanguages.PHP; } + // Ruby (extensions) + if (filename.endsWith('.rb') || filename.endsWith('.rake') || filename.endsWith('.gemspec')) { + return SupportedLanguages.Ruby; + } + // Ruby (extensionless files) + const basename = filename.split('/').pop() || filename; + if (RUBY_EXTENSIONLESS_FILES.has(basename)) { + return SupportedLanguages.Ruby; + } + // Swift (extensions) if (filename.endsWith('.swift')) return SupportedLanguages.Swift; return null; }; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 0bad543f5..9ab48f682 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -11,6 +11,7 @@ import Go from 'tree-sitter-go'; import Rust from 'tree-sitter-rust'; import Kotlin from 'tree-sitter-kotlin'; import PHP from 'tree-sitter-php'; +import Ruby from 'tree-sitter-ruby'; import { createRequire } from 'node:module'; import { SupportedLanguages } from '../../../config/supported-languages.js'; import { LANGUAGE_QUERIES } from '../tree-sitter-queries.js'; @@ -38,6 +39,7 @@ import { detectFrameworkFromAST } from '../framework-detection.js'; import { generateId } from '../../../lib/utils.js'; import { extractNamedBindings } from '../named-binding-extraction.js'; import { appendKotlinWildcard } from '../resolvers/index.js'; +import { routeRubyCall } from '../ruby-call-routing.js'; // ============================================================================ // Types for serializable results @@ -155,6 +157,7 @@ const languageMap: Record = { [SupportedLanguages.Rust]: Rust, [SupportedLanguages.Kotlin]: Kotlin, [SupportedLanguages.PHP]: PHP.php_only, + [SupportedLanguages.Ruby]: Ruby, ...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}), }; @@ -858,6 +861,91 @@ const processFileGroup = ( const callNameNode = captureMap['call.name']; if (callNameNode) { const calledName = callNameNode.text; + + // Ruby: route special calls to imports, heritage, or properties + if (language === SupportedLanguages.Ruby) { + const callNode = captureMap['call']; + const routed = routeRubyCall(calledName, callNode); + + switch (routed.kind) { + case 'skip': + continue; + + case 'import': + result.imports.push({ + filePath: file.path, + rawImportPath: routed.importPath, + language, + }); + continue; + + case 'heritage': + for (const item of routed.items) { + result.heritage.push({ + filePath: file.path, + className: item.enclosingClass, + parentName: item.mixinName, + kind: 'trait-impl', + }); + } + continue; + + case 'properties': + for (const item of routed.items) { + const nodeId = generateId('Property', `${file.path}:${item.propName}`); + result.nodes.push({ + id: nodeId, + label: 'Property', + properties: { + name: item.propName, + filePath: file.path, + startLine: item.startLine, + endLine: item.endLine, + language, + isExported: true, + description: item.accessorType, + }, + }); + result.symbols.push({ + filePath: file.path, + name: item.propName, + nodeId, + type: 'Property', + }); + const fileId = generateId('File', file.path); + const relId = generateId('DEFINES', `${fileId}->${nodeId}`); + result.relationships.push({ + id: relId, + sourceId: fileId, + targetId: nodeId, + type: 'DEFINES', + confidence: 1.0, + reason: '', + }); + } + continue; + + case 'call': + if (!isBuiltInOrNoise(calledName)) { + const sourceId = findEnclosingFunctionId(callNode, file.path) + || generateId('File', file.path); + const callForm = inferCallForm(callNode, callNameNode); + const receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined; + const receiverTypeName = receiverName ? lookupTypeEnv(typeEnv, receiverName, callNode) : undefined; + result.calls.push({ + filePath: file.path, + calledName, + sourceId, + argCount: countCallArguments(callNode), + ...(callForm !== undefined ? { callForm } : {}), + ...(receiverName !== undefined ? { receiverName } : {}), + ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), + }); + } + continue; + } + } + if (!isBuiltInOrNoise(calledName)) { const callNode = captureMap['call']; const sourceId = findEnclosingFunctionId(callNode, file.path) diff --git a/gitnexus/src/core/tree-sitter/parser-loader.ts b/gitnexus/src/core/tree-sitter/parser-loader.ts index f7eadc1ab..c8cac82af 100644 --- a/gitnexus/src/core/tree-sitter/parser-loader.ts +++ b/gitnexus/src/core/tree-sitter/parser-loader.ts @@ -10,6 +10,7 @@ import Go from 'tree-sitter-go'; import Rust from 'tree-sitter-rust'; import Kotlin from 'tree-sitter-kotlin'; import PHP from 'tree-sitter-php'; +import Ruby from 'tree-sitter-ruby'; import { createRequire } from 'node:module'; import { SupportedLanguages } from '../../config/supported-languages.js'; @@ -33,6 +34,7 @@ const languageMap: Record = { [SupportedLanguages.Rust]: Rust, [SupportedLanguages.Kotlin]: Kotlin, [SupportedLanguages.PHP]: PHP.php_only, + [SupportedLanguages.Ruby]: Ruby, ...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}), }; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 2386fe25e..90873f50e 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -32,6 +32,7 @@ export function isTestFilePath(filePath: string): boolean { 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.') ); } diff --git a/gitnexus/test/integration/parsing.test.ts b/gitnexus/test/integration/parsing.test.ts index fb96eb252..e918e181f 100644 --- a/gitnexus/test/integration/parsing.test.ts +++ b/gitnexus/test/integration/parsing.test.ts @@ -742,8 +742,8 @@ describe('parsing', () => { it('falls back gracefully for unsupported language', async () => { // getLanguageFromFilename returns null for extensions with no grammar mapping. - const rubyLang = getLanguageFromFilename('script.rb'); - expect(rubyLang).toBeNull(); + const scalaLang = getLanguageFromFilename('Main.scala'); + expect(scalaLang).toBeNull(); const luaLang = getLanguageFromFilename('module.lua'); expect(luaLang).toBeNull(); diff --git a/gitnexus/test/unit/ingestion-utils.test.ts b/gitnexus/test/unit/ingestion-utils.test.ts index de0507c90..08415bb1a 100644 --- a/gitnexus/test/unit/ingestion-utils.test.ts +++ b/gitnexus/test/unit/ingestion-utils.test.ts @@ -93,6 +93,23 @@ describe('getLanguageFromFilename', () => { }); }); + describe('Ruby', () => { + it.each(['.rb', '.rake', '.gemspec'])( + 'detects %s files', + (ext) => { + expect(getLanguageFromFilename(`file${ext}`)).toBe(SupportedLanguages.Ruby); + } + ); + + it('detects extensionless Rakefile', () => { + expect(getLanguageFromFilename('Rakefile')).toBe(SupportedLanguages.Ruby); + }); + + it('detects extensionless Gemfile', () => { + expect(getLanguageFromFilename('Gemfile')).toBe(SupportedLanguages.Ruby); + }); + }); + describe('Kotlin', () => { it.each(['.kt', '.kts'])( 'detects %s files', @@ -103,7 +120,7 @@ describe('getLanguageFromFilename', () => { }); describe('unsupported', () => { - it.each(['.rb', '.scala', '.r', '.lua', '.zig', '.txt', '.md', '.json', '.yaml'])( + it.each(['.scala', '.r', '.lua', '.zig', '.txt', '.md', '.json', '.yaml'])( 'returns null for %s files', (ext) => { expect(getLanguageFromFilename(`file${ext}`)).toBeNull(); diff --git a/gitnexus/test/unit/parser-loader.test.ts b/gitnexus/test/unit/parser-loader.test.ts index ebc8acd86..11e747f40 100644 --- a/gitnexus/test/unit/parser-loader.test.ts +++ b/gitnexus/test/unit/parser-loader.test.ts @@ -67,8 +67,12 @@ describe('parser-loader', () => { await expect(loadLanguage(SupportedLanguages.TypeScript, 'utils.ts')).resolves.not.toThrow(); }); + it('loads Ruby language', async () => { + await expect(loadLanguage(SupportedLanguages.Ruby)).resolves.not.toThrow(); + }); + it('throws for unsupported language', async () => { - await expect(loadLanguage('ruby' as SupportedLanguages)).rejects.toThrow('Unsupported language'); + await expect(loadLanguage('erlang' as SupportedLanguages)).rejects.toThrow('Unsupported language'); }); }); diff --git a/gitnexus/test/unit/sequential-language-availability.test.ts b/gitnexus/test/unit/sequential-language-availability.test.ts index 4a88a1a6a..8a0d85553 100644 --- a/gitnexus/test/unit/sequential-language-availability.test.ts +++ b/gitnexus/test/unit/sequential-language-availability.test.ts @@ -76,7 +76,7 @@ describe('sequential native parser availability', () => { createASTCache(), createSymbolTable(), createImportMap(), - )).resolves.toBeUndefined(); + )).resolves.toEqual([]); expect(parserLoader.loadLanguage).not.toHaveBeenCalled(); });