Analyze cloned repos from source without running Maven or Gradle on the target project.
Parse build files statically for module wiring, use the exporter's bundled Spring classpath for type bindings, optionally include pre-built generated main sources, and exclude test output and test-scoped dependencies from analysis. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -48,6 +48,10 @@ dependencies {
|
||||
testImplementation 'org.assertj:assertj-core:3.27.7'
|
||||
|
||||
implementation 'net.sourceforge.plantuml:plantuml:1.2024.3'
|
||||
|
||||
// Library types for JDT binding when analyzing cloned source (never runs the target project's build).
|
||||
implementation 'org.springframework.boot:spring-boot-starter:3.2.0'
|
||||
implementation 'org.springframework.statemachine:spring-statemachine-starter:3.2.0'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Discovers generated {@code .java} sources under Maven {@code target/} and Gradle {@code build/} when
|
||||
* they already exist in the workspace. Does not run Maven or Gradle.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class GeneratedSourceDiscovery {
|
||||
|
||||
private static final List<String> BUILD_ROOTS = List.of("target", "build");
|
||||
|
||||
private static final List<String> EXCLUDED_RELATIVE_SEGMENTS = List.of(
|
||||
"classes",
|
||||
"test-classes",
|
||||
"generated-test-sources",
|
||||
"generated-test",
|
||||
"test",
|
||||
"testfixtures",
|
||||
"test-fixtures",
|
||||
"androidtest",
|
||||
"inttest",
|
||||
"functionaltest",
|
||||
"e2etest",
|
||||
"libs",
|
||||
"lib",
|
||||
"tmp",
|
||||
"reports",
|
||||
"test-results",
|
||||
"surefire",
|
||||
"failsafe",
|
||||
"surefire-reports",
|
||||
"failsafe-reports",
|
||||
"cache",
|
||||
"caches",
|
||||
"outputs",
|
||||
"intermediates",
|
||||
"kotlin",
|
||||
"native",
|
||||
"resources",
|
||||
"distributions",
|
||||
"docs",
|
||||
"javadoc",
|
||||
"testOutput");
|
||||
|
||||
public Set<Path> discoverSourceRoots(Collection<Path> moduleDirectories) {
|
||||
Set<Path> roots = new LinkedHashSet<>();
|
||||
if (moduleDirectories == null) {
|
||||
return roots;
|
||||
}
|
||||
for (Path moduleDir : moduleDirectories) {
|
||||
if (moduleDir == null || !Files.isDirectory(moduleDir)) {
|
||||
continue;
|
||||
}
|
||||
roots.addAll(discoverSourceRoots(moduleDir));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
public Set<Path> discoverSourceRoots(Path moduleDirectory) {
|
||||
Set<Path> roots = new LinkedHashSet<>();
|
||||
Path normalizedModule = moduleDirectory.toAbsolutePath().normalize();
|
||||
for (String buildRootName : BUILD_ROOTS) {
|
||||
Path buildRoot = normalizedModule.resolve(buildRootName);
|
||||
if (containsGeneratedJava(normalizedModule, buildRoot)) {
|
||||
roots.add(buildRoot);
|
||||
}
|
||||
}
|
||||
if (!roots.isEmpty()) {
|
||||
log.info("Discovered generated source roots under {}: {}", normalizedModule, roots);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
public boolean hasGeneratedSources(Path moduleDirectory) {
|
||||
return !discoverSourceRoots(moduleDirectory).isEmpty();
|
||||
}
|
||||
|
||||
private boolean containsGeneratedJava(Path moduleDirectory, Path buildRoot) {
|
||||
if (!Files.isDirectory(buildRoot)) {
|
||||
return false;
|
||||
}
|
||||
try (Stream<Path> files = Files.walk(buildRoot)) {
|
||||
return files.filter(Files::isRegularFile)
|
||||
.anyMatch(path -> path.getFileName().toString().endsWith(".java")
|
||||
&& !isExcludedBuildPath(moduleDirectory, path));
|
||||
} catch (IOException e) {
|
||||
log.debug("Failed to scan generated sources under {}", buildRoot, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isExcludedBuildPath(Path moduleDirectory, Path candidate) {
|
||||
Path normalizedModule = moduleDirectory.toAbsolutePath().normalize();
|
||||
Path normalizedCandidate = candidate.toAbsolutePath().normalize();
|
||||
if (!normalizedCandidate.startsWith(normalizedModule)) {
|
||||
return true;
|
||||
}
|
||||
Path relative = normalizedModule.relativize(normalizedCandidate);
|
||||
if (relative.getNameCount() < 2) {
|
||||
return false;
|
||||
}
|
||||
String buildRoot = relative.getName(0).toString().toLowerCase(Locale.ROOT);
|
||||
if (!buildRoot.equals("target") && !buildRoot.equals("build")) {
|
||||
return true;
|
||||
}
|
||||
for (int i = 1; i < relative.getNameCount(); i++) {
|
||||
String segment = relative.getName(i).toString().toLowerCase(Locale.ROOT);
|
||||
if (isExcludedBuildSegment(segment)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static boolean isExcludedBuildSegment(String segment) {
|
||||
if (segment == null || segment.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String lower = segment.toLowerCase(Locale.ROOT);
|
||||
if (EXCLUDED_RELATIVE_SEGMENTS.contains(lower)) {
|
||||
return true;
|
||||
}
|
||||
return lower.contains("test-sources")
|
||||
|| lower.startsWith("test-")
|
||||
|| lower.endsWith("-test")
|
||||
|| lower.contains("-test-");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Queue;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Static wiring model built from parsed {@code settings.gradle}, {@code build.gradle}, and {@code pom.xml}
|
||||
* files — no Maven/Gradle execution.
|
||||
*/
|
||||
public final class ProjectModuleGraph {
|
||||
|
||||
public enum BuildSystem {
|
||||
GRADLE,
|
||||
MAVEN,
|
||||
MIXED
|
||||
}
|
||||
|
||||
public enum DependencyKind {
|
||||
LOCAL_PROJECT,
|
||||
EXTERNAL_LIBRARY
|
||||
}
|
||||
|
||||
public record MavenCoordinates(String groupId, String artifactId) {
|
||||
public MavenCoordinates {
|
||||
Objects.requireNonNull(groupId, "groupId");
|
||||
Objects.requireNonNull(artifactId, "artifactId");
|
||||
}
|
||||
|
||||
public String key() {
|
||||
return groupId + ":" + artifactId;
|
||||
}
|
||||
}
|
||||
|
||||
public record ProjectModule(
|
||||
String id,
|
||||
Path directory,
|
||||
BuildSystem buildSystem,
|
||||
MavenCoordinates mavenCoordinates) {
|
||||
|
||||
public Optional<MavenCoordinates> coordinates() {
|
||||
return Optional.ofNullable(mavenCoordinates);
|
||||
}
|
||||
}
|
||||
|
||||
public record ModuleDependency(
|
||||
String fromModuleId,
|
||||
String toModuleId,
|
||||
MavenCoordinates externalCoordinates,
|
||||
DependencyKind kind) {
|
||||
}
|
||||
|
||||
private final Path workspaceRoot;
|
||||
private final Map<String, ProjectModule> modulesById;
|
||||
private final Map<MavenCoordinates, ProjectModule> modulesByCoordinates;
|
||||
private final List<ModuleDependency> dependencies;
|
||||
|
||||
public ProjectModuleGraph(
|
||||
Path workspaceRoot,
|
||||
Map<String, ProjectModule> modulesById,
|
||||
Map<MavenCoordinates, ProjectModule> modulesByCoordinates,
|
||||
List<ModuleDependency> dependencies) {
|
||||
this.workspaceRoot = workspaceRoot != null ? workspaceRoot.normalize() : null;
|
||||
this.modulesById = Map.copyOf(modulesById);
|
||||
this.modulesByCoordinates = Map.copyOf(modulesByCoordinates);
|
||||
this.dependencies = List.copyOf(dependencies);
|
||||
}
|
||||
|
||||
public Path workspaceRoot() {
|
||||
return workspaceRoot;
|
||||
}
|
||||
|
||||
public Map<String, ProjectModule> modulesById() {
|
||||
return modulesById;
|
||||
}
|
||||
|
||||
public Map<MavenCoordinates, ProjectModule> modulesByCoordinates() {
|
||||
return modulesByCoordinates;
|
||||
}
|
||||
|
||||
public List<ModuleDependency> dependencies() {
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
public Optional<ProjectModule> findByDirectory(Path directory) {
|
||||
if (directory == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Path normalized = directory.toAbsolutePath().normalize();
|
||||
return modulesById.values().stream()
|
||||
.filter(module -> module.directory().toAbsolutePath().normalize().equals(normalized))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
public Optional<ProjectModule> findContainingModule(Path path) {
|
||||
if (path == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Path normalized = path.toAbsolutePath().normalize();
|
||||
ProjectModule best = null;
|
||||
int bestLength = -1;
|
||||
for (ProjectModule module : modulesById.values()) {
|
||||
Path moduleDir = module.directory().toAbsolutePath().normalize();
|
||||
if (normalized.startsWith(moduleDir) && moduleDir.getNameCount() > bestLength) {
|
||||
best = module;
|
||||
bestLength = moduleDir.getNameCount();
|
||||
}
|
||||
}
|
||||
return Optional.ofNullable(best);
|
||||
}
|
||||
|
||||
public Set<Path> allModuleDirectories() {
|
||||
Set<Path> paths = new LinkedHashSet<>();
|
||||
for (ProjectModule module : modulesById.values()) {
|
||||
if (Files.isDirectory(module.directory())) {
|
||||
paths.add(module.directory().toAbsolutePath().normalize());
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
public boolean isGradleAggregatorRoot(Path directory) {
|
||||
if (directory == null || workspaceRoot == null) {
|
||||
return false;
|
||||
}
|
||||
return directory.toAbsolutePath().normalize().equals(workspaceRoot)
|
||||
&& modulesById.values().stream().anyMatch(module -> module.buildSystem() == BuildSystem.GRADLE);
|
||||
}
|
||||
|
||||
public boolean isMavenAggregatorRoot(Path directory) {
|
||||
if (directory == null) {
|
||||
return false;
|
||||
}
|
||||
Path normalized = directory.toAbsolutePath().normalize();
|
||||
long childModules = modulesById.values().stream()
|
||||
.filter(module -> module.directory().toAbsolutePath().normalize().startsWith(normalized))
|
||||
.filter(module -> !module.directory().toAbsolutePath().normalize().equals(normalized))
|
||||
.count();
|
||||
return childModules > 0 && findByDirectory(normalized)
|
||||
.map(module -> module.buildSystem() == BuildSystem.MAVEN || module.buildSystem() == BuildSystem.MIXED)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves scan roots for analysis: the input directory, all Gradle includes when scanning from the
|
||||
* workspace root, and transitively reachable local modules from build-file wiring.
|
||||
*/
|
||||
public Set<Path> resolveScanPaths(Path inputDir) {
|
||||
return resolveScanPaths(inputDir, true);
|
||||
}
|
||||
|
||||
public Set<Path> resolveScanPaths(Path inputDir, boolean includeGeneratedSources) {
|
||||
if (inputDir == null) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<Path> scanPaths = new LinkedHashSet<>();
|
||||
Path normalizedInput = inputDir.toAbsolutePath().normalize();
|
||||
scanPaths.add(normalizedInput);
|
||||
|
||||
if (workspaceRoot != null && normalizedInput.equals(workspaceRoot)) {
|
||||
scanPaths.addAll(allModuleDirectories());
|
||||
}
|
||||
|
||||
Optional<ProjectModule> inputModule = findContainingModule(normalizedInput);
|
||||
if (inputModule.isPresent()) {
|
||||
scanPaths.addAll(transitiveLocalModuleDirectories(inputModule.get().id()));
|
||||
resolveMavenParentDirectory(inputModule.get().directory()).ifPresent(scanPaths::add);
|
||||
Path parentDir = inputModule.get().directory().getParent();
|
||||
if (parentDir != null && Files.exists(parentDir.resolve("pom.xml"))) {
|
||||
scanPaths.add(parentDir.toAbsolutePath().normalize());
|
||||
}
|
||||
}
|
||||
|
||||
if (includeGeneratedSources) {
|
||||
scanPaths.addAll(new GeneratedSourceDiscovery().discoverSourceRoots(scanPaths));
|
||||
}
|
||||
return scanPaths;
|
||||
}
|
||||
|
||||
static Optional<Path> resolveMavenParentDirectory(Path moduleDirectory) {
|
||||
if (moduleDirectory == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Path pom = moduleDirectory.resolve("pom.xml");
|
||||
if (!Files.exists(pom)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
String content = Files.readString(pom);
|
||||
Matcher parentMatcher = Pattern.compile(
|
||||
"<parent>.*?<relativePath>(.*?)</relativePath>.*?</parent>", Pattern.DOTALL).matcher(content);
|
||||
if (!parentMatcher.find()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String relPathStr = parentMatcher.group(1).trim();
|
||||
Path relPath = moduleDirectory.resolve(relPathStr).normalize();
|
||||
if (Files.isRegularFile(relPath) && "pom.xml".equals(relPath.getFileName().toString())) {
|
||||
return Optional.of(relPath.getParent().toAbsolutePath().normalize());
|
||||
}
|
||||
if (Files.isDirectory(relPath)) {
|
||||
return Optional.of(relPath.toAbsolutePath().normalize());
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public Set<Path> transitiveLocalModuleDirectories(String rootModuleId) {
|
||||
Set<Path> reachable = new LinkedHashSet<>();
|
||||
Queue<String> queue = new ArrayDeque<>();
|
||||
Set<String> visited = new HashSet<>();
|
||||
if (rootModuleId != null) {
|
||||
queue.add(rootModuleId);
|
||||
}
|
||||
while (!queue.isEmpty()) {
|
||||
String current = queue.poll();
|
||||
if (!visited.add(current)) {
|
||||
continue;
|
||||
}
|
||||
ProjectModule module = modulesById.get(current);
|
||||
if (module != null && Files.isDirectory(module.directory())) {
|
||||
reachable.add(module.directory().toAbsolutePath().normalize());
|
||||
}
|
||||
for (ModuleDependency dependency : dependencies) {
|
||||
if (dependency.kind() == DependencyKind.LOCAL_PROJECT
|
||||
&& current.equals(dependency.fromModuleId())
|
||||
&& dependency.toModuleId() != null) {
|
||||
queue.add(dependency.toModuleId());
|
||||
}
|
||||
}
|
||||
}
|
||||
return reachable;
|
||||
}
|
||||
|
||||
public List<ModuleDependency> localDependenciesOf(String moduleId) {
|
||||
List<ModuleDependency> result = new ArrayList<>();
|
||||
for (ModuleDependency dependency : dependencies) {
|
||||
if (moduleId.equals(dependency.fromModuleId())) {
|
||||
result.add(dependency);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -3,224 +3,41 @@ package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Discovers related local modules by statically parsing Maven/Gradle build files (never executes build tools).
|
||||
*/
|
||||
@Slf4j
|
||||
public class SiblingDependencyResolver {
|
||||
|
||||
private static final Pattern GRADLE_PROJECT_DEP = Pattern.compile("project\\(['\"]:(.*?)['\"]\\)");
|
||||
private static final Pattern MAVEN_DEPENDENCY = Pattern.compile("<dependency>\\s*<groupId>(.*?)</groupId>\\s*<artifactId>(.*?)</artifactId>\\s*<version>(.*?)</version>", Pattern.DOTALL);
|
||||
private final StaticBuildFileAnalyzer analyzer = new StaticBuildFileAnalyzer();
|
||||
|
||||
public ProjectModuleGraph analyzeProject(Path inputDir) throws IOException {
|
||||
return analyzer.analyze(inputDir);
|
||||
}
|
||||
|
||||
public Set<Path> resolveSiblings(Path projectDir) {
|
||||
Set<Path> allSiblings = new HashSet<>();
|
||||
Queue<Path> queue = new LinkedList<>();
|
||||
queue.add(projectDir);
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
Path current = queue.poll();
|
||||
Set<Path> currentSiblings = new HashSet<>();
|
||||
try {
|
||||
currentSiblings.addAll(resolveGradleSiblings(current));
|
||||
currentSiblings.addAll(resolveMavenSiblings(current));
|
||||
} catch (Exception e) {
|
||||
log.warn("Error resolving sibling dependencies for {}", current, e);
|
||||
}
|
||||
|
||||
for (Path sibling : currentSiblings) {
|
||||
if (!allSiblings.contains(sibling) && !sibling.equals(projectDir)) {
|
||||
allSiblings.add(sibling);
|
||||
queue.add(sibling);
|
||||
}
|
||||
}
|
||||
try {
|
||||
ProjectModuleGraph graph = analyzer.analyze(projectDir);
|
||||
return resolveSiblings(graph, projectDir);
|
||||
} catch (IOException e) {
|
||||
log.warn("Error resolving sibling dependencies for {}", projectDir, e);
|
||||
return Set.of();
|
||||
}
|
||||
return allSiblings;
|
||||
}
|
||||
|
||||
private Set<Path> resolveGradleSiblings(Path projectDir) throws IOException {
|
||||
Set<Path> siblings = new HashSet<>();
|
||||
Path buildGradle = projectDir.resolve("build.gradle");
|
||||
if (!Files.exists(buildGradle)) {
|
||||
buildGradle = projectDir.resolve("build.gradle.kts");
|
||||
}
|
||||
|
||||
if (Files.exists(buildGradle)) {
|
||||
log.info("Found build.gradle at {}", buildGradle);
|
||||
String content = Files.readString(buildGradle);
|
||||
Matcher matcher = GRADLE_PROJECT_DEP.matcher(content);
|
||||
Set<String> gradlePaths = new HashSet<>();
|
||||
while (matcher.find()) {
|
||||
gradlePaths.add(matcher.group(1));
|
||||
}
|
||||
log.info("Found project dependencies: {}", gradlePaths);
|
||||
|
||||
if (!gradlePaths.isEmpty()) {
|
||||
Path rootDir = findGradleRoot(projectDir);
|
||||
log.info("Resolved Gradle root to: {}", rootDir);
|
||||
if (rootDir != null) {
|
||||
for (String gradlePath : gradlePaths) {
|
||||
String relativePath = gradlePath.replace(':', '/');
|
||||
Path resolvedSibling = rootDir.resolve(relativePath).normalize();
|
||||
|
||||
// Check for explicit projectDir mapping in settings.gradle or settings.gradle.kts
|
||||
Path settingsFile = rootDir.resolve("settings.gradle");
|
||||
if (!Files.exists(settingsFile)) {
|
||||
settingsFile = rootDir.resolve("settings.gradle.kts");
|
||||
}
|
||||
if (Files.exists(settingsFile)) {
|
||||
String settingsContent = Files.readString(settingsFile);
|
||||
String regex = "project\\(['\"]:" + gradlePath + "['\"]\\)\\.projectDir\\s*=\\s*(?:file\\(['\"](.*?)['\"]\\)|new File\\(.*?,\\s*['\"](.*?)['\"]\\))";
|
||||
Matcher settingsMatcher = Pattern.compile(regex).matcher(settingsContent);
|
||||
if (settingsMatcher.find()) {
|
||||
String customPath = settingsMatcher.group(1) != null ? settingsMatcher.group(1) : settingsMatcher.group(2);
|
||||
resolvedSibling = rootDir.resolve(customPath).normalize();
|
||||
log.info("Found custom projectDir in settings for {}: {}", gradlePath, resolvedSibling);
|
||||
}
|
||||
}
|
||||
|
||||
if (Files.exists(resolvedSibling)) {
|
||||
log.info("Discovered local Gradle sibling dependency: {}", resolvedSibling);
|
||||
siblings.add(resolvedSibling);
|
||||
}
|
||||
}
|
||||
}
|
||||
public Set<Path> resolveSiblings(ProjectModuleGraph graph, Path inputDir) {
|
||||
Set<Path> scanPaths = graph.resolveScanPaths(inputDir);
|
||||
Set<Path> siblings = new LinkedHashSet<>();
|
||||
Path normalizedInput = inputDir.toAbsolutePath().normalize();
|
||||
for (Path path : scanPaths) {
|
||||
if (!path.equals(normalizedInput)) {
|
||||
siblings.add(path);
|
||||
}
|
||||
}
|
||||
return siblings;
|
||||
}
|
||||
|
||||
private Path findGradleRoot(Path start) {
|
||||
Path current = start.toAbsolutePath();
|
||||
Path lastSettingsDir = null;
|
||||
while (current != null) {
|
||||
if (Files.exists(current.resolve("settings.gradle")) || Files.exists(current.resolve("settings.gradle.kts"))) {
|
||||
lastSettingsDir = current;
|
||||
}
|
||||
if (Files.exists(current.resolve(".git"))) {
|
||||
return lastSettingsDir != null ? lastSettingsDir : current;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
return lastSettingsDir;
|
||||
}
|
||||
|
||||
private Set<Path> resolveMavenSiblings(Path projectDir) throws IOException {
|
||||
Set<Path> siblings = new HashSet<>();
|
||||
Path pom = projectDir.resolve("pom.xml");
|
||||
if (Files.exists(pom)) {
|
||||
log.info("Found pom.xml at {}", pom);
|
||||
String content = Files.readString(pom);
|
||||
|
||||
Set<String> targetArtifacts = new HashSet<>();
|
||||
Matcher matcher = Pattern.compile("<artifactId>(.*?)</artifactId>").matcher(content);
|
||||
// We want artifacts from the <dependencies> section
|
||||
int depsStart = content.indexOf("<dependencies>");
|
||||
int depsEnd = content.indexOf("</dependencies>");
|
||||
if (depsStart != -1 && depsEnd > depsStart) {
|
||||
String depsContent = content.substring(depsStart, depsEnd);
|
||||
Matcher depMatcher = Pattern.compile("<artifactId>(.*?)</artifactId>").matcher(depsContent);
|
||||
while (depMatcher.find()) {
|
||||
targetArtifacts.add(depMatcher.group(1));
|
||||
}
|
||||
}
|
||||
log.info("Found maven target artifacts: {}", targetArtifacts);
|
||||
|
||||
if (!targetArtifacts.isEmpty()) {
|
||||
Path rootDir = findMavenRoot(projectDir);
|
||||
log.info("Resolved Maven root to: {}", rootDir);
|
||||
if (rootDir != null) {
|
||||
try (Stream<Path> paths = Files.walk(rootDir)) {
|
||||
List<Path> allPoms = paths.filter(p -> p.getFileName().toString().equals("pom.xml"))
|
||||
.filter(p -> !p.toString().contains("/target/"))
|
||||
.collect(Collectors.toList());
|
||||
log.info("Scanned {} total pom.xml files under {}", allPoms.size(), rootDir);
|
||||
|
||||
for (Path p : allPoms) {
|
||||
try {
|
||||
String pContent = Files.readString(p);
|
||||
String artifactId = extractOwnArtifactId(pContent);
|
||||
log.info("Checking artifactId {} in {}", artifactId, p);
|
||||
if (artifactId != null && targetArtifacts.contains(artifactId)) {
|
||||
Path siblingDir = p.getParent();
|
||||
if (!siblingDir.equals(projectDir)) {
|
||||
log.info("Discovered local Maven sibling dependency via artifact match: {}", siblingDir);
|
||||
siblings.add(siblingDir);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also directly add aggregator <module> directories declared in this pom
|
||||
Matcher moduleMatcher = Pattern.compile("<module>(.*?)</module>").matcher(content);
|
||||
while (moduleMatcher.find()) {
|
||||
String moduleName = moduleMatcher.group(1).trim();
|
||||
Path moduleDir = projectDir.resolve(moduleName).normalize();
|
||||
if (Files.exists(moduleDir) && Files.exists(moduleDir.resolve("pom.xml")) && !moduleDir.equals(projectDir)) {
|
||||
log.info("Discovered local Maven sibling dependency via <module>: {}", moduleDir);
|
||||
siblings.add(moduleDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Also add parent dir if it has a pom.xml
|
||||
Path parentDir = projectDir.getParent();
|
||||
|
||||
// Check for explicit <relativePath> in parent block
|
||||
Matcher parentRelativePathMatcher = Pattern.compile("<parent>.*?<relativePath>(.*?)</relativePath>.*?</parent>", Pattern.DOTALL).matcher(content);
|
||||
if (parentRelativePathMatcher.find()) {
|
||||
String relPathStr = parentRelativePathMatcher.group(1).trim();
|
||||
Path relPath = projectDir.resolve(relPathStr).normalize();
|
||||
if (Files.isRegularFile(relPath) && relPath.getFileName().toString().equals("pom.xml")) {
|
||||
parentDir = relPath.getParent();
|
||||
} else if (Files.isDirectory(relPath)) {
|
||||
parentDir = relPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (parentDir != null && Files.exists(parentDir.resolve("pom.xml")) && !parentDir.equals(projectDir)) {
|
||||
log.info("Discovered local Maven parent sibling: {}", parentDir);
|
||||
siblings.add(parentDir);
|
||||
}
|
||||
}
|
||||
return siblings;
|
||||
}
|
||||
|
||||
private String extractOwnArtifactId(String pomContent) {
|
||||
// Strip <parent> block if it exists
|
||||
String content = pomContent;
|
||||
int parentEnd = pomContent.indexOf("</parent>");
|
||||
if (parentEnd != -1) {
|
||||
content = pomContent.substring(parentEnd);
|
||||
}
|
||||
|
||||
Matcher matcher = Pattern.compile("<artifactId>(.*?)</artifactId>").matcher(content);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Path findMavenRoot(Path start) {
|
||||
Path current = start.toAbsolutePath();
|
||||
Path lastPomDir = null;
|
||||
while (current != null) {
|
||||
if (Files.exists(current.resolve("pom.xml"))) {
|
||||
lastPomDir = current;
|
||||
}
|
||||
if (Files.exists(current.resolve(".git"))) {
|
||||
return lastPomDir != null ? lastPomDir : current;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
return lastPomDir;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Parses Maven/Gradle build files statically to discover modules and local dependency wiring.
|
||||
*/
|
||||
@Slf4j
|
||||
public class StaticBuildFileAnalyzer {
|
||||
|
||||
private static final Pattern GRADLE_PROJECT_DEP = Pattern.compile("project\\(['\"]:(.*?)['\"]\\)");
|
||||
private static final Pattern QUOTED_TOKEN = Pattern.compile("['\"]([^'\"]+)['\"]");
|
||||
private static final Pattern GRADLE_PROJECT_DIR = Pattern.compile(
|
||||
"project\\(['\"]:(.*?)['\"]\\)\\.projectDir\\s*=\\s*(?:file\\(['\"](.*?)['\"]\\)|new File\\(.*?,\\s*['\"](.*?)['\"]\\))");
|
||||
private static final Pattern MAVEN_MODULE = Pattern.compile("<module>(.*?)</module>");
|
||||
private static final Pattern MAVEN_DEPENDENCY_BLOCK = Pattern.compile("<dependency>(.*?)</dependency>", Pattern.DOTALL);
|
||||
private static final Pattern MAVEN_TAG = Pattern.compile("<(groupId|artifactId)>(.*?)</\\1>");
|
||||
|
||||
public ProjectModuleGraph analyze(Path inputDir) throws IOException {
|
||||
Path workspaceRoot = findWorkspaceRoot(inputDir);
|
||||
Path indexRoot = findIndexingRoot(inputDir, workspaceRoot);
|
||||
if (workspaceRoot == null) {
|
||||
workspaceRoot = inputDir.toAbsolutePath().normalize();
|
||||
}
|
||||
if (indexRoot == null) {
|
||||
indexRoot = workspaceRoot;
|
||||
}
|
||||
|
||||
Map<String, ProjectModuleGraph.ProjectModule> modulesById = new LinkedHashMap<>();
|
||||
Map<ProjectModuleGraph.MavenCoordinates, ProjectModuleGraph.ProjectModule> modulesByCoordinates = new LinkedHashMap<>();
|
||||
List<ProjectModuleGraph.ModuleDependency> dependencies = new ArrayList<>();
|
||||
|
||||
Path gradleSettingsRoot = Files.exists(workspaceRoot.resolve("settings.gradle"))
|
||||
|| Files.exists(workspaceRoot.resolve("settings.gradle.kts"))
|
||||
? workspaceRoot
|
||||
: indexRoot;
|
||||
Map<String, Path> gradleModulePaths = parseGradleIncludes(gradleSettingsRoot);
|
||||
for (Map.Entry<String, Path> entry : gradleModulePaths.entrySet()) {
|
||||
registerGradleModule(entry.getKey(), entry.getValue(), modulesById);
|
||||
}
|
||||
|
||||
List<Path> pomFiles = findPomFiles(indexRoot);
|
||||
for (Path pom : pomFiles) {
|
||||
registerMavenSubmodules(pom, modulesById, modulesByCoordinates);
|
||||
}
|
||||
for (Path pom : pomFiles) {
|
||||
registerMavenModule(pom, modulesById, modulesByCoordinates);
|
||||
}
|
||||
|
||||
ensureInputModule(inputDir, modulesById);
|
||||
|
||||
for (ProjectModuleGraph.ProjectModule module : List.copyOf(modulesById.values())) {
|
||||
if (module.buildSystem() == ProjectModuleGraph.BuildSystem.GRADLE
|
||||
|| module.buildSystem() == ProjectModuleGraph.BuildSystem.MIXED) {
|
||||
dependencies.addAll(parseGradleDependencies(module, modulesById, gradleModulePaths));
|
||||
}
|
||||
}
|
||||
for (Path pom : pomFiles) {
|
||||
dependencies.addAll(parseMavenDependencies(pom, modulesById, modulesByCoordinates));
|
||||
}
|
||||
|
||||
log.info("Static project graph: {} modules, {} dependencies under {}", modulesById.size(), dependencies.size(), workspaceRoot);
|
||||
return new ProjectModuleGraph(workspaceRoot, modulesById, modulesByCoordinates, dependencies);
|
||||
}
|
||||
|
||||
Map<String, Path> parseGradleIncludes(Path gradleRoot) throws IOException {
|
||||
Map<String, Path> includes = new LinkedHashMap<>();
|
||||
if (gradleRoot == null) {
|
||||
return includes;
|
||||
}
|
||||
Path settingsFile = gradleRoot.resolve("settings.gradle");
|
||||
if (!Files.exists(settingsFile)) {
|
||||
settingsFile = gradleRoot.resolve("settings.gradle.kts");
|
||||
}
|
||||
if (!Files.exists(settingsFile)) {
|
||||
return includes;
|
||||
}
|
||||
|
||||
String content = Files.readString(settingsFile);
|
||||
for (String includePath : extractGradleIncludePaths(content)) {
|
||||
String normalized = normalizeGradlePath(includePath);
|
||||
Path moduleDir = resolveGradleModuleDirectory(gradleRoot, normalized, content);
|
||||
if (Files.isDirectory(moduleDir)) {
|
||||
includes.put(normalized, moduleDir.normalize());
|
||||
}
|
||||
}
|
||||
return includes;
|
||||
}
|
||||
|
||||
static Set<String> extractGradleIncludePaths(String settingsContent) {
|
||||
Set<String> paths = new LinkedHashSet<>();
|
||||
for (String line : settingsContent.split("\\R")) {
|
||||
if (!line.contains("include")) {
|
||||
continue;
|
||||
}
|
||||
int includeIndex = line.indexOf("include");
|
||||
Matcher tokenMatcher = QUOTED_TOKEN.matcher(line);
|
||||
while (tokenMatcher.find()) {
|
||||
if (tokenMatcher.start() > includeIndex) {
|
||||
paths.add(normalizeGradlePath(tokenMatcher.group(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
private static String normalizeGradlePath(String includePath) {
|
||||
String path = includePath.trim();
|
||||
if (path.startsWith(":")) {
|
||||
path = path.substring(1);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private Path resolveGradleModuleDirectory(Path gradleRoot, String gradlePath, String settingsContent) {
|
||||
String lookup = gradlePath.contains(":") ? gradlePath : gradlePath.replace('/', ':');
|
||||
Matcher matcher = GRADLE_PROJECT_DIR.matcher(settingsContent);
|
||||
while (matcher.find()) {
|
||||
String configuredPath = matcher.group(1);
|
||||
if (configuredPath != null && configuredPath.equals(lookup)) {
|
||||
String custom = matcher.group(2) != null ? matcher.group(2) : matcher.group(3);
|
||||
if (custom != null) {
|
||||
return gradleRoot.resolve(custom).normalize();
|
||||
}
|
||||
}
|
||||
}
|
||||
return gradleRoot.resolve(gradlePath.replace(':', '/')).normalize();
|
||||
}
|
||||
|
||||
private void registerGradleModule(String gradlePath, Path directory, Map<String, ProjectModuleGraph.ProjectModule> modulesById) {
|
||||
String id = "gradle:" + gradlePath;
|
||||
ProjectModuleGraph.ProjectModule existing = modulesById.get(id);
|
||||
if (existing != null) {
|
||||
modulesById.put(id, new ProjectModuleGraph.ProjectModule(
|
||||
id,
|
||||
directory,
|
||||
ProjectModuleGraph.BuildSystem.MIXED,
|
||||
existing.mavenCoordinates()));
|
||||
return;
|
||||
}
|
||||
modulesById.put(id, new ProjectModuleGraph.ProjectModule(
|
||||
id,
|
||||
directory,
|
||||
ProjectModuleGraph.BuildSystem.GRADLE,
|
||||
null));
|
||||
}
|
||||
|
||||
private void registerMavenSubmodules(
|
||||
Path pomFile,
|
||||
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
|
||||
Map<ProjectModuleGraph.MavenCoordinates, ProjectModuleGraph.ProjectModule> modulesByCoordinates) throws IOException {
|
||||
String content = Files.readString(pomFile);
|
||||
Path parentDir = pomFile.getParent();
|
||||
Matcher moduleMatcher = MAVEN_MODULE.matcher(content);
|
||||
while (moduleMatcher.find()) {
|
||||
Path moduleDir = parentDir.resolve(moduleMatcher.group(1).trim()).normalize();
|
||||
Path childPom = moduleDir.resolve("pom.xml");
|
||||
if (Files.exists(childPom)) {
|
||||
registerMavenModule(childPom, modulesById, modulesByCoordinates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void registerMavenModule(
|
||||
Path pomFile,
|
||||
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
|
||||
Map<ProjectModuleGraph.MavenCoordinates, ProjectModuleGraph.ProjectModule> modulesByCoordinates) throws IOException {
|
||||
String content = Files.readString(pomFile);
|
||||
Optional<ProjectModuleGraph.MavenCoordinates> coordinates = extractOwnMavenCoordinates(content);
|
||||
if (coordinates.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Path directory = pomFile.getParent().normalize();
|
||||
|
||||
for (Map.Entry<String, ProjectModuleGraph.ProjectModule> entry : modulesById.entrySet()) {
|
||||
if (entry.getValue().directory().toAbsolutePath().normalize().equals(directory.toAbsolutePath().normalize())) {
|
||||
ProjectModuleGraph.ProjectModule merged = new ProjectModuleGraph.ProjectModule(
|
||||
entry.getKey(),
|
||||
directory,
|
||||
ProjectModuleGraph.BuildSystem.MIXED,
|
||||
coordinates.get());
|
||||
modulesById.put(entry.getKey(), merged);
|
||||
modulesByCoordinates.put(coordinates.get(), merged);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
String id = "maven:" + coordinates.get().key();
|
||||
ProjectModuleGraph.ProjectModule module = new ProjectModuleGraph.ProjectModule(
|
||||
id,
|
||||
directory,
|
||||
ProjectModuleGraph.BuildSystem.MAVEN,
|
||||
coordinates.get());
|
||||
modulesById.put(id, module);
|
||||
modulesByCoordinates.put(coordinates.get(), module);
|
||||
}
|
||||
|
||||
static Optional<ProjectModuleGraph.MavenCoordinates> extractOwnMavenCoordinates(String pomContent) {
|
||||
String content = pomContent;
|
||||
int parentEnd = pomContent.indexOf("</parent>");
|
||||
if (parentEnd != -1) {
|
||||
content = pomContent.substring(parentEnd);
|
||||
}
|
||||
String groupId = extractFirstTag(content, "groupId").orElse(null);
|
||||
String artifactId = extractFirstTag(content, "artifactId").orElse(null);
|
||||
if (artifactId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (groupId == null) {
|
||||
groupId = extractParentTag(pomContent, "groupId").orElse("unknown");
|
||||
}
|
||||
return Optional.of(new ProjectModuleGraph.MavenCoordinates(groupId, artifactId));
|
||||
}
|
||||
|
||||
private static Optional<String> extractParentTag(String pomContent, String tag) {
|
||||
Matcher parentMatcher = Pattern.compile("<parent>.*?</parent>", Pattern.DOTALL).matcher(pomContent);
|
||||
if (!parentMatcher.find()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return extractFirstTag(parentMatcher.group(), tag);
|
||||
}
|
||||
|
||||
private static Optional<String> extractFirstTag(String content, String tag) {
|
||||
Matcher matcher = Pattern.compile("<" + tag + ">(.*?)</" + tag + ">").matcher(content);
|
||||
if (matcher.find()) {
|
||||
return Optional.of(matcher.group(1).trim());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private List<ProjectModuleGraph.ModuleDependency> parseGradleDependencies(
|
||||
ProjectModuleGraph.ProjectModule module,
|
||||
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
|
||||
Map<String, Path> gradleModulePaths) throws IOException {
|
||||
List<ProjectModuleGraph.ModuleDependency> result = new ArrayList<>();
|
||||
Path buildFile = module.directory().resolve("build.gradle");
|
||||
if (!Files.exists(buildFile)) {
|
||||
buildFile = module.directory().resolve("build.gradle.kts");
|
||||
}
|
||||
if (!Files.exists(buildFile)) {
|
||||
return result;
|
||||
}
|
||||
String content = Files.readString(buildFile);
|
||||
for (String line : content.split("\\R")) {
|
||||
if (isTestGradleConfigurationLine(line)) {
|
||||
continue;
|
||||
}
|
||||
Matcher matcher = GRADLE_PROJECT_DEP.matcher(line);
|
||||
while (matcher.find()) {
|
||||
String gradlePath = normalizeGradlePath(matcher.group(1));
|
||||
String targetId = findGradleModuleId(gradlePath, modulesById, gradleModulePaths);
|
||||
if (targetId != null) {
|
||||
result.add(new ProjectModuleGraph.ModuleDependency(
|
||||
module.id(), targetId, null, ProjectModuleGraph.DependencyKind.LOCAL_PROJECT));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static boolean isTestScopedMavenDependency(String dependencyBlock) {
|
||||
return extractFirstTag(dependencyBlock, "scope")
|
||||
.map(scope -> scope.equalsIgnoreCase("test") || scope.equalsIgnoreCase("testCompile"))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
static boolean isTestGradleConfigurationLine(String line) {
|
||||
if (line == null || line.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = line.trim();
|
||||
return trimmed.matches("(?i).*(testImplementation|testCompileOnly|testRuntimeOnly|testFixturesImplementation|androidTestImplementation|androidTestCompileOnly|androidTestRuntimeOnly|testAnnotationProcessor)\\b.*");
|
||||
}
|
||||
|
||||
private static String findGradleModuleId(
|
||||
String gradlePath,
|
||||
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
|
||||
Map<String, Path> gradleModulePaths) {
|
||||
String exact = "gradle:" + gradlePath;
|
||||
if (modulesById.containsKey(exact)) {
|
||||
return exact;
|
||||
}
|
||||
Path expectedDir = gradleModulePaths.get(gradlePath);
|
||||
if (expectedDir != null) {
|
||||
for (ProjectModuleGraph.ProjectModule module : modulesById.values()) {
|
||||
if (module.directory().toAbsolutePath().normalize().equals(expectedDir.toAbsolutePath().normalize())) {
|
||||
return module.id();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<ProjectModuleGraph.ModuleDependency> parseMavenDependencies(
|
||||
Path pomFile,
|
||||
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
|
||||
Map<ProjectModuleGraph.MavenCoordinates, ProjectModuleGraph.ProjectModule> modulesByCoordinates) throws IOException {
|
||||
List<ProjectModuleGraph.ModuleDependency> result = new ArrayList<>();
|
||||
String content = Files.readString(pomFile);
|
||||
Optional<ProjectModuleGraph.MavenCoordinates> fromCoordinates = extractOwnMavenCoordinates(content);
|
||||
if (fromCoordinates.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
ProjectModuleGraph.ProjectModule fromModule = modulesByCoordinates.get(fromCoordinates.get());
|
||||
if (fromModule == null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
int depsStart = content.indexOf("<dependencies>");
|
||||
int depsEnd = content.indexOf("</dependencies>");
|
||||
if (depsStart == -1 || depsEnd <= depsStart) {
|
||||
return result;
|
||||
}
|
||||
String depsSection = content.substring(depsStart, depsEnd);
|
||||
Matcher blockMatcher = MAVEN_DEPENDENCY_BLOCK.matcher(depsSection);
|
||||
while (blockMatcher.find()) {
|
||||
String block = blockMatcher.group(1);
|
||||
if (isTestScopedMavenDependency(block)) {
|
||||
continue;
|
||||
}
|
||||
String groupId = extractFirstTag(block, "groupId").orElse(null);
|
||||
String artifactId = extractFirstTag(block, "artifactId").orElse(null);
|
||||
if (artifactId == null) {
|
||||
continue;
|
||||
}
|
||||
if (groupId == null) {
|
||||
groupId = extractParentTag(content, "groupId").orElse("unknown");
|
||||
}
|
||||
ProjectModuleGraph.MavenCoordinates targetCoordinates = new ProjectModuleGraph.MavenCoordinates(groupId, artifactId);
|
||||
ProjectModuleGraph.ProjectModule targetModule = modulesByCoordinates.get(targetCoordinates);
|
||||
if (targetModule != null && !targetModule.id().equals(fromModule.id())) {
|
||||
result.add(new ProjectModuleGraph.ModuleDependency(
|
||||
fromModule.id(),
|
||||
targetModule.id(),
|
||||
null,
|
||||
ProjectModuleGraph.DependencyKind.LOCAL_PROJECT));
|
||||
} else {
|
||||
result.add(new ProjectModuleGraph.ModuleDependency(
|
||||
fromModule.id(),
|
||||
null,
|
||||
targetCoordinates,
|
||||
ProjectModuleGraph.DependencyKind.EXTERNAL_LIBRARY));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ensureInputModule(Path inputDir, Map<String, ProjectModuleGraph.ProjectModule> modulesById) {
|
||||
Path normalized = inputDir.toAbsolutePath().normalize();
|
||||
boolean known = modulesById.values().stream()
|
||||
.anyMatch(module -> module.directory().toAbsolutePath().normalize().equals(normalized));
|
||||
if (!known) {
|
||||
modulesById.put("input:" + normalized.getFileName(), new ProjectModuleGraph.ProjectModule(
|
||||
"input:" + normalized.getFileName(),
|
||||
normalized,
|
||||
ProjectModuleGraph.BuildSystem.MIXED,
|
||||
null));
|
||||
}
|
||||
}
|
||||
|
||||
private List<Path> findPomFiles(Path workspaceRoot) throws IOException {
|
||||
if (workspaceRoot == null || !Files.isDirectory(workspaceRoot)) {
|
||||
return List.of();
|
||||
}
|
||||
try (Stream<Path> stream = Files.walk(workspaceRoot)) {
|
||||
return stream.filter(path -> path.getFileName().toString().equals("pom.xml"))
|
||||
.filter(path -> !path.toString().contains("/target/"))
|
||||
.filter(path -> !path.toString().contains("\\target\\"))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
Path findWorkspaceRoot(Path start) {
|
||||
if (start == null) {
|
||||
return null;
|
||||
}
|
||||
Path normalized = start.toAbsolutePath().normalize();
|
||||
Path current = normalized;
|
||||
Path deepestMavenAggregator = null;
|
||||
Path deepestGradleSettings = null;
|
||||
Path deepestPom = null;
|
||||
|
||||
while (current != null) {
|
||||
if (Files.exists(current.resolve("settings.gradle")) || Files.exists(current.resolve("settings.gradle.kts"))) {
|
||||
deepestGradleSettings = current;
|
||||
}
|
||||
Path pom = current.resolve("pom.xml");
|
||||
if (Files.exists(pom)) {
|
||||
deepestPom = current;
|
||||
try {
|
||||
if (Files.readString(pom).contains("<modules>")) {
|
||||
deepestMavenAggregator = current;
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// continue walking
|
||||
}
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
|
||||
if (deepestMavenAggregator != null) {
|
||||
return deepestMavenAggregator;
|
||||
}
|
||||
if (deepestGradleSettings != null) {
|
||||
return deepestGradleSettings;
|
||||
}
|
||||
if (deepestPom != null) {
|
||||
return deepestPom;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
Path findIndexingRoot(Path start, Path workspaceRoot) {
|
||||
if (workspaceRoot == null) {
|
||||
return findIndexingRoot(start);
|
||||
}
|
||||
return workspaceRoot;
|
||||
}
|
||||
|
||||
Path findIndexingRoot(Path start) {
|
||||
if (start == null) {
|
||||
return null;
|
||||
}
|
||||
Path current = start.toAbsolutePath().normalize();
|
||||
Path gitRoot = null;
|
||||
while (current != null) {
|
||||
if (Files.exists(current.resolve(".git"))) {
|
||||
gitRoot = current;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
return gitRoot != null ? gitRoot : findWorkspaceRoot(start);
|
||||
}
|
||||
}
|
||||
@@ -2,172 +2,21 @@ package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Placeholder kept for API stability. The exporter never executes Maven or Gradle against the
|
||||
* analyzed project. Multi-module layout is discovered statically via
|
||||
* {@link click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver}
|
||||
* (parsed {@code settings.gradle}, {@code pom.xml}, {@code build.gradle}).
|
||||
*/
|
||||
@Slf4j
|
||||
public class DynamicClasspathResolver {
|
||||
|
||||
public List<String> resolveClasspath(Path projectRoot) {
|
||||
log.info("Attempting dynamic classpath resolution for project at {}", projectRoot);
|
||||
if (projectRoot == null || !Files.exists(projectRoot)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
if (Files.exists(projectRoot.resolve("pom.xml"))) {
|
||||
return resolveMaven(projectRoot);
|
||||
} else if (Files.exists(projectRoot.resolve("build.gradle")) || Files.exists(projectRoot.resolve("build.gradle.kts"))) {
|
||||
return resolveGradle(projectRoot);
|
||||
}
|
||||
|
||||
log.info("No Maven or Gradle configuration found at {}. Proceeding without dynamic classpath.", projectRoot);
|
||||
log.debug("Skipping external JAR classpath: analysis uses scanned source files only.");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
protected List<String> resolveMaven(Path projectRoot) {
|
||||
log.info("Maven project detected. Resolving dependencies...");
|
||||
try {
|
||||
String mvnCmd = getCommand(projectRoot, "mvnw", "mvn");
|
||||
if (mvnCmd == null) {
|
||||
log.warn("Maven executable not found.");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
mvnCmd,
|
||||
"-q",
|
||||
"dependency:build-classpath",
|
||||
"-DincludeScope=compile",
|
||||
"-Dmdep.outputFile=sme_cp.txt"
|
||||
);
|
||||
pb.directory(projectRoot.toFile());
|
||||
|
||||
runProcess(pb);
|
||||
|
||||
java.util.Set<String> allPaths = new java.util.LinkedHashSet<>();
|
||||
try (java.util.stream.Stream<Path> stream = Files.walk(projectRoot)) {
|
||||
stream.filter(p -> p.getFileName().toString().equals("sme_cp.txt"))
|
||||
.forEach(cpFile -> {
|
||||
try {
|
||||
String cpString = Files.readString(cpFile).trim();
|
||||
if (!cpString.isEmpty()) {
|
||||
allPaths.addAll(Arrays.asList(cpString.split(File.pathSeparator)));
|
||||
}
|
||||
Files.deleteIfExists(cpFile);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to read or delete sme_cp.txt at {}", cpFile, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!allPaths.isEmpty()) {
|
||||
return new ArrayList<>(allPaths);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to dynamically resolve Maven classpath", e);
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
protected List<String> resolveGradle(Path projectRoot) {
|
||||
log.info("Gradle project detected. Resolving dependencies...");
|
||||
Path initScript = null;
|
||||
try {
|
||||
initScript = Files.createTempFile("state_machine_exporter_init", ".gradle");
|
||||
String gradleCmd = getCommand(projectRoot, "gradlew", "gradle");
|
||||
if (gradleCmd == null) {
|
||||
log.warn("Gradle executable not found.");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
String scriptContent = """
|
||||
allprojects {
|
||||
task smePrintClasspath {
|
||||
doLast {
|
||||
def cp = []
|
||||
if (configurations.findByName("compileClasspath")) {
|
||||
cp = configurations.compileClasspath.files
|
||||
}
|
||||
println "SME_CLASSPATH_MARKER:" + cp.join(File.pathSeparator)
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
Files.writeString(initScript, scriptContent);
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
gradleCmd,
|
||||
"-q",
|
||||
"-I", initScript.toAbsolutePath().toString(),
|
||||
"smePrintClasspath"
|
||||
);
|
||||
pb.directory(projectRoot.toFile());
|
||||
|
||||
String output = runProcessAndGetOutput(pb);
|
||||
java.util.Set<String> allPaths = new java.util.LinkedHashSet<>();
|
||||
for (String line : output.split("\\r?\\n")) {
|
||||
if (line.startsWith("SME_CLASSPATH_MARKER:")) {
|
||||
String cpString = line.substring("SME_CLASSPATH_MARKER:".length()).trim();
|
||||
if (!cpString.isEmpty()) {
|
||||
allPaths.addAll(Arrays.asList(cpString.split(File.pathSeparator)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!allPaths.isEmpty()) {
|
||||
return new ArrayList<>(allPaths);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to dynamically resolve Gradle classpath", e);
|
||||
} finally {
|
||||
if (initScript != null) {
|
||||
try {
|
||||
Files.deleteIfExists(initScript);
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private String getCommand(Path projectRoot, String wrapper, String systemBinary) {
|
||||
Path wrapperUnix = projectRoot.resolve(wrapper);
|
||||
Path wrapperWin = projectRoot.resolve(wrapper + ".bat");
|
||||
|
||||
boolean isWin = System.getProperty("os.name").toLowerCase().contains("win");
|
||||
|
||||
if (isWin && Files.exists(wrapperWin)) return wrapperWin.toAbsolutePath().toString();
|
||||
if (!isWin && Files.exists(wrapperUnix)) {
|
||||
// Need absolute path like ./gradlew
|
||||
return wrapperUnix.toAbsolutePath().toString();
|
||||
}
|
||||
|
||||
return systemBinary;
|
||||
}
|
||||
|
||||
protected void runProcess(ProcessBuilder pb) throws IOException, InterruptedException {
|
||||
pb.redirectErrorStream(true);
|
||||
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);
|
||||
Process p = pb.start();
|
||||
p.waitFor();
|
||||
}
|
||||
|
||||
protected String runProcessAndGetOutput(ProcessBuilder pb) throws IOException, InterruptedException {
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
StringBuilder out = new StringBuilder();
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
out.append(line).append("\n");
|
||||
}
|
||||
}
|
||||
p.waitFor();
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Classpath entries from the exporter tool itself — not from running Maven/Gradle on the analyzed project.
|
||||
*/
|
||||
public final class ToolingClasspath {
|
||||
|
||||
private ToolingClasspath() {
|
||||
}
|
||||
|
||||
public static List<String> currentJvmJarEntries() {
|
||||
String raw = System.getProperty("java.class.path");
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return Arrays.stream(raw.split(File.pathSeparator))
|
||||
.filter(entry -> entry.endsWith(".jar"))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,31 @@ public class CodebaseContext {
|
||||
return Collections.unmodifiableList(libraryHints);
|
||||
}
|
||||
|
||||
private final Set<String> ignorePatterns = new HashSet<>(Set.of("**/test/**", "**/build/classes/**", "**/build/libs/**", "**/build/tmp/**", "**/build/reports/**", "**/build/test-results/**", "**/node_modules/**", "**/out/**"));
|
||||
private final Set<String> ignorePatterns = new HashSet<>(Set.of(
|
||||
"**/src/test/**",
|
||||
"**/src/test/java/**",
|
||||
"**/src/testFixtures/**",
|
||||
"**/src/test-fixtures/**",
|
||||
"**/target/classes/**",
|
||||
"**/target/test-classes/**",
|
||||
"**/target/generated-test-sources/**",
|
||||
"**/target/surefire/**",
|
||||
"**/target/failsafe/**",
|
||||
"**/target/surefire-reports/**",
|
||||
"**/target/failsafe-reports/**",
|
||||
"**/build/classes/**",
|
||||
"**/build/test-classes/**",
|
||||
"**/build/generated-test-sources/**",
|
||||
"**/build/generated/sources/**/test/**",
|
||||
"**/build/generated/sources/**/testFixtures/**",
|
||||
"**/build/libs/**",
|
||||
"**/build/tmp/**",
|
||||
"**/build/reports/**",
|
||||
"**/build/test-results/**",
|
||||
"**/build/kotlin/**",
|
||||
"**/build/intermediates/**",
|
||||
"**/node_modules/**",
|
||||
"**/out/**"));
|
||||
private String[] classpath = new String[0];
|
||||
private String[] sourcepath = new String[0];
|
||||
private boolean resolveBindings = false;
|
||||
|
||||
@@ -57,8 +57,8 @@ public class ExporterCommand implements Callable<Integer> {
|
||||
@Option(names = {"--debug"}, description = "Enable diagnostic mode to show unresolved call chains and detailed analysis logging.", defaultValue = "false")
|
||||
private boolean debug;
|
||||
|
||||
@Option(names = {"--resolve-classpath"}, description = "Automatically run Maven/Gradle to resolve full classpath dependencies for perfect AST bindings.", defaultValue = "true", fallbackValue = "true")
|
||||
private boolean resolveClasspath = true;
|
||||
@Option(names = {"--include-generated-sources"}, description = "Scan existing Maven target/ and Gradle build/ trees for pre-generated .java sources (MapStruct, annotation processors, etc.). Does not run Maven or Gradle.", negatable = true, defaultValue = "true", fallbackValue = "true")
|
||||
private boolean includeGeneratedSources = true;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
@@ -90,7 +90,7 @@ public class ExporterCommand implements Callable<Integer> {
|
||||
if (jsonFile != null) {
|
||||
exportService.runJsonExporter(jsonFile, outputDir, selectedFormats, profiles, eventFormat, stateFormat);
|
||||
} else {
|
||||
exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, null, null, eventFormat, stateFormat, resolveClasspath);
|
||||
exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, null, null, eventFormat, stateFormat, includeGeneratedSources);
|
||||
}
|
||||
out.println(CommandLine.Help.Ansi.AUTO.string("%n@|bold,green SUCCESS:|@ All state machines have been exported to " + outputDir.toAbsolutePath()));
|
||||
return 0;
|
||||
|
||||
@@ -9,6 +9,7 @@ import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.JdtIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.ToolingClasspath;
|
||||
import click.kamil.springstatemachineexporter.ast.app.AstTransitionParser;
|
||||
import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator;
|
||||
import click.kamil.springstatemachineexporter.ast.app.TransitionStateUtils;
|
||||
@@ -54,29 +55,36 @@ public class ExportService {
|
||||
public static final Set<String> STATE_MACHINE_RETURN_TYPES = Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory");
|
||||
|
||||
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
|
||||
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, Collections.emptyList(), null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
|
||||
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, Collections.emptyList(), null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, true);
|
||||
}
|
||||
|
||||
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles) throws IOException {
|
||||
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
|
||||
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, true);
|
||||
}
|
||||
|
||||
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter) throws IOException {
|
||||
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
|
||||
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, true);
|
||||
}
|
||||
|
||||
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
|
||||
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, eventFormat, stateFormat, false);
|
||||
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, eventFormat, stateFormat, true);
|
||||
}
|
||||
|
||||
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat, boolean resolveClasspath) throws IOException {
|
||||
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
|
||||
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, eventFormat, stateFormat, true);
|
||||
}
|
||||
|
||||
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat, boolean includeGeneratedSources) throws IOException {
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setActiveProfiles(activeProfiles);
|
||||
|
||||
click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver siblingResolver = new click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver();
|
||||
Set<Path> allPaths = new HashSet<>();
|
||||
allPaths.add(inputDir);
|
||||
allPaths.addAll(siblingResolver.resolveSiblings(inputDir));
|
||||
click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver siblingResolver =
|
||||
new click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver();
|
||||
click.kamil.springstatemachineexporter.analysis.resolver.ProjectModuleGraph projectGraph =
|
||||
siblingResolver.analyzeProject(inputDir);
|
||||
Set<Path> allPaths = projectGraph.resolveScanPaths(inputDir, includeGeneratedSources);
|
||||
log.info("Static module graph discovered {} scan roots from {} (includeGeneratedSources={})",
|
||||
allPaths.size(), inputDir, includeGeneratedSources);
|
||||
|
||||
// Find project root (common ancestor)
|
||||
Path projectRoot = inputDir.toAbsolutePath();
|
||||
@@ -90,23 +98,28 @@ public class ExportService {
|
||||
log.info("Project root resolved to: {}", projectRoot);
|
||||
|
||||
List<String> sourcepaths = allPaths.stream()
|
||||
.map(p -> p.resolve("src/main/java").toString())
|
||||
.filter(p -> Files.exists(Path.of(p)))
|
||||
.flatMap(p -> {
|
||||
List<Path> roots = new ArrayList<>();
|
||||
Path mainJava = p.resolve("src/main/java");
|
||||
if (Files.exists(mainJava)) {
|
||||
roots.add(mainJava);
|
||||
}
|
||||
if (includeGeneratedSources) {
|
||||
roots.addAll(new click.kamil.springstatemachineexporter.analysis.resolver.GeneratedSourceDiscovery()
|
||||
.discoverSourceRoots(p));
|
||||
}
|
||||
return roots.stream();
|
||||
})
|
||||
.map(Path::toString)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
log.info("Setting JDT sourcepath: {}", sourcepaths);
|
||||
context.setSourcepath(sourcepaths);
|
||||
|
||||
if (resolveClasspath) {
|
||||
click.kamil.springstatemachineexporter.analysis.service.DynamicClasspathResolver classpathResolver = new click.kamil.springstatemachineexporter.analysis.service.DynamicClasspathResolver();
|
||||
List<String> dynamicClasspath = classpathResolver.resolveClasspath(projectRoot);
|
||||
if (!dynamicClasspath.isEmpty()) {
|
||||
context.setClasspath(dynamicClasspath);
|
||||
log.info("Injected {} external JARs into JDT ASTParser", dynamicClasspath.size());
|
||||
}
|
||||
} else {
|
||||
log.info("Dynamic classpath resolution disabled. Use --resolve-classpath to enable.");
|
||||
List<String> toolingClasspath = ToolingClasspath.currentJvmJarEntries();
|
||||
if (!toolingClasspath.isEmpty()) {
|
||||
context.setClasspath(toolingClasspath);
|
||||
log.info("Using {} tooling JARs for library type bindings (target project build is not executed).", toolingClasspath.size());
|
||||
}
|
||||
|
||||
context.setResolveBindings(true);
|
||||
|
||||
Path hintsFile = inputDir.resolve("hints.json");
|
||||
|
||||
@@ -161,7 +161,7 @@ public class RegressionTest {
|
||||
if (scenario.inputPath() == null) System.out.println("inputPath is NULL");
|
||||
if (tempDir == null) System.out.println("tempDir is NULL");
|
||||
|
||||
exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml", "dot", "scxml", "json"), true, scenario.activeProfiles(), null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, true);
|
||||
exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml", "dot", "scxml", "json"), true, scenario.activeProfiles(), null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
|
||||
|
||||
// Find the generated directory (it might be named with FQN)
|
||||
List<Path> generatedDirs;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class GeneratedSourceDiscoveryTest {
|
||||
|
||||
private final GeneratedSourceDiscovery discovery = new GeneratedSourceDiscovery();
|
||||
|
||||
@Nested
|
||||
class MavenTarget {
|
||||
|
||||
@Test
|
||||
void shouldDiscoverMapStructLikeSourcesUnderTarget(@TempDir Path tempDir) throws IOException {
|
||||
Path module = tempDir.resolve("service");
|
||||
Path generated = module.resolve("target/generated-sources/annotations/com/acme/mapper");
|
||||
Files.createDirectories(generated);
|
||||
Files.writeString(generated.resolve("OrderMapperImpl.java"), """
|
||||
package com.acme.mapper;
|
||||
public class OrderMapperImpl implements OrderMapper {}
|
||||
""");
|
||||
|
||||
assertThat(discovery.discoverSourceRoots(module))
|
||||
.containsExactly(module.resolve("target").normalize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreCompiledClassesUnderTarget(@TempDir Path tempDir) throws IOException {
|
||||
Path module = tempDir.resolve("service");
|
||||
Path classes = module.resolve("target/classes/com/acme");
|
||||
Files.createDirectories(classes);
|
||||
Files.writeString(classes.resolve("Accidental.java"), "class Accidental {}");
|
||||
|
||||
assertThat(discovery.discoverSourceRoots(module)).isEmpty();
|
||||
assertThat(GeneratedSourceDiscovery.isExcludedBuildPath(module, classes.resolve("Accidental.java")))
|
||||
.isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class GradleBuild {
|
||||
|
||||
@Test
|
||||
void shouldDiscoverAnnotationProcessorOutput(@TempDir Path tempDir) throws IOException {
|
||||
Path module = tempDir.resolve("app");
|
||||
Path generated = module.resolve(
|
||||
"build/generated/sources/annotationProcessor/java/main/com/acme/config");
|
||||
Files.createDirectories(generated);
|
||||
Files.writeString(generated.resolve("ApplicationConfigImpl.java"), """
|
||||
package com.acme.config;
|
||||
public class ApplicationConfigImpl {}
|
||||
""");
|
||||
|
||||
assertThat(discovery.discoverSourceRoots(module))
|
||||
.containsExactly(module.resolve("build").normalize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreGradleBuildClasses(@TempDir Path tempDir) throws IOException {
|
||||
Path module = tempDir.resolve("app");
|
||||
Path classes = module.resolve("build/classes/java/main/com/acme");
|
||||
Files.createDirectories(classes);
|
||||
Files.writeString(classes.resolve("ShouldSkip.java"), "class ShouldSkip {}");
|
||||
|
||||
assertThat(discovery.discoverSourceRoots(module)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreGradleTestAnnotationProcessorOutput(@TempDir Path tempDir) throws IOException {
|
||||
Path module = tempDir.resolve("app");
|
||||
Path generated = module.resolve(
|
||||
"build/generated/sources/annotationProcessor/java/test/com/acme/testsupport");
|
||||
Files.createDirectories(generated);
|
||||
Files.writeString(generated.resolve("TestSupportImpl.java"), "class TestSupportImpl {}");
|
||||
|
||||
assertThat(discovery.discoverSourceRoots(module)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class TestOutputExclusion {
|
||||
|
||||
@Test
|
||||
void shouldIgnoreMavenGeneratedTestSources(@TempDir Path tempDir) throws IOException {
|
||||
Path module = tempDir.resolve("service");
|
||||
Path generated = module.resolve("target/generated-test-sources/test-annotations/com/acme");
|
||||
Files.createDirectories(generated);
|
||||
Files.writeString(generated.resolve("TestMapperImpl.java"), "class TestMapperImpl {}");
|
||||
|
||||
assertThat(discovery.discoverSourceRoots(module)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreTestClassesUnderTarget(@TempDir Path tempDir) throws IOException {
|
||||
Path module = tempDir.resolve("service");
|
||||
Path testClasses = module.resolve("target/test-classes/com/acme");
|
||||
Files.createDirectories(testClasses);
|
||||
Files.writeString(testClasses.resolve("OrderServiceTest.java"), "class OrderServiceTest {}");
|
||||
|
||||
assertThat(discovery.discoverSourceRoots(module)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Integration {
|
||||
|
||||
@Test
|
||||
void shouldAddGeneratedRootsToProjectScanPaths(@TempDir Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("monorepo");
|
||||
Files.createDirectories(root.resolve(".git"));
|
||||
Files.writeString(root.resolve("settings.gradle"), "include 'api'");
|
||||
Path api = root.resolve("api");
|
||||
Files.createDirectories(api);
|
||||
Files.writeString(api.resolve("build.gradle"), "");
|
||||
Path generated = api.resolve("target/generated-sources/annotations/com/example");
|
||||
Files.createDirectories(generated);
|
||||
Files.writeString(generated.resolve("GeneratedBean.java"), """
|
||||
package com.example;
|
||||
public class GeneratedBean {}
|
||||
""");
|
||||
|
||||
StaticBuildFileAnalyzer analyzer = new StaticBuildFileAnalyzer();
|
||||
ProjectModuleGraph graph = analyzer.analyze(root);
|
||||
|
||||
assertThat(graph.resolveScanPaths(root)).contains(api.resolve("target").normalize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSkipGeneratedRootsWhenDisabled(@TempDir Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("monorepo");
|
||||
Files.createDirectories(root.resolve(".git"));
|
||||
Files.writeString(root.resolve("settings.gradle"), "include 'api'");
|
||||
Path api = root.resolve("api");
|
||||
Files.createDirectories(api);
|
||||
Files.writeString(api.resolve("build.gradle"), "");
|
||||
Path generated = api.resolve("target/generated-sources/annotations/com/example");
|
||||
Files.createDirectories(generated);
|
||||
Files.writeString(generated.resolve("GeneratedBean.java"), "package com.example; class GeneratedBean {}");
|
||||
|
||||
ProjectModuleGraph graph = new StaticBuildFileAnalyzer().analyze(root);
|
||||
|
||||
assertThat(graph.resolveScanPaths(root, false)).doesNotContain(api.resolve("target").normalize());
|
||||
assertThat(graph.resolveScanPaths(root, true)).contains(api.resolve("target").normalize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIndexGeneratedTypesWhenScanningModule() throws IOException {
|
||||
Path module = Files.createTempDirectory("sme-gen-src").resolve("service");
|
||||
Path generated = module.resolve("target/generated-sources/annotations/com/acme");
|
||||
Files.createDirectories(generated);
|
||||
Files.writeString(generated.resolve("GeneratedEvent.java"), """
|
||||
package com.acme;
|
||||
public enum GeneratedEvent { PAY, SHIP }
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(Set.of(module), Set.of());
|
||||
|
||||
assertThat(context.getCompilationUnits()).hasSize(1);
|
||||
assertThat(context.getEnumValues("GeneratedEvent"))
|
||||
.contains("com.acme.GeneratedEvent.PAY", "com.acme.GeneratedEvent.SHIP");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class StaticBuildFileAnalyzerTest {
|
||||
|
||||
private final StaticBuildFileAnalyzer analyzer = new StaticBuildFileAnalyzer();
|
||||
|
||||
@Nested
|
||||
class GradleIncludes {
|
||||
|
||||
@Test
|
||||
void shouldParseSettingsGradleIncludeList(@TempDir Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("monorepo");
|
||||
Files.createDirectories(root);
|
||||
Files.writeString(root.resolve("settings.gradle"), """
|
||||
rootProject.name = 'monorepo'
|
||||
include 'module-a', 'module-b', 'module-c'
|
||||
""");
|
||||
createGradleModule(root, "module-a");
|
||||
createGradleModule(root, "module-b");
|
||||
createGradleModule(root, "module-c");
|
||||
|
||||
Map<String, Path> includes = analyzer.parseGradleIncludes(root);
|
||||
|
||||
assertThat(includes).hasSize(3);
|
||||
assertThat(includes.get("module-a")).isEqualTo(root.resolve("module-a").normalize());
|
||||
assertThat(includes.get("module-b")).isEqualTo(root.resolve("module-b").normalize());
|
||||
assertThat(includes.get("module-c")).isEqualTo(root.resolve("module-c").normalize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldParseSettingsGradleKtsIncludeCall(@TempDir Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("monorepo");
|
||||
Files.createDirectories(root);
|
||||
Files.writeString(root.resolve("settings.gradle.kts"), """
|
||||
include(":api", ":core")
|
||||
""");
|
||||
createGradleModule(root, "api");
|
||||
createGradleModule(root, "core");
|
||||
|
||||
Map<String, Path> includes = analyzer.parseGradleIncludes(root);
|
||||
|
||||
assertThat(includes).containsKeys("api", "core");
|
||||
assertThat(includes.get("api")).isEqualTo(root.resolve("api").normalize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveNestedIncludePaths(@TempDir Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("monorepo");
|
||||
Path nested = root.resolve("state_machines/multi/api");
|
||||
Files.createDirectories(nested);
|
||||
Files.writeString(root.resolve("settings.gradle"), """
|
||||
include ':state_machines:multi:api', ':state_machines:multi:core'
|
||||
""");
|
||||
createGradleModule(root, "state_machines/multi/api");
|
||||
createGradleModule(root, "state_machines/multi/core");
|
||||
|
||||
Map<String, Path> includes = analyzer.parseGradleIncludes(root);
|
||||
|
||||
assertThat(includes.get("state_machines:multi:api"))
|
||||
.isEqualTo(root.resolve("state_machines/multi/api").normalize());
|
||||
assertThat(includes.get("state_machines:multi:core"))
|
||||
.isEqualTo(root.resolve("state_machines/multi/core").normalize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHonorCustomProjectDirMapping(@TempDir Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("monorepo");
|
||||
Path custom = root.resolve("custom-location/service");
|
||||
Files.createDirectories(custom);
|
||||
Files.writeString(root.resolve("settings.gradle"), """
|
||||
include ':service'
|
||||
project(':service').projectDir = file('custom-location/service')
|
||||
""");
|
||||
createGradleModule(custom, ".");
|
||||
|
||||
Map<String, Path> includes = analyzer.parseGradleIncludes(root);
|
||||
|
||||
assertThat(includes.get("service")).isEqualTo(custom.normalize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDiscoverAllIncludedModulesWhenScanningFromRoot(@TempDir Path tempDir) throws IOException {
|
||||
Path root = writeGradleMonorepo(tempDir);
|
||||
|
||||
ProjectModuleGraph graph = analyzer.analyze(root.resolve("module-a"));
|
||||
Set<Path> scanPaths = graph.resolveScanPaths(root);
|
||||
|
||||
assertThat(scanPaths).containsExactlyInAnyOrder(
|
||||
root.normalize(),
|
||||
root.resolve("module-a").normalize(),
|
||||
root.resolve("module-b").normalize(),
|
||||
root.resolve("module-c").normalize());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class GradleDependencyGraph {
|
||||
|
||||
@Test
|
||||
void shouldBuildTransitiveProjectDependencyGraph(@TempDir Path tempDir) throws IOException {
|
||||
Path root = writeGradleMonorepo(tempDir);
|
||||
|
||||
ProjectModuleGraph graph = analyzer.analyze(root.resolve("module-a"));
|
||||
|
||||
List<ProjectModuleGraph.ModuleDependency> localDeps = graph.dependencies().stream()
|
||||
.filter(dep -> dep.kind() == ProjectModuleGraph.DependencyKind.LOCAL_PROJECT)
|
||||
.toList();
|
||||
|
||||
assertThat(localDeps).anyMatch(dep ->
|
||||
dep.fromModuleId().contains("module-a") && dep.toModuleId().contains("module-b"));
|
||||
assertThat(localDeps).anyMatch(dep ->
|
||||
dep.fromModuleId().contains("module-b") && dep.toModuleId().contains("module-c"));
|
||||
|
||||
Set<Path> siblings = new SiblingDependencyResolver().resolveSiblings(graph, root.resolve("module-a"));
|
||||
assertThat(siblings).containsExactlyInAnyOrder(
|
||||
root.resolve("module-b").normalize(),
|
||||
root.resolve("module-c").normalize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldParseBuildGradleKtsProjectDependencies(@TempDir Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("monorepo");
|
||||
Files.createDirectories(root);
|
||||
Files.createDirectories(root.resolve(".git"));
|
||||
Files.writeString(root.resolve("settings.gradle.kts"), "include(\":app\", \":lib\")");
|
||||
Files.createDirectories(root.resolve("app"));
|
||||
Files.writeString(root.resolve("app/build.gradle.kts"), """
|
||||
dependencies {
|
||||
implementation(project(":lib"))
|
||||
}
|
||||
""");
|
||||
Files.createDirectories(root.resolve("lib"));
|
||||
Files.writeString(root.resolve("lib/build.gradle.kts"), "");
|
||||
|
||||
ProjectModuleGraph graph = analyzer.analyze(root.resolve("app"));
|
||||
boolean hasLibDep = graph.dependencies().stream()
|
||||
.anyMatch(dep -> dep.kind() == ProjectModuleGraph.DependencyKind.LOCAL_PROJECT
|
||||
&& dep.fromModuleId().contains("app")
|
||||
&& dep.toModuleId().contains("lib"));
|
||||
assertThat(hasLibDep).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreTestImplementationProjectDependencies(@TempDir Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("monorepo");
|
||||
Files.createDirectories(root.resolve(".git"));
|
||||
Files.writeString(root.resolve("settings.gradle"), "include 'app', 'lib', 'test-lib'");
|
||||
createGradleModule(root, "app");
|
||||
createGradleModule(root, "lib");
|
||||
createGradleModule(root, "test-lib");
|
||||
Files.writeString(root.resolve("app/build.gradle"), """
|
||||
dependencies {
|
||||
implementation project(':lib')
|
||||
testImplementation project(':test-lib')
|
||||
}
|
||||
""");
|
||||
|
||||
ProjectModuleGraph graph = analyzer.analyze(root.resolve("app"));
|
||||
List<ProjectModuleGraph.ModuleDependency> localDeps = graph.dependencies().stream()
|
||||
.filter(dep -> dep.kind() == ProjectModuleGraph.DependencyKind.LOCAL_PROJECT)
|
||||
.toList();
|
||||
|
||||
assertThat(localDeps).anyMatch(dep -> dep.toModuleId().contains("lib"));
|
||||
assertThat(localDeps).noneMatch(dep -> dep.toModuleId().contains("test-lib"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class MavenCoordinates {
|
||||
|
||||
@Test
|
||||
void shouldIndexModulesByGroupIdAndArtifactId(@TempDir Path tempDir) throws IOException {
|
||||
Path root = writeMavenMultiModule(tempDir);
|
||||
|
||||
ProjectModuleGraph graph = analyzer.analyze(root.resolve("module-a"));
|
||||
|
||||
assertThat(graph.modulesByCoordinates()).containsKeys(
|
||||
new ProjectModuleGraph.MavenCoordinates("com.acme", "module-a"),
|
||||
new ProjectModuleGraph.MavenCoordinates("com.acme", "module-b"),
|
||||
new ProjectModuleGraph.MavenCoordinates("com.acme", "module-c"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDistinguishSameArtifactIdUnderDifferentGroups(@TempDir Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("repo");
|
||||
Files.createDirectories(root.resolve(".git"));
|
||||
writePom(root.resolve("pom.xml"), """
|
||||
<project>
|
||||
<groupId>com.repo</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<modules>
|
||||
<module>team-a/api</module>
|
||||
<module>team-b/api</module>
|
||||
<module>consumer</module>
|
||||
</modules>
|
||||
</project>
|
||||
""");
|
||||
writePom(root.resolve("team-a/api/pom.xml"), """
|
||||
<project>
|
||||
<groupId>com.team.a</groupId>
|
||||
<artifactId>api</artifactId>
|
||||
</project>
|
||||
""");
|
||||
writePom(root.resolve("team-b/api/pom.xml"), """
|
||||
<project>
|
||||
<groupId>com.team.b</groupId>
|
||||
<artifactId>api</artifactId>
|
||||
</project>
|
||||
""");
|
||||
writePom(root.resolve("consumer/pom.xml"), """
|
||||
<project>
|
||||
<groupId>com.consumer</groupId>
|
||||
<artifactId>consumer</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.team.a</groupId>
|
||||
<artifactId>api</artifactId>
|
||||
<version>1.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""");
|
||||
|
||||
ProjectModuleGraph graph = analyzer.analyze(root.resolve("consumer"));
|
||||
|
||||
List<ProjectModuleGraph.ModuleDependency> deps = graph.localDependenciesOf(
|
||||
graph.modulesByCoordinates()
|
||||
.get(new ProjectModuleGraph.MavenCoordinates("com.consumer", "consumer"))
|
||||
.id());
|
||||
|
||||
assertThat(deps).hasSize(1);
|
||||
assertThat(deps.get(0).toModuleId()).isEqualTo(
|
||||
graph.modulesByCoordinates().get(new ProjectModuleGraph.MavenCoordinates("com.team.a", "api")).id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMarkExternalLibraryWhenCoordinatesAreNotInRepo(@TempDir Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("repo");
|
||||
Files.createDirectories(root.resolve(".git"));
|
||||
writePom(root.resolve("service/pom.xml"), """
|
||||
<project>
|
||||
<groupId>com.acme</groupId>
|
||||
<artifactId>service</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
<version>3.2.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""");
|
||||
|
||||
ProjectModuleGraph graph = analyzer.analyze(root.resolve("service"));
|
||||
String serviceId = graph.modulesByCoordinates()
|
||||
.get(new ProjectModuleGraph.MavenCoordinates("com.acme", "service")).id();
|
||||
|
||||
assertThat(graph.dependencies()).anyMatch(dep ->
|
||||
dep.fromModuleId().equals(serviceId)
|
||||
&& dep.kind() == ProjectModuleGraph.DependencyKind.EXTERNAL_LIBRARY
|
||||
&& dep.externalCoordinates().key().equals("org.springframework.boot:spring-boot-starter"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreTestScopedMavenDependencies(@TempDir Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("repo");
|
||||
Files.createDirectories(root.resolve(".git"));
|
||||
writePom(root.resolve("pom.xml"), """
|
||||
<project>
|
||||
<groupId>com.repo</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<modules><module>app</module><module>test-support</module></modules>
|
||||
</project>
|
||||
""");
|
||||
writePom(root.resolve("app/pom.xml"), """
|
||||
<project>
|
||||
<groupId>com.repo</groupId>
|
||||
<artifactId>app</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.repo</groupId>
|
||||
<artifactId>test-support</artifactId>
|
||||
<version>1.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""");
|
||||
writePom(root.resolve("test-support/pom.xml"), """
|
||||
<project><groupId>com.repo</groupId><artifactId>test-support</artifactId></project>
|
||||
""");
|
||||
|
||||
ProjectModuleGraph graph = analyzer.analyze(root.resolve("app"));
|
||||
String appId = graph.modulesByCoordinates()
|
||||
.get(new ProjectModuleGraph.MavenCoordinates("com.repo", "app")).id();
|
||||
|
||||
assertThat(graph.localDependenciesOf(appId)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class MavenModules {
|
||||
|
||||
@Test
|
||||
void shouldDiscoverAggregatorModules(@TempDir Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("aggregator");
|
||||
Files.createDirectories(root.resolve(".git"));
|
||||
writePom(root.resolve("pom.xml"), """
|
||||
<project>
|
||||
<groupId>com.acme</groupId>
|
||||
<artifactId>root</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<modules>
|
||||
<module>service-x</module>
|
||||
<module>service-y</module>
|
||||
</modules>
|
||||
</project>
|
||||
""");
|
||||
writePom(root.resolve("service-x/pom.xml"), """
|
||||
<project><groupId>com.acme</groupId><artifactId>service-x</artifactId></project>
|
||||
""");
|
||||
writePom(root.resolve("service-y/pom.xml"), """
|
||||
<project><groupId>com.acme</groupId><artifactId>service-y</artifactId></project>
|
||||
""");
|
||||
|
||||
ProjectModuleGraph graph = analyzer.analyze(root);
|
||||
Set<Path> siblings = new SiblingDependencyResolver().resolveSiblings(graph, root);
|
||||
|
||||
assertThat(siblings).containsExactlyInAnyOrder(
|
||||
root.resolve("service-x").normalize(),
|
||||
root.resolve("service-y").normalize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveTransitiveMavenModuleDependencies(@TempDir Path tempDir) throws IOException {
|
||||
Path root = writeMavenMultiModule(tempDir);
|
||||
|
||||
Set<Path> siblings = new SiblingDependencyResolver().resolveSiblings(root.resolve("module-a"));
|
||||
|
||||
assertThat(siblings).containsExactlyInAnyOrder(
|
||||
root.resolve("module-b").normalize(),
|
||||
root.resolve("module-c").normalize(),
|
||||
root.normalize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveParentViaRelativePath(@TempDir Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("root");
|
||||
Path child = tempDir.resolve("child");
|
||||
Files.createDirectories(root);
|
||||
Files.createDirectories(child);
|
||||
writePom(root.resolve("pom.xml"), """
|
||||
<project><groupId>com.acme</groupId><artifactId>root</artifactId></project>
|
||||
""");
|
||||
writePom(child.resolve("pom.xml"), """
|
||||
<project>
|
||||
<parent>
|
||||
<relativePath>../root/pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>child</artifactId>
|
||||
</project>
|
||||
""");
|
||||
|
||||
Set<Path> scanPaths = analyzer.analyze(child).resolveScanPaths(child);
|
||||
|
||||
assertThat(scanPaths).contains(root.toAbsolutePath().normalize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreferNestedMavenAggregatorOverOuterGradleRoot(@TempDir Path tempDir) throws IOException {
|
||||
Path outerGradle = tempDir.resolve("outer");
|
||||
Path nestedMaven = outerGradle.resolve("nested-maven");
|
||||
Files.createDirectories(outerGradle.resolve(".git"));
|
||||
Files.writeString(outerGradle.resolve("settings.gradle"), "rootProject.name = 'outer'");
|
||||
writePom(nestedMaven.resolve("pom.xml"), """
|
||||
<project>
|
||||
<groupId>com.nested</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<modules><module>api</module></modules>
|
||||
</project>
|
||||
""");
|
||||
writePom(nestedMaven.resolve("api/pom.xml"), """
|
||||
<project><groupId>com.nested</groupId><artifactId>api</artifactId></project>
|
||||
""");
|
||||
|
||||
ProjectModuleGraph graph = analyzer.analyze(nestedMaven.resolve("api"));
|
||||
|
||||
assertThat(graph.workspaceRoot()).isEqualTo(nestedMaven.normalize());
|
||||
assertThat(graph.allModuleDirectories()).containsExactlyInAnyOrder(
|
||||
nestedMaven.resolve("api").normalize(),
|
||||
nestedMaven.normalize());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class ExtractHelpers {
|
||||
|
||||
@Test
|
||||
void shouldExtractGradleIncludeTokensFromMixedSyntax() {
|
||||
String settings = """
|
||||
pluginManagement {}
|
||||
include ':api'
|
||||
include(":core", ":infra")
|
||||
include 'web', 'worker'
|
||||
""";
|
||||
|
||||
assertThat(StaticBuildFileAnalyzer.extractGradleIncludePaths(settings))
|
||||
.containsExactlyInAnyOrder("api", "core", "infra", "web", "worker");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExtractOwnMavenCoordinatesSkippingParentSection() {
|
||||
String pom = """
|
||||
<project>
|
||||
<parent>
|
||||
<groupId>com.parent</groupId>
|
||||
<artifactId>parent</artifactId>
|
||||
</parent>
|
||||
<artifactId>child</artifactId>
|
||||
<groupId>com.child</groupId>
|
||||
</project>
|
||||
""";
|
||||
|
||||
assertThat(StaticBuildFileAnalyzer.extractOwnMavenCoordinates(pom))
|
||||
.contains(new ProjectModuleGraph.MavenCoordinates("com.child", "child"));
|
||||
}
|
||||
}
|
||||
|
||||
private static Path writeGradleMonorepo(Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("my-root-project");
|
||||
Files.createDirectories(root);
|
||||
Files.createDirectories(root.resolve(".git"));
|
||||
Files.writeString(root.resolve("settings.gradle"), "include 'module-a', 'module-b', 'module-c'");
|
||||
createGradleModule(root, "module-a");
|
||||
createGradleModule(root, "module-b");
|
||||
createGradleModule(root, "module-c");
|
||||
Files.writeString(root.resolve("module-a/build.gradle"), "dependencies { implementation project(':module-b') }");
|
||||
Files.writeString(root.resolve("module-b/build.gradle"), "dependencies { implementation project(':module-c') }");
|
||||
return root;
|
||||
}
|
||||
|
||||
private static Path writeMavenMultiModule(Path tempDir) throws IOException {
|
||||
Path root = tempDir.resolve("my-maven-root");
|
||||
Files.createDirectories(root.resolve(".git"));
|
||||
writePom(root.resolve("pom.xml"), """
|
||||
<project><groupId>com.acme</groupId><artifactId>root</artifactId></project>
|
||||
""");
|
||||
writePom(root.resolve("module-a/pom.xml"), """
|
||||
<project>
|
||||
<groupId>com.acme</groupId>
|
||||
<artifactId>module-a</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.acme</groupId>
|
||||
<artifactId>module-b</artifactId>
|
||||
<version>1.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""");
|
||||
writePom(root.resolve("module-b/pom.xml"), """
|
||||
<project>
|
||||
<groupId>com.acme</groupId>
|
||||
<artifactId>module-b</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.acme</groupId>
|
||||
<artifactId>module-c</artifactId>
|
||||
<version>1.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
""");
|
||||
writePom(root.resolve("module-c/pom.xml"), """
|
||||
<project><groupId>com.acme</groupId><artifactId>module-c</artifactId></project>
|
||||
""");
|
||||
return root;
|
||||
}
|
||||
|
||||
private static void createGradleModule(Path root, String modulePath) throws IOException {
|
||||
Path moduleDir = ".".equals(modulePath) ? root : root.resolve(modulePath);
|
||||
Files.createDirectories(moduleDir);
|
||||
Path buildFile = moduleDir.resolve("build.gradle");
|
||||
if (!Files.exists(buildFile)) {
|
||||
Files.writeString(buildFile, "");
|
||||
}
|
||||
}
|
||||
|
||||
private static void writePom(Path pomFile, String content) throws IOException {
|
||||
Files.createDirectories(pomFile.getParent());
|
||||
Files.writeString(pomFile, content);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +1,31 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@Tag("dynamic_classpath_resolver")
|
||||
class DynamicClasspathResolverTest {
|
||||
|
||||
@Test
|
||||
void shouldResolveMavenClasspathDynamically(@TempDir Path tempDir) throws IOException {
|
||||
// Create dummy pom.xml
|
||||
void shouldNeverResolveExternalJarsEvenWhenMavenProject(@TempDir Path tempDir) throws Exception {
|
||||
Files.createFile(tempDir.resolve("pom.xml"));
|
||||
|
||||
// Mock the resolver so it doesn't actually run maven, but writes the expected output file
|
||||
DynamicClasspathResolver resolver = new DynamicClasspathResolver() {
|
||||
@Override
|
||||
protected void runProcess(ProcessBuilder pb) throws IOException {
|
||||
// Pretend maven executed and generated the cp.txt
|
||||
String cp = "/fake/m2/repo/spring-core.jar" + File.pathSeparator + "/fake/m2/repo/spring-context.jar";
|
||||
|
||||
String outputFileArg = pb.command().stream()
|
||||
.filter(arg -> arg.startsWith("-Dmdep.outputFile="))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
Path outputFile = pb.directory().toPath().resolve(outputFileArg.substring("-Dmdep.outputFile=".length()));
|
||||
|
||||
Files.writeString(outputFile, cp);
|
||||
}
|
||||
};
|
||||
|
||||
List<String> classpath = resolver.resolveClasspath(tempDir);
|
||||
|
||||
assertThat(classpath).containsExactly(
|
||||
"/fake/m2/repo/spring-core.jar",
|
||||
"/fake/m2/repo/spring-context.jar"
|
||||
);
|
||||
assertThat(new DynamicClasspathResolver().resolveClasspath(tempDir)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveGradleClasspathDynamically(@TempDir Path tempDir) throws IOException {
|
||||
// Create dummy build.gradle
|
||||
void shouldNeverResolveExternalJarsEvenWhenGradleProject(@TempDir Path tempDir) throws Exception {
|
||||
Files.createFile(tempDir.resolve("build.gradle"));
|
||||
|
||||
DynamicClasspathResolver resolver = new DynamicClasspathResolver() {
|
||||
@Override
|
||||
protected String runProcessAndGetOutput(ProcessBuilder pb) {
|
||||
// Pretend gradle executed and printed the marker
|
||||
String cp = "/fake/gradle/caches/spring-core.jar" + File.pathSeparator + "/fake/gradle/caches/spring-context.jar";
|
||||
return "Some noisy gradle output\n" +
|
||||
"SME_CLASSPATH_MARKER:" + cp + "\n" +
|
||||
"BUILD SUCCESSFUL\n";
|
||||
}
|
||||
};
|
||||
|
||||
List<String> classpath = resolver.resolveClasspath(tempDir);
|
||||
|
||||
assertThat(classpath).containsExactly(
|
||||
"/fake/gradle/caches/spring-core.jar",
|
||||
"/fake/gradle/caches/spring-context.jar"
|
||||
);
|
||||
assertThat(new DynamicClasspathResolver().resolveClasspath(tempDir)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyListIfNoBuildFileFound(@TempDir Path tempDir) {
|
||||
DynamicClasspathResolver resolver = new DynamicClasspathResolver();
|
||||
List<String> classpath = resolver.resolveClasspath(tempDir);
|
||||
List<String> classpath = new DynamicClasspathResolver().resolveClasspath(tempDir);
|
||||
assertThat(classpath).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnal
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.ToolingClasspath;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.DynamicClasspathResolver;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -33,12 +33,7 @@ class JdtCallGraphEngineIntegrationTest {
|
||||
context = new CodebaseContext();
|
||||
context.setProjectRoot(projectRoot);
|
||||
context.setSourcepath(List.of(projectRoot.resolve("src/main/java").toString()));
|
||||
|
||||
DynamicClasspathResolver resolver = new DynamicClasspathResolver();
|
||||
List<String> cp = resolver.resolveClasspath(projectRoot);
|
||||
if (!cp.isEmpty()) {
|
||||
context.setClasspath(cp);
|
||||
}
|
||||
context.setClasspath(ToolingClasspath.currentJvmJarEntries());
|
||||
context.setResolveBindings(true);
|
||||
|
||||
context.scan(Set.of(projectRoot), Collections.emptySet());
|
||||
@@ -290,12 +285,7 @@ class JdtCallGraphEngineIntegrationTest {
|
||||
CodebaseContext ctx = new CodebaseContext();
|
||||
ctx.setProjectRoot(projectRoot);
|
||||
ctx.setSourcepath(List.of(projectRoot.resolve("src/main/java").toString()));
|
||||
|
||||
DynamicClasspathResolver resolver = new DynamicClasspathResolver();
|
||||
List<String> cp = resolver.resolveClasspath(projectRoot);
|
||||
if (!cp.isEmpty()) {
|
||||
ctx.setClasspath(cp);
|
||||
}
|
||||
ctx.setClasspath(ToolingClasspath.currentJvmJarEntries());
|
||||
ctx.setResolveBindings(true);
|
||||
ctx.scan(Set.of(projectRoot), Collections.emptySet());
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class LayeredDispatcherSampleTest {
|
||||
ExportService exportService = new ExportService(List.of(new JsonExporter()));
|
||||
exportService.runExporter(sampleRoot, tempDir, List.of("json"), true, List.of(), null, null,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, true);
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JsonNode orderMachine = readMachineJson(tempDir, "StandardOrderStateMachineConfiguration", mapper);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.spring;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.service.ToolingClasspath;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.DynamicClasspathResolver;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -25,12 +25,7 @@ class SpringContextScannerIntegrationTest {
|
||||
context = new CodebaseContext();
|
||||
context.setProjectRoot(projectRoot);
|
||||
context.setSourcepath(List.of(projectRoot.resolve("src/main/java").toString()));
|
||||
|
||||
DynamicClasspathResolver resolver = new DynamicClasspathResolver();
|
||||
List<String> cp = resolver.resolveClasspath(projectRoot);
|
||||
if (!cp.isEmpty()) {
|
||||
context.setClasspath(cp);
|
||||
}
|
||||
context.setClasspath(ToolingClasspath.currentJvmJarEntries());
|
||||
context.setResolveBindings(true);
|
||||
|
||||
context.scan(Set.of(projectRoot), Collections.emptySet());
|
||||
|
||||
Reference in New Issue
Block a user