diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BytecodeComplianceMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BytecodeComplianceMojo.java index 16291f2361..cae5ba15b6 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BytecodeComplianceMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BytecodeComplianceMojo.java @@ -31,6 +31,7 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; +import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; @@ -195,13 +196,52 @@ private boolean hasChangedSinceLastCheck() { return true; } try { - return getSourcesModificationTime(true) > complianceOutputFile.lastModified(); + long lastCheck = complianceOutputFile.lastModified(); + if (getSourcesModificationTime(true) > lastCheck) { + return true; + } + if (lastCheckFailed()) { + // A failure report is not a completed check. Without this a + // rerun with unchanged sources would skip straight past the + // violations that just failed the build. + return true; + } + // The invocation rewrites and the class-version cap mutate the + // compiled classes in place, so gating on source mtimes alone is + // not enough: a later compile pass can regenerate target/classes + // with unchanged sources (e.g. another `mvn package` re-running + // javac), silently shedding the rewrites. Shipping such classes + // breaks device builds -- the iOS translator emits calls to + // virtual_java_lang_String_replaceAll etc. that ParparVM's + // JavaAPI never declares. Re-run whenever any compiled class is + // newer than the last check; the marker is written after the + // rewrites, so an up-to-date output tree stays skippable. + return getCompiledClassesModificationTime() > lastCheck; } catch (IOException ex) { - getLog().error("Failed to check sources modification time for compliance check", ex); + getLog().error("Failed to check sources/classes modification time for compliance check", ex); return true; } } + private boolean lastCheckFailed() throws IOException { + String content = FileUtils.readFileToString(complianceOutputFile, "UTF-8"); + return content.startsWith(FAILURE_REPORT_HEADER); + } + + private static final FilenameFilter CLASS_FILES_FILTER = (dir, name) -> name.endsWith(".class"); + + private long getCompiledClassesModificationTime() { + long mTime = lastModifiedRecursive(new File(project.getBuild().getOutputDirectory()), CLASS_FILES_FILTER); + // With kotlin.compiler.incremental the Kotlin compiler writes to its + // own output tree which executeImpl copies into the output directory, + // so fresh classes can sit there before any copy has happened. + File kotlinIncrementalOutputDir = new File(path(project.getBuild().getDirectory(), "kotlin-ic", "compile", "classes")); + if (kotlinIncrementalOutputDir.exists()) { + mTime = Math.max(mTime, lastModifiedRecursive(kotlinIncrementalOutputDir, CLASS_FILES_FILTER)); + } + return mTime; + } + private void writeComplianceSuccess(String message, int rewrittenClassCount) throws MojoExecutionException { complianceOutputFile.getParentFile().mkdirs(); try { @@ -216,9 +256,11 @@ private void writeComplianceSuccess(String message, int rewrittenClassCount) thr } } + private static final String FAILURE_REPORT_HEADER = "Codename One compliance check failed."; + private void writeComplianceReport(List violations, File outputDir, List dependencyJars, int rewrittenClassCount) throws MojoExecutionException { StringBuilder report = new StringBuilder(); - report.append("Codename One compliance check failed.\n"); + report.append(FAILURE_REPORT_HEADER).append("\n"); report.append("Project: ").append(project.getName()).append("\n"); report.append("Output classes: ").append(outputDir.getAbsolutePath()).append("\n"); report.append("Dependency jars scanned: ").append(dependencyJars.size()).append("\n"); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BytecodeComplianceMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BytecodeComplianceMojoTest.java index 32c5706019..28c4c87a64 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BytecodeComplianceMojoTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BytecodeComplianceMojoTest.java @@ -339,6 +339,93 @@ void recognizesAllSimdAllocaHelperNames() throws Exception { assertFalse(((Boolean) method.invoke(null, "app/Other", "allocaByte", "(I)[B")).booleanValue()); } + @Test + void rerunsComplianceCheckWhenClassesRecompiledWithoutSourceChange(@TempDir Path tempDir) throws Exception { + BytecodeComplianceMojo mojo = newMojoForSkipCheck(tempDir); + long markerTime = markerFile(mojo).lastModified(); + + Path classFile = writeClassWithVersion(outputDir(mojo), "app/Stable", Opcodes.V1_8); + assertTrue(classFile.toFile().setLastModified(markerTime - 60000)); + assertFalse(hasChangedSinceLastCheck(mojo), + "Expected skip when classes are older than the last compliance check"); + + // The rewrites mutate target/classes in place; a recompile with + // unchanged sources regenerates the classes and must invalidate the + // skip or the rewrites are silently lost. + assertTrue(classFile.toFile().setLastModified(markerTime + 60000)); + assertTrue(hasChangedSinceLastCheck(mojo), + "Expected re-run when classes are newer than the last compliance check"); + } + + @Test + void rerunsComplianceCheckWhenKotlinIncrementalClassesAreNewer(@TempDir Path tempDir) throws Exception { + BytecodeComplianceMojo mojo = newMojoForSkipCheck(tempDir); + long markerTime = markerFile(mojo).lastModified(); + + Path kotlinIcDir = tempDir.resolve("ios").resolve("target") + .resolve("kotlin-ic").resolve("compile").resolve("classes"); + Path kotlinClass = writeClassWithVersion(kotlinIcDir, "app/FromKotlinIc", Opcodes.V1_8); + assertTrue(kotlinClass.toFile().setLastModified(markerTime + 60000)); + + assertTrue(hasChangedSinceLastCheck(mojo), + "Expected re-run when the Kotlin incremental output tree has classes newer than the last check"); + } + + @Test + void rerunsComplianceCheckAfterPreviousFailureReport(@TempDir Path tempDir) throws Exception { + BytecodeComplianceMojo mojo = newMojoForSkipCheck(tempDir); + java.io.File marker = markerFile(mojo); + long markerTime = marker.lastModified(); + Files.write(marker.toPath(), + "Codename One compliance check failed.\nProject: test\n".getBytes("UTF-8")); + assertTrue(marker.setLastModified(markerTime)); + + assertTrue(hasChangedSinceLastCheck(mojo), + "Expected re-run when the previous check recorded violations, even with nothing newer"); + } + + private BytecodeComplianceMojo newMojoForSkipCheck(Path tempDir) throws Exception { + Path iosDir = tempDir.resolve("ios"); + Path commonDir = tempDir.resolve("common"); + Files.createDirectories(iosDir.resolve("target").resolve("codenameone")); + Files.createDirectories(commonDir); + + long base = System.currentTimeMillis(); + Path settings = commonDir.resolve("codenameone_settings.properties"); + Files.write(settings, new byte[0]); + assertTrue(settings.toFile().setLastModified(base - 120000)); + + Path marker = iosDir.resolve("target").resolve("codenameone").resolve("compliance_check.txt"); + Files.write(marker, "Completed compliance check on test\n".getBytes("UTF-8")); + assertTrue(marker.toFile().setLastModified(base)); + + MavenProject project = new MavenProject(); + project.setFile(iosDir.resolve("pom.xml").toFile()); + org.apache.maven.model.Build build = new org.apache.maven.model.Build(); + build.setDirectory(iosDir.resolve("target").toString()); + build.setOutputDirectory(iosDir.resolve("target").resolve("classes").toString()); + project.setBuild(build); + + BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + mojo.project = project; + setField(mojo, "complianceOutputFile", marker.toFile()); + return mojo; + } + + private java.io.File markerFile(BytecodeComplianceMojo mojo) throws Exception { + return (java.io.File) field(mojo, "complianceOutputFile"); + } + + private Path outputDir(BytecodeComplianceMojo mojo) { + return java.nio.file.Paths.get(mojo.project.getBuild().getOutputDirectory()); + } + + private boolean hasChangedSinceLastCheck(BytecodeComplianceMojo mojo) throws Exception { + Method method = BytecodeComplianceMojo.class.getDeclaredMethod("hasChangedSinceLastCheck"); + method.setAccessible(true); + return ((Boolean) method.invoke(mojo)).booleanValue(); + } + @SuppressWarnings("unchecked") private Map buildClassIndex(BytecodeComplianceMojo mojo, List roots) throws Exception { Method method = BytecodeComplianceMojo.class.getDeclaredMethod("buildClassIndex", List.class);