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