From 84c618199b96a609920c8d03d6b6a7da8dd5445e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Tue, 4 Aug 2026 16:40:54 +0800 Subject: [PATCH] feat: analyze Maven projects for AI tests --- .../biz/aitest/project/AiTestProject.java | 6 + .../aitest/project/AiTestProjectCatalog.java | 28 ++++ .../biz/aitest/project/MavenModule.java | 27 ++++ .../aitest/project/MavenProjectAnalyzer.java | 147 ++++++++++++++++++ .../biz/aitest/project/MavenProjectModel.java | 45 ++++++ .../aitest/project/StartModuleSelector.java | 16 ++ .../project/MavenProjectAnalyzerTest.java | 83 ++++++++++ .../project/StartModuleSelectorTest.java | 41 +++++ 8 files changed, 393 insertions(+) create mode 100644 src/main/java/com/example/workutils/biz/aitest/project/AiTestProject.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/project/AiTestProjectCatalog.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/project/MavenModule.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/project/MavenProjectAnalyzer.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/project/MavenProjectModel.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/project/StartModuleSelector.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/project/MavenProjectAnalyzerTest.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/project/StartModuleSelectorTest.java diff --git a/src/main/java/com/example/workutils/biz/aitest/project/AiTestProject.java b/src/main/java/com/example/workutils/biz/aitest/project/AiTestProject.java new file mode 100644 index 0000000..acc55e1 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/project/AiTestProject.java @@ -0,0 +1,6 @@ +package com.example.workutils.biz.aitest.project; + +import java.nio.file.Path; + +public record AiTestProject(String name, Path path, String remoteBaseUrl, boolean available) { +} diff --git a/src/main/java/com/example/workutils/biz/aitest/project/AiTestProjectCatalog.java b/src/main/java/com/example/workutils/biz/aitest/project/AiTestProjectCatalog.java new file mode 100644 index 0000000..c1f7acd --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/project/AiTestProjectCatalog.java @@ -0,0 +1,28 @@ +package com.example.workutils.biz.aitest.project; + +import com.example.workutils.system.support.FileOperationHelper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AiTestProjectCatalog { + + private final FileOperationHelper fileOperationHelper; + + public List load() throws IOException { + List projects = new ArrayList<>(); + for (FileOperationHelper.ProjectPathInfo info : fileOperationHelper.getProjectPathsWithStatus()) { + Path path = Path.of(info.getPath()).toAbsolutePath().normalize(); + projects.add(new AiTestProject(fileOperationHelper.extractProjectName(info.getPath()), path, + info.getRemoteBaseUrl(), Files.isDirectory(path))); + } + return List.copyOf(projects); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/project/MavenModule.java b/src/main/java/com/example/workutils/biz/aitest/project/MavenModule.java new file mode 100644 index 0000000..cd3d0c4 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/project/MavenModule.java @@ -0,0 +1,27 @@ +package com.example.workutils.biz.aitest.project; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.Set; + +public record MavenModule(Path directory, Path relativePath, String groupId, + String artifactId, String version, + Set reactorDependencies) { + public MavenModule { + Objects.requireNonNull(directory, "directory"); + Objects.requireNonNull(relativePath, "relativePath"); + Objects.requireNonNull(groupId, "groupId"); + Objects.requireNonNull(artifactId, "artifactId"); + Objects.requireNonNull(version, "version"); + reactorDependencies = Set.copyOf(reactorDependencies); + } + + public String coordinate() { + return groupId + ":" + artifactId; + } + + @Override + public String toString() { + return artifactId; + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/project/MavenProjectAnalyzer.java b/src/main/java/com/example/workutils/biz/aitest/project/MavenProjectAnalyzer.java new file mode 100644 index 0000000..97701d8 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/project/MavenProjectAnalyzer.java @@ -0,0 +1,147 @@ +package com.example.workutils.biz.aitest.project; + +import org.springframework.stereotype.Service; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Service +public class MavenProjectAnalyzer { + + public MavenProjectModel analyze(Path projectRoot) { + Path root = projectRoot.toAbsolutePath().normalize(); + if (Files.notExists(root.resolve("pom.xml"))) { + throw new IllegalArgumentException("Maven pom.xml not found: " + root); + } + List modules = new ArrayList<>(); + parseModule(root, root, modules, new HashSet<>()); + Set reactorCoordinates = new HashSet<>(); + modules.forEach(module -> reactorCoordinates.add(module.coordinate())); + List normalized = modules.stream() + .map(module -> new MavenModule(module.directory(), module.relativePath(), module.groupId(), + module.artifactId(), module.version(), module.reactorDependencies().stream() + .filter(reactorCoordinates::contains).collect(java.util.stream.Collectors.toSet()))) + .toList(); + return new MavenProjectModel(root, normalized); + } + + private void parseModule(Path root, Path directory, List modules, Set visited) { + Path normalizedDirectory = directory.toAbsolutePath().normalize(); + if (!normalizedDirectory.startsWith(root)) { + throw new IllegalArgumentException("Maven module is outside project: " + normalizedDirectory); + } + if (!visited.add(normalizedDirectory)) { + return; + } + Path pom = normalizedDirectory.resolve("pom.xml"); + if (Files.notExists(pom)) { + throw new IllegalArgumentException("Maven module pom.xml not found: " + pom); + } + Document document = parse(pom); + Element project = document.getDocumentElement(); + Element parent = directChild(project, "parent"); + String groupId = firstNonBlank(directText(project, "groupId"), directText(parent, "groupId"), ""); + String artifactId = firstNonBlank(directText(project, "artifactId"), ""); + String version = firstNonBlank(directText(project, "version"), directText(parent, "version"), ""); + if (artifactId.isBlank()) { + throw new IllegalArgumentException("Maven artifactId is missing: " + pom); + } + Set dependencies = dependencies(project, groupId); + modules.add(new MavenModule(normalizedDirectory, root.relativize(normalizedDirectory), + groupId, artifactId, version, dependencies)); + + Element modulesElement = directChild(project, "modules"); + if (modulesElement != null) { + for (Element module : directChildren(modulesElement, "module")) { + String modulePath = module.getTextContent().trim(); + Path child = normalizedDirectory.resolve(modulePath).normalize(); + if (!child.startsWith(root)) { + throw new IllegalArgumentException("Maven module is outside project: " + modulePath); + } + parseModule(root, child, modules, visited); + } + } + } + + private static Set dependencies(Element project, String projectGroupId) { + Set result = new LinkedHashSet<>(); + Element dependencies = directChild(project, "dependencies"); + if (dependencies == null) { + return result; + } + for (Element dependency : directChildren(dependencies, "dependency")) { + String groupId = firstNonBlank(directText(dependency, "groupId"), projectGroupId); + String artifactId = directText(dependency, "artifactId"); + if (!groupId.isBlank() && !artifactId.isBlank()) { + result.add(groupId + ":" + artifactId); + } + } + return result; + } + + private static Document parse(Path pom) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + return factory.newDocumentBuilder().parse(pom.toFile()); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to parse Maven POM: " + pom, e); + } + } + + private static Element directChild(Element parent, String name) { + if (parent == null) { + return null; + } + NodeList nodes = parent.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node instanceof Element element && name.equals(element.getLocalName())) { + return element; + } + } + return null; + } + + private static List directChildren(Element parent, String name) { + List children = new ArrayList<>(); + NodeList nodes = parent.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node instanceof Element element && name.equals(element.getLocalName())) { + children.add(element); + } + } + return children; + } + + private static String directText(Element parent, String name) { + Element child = directChild(parent, name); + return child == null ? "" : child.getTextContent().trim(); + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/project/MavenProjectModel.java b/src/main/java/com/example/workutils/biz/aitest/project/MavenProjectModel.java new file mode 100644 index 0000000..4a5c0a6 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/project/MavenProjectModel.java @@ -0,0 +1,45 @@ +package com.example.workutils.biz.aitest.project; + +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +public record MavenProjectModel(Path root, List modules) { + public MavenProjectModel { + root = root.toAbsolutePath().normalize(); + modules = List.copyOf(modules); + } + + public Optional moduleByArtifactId(String artifactId) { + return modules.stream().filter(module -> module.artifactId().equals(artifactId)).findFirst(); + } + + public boolean canReach(MavenModule selected, MavenModule target) { + if (selected.coordinate().equals(target.coordinate())) { + return true; + } + Map byCoordinate = new HashMap<>(); + modules.forEach(module -> byCoordinate.put(module.coordinate(), module)); + ArrayDeque pending = new ArrayDeque<>(selected.reactorDependencies()); + Set visited = new HashSet<>(); + while (!pending.isEmpty()) { + String coordinate = pending.removeFirst(); + if (!visited.add(coordinate)) { + continue; + } + if (coordinate.equals(target.coordinate())) { + return true; + } + MavenModule dependency = byCoordinate.get(coordinate); + if (dependency != null) { + pending.addAll(dependency.reactorDependencies()); + } + } + return false; + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/project/StartModuleSelector.java b/src/main/java/com/example/workutils/biz/aitest/project/StartModuleSelector.java new file mode 100644 index 0000000..7e132b7 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/project/StartModuleSelector.java @@ -0,0 +1,16 @@ +package com.example.workutils.biz.aitest.project; + +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@Component +public class StartModuleSelector { + public Optional autoSelect(MavenProjectModel project) { + List matches = project.modules().stream() + .filter(module -> module.artifactId().endsWith("-start")) + .toList(); + return matches.size() == 1 ? Optional.of(matches.get(0)) : Optional.empty(); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/project/MavenProjectAnalyzerTest.java b/src/test/java/com/example/workutils/biz/aitest/project/MavenProjectAnalyzerTest.java new file mode 100644 index 0000000..527b6de --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/project/MavenProjectAnalyzerTest.java @@ -0,0 +1,83 @@ +package com.example.workutils.biz.aitest.project; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class MavenProjectAnalyzerTest { + + private final MavenProjectAnalyzer analyzer = new MavenProjectAnalyzer(); + + @Test + void parsesReactorModulesAndDependencyReachability(@TempDir Path root) throws Exception { + write(root.resolve("pom.xml"), rootPom("core", "app-start")); + write(root.resolve("core/pom.xml"), childPom("core", "")); + write(root.resolve("app-start/pom.xml"), childPom("app-start", """ + com.examplecore1 + """)); + + MavenProjectModel model = analyzer.analyze(root); + MavenModule start = model.moduleByArtifactId("app-start").orElseThrow(); + MavenModule core = model.moduleByArtifactId("core").orElseThrow(); + + assertEquals(3, model.modules().size()); + assertTrue(model.canReach(start, core)); + assertFalse(model.canReach(core, start)); + } + + @Test + void inheritsGroupAndVersionFromParent(@TempDir Path root) throws Exception { + write(root.resolve("pom.xml"), rootPom("service-start")); + write(root.resolve("service-start/pom.xml"), childPom("service-start", "")); + + MavenModule module = analyzer.analyze(root).moduleByArtifactId("service-start").orElseThrow(); + + assertEquals("com.example", module.groupId()); + assertEquals("1.0.0", module.version()); + } + + @Test + void rejectsModulePathOutsideProject(@TempDir Path root) throws Exception { + write(root.resolve("pom.xml"), rootPom("../outside")); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> analyzer.analyze(root)); + + assertTrue(error.getMessage().contains("outside project")); + } + + private static String rootPom(String... modules) { + StringBuilder moduleXml = new StringBuilder(); + for (String module : modules) { + moduleXml.append("").append(module).append(""); + } + return """ + + 4.0.0 + com.exampleroot1.0.0 + pom%s + + """.formatted(moduleXml); + } + + private static String childPom(String artifactId, String dependencies) { + return """ + + 4.0.0 + com.exampleroot1.0.0 + %s%s + + """.formatted(artifactId, dependencies); + } + + private static void write(Path file, String content) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, content, StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/project/StartModuleSelectorTest.java b/src/test/java/com/example/workutils/biz/aitest/project/StartModuleSelectorTest.java new file mode 100644 index 0000000..5fc94c9 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/project/StartModuleSelectorTest.java @@ -0,0 +1,41 @@ +package com.example.workutils.biz.aitest.project; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StartModuleSelectorTest { + + private final StartModuleSelector selector = new StartModuleSelector(); + + @Test + void selectsOnlyUniqueArtifactEndingWithStart(@TempDir Path root) { + MavenProjectModel model = model(root, "order-core", "order-start"); + + assertEquals("order-start", selector.autoSelect(model).orElseThrow().artifactId()); + } + + @Test + void requiresChoiceWhenNoStartArtifactExists(@TempDir Path root) { + assertTrue(selector.autoSelect(model(root, "order-core", "order-api")).isEmpty()); + } + + @Test + void requiresChoiceWhenMultipleStartArtifactsExist(@TempDir Path root) { + assertTrue(selector.autoSelect(model(root, "order-start", "admin-start")).isEmpty()); + } + + private static MavenProjectModel model(Path root, String... artifactIds) { + List modules = java.util.Arrays.stream(artifactIds) + .map(id -> new MavenModule(root.resolve(id), Path.of(id), "com.example", id, + "1.0.0", Set.of())) + .toList(); + return new MavenProjectModel(root, modules); + } +}