mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
* feat(java): resolve SpringContextUtil.getBeans(X.class) dynamic lookups * fix(ingestion): make Spring dynamic lookups graph-correct Capture Java and Kotlin lookups from ASTs and resolve them through scoped type bindings and transitive JVM assignability so emitted INJECTS edges are attributable, cache-safe, and production-tested. Co-authored-by: Cursor <cursoragent@cursor.com> * perf(ingestion): keep Spring lookup capture linear Reuse Java and Kotlin scope-query call nodes instead of rewalking each AST, cache DI subtype closures, and enforce linear scaling with production-path benchmarks in CI. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1241 lines
45 KiB
TypeScript
1241 lines
45 KiB
TypeScript
/**
|
||
* Unit tests for the framework-neutral `di` pipeline phase and the Spring
|
||
* DI field matcher registered behind it (`di-extractors/spring.ts`).
|
||
*
|
||
* Phase-level: verifies that injection-annotated (@Autowired / @Inject)
|
||
* collection-typed fields (List<T>, Set<T>, Collection<T>, Map<K,T>) produce
|
||
* INJECTS edges from the consumer class to every class implementing
|
||
* interface T — using only graph data, no filesystem access — and that
|
||
* Property nodes whose language has no registered matcher are skipped.
|
||
* Non-annotated and @Resource fields produce no edges.
|
||
*
|
||
* Matcher-level: pins `springDiFieldMatcher`'s gate + parse behavior
|
||
* directly, node-shape in / match-or-null out.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
|
||
import { diPhase } from '../../../src/core/ingestion/pipeline-phases/di.js';
|
||
import {
|
||
parseSpringCollectionType,
|
||
SPRING_DI_INJECTION_SITES_PROPERTY,
|
||
SPRING_DI_PROVIDER_PROPERTY,
|
||
springDiFieldMatcher,
|
||
} from '../../../src/core/ingestion/di-extractors/spring.js';
|
||
import { generateId } from '../../../src/lib/utils.js';
|
||
import type {
|
||
PhaseResult,
|
||
PipelineContext,
|
||
} from '../../../src/core/ingestion/pipeline-phases/types.js';
|
||
import type { KnowledgeGraph } from '../../../src/core/graph/types.js';
|
||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function makeCtx(graph: KnowledgeGraph, repoPath = '/tmp/repo'): PipelineContext {
|
||
return { repoPath, graph, onProgress: () => {}, pipelineStart: 0 };
|
||
}
|
||
|
||
function phaseResult<T>(phaseName: string, output: T): PhaseResult<T> {
|
||
return { phaseName, output, durationMs: 0 };
|
||
}
|
||
|
||
function addClass(
|
||
graph: KnowledgeGraph,
|
||
name: string,
|
||
language: string,
|
||
label: NodeLabel = 'Class',
|
||
extra: Record<string, unknown> = {},
|
||
): string {
|
||
const id = generateId(label, name);
|
||
graph.addNode({
|
||
id,
|
||
label,
|
||
properties: { name, filePath: `src/${name}.${language}`, language, ...extra },
|
||
});
|
||
return id;
|
||
}
|
||
|
||
/**
|
||
* Add an Interface node. `qualifiedName` mirrors the production shape for
|
||
* languages with a file-scope package declaration (e.g. Java's
|
||
* `com.a.Shape`); when omitted the node carries only the simple `name`, like
|
||
* production interfaces without a package qualifier.
|
||
*
|
||
* The node id is keyed by `language` + the most qualified identity available
|
||
* (production ids embed file path + qualified name), so two same-simple-name
|
||
* interfaces — cross-package or cross-language — are distinct graph nodes,
|
||
* not a silent `addNode` no-op on a duplicate id.
|
||
*/
|
||
function addInterface(
|
||
graph: KnowledgeGraph,
|
||
name: string,
|
||
language = 'java',
|
||
qualifiedName?: string,
|
||
): string {
|
||
const id = generateId('Interface', `${language}:${qualifiedName ?? name}`);
|
||
graph.addNode({
|
||
id,
|
||
label: 'Interface',
|
||
properties: {
|
||
name,
|
||
filePath: `src/${name}.${language}`,
|
||
language,
|
||
...(qualifiedName !== undefined ? { qualifiedName } : {}),
|
||
},
|
||
});
|
||
return id;
|
||
}
|
||
|
||
/**
|
||
* Link `className` IMPLEMENTS the interface added via `addInterface` with the
|
||
* same (`ifaceName`, `ifaceLanguage`, `ifaceQualifiedName`) identity.
|
||
*/
|
||
function addImplements(
|
||
graph: KnowledgeGraph,
|
||
className: string,
|
||
ifaceName: string,
|
||
ifaceLanguage = 'java',
|
||
ifaceQualifiedName?: string,
|
||
sourceLabel: NodeLabel = 'Class',
|
||
): void {
|
||
const classId = generateId(sourceLabel, className);
|
||
const ifaceId = generateId('Interface', `${ifaceLanguage}:${ifaceQualifiedName ?? ifaceName}`);
|
||
graph.addRelationship({
|
||
id: generateId('IMPLEMENTS', `${classId}->${ifaceId}`),
|
||
sourceId: classId,
|
||
targetId: ifaceId,
|
||
type: 'IMPLEMENTS',
|
||
confidence: 1.0,
|
||
reason: '',
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Add a Property node (a field) to a class and link it via HAS_PROPERTY.
|
||
*
|
||
* Mirrors the production extraction shape: `typeText` is the verbatim type
|
||
* source text with generics preserved (e.g. `List<IFoo>`), stored as
|
||
* `rawDeclaredType`, while `declaredType` is the generics-stripped simple
|
||
* name (e.g. `List`) — derived here from the raw text. `annotations` carries
|
||
* '@Name' strings and is OMITTED when empty (production conditional-spread
|
||
* shape); it defaults to `['@Autowired']` so the common annotated case stays
|
||
* terse. The phase matches on `rawDeclaredType` and gates on `annotations`.
|
||
*
|
||
* `rawDeclaredType` defaults to `typeText`; pass `null` to OMIT the property
|
||
* entirely — the shape a rawDeclaredType-plumbing regression produces, where
|
||
* only the stripped `declaredType` reaches the graph.
|
||
*/
|
||
function addProperty(
|
||
graph: KnowledgeGraph,
|
||
ownerClassName: string,
|
||
fieldName: string,
|
||
typeText: string,
|
||
language = 'java',
|
||
annotations: string[] = ['@Autowired'],
|
||
rawDeclaredType: string | null = typeText,
|
||
): string {
|
||
const ownerId = generateId('Class', ownerClassName);
|
||
const propId = generateId('Property', `${ownerClassName}.${fieldName}`);
|
||
// Production `declaredType` is the simple name with generic args stripped.
|
||
const declaredType = typeText.split('<')[0].trim();
|
||
graph.addNode({
|
||
id: propId,
|
||
label: 'Property',
|
||
properties: {
|
||
name: fieldName,
|
||
filePath: `src/${ownerClassName}.${language}`,
|
||
language,
|
||
declaredType,
|
||
...(rawDeclaredType !== null ? { rawDeclaredType } : {}),
|
||
...(annotations.length > 0 ? { annotations } : {}),
|
||
},
|
||
});
|
||
graph.addRelationship({
|
||
id: generateId('HAS_PROPERTY', `${ownerId}->${propId}`),
|
||
sourceId: ownerId,
|
||
targetId: propId,
|
||
type: 'HAS_PROPERTY',
|
||
confidence: 1.0,
|
||
reason: '',
|
||
});
|
||
return propId;
|
||
}
|
||
|
||
function addProviderDeclaration(
|
||
graph: KnowledgeGraph,
|
||
name: string,
|
||
providedTypeName: string,
|
||
declaredByNodeId?: string,
|
||
): string {
|
||
const id = generateId('CodeElement', `spring-bean:${name}`);
|
||
graph.addNode({
|
||
id,
|
||
label: 'CodeElement',
|
||
properties: {
|
||
name,
|
||
filePath: 'src/AppConfiguration.java',
|
||
language: 'java',
|
||
[SPRING_DI_PROVIDER_PROPERTY]: {
|
||
names: [name],
|
||
providedTypeName,
|
||
...(declaredByNodeId === undefined ? {} : { declaredByNodeId }),
|
||
},
|
||
},
|
||
});
|
||
return id;
|
||
}
|
||
|
||
/** Collect all INJECTS relationships currently in the graph. */
|
||
function injectsEdges(graph: KnowledgeGraph) {
|
||
return graph.relationships.filter((r) => r.type === 'INJECTS');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('di phase', () => {
|
||
it('creates INJECTS edges from consumer to every implementer of T', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
// Interface IFoo
|
||
addInterface(graph, 'IFoo');
|
||
|
||
// Two implementers
|
||
addClass(graph, 'FooImpl1', 'java');
|
||
addClass(graph, 'FooImpl2', 'java');
|
||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||
addImplements(graph, 'FooImpl2', 'IFoo');
|
||
|
||
// Consumer with @Autowired List<IFoo>
|
||
addClass(graph, 'MyService', 'java');
|
||
addProperty(graph, 'MyService', 'foos', 'List<IFoo>');
|
||
|
||
const output = await diPhase.execute(
|
||
makeCtx(graph),
|
||
new Map([['mro', phaseResult('mro', { entries: [] })]]),
|
||
);
|
||
|
||
const edges = injectsEdges(graph);
|
||
const targets = new Set(edges.map((e) => e.targetId));
|
||
const sources = new Set(edges.map((e) => e.sourceId));
|
||
|
||
// Exactly 2 edges, both from MyService
|
||
expect(edges).toHaveLength(2);
|
||
expect(sources.size).toBe(1);
|
||
expect(sources.has(generateId('Class', 'MyService'))).toBe(true);
|
||
|
||
// Targets are the two implementers (not IFoo, not MyService)
|
||
expect(targets.has(generateId('Class', 'FooImpl1'))).toBe(true);
|
||
expect(targets.has(generateId('Class', 'FooImpl2'))).toBe(true);
|
||
|
||
// Edge metadata
|
||
for (const edge of edges) {
|
||
expect(edge.type).toBe('INJECTS');
|
||
expect(edge.confidence).toBe(0.8);
|
||
expect(edge.reason).toBe('Spring DI: @Autowired List<IFoo>');
|
||
}
|
||
|
||
// Output stats
|
||
expect(output.injectsEdges).toBe(2);
|
||
expect(output.fieldsScanned).toBe(1);
|
||
});
|
||
|
||
it('does not create self-edges when the consumer also implements T', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'IFoo');
|
||
addClass(graph, 'FooImpl1', 'java');
|
||
addClass(graph, 'FooImpl2', 'java');
|
||
// MyService ALSO implements IFoo — must not inject into itself
|
||
addClass(graph, 'MyService', 'java');
|
||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||
addImplements(graph, 'FooImpl2', 'IFoo');
|
||
addImplements(graph, 'MyService', 'IFoo');
|
||
addProperty(graph, 'MyService', 'foos', 'List<IFoo>');
|
||
|
||
await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
const edges = injectsEdges(graph);
|
||
const myServiceId = generateId('Class', 'MyService');
|
||
|
||
// No self-edge
|
||
expect(edges.some((e) => e.sourceId === myServiceId && e.targetId === myServiceId)).toBe(false);
|
||
|
||
// Still injects into the OTHER two implementers
|
||
expect(edges).toHaveLength(2);
|
||
const targets = new Set(edges.map((e) => e.targetId));
|
||
expect(targets.has(generateId('Class', 'FooImpl1'))).toBe(true);
|
||
expect(targets.has(generateId('Class', 'FooImpl2'))).toBe(true);
|
||
});
|
||
|
||
it('creates no edges when no @Autowired collection fields exist', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'IFoo');
|
||
addClass(graph, 'FooImpl1', 'java');
|
||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||
addClass(graph, 'MyService', 'java');
|
||
// A non-collection field — should be ignored
|
||
addProperty(graph, 'MyService', 'foo', 'IFoo');
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toHaveLength(0);
|
||
expect(output.injectsEdges).toBe(0);
|
||
expect(output.fieldsScanned).toBe(0);
|
||
});
|
||
|
||
it('creates no edges for a node carrying only the generics-stripped declaredType', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'IFoo');
|
||
addClass(graph, 'FooImpl1', 'java');
|
||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||
addClass(graph, 'MyService', 'java');
|
||
|
||
// Production shape when rawDeclaredType plumbing regresses: only the
|
||
// stripped simple name ("List") reaches the graph (rawDeclaredType: null
|
||
// opt-out). The field IS injection-annotated (it passes the annotation
|
||
// gate), so this pins the rawDeclaredType-missing skip path: the phase
|
||
// must NOT fall back to declaredType — zero edges, zero fields scanned
|
||
// (and an isDev warning flags the plumbing-contract breach).
|
||
addProperty(graph, 'MyService', 'foos', 'List<IFoo>', 'java', ['@Autowired'], null);
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toHaveLength(0);
|
||
expect(output.injectsEdges).toBe(0);
|
||
expect(output.fieldsScanned).toBe(0);
|
||
});
|
||
|
||
it('skips non-Java Property nodes', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'IFoo');
|
||
addClass(graph, 'FooImpl1', 'java');
|
||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||
|
||
// TypeScript consumer — even though the declared type looks like a Spring
|
||
// collection, the language is not Java, so it must be skipped.
|
||
addClass(graph, 'TsConsumer', 'typescript');
|
||
addProperty(graph, 'TsConsumer', 'foos', 'List<IFoo>', 'typescript');
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toHaveLength(0);
|
||
expect(output.injectsEdges).toBe(0);
|
||
expect(output.fieldsScanned).toBe(0);
|
||
});
|
||
|
||
it('handles Set<T>, Collection<T>, and Map<K,T> collection shapes', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'IPlugin');
|
||
addClass(graph, 'CorePlugin', 'java');
|
||
addClass(graph, 'ExtraPlugin', 'java');
|
||
addImplements(graph, 'CorePlugin', 'IPlugin');
|
||
addImplements(graph, 'ExtraPlugin', 'IPlugin');
|
||
|
||
// Three consumers, one per collection shape
|
||
addClass(graph, 'SetConsumer', 'java');
|
||
addProperty(graph, 'SetConsumer', 'plugins', 'Set<IPlugin>');
|
||
|
||
addClass(graph, 'CollectionConsumer', 'java');
|
||
addProperty(graph, 'CollectionConsumer', 'plugins', 'Collection<IPlugin>');
|
||
|
||
addClass(graph, 'MapConsumer', 'java');
|
||
// Map<K,V> — V (IPlugin) is the injected bean type
|
||
addProperty(graph, 'MapConsumer', 'plugins', 'Map<String,IPlugin>');
|
||
|
||
await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
const edges = injectsEdges(graph);
|
||
|
||
// 3 consumers × 2 implementers = 6 edges
|
||
expect(edges).toHaveLength(6);
|
||
|
||
const reasons = new Set(edges.map((e) => e.reason));
|
||
expect(reasons.has('Spring DI: @Autowired Set<IPlugin>')).toBe(true);
|
||
expect(reasons.has('Spring DI: @Autowired Collection<IPlugin>')).toBe(true);
|
||
expect(reasons.has('Spring DI: @Autowired Map<IPlugin>')).toBe(true);
|
||
});
|
||
|
||
it('is a no-op on a graph with no Java Property nodes (early exit)', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'IFoo');
|
||
addClass(graph, 'FooImpl1', 'java');
|
||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||
|
||
// Non-Java property — should trigger early exit
|
||
addClass(graph, 'PyConsumer', 'python');
|
||
addProperty(graph, 'PyConsumer', 'foos', 'List<IFoo>', 'python');
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(output.injectsEdges).toBe(0);
|
||
expect(output.fieldsScanned).toBe(0);
|
||
expect(injectsEdges(graph)).toHaveLength(0);
|
||
});
|
||
|
||
it('creates no edges when the interface T has no implementers', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'INobody');
|
||
addClass(graph, 'MyService', 'java');
|
||
addProperty(graph, 'MyService', 'things', 'List<INobody>');
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toHaveLength(0);
|
||
expect(output.injectsEdges).toBe(0);
|
||
// The field was scanned (1), but no implementers exist
|
||
expect(output.fieldsScanned).toBe(1);
|
||
});
|
||
|
||
it('deduplicates edges when multiple fields inject the same interface', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'IFoo');
|
||
addClass(graph, 'FooImpl1', 'java');
|
||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||
|
||
// Same consumer, two different fields both typed List<IFoo>
|
||
addClass(graph, 'MyService', 'java');
|
||
addProperty(graph, 'MyService', 'foos1', 'List<IFoo>');
|
||
addProperty(graph, 'MyService', 'foos2', 'List<IFoo>');
|
||
|
||
await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
// Only 1 edge MyService → FooImpl1 (deduped by edge ID)
|
||
const edges = injectsEdges(graph);
|
||
expect(edges).toHaveLength(1);
|
||
expect(edges[0].sourceId).toBe(generateId('Class', 'MyService'));
|
||
expect(edges[0].targetId).toBe(generateId('Class', 'FooImpl1'));
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Injection-annotation gate (PR #2200 U2)
|
||
// -------------------------------------------------------------------------
|
||
|
||
it('creates edges for @Inject fields and states @Inject in the reason', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'IFoo');
|
||
addClass(graph, 'FooImpl1', 'java');
|
||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||
addClass(graph, 'MyService', 'java');
|
||
addProperty(graph, 'MyService', 'foos', 'List<IFoo>', 'java', ['@Inject']);
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
const edges = injectsEdges(graph);
|
||
expect(edges).toHaveLength(1);
|
||
expect(edges[0]).toMatchObject({
|
||
sourceId: generateId('Class', 'MyService'),
|
||
targetId: generateId('Class', 'FooImpl1'),
|
||
reason: 'Spring DI: @Inject List<IFoo>',
|
||
});
|
||
expect(output.fieldsScanned).toBe(1);
|
||
});
|
||
|
||
it('creates no edges for a plain (non-annotated) collection field of a known interface', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'IFoo');
|
||
addClass(graph, 'FooImpl1', 'java');
|
||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||
addClass(graph, 'MyService', 'java');
|
||
// The false-positive class the review flagged: a collection field with NO
|
||
// injection annotation is never injected by the container.
|
||
addProperty(graph, 'MyService', 'cache', 'List<IFoo>', 'java', []);
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toHaveLength(0);
|
||
expect(output.injectsEdges).toBe(0);
|
||
expect(output.fieldsScanned).toBe(0);
|
||
});
|
||
|
||
it('creates no edges for @Resource fields (deliberate exclusion)', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'IFoo');
|
||
addClass(graph, 'FooImpl1', 'java');
|
||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||
addClass(graph, 'MyService', 'java');
|
||
// @Resource (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. Its exclusion from
|
||
// the gate is deliberate; this test pins it.
|
||
addProperty(graph, 'MyService', 'named', 'List<IFoo>', 'java', ['@Resource']);
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toHaveLength(0);
|
||
expect(output.injectsEdges).toBe(0);
|
||
expect(output.fieldsScanned).toBe(0);
|
||
});
|
||
|
||
it('matches any injection annotation when the field carries multiple annotations', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'IFoo');
|
||
addClass(graph, 'FooImpl1', 'java');
|
||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||
addClass(graph, 'MyService', 'java');
|
||
// Non-injection annotations surround the injection one — the gate must
|
||
// match @Autowired anywhere in the set, not just first position.
|
||
addProperty(graph, 'MyService', 'foos', 'List<IFoo>', 'java', [
|
||
'@Nullable',
|
||
'@Autowired',
|
||
'@Qualifier',
|
||
]);
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
const edges = injectsEdges(graph);
|
||
expect(edges).toHaveLength(1);
|
||
expect(edges[0]).toMatchObject({
|
||
sourceId: generateId('Class', 'MyService'),
|
||
targetId: generateId('Class', 'FooImpl1'),
|
||
reason: 'Spring DI: @Autowired List<IFoo>',
|
||
});
|
||
expect(output.fieldsScanned).toBe(1);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Matcher registry routing (PR #2200 U3)
|
||
// -------------------------------------------------------------------------
|
||
|
||
it('skips Property nodes whose language has no registered matcher', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'IFoo');
|
||
addClass(graph, 'FooImpl1', 'java');
|
||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||
|
||
// A supported language with NO DI_MATCHERS entry: the node carries the
|
||
// full annotated-collection shape, but no matcher is registered for
|
||
// 'python', so the phase must produce zero candidates.
|
||
addClass(graph, 'PyConsumer', 'python');
|
||
addProperty(graph, 'PyConsumer', 'foos', 'List<IFoo>', 'python', ['@Autowired']);
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toHaveLength(0);
|
||
expect(output.injectsEdges).toBe(0);
|
||
expect(output.fieldsScanned).toBe(0);
|
||
});
|
||
|
||
it('skips Property nodes whose language string is not a SupportedLanguages value', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'IFoo');
|
||
addClass(graph, 'FooImpl1', 'java');
|
||
addImplements(graph, 'FooImpl1', 'IFoo');
|
||
|
||
// An arbitrary language string outside the enum exercises the
|
||
// isSupportedLanguage narrowing guard in the phase's routing.
|
||
addClass(graph, 'FortranConsumer', 'fortran');
|
||
addProperty(graph, 'FortranConsumer', 'foos', 'List<IFoo>', 'fortran', ['@Autowired']);
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toHaveLength(0);
|
||
expect(output.injectsEdges).toBe(0);
|
||
expect(output.fieldsScanned).toBe(0);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Language- and qualified-name-scoped interface resolution (PR #2200 U4)
|
||
// -------------------------------------------------------------------------
|
||
|
||
it.each([
|
||
['com.a.Shape inserted first', ['com.a.Shape', 'com.b.Shape'] as const],
|
||
['com.b.Shape inserted first', ['com.b.Shape', 'com.a.Shape'] as const],
|
||
])(
|
||
'fails closed on a two-package same-simple-name collision (%s)',
|
||
async (_label, [firstQn, secondQn]) => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
// Two Java interfaces named `Shape` in different packages. Insertion
|
||
// order is the it.each parameter: identical assertions across both
|
||
// orders pin order-independence (never last-writer-wins).
|
||
addInterface(graph, 'Shape', 'java', firstQn);
|
||
addInterface(graph, 'Shape', 'java', secondQn);
|
||
addClass(graph, 'ShapeAImpl', 'java');
|
||
addImplements(graph, 'ShapeAImpl', 'Shape', 'java', 'com.a.Shape');
|
||
addClass(graph, 'ShapeBImpl', 'java');
|
||
addImplements(graph, 'ShapeBImpl', 'Shape', 'java', 'com.b.Shape');
|
||
|
||
addClass(graph, 'MyService', 'java');
|
||
addProperty(graph, 'MyService', 'shapes', 'List<Shape>');
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
// Bare `Shape` is ambiguous within Java → fail closed, observable skip.
|
||
expect(injectsEdges(graph)).toHaveLength(0);
|
||
expect(output).toMatchObject({
|
||
injectsEdges: 0,
|
||
fieldsScanned: 1,
|
||
ambiguousSkipped: 1,
|
||
});
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
['typescript interface inserted first', ['typescript', 'java'] as const],
|
||
['java interface inserted first', ['java', 'typescript'] as const],
|
||
])(
|
||
'resolves a bare name only within the candidate language (%s)',
|
||
async (_label, [firstLang, secondLang]) => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
// A TS `interface Shape` and a Java `interface Shape` (unique WITHIN
|
||
// Java). The Java consumer's bare `Shape` must resolve to the Java
|
||
// interface regardless of which language's node was inserted first.
|
||
addInterface(graph, 'Shape', firstLang);
|
||
addInterface(graph, 'Shape', secondLang);
|
||
addClass(graph, 'TsShapeImpl', 'typescript');
|
||
addImplements(graph, 'TsShapeImpl', 'Shape', 'typescript');
|
||
addClass(graph, 'JavaShapeImpl', 'java');
|
||
addImplements(graph, 'JavaShapeImpl', 'Shape', 'java');
|
||
|
||
addClass(graph, 'MyService', 'java');
|
||
addProperty(graph, 'MyService', 'shapes', 'List<Shape>');
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
// Edges ONLY to the Java implementer — the TS implementer never
|
||
// participates in a Java candidate's resolution.
|
||
const edges = injectsEdges(graph);
|
||
expect(edges).toHaveLength(1);
|
||
expect(edges[0]).toMatchObject({
|
||
sourceId: generateId('Class', 'MyService'),
|
||
targetId: generateId('Class', 'JavaShapeImpl'),
|
||
});
|
||
expect(output).toMatchObject({
|
||
injectsEdges: 1,
|
||
fieldsScanned: 1,
|
||
ambiguousSkipped: 0,
|
||
});
|
||
},
|
||
);
|
||
|
||
it('resolves a qualified element type via qualifiedName despite simple-name ambiguity', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'Shape', 'java', 'com.a.Shape');
|
||
addInterface(graph, 'Shape', 'java', 'com.b.Shape');
|
||
addClass(graph, 'ShapeAImpl', 'java');
|
||
addImplements(graph, 'ShapeAImpl', 'Shape', 'java', 'com.a.Shape');
|
||
addClass(graph, 'ShapeBImpl', 'java');
|
||
addImplements(graph, 'ShapeBImpl', 'Shape', 'java', 'com.b.Shape');
|
||
|
||
// The field spells the element type fully qualified — exact qualifiedName
|
||
// lookup, unaffected by the bare-name ambiguity.
|
||
addClass(graph, 'MyService', 'java');
|
||
addProperty(graph, 'MyService', 'shapes', 'List<com.a.Shape>');
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
const edges = injectsEdges(graph);
|
||
expect(edges).toHaveLength(1);
|
||
expect(edges[0]).toMatchObject({
|
||
sourceId: generateId('Class', 'MyService'),
|
||
targetId: generateId('Class', 'ShapeAImpl'),
|
||
reason: 'Spring DI: @Autowired List<com.a.Shape>',
|
||
});
|
||
expect(output).toMatchObject({
|
||
injectsEdges: 1,
|
||
fieldsScanned: 1,
|
||
ambiguousSkipped: 0,
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
['module A inserted first', ['moduleA', 'moduleB'] as const],
|
||
['module B inserted first', ['moduleB', 'moduleA'] as const],
|
||
])(
|
||
'fails closed on a duplicate-qualifiedName collision (%s)',
|
||
async (_label, [firstModule, secondModule]) => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
// Two Java interfaces BOTH carrying qualifiedName `com.a.Shape` — the
|
||
// realistic monorepo shape where the same package+name is duplicated
|
||
// across modules or main/test source roots (a Java qualifiedName has no
|
||
// file-path component). Distinct node ids (production ids embed the
|
||
// file path), identical qualifiedName; insertion order is the it.each
|
||
// parameter: identical assertions across both orders pin
|
||
// order-independence (never last-writer-wins).
|
||
const addModuleShape = (module: string): string => {
|
||
const id = generateId('Interface', `java:${module}:com.a.Shape`);
|
||
graph.addNode({
|
||
id,
|
||
label: 'Interface',
|
||
properties: {
|
||
name: 'Shape',
|
||
filePath: `${module}/src/Shape.java`,
|
||
language: 'java',
|
||
qualifiedName: 'com.a.Shape',
|
||
},
|
||
});
|
||
return id;
|
||
};
|
||
const firstIfaceId = addModuleShape(firstModule);
|
||
const secondIfaceId = addModuleShape(secondModule);
|
||
|
||
// One implementer per module's interface, so a wrong (last-writer-wins)
|
||
// resolution WOULD have implementers to fan out to.
|
||
const implAId = addClass(graph, 'ShapeAImpl', 'java');
|
||
const implBId = addClass(graph, 'ShapeBImpl', 'java');
|
||
graph.addRelationship({
|
||
id: generateId('IMPLEMENTS', `${implAId}->${firstIfaceId}`),
|
||
sourceId: implAId,
|
||
targetId: firstIfaceId,
|
||
type: 'IMPLEMENTS',
|
||
confidence: 1.0,
|
||
reason: '',
|
||
});
|
||
graph.addRelationship({
|
||
id: generateId('IMPLEMENTS', `${implBId}->${secondIfaceId}`),
|
||
sourceId: implBId,
|
||
targetId: secondIfaceId,
|
||
type: 'IMPLEMENTS',
|
||
confidence: 1.0,
|
||
reason: '',
|
||
});
|
||
|
||
// The field spells the element type fully qualified — the dotted branch.
|
||
addClass(graph, 'MyService', 'java');
|
||
addProperty(graph, 'MyService', 'shapes', 'List<com.a.Shape>');
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
// Qualified `com.a.Shape` is ambiguous within Java → fail closed,
|
||
// observable skip — regardless of which module's node indexed first.
|
||
expect(injectsEdges(graph)).toHaveLength(0);
|
||
expect(output).toMatchObject({
|
||
injectsEdges: 0,
|
||
fieldsScanned: 1,
|
||
ambiguousSkipped: 1,
|
||
});
|
||
},
|
||
);
|
||
|
||
it('fails closed even when the consumer shares a package with one collision party (pinned)', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'Shape', 'java', 'com.a.Shape');
|
||
addInterface(graph, 'Shape', 'java', 'com.b.Shape');
|
||
addClass(graph, 'ShapeAImpl', 'java');
|
||
addImplements(graph, 'ShapeAImpl', 'Shape', 'java', 'com.a.Shape');
|
||
addClass(graph, 'ShapeBImpl', 'java');
|
||
addImplements(graph, 'ShapeBImpl', 'Shape', 'java', 'com.b.Shape');
|
||
|
||
// The consumer lives in com.a — Java source would resolve its bare
|
||
// `Shape` to com.a.Shape. Resolution has NO package awareness today, so
|
||
// this is still an ambiguous fail-closed skip. PINNED as current
|
||
// behavior: the same-package tiebreaker is a deliberate, documented
|
||
// follow-up (see the plan's Deferred work); implementing it must flip
|
||
// this test knowingly.
|
||
addClass(graph, 'MyService', 'java', 'Class', { qualifiedName: 'com.a.MyService' });
|
||
addProperty(graph, 'MyService', 'shapes', 'List<Shape>');
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toHaveLength(0);
|
||
expect(output).toMatchObject({
|
||
injectsEdges: 0,
|
||
fieldsScanned: 1,
|
||
ambiguousSkipped: 1,
|
||
});
|
||
});
|
||
|
||
it('falls back to structural providers when no implementation is a known bean', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'Port');
|
||
addClass(graph, 'FirstPort', 'java');
|
||
addClass(graph, 'SecondPort', 'java');
|
||
addImplements(graph, 'FirstPort', 'Port');
|
||
addImplements(graph, 'SecondPort', 'Port');
|
||
addClass(graph, 'Consumer', 'java', 'Class', {
|
||
[SPRING_DI_INJECTION_SITES_PROPERTY]: [
|
||
{
|
||
targetTypeName: 'Port',
|
||
cardinality: 'single',
|
||
reason: 'Spring DI: test constructor',
|
||
},
|
||
],
|
||
});
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toHaveLength(2);
|
||
expect(injectsEdges(graph).every((edge) => edge.confidence === 0.5)).toBe(true);
|
||
expect(output).toMatchObject({ injectsEdges: 2, ambiguousInjections: 1 });
|
||
});
|
||
|
||
it('matches a named provider whose concrete provided type implements the requested interface', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'Gateway');
|
||
addClass(graph, 'DefaultGateway', 'java');
|
||
addImplements(graph, 'DefaultGateway', 'Gateway');
|
||
const providerId = addProviderDeclaration(graph, 'defaultGateway', 'DefaultGateway');
|
||
const consumerId = addClass(graph, 'Consumer', 'java', 'Class', {
|
||
[SPRING_DI_INJECTION_SITES_PROPERTY]: [
|
||
{
|
||
targetTypeName: 'Gateway',
|
||
cardinality: 'single',
|
||
namedSelection: {
|
||
name: 'defaultGateway',
|
||
reason: 'resource name "defaultGateway"',
|
||
},
|
||
reason: 'Spring DI: @Resource gateway: Gateway',
|
||
},
|
||
],
|
||
});
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toEqual([
|
||
expect.objectContaining({
|
||
sourceId: consumerId,
|
||
targetId: providerId,
|
||
confidence: 0.95,
|
||
}),
|
||
]);
|
||
expect(output).toMatchObject({ injectsEdges: 1, ambiguousInjections: 0 });
|
||
});
|
||
|
||
it('excludes the provider declared by a factory method from that method parameter injection', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addInterface(graph, 'Gateway');
|
||
addClass(graph, 'DefaultGateway', 'java');
|
||
addImplements(graph, 'DefaultGateway', 'Gateway');
|
||
const configurationId = addClass(graph, 'AppConfiguration', 'java');
|
||
const factoryMethodId = generateId('Method', 'AppConfiguration.gateway');
|
||
graph.addNode({
|
||
id: factoryMethodId,
|
||
label: 'Method',
|
||
properties: {
|
||
name: 'gateway',
|
||
filePath: 'src/AppConfiguration.java',
|
||
language: 'java',
|
||
[SPRING_DI_INJECTION_SITES_PROPERTY]: [
|
||
{
|
||
targetTypeName: 'Gateway',
|
||
cardinality: 'single',
|
||
edgeSource: 'site',
|
||
reason: 'Spring DI: @Bean method gateway parameter dependency: Gateway',
|
||
},
|
||
],
|
||
},
|
||
});
|
||
graph.addRelationship({
|
||
id: generateId('HAS_METHOD', `${configurationId}->${factoryMethodId}`),
|
||
sourceId: configurationId,
|
||
targetId: factoryMethodId,
|
||
type: 'HAS_METHOD',
|
||
confidence: 1,
|
||
reason: '',
|
||
});
|
||
const selfProviderId = addProviderDeclaration(graph, 'gateway', 'Gateway', factoryMethodId);
|
||
const siblingProviderId = addProviderDeclaration(
|
||
graph,
|
||
'fallbackGateway',
|
||
'Gateway',
|
||
generateId('Method', 'AppConfiguration.fallbackGateway'),
|
||
);
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toEqual([
|
||
expect.objectContaining({
|
||
sourceId: factoryMethodId,
|
||
targetId: siblingProviderId,
|
||
confidence: 0.9,
|
||
}),
|
||
]);
|
||
expect(injectsEdges(graph).some((edge) => edge.targetId === selfProviderId)).toBe(false);
|
||
expect(output).toMatchObject({ injectsEdges: 1, ambiguousInjections: 0 });
|
||
});
|
||
|
||
it('fails closed when one injection type name denotes both a class and an interface', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addClass(graph, 'Port', 'java');
|
||
addInterface(graph, 'Port');
|
||
addClass(graph, 'PortImpl', 'java');
|
||
addImplements(graph, 'PortImpl', 'Port');
|
||
addClass(graph, 'Consumer', 'java', 'Class', {
|
||
[SPRING_DI_INJECTION_SITES_PROPERTY]: [
|
||
{
|
||
targetTypeName: 'Port',
|
||
cardinality: 'single',
|
||
reason: 'Spring DI: test constructor',
|
||
},
|
||
],
|
||
});
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toHaveLength(0);
|
||
expect(output).toMatchObject({ injectsEdges: 0, ambiguousSkipped: 1 });
|
||
});
|
||
|
||
it('documents the legacy collection behavior change for a Class/Interface name collision', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
|
||
addClass(graph, 'Port', 'java');
|
||
addInterface(graph, 'Port');
|
||
addClass(graph, 'PortImpl', 'java');
|
||
addImplements(graph, 'PortImpl', 'Port');
|
||
addClass(graph, 'Consumer', 'java', 'Class', {
|
||
[SPRING_DI_INJECTION_SITES_PROPERTY]: [
|
||
{
|
||
targetTypeName: 'Port',
|
||
cardinality: 'collection',
|
||
reason: 'Spring DI: @Autowired List<Port>',
|
||
},
|
||
],
|
||
});
|
||
|
||
const output = await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
// Before concrete-class lookup was added, the interface alone won and
|
||
// collection injection fanned out to PortImpl. The graph-only resolver
|
||
// cannot disambiguate the colliding Java types, so the new behavior is an
|
||
// intentional fail-closed skip rather than a simple-name guess.
|
||
expect(injectsEdges(graph)).toHaveLength(0);
|
||
expect(output).toMatchObject({ injectsEdges: 0, ambiguousSkipped: 1 });
|
||
});
|
||
|
||
it('walks interface assignability transitively, ignores intermediate interfaces, and terminates cycles', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
const parentId = addInterface(graph, 'Parent');
|
||
const childId = addInterface(graph, 'Child');
|
||
const implId = addClass(graph, 'Impl', 'java');
|
||
addImplements(graph, 'Impl', 'Child');
|
||
graph.addRelationship({
|
||
id: generateId('IMPLEMENTS', `${childId}->${parentId}`),
|
||
sourceId: childId,
|
||
targetId: parentId,
|
||
type: 'IMPLEMENTS',
|
||
confidence: 1,
|
||
reason: '',
|
||
});
|
||
graph.addRelationship({
|
||
id: generateId('IMPLEMENTS', `${parentId}->${childId}`),
|
||
sourceId: parentId,
|
||
targetId: childId,
|
||
type: 'IMPLEMENTS',
|
||
confidence: 1,
|
||
reason: 'malformed-cycle regression guard',
|
||
});
|
||
const consumerId = addClass(graph, 'Consumer', 'java', 'Class', {
|
||
[SPRING_DI_INJECTION_SITES_PROPERTY]: [
|
||
{
|
||
targetTypeName: 'Parent',
|
||
cardinality: 'collection',
|
||
reason: 'Spring dynamic lookup: ctx.getBeans(Parent)',
|
||
},
|
||
],
|
||
});
|
||
|
||
await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toEqual([
|
||
expect.objectContaining({
|
||
sourceId: consumerId,
|
||
targetId: implId,
|
||
type: 'INJECTS',
|
||
confidence: 0.8,
|
||
}),
|
||
]);
|
||
});
|
||
|
||
it('keeps records and enums as concrete interface implementers', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
addInterface(graph, 'Parent');
|
||
const recordId = addClass(graph, 'RecordImpl', 'java', 'Record');
|
||
const enumId = addClass(graph, 'EnumImpl', 'java', 'Enum');
|
||
addImplements(graph, 'RecordImpl', 'Parent', 'java', undefined, 'Record');
|
||
addImplements(graph, 'EnumImpl', 'Parent', 'java', undefined, 'Enum');
|
||
const consumerId = addClass(graph, 'Consumer', 'java', 'Class', {
|
||
[SPRING_DI_INJECTION_SITES_PROPERTY]: [
|
||
{
|
||
targetTypeName: 'Parent',
|
||
cardinality: 'collection',
|
||
reason: 'Spring dynamic lookup: ctx.getBeans(Parent)',
|
||
},
|
||
],
|
||
});
|
||
|
||
await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({ sourceId: consumerId, targetId: recordId }),
|
||
expect.objectContaining({ sourceId: consumerId, targetId: enumId }),
|
||
]),
|
||
);
|
||
expect(injectsEdges(graph)).toHaveLength(2);
|
||
});
|
||
|
||
it('walks class inheritance and prefers concrete Spring bean candidates', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
const baseId = addClass(graph, 'Base', 'java');
|
||
const concreteId = addClass(graph, 'Concrete', 'java', 'Class', {
|
||
[SPRING_DI_PROVIDER_PROPERTY]: { names: ['concrete'] },
|
||
});
|
||
graph.addRelationship({
|
||
id: generateId('EXTENDS', `${concreteId}->${baseId}`),
|
||
sourceId: concreteId,
|
||
targetId: baseId,
|
||
type: 'EXTENDS',
|
||
confidence: 1,
|
||
reason: '',
|
||
});
|
||
const consumerId = addClass(graph, 'Consumer', 'java', 'Class', {
|
||
[SPRING_DI_INJECTION_SITES_PROPERTY]: [
|
||
{
|
||
targetTypeName: 'Base',
|
||
cardinality: 'collection',
|
||
reason: 'Spring dynamic lookup: applicationContext.getBeansOfType(Base)',
|
||
},
|
||
],
|
||
});
|
||
|
||
await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toEqual([
|
||
expect.objectContaining({
|
||
sourceId: consumerId,
|
||
targetId: concreteId,
|
||
confidence: 0.8,
|
||
}),
|
||
]);
|
||
});
|
||
|
||
it('resolves a directly requested concrete class', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
const concreteId = addClass(graph, 'Concrete', 'java');
|
||
const consumerId = addClass(graph, 'Consumer', 'java', 'Class', {
|
||
[SPRING_DI_INJECTION_SITES_PROPERTY]: [
|
||
{
|
||
targetTypeName: 'Concrete',
|
||
cardinality: 'single',
|
||
reason: 'Spring dynamic lookup: ctx.getBean(Concrete)',
|
||
},
|
||
],
|
||
});
|
||
|
||
await diPhase.execute(makeCtx(graph), new Map());
|
||
|
||
expect(injectsEdges(graph)).toEqual([
|
||
expect.objectContaining({
|
||
sourceId: consumerId,
|
||
targetId: concreteId,
|
||
confidence: 0.9,
|
||
}),
|
||
]);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Matcher-level tests (di-extractors/spring.ts)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Hand-build a Property GraphNode for direct matcher calls. */
|
||
function matcherNode(properties: {
|
||
name: string;
|
||
rawDeclaredType?: string;
|
||
annotations?: string[];
|
||
language?: string;
|
||
}): GraphNode {
|
||
const { name, ...rest } = properties;
|
||
return {
|
||
id: generateId('Property', name),
|
||
label: 'Property',
|
||
properties: { name, filePath: `src/Owner.java`, language: 'java', ...rest },
|
||
};
|
||
}
|
||
|
||
describe('springDiFieldMatcher', () => {
|
||
it('returns the parsed match for an @Autowired collection field', () => {
|
||
const match = springDiFieldMatcher(
|
||
matcherNode({ name: 'foos', rawDeclaredType: 'List<IFoo>', annotations: ['@Autowired'] }),
|
||
);
|
||
// Wrapper identity and the gating annotation are visible in the reason.
|
||
expect(match).toEqual({
|
||
elementTypeName: 'IFoo',
|
||
reason: 'Spring DI: @Autowired List<IFoo>',
|
||
});
|
||
});
|
||
|
||
it('parses Map<K,T> to the value type T', () => {
|
||
const match = springDiFieldMatcher(
|
||
matcherNode({
|
||
name: 'plugins',
|
||
rawDeclaredType: 'Map<String,IPlugin>',
|
||
annotations: ['@Inject'],
|
||
}),
|
||
);
|
||
// The Map wrapper and the @Inject annotation are visible in the reason.
|
||
expect(match).toEqual({
|
||
elementTypeName: 'IPlugin',
|
||
reason: 'Spring DI: @Inject Map<IPlugin>',
|
||
});
|
||
});
|
||
|
||
it('returns null for a non-annotated collection field', () => {
|
||
expect(
|
||
springDiFieldMatcher(matcherNode({ name: 'cache', rawDeclaredType: 'List<IFoo>' })),
|
||
).toBe(null);
|
||
});
|
||
|
||
it('returns null for @Resource (deliberate exclusion) and other non-injection annotations', () => {
|
||
expect(
|
||
springDiFieldMatcher(
|
||
matcherNode({ name: 'named', rawDeclaredType: 'List<IFoo>', annotations: ['@Resource'] }),
|
||
),
|
||
).toBe(null);
|
||
expect(
|
||
springDiFieldMatcher(
|
||
matcherNode({ name: 'q', rawDeclaredType: 'List<IFoo>', annotations: ['@Qualifier'] }),
|
||
),
|
||
).toBe(null);
|
||
});
|
||
|
||
it('returns null for an annotated non-collection field', () => {
|
||
expect(
|
||
springDiFieldMatcher(
|
||
matcherNode({ name: 'foo', rawDeclaredType: 'IFoo', annotations: ['@Autowired'] }),
|
||
),
|
||
).toBe(null);
|
||
});
|
||
|
||
it('returns null for an annotated field with no rawDeclaredType (plumbing breach)', () => {
|
||
expect(springDiFieldMatcher(matcherNode({ name: 'foos', annotations: ['@Autowired'] }))).toBe(
|
||
null,
|
||
);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Collection-type parser (PR #2200 U5) — table-driven, exact outputs.
|
||
// Every ACCEPT/REJECT shape here was executed as a failing (or must-keep-
|
||
// passing) case during the review; the module docstring documents each
|
||
// rejection.
|
||
// -------------------------------------------------------------------------
|
||
|
||
it.each<[string, string, { collectionType: string; elementTypeName: string }]>([
|
||
// Existing happy shapes — must keep parsing identically.
|
||
['plain List', 'List<IFoo>', { collectionType: 'List', elementTypeName: 'IFoo' }],
|
||
['plain Set', 'Set<IFoo>', { collectionType: 'Set', elementTypeName: 'IFoo' }],
|
||
[
|
||
'plain Collection',
|
||
'Collection<IFoo>',
|
||
{ collectionType: 'Collection', elementTypeName: 'IFoo' },
|
||
],
|
||
['plain Map', 'Map<String,IPlugin>', { collectionType: 'Map', elementTypeName: 'IPlugin' }],
|
||
// Generic Map KEY: the old `[^,]+` regex stopped at the nested comma and
|
||
// captured garbage — the depth-aware split must yield the value type.
|
||
['generic Map key', 'Map<Pair<A,B>, IFoo>', { collectionType: 'Map', elementTypeName: 'IFoo' }],
|
||
// Bounded wildcards — idiomatic Spring collection injection.
|
||
[
|
||
'upper-bounded wildcard',
|
||
'List<? extends IFoo>',
|
||
{ collectionType: 'List', elementTypeName: 'IFoo' },
|
||
],
|
||
[
|
||
'lower-bounded wildcard',
|
||
'List<? super IFoo>',
|
||
{ collectionType: 'List', elementTypeName: 'IFoo' },
|
||
],
|
||
// Whitespace normalization: padded generics, padded Map comma, and a
|
||
// multi-line declaration (raw tree-sitter .text can span lines).
|
||
['padded element', 'List< IFoo >', { collectionType: 'List', elementTypeName: 'IFoo' }],
|
||
['padded Map comma', 'Map<String , IFoo>', { collectionType: 'Map', elementTypeName: 'IFoo' }],
|
||
[
|
||
'multi-line declaration',
|
||
'Map<\n String,\n IFoo\n>',
|
||
{ collectionType: 'Map', elementTypeName: 'IFoo' },
|
||
],
|
||
// Package-qualified WRAPPER: recognized by its last dotted segment; the
|
||
// qualifier is stripped from the wrapper only.
|
||
[
|
||
'qualified wrapper',
|
||
'java.util.List<IFoo>',
|
||
{ collectionType: 'List', elementTypeName: 'IFoo' },
|
||
],
|
||
[
|
||
'qualified Map wrapper',
|
||
'java.util.Map<String, IFoo>',
|
||
{ collectionType: 'Map', elementTypeName: 'IFoo' },
|
||
],
|
||
// Dotted ELEMENT keeps its dots — resolved via qualifiedName downstream.
|
||
[
|
||
'qualified element',
|
||
'List<com.a.Shape>',
|
||
{ collectionType: 'List', elementTypeName: 'com.a.Shape' },
|
||
],
|
||
[
|
||
'wildcard + qualified element',
|
||
'Set<? extends com.a.Shape>',
|
||
{ collectionType: 'Set', elementTypeName: 'com.a.Shape' },
|
||
],
|
||
])('parseSpringCollectionType accepts %s: %j', (_label, raw, expected) => {
|
||
expect(parseSpringCollectionType(raw)).toEqual(expected);
|
||
});
|
||
|
||
it.each<[string, string]>([
|
||
// Element itself generic — unresolvable as a single interface.
|
||
['nested-generic element', 'Map<String, List<IFoo>>'],
|
||
['nested-generic behind wildcard', 'List<? extends List<IFoo>>'],
|
||
// Unbounded wildcard — no element type to fan out to.
|
||
['unbounded wildcard', 'List<?>'],
|
||
// Arrays — not the collect-all-implementers shape INJECTS models.
|
||
['array type', 'IFoo[]'],
|
||
['array of collections', 'List<IFoo>[]'],
|
||
['array element', 'List<IFoo[]>'],
|
||
// Non-collection types.
|
||
['bare interface', 'IFoo'],
|
||
['non-collection wrapper', 'Optional<IFoo>'],
|
||
// Wrong generic arity.
|
||
['Map with one argument', 'Map<String>'],
|
||
['List with two arguments', 'List<A, B>'],
|
||
['empty argument list', 'List<>'],
|
||
// Block comments inside generics are not stripped — fail closed.
|
||
['block comment in generics', 'List</*x*/IFoo>'],
|
||
// Unbalanced brackets — fail closed.
|
||
['unbalanced brackets', 'List<IFoo>>'],
|
||
])('parseSpringCollectionType rejects %s: %j → null', (_label, raw) => {
|
||
expect(parseSpringCollectionType(raw)).toBeNull();
|
||
});
|
||
|
||
it("ignores node language — routing is the DI_MATCHERS registry's job", () => {
|
||
// The matcher never reads properties.language: a valid Spring shape on a
|
||
// 'python'-tagged node still matches. The phase-level registry routing
|
||
// (tested above) is what keeps non-Java nodes away from this matcher.
|
||
const match = springDiFieldMatcher(
|
||
matcherNode({
|
||
name: 'foos',
|
||
rawDeclaredType: 'List<IFoo>',
|
||
annotations: ['@Autowired'],
|
||
language: 'python',
|
||
}),
|
||
);
|
||
expect(match).toMatchObject({
|
||
elementTypeName: 'IFoo',
|
||
reason: 'Spring DI: @Autowired List<IFoo>',
|
||
});
|
||
});
|
||
});
|