fix: address PR #409 review findings (P0-P3) and simplify import resolution API

Bug fixes (P0):
- Narrow Go /cmd/ entry-point detection to only match /main.go
- Fix Rust scoped grouped imports (use crate::models::{User, Repo}) resolution
- Filter PHP use function/use const from class-type namedImportMap bindings

Improvements (P1):
- Add C# resolveStandard fallback when .csproj discovery fails
- Change preprocessImportPath return type to string | null with caller guards
- Add Q_SIGNALS/Q_SLOTS (standard plural Qt macros)
- Fix stale "11 supported languages" comment → 13

API simplification (P2):
- Replace buildImportResolvers() factory with const importResolvers table
- Move configs onto ResolveCtx (extends ImportResolutionContext)
- Eliminate tsconfigPaths parameter threading through 6 non-TS resolvers
- Split utils.ts (1,476 lines) into ast-helpers.ts + call-analysis.ts + utils.ts
- Consolidate findChild/findChildByType into single source of truth
- Match multi-file import bindings to files by basename for namedImportMap

Cleanup (P3):
- EMPTY_INDEX returns shared frozen empty array instead of allocating per call
- Type appendKotlinWildcard parameter as SyntaxNode instead of any
- Document call-routing validation requirement on CallRouter type

Tests:
- Add unit tests for preprocessImportPath (13 tests)
- Add integration tests: Rust scoped multi-file, PHP use function/const,
  C# without .csproj, Go cmd/ helper scoring (14 tests, 4 fixtures)

All 3579 tests pass.
This commit is contained in:
Gergo Magyar 2026-03-21 14:15:15 +00:00
parent 2c17a4642c
commit 76ed0fa53b
39 changed files with 1879 additions and 1392 deletions

View file

@ -9,7 +9,7 @@
* ----------------------------------|------------------------------------------|---------------------------
* tree-sitter-queries.ts | Query string + LANGUAGE_QUERIES entry | (required)
* export-detection.ts | ExportChecker function + table entry | (required)
* import-resolution.ts | Resolver in buildImportResolvers | resolveStandard(...)
* import-resolution.ts | Resolver in importResolvers | resolveStandard(...)
* import-resolution.ts | namedBindingExtractors entry | undefined
* call-routing.ts | callRouters entry | noRouting
* entry-point-scoring.ts | ENTRY_POINT_PATTERNS entry | []

View file

@ -0,0 +1,710 @@
import type Parser from 'tree-sitter';
import { SupportedLanguages } from '../../config/supported-languages.js';
import type { NodeLabel } from '../graph/types.js';
import { generateId } from '../../lib/utils.js';
import { extractSimpleTypeName } from './type-extractors/shared.js';
/** Tree-sitter AST node. Re-exported for use across ingestion modules. */
export type SyntaxNode = Parser.SyntaxNode;
/**
* Ordered list of definition capture keys for tree-sitter query matches.
* Used to extract the definition node from a capture map.
*/
export const DEFINITION_CAPTURE_KEYS = [
'definition.function',
'definition.class',
'definition.interface',
'definition.method',
'definition.struct',
'definition.enum',
'definition.namespace',
'definition.module',
'definition.trait',
'definition.impl',
'definition.type',
'definition.const',
'definition.static',
'definition.typedef',
'definition.macro',
'definition.union',
'definition.property',
'definition.record',
'definition.delegate',
'definition.annotation',
'definition.constructor',
'definition.template',
] as const;
/** Extract the definition node from a tree-sitter query capture map. */
export const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): SyntaxNode | null => {
for (const key of DEFINITION_CAPTURE_KEYS) {
if (captureMap[key]) return captureMap[key];
}
return null;
};
/**
* Node types that represent function/method definitions across languages.
* Used to find the enclosing function for a call site.
*/
export const FUNCTION_NODE_TYPES = new Set([
// TypeScript/JavaScript
'function_declaration',
'arrow_function',
'function_expression',
'method_definition',
'generator_function_declaration',
// Python
'function_definition',
// Common async variants
'async_function_declaration',
'async_arrow_function',
// Java
'method_declaration',
'constructor_declaration',
// C/C++
// 'function_definition' already included above
// Go
// 'method_declaration' already included from Java
// C#
'local_function_statement',
// Rust
'function_item',
'impl_item', // Methods inside impl blocks
// PHP
'anonymous_function',
// Kotlin
'lambda_literal',
// Swift
'init_declaration',
'deinit_declaration',
// Ruby
'method', // def foo
'singleton_method', // def self.foo
]);
/**
* Node types for standard function declarations that need C/C++ declarator handling.
* Used by extractFunctionName to determine how to extract the function name.
*/
export const FUNCTION_DECLARATION_TYPES = new Set([
'function_declaration',
'function_definition',
'async_function_declaration',
'generator_function_declaration',
'function_item',
]);
/** AST node types that represent a class-like container (for HAS_METHOD edge extraction) */
export const CLASS_CONTAINER_TYPES = new Set([
'class_declaration', 'abstract_class_declaration',
'interface_declaration', 'struct_declaration', 'record_declaration',
'class_specifier', 'struct_specifier',
'impl_item', 'trait_item', 'struct_item', 'enum_item',
'class_definition',
'trait_declaration',
'protocol_declaration',
// Ruby
'class',
'module',
// Kotlin
'object_declaration',
'companion_object',
]);
export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
class_declaration: 'Class',
abstract_class_declaration: 'Class',
interface_declaration: 'Interface',
struct_declaration: 'Struct',
struct_specifier: 'Struct',
class_specifier: 'Class',
class_definition: 'Class',
impl_item: 'Impl',
trait_item: 'Trait',
struct_item: 'Struct',
enum_item: 'Enum',
trait_declaration: 'Trait',
record_declaration: 'Record',
protocol_declaration: 'Interface',
class: 'Class',
module: 'Module',
object_declaration: 'Class',
companion_object: 'Class',
};
/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
* Kotlin grammar uses function_declaration for both top-level functions and class methods.
* Returns true when the captured definition node has a class_body ancestor. */
export function isKotlinClassMethod(captureNode: { parent?: any } | null | undefined): boolean {
let ancestor = captureNode?.parent;
while (ancestor) {
if (ancestor.type === 'class_body') return true;
ancestor = ancestor.parent;
}
return false;
}
/**
* C/C++: check if a Function capture is inside a class/struct body.
* If true, the function is already captured by @definition.method and should be skipped
* to prevent double-indexing in globalIndex.
*/
export function isCppDuplicateClassFunction(
functionNode: { parent?: any } | null | undefined,
nodeLabel: string,
language: SupportedLanguages,
): boolean {
if (nodeLabel !== 'Function') return false;
if (language !== SupportedLanguages.CPlusPlus && language !== SupportedLanguages.C) return false;
let ancestor = functionNode?.parent;
while (ancestor) {
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true;
ancestor = ancestor.parent;
}
return false;
}
/**
* Determine the graph node label from a tree-sitter capture map.
* Handles language-specific reclassification (C/C++ duplicate skipping, Kotlin Method promotion).
* Returns null if the capture should be skipped (import, call, C/C++ duplicate, missing name).
*/
export function getLabelFromCaptures(
captureMap: Record<string, any>,
language: SupportedLanguages,
): NodeLabel | null {
if (captureMap['import'] || captureMap['call']) return null;
if (!captureMap['name'] && !captureMap['definition.constructor']) return null;
if (captureMap['definition.function']) {
if (isCppDuplicateClassFunction(captureMap['definition.function'], 'Function', language)) return null;
if (language === SupportedLanguages.Kotlin && isKotlinClassMethod(captureMap['definition.function'])) return 'Method';
return 'Function';
}
if (captureMap['definition.class']) return 'Class';
if (captureMap['definition.interface']) return 'Interface';
if (captureMap['definition.method']) return 'Method';
if (captureMap['definition.struct']) return 'Struct';
if (captureMap['definition.enum']) return 'Enum';
if (captureMap['definition.namespace']) return 'Namespace';
if (captureMap['definition.module']) return 'Module';
if (captureMap['definition.trait']) return 'Trait';
if (captureMap['definition.impl']) return 'Impl';
if (captureMap['definition.type']) return 'TypeAlias';
if (captureMap['definition.const']) return 'Const';
if (captureMap['definition.static']) return 'Static';
if (captureMap['definition.typedef']) return 'Typedef';
if (captureMap['definition.macro']) return 'Macro';
if (captureMap['definition.union']) return 'Union';
if (captureMap['definition.property']) return 'Property';
if (captureMap['definition.record']) return 'Record';
if (captureMap['definition.delegate']) return 'Delegate';
if (captureMap['definition.annotation']) return 'Annotation';
if (captureMap['definition.constructor']) return 'Constructor';
if (captureMap['definition.template']) return 'Template';
return 'CodeElement';
}
/** Walk up AST to find enclosing class/struct/interface/impl, return its generateId or null.
* For Go method_declaration nodes, extracts receiver type (e.g. `func (u *User) Save()` User struct). */
export const findEnclosingClassId = (node: any, filePath: string): string | null => {
let current = node.parent;
while (current) {
// Go: method_declaration has a receiver parameter with the struct type
if (current.type === 'method_declaration') {
const receiver = current.childForFieldName?.('receiver');
if (receiver) {
// receiver is a parameter_list: (u *User) or (u User)
const paramDecl = receiver.namedChildren?.find?.((c: any) => c.type === 'parameter_declaration');
if (paramDecl) {
const typeNode = paramDecl.childForFieldName?.('type');
if (typeNode) {
// Unwrap pointer_type (*User → User)
const inner = typeNode.type === 'pointer_type' ? typeNode.firstNamedChild : typeNode;
if (inner && (inner.type === 'type_identifier' || inner.type === 'identifier')) {
return generateId('Struct', `${filePath}:${inner.text}`);
}
}
}
}
}
// Go: type_declaration wrapping a struct_type (type User struct { ... })
// field_declaration → field_declaration_list → struct_type → type_spec → type_declaration
if (current.type === 'type_declaration') {
const typeSpec = current.children?.find((c: any) => c.type === 'type_spec');
if (typeSpec) {
const typeBody = typeSpec.childForFieldName?.('type');
if (typeBody?.type === 'struct_type' || typeBody?.type === 'interface_type') {
const nameNode = typeSpec.childForFieldName?.('name');
if (nameNode) {
const label = typeBody.type === 'struct_type' ? 'Struct' : 'Interface';
return generateId(label, `${filePath}:${nameNode.text}`);
}
}
}
}
if (CLASS_CONTAINER_TYPES.has(current.type)) {
// Rust impl_item: for `impl Trait for Struct {}`, pick the type after `for`
if (current.type === 'impl_item') {
const children = current.children ?? [];
const forIdx = children.findIndex((c: any) => c.text === 'for');
if (forIdx !== -1) {
const nameNode = children.slice(forIdx + 1).find((c: any) =>
c.type === 'type_identifier' || c.type === 'identifier'
);
if (nameNode) {
return generateId('Impl', `${filePath}:${nameNode.text}`);
}
}
// Fall through: plain `impl Struct {}` — use first type_identifier below
}
const nameNode = current.childForFieldName?.('name')
?? current.children?.find((c: any) =>
c.type === 'type_identifier' || c.type === 'identifier' || c.type === 'name' || c.type === 'constant'
);
if (nameNode) {
const label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class';
return generateId(label, `${filePath}:${nameNode.text}`);
}
}
current = current.parent;
}
return null;
};
/**
* Find a child of `childType` within a sibling node of `siblingType`.
* Used for Kotlin AST traversal where visibility_modifier lives inside a modifiers sibling.
*/
export const findSiblingChild = (parent: any, siblingType: string, childType: string): any | null => {
for (let i = 0; i < parent.childCount; i++) {
const sibling = parent.child(i);
if (sibling?.type === siblingType) {
for (let j = 0; j < sibling.childCount; j++) {
const child = sibling.child(j);
if (child?.type === childType) return child;
}
}
}
return null;
};
/**
* Extract function name and label from a function_definition or similar AST node.
* Handles C/C++ qualified_identifier (ClassName::MethodName) and other language patterns.
*/
export const extractFunctionName = (node: SyntaxNode): { funcName: string | null; label: string } => {
let funcName: string | null = null;
let label = 'Function';
// Swift init/deinit
if (node.type === 'init_declaration' || node.type === 'deinit_declaration') {
return {
funcName: node.type === 'init_declaration' ? 'init' : 'deinit',
label: 'Constructor',
};
}
if (FUNCTION_DECLARATION_TYPES.has(node.type)) {
// C/C++: function_definition -> [pointer_declarator ->] function_declarator -> qualified_identifier/identifier
// Unwrap pointer_declarator / reference_declarator wrappers to reach function_declarator
let declarator = node.childForFieldName?.('declarator');
if (!declarator) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_declarator') { declarator = c; break; }
}
}
while (declarator && (declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator')) {
let nextDeclarator = declarator.childForFieldName?.('declarator');
if (!nextDeclarator) {
for (let i = 0; i < declarator.childCount; i++) {
const c = declarator.child(i);
if (c?.type === 'function_declarator' || c?.type === 'pointer_declarator' || c?.type === 'reference_declarator') { nextDeclarator = c; break; }
}
}
declarator = nextDeclarator;
}
if (declarator) {
let innerDeclarator = declarator.childForFieldName?.('declarator');
if (!innerDeclarator) {
for (let i = 0; i < declarator.childCount; i++) {
const c = declarator.child(i);
if (c?.type === 'qualified_identifier' || c?.type === 'identifier'
|| c?.type === 'field_identifier' || c?.type === 'parenthesized_declarator') { innerDeclarator = c; break; }
}
}
if (innerDeclarator?.type === 'qualified_identifier') {
let nameNode = innerDeclarator.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < innerDeclarator.childCount; i++) {
const c = innerDeclarator.child(i);
if (c?.type === 'identifier') { nameNode = c; break; }
}
}
if (nameNode?.text) {
funcName = nameNode.text;
label = 'Method';
}
} else if (innerDeclarator?.type === 'identifier' || innerDeclarator?.type === 'field_identifier') {
// field_identifier is used for method names inside C++ class bodies
funcName = innerDeclarator.text;
if (innerDeclarator.type === 'field_identifier') label = 'Method';
} else if (innerDeclarator?.type === 'parenthesized_declarator') {
let nestedId: SyntaxNode | null = null;
for (let i = 0; i < innerDeclarator.childCount; i++) {
const c = innerDeclarator.child(i);
if (c?.type === 'qualified_identifier' || c?.type === 'identifier') { nestedId = c; break; }
}
if (nestedId?.type === 'qualified_identifier') {
let nameNode = nestedId.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < nestedId.childCount; i++) {
const c = nestedId.child(i);
if (c?.type === 'identifier') { nameNode = c; break; }
}
}
if (nameNode?.text) {
funcName = nameNode.text;
label = 'Method';
}
} else if (nestedId?.type === 'identifier') {
funcName = nestedId.text;
}
}
}
// Fallback for other languages (Kotlin uses simple_identifier, Swift uses simple_identifier)
if (!funcName) {
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'identifier' || c?.type === 'property_identifier' || c?.type === 'simple_identifier') { nameNode = c; break; }
}
}
funcName = nameNode?.text;
// Kotlin: function_declaration inside a class_body is a method, not a top-level function.
// Must match the label assigned in parse-worker.ts for consistent generateId() output.
if (funcName && node.type === 'function_declaration' && isKotlinClassMethod(node)) {
label = 'Method';
}
}
} else if (node.type === 'impl_item') {
let funcItem: SyntaxNode | null = null;
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_item') { funcItem = c; break; }
}
if (funcItem) {
let nameNode = funcItem.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < funcItem.childCount; i++) {
const c = funcItem.child(i);
if (c?.type === 'identifier') { nameNode = c; break; }
}
}
funcName = nameNode?.text;
label = 'Method';
}
} else if (node.type === 'method_definition') {
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'property_identifier') { nameNode = c; break; }
}
}
funcName = nameNode?.text;
label = 'Method';
} else if (node.type === 'method_declaration' || node.type === 'constructor_declaration') {
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'identifier') { nameNode = c; break; }
}
}
funcName = nameNode?.text;
label = 'Method';
} else if (node.type === 'arrow_function' || node.type === 'function_expression') {
const parent = node.parent;
if (parent?.type === 'variable_declarator') {
let nameNode = parent.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < parent.childCount; i++) {
const c = parent.child(i);
if (c?.type === 'identifier') { nameNode = c; break; }
}
}
funcName = nameNode?.text;
}
} else if (node.type === 'method' || node.type === 'singleton_method') {
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'identifier') { nameNode = c; break; }
}
}
funcName = nameNode?.text;
label = 'Method';
}
return { funcName, label };
};
export interface MethodSignature {
parameterCount: number | undefined;
/** Number of required (non-optional, non-default) parameters.
* Only set when fewer than parameterCount enables range-based arity filtering.
* undefined means all parameters are required (or metadata unavailable). */
requiredParameterCount: number | undefined;
/** Per-parameter type names extracted via extractSimpleTypeName.
* Only populated for languages with method overloading (Java, Kotlin, C#, C++).
* undefined (not []) when no types are extractable avoids empty array allocations. */
parameterTypes: string[] | undefined;
returnType: string | undefined;
}
/** Argument list node types shared between extractMethodSignature and countCallArguments. */
export const CALL_ARGUMENT_LIST_TYPES = new Set([
'arguments',
'argument_list',
'value_arguments',
]);
/**
* Extract parameter count and return type text from an AST method/function node.
* Works across languages by looking for common AST patterns.
*/
export const extractMethodSignature = (node: SyntaxNode | null | undefined): MethodSignature => {
let parameterCount: number | undefined = 0;
let requiredCount = 0;
let returnType: string | undefined;
let isVariadic = false;
const paramTypes: string[] = [];
if (!node) return { parameterCount, requiredParameterCount: undefined, parameterTypes: undefined, returnType };
const paramListTypes = new Set([
'formal_parameters', 'parameters', 'parameter_list',
'function_parameters', 'method_parameters', 'function_value_parameters',
]);
// Node types that indicate variadic/rest parameters
const VARIADIC_PARAM_TYPES = new Set([
'variadic_parameter_declaration', // Go: ...string
'variadic_parameter', // Rust: extern "C" fn(...)
'spread_parameter', // Java: Object... args
'list_splat_pattern', // Python: *args
'dictionary_splat_pattern', // Python: **kwargs
]);
/** AST node types that represent parameters with default values. */
const OPTIONAL_PARAM_TYPES = new Set([
'optional_parameter', // TypeScript, Ruby: (x?: number), (x: number = 5), def f(x = 5)
'default_parameter', // Python: def f(x=5)
'typed_default_parameter', // Python: def f(x: int = 5)
'optional_parameter_declaration', // C++: void f(int x = 5)
]);
/** Check if a parameter node has a default value (handles Kotlin, C#, Swift, PHP
* where defaults are expressed as child nodes rather than distinct node types). */
const hasDefaultValue = (paramNode: SyntaxNode): boolean => {
if (OPTIONAL_PARAM_TYPES.has(paramNode.type)) return true;
// C#, Swift, PHP: check for '=' token or equals_value_clause child
for (let i = 0; i < paramNode.childCount; i++) {
const c = paramNode.child(i);
if (!c) continue;
if (c.type === '=' || c.type === 'equals_value_clause') return true;
}
// Kotlin: default values are siblings of the parameter node, not children.
// The AST is: parameter, =, <literal> — all at function_value_parameters level.
// Check if the immediately following sibling is '=' (default value separator).
const sib = paramNode.nextSibling;
if (sib && sib.type === '=') return true;
return false;
};
const findParameterList = (current: SyntaxNode): SyntaxNode | null => {
for (const child of current.children) {
if (paramListTypes.has(child.type)) return child;
}
for (const child of current.children) {
const nested = findParameterList(child);
if (nested) return nested;
}
return null;
};
const parameterList = (
paramListTypes.has(node.type) ? node // node itself IS the parameter list (e.g. C# primary constructors)
: node.childForFieldName?.('parameters')
?? findParameterList(node)
);
if (parameterList && paramListTypes.has(parameterList.type)) {
for (const param of parameterList.namedChildren) {
if (param.type === 'comment') continue;
if (param.text === 'self' || param.text === '&self' || param.text === '&mut self' ||
param.type === 'self_parameter') {
continue;
}
// Kotlin: default values are siblings of the parameter node inside
// function_value_parameters, so they appear as named children (e.g.
// string_literal, integer_literal, boolean_literal, call_expression).
// Skip any named child that isn't a parameter-like or modifier node.
if (param.type.endsWith('_literal') || param.type === 'call_expression'
|| param.type === 'navigation_expression' || param.type === 'prefix_expression'
|| param.type === 'parenthesized_expression') {
continue;
}
// Check for variadic parameter types
if (VARIADIC_PARAM_TYPES.has(param.type)) {
isVariadic = true;
continue;
}
// TypeScript/JavaScript: rest parameter — required_parameter containing rest_pattern
if (param.type === 'required_parameter' || param.type === 'optional_parameter') {
for (const child of param.children) {
if (child.type === 'rest_pattern') {
isVariadic = true;
break;
}
}
if (isVariadic) continue;
}
// Kotlin: vararg modifier on a regular parameter
if (param.type === 'parameter' || param.type === 'formal_parameter') {
const prev = param.previousSibling;
if (prev?.type === 'parameter_modifiers' && prev.text.includes('vararg')) {
isVariadic = true;
}
}
// Extract parameter type name for overload disambiguation.
// Works for Java (formal_parameter), Kotlin (parameter), C# (parameter),
// C++ (parameter_declaration). Uses childForFieldName('type') which is the
// standard tree-sitter field for typed parameters across these languages.
// Kotlin uses positional children instead of 'type' field — fall back to
// searching for user_type/nullable_type/predefined_type children.
const paramTypeNode = param.childForFieldName('type');
if (paramTypeNode) {
const typeName = extractSimpleTypeName(paramTypeNode);
paramTypes.push(typeName ?? 'unknown');
} else {
// Kotlin: parameter → [simple_identifier, user_type|nullable_type]
let found = false;
for (const child of param.namedChildren) {
if (child.type === 'user_type' || child.type === 'nullable_type'
|| child.type === 'type_identifier' || child.type === 'predefined_type') {
const typeName = extractSimpleTypeName(child);
paramTypes.push(typeName ?? 'unknown');
found = true;
break;
}
}
if (!found) paramTypes.push('unknown');
}
if (!hasDefaultValue(param)) requiredCount++;
parameterCount++;
}
// C/C++: bare `...` token in parameter list (not a named child — check all children)
if (!isVariadic) {
for (const child of parameterList.children) {
if (!child.isNamed && child.text === '...') {
isVariadic = true;
break;
}
}
}
}
// Return type extraction — language-specific field names
// Go: 'result' field is either a type_identifier or parameter_list (multi-return)
const goResult = node.childForFieldName?.('result');
if (goResult) {
if (goResult.type === 'parameter_list') {
// Multi-return: extract first parameter's type only (e.g. (*User, error) → *User)
const firstParam = goResult.firstNamedChild;
if (firstParam?.type === 'parameter_declaration') {
const typeNode = firstParam.childForFieldName('type');
if (typeNode) returnType = typeNode.text;
} else if (firstParam) {
// Unnamed return types: (string, error) — first child is a bare type node
returnType = firstParam.text;
}
} else {
returnType = goResult.text;
}
}
// Rust: 'return_type' field — the value IS the type node (e.g. primitive_type, type_identifier).
// Skip if the node is a type_annotation (TS/Python), which is handled by the generic loop below.
if (!returnType) {
const rustReturn = node.childForFieldName?.('return_type');
if (rustReturn && rustReturn.type !== 'type_annotation') {
returnType = rustReturn.text;
}
}
// C/C++: 'type' field on function_definition
if (!returnType) {
const cppType = node.childForFieldName?.('type');
if (cppType && cppType.text !== 'void') {
returnType = cppType.text;
}
}
// C#: 'returns' field on method_declaration
if (!returnType) {
const csReturn = node.childForFieldName?.('returns');
if (csReturn && csReturn.text !== 'void') {
returnType = csReturn.text;
}
}
// TS/Rust/Python/C#/Kotlin: type_annotation or return_type child
if (!returnType) {
for (const child of node.children) {
if (child.type === 'type_annotation' || child.type === 'return_type') {
const typeNode = child.children.find((c) => c.isNamed);
if (typeNode) returnType = typeNode.text;
}
}
}
// Kotlin: fun getUser(): User — return type is a bare user_type child of
// function_declaration. The Kotlin grammar does NOT wrap it in type_annotation
// or return_type; it appears as a direct child after function_value_parameters.
// Note: Kotlin uses function_value_parameters (not a field), so we find it by type.
if (!returnType) {
let paramsEnd = -1;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === 'function_value_parameters' || child.type === 'value_parameters') {
paramsEnd = child.endIndex;
}
if (paramsEnd >= 0 && child.type === 'user_type' && child.startIndex > paramsEnd) {
returnType = child.text;
break;
}
}
}
if (isVariadic) parameterCount = undefined;
// Only include parameterTypes when at least one type was successfully extracted.
// Use undefined (not []) to avoid empty array allocations for untyped parameters.
const hasTypes = paramTypes.length > 0 && paramTypes.some(t => t !== 'unknown');
// Only set requiredParameterCount when it differs from total — saves memory on the common case.
const requiredParameterCount = (!isVariadic && requiredCount < (parameterCount ?? 0))
? requiredCount : undefined;
return { parameterCount, requiredParameterCount, parameterTypes: hasTypes ? paramTypes : undefined, returnType };
};

View file

@ -0,0 +1,539 @@
import type { SyntaxNode } from './ast-helpers.js';
import { CALL_ARGUMENT_LIST_TYPES } from './ast-helpers.js';
/** 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;
/**
* Count direct arguments for a call expression across common tree-sitter grammars.
* Returns undefined when the argument container cannot be located cheaply.
*/
export const countCallArguments = (callNode: SyntaxNode | null | undefined): number | undefined => {
if (!callNode) return undefined;
// Direct field or direct child (most languages)
let argsNode: SyntaxNode | null | undefined = callNode.childForFieldName('arguments')
?? callNode.children.find((child) => CALL_ARGUMENT_LIST_TYPES.has(child.type));
// Kotlin/Swift: call_expression → call_suffix → value_arguments
// Search one level deeper for languages that wrap arguments in a suffix node
if (!argsNode) {
for (const child of callNode.children) {
if (!child.isNamed) continue;
const nested = child.children.find((gc) => CALL_ARGUMENT_LIST_TYPES.has(gc.type));
if (nested) { argsNode = nested; break; }
}
}
if (!argsNode) return undefined;
let count = 0;
for (const child of argsNode.children) {
if (!child.isNamed) continue;
if (child.type === 'comment') continue;
count++;
}
return count;
};
// ── Call-form discrimination (Phase 1, Step D) ─────────────────────────
/**
* AST node types that indicate a member-access wrapper around the callee name.
* When nameNode.parent.type is one of these, the call is a member call.
*/
const MEMBER_ACCESS_NODE_TYPES = new Set([
'member_expression', // TS/JS: obj.method()
'attribute', // Python: obj.method()
'member_access_expression', // C#: obj.Method()
'field_expression', // Rust/C++: obj.method() / ptr->method()
'selector_expression', // Go: obj.Method()
'navigation_suffix', // Kotlin/Swift: obj.method() — nameNode sits inside navigation_suffix
'member_binding_expression', // C#: user?.Method() — null-conditional access
]);
/**
* Call node types that are inherently constructor invocations.
* Only includes patterns that the tree-sitter queries already capture as @call.
*/
const CONSTRUCTOR_CALL_NODE_TYPES = new Set([
'constructor_invocation', // Kotlin: Foo()
'new_expression', // TS/JS/C++: new Foo()
'object_creation_expression', // Java/C#/PHP: new Foo()
'implicit_object_creation_expression', // C# 9: User u = new(...)
'composite_literal', // Go: User{...}
'struct_expression', // Rust: User { ... }
]);
/**
* AST node types for scoped/qualified calls (e.g., Foo::new() in Rust, Foo::bar() in C++).
*/
const SCOPED_CALL_NODE_TYPES = new Set([
'scoped_identifier', // Rust: Foo::new()
'qualified_identifier', // C++: ns::func()
]);
type CallForm = 'free' | 'member' | 'constructor';
/**
* Infer whether a captured call site is a free call, member call, or constructor.
* Returns undefined if the form cannot be determined.
*
* Works by inspecting the AST structure between callNode (@call) and nameNode (@call.name).
* No tree-sitter query changes needed the distinction is in the node types.
*/
export const inferCallForm = (
callNode: SyntaxNode,
nameNode: SyntaxNode,
): CallForm | undefined => {
// 1. Constructor: callNode itself is a constructor invocation (Kotlin)
if (CONSTRUCTOR_CALL_NODE_TYPES.has(callNode.type)) {
return 'constructor';
}
// 2. Member call: nameNode's parent is a member-access wrapper
const nameParent = nameNode.parent;
if (nameParent && MEMBER_ACCESS_NODE_TYPES.has(nameParent.type)) {
return 'member';
}
// 3. PHP: the callNode itself distinguishes member vs free calls
if (callNode.type === 'member_call_expression' || callNode.type === 'nullsafe_member_call_expression') {
return 'member';
}
if (callNode.type === 'scoped_call_expression') {
return 'member'; // static call Foo::bar()
}
// 4. Java method_invocation: member if it has an 'object' field
if (callNode.type === 'method_invocation' && callNode.childForFieldName('object')) {
return 'member';
}
// 4b. Ruby call with receiver: obj.method
if (callNode.type === 'call' && callNode.childForFieldName('receiver')) {
return 'member';
}
// 5. Scoped calls (Rust Foo::new(), C++ ns::func()): treat as free
// The receiver is a type, not an instance — handled differently in Phase 3
if (nameParent && SCOPED_CALL_NODE_TYPES.has(nameParent.type)) {
return 'free';
}
// 6. Default: if nameNode is a direct child of callNode, it's a free call
if (nameNode.parent === callNode || nameParent?.parent === callNode) {
return 'free';
}
return undefined;
};
/**
* Extract the receiver identifier for member calls.
* Only captures simple identifiers returns undefined for complex expressions
* like getUser().save() or arr[0].method().
*/
const SIMPLE_RECEIVER_TYPES = new Set([
'identifier',
'simple_identifier',
'variable_name', // PHP $variable (tree-sitter-php)
'name', // PHP name node
'this', // TS/JS/Java/C# this.method()
'self', // Rust/Python self.method()
'super', // TS/JS/Java/Kotlin/Ruby super.method()
'super_expression', // Kotlin wraps super in super_expression
'base', // C# base.Method()
'parent', // PHP parent::method()
'constant', // Ruby CONSTANT.method() (uppercase identifiers)
]);
export const extractReceiverName = (
nameNode: SyntaxNode,
): string | undefined => {
const parent = nameNode.parent;
if (!parent) return undefined;
// PHP: member_call_expression / nullsafe_member_call_expression — receiver is on the callNode
// Java: method_invocation — receiver is the 'object' field on callNode
// For these, parent of nameNode is the call itself, so check the call's object field
const callNode = parent.parent ?? parent;
let receiver: SyntaxNode | null = null;
// Try standard field names used across grammars
receiver = parent.childForFieldName('object') // TS/JS member_expression, Python attribute, PHP, Java
?? parent.childForFieldName('value') // Rust field_expression
?? parent.childForFieldName('operand') // Go selector_expression
?? parent.childForFieldName('expression') // C# member_access_expression
?? parent.childForFieldName('argument'); // C++ field_expression
// Java method_invocation: 'object' field is on the callNode, not on nameNode's parent
if (!receiver && callNode.type === 'method_invocation') {
receiver = callNode.childForFieldName('object');
}
// PHP: member_call_expression has 'object' on the call node
if (!receiver && (callNode.type === 'member_call_expression' || callNode.type === 'nullsafe_member_call_expression')) {
receiver = callNode.childForFieldName('object');
}
// Ruby: call node has 'receiver' field
if (!receiver && parent.type === 'call') {
receiver = parent.childForFieldName('receiver');
}
// PHP scoped_call_expression (parent::method(), self::method()):
// nameNode's direct parent IS the scoped_call_expression (name is a direct child)
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');
// relative_scope wraps 'parent'/'self'/'static' — unwrap to get the keyword
if (receiver?.type === 'relative_scope') {
receiver = receiver.firstChild;
}
}
// C# null-conditional: user?.Save() → conditional_access_expression wraps member_binding_expression
if (!receiver && parent.type === 'member_binding_expression') {
const condAccess = parent.parent;
if (condAccess?.type === 'conditional_access_expression') {
receiver = condAccess.firstNamedChild;
}
}
// Kotlin/Swift: navigation_expression target is the first child
if (!receiver && parent.type === 'navigation_suffix') {
const navExpr = parent.parent;
if (navExpr?.type === 'navigation_expression') {
// First named child is the target (receiver)
for (const child of navExpr.children) {
if (child.isNamed && child !== parent) {
receiver = child;
break;
}
}
}
}
if (!receiver) return undefined;
// Only capture simple identifiers — refuse complex expressions
if (SIMPLE_RECEIVER_TYPES.has(receiver.type)) {
return receiver.text;
}
// Python super().method(): receiver is a call node `super()` — extract the function name
if (receiver.type === 'call') {
const func = receiver.childForFieldName('function');
if (func?.text === 'super') return 'super';
}
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;
};
// ── Chained-call extraction ───────────────────────────────────────────────
/** Node types representing member/field access across languages. */
const FIELD_ACCESS_NODE_TYPES = new Set([
'member_expression', // TS/JS
'member_access_expression', // C#
'selector_expression', // Go
'field_expression', // Rust/C++
'field_access', // Java
'attribute', // Python
'navigation_expression', // Kotlin/Swift
'member_binding_expression', // C# null-conditional (user?.Address)
]);
/** One step in a mixed receiver chain. */
export type MixedChainStep = { kind: 'field' | 'call'; name: string };
/**
* Walk a receiver AST node that 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;
}
/**
* Walk a receiver AST node that may interleave field accesses and method calls,
* building a unified chain of steps up to MAX_CHAIN_DEPTH.
*
* For `svc.getUser().address.save()`, called with the receiver of `save`
* (`svc.getUser().address`, a field access node):
* returns { chain: [{ kind:'call', name:'getUser' }, { kind:'field', name:'address' }],
* baseReceiverName: 'svc' }
*
* For `user.getAddress().city.getName()`, called with receiver of `getName`
* (`user.getAddress().city`):
* returns { chain: [{ kind:'call', name:'getAddress' }, { kind:'field', name:'city' }],
* baseReceiverName: 'user' }
*
* Pure field chains and pure call chains are special cases (all steps same kind).
*/
export function extractMixedChain(
receiverNode: SyntaxNode,
): { chain: MixedChainStep[]; baseReceiverName: string | undefined } | undefined {
const chain: MixedChainStep[] = [];
let current: SyntaxNode = receiverNode;
while (chain.length < MAX_CHAIN_DEPTH) {
if (CALL_EXPRESSION_TYPES.has(current.type)) {
// ── Call expression: extract method name + inner receiver ────────────
const funcNode = current.childForFieldName?.('function')
?? current.childForFieldName?.('name')
?? current.childForFieldName?.('method');
let methodName: string | undefined;
let innerReceiver: SyntaxNode | null = null;
if (funcNode) {
methodName = funcNode.lastNamedChild?.text ?? funcNode.text;
}
// Kotlin/Swift: call_expression → navigation_expression
if (!funcNode && current.type === 'call_expression') {
const callee = current.firstNamedChild;
if (callee?.type === 'navigation_expression') {
const suffix = callee.lastNamedChild;
if (suffix?.type === 'navigation_suffix') {
methodName = suffix.lastNamedChild?.text;
for (let i = 0; i < callee.namedChildCount; i++) {
const child = callee.namedChild(i);
if (child && child.type !== 'navigation_suffix') { innerReceiver = child; break; }
}
}
}
}
if (!methodName) break;
chain.unshift({ kind: 'call', name: methodName });
if (!innerReceiver && funcNode) {
innerReceiver = funcNode.childForFieldName?.('object')
?? funcNode.childForFieldName?.('value')
?? funcNode.childForFieldName?.('operand')
?? funcNode.childForFieldName?.('argument') // C/C++ field_expression
?? funcNode.childForFieldName?.('expression')
?? null;
}
if (!innerReceiver && current.type === 'method_invocation') {
innerReceiver = current.childForFieldName?.('object') ?? null;
}
if (!innerReceiver && (current.type === 'member_call_expression' || current.type === 'nullsafe_member_call_expression')) {
innerReceiver = current.childForFieldName?.('object') ?? null;
}
if (!innerReceiver && current.type === 'call') {
innerReceiver = current.childForFieldName?.('receiver') ?? null;
}
if (!innerReceiver) break;
if (CALL_EXPRESSION_TYPES.has(innerReceiver.type) || FIELD_ACCESS_NODE_TYPES.has(innerReceiver.type)) {
current = innerReceiver;
} else {
return { chain, baseReceiverName: innerReceiver.text || undefined };
}
} else if (FIELD_ACCESS_NODE_TYPES.has(current.type)) {
// ── Field/member access: extract property name + inner object ─────────
let propertyName: string | undefined;
let innerObject: SyntaxNode | null = null;
if (current.type === 'navigation_expression') {
for (const child of current.children ?? []) {
if (child.type === 'navigation_suffix') {
for (const sc of child.children ?? []) {
if (sc.isNamed && sc.type !== '.') { propertyName = sc.text; break; }
}
} else if (child.isNamed && !innerObject) {
innerObject = child;
}
}
} else if (current.type === 'attribute') {
innerObject = current.childForFieldName?.('object') ?? null;
propertyName = current.childForFieldName?.('attribute')?.text;
} else {
innerObject = current.childForFieldName?.('object')
?? current.childForFieldName?.('value')
?? current.childForFieldName?.('operand')
?? current.childForFieldName?.('argument') // C/C++ field_expression
?? current.childForFieldName?.('expression')
?? null;
propertyName = (current.childForFieldName?.('property')
?? current.childForFieldName?.('field')
?? current.childForFieldName?.('name'))?.text;
}
if (!propertyName) break;
chain.unshift({ kind: 'field', name: propertyName });
if (!innerObject) break;
if (CALL_EXPRESSION_TYPES.has(innerObject.type) || FIELD_ACCESS_NODE_TYPES.has(innerObject.type)) {
current = innerObject;
} else {
return { chain, baseReceiverName: innerObject.text || undefined };
}
} else {
// Simple identifier — this is the base receiver
return chain.length > 0
? { chain, baseReceiverName: current.text || undefined }
: undefined;
}
}
return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined;
}

View file

@ -18,6 +18,12 @@ import { SupportedLanguages } from '../../config/supported-languages.js';
/** null = this call was not routed; fall through to default call handling */
export type CallRoutingResult = RubyCallRouting | null;
/**
* Per-language call router.
* IMPORTANT: Call-routed imports bypass preprocessImportPath(), so any router that
* returns an importPath MUST validate it independently (length cap, control-char
* rejection). See routeRubyCall for the reference implementation.
*/
export type CallRouter = (
calledName: string,
callNode: any,

View file

@ -14,7 +14,7 @@ import { detectFrameworkFromPath } from './framework-detection.js';
import { SupportedLanguages } from '../../config/supported-languages.js';
// ============================================================================
// NAME PATTERNS - All 11 supported languages
// NAME PATTERNS - All 13 supported languages
// ============================================================================
/**

View file

@ -236,8 +236,8 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
return { framework: 'go-mvc', entryPointMultiplier: 2.5, reason: 'go-controller' };
}
// Go main.go files (THE entry point)
if (p.endsWith('/main.go') || (p.includes('/cmd/') && p.endsWith('.go'))) {
// Go main.go files (THE entry point) — only match main.go, not arbitrary .go files under cmd/
if (p.endsWith('/main.go')) {
return { framework: 'go', entryPointMultiplier: 3.0, reason: 'go-main' };
}
@ -451,7 +451,7 @@ export const FRAMEWORK_AST_PATTERNS = {
'tokio': ['#[tokio::main]', '#[tokio::test]'],
// C++ patterns (Qt, Boost)
'qt': ['Q_OBJECT', 'Q_INVOKABLE', 'Q_PROPERTY', 'Q_SIGNAL', 'Q_SLOT', 'QWidget', 'QApplication'],
'qt': ['Q_OBJECT', 'Q_INVOKABLE', 'Q_PROPERTY', 'Q_SIGNALS', 'Q_SLOTS', 'Q_SIGNAL', 'Q_SLOT', 'QWidget', 'QApplication'],
// Swift/iOS
'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController', '@IBOutlet', '@IBAction', '@objc'],

View file

@ -11,14 +11,8 @@ import { loadImportConfigs } from './language-config.js';
import { buildSuffixIndex } from './resolvers/index.js';
import { callRouters } from './call-routing.js';
import type { ResolutionContext } from './resolution-context.js';
import type {
SuffixIndex,
TsconfigPaths,
GoModuleConfig,
CSharpProjectConfig,
ComposerConfig
} from './resolvers/index.js';
import { buildImportResolvers, namedBindingExtractors, preprocessImportPath } from './import-resolution.js';
import type { SuffixIndex } from './resolvers/index.js';
import { importResolvers, namedBindingExtractors, preprocessImportPath } from './import-resolution.js';
import type { ImportResult, ResolveCtx, NamedBinding } from './import-resolution.js';
// Re-export resolver types for consumers
@ -138,17 +132,39 @@ function applyImportResult(
// If the same local name is imported from multiple files (e.g., Java static imports
// of overloaded methods), remove the entry so resolution falls through to Tier 2a
// import-scoped which sees all candidates and can apply arity narrowing.
if (namedBindings && namedImportMap && files.length === 1) {
const resolvedFile = files[0];
if (namedBindings && namedImportMap) {
if (!namedImportMap.has(filePath)) namedImportMap.set(filePath, new Map());
const fileBindings = namedImportMap.get(filePath)!;
for (const binding of namedBindings) {
const existing = fileBindings.get(binding.local);
if (existing && existing.sourcePath !== resolvedFile) {
// Ambiguous: same name imported from different files — remove to fall through
fileBindings.delete(binding.local);
} else {
fileBindings.set(binding.local, { sourcePath: resolvedFile, exportedName: binding.exported });
if (files.length === 1) {
const resolvedFile = files[0];
for (const binding of namedBindings) {
const existing = fileBindings.get(binding.local);
if (existing && existing.sourcePath !== resolvedFile) {
fileBindings.delete(binding.local);
} else {
fileBindings.set(binding.local, { sourcePath: resolvedFile, exportedName: binding.exported });
}
}
} else {
// Multi-file resolution (e.g., Rust `use crate::models::{User, Repo}`).
// Match each binding to a resolved file by comparing the lowercase binding name
// to the file's basename (without extension). If no match, skip the binding.
for (const binding of namedBindings) {
const lowerName = binding.exported.toLowerCase();
const matchedFile = files.find(f => {
const base = f.replace(/\\/g, '/').split('/').pop() ?? '';
const nameWithoutExt = base.substring(0, base.lastIndexOf('.')).toLowerCase();
return nameWithoutExt === lowerName;
});
if (matchedFile) {
const existing = fileBindings.get(binding.local);
if (existing && existing.sourcePath !== matchedFile) {
fileBindings.delete(binding.local);
} else {
fileBindings.set(binding.local, { sourcePath: matchedFile, exportedName: binding.exported });
}
}
}
}
}
@ -188,8 +204,7 @@ export const processImports = async (
// Load language-specific configs once before the file loop
const configs = await loadImportConfigs(repoRoot || '');
const importResolvers = buildImportResolvers(configs);
const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache };
const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache, configs };
const { addImportEdge, addImportGraphEdge, getResolvedCount } = createImportEdgeHelpers(graph, importMap);
for (let i = 0; i < files.length; i++) {
@ -264,6 +279,7 @@ export const processImports = async (
}
const rawImportPath = preprocessImportPath(sourceNode.text, captureMap['import'], language);
if (!rawImportPath) return;
totalImportsFound++;
const result = importResolvers[language](rawImportPath, file.path, resolveCtx);
@ -325,8 +341,7 @@ export const processImportsFromExtracted = async (
let totalImportsFound = 0;
const configs = await loadImportConfigs(repoRoot || '');
const importResolvers = buildImportResolvers(configs);
const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache };
const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache, configs };
const { addImportEdge, addImportGraphEdge, getResolvedCount } = createImportEdgeHelpers(graph, importMap);
// Group by file for progress reporting (users see file count, not import count)

View file

@ -8,7 +8,7 @@
* Follows the existing ExportChecker / CallRouter pattern:
* - Function aliases (not interfaces) to avoid megamorphic inline-cache issues
* - `satisfies Record<SupportedLanguages, ...>` for compile-time exhaustiveness
* - Factory function that closes over per-language configs at build time
* - Const dispatch table configs are accessed via ctx.configs at call time
*/
import { SupportedLanguages } from '../../config/supported-languages.js';
@ -45,6 +45,7 @@ import {
extractCsharpNamedBindings,
extractJavaNamedBindings,
} from './named-binding-extraction.js';
import type { ImportResolutionContext } from './import-processor.js';
// ============================================================================
// Types
@ -61,6 +62,20 @@ export type ImportResult =
| { kind: 'package'; files: string[]; dirSuffix: string }
| null;
/** Bundled language-specific configs loaded once per ingestion run. */
export interface ImportConfigs {
tsconfigPaths: TsconfigPaths | null;
goModule: GoModuleConfig | null;
composerConfig: ComposerConfig | null;
swiftPackageConfig: SwiftPackageConfig | null;
csharpConfigs: CSharpProjectConfig[];
}
/** Full context for import resolution: file lookups + language configs. */
export interface ResolveCtx extends ImportResolutionContext {
configs: ImportConfigs;
}
/** Per-language import resolver -- function alias matching ExportChecker/CallRouter pattern. */
export type ImportResolverFn = (
rawImportPath: string,
@ -74,24 +89,6 @@ export interface NamedBinding { local: string; exported: string }
/** Per-language named binding extractor -- optional (returns undefined if language has no named imports). */
type NamedBindingExtractorFn = (importNode: SyntaxNode) => NamedBinding[] | undefined;
/** Bundled language-specific configs loaded once per ingestion run. */
export interface ImportConfigs {
tsconfigPaths: TsconfigPaths | null;
goModule: GoModuleConfig | null;
composerConfig: ComposerConfig | null;
swiftPackageConfig: SwiftPackageConfig | null;
csharpConfigs: CSharpProjectConfig[];
}
/** Context for import path resolution — narrowed from ImportResolutionContext with non-null index. */
export interface ResolveCtx {
allFilePaths: Set<string>;
allFileList: string[];
normalizedFileList: string[];
index: SuffixIndex;
resolveCache: Map<string, string | null>;
}
// ============================================================================
// Import path preprocessing
// ============================================================================
@ -105,10 +102,10 @@ export function preprocessImportPath(
sourceText: string,
importNode: SyntaxNode,
language: SupportedLanguages,
): string {
): string | null {
const cleaned = sourceText.replace(/['"<>]/g, '');
// Defense-in-depth: reject null bytes and control characters (matches Ruby call-routing pattern)
if (!cleaned || cleaned.length > 2048 || /[\x00-\x1f]/.test(cleaned)) return '';
if (!cleaned || cleaned.length > 2048 || /[\x00-\x1f]/.test(cleaned)) return null;
if (language === SupportedLanguages.Kotlin) {
return appendKotlinWildcard(cleaned, importNode);
}
@ -128,7 +125,6 @@ function resolveStandard(
filePath: string,
ctx: ResolveCtx,
language: SupportedLanguages,
tsconfigPaths: TsconfigPaths | null,
): ImportResult {
const resolvedPath = resolveImportPath(
filePath,
@ -138,7 +134,7 @@ function resolveStandard(
ctx.normalizedFileList,
ctx.resolveCache,
language,
tsconfigPaths,
ctx.configs.tsconfigPaths,
ctx.index,
);
return resolvedPath ? { kind: 'files', files: [resolvedPath] } : null;
@ -149,7 +145,6 @@ function resolveJavaImport(
rawImportPath: string,
filePath: string,
ctx: ResolveCtx,
tsconfigPaths: TsconfigPaths | null,
): ImportResult {
if (rawImportPath.endsWith('.*')) {
const matchedFiles = resolveJvmWildcard(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index);
@ -158,7 +153,7 @@ function resolveJavaImport(
const memberResolved = resolveJvmMemberImport(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index);
if (memberResolved) return { kind: 'files', files: [memberResolved] };
}
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Java, tsconfigPaths);
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Java);
}
/**
@ -169,7 +164,6 @@ function resolveKotlinImport(
rawImportPath: string,
filePath: string,
ctx: ResolveCtx,
tsconfigPaths: TsconfigPaths | null,
): ImportResult {
if (rawImportPath.endsWith('.*')) {
const matchedFiles = resolveJvmWildcard(rawImportPath, ctx.normalizedFileList, ctx.allFileList, KOTLIN_EXTENSIONS, ctx.index);
@ -200,7 +194,7 @@ function resolveKotlinImport(
if (dirFiles.length > 0) return { kind: 'files', files: dirFiles };
}
}
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Kotlin, tsconfigPaths);
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Kotlin);
}
/** Go: package-level imports via go.mod module path. */
@ -208,9 +202,8 @@ function resolveGoImport(
rawImportPath: string,
filePath: string,
ctx: ResolveCtx,
goModule: GoModuleConfig | null,
tsconfigPaths: TsconfigPaths | null,
): ImportResult {
const goModule = ctx.configs.goModule;
if (goModule && rawImportPath.startsWith(goModule.modulePath)) {
const pkgSuffix = resolveGoPackageDir(rawImportPath, goModule);
if (pkgSuffix) {
@ -221,16 +214,16 @@ function resolveGoImport(
}
// Fall through if no files found (package might be external)
}
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Go, tsconfigPaths);
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Go);
}
/** C#: namespace-based resolution via .csproj configs. */
/** C#: namespace-based resolution via .csproj configs, with suffix-match fallback. */
function resolveCSharpImportDispatch(
rawImportPath: string,
_filePath: string,
filePath: string,
ctx: ResolveCtx,
csharpConfigs: CSharpProjectConfig[],
): ImportResult {
const csharpConfigs = ctx.configs.csharpConfigs;
if (csharpConfigs.length > 0) {
const resolvedFiles = resolveCSharpImportHelper(rawImportPath, csharpConfigs, ctx.normalizedFileList, ctx.allFileList, ctx.index);
if (resolvedFiles.length > 1) {
@ -241,7 +234,7 @@ function resolveCSharpImportDispatch(
}
if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles };
}
return null;
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.CSharp);
}
/** PHP: namespace-based resolution via composer.json PSR-4. */
@ -249,9 +242,8 @@ function resolvePhpImportDispatch(
rawImportPath: string,
_filePath: string,
ctx: ResolveCtx,
composerConfig: ComposerConfig | null,
): ImportResult {
const resolved = resolvePhpImportHelper(rawImportPath, composerConfig, ctx.allFilePaths, ctx.normalizedFileList, ctx.allFileList, ctx.index);
const resolved = resolvePhpImportHelper(rawImportPath, ctx.configs.composerConfig, ctx.allFilePaths, ctx.normalizedFileList, ctx.allFileList, ctx.index);
return resolved ? { kind: 'files', files: [resolved] } : null;
}
@ -260,8 +252,8 @@ function resolveSwiftImportDispatch(
rawImportPath: string,
_filePath: string,
ctx: ResolveCtx,
swiftPackageConfig: SwiftPackageConfig | null,
): ImportResult {
const swiftPackageConfig = ctx.configs.swiftPackageConfig;
if (swiftPackageConfig) {
const targetDir = swiftPackageConfig.targets.get(rawImportPath);
if (targetDir) {
@ -286,12 +278,11 @@ function resolvePythonImportDispatch(
rawImportPath: string,
filePath: string,
ctx: ResolveCtx,
tsconfigPaths: TsconfigPaths | null,
): ImportResult {
const resolved = resolvePythonImportHelper(filePath, rawImportPath, ctx.allFilePaths);
if (resolved) return { kind: 'files', files: [resolved] };
if (rawImportPath.startsWith('.')) return null; // relative but unresolved -- don't suffix-match
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Python, tsconfigPaths);
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Python);
}
/** Ruby: require / require_relative. */
@ -304,13 +295,13 @@ function resolveRubyImportDispatch(
return resolved ? { kind: 'files', files: [resolved] } : null;
}
/** Rust: expand top-level grouped imports: use {crate::a, crate::b}. */
/** Rust: expand grouped imports: use {crate::a, crate::b} and use crate::models::{User, Repo}. */
function resolveRustImportDispatch(
rawImportPath: string,
filePath: string,
ctx: ResolveCtx,
tsconfigPaths: TsconfigPaths | null,
): ImportResult {
// Top-level grouped: use {crate::a, crate::b}
if (rawImportPath.startsWith('{') && rawImportPath.endsWith('}')) {
const inner = rawImportPath.slice(1, -1);
const parts = inner.split(',').map(p => p.trim()).filter(Boolean);
@ -321,7 +312,27 @@ function resolveRustImportDispatch(
}
return resolved.length > 0 ? { kind: 'files', files: resolved } : null;
}
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Rust, tsconfigPaths);
// Scoped grouped: use crate::models::{User, Repo}
const braceIdx = rawImportPath.indexOf('::{');
if (braceIdx !== -1 && rawImportPath.endsWith('}')) {
const pathPrefix = rawImportPath.substring(0, braceIdx);
const braceContent = rawImportPath.substring(braceIdx + 3, rawImportPath.length - 1);
const items = braceContent.split(',').map(s => s.trim()).filter(Boolean);
const resolved: string[] = [];
for (const item of items) {
// Handle `use crate::models::{User, Repo as R}` — strip alias for resolution
const itemName = item.includes(' as ') ? item.split(' as ')[0].trim() : item;
const r = resolveRustImportHelper(filePath, `${pathPrefix}::${itemName}`, ctx.allFilePaths);
if (r) resolved.push(r);
}
if (resolved.length > 0) return { kind: 'files', files: resolved };
// Fallback: resolve the prefix path itself (e.g. crate::models -> models.rs)
const prefixResult = resolveRustImportHelper(filePath, pathPrefix, ctx.allFilePaths);
if (prefixResult) return { kind: 'files', files: [prefixResult] };
}
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Rust);
}
// ============================================================================
@ -329,29 +340,26 @@ function resolveRustImportDispatch(
// ============================================================================
/**
* Build the import resolver dispatch table with per-language configs closed over at construction time.
* Each resolver function encapsulates the full resolution flow for its language, including
* Per-language import resolver dispatch table.
* Configs are accessed via ctx.configs at call time no factory closure needed.
* Each resolver encapsulates the full resolution flow for its language, including
* fallthrough to standard resolution where appropriate.
*/
export function buildImportResolvers(configs: ImportConfigs): Record<SupportedLanguages, ImportResolverFn> {
const { tsconfigPaths, goModule, composerConfig, swiftPackageConfig, csharpConfigs } = configs;
return {
[SupportedLanguages.JavaScript]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.JavaScript, tsconfigPaths),
[SupportedLanguages.TypeScript]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.TypeScript, tsconfigPaths),
[SupportedLanguages.Python]: (raw, fp, ctx) => resolvePythonImportDispatch(raw, fp, ctx, tsconfigPaths),
[SupportedLanguages.Java]: (raw, fp, ctx) => resolveJavaImport(raw, fp, ctx, tsconfigPaths),
[SupportedLanguages.C]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.C, tsconfigPaths),
[SupportedLanguages.CPlusPlus]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.CPlusPlus, tsconfigPaths),
[SupportedLanguages.CSharp]: (raw, fp, ctx) => resolveCSharpImportDispatch(raw, fp, ctx, csharpConfigs),
[SupportedLanguages.Go]: (raw, fp, ctx) => resolveGoImport(raw, fp, ctx, goModule, tsconfigPaths),
[SupportedLanguages.Ruby]: (raw, fp, ctx) => resolveRubyImportDispatch(raw, fp, ctx),
[SupportedLanguages.Rust]: (raw, fp, ctx) => resolveRustImportDispatch(raw, fp, ctx, tsconfigPaths),
[SupportedLanguages.PHP]: (raw, fp, ctx) => resolvePhpImportDispatch(raw, fp, ctx, composerConfig),
[SupportedLanguages.Kotlin]: (raw, fp, ctx) => resolveKotlinImport(raw, fp, ctx, tsconfigPaths),
[SupportedLanguages.Swift]: (raw, fp, ctx) => resolveSwiftImportDispatch(raw, fp, ctx, swiftPackageConfig),
} satisfies Record<SupportedLanguages, ImportResolverFn>;
}
export const importResolvers = {
[SupportedLanguages.JavaScript]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.JavaScript),
[SupportedLanguages.TypeScript]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.TypeScript),
[SupportedLanguages.Python]: (raw, fp, ctx) => resolvePythonImportDispatch(raw, fp, ctx),
[SupportedLanguages.Java]: (raw, fp, ctx) => resolveJavaImport(raw, fp, ctx),
[SupportedLanguages.C]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.C),
[SupportedLanguages.CPlusPlus]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.CPlusPlus),
[SupportedLanguages.CSharp]: (raw, fp, ctx) => resolveCSharpImportDispatch(raw, fp, ctx),
[SupportedLanguages.Go]: (raw, fp, ctx) => resolveGoImport(raw, fp, ctx),
[SupportedLanguages.Ruby]: (raw, fp, ctx) => resolveRubyImportDispatch(raw, fp, ctx),
[SupportedLanguages.Rust]: (raw, fp, ctx) => resolveRustImportDispatch(raw, fp, ctx),
[SupportedLanguages.PHP]: (raw, fp, ctx) => resolvePhpImportDispatch(raw, fp, ctx),
[SupportedLanguages.Kotlin]: (raw, fp, ctx) => resolveKotlinImport(raw, fp, ctx),
[SupportedLanguages.Swift]: (raw, fp, ctx) => resolveSwiftImportDispatch(raw, fp, ctx),
} satisfies Record<SupportedLanguages, ImportResolverFn>;
/**
* Per-language named binding extractor dispatch table.

View file

@ -253,6 +253,13 @@ export function extractPhpNamedBindings(importNode: SyntaxNode): NamedBinding[]
// namespace_use_declaration > namespace_use_group > namespace_use_clause* (grouped)
if (importNode.type !== 'namespace_use_declaration') return undefined;
// Skip 'use function' and 'use const' declarations — these import callables/constants,
// not class types, and should not be added to namedImportMap as type bindings.
const useTypeNode = importNode.childForFieldName?.('type');
if (useTypeNode && (useTypeNode.text === 'function' || useTypeNode.text === 'const')) {
return undefined;
}
const bindings: NamedBinding[] = [];
// Collect all clauses — from direct children AND from namespace_use_group

View file

@ -4,6 +4,7 @@
*/
import type { SuffixIndex } from './utils.js';
import type { SyntaxNode } from '../utils.js';
/** Kotlin file extensions for JVM resolver reuse */
export const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts'];
@ -12,7 +13,7 @@ export const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts'];
* Append .* to a Kotlin import path if the AST has a wildcard_import sibling node.
* Pure function returns a new string without mutating the input.
*/
export const appendKotlinWildcard = (importPath: string, importNode: any): string => {
export const appendKotlinWildcard = (importPath: string, importNode: SyntaxNode): string => {
for (let i = 0; i < importNode.childCount; i++) {
if (importNode.child(i)?.type === 'wildcard_import') {
return importPath.endsWith('.*') ? importPath : `${importPath}.*`;

View file

@ -65,11 +65,13 @@ export interface SuffixIndex {
getFilesInDir(dirSuffix: string, extension: string): string[];
}
const FROZEN_EMPTY_ARRAY: string[] = Object.freeze([]) as string[];
/** Sentinel index that returns no results. Used to release memory after import resolution. */
export const EMPTY_INDEX: SuffixIndex = Object.freeze({
get: () => undefined,
getInsensitive: () => undefined,
getFilesInDir: () => [],
getFilesInDir: () => FROZEN_EMPTY_ARRAY,
});
export function buildSuffixIndex(normalizedFileList: string[], allFileList: string[]): SuffixIndex {

View file

@ -1,6 +1,7 @@
import type { SyntaxNode } from '../utils.js';
import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor, PatternBindingExtractor, LiteralTypeInferrer } from './types.js';
import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
import { extractSimpleTypeName, extractVarName, unwrapAwait, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
import { findChild } from '../resolvers/utils.js';
/** Known container property accessors that operate on the container itself (e.g., dict.Keys, dict.Values) */
const KNOWN_CONTAINER_PROPS: ReadonlySet<string> = new Set(['Keys', 'Values']);
@ -50,8 +51,8 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<str
// tree-sitter-c-sharp may put object_creation_expression as direct child
// or inside equals_value_clause depending on grammar version
if (declarators.length === 1) {
const initializer = findChildByType(declarators[0], 'object_creation_expression')
?? findChildByType(declarators[0], 'equals_value_clause')?.firstNamedChild;
const initializer = findChild(declarators[0], 'object_creation_expression')
?? findChild(declarators[0], 'equals_value_clause')?.firstNamedChild;
if (initializer?.type === 'object_creation_expression') {
const ctorType = initializer.childForFieldName('type');
if (ctorType) typeName = extractSimpleTypeName(ctorType);
@ -142,7 +143,7 @@ const extractCSharpElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArg
// generic_name: List<User>, IEnumerable<User>, Dictionary<string, User>
// C# uses generic_name (not generic_type)
if (typeNode.type === 'generic_name') {
const argList = findChildByType(typeNode, 'type_argument_list');
const argList = findChild(typeNode, 'type_argument_list');
if (argList && argList.namedChildCount >= 1) {
if (pos === 'first') {
const firstArg = argList.namedChild(0);

View file

@ -1,6 +1,6 @@
import type { SyntaxNode } from '../utils.js';
import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, extractGenericTypeArgs, findChildByType, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'var_declaration',

View file

@ -47,6 +47,5 @@ export {
extractSimpleTypeName,
extractGenericTypeArgs,
extractVarName,
findChildByType,
extractRubyConstructorAssignment
} from './shared.js';

View file

@ -1,6 +1,7 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor, PatternBindingExtractor, LiteralTypeInferrer, ConstructorTypeDetector } from './types.js';
import { extractSimpleTypeName, extractVarName, findChildByType, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
import { extractSimpleTypeName, extractVarName, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
import { findChild } from '../resolvers/utils.js';
// ── Java ──────────────────────────────────────────────────────────────────
@ -73,7 +74,7 @@ const scanJavaConstructorBinding: ConstructorBindingScanner = (node) => {
const typeNode = node.childForFieldName('type');
if (!typeNode) return undefined;
if (typeNode.text !== 'var') return undefined;
const declarator = findChildByType(node, 'variable_declarator');
const declarator = findChild(node, 'variable_declarator');
if (!declarator) return undefined;
const nameNode = declarator.childForFieldName('name');
const value = declarator.childForFieldName('value');
@ -325,11 +326,11 @@ const KOTLIN_DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
const extractKotlinDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
if (node.type === 'property_declaration') {
// Kotlin property_declaration: name/type are inside a variable_declaration child
const varDecl = findChildByType(node, 'variable_declaration');
const varDecl = findChild(node, 'variable_declaration');
if (varDecl) {
const nameNode = findChildByType(varDecl, 'simple_identifier');
const typeNode = findChildByType(varDecl, 'user_type')
?? findChildByType(varDecl, 'nullable_type');
const nameNode = findChild(varDecl, 'simple_identifier');
const typeNode = findChild(varDecl, 'user_type')
?? findChild(varDecl, 'nullable_type');
if (!nameNode || !typeNode) return;
const varName = extractVarName(nameNode);
const typeName = extractSimpleTypeName(typeNode);
@ -338,17 +339,17 @@ const extractKotlinDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: M
}
// Fallback: try direct fields
const nameNode = node.childForFieldName('name')
?? findChildByType(node, 'simple_identifier');
?? findChild(node, 'simple_identifier');
const typeNode = node.childForFieldName('type')
?? findChildByType(node, 'user_type');
?? findChild(node, 'user_type');
if (!nameNode || !typeNode) return;
const varName = extractVarName(nameNode);
const typeName = extractSimpleTypeName(typeNode);
if (varName && typeName) env.set(varName, typeName);
} else if (node.type === 'variable_declaration') {
// variable_declaration directly inside functions
const nameNode = findChildByType(node, 'simple_identifier');
const typeNode = findChildByType(node, 'user_type');
const nameNode = findChild(node, 'simple_identifier');
const typeNode = findChild(node, 'user_type');
if (nameNode && typeNode) {
const varName = extractVarName(nameNode);
const typeName = extractSimpleTypeName(typeNode);
@ -360,7 +361,7 @@ const extractKotlinDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: M
/** Kotlin: parameter / formal_parameter type name.
* Kotlin's tree-sitter grammar uses positional children (simple_identifier, user_type)
* rather than named fields (name, type) on `parameter` nodes, so we fall back to
* findChildByType when childForFieldName returns null. */
* findChild when childForFieldName returns null. */
const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
let nameNode: SyntaxNode | null = null;
let typeNode: SyntaxNode | null = null;
@ -374,9 +375,9 @@ const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map<s
}
// Fallback: Kotlin `parameter` nodes use positional children, not named fields
if (!nameNode) nameNode = findChildByType(node, 'simple_identifier');
if (!typeNode) typeNode = findChildByType(node, 'user_type')
?? findChildByType(node, 'nullable_type');
if (!nameNode) nameNode = findChild(node, 'simple_identifier');
if (!typeNode) typeNode = findChild(node, 'user_type')
?? findChild(node, 'nullable_type');
if (!nameNode || !typeNode) return;
const varName = extractVarName(nameNode);
@ -389,7 +390,7 @@ const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map<s
const findKotlinConstructorCallee = (node: SyntaxNode, classNames: ClassNameLookup): string | undefined => {
if (node.type !== 'property_declaration') return undefined;
const value = node.childForFieldName('value')
?? findChildByType(node, 'call_expression');
?? findChild(node, 'call_expression');
if (!value || value.type !== 'call_expression') return undefined;
const callee = value.firstNamedChild;
if (!callee || callee.type !== 'simple_identifier') return undefined;
@ -403,16 +404,16 @@ const findKotlinConstructorCallee = (node: SyntaxNode, classNames: ClassNameLook
* against classNames (which may include cross-file SymbolTable lookups). */
const extractKotlinInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, classNames: ClassNameLookup): void => {
// Skip if there's an explicit type annotation — Tier 0 already handled it
const varDecl = findChildByType(node, 'variable_declaration');
if (varDecl && findChildByType(varDecl, 'user_type')) return;
const varDecl = findChild(node, 'variable_declaration');
if (varDecl && findChild(varDecl, 'user_type')) return;
const calleeName = findKotlinConstructorCallee(node, classNames);
if (!calleeName) return;
// Extract the variable name from the variable_declaration inside property_declaration
const nameNode = varDecl
? findChildByType(varDecl, 'simple_identifier')
: findChildByType(node, 'simple_identifier');
? findChild(varDecl, 'simple_identifier')
: findChild(node, 'simple_identifier');
if (!nameNode) return;
const varName = extractVarName(nameNode);
@ -430,10 +431,10 @@ const detectKotlinConstructorType: ConstructorTypeDetector = (node, classNames)
/** Kotlin: val x = User(...) — constructor binding for property_declaration with call_expression */
const scanKotlinConstructorBinding: ConstructorBindingScanner = (node) => {
if (node.type !== 'property_declaration') return undefined;
const varDecl = findChildByType(node, 'variable_declaration');
const varDecl = findChild(node, 'variable_declaration');
if (!varDecl) return undefined;
if (findChildByType(varDecl, 'user_type')) return undefined;
const callExpr = findChildByType(node, 'call_expression');
if (findChild(varDecl, 'user_type')) return undefined;
const callExpr = findChild(node, 'call_expression');
if (!callExpr) return undefined;
const callee = callExpr.firstNamedChild;
if (!callee) return undefined;
@ -452,7 +453,7 @@ const scanKotlinConstructorBinding: ConstructorBindingScanner = (node) => {
}
}
if (!calleeName) return undefined;
const nameNode = findChildByType(varDecl, 'simple_identifier');
const nameNode = findChild(varDecl, 'simple_identifier');
if (!nameNode) return undefined;
return { varName: nameNode.text, calleeName };
};
@ -466,7 +467,7 @@ const KOTLIN_FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
* Handles the type_projection wrapper that Kotlin uses for generic type arguments. */
const extractKotlinElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
if (typeNode.type === 'user_type') {
const argsNode = findChildByType(typeNode, 'type_arguments');
const argsNode = findChild(typeNode, 'type_arguments');
if (argsNode && argsNode.namedChildCount >= 1) {
const targetArg = pos === 'first'
? argsNode.namedChild(0)
@ -488,14 +489,14 @@ const findKotlinParamElementType = (iterableName: string, startNode: SyntaxNode,
let current: SyntaxNode | null = startNode.parent;
while (current) {
if (current.type === 'function_declaration') {
const paramsNode = findChildByType(current, 'function_value_parameters');
const paramsNode = findChild(current, 'function_value_parameters');
if (paramsNode) {
for (let i = 0; i < paramsNode.namedChildCount; i++) {
const param = paramsNode.namedChild(i);
if (!param || param.type !== 'parameter') continue;
const nameNode = findChildByType(param, 'simple_identifier');
const nameNode = findChild(param, 'simple_identifier');
if (nameNode?.text !== iterableName) continue;
const typeNode = findChildByType(param, 'user_type');
const typeNode = findChild(param, 'user_type');
if (typeNode) return extractKotlinElementTypeFromTypeNode(typeNode, pos);
}
}
@ -510,15 +511,15 @@ const findKotlinParamElementType = (iterableName: string, startNode: SyntaxNode,
* Tier 1c: for `for (user in users)` without annotation, resolves from iterable. */
const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => {
const { scopeEnv, declarationTypeNodes, scope, returnTypeLookup } = ctx;
const varDecl = findChildByType(node, 'variable_declaration');
const varDecl = findChild(node, 'variable_declaration');
if (!varDecl) return;
const nameNode = findChildByType(varDecl, 'simple_identifier');
const nameNode = findChild(varDecl, 'simple_identifier');
if (!nameNode) return;
const varName = extractVarName(nameNode);
if (!varName) return;
// Explicit type annotation (existing behavior): for (user: User in users)
const typeNode = findChildByType(varDecl, 'user_type');
const typeNode = findChild(varDecl, 'user_type');
if (typeNode) {
const typeName = extractSimpleTypeName(typeNode);
if (typeName) scopeEnv.set(varName, typeName);
@ -544,9 +545,9 @@ const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => {
if (child.type === 'navigation_expression') {
// data.keys → navigation_expression > simple_identifier(data) + navigation_suffix > simple_identifier(keys)
const obj = child.firstNamedChild;
const suffix = findChildByType(child, 'navigation_suffix');
const prop = suffix ? findChildByType(suffix, 'simple_identifier') : null;
const hasCallSuffix = suffix ? findChildByType(suffix, 'call_suffix') !== null : false;
const suffix = findChild(child, 'navigation_suffix');
const prop = suffix ? findChild(suffix, 'simple_identifier') : null;
const hasCallSuffix = suffix ? findChild(suffix, 'call_suffix') !== null : false;
// Always try object as iterable + property as method first (handles data.values, data.keys).
// For bare property access without call_suffix, also save property as fallback
// (handles this.users, repo.items where the property IS the iterable).
@ -563,9 +564,9 @@ const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => {
if (callee?.type === 'navigation_expression') {
const obj = callee.firstNamedChild;
if (obj?.type === 'simple_identifier') iterableName = obj.text;
const suffix = findChildByType(callee, 'navigation_suffix');
const suffix = findChild(callee, 'navigation_suffix');
if (suffix) {
const prop = findChildByType(suffix, 'simple_identifier');
const prop = findChild(suffix, 'simple_identifier');
if (prop) methodName = prop.text;
}
} else if (callee?.type === 'simple_identifier') {
@ -607,7 +608,7 @@ const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => {
const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
if (node.type === 'property_declaration') {
// Find the variable name from variable_declaration child
const varDecl = findChildByType(node, 'variable_declaration');
const varDecl = findChild(node, 'variable_declaration');
if (!varDecl) return undefined;
const nameNode = varDecl.firstNamedChild;
if (!nameNode || nameNode.type !== 'simple_identifier') return undefined;
@ -653,7 +654,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
if (node.type === 'variable_declaration') {
// variable_declaration directly inside functions: simple_identifier children
const nameNode = findChildByType(node, 'simple_identifier');
const nameNode = findChild(node, 'simple_identifier');
if (!nameNode) return undefined;
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) return undefined;

View file

@ -497,15 +497,6 @@ export const extractCalleeName = (callNode: SyntaxNode): string | undefined => {
return extractSimpleTypeName(func);
};
/** Find the first named child with the given node type */
export const findChildByType = (node: SyntaxNode, type: string): SyntaxNode | null => {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === type) return child;
}
return null;
};
// Internal helper: extract the first comma-separated argument from a string,
// respecting nested angle-bracket and square-bracket depth.
function extractFirstArg(args: string): string {

View file

@ -1,6 +1,7 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner } from './types.js';
import { extractSimpleTypeName, extractVarName, findChildByType, hasTypeAnnotation } from './shared.js';
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation } from './shared.js';
import { findChild } from '../resolvers/utils.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'property_declaration',
@ -10,9 +11,9 @@ const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
// Swift property_declaration has pattern and type_annotation
const pattern = node.childForFieldName('pattern')
?? findChildByType(node, 'pattern');
?? findChild(node, 'pattern');
const typeAnnotation = node.childForFieldName('type')
?? findChildByType(node, 'type_annotation');
?? findChild(node, 'type_annotation');
if (!pattern || !typeAnnotation) return;
const varName = extractVarName(pattern) ?? pattern.text;
const typeName = extractSimpleTypeName(typeAnnotation);
@ -45,14 +46,14 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, classNames: ClassNameLookup): void => {
if (node.type !== 'property_declaration') return;
// Skip if has type annotation — extractDeclaration handled it
if (node.childForFieldName('type') || findChildByType(node, 'type_annotation')) return;
if (node.childForFieldName('type') || findChild(node, 'type_annotation')) return;
// Find pattern (variable name)
const pattern = node.childForFieldName('pattern') ?? findChildByType(node, 'pattern');
const pattern = node.childForFieldName('pattern') ?? findChild(node, 'pattern');
if (!pattern) return;
const varName = extractVarName(pattern) ?? pattern.text;
if (!varName || env.has(varName)) return;
// Find call_expression in the value
const callExpr = findChildByType(node, 'call_expression');
const callExpr = findChild(node, 'call_expression');
if (!callExpr) return;
const callee = callExpr.firstNamedChild;
if (!callee) return;

File diff suppressed because it is too large Load diff

View file

@ -932,6 +932,7 @@ const processFileGroup = (
// Extract import paths before skipping
if (captureMap['import'] && captureMap['import.source']) {
const rawImportPath = preprocessImportPath(captureMap['import.source'].text, captureMap['import'], language);
if (!rawImportPath) continue;
const extractor = namedBindingExtractors[language];
const namedBindings = extractor ? extractor(captureMap['import']) : undefined;
result.imports.push({

View file

@ -0,0 +1,8 @@
namespace Models
{
public class User
{
public void Save() { }
public string GetName() { return "alice"; }
}
}

View file

@ -0,0 +1,13 @@
using Models;
namespace Services
{
public class UserService
{
public void ProcessUser()
{
var user = new User();
user.Save();
}
}
}

View file

@ -0,0 +1,5 @@
package config
func Load() string {
return "loaded"
}

View file

@ -0,0 +1,7 @@
package main
import "myapp/cmd/server/internal/config"
func main() {
config.Load()
}

View file

@ -0,0 +1,3 @@
module myapp
go 1.21

View file

@ -0,0 +1,5 @@
<?php
namespace App\Config;
const MAX_RETRIES = 3;

View file

@ -0,0 +1,10 @@
<?php
namespace App\Models;
class User {
public function save(): void {}
public function getName(): string {
return "alice";
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Services;
use App\Models\User;
use function App\Utils\formatName;
use const App\Config\MAX_RETRIES;
class Calculator {
public function process(): void {
$user = new User();
$user->save();
$name = formatName("test");
echo MAX_RETRIES;
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Utils;
function formatName(string $name): string {
return strtoupper($name);
}

View file

@ -0,0 +1,7 @@
{
"autoload": {
"psr-4": {
"App\\": "app/"
}
}
}

View file

@ -0,0 +1,11 @@
mod models;
use crate::models::{User, Repo};
fn main() {
let user = User::new("alice");
user.save();
let repo = Repo::new("my-repo");
repo.clone_repo();
}

View file

@ -0,0 +1,5 @@
mod user;
mod repo;
pub use user::User;
pub use repo::Repo;

View file

@ -0,0 +1,13 @@
pub struct Repo {
name: String,
}
impl Repo {
pub fn new(name: &str) -> Self {
Repo { name: name.to_string() }
}
pub fn clone_repo(&self) {
println!("Cloning repo {}", self.name);
}
}

View file

@ -0,0 +1,13 @@
pub struct User {
name: String,
}
impl User {
pub fn new(name: &str) -> Self {
User { name: name.to_string() }
}
pub fn save(&self) {
println!("Saving user {}", self.name);
}
}

View file

@ -1587,3 +1587,42 @@ describe('C# cross-file binding propagation', () => {
expect(getNameEdge).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// C# fallback without .csproj (P1-4 fix)
// When no .csproj file is found, import resolution should fall back to
// suffix-based matching rather than returning null.
// ---------------------------------------------------------------------------
describe('C# import resolution without .csproj (suffix fallback)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'csharp-no-csproj'),
() => {},
);
}, 60000);
it('detects User class with Save and GetName methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('Save');
});
it('detects UserService class with ProcessUser method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('UserService');
expect(getNodesByLabel(result, 'Method')).toContain('ProcessUser');
});
// C# 'using Models;' is a namespace import — suffix matching cannot resolve
// namespace-to-directory mappings without .csproj. The fallback prevents a null
// return (so other resolution paths can attempt it), but namespace imports
// inherently require project config for file discovery.
it('does not crash on namespace import without .csproj (graceful fallback)', () => {
// Pipeline completes without errors and detects symbols from both files,
// even though no IMPORTS edge is created for the namespace import.
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('User');
expect(classes).toContain('UserService');
});
});

View file

@ -1247,3 +1247,33 @@ describe('Go cross-file binding propagation', () => {
expect(getNameEdge).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Go cmd/ helper files should NOT get entry-point multiplier (P0-1 fix)
// Only main.go files should get the 3.0 entry-point boost, not arbitrary
// .go files under cmd/ subdirectories.
// ---------------------------------------------------------------------------
describe('Go cmd/ helper files entry-point scoring', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-cmd-helper'),
() => {},
);
}, 60000);
it('detects main function and Load function', () => {
expect(getNodesByLabel(result, 'Function')).toContain('main');
expect(getNodesByLabel(result, 'Function')).toContain('Load');
});
it('emits IMPORTS edge from main.go to config/config.go', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(e =>
e.sourceFilePath.includes('main') && e.targetFilePath.includes('config'),
);
expect(edge).toBeDefined();
});
});

View file

@ -1515,3 +1515,53 @@ describe('PHP cross-file binding propagation', () => {
expect(getNameEdge).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// PHP use function / use const filtering (P0-3 fix)
// Verifies that `use function` and `use const` declarations do NOT produce
// class-type namedImportMap entries, while regular `use` class imports still work.
// ---------------------------------------------------------------------------
describe('PHP use function / use const filtering', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'php-use-function-const'),
() => {},
);
}, 60000);
it('detects User class with save and getName methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('getName');
});
it('detects Calculator class with process method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Calculator');
expect(getNodesByLabel(result, 'Method')).toContain('process');
});
it('detects formatName as a standalone function (not a class)', () => {
expect(getNodesByLabel(result, 'Function')).toContain('formatName');
// formatName should NOT appear as a Class
expect(getNodesByLabel(result, 'Class')).not.toContain('formatName');
});
it('emits IMPORTS edge from Calculator.php to User.php (class import)', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(e =>
e.sourceFilePath.includes('Calculator') && e.targetFilePath.includes('User'),
);
expect(edge).toBeDefined();
});
it('resolves $user->save() to User#save via class import binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('User'),
);
expect(saveCall).toBeDefined();
});
});

View file

@ -408,6 +408,54 @@ describe('Rust grouped import resolution', () => {
});
});
// ---------------------------------------------------------------------------
// Scoped grouped imports with multi-file resolution:
// use crate::models::{User, Repo} where User and Repo are in separate files.
// Verifies IMPORTS edges are created for each file AND namedImportMap entries
// match bindings to files by basename.
// ---------------------------------------------------------------------------
describe('Rust scoped grouped imports (multi-file)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-scoped-multi-file'),
() => {},
);
}, 60000);
it('detects User and Repo structs', () => {
const classes = getNodesByLabel(result, 'Struct');
expect(classes).toContain('User');
expect(classes).toContain('Repo');
});
it('emits IMPORTS edge from main.rs to models/mod.rs', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(e =>
e.sourceFilePath.includes('main') && e.targetFilePath.includes('models'),
);
expect(edge).toBeDefined();
});
it('resolves user.save() call to User#save in models/user.rs', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'main' && c.targetFilePath.includes('user'),
);
expect(saveCall).toBeDefined();
});
it('resolves repo.clone_repo() call to Repo#clone_repo in models/repo.rs', () => {
const calls = getRelationships(result, 'CALLS');
const cloneCall = calls.find(c =>
c.target === 'clone_repo' && c.source === 'main' && c.targetFilePath.includes('repo'),
);
expect(cloneCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Constructor-inferred type resolution: let user = User::new(); user.save()
// Rust scoped_identifier constructor pattern (no explicit type annotations)

View file

@ -140,6 +140,11 @@ describe('detectFrameworkFromPath', () => {
expect(result).not.toBeNull();
expect(result!.entryPointMultiplier).toBe(3.0);
});
it('does NOT treat Go helper files under cmd/ as entry points', () => {
expect(detectFrameworkFromPath('cmd/server/internal/util.go')).toBeNull();
expect(detectFrameworkFromPath('cmd/foo/config/setup.go')).toBeNull();
});
});
describe('Rust frameworks', () => {

View file

@ -0,0 +1,149 @@
/**
* Unit tests for import-resolution.ts
*
* Coverage notes:
* - `preprocessImportPath` is tested directly below (no tree-sitter required for most paths).
* - Rust scoped grouped import logic (`resolveRustImportDispatch`) requires a live file system
* and ResolveCtx that path is covered by test/integration/resolvers/rust.test.ts.
* - PHP `use function` / `use const` filtering (via `extractPhpNamedBindings`) requires
* tree-sitter PHP nodes covered by test/integration/resolvers/php.test.ts.
*/
import { describe, it, expect } from 'vitest';
import { preprocessImportPath } from '../../src/core/ingestion/import-resolution.js';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
// ---------------------------------------------------------------------------
// Minimal SyntaxNode stub — only the fields preprocessImportPath touches.
// For non-Kotlin languages preprocessImportPath never reads the node, so an
// empty stub satisfies the type requirement without loading tree-sitter.
// ---------------------------------------------------------------------------
function makeNode(overrides: Partial<{ childCount: number; child: (i: number) => any }> = {}): any {
return {
childCount: overrides.childCount ?? 0,
child: overrides.child ?? (() => null),
};
}
// ---------------------------------------------------------------------------
// preprocessImportPath — universal cleaning behaviour
// ---------------------------------------------------------------------------
describe('preprocessImportPath', () => {
describe('quote and bracket stripping', () => {
it('strips double quotes from a bare module path', () => {
const node = makeNode();
expect(preprocessImportPath('"foo"', node, SupportedLanguages.TypeScript)).toBe('foo');
});
it('strips single quotes from a bare module path', () => {
const node = makeNode();
expect(preprocessImportPath("'bar/baz'", node, SupportedLanguages.JavaScript)).toBe('bar/baz');
});
it('strips angle brackets from a C-style include path', () => {
const node = makeNode();
expect(preprocessImportPath('<stdio.h>', node, SupportedLanguages.C)).toBe('stdio.h');
});
it('strips mixed quote and angle bracket characters', () => {
const node = makeNode();
// Pathological input — all stripped characters removed
expect(preprocessImportPath('"<hello>"', node, SupportedLanguages.TypeScript)).toBe('hello');
});
});
describe('null returns for invalid inputs', () => {
it('returns null for an empty string (after cleaning)', () => {
const node = makeNode();
// Only quote characters — cleaned result is empty string
expect(preprocessImportPath('""', node, SupportedLanguages.TypeScript)).toBeNull();
});
it('returns null for a string containing control characters', () => {
const node = makeNode();
// \x01 is a control character that passes the length check but fails the regex guard
expect(preprocessImportPath('foo\x01bar', node, SupportedLanguages.Rust)).toBeNull();
});
it('returns null for a string containing a null byte', () => {
const node = makeNode();
expect(preprocessImportPath('foo\x00bar', node, SupportedLanguages.Go)).toBeNull();
});
it('returns null for a path exceeding 2048 characters', () => {
const node = makeNode();
const longPath = 'a'.repeat(2049);
expect(preprocessImportPath(longPath, node, SupportedLanguages.Python)).toBeNull();
});
it('accepts a path of exactly 2048 characters', () => {
const node = makeNode();
const maxPath = 'a'.repeat(2048);
expect(preprocessImportPath(maxPath, node, SupportedLanguages.Python)).toBe(maxPath);
});
});
describe('Kotlin wildcard pass-through', () => {
it('delegates to appendKotlinWildcard when language is Kotlin — no wildcard child', () => {
// Node with no children -> appendKotlinWildcard returns the path unchanged
const node = makeNode({ childCount: 0 });
const result = preprocessImportPath('com.example.models', node, SupportedLanguages.Kotlin);
// Without a wildcard_import child the path is returned as-is
expect(result).toBe('com.example.models');
});
it('delegates to appendKotlinWildcard when language is Kotlin — wildcard_import child present', () => {
// Simulate a node that has a wildcard_import child at index 0
const wildcardChild = { type: 'wildcard_import' };
const node = makeNode({
childCount: 1,
child: (i: number) => (i === 0 ? wildcardChild : null),
});
const result = preprocessImportPath('com.example.models', node, SupportedLanguages.Kotlin);
// appendKotlinWildcard appends .* when the wildcard_import child is found
expect(result).toBe('com.example.models.*');
});
});
describe('non-Kotlin languages are returned unchanged (after cleaning)', () => {
it('returns the cleaned path for Rust without modification', () => {
const node = makeNode();
expect(preprocessImportPath('"crate::models"', node, SupportedLanguages.Rust)).toBe('crate::models');
});
it('returns the cleaned path for PHP without modification', () => {
const node = makeNode();
expect(preprocessImportPath('"App\\\\Models\\\\User"', node, SupportedLanguages.PHP)).toBe('App\\\\Models\\\\User');
});
});
});
// ---------------------------------------------------------------------------
// Rust scoped grouped import logic (resolveRustImportDispatch)
// ---------------------------------------------------------------------------
// The dispatch function requires a live ResolveCtx with file lists — unit
// testing it without a file system would duplicate the integration fixtures.
// The following comment documents what the integration tests verify:
//
// test/integration/resolvers/rust.test.ts covers:
// - Top-level grouped: use {crate::a, crate::b}
// - Scoped grouped: use crate::models::{User, Repo}
// - Alias stripping: use crate::models::{User, Repo as R} -> resolves User + Repo
// - Prefix fallback: when no individual items resolve, resolves the prefix path
//
// The ::{ detection and alias-stripping logic lives in resolveRustImportDispatch()
// at import-resolution.ts lines 328-344.
// ---------------------------------------------------------------------------
// PHP use function / use const filtering (extractPhpNamedBindings)
// ---------------------------------------------------------------------------
// extractPhpNamedBindings requires live tree-sitter PHP SyntaxNode objects.
// The filtering of `use function` and `use const` declarations is covered by:
//
// test/integration/resolvers/php.test.ts
//
// which runs the full ingestion pipeline over PHP fixture repositories and
// asserts that function/const use-declarations do not produce spurious IMPORTS
// edges to non-existent class files.