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:
Gergo Magyar 2026-03-15 16:00:00 +00:00
parent 5caaae9f63
commit ec4dca45ef
15 changed files with 280 additions and 9 deletions

View file

@ -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

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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;

View file

@ -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

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

View file

@ -0,0 +1,9 @@
namespace Models;
public class Repo
{
public bool Save()
{
return true;
}
}

View file

@ -0,0 +1,9 @@
namespace Models;
public class User
{
public bool Save()
{
return true;
}
}

View file

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

View 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

View 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

View file

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

View file

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

View file

@ -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');
});
});

View file

@ -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', () => {