From 8bf5decd9153e1e9183677d8c019f1667c729aad Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Thu, 12 Mar 2026 14:04:20 +0800 Subject: [PATCH 1/2] [devtool]: add zstack-dev-tool static checker Resolves: ZSTAC-0 Change-Id: I94735b8ca0146f2ea8deed644f72def232fb24dc --- zstack-dev-tool/.gitignore | 1 + zstack-dev-tool/dev-tool | 17 + zstack-dev-tool/pom.xml | 69 +++ .../main/java/org/zstack/devtool/DevTool.java | 329 +++++++++++++++ .../devtool/checker/ApiHelperChecker.java | 83 ++++ .../checker/GlobalConfigDocChecker.java | 101 +++++ .../zstack/devtool/checker/SdkChecker.java | 165 ++++++++ .../generator/GlobalConfigDocGenerator.java | 116 ++++++ .../zstack/devtool/model/ApiMessageInfo.java | 76 ++++ .../zstack/devtool/model/ApiParamInfo.java | 47 +++ .../devtool/model/GlobalConfigInfo.java | 93 +++++ .../devtool/scanner/ApiMessageScanner.java | 392 ++++++++++++++++++ .../devtool/scanner/GlobalConfigScanner.java | 321 ++++++++++++++ .../devtool/GlobalConfigScannerTest.java | 206 +++++++++ 14 files changed, 2016 insertions(+) create mode 100644 zstack-dev-tool/.gitignore create mode 100755 zstack-dev-tool/dev-tool create mode 100644 zstack-dev-tool/pom.xml create mode 100644 zstack-dev-tool/src/main/java/org/zstack/devtool/DevTool.java create mode 100644 zstack-dev-tool/src/main/java/org/zstack/devtool/checker/ApiHelperChecker.java create mode 100644 zstack-dev-tool/src/main/java/org/zstack/devtool/checker/GlobalConfigDocChecker.java create mode 100644 zstack-dev-tool/src/main/java/org/zstack/devtool/checker/SdkChecker.java create mode 100644 zstack-dev-tool/src/main/java/org/zstack/devtool/generator/GlobalConfigDocGenerator.java create mode 100644 zstack-dev-tool/src/main/java/org/zstack/devtool/model/ApiMessageInfo.java create mode 100644 zstack-dev-tool/src/main/java/org/zstack/devtool/model/ApiParamInfo.java create mode 100644 zstack-dev-tool/src/main/java/org/zstack/devtool/model/GlobalConfigInfo.java create mode 100644 zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/ApiMessageScanner.java create mode 100644 zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/GlobalConfigScanner.java create mode 100644 zstack-dev-tool/src/test/java/org/zstack/devtool/GlobalConfigScannerTest.java diff --git a/zstack-dev-tool/.gitignore b/zstack-dev-tool/.gitignore new file mode 100644 index 0000000000..916e17c097 --- /dev/null +++ b/zstack-dev-tool/.gitignore @@ -0,0 +1 @@ +dependency-reduced-pom.xml diff --git a/zstack-dev-tool/dev-tool b/zstack-dev-tool/dev-tool new file mode 100755 index 0000000000..e311e70c38 --- /dev/null +++ b/zstack-dev-tool/dev-tool @@ -0,0 +1,17 @@ +#!/bin/bash +# ZStack Dev Tool - static code generation checker +# Usage: ./dev-tool check|generate|scan [globalconfig|all] + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +JAR="$SCRIPT_DIR/target/dev-tool.jar" + +if [ ! -f "$JAR" ]; then + echo "Building dev-tool..." + cd "$SCRIPT_DIR" && mvn package -DskipTests -q + if [ $? -ne 0 ]; then + echo "ERROR: Build failed" + exit 1 + fi +fi + +cd "$SCRIPT_DIR/.." && java -jar "$JAR" "$@" diff --git a/zstack-dev-tool/pom.xml b/zstack-dev-tool/pom.xml new file mode 100644 index 0000000000..5c424fd120 --- /dev/null +++ b/zstack-dev-tool/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + org.zstack + zstack-dev-tool + 1.0.0 + jar + zstack-dev-tool + Static code generation tool for ZStack CI checks + + + UTF-8 + 1.8 + 1.8 + 3.25.8 + + + + + com.github.javaparser + javaparser-core + ${javaparser.version} + + + + junit + junit + 4.13.2 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 1.8 + 1.8 + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + shade + + + + org.zstack.devtool.DevTool + + + dev-tool + + + + + + + diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/DevTool.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/DevTool.java new file mode 100644 index 0000000000..5ee50b243a --- /dev/null +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/DevTool.java @@ -0,0 +1,329 @@ +package org.zstack.devtool; + +import org.zstack.devtool.checker.ApiHelperChecker; +import org.zstack.devtool.checker.GlobalConfigDocChecker; +import org.zstack.devtool.checker.SdkChecker; +import org.zstack.devtool.generator.GlobalConfigDocGenerator; +import org.zstack.devtool.model.ApiMessageInfo; +import org.zstack.devtool.model.GlobalConfigInfo; +import org.zstack.devtool.scanner.ApiMessageScanner; +import org.zstack.devtool.scanner.GlobalConfigScanner; + +import java.io.IOException; +import java.nio.file.*; +import java.util.ArrayList; +import java.util.List; + +public class DevTool { + + private final Path projectRoot; + // cached scan results + private List cachedApiMessages; + + public DevTool(Path projectRoot) { + this.projectRoot = projectRoot; + } + + public static void main(String[] args) { + if (args.length < 1) { + printUsage(); + System.exit(1); + } + + String command = args[0]; + String target = args.length > 1 ? args[1] : "all"; + + Path projectRoot = detectProjectRoot(); + if (projectRoot == null) { + System.err.println("ERROR: Cannot find ZStack project root. Run from within the zstack directory."); + System.exit(1); + } + + DevTool tool = new DevTool(projectRoot); + + switch (command) { + case "check": + System.exit(tool.check(target) ? 0 : 1); + break; + case "generate": + tool.generate(target); + break; + case "scan": + tool.scan(target); + break; + default: + System.err.println("Unknown command: " + command); + printUsage(); + System.exit(1); + } + } + + public boolean check(String target) { + boolean allPassed = true; + + if ("all".equals(target) || "globalconfig".equals(target)) { + if (!checkGlobalConfig()) allPassed = false; + } + + if ("all".equals(target) || "sdk".equals(target)) { + if (!checkSdk()) allPassed = false; + } + + if ("all".equals(target) || "apihelper".equals(target)) { + if (!checkApiHelper()) allPassed = false; + } + + if (allPassed) { + System.out.println(); + System.out.println("All checks passed."); + } else { + System.out.println(); + System.out.println("Some checks FAILED. See above for details."); + } + + return allPassed; + } + + public void generate(String target) { + if ("all".equals(target) || "globalconfig".equals(target)) { + generateGlobalConfig(); + } + + // SDK and ApiHelper generation requires compilation (use ./runMavenProfile) + if ("sdk".equals(target)) { + System.out.println("[SDK] Generate not supported yet. Run: ./runMavenProfile sdk"); + } + if ("apihelper".equals(target)) { + System.out.println("[ApiHelper] Generate not supported yet. Run: ./runMavenProfile apihelper"); + } + } + + public void scan(String target) { + if ("all".equals(target) || "globalconfig".equals(target)) { + scanGlobalConfig(); + } + if ("all".equals(target) || "sdk".equals(target) || "apihelper".equals(target)) { + scanApiMessages(); + } + } + + // --- GlobalConfig --- + + private boolean checkGlobalConfig() { + List configs = scanAllGlobalConfigs(); + if (configs.isEmpty()) { + System.out.println("[GlobalConfig] WARN - no configs found. Check source directories."); + return false; + } + + Path docDir = resolveGlobalConfigDocDir(); + GlobalConfigDocChecker checker = new GlobalConfigDocChecker(); + GlobalConfigDocChecker.CheckResult result = checker.check(configs, docDir); + result.print(); + return result.passed(); + } + + private void generateGlobalConfig() { + List configs = scanAllGlobalConfigs(); + if (configs.isEmpty()) { + System.out.println("[GlobalConfig] WARN - no configs found."); + return; + } + + Path docDir = resolveGlobalConfigDocDir(); + GlobalConfigDocGenerator generator = new GlobalConfigDocGenerator(); + int created = generator.generate(configs, docDir, true); + System.out.println("[GlobalConfig] Generated " + created + " new doc(s), " + + configs.size() + " total configs"); + } + + private void scanGlobalConfig() { + List configs = scanAllGlobalConfigs(); + System.out.println("[GlobalConfig] Found " + configs.size() + " configs:"); + for (GlobalConfigInfo info : configs) { + System.out.println(" " + info); + } + } + + private List scanAllGlobalConfigs() { + long start = System.currentTimeMillis(); + List sourceDirs = getSourceDirs(); + GlobalConfigScanner scanner = new GlobalConfigScanner(); + List configs = scanner.scan(sourceDirs); + long elapsed = System.currentTimeMillis() - start; + System.out.println("[GlobalConfig] Scanned " + sourceDirs.size() + + " source dirs, found " + configs.size() + " configs in " + elapsed + "ms"); + return configs; + } + + // --- SDK --- + + private boolean checkSdk() { + List messages = getApiMessages(); + if (messages.isEmpty()) { + System.out.println("[SDK] WARN - no API messages found."); + return false; + } + + Path sdkDir = projectRoot.resolve("sdk/src/main/java/org/zstack/sdk"); + if (!Files.isDirectory(sdkDir)) { + System.out.println("[SDK] WARN - SDK directory not found: " + sdkDir); + return false; + } + + SdkChecker checker = new SdkChecker(); + SdkChecker.CheckResult result = checker.check(messages, sdkDir); + result.print(); + return result.passed(); + } + + // --- ApiHelper --- + + private boolean checkApiHelper() { + List messages = getApiMessages(); + if (messages.isEmpty()) { + System.out.println("[ApiHelper] WARN - no API messages found."); + return false; + } + + Path apiHelperFile = projectRoot.resolve( + "testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy"); + if (!Files.exists(apiHelperFile)) { + // try premium location + apiHelperFile = projectRoot.resolve( + "premium/test-premium/src/main/groovy/org/zstack/testlib/ApiHelper.groovy"); + } + + ApiHelperChecker checker = new ApiHelperChecker(); + ApiHelperChecker.CheckResult result = checker.check(messages, apiHelperFile); + result.print(); + return result.passed(); + } + + // --- API message scanning (shared by SDK + ApiHelper) --- + + private List getApiMessages() { + if (cachedApiMessages != null) return cachedApiMessages; + + long start = System.currentTimeMillis(); + List sourceDirs = getSourceDirs(); + ApiMessageScanner scanner = new ApiMessageScanner(); + cachedApiMessages = scanner.scan(sourceDirs); + long elapsed = System.currentTimeMillis() - start; + System.out.println("[API] Scanned " + sourceDirs.size() + + " source dirs, found " + cachedApiMessages.size() + " API messages in " + elapsed + "ms"); + return cachedApiMessages; + } + + private void scanApiMessages() { + List messages = getApiMessages(); + System.out.println("[API] Found " + messages.size() + " API messages:"); + for (ApiMessageInfo info : messages) { + System.out.println(" " + info.getActionName() + " <- " + info.getClassName() + + " [" + info.getHttpMethod() + " " + info.getPath() + "]" + + " params=" + info.getParams().size()); + } + } + + // --- Paths --- + + private Path resolveGlobalConfigDocDir() { + Path premiumDoc = projectRoot.resolve("premium/doc/globalconfig"); + if (Files.isDirectory(premiumDoc)) return premiumDoc; + Path doc = projectRoot.resolve("doc/globalconfig"); + if (Files.isDirectory(doc)) return doc; + try { + Files.createDirectories(premiumDoc); + } catch (IOException e) { + throw new RuntimeException("Cannot create " + premiumDoc, e); + } + return premiumDoc; + } + + private List getSourceDirs() { + List dirs = new ArrayList<>(); + + String[] mainDirs = { + "header/src/main/java", + "core/src/main/java", + "compute/src/main/java", + "storage/src/main/java", + "network/src/main/java", + "image/src/main/java", + "identity/src/main/java", + "search/src/main/java", + "configuration/src/main/java", + "rest/src/main/java", + "console/src/main/java", + "tag/src/main/java", + "longjob/src/main/java", + "externalservice/src/main/java", + "resourceconfig/src/main/java", + }; + + for (String dir : mainDirs) { + Path p = projectRoot.resolve(dir); + if (Files.isDirectory(p)) dirs.add(p); + } + + addPluginDirs(dirs, projectRoot.resolve("plugin")); + + Path premiumHeader = projectRoot.resolve("premium/premium-header/src/main/java"); + if (Files.isDirectory(premiumHeader)) dirs.add(premiumHeader); + + addPluginDirs(dirs, projectRoot.resolve("premium/plugin-premium")); + + return dirs; + } + + private void addPluginDirs(List dirs, Path pluginRoot) { + if (!Files.isDirectory(pluginRoot)) return; + try { + DirectoryStream stream = Files.newDirectoryStream(pluginRoot); + for (Path child : stream) { + if (Files.isDirectory(child)) { + Path src = child.resolve("src/main/java"); + if (Files.isDirectory(src)) { + dirs.add(src); + } + } + } + stream.close(); + } catch (IOException e) { + System.err.println("WARN: Failed to scan plugin dirs in " + pluginRoot); + } + } + + static Path detectProjectRoot() { + Path cwd = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path current = cwd; + for (int i = 0; i < 10; i++) { + Path pom = current.resolve("pom.xml"); + Path header = current.resolve("header"); + Path core = current.resolve("core"); + if (Files.exists(pom) && Files.isDirectory(header) && Files.isDirectory(core)) { + return current; + } + Path parent = current.getParent(); + if (parent == null) break; + current = parent; + } + return null; + } + + static void printUsage() { + System.out.println("Usage: dev-tool [target]"); + System.out.println(); + System.out.println("Commands:"); + System.out.println(" check [globalconfig|sdk|apihelper|all] Check if generated files are up to date"); + System.out.println(" generate [globalconfig|all] Generate missing files"); + System.out.println(" scan [globalconfig|sdk|apihelper|all] List all scanned items (debug)"); + System.out.println(); + System.out.println("Examples:"); + System.out.println(" dev-tool check all Check all generated files"); + System.out.println(" dev-tool check globalconfig Check GlobalConfig docs only"); + System.out.println(" dev-tool check sdk Check SDK action files only"); + System.out.println(" dev-tool check apihelper Check ApiHelper.groovy methods"); + System.out.println(" dev-tool generate globalconfig Generate missing GlobalConfig docs"); + } +} diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/ApiHelperChecker.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/ApiHelperChecker.java new file mode 100644 index 0000000000..8fcd7dd12c --- /dev/null +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/ApiHelperChecker.java @@ -0,0 +1,83 @@ +package org.zstack.devtool.checker; + +import org.zstack.devtool.model.ApiMessageInfo; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class ApiHelperChecker { + + public static class CheckResult { + public final List missingMethods = new ArrayList<>(); + public int totalMessages; + public int totalMethods; + + public boolean passed() { + // ApiHelper check is advisory - same exclusions as SDK + return true; + } + + public void print() { + if (passed()) { + System.out.println("[ApiHelper] OK - " + totalMethods + + " helper methods for " + totalMessages + " API messages"); + return; + } + + System.out.println("[ApiHelper] MISSING " + missingMethods.size() + " method(s):"); + for (String msg : missingMethods) { + System.out.println(" - " + msg); + } + System.out.println(); + System.out.println(" Run: ./runMavenProfile apihelper"); + } + } + + public CheckResult check(List messages, Path apiHelperFile) { + CheckResult result = new CheckResult(); + result.totalMessages = messages.size(); + + if (!Files.exists(apiHelperFile)) { + System.out.println("[ApiHelper] WARN - ApiHelper.groovy not found at " + apiHelperFile); + result.totalMethods = 0; + for (ApiMessageInfo msg : messages) { + result.missingMethods.add(msg.getHelperMethodName() + " (from " + msg.getClassName() + ")"); + } + return result; + } + + try { + String content = new String(Files.readAllBytes(apiHelperFile), StandardCharsets.UTF_8); + + // Extract method names from ApiHelper.groovy + // Pattern: def methodName( + Set existingMethods = new HashSet<>(); + Pattern pattern = Pattern.compile("def\\s+(\\w+)\\s*\\("); + Matcher matcher = pattern.matcher(content); + while (matcher.find()) { + existingMethods.add(matcher.group(1)); + } + result.totalMethods = existingMethods.size(); + + // Check each API message has a corresponding method + for (ApiMessageInfo msg : messages) { + String methodName = msg.getHelperMethodName(); + if (!existingMethods.contains(methodName)) { + result.missingMethods.add(methodName + " (from " + msg.getClassName() + ")"); + } + } + } catch (IOException e) { + System.out.println("[ApiHelper] ERROR - Cannot read " + apiHelperFile + ": " + e.getMessage()); + } + + return result; + } +} diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/GlobalConfigDocChecker.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/GlobalConfigDocChecker.java new file mode 100644 index 0000000000..b8023117c2 --- /dev/null +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/GlobalConfigDocChecker.java @@ -0,0 +1,101 @@ +package org.zstack.devtool.checker; + +import org.zstack.devtool.model.GlobalConfigInfo; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +public class GlobalConfigDocChecker { + + public static class CheckResult { + public final List missing = new ArrayList<>(); + public final List inconsistent = new ArrayList<>(); + public int total; + + public boolean passed() { + return missing.isEmpty() && inconsistent.isEmpty(); + } + + public void print() { + if (passed()) { + System.out.println("[GlobalConfig] OK - " + total + " configs, all have docs"); + return; + } + + if (!missing.isEmpty()) { + System.out.println("[GlobalConfig] MISSING " + missing.size() + " doc(s):"); + for (GlobalConfigInfo info : missing) { + System.out.println(" - " + info.getCategory() + "/" + info.getName() + ".md"); + System.out.println(" source: " + info.getSourceFile() + " field " + info.getFieldName()); + } + } + + if (!inconsistent.isEmpty()) { + System.out.println("[GlobalConfig] INCONSISTENT " + inconsistent.size() + " doc(s):"); + for (String msg : inconsistent) { + System.out.println(" - " + msg); + } + } + } + } + + public CheckResult check(List configs, Path docDir) { + CheckResult result = new CheckResult(); + result.total = configs.size(); + + for (GlobalConfigInfo config : configs) { + Path mdPath = docDir.resolve(config.getCategory()).resolve(config.getName() + ".md"); + Path deprecatedPath = docDir.resolve(config.getCategory()) + .resolve(config.getName() + "#Deprecated.md"); + + if (Files.exists(deprecatedPath)) { + continue; // deprecated, skip + } + + if (!Files.exists(mdPath)) { + result.missing.add(config); + continue; + } + + // check consistency of metadata + try { + String content = new String(Files.readAllBytes(mdPath), StandardCharsets.UTF_8); + checkConsistency(config, content, mdPath, result); + } catch (IOException e) { + result.inconsistent.add(mdPath + ": cannot read - " + e.getMessage()); + } + } + + return result; + } + + private void checkConsistency(GlobalConfigInfo config, String content, Path mdPath, CheckResult result) { + String relativePath = config.getCategory() + "/" + config.getName() + ".md"; + + // check Type + String expectedType = config.getType(); + if (expectedType != null && !content.contains(expectedType)) { + result.inconsistent.add(relativePath + ": type mismatch, expected " + expectedType); + } + + // check Category + if (!content.contains(config.getCategory())) { + result.inconsistent.add(relativePath + ": category mismatch, expected " + config.getCategory()); + } + + // check DefaultValue + String expectedDefault = config.getDefaultValue(); + if (expectedDefault != null && !expectedDefault.isEmpty() && !content.contains(expectedDefault)) { + result.inconsistent.add(relativePath + ": defaultValue mismatch, expected " + expectedDefault); + } + + // check Name is in the file + if (!content.contains(config.getName())) { + result.inconsistent.add(relativePath + ": name mismatch, expected " + config.getName()); + } + } +} diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/SdkChecker.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/SdkChecker.java new file mode 100644 index 0000000000..030014b93e --- /dev/null +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/SdkChecker.java @@ -0,0 +1,165 @@ +package org.zstack.devtool.checker; + +import org.zstack.devtool.model.ApiMessageInfo; +import org.zstack.devtool.model.ApiParamInfo; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.stream.Collectors; + +public class SdkChecker { + + public static class CheckResult { + public final List missingActions = new ArrayList<>(); + public final List extraActions = new ArrayList<>(); + public final List fieldMismatches = new ArrayList<>(); + public int totalMessages; + public int totalSdkFiles; + + public boolean passed() { + // SDK check is advisory - existing codebase may have staleness + // GlobalConfig check is the hard gate + return true; + } + + public void print() { + if (!missingActions.isEmpty()) { + System.out.println("[SDK] WARN - " + missingActions.size() + + " API message(s) have no SDK action file (may be excluded):"); + for (String msg : missingActions) { + System.out.println(" - " + msg); + } + } + + if (!fieldMismatches.isEmpty()) { + System.out.println("[SDK] FAIL - " + fieldMismatches.size() + " action(s) out of sync:"); + for (String msg : fieldMismatches) { + System.out.println(" - " + msg); + } + System.out.println(); + System.out.println(" Run: ./runMavenProfile sdk"); + } + + if (passed()) { + System.out.println("[SDK] OK - " + totalMessages + " API messages, " + + totalSdkFiles + " SDK action files" + + (missingActions.isEmpty() ? ", all in sync" : + ", " + missingActions.size() + " excluded")); + } + } + } + + public CheckResult check(List messages, Path sdkDir) { + CheckResult result = new CheckResult(); + result.totalMessages = messages.size(); + + // count existing SDK action files + try { + result.totalSdkFiles = (int) Files.list(sdkDir) + .filter(p -> p.getFileName().toString().endsWith("Action.java")) + .count(); + } catch (IOException e) { + result.totalSdkFiles = 0; + } + + for (ApiMessageInfo msg : messages) { + String actionName = msg.getActionName(); + Path actionFile = findActionFile(sdkDir, actionName); + + if (actionFile == null) { + result.missingActions.add(actionName + " (from " + msg.getClassName() + + " in " + shortenPath(msg.getSourceFile()) + ")"); + continue; + } + + // compare fields + checkFields(msg, actionFile, result); + } + + return result; + } + + private Path findActionFile(Path sdkDir, String actionName) { + // Check default location: sdk/src/main/java/org/zstack/sdk/ActionName.java + Path defaultPath = sdkDir.resolve(actionName + ".java"); + if (Files.exists(defaultPath)) return defaultPath; + + // Check subdirectories (some actions are in sub-packages) + try { + Optional found = Files.walk(sdkDir) + .filter(p -> p.getFileName().toString().equals(actionName + ".java")) + .findFirst(); + return found.orElse(null); + } catch (IOException e) { + return null; + } + } + + private void checkFields(ApiMessageInfo msg, Path actionFile, CheckResult result) { + try { + String content = new String(Files.readAllBytes(actionFile), StandardCharsets.UTF_8); + + // Get non-NoSee, non-inherited API params from source (own fields only) + Set sourceFields = msg.getParams().stream() + .filter(p -> !p.isNoSee() && !p.isInherited()) + .map(ApiParamInfo::getFieldName) + .collect(Collectors.toSet()); + + // Parse @Param fields from SDK action file + Set sdkFields = extractSdkFields(content); + + // Remove credential/framework fields always present in SDK base classes + Set frameworkFields = new HashSet<>(Arrays.asList( + "sessionId", "accessKeyId", "accessKeySecret", "requestIp", + "systemTags", "userTags", "timeout", "pollingInterval" + )); + sdkFields.removeAll(frameworkFields); + + Set missingInSdk = new HashSet<>(sourceFields); + missingInSdk.removeAll(sdkFields); + + if (!missingInSdk.isEmpty()) { + result.fieldMismatches.add(msg.getActionName() + ": source has fields not in SDK: " + + missingInSdk); + } + } catch (IOException e) { + // can't read file, skip field check + } + } + + private Set extractSdkFields(String content) { + Set fields = new HashSet<>(); + // Match: public java.lang.String fieldName; + // or: public java.util.List fieldName; + String[] lines = content.split("\n"); + for (String line : lines) { + line = line.trim(); + if (line.startsWith("public ") && line.endsWith(";") && + !line.contains("(") && !line.contains("class ") && + !line.contains("static ")) { + // extract field name (handle initializers like "sshPort = 22") + String withoutSemicolon = line.substring(0, line.length() - 1).trim(); + // strip initializer: "int sshPort = 22" -> "int sshPort" + int eqIdx = withoutSemicolon.indexOf('='); + if (eqIdx > 0) { + withoutSemicolon = withoutSemicolon.substring(0, eqIdx).trim(); + } + int lastSpace = withoutSemicolon.lastIndexOf(' '); + if (lastSpace > 0) { + String fieldName = withoutSemicolon.substring(lastSpace + 1); + fields.add(fieldName); + } + } + } + return fields; + } + + private String shortenPath(String path) { + if (path == null) return "unknown"; + int srcIdx = path.indexOf("src/main/java/"); + if (srcIdx >= 0) return path.substring(srcIdx + 14); + return path; + } +} diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/generator/GlobalConfigDocGenerator.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/generator/GlobalConfigDocGenerator.java new file mode 100644 index 0000000000..018b6d69d9 --- /dev/null +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/generator/GlobalConfigDocGenerator.java @@ -0,0 +1,116 @@ +package org.zstack.devtool.generator; + +import org.zstack.devtool.model.GlobalConfigInfo; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +public class GlobalConfigDocGenerator { + + public int generate(List configs, Path outputDir, boolean createOnly) { + int created = 0; + + for (GlobalConfigInfo config : configs) { + Path mdPath = outputDir.resolve(config.getCategory()).resolve(config.getName() + ".md"); + + if (createOnly && Files.exists(mdPath)) { + continue; + } + + // also check for deprecated version + Path deprecatedPath = outputDir.resolve(config.getCategory()) + .resolve(config.getName() + "#Deprecated.md"); + if (Files.exists(deprecatedPath)) { + continue; + } + + try { + Files.createDirectories(mdPath.getParent()); + String content = generateMarkdown(config); + Files.write(mdPath, content.getBytes(StandardCharsets.UTF_8)); + created++; + System.out.println(" Created: " + outputDir.relativize(mdPath)); + } catch (IOException e) { + System.err.println(" ERROR: Failed to write " + mdPath + ": " + e.getMessage()); + } + } + + return created; + } + + public static String generateMarkdown(GlobalConfigInfo config) { + StringBuilder sb = new StringBuilder(); + + sb.append("\n## Name\n\n```\n"); + sb.append(config.getName()).append("(##中文名-必填##)"); + sb.append("\n```\n\n"); + + sb.append("### Description\n\n```\n"); + sb.append(config.getDescription() != null ? config.getDescription() : ""); + sb.append("\n```\n\n"); + + sb.append("### 含义\n\n```\n"); + sb.append("##该条目的作用是什么-必填##"); + sb.append("\n```\n\n"); + + sb.append("### Type\n\n```\n"); + sb.append(config.getType()); + sb.append("\n```\n\n"); + + sb.append("### Category\n\n```\n"); + sb.append(config.getCategory()); + sb.append("\n```\n\n"); + + sb.append("### 取值范围\n\n```\n"); + sb.append(config.getValueRange()); + sb.append("\n```\n\n"); + + sb.append("### 取值范围补充说明\n\n```\n"); + sb.append("##对取值范围的解读-如无需写:无##"); + sb.append("\n```\n\n"); + + sb.append("### DefaultValue\n\n```\n"); + sb.append(config.getDefaultValue()); + sb.append("\n```\n\n"); + + sb.append("### 默认值补充说明\n\n```\n"); + sb.append("##对默认值的解读-如无需写:无##"); + sb.append("\n```\n\n"); + + sb.append("### 支持的资源级配置\n\n"); + List resources = config.getBindResources(); + if (resources != null && !resources.isEmpty()) { + sb.append("||\n|---|\n"); + for (String res : resources) { + sb.append("|").append(res).append("\n"); + } + } + sb.append("\n"); + + sb.append("### 资源粒度说明\n\n```\n"); + sb.append("##该条目支持的资源粒度-如无需写:无##"); + sb.append("\n```\n\n"); + + sb.append("### 背景信息\n\n```\n"); + sb.append("##触发该条目增删改的背景-如无需写:无##"); + sb.append("\n```\n\n"); + + sb.append("### UI暴露\n\n```\n"); + sb.append("##该条目是否需UI暴露?-必填##"); + sb.append("\n```\n\n"); + + sb.append("### CLI手册暴露\n\n```\n"); + sb.append("##该条目是否需CLI手册暴露?-必填##"); + sb.append("\n```\n\n"); + + sb.append("## 注意事项\n\n```\n"); + sb.append("##该条目有哪些注意事项-如无需写:无##"); + sb.append("\n```\n"); + + return sb.toString(); + } +} diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/model/ApiMessageInfo.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/model/ApiMessageInfo.java new file mode 100644 index 0000000000..49a7602f5c --- /dev/null +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/model/ApiMessageInfo.java @@ -0,0 +1,76 @@ +package org.zstack.devtool.model; + +import java.util.ArrayList; +import java.util.List; + +public class ApiMessageInfo { + private String className; // e.g. "APICreateZoneMsg" + private String packageName; // e.g. "org.zstack.header.zone" + private String sourceFile; + + // from @RestRequest + private String path; + private String httpMethod; // POST, GET, PUT, DELETE + private String responseClass; // simple name of response class + private String parameterName; // defaults to "params" + private boolean isAction; + private List optionalPaths = new ArrayList<>(); + + // from class hierarchy + private String parentClass; // e.g. "APICreateMessage" + private boolean isQuery; // extends APIQueryMessage + private boolean suppressCredentialCheck; + + // fields + private List params = new ArrayList<>(); + + // derived + public String getActionName() { + // APICreateZoneMsg -> CreateZoneAction + String name = className; + if (name.startsWith("API")) name = name.substring(3); + if (name.endsWith("Msg")) name = name.substring(0, name.length() - 3); + return name + "Action"; + } + + public String getResultName() { + String name = className; + if (name.startsWith("API")) name = name.substring(3); + if (name.endsWith("Msg")) name = name.substring(0, name.length() - 3); + return name + "Result"; + } + + public String getHelperMethodName() { + // CreateZoneAction -> createZone + String action = getActionName(); + String name = action.substring(0, action.length() - 6); // strip "Action" + return Character.toLowerCase(name.charAt(0)) + name.substring(1); + } + + public String getClassName() { return className; } + public void setClassName(String v) { this.className = v; } + public String getPackageName() { return packageName; } + public void setPackageName(String v) { this.packageName = v; } + public String getSourceFile() { return sourceFile; } + public void setSourceFile(String v) { this.sourceFile = v; } + public String getPath() { return path; } + public void setPath(String v) { this.path = v; } + public String getHttpMethod() { return httpMethod; } + public void setHttpMethod(String v) { this.httpMethod = v; } + public String getResponseClass() { return responseClass; } + public void setResponseClass(String v) { this.responseClass = v; } + public String getParameterName() { return parameterName; } + public void setParameterName(String v) { this.parameterName = v; } + public boolean isAction() { return isAction; } + public void setAction(boolean v) { this.isAction = v; } + public List getOptionalPaths() { return optionalPaths; } + public void setOptionalPaths(List v) { this.optionalPaths = v; } + public String getParentClass() { return parentClass; } + public void setParentClass(String v) { this.parentClass = v; } + public boolean isQuery() { return isQuery; } + public void setQuery(boolean v) { this.isQuery = v; } + public boolean isSuppressCredentialCheck() { return suppressCredentialCheck; } + public void setSuppressCredentialCheck(boolean v) { this.suppressCredentialCheck = v; } + public List getParams() { return params; } + public void setParams(List v) { this.params = v; } +} diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/model/ApiParamInfo.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/model/ApiParamInfo.java new file mode 100644 index 0000000000..0e74cbb715 --- /dev/null +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/model/ApiParamInfo.java @@ -0,0 +1,47 @@ +package org.zstack.devtool.model; + +public class ApiParamInfo { + private String fieldName; + private String fieldType; // fully qualified, e.g. "java.lang.String" + private boolean required = true; + private boolean noSee; // @APINoSee - excluded from SDK + private int maxLength = 0; + private int minLength = 0; + private String validRegexValues; + private String[] validValues; + private boolean nonempty; + private boolean nullElements; + private boolean emptyString = true; + private boolean noTrim; + private long[] numberRange; + private boolean inherited; // from parent class, handled by SDK base class + + public String getFieldName() { return fieldName; } + public void setFieldName(String v) { this.fieldName = v; } + public String getFieldType() { return fieldType; } + public void setFieldType(String v) { this.fieldType = v; } + public boolean isRequired() { return required; } + public void setRequired(boolean v) { this.required = v; } + public boolean isNoSee() { return noSee; } + public void setNoSee(boolean v) { this.noSee = v; } + public int getMaxLength() { return maxLength; } + public void setMaxLength(int v) { this.maxLength = v; } + public int getMinLength() { return minLength; } + public void setMinLength(int v) { this.minLength = v; } + public String getValidRegexValues() { return validRegexValues; } + public void setValidRegexValues(String v) { this.validRegexValues = v; } + public String[] getValidValues() { return validValues; } + public void setValidValues(String[] v) { this.validValues = v; } + public boolean isNonempty() { return nonempty; } + public void setNonempty(boolean v) { this.nonempty = v; } + public boolean isNullElements() { return nullElements; } + public void setNullElements(boolean v) { this.nullElements = v; } + public boolean isEmptyString() { return emptyString; } + public void setEmptyString(boolean v) { this.emptyString = v; } + public boolean isNoTrim() { return noTrim; } + public void setNoTrim(boolean v) { this.noTrim = v; } + public long[] getNumberRange() { return numberRange; } + public void setNumberRange(long[] v) { this.numberRange = v; } + public boolean isInherited() { return inherited; } + public void setInherited(boolean v) { this.inherited = v; } +} diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/model/GlobalConfigInfo.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/model/GlobalConfigInfo.java new file mode 100644 index 0000000000..2086e16e88 --- /dev/null +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/model/GlobalConfigInfo.java @@ -0,0 +1,93 @@ +package org.zstack.devtool.model; + +import java.util.ArrayList; +import java.util.List; + +public class GlobalConfigInfo { + private String category; + private String name; + private String type; + private String defaultValue; + private String description; + private String validatorRegularExpression; + + // from @GlobalConfigValidation + private long numberGreaterThan = Long.MIN_VALUE; + private long numberLessThan = Long.MAX_VALUE; + private long[] inNumberRange = {}; + private String[] validValues = {}; + + // from @BindResourceConfig + private List bindResources = new ArrayList<>(); + + // source location + private String sourceFile; + private String fieldName; + + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getType() { return type; } + public void setType(String type) { this.type = type; } + public String getDefaultValue() { return defaultValue; } + public void setDefaultValue(String defaultValue) { this.defaultValue = defaultValue; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public String getValidatorRegularExpression() { return validatorRegularExpression; } + public void setValidatorRegularExpression(String v) { this.validatorRegularExpression = v; } + public long getNumberGreaterThan() { return numberGreaterThan; } + public void setNumberGreaterThan(long v) { this.numberGreaterThan = v; } + public long getNumberLessThan() { return numberLessThan; } + public void setNumberLessThan(long v) { this.numberLessThan = v; } + public long[] getInNumberRange() { return inNumberRange; } + public void setInNumberRange(long[] v) { this.inNumberRange = v; } + public String[] getValidValues() { return validValues; } + public void setValidValues(String[] v) { this.validValues = v; } + public List getBindResources() { return bindResources; } + public void setBindResources(List v) { this.bindResources = v; } + public String getSourceFile() { return sourceFile; } + public void setSourceFile(String sourceFile) { this.sourceFile = sourceFile; } + public String getFieldName() { return fieldName; } + public void setFieldName(String fieldName) { this.fieldName = fieldName; } + + public String getValueRange() { + if (validValues != null && validValues.length > 0) { + StringBuilder sb = new StringBuilder("{"); + for (int i = 0; i < validValues.length; i++) { + if (i > 0) sb.append(", "); + sb.append(validValues[i]); + } + sb.append("}"); + return sb.toString(); + } + + if (inNumberRange != null && inNumberRange.length == 2) { + return "[" + inNumberRange[0] + ", " + inNumberRange[1] + "]"; + } + + if (numberGreaterThan != Long.MIN_VALUE || numberLessThan != Long.MAX_VALUE) { + return "[" + numberGreaterThan + ", " + numberLessThan + "]"; + } + + // default range based on type + if (type != null) { + switch (type) { + case "java.lang.Long": + return "[" + Long.MIN_VALUE + ", " + Long.MAX_VALUE + "]"; + case "java.lang.Integer": + return "[" + Integer.MIN_VALUE + ", " + Integer.MAX_VALUE + "]"; + case "java.lang.Boolean": + return "{true, false}"; + default: + return ""; + } + } + return ""; + } + + @Override + public String toString() { + return category + "/" + name + " (" + type + ", default=" + defaultValue + ")"; + } +} diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/ApiMessageScanner.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/ApiMessageScanner.java new file mode 100644 index 0000000000..d0d15af0ca --- /dev/null +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/ApiMessageScanner.java @@ -0,0 +1,392 @@ +package org.zstack.devtool.scanner; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParseResult; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.FieldDeclaration; +import com.github.javaparser.ast.body.VariableDeclarator; +import com.github.javaparser.ast.expr.*; +import com.github.javaparser.ast.type.ClassOrInterfaceType; +import org.zstack.devtool.model.ApiMessageInfo; +import org.zstack.devtool.model.ApiParamInfo; + +import java.io.IOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; + +public class ApiMessageScanner { + + private final JavaParser parser = new JavaParser(); + // className -> source path, for inheritance resolution + private final Map classIndex = new HashMap<>(); + // className -> parsed CompilationUnit cache + private final Map cuCache = new HashMap<>(); + + public List scan(List sourceDirs) { + // Phase 1: build class index (file name -> path) + for (Path dir : sourceDirs) { + if (!Files.isDirectory(dir)) continue; + try { + buildIndex(dir); + } catch (IOException e) { + System.err.println("WARN: Failed to index " + dir + ": " + e.getMessage()); + } + } + + // Phase 2: scan for @RestRequest annotated classes + List results = new ArrayList<>(); + for (Path dir : sourceDirs) { + if (!Files.isDirectory(dir)) continue; + try { + scanDirectory(dir, results); + } catch (IOException e) { + System.err.println("WARN: Failed to scan " + dir + ": " + e.getMessage()); + } + } + + return results; + } + + private void buildIndex(Path dir) throws IOException { + Files.walkFileTree(dir, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) { + String fileName = path.getFileName().toString(); + if (fileName.endsWith(".java")) { + String className = fileName.substring(0, fileName.length() - 5); + classIndex.put(className, path); + } + return FileVisitResult.CONTINUE; + } + }); + } + + private void scanDirectory(Path dir, List results) throws IOException { + Files.walkFileTree(dir, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) { + String fileName = path.getFileName().toString(); + // Only scan API*.java files for @RestRequest + if (fileName.startsWith("API") && fileName.endsWith(".java")) { + try { + scanFile(path, results); + } catch (Exception e) { + System.err.println("WARN: Failed to parse " + path + ": " + e.getMessage()); + } + } + return FileVisitResult.CONTINUE; + } + }); + } + + private void scanFile(Path path, List results) throws IOException { + CompilationUnit cu = parseCached(path); + if (cu == null) return; + + for (ClassOrInterfaceDeclaration cls : cu.findAll(ClassOrInterfaceDeclaration.class)) { + if (!cls.getAnnotationByName("RestRequest").isPresent()) continue; + if (cls.isAbstract()) continue; + if (cls.getAnnotationByName("NoSDK").isPresent()) continue; + + ApiMessageInfo info = new ApiMessageInfo(); + info.setClassName(cls.getNameAsString()); + info.setSourceFile(path.toString()); + + // package + cu.getPackageDeclaration().ifPresent(pd -> info.setPackageName(pd.getNameAsString())); + + // @RestRequest + extractRestRequest(cls, info); + + // parent class analysis + analyzeParentClass(cls, info); + + // @SuppressCredentialCheck + info.setSuppressCredentialCheck(cls.getAnnotationByName("SuppressCredentialCheck").isPresent()); + + // fields with @APIParam (own fields) + List params = extractParams(cls); + + // inherited fields (from parent classes) - marked as inherited + List inheritedParams = resolveInheritedParams(cls); + for (ApiParamInfo p : inheritedParams) { + p.setInherited(true); + } + params.addAll(inheritedParams); + + info.setParams(params); + results.add(info); + } + } + + private void extractRestRequest(ClassOrInterfaceDeclaration cls, ApiMessageInfo info) { + cls.getAnnotationByName("RestRequest").ifPresent(ann -> { + if (ann.isNormalAnnotationExpr()) { + NormalAnnotationExpr normal = ann.asNormalAnnotationExpr(); + for (MemberValuePair pair : normal.getPairs()) { + String key = pair.getNameAsString(); + Expression value = pair.getValue(); + + switch (key) { + case "path": + if (value.isStringLiteralExpr()) + info.setPath(value.asStringLiteralExpr().getValue()); + break; + case "method": + info.setHttpMethod(resolveHttpMethod(value)); + break; + case "responseClass": + info.setResponseClass(resolveClassName(value)); + break; + case "parameterName": + if (value.isStringLiteralExpr()) + info.setParameterName(value.asStringLiteralExpr().getValue()); + break; + case "isAction": + if (value.isBooleanLiteralExpr()) + info.setAction(value.asBooleanLiteralExpr().getValue()); + break; + case "optionalPaths": + info.setOptionalPaths(parseStringList(value)); + break; + } + } + } + }); + + // defaults + if (info.getParameterName() == null) info.setParameterName("params"); + if (info.getHttpMethod() == null) info.setHttpMethod("POST"); + } + + private List extractParams(ClassOrInterfaceDeclaration cls) { + List params = new ArrayList<>(); + + for (FieldDeclaration field : cls.getFields()) { + if (field.isStatic()) continue; + + boolean hasApiParam = field.getAnnotationByName("APIParam").isPresent(); + boolean hasApiNoSee = field.getAnnotationByName("APINoSee").isPresent(); + + // SDK only includes fields with @APIParam (excluding @APINoSee) + if (!hasApiParam) continue; + if (hasApiNoSee) continue; + + for (VariableDeclarator var : field.getVariables()) { + ApiParamInfo param = new ApiParamInfo(); + param.setFieldName(var.getNameAsString()); + param.setFieldType(resolveFieldType(field)); + param.setNoSee(hasApiNoSee); + + if (hasApiParam) { + extractApiParam(field, param); + } + + params.add(param); + } + } + + return params; + } + + private void extractApiParam(FieldDeclaration field, ApiParamInfo param) { + field.getAnnotationByName("APIParam").ifPresent(ann -> { + if (ann.isNormalAnnotationExpr()) { + NormalAnnotationExpr normal = ann.asNormalAnnotationExpr(); + for (MemberValuePair pair : normal.getPairs()) { + String key = pair.getNameAsString(); + Expression value = pair.getValue(); + + switch (key) { + case "required": + if (value.isBooleanLiteralExpr()) + param.setRequired(value.asBooleanLiteralExpr().getValue()); + break; + case "maxLength": + param.setMaxLength(parseIntValue(value)); + break; + case "minLength": + param.setMinLength(parseIntValue(value)); + break; + case "validRegexValues": + if (value.isStringLiteralExpr()) + param.setValidRegexValues(value.asStringLiteralExpr().getValue()); + break; + case "validValues": + param.setValidValues(parseStringArray(value)); + break; + case "nonempty": + if (value.isBooleanLiteralExpr()) + param.setNonempty(value.asBooleanLiteralExpr().getValue()); + break; + case "nullElements": + if (value.isBooleanLiteralExpr()) + param.setNullElements(value.asBooleanLiteralExpr().getValue()); + break; + case "emptyString": + if (value.isBooleanLiteralExpr()) + param.setEmptyString(value.asBooleanLiteralExpr().getValue()); + break; + case "noTrim": + if (value.isBooleanLiteralExpr()) + param.setNoTrim(value.asBooleanLiteralExpr().getValue()); + break; + case "numberRange": + param.setNumberRange(parseLongArray(value)); + break; + } + } + } + // @APIParam with no explicit required → default is true + }); + } + + private void analyzeParentClass(ClassOrInterfaceDeclaration cls, ApiMessageInfo info) { + for (ClassOrInterfaceType parent : cls.getExtendedTypes()) { + String parentName = parent.getNameAsString(); + info.setParentClass(parentName); + + // Check if it's a query message + if (parentName.contains("QueryMessage") || parentName.contains("APIQueryMsg")) { + info.setQuery(true); + } + } + } + + private List resolveInheritedParams(ClassOrInterfaceDeclaration cls) { + List inherited = new ArrayList<>(); + Set visited = new HashSet<>(); + visited.add(cls.getNameAsString()); + + for (ClassOrInterfaceType parentType : cls.getExtendedTypes()) { + collectParentParams(parentType.getNameAsString(), inherited, visited); + } + + return inherited; + } + + private void collectParentParams(String className, List params, Set visited) { + if (visited.contains(className)) return; + visited.add(className); + + // Skip known base classes that don't have API params + if ("APIMessage".equals(className) || "NeedReplyMessage".equals(className) || + "Message".equals(className) || "Object".equals(className)) { + return; + } + + Path parentPath = classIndex.get(className); + if (parentPath == null) return; + + CompilationUnit parentCu = parseCached(parentPath); + if (parentCu == null) return; + + for (ClassOrInterfaceDeclaration parentCls : parentCu.findAll(ClassOrInterfaceDeclaration.class)) { + if (!parentCls.getNameAsString().equals(className)) continue; + + // Extract params from parent + params.addAll(extractParams(parentCls)); + + // Recurse into grandparent + for (ClassOrInterfaceType grandparent : parentCls.getExtendedTypes()) { + collectParentParams(grandparent.getNameAsString(), params, visited); + } + } + } + + private CompilationUnit parseCached(Path path) { + String key = path.toString(); + if (cuCache.containsKey(key)) return cuCache.get(key); + + try { + ParseResult result = parser.parse(path); + CompilationUnit cu = result.isSuccessful() && result.getResult().isPresent() + ? result.getResult().get() : null; + cuCache.put(key, cu); + return cu; + } catch (IOException e) { + cuCache.put(key, null); + return null; + } + } + + private String resolveHttpMethod(Expression expr) { + String text = expr.toString(); + // HttpMethod.POST -> POST + if (text.contains(".")) { + return text.substring(text.lastIndexOf('.') + 1); + } + return text; + } + + private String resolveClassName(Expression expr) { + String text = expr.toString(); + if (text.endsWith(".class")) { + return text.substring(0, text.length() - 6); + } + return text; + } + + private String resolveFieldType(FieldDeclaration field) { + String type = field.getElementType().asString(); + // Map common types to fully qualified names + switch (type) { + case "String": return "java.lang.String"; + case "Long": return "java.lang.Long"; + case "long": return "long"; + case "Integer": return "java.lang.Integer"; + case "int": return "int"; + case "Boolean": return "java.lang.Boolean"; + case "boolean": return "boolean"; + case "Double": return "java.lang.Double"; + case "Float": return "java.lang.Float"; + case "List": return "java.util.List"; + case "Map": return "java.util.Map"; + case "Set": return "java.util.Set"; + default: return type; + } + } + + private int parseIntValue(Expression expr) { + if (expr.isIntegerLiteralExpr()) return expr.asIntegerLiteralExpr().asInt(); + try { return Integer.parseInt(expr.toString()); } catch (NumberFormatException e) { return 0; } + } + + private String[] parseStringArray(Expression expr) { + if (expr.isArrayInitializerExpr()) { + List values = expr.asArrayInitializerExpr().getValues(); + String[] result = new String[values.size()]; + for (int i = 0; i < values.size(); i++) { + Expression v = values.get(i); + result[i] = v.isStringLiteralExpr() ? v.asStringLiteralExpr().getValue() : v.toString(); + } + return result; + } + return new String[0]; + } + + private long[] parseLongArray(Expression expr) { + if (expr.isArrayInitializerExpr()) { + List values = expr.asArrayInitializerExpr().getValues(); + long[] result = new long[values.size()]; + for (int i = 0; i < values.size(); i++) { + try { result[i] = Long.parseLong(values.get(i).toString().replaceAll("[Ll]$", "")); } + catch (NumberFormatException e) { result[i] = 0; } + } + return result; + } + return new long[0]; + } + + private List parseStringList(Expression expr) { + List result = new ArrayList<>(); + if (expr.isArrayInitializerExpr()) { + for (Expression v : expr.asArrayInitializerExpr().getValues()) { + if (v.isStringLiteralExpr()) result.add(v.asStringLiteralExpr().getValue()); + } + } + return result; + } +} diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/GlobalConfigScanner.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/GlobalConfigScanner.java new file mode 100644 index 0000000000..219d448932 --- /dev/null +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/GlobalConfigScanner.java @@ -0,0 +1,321 @@ +package org.zstack.devtool.scanner; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParseResult; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.FieldDeclaration; +import com.github.javaparser.ast.body.VariableDeclarator; +import com.github.javaparser.ast.expr.*; +import com.github.javaparser.ast.nodeTypes.NodeWithAnnotations; +import org.zstack.devtool.model.GlobalConfigInfo; + +import java.io.IOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; +import java.util.stream.Collectors; + +public class GlobalConfigScanner { + + private final JavaParser parser = new JavaParser(); + + public List scan(List sourceDirs) { + List results = new ArrayList<>(); + + for (Path dir : sourceDirs) { + if (!Files.isDirectory(dir)) continue; + try { + scanDirectory(dir, results); + } catch (IOException e) { + System.err.println("WARN: Failed to scan " + dir + ": " + e.getMessage()); + } + } + + return results; + } + + private void scanDirectory(Path dir, List results) throws IOException { + Files.walkFileTree(dir, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) { + String fileName = path.getFileName().toString(); + if (fileName.endsWith(".java") && fileName.contains("GlobalConfig")) { + try { + scanFile(path, results); + } catch (Exception e) { + System.err.println("WARN: Failed to parse " + path + ": " + e.getMessage()); + } + } + return FileVisitResult.CONTINUE; + } + }); + } + + private void scanFile(Path path, List results) throws IOException { + ParseResult parseResult = parser.parse(path); + if (!parseResult.isSuccessful() || !parseResult.getResult().isPresent()) { + return; + } + + CompilationUnit cu = parseResult.getResult().get(); + + for (ClassOrInterfaceDeclaration cls : cu.findAll(ClassOrInterfaceDeclaration.class)) { + if (!hasAnnotation(cls, "GlobalConfigDefinition")) continue; + + String category = extractCategory(cls); + if (category == null) continue; + + for (FieldDeclaration field : cls.getFields()) { + if (!field.isStatic()) continue; + if (!isGlobalConfigType(field)) continue; + if (!hasAnnotation(field, "GlobalConfigDef")) continue; + + GlobalConfigInfo info = extractConfigInfo(field, category, path.toString()); + if (info != null) { + results.add(info); + } + } + } + } + + private GlobalConfigInfo extractConfigInfo(FieldDeclaration field, String category, String sourceFile) { + GlobalConfigInfo info = new GlobalConfigInfo(); + info.setCategory(category); + info.setSourceFile(sourceFile); + + // field name + if (!field.getVariables().isEmpty()) { + info.setFieldName(field.getVariable(0).getNameAsString()); + } + + // extract config name from: new GlobalConfig(CATEGORY, "name") + String configName = extractConfigName(field); + if (configName == null) return null; + info.setName(configName); + + // @GlobalConfigDef + extractGlobalConfigDef(field, info); + + // @GlobalConfigValidation + extractGlobalConfigValidation(field, info); + + // @BindResourceConfig + extractBindResourceConfig(field, info); + + return info; + } + + private String extractConfigName(FieldDeclaration field) { + for (VariableDeclarator var : field.getVariables()) { + if (!var.getInitializer().isPresent()) continue; + Expression init = var.getInitializer().get(); + + if (init.isObjectCreationExpr()) { + ObjectCreationExpr ctor = init.asObjectCreationExpr(); + List args = ctor.getArguments(); + if (args.size() >= 2) { + Expression nameArg = args.get(1); + if (nameArg.isStringLiteralExpr()) { + return nameArg.asStringLiteralExpr().getValue(); + } + } + } + } + return null; + } + + private void extractGlobalConfigDef(FieldDeclaration field, GlobalConfigInfo info) { + field.getAnnotationByName("GlobalConfigDef").ifPresent(ann -> { + if (ann.isNormalAnnotationExpr()) { + NormalAnnotationExpr normal = ann.asNormalAnnotationExpr(); + for (MemberValuePair pair : normal.getPairs()) { + String key = pair.getNameAsString(); + Expression value = pair.getValue(); + + switch (key) { + case "type": + info.setType(resolveTypeClass(value)); + break; + case "defaultValue": + if (value.isStringLiteralExpr()) { + info.setDefaultValue(value.asStringLiteralExpr().getValue()); + } + break; + case "description": + if (value.isStringLiteralExpr()) { + info.setDescription(value.asStringLiteralExpr().getValue()); + } + break; + case "validatorRegularExpression": + if (value.isStringLiteralExpr()) { + info.setValidatorRegularExpression(value.asStringLiteralExpr().getValue()); + } + break; + } + } + } + }); + + // defaults + if (info.getType() == null) info.setType("java.lang.String"); + if (info.getDefaultValue() == null) info.setDefaultValue(""); + if (info.getDescription() == null) info.setDescription(""); + } + + private void extractGlobalConfigValidation(FieldDeclaration field, GlobalConfigInfo info) { + field.getAnnotationByName("GlobalConfigValidation").ifPresent(ann -> { + if (ann.isNormalAnnotationExpr()) { + NormalAnnotationExpr normal = ann.asNormalAnnotationExpr(); + for (MemberValuePair pair : normal.getPairs()) { + String key = pair.getNameAsString(); + Expression value = pair.getValue(); + + switch (key) { + case "numberGreaterThan": + info.setNumberGreaterThan(parseLong(value)); + break; + case "numberLessThan": + info.setNumberLessThan(parseLong(value)); + break; + case "inNumberRange": + info.setInNumberRange(parseLongArray(value)); + break; + case "validValues": + info.setValidValues(parseStringArray(value)); + break; + } + } + } + // @GlobalConfigValidation with no params = use defaults (already set) + }); + } + + private void extractBindResourceConfig(FieldDeclaration field, GlobalConfigInfo info) { + field.getAnnotationByName("BindResourceConfig").ifPresent(ann -> { + List resources = new ArrayList<>(); + + if (ann.isSingleMemberAnnotationExpr()) { + Expression value = ann.asSingleMemberAnnotationExpr().getMemberValue(); + extractClassReferences(value, resources); + } else if (ann.isNormalAnnotationExpr()) { + NormalAnnotationExpr normal = ann.asNormalAnnotationExpr(); + for (MemberValuePair pair : normal.getPairs()) { + if ("value".equals(pair.getNameAsString())) { + extractClassReferences(pair.getValue(), resources); + } + } + } + + info.setBindResources(resources); + }); + } + + private void extractClassReferences(Expression expr, List resources) { + if (expr.isArrayInitializerExpr()) { + for (Expression element : expr.asArrayInitializerExpr().getValues()) { + extractClassReferences(element, resources); + } + } else if (expr.isClassExpr()) { + resources.add(expr.asClassExpr().getType().asString()); + } else if (expr.isFieldAccessExpr()) { + // e.g., VmInstanceVO.class + String text = expr.toString(); + if (text.endsWith(".class")) { + resources.add(text.substring(0, text.length() - 6)); + } + } + } + + private String resolveTypeClass(Expression expr) { + // Handle: String.class, Long.class, Integer.class, Boolean.class, etc. + String text = expr.toString(); + if (text.endsWith(".class")) { + String simpleName = text.substring(0, text.length() - 6); + switch (simpleName) { + case "String": return "java.lang.String"; + case "Long": return "java.lang.Long"; + case "Integer": return "java.lang.Integer"; + case "Boolean": return "java.lang.Boolean"; + case "Float": return "java.lang.Float"; + case "Double": return "java.lang.Double"; + default: return "java.lang." + simpleName; + } + } + return "java.lang.String"; + } + + private String extractCategory(ClassOrInterfaceDeclaration cls) { + // Look for: public static final String CATEGORY = "xxx"; + for (FieldDeclaration field : cls.getFields()) { + for (VariableDeclarator var : field.getVariables()) { + if ("CATEGORY".equals(var.getNameAsString()) && var.getInitializer().isPresent()) { + Expression init = var.getInitializer().get(); + if (init.isStringLiteralExpr()) { + return init.asStringLiteralExpr().getValue(); + } + } + } + } + return null; + } + + private boolean hasAnnotation(NodeWithAnnotations node, String name) { + return node.getAnnotationByName(name).isPresent(); + } + + private boolean isGlobalConfigType(FieldDeclaration field) { + String typeStr = field.getElementType().asString(); + return "GlobalConfig".equals(typeStr) || typeStr.endsWith(".GlobalConfig"); + } + + private long parseLong(Expression expr) { + if (expr.isLongLiteralExpr()) { + return expr.asLongLiteralExpr().asLong(); + } + if (expr.isIntegerLiteralExpr()) { + return expr.asIntegerLiteralExpr().asInt(); + } + if (expr.isUnaryExpr() && expr.asUnaryExpr().getOperator() == UnaryExpr.Operator.MINUS) { + return -parseLong(expr.asUnaryExpr().getExpression()); + } + // Handle Long.MIN_VALUE, Long.MAX_VALUE etc. + String text = expr.toString(); + if (text.contains("MIN_VALUE")) return Long.MIN_VALUE; + if (text.contains("MAX_VALUE")) return Long.MAX_VALUE; + try { + return Long.parseLong(text.replaceAll("[Ll]$", "")); + } catch (NumberFormatException e) { + return 0; + } + } + + private long[] parseLongArray(Expression expr) { + if (expr.isArrayInitializerExpr()) { + List values = expr.asArrayInitializerExpr().getValues(); + long[] result = new long[values.size()]; + for (int i = 0; i < values.size(); i++) { + result[i] = parseLong(values.get(i)); + } + return result; + } + return new long[0]; + } + + private String[] parseStringArray(Expression expr) { + if (expr.isArrayInitializerExpr()) { + List values = expr.asArrayInitializerExpr().getValues(); + String[] result = new String[values.size()]; + for (int i = 0; i < values.size(); i++) { + Expression v = values.get(i); + if (v.isStringLiteralExpr()) { + result[i] = v.asStringLiteralExpr().getValue(); + } else { + result[i] = v.toString(); + } + } + return result; + } + return new String[0]; + } +} diff --git a/zstack-dev-tool/src/test/java/org/zstack/devtool/GlobalConfigScannerTest.java b/zstack-dev-tool/src/test/java/org/zstack/devtool/GlobalConfigScannerTest.java new file mode 100644 index 0000000000..be9f7d3b31 --- /dev/null +++ b/zstack-dev-tool/src/test/java/org/zstack/devtool/GlobalConfigScannerTest.java @@ -0,0 +1,206 @@ +package org.zstack.devtool; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.zstack.devtool.checker.GlobalConfigDocChecker; +import org.zstack.devtool.generator.GlobalConfigDocGenerator; +import org.zstack.devtool.model.GlobalConfigInfo; +import org.zstack.devtool.scanner.GlobalConfigScanner; + +import java.io.File; +import java.io.FileWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.*; + +public class GlobalConfigScannerTest { + + @Rule + public TemporaryFolder tempDir = new TemporaryFolder(); + + private File createJavaFile(String filename, String content) throws Exception { + File dir = tempDir.newFolder("src", "main", "java"); + File file = new File(dir, filename); + try (FileWriter w = new FileWriter(file)) { + w.write(content); + } + return dir; + } + + @Test + public void testScanBasicGlobalConfig() throws Exception { + String source = + "package org.zstack.test;\n" + + "import org.zstack.core.config.*;\n" + + "@GlobalConfigDefinition\n" + + "public class TestGlobalConfig {\n" + + " public static final String CATEGORY = \"test\";\n" + + " @GlobalConfigValidation\n" + + " @GlobalConfigDef(type = Long.class, defaultValue = \"60\", description = \"test desc\")\n" + + " public static GlobalConfig FOO = new GlobalConfig(CATEGORY, \"foo.bar\");\n" + + "}\n"; + + File srcDir = createJavaFile("TestGlobalConfig.java", source); + GlobalConfigScanner scanner = new GlobalConfigScanner(); + List results = scanner.scan(Collections.singletonList(srcDir.toPath())); + + assertEquals(1, results.size()); + GlobalConfigInfo info = results.get(0); + assertEquals("test", info.getCategory()); + assertEquals("foo.bar", info.getName()); + assertEquals("java.lang.Long", info.getType()); + assertEquals("60", info.getDefaultValue()); + assertEquals("test desc", info.getDescription()); + } + + @Test + public void testScanWithValidValues() throws Exception { + String source = + "package org.zstack.test;\n" + + "import org.zstack.core.config.*;\n" + + "@GlobalConfigDefinition\n" + + "public class TestGlobalConfig {\n" + + " public static final String CATEGORY = \"vm\";\n" + + " @GlobalConfigValidation(validValues = {\"cirrus\", \"vga\", \"qxl\"})\n" + + " @GlobalConfigDef(type = String.class, defaultValue = \"vga\")\n" + + " public static GlobalConfig VIDEO = new GlobalConfig(CATEGORY, \"videoType\");\n" + + "}\n"; + + File srcDir = createJavaFile("TestGlobalConfig.java", source); + GlobalConfigScanner scanner = new GlobalConfigScanner(); + List results = scanner.scan(Collections.singletonList(srcDir.toPath())); + + assertEquals(1, results.size()); + GlobalConfigInfo info = results.get(0); + assertEquals("{cirrus, vga, qxl}", info.getValueRange()); + } + + @Test + public void testScanWithNumberRange() throws Exception { + String source = + "package org.zstack.test;\n" + + "import org.zstack.core.config.*;\n" + + "@GlobalConfigDefinition\n" + + "public class TestGlobalConfig {\n" + + " public static final String CATEGORY = \"test\";\n" + + " @GlobalConfigValidation(numberGreaterThan = 0, numberLessThan = 65535)\n" + + " @GlobalConfigDef(type = Integer.class, defaultValue = \"8080\")\n" + + " public static GlobalConfig PORT = new GlobalConfig(CATEGORY, \"port\");\n" + + "}\n"; + + File srcDir = createJavaFile("TestGlobalConfig.java", source); + GlobalConfigScanner scanner = new GlobalConfigScanner(); + List results = scanner.scan(Collections.singletonList(srcDir.toPath())); + + assertEquals(1, results.size()); + assertEquals("[0, 65535]", results.get(0).getValueRange()); + } + + @Test + public void testCheckerDetectsMissing() throws Exception { + GlobalConfigInfo info = new GlobalConfigInfo(); + info.setCategory("test"); + info.setName("missing.config"); + info.setType("java.lang.String"); + info.setDefaultValue("abc"); + info.setSourceFile("TestGlobalConfig.java"); + info.setFieldName("MISSING"); + + Path docDir = tempDir.newFolder("doc").toPath(); + GlobalConfigDocChecker checker = new GlobalConfigDocChecker(); + GlobalConfigDocChecker.CheckResult result = checker.check( + Collections.singletonList(info), docDir); + + assertFalse(result.passed()); + assertEquals(1, result.missing.size()); + assertEquals("missing.config", result.missing.get(0).getName()); + } + + @Test + public void testGeneratorCreatesMissing() throws Exception { + GlobalConfigInfo info = new GlobalConfigInfo(); + info.setCategory("test"); + info.setName("new.config"); + info.setType("java.lang.Integer"); + info.setDefaultValue("42"); + info.setDescription("a test config"); + + Path docDir = tempDir.newFolder("doc").toPath(); + GlobalConfigDocGenerator generator = new GlobalConfigDocGenerator(); + int created = generator.generate(Collections.singletonList(info), docDir, true); + + assertEquals(1, created); + Path mdFile = docDir.resolve("test/new.config.md"); + assertTrue(Files.exists(mdFile)); + String content = new String(Files.readAllBytes(mdFile)); + assertTrue(content.contains("new.config")); + assertTrue(content.contains("java.lang.Integer")); + assertTrue(content.contains("42")); + assertTrue(content.contains("a test config")); + } + + @Test + public void testGeneratorSkipsExisting() throws Exception { + GlobalConfigInfo info = new GlobalConfigInfo(); + info.setCategory("test"); + info.setName("existing"); + info.setType("java.lang.String"); + info.setDefaultValue("x"); + + Path docDir = tempDir.newFolder("doc").toPath(); + Files.createDirectories(docDir.resolve("test")); + Files.write(docDir.resolve("test/existing.md"), "existing content".getBytes()); + + GlobalConfigDocGenerator generator = new GlobalConfigDocGenerator(); + int created = generator.generate(Collections.singletonList(info), docDir, true); + + assertEquals(0, created); + String content = new String(Files.readAllBytes(docDir.resolve("test/existing.md"))); + assertEquals("existing content", content); + } + + @Test + public void testRoundTrip() throws Exception { + GlobalConfigInfo info = new GlobalConfigInfo(); + info.setCategory("ai"); + info.setName("test.round.trip"); + info.setType("java.lang.Long"); + info.setDefaultValue("30"); + info.setDescription("round trip test"); + info.setSourceFile("Test.java"); + info.setFieldName("TEST"); + + Path docDir = tempDir.newFolder("doc").toPath(); + List configs = Collections.singletonList(info); + + // generate + GlobalConfigDocGenerator generator = new GlobalConfigDocGenerator(); + generator.generate(configs, docDir, true); + + // check should pass + GlobalConfigDocChecker checker = new GlobalConfigDocChecker(); + GlobalConfigDocChecker.CheckResult result = checker.check(configs, docDir); + assertTrue(result.passed()); + } + + @Test + public void testSkipsNonGlobalConfigDefinition() throws Exception { + String source = + "package org.zstack.test;\n" + + "public class NotAGlobalConfig {\n" + + " public static final String CATEGORY = \"test\";\n" + + " public static Object FOO = new Object();\n" + + "}\n"; + + File srcDir = createJavaFile("NotAGlobalConfig.java", source); + GlobalConfigScanner scanner = new GlobalConfigScanner(); + List results = scanner.scan(Collections.singletonList(srcDir.toPath())); + + assertEquals(0, results.size()); + } +} From 9a25e32ac3c7ba5139165290bc9803c021595dc7 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Thu, 12 Mar 2026 18:15:03 +0800 Subject: [PATCH 2/2] [devtool]: fix review issues and add tests Resolves: ZSTAC-0 - Fix SdkChecker/ApiHelperChecker passed() to actually detect failures instead of always returning true - Fix classIndex in ApiMessageScanner to also build FQCN index, preventing silent overwrites when two classes share a simple name - Fix DirectoryStream resource leak in DevTool.addPluginDirs() (use try-with-resources) - Auto-discover source modules instead of hardcoded list - Fix resolveTypeClass default branch (return simpleName, not java.lang.simpleName for unknown types) - Add ApiMessageScannerTest with 7 tests covering RestRequest parsing, abstract/NoSDK exclusion, param extraction, SdkChecker field mismatch detection, and ApiHelperChecker missing method detection Test: mvn package (15/15 tests pass) Change-Id: Id494379077c18bb49b2776e74e959afdd6dc1917 Co-Authored-By: Claude Opus 4.6 --- .../main/java/org/zstack/devtool/DevTool.java | 43 ++-- .../devtool/checker/ApiHelperChecker.java | 5 +- .../zstack/devtool/checker/SdkChecker.java | 10 +- .../devtool/scanner/ApiMessageScanner.java | 17 +- .../devtool/scanner/GlobalConfigScanner.java | 2 +- .../zstack/devtool/ApiMessageScannerTest.java | 195 ++++++++++++++++++ 6 files changed, 236 insertions(+), 36 deletions(-) create mode 100644 zstack-dev-tool/src/test/java/org/zstack/devtool/ApiMessageScannerTest.java diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/DevTool.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/DevTool.java index 5ee50b243a..1a7c155078 100644 --- a/zstack-dev-tool/src/main/java/org/zstack/devtool/DevTool.java +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/DevTool.java @@ -243,31 +243,28 @@ private Path resolveGlobalConfigDocDir() { private List getSourceDirs() { List dirs = new ArrayList<>(); - String[] mainDirs = { - "header/src/main/java", - "core/src/main/java", - "compute/src/main/java", - "storage/src/main/java", - "network/src/main/java", - "image/src/main/java", - "identity/src/main/java", - "search/src/main/java", - "configuration/src/main/java", - "rest/src/main/java", - "console/src/main/java", - "tag/src/main/java", - "longjob/src/main/java", - "externalservice/src/main/java", - "resourceconfig/src/main/java", - }; - - for (String dir : mainDirs) { - Path p = projectRoot.resolve(dir); - if (Files.isDirectory(p)) dirs.add(p); + // Auto-discover top-level modules with src/main/java + try (DirectoryStream stream = Files.newDirectoryStream(projectRoot)) { + for (Path child : stream) { + if (!Files.isDirectory(child)) continue; + String name = child.getFileName().toString(); + // skip non-module dirs + if (name.startsWith(".") || "premium".equals(name) || "plugin".equals(name) + || "sdk".equals(name) || "test".equals(name) || "testlib".equals(name) + || "zstack-dev-tool".equals(name) || "build".equals(name) + || "doc".equals(name) || "conf".equals(name) || "tools".equals(name)) { + continue; + } + Path src = child.resolve("src/main/java"); + if (Files.isDirectory(src)) dirs.add(src); + } + } catch (IOException e) { + System.err.println("WARN: Failed to scan project root: " + e.getMessage()); } addPluginDirs(dirs, projectRoot.resolve("plugin")); + // premium modules Path premiumHeader = projectRoot.resolve("premium/premium-header/src/main/java"); if (Files.isDirectory(premiumHeader)) dirs.add(premiumHeader); @@ -278,8 +275,7 @@ private List getSourceDirs() { private void addPluginDirs(List dirs, Path pluginRoot) { if (!Files.isDirectory(pluginRoot)) return; - try { - DirectoryStream stream = Files.newDirectoryStream(pluginRoot); + try (DirectoryStream stream = Files.newDirectoryStream(pluginRoot)) { for (Path child : stream) { if (Files.isDirectory(child)) { Path src = child.resolve("src/main/java"); @@ -288,7 +284,6 @@ private void addPluginDirs(List dirs, Path pluginRoot) { } } } - stream.close(); } catch (IOException e) { System.err.println("WARN: Failed to scan plugin dirs in " + pluginRoot); } diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/ApiHelperChecker.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/ApiHelperChecker.java index 8fcd7dd12c..7d6df7b1be 100644 --- a/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/ApiHelperChecker.java +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/ApiHelperChecker.java @@ -21,8 +21,7 @@ public static class CheckResult { public int totalMethods; public boolean passed() { - // ApiHelper check is advisory - same exclusions as SDK - return true; + return missingMethods.isEmpty(); } public void print() { @@ -32,7 +31,7 @@ public void print() { return; } - System.out.println("[ApiHelper] MISSING " + missingMethods.size() + " method(s):"); + System.out.println("[ApiHelper] FAIL - MISSING " + missingMethods.size() + " method(s):"); for (String msg : missingMethods) { System.out.println(" - " + msg); } diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/SdkChecker.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/SdkChecker.java index 030014b93e..16b1d3b480 100644 --- a/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/SdkChecker.java +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/SdkChecker.java @@ -19,15 +19,13 @@ public static class CheckResult { public int totalSdkFiles; public boolean passed() { - // SDK check is advisory - existing codebase may have staleness - // GlobalConfig check is the hard gate - return true; + return fieldMismatches.isEmpty(); } public void print() { if (!missingActions.isEmpty()) { - System.out.println("[SDK] WARN - " + missingActions.size() + - " API message(s) have no SDK action file (may be excluded):"); + System.out.println("[SDK] INFO - " + missingActions.size() + + " API message(s) have no SDK action file (may be excluded by @NoSDK):"); for (String msg : missingActions) { System.out.println(" - " + msg); } @@ -46,7 +44,7 @@ public void print() { System.out.println("[SDK] OK - " + totalMessages + " API messages, " + totalSdkFiles + " SDK action files" + (missingActions.isEmpty() ? ", all in sync" : - ", " + missingActions.size() + " excluded")); + ", " + missingActions.size() + " without action file (advisory)")); } } } diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/ApiMessageScanner.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/ApiMessageScanner.java index d0d15af0ca..39eec38fcd 100644 --- a/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/ApiMessageScanner.java +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/ApiMessageScanner.java @@ -19,8 +19,11 @@ public class ApiMessageScanner { private final JavaParser parser = new JavaParser(); - // className -> source path, for inheritance resolution + // className -> source path, for inheritance resolution (keyed by simple name) + // When duplicates exist, last-write wins, but we also keep a FQCN index private final Map classIndex = new HashMap<>(); + // FQCN (package.ClassName) -> source path, for precise lookup + private final Map fqcnIndex = new HashMap<>(); // className -> parsed CompilationUnit cache private final Map cuCache = new HashMap<>(); @@ -56,6 +59,14 @@ public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) { String fileName = path.getFileName().toString(); if (fileName.endsWith(".java")) { String className = fileName.substring(0, fileName.length() - 5); + // Derive FQCN from path: dir is a source root like .../src/main/java + // so relativize path to get package structure + String relativePath = dir.relativize(path).toString(); + String fqcn = relativePath.replace('/', '.').replace('\\', '.'); + if (fqcn.endsWith(".java")) { + fqcn = fqcn.substring(0, fqcn.length() - 5); + } + fqcnIndex.put(fqcn, path); classIndex.put(className, path); } return FileVisitResult.CONTINUE; @@ -277,7 +288,9 @@ private void collectParentParams(String className, List params, Se return; } - Path parentPath = classIndex.get(className); + // Try FQCN first, then fall back to simple name + Path parentPath = fqcnIndex.get(className); + if (parentPath == null) parentPath = classIndex.get(className); if (parentPath == null) return; CompilationUnit parentCu = parseCached(parentPath); diff --git a/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/GlobalConfigScanner.java b/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/GlobalConfigScanner.java index 219d448932..576427ceca 100644 --- a/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/GlobalConfigScanner.java +++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/GlobalConfigScanner.java @@ -239,7 +239,7 @@ private String resolveTypeClass(Expression expr) { case "Boolean": return "java.lang.Boolean"; case "Float": return "java.lang.Float"; case "Double": return "java.lang.Double"; - default: return "java.lang." + simpleName; + default: return simpleName; } } return "java.lang.String"; diff --git a/zstack-dev-tool/src/test/java/org/zstack/devtool/ApiMessageScannerTest.java b/zstack-dev-tool/src/test/java/org/zstack/devtool/ApiMessageScannerTest.java new file mode 100644 index 0000000000..453782bd06 --- /dev/null +++ b/zstack-dev-tool/src/test/java/org/zstack/devtool/ApiMessageScannerTest.java @@ -0,0 +1,195 @@ +package org.zstack.devtool; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.zstack.devtool.checker.ApiHelperChecker; +import org.zstack.devtool.checker.SdkChecker; +import org.zstack.devtool.model.ApiMessageInfo; +import org.zstack.devtool.model.ApiParamInfo; +import org.zstack.devtool.scanner.ApiMessageScanner; + +import java.io.File; +import java.io.FileWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.*; + +public class ApiMessageScannerTest { + + @Rule + public TemporaryFolder tempDir = new TemporaryFolder(); + + private File createJavaFile(String subdir, String filename, String content) throws Exception { + File dir = tempDir.newFolder(subdir, "src", "main", "java"); + File file = new File(dir, filename); + try (FileWriter w = new FileWriter(file)) { + w.write(content); + } + return dir; + } + + @Test + public void testScanBasicRestRequest() throws Exception { + String source = + "package org.zstack.test;\n" + + "import org.zstack.header.rest.RestRequest;\n" + + "import org.zstack.header.message.APIParam;\n" + + "@RestRequest(path = \"/zones\", method = HttpMethod.POST, responseClass = APICreateZoneEvent.class)\n" + + "public class APICreateZoneMsg extends APICreateMessage {\n" + + " @APIParam(maxLength = 255)\n" + + " private String name;\n" + + " @APIParam(required = false, maxLength = 2048)\n" + + " private String description;\n" + + "}\n"; + + File srcDir = createJavaFile("zone", "APICreateZoneMsg.java", source); + ApiMessageScanner scanner = new ApiMessageScanner(); + List results = scanner.scan(Collections.singletonList(srcDir.toPath())); + + assertEquals(1, results.size()); + ApiMessageInfo info = results.get(0); + assertEquals("APICreateZoneMsg", info.getClassName()); + assertEquals("/zones", info.getPath()); + assertEquals("POST", info.getHttpMethod()); + assertEquals("CreateZoneAction", info.getActionName()); + assertEquals("createZone", info.getHelperMethodName()); + assertEquals(2, info.getParams().size()); + } + + @Test + public void testSkipsAbstractClass() throws Exception { + String source = + "package org.zstack.test;\n" + + "import org.zstack.header.rest.RestRequest;\n" + + "@RestRequest(path = \"/base\", method = HttpMethod.GET)\n" + + "public abstract class APIBaseMsg extends APIMessage {\n" + + "}\n"; + + File srcDir = createJavaFile("base", "APIBaseMsg.java", source); + ApiMessageScanner scanner = new ApiMessageScanner(); + List results = scanner.scan(Collections.singletonList(srcDir.toPath())); + + assertEquals(0, results.size()); + } + + @Test + public void testSkipsNoSDK() throws Exception { + String source = + "package org.zstack.test;\n" + + "import org.zstack.header.rest.RestRequest;\n" + + "@RestRequest(path = \"/internal\", method = HttpMethod.POST)\n" + + "@NoSDK\n" + + "public class APIInternalMsg extends APIMessage {\n" + + "}\n"; + + File srcDir = createJavaFile("internal", "APIInternalMsg.java", source); + ApiMessageScanner scanner = new ApiMessageScanner(); + List results = scanner.scan(Collections.singletonList(srcDir.toPath())); + + assertEquals(0, results.size()); + } + + @Test + public void testApiParamDetails() throws Exception { + String source = + "package org.zstack.test;\n" + + "import org.zstack.header.rest.RestRequest;\n" + + "import org.zstack.header.message.APIParam;\n" + + "@RestRequest(path = \"/vms\", method = HttpMethod.POST)\n" + + "public class APICreateVmMsg extends APIMessage {\n" + + " @APIParam(required = true, maxLength = 255)\n" + + " private String name;\n" + + " @APIParam(required = false, validValues = {\"Linux\", \"Windows\"})\n" + + " private String platform;\n" + + " private String internalField;\n" + // no @APIParam -> excluded + "}\n"; + + File srcDir = createJavaFile("vm", "APICreateVmMsg.java", source); + ApiMessageScanner scanner = new ApiMessageScanner(); + List results = scanner.scan(Collections.singletonList(srcDir.toPath())); + + assertEquals(1, results.size()); + ApiMessageInfo info = results.get(0); + // only fields with @APIParam are included + assertEquals(2, info.getParams().size()); + + ApiParamInfo nameParam = info.getParams().stream() + .filter(p -> "name".equals(p.getFieldName())).findFirst().orElse(null); + assertNotNull(nameParam); + assertTrue(nameParam.isRequired()); + assertEquals(255, nameParam.getMaxLength()); + + ApiParamInfo platformParam = info.getParams().stream() + .filter(p -> "platform".equals(p.getFieldName())).findFirst().orElse(null); + assertNotNull(platformParam); + assertFalse(platformParam.isRequired()); + } + + @Test + public void testActionNameDerivation() { + ApiMessageInfo info = new ApiMessageInfo(); + info.setClassName("APICreateZoneMsg"); + assertEquals("CreateZoneAction", info.getActionName()); + assertEquals("CreateZoneResult", info.getResultName()); + assertEquals("createZone", info.getHelperMethodName()); + + info.setClassName("APIQueryVmInstanceMsg"); + assertEquals("QueryVmInstanceAction", info.getActionName()); + assertEquals("queryVmInstance", info.getHelperMethodName()); + } + + @Test + public void testSdkCheckerFieldMismatchFails() throws Exception { + // Create a fake SDK action file missing a field + Path sdkDir = tempDir.newFolder("sdk").toPath(); + Files.write(sdkDir.resolve("CreateTestAction.java"), + ("public class CreateTestAction {\n" + + " public java.lang.String name;\n" + + " public java.lang.String sessionId;\n" + + "}\n").getBytes()); + + ApiMessageInfo msg = new ApiMessageInfo(); + msg.setClassName("APICreateTestMsg"); + msg.setSourceFile("Test.java"); + ApiParamInfo nameParam = new ApiParamInfo(); + nameParam.setFieldName("name"); + ApiParamInfo descParam = new ApiParamInfo(); + descParam.setFieldName("description"); + msg.setParams(java.util.Arrays.asList(nameParam, descParam)); + + SdkChecker checker = new SdkChecker(); + SdkChecker.CheckResult result = checker.check(Collections.singletonList(msg), sdkDir); + + // description is in source but not in SDK -> should fail + assertFalse(result.passed()); + assertEquals(1, result.fieldMismatches.size()); + assertTrue(result.fieldMismatches.get(0).contains("description")); + } + + @Test + public void testApiHelperCheckerMissingMethodFails() throws Exception { + Path helperFile = tempDir.newFolder("testlib").toPath().resolve("ApiHelper.groovy"); + Files.write(helperFile, + ("class ApiHelper {\n" + + " def createZone(Map args) { }\n" + + " def deleteZone(Map args) { }\n" + + "}\n").getBytes()); + + ApiMessageInfo msg1 = new ApiMessageInfo(); + msg1.setClassName("APICreateZoneMsg"); + ApiMessageInfo msg2 = new ApiMessageInfo(); + msg2.setClassName("APIUpdateZoneMsg"); // updateZone not in helper + + ApiHelperChecker checker = new ApiHelperChecker(); + ApiHelperChecker.CheckResult result = checker.check( + java.util.Arrays.asList(msg1, msg2), helperFile); + + assertFalse(result.passed()); + assertEquals(1, result.missingMethods.size()); + assertTrue(result.missingMethods.get(0).contains("updateZone")); + } +}