feat: analyze Maven projects for AI tests

This commit is contained in:
张家训
2026-08-04 16:40:54 +08:00
parent fc262da350
commit 84c618199b
8 changed files with 393 additions and 0 deletions
@@ -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);
}
}