mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
fix: C# null-conditional calls, Ruby YARD bracket-balanced split, PHPDoc alternate order, escapeValue hardening
- Add C# null-conditional call support (user?.Save()): tree-sitter query for
conditional_access_expression, member_binding_expression in MEMBER_ACCESS_NODE_TYPES,
receiver extraction via conditional_access_expression parent walk
- Fix Ruby YARD type parsing for nested generics (Hash<Symbol, User>): replace
naive split(',') with bracket-balanced splitter respecting <> depth
- Add alternate YARD format (@param [Type] name) alongside standard (@param name [Type])
- Add alternate PHPDoc format (@param $name Type) alongside standard (@param Type $name)
- Harden escapeValue in kuzu-adapter.ts: escape \n and \r to prevent Cypher injection
- Integration tests: C# null-conditional fixture (5 tests), Ruby YARD generics fixture (6 tests)
- Unit tests: PHPDoc alternate order (2 tests), C# null-conditional call-form (updated)
This commit is contained in:
parent
5caaae9f63
commit
ec4dca45ef
15 changed files with 280 additions and 9 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string> = 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<string, string> => {
|
|||
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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Symbol, User>
|
||||
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<string, string> => {
|
|||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/App.cs
vendored
Normal file
16
gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/App.cs
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/Repo.cs
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/Repo.cs
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace Models;
|
||||
|
||||
public class Repo
|
||||
{
|
||||
public bool Save()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/User.cs
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/Models/User.cs
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace Models;
|
||||
|
||||
public class User
|
||||
{
|
||||
public bool Save()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/NullConditional.csproj
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/NullConditional.csproj
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
19
gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/models.rb
vendored
Normal file
19
gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/models.rb
vendored
Normal file
|
|
@ -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
|
||||
16
gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/service.rb
vendored
Normal file
16
gitnexus/test/fixtures/lang-resolution/ruby-yard-generics/service.rb
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
require_relative './models'
|
||||
|
||||
class DataService
|
||||
# @param repo [UserRepo] the user repository
|
||||
# @param cache [Hash<Symbol, UserRepo>] 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
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -693,3 +693,65 @@ describe('Ruby constant factory call resolution (SERVICE = build_service())', ()
|
|||
expect(wrongCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ruby YARD generic type annotations (Hash<Symbol, User>)', () => {
|
||||
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<Symbol, UserRepo> is a generic container)', () => {
|
||||
// The @param cache [Hash<Symbol, UserRepo>] 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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(`<?php
|
||||
/**
|
||||
* @param UserRepo $repo the repository
|
||||
* @param string $name the user name
|
||||
*/
|
||||
function create($repo, $name) {
|
||||
$repo->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(`<?php
|
||||
/**
|
||||
* @param $repo UserRepo the repository
|
||||
* @param $name string the user name
|
||||
*/
|
||||
function process($repo, $name) {
|
||||
$repo->save();
|
||||
}
|
||||
`, PHP.php);
|
||||
const { env } = buildTypeEnv(tree, 'php');
|
||||
expect(flatGet(env, '$repo')).toBe('UserRepo');
|
||||
expect(flatGet(env, '$name')).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ruby YARD annotations', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue