diff --git a/.github/workflows/ci-integration.yml b/.github/workflows/ci-integration.yml index 2043eb9ff5..2cdb89d3e0 100644 --- a/.github/workflows/ci-integration.yml +++ b/.github/workflows/ci-integration.yml @@ -23,7 +23,7 @@ jobs: # on Linux, and its C++ destructors segfault during # process.exit(). Running each file in its own process lets # the OS reclaim all resources cleanly. - # pipeline — 12 files: ingestion pipeline + csv + 9 resolver tests + # pipeline — 13 files: ingestion pipeline + csv + 10 resolver tests # e2e — 2 files: child-process only (spawnSync), no in-process kuzu # standalone — 4 files: pure logic, no kuzu, no child processes test-matrix: @@ -51,6 +51,7 @@ jobs: test/integration/resolvers/go.test.ts test/integration/resolvers/kotlin.test.ts test/integration/resolvers/php.test.ts + test/integration/resolvers/ruby.test.ts - test-group: e2e test-glob: >- test/integration/cli-e2e.test.ts @@ -159,6 +160,7 @@ jobs: test/integration/resolvers/go.test.ts test/integration/resolvers/kotlin.test.ts test/integration/resolvers/php.test.ts + test/integration/resolvers/ruby.test.ts - name: Upload integration coverage if: always() diff --git a/gitnexus-web/src/core/graph/types.ts b/gitnexus-web/src/core/graph/types.ts index 7bc9a5a95f..ceff9f51bf 100644 --- a/gitnexus-web/src/core/graph/types.ts +++ b/gitnexus-web/src/core/graph/types.ts @@ -54,6 +54,7 @@ export type RelationshipType = | 'DECORATES' | 'IMPLEMENTS' | 'EXTENDS' + | 'HAS_METHOD' | 'MEMBER_OF' | 'STEP_IN_PROCESS' diff --git a/gitnexus-web/src/core/ingestion/call-processor.ts b/gitnexus-web/src/core/ingestion/call-processor.ts index 4e7816784f..c3c17452f2 100644 --- a/gitnexus-web/src/core/ingestion/call-processor.ts +++ b/gitnexus-web/src/core/ingestion/call-processor.ts @@ -6,8 +6,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 { SupportedLanguages } from '../../config/supported-languages'; -import { routeRubyCall } from './ruby-call-routing'; +import { callRouters } from './call-routing'; /** * Node types that represent function/method definitions across languages. @@ -108,7 +107,7 @@ const findEnclosingFunction = ( const nameNode = current.childForFieldName?.('name') || current.children?.find((c: any) => c.type === 'identifier'); funcName = nameNode?.text; - label = 'Function'; + label = 'Method'; } else if (current.type === 'arrow_function' || current.type === 'function_expression') { // Arrow/expression: const foo = () => {} - check parent variable declarator const parent = current.parent; @@ -143,6 +142,47 @@ const findEnclosingFunction = ( return null; // Top-level call (not inside any function) }; +/** AST node types that represent a class-like container */ +const CLASS_CONTAINER_TYPES = new Set([ + 'class_declaration', 'abstract_class_declaration', + 'interface_declaration', 'struct_declaration', 'record_declaration', + 'class_specifier', 'struct_specifier', + 'impl_item', 'trait_item', + 'class_definition', + 'trait_declaration', + 'protocol_declaration', + 'class', 'module', // Ruby +]); + +const CONTAINER_TYPE_TO_LABEL: Record = { + class_declaration: 'Class', abstract_class_declaration: 'Class', + interface_declaration: 'Interface', + struct_declaration: 'Struct', struct_specifier: 'Struct', + class_specifier: 'Class', class_definition: 'Class', + impl_item: 'Impl', trait_item: 'Trait', trait_declaration: 'Trait', + record_declaration: 'Record', protocol_declaration: 'Interface', + class: 'Class', module: 'Module', +}; + +/** Walk up AST to find enclosing class/struct/interface, return its generateId or null. */ +const findEnclosingClassId = (node: any, filePath: string): string | null => { + let current = node.parent; + while (current) { + if (CLASS_CONTAINER_TYPES.has(current.type)) { + const nameNode = current.childForFieldName?.('name') + ?? current.children?.find((c: any) => + c.type === 'type_identifier' || c.type === 'identifier' || c.type === 'name' || c.type === 'constant' + ); + if (nameNode) { + const label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class'; + return generateId(label, `${filePath}:${nameNode.text}`); + } + } + current = current.parent; + } + return null; +}; + export const processCalls = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], @@ -188,6 +228,8 @@ export const processCalls = async ( continue; } + const callRouter = callRouters[language]; + // 3. Process each call match matches.forEach(match => { const captureMap: Record = {}; @@ -201,11 +243,9 @@ 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); - + // Dispatch: route language-specific calls (heritage, properties, imports) + const routed = callRouter(calledName, captureMap['call']); + if (routed) { switch (routed.kind) { case 'skip': case 'import': // handled by import-processor @@ -219,10 +259,10 @@ export const processCalls = async ( const parentId = symbolTable.lookupFuzzy(item.mixinName)[0]?.nodeId || generateId('Module', `${item.mixinName}`); if (childId && parentId) { - const relId = generateId('IMPLEMENTS', `${childId}->${parentId}`); + const relId = generateId('IMPLEMENTS', `${childId}->${parentId}:${item.heritageKind}`); graph.addRelationship({ id: relId, sourceId: childId, targetId: parentId, - type: 'IMPLEMENTS', confidence: 1.0, reason: 'trait-impl', + type: 'IMPLEMENTS', confidence: 1.0, reason: item.heritageKind, }); } } @@ -230,6 +270,7 @@ export const processCalls = async ( case 'properties': { const fileId = generateId('File', file.path); + const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path); for (const item of routed.items) { const nodeId = generateId('Property', `${file.path}:${item.propName}`); graph.addNode({ @@ -238,7 +279,7 @@ export const processCalls = async ( properties: { name: item.propName, filePath: file.path, startLine: item.startLine, endLine: item.endLine, - language: SupportedLanguages.Ruby, isExported: true, + language, isExported: true, description: item.accessorType, }, }); @@ -248,6 +289,13 @@ export const processCalls = async ( id: relId, sourceId: fileId, targetId: nodeId, type: 'DEFINES', confidence: 1.0, reason: '', }); + if (propEnclosingClassId) { + graph.addRelationship({ + id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`), + sourceId: propEnclosingClassId, targetId: nodeId, + type: 'HAS_METHOD', confidence: 1.0, reason: '', + }); + } } return; } diff --git a/gitnexus-web/src/core/ingestion/ruby-call-routing.ts b/gitnexus-web/src/core/ingestion/call-routing.ts similarity index 77% rename from gitnexus-web/src/core/ingestion/ruby-call-routing.ts rename to gitnexus-web/src/core/ingestion/call-routing.ts index c634742d33..96a9d54fd8 100644 --- a/gitnexus-web/src/core/ingestion/ruby-call-routing.ts +++ b/gitnexus-web/src/core/ingestion/call-routing.ts @@ -11,6 +11,37 @@ * Keep both copies in sync until a shared package is introduced. */ +import { SupportedLanguages } from '../../config/supported-languages'; + +// ── Call routing dispatch table ───────────────────────────────────────────── + +/** null = this call was not routed; fall through to default call handling */ +export type CallRoutingResult = RubyCallRouting | null; + +export type CallRouter = ( + calledName: string, + callNode: any, +) => CallRoutingResult; + +/** No-op router: returns null for every call (passthrough to normal processing) */ +const noRouting: CallRouter = () => null; + +/** Per-language call routing. noRouting = no special routing (normal call processing) */ +export const callRouters: Record = { + [SupportedLanguages.JavaScript]: noRouting, + [SupportedLanguages.TypeScript]: noRouting, + [SupportedLanguages.Python]: noRouting, + [SupportedLanguages.Java]: noRouting, + [SupportedLanguages.Go]: noRouting, + [SupportedLanguages.Rust]: noRouting, + [SupportedLanguages.CSharp]: noRouting, + [SupportedLanguages.PHP]: noRouting, + [SupportedLanguages.Swift]: noRouting, + [SupportedLanguages.CPlusPlus]: noRouting, + [SupportedLanguages.C]: noRouting, + [SupportedLanguages.Ruby]: routeRubyCall, +}; + // ── Result types ──────────────────────────────────────────────────────────── export type RubyCallRouting = @@ -23,6 +54,7 @@ export type RubyCallRouting = export interface RubyHeritageItem { enclosingClass: string; mixinName: string; + heritageKind: 'include' | 'extend' | 'prepend'; } export type RubyAccessorType = 'attr_accessor' | 'attr_reader' | 'attr_writer'; @@ -88,7 +120,7 @@ export function routeRubyCall(calledName: string, callNode: any): RubyCallRoutin 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 }); + items.push({ enclosingClass, mixinName: arg.text, heritageKind: calledName as 'include' | 'extend' | 'prepend' }); } } return items.length > 0 ? { kind: 'heritage', items } : SKIP_RESULT; diff --git a/gitnexus-web/src/core/ingestion/import-processor.ts b/gitnexus-web/src/core/ingestion/import-processor.ts index e49ab7aa9c..61aeaf9dd2 100644 --- a/gitnexus-web/src/core/ingestion/import-processor.ts +++ b/gitnexus-web/src/core/ingestion/import-processor.ts @@ -4,7 +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'; +import { callRouters } from './call-routing'; // Type: Map> // Stores all files that a given file imports from @@ -224,12 +224,13 @@ export const processImports = async ( } } - // ---- Ruby: require/require_relative come through @call, not @import ---- - if (language === 'ruby' && captureMap['call']) { + // ---- Language-specific call-as-import routing (Ruby require, etc.) ---- + if (captureMap['call']) { const callNameNode = captureMap['call.name']; if (callNameNode) { - const routed = routeRubyCall(callNameNode.text, captureMap['call']); - if (routed.kind === 'import') { + const callRouter = callRouters[language]; + const routed = callRouter(callNameNode.text, captureMap['call']); + if (routed && routed.kind === 'import') { totalImportsFound++; const resolvedPath = resolveImportPath( file.path, routed.importPath, allFilePaths, allFileList, resolveCache diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 81dc96318a..76ec631862 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -19,11 +19,12 @@ import { countCallArguments, inferCallForm, extractReceiverName, + findEnclosingClassId, } from './utils.js'; import { buildTypeEnv, lookupTypeEnv } from './type-env.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { ExtractedCall, ExtractedHeritage, ExtractedRoute } from './workers/parse-worker.js'; -import { routeRubyCall } from './ruby-call-routing.js'; +import { callRouters } from './call-routing.js'; /** * Walk up the AST from a node to find the enclosing function/method. @@ -121,6 +122,7 @@ export const processCalls = async ( // Build per-file TypeEnv for receiver resolution const lang = getLanguageFromFilename(file.path); const typeEnv = lang ? buildTypeEnv(tree, lang) : new Map(); + const callRouter = callRouters[language]; // 3. Process each call match matches.forEach(match => { @@ -135,11 +137,9 @@ 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); - + // Dispatch: route language-specific calls (heritage, properties, imports) + const routed = callRouter(calledName, captureMap['call']); + if (routed) { switch (routed.kind) { case 'skip': case 'import': // handled by import-processor @@ -151,13 +151,14 @@ export const processCalls = async ( filePath: file.path, className: item.enclosingClass, parentName: item.mixinName, - kind: 'trait-impl', + kind: item.heritageKind, }); } return; case 'properties': { const fileId = generateId('File', file.path); + const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path); for (const item of routed.items) { const nodeId = generateId('Property', `${file.path}:${item.propName}`); graph.addNode({ @@ -166,16 +167,24 @@ export const processCalls = async ( properties: { name: item.propName, filePath: file.path, startLine: item.startLine, endLine: item.endLine, - language: SupportedLanguages.Ruby, isExported: true, + language, isExported: true, description: item.accessorType, }, }); - symbolTable.add(file.path, item.propName, nodeId, 'Property'); + symbolTable.add(file.path, item.propName, nodeId, 'Property', + propEnclosingClassId ? { ownerId: propEnclosingClassId } : undefined); const relId = generateId('DEFINES', `${fileId}->${nodeId}`); graph.addRelationship({ id: relId, sourceId: fileId, targetId: nodeId, type: 'DEFINES', confidence: 1.0, reason: '', }); + if (propEnclosingClassId) { + graph.addRelationship({ + id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`), + sourceId: propEnclosingClassId, targetId: nodeId, + type: 'HAS_METHOD', confidence: 1.0, reason: '', + }); + } } return; } diff --git a/gitnexus/src/core/ingestion/ruby-call-routing.ts b/gitnexus/src/core/ingestion/call-routing.ts similarity index 77% rename from gitnexus/src/core/ingestion/ruby-call-routing.ts rename to gitnexus/src/core/ingestion/call-routing.ts index c634742d33..50a91d29bb 100644 --- a/gitnexus/src/core/ingestion/ruby-call-routing.ts +++ b/gitnexus/src/core/ingestion/call-routing.ts @@ -11,6 +11,38 @@ * Keep both copies in sync until a shared package is introduced. */ +import { SupportedLanguages } from '../../config/supported-languages.js'; + +// ── Call routing dispatch table ───────────────────────────────────────────── + +/** null = this call was not routed; fall through to default call handling */ +export type CallRoutingResult = RubyCallRouting | null; + +export type CallRouter = ( + calledName: string, + callNode: any, +) => CallRoutingResult; + +/** No-op router: returns null for every call (passthrough to normal processing) */ +const noRouting: CallRouter = () => null; + +/** Per-language call routing. noRouting = no special routing (normal call processing) */ +export const callRouters: Record = { + [SupportedLanguages.JavaScript]: noRouting, + [SupportedLanguages.TypeScript]: noRouting, + [SupportedLanguages.Python]: noRouting, + [SupportedLanguages.Java]: noRouting, + [SupportedLanguages.Kotlin]: noRouting, + [SupportedLanguages.Go]: noRouting, + [SupportedLanguages.Rust]: noRouting, + [SupportedLanguages.CSharp]: noRouting, + [SupportedLanguages.PHP]: noRouting, + [SupportedLanguages.Swift]: noRouting, + [SupportedLanguages.CPlusPlus]: noRouting, + [SupportedLanguages.C]: noRouting, + [SupportedLanguages.Ruby]: routeRubyCall, +}; + // ── Result types ──────────────────────────────────────────────────────────── export type RubyCallRouting = @@ -23,6 +55,7 @@ export type RubyCallRouting = export interface RubyHeritageItem { enclosingClass: string; mixinName: string; + heritageKind: 'include' | 'extend' | 'prepend'; } export type RubyAccessorType = 'attr_accessor' | 'attr_reader' | 'attr_writer'; @@ -88,7 +121,7 @@ export function routeRubyCall(calledName: string, callNode: any): RubyCallRoutin 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 }); + items.push({ enclosingClass, mixinName: arg.text, heritageKind: calledName as 'include' | 'extend' | 'prepend' }); } } return items.length > 0 ? { kind: 'heritage', items } : SKIP_RESULT; diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index e9abd6ef78..40eb298572 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -298,7 +298,7 @@ export const processHeritageFromExtracted = async ( reason: '', }); } - } else if (h.kind === 'trait-impl') { + } else if (h.kind === 'trait-impl' || h.kind === 'include' || h.kind === 'extend' || h.kind === 'prepend') { const structId = symbolTable.lookupExact(h.filePath, h.className) || resolveSymbol(h.className, h.filePath, symbolTable, importMap, packageMap)?.nodeId || generateId('Struct', `${h.filePath}:${h.className}`); @@ -308,12 +308,12 @@ export const processHeritageFromExtracted = async ( if (structId && traitId) { graph.addRelationship({ - id: generateId('IMPLEMENTS', `${structId}->${traitId}`), + id: generateId('IMPLEMENTS', `${structId}->${traitId}:${h.kind}`), sourceId: structId, targetId: traitId, type: 'IMPLEMENTS', confidence: 1.0, - reason: 'trait-impl', + reason: h.kind, }); } } diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 447bd98a50..868baaf6e7 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -30,8 +30,9 @@ import { resolveCSharpNamespaceDir, resolvePhpImport, resolveRustImport, + resolveRubyImport, } from './resolvers/index.js'; -import { routeRubyCall } from './ruby-call-routing.js'; +import { callRouters } from './call-routing.js'; import type { SuffixIndex, TsconfigPaths, @@ -223,6 +224,12 @@ function resolveLanguageImport( return null; // External framework (Foundation, UIKit, etc.) } + // Ruby: require / require_relative + if (language === SupportedLanguages.Ruby) { + const resolved = resolveRubyImport(rawImportPath, normalizedFileList, allFileList, index); + return resolved ? { kind: 'files', files: [resolved] } : null; + } + // Rust: expand top-level grouped imports: use {crate::a, crate::b} if (language === SupportedLanguages.Rust && rawImportPath.startsWith('{') && rawImportPath.endsWith('}')) { const inner = rawImportPath.slice(1, -1); @@ -446,20 +453,16 @@ export const processImports = async ( 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']) { + // ---- Language-specific call-as-import routing (Ruby require, etc.) ---- + if (captureMap['call']) { const callNameNode = captureMap['call.name']; if (callNameNode) { - const routed = routeRubyCall(callNameNode.text, captureMap['call']); - if (routed.kind === 'import') { + const callRouter = callRouters[language]; + const routed = callRouter(callNameNode.text, captureMap['call']); + if (routed && 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); - } + const result = resolveLanguageImport(file.path, routed.importPath, language, configs, ctx); + applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge); } } } diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 6f40b0d785..d2a1c2ad3e 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -518,12 +518,22 @@ export const RUBY_QUERIES = ` ; ── Singleton (class-level) methods ────────────────────────────────────────── (singleton_method - name: (identifier) @name) @definition.function + name: (identifier) @name) @definition.method ; ── All calls (require, include, attr_*, and regular calls routed in JS) ───── (call method: (identifier) @call.name) @call +; ── Bare calls without parens (identifiers at statement level are method calls) ─ +; NOTE: This may over-capture variable reads as calls (e.g. 'result' at +; statement level). Ruby's grammar makes bare identifiers ambiguous — they +; could be local variables or zero-arity method calls. Post-processing via +; isBuiltInOrNoise and symbol resolution filtering suppresses most false +; positives, but a variable name that coincidentally matches a method name +; elsewhere may produce a false CALLS edge. +(body_statement + (identifier) @call.name @call) + ; ── Heritage: class < SuperClass ───────────────────────────────────────────── (class name: (constant) @heritage.class diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index a180166dc4..3d87a109c5 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -77,6 +77,9 @@ export const FUNCTION_NODE_TYPES = new Set([ // Swift 'init_declaration', 'deinit_declaration', + // Ruby + 'method', // def foo + 'singleton_method', // def self.foo ]); /** @@ -265,6 +268,9 @@ export const CLASS_CONTAINER_TYPES = new Set([ 'class_definition', 'trait_declaration', 'protocol_declaration', + // Ruby + 'class', + 'module', ]); export const CONTAINER_TYPE_TO_LABEL: Record = { @@ -280,6 +286,8 @@ export const CONTAINER_TYPE_TO_LABEL: Record = { trait_declaration: 'Trait', record_declaration: 'Record', protocol_declaration: 'Interface', + class: 'Class', + module: 'Module', }; /** Walk up AST to find enclosing class/struct/interface/impl, return its generateId or null. @@ -322,7 +330,7 @@ export const findEnclosingClassId = (node: any, filePath: string): string | null } const nameNode = current.childForFieldName?.('name') ?? current.children?.find((c: any) => - c.type === 'type_identifier' || c.type === 'identifier' || c.type === 'name' + c.type === 'type_identifier' || c.type === 'identifier' || c.type === 'name' || c.type === 'constant' ); if (nameNode) { const label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class'; @@ -421,6 +429,11 @@ export const extractFunctionName = (node: any): { funcName: string | null; label parent.children?.find((c: any) => c.type === 'identifier'); funcName = nameNode?.text; } + } else if (node.type === 'method' || node.type === 'singleton_method') { + const nameNode = node.childForFieldName?.('name') || + node.children?.find((c: any) => c.type === 'identifier'); + funcName = nameNode?.text; + label = 'Method'; } return { funcName, label }; @@ -743,6 +756,11 @@ export const inferCallForm = ( return 'member'; } + // 4b. Ruby call with receiver: obj.method + if (callNode.type === 'call' && callNode.childForFieldName('receiver')) { + return 'member'; + } + // 5. Scoped calls (Rust Foo::new(), C++ ns::func()): treat as free // The receiver is a type, not an instance — handled differently in Phase 3 if (nameParent && SCOPED_CALL_NODE_TYPES.has(nameParent.type)) { @@ -801,6 +819,11 @@ export const extractReceiverName = ( receiver = callNode.childForFieldName('object'); } + // Ruby: call node has 'receiver' field + if (!receiver && parent.type === 'call') { + receiver = parent.childForFieldName('receiver'); + } + // Kotlin/Swift: navigation_expression target is the first child if (!receiver && parent.type === 'navigation_suffix') { const navExpr = parent.parent; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 9ab48f6822..c3bc275f08 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -39,7 +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'; +import { callRouters } from '../call-routing.js'; // ============================================================================ // Types for serializable results @@ -107,7 +107,7 @@ export interface ExtractedHeritage { filePath: string; className: string; parentName: string; - /** 'extends' | 'implements' | 'trait-impl' */ + /** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */ kind: string; } @@ -826,6 +826,7 @@ const processFileGroup = ( // Build per-file TypeEnv from explicit type annotations (for receiver resolution) const typeEnv = buildTypeEnv(tree, language); + const callRouter = callRouters[language]; let matches; try { @@ -862,88 +863,81 @@ const processFileGroup = ( 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); + // Dispatch: route language-specific calls (heritage, properties, imports) + const routed = callRouter(calledName, captureMap['call']); + if (routed) { + if (routed.kind === 'skip') continue; - switch (routed.kind) { - case 'skip': - continue; + if (routed.kind === 'import') { + result.imports.push({ + filePath: file.path, + rawImportPath: routed.importPath, + language, + }); + continue; + } - case 'import': - result.imports.push({ + if (routed.kind === 'heritage') { + for (const item of routed.items) { + result.heritage.push({ filePath: file.path, - rawImportPath: routed.importPath, - language, + className: item.enclosingClass, + parentName: item.mixinName, + kind: item.heritageKind, }); - continue; + } + 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, + if (routed.kind === 'properties') { + const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path); + 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, - nodeId, - type: 'Property', - }); - const fileId = generateId('File', file.path); - const relId = generateId('DEFINES', `${fileId}->${nodeId}`); + 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', + ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}), + }); + 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: '', + }); + if (propEnclosingClassId) { result.relationships.push({ - id: relId, - sourceId: fileId, + id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`), + sourceId: propEnclosingClassId, targetId: nodeId, - type: 'DEFINES', + type: 'HAS_METHOD', 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; + } + continue; } + + // kind === 'call' — fall through to normal call processing below } if (!isBuiltInOrNoise(calledName)) { diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/lib/user_handler.rb b/gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/lib/user_handler.rb new file mode 100644 index 0000000000..c72ff27bfd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/lib/user_handler.rb @@ -0,0 +1,7 @@ +require_relative '../models/handler' + +class UserHandler < Handler + def handle_event + process_request + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/models/handler.rb b/gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/models/handler.rb new file mode 100644 index 0000000000..a51ba250c0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/models/handler.rb @@ -0,0 +1,5 @@ +class Handler + def process_request + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/other/handler.rb b/gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/other/handler.rb new file mode 100644 index 0000000000..480c60b114 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-ambiguous/other/handler.rb @@ -0,0 +1,5 @@ +class Handler + def process_request + false + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/base_model.rb b/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/base_model.rb new file mode 100644 index 0000000000..aa87bad8cd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/base_model.rb @@ -0,0 +1,17 @@ +require 'lib/concerns/serializable' + +class BaseModel + attr_accessor :id, :created_at + + def persist + run_validations + end + + def run_validations + true + end + + def self.factory + run_validations + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/cacheable.rb b/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/cacheable.rb new file mode 100644 index 0000000000..5237d07320 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/cacheable.rb @@ -0,0 +1,5 @@ +module Cacheable + def cache_key + "key" + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/loggable.rb b/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/loggable.rb new file mode 100644 index 0000000000..0098619d3a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/loggable.rb @@ -0,0 +1,5 @@ +module Loggable + def log_action + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/serializable.rb b/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/serializable.rb new file mode 100644 index 0000000000..396f20518a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/concerns/serializable.rb @@ -0,0 +1,5 @@ +module Serializable + def serialize_data + "{}" + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/service.rb b/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/service.rb new file mode 100644 index 0000000000..05face3056 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/service.rb @@ -0,0 +1,9 @@ +require_relative './user' + +class UserService + def create_user + user = User.new + user.persist + user.greet_user + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/user.rb b/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/user.rb new file mode 100644 index 0000000000..0fce9b008a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-app/lib/user.rb @@ -0,0 +1,18 @@ +require_relative './base_model' +require_relative './concerns/serializable' +require_relative './concerns/loggable' +require_relative './concerns/cacheable' + +class User < BaseModel + include Serializable + extend Loggable + prepend Cacheable + + attr_reader :name + attr_writer :email + + def greet_user + persist + serialize_data + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/one_arg.rb b/gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/one_arg.rb new file mode 100644 index 0000000000..58db3276c8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/one_arg.rb @@ -0,0 +1,5 @@ +class OneArg + def write_audit(message) + message + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/service.rb b/gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/service.rb new file mode 100644 index 0000000000..d0e1a4fe68 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/service.rb @@ -0,0 +1,8 @@ +require_relative './one_arg' +require_relative './two_args' + +class Service + def run_task + write_audit("done") + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/two_args.rb b/gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/two_args.rb new file mode 100644 index 0000000000..b4ed5d06ff --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-calls/lib/two_args.rb @@ -0,0 +1,5 @@ +class TwoArgs + def write_audit(message, level) + message + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-local-shadow/lib/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-local-shadow/lib/app.rb new file mode 100644 index 0000000000..b01f293f95 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-local-shadow/lib/app.rb @@ -0,0 +1,9 @@ +require_relative './utils' + +def do_work + false +end + +def run_app + do_work +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-local-shadow/lib/utils.rb b/gitnexus/test/fixtures/lang-resolution/ruby-local-shadow/lib/utils.rb new file mode 100644 index 0000000000..539c3a9d1f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-local-shadow/lib/utils.rb @@ -0,0 +1,5 @@ +class Utils + def do_work + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-member-calls/lib/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-member-calls/lib/app.rb new file mode 100644 index 0000000000..9f00419473 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-member-calls/lib/app.rb @@ -0,0 +1,8 @@ +require_relative './user' + +class App + def process_user + user = User.new + user.persist_record + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-member-calls/lib/user.rb b/gitnexus/test/fixtures/lang-resolution/ruby-member-calls/lib/user.rb new file mode 100644 index 0000000000..4870497a05 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-member-calls/lib/user.rb @@ -0,0 +1,5 @@ +class User + def persist_record + true + end +end diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts new file mode 100644 index 0000000000..9a44900b2d --- /dev/null +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -0,0 +1,317 @@ +/** + * Ruby: require_relative imports, include heritage (mixins), attr_* properties, + * calls, member calls, ambiguous disambiguation, local shadow, + * constructor-inferred type resolution + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabel, edgeSet, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Heritage: require_relative imports + include heritage + attr_* properties + calls +// --------------------------------------------------------------------------- + +describe('Ruby require_relative, heritage & property resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-app'), + () => {}, + ); + }, 60000); + + // --- Node detection --- + + it('detects 3 classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual([ + 'BaseModel', 'User', 'UserService', + ]); + }); + + it('detects 3 modules', () => { + expect(getNodesByLabel(result, 'Module')).toEqual(['Cacheable', 'Loggable', 'Serializable']); + }); + + it('detects methods on classes and modules', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('persist'); + expect(methods).toContain('run_validations'); + expect(methods).toContain('greet_user'); + expect(methods).toContain('serialize_data'); + expect(methods).toContain('create_user'); + }); + + it('detects singleton method (def self.factory) as Method', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('factory'); + }); + + it('emits CALLS from singleton method: factory → run_validations', () => { + const calls = getRelationships(result, 'CALLS') + .filter(e => e.source === 'factory' && e.target === 'run_validations'); + expect(calls.length).toBe(1); + expect(calls[0].sourceLabel).toBe('Method'); + }); + + // --- Import resolution via require_relative --- + + it('resolves 5 require_relative imports to IMPORTS edges', () => { + const imports = getRelationships(result, 'IMPORTS'); + const importEdges = edgeSet(imports); + expect(importEdges).toContain('user.rb → base_model.rb'); + expect(importEdges).toContain('user.rb → serializable.rb'); + expect(importEdges).toContain('user.rb → loggable.rb'); + expect(importEdges).toContain('user.rb → cacheable.rb'); + expect(importEdges).toContain('service.rb → user.rb'); + }); + + it('resolves bare require to IMPORTS edge', () => { + const imports = getRelationships(result, 'IMPORTS'); + const bareRequire = imports.find(e => + e.sourceFilePath.includes('base_model.rb') && + e.targetFilePath.includes('serializable.rb') + ); + expect(bareRequire).toBeDefined(); + }); + + // --- Heritage: include → IMPLEMENTS --- + + it('emits IMPLEMENTS edge for include Serializable with reason "include"', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + const edge = implements_.find(e => e.source === 'User' && e.target === 'Serializable'); + expect(edge).toBeDefined(); + expect(edge!.rel.reason).toBe('include'); + }); + + it('emits IMPLEMENTS edge for extend Loggable with reason "extend"', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + const edge = implements_.find(e => e.source === 'User' && e.target === 'Loggable'); + expect(edge).toBeDefined(); + expect(edge!.rel.reason).toBe('extend'); + }); + + it('emits IMPLEMENTS edge for prepend Cacheable with reason "prepend"', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + const edge = implements_.find(e => e.source === 'User' && e.target === 'Cacheable'); + expect(edge).toBeDefined(); + expect(edge!.rel.reason).toBe('prepend'); + }); + + // --- Extends: class inheritance --- + + it('emits EXTENDS edge: User → BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBeGreaterThanOrEqual(1); + const edges = edgeSet(extends_); + expect(edges).toContain('User → BaseModel'); + }); + + // --- Property nodes: attr_accessor, attr_reader, attr_writer --- + + it('creates Property nodes for attr_accessor :id and :created_at', () => { + const props = getNodesByLabel(result, 'Property'); + expect(props).toContain('id'); + expect(props).toContain('created_at'); + }); + + it('creates Property nodes for attr_reader :name and attr_writer :email', () => { + const props = getNodesByLabel(result, 'Property'); + expect(props).toContain('name'); + expect(props).toContain('email'); + }); + + it('emits HAS_METHOD from User to attr_reader :name', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find(e => e.source === 'User' && e.target === 'name'); + expect(edge).toBeDefined(); + }); + + it('emits HAS_METHOD from BaseModel to attr_accessor :id', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find(e => e.source === 'BaseModel' && e.target === 'id'); + expect(edge).toBeDefined(); + }); + + // --- Call resolution: method-level attribution --- + + it('emits method-level CALLS: create_user → persist (member call)', () => { + const calls = getRelationships(result, 'CALLS') + .filter(e => e.source === 'create_user' && e.target === 'persist'); + expect(calls.length).toBe(1); + expect(calls[0].sourceLabel).toBe('Method'); + expect(calls[0].targetLabel).toBe('Method'); + }); + + it('emits method-level CALLS: create_user → greet_user (member call)', () => { + const calls = getRelationships(result, 'CALLS') + .filter(e => e.source === 'create_user' && e.target === 'greet_user'); + expect(calls.length).toBe(1); + expect(calls[0].sourceLabel).toBe('Method'); + expect(calls[0].targetLabel).toBe('Method'); + }); + + it('emits method-level CALLS: greet_user → persist (bare call)', () => { + const calls = getRelationships(result, 'CALLS') + .filter(e => e.source === 'greet_user' && e.target === 'persist'); + expect(calls.length).toBe(1); + }); + + it('emits method-level CALLS: greet_user → serialize_data (bare call)', () => { + const calls = getRelationships(result, 'CALLS') + .filter(e => e.source === 'greet_user' && e.target === 'serialize_data'); + expect(calls.length).toBe(1); + }); + + it('emits method-level CALLS: persist → run_validations (bare call)', () => { + const calls = getRelationships(result, 'CALLS') + .filter(e => e.source === 'persist' && e.target === 'run_validations'); + expect(calls.length).toBe(1); + }); + + // --- Heritage edges point to real graph nodes --- + + it('all heritage edges point to real graph nodes', () => { + for (const edge of [...getRelationships(result, 'EXTENDS'), ...getRelationships(result, 'IMPLEMENTS')]) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + } + }); + + // --- No OVERRIDES edges target Property nodes --- + + it('no OVERRIDES edges target Property nodes', () => { + const overrides = getRelationships(result, 'OVERRIDES'); + for (const edge of overrides) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.label).not.toBe('Property'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Calls: arity-based disambiguation +// --------------------------------------------------------------------------- + +describe('Ruby call resolution with arity filtering', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-calls'), + () => {}, + ); + }, 60000); + + it('resolves run_task → write_audit to one_arg.rb via arity narrowing', () => { + const calls = getRelationships(result, 'CALLS'); + const auditCall = calls.find(c => c.target === 'write_audit'); + expect(auditCall).toBeDefined(); + expect(auditCall!.source).toBe('run_task'); + expect(auditCall!.targetFilePath).toContain('one_arg.rb'); + expect(auditCall!.rel.reason).toBe('import-resolved'); + }); +}); + +// --------------------------------------------------------------------------- +// Member-call resolution: obj.method() resolves through pipeline +// --------------------------------------------------------------------------- + +describe('Ruby member-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-member-calls'), + () => {}, + ); + }, 60000); + + it('resolves process_user → persist_record as a member call on User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'persist_record'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('process_user'); + expect(saveCall!.targetFilePath).toContain('user.rb'); + }); + + it('detects User class and persist_record method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('persist_record'); + }); + + it('emits HAS_METHOD edge from User to persist_record', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find(e => e.source === 'User' && e.target === 'persist_record'); + expect(edge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Ambiguous: Handler in two dirs, require_relative disambiguates +// --------------------------------------------------------------------------- + +describe('Ruby ambiguous symbol resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-ambiguous'), + () => {}, + ); + }, 60000); + + it('detects 2 Handler classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes.filter(n => n === 'Handler').length).toBe(2); + expect(classes).toContain('UserHandler'); + }); + + it('resolves EXTENDS to models/handler.rb (not other/handler.rb)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('UserHandler'); + expect(extends_[0].target).toBe('Handler'); + expect(extends_[0].targetFilePath).toBe('models/handler.rb'); + }); + + it('import edge points to models/ not other/', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(1); + expect(imports[0].targetFilePath).toBe('models/handler.rb'); + }); + + it('all heritage edges point to real graph nodes', () => { + for (const edge of getRelationships(result, 'EXTENDS')) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Local shadow: same-file definition takes priority over imported name +// --------------------------------------------------------------------------- + +describe('Ruby local definition shadows import', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-local-shadow'), + () => {}, + ); + }, 60000); + + it('resolves run_app → do_work to same-file definition, not the imported one', () => { + const calls = getRelationships(result, 'CALLS'); + const doWorkCall = calls.find(c => c.target === 'do_work' && c.source === 'run_app'); + expect(doWorkCall).toBeDefined(); + expect(doWorkCall!.targetFilePath).toContain('app.rb'); + }); +}); +