Skills for creating, testing, reviewing, migrating and guarding Spring Boot 4 projects with Java 25: project scaffolding with architecture patterns, Boot 4 test templates, code review, Boot 3 to 4 migration, and a team harness of build gates and agent hooks.
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
#!/usr/bin/env python3
"""
Spring Boot Migration Scanner
Scans a Spring Boot project for migration issues related to:
- Spring Boot 4.0
- Spring Modulith 2.0
- Testcontainers 2.x
- Java preview APIs that change between JDK releases
Usage:
python3 scan_migration_issues.py /path/to/project [--check]
--check exits with 1 when any CRITICAL issue remains, so a finished migration
can be confirmed with a single command.
"""
import argparse
import os
import re
import sys
from pathlib import Path
from typing import List, Dict, Set
from dataclasses import dataclass, field
# Newest Spring Boot minor line (checked 2026-10-21). Projects on an older 4.x
# line get a warning, because the skill's references describe this one.
CURRENT_BOOT = (4, 1)
# Build output, VCS data, and hidden directories. Hidden ones include
# project-level skill installs (.claude/skills, .codex/skills), whose
# templates and docs would otherwise be scanned as project code.
_SKIP_DIRS = {"target", "build", "node_modules", "out", "bin"}
def _is_project_file(path: Path, root: Path) -> bool:
parts = path.relative_to(root).parts[:-1]
return not any(part in _SKIP_DIRS or part.startswith('.') for part in parts)
_MAVEN_DEPENDENCY_RE = re.compile(r'<(?:[\w-]+:)?dependency\b[^>]*>(.*?)</(?:[\w-]+:)?dependency>', re.S)
def _maven_tag(block: str, tag: str):
return re.search(rf'<(?:[\w-]+:)?{tag}>\s*([^<\s]+)\s*</(?:[\w-]+:)?{tag}>', block)
def _maven_dependencies(content: str):
"""Yield (group_id, artifact_id, line) per <dependency> element.
Reads groupId and artifactId from the same element, so values from a
neighbouring dependency or from <exclusions> are never combined.
`content` must already have XML comments blanked out.
"""
for dep in _MAVEN_DEPENDENCY_RE.finditer(content):
body = dep.group(1)
own = re.split(r'<(?:[\w-]+:)?exclusions\b', body, maxsplit=1)[0]
group, artifact = _maven_tag(own, 'groupId'), _maven_tag(own, 'artifactId')
if not group or not artifact:
continue
offset = dep.start(1) + artifact.start()
yield group.group(1), artifact.group(1), content.count('\n', 0, offset) + 1
def _yaml_values(content: str) -> Dict[str, str]:
"""Map full dotted paths to scalar values for simple block-style YAML.
Handles nesting by indentation, dotted keys (`spring.modulith: ...`),
comments, quotes, and several documents (`---`; later ones win, as with
Spring profiles). Flow mappings and multi-line scalars are ignored.
"""
values: Dict[str, str] = {}
stack: List[tuple] = [] # (indent, path parts)
for raw in content.splitlines():
if raw.strip() == '---':
stack = []
continue
line = re.sub(r'(^|\s)#.*$', '', raw).rstrip()
match = re.match(r'^(\s*)([^\s:#][^:]*?)\s*:(?:\s+(.*))?$', line)
if not match or match.group(1).count('\t') or line.lstrip().startswith('- '):
continue
indent = len(match.group(1))
while stack and stack[-1][0] >= indent:
stack.pop()
parts = (stack[-1][1] if stack else []) + match.group(2).strip('"\'').split('.')
value = (match.group(3) or '').strip()
if value and value not in ('|', '>'):
values['.'.join(parts)] = value.strip('"\'')
else:
stack.append((indent, parts))
return values
def _strip_sql_comments(sql: str) -> str:
"""Remove -- and /* */ comments, leaving quoted strings and identifiers intact."""
out, i, n = [], 0, len(sql)
while i < n:
ch = sql[i]
if ch in ("'", '"'):
end = i + 1
while end < n:
if sql[end] == ch:
if end + 1 < n and sql[end + 1] == ch: # doubled quote escape
end += 2
continue
break
end += 1
out.append(sql[i:end + 1])
i = end + 1
elif sql.startswith('--', i):
end = sql.find('\n', i)
i = n if end == -1 else end
elif sql.startswith('/*', i):
end = sql.find('*/', i + 2)
out.append(' ')
i = n if end == -1 else end + 2
else:
out.append(ch)
i += 1
return ''.join(out)
def _is_old_testcontainers_artifact(artifact_id: str) -> bool:
"""1.x module artifacts lack the testcontainers- prefix used in 2.x."""
return artifact_id not in ('testcontainers', 'testcontainers-bom') \
and not artifact_id.startswith('testcontainers-')
@dataclass
class Issue:
"""Represents a migration issue"""
category: str
severity: str # CRITICAL, WARNING, INFO
file_path: str
line_number: int
issue: str
suggestion: str
@dataclass
class ScanResult:
"""Results from migration scan"""
spring_boot_version: str = "Unknown"
spring_modulith_version: str = "Unknown"
testcontainers_version: str = "Unknown"
issues: List[Issue] = field(default_factory=list)
def add_issue(self, category: str, severity: str, file_path: str,
line_number: int, issue: str, suggestion: str):
self.issues.append(Issue(category, severity, file_path, line_number, issue, suggestion))
class MigrationScanner:
"""Scans project for migration issues"""
def __init__(self, project_path: str):
self.project_path = Path(project_path)
self.result = ScanResult()
self.modulith_schema_configured = False
self.modulith_schema_name = None
self.modulith_in_use = False
self.has_restclient_starter = False
self.has_webclient_starter = False
self.uses_restclient = False
self.uses_webclient = False
def scan(self) -> ScanResult:
"""Run full migration scan"""
print(f"Scanning project: {self.project_path}")
print("=" * 80)
# Scan build files — Maven and/or Gradle. Projects occasionally have both
# (e.g. mid-migration); scan whichever are present.
pom_path = self.project_path / "pom.xml"
gradle_groovy = self.project_path / "build.gradle"
gradle_kotlin = self.project_path / "build.gradle.kts"
# Multi-module Maven: the root pom usually only declares <modules> and
# the actual Spring Boot version sits in submodule poms. Discover and
# scan every pom.xml in the tree (root first so its outputs print
# first), skipping `target/` and `.git/` to avoid noise. The first
# match wins for version detection because `_scan_pom` only assigns
# when the field is still default.
if pom_path.exists():
poms = [pom_path]
for child in sorted(self.project_path.rglob("pom.xml")):
if child == pom_path or not _is_project_file(child, self.project_path):
continue
poms.append(child)
for pom in poms:
self._scan_pom(pom)
# Single summary after walking every pom — first detection wins.
print(f" Spring Boot: {self.result.spring_boot_version}")
print(f" Spring Modulith: {self.result.spring_modulith_version}")
print(f" Testcontainers: {self.result.testcontainers_version}")
if gradle_groovy.exists():
self._scan_gradle(gradle_groovy)
if gradle_kotlin.exists():
self._scan_gradle(gradle_kotlin)
if not (pom_path.exists() or gradle_groovy.exists() or gradle_kotlin.exists()):
print("⚠️ Warning: no pom.xml, build.gradle, or build.gradle.kts found")
# Scan Java files
self._scan_java_files()
# Scan properties files
self._scan_properties()
# Scan Flyway migrations
self._scan_flyway_migrations()
# Resolve build-file label for post-scan warnings
if pom_path.exists():
build_file_ref = "pom.xml"
elif gradle_groovy.exists():
build_file_ref = "build.gradle"
elif gradle_kotlin.exists():
build_file_ref = "build.gradle.kts"
else:
build_file_ref = "build file"
# Post-scan: check for missing modular HTTP client starters
# CRITICAL: without the starter there is no RestClient.Builder bean, and
# the context fails to start (seen on 4.1.1 in Scenario 15)
if self.uses_restclient and not self.has_restclient_starter:
self.result.add_issue(
"Spring Boot 4 - Dependencies",
"CRITICAL",
build_file_ref,
0,
"RestClient used but spring-boot-starter-restclient not found",
"Boot 4 modular starters require spring-boot-starter-restclient for RestClient auto-configuration"
)
if self.uses_webclient and not self.has_webclient_starter:
self.result.add_issue(
"Spring Boot 4 - Dependencies",
"CRITICAL",
build_file_ref,
0,
"WebClient used but spring-boot-starter-webclient not found",
"Boot 4 modular starters require spring-boot-starter-webclient for WebClient auto-configuration"
)
self._check_boot_line(build_file_ref)
return self.result
def _check_boot_line(self, build_file_ref: str):
match = re.match(r'(\d+)\.(\d+)', self.result.spring_boot_version)
if not match:
return
line = (int(match.group(1)), int(match.group(2)))
if line[0] == CURRENT_BOOT[0] and line < CURRENT_BOOT:
current = f"{CURRENT_BOOT[0]}.{CURRENT_BOOT[1]}"
self.result.add_issue(
"Spring Boot 4 - Version",
"WARNING",
build_file_ref,
0,
f"Spring Boot {self.result.spring_boot_version} is not on the current {current}.x line",
f"Target the latest {current}.x release; the references in this skill describe {current}"
)
def _scan_pom(self, pom_path: Path):
"""Scan pom.xml for dependency issues"""
try:
rel = pom_path.relative_to(self.project_path)
except ValueError:
rel = pom_path
print(f"\n📦 Scanning {rel}...")
with open(pom_path, 'r') as f:
content = f.read()
# Strip <!-- ... --> XML comments before parsing so commented-out
# dependencies aren't flagged as real issues. Replace each comment with
# the same number of newlines it spanned, so reported line numbers
# stay accurate.
content = re.sub(
r'<!--.*?-->',
lambda m: '\n' * m.group(0).count('\n'),
content,
flags=re.DOTALL,
)
# Version patterns intentionally accept pre-release/snapshot suffixes
# (e.g. 4.0.0-RC1, 2.0.0-SNAPSHOT, 2.0.0-M3) instead of truncating to
# the leading digits.
version_value = r'([\d.]+(?:[-.\w]*)?)'
# Spring Boot version detection — try three idiomatic styles in order:
# (1) <spring-boot.version>X</spring-boot.version> property style
# (2) <parent>...<artifactId>spring-boot-starter-parent</artifactId>
# <version>X</version></parent> (Spring Initializr default)
# (3) <dependencyManagement> BOM import of spring-boot-dependencies
spring_boot_match = re.search(r'<spring-boot\.version>' + version_value, content)
if not spring_boot_match:
# <parent> style: capture the version that sits inside the same
# <parent>...</parent> block as spring-boot-starter-parent. Block
# is non-greedy and anchored on both tags so the regex doesn't
# roam across unrelated <parent> declarations (e.g. a multi-module
# build with its own parent above the spring-boot one).
spring_boot_match = re.search(
r'<parent>[\s\S]*?<artifactId>\s*spring-boot-starter-parent\s*</artifactId>'
r'[\s\S]*?<version>\s*' + version_value + r'\s*</version>'
r'[\s\S]*?</parent>',
content,
)
if not spring_boot_match:
# <dependencyManagement> BOM-import style.
spring_boot_match = re.search(
r'<artifactId>\s*spring-boot-dependencies\s*</artifactId>'
r'[\s\S]{0,400}?<version>\s*' + version_value + r'\s*</version>',
content,
)
# In multi-module scans the root pom may not declare a version while
# submodule poms do. First match wins — don't overwrite a value found
# in an earlier (e.g. root or sibling) pom.
if spring_boot_match and self.result.spring_boot_version == "Unknown":
self.result.spring_boot_version = spring_boot_match.group(1)
spring_modulith_match = re.search(r'<spring-modulith\.version>' + version_value, content)
if not spring_modulith_match:
# spring-modulith-bom import style.
spring_modulith_match = re.search(
r'<artifactId>\s*spring-modulith-bom\s*</artifactId>'
r'[\s\S]{0,400}?<version>\s*' + version_value + r'\s*</version>',
content,
)
if spring_modulith_match:
if self.result.spring_modulith_version == "Unknown":
self.result.spring_modulith_version = spring_modulith_match.group(1)
self.modulith_in_use = True
testcontainers_match = re.search(r'<testcontainers\.version>' + version_value, content)
if not testcontainers_match:
# testcontainers-bom import style.
testcontainers_match = re.search(
r'<artifactId>\s*testcontainers-bom\s*</artifactId>'
r'[\s\S]{0,400}?<version>\s*' + version_value + r'\s*</version>',
content,
)
if testcontainers_match and self.result.testcontainers_version == "Unknown":
self.result.testcontainers_version = testcontainers_match.group(1)
if re.search(r'<artifactId>spring-modulith', content) or re.search(
r'<groupId>org\.springframework\.modulith</groupId>', content
):
self.modulith_in_use = True
# Check for old starters — verify the surrounding <dependency> block
# carries <groupId>org.springframework.boot</groupId> so a third-party
# artifact reusing the same name isn't misflagged.
old_starters = {
'spring-boot-starter-web': 'spring-boot-starter-webmvc',
'spring-boot-starter-aop': 'spring-boot-starter-aspectj',
}
spring_retry_reported = False
for group_id, artifact_id, i in _maven_dependencies(content):
if group_id == 'org.springframework.boot' and artifact_id in old_starters:
self.result.add_issue(
"Spring Boot 4 - Dependencies",
"CRITICAL",
str(pom_path),
i,
f"Old starter: {artifact_id}",
f"Change to: {old_starters[artifact_id]} (or use spring-boot-starter-classic for gradual migration)"
)
if group_id == 'org.springframework.security' and artifact_id == 'spring-security-test':
self.result.add_issue(
"Spring Boot 4 - Dependencies",
"CRITICAL",
str(pom_path),
i,
"Old spring-security-test dependency",
"Change to: spring-boot-starter-security-test"
)
# In 2.x every Testcontainers module artifact has the testcontainers-
# prefix, and Boot 4 only manages the new names
if group_id == 'org.testcontainers' and _is_old_testcontainers_artifact(artifact_id):
self.result.add_issue(
"Testcontainers 2.x - Dependencies",
"CRITICAL",
str(pom_path),
i,
f"Old Testcontainers artifact: {artifact_id}",
f"Change to: testcontainers-{artifact_id} (Boot 4 manages only the 2.x names)"
)
if (not spring_retry_reported and group_id == 'org.springframework.retry'
and artifact_id == 'spring-retry'):
spring_retry_reported = True
self.result.add_issue(
"Spring Boot 4 - Legacy Spring Retry",
"WARNING",
str(pom_path),
i,
"Legacy spring-retry dependency found",
"Remove spring-retry dependency — Spring Framework 7 provides native @Retryable via org.springframework.resilience.annotation.*"
)
# Track modular HTTP client starters
self.has_restclient_starter = 'spring-boot-starter-restclient' in content
self.has_webclient_starter = 'spring-boot-starter-webclient' in content
def _scan_gradle(self, gradle_path: Path):
"""Scan build.gradle or build.gradle.kts for dependency issues.
Handles both the Groovy DSL (single quotes, ``id 'foo' version 'x'``)
and the Kotlin DSL (double quotes, ``id("foo") version "x"``).
"""
flavor = gradle_path.name
print(f"\n📦 Scanning {flavor}...")
try:
with open(gradle_path, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
print(f" Error reading {gradle_path}: {e}")
return
# Strip /* ... */ block comments before parsing so deps inside disabled
# blocks don't surface as real issues. Replace each comment with the same
# number of newlines it spanned, so reported line numbers stay accurate.
content = re.sub(
r'/\*.*?\*/',
lambda m: '\n' * m.group(0).count('\n'),
content,
flags=re.DOTALL,
)
lines = content.split('\n')
# Build a code-only view of the content with `//` line comments stripped
# (same rule the per-line loop uses below: `//` at start-of-line or
# preceded by whitespace, leaving `://` inside URL string literals
# alone). Commented portions are blanked rather than removed so line
# numbers in `lines` still match. All content-level substring/regex
# checks below run against `code_content` so a commented-out dep like
# // implementation("org.springframework.boot:spring-boot-starter-restclient")
# cannot satisfy a presence flag.
def _strip_line_comment(line: str) -> str:
comment_match = re.search(r'(^//|\s//)', line)
return line[:comment_match.end(0) - 2] if comment_match else line
code_content = '\n'.join(_strip_line_comment(line) for line in lines)
# Spring Boot plugin version — works for both DSLs:
# id 'org.springframework.boot' version '4.0.0'
# id("org.springframework.boot") version "4.0.0"
sb_plugin = re.search(
r"""id\s*[\(\s]+\s*['"]org\.springframework\.boot['"]\s*\)?\s*version\s*['"]([\d.]+(?:[-.\w]*)?)['"]""",
code_content,
)
if sb_plugin:
self.result.spring_boot_version = sb_plugin.group(1)
# Common property style for Modulith / Testcontainers versions:
# ext { set('springModulithVersion', '2.0.0') } (Groovy)
# springModulithVersion = '2.0.0' (Groovy ext)
# extra["springModulithVersion"] = "2.0.0" (Kotlin)
for prop_pattern, target_attr, marks_modulith in [
(r"""springModulithVersion['"\]]*\s*[=,]\s*['"]([\d.]+(?:[-.\w]*)?)['"]""",
"spring_modulith_version", True),
(r"""testcontainersVersion['"\]]*\s*[=,]\s*['"]([\d.]+(?:[-.\w]*)?)['"]""",
"testcontainers_version", False),
]:
m = re.search(prop_pattern, code_content)
if m:
setattr(self.result, target_attr, m.group(1))
if marks_modulith:
self.modulith_in_use = True
# Spring Modulith presence by groupId/artifactId
if 'org.springframework.modulith' in code_content or 'spring-modulith' in code_content:
self.modulith_in_use = True
print(f" Spring Boot: {self.result.spring_boot_version}")
print(f" Spring Modulith: {self.result.spring_modulith_version}")
print(f" Testcontainers: {self.result.testcontainers_version}")
# Dependency notation: 'group:artifact[:version]' or "group:artifact[:version]"
# Both DSLs use the same string form for the GAV coordinate.
dep_re = re.compile(
r"""['"]([A-Za-z0-9_.\-]+):([A-Za-z0-9_.\-]+)(?::([\w.+\-]+))?['"]"""
)
# Map-style notation, same line:
# Groovy: implementation group: 'g', name: 'a' [, version: 'v']
# Kotlin: implementation(group = "g", name = "a" [, version = "v"])
# Keys may appear in any order on the line. We only need group + name
# to identify the artifact; version is ignored. Multi-line map-style
# is uncommon and intentionally not handled — a separate pass over
# logical statements would be required.
map_kv_re = re.compile(
r"""\b(group|name)\s*[:=]\s*['"]([A-Za-z0-9_.\-]+)['"]"""
)
def _gradle_gav_pairs(code_line):
"""Yield (group_id, artifact_id) for every dependency on this line.
Covers both the string-form GAV coordinate and the map-style
notation. For map-style, only the first occurrence of each key on
the line is taken — pairing multiple group/name kv-pairs on the
same line is ambiguous and not idiomatic Gradle.
"""
for m in dep_re.finditer(code_line):
yield m.group(1), m.group(2)
kv = {}
for m in map_kv_re.finditer(code_line):
kv.setdefault(m.group(1), m.group(2))
if 'group' in kv and 'name' in kv:
yield kv['group'], kv['name']
old_starters = {
'spring-boot-starter-web': 'spring-boot-starter-webmvc',
'spring-boot-starter-aop': 'spring-boot-starter-aspectj',
}
spring_retry_reported = False
for i, line in enumerate(lines, 1):
# Drop trailing line comments so `impl 'group:art' // note` still
# parses the dep, while fully-commented lines fall through below.
code = _strip_line_comment(line)
stripped = code.strip()
if not stripped or stripped.startswith('#'):
continue
for group_id, artifact_id in _gradle_gav_pairs(code):
if group_id == 'org.springframework.boot' and artifact_id in old_starters:
self.result.add_issue(
"Spring Boot 4 - Dependencies",
"CRITICAL",
str(gradle_path),
i,
f"Old starter: {artifact_id}",
f"Change to: {old_starters[artifact_id]} (or use spring-boot-starter-classic for gradual migration)"
)
if group_id == 'org.springframework.security' and artifact_id == 'spring-security-test':
self.result.add_issue(
"Spring Boot 4 - Dependencies",
"CRITICAL",
str(gradle_path),
i,
"Old spring-security-test dependency",
"Change to: spring-boot-starter-security-test"
)
if group_id == 'org.testcontainers' and _is_old_testcontainers_artifact(artifact_id):
self.result.add_issue(
"Testcontainers 2.x - Dependencies",
"CRITICAL",
str(gradle_path),
i,
f"Old Testcontainers artifact: {artifact_id}",
f"Change to: testcontainers-{artifact_id} (Boot 4 manages only the 2.x names)"
)
# Legacy spring-retry — coordinate-based, not substring, so
# incidental mentions in strings/comments don't fire.
if (
not spring_retry_reported
and group_id == 'org.springframework.retry'
and artifact_id == 'spring-retry'
):
self.result.add_issue(
"Spring Boot 4 - Legacy Spring Retry",
"WARNING",
str(gradle_path),
i,
"Legacy spring-retry dependency found",
"Remove spring-retry dependency — Spring Framework 7 provides native @Retryable via org.springframework.resilience.annotation.*"
)
spring_retry_reported = True
# Track modular HTTP client starters (use code_content so a commented
# `// implementation("...spring-boot-starter-restclient")` cannot flip
# the presence flag and suppress the missing-starter warning).
if 'spring-boot-starter-restclient' in code_content:
self.has_restclient_starter = True
if 'spring-boot-starter-webclient' in code_content:
self.has_webclient_starter = True
def _scan_java_files(self):
"""Scan Java files for code issues"""
print("\n☕ Scanning Java files...")
java_files = [f for f in self.project_path.rglob("*.java")
if _is_project_file(f, self.project_path)]
print(f" Found {len(java_files)} Java files")
for java_file in java_files:
self._scan_java_file(java_file)
def _scan_java_file(self, file_path: Path):
"""Scan individual Java file"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
except Exception as e:
print(f" Error reading {file_path}: {e}")
return
content = ''.join(lines)
rel_path = file_path.relative_to(self.project_path)
imports = [
line.strip().replace("import ", "").replace(";", "")
for line in lines
if line.strip().startswith("import ")
]
if any(i.startswith("org.springframework.modulith") for i in imports):
self.modulith_in_use = True
# Track RestClient/WebClient usage for modular starter check
if 'RestClient' in content and not self.uses_restclient:
for line in lines:
stripped = line.strip()
if ('RestClient' in stripped and not stripped.startswith('//')
and 'RestTestClient' not in stripped
and 'import' not in stripped.lower()):
self.uses_restclient = True
break
if 'WebClient' in content and not self.uses_webclient:
for line in lines:
stripped = line.strip()
if ('WebClient' in stripped and not stripped.startswith('//')
and 'import' not in stripped.lower()):
self.uses_webclient = True
break
# Check for old test annotations
test_annotation_patterns = {
'@MockBean': '@MockitoBean',
'@SpyBean': '@MockitoSpyBean',
}
for i, line in enumerate(lines, 1):
for old, new in test_annotation_patterns.items():
if old in line and not line.strip().startswith('//'):
self.result.add_issue(
"Spring Boot 4 - Test Annotations",
"CRITICAL",
str(rel_path),
i,
f"Old test annotation: {old}",
f"Change to: {new}"
)
# Check for old imports
old_imports = {
'org.springframework.boot.test.mock.mockito.MockBean':
'org.springframework.test.context.bean.override.mockito.MockitoBean',
'org.springframework.boot.test.mock.mockito.SpyBean':
'org.springframework.test.context.bean.override.mockito.MockitoSpyBean',
'org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest':
'org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest',
'org.springframework.boot.autoconfigure.domain.EntityScan':
'org.springframework.boot.persistence.autoconfigure.EntityScan',
'org.springframework.boot.BootstrapRegistry':
'org.springframework.boot.bootstrap.BootstrapRegistry',
'org.springframework.boot.BootstrapContext':
'org.springframework.boot.bootstrap.BootstrapContext',
}
for i, line in enumerate(lines, 1):
if line.strip().startswith('//'):
continue
for old_import, new_import in old_imports.items():
if f'import {old_import}' in line:
self.result.add_issue(
"Spring Boot 4 - Package Relocations",
"CRITICAL",
str(rel_path),
i,
f"Old import: {old_import}",
f"Change to: {new_import}"
)
# Check for Testcontainers old imports
tc_old_imports = {
'org.testcontainers.containers.PostgreSQLContainer':
'org.testcontainers.postgresql.PostgreSQLContainer',
'org.testcontainers.containers.MySQLContainer':
'org.testcontainers.mysql.MySQLContainer',
'org.testcontainers.containers.MongoDBContainer':
'org.testcontainers.mongodb.MongoDBContainer',
'org.testcontainers.containers.MariaDBContainer':
'org.testcontainers.mariadb.MariaDBContainer',
'org.testcontainers.containers.RabbitMQContainer':
'org.testcontainers.rabbitmq.RabbitMQContainer',
'org.testcontainers.containers.localstack.LocalStackContainer':
'org.testcontainers.localstack.LocalStackContainer',
'org.testcontainers.containers.KafkaContainer':
'org.testcontainers.kafka.ConfluentKafkaContainer (same confluentinc/cp-kafka image) '
'or org.testcontainers.kafka.KafkaContainer (apache/kafka image only); '
'required for @ServiceConnection in Boot 4',
}
for i, line in enumerate(lines, 1):
if line.strip().startswith('//'):
continue
for old_import, new_import in tc_old_imports.items():
if f'import {old_import}' in line:
self.result.add_issue(
"Testcontainers 2.x - Package Changes",
"WARNING",
str(rel_path),
i,
f"Deprecated Testcontainers class: {old_import}",
f"Change to: {new_import}"
)
# Check for LocalStack Service enum usage
if 'LocalStackContainer.Service' in content:
for i, line in enumerate(lines, 1):
if 'LocalStackContainer.Service' in line and not line.strip().startswith('//'):
self.result.add_issue(
"Testcontainers 2.x - API Changes",
"CRITICAL",
str(rel_path),
i,
"LocalStackContainer.Service exists only on the deprecated org.testcontainers.containers.localstack class",
"With org.testcontainers.localstack.LocalStackContainer use strings: .withServices(\"s3\") and getEndpoint()"
)
# JUnit 4 rules: GenericContainer no longer implements TestRule in 2.x
if any(i in ('org.junit.Rule', 'org.junit.ClassRule') for i in imports) and 'Container' in content:
for i, line in enumerate(lines, 1):
if re.search(r'@(Class)?Rule\b', line) and not line.strip().startswith('//'):
self.result.add_issue(
"Testcontainers 2.x - JUnit 4",
"CRITICAL",
str(rel_path),
i,
"JUnit 4 @Rule/@ClassRule container: containers are no longer TestRules, and junit:junit is no longer transitive in 2.x",
"Move to JUnit 5 (@Testcontainers/@Container) or Spring Boot @ServiceConnection beans"
)
break
# LocalStack images from 2026.03 need an auth token
if 'localstack/localstack' in content:
for i, line in enumerate(lines, 1):
if 'localstack/localstack' in line and not line.strip().startswith('//'):
self.result.add_issue(
"Testcontainers 2.x - LocalStack Image",
"INFO",
str(rel_path),
i,
"LocalStack images from 2026.03 require LOCALSTACK_AUTH_TOKEN",
"Pin a tag; with 2026.03+ (and latest) set LOCALSTACK_AUTH_TOKEN in CI"
)
break
# Note: org.springframework.resilience.* is used in the external sample repo.
# Keep this as informational instead of treating it as invalid.
if 'org.springframework.resilience' in content:
for i, line in enumerate(lines, 1):
if 'org.springframework.resilience' in line and not line.strip().startswith('//'):
self.result.add_issue(
"Spring Boot 4 - Retry/Resilience",
"INFO",
str(rel_path),
i,
"Using org.springframework.resilience annotations",
"Native retry detected; ensure @EnableResilientMethods on a @Configuration class"
)
# Check for @Retryable usage and align suggestion with imports
if '@Retryable' in content:
uses_spring_retry = any(i.startswith("org.springframework.retry.annotation") for i in imports)
uses_resilience = any(i.startswith("org.springframework.resilience.annotation") for i in imports)
for i, line in enumerate(lines, 1):
if '@Retryable' in line and not line.strip().startswith('//'):
if uses_resilience:
suggestion = "Ensure @EnableResilientMethods on a @Configuration class"
category = "Spring Boot 4 - Retry/Resilience"
elif uses_spring_retry:
suggestion = "Migrate to native org.springframework.resilience.annotation.* — Spring Retry is maintenance-only"
category = "Spring Boot 4 - Legacy Spring Retry"
else:
suggestion = "Add imports from org.springframework.resilience.annotation.* + @EnableResilientMethods"
category = "Spring Boot 4 - Retry/Resilience"
self.result.add_issue(
category,
"INFO",
str(rel_path),
i,
"Using @Retryable",
suggestion
)
# Check for TestRestTemplate usage
if 'TestRestTemplate' in content:
for i, line in enumerate(lines, 1):
if 'TestRestTemplate' in line and not line.strip().startswith('//'):
self.result.add_issue(
"Spring Boot 4 - Testing",
"WARNING",
str(rel_path),
i,
"TestRestTemplate is no longer auto-provided by @SpringBootTest in Spring Boot 4 (class itself still supported, not deprecated)",
"Opt in via @AutoConfigureTestRestTemplate + spring-boot-resttestclient dep, or migrate to RestTestClient (org.springframework.test.web.servlet.client.RestTestClient)"
)
# Check for manual HttpServiceProxyFactory setup
if 'HttpServiceProxyFactory' in content:
for i, line in enumerate(lines, 1):
if 'HttpServiceProxyFactory' in line and not line.strip().startswith('//'):
self.result.add_issue(
"Spring Boot 4 - HTTP Service Client",
"WARNING",
str(rel_path),
i,
"Manual HttpServiceProxyFactory setup is unnecessary in Boot 4",
"Replace with @ImportHttpServices auto-configuration (org.springframework.web.service.registry.ImportHttpServices)"
)
# Check for Jackson 2 classes
jackson2_classes = {
'Jackson2ObjectMapperBuilderCustomizer': 'JsonMapperBuilderCustomizer',
'@JsonComponent': '@JacksonComponent',
}
for i, line in enumerate(lines, 1):
for old_class, new_class in jackson2_classes.items():
if old_class in line and not line.strip().startswith('//'):
self.result.add_issue(
"Spring Boot 4 - Jackson 3",
"CRITICAL",
str(rel_path),
i,
f"Old Jackson 2 class: {old_class}",
f"Change to Jackson 3: {new_class}"
)
# jackson-annotations keeps the com.fasterxml.jackson.annotation package in Jackson 3, so only
# databind and core imports are Jackson 2.
for i, line in enumerate(lines, 1):
match = re.match(r'\s*import\s+(?:static\s+)?(com\.fasterxml\.jackson\.(?:databind|core))\.', line)
if match:
self.result.add_issue(
"Spring Boot 4 - Jackson 3",
"CRITICAL",
str(rel_path),
i,
f"Jackson 2 import: {match.group(1)}",
"Change to tools.jackson.databind / tools.jackson.core (ObjectMapper becomes JsonMapper)"
)
# Check for generic Testcontainers types
if 'PostgreSQLContainer<?>' in content or 'MySQLContainer<?>' in content:
for i, line in enumerate(lines, 1):
if ('PostgreSQLContainer<?>' in line or 'MySQLContainer<?>' in line) \
and not line.strip().startswith('//'):
self.result.add_issue(
"Testcontainers 2.x - Generic Types",
"WARNING",
str(rel_path),
i,
"Generic type in Testcontainers container",
"Remove generic type: PostgreSQLContainer<?> → PostgreSQLContainer"
)
# Check for getEndpointOverride with Service parameter
if 'getEndpointOverride(' in content:
for i, line in enumerate(lines, 1):
if 'getEndpointOverride(' in line and 'Service' in line \
and not line.strip().startswith('//'):
self.result.add_issue(
"Testcontainers 2.x - LocalStack API",
"CRITICAL",
str(rel_path),
i,
"getEndpointOverride(Service) deprecated",
"Change to: getEndpoint()"
)
# Check for preview APIs whose shape changes between JDK releases
preview_api_patterns = {
'StableValue': (
"StableValue preview API (JDK 25 only, removed in JDK 26)",
"On JDK 26+ use LazyConstant.of(supplier) and get(); "
"see java-version-policy.md"
),
'StructuredTaskScope': (
"StructuredTaskScope preview API (shape changes each JDK release)",
"Match the code to the target JDK; on JDK 27 join() throws "
"ExecutionException; see java-version-policy.md"
),
}
for api, (issue, suggestion) in preview_api_patterns.items():
if api not in content:
continue
for i, line in enumerate(lines, 1):
if re.search(rf'\b{api}\b', line) and not line.strip().startswith('//'):
self.result.add_issue(
"Java - Preview APIs",
"WARNING",
str(rel_path),
i,
issue,
suggestion
)
break
def _scan_properties(self):
"""Scan application.properties for configuration issues"""
print("\n⚙️ Scanning application.properties...")
props_paths = [
self.project_path / "src/main/resources/application.properties",
self.project_path / "src/main/resources/application.yml",
self.project_path / "src/main/resources/application.yaml",
]
for props_path in props_paths:
if props_path.exists():
has_modulith_schema = self._scan_properties_file(props_path)
self.modulith_schema_configured = (
self.modulith_schema_configured or has_modulith_schema
)
def _scan_properties_file(self, file_path: Path) -> bool:
"""Scan properties file"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
except Exception as e:
print(f" Error reading {file_path}: {e}")
return False
content = ''.join(lines)
# Spring Modulith dedicated event schema: the exact key only, not
# spring.modulith.events.jdbc.schema-initialization.enabled or an
# unrelated *.jdbc.schema elsewhere in the file
key = 'spring.modulith.events.jdbc.schema'
if file_path.suffix in ('.yml', '.yaml'):
value = _yaml_values(content).get(key)
else:
match = re.search(r'^[ \t]*spring\.modulith\.events\.jdbc\.schema[ \t]*[=:][ \t]*(\S+)', content, re.M)
value = match.group(1) if match else None
if value:
self.modulith_schema_name = value.strip('"\'')
return True
return False
def _scan_flyway_migrations(self):
"""Scan Flyway migrations for Spring Modulith event schema"""
print("\n🗄️ Scanning Flyway migrations...")
migration_dir = self.project_path / "src/main/resources/db/migration"
if not migration_dir.exists():
print(" No Flyway migrations found")
return
# Check for events schema migration only if configured
if self.modulith_in_use and self.modulith_schema_configured:
schema = re.escape(self.modulith_schema_name or 'events')
create_schema = re.compile(rf'CREATE\s+SCHEMA\s+(IF\s+NOT\s+EXISTS\s+)?"?{schema}"?\b', re.I)
found = False
for sql in migration_dir.rglob("*.sql"):
try:
if create_schema.search(_strip_sql_comments(sql.read_text(encoding='utf-8'))):
found = True
break
except Exception:
continue
if not found:
self.result.add_issue(
"Spring Modulith 2.0 - Database",
"CRITICAL",
"src/main/resources/db/migration/",
0,
f"No migration creates the '{self.modulith_schema_name}' schema set in spring.modulith.events.jdbc.schema",
f"Add a migration with 'CREATE SCHEMA IF NOT EXISTS {self.modulith_schema_name};' "
"(schema names work only on PostgreSQL, H2, HSQLDB)"
)
def print_report(self):
"""Print scan report"""
print("\n")
print("=" * 80)
print("MIGRATION SCAN REPORT")
print("=" * 80)
# Group issues by category and severity
issues_by_category = {}
for issue in self.result.issues:
key = f"{issue.category} - {issue.severity}"
if key not in issues_by_category:
issues_by_category[key] = []
issues_by_category[key].append(issue)
if not self.result.issues:
print("\n✅ No migration issues found!")
return
# Print summary
critical_count = sum(1 for i in self.result.issues if i.severity == "CRITICAL")
warning_count = sum(1 for i in self.result.issues if i.severity == "WARNING")
info_count = sum(1 for i in self.result.issues if i.severity == "INFO")
print(f"\n📊 Summary:")
print(f" 🔴 Critical: {critical_count}")
print(f" 🟡 Warnings: {warning_count}")
print(f" ℹ️ Info: {info_count}")
print(f" Total: {len(self.result.issues)}")
# Print detailed issues
for category_severity in sorted(issues_by_category.keys()):
issues = issues_by_category[category_severity]
category, severity = category_severity.rsplit(' - ', 1)
severity_icon = {
"CRITICAL": "🔴",
"WARNING": "🟡",
"INFO": "ℹ️"
}[severity]
print(f"\n{severity_icon} {category} ({len(issues)} issues)")
print("-" * 80)
for issue in issues:
print(f"\n File: {issue.file_path}:{issue.line_number}")
print(f" Issue: {issue.issue}")
print(f" Fix: {issue.suggestion}")
print("\n" + "=" * 80)
print("\n📚 Next Steps:")
print(" 1. Read the relevant migration guides in references/")
print(" 2. Start with CRITICAL issues first")
print(" 3. Apply fixes in phases: Dependencies → Code → Configuration → Testing")
print(" 4. Test thoroughly after each phase")
print("\n" + "=" * 80)
def main():
# Windows consoles and pipes default to a code page that cannot encode the report's emoji,
# and `python -I` ignores PYTHONIOENCODING, so set the encoding here.
sys.stdout.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="Scan a Spring Boot project for Boot 4 migration issues")
parser.add_argument("project_path")
parser.add_argument("--check", action="store_true",
help="Exit with 1 when any CRITICAL issue remains")
args = parser.parse_args()
if not os.path.exists(args.project_path):
print(f"Error: Path does not exist: {args.project_path}")
sys.exit(1)
scanner = MigrationScanner(args.project_path)
result = scanner.scan()
scanner.print_report()
if args.check:
critical = sum(1 for issue in result.issues if issue.severity == "CRITICAL")
if critical:
print(f"\nCHECK FAILED: {critical} critical issue(s) remain")
sys.exit(1)
print("\nCHECK PASSED: no critical issues")
if __name__ == "__main__":
main()