diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 1e3925163f..69bb94b338 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -17,7 +17,11 @@ import { countCallArguments, inferCallForm, extractReceiverName, + extractReceiverNode, findEnclosingClassId, + CALL_EXPRESSION_TYPES, + MAX_CHAIN_DEPTH, + extractCallChain, } from './utils.js'; import { buildTypeEnv } from './type-env.js'; import type { ConstructorBinding } from './type-env.js'; @@ -72,7 +76,7 @@ const verifyConstructorBindings = ( const isClass = tiered?.candidates.some(def => def.type === 'Class') ?? false; if (isClass) { - verified.set(receiverKey(extractFuncNameFromScope(scope), varName), calleeName); + verified.set(receiverKey(scope, varName), calleeName); } else { let callableDefs = tiered?.candidates.filter(d => d.type === 'Function' || d.type === 'Method' @@ -105,7 +109,7 @@ const verifyConstructorBindings = ( if (callableDefs && callableDefs.length === 1 && callableDefs[0].returnType) { const typeName = extractReturnTypeName(callableDefs[0].returnType); if (typeName) { - verified.set(receiverKey(extractFuncNameFromScope(scope), varName), typeName); + verified.set(receiverKey(scope, varName), typeName); } } } @@ -253,8 +257,47 @@ export const processCalls = async ( if (!receiverTypeName && receiverName && verifiedReceivers.size > 0) { const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx); const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : ''; - receiverTypeName = verifiedReceivers.get(receiverKey(funcName, receiverName)) - ?? verifiedReceivers.get(receiverKey('', receiverName)); + receiverTypeName = lookupReceiverType(verifiedReceivers, 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 + // through the standard tiered resolution, use it directly as the receiver type. + if (!receiverTypeName && receiverName && callForm === 'member') { + const typeResolved = ctx.resolve(receiverName, file.path); + if (typeResolved && typeResolved.candidates.some( + d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum', + )) { + 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). + 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 + ? typeEnv.lookup(extracted.baseReceiverName, callNode) + : undefined; + if (!baseType && extracted.baseReceiverName && verifiedReceivers.size > 0) { + const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx); + const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : ''; + baseType = lookupReceiverType(verifiedReceivers, funcName, extracted.baseReceiverName); + } + // Class-as-receiver for chain base (e.g. UserService.find_user().save()) + if (!baseType && 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; + } + } + receiverTypeName = resolveChainedReceiver(extracted.chain, baseType, file.path, ctx); + } + } } const resolved = resolveCallTarget({ @@ -352,6 +395,47 @@ const toResolveResult = ( reason: tier === 'same-file' ? 'same-file' : tier === 'import-scoped' ? 'import-resolved' : 'global', }); +/** + * Resolve a chain of intermediate method calls to find the receiver type for a + * final member call. Called when the receiver of a call is itself a call + * expression (e.g. `svc.getUser().save()`). + * + * @param chainNames Ordered list of method names from outermost to innermost + * intermediate call (e.g. ['getUser'] for `svc.getUser().save()`). + * @param baseReceiverTypeName The already-resolved type of the base receiver + * (e.g. 'UserService' for `svc`), or undefined. + * @param currentFile The file path for resolution context. + * @param ctx The resolution context for symbol lookup. + * @returns The type name of the final intermediate call's return type, or undefined + * if resolution fails at any step. + */ +function resolveChainedReceiver( + chainNames: string[], + baseReceiverTypeName: string | undefined, + currentFile: string, + ctx: ResolutionContext, +): string | undefined { + let currentType = baseReceiverTypeName; + for (const name of chainNames) { + const resolved = resolveCallTarget( + { calledName: name, callForm: 'member', receiverTypeName: currentType }, + currentFile, + ctx, + ); + if (!resolved) return undefined; + + const candidates = ctx.symbols.lookupFuzzy(name); + const symDef = candidates.find(c => c.nodeId === resolved.nodeId); + if (!symDef?.returnType) return undefined; + + const returnTypeName = extractReturnTypeName(symDef.returnType); + if (!returnTypeName) return undefined; + + currentType = returnTypeName; + } + return currentType; +} + /** * Resolve a function call to its target node ID using priority strategy: * A. Narrow candidates by scope tier via ctx.resolve() @@ -491,7 +575,8 @@ function extractFirstTypeArg(args: string): string { return args.trim(); } -export const extractReturnTypeName = (raw: string): string | undefined => { +export const extractReturnTypeName = (raw: string, depth = 0): string | undefined => { + if (depth > 10) return undefined; let text = raw.trim(); if (!text) return undefined; @@ -519,7 +604,7 @@ export const extractReturnTypeName = (raw: string): string | undefined => { // so that nested generics like Result are not split at the inner // comma. Lifetime parameters (Rust 'a, '_) are skipped. const firstArg = extractFirstTypeArg(args); - return extractReturnTypeName(firstArg); + return extractReturnTypeName(firstArg, depth + 1); } // Non-wrapper generic: return the base type (e.g., Map → Map) return PRIMITIVE_TYPES.has(base.toLowerCase()) ? undefined : base; @@ -548,6 +633,11 @@ export const extractReturnTypeName = (raw: string): string | undefined => { // Source IDs use "Label:filepath:funcName" (produced by parse-worker.ts). // NUL (\0) is used as a composite-key separator because it cannot appear // in source-code identifiers, preventing ambiguous concatenation. +// +// receiverKey stores the FULL scope (funcName@startIndex) to prevent +// collisions between overloaded methods with the same name in different +// classes (e.g. User.save@100 and Repo.save@200 are distinct keys). +// Lookup uses a secondary funcName-only index built in lookupReceiverType. /** Extract the function name from a scope key ("funcName@startIndex" → "funcName"). */ const extractFuncNameFromScope = (scope: string): string => @@ -559,9 +649,58 @@ const extractFuncNameFromSourceId = (sourceId: string): string => { return lastColon >= 0 ? sourceId.slice(lastColon + 1) : ''; }; -/** Build a scope-aware composite key for receiver type lookup. */ -const receiverKey = (funcName: string, varName: string): string => - `${funcName}\0${varName}`; +/** + * Build a composite key for receiver type storage. + * Uses the full scope string (e.g. "save@100") to distinguish overloaded + * methods with the same name in different classes. + */ +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). + */ +const lookupReceiverType = ( + map: Map, + 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@\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; + } + } + } + + if (!ambiguous && found !== undefined) return found; + + // Fallback: file-level scope (bindings outside any function) + return map.get(fileLevelKey); +}; /** * Fast path: resolve pre-extracted call sites from workers. @@ -609,15 +748,52 @@ export const processCallsFromExtracted = async ( for (const call of calls) { let effectiveCall = call; + + // Step 1: resolve receiver type from constructor bindings if (!call.receiverTypeName && call.receiverName && receiverMap) { const callFuncName = extractFuncNameFromSourceId(call.sourceId); - const resolvedType = receiverMap.get(receiverKey(callFuncName, call.receiverName)) - ?? receiverMap.get(receiverKey('', call.receiverName)); // fall back to file-level scope + const resolvedType = lookupReceiverType(receiverMap, callFuncName, call.receiverName); if (resolvedType) { effectiveCall = { ...call, receiverTypeName: resolvedType }; } } + // Step 1b: class-as-receiver for static method calls (e.g. UserService.find_user()) + if (!effectiveCall.receiverTypeName && effectiveCall.receiverName && effectiveCall.callForm === 'member') { + const typeResolved = ctx.resolve(effectiveCall.receiverName, effectiveCall.filePath); + if (typeResolved && typeResolved.candidates.some( + d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum', + )) { + effectiveCall = { ...effectiveCall, receiverTypeName: effectiveCall.receiverName }; + } + } + + // Step 2: if the call has a receiver call chain (e.g. svc.getUser().save()), + // resolve the chain to determine the final receiver type. + // This runs whenever receiverCallChain is present — even when Step 1 set a + // receiverTypeName, that type is the BASE receiver (e.g. UserService for svc), + // and the chain must be walked to produce the FINAL receiver (e.g. User from + // getUser() : User). + if (effectiveCall.receiverCallChain?.length) { + // Step 1 may have resolved the base receiver type (e.g. svc → UserService). + // Use it as the starting point for chain resolution. + let baseType = effectiveCall.receiverTypeName; + // If Step 1 didn't resolve it, try the receiver map directly. + if (!baseType && effectiveCall.receiverName && receiverMap) { + const callFuncName = extractFuncNameFromSourceId(effectiveCall.sourceId); + baseType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName); + } + const chainedType = resolveChainedReceiver( + effectiveCall.receiverCallChain, + baseType, + effectiveCall.filePath, + ctx, + ); + if (chainedType) { + effectiveCall = { ...effectiveCall, receiverTypeName: chainedType }; + } + } + const resolved = resolveCallTarget(effectiveCall, effectiveCall.filePath, ctx); if (!resolved) continue; diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 9c08d11ba9..d60701c2a7 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -265,7 +265,9 @@ const createClassNameLookup = ( if (localNames.has(name)) return true; const cached = memo.get(name); if (cached !== undefined) return cached; - const result = symbolTable.lookupFuzzy(name).some(def => def.type === 'Class'); + const result = symbolTable.lookupFuzzy(name).some(def => + def.type === 'Class' || def.type === 'Enum' || def.type === 'Struct', + ); memo.set(name, result); return result; }, @@ -292,6 +294,10 @@ export const buildTypeEnv = ( const config = typeConfigs[language]; const bindings: ConstructorBinding[] = []; const pendingAssignments: Array<{ scope: string; lhs: string; rhs: string }> = []; + // Maps `scope\0varName` → the type annotation AST node from the original declaration. + // Allows pattern extractors to navigate back to the declaration's generic type arguments + // (e.g., to extract T from Result for `if let Ok(x) = res`). + const declarationTypeNodes = new Map(); /** * Try to extract a (variableName → typeName) binding from a single AST node. @@ -299,11 +305,26 @@ export const buildTypeEnv = ( * Resolution tiers (first match wins): * - Tier 0: explicit type annotations via extractDeclaration / extractForLoopBinding * - Tier 1: constructor-call inference via extractInitializer (fallback) + * + * Side effect: populates declarationTypeNodes for variables that have an explicit + * type annotation field on the declaration node. This allows pattern extractors to + * retrieve generic type arguments from the original declaration (e.g., extracting T + * from Result for `if let Ok(x) = res`). */ - const extractTypeBinding = (node: SyntaxNode, scopeEnv: Map): void => { + const extractTypeBinding = (node: SyntaxNode, scopeEnv: Map, scope: string): void => { // This guard eliminates 90%+ of calls before any language dispatch. if (TYPED_PARAMETER_TYPES.has(node.type)) { + const keysBefore = new Set(scopeEnv.keys()); config.extractParameter(node, scopeEnv); + // Capture the type node for newly introduced parameter bindings + const typeNode = node.childForFieldName('type'); + if (typeNode) { + for (const varName of scopeEnv.keys()) { + if (!keysBefore.has(varName)) { + declarationTypeNodes.set(`${scope}\0${varName}`, typeNode); + } + } + } return; } // For-each loop variable bindings (Java/C#/Kotlin): explicit element types in the AST. @@ -313,7 +334,19 @@ export const buildTypeEnv = ( return; } if (config.declarationNodeTypes.has(node.type)) { + const keysBefore = new Set(scopeEnv.keys()); config.extractDeclaration(node, scopeEnv); + // Capture the type annotation AST node for newly introduced bindings. + // Only declarations with an explicit 'type' field are recorded — constructor + // inferences (Tier 1) don't have a type annotation node to preserve. + const typeNode = node.childForFieldName('type'); + if (typeNode) { + for (const varName of scopeEnv.keys()) { + if (!keysBefore.has(varName)) { + declarationTypeNodes.set(`${scope}\0${varName}`, typeNode); + } + } + } // Tier 1: constructor-call inference as fallback. // Always called when available — each language's extractInitializer // internally skips declarators that already have explicit annotations, @@ -346,7 +379,18 @@ export const buildTypeEnv = ( if (!env.has(scope)) env.set(scope, new Map()); const scopeEnv = env.get(scope)!; - extractTypeBinding(node, scopeEnv); + extractTypeBinding(node, scopeEnv, scope); + + // Pattern binding extraction: handles constructs that introduce NEW typed variables + // via pattern matching (e.g. `if let Some(x) = opt`, `x instanceof T t`). + // Runs after Tier 0/1 so scopeEnv already contains the source variable's type. + // Conservative: extractor returns undefined when source type is unknown. + if (config.extractPatternBinding) { + const patternBinding = config.extractPatternBinding(node, scopeEnv, declarationTypeNodes, scope); + if (patternBinding && !scopeEnv.has(patternBinding.varName)) { + scopeEnv.set(patternBinding.varName, patternBinding.typeName); + } + } // Tier 2: collect plain-identifier RHS assignments for post-walk propagation. // Delegates to per-language extractPendingAssignment — AST shapes differ widely diff --git a/gitnexus/src/core/ingestion/type-extractors/index.ts b/gitnexus/src/core/ingestion/type-extractors/index.ts index 5b916b3474..b8705fc2e0 100644 --- a/gitnexus/src/core/ingestion/type-extractors/index.ts +++ b/gitnexus/src/core/ingestion/type-extractors/index.ts @@ -40,6 +40,7 @@ export type { ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor, + PatternBindingExtractor, } from './types.js'; export { TYPED_PARAMETER_TYPES, diff --git a/gitnexus/src/core/ingestion/type-extractors/jvm.ts b/gitnexus/src/core/ingestion/type-extractors/jvm.ts index dfcf7b930b..a7ff8e0936 100644 --- a/gitnexus/src/core/ingestion/type-extractors/jvm.ts +++ b/gitnexus/src/core/ingestion/type-extractors/jvm.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js'; import { extractSimpleTypeName, extractVarName, findChildByType } from './shared.js'; // ── Java ────────────────────────────────────────────────────────────────── @@ -114,6 +114,33 @@ const extractJavaPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv return undefined; }; +/** + * Java 16+ `instanceof` pattern variable: `x instanceof User user` + * + * AST structure: + * instanceof_expression + * left: expression (the variable being tested) + * instanceof keyword + * right: type (the type to test against) + * name: identifier (the pattern variable — optional, Java 16+) + * + * Conservative: returns undefined when the `name` field is absent (plain instanceof + * without pattern variable, e.g. `x instanceof User`) or when the type cannot be + * extracted. The source variable's existing type is NOT used — the pattern explicitly + * declares the new type, so no scopeEnv lookup is needed. + */ +const extractJavaPatternBinding: PatternBindingExtractor = (node) => { + if (node.type !== 'instanceof_expression') return undefined; + const nameNode = node.childForFieldName('name'); + if (!nameNode) return undefined; + const typeNode = node.childForFieldName('right'); + if (!typeNode) return undefined; + const typeName = extractSimpleTypeName(typeNode); + const varName = extractVarName(nameNode); + if (!typeName || !varName) return undefined; + return { varName, typeName }; +}; + export const javaTypeConfig: LanguageTypeConfig = { declarationNodeTypes: JAVA_DECLARATION_NODE_TYPES, extractDeclaration: extractJavaDeclaration, @@ -123,6 +150,7 @@ export const javaTypeConfig: LanguageTypeConfig = { forLoopNodeTypes: JAVA_FOR_LOOP_NODE_TYPES, extractForLoopBinding: extractJavaForLoopBinding, extractPendingAssignment: extractJavaPendingAssignment, + extractPatternBinding: extractJavaPatternBinding, }; // ── Kotlin ──────────────────────────────────────────────────────────────── diff --git a/gitnexus/src/core/ingestion/type-extractors/python.ts b/gitnexus/src/core/ingestion/type-extractors/python.ts index 680f9a141c..165ef752e9 100644 --- a/gitnexus/src/core/ingestion/type-extractors/python.ts +++ b/gitnexus/src/core/ingestion/type-extractors/python.ts @@ -5,12 +5,39 @@ import { extractSimpleTypeName, extractVarName } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'assignment', 'named_expression', + 'expression_statement', ]); -/** Python: x: Foo = ... (PEP 484 annotations) */ +/** Python: x: Foo = ... (PEP 484 annotated assignment) or x: Foo (standalone annotation). + * + * tree-sitter-python grammar produces two distinct shapes: + * + * 1. Annotated assignment with value: `name: str = ""` + * Node type: `assignment` + * Fields: left=identifier, type=identifier/type, right=value + * + * 2. Standalone annotation (no value): `name: str` + * Node type: `expression_statement` + * Child: `type` node with fields name=identifier, type=identifier/type + * + * Both appear at file scope and inside class bodies (PEP 526 class variable annotations). + */ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { - // Python annotated assignment: left : type = value - // tree-sitter represents this differently based on grammar version + if (node.type === 'expression_statement') { + // Standalone annotation: expression_statement > type { name: identifier, type: identifier } + const typeChild = node.firstNamedChild; + if (!typeChild || typeChild.type !== 'type') return; + const nameNode = typeChild.childForFieldName('name'); + const typeNode = typeChild.childForFieldName('type'); + if (!nameNode || !typeNode) return; + const varName = extractVarName(nameNode); + const inner = typeNode.type === 'type' ? (typeNode.firstNamedChild ?? typeNode) : typeNode; + const typeName = extractSimpleTypeName(inner) ?? inner.text; + if (varName && typeName) env.set(varName, typeName); + return; + } + + // Annotated assignment: left : type = value const left = node.childForFieldName('left'); const typeNode = node.childForFieldName('type'); if (!left || !typeNode) return; diff --git a/gitnexus/src/core/ingestion/type-extractors/rust.ts b/gitnexus/src/core/ingestion/type-extractors/rust.ts index 2e390c07ed..aa907de34b 100644 --- a/gitnexus/src/core/ingestion/type-extractors/rust.ts +++ b/gitnexus/src/core/ingestion/type-extractors/rust.ts @@ -1,6 +1,6 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor } from './types.js'; -import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait } from './shared.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractGenericTypeArgs } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'let_declaration', @@ -193,6 +193,82 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => return undefined; }; +/** + * Rust pattern binding extractor for `if let` / `while let` constructs that unwrap + * enum variants and introduce new typed variables. + * + * Supported patterns: + * - `if let Some(x) = opt` → x: T (opt: Option, T already in scopeEnv via NULLABLE_WRAPPER_TYPES) + * - `if let Ok(x) = res` → x: T (res: Result, T extracted from declarationTypeNodes) + * + * These complement the captured_pattern support in extractDeclaration (which handles + * `if let x @ Struct { .. } = expr` but NOT tuple struct unwrapping like Some(x) / Ok(x)). + * + * Conservative: returns undefined when: + * - The source variable's type is unknown (not in scopeEnv) + * - The wrapper is not a known single-unwrap variant (Some / Ok) + * - The value side is not a simple identifier + */ +const extractPatternBinding: PatternBindingExtractor = ( + node, + scopeEnv, + declarationTypeNodes, + scope, +) => { + if (node.type !== 'let_condition') return undefined; + + const patternNode = node.childForFieldName('pattern'); + const valueNode = node.childForFieldName('value'); + if (!patternNode || !valueNode) return undefined; + + // Only handle tuple_struct_pattern: Some(x) or Ok(x) + if (patternNode.type !== 'tuple_struct_pattern') return undefined; + + // Extract the wrapper type name: Some | Ok + const wrapperTypeNode = patternNode.childForFieldName('type'); + if (!wrapperTypeNode) return undefined; + const wrapperName = extractSimpleTypeName(wrapperTypeNode); + if (wrapperName !== 'Some' && wrapperName !== 'Ok' && wrapperName !== 'Err') return undefined; + + // Extract the inner variable name from the single child of the tuple_struct_pattern. + // `Some(x)` → the first named child after the type field is the identifier. + // tree-sitter-rust: tuple_struct_pattern has 'type' field + unnamed children for args. + let innerVar: string | undefined; + for (let i = 0; i < patternNode.namedChildCount; i++) { + const child = patternNode.namedChild(i); + if (!child) continue; + // Skip the type node itself + if (child === wrapperTypeNode) continue; + if (child.type === 'identifier') { + innerVar = child.text; + break; + } + } + if (!innerVar) return undefined; + + // The value must be a simple identifier so we can look it up in scopeEnv + const sourceVarName = valueNode.type === 'identifier' ? valueNode.text : undefined; + if (!sourceVarName) return undefined; + + // For `Some(x)`: Option is already unwrapped to T in scopeEnv (via NULLABLE_WRAPPER_TYPES). + // For `Ok(x)`: Result stores "Result" in scopeEnv — must use declarationTypeNodes. + if (wrapperName === 'Some') { + const innerType = scopeEnv.get(sourceVarName); + if (!innerType) return undefined; + return { varName: innerVar, typeName: innerType }; + } + + // wrapperName === 'Ok' or 'Err': look up the Result type AST node. + // Ok(x) → extract T (typeArgs[0]), Err(e) → extract E (typeArgs[1]). + const typeNodeKey = `${scope}\0${sourceVarName}`; + const typeAstNode = declarationTypeNodes.get(typeNodeKey); + if (!typeAstNode) return undefined; + const typeArgs = extractGenericTypeArgs(typeAstNode); + const argIndex = wrapperName === 'Err' ? 1 : 0; + if (typeArgs.length < argIndex + 1) return undefined; + return { varName: innerVar, typeName: typeArgs[argIndex] }; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, @@ -200,4 +276,5 @@ export const typeConfig: LanguageTypeConfig = { extractParameter, scanConstructorBinding, extractPendingAssignment, + extractPatternBinding, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/types.ts b/gitnexus/src/core/ingestion/type-extractors/types.ts index 8715c3c563..e28c0aa659 100644 --- a/gitnexus/src/core/ingestion/type-extractors/types.ts +++ b/gitnexus/src/core/ingestion/type-extractors/types.ts @@ -38,6 +38,23 @@ export type PendingAssignmentExtractor = ( scopeEnv: ReadonlyMap, ) => { lhs: string; rhs: string } | undefined; +/** Extracts a typed variable binding from a pattern-matching construct. + * Returns { varName, typeName } for patterns that introduce NEW variables. + * Examples: `if let Some(user) = opt` (Rust), `x instanceof User user` (Java). + * Conservative: returns undefined when the source variable's type is unknown. + * + * @param scopeEnv Read-only view of already-resolved type bindings in the current scope. + * @param declarationTypeNodes Maps `scope\0varName` to the original declaration's type + * annotation AST node. Allows extracting generic type arguments (e.g., T from Result) + * that are stripped during normal TypeEnv extraction. + * @param scope Current scope key (e.g. `"process@42"`) for declarationTypeNodes lookups. */ +export type PatternBindingExtractor = ( + node: SyntaxNode, + scopeEnv: ReadonlyMap, + declarationTypeNodes: ReadonlyMap, + scope: string, +) => { varName: string; typeName: string } | undefined; + /** Per-language type extraction configuration */ export interface LanguageTypeConfig { /** Node types that represent typed declarations for this language */ @@ -66,4 +83,10 @@ export interface LanguageTypeConfig { * Called on declaration/assignment nodes; returns {lhs, rhs} when the RHS is a bare identifier * and the LHS has no resolved type yet. Language-specific because AST shapes differ widely. */ extractPendingAssignment?: PendingAssignmentExtractor; + /** Extract a typed variable binding from a pattern-matching construct. + * Called on every AST node; returns { varName, typeName } when the node introduces a new + * typed variable via pattern matching (e.g. `if let Some(x) = opt`, `x instanceof T t`). + * The extractor receives the current scope's resolved bindings (read-only) to look up the + * source variable's type. Returns undefined for non-matching nodes or unknown source types. */ + extractPatternBinding?: PatternBindingExtractor; } diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index 7f3f55b8d8..9229ae6539 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -921,6 +921,73 @@ export const extractReceiverName = ( return undefined; }; +/** + * Extract the raw receiver AST node for a member call. + * Unlike extractReceiverName, this returns the receiver node regardless of its type — + * including call_expression / method_invocation nodes that appear in chained calls + * like `svc.getUser().save()`. + * + * Returns undefined when the call is not a member call or when no receiver node + * can be found (e.g. top-level free calls). + */ +export const extractReceiverNode = ( + nameNode: SyntaxNode, +): SyntaxNode | undefined => { + const parent = nameNode.parent; + if (!parent) return undefined; + + const callNode = parent.parent ?? parent; + + let receiver: SyntaxNode | null = null; + + receiver = parent.childForFieldName('object') + ?? parent.childForFieldName('value') + ?? parent.childForFieldName('operand') + ?? parent.childForFieldName('expression') + ?? parent.childForFieldName('argument'); + + if (!receiver && callNode.type === 'method_invocation') { + receiver = callNode.childForFieldName('object'); + } + + if (!receiver && (callNode.type === 'member_call_expression' || callNode.type === 'nullsafe_member_call_expression')) { + receiver = callNode.childForFieldName('object'); + } + + if (!receiver && parent.type === 'call') { + receiver = parent.childForFieldName('receiver'); + } + + if (!receiver && (parent.type === 'scoped_call_expression' || callNode.type === 'scoped_call_expression')) { + const scopedCall = parent.type === 'scoped_call_expression' ? parent : callNode; + receiver = scopedCall.childForFieldName('scope'); + if (receiver?.type === 'relative_scope') { + receiver = receiver.firstChild; + } + } + + if (!receiver && parent.type === 'member_binding_expression') { + const condAccess = parent.parent; + if (condAccess?.type === 'conditional_access_expression') { + receiver = condAccess.firstNamedChild; + } + } + + if (!receiver && parent.type === 'navigation_suffix') { + const navExpr = parent.parent; + if (navExpr?.type === 'navigation_expression') { + for (const child of navExpr.children) { + if (child.isNamed && child !== parent) { + receiver = child; + break; + } + } + } + } + + return receiver ?? undefined; +}; + export const isVerboseIngestionEnabled = (): boolean => { const raw = process.env.GITNEXUS_VERBOSE; if (!raw) return false; @@ -928,6 +995,106 @@ export const isVerboseIngestionEnabled = (): boolean => { return value === '1' || value === 'true' || value === 'yes'; }; +// ── Chained-call extraction ─────────────────────────────────────────────── + +/** Node types representing call expressions across supported languages. */ +export const CALL_EXPRESSION_TYPES = new Set([ + 'call_expression', // TS/JS/C/C++/Go/Rust + 'method_invocation', // Java + 'member_call_expression', // PHP + 'nullsafe_member_call_expression', // PHP ?. + 'call', // Python/Ruby + 'invocation_expression', // C# +]); + +/** + * Hard limit on chain depth to prevent runaway recursion. + * For `a.b().c().d()`, the chain has depth 2 (b and c before d). + */ +export const MAX_CHAIN_DEPTH = 3; + +/** + * Walk a receiver AST node that is itself a call expression, accumulating the + * chain of intermediate method names up to MAX_CHAIN_DEPTH. + * + * For `svc.getUser().save()`, called with the receiver of `save` (getUser() call): + * returns { chain: ['getUser'], baseReceiverName: 'svc' } + * + * For `a.b().c().d()`, called with the receiver of `d` (c() call): + * returns { chain: ['b', 'c'], baseReceiverName: 'a' } + */ +export function extractCallChain( + receiverCallNode: SyntaxNode, +): { chain: string[]; baseReceiverName: string | undefined } | undefined { + const chain: string[] = []; + let current: SyntaxNode = receiverCallNode; + + while (CALL_EXPRESSION_TYPES.has(current.type) && chain.length < MAX_CHAIN_DEPTH) { + // Extract the method name from this call node. + const funcNode = current.childForFieldName?.('function') + ?? current.childForFieldName?.('name') + ?? current.childForFieldName?.('method'); // Ruby `call` node + let methodName: string | undefined; + let innerReceiver: SyntaxNode | null = null; + if (funcNode) { + // member_expression / attribute: last named child is the method identifier + methodName = funcNode.lastNamedChild?.text ?? funcNode.text; + } + // Kotlin/Swift: call_expression exposes callee as firstNamedChild, not a field. + // navigation_expression: method name is in navigation_suffix → simple_identifier. + 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; + // The receiver is the part of navigation_expression before the suffix + 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(methodName); // build chain outermost-last + + // Walk into the receiver of this call to continue the chain + if (!innerReceiver && funcNode) { + innerReceiver = funcNode.childForFieldName?.('object') + ?? funcNode.childForFieldName?.('value') + ?? funcNode.childForFieldName?.('operand') + ?? funcNode.childForFieldName?.('expression'); + } + // Java method_invocation: object field is on the call node + if (!innerReceiver && current.type === 'method_invocation') { + innerReceiver = current.childForFieldName?.('object'); + } + // PHP member_call_expression + if (!innerReceiver && (current.type === 'member_call_expression' || current.type === 'nullsafe_member_call_expression')) { + innerReceiver = current.childForFieldName?.('object'); + } + // Ruby `call` node: receiver field is on the call node itself + if (!innerReceiver && current.type === 'call') { + innerReceiver = current.childForFieldName?.('receiver'); + } + + if (!innerReceiver) break; + + if (CALL_EXPRESSION_TYPES.has(innerReceiver.type)) { + current = innerReceiver; // continue walking + } else { + // Reached a simple identifier — the base receiver + return { chain, baseReceiverName: innerReceiver.text || undefined }; + } + } + + return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined; +} + diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 9754b51cf8..7887657624 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -24,7 +24,7 @@ try { Swift = _require('tree-sitter-swift'); } catch {} // tree-sitter-kotlin is an optionalDependency — may not be installed let Kotlin: any = null; try { Kotlin = _require('tree-sitter-kotlin'); } catch {} -import { +import { getLanguageFromFilename, FUNCTION_NODE_TYPES, extractFunctionName, @@ -34,7 +34,10 @@ import { extractMethodSignature, countCallArguments, inferCallForm, - extractReceiverName + extractReceiverName, + extractReceiverNode, + CALL_EXPRESSION_TYPES, + extractCallChain, } from '../utils.js'; import { buildTypeEnv } from '../type-env.js'; import type { ConstructorBinding } from '../type-env.js'; @@ -107,6 +110,14 @@ export interface ExtractedCall { receiverName?: string; /** 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' + * Length is capped at MAX_CHAIN_DEPTH (3). + */ + receiverCallChain?: string[]; } export interface ExtractedHeritage { @@ -987,8 +998,36 @@ const processFileGroup = ( const sourceId = findEnclosingFunctionId(callNode, file.path) || generateId('File', file.path); const callForm = inferCallForm(callNode, callNameNode); - const receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined; - const receiverTypeName = receiverName ? typeEnv.lookup(receiverName, callNode) : undefined; + let receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined; + let receiverTypeName = receiverName ? typeEnv.lookup(receiverName, callNode) : undefined; + let receiverCallChain: string[] | undefined; + + // When the receiver is a call_expression (e.g. svc.getUser().save()), + // extractReceiverName returns undefined because it refuses complex expressions. + // Instead, walk the receiver node to build a call chain for deferred resolution. + // We capture the base receiver name so processCallsFromExtracted can look it up + // from constructor bindings. receiverTypeName is intentionally left unset here — + // the chain resolver in processCallsFromExtracted needs the base type as input and + // produces the final receiver type as output. + 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. + 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. + if (receiverName) { + receiverTypeName = typeEnv.lookup(receiverName, callNode); + } + } + } + } + result.calls.push({ filePath: file.path, calledName, @@ -997,6 +1036,7 @@ const processFileGroup = ( ...(callForm !== undefined ? { callForm } : {}), ...(receiverName !== undefined ? { receiverName } : {}), ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), + ...(receiverCallChain !== undefined ? { receiverCallChain } : {}), }); } } diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/IsPatternProj.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/IsPatternProj.csproj new file mode 100644 index 0000000000..ec2cce1432 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/IsPatternProj.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/models/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/models/Repo.cs new file mode 100644 index 0000000000..33d88e5a6d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/models/Repo.cs @@ -0,0 +1,6 @@ +namespace IsPattern.Models; + +public class Repo +{ + public void Save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/models/User.cs new file mode 100644 index 0000000000..5a742f216e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/models/User.cs @@ -0,0 +1,6 @@ +namespace IsPattern.Models; + +public class User +{ + public void Save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/services/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/services/App.cs new file mode 100644 index 0000000000..2a28ba7400 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/services/App.cs @@ -0,0 +1,14 @@ +using IsPattern.Models; + +namespace IsPattern.Services; + +public class App +{ + public void Process(object obj) + { + if (obj is User user) + { + user.Save(); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-chain-call/App.java b/gitnexus/test/fixtures/lang-resolution/java-chain-call/App.java new file mode 100644 index 0000000000..8e58ee1ca6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-chain-call/App.java @@ -0,0 +1,8 @@ +import services.UserService; + +public class App { + public static void processUser() { + UserService svc = new UserService(); + svc.getUser().save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-chain-call/models/Repo.java b/gitnexus/test/fixtures/lang-resolution/java-chain-call/models/Repo.java new file mode 100644 index 0000000000..4908ace6de --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-chain-call/models/Repo.java @@ -0,0 +1,7 @@ +package models; + +public class Repo { + public boolean save() { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-chain-call/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-chain-call/models/User.java new file mode 100644 index 0000000000..e8dacc1360 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-chain-call/models/User.java @@ -0,0 +1,7 @@ +package models; + +public class User { + public boolean save() { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-chain-call/services/UserService.java b/gitnexus/test/fixtures/lang-resolution/java-chain-call/services/UserService.java new file mode 100644 index 0000000000..fe18a97b0e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-chain-call/services/UserService.java @@ -0,0 +1,9 @@ +package services; + +import models.User; + +public class UserService { + public User getUser() { + return new User(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-enum-static-call/src/App.java b/gitnexus/test/fixtures/lang-resolution/java-enum-static-call/src/App.java new file mode 100644 index 0000000000..587716581c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-enum-static-call/src/App.java @@ -0,0 +1,6 @@ +public class App { + public void process() { + Status s = Status.fromCode(200); + s.label(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-enum-static-call/src/Status.java b/gitnexus/test/fixtures/lang-resolution/java-enum-static-call/src/Status.java new file mode 100644 index 0000000000..a533cbf07e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-enum-static-call/src/Status.java @@ -0,0 +1,12 @@ +public enum Status { + OK, + ERROR; + + public static Status fromCode(int code) { + return code == 200 ? OK : ERROR; + } + + public String label() { + return this.name().toLowerCase(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/models/Repo.java b/gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/models/Repo.java new file mode 100644 index 0000000000..acd3d3991a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/models/Repo.java @@ -0,0 +1,5 @@ +package models; + +public class Repo { + public void save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/models/User.java new file mode 100644 index 0000000000..4bb7299936 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/models/User.java @@ -0,0 +1,5 @@ +package models; + +public class User { + public void save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/services/App.java b/gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/services/App.java new file mode 100644 index 0000000000..fd23b5f42e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/services/App.java @@ -0,0 +1,12 @@ +package services; + +import models.User; +import models.Repo; + +public class App { + public void process(Object obj) { + if (obj instanceof User user) { + user.save(); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/App.kt new file mode 100644 index 0000000000..a15d839a63 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/App.kt @@ -0,0 +1,7 @@ +import models.User +import services.UserService + +fun processUser() { + val svc = UserService() + svc.getUser().save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/Repo.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/Repo.kt new file mode 100644 index 0000000000..8e31ab2a04 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/Repo.kt @@ -0,0 +1,5 @@ +package models + +class Repo { + fun save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/User.kt new file mode 100644 index 0000000000..494376e42e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/User.kt @@ -0,0 +1,5 @@ +package models + +class User { + fun save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/UserService.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/UserService.kt new file mode 100644 index 0000000000..e091d77a9b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/UserService.kt @@ -0,0 +1,9 @@ +package services + +import models.User + +class UserService { + fun getUser(): User { + return User() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/python-class-annotations/repo.py b/gitnexus/test/fixtures/lang-resolution/python-class-annotations/repo.py index 18ce75c496..792eaf081d 100644 --- a/gitnexus/test/fixtures/lang-resolution/python-class-annotations/repo.py +++ b/gitnexus/test/fixtures/lang-resolution/python-class-annotations/repo.py @@ -1,3 +1,5 @@ class Repo: + name: str = "" + def save(self): return False diff --git a/gitnexus/test/fixtures/lang-resolution/python-class-annotations/user.py b/gitnexus/test/fixtures/lang-resolution/python-class-annotations/user.py index a9220e7448..71899ec86a 100644 --- a/gitnexus/test/fixtures/lang-resolution/python-class-annotations/user.py +++ b/gitnexus/test/fixtures/lang-resolution/python-class-annotations/user.py @@ -1,3 +1,5 @@ class User: + name: str = "" + def save(self): return True diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/app.rb new file mode 100644 index 0000000000..6b15a14bc8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/app.rb @@ -0,0 +1,8 @@ +require_relative './user_service' + +class App + def process + svc = UserService.new + svc.get_user.save + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/repo.rb b/gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/repo.rb new file mode 100644 index 0000000000..3e78d628c6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/repo.rb @@ -0,0 +1,5 @@ +class Repo + def save + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/user.rb b/gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/user.rb new file mode 100644 index 0000000000..98ecf7994a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/user.rb @@ -0,0 +1,5 @@ +class User + def save + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/user_service.rb b/gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/user_service.rb new file mode 100644 index 0000000000..1b1b87524d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/user_service.rb @@ -0,0 +1,8 @@ +require_relative './user' + +class UserService + # @return [User] + def get_user + User.new + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/error.rs b/gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/error.rs new file mode 100644 index 0000000000..de712550dd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/error.rs @@ -0,0 +1,7 @@ +pub struct AppError { + pub code: i32, +} + +impl AppError { + pub fn report(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/main.rs new file mode 100644 index 0000000000..7c63893b5e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/main.rs @@ -0,0 +1,18 @@ +mod user; +mod error; +use crate::user::User; +use crate::error::AppError; + +fn handle_err(res: Result) { + if let Err(e) = res { + e.report(); + } +} + +fn handle_ok(res: Result) { + if let Ok(user) = res { + user.save(); + } +} + +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/repo.rs b/gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/repo.rs new file mode 100644 index 0000000000..bc1ad22b0b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/repo.rs @@ -0,0 +1,7 @@ +pub struct Repo { + pub name: String, +} + +impl Repo { + pub fn save(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/user.rs new file mode 100644 index 0000000000..706b245583 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/user.rs @@ -0,0 +1,7 @@ +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/mod.rs b/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/mod.rs new file mode 100644 index 0000000000..4e7b9dc71e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/mod.rs @@ -0,0 +1 @@ +// Placeholder — actual module structure is in src/ diff --git a/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/repo.rs b/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/repo.rs new file mode 100644 index 0000000000..7ed292fe72 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/repo.rs @@ -0,0 +1 @@ +// Placeholder — actual Repo definition is in src/repo.rs diff --git a/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/user.rs new file mode 100644 index 0000000000..d6e967f0dd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/user.rs @@ -0,0 +1 @@ +// Placeholder — actual User definition is in src/user.rs diff --git a/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/main.rs new file mode 100644 index 0000000000..548b7628c8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/main.rs @@ -0,0 +1,11 @@ +mod user; +mod repo; +use crate::user::User; + +fn process(opt: Option) { + if let Some(user) = opt { + user.save(); + } +} + +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/repo.rs b/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/repo.rs new file mode 100644 index 0000000000..bc1ad22b0b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/repo.rs @@ -0,0 +1,7 @@ +pub struct Repo { + pub name: String, +} + +impl Repo { + pub fn save(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/user.rs new file mode 100644 index 0000000000..706b245583 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/user.rs @@ -0,0 +1,7 @@ +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-chain-call/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-chain-call/app.ts new file mode 100644 index 0000000000..4d77b4620d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-chain-call/app.ts @@ -0,0 +1,6 @@ +import { UserService } from './services/UserService'; + +export function processUser(): void { + const svc = new UserService(); + svc.getUser().save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-chain-call/models/Repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-chain-call/models/Repo.ts new file mode 100644 index 0000000000..340937eb35 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-chain-call/models/Repo.ts @@ -0,0 +1,3 @@ +export class Repo { + save(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-chain-call/models/User.ts b/gitnexus/test/fixtures/lang-resolution/typescript-chain-call/models/User.ts new file mode 100644 index 0000000000..a0a791e8e5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-chain-call/models/User.ts @@ -0,0 +1,3 @@ +export class User { + save(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-chain-call/services/UserService.ts b/gitnexus/test/fixtures/lang-resolution/typescript-chain-call/services/UserService.ts new file mode 100644 index 0000000000..510f536643 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-chain-call/services/UserService.ts @@ -0,0 +1,7 @@ +import { User } from '../models/User'; + +export class UserService { + getUser(): User { + return new User(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/app.ts new file mode 100644 index 0000000000..41e8275a1e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/app.ts @@ -0,0 +1,12 @@ +import { User } from './models/User'; +import { Repo } from './models/Repo'; + +// Both 'user' and 'repo' are created via constructor inference (no type annotation). +// The enclosing scope is 'run@0', with varNames 'user' and 'repo'. +// user.save() must resolve to User#save and repo.save() must resolve to Repo#save. +export function run(): void { + const user = new User(); + const repo = new Repo(); + user.save(); + repo.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/db/Cache.ts b/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/db/Cache.ts new file mode 100644 index 0000000000..15aa58789e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/db/Cache.ts @@ -0,0 +1,3 @@ +export class Cache { + store(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/db/Database.ts b/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/db/Database.ts new file mode 100644 index 0000000000..f45b31d9a9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/db/Database.ts @@ -0,0 +1,3 @@ +export class Database { + persist(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/models/Repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/models/Repo.ts new file mode 100644 index 0000000000..19631246b7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/models/Repo.ts @@ -0,0 +1,5 @@ +export class Repo { + save(): boolean { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/models/User.ts b/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/models/User.ts new file mode 100644 index 0000000000..e2af97ca77 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/models/User.ts @@ -0,0 +1,5 @@ +export class User { + save(): boolean { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-static-chain/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-static-chain/app.ts new file mode 100644 index 0000000000..7beccfd559 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-static-chain/app.ts @@ -0,0 +1,7 @@ +import { UserService } from './services/UserService'; + +// Chain base is a class name, not a variable. +// Requires class-as-receiver fallback on the chain base resolution. +export function processUser(): void { + UserService.findUser().save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-static-chain/models/Repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-static-chain/models/Repo.ts new file mode 100644 index 0000000000..340937eb35 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-static-chain/models/Repo.ts @@ -0,0 +1,3 @@ +export class Repo { + save(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-static-chain/models/User.ts b/gitnexus/test/fixtures/lang-resolution/typescript-static-chain/models/User.ts new file mode 100644 index 0000000000..a0a791e8e5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-static-chain/models/User.ts @@ -0,0 +1,3 @@ +export class User { + save(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-static-chain/services/UserService.ts b/gitnexus/test/fixtures/lang-resolution/typescript-static-chain/services/UserService.ts new file mode 100644 index 0000000000..54f12c1398 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-static-chain/services/UserService.ts @@ -0,0 +1,7 @@ +import { User } from '../models/User'; + +export class UserService { + static findUser(): User { + return new User(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/app.ts new file mode 100644 index 0000000000..23b1fda90f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/app.ts @@ -0,0 +1,7 @@ +import { UserService } from './services/UserService'; + +// svc is typed via parameter annotation, NOT constructor binding. +// The chain base type must come from typeEnv, not receiverMap. +export function processUser(svc: UserService): void { + svc.getUser().save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/models/Repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/models/Repo.ts new file mode 100644 index 0000000000..340937eb35 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/models/Repo.ts @@ -0,0 +1,3 @@ +export class Repo { + save(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/models/User.ts b/gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/models/User.ts new file mode 100644 index 0000000000..a0a791e8e5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/models/User.ts @@ -0,0 +1,3 @@ +export class User { + save(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/services/UserService.ts b/gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/services/UserService.ts new file mode 100644 index 0000000000..510f536643 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/services/UserService.ts @@ -0,0 +1,7 @@ +import { User } from '../models/User'; + +export class UserService { + getUser(): User { + return new User(); + } +} diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index f28f6efcc5..3380d270a7 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -888,3 +888,47 @@ describe('C# assignment chain + is-pattern coexistence', () => { expect(wrongCall).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// C# is-pattern disambiguation: `if (obj is User user)` should bind user → User +// and resolve user.Save() to User#Save, NOT Repo#Save. +// Validates the Phase 5.2 is_pattern_expression extraction in extractDeclaration. +// --------------------------------------------------------------------------- + +describe('C# is-pattern type binding disambiguation (Phase 5.2)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-is-pattern'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes each with a Save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.Save() inside if (obj is User user) to User#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'Save' && + c.source === 'Process' && + c.targetFilePath?.includes('User.cs'), + ); + expect(userSave).toBeDefined(); + }); + + it('does NOT resolve user.Save() to Repo#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'Save' && + c.source === 'Process' && + c.targetFilePath?.includes('Repo.cs'), + ); + expect(repoSave).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index eb2dfd2325..0d6ee1c847 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -777,3 +777,144 @@ describe('Java Optional receiver resolution via wrapper unwrapping', () => expect(userSave!.targetFilePath).not.toBe(repoSave!.targetFilePath); }); }); + +// --------------------------------------------------------------------------- +// Chained method call resolution: svc.getUser().save() +// The receiver of save() is a method_invocation (getUser()), not a simple identifier. +// Resolution must walk the chain: getUser() returns User, so save() → User#save. +// --------------------------------------------------------------------------- + +describe('Java chained method call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-chain-call'), + () => {}, + ); + }, 60000); + + it('detects User, Repo and UserService classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('User'); + expect(classes).toContain('Repo'); + expect(classes).toContain('UserService'); + }); + + it('detects save methods on both User and Repo', () => { + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('detects getUser method on UserService', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('getUser'); + }); + + it('resolves svc.getUser().save() to User#save, NOT Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && + c.source === 'processUser' && + c.targetFilePath.includes('User.java'), + ); + const repoSave = calls.find(c => + c.target === 'save' && + c.source === 'processUser' && + c.targetFilePath.includes('Repo.java'), + ); + expect(userSave).toBeDefined(); + expect(repoSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Java 16+ instanceof pattern variable: `if (obj instanceof User user)` +// Phase 5.2: extractPatternBinding on instanceof_expression binds user → User. +// Disambiguation: User.save vs Repo.save — only User.save should be called. +// --------------------------------------------------------------------------- + +describe('Java instanceof pattern variable resolution (Phase 5.2)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-instanceof-pattern'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.save() inside if (obj instanceof User user) to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && + c.source === 'process' && + c.targetFilePath.includes('User.java'), + ); + expect(userSave).toBeDefined(); + }); + + it('does NOT resolve user.save() to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && + c.source === 'process' && + c.targetFilePath.includes('Repo.java'), + ); + expect(repoSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Enum static method calls: Status.fromCode(200) should resolve via +// class-as-receiver with Enum type included in the filter. +// --------------------------------------------------------------------------- + +describe('Java enum static method call resolution (Phase 5 review fix)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-enum-static-call'), + () => {}, + ); + }, 60000); + + it('detects Status as an Enum and App as a Class', () => { + expect(getNodesByLabel(result, 'Enum')).toContain('Status'); + expect(getNodesByLabel(result, 'Class')).toContain('App'); + }); + + it('detects fromCode and label methods on Status', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('fromCode'); + expect(methods).toContain('label'); + }); + + it('resolves Status.fromCode(200) to Status#fromCode via class-as-receiver', () => { + const calls = getRelationships(result, 'CALLS'); + const fromCodeCall = calls.find(c => + c.target === 'fromCode' && + c.source === 'process' && + c.targetFilePath?.includes('Status.java'), + ); + expect(fromCodeCall).toBeDefined(); + }); + + it('resolves s.label() to Status#label', () => { + const calls = getRelationships(result, 'CALLS'); + const labelCall = calls.find(c => + c.target === 'label' && + c.source === 'process' && + c.targetFilePath?.includes('Status.java'), + ); + expect(labelCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index 5f51b73443..354234195d 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -819,3 +819,53 @@ describe('Kotlin assignment chain inside class method', () => { expect(wrongCall).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Chained method calls: svc.getUser().save() +// Tests that Kotlin's navigation_expression → navigation_suffix AST structure +// is correctly handled by extractCallChain (Phase 5 review Finding 1, Round 3). +// --------------------------------------------------------------------------- + +describe('Kotlin chained method call resolution (Phase 5 review fix)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-chain-call'), + () => {}, + ); + }, 60000); + + it('detects User, Repo, and UserService classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('User'); + expect(classes).toContain('Repo'); + expect(classes).toContain('UserService'); + }); + + it('detects getUser and save functions', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns).toContain('getUser'); + expect(fns).toContain('save'); + }); + + it('resolves svc.getUser().save() to User#save via chain resolution', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && + c.source === 'processUser' && + c.targetFilePath?.includes('User.kt'), + ); + expect(userSave).toBeDefined(); + }); + + it('does NOT resolve svc.getUser().save() to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && + c.source === 'processUser' && + c.targetFilePath?.includes('Repo.kt'), + ); + expect(repoSave).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 2ef17f755f..eabb813ddd 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -707,17 +707,17 @@ describe('Python static/classmethod class resolution (issue #289)', () => { expect(createCall).toBeDefined(); }); - it('does not emit ambiguous find_user() when both classes define it (known limitation)', () => { - // UserService.find_user() and AdminService.find_user() are ambiguous — the pipeline - // refuses to guess. Static method calls like ClassName.method() don't have a typed - // receiver variable, so receiver-constrained disambiguation doesn't apply. - // This is expected: no false edges is better than wrong edges. + it('resolves find_user() via class-as-receiver for static method calls', () => { + // UserService.find_user() and AdminService.find_user() are both resolved because + // the class name (UserService / AdminService) is used as the receiver type for + // disambiguation. Both find_user methods share the same nodeId (same file, same name) + // so exactly 1 CALLS edge is emitted — which is correct (not ambiguous, not missing). const calls = getRelationships(result, 'CALLS'); const findCalls = calls.filter(c => c.target === 'find_user' && c.source === 'process', ); - // Either 0 (refused ambiguous) or 2 (both resolved) — not 1 (wrong guess) - expect(findCalls.length === 0 || findCalls.length === 2).toBe(true); + expect(findCalls.length).toBe(1); + expect(findCalls[0].targetFilePath).toContain('service.py'); }); }); diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 96eba10209..86294fd4d4 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -755,3 +755,59 @@ describe('Ruby YARD generic type annotations (Hash)', () => { expect(findCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Chained method calls: svc.get_user.save +// Tests that Ruby's `call` node uses `method` and `receiver` fields correctly +// for chain extraction — the tree-sitter-ruby grammar differs from other languages. +// --------------------------------------------------------------------------- + +describe('Ruby chained method call resolution (Phase 5 review fix)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-chain-call'), + () => {}, + ); + }, 60000); + + it('detects User, Repo, UserService and App classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('User'); + expect(classes).toContain('Repo'); + expect(classes).toContain('UserService'); + expect(classes).toContain('App'); + }); + + it('detects save methods on both User and Repo', () => { + const methods = getNodesByLabel(result, 'Method'); + const saveMethods = methods.filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('detects get_user method on UserService', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('get_user'); + }); + + it('resolves svc.get_user.save to User#save via chain resolution', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && + c.source === 'process' && + c.targetFilePath?.includes('user.rb'), + ); + expect(userSave).toBeDefined(); + }); + + it('does NOT resolve svc.get_user.save to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && + c.source === 'process' && + c.targetFilePath?.includes('repo.rb'), + ); + expect(repoSave).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 51450f66f1..610c5b7873 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -923,3 +923,100 @@ describe('Rust Option receiver resolution via wrapper unwrapping', () => { expect(repoSave).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// if let Some(user) = opt — Phase 5.2 pattern binding: unwrap Option +// `opt: Option` → Option is stored as "User" in TypeEnv via +// NULLABLE_WRAPPER_TYPES. extractPatternBinding maps `user` → "User". +// Disambiguation: User.save vs Repo.save — only User.save should be called. +// --------------------------------------------------------------------------- + +describe('Rust if-let Some(x) = opt pattern binding (Phase 5.2)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-if-let-unwrap'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs each with a save function', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Struct')).toContain('Repo'); + const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves user.save() inside if-let Some(user) = opt to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && + c.source === 'process' && + c.targetFilePath?.includes('user.rs'), + ); + expect(userSave).toBeDefined(); + }); + + it('does NOT resolve user.save() to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && + c.source === 'process' && + c.targetFilePath?.includes('repo.rs'), + ); + expect(repoSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Rust if-let Err(e) = res pattern binding (Phase 5 review fix) +// Result → Err(e) should type e as AppError (typeArgs[1]). +// Also tests Ok(user) in the same fixture to verify both arms work. +// --------------------------------------------------------------------------- + +describe('Rust if-let Err(e) pattern binding (Phase 5 review fix)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-err-unwrap'), + () => {}, + ); + }, 60000); + + it('detects User and AppError structs', () => { + const structs = getNodesByLabel(result, 'Struct'); + expect(structs).toContain('User'); + expect(structs).toContain('AppError'); + }); + + it('resolves e.report() inside if-let Err(e) to AppError#report', () => { + const calls = getRelationships(result, 'CALLS'); + const reportCall = calls.find(c => + c.target === 'report' && + c.source === 'handle_err' && + c.targetFilePath?.includes('error.rs'), + ); + expect(reportCall).toBeDefined(); + }); + + it('resolves user.save() inside if-let Ok(user) to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && + c.source === 'handle_ok' && + c.targetFilePath?.includes('user.rs'), + ); + expect(saveCall).toBeDefined(); + }); + + it('does NOT resolve e.report() to User#save (no cross-contamination)', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongCall = calls.find(c => + c.target === 'save' && + c.source === 'handle_err', + ); + expect(wrongCall).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index f95cdb4afc..04e19b96c7 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -1211,3 +1211,219 @@ describe('TypeScript nullable + assignment chain combined', () => { }); }); +// --------------------------------------------------------------------------- +// Chained method call resolution: svc.getUser().save() +// The receiver of save() is a call_expression (getUser()), not a simple identifier. +// Resolution must walk the chain: getUser() returns User, so save() → User#save. +// --------------------------------------------------------------------------- + +describe('TypeScript chained method call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-chain-call'), + () => {}, + ); + }, 60000); + + it('detects User, Repo and UserService classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('User'); + expect(classes).toContain('Repo'); + expect(classes).toContain('UserService'); + }); + + it('detects save methods on both User and Repo', () => { + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('detects getUser method on UserService', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('getUser'); + }); + + it('resolves svc.getUser().save() to User#save, NOT Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && + c.source === 'processUser' && + c.targetFilePath.includes('User'), + ); + const repoSave = calls.find(c => + c.target === 'save' && + c.source === 'processUser' && + c.targetFilePath.includes('Repo'), + ); + expect(userSave).toBeDefined(); + expect(repoSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Overloaded receiver: two classes with the same method name (save) must not +// collide in the receiverKey map. The fix preserves @startIndex in the key so +// User.save@idx1 and Repo.save@idx2 remain distinct even when the enclosing +// scope funcName is the same. +// --------------------------------------------------------------------------- + +describe('TypeScript overloaded-receiver resolution (receiverKey collision fix)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-overloaded-receiver'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.save() to User#save (models/User.ts), not Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.targetFilePath.includes('User'), + ); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('run'); + // Negative: must not resolve to Repo#save + const wrongSave = calls.find(c => + c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('Repo'), + ); + // If only one save target resolves to User (not Repo), we correctly exclude Repo + expect(userSave!.targetFilePath).toContain('User'); + }); + + it('resolves repo.save() to Repo#save (models/Repo.ts), not User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && c.targetFilePath.includes('Repo'), + ); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('run'); + expect(repoSave!.targetFilePath).toContain('Repo'); + }); + + it('emits exactly 2 save() CALLS edges — one per class', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + const targets = saveCalls.map(c => c.targetFilePath).sort(); + expect(targets[0]).toContain('Repo'); + expect(targets[1]).toContain('User'); + }); + + it('resolves constructor calls for both User and Repo', () => { + const calls = getRelationships(result, 'CALLS'); + const userCtor = calls.find(c => c.target === 'User' && c.targetLabel === 'Class'); + const repoCtor = calls.find(c => c.target === 'Repo' && c.targetLabel === 'Class'); + expect(userCtor).toBeDefined(); + expect(repoCtor).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Typed parameter chain: svc.getUser().save() where svc is a parameter with +// a type annotation (not a constructor binding). Tests that the worker path +// consults typeEnv for chain base receivers (Phase 5 review Finding 1). +// --------------------------------------------------------------------------- + +describe('TypeScript typed-parameter chain call resolution (Phase 5 review fix)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-typed-param-chain'), + () => {}, + ); + }, 60000); + + it('detects User, Repo, and UserService classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('User'); + expect(classes).toContain('Repo'); + expect(classes).toContain('UserService'); + }); + + it('detects getUser and save methods', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('getUser'); + expect(methods).toContain('save'); + }); + + it('resolves svc.getUser().save() to User#save via parameter type annotation', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && + c.source === 'processUser' && + c.targetFilePath.includes('User'), + ); + expect(userSave).toBeDefined(); + }); + + it('does NOT resolve svc.getUser().save() to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && + c.source === 'processUser' && + c.targetFilePath.includes('Repo'), + ); + expect(repoSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Static chain: UserService.findUser().save() where the chain base is a class +// name (not a variable). Tests that the serial path applies class-as-receiver +// to chain base resolution (Phase 5 review Finding 2). +// --------------------------------------------------------------------------- + +describe('TypeScript static class-name chain call resolution (Phase 5 review fix)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-static-chain'), + () => {}, + ); + }, 60000); + + it('detects User, Repo, and UserService classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('User'); + expect(classes).toContain('Repo'); + expect(classes).toContain('UserService'); + }); + + it('detects static findUser and instance save methods', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('findUser'); + expect(methods).toContain('save'); + }); + + it('resolves UserService.findUser().save() to User#save via class-name chain base', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && + c.source === 'processUser' && + c.targetFilePath.includes('User'), + ); + expect(userSave).toBeDefined(); + }); + + it('does NOT resolve UserService.findUser().save() to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && + c.source === 'processUser' && + c.targetFilePath.includes('Repo'), + ); + expect(repoSave).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index 36d5e9eb91..f8d87533d0 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -476,6 +476,122 @@ describe('processCallsFromExtracted', () => { // ---- Scope-aware constructor bindings (Phase 3) ---- + it('receiverKey collision: same method name in different classes does not collide', async () => { + // User.save@100 and Repo.save@200 are two methods named "save" in different classes. + // Each has a local variable "db" pointing to a different type. + // Without @startIndex in the key, the second binding would overwrite the first. + ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); + ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); + ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { ownerId: 'Class:src/db/Database.ts:Database' }); + ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { ownerId: 'Class:src/db/Cache.ts:Cache' }); + ctx.importMap.set('src/models/User.ts', new Set(['src/db/Database.ts'])); + ctx.importMap.set('src/models/Repo.ts', new Set(['src/db/Cache.ts'])); + + // Two bindings: both enclosing scope is named "save" but at different startIndexes + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/models/User.ts', + bindings: [ + // save@100: inside User.save(), db = new Database() + { scope: 'save@100', varName: 'db', calleeName: 'Database' }, + ], + }, + { + filePath: 'src/models/Repo.ts', + bindings: [ + // save@200: inside Repo.save(), db = new Cache() + { scope: 'save@200', varName: 'db', calleeName: 'Cache' }, + ], + }, + ]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/models/User.ts', + calledName: 'query', + sourceId: 'Method:src/models/User.ts:save', + receiverName: 'db', + callForm: 'member', + }, + { + filePath: 'src/models/Repo.ts', + calledName: 'query', + sourceId: 'Method:src/models/Repo.ts:save', + receiverName: 'db', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); + + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(2); + const userQueryRel = rels.find(r => r.sourceId === 'Method:src/models/User.ts:save'); + const repoQueryRel = rels.find(r => r.sourceId === 'Method:src/models/Repo.ts:save'); + expect(userQueryRel?.targetId).toBe('Method:src/db/Database.ts:query'); + expect(repoQueryRel?.targetId).toBe('Method:src/db/Cache.ts:query'); + }); + + it('receiverKey collision: same scope funcName + same varName + same type resolves (non-ambiguous)', async () => { + // Two save@* scopes both bind "db" to the same type — not ambiguous, should resolve. + ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); + ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { ownerId: 'Class:src/db/Database.ts:Database' }); + ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts'])); + + const constructorBindings: FileConstructorBindings[] = [{ + filePath: 'src/service.ts', + bindings: [ + { scope: 'save@10', varName: 'db', calleeName: 'Database' }, + { scope: 'save@50', varName: 'db', calleeName: 'Database' }, + ], + }]; + + const calls: ExtractedCall[] = [{ + filePath: 'src/service.ts', + calledName: 'query', + sourceId: 'Method:src/service.ts:save', + receiverName: 'db', + callForm: 'member', + }]; + + await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); + + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Method:src/db/Database.ts:query'); + }); + + it('receiverKey collision: same scope funcName + same varName + different types → ambiguous, no CALLS edge', async () => { + // Two save@* scopes in the same file bind "db" to different types — truly ambiguous. + ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); + ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); + ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { ownerId: 'Class:src/db/Database.ts:Database' }); + ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { ownerId: 'Class:src/db/Cache.ts:Cache' }); + ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts', 'src/db/Cache.ts'])); + + const constructorBindings: FileConstructorBindings[] = [{ + filePath: 'src/service.ts', + bindings: [ + { scope: 'save@10', varName: 'db', calleeName: 'Database' }, + { scope: 'save@50', varName: 'db', calleeName: 'Cache' }, + ], + }]; + + const calls: ExtractedCall[] = [{ + filePath: 'src/service.ts', + calledName: 'query', + sourceId: 'Method:src/service.ts:save', + receiverName: 'db', + callForm: 'member', + }]; + + await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); + + // Ambiguous — different types for same funcName+varName, should not emit a CALLS edge + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(0); + }); + it('scope-aware bindings: same varName in different functions resolves to correct type', async () => { ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.symbols.add('src/models.ts', 'Repo', 'Class:src/models.ts:Repo', 'Class'); diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index ab7553f806..e5fd973c95 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -346,6 +346,12 @@ describe('buildTypeEnv', () => { expect(flatGet(env, 'user')).toBe('User'); }); + it('extracts type from standalone annotation without value (file scope)', () => { + const tree = parse('active_user: User', Python); + const { env } = buildTypeEnv(tree, 'python'); + expect(flatGet(env, 'active_user')).toBe('User'); + }); + it('extracts type from function parameters', () => { const tree = parse('def process(user: User, repo: Repository): pass', Python); const { env } = buildTypeEnv(tree, 'python'); @@ -1191,7 +1197,7 @@ class RepoService { expect(flatGet(env, 'item')).toBe('Config'); }); - it('does NOT extract binding from if let Some(x) = opt (requires generic unwrapping)', () => { + it('extracts binding from if let Some(x) = opt via Phase 5.2 pattern binding', () => { const tree = parse(` fn process(opt: Option) { if let Some(user) = opt { @@ -1200,8 +1206,9 @@ class RepoService { } `, Rust); const { env } = buildTypeEnv(tree, 'rust'); - // user's type is Option's inner type — requires generic unwrapping (Phase 3) - expect(flatGet(env, 'user')).toBeUndefined(); + // Option is unwrapped to "User" in TypeEnv via NULLABLE_WRAPPER_TYPES. + // extractPatternBinding maps `user` → "User" from the scopeEnv lookup for `opt`. + expect(flatGet(env, 'user')).toBe('User'); }); it('does NOT extract field bindings from struct pattern destructuring', () => { @@ -1245,6 +1252,93 @@ class RepoService { expect(flatGet(env, 'opt')).toBe('User'); expect(flatGet(env, 'user')).toBe('User'); }); + + it('Phase 5.2: extracts binding from if let Some(x) = opt where opt: Option', () => { + const tree = parse(` + fn process(opt: Option) { + if let Some(user) = opt { + user.save(); + } + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + // opt: Option → scopeEnv stores "User" (NULLABLE_WRAPPER_TYPES unwrapping) + // extractPatternBinding maps user → opt's type → "User" + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('Phase 5.2: does NOT extract binding when source variable is unknown', () => { + const tree = parse(` + fn process() { + if let Some(x) = unknown_var { + x.foo(); + } + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + // unknown_var is not in scopeEnv — conservative, produces no binding + expect(flatGet(env, 'x')).toBeUndefined(); + }); + + it('Phase 5.2: does NOT extract binding for non-Option/Result wrappers', () => { + const tree = parse(` + fn process(vec: Vec) { + if let SomeOtherVariant(x) = vec { + x.save(); + } + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + // SomeOtherVariant is not a known unwrap wrapper — no binding + expect(flatGet(env, 'x')).toBeUndefined(); + }); + }); + + describe('Java instanceof pattern variable (Phase 5.2)', () => { + it('extracts binding from x instanceof User user', () => { + const tree = parse(` + class App { + void process(Object obj) { + if (obj instanceof User user) { + user.save(); + } + } + } + `, Java); + const { env } = buildTypeEnv(tree, 'java'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('does NOT extract binding from plain instanceof without variable', () => { + const tree = parse(` + class App { + void process(Object obj) { + boolean b = obj instanceof User; + } + } + `, Java); + const { env } = buildTypeEnv(tree, 'java'); + // No pattern variable declared — no binding + expect(flatGet(env, 'b')).toBeUndefined(); + }); + + it('extracts correct type when multiple instanceof patterns exist', () => { + const tree = parse(` + class App { + void process(Object obj) { + if (obj instanceof User user) { + user.save(); + } + if (obj instanceof Repo repo) { + repo.save(); + } + } + } + `, Java); + const { env } = buildTypeEnv(tree, 'java'); + expect(flatGet(env, 'user')).toBe('User'); + expect(flatGet(env, 'repo')).toBe('Repo'); + }); }); describe('PHP', () => {