From a22ac0c5a75039a1ea81e93b303f521da4ec7d09 Mon Sep 17 00:00:00 2001 From: Oleg Dizus Date: Tue, 24 Mar 2026 14:28:22 +0600 Subject: [PATCH] test-phase8-add-field-extraction-unit-tests --- .../ingestion/field-extractors/typescript.ts | 20 +- gitnexus/test/unit/field-extraction.test.ts | 402 ++++++++++++++++++ 2 files changed, 417 insertions(+), 5 deletions(-) create mode 100644 gitnexus/test/unit/field-extraction.test.ts diff --git a/gitnexus/src/core/ingestion/field-extractors/typescript.ts b/gitnexus/src/core/ingestion/field-extractors/typescript.ts index de4601402..b58106fe0 100644 --- a/gitnexus/src/core/ingestion/field-extractors/typescript.ts +++ b/gitnexus/src/core/ingestion/field-extractors/typescript.ts @@ -58,7 +58,18 @@ export class TypeScriptFieldExtractor extends BaseFieldExtractor { * Extract visibility modifier from a field node */ protected extractVisibility(node: SyntaxNode): string { - // Check for modifiers in the field's unnamed children + // Check for accessibility_modifier named child (tree-sitter typescript) + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === 'accessibility_modifier') { + const text = child.text.trim(); + if (TypeScriptFieldExtractor.VISIBILITY_MODIFIERS.has(text)) { + return text; + } + } + } + + // Check for modifiers in the field's unnamed children (fallback) for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (child && !child.isNamed) { @@ -340,10 +351,9 @@ export class TypeScriptFieldExtractor extends BaseFieldExtractor { } // Find nested type declarations - const nestedDeclarations = node.descendantsOfType([ - 'class_declaration', - 'interface_declaration', - ].join(',')); + const nestedClasses = node.descendantsOfType('class_declaration'); + const nestedInterfaces = node.descendantsOfType('interface_declaration'); + const nestedDeclarations = [...nestedClasses, ...nestedInterfaces]; for (const nested of nestedDeclarations) { // Skip the current node itself diff --git a/gitnexus/test/unit/field-extraction.test.ts b/gitnexus/test/unit/field-extraction.test.ts new file mode 100644 index 000000000..f9c409c6e --- /dev/null +++ b/gitnexus/test/unit/field-extraction.test.ts @@ -0,0 +1,402 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { TypeScriptFieldExtractor } from '../../src/core/ingestion/field-extractors/typescript.js'; +import type { FieldExtractorContext, ExtractedFields } from '../../src/core/ingestion/field-types.js'; +import type { TypeEnv } from '../../src/core/ingestion/type-env.js'; +import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; + +const parser = new Parser(); + +const parse = (code: string) => { + parser.setLanguage(TypeScript.typescript); + return parser.parse(code); +}; + +// Mock context for tests +const createMockContext = (): FieldExtractorContext => ({ + typeEnv: new Map() as TypeEnv, + symbolTable: createSymbolTable(), + filePath: 'test.ts', + language: SupportedLanguages.TypeScript, +}); + +describe('TypeScriptFieldExtractor', () => { + let extractor: TypeScriptFieldExtractor; + let mockContext: FieldExtractorContext; + + beforeEach(() => { + extractor = new TypeScriptFieldExtractor(); + mockContext = createMockContext(); + }); + + describe('isTypeDeclaration', () => { + it('recognizes class_declaration', () => { + const tree = parse('class User {}'); + const classNode = tree.rootNode.child(0); + expect(classNode).toBeDefined(); + expect(extractor.isTypeDeclaration(classNode!)).toBe(true); + }); + + it('recognizes interface_declaration', () => { + const tree = parse('interface IUser {}'); + const interfaceNode = tree.rootNode.child(0); + expect(interfaceNode).toBeDefined(); + expect(extractor.isTypeDeclaration(interfaceNode!)).toBe(true); + }); + + it('recognizes abstract_class_declaration', () => { + const tree = parse('abstract class BaseService {}'); + const abstractNode = tree.rootNode.child(0); + expect(abstractNode).toBeDefined(); + expect(extractor.isTypeDeclaration(abstractNode!)).toBe(true); + }); + + it('rejects function_declaration', () => { + const tree = parse('function getUser() {}'); + const functionNode = tree.rootNode.child(0); + expect(functionNode).toBeDefined(); + expect(extractor.isTypeDeclaration(functionNode!)).toBe(false); + }); + + it('rejects variable declaration', () => { + const tree = parse('const user = {};'); + const variableNode = tree.rootNode.child(0); + expect(variableNode).toBeDefined(); + expect(extractor.isTypeDeclaration(variableNode!)).toBe(false); + }); + }); + + describe('extract', () => { + it('extracts single field with type', () => { + const tree = parse(` + class User { + name: string; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('User'); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('name'); + expect(result!.fields[0].type).toBe('string'); + expect(result!.fields[0].visibility).toBe('public'); + }); + + it('extracts private field', () => { + const tree = parse(` + class User { + private password: string; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('password'); + expect(result!.fields[0].type).toBe('string'); + expect(result!.fields[0].visibility).toBe('private'); + }); + + it('extracts static readonly field', () => { + const tree = parse(` + class Config { + static readonly VERSION: string = '1.0'; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('VERSION'); + expect(result!.fields[0].type).toBe('string'); + expect(result!.fields[0].isStatic).toBe(true); + expect(result!.fields[0].isReadonly).toBe(true); + expect(result!.fields[0].visibility).toBe('public'); + }); + + it('extracts optional field (?:)', () => { + const tree = parse(` + interface User { + email?: string; + } + `); + const interfaceNode = tree.rootNode.child(0); + const result = extractor.extract(interfaceNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('email'); + // Note: optional fields may have type modified to include undefined + expect(result!.fields[0].type).toContain('string'); + }); + + it('extracts multiple fields with different visibilities', () => { + const tree = parse(` + class User { + public id: number; + private secretKey: string; + protected createdAt: Date; + name: string; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(4); + + const fields = result!.fields; + + const idField = fields.find(f => f.name === 'id'); + expect(idField).toBeDefined(); + expect(idField!.visibility).toBe('public'); + expect(idField!.type).toBe('number'); + + const secretKeyField = fields.find(f => f.name === 'secretKey'); + expect(secretKeyField).toBeDefined(); + expect(secretKeyField!.visibility).toBe('private'); + + const createdAtField = fields.find(f => f.name === 'createdAt'); + expect(createdAtField).toBeDefined(); + expect(createdAtField!.visibility).toBe('protected'); + + const nameField = fields.find(f => f.name === 'name'); + expect(nameField).toBeDefined(); + expect(nameField!.visibility).toBe('public'); // default + }); + + it('handles field without type annotation', () => { + const tree = parse(` + class User { + name; + age; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(2); + + const nameField = result!.fields.find(f => f.name === 'name'); + expect(nameField).toBeDefined(); + expect(nameField!.type).toBeNull(); + + const ageField = result!.fields.find(f => f.name === 'age'); + expect(ageField).toBeDefined(); + expect(ageField!.type).toBeNull(); + }); + + it('extracts complex generic types (Map, Array)', () => { + const tree = parse(` + class Repository { + users: Map; + ids: Array; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(2); + + const usersField = result!.fields.find(f => f.name === 'users'); + expect(usersField).toBeDefined(); + expect(usersField!.type).toBe('Map'); + + const idsField = result!.fields.find(f => f.name === 'ids'); + expect(idsField).toBeDefined(); + expect(idsField!.type).toBe('Array'); + }); + + it('extracts nested types', () => { + const tree = parse(` + class Container { + data: OuterType>; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('data'); + expect(result!.fields[0].type).toBe('OuterType>'); + }); + + it('extracts fields from interface', () => { + const tree = parse(` + interface UserDTO { + id: number; + name: string; + email?: string; + } + `); + const interfaceNode = tree.rootNode.child(0); + const result = extractor.extract(interfaceNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('UserDTO'); + expect(result!.fields).toHaveLength(3); + + const idField = result!.fields.find(f => f.name === 'id'); + expect(idField).toBeDefined(); + expect(idField!.type).toBe('number'); + + const nameField = result!.fields.find(f => f.name === 'name'); + expect(nameField).toBeDefined(); + expect(nameField!.type).toBe('string'); + + const emailField = result!.fields.find(f => f.name === 'email'); + expect(emailField).toBeDefined(); + }); + + it('extracts fields from abstract class', () => { + const tree = parse(` + abstract class BaseEntity { + protected id: number; + createdAt: Date; + } + `); + const abstractNode = tree.rootNode.child(0); + const result = extractor.extract(abstractNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('BaseEntity'); + expect(result!.fields).toHaveLength(2); + + const idField = result!.fields.find(f => f.name === 'id'); + expect(idField).toBeDefined(); + expect(idField!.visibility).toBe('protected'); + + const createdAtField = result!.fields.find(f => f.name === 'createdAt'); + expect(createdAtField).toBeDefined(); + expect(createdAtField!.visibility).toBe('public'); + }); + + it('extracts array types', () => { + const tree = parse(` + class UserService { + users: User[]; + ids: number[]; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(2); + + const usersField = result!.fields.find(f => f.name === 'users'); + expect(usersField).toBeDefined(); + expect(usersField!.type).toBe('User[]'); + + const idsField = result!.fields.find(f => f.name === 'ids'); + expect(idsField).toBeDefined(); + expect(idsField!.type).toBe('number[]'); + }); + + it('extracts union types', () => { + const tree = parse(` + class Field { + value: string | number | null; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('value'); + expect(result!.fields[0].type).toBe('string | number | null'); + }); + + it('returns null for non-type declaration nodes', () => { + const tree = parse('function getUser() {}'); + const functionNode = tree.rootNode.child(0); + const result = extractor.extract(functionNode!, mockContext); + expect(result).toBeNull(); + }); + + it('extracts fields from type alias with object type', () => { + const tree = parse(` + type UserDTO = { + id: number; + name: string; + } + `); + const typeAliasNode = tree.rootNode.child(0); + const result = extractor.extract(typeAliasNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('UserDTO'); + expect(result!.fields).toHaveLength(2); + + const idField = result!.fields.find(f => f.name === 'id'); + expect(idField).toBeDefined(); + expect(idField!.type).toBe('number'); + + const nameField = result!.fields.find(f => f.name === 'name'); + expect(nameField).toBeDefined(); + expect(nameField!.type).toBe('string'); + }); + + it('includes source file path in field info', () => { + const tree = parse(` + class User { + name: string; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].sourceFile).toBe('test.ts'); + }); + + it('includes line number in field info', () => { + const tree = parse(` + class User { + name: string; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].line).toBeGreaterThan(0); + }); + + it('detects nested interface declarations in methods', () => { + const tree = parse(` + class Container { + data: string; + + process() { + interface LocalInterface { + value: number; + } + } + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.ownerFqn).toBe('Container'); + // Note: Nested types within method bodies are detected + expect(result!.nestedTypes).toContain('LocalInterface'); + // Should only extract fields from the outer class + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('data'); + }); + }); +});