feat: Phase 8 field/property type resolution (#354)

* feat: Phase 8 field/property type resolution — resolve chained member access

Add field/property type extraction to the type resolution system so that
chained member access like `user.address.save()` resolves the intermediate
receiver type (`address → Address`) through Property symbols in SymbolTable.

Key changes:
- SymbolTable: add `declaredType` field, `fieldByOwner` O(1) index,
  `lookupFieldByOwner()` method, P0 conditional callableIndex invalidation,
  P2 exclude Properties from globalIndex to prevent namespace pollution
- tree-sitter queries: add `definition.property` for TypeScript, Java, Go
- parse-worker: extract declared types for Property nodes via
  `extractPropertyDeclaredType()`, capture field-access receiver info
- call-processor: add `resolveFieldAccessType()` helper and field-access
  branch in both sequential and worker receiver resolution paths
- Integration tests: new field-types test suite verifying end-to-end
  `user.address.save() → Address#save` resolution

* fix: Go tree-sitter query captures field_declaration not field_declaration_list

Post-review fix: the Go struct field query incorrectly put @definition.property
on field_declaration_list (the list container) instead of field_declaration
(the individual field). Also removed unused `language` parameter from
extractPropertyDeclaredType.

* feat: expand field-type tests to 6 languages, fix Go ownerId and Kotlin navigation_expression

- Add integration test fixtures for Java, C#, Go, Kotlin, PHP (alongside existing TS)
- Fix Go: add type_declaration handling in findEnclosingClassId for struct fields
  (field_declaration → field_declaration_list → struct_type → type_spec → type_declaration)
- Fix Kotlin: add navigation_expression handling in field-access resolution
  (Kotlin uses navigation_expression + navigation_suffix, not member_expression)
- Add extractMemberAccessParts helper in call-processor for cross-language member access
- All 24 field-type tests pass across 6 languages, 181 Go+Kotlin tests pass with no regressions

* refactor: split HAS_METHOD into HAS_METHOD + HAS_PROPERTY edge types

Property nodes now use HAS_PROPERTY edges instead of HAS_METHOD, giving
the graph schema proper semantic separation between methods and fields.

- HAS_METHOD: Method, Constructor, Function (when inside a class)
- HAS_PROPERTY: Property nodes (class fields, struct fields, attributes)

MRO processor only reads HAS_METHOD — properties correctly excluded from
method resolution order. Impact analysis accepts both edge types.

Updated 12 files: graph types, schema, tools docs, parse-worker,
parsing-processor, call-processor, and 6 test files.

* fix(test): update security test to expect 7 VALID_RELATION_TYPES (added HAS_PROPERTY)

* test: add unit tests for Phase 8 SymbolTable features (39 tests, up from 19)

Cover all new branches: declaredType metadata, Property exclusion from
globalIndex, conditional callableIndex invalidation, lookupFieldByOwner
(happy path + edge cases), lookupFuzzyCallable filtering, and clear()
with fieldByOwner. Fixes branch coverage threshold (21.8% → 23%+).

* 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

* fix: Python declaredType extraction and sequential-path property registration

- Move @definition.property capture from expression_statement to assignment
  node in Python queries so Strategy 1 childForFieldName('type') succeeds
- Pass item.declaredType through ctx.symbols.add in sequential call-processor
  path, matching worker path behavior (fixes Ruby YARD declaredType drop)
- Add Python chain resolution integration test (user.address.save → Address#save)
- Update Rust/Python status in roadmap and system docs to reflect actual coverage

* fix: Python/Ruby field type disambiguation and Rust chain test

Three fixes from PR #354 third review:

1. Python typed_parameter name extraction: tree-sitter-python's
   typed_parameter uses positional children for the name, not a named
   field. TypeEnv and extractParameter now fall back to firstNamedChild.

2. Ruby/Python call-step field resolution: Ruby's AST uses `call` nodes
   for both property access and method calls. The chain walker now tries
   resolveFieldAccessType before resolveCallTarget for call steps, so
   attr_accessor properties resolve via declaredType.

3. Rust chain resolution test: added missing integration test asserting
   user.address.save() resolves to Address#save.

Also splits C/C++ and TS/JS columns in type-resolution-system.md
language matrix with footnotes for accuracy.

1062 resolver integration tests passing, 0 failures.

* refactor: Phase 8 code review cleanup — extract walkMixedChain, fix MCP agent gaps

- Extract duplicated chain resolution loop into shared walkMixedChain() helper,
  eliminating ~60 lines of copy-pasted code between sequential and worker paths
- Add returnType to ResolveResult, removing redundant lookupFuzzy+find per chain step
- Fix context() tool to include HAS_METHOD, HAS_PROPERTY, OVERRIDES in queries
  so agents can discover class members
- Fix p.declaredType Cypher example (column doesn't exist) → p.description
- Add HAS_METHOD, HAS_PROPERTY, OVERRIDES to schema resource
- Document HAS_METHOD/HAS_PROPERTY in impact tool description
- Delete dead code extractMemberAccessParts (superseded by extractMixedChain)
- Replace any with SyntaxNode on extractPropertyDeclaredType
- Add Rust deep-field-chain test (5 tests), Java mixed-chain (4), Go mixed-chain (4)
- All 1075 tests pass (13 new, 0 regressions)

* refactor: type SymbolDefinition.type as NodeLabel, add O(1) receiver index

- Change SymbolDefinition.type from string to NodeLabel union (35 members)
  across symbol-table.ts, parse-worker.ts, parsing-processor.ts — compiler
  now enforces correctness at all comparison/assignment sites
- Replace O(N*M) linear scan in lookupReceiverType with pre-built
  ReceiverTypeIndex (Map<funcName, Map<varName, Entry>>) for O(1) lookups
  with proper ambiguity handling and file-level fallback
- All 1075 tests pass, 0 regressions

* fix: capture C++ pointer/ref fields, Kotlin data class props, PHP constructor promotion

Add tree-sitter query patterns for three previously missed property declaration
forms: C++ pointer/reference member fields (Address* addr; Address& ref;),
Kotlin primary constructor val/var parameters (data class User(val name: String)),
and PHP 8.0+ constructor property promotion (public Address $address).

Fix "10 languages" off-by-one in docs (Ruby is single-level only, not deep chain).
Update Python feature matrix cell from No* to Yes* after 31b95f0 fix.

11 new integration tests with per-language fixtures verify property capture,
HAS_PROPERTY edge emission, and field-access chain resolution.
This commit is contained in:
Gergő Magyar 2026-03-18 18:47:33 +00:00 committed by GitHub
parent e0a6c40b45
commit 11a3d0515c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
104 changed files with 3147 additions and 290 deletions

View file

@ -80,6 +80,7 @@ export type RelationshipType =
| 'IMPLEMENTS'
| 'EXTENDS'
| 'HAS_METHOD'
| 'HAS_PROPERTY'
| 'MEMBER_OF'
| 'STEP_IN_PROCESS'

View file

@ -20,15 +20,25 @@ import {
extractReceiverNode,
findEnclosingClassId,
CALL_EXPRESSION_TYPES,
MAX_CHAIN_DEPTH,
extractCallChain,
extractMixedChain,
type MixedChainStep,
} 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.
@ -178,6 +188,7 @@ export const processCalls = async (
const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0
? verifyConstructorBindings(typeEnv.constructorBindings, file.path, ctx)
: new Map<string, string>();
const receiverIndex = buildReceiverTypeIndex(verifiedReceivers);
ctx.enableCache(file.path);
@ -225,8 +236,10 @@ export const processCalls = async (
description: item.accessorType,
},
});
ctx.symbols.add(file.path, item.propName, nodeId, 'Property',
propEnclosingClassId ? { ownerId: propEnclosingClassId } : undefined);
ctx.symbols.add(file.path, item.propName, nodeId, 'Property', {
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
...(item.declaredType ? { declaredType: item.declaredType } : {}),
});
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
graph.addRelationship({
id: relId, sourceId: fileId, targetId: nodeId,
@ -234,9 +247,9 @@ export const processCalls = async (
});
if (propEnclosingClassId) {
graph.addRelationship({
id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
sourceId: propEnclosingClassId, targetId: nodeId,
type: 'HAS_METHOD', confidence: 1.0, reason: '',
type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
});
}
}
@ -255,10 +268,10 @@ export const processCalls = async (
const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined;
let receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined;
// Fall back to verified constructor bindings for return type inference
if (!receiverTypeName && receiverName && verifiedReceivers.size > 0) {
if (!receiverTypeName && receiverName && receiverIndex.size > 0) {
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
receiverTypeName = lookupReceiverType(verifiedReceivers, funcName, receiverName);
receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverName);
}
// Fall back to class-as-receiver for static method calls (e.g. UserService.find_user()).
// When the receiver name is not a variable in TypeEnv but resolves to a Class/Struct/Interface
@ -271,32 +284,33 @@ export const processCalls = async (
receiverTypeName = receiverName;
}
}
// 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).
// 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 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 && receiverIndex.size > 0) {
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
baseType = lookupReceiverType(verifiedReceivers, funcName, extracted.baseReceiverName);
currentType = lookupReceiverType(receiverIndex, 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) {
receiverTypeName = walkMixedChain(extracted.chain, currentType, file.path, ctx);
}
}
}
}
@ -345,6 +359,7 @@ interface ResolveResult {
nodeId: string;
confidence: number;
reason: string;
returnType?: string;
}
const CALLABLE_SYMBOL_TYPES = new Set([
@ -394,48 +409,9 @@ const toResolveResult = (
nodeId: definition.nodeId,
confidence: TIER_CONFIDENCE[tier],
reason: tier === 'same-file' ? 'same-file' : tier === 'import-scoped' ? 'import-resolved' : 'global',
returnType: definition.returnType,
});
/**
* 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:
@ -529,48 +505,139 @@ const receiverKey = (scope: string, varName: string): string =>
`${scope}\0${varName}`;
/**
* Look up a receiver type from a verified receiver map.
* The map is keyed by `scope\0varName` (full scope with @startIndex).
* Since the lookup side only has `funcName` (no startIndex), we scan for
* all entries whose key starts with `funcName@` and has the matching varName.
* If exactly one unique type is found, return it. If multiple distinct types
* exist (true overload collision), return undefined (refuse to guess).
* Falls back to the file-level scope key `\0varName` (empty funcName).
* Pre-built secondary index for O(1) receiver type lookups.
* Built once per file from the verified receiver map, keyed by funcName varName.
*/
type ReceiverTypeEntry =
| { readonly kind: 'resolved'; readonly value: string }
| { readonly kind: 'ambiguous' };
type ReceiverTypeIndex = Map<string, Map<string, ReceiverTypeEntry>>;
/**
* Build a two-level secondary index from the verified receiver map.
* The verified map is keyed by `scope\0varName` where scope is either
* "funcName@startIndex" (inside a function) or "" (file level).
* Index structure: Map<funcName, Map<varName, ReceiverTypeEntry>>
*/
const buildReceiverTypeIndex = (map: Map<string, string>): ReceiverTypeIndex => {
const index: ReceiverTypeIndex = new Map();
for (const [key, typeName] of map) {
const nul = key.indexOf('\0');
if (nul < 0) continue;
const scope = key.slice(0, nul);
const varName = key.slice(nul + 1);
if (!varName) continue;
if (scope !== '' && !scope.includes('@')) continue;
const funcName = scope === '' ? '' : scope.slice(0, scope.indexOf('@'));
let varMap = index.get(funcName);
if (!varMap) { varMap = new Map(); index.set(funcName, varMap); }
const existing = varMap.get(varName);
if (existing === undefined) {
varMap.set(varName, { kind: 'resolved', value: typeName });
} else if (existing.kind === 'resolved' && existing.value !== typeName) {
varMap.set(varName, { kind: 'ambiguous' });
}
}
return index;
};
/**
* O(1) receiver type lookup using the pre-built secondary index.
* Returns the unique type name if unambiguous. Falls back to file-level scope.
*/
const lookupReceiverType = (
map: Map<string, string>,
index: ReceiverTypeIndex,
funcName: string,
varName: string,
): string | undefined => {
// Fast path: file-level scope (empty funcName — used as fallback)
const fileLevelKey = receiverKey('', varName);
const prefix = `${funcName}@`;
const suffix = `\0${varName}`;
let found: string | undefined;
let ambiguous = false;
for (const [key, value] of map) {
if (key === fileLevelKey) continue; // handled separately below
if (key.startsWith(prefix) && key.endsWith(suffix)) {
// Verify the key is exactly "funcName@<digits>\0varName" with no extra chars.
// The part between prefix and suffix should be the startIndex (digits only),
// but we accept any non-empty segment to be forward-compatible.
const middle = key.slice(prefix.length, key.length - suffix.length);
if (middle.length === 0) continue; // malformed key — skip
if (found === undefined) {
found = value;
} else if (found !== value) {
ambiguous = true;
break;
}
const funcBucket = index.get(funcName);
if (funcBucket) {
const entry = funcBucket.get(varName);
if (entry?.kind === 'resolved') return entry.value;
if (entry?.kind === 'ambiguous') {
// Ambiguous in this function scope — try file-level fallback
const fileEntry = index.get('')?.get(varName);
return fileEntry?.kind === 'resolved' ? fileEntry.value : undefined;
}
}
// Fallback: file-level scope (funcName "")
if (funcName !== '') {
const fileEntry = index.get('')?.get(varName);
if (fileEntry?.kind === 'resolved') return fileEntry.value;
}
return undefined;
};
if (!ambiguous && found !== undefined) return found;
const resolveFieldAccessType = (
receiverName: string,
fieldName: string,
filePath: string,
ctx: ResolutionContext,
): string | undefined => {
// Resolve the receiver's type to a class/struct nodeId
const typeResolved = ctx.resolve(receiverName, filePath);
if (!typeResolved) return undefined;
const classDef = typeResolved.candidates.find(
d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl',
);
if (!classDef) return undefined;
// Fallback: file-level scope (bindings outside any function)
return map.get(fileLevelKey);
const fieldDef = ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName);
if (!fieldDef?.declaredType) return undefined;
// 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);
};
/**
* Walk a pre-built mixed chain of field/call steps, threading the current type
* through each step and returning the final resolved type.
*
* Returns `undefined` if any step cannot be resolved (chain is broken).
* The caller is responsible for seeding `startType` from its own context
* (TypeEnv, constructor bindings, or static-class fallback).
*/
const walkMixedChain = (
chain: MixedChainStep[],
startType: string,
filePath: string,
ctx: ResolutionContext,
): string | undefined => {
let currentType: string | undefined = startType;
for (const step of chain) {
if (!currentType) break;
if (step.kind === 'field') {
currentType = resolveFieldAccessType(currentType, step.name, filePath, ctx);
} else {
// Ruby/Python: property access is syntactically identical to method calls.
// Try field resolution first — if the name is a known property with declaredType,
// use that type directly. Otherwise fall back to method call resolution.
const fieldType = resolveFieldAccessType(currentType, step.name, filePath, ctx);
if (fieldType) {
currentType = fieldType;
continue;
}
const resolved = resolveCallTarget(
{ calledName: step.name, callForm: 'member', receiverTypeName: currentType },
filePath,
ctx,
);
if (!resolved) {
// Stdlib passthrough: unwrap(), clone(), etc. preserve the receiver type
if (TYPE_PRESERVING_METHODS.has(step.name)) continue;
currentType = undefined; break;
}
if (!resolved.returnType) { currentType = undefined; break; }
const retType = extractReturnTypeName(resolved.returnType);
if (!retType) { currentType = undefined; break; }
currentType = retType;
}
}
return currentType;
};
/**
@ -587,12 +654,12 @@ export const processCallsFromExtracted = async (
// Scope-aware receiver types: keyed by filePath → "funcName\0varName" → typeName.
// The scope dimension prevents collisions when two functions in the same file
// have same-named locals pointing to different constructor types.
const fileReceiverTypes = new Map<string, Map<string, string>>();
const fileReceiverTypes = new Map<string, ReceiverTypeIndex>();
if (constructorBindings) {
for (const { filePath, bindings } of constructorBindings) {
const verified = verifyConstructorBindings(bindings, filePath, ctx, graph);
if (verified.size > 0) {
fileReceiverTypes.set(filePath, verified);
fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified));
}
}
}
@ -638,29 +705,31 @@ export const processCallsFromExtracted = async (
}
}
// 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) {
// 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);
baseType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
currentType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
}
const chainedType = resolveChainedReceiver(
effectiveCall.receiverCallChain,
baseType,
effectiveCall.filePath,
ctx,
);
if (chainedType) {
effectiveCall = { ...effectiveCall, receiverTypeName: chainedType };
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',
)) {
currentType = effectiveCall.receiverName;
}
}
if (currentType) {
const walkedType = walkMixedChain(
effectiveCall.receiverMixedChain, currentType, effectiveCall.filePath, ctx,
);
if (walkedType) {
effectiveCall = { ...effectiveCall, receiverTypeName: walkedType };
}
}
}

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

@ -1,14 +1,16 @@
import { KnowledgeGraph, GraphNode, GraphRelationship } from '../graph/types.js';
import { KnowledgeGraph, GraphNode, GraphRelationship, type NodeLabel } from '../graph/types.js';
import Parser from 'tree-sitter';
import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js';
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 { 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';
@ -81,6 +83,7 @@ const processParsingWithWorkers = async (
symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, {
parameterCount: sym.parameterCount,
returnType: sym.returnType,
declaredType: sym.declaredType,
ownerId: sym.ownerId,
});
}
@ -198,9 +201,24 @@ const processParsingSequential = async (
if (!nameNode && !captureMap['definition.constructor']) return;
const nodeName = nameNode ? nameNode.text : 'init';
let nodeLabel = 'CodeElement';
let nodeLabel: 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';
@ -275,9 +293,15 @@ const processParsingSequential = async (
const needsOwner = nodeLabel === 'Method' || nodeLabel === 'Constructor' || nodeLabel === 'Property' || nodeLabel === 'Function';
const enclosingClassId = needsOwner ? findEnclosingClassId(nameNode || definitionNodeForRange, file.path) : null;
// Extract declared type for Property nodes (field/property type annotations)
const declaredType = (nodeLabel === 'Property' && definitionNode)
? extractPropertyDeclaredType(definitionNode)
: undefined;
symbolTable.add(file.path, nodeName, nodeId, nodeLabel, {
parameterCount: methodSig?.parameterCount,
returnType: methodSig?.returnType,
declaredType,
ownerId: enclosingClassId ?? undefined,
});
@ -296,13 +320,14 @@ const processParsingSequential = async (
graph.addRelationship(relationship);
// ── HAS_METHOD: link method/constructor/property to enclosing class ──
// ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ──
if (enclosingClassId) {
const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD';
graph.addRelationship({
id: generateId('HAS_METHOD', `${enclosingClassId}->${nodeId}`),
id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`),
sourceId: enclosingClassId,
targetId: nodeId,
type: 'HAS_METHOD',
type: memberEdgeType,
confidence: 1.0,
reason: '',
});

View file

@ -1,11 +1,15 @@
import type { NodeLabel } from '../graph/types.js';
export interface SymbolDefinition {
nodeId: string;
filePath: string;
type: string; // 'Function', 'Class', etc.
type: NodeLabel;
parameterCount?: number;
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
returnType?: string;
/** Links Method/Constructor to owning Class/Struct/Trait nodeId */
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
declaredType?: string;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
}
@ -17,8 +21,8 @@ export interface SymbolTable {
filePath: string,
name: string,
nodeId: string,
type: string,
metadata?: { parameterCount?: number; returnType?: string; ownerId?: string }
type: NodeLabel,
metadata?: { parameterCount?: number; returnType?: string; declaredType?: string; ownerId?: string }
) => void;
/**
@ -45,7 +49,14 @@ export interface SymbolTable {
* Used by ReturnTypeLookup to resolve callee return type.
*/
lookupFuzzyCallable: (name: string) => SymbolDefinition[];
/**
* Look up a field/property by its owning class nodeId and field name.
* O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0fieldName`.
* Returns undefined when no matching property exists or the owner is ambiguous.
*/
lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => SymbolDefinition | undefined;
/**
* Debugging: See how many symbols are tracked
*/
@ -71,14 +82,18 @@ export const createSymbolTable = (): SymbolTable => {
// Only Function, Method, Constructor symbols are indexed.
let callableIndex: Map<string, SymbolDefinition[]> | null = null;
// 4. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName".
// Only Property symbols with ownerId and declaredType are indexed.
const fieldByOwner = new Map<string, SymbolDefinition>();
const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']);
const add = (
filePath: string,
name: string,
nodeId: string,
type: string,
metadata?: { parameterCount?: number; returnType?: string; ownerId?: string }
type: NodeLabel,
metadata?: { parameterCount?: number; returnType?: string; declaredType?: string; ownerId?: string }
) => {
const def: SymbolDefinition = {
nodeId,
@ -86,6 +101,7 @@ export const createSymbolTable = (): SymbolTable => {
type,
...(metadata?.parameterCount !== undefined ? { parameterCount: metadata.parameterCount } : {}),
...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}),
...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}),
...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}),
};
@ -95,14 +111,26 @@ export const createSymbolTable = (): SymbolTable => {
}
fileIndex.get(filePath)!.set(name, def);
// B. Add to Global Index (same object reference)
// B. Properties go to fieldByOwner index only — skip globalIndex to prevent
// namespace pollution for common names like 'id', 'name', 'type'.
if (type === 'Property' && metadata?.ownerId) {
if (metadata?.declaredType) {
fieldByOwner.set(`${metadata.ownerId}\0${name}`, def);
}
// Still add to fileIndex above (for lookupExact), but skip globalIndex
return;
}
// C. Add to Global Index (same object reference)
if (!globalIndex.has(name)) {
globalIndex.set(name, []);
}
globalIndex.get(name)!.push(def);
// Invalidate the lazy callable index — it will be rebuilt on next use
callableIndex = null;
// D. Invalidate the lazy callable index only when adding callable types
if (CALLABLE_TYPES.has(type)) {
callableIndex = null;
}
};
const lookupExact = (filePath: string, name: string): string | undefined => {
@ -129,6 +157,10 @@ export const createSymbolTable = (): SymbolTable => {
return callableIndex.get(name) ?? [];
};
const lookupFieldByOwner = (ownerNodeId: string, fieldName: string): SymbolDefinition | undefined => {
return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`);
};
const getStats = () => ({
fileCount: fileIndex.size,
globalSymbolCount: globalIndex.size
@ -138,7 +170,8 @@ export const createSymbolTable = (): SymbolTable => {
fileIndex.clear();
globalIndex.clear();
callableIndex = null;
fieldByOwner.clear();
};
return { add, lookupExact, lookupExactFull, lookupFuzzy, lookupFuzzyCallable, getStats, clear };
return { add, lookupExact, lookupExactFull, lookupFuzzy, lookupFuzzyCallable, lookupFieldByOwner, getStats, clear };
};

View file

@ -62,6 +62,19 @@ export const TYPESCRIPT_QUERIES = `
(new_expression
constructor: (identifier) @call.name) @call
; Class properties public_field_definition covers most TS class fields
(public_field_definition
name: (property_identifier) @name) @definition.property
; 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
@ -128,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
@ -160,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
@ -179,6 +204,11 @@ export const JAVA_QUERIES = `
(method_declaration name: (identifier) @name) @definition.method
(constructor_declaration name: (identifier) @name) @definition.constructor
; Fields typed field declarations inside class bodies
(field_declaration
declarator: (variable_declarator
name: (identifier) @name)) @definition.property
; Imports - capture any import declaration child as source
(import_declaration (_) @import.source) @import
@ -243,6 +273,11 @@ export const GO_QUERIES = `
(import_declaration (import_spec path: (interpreted_string_literal) @import.source)) @import
(import_declaration (import_spec_list (import_spec path: (interpreted_string_literal) @import.source))) @import
; Struct fields named field declarations inside struct types
(field_declaration_list
(field_declaration
name: (field_identifier) @name) @definition.property)
; Struct embedding (anonymous fields = inheritance)
(type_declaration
(type_spec
@ -299,6 +334,21 @@ 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
; Pointer member fields (Address* address;)
(field_declaration
declarator: (pointer_declarator
declarator: (field_identifier) @name)) @definition.property
; Reference member fields (Address& address;)
(field_declaration
declarator: (reference_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
@ -414,6 +464,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
@ -457,6 +512,13 @@ export const PHP_QUERIES = `
(variable_name
(name) @name))) @definition.property
; Constructor property promotion (PHP 8.0+: public Address $address in __construct)
(method_declaration
parameters: (formal_parameters
(property_promotion_parameter
name: (variable_name
(name) @name)))) @definition.property
; Imports: use statements
; Simple: use App\\Models\\User;
(namespace_use_declaration
@ -582,6 +644,12 @@ export const KOTLIN_QUERIES = `
(variable_declaration
(simple_identifier) @name)) @definition.property
; Primary constructor val/var parameters (data class, value class, regular class)
; binding_pattern_kind contains "val" or "var" without it, the param is not a property
(class_parameter
(binding_pattern_kind)
(simple_identifier) @name) @definition.property
; Enum entries
(enum_entry
(simple_identifier) @name) @definition.enum

View file

@ -439,7 +439,9 @@ export const buildTypeEnv = (
let typeNode = node.childForFieldName('type');
if (typeNode) {
const nameNode = node.childForFieldName('name')
?? node.childForFieldName('pattern');
?? node.childForFieldName('pattern')
// Python typed_parameter: name is a positional child (identifier), not a named field
?? (node.firstNamedChild?.type === 'identifier' ? node.firstNamedChild : null);
if (nameNode) {
const varName = extractVarName(nameNode);
if (varName && !declarationTypeNodes.has(`${scope}\0${varName}`)) {

View file

@ -62,6 +62,10 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
} else {
nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern');
typeNode = node.childForFieldName('type');
// Python typed_parameter: name is a positional child (identifier), not a named field
if (!nameNode && node.type === 'typed_parameter') {
nameNode = node.firstNamedChild?.type === 'identifier' ? node.firstNamedChild : null;
}
}
if (!nameNode || !typeNode) return;

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: SyntaxNode | null): 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',
@ -318,6 +320,21 @@ export const findEnclosingClassId = (node: any, filePath: string): string | null
}
}
}
// Go: type_declaration wrapping a struct_type (type User struct { ... })
// field_declaration → field_declaration_list → struct_type → type_spec → type_declaration
if (current.type === 'type_declaration') {
const typeSpec = current.children?.find((c: any) => c.type === 'type_spec');
if (typeSpec) {
const typeBody = typeSpec.childForFieldName?.('type');
if (typeBody?.type === 'struct_type' || typeBody?.type === 'interface_type') {
const nameNode = typeSpec.childForFieldName?.('name');
if (nameNode) {
const label = typeBody.type === 'struct_type' ? 'Struct' : 'Interface';
return generateId(label, `${filePath}:${nameNode.text}`);
}
}
}
}
if (CLASS_CONTAINER_TYPES.has(current.type)) {
// Rust impl_item: for `impl Trait for Struct {}`, pick the type after `for`
if (current.type === 'impl_item') {
@ -1156,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,6 +48,8 @@ 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 { extractPropertyDeclaredType } from '../type-extractors/shared.js';
import type { NodeLabel } from '../../graph/types.js';
// ============================================================================
// Types for serializable results
@ -75,7 +77,7 @@ interface ParsedRelationship {
id: string;
sourceId: string;
targetId: string;
type: 'DEFINES' | 'HAS_METHOD';
type: 'DEFINES' | 'HAS_METHOD' | 'HAS_PROPERTY';
confidence: number;
reason: string;
}
@ -84,9 +86,10 @@ interface ParsedSymbol {
filePath: string;
name: string;
nodeId: string;
type: string;
type: NodeLabel;
parameterCount?: number;
returnType?: string;
declaredType?: string;
ownerId?: string;
}
@ -111,13 +114,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[];
receiverMixedChain?: MixedChainStep[];
}
export interface ExtractedHeritage {
@ -233,7 +237,7 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
// Label detection from capture map
// ============================================================================
const getLabelFromCaptures = (captureMap: Record<string, any>): string | null => {
const getLabelFromCaptures = (captureMap: Record<string, any>): NodeLabel | null => {
// Skip imports (handled separately) and calls
if (captureMap['import'] || captureMap['call']) return null;
if (!captureMap['name']) return null;
@ -965,6 +969,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}`);
@ -978,10 +983,10 @@ const processFileGroup = (
});
if (propEnclosingClassId) {
result.relationships.push({
id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
sourceId: propEnclosingClassId,
targetId: nodeId,
type: 'HAS_METHOD',
type: 'HAS_PROPERTY',
confidence: 1.0,
reason: '',
});
@ -1000,27 +1005,20 @@ 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;
let receiverMixedChain: MixedChainStep[] | 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.
// 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);
}
@ -1036,7 +1034,7 @@ const processFileGroup = (
...(callForm !== undefined ? { callForm } : {}),
...(receiverName !== undefined ? { receiverName } : {}),
...(receiverTypeName !== undefined ? { receiverTypeName } : {}),
...(receiverCallChain !== undefined ? { receiverCallChain } : {}),
...(receiverMixedChain !== undefined ? { receiverMixedChain } : {}),
});
}
}
@ -1086,6 +1084,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;
@ -1109,6 +1124,7 @@ const processFileGroup = (
let parameterCount: number | undefined;
let returnType: string | undefined;
let declaredType: string | undefined;
if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') {
const sig = extractMethodSignature(definitionNode);
parameterCount = sig.parameterCount;
@ -1123,6 +1139,10 @@ const processFileGroup = (
if (docReturn) returnType = docReturn;
}
}
} else if (nodeLabel === 'Property' && definitionNode) {
// Extract the declared type for property/field nodes.
// Walk the definition node for type annotation children.
declaredType = extractPropertyDeclaredType(definitionNode);
}
result.nodes.push({
@ -1157,6 +1177,7 @@ const processFileGroup = (
type: nodeLabel,
...(parameterCount !== undefined ? { parameterCount } : {}),
...(returnType !== undefined ? { returnType } : {}),
...(declaredType !== undefined ? { declaredType } : {}),
...(enclosingClassId ? { ownerId: enclosingClassId } : {}),
});
@ -1171,13 +1192,14 @@ const processFileGroup = (
reason: '',
});
// ── HAS_METHOD: link method/constructor/property to enclosing class ──
// ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ──
if (enclosingClassId) {
const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD';
result.relationships.push({
id: generateId('HAS_METHOD', `${enclosingClassId}->${nodeId}`),
id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`),
sourceId: enclosingClassId,
targetId: nodeId,
type: 'HAS_METHOD',
type: memberEdgeType,
confidence: 1.0,
reason: '',
});

View file

@ -26,7 +26,7 @@ export type NodeTableName = typeof NODE_TABLES[number];
export const REL_TABLE_NAME = 'CodeRelation';
// Valid relation types
export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS'] as const;
export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS'] as const;
export type RelType = typeof REL_TYPES[number];
// ============================================================================

View file

@ -47,7 +47,7 @@ export const VALID_NODE_LABELS = new Set([
]);
/** Valid relation types for impact analysis filtering */
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES']);
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES']);
/** Regex to detect write operations in user-supplied Cypher queries */
export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
@ -898,7 +898,7 @@ export class LocalBackend {
// Categorized incoming refs
const incomingRows = await executeParameterized(repo.id, `
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES']
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
LIMIT 30
`, { symId });
@ -906,7 +906,7 @@ export class LocalBackend {
// Categorized outgoing refs
const outgoingRows = await executeParameterized(repo.id, `
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES']
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
LIMIT 30
`, { symId });

View file

@ -328,6 +328,9 @@ relationships:
- IMPORTS: Module imports
- EXTENDS: Class inheritance
- IMPLEMENTS: Interface implementation
- HAS_METHOD: Class/Struct/Interface owns a Method
- HAS_PROPERTY: Class/Struct/Interface owns a Property (field)
- OVERRIDES: Method overrides another Method (MRO)
- MEMBER_OF: Symbol belongs to community
- STEP_IN_PROCESS: Symbol is step N in process

View file

@ -78,7 +78,7 @@ SCHEMA:
- Nodes: File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process
- Multi-language nodes (use backticks): \`Struct\`, \`Enum\`, \`Trait\`, \`Impl\`, etc.
- All edges via single CodeRelation table with 'type' property
- Edge types: CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES, MEMBER_OF, STEP_IN_PROCESS
- Edge types: CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES, MEMBER_OF, STEP_IN_PROCESS
- Edge properties: type (STRING), confidence (DOUBLE), reason (STRING), step (INT32)
EXAMPLES:
@ -94,6 +94,9 @@ EXAMPLES:
Find all methods of a class:
MATCH (c:Class {name: "UserService"})-[r:CodeRelation {type: 'HAS_METHOD'}]->(m:Method) RETURN m.name, m.parameterCount, m.returnType
Find all properties of a class:
MATCH (c:Class {name: "User"})-[r:CodeRelation {type: 'HAS_PROPERTY'}]->(p:Property) RETURN p.name, p.description
Find method overrides (MRO resolution):
MATCH (winner:Method)-[r:CodeRelation {type: 'OVERRIDES'}]->(loser:Method) RETURN winner.name, winner.filePath, loser.filePath, r.reason
@ -119,7 +122,7 @@ TIPS:
{
name: 'context',
description: `360-degree view of a single code symbol.
Shows categorized incoming/outgoing references (calls, imports, extends, implements), process participation, and file location.
Shows categorized incoming/outgoing references (calls, imports, extends, implements, methods, properties, overrides), process participation, and file location.
WHEN TO USE: After query() to understand a specific symbol in depth. When you need to know all callers, callees, and what execution flows a symbol participates in.
AFTER THIS: Use impact() if planning changes, or READ gitnexus://repo/{name}/process/{processName} for full execution trace.
@ -200,7 +203,9 @@ Depth groups:
- d=2: LIKELY AFFECTED (indirect)
- d=3: MAY NEED TESTING (transitive)
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES
TIP: Default traversal uses CALLS/IMPORTS/EXTENDS/IMPLEMENTS. For class members, include HAS_METHOD and HAS_PROPERTY in relationTypes.
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES
Confidence: 1.0 = certain, <0.8 = fuzzy match`,
inputSchema: {
type: 'object',
@ -208,7 +213,7 @@ Confidence: 1.0 = certain, <0.8 = fuzzy match`,
target: { type: 'string', description: 'Name of function, class, or file to analyze' },
direction: { type: 'string', description: 'upstream (what depends on this) or downstream (what this depends on)' },
maxDepth: { type: 'number', description: 'Max relationship depth (default: 3)', default: 3 },
relationTypes: { type: 'array', items: { type: 'string' }, description: 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES (default: usage-based)' },
relationTypes: { type: 'array', items: { type: 'string' }, description: 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES (default: usage-based)' },
includeTests: { type: 'boolean', description: 'Include test files (default: false)' },
minConfidence: { type: 'number', description: 'Minimum confidence 0-1 (default: 0.7)' },
repo: { type: 'string', description: 'Repository name or path. Omit if only one repo is indexed.' },

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,21 @@
#pragma once
class Address {
public:
std::string city;
void save() {
// persist address
}
};
class User {
public:
Address* address; // raw pointer member field
Address& ref_address; // reference member field
std::string name;
std::string greet() {
return name;
}
};

View file

@ -0,0 +1,6 @@
#include "models.h"
void processUser(User user) {
// Pointer member field access: user.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,22 @@
namespace FieldTypes;
public class Address
{
public string City { 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,10 @@
namespace FieldTypes;
public class Service
{
public static void ProcessUser(User user)
{
// Field-access chain: user.Address → Address, then .Save() → Address#Save
user.Address.Save();
}
}

View file

@ -0,0 +1,24 @@
export class Address {
city: string;
save(): void {
// persist address
}
}
export class User {
name: string;
address: Address;
greet(): string {
return this.name;
}
}
export class Config {
static DEFAULT: Config = new Config();
validate(): boolean {
return true;
}
}

View file

@ -0,0 +1,11 @@
import { User, Config } from './models';
function processUser(user: User) {
// Field-access chain: user.address resolves to Address, then .save() resolves to Address#save
user.address.save();
}
function validateConfig() {
// Static field access: Config.DEFAULT resolves to Config, then .validate() resolves to Config#validate
Config.DEFAULT.validate();
}

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,8 @@
package main
import "example.com/go-field-types/models"
func processUser(user models.User) {
// Field-access chain: user.Address → Address, then .Save() → Address#Save
user.Address.Save()
}

View file

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

View file

@ -0,0 +1,18 @@
package models
type Address struct {
City 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 @@
package main
import "example.com/go-mixed-chain/models"
func processWithService(svc *models.UserService) {
svc.GetUser().Address.Save()
}
func processWithUser(user *models.User) {
user.GetAddress().City.GetName()
}

View file

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

View file

@ -0,0 +1,32 @@
package models
type City struct {
Name string
}
func (c *City) GetName() string {
return c.Name
}
type Address struct {
City City
Street string
}
func (a *Address) Save() {
}
type User struct {
Name string
Address Address
}
func (u *User) GetAddress() *Address {
return &u.Address
}
type UserService struct{}
func (s *UserService) GetUser() *User {
return &User{}
}

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,8 @@
import models.User;
public class App {
public static void processUser(User user) {
// Field-access chain: user.address Address, then .save() Address#save
user.address.save();
}
}

View file

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

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,12 @@
import services.UserService;
import models.User;
public class App {
public static void processWithService(UserService svc) {
svc.getUser().address.save();
}
public static void processWithUser(User user) {
user.getAddress().city.getName();
}
}

View file

@ -0,0 +1,8 @@
package models;
public class Address {
public City city;
public void save() {
}
}

View file

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

View file

@ -0,0 +1,9 @@
package models;
public class User {
public Address address;
public Address getAddress() {
return this.address;
}
}

View file

@ -0,0 +1,9 @@
package services;
import models.User;
public class UserService {
public User getUser() {
return new User();
}
}

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,13 @@
class Address {
var city: String = ""
fun save() {
// persist address
}
}
data class User(
val name: String,
val address: Address,
val age: Int
)

View file

@ -0,0 +1,4 @@
fun processUser(user: User) {
// Field-access chain: user.address → Address, then .save() → Address#save
user.address.save()
}

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,16 @@
class Address {
var city: String = ""
fun save() {
// persist address
}
}
class User {
var name: String = ""
var address: Address = Address()
fun greet(): String {
return name
}
}

View file

@ -0,0 +1,4 @@
fun processUser(user: User) {
// Field-access chain: user.address → Address, then .save() → Address#save
user.address.save()
}

View file

@ -0,0 +1,20 @@
<?php
class Address {
public string $city;
public function save(): void {
// persist address
}
}
class User {
public function __construct(
public string $name,
public Address $address,
) {}
public function greet(): string {
return $this->name;
}
}

View file

@ -0,0 +1,8 @@
<?php
class Service {
public function processUser(User $user): void {
// Field-access chain: $user->address → Address, then ->save() → Address#save
$user->address->save();
}
}

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,22 @@
<?php
class Address {
/** @var string */
public string $city;
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,8 @@
<?php
class Service {
public function processUser(User $user): void {
// Field-access chain: $user->address → Address, then ->save() → Address#save
$user->address->save();
}
}

View file

@ -0,0 +1,5 @@
class Address:
city: str
def save(self):
pass

View file

@ -0,0 +1,6 @@
from user import User
def process_user(user: User):
# Field-access chain: user.address → Address, then .save() must resolve
# to Address#save (NOT User#save) — only lookupFieldByOwner can disambiguate.
user.address.save()

View file

@ -0,0 +1,8 @@
from address import Address
class User:
name: str
address: Address
def save(self):
pass

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,8 @@
class Address
# @return [String]
attr_accessor :city
def save
true
end
end

View file

@ -0,0 +1,8 @@
require_relative 'user'
# @param user [User]
def process_user(user)
# Field-access chain: user.address → Address, then .save → Address#save
# Both User and Address have save — only lookupFieldByOwner can disambiguate.
user.address.save
end

View file

@ -0,0 +1,13 @@
require_relative 'address'
class User
# @return [String]
attr_accessor :name
# @return [Address]
attr_accessor :address
def save
true
end
end

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,25 @@
pub struct City {
pub zip_code: String,
}
impl City {
pub fn get_name(&self) -> &str {
"city"
}
}
pub struct Address {
pub city: City,
pub street: String,
}
impl Address {
pub fn save(&self) {
// persist address
}
}
pub struct User {
pub name: String,
pub address: Address,
}

View file

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

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,126 @@ 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();
});
});
// ---------------------------------------------------------------------------
// Pointer and reference member fields (Address* address; Address& ref_address;)
// ---------------------------------------------------------------------------
describe('C++ pointer/reference member field capture', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-pointer-ref-fields'),
() => {},
);
}, 60000);
it('detects classes: Address, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']);
});
it('detects Property nodes for pointer and reference member fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('ref_address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges for pointer/reference fields', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('User → ref_address');
expect(edgeSet(propEdges)).toContain('User → name');
});
});

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

@ -940,3 +940,127 @@ 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();
});
});
// ---------------------------------------------------------------------------
// Mixed field+call chain resolution (Go)
// ---------------------------------------------------------------------------
describe('Mixed field+call chain resolution (Go)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-mixed-chain'),
() => {},
);
}, 60000);
it('detects structs: Address, City, User, UserService', () => {
expect(getNodesByLabel(result, 'Struct')).toEqual(['Address', 'City', 'User', 'UserService']);
});
it('detects Property nodes for mixed-chain fields', () => {
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

@ -1068,3 +1068,132 @@ 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();
});
});
// ---------------------------------------------------------------------------
// Mixed field+call chain resolution (Java)
// ---------------------------------------------------------------------------
describe('Mixed field+call chain resolution (Java)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'java-mixed-chain'),
() => {},
);
}, 60000);
it('detects classes: Address, App, City, User, UserService', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'App', 'City', 'User', 'UserService']);
});
it('detects Property nodes for mixed-chain fields', () => {
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('Address');
});
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('City');
});
});

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,135 @@ 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();
});
});
// ---------------------------------------------------------------------------
// Kotlin data class primary constructor val/var properties
// ---------------------------------------------------------------------------
describe('Kotlin data class primary constructor property capture', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-data-class-fields'),
() => {},
);
}, 60000);
it('detects classes: Address, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']);
});
it('detects Property nodes for data class val parameters', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('name');
expect(properties).toContain('address');
expect(properties).toContain('age');
});
it('emits HAS_PROPERTY edges for primary constructor properties', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(edgeSet(propEdges)).toContain('User → name');
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('User → age');
});
it('resolves user.address.save() → Address#save via data class 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

@ -1161,3 +1161,128 @@ 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();
});
});
// ---------------------------------------------------------------------------
// PHP 8.0+ constructor promotion as property declarations
// ---------------------------------------------------------------------------
describe('PHP constructor promotion property capture', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'php-constructor-promotion-fields'),
() => {},
);
}, 60000);
it('detects classes: Address, Service, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'Service', 'User']);
});
it('detects Property nodes for promoted constructor parameters', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('name');
expect(properties).toContain('address');
});
it('emits HAS_PROPERTY edges for promoted parameters', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(edgeSet(propEdges)).toContain('User → name');
expect(edgeSet(propEdges)).toContain('User → address');
});
it('resolves $user->address->save() → Address#save via promoted 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

@ -1281,3 +1281,77 @@ describe('Python enumerate() for-loop resolution', () => {
expect(userSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// 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');
});
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 === 'process_user' && e.targetFilePath.includes('models'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field type disambiguation — both User and Address have save()
// ---------------------------------------------------------------------------
describe('Field type disambiguation (Python)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-field-type-disambig'),
() => {},
);
}, 60000);
it('detects both User#save and Address#save', () => {
const methods = getNodesByLabel(result, 'Function');
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 === 'process_user',
);
expect(saveCalls.length).toBe(1);
expect(saveCalls[0].targetFilePath).toContain('address');
expect(saveCalls[0].targetFilePath).not.toContain('user');
});
});

View file

@ -124,15 +124,15 @@ describe('Ruby require_relative, heritage & property resolution', () => {
expect(props).toContain('email');
});
it('emits HAS_METHOD from User to attr_reader :name', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const edge = hasMethod.find(e => e.source === 'User' && e.target === 'name');
it('emits HAS_PROPERTY from User to attr_reader :name', () => {
const hasProperty = getRelationships(result, 'HAS_PROPERTY');
const edge = hasProperty.find(e => e.source === 'User' && e.target === 'name');
expect(edge).toBeDefined();
});
it('emits HAS_METHOD from BaseModel to attr_accessor :id', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const edge = hasMethod.find(e => e.source === 'BaseModel' && e.target === 'id');
it('emits HAS_PROPERTY from BaseModel to attr_accessor :id', () => {
const hasProperty = getRelationships(result, 'HAS_PROPERTY');
const edge = hasProperty.find(e => e.source === 'BaseModel' && e.target === 'id');
expect(edge).toBeDefined();
});
@ -846,3 +846,77 @@ 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();
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field type disambiguation — both User and Address have save()
// ---------------------------------------------------------------------------
describe('Field type disambiguation (Ruby)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-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 === 'process_user',
);
expect(saveCalls.length).toBe(1);
expect(saveCalls[0].targetFilePath).toContain('address');
expect(saveCalls[0].targetFilePath).not.toContain('user');
});
});

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,88 @@ 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);
});
it('resolves user.address.save() → Address#save via field type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(
e => e.target === 'save' && e.source === 'process_user',
);
expect(saveCalls.length).toBe(1);
expect(saveCalls[0].targetFilePath).toContain('models');
});
});
// ---------------------------------------------------------------------------
// Phase 8B: Deep field chain resolution (3-level)
// ---------------------------------------------------------------------------
describe('Deep field chain resolution (Rust)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-deep-field-chain'),
() => {},
);
}, 60000);
it('detects structs: Address, City, User', () => {
expect(getNodesByLabel(result, 'Struct')).toEqual(['Address', 'City', 'User']);
});
it('detects Property nodes for Rust struct fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('city');
expect(properties).toContain('zip_code');
});
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 === 'process_user');
const addressSave = saveCalls.find(e => e.targetFilePath.includes('models'));
expect(addressSave).toBeDefined();
});
it('resolves 3-level chain: user.address.city.get_name() → City#get_name', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCalls = calls.filter(e => e.target === 'get_name' && e.source === 'process_user');
const cityGetName = getNameCalls.find(e => e.targetFilePath.includes('models'));
expect(cityGetName).toBeDefined();
});
});

View file

@ -64,6 +64,12 @@ describe('TypeScript heritage resolution', () => {
]);
});
it('emits HAS_PROPERTY edge for class fields', () => {
const hasProperty = getRelationships(result, 'HAS_PROPERTY');
expect(hasProperty.length).toBe(1);
expect(edgeSet(hasProperty)).toEqual(['BaseService → name']);
});
it('no OVERRIDES edges target Property nodes', () => {
const overrides = getRelationships(result, 'OVERRIDES');
for (const edge of overrides) {
@ -1692,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

@ -292,19 +292,19 @@ describe('computeMRO', () => {
addExtends(graph, 'Child', 'ParentA');
addExtends(graph, 'Child', 'ParentB');
// Add Property nodes (same name 'name') to both parents via HAS_METHOD
// Add Property nodes (same name 'name') to both parents via HAS_PROPERTY
const propA = generateId('Property', 'ParentA.name');
graph.addNode({ id: propA, label: 'Property', properties: { name: 'name', filePath: 'src/ParentA.ts' } });
graph.addRelationship({
id: generateId('HAS_METHOD', `${parentA}->${propA}`),
sourceId: parentA, targetId: propA, type: 'HAS_METHOD', confidence: 1.0, reason: '',
id: generateId('HAS_PROPERTY', `${parentA}->${propA}`),
sourceId: parentA, targetId: propA, type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
});
const propB = generateId('Property', 'ParentB.name');
graph.addNode({ id: propB, label: 'Property', properties: { name: 'name', filePath: 'src/ParentB.ts' } });
graph.addRelationship({
id: generateId('HAS_METHOD', `${parentB}->${propB}`),
sourceId: parentB, targetId: propB, type: 'HAS_METHOD', confidence: 1.0, reason: '',
id: generateId('HAS_PROPERTY', `${parentB}->${propB}`),
sourceId: parentB, targetId: propB, type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
});
const result = computeMRO(graph);
@ -328,19 +328,19 @@ describe('computeMRO', () => {
const methodA = addMethod(graph, 'PA', 'doWork');
addMethod(graph, 'PB', 'doWork');
// Property collision (should NOT trigger OVERRIDES)
// Property collision (should NOT trigger OVERRIDES — properties use HAS_PROPERTY, not HAS_METHOD)
const propA = generateId('Property', 'PA.id');
graph.addNode({ id: propA, label: 'Property', properties: { name: 'id', filePath: 'src/PA.ts' } });
graph.addRelationship({
id: generateId('HAS_METHOD', `${parentA}->${propA}`),
sourceId: parentA, targetId: propA, type: 'HAS_METHOD', confidence: 1.0, reason: '',
id: generateId('HAS_PROPERTY', `${parentA}->${propA}`),
sourceId: parentA, targetId: propA, type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
});
const propB = generateId('Property', 'PB.id');
graph.addNode({ id: propB, label: 'Property', properties: { name: 'id', filePath: 'src/PB.ts' } });
graph.addRelationship({
id: generateId('HAS_METHOD', `${parentB}->${propB}`),
sourceId: parentB, targetId: propB, type: 'HAS_METHOD', confidence: 1.0, reason: '',
id: generateId('HAS_PROPERTY', `${parentB}->${propB}`),
sourceId: parentB, targetId: propB, type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
});
const result = computeMRO(graph);

View file

@ -122,11 +122,11 @@ describe('LadybugDB Schema', () => {
it('has all FROM/TO pairs needed for HAS_METHOD edges', () => {
// HAS_METHOD sources: Class, Interface, Struct, Trait, Impl, Record
// HAS_METHOD targets: Method, Constructor, Property
// HAS_METHOD targets: Method, Constructor (Property is now HAS_PROPERTY)
const sources = ['Class', 'Interface'];
const backtickSources = ['Struct', 'Trait', 'Impl', 'Record'];
const targets = ['Method'];
const backtickTargets = ['Constructor', 'Property'];
const backtickTargets = ['Constructor'];
// Non-backtick source → non-backtick target
for (const src of sources) {

Some files were not shown because too many files have changed in this diff Show more