diff --git a/state_machine_exporter/build.gradle b/state_machine_exporter/build.gradle index d335c9e..81015bb 100644 --- a/state_machine_exporter/build.gradle +++ b/state_machine_exporter/build.gradle @@ -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') { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/GeneratedSourceDiscovery.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/GeneratedSourceDiscovery.java new file mode 100644 index 0000000..c32a832 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/GeneratedSourceDiscovery.java @@ -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 BUILD_ROOTS = List.of("target", "build"); + + private static final List 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 discoverSourceRoots(Collection moduleDirectories) { + Set 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 discoverSourceRoots(Path moduleDirectory) { + Set 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 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-"); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ProjectModuleGraph.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ProjectModuleGraph.java new file mode 100644 index 0000000..2a74ce6 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ProjectModuleGraph.java @@ -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 coordinates() { + return Optional.ofNullable(mavenCoordinates); + } + } + + public record ModuleDependency( + String fromModuleId, + String toModuleId, + MavenCoordinates externalCoordinates, + DependencyKind kind) { + } + + private final Path workspaceRoot; + private final Map modulesById; + private final Map modulesByCoordinates; + private final List dependencies; + + public ProjectModuleGraph( + Path workspaceRoot, + Map modulesById, + Map modulesByCoordinates, + List 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 modulesById() { + return modulesById; + } + + public Map modulesByCoordinates() { + return modulesByCoordinates; + } + + public List dependencies() { + return dependencies; + } + + public Optional 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 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 allModuleDirectories() { + Set 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 resolveScanPaths(Path inputDir) { + return resolveScanPaths(inputDir, true); + } + + public Set resolveScanPaths(Path inputDir, boolean includeGeneratedSources) { + if (inputDir == null) { + return Set.of(); + } + Set scanPaths = new LinkedHashSet<>(); + Path normalizedInput = inputDir.toAbsolutePath().normalize(); + scanPaths.add(normalizedInput); + + if (workspaceRoot != null && normalizedInput.equals(workspaceRoot)) { + scanPaths.addAll(allModuleDirectories()); + } + + Optional 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 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( + ".*?(.*?).*?", 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 transitiveLocalModuleDirectories(String rootModuleId) { + Set reachable = new LinkedHashSet<>(); + Queue queue = new ArrayDeque<>(); + Set 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 localDependenciesOf(String moduleId) { + List result = new ArrayList<>(); + for (ModuleDependency dependency : dependencies) { + if (moduleId.equals(dependency.fromModuleId())) { + result.add(dependency); + } + } + return result; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/SiblingDependencyResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/SiblingDependencyResolver.java index 887427d..3060617 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/SiblingDependencyResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/SiblingDependencyResolver.java @@ -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("\\s*(.*?)\\s*(.*?)\\s*(.*?)", Pattern.DOTALL); + private final StaticBuildFileAnalyzer analyzer = new StaticBuildFileAnalyzer(); + + public ProjectModuleGraph analyzeProject(Path inputDir) throws IOException { + return analyzer.analyze(inputDir); + } public Set resolveSiblings(Path projectDir) { - Set allSiblings = new HashSet<>(); - Queue queue = new LinkedList<>(); - queue.add(projectDir); - - while (!queue.isEmpty()) { - Path current = queue.poll(); - Set 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 resolveGradleSiblings(Path projectDir) throws IOException { - Set 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 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 resolveSiblings(ProjectModuleGraph graph, Path inputDir) { + Set scanPaths = graph.resolveScanPaths(inputDir); + Set 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 resolveMavenSiblings(Path projectDir) throws IOException { - Set 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 targetArtifacts = new HashSet<>(); - Matcher matcher = Pattern.compile("(.*?)").matcher(content); - // We want artifacts from the section - int depsStart = content.indexOf(""); - int depsEnd = content.indexOf(""); - if (depsStart != -1 && depsEnd > depsStart) { - String depsContent = content.substring(depsStart, depsEnd); - Matcher depMatcher = Pattern.compile("(.*?)").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 paths = Files.walk(rootDir)) { - List 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 directories declared in this pom - Matcher moduleMatcher = Pattern.compile("(.*?)").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 : {}", moduleDir); - siblings.add(moduleDir); - } - } - - // Also add parent dir if it has a pom.xml - Path parentDir = projectDir.getParent(); - - // Check for explicit in parent block - Matcher parentRelativePathMatcher = Pattern.compile(".*?(.*?).*?", 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 block if it exists - String content = pomContent; - int parentEnd = pomContent.indexOf(""); - if (parentEnd != -1) { - content = pomContent.substring(parentEnd); - } - - Matcher matcher = Pattern.compile("(.*?)").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; - } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/StaticBuildFileAnalyzer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/StaticBuildFileAnalyzer.java new file mode 100644 index 0000000..c647174 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/StaticBuildFileAnalyzer.java @@ -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("(.*?)"); + private static final Pattern MAVEN_DEPENDENCY_BLOCK = Pattern.compile("(.*?)", Pattern.DOTALL); + private static final Pattern MAVEN_TAG = Pattern.compile("<(groupId|artifactId)>(.*?)"); + + 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 modulesById = new LinkedHashMap<>(); + Map modulesByCoordinates = new LinkedHashMap<>(); + List dependencies = new ArrayList<>(); + + Path gradleSettingsRoot = Files.exists(workspaceRoot.resolve("settings.gradle")) + || Files.exists(workspaceRoot.resolve("settings.gradle.kts")) + ? workspaceRoot + : indexRoot; + Map gradleModulePaths = parseGradleIncludes(gradleSettingsRoot); + for (Map.Entry entry : gradleModulePaths.entrySet()) { + registerGradleModule(entry.getKey(), entry.getValue(), modulesById); + } + + List 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 parseGradleIncludes(Path gradleRoot) throws IOException { + Map 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 extractGradleIncludePaths(String settingsContent) { + Set 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 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 modulesById, + Map 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 modulesById, + Map modulesByCoordinates) throws IOException { + String content = Files.readString(pomFile); + Optional coordinates = extractOwnMavenCoordinates(content); + if (coordinates.isEmpty()) { + return; + } + Path directory = pomFile.getParent().normalize(); + + for (Map.Entry 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 extractOwnMavenCoordinates(String pomContent) { + String content = pomContent; + int parentEnd = pomContent.indexOf(""); + 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 extractParentTag(String pomContent, String tag) { + Matcher parentMatcher = Pattern.compile(".*?", Pattern.DOTALL).matcher(pomContent); + if (!parentMatcher.find()) { + return Optional.empty(); + } + return extractFirstTag(parentMatcher.group(), tag); + } + + private static Optional extractFirstTag(String content, String tag) { + Matcher matcher = Pattern.compile("<" + tag + ">(.*?)").matcher(content); + if (matcher.find()) { + return Optional.of(matcher.group(1).trim()); + } + return Optional.empty(); + } + + private List parseGradleDependencies( + ProjectModuleGraph.ProjectModule module, + Map modulesById, + Map gradleModulePaths) throws IOException { + List 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 modulesById, + Map 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 parseMavenDependencies( + Path pomFile, + Map modulesById, + Map modulesByCoordinates) throws IOException { + List result = new ArrayList<>(); + String content = Files.readString(pomFile); + Optional fromCoordinates = extractOwnMavenCoordinates(content); + if (fromCoordinates.isEmpty()) { + return result; + } + ProjectModuleGraph.ProjectModule fromModule = modulesByCoordinates.get(fromCoordinates.get()); + if (fromModule == null) { + return result; + } + + int depsStart = content.indexOf(""); + int depsEnd = content.indexOf(""); + 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 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 findPomFiles(Path workspaceRoot) throws IOException { + if (workspaceRoot == null || !Files.isDirectory(workspaceRoot)) { + return List.of(); + } + try (Stream 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("")) { + 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); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/DynamicClasspathResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/DynamicClasspathResolver.java index a4ae675..9ac0520 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/DynamicClasspathResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/DynamicClasspathResolver.java @@ -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 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 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 allPaths = new java.util.LinkedHashSet<>(); - try (java.util.stream.Stream 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 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 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(); - } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ToolingClasspath.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ToolingClasspath.java new file mode 100644 index 0000000..6927ef7 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ToolingClasspath.java @@ -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 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()); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java index 55143b9..4cb7a5a 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java @@ -75,7 +75,31 @@ public class CodebaseContext { return Collections.unmodifiableList(libraryHints); } - private final Set ignorePatterns = new HashSet<>(Set.of("**/test/**", "**/build/classes/**", "**/build/libs/**", "**/build/tmp/**", "**/build/reports/**", "**/build/test-results/**", "**/node_modules/**", "**/out/**")); + private final Set 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; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java index cdba76c..3d923bf 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java @@ -57,8 +57,8 @@ public class ExporterCommand implements Callable { @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 { 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; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java index 0225dcc..9fba0b7 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java @@ -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 STATE_MACHINE_RETURN_TYPES = Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory"); public void runExporter(Path inputDir, Path outputDir, List 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 selectedFormats, boolean renderChoicesAsDiamonds, List 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 selectedFormats, boolean renderChoicesAsDiamonds, List 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 selectedFormats, boolean renderChoicesAsDiamonds, List 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 selectedFormats, boolean renderChoicesAsDiamonds, List 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 selectedFormats, boolean renderChoicesAsDiamonds, List 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 selectedFormats, boolean renderChoicesAsDiamonds, List 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 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 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 sourcepaths = allPaths.stream() - .map(p -> p.resolve("src/main/java").toString()) - .filter(p -> Files.exists(Path.of(p))) + .flatMap(p -> { + List 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 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 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"); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java index c5b5e84..f02fdab 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java @@ -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 generatedDirs; diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/GeneratedSourceDiscoveryTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/GeneratedSourceDiscoveryTest.java new file mode 100644 index 0000000..4905c97 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/GeneratedSourceDiscoveryTest.java @@ -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"); + } + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/StaticBuildFileAnalyzerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/StaticBuildFileAnalyzerTest.java new file mode 100644 index 0000000..7f29f70 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/StaticBuildFileAnalyzerTest.java @@ -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 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 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 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 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 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 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 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 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"), """ + + com.repo + parent + pom + + team-a/api + team-b/api + consumer + + + """); + writePom(root.resolve("team-a/api/pom.xml"), """ + + com.team.a + api + + """); + writePom(root.resolve("team-b/api/pom.xml"), """ + + com.team.b + api + + """); + writePom(root.resolve("consumer/pom.xml"), """ + + com.consumer + consumer + + + com.team.a + api + 1.0 + + + + """); + + ProjectModuleGraph graph = analyzer.analyze(root.resolve("consumer")); + + List 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"), """ + + com.acme + service + + + org.springframework.boot + spring-boot-starter + 3.2.0 + + + + """); + + 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"), """ + + com.repo + parent + pom + apptest-support + + """); + writePom(root.resolve("app/pom.xml"), """ + + com.repo + app + + + com.repo + test-support + 1.0 + test + + + + """); + writePom(root.resolve("test-support/pom.xml"), """ + com.repotest-support + """); + + 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"), """ + + com.acme + root + pom + + service-x + service-y + + + """); + writePom(root.resolve("service-x/pom.xml"), """ + com.acmeservice-x + """); + writePom(root.resolve("service-y/pom.xml"), """ + com.acmeservice-y + """); + + ProjectModuleGraph graph = analyzer.analyze(root); + Set 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 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"), """ + com.acmeroot + """); + writePom(child.resolve("pom.xml"), """ + + + ../root/pom.xml + + child + + """); + + Set 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"), """ + + com.nested + parent + pom + api + + """); + writePom(nestedMaven.resolve("api/pom.xml"), """ + com.nestedapi + """); + + 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 = """ + + + com.parent + parent + + child + com.child + + """; + + 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"), """ + com.acmeroot + """); + writePom(root.resolve("module-a/pom.xml"), """ + + com.acme + module-a + + + com.acme + module-b + 1.0 + + + + """); + writePom(root.resolve("module-b/pom.xml"), """ + + com.acme + module-b + + + com.acme + module-c + 1.0 + + + + """); + writePom(root.resolve("module-c/pom.xml"), """ + com.acmemodule-c + """); + 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); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DynamicClasspathResolverTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DynamicClasspathResolverTest.java index 2f77a2b..70d9ac7 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DynamicClasspathResolverTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DynamicClasspathResolverTest.java @@ -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 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 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 classpath = resolver.resolveClasspath(tempDir); + List classpath = new DynamicClasspathResolver().resolveClasspath(tempDir); assertThat(classpath).isEmpty(); } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngineIntegrationTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngineIntegrationTest.java index 23cbd8a..b1821cc 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngineIntegrationTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngineIntegrationTest.java @@ -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 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 cp = resolver.resolveClasspath(projectRoot); - if (!cp.isEmpty()) { - ctx.setClasspath(cp); - } + ctx.setClasspath(ToolingClasspath.currentJvmJarEntries()); ctx.setResolveBindings(true); ctx.scan(Set.of(projectRoot), Collections.emptySet()); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/LayeredDispatcherSampleTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/LayeredDispatcherSampleTest.java index 117add1..fec642c 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/LayeredDispatcherSampleTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/LayeredDispatcherSampleTest.java @@ -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); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/spring/SpringContextScannerIntegrationTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/spring/SpringContextScannerIntegrationTest.java index a51121b..b183085 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/spring/SpringContextScannerIntegrationTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/spring/SpringContextScannerIntegrationTest.java @@ -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 cp = resolver.resolveClasspath(projectRoot); - if (!cp.isEmpty()) { - context.setClasspath(cp); - } + context.setClasspath(ToolingClasspath.currentJvmJarEntries()); context.setResolveBindings(true); context.scan(Set.of(projectRoot), Collections.emptySet()); diff --git a/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/command/HtmlExporterCommand.java b/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/command/HtmlExporterCommand.java index 47d4b84..21329b0 100644 --- a/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/command/HtmlExporterCommand.java +++ b/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/command/HtmlExporterCommand.java @@ -62,8 +62,8 @@ public class HtmlExporterCommand implements Callable { @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 = "Resolve external dependencies to enable deep JDT binding across modules.", 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. Does not run Maven or Gradle.", negatable = true, defaultValue = "true", fallbackValue = "true") + private boolean includeGeneratedSources = true; @Override public Integer call() throws Exception { @@ -100,7 +100,7 @@ public class HtmlExporterCommand implements Callable { exportService.runJsonExporter(jsonFile, outputDir, formats, profiles); } else { boolean renderChoicesAsDiamonds = !noDiamonds; - exportService.runExporter(inputDir, outputDir, formats, renderChoicesAsDiamonds, profiles, flowsFile, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, resolveClasspath); + exportService.runExporter(inputDir, outputDir, formats, renderChoicesAsDiamonds, profiles, flowsFile, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, includeGeneratedSources); } System.clearProperty("html.hideMetadataPane");