feat(spring): add Kotlin dependency injection support

This commit is contained in:
Shining 2026-07-23 20:56:27 +08:00
parent e2c6f92321
commit 710eb729d4
11 changed files with 981 additions and 20 deletions

View file

@ -10,7 +10,8 @@
*
* 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.
* framework actually lands. Java and Kotlin share Spring's attached metadata
* contract while retaining language-specific syntax capture.
*/
import { SupportedLanguages } from 'gitnexus-shared';
@ -67,4 +68,7 @@ export function isSupportedLanguage(value: string): value is SupportedLanguages
export const DI_RESOLVERS: ReadonlyMap<SupportedLanguages, DiResolver> = new Map<
SupportedLanguages,
DiResolver
>([[SupportedLanguages.Java, springDiResolver]]);
>([
[SupportedLanguages.Java, springDiResolver],
[SupportedLanguages.Kotlin, springDiResolver],
]);

View file

@ -51,9 +51,11 @@
* between `<` and the element) are NOT stripped and fail closed —
* acceptable.
*
* Registered under `SupportedLanguages.Java` in `./index.ts` (`DI_RESOLVERS`);
* 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';
@ -274,7 +276,7 @@ function isProviderMatch(value: unknown): value is DiProviderMatch {
);
}
/** Java/Spring resolver registered behind the framework-neutral DI seam. */
/** JVM/Spring resolver registered behind the framework-neutral DI seam. */
export const springDiResolver: DiResolver = {
matchInjectionSites(node): readonly DiInjectionMatch[] {
const matches: DiInjectionMatch[] = [];

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,522 @@
import { makeScopeId, type ParsedFile, type 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 { createSpringAnnotationNameResolver } from '../../frameworks/spring/bean-candidates.js';
import { SPRING_BEAN_STEREOTYPES } from '../../frameworks/spring/bean-catalog.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 { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { getKotlinSpringDiFacts } from './capture-side-channel.js';
import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js';
export interface KotlinAnnotationSyntaxFact {
readonly name: string;
readonly text: string;
readonly useSiteTarget?: string;
}
export interface KotlinSpringDependencyFact {
readonly name: string;
readonly rawType: string;
readonly annotations: readonly KotlinAnnotationSyntaxFact[];
}
export interface KotlinSpringInjectionSiteFact {
readonly kind: 'property' | 'constructor' | 'method';
readonly memberName: string;
readonly implicitConstructor: boolean;
readonly annotations: readonly KotlinAnnotationSyntaxFact[];
readonly dependencies: readonly KotlinSpringDependencyFact[];
}
export interface KotlinSpringDiClassFact {
readonly classScopeId: ScopeId;
readonly classAnnotations: readonly KotlinAnnotationSyntaxFact[];
readonly injectionSites: readonly KotlinSpringInjectionSiteFact[];
}
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) => annotationSimpleName(name)),
);
const KOTLIN_TYPE_NODES = new Set(['user_type', 'nullable_type', 'function_type']);
function annotationSimpleName(name: string): string {
const separator = name.lastIndexOf('.');
return separator === -1 ? name : name.slice(separator + 1);
}
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 hasRelevantAnnotation(annotations: readonly KotlinAnnotationSyntaxFact[]): boolean {
return annotations.some((annotation) =>
CAPTURE_RELEVANT_ANNOTATIONS.has(annotationSimpleName(annotation.name)),
);
}
function hasStereotypeSyntax(annotations: readonly KotlinAnnotationSyntaxFact[]): boolean {
return annotations.some((annotation) =>
STEREOTYPE_SIMPLE_NAMES.has(annotationSimpleName(annotation.name)),
);
}
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 &&
hasStereotypeSyntax(classAnnotations) &&
!hasRelevantAnnotation(annotations);
if (implicitConstructor || hasRelevantAnnotation(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 &&
hasStereotypeSyntax(classAnnotations) &&
!hasRelevantAnnotation(annotations);
if (!implicitConstructor && !hasRelevantAnnotation(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 (!hasRelevantAnnotation(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 (!hasRelevantAnnotation(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 && !hasRelevantAnnotation(classAnnotations)) return null;
const classCapture = nodeToCapture('@spring-di.class', classNode);
return {
classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }),
classAnnotations,
injectionSites,
};
}
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);
}
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 function attachKotlinSpringDiMetadata(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
indexes: ScopeResolutionIndexes,
): void {
const resolveAnnotation = createSpringAnnotationNameResolver(indexes);
for (const parsed of parsedFiles) {
const incomplete = isKotlinPackageSiblingVisibilityIncomplete(parsed.filePath);
for (const fact of getKotlinSpringDiFacts(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: KotlinAnnotationSyntaxFact,
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 = {
isBean: true,
names: [...names],
primary,
};
classNode.properties[SPRING_DI_PROVIDER_PROPERTY] = provider;
}
const matches: DiInjectionMatch[] = [];
const semanticallyOwnedPropertyNames = new Set<string>();
for (const site of fact.injectionSites) {
let injectionAnnotation: KotlinAnnotationSyntaxFact | undefined;
for (const annotation of site.annotations) {
if (!isApplicableInjectionAnnotation(annotation, site)) 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 === 'property') {
semanticallyOwnedPropertyNames.add(site.memberName);
}
for (const dependency of site.dependencies) {
const parsedType = parseKotlinSpringInjectionType(dependency.rawType);
if (parsedType === null) continue;
let qualifierAnnotation: KotlinAnnotationSyntaxFact | undefined;
for (const annotation of dependency.annotations) {
if (!isApplicableQualifierAnnotation(annotation, site)) 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);
if (qualifierAnnotation !== undefined && qualifier === undefined) continue;
const trigger =
injectionAnnotation === undefined
? 'constructor'
: `@${annotationSimpleName(injectionAnnotation.name)} ${site.kind}`;
const location =
site.kind === 'property'
? site.memberName
: `${site.memberName} parameter ${dependency.name}`;
matches.push({
targetTypeName: parsedType.targetTypeName,
cardinality: parsedType.cardinality,
...(qualifier === undefined ? {} : { qualifier }),
reason: `Spring DI: ${trigger} ${location}: ${parsedType.displayType}`,
});
}
}
if (matches.length > 0) classNode.properties[SPRING_DI_INJECTION_SITES_PROPERTY] = matches;
for (const propertyName of semanticallyOwnedPropertyNames) {
for (const { def } of classScope.bindings.get(propertyName) ?? []) {
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

@ -55,8 +55,8 @@ 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 Spring DI facts persist constructor, field, and method injection
// sites plus bean-name and @Primary provider metadata.
// 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

View file

@ -3,14 +3,16 @@
*
* Guards the two hot paths introduced by standard Spring injection:
*
* 1. Java capture emission collects DI facts from the existing scope-query
* traversal instead of recursively walking the AST root a second time.
* 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 tripwire uses one dense Java file to catch a capture
* re-regression. The gated suites measure capture and full-pipeline scaling:
* The normal-CI tripwires use dense Java/Kotlin files to catch a capture
* re-regression. The gated suites measure Java capture and full-pipeline
* scaling:
*
* GITNEXUS_BENCH=1 npx vitest run test/integration/spring-di-benchmark.test.ts
*/
@ -20,6 +22,8 @@ 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';
@ -75,6 +79,49 @@ function runCaptureBenchmark(consumerCount: number, run: number): CaptureBenchRe
};
}
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;
@ -87,6 +134,18 @@ describe('Spring DI capture O(n²) regression tripwire (#2414)', () => {
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)', () => {
@ -126,6 +185,43 @@ describe.skipIf(!BENCH_ENABLED)('Spring DI capture scaling benchmark (#2414)', (
}, 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(

View file

@ -394,3 +394,185 @@ public class AmbiguousConsumer {
);
});
});
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

@ -101,6 +101,14 @@ function captureKotlinClassAnnotations(code: string): KotlinCaptureSideChannel['
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(`
@ -136,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 = [