mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
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:
parent
43a842724d
commit
19f6731c34
18 changed files with 1299 additions and 18 deletions
1
.github/workflows/ci-tests.yml
vendored
1
.github/workflows/ci-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
177
gitnexus/src/core/ingestion/frameworks/spring/dynamic-lookups.ts
Normal file
177
gitnexus/src/core/ingestion/frameworks/spring/dynamic-lookups.ts
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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 : [],
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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 : [],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
220
gitnexus/test/integration/spring-dynamic-lookup.test.ts
Normal file
220
gitnexus/test/integration/spring-dynamic-lookup.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
179
gitnexus/test/unit/spring-dynamic-lookup.test.ts
Normal file
179
gitnexus/test/unit/spring-dynamic-lookup.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue