diff --git a/gitnexus/src/core/ingestion/di-extractors/index.ts b/gitnexus/src/core/ingestion/di-extractors/index.ts index 2d991032a..28582e635 100644 --- a/gitnexus/src/core/ingestion/di-extractors/index.ts +++ b/gitnexus/src/core/ingestion/di-extractors/index.ts @@ -20,13 +20,17 @@ import { springDiResolver } from './spring.js'; /** A successful injection-site match, produced by a per-language resolver. */ export interface DiInjectionMatch { - /** The requested bean type name. */ + /** The requested dependency type name. */ targetTypeName: string; - /** A collection receives every matching provider; a single site must be - * disambiguated by qualifier, primary status, or uncertainty. */ + /** A collection receives every matching provider; a single site may need + * framework-specific named/preferred-provider disambiguation. */ cardinality: 'single' | 'collection'; - /** Statically known provider/bean name requested at the injection site. */ - qualifier?: string; + /** 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. */ @@ -35,12 +39,11 @@ export interface DiInjectionMatch { /** Provider metadata used by the shared resolver without naming a framework. */ export interface DiProviderMatch { - /** Whether the class is statically known to be container-managed. */ - isBean: boolean; - /** Bean names and qualifier aliases that can satisfy a named injection. */ + /** Provider names and aliases that can satisfy a named injection. */ names: readonly string[]; - /** Whether this provider has the framework's preferred-candidate marker. */ - primary: boolean; + /** 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 diff --git a/gitnexus/src/core/ingestion/di-extractors/spring.ts b/gitnexus/src/core/ingestion/di-extractors/spring.ts index 5fa4bfd8d..44c5b1aca 100644 --- a/gitnexus/src/core/ingestion/di-extractors/spring.ts +++ b/gitnexus/src/core/ingestion/di-extractors/spring.ts @@ -257,11 +257,16 @@ export const springDiFieldMatcher = ( function isInjectionMatch(value: unknown): value is DiInjectionMatch { if (value === null || typeof value !== 'object') return false; const match = value as Partial; + const namedSelection = match.namedSelection; return ( typeof match.targetTypeName === 'string' && (match.cardinality === 'single' || match.cardinality === 'collection') && typeof match.reason === 'string' && - (match.qualifier === undefined || typeof match.qualifier === 'string') + (namedSelection === undefined || + (typeof namedSelection === 'object' && + namedSelection !== null && + typeof namedSelection.name === 'string' && + typeof namedSelection.reason === 'string')) ); } @@ -269,10 +274,9 @@ function isProviderMatch(value: unknown): value is DiProviderMatch { if (value === null || typeof value !== 'object') return false; const provider = value as Partial; return ( - typeof provider.isBean === 'boolean' && - typeof provider.primary === 'boolean' && Array.isArray(provider.names) && - provider.names.every((name) => typeof name === 'string') + provider.names.every((name) => typeof name === 'string') && + (provider.preferenceReason === undefined || typeof provider.preferenceReason === 'string') ); } diff --git a/gitnexus/src/core/ingestion/languages/java/spring-di.ts b/gitnexus/src/core/ingestion/languages/java/spring-di.ts index ea4cd2477..52dc247a5 100644 --- a/gitnexus/src/core/ingestion/languages/java/spring-di.ts +++ b/gitnexus/src/core/ingestion/languages/java/spring-di.ts @@ -304,9 +304,8 @@ export function attachJavaSpringDiMetadata( if (explicitBeanName !== undefined) names.add(explicitBeanName); else if (!hasDynamicBeanName) names.add(defaultBeanName(classNode.properties.name)); const provider: DiProviderMatch = { - isBean: true, names: [...names], - primary, + ...(primary ? { preferenceReason: 'selected @Primary' } : {}), }; classNode.properties[SPRING_DI_PROVIDER_PROPERTY] = provider; } @@ -363,7 +362,14 @@ export function attachJavaSpringDiMetadata( matches.push({ targetTypeName: parsedType.targetTypeName, cardinality: parsedType.cardinality, - ...(qualifier === undefined ? {} : { qualifier }), + ...(qualifier === undefined + ? {} + : { + namedSelection: { + name: qualifier, + reason: `qualifier "${qualifier}"`, + }, + }), reason: `Spring DI: ${trigger} ${location}: ${parsedType.displayType}`, }); } diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts index 0d554d829..000bc6d3b 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts @@ -446,9 +446,8 @@ export function attachKotlinSpringDiMetadata( if (explicitBeanName !== undefined) names.add(explicitBeanName); else if (!hasDynamicBeanName) names.add(defaultBeanName(classNode.properties.name)); const provider: DiProviderMatch = { - isBean: true, names: [...names], - primary, + ...(primary ? { preferenceReason: 'selected @Primary' } : {}), }; classNode.properties[SPRING_DI_PROVIDER_PROPERTY] = provider; } @@ -499,7 +498,14 @@ export function attachKotlinSpringDiMetadata( matches.push({ targetTypeName: parsedType.targetTypeName, cardinality: parsedType.cardinality, - ...(qualifier === undefined ? {} : { qualifier }), + ...(qualifier === undefined + ? {} + : { + namedSelection: { + name: qualifier, + reason: `qualifier "${qualifier}"`, + }, + }), reason: `Spring DI: ${trigger} ${location}: ${parsedType.displayType}`, }); } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/di.ts b/gitnexus/src/core/ingestion/pipeline-phases/di.ts index ca1b9d0b6..1a8f789fc 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/di.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/di.ts @@ -76,12 +76,12 @@ function providerCandidates( providers: ReadonlyMap, ): string[] { const all = [...ids]; - const managed = all.filter((id) => providers.get(id)?.isBean === true); - // Recall-first fallback: bean metadata is deliberately incomplete (custom - // stereotypes, factory-created beans, and legacy indexes can all omit it). - // Prefer known managed providers when present, but keep structurally valid + 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 managed.length > 0 ? managed : all; + return recognized.length > 0 ? recognized : all; } export const diPhase: PipelinePhase = { @@ -200,16 +200,17 @@ export const diPhase: PipelinePhase = { if (structural.size === 0) continue; let viable = providerCandidates(structural, providers); - const qualifier = candidate.qualifier; - if (qualifier !== undefined) { - viable = viable.filter((id) => providers.get(id)?.names.includes(qualifier) === true); + const namedSelection = candidate.namedSelection; + if (namedSelection !== undefined) { + viable = viable.filter( + (id) => providers.get(id)?.names.includes(namedSelection.name) === true, + ); if (viable.length === 0) continue; } if (candidate.cardinality === 'collection') { - const confidence = candidate.qualifier === undefined ? 0.8 : 0.9; - const suffix = - candidate.qualifier === undefined ? '' : `; qualifier "${candidate.qualifier}"`; + const confidence = namedSelection === undefined ? 0.8 : 0.9; + const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`; for (const targetId of viable) { queueEdge({ sourceId: consumerClassId, @@ -222,24 +223,27 @@ export const diPhase: PipelinePhase = { } if (viable.length === 1) { - const suffix = - candidate.qualifier === undefined ? '' : `; qualifier "${candidate.qualifier}"`; + const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`; queueEdge({ sourceId: consumerClassId, targetId: viable[0], - confidence: candidate.qualifier === undefined ? 0.9 : 0.95, + confidence: namedSelection === undefined ? 0.9 : 0.95, reason: candidate.reason + suffix, }); continue; } - const primary = viable.filter((id) => providers.get(id)?.primary === true); - if (candidate.qualifier === undefined && primary.length === 1) { + 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: primary[0], + targetId: selected.id, confidence: 0.95, - reason: `${candidate.reason}; selected @Primary`, + reason: `${candidate.reason}; ${selected.reason}`, }); continue; }