diff --git a/.github/workflows/cldc11-check.yml b/.github/workflows/cldc11-check.yml new file mode 100644 index 0000000000..b6a67408a0 --- /dev/null +++ b/.github/workflows/cldc11-check.yml @@ -0,0 +1,62 @@ +name: CLDC11 Compatibility Check + +on: + push: + paths: + - 'Ports/CLDC11/**' + - 'vm/JavaAPI/**' + - '.github/workflows/cldc11-check.yml' + branches: + - master + pull_request: + paths: + - 'Ports/CLDC11/**' + - 'vm/JavaAPI/**' + - '.github/workflows/cldc11-check.yml' + +permissions: + contents: read + +jobs: + check-cldc11: + runs-on: ubuntu-latest + container: + image: ghcr.io/codenameone/codenameone/ci-container:latest + defaults: + run: + shell: bash + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build APIChecker + run: JAVA_HOME=$JAVA11_HOME mvn package -f scripts/api-checker/pom.xml + + - name: Build Ports/CLDC11 + run: | + cd Ports/CLDC11 + ant jar + + - name: Build vm/JavaAPI + run: mvn package -f vm/JavaAPI/pom.xml -DskipTests + + - name: Check CLDC11 vs JavaSE 11 + run: | + $JAVA11_HOME/bin/java -jar scripts/api-checker/target/api-checker-1.0-SNAPSHOT.jar \ + --subject Ports/CLDC11/dist/CLDC11.jar \ + --reference java11 + + - name: Check CLDC11 vs vm/JavaAPI + run: | + $JAVA11_HOME/bin/java -jar scripts/api-checker/target/api-checker-1.0-SNAPSHOT.jar \ + --subject Ports/CLDC11/dist/CLDC11.jar \ + --reference vm/JavaAPI/target/classes \ + --report extra_apis_report.json + + - name: Upload Extra APIs Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: extra-apis-report + path: extra_apis_report.json diff --git a/scripts/api-checker/pom.xml b/scripts/api-checker/pom.xml new file mode 100644 index 0000000000..5c5df6c286 --- /dev/null +++ b/scripts/api-checker/pom.xml @@ -0,0 +1,64 @@ + + 4.0.0 + + com.codename1.tools + api-checker + 1.0-SNAPSHOT + jar + + + 11 + 11 + 9.6 + + + + + org.ow2.asm + asm + ${asm.version} + + + org.ow2.asm + asm-tree + ${asm.version} + + + org.ow2.asm + asm-commons + ${asm.version} + + + com.fasterxml.jackson.core + jackson-databind + 2.15.2 + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.0 + + + package + + shade + + + + + com.codename1.apichecker.APIChecker + + + + + + + + + diff --git a/scripts/api-checker/src/main/java/com/codename1/apichecker/APIChecker.java b/scripts/api-checker/src/main/java/com/codename1/apichecker/APIChecker.java new file mode 100644 index 0000000000..cc5c053626 --- /dev/null +++ b/scripts/api-checker/src/main/java/com/codename1/apichecker/APIChecker.java @@ -0,0 +1,281 @@ +package com.codename1.apichecker; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldNode; +import org.objectweb.asm.tree.MethodNode; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.*; +import java.util.*; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.stream.Collectors; + +public class APIChecker { + + public static void main(String[] args) throws IOException { + String subjectPath = null; + String referencePath = null; + String reportPath = null; + + for (int i = 0; i < args.length; i++) { + if ("--subject".equals(args[i])) { + subjectPath = args[++i]; + } else if ("--reference".equals(args[i])) { + referencePath = args[++i]; + } else if ("--report".equals(args[i])) { + reportPath = args[++i]; + } + } + + if (subjectPath == null || referencePath == null) { + System.err.println("Usage: APIChecker --subject --reference [--report ]"); + System.exit(1); + } + + System.out.println("Loading subject classes from: " + subjectPath); + Map subjectClasses = loadClasses(subjectPath); + + System.out.println("Loading reference classes from: " + referencePath); + Map referenceClasses; + if ("java11".equals(referencePath)) { + referenceClasses = loadJava11Classes(); + } else { + referenceClasses = loadClasses(referencePath); + } + + List errors = new ArrayList<>(); + Map> extraApis = new TreeMap<>(); + + // Pre-calculate subject packages for faster lookup + Set subjectPackages = new HashSet<>(); + for (String className : subjectClasses.keySet()) { + int lastSlash = className.lastIndexOf('/'); + if (lastSlash != -1) { + subjectPackages.add(className.substring(0, lastSlash)); + } + } + + // Check Subject vs Reference (must be subset) + for (ClassNode subjectClass : subjectClasses.values()) { + if (isModuleInfo(subjectClass.name)) continue; + if (!isPublicOrProtected(subjectClass.access)) continue; + + // Only check standard packages if comparing against Java 11 + if ("java11".equals(referencePath) && !isStandardPackage(subjectClass.name)) { + continue; + } + + ClassNode referenceClass = referenceClasses.get(subjectClass.name); + if (referenceClass == null) { + errors.add("Class " + subjectClass.name + " is present in Subject but missing in Reference."); + continue; + } + + // Check superclass + if (!Objects.equals(subjectClass.superName, referenceClass.superName)) { + if (subjectClass.superName != null || referenceClass.superName != null) { + errors.add("Class " + subjectClass.name + " extends " + subjectClass.superName + " but Reference extends " + referenceClass.superName); + } + } + + // Check interfaces + Set refInterfaces = new HashSet<>(referenceClass.interfaces); + for (String iface : subjectClass.interfaces) { + if (!refInterfaces.contains(iface)) { + errors.add("Class " + subjectClass.name + " implements " + iface + " which is missing in Reference."); + } + } + + // Check fields + for (FieldNode subjectField : subjectClass.fields) { + if (!isPublicOrProtected(subjectField.access)) continue; + boolean found = false; + for (FieldNode refField : referenceClass.fields) { + if (refField.name.equals(subjectField.name) && refField.desc.equals(subjectField.desc)) { + found = true; + break; + } + } + if (!found) { + errors.add("Field " + subjectClass.name + "." + subjectField.name + " " + subjectField.desc + " is missing in Reference."); + } + } + + // Check methods + for (MethodNode subjectMethod : subjectClass.methods) { + if (!isPublicOrProtected(subjectMethod.access)) continue; + if ("".equals(subjectMethod.name)) continue; + + boolean found = false; + for (MethodNode refMethod : referenceClass.methods) { + if (refMethod.name.equals(subjectMethod.name) && refMethod.desc.equals(subjectMethod.desc)) { + found = true; + break; + } + } + if (!found) { + errors.add("Method " + subjectClass.name + "." + subjectMethod.name + subjectMethod.desc + " is missing in Reference."); + } + } + } + + // Check Reference vs Subject (for report) + if (reportPath != null) { + for (ClassNode referenceClass : referenceClasses.values()) { + if (isModuleInfo(referenceClass.name)) continue; + if (!isPublicOrProtected(referenceClass.access)) continue; + + ClassNode subjectClass = subjectClasses.get(referenceClass.name); + + if (subjectClass == null) { + // Check if the package exists in Subject to reduce noise (optional, but good for library comparison) + // If reference is java11, we only care if CLDC11 *should* have it. + // But if strict report is required: "classes/methods that are in vm/JavaAPI but not in CLDC11". + // I'll stick to packages present in Subject to avoid reporting unrelated libs if any. + // But for vm/JavaAPI, it should be mostly matching. + // Let's report if package matches or if we are not checking against java11 (where we expect huge diffs). + + boolean relevant = !"java11".equals(referencePath) || isPackageInSubject(referenceClass.name, subjectPackages); + if (relevant) { + addExtra(extraApis, referenceClass.name, "Class " + referenceClass.name + " is missing in Subject."); + } + } else { + // Class exists, check members + for (FieldNode refField : referenceClass.fields) { + if (!isPublicOrProtected(refField.access)) continue; + boolean found = false; + for (FieldNode subjectField : subjectClass.fields) { + if (subjectField.name.equals(refField.name) && subjectField.desc.equals(refField.desc)) { + found = true; + break; + } + } + if (!found) { + addExtra(extraApis, referenceClass.name, "Field " + refField.name + " " + refField.desc); + } + } + + for (MethodNode refMethod : referenceClass.methods) { + if (!isPublicOrProtected(refMethod.access)) continue; + if ("".equals(refMethod.name)) continue; + boolean found = false; + for (MethodNode subjectMethod : subjectClass.methods) { + if (subjectMethod.name.equals(refMethod.name) && subjectMethod.desc.equals(refMethod.desc)) { + found = true; + break; + } + } + if (!found) { + addExtra(extraApis, referenceClass.name, "Method " + refMethod.name + refMethod.desc); + } + } + } + } + + // Write report + ObjectMapper mapper = new ObjectMapper(); + mapper.enable(SerializationFeature.INDENT_OUTPUT); + mapper.writeValue(new File(reportPath), extraApis); + System.out.println("Report written to " + reportPath); + } + + if (!errors.isEmpty()) { + System.err.println("Compatibility Errors Found:"); + for (String error : errors) { + System.err.println(error); + } + System.exit(1); + } else { + System.out.println("Compatibility Check Passed."); + } + } + + private static void addExtra(Map> report, String className, String item) { + report.computeIfAbsent(className, k -> new ArrayList<>()).add(item); + } + + private static boolean isPackageInSubject(String className, Set subjectPackages) { + int lastSlash = className.lastIndexOf('/'); + if (lastSlash == -1) return false; + String pkg = className.substring(0, lastSlash); + // Direct lookup or parent lookup? Usually direct. + return subjectPackages.contains(pkg); + } + + private static boolean isStandardPackage(String className) { + return className.startsWith("java/") || className.startsWith("javax/"); + } + + private static boolean isModuleInfo(String className) { + return className.endsWith("module-info"); + } + + private static boolean isPublicOrProtected(int access) { + return (access & Opcodes.ACC_PUBLIC) != 0 || (access & Opcodes.ACC_PROTECTED) != 0; + } + + private static Map loadClasses(String path) throws IOException { + Map classes = new HashMap<>(); + File file = new File(path); + if (file.isDirectory()) { + Files.walk(file.toPath()) + .filter(p -> p.toString().endsWith(".class")) + .forEach(p -> { + try { + ClassNode cn = readClass(Files.readAllBytes(p)); + if (!isModuleInfo(cn.name)) { + classes.put(cn.name, cn); + } + } catch (IOException e) { + e.printStackTrace(); + } + }); + } else if (path.endsWith(".jar")) { + try (JarFile jar = new JarFile(file)) { + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (entry.getName().endsWith(".class")) { + ClassNode cn = readClass(jar.getInputStream(entry).readAllBytes()); + if (!isModuleInfo(cn.name)) { + classes.put(cn.name, cn); + } + } + } + } + } + return classes; + } + + private static Map loadJava11Classes() throws IOException { + Map classes = new HashMap<>(); + FileSystem fs = FileSystems.getFileSystem(URI.create("jrt:/")); + Path modules = fs.getPath("modules"); + Files.walk(modules) + .filter(p -> p.toString().endsWith(".class")) + .forEach(p -> { + try { + if (p.getFileName().toString().equals("module-info.class")) return; + ClassNode cn = readClass(Files.readAllBytes(p)); + classes.put(cn.name, cn); + } catch (IOException e) { + e.printStackTrace(); + } + }); + return classes; + } + + private static ClassNode readClass(byte[] bytes) { + ClassReader cr = new ClassReader(bytes); + ClassNode cn = new ClassNode(); + cr.accept(cn, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + return cn; + } +}