mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (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
* feat: add Spring DI resolver for @Autowired List<T> injection Addresses all P0/P1 findings from tri-review (#2200): - P0: Register INJECTS in RelationshipType union (compiles) - P0: Rewrite execute() to emit consumer→implementation edges from graph data only - P1: Register in VALID_RELATION_TYPES, single-pass O(N) indexes - P1: Java-only gate with early exit on non-Java repos - P1: Update FULL_ORDER golden test - 8 unit tests covering all edge cases * test: make VALID_RELATION_TYPES size assertion array-driven (no hardcoded count) The security test hardcoded toBe(16) for the relation type count, but PR #2200 added INJECTS, bumping it to 17. Replace the magic number with an EXPECTED_RELATION_TYPES array whose .length drives the size assertion, so future additions only need to append to the list. Fixes CI failure on PR #2200. * fix(ingestion): thread raw generic field types onto Property nodes so Spring DI matching works (review 4616076037 P0) Production declaredType is generics-stripped by design (extractSimpleTypeName: List<Shape> -> "List"), so the spring-di phase's anchored regexes could never match real extraction output — the phase was a silent no-op on every real Java repository, while its unit tests passed against hand-built node shapes. Add FieldInfo.rawDeclaredType captured verbatim from the field's type node (.text, generics and qualifiers preserved — same precedent as the JVM method extractor), thread it through both parse-worker Property sites, add it to the shared NodeProperties contract, and match on rawDeclaredType ONLY (no declaredType fallback: it can never match real data and would mask future plumbing regressions as quiet no-ops). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ingestion): gate Spring DI on real injection annotations, honest edge reason (review 4616076037 P1) Extract Java field annotations (shared extractAnnotations helper, moved verbatim from the method extractor) onto Property nodes and require @Autowired or @Inject before a collection field becomes an INJECTS candidate. Previously every edge's reason string fabricated "@Autowired" without any annotation ever being checked, and any plain collection field would have fanned out false edges once matching worked. @Resource is deliberately excluded: JSR-250 resolves by bean name first (defaulting to the field name), injecting a single named collection bean — the opposite of the collect-all-implementers fan-out INJECTS models. Pinned by a test. An annotated candidate missing rawDeclaredType now logs an isDev warning (plumbing-contract breach signal) instead of vanishing silently. SCHEMA_BUMP 9 -> 10: Property nodes gained rawDeclaredType + annotations; warm parse caches must invalidate or the DI phase silently no-ops on replayed pre-upgrade nodes (the #2038 trap). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(ingestion): framework-neutral di phase + language-scoped Spring matcher registry (review 4616076037 P1) spring-di was the only pipeline phase naming a language in shared core/ingestion code (DoD.md language rule; the maintainer's direction is a generic DI solution). Split it: - di-extractors/spring.ts: the Spring matcher (annotation gate, collection type parse, @Resource exclusion rationale, framework-specific reason payload) — language-scoped home, mirroring route-extractors/. - di-extractors/index.ts: DI_MATCHERS, a single-valued ReadonlyMap<SupportedLanguages, DiFieldMatcher> mirroring the SCOPE_RESOLVERS registry shape sanctioned by AGENTS.md. Constructor injection deliberately out of scope; widen to arrays only when a second same-language framework lands. - pipeline-phases/di.ts (renamed from spring-di.ts): framework-neutral — routes Property nodes to registered matchers by node language via a typed guard, then runs the unchanged reverse-index fan-out. Zero language or framework names remain (grep-verified). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ingestion): language- and qualified-name-scoped interface resolution for DI fan-out (review 4616076037 P2) The interface index was built from ALL Interface nodes regardless of language, keyed by bare simple name with last-writer-wins overwrite — a polyglot repo with a TS and a Java 'Shape' could fan Java INJECTS edges into TypeScript classes, and two same-named Java interfaces in different packages silently collapsed to whichever parsed last (documented GitNexus bug class: #2054, PR #1956). Resolution is now per-language with qualifiedName as the primary key (Interface nodes already carry package-qualified qualifiedName); dotted element types resolve via qualifiedName, bare names via a per-language simple-name index that records ambiguity and fails CLOSED. Ambiguity skips are observable: DIOutput.ambiguousSkipped + an aggregated isDev debug log, so 'no DI fields' is distinguishable from 'all candidates ambiguous'. Same-package tiebreaking is a pinned, documented follow-up. Order-independence pinned by running collision tests in both insertion orders. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ingestion): depth-aware Spring collection-type parser for idiomatic generics (review 4616076037 P3) The two anchored regexes silently skipped idiomatic Spring shapes: Map<Pair<A,B>, IFoo> (nested-generic key broke the [^,]+ split), List<? extends IFoo> / List<? super IFoo> (bounded wildcards), java.util.List<IFoo> (qualified wrapper), and whitespace/multi-line declarations. Replace them with a small scanner: whitespace normalization, wrapper matched by last dotted segment, depth-aware top-level-comma split, wildcard bound stripping, and a final plain-dotted-type-name gate so anything else (nested-generic elements, arrays, unbounded wildcards, embedded comments, unbalanced brackets) fails closed. Every accept and reject is documented in the module docstring and pinned by 27 table-driven cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(integration): prove Spring DI end-to-end through the real pipeline (review 4616076037 P1) Both no-op incarnations of this feature shipped with a green unit suite because every test hand-built the exact graph shape the phase expected — no test ever ran real Java source through the actual extraction pipeline. Add test/integration/spring-di-pipeline.test.ts: real .java fixtures via runPipelineFromRepo, pinning (a) the extraction contract on the annotated field's Property node (declaredType 'List', rawDeclaredType 'List<IFoo>', annotations ['@Autowired']), (b) set-equality on ALL INJECTS edges (exactly Consumer->FooA and Consumer->FooB; the non-annotated 'plain' field of the same type contributes nothing; no self-edges), and (c) a negative-control fixture with no injection annotations producing zero INJECTS edges. Either historical regression fails at least one of these. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(incremental): register INJECTS across product surfaces + delete-before-writeback (review 4616076037 P2) INJECTS was allowlisted in VALID_RELATION_TYPES but invisible or unhandled everywhere else. Register it deliberately: - REL_TYPES (gitnexus-shared schema-constants): web-side validRelType() otherwise silently rejects INJECTS filters (CLI/web single source of truth). - mcp/tools.ts cypher edge list (agent-facing schema discovery). - isGraphWideRelType: INJECTS validity is a whole-program property — a change to a THIRD file (the interface, or a new/removed implementer) creates/invalidates edges between two untouched files (the TAINT_PATH / #2084 M4 U6 class), so incremental extraction must always re-include the full fresh set. - deleteAllInjects (lbug-adapter): mirrors deleteAllInterprocTaintPaths — COUNT-then-DELETE under withConnLock, benign missing-table carve-out, re-throw otherwise (CodeRelation has no PK and there is no read-side dedup; a fail-soft delete + re-add would silently duplicate rows). - run-analyze.ts: the delete is UNCONDITIONAL, next to the Communities delete — deliberately NOT inside the options.pdg block: the di phase runs on every persisting analyze while the graph-wide re-include is unconditional, so a pdg-gated delete would append without deleting on every non-pdg incremental run (N runs = N copies). - local-backend.ts comment: opt-in traversal by design (not in default impact()/context() lists; no IMPACT_RELATION_CONFIDENCE entry per the WRAPS/FETCHES precedent — edges carry their own 0.8). - ARCHITECTURE.md: 14 -> 15 phases, DAG diagram, phase table, skip-list. Note: the tools.ts edge list also predates WRAPS/QUERIES/USES — that drift is pre-existing and left for a follow-up. Idempotency pinned end-to-end: two successive incremental runs (real runFullAnalysis + real LadybugDB, unrelated-file touches) leave the INJECTS row count stable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: describe INJECTS' actual precondition; drop stale fixed-at-16 comments (review 4616076037 P3) The shared-schema doc for INJECTS claimed an @Autowired precondition the code (pre-fix) never checked, and hardwired Spring semantics into what is now a framework-neutral edge type. Reword: precondition is an injection annotation recognized by a per-language matcher in di-extractors/; framework specifics live in the reason payload, not the type contract. security.test.ts comments still said the allow-list size 'stays fixed at 16' (it is 17 and the assertion derives from EXPECTED_RELATION_TYPES). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: simplify DI surfaces — narrow matcher contract, dedup delete-alls, derive tools edge list Post-implementation simplification pass (4 review angles): - DiFieldMatch/CandidateField carried collectionType + matchedAnnotation that no consumer read (the matcher bakes both into reason) — narrowed to {elementTypeName, reason}. - parseElementTypeName had two guard branches fully subsumed by the final plain-dotted-type-name gate — deleted, rationale folded into the regex comment. - The three byte-identical delete-all-by-rel-type functions in lbug-adapter (TAINT_PATH / CALL_SUMMARY / INJECTS) are now one parameterized helper + thin wrappers with identical names, signatures, and message text (character-diff verified) — the missing-table regex and abort policy now live in exactly one place. - The cypher tool's hand-maintained edge-type list (already missing WRAPS/QUERIES/USES) is now derived from the canonical REL_TYPES — the drift class is gone rather than patched. - di phase: interface indexes are built only for languages that actually have candidates; test builder gained a rawDeclaredType opt-out replacing a hand-rolled node. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: apply Tier-2 review findings — qualified-name fail-closed, honest cypher docs, pinned delete contract, hook isolation - byQualifiedName was last-writer-wins on duplicate qualified names (reproduced: order-dependent INJECTS edges with ambiguousSkipped 0 — same package+interface duplicated across monorepo modules/source roots; Java qualifiedName has no file-path component). Both indexes now share the AMBIGUOUS fail-closed sentinel; order-flip test added. - The REL_TYPES-derived cypher edge list advertised pdg-gated types with no caveat (LLM queries on them silently return zero rows on default indexes) — caveat appended, INJECTS example added, impact relationTypes description now names the DI fan-out opt-in. - The delete-all re-throw contract (only defense against duplicate CodeRelation rows) was untested — error classification extracted to a pure classifyDeleteAllError and pinned exhaustively. - extractRawType/extractAnnotations hooks lacked the per-hook try/catch the pipeline applies elsewhere (#2286 pattern): a throwing hook would silently drop every remaining file in the language group. Hardened, degradation tested. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1502 lines
53 KiB
TypeScript
1502 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(),
|
|
} as 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 as Parser.Language);
|
|
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 as Parser.Language);
|
|
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 as Parser.Language);
|
|
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');
|
|
});
|
|
});
|