diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index d48d780d9..bedc50b2f 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -365,6 +365,13 @@ export const CSHARP_QUERIES = ` (invocation_expression function: (identifier) @call.name) @call (invocation_expression function: (member_access_expression name: (identifier) @call.name)) @call +; Null-conditional method calls: user?.Save() +; Parses as: invocation_expression → conditional_access_expression → member_binding_expression → identifier +(invocation_expression + function: (conditional_access_expression + (member_binding_expression + (identifier) @call.name))) @call + ; Constructor calls: new Foo() and new Foo { Props } (object_creation_expression type: (identifier) @call.name) @call diff --git a/gitnexus/src/core/ingestion/type-extractors/php.ts b/gitnexus/src/core/ingestion/type-extractors/php.ts index 570a5ba0f..f96298e66 100644 --- a/gitnexus/src/core/ingestion/type-extractors/php.ts +++ b/gitnexus/src/core/ingestion/type-extractors/php.ts @@ -69,8 +69,10 @@ const normalizePhpType = (raw: string): string | undefined => { * PHP 8+ attributes (#[Route(...)]) appear as named siblings between PHPDoc and method. */ const SKIP_NODE_TYPES: ReadonlySet = new Set(['attribute_list', 'attribute']); -/** Regex to extract PHPDoc @param annotations: `@param Type $name` */ +/** Regex to extract PHPDoc @param annotations: `@param Type $name` (standard order) */ const PHPDOC_PARAM_RE = /@param\s+(\S+)\s+\$(\w+)/g; +/** Alternate PHPDoc order: `@param $name Type` (name first) */ +const PHPDOC_PARAM_ALT_RE = /@param\s+\$(\w+)\s+(\S+)/g; /** * Collect PHPDoc @param type bindings from comment nodes preceding a method/function. @@ -101,6 +103,17 @@ const collectPhpDocParams = (methodNode: SyntaxNode): Map => { params.set('$' + paramName, typeName); } } + + // Also check alternate PHPDoc order: @param $name Type + PHPDOC_PARAM_ALT_RE.lastIndex = 0; + while ((match = PHPDOC_PARAM_ALT_RE.exec(commentBlock)) !== null) { + const paramName = match[1]; + if (params.has('$' + paramName)) continue; // standard format takes priority + const typeName = normalizePhpType(match[2]); + if (typeName) { + params.set('$' + paramName, typeName); + } + } return params; }; diff --git a/gitnexus/src/core/ingestion/type-extractors/ruby.ts b/gitnexus/src/core/ingestion/type-extractors/ruby.ts index 370d884c0..e2c890760 100644 --- a/gitnexus/src/core/ingestion/type-extractors/ruby.ts +++ b/gitnexus/src/core/ingestion/type-extractors/ruby.ts @@ -25,6 +25,8 @@ import { SyntaxNode } from '../utils.js'; /** Regex to extract @param annotations: `@param name [Type]` */ const YARD_PARAM_RE = /@param\s+(\w+)\s+\[([^\]]+)\]/g; +/** Alternate YARD order: `@param [Type] name` */ +const YARD_PARAM_ALT_RE = /@param\s+\[([^\]]+)\]\s+(\w+)/g; /** Regex to extract @return annotations: `@return [Type]` */ const YARD_RETURN_RE = /@return\s+\[([^\]]+)\]/; @@ -42,10 +44,22 @@ const extractYardTypeName = (yardType: string): string | undefined => { const trimmed = yardType.trim(); // Handle nullable: "Type, nil" or "nil, Type" - const parts = trimmed.split(',').map(p => p.trim()).filter(p => p !== 'nil'); - if (parts.length !== 1) return undefined; // ambiguous union + // Use bracket-balanced split to avoid breaking on commas inside generics like Hash + const parts: string[] = []; + let depth = 0, start = 0; + for (let i = 0; i < trimmed.length; i++) { + if (trimmed[i] === '<') depth++; + else if (trimmed[i] === '>') depth--; + else if (trimmed[i] === ',' && depth === 0) { + parts.push(trimmed.slice(start, i).trim()); + start = i + 1; + } + } + parts.push(trimmed.slice(start).trim()); + const filtered = parts.filter(p => p !== '' && p !== 'nil'); + if (filtered.length !== 1) return undefined; // ambiguous union - const typePart = parts[0]; + const typePart = filtered[0]; // Handle qualified: "Models::User" → "User" const segments = typePart.split('::'); @@ -122,6 +136,18 @@ const collectYardParams = (methodNode: SyntaxNode): Map => { } } + // Also check alternate YARD order: @param [Type] name + YARD_PARAM_ALT_RE.lastIndex = 0; + while ((match = YARD_PARAM_ALT_RE.exec(commentBlock)) !== null) { + const rawType = match[1]; + const paramName = match[2]; + if (params.has(paramName)) continue; // standard format takes priority + const typeName = extractYardTypeName(rawType); + if (typeName) { + params.set(paramName, typeName); + } + } + return params; }; diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index 1439dbee0..b0da07588 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -719,6 +719,7 @@ const MEMBER_ACCESS_NODE_TYPES = new Set([ 'field_expression', // Rust/C++: obj.method() / ptr->method() 'selector_expression', // Go: obj.Method() 'navigation_suffix', // Kotlin/Swift: obj.method() — nameNode sits inside navigation_suffix + 'member_binding_expression', // C#: user?.Method() — null-conditional access ]); /** @@ -863,6 +864,14 @@ export const extractReceiverName = ( } } + // C# null-conditional: user?.Save() → conditional_access_expression wraps member_binding_expression + if (!receiver && parent.type === 'member_binding_expression') { + const condAccess = parent.parent; + if (condAccess?.type === 'conditional_access_expression') { + receiver = condAccess.firstNamedChild; + } + } + // 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/kuzu/kuzu-adapter.ts b/gitnexus/src/core/kuzu/kuzu-adapter.ts index 0473bad9b..5a4942d58 100644 --- a/gitnexus/src/core/kuzu/kuzu-adapter.ts +++ b/gitnexus/src/core/kuzu/kuzu-adapter.ts @@ -361,7 +361,7 @@ export const insertNodeToKuzu = async ( if (v === null || v === undefined) return 'NULL'; if (typeof v === 'number') return String(v); // Escape backslashes first (for Windows paths), then single quotes - return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`; + return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/\n/g, '\\n').replace(/\r/g, '\\r')}'`; }; // Build INSERT query based on node type diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/App.cs new file mode 100644 index 000000000..8869ba372 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/App.cs @@ -0,0 +1,16 @@ +using Models; + +namespace App; + +public class AppService +{ + public void Process() + { + User user = new User(); + Repo repo = new Repo(); + + // Null-conditional calls — should disambiguate via receiver type + user?.Save(); + repo?.Save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/Repo.cs new file mode 100644 index 000000000..4b19312d8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/Repo.cs @@ -0,0 +1,9 @@ +namespace Models; + +public class Repo +{ + public bool Save() + { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/User.cs new file mode 100644 index 000000000..2d2ffe30a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/User.cs @@ -0,0 +1,9 @@ +namespace Models; + +public class User +{ + public bool Save() + { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/NullConditional.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/NullConditional.csproj new file mode 100644 index 000000000..ec2cce143 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/NullConditional.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/models.rb b/gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/models.rb new file mode 100644 index 000000000..a2082d0f9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/models.rb @@ -0,0 +1,19 @@ +class UserRepo + def save + true + end + + def find_all + [] + end +end + +class AdminRepo + def save + true + end + + def find_all + [] + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/service.rb b/gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/service.rb new file mode 100644 index 000000000..5d18dda1d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/service.rb @@ -0,0 +1,16 @@ +require_relative './models' + +class DataService + # @param repo [UserRepo] the user repository + # @param cache [Hash] cache of repos by symbol key + def sync(repo, cache) + repo.save + repo.find_all + end + + # @param [AdminRepo] admin_repo the admin repository (alternate YARD order) + def audit(admin_repo) + admin_repo.save + admin_repo.find_all + end +end diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index f65611130..622a4309a 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -606,3 +606,52 @@ describe('C# return type inference via var + invocation', () => { expect(repoSave).toBeUndefined(); }); }); + +describe('C# null-conditional call resolution (user?.Save())', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-null-conditional'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes with competing Save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter((m: string) => m === 'Save'); + expect(saveMethods.length).toBe(2); + }); + + it('captures null-conditional user?.Save() call', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'Save' && c.source === 'Process'); + expect(saveCalls.length).toBeGreaterThan(0); + }); + + it('resolves user?.Save() to User#Save via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'Save' && c.source === 'Process' && c.targetFilePath.includes('User.cs'), + ); + expect(userSave).toBeDefined(); + }); + + it('resolves repo?.Save() to Repo#Save via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'Save' && c.source === 'Process' && c.targetFilePath.includes('Repo.cs'), + ); + expect(repoSave).toBeDefined(); + }); + + it('does NOT cross-contaminate (exactly 1 Save per receiver file)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'Save' && c.source === 'Process'); + const userTargeted = saveCalls.filter(c => c.targetFilePath.includes('User.cs')); + const repoTargeted = saveCalls.filter(c => c.targetFilePath.includes('Repo.cs')); + expect(userTargeted.length).toBe(1); + expect(repoTargeted.length).toBe(1); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 9c3891b61..96eba1020 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -693,3 +693,65 @@ describe('Ruby constant factory call resolution (SERVICE = build_service())', () expect(wrongCall).toBeUndefined(); }); }); + +describe('Ruby YARD generic type annotations (Hash)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-yard-generics'), + () => {}, + ); + }, 60000); + + it('detects UserRepo, AdminRepo, and DataService classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('UserRepo'); + expect(getNodesByLabel(result, 'Class')).toContain('AdminRepo'); + expect(getNodesByLabel(result, 'Class')).toContain('DataService'); + }); + + it('detects save and find_all on both repos, plus sync and audit methods', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('save'); + expect(methods).toContain('find_all'); + expect(methods).toContain('sync'); + expect(methods).toContain('audit'); + }); + + it('resolves repo.save in sync() to UserRepo#save via @param repo [UserRepo]', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'sync' && c.targetFilePath.includes('models.rb'), + ); + expect(saveCall).toBeDefined(); + }); + + it('does NOT resolve cache param to a class (Hash is a generic container)', () => { + // The @param cache [Hash] should extract type "Hash" — not "UserRepo". + // Since Hash is not a class in the fixture, no type binding is created for cache. + // This verifies the bracket-balanced split doesn't break on the inner comma. + const calls = getRelationships(result, 'CALLS'); + // No calls should originate from cache.* since cache has no resolved type + const cacheCall = calls.find(c => + c.source === 'sync' && c.target === 'save' && c.targetFilePath.includes('admin'), + ); + expect(cacheCall).toBeUndefined(); + }); + + it('resolves admin_repo.save in audit() to AdminRepo#save via alternate @param [AdminRepo] order', () => { + const calls = getRelationships(result, 'CALLS'); + // audit() calls admin_repo.save — should resolve via the alternate YARD format + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'audit', + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves admin_repo.find_all in audit() to AdminRepo#find_all', () => { + const calls = getRelationships(result, 'CALLS'); + const findCall = calls.find(c => + c.target === 'find_all' && c.source === 'audit', + ); + expect(findCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/unit/call-form.test.ts b/gitnexus/test/unit/call-form.test.ts index 7467c1d7c..8db43d96f 100644 --- a/gitnexus/test/unit/call-form.test.ts +++ b/gitnexus/test/unit/call-form.test.ts @@ -387,14 +387,15 @@ describe('extractReceiverName', () => { expect(extractReceiverName(match!.nameNode)).toBe('user'); }); - it('does not capture null-conditional user?.Save() with current queries', () => { + it('captures null-conditional user?.Save() and extracts receiver', () => { parser.setLanguage(CSharp); const code = `class Foo { void Run() { user?.Save(); } }`; const captures = extractCallCaptures(parser, code, SupportedLanguages.CSharp); const match = captures.find(c => c.calledName === 'Save'); - // C# conditional_access_expression uses member_binding_expression, not member_access_expression - // The tree-sitter query doesn't match — this documents the gap for future work - expect(match).toBeUndefined(); + // C# conditional_access_expression (user?.Save()) is now captured via member_binding_expression + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member'); + expect(extractReceiverName(match!.nameNode)).toBe('user'); }); }); diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 240066109..16bd99009 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -518,6 +518,36 @@ class UserService { const { env } = buildTypeEnv(tree, 'php'); expect(flatGet(env, '$name')).toBe('string'); }); + + it('extracts PHPDoc @param with standard order: @param Type $name', () => { + const tree = parse(`save(); +} + `, PHP.php); + const { env } = buildTypeEnv(tree, 'php'); + expect(flatGet(env, '$repo')).toBe('UserRepo'); + expect(flatGet(env, '$name')).toBe('string'); + }); + + it('extracts PHPDoc @param with alternate order: @param $name Type', () => { + const tree = parse(`save(); +} + `, PHP.php); + const { env } = buildTypeEnv(tree, 'php'); + expect(flatGet(env, '$repo')).toBe('UserRepo'); + expect(flatGet(env, '$name')).toBe('string'); + }); }); describe('Ruby YARD annotations', () => {