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..1a7c155078
--- /dev/null
+++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/DevTool.java
@@ -0,0 +1,324 @@
+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<>();
+
+ // 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);
+
+ 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);
+ }
+ }
+ }
+ } 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..7d6df7b1be
--- /dev/null
+++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/ApiHelperChecker.java
@@ -0,0 +1,82 @@
+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() {
+ return missingMethods.isEmpty();
+ }
+
+ public void print() {
+ if (passed()) {
+ System.out.println("[ApiHelper] OK - " + totalMethods +
+ " helper methods for " + totalMessages + " API messages");
+ return;
+ }
+
+ System.out.println("[ApiHelper] FAIL - 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..16b1d3b480
--- /dev/null
+++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/checker/SdkChecker.java
@@ -0,0 +1,163 @@
+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() {
+ return fieldMismatches.isEmpty();
+ }
+
+ public void print() {
+ if (!missingActions.isEmpty()) {
+ 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);
+ }
+ }
+
+ 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() + " without action file (advisory)"));
+ }
+ }
+ }
+
+ 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..39eec38fcd
--- /dev/null
+++ b/zstack-dev-tool/src/main/java/org/zstack/devtool/scanner/ApiMessageScanner.java
@@ -0,0 +1,405 @@
+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 (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<>();
+
+ 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);
+ // 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;
+ }
+ });
+ }
+
+ 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;
+ }
+
+ // 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);
+ 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..576427ceca
--- /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 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/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"));
+ }
+}
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());
+ }
+}