Merge branch 'main' into feat/cpp-ue-macro-preprocessor

This commit is contained in:
Gergő Magyar 2026-05-09 09:08:35 +01:00 committed by GitHub
commit 0ee01bcaa3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 408 additions and 23 deletions

View file

@ -9,6 +9,11 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js';
const CSHARP_VIS = new Set<FieldVisibility>(['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;
},

View file

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

View file

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

View file

@ -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<string, BufferedCSVWriter>();
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(','),
);
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,18 @@
namespace App;
public class USER_INFO
{
public string? USER_ID { get; set; }
}
public interface IEntityTypeConfiguration<T>
{
}
public class UserInfoConfiguration : IEntityTypeConfiguration<USER_INFO>
{
public Task<List<USER_INFO>> Load(List<USER_INFO> users)
{
return Task.FromResult(users);
}
}

View file

@ -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<USER_INFO>`. 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> USER_INFO { get; set; }', description:'', declaredType:'DbSet<USER_INFO>'})`,
`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<USER_INFO>',
}),
]);
});
});
},
{
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;
},
},
);

View file

@ -1440,6 +1440,24 @@ describe('Write access tracking (C#)', () => {
});
});
// ---------------------------------------------------------------------------
// Generic type references: IEntityTypeConfiguration<USER_INFO>, List<USER_INFO>
// ---------------------------------------------------------------------------
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()
// ---------------------------------------------------------------------------

View file

@ -11,6 +11,9 @@ import type { GraphRelationship } from 'gitnexus-shared';
const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, ReadonlySet<string>>> = {
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

View file

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

View file

@ -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<USER_INFO> { public Task<List<USER_INFO>> Load(List<USER_INFO> 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<USER_INFO>(); } }',
'test.cs',
);
const names = matches
.filter((m) => '@reference.type' in m)
.map((m) => m['@reference.name'].text);
expect(names).toContain('USER_INFO');
});
});