Add custom-starter module: a Boot 4 auto-configuration starter with conditions, metadata and runner tests

Two-jar starter (autoconfigure + starter), a demo app, and 17 captured transcripts
covering AutoConfiguration.imports, @ConditionalOn*, ordering, optional Jackson 3
integration, configuration metadata and the Boot 4 module split.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
This commit is contained in:
Claude
2026-09-24 06:53:07 +00:00
parent d6a2e1a5f2
commit c418589251
57 changed files with 1590 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
# custom-starter
A Spring Boot 4 starter written from scratch, and the application that uses it. Companion to
[Write Your Own Spring Boot 4 Starter: Auto-Configuration, @Conditional and Properties](https://ankurm.com/write-your-own-spring-boot-4-starter-auto-configuration-conditional-properties/).
The starter provides one thing, a `Masker` that hides all but the last few characters of a value, plus an optional
Jackson integration that writes a `Sensitive` value masked. The subject is the packaging, not the masking.
| | |
|---|---|
| Spring Boot | 4.1.1 (parent pom, published 20 Aug 2026) |
| Spring Framework | 7.0.9 |
| Java | 25 |
| Maven | 3.9 |
There is **no `docs/` folder**. The explanations live in the article, in collapsible sections next to the paragraph
each one extends, and every console block in the article is quoted from a file in [`output/`](output).
## Layout
| Module | What it is |
|---|---|
| [`masker-spring-boot-autoconfigure/`](masker-spring-boot-autoconfigure) | `MaskerAutoConfiguration`, `MaskerJacksonAutoConfiguration`, `MaskerProperties`, the `AutoConfiguration.imports` file, the configuration metadata, and the tests |
| [`masker-spring-boot-starter/`](masker-spring-boot-starter) | The dependency an application adds. No code, only a pom |
| [`masker-demo/`](masker-demo) | An application that adds the starter and nothing else |
`masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/` holds auto-configurations that are wrong on
purpose. `src/broken/` holds a source file that is made not to compile on purpose. Neither is part of the shipped jar.
## Run it
```bash
export JAVA_HOME=/path/to/jdk-25
./scripts/run-all.sh # tests, then the jar and failure captures: regenerates all of output/
mvn -B test # only the tests
```
## Captured output
| File | What it shows | Made by |
|---|---|---|
| [`01`](output/01-defaults-without-any-configuration.txt) | the auto-configuration with no properties and no user beans | `MaskerAutoConfigurationTests` |
| [`02`](output/02-properties-and-the-off-switch.txt) | `masker.*` properties and `masker.enabled=false` | `MaskerAutoConfigurationTests` |
| [`03`](output/03-conditional-on-missing-bean-and-registration-order.txt) | `@ConditionalOnMissingBean` backing off, and the registration order that decides it | `MaskerAutoConfigurationTests` |
| [`04`](output/04-jackson-integration-active.txt) | the Jackson module registered, a `Sensitive` written masked, the recorded condition outcomes | `JacksonIntegrationTests` |
| [`05`](output/05-jackson-integration-absent-without-error.txt) | Jackson hidden with `FilteredClassLoader`: no failure, no module | `JacksonIntegrationTests` |
| [`06`](output/06-conditional-on-bean-needs-an-ordering.txt) | `@ConditionalOnBean` with and without an ordering constraint, and the sorted order | `ConditionTrapTests` |
| [`07`](output/07-conditional-on-class-on-a-bean-method.txt) | `@ConditionalOnClass` on a `@Bean` method, on a classpath with no Jackson | `capture-jar-facts.sh` |
| [`08`](output/08-auto-configuration-missing-from-the-imports-file.txt) | an auto-configuration missing from the imports file: passes a runner test, absent everywhere | `ForgottenImportsEntryTests` |
| [`09`](output/09-reading-the-imports-file-in-a-test.txt) | `ImportCandidates`, the guard test for the omission | `ForgottenImportsEntryTests` |
| [`10`](output/10-demo-app-with-only-the-starter.txt) | an application with only the starter dependency, and Boot's recorded conditions | `DemoApplicationTests` |
| [`11`](output/11-demo-app-properties-and-off-switch.txt) | the same application with properties, then switched off | `DemoApplicationTests` |
| [`12`](output/12-jar-contents-and-dependency-trees.txt) | what is in the two jars, and what the starter pulls in | `capture-jar-facts.sh` |
| [`13`](output/13-configuration-metadata-in-the-jar.txt) | the generated and the hand-written configuration metadata, and the auto-configuration conditions metadata | `capture-jar-facts.sh` |
| [`14`](output/14-boot4-autoconfiguration-locations.txt) | Boot 4's split of `spring-boot-autoconfigure`, and the Boot 3 import that no longer compiles | `capture-jar-facts.sh` |
| [`15`](output/15-annotation-processor-version-trap.txt) | the processor path failing when the version is taken from your own parent | `capture-jar-facts.sh` |
| [`17`](output/17-where-the-classes-live.txt) | which Boot 4.1.1 jar holds each class this starter imports | `capture-jar-facts.sh` |
| [`16`](output/16-conditional-on-property-without-match-if-missing.txt) | `@ConditionalOnProperty` without `matchIfMissing` | `ConditionTrapTests` |
Transcripts are deterministic; only build-directory paths are rewritten, to `<custom-starter>`.
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm.masker</groupId>
<artifactId>masker-parent</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>masker-demo</artifactId>
<name>masker-demo</name>
<description>An ordinary application that adds the starter and nothing else</description>
<dependencies>
<dependency>
<groupId>com.ankurm.masker</groupId>
<artifactId>masker-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,7 @@
package com.ankurm.demo;
import com.ankurm.masker.Sensitive;
/** An application type that carries a value which must not reach a log or a response in clear. */
public record Customer(String name, Sensitive card) {
}
@@ -0,0 +1,16 @@
package com.ankurm.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* An ordinary application. Its only knowledge of the masker is the one dependency in its pom:
* no {@code @Import}, no {@code @Bean}, no {@code @ComponentScan} mentioning the starter.
*/
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
@@ -0,0 +1,68 @@
package com.ankurm.demo;
import java.util.Arrays;
import com.ankurm.masker.Masker;
import com.ankurm.masker.MaskerJacksonModule;
import com.ankurm.masker.MaskerProperties;
import com.ankurm.masker.Sensitive;
import org.junit.jupiter.api.Test;
import org.springframework.boot.Banner;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import tools.jackson.databind.json.JsonMapper;
import static org.assertj.core.api.Assertions.assertThat;
/** The consumer's view: a real application started with SpringApplication, the starter reached only through its pom. */
class DemoApplicationTests {
private static ConfigurableApplicationContext start(String... args) {
return new SpringApplicationBuilder(DemoApplication.class).web(WebApplicationType.NONE)
.bannerMode(Banner.Mode.OFF).properties("logging.level.root=WARN").run(args);
}
@Test
void addingTheDependencyIsEnough() {
try (Transcript t = new Transcript("10-demo-app-with-only-the-starter.txt",
"An application that adds masker-spring-boot-starter and nothing else");
ConfigurableApplicationContext context = start()) {
assertThat(context).isNotNull();
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
t.line("MaskerProperties beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerProperties.class)));
t.line("Jackson module beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerJacksonModule.class)));
String json = context.getBean(JsonMapper.class).writeValueAsString(
new Customer("Asha", new Sensitive("4111111111111111")));
t.line("JSON of a Customer : %s", json);
assertThat(json).isEqualTo("{\"name\":\"Asha\",\"card\":\"************1111\"}");
t.section("what Boot recorded (the same lines --debug prints)");
ReportSupport.print(t, context, "com.ankurm.masker.Masker");
}
}
@Test
void propertiesAndTheOffSwitchWorkFromTheApplication() {
try (Transcript t = new Transcript("11-demo-app-properties-and-off-switch.txt",
"The same application configured with masker.* properties, then switched off")) {
t.section("--masker.replacement=# --masker.visible-tail=6");
try (ConfigurableApplicationContext context = start("--masker.replacement=#", "--masker.visible-tail=6")) {
String json = context.getBean(JsonMapper.class).writeValueAsString(
new Customer("Asha", new Sensitive("4111111111111111")));
t.line("JSON of a Customer : %s", json);
assertThat(json).contains("##########111111");
}
t.section("--masker.enabled=false");
try (ConfigurableApplicationContext context = start("--masker.enabled=false")) {
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
t.line("Jackson module beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerJacksonModule.class)));
assertThat(context).isNotNull();
assertThat(context.getBeanNamesForType(Masker.class)).isEmpty();
t.section("what Boot recorded");
ReportSupport.print(t, context, "com.ankurm.masker.Masker");
}
}
}
}
@@ -0,0 +1,25 @@
package com.ankurm.demo;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport;
import org.springframework.context.ConfigurableApplicationContext;
/** Prints the condition outcomes Boot recorded for the classes whose name contains a filter, as {@code --debug} would. */
final class ReportSupport {
private ReportSupport() {
}
static void print(Transcript t, ConfigurableApplicationContext context, String filter) {
ConditionEvaluationReport report = ConditionEvaluationReport.get(context.getBeanFactory());
report.getConditionAndOutcomesBySource().entrySet().stream()
.filter(e -> e.getKey().contains(filter))
.sorted(Map.Entry.comparingByKey())
.forEach(e -> {
t.line("%s [%s]", e.getKey(), e.getValue().isFullMatch() ? "matched" : "not matched");
e.getValue().forEach(o -> t.line(" %s %s",
o.getOutcome().isMatch() ? "matched:" : "did not match:", o.getOutcome().getMessage()));
});
}
}
@@ -0,0 +1,52 @@
package com.ankurm.demo;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code custom-starter/output/} and echoes it to the console.
* Every console block quoted in the article comes out of one of these files verbatim.
*/
public final class Transcript implements AutoCloseable {
private static final Path ROOT =
Path.of(System.getProperty("basedir", ".")).toAbsolutePath().normalize().getParent();
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = ROOT.resolve("output").resolve(fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript section(String heading) {
out.println();
out.println("--- " + heading + " ---");
return this;
}
@Override
public void close() {
out.flush();
// Absolute paths of whoever ran the build are environment noise, not a finding.
String text = buffer.toString().replace(ROOT.toString(), "<custom-starter>");
try {
Files.createDirectories(path.getParent());
Files.writeString(path, text);
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(text);
}
}
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm.masker</groupId>
<artifactId>masker-parent</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>masker-spring-boot-autoconfigure</artifactId>
<name>masker-spring-boot-autoconfigure</name>
<description>The auto-configuration, the properties and the two optional integrations</description>
<dependencies>
<!-- the infrastructure every auto-configuration needs: @AutoConfiguration and the @Conditional* annotations -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<!-- optional: the Jackson integration only activates when the application brings Jackson itself -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jackson</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</path>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure-processor</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,9 @@
package broken;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
/** How a Boot 3 starter orders itself after Jackson. Not part of the build: scripts/capture-jar-facts.sh compiles it on purpose. */
@AutoConfiguration(after = JacksonAutoConfiguration.class)
public class OldJacksonImport {
}
@@ -0,0 +1,12 @@
package com.ankurm.masker;
/**
* The one thing this starter provides: turn a sensitive string into one that is safe to log or return.
*
* <p>An application that wants different behaviour declares its own {@code Masker} bean and the
* auto-configuration backs off (see {@link MaskerAutoConfiguration}).
*/
public interface Masker {
String mask(String value);
}
@@ -0,0 +1,25 @@
package com.ankurm.masker;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
/**
* The starter's main auto-configuration. It is listed in
* {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}; that file, not this
* annotation, is what makes Boot load it.
*/
@AutoConfiguration
@ConditionalOnProperty(prefix = "masker", name = "enabled", matchIfMissing = true)
@EnableConfigurationProperties(MaskerProperties.class)
public class MaskerAutoConfiguration {
/** Backs off when the application defines its own {@link Masker}. */
@Bean
@ConditionalOnMissingBean
public Masker masker(MaskerProperties properties) {
return new TailMasker(properties.replacement(), properties.visibleTail());
}
}
@@ -0,0 +1,27 @@
package com.ankurm.masker;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import tools.jackson.databind.json.JsonMapper;
/**
* Registers {@link MaskerJacksonModule} when Jackson is on the classpath and a {@link Masker} exists.
*
* <p>{@code @ConditionalOnClass} names {@link JsonMapper} in an annotation, which Spring reads from the class file
* without loading it, so this class is safe to have on a classpath that has no Jackson. It must therefore stay a
* separate auto-configuration: a nested class or a {@code @Bean} method signature that mentions Jackson would not be.
*/
@AutoConfiguration(after = MaskerAutoConfiguration.class)
@ConditionalOnClass(JsonMapper.class)
@ConditionalOnBean(Masker.class)
public class MaskerJacksonAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public MaskerJacksonModule maskerJacksonModule(Masker masker) {
return new MaskerJacksonModule(masker);
}
}
@@ -0,0 +1,20 @@
package com.ankurm.masker;
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.module.SimpleModule;
import tools.jackson.databind.ser.std.StdSerializer;
/** Writes {@link Sensitive} values through a {@link Masker}. Boot registers any {@code JacksonModule} bean itself. */
public class MaskerJacksonModule extends SimpleModule {
public MaskerJacksonModule(Masker masker) {
super("masker");
addSerializer(Sensitive.class, new StdSerializer<>(Sensitive.class) {
@Override
public void serialize(Sensitive value, JsonGenerator generator, SerializationContext context) {
generator.writeString(masker.mask(value.value()));
}
});
}
}
@@ -0,0 +1,18 @@
package com.ankurm.masker;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* Settings for the auto-configured {@link Masker}.
*
* @param enabled whether the starter configures anything at all
* @param replacement the text that stands in for each hidden character
* @param visibleTail how many characters at the end of the value stay readable
*/
@ConfigurationProperties("masker")
public record MaskerProperties(
@DefaultValue("true") boolean enabled,
@DefaultValue("*") String replacement,
@DefaultValue("4") int visibleTail) {
}
@@ -0,0 +1,5 @@
package com.ankurm.masker;
/** A value that must never be serialised in clear. With the Jackson integration active it is written masked. */
public record Sensitive(String value) {
}
@@ -0,0 +1,22 @@
package com.ankurm.masker;
/** Replaces everything except the last {@code visibleTail} characters. */
public class TailMasker implements Masker {
private final String replacement;
private final int visibleTail;
public TailMasker(String replacement, int visibleTail) {
this.replacement = replacement;
this.visibleTail = visibleTail;
}
@Override
public String mask(String value) {
if (value == null) {
return null;
}
int hidden = Math.max(0, value.length() - visibleTail);
return replacement.repeat(hidden) + value.substring(hidden);
}
}
@@ -0,0 +1,12 @@
{
"hints": [
{
"name": "masker.replacement",
"values": [
{ "value": "*", "description": "Asterisk (the default)." },
{ "value": "#", "description": "Hash sign." },
{ "value": "•", "description": "Bullet." }
]
}
]
}
@@ -0,0 +1,2 @@
com.ankurm.masker.MaskerAutoConfiguration
com.ankurm.masker.MaskerJacksonAutoConfiguration
@@ -0,0 +1,8 @@
package com.ankurm.consumer;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** A consumer: its own package, the starter on the classpath, nothing else. */
@SpringBootApplication
public class ConsumerApplication {
}
@@ -0,0 +1,68 @@
package com.ankurm.masker;
import com.ankurm.traps.DefaultOffAutoConfiguration;
import com.ankurm.traps.EarlyModuleAutoConfiguration;
import com.ankurm.traps.Marker;
import com.ankurm.traps.OrderedModuleAutoConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener;
import org.springframework.boot.context.annotation.Configurations;
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.ConfigurableApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/** Correct-looking conditions that are wrong: one runs too early, one defaults to off. */
class ConditionTrapTests {
@Test
void aConditionalOnBeanNeedsAnOrderingOrItDependsOnTheClassName() {
try (Transcript t = new Transcript("06-conditional-on-bean-needs-an-ordering.txt",
"@ConditionalOnBean(JsonMapper) with and without an ordering constraint")) {
AutoConfigurations configurations = AutoConfigurations.of(
JacksonAutoConfiguration.class, OrderedModuleAutoConfiguration.class,
EarlyModuleAutoConfiguration.class, MaskerAutoConfiguration.class);
t.section("the order Boot evaluates them in");
for (Class<?> c : Configurations.getClasses(configurations)) {
t.line("%s", c.getName());
}
new ApplicationContextRunner().withInitializer(new ConditionEvaluationReportLoggingListener())
.withConfiguration(configurations).run(context -> {
t.section("the beans each one produced");
for (Marker marker : context.getBeansOfType(Marker.class).values()) {
t.line("Marker bean created by %s", marker.createdBy());
}
assertThat(context.getBeansOfType(Marker.class).values())
.extracting(Marker::createdBy).containsExactly("OrderedModuleAutoConfiguration");
t.section("what Boot recorded");
ReportSupport.print(t, (ConfigurableApplicationContext) context.getSourceApplicationContext(),
"ModuleAutoConfiguration");
});
}
}
@Test
void aPropertyConditionWithoutMatchIfMissingIsOffUntilSomeoneSetsIt() {
try (Transcript t = new Transcript("16-conditional-on-property-without-match-if-missing.txt",
"@ConditionalOnProperty(prefix = \"traps\", name = \"enabled\") with no matchIfMissing")) {
var runner = new ApplicationContextRunner().withInitializer(new ConditionEvaluationReportLoggingListener())
.withConfiguration(AutoConfigurations.of(DefaultOffAutoConfiguration.class));
t.section("no property set");
runner.run(context -> {
t.line("Marker beans : %d", context.getBeansOfType(Marker.class).size());
assertThat(context).doesNotHaveBean(Marker.class);
ReportSupport.print(t, (ConfigurableApplicationContext) context.getSourceApplicationContext(),
"DefaultOffAutoConfiguration");
});
t.section("traps.enabled=true");
runner.withPropertyValues("traps.enabled=true").run(context -> {
t.line("Marker beans : %d", context.getBeansOfType(Marker.class).size());
assertThat(context).hasSingleBean(Marker.class);
});
}
}
}
@@ -0,0 +1,73 @@
package com.ankurm.masker;
import java.util.Arrays;
import com.ankurm.consumer.ConsumerApplication;
import com.ankurm.masker.scanapp.ForgottenAutoConfiguration;
import com.ankurm.masker.scanapp.OwnersApplication;
import org.junit.jupiter.api.Test;
import org.springframework.boot.Banner;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.annotation.ImportCandidates;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.ConfigurableApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/** An auto-configuration missing from the imports file passes its own tests and does nothing in any application. */
class ForgottenImportsEntryTests {
private static ConfigurableApplicationContext start(Class<?> application) {
return new SpringApplicationBuilder(application).web(WebApplicationType.NONE).bannerMode(Banner.Mode.OFF)
.properties("logging.level.root=WARN").run();
}
@Test
void aRunnerTestNamesTheClassAndSoItPassesWhateverTheImportsFileSays() {
try (Transcript t = new Transcript("08-auto-configuration-missing-from-the-imports-file.txt",
"An auto-configuration that is not listed in AutoConfiguration.imports (it is listed in spring.factories, which Boot 4 ignores for this)")) {
t.section("ApplicationContextRunner: the test names the class itself");
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(ForgottenAutoConfiguration.class))
.run(context -> {
t.line("forgottenMarker bean : %s", context.containsBean("forgottenMarker"));
assertThat(context).hasBean("forgottenMarker");
});
t.section("the starter author's application: @SpringBootApplication scanning the package that holds both classes");
try (ConfigurableApplicationContext context = start(OwnersApplication.class)) {
t.line("forgottenMarker (@AutoConfiguration) : %s", context.containsBean("forgottenMarker"));
t.line("forgottenPlainMarker (@Configuration) : %s", context.containsBean("forgottenPlainMarker"));
assertThat(context.containsBean("forgottenMarker")).isFalse();
assertThat(context.containsBean("forgottenPlainMarker")).isTrue();
}
t.section("a consumer application: its own package, the starter on the classpath");
try (ConfigurableApplicationContext context = start(ConsumerApplication.class)) {
t.line("forgottenMarker (@AutoConfiguration) : %s", context.containsBean("forgottenMarker"));
t.line("forgottenPlainMarker (@Configuration) : %s", context.containsBean("forgottenPlainMarker"));
t.line("Masker beans : %s",
Arrays.toString(context.getBeanNamesForType(Masker.class)));
assertThat(context.containsBean("forgottenMarker")).isFalse();
assertThat(context.containsBean("forgottenPlainMarker")).isFalse();
}
}
}
@Test
void aTestThatReadsTheImportsFileCatchesTheOmission() {
try (Transcript t = new Transcript("09-reading-the-imports-file-in-a-test.txt",
"ImportCandidates: what Boot itself reads from META-INF/spring/...AutoConfiguration.imports")) {
var candidates = ImportCandidates.load(AutoConfiguration.class, getClass().getClassLoader()).getCandidates();
var ours = candidates.stream().filter(c -> c.startsWith("com.ankurm.masker.")).toList();
t.line("entries under com.ankurm.masker: %d", ours.size());
ours.forEach(c -> t.line(" %s", c));
t.line("ForgottenAutoConfiguration listed: %s", candidates.contains(ForgottenAutoConfiguration.class.getName()));
assertThat(ours).containsExactlyInAnyOrder(MaskerAutoConfiguration.class.getName(),
MaskerJacksonAutoConfiguration.class.getName());
}
}
}
@@ -0,0 +1,58 @@
package com.ankurm.masker;
import java.util.Arrays;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener;
import org.springframework.context.ConfigurableApplicationContext;
import tools.jackson.databind.json.JsonMapper;
import static org.assertj.core.api.Assertions.assertThat;
/** The optional integration: present with Jackson, silently absent without it, and the order that matters. */
class JacksonIntegrationTests {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withInitializer(new ConditionEvaluationReportLoggingListener())
.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class,
MaskerAutoConfiguration.class, MaskerJacksonAutoConfiguration.class));
@Test
void withJacksonASensitiveValueIsWrittenMasked() {
try (Transcript t = new Transcript("04-jackson-integration-active.txt",
"Jackson on the classpath: the module is registered and Sensitive is written masked")) {
runner.run(context -> {
assertThat(context).hasSingleBean(MaskerJacksonModule.class);
String json = context.getBean(JsonMapper.class)
.writeValueAsString(Map.of("card", new Sensitive("4111111111111111")));
assertThat(json).isEqualTo("{\"card\":\"************1111\"}");
t.line("module beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerJacksonModule.class)));
t.line("JSON : %s", json);
t.section("what Boot recorded for the Jackson auto-configuration");
ReportSupport.print(t, (ConfigurableApplicationContext) context.getSourceApplicationContext(),
"MaskerJacksonAutoConfiguration");
});
}
}
@Test
void withoutJacksonTheStarterStillStartsAndSaysWhy() {
try (Transcript t = new Transcript("05-jackson-integration-absent-without-error.txt",
"Jackson hidden with FilteredClassLoader: nothing fails, the integration just is not there")) {
runner.withClassLoader(new FilteredClassLoader("tools.jackson")).run(context -> {
assertThat(context).hasNotFailed().hasSingleBean(Masker.class).doesNotHaveBean(MaskerJacksonModule.class);
t.line("context failed : %s", context.getStartupFailure() != null);
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
t.line("module beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerJacksonModule.class)));
t.section("what Boot recorded for the Jackson auto-configuration");
ReportSupport.print(t, (ConfigurableApplicationContext) context.getSourceApplicationContext(),
"MaskerJacksonAutoConfiguration");
});
}
}
}
@@ -0,0 +1,93 @@
package com.ankurm.masker;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
/** The auto-configuration exercised with {@link ApplicationContextRunner}: no application, no server, a few milliseconds. */
class MaskerAutoConfigurationTests {
private static final String CARD = "4111111111111111";
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(MaskerAutoConfiguration.class));
/** A user configuration. Deliberately not annotated: the runner registers whatever class it is given. */
static class CustomMasker {
@Bean
Masker customMasker() {
return value -> "<redacted>";
}
}
@Test
void defaults() {
try (Transcript t = new Transcript("01-defaults-without-any-configuration.txt",
"The auto-configuration with no properties and no user beans")) {
runner.run(context -> {
assertThat(context).hasSingleBean(Masker.class).hasSingleBean(MaskerProperties.class);
MaskerProperties properties = context.getBean(MaskerProperties.class);
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
t.line("Masker implementation : %s", context.getBean(Masker.class).getClass().getSimpleName());
t.line("defaults bound : enabled=%s replacement='%s' visibleTail=%d",
properties.enabled(), properties.replacement(), properties.visibleTail());
t.line("mask(\"%s\") = %s", CARD, context.getBean(Masker.class).mask(CARD));
});
}
}
@Test
void propertiesChangeTheBehaviourAndEnabledFalseRemovesEverything() {
try (Transcript t = new Transcript("02-properties-and-the-off-switch.txt",
"masker.* properties, and masker.enabled=false")) {
t.section("masker.replacement=# masker.visible-tail=2");
runner.withPropertyValues("masker.replacement=#", "masker.visible-tail=2").run(context -> {
assertThat(context.getBean(Masker.class).mask(CARD)).isEqualTo("##############11");
t.line("mask(\"%s\") = %s", CARD, context.getBean(Masker.class).mask(CARD));
});
t.section("masker.enabled=false");
runner.withPropertyValues("masker.enabled=false").run(context -> {
assertThat(context).doesNotHaveBean(Masker.class).doesNotHaveBean(MaskerProperties.class);
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
t.line("MaskerProperties beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerProperties.class)));
});
}
}
@Test
void aUserBeanMakesTheStarterBackOffOnlyWhenItIsRegisteredFirst() {
try (Transcript t = new Transcript("03-conditional-on-missing-bean-and-registration-order.txt",
"@ConditionalOnMissingBean: backing off, and the order that decides it")) {
t.section("user configuration + auto-configuration (the way Boot loads them)");
runner.withUserConfiguration(CustomMasker.class).run(context -> {
assertThat(context).hasSingleBean(Masker.class);
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
t.line("getBean(Masker.class) : mask -> %s", context.getBean(Masker.class).mask(CARD));
});
t.section("the same two classes registered as ordinary configuration, starter first");
new ApplicationContextRunner().withUserConfiguration(MaskerAutoConfiguration.class, CustomMasker.class)
.run(context -> {
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
Throwable failure = catchThrowable(() -> context.getBean(Masker.class));
assertThat(failure).isInstanceOf(NoUniqueBeanDefinitionException.class);
t.line("getBean(Masker.class) : %s", failure.getClass().getSimpleName());
});
t.section("the same two classes, user configuration first");
new ApplicationContextRunner().withUserConfiguration(CustomMasker.class, MaskerAutoConfiguration.class)
.run(context -> {
assertThat(context).hasSingleBean(Masker.class);
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
});
}
}
}
@@ -0,0 +1,25 @@
package com.ankurm.masker;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport;
import org.springframework.context.ConfigurableApplicationContext;
/** Prints the condition outcomes Boot recorded for the classes whose name contains a filter, as {@code --debug} would. */
final class ReportSupport {
private ReportSupport() {
}
static void print(Transcript t, ConfigurableApplicationContext context, String filter) {
ConditionEvaluationReport report = ConditionEvaluationReport.get(context.getBeanFactory());
report.getConditionAndOutcomesBySource().entrySet().stream()
.filter(e -> e.getKey().contains(filter))
.sorted(Map.Entry.comparingByKey())
.forEach(e -> {
t.line("%s [%s]", e.getKey(), e.getValue().isFullMatch() ? "matched" : "not matched");
e.getValue().forEach(o -> t.line(" %s %s",
o.getOutcome().isMatch() ? "matched:" : "did not match:", o.getOutcome().getMessage()));
});
}
}
@@ -0,0 +1,52 @@
package com.ankurm.masker;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code custom-starter/output/} and echoes it to the console.
* Every console block quoted in the article comes out of one of these files verbatim.
*/
public final class Transcript implements AutoCloseable {
private static final Path ROOT =
Path.of(System.getProperty("basedir", ".")).toAbsolutePath().normalize().getParent();
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = ROOT.resolve("output").resolve(fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript section(String heading) {
out.println();
out.println("--- " + heading + " ---");
return this;
}
@Override
public void close() {
out.flush();
// Absolute paths of whoever ran the build are environment noise, not a finding.
String text = buffer.toString().replace(ROOT.toString(), "<custom-starter>");
try {
Files.createDirectories(path.getParent());
Files.writeString(path, text);
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(text);
}
}
@@ -0,0 +1,17 @@
package com.ankurm.masker.scanapp;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Bean;
/**
* An auto-configuration whose author never added it to {@code AutoConfiguration.imports}.
* It looks finished: annotated, conditional-free, and it compiles.
*/
@AutoConfiguration
public class ForgottenAutoConfiguration {
@Bean
String forgottenMarker() {
return "created by ForgottenAutoConfiguration";
}
}
@@ -0,0 +1,14 @@
package com.ankurm.masker.scanapp;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** The other way to forget: a plain {@code @Configuration}, also missing from the imports file. */
@Configuration(proxyBeanMethods = false)
public class ForgottenPlainConfiguration {
@Bean
String forgottenPlainMarker() {
return "created by ForgottenPlainConfiguration";
}
}
@@ -0,0 +1,11 @@
package com.ankurm.masker.scanapp;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* The starter author's own test application. It scans {@code com.ankurm.masker}, which is the starter's package,
* so it finds classes a real consumer's scan never will.
*/
@SpringBootApplication(scanBasePackages = "com.ankurm.masker.scanapp")
public class OwnersApplication {
}
@@ -0,0 +1,16 @@
package com.ankurm.traps;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
/** {@code @ConditionalOnProperty} without {@code matchIfMissing}: the property has to be set for anything to happen. */
@AutoConfiguration
@ConditionalOnProperty(prefix = "traps", name = "enabled")
public class DefaultOffAutoConfiguration {
@Bean
Marker defaultOffMarker() {
return new Marker("DefaultOffAutoConfiguration");
}
}
@@ -0,0 +1,20 @@
package com.ankurm.traps;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.context.annotation.Bean;
import tools.jackson.databind.json.JsonMapper;
/**
* Conditional on a bean that another auto-configuration creates, and says nothing about ordering.
* Its name sorts before Boot's, so it is evaluated first.
*/
@AutoConfiguration
@ConditionalOnBean(JsonMapper.class)
public class EarlyModuleAutoConfiguration {
@Bean
Marker earlyMarker() {
return new Marker("EarlyModuleAutoConfiguration");
}
}
@@ -0,0 +1,5 @@
package com.ankurm.traps;
/** A bean that only says which auto-configuration created it. */
public record Marker(String createdBy) {
}
@@ -0,0 +1,23 @@
package com.ankurm.traps;
import com.ankurm.masker.Masker;
import com.ankurm.masker.MaskerJacksonModule;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import tools.jackson.databind.json.JsonMapper;
/**
* The condition is on the {@code @Bean} method, but the method's return type needs Jackson to be loaded at all.
* Compare with {@code MaskerJacksonAutoConfiguration}, where the whole class carries the condition.
*/
@AutoConfiguration
public class MethodLevelConditionAutoConfiguration {
@Bean
@ConditionalOnClass(JsonMapper.class)
MaskerJacksonModule methodLevelModule(Masker masker) {
return new MaskerJacksonModule(masker);
}
}
@@ -0,0 +1,36 @@
package com.ankurm.traps;
import com.ankurm.masker.MaskerAutoConfiguration;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
/**
* Started by {@code scripts/capture-jar-facts.sh}, on classpaths with and without Jackson. A test cannot show this:
* the classes under test are already loaded by a loader that can see Jackson, whatever a filtered loader hides.
*
* <p>The argument picks the auto-configuration: {@code method} (condition on the {@code @Bean} method) or
* {@code nested} (condition on a nested class).
*/
public final class MethodLevelMain {
public static void main(String[] args) {
boolean nested = args.length > 0 && args[0].equals("nested");
Class<?> configuration = nested ? NestedConditionAutoConfiguration.class : MethodLevelConditionAutoConfiguration.class;
String beanName = nested ? "nestedModule" : "methodLevelModule";
try (var context = new AnnotationConfigApplicationContext()) {
context.register(MaskerAutoConfiguration.class, configuration);
context.refresh();
System.out.println("context started; " + beanName + " bean present: " + context.containsBean(beanName));
} catch (Throwable failure) {
StringBuilder chain = new StringBuilder();
Throwable root = failure;
for (Throwable t = failure; t != null; t = t.getCause()) {
chain.append(chain.isEmpty() ? "" : " -> ").append(t.getClass().getSimpleName());
root = t;
}
System.out.println("context failed");
System.out.println("exception chain: " + chain);
System.out.println("root cause : " + root.getClass().getName() + ": " + root.getMessage());
}
}
}
@@ -0,0 +1,25 @@
package com.ankurm.traps;
import com.ankurm.masker.Masker;
import com.ankurm.masker.MaskerJacksonModule;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tools.jackson.databind.json.JsonMapper;
/** The repair: the condition sits on a nested class, so the outer class never has a Jackson type in any signature. */
@AutoConfiguration
public class NestedConditionAutoConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(JsonMapper.class)
static class JacksonConfiguration {
@Bean
MaskerJacksonModule nestedModule(Masker masker) {
return new MaskerJacksonModule(masker);
}
}
}
@@ -0,0 +1,17 @@
package com.ankurm.traps;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.context.annotation.Bean;
import tools.jackson.databind.json.JsonMapper;
/** The same condition, with the ordering stated. {@code afterName} needs no compile-time dependency on Boot's Jackson module. */
@AutoConfiguration(afterName = "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration")
@ConditionalOnBean(JsonMapper.class)
public class OrderedModuleAutoConfiguration {
@Bean
Marker orderedMarker() {
return new Marker("OrderedModuleAutoConfiguration");
}
}
@@ -0,0 +1,2 @@
# The pre-Boot-2.7 way to register an auto-configuration. ForgottenImportsEntryTests shows that Boot 4 ignores it.
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.ankurm.masker.scanapp.ForgottenAutoConfiguration
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm.masker</groupId>
<artifactId>masker-parent</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>masker-spring-boot-starter</artifactId>
<name>masker-spring-boot-starter</name>
<description>The dependency an application adds. It has no code: it pulls in the auto-configuration module.</description>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.ankurm.masker</groupId>
<artifactId>masker-spring-boot-autoconfigure</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,6 @@
# The auto-configuration with no properties and no user beans
Masker beans : [masker]
Masker implementation : TailMasker
defaults bound : enabled=true replacement='*' visibleTail=4
mask("4111111111111111") = ************1111
@@ -0,0 +1,9 @@
# masker.* properties, and masker.enabled=false
--- masker.replacement=# masker.visible-tail=2 ---
mask("4111111111111111") = ##############11
--- masker.enabled=false ---
Masker beans : []
MaskerProperties beans : []
@@ -0,0 +1,13 @@
# @ConditionalOnMissingBean: backing off, and the order that decides it
--- user configuration + auto-configuration (the way Boot loads them) ---
Masker beans : [customMasker]
getBean(Masker.class) : mask -> <redacted>
--- the same two classes registered as ordinary configuration, starter first ---
Masker beans : [masker, customMasker]
getBean(Masker.class) : NoUniqueBeanDefinitionException
--- the same two classes, user configuration first ---
Masker beans : [customMasker]
@@ -0,0 +1,11 @@
# Jackson on the classpath: the module is registered and Sensitive is written masked
module beans : [maskerJacksonModule]
JSON : {"card":"************1111"}
--- what Boot recorded for the Jackson auto-configuration ---
com.ankurm.masker.MaskerJacksonAutoConfiguration [matched]
matched: @ConditionalOnClass found required class 'tools.jackson.databind.json.JsonMapper'
matched: @ConditionalOnBean (types: com.ankurm.masker.Masker; SearchStrategy: all) found bean 'masker'
com.ankurm.masker.MaskerJacksonAutoConfiguration#maskerJacksonModule [matched]
matched: @ConditionalOnMissingBean (types: com.ankurm.masker.MaskerJacksonModule; SearchStrategy: all) did not find any beans
@@ -0,0 +1,9 @@
# Jackson hidden with FilteredClassLoader: nothing fails, the integration just is not there
context failed : false
Masker beans : [masker]
module beans : []
--- what Boot recorded for the Jackson auto-configuration ---
com.ankurm.masker.MaskerJacksonAutoConfiguration [not matched]
did not match: @ConditionalOnClass did not find required class 'tools.jackson.databind.json.JsonMapper'
@@ -0,0 +1,17 @@
# @ConditionalOnBean(JsonMapper) with and without an ordering constraint
--- the order Boot evaluates them in ---
com.ankurm.masker.MaskerAutoConfiguration
com.ankurm.traps.EarlyModuleAutoConfiguration
org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration
com.ankurm.traps.OrderedModuleAutoConfiguration
--- the beans each one produced ---
Marker bean created by OrderedModuleAutoConfiguration
--- what Boot recorded ---
com.ankurm.traps.EarlyModuleAutoConfiguration [not matched]
did not match: @ConditionalOnBean (types: tools.jackson.databind.json.JsonMapper; SearchStrategy: all) did not find any beans of type tools.jackson.databind.json.JsonMapper
com.ankurm.traps.OrderedModuleAutoConfiguration [matched]
matched: @ConditionalOnBean (types: tools.jackson.databind.json.JsonMapper; SearchStrategy: all) found bean 'jacksonJsonMapper'
@@ -0,0 +1,12 @@
# @ConditionalOnClass(JsonMapper.class) on a @Bean method, versus on a nested class, with and without Jackson
--- condition on the @Bean method, classpath without any Jackson jar ---
context failed
exception chain: BeanDefinitionStoreException -> IllegalStateException -> NoClassDefFoundError -> ClassNotFoundException
root cause : java.lang.ClassNotFoundException: tools.jackson.databind.module.SimpleModule
--- condition on a nested class, classpath without any Jackson jar ---
context started; nestedModule bean present: false
--- condition on the @Bean method, classpath with Jackson ---
context started; methodLevelModule bean present: true
@@ -0,0 +1,14 @@
# An auto-configuration that is not listed in AutoConfiguration.imports (it is listed in spring.factories, which Boot 4 ignores for this)
--- ApplicationContextRunner: the test names the class itself ---
forgottenMarker bean : true
--- the starter author's application: @SpringBootApplication scanning the package that holds both classes ---
forgottenMarker (@AutoConfiguration) : false
forgottenPlainMarker (@Configuration) : true
--- a consumer application: its own package, the starter on the classpath ---
forgottenMarker (@AutoConfiguration) : false
forgottenPlainMarker (@Configuration) : false
Masker beans : [masker]
@@ -0,0 +1,6 @@
# ImportCandidates: what Boot itself reads from META-INF/spring/...AutoConfiguration.imports
entries under com.ankurm.masker: 2
com.ankurm.masker.MaskerAutoConfiguration
com.ankurm.masker.MaskerJacksonAutoConfiguration
ForgottenAutoConfiguration listed: false
@@ -0,0 +1,17 @@
# An application that adds masker-spring-boot-starter and nothing else
Masker beans : [masker]
MaskerProperties beans : [masker-com.ankurm.masker.MaskerProperties]
Jackson module beans : [maskerJacksonModule]
JSON of a Customer : {"name":"Asha","card":"************1111"}
--- what Boot recorded (the same lines --debug prints) ---
com.ankurm.masker.MaskerAutoConfiguration [matched]
matched: @ConditionalOnProperty (masker.enabled) matched
com.ankurm.masker.MaskerAutoConfiguration#masker [matched]
matched: @ConditionalOnMissingBean (types: com.ankurm.masker.Masker; SearchStrategy: all) did not find any beans
com.ankurm.masker.MaskerJacksonAutoConfiguration [matched]
matched: @ConditionalOnClass found required class 'tools.jackson.databind.json.JsonMapper'
matched: @ConditionalOnBean (types: com.ankurm.masker.Masker; SearchStrategy: all) found bean 'masker'
com.ankurm.masker.MaskerJacksonAutoConfiguration#maskerJacksonModule [matched]
matched: @ConditionalOnMissingBean (types: com.ankurm.masker.MaskerJacksonModule; SearchStrategy: all) did not find any beans
@@ -0,0 +1,16 @@
# The same application configured with masker.* properties, then switched off
--- --masker.replacement=# --masker.visible-tail=6 ---
JSON of a Customer : {"name":"Asha","card":"##########111111"}
--- --masker.enabled=false ---
Masker beans : []
Jackson module beans : []
--- what Boot recorded ---
com.ankurm.masker.MaskerAutoConfiguration [not matched]
did not match: @ConditionalOnProperty (masker.enabled) found different value in property 'enabled'
com.ankurm.masker.MaskerJacksonAutoConfiguration [not matched]
matched: @ConditionalOnClass found required class 'tools.jackson.databind.json.JsonMapper'
did not match: @ConditionalOnBean (types: com.ankurm.masker.Masker; SearchStrategy: all) did not find any beans of type com.ankurm.masker.Masker
@@ -0,0 +1,74 @@
# What is inside the two jars, and what the starter drags in
--- masker-spring-boot-starter-1.0.0.jar ---
META-INF/MANIFEST.MF
META-INF/maven/com.ankurm.masker/masker-spring-boot-starter/pom.xml
META-INF/maven/com.ankurm.masker/masker-spring-boot-starter/pom.properties
--- masker-spring-boot-autoconfigure-1.0.0.jar (META-INF and the classes) ---
META-INF/spring-autoconfigure-metadata.properties
META-INF/additional-spring-configuration-metadata.json
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
META-INF/spring-configuration-metadata.json
com/ankurm/masker/MaskerJacksonAutoConfiguration.class
com/ankurm/masker/Sensitive.class
com/ankurm/masker/Masker.class
com/ankurm/masker/MaskerAutoConfiguration.class
com/ankurm/masker/TailMasker.class
com/ankurm/masker/MaskerJacksonModule.class
com/ankurm/masker/MaskerProperties.class
com/ankurm/masker/MaskerJacksonModule$1.class
--- META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports ---
com.ankurm.masker.MaskerAutoConfiguration
com.ankurm.masker.MaskerJacksonAutoConfiguration
--- dependency tree of masker-spring-boot-starter (Jackson is not in it) ---
com.ankurm.masker:masker-spring-boot-starter:jar:1.0.0
+- org.springframework.boot:spring-boot-starter:jar:4.1.1:compile
| +- org.springframework.boot:spring-boot-starter-logging:jar:4.1.1:compile
| | +- ch.qos.logback:logback-classic:jar:1.5.38:compile
| | | +- ch.qos.logback:logback-core:jar:1.5.38:compile
| | | \- org.slf4j:slf4j-api:jar:2.0.18:compile
| | +- org.apache.logging.log4j:log4j-to-slf4j:jar:2.25.5:compile
| | | \- org.apache.logging.log4j:log4j-api:jar:2.25.5:compile
| | \- org.slf4j:jul-to-slf4j:jar:2.0.18:compile
| +- org.springframework.boot:spring-boot-autoconfigure:jar:4.1.1:compile
| | \- org.springframework.boot:spring-boot:jar:4.1.1:compile
| | +- org.springframework:spring-core:jar:7.0.9:compile
| | | +- commons-logging:commons-logging:jar:1.3.6:compile
| | | \- org.jspecify:jspecify:jar:1.0.1:compile
| | \- org.springframework:spring-context:jar:7.0.9:compile
| | +- org.springframework:spring-aop:jar:7.0.9:compile
| | +- org.springframework:spring-beans:jar:7.0.9:compile
| | +- org.springframework:spring-expression:jar:7.0.9:compile
| | \- io.micrometer:micrometer-observation:jar:1.17.1:compile
| | \- io.micrometer:micrometer-commons:jar:1.17.1:compile
| +- jakarta.annotation:jakarta.annotation-api:jar:3.0.0:compile
| \- org.yaml:snakeyaml:jar:2.6:compile
\- com.ankurm.masker:masker-spring-boot-autoconfigure:jar:1.0.0:compile
--- dependency tree of masker-spring-boot-autoconfigure (Jackson is there, marked optional in the pom) ---
com.ankurm.masker:masker-spring-boot-autoconfigure:jar:1.0.0
+- org.springframework.boot:spring-boot-autoconfigure:jar:4.1.1:compile
| \- org.springframework.boot:spring-boot:jar:4.1.1:compile
| \- org.springframework:spring-context:jar:7.0.9:compile
| +- org.springframework:spring-aop:jar:7.0.9:compile
| +- org.springframework:spring-beans:jar:7.0.9:compile
| +- org.springframework:spring-expression:jar:7.0.9:compile
| \- io.micrometer:micrometer-observation:jar:1.17.1:compile
| \- io.micrometer:micrometer-commons:jar:1.17.1:compile
\- org.springframework.boot:spring-boot-starter-jackson:jar:4.1.1:compile (optional)
+- org.springframework.boot:spring-boot-starter:jar:4.1.1:compile
| +- org.springframework.boot:spring-boot-starter-logging:jar:4.1.1:compile
| | +- ch.qos.logback:logback-classic:jar:1.5.38:compile
| | | \- ch.qos.logback:logback-core:jar:1.5.38:compile
| | +- org.apache.logging.log4j:log4j-to-slf4j:jar:2.25.5:compile
| | | \- org.apache.logging.log4j:log4j-api:jar:2.25.5:compile
| | \- org.slf4j:jul-to-slf4j:jar:2.0.18:compile
| +- jakarta.annotation:jakarta.annotation-api:jar:3.0.0:compile
| \- org.yaml:snakeyaml:jar:2.6:compile
\- org.springframework.boot:spring-boot-jackson:jar:4.1.1:compile (optional)
\- tools.jackson.core:jackson-databind:jar:3.1.5:compile (optional)
+- com.fasterxml.jackson.core:jackson-annotations:jar:2.21:compile (optional)
\- tools.jackson.core:jackson-core:jar:3.1.5:compile (optional)
@@ -0,0 +1,80 @@
# Metadata the two annotation processors write into the auto-configuration jar
--- META-INF/spring-configuration-metadata.json, written by spring-boot-configuration-processor ---
{
"groups": [
{
"name": "masker",
"type": "com.ankurm.masker.MaskerProperties",
"sourceType": "com.ankurm.masker.MaskerProperties"
}
],
"properties": [
{
"name": "masker.enabled",
"type": "java.lang.Boolean",
"description": "whether the starter configures anything at all",
"sourceType": "com.ankurm.masker.MaskerProperties",
"defaultValue": true
},
{
"name": "masker.replacement",
"type": "java.lang.String",
"description": "the text that stands in for each hidden character",
"sourceType": "com.ankurm.masker.MaskerProperties",
"defaultValue": "*"
},
{
"name": "masker.visible-tail",
"type": "java.lang.Integer",
"description": "how many characters at the end of the value stay readable",
"sourceType": "com.ankurm.masker.MaskerProperties",
"defaultValue": 4
}
],
"hints": [
{
"name": "masker.replacement",
"values": [
{
"value": "*",
"description": "Asterisk (the default)."
},
{
"value": "#",
"description": "Hash sign."
},
{
"value": "•",
"description": "Bullet."
}
]
}
],
"ignored": {
"properties": []
}
}
--- META-INF/additional-spring-configuration-metadata.json, hand-written and kept as is ---
{
"hints": [
{
"name": "masker.replacement",
"values": [
{ "value": "*", "description": "Asterisk (the default)." },
{ "value": "#", "description": "Hash sign." },
{ "value": "•", "description": "Bullet." }
]
}
]
}
--- META-INF/spring-autoconfigure-metadata.properties, written by spring-boot-autoconfigure-processor ---
com.ankurm.masker.MaskerAutoConfiguration=
com.ankurm.masker.MaskerJacksonAutoConfiguration=
com.ankurm.masker.MaskerJacksonAutoConfiguration.AutoConfigureAfter=com.ankurm.masker.MaskerAutoConfiguration
com.ankurm.masker.MaskerJacksonAutoConfiguration.ConditionalOnBean=com.ankurm.masker.Masker
com.ankurm.masker.MaskerJacksonAutoConfiguration.ConditionalOnClass=tools.jackson.databind.json.JsonMapper
@@ -0,0 +1,30 @@
# Boot 4.1.1: where the auto-configuration classes a starter refers to now live
--- entries in spring-boot-autoconfigure's own AutoConfiguration.imports ---
12
--- JacksonAutoConfiguration: which jar, which package ---
spring-boot-autoconfigure-4.1.1.jar, classes named JacksonAutoConfiguration: 0
spring-boot-jackson-4.1.1.jar: org/springframework/boot/jackson/autoconfigure/JacksonAutoConfiguration.class
--- what @AutoConfiguration is made of (javap -v, class-level annotations) ---
org.springframework.context.annotation.Configuration(
proxyBeanMethods=false
org.springframework.boot.autoconfigure.AutoConfigureBefore
org.springframework.boot.autoconfigure.AutoConfigureAfter
--- AutoConfigurationExcludeFilter, the filter @SpringBootApplication's scan applies: the classes it tests for (javap -c) ---
ldc #26 // class org/springframework/context/annotation/Configuration
ldc #40 // class org/springframework/boot/autoconfigure/AutoConfiguration
ldc #40 // class org/springframework/boot/autoconfigure/AutoConfiguration
invokestatic #65 // Method org/springframework/boot/context/annotation/ImportCandidates.load:(Ljava/lang/Class;Ljava/lang/ClassLoader;)Lorg/springframework/boot/context/annotation/ImportCandidates;
--- the Boot 3 import, compiled against the 4.1.1 jars ---
masker-spring-boot-autoconfigure/src/broken/OldJacksonImport.java:4: error: package org.springframework.boot.autoconfigure.jackson does not exist
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
^
masker-spring-boot-autoconfigure/src/broken/OldJacksonImport.java:7: error: cannot find symbol
@AutoConfiguration(after = JacksonAutoConfiguration.class)
^
symbol: class JacksonAutoConfiguration
2 errors
@@ -0,0 +1,3 @@
# The processor path with <version>${project.parent.version}</version>, when the module's parent is not Spring Boot's
Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project masker-spring-boot-autoconfigure: Resolution of annotationProcessorPath dependencies failed: The following artifacts could not be resolved: org.springframework.boot:spring-boot-configuration-processor:jar:1.0.0 (absent): Could not find artifact org.springframework.boot:spring-boot-configuration-processor:jar:1.0.0 in central (https://repo.maven.apache.org/maven2)
@@ -0,0 +1,10 @@
# @ConditionalOnProperty(prefix = "traps", name = "enabled") with no matchIfMissing
--- no property set ---
Marker beans : 0
com.ankurm.traps.DefaultOffAutoConfiguration [not matched]
did not match: @ConditionalOnProperty (traps.enabled) did not find property 'enabled'
--- traps.enabled=true ---
Marker beans : 1
@@ -0,0 +1,15 @@
# Which 4.1.1 jar holds each Boot class this starter and its tests import (searched in every Boot jar the build resolved)
spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/AutoConfiguration.class
spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/AutoConfigureAfter.class
spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/AutoConfigurations.class
spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/condition/ConditionalOnClass.class
spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/condition/ConditionalOnBean.class
spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBean.class
spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/condition/ConditionalOnProperty.class
spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLoggingListener.class
spring-boot-4.1.1.jar org/springframework/boot/context/annotation/ImportCandidates.class
spring-boot-4.1.1.jar org/springframework/boot/context/annotation/Configurations.class
spring-boot-jackson-4.1.1.jar org/springframework/boot/jackson/autoconfigure/JacksonAutoConfiguration.class
spring-boot-test-4.1.1.jar org/springframework/boot/test/context/runner/ApplicationContextRunner.class
spring-boot-test-4.1.1.jar org/springframework/boot/test/context/FilteredClassLoader.class
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm.masker</groupId>
<artifactId>masker-parent</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<name>masker-parent</name>
<description>A custom Spring Boot 4 starter: auto-configuration, @Conditional, properties metadata and tests</description>
<properties>
<java.version>25</java.version>
</properties>
<modules>
<module>masker-spring-boot-autoconfigure</module>
<module>masker-spring-boot-starter</module>
<module>masker-demo</module>
</modules>
</project>
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# Facts that a test cannot show, read from the built jars and from deliberately failing builds:
# 07 @ConditionalOnClass on a @Bean method versus on a nested class, on classpaths with and without Jackson
# 12 what is in the starter jar and the auto-configuration jar, and what the starter pulls in
# 13 the generated configuration metadata and auto-configuration metadata, as they sit in the jar
# 14 Boot 4's split of spring-boot-autoconfigure, and the Boot 3 import that no longer compiles
# 15 the annotation-processor version trap when the module has its own parent pom
# 17 which Boot 4.1.1 jar holds each class the starter imports
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p output
M2=~/.m2/repository/org/springframework/boot
AC=masker-spring-boot-autoconfigure
JP() { "$@" 2>&1 | grep -v -E '^(Picked up|WARNING)'; }
ROOT=$(pwd)
clean() { sed "s#$ROOT#<custom-starter>#g; s#$HOME#~#g"; }
echo "== build the jars"
mvn -B -q package -DskipTests dependency:tree -Dscope=compile -DoutputFile=target/tree.txt >/dev/null 2>&1 || mvn -B package -DskipTests dependency:tree -Dscope=compile -DoutputFile=target/tree.txt
echo "== 07"
mvn -B -q -pl "$AC" test-compile dependency:build-classpath -Dmdep.outputFile=/tmp/masker-cp.txt -Dmdep.includeScope=test >/dev/null 2>&1
FULL=$(cat /tmp/masker-cp.txt)
NOJACKSON=$(tr ':' '\n' < /tmp/masker-cp.txt | grep -v jackson | paste -sd:)
{
echo "# @ConditionalOnClass(JsonMapper.class) on a @Bean method, versus on a nested class, with and without Jackson"
echo
echo "--- condition on the @Bean method, classpath without any Jackson jar ---"
JP java -cp "$AC/target/test-classes:$AC/target/classes:$NOJACKSON" com.ankurm.traps.MethodLevelMain method | grep -v -E '^[0-9:.]+ \[main\]'
echo
echo "--- condition on a nested class, classpath without any Jackson jar ---"
JP java -cp "$AC/target/test-classes:$AC/target/classes:$NOJACKSON" com.ankurm.traps.MethodLevelMain nested | grep -v -E '^[0-9:.]+ \[main\]'
echo
echo "--- condition on the @Bean method, classpath with Jackson ---"
JP java -cp "$AC/target/test-classes:$AC/target/classes:$FULL" com.ankurm.traps.MethodLevelMain method | grep -v -E '^[0-9:.]+ \[main\]'
} | clean > output/07-conditional-on-class-on-a-bean-method.txt
echo "== 12"
{
echo "# What is inside the two jars, and what the starter drags in"
echo
echo "--- masker-spring-boot-starter-1.0.0.jar ---"
unzip -l masker-spring-boot-starter/target/masker-spring-boot-starter-1.0.0.jar | awk 'NR>3 && $4!="" {print $4}' | grep -v '/$'
echo
echo "--- masker-spring-boot-autoconfigure-1.0.0.jar (META-INF and the classes) ---"
unzip -l $AC/target/$AC-1.0.0.jar | awk 'NR>3 && $4!="" {print $4}' | grep -v '/$' | grep -v -E 'pom\.(xml|properties)|MANIFEST'
echo
echo "--- META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports ---"
unzip -p $AC/target/$AC-1.0.0.jar META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
echo
echo "--- dependency tree of masker-spring-boot-starter (Jackson is not in it) ---"
cat masker-spring-boot-starter/target/tree.txt
echo
echo "--- dependency tree of masker-spring-boot-autoconfigure (Jackson is there, marked optional in the pom) ---"
cat $AC/target/tree.txt
} | clean > output/12-jar-contents-and-dependency-trees.txt
echo "== 13"
{
echo "# Metadata the two annotation processors write into the auto-configuration jar"
echo
echo "--- META-INF/spring-configuration-metadata.json, written by spring-boot-configuration-processor ---"
echo
unzip -p $AC/target/$AC-1.0.0.jar META-INF/spring-configuration-metadata.json
echo
echo
echo "--- META-INF/additional-spring-configuration-metadata.json, hand-written and kept as is ---"
unzip -p $AC/target/$AC-1.0.0.jar META-INF/additional-spring-configuration-metadata.json
echo
echo
echo "--- META-INF/spring-autoconfigure-metadata.properties, written by spring-boot-autoconfigure-processor ---"
unzip -p $AC/target/$AC-1.0.0.jar META-INF/spring-autoconfigure-metadata.properties
} > output/13-configuration-metadata-in-the-jar.txt
echo "== 14"
BOOT_AC=$M2/spring-boot-autoconfigure/4.1.1/spring-boot-autoconfigure-4.1.1.jar
BOOT_JACKSON=$M2/spring-boot-jackson/4.1.1/spring-boot-jackson-4.1.1.jar
{
echo "# Boot 4.1.1: where the auto-configuration classes a starter refers to now live"
echo
echo "--- entries in spring-boot-autoconfigure's own AutoConfiguration.imports ---"
unzip -p "$BOOT_AC" META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | grep -c .
echo
echo "--- JacksonAutoConfiguration: which jar, which package ---"
echo "spring-boot-autoconfigure-4.1.1.jar, classes named JacksonAutoConfiguration: $(unzip -l "$BOOT_AC" | grep -c 'JacksonAutoConfiguration' || true)"
unzip -l "$BOOT_JACKSON" | awk '/JacksonAutoConfiguration.class/ {print "spring-boot-jackson-4.1.1.jar: " $4}'
echo
X=$(mktemp -d)
unzip -q -o "$BOOT_AC" 'org/springframework/boot/autoconfigure/AutoConfiguration.class' \
'org/springframework/boot/autoconfigure/AutoConfigurationExcludeFilter.class' -d "$X"
echo "--- what @AutoConfiguration is made of (javap -v, class-level annotations) ---"
JP javap -v -cp "$X" org.springframework.boot.autoconfigure.AutoConfiguration \
| sed -n '/^RuntimeVisibleAnnotations/,$p' | grep -E '^ (org\.springframework)|proxyBeanMethods' | sed 's/^ *//'
echo
echo "--- AutoConfigurationExcludeFilter, the filter @SpringBootApplication's scan applies: the classes it tests for (javap -c) ---"
JP javap -p -c -cp "$X" org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter \
| grep -E 'ldc|ImportCandidates.load' | sed -E 's/^ *[0-9]+: //; s/ +/ /g'
rm -rf "$X"
echo
echo "--- the Boot 3 import, compiled against the 4.1.1 jars ---"
mkdir -p /tmp/masker-broken
JP javac -d /tmp/masker-broken -cp "$BOOT_AC:$BOOT_JACKSON" $AC/src/broken/OldJacksonImport.java | clean || true
} > output/14-boot4-autoconfiguration-locations.txt
echo "== 17"
{
echo "# Which 4.1.1 jar holds each Boot class this starter and its tests import (searched in every Boot jar the build resolved)"
echo
for c in AutoConfiguration AutoConfigureAfter AutoConfigurations ConditionalOnClass ConditionalOnBean ConditionalOnMissingBean ConditionalOnProperty \
ConditionEvaluationReportLoggingListener ImportCandidates Configurations JacksonAutoConfiguration ApplicationContextRunner FilteredClassLoader; do
for j in "$M2"/spring-boot*/4.1.1/spring-boot*-4.1.1.jar; do
unzip -l "$j" | awk -v c="/$c.class" -v j="$(basename "$j")" 'index($4, c) && $4 !~ /\$/ {printf "%-46s %s\n", j, $4}'
done
done
} > output/17-where-the-classes-live.txt
echo "== 15"
TMP=$(mktemp -d)
cp -r pom.xml "$AC" "$TMP/"
mkdir -p "$TMP/masker-spring-boot-starter" "$TMP/masker-demo"
cp masker-spring-boot-starter/pom.xml "$TMP/masker-spring-boot-starter/"; cp masker-demo/pom.xml "$TMP/masker-demo/"
rm -rf "$TMP/$AC/target"
python3 - "$TMP/$AC/pom.xml" <<'PY'
import sys
p=sys.argv[1]
s=open(p).read()
s=s.replace("<artifactId>spring-boot-configuration-processor</artifactId>","<artifactId>spring-boot-configuration-processor</artifactId>\n <version>${project.parent.version}</version>")
open(p,'w').write(s)
PY
{
echo "# The processor path with <version>\${project.parent.version}</version>, when the module's parent is not Spring Boot's"
echo
(cd "$TMP" && mvn -B -U -pl $AC compile 2>&1 | grep -E 'ERROR.*(annotationProcessorPath|Could not find artifact)' | head -1 | sed -E 's/^\[ERROR\] //; s/ -> \[Help 1\]//')
} > output/15-annotation-processor-version-trap.txt
rm -rf "$TMP"
echo "done"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Regenerates every file under output/.
#
# ./scripts/run-all.sh
#
# Needs a JDK 25 and Maven 3.9. Transcripts 01-06, 08-11 and 16 come out of the test suite, which is the point:
# the figures in the article are assertions that fail the build if they stop being true.
# Transcripts 07 and 12-15 are read from the built jars and from builds that are made to fail on purpose.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== test suite (transcripts 01-06, 08-11, 16)"
mvn -B test
echo "== jar contents, metadata, Boot 4 locations and deliberate failures (07, 12-15, 17)"
./scripts/capture-jar-facts.sh
echo
echo "output:"
ls -1 output