From 4af6fe8587f8a286cd333178409f43c7fd0610c9 Mon Sep 17 00:00:00 2001 From: MyShining <249674729@qq.com> Date: Fri, 24 Jul 2026 15:25:38 +0800 Subject: [PATCH] feat(spring): resolve constructor and standard injection (#2632) --- gitnexus-shared/src/graph/types.ts | 16 +- .../src/core/ingestion/di-extractors/index.ts | 78 ++-- .../core/ingestion/di-extractors/spring.ts | 102 +++- .../frameworks/spring/di-metadata.ts | 317 +++++++++++++ .../languages/java/capture-side-channel.ts | 30 +- .../core/ingestion/languages/java/captures.ts | 12 + .../languages/java/scope-resolver.ts | 2 + .../ingestion/languages/java/spring-di.ts | 153 ++++++ .../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 | 299 ++++++++++++ .../src/core/ingestion/pipeline-phases/di.ts | 395 ++++++++-------- gitnexus/src/storage/parse-cache.ts | 4 +- .../integration/spring-di-benchmark.test.ts | 284 ++++++++++++ .../integration/spring-di-pipeline.test.ts | 436 ++++++++++++++++++ gitnexus/test/unit/ingestion/di.test.ts | 76 +++ .../test/unit/spring-bean-extractor.test.ts | 152 ++++++ 19 files changed, 2177 insertions(+), 242 deletions(-) create mode 100644 gitnexus/src/core/ingestion/frameworks/spring/di-metadata.ts create mode 100644 gitnexus/src/core/ingestion/languages/java/spring-di.ts create mode 100644 gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts create mode 100644 gitnexus/test/integration/spring-di-benchmark.test.ts diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index a7d43a918..97b45b085 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -127,14 +127,14 @@ export type RelationshipType = | 'ENTRY_POINT_OF' | 'WRAPS' | 'QUERIES' - /** Dependency-injection edge: a consumer class receives every implementer - * of interface `T` via a container-injected collection-typed field - * (`List`, `Set`, `Collection`, or `Map`). Precondition: the - * field carries an injection annotation recognized by a per-language - * matcher registered in `di-extractors/` (Java/Spring today: `@Autowired` - * or `@Inject`; `@Resource` is excluded — by-name-first semantics). - * Source = the consumer Class node (the one owning the field). - * Target = an implementing Class node. + /** Dependency-injection edge: a consumer class receives a likely provider + * through constructor, field, method, or collection injection. A + * per-language resolver identifies the site and provider metadata; the + * shared DI phase uses type heritage, qualifier names, and preferred + * provider markers to resolve it. Ambiguous single injection is represented + * by multiple lower-confidence edges instead of a fabricated exact target. + * Source = the consumer Class node (the one owning the injection site). + * Target = a concrete provider Class node. * Framework specifics live in the `reason` payload (e.g. * `Spring DI: @Autowired List`), not in this type contract. * Lets Cypher queries trace which beans the container injects into a given diff --git a/gitnexus/src/core/ingestion/di-extractors/index.ts b/gitnexus/src/core/ingestion/di-extractors/index.ts index 0c0869c52..28582e635 100644 --- a/gitnexus/src/core/ingestion/di-extractors/index.ts +++ b/gitnexus/src/core/ingestion/di-extractors/index.ts @@ -1,61 +1,77 @@ /** - * Per-language DI field-matcher registry — the lookup the generic `di` - * pipeline phase uses to decide whether a `Property` node is a - * dependency-injection fan-out candidate. + * Per-language DI resolver registry — the lookup the generic `di` pipeline + * phase uses to discover injection sites and provider metadata on graph nodes. * * Mirrors `scope-resolution/pipeline/registry.ts` (`SCOPE_RESOLVERS`): a - * single-valued `ReadonlyMap` consumed by + * single-valued `ReadonlyMap` consumed by * a framework-neutral phase, so no language or framework names leak into - * shared pipeline code. Adding a framework is two lines: implement a - * `DiFieldMatcher` in `di-extractors/.ts` and register it here. + * shared pipeline code. Adding a framework means implementing a `DiResolver` + * in `di-extractors/.ts` and registering it here. * - * Scope honesty: matchers are per-language *field-injection* matchers. - * Constructor injection (the dominant modern Spring idiom) lives on - * Method/parameter nodes and would require widening the phase's routing — - * deliberately out of scope (see the plan's Deferred work). The registry is - * single-valued per language, matching the `SCOPE_RESOLVERS` shape; widen the - * value type to arrays only when a second same-language framework actually - * lands (a one-line type change then). + * The registry is single-valued per language, matching the `SCOPE_RESOLVERS` + * shape; widen the value type to arrays only when a second same-language + * framework actually lands. Java and Kotlin share Spring's attached metadata + * contract while retaining language-specific syntax capture. */ import { SupportedLanguages } from 'gitnexus-shared'; import type { GraphNode } from 'gitnexus-shared'; -import { springDiFieldMatcher } from './spring.js'; +import { springDiResolver } from './spring.js'; -/** A successful DI field match, produced by a per-language matcher. */ -export interface DiFieldMatch { - /** The element type name `T` — the injected bean interface. */ - elementTypeName: string; +/** A successful injection-site match, produced by a per-language resolver. */ +export interface DiInjectionMatch { + /** The requested dependency type name. */ + targetTypeName: string; + /** A collection receives every matching provider; a single site may need + * framework-specific named/preferred-provider disambiguation. */ + cardinality: 'single' | 'collection'; + /** Statically known provider name requested at the injection site. The + * resolver owns the human-readable explanation of that selection. */ + namedSelection?: { + name: string; + reason: string; + }; /** Human-readable edge reason. Framework specifics (names, idioms, * collection wrapper, gating annotation) live in this payload so the * shared `di` phase stays framework-neutral. */ reason: string; } -/** - * A per-language field-injection matcher: given a `Property` node, return the - * parsed DI match or `null` when the field is not container-injected. The - * matcher receives the whole node (not pre-plucked fields) so the shared - * phase stays ignorant of which properties matter. - */ -export type DiFieldMatcher = (node: GraphNode) => DiFieldMatch | null; +/** Provider metadata used by the shared resolver without naming a framework. */ +export interface DiProviderMatch { + /** Provider names and aliases that can satisfy a named injection. */ + names: readonly string[]; + /** Present when the framework marks this as its preferred candidate. The + * value is appended to the emitted edge reason when it disambiguates. */ + preferenceReason?: string; +} + +/** Per-language DI behavior. Matchers receive whole nodes so the shared phase + * remains ignorant of language/framework-specific property shapes. */ +export interface DiResolver { + matchInjectionSites(node: GraphNode): readonly DiInjectionMatch[]; + matchProvider(node: GraphNode): DiProviderMatch | null; +} /** All `SupportedLanguages` string values, for narrowing raw graph strings. */ const SUPPORTED_LANGUAGE_VALUES: ReadonlySet = new Set(Object.values(SupportedLanguages)); /** * Type guard narrowing an arbitrary graph `language` string to - * `SupportedLanguages`, so `DI_MATCHERS.get()` needs no cast. + * `SupportedLanguages`, so `DI_RESOLVERS.get()` needs no cast. */ export function isSupportedLanguage(value: string): value is SupportedLanguages { return SUPPORTED_LANGUAGE_VALUES.has(value); } -/** Map of `SupportedLanguages` → `DiFieldMatcher`. The `di` phase routes each - * `Property` node here by `node.properties.language`; no entry ⇒ the node is +/** Map of `SupportedLanguages` → `DiResolver`. The `di` phase routes each + * graph node here by `node.properties.language`; no entry ⇒ the node is * skipped. This is the single source of truth for which languages (and, * transitively, frameworks) produce INJECTS edges. */ -export const DI_MATCHERS: ReadonlyMap = new Map< +export const DI_RESOLVERS: ReadonlyMap = new Map< SupportedLanguages, - DiFieldMatcher ->([[SupportedLanguages.Java, springDiFieldMatcher]]); + DiResolver +>([ + [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 0a6da59ea..44c5b1aca 100644 --- a/gitnexus/src/core/ingestion/di-extractors/spring.ts +++ b/gitnexus/src/core/ingestion/di-extractors/spring.ts @@ -51,13 +51,15 @@ * between `<` and the element) are NOT stripped and fail closed — * acceptable. * - * Registered under `SupportedLanguages.Java` in `./index.ts` (`DI_MATCHERS`); - * language routing is the registry's job, so the matcher itself never reads - * `node.properties.language`. + * Registered for Java and Kotlin in `./index.ts` (`DI_RESOLVERS`); language + * routing is the registry's job, so the matcher itself never reads + * `node.properties.language`. Kotlin's AST-backed class metadata is the + * primary path because Kotlin Property extraction intentionally exposes less + * annotation/type syntax than Java's legacy field contract. */ import type { GraphNode } from 'gitnexus-shared'; -import type { DiFieldMatch, DiFieldMatcher } from './index.js'; +import type { DiInjectionMatch, DiProviderMatch, DiResolver } from './index.js'; import { isDev } from '../utils/env.js'; import { logger } from '../../logger.js'; @@ -84,6 +86,17 @@ const WILDCARD_SUPER_PREFIX = '? super '; * punctuation) fails closed. */ const JAVA_TYPE_NAME_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/; +/** Ephemeral Class-node property populated by Java's post-resolution Spring + * metadata hook. It is consumed in the same pipeline run before persistence. */ +export const SPRING_DI_INJECTION_SITES_PROPERTY = 'springDiInjectionSites'; + +/** Ephemeral Class-node property carrying Spring bean names / @Primary. */ +export const SPRING_DI_PROVIDER_PROPERTY = 'springDiProvider'; + +/** Marker placed on Property nodes whose richer AST-backed field fact was + * attached to the owning Class, suppressing the legacy collection fallback. */ +export const SPRING_DI_CAPTURED_FIELD_PROPERTY = 'springDiCapturedField'; + /** * Split a generic-argument list on TOP-LEVEL commas only, tracking `<`/`>` * bracket depth so nested generics (e.g. the `Pair` key in @@ -181,13 +194,33 @@ export function parseSpringCollectionType( return { collectionType: wrapper, elementTypeName }; } +/** Parse either a supported collect-all type or a standard single bean type. */ +export function parseSpringInjectionType( + rawDeclaredType: string, +): { targetTypeName: string; cardinality: 'single' | 'collection'; displayType: string } | null { + const collection = parseSpringCollectionType(rawDeclaredType); + if (collection !== null) { + return { + targetTypeName: collection.elementTypeName, + cardinality: 'collection', + displayType: `${collection.collectionType}<${collection.elementTypeName}>`, + }; + } + + const normalized = rawDeclaredType.replace(/\s+/g, '').trim(); + if (!JAVA_TYPE_NAME_PATTERN.test(normalized)) return null; + return { targetTypeName: normalized, cardinality: 'single', displayType: normalized }; +} + /** * Match a `Property` node against Spring's collection-injection shape. * * Returns the parsed match (with a Spring-specific human-readable `reason` * payload) or `null` when the field is not container-injected. */ -export const springDiFieldMatcher: DiFieldMatcher = (node: GraphNode): DiFieldMatch | null => { +export const springDiFieldMatcher = ( + node: GraphNode, +): { elementTypeName: string; reason: string } | null => { // Injection-annotation gate: only fields the container actually // injects (@Autowired / @Inject) are candidates. Plain collection // fields are never injected; @Resource is deliberately excluded @@ -220,3 +253,62 @@ export const springDiFieldMatcher: DiFieldMatcher = (node: GraphNode): DiFieldMa reason: `Spring DI: ${matchedAnnotation} ${parsed.collectionType}<${parsed.elementTypeName}>`, }; }; + +function isInjectionMatch(value: unknown): value is DiInjectionMatch { + if (value === null || typeof value !== 'object') return false; + const match = value as Partial; + const namedSelection = match.namedSelection; + return ( + typeof match.targetTypeName === 'string' && + (match.cardinality === 'single' || match.cardinality === 'collection') && + typeof match.reason === 'string' && + (namedSelection === undefined || + (typeof namedSelection === 'object' && + namedSelection !== null && + typeof namedSelection.name === 'string' && + typeof namedSelection.reason === 'string')) + ); +} + +function isProviderMatch(value: unknown): value is DiProviderMatch { + if (value === null || typeof value !== 'object') return false; + const provider = value as Partial; + return ( + Array.isArray(provider.names) && + provider.names.every((name) => typeof name === 'string') && + (provider.preferenceReason === undefined || typeof provider.preferenceReason === 'string') + ); +} + +/** JVM/Spring resolver registered behind the framework-neutral DI seam. */ +export const springDiResolver: DiResolver = { + matchInjectionSites(node): readonly DiInjectionMatch[] { + const matches: DiInjectionMatch[] = []; + + // Preserve the existing Property-node collection contract for hand-built + // graphs and for compatibility with pre-#2414 extraction fixtures. + if (node.label === 'Property' && node.properties[SPRING_DI_CAPTURED_FIELD_PROPERTY] !== true) { + const field = springDiFieldMatcher(node); + if (field !== null) { + matches.push({ + targetTypeName: field.elementTypeName, + cardinality: 'collection', + reason: field.reason, + }); + } + } + + const attached = node.properties[SPRING_DI_INJECTION_SITES_PROPERTY]; + if (Array.isArray(attached)) { + for (const candidate of attached) { + if (isInjectionMatch(candidate)) matches.push(candidate); + } + } + return matches; + }, + + matchProvider(node): DiProviderMatch | null { + const attached = node.properties[SPRING_DI_PROVIDER_PROPERTY]; + return isProviderMatch(attached) ? attached : null; + }, +}; diff --git a/gitnexus/src/core/ingestion/frameworks/spring/di-metadata.ts b/gitnexus/src/core/ingestion/frameworks/spring/di-metadata.ts new file mode 100644 index 000000000..5083fb4ed --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/di-metadata.ts @@ -0,0 +1,317 @@ +import type { ParsedFile, ScopeId } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { DiInjectionMatch, DiProviderMatch } from '../../di-extractors/index.js'; +import { + parseSpringInjectionType, + SPRING_DI_CAPTURED_FIELD_PROPERTY, + SPRING_DI_INJECTION_SITES_PROPERTY, + SPRING_DI_PROVIDER_PROPERTY, +} from '../../di-extractors/spring.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { createSpringAnnotationNameResolver } from './bean-candidates.js'; +import { SPRING_BEAN_STEREOTYPES } from './bean-catalog.js'; + +export interface SpringDiAnnotationFact { + readonly name: string; + readonly text: string; +} + +export interface SpringDiDependencyFact { + readonly name: string; + readonly rawType: string; + readonly annotations: readonly Annotation[]; +} + +export interface SpringDiInjectionSiteFact< + Annotation extends SpringDiAnnotationFact, + SiteKind extends string, +> { + readonly kind: SiteKind; + readonly memberName: string; + readonly implicitConstructor: boolean; + readonly annotations: readonly Annotation[]; + readonly dependencies: readonly SpringDiDependencyFact[]; +} + +export interface SpringDiClassFact< + Annotation extends SpringDiAnnotationFact, + SiteKind extends string, +> { + readonly classScopeId: ScopeId; + readonly classAnnotations: readonly Annotation[]; + readonly injectionSites: readonly SpringDiInjectionSiteFact[]; +} + +const INJECTION_ANNOTATIONS = new Set([ + 'org.springframework.beans.factory.annotation.Autowired', + 'jakarta.inject.Inject', + 'javax.inject.Inject', +]); + +const QUALIFIER_ANNOTATIONS = new Set([ + 'org.springframework.beans.factory.annotation.Qualifier', + 'jakarta.inject.Named', + 'javax.inject.Named', +]); + +const PRIMARY_ANNOTATIONS = new Set(['org.springframework.context.annotation.Primary']); + +const RESOLVABLE_DI_ANNOTATIONS = new Set([ + ...SPRING_BEAN_STEREOTYPES.keys(), + ...INJECTION_ANNOTATIONS, + ...QUALIFIER_ANNOTATIONS, + ...PRIMARY_ANNOTATIONS, +]); + +const CAPTURE_RELEVANT_ANNOTATIONS = new Set([ + 'Autowired', + 'Inject', + 'Qualifier', + 'Named', + 'Primary', + 'Component', + 'Service', + 'Repository', + 'Controller', + 'RestController', + 'Configuration', +]); + +const STEREOTYPE_SIMPLE_NAMES = new Set( + [...SPRING_BEAN_STEREOTYPES.keys()].map((name) => springAnnotationSimpleName(name)), +); + +export function springAnnotationSimpleName(name: string): string { + const separator = name.lastIndexOf('.'); + return separator === -1 ? name : name.slice(separator + 1); +} + +export function hasSpringDiRelevantAnnotation( + annotations: readonly SpringDiAnnotationFact[], +): boolean { + return annotations.some((annotation) => + CAPTURE_RELEVANT_ANNOTATIONS.has(springAnnotationSimpleName(annotation.name)), + ); +} + +export function hasSpringStereotypeSyntax(annotations: readonly SpringDiAnnotationFact[]): boolean { + return annotations.some((annotation) => + STEREOTYPE_SIMPLE_NAMES.has(springAnnotationSimpleName(annotation.name)), + ); +} + +function staticStringArgument(annotationText: string): string | undefined { + const args = annotationText.match(/\((.*)\)$/s)?.[1]?.trim(); + if (args === undefined) return undefined; + const value = args.replace(/^value\s*=\s*/, '').trim(); + const literal = value.match(/^"((?:\\.|[^"\\])*)"$/s); + if (literal === null) return undefined; + try { + return JSON.parse(`"${literal[1]}"`) as string; + } catch { + return undefined; + } +} + +function defaultBeanName(className: string): string { + if (className.length === 0) return className; + if ( + className.length > 1 && + className[0] !== className[0].toLowerCase() && + className[1] !== className[1].toLowerCase() + ) { + return className; + } + return className[0].toLowerCase() + className.slice(1); +} + +type ParsedSpringInjectionType = NonNullable>; + +export interface SpringDiMetadataAdapter< + Annotation extends SpringDiAnnotationFact, + SiteKind extends string, +> { + getFacts(filePath: string): readonly SpringDiClassFact[]; + isPackageVisibilityIncomplete(filePath: string): boolean; + parseInjectionType(rawType: string): ParsedSpringInjectionType | null; + capturedMemberKind: SiteKind; + isInjectionAnnotationApplicable?( + annotation: Annotation, + site: SpringDiInjectionSiteFact, + ): boolean; + isQualifierAnnotationApplicable?( + annotation: Annotation, + site: SpringDiInjectionSiteFact, + ): boolean; +} + +/** + * Build the post-resolution Spring DI metadata hook shared by language adapters. + * Language adapters retain syntax capture, type normalization, use-site rules, + * and side-channel ownership; this function owns framework semantics only. + */ +export function createSpringDiMetadataAttacher< + Annotation extends SpringDiAnnotationFact, + SiteKind extends string, +>(adapter: SpringDiMetadataAdapter) { + return ( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + indexes: ScopeResolutionIndexes, + ): void => { + const resolveAnnotation = createSpringAnnotationNameResolver(indexes); + + for (const parsed of parsedFiles) { + const incomplete = adapter.isPackageVisibilityIncomplete(parsed.filePath); + for (const fact of adapter.getFacts(parsed.filePath)) { + const classScope = indexes.scopeTree.getScope(fact.classScopeId); + if (classScope === undefined || classScope.kind !== 'Class') continue; + const classDef = classScope.ownedDefs.find((definition) => definition.type === 'Class'); + if (classDef === undefined) continue; + const graphId = resolveDefGraphId(parsed.filePath, classDef, nodeLookup); + if (graphId === undefined) continue; + const classNode = graph.getNode(graphId); + if (classNode === undefined || classNode.label !== 'Class') continue; + + const resolvedAnnotations = new Map(); + const resolveFact = ( + annotation: Annotation, + 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 = { + names: [...names], + ...(primary ? { preferenceReason: 'selected @Primary' } : {}), + }; + classNode.properties[SPRING_DI_PROVIDER_PROPERTY] = provider; + } + + const matches: DiInjectionMatch[] = []; + const semanticallyOwnedMemberNames = new Set(); + for (const site of fact.injectionSites) { + let injectionAnnotation: Annotation | undefined; + for (const annotation of site.annotations) { + if (adapter.isInjectionAnnotationApplicable?.(annotation, site) === false) continue; + const resolved = resolveFact(annotation, classScope.id); + if (resolved !== undefined && INJECTION_ANNOTATIONS.has(resolved)) { + injectionAnnotation = annotation; + break; + } + } + if (injectionAnnotation === undefined) { + if (!site.implicitConstructor || frameworkAnnotations.length === 0) continue; + } else if (site.kind === adapter.capturedMemberKind) { + // Claim the member only after its injection annotation resolves to + // a recognized FQN. Ambiguous wildcard imports stay unclaimed so + // the legacy collection matcher can fall back. A dynamic qualifier + // later fails closed, but this path still owns the member and must + // suppress that legacy fallback. + semanticallyOwnedMemberNames.add(site.memberName); + } + + for (const dependency of site.dependencies) { + const parsedType = adapter.parseInjectionType(dependency.rawType); + if (parsedType === null) continue; + let qualifierAnnotation: Annotation | undefined; + for (const annotation of dependency.annotations) { + if (adapter.isQualifierAnnotationApplicable?.(annotation, site) === false) continue; + const resolved = resolveFact(annotation, classScope.id); + if (resolved !== undefined && QUALIFIER_ANNOTATIONS.has(resolved)) { + qualifierAnnotation = annotation; + break; + } + } + const qualifier = + qualifierAnnotation === undefined + ? undefined + : staticStringArgument(qualifierAnnotation.text); + // A present-but-dynamic qualifier is not the same as no qualifier. + // Without its value we cannot choose a provider honestly, so fail + // closed instead of emitting the unqualified candidate set. + if (qualifierAnnotation !== undefined && qualifier === undefined) continue; + const trigger = + injectionAnnotation === undefined + ? 'constructor' + : `@${springAnnotationSimpleName(injectionAnnotation.name)} ${site.kind}`; + const location = + site.kind === adapter.capturedMemberKind + ? site.memberName + : `${site.memberName} parameter ${dependency.name}`; + matches.push({ + targetTypeName: parsedType.targetTypeName, + cardinality: parsedType.cardinality, + ...(qualifier === undefined + ? {} + : { + namedSelection: { + name: qualifier, + reason: `qualifier "${qualifier}"`, + }, + }), + reason: `Spring DI: ${trigger} ${location}: ${parsedType.displayType}`, + }); + } + } + if (matches.length > 0) { + classNode.properties[SPRING_DI_INJECTION_SITES_PROPERTY] = matches; + } + + for (const memberName of semanticallyOwnedMemberNames) { + for (const { def } of classScope.bindings.get(memberName) ?? []) { + if (def.ownerId !== classDef.nodeId) continue; + const propertyId = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (propertyId === undefined) continue; + const property = graph.getNode(propertyId); + if (property?.label === 'Property') { + property.properties[SPRING_DI_CAPTURED_FIELD_PROPERTY] = true; + } + } + } + } + } + }; +} diff --git a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts index 348c91653..51a8d6b2e 100644 --- a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts @@ -10,6 +10,7 @@ import { } from '../jvm/package-facts.js'; import { getJavaPackageFact, setJavaPackageFact } from './package-facts.js'; import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js'; +import type { JavaSpringDiClassFact } from './spring-di.js'; export type JavaClassAnnotationFact = ClassAnnotationFact; @@ -18,15 +19,18 @@ export interface JavaCaptureSideChannel { readonly packageFact: JvmPackageFact; readonly classAnnotations: readonly JavaClassAnnotationFact[]; readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[]; + readonly springDiFacts?: readonly JavaSpringDiClassFact[]; } const classAnnotations = createClassAnnotationFactStore(); const springConfigConsumers = new Map(); +const springDiFacts = new Map(); /** Clear facts retained by a prior workspace pass in a long-lived process. */ export function clearJavaClassAnnotationFacts(): void { classAnnotations.clear(); springConfigConsumers.clear(); + springDiFacts.clear(); } /** Store the annotation syntax collected by Java's existing scope-query traversal. */ @@ -51,14 +55,32 @@ export function getJavaSpringConfigConsumerFacts( return springConfigConsumers.get(filePath) ?? []; } +export function setJavaSpringDiFacts( + filePath: string, + facts: readonly JavaSpringDiClassFact[], +): void { + if (facts.length === 0) springDiFacts.delete(filePath); + else springDiFacts.set(filePath, facts); +} + +export function getJavaSpringDiFacts(filePath: string): readonly JavaSpringDiClassFact[] { + return springDiFacts.get(filePath) ?? []; +} + /** Snapshot worker-local Java annotation facts for ParsedFile serialization. */ export function collectJavaCaptureSideChannel( filePath: string, ): JavaCaptureSideChannel | undefined { const facts = classAnnotations.get(filePath); const configConsumers = springConfigConsumers.get(filePath) ?? []; + const diFacts = springDiFacts.get(filePath) ?? []; const packageFact = getJavaPackageFact(filePath); - if (facts.length === 0 && configConsumers.length === 0 && packageFact === undefined) { + if ( + facts.length === 0 && + configConsumers.length === 0 && + diFacts.length === 0 && + packageFact === undefined + ) { return undefined; } return { @@ -66,6 +88,7 @@ export function collectJavaCaptureSideChannel( packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT, classAnnotations: facts, ...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}), + ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), }; } @@ -85,6 +108,7 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { ) { setJavaClassAnnotationFacts(parsed.filePath, []); setJavaSpringConfigConsumerFacts(parsed.filePath, []); + setJavaSpringDiFacts(parsed.filePath, []); setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); return; } @@ -93,6 +117,10 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { parsed.filePath, Array.isArray(data.springConfigConsumers) ? data.springConfigConsumers : [], ); + setJavaSpringDiFacts( + parsed.filePath, + Array.isArray(data.springDiFacts) ? data.springDiFacts : [], + ); setJavaPackageFact( parsed.filePath, isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT, diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 62d9fe788..c22108a54 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -35,10 +35,12 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { setJavaClassAnnotationFacts, setJavaSpringConfigConsumerFacts, + setJavaSpringDiFacts, } from './capture-side-channel.js'; import { captureJavaPackageFact } from './package-facts.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js'; +import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.js'; /** Declaration anchors that carry function-like arity metadata. */ const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const; @@ -99,6 +101,8 @@ export function emitJavaScopeCaptures( const rawMatches = getJavaScopeQuery().matches(tree.rootNode); const out: CaptureMatch[] = []; const classAnnotations = new Map>(); + const springDiFacts: JavaSpringDiClassFact[] = []; + const springDiClassNodeIds = new Set(); for (const m of rawMatches) { const grouped: Record = {}; @@ -118,6 +122,13 @@ export function emitJavaScopeCaptures( } if (Object.keys(grouped).length === 0) continue; + const springDiClassNode = nodeIfType(nodeMap['@scope.class'], 'class_declaration'); + if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) { + springDiClassNodeIds.add(springDiClassNode.id); + const fact = captureJavaSpringDiClassFact(springDiClassNode, filePath); + if (fact !== null) springDiFacts.push(fact); + } + const annotatedClass = grouped['@class-annotation.class']; const annotationName = grouped['@class-annotation.name']; if (annotatedClass !== undefined && annotationName !== undefined) { @@ -288,6 +299,7 @@ export function emitJavaScopeCaptures( filePath, captureJavaSpringConfigConsumerFacts(tree.rootNode, filePath), ); + setJavaSpringDiFacts(filePath, springDiFacts); return [ ...resolveVarTypeBindings(out), diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index 52266456b..e79624fea 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -31,6 +31,7 @@ import { import { populateJavaPackageSiblings } from './package-siblings.js'; import { attachSpringBeanCandidateMetadata } from './spring-bean-metadata.js'; import { attachJavaSpringConfigBindings } from './spring-config-bindings.js'; +import { attachJavaSpringDiMetadata } from './spring-di.js'; import { applyJavaCaptureSideChannel, clearJavaClassAnnotationFacts, @@ -86,6 +87,7 @@ const javaScopeResolver: ScopeResolver = { populateRangeBindings: populateJavaCrossFileReturnTypes, emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes, ctx) => { attachSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes); + attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx); }, }; diff --git a/gitnexus/src/core/ingestion/languages/java/spring-di.ts b/gitnexus/src/core/ingestion/languages/java/spring-di.ts new file mode 100644 index 000000000..2b106060c --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/spring-di.ts @@ -0,0 +1,153 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringDiMetadataAttacher, + hasSpringDiRelevantAnnotation, + hasSpringStereotypeSyntax, + type SpringDiAnnotationFact, + type SpringDiClassFact, + type SpringDiDependencyFact, + type SpringDiInjectionSiteFact, +} from '../../frameworks/spring/di-metadata.js'; +import { parseSpringInjectionType } from '../../di-extractors/spring.js'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js'; +import { getJavaSpringDiFacts } from './capture-side-channel.js'; + +export type JavaAnnotationSyntaxFact = SpringDiAnnotationFact; + +export type JavaSpringDependencyFact = SpringDiDependencyFact; + +type JavaSpringInjectionSiteKind = 'field' | 'constructor' | 'method'; + +export type JavaSpringInjectionSiteFact = SpringDiInjectionSiteFact< + JavaAnnotationSyntaxFact, + JavaSpringInjectionSiteKind +>; + +export type JavaSpringDiClassFact = SpringDiClassFact< + JavaAnnotationSyntaxFact, + JavaSpringInjectionSiteKind +>; + +function annotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] { + const facts: JavaAnnotationSyntaxFact[] = []; + for (const child of node.namedChildren) { + if (child.type !== 'modifiers') continue; + for (const modifier of child.namedChildren) { + if (modifier.type !== 'marker_annotation' && modifier.type !== 'annotation') continue; + const nameNode = modifier.childForFieldName('name') ?? modifier.firstNamedChild; + if (nameNode === null) continue; + facts.push({ name: nameNode.text.trim(), text: modifier.text.trim() }); + } + } + return facts; +} + +function dependenciesOf(callable: SyntaxNode): JavaSpringDependencyFact[] { + const parameters = callable.childForFieldName('parameters'); + if (parameters === null) return []; + const dependencies: JavaSpringDependencyFact[] = []; + for (const parameter of parameters.namedChildren) { + if (parameter.type !== 'formal_parameter' && parameter.type !== 'spread_parameter') continue; + const nameNode = parameter.childForFieldName('name'); + const typeNode = parameter.childForFieldName('type'); + if (nameNode === null || typeNode === null) continue; + dependencies.push({ + name: nameNode.text.trim(), + rawType: typeNode.text.trim(), + annotations: annotationFacts(parameter), + }); + } + return dependencies; +} + +/** + * Capture one class already surfaced by Java's scope query. + * + * `captures.ts` calls this from its existing query-match traversal, so Spring + * DI does not perform a second recursive walk from the AST root. + */ +export function captureJavaSpringDiClassFact( + classNode: SyntaxNode, + filePath: string, +): JavaSpringDiClassFact | null { + const body = classNode.childForFieldName('body'); + if (body === null) return null; + const classAnnotations = annotationFacts(classNode); + const injectionSites: JavaSpringInjectionSiteFact[] = []; + + const constructors = body.namedChildren.filter( + (child) => child.type === 'constructor_declaration', + ); + for (const constructor of constructors) { + const annotations = annotationFacts(constructor); + const implicitConstructor = + constructors.length === 1 && + hasSpringStereotypeSyntax(classAnnotations) && + !hasSpringDiRelevantAnnotation(annotations); + if (!implicitConstructor && !hasSpringDiRelevantAnnotation(annotations)) continue; + injectionSites.push({ + kind: 'constructor', + memberName: constructor.childForFieldName('name')?.text.trim() ?? '', + implicitConstructor, + annotations, + dependencies: dependenciesOf(constructor), + }); + } + + for (const member of body.namedChildren) { + if (member.type === 'field_declaration') { + const annotations = annotationFacts(member); + if (!hasSpringDiRelevantAnnotation(annotations)) continue; + const typeNode = member.childForFieldName('type'); + if (typeNode === null) continue; + for (const declarator of member.namedChildren) { + if (declarator.type !== 'variable_declarator') continue; + const nameNode = declarator.childForFieldName('name'); + if (nameNode === null) continue; + injectionSites.push({ + kind: 'field', + memberName: nameNode.text.trim(), + implicitConstructor: false, + annotations, + dependencies: [ + { + name: nameNode.text.trim(), + rawType: typeNode.text.trim(), + annotations, + }, + ], + }); + } + } else if (member.type === 'method_declaration') { + const annotations = annotationFacts(member); + if (!hasSpringDiRelevantAnnotation(annotations)) continue; + injectionSites.push({ + kind: 'method', + memberName: member.childForFieldName('name')?.text.trim() ?? '', + implicitConstructor: false, + annotations, + dependencies: dependenciesOf(member), + }); + } + } + + if (injectionSites.length === 0 && !hasSpringDiRelevantAnnotation(classAnnotations)) return null; + const classCapture = nodeToCapture('@spring-di.class', classNode); + return { + classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }), + classAnnotations, + injectionSites, + }; +} + +/** Attach resolved, framework-private DI metadata to Class nodes. */ +export const attachJavaSpringDiMetadata = createSpringDiMetadataAttacher< + JavaAnnotationSyntaxFact, + JavaSpringInjectionSiteKind +>({ + getFacts: getJavaSpringDiFacts, + isPackageVisibilityIncomplete: isJavaPackageSiblingVisibilityIncomplete, + parseInjectionType: parseSpringInjectionType, + capturedMemberKind: 'field', +}); 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..3efc60522 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts @@ -0,0 +1,299 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { parseSpringInjectionType } from '../../di-extractors/spring.js'; +import { + createSpringDiMetadataAttacher, + hasSpringDiRelevantAnnotation, + hasSpringStereotypeSyntax, + type SpringDiAnnotationFact, + type SpringDiClassFact, + type SpringDiDependencyFact, + type SpringDiInjectionSiteFact, +} from '../../frameworks/spring/di-metadata.js'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { getKotlinSpringDiFacts } from './capture-side-channel.js'; +import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js'; + +export interface KotlinAnnotationSyntaxFact extends SpringDiAnnotationFact { + readonly useSiteTarget?: string; +} + +export type KotlinSpringDependencyFact = SpringDiDependencyFact; + +type KotlinSpringInjectionSiteKind = 'property' | 'constructor' | 'method'; + +export type KotlinSpringInjectionSiteFact = SpringDiInjectionSiteFact< + KotlinAnnotationSyntaxFact, + KotlinSpringInjectionSiteKind +>; + +export type KotlinSpringDiClassFact = SpringDiClassFact< + KotlinAnnotationSyntaxFact, + KotlinSpringInjectionSiteKind +>; + +const KOTLIN_TYPE_NODES = new Set(['user_type', 'nullable_type', 'function_type']); + +function firstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | undefined { + const stack = [...node.namedChildren].reverse(); + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) continue; + if (current.type === type) return current; + for (let index = current.namedChildren.length - 1; index >= 0; index--) { + const child = current.namedChildren[index]; + if (child !== undefined) stack.push(child); + } + } + return undefined; +} + +function annotationFact(annotation: SyntaxNode): KotlinAnnotationSyntaxFact | null { + const nameNode = firstDescendantOfType(annotation, 'user_type'); + if (nameNode === undefined) return null; + const useSiteTarget = annotation.namedChildren + .find((child) => child.type === 'use_site_target') + ?.text.replace(/:\s*$/, '') + .trim(); + return { + name: nameNode.text.trim(), + text: annotation.text.trim(), + ...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }), + }; +} + +function annotationsFromModifierContainer(node: SyntaxNode): KotlinAnnotationSyntaxFact[] { + const facts: KotlinAnnotationSyntaxFact[] = []; + for (const child of node.namedChildren) { + if (child.type !== 'annotation') continue; + const fact = annotationFact(child); + if (fact !== null) facts.push(fact); + } + return facts; +} + +function annotationFacts(node: SyntaxNode): KotlinAnnotationSyntaxFact[] { + const facts: KotlinAnnotationSyntaxFact[] = []; + for (const child of node.namedChildren) { + if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue; + facts.push(...annotationsFromModifierContainer(child)); + } + return facts; +} + +function directTypeNode(node: SyntaxNode): SyntaxNode | undefined { + return node.namedChildren.find((child) => KOTLIN_TYPE_NODES.has(child.type)); +} + +function parameterDependency( + parameter: SyntaxNode, + precedingAnnotations: readonly KotlinAnnotationSyntaxFact[] = [], +): KotlinSpringDependencyFact | null { + const nameNode = parameter.namedChildren.find((child) => child.type === 'simple_identifier'); + const typeNode = directTypeNode(parameter); + if (nameNode === undefined || typeNode === undefined) return null; + return { + name: nameNode.text.trim(), + rawType: typeNode.text.trim(), + annotations: [...precedingAnnotations, ...annotationFacts(parameter)], + }; +} + +function functionDependencies(callable: SyntaxNode): KotlinSpringDependencyFact[] { + const parameters = callable.namedChildren.find( + (child) => child.type === 'function_value_parameters', + ); + if (parameters === undefined) return []; + const dependencies: KotlinSpringDependencyFact[] = []; + let pendingAnnotations: KotlinAnnotationSyntaxFact[] = []; + for (const child of parameters.namedChildren) { + if (child.type === 'parameter_modifiers') { + pendingAnnotations = annotationsFromModifierContainer(child); + continue; + } + if (child.type !== 'parameter') continue; + const dependency = parameterDependency(child, pendingAnnotations); + pendingAnnotations = []; + if (dependency !== null) dependencies.push(dependency); + } + return dependencies; +} + +function primaryConstructorDependencies(constructor: SyntaxNode): KotlinSpringDependencyFact[] { + const dependencies: KotlinSpringDependencyFact[] = []; + for (const parameter of constructor.namedChildren) { + if (parameter.type !== 'class_parameter') continue; + const dependency = parameterDependency(parameter); + if (dependency !== null) dependencies.push(dependency); + } + return dependencies; +} + +function propertyDependency(property: SyntaxNode): KotlinSpringDependencyFact | null { + const variable = property.namedChildren.find((child) => child.type === 'variable_declaration'); + if (variable === undefined) return null; + const nameNode = variable.namedChildren.find((child) => child.type === 'simple_identifier'); + const typeNode = directTypeNode(variable); + if (nameNode === undefined || typeNode === undefined) return null; + const annotations = annotationFacts(property); + return { + name: nameNode.text.trim(), + rawType: typeNode.text.trim(), + annotations, + }; +} + +function isKotlinBeanCandidateClass(classNode: SyntaxNode): boolean { + if (classNode.children.some((child) => child.type === 'interface' || child.type === 'enum')) { + return false; + } + const modifiers = classNode.namedChildren.find((child) => child.type === 'modifiers'); + return !modifiers?.namedChildren.some( + (child) => child.type === 'class_modifier' && child.text.trim() === 'annotation', + ); +} + +/** + * Capture one class already surfaced by Kotlin's scope query. Kotlin-specific + * syntax is normalized here while import/FQN semantics remain deferred until + * post-resolution. + */ +export function captureKotlinSpringDiClassFact( + classNode: SyntaxNode, + filePath: string, +): KotlinSpringDiClassFact | null { + if (!isKotlinBeanCandidateClass(classNode)) return null; + const classAnnotations = annotationFacts(classNode); + const injectionSites: KotlinSpringInjectionSiteFact[] = []; + const body = classNode.namedChildren.find((child) => child.type === 'class_body'); + const primaryConstructor = classNode.namedChildren.find( + (child) => child.type === 'primary_constructor', + ); + const secondaryConstructors = + body?.namedChildren.filter((child) => child.type === 'secondary_constructor') ?? []; + const constructorCount = + (primaryConstructor === undefined ? 0 : 1) + secondaryConstructors.length; + + if (primaryConstructor !== undefined) { + const annotations = annotationFacts(primaryConstructor); + const implicitConstructor = + constructorCount === 1 && + hasSpringStereotypeSyntax(classAnnotations) && + !hasSpringDiRelevantAnnotation(annotations); + if (implicitConstructor || hasSpringDiRelevantAnnotation(annotations)) { + injectionSites.push({ + kind: 'constructor', + memberName: '', + implicitConstructor, + annotations, + dependencies: primaryConstructorDependencies(primaryConstructor), + }); + } + } + + for (const constructor of secondaryConstructors) { + const annotations = annotationFacts(constructor); + const implicitConstructor = + constructorCount === 1 && + hasSpringStereotypeSyntax(classAnnotations) && + !hasSpringDiRelevantAnnotation(annotations); + if (!implicitConstructor && !hasSpringDiRelevantAnnotation(annotations)) continue; + injectionSites.push({ + kind: 'constructor', + memberName: '', + implicitConstructor, + annotations, + dependencies: functionDependencies(constructor), + }); + } + + if (body !== undefined) { + for (const member of body.namedChildren) { + if (member.type === 'property_declaration') { + const annotations = annotationFacts(member); + if (!hasSpringDiRelevantAnnotation(annotations)) continue; + const dependency = propertyDependency(member); + if (dependency === null) continue; + injectionSites.push({ + kind: 'property', + memberName: dependency.name, + implicitConstructor: false, + annotations, + dependencies: [dependency], + }); + } else if (member.type === 'function_declaration') { + const annotations = annotationFacts(member); + if (!hasSpringDiRelevantAnnotation(annotations)) continue; + const name = + member.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim() ?? + ''; + injectionSites.push({ + kind: 'method', + memberName: name, + implicitConstructor: false, + annotations, + dependencies: functionDependencies(member), + }); + } + } + } + + if (injectionSites.length === 0 && !hasSpringDiRelevantAnnotation(classAnnotations)) return null; + const classCapture = nodeToCapture('@spring-di.class', classNode); + return { + classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }), + classAnnotations, + injectionSites, + }; +} + +function isApplicableInjectionAnnotation( + annotation: KotlinAnnotationSyntaxFact, + site: KotlinSpringInjectionSiteFact, +): boolean { + if (annotation.useSiteTarget === undefined) return true; + if (site.kind === 'constructor') return annotation.useSiteTarget === 'constructor'; + if (site.kind === 'property') { + return annotation.useSiteTarget === 'field' || annotation.useSiteTarget === 'set'; + } + return false; +} + +function isApplicableQualifierAnnotation( + annotation: KotlinAnnotationSyntaxFact, + site: KotlinSpringInjectionSiteFact, +): boolean { + if (annotation.useSiteTarget === undefined) return true; + if (site.kind === 'property') { + return ( + annotation.useSiteTarget === 'field' || + annotation.useSiteTarget === 'param' || + annotation.useSiteTarget === 'setparam' + ); + } + return annotation.useSiteTarget === 'param'; +} + +function parseKotlinSpringInjectionType(rawType: string) { + // Kotlin nullable suffixes, type projections, and mutable collection aliases + // do not change the JVM bean type selected by Spring. Normalize only those + // surface forms; stars, function types, arrays, and nested generic elements + // still fail closed in the shared parser. + const normalized = rawType + .replace(/\bMutable(List|Set|Collection|Map)(?=\s*<)/g, '$1') + .replace(/([<,])\s*(?:out|in)\s+/g, '$1') + .replace(/\?(?=\s*(?:[>,]|$))/g, ''); + return parseSpringInjectionType(normalized); +} + +/** Attach resolved, framework-private DI metadata to Kotlin Class nodes. */ +export const attachKotlinSpringDiMetadata = createSpringDiMetadataAttacher< + KotlinAnnotationSyntaxFact, + KotlinSpringInjectionSiteKind +>({ + getFacts: getKotlinSpringDiFacts, + isPackageVisibilityIncomplete: isKotlinPackageSiblingVisibilityIncomplete, + parseInjectionType: parseKotlinSpringInjectionType, + capturedMemberKind: 'property', + isInjectionAnnotationApplicable: isApplicableInjectionAnnotation, + isQualifierAnnotationApplicable: isApplicableQualifierAnnotation, +}); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/di.ts b/gitnexus/src/core/ingestion/pipeline-phases/di.ts index 784e65bc4..1a8f789fc 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/di.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/di.ts @@ -1,91 +1,91 @@ /** * Phase: di * - * Framework-neutral dependency-injection resolution. Routes `Property` nodes - * by `properties.language` to the per-language field matchers registered in - * `di-extractors/` (`DI_MATCHERS` — same registry seam shape as - * `SCOPE_RESOLVERS`), then fans each match out to `INJECTS` edges from the - * consumer Class node to every Class implementing the matched element - * interface. - * - * This file names NO language or framework: which fields count as - * container-injected — and why — is entirely the registered matcher's - * business (see `di-extractors/` for the matchers and their semantics, - * including deliberate annotation exclusions). The matcher also supplies the - * human-readable edge `reason`, so framework specifics stay in the payload, - * never in this phase. - * - * The resolution uses ONLY graph data — Property nodes, `HAS_PROPERTY` edges, - * `IMPLEMENTS` edges, and Interface nodes. No filesystem access is performed: - * the structural information was already extracted by earlier parse / - * structure phases. - * - * Interface resolution is scoped to the CANDIDATE'S OWN language and prefers - * qualified names: a dotted element type resolves via the language's - * `qualifiedName` index; a bare simple name resolves only while unique within - * that language. Ambiguous names — simple OR qualified (a qualifiedName has - * no file-path component, so the same package+name duplicated across monorepo - * modules collides too) — fail CLOSED — no edge, never - * last-writer-wins — but observably: skips are counted in the phase output's - * `ambiguousSkipped` and named in an isDev debug log, so "no DI fields" is - * distinguishable from "all candidates ambiguous". Same-package/import-aware - * disambiguation is a documented follow-up (see the plan's Deferred work). + * Framework-neutral dependency-injection resolution. Per-language resolvers + * identify injection sites and provider metadata; this phase performs only + * graph-level type/heritage resolution and emits Class -> Class INJECTS edges. * * @deps mro - * @reads graph (Property nodes, HAS_PROPERTY edges, IMPLEMENTS edges, Interface nodes) + * @reads graph (Class/Interface/member nodes and heritage/ownership edges) * @writes graph (INJECTS edges) */ -import type { SupportedLanguages } from 'gitnexus-shared'; +import type { GraphNode, SupportedLanguages } from 'gitnexus-shared'; import type { PipelinePhase, PipelineContext } from './types.js'; -import { DI_MATCHERS, isSupportedLanguage } from '../di-extractors/index.js'; +import { + DI_RESOLVERS, + isSupportedLanguage, + type DiInjectionMatch, + type DiProviderMatch, +} from '../di-extractors/index.js'; import { isDev } from '../utils/env.js'; import { logger } from '../../logger.js'; export interface DIOutput { injectsEdges: number; + /** Kept for output compatibility; now counts every matched injection site. */ fieldsScanned: number; - /** Candidates skipped because their element type name — bare simple name - * or dotted qualified name — matched more than one Interface within the - * candidate's language (fail-closed). */ + /** Sites skipped because the requested type name itself was ambiguous. */ ambiguousSkipped: number; + /** Single-valued sites represented by multiple low-confidence candidates. */ + ambiguousInjections: number; } -/** Sentinel marking an interface name (simple or qualified) claimed by more - * than one Interface node within a language — resolution must fail closed. */ const AMBIGUOUS: unique symbol = Symbol('ambiguous'); -/** Per-language interface lookup: qualified names resolve exactly; bare - * simple names resolve only while unique within the language. Both indexes - * fail closed on their own duplicates. */ -interface InterfaceIndex { - /** `properties.qualifiedName` → Interface node id (when extracted — e.g. - * package-qualified for languages with a file-scope package declaration), - * or {@link AMBIGUOUS} once a second Interface claims the same qualified - * name in the same language — realistic in monorepos, where the same - * package+name is duplicated across modules or main/test source roots - * (a qualifiedName carries no file-path component). */ +interface NameIndex { byQualifiedName: Map; - /** `properties.name` → Interface node id, or {@link AMBIGUOUS} once a - * second same-name Interface appears in the same language. */ bySimpleName: Map; } -/** A Property node a registered matcher accepted as a DI fan-out candidate. */ -interface CandidateField { - propertyId: string; - /** The candidate's language — interface resolution (Pass 3) looks up ONLY - * this language's interface index. */ +interface CandidateSite extends DiInjectionMatch { + siteNodeId: string; language: SupportedLanguages; - elementTypeName: string; - /** Matcher-supplied edge reason (carries the framework specifics). */ +} + +interface PendingEdge { + sourceId: string; + targetId: string; + confidence: number; reason: string; } +function emptyNameIndex(): NameIndex { + return { byQualifiedName: new Map(), bySimpleName: new Map() }; +} + +function addIndexedName(index: NameIndex, node: GraphNode): void { + const qualifiedName = node.properties.qualifiedName; + if (typeof qualifiedName === 'string') { + index.byQualifiedName.set( + qualifiedName, + index.byQualifiedName.has(qualifiedName) ? AMBIGUOUS : node.id, + ); + } + const simpleName = node.properties.name; + index.bySimpleName.set(simpleName, index.bySimpleName.has(simpleName) ? AMBIGUOUS : node.id); +} + +function resolveIndexedName(index: NameIndex | undefined, name: string) { + if (index === undefined) return undefined; + return name.includes('.') ? index.byQualifiedName.get(name) : index.bySimpleName.get(name); +} + +function providerCandidates( + ids: ReadonlySet, + providers: ReadonlyMap, +): string[] { + const all = [...ids]; + const recognized = all.filter((id) => providers.has(id)); + // Recall-first fallback: provider metadata can be incomplete (custom + // registration mechanisms and legacy indexes can omit it). Prefer + // framework-recognized providers when present, but keep structurally valid + // candidates when none are known instead of dropping the injection entirely. + return recognized.length > 0 ? recognized : all; +} + export const diPhase: PipelinePhase = { name: 'di', - // Depends on `mro` for ordering: heritage edges (IMPLEMENTS/EXTENDS) must be - // fully populated before we resolve interface→implementer fan-out. deps: ['mro'], async execute(ctx: PipelineContext): Promise { @@ -96,174 +96,193 @@ export const diPhase: PipelinePhase = { stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: ctx.graph.nodeCount }, }); - // ── Pass 1: route Property nodes to registered per-language matchers ─── - // Early-exit optimization: if no registered matcher accepts any Property - // node, skip all index construction. This makes the phase a no-op on - // repos with no DI-matched fields (no IMPLEMENTS / HAS_PROPERTY scans). - const candidates: CandidateField[] = []; - + const candidates: CandidateSite[] = []; + const providers = new Map(); ctx.graph.forEachNode((node) => { - if (node.label !== 'Property') return; const language = node.properties.language; if (language === undefined || !isSupportedLanguage(language)) return; - const matcher = DI_MATCHERS.get(language); - if (matcher === undefined) return; - const match = matcher(node); - if (match === null) return; - candidates.push({ - propertyId: node.id, - language, - elementTypeName: match.elementTypeName, - reason: match.reason, - }); + const resolver = DI_RESOLVERS.get(language); + if (resolver === undefined) return; + + const provider = resolver.matchProvider(node); + if (provider !== null) providers.set(node.id, provider); + for (const match of resolver.matchInjectionSites(node)) { + candidates.push({ ...match, siteNodeId: node.id, language }); + } }); if (candidates.length === 0) { - return { injectsEdges: 0, fieldsScanned: 0, ambiguousSkipped: 0 }; + return { + injectsEdges: 0, + fieldsScanned: 0, + ambiguousSkipped: 0, + ambiguousInjections: 0, + }; } - // ── Pass 2: build single-pass reverse indexes ───────────────────────── - - // interfaceNodeId → Set (reverse of IMPLEMENTS edge) - // IMPLEMENTS edges go Class→Interface, so target is the interface. - // Keyed by node id — globally unique — so this index needs no language - // scoping; only NAME-based lookups (below) do. const interfaceToImplementers = new Map>(); for (const rel of ctx.graph.iterRelationshipsByType('IMPLEMENTS')) { - const implementerId = rel.sourceId; // Class - const interfaceId = rel.targetId; // Interface - let set = interfaceToImplementers.get(interfaceId); - if (set === undefined) { - set = new Set(); - interfaceToImplementers.set(interfaceId, set); + const set = interfaceToImplementers.get(rel.targetId) ?? new Set(); + set.add(rel.sourceId); + interfaceToImplementers.set(rel.targetId, set); + } + + const memberToClass = new Map(); + for (const relationType of ['HAS_PROPERTY', 'HAS_METHOD'] as const) { + for (const rel of ctx.graph.iterRelationshipsByType(relationType)) { + memberToClass.set(rel.targetId, rel.sourceId); } - set.add(implementerId); } - // propertyNodeId → consumerClassId (reverse of HAS_PROPERTY edge) - // HAS_PROPERTY edges go Class→Property, so target is the property. - const propertyToClass = new Map(); - for (const rel of ctx.graph.iterRelationshipsByType('HAS_PROPERTY')) { - propertyToClass.set(rel.targetId, rel.sourceId); - } - - // language → InterfaceIndex (from Interface-labeled nodes). Scoped per - // language so an Interface in one language can never satisfy a candidate - // from another. Within a language, a name resolves only while unique — - // a second Interface claiming the same simple OR qualified name flips - // that entry to AMBIGUOUS and resolution fails closed (never - // last-writer-wins). - // Index only languages that can resolve: an Interface in a language with - // no candidate can never be looked up in Pass 3. - const candidateLanguages = new Set(candidates.map((c) => c.language)); - const interfacesByLanguage = new Map(); + const candidateLanguages = new Set(candidates.map((candidate) => candidate.language)); + const interfacesByLanguage = new Map(); + const classesByLanguage = new Map(); + const classNodes = new Map(); ctx.graph.forEachNode((node) => { - if (node.label !== 'Interface') return; + if (node.label !== 'Class' && node.label !== 'Interface') return; const language = node.properties.language; - if (typeof language !== 'string') return; // no language ⇒ unindexable - if (!candidateLanguages.has(language)) return; - let index = interfacesByLanguage.get(language); - if (index === undefined) { - index = { byQualifiedName: new Map(), bySimpleName: new Map() }; - interfacesByLanguage.set(language, index); - } - // `qualifiedName` reaches NodeProperties through the extensible index - // signature, so narrow it explicitly (no `any`). - const qualifiedName = node.properties.qualifiedName; - if (typeof qualifiedName === 'string') { - index.byQualifiedName.set( - qualifiedName, - index.byQualifiedName.has(qualifiedName) ? AMBIGUOUS : node.id, - ); - } - const simpleName = node.properties.name; - index.bySimpleName.set(simpleName, index.bySimpleName.has(simpleName) ? AMBIGUOUS : node.id); + if (typeof language !== 'string' || !candidateLanguages.has(language)) return; + const indexes = node.label === 'Class' ? classesByLanguage : interfacesByLanguage; + const index = indexes.get(language) ?? emptyNameIndex(); + addIndexedName(index, node); + indexes.set(language, index); + if (node.label === 'Class') classNodes.set(node.id, node); }); - // ── Pass 3: emit INJECTS edges ──────────────────────────────────────── - let injectsEdges = 0; let ambiguousSkipped = 0; - const ambiguousElementTypes = new Set(); - const seenEdges = new Set(); + let ambiguousInjections = 0; + const ambiguousTypeNames = new Set(); + const pending = new Map(); + + const queueEdge = (edge: PendingEdge): void => { + if (edge.sourceId === edge.targetId) return; + const id = `INJECTS:${edge.sourceId}->${edge.targetId}`; + const existing = pending.get(id); + if (existing === undefined || edge.confidence > existing.confidence) pending.set(id, edge); + }; for (const candidate of candidates) { - // Resolve the consumer Class that owns this Property. - const consumerClassId = propertyToClass.get(candidate.propertyId); - if (!consumerClassId) continue; + const siteNode = ctx.graph.getNode(candidate.siteNodeId); + const consumerClassId = + siteNode?.label === 'Class' ? siteNode.id : memberToClass.get(candidate.siteNodeId); + if (consumerClassId === undefined) continue; - // Resolve the element type name via the CANDIDATE'S OWN language index - // only — a same-named Interface in another language never participates. - const index = interfacesByLanguage.get(candidate.language); - if (index === undefined) continue; - - // A dotted element type is a qualified name (e.g. `com.a.Shape`) — - // exact qualifiedName lookup, unaffected by simple-name ambiguity. - // A bare name uses the simple-name index. BOTH lookups fail CLOSED - // on their own ambiguity (a qualified name too can be claimed twice — - // same package+name across monorepo modules): no edge (never - // last-writer-wins), but counted and logged so the skip is - // observable. Same-package/import-aware disambiguation is a - // deliberate follow-up (plan: Deferred work). - let interfaceId: string | undefined; - if (candidate.elementTypeName.includes('.')) { - const entry = index.byQualifiedName.get(candidate.elementTypeName); - if (entry === AMBIGUOUS) { - ambiguousSkipped++; - ambiguousElementTypes.add(candidate.elementTypeName); - continue; - } - interfaceId = entry; - } else { - const entry = index.bySimpleName.get(candidate.elementTypeName); - if (entry === AMBIGUOUS) { - ambiguousSkipped++; - ambiguousElementTypes.add(candidate.elementTypeName); - continue; - } - interfaceId = entry; + const classEntry = resolveIndexedName( + classesByLanguage.get(candidate.language), + candidate.targetTypeName, + ); + const interfaceEntry = resolveIndexedName( + interfacesByLanguage.get(candidate.language), + candidate.targetTypeName, + ); + if ( + classEntry === AMBIGUOUS || + interfaceEntry === AMBIGUOUS || + (classEntry !== undefined && interfaceEntry !== undefined) + ) { + // A simple/qualified name claimed by both a Class and an Interface is + // type-ambiguous too. Fail closed rather than guessing which Java type + // the injection site meant; import-aware disambiguation is not + // available in this graph-only phase. This intentionally applies to + // legacy collection sites too: a Class/Interface collision no longer + // fans out through the interface on a simple-name guess. + ambiguousSkipped++; + ambiguousTypeNames.add(candidate.targetTypeName); + continue; } - if (interfaceId === undefined) continue; - // Fan out to every class implementing that interface. - const implementers = interfaceToImplementers.get(interfaceId); - if (!implementers) continue; + const structural = new Set(); + if (typeof classEntry === 'string') structural.add(classEntry); + if (typeof interfaceEntry === 'string') { + for (const id of interfaceToImplementers.get(interfaceEntry) ?? []) structural.add(id); + } + structural.delete(consumerClassId); + if (structural.size === 0) continue; - for (const implId of implementers) { - // Skip self-edges: a class never injects its own bean into itself. - if (implId === consumerClassId) continue; + let viable = providerCandidates(structural, providers); + const namedSelection = candidate.namedSelection; + if (namedSelection !== undefined) { + viable = viable.filter( + (id) => providers.get(id)?.names.includes(namedSelection.name) === true, + ); + if (viable.length === 0) continue; + } - // Dedup-safe edge ID: deterministic from (consumer, implementer). - const edgeId = `INJECTS:${consumerClassId}->${implId}`; - if (seenEdges.has(edgeId)) continue; - seenEdges.add(edgeId); + if (candidate.cardinality === 'collection') { + const confidence = namedSelection === undefined ? 0.8 : 0.9; + const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`; + for (const targetId of viable) { + queueEdge({ + sourceId: consumerClassId, + targetId, + confidence, + reason: candidate.reason + suffix, + }); + } + continue; + } - ctx.graph.addRelationship({ - id: edgeId, + if (viable.length === 1) { + const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`; + queueEdge({ sourceId: consumerClassId, - targetId: implId, - type: 'INJECTS', - confidence: 0.8, - // Matcher-supplied reason — names the framework and the annotation - // actually found on the field (see di-extractors/). - reason: candidate.reason, + targetId: viable[0], + confidence: namedSelection === undefined ? 0.9 : 0.95, + reason: candidate.reason + suffix, }); - injectsEdges++; + continue; } + + const preferred = viable.flatMap((id) => { + const reason = providers.get(id)?.preferenceReason; + return reason === undefined ? [] : [{ id, reason }]; + }); + if (namedSelection === undefined && preferred.length === 1) { + const selected = preferred[0]; + queueEdge({ + sourceId: consumerClassId, + targetId: selected.id, + confidence: 0.95, + reason: `${candidate.reason}; ${selected.reason}`, + }); + continue; + } + + ambiguousInjections++; + const candidateNames = viable + .map((id) => classNodes.get(id)?.properties.name ?? id) + .sort() + .join(', '); + for (const targetId of viable) { + queueEdge({ + sourceId: consumerClassId, + targetId, + confidence: 0.5, + reason: `${candidate.reason}; ambiguous candidates: ${candidateNames}`, + }); + } + } + + for (const [id, edge] of pending) { + ctx.graph.addRelationship({ id, type: 'INJECTS', ...edge }); } if (isDev && ambiguousSkipped > 0) { - // One aggregated debug line (not per-candidate spam): duplicate simple - // names are NORMAL in large repos, but the skip must stay observable. logger.debug( - `🧩 DI: ${ambiguousSkipped} candidate(s) skipped — ambiguous element interface name(s): ${[...ambiguousElementTypes].sort().join(', ')}`, + `DI: ${ambiguousSkipped} site(s) skipped because requested type names were ambiguous: ${[...ambiguousTypeNames].sort().join(', ')}`, ); } - if (isDev && (injectsEdges > 0 || ambiguousSkipped > 0)) { + if (isDev && (pending.size > 0 || ambiguousInjections > 0)) { logger.info( - `🧩 DI: ${injectsEdges} INJECTS edges from ${candidates.length} injection-annotated collection fields (${ambiguousSkipped} ambiguous skipped)`, + `DI: ${pending.size} INJECTS edges from ${candidates.length} injection sites (${ambiguousInjections} ambiguous single-site resolutions)`, ); } - return { injectsEdges, fieldsScanned: candidates.length, ambiguousSkipped }; + return { + injectsEdges: pending.size, + fieldsScanned: candidates.length, + ambiguousSkipped, + ambiguousInjections, + }; }, }; diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 64dacd51f..60828b709 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -55,13 +55,15 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // the main thread (the #1983 OOM). Because the two stores share this version, // any future change to the `ParsedFile` serialization shape MUST bump // SCHEMA_BUMP so both invalidate in lockstep. +// v21: Java/Kotlin Spring DI facts persist constructor, field/property, and +// method injection sites plus bean-name and @Primary provider metadata. // v20: Java/Kotlin capture side-channels persist package and class-annotation // facts for shared Spring Bean resolution. // v19: Java enum constant bodies emit E$N Class nodes; anonymous naming uses // JLS 13.1 immediate-host chains (#2555). // v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity. // v16: direct callee identity. -const SCHEMA_BUMP = 20; +const SCHEMA_BUMP = 21; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/integration/spring-di-benchmark.test.ts b/gitnexus/test/integration/spring-di-benchmark.test.ts new file mode 100644 index 000000000..cdd08e086 --- /dev/null +++ b/gitnexus/test/integration/spring-di-benchmark.test.ts @@ -0,0 +1,284 @@ +/** + * Spring standard-DI scaling benchmark (#2414 / PR #2632 review). + * + * Guards the two hot paths introduced by standard Spring injection: + * + * 1. Java and Kotlin capture emission collect DI facts from their existing + * scope-query traversals instead of recursively walking the AST root a + * second time. + * 2. Post-resolution metadata attachment finds captured fields through the + * owning class scope's bindings instead of scanning every HAS_PROPERTY + * relationship in the graph. + * + * The normal-CI tripwires use dense Java/Kotlin files to catch a capture + * re-regression. The gated suites measure Java and Kotlin capture plus + * full-pipeline scaling: + * + * GITNEXUS_BENCH=1 npx vitest run test/integration/spring-di-benchmark.test.ts + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { emitJavaScopeCaptures } from '../../src/core/ingestion/languages/java/captures.js'; +import { collectJavaCaptureSideChannel } from '../../src/core/ingestion/languages/java/capture-side-channel.js'; +import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.js'; +import { collectKotlinCaptureSideChannel } from '../../src/core/ingestion/languages/kotlin/capture-side-channel.js'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1'; + +function denseSpringSource(consumerCount: number): string { + const consumers = Array.from( + { length: consumerCount }, + (_, index) => ` +@Service +class Consumer${index} { + @Autowired private Gateway field${index}; + + Consumer${index}(@Qualifier("gatewayImpl") Gateway gateway) {} + + @Inject void setGateway(Gateway gateway) {} +} +`, + ).join('\n'); + + return `package com.example; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import jakarta.inject.Inject; + +interface Gateway {} + +@Service +class GatewayImpl implements Gateway {} + +${consumers} +`; +} + +interface CaptureBenchResult { + consumers: number; + elapsedMs: number; + captureCount: number; + factCount: number; +} + +function runCaptureBenchmark(consumerCount: number, run: number): CaptureBenchResult { + const filePath = `src/SpringDiBench${consumerCount}_${run}.java`; + const start = performance.now(); + const captures = emitJavaScopeCaptures(denseSpringSource(consumerCount), filePath); + const elapsedMs = performance.now() - start; + const facts = collectJavaCaptureSideChannel(filePath)?.springDiFacts ?? []; + return { + consumers: consumerCount, + elapsedMs, + captureCount: captures.length, + factCount: facts.length, + }; +} + +function denseKotlinSpringSource(consumerCount: number): string { + const consumers = Array.from( + { length: consumerCount }, + (_, index) => ` +@Service +class Consumer${index} @Autowired constructor( + @param:Qualifier("gatewayImpl") gateway: Gateway, +) { + @field:Autowired lateinit var field${index}: Gateway + @Inject fun setGateway(gateway: Gateway) {} +} +`, + ).join('\n'); + + return `package com.example +import org.springframework.stereotype.Service +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier +import jakarta.inject.Inject + +interface Gateway + +@Service +class GatewayImpl : Gateway + +${consumers} +`; +} + +function runKotlinCaptureBenchmark(consumerCount: number, run: number): CaptureBenchResult { + const filePath = `src/SpringDiBench${consumerCount}_${run}.kt`; + const start = performance.now(); + const captures = emitKotlinScopeCaptures(denseKotlinSpringSource(consumerCount), filePath); + const elapsedMs = performance.now() - start; + const facts = collectKotlinCaptureSideChannel(filePath)?.springDiFacts ?? []; + return { + consumers: consumerCount, + elapsedMs, + captureCount: captures.length, + factCount: facts.length, + }; +} + +describe('Spring DI capture O(n²) regression tripwire (#2414)', () => { + it('captures a dense 400-consumer file within a coarse linear-time budget', () => { + const consumers = 400; + const budgetMs = 10_000; + + runCaptureBenchmark(4, 0); + const result = runCaptureBenchmark(consumers, 1); + + expect(result.factCount).toBe(consumers + 1); + expect(result.captureCount).toBeGreaterThan(consumers * 10); + expect(result.elapsedMs).toBeLessThan(budgetMs); + }, 30_000); + + it('captures a dense 400-consumer Kotlin file within a coarse linear-time budget', () => { + const consumers = 400; + const budgetMs = 10_000; + + runKotlinCaptureBenchmark(4, 0); + const result = runKotlinCaptureBenchmark(consumers, 1); + + expect(result.factCount).toBe(consumers + 1); + expect(result.captureCount).toBeGreaterThan(consumers * 8); + expect(result.elapsedMs).toBeLessThan(budgetMs); + }, 30_000); +}); + +describe.skipIf(!BENCH_ENABLED)('Spring DI capture scaling benchmark (#2414)', () => { + it('scales sub-quadratically as classes and injection sites grow together', () => { + const scales = [100, 200, 400]; + const repetitions = 4; + const results: CaptureBenchResult[] = []; + + runCaptureBenchmark(8, 0); + for (const consumers of scales) { + let elapsedMs = 0; + let captureCount = 0; + let factCount = 0; + for (let run = 0; run < repetitions; run++) { + const current = runCaptureBenchmark(consumers, run + 1); + elapsedMs += current.elapsedMs; + captureCount = current.captureCount; + factCount = current.factCount; + } + results.push({ consumers, elapsedMs, captureCount, factCount }); + console.log( + ` capture n=${consumers} ×${repetitions}: ${elapsedMs.toFixed(1)}ms ` + + `(${factCount} facts, ${captureCount} captures/run)`, + ); + } + + const first = results[0]; + const last = results[results.length - 1]; + const sizeRatio = last.consumers / first.consumers; + if (first.elapsedMs >= 20) { + const wallRatio = last.elapsedMs / first.elapsedMs; + expect(wallRatio).toBeLessThan(Math.pow(sizeRatio, 1.5)); + } else { + expect(last.elapsedMs).toBeLessThan(10_000); + } + expect(last.factCount).toBe(last.consumers + 1); + }, 120_000); +}); + +describe.skipIf(!BENCH_ENABLED)('Kotlin Spring DI capture scaling benchmark (#2414)', () => { + it('scales sub-quadratically as classes and injection sites grow together', () => { + const scales = [100, 200, 400]; + const repetitions = 4; + const results: CaptureBenchResult[] = []; + + runKotlinCaptureBenchmark(8, 0); + for (const consumers of scales) { + let elapsedMs = 0; + let captureCount = 0; + let factCount = 0; + for (let run = 0; run < repetitions; run++) { + const current = runKotlinCaptureBenchmark(consumers, run + 1); + elapsedMs += current.elapsedMs; + captureCount = current.captureCount; + factCount = current.factCount; + } + results.push({ consumers, elapsedMs, captureCount, factCount }); + console.log( + ` kotlin capture n=${consumers} ×${repetitions}: ${elapsedMs.toFixed(1)}ms ` + + `(${factCount} facts, ${captureCount} captures/run)`, + ); + } + + const first = results[0]; + const last = results[results.length - 1]; + const sizeRatio = last.consumers / first.consumers; + if (first.elapsedMs >= 20) { + const wallRatio = last.elapsedMs / first.elapsedMs; + expect(wallRatio).toBeLessThan(Math.pow(sizeRatio, 1.5)); + } else { + expect(last.elapsedMs).toBeLessThan(10_000); + } + expect(last.factCount).toBe(last.consumers + 1); + }, 120_000); +}); + +function writeSpringDiRepo(consumerCount: number): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `spring-di-bench-${consumerCount}-`)); + fs.writeFileSync( + path.join(dir, 'Gateway.java'), + `package com.example; +public interface Gateway {} +`, + ); + fs.writeFileSync( + path.join(dir, 'GatewayImpl.java'), + `package com.example; +import org.springframework.stereotype.Service; +@Service +public class GatewayImpl implements Gateway {} +`, + ); + for (let index = 0; index < consumerCount; index++) { + fs.writeFileSync( + path.join(dir, `Consumer${index}.java`), + `package com.example; +import org.springframework.stereotype.Service; +@Service +public class Consumer${index} { + public Consumer${index}(Gateway gateway) {} +} +`, + ); + } + return dir; +} + +describe.skipIf(!BENCH_ENABLED)('Spring DI end-to-end scaling benchmark (#2414)', () => { + it('keeps full-pipeline injection resolution sub-quadratic across file counts', async () => { + const scales = [25, 50, 100]; + const results: Array<{ consumers: number; elapsedMs: number; injects: number }> = []; + + for (const consumers of scales) { + const dir = writeSpringDiRepo(consumers); + try { + const start = performance.now(); + const result = await runPipelineFromRepo(dir, () => {}, {}); + const elapsedMs = performance.now() - start; + const injects = [...result.graph.iterRelationshipsByType('INJECTS')].length; + results.push({ consumers, elapsedMs, injects }); + console.log( + ` pipeline n=${consumers}: ${elapsedMs.toFixed(1)}ms (${injects} INJECTS edges)`, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + + for (const result of results) expect(result.injects).toBe(result.consumers); + const first = results[0]; + const last = results[results.length - 1]; + const sizeRatio = last.consumers / first.consumers; + const wallRatio = last.elapsedMs / first.elapsedMs; + expect(wallRatio).toBeLessThan(Math.pow(sizeRatio, 1.5)); + }, 300_000); +}); diff --git a/gitnexus/test/integration/spring-di-pipeline.test.ts b/gitnexus/test/integration/spring-di-pipeline.test.ts index 1b18efcc8..a355ad5f2 100644 --- a/gitnexus/test/integration/spring-di-pipeline.test.ts +++ b/gitnexus/test/integration/spring-di-pipeline.test.ts @@ -41,6 +41,15 @@ public class Consumer { } `; +const WILDCARD_CONSUMER = `package com.example; +import java.util.*; +import org.springframework.beans.factory.annotation.*; + +public class WildcardConsumer { + @Autowired private List foos; +} +`; + /** A consumer whose collection fields carry NO injection annotation. */ const PLAIN_CONSUMER = `package com.example; import java.util.List; @@ -69,6 +78,19 @@ function injectsPairs(result: PipelineResult): string[] { .sort(); } +function injectsDetails(result: PipelineResult) { + const nameById = new Map(); + result.graph.forEachNode((node) => nameById.set(node.id, String(node.properties.name))); + return result.graph.relationships + .filter((relationship) => relationship.type === 'INJECTS') + .map((relationship) => ({ + pair: `${nameById.get(relationship.sourceId)}->${nameById.get(relationship.targetId)}`, + confidence: relationship.confidence, + reason: relationship.reason, + })) + .sort((left, right) => left.pair.localeCompare(right.pair)); +} + describe('Spring DI collection-injection pipeline (#2200)', () => { let dir: string; let result: PipelineResult; @@ -117,6 +139,28 @@ describe('Spring DI collection-injection pipeline (#2200)', () => { }); }); +describe('Spring DI wildcard-import collection fallback (#2200, #2414)', () => { + let dir: string; + let result: PipelineResult; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-di-wildcard-')); + fs.writeFileSync(path.join(dir, 'IFoo.java'), IFOO); + fs.writeFileSync(path.join(dir, 'FooA.java'), FOO_A); + fs.writeFileSync(path.join(dir, 'FooB.java'), FOO_B); + fs.writeFileSync(path.join(dir, 'WildcardConsumer.java'), WILDCARD_CONSUMER); + result = await runPipelineFromRepo(dir, () => {}, {}); + }, 60_000); + + afterAll(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('preserves collection edges when multiple wildcard imports prevent annotation FQN resolution', () => { + expect(injectsPairs(result)).toEqual(['WildcardConsumer->FooA', 'WildcardConsumer->FooB']); + }); +}); + describe('Spring DI pipeline negative control: no injection annotations anywhere (#2200)', () => { let dir: string; let result: PipelineResult; @@ -140,3 +184,395 @@ describe('Spring DI pipeline negative control: no injection annotations anywhere expect(injectsPairs(result)).toEqual([]); }); }); + +describe('Spring standard injection pipeline (#2414)', () => { + let dir: string; + let result: PipelineResult; + + const sources: Record = { + 'PaymentGateway.java': `package com.example; +public interface PaymentGateway {} +`, + 'FastGateway.java': `package com.example; +import org.springframework.stereotype.Service; +import org.springframework.context.annotation.Primary; +@Service @Primary +public class FastGateway implements PaymentGateway {} +`, + 'SlowGateway.java': `package com.example; +import org.springframework.stereotype.Service; +@Service("slowGateway") +public class SlowGateway implements PaymentGateway {} +`, + 'ConcreteRepo.java': `package com.example; +import org.springframework.stereotype.Repository; +@Repository +public class ConcreteRepo {} +`, + 'S3Client.java': `package com.example; +import org.springframework.stereotype.Service; +@Service +public class S3Client {} +`, + 'DigitBeanNameConsumer.java': `package com.example; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Qualifier; +@Service +public class DigitBeanNameConsumer { + public DigitBeanNameConsumer(@Qualifier("s3Client") S3Client client) {} +} +`, + 'EmptyParenService.java': `package com.example; +import org.springframework.stereotype.Service; +@Service() +public class EmptyParenService {} +`, + 'EmptyParenConsumer.java': `package com.example; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Qualifier; +@Service +public class EmptyParenConsumer { + public EmptyParenConsumer( + @Qualifier("emptyParenService") EmptyParenService service + ) {} +} +`, + 'ConstructorConsumer.java': `package com.example; +import org.springframework.stereotype.Service; +@Service +public class ConstructorConsumer { + public ConstructorConsumer(PaymentGateway gateway, ConcreteRepo repo) {} +} +`, + 'ExplicitConstructorConsumer.java': `package com.example; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; +@Service +public class ExplicitConstructorConsumer { + public ExplicitConstructorConsumer() {} + @Autowired public ExplicitConstructorConsumer(ConcreteRepo repo) {} +} +`, + 'QualifiedConsumer.java': `package com.example; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Qualifier; +@Service +public class QualifiedConsumer { + public QualifiedConsumer(@Qualifier("slowGateway") PaymentGateway gateway) {} +} +`, + 'DynamicQualifierConsumer.java': `package com.example; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Qualifier; +@Service +public class DynamicQualifierConsumer { + private static final String GATEWAY = "slowGateway"; + public DynamicQualifierConsumer(@Qualifier(GATEWAY) PaymentGateway gateway) {} +} +`, + 'DynamicCollectionQualifierConsumer.java': `package com.example; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +@Service +public class DynamicCollectionQualifierConsumer { + private static final String GATEWAY = "slowGateway"; + @Autowired @Qualifier(GATEWAY) private List gateways; +} +`, + 'PlainConstructorConsumer.java': `package com.example; +public class PlainConstructorConsumer { + public PlainConstructorConsumer(PaymentGateway gateway) {} +} +`, + 'FieldConsumer.java': `package com.example; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; +@Service +public class FieldConsumer { + @Autowired private PaymentGateway gateway; +} +`, + 'QualifiedCollectionConsumer.java': `package com.example; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +@Service +public class QualifiedCollectionConsumer { + @Autowired @Qualifier("slowGateway") private List gateways; +} +`, + 'SetterConsumer.java': `package com.example; +import org.springframework.stereotype.Service; +import jakarta.inject.Inject; +@Service +public class SetterConsumer { + @Inject public void setRepo(ConcreteRepo repo) {} +} +`, + 'Formatter.java': `package com.example; +public interface Formatter {} +`, + 'JsonFormatter.java': `package com.example; +import org.springframework.stereotype.Service; +@Service +public class JsonFormatter implements Formatter {} +`, + 'XmlFormatter.java': `package com.example; +import org.springframework.stereotype.Service; +@Service +public class XmlFormatter implements Formatter {} +`, + 'AmbiguousConsumer.java': `package com.example; +import org.springframework.stereotype.Service; +@Service +public class AmbiguousConsumer { + public AmbiguousConsumer(Formatter formatter) {} +} +`, + }; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-standard-di-')); + for (const [fileName, source] of Object.entries(sources)) { + fs.writeFileSync(path.join(dir, fileName), source); + } + result = await runPipelineFromRepo(dir, () => {}, {}); + }, 60_000); + + afterAll(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('resolves implicit constructor, concrete, field, setter, qualifier, and primary injection', () => { + const details = injectsDetails(result); + expect(details.map((detail) => detail.pair)).toEqual([ + 'AmbiguousConsumer->JsonFormatter', + 'AmbiguousConsumer->XmlFormatter', + 'ConstructorConsumer->ConcreteRepo', + 'ConstructorConsumer->FastGateway', + 'DigitBeanNameConsumer->S3Client', + 'EmptyParenConsumer->EmptyParenService', + 'ExplicitConstructorConsumer->ConcreteRepo', + 'FieldConsumer->FastGateway', + 'QualifiedCollectionConsumer->SlowGateway', + 'QualifiedConsumer->SlowGateway', + 'SetterConsumer->ConcreteRepo', + ]); + + expect( + details.find((detail) => detail.pair === 'ConstructorConsumer->FastGateway'), + ).toMatchObject({ confidence: 0.95, reason: expect.stringContaining('selected @Primary') }); + expect( + details.find((detail) => detail.pair === 'QualifiedConsumer->SlowGateway'), + ).toMatchObject({ + confidence: 0.95, + reason: expect.stringContaining('qualifier "slowGateway"'), + }); + expect( + details.find((detail) => detail.pair === 'SetterConsumer->ConcreteRepo')?.reason, + ).toContain('@Inject method'); + }); + + it('surfaces unresolved single-bean ambiguity as multiple low-confidence candidates', () => { + const ambiguous = injectsDetails(result).filter((detail) => + detail.pair.startsWith('AmbiguousConsumer->'), + ); + expect(ambiguous).toHaveLength(2); + expect(ambiguous.every((detail) => detail.confidence === 0.5)).toBe(true); + expect(ambiguous.every((detail) => detail.reason.includes('ambiguous candidates'))).toBe(true); + }); + + it('fails closed for unmanaged implicit constructors and unresolved dynamic qualifiers', () => { + const pairs = injectsDetails(result).map((detail) => detail.pair); + expect(pairs.some((pair) => pair.startsWith('PlainConstructorConsumer->'))).toBe(false); + expect(pairs.some((pair) => pair.startsWith('DynamicQualifierConsumer->'))).toBe(false); + expect(pairs.some((pair) => pair.startsWith('DynamicCollectionQualifierConsumer->'))).toBe( + false, + ); + }); +}); + +describe('Kotlin Spring standard injection pipeline (#2414)', () => { + let dir: string; + let result: PipelineResult; + + const sources: Record = { + '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/ingestion/di.test.ts b/gitnexus/test/unit/ingestion/di.test.ts index b5b1990f5..4bed5966e 100644 --- a/gitnexus/test/unit/ingestion/di.test.ts +++ b/gitnexus/test/unit/ingestion/di.test.ts @@ -17,6 +17,7 @@ import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; import { diPhase } from '../../../src/core/ingestion/pipeline-phases/di.js'; import { parseSpringCollectionType, + SPRING_DI_INJECTION_SITES_PROPERTY, springDiFieldMatcher, } from '../../../src/core/ingestion/di-extractors/spring.js'; import { generateId } from '../../../src/lib/utils.js'; @@ -728,6 +729,81 @@ describe('di phase', () => { ambiguousSkipped: 1, }); }); + + it('falls back to structural providers when no implementation is a known bean', async () => { + const graph = createKnowledgeGraph(); + + addInterface(graph, 'Port'); + addClass(graph, 'FirstPort', 'java'); + addClass(graph, 'SecondPort', 'java'); + addImplements(graph, 'FirstPort', 'Port'); + addImplements(graph, 'SecondPort', 'Port'); + addClass(graph, 'Consumer', 'java', 'Class', { + [SPRING_DI_INJECTION_SITES_PROPERTY]: [ + { + targetTypeName: 'Port', + cardinality: 'single', + reason: 'Spring DI: test constructor', + }, + ], + }); + + const output = await diPhase.execute(makeCtx(graph), new Map()); + + expect(injectsEdges(graph)).toHaveLength(2); + expect(injectsEdges(graph).every((edge) => edge.confidence === 0.5)).toBe(true); + expect(output).toMatchObject({ injectsEdges: 2, ambiguousInjections: 1 }); + }); + + it('fails closed when one injection type name denotes both a class and an interface', async () => { + const graph = createKnowledgeGraph(); + + addClass(graph, 'Port', 'java'); + addInterface(graph, 'Port'); + addClass(graph, 'PortImpl', 'java'); + addImplements(graph, 'PortImpl', 'Port'); + addClass(graph, 'Consumer', 'java', 'Class', { + [SPRING_DI_INJECTION_SITES_PROPERTY]: [ + { + targetTypeName: 'Port', + cardinality: 'single', + reason: 'Spring DI: test constructor', + }, + ], + }); + + const output = await diPhase.execute(makeCtx(graph), new Map()); + + expect(injectsEdges(graph)).toHaveLength(0); + expect(output).toMatchObject({ injectsEdges: 0, ambiguousSkipped: 1 }); + }); + + it('documents the legacy collection behavior change for a Class/Interface name collision', async () => { + const graph = createKnowledgeGraph(); + + addClass(graph, 'Port', 'java'); + addInterface(graph, 'Port'); + addClass(graph, 'PortImpl', 'java'); + addImplements(graph, 'PortImpl', 'Port'); + addClass(graph, 'Consumer', 'java', 'Class', { + [SPRING_DI_INJECTION_SITES_PROPERTY]: [ + { + targetTypeName: 'Port', + cardinality: 'collection', + reason: 'Spring DI: @Autowired List', + }, + ], + }); + + const output = await diPhase.execute(makeCtx(graph), new Map()); + + // Before concrete-class lookup was added, the interface alone won and + // collection injection fanned out to PortImpl. The graph-only resolver + // cannot disambiguate the colliding Java types, so the new behavior is an + // intentional fail-closed skip rather than a simple-name guess. + expect(injectsEdges(graph)).toHaveLength(0); + expect(output).toMatchObject({ injectsEdges: 0, ambiguousSkipped: 1 }); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/spring-bean-extractor.test.ts b/gitnexus/test/unit/spring-bean-extractor.test.ts index bde4b8ee4..cbc5f5c4a 100644 --- a/gitnexus/test/unit/spring-bean-extractor.test.ts +++ b/gitnexus/test/unit/spring-bean-extractor.test.ts @@ -19,6 +19,12 @@ function captureClassAnnotations(code: string): JavaCaptureSideChannel['classAnn return collectJavaCaptureSideChannel(filePath)?.classAnnotations ?? []; } +function captureSpringDiFacts(code: string): NonNullable { + const filePath = 'src/Test.java'; + emitJavaScopeCaptures(code, filePath); + return collectJavaCaptureSideChannel(filePath)?.springDiFacts ?? []; +} + describe('Java class annotation capture', () => { it('collects annotation names during the existing scope-query traversal', () => { const facts = captureClassAnnotations(` @@ -51,12 +57,58 @@ describe('Java class annotation capture', () => { }); }); +describe('Java Spring injection syntax capture', () => { + it('preserves constructor, field, method, qualifier, and bean-name syntax in the side channel', () => { + const facts = captureSpringDiFacts(` + @Service("checkout") class Checkout { + Checkout(@Qualifier("fastGateway") Gateway gateway) {} + @Autowired Gateway fallback; + @Inject void setRepo(Repo repo) {} + } + `); + + expect(facts).toHaveLength(1); + expect(facts[0].classAnnotations).toEqual([{ name: 'Service', text: '@Service("checkout")' }]); + expect(facts[0].injectionSites).toMatchObject([ + { + kind: 'constructor', + implicitConstructor: true, + dependencies: [ + { + name: 'gateway', + rawType: 'Gateway', + annotations: [{ name: 'Qualifier', text: '@Qualifier("fastGateway")' }], + }, + ], + }, + { + kind: 'field', + memberName: 'fallback', + dependencies: [{ name: 'fallback', rawType: 'Gateway' }], + }, + { + kind: 'method', + memberName: 'setRepo', + dependencies: [{ name: 'repo', rawType: 'Repo' }], + }, + ]); + }); +}); + function captureKotlinClassAnnotations(code: string): KotlinCaptureSideChannel['classAnnotations'] { const filePath = 'src/Test.kt'; emitKotlinScopeCaptures(code, filePath); return collectKotlinCaptureSideChannel(filePath)?.classAnnotations ?? []; } +function captureKotlinSpringDiFacts( + code: string, +): NonNullable { + const filePath = 'src/Test.kt'; + emitKotlinScopeCaptures(code, filePath); + return collectKotlinCaptureSideChannel(filePath)?.springDiFacts ?? []; +} + describe('Kotlin class annotation capture', () => { it('captures supported class forms and excludes non-candidate declarations', () => { const facts = captureKotlinClassAnnotations(` @@ -92,6 +144,106 @@ describe('Kotlin class annotation capture', () => { }); }); +describe('Kotlin Spring injection syntax capture', () => { + it('preserves primary constructor, property, method, nullable type, projection, and use-site syntax', () => { + const facts = captureKotlinSpringDiFacts(` + @Service("checkout") @Primary + class Checkout @Autowired constructor( + @param:Qualifier("fastGateway") private val gateway: PaymentGateway, + @Named("repo") repo: Repo?, + val gateways: List, + ) { + @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 = [