feat(spring): resolve constructor and standard injection (#2632)

This commit is contained in:
MyShining 2026-07-24 15:25:38 +08:00 committed by GitHub
parent e34967eed5
commit 4af6fe8587
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2177 additions and 242 deletions

View file

@ -127,14 +127,14 @@ export type RelationshipType =
| 'ENTRY_POINT_OF'
| 'WRAPS'
| 'QUERIES'
/** Dependency-injection edge: a consumer class receives every implementer
* of interface `T` via a container-injected collection-typed field
* (`List<T>`, `Set<T>`, `Collection<T>`, or `Map<K,T>`). Precondition: the
* field carries an injection annotation recognized by a per-language
* matcher registered in `di-extractors/` (Java/Spring today: `@Autowired`
* or `@Inject`; `@Resource` is excluded by-name-first semantics).
* Source = the consumer Class node (the one owning the field).
* Target = an implementing Class node.
/** Dependency-injection edge: a consumer class receives a likely provider
* through constructor, field, method, or collection injection. A
* per-language resolver identifies the site and provider metadata; the
* shared DI phase uses type heritage, qualifier names, and preferred
* provider markers to resolve it. Ambiguous single injection is represented
* by multiple lower-confidence edges instead of a fabricated exact target.
* Source = the consumer Class node (the one owning the injection site).
* Target = a concrete provider Class node.
* Framework specifics live in the `reason` payload (e.g.
* `Spring DI: @Autowired List<T>`), not in this type contract.
* Lets Cypher queries trace which beans the container injects into a given

View file

@ -1,61 +1,77 @@
/**
* Per-language DI field-matcher registry the lookup the generic `di`
* pipeline phase uses to decide whether a `Property` node is a
* dependency-injection fan-out candidate.
* Per-language DI resolver registry the lookup the generic `di` pipeline
* phase uses to discover injection sites and provider metadata on graph nodes.
*
* Mirrors `scope-resolution/pipeline/registry.ts` (`SCOPE_RESOLVERS`): a
* single-valued `ReadonlyMap<SupportedLanguages, DiFieldMatcher>` consumed by
* single-valued `ReadonlyMap<SupportedLanguages, DiResolver>` consumed by
* a framework-neutral phase, so no language or framework names leak into
* shared pipeline code. Adding a framework is two lines: implement a
* `DiFieldMatcher` in `di-extractors/<framework>.ts` and register it here.
* shared pipeline code. Adding a framework means implementing a `DiResolver`
* in `di-extractors/<framework>.ts` and registering it here.
*
* Scope honesty: matchers are per-language *field-injection* matchers.
* Constructor injection (the dominant modern Spring idiom) lives on
* Method/parameter nodes and would require widening the phase's routing
* deliberately out of scope (see the plan's Deferred work). The registry is
* single-valued per language, matching the `SCOPE_RESOLVERS` shape; widen the
* value type to arrays only when a second same-language framework actually
* lands (a one-line type change then).
* The registry is single-valued per language, matching the `SCOPE_RESOLVERS`
* shape; widen the value type to arrays only when a second same-language
* framework actually lands. Java and Kotlin share Spring's attached metadata
* contract while retaining language-specific syntax capture.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { GraphNode } from 'gitnexus-shared';
import { springDiFieldMatcher } from './spring.js';
import { springDiResolver } from './spring.js';
/** A successful DI field match, produced by a per-language matcher. */
export interface DiFieldMatch {
/** The element type name `T` — the injected bean interface. */
elementTypeName: string;
/** A successful injection-site match, produced by a per-language resolver. */
export interface DiInjectionMatch {
/** The requested dependency type name. */
targetTypeName: string;
/** A collection receives every matching provider; a single site may need
* framework-specific named/preferred-provider disambiguation. */
cardinality: 'single' | 'collection';
/** Statically known provider name requested at the injection site. The
* resolver owns the human-readable explanation of that selection. */
namedSelection?: {
name: string;
reason: string;
};
/** Human-readable edge reason. Framework specifics (names, idioms,
* collection wrapper, gating annotation) live in this payload so the
* shared `di` phase stays framework-neutral. */
reason: string;
}
/**
* A per-language field-injection matcher: given a `Property` node, return the
* parsed DI match or `null` when the field is not container-injected. The
* matcher receives the whole node (not pre-plucked fields) so the shared
* phase stays ignorant of which properties matter.
*/
export type DiFieldMatcher = (node: GraphNode) => DiFieldMatch | null;
/** Provider metadata used by the shared resolver without naming a framework. */
export interface DiProviderMatch {
/** Provider names and aliases that can satisfy a named injection. */
names: readonly string[];
/** Present when the framework marks this as its preferred candidate. The
* value is appended to the emitted edge reason when it disambiguates. */
preferenceReason?: string;
}
/** Per-language DI behavior. Matchers receive whole nodes so the shared phase
* remains ignorant of language/framework-specific property shapes. */
export interface DiResolver {
matchInjectionSites(node: GraphNode): readonly DiInjectionMatch[];
matchProvider(node: GraphNode): DiProviderMatch | null;
}
/** All `SupportedLanguages` string values, for narrowing raw graph strings. */
const SUPPORTED_LANGUAGE_VALUES: ReadonlySet<string> = new Set(Object.values(SupportedLanguages));
/**
* Type guard narrowing an arbitrary graph `language` string to
* `SupportedLanguages`, so `DI_MATCHERS.get()` needs no cast.
* `SupportedLanguages`, so `DI_RESOLVERS.get()` needs no cast.
*/
export function isSupportedLanguage(value: string): value is SupportedLanguages {
return SUPPORTED_LANGUAGE_VALUES.has(value);
}
/** Map of `SupportedLanguages` `DiFieldMatcher`. The `di` phase routes each
* `Property` node here by `node.properties.language`; no entry the node is
/** Map of `SupportedLanguages` `DiResolver`. The `di` phase routes each
* graph node here by `node.properties.language`; no entry the node is
* skipped. This is the single source of truth for which languages (and,
* transitively, frameworks) produce INJECTS edges. */
export const DI_MATCHERS: ReadonlyMap<SupportedLanguages, DiFieldMatcher> = new Map<
export const DI_RESOLVERS: ReadonlyMap<SupportedLanguages, DiResolver> = new Map<
SupportedLanguages,
DiFieldMatcher
>([[SupportedLanguages.Java, springDiFieldMatcher]]);
DiResolver
>([
[SupportedLanguages.Java, springDiResolver],
[SupportedLanguages.Kotlin, springDiResolver],
]);

View file

@ -51,13 +51,15 @@
* between `<` and the element) are NOT stripped and fail closed
* acceptable.
*
* Registered under `SupportedLanguages.Java` in `./index.ts` (`DI_MATCHERS`);
* language routing is the registry's job, so the matcher itself never reads
* `node.properties.language`.
* Registered for Java and Kotlin in `./index.ts` (`DI_RESOLVERS`); language
* routing is the registry's job, so the matcher itself never reads
* `node.properties.language`. Kotlin's AST-backed class metadata is the
* primary path because Kotlin Property extraction intentionally exposes less
* annotation/type syntax than Java's legacy field contract.
*/
import type { GraphNode } from 'gitnexus-shared';
import type { DiFieldMatch, DiFieldMatcher } from './index.js';
import type { DiInjectionMatch, DiProviderMatch, DiResolver } from './index.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
@ -84,6 +86,17 @@ const WILDCARD_SUPER_PREFIX = '? super ';
* punctuation) fails closed. */
const JAVA_TYPE_NAME_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/;
/** Ephemeral Class-node property populated by Java's post-resolution Spring
* metadata hook. It is consumed in the same pipeline run before persistence. */
export const SPRING_DI_INJECTION_SITES_PROPERTY = 'springDiInjectionSites';
/** Ephemeral Class-node property carrying Spring bean names / @Primary. */
export const SPRING_DI_PROVIDER_PROPERTY = 'springDiProvider';
/** Marker placed on Property nodes whose richer AST-backed field fact was
* attached to the owning Class, suppressing the legacy collection fallback. */
export const SPRING_DI_CAPTURED_FIELD_PROPERTY = 'springDiCapturedField';
/**
* Split a generic-argument list on TOP-LEVEL commas only, tracking `<`/`>`
* bracket depth so nested generics (e.g. the `Pair<A,B>` key in
@ -181,13 +194,33 @@ export function parseSpringCollectionType(
return { collectionType: wrapper, elementTypeName };
}
/** Parse either a supported collect-all type or a standard single bean type. */
export function parseSpringInjectionType(
rawDeclaredType: string,
): { targetTypeName: string; cardinality: 'single' | 'collection'; displayType: string } | null {
const collection = parseSpringCollectionType(rawDeclaredType);
if (collection !== null) {
return {
targetTypeName: collection.elementTypeName,
cardinality: 'collection',
displayType: `${collection.collectionType}<${collection.elementTypeName}>`,
};
}
const normalized = rawDeclaredType.replace(/\s+/g, '').trim();
if (!JAVA_TYPE_NAME_PATTERN.test(normalized)) return null;
return { targetTypeName: normalized, cardinality: 'single', displayType: normalized };
}
/**
* Match a `Property` node against Spring's collection-injection shape.
*
* Returns the parsed match (with a Spring-specific human-readable `reason`
* payload) or `null` when the field is not container-injected.
*/
export const springDiFieldMatcher: DiFieldMatcher = (node: GraphNode): DiFieldMatch | null => {
export const springDiFieldMatcher = (
node: GraphNode,
): { elementTypeName: string; reason: string } | null => {
// Injection-annotation gate: only fields the container actually
// injects (@Autowired / @Inject) are candidates. Plain collection
// fields are never injected; @Resource is deliberately excluded
@ -220,3 +253,62 @@ export const springDiFieldMatcher: DiFieldMatcher = (node: GraphNode): DiFieldMa
reason: `Spring DI: ${matchedAnnotation} ${parsed.collectionType}<${parsed.elementTypeName}>`,
};
};
function isInjectionMatch(value: unknown): value is DiInjectionMatch {
if (value === null || typeof value !== 'object') return false;
const match = value as Partial<DiInjectionMatch>;
const namedSelection = match.namedSelection;
return (
typeof match.targetTypeName === 'string' &&
(match.cardinality === 'single' || match.cardinality === 'collection') &&
typeof match.reason === 'string' &&
(namedSelection === undefined ||
(typeof namedSelection === 'object' &&
namedSelection !== null &&
typeof namedSelection.name === 'string' &&
typeof namedSelection.reason === 'string'))
);
}
function isProviderMatch(value: unknown): value is DiProviderMatch {
if (value === null || typeof value !== 'object') return false;
const provider = value as Partial<DiProviderMatch>;
return (
Array.isArray(provider.names) &&
provider.names.every((name) => typeof name === 'string') &&
(provider.preferenceReason === undefined || typeof provider.preferenceReason === 'string')
);
}
/** JVM/Spring resolver registered behind the framework-neutral DI seam. */
export const springDiResolver: DiResolver = {
matchInjectionSites(node): readonly DiInjectionMatch[] {
const matches: DiInjectionMatch[] = [];
// Preserve the existing Property-node collection contract for hand-built
// graphs and for compatibility with pre-#2414 extraction fixtures.
if (node.label === 'Property' && node.properties[SPRING_DI_CAPTURED_FIELD_PROPERTY] !== true) {
const field = springDiFieldMatcher(node);
if (field !== null) {
matches.push({
targetTypeName: field.elementTypeName,
cardinality: 'collection',
reason: field.reason,
});
}
}
const attached = node.properties[SPRING_DI_INJECTION_SITES_PROPERTY];
if (Array.isArray(attached)) {
for (const candidate of attached) {
if (isInjectionMatch(candidate)) matches.push(candidate);
}
}
return matches;
},
matchProvider(node): DiProviderMatch | null {
const attached = node.properties[SPRING_DI_PROVIDER_PROPERTY];
return isProviderMatch(attached) ? attached : null;
},
};

View file

@ -0,0 +1,317 @@
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { DiInjectionMatch, DiProviderMatch } from '../../di-extractors/index.js';
import {
parseSpringInjectionType,
SPRING_DI_CAPTURED_FIELD_PROPERTY,
SPRING_DI_INJECTION_SITES_PROPERTY,
SPRING_DI_PROVIDER_PROPERTY,
} from '../../di-extractors/spring.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
import { createSpringAnnotationNameResolver } from './bean-candidates.js';
import { SPRING_BEAN_STEREOTYPES } from './bean-catalog.js';
export interface SpringDiAnnotationFact {
readonly name: string;
readonly text: string;
}
export interface SpringDiDependencyFact<Annotation extends SpringDiAnnotationFact> {
readonly name: string;
readonly rawType: string;
readonly annotations: readonly Annotation[];
}
export interface SpringDiInjectionSiteFact<
Annotation extends SpringDiAnnotationFact,
SiteKind extends string,
> {
readonly kind: SiteKind;
readonly memberName: string;
readonly implicitConstructor: boolean;
readonly annotations: readonly Annotation[];
readonly dependencies: readonly SpringDiDependencyFact<Annotation>[];
}
export interface SpringDiClassFact<
Annotation extends SpringDiAnnotationFact,
SiteKind extends string,
> {
readonly classScopeId: ScopeId;
readonly classAnnotations: readonly Annotation[];
readonly injectionSites: readonly SpringDiInjectionSiteFact<Annotation, SiteKind>[];
}
const INJECTION_ANNOTATIONS = new Set([
'org.springframework.beans.factory.annotation.Autowired',
'jakarta.inject.Inject',
'javax.inject.Inject',
]);
const QUALIFIER_ANNOTATIONS = new Set([
'org.springframework.beans.factory.annotation.Qualifier',
'jakarta.inject.Named',
'javax.inject.Named',
]);
const PRIMARY_ANNOTATIONS = new Set(['org.springframework.context.annotation.Primary']);
const RESOLVABLE_DI_ANNOTATIONS = new Set([
...SPRING_BEAN_STEREOTYPES.keys(),
...INJECTION_ANNOTATIONS,
...QUALIFIER_ANNOTATIONS,
...PRIMARY_ANNOTATIONS,
]);
const CAPTURE_RELEVANT_ANNOTATIONS = new Set([
'Autowired',
'Inject',
'Qualifier',
'Named',
'Primary',
'Component',
'Service',
'Repository',
'Controller',
'RestController',
'Configuration',
]);
const STEREOTYPE_SIMPLE_NAMES = new Set(
[...SPRING_BEAN_STEREOTYPES.keys()].map((name) => springAnnotationSimpleName(name)),
);
export function springAnnotationSimpleName(name: string): string {
const separator = name.lastIndexOf('.');
return separator === -1 ? name : name.slice(separator + 1);
}
export function hasSpringDiRelevantAnnotation(
annotations: readonly SpringDiAnnotationFact[],
): boolean {
return annotations.some((annotation) =>
CAPTURE_RELEVANT_ANNOTATIONS.has(springAnnotationSimpleName(annotation.name)),
);
}
export function hasSpringStereotypeSyntax(annotations: readonly SpringDiAnnotationFact[]): boolean {
return annotations.some((annotation) =>
STEREOTYPE_SIMPLE_NAMES.has(springAnnotationSimpleName(annotation.name)),
);
}
function staticStringArgument(annotationText: string): string | undefined {
const args = annotationText.match(/\((.*)\)$/s)?.[1]?.trim();
if (args === undefined) return undefined;
const value = args.replace(/^value\s*=\s*/, '').trim();
const literal = value.match(/^"((?:\\.|[^"\\])*)"$/s);
if (literal === null) return undefined;
try {
return JSON.parse(`"${literal[1]}"`) as string;
} catch {
return undefined;
}
}
function defaultBeanName(className: string): string {
if (className.length === 0) return className;
if (
className.length > 1 &&
className[0] !== className[0].toLowerCase() &&
className[1] !== className[1].toLowerCase()
) {
return className;
}
return className[0].toLowerCase() + className.slice(1);
}
type ParsedSpringInjectionType = NonNullable<ReturnType<typeof parseSpringInjectionType>>;
export interface SpringDiMetadataAdapter<
Annotation extends SpringDiAnnotationFact,
SiteKind extends string,
> {
getFacts(filePath: string): readonly SpringDiClassFact<Annotation, SiteKind>[];
isPackageVisibilityIncomplete(filePath: string): boolean;
parseInjectionType(rawType: string): ParsedSpringInjectionType | null;
capturedMemberKind: SiteKind;
isInjectionAnnotationApplicable?(
annotation: Annotation,
site: SpringDiInjectionSiteFact<Annotation, SiteKind>,
): boolean;
isQualifierAnnotationApplicable?(
annotation: Annotation,
site: SpringDiInjectionSiteFact<Annotation, SiteKind>,
): boolean;
}
/**
* Build the post-resolution Spring DI metadata hook shared by language adapters.
* Language adapters retain syntax capture, type normalization, use-site rules,
* and side-channel ownership; this function owns framework semantics only.
*/
export function createSpringDiMetadataAttacher<
Annotation extends SpringDiAnnotationFact,
SiteKind extends string,
>(adapter: SpringDiMetadataAdapter<Annotation, SiteKind>) {
return (
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
indexes: ScopeResolutionIndexes,
): void => {
const resolveAnnotation = createSpringAnnotationNameResolver(indexes);
for (const parsed of parsedFiles) {
const incomplete = adapter.isPackageVisibilityIncomplete(parsed.filePath);
for (const fact of adapter.getFacts(parsed.filePath)) {
const classScope = indexes.scopeTree.getScope(fact.classScopeId);
if (classScope === undefined || classScope.kind !== 'Class') continue;
const classDef = classScope.ownedDefs.find((definition) => definition.type === 'Class');
if (classDef === undefined) continue;
const graphId = resolveDefGraphId(parsed.filePath, classDef, nodeLookup);
if (graphId === undefined) continue;
const classNode = graph.getNode(graphId);
if (classNode === undefined || classNode.label !== 'Class') continue;
const resolvedAnnotations = new Map<string, string | undefined>();
const resolveFact = (
annotation: Annotation,
enclosingScope: ScopeId | null = classScope.parent,
): string | undefined => {
const cacheKey = `${enclosingScope ?? '<root>'}\0${annotation.name}`;
if (resolvedAnnotations.has(cacheKey)) return resolvedAnnotations.get(cacheKey);
const resolved = resolveAnnotation(
annotation.name,
parsed,
enclosingScope,
RESOLVABLE_DI_ANNOTATIONS,
incomplete,
);
resolvedAnnotations.set(cacheKey, resolved);
return resolved;
};
const frameworkAnnotations = Array.isArray(classNode.properties.frameworkAnnotations)
? classNode.properties.frameworkAnnotations.filter(
(annotation): annotation is string => typeof annotation === 'string',
)
: [];
if (frameworkAnnotations.length > 0) {
const names = new Set<string>();
let explicitBeanName: string | undefined;
let hasDynamicBeanName = false;
let primary = false;
for (const annotation of fact.classAnnotations) {
const resolved = resolveFact(annotation);
if (resolved === undefined) continue;
if (SPRING_BEAN_STEREOTYPES.has(resolved)) {
const argumentText = annotation.text.match(/\((.*)\)$/s)?.[1]?.trim();
if (argumentText !== undefined && argumentText.length > 0) {
const staticName = staticStringArgument(annotation.text);
if (staticName === undefined) hasDynamicBeanName = true;
else if (staticName.length > 0) explicitBeanName = staticName;
}
}
if (QUALIFIER_ANNOTATIONS.has(resolved)) {
const qualifier = staticStringArgument(annotation.text);
if (qualifier !== undefined) names.add(qualifier);
}
if (PRIMARY_ANNOTATIONS.has(resolved)) primary = true;
}
if (explicitBeanName !== undefined) names.add(explicitBeanName);
else if (!hasDynamicBeanName) names.add(defaultBeanName(classNode.properties.name));
const provider: DiProviderMatch = {
names: [...names],
...(primary ? { preferenceReason: 'selected @Primary' } : {}),
};
classNode.properties[SPRING_DI_PROVIDER_PROPERTY] = provider;
}
const matches: DiInjectionMatch[] = [];
const semanticallyOwnedMemberNames = new Set<string>();
for (const site of fact.injectionSites) {
let injectionAnnotation: Annotation | undefined;
for (const annotation of site.annotations) {
if (adapter.isInjectionAnnotationApplicable?.(annotation, site) === false) continue;
const resolved = resolveFact(annotation, classScope.id);
if (resolved !== undefined && INJECTION_ANNOTATIONS.has(resolved)) {
injectionAnnotation = annotation;
break;
}
}
if (injectionAnnotation === undefined) {
if (!site.implicitConstructor || frameworkAnnotations.length === 0) continue;
} else if (site.kind === adapter.capturedMemberKind) {
// Claim the member only after its injection annotation resolves to
// a recognized FQN. Ambiguous wildcard imports stay unclaimed so
// the legacy collection matcher can fall back. A dynamic qualifier
// later fails closed, but this path still owns the member and must
// suppress that legacy fallback.
semanticallyOwnedMemberNames.add(site.memberName);
}
for (const dependency of site.dependencies) {
const parsedType = adapter.parseInjectionType(dependency.rawType);
if (parsedType === null) continue;
let qualifierAnnotation: Annotation | undefined;
for (const annotation of dependency.annotations) {
if (adapter.isQualifierAnnotationApplicable?.(annotation, site) === false) continue;
const resolved = resolveFact(annotation, classScope.id);
if (resolved !== undefined && QUALIFIER_ANNOTATIONS.has(resolved)) {
qualifierAnnotation = annotation;
break;
}
}
const qualifier =
qualifierAnnotation === undefined
? undefined
: staticStringArgument(qualifierAnnotation.text);
// A present-but-dynamic qualifier is not the same as no qualifier.
// Without its value we cannot choose a provider honestly, so fail
// closed instead of emitting the unqualified candidate set.
if (qualifierAnnotation !== undefined && qualifier === undefined) continue;
const trigger =
injectionAnnotation === undefined
? 'constructor'
: `@${springAnnotationSimpleName(injectionAnnotation.name)} ${site.kind}`;
const location =
site.kind === adapter.capturedMemberKind
? site.memberName
: `${site.memberName} parameter ${dependency.name}`;
matches.push({
targetTypeName: parsedType.targetTypeName,
cardinality: parsedType.cardinality,
...(qualifier === undefined
? {}
: {
namedSelection: {
name: qualifier,
reason: `qualifier "${qualifier}"`,
},
}),
reason: `Spring DI: ${trigger} ${location}: ${parsedType.displayType}`,
});
}
}
if (matches.length > 0) {
classNode.properties[SPRING_DI_INJECTION_SITES_PROPERTY] = matches;
}
for (const memberName of semanticallyOwnedMemberNames) {
for (const { def } of classScope.bindings.get(memberName) ?? []) {
if (def.ownerId !== classDef.nodeId) continue;
const propertyId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (propertyId === undefined) continue;
const property = graph.getNode(propertyId);
if (property?.label === 'Property') {
property.properties[SPRING_DI_CAPTURED_FIELD_PROPERTY] = true;
}
}
}
}
}
};
}

View file

@ -10,6 +10,7 @@ import {
} from '../jvm/package-facts.js';
import { getJavaPackageFact, setJavaPackageFact } from './package-facts.js';
import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js';
import type { JavaSpringDiClassFact } from './spring-di.js';
export type JavaClassAnnotationFact = ClassAnnotationFact;
@ -18,15 +19,18 @@ export interface JavaCaptureSideChannel {
readonly packageFact: JvmPackageFact;
readonly classAnnotations: readonly JavaClassAnnotationFact[];
readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[];
readonly springDiFacts?: readonly JavaSpringDiClassFact[];
}
const classAnnotations = createClassAnnotationFactStore();
const springConfigConsumers = new Map<string, readonly JavaSpringConfigConsumerFact[]>();
const springDiFacts = new Map<string, readonly JavaSpringDiClassFact[]>();
/** Clear facts retained by a prior workspace pass in a long-lived process. */
export function clearJavaClassAnnotationFacts(): void {
classAnnotations.clear();
springConfigConsumers.clear();
springDiFacts.clear();
}
/** Store the annotation syntax collected by Java's existing scope-query traversal. */
@ -51,14 +55,32 @@ export function getJavaSpringConfigConsumerFacts(
return springConfigConsumers.get(filePath) ?? [];
}
export function setJavaSpringDiFacts(
filePath: string,
facts: readonly JavaSpringDiClassFact[],
): void {
if (facts.length === 0) springDiFacts.delete(filePath);
else springDiFacts.set(filePath, facts);
}
export function getJavaSpringDiFacts(filePath: string): readonly JavaSpringDiClassFact[] {
return springDiFacts.get(filePath) ?? [];
}
/** Snapshot worker-local Java annotation facts for ParsedFile serialization. */
export function collectJavaCaptureSideChannel(
filePath: string,
): JavaCaptureSideChannel | undefined {
const facts = classAnnotations.get(filePath);
const configConsumers = springConfigConsumers.get(filePath) ?? [];
const diFacts = springDiFacts.get(filePath) ?? [];
const packageFact = getJavaPackageFact(filePath);
if (facts.length === 0 && configConsumers.length === 0 && packageFact === undefined) {
if (
facts.length === 0 &&
configConsumers.length === 0 &&
diFacts.length === 0 &&
packageFact === undefined
) {
return undefined;
}
return {
@ -66,6 +88,7 @@ export function collectJavaCaptureSideChannel(
packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT,
classAnnotations: facts,
...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}),
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
};
}
@ -85,6 +108,7 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void {
) {
setJavaClassAnnotationFacts(parsed.filePath, []);
setJavaSpringConfigConsumerFacts(parsed.filePath, []);
setJavaSpringDiFacts(parsed.filePath, []);
setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
return;
}
@ -93,6 +117,10 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void {
parsed.filePath,
Array.isArray(data.springConfigConsumers) ? data.springConfigConsumers : [],
);
setJavaSpringDiFacts(
parsed.filePath,
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
);
setJavaPackageFact(
parsed.filePath,
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,

View file

@ -35,10 +35,12 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import {
setJavaClassAnnotationFacts,
setJavaSpringConfigConsumerFacts,
setJavaSpringDiFacts,
} from './capture-side-channel.js';
import { captureJavaPackageFact } from './package-facts.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js';
import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.js';
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
@ -99,6 +101,8 @@ export function emitJavaScopeCaptures(
const rawMatches = getJavaScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
const classAnnotations = new Map<ScopeId, Set<string>>();
const springDiFacts: JavaSpringDiClassFact[] = [];
const springDiClassNodeIds = new Set<number>();
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
@ -118,6 +122,13 @@ export function emitJavaScopeCaptures(
}
if (Object.keys(grouped).length === 0) continue;
const springDiClassNode = nodeIfType(nodeMap['@scope.class'], 'class_declaration');
if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) {
springDiClassNodeIds.add(springDiClassNode.id);
const fact = captureJavaSpringDiClassFact(springDiClassNode, filePath);
if (fact !== null) springDiFacts.push(fact);
}
const annotatedClass = grouped['@class-annotation.class'];
const annotationName = grouped['@class-annotation.name'];
if (annotatedClass !== undefined && annotationName !== undefined) {
@ -288,6 +299,7 @@ export function emitJavaScopeCaptures(
filePath,
captureJavaSpringConfigConsumerFacts(tree.rootNode, filePath),
);
setJavaSpringDiFacts(filePath, springDiFacts);
return [
...resolveVarTypeBindings(out),

View file

@ -31,6 +31,7 @@ import {
import { populateJavaPackageSiblings } from './package-siblings.js';
import { attachSpringBeanCandidateMetadata } from './spring-bean-metadata.js';
import { attachJavaSpringConfigBindings } from './spring-config-bindings.js';
import { attachJavaSpringDiMetadata } from './spring-di.js';
import {
applyJavaCaptureSideChannel,
clearJavaClassAnnotationFacts,
@ -86,6 +87,7 @@ const javaScopeResolver: ScopeResolver = {
populateRangeBindings: populateJavaCrossFileReturnTypes,
emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes, ctx) => {
attachSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes);
attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx);
},
};

View file

@ -0,0 +1,153 @@
import { makeScopeId } from 'gitnexus-shared';
import {
createSpringDiMetadataAttacher,
hasSpringDiRelevantAnnotation,
hasSpringStereotypeSyntax,
type SpringDiAnnotationFact,
type SpringDiClassFact,
type SpringDiDependencyFact,
type SpringDiInjectionSiteFact,
} from '../../frameworks/spring/di-metadata.js';
import { parseSpringInjectionType } from '../../di-extractors/spring.js';
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js';
import { getJavaSpringDiFacts } from './capture-side-channel.js';
export type JavaAnnotationSyntaxFact = SpringDiAnnotationFact;
export type JavaSpringDependencyFact = SpringDiDependencyFact<JavaAnnotationSyntaxFact>;
type JavaSpringInjectionSiteKind = 'field' | 'constructor' | 'method';
export type JavaSpringInjectionSiteFact = SpringDiInjectionSiteFact<
JavaAnnotationSyntaxFact,
JavaSpringInjectionSiteKind
>;
export type JavaSpringDiClassFact = SpringDiClassFact<
JavaAnnotationSyntaxFact,
JavaSpringInjectionSiteKind
>;
function annotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] {
const facts: JavaAnnotationSyntaxFact[] = [];
for (const child of node.namedChildren) {
if (child.type !== 'modifiers') continue;
for (const modifier of child.namedChildren) {
if (modifier.type !== 'marker_annotation' && modifier.type !== 'annotation') continue;
const nameNode = modifier.childForFieldName('name') ?? modifier.firstNamedChild;
if (nameNode === null) continue;
facts.push({ name: nameNode.text.trim(), text: modifier.text.trim() });
}
}
return facts;
}
function dependenciesOf(callable: SyntaxNode): JavaSpringDependencyFact[] {
const parameters = callable.childForFieldName('parameters');
if (parameters === null) return [];
const dependencies: JavaSpringDependencyFact[] = [];
for (const parameter of parameters.namedChildren) {
if (parameter.type !== 'formal_parameter' && parameter.type !== 'spread_parameter') continue;
const nameNode = parameter.childForFieldName('name');
const typeNode = parameter.childForFieldName('type');
if (nameNode === null || typeNode === null) continue;
dependencies.push({
name: nameNode.text.trim(),
rawType: typeNode.text.trim(),
annotations: annotationFacts(parameter),
});
}
return dependencies;
}
/**
* Capture one class already surfaced by Java's scope query.
*
* `captures.ts` calls this from its existing query-match traversal, so Spring
* DI does not perform a second recursive walk from the AST root.
*/
export function captureJavaSpringDiClassFact(
classNode: SyntaxNode,
filePath: string,
): JavaSpringDiClassFact | null {
const body = classNode.childForFieldName('body');
if (body === null) return null;
const classAnnotations = annotationFacts(classNode);
const injectionSites: JavaSpringInjectionSiteFact[] = [];
const constructors = body.namedChildren.filter(
(child) => child.type === 'constructor_declaration',
);
for (const constructor of constructors) {
const annotations = annotationFacts(constructor);
const implicitConstructor =
constructors.length === 1 &&
hasSpringStereotypeSyntax(classAnnotations) &&
!hasSpringDiRelevantAnnotation(annotations);
if (!implicitConstructor && !hasSpringDiRelevantAnnotation(annotations)) continue;
injectionSites.push({
kind: 'constructor',
memberName: constructor.childForFieldName('name')?.text.trim() ?? '<constructor>',
implicitConstructor,
annotations,
dependencies: dependenciesOf(constructor),
});
}
for (const member of body.namedChildren) {
if (member.type === 'field_declaration') {
const annotations = annotationFacts(member);
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
const typeNode = member.childForFieldName('type');
if (typeNode === null) continue;
for (const declarator of member.namedChildren) {
if (declarator.type !== 'variable_declarator') continue;
const nameNode = declarator.childForFieldName('name');
if (nameNode === null) continue;
injectionSites.push({
kind: 'field',
memberName: nameNode.text.trim(),
implicitConstructor: false,
annotations,
dependencies: [
{
name: nameNode.text.trim(),
rawType: typeNode.text.trim(),
annotations,
},
],
});
}
} else if (member.type === 'method_declaration') {
const annotations = annotationFacts(member);
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
injectionSites.push({
kind: 'method',
memberName: member.childForFieldName('name')?.text.trim() ?? '<method>',
implicitConstructor: false,
annotations,
dependencies: dependenciesOf(member),
});
}
}
if (injectionSites.length === 0 && !hasSpringDiRelevantAnnotation(classAnnotations)) return null;
const classCapture = nodeToCapture('@spring-di.class', classNode);
return {
classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }),
classAnnotations,
injectionSites,
};
}
/** Attach resolved, framework-private DI metadata to Class nodes. */
export const attachJavaSpringDiMetadata = createSpringDiMetadataAttacher<
JavaAnnotationSyntaxFact,
JavaSpringInjectionSiteKind
>({
getFacts: getJavaSpringDiFacts,
isPackageVisibilityIncomplete: isJavaPackageSiblingVisibilityIncomplete,
parseInjectionType: parseSpringInjectionType,
capturedMemberKind: 'field',
});

View file

@ -185,10 +185,10 @@ export const kotlinProvider = defineLanguage({
emitScopeCaptures: emitKotlinScopeCaptures,
// ── #2195 PDG layer: Kotlin CFG visitor (vendored grammar) ──
cfgVisitor: createKotlinCfgVisitor(),
// Worker-side: snapshot companion-scope marks, package visibility, and
// class-annotation facts `emitKotlinScopeCaptures` just populated into plain
// data on `ParsedFile.captureSideChannel`, so the main thread can restore all
// three via `applyCaptureSideChannel` WITHOUT a re-parse (#1983). See
// Worker-side: snapshot companion-scope marks, package visibility, class
// annotations, and Spring DI facts `emitKotlinScopeCaptures` just populated
// into plain data on `ParsedFile.captureSideChannel`, so the main thread can
// restore them via `applyCaptureSideChannel` WITHOUT a re-parse (#1983). See
// `kotlin/capture-side-channel.ts`.
// `assertCloneable` is a runtime identity; it makes a future non-serializable
// value in the side-channel payload a compile error here, at the source, rather

View file

@ -9,6 +9,8 @@
* from the `@scope.companion` marker capture.
* - Spring Bean class-annotation facts collected during the same scope-query
* traversal, consumed only after imports and package visibility finalize.
* - Spring DI class facts (constructor/property/method injection syntax),
* resolved and attached only after imports finalize.
* - A JVM package fact read from the already-parsed root, so package-sibling
* visibility never re-parses Kotlin source on the main thread.
*
@ -29,7 +31,8 @@
* The single generic `ParsedFile.captureSideChannel` field is shared with C++,
* which is safe because each file is one language (a `.kt` file uses the kotlin
* provider, a `.cpp` file the cpp provider). The payload is self-describing
* (`{ kind: 'kotlin', companionScopes, packageFact, classAnnotations }`) so
* (`{ kind: 'kotlin', companionScopes, packageFact, classAnnotations,
* springDiFacts }`) so
* `applyKotlinCaptureSideChannel` only restores kotlin state and ignores a
* foreign-shaped snapshot.
*/
@ -46,8 +49,10 @@ import {
} from '../jvm/package-facts.js';
import { getCompanionScopesForFile, markCompanionScope } from './companion-scopes.js';
import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js';
import type { KotlinSpringDiClassFact } from './spring-di.js';
const classAnnotations = createClassAnnotationFactStore();
const springDiFacts = new Map<string, readonly KotlinSpringDiClassFact[]>();
/**
* Plain JSON-serializable snapshot of the per-file Kotlin capture-time
@ -63,10 +68,13 @@ export interface KotlinCaptureSideChannel {
readonly packageFact: JvmPackageFact;
/** Class annotation syntax collected by the existing scope traversal. */
readonly classAnnotations: readonly ClassAnnotationFact[];
/** Constructor, property, and method injection syntax captured per class. */
readonly springDiFacts?: readonly KotlinSpringDiClassFact[];
}
export function clearKotlinClassAnnotationFacts(): void {
classAnnotations.clear();
springDiFacts.clear();
}
export function setKotlinClassAnnotationFacts(
@ -80,6 +88,18 @@ export function getKotlinClassAnnotationFacts(filePath: string): readonly ClassA
return classAnnotations.get(filePath);
}
export function setKotlinSpringDiFacts(
filePath: string,
facts: readonly KotlinSpringDiClassFact[],
): void {
if (facts.length === 0) springDiFacts.delete(filePath);
else springDiFacts.set(filePath, facts);
}
export function getKotlinSpringDiFacts(filePath: string): readonly KotlinSpringDiClassFact[] {
return springDiFacts.get(filePath) ?? [];
}
/**
* `LanguageProvider.collectCaptureSideChannel` implementation for Kotlin.
* Returns `undefined` when this file recorded no side-channel state at all, so
@ -90,8 +110,14 @@ export function collectKotlinCaptureSideChannel(
): KotlinCaptureSideChannel | undefined {
const companionScopes = getCompanionScopesForFile(filePath);
const annotationFacts = classAnnotations.get(filePath);
const diFacts = springDiFacts.get(filePath) ?? [];
const packageFact = getKotlinPackageFact(filePath);
if (companionScopes.length === 0 && annotationFacts.length === 0 && packageFact === undefined) {
if (
companionScopes.length === 0 &&
annotationFacts.length === 0 &&
diFacts.length === 0 &&
packageFact === undefined
) {
return undefined;
}
return {
@ -99,6 +125,7 @@ export function collectKotlinCaptureSideChannel(
companionScopes,
packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT,
classAnnotations: annotationFacts,
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
};
}
@ -121,6 +148,7 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
!Array.isArray(data.classAnnotations)
) {
classAnnotations.set(parsed.filePath, []);
setKotlinSpringDiFacts(parsed.filePath, []);
setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
return;
}
@ -128,6 +156,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
markCompanionScope(parsed.filePath, scopeId);
}
classAnnotations.set(parsed.filePath, data.classAnnotations);
setKotlinSpringDiFacts(
parsed.filePath,
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
);
setKotlinPackageFact(
parsed.filePath,
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,

View file

@ -18,9 +18,10 @@ import { normalizeKotlinType } from './interpret.js';
import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
import { getKotlinParser, getKotlinScopeQuery } from './query.js';
import { markCompanionScope } from './companion-scopes.js';
import { setKotlinClassAnnotationFacts } from './capture-side-channel.js';
import { setKotlinClassAnnotationFacts, setKotlinSpringDiFacts } from './capture-side-channel.js';
import { captureKotlinPackageFact } from './package-facts.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.js';
const FUNCTION_DECL_TAGS = ['@declaration.function'] as const;
@ -83,6 +84,8 @@ export function emitKotlinScopeCaptures(
const out: CaptureMatch[] = [];
const classAnnotations = new Map<ScopeId, Set<string>>();
const springDiFacts: KotlinSpringDiClassFact[] = [];
const springDiClassNodeIds = new Set<number>();
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
@ -106,6 +109,13 @@ export function emitKotlinScopeCaptures(
}
if (Object.keys(grouped).length === 0) continue;
const springDiClassNode = nodeIfType(groupedNodes['@scope.class'], 'class_declaration');
if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) {
springDiClassNodeIds.add(springDiClassNode.id);
const fact = captureKotlinSpringDiClassFact(springDiClassNode, filePath);
if (fact !== null) springDiFacts.push(fact);
}
const annotatedClass = grouped['@class-annotation.class'];
const annotationName = grouped['@class-annotation.name'];
if (annotatedClass !== undefined && annotationName !== undefined) {
@ -288,6 +298,7 @@ export function emitKotlinScopeCaptures(
}
setKotlinClassAnnotationFacts(filePath, materializeClassAnnotationFacts(classAnnotations));
setKotlinSpringDiFacts(filePath, springDiFacts);
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS));
return out;
}

View file

@ -22,6 +22,7 @@ import { isKotlinStaticOnly } from './owners.js';
import { populateKotlinPackageSiblings } from './package-siblings.js';
import { attachKotlinSpringBeanCandidateMetadata } from './spring-bean-metadata.js';
import { clearKotlinPackageFacts } from './package-facts.js';
import { attachKotlinSpringDiMetadata } from './spring-di.js';
/**
* Kotlin scope resolver for RFC #909 Ring 3.
@ -122,7 +123,10 @@ export const kotlinScopeResolver: ScopeResolver = {
hoistTypeBindingsToModule: true,
postExtractSourceTextPolicy: 'uncached-files',
populateNamespaceSiblings: populateKotlinPackageSiblings,
emitPostResolutionEdges: attachKotlinSpringBeanCandidateMetadata,
emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes) => {
attachKotlinSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes);
attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
},
};
/**

View file

@ -0,0 +1,299 @@
import { makeScopeId } from 'gitnexus-shared';
import { parseSpringInjectionType } from '../../di-extractors/spring.js';
import {
createSpringDiMetadataAttacher,
hasSpringDiRelevantAnnotation,
hasSpringStereotypeSyntax,
type SpringDiAnnotationFact,
type SpringDiClassFact,
type SpringDiDependencyFact,
type SpringDiInjectionSiteFact,
} from '../../frameworks/spring/di-metadata.js';
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { getKotlinSpringDiFacts } from './capture-side-channel.js';
import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js';
export interface KotlinAnnotationSyntaxFact extends SpringDiAnnotationFact {
readonly useSiteTarget?: string;
}
export type KotlinSpringDependencyFact = SpringDiDependencyFact<KotlinAnnotationSyntaxFact>;
type KotlinSpringInjectionSiteKind = 'property' | 'constructor' | 'method';
export type KotlinSpringInjectionSiteFact = SpringDiInjectionSiteFact<
KotlinAnnotationSyntaxFact,
KotlinSpringInjectionSiteKind
>;
export type KotlinSpringDiClassFact = SpringDiClassFact<
KotlinAnnotationSyntaxFact,
KotlinSpringInjectionSiteKind
>;
const KOTLIN_TYPE_NODES = new Set(['user_type', 'nullable_type', 'function_type']);
function firstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | undefined {
const stack = [...node.namedChildren].reverse();
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) continue;
if (current.type === type) return current;
for (let index = current.namedChildren.length - 1; index >= 0; index--) {
const child = current.namedChildren[index];
if (child !== undefined) stack.push(child);
}
}
return undefined;
}
function annotationFact(annotation: SyntaxNode): KotlinAnnotationSyntaxFact | null {
const nameNode = firstDescendantOfType(annotation, 'user_type');
if (nameNode === undefined) return null;
const useSiteTarget = annotation.namedChildren
.find((child) => child.type === 'use_site_target')
?.text.replace(/:\s*$/, '')
.trim();
return {
name: nameNode.text.trim(),
text: annotation.text.trim(),
...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }),
};
}
function annotationsFromModifierContainer(node: SyntaxNode): KotlinAnnotationSyntaxFact[] {
const facts: KotlinAnnotationSyntaxFact[] = [];
for (const child of node.namedChildren) {
if (child.type !== 'annotation') continue;
const fact = annotationFact(child);
if (fact !== null) facts.push(fact);
}
return facts;
}
function annotationFacts(node: SyntaxNode): KotlinAnnotationSyntaxFact[] {
const facts: KotlinAnnotationSyntaxFact[] = [];
for (const child of node.namedChildren) {
if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue;
facts.push(...annotationsFromModifierContainer(child));
}
return facts;
}
function directTypeNode(node: SyntaxNode): SyntaxNode | undefined {
return node.namedChildren.find((child) => KOTLIN_TYPE_NODES.has(child.type));
}
function parameterDependency(
parameter: SyntaxNode,
precedingAnnotations: readonly KotlinAnnotationSyntaxFact[] = [],
): KotlinSpringDependencyFact | null {
const nameNode = parameter.namedChildren.find((child) => child.type === 'simple_identifier');
const typeNode = directTypeNode(parameter);
if (nameNode === undefined || typeNode === undefined) return null;
return {
name: nameNode.text.trim(),
rawType: typeNode.text.trim(),
annotations: [...precedingAnnotations, ...annotationFacts(parameter)],
};
}
function functionDependencies(callable: SyntaxNode): KotlinSpringDependencyFact[] {
const parameters = callable.namedChildren.find(
(child) => child.type === 'function_value_parameters',
);
if (parameters === undefined) return [];
const dependencies: KotlinSpringDependencyFact[] = [];
let pendingAnnotations: KotlinAnnotationSyntaxFact[] = [];
for (const child of parameters.namedChildren) {
if (child.type === 'parameter_modifiers') {
pendingAnnotations = annotationsFromModifierContainer(child);
continue;
}
if (child.type !== 'parameter') continue;
const dependency = parameterDependency(child, pendingAnnotations);
pendingAnnotations = [];
if (dependency !== null) dependencies.push(dependency);
}
return dependencies;
}
function primaryConstructorDependencies(constructor: SyntaxNode): KotlinSpringDependencyFact[] {
const dependencies: KotlinSpringDependencyFact[] = [];
for (const parameter of constructor.namedChildren) {
if (parameter.type !== 'class_parameter') continue;
const dependency = parameterDependency(parameter);
if (dependency !== null) dependencies.push(dependency);
}
return dependencies;
}
function propertyDependency(property: SyntaxNode): KotlinSpringDependencyFact | null {
const variable = property.namedChildren.find((child) => child.type === 'variable_declaration');
if (variable === undefined) return null;
const nameNode = variable.namedChildren.find((child) => child.type === 'simple_identifier');
const typeNode = directTypeNode(variable);
if (nameNode === undefined || typeNode === undefined) return null;
const annotations = annotationFacts(property);
return {
name: nameNode.text.trim(),
rawType: typeNode.text.trim(),
annotations,
};
}
function isKotlinBeanCandidateClass(classNode: SyntaxNode): boolean {
if (classNode.children.some((child) => child.type === 'interface' || child.type === 'enum')) {
return false;
}
const modifiers = classNode.namedChildren.find((child) => child.type === 'modifiers');
return !modifiers?.namedChildren.some(
(child) => child.type === 'class_modifier' && child.text.trim() === 'annotation',
);
}
/**
* Capture one class already surfaced by Kotlin's scope query. Kotlin-specific
* syntax is normalized here while import/FQN semantics remain deferred until
* post-resolution.
*/
export function captureKotlinSpringDiClassFact(
classNode: SyntaxNode,
filePath: string,
): KotlinSpringDiClassFact | null {
if (!isKotlinBeanCandidateClass(classNode)) return null;
const classAnnotations = annotationFacts(classNode);
const injectionSites: KotlinSpringInjectionSiteFact[] = [];
const body = classNode.namedChildren.find((child) => child.type === 'class_body');
const primaryConstructor = classNode.namedChildren.find(
(child) => child.type === 'primary_constructor',
);
const secondaryConstructors =
body?.namedChildren.filter((child) => child.type === 'secondary_constructor') ?? [];
const constructorCount =
(primaryConstructor === undefined ? 0 : 1) + secondaryConstructors.length;
if (primaryConstructor !== undefined) {
const annotations = annotationFacts(primaryConstructor);
const implicitConstructor =
constructorCount === 1 &&
hasSpringStereotypeSyntax(classAnnotations) &&
!hasSpringDiRelevantAnnotation(annotations);
if (implicitConstructor || hasSpringDiRelevantAnnotation(annotations)) {
injectionSites.push({
kind: 'constructor',
memberName: '<primary-constructor>',
implicitConstructor,
annotations,
dependencies: primaryConstructorDependencies(primaryConstructor),
});
}
}
for (const constructor of secondaryConstructors) {
const annotations = annotationFacts(constructor);
const implicitConstructor =
constructorCount === 1 &&
hasSpringStereotypeSyntax(classAnnotations) &&
!hasSpringDiRelevantAnnotation(annotations);
if (!implicitConstructor && !hasSpringDiRelevantAnnotation(annotations)) continue;
injectionSites.push({
kind: 'constructor',
memberName: '<secondary-constructor>',
implicitConstructor,
annotations,
dependencies: functionDependencies(constructor),
});
}
if (body !== undefined) {
for (const member of body.namedChildren) {
if (member.type === 'property_declaration') {
const annotations = annotationFacts(member);
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
const dependency = propertyDependency(member);
if (dependency === null) continue;
injectionSites.push({
kind: 'property',
memberName: dependency.name,
implicitConstructor: false,
annotations,
dependencies: [dependency],
});
} else if (member.type === 'function_declaration') {
const annotations = annotationFacts(member);
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
const name =
member.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim() ??
'<method>';
injectionSites.push({
kind: 'method',
memberName: name,
implicitConstructor: false,
annotations,
dependencies: functionDependencies(member),
});
}
}
}
if (injectionSites.length === 0 && !hasSpringDiRelevantAnnotation(classAnnotations)) return null;
const classCapture = nodeToCapture('@spring-di.class', classNode);
return {
classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }),
classAnnotations,
injectionSites,
};
}
function isApplicableInjectionAnnotation(
annotation: KotlinAnnotationSyntaxFact,
site: KotlinSpringInjectionSiteFact,
): boolean {
if (annotation.useSiteTarget === undefined) return true;
if (site.kind === 'constructor') return annotation.useSiteTarget === 'constructor';
if (site.kind === 'property') {
return annotation.useSiteTarget === 'field' || annotation.useSiteTarget === 'set';
}
return false;
}
function isApplicableQualifierAnnotation(
annotation: KotlinAnnotationSyntaxFact,
site: KotlinSpringInjectionSiteFact,
): boolean {
if (annotation.useSiteTarget === undefined) return true;
if (site.kind === 'property') {
return (
annotation.useSiteTarget === 'field' ||
annotation.useSiteTarget === 'param' ||
annotation.useSiteTarget === 'setparam'
);
}
return annotation.useSiteTarget === 'param';
}
function parseKotlinSpringInjectionType(rawType: string) {
// Kotlin nullable suffixes, type projections, and mutable collection aliases
// do not change the JVM bean type selected by Spring. Normalize only those
// surface forms; stars, function types, arrays, and nested generic elements
// still fail closed in the shared parser.
const normalized = rawType
.replace(/\bMutable(List|Set|Collection|Map)(?=\s*<)/g, '$1')
.replace(/([<,])\s*(?:out|in)\s+/g, '$1')
.replace(/\?(?=\s*(?:[>,]|$))/g, '');
return parseSpringInjectionType(normalized);
}
/** Attach resolved, framework-private DI metadata to Kotlin Class nodes. */
export const attachKotlinSpringDiMetadata = createSpringDiMetadataAttacher<
KotlinAnnotationSyntaxFact,
KotlinSpringInjectionSiteKind
>({
getFacts: getKotlinSpringDiFacts,
isPackageVisibilityIncomplete: isKotlinPackageSiblingVisibilityIncomplete,
parseInjectionType: parseKotlinSpringInjectionType,
capturedMemberKind: 'property',
isInjectionAnnotationApplicable: isApplicableInjectionAnnotation,
isQualifierAnnotationApplicable: isApplicableQualifierAnnotation,
});

View file

@ -1,91 +1,91 @@
/**
* Phase: di
*
* Framework-neutral dependency-injection resolution. Routes `Property` nodes
* by `properties.language` to the per-language field matchers registered in
* `di-extractors/` (`DI_MATCHERS` same registry seam shape as
* `SCOPE_RESOLVERS`), then fans each match out to `INJECTS` edges from the
* consumer Class node to every Class implementing the matched element
* interface.
*
* This file names NO language or framework: which fields count as
* container-injected and why is entirely the registered matcher's
* business (see `di-extractors/` for the matchers and their semantics,
* including deliberate annotation exclusions). The matcher also supplies the
* human-readable edge `reason`, so framework specifics stay in the payload,
* never in this phase.
*
* The resolution uses ONLY graph data Property nodes, `HAS_PROPERTY` edges,
* `IMPLEMENTS` edges, and Interface nodes. No filesystem access is performed:
* the structural information was already extracted by earlier parse /
* structure phases.
*
* Interface resolution is scoped to the CANDIDATE'S OWN language and prefers
* qualified names: a dotted element type resolves via the language's
* `qualifiedName` index; a bare simple name resolves only while unique within
* that language. Ambiguous names simple OR qualified (a qualifiedName has
* no file-path component, so the same package+name duplicated across monorepo
* modules collides too) fail CLOSED no edge, never
* last-writer-wins but observably: skips are counted in the phase output's
* `ambiguousSkipped` and named in an isDev debug log, so "no DI fields" is
* distinguishable from "all candidates ambiguous". Same-package/import-aware
* disambiguation is a documented follow-up (see the plan's Deferred work).
* Framework-neutral dependency-injection resolution. Per-language resolvers
* identify injection sites and provider metadata; this phase performs only
* graph-level type/heritage resolution and emits Class -> Class INJECTS edges.
*
* @deps mro
* @reads graph (Property nodes, HAS_PROPERTY edges, IMPLEMENTS edges, Interface nodes)
* @reads graph (Class/Interface/member nodes and heritage/ownership edges)
* @writes graph (INJECTS edges)
*/
import type { SupportedLanguages } from 'gitnexus-shared';
import type { GraphNode, SupportedLanguages } from 'gitnexus-shared';
import type { PipelinePhase, PipelineContext } from './types.js';
import { DI_MATCHERS, isSupportedLanguage } from '../di-extractors/index.js';
import {
DI_RESOLVERS,
isSupportedLanguage,
type DiInjectionMatch,
type DiProviderMatch,
} from '../di-extractors/index.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
export interface DIOutput {
injectsEdges: number;
/** Kept for output compatibility; now counts every matched injection site. */
fieldsScanned: number;
/** Candidates skipped because their element type name bare simple name
* or dotted qualified name matched more than one Interface within the
* candidate's language (fail-closed). */
/** Sites skipped because the requested type name itself was ambiguous. */
ambiguousSkipped: number;
/** Single-valued sites represented by multiple low-confidence candidates. */
ambiguousInjections: number;
}
/** Sentinel marking an interface name (simple or qualified) claimed by more
* than one Interface node within a language resolution must fail closed. */
const AMBIGUOUS: unique symbol = Symbol('ambiguous');
/** Per-language interface lookup: qualified names resolve exactly; bare
* simple names resolve only while unique within the language. Both indexes
* fail closed on their own duplicates. */
interface InterfaceIndex {
/** `properties.qualifiedName` Interface node id (when extracted e.g.
* package-qualified for languages with a file-scope package declaration),
* or {@link AMBIGUOUS} once a second Interface claims the same qualified
* name in the same language realistic in monorepos, where the same
* package+name is duplicated across modules or main/test source roots
* (a qualifiedName carries no file-path component). */
interface NameIndex {
byQualifiedName: Map<string, string | typeof AMBIGUOUS>;
/** `properties.name` Interface node id, or {@link AMBIGUOUS} once a
* second same-name Interface appears in the same language. */
bySimpleName: Map<string, string | typeof AMBIGUOUS>;
}
/** A Property node a registered matcher accepted as a DI fan-out candidate. */
interface CandidateField {
propertyId: string;
/** The candidate's language interface resolution (Pass 3) looks up ONLY
* this language's interface index. */
interface CandidateSite extends DiInjectionMatch {
siteNodeId: string;
language: SupportedLanguages;
elementTypeName: string;
/** Matcher-supplied edge reason (carries the framework specifics). */
}
interface PendingEdge {
sourceId: string;
targetId: string;
confidence: number;
reason: string;
}
function emptyNameIndex(): NameIndex {
return { byQualifiedName: new Map(), bySimpleName: new Map() };
}
function addIndexedName(index: NameIndex, node: GraphNode): void {
const qualifiedName = node.properties.qualifiedName;
if (typeof qualifiedName === 'string') {
index.byQualifiedName.set(
qualifiedName,
index.byQualifiedName.has(qualifiedName) ? AMBIGUOUS : node.id,
);
}
const simpleName = node.properties.name;
index.bySimpleName.set(simpleName, index.bySimpleName.has(simpleName) ? AMBIGUOUS : node.id);
}
function resolveIndexedName(index: NameIndex | undefined, name: string) {
if (index === undefined) return undefined;
return name.includes('.') ? index.byQualifiedName.get(name) : index.bySimpleName.get(name);
}
function providerCandidates(
ids: ReadonlySet<string>,
providers: ReadonlyMap<string, DiProviderMatch>,
): string[] {
const all = [...ids];
const recognized = all.filter((id) => providers.has(id));
// Recall-first fallback: provider metadata can be incomplete (custom
// registration mechanisms and legacy indexes can omit it). Prefer
// framework-recognized providers when present, but keep structurally valid
// candidates when none are known instead of dropping the injection entirely.
return recognized.length > 0 ? recognized : all;
}
export const diPhase: PipelinePhase<DIOutput> = {
name: 'di',
// Depends on `mro` for ordering: heritage edges (IMPLEMENTS/EXTENDS) must be
// fully populated before we resolve interface→implementer fan-out.
deps: ['mro'],
async execute(ctx: PipelineContext): Promise<DIOutput> {
@ -96,174 +96,193 @@ export const diPhase: PipelinePhase<DIOutput> = {
stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: ctx.graph.nodeCount },
});
// ── Pass 1: route Property nodes to registered per-language matchers ───
// Early-exit optimization: if no registered matcher accepts any Property
// node, skip all index construction. This makes the phase a no-op on
// repos with no DI-matched fields (no IMPLEMENTS / HAS_PROPERTY scans).
const candidates: CandidateField[] = [];
const candidates: CandidateSite[] = [];
const providers = new Map<string, DiProviderMatch>();
ctx.graph.forEachNode((node) => {
if (node.label !== 'Property') return;
const language = node.properties.language;
if (language === undefined || !isSupportedLanguage(language)) return;
const matcher = DI_MATCHERS.get(language);
if (matcher === undefined) return;
const match = matcher(node);
if (match === null) return;
candidates.push({
propertyId: node.id,
language,
elementTypeName: match.elementTypeName,
reason: match.reason,
});
const resolver = DI_RESOLVERS.get(language);
if (resolver === undefined) return;
const provider = resolver.matchProvider(node);
if (provider !== null) providers.set(node.id, provider);
for (const match of resolver.matchInjectionSites(node)) {
candidates.push({ ...match, siteNodeId: node.id, language });
}
});
if (candidates.length === 0) {
return { injectsEdges: 0, fieldsScanned: 0, ambiguousSkipped: 0 };
return {
injectsEdges: 0,
fieldsScanned: 0,
ambiguousSkipped: 0,
ambiguousInjections: 0,
};
}
// ── Pass 2: build single-pass reverse indexes ─────────────────────────
// interfaceNodeId → Set<implementerClassId> (reverse of IMPLEMENTS edge)
// IMPLEMENTS edges go Class→Interface, so target is the interface.
// Keyed by node id — globally unique — so this index needs no language
// scoping; only NAME-based lookups (below) do.
const interfaceToImplementers = new Map<string, Set<string>>();
for (const rel of ctx.graph.iterRelationshipsByType('IMPLEMENTS')) {
const implementerId = rel.sourceId; // Class
const interfaceId = rel.targetId; // Interface
let set = interfaceToImplementers.get(interfaceId);
if (set === undefined) {
set = new Set();
interfaceToImplementers.set(interfaceId, set);
const set = interfaceToImplementers.get(rel.targetId) ?? new Set<string>();
set.add(rel.sourceId);
interfaceToImplementers.set(rel.targetId, set);
}
const memberToClass = new Map<string, string>();
for (const relationType of ['HAS_PROPERTY', 'HAS_METHOD'] as const) {
for (const rel of ctx.graph.iterRelationshipsByType(relationType)) {
memberToClass.set(rel.targetId, rel.sourceId);
}
set.add(implementerId);
}
// propertyNodeId → consumerClassId (reverse of HAS_PROPERTY edge)
// HAS_PROPERTY edges go Class→Property, so target is the property.
const propertyToClass = new Map<string, string>();
for (const rel of ctx.graph.iterRelationshipsByType('HAS_PROPERTY')) {
propertyToClass.set(rel.targetId, rel.sourceId);
}
// language → InterfaceIndex (from Interface-labeled nodes). Scoped per
// language so an Interface in one language can never satisfy a candidate
// from another. Within a language, a name resolves only while unique —
// a second Interface claiming the same simple OR qualified name flips
// that entry to AMBIGUOUS and resolution fails closed (never
// last-writer-wins).
// Index only languages that can resolve: an Interface in a language with
// no candidate can never be looked up in Pass 3.
const candidateLanguages = new Set<string>(candidates.map((c) => c.language));
const interfacesByLanguage = new Map<string, InterfaceIndex>();
const candidateLanguages = new Set<string>(candidates.map((candidate) => candidate.language));
const interfacesByLanguage = new Map<string, NameIndex>();
const classesByLanguage = new Map<string, NameIndex>();
const classNodes = new Map<string, GraphNode>();
ctx.graph.forEachNode((node) => {
if (node.label !== 'Interface') return;
if (node.label !== 'Class' && node.label !== 'Interface') return;
const language = node.properties.language;
if (typeof language !== 'string') return; // no language ⇒ unindexable
if (!candidateLanguages.has(language)) return;
let index = interfacesByLanguage.get(language);
if (index === undefined) {
index = { byQualifiedName: new Map(), bySimpleName: new Map() };
interfacesByLanguage.set(language, index);
}
// `qualifiedName` reaches NodeProperties through the extensible index
// signature, so narrow it explicitly (no `any`).
const qualifiedName = node.properties.qualifiedName;
if (typeof qualifiedName === 'string') {
index.byQualifiedName.set(
qualifiedName,
index.byQualifiedName.has(qualifiedName) ? AMBIGUOUS : node.id,
);
}
const simpleName = node.properties.name;
index.bySimpleName.set(simpleName, index.bySimpleName.has(simpleName) ? AMBIGUOUS : node.id);
if (typeof language !== 'string' || !candidateLanguages.has(language)) return;
const indexes = node.label === 'Class' ? classesByLanguage : interfacesByLanguage;
const index = indexes.get(language) ?? emptyNameIndex();
addIndexedName(index, node);
indexes.set(language, index);
if (node.label === 'Class') classNodes.set(node.id, node);
});
// ── Pass 3: emit INJECTS edges ────────────────────────────────────────
let injectsEdges = 0;
let ambiguousSkipped = 0;
const ambiguousElementTypes = new Set<string>();
const seenEdges = new Set<string>();
let ambiguousInjections = 0;
const ambiguousTypeNames = new Set<string>();
const pending = new Map<string, PendingEdge>();
const queueEdge = (edge: PendingEdge): void => {
if (edge.sourceId === edge.targetId) return;
const id = `INJECTS:${edge.sourceId}->${edge.targetId}`;
const existing = pending.get(id);
if (existing === undefined || edge.confidence > existing.confidence) pending.set(id, edge);
};
for (const candidate of candidates) {
// Resolve the consumer Class that owns this Property.
const consumerClassId = propertyToClass.get(candidate.propertyId);
if (!consumerClassId) continue;
const siteNode = ctx.graph.getNode(candidate.siteNodeId);
const consumerClassId =
siteNode?.label === 'Class' ? siteNode.id : memberToClass.get(candidate.siteNodeId);
if (consumerClassId === undefined) continue;
// Resolve the element type name via the CANDIDATE'S OWN language index
// only — a same-named Interface in another language never participates.
const index = interfacesByLanguage.get(candidate.language);
if (index === undefined) continue;
// A dotted element type is a qualified name (e.g. `com.a.Shape`) —
// exact qualifiedName lookup, unaffected by simple-name ambiguity.
// A bare name uses the simple-name index. BOTH lookups fail CLOSED
// on their own ambiguity (a qualified name too can be claimed twice —
// same package+name across monorepo modules): no edge (never
// last-writer-wins), but counted and logged so the skip is
// observable. Same-package/import-aware disambiguation is a
// deliberate follow-up (plan: Deferred work).
let interfaceId: string | undefined;
if (candidate.elementTypeName.includes('.')) {
const entry = index.byQualifiedName.get(candidate.elementTypeName);
if (entry === AMBIGUOUS) {
ambiguousSkipped++;
ambiguousElementTypes.add(candidate.elementTypeName);
continue;
}
interfaceId = entry;
} else {
const entry = index.bySimpleName.get(candidate.elementTypeName);
if (entry === AMBIGUOUS) {
ambiguousSkipped++;
ambiguousElementTypes.add(candidate.elementTypeName);
continue;
}
interfaceId = entry;
const classEntry = resolveIndexedName(
classesByLanguage.get(candidate.language),
candidate.targetTypeName,
);
const interfaceEntry = resolveIndexedName(
interfacesByLanguage.get(candidate.language),
candidate.targetTypeName,
);
if (
classEntry === AMBIGUOUS ||
interfaceEntry === AMBIGUOUS ||
(classEntry !== undefined && interfaceEntry !== undefined)
) {
// A simple/qualified name claimed by both a Class and an Interface is
// type-ambiguous too. Fail closed rather than guessing which Java type
// the injection site meant; import-aware disambiguation is not
// available in this graph-only phase. This intentionally applies to
// legacy collection sites too: a Class/Interface collision no longer
// fans out through the interface on a simple-name guess.
ambiguousSkipped++;
ambiguousTypeNames.add(candidate.targetTypeName);
continue;
}
if (interfaceId === undefined) continue;
// Fan out to every class implementing that interface.
const implementers = interfaceToImplementers.get(interfaceId);
if (!implementers) continue;
const structural = new Set<string>();
if (typeof classEntry === 'string') structural.add(classEntry);
if (typeof interfaceEntry === 'string') {
for (const id of interfaceToImplementers.get(interfaceEntry) ?? []) structural.add(id);
}
structural.delete(consumerClassId);
if (structural.size === 0) continue;
for (const implId of implementers) {
// Skip self-edges: a class never injects its own bean into itself.
if (implId === consumerClassId) continue;
let viable = providerCandidates(structural, providers);
const namedSelection = candidate.namedSelection;
if (namedSelection !== undefined) {
viable = viable.filter(
(id) => providers.get(id)?.names.includes(namedSelection.name) === true,
);
if (viable.length === 0) continue;
}
// Dedup-safe edge ID: deterministic from (consumer, implementer).
const edgeId = `INJECTS:${consumerClassId}->${implId}`;
if (seenEdges.has(edgeId)) continue;
seenEdges.add(edgeId);
if (candidate.cardinality === 'collection') {
const confidence = namedSelection === undefined ? 0.8 : 0.9;
const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`;
for (const targetId of viable) {
queueEdge({
sourceId: consumerClassId,
targetId,
confidence,
reason: candidate.reason + suffix,
});
}
continue;
}
ctx.graph.addRelationship({
id: edgeId,
if (viable.length === 1) {
const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`;
queueEdge({
sourceId: consumerClassId,
targetId: implId,
type: 'INJECTS',
confidence: 0.8,
// Matcher-supplied reason — names the framework and the annotation
// actually found on the field (see di-extractors/).
reason: candidate.reason,
targetId: viable[0],
confidence: namedSelection === undefined ? 0.9 : 0.95,
reason: candidate.reason + suffix,
});
injectsEdges++;
continue;
}
const preferred = viable.flatMap((id) => {
const reason = providers.get(id)?.preferenceReason;
return reason === undefined ? [] : [{ id, reason }];
});
if (namedSelection === undefined && preferred.length === 1) {
const selected = preferred[0];
queueEdge({
sourceId: consumerClassId,
targetId: selected.id,
confidence: 0.95,
reason: `${candidate.reason}; ${selected.reason}`,
});
continue;
}
ambiguousInjections++;
const candidateNames = viable
.map((id) => classNodes.get(id)?.properties.name ?? id)
.sort()
.join(', ');
for (const targetId of viable) {
queueEdge({
sourceId: consumerClassId,
targetId,
confidence: 0.5,
reason: `${candidate.reason}; ambiguous candidates: ${candidateNames}`,
});
}
}
for (const [id, edge] of pending) {
ctx.graph.addRelationship({ id, type: 'INJECTS', ...edge });
}
if (isDev && ambiguousSkipped > 0) {
// One aggregated debug line (not per-candidate spam): duplicate simple
// names are NORMAL in large repos, but the skip must stay observable.
logger.debug(
`🧩 DI: ${ambiguousSkipped} candidate(s) skipped — ambiguous element interface name(s): ${[...ambiguousElementTypes].sort().join(', ')}`,
`DI: ${ambiguousSkipped} site(s) skipped because requested type names were ambiguous: ${[...ambiguousTypeNames].sort().join(', ')}`,
);
}
if (isDev && (injectsEdges > 0 || ambiguousSkipped > 0)) {
if (isDev && (pending.size > 0 || ambiguousInjections > 0)) {
logger.info(
`🧩 DI: ${injectsEdges} INJECTS edges from ${candidates.length} injection-annotated collection fields (${ambiguousSkipped} ambiguous skipped)`,
`DI: ${pending.size} INJECTS edges from ${candidates.length} injection sites (${ambiguousInjections} ambiguous single-site resolutions)`,
);
}
return { injectsEdges, fieldsScanned: candidates.length, ambiguousSkipped };
return {
injectsEdges: pending.size,
fieldsScanned: candidates.length,
ambiguousSkipped,
ambiguousInjections,
};
},
};

View file

@ -55,13 +55,15 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// the main thread (the #1983 OOM). Because the two stores share this version,
// any future change to the `ParsedFile` serialization shape MUST bump
// SCHEMA_BUMP so both invalidate in lockstep.
// v21: Java/Kotlin Spring DI facts persist constructor, field/property, and
// method injection sites plus bean-name and @Primary provider metadata.
// v20: Java/Kotlin capture side-channels persist package and class-annotation
// facts for shared Spring Bean resolution.
// v19: Java enum constant bodies emit E$N Class nodes; anonymous naming uses
// JLS 13.1 immediate-host chains (#2555).
// v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity.
// v16: direct callee identity.
const SCHEMA_BUMP = 20;
const SCHEMA_BUMP = 21;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -0,0 +1,284 @@
/**
* Spring standard-DI scaling benchmark (#2414 / PR #2632 review).
*
* Guards the two hot paths introduced by standard Spring injection:
*
* 1. Java and Kotlin capture emission collect DI facts from their existing
* scope-query traversals instead of recursively walking the AST root a
* second time.
* 2. Post-resolution metadata attachment finds captured fields through the
* owning class scope's bindings instead of scanning every HAS_PROPERTY
* relationship in the graph.
*
* The normal-CI tripwires use dense Java/Kotlin files to catch a capture
* re-regression. The gated suites measure Java and Kotlin capture plus
* full-pipeline scaling:
*
* GITNEXUS_BENCH=1 npx vitest run test/integration/spring-di-benchmark.test.ts
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { emitJavaScopeCaptures } from '../../src/core/ingestion/languages/java/captures.js';
import { collectJavaCaptureSideChannel } from '../../src/core/ingestion/languages/java/capture-side-channel.js';
import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.js';
import { collectKotlinCaptureSideChannel } from '../../src/core/ingestion/languages/kotlin/capture-side-channel.js';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
function denseSpringSource(consumerCount: number): string {
const consumers = Array.from(
{ length: consumerCount },
(_, index) => `
@Service
class Consumer${index} {
@Autowired private Gateway field${index};
Consumer${index}(@Qualifier("gatewayImpl") Gateway gateway) {}
@Inject void setGateway(Gateway gateway) {}
}
`,
).join('\n');
return `package com.example;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import jakarta.inject.Inject;
interface Gateway {}
@Service
class GatewayImpl implements Gateway {}
${consumers}
`;
}
interface CaptureBenchResult {
consumers: number;
elapsedMs: number;
captureCount: number;
factCount: number;
}
function runCaptureBenchmark(consumerCount: number, run: number): CaptureBenchResult {
const filePath = `src/SpringDiBench${consumerCount}_${run}.java`;
const start = performance.now();
const captures = emitJavaScopeCaptures(denseSpringSource(consumerCount), filePath);
const elapsedMs = performance.now() - start;
const facts = collectJavaCaptureSideChannel(filePath)?.springDiFacts ?? [];
return {
consumers: consumerCount,
elapsedMs,
captureCount: captures.length,
factCount: facts.length,
};
}
function denseKotlinSpringSource(consumerCount: number): string {
const consumers = Array.from(
{ length: consumerCount },
(_, index) => `
@Service
class Consumer${index} @Autowired constructor(
@param:Qualifier("gatewayImpl") gateway: Gateway,
) {
@field:Autowired lateinit var field${index}: Gateway
@Inject fun setGateway(gateway: Gateway) {}
}
`,
).join('\n');
return `package com.example
import org.springframework.stereotype.Service
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import jakarta.inject.Inject
interface Gateway
@Service
class GatewayImpl : Gateway
${consumers}
`;
}
function runKotlinCaptureBenchmark(consumerCount: number, run: number): CaptureBenchResult {
const filePath = `src/SpringDiBench${consumerCount}_${run}.kt`;
const start = performance.now();
const captures = emitKotlinScopeCaptures(denseKotlinSpringSource(consumerCount), filePath);
const elapsedMs = performance.now() - start;
const facts = collectKotlinCaptureSideChannel(filePath)?.springDiFacts ?? [];
return {
consumers: consumerCount,
elapsedMs,
captureCount: captures.length,
factCount: facts.length,
};
}
describe('Spring DI capture O(n²) regression tripwire (#2414)', () => {
it('captures a dense 400-consumer file within a coarse linear-time budget', () => {
const consumers = 400;
const budgetMs = 10_000;
runCaptureBenchmark(4, 0);
const result = runCaptureBenchmark(consumers, 1);
expect(result.factCount).toBe(consumers + 1);
expect(result.captureCount).toBeGreaterThan(consumers * 10);
expect(result.elapsedMs).toBeLessThan(budgetMs);
}, 30_000);
it('captures a dense 400-consumer Kotlin file within a coarse linear-time budget', () => {
const consumers = 400;
const budgetMs = 10_000;
runKotlinCaptureBenchmark(4, 0);
const result = runKotlinCaptureBenchmark(consumers, 1);
expect(result.factCount).toBe(consumers + 1);
expect(result.captureCount).toBeGreaterThan(consumers * 8);
expect(result.elapsedMs).toBeLessThan(budgetMs);
}, 30_000);
});
describe.skipIf(!BENCH_ENABLED)('Spring DI capture scaling benchmark (#2414)', () => {
it('scales sub-quadratically as classes and injection sites grow together', () => {
const scales = [100, 200, 400];
const repetitions = 4;
const results: CaptureBenchResult[] = [];
runCaptureBenchmark(8, 0);
for (const consumers of scales) {
let elapsedMs = 0;
let captureCount = 0;
let factCount = 0;
for (let run = 0; run < repetitions; run++) {
const current = runCaptureBenchmark(consumers, run + 1);
elapsedMs += current.elapsedMs;
captureCount = current.captureCount;
factCount = current.factCount;
}
results.push({ consumers, elapsedMs, captureCount, factCount });
console.log(
` capture n=${consumers} ×${repetitions}: ${elapsedMs.toFixed(1)}ms ` +
`(${factCount} facts, ${captureCount} captures/run)`,
);
}
const first = results[0];
const last = results[results.length - 1];
const sizeRatio = last.consumers / first.consumers;
if (first.elapsedMs >= 20) {
const wallRatio = last.elapsedMs / first.elapsedMs;
expect(wallRatio).toBeLessThan(Math.pow(sizeRatio, 1.5));
} else {
expect(last.elapsedMs).toBeLessThan(10_000);
}
expect(last.factCount).toBe(last.consumers + 1);
}, 120_000);
});
describe.skipIf(!BENCH_ENABLED)('Kotlin Spring DI capture scaling benchmark (#2414)', () => {
it('scales sub-quadratically as classes and injection sites grow together', () => {
const scales = [100, 200, 400];
const repetitions = 4;
const results: CaptureBenchResult[] = [];
runKotlinCaptureBenchmark(8, 0);
for (const consumers of scales) {
let elapsedMs = 0;
let captureCount = 0;
let factCount = 0;
for (let run = 0; run < repetitions; run++) {
const current = runKotlinCaptureBenchmark(consumers, run + 1);
elapsedMs += current.elapsedMs;
captureCount = current.captureCount;
factCount = current.factCount;
}
results.push({ consumers, elapsedMs, captureCount, factCount });
console.log(
` kotlin capture n=${consumers} ×${repetitions}: ${elapsedMs.toFixed(1)}ms ` +
`(${factCount} facts, ${captureCount} captures/run)`,
);
}
const first = results[0];
const last = results[results.length - 1];
const sizeRatio = last.consumers / first.consumers;
if (first.elapsedMs >= 20) {
const wallRatio = last.elapsedMs / first.elapsedMs;
expect(wallRatio).toBeLessThan(Math.pow(sizeRatio, 1.5));
} else {
expect(last.elapsedMs).toBeLessThan(10_000);
}
expect(last.factCount).toBe(last.consumers + 1);
}, 120_000);
});
function writeSpringDiRepo(consumerCount: number): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `spring-di-bench-${consumerCount}-`));
fs.writeFileSync(
path.join(dir, 'Gateway.java'),
`package com.example;
public interface Gateway {}
`,
);
fs.writeFileSync(
path.join(dir, 'GatewayImpl.java'),
`package com.example;
import org.springframework.stereotype.Service;
@Service
public class GatewayImpl implements Gateway {}
`,
);
for (let index = 0; index < consumerCount; index++) {
fs.writeFileSync(
path.join(dir, `Consumer${index}.java`),
`package com.example;
import org.springframework.stereotype.Service;
@Service
public class Consumer${index} {
public Consumer${index}(Gateway gateway) {}
}
`,
);
}
return dir;
}
describe.skipIf(!BENCH_ENABLED)('Spring DI end-to-end scaling benchmark (#2414)', () => {
it('keeps full-pipeline injection resolution sub-quadratic across file counts', async () => {
const scales = [25, 50, 100];
const results: Array<{ consumers: number; elapsedMs: number; injects: number }> = [];
for (const consumers of scales) {
const dir = writeSpringDiRepo(consumers);
try {
const start = performance.now();
const result = await runPipelineFromRepo(dir, () => {}, {});
const elapsedMs = performance.now() - start;
const injects = [...result.graph.iterRelationshipsByType('INJECTS')].length;
results.push({ consumers, elapsedMs, injects });
console.log(
` pipeline n=${consumers}: ${elapsedMs.toFixed(1)}ms (${injects} INJECTS edges)`,
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
for (const result of results) expect(result.injects).toBe(result.consumers);
const first = results[0];
const last = results[results.length - 1];
const sizeRatio = last.consumers / first.consumers;
const wallRatio = last.elapsedMs / first.elapsedMs;
expect(wallRatio).toBeLessThan(Math.pow(sizeRatio, 1.5));
}, 300_000);
});

View file

@ -41,6 +41,15 @@ public class Consumer {
}
`;
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;
@ -69,6 +78,19 @@ function injectsPairs(result: PipelineResult): string[] {
.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;
@ -117,6 +139,28 @@ describe('Spring DI collection-injection pipeline (#2200)', () => {
});
});
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;
@ -140,3 +184,395 @@ describe('Spring DI pipeline negative control: no injection annotations anywhere
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);
});
});

View file

@ -17,6 +17,7 @@ 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,
springDiFieldMatcher,
} from '../../../src/core/ingestion/di-extractors/spring.js';
import { generateId } from '../../../src/lib/utils.js';
@ -728,6 +729,81 @@ describe('di phase', () => {
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('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 });
});
});
// ---------------------------------------------------------------------------

View file

@ -19,6 +19,12 @@ function captureClassAnnotations(code: string): JavaCaptureSideChannel['classAnn
return collectJavaCaptureSideChannel(filePath)?.classAnnotations ?? [];
}
function captureSpringDiFacts(code: string): NonNullable<JavaCaptureSideChannel['springDiFacts']> {
const filePath = 'src/Test.java';
emitJavaScopeCaptures(code, filePath);
return collectJavaCaptureSideChannel(filePath)?.springDiFacts ?? [];
}
describe('Java class annotation capture', () => {
it('collects annotation names during the existing scope-query traversal', () => {
const facts = captureClassAnnotations(`
@ -51,12 +57,58 @@ describe('Java class annotation capture', () => {
});
});
describe('Java Spring injection syntax capture', () => {
it('preserves constructor, field, method, qualifier, and bean-name syntax in the side channel', () => {
const facts = captureSpringDiFacts(`
@Service("checkout") class Checkout {
Checkout(@Qualifier("fastGateway") Gateway gateway) {}
@Autowired Gateway fallback;
@Inject void setRepo(Repo repo) {}
}
`);
expect(facts).toHaveLength(1);
expect(facts[0].classAnnotations).toEqual([{ name: 'Service', text: '@Service("checkout")' }]);
expect(facts[0].injectionSites).toMatchObject([
{
kind: 'constructor',
implicitConstructor: true,
dependencies: [
{
name: 'gateway',
rawType: 'Gateway',
annotations: [{ name: 'Qualifier', text: '@Qualifier("fastGateway")' }],
},
],
},
{
kind: 'field',
memberName: 'fallback',
dependencies: [{ name: 'fallback', rawType: 'Gateway' }],
},
{
kind: 'method',
memberName: 'setRepo',
dependencies: [{ name: 'repo', rawType: 'Repo' }],
},
]);
});
});
function captureKotlinClassAnnotations(code: string): KotlinCaptureSideChannel['classAnnotations'] {
const filePath = 'src/Test.kt';
emitKotlinScopeCaptures(code, filePath);
return collectKotlinCaptureSideChannel(filePath)?.classAnnotations ?? [];
}
function captureKotlinSpringDiFacts(
code: string,
): NonNullable<KotlinCaptureSideChannel['springDiFacts']> {
const filePath = 'src/Test.kt';
emitKotlinScopeCaptures(code, filePath);
return collectKotlinCaptureSideChannel(filePath)?.springDiFacts ?? [];
}
describe('Kotlin class annotation capture', () => {
it('captures supported class forms and excludes non-candidate declarations', () => {
const facts = captureKotlinClassAnnotations(`
@ -92,6 +144,106 @@ describe('Kotlin class annotation capture', () => {
});
});
describe('Kotlin Spring injection syntax capture', () => {
it('preserves primary constructor, property, method, nullable type, projection, and use-site syntax', () => {
const facts = captureKotlinSpringDiFacts(`
@Service("checkout") @Primary
class Checkout @Autowired constructor(
@param:Qualifier("fastGateway") private val gateway: PaymentGateway,
@Named("repo") repo: Repo?,
val gateways: List<out PaymentGateway>,
) {
@field:Autowired
@field:Qualifier("slowGateway")
lateinit var fallback: PaymentGateway
@set:Inject
var optional: Repo? = null
@Inject
fun setRepo(@Named("repo") repo: Repo) {}
}
`);
expect(facts).toHaveLength(1);
expect(facts[0].classAnnotations).toEqual([
{ name: 'Service', text: '@Service("checkout")' },
{ name: 'Primary', text: '@Primary' },
]);
expect(facts[0].injectionSites).toMatchObject([
{
kind: 'constructor',
implicitConstructor: false,
dependencies: [
{
name: 'gateway',
rawType: 'PaymentGateway',
annotations: [
{
name: 'Qualifier',
text: '@param:Qualifier("fastGateway")',
useSiteTarget: 'param',
},
],
},
{
name: 'repo',
rawType: 'Repo?',
annotations: [{ name: 'Named', text: '@Named("repo")' }],
},
{
name: 'gateways',
rawType: 'List<out PaymentGateway>',
},
],
},
{
kind: 'property',
memberName: 'fallback',
annotations: [
{ name: 'Autowired', text: '@field:Autowired', useSiteTarget: 'field' },
{
name: 'Qualifier',
text: '@field:Qualifier("slowGateway")',
useSiteTarget: 'field',
},
],
},
{
kind: 'property',
memberName: 'optional',
annotations: [{ name: 'Inject', text: '@set:Inject', useSiteTarget: 'set' }],
},
{
kind: 'method',
memberName: 'setRepo',
dependencies: [
{
name: 'repo',
rawType: 'Repo',
annotations: [{ name: 'Named', text: '@Named("repo")' }],
},
],
},
]);
});
it('captures sole stereotype primary constructors as implicit injection sites', () => {
const facts = captureKotlinSpringDiFacts(`
@Service
class Checkout(private val gateway: PaymentGateway)
`);
expect(facts[0].injectionSites).toMatchObject([
{
kind: 'constructor',
implicitConstructor: true,
dependencies: [{ name: 'gateway', rawType: 'PaymentGateway' }],
},
]);
});
});
describe('deriveSpringBeanMetadata', () => {
it('maps all supported canonical stereotypes to roles', () => {
const cases = [