From 06287101062ae4a56318920bd74299dac8e0d811 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Sat, 11 Jul 2026 10:35:52 +0200 Subject: [PATCH] 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 --- state_machine_exporter/build.gradle | 4 + .../resolver/GeneratedSourceDiscovery.java | 140 +++++ .../analysis/resolver/ProjectModuleGraph.java | 259 +++++++++ .../resolver/SiblingDependencyResolver.java | 229 +------- .../resolver/StaticBuildFileAnalyzer.java | 450 ++++++++++++++++ .../service/DynamicClasspathResolver.java | 165 +----- .../analysis/service/ToolingClasspath.java | 25 + .../ast/common/CodebaseContext.java | 26 +- .../command/ExporterCommand.java | 6 +- .../service/ExportService.java | 57 +- .../RegressionTest.java | 2 +- .../GeneratedSourceDiscoveryTest.java | 173 ++++++ .../resolver/StaticBuildFileAnalyzerTest.java | 510 ++++++++++++++++++ .../service/DynamicClasspathResolverTest.java | 57 +- .../JdtCallGraphEngineIntegrationTest.java | 16 +- .../service/LayeredDispatcherSampleTest.java | 2 +- .../SpringContextScannerIntegrationTest.java | 9 +- .../html/command/HtmlExporterCommand.java | 6 +- 18 files changed, 1669 insertions(+), 467 deletions(-) create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/GeneratedSourceDiscovery.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ProjectModuleGraph.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/StaticBuildFileAnalyzer.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ToolingClasspath.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/GeneratedSourceDiscoveryTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/StaticBuildFileAnalyzerTest.java 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");