feat: analyze Maven projects for AI tests
This commit is contained in:
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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<AiTestProject> load() throws IOException {
|
||||||
|
List<AiTestProject> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MavenModule> modules = new ArrayList<>();
|
||||||
|
parseModule(root, root, modules, new HashSet<>());
|
||||||
|
Set<String> reactorCoordinates = new HashSet<>();
|
||||||
|
modules.forEach(module -> reactorCoordinates.add(module.coordinate()));
|
||||||
|
List<MavenModule> 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<MavenModule> modules, Set<Path> 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<String> 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<String> dependencies(Element project, String projectGroupId) {
|
||||||
|
Set<String> 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<Element> directChildren(Element parent, String name) {
|
||||||
|
List<Element> 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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MavenModule> modules) {
|
||||||
|
public MavenProjectModel {
|
||||||
|
root = root.toAbsolutePath().normalize();
|
||||||
|
modules = List.copyOf(modules);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<MavenModule> 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<String, MavenModule> byCoordinate = new HashMap<>();
|
||||||
|
modules.forEach(module -> byCoordinate.put(module.coordinate(), module));
|
||||||
|
ArrayDeque<String> pending = new ArrayDeque<>(selected.reactorDependencies());
|
||||||
|
Set<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MavenModule> autoSelect(MavenProjectModel project) {
|
||||||
|
List<MavenModule> matches = project.modules().stream()
|
||||||
|
.filter(module -> module.artifactId().endsWith("-start"))
|
||||||
|
.toList();
|
||||||
|
return matches.size() == 1 ? Optional.of(matches.get(0)) : Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", """
|
||||||
|
<dependency><groupId>com.example</groupId><artifactId>core</artifactId><version>1</version></dependency>
|
||||||
|
"""));
|
||||||
|
|
||||||
|
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("<module>").append(module).append("</module>");
|
||||||
|
}
|
||||||
|
return """
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<groupId>com.example</groupId><artifactId>root</artifactId><version>1.0.0</version>
|
||||||
|
<packaging>pom</packaging><modules>%s</modules>
|
||||||
|
</project>
|
||||||
|
""".formatted(moduleXml);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String childPom(String artifactId, String dependencies) {
|
||||||
|
return """
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent><groupId>com.example</groupId><artifactId>root</artifactId><version>1.0.0</version></parent>
|
||||||
|
<artifactId>%s</artifactId><dependencies>%s</dependencies>
|
||||||
|
</project>
|
||||||
|
""".formatted(artifactId, dependencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void write(Path file, String content) throws IOException {
|
||||||
|
Files.createDirectories(file.getParent());
|
||||||
|
Files.writeString(file, content, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MavenModule> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user