feat(java): resolve SpringContextUtil.getBeans(X.class) dynamic lookups (#2886)

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ChunxueLi 2026-08-31 07:09:21 +08:00 • committed by GitHub
parent 43a842724d
commit 19f6731c34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1299 additions and 18 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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<string>();
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,
];
}
}
};
}

View file

@ -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<string, readonly JavaSpringAopFact[]>();
const springConfigConsumers = new Map<string, readonly JavaSpringConfigConsumerFact[]>();
const springConditionalFacts = new Map<string, readonly JavaSpringConditionalFact[]>();
const springDiFacts = new Map<string, readonly JavaSpringDiClassFact[]>();
const springDynamicLookupFacts = new Map<string, readonly SpringDynamicLookupFact[]>();
const springNonHttpHandlerFacts = new Map<string, readonly JavaSpringNonHttpHandlerFact[]>();
/** 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 : [],

View file

@ -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<number>();
const springDynamicLookupFacts: SpringDynamicLookupFact[] = [];
const springDynamicLookupNodeIds = new Set<number>();
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
@ -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 [

View file

@ -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);
},
};

View file

@ -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<string>();
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,
});

View file

@ -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<string, readonly KotlinSpringAopFact[]>();
const springConditionalFacts = new Map<string, readonly KotlinSpringConditionalFact[]>();
const springDiFacts = new Map<string, readonly KotlinSpringDiClassFact[]>();
const springDynamicLookupFacts = new Map<string, readonly SpringDynamicLookupFact[]>();
const springNonHttpHandlerFacts = new Map<string, readonly KotlinSpringNonHttpHandlerFact[]>();
/**
@ -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 : [],

View file

@ -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<number>();
const springDiClassNodeIds = new Set<number>();
const springDynamicLookupFacts: SpringDynamicLookupFact[] = [];
const springDynamicLookupNodeIds = new Set<number>();
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;

View file

@ -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);
},
};

View file

@ -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<string>();
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,
});

View file

@ -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<DIOutput> = {
name: 'di',
deps: ['mro'],
@ -160,21 +164,27 @@ export const diPhase: PipelinePhase<DIOutput> = {
};
}
const interfaceToImplementers = new Map<string, Set<string>>();
const directSubtypes = new Map<string, Set<string>>();
const directSupertypes = new Map<string, Set<string>>();
for (const rel of ctx.graph.iterRelationshipsByType('IMPLEMENTS')) {
const set = interfaceToImplementers.get(rel.targetId) ?? new Set<string>();
set.add(rel.sourceId);
interfaceToImplementers.set(rel.targetId, set);
const subtypes = directSubtypes.get(rel.targetId) ?? new Set<string>();
subtypes.add(rel.sourceId);
directSubtypes.set(rel.targetId, subtypes);
const supertypes = directSupertypes.get(rel.sourceId) ?? new Set<string>();
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<string>();
subtypes.add(rel.sourceId);
directSubtypes.set(rel.targetId, subtypes);
const supertypes = directSupertypes.get(rel.sourceId) ?? new Set<string>();
supertypes.add(rel.targetId);
directSupertypes.set(rel.sourceId, supertypes);
}
const orderedDirectSubtypes = new Map<string, readonly string[]>(
[...directSubtypes].map(([typeId, subtypes]) => [typeId, [...subtypes].sort().reverse()]),
);
const memberToClass = new Map<string, string>();
for (const relationType of ['HAS_PROPERTY', 'HAS_METHOD'] as const) {
@ -187,16 +197,45 @@ export const diPhase: PipelinePhase<DIOutput> = {
const interfacesByLanguage = new Map<string, NameIndex>();
const classesByLanguage = new Map<string, NameIndex>();
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<string, ReadonlySet<string>>();
const concreteSubtypes = (rootTypeId: string, language: string): ReadonlySet<string> => {
const cacheKey = `${language}\0${rootTypeId}`;
const cached = concreteSubtypesByRoot.get(cacheKey);
if (cached !== undefined) return cached;
const concrete = new Set<string>();
const queue = [rootTypeId];
const visited = new Set<string>();
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<DIOutput> = {
continue;
}
const structural = new Set<string>();
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);
}

View file

@ -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

View file

@ -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<void> {
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);
});

View file

@ -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);
});

View file

@ -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);
}

View file

@ -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,
}),
]);
});
});
// ---------------------------------------------------------------------------

View file

@ -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([]);
});
});