diff --git a/gitnexus/README.md b/gitnexus/README.md index ac2990636e..312a465391 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -103,6 +103,8 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas 1. **Structure** — Walks the file tree and maps folder/file relationships 2. **Parsing** — Extracts functions, classes, methods, and interfaces using Tree-sitter ASTs 3. **Resolution** — Resolves imports and function calls across files with language-aware logic + - **Field & Property Type Resolution** — Tracks field types across classes and interfaces for deep chain resolution (e.g., `user.address.city.getName()`) + - **Return-Type-Aware Variable Binding** — Infers variable types from function return types, enabling accurate call-result binding 4. **Clustering** — Groups related symbols into functional communities 5. **Processes** — Traces execution flows from entry points through call chains 6. **Search** — Builds hybrid search indexes for fast retrieval diff --git a/gitnexus/src/core/graph/types.ts b/gitnexus/src/core/graph/types.ts index 69cfc81fed..ed89a2ad4c 100644 --- a/gitnexus/src/core/graph/types.ts +++ b/gitnexus/src/core/graph/types.ts @@ -71,6 +71,11 @@ export type NodeProperties = { // Section-specific (markdown heading level, 1-6) level?: number, returnType?: string, + // Field/property metadata (populated by FieldExtractor) + declaredType?: string, + visibility?: string, // 'public' | 'private' | 'protected' | 'internal' etc. + isStatic?: boolean, + isReadonly?: boolean, // Response shape (top-level keys from NextResponse.json({...}) / res.json({...})) responseKeys?: string[], // Error response shape (top-level keys from .json() calls with status >= 400) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 53f7b1e8cb..4cc15ae69a 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -395,7 +395,7 @@ export const processCalls = async ( const importedBindings = importedBindingsMap?.get(file.path); const importedReturnTypes = importedReturnTypesMap?.get(file.path); const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path); - const typeEnv = buildTypeEnv(tree, language, { symbolTable: ctx.symbols, parentMap, importedBindings, importedReturnTypes, importedRawReturnTypes }); + const typeEnv = buildTypeEnv(tree, language, { symbolTable: ctx.symbols, parentMap, importedBindings, importedReturnTypes, importedRawReturnTypes, enclosingFunctionFinder: provider?.enclosingFunctionFinder }); if (typeEnv && exportedTypeMap) { const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph); if (fileExports) exportedTypeMap.set(file.path, fileExports); diff --git a/gitnexus/src/core/ingestion/field-extractor.ts b/gitnexus/src/core/ingestion/field-extractor.ts new file mode 100644 index 0000000000..9a60e48f02 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractor.ts @@ -0,0 +1,61 @@ +// gitnexus/src/core/ingestion/field-extractor.ts + +import type { SyntaxNode } from './utils/ast-helpers.js'; +import { SupportedLanguages } from '../../config/supported-languages.js'; +import type { + FieldExtractorContext, + ExtractedFields, + FieldVisibility, +} from './field-types.js'; + +/** + * Language-specific field extractor + */ +export interface FieldExtractor { + /** Language this extractor handles */ + language: SupportedLanguages; + + /** + * Extract fields from a class/struct/interface declaration + */ + extract(node: SyntaxNode, context: FieldExtractorContext): ExtractedFields | null; + + /** + * Check if this node represents a type declaration with fields + */ + isTypeDeclaration(node: SyntaxNode): boolean; +} + +/** + * Base class for field extractors with common utilities + */ +export abstract class BaseFieldExtractor implements FieldExtractor { + abstract language: SupportedLanguages; + + abstract extract(node: SyntaxNode, context: FieldExtractorContext): ExtractedFields | null; + abstract isTypeDeclaration(node: SyntaxNode): boolean; + + protected normalizeType(type: string | null): string | null { + if (!type) return null; + return type.trim().replace(/\s+/g, ' '); + } + + protected resolveType(typeName: string, context: FieldExtractorContext): string | null { + const { typeEnv, symbolTable, filePath } = context; + + // Try to find in type environment (check file scope first) + const fileEnv = typeEnv.fileScope(); + const local = fileEnv.get(typeName); + if (local) return local; + + // Try symbol table lookup in current file + const symbols = symbolTable.lookupExactAll(filePath, typeName); + if (symbols.length === 1) { + return symbols[0].nodeId; + } + + return typeName; + } + + protected abstract extractVisibility(node: SyntaxNode): FieldVisibility; +} diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/field-extractors/configs/c-cpp.ts new file mode 100644 index 0000000000..7ecf64828e --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/c-cpp.ts @@ -0,0 +1,121 @@ +// gitnexus/src/core/ingestion/field-extractors/configs/c-cpp.ts + +import { SupportedLanguages } from '../../../../config/supported-languages.js'; +import type { FieldExtractionConfig } from '../generic.js'; +import { hasKeyword } from './helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import type { FieldVisibility } from '../../field-types.js'; + +/** + * Detect C++ access specifier (public:/private:/protected:) by walking + * backwards from the field node through siblings. + */ +function cppAccessSpecifier(node: SyntaxNode): FieldVisibility | undefined { + let sibling = node.previousNamedSibling; + while (sibling) { + if (sibling.type === 'access_specifier') { + const text = sibling.text.replace(':', '').trim(); + if (text === 'public' || text === 'private' || text === 'protected') return text; + } + sibling = sibling.previousNamedSibling; + } + return undefined; +} + +function extractFieldName(node: SyntaxNode): string | undefined { + // field_declaration > declarator:(field_identifier | pointer_declarator > field_identifier) + const declarator = node.childForFieldName('declarator'); + if (declarator) { + if (declarator.type === 'field_identifier') return declarator.text; + // pointer_declarator: *fieldName + for (let i = 0; i < declarator.namedChildCount; i++) { + const child = declarator.namedChild(i); + if (child?.type === 'field_identifier') return child.text; + } + return declarator.text; + } + // fallback + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'field_identifier') return child.text; + } + return undefined; +} + +function extractFieldType(node: SyntaxNode): string | undefined { + const typeNode = node.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + // fallback: first child that is a type node + const first = node.firstNamedChild; + if (first && (first.type === 'type_identifier' || first.type === 'primitive_type' + || first.type === 'sized_type_specifier' || first.type === 'template_type')) { + return extractSimpleTypeName(first) ?? first.text?.trim(); + } + return undefined; +} + +// --------------------------------------------------------------------------- +// C++ config +// --------------------------------------------------------------------------- + +export const cppConfig: FieldExtractionConfig = { + language: SupportedLanguages.CPlusPlus, + typeDeclarationNodes: [ + 'struct_specifier', + 'class_specifier', + 'union_specifier', + ], + fieldNodeTypes: ['field_declaration'], + bodyNodeTypes: ['field_declaration_list'], + defaultVisibility: 'private', // C++ class default is private + + extractName: extractFieldName, + extractType: extractFieldType, + + extractVisibility(node) { + const access = cppAccessSpecifier(node); + if (access) return access; + // struct default = public, class default = private + const parent = node.parent?.parent; + return parent?.type === 'struct_specifier' ? 'public' : 'private'; + }, + + isStatic(node) { + return hasKeyword(node, 'static'); + }, + + isReadonly(node) { + return hasKeyword(node, 'const'); + }, +}; + +// --------------------------------------------------------------------------- +// C config (subset of C++) +// --------------------------------------------------------------------------- + +export const cConfig: FieldExtractionConfig = { + language: SupportedLanguages.C, + typeDeclarationNodes: [ + 'struct_specifier', + 'union_specifier', + ], + fieldNodeTypes: ['field_declaration'], + bodyNodeTypes: ['field_declaration_list'], + defaultVisibility: 'public', // C structs are always public + + extractName: extractFieldName, + extractType: extractFieldType, + + extractVisibility(_node) { + return 'public'; // C has no access control + }, + + isStatic(node) { + return hasKeyword(node, 'static'); + }, + + isReadonly(node) { + return hasKeyword(node, 'const'); + }, +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/csharp.ts b/gitnexus/src/core/ingestion/field-extractors/configs/csharp.ts new file mode 100644 index 0000000000..90ccd861d1 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/csharp.ts @@ -0,0 +1,80 @@ +// gitnexus/src/core/ingestion/field-extractors/configs/csharp.ts + +import { SupportedLanguages } from '../../../../config/supported-languages.js'; +import type { FieldExtractionConfig } from '../generic.js'; +import { findVisibility, hasKeyword, hasModifier } from './helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { FieldVisibility } from '../../field-types.js'; + +const CSHARP_VIS = new Set(['public', 'private', 'protected', 'internal']); + +/** + * C# field extraction config. + * + * Handles field_declaration and property_declaration inside class/struct/interface bodies. + * The body node in tree-sitter-c-sharp is 'declaration_list'. + */ +export const csharpConfig: FieldExtractionConfig = { + language: SupportedLanguages.CSharp, + typeDeclarationNodes: [ + 'class_declaration', + 'struct_declaration', + 'interface_declaration', + 'record_declaration', + ], + fieldNodeTypes: ['field_declaration', 'property_declaration'], + bodyNodeTypes: ['declaration_list'], + defaultVisibility: 'private', + + extractName(node) { + // field_declaration > variable_declaration > variable_declarator > identifier + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'variable_declaration') { + for (let j = 0; j < child.namedChildCount; j++) { + const declarator = child.namedChild(j); + if (declarator?.type === 'variable_declarator') { + const name = declarator.childForFieldName('name'); + return name?.text ?? declarator.firstNamedChild?.text; + } + } + } + } + // property_declaration: name field + const nameNode = node.childForFieldName('name'); + if (nameNode) return nameNode.text; + return undefined; + }, + + extractType(node) { + // field_declaration > variable_declaration > type:(predefined_type | identifier | ...) + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'variable_declaration') { + const typeNode = child.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + // fallback: first child that is a type + const first = child.firstNamedChild; + if (first && first.type !== 'variable_declarator') { + return extractSimpleTypeName(first) ?? first.text?.trim(); + } + } + } + // property_declaration: type is first named child + const typeNode = node.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + return undefined; + }, + + extractVisibility(node) { + return findVisibility(node, CSHARP_VIS, 'private', 'modifier'); + }, + + isStatic(node) { + return hasKeyword(node, 'static') || hasModifier(node, 'modifier', 'static'); + }, + + isReadonly(node) { + return hasKeyword(node, 'readonly') || hasModifier(node, 'modifier', 'readonly'); + }, +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/dart.ts b/gitnexus/src/core/ingestion/field-extractors/configs/dart.ts new file mode 100644 index 0000000000..96b46e4c59 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/dart.ts @@ -0,0 +1,81 @@ +// gitnexus/src/core/ingestion/field-extractors/configs/dart.ts + +import { SupportedLanguages } from '../../../../config/supported-languages.js'; +import type { FieldExtractionConfig } from '../generic.js'; +import { hasKeyword } from './helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; + +/** + * Dart field extraction config. + * + * Dart class fields appear as declaration nodes inside class_body. + * Visibility is convention-based: underscore prefix = private. + */ +export const dartConfig: FieldExtractionConfig = { + language: SupportedLanguages.Dart, + typeDeclarationNodes: ['class_definition'], + fieldNodeTypes: ['declaration'], + bodyNodeTypes: ['class_body'], + defaultVisibility: 'public', + + extractName(node) { + // declaration > initialized_identifier_list > initialized_identifier > identifier + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'initialized_identifier_list') { + for (let j = 0; j < child.namedChildCount; j++) { + const init = child.namedChild(j); + if (init?.type === 'initialized_identifier') { + const ident = init.firstNamedChild; + if (ident?.type === 'identifier') return ident.text; + } + } + } + if (child?.type === 'initialized_identifier') { + const ident = child.firstNamedChild; + if (ident?.type === 'identifier') return ident.text; + } + } + // fallback: look for direct identifier + const name = node.childForFieldName('name'); + return name?.text; + }, + + extractType(node) { + // declaration > type_identifier (first named child usually) + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && (child.type === 'type_identifier' || child.type === 'generic_type' + || child.type === 'function_type')) { + return extractSimpleTypeName(child) ?? child.text?.trim(); + } + } + return undefined; + }, + + extractVisibility(node) { + // Dart uses _ prefix for private + // Walk to find the identifier name + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'initialized_identifier_list') { + for (let j = 0; j < child.namedChildCount; j++) { + const init = child.namedChild(j); + if (init?.type === 'initialized_identifier') { + const ident = init.firstNamedChild; + if (ident?.text?.startsWith('_')) return 'private'; + } + } + } + } + return 'public'; + }, + + isStatic(node) { + return hasKeyword(node, 'static'); + }, + + isReadonly(node) { + return hasKeyword(node, 'final') || hasKeyword(node, 'const'); + }, +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/go.ts b/gitnexus/src/core/ingestion/field-extractors/configs/go.ts new file mode 100644 index 0000000000..6f03aab4fc --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/go.ts @@ -0,0 +1,68 @@ +// gitnexus/src/core/ingestion/field-extractors/configs/go.ts + +import { SupportedLanguages } from '../../../../config/supported-languages.js'; +import type { FieldExtractionConfig } from '../generic.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; + +/** + * Go field extraction config. + * + * Go struct fields live inside type_declaration > type_spec > struct_type > + * field_declaration_list > field_declaration. + * + * Visibility in Go is based on the first character: uppercase = exported (public), + * lowercase = unexported (package). + */ +export const goConfig: FieldExtractionConfig = { + language: SupportedLanguages.Go, + typeDeclarationNodes: [ + 'type_declaration', + ], + fieldNodeTypes: ['field_declaration'], + bodyNodeTypes: ['field_declaration_list'], + defaultVisibility: 'package', + + extractName(node) { + // field_declaration > name:(field_identifier) + const name = node.childForFieldName('name'); + if (name) return name.text; + // fallback: first field_identifier child + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'field_identifier') return child.text; + } + return undefined; + }, + + extractType(node) { + // field_declaration > type:(type_identifier | pointer_type | ...) + const typeNode = node.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + // fallback: second named child is usually the type + if (node.namedChildCount >= 2) { + const t = node.namedChild(1); + if (t) return extractSimpleTypeName(t) ?? t.text?.trim(); + } + return undefined; + }, + + extractVisibility(node) { + const name = node.childForFieldName('name'); + const text = name?.text; + if (text && text.length > 0) { + const first = text.charAt(0); + return first === first.toUpperCase() && first !== first.toLowerCase() + ? 'public' + : 'package'; + } + return 'package'; + }, + + isStatic(_node) { + return false; // Go has no static fields + }, + + isReadonly(_node) { + return false; // Go fields are not readonly + }, +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts b/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts new file mode 100644 index 0000000000..63fe9b9055 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts @@ -0,0 +1,148 @@ +// gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts + +/** + * Shared AST-walking helpers used by multiple language configs. + * Keeps individual config files small. + */ + +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { FieldVisibility } from '../../field-types.js'; + +// --------------------------------------------------------------------------- +// Modifier scanning +// --------------------------------------------------------------------------- + +/** + * Check whether any child of `node` (named or unnamed) has .text matching + * one of the given `keywords`. + */ +export function hasKeyword(node: SyntaxNode, keyword: string): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.text.trim() === keyword) return true; + } + return false; +} + +/** + * Check whether a named child of type `modifierType` contains `keyword`. + * Useful for languages that group modifiers under a wrapper node + * (e.g. Java 'modifiers', Kotlin 'modifiers'). + */ +export function hasModifier(node: SyntaxNode, modifierType: string, keyword: string): boolean { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === modifierType) { + for (let j = 0; j < child.childCount; j++) { + const mod = child.child(j); + if (mod && mod.text.trim() === keyword) return true; + } + } + } + return false; +} + +/** + * Return the first matching visibility keyword found either as a direct keyword + * child or inside a modifier wrapper node. + */ +export function findVisibility( + node: SyntaxNode, + keywords: ReadonlySet, + defaultVis: FieldVisibility, + modifierNodeType?: string, +): FieldVisibility { + // Direct keyword children + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + const text = child?.text.trim() as FieldVisibility | undefined; + if (text && (keywords as ReadonlySet).has(text)) return text; + } + // Modifier wrapper + if (modifierNodeType) { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === modifierNodeType) { + for (let j = 0; j < child.childCount; j++) { + const mod = child.child(j); + const modText = mod?.text.trim() as FieldVisibility | undefined; + if (modText && (keywords as ReadonlySet).has(modText)) return modText; + } + } + } + } + return defaultVis; +} + +// --------------------------------------------------------------------------- +// Name and type extraction +// --------------------------------------------------------------------------- + +/** + * Extract the text of the first named child whose type is in `types`. + */ +export function firstChildText(node: SyntaxNode, types: ReadonlySet): string | undefined { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && types.has(child.type)) return child.text; + } + return undefined; +} + +/** + * Extract the first named child node whose type is in `types`. + */ +export function firstChildOfType(node: SyntaxNode, types: ReadonlySet): SyntaxNode | null { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && types.has(child.type)) return child; + } + return null; +} + +/** + * Get type text from a named field on the node, using extractSimpleTypeName. + * Falls back to raw .text of the field child if extractSimpleTypeName returns undefined. + */ +export function typeFromField(node: SyntaxNode, fieldName: string): string | undefined { + const typeNode = node.childForFieldName(fieldName); + if (!typeNode) return undefined; + return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); +} + +/** + * Walk named children looking for a type_annotation node and extract its type. + */ +export function typeFromAnnotation(node: SyntaxNode): string | undefined { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === 'type_annotation') { + const inner = child.firstNamedChild; + if (inner) return extractSimpleTypeName(inner) ?? inner.text?.trim(); + } + } + return undefined; +} + +/** + * Find the first descendant (depth-first, one level) matching one of the given types + * and return its text via extractSimpleTypeName. + */ +export function typeFromDescendant(node: SyntaxNode, types: ReadonlySet): string | undefined { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (types.has(child.type)) { + return extractSimpleTypeName(child) ?? child.text?.trim(); + } + // one more level + for (let j = 0; j < child.namedChildCount; j++) { + const grandchild = child.namedChild(j); + if (grandchild && types.has(grandchild.type)) { + return extractSimpleTypeName(grandchild) ?? grandchild.text?.trim(); + } + } + } + return undefined; +} diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts new file mode 100644 index 0000000000..e72bd245c1 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts @@ -0,0 +1,134 @@ +// gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts + +import { SupportedLanguages } from '../../../../config/supported-languages.js'; +import type { FieldExtractionConfig } from '../generic.js'; +import { findVisibility, hasKeyword, hasModifier, typeFromField } from './helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { FieldVisibility } from '../../field-types.js'; + +// --------------------------------------------------------------------------- +// Java +// --------------------------------------------------------------------------- + +const JAVA_VIS = new Set(['public', 'private', 'protected']); + +export const javaConfig: FieldExtractionConfig = { + language: SupportedLanguages.Java, + typeDeclarationNodes: [ + 'class_declaration', + 'interface_declaration', + 'enum_declaration', + 'record_declaration', + ], + fieldNodeTypes: ['field_declaration'], + bodyNodeTypes: ['class_body', 'interface_body', 'enum_body'], + defaultVisibility: 'package', + + extractName(node) { + // field_declaration > declarator:(variable_declarator name:(identifier)) + const declarator = node.childForFieldName('declarator'); + if (declarator) { + const name = declarator.childForFieldName('name'); + return name?.text; + } + // fallback: walk children for variable_declarator + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'variable_declarator') { + const name = child.childForFieldName('name'); + return name?.text; + } + } + return undefined; + }, + + extractType(node) { + // field_declaration > type:(type_identifier|generic_type|...) + const t = typeFromField(node, 'type'); + if (t) return t; + // fallback: first named child that looks like a type + const first = node.firstNamedChild; + if (first && first.type !== 'modifiers') { + return extractSimpleTypeName(first) ?? first.text?.trim(); + } + return undefined; + }, + + extractVisibility(node) { + return findVisibility(node, JAVA_VIS, 'package', 'modifiers'); + }, + + isStatic(node) { + return hasKeyword(node, 'static') || hasModifier(node, 'modifiers', 'static'); + }, + + isReadonly(node) { + return hasKeyword(node, 'final') || hasModifier(node, 'modifiers', 'final'); + }, +}; + +// --------------------------------------------------------------------------- +// Kotlin +// --------------------------------------------------------------------------- + +const KOTLIN_VIS = new Set(['public', 'private', 'protected', 'internal']); + +export const kotlinConfig: FieldExtractionConfig = { + language: SupportedLanguages.Kotlin, + typeDeclarationNodes: [ + 'class_declaration', + 'object_declaration', + ], + fieldNodeTypes: ['property_declaration'], + bodyNodeTypes: ['class_body'], + defaultVisibility: 'public', + + extractName(node) { + // property_declaration > variable_declaration > simple_identifier + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'variable_declaration') { + for (let j = 0; j < child.namedChildCount; j++) { + const ident = child.namedChild(j); + if (ident?.type === 'simple_identifier') return ident.text; + } + } + if (child?.type === 'simple_identifier') return child.text; + } + return undefined; + }, + + extractType(node) { + // property_declaration may have a user_type or type_identifier under variable_declaration + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'variable_declaration') { + for (let j = 0; j < child.namedChildCount; j++) { + const t = child.namedChild(j); + if (t && (t.type === 'user_type' || t.type === 'type_identifier' + || t.type === 'nullable_type' || t.type === 'generic_type')) { + return extractSimpleTypeName(t) ?? t.text?.trim(); + } + } + } + if (child?.type === 'user_type' || child?.type === 'nullable_type') { + return extractSimpleTypeName(child) ?? child.text?.trim(); + } + } + return undefined; + }, + + extractVisibility(node) { + return findVisibility(node, KOTLIN_VIS, 'public', 'modifiers'); + }, + + isStatic(_node) { + // Kotlin doesn't have static; companion object members are handled separately + return false; + }, + + isReadonly(node) { + // 'val' = readonly, 'var' = mutable + return hasKeyword(node, 'val'); + }, +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/php.ts b/gitnexus/src/core/ingestion/field-extractors/configs/php.ts new file mode 100644 index 0000000000..d6054209fb --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/php.ts @@ -0,0 +1,76 @@ +// gitnexus/src/core/ingestion/field-extractors/configs/php.ts + +import { SupportedLanguages } from '../../../../config/supported-languages.js'; +import type { FieldExtractionConfig } from '../generic.js'; +import { findVisibility, hasKeyword } from './helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { FieldVisibility } from '../../field-types.js'; + +const PHP_VIS = new Set(['public', 'private', 'protected']); + +/** + * PHP field extraction config. + * + * Handles property_declaration inside class/interface/trait bodies. + * tree-sitter-php uses 'declaration_list' for the class body. + */ +export const phpConfig: FieldExtractionConfig = { + language: SupportedLanguages.PHP, + typeDeclarationNodes: [ + 'class_declaration', + 'interface_declaration', + 'trait_declaration', + ], + fieldNodeTypes: ['property_declaration'], + bodyNodeTypes: ['declaration_list'], + defaultVisibility: 'public', + + extractName(node) { + // property_declaration > property_element > variable_name ($varName) + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'property_element') { + const varName = child.childForFieldName('name') + ?? child.firstNamedChild; + if (varName) { + // strip leading $ from PHP variable names + const text = varName.text; + return text.startsWith('$') ? text.slice(1) : text; + } + } + // fallback: variable_name direct child + if (child?.type === 'variable_name') { + const text = child.text; + return text.startsWith('$') ? text.slice(1) : text; + } + } + return undefined; + }, + + extractType(node) { + // property_declaration may have a type before the property_element + // tree-sitter-php: type can be union_type, named_type, optional_type, primitive_type + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (child.type === 'union_type' || child.type === 'named_type' + || child.type === 'optional_type' || child.type === 'primitive_type' + || child.type === 'intersection_type' || child.type === 'nullable_type') { + return extractSimpleTypeName(child) ?? child.text?.trim(); + } + } + return undefined; + }, + + extractVisibility(node) { + return findVisibility(node, PHP_VIS, 'public'); + }, + + isStatic(node) { + return hasKeyword(node, 'static'); + }, + + isReadonly(node) { + return hasKeyword(node, 'readonly'); + }, +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/python.ts b/gitnexus/src/core/ingestion/field-extractors/configs/python.ts new file mode 100644 index 0000000000..aea0f94ebd --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/python.ts @@ -0,0 +1,96 @@ +// gitnexus/src/core/ingestion/field-extractors/configs/python.ts + +import { SupportedLanguages } from '../../../../config/supported-languages.js'; +import type { FieldExtractionConfig } from '../generic.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; + +/** + * Python field extraction config. + * + * Python class fields appear as: + * - Annotated assignments: `name: str = ""` + * - Plain assignments in __init__: `self.name = value` + * + * For AST-level extraction we handle expression_statement containing + * assignment or type nodes inside a class body block. + */ +export const pythonConfig: FieldExtractionConfig = { + language: SupportedLanguages.Python, + typeDeclarationNodes: ['class_definition'], + fieldNodeTypes: ['expression_statement'], + bodyNodeTypes: ['block'], + defaultVisibility: 'public', + + extractName(node) { + // expression_statement wrapping an assignment or type + const inner = node.firstNamedChild; + if (!inner) return undefined; + + // Annotated assignment: name: str = "default" + // tree-sitter node: type (expression_statement (type (identifier) (type) ...)) + if (inner.type === 'type') { + const ident = inner.childForFieldName('name') ?? inner.firstNamedChild; + return ident?.type === 'identifier' ? ident.text : undefined; + } + + // assignment: x = 5 (class variable) + if (inner.type === 'assignment') { + const left = inner.childForFieldName('left'); + if (left?.type === 'identifier') return left.text; + } + + return undefined; + }, + + extractType(node) { + const inner = node.firstNamedChild; + if (!inner) return undefined; + + // Annotated assignment with value: `name: str = "default"` + // AST: expression_statement > type > [identifier, type, ...] + if (inner.type === 'type') { + const typeNode = inner.childForFieldName('type') ?? inner.namedChild(1); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + } + + // Annotation without value: `address: Address` + // AST: expression_statement > assignment > [identifier, type] + if (inner.type === 'assignment') { + for (let i = 0; i < inner.childCount; i++) { + const child = inner.child(i); + if (child?.type === 'type') { + const typeId = child.firstNamedChild; + if (typeId) return extractSimpleTypeName(typeId) ?? typeId.text?.trim(); + } + } + } + + return undefined; + }, + + extractVisibility(node) { + const inner = node.firstNamedChild; + let name: string | undefined; + if (inner?.type === 'type') { + const ident = inner.childForFieldName('name') ?? inner.firstNamedChild; + name = ident?.text; + } else if (inner?.type === 'assignment') { + const left = inner.childForFieldName('left'); + name = left?.text; + } + if (!name) return 'public'; + if (name.startsWith('__') && !name.endsWith('__')) return 'private'; + if (name.startsWith('_')) return 'protected'; + return 'public'; + }, + + isStatic(_node) { + // Reports syntactic static keyword — Python class variables don't use explicit static keyword. + // Instance variables (self.x) live in __init__ and are not extracted here. + return false; + }, + + isReadonly(_node) { + return false; + }, +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/ruby.ts b/gitnexus/src/core/ingestion/field-extractors/configs/ruby.ts new file mode 100644 index 0000000000..17cfac62fb --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/ruby.ts @@ -0,0 +1,83 @@ +// gitnexus/src/core/ingestion/field-extractors/configs/ruby.ts + +import { SupportedLanguages } from '../../../../config/supported-languages.js'; +import type { FieldExtractionConfig } from '../generic.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Collect all field names declared by an `attr_accessor`, `attr_reader`, or + * `attr_writer` call node. A single call may list multiple symbols: + * attr_accessor :foo, :bar, :baz + */ +function extractAttrNames(node: SyntaxNode): string[] { + const method = node.childForFieldName('method'); + if (!method) return []; + const methodName = method.text; + if (methodName !== 'attr_accessor' && methodName !== 'attr_reader' + && methodName !== 'attr_writer') { + return []; + } + const args = node.childForFieldName('arguments'); + if (!args) return []; + const names: string[] = []; + for (let i = 0; i < args.namedChildCount; i++) { + const arg = args.namedChild(i); + if (!arg) continue; + // simple_symbol text is :name — strip the leading colon + const text = arg.text; + names.push(text.startsWith(':') ? text.slice(1) : text); + } + return names; +} + +/** + * Ruby field extraction config. + * + * Ruby is unusual: there are no field declarations in the traditional sense. + * Fields are instance variables (@var) created by assignment, or declared + * via attr_accessor / attr_reader / attr_writer calls. + * + * We detect: + * - `call` nodes for attr_accessor / attr_reader / attr_writer + * (their arguments are symbol names → field names) + * + * For simplicity we focus on attr_* calls in the class body. + * Instance variable assignments (self.x = ...) would require deeper analysis. + */ +export const rubyConfig: FieldExtractionConfig = { + language: SupportedLanguages.Ruby, + typeDeclarationNodes: ['class'], + fieldNodeTypes: ['call'], + bodyNodeTypes: ['body_statement'], + defaultVisibility: 'public', + + extractName(node) { + // Returns the first symbol name for interface compatibility. + // Use extractNames to obtain all names from a single attr_* call. + return extractAttrNames(node)[0]; + }, + + extractNames(node) { + return extractAttrNames(node); + }, + + extractType(_node) { + // Ruby is dynamically typed; no type annotations in standard Ruby + return undefined; + }, + + extractVisibility(_node) { + // attr_accessor/attr_writer fields are effectively public + // attr_reader fields are read-only from outside but still public + return 'public'; + }, + + isStatic(_node) { + return false; + }, + + isReadonly(node) { + const method = node.childForFieldName('method'); + return method?.text === 'attr_reader'; + }, +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/rust.ts b/gitnexus/src/core/ingestion/field-extractors/configs/rust.ts new file mode 100644 index 0000000000..4ac1c1f1c7 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/rust.ts @@ -0,0 +1,59 @@ +// gitnexus/src/core/ingestion/field-extractors/configs/rust.ts + +import { SupportedLanguages } from '../../../../config/supported-languages.js'; +import type { FieldExtractionConfig } from '../generic.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import { hasKeyword } from './helpers.js'; + +/** + * Rust field extraction config. + * + * Handles struct fields (named and tuple variants are out of scope). + * Visibility: `pub` keyword = public, otherwise private (crate-private). + * All fields are immutable by default in Rust (mutability is on the binding). + */ +export const rustConfig: FieldExtractionConfig = { + language: SupportedLanguages.Rust, + typeDeclarationNodes: [ + 'struct_item', + 'enum_item', + ], + fieldNodeTypes: ['field_declaration'], + bodyNodeTypes: ['field_declaration_list'], + defaultVisibility: 'private', + + extractName(node) { + const name = node.childForFieldName('name'); + if (name) return name.text; + // fallback: first field_identifier + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'field_identifier') return child.text; + } + return undefined; + }, + + extractType(node) { + const typeNode = node.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + return undefined; + }, + + extractVisibility(node) { + // Check for visibility_modifier named child (pub, pub(crate), pub(super)) + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'visibility_modifier') return 'public'; + } + return hasKeyword(node, 'pub') ? 'public' : 'private'; + }, + + isStatic(_node) { + return false; // Rust struct fields are never static + }, + + isReadonly(_node) { + // All Rust fields are immutable by default (mutability is per-binding) + return true; + }, +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/swift.ts b/gitnexus/src/core/ingestion/field-extractors/configs/swift.ts new file mode 100644 index 0000000000..e353f04061 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/swift.ts @@ -0,0 +1,70 @@ +// gitnexus/src/core/ingestion/field-extractors/configs/swift.ts + +import { SupportedLanguages } from '../../../../config/supported-languages.js'; +import type { FieldExtractionConfig } from '../generic.js'; +import { hasKeyword, findVisibility } from './helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { FieldVisibility } from '../../field-types.js'; + +const SWIFT_VIS = new Set(['public', 'private', 'fileprivate', 'internal', 'open']); + +/** + * Swift field extraction config. + * + * Handles property_declaration inside class_body / protocol_body. + * tree-sitter-swift uses property_declaration for stored/computed properties. + */ +export const swiftConfig: FieldExtractionConfig = { + language: SupportedLanguages.Swift, + typeDeclarationNodes: [ + 'class_declaration', + 'struct_declaration', + 'protocol_declaration', + ], + fieldNodeTypes: ['property_declaration'], + bodyNodeTypes: ['class_body', 'protocol_body'], + defaultVisibility: 'internal', + + extractName(node) { + // property_declaration > pattern > simple_identifier + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'pattern') { + for (let j = 0; j < child.namedChildCount; j++) { + const ident = child.namedChild(j); + if (ident?.type === 'simple_identifier') return ident.text; + } + return child.text; + } + if (child?.type === 'simple_identifier') return child.text; + } + // fallback: childForFieldName('name') + const name = node.childForFieldName('name'); + return name?.text; + }, + + extractType(node) { + // property_declaration > type_annotation > type_identifier + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'type_annotation') { + const inner = child.firstNamedChild; + if (inner) return extractSimpleTypeName(inner) ?? inner.text?.trim(); + } + } + return undefined; + }, + + extractVisibility(node) { + return findVisibility(node, SWIFT_VIS, 'internal', 'modifiers'); + }, + + isStatic(node) { + return hasKeyword(node, 'static') || hasKeyword(node, 'class'); + }, + + isReadonly(node) { + // 'let' = constant/readonly, 'var' = variable + return hasKeyword(node, 'let'); + }, +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/field-extractors/configs/typescript-javascript.ts new file mode 100644 index 0000000000..67ad53f6a5 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/typescript-javascript.ts @@ -0,0 +1,71 @@ +// gitnexus/src/core/ingestion/field-extractors/configs/typescript-javascript.ts + +import { SupportedLanguages } from '../../../../config/supported-languages.js'; +import type { FieldExtractionConfig } from '../generic.js'; +import { hasKeyword, findVisibility, typeFromAnnotation } from './helpers.js'; +import type { FieldVisibility } from '../../field-types.js'; + +const VISIBILITY_KEYWORDS = new Set(['public', 'private', 'protected']); + +const shared: Omit = { + typeDeclarationNodes: [ + 'class_declaration', + 'abstract_class_declaration', + 'interface_declaration', + ], + fieldNodeTypes: [ + 'public_field_definition', + 'property_signature', + 'field_definition', + ], + bodyNodeTypes: ['class_body', 'interface_body', 'object_type'], + defaultVisibility: 'public', + + extractName(node) { + const nameNode = node.childForFieldName('name') ?? node.childForFieldName('property'); + return nameNode?.text; + }, + + extractType(node) { + // tree-sitter TS uses a named 'type' field for type_annotation + const typeField = node.childForFieldName('type'); + if (typeField) { + if (typeField.type === 'type_annotation') { + const inner = typeField.firstNamedChild; + return inner?.text?.trim(); + } + return typeField.text?.trim(); + } + return typeFromAnnotation(node); + }, + + extractVisibility(node) { + // TypeScript accessibility_modifier + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === 'accessibility_modifier') { + const t = child.text.trim() as FieldVisibility; + if (VISIBILITY_KEYWORDS.has(t)) return t; + } + } + return findVisibility(node, VISIBILITY_KEYWORDS, 'public', 'modifiers'); + }, + + isStatic(node) { + return hasKeyword(node, 'static'); + }, + + isReadonly(node) { + return hasKeyword(node, 'readonly'); + }, +}; + +export const typescriptConfig: FieldExtractionConfig = { + ...shared, + language: SupportedLanguages.TypeScript, +}; + +export const javascriptConfig: FieldExtractionConfig = { + ...shared, + language: SupportedLanguages.JavaScript, +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/generic.ts b/gitnexus/src/core/ingestion/field-extractors/generic.ts new file mode 100644 index 0000000000..baf850b337 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/generic.ts @@ -0,0 +1,181 @@ +// gitnexus/src/core/ingestion/field-extractors/generic.ts + +/** + * Generic table-driven field extractor factory. + * + * Instead of 14 separate 300-line files, define a config per language and + * generate extractors from configs. The factory creates a class extending + * BaseFieldExtractor whose behaviour is entirely driven by FieldExtractionConfig. + */ + +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import { SupportedLanguages } from '../../../config/supported-languages.js'; +import { BaseFieldExtractor } from '../field-extractor.js'; +import type { FieldExtractor } from '../field-extractor.js'; +import type { FieldExtractorContext, ExtractedFields, FieldInfo, FieldVisibility } from '../field-types.js'; + +// --------------------------------------------------------------------------- +// Config interface +// --------------------------------------------------------------------------- + +export interface FieldExtractionConfig { + language: SupportedLanguages; + /** AST node types that are class/struct/interface declarations */ + typeDeclarationNodes: string[]; + /** AST node types that represent field/property declarations inside a body */ + fieldNodeTypes: string[]; + /** AST node type(s) for the class body container (e.g., 'class_body', 'declaration_list') */ + bodyNodeTypes: string[]; + /** Default visibility when no modifier is present */ + defaultVisibility: FieldVisibility; + /** + * Extract field name from a field declaration node. + * Use this for nodes that declare exactly one field. + */ + extractName: (node: SyntaxNode) => string | undefined; + /** + * Extract multiple field names from a single declaration node. + * Optional override for languages where one AST node can declare + * several fields (e.g. Ruby `attr_accessor :foo, :bar`). + * When present, the factory uses this instead of `extractName`. + */ + extractNames?: (node: SyntaxNode) => string[]; + /** Extract type annotation from a field declaration node */ + extractType: (node: SyntaxNode) => string | undefined; + /** Extract visibility from a field declaration node */ + extractVisibility: (node: SyntaxNode) => FieldVisibility; + /** Check if a field is static */ + isStatic: (node: SyntaxNode) => boolean; + /** Check if a field is readonly/final/const */ + isReadonly: (node: SyntaxNode) => boolean; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Create a FieldExtractor from a declarative config. + */ +export function createFieldExtractor(config: FieldExtractionConfig): FieldExtractor { + const typeDeclarationSet = new Set(config.typeDeclarationNodes); + const fieldNodeSet = new Set(config.fieldNodeTypes); + const bodyNodeSet = new Set(config.bodyNodeTypes); + + class GenericFieldExtractor extends BaseFieldExtractor { + language = config.language; + + isTypeDeclaration(node: SyntaxNode): boolean { + return typeDeclarationSet.has(node.type); + } + + protected extractVisibility(node: SyntaxNode): FieldVisibility { + return config.extractVisibility(node); + } + + extract(node: SyntaxNode, context: FieldExtractorContext): ExtractedFields | null { + if (!this.isTypeDeclaration(node)) return null; + + const nameNode = node.childForFieldName('name'); + if (!nameNode) return null; + + const ownerFqn = nameNode.text; + const fields: FieldInfo[] = []; + + // Find body container(s) + const bodies = this.findBodies(node); + for (const body of bodies) { + this.extractFieldsFromBody(body, context, fields); + } + + return { ownerFqn, fields, nestedTypes: [] }; + } + + // ------------------------------------------------------------------ + // private helpers + // ------------------------------------------------------------------ + + private findBodies(node: SyntaxNode): SyntaxNode[] { + const result: SyntaxNode[] = []; + // Try named 'body' field first + const bodyField = node.childForFieldName('body'); + if (bodyField && bodyNodeSet.has(bodyField.type)) { + result.push(bodyField); + return result; + } + // Walk immediate children for matching body node types + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && bodyNodeSet.has(child.type)) { + result.push(child); + } + } + // Fallback: use the body field even if its type is not in bodyNodeSet + if (result.length === 0 && bodyField) { + result.push(bodyField); + } + return result; + } + + private extractFieldsFromBody( + body: SyntaxNode, + context: FieldExtractorContext, + out: FieldInfo[], + ): void { + for (let i = 0; i < body.namedChildCount; i++) { + const child = body.namedChild(i); + if (!child) continue; + + if (fieldNodeSet.has(child.type)) { + if (config.extractNames) { + // Multi-name path: one node may declare several fields (e.g. Ruby attr_accessor) + const names = config.extractNames(child); + for (const name of names) { + const field = this.buildField(child, name, context); + if (field) out.push(field); + } + } else { + const field = this.extractSingleField(child, context); + if (field) out.push(field); + } + } + } + } + + private extractSingleField( + node: SyntaxNode, + context: FieldExtractorContext, + ): FieldInfo | null { + const name = config.extractName(node); + if (!name) return null; + return this.buildField(node, name, context); + } + + private buildField( + node: SyntaxNode, + name: string, + context: FieldExtractorContext, + ): FieldInfo | null { + if (!name) return null; + + let type: string | null = config.extractType(node) ?? null; + if (type) { + type = this.normalizeType(type); + const resolved = this.resolveType(type, context); + if (resolved) type = resolved; + } + + return { + name, + type, + visibility: config.extractVisibility(node), + isStatic: config.isStatic(node), + isReadonly: config.isReadonly(node), + sourceFile: context.filePath, + line: node.startPosition.row + 1, + }; + } + } + + return new GenericFieldExtractor(); +} diff --git a/gitnexus/src/core/ingestion/field-extractors/typescript.ts b/gitnexus/src/core/ingestion/field-extractors/typescript.ts new file mode 100644 index 0000000000..4ab5182849 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/typescript.ts @@ -0,0 +1,333 @@ +// gitnexus/src/core/ingestion/field-extractors/typescript.ts + +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import { SupportedLanguages } from '../../../config/supported-languages.js'; +import { BaseFieldExtractor } from '../field-extractor.js'; +import type { FieldExtractorContext, ExtractedFields, FieldInfo, FieldVisibility } from '../field-types.js'; + +/** + * Hand-written TypeScript field extractor. + * + * This exists alongside the config-based extractor in configs/typescript-javascript.ts + * (used for JavaScript) because TypeScript has unique requirements: + * 1. type_alias_declaration with object type literals (e.g., type Config = { key: string }) + * 2. Optional property detection appending '| undefined' to types + * 3. Nested type discovery within class/interface bodies + * + * The config-based extractor cannot express these TS-specific capabilities. + * JavaScript uses the config-based version since it lacks type syntax. + */ +export class TypeScriptFieldExtractor extends BaseFieldExtractor { + language = SupportedLanguages.TypeScript; + + /** + * Node types that represent type declarations with fields in TypeScript + */ + private static readonly TYPE_DECLARATION_NODES = new Set([ + 'class_declaration', + 'interface_declaration', + 'abstract_class_declaration', + 'type_alias_declaration', // for object type literals + ]); + + /** + * Node types that contain field definitions within class bodies + */ + private static readonly FIELD_NODE_TYPES = new Set([ + 'public_field_definition', // class field: private users: User[] + 'property_signature', // interface property: name: string + 'field_definition', // fallback field type + ]); + + /** + * Visibility modifiers in TypeScript + */ + private static readonly VISIBILITY_MODIFIERS = new Set([ + 'public', + 'private', + 'protected', + ]); + + /** + * Check if this node represents a type declaration with fields + */ + isTypeDeclaration(node: SyntaxNode): boolean { + return TypeScriptFieldExtractor.TYPE_DECLARATION_NODES.has(node.type); + } + + /** + * Extract visibility modifier from a field node + */ + protected extractVisibility(node: SyntaxNode): FieldVisibility { + // Check for accessibility_modifier named child (tree-sitter typescript) + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === 'accessibility_modifier') { + const text = child.text.trim() as FieldVisibility; + if (TypeScriptFieldExtractor.VISIBILITY_MODIFIERS.has(text)) { + return text; + } + } + } + + // Check for modifiers in the field's unnamed children (fallback) + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && !child.isNamed) { + const text = child.text.trim() as FieldVisibility; + if (TypeScriptFieldExtractor.VISIBILITY_MODIFIERS.has(text)) { + return text; + } + } + } + + // Check for modifier node (tree-sitter typescript may group these) + const modifiers = node.childForFieldName('modifiers'); + if (modifiers) { + for (let i = 0; i < modifiers.childCount; i++) { + const modifier = modifiers.child(i); + const modText = modifier?.text.trim() as FieldVisibility | undefined; + if (modText && TypeScriptFieldExtractor.VISIBILITY_MODIFIERS.has(modText)) { + return modText; + } + } + } + + // TypeScript class members are public by default + return 'public'; + } + + /** + * Check if a field has the static modifier + */ + private isStatic(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && !child.isNamed && child.text.trim() === 'static') { + return true; + } + } + + const modifiers = node.childForFieldName('modifiers'); + if (modifiers) { + for (let i = 0; i < modifiers.childCount; i++) { + const modifier = modifiers.child(i); + if (modifier && modifier.text === 'static') { + return true; + } + } + } + + return false; + } + + /** + * Check if a field has the readonly modifier + */ + private isReadonly(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && !child.isNamed && child.text.trim() === 'readonly') { + return true; + } + } + + const modifiers = node.childForFieldName('modifiers'); + if (modifiers) { + for (let i = 0; i < modifiers.childCount; i++) { + const modifier = modifiers.child(i); + if (modifier && modifier.text === 'readonly') { + return true; + } + } + } + + return false; + } + + /** + * Check if a property is optional (has ?: syntax) + */ + private isOptional(node: SyntaxNode): boolean { + // Look for the optional marker '?' in unnamed children + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && !child.isNamed && child.text === '?') { + return true; + } + } + + // Also check for optional_property_signature or marker in type + const kind = node.childForFieldName('kind'); + if (kind && kind.text === '?') { + return true; + } + + return false; + } + + /** + * Extract the full type text, handling complex generic types. + * + * type_annotation nodes wrap the literal ': SomeType' — only that branch + * needs special handling to unwrap the inner child and skip the colon. + * All other node kinds are already the type text itself, so normalizeType + * is applied directly. + */ + private extractFullType(typeNode: SyntaxNode | null): string | null { + if (!typeNode) return null; + if (typeNode.type === 'type_annotation') { + const innerType = typeNode.firstNamedChild; + return innerType ? this.normalizeType(innerType.text) : null; + } + return this.normalizeType(typeNode.text); + } + + /** + * Extract a single field from a field definition node + */ + private extractField(node: SyntaxNode, context: FieldExtractorContext): FieldInfo | null { + // Get the field name + const nameNode = node.childForFieldName('name') ?? node.childForFieldName('property'); + if (!nameNode) return null; + + const name = nameNode.text; + if (!name) return null; + + // Get the type annotation + const typeNode = node.childForFieldName('type'); + let type: string | null = this.extractFullType(typeNode); + + // Try to resolve the type using the context + if (type) { + const resolvedType = this.resolveType(type, context); + type = resolvedType ?? type; + } + + return { + name, + type, + visibility: this.extractVisibility(node), + isStatic: this.isStatic(node), + isReadonly: this.isReadonly(node), + sourceFile: context.filePath, + line: node.startPosition.row + 1, + }; + } + + /** + * Extract fields from a class body or interface body + */ + private extractFieldsFromBody( + bodyNode: SyntaxNode, + context: FieldExtractorContext, + ): FieldInfo[] { + const fields: FieldInfo[] = []; + + // Find all field definition nodes within the body + for (let i = 0; i < bodyNode.namedChildCount; i++) { + const child = bodyNode.namedChild(i); + if (!child) continue; + + if (TypeScriptFieldExtractor.FIELD_NODE_TYPES.has(child.type)) { + const field = this.extractField(child, context); + if (field) { + fields.push(field); + } + } + } + + return fields; + } + + /** + * Extract fields from an object type (used in type aliases) + */ + private extractFieldsFromObjectType( + objectTypeNode: SyntaxNode, + context: FieldExtractorContext, + ): FieldInfo[] { + const fields: FieldInfo[] = []; + + // Find all property_signature nodes within the object type + const propertySignatures = objectTypeNode.descendantsOfType('property_signature'); + + for (const propNode of propertySignatures) { + const field = this.extractField(propNode, context); + if (field) { + // Mark optional properties + if (this.isOptional(propNode) && field.type) { + field.type = field.type + ' | undefined'; + } + fields.push(field); + } + } + + return fields; + } + + /** + * Extract fields from a class or interface declaration + */ + extract(node: SyntaxNode, context: FieldExtractorContext): ExtractedFields | null { + if (!this.isTypeDeclaration(node)) return null; + + // Get the type name + const nameNode = node.childForFieldName('name'); + if (!nameNode) return null; + + const typeName = nameNode.text; + const ownerFqn = typeName; + + const fields: FieldInfo[] = []; + const nestedTypes: string[] = []; + + // Handle different declaration types + if (node.type === 'class_declaration' || node.type === 'abstract_class_declaration') { + // Find the class body + const bodyNode = node.childForFieldName('body'); + if (bodyNode) { + const extractedFields = this.extractFieldsFromBody(bodyNode, context); + fields.push(...extractedFields); + } + } else if (node.type === 'interface_declaration') { + // Find the interface body + const bodyNode = node.childForFieldName('body'); + if (bodyNode) { + const extractedFields = this.extractFieldsFromBody(bodyNode, context); + fields.push(...extractedFields); + } + } else if (node.type === 'type_alias_declaration') { + // Handle type aliases with object types + const valueNode = node.childForFieldName('value'); + if (valueNode && valueNode.type === 'object_type') { + const extractedFields = this.extractFieldsFromObjectType(valueNode, context); + fields.push(...extractedFields); + } + } + + // Find nested type declarations + const nestedClasses = node.descendantsOfType('class_declaration'); + const nestedInterfaces = node.descendantsOfType('interface_declaration'); + const nestedDeclarations = [...nestedClasses, ...nestedInterfaces]; + + for (const nested of nestedDeclarations) { + // Skip the current node itself + if (nested === node) continue; + + const nestedName = nested.childForFieldName('name'); + if (nestedName) { + nestedTypes.push(nestedName.text); + } + } + + return { + ownerFqn, + fields, + nestedTypes, + }; + } +} + +// Export a singleton instance for registration +export const typescriptFieldExtractor = new TypeScriptFieldExtractor(); diff --git a/gitnexus/src/core/ingestion/field-types.ts b/gitnexus/src/core/ingestion/field-types.ts new file mode 100644 index 0000000000..29e7adda55 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-types.ts @@ -0,0 +1,73 @@ +// gitnexus/src/core/ingestion/field-types.ts + +import type { TypeEnvironment } from './type-env.js'; +import type { SymbolTable } from './symbol-table.js'; +import { SupportedLanguages } from '../../config/supported-languages.js'; + +/** + * Visibility levels used across all supported languages. + * - public / private / protected: universal modifiers + * - internal: C#, Kotlin (assembly/module scope) + * - package: Java (package-private, no keyword) + * - fileprivate: Swift (file scope) + * - open: Swift (subclassable across modules) + */ +export type FieldVisibility = + | 'public' + | 'private' + | 'protected' + | 'internal' + | 'package' + | 'fileprivate' + | 'open'; + +/** + * Represents a field or property within a class/struct/interface + */ +export interface FieldInfo { + /** Field name */ + name: string; + /** Resolved type (may be primitive, FQN, or generic) */ + type: string | null; + /** Visibility modifier */ + visibility: FieldVisibility; + /** Is this a static member? */ + isStatic: boolean; + /** Is this readonly/const? */ + isReadonly: boolean; + /** Source file path */ + sourceFile: string; + /** Line number */ + line: number; +} + +/** + * Maps owner type FQN to its fields + */ +export type FieldTypeMap = Map; + +/** + * Context for field extraction + */ +export interface FieldExtractorContext { + /** Type environment for resolution */ + typeEnv: TypeEnvironment; + /** Symbol table for FQN lookups */ + symbolTable: SymbolTable; + /** Current file path */ + filePath: string; + /** Language ID */ + language: SupportedLanguages; +} + +/** + * Result of field extraction from a type declaration + */ +export interface ExtractedFields { + /** Owner type FQN */ + ownerFqn: string; + /** Extracted fields */ + fields: FieldInfo[]; + /** Nested types found during extraction */ + nestedTypes: string[]; +} diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index d08d4fc13d..2aabdb6df1 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -13,6 +13,7 @@ import type { SupportedLanguages } from '../../config/supported-languages.js'; import type { LanguageTypeConfig } from './type-extractors/types.js'; import type { CallRouter } from './call-routing.js'; import type { ExportChecker } from './export-detection.js'; +import type { FieldExtractor } from './field-extractor.js'; import type { ImportResolverFn } from './import-resolvers/types.js'; import type { NamedBindingExtractorFn } from './named-bindings/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; @@ -114,6 +115,10 @@ interface LanguageProviderConfig { readonly mroStrategy?: MroStrategy; // ── Language-specific extraction hooks ──────────────────────────── + /** Field extractor for extracting field/property definitions from class/struct + * declarations. Produces FieldInfo[] with name, type, visibility, static, + * readonly metadata. Default: undefined (no field extraction). */ + readonly fieldExtractor?: FieldExtractor; /** Extract a semantic description for a definition node (e.g., PHP Eloquent * property arrays, relation method descriptions). * Default: undefined (no description extraction). */ diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index feaca22382..17b184e575 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -17,6 +17,8 @@ import { C_QUERIES, CPP_QUERIES } from '../tree-sitter-queries.js'; import { isCppInsideClassOrStruct } from '../utils/ast-helpers.js'; import type { LanguageProvider } from '../language-provider.js'; +import { createFieldExtractor } from '../field-extractors/generic.js'; +import { cConfig as cFieldConfig, cppConfig as cppFieldConfig } from '../field-extractors/configs/c-cpp.js'; const C_BUILT_INS: ReadonlySet = new Set([ 'printf', 'fprintf', 'sprintf', 'snprintf', 'vprintf', 'vfprintf', 'vsprintf', 'vsnprintf', @@ -55,6 +57,7 @@ export const cProvider = defineLanguage({ exportChecker: cCppExportChecker, importResolver: resolveCImport, importSemantics: 'wildcard', + fieldExtractor: createFieldExtractor(cFieldConfig), labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, }); @@ -68,6 +71,7 @@ export const cppProvider = defineLanguage({ importResolver: resolveCppImport, importSemantics: 'wildcard', mroStrategy: 'leftmost-base', + fieldExtractor: createFieldExtractor(cppFieldConfig), labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/csharp.ts b/gitnexus/src/core/ingestion/languages/csharp.ts index 313eecb5a2..5921cc3254 100644 --- a/gitnexus/src/core/ingestion/languages/csharp.ts +++ b/gitnexus/src/core/ingestion/languages/csharp.ts @@ -13,6 +13,8 @@ import { csharpExportChecker } from '../export-detection.js'; import { resolveCSharpImport } from '../import-resolvers/csharp.js'; import { extractCSharpNamedBindings } from '../named-bindings/csharp.js'; import { CSHARP_QUERIES } from '../tree-sitter-queries.js'; +import { createFieldExtractor } from '../field-extractors/generic.js'; +import { csharpConfig as csharpFieldConfig } from '../field-extractors/configs/csharp.js'; const BUILT_INS: ReadonlySet = new Set([ 'Console', 'WriteLine', 'ReadLine', 'Write', @@ -45,5 +47,6 @@ export const csharpProvider = defineLanguage({ namedBindingExtractor: extractCSharpNamedBindings, interfaceNamePattern: /^I[A-Z]/, mroStrategy: 'implements-split', + fieldExtractor: createFieldExtractor(csharpFieldConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index ddd8bc0479..26d3ded6b2 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -19,6 +19,8 @@ import { typeConfig as dartConfig } from '../type-extractors/dart.js'; import { dartExportChecker } from '../export-detection.js'; import { resolveDartImport } from '../import-resolvers/dart.js'; import { DART_QUERIES } from '../tree-sitter-queries.js'; +import { createFieldExtractor } from '../field-extractors/generic.js'; +import { dartConfig as dartFieldConfig } from '../field-extractors/configs/dart.js'; /** * Resolve the enclosing function from a `function_body` node by looking at its @@ -54,6 +56,7 @@ export const dartProvider = defineLanguage({ exportChecker: dartExportChecker, importResolver: resolveDartImport, importSemantics: 'wildcard', + fieldExtractor: createFieldExtractor(dartFieldConfig), enclosingFunctionFinder: dartEnclosingFunctionFinder, builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 19526ea33d..fa9050cc1a 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -15,6 +15,8 @@ import { typeConfig as goConfig } from '../type-extractors/go.js'; import { goExportChecker } from '../export-detection.js'; import { resolveGoImport } from '../import-resolvers/go.js'; import { GO_QUERIES } from '../tree-sitter-queries.js'; +import { createFieldExtractor } from '../field-extractors/generic.js'; +import { goConfig as goFieldConfig } from '../field-extractors/configs/go.js'; export const goProvider = defineLanguage({ id: SupportedLanguages.Go, @@ -24,4 +26,5 @@ export const goProvider = defineLanguage({ exportChecker: goExportChecker, importResolver: resolveGoImport, importSemantics: 'wildcard', + fieldExtractor: createFieldExtractor(goFieldConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index f4c0146952..0e647a865e 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -14,6 +14,8 @@ import { javaExportChecker } from '../export-detection.js'; import { resolveJavaImport } from '../import-resolvers/jvm.js'; import { extractJavaNamedBindings } from '../named-bindings/java.js'; import { JAVA_QUERIES } from '../tree-sitter-queries.js'; +import { createFieldExtractor } from '../field-extractors/generic.js'; +import { javaConfig } from '../field-extractors/configs/jvm.js'; export const javaProvider = defineLanguage({ id: SupportedLanguages.Java, @@ -25,4 +27,5 @@ export const javaProvider = defineLanguage({ namedBindingExtractor: extractJavaNamedBindings, interfaceNamePattern: /^I[A-Z]/, mroStrategy: 'implements-split', + fieldExtractor: createFieldExtractor(javaConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 97d77b42db..1ed7983c91 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -16,6 +16,8 @@ import { extractKotlinNamedBindings } from '../named-bindings/kotlin.js'; import { appendKotlinWildcard } from '../import-resolvers/jvm.js'; import { KOTLIN_QUERIES } from '../tree-sitter-queries.js'; import { isKotlinClassMethod } from '../utils/ast-helpers.js'; +import { createFieldExtractor } from '../field-extractors/generic.js'; +import { kotlinConfig } from '../field-extractors/configs/jvm.js'; const BUILT_INS: ReadonlySet = new Set([ 'println', 'print', 'readLine', 'require', 'requireNotNull', 'check', 'assert', 'lazy', 'error', @@ -42,6 +44,7 @@ export const kotlinProvider = defineLanguage({ namedBindingExtractor: extractKotlinNamedBindings, importPathPreprocessor: appendKotlinWildcard, mroStrategy: 'implements-split', + fieldExtractor: createFieldExtractor(kotlinConfig), builtInNames: BUILT_INS, labelOverride: (functionNode, defaultLabel) => { if (defaultLabel !== 'Function') return defaultLabel; diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index 41426b20c6..86523cb5df 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -15,6 +15,8 @@ import { extractPhpNamedBindings } from '../named-bindings/php.js'; import { PHP_QUERIES } from '../tree-sitter-queries.js'; import { findDescendant, extractStringContent } from '../utils/ast-helpers.js'; import type { NodeLabel } from '../../graph/types.js'; +import { createFieldExtractor } from '../field-extractors/generic.js'; +import { phpConfig as phpFieldConfig } from '../field-extractors/configs/php.js'; const BUILT_INS: ReadonlySet = new Set([ 'echo', 'isset', 'empty', 'unset', 'list', 'array', 'compact', 'extract', @@ -147,6 +149,7 @@ export const phpProvider = defineLanguage({ exportChecker: phpExportChecker, importResolver: resolvePhpImport, namedBindingExtractor: extractPhpNamedBindings, + fieldExtractor: createFieldExtractor(phpFieldConfig), descriptionExtractor: phpDescriptionExtractor, isRouteFile: isPhpRouteFile, builtInNames: BUILT_INS, diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index 9f7992ab85..1c596ce5c1 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -17,6 +17,8 @@ import { pythonExportChecker } from '../export-detection.js'; import { resolvePythonImport } from '../import-resolvers/python.js'; import { extractPythonNamedBindings } from '../named-bindings/python.js'; import { PYTHON_QUERIES } from '../tree-sitter-queries.js'; +import { createFieldExtractor } from '../field-extractors/generic.js'; +import { pythonConfig as pythonFieldConfig } from '../field-extractors/configs/python.js'; const BUILT_INS: ReadonlySet = new Set([ 'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple', @@ -35,5 +37,6 @@ export const pythonProvider = defineLanguage({ namedBindingExtractor: extractPythonNamedBindings, importSemantics: 'namespace', mroStrategy: 'c3', + fieldExtractor: createFieldExtractor(pythonFieldConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index 217f87bbff..0070f2fd52 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -14,6 +14,8 @@ import { routeRubyCall } from '../call-routing.js'; import { rubyExportChecker } from '../export-detection.js'; import { resolveRubyImport } from '../import-resolvers/ruby.js'; import { RUBY_QUERIES } from '../tree-sitter-queries.js'; +import { createFieldExtractor } from '../field-extractors/generic.js'; +import { rubyConfig as rubyFieldConfig } from '../field-extractors/configs/ruby.js'; const BUILT_INS: ReadonlySet = new Set([ 'puts', 'p', 'pp', 'raise', 'fail', @@ -40,5 +42,6 @@ export const rubyProvider = defineLanguage({ importResolver: resolveRubyImport, callRouter: routeRubyCall, importSemantics: 'wildcard', + fieldExtractor: createFieldExtractor(rubyFieldConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index 2119f5444c..aa91ebeba8 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -17,6 +17,8 @@ import { rustExportChecker } from '../export-detection.js'; import { resolveRustImport } from '../import-resolvers/rust.js'; import { extractRustNamedBindings } from '../named-bindings/rust.js'; import { RUST_QUERIES } from '../tree-sitter-queries.js'; +import { createFieldExtractor } from '../field-extractors/generic.js'; +import { rustConfig as rustFieldConfig } from '../field-extractors/configs/rust.js'; const BUILT_INS: ReadonlySet = new Set([ 'unwrap', 'expect', 'unwrap_or', 'unwrap_or_else', 'unwrap_or_default', @@ -40,5 +42,6 @@ export const rustProvider = defineLanguage({ importResolver: resolveRustImport, namedBindingExtractor: extractRustNamedBindings, mroStrategy: 'qualified-syntax', + fieldExtractor: createFieldExtractor(rustFieldConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index df0635a78c..8b221abde3 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -17,6 +17,8 @@ import { swiftExportChecker } from '../export-detection.js'; import { resolveSwiftImport } from '../import-resolvers/swift.js'; import { SWIFT_QUERIES } from '../tree-sitter-queries.js'; import type { SwiftPackageConfig } from '../language-config.js'; +import { createFieldExtractor } from '../field-extractors/generic.js'; +import { swiftConfig as swiftFieldConfig } from '../field-extractors/configs/swift.js'; /** * Group Swift files by SPM target for implicit module visibility. @@ -138,6 +140,7 @@ export const swiftProvider = defineLanguage({ importResolver: resolveSwiftImport, importSemantics: 'wildcard', heritageDefaultEdge: 'IMPLEMENTS', + fieldExtractor: createFieldExtractor(swiftFieldConfig), implicitImportWirer: wireSwiftImplicitImports, builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index d1e697b1db..d1e4a1b5ad 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -14,6 +14,9 @@ import { tsExportChecker } from '../export-detection.js'; import { resolveTypescriptImport, resolveJavascriptImport } from '../import-resolvers/standard.js'; import { extractTsNamedBindings } from '../named-bindings/typescript.js'; import { TYPESCRIPT_QUERIES, JAVASCRIPT_QUERIES } from '../tree-sitter-queries.js'; +import { typescriptFieldExtractor } from '../field-extractors/typescript.js'; +import { createFieldExtractor } from '../field-extractors/generic.js'; +import { javascriptConfig } from '../field-extractors/configs/typescript-javascript.js'; const BUILT_INS: ReadonlySet = new Set([ 'console', 'log', 'warn', 'error', 'info', 'debug', @@ -44,6 +47,7 @@ export const typescriptProvider = defineLanguage({ exportChecker: tsExportChecker, importResolver: resolveTypescriptImport, namedBindingExtractor: extractTsNamedBindings, + fieldExtractor: typescriptFieldExtractor, builtInNames: BUILT_INS, }); @@ -55,5 +59,6 @@ export const javascriptProvider = defineLanguage({ exportChecker: tsExportChecker, importResolver: resolveJavascriptImport, namedBindingExtractor: extractTsNamedBindings, + fieldExtractor: createFieldExtractor(javascriptConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 6734aa7f89..c9f5eb12d9 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -7,9 +7,11 @@ import { SymbolTable } from './symbol-table.js'; import { ASTCache } from './ast-cache.js'; import { getLanguageFromFilename } from './utils/language-detection.js'; import { yieldToEventLoop } from './utils/event-loop.js'; -import { getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature, getLabelFromCaptures } from './utils/ast-helpers.js'; -import { extractPropertyDeclaredType } from './type-extractors/shared.js'; +import { getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature, getLabelFromCaptures, CLASS_CONTAINER_TYPES, type SyntaxNode } from './utils/ast-helpers.js'; import { detectFrameworkFromAST } from './framework-detection.js'; +import { buildTypeEnv } from './type-env.js'; +import type { FieldInfo, FieldExtractorContext } from './field-types.js'; +import type { LanguageProvider } from './language-provider.js'; import { WorkerPool } from './workers/worker-pool.js'; import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, ExtractedFetchCall, ExtractedDecoratorRoute, ExtractedToolDef, FileConstructorBindings, FileTypeEnvBindings, ExtractedORMQuery } from './workers/parse-worker.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; @@ -154,6 +156,43 @@ const cachedExportCheck = (checker: (node: any, name: string) => boolean, node: return result; }; +// FieldExtractor cache for sequential path — same pattern as parse-worker.ts +const seqFieldInfoCache = new Map>(); + +function seqFindEnclosingClassNode(node: any): any | null { + let current = node.parent; + while (current) { + if (CLASS_CONTAINER_TYPES.has(current.type)) return current; + current = current.parent; + } + return null; +} + +/** Minimal no-op SymbolTable stub for FieldExtractorContext (sequential path has a real + * SymbolTable, but it's incomplete at this stage — use the stub for safety). */ +const NOOP_SYMBOL_TABLE_SEQ: any = { + lookupExactAll: () => [], + lookupExact: () => undefined, + lookupExactFull: () => undefined, +}; + +function seqGetFieldInfo( + classNode: SyntaxNode, + provider: LanguageProvider, + context: FieldExtractorContext, +): Map | undefined { + if (!provider.fieldExtractor) return undefined; + const cacheKey = classNode.startIndex; + let cached = seqFieldInfoCache.get(cacheKey); + if (cached) return cached; + const extracted = provider.fieldExtractor.extract(classNode, context); + if (!extracted?.fields?.length) return undefined; + cached = new Map(); + for (const field of extracted.fields) cached.set(field.name, field); + seqFieldInfoCache.set(cacheKey, cached); + return cached; +} + const processParsingSequential = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], @@ -171,6 +210,7 @@ const processParsingSequential = async ( // Reset memoization before each new file (node refs are per-tree) classIdCache.clear(); exportCache.clear(); + seqFieldInfoCache.clear(); onFileProgress?.(i + 1, total, file.path); @@ -222,6 +262,9 @@ const processParsingSequential = async ( continue; } + // Build per-file type environment for FieldExtractor context (lightweight — skipped if no fieldExtractor) + const typeEnv = provider.fieldExtractor ? buildTypeEnv(tree, language, { enclosingFunctionFinder: provider.enclosingFunctionFinder }) : null; + matches.forEach(match => { const captureMap: Record = {}; @@ -291,10 +334,36 @@ const processParsingSequential = async ( const needsOwner = nodeLabel === 'Method' || nodeLabel === 'Constructor' || nodeLabel === 'Property' || nodeLabel === 'Function'; const enclosingClassId = needsOwner ? cachedFindEnclosingClassId(nameNode || definitionNodeForRange, file.path) : null; - // Extract declared type for Property nodes (field/property type annotations) - const declaredType = (nodeLabel === 'Property' && definitionNode) - ? extractPropertyDeclaredType(definitionNode) - : undefined; + // Extract declared type and field metadata for Property nodes + let declaredType: string | undefined; + let seqVisibility: string | undefined; + let seqIsStatic: boolean | undefined; + let seqIsReadonly: boolean | undefined; + if (nodeLabel === 'Property' && definitionNode) { + // FieldExtractor is the single source of truth when available + if (provider.fieldExtractor && typeEnv) { + const classNode = seqFindEnclosingClassNode(definitionNode); + if (classNode) { + const fieldMap = seqGetFieldInfo(classNode, provider, { + typeEnv, symbolTable: NOOP_SYMBOL_TABLE_SEQ, filePath: file.path, language, + }); + const info = fieldMap?.get(nodeName); + if (info) { + declaredType = info.type ?? undefined; + seqVisibility = info.visibility; + seqIsStatic = info.isStatic; + seqIsReadonly = info.isReadonly; + } + } + } + // All 14 languages register a FieldExtractor — no fallback needed. + } + + // Apply field metadata to the graph node retroactively + if (seqVisibility !== undefined) node.properties.visibility = seqVisibility; + if (seqIsStatic !== undefined) node.properties.isStatic = seqIsStatic; + if (seqIsReadonly !== undefined) node.properties.isReadonly = seqIsReadonly; + if (declaredType !== undefined) node.properties.declaredType = declaredType; symbolTable.add(file.path, nodeName, nodeId, nodeLabel, { parameterCount: methodSig?.parameterCount, diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index a694c24471..ffa5dfc363 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -6,6 +6,7 @@ import { getProvider } from './languages/index.js'; import type { ClassNameLookup, ReturnTypeLookup, ForLoopExtractorContext, PendingAssignment } from './type-extractors/types.js'; import { extractSimpleTypeName, extractVarName, stripNullable, extractReturnTypeName } from './type-extractors/shared.js'; import type { SymbolTable } from './symbol-table.js'; +import type { NodeLabel } from '../graph/types.js'; /** * Per-file scoped type environment: maps (scope, variableName) → typeName. @@ -115,6 +116,7 @@ const lookupInEnv = ( varName: string, callNode: SyntaxNode, patternOverrides?: PatternOverrides, + enclosingFunctionFinder?: (n: SyntaxNode) => { funcName: string; label: NodeLabel } | null, ): string | undefined => { // Self/this receiver: resolve to enclosing class name via AST walk if (varName === 'self' || varName === 'this' || varName === '$this') { @@ -128,7 +130,7 @@ const lookupInEnv = ( } // Determine the enclosing function scope for the call - const scopeKey = findEnclosingScopeKey(callNode); + const scopeKey = findEnclosingScopeKey(callNode, enclosingFunctionFinder); // Check position-indexed pattern overrides first (e.g., Kotlin when/is smart casts). // These take priority over flat scopeEnv because they represent per-branch narrowing. @@ -338,14 +340,30 @@ const extractParentClassFromNode = (classNode: SyntaxNode): string | undefined = return undefined; }; -/** Find the enclosing function name for scope lookup. */ -const findEnclosingScopeKey = (node: SyntaxNode): string | undefined => { +/** Find the enclosing function name for scope lookup. + * When an `enclosingFunctionFinder` hook is provided (from the language provider), + * it is consulted for each ancestor before the default FUNCTION_NODE_TYPES check. + * This handles languages like Dart where the function body is a sibling of the + * signature instead of a child. */ +const findEnclosingScopeKey = ( + node: SyntaxNode, + enclosingFunctionFinder?: (n: SyntaxNode) => { funcName: string; label: NodeLabel } | null, +): string | undefined => { let current = node.parent; while (current) { if (FUNCTION_NODE_TYPES.has(current.type)) { const { funcName } = extractFunctionName(current); if (funcName) return `${funcName}@${current.startIndex}`; } + // Language-specific hook (e.g., Dart function_body → sibling function_signature) + if (enclosingFunctionFinder) { + const result = enclosingFunctionFinder(current); + if (result) { + const sigNode = current.previousSibling; + const startIdx = sigNode?.startIndex ?? current.startIndex; + return `${result.funcName}@${startIdx}`; + } + } current = current.parent; } return undefined; @@ -627,7 +645,10 @@ const resolveFixpointBindings = ( let typeName: string | undefined; switch (item.kind) { case 'callResult': - typeName = returnTypeLookup.lookupReturnType(item.callee); + // Phase 9: Prefer FQN lookup when available for higher precision + typeName = item.calleeFqn + ? returnTypeLookup.lookupReturnType(item.calleeFqn) + : returnTypeLookup.lookupReturnType(item.callee); break; case 'copy': typeName = scopeEnv.get(item.rhs) ?? env.get(FILE_SCOPE)?.get(item.rhs); @@ -680,6 +701,10 @@ export interface BuildTypeEnvOptions { * Stores raw declared return type strings (e.g., 'User[]', 'List'). * Used by lookupRawReturnType for for-loop element extraction. */ importedRawReturnTypes?: ReadonlyMap; + /** Language-specific enclosing function resolver for scope key lookup. + * Same hook as LanguageProvider.enclosingFunctionFinder — handles languages + * where function_body is a sibling of the signature (e.g., Dart). */ + enclosingFunctionFinder?: (ancestorNode: SyntaxNode) => { funcName: string; label: NodeLabel } | null; } /** Seed cross-file type bindings into the file scope. @@ -1107,7 +1132,7 @@ export const buildTypeEnv = ( } return { - lookup: (varName, callNode) => lookupInEnv(env, varName, callNode, patternOverrides), + lookup: (varName, callNode) => lookupInEnv(env, varName, callNode, patternOverrides, options?.enclosingFunctionFinder), constructorBindings: bindings, fileScope: () => env.get(FILE_SCOPE) ?? EMPTY_FILE_SCOPE, allScopes: () => env as ReadonlyMap>, diff --git a/gitnexus/src/core/ingestion/type-extractors/shared.ts b/gitnexus/src/core/ingestion/type-extractors/shared.ts index 729124d80e..f9c2ba4f8a 100644 --- a/gitnexus/src/core/ingestion/type-extractors/shared.ts +++ b/gitnexus/src/core/ingestion/type-extractors/shared.ts @@ -752,101 +752,6 @@ export const extractReturnTypeName = (raw: string, depth = 0): string | undefine return text; }; -// ── Property declared-type extraction ──────────────────────────────────── -// Shared between parse-worker (worker path) and parsing-processor (sequential path). - -/** - * Extract the declared type of a property/field from its AST definition node. - * Handles cross-language patterns: - * - TypeScript: `name: Type` → type_annotation child - * - Java: `Type name` → type child on field_declaration - * - C#: `Type Name { get; set; }` → type child on property_declaration - * - Go: `Name Type` → type child on field_declaration - * - Kotlin: `var name: Type` → variable_declaration child with type field - * - * Returns the normalized type name, or undefined if no type can be extracted. - */ -export const extractPropertyDeclaredType = (definitionNode: SyntaxNode | null): string | undefined => { - if (!definitionNode) return undefined; - - // Strategy 1: Look for a `type` or `type_annotation` named field - const typeNode = definitionNode.childForFieldName?.('type'); - if (typeNode) { - const typeName = extractSimpleTypeName(typeNode); - if (typeName) return typeName; - // Fallback: use the raw text (for complex types like User[] or List) - const text = typeNode.text?.trim(); - if (text && text.length < 100) return text; - } - - // Strategy 2: Walk children looking for type_annotation (TypeScript pattern) - for (let i = 0; i < definitionNode.childCount; i++) { - const child = definitionNode.child(i); - if (!child) continue; - if (child.type === 'type_annotation') { - // Type annotation has the actual type as a child - for (let j = 0; j < child.childCount; j++) { - const typeChild = child.child(j); - if (typeChild && typeChild.type !== ':') { - const typeName = extractSimpleTypeName(typeChild); - if (typeName) return typeName; - const text = typeChild.text?.trim(); - if (text && text.length < 100) return text; - } - } - } - } - - // Strategy 3: For Java field_declaration, the type is a sibling of variable_declarator - // AST: (field_declaration type: (type_identifier) declarator: (variable_declarator ...)) - const parentDecl = definitionNode.parent; - if (parentDecl) { - const parentType = parentDecl.childForFieldName?.('type'); - if (parentType) { - const typeName = extractSimpleTypeName(parentType); - if (typeName) return typeName; - } - } - - // Strategy 4: Kotlin property_declaration — type is nested inside variable_declaration child - // AST: (property_declaration (variable_declaration (simple_identifier) ":" (user_type (type_identifier)))) - // Kotlin's variable_declaration has NO named 'type' field — children are all positional. - for (let i = 0; i < definitionNode.childCount; i++) { - const child = definitionNode.child(i); - if (child?.type === 'variable_declaration') { - // Try named field first (works for other languages sharing this strategy) - const varType = child.childForFieldName?.('type'); - if (varType) { - const typeName = extractSimpleTypeName(varType); - if (typeName) return typeName; - const text = varType.text?.trim(); - if (text && text.length < 100) return text; - } - // Fallback: walk unnamed children for user_type / type_identifier (Kotlin) - for (let j = 0; j < child.namedChildCount; j++) { - const varChild = child.namedChild(j); - if (varChild && (varChild.type === 'user_type' || varChild.type === 'type_identifier' - || varChild.type === 'nullable_type' || varChild.type === 'generic_type')) { - const typeName = extractSimpleTypeName(varChild); - if (typeName) return typeName; - } - } - } - } - - // Strategy 5: PHP @var PHPDoc — look for preceding comment with @var Type - // Handles pre-PHP-7.4 code: /** @var Address */ public $address; - const prevSibling = definitionNode.previousNamedSibling ?? definitionNode.parent?.previousNamedSibling; - if (prevSibling?.type === 'comment') { - const commentText = prevSibling.text; - const varMatch = commentText?.match(/@var\s+([A-Z][\w\\]*)/); - if (varMatch) { - // Strip namespace prefix: \App\Models\User → User - const raw = varMatch[1]; - const base = raw.includes('\\') ? raw.split('\\').pop()! : raw; - if (base && /^[A-Z]\w*$/.test(base)) return base; - } - } - - return undefined; -}; +// extractPropertyDeclaredType removed — all 14 languages register a FieldExtractor +// via defineLanguage() which is the single source of truth for Property metadata +// (declaredType, visibility, isStatic, isReadonly). diff --git a/gitnexus/src/core/ingestion/type-extractors/types.ts b/gitnexus/src/core/ingestion/type-extractors/types.ts index fe03563e64..1b32038724 100644 --- a/gitnexus/src/core/ingestion/type-extractors/types.ts +++ b/gitnexus/src/core/ingestion/type-extractors/types.ts @@ -76,7 +76,7 @@ export type ForLoopExtractor = (node: SyntaxNode, ctx: ForLoopExtractorContext) * - `methodCallResult` — `const b = a.method()` (bind b to method's returnType on a's type) */ export type PendingAssignment = | { kind: 'copy'; lhs: string; rhs: string } - | { kind: 'callResult'; lhs: string; callee: string } + | { kind: 'callResult'; lhs: string; callee: string; calleeFqn?: string; line?: number } | { kind: 'fieldAccess'; lhs: string; receiver: string; field: string } | { kind: 'methodCallResult'; lhs: string; receiver: string; method: string }; diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index acdf856844..3a173aee5d 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -633,6 +633,18 @@ export const extractMethodSignature = (node: SyntaxNode | null | undefined): Met } } + // Swift fallback: tree-sitter-swift places `parameter` nodes as direct children of + // function_declaration without a wrapping parameters/function_parameters list node. + // When no parameter list was found, count direct `parameter` children on the node. + if (!parameterList && parameterCount === 0) { + for (const child of node.namedChildren) { + if (child.type === 'parameter') { + if (!hasDefaultValue(child)) requiredCount++; + parameterCount++; + } + } + } + // 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'); diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index ae8dff9ae0..2527136523 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -38,6 +38,7 @@ import { extractMethodSignature, findDescendant, extractStringContent, + type SyntaxNode, } from '../utils/ast-helpers.js'; import { countCallArguments, @@ -53,8 +54,9 @@ import { detectFrameworkFromAST } from '../framework-detection.js'; import { generateId } from '../../../lib/utils.js'; import { preprocessImportPath } from '../import-processor.js'; import type { NamedBinding } from '../named-bindings/types.js'; -import { extractPropertyDeclaredType } from '../type-extractors/shared.js'; import type { NodeLabel } from '../../graph/types.js'; +import type { FieldInfo, FieldExtractorContext } from '../field-types.js'; +import { CLASS_CONTAINER_TYPES } from '../utils/ast-helpers.js'; // ============================================================================ // Types for serializable results @@ -76,6 +78,11 @@ interface ParsedNode { parameterCount?: number; requiredParameterCount?: number; returnType?: string; + // Field/property metadata (populated by FieldExtractor) + declaredType?: string; + visibility?: string; + isStatic?: boolean; + isReadonly?: boolean; }; } @@ -99,6 +106,9 @@ interface ParsedSymbol { returnType?: string; declaredType?: string; ownerId?: string; + visibility?: string; + isStatic?: boolean; + isReadonly?: boolean; } export interface ExtractedImport { @@ -287,7 +297,67 @@ const classIdCache = new Map(); const functionIdCache = new Map(); const exportCache = new Map(); -const clearCaches = (): void => { classIdCache.clear(); functionIdCache.clear(); exportCache.clear(); }; +const clearCaches = (): void => { classIdCache.clear(); functionIdCache.clear(); exportCache.clear(); fieldInfoCache.clear(); }; + +// ============================================================================ +// FieldExtractor cache — extract field metadata once per class, reuse for each property. +// Keyed by class node startIndex (unique per AST node within a file). +// ============================================================================ + +const fieldInfoCache = new Map>(); + +/** + * Walk up from a definition node to find the nearest enclosing class/struct/interface + * AST node. Returns the SyntaxNode itself (not an ID) for passing to FieldExtractor. + */ +function findEnclosingClassNode(node: SyntaxNode): SyntaxNode | null { + let current = node.parent; + while (current) { + if (CLASS_CONTAINER_TYPES.has(current.type)) { + return current; + } + current = current.parent; + } + return null; +} + +/** + * Minimal no-op SymbolTable stub for FieldExtractorContext in the worker. + * Field extraction only uses symbolTable.lookupExactAll for optional type resolution — + * returning [] causes the extractor to use the raw type string, which is fine for us. + */ +const NOOP_SYMBOL_TABLE: any = { + lookupExactAll: () => [], + lookupExact: () => undefined, + lookupExactFull: () => undefined, +}; + +/** + * Get (or extract and cache) field info for a class node. + * Returns a name→FieldInfo map, or undefined if the provider has no field extractor + * or the class yielded no fields. + */ +function getFieldInfo( + classNode: SyntaxNode, + provider: LanguageProvider, + context: FieldExtractorContext, +): Map | undefined { + if (!provider.fieldExtractor) return undefined; + + const cacheKey = classNode.startIndex; + let cached = fieldInfoCache.get(cacheKey); + if (cached) return cached; + + const result = provider.fieldExtractor.extract(classNode, context); + if (!result?.fields?.length) return undefined; + + cached = new Map(); + for (const field of result.fields) { + cached.set(field.name, field); + } + fieldInfoCache.set(cacheKey, cached); + return cached; +} // ============================================================================ // Enclosing function detection (for call extraction) — cached @@ -975,8 +1045,8 @@ const processFileGroup = ( // Build per-file type environment + constructor bindings in a single AST walk. // Constructor bindings are verified against the SymbolTable in processCallsFromExtracted. const parentMap: ReadonlyMap = fileParentMap; - const typeEnv = buildTypeEnv(tree, language, { parentMap }); const provider = getProvider(language); + const typeEnv = buildTypeEnv(tree, language, { parentMap, enclosingFunctionFinder: provider?.enclosingFunctionFinder }); const callRouter = provider.callRouter; if (typeEnv.constructorBindings.length > 0) { @@ -1147,7 +1217,18 @@ const processFileGroup = ( if (routed.kind === 'properties') { const propEnclosingClassId = cachedFindEnclosingClassId(captureMap['call'], file.path); + // Enrich routed properties with FieldExtractor metadata + let routedFieldMap: Map | undefined; + if (provider.fieldExtractor && typeEnv) { + const classNode = findEnclosingClassNode(captureMap['call']); + if (classNode) { + routedFieldMap = getFieldInfo(classNode, provider, { + typeEnv, symbolTable: NOOP_SYMBOL_TABLE, filePath: file.path, language, + }); + } + } for (const item of routed.items) { + const routedFieldInfo = routedFieldMap?.get(item.propName); const nodeId = generateId('Property', `${file.path}:${item.propName}`); result.nodes.push({ id: nodeId, @@ -1160,6 +1241,10 @@ const processFileGroup = ( language, isExported: true, description: item.accessorType, + ...(item.declaredType ? { declaredType: item.declaredType } : routedFieldInfo?.type ? { declaredType: routedFieldInfo.type } : {}), + ...(routedFieldInfo?.visibility !== undefined ? { visibility: routedFieldInfo.visibility } : {}), + ...(routedFieldInfo?.isStatic !== undefined ? { isStatic: routedFieldInfo.isStatic } : {}), + ...(routedFieldInfo?.isReadonly !== undefined ? { isReadonly: routedFieldInfo.isReadonly } : {}), }, }); result.symbols.push({ @@ -1168,7 +1253,10 @@ const processFileGroup = ( nodeId, type: 'Property', ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}), - ...(item.declaredType ? { declaredType: item.declaredType } : {}), + ...(item.declaredType ? { declaredType: item.declaredType } : routedFieldInfo?.type ? { declaredType: routedFieldInfo.type } : {}), + ...(routedFieldInfo?.visibility !== undefined ? { visibility: routedFieldInfo.visibility } : {}), + ...(routedFieldInfo?.isStatic !== undefined ? { isStatic: routedFieldInfo.isStatic } : {}), + ...(routedFieldInfo?.isReadonly !== undefined ? { isReadonly: routedFieldInfo.isReadonly } : {}), }); const fileId = generateId('File', file.path); const relId = generateId('DEFINES', `${fileId}->${nodeId}`); @@ -1332,6 +1420,9 @@ const processFileGroup = ( let parameterTypes: string[] | undefined; let returnType: string | undefined; let declaredType: string | undefined; + let visibility: string | undefined; + let isStatic: boolean | undefined; + let isReadonly: boolean | undefined; if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') { const sig = extractMethodSignature(definitionNode); parameterCount = sig.parameterCount; @@ -1349,9 +1440,22 @@ const processFileGroup = ( } } } else if (nodeLabel === 'Property' && definitionNode) { - // Extract the declared type for property/field nodes. - // Walk the definition node for type annotation children. - declaredType = extractPropertyDeclaredType(definitionNode); + // FieldExtractor is the single source of truth when available + if (provider.fieldExtractor && typeEnv) { + const classNode = findEnclosingClassNode(definitionNode); + if (classNode) { + const fieldMap = getFieldInfo(classNode, provider, { + typeEnv, symbolTable: NOOP_SYMBOL_TABLE, filePath: file.path, language, + }); + const info = fieldMap?.get(nodeName); + if (info) { + declaredType = info.type ?? undefined; + visibility = info.visibility; + isStatic = info.isStatic; + isReadonly = info.isReadonly; + } + } + } } result.nodes.push({ @@ -1373,6 +1477,10 @@ const processFileGroup = ( ...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}), ...(parameterTypes !== undefined ? { parameterTypes } : {}), ...(returnType !== undefined ? { returnType } : {}), + ...(declaredType !== undefined ? { declaredType } : {}), + ...(visibility !== undefined ? { visibility } : {}), + ...(isStatic !== undefined ? { isStatic } : {}), + ...(isReadonly !== undefined ? { isReadonly } : {}), }, }); @@ -1392,6 +1500,9 @@ const processFileGroup = ( ...(returnType !== undefined ? { returnType } : {}), ...(declaredType !== undefined ? { declaredType } : {}), ...(enclosingClassId ? { ownerId: enclosingClassId } : {}), + ...(visibility !== undefined ? { visibility } : {}), + ...(isStatic !== undefined ? { isStatic } : {}), + ...(isReadonly !== undefined ? { isReadonly } : {}), }); const fileId = generateId('File', file.path); diff --git a/gitnexus/test/fixtures/lang-resolution/swift-call-result-binding/App.swift b/gitnexus/test/fixtures/lang-resolution/swift-call-result-binding/App.swift new file mode 100644 index 0000000000..edf1600d96 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-call-result-binding/App.swift @@ -0,0 +1,4 @@ +func processUser() { + let user = getUser(name: "alice") + user.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-call-result-binding/Models.swift b/gitnexus/test/fixtures/lang-resolution/swift-call-result-binding/Models.swift new file mode 100644 index 0000000000..fbf620bcb5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-call-result-binding/Models.swift @@ -0,0 +1,11 @@ +class User { + var name: String = "" + + func save() -> Bool { + return true + } +} + +func getUser(name: String) -> User { + return User() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-field-types/App.swift b/gitnexus/test/fixtures/lang-resolution/swift-field-types/App.swift new file mode 100644 index 0000000000..43e6eca468 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-field-types/App.swift @@ -0,0 +1,3 @@ +func processUser(user: User) { + user.address.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-field-types/Models.swift b/gitnexus/test/fixtures/lang-resolution/swift-field-types/Models.swift new file mode 100644 index 0000000000..01de26dbb6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-field-types/Models.swift @@ -0,0 +1,16 @@ +class Address { + var city: String = "" + + func save() { + // persist address + } +} + +class User { + var name: String = "" + var address: Address = Address() + + func greet() -> String { + return name + } +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 4bdf84b564..7fe796770c 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -857,6 +857,20 @@ describe('Field type resolution (C++)', () => { ); expect(addressSave).toBeDefined(); }); + + it('populates field metadata (visibility, declaredType) on Property nodes', () => { + const properties = getNodesByLabelFull(result, 'Property'); + + const city = properties.find(p => p.name === 'city'); + expect(city).toBeDefined(); + expect(city!.properties.visibility).toBe('public'); + expect(city!.properties.isStatic).toBe(false); + expect(city!.properties.isReadonly).toBe(false); + + const addr = properties.find(p => p.name === 'address'); + expect(addr).toBeDefined(); + expect(addr!.properties.visibility).toBe('public'); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index ef048a780e..9faa2d4213 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -1235,6 +1235,21 @@ describe('Field type resolution (C#)', () => { ); expect(addressSave).toBeDefined(); }); + + it('populates field metadata (visibility, declaredType) on Property nodes', () => { + const properties = getNodesByLabelFull(result, 'Property'); + + const city = properties.find(p => p.name === 'City'); + expect(city).toBeDefined(); + expect(city!.properties.visibility).toBe('public'); + expect(city!.properties.isStatic).toBe(false); + expect(city!.properties.declaredType).toBe('string'); + + const addr = properties.find(p => p.name === 'Address'); + expect(addr).toBeDefined(); + expect(addr!.properties.visibility).toBe('public'); + expect(addr!.properties.declaredType).toBe('Address'); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/dart.test.ts b/gitnexus/test/integration/resolvers/dart.test.ts index 65a87d348b..c00e752b24 100644 --- a/gitnexus/test/integration/resolvers/dart.test.ts +++ b/gitnexus/test/integration/resolvers/dart.test.ts @@ -3,8 +3,8 @@ * Verifies that class fields are captured as Property nodes with HAS_PROPERTY * edges, and that calls (including chained and call-result-bound) are resolved. * - * Remaining known Dart gaps (field-chain ACCESSES) are documented as - * it.todo() tests to be filled when the pipeline is extended. + * All Dart pipeline features are covered: Property nodes, HAS_PROPERTY edges, + * CALLS chain resolution, IMPORTS, call attribution, and ACCESSES field reads. */ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; @@ -78,15 +78,7 @@ describe.skipIf(!dartAvailable)('Dart field-type resolution', () => { expect(appImports.length).toBe(1); }); - // Dart field-chain ACCESSES edges require the call-processor's chain-resolution - // tier (Step 1c) to fire. This needs the type-env's scoped parameter binding - // (processUser's `user: User`) to propagate to processCallsFromExtracted so - // walkMixedChain can resolve User → address → Address and emit ACCESSES. - // The chain extraction (extractMixedChain) and member detection - // (MEMBER_ACCESS_NODE_TYPES) are wired, but the base receiver type lookup - // from the type-env currently returns undefined for Dart function parameters - // in the call-processor context. Tracked for follow-up. - it.skip('emits ACCESSES edges for field reads in chains', () => { + it('emits ACCESSES edges for field reads in chains', () => { const accesses = getRelationships(result, 'ACCESSES'); const addressReads = accesses.filter( (e) => e.target === 'address' && e.rel.reason === 'read', diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 6a464e9a6a..3e73de9c81 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; import { - FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, edgeSet, + FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; @@ -982,6 +982,19 @@ describe('Field type resolution (Go)', () => { ); expect(addressSave).toBeDefined(); }); + + it('Property nodes contain expected field names', () => { + const properties = getNodesByLabelFull(result, 'Property'); + + const city = properties.find(p => p.name === 'City'); + expect(city).toBeDefined(); + + const name = properties.find(p => p.name === 'Name'); + expect(name).toBeDefined(); + + const addr = properties.find(p => p.name === 'Address'); + expect(addr).toBeDefined(); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index a3d0a81e5b..b82829ca2b 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -1119,6 +1119,22 @@ describe('Field type resolution (Java)', () => { expect(addressReads[0].source).toBe('processUser'); expect(addressReads[0].targetLabel).toBe('Property'); }); + + it('populates field metadata (visibility, isStatic, declaredType) on Property nodes', () => { + const properties = getNodesByLabelFull(result, 'Property'); + + const city = properties.find(p => p.name === 'city'); + expect(city).toBeDefined(); + expect(city!.properties.visibility).toBe('public'); + expect(city!.properties.isStatic).toBe(false); + expect(city!.properties.isReadonly).toBe(false); + expect(city!.properties.declaredType).toBe('String'); + + const addr = properties.find(p => p.name === 'address'); + expect(addr).toBeDefined(); + expect(addr!.properties.visibility).toBe('public'); + expect(addr!.properties.declaredType).toBe('Address'); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/javascript.test.ts b/gitnexus/test/integration/resolvers/javascript.test.ts index 15f00d8c6f..630d59cc43 100644 --- a/gitnexus/test/integration/resolvers/javascript.test.ts +++ b/gitnexus/test/integration/resolvers/javascript.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; import { - FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, edgeSet, + FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; @@ -268,6 +268,30 @@ describe('Field type resolution (JavaScript)', () => { expect(edgeSet(propEdges)).toContain('Address → city'); expect(edgeSet(propEdges)).toContain('Config → DEFAULT'); }); + + it('populates field metadata (visibility, isStatic, isReadonly) on Property nodes', () => { + const properties = getNodesByLabelFull(result, 'Property'); + + const city = properties.find(p => p.name === 'city'); + expect(city).toBeDefined(); + expect(city!.properties.visibility).toBe('public'); + expect(city!.properties.isStatic).toBe(false); + expect(city!.properties.isReadonly).toBe(false); + + const addr = properties.find(p => p.name === 'address'); + expect(addr).toBeDefined(); + expect(addr!.properties.visibility).toBe('public'); + expect(addr!.properties.isStatic).toBe(false); + expect(addr!.properties.isReadonly).toBe(false); + }); + + it('marks Config.DEFAULT as static', () => { + const properties = getNodesByLabelFull(result, 'Property'); + const def = properties.find(p => p.name === 'DEFAULT'); + expect(def).toBeDefined(); + expect(def!.properties.isStatic).toBe(true); + expect(def!.properties.visibility).toBe('public'); + }); }); // ACCESSES write edges from assignment expressions diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index 9c8486fbcb..0283ae6643 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -1258,6 +1258,19 @@ describe('Field type resolution (Kotlin)', () => { ); expect(addressSave).toBeDefined(); }); + + it('Property nodes contain expected field names', () => { + const properties = getNodesByLabelFull(result, 'Property'); + + const city = properties.find(p => p.name === 'city'); + expect(city).toBeDefined(); + + const name = properties.find(p => p.name === 'name'); + expect(name).toBeDefined(); + + const addr = properties.find(p => p.name === 'address'); + expect(addr).toBeDefined(); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index c739936cb6..c6827bbb9c 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; import { - FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, edgeSet, + FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; @@ -1200,6 +1200,21 @@ describe('Field type resolution (PHP)', () => { ); expect(addressSave).toBeDefined(); }); + + it('populates field metadata (visibility, declaredType) on Property nodes', () => { + const properties = getNodesByLabelFull(result, 'Property'); + + const city = properties.find(p => p.name === 'city'); + expect(city).toBeDefined(); + expect(city!.properties.visibility).toBe('public'); + expect(city!.properties.isStatic).toBe(false); + expect(city!.properties.declaredType).toBe('string'); + + const addr = properties.find(p => p.name === 'address'); + expect(addr).toBeDefined(); + expect(addr!.properties.visibility).toBe('public'); + expect(addr!.properties.declaredType).toBe('Address'); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 083ff7b8d4..8fa10def63 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; import { - FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, edgeSet, + FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; @@ -1444,6 +1444,23 @@ describe('Field type resolution (Python)', () => { ); expect(addressSave).toBeDefined(); }); + + it('populates field metadata (visibility, isStatic, isReadonly) on Property nodes', () => { + const properties = getNodesByLabelFull(result, 'Property'); + + const city = properties.find(p => p.name === 'city'); + expect(city).toBeDefined(); + expect(city!.properties.visibility).toBe('public'); + expect(city!.properties.isStatic).toBe(false); + expect(city!.properties.isReadonly).toBe(false); + expect(city!.properties.declaredType).toBe('str'); + + const addr = properties.find(p => p.name === 'address'); + expect(addr).toBeDefined(); + expect(addr!.properties.visibility).toBe('public'); + expect(addr!.properties.isStatic).toBe(false); + expect(addr!.properties.declaredType).toBe('Address'); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index c52563de12..d49dd42798 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; import { - FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, edgeSet, + FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; @@ -888,6 +888,19 @@ describe('Field type resolution (Ruby)', () => { ); expect(addressSave).toBeDefined(); }); + + it('Property nodes contain expected field names', () => { + const properties = getNodesByLabelFull(result, 'Property'); + + const city = properties.find(p => p.name === 'city'); + expect(city).toBeDefined(); + + const name = properties.find(p => p.name === 'name'); + expect(name).toBeDefined(); + + const addr = properties.find(p => p.name === 'address'); + expect(addr).toBeDefined(); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index ee5d8d5c04..906005968d 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; import { - FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, edgeSet, + FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; @@ -1384,6 +1384,23 @@ describe('Field type resolution (Rust)', () => { expect(saveCalls.length).toBe(1); expect(saveCalls[0].targetFilePath).toContain('models'); }); + + it('populates field metadata (visibility, isReadonly, declaredType) on Property nodes', () => { + const properties = getNodesByLabelFull(result, 'Property'); + + const city = properties.find(p => p.name === 'city'); + expect(city).toBeDefined(); + expect(city!.properties.visibility).toBe('public'); + expect(city!.properties.isStatic).toBe(false); + expect(city!.properties.isReadonly).toBe(true); + expect(city!.properties.declaredType).toBe('String'); + + const addr = properties.find(p => p.name === 'address'); + expect(addr).toBeDefined(); + expect(addr!.properties.visibility).toBe('public'); + expect(addr!.properties.isReadonly).toBe(true); + expect(addr!.properties.declaredType).toBe('Address'); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index e773ce56e2..69222c9db8 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; import { - FIXTURES, getRelationships, getNodesByLabel, + FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; @@ -500,3 +500,106 @@ describe.skipIf(!swiftAvailable)('Swift for-in loop element type inference', () expect(imports.length).toBeGreaterThan(0); }); }); + +// ── Phase 8: Field-type resolution ────────────────────────────────────── + +describe.skipIf(!swiftAvailable)('Swift field-type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-field-types'), + () => {}, + ); + }, 60000); + + it('detects classes and their properties', () => { + expect(getNodesByLabel(result, 'Class')).toEqual( + expect.arrayContaining(['Address', 'User']), + ); + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('address'); + expect(properties).toContain('city'); + expect(properties).toContain('name'); + }); + + it('emits HAS_PROPERTY edges from class to field', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(edgeSet(propEdges)).toEqual( + expect.arrayContaining([ + 'User → address', + 'Address → city', + ]), + ); + }); + + it('resolves field-chain call user.address.save() → Address#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter( + (c) => c.target === 'save' && c.source === 'processUser', + ); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0]!.targetFilePath).toContain('Models.swift'); + }); + + it('emits ACCESSES edges for field reads in chains', () => { + const accesses = getRelationships(result, 'ACCESSES'); + const addressReads = accesses.filter( + (e) => e.target === 'address' && e.rel.reason === 'read', + ); + expect(addressReads.length).toBeGreaterThanOrEqual(1); + expect(addressReads[0]!.source).toBe('processUser'); + expect(addressReads[0]!.targetLabel).toBe('Property'); + }); + + it('populates field metadata (visibility, declaredType) on Property nodes', () => { + const properties = getNodesByLabelFull(result, 'Property'); + + const city = properties.find(p => p.name === 'city'); + expect(city).toBeDefined(); + // Swift default visibility is 'internal', not 'public' + expect(city!.properties.visibility).toBe('internal'); + expect(city!.properties.isStatic).toBe(false); + expect(city!.properties.declaredType).toBe('String'); + + const addr = properties.find(p => p.name === 'address'); + expect(addr).toBeDefined(); + expect(addr!.properties.visibility).toBe('internal'); + expect(addr!.properties.declaredType).toBe('Address'); + }); +}); + +// ── Phase 9: Call-result binding ──────────────────────────────────────── + +describe.skipIf(!swiftAvailable)('Swift call-result binding', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-call-result-binding'), + () => {}, + ); + }, 60000); + + it('resolves call-result-bound method call user.save() → User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter( + (c) => c.target === 'save' && c.source === 'processUser', + ); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0]!.targetFilePath).toContain('Models.swift'); + }); + + it('getUser() is present as a defined function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('getUser'); + }); + + it('emits processUser -> getUser CALLS edge for let-assigned free function call', () => { + const calls = getRelationships(result, 'CALLS'); + const getUserCall = calls.find(c => + c.target === 'getUser' && c.source === 'processUser', + ); + expect(getUserCall).toBeDefined(); + expect(getUserCall!.targetFilePath).toContain('Models.swift'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index a18d8ba214..0060cbc90d 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -1763,6 +1763,33 @@ describe('Field type resolution (TypeScript)', () => { expect(edge.rel.reason).toBe('read'); } }); + + it('populates field metadata (visibility, isStatic, isReadonly, declaredType) on Property nodes', () => { + const properties = getNodesByLabelFull(result, 'Property'); + + const city = properties.find(p => p.name === 'city'); + expect(city).toBeDefined(); + expect(city!.properties.visibility).toBe('public'); + expect(city!.properties.isStatic).toBe(false); + expect(city!.properties.isReadonly).toBe(false); + expect(city!.properties.declaredType).toBe('string'); + + const addr = properties.find(p => p.name === 'address'); + expect(addr).toBeDefined(); + expect(addr!.properties.visibility).toBe('public'); + expect(addr!.properties.isStatic).toBe(false); + expect(addr!.properties.isReadonly).toBe(false); + expect(addr!.properties.declaredType).toBe('Address'); + }); + + it('marks Config.DEFAULT as static', () => { + const properties = getNodesByLabelFull(result, 'Property'); + const def = properties.find(p => p.name === 'DEFAULT'); + expect(def).toBeDefined(); + expect(def!.properties.isStatic).toBe(true); + expect(def!.properties.declaredType).toBe('Config'); + expect(def!.properties.visibility).toBe('public'); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/field-extraction.test.ts b/gitnexus/test/unit/field-extraction.test.ts new file mode 100644 index 0000000000..64dab96219 --- /dev/null +++ b/gitnexus/test/unit/field-extraction.test.ts @@ -0,0 +1,929 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { TypeScriptFieldExtractor } from '../../src/core/ingestion/field-extractors/typescript.js'; +import { createFieldExtractor } from '../../src/core/ingestion/field-extractors/generic.js'; +import { typescriptConfig } from '../../src/core/ingestion/field-extractors/configs/typescript-javascript.js'; +import { pythonConfig } from '../../src/core/ingestion/field-extractors/configs/python.js'; +import { goConfig } from '../../src/core/ingestion/field-extractors/configs/go.js'; +import { cppConfig } from '../../src/core/ingestion/field-extractors/configs/c-cpp.js'; +import { rubyConfig } from '../../src/core/ingestion/field-extractors/configs/ruby.js'; +import type { FieldExtractorContext, ExtractedFields } from '../../src/core/ingestion/field-types.js'; +import type { TypeEnvironment } from '../../src/core/ingestion/type-env.js'; +import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import Python from 'tree-sitter-python'; +import Go from 'tree-sitter-go'; +import Cpp from 'tree-sitter-cpp'; +import Ruby from 'tree-sitter-ruby'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; + +const parser = new Parser(); + +const parse = (code: string) => { + parser.setLanguage(TypeScript.typescript); + return parser.parse(code); +}; + +// Mock context for tests +const createMockContext = (): FieldExtractorContext => ({ + typeEnv: { + lookup: () => undefined, + constructorBindings: [], + fileScope: () => new Map(), + allScopes: () => new Map(), + constructorTypeMap: new Map(), + } as TypeEnvironment, + symbolTable: createSymbolTable(), + filePath: 'test.ts', + language: SupportedLanguages.TypeScript, +}); + +describe('TypeScriptFieldExtractor', () => { + let extractor: TypeScriptFieldExtractor; + let mockContext: FieldExtractorContext; + + beforeEach(() => { + extractor = new TypeScriptFieldExtractor(); + mockContext = createMockContext(); + }); + + describe('isTypeDeclaration', () => { + it('recognizes class_declaration', () => { + const tree = parse('class User {}'); + const classNode = tree.rootNode.child(0); + expect(classNode).toBeDefined(); + expect(extractor.isTypeDeclaration(classNode!)).toBe(true); + }); + + it('recognizes interface_declaration', () => { + const tree = parse('interface IUser {}'); + const interfaceNode = tree.rootNode.child(0); + expect(interfaceNode).toBeDefined(); + expect(extractor.isTypeDeclaration(interfaceNode!)).toBe(true); + }); + + it('recognizes abstract_class_declaration', () => { + const tree = parse('abstract class BaseService {}'); + const abstractNode = tree.rootNode.child(0); + expect(abstractNode).toBeDefined(); + expect(extractor.isTypeDeclaration(abstractNode!)).toBe(true); + }); + + it('rejects function_declaration', () => { + const tree = parse('function getUser() {}'); + const functionNode = tree.rootNode.child(0); + expect(functionNode).toBeDefined(); + expect(extractor.isTypeDeclaration(functionNode!)).toBe(false); + }); + + it('rejects variable declaration', () => { + const tree = parse('const user = {};'); + const variableNode = tree.rootNode.child(0); + expect(variableNode).toBeDefined(); + expect(extractor.isTypeDeclaration(variableNode!)).toBe(false); + }); + }); + + describe('extract', () => { + it('extracts single field with type', () => { + const tree = parse(` + class User { + name: string; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('User'); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('name'); + expect(result!.fields[0].type).toBe('string'); + expect(result!.fields[0].visibility).toBe('public'); + }); + + it('extracts private field', () => { + const tree = parse(` + class User { + private password: string; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('password'); + expect(result!.fields[0].type).toBe('string'); + expect(result!.fields[0].visibility).toBe('private'); + }); + + it('extracts static readonly field', () => { + const tree = parse(` + class Config { + static readonly VERSION: string = '1.0'; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('VERSION'); + expect(result!.fields[0].type).toBe('string'); + expect(result!.fields[0].isStatic).toBe(true); + expect(result!.fields[0].isReadonly).toBe(true); + expect(result!.fields[0].visibility).toBe('public'); + }); + + it('extracts optional field (?:)', () => { + const tree = parse(` + interface User { + email?: string; + } + `); + const interfaceNode = tree.rootNode.child(0); + const result = extractor.extract(interfaceNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('email'); + // Note: optional fields may have type modified to include undefined + expect(result!.fields[0].type).toContain('string'); + }); + + it('extracts multiple fields with different visibilities', () => { + const tree = parse(` + class User { + public id: number; + private secretKey: string; + protected createdAt: Date; + name: string; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(4); + + const fields = result!.fields; + + const idField = fields.find(f => f.name === 'id'); + expect(idField).toBeDefined(); + expect(idField!.visibility).toBe('public'); + expect(idField!.type).toBe('number'); + + const secretKeyField = fields.find(f => f.name === 'secretKey'); + expect(secretKeyField).toBeDefined(); + expect(secretKeyField!.visibility).toBe('private'); + + const createdAtField = fields.find(f => f.name === 'createdAt'); + expect(createdAtField).toBeDefined(); + expect(createdAtField!.visibility).toBe('protected'); + + const nameField = fields.find(f => f.name === 'name'); + expect(nameField).toBeDefined(); + expect(nameField!.visibility).toBe('public'); // default + }); + + it('handles field without type annotation', () => { + const tree = parse(` + class User { + name; + age; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(2); + + const nameField = result!.fields.find(f => f.name === 'name'); + expect(nameField).toBeDefined(); + expect(nameField!.type).toBeNull(); + + const ageField = result!.fields.find(f => f.name === 'age'); + expect(ageField).toBeDefined(); + expect(ageField!.type).toBeNull(); + }); + + it('extracts complex generic types (Map, Array)', () => { + const tree = parse(` + class Repository { + users: Map; + ids: Array; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(2); + + const usersField = result!.fields.find(f => f.name === 'users'); + expect(usersField).toBeDefined(); + expect(usersField!.type).toBe('Map'); + + const idsField = result!.fields.find(f => f.name === 'ids'); + expect(idsField).toBeDefined(); + expect(idsField!.type).toBe('Array'); + }); + + it('extracts nested types', () => { + const tree = parse(` + class Container { + data: OuterType>; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('data'); + expect(result!.fields[0].type).toBe('OuterType>'); + }); + + it('extracts fields from interface', () => { + const tree = parse(` + interface UserDTO { + id: number; + name: string; + email?: string; + } + `); + const interfaceNode = tree.rootNode.child(0); + const result = extractor.extract(interfaceNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('UserDTO'); + expect(result!.fields).toHaveLength(3); + + const idField = result!.fields.find(f => f.name === 'id'); + expect(idField).toBeDefined(); + expect(idField!.type).toBe('number'); + + const nameField = result!.fields.find(f => f.name === 'name'); + expect(nameField).toBeDefined(); + expect(nameField!.type).toBe('string'); + + const emailField = result!.fields.find(f => f.name === 'email'); + expect(emailField).toBeDefined(); + }); + + it('extracts fields from abstract class', () => { + const tree = parse(` + abstract class BaseEntity { + protected id: number; + createdAt: Date; + } + `); + const abstractNode = tree.rootNode.child(0); + const result = extractor.extract(abstractNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('BaseEntity'); + expect(result!.fields).toHaveLength(2); + + const idField = result!.fields.find(f => f.name === 'id'); + expect(idField).toBeDefined(); + expect(idField!.visibility).toBe('protected'); + + const createdAtField = result!.fields.find(f => f.name === 'createdAt'); + expect(createdAtField).toBeDefined(); + expect(createdAtField!.visibility).toBe('public'); + }); + + it('extracts array types', () => { + const tree = parse(` + class UserService { + users: User[]; + ids: number[]; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(2); + + const usersField = result!.fields.find(f => f.name === 'users'); + expect(usersField).toBeDefined(); + expect(usersField!.type).toBe('User[]'); + + const idsField = result!.fields.find(f => f.name === 'ids'); + expect(idsField).toBeDefined(); + expect(idsField!.type).toBe('number[]'); + }); + + it('extracts union types', () => { + const tree = parse(` + class Field { + value: string | number | null; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('value'); + expect(result!.fields[0].type).toBe('string | number | null'); + }); + + it('returns null for non-type declaration nodes', () => { + const tree = parse('function getUser() {}'); + const functionNode = tree.rootNode.child(0); + const result = extractor.extract(functionNode!, mockContext); + expect(result).toBeNull(); + }); + + it('extracts fields from type alias with object type', () => { + const tree = parse(` + type UserDTO = { + id: number; + name: string; + } + `); + const typeAliasNode = tree.rootNode.child(0); + const result = extractor.extract(typeAliasNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('UserDTO'); + expect(result!.fields).toHaveLength(2); + + const idField = result!.fields.find(f => f.name === 'id'); + expect(idField).toBeDefined(); + expect(idField!.type).toBe('number'); + + const nameField = result!.fields.find(f => f.name === 'name'); + expect(nameField).toBeDefined(); + expect(nameField!.type).toBe('string'); + }); + + it('includes source file path in field info', () => { + const tree = parse(` + class User { + name: string; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].sourceFile).toBe('test.ts'); + }); + + it('includes line number in field info', () => { + const tree = parse(` + class User { + name: string; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].line).toBeGreaterThan(0); + }); + + it('detects nested interface declarations in methods', () => { + const tree = parse(` + class Container { + data: string; + + process() { + interface LocalInterface { + value: number; + } + } + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('Container'); + // Note: Nested types within method bodies are detected + expect(result!.nestedTypes).toContain('LocalInterface'); + // Should only extract fields from the outer class + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('data'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Generic factory tests — TypeScript config +// --------------------------------------------------------------------------- + +describe('GenericFieldExtractor — TypeScript config', () => { + const parser = new Parser(); + const extractor = createFieldExtractor(typescriptConfig); + const mockContext = createMockContext(); + mockContext.language = SupportedLanguages.TypeScript; + + it('extracts public and private fields from a class', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse(` + class User { + public name: string; + private age: number; + } + `); + const classNode = tree.rootNode.child(0); + expect(classNode).toBeDefined(); + expect(extractor.isTypeDeclaration(classNode!)).toBe(true); + + const result = extractor.extract(classNode!, mockContext); + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('User'); + expect(result!.fields).toHaveLength(2); + + const nameField = result!.fields.find(f => f.name === 'name'); + expect(nameField).toBeDefined(); + expect(nameField!.visibility).toBe('public'); + expect(nameField!.type).toBe('string'); + + const ageField = result!.fields.find(f => f.name === 'age'); + expect(ageField).toBeDefined(); + expect(ageField!.visibility).toBe('private'); + expect(ageField!.type).toBe('number'); + }); + + it('uses body-discovery fallback when body type does not match config', () => { + parser.setLanguage(TypeScript.typescript); + // interface_declaration has an interface_body — which IS in the config bodyNodeTypes + // but type_alias_declaration has an object_type body which is also in bodyNodeTypes + const tree = parser.parse(` + interface Settings { + theme: string; + debug: boolean; + } + `); + const ifaceNode = tree.rootNode.child(0); + expect(ifaceNode).toBeDefined(); + expect(extractor.isTypeDeclaration(ifaceNode!)).toBe(true); + + const result = extractor.extract(ifaceNode!, mockContext); + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('Settings'); + expect(result!.fields).toHaveLength(2); + expect(result!.fields.map(f => f.name)).toContain('theme'); + expect(result!.fields.map(f => f.name)).toContain('debug'); + }); + + it('returns null for non-type-declaration nodes', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('function hello() {}'); + const fnNode = tree.rootNode.child(0); + expect(extractor.isTypeDeclaration(fnNode!)).toBe(false); + expect(extractor.extract(fnNode!, mockContext)).toBeNull(); + }); + + it('extracts static and readonly modifiers', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse(` + class Config { + static readonly MAX: number; + private count: number; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(2); + + const maxField = result!.fields.find(f => f.name === 'MAX'); + expect(maxField).toBeDefined(); + expect(maxField!.isStatic).toBe(true); + expect(maxField!.isReadonly).toBe(true); + + const countField = result!.fields.find(f => f.name === 'count'); + expect(countField).toBeDefined(); + expect(countField!.isStatic).toBe(false); + expect(countField!.isReadonly).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Python config +// --------------------------------------------------------------------------- + +describe('GenericFieldExtractor — Python', () => { + const parser = new Parser(); + const extractor = createFieldExtractor(pythonConfig); + const mockContext = createMockContext(); + mockContext.language = SupportedLanguages.Python; + mockContext.filePath = 'test.py'; + + it('extracts annotated class fields', () => { + parser.setLanguage(Python); + const tree = parser.parse(`class User: + name: str + email: str +`); + const classNode = tree.rootNode.child(0); + expect(classNode).toBeDefined(); + expect(extractor.isTypeDeclaration(classNode!)).toBe(true); + + const result = extractor.extract(classNode!, mockContext); + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('User'); + expect(result!.fields).toHaveLength(2); + + const nameField = result!.fields.find(f => f.name === 'name'); + expect(nameField).toBeDefined(); + expect(nameField!.type).toBe('str'); + expect(nameField!.visibility).toBe('public'); + + const emailField = result!.fields.find(f => f.name === 'email'); + expect(emailField).toBeDefined(); + expect(emailField!.type).toBe('str'); + }); + + it('detects underscore-based visibility: _protected and __private', () => { + parser.setLanguage(Python); + const tree = parser.parse(`class Settings: + name: str + _internal: int + __secret: str +`); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(3); + + const nameField = result!.fields.find(f => f.name === 'name'); + expect(nameField).toBeDefined(); + expect(nameField!.visibility).toBe('public'); + + const internalField = result!.fields.find(f => f.name === '_internal'); + expect(internalField).toBeDefined(); + expect(internalField!.visibility).toBe('protected'); + + const secretField = result!.fields.find(f => f.name === '__secret'); + expect(secretField).toBeDefined(); + expect(secretField!.visibility).toBe('private'); + }); + + it('does not mark dunder attributes as private', () => { + parser.setLanguage(Python); + // __slots__ starts with __ but also ends with __ so the private check + // (startsWith('__') && !endsWith('__')) is skipped. + // However it still starts with _ so the config classifies it as protected. + const tree = parser.parse(`class Meta: + __slots__: list +`); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + // __slots__ ends with __ → not private, but still starts with _ → protected + expect(result!.fields[0].visibility).toBe('protected'); + }); + + it('reports isStatic and isReadonly as false', () => { + parser.setLanguage(Python); + const tree = parser.parse(`class A: + x: int +`); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields[0].isStatic).toBe(false); + expect(result!.fields[0].isReadonly).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Go config +// --------------------------------------------------------------------------- + +describe('GenericFieldExtractor — Go', () => { + const parser = new Parser(); + const extractor = createFieldExtractor(goConfig); + const mockContext = createMockContext(); + mockContext.language = SupportedLanguages.Go; + mockContext.filePath = 'test.go'; + + // Helper: Go AST nests name under type_spec, not type_declaration. + // The generic factory's extract() requires a node with a 'name' field, + // so we walk to type_spec which holds the name. + function findTypeSpec(src: string) { + parser.setLanguage(Go); + const tree = parser.parse(src); + const typeDecl = tree.rootNode.child(0)!; + return { typeDecl, typeSpec: typeDecl.namedChild(0)! }; + } + + it('recognizes type_declaration via isTypeDeclaration', () => { + const { typeDecl } = findTypeSpec(`type User struct {\n\tName string\n}`); + expect(typeDecl.type).toBe('type_declaration'); + expect(extractor.isTypeDeclaration(typeDecl)).toBe(true); + }); + + it('rejects non-type-declaration nodes', () => { + parser.setLanguage(Go); + const tree = parser.parse(`func main() {}`); + const fnNode = tree.rootNode.child(0); + expect(extractor.isTypeDeclaration(fnNode!)).toBe(false); + expect(extractor.extract(fnNode!, mockContext)).toBeNull(); + }); + + it('detects uppercase-based visibility via extractVisibility on field nodes', () => { + const { typeDecl } = findTypeSpec(`type Config struct {\n\tHost string\n\tport int\n\tTimeout int\n}`); + // Navigate to field_declaration nodes inside the struct + // type_declaration > type_spec > struct_type > field_declaration_list > field_declaration + const typeSpec = typeDecl.namedChild(0)!; + const structType = typeSpec.namedChild(1)!; + const fieldList = structType.namedChild(0)!; + + expect(fieldList.type).toBe('field_declaration_list'); + + const hostNode = fieldList.namedChild(0)!; + const portNode = fieldList.namedChild(1)!; + const timeoutNode = fieldList.namedChild(2)!; + + expect(goConfig.extractName(hostNode)).toBe('Host'); + expect(goConfig.extractVisibility(hostNode)).toBe('public'); + + expect(goConfig.extractName(portNode)).toBe('port'); + expect(goConfig.extractVisibility(portNode)).toBe('package'); + + expect(goConfig.extractName(timeoutNode)).toBe('Timeout'); + expect(goConfig.extractVisibility(timeoutNode)).toBe('public'); + }); + + it('extracts field types', () => { + const { typeDecl } = findTypeSpec(`type Point struct {\n\tX float64\n\tY float64\n}`); + const typeSpec = typeDecl.namedChild(0)!; + const structType = typeSpec.namedChild(1)!; + const fieldList = structType.namedChild(0)!; + + const xNode = fieldList.namedChild(0)!; + expect(goConfig.extractName(xNode)).toBe('X'); + expect(goConfig.extractType(xNode)).toBe('float64'); + }); + + it('reports isStatic and isReadonly as false for all fields', () => { + const { typeDecl } = findTypeSpec(`type S struct {\n\tX int\n}`); + const typeSpec = typeDecl.namedChild(0)!; + const structType = typeSpec.namedChild(1)!; + const fieldList = structType.namedChild(0)!; + const fieldNode = fieldList.namedChild(0)!; + + expect(goConfig.isStatic(fieldNode)).toBe(false); + expect(goConfig.isReadonly(fieldNode)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// C++ config +// --------------------------------------------------------------------------- + +describe('GenericFieldExtractor — C++', () => { + const parser = new Parser(); + const extractor = createFieldExtractor(cppConfig); + const mockContext = createMockContext(); + mockContext.language = SupportedLanguages.CPlusPlus; + mockContext.filePath = 'test.cpp'; + + it('extracts fields from a class with access specifiers', () => { + parser.setLanguage(Cpp); + const tree = parser.parse(`class User { +public: + int id; + std::string name; +private: + std::string password; +};`); + const classNode = tree.rootNode.child(0); + expect(classNode).toBeDefined(); + expect(extractor.isTypeDeclaration(classNode!)).toBe(true); + + const result = extractor.extract(classNode!, mockContext); + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('User'); + expect(result!.fields).toHaveLength(3); + + const idField = result!.fields.find(f => f.name === 'id'); + expect(idField).toBeDefined(); + expect(idField!.visibility).toBe('public'); + expect(idField!.type).toBe('int'); + + const nameField = result!.fields.find(f => f.name === 'name'); + expect(nameField).toBeDefined(); + expect(nameField!.visibility).toBe('public'); + + const pwField = result!.fields.find(f => f.name === 'password'); + expect(pwField).toBeDefined(); + expect(pwField!.visibility).toBe('private'); + }); + + it('uses backward-sibling walk to find access specifier', () => { + parser.setLanguage(Cpp); + // protected section followed by more fields + const tree = parser.parse(`class Base { +protected: + int x; + int y; +public: + int z; +};`); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(3); + + const xField = result!.fields.find(f => f.name === 'x'); + expect(xField).toBeDefined(); + expect(xField!.visibility).toBe('protected'); + + const yField = result!.fields.find(f => f.name === 'y'); + expect(yField).toBeDefined(); + expect(yField!.visibility).toBe('protected'); + + const zField = result!.fields.find(f => f.name === 'z'); + expect(zField).toBeDefined(); + expect(zField!.visibility).toBe('public'); + }); + + it('defaults to private for class without access specifiers', () => { + parser.setLanguage(Cpp); + const tree = parser.parse(`class Secret { + int value; +};`); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('value'); + expect(result!.fields[0].visibility).toBe('private'); + }); + + it('defaults to public for struct without access specifiers', () => { + parser.setLanguage(Cpp); + const tree = parser.parse(`struct Point { + double x; + double y; +};`); + const structNode = tree.rootNode.child(0); + expect(extractor.isTypeDeclaration(structNode!)).toBe(true); + + const result = extractor.extract(structNode!, mockContext); + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(2); + expect(result!.fields[0].visibility).toBe('public'); + expect(result!.fields[1].visibility).toBe('public'); + }); + + it('detects static and const fields', () => { + parser.setLanguage(Cpp); + const tree = parser.parse(`class Config { +public: + static int count; + const int MAX_SIZE; +};`); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(2); + + const countField = result!.fields.find(f => f.name === 'count'); + expect(countField).toBeDefined(); + expect(countField!.isStatic).toBe(true); + + const maxField = result!.fields.find(f => f.name === 'MAX_SIZE'); + expect(maxField).toBeDefined(); + expect(maxField!.isReadonly).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Ruby config +// --------------------------------------------------------------------------- + +describe('GenericFieldExtractor — Ruby', () => { + const parser = new Parser(); + const extractor = createFieldExtractor(rubyConfig); + const mockContext = createMockContext(); + mockContext.language = SupportedLanguages.Ruby; + mockContext.filePath = 'test.rb'; + + it('extracts multiple fields from attr_accessor', () => { + parser.setLanguage(Ruby); + const tree = parser.parse(`class User + attr_accessor :name, :email, :age +end`); + const classNode = tree.rootNode.child(0); + expect(classNode).toBeDefined(); + expect(extractor.isTypeDeclaration(classNode!)).toBe(true); + + const result = extractor.extract(classNode!, mockContext); + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('User'); + expect(result!.fields).toHaveLength(3); + + const names = result!.fields.map(f => f.name); + expect(names).toContain('name'); + expect(names).toContain('email'); + expect(names).toContain('age'); + + // All attr_accessor fields are public + for (const field of result!.fields) { + expect(field.visibility).toBe('public'); + expect(field.type).toBeNull(); + } + }); + + it('extracts fields from attr_reader as readonly', () => { + parser.setLanguage(Ruby); + const tree = parser.parse(`class Config + attr_reader :host, :port +end`); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(2); + expect(result!.fields[0].isReadonly).toBe(true); + expect(result!.fields[1].isReadonly).toBe(true); + }); + + it('extracts fields from attr_writer as non-readonly', () => { + parser.setLanguage(Ruby); + const tree = parser.parse(`class Settings + attr_writer :theme +end`); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('theme'); + expect(result!.fields[0].isReadonly).toBe(false); + }); + + it('handles multiple attr_* calls in the same class', () => { + parser.setLanguage(Ruby); + const tree = parser.parse(`class Person + attr_accessor :name + attr_reader :id + attr_writer :password +end`); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(3); + + const nameField = result!.fields.find(f => f.name === 'name'); + expect(nameField).toBeDefined(); + expect(nameField!.isReadonly).toBe(false); + + const idField = result!.fields.find(f => f.name === 'id'); + expect(idField).toBeDefined(); + expect(idField!.isReadonly).toBe(true); + + const pwField = result!.fields.find(f => f.name === 'password'); + expect(pwField).toBeDefined(); + expect(pwField!.isReadonly).toBe(false); + }); + + it('reports type as null (Ruby is dynamically typed)', () => { + parser.setLanguage(Ruby); + const tree = parser.parse(`class Item + attr_accessor :value +end`); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].type).toBeNull(); + }); + + it('reports isStatic as false', () => { + parser.setLanguage(Ruby); + const tree = parser.parse(`class Demo + attr_accessor :data +end`); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields[0].isStatic).toBe(false); + }); +});