Merge pull request #409 from abhigyanpatwari/refactor/language-dispatch-unification

refactor: unify language dispatch with compile-time exhaustive tables
This commit is contained in:
Gergő Magyar 2026-03-21 14:33:28 +00:00 committed by GitHub
commit 2428e72bd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 2441 additions and 1811 deletions

View file

@ -1,3 +1,33 @@
/**
* HOW TO ADD A NEW LANGUAGE:
*
* 1. Add the enum member below (e.g., Scala = 'scala')
* 2. Run `tsc --noEmit` compiler errors guide you to every dispatch table
* 3. Use this checklist for each file:
*
* FILE | WHAT TO ADD | DEFAULT (simple languages)
* ----------------------------------|------------------------------------------|---------------------------
* tree-sitter-queries.ts | Query string + LANGUAGE_QUERIES entry | (required)
* export-detection.ts | ExportChecker function + table entry | (required)
* 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 | []
* framework-detection.ts | AST_FRAMEWORK_PATTERNS entry | []
* type-extractors/<lang>.ts | New file + index.ts import | (required)
* resolvers/<lang>.ts | Resolver file (if non-standard) | (only if resolveStandard insufficient)
* named-binding-extraction.ts | Extractor (if named imports) | (only if language has named imports)
*
* 4. Also check these files for language-specific if-checks (no compile-time guard):
* - mro-processor.ts (MRO strategy selection)
* - heritage-processor.ts (extends/implements handling)
* - parse-worker.ts (AST edge cases)
* - parsing-processor.ts (node label normalization)
*
* 5. Add tree-sitter-<lang> to package.json dependencies
* 6. Add file extension mapping in utils.ts getLanguageFromFilename()
* 7. Run full test suite
*/
export enum SupportedLanguages {
JavaScript = 'javascript',
TypeScript = 'typescript',

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,30 +14,33 @@ 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
// ============================================================================
/**
* Common entry point naming patterns by language
* These patterns indicate functions that are likely feature entry points
* Common entry point naming patterns by language.
* These patterns indicate functions that are likely feature entry points.
*
* Universal patterns are separated from per-language patterns so the per-language
* table can use `satisfies Record<SupportedLanguages, RegExp[]>` for compile-time
* exhaustiveness the compiler catches any missing language entry.
*/
const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
// Universal patterns (apply to all languages)
'*': [
/^(main|init|bootstrap|start|run|setup|configure)$/i,
/^handle[A-Z]/, // handleLogin, handleSubmit
/^on[A-Z]/, // onClick, onSubmit
/Handler$/, // RequestHandler
/Controller$/, // UserController
/^process[A-Z]/, // processPayment
/^execute[A-Z]/, // executeQuery
/^perform[A-Z]/, // performAction
/^dispatch[A-Z]/, // dispatchEvent
/^trigger[A-Z]/, // triggerAction
/^fire[A-Z]/, // fireEvent
/^emit[A-Z]/, // emitEvent
],
const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [
/^(main|init|bootstrap|start|run|setup|configure)$/i,
/^handle[A-Z]/, // handleLogin, handleSubmit
/^on[A-Z]/, // onClick, onSubmit
/Handler$/, // RequestHandler
/Controller$/, // UserController
/^process[A-Z]/, // processPayment
/^execute[A-Z]/, // executeQuery
/^perform[A-Z]/, // performAction
/^dispatch[A-Z]/, // dispatchEvent
/^trigger[A-Z]/, // triggerAction
/^fire[A-Z]/, // fireEvent
/^emit[A-Z]/, // emitEvent
];
const ENTRY_POINT_PATTERNS = {
// JavaScript/TypeScript
[SupportedLanguages.JavaScript]: [
/^use[A-Z]/, // React hooks (useEffect, etc.)
@ -62,6 +65,17 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
/Service$/, // UserService
],
// Kotlin
[SupportedLanguages.Kotlin]: [
/^on(Create|Start|Resume|Pause|Stop|Destroy)$/, // Android lifecycle
/^do[A-Z]/, // doGet, doPost (shared JVM Servlet pattern)
/^create[A-Z]/, // Factory patterns
/^build[A-Z]/, // Builder patterns
/ViewModel$/, // MVVM pattern (Android)
/^module$/, // Ktor module entry point
/Service$/, // Service classes
],
// C#
[SupportedLanguages.CSharp]: [
/^(Get|Post|Put|Delete|Patch)/, // ASP.NET action methods
@ -77,7 +91,7 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
/Service$/, // Service classes
/^Seed/, // Database seeding
],
// Go
[SupportedLanguages.Go]: [
/Handler$/, // http.Handler pattern
@ -85,7 +99,7 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
/^New[A-Z]/, // Constructor pattern (returns new instance)
/^Make[A-Z]/, // Make functions
],
// Rust
[SupportedLanguages.Rust]: [
/^(get|post|put|delete)_handler$/i,
@ -94,7 +108,7 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
/^run$/, // run entry point
/^spawn/, // Async spawn
],
// C - explicit main() boost plus common C entry point conventions
[SupportedLanguages.C]: [
/^main$/, // THE entry point
@ -198,15 +212,15 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
/^perform$/, // Background jobs (Sidekiq, ActiveJob)
/^execute$/, // Command pattern
],
};
} satisfies Record<SupportedLanguages, RegExp[]>;
/** Pre-computed merged patterns (universal + language-specific) to avoid per-call array allocation. */
const MERGED_ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {};
const UNIVERSAL_PATTERNS = ENTRY_POINT_PATTERNS['*'] || [];
for (const [lang, patterns] of Object.entries(ENTRY_POINT_PATTERNS)) {
if (lang === '*') continue;
MERGED_ENTRY_POINT_PATTERNS[lang] = [...UNIVERSAL_PATTERNS, ...patterns];
}
const MERGED_ENTRY_POINT_PATTERNS = Object.fromEntries(
(Object.keys(ENTRY_POINT_PATTERNS) as SupportedLanguages[]).map(lang => [
lang,
[...UNIVERSAL_ENTRY_POINT_PATTERNS, ...ENTRY_POINT_PATTERNS[lang]],
])
) as Record<SupportedLanguages, RegExp[]>;
// ============================================================================
// UTILITY PATTERNS - Functions that should be penalized
@ -295,7 +309,7 @@ export function calculateEntryPointScore(
reasons.push('utility-pattern');
} else {
// Check positive patterns
const allPatterns = MERGED_ENTRY_POINT_PATTERNS[language] || UNIVERSAL_PATTERNS;
const allPatterns = MERGED_ENTRY_POINT_PATTERNS[language];
if (allPatterns.some(p => p.test(name))) {
nameMultiplier = 1.5; // Bonus for matching entry point pattern

View file

@ -10,6 +10,8 @@
* (no bonus, no penalty) - same behavior as before this feature.
*/
import { SupportedLanguages } from '../../config/supported-languages.js';
// ============================================================================
// TYPES
// ============================================================================
@ -234,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.endsWith('/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' };
}
@ -431,25 +433,36 @@ export const FRAMEWORK_AST_PATTERNS = {
'blazor': ['@page', '[Parameter]', '@inject'],
'efcore': ['DbContext', 'DbSet<', 'OnModelCreating'],
// Go patterns (function signatures)
'go-http': ['http.Handler', 'http.HandlerFunc', 'ServeHTTP'],
// Go patterns (function signatures include framework types)
'go-http': ['http.Handler', 'http.HandlerFunc', 'ServeHTTP', 'http.ResponseWriter', 'http.Request'],
'gin': ['gin.Context', 'gin.Default', 'gin.New'],
'echo': ['echo.Context', 'echo.New'],
'fiber': ['fiber.Ctx', 'fiber.New', 'fiber.App'],
'go-grpc': ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'],
// PHP/Laravel
'laravel': ['Route::get', 'Route::post', 'Route::put', 'Route::delete',
'Route::resource', 'Route::apiResource', '#[Route('],
// Rust macros
'actix': ['#[get', '#[post', '#[put', '#[delete'],
'axum': ['Router::new'],
'rocket': ['#[get', '#[post'],
// Rust macros (proc-macro attributes in definition text)
'actix': ['#[get', '#[post', '#[put', '#[delete', '#[actix_web', 'HttpRequest', 'HttpResponse'],
'axum': ['Router::new', 'axum::extract', 'axum::routing'],
'rocket': ['#[get', '#[post', '#[launch', 'rocket::'],
'tokio': ['#[tokio::main]', '#[tokio::test]'],
// C++ patterns (Qt, Boost)
'qt': ['Q_OBJECT', 'Q_INVOKABLE', 'Q_PROPERTY', 'Q_SIGNALS', 'Q_SLOTS', 'Q_SIGNAL', 'Q_SLOT', 'QWidget', 'QApplication'],
// Swift/iOS
'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController'],
'swiftui': ['@main', 'WindowGroup', 'ContentView', '@StateObject', '@ObservedObject'],
'combine': ['sink', 'assign', 'Publisher', 'Subscriber'],
};
'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController', '@IBOutlet', '@IBAction', '@objc'],
'swiftui': ['@main', 'WindowGroup', 'ContentView', '@StateObject', '@ObservedObject', '@EnvironmentObject', '@Published'],
'vapor': ['app.get', 'app.post', 'req.content.decode', 'Vapor'],
import { SupportedLanguages } from '../../config/supported-languages.js';
// Ruby patterns (class-level macros in definition text)
'rails': ['ApplicationController', 'ApplicationRecord', 'ActiveRecord::Base',
'before_action', 'after_action', 'has_many', 'belongs_to', 'has_one', 'validates'],
'sinatra': ['Sinatra::Base', 'Sinatra::Application'],
};
interface AstFrameworkPatternConfig {
framework: string;
@ -458,7 +471,7 @@ interface AstFrameworkPatternConfig {
patterns: string[];
}
const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record<string, AstFrameworkPatternConfig[]> = {
const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = {
[SupportedLanguages.JavaScript]: [
{ framework: 'nestjs', entryPointMultiplier: 3.2, reason: 'nestjs-decorator', patterns: FRAMEWORK_AST_PATTERNS.nestjs },
],
@ -488,7 +501,33 @@ const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record<string, AstFrameworkPatternConf
[SupportedLanguages.PHP]: [
{ framework: 'laravel', entryPointMultiplier: 3.0, reason: 'php-route-attribute', patterns: FRAMEWORK_AST_PATTERNS.laravel },
],
};
[SupportedLanguages.Go]: [
{ framework: 'go-http', entryPointMultiplier: 2.5, reason: 'go-http-handler', patterns: FRAMEWORK_AST_PATTERNS['go-http'] },
{ framework: 'gin', entryPointMultiplier: 3.0, reason: 'gin-handler', patterns: FRAMEWORK_AST_PATTERNS.gin },
{ framework: 'echo', entryPointMultiplier: 3.0, reason: 'echo-handler', patterns: FRAMEWORK_AST_PATTERNS.echo },
{ framework: 'fiber', entryPointMultiplier: 3.0, reason: 'fiber-handler', patterns: FRAMEWORK_AST_PATTERNS.fiber },
{ framework: 'go-grpc', entryPointMultiplier: 2.8, reason: 'grpc-service', patterns: FRAMEWORK_AST_PATTERNS['go-grpc'] },
],
[SupportedLanguages.Rust]: [
{ framework: 'actix-web', entryPointMultiplier: 3.0, reason: 'actix-attribute', patterns: FRAMEWORK_AST_PATTERNS.actix },
{ framework: 'axum', entryPointMultiplier: 3.0, reason: 'axum-routing', patterns: FRAMEWORK_AST_PATTERNS.axum },
{ framework: 'rocket', entryPointMultiplier: 3.0, reason: 'rocket-attribute', patterns: FRAMEWORK_AST_PATTERNS.rocket },
{ framework: 'tokio', entryPointMultiplier: 2.5, reason: 'tokio-runtime', patterns: FRAMEWORK_AST_PATTERNS.tokio },
],
[SupportedLanguages.C]: [], // C has no framework-specific AST patterns (POSIX/socket patterns are in entry-point-scoring)
[SupportedLanguages.CPlusPlus]: [
{ framework: 'qt', entryPointMultiplier: 2.8, reason: 'qt-macro', patterns: FRAMEWORK_AST_PATTERNS.qt },
],
[SupportedLanguages.Swift]: [
{ framework: 'uikit', entryPointMultiplier: 2.5, reason: 'uikit-lifecycle', patterns: FRAMEWORK_AST_PATTERNS.uikit },
{ framework: 'swiftui', entryPointMultiplier: 2.8, reason: 'swiftui-pattern', patterns: FRAMEWORK_AST_PATTERNS.swiftui },
{ framework: 'vapor', entryPointMultiplier: 3.0, reason: 'vapor-routing', patterns: FRAMEWORK_AST_PATTERNS.vapor },
],
[SupportedLanguages.Ruby]: [
{ framework: 'rails', entryPointMultiplier: 3.0, reason: 'rails-pattern', patterns: FRAMEWORK_AST_PATTERNS.rails },
{ framework: 'sinatra', entryPointMultiplier: 2.8, reason: 'sinatra-pattern', patterns: FRAMEWORK_AST_PATTERNS.sinatra },
],
} satisfies Record<SupportedLanguages, AstFrameworkPatternConfig[]>;
/** Pre-lowercased patterns for O(1) pattern matching at runtime */
const AST_PATTERNS_LOWERED: Record<string, Array<{ framework: string; entryPointMultiplier: number; reason: string; patterns: string[] }>> =

View file

@ -5,43 +5,15 @@ import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/pa
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename, isVerboseIngestionEnabled, yieldToEventLoop } from './utils.js';
import { SupportedLanguages } from '../../config/supported-languages.js';
import { extractNamedBindings } from './named-binding-extraction.js';
import type { ExtractedImport } from './workers/parse-worker.js';
import { getTreeSitterBufferSize } from './constants.js';
import {
loadTsconfigPaths,
loadGoModulePath,
loadComposerConfig,
loadCSharpProjectConfig,
loadSwiftPackageConfig,
type SwiftPackageConfig,
} from './language-config.js';
import {
buildSuffixIndex,
resolveImportPath,
appendKotlinWildcard,
KOTLIN_EXTENSIONS,
resolveJvmWildcard,
resolveJvmMemberImport,
resolveGoPackageDir,
resolveGoPackage,
resolveCSharpImport,
resolveCSharpNamespaceDir,
resolvePhpImport,
resolveRustImport,
resolveRubyImport,
resolvePythonImport,
} from './resolvers/index.js';
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 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
export type {
@ -89,204 +61,40 @@ export interface ImportResolutionContext {
allFilePaths: Set<string>;
allFileList: string[];
normalizedFileList: string[];
suffixIndex: SuffixIndex | null;
index: SuffixIndex;
resolveCache: Map<string, string | null>;
/** Release heavyweight fields (suffix index, file lists) to free memory after import resolution. */
dispose(): void;
}
export function buildImportResolutionContext(allPaths: string[]): ImportResolutionContext {
const allFileList = allPaths;
const normalizedFileList = allFileList.map(p => p.replace(/\\/g, '/'));
const allFilePaths = new Set(allFileList);
const suffixIndex = buildSuffixIndex(normalizedFileList, allFileList);
const ctx: ImportResolutionContext = {
allFilePaths, allFileList, normalizedFileList, suffixIndex, resolveCache: new Map(),
dispose() {
ctx.suffixIndex = null;
ctx.normalizedFileList = [];
ctx.resolveCache.clear();
},
};
return ctx;
const index = buildSuffixIndex(normalizedFileList, allFileList);
return { allFilePaths, allFileList, normalizedFileList, index, resolveCache: new Map() };
}
// Config loaders extracted to ./language-config.ts (Phase 2 refactor)
// Resolver functions are in ./resolvers/ — imported above
// Resolver dispatch tables are in ./import-resolution.ts — imported above
// ============================================================================
// SHARED LANGUAGE DISPATCH
// ============================================================================
/** Create IMPORTS edge helpers that share a resolved-count tracker. */
function createImportEdgeHelpers(graph: KnowledgeGraph, importMap: ImportMap) {
let totalImportsResolved = 0;
/** Bundled language-specific configs loaded once per ingestion run. */
interface LanguageConfigs {
tsconfigPaths: TsconfigPaths | null;
goModule: GoModuleConfig | null;
composerConfig: ComposerConfig | null;
swiftPackageConfig: SwiftPackageConfig | null;
csharpConfigs: CSharpProjectConfig[];
}
const addImportGraphEdge = (filePath: string, resolvedPath: string) => {
const sourceId = generateId('File', filePath);
const targetId = generateId('File', resolvedPath);
const relId = generateId('IMPORTS', `${filePath}->${resolvedPath}`);
totalImportsResolved++;
graph.addRelationship({ id: relId, sourceId, targetId, type: 'IMPORTS', confidence: 1.0, reason: '' });
};
/** Context for import path resolution (file lists, indexes, cache). */
interface ResolveCtx {
allFilePaths: Set<string>;
allFileList: string[];
normalizedFileList: string[];
index: SuffixIndex;
resolveCache: Map<string, string | null>;
}
const addImportEdge = (filePath: string, resolvedPath: string) => {
addImportGraphEdge(filePath, resolvedPath);
if (!importMap.has(filePath)) importMap.set(filePath, new Set());
importMap.get(filePath)!.add(resolvedPath);
};
/**
* Result of resolving an import via language-specific dispatch.
* - 'files': resolved to one or more files add to ImportMap
* - 'package': resolved to a directory add graph edges + store dirSuffix in PackageMap
* - null: no resolution (external dependency, etc.)
*/
type ImportResult =
| { kind: 'files'; files: string[] }
| { kind: 'package'; files: string[]; dirSuffix: string }
| null;
/**
* Shared language dispatch for import resolution.
* Used by both processImports and processImportsFromExtracted.
*/
function resolveLanguageImport(
filePath: string,
rawImportPath: string,
language: SupportedLanguages,
configs: LanguageConfigs,
ctx: ResolveCtx,
): ImportResult {
const { allFilePaths, allFileList, normalizedFileList, index, resolveCache } = ctx;
const { tsconfigPaths, goModule, composerConfig, swiftPackageConfig, csharpConfigs } = configs;
// JVM languages (Java + Kotlin): handle wildcards and member imports
if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) {
const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS;
if (rawImportPath.endsWith('.*')) {
const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index);
if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) {
const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
if (javaMatches.length > 0) return { kind: 'files', files: javaMatches };
}
if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles };
// Fall through to standard resolution
} else {
let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index);
if (!memberResolved && language === SupportedLanguages.Kotlin) {
memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
}
if (memberResolved) return { kind: 'files', files: [memberResolved] };
// Kotlin: top-level function imports (e.g. import models.getUser) have only 2 segments,
// which resolveJvmMemberImport skips (requires ≥3). Fall back to package-directory scan
// for lowercase last segments (function/property imports). Uppercase last segments
// (class imports like models.User) fall through to standard suffix resolution.
if (language === SupportedLanguages.Kotlin) {
const segments = rawImportPath.split('.');
const lastSeg = segments[segments.length - 1];
if (segments.length >= 2 && lastSeg[0] && lastSeg[0] === lastSeg[0].toLowerCase()) {
const pkgWildcard = segments.slice(0, -1).join('.') + '.*';
let dirFiles = resolveJvmWildcard(pkgWildcard, normalizedFileList, allFileList, exts, index);
if (dirFiles.length === 0) {
dirFiles = resolveJvmWildcard(pkgWildcard, normalizedFileList, allFileList, ['.java'], index);
}
if (dirFiles.length > 0) return { kind: 'files', files: dirFiles };
}
}
// Fall through to standard resolution
}
}
// Go: handle package-level imports
if (language === SupportedLanguages.Go && goModule && rawImportPath.startsWith(goModule.modulePath)) {
const pkgSuffix = resolveGoPackageDir(rawImportPath, goModule);
if (pkgSuffix) {
const pkgFiles = resolveGoPackage(rawImportPath, goModule, normalizedFileList, allFileList);
if (pkgFiles.length > 0) {
return { kind: 'package', files: pkgFiles, dirSuffix: pkgSuffix };
}
}
// Fall through if no files found (package might be external)
}
// C#: handle namespace-based imports (using directives)
if (language === SupportedLanguages.CSharp && csharpConfigs.length > 0) {
const resolvedFiles = resolveCSharpImport(rawImportPath, csharpConfigs, normalizedFileList, allFileList, index);
if (resolvedFiles.length > 1) {
const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
if (dirSuffix) {
return { kind: 'package', files: resolvedFiles, dirSuffix };
}
}
if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles };
return null;
}
// PHP: handle namespace-based imports (use statements)
if (language === SupportedLanguages.PHP) {
const resolved = resolvePhpImport(rawImportPath, composerConfig, allFilePaths, normalizedFileList, allFileList, index);
return resolved ? { kind: 'files', files: [resolved] } : null;
}
// Swift: handle module imports
if (language === SupportedLanguages.Swift && swiftPackageConfig) {
const targetDir = swiftPackageConfig.targets.get(rawImportPath);
if (targetDir) {
const dirPrefix = targetDir + '/';
const files: string[] = [];
for (let i = 0; i < normalizedFileList.length; i++) {
if (normalizedFileList[i].startsWith(dirPrefix) && normalizedFileList[i].endsWith('.swift')) {
files.push(allFileList[i]);
}
}
if (files.length > 0) return { kind: 'files', files };
}
return null; // External framework (Foundation, UIKit, etc.)
}
// Python: relative imports (PEP 328) + proximity-based bare imports
// Falls through to standard suffix resolution when proximity finds no match.
if (language === SupportedLanguages.Python) {
const resolved = resolvePythonImport(filePath, rawImportPath, allFilePaths);
if (resolved) return { kind: 'files', files: [resolved] };
if (rawImportPath.startsWith('.')) return null; // relative but unresolved — don't suffix-match
}
// Ruby: require / require_relative
if (language === SupportedLanguages.Ruby) {
const resolved = resolveRubyImport(rawImportPath, normalizedFileList, allFileList, index);
return resolved ? { kind: 'files', files: [resolved] } : null;
}
// Rust: expand top-level grouped imports: use {crate::a, crate::b}
if (language === SupportedLanguages.Rust && rawImportPath.startsWith('{') && rawImportPath.endsWith('}')) {
const inner = rawImportPath.slice(1, -1);
const parts = inner.split(',').map(p => p.trim()).filter(Boolean);
const resolved: string[] = [];
for (const part of parts) {
const r = resolveRustImport(filePath, part, allFilePaths);
if (r) resolved.push(r);
}
return resolved.length > 0 ? { kind: 'files', files: resolved } : null;
}
// Standard single-file resolution
const resolvedPath = resolveImportPath(
filePath,
rawImportPath,
allFilePaths,
allFileList,
normalizedFileList,
resolveCache,
language,
tsconfigPaths,
index,
);
return resolvedPath ? { kind: 'files', files: [resolvedPath] } : null;
return { addImportEdge, addImportGraphEdge, getResolvedCount: () => totalImportsResolved };
}
/**
@ -301,7 +109,7 @@ function applyImportResult(
packageMap: PackageMap | undefined,
addImportEdge: (from: string, to: string) => void,
addImportGraphEdge: (from: string, to: string) => void,
namedBindings?: { local: string; exported: string }[],
namedBindings?: NamedBinding[],
namedImportMap?: NamedImportMap,
): void {
if (!result) return;
@ -324,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 });
}
}
}
}
}
@ -371,46 +201,11 @@ export const processImports = async (
// Track import statistics
let totalImportsFound = 0;
let totalImportsResolved = 0;
// Load language-specific configs once before the file loop
const effectiveRoot = repoRoot || '';
const configs: LanguageConfigs = {
tsconfigPaths: await loadTsconfigPaths(effectiveRoot),
goModule: await loadGoModulePath(effectiveRoot),
composerConfig: await loadComposerConfig(effectiveRoot),
swiftPackageConfig: await loadSwiftPackageConfig(effectiveRoot),
csharpConfigs: await loadCSharpProjectConfig(effectiveRoot),
};
const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache };
// Helper: add an IMPORTS edge to the graph only (no ImportMap update)
const addImportGraphEdge = (filePath: string, resolvedPath: string) => {
const sourceId = generateId('File', filePath);
const targetId = generateId('File', resolvedPath);
const relId = generateId('IMPORTS', `${filePath}->${resolvedPath}`);
totalImportsResolved++;
graph.addRelationship({
id: relId,
sourceId,
targetId,
type: 'IMPORTS',
confidence: 1.0,
reason: '',
});
};
// Helper: add an IMPORTS edge + update import map
const addImportEdge = (filePath: string, resolvedPath: string) => {
addImportGraphEdge(filePath, resolvedPath);
if (!importMap.has(filePath)) {
importMap.set(filePath, new Set());
}
importMap.get(filePath)!.add(resolvedPath);
};
const configs = await loadImportConfigs(repoRoot || '');
const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache, configs };
const { addImportEdge, addImportGraphEdge, getResolvedCount } = createImportEdgeHelpers(graph, importMap);
for (let i = 0; i < files.length; i++) {
const file = files[i];
@ -483,14 +278,13 @@ export const processImports = async (
return;
}
// Clean path (remove quotes and angle brackets for C/C++ includes)
const rawImportPath = language === SupportedLanguages.Kotlin
? appendKotlinWildcard(sourceNode.text.replace(/['"<>]/g, ''), captureMap['import'])
: sourceNode.text.replace(/['"<>]/g, '');
const rawImportPath = preprocessImportPath(sourceNode.text, captureMap['import'], language);
if (!rawImportPath) return;
totalImportsFound++;
const result = resolveLanguageImport(file.path, rawImportPath, language, configs, resolveCtx);
const bindings = namedImportMap ? extractNamedBindings(captureMap['import'], language) : undefined;
const result = importResolvers[language](rawImportPath, file.path, resolveCtx);
const extractor = namedBindingExtractors[language];
const bindings = namedImportMap && extractor ? extractor(captureMap['import']) : undefined;
applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge, bindings, namedImportMap);
}
@ -502,7 +296,7 @@ export const processImports = async (
const routed = callRouter(callNameNode.text, captureMap['call']);
if (routed && routed.kind === 'import') {
totalImportsFound++;
const result = resolveLanguageImport(file.path, routed.importPath, language, configs, resolveCtx);
const result = importResolvers[language](routed.importPath, file.path, resolveCtx);
applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge);
}
}
@ -521,7 +315,7 @@ export const processImports = async (
}
if (isDev) {
console.log(`📊 Import processing complete: ${totalImportsResolved}/${totalImportsFound} imports resolved to graph edges`);
console.log(`📊 Import processing complete: ${getResolvedCount()}/${totalImportsFound} imports resolved to graph edges`);
}
};
@ -542,47 +336,13 @@ export const processImportsFromExtracted = async (
const packageMap = ctx.packageMap;
const namedImportMap = ctx.namedImportMap;
const importCtx = prebuiltCtx ?? buildImportResolutionContext(files.map(f => f.path));
const { allFilePaths, allFileList, normalizedFileList, suffixIndex: index, resolveCache } = importCtx;
const { allFilePaths, allFileList, normalizedFileList, index, resolveCache } = importCtx;
let totalImportsFound = 0;
let totalImportsResolved = 0;
const effectiveRoot = repoRoot || '';
const configs: LanguageConfigs = {
tsconfigPaths: await loadTsconfigPaths(effectiveRoot),
goModule: await loadGoModulePath(effectiveRoot),
composerConfig: await loadComposerConfig(effectiveRoot),
swiftPackageConfig: await loadSwiftPackageConfig(effectiveRoot),
csharpConfigs: await loadCSharpProjectConfig(effectiveRoot),
};
const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache };
// Helper: add an IMPORTS edge to the graph only (no ImportMap update)
const addImportGraphEdge = (filePath: string, resolvedPath: string) => {
const sourceId = generateId('File', filePath);
const targetId = generateId('File', resolvedPath);
const relId = generateId('IMPORTS', `${filePath}->${resolvedPath}`);
totalImportsResolved++;
graph.addRelationship({
id: relId,
sourceId,
targetId,
type: 'IMPORTS',
confidence: 1.0,
reason: '',
});
};
const addImportEdge = (filePath: string, resolvedPath: string) => {
addImportGraphEdge(filePath, resolvedPath);
if (!importMap.has(filePath)) {
importMap.set(filePath, new Set());
}
importMap.get(filePath)!.add(resolvedPath);
};
const configs = await loadImportConfigs(repoRoot || '');
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)
const importsByFile = new Map<string, ExtractedImport[]>();
@ -608,7 +368,7 @@ export const processImportsFromExtracted = async (
for (const imp of fileImports) {
totalImportsFound++;
const result = resolveLanguageImport(filePath, imp.rawImportPath, imp.language, configs, resolveCtx);
const result = importResolvers[imp.language](imp.rawImportPath, filePath, resolveCtx);
applyImportResult(result, filePath, importMap, packageMap, addImportEdge, addImportGraphEdge, imp.namedBindings, namedImportMap);
}
}
@ -616,6 +376,6 @@ export const processImportsFromExtracted = async (
onProgress?.(totalFiles, totalFiles);
if (isDev) {
console.log(`📊 Import processing (fast path): ${totalImportsResolved}/${totalImportsFound} imports resolved to graph edges`);
console.log(`📊 Import processing (fast path): ${getResolvedCount()}/${totalImportsFound} imports resolved to graph edges`);
}
};

View file

@ -0,0 +1,383 @@
/**
* Import Resolution Dispatch
*
* Per-language dispatch table for import resolution and named binding extraction.
* Replaces the 120-line if-chain in resolveLanguageImport() and the 7-branch
* dispatch in extractNamedBindings() with a single table lookup each.
*
* Follows the existing ExportChecker / CallRouter pattern:
* - Function aliases (not interfaces) to avoid megamorphic inline-cache issues
* - `satisfies Record<SupportedLanguages, ...>` for compile-time exhaustiveness
* - Const dispatch table configs are accessed via ctx.configs at call time
*/
import { SupportedLanguages } from '../../config/supported-languages.js';
import type { SyntaxNode } from './utils.js';
import {
KOTLIN_EXTENSIONS,
appendKotlinWildcard,
resolveJvmWildcard,
resolveJvmMemberImport,
resolveGoPackageDir,
resolveGoPackage,
resolveCSharpImport as resolveCSharpImportHelper,
resolveCSharpNamespaceDir,
resolvePhpImport as resolvePhpImportHelper,
resolveRustImport as resolveRustImportHelper,
resolveRubyImport as resolveRubyImportHelper,
resolvePythonImport as resolvePythonImportHelper,
resolveImportPath,
} from './resolvers/index.js';
import type {
SuffixIndex,
TsconfigPaths,
GoModuleConfig,
CSharpProjectConfig,
ComposerConfig,
} from './resolvers/index.js';
import type { SwiftPackageConfig } from './language-config.js';
import {
extractTsNamedBindings,
extractPythonNamedBindings,
extractKotlinNamedBindings,
extractRustNamedBindings,
extractPhpNamedBindings,
extractCsharpNamedBindings,
extractJavaNamedBindings,
} from './named-binding-extraction.js';
import type { ImportResolutionContext } from './import-processor.js';
// ============================================================================
// Types
// ============================================================================
/**
* Result of resolving an import via language-specific dispatch.
* - 'files': resolved to one or more files -> add to ImportMap
* - 'package': resolved to a directory -> add graph edges + store dirSuffix in PackageMap
* - null: no resolution (external dependency, etc.)
*/
export type ImportResult =
| { kind: 'files'; files: string[] }
| { 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,
filePath: string,
resolveCtx: ResolveCtx,
) => ImportResult;
/** A single named import binding: local name in the importing file and exported name from the source. */
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;
// ============================================================================
// Import path preprocessing
// ============================================================================
/**
* Clean and preprocess a raw import source text into a resolved import path.
* Strips quotes/angle brackets (universal) and applies language-specific
* transformations (currently only Kotlin wildcard import detection).
*/
export function preprocessImportPath(
sourceText: string,
importNode: SyntaxNode,
language: SupportedLanguages,
): 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 null;
if (language === SupportedLanguages.Kotlin) {
return appendKotlinWildcard(cleaned, importNode);
}
return cleaned;
}
// ============================================================================
// Per-language resolver functions
// ============================================================================
/**
* Standard single-file resolution (TS/JS/C/C++ and fallback for other languages).
* Handles relative imports, tsconfig path aliases, and suffix matching.
*/
function resolveStandard(
rawImportPath: string,
filePath: string,
ctx: ResolveCtx,
language: SupportedLanguages,
): ImportResult {
const resolvedPath = resolveImportPath(
filePath,
rawImportPath,
ctx.allFilePaths,
ctx.allFileList,
ctx.normalizedFileList,
ctx.resolveCache,
language,
ctx.configs.tsconfigPaths,
ctx.index,
);
return resolvedPath ? { kind: 'files', files: [resolvedPath] } : null;
}
/** Java: JVM wildcard -> member import -> standard fallthrough */
function resolveJavaImport(
rawImportPath: string,
filePath: string,
ctx: ResolveCtx,
): ImportResult {
if (rawImportPath.endsWith('.*')) {
const matchedFiles = resolveJvmWildcard(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index);
if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles };
} else {
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);
}
/**
* Kotlin: JVM wildcard/member with Java-interop fallback -> top-level function imports -> standard.
* Kotlin can import from .kt/.kts files OR from .java files (Java interop).
*/
function resolveKotlinImport(
rawImportPath: string,
filePath: string,
ctx: ResolveCtx,
): ImportResult {
if (rawImportPath.endsWith('.*')) {
const matchedFiles = resolveJvmWildcard(rawImportPath, ctx.normalizedFileList, ctx.allFileList, KOTLIN_EXTENSIONS, ctx.index);
if (matchedFiles.length === 0) {
const javaMatches = resolveJvmWildcard(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index);
if (javaMatches.length > 0) return { kind: 'files', files: javaMatches };
}
if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles };
} else {
let memberResolved = resolveJvmMemberImport(rawImportPath, ctx.normalizedFileList, ctx.allFileList, KOTLIN_EXTENSIONS, ctx.index);
if (!memberResolved) {
memberResolved = resolveJvmMemberImport(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index);
}
if (memberResolved) return { kind: 'files', files: [memberResolved] };
// Kotlin: top-level function imports (e.g. import models.getUser) have only 2 segments,
// which resolveJvmMemberImport skips (requires >=3). Fall back to package-directory scan
// for lowercase last segments (function/property imports). Uppercase last segments
// (class imports like models.User) fall through to standard suffix resolution.
const segments = rawImportPath.split('.');
const lastSeg = segments[segments.length - 1];
if (segments.length >= 2 && lastSeg[0] && lastSeg[0] === lastSeg[0].toLowerCase()) {
const pkgWildcard = segments.slice(0, -1).join('.') + '.*';
let dirFiles = resolveJvmWildcard(pkgWildcard, ctx.normalizedFileList, ctx.allFileList, KOTLIN_EXTENSIONS, ctx.index);
if (dirFiles.length === 0) {
dirFiles = resolveJvmWildcard(pkgWildcard, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index);
}
if (dirFiles.length > 0) return { kind: 'files', files: dirFiles };
}
}
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Kotlin);
}
/** Go: package-level imports via go.mod module path. */
function resolveGoImport(
rawImportPath: string,
filePath: string,
ctx: ResolveCtx,
): ImportResult {
const goModule = ctx.configs.goModule;
if (goModule && rawImportPath.startsWith(goModule.modulePath)) {
const pkgSuffix = resolveGoPackageDir(rawImportPath, goModule);
if (pkgSuffix) {
const pkgFiles = resolveGoPackage(rawImportPath, goModule, ctx.normalizedFileList, ctx.allFileList);
if (pkgFiles.length > 0) {
return { kind: 'package', files: pkgFiles, dirSuffix: pkgSuffix };
}
}
// Fall through if no files found (package might be external)
}
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Go);
}
/** C#: namespace-based resolution via .csproj configs, with suffix-match fallback. */
function resolveCSharpImportDispatch(
rawImportPath: string,
filePath: string,
ctx: ResolveCtx,
): 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) {
const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
if (dirSuffix) {
return { kind: 'package', files: resolvedFiles, dirSuffix };
}
}
if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles };
}
return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.CSharp);
}
/** PHP: namespace-based resolution via composer.json PSR-4. */
function resolvePhpImportDispatch(
rawImportPath: string,
_filePath: string,
ctx: ResolveCtx,
): ImportResult {
const resolved = resolvePhpImportHelper(rawImportPath, ctx.configs.composerConfig, ctx.allFilePaths, ctx.normalizedFileList, ctx.allFileList, ctx.index);
return resolved ? { kind: 'files', files: [resolved] } : null;
}
/** Swift: module imports via Package.swift target map. */
function resolveSwiftImportDispatch(
rawImportPath: string,
_filePath: string,
ctx: ResolveCtx,
): ImportResult {
const swiftPackageConfig = ctx.configs.swiftPackageConfig;
if (swiftPackageConfig) {
const targetDir = swiftPackageConfig.targets.get(rawImportPath);
if (targetDir) {
const dirPrefix = targetDir + '/';
const files: string[] = [];
for (let i = 0; i < ctx.normalizedFileList.length; i++) {
if (ctx.normalizedFileList[i].startsWith(dirPrefix) && ctx.normalizedFileList[i].endsWith('.swift')) {
files.push(ctx.allFileList[i]);
}
}
if (files.length > 0) return { kind: 'files', files };
}
}
return null; // External framework (Foundation, UIKit, etc.)
}
/**
* Python: relative imports (PEP 328) + proximity-based bare imports.
* Falls through to standard suffix resolution when proximity finds no match.
*/
function resolvePythonImportDispatch(
rawImportPath: string,
filePath: string,
ctx: ResolveCtx,
): 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);
}
/** Ruby: require / require_relative. */
function resolveRubyImportDispatch(
rawImportPath: string,
_filePath: string,
ctx: ResolveCtx,
): ImportResult {
const resolved = resolveRubyImportHelper(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ctx.index);
return resolved ? { kind: 'files', files: [resolved] } : null;
}
/** Rust: expand grouped imports: use {crate::a, crate::b} and use crate::models::{User, Repo}. */
function resolveRustImportDispatch(
rawImportPath: string,
filePath: string,
ctx: ResolveCtx,
): 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);
const resolved: string[] = [];
for (const part of parts) {
const r = resolveRustImportHelper(filePath, part, ctx.allFilePaths);
if (r) resolved.push(r);
}
return resolved.length > 0 ? { kind: 'files', files: resolved } : null;
}
// 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);
}
// ============================================================================
// Dispatch tables
// ============================================================================
/**
* 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 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.
* Languages with whole-module import semantics (Go, Ruby, C/C++, Swift) return undefined --
* their bindings are synthesized post-parse by synthesizeWildcardImportBindings() in pipeline.ts.
*/
export const namedBindingExtractors = {
[SupportedLanguages.JavaScript]: extractTsNamedBindings,
[SupportedLanguages.TypeScript]: extractTsNamedBindings,
[SupportedLanguages.Python]: extractPythonNamedBindings,
[SupportedLanguages.Java]: extractJavaNamedBindings,
[SupportedLanguages.C]: undefined,
[SupportedLanguages.CPlusPlus]: undefined,
[SupportedLanguages.CSharp]: extractCsharpNamedBindings,
[SupportedLanguages.Go]: undefined,
[SupportedLanguages.Ruby]: undefined,
[SupportedLanguages.Rust]: extractRustNamedBindings,
[SupportedLanguages.PHP]: extractPhpNamedBindings,
[SupportedLanguages.Kotlin]: extractKotlinNamedBindings,
[SupportedLanguages.Swift]: undefined,
} satisfies Record<SupportedLanguages, NamedBindingExtractorFn | undefined>;

View file

@ -1,5 +1,6 @@
import fs from 'fs/promises';
import path from 'path';
import type { ImportConfigs } from './import-resolution.js';
const isDev = process.env.NODE_ENV === 'development';
@ -213,3 +214,18 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
}
return null;
}
// ============================================================================
// BUNDLED CONFIG LOADER
// ============================================================================
/** Load all language-specific configs once for an ingestion run. */
export async function loadImportConfigs(repoRoot: string): Promise<ImportConfigs> {
return {
tsconfigPaths: await loadTsconfigPaths(repoRoot),
goModule: await loadGoModulePath(repoRoot),
composerConfig: await loadComposerConfig(repoRoot),
swiftPackageConfig: await loadSwiftPackageConfig(repoRoot),
csharpConfigs: await loadCSharpProjectConfig(repoRoot),
};
}

View file

@ -1,6 +1,8 @@
import { SupportedLanguages } from '../../config/supported-languages.js';
import type { SymbolTable, SymbolDefinition } from './symbol-table.js';
import type { NamedImportMap } from './import-processor.js';
import type { NamedBinding } from './import-resolution.js';
import type { SyntaxNode } from './utils.js';
import { findChild } from './resolvers/utils.js';
/**
* Walk a named-binding re-export chain through NamedImportMap.
@ -54,61 +56,14 @@ export function walkBindingChain(
return null;
}
/**
* Extract named bindings from an import AST node.
* Returns undefined if the import is not a named import (e.g., import * or default).
*
* TS: import { User, Repo as R } from './models'
* [{local:'User', exported:'User'}, {local:'R', exported:'Repo'}]
*
* Python: from models import User, Repo as R
* [{local:'User', exported:'User'}, {local:'R', exported:'Repo'}]
*/
export function extractNamedBindings(
importNode: any,
language: SupportedLanguages,
): { local: string; exported: string }[] | undefined {
if (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) {
return extractTsNamedBindings(importNode);
}
if (language === SupportedLanguages.Python) {
return extractPythonNamedBindings(importNode);
}
if (language === SupportedLanguages.Kotlin) {
return extractKotlinNamedBindings(importNode);
}
if (language === SupportedLanguages.Rust) {
return extractRustNamedBindings(importNode);
}
if (language === SupportedLanguages.PHP) {
return extractPhpNamedBindings(importNode);
}
if (language === SupportedLanguages.CSharp) {
return extractCsharpNamedBindings(importNode);
}
if (language === SupportedLanguages.Java) {
return extractJavaNamedBindings(importNode);
}
// Languages below use whole-module import semantics — the import AST node does not
// name specific symbols. namedImportMap entries are synthesized post-parse by
// synthesizeWildcardImportBindings() in pipeline.ts, which expands ImportMap edges
// into per-symbol bindings using graph-exported symbols.
//
// Go: `import "pkg"` — all PascalCase symbols available as pkg.Symbol
// Ruby: `require 'file'` — all top-level classes/modules available
// C/C++: `#include "file.h"` — textual inclusion, all non-static declarations available
// Swift: `import Module` — entire module imported (Phase S blocked on tree-sitter-swift Node 22)
return undefined;
}
export function extractTsNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
export function extractTsNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
// import_statement > import_clause > named_imports > import_specifier*
const importClause = findChild(importNode, 'import_clause');
if (importClause) {
const namedImports = findChild(importClause, 'named_imports');
if (!namedImports) return undefined; // default import, namespace import, or side-effect
const bindings: { local: string; exported: string }[] = [];
const bindings: NamedBinding[] = [];
for (let i = 0; i < namedImports.namedChildCount; i++) {
const specifier = namedImports.namedChild(i);
if (specifier?.type !== 'import_specifier') continue;
@ -132,7 +87,7 @@ export function extractTsNamedBindings(importNode: any): { local: string; export
// Re-export: export { X } from './y' → export_statement > export_clause > export_specifier
const exportClause = findChild(importNode, 'export_clause');
if (exportClause) {
const bindings: { local: string; exported: string }[] = [];
const bindings: NamedBinding[] = [];
for (let i = 0; i < exportClause.namedChildCount; i++) {
const specifier = exportClause.namedChild(i);
if (specifier?.type !== 'export_specifier') continue;
@ -159,11 +114,11 @@ export function extractTsNamedBindings(importNode: any): { local: string; export
return undefined;
}
export function extractPythonNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
export function extractPythonNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
// Only from import_from_statement, not plain import_statement
if (importNode.type !== 'import_from_statement') return undefined;
const bindings: { local: string; exported: string }[] = [];
const bindings: NamedBinding[] = [];
for (let i = 0; i < importNode.namedChildCount; i++) {
const child = importNode.namedChild(i);
if (!child) continue;
@ -191,7 +146,7 @@ export function extractPythonNamedBindings(importNode: any): { local: string; ex
return bindings.length > 0 ? bindings : undefined;
}
export function extractKotlinNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
export function extractKotlinNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
// import_header > identifier + import_alias > simple_identifier
if (importNode.type !== 'import_header') return undefined;
@ -226,16 +181,16 @@ export function extractKotlinNamedBindings(importNode: any): { local: string; ex
return [{ local: exportedName, exported: exportedName }];
}
export function extractRustNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
export function extractRustNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
// use_declaration may contain use_as_clause at any depth
if (importNode.type !== 'use_declaration') return undefined;
const bindings: { local: string; exported: string }[] = [];
const bindings: NamedBinding[] = [];
collectRustBindings(importNode, bindings);
return bindings.length > 0 ? bindings : undefined;
}
function collectRustBindings(node: any, bindings: { local: string; exported: string }[]): void {
function collectRustBindings(node: SyntaxNode, bindings: NamedBinding[]): void {
if (node.type === 'use_as_clause') {
// First identifier = exported name, second identifier = local alias
const idents: string[] = [];
@ -293,15 +248,22 @@ function collectRustBindings(node: any, bindings: { local: string; exported: str
}
}
export function extractPhpNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
export function extractPhpNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
// namespace_use_declaration > namespace_use_clause* (flat)
// namespace_use_declaration > namespace_use_group > namespace_use_clause* (grouped)
if (importNode.type !== 'namespace_use_declaration') return undefined;
const bindings: { local: string; exported: string }[] = [];
// 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
const clauses: any[] = [];
const clauses: SyntaxNode[] = [];
for (let i = 0; i < importNode.namedChildCount; i++) {
const child = importNode.namedChild(i);
if (child?.type === 'namespace_use_clause') {
@ -316,8 +278,8 @@ export function extractPhpNamedBindings(importNode: any): { local: string; expor
for (const clause of clauses) {
// Flat imports: qualified_name + name (alias)
let qualifiedName: any = null;
const names: any[] = [];
let qualifiedName: SyntaxNode | null = null;
const names: SyntaxNode[] = [];
for (let j = 0; j < clause.namedChildCount; j++) {
const child = clause.namedChild(j);
if (child?.type === 'qualified_name') qualifiedName = child;
@ -345,15 +307,15 @@ export function extractPhpNamedBindings(importNode: any): { local: string; expor
return bindings.length > 0 ? bindings : undefined;
}
export function extractCsharpNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
export function extractCsharpNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
// using_directive — three forms:
// using Alias = NS.Type; → aliasIdent + qualifiedName
// using static NS.Type; → static + qualifiedName (no alias)
// using NS; → qualifiedName only (namespace, not capturable)
if (importNode.type !== 'using_directive') return undefined;
let aliasIdent: any = null;
let qualifiedName: any = null;
let aliasIdent: SyntaxNode | null = null;
let qualifiedName: SyntaxNode | null = null;
let isStatic = false;
for (let i = 0; i < importNode.childCount; i++) {
const child = importNode.child(i);
@ -383,7 +345,7 @@ export function extractCsharpNamedBindings(importNode: any): { local: string; ex
return undefined;
}
export function extractJavaNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
export function extractJavaNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined {
// import_declaration > scoped_identifier "com.example.models.User"
// Wildcard imports (.*) don't produce named bindings
if (importNode.type !== 'import_declaration') return undefined;
@ -411,10 +373,3 @@ export function extractJavaNamedBindings(importNode: any): { local: string; expo
return [{ local: name, exported: name }];
}
function findChild(node: any, type: string): any {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === type) return child;
}
return null;
}

View file

@ -5,12 +5,11 @@ import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
import { generateId } from '../../lib/utils.js';
import { SymbolTable } from './symbol-table.js';
import { ASTCache } from './ast-cache.js';
import { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature, isKotlinClassMethod } from './utils.js';
import { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature, getLabelFromCaptures } from './utils.js';
import { extractPropertyDeclaredType } from './type-extractors/shared.js';
import { isNodeExported } from './export-detection.js';
import { detectFrameworkFromAST } from './framework-detection.js';
import { typeConfigs } from './type-extractors/index.js';
import { SupportedLanguages } from '../../config/supported-languages.js';
import { WorkerPool } from './workers/worker-pool.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings, FileTypeEnvBindings } from './workers/parse-worker.js';
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
@ -27,10 +26,6 @@ export interface WorkerExtractedData {
typeEnvBindings: FileTypeEnvBindings[];
}
// isNodeExported imported from ./export-detection.js (shared module)
// Re-export for backward compatibility with any external consumers
export { isNodeExported } from './export-detection.js';
// ============================================================================
// Worker-based parallel parsing
// ============================================================================
@ -196,65 +191,14 @@ const processParsingSequential = async (
captureMap[c.name] = c.node;
});
if (captureMap['import']) {
return;
}
if (captureMap['call']) {
return;
}
const nodeLabel = getLabelFromCaptures(captureMap, language);
if (!nodeLabel) return;
const nameNode = captureMap['name'];
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (!nameNode && !captureMap['definition.constructor']) return;
if (!nameNode && nodeLabel !== 'Constructor') return;
const nodeName = nameNode ? nameNode.text : 'init';
let nodeLabel: NodeLabel = 'CodeElement';
if (captureMap['definition.function']) {
// C/C++: @definition.function is broad and also matches inline class methods (inside
// a class/struct body). Those are already captured by @definition.method, so skip
// the duplicate Function entry to prevent double-indexing in globalIndex.
if (language === SupportedLanguages.CPlusPlus || language === SupportedLanguages.C) {
let ancestor = captureMap['definition.function']?.parent;
while (ancestor) {
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') {
break;
}
ancestor = ancestor.parent;
}
if (ancestor) return; // inside a class body — handled by @definition.method
}
// Kotlin: function_declaration inside a class_body is a method, not a top-level function.
if (language === SupportedLanguages.Kotlin &&
isKotlinClassMethod(captureMap['definition.function'])) {
nodeLabel = 'Method';
}
if (nodeLabel !== 'Method') nodeLabel = 'Function';
}
else if (captureMap['definition.class']) nodeLabel = 'Class';
else if (captureMap['definition.interface']) nodeLabel = 'Interface';
else if (captureMap['definition.method']) nodeLabel = 'Method';
else if (captureMap['definition.struct']) nodeLabel = 'Struct';
else if (captureMap['definition.enum']) nodeLabel = 'Enum';
else if (captureMap['definition.namespace']) nodeLabel = 'Namespace';
else if (captureMap['definition.module']) nodeLabel = 'Module';
else if (captureMap['definition.trait']) nodeLabel = 'Trait';
else if (captureMap['definition.impl']) nodeLabel = 'Impl';
else if (captureMap['definition.type']) nodeLabel = 'TypeAlias';
else if (captureMap['definition.const']) nodeLabel = 'Const';
else if (captureMap['definition.static']) nodeLabel = 'Static';
else if (captureMap['definition.typedef']) nodeLabel = 'Typedef';
else if (captureMap['definition.macro']) nodeLabel = 'Macro';
else if (captureMap['definition.union']) nodeLabel = 'Union';
else if (captureMap['definition.property']) nodeLabel = 'Property';
else if (captureMap['definition.record']) nodeLabel = 'Record';
else if (captureMap['definition.delegate']) nodeLabel = 'Delegate';
else if (captureMap['definition.annotation']) nodeLabel = 'Annotation';
else if (captureMap['definition.constructor']) nodeLabel = 'Constructor';
else if (captureMap['definition.template']) nodeLabel = 'Template';
const definitionNodeForRange = getDefinitionNodeFromCaptures(captureMap);
const startLine = definitionNodeForRange ? definitionNodeForRange.startPosition.row : (nameNode ? nameNode.startPosition.row : 0);
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);

View file

@ -7,6 +7,7 @@ import {
processImportsFromExtracted,
buildImportResolutionContext
} from './import-processor.js';
import { EMPTY_INDEX } from './resolvers/index.js';
import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted, seedCrossFileReceiverTypes, buildImportedReturnTypes, buildImportedRawReturnTypes, type ExportedTypeMap, buildExportedTypeMapFromGraph } from './call-processor.js';
import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js';
import { computeMRO } from './mro-processor.js';
@ -729,7 +730,8 @@ export const runPipelineFromRepo = async (
// (allPathObjects and importCtx hold ~94MB+ for large repos)
allPathObjects.length = 0;
importCtx.resolveCache.clear();
importCtx.dispose();
importCtx.index = EMPTY_INDEX; // Release suffix index memory (~30MB for large repos)
importCtx.normalizedFileList = [];
let communityResult: Awaited<ReturnType<typeof processCommunities>> | undefined;
let processResult: Awaited<ReturnType<typeof processProcesses>> | undefined;

View file

@ -3,7 +3,7 @@
* Extracted from import-processor.ts for maintainability.
*/
export { EXTENSIONS, tryResolveWithExtensions, buildSuffixIndex, suffixResolve } from './utils.js';
export { EXTENSIONS, tryResolveWithExtensions, buildSuffixIndex, suffixResolve, EMPTY_INDEX } from './utils.js';
export type { SuffixIndex } from './utils.js';
export { KOTLIN_EXTENSIONS, appendKotlinWildcard, resolveJvmWildcard, resolveJvmMemberImport } from './jvm.js';

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

@ -3,6 +3,8 @@
* Extracted from import-processor.ts to reduce file size.
*/
import type { SyntaxNode } from '../utils.js';
/** All file extensions to try during resolution */
export const EXTENSIONS = [
'',
@ -63,6 +65,15 @@ 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: () => FROZEN_EMPTY_ARRAY,
});
export function buildSuffixIndex(normalizedFileList: string[], allFileList: string[]): SuffixIndex {
// Map: normalized suffix -> original file path
const exactMap = new Map<string, string>();
@ -156,3 +167,12 @@ export function suffixResolve(
}
return null;
}
/** Find the first direct named child of a tree-sitter node matching the given type. */
export function findChild(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;
}

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

@ -31,7 +31,7 @@ import {
isBuiltInOrNoise,
getDefinitionNodeFromCaptures,
findEnclosingClassId,
isKotlinClassMethod,
getLabelFromCaptures,
extractMethodSignature,
countCallArguments,
inferCallForm,
@ -46,8 +46,8 @@ import { isNodeExported } from '../export-detection.js';
import { detectFrameworkFromAST } from '../framework-detection.js';
import { typeConfigs } from '../type-extractors/index.js';
import { generateId } from '../../../lib/utils.js';
import { extractNamedBindings } from '../named-binding-extraction.js';
import { appendKotlinWildcard } from '../resolvers/index.js';
import { namedBindingExtractors, preprocessImportPath } from '../import-resolution.js';
import type { NamedBinding } from '../import-resolution.js';
import { callRouters } from '../call-routing.js';
import { extractPropertyDeclaredType } from '../type-extractors/shared.js';
import type { NodeLabel } from '../../graph/types.js';
@ -102,7 +102,7 @@ export interface ExtractedImport {
rawImportPath: string;
language: SupportedLanguages;
/** Named bindings from the import (e.g., import {User as U} → [{local:'U', exported:'User'}]) */
namedBindings?: { local: string; exported: string }[];
namedBindings?: NamedBinding[];
}
export interface ExtractedCall {
@ -259,40 +259,7 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
return null;
};
// ============================================================================
// Label detection from capture map
// ============================================================================
const getLabelFromCaptures = (captureMap: Record<string, any>): NodeLabel | null => {
// Skip imports (handled separately) and calls
if (captureMap['import'] || captureMap['call']) return null;
// Allow constructors without explicit @name capture (e.g. Swift init) — name synthesized downstream
if (!captureMap['name'] && !captureMap['definition.constructor']) return null;
if (captureMap['definition.function']) 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';
};
// Label detection moved to shared getLabelFromCaptures in utils.ts
// DEFINITION_CAPTURE_KEYS and getDefinitionNodeFromCaptures imported from ../utils.js
@ -964,10 +931,10 @@ const processFileGroup = (
// Extract import paths before skipping
if (captureMap['import'] && captureMap['import.source']) {
const rawImportPath = language === SupportedLanguages.Kotlin
? appendKotlinWildcard(captureMap['import.source'].text.replace(/['"<>]/g, ''), captureMap['import'])
: captureMap['import.source'].text.replace(/['"<>]/g, '');
const namedBindings = extractNamedBindings(captureMap['import'], language);
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({
filePath: file.path,
rawImportPath,
@ -1166,32 +1133,9 @@ const processFileGroup = (
}
}
let nodeLabel = getLabelFromCaptures(captureMap);
const nodeLabel = getLabelFromCaptures(captureMap, language);
if (!nodeLabel) continue;
// C/C++: @definition.function is broad and also matches inline class methods (inside
// a class/struct body). Those are already captured by @definition.method, so skip
// the duplicate Function entry to prevent double-indexing in globalIndex.
if (
(language === SupportedLanguages.CPlusPlus || language === SupportedLanguages.C) &&
nodeLabel === 'Function'
) {
let ancestor = captureMap['definition.function']?.parent;
while (ancestor) {
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') {
break; // inside a class body — duplicate of @definition.method
}
ancestor = ancestor.parent;
}
if (ancestor) continue; // found a class/struct ancestor → skip
}
// Kotlin: function_declaration inside a class_body is a method, not a top-level function.
if (language === SupportedLanguages.Kotlin && nodeLabel === 'Function' &&
isKotlinClassMethod(captureMap['definition.function'])) {
nodeLabel = 'Method';
}
const nameNode = captureMap['name'];
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (!nameNode && nodeLabel !== 'Constructor') continue;

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

@ -10,7 +10,7 @@ import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { isNodeExported } from '../../src/core/ingestion/parsing-processor.js';
import { isNodeExported } from '../../src/core/ingestion/export-detection.js';
import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js';
import { getLanguageFromFilename } from '../../src/core/ingestion/utils.js';
import { SupportedLanguages } from '../../src/config/supported-languages.js';

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', () => {
@ -299,8 +304,14 @@ describe('detectFrameworkFromAST', () => {
expect(result!.framework).toBe('laravel');
});
it('returns null for unsupported language', () => {
expect(detectFrameworkFromAST('rust', '#[get("/")]')).toBeNull();
it('detects Actix-web route attributes in Rust', () => {
const result = detectFrameworkFromAST('rust', '#[get("/")]');
expect(result).not.toBeNull();
expect(result!.framework).toBe('actix-web');
});
it('returns null for language with no matching pattern', () => {
expect(detectFrameworkFromAST('c', 'int main() { return 0; }')).toBeNull();
});
it('is case-insensitive', () => {
@ -313,8 +324,9 @@ describe('FRAMEWORK_AST_PATTERNS', () => {
it('has patterns for all expected frameworks', () => {
const expectedFrameworks = [
'nestjs', 'express', 'fastapi', 'flask', 'spring', 'jaxrs',
'aspnet', 'go-http', 'laravel', 'actix', 'axum', 'rocket',
'uikit', 'swiftui', 'combine',
'aspnet', 'go-http', 'gin', 'echo', 'fiber', 'go-grpc',
'laravel', 'actix', 'axum', 'rocket', 'tokio', 'qt',
'uikit', 'swiftui', 'vapor', 'rails', 'sinatra',
];
for (const fw of expectedFrameworks) {
expect(FRAMEWORK_AST_PATTERNS).toHaveProperty(fw);

View file

@ -48,8 +48,8 @@ describe('buildImportResolutionContext', () => {
});
it('creates a suffix index for O(1) lookups', () => {
expect(ctx.suffixIndex).toBeDefined();
expect(typeof ctx.suffixIndex.get).toBe('function');
expect(ctx.index).toBeDefined();
expect(typeof ctx.index.get).toBe('function');
});
it('initializes empty resolve cache', () => {
@ -65,22 +65,22 @@ describe('buildImportResolutionContext', () => {
describe('suffix index', () => {
it('resolves file by suffix', () => {
const result = ctx.suffixIndex.get('utils.ts');
const result = ctx.index.get('utils.ts');
expect(result).toBeDefined();
});
it('resolves file by full path', () => {
const result = ctx.suffixIndex.get('src/index.ts');
const result = ctx.index.get('src/index.ts');
expect(result).toBeDefined();
});
it('resolves nested component path', () => {
const result = ctx.suffixIndex.get('components/Button.tsx');
const result = ctx.index.get('components/Button.tsx');
expect(result).toBeDefined();
});
it('returns undefined for non-existent suffix', () => {
const result = ctx.suffixIndex.get('nonexistent.ts');
const result = ctx.index.get('nonexistent.ts');
expect(result).toBeUndefined();
});
});

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.