GitNexus/gitnexus/test/unit/ingestion/di.test.ts
ChunxueLi 19f6731c34
feat(java): resolve SpringContextUtil.getBeans(X.class) dynamic lookups (#2886)
* 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>
2026-08-30 23:09:21 +00:00

1241 lines
45 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>',
});
});
});