From 19f6731c344f533b39b383034c4a7ef1ddadbc6e Mon Sep 17 00:00:00 2001 From: ChunxueLi <54129170+ChunxueLi@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:09:21 +0800 Subject: [PATCH] feat(java): resolve SpringContextUtil.getBeans(X.class) dynamic lookups (#2886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(java): resolve SpringContextUtil.getBeans(X.class) dynamic lookups * fix(ingestion): make Spring dynamic lookups graph-correct Capture Java and Kotlin lookups from ASTs and resolve them through scoped type bindings and transitive JVM assignability so emitted INJECTS edges are attributable, cache-safe, and production-tested. Co-authored-by: Cursor * perf(ingestion): keep Spring lookup capture linear Reuse Java and Kotlin scope-query call nodes instead of rewalking each AST, cache DI subtype closures, and enforce linear scaling with production-path benchmarks in CI. Co-authored-by: Cursor --------- Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- .github/workflows/ci-tests.yml | 1 + ARCHITECTURE.md | 2 +- .../frameworks/spring/dynamic-lookups.ts | 177 +++++++++++ .../languages/java/capture-side-channel.ts | 26 ++ .../core/ingestion/languages/java/captures.ts | 13 + .../languages/java/scope-resolver.ts | 2 + .../languages/java/spring-dynamic-lookup.ts | 77 +++++ .../languages/kotlin/capture-side-channel.ts | 27 ++ .../ingestion/languages/kotlin/captures.ts | 13 + .../languages/kotlin/scope-resolver.ts | 2 + .../languages/kotlin/spring-dynamic-lookup.ts | 90 ++++++ .../src/core/ingestion/pipeline-phases/di.ts | 67 ++++- gitnexus/src/storage/parse-cache.ts | 6 +- .../spring-dynamic-lookup-benchmark.test.ts | 275 ++++++++++++++++++ .../integration/spring-dynamic-lookup.test.ts | 220 ++++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 6 +- gitnexus/test/unit/ingestion/di.test.ts | 134 ++++++++- .../test/unit/spring-dynamic-lookup.test.ts | 179 ++++++++++++ 18 files changed, 1299 insertions(+), 18 deletions(-) create mode 100644 gitnexus/src/core/ingestion/frameworks/spring/dynamic-lookups.ts create mode 100644 gitnexus/src/core/ingestion/languages/java/spring-dynamic-lookup.ts create mode 100644 gitnexus/src/core/ingestion/languages/kotlin/spring-dynamic-lookup.ts create mode 100644 gitnexus/test/integration/spring-dynamic-lookup-benchmark.test.ts create mode 100644 gitnexus/test/integration/spring-dynamic-lookup.test.ts create mode 100644 gitnexus/test/unit/spring-dynamic-lookup.test.ts diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 3cf5c0aeb..5bdcf3560 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -726,6 +726,7 @@ jobs: test/integration/data-route-table-benchmark.test.ts test/integration/instance-ownership-pipeline-benchmark.test.ts test/integration/spring-bean-resource-benchmark.test.ts + test/integration/spring-dynamic-lookup-benchmark.test.ts test/integration/rust-pipeline-benchmark.test.ts test/integration/php-pipeline-benchmark.test.ts test/integration/ruby-pipeline-benchmark.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6fe547b70..b1076d1d5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -108,7 +108,7 @@ scan → structure → [springConfig, markdown, cobol] → parse → [routes, to | `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution | | `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges | | `springAopInheritance` | `spring-aop.ts` | `springAop`, `mro` | Propagates declarative behavior through class/interface inheritance decisions | -| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes or factory Methods to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) | +| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes, factory Methods, or AST-captured programmatic lookup callables to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) | | `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) | | `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges | diff --git a/gitnexus/src/core/ingestion/frameworks/spring/dynamic-lookups.ts b/gitnexus/src/core/ingestion/frameworks/spring/dynamic-lookups.ts new file mode 100644 index 000000000..dfcfbb7c0 --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/dynamic-lookups.ts @@ -0,0 +1,177 @@ +import type { ParsedFile, Range, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { DiInjectionMatch } from '../../di-extractors/index.js'; +import { SPRING_DI_INJECTION_SITES_PROPERTY } from '../../di-extractors/spring.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { + resolveCallerGraphId, + resolveDefGraphId, +} from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { isClassLike, lookupBindingsAt } from '../../scope-resolution/scope/walkers.js'; + +const COLLECTION_LOOKUP_METHODS = new Set(['getBeans', 'getBeansOfType']); +const SINGLE_LOOKUP_METHODS = new Set(['getBean']); + +/** + * Distinctive utility names plus conventional Spring context variable names. + * Generic locals remain recall-oriented because repositories often omit the + * third-party context type from the index; AST call/class-literal gates and + * import-aware target resolution prevent the raw-text false-positive class. + */ +const KNOWN_RECEIVERS = new Set([ + 'SpringContextUtil', + 'SpringContextHolder', + 'SpringBeanUtil', + 'ApplicationContextProvider', + 'BeanFactoryProvider', + 'ApplicationContext', + 'BeanFactory', + 'ListableBeanFactory', + 'applicationContext', + 'context', + 'ctx', + 'appContext', + 'beanFactory', +]); + +export interface SpringDynamicLookupFact { + readonly ownerScopeId: ScopeId; + readonly ownerRange: Range; + readonly receiverName: string; + readonly methodName: string; + readonly targetTypeName: string; +} + +export function springDynamicLookupCardinality( + receiverName: string, + methodName: string, +): DiInjectionMatch['cardinality'] | null { + const receiverSimpleName = receiverName.slice(receiverName.lastIndexOf('.') + 1); + if (!KNOWN_RECEIVERS.has(receiverSimpleName)) return null; + if (COLLECTION_LOOKUP_METHODS.has(methodName)) return 'collection'; + if (SINGLE_LOOKUP_METHODS.has(methodName)) return 'single'; + return null; +} + +function visibleTypeDefinitions( + fact: SpringDynamicLookupFact, + indexes: ScopeResolutionIndexes, +): readonly SymbolDefinition[] { + const simpleName = fact.targetTypeName.slice(fact.targetTypeName.lastIndexOf('.') + 1); + let scopeId: ScopeId | null = fact.ownerScopeId; + + while (scopeId !== null) { + const visible = lookupBindingsAt(scopeId, simpleName, indexes) + .map(({ def }) => def) + .filter((def) => isClassLike(def.type)) + .filter( + (def) => !fact.targetTypeName.includes('.') || def.qualifiedName === fact.targetTypeName, + ); + if (visible.length > 0) { + const unique = new Map(visible.map((def) => [def.nodeId, def])); + return [...unique.values()]; + } + scopeId = indexes.scopeTree.getScope(scopeId)?.parent ?? null; + } + + return []; +} + +function resolveTargetTypeName( + graph: KnowledgeGraph, + fact: SpringDynamicLookupFact, + callerLanguage: string | undefined, + nodeLookup: GraphNodeLookup, + indexes: ScopeResolutionIndexes, +): string | undefined { + const graphIds = new Set(); + for (const definition of visibleTypeDefinitions(fact, indexes)) { + const graphId = resolveDefGraphId(definition.filePath, definition, nodeLookup); + if (graphId === undefined) continue; + const node = graph.getNode(graphId); + if ( + (node?.label === 'Class' || + node?.label === 'Interface' || + node?.label === 'Record' || + node?.label === 'Enum') && + node.properties.language === callerLanguage + ) { + graphIds.add(graphId); + } + } + if (graphIds.size !== 1) return undefined; + + const targetId = graphIds.values().next().value; + if (targetId === undefined) return undefined; + const target = graph.getNode(targetId); + if (target === undefined) return undefined; + const qualifiedName = target.properties.qualifiedName; + return typeof qualifiedName === 'string' ? qualifiedName : target.properties.name; +} + +export interface SpringDynamicLookupMetadataAdapter { + getFacts(filePath: string): readonly SpringDynamicLookupFact[]; +} + +/** + * Attach AST-captured programmatic Spring lookups to the framework-neutral DI + * resolver. Java/Kotlin own syntax capture; this shared JVM/Spring seam owns + * import-aware type binding and metadata attachment. + */ +export function createSpringDynamicLookupMetadataAttacher( + adapter: SpringDynamicLookupMetadataAdapter, +) { + return ( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + indexes: ScopeResolutionIndexes, + ): void => { + for (const parsed of parsedFiles) { + for (const fact of adapter.getFacts(parsed.filePath)) { + const cardinality = springDynamicLookupCardinality(fact.receiverName, fact.methodName); + if (cardinality === null) continue; + + const callerId = resolveCallerGraphId(fact.ownerScopeId, indexes, nodeLookup, { + startLine: fact.ownerRange.startLine, + startCol: fact.ownerRange.startCol, + }); + if (callerId === undefined) continue; + const caller = graph.getNode(callerId); + if ( + caller === undefined || + (caller.label !== 'Function' && + caller.label !== 'Method' && + caller.label !== 'Constructor') + ) { + continue; + } + + const targetTypeName = resolveTargetTypeName( + graph, + fact, + caller.properties.language, + nodeLookup, + indexes, + ); + if (targetTypeName === undefined) continue; + + const match: DiInjectionMatch = { + targetTypeName, + cardinality, + edgeSource: 'site', + reason: `Spring dynamic lookup: ${fact.receiverName}.${fact.methodName}(${fact.targetTypeName})`, + }; + // Singular lookups intentionally use the shared DI selection policy: + // a unique/@Primary candidate wins; unresolved multiplicity is an + // explicit 0.5-confidence fan-out rather than a guessed runtime winner. + const existing = caller.properties[SPRING_DI_INJECTION_SITES_PROPERTY]; + caller.properties[SPRING_DI_INJECTION_SITES_PROPERTY] = [ + ...(Array.isArray(existing) ? existing : []), + match, + ]; + } + } + }; +} 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 91a910fa5..0da803429 100644 --- a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts @@ -13,6 +13,7 @@ import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js'; import type { JavaSpringAopFact } from './spring-aop.js'; import type { JavaSpringConditionalFact } from './spring-conditionals.js'; import type { JavaSpringDiClassFact } from './spring-di.js'; +import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js'; import type { JavaSpringNonHttpHandlerFact } from './spring-non-http-handlers.js'; export type JavaClassAnnotationFact = ClassAnnotationFact; @@ -25,6 +26,7 @@ export interface JavaCaptureSideChannel { readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[]; readonly springConditionalFacts?: readonly JavaSpringConditionalFact[]; readonly springDiFacts?: readonly JavaSpringDiClassFact[]; + readonly springDynamicLookupFacts?: readonly SpringDynamicLookupFact[]; readonly springNonHttpHandlerFacts?: readonly JavaSpringNonHttpHandlerFact[]; } @@ -33,6 +35,7 @@ const springAopFacts = new Map(); const springConfigConsumers = new Map(); const springConditionalFacts = new Map(); const springDiFacts = new Map(); +const springDynamicLookupFacts = new Map(); const springNonHttpHandlerFacts = new Map(); /** Clear facts retained by a prior workspace pass in a long-lived process. */ @@ -42,6 +45,7 @@ export function clearJavaClassAnnotationFacts(): void { springConfigConsumers.clear(); springConditionalFacts.clear(); springDiFacts.clear(); + springDynamicLookupFacts.clear(); springNonHttpHandlerFacts.clear(); } @@ -102,6 +106,20 @@ export function getJavaSpringDiFacts(filePath: string): readonly JavaSpringDiCla return springDiFacts.get(filePath) ?? []; } +export function setJavaSpringDynamicLookupFacts( + filePath: string, + facts: readonly SpringDynamicLookupFact[], +): void { + if (facts.length === 0) springDynamicLookupFacts.delete(filePath); + else springDynamicLookupFacts.set(filePath, facts); +} + +export function getJavaSpringDynamicLookupFacts( + filePath: string, +): readonly SpringDynamicLookupFact[] { + return springDynamicLookupFacts.get(filePath) ?? []; +} + export function setJavaSpringNonHttpHandlerFacts( filePath: string, facts: readonly JavaSpringNonHttpHandlerFact[], @@ -125,6 +143,7 @@ export function collectJavaCaptureSideChannel( const configConsumers = springConfigConsumers.get(filePath) ?? []; const conditionFacts = springConditionalFacts.get(filePath) ?? []; const diFacts = springDiFacts.get(filePath) ?? []; + const dynamicLookupFacts = springDynamicLookupFacts.get(filePath) ?? []; const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? []; const packageFact = getJavaPackageFact(filePath); if ( @@ -133,6 +152,7 @@ export function collectJavaCaptureSideChannel( configConsumers.length === 0 && conditionFacts.length === 0 && diFacts.length === 0 && + dynamicLookupFacts.length === 0 && nonHttpHandlerFacts.length === 0 && packageFact === undefined ) { @@ -146,6 +166,7 @@ export function collectJavaCaptureSideChannel( ...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}), ...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}), ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), + ...(dynamicLookupFacts.length > 0 ? { springDynamicLookupFacts: dynamicLookupFacts } : {}), ...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}), }; } @@ -169,6 +190,7 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { setJavaSpringConfigConsumerFacts(parsed.filePath, []); setJavaSpringConditionalFacts(parsed.filePath, []); setJavaSpringDiFacts(parsed.filePath, []); + setJavaSpringDynamicLookupFacts(parsed.filePath, []); setJavaSpringNonHttpHandlerFacts(parsed.filePath, []); setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); return; @@ -190,6 +212,10 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { parsed.filePath, Array.isArray(data.springDiFacts) ? data.springDiFacts : [], ); + setJavaSpringDynamicLookupFacts( + parsed.filePath, + Array.isArray(data.springDynamicLookupFacts) ? data.springDynamicLookupFacts : [], + ); setJavaSpringNonHttpHandlerFacts( parsed.filePath, Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : [], diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index ce5ed4b93..22083e6e5 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -39,12 +39,15 @@ import { setJavaSpringConfigConsumerFacts, setJavaSpringConditionalFacts, setJavaSpringDiFacts, + setJavaSpringDynamicLookupFacts, setJavaSpringNonHttpHandlerFacts, } 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'; +import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js'; +import { captureJavaSpringDynamicLookupFact } from './spring-dynamic-lookup.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; import { captureJavaSpringAopFacts, type JavaSpringAopFact } from './spring-aop.js'; import { @@ -146,6 +149,8 @@ export function emitJavaScopeCaptures( const springDiFacts: JavaSpringDiClassFact[] = []; const springNonHttpHandlerFacts: JavaSpringNonHttpHandlerFact[] = []; const springDiClassNodeIds = new Set(); + const springDynamicLookupFacts: SpringDynamicLookupFact[] = []; + const springDynamicLookupNodeIds = new Set(); for (const m of rawMatches) { const grouped: Record = {}; @@ -165,6 +170,13 @@ export function emitJavaScopeCaptures( } if (Object.keys(grouped).length === 0) continue; + const dynamicLookupNode = nodeIfType(nodeMap['@reference.call.member'], 'method_invocation'); + if (dynamicLookupNode !== null && !springDynamicLookupNodeIds.has(dynamicLookupNode.id)) { + springDynamicLookupNodeIds.add(dynamicLookupNode.id); + const fact = captureJavaSpringDynamicLookupFact(dynamicLookupNode, filePath); + if (fact !== null) springDynamicLookupFacts.push(fact); + } + const springAopTypeNode = [ nodeIfType(nodeMap['@scope.class'], 'class_declaration'), nodeIfType(nodeMap['@scope.class'], 'interface_declaration'), @@ -401,6 +413,7 @@ export function emitJavaScopeCaptures( setJavaSpringAopFacts(filePath, springAopFacts); setJavaSpringConditionalFacts(filePath, springConditionalFacts); setJavaSpringDiFacts(filePath, springDiFacts); + setJavaSpringDynamicLookupFacts(filePath, springDynamicLookupFacts); setJavaSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts); return [ diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index 0410baa6c..3f23040a6 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -35,6 +35,7 @@ import { attachJavaSpringConfigBindings } from './spring-config-bindings.js'; import { attachJavaSpringConditionalMetadata } from './spring-conditionals.js'; import { attachJavaSpringDiMetadata } from './spring-di.js'; import { attachJavaSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js'; +import { attachJavaSpringDynamicLookup } from './spring-dynamic-lookup.js'; import { applyJavaCaptureSideChannel, clearJavaClassAnnotationFacts, @@ -97,6 +98,7 @@ const javaScopeResolver: ScopeResolver = { attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringNonHttpHandlerMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx); + attachJavaSpringDynamicLookup(graph, parsedFiles, nodeLookup, indexes); }, }; diff --git a/gitnexus/src/core/ingestion/languages/java/spring-dynamic-lookup.ts b/gitnexus/src/core/ingestion/languages/java/spring-dynamic-lookup.ts new file mode 100644 index 000000000..922264bf8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/spring-dynamic-lookup.ts @@ -0,0 +1,77 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringDynamicLookupMetadataAttacher, + springDynamicLookupCardinality, + type SpringDynamicLookupFact, +} from '../../frameworks/spring/dynamic-lookups.js'; +import { + findAncestorBeforeBoundary, + nodeToCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { getJavaSpringDynamicLookupFacts } from './capture-side-channel.js'; + +const CALLABLE_NODE_TYPES = new Set([ + 'method_declaration', + 'constructor_declaration', + 'compact_constructor_declaration', +]); +const NO_CALLABLE_BOUNDARIES = new Set(); + +function classLiteralTypeName(argument: SyntaxNode): string | null { + if (argument.type !== 'class_literal' || argument.namedChildCount !== 1) return null; + return argument.namedChild(0)?.text.trim() ?? null; +} + +/** Capture real Java method invocations; comments and literals are never visited as calls. */ +export function captureJavaSpringDynamicLookupFact( + node: SyntaxNode, + filePath: string, +): SpringDynamicLookupFact | null { + if (node.type !== 'method_invocation') return null; + const receiverName = node.childForFieldName('object')?.text.trim(); + const methodName = node.childForFieldName('name')?.text.trim(); + const argumentsNode = node.childForFieldName('arguments'); + if (receiverName === undefined || methodName === undefined || argumentsNode === null) return null; + if (springDynamicLookupCardinality(receiverName, methodName) === null) return null; + + const argumentsWithoutComments = argumentsNode.namedChildren.filter( + (child) => child.type !== 'line_comment' && child.type !== 'block_comment', + ); + if (argumentsWithoutComments.length !== 1) return null; + const argument = argumentsWithoutComments[0]; + if (argument === undefined) return null; + const targetTypeName = classLiteralTypeName(argument); + if (targetTypeName === null) return null; + + const owner = findAncestorBeforeBoundary(node, CALLABLE_NODE_TYPES, NO_CALLABLE_BOUNDARIES); + if (owner === null) return null; + const ownerCapture = nodeToCapture('@spring-dynamic-lookup.owner', owner); + return { + ownerScopeId: makeScopeId({ + filePath, + range: ownerCapture.range, + kind: 'Function', + }), + ownerRange: ownerCapture.range, + receiverName, + methodName, + targetTypeName, + }; +} + +/** Standalone extractor for focused tests; production reuses scope-query call nodes. */ +export function captureJavaSpringDynamicLookupFacts( + rootNode: SyntaxNode, + filePath: string, +): SpringDynamicLookupFact[] { + return rootNode + .descendantsOfType('method_invocation') + .map((node) => captureJavaSpringDynamicLookupFact(node, filePath)) + .filter((fact): fact is SpringDynamicLookupFact => fact !== null); +} + +/** Attach Java lookup facts for later resolution by the shared DI phase. */ +export const attachJavaSpringDynamicLookup = createSpringDynamicLookupMetadataAttacher({ + getFacts: getJavaSpringDynamicLookupFacts, +}); 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 6ea9480a8..8c8ed35a7 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts @@ -49,6 +49,7 @@ import { } from '../jvm/package-facts.js'; import { getCompanionScopesForFile, markCompanionScope } from './companion-scopes.js'; import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js'; +import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js'; import type { KotlinSpringAopFact } from './spring-aop.js'; import type { KotlinSpringConditionalFact } from './spring-conditionals.js'; import type { KotlinSpringDiClassFact } from './spring-di.js'; @@ -58,6 +59,7 @@ const classAnnotations = createClassAnnotationFactStore(); const springAopFacts = new Map(); const springConditionalFacts = new Map(); const springDiFacts = new Map(); +const springDynamicLookupFacts = new Map(); const springNonHttpHandlerFacts = new Map(); /** @@ -80,6 +82,8 @@ export interface KotlinCaptureSideChannel { readonly springConditionalFacts?: readonly KotlinSpringConditionalFact[]; /** Constructor, property, and method injection syntax captured per class. */ readonly springDiFacts?: readonly KotlinSpringDiClassFact[]; + /** Programmatic Spring bean lookups captured per callable. */ + readonly springDynamicLookupFacts?: readonly SpringDynamicLookupFact[]; /** Scheduled, event, messaging, and managed-job handler syntax captured per callable. */ readonly springNonHttpHandlerFacts?: readonly KotlinSpringNonHttpHandlerFact[]; } @@ -89,6 +93,7 @@ export function clearKotlinClassAnnotationFacts(): void { springAopFacts.clear(); springConditionalFacts.clear(); springDiFacts.clear(); + springDynamicLookupFacts.clear(); springNonHttpHandlerFacts.clear(); } @@ -141,6 +146,20 @@ export function getKotlinSpringDiFacts(filePath: string): readonly KotlinSpringD return springDiFacts.get(filePath) ?? []; } +export function setKotlinSpringDynamicLookupFacts( + filePath: string, + facts: readonly SpringDynamicLookupFact[], +): void { + if (facts.length === 0) springDynamicLookupFacts.delete(filePath); + else springDynamicLookupFacts.set(filePath, facts); +} + +export function getKotlinSpringDynamicLookupFacts( + filePath: string, +): readonly SpringDynamicLookupFact[] { + return springDynamicLookupFacts.get(filePath) ?? []; +} + export function setKotlinSpringNonHttpHandlerFacts( filePath: string, facts: readonly KotlinSpringNonHttpHandlerFact[], @@ -168,6 +187,7 @@ export function collectKotlinCaptureSideChannel( const aopFacts = springAopFacts.get(filePath) ?? []; const conditionFacts = springConditionalFacts.get(filePath) ?? []; const diFacts = springDiFacts.get(filePath) ?? []; + const dynamicLookupFacts = springDynamicLookupFacts.get(filePath) ?? []; const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? []; const packageFact = getKotlinPackageFact(filePath); if ( @@ -176,6 +196,7 @@ export function collectKotlinCaptureSideChannel( aopFacts.length === 0 && conditionFacts.length === 0 && diFacts.length === 0 && + dynamicLookupFacts.length === 0 && nonHttpHandlerFacts.length === 0 && packageFact === undefined ) { @@ -189,6 +210,7 @@ export function collectKotlinCaptureSideChannel( ...(aopFacts.length > 0 ? { springAopFacts: aopFacts } : {}), ...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}), ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), + ...(dynamicLookupFacts.length > 0 ? { springDynamicLookupFacts: dynamicLookupFacts } : {}), ...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}), }; } @@ -215,6 +237,7 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void { setKotlinSpringAopFacts(parsed.filePath, []); setKotlinSpringConditionalFacts(parsed.filePath, []); setKotlinSpringDiFacts(parsed.filePath, []); + setKotlinSpringDynamicLookupFacts(parsed.filePath, []); setKotlinSpringNonHttpHandlerFacts(parsed.filePath, []); setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); return; @@ -235,6 +258,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void { parsed.filePath, Array.isArray(data.springDiFacts) ? data.springDiFacts : [], ); + setKotlinSpringDynamicLookupFacts( + parsed.filePath, + Array.isArray(data.springDynamicLookupFacts) ? data.springDynamicLookupFacts : [], + ); setKotlinSpringNonHttpHandlerFacts( parsed.filePath, Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : [], diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 84ec8dc4d..f3b00db03 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -23,11 +23,14 @@ import { setKotlinSpringAopFacts, setKotlinSpringConditionalFacts, setKotlinSpringDiFacts, + setKotlinSpringDynamicLookupFacts, setKotlinSpringNonHttpHandlerFacts, } 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'; +import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js'; +import { captureKotlinSpringDynamicLookupFact } from './spring-dynamic-lookup.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; import { captureKotlinSpringAopFacts, type KotlinSpringAopFact } from './spring-aop.js'; import { @@ -107,6 +110,8 @@ export function emitKotlinScopeCaptures( const springNonHttpHandlerFacts: KotlinSpringNonHttpHandlerFact[] = []; const springNonHttpHandlerTypeNodeIds = new Set(); const springDiClassNodeIds = new Set(); + const springDynamicLookupFacts: SpringDynamicLookupFact[] = []; + const springDynamicLookupNodeIds = new Set(); const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode); out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes)); out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes)); @@ -130,6 +135,13 @@ export function emitKotlinScopeCaptures( } if (Object.keys(grouped).length === 0) continue; + const dynamicLookupNode = nodeIfType(groupedNodes['@reference.call.member'], 'call_expression'); + if (dynamicLookupNode !== null && !springDynamicLookupNodeIds.has(dynamicLookupNode.id)) { + springDynamicLookupNodeIds.add(dynamicLookupNode.id); + const fact = captureKotlinSpringDynamicLookupFact(dynamicLookupNode, filePath); + if (fact !== null) springDynamicLookupFacts.push(fact); + } + // tree-sitter-kotlin represents both classes and interfaces with // `class_declaration`; `object_declaration` is the separate object form. const springAopTypeNode = [ @@ -357,6 +369,7 @@ export function emitKotlinScopeCaptures( setKotlinSpringAopFacts(filePath, springAopFacts); setKotlinSpringConditionalFacts(filePath, springConditionalFacts); setKotlinSpringDiFacts(filePath, springDiFacts); + setKotlinSpringDynamicLookupFacts(filePath, springDynamicLookupFacts); setKotlinSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts); 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 0fffeb60a..514d65d26 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -27,6 +27,7 @@ import { clearKotlinPackageFacts } from './package-facts.js'; import { attachKotlinSpringDiMetadata } from './spring-di.js'; import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js'; import { attachKotlinSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js'; +import { attachKotlinSpringDynamicLookup } from './spring-dynamic-lookup.js'; /** * Kotlin scope resolver for RFC #909 Ring 3. @@ -148,6 +149,7 @@ export const kotlinScopeResolver: ScopeResolver = { attachKotlinSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes); attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); attachKotlinSpringNonHttpHandlerMetadata(graph, parsedFiles, nodeLookup, indexes); + attachKotlinSpringDynamicLookup(graph, parsedFiles, nodeLookup, indexes); }, }; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-dynamic-lookup.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-dynamic-lookup.ts new file mode 100644 index 000000000..184048e07 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-dynamic-lookup.ts @@ -0,0 +1,90 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringDynamicLookupMetadataAttacher, + springDynamicLookupCardinality, + type SpringDynamicLookupFact, +} from '../../frameworks/spring/dynamic-lookups.js'; +import { + findAncestorBeforeBoundary, + nodeToCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { getKotlinSpringDynamicLookupFacts } from './capture-side-channel.js'; + +// Kotlin emits graph callables for functions and secondary constructors. +// `init {}` / primary-constructor bodies have no independent callable node, so +// attributing their lookups to the enclosing Class would violate graph semantics. +const CALLABLE_NODE_TYPES = new Set(['function_declaration', 'secondary_constructor']); +const NO_CALLABLE_BOUNDARIES = new Set(); +const KOTLIN_CLASS_LITERAL = + /^([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)::class(?:\.java)?$/; + +function navigationParts(node: SyntaxNode): { receiverName: string; methodName: string } | null { + if (node.type !== 'navigation_expression') return null; + const text = node.text.trim(); + const separator = text.lastIndexOf('.'); + if (separator <= 0 || separator === text.length - 1) return null; + return { + receiverName: text.slice(0, separator), + methodName: text.slice(separator + 1), + }; +} + +function singleClassLiteralArgument(node: SyntaxNode): string | null { + const suffix = node.namedChildren.find((child) => child.type === 'call_suffix'); + const argumentsNode = suffix?.namedChildren.find((child) => child.type === 'value_arguments'); + if (argumentsNode === undefined) return null; + const argumentsWithoutComments = argumentsNode.namedChildren.filter( + (child) => child.type !== 'line_comment' && child.type !== 'multiline_comment', + ); + if (argumentsWithoutComments.length !== 1) return null; + const value = argumentsWithoutComments[0]; + if (value?.type !== 'value_argument' || value.namedChildCount !== 1) return null; + return value.namedChild(0)?.text.trim().match(KOTLIN_CLASS_LITERAL)?.[1] ?? null; +} + +/** Capture real Kotlin calls using `Type::class` or `Type::class.java`. */ +export function captureKotlinSpringDynamicLookupFact( + node: SyntaxNode, + filePath: string, +): SpringDynamicLookupFact | null { + if (node.type !== 'call_expression') return null; + const callee = node.namedChildren.find((child) => child.type === 'navigation_expression'); + if (callee === undefined) return null; + const parts = navigationParts(callee); + if (parts === null) return null; + if (springDynamicLookupCardinality(parts.receiverName, parts.methodName) === null) return null; + const targetTypeName = singleClassLiteralArgument(node); + if (targetTypeName === null) return null; + + const owner = findAncestorBeforeBoundary(node, CALLABLE_NODE_TYPES, NO_CALLABLE_BOUNDARIES); + if (owner === null) return null; + const ownerCapture = nodeToCapture('@spring-dynamic-lookup.owner', owner); + return { + ownerScopeId: makeScopeId({ + filePath, + range: ownerCapture.range, + kind: 'Function', + }), + ownerRange: ownerCapture.range, + receiverName: parts.receiverName, + methodName: parts.methodName, + targetTypeName, + }; +} + +/** Standalone extractor for focused tests; production reuses scope-query call nodes. */ +export function captureKotlinSpringDynamicLookupFacts( + rootNode: SyntaxNode, + filePath: string, +): SpringDynamicLookupFact[] { + return rootNode + .descendantsOfType('call_expression') + .map((node) => captureKotlinSpringDynamicLookupFact(node, filePath)) + .filter((fact): fact is SpringDynamicLookupFact => fact !== null); +} + +/** Attach Kotlin lookup facts for later resolution by the shared DI phase. */ +export const attachKotlinSpringDynamicLookup = createSpringDynamicLookupMetadataAttacher({ + getFacts: getKotlinSpringDynamicLookupFacts, +}); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/di.ts b/gitnexus/src/core/ingestion/pipeline-phases/di.ts index 12ee5cb3e..b8bf69bd8 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/di.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/di.ts @@ -102,6 +102,10 @@ function providerCandidates( return recognized.length > 0 ? recognized : all; } +function isConcreteTypeNode(node: GraphNode | undefined): boolean { + return node?.label === 'Class' || node?.label === 'Record' || node?.label === 'Enum'; +} + export const diPhase: PipelinePhase = { name: 'di', deps: ['mro'], @@ -160,21 +164,27 @@ export const diPhase: PipelinePhase = { }; } - const interfaceToImplementers = new Map>(); + const directSubtypes = new Map>(); const directSupertypes = new Map>(); for (const rel of ctx.graph.iterRelationshipsByType('IMPLEMENTS')) { - const set = interfaceToImplementers.get(rel.targetId) ?? new Set(); - set.add(rel.sourceId); - interfaceToImplementers.set(rel.targetId, set); + const subtypes = directSubtypes.get(rel.targetId) ?? new Set(); + subtypes.add(rel.sourceId); + directSubtypes.set(rel.targetId, subtypes); const supertypes = directSupertypes.get(rel.sourceId) ?? new Set(); supertypes.add(rel.targetId); directSupertypes.set(rel.sourceId, supertypes); } for (const rel of ctx.graph.iterRelationshipsByType('EXTENDS')) { + const subtypes = directSubtypes.get(rel.targetId) ?? new Set(); + subtypes.add(rel.sourceId); + directSubtypes.set(rel.targetId, subtypes); const supertypes = directSupertypes.get(rel.sourceId) ?? new Set(); supertypes.add(rel.targetId); directSupertypes.set(rel.sourceId, supertypes); } + const orderedDirectSubtypes = new Map( + [...directSubtypes].map(([typeId, subtypes]) => [typeId, [...subtypes].sort().reverse()]), + ); const memberToClass = new Map(); for (const relationType of ['HAS_PROPERTY', 'HAS_METHOD'] as const) { @@ -187,16 +197,45 @@ export const diPhase: PipelinePhase = { const interfacesByLanguage = new Map(); const classesByLanguage = new Map(); ctx.graph.forEachNode((node) => { - if (node.label !== 'Class' && node.label !== 'Interface') return; + const concreteType = isConcreteTypeNode(node); + if (!concreteType && node.label !== 'Interface') return; const language = node.properties.language; if (typeof language !== 'string' || !candidateLanguages.has(language)) return; - const indexes = node.label === 'Class' ? classesByLanguage : interfacesByLanguage; + const indexes = concreteType ? classesByLanguage : interfacesByLanguage; const index = indexes.get(language) ?? emptyNameIndex(); addIndexedName(index, node); indexes.set(language, index); - if (node.label === 'Class') providerNodes.set(node.id, node); + if (concreteType) providerNodes.set(node.id, node); }); + const concreteSubtypesByRoot = new Map>(); + const concreteSubtypes = (rootTypeId: string, language: string): ReadonlySet => { + const cacheKey = `${language}\0${rootTypeId}`; + const cached = concreteSubtypesByRoot.get(cacheKey); + if (cached !== undefined) return cached; + + const concrete = new Set(); + const queue = [rootTypeId]; + const visited = new Set(); + while (queue.length > 0) { + const typeId = queue.pop(); + if (typeId === undefined || visited.has(typeId)) continue; + visited.add(typeId); + const typeNode = ctx.graph.getNode(typeId); + if ( + typeNode !== undefined && + isConcreteTypeNode(typeNode) && + typeNode.properties.language === language + ) { + concrete.add(typeId); + } + const children = orderedDirectSubtypes.get(typeId) ?? []; + queue.push(...children); + } + concreteSubtypesByRoot.set(cacheKey, concrete); + return concrete; + }; + // A declaration returning a concrete class is assignable to every class or // interface that type extends/implements. Expand once per language+type and // register the declaration under those ancestor names. This keeps named @@ -300,11 +339,15 @@ export const diPhase: PipelinePhase = { 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); - } + const rootTypeId = + typeof classEntry === 'string' + ? classEntry + : typeof interfaceEntry === 'string' + ? interfaceEntry + : undefined; + const structural = new Set( + rootTypeId === undefined ? [] : concreteSubtypes(rootTypeId, candidate.language), + ); for (const id of providedTypes.get(candidate.language)?.get(candidate.targetTypeName) ?? []) { structural.add(id); } diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index efd17a7f6..b44beb456 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -668,7 +668,11 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // each (no JSON/path/generation siblings). A v80 index still names `.json` // keys and would skip workers while scope-resolution found nothing — the // #1983 main-thread reparse. origin/main at allocation is 80. -const SCHEMA_BUMP = 81; +// 81 -> 82: Java and Kotlin ParsedFile capture side channels now carry +// programmatic Spring lookup facts. A warm v81 cache has no such facts, so it +// would skip workers and silently omit the new INJECTS edges. origin/main at +// allocation is 81. +const SCHEMA_BUMP = 82; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/integration/spring-dynamic-lookup-benchmark.test.ts b/gitnexus/test/integration/spring-dynamic-lookup-benchmark.test.ts new file mode 100644 index 000000000..4e9e80e41 --- /dev/null +++ b/gitnexus/test/integration/spring-dynamic-lookup-benchmark.test.ts @@ -0,0 +1,275 @@ +/** + * Spring programmatic lookup scaling for Java and Kotlin. + * + * Always-on tripwires catch a quadratic re-walk of every invocation in a dense + * file. Gated suites measure capture and full-pipeline INJECTS resolution: + * + * GITNEXUS_BENCH=1 npx vitest run test/integration/spring-dynamic-lookup-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'; +const LOOKUPS_PER_CONSUMER = 2; +// Time growth divided by input growth: linear work stays near 1. +const LINEAR_SCALING_TOLERANCE = 1.5; + +interface CaptureBenchResult { + consumers: number; + elapsedMs: number; + captureCount: number; + factCount: number; +} + +function denseJavaLookupSource(consumerCount: number): string { + const consumers = Array.from({ length: consumerCount }, (_, index) => { + return ` +class Consumer${index} { + void collect${index}() { ctx.getBeans(Parent.class); } + void single${index}() { applicationContext.getBean(Parent.class); } + void decoy${index}() { other.getName(); } + void noise${index}() { + // ctx.getBeans(Parent.class); + String example = "ctx.getBean(Parent.class)"; + } +} +`; + }).join('\n'); + + return `package com.example; +interface Parent {} +class Impl implements Parent {} +${consumers} +`; +} + +function denseKotlinLookupSource(consumerCount: number): string { + const consumers = Array.from({ length: consumerCount }, (_, index) => { + return ` +class Consumer${index} { + fun collect${index}() { ctx.getBeans(Parent::class.java) } + fun single${index}() { applicationContext.getBean(Parent::class) } + fun decoy${index}() { other.getName() } + fun noise${index}() { + // ctx.getBeans(Parent::class.java) + val example = "ctx.getBean(Parent::class.java)" + } +} +`; + }).join('\n'); + + return `package com.example +interface Parent +class Impl : Parent +${consumers} +`; +} + +function runJavaCaptureBenchmark(consumerCount: number, run: number): CaptureBenchResult { + const filePath = `src/SpringDynamicLookupBench${consumerCount}_${run}.java`; + const start = performance.now(); + const captures = emitJavaScopeCaptures(denseJavaLookupSource(consumerCount), filePath); + const elapsedMs = performance.now() - start; + const facts = collectJavaCaptureSideChannel(filePath)?.springDynamicLookupFacts ?? []; + return { + consumers: consumerCount, + elapsedMs, + captureCount: captures.length, + factCount: facts.length, + }; +} + +function runKotlinCaptureBenchmark(consumerCount: number, run: number): CaptureBenchResult { + const filePath = `src/SpringDynamicLookupBench${consumerCount}_${run}.kt`; + const start = performance.now(); + const captures = emitKotlinScopeCaptures(denseKotlinLookupSource(consumerCount), filePath); + const elapsedMs = performance.now() - start; + const facts = collectKotlinCaptureSideChannel(filePath)?.springDynamicLookupFacts ?? []; + return { + consumers: consumerCount, + elapsedMs, + captureCount: captures.length, + factCount: facts.length, + }; +} + +function assertCaptureScaling(results: readonly CaptureBenchResult[]): void { + const first = results[0]; + const last = results[results.length - 1]; + expect(last.factCount).toBe(last.consumers * LOOKUPS_PER_CONSUMER); + const sizeRatio = last.consumers / first.consumers; + if (first.elapsedMs >= 20) { + const normalizedGrowth = last.elapsedMs / first.elapsedMs / sizeRatio; + expect(normalizedGrowth).toBeLessThan(LINEAR_SCALING_TOLERANCE); + } else { + expect(last.elapsedMs).toBeLessThan(10_000); + } +} + +describe('Spring dynamic lookup capture O(n²) regression tripwire', () => { + it('captures a dense 400-consumer Java file within a coarse linear-time budget', () => { + const consumers = 400; + runJavaCaptureBenchmark(4, 0); + const result = runJavaCaptureBenchmark(consumers, 1); + expect(result.factCount).toBe(consumers * LOOKUPS_PER_CONSUMER); + expect(result.captureCount).toBeGreaterThan(consumers * 8); + expect(result.elapsedMs).toBeLessThan(10_000); + }, 30_000); + + it('captures a dense 400-consumer Kotlin file within a coarse linear-time budget', () => { + const consumers = 400; + runKotlinCaptureBenchmark(4, 0); + const result = runKotlinCaptureBenchmark(consumers, 1); + expect(result.factCount).toBe(consumers * LOOKUPS_PER_CONSUMER); + expect(result.captureCount).toBeGreaterThan(consumers * 8); + expect(result.elapsedMs).toBeLessThan(10_000); + }, 30_000); +}); + +describe.skipIf(!BENCH_ENABLED)('Java Spring dynamic lookup capture scaling', () => { + it('scales sub-quadratically as Java lookup sites grow', () => { + const scales = [100, 200, 400]; + const repetitions = 4; + const results: CaptureBenchResult[] = []; + runJavaCaptureBenchmark(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 = runJavaCaptureBenchmark(consumers, run + 1); + elapsedMs += current.elapsedMs; + captureCount = current.captureCount; + factCount = current.factCount; + } + results.push({ consumers, elapsedMs, captureCount, factCount }); + process.stdout.write( + ` java capture n=${consumers} ×${repetitions}: ${elapsedMs.toFixed(1)}ms ` + + `(${factCount} facts, ${captureCount} captures/run)\n`, + ); + } + assertCaptureScaling(results); + }, 120_000); +}); + +describe.skipIf(!BENCH_ENABLED)('Kotlin Spring dynamic lookup capture scaling', () => { + it('scales sub-quadratically as Kotlin lookup sites grow', () => { + 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 }); + process.stdout.write( + ` kotlin capture n=${consumers} ×${repetitions}: ${elapsedMs.toFixed(1)}ms ` + + `(${factCount} facts, ${captureCount} captures/run)\n`, + ); + } + assertCaptureScaling(results); + }, 120_000); +}); + +function writeJavaLookupRepo(consumerCount: number): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `spring-dynamic-java-${consumerCount}-`)); + fs.writeFileSync( + path.join(dir, 'Parent.java'), + `package a; +public interface Parent {} +`, + ); + fs.writeFileSync( + path.join(dir, 'Impl.java'), + `package a; +public class Impl implements Parent {} +`, + ); + for (let index = 0; index < consumerCount; index++) { + fs.writeFileSync( + path.join(dir, `Consumer${index}.java`), + `package c; +import a.Parent; +class Consumer${index} { + void lookup() { ctx.getBeans(Parent.class); } +} +`, + ); + } + return dir; +} + +function writeKotlinLookupRepo(consumerCount: number): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `spring-dynamic-kotlin-${consumerCount}-`)); + fs.writeFileSync(path.join(dir, 'Parent.kt'), 'package a\ninterface Parent\n'); + fs.writeFileSync(path.join(dir, 'Impl.kt'), 'package a\nclass Impl : Parent\n'); + for (let index = 0; index < consumerCount; index++) { + fs.writeFileSync( + path.join(dir, `Consumer${index}.kt`), + `package c +import a.Parent +class Consumer${index} { + fun lookup() { ctx.getBeans(Parent::class.java) } +} +`, + ); + } + return dir; +} + +async function runPipelineBenchmark( + label: string, + writeRepo: (consumerCount: number) => string, +): Promise { + const scales = [25, 50, 100]; + const results: Array<{ consumers: number; elapsedMs: number; injects: number }> = []; + + for (const consumers of scales) { + const dir = writeRepo(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 }); + process.stdout.write( + ` ${label} pipeline n=${consumers}: ${elapsedMs.toFixed(1)}ms (${injects} INJECTS edges)\n`, + ); + } 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 normalizedGrowth = last.elapsedMs / first.elapsedMs / sizeRatio; + expect(normalizedGrowth).toBeLessThan(LINEAR_SCALING_TOLERANCE); +} + +describe.skipIf(!BENCH_ENABLED)('Java Spring dynamic lookup end-to-end scaling', () => { + it('keeps Java pipeline lookup resolution sub-quadratic across file counts', async () => { + await runPipelineBenchmark('java', writeJavaLookupRepo); + }, 300_000); +}); + +describe.skipIf(!BENCH_ENABLED)('Kotlin Spring dynamic lookup end-to-end scaling', () => { + it('keeps Kotlin pipeline lookup resolution sub-quadratic across file counts', async () => { + await runPipelineBenchmark('kotlin', writeKotlinLookupRepo); + }, 300_000); +}); diff --git a/gitnexus/test/integration/spring-dynamic-lookup.test.ts b/gitnexus/test/integration/spring-dynamic-lookup.test.ts new file mode 100644 index 000000000..1471901af --- /dev/null +++ b/gitnexus/test/integration/spring-dynamic-lookup.test.ts @@ -0,0 +1,220 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + getRelationships, + runPipelineFromRepo, + writeFixtureRepo, + type PipelineResult, +} from './resolvers/helpers.js'; + +const temporaryRepositories: string[] = []; + +function temporaryRepository(prefix: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + temporaryRepositories.push(root); + return root; +} + +function injectionEdges(result: PipelineResult) { + return getRelationships(result, 'INJECTS').map((edge) => ({ + source: edge.source, + target: edge.target, + type: edge.rel.type, + confidence: edge.rel.confidence, + reason: edge.rel.reason, + })); +} + +afterEach(() => { + for (const root of temporaryRepositories.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe('Spring dynamic lookup production integration', () => { + it('resolves Java imports, assignability, concrete targets, ranges, and nested callables', async () => { + const root = temporaryRepository('gitnexus-java-spring-dynamic-'); + writeFixtureRepo(root, { + 'src/a/Parent.java': `package a; + interface Parent {} + interface Child extends Parent {} + class Impl implements Child {} + class SecondImpl implements Child {}`, + 'src/a/Base.java': 'package a; public class Base {}', + 'src/a/Concrete.java': 'package a; public class Concrete extends Base {}', + 'src/a/RecordBean.java': 'package a; public record RecordBean(String value) {}', + 'src/b/Parent.java': 'package b; public interface Parent {}', + 'src/b/OtherImpl.java': 'package b; public class OtherImpl implements Parent {}', + 'src/a/SamePackageCaller.java': `package a; + class SamePackageCaller { + void samePackageLookup() { ctx.getBeans(Parent.class); } + }`, + 'src/c/JavaCaller.java': `package c; + import a.Parent; + import a.Base; + import a.Concrete; + import a.RecordBean; + class JavaCaller { + JavaCaller() { beanFactory.getBean(Concrete.class); } + void javaCollection(){ SpringContextUtil.getBeans(Parent.class); } + void adjacentMiss(){} + void javaBase(){ applicationContext.getBeansOfType(Base.class); } + void javaSingle(){ ctx.getBean(Concrete.class); } + void javaRecord(){ ctx.getBean(RecordBean.class); } + void javaAmbiguousSingle(){ ctx.getBean(Parent.class); } + void javaOuter() { + Runnable task = new Runnable() { + public void run() { ctx.getBeans(Parent.class); } + }; + } + void javaFalsePositives() { + // ctx.getBeans(Parent.class); + /* applicationContext.getBeansOfType(Parent.class); */ + String normal = "ctx.getBean(Concrete.class)"; + String block = """ + ctx.getBeans(Parent.class) + """; + } + }`, + 'src/c/AmbiguousCaller.java': `package c; + import a.*; + import b.*; + class AmbiguousCaller { + void ambiguousLookup() { ctx.getBeans(Parent.class); } + }`, + 'src/foreign.ts': 'interface Parent {}', + }); + + const result = await runPipelineFromRepo(root, () => {}); + const edges = injectionEdges(result); + + expect(edges).toEqual( + expect.arrayContaining([ + { + source: 'javaCollection', + target: 'Impl', + type: 'INJECTS', + confidence: 0.8, + reason: 'Spring dynamic lookup: SpringContextUtil.getBeans(Parent)', + }, + { + source: 'javaCollection', + target: 'SecondImpl', + type: 'INJECTS', + confidence: 0.8, + reason: 'Spring dynamic lookup: SpringContextUtil.getBeans(Parent)', + }, + expect.objectContaining({ source: 'javaBase', target: 'Concrete', confidence: 0.8 }), + { + source: 'javaSingle', + target: 'Concrete', + type: 'INJECTS', + confidence: 0.9, + reason: 'Spring dynamic lookup: ctx.getBean(Concrete)', + }, + expect.objectContaining({ + source: 'javaRecord', + target: 'RecordBean', + confidence: 0.9, + }), + expect.objectContaining({ + source: 'javaAmbiguousSingle', + target: 'Impl', + confidence: 0.5, + }), + expect.objectContaining({ + source: 'javaAmbiguousSingle', + target: 'SecondImpl', + confidence: 0.5, + }), + expect.objectContaining({ source: 'run', target: 'Impl', confidence: 0.8 }), + expect.objectContaining({ source: 'samePackageLookup', target: 'Impl', confidence: 0.8 }), + ]), + ); + expect(edges.some((edge) => edge.source === 'adjacentMiss')).toBe(false); + expect(edges.some((edge) => edge.source === 'javaOuter')).toBe(false); + expect(edges.some((edge) => edge.source === 'javaFalsePositives')).toBe(false); + expect(edges.some((edge) => edge.source === 'ambiguousLookup')).toBe(false); + expect( + edges + .filter((edge) => edge.source === 'javaAmbiguousSingle') + .every((edge) => edge.reason.includes('ambiguous candidates: Impl, SecondImpl')), + ).toBe(true); + expect( + edges.some( + (edge) => + edge.source === 'javaCollection' && + (edge.target === 'Child' || edge.target === 'OtherImpl'), + ), + ).toBe(false); + expect(edges.some((edge) => edge.target === 'Concrete' && edge.confidence === 0.5)).toBe(false); + expect( + edges.filter((edge) => edge.source === 'JavaCaller' && edge.target === 'Concrete'), + ).toHaveLength(1); + }, 60000); + + it('resolves Kotlin class literals through the same graph semantics', async () => { + const root = temporaryRepository('gitnexus-kotlin-spring-dynamic-'); + writeFixtureRepo(root, { + 'src/a/Parent.kt': 'package a\ninterface Parent', + 'src/a/Child.kt': 'package a\ninterface Child : Parent', + 'src/a/Impl.kt': 'package a\nclass Impl : Child', + 'src/a/Concrete.kt': 'package a\nopen class Base\nclass Concrete : Base()', + 'src/c/KotlinCaller.kt': `package c + import a.Parent + import a.Base + import a.Concrete + class KotlinCaller { + constructor(marker: String) { beanFactory.getBean(Concrete::class.java) } + fun kotlinCollection(){ SpringContextUtil.getBeans(Parent::class.java) } + fun adjacentMiss(){} + fun kotlinBase(){ applicationContext.getBeansOfType(Base::class.java) } + fun kotlinSingle(){ ctx.getBean(Concrete::class) } + fun kotlinOuter() { + val task = object : Runnable { + override fun run() { ctx.getBeans(Parent::class.java) } + } + } + fun kotlinFalsePositives() { + // ctx.getBeans(Parent::class.java) + /* applicationContext.getBeansOfType(Parent::class.java) */ + val normal = "ctx.getBean(Concrete::class.java)" + val raw = ${'"""'}ctx.getBeans(Parent::class.java)${'"""'} + } + }`, + 'src/foreign.ts': 'interface Parent {}', + }); + + const result = await runPipelineFromRepo(root, () => {}); + const edges = injectionEdges(result); + + expect(edges).toEqual( + expect.arrayContaining([ + { + source: 'kotlinCollection', + target: 'Impl', + type: 'INJECTS', + confidence: 0.8, + reason: 'Spring dynamic lookup: SpringContextUtil.getBeans(Parent)', + }, + expect.objectContaining({ source: 'kotlinBase', target: 'Concrete', confidence: 0.8 }), + { + source: 'kotlinSingle', + target: 'Concrete', + type: 'INJECTS', + confidence: 0.9, + reason: 'Spring dynamic lookup: ctx.getBean(Concrete)', + }, + expect.objectContaining({ source: 'run', target: 'Impl', confidence: 0.8 }), + ]), + ); + expect(edges.some((edge) => edge.source === 'adjacentMiss')).toBe(false); + expect(edges.some((edge) => edge.source === 'kotlinOuter')).toBe(false); + expect(edges.some((edge) => edge.source === 'kotlinFalsePositives')).toBe(false); + expect( + edges.filter((edge) => edge.source === 'constructor' && edge.target === 'Concrete'), + ).toHaveLength(1); + }, 60000); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 69a366c10..cc6c7167b 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -244,11 +244,11 @@ describe('PARSE_CACHE_VERSION', () => { // collided, because each re-checked once and neither re-checked after the // other moved — which is why the rule is re-applied AT MERGE, not when the // number is picked. - it('pins SCHEMA_BUMP to 81 so concurrent bumps cannot silently collide (#2766, #3015, #3088)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(81); + it('pins SCHEMA_BUMP to 82 so concurrent bumps cannot silently collide (#2766, #3015, #3088)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(82); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); for (const taken of [ - 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, + 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/ingestion/di.test.ts b/gitnexus/test/unit/ingestion/di.test.ts index 2faa03341..28ae20b97 100644 --- a/gitnexus/test/unit/ingestion/di.test.ts +++ b/gitnexus/test/unit/ingestion/di.test.ts @@ -98,8 +98,9 @@ function addImplements( ifaceName: string, ifaceLanguage = 'java', ifaceQualifiedName?: string, + sourceLabel: NodeLabel = 'Class', ): void { - const classId = generateId('Class', className); + const classId = generateId(sourceLabel, className); const ifaceId = generateId('Interface', `${ifaceLanguage}:${ifaceQualifiedName ?? ifaceName}`); graph.addRelationship({ id: generateId('IMPLEMENTS', `${classId}->${ifaceId}`), @@ -916,6 +917,137 @@ describe('di phase', () => { expect(injectsEdges(graph)).toHaveLength(0); expect(output).toMatchObject({ injectsEdges: 0, ambiguousSkipped: 1 }); }); + + it('walks interface assignability transitively, ignores intermediate interfaces, and terminates cycles', async () => { + const graph = createKnowledgeGraph(); + const parentId = addInterface(graph, 'Parent'); + const childId = addInterface(graph, 'Child'); + const implId = addClass(graph, 'Impl', 'java'); + addImplements(graph, 'Impl', 'Child'); + graph.addRelationship({ + id: generateId('IMPLEMENTS', `${childId}->${parentId}`), + sourceId: childId, + targetId: parentId, + type: 'IMPLEMENTS', + confidence: 1, + reason: '', + }); + graph.addRelationship({ + id: generateId('IMPLEMENTS', `${parentId}->${childId}`), + sourceId: parentId, + targetId: childId, + type: 'IMPLEMENTS', + confidence: 1, + reason: 'malformed-cycle regression guard', + }); + const consumerId = addClass(graph, 'Consumer', 'java', 'Class', { + [SPRING_DI_INJECTION_SITES_PROPERTY]: [ + { + targetTypeName: 'Parent', + cardinality: 'collection', + reason: 'Spring dynamic lookup: ctx.getBeans(Parent)', + }, + ], + }); + + await diPhase.execute(makeCtx(graph), new Map()); + + expect(injectsEdges(graph)).toEqual([ + expect.objectContaining({ + sourceId: consumerId, + targetId: implId, + type: 'INJECTS', + confidence: 0.8, + }), + ]); + }); + + it('keeps records and enums as concrete interface implementers', async () => { + const graph = createKnowledgeGraph(); + addInterface(graph, 'Parent'); + const recordId = addClass(graph, 'RecordImpl', 'java', 'Record'); + const enumId = addClass(graph, 'EnumImpl', 'java', 'Enum'); + addImplements(graph, 'RecordImpl', 'Parent', 'java', undefined, 'Record'); + addImplements(graph, 'EnumImpl', 'Parent', 'java', undefined, 'Enum'); + const consumerId = addClass(graph, 'Consumer', 'java', 'Class', { + [SPRING_DI_INJECTION_SITES_PROPERTY]: [ + { + targetTypeName: 'Parent', + cardinality: 'collection', + reason: 'Spring dynamic lookup: ctx.getBeans(Parent)', + }, + ], + }); + + await diPhase.execute(makeCtx(graph), new Map()); + + expect(injectsEdges(graph)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sourceId: consumerId, targetId: recordId }), + expect.objectContaining({ sourceId: consumerId, targetId: enumId }), + ]), + ); + expect(injectsEdges(graph)).toHaveLength(2); + }); + + it('walks class inheritance and prefers concrete Spring bean candidates', async () => { + const graph = createKnowledgeGraph(); + const baseId = addClass(graph, 'Base', 'java'); + const concreteId = addClass(graph, 'Concrete', 'java', 'Class', { + [SPRING_DI_PROVIDER_PROPERTY]: { names: ['concrete'] }, + }); + graph.addRelationship({ + id: generateId('EXTENDS', `${concreteId}->${baseId}`), + sourceId: concreteId, + targetId: baseId, + type: 'EXTENDS', + confidence: 1, + reason: '', + }); + const consumerId = addClass(graph, 'Consumer', 'java', 'Class', { + [SPRING_DI_INJECTION_SITES_PROPERTY]: [ + { + targetTypeName: 'Base', + cardinality: 'collection', + reason: 'Spring dynamic lookup: applicationContext.getBeansOfType(Base)', + }, + ], + }); + + await diPhase.execute(makeCtx(graph), new Map()); + + expect(injectsEdges(graph)).toEqual([ + expect.objectContaining({ + sourceId: consumerId, + targetId: concreteId, + confidence: 0.8, + }), + ]); + }); + + it('resolves a directly requested concrete class', async () => { + const graph = createKnowledgeGraph(); + const concreteId = addClass(graph, 'Concrete', 'java'); + const consumerId = addClass(graph, 'Consumer', 'java', 'Class', { + [SPRING_DI_INJECTION_SITES_PROPERTY]: [ + { + targetTypeName: 'Concrete', + cardinality: 'single', + reason: 'Spring dynamic lookup: ctx.getBean(Concrete)', + }, + ], + }); + + await diPhase.execute(makeCtx(graph), new Map()); + + expect(injectsEdges(graph)).toEqual([ + expect.objectContaining({ + sourceId: consumerId, + targetId: concreteId, + confidence: 0.9, + }), + ]); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/spring-dynamic-lookup.test.ts b/gitnexus/test/unit/spring-dynamic-lookup.test.ts new file mode 100644 index 000000000..438d78533 --- /dev/null +++ b/gitnexus/test/unit/spring-dynamic-lookup.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; +import { getJavaParser } from '../../src/core/ingestion/languages/java/query.js'; +import { captureJavaSpringDynamicLookupFacts } from '../../src/core/ingestion/languages/java/spring-dynamic-lookup.js'; +import { getKotlinParser } from '../../src/core/ingestion/languages/kotlin/query.js'; +import { captureKotlinSpringDynamicLookupFacts } from '../../src/core/ingestion/languages/kotlin/spring-dynamic-lookup.js'; + +function javaFacts(source: string) { + return captureJavaSpringDynamicLookupFacts( + getJavaParser().parse(source).rootNode, + 'src/Example.java', + ); +} + +function kotlinFacts(source: string) { + return captureKotlinSpringDynamicLookupFacts( + getKotlinParser().parse(source).rootNode, + 'src/Example.kt', + ); +} + +describe('Java Spring dynamic lookup capture', () => { + it('captures collection and singular class-literal calls from known receivers', () => { + const facts = javaFacts(`class Example { + void load() { + SpringContextUtil.getBeans(Port.class); + ApplicationContext.getBeansOfType(com.example.Service.class); + ctx.getBean(Concrete.class); + } + }`); + + expect( + facts.map(({ receiverName, methodName, targetTypeName }) => ({ + receiverName, + methodName, + targetTypeName, + })), + ).toEqual([ + { + receiverName: 'SpringContextUtil', + methodName: 'getBeans', + targetTypeName: 'Port', + }, + { + receiverName: 'ApplicationContext', + methodName: 'getBeansOfType', + targetTypeName: 'com.example.Service', + }, + { receiverName: 'ctx', methodName: 'getBean', targetTypeName: 'Concrete' }, + ]); + }); + + it('assigns adjacent one-line calls only to their actual callable', () => { + const facts = javaFacts(`class Example { + void hit(){ ctx.getBeans(Port.class); } + void miss(){} + }`); + + expect(facts).toHaveLength(1); + expect(facts[0]?.ownerRange.startLine).toBe(2); + expect(facts[0]?.ownerRange.endLine).toBe(2); + }); + + it('assigns an anonymous-class lookup only to the nested method', () => { + const facts = javaFacts(`class Example { + void outer() { + Runnable task = new Runnable() { + public void run() { ctx.getBeans(Port.class); } + }; + } + }`); + + expect(facts).toHaveLength(1); + expect(facts[0]?.ownerRange.startLine).toBe(4); + expect(facts[0]?.ownerRange.endLine).toBe(4); + }); + + it('captures constructor lookups using normal callable semantics', () => { + const facts = javaFacts(`class Example { + Example() { beanFactory.getBean(Concrete.class); } + }`); + + expect(facts).toHaveLength(1); + expect(facts[0]?.methodName).toBe('getBean'); + }); + + it('ignores comments, Javadoc, strings, text blocks, and unsupported calls', () => { + const facts = javaFacts(`class Example { + /** + * ctx.getBeans(Port.class) + */ + void load() { + // ctx.getBeans(Port.class); + /* applicationContext.getBeansOfType(Port.class); */ + String normal = "ctx.getBean(Port.class)"; + String block = """ + ctx.getBeans(Port.class) + """; + unrelated.getBeans(Port.class); + ctx.getBean("namedBean"); + } + }`); + + expect(facts).toEqual([]); + }); +}); + +describe('Kotlin Spring dynamic lookup capture', () => { + it('captures Kotlin class literals with and without the Java bridge', () => { + const facts = kotlinFacts(`class Example { + fun load() { + SpringContextUtil.getBeans(Port::class.java) + applicationContext.getBeansOfType(com.example.Service::class.java) + ctx.getBean(Concrete::class) + } + }`); + + expect( + facts.map(({ receiverName, methodName, targetTypeName }) => ({ + receiverName, + methodName, + targetTypeName, + })), + ).toEqual([ + { + receiverName: 'SpringContextUtil', + methodName: 'getBeans', + targetTypeName: 'Port', + }, + { + receiverName: 'applicationContext', + methodName: 'getBeansOfType', + targetTypeName: 'com.example.Service', + }, + { receiverName: 'ctx', methodName: 'getBean', targetTypeName: 'Concrete' }, + ]); + }); + + it('assigns an object-expression lookup only to the nested method', () => { + const facts = kotlinFacts(`class Example { + fun outer() { + val task = object : Runnable { + override fun run() { ctx.getBeans(Port::class.java) } + } + } + }`); + + expect(facts).toHaveLength(1); + expect(facts[0]?.ownerRange.startLine).toBe(4); + expect(facts[0]?.ownerRange.endLine).toBe(4); + }); + + it('captures secondary-constructor lookups and excludes init blocks without graph callables', () => { + const facts = kotlinFacts(`class Example { + init { ctx.getBeans(Ignored::class.java) } + constructor(marker: String) { ctx.getBean(Concrete::class.java) } + }`); + + expect(facts).toHaveLength(1); + expect(facts[0]?.targetTypeName).toBe('Concrete'); + }); + + it('ignores comments, KDoc, strings, raw strings, and unsupported calls', () => { + const facts = kotlinFacts(`class Example { + /** + * ctx.getBeans(Port::class.java) + */ + fun load() { + // ctx.getBeans(Port::class.java) + /* applicationContext.getBeansOfType(Port::class.java) */ + val normal = "ctx.getBean(Port::class.java)" + val raw = ${'"""'}ctx.getBeans(Port::class.java)${'"""'} + unrelated.getBeans(Port::class.java) + ctx.getBean("namedBean") + } + }`); + + expect(facts).toEqual([]); + }); +});