From 710eb729d468deac8dbd1ff2bbbba0421f05f3f0 Mon Sep 17 00:00:00 2001 From: Shining Date: Thu, 23 Jul 2026 20:56:27 +0800 Subject: [PATCH] feat(spring): add Kotlin dependency injection support --- .../src/core/ingestion/di-extractors/index.ts | 8 +- .../core/ingestion/di-extractors/spring.ts | 10 +- .../src/core/ingestion/languages/kotlin.ts | 8 +- .../languages/kotlin/capture-side-channel.ts | 36 +- .../ingestion/languages/kotlin/captures.ts | 13 +- .../languages/kotlin/scope-resolver.ts | 6 +- .../ingestion/languages/kotlin/spring-di.ts | 522 ++++++++++++++++++ gitnexus/src/storage/parse-cache.ts | 4 +- .../integration/spring-di-benchmark.test.ts | 104 +++- .../integration/spring-di-pipeline.test.ts | 182 ++++++ .../test/unit/spring-bean-extractor.test.ts | 108 ++++ 11 files changed, 981 insertions(+), 20 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts diff --git a/gitnexus/src/core/ingestion/di-extractors/index.ts b/gitnexus/src/core/ingestion/di-extractors/index.ts index d69213cc0..2d991032a 100644 --- a/gitnexus/src/core/ingestion/di-extractors/index.ts +++ b/gitnexus/src/core/ingestion/di-extractors/index.ts @@ -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 = new Map< SupportedLanguages, DiResolver ->([[SupportedLanguages.Java, springDiResolver]]); +>([ + [SupportedLanguages.Java, springDiResolver], + [SupportedLanguages.Kotlin, springDiResolver], +]); diff --git a/gitnexus/src/core/ingestion/di-extractors/spring.ts b/gitnexus/src/core/ingestion/di-extractors/spring.ts index 5c1c619f1..5fa4bfd8d 100644 --- a/gitnexus/src/core/ingestion/di-extractors/spring.ts +++ b/gitnexus/src/core/ingestion/di-extractors/spring.ts @@ -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[] = []; diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index be64475a0..18d4fd9c1 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts index 52bcec32b..14ea8f122 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts @@ -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(); /** * 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, diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 408083780..afcbd703e 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -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>(); + const springDiFacts: KotlinSpringDiClassFact[] = []; + const springDiClassNodeIds = new Set(); 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; } diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index 0b0381bc2..e8009fee1 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -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); + }, }; /** diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts new file mode 100644 index 000000000..0d554d829 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts @@ -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: '', + 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: '', + 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() ?? + ''; + 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(); + const resolveFact = ( + annotation: KotlinAnnotationSyntaxFact, + enclosingScope: ScopeId | null = classScope.parent, + ): string | undefined => { + const cacheKey = `${enclosingScope ?? ''}\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(); + 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(); + 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; + } + } + } + } + } +} diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index ee88451c9..60828b709 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 diff --git a/gitnexus/test/integration/spring-di-benchmark.test.ts b/gitnexus/test/integration/spring-di-benchmark.test.ts index 999931e51..5adf5a75b 100644 --- a/gitnexus/test/integration/spring-di-benchmark.test.ts +++ b/gitnexus/test/integration/spring-di-benchmark.test.ts @@ -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( diff --git a/gitnexus/test/integration/spring-di-pipeline.test.ts b/gitnexus/test/integration/spring-di-pipeline.test.ts index 755fcdfd6..a355ad5f2 100644 --- a/gitnexus/test/integration/spring-di-pipeline.test.ts +++ b/gitnexus/test/integration/spring-di-pipeline.test.ts @@ -394,3 +394,185 @@ public class AmbiguousConsumer { ); }); }); + +describe('Kotlin Spring standard injection pipeline (#2414)', () => { + let dir: string; + let result: PipelineResult; + + const sources: Record = { + '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) +`, + 'MutableCollectionConsumer.kt': `package com.example +import org.springframework.stereotype.Service +@Service +class MutableCollectionConsumer(val gateways: MutableList?) +`, + '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); + }); +}); diff --git a/gitnexus/test/unit/spring-bean-extractor.test.ts b/gitnexus/test/unit/spring-bean-extractor.test.ts index 0dc6d66ea..cbc5f5c4a 100644 --- a/gitnexus/test/unit/spring-bean-extractor.test.ts +++ b/gitnexus/test/unit/spring-bean-extractor.test.ts @@ -101,6 +101,14 @@ function captureKotlinClassAnnotations(code: string): KotlinCaptureSideChannel[' return collectKotlinCaptureSideChannel(filePath)?.classAnnotations ?? []; } +function captureKotlinSpringDiFacts( + code: string, +): NonNullable { + 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, + ) { + @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', + }, + ], + }, + { + 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 = [