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