diff --git a/gitnexus/src/core/graph/types.ts b/gitnexus/src/core/graph/types.ts index 594a94c7e..289e1fd40 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/field-extractor.ts b/gitnexus/src/core/ingestion/field-extractor.ts index 079228369..e19000826 100644 --- a/gitnexus/src/core/ingestion/field-extractor.ts +++ b/gitnexus/src/core/ingestion/field-extractor.ts @@ -25,25 +25,6 @@ export interface FieldExtractor { isTypeDeclaration(node: SyntaxNode): boolean; } -/** - * Registry of field extractors by language - */ -export class FieldExtractorRegistry { - private extractors = new Map(); - - register(extractor: FieldExtractor): void { - this.extractors.set(extractor.language, extractor); - } - - get(language: SupportedLanguages): FieldExtractor | undefined { - return this.extractors.get(language); - } - - has(language: SupportedLanguages): boolean { - return this.extractors.has(language); - } -} - /** * Base class for field extractors with common utilities */ 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 000000000..34d6862f2 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/c-cpp.ts @@ -0,0 +1,120 @@ +// 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'; + +/** + * Detect C++ access specifier (public:/private:/protected:) by walking + * backwards from the field node through siblings. + */ +function cppAccessSpecifier(node: SyntaxNode): string | 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 000000000..9238a51ec --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/csharp.ts @@ -0,0 +1,79 @@ +// 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'; + +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 000000000..96b46e4c5 --- /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 000000000..6f03aab4f --- /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 000000000..27490b4bd --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts @@ -0,0 +1,145 @@ +// 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'; + +// --------------------------------------------------------------------------- +// 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: string, + modifierNodeType?: string, +): string { + // Direct keyword children + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && keywords.has(child.text.trim())) return child.text.trim(); + } + // 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); + if (mod && keywords.has(mod.text.trim())) return mod.text.trim(); + } + } + } + } + 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 000000000..7b05ff7a0 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/jvm.ts @@ -0,0 +1,133 @@ +// 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'; + +// --------------------------------------------------------------------------- +// 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 000000000..49437ec51 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/php.ts @@ -0,0 +1,75 @@ +// 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'; + +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 000000000..4eae9559c --- /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) { + // Class-level variables in Python are effectively static; + // 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 000000000..b28781e92 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/ruby.ts @@ -0,0 +1,65 @@ +// gitnexus/src/core/ingestion/field-extractors/configs/ruby.ts + +import { SupportedLanguages } from '../../../../config/supported-languages.js'; +import type { FieldExtractionConfig } from '../generic.js'; + +/** + * 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) { + // call node: method = attr_accessor / attr_reader / attr_writer + const method = node.childForFieldName('method'); + if (!method) return undefined; + const methodName = method.text; + if (methodName !== 'attr_accessor' && methodName !== 'attr_reader' + && methodName !== 'attr_writer') { + return undefined; + } + // arguments: argument_list > simple_symbol (:name) + const args = node.childForFieldName('arguments'); + if (!args) return undefined; + const firstArg = args.firstNamedChild; + if (!firstArg) return undefined; + // simple_symbol text is :name — strip the colon + const text = firstArg.text; + return text.startsWith(':') ? text.slice(1) : text; + }, + + 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 000000000..4ac1c1f1c --- /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 000000000..05af283ab --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/swift.ts @@ -0,0 +1,69 @@ +// 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'; + +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 000000000..af012c1c1 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/typescript-javascript.ts @@ -0,0 +1,70 @@ +// 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'; + +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(); + 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 000000000..9ae32dce4 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/generic.ts @@ -0,0 +1,153 @@ +// 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 } 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: string; + /** Extract field name from a field declaration node */ + extractName: (node: SyntaxNode) => string | undefined; + /** Extract type annotation from a field declaration node */ + extractType: (node: SyntaxNode) => string | undefined; + /** Extract visibility from a field declaration node */ + extractVisibility: (node: SyntaxNode) => string; + /** 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): string { + 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)) { + 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; + + 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/index.ts b/gitnexus/src/core/ingestion/field-extractors/index.ts deleted file mode 100644 index 0354bdecf..000000000 --- a/gitnexus/src/core/ingestion/field-extractors/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -// gitnexus/src/core/ingestion/field-extractors/index.ts - -/** - * Field Extractors Index - * - * Language-specific field extractors for extracting field/property definitions - * from class/struct/interface declarations across supported languages. - * - * Each extractor: - * 1. Extends BaseFieldExtractor - * 2. Implements isTypeDeclaration() to recognize type declarations - * 3. Implements extract() to pull field definitions with: - * - Name and type - * - Visibility modifiers - * - Static/readonly flags - */ - -import type { FieldExtractor } from '../field-extractor.js'; -import { SupportedLanguages } from '../../../config/supported-languages.js'; -import { typescriptFieldExtractor, TypeScriptFieldExtractor } from './typescript.js'; - -// Re-export for direct usage -export { typescriptFieldExtractor, TypeScriptFieldExtractor }; - -/** - * Registry of all available field extractors. - * Populated lazily on first access. - */ -let extractorRegistry: Map | null = null; - -/** - * Get all available field extractors. - * Returns a map from language to extractor instance. - */ -export function getFieldExtractors(): Map { - if (!extractorRegistry) { - extractorRegistry = new Map(); - - // Register TypeScript extractor - extractorRegistry.set(SupportedLanguages.TypeScript, typescriptFieldExtractor); - - // Future: Add other language extractors here - // extractorRegistry.set(SupportedLanguages.Java, javaFieldExtractor); - // extractorRegistry.set(SupportedLanguages.CSharp, csharpFieldExtractor); - // extractorRegistry.set(SupportedLanguages.Python, pythonFieldExtractor); - // etc. - } - - return extractorRegistry; -} - -/** - * Get a field extractor for a specific language. - * Returns undefined if no extractor is available for that language. - */ -export function getFieldExtractor(language: SupportedLanguages): FieldExtractor | undefined { - return getFieldExtractors().get(language); -} - -/** - * Check if a field extractor is available for a given language. - */ -export function hasFieldExtractor(language: SupportedLanguages): boolean { - return getFieldExtractors().has(language); -} diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index b7449d33f..d86e1dfdd 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'; @@ -98,6 +99,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 6881a9ee4..9d0ea668c 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'; /** Label override shared by C and C++: skip function_definition captures inside class/struct * bodies (they're duplicates of definition.method captures). */ @@ -33,6 +35,7 @@ export const cProvider = defineLanguage({ exportChecker: cCppExportChecker, importResolver: resolveCImport, importSemantics: 'wildcard', + fieldExtractor: createFieldExtractor(cFieldConfig), labelOverride: cppLabelOverride, }); @@ -45,5 +48,6 @@ export const cppProvider = defineLanguage({ importResolver: resolveCppImport, importSemantics: 'wildcard', mroStrategy: 'leftmost-base', + fieldExtractor: createFieldExtractor(cppFieldConfig), labelOverride: cppLabelOverride, }); diff --git a/gitnexus/src/core/ingestion/languages/csharp.ts b/gitnexus/src/core/ingestion/languages/csharp.ts index 707e225eb..d38f0a480 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'; export const csharpProvider = defineLanguage({ id: SupportedLanguages.CSharp, @@ -24,4 +26,5 @@ export const csharpProvider = defineLanguage({ namedBindingExtractor: extractCSharpNamedBindings, interfaceNamePattern: /^I[A-Z]/, mroStrategy: 'implements-split', + fieldExtractor: createFieldExtractor(csharpFieldConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index 8b4dd3ee5..87f2fd10c 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -13,6 +13,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'; export const dartProvider = defineLanguage({ id: SupportedLanguages.Dart, @@ -22,4 +24,5 @@ export const dartProvider = defineLanguage({ exportChecker: dartExportChecker, importResolver: resolveDartImport, importSemantics: 'wildcard', + fieldExtractor: createFieldExtractor(dartFieldConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 19526ea33..fa9050cc1 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 f4c014695..0e647a865 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 a70153adb..514666550 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'; export const kotlinProvider = defineLanguage({ id: SupportedLanguages.Kotlin, @@ -27,6 +29,7 @@ export const kotlinProvider = defineLanguage({ namedBindingExtractor: extractKotlinNamedBindings, importPathPreprocessor: appendKotlinWildcard, mroStrategy: 'implements-split', + fieldExtractor: createFieldExtractor(kotlinConfig), labelOverride: (functionNode, defaultLabel) => { if (defaultLabel !== 'Function') return defaultLabel; if (isKotlinClassMethod(functionNode)) return 'Method'; diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index 584668140..01eaf8513 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'; /** Eloquent model properties whose array values are worth indexing. */ const ELOQUENT_ARRAY_PROPS = new Set(['fillable', 'casts', 'hidden', 'guarded', 'with', 'appends']); @@ -128,6 +130,7 @@ export const phpProvider = defineLanguage({ exportChecker: phpExportChecker, importResolver: resolvePhpImport, namedBindingExtractor: extractPhpNamedBindings, + fieldExtractor: createFieldExtractor(phpFieldConfig), descriptionExtractor: phpDescriptionExtractor, isRouteFile: isPhpRouteFile, }); diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index 49154e7fa..53f14648e 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'; export const pythonProvider = defineLanguage({ id: SupportedLanguages.Python, @@ -28,4 +30,5 @@ export const pythonProvider = defineLanguage({ namedBindingExtractor: extractPythonNamedBindings, importSemantics: 'namespace', mroStrategy: 'c3', + fieldExtractor: createFieldExtractor(pythonFieldConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index 8c086175d..15568ee92 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'; export const rubyProvider = defineLanguage({ id: SupportedLanguages.Ruby, @@ -24,4 +26,5 @@ export const rubyProvider = defineLanguage({ importResolver: resolveRubyImport, callRouter: routeRubyCall, importSemantics: 'wildcard', + fieldExtractor: createFieldExtractor(rubyFieldConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index 71a2448f6..0457faa08 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'; export const rustProvider = defineLanguage({ id: SupportedLanguages.Rust, @@ -27,4 +29,5 @@ export const rustProvider = defineLanguage({ importResolver: resolveRustImport, namedBindingExtractor: extractRustNamedBindings, mroStrategy: 'qualified-syntax', + fieldExtractor: createFieldExtractor(rustFieldConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index 8d8af74e2..4fd31134e 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. @@ -110,5 +112,6 @@ export const swiftProvider = defineLanguage({ importResolver: resolveSwiftImport, importSemantics: 'wildcard', heritageDefaultEdge: 'IMPLEMENTS', + fieldExtractor: createFieldExtractor(swiftFieldConfig), implicitImportWirer: wireSwiftImplicitImports, }); diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 7ec116c7a..8dc3bb025 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'; export const typescriptProvider = defineLanguage({ id: SupportedLanguages.TypeScript, @@ -23,6 +26,7 @@ export const typescriptProvider = defineLanguage({ exportChecker: tsExportChecker, importResolver: resolveTypescriptImport, namedBindingExtractor: extractTsNamedBindings, + fieldExtractor: typescriptFieldExtractor, }); export const javascriptProvider = defineLanguage({ @@ -33,4 +37,5 @@ export const javascriptProvider = defineLanguage({ exportChecker: tsExportChecker, importResolver: resolveJavascriptImport, namedBindingExtractor: extractTsNamedBindings, + fieldExtractor: createFieldExtractor(javascriptConfig), }); diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 6734aa7f8..e8aa35c39 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 } 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: any, + provider: LanguageProvider, + context: FieldExtractorContext, +): Map | undefined { + if (!provider.fieldExtractor) return undefined; + const cacheKey = classNode.startIndex as number; + 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) : 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-extractors/shared.ts b/gitnexus/src/core/ingestion/type-extractors/shared.ts index 729124d80..f9c2ba4f8 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/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 1f0e72436..35ae3b169 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -54,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 @@ -77,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; }; } @@ -100,6 +106,9 @@ interface ParsedSymbol { returnType?: string; declaredType?: string; ownerId?: string; + visibility?: string; + isStatic?: boolean; + isReadonly?: boolean; } export interface ExtractedImport { @@ -288,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: 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 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: any, + provider: LanguageProvider, + context: FieldExtractorContext, +): Map | undefined { + if (!provider.fieldExtractor) return undefined; + + const cacheKey = classNode.startIndex as number; + 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 @@ -1131,7 +1200,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, @@ -1144,6 +1224,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({ @@ -1152,7 +1236,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}`); @@ -1316,6 +1403,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; @@ -1333,9 +1423,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({ @@ -1357,6 +1460,10 @@ const processFileGroup = ( ...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}), ...(parameterTypes !== undefined ? { parameterTypes } : {}), ...(returnType !== undefined ? { returnType } : {}), + ...(declaredType !== undefined ? { declaredType } : {}), + ...(visibility !== undefined ? { visibility } : {}), + ...(isStatic !== undefined ? { isStatic } : {}), + ...(isReadonly !== undefined ? { isReadonly } : {}), }, }); @@ -1376,6 +1483,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/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 4bdf84b56..7fe796770 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 ef048a780..9faa2d421 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/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 6a464e9a6..3e73de9c8 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 89a8d89fd..c11f8096a 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -1118,6 +1118,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 15f00d8c6..630d59cc4 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 9c8486fbc..0283ae664 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 c739936cb..c6827bbb9 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 083ff7b8d..8fa10def6 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 c52563de1..d49dd4279 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 ee5d8d5c0..906005968 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 307f2e62a..69222c9db 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, edgeSet, + FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; @@ -551,6 +551,22 @@ describe.skipIf(!swiftAvailable)('Swift field-type resolution', () => { 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 ──────────────────────────────────────── diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index a18d8ba21..0060cbc90 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'); + }); }); // ---------------------------------------------------------------------------