refactor(di): make resolver metadata framework-neutral

This commit is contained in:
Shining 2026-07-23 22:35:24 +08:00
parent 710eb729d4
commit 61bfd0eb18
5 changed files with 61 additions and 38 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -76,12 +76,12 @@ function providerCandidates(
providers: ReadonlyMap<string, DiProviderMatch>,
): 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<DIOutput> = {
@ -200,16 +200,17 @@ export const diPhase: PipelinePhase<DIOutput> = {
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<DIOutput> = {
}
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;
}