mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
1503 lines
53 KiB
TypeScript
1503 lines
53 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { TypeScriptFieldExtractor } from '../../src/core/ingestion/field-extractors/typescript.js';
|
|
import { createFieldExtractor } from '../../src/core/ingestion/field-extractors/generic.js';
|
|
import { typescriptConfig } from '../../src/core/ingestion/field-extractors/configs/typescript-javascript.js';
|
|
import { pythonConfig } from '../../src/core/ingestion/field-extractors/configs/python.js';
|
|
import { goConfig } from '../../src/core/ingestion/field-extractors/configs/go.js';
|
|
import { cppConfig } from '../../src/core/ingestion/field-extractors/configs/c-cpp.js';
|
|
import { rubyConfig } from '../../src/core/ingestion/field-extractors/configs/ruby.js';
|
|
import { dartConfig } from '../../src/core/ingestion/field-extractors/configs/dart.js';
|
|
import { javaConfig, kotlinConfig } from '../../src/core/ingestion/field-extractors/configs/jvm.js';
|
|
import { swiftConfig } from '../../src/core/ingestion/field-extractors/configs/swift.js';
|
|
import type { FieldExtractorContext } from '../../src/core/ingestion/field-types.js';
|
|
import type { TypeEnvironment } from '../../src/core/ingestion/type-env.js';
|
|
import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js';
|
|
import Parser from 'tree-sitter';
|
|
import TypeScript from 'tree-sitter-typescript';
|
|
import Python from 'tree-sitter-python';
|
|
import Go from 'tree-sitter-go';
|
|
import Cpp from 'tree-sitter-cpp';
|
|
import Ruby from 'tree-sitter-ruby';
|
|
import Java from 'tree-sitter-java';
|
|
import CSharp from 'tree-sitter-c-sharp';
|
|
import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.js';
|
|
|
|
// Vendored grammars — loaded from vendor/ by absolute path, never node_modules (#2111).
|
|
const Dart = requireVendoredGrammar('tree-sitter-dart');
|
|
|
|
let Kotlin: unknown;
|
|
try {
|
|
Kotlin = requireVendoredGrammar('tree-sitter-kotlin');
|
|
} catch {
|
|
// Kotlin grammar may not have a prebuild for this platform
|
|
}
|
|
|
|
let Swift: unknown;
|
|
try {
|
|
Swift = requireVendoredGrammar('tree-sitter-swift');
|
|
} catch {
|
|
// Swift grammar may not have a prebuild for this platform
|
|
}
|
|
import { csharpConfig as csharpFieldConfig } from '../../src/core/ingestion/field-extractors/configs/csharp.js';
|
|
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. symbolTable comes from createSemanticModel().symbols
|
|
// (the facade) rather than createSymbolTable() (the raw leaf) — this mirrors
|
|
// production, where FieldExtractorContext always receives the SemanticModel-
|
|
// wrapped facade so any .add() write dispatches through the owner-scoped
|
|
// registries. No current field extractor calls symbolTable.add(), but
|
|
// matching the production shape prevents silent drift if a future extractor
|
|
// starts registering dynamically-discovered properties.
|
|
const createMockContext = (): FieldExtractorContext => ({
|
|
typeEnv: {
|
|
lookup: () => undefined,
|
|
constructorBindings: [],
|
|
fileScope: () => new Map(),
|
|
allScopes: () => new Map(),
|
|
constructorTypeMap: new Map(),
|
|
flush: () => {},
|
|
} satisfies TypeEnvironment,
|
|
symbolTable: createSemanticModel().symbols,
|
|
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<string, User>, Array<number>)', () => {
|
|
const tree = parse(`
|
|
class Repository {
|
|
users: Map<string, User>;
|
|
ids: Array<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('Map<string, User>');
|
|
|
|
const idsField = result!.fields.find((f) => f.name === 'ids');
|
|
expect(idsField).toBeDefined();
|
|
expect(idsField!.type).toBe('Array<number>');
|
|
});
|
|
|
|
it('extracts nested types', () => {
|
|
const tree = parse(`
|
|
class Container {
|
|
data: OuterType<InnerType<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('data');
|
|
expect(result!.fields[0].type).toBe('OuterType<InnerType<string>>');
|
|
});
|
|
|
|
it('extracts fields from interface', () => {
|
|
const tree = parse(`
|
|
interface UserDTO {
|
|
id: number;
|
|
name: string;
|
|
email?: string;
|
|
}
|
|
`);
|
|
const interfaceNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(interfaceNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.ownerFqn).toBe('UserDTO');
|
|
expect(result!.fields).toHaveLength(3);
|
|
|
|
const idField = result!.fields.find((f) => f.name === 'id');
|
|
expect(idField).toBeDefined();
|
|
expect(idField!.type).toBe('number');
|
|
|
|
const nameField = result!.fields.find((f) => f.name === 'name');
|
|
expect(nameField).toBeDefined();
|
|
expect(nameField!.type).toBe('string');
|
|
|
|
const emailField = result!.fields.find((f) => f.name === 'email');
|
|
expect(emailField).toBeDefined();
|
|
});
|
|
|
|
it('extracts fields from abstract class', () => {
|
|
const tree = parse(`
|
|
abstract class BaseEntity {
|
|
protected id: number;
|
|
createdAt: Date;
|
|
}
|
|
`);
|
|
const abstractNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(abstractNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.ownerFqn).toBe('BaseEntity');
|
|
expect(result!.fields).toHaveLength(2);
|
|
|
|
const idField = result!.fields.find((f) => f.name === 'id');
|
|
expect(idField).toBeDefined();
|
|
expect(idField!.visibility).toBe('protected');
|
|
|
|
const createdAtField = result!.fields.find((f) => f.name === 'createdAt');
|
|
expect(createdAtField).toBeDefined();
|
|
expect(createdAtField!.visibility).toBe('public');
|
|
});
|
|
|
|
it('extracts array types', () => {
|
|
const tree = parse(`
|
|
class UserService {
|
|
users: User[];
|
|
ids: number[];
|
|
}
|
|
`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(2);
|
|
|
|
const usersField = result!.fields.find((f) => f.name === 'users');
|
|
expect(usersField).toBeDefined();
|
|
expect(usersField!.type).toBe('User[]');
|
|
|
|
const idsField = result!.fields.find((f) => f.name === 'ids');
|
|
expect(idsField).toBeDefined();
|
|
expect(idsField!.type).toBe('number[]');
|
|
});
|
|
|
|
it('extracts union types', () => {
|
|
const tree = parse(`
|
|
class Field {
|
|
value: string | number | null;
|
|
}
|
|
`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(1);
|
|
expect(result!.fields[0].name).toBe('value');
|
|
expect(result!.fields[0].type).toBe('string | number | null');
|
|
});
|
|
|
|
it('returns null for non-type declaration nodes', () => {
|
|
const tree = parse('function getUser() {}');
|
|
const functionNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(functionNode!, mockContext);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('extracts fields from type alias with object type', () => {
|
|
const tree = parse(`
|
|
type UserDTO = {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
`);
|
|
const typeAliasNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(typeAliasNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.ownerFqn).toBe('UserDTO');
|
|
expect(result!.fields).toHaveLength(2);
|
|
|
|
const idField = result!.fields.find((f) => f.name === 'id');
|
|
expect(idField).toBeDefined();
|
|
expect(idField!.type).toBe('number');
|
|
|
|
const nameField = result!.fields.find((f) => f.name === 'name');
|
|
expect(nameField).toBeDefined();
|
|
expect(nameField!.type).toBe('string');
|
|
});
|
|
|
|
it('includes source file path in field info', () => {
|
|
const tree = parse(`
|
|
class User {
|
|
name: string;
|
|
}
|
|
`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(1);
|
|
expect(result!.fields[0].sourceFile).toBe('test.ts');
|
|
});
|
|
|
|
it('includes line number in field info', () => {
|
|
const tree = parse(`
|
|
class User {
|
|
name: string;
|
|
}
|
|
`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(1);
|
|
expect(result!.fields[0].line).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('detects nested interface declarations in methods', () => {
|
|
const tree = parse(`
|
|
class Container {
|
|
data: string;
|
|
|
|
process() {
|
|
interface LocalInterface {
|
|
value: number;
|
|
}
|
|
}
|
|
}
|
|
`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.ownerFqn).toBe('Container');
|
|
// Note: Nested types within method bodies are detected
|
|
expect(result!.nestedTypes).toContain('LocalInterface');
|
|
// Should only extract fields from the outer class
|
|
expect(result!.fields).toHaveLength(1);
|
|
expect(result!.fields[0].name).toBe('data');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Generic factory tests — TypeScript config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GenericFieldExtractor — TypeScript config', () => {
|
|
const parser = new Parser();
|
|
const extractor = createFieldExtractor(typescriptConfig);
|
|
const mockContext = createMockContext();
|
|
mockContext.language = SupportedLanguages.TypeScript;
|
|
|
|
it('extracts public and private fields from a class', () => {
|
|
parser.setLanguage(TypeScript.typescript);
|
|
const tree = parser.parse(`
|
|
class User {
|
|
public name: string;
|
|
private age: number;
|
|
}
|
|
`);
|
|
const classNode = tree.rootNode.child(0);
|
|
expect(classNode).toBeDefined();
|
|
expect(extractor.isTypeDeclaration(classNode!)).toBe(true);
|
|
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.ownerFqn).toBe('User');
|
|
expect(result!.fields).toHaveLength(2);
|
|
|
|
const nameField = result!.fields.find((f) => f.name === 'name');
|
|
expect(nameField).toBeDefined();
|
|
expect(nameField!.visibility).toBe('public');
|
|
expect(nameField!.type).toBe('string');
|
|
|
|
const ageField = result!.fields.find((f) => f.name === 'age');
|
|
expect(ageField).toBeDefined();
|
|
expect(ageField!.visibility).toBe('private');
|
|
expect(ageField!.type).toBe('number');
|
|
});
|
|
|
|
it('uses body-discovery fallback when body type does not match config', () => {
|
|
parser.setLanguage(TypeScript.typescript);
|
|
// interface_declaration has an interface_body — which IS in the config bodyNodeTypes
|
|
// but type_alias_declaration has an object_type body which is also in bodyNodeTypes
|
|
const tree = parser.parse(`
|
|
interface Settings {
|
|
theme: string;
|
|
debug: boolean;
|
|
}
|
|
`);
|
|
const ifaceNode = tree.rootNode.child(0);
|
|
expect(ifaceNode).toBeDefined();
|
|
expect(extractor.isTypeDeclaration(ifaceNode!)).toBe(true);
|
|
|
|
const result = extractor.extract(ifaceNode!, mockContext);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.ownerFqn).toBe('Settings');
|
|
expect(result!.fields).toHaveLength(2);
|
|
expect(result!.fields.map((f) => f.name)).toContain('theme');
|
|
expect(result!.fields.map((f) => f.name)).toContain('debug');
|
|
});
|
|
|
|
it('returns null for non-type-declaration nodes', () => {
|
|
parser.setLanguage(TypeScript.typescript);
|
|
const tree = parser.parse('function hello() {}');
|
|
const fnNode = tree.rootNode.child(0);
|
|
expect(extractor.isTypeDeclaration(fnNode!)).toBe(false);
|
|
expect(extractor.extract(fnNode!, mockContext)).toBeNull();
|
|
});
|
|
|
|
it('extracts static and readonly modifiers', () => {
|
|
parser.setLanguage(TypeScript.typescript);
|
|
const tree = parser.parse(`
|
|
class Config {
|
|
static readonly MAX: number;
|
|
private count: number;
|
|
}
|
|
`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(2);
|
|
|
|
const maxField = result!.fields.find((f) => f.name === 'MAX');
|
|
expect(maxField).toBeDefined();
|
|
expect(maxField!.isStatic).toBe(true);
|
|
expect(maxField!.isReadonly).toBe(true);
|
|
|
|
const countField = result!.fields.find((f) => f.name === 'count');
|
|
expect(countField).toBeDefined();
|
|
expect(countField!.isStatic).toBe(false);
|
|
expect(countField!.isReadonly).toBe(false);
|
|
});
|
|
|
|
it('does not false-positive visibility on fields named after visibility keywords', () => {
|
|
parser.setLanguage(TypeScript.typescript);
|
|
// A class field literally named 'private' with no accessibility modifier —
|
|
// findVisibility must not confuse the name with a keyword
|
|
const tree = parser.parse(`
|
|
class Flags {
|
|
private: boolean;
|
|
}
|
|
`);
|
|
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('private');
|
|
// Default visibility is public — the field NAME 'private' must not be treated as a keyword
|
|
expect(result!.fields[0].visibility).toBe('public');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Python config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GenericFieldExtractor — Python', () => {
|
|
const parser = new Parser();
|
|
const extractor = createFieldExtractor(pythonConfig);
|
|
const mockContext = createMockContext();
|
|
mockContext.language = SupportedLanguages.Python;
|
|
mockContext.filePath = 'test.py';
|
|
|
|
it('extracts annotated class fields', () => {
|
|
parser.setLanguage(Python);
|
|
const tree = parser.parse(`class User:
|
|
name: str
|
|
email: str
|
|
`);
|
|
const classNode = tree.rootNode.child(0);
|
|
expect(classNode).toBeDefined();
|
|
expect(extractor.isTypeDeclaration(classNode!)).toBe(true);
|
|
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.ownerFqn).toBe('User');
|
|
expect(result!.fields).toHaveLength(2);
|
|
|
|
const nameField = result!.fields.find((f) => f.name === 'name');
|
|
expect(nameField).toBeDefined();
|
|
expect(nameField!.type).toBe('str');
|
|
expect(nameField!.visibility).toBe('public');
|
|
|
|
const emailField = result!.fields.find((f) => f.name === 'email');
|
|
expect(emailField).toBeDefined();
|
|
expect(emailField!.type).toBe('str');
|
|
});
|
|
|
|
it('detects underscore-based visibility: _protected and __private', () => {
|
|
parser.setLanguage(Python);
|
|
const tree = parser.parse(`class Settings:
|
|
name: str
|
|
_internal: int
|
|
__secret: str
|
|
`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(3);
|
|
|
|
const nameField = result!.fields.find((f) => f.name === 'name');
|
|
expect(nameField).toBeDefined();
|
|
expect(nameField!.visibility).toBe('public');
|
|
|
|
const internalField = result!.fields.find((f) => f.name === '_internal');
|
|
expect(internalField).toBeDefined();
|
|
expect(internalField!.visibility).toBe('protected');
|
|
|
|
const secretField = result!.fields.find((f) => f.name === '__secret');
|
|
expect(secretField).toBeDefined();
|
|
expect(secretField!.visibility).toBe('private');
|
|
});
|
|
|
|
it('does not mark dunder attributes as private', () => {
|
|
parser.setLanguage(Python);
|
|
// __slots__ starts with __ but also ends with __ so the private check
|
|
// (startsWith('__') && !endsWith('__')) is skipped.
|
|
// However it still starts with _ so the config classifies it as protected.
|
|
const tree = parser.parse(`class Meta:
|
|
__slots__: list
|
|
`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(1);
|
|
// __slots__ ends with __ → not private, but still starts with _ → protected
|
|
expect(result!.fields[0].visibility).toBe('protected');
|
|
});
|
|
|
|
it('reports isStatic and isReadonly as false', () => {
|
|
parser.setLanguage(Python);
|
|
const tree = parser.parse(`class A:
|
|
x: int
|
|
`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields[0].isStatic).toBe(false);
|
|
expect(result!.fields[0].isReadonly).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Go config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GenericFieldExtractor — Go', () => {
|
|
const parser = new Parser();
|
|
const extractor = createFieldExtractor(goConfig);
|
|
const mockContext = createMockContext();
|
|
mockContext.language = SupportedLanguages.Go;
|
|
mockContext.filePath = 'test.go';
|
|
|
|
// Helper: Go AST nests name under type_spec, not type_declaration.
|
|
// The generic factory's extract() requires a node with a 'name' field,
|
|
// so we walk to type_spec which holds the name.
|
|
function findTypeSpec(src: string) {
|
|
parser.setLanguage(Go);
|
|
const tree = parser.parse(src);
|
|
const typeDecl = tree.rootNode.child(0)!;
|
|
return { typeDecl, typeSpec: typeDecl.namedChild(0)! };
|
|
}
|
|
|
|
it('recognizes type_declaration via isTypeDeclaration', () => {
|
|
const { typeDecl } = findTypeSpec(`type User struct {\n\tName string\n}`);
|
|
expect(typeDecl.type).toBe('type_declaration');
|
|
expect(extractor.isTypeDeclaration(typeDecl)).toBe(true);
|
|
});
|
|
|
|
it('rejects non-type-declaration nodes', () => {
|
|
parser.setLanguage(Go);
|
|
const tree = parser.parse(`func main() {}`);
|
|
const fnNode = tree.rootNode.child(0);
|
|
expect(extractor.isTypeDeclaration(fnNode!)).toBe(false);
|
|
expect(extractor.extract(fnNode!, mockContext)).toBeNull();
|
|
});
|
|
|
|
it('detects uppercase-based visibility via extractVisibility on field nodes', () => {
|
|
const { typeDecl } = findTypeSpec(
|
|
`type Config struct {\n\tHost string\n\tport int\n\tTimeout int\n}`,
|
|
);
|
|
// Navigate to field_declaration nodes inside the struct
|
|
// type_declaration > type_spec > struct_type > field_declaration_list > field_declaration
|
|
const typeSpec = typeDecl.namedChild(0)!;
|
|
const structType = typeSpec.namedChild(1)!;
|
|
const fieldList = structType.namedChild(0)!;
|
|
|
|
expect(fieldList.type).toBe('field_declaration_list');
|
|
|
|
const hostNode = fieldList.namedChild(0)!;
|
|
const portNode = fieldList.namedChild(1)!;
|
|
const timeoutNode = fieldList.namedChild(2)!;
|
|
|
|
expect(goConfig.extractName(hostNode)).toBe('Host');
|
|
expect(goConfig.extractVisibility(hostNode)).toBe('public');
|
|
|
|
expect(goConfig.extractName(portNode)).toBe('port');
|
|
expect(goConfig.extractVisibility(portNode)).toBe('package');
|
|
|
|
expect(goConfig.extractName(timeoutNode)).toBe('Timeout');
|
|
expect(goConfig.extractVisibility(timeoutNode)).toBe('public');
|
|
});
|
|
|
|
it('extracts field types', () => {
|
|
const { typeDecl } = findTypeSpec(`type Point struct {\n\tX float64\n\tY float64\n}`);
|
|
const typeSpec = typeDecl.namedChild(0)!;
|
|
const structType = typeSpec.namedChild(1)!;
|
|
const fieldList = structType.namedChild(0)!;
|
|
|
|
const xNode = fieldList.namedChild(0)!;
|
|
expect(goConfig.extractName(xNode)).toBe('X');
|
|
expect(goConfig.extractType(xNode)).toBe('float64');
|
|
});
|
|
|
|
it('extracts every name from Go multi-name struct fields', () => {
|
|
const { typeDecl } = findTypeSpec(`type Point struct {\n\tX, y int\n}`);
|
|
|
|
const result = extractor.extract(typeDecl, mockContext);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(2);
|
|
|
|
const xField = result!.fields.find((field) => field.name === 'X');
|
|
expect(xField).toBeDefined();
|
|
expect(xField!.type).toBe('int');
|
|
expect(xField!.visibility).toBe('public');
|
|
|
|
const yField = result!.fields.find((field) => field.name === 'y');
|
|
expect(yField).toBeDefined();
|
|
expect(yField!.type).toBe('int');
|
|
expect(yField!.visibility).toBe('package');
|
|
});
|
|
|
|
it('extracts Go multi-name struct fields when called with the runtime struct_type owner', () => {
|
|
const { typeDecl } = findTypeSpec(`type Point struct {\n\tX, y int\n}`);
|
|
const typeSpec = typeDecl.namedChild(0)!;
|
|
const structType = typeSpec.namedChild(1)!;
|
|
|
|
expect(structType.type).toBe('struct_type');
|
|
expect(extractor.isTypeDeclaration(structType)).toBe(true);
|
|
|
|
const result = extractor.extract(structType, mockContext);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.ownerFqn).toBe('Point');
|
|
expect(result!.fields).toHaveLength(2);
|
|
|
|
const xField = result!.fields.find((field) => field.name === 'X');
|
|
expect(xField).toBeDefined();
|
|
expect(xField!.type).toBe('int');
|
|
expect(xField!.visibility).toBe('public');
|
|
|
|
const yField = result!.fields.find((field) => field.name === 'y');
|
|
expect(yField).toBeDefined();
|
|
expect(yField!.type).toBe('int');
|
|
expect(yField!.visibility).toBe('package');
|
|
});
|
|
|
|
it('reports isStatic and isReadonly as false for all fields', () => {
|
|
const { typeDecl } = findTypeSpec(`type S struct {\n\tX int\n}`);
|
|
const typeSpec = typeDecl.namedChild(0)!;
|
|
const structType = typeSpec.namedChild(1)!;
|
|
const fieldList = structType.namedChild(0)!;
|
|
const fieldNode = fieldList.namedChild(0)!;
|
|
|
|
expect(goConfig.isStatic(fieldNode)).toBe(false);
|
|
expect(goConfig.isReadonly(fieldNode)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// C++ config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GenericFieldExtractor — C++', () => {
|
|
const parser = new Parser();
|
|
const extractor = createFieldExtractor(cppConfig);
|
|
const mockContext = createMockContext();
|
|
mockContext.language = SupportedLanguages.CPlusPlus;
|
|
mockContext.filePath = 'test.cpp';
|
|
|
|
it('extracts fields from a class with access specifiers', () => {
|
|
parser.setLanguage(Cpp);
|
|
const tree = parser.parse(`class User {
|
|
public:
|
|
int id;
|
|
std::string name;
|
|
private:
|
|
std::string password;
|
|
};`);
|
|
const classNode = tree.rootNode.child(0);
|
|
expect(classNode).toBeDefined();
|
|
expect(extractor.isTypeDeclaration(classNode!)).toBe(true);
|
|
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.ownerFqn).toBe('User');
|
|
expect(result!.fields).toHaveLength(3);
|
|
|
|
const idField = result!.fields.find((f) => f.name === 'id');
|
|
expect(idField).toBeDefined();
|
|
expect(idField!.visibility).toBe('public');
|
|
expect(idField!.type).toBe('int');
|
|
|
|
const nameField = result!.fields.find((f) => f.name === 'name');
|
|
expect(nameField).toBeDefined();
|
|
expect(nameField!.visibility).toBe('public');
|
|
|
|
const pwField = result!.fields.find((f) => f.name === 'password');
|
|
expect(pwField).toBeDefined();
|
|
expect(pwField!.visibility).toBe('private');
|
|
});
|
|
|
|
it('uses backward-sibling walk to find access specifier', () => {
|
|
parser.setLanguage(Cpp);
|
|
// protected section followed by more fields
|
|
const tree = parser.parse(`class Base {
|
|
protected:
|
|
int x;
|
|
int y;
|
|
public:
|
|
int z;
|
|
};`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(3);
|
|
|
|
const xField = result!.fields.find((f) => f.name === 'x');
|
|
expect(xField).toBeDefined();
|
|
expect(xField!.visibility).toBe('protected');
|
|
|
|
const yField = result!.fields.find((f) => f.name === 'y');
|
|
expect(yField).toBeDefined();
|
|
expect(yField!.visibility).toBe('protected');
|
|
|
|
const zField = result!.fields.find((f) => f.name === 'z');
|
|
expect(zField).toBeDefined();
|
|
expect(zField!.visibility).toBe('public');
|
|
});
|
|
|
|
it('defaults to private for class without access specifiers', () => {
|
|
parser.setLanguage(Cpp);
|
|
const tree = parser.parse(`class Secret {
|
|
int value;
|
|
};`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(1);
|
|
expect(result!.fields[0].name).toBe('value');
|
|
expect(result!.fields[0].visibility).toBe('private');
|
|
});
|
|
|
|
it('defaults to public for struct without access specifiers', () => {
|
|
parser.setLanguage(Cpp);
|
|
const tree = parser.parse(`struct Point {
|
|
double x;
|
|
double y;
|
|
};`);
|
|
const structNode = tree.rootNode.child(0);
|
|
expect(extractor.isTypeDeclaration(structNode!)).toBe(true);
|
|
|
|
const result = extractor.extract(structNode!, mockContext);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(2);
|
|
expect(result!.fields[0].visibility).toBe('public');
|
|
expect(result!.fields[1].visibility).toBe('public');
|
|
});
|
|
|
|
it('detects static and const fields', () => {
|
|
parser.setLanguage(Cpp);
|
|
const tree = parser.parse(`class Config {
|
|
public:
|
|
static int count;
|
|
const int MAX_SIZE;
|
|
};`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(2);
|
|
|
|
const countField = result!.fields.find((f) => f.name === 'count');
|
|
expect(countField).toBeDefined();
|
|
expect(countField!.isStatic).toBe(true);
|
|
|
|
const maxField = result!.fields.find((f) => f.name === 'MAX_SIZE');
|
|
expect(maxField).toBeDefined();
|
|
expect(maxField!.isReadonly).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ruby config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GenericFieldExtractor — Ruby', () => {
|
|
const parser = new Parser();
|
|
const extractor = createFieldExtractor(rubyConfig);
|
|
const mockContext = createMockContext();
|
|
mockContext.language = SupportedLanguages.Ruby;
|
|
mockContext.filePath = 'test.rb';
|
|
|
|
it('extracts multiple fields from attr_accessor', () => {
|
|
parser.setLanguage(Ruby);
|
|
const tree = parser.parse(`class User
|
|
attr_accessor :name, :email, :age
|
|
end`);
|
|
const classNode = tree.rootNode.child(0);
|
|
expect(classNode).toBeDefined();
|
|
expect(extractor.isTypeDeclaration(classNode!)).toBe(true);
|
|
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.ownerFqn).toBe('User');
|
|
expect(result!.fields).toHaveLength(3);
|
|
|
|
const names = result!.fields.map((f) => f.name);
|
|
expect(names).toContain('name');
|
|
expect(names).toContain('email');
|
|
expect(names).toContain('age');
|
|
|
|
// All attr_accessor fields are public
|
|
for (const field of result!.fields) {
|
|
expect(field.visibility).toBe('public');
|
|
expect(field.type).toBeNull();
|
|
}
|
|
});
|
|
|
|
it('extracts fields from attr_reader as readonly', () => {
|
|
parser.setLanguage(Ruby);
|
|
const tree = parser.parse(`class Config
|
|
attr_reader :host, :port
|
|
end`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(2);
|
|
expect(result!.fields[0].isReadonly).toBe(true);
|
|
expect(result!.fields[1].isReadonly).toBe(true);
|
|
});
|
|
|
|
it('extracts fields from attr_writer as non-readonly', () => {
|
|
parser.setLanguage(Ruby);
|
|
const tree = parser.parse(`class Settings
|
|
attr_writer :theme
|
|
end`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(1);
|
|
expect(result!.fields[0].name).toBe('theme');
|
|
expect(result!.fields[0].isReadonly).toBe(false);
|
|
});
|
|
|
|
it('handles multiple attr_* calls in the same class', () => {
|
|
parser.setLanguage(Ruby);
|
|
const tree = parser.parse(`class Person
|
|
attr_accessor :name
|
|
attr_reader :id
|
|
attr_writer :password
|
|
end`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(3);
|
|
|
|
const nameField = result!.fields.find((f) => f.name === 'name');
|
|
expect(nameField).toBeDefined();
|
|
expect(nameField!.isReadonly).toBe(false);
|
|
|
|
const idField = result!.fields.find((f) => f.name === 'id');
|
|
expect(idField).toBeDefined();
|
|
expect(idField!.isReadonly).toBe(true);
|
|
|
|
const pwField = result!.fields.find((f) => f.name === 'password');
|
|
expect(pwField).toBeDefined();
|
|
expect(pwField!.isReadonly).toBe(false);
|
|
});
|
|
|
|
it('reports type as null (Ruby is dynamically typed)', () => {
|
|
parser.setLanguage(Ruby);
|
|
const tree = parser.parse(`class Item
|
|
attr_accessor :value
|
|
end`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(1);
|
|
expect(result!.fields[0].type).toBeNull();
|
|
});
|
|
|
|
it('reports isStatic as false', () => {
|
|
parser.setLanguage(Ruby);
|
|
const tree = parser.parse(`class Demo
|
|
attr_accessor :data
|
|
end`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields[0].isStatic).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// C# config — primary constructor fields
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GenericFieldExtractor — C# primary constructor fields', () => {
|
|
const parser = new Parser();
|
|
const extractor = createFieldExtractor(csharpFieldConfig);
|
|
const mockContext = createMockContext();
|
|
mockContext.language = SupportedLanguages.CSharp;
|
|
mockContext.filePath = 'test.cs';
|
|
|
|
it('extracts record positional parameters as public readonly fields', () => {
|
|
parser.setLanguage(CSharp);
|
|
const tree = parser.parse('public record Person(string Name, int Age);');
|
|
const classNode = tree.rootNode.child(0);
|
|
expect(classNode).toBeDefined();
|
|
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.ownerFqn).toBe('Person');
|
|
expect(result!.fields).toHaveLength(2);
|
|
|
|
const nameField = result!.fields.find((f) => f.name === 'Name');
|
|
expect(nameField).toBeDefined();
|
|
expect(nameField!.type).toBe('string');
|
|
expect(nameField!.visibility).toBe('public');
|
|
expect(nameField!.isReadonly).toBe(true);
|
|
expect(nameField!.isStatic).toBe(false);
|
|
|
|
const ageField = result!.fields.find((f) => f.name === 'Age');
|
|
expect(ageField).toBeDefined();
|
|
expect(ageField!.type).toBe('int');
|
|
expect(ageField!.visibility).toBe('public');
|
|
expect(ageField!.isReadonly).toBe(true);
|
|
});
|
|
|
|
it('extracts class primary constructor parameters as private fields', () => {
|
|
parser.setLanguage(CSharp);
|
|
const tree = parser.parse(`public class Point(int x, int y) {
|
|
public int X => x;
|
|
}`);
|
|
const classNode = tree.rootNode.child(0);
|
|
expect(classNode).toBeDefined();
|
|
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
expect(result).not.toBeNull();
|
|
|
|
const xField = result!.fields.find((f) => f.name === 'x');
|
|
expect(xField).toBeDefined();
|
|
expect(xField!.type).toBe('int');
|
|
expect(xField!.visibility).toBe('private');
|
|
expect(xField!.isReadonly).toBe(false);
|
|
});
|
|
|
|
it('combines body fields with primary constructor fields', () => {
|
|
parser.setLanguage(CSharp);
|
|
const tree = parser.parse(`public class Service(string name) {
|
|
public int Count;
|
|
}`);
|
|
const classNode = tree.rootNode.child(0);
|
|
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
expect(result).not.toBeNull();
|
|
// Body field + primary constructor field
|
|
expect(result!.fields.length).toBeGreaterThanOrEqual(2);
|
|
|
|
const nameField = result!.fields.find((f) => f.name === 'name');
|
|
expect(nameField).toBeDefined();
|
|
expect(nameField!.visibility).toBe('private');
|
|
|
|
const countField = result!.fields.find((f) => f.name === 'Count');
|
|
expect(countField).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dart config — F26: static const / static final class fields
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GenericFieldExtractor — Dart', () => {
|
|
const parser = new Parser();
|
|
const extractor = createFieldExtractor(dartConfig);
|
|
const mockContext = createMockContext();
|
|
mockContext.language = SupportedLanguages.Dart;
|
|
mockContext.filePath = 'test.dart';
|
|
|
|
it('extracts a static const field as static + readonly (F26)', () => {
|
|
parser.setLanguage(Dart);
|
|
const tree = parser.parse(`class C {
|
|
static const a = 1;
|
|
}`);
|
|
const classNode = tree.rootNode.child(0);
|
|
expect(classNode).toBeDefined();
|
|
expect(extractor.isTypeDeclaration(classNode!)).toBe(true);
|
|
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.ownerFqn).toBe('C');
|
|
const a = result!.fields.find((f) => f.name === 'a');
|
|
expect(a).toBeDefined();
|
|
expect(a!.isStatic).toBe(true);
|
|
expect(a!.isReadonly).toBe(true);
|
|
expect(a!.visibility).toBe('public');
|
|
});
|
|
|
|
it('extracts multi-name static final fields, all static + readonly (F26)', () => {
|
|
parser.setLanguage(Dart);
|
|
const tree = parser.parse(`class C {
|
|
static final String b = 'x', c = 'y';
|
|
}`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
// Exact-count guard (#1919 review CF4): `find()` below passes even on a
|
|
// double-emit, so assert b/c surface exactly twice total — one field each,
|
|
// no duplicate from the static_final_declaration_list multi-name path.
|
|
expect(result!.fields.filter((f) => f.name === 'b' || f.name === 'c')).toHaveLength(2);
|
|
const b = result!.fields.find((f) => f.name === 'b');
|
|
const c = result!.fields.find((f) => f.name === 'c');
|
|
expect(b).toBeDefined();
|
|
expect(c).toBeDefined();
|
|
for (const f of [b!, c!]) {
|
|
expect(f.isStatic).toBe(true);
|
|
expect(f.isReadonly).toBe(true);
|
|
expect(f.type).toBe('String');
|
|
}
|
|
});
|
|
|
|
it('still extracts instance fields (regression)', () => {
|
|
parser.setLanguage(Dart);
|
|
const tree = parser.parse(`class C {
|
|
int z = 0;
|
|
}`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
const z = result!.fields.find((f) => f.name === 'z');
|
|
expect(z).toBeDefined();
|
|
expect(z!.isStatic).toBe(false);
|
|
expect(z!.isReadonly).toBe(false);
|
|
expect(z!.type).toBe('int');
|
|
});
|
|
|
|
it('marks an underscore-prefixed static const as private (F26)', () => {
|
|
parser.setLanguage(Dart);
|
|
const tree = parser.parse(`class C {
|
|
static const _p = 1;
|
|
}`);
|
|
const classNode = tree.rootNode.child(0);
|
|
const result = extractor.extract(classNode!, mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
const p = result!.fields.find((f) => f.name === '_p');
|
|
expect(p).toBeDefined();
|
|
expect(p!.visibility).toBe('private');
|
|
expect(p!.isStatic).toBe(true);
|
|
expect(p!.isReadonly).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Java config — rawDeclaredType: verbatim generic type text (PR #2200 U1)
|
|
// and annotations: '@Name' strings from the modifiers child (PR #2200 U2)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GenericFieldExtractor — Java (rawDeclaredType + annotations)', () => {
|
|
const parser = new Parser();
|
|
const extractor = createFieldExtractor(javaConfig);
|
|
const mockContext = createMockContext();
|
|
mockContext.language = SupportedLanguages.Java;
|
|
mockContext.filePath = 'Test.java';
|
|
|
|
/** Parse `src` and return the first class_declaration node. */
|
|
function classNode(src: string) {
|
|
parser.setLanguage(Java);
|
|
const tree = parser.parse(src);
|
|
const node = tree.rootNode.child(0);
|
|
if (!node) throw new Error('no class node');
|
|
return node;
|
|
}
|
|
|
|
it.each([
|
|
{
|
|
field: 'private List<Shape> shapes;',
|
|
name: 'shapes',
|
|
type: 'List',
|
|
rawDeclaredType: 'List<Shape>',
|
|
},
|
|
{
|
|
field: 'private Set<T> items;',
|
|
name: 'items',
|
|
type: 'Set',
|
|
rawDeclaredType: 'Set<T>',
|
|
},
|
|
{
|
|
field: 'private Map<String, Foo> byName;',
|
|
name: 'byName',
|
|
type: 'Map',
|
|
rawDeclaredType: 'Map<String, Foo>',
|
|
},
|
|
{
|
|
// Non-generic field: rawDeclaredType is PRESENT and equals the type text.
|
|
field: 'private String name;',
|
|
name: 'name',
|
|
type: 'String',
|
|
rawDeclaredType: 'String',
|
|
},
|
|
{
|
|
// Qualified generic: raw text preserved verbatim; simple name still last segment.
|
|
field: 'private java.util.List<Shape> shapes;',
|
|
name: 'shapes',
|
|
type: 'List',
|
|
rawDeclaredType: 'java.util.List<Shape>',
|
|
},
|
|
])(
|
|
'extracts type "$type" and rawDeclaredType "$rawDeclaredType" from `$field`',
|
|
({ field, name, type, rawDeclaredType }) => {
|
|
const result = extractor.extract(classNode(`class C { ${field} }`), mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(1);
|
|
expect(result!.fields[0]).toMatchObject({ name, type, rawDeclaredType });
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
// marker_annotation node type (no arguments).
|
|
field: '@Autowired private List<Shape> shapes;',
|
|
annotations: ['@Autowired'],
|
|
},
|
|
{
|
|
// `annotation` node type (with arguments), not `marker_annotation` —
|
|
// the name comes from the annotation's `name` field.
|
|
field: '@Autowired(required=false) private List<Shape> shapes;',
|
|
annotations: ['@Autowired'],
|
|
},
|
|
{
|
|
// Multiple annotations on one field — all are collected, in order.
|
|
field: '@Nullable @Autowired @Qualifier("shapeBeans") private List<Shape> shapes;',
|
|
annotations: ['@Nullable', '@Autowired', '@Qualifier'],
|
|
},
|
|
])('extracts annotations $annotations from `$field`', ({ field, annotations }) => {
|
|
const result = extractor.extract(classNode(`class C { ${field} }`), mockContext);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(1);
|
|
expect(result!.fields[0]).toMatchObject({ name: 'shapes', annotations });
|
|
});
|
|
|
|
it('omits annotations entirely for a non-annotated field', () => {
|
|
const result = extractor.extract(
|
|
classNode('class C { private List<Shape> shapes; }'),
|
|
mockContext,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(1);
|
|
expect(result!.fields[0]).not.toHaveProperty('annotations');
|
|
});
|
|
|
|
it('still extracts the field when extractRawType/extractAnnotations throw (per-hook isolation)', () => {
|
|
// A throwing hook must degrade to a field WITHOUT raw/annotations — never
|
|
// escape buildField: an escaped throw reaches the language-group catch
|
|
// upstream (processFileGroup) and silently drops every remaining file in
|
|
// the group (#2286-review guard pattern).
|
|
const throwingExtractor = createFieldExtractor({
|
|
...javaConfig,
|
|
extractRawType: () => {
|
|
throw new Error('unexpected node shape');
|
|
},
|
|
extractAnnotations: () => {
|
|
throw new Error('unexpected node shape');
|
|
},
|
|
});
|
|
|
|
const result = throwingExtractor.extract(
|
|
classNode('class C { @Autowired private List<Shape> shapes; }'),
|
|
mockContext,
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.fields).toHaveLength(1);
|
|
expect(result!.fields[0]).toMatchObject({ name: 'shapes', type: 'List' });
|
|
expect(result!.fields[0]).not.toHaveProperty('rawDeclaredType');
|
|
expect(result!.fields[0]).not.toHaveProperty('annotations');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Kotlin config — F52: companion-object properties indexed as fields
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const describeKotlin = Kotlin ? describe : describe.skip;
|
|
|
|
describeKotlin('GenericFieldExtractor — Kotlin (F52 companion)', () => {
|
|
const parser = new Parser();
|
|
const extractor = createFieldExtractor(kotlinConfig);
|
|
const mockContext = createMockContext();
|
|
mockContext.language = SupportedLanguages.Kotlin;
|
|
mockContext.filePath = 'test.kt';
|
|
|
|
/** The first companion_object node in `src`. */
|
|
function companion(src: string): Parser.SyntaxNode {
|
|
parser.setLanguage(Kotlin);
|
|
const tree = parser.parse(src);
|
|
let found: Parser.SyntaxNode | undefined;
|
|
const walk = (n: Parser.SyntaxNode) => {
|
|
if (n.type === 'companion_object') found ??= n;
|
|
for (let i = 0; i < n.namedChildCount; i++) {
|
|
const c = n.namedChild(i);
|
|
if (c) walk(c);
|
|
}
|
|
};
|
|
walk(tree.rootNode);
|
|
if (!found) throw new Error('no companion_object found');
|
|
return found;
|
|
}
|
|
|
|
it('extracts a `const val` companion property as a static, readonly field', () => {
|
|
const node = companion(`class C {
|
|
companion object {
|
|
const val TAG = "c"
|
|
}
|
|
}`);
|
|
expect(extractor.isTypeDeclaration(node)).toBe(true);
|
|
const result = extractor.extract(node, mockContext);
|
|
expect(result).not.toBeNull();
|
|
const tag = result!.fields.find((f) => f.name === 'TAG');
|
|
expect(tag).toBeDefined();
|
|
expect(tag!.isStatic).toBe(true);
|
|
expect(tag!.isReadonly).toBe(true);
|
|
});
|
|
|
|
it('extracts a property from a NAMED companion object', () => {
|
|
const node = companion(`class C {
|
|
companion object Factory {
|
|
val x = 1
|
|
}
|
|
}`);
|
|
const result = extractor.extract(node, mockContext);
|
|
const x = result!.fields.find((f) => f.name === 'x');
|
|
expect(x).toBeDefined();
|
|
expect(x!.isStatic).toBe(true);
|
|
expect(x!.isReadonly).toBe(true);
|
|
});
|
|
|
|
it('indexes only the property, not the function, and emits it exactly once', () => {
|
|
const node = companion(`class C {
|
|
companion object {
|
|
val onlyField = 1
|
|
fun create() {}
|
|
}
|
|
}`);
|
|
const result = extractor.extract(node, mockContext);
|
|
const fieldNames = result!.fields.map((f) => f.name);
|
|
expect(fieldNames).toEqual(['onlyField']); // function excluded, no duplication
|
|
});
|
|
|
|
// CF4 (#1919 review): guard the new `isInsideKotlinCompanion` walk against
|
|
// false-positives — a plain (non-companion) class property must be isStatic=false.
|
|
it('reports a plain non-companion class property as isStatic=false (CF4)', () => {
|
|
const classNode = firstNodeOfType(
|
|
`class C {
|
|
val x: Int = 1
|
|
}`,
|
|
'class_declaration',
|
|
);
|
|
expect(extractor.isTypeDeclaration(classNode)).toBe(true);
|
|
const result = extractor.extract(classNode, mockContext);
|
|
expect(result).not.toBeNull();
|
|
const x = result!.fields.find((f) => f.name === 'x');
|
|
expect(x).toBeDefined();
|
|
expect(x!.isStatic).toBe(false);
|
|
});
|
|
|
|
/** Parse `src` and return the first node of the given type (depth-first). */
|
|
function firstNodeOfType(src: string, type: string): Parser.SyntaxNode {
|
|
parser.setLanguage(Kotlin);
|
|
const tree = parser.parse(src);
|
|
let found: Parser.SyntaxNode | undefined;
|
|
const walk = (n: Parser.SyntaxNode) => {
|
|
if (found) return;
|
|
if (n.type === type) {
|
|
found = n;
|
|
return;
|
|
}
|
|
for (let i = 0; i < n.namedChildCount; i++) {
|
|
const c = n.namedChild(i);
|
|
if (c) walk(c);
|
|
}
|
|
};
|
|
walk(tree.rootNode);
|
|
if (!found) throw new Error(`no ${type} found`);
|
|
return found;
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Swift config — F75: protocol property requirements extracted as fields
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const describeSwift = Swift ? describe : describe.skip;
|
|
|
|
describeSwift('GenericFieldExtractor — Swift (F75 protocol property requirements)', () => {
|
|
const parser = new Parser();
|
|
const extractor = createFieldExtractor(swiftConfig);
|
|
const mockContext = createMockContext();
|
|
mockContext.language = SupportedLanguages.Swift;
|
|
mockContext.filePath = 'test.swift';
|
|
|
|
/** Parse `src` and return the first class/protocol declaration node. */
|
|
function declNode(src: string): Parser.SyntaxNode {
|
|
parser.setLanguage(Swift);
|
|
const tree = parser.parse(src);
|
|
const node = tree.rootNode.child(0);
|
|
if (!node) throw new Error('no declaration node');
|
|
return node;
|
|
}
|
|
|
|
it('extracts a `{ get }` protocol property requirement as a field (F75)', () => {
|
|
const node = declNode(`protocol P {
|
|
var title: String { get }
|
|
}`);
|
|
expect(extractor.isTypeDeclaration(node)).toBe(true);
|
|
const result = extractor.extract(node, mockContext);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.ownerFqn).toBe('P');
|
|
const title = result!.fields.find((f) => f.name === 'title');
|
|
expect(title).toBeDefined();
|
|
expect(title!.type).toBe('String');
|
|
expect(title!.isStatic).toBe(false);
|
|
});
|
|
|
|
it('extracts a `{ get set }` protocol property requirement (F75)', () => {
|
|
const node = declNode(`protocol P {
|
|
var count: Int { get set }
|
|
}`);
|
|
const result = extractor.extract(node, mockContext);
|
|
const count = result!.fields.find((f) => f.name === 'count');
|
|
expect(count).toBeDefined();
|
|
expect(count!.type).toBe('Int');
|
|
});
|
|
|
|
it('extracts a static protocol property requirement as static (F75)', () => {
|
|
const node = declNode(`protocol P {
|
|
static var shared: P { get }
|
|
}`);
|
|
const result = extractor.extract(node, mockContext);
|
|
const shared = result!.fields.find((f) => f.name === 'shared');
|
|
expect(shared).toBeDefined();
|
|
expect(shared!.type).toBe('P');
|
|
expect(shared!.isStatic).toBe(true);
|
|
});
|
|
|
|
it('extracts all requirements from a multi-property protocol (F75)', () => {
|
|
const node = declNode(`protocol P {
|
|
var title: String { get }
|
|
var count: Int { get set }
|
|
static var shared: P { get }
|
|
}`);
|
|
const result = extractor.extract(node, mockContext);
|
|
const names = result!.fields.map((f) => f.name).sort();
|
|
expect(names).toEqual(['count', 'shared', 'title']);
|
|
});
|
|
|
|
it('still extracts a class stored property exactly once (regression)', () => {
|
|
const node = declNode(`class C {
|
|
var name: String = ""
|
|
}`);
|
|
expect(extractor.isTypeDeclaration(node)).toBe(true);
|
|
const result = extractor.extract(node, mockContext);
|
|
const matches = result!.fields.filter((f) => f.name === 'name');
|
|
expect(matches).toHaveLength(1);
|
|
expect(matches[0].type).toBe('String');
|
|
});
|
|
});
|