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();
});