GitNexus/gitnexus/test/integration/spring-di-pipeline.test.ts

578 lines
20 KiB
TypeScript

/**
* End-to-end pipeline coverage for Spring DI collection injection (#2200).
* Real Java sources run through the ACTUAL pipeline (parse worker → field
* extraction → heritage → `di` phase): an `@Autowired List<IFoo>` field must
* yield a Property node carrying the extraction contract
* (`declaredType`/`rawDeclaredType`/`annotations`) and exactly one INJECTS
* edge per implementer of `IFoo` — while a non-annotated collection field of
* the very same type contributes nothing. Both prior no-op incarnations of
* this feature (stripped `declaredType` only; no annotation gate) fail here.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
import type { GraphNode } from 'gitnexus-shared';
const IFOO = `package com.example;
public interface IFoo {}
`;
const FOO_A = `package com.example;
public class FooA implements IFoo {}
`;
const FOO_B = `package com.example;
public class FooB implements IFoo {}
`;
const CONSUMER = `package com.example;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
public class Consumer {
@Autowired private List<IFoo> foos;
private List<IFoo> plain;
}
`;
const WILDCARD_CONSUMER = `package com.example;
import java.util.*;
import org.springframework.beans.factory.annotation.*;
public class WildcardConsumer {
@Autowired private List<IFoo> foos;
}
`;
/** A consumer whose collection fields carry NO injection annotation. */
const PLAIN_CONSUMER = `package com.example;
import java.util.List;
public class PlainConsumer {
private List<IFoo> plain;
private List<IFoo> cache;
}
`;
function findProperty(result: PipelineResult, name: string): GraphNode | undefined {
let found: GraphNode | undefined;
result.graph.forEachNode((n) => {
if (n.label === 'Property' && n.properties.name === name) found = n;
});
return found;
}
/** All INJECTS edges as sorted `sourceName->targetName` pairs (set-equality food). */
function injectsPairs(result: PipelineResult): string[] {
const nameById = new Map<string, string>();
result.graph.forEachNode((n) => nameById.set(n.id, String(n.properties.name)));
return result.graph.relationships
.filter((r) => r.type === 'INJECTS')
.map((r) => `${nameById.get(r.sourceId)}->${nameById.get(r.targetId)}`)
.sort();
}
function injectsDetails(result: PipelineResult) {
const nameById = new Map<string, string>();
result.graph.forEachNode((node) => nameById.set(node.id, String(node.properties.name)));
return result.graph.relationships
.filter((relationship) => relationship.type === 'INJECTS')
.map((relationship) => ({
pair: `${nameById.get(relationship.sourceId)}->${nameById.get(relationship.targetId)}`,
confidence: relationship.confidence,
reason: relationship.reason,
}))
.sort((left, right) => left.pair.localeCompare(right.pair));
}
describe('Spring DI collection-injection pipeline (#2200)', () => {
let dir: string;
let result: PipelineResult;
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-di-'));
fs.writeFileSync(path.join(dir, 'IFoo.java'), IFOO);
fs.writeFileSync(path.join(dir, 'FooA.java'), FOO_A);
fs.writeFileSync(path.join(dir, 'FooB.java'), FOO_B);
fs.writeFileSync(path.join(dir, 'Consumer.java'), CONSUMER);
result = await runPipelineFromRepo(dir, () => {}, {});
}, 60_000);
afterAll(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
it('extracts the annotated field with the full Property contract (declaredType / rawDeclaredType / annotations)', () => {
// THE extraction pin: both no-op incarnations broke exactly here — the
// graph never carried a matchable generic type or the gating annotation.
const foos = findProperty(result, 'foos');
expect(foos, 'Consumer.foos should be a Property node').toBeTruthy();
expect(foos!.properties).toMatchObject({
declaredType: 'List',
rawDeclaredType: 'List<IFoo>',
});
expect(foos!.properties.annotations).toContain('@Autowired');
});
it('extracts the non-annotated field with the same type contract but NO annotations key', () => {
const plain = findProperty(result, 'plain');
expect(plain, 'Consumer.plain should be a Property node').toBeTruthy();
expect(plain!.properties).toMatchObject({
declaredType: 'List',
rawDeclaredType: 'List<IFoo>',
});
// Empty annotation lists are OMITTED (production conditional-spread shape).
expect(plain!.properties.annotations).toBeUndefined();
});
it('emits exactly the two Consumer→implementer INJECTS edges — nothing from `plain`, no self-edges', () => {
// Full set-equality on ALL INJECTS edges in the graph: an extra edge
// (e.g. one fanned out from the non-annotated `plain` field, or a
// self-edge) fails this, as does a missing implementer.
expect(injectsPairs(result)).toEqual(['Consumer->FooA', 'Consumer->FooB']);
});
});
describe('Spring DI wildcard-import collection fallback (#2200, #2414)', () => {
let dir: string;
let result: PipelineResult;
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-di-wildcard-'));
fs.writeFileSync(path.join(dir, 'IFoo.java'), IFOO);
fs.writeFileSync(path.join(dir, 'FooA.java'), FOO_A);
fs.writeFileSync(path.join(dir, 'FooB.java'), FOO_B);
fs.writeFileSync(path.join(dir, 'WildcardConsumer.java'), WILDCARD_CONSUMER);
result = await runPipelineFromRepo(dir, () => {}, {});
}, 60_000);
afterAll(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
it('preserves collection edges when multiple wildcard imports prevent annotation FQN resolution', () => {
expect(injectsPairs(result)).toEqual(['WildcardConsumer->FooA', 'WildcardConsumer->FooB']);
});
});
describe('Spring DI pipeline negative control: no injection annotations anywhere (#2200)', () => {
let dir: string;
let result: PipelineResult;
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-di-neg-'));
fs.writeFileSync(path.join(dir, 'IFoo.java'), IFOO);
fs.writeFileSync(path.join(dir, 'FooA.java'), FOO_A);
fs.writeFileSync(path.join(dir, 'FooB.java'), FOO_B);
fs.writeFileSync(path.join(dir, 'PlainConsumer.java'), PLAIN_CONSUMER);
result = await runPipelineFromRepo(dir, () => {}, {});
}, 60_000);
afterAll(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
it('emits zero INJECTS edges when no field carries an injection annotation', () => {
// The interface + implementers exist, so fan-out WOULD fire if the
// annotation gate regressed — the pre-U2 false-positive class.
expect(injectsPairs(result)).toEqual([]);
});
});
describe('Spring standard injection pipeline (#2414)', () => {
let dir: string;
let result: PipelineResult;
const sources: Record<string, string> = {
'PaymentGateway.java': `package com.example;
public interface PaymentGateway {}
`,
'FastGateway.java': `package com.example;
import org.springframework.stereotype.Service;
import org.springframework.context.annotation.Primary;
@Service @Primary
public class FastGateway implements PaymentGateway {}
`,
'SlowGateway.java': `package com.example;
import org.springframework.stereotype.Service;
@Service("slowGateway")
public class SlowGateway implements PaymentGateway {}
`,
'ConcreteRepo.java': `package com.example;
import org.springframework.stereotype.Repository;
@Repository
public class ConcreteRepo {}
`,
'S3Client.java': `package com.example;
import org.springframework.stereotype.Service;
@Service
public class S3Client {}
`,
'DigitBeanNameConsumer.java': `package com.example;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Qualifier;
@Service
public class DigitBeanNameConsumer {
public DigitBeanNameConsumer(@Qualifier("s3Client") S3Client client) {}
}
`,
'EmptyParenService.java': `package com.example;
import org.springframework.stereotype.Service;
@Service()
public class EmptyParenService {}
`,
'EmptyParenConsumer.java': `package com.example;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Qualifier;
@Service
public class EmptyParenConsumer {
public EmptyParenConsumer(
@Qualifier("emptyParenService") EmptyParenService service
) {}
}
`,
'ConstructorConsumer.java': `package com.example;
import org.springframework.stereotype.Service;
@Service
public class ConstructorConsumer {
public ConstructorConsumer(PaymentGateway gateway, ConcreteRepo repo) {}
}
`,
'ExplicitConstructorConsumer.java': `package com.example;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
@Service
public class ExplicitConstructorConsumer {
public ExplicitConstructorConsumer() {}
@Autowired public ExplicitConstructorConsumer(ConcreteRepo repo) {}
}
`,
'QualifiedConsumer.java': `package com.example;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Qualifier;
@Service
public class QualifiedConsumer {
public QualifiedConsumer(@Qualifier("slowGateway") PaymentGateway gateway) {}
}
`,
'DynamicQualifierConsumer.java': `package com.example;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Qualifier;
@Service
public class DynamicQualifierConsumer {
private static final String GATEWAY = "slowGateway";
public DynamicQualifierConsumer(@Qualifier(GATEWAY) PaymentGateway gateway) {}
}
`,
'DynamicCollectionQualifierConsumer.java': `package com.example;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
@Service
public class DynamicCollectionQualifierConsumer {
private static final String GATEWAY = "slowGateway";
@Autowired @Qualifier(GATEWAY) private List<PaymentGateway> gateways;
}
`,
'PlainConstructorConsumer.java': `package com.example;
public class PlainConstructorConsumer {
public PlainConstructorConsumer(PaymentGateway gateway) {}
}
`,
'FieldConsumer.java': `package com.example;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
@Service
public class FieldConsumer {
@Autowired private PaymentGateway gateway;
}
`,
'QualifiedCollectionConsumer.java': `package com.example;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
@Service
public class QualifiedCollectionConsumer {
@Autowired @Qualifier("slowGateway") private List<PaymentGateway> gateways;
}
`,
'SetterConsumer.java': `package com.example;
import org.springframework.stereotype.Service;
import jakarta.inject.Inject;
@Service
public class SetterConsumer {
@Inject public void setRepo(ConcreteRepo repo) {}
}
`,
'Formatter.java': `package com.example;
public interface Formatter {}
`,
'JsonFormatter.java': `package com.example;
import org.springframework.stereotype.Service;
@Service
public class JsonFormatter implements Formatter {}
`,
'XmlFormatter.java': `package com.example;
import org.springframework.stereotype.Service;
@Service
public class XmlFormatter implements Formatter {}
`,
'AmbiguousConsumer.java': `package com.example;
import org.springframework.stereotype.Service;
@Service
public class AmbiguousConsumer {
public AmbiguousConsumer(Formatter formatter) {}
}
`,
};
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-standard-di-'));
for (const [fileName, source] of Object.entries(sources)) {
fs.writeFileSync(path.join(dir, fileName), source);
}
result = await runPipelineFromRepo(dir, () => {}, {});
}, 60_000);
afterAll(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
it('resolves implicit constructor, concrete, field, setter, qualifier, and primary injection', () => {
const details = injectsDetails(result);
expect(details.map((detail) => detail.pair)).toEqual([
'AmbiguousConsumer->JsonFormatter',
'AmbiguousConsumer->XmlFormatter',
'ConstructorConsumer->ConcreteRepo',
'ConstructorConsumer->FastGateway',
'DigitBeanNameConsumer->S3Client',
'EmptyParenConsumer->EmptyParenService',
'ExplicitConstructorConsumer->ConcreteRepo',
'FieldConsumer->FastGateway',
'QualifiedCollectionConsumer->SlowGateway',
'QualifiedConsumer->SlowGateway',
'SetterConsumer->ConcreteRepo',
]);
expect(
details.find((detail) => detail.pair === 'ConstructorConsumer->FastGateway'),
).toMatchObject({ confidence: 0.95, reason: expect.stringContaining('selected @Primary') });
expect(
details.find((detail) => detail.pair === 'QualifiedConsumer->SlowGateway'),
).toMatchObject({
confidence: 0.95,
reason: expect.stringContaining('qualifier "slowGateway"'),
});
expect(
details.find((detail) => detail.pair === 'SetterConsumer->ConcreteRepo')?.reason,
).toContain('@Inject method');
});
it('surfaces unresolved single-bean ambiguity as multiple low-confidence candidates', () => {
const ambiguous = injectsDetails(result).filter((detail) =>
detail.pair.startsWith('AmbiguousConsumer->'),
);
expect(ambiguous).toHaveLength(2);
expect(ambiguous.every((detail) => detail.confidence === 0.5)).toBe(true);
expect(ambiguous.every((detail) => detail.reason.includes('ambiguous candidates'))).toBe(true);
});
it('fails closed for unmanaged implicit constructors and unresolved dynamic qualifiers', () => {
const pairs = injectsDetails(result).map((detail) => detail.pair);
expect(pairs.some((pair) => pair.startsWith('PlainConstructorConsumer->'))).toBe(false);
expect(pairs.some((pair) => pair.startsWith('DynamicQualifierConsumer->'))).toBe(false);
expect(pairs.some((pair) => pair.startsWith('DynamicCollectionQualifierConsumer->'))).toBe(
false,
);
});
});
describe('Kotlin Spring standard injection pipeline (#2414)', () => {
let dir: string;
let result: PipelineResult;
const sources: Record<string, string> = {
'PaymentGateway.kt': `package com.example
interface PaymentGateway
`,
'FastGateway.kt': `package com.example
import org.springframework.context.annotation.Primary
import org.springframework.stereotype.Service
@Service @Primary
class FastGateway : PaymentGateway
`,
'SlowGateway.kt': `package com.example
import org.springframework.stereotype.Service
@Service("slowGateway")
class SlowGateway : PaymentGateway
`,
'ConcreteRepo.kt': `package com.example
import org.springframework.stereotype.Repository
@Repository
class ConcreteRepo
`,
'ConstructorConsumer.kt': `package com.example
import org.springframework.stereotype.Service
@Service
class ConstructorConsumer(
val gateway: PaymentGateway,
repo: ConcreteRepo?,
)
`,
'ExplicitConstructorConsumer.kt': `package com.example
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
@Service
class ExplicitConstructorConsumer() {
@Autowired constructor(repo: ConcreteRepo) : this()
}
`,
'QualifiedConsumer.kt': `package com.example
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Service
@Service
class QualifiedConsumer(
@param:Qualifier("slowGateway") gateway: PaymentGateway,
)
`,
'NamedConsumer.kt': `package com.example
import jakarta.inject.Named
import org.springframework.stereotype.Service
@Service
class NamedConsumer(@Named("slowGateway") gateway: PaymentGateway)
`,
'FieldConsumer.kt': `package com.example
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
@Service
class FieldConsumer {
@field:Autowired
lateinit var gateway: PaymentGateway
}
`,
'QualifiedFieldConsumer.kt': `package com.example
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Service
@Service
class QualifiedFieldConsumer {
@field:Autowired
@field:Qualifier("slowGateway")
lateinit var gateway: PaymentGateway
}
`,
'SetterPropertyConsumer.kt': `package com.example
import jakarta.inject.Inject
import org.springframework.stereotype.Service
@Service
class SetterPropertyConsumer {
@set:Inject
var repo: ConcreteRepo? = null
}
`,
'MethodConsumer.kt': `package com.example
import javax.inject.Inject
import org.springframework.stereotype.Service
@Service
class MethodConsumer {
@Inject fun setRepo(repo: ConcreteRepo) {}
}
`,
'CollectionConsumer.kt': `package com.example
import org.springframework.stereotype.Service
@Service
class CollectionConsumer(val gateways: List<out PaymentGateway>)
`,
'MutableCollectionConsumer.kt': `package com.example
import org.springframework.stereotype.Service
@Service
class MutableCollectionConsumer(val gateways: MutableList<PaymentGateway?>?)
`,
'PlainConstructorConsumer.kt': `package com.example
class PlainConstructorConsumer(gateway: PaymentGateway)
`,
'MultipleConstructorConsumer.kt': `package com.example
import org.springframework.stereotype.Service
@Service
class MultipleConstructorConsumer(gateway: PaymentGateway) {
constructor(repo: ConcreteRepo) : this(FastGateway())
}
`,
'GetterTargetConsumer.kt': `package com.example
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
@Service
class GetterTargetConsumer {
@get:Autowired
var gateway: PaymentGateway? = null
}
`,
'DynamicQualifierConsumer.kt': `package com.example
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Service
const val GATEWAY = "slowGateway"
@Service
class DynamicQualifierConsumer(@Qualifier(GATEWAY) gateway: PaymentGateway)
`,
};
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-kotlin-spring-standard-di-'));
for (const [fileName, source] of Object.entries(sources)) {
fs.writeFileSync(path.join(dir, fileName), source);
}
result = await runPipelineFromRepo(dir, () => {}, {});
}, 60_000);
afterAll(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
it('resolves Kotlin primary/secondary constructors, properties, methods, qualifiers, primary, nullable types, and projections', () => {
const details = injectsDetails(result);
expect(details.map((detail) => detail.pair)).toEqual([
'CollectionConsumer->FastGateway',
'CollectionConsumer->SlowGateway',
'ConstructorConsumer->ConcreteRepo',
'ConstructorConsumer->FastGateway',
'ExplicitConstructorConsumer->ConcreteRepo',
'FieldConsumer->FastGateway',
'MethodConsumer->ConcreteRepo',
'MutableCollectionConsumer->FastGateway',
'MutableCollectionConsumer->SlowGateway',
'NamedConsumer->SlowGateway',
'QualifiedConsumer->SlowGateway',
'QualifiedFieldConsumer->SlowGateway',
'SetterPropertyConsumer->ConcreteRepo',
]);
expect(
details.find((detail) => detail.pair === 'ConstructorConsumer->FastGateway'),
).toMatchObject({ confidence: 0.95, reason: expect.stringContaining('selected @Primary') });
expect(
details.find((detail) => detail.pair === 'QualifiedConsumer->SlowGateway'),
).toMatchObject({
confidence: 0.95,
reason: expect.stringContaining('qualifier "slowGateway"'),
});
expect(
details.find((detail) => detail.pair === 'SetterPropertyConsumer->ConcreteRepo')?.reason,
).toContain('@Inject property');
});
it('fails closed for unmanaged or ambiguous constructors, unsupported getter targets, and dynamic qualifiers', () => {
const pairs = injectsPairs(result);
expect(pairs.some((pair) => pair.startsWith('PlainConstructorConsumer->'))).toBe(false);
expect(pairs.some((pair) => pair.startsWith('MultipleConstructorConsumer->'))).toBe(false);
expect(pairs.some((pair) => pair.startsWith('GetterTargetConsumer->'))).toBe(false);
expect(pairs.some((pair) => pair.startsWith('DynamicQualifierConsumer->'))).toBe(false);
});
});