diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/csharp.ts b/gitnexus/src/core/ingestion/field-extractors/configs/csharp.ts index 4d83a6cd7..75032a9d7 100644 --- a/gitnexus/src/core/ingestion/field-extractors/configs/csharp.ts +++ b/gitnexus/src/core/ingestion/field-extractors/configs/csharp.ts @@ -9,6 +9,11 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js'; const CSHARP_VIS = new Set(['public', 'private', 'protected', 'internal']); +const extractCsharpDeclaredType = (typeNode: SyntaxNode): string | undefined => { + if (typeNode.type === 'generic_name') return typeNode.text.trim(); + return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); +}; + /** * C# field extraction config. * @@ -53,17 +58,17 @@ export const csharpConfig: FieldExtractionConfig = { const child = node.namedChild(i); if (child?.type === 'variable_declaration') { const typeNode = child.childForFieldName('type'); - if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + if (typeNode) return extractCsharpDeclaredType(typeNode); // fallback: first child that is a type const first = child.firstNamedChild; if (first && first.type !== 'variable_declarator') { - return extractSimpleTypeName(first) ?? first.text?.trim(); + return extractCsharpDeclaredType(first); } } } // property_declaration: type is first named child const typeNode = node.childForFieldName('type'); - if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + if (typeNode) return extractCsharpDeclaredType(typeNode); return undefined; }, diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts index 2f913a14e..e29552e78 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -42,6 +42,39 @@ const FUNCTION_NODE_TYPES = [ 'local_function_statement', ] as const; +const BUILTIN_TYPE_NAMES = new Set([ + 'bool', + 'byte', + 'char', + 'decimal', + 'double', + 'float', + 'int', + 'long', + 'object', + 'sbyte', + 'short', + 'string', + 'uint', + 'ulong', + 'ushort', + 'void', +]); + +function shouldEmitReadMember(memberNode: SyntaxNode): boolean { + const parent = memberNode.parent; + if (parent === null) return true; + + switch (parent.type) { + case 'invocation_expression': + return parent.childForFieldName('function')?.id !== memberNode.id; + case 'assignment_expression': + return parent.childForFieldName('left')?.id !== memberNode.id; + default: + return true; + } +} + export function emitCsharpScopeCaptures( sourceText: string, _filePath: string, @@ -94,6 +127,14 @@ export function emitCsharpScopeCaptures( continue; } + if (grouped['@reference.read.member'] !== undefined) { + const anchor = grouped['@reference.read.member']; + const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'member_access_expression'); + if (memberNode === null || !shouldEmitReadMember(memberNode)) { + continue; + } + } + // Synthesize `this` / `base` receiver type-bindings on every // instance method-like. Tree-sitter can't cleanly express "the // implicit receiver of a non-static member of a class/struct/ @@ -209,9 +250,63 @@ export function emitCsharpScopeCaptures( } } + out.push(...synthesizeGenericTypeArgumentReferences(tree.rootNode)); + return out; } +function synthesizeGenericTypeArgumentReferences(root: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + // Treat all generic type arguments as static type references, including + // declaration signatures and call-site generic instantiations. + visit(root, (node) => { + if (node.type !== 'generic_name') return; + const args = findNamedChild(node, 'type_argument_list'); + if (args === null) return; + + for (const arg of args.namedChildren) { + if (arg === null) continue; + const nameNode = terminalTypeNameNode(arg); + if (nameNode === null) continue; + if (BUILTIN_TYPE_NAMES.has(nameNode.text)) continue; + out.push({ + '@reference.type': nodeToCapture('@reference.type', nameNode), + '@reference.name': nodeToCapture('@reference.name', nameNode), + }); + } + }); + return out; +} + +function terminalTypeNameNode(node: SyntaxNode): SyntaxNode | null { + switch (node.type) { + case 'identifier': + return node; + case 'nullable_type': + return node.firstNamedChild === null ? null : terminalTypeNameNode(node.firstNamedChild); + case 'qualified_name': + return node.lastNamedChild; + case 'generic_name': + return node.childForFieldName('name') ?? node.firstNamedChild; + default: + return null; + } +} + +function findNamedChild(node: SyntaxNode, type: string): SyntaxNode | null { + for (const child of node.namedChildren) { + if (child !== null && child.type === type) return child; + } + return null; +} + +function visit(node: SyntaxNode, cb: (node: SyntaxNode) => void): void { + cb(node); + for (const child of node.namedChildren) { + if (child !== null) visit(child, cb); + } +} + /** C# 12 primary constructor: `class X(a, b) { }` / `record X(a, b)`. * The parameters are a bare `parameter_list` named child of the type * declaration (no `constructor_declaration` node). Emit a synthetic diff --git a/gitnexus/src/core/ingestion/languages/csharp/query.ts b/gitnexus/src/core/ingestion/languages/csharp/query.ts index 11da2d8cd..f299aaafa 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/query.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/query.ts @@ -499,6 +499,12 @@ const CSHARP_SCOPE_QUERY = ` left: (member_access_expression expression: "base" @reference.receiver name: (identifier) @reference.name)) @reference.write.member + +;; References — field/property reads: \`obj.Name\` +;; Emit-side filtering drops call targets and assignment left-hand sides. +(member_access_expression + expression: (_) @reference.receiver + name: (identifier) @reference.name) @reference.read.member `; let _parser: Parser | null = null; diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 616a00cac..20df51ebd 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -301,11 +301,15 @@ export const streamAllCSVsToDisk = async ( 'Template', 'Module', ] as const; + const propertyHeader = 'id,name,filePath,startLine,endLine,content,description,declaredType'; const multiLangWriters = new Map(); for (const t of MULTI_LANG_TYPES) { multiLangWriters.set( t, - new BufferedCSVWriter(path.join(csvDir, `${t.toLowerCase()}.csv`), multiLangHeader), + new BufferedCSVWriter( + path.join(csvDir, `${t.toLowerCase()}.csv`), + t === 'Property' ? propertyHeader : multiLangHeader, + ), ); } @@ -478,6 +482,9 @@ export const streamAllCSVsToDisk = async ( escapeCSVNumber(node.properties.endLine, -1), escapeCSVField(content), escapeCSVField(node.properties.description || ''), + ...(node.label === 'Property' + ? [escapeCSVField(node.properties.declaredType || '')] + : []), ].join(','), ); } diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index fb4cf76de..cb88986ca 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -608,6 +608,9 @@ const getCopyQuery = (table: NodeTableName, filePath: string): string => { if (table === 'Method') { return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`; } + if (table === 'Property') { + return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, declaredType) FROM "${filePath}" ${COPY_CSV_OPTS}`; + } // TypeScript/JS code element tables have isExported; multi-language tables do not if (TABLES_WITH_EXPORTED.has(table)) { return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`; @@ -659,6 +662,11 @@ export const insertNodeToLbug = async ( ? `, description: ${escapeValue(properties.description)}` : ''; query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${escapeValue(properties.content || '')}${descPart}})`; + } else if (label === 'Property') { + const descPart = properties.description + ? `, description: ${escapeValue(properties.description)}` + : ''; + query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${escapeValue(properties.content || '')}${descPart}, declaredType: ${escapeValue(properties.declaredType || '')}})`; } else { // Multi-language tables (Struct, Impl, Trait, Macro, etc.) — no isExported const descPart = properties.description @@ -737,6 +745,11 @@ export const batchInsertNodesToLbug = async ( ? `, n.description = ${escapeValue(properties.description)}` : ''; query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${escapeValue(properties.content || '')}${descPart}`; + } else if (label === 'Property') { + const descPart = properties.description + ? `, n.description = ${escapeValue(properties.description)}` + : ''; + query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${escapeValue(properties.content || '')}${descPart}, n.declaredType = ${escapeValue(properties.declaredType || '')}`; } else { const descPart = properties.description ? `, n.description = ${escapeValue(properties.description)}` diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 5579036f1..cd6838dd8 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -167,7 +167,18 @@ export const TYPE_ALIAS_SCHEMA = CODE_ELEMENT_BASE('TypeAlias'); export const CONST_SCHEMA = CODE_ELEMENT_BASE('Const'); export const STATIC_SCHEMA = CODE_ELEMENT_BASE('Static'); export const VARIABLE_SCHEMA = CODE_ELEMENT_BASE('Variable'); -export const PROPERTY_SCHEMA = CODE_ELEMENT_BASE('Property'); +export const PROPERTY_SCHEMA = ` +CREATE NODE TABLE \`Property\` ( + id STRING, + name STRING, + filePath STRING, + startLine INT64, + endLine INT64, + content STRING, + description STRING, + declaredType STRING, + PRIMARY KEY (id) +)`; export const RECORD_SCHEMA = CODE_ELEMENT_BASE('Record'); export const DELEGATE_SCHEMA = CODE_ELEMENT_BASE('Delegate'); export const ANNOTATION_SCHEMA = CODE_ELEMENT_BASE('Annotation'); diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 6175af875..167c1db81 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -1756,12 +1756,13 @@ export class LocalBackend { repo.id, ` MATCH (caller)-[r:CodeRelation]->(n {id: $symId}) - WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES'] + WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES'] RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind LIMIT 30 `, { symId }, ); + let typedPropertyRows: any[] = []; // Fix #480: Class/Interface nodes have no direct CALLS/IMPORTS edges — // those point to Constructor and File nodes respectively. Fetch those @@ -1795,23 +1796,24 @@ export class LocalBackend { if (isClassLike) { try { - // Run both incoming-ref queries in parallel — they are independent. - const [ctorIncoming, fileIncoming] = await Promise.all([ - executeParameterized( - repo.id, - ` + // Run incoming-ref queries in parallel — they are independent. + const [ctorIncoming, fileIncoming, typedPropertyIncoming, typedProperties] = + await Promise.all([ + executeParameterized( + repo.id, + ` MATCH (n)-[hm:CodeRelation]->(ctor:Constructor) WHERE n.id = $symId AND hm.type = 'HAS_METHOD' MATCH (caller)-[r:CodeRelation]->(ctor) - WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'ACCESSES'] + WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'ACCESSES'] RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind LIMIT 30 `, - { symId }, - ), - executeParameterized( - repo.id, - ` + { symId }, + ), + executeParameterized( + repo.id, + ` MATCH (f:File)-[rel:CodeRelation]->(n) WHERE n.id = $symId AND rel.type = 'DEFINES' MATCH (caller)-[r:CodeRelation]->(f) @@ -1819,9 +1821,45 @@ export class LocalBackend { RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind LIMIT 30 `, - { symId }, - ), - ]); + { symId }, + ), + executeParameterized( + repo.id, + ` + MATCH (p:\`Property\`) + WHERE p.declaredType = $name + OR p.declaredType STARTS WITH $genericPrefix + OR p.declaredType CONTAINS $genericArg + MATCH (caller)-[r:CodeRelation]->(p) + WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'ACCESSES'] + RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind + LIMIT 30 + `, + { + name: sym.name, + genericPrefix: `${sym.name}<`, + genericArg: `<${sym.name}>`, + }, + ), + executeParameterized( + repo.id, + ` + MATCH (p:\`Property\`) + WHERE p.declaredType = $name + OR p.declaredType STARTS WITH $genericPrefix + OR p.declaredType CONTAINS $genericArg + RETURN p.id AS uid, p.name AS name, p.filePath AS filePath, labels(p)[0] AS kind, + p.declaredType AS declaredType + LIMIT 30 + `, + { + name: sym.name, + genericPrefix: `${sym.name}<`, + genericArg: `<${sym.name}>`, + }, + ), + ]); + typedPropertyRows = typedProperties; // Deduplicate by (relType, uid) — a caller can have multiple relation // types to the same target (e.g. both IMPORTS and CALLS), and each @@ -1829,7 +1867,7 @@ export class LocalBackend { const seenKeys = new Set( incomingRows.map((r: any) => `${r.relType || r[0]}:${r.uid || r[1]}`), ); - for (const r of [...ctorIncoming, ...fileIncoming]) { + for (const r of [...ctorIncoming, ...fileIncoming, ...typedPropertyIncoming]) { const key = `${r.relType || r[0]}:${r.uid || r[1]}`; if (!seenKeys.has(key)) { seenKeys.add(key); @@ -1846,7 +1884,7 @@ export class LocalBackend { repo.id, ` MATCH (n {id: $symId})-[r:CodeRelation]->(target) - WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES'] + WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES'] RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind LIMIT 30 `, @@ -1935,6 +1973,17 @@ export class LocalBackend { }, incoming: categorize(incomingRows), outgoing: categorize(outgoingRows), + ...(typedPropertyRows.length > 0 + ? { + typed_properties: typedPropertyRows.map((r: any) => ({ + uid: r.uid || r[0], + name: r.name || r[1], + filePath: r.filePath || r[2], + kind: r.kind || r[3], + declaredType: r.declaredType || r[4], + })), + } + : {}), processes: processRows.map((r: any) => ({ id: r.pid || r[0], name: r.label || r[1], @@ -2500,6 +2549,7 @@ export class LocalBackend { const mappedRelTypes = params.relationTypes?.flatMap((t: string) => t === 'OVERRIDES' ? ['OVERRIDES', 'METHOD_OVERRIDES'] : [t], ); + const hasExplicitRelationTypes = mappedRelTypes !== undefined && mappedRelTypes.length > 0; const rawRelTypes = mappedRelTypes && mappedRelTypes.length > 0 ? mappedRelTypes.filter((t: string) => VALID_RELATION_TYPES.has(t)) @@ -2508,6 +2558,7 @@ export class LocalBackend { 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', + 'USES', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', @@ -2520,6 +2571,7 @@ export class LocalBackend { 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', + 'USES', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', @@ -2579,9 +2631,16 @@ export class LocalBackend { }; const symType = outcome.resolvedLabel || outcome.symbol.type || ''; + const effectiveRelationTypes = + (symType === 'Class' || symType === 'Interface') && + !hasExplicitRelationTypes && + !relationTypes.includes('ACCESSES') + ? [...relationTypes, 'ACCESSES'] + : relationTypes; + return this._runImpactBFS(repo, sym, symType, direction, { maxDepth, - relationTypes, + relationTypes: effectiveRelationTypes, includeTests, minConfidence, }); @@ -2660,6 +2719,30 @@ export class LocalBackend { frontier.push(rid); } } + + const typedPropertyRows = await executeParameterized( + repo.id, + ` + MATCH (p:\`Property\`) + WHERE p.declaredType = $name + OR p.declaredType STARTS WITH $genericPrefix + OR p.declaredType CONTAINS $genericArg + RETURN p.id AS id, p.name AS name, labels(p)[0] AS type, p.filePath AS filePath + `, + { + name: sym.name, + genericPrefix: `${sym.name}<`, + genericArg: `<${sym.name}>`, + }, + ); + + for (const r of typedPropertyRows) { + const rid = r.id || r[0]; + if (rid && !visited.has(rid)) { + visited.add(rid); + frontier.push(rid); + } + } } catch (e) { logQueryError('impact:class-node-expansion', e); } diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-generic-type-refs/Program.cs b/gitnexus/test/fixtures/lang-resolution/csharp-generic-type-refs/Program.cs new file mode 100644 index 000000000..28674ecda --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-generic-type-refs/Program.cs @@ -0,0 +1,18 @@ +namespace App; + +public class USER_INFO +{ + public string? USER_ID { get; set; } +} + +public interface IEntityTypeConfiguration +{ +} + +public class UserInfoConfiguration : IEntityTypeConfiguration +{ + public Task> Load(List users) + { + return Task.FromResult(users); + } +} diff --git a/gitnexus/test/integration/context-typed-property.test.ts b/gitnexus/test/integration/context-typed-property.test.ts new file mode 100644 index 000000000..aef442147 --- /dev/null +++ b/gitnexus/test/integration/context-typed-property.test.ts @@ -0,0 +1,77 @@ +/** + * Integration test: context() expands Class symbols through typed properties. + * + * Reproduces EF-style usage where code reads a DbContext property + * (`db.USER_INFO`) whose source type is `DbSet`. The direct + * graph edge is Method -> Property, not Method -> Class, so context() must + * use the same typed-property bridge that impact() uses. + */ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; + +vi.mock('../../src/storage/repo-manager.js', () => ({ + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), +})); + +const SEED = [ + `CREATE (c:Class {id:'Class:Models/USER_INFO.cs:USER_INFO', name:'USER_INFO', filePath:'Models/USER_INFO.cs', startLine:1, endLine:5, content:'public class USER_INFO {}', description:''})`, + `CREATE (p:\`Property\` {id:'Property:Data/UserDbContext.cs:UserDbContext.USER_INFO', name:'USER_INFO', filePath:'Data/UserDbContext.cs', startLine:10, endLine:10, content:'public DbSet USER_INFO { get; set; }', description:'', declaredType:'DbSet'})`, + `CREATE (m:Method {id:'Method:Services/UserService.cs:UserService.GetUserInfo#1', name:'GetUserInfo', filePath:'Services/UserService.cs', startLine:20, endLine:30, isExported:false, content:'db.USER_INFO.FirstOrDefault();', description:'', parameterCount:1, returnType:'USER_INFO'})`, + `MATCH (m:Method {id:'Method:Services/UserService.cs:UserService.GetUserInfo#1'}), (p:\`Property\` {id:'Property:Data/UserDbContext.cs:UserDbContext.USER_INFO'}) CREATE (m)-[:CodeRelation {type:'ACCESSES', confidence:1.0, reason:'read', step:1}]->(p)`, +]; + +withTestLbugDB( + 'context-typed-property', + (handle) => { + let backend: LocalBackend; + + beforeAll(async () => { + backend = (handle as any)._backend; + }); + + describe('context() typed-property expansion', () => { + it('surfaces property callers and explains the typed property bridge', async () => { + const result = await backend.callTool('context', { + uid: 'Class:Models/USER_INFO.cs:USER_INFO', + }); + + expect(result.status).toBe('found'); + expect(result.symbol.kind).toBe('Class'); + + const accesses = result.incoming.accesses || []; + expect(accesses.map((r: any) => r.name)).toContain('GetUserInfo'); + + expect(result.typed_properties).toEqual([ + expect.objectContaining({ + uid: 'Property:Data/UserDbContext.cs:UserDbContext.USER_INFO', + name: 'USER_INFO', + declaredType: 'DbSet', + }), + ]); + }); + }); + }, + { + seed: SEED, + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'test-repo', + path: '/test/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 3, nodes: 3, communities: 0, processes: 0 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as any)._backend = backend; + }, + }, +); diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index 613ff7ec2..073b2da16 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -1440,6 +1440,24 @@ describe('Write access tracking (C#)', () => { }); }); +// --------------------------------------------------------------------------- +// Generic type references: IEntityTypeConfiguration, List +// --------------------------------------------------------------------------- + +describe('C# generic type-reference tracking', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-generic-type-refs'), () => {}); + }, 60000); + + it('emits USES edges for generic type arguments', () => { + const uses = getRelationships(result, 'USES').filter((e) => e.target === 'USER_INFO'); + expect(edgeSet(uses)).toContain('UserInfoConfiguration → USER_INFO'); + expect(edgeSet(uses)).toContain('Load → USER_INFO'); + }); +}); + // --------------------------------------------------------------------------- // Call-result variable binding (Phase 9): var user = GetUser(); user.Save() // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 5368c1715..e92526b19 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -11,6 +11,9 @@ import type { GraphRelationship } from 'gitnexus-shared'; const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly>> = { csharp: new Set([ 'emits the using-import edge App/Program.cs -> Models/User.cs through the scope-resolution path', + // Generic type-argument USES edges are emitted by the registry-primary + // resolver only; the legacy DAG path does not synthesize these references. + 'emits USES edges for generic type arguments', ]), go: new Set([ // The legacy DAG path does not resolve method calls when the method is diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index cab80984a..17db9bdf9 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -13,6 +13,7 @@ import { CLASS_SCHEMA, INTERFACE_SCHEMA, METHOD_SCHEMA, + PROPERTY_SCHEMA, CODE_ELEMENT_SCHEMA, COMMUNITY_SCHEMA, PROCESS_SCHEMA, @@ -117,6 +118,11 @@ describe('LadybugDB Schema', () => { expect(FUNCTION_SCHEMA).toContain('isExported BOOLEAN'); }); + it('Property schema preserves declaredType', () => { + expect(SCHEMA_QUERIES).toContain(PROPERTY_SCHEMA); + expect(PROPERTY_SCHEMA).toContain('declaredType STRING'); + }); + it('Community schema has heuristicLabel and cohesion', () => { expect(COMMUNITY_SCHEMA).toContain('heuristicLabel STRING'); expect(COMMUNITY_SCHEMA).toContain('cohesion DOUBLE'); diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts index c8111d203..9fa9bf181 100644 --- a/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts @@ -422,4 +422,47 @@ describe('emitCsharpScopeCaptures — references', () => { expect(m!['@reference.receiver'].text).toBe('obj'); expect(m!['@reference.name'].text).toBe('Name'); }); + + it('captures member reads `obj.Name`', () => { + const m = findMatch('class A { void M(User obj) { var name = obj.Name; } }', (t) => + t.includes('@reference.read.member'), + ); + expect(m).toBeDefined(); + expect(m!['@reference.receiver'].text).toBe('obj'); + expect(m!['@reference.name'].text).toBe('Name'); + }); + + it('does not capture member calls as member reads', () => { + const matches = emitCsharpScopeCaptures( + 'class A { void M(User obj) { obj.Save(); } }', + 'test.cs', + ); + expect(matches.some((m) => '@reference.call.member' in m)).toBe(true); + expect(matches.some((m) => '@reference.read.member' in m)).toBe(false); + }); + + it('captures generic type arguments as type references', () => { + const matches = emitCsharpScopeCaptures( + 'class A : IEntityTypeConfiguration { public Task> Load(List users) => null!; }', + 'test.cs', + ); + const names = matches + .filter((m) => '@reference.type' in m) + .map((m) => m['@reference.name'].text); + + expect(names).toContain('USER_INFO'); + expect(names).not.toContain('string'); + }); + + it('captures call-site generic type arguments as type references', () => { + const matches = emitCsharpScopeCaptures( + 'class A { void M(IRepo repo) { repo.Get(); } }', + 'test.cs', + ); + const names = matches + .filter((m) => '@reference.type' in m) + .map((m) => m['@reference.name'].text); + + expect(names).toContain('USER_INFO'); + }); });