mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-21 00:21:30 +00:00
Merge pull request #409 from abhigyanpatwari/refactor/language-dispatch-unification
refactor: unify language dispatch with compile-time exhaustive tables
This commit is contained in:
commit
2428e72bd2
45 changed files with 2441 additions and 1811 deletions
|
|
@ -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',
|
||||
|
|
|
|||
710
gitnexus/src/core/ingestion/ast-helpers.ts
Normal file
710
gitnexus/src/core/ingestion/ast-helpers.ts
Normal 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 };
|
||||
};
|
||||
|
||||
539
gitnexus/src/core/ingestion/call-analysis.ts
Normal file
539
gitnexus/src/core/ingestion/call-analysis.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[] }>> =
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
383
gitnexus/src/core/ingestion/import-resolution.ts
Normal file
383
gitnexus/src/core/ingestion/import-resolution.ts
Normal 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>;
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}.*`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -47,6 +47,5 @@ export {
|
|||
extractSimpleTypeName,
|
||||
extractGenericTypeArgs,
|
||||
extractVarName,
|
||||
findChildByType,
|
||||
extractRubyConstructorAssignment
|
||||
} from './shared.js';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
8
gitnexus/test/fixtures/lang-resolution/csharp-no-csproj/Models/User.cs
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/csharp-no-csproj/Models/User.cs
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace Models
|
||||
{
|
||||
public class User
|
||||
{
|
||||
public void Save() { }
|
||||
public string GetName() { return "alice"; }
|
||||
}
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/csharp-no-csproj/Services/UserService.cs
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/csharp-no-csproj/Services/UserService.cs
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using Models;
|
||||
|
||||
namespace Services
|
||||
{
|
||||
public class UserService
|
||||
{
|
||||
public void ProcessUser()
|
||||
{
|
||||
var user = new User();
|
||||
user.Save();
|
||||
}
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/go-cmd-helper/cmd/server/internal/config/config.go
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/go-cmd-helper/cmd/server/internal/config/config.go
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package config
|
||||
|
||||
func Load() string {
|
||||
return "loaded"
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/go-cmd-helper/cmd/server/main.go
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/go-cmd-helper/cmd/server/main.go
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package main
|
||||
|
||||
import "myapp/cmd/server/internal/config"
|
||||
|
||||
func main() {
|
||||
config.Load()
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/go-cmd-helper/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/go-cmd-helper/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module myapp
|
||||
|
||||
go 1.21
|
||||
5
gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Config/constants.php
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Config/constants.php
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
10
gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Models/User.php
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Models/User.php
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class User {
|
||||
public function save(): void {}
|
||||
public function getName(): string {
|
||||
return "alice";
|
||||
}
|
||||
}
|
||||
17
gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Services/Calculator.php
vendored
Normal file
17
gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Services/Calculator.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Utils/helpers.php
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Utils/helpers.php
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
namespace App\Utils;
|
||||
|
||||
function formatName(string $name): string {
|
||||
return strtoupper($name);
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/php-use-function-const/composer.json
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/php-use-function-const/composer.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/"
|
||||
}
|
||||
}
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/main.rs
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/main.rs
vendored
Normal 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();
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/models/mod.rs
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/models/mod.rs
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
mod user;
|
||||
mod repo;
|
||||
|
||||
pub use user::User;
|
||||
pub use repo::Repo;
|
||||
13
gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/models/repo.rs
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/models/repo.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/models/user.rs
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/models/user.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
149
gitnexus/test/unit/import-resolution.test.ts
Normal file
149
gitnexus/test/unit/import-resolution.test.ts
Normal 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.
|
||||
Loading…
Add table
Reference in a new issue