feat: Phase 8B mixed field+method chain resolution, C++/Rust chain fixes

Unify field and method chain resolution into a single `extractMixedChain`
walker that handles interleaved patterns like `svc.getUser().address.save()`.
Fix C++ chain calls (tree-sitter-cpp `field_expression` uses `argument` not
`object`), Rust unit struct instantiation (`let svc = TypeName;`), and add
stdlib passthrough for `unwrap()`/`clone()`/`expect()` in chain loops.

Key changes:
- Replace `receiverCallChain` + `receiverFieldAccess` with unified
  `receiverMixedChain: MixedChainStep[]` on ExtractedCall
- Add `extractMixedChain` in utils.ts (handles both call_expression and
  field_expression nodes, including C++ `argument` field)
- Add `TYPE_PRESERVING_METHODS` set for stdlib identity operations
- Add C++ inline method double-indexing guard in parsing-processor.ts
  and parse-worker.ts
- Add Rust unit struct recognition in type-extractors/rust.ts
- Split field-types.test.ts into per-language test files
- Add ts-mixed-chain fixture and integration tests
- Resolve rust.test.ts todo: Option<T>.unwrap().save() now works
- Update roadmap: Phases 7+8 complete, Phase 9 is next
This commit is contained in:
Gergo Magyar 2026-03-18 14:23:58 +00:00
parent c6ca847641
commit 8ef6911a8f
57 changed files with 1955 additions and 642 deletions

View file

@ -20,15 +20,24 @@ import {
extractReceiverNode,
findEnclosingClassId,
CALL_EXPRESSION_TYPES,
MAX_CHAIN_DEPTH,
extractCallChain,
extractMixedChain,
} from './utils.js';
import { buildTypeEnv } from './type-env.js';
import type { ConstructorBinding } from './type-env.js';
import { getTreeSitterBufferSize } from './constants.js';
import type { ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
import { callRouters } from './call-routing.js';
import { extractReturnTypeName } from './type-extractors/shared.js';
import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js';
// Stdlib methods that preserve the receiver's type identity. When TypeEnv already
// strips nullable wrappers (Option<User> → User), these chain steps are no-ops
// for type resolution — the current type passes through unchanged.
const TYPE_PRESERVING_METHODS = new Set([
'unwrap', 'expect', 'unwrap_or', 'unwrap_or_default', 'unwrap_or_else', // Rust Option/Result
'clone', 'to_owned', 'as_ref', 'as_mut', 'borrow', 'borrow_mut', // Rust clone/borrow
'get', // Kotlin/Java Optional.get()
'orElseThrow', // Java Optional
]);
/**
* Walk up the AST from a node to find the enclosing function/method.
@ -271,52 +280,56 @@ 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).
// Fall back to mixed chain resolution when the receiver is a complex expression
// (field chain, call chain, or interleaved — e.g. user.address.city.save() or
// svc.getUser().address.save()). Handles all cases with a single unified walk.
if (callForm === 'member' && !receiverTypeName && !receiverName) {
const receiverNode = extractReceiverNode(nameNode);
if (receiverNode && !CALL_EXPRESSION_TYPES.has(receiverNode.type)) {
const parts = extractMemberAccessParts(receiverNode);
if (parts) {
// Resolve the object's type from TypeEnv
let objectType = typeEnv ? typeEnv.lookup(parts.objectName, callNode) : undefined;
if (!objectType && verifiedReceivers.size > 0) {
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
objectType = lookupReceiverType(verifiedReceivers, funcName, parts.objectName);
}
if (objectType) {
receiverTypeName = resolveFieldAccessType(objectType, parts.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) {
const receiverNode = extractReceiverNode(nameNode);
if (receiverNode && CALL_EXPRESSION_TYPES.has(receiverNode.type)) {
const extracted = extractCallChain(receiverNode);
if (extracted) {
// Resolve the base receiver type if possible
let baseType = extracted.baseReceiverName && typeEnv
if (receiverNode) {
const extracted = extractMixedChain(receiverNode);
if (extracted && extracted.chain.length > 0) {
let currentType = extracted.baseReceiverName && typeEnv
? typeEnv.lookup(extracted.baseReceiverName, callNode)
: undefined;
if (!baseType && extracted.baseReceiverName && verifiedReceivers.size > 0) {
if (!currentType && extracted.baseReceiverName && verifiedReceivers.size > 0) {
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
baseType = lookupReceiverType(verifiedReceivers, funcName, extracted.baseReceiverName);
currentType = lookupReceiverType(verifiedReceivers, funcName, extracted.baseReceiverName);
}
// Class-as-receiver for chain base (e.g. UserService.find_user().save())
if (!baseType && extracted.baseReceiverName) {
if (!currentType && extracted.baseReceiverName) {
const cr = ctx.resolve(extracted.baseReceiverName, file.path);
if (cr?.candidates.some(d =>
d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum',
)) {
baseType = extracted.baseReceiverName;
currentType = extracted.baseReceiverName;
}
}
receiverTypeName = resolveChainedReceiver(extracted.chain, baseType, file.path, ctx);
if (currentType) {
for (const step of extracted.chain) {
if (!currentType) break;
if (step.kind === 'field') {
currentType = resolveFieldAccessType(currentType, step.name, file.path, ctx);
} else {
const resolved = resolveCallTarget(
{ calledName: step.name, callForm: 'member', receiverTypeName: currentType },
file.path,
ctx,
);
if (!resolved) {
// Stdlib passthrough: unwrap(), clone(), etc. preserve the receiver type
if (TYPE_PRESERVING_METHODS.has(step.name)) continue;
currentType = undefined; break;
}
const candidates = ctx.symbols.lookupFuzzy(step.name);
const symDef = candidates.find(c => c.nodeId === resolved.nodeId);
if (!symDef?.returnType) { currentType = undefined; break; }
const retType = extractReturnTypeName(symDef.returnType);
if (!retType) { currentType = undefined; break; }
currentType = retType;
}
}
receiverTypeName = currentType;
}
}
}
}
@ -416,46 +429,6 @@ const toResolveResult = (
reason: tier === 'same-file' ? 'same-file' : tier === 'import-scoped' ? 'import-resolved' : 'global',
});
/**
* Resolve a chain of intermediate method calls to find the receiver type for a
* final member call. Called when the receiver of a call is itself a call
* expression (e.g. `svc.getUser().save()`).
*
* @param chainNames Ordered list of method names from outermost to innermost
* intermediate call (e.g. ['getUser'] for `svc.getUser().save()`).
* @param baseReceiverTypeName The already-resolved type of the base receiver
* (e.g. 'UserService' for `svc`), or undefined.
* @param currentFile The file path for resolution context.
* @param ctx The resolution context for symbol lookup.
* @returns The type name of the final intermediate call's return type, or undefined
* if resolution fails at any step.
*/
function resolveChainedReceiver(
chainNames: string[],
baseReceiverTypeName: string | undefined,
currentFile: string,
ctx: ResolutionContext,
): string | undefined {
let currentType = baseReceiverTypeName;
for (const name of chainNames) {
const resolved = resolveCallTarget(
{ calledName: name, callForm: 'member', receiverTypeName: currentType },
currentFile,
ctx,
);
if (!resolved) return undefined;
const candidates = ctx.symbols.lookupFuzzy(name);
const symDef = candidates.find(c => c.nodeId === resolved.nodeId);
if (!symDef?.returnType) return undefined;
const returnTypeName = extractReturnTypeName(symDef.returnType);
if (!returnTypeName) return undefined;
currentType = returnTypeName;
}
return currentType;
}
/**
* Resolve a function call to its target node ID using priority strategy:
@ -593,16 +566,6 @@ 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.
*/
/**
* Extract object and property names from a member-access AST node.
* Handles cross-language AST variations:
@ -673,14 +636,17 @@ const resolveFieldAccessType = (
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',
d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl',
);
if (!classDef) return undefined;
const fieldDef = ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName);
if (!fieldDef?.declaredType) return undefined;
return extractReturnTypeName(fieldDef.declaredType);
// Use stripNullable (not extractReturnTypeName) — field types like List<User>
// should be preserved as-is, not unwrapped to User. Only strip nullable wrappers.
return stripNullable(fieldDef.declaredType);
};
/**
@ -748,55 +714,52 @@ 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) {
// Step 1c: mixed chain resolution (field, call, or interleaved — e.g. svc.getUser().address.save()).
// Runs whenever receiverMixedChain is present. Steps 1/1b may have resolved the base receiver
// type already; that type is used as the chain's starting point.
if (effectiveCall.receiverMixedChain?.length) {
// Use the already-resolved base type (from Steps 1/1b) or look it up now.
let currentType: string | undefined = effectiveCall.receiverTypeName;
if (!currentType && effectiveCall.receiverName && receiverMap) {
const callFuncName = extractFuncNameFromSourceId(effectiveCall.sourceId);
objectType = lookupReceiverType(receiverMap, callFuncName, objectName);
currentType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
}
if (!objectType) {
const typeResolved = ctx.resolve(objectName, effectiveCall.filePath);
if (!currentType && effectiveCall.receiverName) {
const typeResolved = ctx.resolve(effectiveCall.receiverName, effectiveCall.filePath);
if (typeResolved?.candidates.some(d =>
d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum',
)) {
objectType = objectName;
currentType = effectiveCall.receiverName;
}
}
if (objectType) {
const fieldType = resolveFieldAccessType(objectType, fieldName, effectiveCall.filePath, ctx);
if (fieldType) {
effectiveCall = { ...effectiveCall, receiverTypeName: fieldType };
if (currentType) {
for (const step of effectiveCall.receiverMixedChain) {
if (!currentType) break;
if (step.kind === 'field') {
currentType = resolveFieldAccessType(currentType, step.name, effectiveCall.filePath, ctx);
} else {
// step.kind === 'call': resolve the method and get its return type
const resolved = resolveCallTarget(
{ calledName: step.name, callForm: 'member', receiverTypeName: currentType },
effectiveCall.filePath,
ctx,
);
if (!resolved) {
// Stdlib passthrough: unwrap(), clone(), etc. preserve the receiver type
if (TYPE_PRESERVING_METHODS.has(step.name)) continue;
currentType = undefined; break;
}
const candidates = ctx.symbols.lookupFuzzy(step.name);
const symDef = candidates.find(c => c.nodeId === resolved.nodeId);
if (!symDef?.returnType) { currentType = undefined; break; }
const returnTypeName = extractReturnTypeName(symDef.returnType);
if (!returnTypeName) { currentType = undefined; break; }
currentType = returnTypeName;
}
}
if (currentType) {
effectiveCall = { ...effectiveCall, receiverTypeName: currentType };
}
}
}
// 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
// receiverTypeName, that type is the BASE receiver (e.g. UserService for svc),
// and the chain must be walked to produce the FINAL receiver (e.g. User from
// getUser() : User).
if (effectiveCall.receiverCallChain?.length) {
// Step 1 may have resolved the base receiver type (e.g. svc → UserService).
// Use it as the starting point for chain resolution.
let baseType = effectiveCall.receiverTypeName;
// If Step 1 didn't resolve it, try the receiver map directly.
if (!baseType && effectiveCall.receiverName && receiverMap) {
const callFuncName = extractFuncNameFromSourceId(effectiveCall.sourceId);
baseType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
}
const chainedType = resolveChainedReceiver(
effectiveCall.receiverCallChain,
baseType,
effectiveCall.filePath,
ctx,
);
if (chainedType) {
effectiveCall = { ...effectiveCall, receiverTypeName: chainedType };
}
}

View file

@ -65,6 +65,8 @@ export interface RubyPropertyItem {
accessorType: RubyAccessorType;
startLine: number;
endLine: number;
/** YARD @return [Type] annotation preceding the attr_accessor call */
declaredType?: string;
}
// ── Pre-allocated singletons for common return values ────────────────────────
@ -129,6 +131,25 @@ export function routeRubyCall(calledName: string, callNode: any): RubyCallRoutin
// ── attr_accessor / attr_reader / attr_writer → property definitions ───
if (calledName === 'attr_accessor' || calledName === 'attr_reader' || calledName === 'attr_writer') {
// Extract YARD @return [Type] from preceding comment (e.g. `# @return [Address]`)
let yardType: string | undefined;
let sibling = callNode.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = /@return\s+\[([^\]]+)\]/.exec(sibling.text);
if (match) {
const raw = match[1].trim();
// Extract simple type name: "User", "Array<User>" → "User"
const simple = raw.match(/^([A-Z]\w*)/);
if (simple) yardType = simple[1];
break;
}
} else if (sibling.isNamed) {
break; // stop at non-comment named sibling
}
sibling = sibling.previousSibling;
}
const items: RubyPropertyItem[] = [];
const argList = callNode.childForFieldName?.('arguments');
for (const arg of (argList?.children ?? [])) {
@ -138,6 +159,7 @@ export function routeRubyCall(calledName: string, callNode: any): RubyCallRoutin
accessorType: calledName as RubyAccessorType,
startLine: arg.startPosition.row,
endLine: arg.endPosition.row,
...(yardType ? { declaredType: yardType } : {}),
});
}
}

View file

@ -5,11 +5,12 @@ import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
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 { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature } from './utils.js';
import { extractPropertyDeclaredType } from './type-extractors/shared.js';
import { isNodeExported } from './export-detection.js';
import { detectFrameworkFromAST } from './framework-detection.js';
import { typeConfigs } from './type-extractors/index.js';
import { SupportedLanguages } from '../../config/supported-languages.js';
import { WorkerPool } from './workers/worker-pool.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
@ -202,7 +203,22 @@ const processParsingSequential = async (
let nodeLabel = 'CodeElement';
if (captureMap['definition.function']) nodeLabel = 'Function';
if (captureMap['definition.function']) {
// C/C++: @definition.function is broad and also matches inline class methods (inside
// a class/struct body). Those are already captured by @definition.method, so skip
// the duplicate Function entry to prevent double-indexing in globalIndex.
if (language === SupportedLanguages.CPlusPlus || language === SupportedLanguages.C) {
let ancestor = captureMap['definition.function']?.parent;
while (ancestor) {
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') {
break;
}
ancestor = ancestor.parent;
}
if (ancestor) return; // inside a class body — handled by @definition.method
}
nodeLabel = 'Function';
}
else if (captureMap['definition.class']) nodeLabel = 'Class';
else if (captureMap['definition.interface']) nodeLabel = 'Interface';
else if (captureMap['definition.method']) nodeLabel = 'Method';
@ -278,34 +294,9 @@ const processParsingSequential = async (
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;
}
}
const declaredType = (nodeLabel === 'Property' && definitionNode)
? extractPropertyDeclaredType(definitionNode)
: undefined;
symbolTable.add(file.path, nodeName, nodeId, nodeLabel, {
parameterCount: methodSig?.parameterCount,

View file

@ -66,6 +66,15 @@ export const TYPESCRIPT_QUERIES = `
(public_field_definition
name: (property_identifier) @name) @definition.property
; Private class fields: #address: Address
(public_field_definition
name: (private_property_identifier) @name) @definition.property
; Constructor parameter properties: constructor(public address: Address)
(required_parameter
(accessibility_modifier)
pattern: (identifier) @name) @definition.property
; Heritage queries - class extends
(class_declaration
name: (type_identifier) @heritage.class
@ -132,6 +141,10 @@ export const JAVASCRIPT_QUERIES = `
(new_expression
constructor: (identifier) @call.name) @call
; Class fields — field_definition captures JS class fields (class User { address = ... })
(field_definition
property: (property_identifier) @name) @definition.property
; Heritage queries - class extends (JavaScript uses different AST than TypeScript)
; In tree-sitter-javascript, class_heritage directly contains the parent identifier
(class_declaration
@ -164,6 +177,14 @@ export const PYTHON_QUERIES = `
function: (attribute
attribute: (identifier) @call.name)) @call
; Class attribute type annotations — PEP 526: address: Address or address: Address = Address()
; Both bare annotations (address: Address) and annotated assignments (name: str = "test")
; are parsed as (assignment left: ... type: ...) in tree-sitter-python.
(expression_statement
(assignment
left: (identifier) @name
type: (type))) @definition.property
; Heritage queries - Python class inheritance
(class_definition
name: (identifier) @heritage.class
@ -313,6 +334,11 @@ export const CPP_QUERIES = `
(declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.function
(declaration declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @name))) @definition.function
; Class/struct data member fields (Address address; int count;)
; Uses field_identifier to exclude method declarations (which use function_declarator)
(field_declaration
declarator: (field_identifier) @name) @definition.property
; Inline class method declarations (inside class body, no body: void Foo();)
(field_declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.method
@ -428,6 +454,11 @@ export const RUST_QUERIES = `
; Struct literal construction: User { name: value }
(struct_expression name: (type_identifier) @call.name) @call
; Struct fields — named field declarations inside struct bodies
(field_declaration_list
(field_declaration
name: (field_identifier) @name) @definition.property)
; Heritage (trait implementation) — all combinations of concrete/generic trait × concrete/generic type
(impl_item trait: (type_identifier) @heritage.trait type: (type_identifier) @heritage.class) @heritage
(impl_item trait: (generic_type type: (type_identifier) @heritage.trait) type: (type_identifier) @heritage.class) @heritage

View file

@ -95,7 +95,7 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<str
};
/** Rust: let x = User::new(), let x = User::default(), or let x = User { ... } */
const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, _classNames: ClassNameLookup): void => {
const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, classNames: ClassNameLookup): void => {
// Skip if there's an explicit type annotation — Tier 0 already handled it
if (node.childForFieldName('type') !== null) return;
const pattern = node.childForFieldName('pattern');
@ -116,6 +116,13 @@ const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<str
return;
}
// Unit struct instantiation: let svc = UserService; (bare identifier, no braces or call)
if (value.type === 'identifier' && classNames.has(value.text)) {
const varName = extractVarName(pattern);
if (varName) env.set(varName, value.text);
return;
}
if (value.type !== 'call_expression') return;
const func = value.childForFieldName('function');
if (!func || func.type !== 'scoped_identifier') return;

View file

@ -745,3 +745,91 @@ export const extractReturnTypeName = (raw: string, depth = 0): string | undefine
return text;
};
// ── Property declared-type extraction ────────────────────────────────────
// Shared between parse-worker (worker path) and parsing-processor (sequential path).
/**
* Extract the declared type of a property/field from its AST definition node.
* Handles cross-language patterns:
* - 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
* - Kotlin: `var name: Type` → variable_declaration child with type field
*
* Returns the normalized type name, or undefined if no type can be extracted.
*/
export const extractPropertyDeclaredType = (definitionNode: any): 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;
}
}
// Strategy 4: Kotlin property_declaration — type is nested inside variable_declaration child
// AST: (property_declaration (variable_declaration name: ... type: (user_type ...)))
for (let i = 0; i < definitionNode.childCount; i++) {
const child = definitionNode.child(i);
if (child?.type === 'variable_declaration') {
const varType = child.childForFieldName?.('type');
if (varType) {
const typeName = extractSimpleTypeName(varType);
if (typeName) return typeName;
const text = varType.text?.trim();
if (text && text.length < 100) return text;
}
}
}
// Strategy 5: PHP @var PHPDoc — look for preceding comment with @var Type
// Handles pre-PHP-7.4 code: /** @var Address */ public $address;
const prevSibling = definitionNode.previousNamedSibling ?? definitionNode.parent?.previousNamedSibling;
if (prevSibling?.type === 'comment') {
const commentText = prevSibling.text;
const varMatch = commentText?.match(/@var\s+([A-Z][\w\\]*)/);
if (varMatch) {
// Strip namespace prefix: \App\Models\User → User
const raw = varMatch[1];
const base = raw.includes('\\') ? raw.split('\\').pop()! : raw;
if (base && /^[A-Z]\w*$/.test(base)) return base;
}
}
return undefined;
};

View file

@ -264,7 +264,7 @@ export const CLASS_CONTAINER_TYPES = new Set([
'class_declaration', 'abstract_class_declaration',
'interface_declaration', 'struct_declaration', 'record_declaration',
'class_specifier', 'struct_specifier',
'impl_item', 'trait_item',
'impl_item', 'trait_item', 'struct_item', 'enum_item',
'class_definition',
'trait_declaration',
'protocol_declaration',
@ -286,6 +286,8 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
class_definition: 'Class',
impl_item: 'Impl',
trait_item: 'Trait',
struct_item: 'Struct',
enum_item: 'Enum',
trait_declaration: 'Trait',
record_declaration: 'Record',
protocol_declaration: 'Interface',
@ -1171,6 +1173,142 @@ export function extractCallChain(
return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined;
}
/** Node types representing member/field access across languages. */
const FIELD_ACCESS_NODE_TYPES = new Set([
'member_expression', // TS/JS
'member_access_expression', // C#
'selector_expression', // Go
'field_expression', // Rust/C++
'attribute', // Python
'navigation_expression', // Kotlin/Swift
'member_binding_expression', // C# null-conditional (user?.Address)
]);
/** One step in a mixed receiver chain. */
export type MixedChainStep = { kind: 'field' | 'call'; name: string };
/**
* Walk a receiver AST node that may interleave field accesses and method calls,
* building a unified chain of steps up to MAX_CHAIN_DEPTH.
*
* For `svc.getUser().address.save()`, called with the receiver of `save`
* (`svc.getUser().address`, a field access node):
* returns { chain: [{ kind:'call', name:'getUser' }, { kind:'field', name:'address' }],
* baseReceiverName: 'svc' }
*
* For `user.getAddress().city.getName()`, called with receiver of `getName`
* (`user.getAddress().city`):
* returns { chain: [{ kind:'call', name:'getAddress' }, { kind:'field', name:'city' }],
* baseReceiverName: 'user' }
*
* Pure field chains and pure call chains are special cases (all steps same kind).
*/
export function extractMixedChain(
receiverNode: SyntaxNode,
): { chain: MixedChainStep[]; baseReceiverName: string | undefined } | undefined {
const chain: MixedChainStep[] = [];
let current: SyntaxNode = receiverNode;
while (chain.length < MAX_CHAIN_DEPTH) {
if (CALL_EXPRESSION_TYPES.has(current.type)) {
// ── Call expression: extract method name + inner receiver ────────────
const funcNode = current.childForFieldName?.('function')
?? current.childForFieldName?.('name')
?? current.childForFieldName?.('method');
let methodName: string | undefined;
let innerReceiver: SyntaxNode | null = null;
if (funcNode) {
methodName = funcNode.lastNamedChild?.text ?? funcNode.text;
}
// Kotlin/Swift: call_expression → navigation_expression
if (!funcNode && current.type === 'call_expression') {
const callee = current.firstNamedChild;
if (callee?.type === 'navigation_expression') {
const suffix = callee.lastNamedChild;
if (suffix?.type === 'navigation_suffix') {
methodName = suffix.lastNamedChild?.text;
for (let i = 0; i < callee.namedChildCount; i++) {
const child = callee.namedChild(i);
if (child && child.type !== 'navigation_suffix') { innerReceiver = child; break; }
}
}
}
}
if (!methodName) break;
chain.unshift({ kind: 'call', name: methodName });
if (!innerReceiver && funcNode) {
innerReceiver = funcNode.childForFieldName?.('object')
?? funcNode.childForFieldName?.('value')
?? funcNode.childForFieldName?.('operand')
?? funcNode.childForFieldName?.('argument') // C/C++ field_expression
?? funcNode.childForFieldName?.('expression')
?? null;
}
if (!innerReceiver && current.type === 'method_invocation') {
innerReceiver = current.childForFieldName?.('object') ?? null;
}
if (!innerReceiver && (current.type === 'member_call_expression' || current.type === 'nullsafe_member_call_expression')) {
innerReceiver = current.childForFieldName?.('object') ?? null;
}
if (!innerReceiver && current.type === 'call') {
innerReceiver = current.childForFieldName?.('receiver') ?? null;
}
if (!innerReceiver) break;
if (CALL_EXPRESSION_TYPES.has(innerReceiver.type) || FIELD_ACCESS_NODE_TYPES.has(innerReceiver.type)) {
current = innerReceiver;
} else {
return { chain, baseReceiverName: innerReceiver.text || undefined };
}
} else if (FIELD_ACCESS_NODE_TYPES.has(current.type)) {
// ── Field/member access: extract property name + inner object ─────────
let propertyName: string | undefined;
let innerObject: SyntaxNode | null = null;
if (current.type === 'navigation_expression') {
for (const child of current.children ?? []) {
if (child.type === 'navigation_suffix') {
for (const sc of child.children ?? []) {
if (sc.isNamed && sc.type !== '.') { propertyName = sc.text; break; }
}
} else if (child.isNamed && !innerObject) {
innerObject = child;
}
}
} else if (current.type === 'attribute') {
innerObject = current.childForFieldName?.('object') ?? null;
propertyName = current.childForFieldName?.('attribute')?.text;
} else {
innerObject = current.childForFieldName?.('object')
?? current.childForFieldName?.('value')
?? current.childForFieldName?.('operand')
?? current.childForFieldName?.('argument') // C/C++ field_expression
?? current.childForFieldName?.('expression')
?? null;
propertyName = (current.childForFieldName?.('property')
?? current.childForFieldName?.('field')
?? current.childForFieldName?.('name'))?.text;
}
if (!propertyName) break;
chain.unshift({ kind: 'field', name: propertyName });
if (!innerObject) break;
if (CALL_EXPRESSION_TYPES.has(innerObject.type) || FIELD_ACCESS_NODE_TYPES.has(innerObject.type)) {
current = innerObject;
} else {
return { chain, baseReceiverName: innerObject.text || undefined };
}
} else {
// Simple identifier — this is the base receiver
return chain.length > 0
? { chain, baseReceiverName: current.text || undefined }
: undefined;
}
}
return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined;
}

View file

@ -36,8 +36,8 @@ import {
inferCallForm,
extractReceiverName,
extractReceiverNode,
CALL_EXPRESSION_TYPES,
extractCallChain,
extractMixedChain,
type MixedChainStep,
} from '../utils.js';
import { buildTypeEnv } from '../type-env.js';
import type { ConstructorBinding } from '../type-env.js';
@ -48,66 +48,7 @@ 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): 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;
};
import { extractPropertyDeclaredType } from '../type-extractors/shared.js';
// ============================================================================
// Types for serializable results
@ -172,19 +113,14 @@ export interface ExtractedCall {
/** Resolved type name of the receiver (e.g., 'User' for user.save() when user: User) */
receiverTypeName?: string;
/**
* Chained call names when the receiver is itself a call expression.
* For `svc.getUser().save()`, the `save` ExtractedCall gets receiverCallChain = ['getUser']
* with receiverName = 'svc'. The chain is ordered outermost-last, e.g.:
* `a.b().c().d()` → calledName='d', receiverCallChain=['b','c'], receiverName='a'
* Unified mixed chain when the receiver is a chain of field accesses and/or method calls.
* Steps are ordered base-first (innermost to outermost). Examples:
* `svc.getUser().save()` → chain=[{kind:'call',name:'getUser'}], receiverName='svc'
* `user.address.save()` → chain=[{kind:'field',name:'address'}], receiverName='user'
* `svc.getUser().address.save()` → chain=[{kind:'call',name:'getUser'},{kind:'field',name:'address'}]
* 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 };
receiverMixedChain?: MixedChainStep[];
}
export interface ExtractedHeritage {
@ -1032,6 +968,7 @@ const processFileGroup = (
nodeId,
type: 'Property',
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
...(item.declaredType ? { declaredType: item.declaredType } : {}),
});
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
@ -1067,72 +1004,24 @@ const processFileGroup = (
const callForm = inferCallForm(callNode, callNameNode);
let receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined;
let receiverTypeName = receiverName ? typeEnv.lookup(receiverName, callNode) : undefined;
let receiverCallChain: string[] | undefined;
// When the receiver is a call_expression (e.g. svc.getUser().save()),
// extractReceiverName returns undefined because it refuses complex expressions.
// Instead, walk the receiver node to build a call chain for deferred resolution.
// We capture the base receiver name so processCallsFromExtracted can look it up
// 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;
let receiverMixedChain: MixedChainStep[] | undefined;
// When the receiver is a complex expression (call chain, field chain, or mixed),
// extractReceiverName returns undefined. Walk the receiver node to build a unified
// mixed chain for deferred resolution in processCallsFromExtracted.
if (callForm === 'member' && receiverName === undefined && !receiverTypeName) {
const receiverNode = extractReceiverNode(callNameNode);
if (receiverNode && CALL_EXPRESSION_TYPES.has(receiverNode.type)) {
const extracted = extractCallChain(receiverNode);
if (extracted) {
receiverCallChain = extracted.chain;
// Set receiverName to the base object so Step 1 in processCallsFromExtracted
// can resolve it via constructor bindings to a base type for the chain.
if (receiverNode) {
const extracted = extractMixedChain(receiverNode);
if (extracted && extracted.chain.length > 0) {
receiverMixedChain = extracted.chain;
receiverName = extracted.baseReceiverName;
// Also try the type environment immediately (covers explicitly-typed locals
// and annotated parameters like `fn process(svc: &UserService)`).
// This sets a base type that chain resolution (Step 2) will use as input.
// Try the type environment immediately for the base receiver
// (covers explicitly-typed locals and annotated parameters).
if (receiverName) {
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.
let objectName: string | undefined;
let fieldName: string | undefined;
// Kotlin/Swift: navigation_expression — object is first child, property inside navigation_suffix
if (receiverNode.type === 'navigation_expression') {
for (const child of receiverNode.children ?? []) {
if (child.type === 'navigation_suffix') {
for (const sc of child.children ?? []) {
if (sc.isNamed && sc.type !== '.') { fieldName = sc.text; break; }
}
} else if (child.isNamed && !objectName) {
objectName = child.text;
}
}
} else {
// General: try standard field names used across grammars
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) objectName = objectNode.text;
if (propertyNode) fieldName = propertyNode.text;
}
if (objectName && fieldName) {
receiverFieldAccess = { objectName, fieldName };
// Try resolving the object's type immediately from TypeEnv
const objectType = typeEnv.lookup(objectName, callNode);
if (objectType) {
receiverName = objectName;
receiverTypeName = objectType;
}
}
}
}
@ -1144,8 +1033,7 @@ const processFileGroup = (
...(callForm !== undefined ? { callForm } : {}),
...(receiverName !== undefined ? { receiverName } : {}),
...(receiverTypeName !== undefined ? { receiverTypeName } : {}),
...(receiverCallChain !== undefined ? { receiverCallChain } : {}),
...(receiverFieldAccess !== undefined ? { receiverFieldAccess } : {}),
...(receiverMixedChain !== undefined ? { receiverMixedChain } : {}),
});
}
}
@ -1195,6 +1083,23 @@ const processFileGroup = (
const nodeLabel = getLabelFromCaptures(captureMap);
if (!nodeLabel) continue;
// C/C++: @definition.function is broad and also matches inline class methods (inside
// a class/struct body). Those are already captured by @definition.method, so skip
// the duplicate Function entry to prevent double-indexing in globalIndex.
if (
(language === SupportedLanguages.CPlusPlus || language === SupportedLanguages.C) &&
nodeLabel === 'Function'
) {
let ancestor = captureMap['definition.function']?.parent;
while (ancestor) {
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') {
break; // inside a class body — duplicate of @definition.method
}
ancestor = ancestor.parent;
}
if (ancestor) continue; // found a class/struct ancestor → skip
}
const nameNode = captureMap['name'];
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (!nameNode && nodeLabel !== 'Constructor') continue;

View file

@ -1,4 +1,5 @@
#include "service.h"
#include "user.h"
#include "repo.h"
void processUser() {

View file

@ -0,0 +1,30 @@
#pragma once
class City {
public:
std::string zipCode;
std::string getName() {
return "city";
}
};
class Address {
public:
City city;
std::string street;
void save() {
// persist address
}
};
class User {
public:
std::string name;
Address address;
std::string greet() {
return name;
}
};

View file

@ -0,0 +1,9 @@
#include "models.h"
void processUser(User user) {
// 2-level chain: user.address → Address, then .save() → Address#save
user.address.save();
// 3-level chain: user.address → Address, .city → City, .getName() → City#getName
user.address.city.getName();
}

View file

@ -0,0 +1,20 @@
#pragma once
class Address {
public:
std::string city;
void save() {
// persist address
}
};
class User {
public:
std::string name;
Address address;
std::string greet() {
return name;
}
};

View file

@ -0,0 +1,6 @@
#include "models.h"
void processUser(User user) {
// Field-access chain: user.address → Address, then .save() → Address#save
user.address.save();
}

View file

@ -0,0 +1,33 @@
namespace DeepFieldChain;
public class City
{
public string ZipCode { get; set; }
public string GetName()
{
return "city";
}
}
public class Address
{
public City City { get; set; }
public string Street { get; set; }
public void Save()
{
// persist address
}
}
public class User
{
public string Name { get; set; }
public Address Address { get; set; }
public string Greet()
{
return Name;
}
}

View file

@ -0,0 +1,13 @@
namespace DeepFieldChain;
public class Service
{
public static void ProcessUser(User user)
{
// 2-level chain: user.Address → Address, then .Save() → Address#Save
user.Address.Save();
// 3-level chain: user.Address → Address, .City → City, .GetName() → City#GetName
user.Address.City.GetName();
}
}

View file

@ -0,0 +1,11 @@
package main
import "example.com/go-deep-field-chain/models"
func processUser(user models.User) {
// 2-level chain: user.Address → Address, then .Save() → Address#Save
user.Address.Save()
// 3-level chain: user.Address → Address, .City → City, .GetName() → City#GetName
user.Address.City.GetName()
}

View file

@ -0,0 +1,3 @@
module example.com/go-deep-field-chain
go 1.21

View file

@ -0,0 +1,27 @@
package models
type City struct {
ZipCode string
}
func (c *City) GetName() string {
return "city"
}
type Address struct {
City City
Street string
}
func (a *Address) Save() bool {
return true
}
type User struct {
Name string
Address Address
}
func (u *User) Greet() string {
return u.Name
}

View file

@ -0,0 +1,11 @@
import models.User;
public class App {
public static void processUser(User user) {
// 2-level chain: user.address → Address, then .save() → Address#save
user.address.save();
// 3-level chain: user.address → Address, .city → City, .getName() → City#getName
user.address.city.getName();
}
}

View file

@ -0,0 +1,10 @@
package models;
public class Address {
public City city;
public String street;
public void save() {
// persist address
}
}

View file

@ -0,0 +1,9 @@
package models;
public class City {
public String zipCode;
public String getName() {
return "city";
}
}

View file

@ -0,0 +1,10 @@
package models;
public class User {
public String name;
public Address address;
public String greet() {
return this.name;
}
}

View file

@ -0,0 +1,26 @@
class Address {
city = '';
save() {
// persist address
}
}
class User {
name = '';
address = new Address();
greet() {
return this.name;
}
}
class Config {
static DEFAULT = new Config();
validate() {
return true;
}
}
module.exports = { Address, User, Config };

View file

@ -0,0 +1,9 @@
const { User, Config } = require('./models');
function processUser(user) {
user.address.save();
}
function validateConfig() {
Config.DEFAULT.validate();
}

View file

@ -0,0 +1,25 @@
class City {
var zipCode: String = ""
fun getName(): String {
return "city"
}
}
class Address {
var city: City = City()
var street: String = ""
fun save() {
// persist address
}
}
class User {
var name: String = ""
var address: Address = Address()
fun greet(): String {
return name
}
}

View file

@ -0,0 +1,7 @@
fun processUser(user: User) {
// 2-level chain: user.address → Address, then .save() → Address#save
user.address.save()
// 3-level chain: user.address → Address, .city → City, .getName() → City#getName
user.address.city.getName()
}

View file

@ -0,0 +1,34 @@
<?php
class City {
/** @var string */
public string $zipCode;
public function getName(): string {
return "city";
}
}
class Address {
/** @var City */
public City $city;
/** @var string */
public string $street;
public function save(): void {
// persist address
}
}
class User {
/** @var string */
public string $name;
/** @var Address */
public Address $address;
public function greet(): string {
return $this->name;
}
}

View file

@ -0,0 +1,11 @@
<?php
class Service {
public function processUser(User $user): void {
// 2-level chain: $user->address → Address, then ->save() → Address#save
$user->address->save();
// 3-level chain: $user->address → Address, ->city → City, ->getName() → City#getName
$user->address->city->getName();
}
}

View file

@ -0,0 +1,12 @@
class Address:
city: str
def save(self):
pass
class User:
name: str
address: Address
def greet(self) -> str:
return self.name

View file

@ -0,0 +1,4 @@
from models import User
def process_user(user: User):
user.address.save()

View file

@ -0,0 +1,20 @@
class Address
# @return [String]
attr_accessor :city
def save
true
end
end
class User
# @return [String]
attr_accessor :name
# @return [Address]
attr_accessor :address
def greet
name
end
end

View file

@ -0,0 +1,7 @@
require_relative 'models'
# @param user [User]
def process_user(user)
# Field-access chain: user.address → Address, then .save → Address#save
user.address.save
end

View file

@ -0,0 +1,20 @@
pub struct Address {
pub city: String,
}
impl Address {
pub fn save(&self) {
// persist address
}
}
pub struct User {
pub name: String,
pub address: Address,
}
impl User {
pub fn greet(&self) -> &str {
&self.name
}
}

View file

@ -0,0 +1,5 @@
use crate::models::{User, Address};
fn process_user(user: &User) {
user.address.save();
}

View file

@ -0,0 +1,25 @@
export class City {
zipCode: string;
getName(): string {
return 'city';
}
}
export class Address {
city: City;
street: string;
save(): void {
// persist address
}
}
export class User {
name: string;
address: Address;
greet(): string {
return this.name;
}
}

View file

@ -0,0 +1,9 @@
import { User } from './models';
function processUser(user: User) {
// 2-level chain: user.address → Address, then .save() → Address#save
user.address.save();
// 3-level chain: user.address → Address, .city → City, .getName() → City#getName
user.address.city.getName();
}

View file

@ -0,0 +1,7 @@
export class Address {
city: string;
save(): void {
// persist address
}
}

View file

@ -0,0 +1,7 @@
import { User } from './user';
function processUser(user: User) {
// Field-access chain: user.address resolves to Address, then .save() must resolve
// to Address#save (NOT User#save) — only lookupFieldByOwner can disambiguate.
user.address.save();
}

View file

@ -0,0 +1,10 @@
import { Address } from './address';
export class User {
name: string;
address: Address;
save(): void {
// persist user
}
}

View file

@ -0,0 +1,27 @@
export class City {
getName(): string {
return 'city';
}
}
export class Address {
city: City;
save(): void {
// persist address
}
}
export class User {
address: Address;
getAddress(): Address {
return this.address;
}
}
export class UserService {
getUser(): User {
return new User();
}
}

View file

@ -0,0 +1,11 @@
import { User, UserService } from './models';
function processWithService(svc: UserService) {
// call → field → call: svc.getUser().address.save()
svc.getUser().address.save();
}
function processWithUser(user: User) {
// field → call → call: user.getAddress().city.getName()
user.getAddress().city.getName();
}

View file

@ -0,0 +1,22 @@
export class Address {
city: string;
save(): void {
// persist address
}
}
export class User {
#secret: string;
constructor(
public name: string,
public address: Address,
) {
this.#secret = 'hidden';
}
greet(): string {
return this.name;
}
}

View file

@ -0,0 +1,5 @@
import { User } from './models';
function processUser(user: User) {
user.address.save();
}

View file

@ -812,3 +812,92 @@ describe('C++ pointer dereference in range-for', () => {
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field/property type resolution (1-level)
// ---------------------------------------------------------------------------
describe('Field type resolution (C++)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']);
});
it('detects Property nodes for C++ data member fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking fields to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(2);
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('Address → city');
});
it('resolves user.address.save() → Address#save via field type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'save');
const addressSave = saveCalls.find(
e => e.source === 'processUser' && e.targetFilePath.includes('models'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8A: Deep field chain resolution (3-level)
// ---------------------------------------------------------------------------
describe('Deep field chain resolution (C++)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-deep-field-chain'),
() => {},
);
}, 60000);
it('detects classes: Address, City, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'City', 'User']);
});
it('detects Property nodes for all typed fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('city');
expect(properties).toContain('zipCode');
});
it('emits HAS_PROPERTY edges for nested type chain', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('Address → city');
expect(edgeSet(propEdges)).toContain('City → zipCode');
});
it('resolves 2-level chain: user.address.save() → Address#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processUser');
const addressSave = saveCalls.find(e => e.targetFilePath.includes('models'));
expect(addressSave).toBeDefined();
});
it('resolves 3-level chain: user.address.city.getName() → City#getName', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCalls = calls.filter(e => e.target === 'getName' && e.source === 'processUser');
const cityGetName = getNameCalls.find(e => e.targetFilePath.includes('models'));
expect(cityGetName).toBeDefined();
});
});

View file

@ -1193,3 +1193,93 @@ describe('C# nested member access foreach (this.data.Values)', () => {
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field/property type resolution (1-level)
// ---------------------------------------------------------------------------
describe('Field type resolution (C#)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'csharp-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, Service, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'Service', 'User']);
});
it('detects Property nodes for C# properties', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('Address');
expect(properties).toContain('Name');
expect(properties).toContain('City');
});
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
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');
const saveCalls = calls.filter(e => e.target === 'Save');
const addressSave = saveCalls.find(
e => e.source === 'ProcessUser' && e.targetFilePath.includes('Models'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8A: Deep field chain resolution (3-level)
// ---------------------------------------------------------------------------
describe('Deep field chain resolution (C#)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'csharp-deep-field-chain'),
() => {},
);
}, 60000);
it('detects classes: Address, City, Service, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'City', 'Service', 'User']);
});
it('detects Property nodes for C# properties', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('Address');
expect(properties).toContain('City');
expect(properties).toContain('ZipCode');
});
it('emits HAS_PROPERTY edges for nested type chain', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(edgeSet(propEdges)).toContain('User → Address');
expect(edgeSet(propEdges)).toContain('Address → City');
expect(edgeSet(propEdges)).toContain('City → ZipCode');
});
it('resolves 2-level chain: user.Address.Save() → Address#Save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'Save' && e.source === 'ProcessUser');
const addressSave = saveCalls.find(e => e.targetFilePath.includes('Models'));
expect(addressSave).toBeDefined();
});
it('resolves 3-level chain: user.Address.City.GetName() → City#GetName', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCalls = calls.filter(e => e.target === 'GetName' && e.source === 'ProcessUser');
const cityGetName = getNameCalls.find(e => e.targetFilePath.includes('Models'));
expect(cityGetName).toBeDefined();
});
});

View file

@ -1,269 +0,0 @@
/**
* Phase 8: Field/property type resolution — verifies that chained member access
* through typed fields resolves correctly (e.g. user.address.save() → Address#save).
*
* Per-language fixtures test:
* 1. Property nodes are extracted with correct ownerId linkage
* 2. HAS_PROPERTY edges link properties to their owning classes
* 3. Field-access chain resolution resolves 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';
// ---------------------------------------------------------------------------
// TypeScript
// ---------------------------------------------------------------------------
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');
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
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');
const saveCalls = calls.filter(e => e.target === 'save');
const addressSave = saveCalls.find(e => e.targetFilePath.includes('models'));
expect(addressSave).toBeDefined();
expect(addressSave!.source).toBe('processUser');
});
});
// ---------------------------------------------------------------------------
// Java
// ---------------------------------------------------------------------------
describe('Field type resolution (Java)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'java-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, App, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'App', 'User']);
});
it('detects Property nodes for Java fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
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');
const saveCalls = calls.filter(e => e.target === 'save');
const addressSave = saveCalls.find(
e => e.source === 'processUser' && e.targetFilePath.includes('Address'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// C#
// ---------------------------------------------------------------------------
describe('Field type resolution (C#)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'csharp-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, Service, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'Service', 'User']);
});
it('detects Property nodes for C# properties', () => {
const properties = getNodesByLabel(result, 'Property');
// C# property_declaration already captured before Phase 8
expect(properties).toContain('Address');
expect(properties).toContain('Name');
expect(properties).toContain('City');
});
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
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');
const saveCalls = calls.filter(e => e.target === 'Save');
const addressSave = saveCalls.find(
e => e.source === 'ProcessUser' && e.targetFilePath.includes('Models'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Go
// ---------------------------------------------------------------------------
describe('Field type resolution (Go)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-field-types'),
() => {},
);
}, 60000);
it('detects structs: Address, User', () => {
expect(getNodesByLabel(result, 'Struct')).toEqual(['Address', 'User']);
});
it('detects Property nodes for Go struct fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('Address');
expect(properties).toContain('Name');
expect(properties).toContain('City');
});
it('emits HAS_PROPERTY edges linking struct fields to structs', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(2);
});
it('resolves user.Address.Save() → Address#Save via field type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'Save');
const addressSave = saveCalls.find(
e => e.source === 'processUser' && e.targetFilePath.includes('models'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Kotlin
// ---------------------------------------------------------------------------
describe('Field type resolution (Kotlin)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']);
});
it('detects Property nodes for Kotlin properties', () => {
const properties = getNodesByLabel(result, 'Property');
// Kotlin property_declaration was already captured pre-Phase 8
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
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');
const saveCalls = calls.filter(e => e.target === 'save');
const addressSave = saveCalls.find(
e => e.source === 'processUser' && e.targetFilePath.includes('Models'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// PHP
// ---------------------------------------------------------------------------
describe('Field type resolution (PHP)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'php-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, Service, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'Service', 'User']);
});
it('detects Property nodes for PHP properties', () => {
const properties = getNodesByLabel(result, 'Property');
// PHP property_declaration was already captured pre-Phase 8
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
});
it('resolves $user->address->save() → Address#save via field type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'save');
const addressSave = saveCalls.find(
e => e.source === 'processUser' && e.targetFilePath.includes('Models'),
);
expect(addressSave).toBeDefined();
});
});

View file

@ -940,3 +940,88 @@ describe('Go for-loop call_expression iterable resolution (Phase 7.3)', () => {
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field/property type resolution (1-level)
// ---------------------------------------------------------------------------
describe('Field type resolution (Go)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-field-types'),
() => {},
);
}, 60000);
it('detects structs: Address, User', () => {
expect(getNodesByLabel(result, 'Struct')).toEqual(['Address', 'User']);
});
it('detects Property nodes for Go struct fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('Address');
expect(properties).toContain('Name');
expect(properties).toContain('City');
});
it('emits HAS_PROPERTY edges linking struct fields to structs', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(2);
});
it('resolves user.Address.Save() → Address#Save via field type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'Save');
const addressSave = saveCalls.find(
e => e.source === 'processUser' && e.targetFilePath.includes('models'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8A: Deep field chain resolution (3-level)
// ---------------------------------------------------------------------------
describe('Deep field chain resolution (Go)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-deep-field-chain'),
() => {},
);
}, 60000);
it('detects structs: Address, City, User', () => {
expect(getNodesByLabel(result, 'Struct')).toEqual(['Address', 'City', 'User']);
});
it('detects Property nodes for Go struct fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('Address');
expect(properties).toContain('City');
expect(properties).toContain('ZipCode');
});
it('emits HAS_PROPERTY edges for nested type chain', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
});
it('resolves 2-level chain: user.Address.Save() → Address#Save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'Save' && e.source === 'processUser');
const addressSave = saveCalls.find(e => e.targetFilePath.includes('models'));
expect(addressSave).toBeDefined();
});
it('resolves 3-level chain: user.Address.City.GetName() → City#GetName', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCalls = calls.filter(e => e.target === 'GetName' && e.source === 'processUser');
const cityGetName = getNameCalls.find(e => e.targetFilePath.includes('models'));
expect(cityGetName).toBeDefined();
});
});

View file

@ -1068,3 +1068,93 @@ describe('Java foreach call_expression iterable resolution (Phase 7.3)', () => {
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field/property type resolution (1-level)
// ---------------------------------------------------------------------------
describe('Field type resolution (Java)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'java-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, App, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'App', 'User']);
});
it('detects Property nodes for Java fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
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');
const saveCalls = calls.filter(e => e.target === 'save');
const addressSave = saveCalls.find(
e => e.source === 'processUser' && e.targetFilePath.includes('Address'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8A: Deep field chain resolution (3-level)
// ---------------------------------------------------------------------------
describe('Deep field chain resolution (Java)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'java-deep-field-chain'),
() => {},
);
}, 60000);
it('detects classes: Address, App, City, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'App', 'City', 'User']);
});
it('detects Property nodes for Java fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('city');
expect(properties).toContain('zipCode');
});
it('emits HAS_PROPERTY edges for nested type chain', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('Address → city');
expect(edgeSet(propEdges)).toContain('City → zipCode');
});
it('resolves 2-level chain: user.address.save() → Address#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processUser');
const addressSave = saveCalls.find(e => e.targetFilePath.includes('Address'));
expect(addressSave).toBeDefined();
});
it('resolves 3-level chain: user.address.city.getName() → City#getName', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCalls = calls.filter(e => e.target === 'getName' && e.source === 'processUser');
const cityGetName = getNameCalls.find(e => e.targetFilePath.includes('City'));
expect(cityGetName).toBeDefined();
});
});

View file

@ -4,7 +4,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES, getRelationships, getNodesByLabel,
FIXTURES, getRelationships, getNodesByLabel, edgeSet,
runPipelineFromRepo, type PipelineResult,
} from './helpers.js';
@ -234,3 +234,37 @@ describe('JavaScript chained method call resolution', () => {
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field/property type resolution — class field_definition capture
// ---------------------------------------------------------------------------
describe('Field type resolution (JavaScript)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'js-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, Config, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'Config', 'User']);
});
it('detects Property nodes for JS class fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking fields to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('User → name');
expect(edgeSet(propEdges)).toContain('Address → city');
});
});

View file

@ -1218,3 +1218,93 @@ describe('Kotlin for-loop call_expression iterable resolution (Phase 7.3)', () =
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field/property type resolution (1-level)
// ---------------------------------------------------------------------------
describe('Field type resolution (Kotlin)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']);
});
it('detects Property nodes for Kotlin properties', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
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');
const saveCalls = calls.filter(e => e.target === 'save');
const addressSave = saveCalls.find(
e => e.source === 'processUser' && e.targetFilePath.includes('Models'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8A: Deep field chain resolution (3-level)
// ---------------------------------------------------------------------------
describe('Deep field chain resolution (Kotlin)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-deep-field-chain'),
() => {},
);
}, 60000);
it('detects classes: Address, City, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'City', 'User']);
});
it('detects Property nodes for Kotlin properties', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('city');
expect(properties).toContain('zipCode');
});
it('emits HAS_PROPERTY edges for nested type chain', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('Address → city');
expect(edgeSet(propEdges)).toContain('City → zipCode');
});
it('resolves 2-level chain: user.address.save() → Address#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processUser');
const addressSave = saveCalls.find(e => e.targetFilePath.includes('Models'));
expect(addressSave).toBeDefined();
});
it('resolves 3-level chain: user.address.city.getName() → City#getName', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCalls = calls.filter(e => e.target === 'getName' && e.source === 'processUser');
const cityGetName = getNameCalls.find(e => e.targetFilePath.includes('Models'));
expect(cityGetName).toBeDefined();
});
});

View file

@ -1161,3 +1161,88 @@ describe('PHP foreach call_expression iterable resolution (Phase 7.3)', () => {
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field/property type resolution (1-level)
// ---------------------------------------------------------------------------
describe('Field type resolution (PHP)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'php-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, Service, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'Service', 'User']);
});
it('detects Property nodes for PHP properties', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
});
it('resolves $user->address->save() → Address#save via field type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'save');
const addressSave = saveCalls.find(
e => e.source === 'processUser' && e.targetFilePath.includes('Models'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8A: Deep field chain resolution (3-level)
// ---------------------------------------------------------------------------
describe('Deep field chain resolution (PHP)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'php-deep-field-chain'),
() => {},
);
}, 60000);
it('detects classes: Address, City, Service, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'City', 'Service', 'User']);
});
it('detects Property nodes for PHP properties', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('city');
expect(properties).toContain('zipCode');
});
it('emits HAS_PROPERTY edges for nested type chain', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
});
it('resolves 2-level chain: $user->address->save() → Address#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processUser');
const addressSave = saveCalls.find(e => e.targetFilePath.includes('Models'));
expect(addressSave).toBeDefined();
});
it('resolves 3-level chain: $user->address->city->getName() → City#getName', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCalls = calls.filter(e => e.target === 'getName' && e.source === 'processUser');
const cityGetName = getNameCalls.find(e => e.targetFilePath.includes('Models'));
expect(cityGetName).toBeDefined();
});
});

View file

@ -1224,3 +1224,37 @@ describe('Python for-loop call_expression iterable resolution (Phase 7.3)', () =
expect(saveCalls.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field/property type resolution — annotated attribute capture
// ---------------------------------------------------------------------------
describe('Field type resolution (Python)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']);
});
it('detects Property nodes for Python annotated attributes', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking attributes to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('User → name');
expect(edgeSet(propEdges)).toContain('Address → city');
});
});

View file

@ -846,3 +846,46 @@ describe('Ruby for-in loop resolution', () => {
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field/property type resolution via YARD @return annotations
// ---------------------------------------------------------------------------
describe('Field type resolution (Ruby)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']);
});
it('detects Property nodes for attr_accessor fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
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 YARD @return [Address]', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'save');
const addressSave = saveCalls.find(
e => e.source === 'process_user' && e.targetFilePath.includes('models'),
);
expect(addressSave).toBeDefined();
});
});

View file

@ -832,10 +832,25 @@ describe('Rust nullable receiver resolution (Option<T>)', () => {
expect(saveFns.length).toBe(2);
});
// Known limitation: user.unwrap().save() chains two method calls. unwrap()
// returns User but TypeEnv doesn't track intermediate return values in chains.
// Disambiguating through .unwrap() requires chained return type inference (Phase 5).
it.todo('resolves user.unwrap().save() to User.save (requires chained call inference)');
it('resolves user.unwrap().save() to User#save via Option<User> unwrapping', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'save' &&
c.source === 'process_entities' &&
c.targetFilePath?.includes('user'),
);
expect(userSave).toBeDefined();
});
it('resolves repo.unwrap().save() to Repo#save via Option<Repo> unwrapping', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(c =>
c.target === 'save' &&
c.source === 'process_entities' &&
c.targetFilePath?.includes('repo'),
);
expect(repoSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
@ -1279,3 +1294,34 @@ describe('Rust for-loop direct call_expression iterable resolution (Phase 7.3)',
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field/property type resolution — struct field capture
// ---------------------------------------------------------------------------
describe('Field type resolution (Rust)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-field-types'),
() => {},
);
}, 60000);
it('detects structs: Address, User', () => {
expect(getNodesByLabel(result, 'Struct')).toEqual(['Address', 'User']);
});
it('detects Property nodes for Rust struct fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking fields to structs', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(2);
});
});

View file

@ -1698,3 +1698,205 @@ describe('TypeScript for-of call_expression iterable resolution (Phase 7.3)', ()
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field/property type resolution (1-level)
// ---------------------------------------------------------------------------
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');
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
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');
const saveCalls = calls.filter(e => e.target === 'save');
const addressSave = saveCalls.find(e => e.targetFilePath.includes('models'));
expect(addressSave).toBeDefined();
expect(addressSave!.source).toBe('processUser');
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field type disambiguation — both User and Address have save()
// ---------------------------------------------------------------------------
describe('Field type disambiguation (TypeScript)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ts-field-type-disambig'),
() => {},
);
}, 60000);
it('detects both User#save and Address#save', () => {
const methods = getNodesByLabel(result, 'Method');
const saveMethods = methods.filter(m => m === 'save');
expect(saveMethods.length).toBe(2);
});
it('resolves user.address.save() → Address#save (not User#save)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(
e => e.target === 'save' && e.source === 'processUser',
);
expect(saveCalls.length).toBe(1);
expect(saveCalls[0].targetFilePath).toContain('address');
expect(saveCalls[0].targetFilePath).not.toContain('user');
});
});
// ---------------------------------------------------------------------------
// Phase 8: Parameter properties and #private fields
// ---------------------------------------------------------------------------
describe('Field type resolution (TS parameter properties)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ts-param-property-fields'),
() => {},
);
}, 60000);
it('detects classes: Address, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']);
});
it('captures constructor parameter properties as Property nodes', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('name');
expect(properties).toContain('address');
});
it('captures #private fields as Property nodes', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('#secret');
});
it('emits HAS_PROPERTY edges for parameter properties', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(edgeSet(propEdges)).toContain('User → name');
expect(edgeSet(propEdges)).toContain('User → address');
});
it('resolves user.address.save() via parameter property type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processUser');
expect(saveCalls.length).toBe(1);
expect(saveCalls[0].targetFilePath).toContain('models');
});
});
// ---------------------------------------------------------------------------
// Phase 8A: Deep field chain resolution (3-level: user.address.city.getName())
// ---------------------------------------------------------------------------
describe('Deep field chain resolution (TypeScript)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ts-deep-field-chain'),
() => {},
);
}, 60000);
it('detects classes: Address, City, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'City', 'User']);
});
it('detects Property nodes for all typed fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('city');
expect(properties).toContain('zipCode');
});
it('emits HAS_PROPERTY edges for nested type chain', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('Address → city');
expect(edgeSet(propEdges)).toContain('City → zipCode');
});
it('resolves 2-level chain: user.address.save() → Address#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processUser');
expect(saveCalls.length).toBe(1);
expect(saveCalls[0].targetFilePath).toContain('models');
});
it('resolves 3-level chain: user.address.city.getName() → City#getName', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCalls = calls.filter(e => e.target === 'getName' && e.source === 'processUser');
expect(getNameCalls.length).toBe(1);
expect(getNameCalls[0].targetFilePath).toContain('models');
});
});
// ---------------------------------------------------------------------------
// Mixed chain resolution (field ↔ call interleaved)
// ---------------------------------------------------------------------------
describe('Mixed field+call chain resolution (TypeScript)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ts-mixed-chain'),
() => {},
);
}, 60000);
it('detects classes: Address, City, User, UserService', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'City', 'User', 'UserService']);
});
it('detects Property node for Address.city field', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('city');
expect(properties).toContain('address');
});
it('resolves call→field chain: svc.getUser().address.save() → Address#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processWithService');
expect(saveCalls.length).toBe(1);
expect(saveCalls[0].targetFilePath).toContain('models');
});
it('resolves field→call chain: user.getAddress().city.getName() → City#getName', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCalls = calls.filter(e => e.target === 'getName' && e.source === 'processWithUser');
expect(getNameCalls.length).toBe(1);
expect(getNameCalls[0].targetFilePath).toContain('models');
});
});

View file

@ -107,32 +107,76 @@ The interface change touched all extractors but remained additive — no existin
---
## Phase 8: Field and Property Type Resolution
## Phase 8: Field and Property Type Resolution *(delivered)*
### Goal
Model class / struct fields so chained member access can be resolved more accurately.
### Status
**Delivered.** One-level, deep, and mixed field+method chain resolution is implemented across 10 languages. Pattern destructuring (8C) remains open.
#### What shipped
- **SymbolTable `fieldByOwner` index** — O(1) lookup via `ownerNodeId\0fieldName` key. Properties excluded from `globalIndex` to prevent namespace pollution. *(Q1 resolved)*
- **`HAS_PROPERTY` edge type** — split from `HAS_METHOD` to distinguish property linkage
- **`declaredType` field** on Property symbols — semantic split from `returnType` (methods)
- **`resolveFieldAccessType`** in call-processor — resolves field access chains at call sites
- **`extractPropertyDeclaredType`** in shared utils — 5-strategy cross-language type extraction
- **Per-language `@definition.property` captures** — see coverage table below
- **`extractMixedChain`** in utils — unified recursive AST walker that handles both `call_expression` and `field_expression` nodes interchangeably, building `MixedChainStep[]` capped at `MAX_CHAIN_DEPTH` (3). Replaces the earlier separate `extractFieldChain` / `extractCallChain` functions.
- **`receiverMixedChain`** on `ExtractedCall` — unified chain representation replacing the old `receiverCallChain` + `receiverFieldAccess` split
- **Unified chain resolution** in call-processor — a single loop in both `processCalls` (sequential) and `processCallsFromExtracted` (worker) walks `MixedChainStep[]`, dispatching `kind: 'field'` to `resolveFieldAccessType` and `kind: 'call'` to `resolveCallTarget` + return type extraction
- **Type-preserving stdlib passthrough** — `unwrap()`, `expect()`, `clone()`, `as_ref()`, and similar stdlib methods that don't change the receiver type are recognized as identity operations in the chain loop, allowing chains like `user.unwrap().save()` to resolve correctly when TypeEnv has already stripped the nullable wrapper
- **C++ `field_declaration`** property capture via `field_identifier` declarator
- **C++ `field_expression` support** — tree-sitter-cpp uses `argument` (not `object`) for the receiver of `field_expression`; `extractMixedChain` handles this
- **C++ inline method double-indexing guard** — prevents `@definition.function` from creating duplicate symbol entries for methods already captured by `@definition.method` inside class/struct bodies (applied in both `parsing-processor.ts` and `parse-worker.ts`)
- **Rust unit struct instantiation** — `let svc = UserService;` (bare identifier assignment) now recognized by type-env when the RHS matches a known class/struct name
- **Ruby YARD `@return [Type]`** extraction for `attr_accessor` properties, enabling field-type resolution in dynamically typed Ruby
#### Language coverage
| Language | Property capture | `declaredType` extraction | Deep chain | Notes |
|----------|-----------------|--------------------------|:----------:|-------|
| TypeScript | ✅ `public_field_definition`, `private_property_identifier`, `required_parameter` | ✅ Strategy 2 (type_annotation) | ✅ | Parameter properties added |
| JavaScript | ✅ `field_definition` | ⚠️ No type annotations in JS | — | Capture added; declaredType requires JSDoc |
| Java | ✅ `field_declaration` | ✅ Strategy 3 (parent type) | ✅ | |
| C# | ✅ `property_declaration` | ✅ Strategy 1 (type field) | ✅ | |
| Go | ✅ `field_declaration` | ✅ Strategy 1 (type field) | ✅ | |
| Kotlin | ✅ `property_declaration` | ✅ Strategy 4 (variable_declaration) | ✅ | New strategy added |
| PHP | ✅ `property_declaration` | ✅ Strategy 1 + PHPDoc @var fallback | ✅ | Strategy 5 for pre-7.4 |
| Rust | ✅ `field_declaration` | ✅ Strategy 1 (type field) | — | Capture only (no field-access call resolution) |
| Python | ✅ `assignment` with `type` | ⚠️ Class-level only | — | `self.x` pattern needs work |
| Ruby | ✅ `attr_*` via call routing | ✅ YARD `@return [Type]` | — | YARD fallback for dynamically typed properties |
| C++ | ✅ `field_declaration` via `field_identifier` | ✅ Strategy 1 (type field) | ✅ | |
| Swift | ✅ `property_declaration` | ⚠️ Untested | — | |
#### What remains open
- **8C. Pattern destructuring** dependent on field knowledge
- Python `self.x` instance attribute pattern
### Problems this phase addresses
#### 8A. Deep property chains
#### 8A. Deep property chains *(delivered)*
```typescript
user.address.city
user.address.city.getName()
```
Today the system may resolve `user -> User`, but it cannot generally resolve:
✅ `extractFieldChain` recursively walks nested member_expression nodes at parse time, building a `fieldChain: string[]`. At resolution time, the chain is walked step-by-step: `user → User`, `address → Address`, `city → City`, `getName() → City#getName`. Supported across TS, Java, C#, Go, Kotlin, PHP, C++.
- `address -> Address`
- `city -> City` or scalar type
#### 8B. Chained method targets through field access
#### 8B. Mixed field+method chain resolution *(delivered)*
```typescript
user.address.save()
svc.getUser().address.save() // call → field → call
user.getAddress().city.getName() // call → field → call
user.address.getCity().save() // field → call → call
user.unwrap().save() // stdlib passthrough → call
```
Without field typing, the resolver cannot reliably identify the receiver type of `address`.
✅ `extractMixedChain` walks both call-expression and field-expression nodes in a single unified pass, producing `MixedChainStep[]`. The resolver walks steps left-to-right: `kind: 'field'` resolves via `resolveFieldAccessType`, `kind: 'call'` resolves via `resolveCallTarget` + return type extraction. Stdlib passthroughs (`unwrap`, `clone`, `expect`, etc.) are recognized as type-preserving identity operations.
#### 8C. Pattern destructuring that depends on field knowledge
@ -142,32 +186,42 @@ This is especially relevant for:
- PHP chained property access
- richer TypeScript or Python object-based destructuring in future work
### Engineering direction
### Engineering direction (as implemented)
- parse field / property declarations per class or struct
- build a field-type map keyed by owning type
- teach lookup and chain-resolution logic to walk member segments
- ~~parse field / property declarations per class or struct~~ ✅
- ~~build a field-type map keyed by owning type~~ ✅ (`fieldByOwner` index)
- ~~teach lookup and chain-resolution logic to walk member segments (deep chains)~~ ✅ (`extractMixedChain` + unified chain-walking loop)
- ~~unify field chains and call chains into a single representation~~ ✅ (`MixedChainStep[]` replaces separate `receiverCallChain` / `receiverFieldAccess`)
- ~~C++ struct member field capture~~ ✅ (`field_declaration` via `field_identifier`)
- ~~C++ `field_expression` receiver extraction~~ ✅ (`argument` field support in `extractMixedChain`)
- ~~Rust unit struct instantiation~~ ✅ (`let svc = TypeName;` recognized by type-env)
- ~~Ruby YARD `@return` for `attr_accessor`~~ ✅ (comment-walking in `call-routing.ts`)
- ~~stdlib passthrough methods~~ ✅ (`TYPE_PRESERVING_METHODS` set in call-processor)
- keep this separate from the base variable-binding layer where possible
### Expected impact
### Delivered impact
This is the biggest unlock for richer static analysis because it allows the graph to model more than just top-level receivers.
It would materially improve:
It materially improved:
- chained property resolution
- member-based call disambiguation
- chained property resolution (up to 3 levels deep)
- mixed field+method chain resolution (e.g. `svc.getUser().address.save()`)
- member-based call disambiguation across 10 languages
- deeper context extraction for downstream tooling
- C++ struct/class field visibility in the knowledge graph
- C++ chained method call resolution (previously blocked by missing `argument` field support)
- Rust nullable receiver chains (`user.unwrap().save()`)
- Ruby field-type resolution via YARD documentation
### Risk level
**High**
**High** (delivered — risk was managed through incremental delivery across 8, 8A, 8B)
This is the first phase that pushes the system from variable typing into structural object modelling. It will likely require:
This phase pushed the system from variable typing into structural object modelling. Remaining work:
- schema expansion or new internal maps
- careful handling of inheritance / embedding / language-specific member semantics
- broader test coverage than earlier phases
- pattern destructuring dependent on field knowledge (8C)
---
@ -282,44 +336,33 @@ Key remaining gap:
Shared missing capabilities:
- field / property type resolution
- generalised return-type-aware binding in `TypeEnv`
- ~~field / property type resolution~~ ✓ shipped in Phase 8 + 8A (10 languages)
- ~~mixed field+method chain resolution~~ ✓ shipped in Phase 8B (unified `MixedChainStep[]`)
- generalised return-type-aware binding in `TypeEnv` (Phase 9)
**Priority:** Very High
**Reason:** These are the biggest remaining blockers to deeper static analysis.
**Priority:** High
**Reason:** Return-type propagation is the biggest remaining blocker to deeper static analysis.
---
## Recommended Delivery Order
### 1. Generalise existing return and loop inference
### ~~1. Generalise existing return and loop inference~~ ✅ Phase 7
This is the best cost-to-value step.
Delivered. Iterable call-expression support, `ReturnTypeLookup`, file-scope binding, PHP Strategy C.
Deliverables:
### ~~2. Add field / property type maps~~ ✅ Phase 8 + 8A + 8B
- iterable call-expression support
- wider access to return-type maps
- file-scope binding visibility where needed
Delivered. Per-type field metadata, deep chain resolution (up to 3 levels), mixed field+method chains, type-preserving stdlib passthrough, C++ and Rust fixes.
### 2. Add field / property type maps
This unlocks the next class of analysis depth.
Deliverables:
- per-type field metadata
- chained property resolution
- better destructuring support
### 3. Promote return types into first-class `TypeEnv` inputs
### 3. Promote return types into first-class `TypeEnv` inputs ← **next**
This converts existing downstream validation into a broader inference capability.
Deliverables:
- call-result variable binding
- loop inference from call results
- call-result variable binding (`var x = f()` propagation)
- loop inference from call results (already done for direct iterables, pending for assigned results)
- broader chain propagation
### 4. Broaden branch-sensitive narrowing where low-risk
@ -352,23 +395,25 @@ That would be sufficient for:
## Suggested Milestone Definitions
### Milestone A — Inference Expansion
### Milestone A — Inference Expansion ✅
Success looks like:
Delivered in Phase 7.
- loop inference works for identifier iterables and common call-expression iterables
- simple call-result assignments benefit from return types more broadly
- no major regression in ambiguity handling
- loop inference works for identifier iterables and common call-expression iterables across 7 languages
- `ReturnTypeLookup` threads return-type knowledge into TypeEnv
- PHP class-level `@var` property typing for `$this->property` foreach
### Milestone B — Structural Member Typing
### Milestone B — Structural Member Typing ✅
Success looks like:
Delivered in Phase 8 + 8A + 8B.
- field/property maps exist for class-like types
- chained access can resolve at least one segment beyond the base receiver
- field-aware member-call resolution works in the most important languages
- field/property maps exist for class-like types across 10 languages
- deep chains resolve up to 3 levels (`user.address.city.getName()`)
- mixed field+method chains resolve interleaved patterns (`svc.getUser().address.save()`)
- stdlib passthroughs (`unwrap`, `clone`, etc.) are type-preserving in chains
- C++ and Rust chain call resolution fixed (field_expression argument, unit struct)
### Milestone C — Static-Analysis Foundation
### Milestone C — Static-Analysis Foundation ← **next**
Success looks like:
@ -382,8 +427,8 @@ Success looks like:
These should be resolved before or during implementation of the later phases.
1. **Where should field-type metadata live?**
In `TypeEnv`, in `SymbolTable`, or in a dedicated side structure?
1. **Where should field-type metadata live?**
✅ Resolved: in `SymbolTable` via the `fieldByOwner` index, keyed by `ownerNodeId\0fieldName`. Properties live alongside other symbols but are excluded from `globalIndex` to prevent namespace pollution.
2. **How should ambiguity be represented?**
Is `undefined` sufficient, or do later phases need a richer "known ambiguous" state?
@ -394,19 +439,23 @@ These should be resolved before or during implementation of the later phases.
4. **How much branch sensitivity is worth the complexity?**
Some narrowing gives clear value; full control-flow typing likely does not.
5. **Should field typing and chain typing be one phase or two?**
Keeping them separate may reduce risk and make regressions easier to isolate.
5. **Should field typing and chain typing be one phase or two?**
✅ Resolved: delivered as Phase 8 (single-level) + Phase 8A (deep chains) in the same branch, with separate test suites per language. Incremental delivery within one phase worked well.
---
## Summary
The next stage of the type system should focus on **generalising what already works** before attempting compiler-like sophistication.
Phases 7 and 8 (including 8A and 8B) are **complete**. The type system now handles:
The most important path is:
- ✅ explicit type annotations and parameters across 13 languages
- ✅ initializer/constructor inference with SymbolTable validation
- ✅ loop element inference including call-expression iterables (7 languages)
- ✅ field/property type resolution with deep chains (up to 3 levels, 10 languages)
- ✅ mixed field+method chains (`svc.getUser().address.save()`)
- ✅ type-preserving stdlib passthroughs (`unwrap`, `clone`, `expect`, etc.)
- ✅ comment-based types (JSDoc, PHPDoc, YARD)
1. extend return-type and iterable inference
2. add field/property type knowledge
3. promote return-type-aware inference into `TypeEnv`
**The next step is Phase 9**: promote return-type-aware inference into `TypeEnv` as a first-class input, enabling `var x = f()` variable binding and broader chain propagation. The `pendingCallResults` infrastructure is already in place (Tier 2b loop + `PendingAssignment` union) — it just needs extractors to emit `{ kind: 'callResult' }` entries.
That path preserves the current strengths of the system while moving GitNexus materially closer to a robust, production-grade static-analysis foundation.
That path preserves the current strengths of the system while moving GitNexus the final step toward a robust, production-grade static-analysis foundation.

View file

@ -122,7 +122,6 @@ It does not:
- perform full semantic type checking
- run fixpoint inference
- propagate inferred bindings across files as ordinary environment entries
- model deep field/property chains such as `user.address.city`
- guarantee resolution for every ambiguous construct
---
@ -375,22 +374,28 @@ So return-type-aware receiver inference already exists in a constrained downstre
| For-loop element types | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes |
| Pattern binding | Yes | Yes | Yes | Yes | No | Yes | Yes | No | No | No | No |
| Assignment chains | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes |
| Field/property type resolution | Yes | Yes | Yes | Yes | Yes | Yes | No* | Yes | YARD | No | Yes |
| Comment-based types | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No |
| Return type extraction | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No |
\* Python has a type annotation query for fields but no `declaredType` extraction for the `self.x` pattern yet.
---
## Current Strengths
The current system already provides strong value for call resolution because it combines:
The current system provides strong value for call resolution because it combines:
- explicit annotation extraction
- generic-aware loop element typing
- initializer-based inference
- explicit annotation extraction across 13 languages
- generic-aware loop element typing (including call-expression iterables)
- initializer-based inference with SymbolTable validation
- selected pattern-based narrowing
- scope-aware lookups
- comment-based fallbacks for dynamic ecosystems
- comment-based fallbacks for dynamic ecosystems (JSDoc, PHPDoc, YARD)
- constrained return-type-aware receiver inference in call processing
- deep field/property chains up to 3 levels across 10 languages
- mixed field+method chain resolution (e.g. `svc.getUser().address.save()`)
- type-preserving stdlib passthrough for `unwrap()`, `clone()`, `expect()`, etc.
This is enough to materially improve call-edge precision even without implementing a full static type system.
@ -400,7 +405,6 @@ This is enough to materially improve call-edge precision even without implementi
Important gaps still remain:
- no field / property type map for deep chains such as `user.address.city`
- no general cross-file propagation of inferred bindings
- no fixpoint inference
- limited branch-sensitive narrowing outside selected pattern constructs