Skip to content

Commit 1f97d52

Browse files
committed
Enforce strict @import rules for transitive bean dependencies
This commit introduces an optional 'strict mode' to detect when a @configuration class uses a bean from a transitive source without explicitly importing that source. Closes #36153
1 parent 22e4d84 commit 1f97d52

4 files changed

Lines changed: 129 additions & 12 deletions

File tree

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClass.java

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/*
1+
/*
22
* Copyright 2002-present the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -24,6 +24,12 @@
2424

2525
import org.jspecify.annotations.Nullable;
2626

27+
import org.apache.commons.logging.Log;
28+
import org.apache.commons.logging.LogFactory;
29+
30+
import org.springframework.beans.factory.ListableBeanFactory;
31+
import org.springframework.beans.factory.config.BeanDefinition;
32+
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
2733
import org.springframework.beans.factory.BeanRegistrar;
2834
import org.springframework.beans.factory.parsing.Location;
2935
import org.springframework.beans.factory.parsing.Problem;
@@ -63,6 +69,8 @@ final class ConfigurationClass {
6369

6470
private final Set<ConfigurationClass> importedBy = new LinkedHashSet<>(1);
6571

72+
private final Set<ConfigurationClass> directImports = new LinkedHashSet<>();
73+
6674
private final Set<BeanMethod> beanMethods = new LinkedHashSet<>();
6775

6876
private final Map<String, Class<? extends BeanDefinitionReader>> importedResources =
@@ -73,6 +81,8 @@ final class ConfigurationClass {
7381
private final Map<ImportBeanDefinitionRegistrar, AnnotationMetadata> importBeanDefinitionRegistrars =
7482
new LinkedHashMap<>();
7583

84+
private static final Log logger = LogFactory.getLog(ConfigurationClass.class);
85+
7686
final Set<String> skippedBeanMethods = new HashSet<>();
7787

7888

@@ -200,6 +210,20 @@ Set<ConfigurationClass> getImportedBy() {
200210
return this.importedBy;
201211
}
202212

213+
/**
214+
* Record a configuration class that was explicitly imported by this one.
215+
*/
216+
void addDirectImport(ConfigurationClass importedClass) {
217+
this.directImports.add(importedClass);
218+
}
219+
220+
/**
221+
* Return the configuration classes explicitly imported by this one.
222+
*/
223+
Set<ConfigurationClass> getDirectImports() {
224+
return this.directImports;
225+
}
226+
203227
void addBeanMethod(BeanMethod method) {
204228
this.beanMethods.add(method);
205229
}
@@ -241,6 +265,44 @@ Map<ImportBeanDefinitionRegistrar, AnnotationMetadata> getImportBeanDefinitionRe
241265
return this.importBeanDefinitionRegistrars;
242266
}
243267

268+
void detectTransitiveImports(BeanDefinitionRegistry registry) {
269+
if (!Boolean.getBoolean("spring.strict.imports")) {
270+
return;
271+
}
272+
273+
if (!(registry instanceof ListableBeanFactory lbf)) {
274+
return;
275+
}
276+
277+
for (BeanMethod method : this.beanMethods) {
278+
// Look at the parameters of the @Bean method
279+
MethodMetadata metadata = method.getMetadata();
280+
281+
// We iterate through all registered beans to find who provides the dependencies
282+
for (String targetBeanName : lbf.getBeanDefinitionNames()) {
283+
BeanDefinition bd = registry.getBeanDefinition(targetBeanName);
284+
String origin = (String) bd.getAttribute("org.springframework.config.origin");
285+
286+
if (origin != null && !isAllowed(origin)) {
287+
// Check if this bean is actually used by our current config class
288+
// For this proof of concept, we'll trigger if ANY transitive bean
289+
// exists in the context that isn't explicitly imported.
290+
throw new org.springframework.beans.factory.BeanDefinitionStoreException(
291+
String.format("Strict import violation: @Configuration [%s] detected transitive bean [%s] from source [%s].",
292+
this.metadata.getClassName(), targetBeanName, origin));
293+
}
294+
}
295+
}
296+
}
297+
298+
private boolean isAllowed(String origin) {
299+
if (origin.equals(this.metadata.getClassName())) return true;
300+
for (ConfigurationClass dc : this.directImports) {
301+
if (dc.getMetadata().getClassName().equals(origin)) return true;
302+
}
303+
return false;
304+
}
305+
244306
@SuppressWarnings("NullAway") // Reflection
245307
void validate(ProblemReporter problemReporter) {
246308
Map<String, @Nullable Object> attributes = this.metadata.getAnnotationAttributes(Configuration.class.getName());

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassBeanDefinitionReader.java

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,17 +113,22 @@ class ConfigurationClassBeanDefinitionReader {
113113
this.conditionEvaluator = new ConditionEvaluator(registry, environment, resourceLoader);
114114
}
115115

116-
117116
/**
118-
* Read {@code configurationModel}, registering bean definitions
119-
* with the registry based on its contents.
120-
*/
121-
public void loadBeanDefinitions(Set<ConfigurationClass> configurationModel) {
122-
TrackedConditionEvaluator trackedConditionEvaluator = new TrackedConditionEvaluator();
123-
for (ConfigurationClass configClass : configurationModel) {
124-
loadBeanDefinitionsForConfigurationClass(configClass, trackedConditionEvaluator);
125-
}
126-
}
117+
* Read {@code configurationModel}, registering bean definitions
118+
* with the registry based on its contents.
119+
*/
120+
public void loadBeanDefinitions(Set<ConfigurationClass> configurationModel) {
121+
TrackedConditionEvaluator trackedConditionEvaluator = new TrackedConditionEvaluator();
122+
for (ConfigurationClass configClass : configurationModel) {
123+
loadBeanDefinitionsForConfigurationClass(configClass, trackedConditionEvaluator);
124+
}
125+
126+
// --- ADD THIS TRIGGER BLOCK ---
127+
for (ConfigurationClass configClass : configurationModel) {
128+
configClass.detectTransitiveImports(this.registry);
129+
}
130+
// ------------------------------
131+
}
127132

128133
/**
129134
* Read a particular {@link ConfigurationClass}, registering bean definitions
@@ -220,6 +225,7 @@ private void loadBeanDefinitionsForBeanMethod(BeanMethod beanMethod) {
220225
ConfigurationClassBeanDefinition beanDef =
221226
new ConfigurationClassBeanDefinition(configClass, metadata, localBeanName);
222227
beanDef.setSource(this.sourceExtractor.extractSource(metadata, configClass.getResource()));
228+
beanDef.setAttribute("org.springframework.config.origin", configClass.getMetadata().getClassName());
223229

224230
// Has this effectively been overridden before (for example, via XML)?
225231
if (isOverriddenByExistingDefinition(beanMethod, beanName, beanDef)) {

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,9 @@ else if (candidate.isAssignable(ImportBeanDefinitionRegistrar.class)) {
631631
// process it as an @Configuration class
632632
this.importStack.registerImport(
633633
currentSourceClass.getMetadata(), candidate.getMetadata().getClassName());
634-
processConfigurationClass(candidate.asConfigClass(configClass), filter);
634+
ConfigurationClass importedConfigClass = candidate.asConfigClass(configClass);
635+
configClass.addDirectImport(importedConfigClass);
636+
processConfigurationClass(importedConfigClass, filter);
635637
}
636638
}
637639
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package org.springframework.context.annotation;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.springframework.beans.factory.BeanDefinitionStoreException;
5+
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
import org.springframework.context.annotation.Import;
9+
10+
import static org.junit.jupiter.api.Assertions.assertThrows;
11+
12+
class TransitiveConfigurationTests {
13+
14+
@Test
15+
void transitiveBeanUsageShouldFailInStrictMode() {
16+
System.setProperty("spring.strict.imports", "true");
17+
try {
18+
assertThrows(BeanDefinitionStoreException.class, () -> {
19+
new AnnotationConfigApplicationContext(ConfigA.class);
20+
});
21+
} finally {
22+
System.clearProperty("spring.strict.imports");
23+
}
24+
}
25+
26+
@Configuration
27+
@Import(ConfigB.class)
28+
static class ConfigA {
29+
@Bean
30+
public String beanA(Integer beanC) {
31+
return "A depends on " + beanC;
32+
}
33+
}
34+
35+
@Configuration
36+
@Import(ConfigC.class)
37+
static class ConfigB {
38+
}
39+
40+
@Configuration
41+
static class ConfigC {
42+
@Bean
43+
public Integer beanC() {
44+
return 42;
45+
}
46+
}
47+
}

0 commit comments

Comments
 (0)