mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-22 05:23:50 +00:00
feat(spring): add Kotlin dependency injection support
This commit is contained in:
parent
e2c6f92321
commit
710eb729d4
11 changed files with 981 additions and 20 deletions
|
|
@ -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],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
522
gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts
Normal file
522
gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue