diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index d4f55d37b00..2381600d480 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -206,6 +206,66 @@ private void mergeJars(File dest, File... src) { task.execute(); } + /** + * Local JavaScript builds run ParparVM's translator on this machine, so + * they keep the classes a server build would have re-supplied. + */ + static boolean isLocalJavascriptBuild(String buildTarget) { + return buildTarget != null && buildTarget.contains("javascript") && isLocalBuildTarget(buildTarget); + } + + // A null build target never reaches these from the mojo -- the parameter is + // required -- but they are static and package private, so treat an absent + // target as "not local": the conservative answer, since it keeps the server + // supplied artifacts out of the staged jar rather than throwing. + + /** + * Whether the build itself re-supplies the artifact's classes after they + * are left out of the staged jar: codenameone-core and java-runtime come + * from the build server (or, for a local JavaScript build, stay in the jar + * so ParparVM's translator can see them), and kotlin-stdlib comes from the + * build server too. This is deliberately narrower than + * {@link #isStrippedFromStagedJar}: a reference into anything else that is + * left out really is a reference to a class nobody will supply. + */ + static boolean isSuppliedByBuildServer(String groupId, String artifactId, String buildTarget) { + if (GROUP_ID.equals(groupId) && contains(artifactId, BUNDLE_ARTIFACT_ID_BLACKLIST)) { + return !isLocalJavascriptBuild(buildTarget); + } + return !isLocalBuildTarget(buildTarget) + && "org.jetbrains.kotlin".equals(groupId) + && "kotlin-stdlib".equals(artifactId); + } + + /** + * Whether the given artifact is left out of the jar-with-dependencies that + * is staged for the build, either because the build re-supplies it or + * because its scope says it is not part of the application. Shared by the + * jar assembly and the class closure check so the two can never disagree + * about what is in the jar. + */ + static boolean isStrippedFromStagedJar(String groupId, String artifactId, String scope, String buildTarget) { + if (GROUP_ID.equals(groupId) && contains(artifactId, BUNDLE_ARTIFACT_ID_BLACKLIST)) { + // For local JavaScript builds we need codenameone-core and java-runtime classes + // in the staged jar - the build server normally re-supplies those, but ParparVM's + // ByteCodeTranslator runs locally here and resolves everything from the staged class + // directory. + return !isLocalJavascriptBuild(buildTarget); + } + if (isSuppliedByBuildServer(groupId, artifactId, buildTarget)) { + // When sending to the build server, we'll strip the kotlin-stdlib and the server will + // provide it. For local builds, it's easier to just include it. + return true; + } + // An artifact with no scope was never scoped out of the application, so + // keep its classes rather than dropping them from the jar. + return scope != null && !"compile".equals(scope); + } + + private boolean isStrippedFromStagedJar(Artifact artifact) { + return isStrippedFromStagedJar(artifact.getGroupId(), artifact.getArtifactId(), artifact.getScope(), buildTarget); + } + /** * Fails the build when the staged jar-with-dependencies contains an * application class that references another application class which is not @@ -241,21 +301,50 @@ private void verifyApplicationClassClosure(File jarWithDependencies, List providedJars = new ArrayList(); + // Classes left out of the staged jar that the build re-supplies: references + // into them are not missing. Anything else that was left out -- a `provided` + // scope third party dependency, say -- stays reportable, because nothing + // puts those classes back before the translators run. + boolean localJavascriptBuild = isLocalJavascriptBuild(buildTarget); + Set providedJars = new LinkedHashSet(); for (Artifact artifact : project.getArtifacts()) { - boolean providedByServer = GROUP_ID.equals(artifact.getGroupId()) - && contains(artifact.getArtifactId(), BUNDLE_ARTIFACT_ID_BLACKLIST); - providedByServer = providedByServer || ("org.jetbrains.kotlin".equals(artifact.getGroupId()) - && "kotlin-stdlib".equals(artifact.getArtifactId())); - if (providedByServer) { + if (isSuppliedByBuildServer(artifact.getGroupId(), artifact.getArtifactId(), buildTarget)) { File jar = getJar(artifact); if (jar != null && jar.isFile()) { providedJars.add(jar); } } } + if (!localJavascriptBuild) { + // codenameone-core and java-runtime are `provided` scope in the app's common + // module and `provided` is not transitive, so a platform module (ios, android, + // ...) that only depends on common does not see them among its own artifacts. + // They still have to count as provided: without them every reference into a + // package the app shares with the framework -- a patched framework class, or a + // helper deliberately placed in a com.codename1 package to reach package + // private API -- makes the whole framework package look like it is missing + // from the build. Resolve them from the plugin's own dependencies instead. + boolean coreResolved = false; + for (String bundled : BUNDLE_ARTIFACT_ID_BLACKLIST) { + File jar = getJar(GROUP_ID, bundled); + if (jar != null && jar.isFile()) { + providedJars.add(jar); + if ("codenameone-core".equals(bundled)) { + coreResolved = true; + } + } + } + if (!coreResolved) { + // Every application class references the framework, so without its class + // list the check cannot tell a stale class from a framework class and would + // report false positives. Skipping is the only safe answer, but say so + // loudly: a stale class will now reach the build unreported. + getLog().warn("Skipping the application class closure check for build target " + buildTarget + + " because " + GROUP_ID + ":codenameone-core could not be resolved." + + " Stale application classes will not be detected before the build runs."); + return; + } + } Map> missing; try { missing = ClassClosureVerifier.findMissingProjectReferences(jarWithDependencies, projectClassRoots, providedJars); @@ -524,7 +613,10 @@ private File getStringsJar() throws IOException { public static final String BUILD_TARGET_MAC_NATIVE = Executor.BUILD_TARGET_MAC_NATIVE; public static final String BUILD_TARGET_LINUX_NATIVE = Executor.BUILD_TARGET_LINUX_NATIVE; - private boolean isLocalBuildTarget(String buildTarget) { + private static boolean isLocalBuildTarget(String buildTarget) { + if (buildTarget == null) { + return false; + } // windows-device (BUILD_TARGET_WINDOWS_NATIVE) is a *cloud* build: it sends // a "win32" build to the server (see the windows-device target in // buildxml-template.xml), mirroring linux-device. Only the explicit @@ -652,34 +744,14 @@ private void createAntProject() throws IOException, LibraryPropertiesException, // in the staged jar — the build server normally re-supplies those, but ParparVM's // ByteCodeTranslator runs locally here and resolves everything from the staged class // directory. - boolean localJsBuild = buildTarget.contains("javascript") && isLocalBuildTarget(buildTarget); + boolean localJsBuild = isLocalJavascriptBuild(buildTarget); for (Artifact artifact : project.getArtifacts()) { - boolean addToBlacklist = false; - if (artifact.getGroupId().equals("com.codenameone") && contains(artifact.getArtifactId(), BUNDLE_ARTIFACT_ID_BLACKLIST)) { - if (!localJsBuild) { - addToBlacklist = true; - } - } - if (!addToBlacklist && !isLocalBuildTarget(buildTarget)) { - // When sending to the build server, we'll strip the kotlin-stdlib and the server will provide it - // for local builds, it's easier to just include it. - if (artifact.getGroupId().equals("org.jetbrains.kotlin") && artifact.getArtifactId().equals("kotlin-stdlib")) { - addToBlacklist = true; - serverMustProvideKotlinVersion = artifact.getVersion(); - getLog().debug("Adding kotlin-stdlib to blacklist. Server will provide this:" + artifact); - } - } - if (!addToBlacklist && !"compile".equals(artifact.getScope())) { - // Local JS builds need codenameone-core / java-runtime even though they are - // marked `provided` in the user's POM — the build server normally re-supplies - // them, but locally we have to bundle them so ParparVM's translator can see - // every referenced class. - boolean keepForLocalJs = localJsBuild - && "com.codenameone".equals(artifact.getGroupId()) - && contains(artifact.getArtifactId(), BUNDLE_ARTIFACT_ID_BLACKLIST); - if (!keepForLocalJs) { - addToBlacklist = true; - } + boolean addToBlacklist = isStrippedFromStagedJar(artifact); + if (addToBlacklist && !isLocalBuildTarget(buildTarget) + && "org.jetbrains.kotlin".equals(artifact.getGroupId()) + && "kotlin-stdlib".equals(artifact.getArtifactId())) { + serverMustProvideKotlinVersion = artifact.getVersion(); + getLog().debug("Adding kotlin-stdlib to blacklist. Server will provide this:" + artifact); } if (addToBlacklist) { File jar = getJar(artifact); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ClassClosureVerifier.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ClassClosureVerifier.java index 77fae9eec4a..5879ee5c171 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ClassClosureVerifier.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ClassClosureVerifier.java @@ -36,10 +36,10 @@ import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; @@ -86,7 +86,7 @@ private ClassClosureVerifier() { * @return map of missing class internal name to the internal names of the * classes that reference it, empty when the closure is consistent */ - static Map> findMissingProjectReferences(File stagedJar, List projectClassRoots, List providedJars) throws IOException { + static Map> findMissingProjectReferences(File stagedJar, Collection projectClassRoots, Collection providedJars) throws IOException { Set projectPackages = new HashSet(); for (File root : projectClassRoots) { if (root.isDirectory()) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoTest.java index cd147390dc0..1514a21e8d7 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2021, 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.maven; import com.codename1.ant.SortedProperties; @@ -8,6 +30,7 @@ import java.util.Properties; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -50,4 +73,59 @@ public void mergeRequiredPropertiesStillFailsOnOtherConflicts() throws Exception assertTrue(ex.getCause().getMessage().contains("Property codename1.arg.test has a conflict")); } } + + @Test + public void stripsTheFrameworkTheServerReSupplies() { + assertTrue(CN1BuildMojo.isStrippedFromStagedJar("com.codenameone", "codenameone-core", "provided", "ios-device")); + assertTrue(CN1BuildMojo.isStrippedFromStagedJar("com.codenameone", "codenameone-core", "provided", "ios-source")); + assertTrue(CN1BuildMojo.isStrippedFromStagedJar("com.codenameone", "java-runtime", "provided", "android-device")); + } + + @Test + public void keepsTheFrameworkForLocalJavascriptBuilds() { + // ParparVM translates locally for these, so it needs every class in the jar. + assertFalse(CN1BuildMojo.isStrippedFromStagedJar("com.codenameone", "codenameone-core", "provided", "local-javascript")); + assertFalse(CN1BuildMojo.isStrippedFromStagedJar("com.codenameone", "java-runtime", "provided", "local-javascript")); + } + + @Test + public void stripsKotlinStdlibOnlyForServerBuilds() { + assertTrue(CN1BuildMojo.isStrippedFromStagedJar("org.jetbrains.kotlin", "kotlin-stdlib", "compile", "ios-device")); + // ios-source generates the Xcode project on this machine, so it is a local + // target and bundling kotlin-stdlib is simpler than having it re-supplied. + assertFalse(CN1BuildMojo.isStrippedFromStagedJar("org.jetbrains.kotlin", "kotlin-stdlib", "compile", "ios-source")); + } + + @Test + public void keepsTheApplicationsOwnCompileDependencies() { + assertFalse(CN1BuildMojo.isStrippedFromStagedJar("com.mycompany", "myproject-common", "compile", "ios-source")); + assertFalse(CN1BuildMojo.isStrippedFromStagedJar("com.codenameone", "cn1-admob-lib", "compile", "ios-device")); + assertTrue(CN1BuildMojo.isStrippedFromStagedJar("org.junit.jupiter", "junit-jupiter", "test", "ios-device")); + } + + @Test + public void keepsArtifactsWithoutAScope() { + // A null scope is not a statement that the artifact is outside the application. + assertFalse(CN1BuildMojo.isStrippedFromStagedJar("com.mycompany", "some-lib", null, "ios-device")); + } + + @Test + public void toleratesAnAbsentBuildTarget() { + assertTrue(CN1BuildMojo.isSuppliedByBuildServer("com.codenameone", "codenameone-core", null)); + assertFalse(CN1BuildMojo.isSuppliedByBuildServer("com.thirdparty", "some-api", null)); + assertFalse(CN1BuildMojo.isLocalJavascriptBuild(null)); + assertTrue(CN1BuildMojo.isStrippedFromStagedJar("com.codenameone", "codenameone-core", "provided", null)); + } + + @Test + public void onlyTheFrameworkCountsAsSuppliedByTheBuildServer() { + assertTrue(CN1BuildMojo.isSuppliedByBuildServer("com.codenameone", "codenameone-core", "ios-device")); + assertTrue(CN1BuildMojo.isSuppliedByBuildServer("com.codenameone", "java-runtime", "ios-source")); + assertTrue(CN1BuildMojo.isSuppliedByBuildServer("org.jetbrains.kotlin", "kotlin-stdlib", "ios-device")); + assertFalse(CN1BuildMojo.isSuppliedByBuildServer("com.codenameone", "codenameone-core", "local-javascript")); + // A `provided` scope third party dependency is stripped from the jar but nobody + // puts it back, so a reference into it stays reportable. + assertTrue(CN1BuildMojo.isStrippedFromStagedJar("com.thirdparty", "some-api", "provided", "ios-device")); + assertFalse(CN1BuildMojo.isSuppliedByBuildServer("com.thirdparty", "some-api", "ios-device")); + } }