mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
feat: Phase 8 field/property type resolution — resolve chained member access
Add field/property type extraction to the type resolution system so that chained member access like `user.address.save()` resolves the intermediate receiver type (`address → Address`) through Property symbols in SymbolTable. Key changes: - SymbolTable: add `declaredType` field, `fieldByOwner` O(1) index, `lookupFieldByOwner()` method, P0 conditional callableIndex invalidation, P2 exclude Properties from globalIndex to prevent namespace pollution - tree-sitter queries: add `definition.property` for TypeScript, Java, Go - parse-worker: extract declared types for Property nodes via `extractPropertyDeclaredType()`, capture field-access receiver info - call-processor: add `resolveFieldAccessType()` helper and field-access branch in both sequential and worker receiver resolution paths - Integration tests: new field-types test suite verifying end-to-end `user.address.save() → Address#save` resolution
This commit is contained in:
parent
fb5270c260
commit
ac8957f211
9 changed files with 360 additions and 10 deletions
|
|
@ -271,6 +271,35 @@ export const processCalls = async (
|
|||
receiverTypeName = receiverName;
|
||||
}
|
||||
}
|
||||
// Fall back to field-access resolution when the receiver is a member_expression
|
||||
// (e.g. user.address.save() — the receiver of save() is user.address, a field access).
|
||||
if (callForm === 'member' && !receiverTypeName && !receiverName) {
|
||||
const receiverNode = extractReceiverNode(nameNode);
|
||||
if (receiverNode && !CALL_EXPRESSION_TYPES.has(receiverNode.type)) {
|
||||
// receiverNode is a member_expression — extract object.property
|
||||
const objectNode = receiverNode.childForFieldName?.('object')
|
||||
?? receiverNode.childForFieldName?.('value')
|
||||
?? receiverNode.childForFieldName?.('operand')
|
||||
?? receiverNode.childForFieldName?.('expression');
|
||||
const propertyNode = receiverNode.childForFieldName?.('property')
|
||||
?? receiverNode.childForFieldName?.('field')
|
||||
?? receiverNode.childForFieldName?.('name');
|
||||
if (objectNode && propertyNode) {
|
||||
const objectName = objectNode.text;
|
||||
const propertyName = propertyNode.text;
|
||||
// Resolve the object's type from TypeEnv
|
||||
let objectType = typeEnv ? typeEnv.lookup(objectName, callNode) : undefined;
|
||||
if (!objectType && verifiedReceivers.size > 0) {
|
||||
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
|
||||
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
|
||||
objectType = lookupReceiverType(verifiedReceivers, funcName, objectName);
|
||||
}
|
||||
if (objectType) {
|
||||
receiverTypeName = resolveFieldAccessType(objectType, propertyName, file.path, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to chained call resolution when the receiver is a call expression
|
||||
// (e.g. svc.getUser().save() — receiver of save() is getUser(), not a simple identifier).
|
||||
if (callForm === 'member' && !receiverTypeName && !receiverName) {
|
||||
|
|
@ -573,6 +602,36 @@ const lookupReceiverType = (
|
|||
return map.get(fileLevelKey);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a property/field access on a typed receiver to determine the field's declared type.
|
||||
* Used when a call's receiver is a member_expression (e.g. `user.address.save()` — the receiver
|
||||
* of `save()` is `user.address` which is a field access, not a method call).
|
||||
*
|
||||
* Walks up to MAX_CHAIN_DEPTH levels of nested member_expression nodes to handle chains
|
||||
* like `user.address.city.getName()`.
|
||||
*
|
||||
* @returns The resolved type of the deepest field access, or undefined if resolution fails.
|
||||
*/
|
||||
const resolveFieldAccessType = (
|
||||
receiverName: string,
|
||||
fieldName: string,
|
||||
filePath: string,
|
||||
ctx: ResolutionContext,
|
||||
): string | undefined => {
|
||||
// Resolve the receiver's type to a class/struct nodeId
|
||||
const typeResolved = ctx.resolve(receiverName, filePath);
|
||||
if (!typeResolved) return undefined;
|
||||
const classDef = typeResolved.candidates.find(
|
||||
d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface',
|
||||
);
|
||||
if (!classDef) return undefined;
|
||||
|
||||
const fieldDef = ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName);
|
||||
if (!fieldDef?.declaredType) return undefined;
|
||||
|
||||
return extractReturnTypeName(fieldDef.declaredType);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fast path: resolve pre-extracted call sites from workers.
|
||||
* No AST parsing — workers already extracted calledName + sourceId.
|
||||
|
|
@ -638,6 +697,32 @@ export const processCallsFromExtracted = async (
|
|||
}
|
||||
}
|
||||
|
||||
// Step 1c: field-access resolution (e.g. user.address.save())
|
||||
// When the parse-worker captured a receiverFieldAccess, resolve the field's declared type.
|
||||
if (!effectiveCall.receiverTypeName && effectiveCall.receiverFieldAccess) {
|
||||
const { objectName, fieldName } = effectiveCall.receiverFieldAccess;
|
||||
// Resolve the object's type from constructor bindings or class-as-receiver
|
||||
let objectType: string | undefined;
|
||||
if (receiverMap) {
|
||||
const callFuncName = extractFuncNameFromSourceId(effectiveCall.sourceId);
|
||||
objectType = lookupReceiverType(receiverMap, callFuncName, objectName);
|
||||
}
|
||||
if (!objectType) {
|
||||
const typeResolved = ctx.resolve(objectName, effectiveCall.filePath);
|
||||
if (typeResolved?.candidates.some(d =>
|
||||
d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum',
|
||||
)) {
|
||||
objectType = objectName;
|
||||
}
|
||||
}
|
||||
if (objectType) {
|
||||
const fieldType = resolveFieldAccessType(objectType, fieldName, effectiveCall.filePath, ctx);
|
||||
if (fieldType) {
|
||||
effectiveCall = { ...effectiveCall, receiverTypeName: fieldType };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: if the call has a receiver call chain (e.g. svc.getUser().save()),
|
||||
// resolve the chain to determine the final receiver type.
|
||||
// This runs whenever receiverCallChain is present — even when Step 1 set a
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { generateId } from '../../lib/utils.js';
|
|||
import { SymbolTable } from './symbol-table.js';
|
||||
import { ASTCache } from './ast-cache.js';
|
||||
import { getLanguageFromFilename, yieldToEventLoop, DEFINITION_CAPTURE_KEYS, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature } from './utils.js';
|
||||
import { extractSimpleTypeName } from './type-extractors/shared.js';
|
||||
import { isNodeExported } from './export-detection.js';
|
||||
import { detectFrameworkFromAST } from './framework-detection.js';
|
||||
import { typeConfigs } from './type-extractors/index.js';
|
||||
|
|
@ -81,6 +82,7 @@ const processParsingWithWorkers = async (
|
|||
symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, {
|
||||
parameterCount: sym.parameterCount,
|
||||
returnType: sym.returnType,
|
||||
declaredType: sym.declaredType,
|
||||
ownerId: sym.ownerId,
|
||||
});
|
||||
}
|
||||
|
|
@ -275,9 +277,40 @@ const processParsingSequential = async (
|
|||
const needsOwner = nodeLabel === 'Method' || nodeLabel === 'Constructor' || nodeLabel === 'Property' || nodeLabel === 'Function';
|
||||
const enclosingClassId = needsOwner ? findEnclosingClassId(nameNode || definitionNodeForRange, file.path) : null;
|
||||
|
||||
// Extract declared type for Property nodes (field/property type annotations)
|
||||
let declaredType: string | undefined;
|
||||
if (nodeLabel === 'Property' && definitionNode) {
|
||||
const typeNode = definitionNode.childForFieldName?.('type');
|
||||
if (typeNode) {
|
||||
declaredType = extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
|
||||
}
|
||||
if (!declaredType) {
|
||||
// TypeScript pattern: look for type_annotation child
|
||||
for (let i = 0; i < definitionNode.childCount; i++) {
|
||||
const child = definitionNode.child(i);
|
||||
if (child?.type === 'type_annotation') {
|
||||
for (let j = 0; j < child.childCount; j++) {
|
||||
const typeChild = child.child(j);
|
||||
if (typeChild && typeChild.type !== ':') {
|
||||
declaredType = extractSimpleTypeName(typeChild) ?? typeChild.text?.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Java: type is on the parent field_declaration
|
||||
if (!declaredType && definitionNode.parent) {
|
||||
const parentType = definitionNode.parent.childForFieldName?.('type');
|
||||
if (parentType) declaredType = extractSimpleTypeName(parentType) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
symbolTable.add(file.path, nodeName, nodeId, nodeLabel, {
|
||||
parameterCount: methodSig?.parameterCount,
|
||||
returnType: methodSig?.returnType,
|
||||
declaredType,
|
||||
ownerId: enclosingClassId ?? undefined,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ export interface SymbolDefinition {
|
|||
parameterCount?: number;
|
||||
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
|
||||
returnType?: string;
|
||||
/** Links Method/Constructor to owning Class/Struct/Trait nodeId */
|
||||
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
|
||||
declaredType?: string;
|
||||
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
|
||||
ownerId?: string;
|
||||
}
|
||||
|
||||
|
|
@ -18,7 +20,7 @@ export interface SymbolTable {
|
|||
name: string,
|
||||
nodeId: string,
|
||||
type: string,
|
||||
metadata?: { parameterCount?: number; returnType?: string; ownerId?: string }
|
||||
metadata?: { parameterCount?: number; returnType?: string; declaredType?: string; ownerId?: string }
|
||||
) => void;
|
||||
|
||||
/**
|
||||
|
|
@ -45,7 +47,14 @@ export interface SymbolTable {
|
|||
* Used by ReturnTypeLookup to resolve callee → return type.
|
||||
*/
|
||||
lookupFuzzyCallable: (name: string) => SymbolDefinition[];
|
||||
|
||||
|
||||
/**
|
||||
* Look up a field/property by its owning class nodeId and field name.
|
||||
* O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0fieldName`.
|
||||
* Returns undefined when no matching property exists or the owner is ambiguous.
|
||||
*/
|
||||
lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => SymbolDefinition | undefined;
|
||||
|
||||
/**
|
||||
* Debugging: See how many symbols are tracked
|
||||
*/
|
||||
|
|
@ -71,6 +80,10 @@ export const createSymbolTable = (): SymbolTable => {
|
|||
// Only Function, Method, Constructor symbols are indexed.
|
||||
let callableIndex: Map<string, SymbolDefinition[]> | null = null;
|
||||
|
||||
// 4. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName".
|
||||
// Only Property symbols with ownerId and declaredType are indexed.
|
||||
const fieldByOwner = new Map<string, SymbolDefinition>();
|
||||
|
||||
const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']);
|
||||
|
||||
const add = (
|
||||
|
|
@ -78,7 +91,7 @@ export const createSymbolTable = (): SymbolTable => {
|
|||
name: string,
|
||||
nodeId: string,
|
||||
type: string,
|
||||
metadata?: { parameterCount?: number; returnType?: string; ownerId?: string }
|
||||
metadata?: { parameterCount?: number; returnType?: string; declaredType?: string; ownerId?: string }
|
||||
) => {
|
||||
const def: SymbolDefinition = {
|
||||
nodeId,
|
||||
|
|
@ -86,6 +99,7 @@ export const createSymbolTable = (): SymbolTable => {
|
|||
type,
|
||||
...(metadata?.parameterCount !== undefined ? { parameterCount: metadata.parameterCount } : {}),
|
||||
...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}),
|
||||
...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}),
|
||||
...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}),
|
||||
};
|
||||
|
||||
|
|
@ -95,14 +109,26 @@ export const createSymbolTable = (): SymbolTable => {
|
|||
}
|
||||
fileIndex.get(filePath)!.set(name, def);
|
||||
|
||||
// B. Add to Global Index (same object reference)
|
||||
// B. Properties go to fieldByOwner index only — skip globalIndex to prevent
|
||||
// namespace pollution for common names like 'id', 'name', 'type'.
|
||||
if (type === 'Property' && metadata?.ownerId) {
|
||||
if (metadata?.declaredType) {
|
||||
fieldByOwner.set(`${metadata.ownerId}\0${name}`, def);
|
||||
}
|
||||
// Still add to fileIndex above (for lookupExact), but skip globalIndex
|
||||
return;
|
||||
}
|
||||
|
||||
// C. Add to Global Index (same object reference)
|
||||
if (!globalIndex.has(name)) {
|
||||
globalIndex.set(name, []);
|
||||
}
|
||||
globalIndex.get(name)!.push(def);
|
||||
|
||||
// Invalidate the lazy callable index — it will be rebuilt on next use
|
||||
callableIndex = null;
|
||||
// D. Invalidate the lazy callable index only when adding callable types
|
||||
if (CALLABLE_TYPES.has(type)) {
|
||||
callableIndex = null;
|
||||
}
|
||||
};
|
||||
|
||||
const lookupExact = (filePath: string, name: string): string | undefined => {
|
||||
|
|
@ -129,6 +155,10 @@ export const createSymbolTable = (): SymbolTable => {
|
|||
return callableIndex.get(name) ?? [];
|
||||
};
|
||||
|
||||
const lookupFieldByOwner = (ownerNodeId: string, fieldName: string): SymbolDefinition | undefined => {
|
||||
return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`);
|
||||
};
|
||||
|
||||
const getStats = () => ({
|
||||
fileCount: fileIndex.size,
|
||||
globalSymbolCount: globalIndex.size
|
||||
|
|
@ -138,7 +168,8 @@ export const createSymbolTable = (): SymbolTable => {
|
|||
fileIndex.clear();
|
||||
globalIndex.clear();
|
||||
callableIndex = null;
|
||||
fieldByOwner.clear();
|
||||
};
|
||||
|
||||
return { add, lookupExact, lookupExactFull, lookupFuzzy, lookupFuzzyCallable, getStats, clear };
|
||||
return { add, lookupExact, lookupExactFull, lookupFuzzy, lookupFuzzyCallable, lookupFieldByOwner, getStats, clear };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -62,6 +62,10 @@ export const TYPESCRIPT_QUERIES = `
|
|||
(new_expression
|
||||
constructor: (identifier) @call.name) @call
|
||||
|
||||
; Class properties — public_field_definition covers most TS class fields
|
||||
(public_field_definition
|
||||
name: (property_identifier) @name) @definition.property
|
||||
|
||||
; Heritage queries - class extends
|
||||
(class_declaration
|
||||
name: (type_identifier) @heritage.class
|
||||
|
|
@ -179,6 +183,11 @@ export const JAVA_QUERIES = `
|
|||
(method_declaration name: (identifier) @name) @definition.method
|
||||
(constructor_declaration name: (identifier) @name) @definition.constructor
|
||||
|
||||
; Fields — typed field declarations inside class bodies
|
||||
(field_declaration
|
||||
declarator: (variable_declarator
|
||||
name: (identifier) @name)) @definition.property
|
||||
|
||||
; Imports - capture any import declaration child as source
|
||||
(import_declaration (_) @import.source) @import
|
||||
|
||||
|
|
@ -243,6 +252,11 @@ export const GO_QUERIES = `
|
|||
(import_declaration (import_spec path: (interpreted_string_literal) @import.source)) @import
|
||||
(import_declaration (import_spec_list (import_spec path: (interpreted_string_literal) @import.source))) @import
|
||||
|
||||
; Struct fields — named field declarations inside struct types
|
||||
(field_declaration_list
|
||||
(field_declaration
|
||||
name: (field_identifier) @name)) @definition.property
|
||||
|
||||
; Struct embedding (anonymous fields = inheritance)
|
||||
(type_declaration
|
||||
(type_spec
|
||||
|
|
|
|||
|
|
@ -48,6 +48,66 @@ import { generateId } from '../../../lib/utils.js';
|
|||
import { extractNamedBindings } from '../named-binding-extraction.js';
|
||||
import { appendKotlinWildcard } from '../resolvers/index.js';
|
||||
import { callRouters } from '../call-routing.js';
|
||||
import { extractSimpleTypeName } from '../type-extractors/shared.js';
|
||||
|
||||
// ============================================================================
|
||||
// Property type extraction
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract the declared type from a field/property AST definition node.
|
||||
* Handles common patterns across languages:
|
||||
* - TypeScript: `name: Type` → type_annotation child
|
||||
* - Java: `Type name` → type child on field_declaration
|
||||
* - C#: `Type Name { get; set; }` → type child on property_declaration
|
||||
* - Go: `Name Type` → type child on field_declaration
|
||||
*
|
||||
* Returns the normalized type name, or undefined if no type can be extracted.
|
||||
*/
|
||||
const extractPropertyDeclaredType = (definitionNode: any, language: SupportedLanguages): string | undefined => {
|
||||
if (!definitionNode) return undefined;
|
||||
|
||||
// Strategy 1: Look for a `type` or `type_annotation` named field
|
||||
const typeNode = definitionNode.childForFieldName?.('type');
|
||||
if (typeNode) {
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
if (typeName) return typeName;
|
||||
// Fallback: use the raw text (for complex types like User[] or List<User>)
|
||||
const text = typeNode.text?.trim();
|
||||
if (text && text.length < 100) return text;
|
||||
}
|
||||
|
||||
// Strategy 2: Walk children looking for type_annotation (TypeScript pattern)
|
||||
for (let i = 0; i < definitionNode.childCount; i++) {
|
||||
const child = definitionNode.child(i);
|
||||
if (!child) continue;
|
||||
if (child.type === 'type_annotation') {
|
||||
// Type annotation has the actual type as a child
|
||||
for (let j = 0; j < child.childCount; j++) {
|
||||
const typeChild = child.child(j);
|
||||
if (typeChild && typeChild.type !== ':') {
|
||||
const typeName = extractSimpleTypeName(typeChild);
|
||||
if (typeName) return typeName;
|
||||
const text = typeChild.text?.trim();
|
||||
if (text && text.length < 100) return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: For Java field_declaration, the type is a sibling of variable_declarator
|
||||
// AST: (field_declaration type: (type_identifier) declarator: (variable_declarator ...))
|
||||
const parentDecl = definitionNode.parent;
|
||||
if (parentDecl) {
|
||||
const parentType = parentDecl.childForFieldName?.('type');
|
||||
if (parentType) {
|
||||
const typeName = extractSimpleTypeName(parentType);
|
||||
if (typeName) return typeName;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Types for serializable results
|
||||
|
|
@ -87,6 +147,7 @@ interface ParsedSymbol {
|
|||
type: string;
|
||||
parameterCount?: number;
|
||||
returnType?: string;
|
||||
declaredType?: string;
|
||||
ownerId?: string;
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +179,12 @@ export interface ExtractedCall {
|
|||
* Length is capped at MAX_CHAIN_DEPTH (3).
|
||||
*/
|
||||
receiverCallChain?: string[];
|
||||
/**
|
||||
* Field-access receiver when the receiver is a member_expression (not a call or identifier).
|
||||
* For `user.address.save()`, the `save` ExtractedCall gets
|
||||
* receiverFieldAccess = { objectName: 'user', fieldName: 'address' }.
|
||||
*/
|
||||
receiverFieldAccess?: { objectName: string; fieldName: string };
|
||||
}
|
||||
|
||||
export interface ExtractedHeritage {
|
||||
|
|
@ -1009,6 +1076,8 @@ const processFileGroup = (
|
|||
// from constructor bindings. receiverTypeName is intentionally left unset here —
|
||||
// the chain resolver in processCallsFromExtracted needs the base type as input and
|
||||
// produces the final receiver type as output.
|
||||
let receiverFieldAccess: { objectName: string; fieldName: string } | undefined;
|
||||
|
||||
if (callForm === 'member' && receiverName === undefined && !receiverTypeName) {
|
||||
const receiverNode = extractReceiverNode(callNameNode);
|
||||
if (receiverNode && CALL_EXPRESSION_TYPES.has(receiverNode.type)) {
|
||||
|
|
@ -1025,6 +1094,27 @@ const processFileGroup = (
|
|||
receiverTypeName = typeEnv.lookup(receiverName, callNode);
|
||||
}
|
||||
}
|
||||
} else if (receiverNode) {
|
||||
// Receiver is a member_expression (field access like user.address.save()).
|
||||
// Extract object and property so processCallsFromExtracted can resolve the field type.
|
||||
const objectNode = receiverNode.childForFieldName?.('object')
|
||||
?? receiverNode.childForFieldName?.('value')
|
||||
?? receiverNode.childForFieldName?.('operand')
|
||||
?? receiverNode.childForFieldName?.('expression');
|
||||
const propertyNode = receiverNode.childForFieldName?.('property')
|
||||
?? receiverNode.childForFieldName?.('field')
|
||||
?? receiverNode.childForFieldName?.('name');
|
||||
if (objectNode && propertyNode) {
|
||||
const objectName = objectNode.text;
|
||||
const fieldName = propertyNode.text;
|
||||
receiverFieldAccess = { objectName, fieldName };
|
||||
// Try resolving the object's type immediately from TypeEnv
|
||||
const objectType = typeEnv.lookup(objectName, callNode);
|
||||
if (objectType) {
|
||||
receiverName = objectName;
|
||||
receiverTypeName = objectType;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1037,6 +1127,7 @@ const processFileGroup = (
|
|||
...(receiverName !== undefined ? { receiverName } : {}),
|
||||
...(receiverTypeName !== undefined ? { receiverTypeName } : {}),
|
||||
...(receiverCallChain !== undefined ? { receiverCallChain } : {}),
|
||||
...(receiverFieldAccess !== undefined ? { receiverFieldAccess } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1109,6 +1200,7 @@ const processFileGroup = (
|
|||
|
||||
let parameterCount: number | undefined;
|
||||
let returnType: string | undefined;
|
||||
let declaredType: string | undefined;
|
||||
if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') {
|
||||
const sig = extractMethodSignature(definitionNode);
|
||||
parameterCount = sig.parameterCount;
|
||||
|
|
@ -1123,6 +1215,10 @@ const processFileGroup = (
|
|||
if (docReturn) returnType = docReturn;
|
||||
}
|
||||
}
|
||||
} else if (nodeLabel === 'Property' && definitionNode) {
|
||||
// Extract the declared type for property/field nodes.
|
||||
// Walk the definition node for type annotation children.
|
||||
declaredType = extractPropertyDeclaredType(definitionNode, language);
|
||||
}
|
||||
|
||||
result.nodes.push({
|
||||
|
|
@ -1157,6 +1253,7 @@ const processFileGroup = (
|
|||
type: nodeLabel,
|
||||
...(parameterCount !== undefined ? { parameterCount } : {}),
|
||||
...(returnType !== undefined ? { returnType } : {}),
|
||||
...(declaredType !== undefined ? { declaredType } : {}),
|
||||
...(enclosingClassId ? { ownerId: enclosingClassId } : {}),
|
||||
});
|
||||
|
||||
|
|
|
|||
24
gitnexus/test/fixtures/lang-resolution/field-types/models.ts
vendored
Normal file
24
gitnexus/test/fixtures/lang-resolution/field-types/models.ts
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export class Address {
|
||||
city: string;
|
||||
|
||||
save(): void {
|
||||
// persist address
|
||||
}
|
||||
}
|
||||
|
||||
export class User {
|
||||
name: string;
|
||||
address: Address;
|
||||
|
||||
greet(): string {
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class Config {
|
||||
static DEFAULT: Config = new Config();
|
||||
|
||||
validate(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/field-types/service.ts
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/field-types/service.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { User, Config } from './models';
|
||||
|
||||
function processUser(user: User) {
|
||||
// Field-access chain: user.address resolves to Address, then .save() resolves to Address#save
|
||||
user.address.save();
|
||||
}
|
||||
|
||||
function validateConfig() {
|
||||
// Static field access: Config.DEFAULT resolves to Config, then .validate() resolves to Config#validate
|
||||
Config.DEFAULT.validate();
|
||||
}
|
||||
54
gitnexus/test/integration/resolvers/field-types.test.ts
Normal file
54
gitnexus/test/integration/resolvers/field-types.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Phase 8: Field/property type resolution — verifies that chained member access
|
||||
* through typed fields resolves correctly (e.g. user.address.save() → Address#save).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES, getRelationships, getNodesByLabel, edgeSet,
|
||||
runPipelineFromRepo, type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
describe('Field type resolution (TypeScript)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'field-types'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects classes: Address, Config, User', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'Config', 'User']);
|
||||
});
|
||||
|
||||
it('detects Property nodes for typed fields', () => {
|
||||
const properties = getNodesByLabel(result, 'Property');
|
||||
// Should capture: address, city, name (from User + Address classes)
|
||||
// DEFAULT is static and may or may not be captured depending on tree-sitter query
|
||||
expect(properties).toContain('address');
|
||||
expect(properties).toContain('name');
|
||||
expect(properties).toContain('city');
|
||||
});
|
||||
|
||||
it('emits HAS_METHOD edges linking properties to classes', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
const propEdges = hasMethod.filter(e => e.targetLabel === 'Property');
|
||||
// At minimum: User.address, User.name, Address.city
|
||||
expect(propEdges.length).toBeGreaterThanOrEqual(3);
|
||||
expect(edgeSet(propEdges)).toContain('User → address');
|
||||
expect(edgeSet(propEdges)).toContain('User → name');
|
||||
expect(edgeSet(propEdges)).toContain('Address → city');
|
||||
});
|
||||
|
||||
it('resolves user.address.save() → Address#save via field type', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
// processUser should call save() with receiver type Address
|
||||
const saveCalls = calls.filter(e => e.target === 'save');
|
||||
// The save method belongs to Address, so the target should be Address's save
|
||||
const addressSave = saveCalls.find(e => e.targetFilePath.includes('models'));
|
||||
expect(addressSave).toBeDefined();
|
||||
expect(addressSave!.source).toBe('processUser');
|
||||
});
|
||||
});
|
||||
|
|
@ -53,11 +53,12 @@ describe('TypeScript heritage resolution', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('emits HAS_METHOD edges linking methods to classes', () => {
|
||||
it('emits HAS_METHOD edges linking methods and properties to classes', () => {
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
expect(hasMethod.length).toBe(4);
|
||||
expect(hasMethod.length).toBe(5);
|
||||
expect(edgeSet(hasMethod)).toEqual([
|
||||
'BaseService → getName',
|
||||
'BaseService → name',
|
||||
'ConsoleLogger → log',
|
||||
'UserService → getUsers',
|
||||
'UserService → log',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue