springdocker 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,332 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class DockerfileOptions:
9
+ build_tool: str
10
+ java_version: int = 25
11
+ use_buildkit_cache: bool = True
12
+ use_jlink: bool = True
13
+ non_root: bool = True
14
+ tuned_jvm_flags: bool = True
15
+ must_have_modules: tuple[str, ...] = ()
16
+ runtime_image: str = "temurin"
17
+ platform_aware: bool = True
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class DockerfileSection:
22
+ lines: tuple[str, ...]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class DockerfileDocument:
27
+ sections: tuple[DockerfileSection, ...]
28
+
29
+ def render(self) -> str:
30
+ lines: list[str] = []
31
+ for section in self.sections:
32
+ lines.extend(section.lines)
33
+ return "\n".join(lines)
34
+
35
+
36
+ def _build_setup(build_tool: str) -> tuple[list[str], str, str]:
37
+ if build_tool == "maven":
38
+ return (
39
+ [
40
+ "COPY mvnw pom.xml ./",
41
+ "COPY .mvn ./.mvn",
42
+ "RUN chmod +x mvnw",
43
+ "COPY src ./src",
44
+ ],
45
+ "./mvnw -B -q package -DskipTests",
46
+ "target/*.jar",
47
+ )
48
+ return (
49
+ [
50
+ "COPY gradlew build.gradle settings.gradle ./",
51
+ "COPY gradle ./gradle",
52
+ "RUN chmod +x gradlew",
53
+ "COPY src ./src",
54
+ ],
55
+ "./gradlew --no-daemon bootJar -x test",
56
+ "build/libs/*-SNAPSHOT.jar",
57
+ )
58
+
59
+
60
+ def _section(*lines: str) -> DockerfileSection:
61
+ return DockerfileSection(lines=tuple(lines))
62
+
63
+
64
+ def _validate_options(options: DockerfileOptions) -> None:
65
+ if options.build_tool not in {"maven", "gradle"}:
66
+ raise ValueError("build tool must be 'maven' or 'gradle'")
67
+ if options.java_version < 17:
68
+ raise ValueError("java version must be >= 17")
69
+ if options.runtime_image not in {"temurin", "distroless"}:
70
+ raise ValueError("runtime_image must be 'temurin' or 'distroless'")
71
+
72
+
73
+ def _compose_dockerfile(options: DockerfileOptions) -> DockerfileDocument:
74
+ setup, build_cmd, jar_path = _build_setup(options.build_tool)
75
+ build_step = (
76
+ "RUN --mount=type=cache,sharing=locked,target=/root/.m2 " + build_cmd
77
+ if options.use_buildkit_cache and options.build_tool == "maven"
78
+ else "RUN --mount=type=cache,sharing=locked,target=/root/.gradle " + build_cmd
79
+ if options.use_buildkit_cache and options.build_tool == "gradle"
80
+ else f"RUN {build_cmd}"
81
+ )
82
+
83
+ jvm_args: list[str] = []
84
+ if options.tuned_jvm_flags:
85
+ jvm_args.extend(
86
+ [
87
+ "-XX:MaxRAMPercentage=75",
88
+ "-XX:+ExitOnOutOfMemoryError",
89
+ "-Djava.io.tmpdir=/tmp",
90
+ ]
91
+ )
92
+
93
+ sections: list[DockerfileSection] = [
94
+ _section(
95
+ "# syntax=docker/dockerfile:1",
96
+ "# Generated by springdocker",
97
+ f"# Java {options.java_version} | build-tool: {options.build_tool}",
98
+ "",
99
+ )
100
+ ]
101
+ if options.platform_aware:
102
+ sections.append(_section("ARG TARGETPLATFORM", "ARG BUILDPLATFORM", ""))
103
+ sections.append(
104
+ _section(
105
+ f"FROM --platform=$BUILDPLATFORM eclipse-temurin:{options.java_version}-jdk AS build",
106
+ "WORKDIR /app",
107
+ *setup,
108
+ build_step,
109
+ "",
110
+ )
111
+ )
112
+
113
+ if options.use_jlink:
114
+ must_have_csv = ",".join(options.must_have_modules).replace('"', '\\"')
115
+ sections.append(
116
+ _section(
117
+ f"FROM --platform=$BUILDPLATFORM eclipse-temurin:{options.java_version}-jdk AS jre-builder",
118
+ "WORKDIR /jre",
119
+ f"COPY --from=build /app/{jar_path} app.jar",
120
+ (
121
+ f"RUN jdeps --ignore-missing-deps --recursive --multi-release {options.java_version} "
122
+ "--print-module-deps app.jar > modules.txt"
123
+ ),
124
+ f'ARG MUSTHAVE_MODULES="{must_have_csv}"',
125
+ "RUN set -eux; \\",
126
+ " MODULES=$( (tr ',' '\\n' < modules.txt; printf '%s\\n' \"$MUSTHAVE_MODULES\" | tr ',' '\\n') \\",
127
+ " | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -v '^$' | sort -u | paste -sd, -); \\",
128
+ " jlink --add-modules \"$MODULES\" --strip-debug --no-man-pages --no-header-files --compress=2 --output /opt/java",
129
+ "",
130
+ )
131
+ )
132
+
133
+ if options.runtime_image == "distroless":
134
+ runtime_base = (
135
+ "gcr.io/distroless/base-debian12:nonroot"
136
+ if options.use_jlink
137
+ else f"gcr.io/distroless/java{options.java_version}-debian12:nonroot"
138
+ )
139
+ distroless_lines = [
140
+ f"FROM --platform=$TARGETPLATFORM {runtime_base}",
141
+ "WORKDIR /app",
142
+ "VOLUME /tmp",
143
+ f"COPY --from=build /app/{jar_path} app.jar",
144
+ "EXPOSE 8080",
145
+ "EXPOSE 8081",
146
+ ]
147
+ if options.use_jlink:
148
+ distroless_lines.extend(
149
+ [
150
+ "COPY --from=jre-builder /opt/java /opt/java",
151
+ "ENV JAVA_HOME=/opt/java",
152
+ 'ENV PATH="${JAVA_HOME}/bin:${PATH}"',
153
+ ]
154
+ )
155
+ if options.non_root:
156
+ distroless_lines.append("USER nonroot")
157
+ sections.append(_section(*distroless_lines))
158
+ else:
159
+ temurin_lines = [f"FROM --platform=$TARGETPLATFORM eclipse-temurin:{options.java_version}-jre"]
160
+ if options.non_root:
161
+ temurin_lines.extend(
162
+ [
163
+ "RUN groupadd --system --gid 1001 javauser && useradd --system --uid 1001 --gid 1001 --no-create-home --shell /usr/sbin/nologin javauser",
164
+ "RUN install -d -o 1001 -g 1001 -m 755 /app && install -d -o 1001 -g 1001 -m 1777 /tmp",
165
+ ]
166
+ )
167
+ else:
168
+ temurin_lines.append("RUN install -d -m 755 /app && install -d -m 1777 /tmp")
169
+
170
+ temurin_lines.extend(
171
+ [
172
+ "WORKDIR /app",
173
+ "VOLUME /tmp",
174
+ f"COPY --from=build {'--chown=1001:1001 ' if options.non_root else ''}/app/{jar_path} app.jar",
175
+ "EXPOSE 8080",
176
+ "EXPOSE 8081",
177
+ ]
178
+ )
179
+ sections.append(_section(*temurin_lines))
180
+
181
+ if options.use_jlink and options.runtime_image != "distroless":
182
+ sections.append(
183
+ _section(
184
+ "COPY --from=jre-builder /opt/java /opt/java",
185
+ "ENV JAVA_HOME=/opt/java",
186
+ 'ENV PATH="${JAVA_HOME}/bin:${PATH}"',
187
+ )
188
+ )
189
+
190
+ entrypoint = ["java", *jvm_args, "-jar", "app.jar"]
191
+ tail_lines: list[str] = []
192
+ if options.non_root and options.runtime_image != "distroless":
193
+ tail_lines.append("USER 1001")
194
+ tail_lines.append("ENTRYPOINT [" + ", ".join(f'"{arg}"' for arg in entrypoint) + "]")
195
+ tail_lines.append(
196
+ "# Runtime hardening tip: run with --read-only --cap-drop=ALL --security-opt=no-new-privileges --tmpfs /tmp"
197
+ )
198
+ tail_lines.append("")
199
+ sections.append(_section(*tail_lines))
200
+ return DockerfileDocument(sections=tuple(sections))
201
+
202
+
203
+ def build_dockerfile(options: DockerfileOptions) -> str:
204
+ _validate_options(options)
205
+ return _compose_dockerfile(options).render()
206
+
207
+
208
+ def explain_dockerfile_text(text: str) -> dict[str, object]:
209
+ lines = text.splitlines()
210
+ if "# Generated by springdocker" not in text:
211
+ raise ValueError("Dockerfile was not generated by springdocker")
212
+
213
+ header_match = re.search(r"# Java (\d+) \| build-tool: (maven|gradle)", text)
214
+ java_version = int(header_match.group(1)) if header_match else None
215
+ build_tool = header_match.group(2) if header_match else None
216
+
217
+ features: list[dict[str, object]] = []
218
+ if sum(1 for line in lines if line.startswith("FROM ")) >= 2:
219
+ features.append(
220
+ {
221
+ "name": "multi-stage build",
222
+ "enabled": True,
223
+ "reason": "Separates the build stage from the runtime stage.",
224
+ }
225
+ )
226
+ if "jdeps" in text and "jlink" in text:
227
+ features.append(
228
+ {
229
+ "name": "jlink runtime",
230
+ "enabled": True,
231
+ "reason": "Builds a smaller custom runtime from the detected module list.",
232
+ }
233
+ )
234
+ if "--mount=type=cache" in text:
235
+ features.append(
236
+ {
237
+ "name": "BuildKit cache",
238
+ "enabled": True,
239
+ "reason": "Caches Maven or Gradle dependencies between builds.",
240
+ }
241
+ )
242
+ if "TARGETPLATFORM" in text and "BUILDPLATFORM" in text:
243
+ features.append(
244
+ {
245
+ "name": "multi-architecture build",
246
+ "enabled": True,
247
+ "reason": "Uses Buildx platform arguments for arm64 and amd64 builds.",
248
+ }
249
+ )
250
+ if "gcr.io/distroless" in text:
251
+ features.append(
252
+ {
253
+ "name": "distroless runtime",
254
+ "enabled": True,
255
+ "reason": "Uses a minimal distroless runtime image.",
256
+ }
257
+ )
258
+ if "VOLUME /tmp" in text:
259
+ features.append(
260
+ {
261
+ "name": "read-only filesystem ready",
262
+ "enabled": True,
263
+ "reason": "Keeps /tmp writable when the container root filesystem is read-only.",
264
+ }
265
+ )
266
+ if "USER 1001" in text or "USER nonroot" in text or "gcr.io/distroless" in text:
267
+ features.append(
268
+ {
269
+ "name": "non-root runtime",
270
+ "enabled": True,
271
+ "reason": "Runs the application as an unprivileged container user.",
272
+ }
273
+ )
274
+ if "-XX:MaxRAMPercentage=75" in text:
275
+ features.append(
276
+ {
277
+ "name": "tuned JVM flags",
278
+ "enabled": True,
279
+ "reason": "Applies container-friendly JVM memory and failure defaults.",
280
+ }
281
+ )
282
+
283
+ must_have_match = re.search(r'ARG MUSTHAVE_MODULES="([^"]*)"', text)
284
+ must_have_modules = tuple(
285
+ module for module in (part.strip() for part in (must_have_match.group(1) if must_have_match else "").split(",")) if module
286
+ )
287
+ if must_have_modules:
288
+ features.append(
289
+ {
290
+ "name": "must-have modules",
291
+ "enabled": True,
292
+ "reason": "Includes manually curated modules that jdeps cannot infer reliably.",
293
+ }
294
+ )
295
+
296
+ summary_parts = []
297
+ if build_tool and java_version is not None:
298
+ summary_parts.append(f"This {build_tool} Dockerfile targets Java {java_version}.")
299
+ if any(feature["name"] == "multi-stage build" for feature in features):
300
+ summary_parts.append("It uses a multi-stage build to keep the runtime image separate from compilation.")
301
+ if any(feature["name"] == "jlink runtime" for feature in features):
302
+ summary_parts.append("It builds a custom runtime with jlink.")
303
+ if any(feature["name"] == "non-root runtime" for feature in features):
304
+ summary_parts.append("It runs as a non-root user.")
305
+ if any(feature["name"] == "BuildKit cache" for feature in features):
306
+ summary_parts.append("It uses BuildKit cache mounts to speed up repeat builds.")
307
+ if any(feature["name"] == "multi-architecture build" for feature in features):
308
+ summary_parts.append("It is Buildx-friendly for amd64 and arm64 image builds.")
309
+ if any(feature["name"] == "read-only filesystem ready" for feature in features):
310
+ summary_parts.append("It keeps /tmp writable for read-only root filesystem deployments.")
311
+ if any(feature["name"] == "tuned JVM flags" for feature in features):
312
+ summary_parts.append("It applies container-oriented JVM defaults.")
313
+ if must_have_modules:
314
+ summary_parts.append("It adds curated modules for reflection or dynamic-loading edge cases.")
315
+
316
+ if not summary_parts:
317
+ summary_parts.append("No recognized springdocker optimizations were detected.")
318
+
319
+ notes = [
320
+ "Only Dockerfiles generated by springdocker are supported in v1.",
321
+ "Explanation is based on static text signals in the file.",
322
+ ]
323
+
324
+ return {
325
+ "source": "springdocker-generated Dockerfile",
326
+ "build_tool": build_tool,
327
+ "java_version": java_version,
328
+ "stage_count": sum(1 for line in lines if line.startswith("FROM ")),
329
+ "features": features,
330
+ "summary": " ".join(summary_parts),
331
+ "notes": notes,
332
+ }
springdocker/errors.py ADDED
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ EXIT_OK = 0
6
+ EXIT_FAILURE = 1
7
+ EXIT_USAGE = 2
8
+
9
+
10
+ def print_error(message: str) -> None:
11
+ print(f"error: {message}", file=sys.stderr)
12
+
13
+
14
+ def print_warning(message: str) -> None:
15
+ print(f"warning: {message}", file=sys.stderr)
16
+
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from importlib.metadata import EntryPoint, entry_points
6
+ from typing import Any, Protocol, cast
7
+
8
+ from .dockerfile import DockerfileOptions
9
+
10
+ DOCKERFILE_MUTATOR_GROUP = "springdocker.dockerfile_mutators"
11
+
12
+
13
+ class DockerfileMutator(Protocol):
14
+ name: str
15
+
16
+ def mutate_dockerfile(self, dockerfile_text: str, options: DockerfileOptions) -> str: ...
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class PluginExecution:
21
+ dockerfile_text: str
22
+ warnings: tuple[str, ...]
23
+
24
+
25
+ def _iter_entry_points(group: str) -> list[EntryPoint]:
26
+ discovered = entry_points()
27
+ if hasattr(discovered, "select"):
28
+ return list(discovered.select(group=group))
29
+ legacy = cast(dict[str, list[EntryPoint]], discovered)
30
+ return list(legacy.get(group, []))
31
+
32
+
33
+ def _to_mutator(candidate: Any, entry_point_name: str) -> DockerfileMutator:
34
+ instance = candidate() if isinstance(candidate, type) else candidate
35
+ if hasattr(instance, "mutate_dockerfile") and callable(instance.mutate_dockerfile):
36
+ if hasattr(instance, "name"):
37
+ return cast(DockerfileMutator, instance)
38
+
39
+ class _NamedMutator:
40
+ name = entry_point_name
41
+
42
+ def mutate_dockerfile(self, dockerfile_text: str, options: DockerfileOptions) -> str:
43
+ return cast(str, instance.mutate_dockerfile(dockerfile_text, options))
44
+
45
+ return cast(DockerfileMutator, _NamedMutator())
46
+ if callable(instance):
47
+ fn = instance
48
+
49
+ class _FunctionMutator:
50
+ name = entry_point_name
51
+
52
+ def mutate_dockerfile(self, dockerfile_text: str, options: DockerfileOptions) -> str:
53
+ return cast(str, fn(dockerfile_text, options))
54
+
55
+ return cast(DockerfileMutator, _FunctionMutator())
56
+ raise TypeError("plugin must define mutate_dockerfile(...) or be a callable")
57
+
58
+
59
+ def apply_dockerfile_mutators(dockerfile_text: str, options: DockerfileOptions) -> PluginExecution:
60
+ if os.environ.get("SPRINGDOCKER_DISABLE_PLUGINS", "").lower() in {"1", "true", "yes", "on"}:
61
+ return PluginExecution(dockerfile_text=dockerfile_text, warnings=())
62
+
63
+ warnings: list[str] = []
64
+ rendered = dockerfile_text
65
+ for plugin_entry in _iter_entry_points(DOCKERFILE_MUTATOR_GROUP):
66
+ try:
67
+ loaded = plugin_entry.load()
68
+ mutator = _to_mutator(loaded, plugin_entry.name)
69
+ mutated = mutator.mutate_dockerfile(rendered, options)
70
+ if not isinstance(mutated, str):
71
+ raise TypeError("plugin must return str from mutate_dockerfile")
72
+ rendered = mutated
73
+ except Exception as exc: # pragma: no cover - covered through behavior tests
74
+ warnings.append(f"plugin '{plugin_entry.name}' failed: {exc}")
75
+ return PluginExecution(dockerfile_text=rendered, warnings=tuple(warnings))
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import xml.etree.ElementTree as ET
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ProjectInfo:
11
+ root: Path
12
+ build_tool: str
13
+ has_spring_markers: bool
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class InspectInfo:
18
+ root: Path
19
+ build_tool: str
20
+ has_spring_markers: bool
21
+ java_version: int | None
22
+ spring_boot_version: str | None
23
+ direct_dependencies: tuple[str, ...]
24
+ config_exists: bool
25
+ generated_dockerfiles: tuple[str, ...]
26
+ reflection_hits: tuple[str, ...]
27
+ runtime_compatibility: str
28
+ recommendations: tuple[str, ...]
29
+
30
+
31
+ def detect_build_tool(root: Path, explicit: str | None = None) -> str:
32
+ """Detect Maven or Gradle build tool from project markers."""
33
+ if explicit:
34
+ if explicit not in {"maven", "gradle"}:
35
+ raise ValueError("build tool must be 'maven' or 'gradle'")
36
+ return explicit
37
+
38
+ has_maven = (root / "pom.xml").exists()
39
+ has_gradle = (root / "gradlew").exists() or any(
40
+ (root / name).exists() for name in ("build.gradle", "build.gradle.kts")
41
+ )
42
+
43
+ if has_maven and has_gradle:
44
+ raise ValueError(
45
+ "Both Maven and Gradle markers found. Pass --build-tool explicitly."
46
+ )
47
+ if has_maven:
48
+ return "maven"
49
+ if has_gradle:
50
+ return "gradle"
51
+ raise ValueError("Could not detect build tool. Expected pom.xml or gradle markers.")
52
+
53
+
54
+ def has_spring_project_markers(root: Path) -> bool:
55
+ """Best-effort Spring Boot marker detection for Java projects."""
56
+ if (root / "src" / "main" / "resources" / "application.properties").exists():
57
+ return True
58
+ if (root / "src" / "main" / "resources" / "application.yml").exists():
59
+ return True
60
+
61
+ # Lightweight content checks in build descriptors.
62
+ for descriptor in ("pom.xml", "build.gradle", "build.gradle.kts"):
63
+ path = root / descriptor
64
+ if not path.exists():
65
+ continue
66
+ text = path.read_text(encoding="utf-8", errors="ignore")
67
+ if "spring-boot" in text or "org.springframework.boot" in text:
68
+ return True
69
+ return False
70
+
71
+
72
+ def inspect_project(root: Path, explicit_build_tool: str | None = None) -> ProjectInfo:
73
+ build_tool = detect_build_tool(root, explicit_build_tool)
74
+ return ProjectInfo(
75
+ root=root,
76
+ build_tool=build_tool,
77
+ has_spring_markers=has_spring_project_markers(root),
78
+ )
79
+
80
+
81
+ def _strip_namespace(element: ET.Element) -> None:
82
+ for child in element.iter():
83
+ if "}" in child.tag:
84
+ child.tag = child.tag.split("}", 1)[1]
85
+
86
+
87
+ def _parse_int(text: str | None) -> int | None:
88
+ if text is None:
89
+ return None
90
+ match = re.search(r"\d+", text)
91
+ if not match:
92
+ return None
93
+ return int(match.group(0))
94
+
95
+
96
+ def _extract_maven_info(root: Path) -> tuple[int | None, str | None, tuple[str, ...]]:
97
+ pom = root / "pom.xml"
98
+ if not pom.exists():
99
+ return None, None, ()
100
+
101
+ try:
102
+ tree = ET.parse(pom)
103
+ except ET.ParseError:
104
+ return None, None, ()
105
+
106
+ xml_root = tree.getroot()
107
+ _strip_namespace(xml_root)
108
+
109
+ java_version = None
110
+ properties = xml_root.find("properties")
111
+ if properties is not None:
112
+ for key in ("java.version", "maven.compiler.release", "maven.compiler.source", "maven.compiler.target"):
113
+ java_version = _parse_int(properties.findtext(key))
114
+ if java_version is not None:
115
+ break
116
+
117
+ spring_boot_version = None
118
+ parent = xml_root.find("parent")
119
+ if parent is not None:
120
+ version = parent.findtext("version")
121
+ if version:
122
+ spring_boot_version = version.strip()
123
+ if spring_boot_version is None:
124
+ deps = xml_root.find("dependencyManagement")
125
+ if deps is not None:
126
+ spring_boot_version = None
127
+
128
+ dependencies: list[str] = []
129
+ for deps in xml_root.findall("dependencies"):
130
+ for dep in deps.findall("dependency"):
131
+ group = (dep.findtext("groupId") or "").strip()
132
+ artifact = (dep.findtext("artifactId") or "").strip()
133
+ if group and artifact:
134
+ dependencies.append(f"{group}:{artifact}")
135
+
136
+ return java_version, spring_boot_version, tuple(dependencies)
137
+
138
+
139
+ def _extract_gradle_info(root: Path) -> tuple[int | None, str | None, tuple[str, ...]]:
140
+ for candidate in ("build.gradle", "build.gradle.kts"):
141
+ path = root / candidate
142
+ if not path.exists():
143
+ continue
144
+ text = path.read_text(encoding="utf-8", errors="ignore")
145
+
146
+ spring_boot_version = None
147
+ m = re.search(r"""org\.springframework\.boot['"]\s+version\s+['"]([^'"]+)['"]""", text)
148
+ if m:
149
+ spring_boot_version = m.group(1)
150
+
151
+ java_version = None
152
+ for pattern in (
153
+ r"JavaLanguageVersion\.of\((\d+)\)",
154
+ r"sourceCompatibility\s*=\s*['\"]?(\d+)",
155
+ r"targetCompatibility\s*=\s*['\"]?(\d+)",
156
+ ):
157
+ m = re.search(pattern, text)
158
+ if m:
159
+ java_version = int(m.group(1))
160
+ break
161
+
162
+ dependencies: list[str] = []
163
+ for dep_match in re.finditer(
164
+ r"""(?:implementation|api|compileOnly|runtimeOnly|testImplementation)\s*(?:\(|\s)\s*['"]([^:'"]+):([^:'"]+):([^'"]+)['"]""",
165
+ text,
166
+ ):
167
+ dependencies.append(f"{dep_match.group(1)}:{dep_match.group(2)}")
168
+
169
+ return java_version, spring_boot_version, tuple(dependencies)
170
+
171
+ return None, None, ()
172
+
173
+
174
+ def _find_reflection_hits(root: Path) -> tuple[str, ...]:
175
+ hits: list[str] = []
176
+ for path in sorted(root.rglob("*.java")):
177
+ try:
178
+ lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
179
+ except OSError:
180
+ continue
181
+ for line_no, line in enumerate(lines, start=1):
182
+ if any(token in line for token in ("Class.forName(", "getDeclaredMethod(", "getDeclaredField(", "setAccessible(")):
183
+ hits.append(f"{path.relative_to(root)}:{line_no}:{line.strip()}")
184
+ return tuple(hits)
185
+
186
+
187
+ def _runtime_compatibility(java_version: int | None, spring_boot_version: str | None) -> tuple[str, tuple[str, ...]]:
188
+ recommendations: list[str] = []
189
+ if spring_boot_version is None:
190
+ return "unknown", ("Spring Boot version could not be detected statically.",)
191
+
192
+ boot_major = _parse_int(spring_boot_version.split(".", 1)[0])
193
+ if boot_major is None:
194
+ return "unknown", ("Spring Boot version could not be parsed cleanly.",)
195
+
196
+ if boot_major >= 4:
197
+ min_java = 21
198
+ elif boot_major >= 3:
199
+ min_java = 17
200
+ else:
201
+ min_java = 8
202
+
203
+ if java_version is None:
204
+ recommendations.append("Java version could not be detected statically.")
205
+ return "unknown", tuple(recommendations)
206
+
207
+ if java_version < min_java:
208
+ return "incompatible", (f"Spring Boot {spring_boot_version} generally expects Java {min_java}+.",)
209
+ if java_version >= min_java:
210
+ return "compatible", (f"Spring Boot {spring_boot_version} and Java {java_version} look compatible.",)
211
+ return "unknown", ("Compatibility could not be determined.",)
212
+
213
+
214
+ def inspect_project_details(root: Path, explicit_build_tool: str | None = None) -> InspectInfo:
215
+ project = inspect_project(root, explicit_build_tool)
216
+ java_version = None
217
+ spring_boot_version = None
218
+ direct_dependencies: tuple[str, ...] = ()
219
+ if project.build_tool == "maven":
220
+ java_version, spring_boot_version, direct_dependencies = _extract_maven_info(root)
221
+ else:
222
+ java_version, spring_boot_version, direct_dependencies = _extract_gradle_info(root)
223
+
224
+ compatibility, compatibility_notes = _runtime_compatibility(java_version, spring_boot_version)
225
+ config_exists = (root / ".springdocker.toml").exists()
226
+ generated_dockerfiles = tuple(
227
+ str(path.relative_to(root))
228
+ for path in sorted(root.glob("Dockerfile*"))
229
+ if path.is_file()
230
+ )
231
+ reflection_hits = _find_reflection_hits(root)
232
+ recommendations = list(compatibility_notes)
233
+ if not config_exists:
234
+ recommendations.append("Run `springdocker init` to create a starter .springdocker.toml file.")
235
+ if not generated_dockerfiles:
236
+ recommendations.append("No Dockerfile artifacts found in the project root.")
237
+ if not direct_dependencies:
238
+ recommendations.append("No direct dependency list could be extracted statically.")
239
+ if not reflection_hits:
240
+ recommendations.append("No direct reflection calls were found in Java source.")
241
+ else:
242
+ recommendations.append("Direct reflection calls were found in Java source; review them before using jlink.")
243
+
244
+ return InspectInfo(
245
+ root=project.root,
246
+ build_tool=project.build_tool,
247
+ has_spring_markers=project.has_spring_markers,
248
+ java_version=java_version,
249
+ spring_boot_version=spring_boot_version,
250
+ direct_dependencies=direct_dependencies,
251
+ config_exists=config_exists,
252
+ generated_dockerfiles=generated_dockerfiles,
253
+ reflection_hits=reflection_hits,
254
+ runtime_compatibility=compatibility,
255
+ recommendations=tuple(recommendations),
256
+ )