diff --git a/openrewrite/README.md b/openrewrite/README.md
new file mode 100644
index 0000000..251fa3d
--- /dev/null
+++ b/openrewrite/README.md
@@ -0,0 +1,27 @@
+# openrewrite
+
+Companion module for the ankurm.com post *Automating javax → jakarta and Boot 3 → 4 Upgrades with OpenRewrite*.
+
+`legacy-app/` is a small Spring Boot 2.7.18 application (JPA, validation, security, a servlet filter, `RestTemplate`, a JUnit 4 test, a `spring.factories` auto-configuration). `scripts/run-all.sh` copies it to a scratch directory outside this checkout, runs two OpenRewrite recipes in sequence, builds and tests after each, and records everything under `output/`.
+
+| Tool | Version |
+|---|---|
+| rewrite-maven-plugin | 6.46.1 |
+| rewrite-spring | 6.37.1 |
+| rewrite-migrate-java | 3.42.1 |
+| JDK / Maven | 17 / 3.9 |
+
+```bash
+export JAVA_HOME=/path/to/jdk17
+./scripts/run-all.sh
+```
+
+| Output | Shows |
+|---|---|
+| `00-gotcha-gitignore.txt` | OpenRewrite silently skips sources that git ignores |
+| `01-baseline.txt` | Legacy app builds and passes on Boot 2.7.18 |
+| `02-step1-diff.txt` / `03-step1-build.txt` | `UpgradeSpringBoot_3_5`: diff and build |
+| `04-step2-diff.txt` / `05-step2-build.txt` | `UpgradeSpringBoot_4_0`: diff and build |
+| `06-what-it-left-behind.txt` | What the recipes did not change |
+| `07-step3-boot-4.1.1.txt` | Hand bump from 4.0.x to 4.1.1, build and test |
+| `08-ci-gate.txt` | `rewrite:dryRun` exit codes, including a property-name trap |
diff --git a/openrewrite/legacy-app/pom.xml b/openrewrite/legacy-app/pom.xml
new file mode 100644
index 0000000..492b954
--- /dev/null
+++ b/openrewrite/legacy-app/pom.xml
@@ -0,0 +1,32 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 2.7.18
+
+
+ com.ankurm
+ legacy-app
+ 1.0.0
+
+ 17
+
+
+ org.springframework.bootspring-boot-starter-web
+ org.springframework.bootspring-boot-starter-data-jpa
+ org.springframework.bootspring-boot-starter-validation
+ org.springframework.bootspring-boot-starter-security
+ org.springframework.bootspring-boot-starter-actuator
+ com.h2databaseh2runtime
+ org.springframework.bootspring-boot-starter-testtest
+ org.junit.vintagejunit-vintage-enginetest
+ org.springframework.securityspring-security-testtest
+
+
+
+ org.springframework.bootspring-boot-maven-plugin
+
+
+
diff --git a/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/AuditAutoConfiguration.java b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/AuditAutoConfiguration.java
new file mode 100644
index 0000000..e578260
--- /dev/null
+++ b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/AuditAutoConfiguration.java
@@ -0,0 +1,7 @@
+package com.ankurm.legacy;
+
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class AuditAutoConfiguration {
+}
diff --git a/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/Customer.java b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/Customer.java
new file mode 100644
index 0000000..2942388
--- /dev/null
+++ b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/Customer.java
@@ -0,0 +1,26 @@
+package com.ankurm.legacy;
+
+import javax.persistence.Entity;
+import javax.persistence.GeneratedValue;
+import javax.persistence.Id;
+import javax.validation.constraints.Email;
+import javax.validation.constraints.NotBlank;
+
+@Entity
+public class Customer {
+ @Id
+ @GeneratedValue
+ private Long id;
+ @NotBlank
+ private String name;
+ @Email
+ private String email;
+
+ protected Customer() {}
+ public Customer(String name, String email) { this.name = name; this.email = email; }
+ public Long getId() { return id; }
+ public String getName() { return name; }
+ public String getEmail() { return email; }
+ public void setName(String name) { this.name = name; }
+ public void setEmail(String email) { this.email = email; }
+}
diff --git a/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/CustomerController.java b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/CustomerController.java
new file mode 100644
index 0000000..88e9b91
--- /dev/null
+++ b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/CustomerController.java
@@ -0,0 +1,28 @@
+package com.ankurm.legacy;
+
+import java.util.List;
+import javax.annotation.PostConstruct;
+import javax.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/customers")
+public class CustomerController {
+
+ private final CustomerRepository repository;
+
+ public CustomerController(CustomerRepository repository) { this.repository = repository; }
+
+ @PostConstruct
+ void seed() {
+ repository.save(new Customer("Ada", "ada@example.com"));
+ }
+
+ @GetMapping
+ List all() { return repository.findAll(); }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ Customer create(@Valid @RequestBody Customer c) { return repository.save(c); }
+}
diff --git a/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/CustomerRepository.java b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/CustomerRepository.java
new file mode 100644
index 0000000..09695e1
--- /dev/null
+++ b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/CustomerRepository.java
@@ -0,0 +1,14 @@
+package com.ankurm.legacy;
+
+import javax.persistence.LockModeType;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.QueryHints;
+import javax.persistence.QueryHint;
+
+public interface CustomerRepository extends JpaRepository {
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @QueryHints(@QueryHint(name = "javax.persistence.lock.timeout", value = "3000"))
+ Customer findFirstByName(String name);
+}
diff --git a/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/LegacyApplication.java b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/LegacyApplication.java
new file mode 100644
index 0000000..9dbd00c
--- /dev/null
+++ b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/LegacyApplication.java
@@ -0,0 +1,11 @@
+package com.ankurm.legacy;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class LegacyApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(LegacyApplication.class, args);
+ }
+}
diff --git a/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/RemoteClient.java b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/RemoteClient.java
new file mode 100644
index 0000000..7c79947
--- /dev/null
+++ b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/RemoteClient.java
@@ -0,0 +1,13 @@
+package com.ankurm.legacy;
+
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+
+@Component
+public class RemoteClient {
+ private final RestTemplate rest = new RestTemplate();
+
+ public String fetch(String url) {
+ return rest.getForObject(url, String.class);
+ }
+}
diff --git a/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/RequestLogFilter.java b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/RequestLogFilter.java
new file mode 100644
index 0000000..8fc169e
--- /dev/null
+++ b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/RequestLogFilter.java
@@ -0,0 +1,19 @@
+package com.ankurm.legacy;
+
+import java.io.IOException;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+@Component
+public class RequestLogFilter extends OncePerRequestFilter {
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException {
+ response.setHeader("X-Seen-By", "RequestLogFilter");
+ chain.doFilter(request, response);
+ }
+}
diff --git a/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/SecurityConfig.java b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/SecurityConfig.java
new file mode 100644
index 0000000..0127fe8
--- /dev/null
+++ b/openrewrite/legacy-app/src/main/java/com/ankurm/legacy/SecurityConfig.java
@@ -0,0 +1,17 @@
+package com.ankurm.legacy;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig extends WebSecurityConfigurerAdapter {
+ @Override
+ protected void configure(HttpSecurity http) throws Exception {
+ http.csrf().disable()
+ .authorizeRequests().antMatchers("/actuator/health").permitAll()
+ .anyRequest().permitAll();
+ }
+}
diff --git a/openrewrite/legacy-app/src/main/resources/META-INF/spring.factories b/openrewrite/legacy-app/src/main/resources/META-INF/spring.factories
new file mode 100644
index 0000000..03563db
--- /dev/null
+++ b/openrewrite/legacy-app/src/main/resources/META-INF/spring.factories
@@ -0,0 +1 @@
+org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.ankurm.legacy.AuditAutoConfiguration
diff --git a/openrewrite/legacy-app/src/main/resources/application.properties b/openrewrite/legacy-app/src/main/resources/application.properties
new file mode 100644
index 0000000..7745c02
--- /dev/null
+++ b/openrewrite/legacy-app/src/main/resources/application.properties
@@ -0,0 +1,6 @@
+spring.redis.host=localhost
+spring.redis.port=6379
+management.metrics.export.prometheus.enabled=true
+spring.jpa.hibernate.ddl-auto=create-drop
+server.max-http-header-size=16KB
+spring.jpa.properties.javax.persistence.validation.mode=none
diff --git a/openrewrite/legacy-app/src/test/java/com/ankurm/legacy/CustomerControllerTest.java b/openrewrite/legacy-app/src/test/java/com/ankurm/legacy/CustomerControllerTest.java
new file mode 100644
index 0000000..8771ce5
--- /dev/null
+++ b/openrewrite/legacy-app/src/test/java/com/ankurm/legacy/CustomerControllerTest.java
@@ -0,0 +1,26 @@
+package com.ankurm.legacy;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.web.servlet.MockMvc;
+
+@RunWith(SpringRunner.class)
+@SpringBootTest
+@AutoConfigureMockMvc
+public class CustomerControllerTest {
+
+ @Autowired MockMvc mvc;
+
+ @Test
+ public void listsSeededCustomer() throws Exception {
+ mvc.perform(get("/customers")).andExpect(status().isOk()).andExpect(jsonPath("$[0].name").value("Ada"));
+ }
+}
diff --git a/openrewrite/output/00-gotcha-gitignore.txt b/openrewrite/output/00-gotcha-gitignore.txt
new file mode 100644
index 0000000..dd5742f
--- /dev/null
+++ b/openrewrite/output/00-gotcha-gitignore.txt
@@ -0,0 +1,3 @@
+# 00-gotcha-gitignore: same recipe, same project, sources under a .gitignore'd directory
+[INFO] Project [legacy-app] Parsing source files
+[INFO] Applying recipes would make no changes. No patch file generated.
diff --git a/openrewrite/output/01-baseline.txt b/openrewrite/output/01-baseline.txt
new file mode 100644
index 0000000..652232c
--- /dev/null
+++ b/openrewrite/output/01-baseline.txt
@@ -0,0 +1,3 @@
+# 01-baseline: legacy app, Spring Boot 2.7.18, JDK 17
+[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
+[INFO] BUILD SUCCESS
diff --git a/openrewrite/output/02-step1-diff.txt b/openrewrite/output/02-step1-diff.txt
new file mode 100644
index 0000000..c4e73fd
--- /dev/null
+++ b/openrewrite/output/02-step1-diff.txt
@@ -0,0 +1,173 @@
+# 02-step1-diff: recipe org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_5 on the legacy app (52 s)
+diff -ru -x target legacy-app/pom.xml step1/pom.xml
+--- legacy-app/pom.xml
++++ step1/pom.xml
+@@ -4,7 +4,7 @@
+
+ org.springframework.boot
+ spring-boot-starter-parent
+- 2.7.18
++ 3.5.16
+
+
+ com.ankurm
+@@ -14,6 +14,10 @@
+ 17
+
+
++
++ jakarta.servlet
++ jakarta.servlet-api
++
+ org.springframework.bootspring-boot-starter-web
+ org.springframework.bootspring-boot-starter-data-jpa
+ org.springframework.bootspring-boot-starter-validation
+@@ -21,7 +25,6 @@
+ org.springframework.bootspring-boot-starter-actuator
+ com.h2databaseh2runtime
+ org.springframework.bootspring-boot-starter-testtest
+- org.junit.vintagejunit-vintage-enginetest
+ org.springframework.securityspring-security-testtest
+
+
+diff -ru -x target legacy-app/src/main/java/com/ankurm/legacy/AuditAutoConfiguration.java step1/src/main/java/com/ankurm/legacy/AuditAutoConfiguration.java
+--- legacy-app/src/main/java/com/ankurm/legacy/AuditAutoConfiguration.java
++++ step1/src/main/java/com/ankurm/legacy/AuditAutoConfiguration.java
+@@ -1,7 +1,7 @@
+ package com.ankurm.legacy;
+
+-import org.springframework.context.annotation.Configuration;
++import org.springframework.boot.autoconfigure.AutoConfiguration;
+
+-@Configuration
++@AutoConfiguration
+ public class AuditAutoConfiguration {
+ }
+diff -ru -x target legacy-app/src/main/java/com/ankurm/legacy/Customer.java step1/src/main/java/com/ankurm/legacy/Customer.java
+--- legacy-app/src/main/java/com/ankurm/legacy/Customer.java
++++ step1/src/main/java/com/ankurm/legacy/Customer.java
+@@ -1,10 +1,10 @@
+ package com.ankurm.legacy;
+
+-import javax.persistence.Entity;
+-import javax.persistence.GeneratedValue;
+-import javax.persistence.Id;
+-import javax.validation.constraints.Email;
+-import javax.validation.constraints.NotBlank;
++import jakarta.persistence.Entity;
++import jakarta.persistence.GeneratedValue;
++import jakarta.persistence.Id;
++import jakarta.validation.constraints.Email;
++import jakarta.validation.constraints.NotBlank;
+
+ @Entity
+ public class Customer {
+diff -ru -x target legacy-app/src/main/java/com/ankurm/legacy/CustomerController.java step1/src/main/java/com/ankurm/legacy/CustomerController.java
+--- legacy-app/src/main/java/com/ankurm/legacy/CustomerController.java
++++ step1/src/main/java/com/ankurm/legacy/CustomerController.java
+@@ -1,8 +1,8 @@
+ package com.ankurm.legacy;
+
+ import java.util.List;
+-import javax.annotation.PostConstruct;
+-import javax.validation.Valid;
++import jakarta.annotation.PostConstruct;
++import jakarta.validation.Valid;
+ import org.springframework.http.HttpStatus;
+ import org.springframework.web.bind.annotation.*;
+
+diff -ru -x target legacy-app/src/main/java/com/ankurm/legacy/CustomerRepository.java step1/src/main/java/com/ankurm/legacy/CustomerRepository.java
+--- legacy-app/src/main/java/com/ankurm/legacy/CustomerRepository.java
++++ step1/src/main/java/com/ankurm/legacy/CustomerRepository.java
+@@ -1,10 +1,10 @@
+ package com.ankurm.legacy;
+
+-import javax.persistence.LockModeType;
++import jakarta.persistence.LockModeType;
+ import org.springframework.data.jpa.repository.JpaRepository;
+ import org.springframework.data.jpa.repository.Lock;
+ import org.springframework.data.jpa.repository.QueryHints;
+-import javax.persistence.QueryHint;
++import jakarta.persistence.QueryHint;
+
+ public interface CustomerRepository extends JpaRepository {
+
+diff -ru -x target legacy-app/src/main/java/com/ankurm/legacy/RequestLogFilter.java step1/src/main/java/com/ankurm/legacy/RequestLogFilter.java
+--- legacy-app/src/main/java/com/ankurm/legacy/RequestLogFilter.java
++++ step1/src/main/java/com/ankurm/legacy/RequestLogFilter.java
+@@ -1,10 +1,10 @@
+ package com.ankurm.legacy;
+
+ import java.io.IOException;
+-import javax.servlet.FilterChain;
+-import javax.servlet.ServletException;
+-import javax.servlet.http.HttpServletRequest;
+-import javax.servlet.http.HttpServletResponse;
++import jakarta.servlet.FilterChain;
++import jakarta.servlet.ServletException;
++import jakarta.servlet.http.HttpServletRequest;
++import jakarta.servlet.http.HttpServletResponse;
+ import org.springframework.stereotype.Component;
+ import org.springframework.web.filter.OncePerRequestFilter;
+
+diff -ru -x target legacy-app/src/main/java/com/ankurm/legacy/SecurityConfig.java step1/src/main/java/com/ankurm/legacy/SecurityConfig.java
+--- legacy-app/src/main/java/com/ankurm/legacy/SecurityConfig.java
++++ step1/src/main/java/com/ankurm/legacy/SecurityConfig.java
+@@ -1,17 +1,19 @@
+ package com.ankurm.legacy;
+
++import org.springframework.context.annotation.Bean;
+ import org.springframework.context.annotation.Configuration;
+ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+-import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
++import org.springframework.security.web.SecurityFilterChain;
+
+ @Configuration
+ @EnableWebSecurity
+-public class SecurityConfig extends WebSecurityConfigurerAdapter {
+- @Override
+- protected void configure(HttpSecurity http) throws Exception {
+- http.csrf().disable()
+- .authorizeRequests().antMatchers("/actuator/health").permitAll()
+- .anyRequest().permitAll();
++public class SecurityConfig {
++ @Bean
++ SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
++ http.csrf(csrf -> csrf.disable())
++ .authorizeHttpRequests(requests -> requests.requestMatchers("/actuator/health").permitAll()
++ .anyRequest().permitAll());
++ return http.build();
+ }
+ }
+Only in step1/src/main/resources/META-INF: spring
+diff -ru -x target legacy-app/src/main/resources/application.properties step1/src/main/resources/application.properties
+--- legacy-app/src/main/resources/application.properties
++++ step1/src/main/resources/application.properties
+@@ -3,4 +3,4 @@
+ management.metrics.export.prometheus.enabled=true
+ spring.jpa.hibernate.ddl-auto=create-drop
+ server.max-http-header-size=16KB
+-spring.jpa.properties.javax.persistence.validation.mode=none
++spring.jpa.properties.jakarta.persistence.validation.mode=none
+diff -ru -x target legacy-app/src/test/java/com/ankurm/legacy/CustomerControllerTest.java step1/src/test/java/com/ankurm/legacy/CustomerControllerTest.java
+--- legacy-app/src/test/java/com/ankurm/legacy/CustomerControllerTest.java
++++ step1/src/test/java/com/ankurm/legacy/CustomerControllerTest.java
+@@ -4,15 +4,12 @@
+ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+-import org.junit.Test;
+-import org.junit.runner.RunWith;
++import org.junit.jupiter.api.Test;
+ import org.springframework.beans.factory.annotation.Autowired;
+ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+ import org.springframework.boot.test.context.SpringBootTest;
+-import org.springframework.test.context.junit4.SpringRunner;
+ import org.springframework.test.web.servlet.MockMvc;
+
+-@RunWith(SpringRunner.class)
+ @SpringBootTest
+ @AutoConfigureMockMvc
+ public class CustomerControllerTest {
+Only in step1/src/main/resources/META-INF: spring
diff --git a/openrewrite/output/03-step1-build.txt b/openrewrite/output/03-step1-build.txt
new file mode 100644
index 0000000..8cf7e6b
--- /dev/null
+++ b/openrewrite/output/03-step1-build.txt
@@ -0,0 +1,3 @@
+# 03-step1-build: after step 1, JDK 17
+[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
+[INFO] BUILD SUCCESS
diff --git a/openrewrite/output/04-step2-diff.txt b/openrewrite/output/04-step2-diff.txt
new file mode 100644
index 0000000..3b245c7
--- /dev/null
+++ b/openrewrite/output/04-step2-diff.txt
@@ -0,0 +1,65 @@
+# 04-step2-diff: recipe org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0 applied on top of step 1 (32 s)
+diff -ru -x target step1/pom.xml step2/pom.xml
+--- step1/pom.xml
++++ step2/pom.xml
+@@ -4,7 +4,7 @@
+
+ org.springframework.boot
+ spring-boot-starter-parent
+- 3.5.16
++ 4.0.8
+
+
+ com.ankurm
+@@ -18,14 +18,23 @@
+ jakarta.servlet
+ jakarta.servlet-api
+
+- org.springframework.bootspring-boot-starter-web
++ org.springframework.bootspring-boot-starter-webmvc
+ org.springframework.bootspring-boot-starter-data-jpa
++
++ org.springframework.boot
++ spring-boot-starter-restclient
++
+ org.springframework.bootspring-boot-starter-validation
+ org.springframework.bootspring-boot-starter-security
+ org.springframework.bootspring-boot-starter-actuator
+ com.h2databaseh2runtime
+ org.springframework.bootspring-boot-starter-testtest
+- org.springframework.securityspring-security-testtest
++
++ org.springframework.boot
++ spring-boot-starter-webmvc-test
++ test
++
++ org.springframework.bootspring-boot-starter-security-testtest
+
+
+
+diff -ru -x target step1/src/main/resources/application.properties step2/src/main/resources/application.properties
+--- step1/src/main/resources/application.properties
++++ step2/src/main/resources/application.properties
+@@ -1,6 +1,6 @@
+-spring.redis.host=localhost
+-spring.redis.port=6379
+-management.metrics.export.prometheus.enabled=true
++spring.data.redis.host=localhost
++spring.data.redis.port=6379
++management.prometheus.metrics.export.enabled=true
+ spring.jpa.hibernate.ddl-auto=create-drop
+-server.max-http-header-size=16KB
++server.max-http-request-header-size=16KB
+ spring.jpa.properties.jakarta.persistence.validation.mode=none
+diff -ru -x target step1/src/test/java/com/ankurm/legacy/CustomerControllerTest.java step2/src/test/java/com/ankurm/legacy/CustomerControllerTest.java
+--- step1/src/test/java/com/ankurm/legacy/CustomerControllerTest.java
++++ step2/src/test/java/com/ankurm/legacy/CustomerControllerTest.java
+@@ -6,7 +6,7 @@
+
+ import org.junit.jupiter.api.Test;
+ import org.springframework.beans.factory.annotation.Autowired;
+-import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
++import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+ import org.springframework.boot.test.context.SpringBootTest;
+ import org.springframework.test.web.servlet.MockMvc;
+
diff --git a/openrewrite/output/05-step2-build.txt b/openrewrite/output/05-step2-build.txt
new file mode 100644
index 0000000..926d958
--- /dev/null
+++ b/openrewrite/output/05-step2-build.txt
@@ -0,0 +1,3 @@
+# 05-step2-build: after step 2, JDK 17
+[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
+[INFO] BUILD SUCCESS
diff --git a/openrewrite/output/06-what-it-left-behind.txt b/openrewrite/output/06-what-it-left-behind.txt
new file mode 100644
index 0000000..39e1368
--- /dev/null
+++ b/openrewrite/output/06-what-it-left-behind.txt
@@ -0,0 +1,13 @@
+# 06-what-it-left-behind (in /tmp/or-work/step2, after both recipes)
+--- leftover 'javax.' string literals or keys in source and resources:
+src/main/java/com/ankurm/legacy/CustomerRepository.java:12: @QueryHints(@QueryHint(name = "javax.persistence.lock.timeout", value = "3000"))
+--- META-INF files:
+./spring.factories
+./spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+--- explicit jakarta.servlet-api dependency added to the pom:
+1
+--- RestTemplate usages (recipe does not touch them):
+src/main/java/com/ankurm/legacy/RemoteClient.java:4:import org.springframework.web.client.RestTemplate;
+src/main/java/com/ankurm/legacy/RemoteClient.java:8: private final RestTemplate rest = new RestTemplate();
+--- Spring Boot version the recipe stopped at:
+ 4.0.8
diff --git a/openrewrite/output/07-step3-boot-4.1.1.txt b/openrewrite/output/07-step3-boot-4.1.1.txt
new file mode 100644
index 0000000..f964c31
--- /dev/null
+++ b/openrewrite/output/07-step3-boot-4.1.1.txt
@@ -0,0 +1,4 @@
+# 07-step3-boot-4.1.1: parent bumped by hand from the recipe's 4.0.x to 4.1.1, JDK 17
+ 4.1.1
+[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
+[INFO] BUILD SUCCESS
diff --git a/openrewrite/output/08-ci-gate.txt b/openrewrite/output/08-ci-gate.txt
new file mode 100644
index 0000000..b154ade
--- /dev/null
+++ b/openrewrite/output/08-ci-gate.txt
@@ -0,0 +1,4 @@
+# 08-ci-gate (mvn rewrite:dryRun, exit code decides the CI job)
+legacy app, -Drewrite.failOnDryRunResults=true (the name you would guess): exit code 0
+legacy app, -DfailOnDryRunResults=true (the real user property): exit code 1
+migrated app, -DfailOnDryRunResults=true: exit code 0
diff --git a/openrewrite/scripts/rewrite.sh b/openrewrite/scripts/rewrite.sh
new file mode 100755
index 0000000..5df63b6
--- /dev/null
+++ b/openrewrite/scripts/rewrite.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# usage: rewrite.sh (copies from-dir to to-dir and runs the recipe there)
+set -uo pipefail
+RW=6.46.1; SPRING=6.37.1; MIGRATE=3.42.1
+rm -rf "$2"; mkdir -p "$(dirname "$2")"; cp -r "$1" "$2"; rm -rf "$2/target"
+cd "$2"
+mvn -B -q org.openrewrite.maven:rewrite-maven-plugin:$RW:run \
+ -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:$SPRING,org.openrewrite.recipe:rewrite-migrate-java:$MIGRATE \
+ -Drewrite.activeRecipes="$3" -Drewrite.exportDatatables=false 2>&1
diff --git a/openrewrite/scripts/run-all.sh b/openrewrite/scripts/run-all.sh
new file mode 100755
index 0000000..814cd11
--- /dev/null
+++ b/openrewrite/scripts/run-all.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+# Regenerates output/. Needs JDK 17 on JAVA_HOME (the legacy app targets 17), Maven 3.9, network access to Maven Central.
+# Work happens in $WORK (default /tmp/or-work) OUTSIDE this git checkout, because OpenRewrite honours .gitignore
+# and ignores files git ignores (see output/00-gotcha-gitignore.txt).
+set -uo pipefail
+cd "$(dirname "$0")/.."
+WORK=${WORK:-/tmp/or-work}; OUT=$PWD/output; mkdir -p "$OUT"
+R1=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_5
+R2=org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0
+tests() { (cd "$1" && mvn -B test 2>&1 | grep -E "Tests run:.*Fail|BUILD|ERROR" | grep -v "Time elapsed" | head -${2:-8}); }
+difff() { diff -ru -x target "$1" "$2" | sed -E 's/\t20[0-9-]+ [0-9:.]+ \+0000$//; s#'"$WORK"'/##g'; }
+
+{ echo "# 01-baseline: legacy app, Spring Boot 2.7.18, JDK 17"; tests legacy-app; } > "$OUT/01-baseline.txt"
+
+s=$(date +%s); ./scripts/rewrite.sh legacy-app "$WORK/step1" $R1 >/dev/null 2>&1; e=$(date +%s)
+{ echo "# 02-step1-diff: recipe $R1 on the legacy app ($((e-s)) s)"; difff legacy-app "$WORK/step1"; diff -rq legacy-app "$WORK/step1" -x target | grep '^Only in' | sed "s#$WORK/##"; } > "$OUT/02-step1-diff.txt"
+{ echo "# 03-step1-build: after step 1, JDK 17"; tests "$WORK/step1"; } > "$OUT/03-step1-build.txt"
+
+s=$(date +%s); ./scripts/rewrite.sh "$WORK/step1" "$WORK/step2" $R2 >/dev/null 2>&1; e=$(date +%s)
+{ echo "# 04-step2-diff: recipe $R2 applied on top of step 1 ($((e-s)) s)"; difff "$WORK/step1" "$WORK/step2"; } > "$OUT/04-step2-diff.txt"
+{ echo "# 05-step2-build: after step 2, JDK 17"; tests "$WORK/step2"; } > "$OUT/05-step2-build.txt"
+
+{
+ echo "# 06-what-it-left-behind (in $WORK/step2, after both recipes)"
+ echo "--- leftover 'javax.' string literals or keys in source and resources:"
+ grep -rn 'javax\.' "$WORK/step2/src" | sed "s#$WORK/step2/##" || true
+ echo "--- META-INF files:"; (cd "$WORK/step2/src/main/resources/META-INF" && find . -type f | sort)
+ echo "--- explicit jakarta.servlet-api dependency added to the pom:"; grep -c 'jakarta.servlet-api' "$WORK/step2/pom.xml"
+ echo "--- RestTemplate usages (recipe does not touch them):"; grep -rn 'RestTemplate' "$WORK/step2/src" | sed "s#$WORK/step2/##"
+ echo "--- Spring Boot version the recipe stopped at:"; grep -A1 'spring-boot-starter-parent' "$WORK/step2/pom.xml" | grep version
+} > "$OUT/06-what-it-left-behind.txt"
+
+rm -rf "$WORK/step3"; cp -r "$WORK/step2" "$WORK/step3"; rm -rf "$WORK/step3/target"
+sed -i 's#4.0.[0-9]*#4.1.1#' "$WORK/step3/pom.xml"
+{ echo "# 07-step3-boot-4.1.1: parent bumped by hand from the recipe's 4.0.x to 4.1.1, JDK 17"; grep -A1 'spring-boot-starter-parent' "$WORK/step3/pom.xml" | grep version; tests "$WORK/step3"; } > "$OUT/07-step3-boot-4.1.1.txt"
+
+# CI gate: dryRun with failOnDryRunResults. Fails on the legacy app, passes once migrated.
+gate() { (cd "$1" && rm -rf target && mvn -B -q org.openrewrite.maven:rewrite-maven-plugin:6.46.1:dryRun \
+ -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:6.37.1,org.openrewrite.recipe:rewrite-migrate-java:3.42.1 \
+ -Drewrite.activeRecipes="$2" -D"$3"=true >/dev/null 2>&1; echo "exit code $?"); }
+
+rm -rf "$WORK/g1" "$WORK/g2"; cp -r legacy-app "$WORK/g1"; cp -r "$WORK/step2" "$WORK/g2"
+{ echo "# 08-ci-gate (mvn rewrite:dryRun, exit code decides the CI job)"
+ echo "legacy app, -Drewrite.failOnDryRunResults=true (the name you would guess): $(gate "$WORK/g1" $R1 rewrite.failOnDryRunResults)"
+ echo "legacy app, -DfailOnDryRunResults=true (the real user property): $(gate "$WORK/g1" $R1 failOnDryRunResults)"
+ echo "migrated app, -DfailOnDryRunResults=true: $(gate "$WORK/g2" $R1 failOnDryRunResults)"; } > "$OUT/08-ci-gate.txt"
+
+{ echo "# 00-gotcha-gitignore: same recipe, same project, sources under a .gitignore'd directory"
+ rm -rf "$WORK/ign"; mkdir -p "$WORK/ign"; (cd "$WORK/ign" && git init -q && echo 'legacy/' > .gitignore && cp -r "$OLDPWD/legacy-app" legacy && cd legacy \
+ && mvn -B org.openrewrite.maven:rewrite-maven-plugin:6.46.1:dryRun -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:6.37.1,org.openrewrite.recipe:rewrite-migrate-java:3.42.1 -Drewrite.activeRecipes=$R1 2>&1 | grep -E "Applying recipes|Parsing|patch file" )
+} > "$OUT/00-gotcha-gitignore.txt"
+ls -1 "$OUT"