GitNexus/gitnexus/test/unit/field-extraction.test.ts
Abhigyan Patwari 2b44565534
Some checks are pending
Gitleaks / gitleaks (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
fix(resolution): correct receiver drops and property ownership (#3653)
* fix(js): withdraw receiver drops for object-literal key writes a later pass answers

The JS capture makes the enclosing object literal the receiver of each key
it writes (`return { wickRatio: row.a }`). Case 0 of the receiver-bound pass
treats any receiver text holding `.` or `(` as a compound expression, and a
literal holding `row.a`, `0.5` or `...seed` does, so the fold failed and the
site was recorded as a `receiver-unresolved` drop. The return-shape and
unique-name passes run later and answered 17 of these 19 writes with an
ACCESSES edge, but nothing withdrew the earlier drop.

Both passes now report the sites they emitted an edge for, keyed by file,
position, site kind and member name (position alone is shared by `a.b` and
`a.b.c()`), and run.ts withdraws the matching `receiver-unresolved` drops
once they have run. A site they leave unanswered keeps its drop.

The other two writes, in `SignalService.make()`, had no edge at all: the
own-return-shape tier read the caller's owner qualifier as
`SignalService.make#0`, so it never matched the method's keys
(`SignalService.make.secretFlag`) and three same-named candidates were
refused. The qualifier is now the qualified name after `<Label>:<file>:`
without the `#<arity>` suffix. It also stops cutting a positioned id
(`outer.parse@25:2`) down to its column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(js): type a static field from its initializer for class-level receivers

`Config.DEFAULT.validate()` with `static DEFAULT = new Config()` produced no
CALLS edge. JS and TS keep a static field's type binding out of the class
scope on purpose: the scope has one binding map for static and instance
members, so the static binding would retype an instance field of the same
name. The class-level receiver route (`declaredTypeOfClassLevelField`) reads
the static Property node instead, but only its annotation-derived
`declaredType`, and an unannotated field has none. The type had nowhere to
live.

The field extractor now records `initializerType` for a field that declares
no type and is initialized by `new X()` / `new ns.X()` (the same two shapes
the scope queries bind), the parse worker stamps it on the Property node, and
the class-level route falls back to it after `declaredType`. The binding still
never enters the shared class-scope map, so the instance-twin hazard does not
return.

This resolves one hop: `Host.q.m()` now reaches `Alien.m`. A longer chain off
the field still goes through the compound fold, which types a field step only
from the class-scope map, so it stays unresolved. The matrix known-gap row
`static-read-without-a-twin-loses-its-type` flips to a positive row for TS,
with a JS twin added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(js): keep CommonJS module.exports declarations out of receiver drops

`module.exports.arrowRequest = (url) => fetch(url)` declares this module's
`arrowRequest` export; the CJS capture rules already bind it like
`exports.memberRequest = …`. The same line also yields a write reference
whose receiver is `module.exports`, and because that text has a dot the
receiver-bound pass folded it as a compound value, failed, and recorded the
declaration as a `receiver-unresolved` write. The bare `exports.x = …`
spelling never reached that path.

A new ScopeResolver hook, `isModuleExportAssignment`, lets a language say a
write site is a module-export declaration. The receiver-bound pass consults
it only at the drop guard, after every case has failed, so it records no
drop and changes nothing else: the site is not marked handled, no edge is
added, and the existing module-level write edge stays. The JS and TS
resolvers implement it for a receiver of exactly `module.exports` (from the
minted chain, or the text when no chain was minted) when `module` resolves
to nothing in scope. A `module` parameter, as in a UMD wrapper, or a write
into a value hanging off the export object still records its drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): record an owned-`this` chain receiver as owned-but-unbound

`this.route.go()` inside an object-literal method was filed as a generic
`receiver-unresolved` drop (or, depending on site order, as nothing). The
method scope owns `this` and gives it no type, which the pass detects with
`isReceiverOwnedButUnbound`, but that check was handed the whole receiver
text `this.route`. No scope owns a chain, so the call fell through to Case 0,
whose fold cannot type an owned `this` either.

The check now receives the receiver's base (from the minted chain, else the
text before the first `.`/`(`), the same base the `suppressReceiverLookup`
check already used. The call is recorded as `receiver-owned-but-unbound`,
its real reason, and still emits no edge: the dynamic-this negative control
holds. On the fixture corpus this moves only that site; no edge changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(resolution): preserve receiver ownership

Bind a return key's declaration write by exact file and position so
unrelated input accesses keep their ordinary candidate ranking. Stop at
untyped static overrides and resolve constructor initializers as values.

Preserve the combined drop guards after rebasing onto main, cover the
binding regressions, and refresh the receiver baseline after #3654.

Key answered sites by their full range to retain repeated-name outer
reads. Keep owned-but-unbound calls in the persisted completeness signal.

* fix(resolution): address feedback (#3653)

Classify module-export declarations using captured module kind before
suppressing receiver drops. Match same-named field metadata by source
position, including declarations on one line.

Require reference kinds in suppression assertions and guard temporary
fixture cleanup. Cover the reported failures with regression tests.

* perf(parser): index repeated field declarations

Sort private field buckets once and use binary search to avoid
quadratic scans while preserving declaration disambiguation.

* fix(ingestion): preserve capture metadata

Record module kind from the parsed tree on worker and main-thread paths.
Match Ruby field metadata at the accessor call instead of its argument.

* fix(ingestion): harden capture metadata

Keep Ruby reader/writer properties writable. Walk module-kind ASTs iteratively so deep expressions cannot abort extraction.

* test(ingestion): clarify worker metadata scope

* docs(ingestion): state capture metadata effects

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 05:59:59 +01:00

1570 lines
55 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 {
javascriptConfig,
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 {
ExtractedFields,
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 JavaScript from 'tree-sitter-javascript';
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/bindings/node/index.js';
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: Parser.Language | null = null;
try {
Kotlin = requireVendoredGrammar('tree-sitter-kotlin');
} catch {
// Kotlin grammar may not have a prebuild for this platform
}
let Swift: Parser.Language | null = null;
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');
});
});
// A JS/TS field with no declared type is typed only by what its initializer
// constructs. The scope query binds that type for an INSTANCE field, but a
// static field's binding is kept out of the class scope (it would retype an
// instance field of the same name), so the field extractor records it on the
// field instead, for the Property node. A declared type, when present, is the
// type, and nothing is recorded beside it.
describe('field initializer type (JS/TS)', () => {
const javascriptExtractor = createFieldExtractor(javascriptConfig);
const typescriptExtractor = new TypeScriptFieldExtractor();
const context = createMockContext();
const initializerTypeOf = (
language: Parser.Language,
extract: (node: Parser.SyntaxNode) => ExtractedFields | null,
source: string,
field: string,
): string | undefined => {
const classParser = new Parser();
classParser.setLanguage(language);
const [classNode] = classParser.parse(source).rootNode.namedChildren;
return extract(classNode)?.fields.find((f) => f.name === field)?.initializerType;
};
it.each<readonly [string, string, string | undefined]>([
['static DEFAULT = new Config();', 'DEFAULT', 'Config'],
['static repo = new models.Repo();', 'repo', 'models.Repo'],
['p = new Outer();', 'p', 'Outer'],
['#q = new Outer();', '#q', 'Outer'],
['static n = 5;', 'n', undefined],
['static made = make();', 'made', undefined],
['static bare;', 'bare', undefined],
])('JavaScript `%s` -> %s: %s', (member, field, expected) => {
expect(
initializerTypeOf(
JavaScript,
(node) => javascriptExtractor.extract(node, context),
`class C { ${member} }`,
field,
),
).toBe(expected);
});
it.each<readonly [string, string, string | undefined]>([
['static DEFAULT = new Config();', 'DEFAULT', 'Config'],
['static repo = new models.Repo();', 'repo', 'models.Repo'],
['private p = new Outer();', 'p', 'Outer'],
['static r: Alien = new Alien();', 'r', undefined],
['static n = 5;', 'n', undefined],
['static made = make();', 'made', undefined],
])('TypeScript `%s` -> %s: %s', (member, field, expected) => {
expect(
initializerTypeOf(
TypeScript.typescript,
(node) => typescriptExtractor.extract(node, context),
`class C { ${member} }`,
field,
),
).toBe(expected);
});
});