From 0dc503e0afda37281019abecca47013adf991f05 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:30:53 +0300 Subject: [PATCH 01/94] Stop an ordinary AndroidX dependency from failing the build on a duplicate Kotlin class Kotlin 1.8.0 folded kotlin-stdlib-jdk7 and kotlin-stdlib-jdk8 into kotlin-stdlib and left the two jdk artifacts as empty shims. Gradle resolves every module's version on its own, so a graph reaching kotlin-stdlib at 1.8 or newer through one path and kotlin-stdlib-jdk8 at something older through another ends up with two real jars carrying the same classes: Duplicate class kotlin.collections.jdk8.CollectionsJDK8Kt found in modules kotlin-stdlib-1.8.22.jar (org.jetbrains.kotlin:kotlin-stdlib:1.8.22) and kotlin-stdlib-jdk8-1.6.21.jar (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21) Nothing Kotlin-shaped in the app is needed to produce it, which is what makes the report unreadable from the app side: com.android.billingclient:billing:9.1.0 does it on its own, reaching kotlin-stdlib 1.8.22 through androidx.core:core -> core-ktx and kotlin-stdlib-jdk8 1.6.21 through androidx.core:core -> lifecycle-runtime -> kotlinx-coroutines-android. The app declares one library and the failure names two Kotlin artifacts it has never heard of. Our own androidx.appcompat default puts a second kotlin-stdlib-jdk8 branch in the graph too, so the exposure is not confined to billing. Gradle would normally sort this out. From 1.9.22 kotlin-stdlib publishes module metadata constraining kotlin-stdlib-jdk7 and jdk8 to 1.8.0 -- exactly the alignment added here. The 1.8.x line, which is what the current AndroidX releases resolve to, publishes no .module file at all, only a POM, and a POM cannot express a constraint. So on 1.8.x nothing tells Gradle the two artifacts overlap. This supplies for 1.8.x what JetBrains supplies from 1.9.22 on. Written as a constraint rather than a force: it raises a version, never lowers one, and never pulls a module into a graph that does not already contain it. An app with no Kotlin anywhere resolves exactly as before. Skipped when the Kotlin Gradle plugin is applied, since it performs the same alignment itself and the Kotlin version such a build compiles with can be older than the floor. Skipped when the app already names either jdk artifact or the Kotlin BOM in its own Gradle build hints, and switchable with android.kotlinStdlibAlignment=false. Verified against the real graph with Gradle rather than by reading POMs. Both coordinates resolved from google() + mavenCentral() for billing:9.1.0 plus appcompat:1.6.1: without the block: kotlin-stdlib:1.8.22, kotlin-stdlib-jdk7:1.6.21, kotlin-stdlib-jdk8:1.6.21 <- two real jars, the failure with the block: kotlin-stdlib:1.8.22, kotlin-stdlib-jdk7:1.8.0, kotlin-stdlib-jdk8:1.8.0 <- shims, no duplicate and for an app whose graph has no Kotlin in it, zero org.jetbrains.kotlin modules either way. jdk7 resolving to 1.6.21 alongside jdk8 is why both are aligned and not just the one the reports name. Co-Authored-By: Claude Opus 5 (1M context) --- .../build/shared/BuildHintsAndroid.java | 17 ++ .../builders/AndroidGradleBuilder.java | 26 ++ .../builders/KotlinStdlibAlignment.java | 202 ++++++++++++++++ .../builders/KotlinStdlibAlignmentTest.java | 224 ++++++++++++++++++ 4 files changed, 469 insertions(+) create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index 1a5ac2d7a60..7b624601fb9 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -638,6 +638,23 @@ static void register(List h) { .doc("Boolean true/false defaults to true. Toggles the new async keyboard mode that leaves the " + "keyboard open while you move between text components")); + h.add(new Hint("android.kotlinStdlibAlignment") + .group(HintGroup.ANDROID) + .type(HintType.BOOLEAN) + .def("true") + .platform("android") + .doc("Boolean true/false defaults to true. Keeps `kotlin-stdlib-jdk7` and " + + "`kotlin-stdlib-jdk8` at 1.8.0 or newer, the versions where both became empty " + + "shims because their classes moved into `kotlin-stdlib`. Without it a graph " + + "that reaches `kotlin-stdlib` 1.8 or newer through one dependency and an older " + + "`kotlin-stdlib-jdk8` through another gets two jars carrying the same classes, " + + "and the build fails in `checkReleaseDuplicateClasses` naming Kotlin artifacts " + + "the app never asked for. It is expressed as a Gradle constraint, so it adds " + + "nothing to an app with no Kotlin anywhere in its dependencies and never " + + "lowers a version. Set to false only to manage those coordinates yourself; " + + "declaring `kotlin-stdlib-jdk7`, `kotlin-stdlib-jdk8` or `kotlin-bom` in your " + + "own Gradle build hints already switches it off.")); + h.add(new Hint("android.largeScreens") .group(HintGroup.ANDROID) .type(HintType.BOOLEAN) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index f0b655f35c2..de5fd8fef3a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7270,6 +7270,31 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { namespace = "namespace '"+request.getPackageName()+"'\n"; } + // Kotlin stdlib alignment, emitted for every AndroidX build rather than + // for Kotlin-shaped apps: the duplicate class it prevents is produced by + // ordinary AndroidX and Play dependencies, not by anything the app wrote. + // See KotlinStdlibAlignment for the mechanism and for why Gradle cannot + // work it out for itself on the kotlin-stdlib 1.8.x line. A constraint + // adds nothing to a graph that has no Kotlin in it, so an app that could + // never hit the clash resolves exactly as it did before. + // + // Gated on AndroidX because the block is written on the implementation + // configuration and Gradle's constraints DSL only arrived in 4.6; the + // legacy support-library templates predate both, and predate the AndroidX + // releases that produce the clash. + String kotlinStdlibConstraints = ""; + if (useAndroidX && gradleVersionInt >= 6 + && request.getArg("android.kotlinStdlibAlignment", "true").equals("true")) { + kotlinStdlibConstraints = KotlinStdlibAlignment.constraintsBlock( + compile, + hasKotlinSources || request.getArg("android.topDependency", "") + .contains("kotlin-gradle-plugin"), + additionalDependencies, + aiExtraGradleDependencies.toString(), + request.getArg("android.gradleDep", ""), + request.getArg("android.xgradle", "")); + } + String gradleProps = "apply plugin: 'com.android.application'\n" + kotlinPluginApply + request.getArg("android.gradlePlugin", "") @@ -7361,6 +7386,7 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { + addNewlineIfMissing(aiExtraGradleDependencies.toString()) + addNewlineIfMissing(request.getArg("android.gradleDep", "")) + addNewlineIfMissing(aarDependencies) + + kotlinStdlibConstraints + "}\n" + request.getArg("android.xgradle", ""); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java new file mode 100644 index 00000000000..dd90cd49d20 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) 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.builders; + +/** + * The Kotlin stdlib alignment written into the generated Android + * {@code build.gradle}. + * + *

The failure it prevents. Kotlin 1.8.0 folded the contents of + * {@code kotlin-stdlib-jdk7} and {@code kotlin-stdlib-jdk8} into + * {@code kotlin-stdlib} and left the two jdk artifacts as empty shims that + * only depend on it. Gradle resolves every module's version independently, + * so a graph that asks for {@code kotlin-stdlib} at 1.8.0 or newer through + * one path and {@code kotlin-stdlib-jdk8} at something older through + * another ends up with two real jars carrying the same classes, and the + * build dies in {@code checkReleaseDuplicateClasses}:

+ * + *
+ * Duplicate class kotlin.collections.jdk8.CollectionsJDK8Kt found in modules
+ *   kotlin-stdlib-1.8.22.jar (org.jetbrains.kotlin:kotlin-stdlib:1.8.22) and
+ *   kotlin-stdlib-jdk8-1.6.21.jar (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21)
+ * 
+ * + *

Nothing exotic is needed to produce it. A single ordinary dependency + * does it on its own: {@code com.android.billingclient:billing:9.1.0} pulls + * {@code androidx.core:core:1.15.0}, which reaches {@code kotlin-stdlib} + * 1.8.22 through {@code core-ktx} and {@code kotlin-stdlib-jdk8} 1.6.21 + * through {@code lifecycle-runtime -> kotlinx-coroutines-android:1.6.4}. + * Neither coordinate is anything Codename One asked for, which is what makes + * the error so hard to read from the app side: the app declares one library + * and the report names two Kotlin artifacts it has never heard of.

+ * + *

Why Gradle does not sort this out by itself. It normally would. + * From 1.9.22 {@code kotlin-stdlib} publishes Gradle module metadata whose + * {@code jvmApiElements} and {@code jvmRuntimeElements} variants carry + * dependency constraints raising {@code kotlin-stdlib-jdk7} and + * {@code kotlin-stdlib-jdk8} to {@value #MERGED_STDLIB_FLOOR} -- the exact + * alignment below. The 1.8.x line, which is what the current AndroidX + * releases resolve to, publishes no {@code .module} file at all, only + * a POM, and a POM cannot express a constraint. So on 1.8.x there is nothing + * telling Gradle the two artifacts overlap, and it has no way to find out. + * This class supplies for 1.8.x what JetBrains supplies from 1.9.22 on.

+ * + *

Why a constraint and not a force. A constraint raises a version + * and never lowers one, and never pulls a module into a graph that does not + * already contain it. An app with no Kotlin anywhere is therefore completely + * unaffected -- the block resolves to nothing. An app that does have the jdk + * artifacts gets them at {@value #MERGED_STDLIB_FLOOR} or newer, which is + * always a shim, so the duplicate cannot arise whichever version of + * {@code kotlin-stdlib} the rest of the graph settles on. Forcing a fixed + * version would instead override a newer one the app deliberately asked + * for.

+ * + *

Why it is skipped when the Kotlin Gradle plugin is applied. The + * plugin performs this same alignment itself + * ({@code kotlin.stdlib.jdk.variants.version.alignment}, on by default), so + * emitting ours would be redundant there. More to the point, an app with + * {@code .kt} sources is compiled by whatever Kotlin version the build + * selected, and that can be older than {@value #MERGED_STDLIB_FLOOR} -- + * pushing a newer stdlib underneath an older compiler earns a + * "runtime version is newer than compiler" warning for no gain. Leaving the + * plugin to do its own job avoids both.

+ * + *

Extracted into a pure static helper so it is unit-testable without a + * Gradle run and so the BuildDaemon copy stays trivially diffable -- + * keep this file in sync with its twin in the other repository.

+ */ +public class KotlinStdlibAlignment { + + /** + * The first {@code kotlin-stdlib} release that absorbed the jdk7/jdk8 + * classes, which is therefore the first version of those two artifacts + * that is an empty shim rather than a second copy of the classes. + * Verified against the published jars: {@code kotlin-stdlib-jdk8:1.7.22} + * carries 14 classes including {@code CollectionsJDK8Kt}, and + * {@code kotlin-stdlib-jdk8:1.8.0} carries one and none of them. + * + *

It is also the exact floor {@code kotlin-stdlib:1.9.22}'s own module + * metadata constrains them to, so this is JetBrains' number rather than + * one chosen here.

+ */ + public static final String MERGED_STDLIB_FLOOR = "1.8.0"; + + /** + * Coordinates that mean the app is already managing the Kotlin stdlib + * artifacts itself, in which case this class stays out of the way. + * A BOM counts: it aligns the whole {@code org.jetbrains.kotlin} group, + * which is a superset of what the constraints below do. + */ + private static final String[] APP_MANAGED_MARKERS = { + "kotlin-stdlib-jdk7", + "kotlin-stdlib-jdk8", + "kotlin-bom" + }; + + private KotlinStdlibAlignment() { + } + + /** + * The {@code constraints} block to append inside the generated + * {@code dependencies { }}, or an empty string when no alignment should + * be written. + * + * @param configuration the dependency configuration to declare the + * constraints on, {@code implementation} on any AndroidX project. The + * caller passes the same name it uses for the rest of the block so a + * legacy {@code compile} project stays consistent with itself. + * @param kotlinGradlePluginApplied whether this build applies the Kotlin + * Gradle plugin, which does the alignment itself. Nothing is emitted + * when it does. + * @param appGradleFragments the Gradle text the app itself contributed + * ({@code gradleDependencies}, {@code android.gradleDep} and the like). + * An app already naming one of the jdk artifacts or the Kotlin BOM has + * made a deliberate choice and is left alone. Null entries are ignored. + * @return the block, newline terminated, or {@code ""} + */ + public static String constraintsBlock(String configuration, + boolean kotlinGradlePluginApplied, String... appGradleFragments) { + if (kotlinGradlePluginApplied) { + return ""; + } + if (configuration == null || configuration.trim().length() == 0) { + return ""; + } + if (appManagesKotlinStdlib(appGradleFragments)) { + return ""; + } + String config = configuration.trim(); + // "because" is not decoration: it is what `gradle dependencyInsight` prints + // next to the raised version, and this constraint is otherwise unattributable + // to anything in the developer's project. + String because = "Codename One: kotlin-stdlib " + MERGED_STDLIB_FLOOR + + " absorbed the jdk7/jdk8 classes and the 1.8.x line ships no " + + "Gradle module metadata to say so, so these are raised to the " + + "empty shims to avoid a duplicate class in checkDuplicateClasses"; + StringBuilder out = new StringBuilder(); + out.append(" constraints {\n"); + for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { + out.append(" ").append(config) + .append("('org.jetbrains.kotlin:").append(ALIGNED_ARTIFACTS[i]) + .append(':').append(MERGED_STDLIB_FLOOR).append("') {\n") + .append(" because '").append(because).append("'\n") + .append(" }\n"); + } + out.append(" }\n"); + return out.toString(); + } + + /** + * The two artifacts whose classes moved into {@code kotlin-stdlib}. + * jdk7 is aligned alongside jdk8 even though jdk8 is the one that shows + * up in the reports: jdk8 depends on jdk7, so an app reaching a + * pre-{@value #MERGED_STDLIB_FLOOR} jdk7 through some other path would + * hit the identical duplicate on {@code kotlin.jdk7.AutoCloseableKt}. + */ + private static final String[] ALIGNED_ARTIFACTS = { + "kotlin-stdlib-jdk7", + "kotlin-stdlib-jdk8" + }; + + /** + * Whether the app's own Gradle fragments already pin or align the Kotlin + * stdlib jdk artifacts. + */ + public static boolean appManagesKotlinStdlib(String... appGradleFragments) { + if (appGradleFragments == null) { + return false; + } + for (int i = 0; i < appGradleFragments.length; i++) { + String fragment = appGradleFragments[i]; + if (fragment == null) { + continue; + } + for (int j = 0; j < APP_MANAGED_MARKERS.length; j++) { + if (fragment.contains(APP_MANAGED_MARKERS[j])) { + return true; + } + } + } + return false; + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java new file mode 100644 index 00000000000..530f52726ad --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 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.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The Kotlin stdlib alignment written into the generated Android + * {@code build.gradle}. + * + *

Every case here is about restraint rather than about the block's text: + * the alignment lands in the dependency graph of every AndroidX app, so the + * cases that must produce nothing matter more than the one that must produce + * something. The floor gets a test of its own because 1.8.0 is not a + * preference -- it is the release where the two artifacts became empty + * shims, and lowering it would reintroduce the duplicate class the block + * exists to prevent.

+ */ +public class KotlinStdlibAlignmentTest { + + private static String block() { + return KotlinStdlibAlignment.constraintsBlock("implementation", false); + } + + @Test + public void constrainsBothJdkArtifactsToTheShimFloor() { + String out = block(); + assertTrue(out.contains( + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0')")); + assertTrue(out.contains( + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0')")); + } + + /** + * jdk8 is the artifact that shows up in the duplicate class reports, but + * it depends on jdk7, so leaving jdk7 alone would move the same failure + * onto {@code kotlin.jdk7.AutoCloseableKt} rather than remove it. + */ + @Test + public void doesNotAlignJdk8Alone() { + assertTrue(block().contains("kotlin-stdlib-jdk7")); + } + + /** + * 1.8.0 is the first release of the two jdk artifacts that carries no + * classes. An older floor would still leave a real jar in the graph. + */ + @Test + public void theFloorIsTheVersionWhereTheClassesMoved() { + assertEquals("1.8.0", KotlinStdlibAlignment.MERGED_STDLIB_FLOOR); + } + + /** It is a constraints block, not a dependency declaration or a force. */ + @Test + public void declaresConstraintsRatherThanDependencies() { + String out = block(); + assertTrue(out.contains("constraints {")); + assertFalse(out.contains("force")); + int open = 0; + int close = 0; + for (int i = 0; i < out.length(); i++) { + if (out.charAt(i) == '{') { + open++; + } else if (out.charAt(i) == '}') { + close++; + } + } + assertEquals(open, close); + } + + /** + * Gradle prints the reason next to the raised version in + * {@code dependencyInsight}, and this constraint corresponds to nothing + * in the developer's own project, so an unattributed one is a support + * question waiting to happen. + */ + @Test + public void everyConstraintCarriesAReason() { + String out = block(); + assertEquals(2, countOccurrences(out, "because '")); + assertTrue(out.contains("Codename One")); + } + + /** + * The Kotlin Gradle plugin performs the same alignment itself, and the + * Kotlin version a build compiles with can be older than the floor, so + * pushing a newer stdlib underneath it would only earn a warning. + */ + @Test + public void emitsNothingWhenTheKotlinGradlePluginIsApplied() { + assertEquals("", KotlinStdlibAlignment.constraintsBlock("implementation", true)); + } + + @Test + public void emitsNothingWhenTheAppPinsAJdkArtifactItself() { + assertEquals("", KotlinStdlibAlignment.constraintsBlock("implementation", false, + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n")); + assertEquals("", KotlinStdlibAlignment.constraintsBlock("implementation", false, + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22'\n")); + } + + /** + * A BOM aligns the whole {@code org.jetbrains.kotlin} group, which is a + * superset of this block, so an app using one has already answered the + * question. + */ + @Test + public void emitsNothingWhenTheAppUsesTheKotlinBom() { + assertEquals("", KotlinStdlibAlignment.constraintsBlock("implementation", false, + " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n")); + } + + /** + * The fragments arrive straight from build hints, so an unset hint shows + * up as an empty string and an absent one can be null. Neither is a + * reason to skip the alignment, and neither may throw. + */ + @Test + public void ignoresEmptyAndNullFragments() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", false, + "", null, " implementation 'androidx.appcompat:appcompat:1.6.1'\n"); + assertTrue(out.contains("kotlin-stdlib-jdk8")); + assertFalse(KotlinStdlibAlignment.appManagesKotlinStdlib((String[]) null)); + } + + /** + * An unrelated Kotlin coordinate is not a pin. Only the two jdk + * artifacts and the BOM decide who owns the alignment; matching + * "kotlin" loosely would silently switch the fix off for any app that + * happens to use a Kotlin library. + */ + @Test + public void anUnrelatedKotlinDependencyIsNotAPin() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", false, + " implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'\n"); + assertTrue(out.contains("kotlin-stdlib-jdk8")); + } + + /** + * A pre-AndroidX project declares its dependencies on {@code compile}, + * and a constraints block on a configuration the project does not have + * fails evaluation rather than being ignored. + */ + @Test + public void usesTheConfigurationItWasGiven() { + String out = KotlinStdlibAlignment.constraintsBlock("compile", false); + assertTrue(out.contains("compile('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0')")); + assertFalse(out.contains("implementation(")); + } + + @Test + public void emitsNothingWithoutAConfiguration() { + assertEquals("", KotlinStdlibAlignment.constraintsBlock(null, false)); + assertEquals("", KotlinStdlibAlignment.constraintsBlock(" ", false)); + } + + /** + * The block is concatenated inside the generated {@code dependencies} + * block between the last dependency and the closing brace, so it has to + * both start and end on its own line. + */ + @Test + public void isNewlineTerminatedForConcatenation() { + String out = block(); + assertTrue(out.endsWith("}\n")); + assertTrue(out.startsWith(" constraints {\n")); + } + + /** + * The half a unit test of the helper cannot see. The helper returning the + * right text is worthless if the builder stops concatenating it, and that + * is a one-character deletion in a 100-line string expression nothing + * else would notice -- the build stays green and the duplicate class + * comes back. + * + *

Source text, because the expression is a local inside a method + * thousands of lines long that cannot be called without a whole staged + * Android project.

+ */ + @Test + public void theBuilderStillWritesItIntoTheDependenciesBlock() throws Exception { + byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); + String src = new String(bytes, "UTF-8"); + int at = src.indexOf("\"dependencies {\\n\""); + assertTrue(at >= 0); + String block = src.substring(at, src.indexOf("+ \"}\\n\"", at)); + assertTrue(block.contains("+ kotlinStdlibConstraints")); + } + + private static int countOccurrences(String haystack, String needle) { + int count = 0; + int at = haystack.indexOf(needle); + while (at >= 0) { + count++; + at = haystack.indexOf(needle, at + needle.length()); + } + return count; + } +} From f80003510fc178e828c1753d9f5635261e0fa892 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:49:28 +0300 Subject: [PATCH 02/94] Say the hint's rationale without the contraction Vale rejects The build hint catalog's doc text is rendered into the developer guide, where Microsoft.Contractions is an error rather than a suggestion, so "It is expressed as a Gradle constraint" failed the prose gate on a file nothing in the tree edits by hand. Reproduced locally against the rendered table rather than guessed at: vale over docs/developer-guide/_generated-build-hints.adoc reports the one alert with the old wording and none with this one, and LanguageTool runs clean with status ok (not the "Detected java 1.8" fail-open). Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/com/codename1/build/shared/BuildHintsAndroid.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index 7b624601fb9..454a9885216 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -649,7 +649,7 @@ static void register(List h) { + "that reaches `kotlin-stdlib` 1.8 or newer through one dependency and an older " + "`kotlin-stdlib-jdk8` through another gets two jars carrying the same classes, " + "and the build fails in `checkReleaseDuplicateClasses` naming Kotlin artifacts " - + "the app never asked for. It is expressed as a Gradle constraint, so it adds " + + "the app never asked for. Expressed as a Gradle constraint, so it adds " + "nothing to an app with no Kotlin anywhere in its dependencies and never " + "lowers a version. Set to false only to manage those coordinates yourself; " + "declaring `kotlin-stdlib-jdk7`, `kotlin-stdlib-jdk8` or `kotlin-bom` in your " From 6bd4562cd5afe0a04d4b91f098093a42b2c9a1c4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:05:26 +0300 Subject: [PATCH 03/94] Decide the Kotlin plugin skip on its version, and suppress per artifact Two review findings, both real, both verified against a resolved Gradle graph rather than reasoned about. Skipping whenever a Kotlin plugin was applied was too broad. Only 1.8 and newer align the jdk stdlib variants themselves; on the android.useGradle8=false path this builder selects 1.7.22, which does not. Measured: plugin 1.7.22 alone stdlib 1.7.22 + jdk7/jdk8 1.7.22 no duplicate plugin 1.7.22 + billing 9.1.0 stdlib 1.8.22 + jdk7/jdk8 1.7.22 DUPLICATE the same, with the block stdlib 1.8.22 + jdk7/jdk8 1.8.0 fixed The middle row is worse than a transitive accident: the 1.7 plugin ADDS kotlin-stdlib-jdk8 at its own version, so the pre-merge real jar is guaranteed present rather than merely possible. The test is now the applied plugin's version, and an unreadable one -- kotlin-gradle-plugin:$kotlin_version parses to nothing -- counts as "does not align" so the block is written rather than skipped. That costs one case, stated in the class comment rather than left to be discovered: on the same pre-1.8 path, an app whose graph has no merged stdlib did not need the block and gets its stdlib raised to 1.8.0 anyway, newer than the compiler in use, which Kotlin warns about. Gradle cannot express a constraint conditional on what another module resolved to, so the choice is a warning where it was not needed against a failed build where it was. Suppression is now per artifact. jdk8 depends on jdk7, so an app pinning jdk8 raises jdk7 with it -- but an app pinning jdk7 leaves jdk8 exactly where the graph put it, and dropping the whole block there left the original duplicate intact with its fix switched off. Safe to split because the two jars' class sets are disjoint (kotlin.jdk7 / kotlin.io.path against kotlin.collections.jdk8 / kotlin.streams.jdk8), so constraining one and not the other cannot make a new duplicate. The Kotlin BOM still suppresses both, since it aligns the whole group. Three new cases cover this, and all three fail against the previous behaviour -- checked by reverting each half in turn, not assumed. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 19 +- .../builders/KotlinStdlibAlignment.java | 172 +++++++++----- .../builders/KotlinStdlibAlignmentTest.java | 211 ++++++++++++------ 3 files changed, 284 insertions(+), 118 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index de5fd8fef3a..79cd13cc744 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7282,13 +7282,28 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // configuration and Gradle's constraints DSL only arrived in 4.6; the // legacy support-library templates predate both, and predate the AndroidX // releases that produce the clash. + // The Kotlin Gradle plugin version this build actually applies, empty when it + // applies none. Which version matters: only 1.8 and newer align the jdk stdlib + // variants themselves. An app that declares its own kotlin-gradle-plugin wins, + // because the generator then skips its own plugin line -- and a declaration + // whose version is a Gradle variable parses to null, which reads downstream as + // "cannot tell" and therefore as "does not align", so the block is written. + String appliedKotlinPlugin = ""; + if (hasKotlinSources) { + appliedKotlinPlugin = kotlinVersion; + String kotlinTopDependency = request.getArg("android.topDependency", ""); + if (HealthManifestFragments.declaresKotlinPlugin(kotlinTopDependency)) { + String declared = HealthManifestFragments + .declaredKotlinPluginVersion(kotlinTopDependency); + appliedKotlinPlugin = declared == null ? "" : declared; + } + } String kotlinStdlibConstraints = ""; if (useAndroidX && gradleVersionInt >= 6 && request.getArg("android.kotlinStdlibAlignment", "true").equals("true")) { kotlinStdlibConstraints = KotlinStdlibAlignment.constraintsBlock( compile, - hasKotlinSources || request.getArg("android.topDependency", "") - .contains("kotlin-gradle-plugin"), + appliedKotlinPlugin, additionalDependencies, aiExtraGradleDependencies.toString(), request.getArg("android.gradleDep", ""), diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index dd90cd49d20..476b37ad988 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -71,15 +71,37 @@ * version would instead override a newer one the app deliberately asked * for.

* - *

Why it is skipped when the Kotlin Gradle plugin is applied. The - * plugin performs this same alignment itself - * ({@code kotlin.stdlib.jdk.variants.version.alignment}, on by default), so - * emitting ours would be redundant there. More to the point, an app with - * {@code .kt} sources is compiled by whatever Kotlin version the build - * selected, and that can be older than {@value #MERGED_STDLIB_FLOOR} -- - * pushing a newer stdlib underneath an older compiler earns a - * "runtime version is newer than compiler" warning for no gain. Leaving the - * plugin to do its own job avoids both.

+ *

Why the Kotlin Gradle plugin only sometimes excuses this. From + * 1.8.0 the plugin aligns the jdk variants itself, so the block would be a + * no-op and is skipped. An older plugin does not, and skipping there was a + * real bug: the versions were resolved with Gradle rather than reasoned + * about, and a project on the {@code android.useGradle8=false} path -- where + * this builder selects Kotlin 1.7.22 -- resolves like this:

+ * + *
+ * plugin 1.7.22 alone            stdlib 1.7.22 + jdk7/jdk8 1.7.22   no duplicate
+ * plugin 1.7.22 + billing 9.1.0  stdlib 1.8.22 + jdk7/jdk8 1.7.22   DUPLICATE
+ * the same, with this block      stdlib 1.8.22 + jdk7/jdk8 1.8.0    fixed
+ * 
+ * + *

Note the middle row is worse than a transitive accident: the 1.7.x + * plugin adds {@code kotlin-stdlib-jdk8} at its own version, so the + * older real jar is guaranteed present rather than merely possible, and any + * dependency that reaches a merged stdlib collides with it. Hence the test + * is the applied plugin's version, not whether a plugin is applied at all, + * and an unreadable version counts as "does not align" so the block is + * written rather than skipped.

+ * + *

The cost of that, stated plainly. On the same pre-1.8 plugin + * path, an app whose graph contains no merged stdlib (the first row above) + * did not need the block, and gets its stdlib family raised to + * {@value #MERGED_STDLIB_FLOOR} anyway -- newer than the compiler in use, + * which Kotlin warns about. That is deliberate. Gradle cannot express a + * constraint conditional on what another module resolved to, so the choice + * is between a warning in the case that did not need help and a failed build + * in the case that did, and a warning is the better of the two. Raising the + * builder's own pre-Gradle-8 Kotlin default would remove even that, and is a + * bigger change than this one should carry.

* *

Extracted into a pure static helper so it is unit-testable without a * Gradle run and so the BuildDaemon copy stays trivially diffable -- @@ -96,23 +118,39 @@ public class KotlinStdlibAlignment { * {@code kotlin-stdlib-jdk8:1.8.0} carries one and none of them. * *

It is also the exact floor {@code kotlin-stdlib:1.9.22}'s own module - * metadata constrains them to, so this is JetBrains' number rather than - * one chosen here.

+ * metadata constrains them to, and the version from which the Kotlin + * Gradle plugin performs this alignment itself, so this is JetBrains' + * number in three separate places rather than one chosen here.

*/ public static final String MERGED_STDLIB_FLOOR = "1.8.0"; /** - * Coordinates that mean the app is already managing the Kotlin stdlib - * artifacts itself, in which case this class stays out of the way. - * A BOM counts: it aligns the whole {@code org.jetbrains.kotlin} group, - * which is a superset of what the constraints below do. + * The two artifacts whose classes moved into {@code kotlin-stdlib}. + * + *

Both are aligned, and each is suppressed on its own. Suppressing + * both because the app named one would leave the artifact it did not name + * unconstrained, and that is not symmetrical: {@code jdk8} depends on + * {@code jdk7}, so an app pinning jdk8 raises jdk7 with it, while an app + * pinning jdk7 leaves jdk8 exactly where the graph put it -- the original + * duplicate, intact, with the block that would have fixed it switched + * off. They can be treated separately because their class sets are + * disjoint ({@code kotlin.jdk7} / {@code kotlin.io.path} against + * {@code kotlin.collections.jdk8} / {@code kotlin.streams.jdk8}), so + * constraining one and not the other cannot make a new duplicate.

*/ - private static final String[] APP_MANAGED_MARKERS = { + private static final String[] ALIGNED_ARTIFACTS = { "kotlin-stdlib-jdk7", - "kotlin-stdlib-jdk8", - "kotlin-bom" + "kotlin-stdlib-jdk8" }; + /** + * The marker that suppresses the whole block rather than one artifact. + * A BOM aligns every module in the {@code org.jetbrains.kotlin} group, + * which is a superset of what this class does, so an app using one has + * already answered the question for both artifacts. + */ + private static final String KOTLIN_BOM = "kotlin-bom"; + private KotlinStdlibAlignment() { } @@ -125,24 +163,26 @@ private KotlinStdlibAlignment() { * constraints on, {@code implementation} on any AndroidX project. The * caller passes the same name it uses for the rest of the block so a * legacy {@code compile} project stays consistent with itself. - * @param kotlinGradlePluginApplied whether this build applies the Kotlin - * Gradle plugin, which does the alignment itself. Nothing is emitted - * when it does. + * @param appliedKotlinPluginVersion the version of the Kotlin Gradle + * plugin this build applies, or null/empty when it applies none. Only + * {@value #MERGED_STDLIB_FLOOR} and newer align the jdk variants + * themselves; anything older, or anything this cannot read, is treated + * as not aligning and the block is written. * @param appGradleFragments the Gradle text the app itself contributed * ({@code gradleDependencies}, {@code android.gradleDep} and the like). - * An app already naming one of the jdk artifacts or the Kotlin BOM has - * made a deliberate choice and is left alone. Null entries are ignored. + * An artifact the app names there is left to the app; the Kotlin BOM + * suppresses both. Null entries are ignored. * @return the block, newline terminated, or {@code ""} */ public static String constraintsBlock(String configuration, - boolean kotlinGradlePluginApplied, String... appGradleFragments) { - if (kotlinGradlePluginApplied) { + String appliedKotlinPluginVersion, String... appGradleFragments) { + if (alignsItsOwnJdkVariants(appliedKotlinPluginVersion)) { return ""; } if (configuration == null || configuration.trim().length() == 0) { return ""; } - if (appManagesKotlinStdlib(appGradleFragments)) { + if (contains(KOTLIN_BOM, appGradleFragments)) { return ""; } String config = configuration.trim(); @@ -154,49 +194,81 @@ public static String constraintsBlock(String configuration, + "Gradle module metadata to say so, so these are raised to the " + "empty shims to avoid a duplicate class in checkDuplicateClasses"; StringBuilder out = new StringBuilder(); - out.append(" constraints {\n"); for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { + if (contains(ALIGNED_ARTIFACTS[i], appGradleFragments)) { + continue; + } out.append(" ").append(config) .append("('org.jetbrains.kotlin:").append(ALIGNED_ARTIFACTS[i]) .append(':').append(MERGED_STDLIB_FLOOR).append("') {\n") .append(" because '").append(because).append("'\n") .append(" }\n"); } - out.append(" }\n"); - return out.toString(); + if (out.length() == 0) { + return ""; + } + return " constraints {\n" + out + " }\n"; } /** - * The two artifacts whose classes moved into {@code kotlin-stdlib}. - * jdk7 is aligned alongside jdk8 even though jdk8 is the one that shows - * up in the reports: jdk8 depends on jdk7, so an app reaching a - * pre-{@value #MERGED_STDLIB_FLOOR} jdk7 through some other path would - * hit the identical duplicate on {@code kotlin.jdk7.AutoCloseableKt}. + * Whether a Kotlin Gradle plugin of this version aligns the jdk stdlib + * variants on its own, making this class's block a no-op. + * + *

Answered from the version rather than from "is a plugin applied", + * because the two differ exactly where it matters. Unknown reads as + * false: a version that cannot be parsed -- an app declaring + * {@code kotlin-gradle-plugin:$kotlin_version} produces one -- must not + * silently switch the alignment off.

*/ - private static final String[] ALIGNED_ARTIFACTS = { - "kotlin-stdlib-jdk7", - "kotlin-stdlib-jdk8" - }; + public static boolean alignsItsOwnJdkVariants(String kotlinPluginVersion) { + if (kotlinPluginVersion == null) { + return false; + } + // Shared with the Health Connect floor check rather than parsed again here: + // it already drops a qualifier, which rounds a prerelease up to its release + // and is the forgiving direction for a floor. + String numeric = HealthManifestFragments.numericVersionPrefix( + kotlinPluginVersion.trim()); + if (numeric == null) { + return false; + } + return compareVersions(numeric, MERGED_STDLIB_FLOOR) >= 0; + } - /** - * Whether the app's own Gradle fragments already pin or align the Kotlin - * stdlib jdk artifacts. - */ - public static boolean appManagesKotlinStdlib(String... appGradleFragments) { + /** Whether any fragment names this coordinate. */ + private static boolean contains(String marker, String[] appGradleFragments) { if (appGradleFragments == null) { return false; } for (int i = 0; i < appGradleFragments.length; i++) { String fragment = appGradleFragments[i]; - if (fragment == null) { - continue; - } - for (int j = 0; j < APP_MANAGED_MARKERS.length; j++) { - if (fragment.contains(APP_MANAGED_MARKERS[j])) { - return true; - } + if (fragment != null && fragment.contains(marker)) { + return true; } } return false; } + + /** Numeric dotted version compare; a missing segment counts as zero. */ + private static int compareVersions(String left, String right) { + String[] l = left.split("\\."); + String[] r = right.split("\\."); + int len = Math.max(l.length, r.length); + for (int i = 0; i < len; i++) { + int a = i < l.length ? parse(l[i]) : 0; + int b = i < r.length ? parse(r[i]) : 0; + if (a != b) { + return a < b ? -1 : 1; + } + } + return 0; + } + + private static int parse(String segment) { + try { + return Integer.parseInt(segment); + } catch (NumberFormatException notANumber) { + return 0; + } + } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 530f52726ad..b442a755bd0 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -24,8 +24,6 @@ import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -35,34 +33,32 @@ *

Every case here is about restraint rather than about the block's text: * the alignment lands in the dependency graph of every AndroidX app, so the * cases that must produce nothing matter more than the one that must produce - * something. The floor gets a test of its own because 1.8.0 is not a - * preference -- it is the release where the two artifacts became empty - * shims, and lowering it would reintroduce the duplicate class the block - * exists to prevent.

+ * something. The two that must NOT produce nothing -- + * {@link #aPreMergeKotlinPluginStillGetsTheAlignment()} and + * {@link #pinningOneJdkArtifactLeavesTheOtherConstrained()} -- are the ones + * that caught a real over-suppression, so treat a change that makes either + * pass vacuously as a regression.

*/ public class KotlinStdlibAlignmentTest { - private static String block() { - return KotlinStdlibAlignment.constraintsBlock("implementation", false); - } - - @Test - public void constrainsBothJdkArtifactsToTheShimFloor() { - String out = block(); - assertTrue(out.contains( - "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0')")); - assertTrue(out.contains( - "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0')")); + return KotlinStdlibAlignment.constraintsBlock("implementation", null); } /** - * jdk8 is the artifact that shows up in the duplicate class reports, but - * it depends on jdk7, so leaving jdk7 alone would move the same failure - * onto {@code kotlin.jdk7.AutoCloseableKt} rather than remove it. + * jdk8 is the artifact the duplicate class reports name, but the real + * graph resolves both to the same old version, so aligning jdk8 alone + * would move the failure onto {@code kotlin.jdk7.AutoCloseableKt} rather + * than remove it. */ @Test - public void doesNotAlignJdk8Alone() { - assertTrue(block().contains("kotlin-stdlib-jdk7")); + public void constrainsBothJdkArtifactsToTheShimFloor() { + String out = block(); + check(out.contains( + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0')"), + "jdk7 is aligned"); + check(out.contains( + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0')"), + "jdk8 is aligned"); } /** @@ -71,15 +67,16 @@ public void doesNotAlignJdk8Alone() { */ @Test public void theFloorIsTheVersionWhereTheClassesMoved() { - assertEquals("1.8.0", KotlinStdlibAlignment.MERGED_STDLIB_FLOOR); + check("1.8.0".equals(KotlinStdlibAlignment.MERGED_STDLIB_FLOOR), + "the floor is the version where the classes moved"); } /** It is a constraints block, not a dependency declaration or a force. */ @Test public void declaresConstraintsRatherThanDependencies() { String out = block(); - assertTrue(out.contains("constraints {")); - assertFalse(out.contains("force")); + check(out.contains("constraints {"), "it is a constraints block"); + check(!out.contains("force"), "it constrains rather than forces"); int open = 0; int close = 0; for (int i = 0; i < out.length(); i++) { @@ -89,7 +86,7 @@ public void declaresConstraintsRatherThanDependencies() { close++; } } - assertEquals(open, close); + check(open == close, "the block's braces balance"); } /** @@ -101,37 +98,110 @@ public void declaresConstraintsRatherThanDependencies() { @Test public void everyConstraintCarriesAReason() { String out = block(); - assertEquals(2, countOccurrences(out, "because '")); - assertTrue(out.contains("Codename One")); + check(countOccurrences(out, "because '") == 2, + "both constraints say why they are there"); + check(out.contains("Codename One"), "the reason names who wrote it"); + } + + /** + * From 1.8.0 the Kotlin Gradle plugin aligns the jdk variants itself, so + * the block would be a no-op. + */ + @Test + public void skipsOnlyAKotlinPluginThatAlignsItself() { + check("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", "1.8.0")), + "the release that starts aligning is skipped"); + check("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", "1.9.22")), + "a newer plugin is skipped"); + check(KotlinStdlibAlignment.alignsItsOwnJdkVariants("2.0.0"), + "a major bump still aligns"); + check(KotlinStdlibAlignment.alignsItsOwnJdkVariants("1.9.22-RC2"), + "a qualifier does not hide an aligning version"); + } + + /** + * The case that made this a version test rather than an is-a-plugin-applied + * test. On the {@code android.useGradle8=false} path the builder selects + * Kotlin 1.7.22, which predates the merge and does not align. Worse, the + * 1.7 plugin ADDS {@code kotlin-stdlib-jdk8} at its own version, so the + * pre-merge real jar is guaranteed present; any dependency reaching a + * merged stdlib then collides with it. Measured with Gradle: plugin 1.7.22 + * plus billing 9.1.0 resolves kotlin-stdlib 1.8.22 beside jdk7/jdk8 + * 1.7.22, which is the duplicate. Skipping there shipped the bug. + */ + @Test + public void aPreMergeKotlinPluginStillGetsTheAlignment() { + check(KotlinStdlibAlignment.constraintsBlock("implementation", "1.7.22") + .contains("kotlin-stdlib-jdk8"), + "a pre-merge plugin still gets the alignment"); + check(!KotlinStdlibAlignment.alignsItsOwnJdkVariants("1.7.22"), + "1.7.22 does not align"); + check(!KotlinStdlibAlignment.alignsItsOwnJdkVariants("1.6.21"), + "1.6.21 does not align"); } /** - * The Kotlin Gradle plugin performs the same alignment itself, and the - * Kotlin version a build compiles with can be older than the floor, so - * pushing a newer stdlib underneath it would only earn a warning. + * An app declaring {@code kotlin-gradle-plugin:$kotlin_version} parses to + * nothing. Unknown must read as "does not align" -- guessing the other way + * switches the fix off silently. */ @Test - public void emitsNothingWhenTheKotlinGradlePluginIsApplied() { - assertEquals("", KotlinStdlibAlignment.constraintsBlock("implementation", true)); + public void anUnreadablePluginVersionStillGetsTheAlignment() { + check(!KotlinStdlibAlignment.alignsItsOwnJdkVariants(""), + "no plugin does not align"); + check(!KotlinStdlibAlignment.alignsItsOwnJdkVariants(null), + "a null version does not align"); + check(!KotlinStdlibAlignment.alignsItsOwnJdkVariants("$kotlin_version"), + "a Gradle variable does not read as aligning"); + check(KotlinStdlibAlignment.constraintsBlock( + "implementation", "$kotlin_version").contains("kotlin-stdlib-jdk8"), + "an unreadable version still gets the alignment"); } + /** + * Suppression is per artifact. jdk8 depends on jdk7, so an app pinning + * jdk8 raises jdk7 with it -- but an app pinning jdk7 leaves jdk8 exactly + * where the graph put it, and dropping the whole block there would leave + * the original duplicate intact with its fix switched off. + */ @Test - public void emitsNothingWhenTheAppPinsAJdkArtifactItself() { - assertEquals("", KotlinStdlibAlignment.constraintsBlock("implementation", false, - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n")); - assertEquals("", KotlinStdlibAlignment.constraintsBlock("implementation", false, - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22'\n")); + public void pinningOneJdkArtifactLeavesTheOtherConstrained() { + String pinnedJdk7 = KotlinStdlibAlignment.constraintsBlock( + "implementation", null, + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22'\n"); + check(pinnedJdk7.contains("kotlin-stdlib-jdk8"), + "pinning jdk7 leaves jdk8 constrained"); + check(!pinnedJdk7.contains("kotlin-stdlib-jdk7:1.8.0"), + "the artifact the app pinned is left to the app"); + + String pinnedJdk8 = KotlinStdlibAlignment.constraintsBlock( + "implementation", null, + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); + check(pinnedJdk8.contains("kotlin-stdlib-jdk7"), + "pinning jdk8 leaves jdk7 constrained"); + check(!pinnedJdk8.contains("kotlin-stdlib-jdk8:1.8.0"), + "the artifact the app pinned is left to the app"); + + String pinnedBoth = KotlinStdlibAlignment.constraintsBlock( + "implementation", null, + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22'\n" + + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); + check("".equals(pinnedBoth), + "an app managing both gets no block at all, not an empty one"); } /** * A BOM aligns the whole {@code org.jetbrains.kotlin} group, which is a - * superset of this block, so an app using one has already answered the - * question. + * superset of this block, so it is the one marker that suppresses both. */ @Test public void emitsNothingWhenTheAppUsesTheKotlinBom() { - assertEquals("", KotlinStdlibAlignment.constraintsBlock("implementation", false, - " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n")); + check("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", null, + " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n")), + "an app using the Kotlin BOM is left alone"); } /** @@ -141,23 +211,24 @@ public void emitsNothingWhenTheAppUsesTheKotlinBom() { */ @Test public void ignoresEmptyAndNullFragments() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", false, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, "", null, " implementation 'androidx.appcompat:appcompat:1.6.1'\n"); - assertTrue(out.contains("kotlin-stdlib-jdk8")); - assertFalse(KotlinStdlibAlignment.appManagesKotlinStdlib((String[]) null)); + check(out.contains("kotlin-stdlib-jdk8"), + "an empty or absent hint is not a pin"); } /** - * An unrelated Kotlin coordinate is not a pin. Only the two jdk - * artifacts and the BOM decide who owns the alignment; matching - * "kotlin" loosely would silently switch the fix off for any app that - * happens to use a Kotlin library. + * An unrelated Kotlin coordinate is not a pin. Only the two jdk artifacts + * and the BOM decide who owns the alignment; matching "kotlin" loosely + * would silently switch the fix off for any app that happens to use a + * Kotlin library. */ @Test public void anUnrelatedKotlinDependencyIsNotAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", false, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, " implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'\n"); - assertTrue(out.contains("kotlin-stdlib-jdk8")); + check(out.contains("kotlin-stdlib-jdk8"), + "a coroutines dependency does not switch the alignment off"); } /** @@ -167,15 +238,19 @@ public void anUnrelatedKotlinDependencyIsNotAPin() { */ @Test public void usesTheConfigurationItWasGiven() { - String out = KotlinStdlibAlignment.constraintsBlock("compile", false); - assertTrue(out.contains("compile('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0')")); - assertFalse(out.contains("implementation(")); + String out = KotlinStdlibAlignment.constraintsBlock("compile", null); + check(out.contains("compile('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0')"), + "the caller's configuration is used"); + check(!out.contains("implementation("), + "no other configuration is assumed"); } @Test public void emitsNothingWithoutAConfiguration() { - assertEquals("", KotlinStdlibAlignment.constraintsBlock(null, false)); - assertEquals("", KotlinStdlibAlignment.constraintsBlock(" ", false)); + check("".equals(KotlinStdlibAlignment.constraintsBlock(null, null)), + "a null configuration writes nothing"); + check("".equals(KotlinStdlibAlignment.constraintsBlock(" ", null)), + "a blank configuration writes nothing"); } /** @@ -186,8 +261,18 @@ public void emitsNothingWithoutAConfiguration() { @Test public void isNewlineTerminatedForConcatenation() { String out = block(); - assertTrue(out.endsWith("}\n")); - assertTrue(out.startsWith(" constraints {\n")); + check(out.endsWith("}\n"), "it ends its own line"); + check(out.startsWith(" constraints {\n"), "it starts its own line"); + } + + private static int countOccurrences(String haystack, String needle) { + int count = 0; + int at = haystack.indexOf(needle); + while (at >= 0) { + count++; + at = haystack.indexOf(needle, at + needle.length()); + } + return count; } /** @@ -212,13 +297,7 @@ public void theBuilderStillWritesItIntoTheDependenciesBlock() throws Exception { assertTrue(block.contains("+ kotlinStdlibConstraints")); } - private static int countOccurrences(String haystack, String needle) { - int count = 0; - int at = haystack.indexOf(needle); - while (at >= 0) { - count++; - at = haystack.indexOf(needle, at + needle.length()); - } - return count; + private static void check(boolean condition, String message) { + assertTrue(condition, message); } } From 23ff2338d529c1f0c18cf0a5ec2ab2e8c3984d1b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:15:38 +0300 Subject: [PATCH 04/94] Read the Kotlin BOM by version too, not by presence A BOM manages the whole org.jetbrains.kotlin group, jdk7 and jdk8 included, so it looked like the one marker that could safely suppress both constraints. It is not, unless it is new enough: a platform contributes constraints, and the highest version still wins, so a BOM can raise the jdk artifacts but cannot pull kotlin-stdlib back down. Measured against a graph that wants stdlib 1.8.22: no BOM stdlib 1.8.22 + jdk7/jdk8 1.6.21 duplicate kotlin-bom 1.7.22 stdlib 1.8.22 + jdk7/jdk8 1.7.22 STILL a duplicate kotlin-bom 1.9.22 all 1.9.22, jdk artifacts shims safe 1.7.22 + this block stdlib 1.8.22 + jdk7/jdk8 1.8.0 fixed So the BOM is now tested the same way the Kotlin Gradle plugin already is, by version rather than by presence, and through the same predicate -- the two questions turned out to be one question asked about two things. An unreadable version reads as "does not align" for both, so kotlin-bom:$kotlinVersion gets the block rather than silently losing it. Worth recording that the first probe of this said the opposite. A bare Gradle configuration with no attributes cannot select a platform's constraint variant, so the BOM appeared to do nothing at any version and the finding looked wrong. It was the probe that was wrong. Anything measuring platform behaviour needs a configuration with real usage attributes, which is what the numbers above use. Three new cases, all three failing against the previous behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 84 +++++++++++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 47 ++++++++++- 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 476b37ad988..6830a3b9871 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -144,13 +144,34 @@ public class KotlinStdlibAlignment { }; /** - * The marker that suppresses the whole block rather than one artifact. - * A BOM aligns every module in the {@code org.jetbrains.kotlin} group, - * which is a superset of what this class does, so an app using one has - * already answered the question for both artifacts. + * The marker that can suppress the whole block rather than one artifact. + * A BOM manages every module in the {@code org.jetbrains.kotlin} group, + * jdk7 and jdk8 included, so a new enough one answers the question for + * both artifacts at once. + * + *

Only a new enough one. A BOM raises the jdk artifacts but + * cannot pull {@code kotlin-stdlib} back down, because a platform + * contributes constraints and the highest version still wins. So a + * pre-merge BOM leaves exactly the arrangement this class exists to + * prevent -- measured, with the same graph as the class comment's + * table:

+ * + *
+     * no BOM           stdlib 1.8.22 + jdk7/jdk8 1.6.21   duplicate
+     * kotlin-bom 1.7.22  stdlib 1.8.22 + jdk7/jdk8 1.7.22   STILL a duplicate
+     * kotlin-bom 1.9.22  all 1.9.22, jdk artifacts shims    safe
+     * 
+ * + *

The BOM is therefore tested by version, exactly like the Kotlin + * Gradle plugin above it, and for the same reason: presence is not + * alignment.

*/ private static final String KOTLIN_BOM = "kotlin-bom"; + /** The coordinate a BOM's version is read from. */ + private static final String KOTLIN_BOM_COORDINATE = + "org.jetbrains.kotlin:kotlin-bom:"; + private KotlinStdlibAlignment() { } @@ -182,7 +203,9 @@ public static String constraintsBlock(String configuration, if (configuration == null || configuration.trim().length() == 0) { return ""; } - if (contains(KOTLIN_BOM, appGradleFragments)) { + if (contains(KOTLIN_BOM, appGradleFragments) + && atOrPastTheMerge( + declaredVersion(KOTLIN_BOM_COORDINATE, appGradleFragments))) { return ""; } String config = configuration.trim(); @@ -221,20 +244,67 @@ public static String constraintsBlock(String configuration, * silently switch the alignment off.

*/ public static boolean alignsItsOwnJdkVariants(String kotlinPluginVersion) { - if (kotlinPluginVersion == null) { + return atOrPastTheMerge(kotlinPluginVersion); + } + + /** + * Whether a Kotlin version is at or past the release that merged the jdk + * artifacts away, and therefore aligns them wherever it is in force -- + * as the Gradle plugin's version or as a BOM's. + * + *

Unknown reads as false everywhere it is used. A version that cannot + * be parsed -- {@code kotlin-gradle-plugin:$kotlin_version} and + * {@code kotlin-bom:$kotlinVersion} both produce one -- must not silently + * switch the alignment off.

+ */ + private static boolean atOrPastTheMerge(String kotlinVersion) { + if (kotlinVersion == null) { return false; } // Shared with the Health Connect floor check rather than parsed again here: // it already drops a qualifier, which rounds a prerelease up to its release // and is the forgiving direction for a floor. String numeric = HealthManifestFragments.numericVersionPrefix( - kotlinPluginVersion.trim()); + kotlinVersion.trim()); if (numeric == null) { return false; } return compareVersions(numeric, MERGED_STDLIB_FLOOR) >= 0; } + /** + * The version an app's own Gradle text declares immediately after + * {@code coordinate}, or null when it declares none there or writes one + * this cannot read -- a Gradle variable rather than a literal. + */ + private static String declaredVersion(String coordinate, + String[] appGradleFragments) { + if (appGradleFragments == null) { + return null; + } + for (int i = 0; i < appGradleFragments.length; i++) { + String fragment = appGradleFragments[i]; + if (fragment == null) { + continue; + } + int at = fragment.indexOf(coordinate); + if (at < 0) { + continue; + } + int from = at + coordinate.length(); + int to = from; + while (to < fragment.length() + && "0123456789.".indexOf(fragment.charAt(to)) >= 0) { + to++; + } + while (to > from && fragment.charAt(to - 1) == '.') { + to--; + } + return to > from ? fragment.substring(from, to) : null; + } + return null; + } + /** Whether any fragment names this coordinate. */ private static boolean contains(String marker, String[] appGradleFragments) { if (appGradleFragments == null) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index b442a755bd0..d49f031d700 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -193,15 +193,56 @@ public void pinningOneJdkArtifactLeavesTheOtherConstrained() { } /** - * A BOM aligns the whole {@code org.jetbrains.kotlin} group, which is a - * superset of this block, so it is the one marker that suppresses both. + * A BOM manages the whole {@code org.jetbrains.kotlin} group, jdk7 and + * jdk8 included, so a new enough one is the single marker that suppresses + * both constraints. + * + *

New enough is the whole point. A BOM raises the jdk artifacts but + * cannot pull {@code kotlin-stdlib} down -- a platform contributes + * constraints and the highest version still wins -- so a pre-merge BOM + * leaves a merged stdlib beside class-bearing jdk jars, which is the + * duplicate. Measured: kotlin-bom 1.7.22 against a graph wanting stdlib + * 1.8.22 resolves jdk7/jdk8 to 1.7.22, still class-bearing.

*/ @Test public void emitsNothingWhenTheAppUsesTheKotlinBom() { check("".equals(KotlinStdlibAlignment.constraintsBlock( "implementation", null, " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n")), - "an app using the Kotlin BOM is left alone"); + "an app using a merged-era Kotlin BOM is left alone"); + check("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", null, + " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')\n")), + "the BOM at the merge itself is enough"); + } + + /** + * The counterpart, and the reason the BOM is read by version rather than + * by presence: a pre-merge BOM does not make the graph safe, so it must + * not switch the block off. + */ + @Test + public void aPreMergeKotlinBomStillGetsTheAlignment() { + String out = KotlinStdlibAlignment.constraintsBlock( + "implementation", null, + " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.7.22')\n"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), + "a pre-merge BOM still gets jdk7 aligned"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a pre-merge BOM still gets jdk8 aligned"); + } + + /** + * A BOM whose version is a Gradle variable reads as unknown, and unknown + * must not suppress -- the same fail-safe the plugin version gets. + */ + @Test + public void anUnreadableBomVersionStillGetsTheAlignment() { + String out = KotlinStdlibAlignment.constraintsBlock( + "implementation", null, + " implementation platform(\"org.jetbrains.kotlin:kotlin-bom:$kotlinVersion\")\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "an unreadable BOM version still gets the alignment"); } /** From e1f4b141c095ed2e0ff5351c48a7f2d67c27f8db Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:25:05 +0300 Subject: [PATCH 05/94] Suppress on an active declaration, not on the artifact name appearing anywhere Two review findings, one on each repo, with the same root cause: the check asked whether a string occurs in the app's Gradle text rather than whether the app actually declares anything. Both near misses switch the alignment off for an app that pinned nothing, which is the duplicate-class failure this change exists to prevent: // implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22') exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8' The first is not a declaration. The second is the opposite of one -- a Gradle exclusion applies only to the dependency edge it is written on, so an independent path still brings the class-bearing jar it names. Comments are now stripped before matching and exclusion lines are dropped, and a declaration has to be spelled as one: the colon-joined coordinate or the map form. Anything else falls through to "not declared", which is the safe direction -- emitting a constraint the app did not need only raises an artifact to a shim, while skipping one it did need fails the build. The BOM's version is read from the same active text, so a commented-out BOM cannot supply the version that suppresses the block. One trap worth naming, because the obvious implementation has it: stripping from every "//" cuts `maven { url 'https://...' }` in half, and these fragments do carry repository URLs. A "//" only opens a comment when it does not follow a colon, and there is a case pinning that. Four new cases. Two fail against the previous behaviour; the other two pin things the stricter matching could plausibly have broken -- the map form still counting as a pin, and a URL still not being a comment. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 146 +++++++++++++++--- .../builders/KotlinStdlibAlignmentTest.java | 63 ++++++++ 2 files changed, 185 insertions(+), 24 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 6830a3b9871..a05beadd0da 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -107,6 +107,9 @@ * Gradle run and so the BuildDaemon copy stays trivially diffable -- * keep this file in sync with its twin in the other repository.

*/ +import java.util.ArrayList; +import java.util.List; + public class KotlinStdlibAlignment { /** @@ -172,6 +175,9 @@ public class KotlinStdlibAlignment { private static final String KOTLIN_BOM_COORDINATE = "org.jetbrains.kotlin:kotlin-bom:"; + /** The group every artifact this class reasons about belongs to. */ + private static final String KOTLIN_GROUP = "org.jetbrains.kotlin"; + private KotlinStdlibAlignment() { } @@ -203,7 +209,7 @@ public static String constraintsBlock(String configuration, if (configuration == null || configuration.trim().length() == 0) { return ""; } - if (contains(KOTLIN_BOM, appGradleFragments) + if (declaresArtifact(KOTLIN_BOM, appGradleFragments) && atOrPastTheMerge( declaredVersion(KOTLIN_BOM_COORDINATE, appGradleFragments))) { return ""; @@ -218,7 +224,7 @@ && atOrPastTheMerge( + "empty shims to avoid a duplicate class in checkDuplicateClasses"; StringBuilder out = new StringBuilder(); for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { - if (contains(ALIGNED_ARTIFACTS[i], appGradleFragments)) { + if (declaresArtifact(ALIGNED_ARTIFACTS[i], appGradleFragments)) { continue; } out.append(" ").append(config) @@ -283,42 +289,134 @@ private static String declaredVersion(String coordinate, return null; } for (int i = 0; i < appGradleFragments.length; i++) { - String fragment = appGradleFragments[i]; - if (fragment == null) { - continue; - } - int at = fragment.indexOf(coordinate); - if (at < 0) { - continue; - } - int from = at + coordinate.length(); - int to = from; - while (to < fragment.length() - && "0123456789.".indexOf(fragment.charAt(to)) >= 0) { - to++; + // The same active text the declaration check reads, so a commented-out + // BOM cannot supply the version that suppresses the block. + String[] lines = activeLines(appGradleFragments[i]); + for (int j = 0; j < lines.length; j++) { + String fragment = lines[j]; + int at = fragment.indexOf(coordinate); + if (at < 0) { + continue; + } + int from = at + coordinate.length(); + int to = from; + while (to < fragment.length() + && "0123456789.".indexOf(fragment.charAt(to)) >= 0) { + to++; + } + while (to > from && fragment.charAt(to - 1) == '.') { + to--; + } + return to > from ? fragment.substring(from, to) : null; } - while (to > from && fragment.charAt(to - 1) == '.') { - to--; - } - return to > from ? fragment.substring(from, to) : null; } return null; } - /** Whether any fragment names this coordinate. */ - private static boolean contains(String marker, String[] appGradleFragments) { + /** + * Whether the app actively declares this {@code org.jetbrains.kotlin} + * artifact, rather than merely mentioning its name somewhere in a Gradle + * fragment. + * + *

The difference is the whole point, because both near misses produce + * the failure this class exists to prevent -- suppressing the constraint + * for an app that never pinned anything:

+ * + *
+     * // implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')
+     * exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8'
+     * 
+ * + *

The first is not a declaration at all. The second is the opposite of + * one: a Gradle exclusion applies only to the dependency edge it is + * written on, so an independent path can still bring the class-bearing jar + * it names. Neither may switch the alignment off.

+ * + *

Two spellings count as a declaration -- the colon-joined coordinate + * and the map form -- because those are what a pin is actually written as. + * Anything else falls through to "not declared", which is the safe + * direction: emitting a constraint the app did not need only raises an + * artifact to a shim, while skipping one it did need fails the build.

+ */ + private static boolean declaresArtifact(String artifact, String[] appGradleFragments) { if (appGradleFragments == null) { return false; } for (int i = 0; i < appGradleFragments.length; i++) { - String fragment = appGradleFragments[i]; - if (fragment != null && fragment.contains(marker)) { - return true; + String[] lines = activeLines(appGradleFragments[i]); + for (int j = 0; j < lines.length; j++) { + if (declaresArtifactOnLine(artifact, lines[j])) { + return true; + } } } return false; } + private static boolean declaresArtifactOnLine(String artifact, String line) { + if (line.contains(KOTLIN_GROUP + ":" + artifact)) { + return true; + } + // group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8', version: '...' + return line.contains(KOTLIN_GROUP) + && (line.contains("name: '" + artifact + "'") + || line.contains("name: \"" + artifact + "\"")); + } + + /** + * A fragment's lines with comments removed and exclusions dropped -- the + * text that actually declares something. + * + *

A line comment is only a comment when the {@code //} does not follow + * a colon: {@code maven { url 'https://...' }} is an ordinary declaration + * that a naive strip would cut in half, and these fragments really do + * carry repository URLs.

+ */ + private static String[] activeLines(String fragment) { + if (fragment == null) { + return new String[0]; + } + StringBuilder out = new StringBuilder(); + boolean inBlockComment = false; + for (int i = 0; i < fragment.length(); i++) { + char c = fragment.charAt(i); + if (inBlockComment) { + if (c == '*' && i + 1 < fragment.length() && fragment.charAt(i + 1) == '/') { + inBlockComment = false; + i++; + } else if (c == '\n') { + out.append(c); + } + continue; + } + if (c == '/' && i + 1 < fragment.length()) { + char next = fragment.charAt(i + 1); + if (next == '*') { + inBlockComment = true; + i++; + continue; + } + if (next == '/' && (i == 0 || fragment.charAt(i - 1) != ':')) { + while (i < fragment.length() && fragment.charAt(i) != '\n') { + i++; + } + out.append('\n'); + continue; + } + } + out.append(c); + } + String[] lines = out.toString().split("\n"); + List kept = new ArrayList(); + for (int i = 0; i < lines.length; i++) { + if (lines[i].contains("exclude")) { + continue; + } + kept.add(lines[i]); + } + return kept.toArray(new String[kept.size()]); + } + /** Numeric dotted version compare; a missing segment counts as zero. */ private static int compareVersions(String left, String right) { String[] l = left.split("\\."); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index d49f031d700..03b490858d9 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -245,6 +245,69 @@ public void anUnreadableBomVersionStillGetsTheAlignment() { "an unreadable BOM version still gets the alignment"); } + /** + * A commented-out declaration is not a declaration. The same hazard the + * VPN manifest checks cover, in the same builder's hint text: a developer + * parks a line with {@code //} and the substring match reads it as a live + * pin, switching off the alignment for an app that pinned nothing. + */ + @Test + public void aCommentedOutDeclarationIsNotADeclaration() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " // implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n" + + " // implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), "a commented-out BOM does not suppress"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), "a commented-out pin does not suppress"); + + String blockComment = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " /* implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22' */\n"); + check(blockComment.contains("kotlin-stdlib-jdk8:1.8.0"), + "a block-commented pin does not suppress"); + } + + /** + * An exclusion is the opposite of a pin. It applies only to the dependency + * edge it is written on, so an independent path still brings the + * class-bearing jar -- reading it as "the app manages this" removes the + * constraint precisely where it is still needed. + */ + @Test + public void anExclusionIsNotAPin() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " implementation('com.example:thing:1.0') {\n" + + " exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8'\n" + + " }\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "excluding jdk8 on one edge does not switch its constraint off"); + } + + /** + * The map form is a real pin and is honoured, so the stricter matching did + * not simply narrow to one spelling. + */ + @Test + public void theMapFormCountsAsAPin() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " implementation group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib-jdk8', version: '1.9.22'\n"); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), "the map form pins jdk8"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), "and leaves jdk7 constrained"); + } + + /** + * A repository URL is not a comment. Stripping from every {@code //} would + * cut {@code maven { url 'https://...' }} in half, and these fragments do + * carry repository URLs. + */ + @Test + public void aUrlIsNotAComment() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " maven { url 'https://example.com/repo' }\n" + + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the pin after a URL line is still seen"); + } + /** * The fragments arrive straight from build hints, so an unset hint shows * up as an empty string and an absent one can be null. Neither is a From a1d20893446104335f986f850af5deeb78518b47 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:47:38 +0300 Subject: [PATCH 06/94] Hand the alignment every app-controlled fragment, from the tree's own list android.supportv4Dep is written into the generated dependencies block a few lines below the constraints, and it was not among the fragments the alignment was told about -- so an app pinning a jdk artifact through that hint would have had the pin ignored and the constraint written over the top of it. Fixed by taking the enumeration from ShieldInjector's GRADLE_TEXT_HINTS, which is this tree's list of hints interpolated into a Gradle file, rather than from the ones that came to mind. Everything else on that list lands in buildscript, repositories or the android block, where a dependency cannot be declared, and aarDependencies is generated from .aar filenames and cannot express a version. The new check reads the builder's source, because an omission is invisible to a test that only exercises what is passed. It took two goes to make it real, and both failures are worth recording since they are the ordinary way this kind of check ends up proving nothing: - Matching the bare hint name passed with the argument deleted, because the comment above the argument list names android.supportv4Dep too. It matches the call form now. - Slicing the call to the first "));" cut the closing paren off the LAST argument, so that fragment never matched and the check failed for a reason unrelated to what it tests. It slices to the statement terminator now. Verified in both directions: passing on the real source, failing when the argument is deleted. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 9 ++++ .../builders/KotlinStdlibAlignmentTest.java | 41 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 79cd13cc744..07bd76cd904 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7304,9 +7304,18 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { kotlinStdlibConstraints = KotlinStdlibAlignment.constraintsBlock( compile, appliedKotlinPlugin, + // Every app-controlled fragment that reaches the generated + // dependencies block. Read off ShieldInjector's GRADLE_TEXT_HINTS, + // which is this tree's enumeration of hints interpolated into a + // Gradle file, rather than off the ones that came to mind -- + // android.supportv4Dep was missed exactly that way, and it is + // written into the block a few lines below. The rest of that list + // lands in buildscript, repositories or the android block, where a + // dependency cannot be declared. additionalDependencies, aiExtraGradleDependencies.toString(), request.getArg("android.gradleDep", ""), + request.getArg("android.supportv4Dep", ""), request.getArg("android.xgradle", "")); } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 03b490858d9..ee518c0b1e3 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -245,6 +245,47 @@ public void anUnreadableBomVersionStillGetsTheAlignment() { "an unreadable BOM version still gets the alignment"); } + /** + * The builder has to hand over every app-controlled fragment that reaches + * the generated dependencies block, not the ones that came to mind. + * android.supportv4Dep was missed that way: it is written into that block + * a few lines below the constraints, so an app pinning a jdk artifact + * through it would have had the pin ignored and the constraint written + * over the top. + * + *

The list is checked against the builder's source rather than + * re-derived, because the failure is an omission and an omission is + * invisible to a test that only exercises what is passed.

+ */ + @Test + public void theBuilderPassesEveryAppControlledDependencyFragment() throws Exception { + byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); + String src = new String(bytes, "UTF-8"); + int at = src.indexOf("KotlinStdlibAlignment.constraintsBlock("); + check(at >= 0, "the builder calls the alignment"); + // To the statement terminator, not to "));" -- that lands on the closing paren + // of the LAST argument and slices it in half, so the final fragment never + // matched and the check failed for the wrong reason. + String call = src.substring(at, src.indexOf(";", at)); + // The call form, not the bare hint name: the comment above the argument list + // names android.supportv4Dep too, so matching the name alone passed with the + // argument deleted. Checked by deleting it, which is the only way that kind of + // vacuity shows up. + String[] fragments = { + "additionalDependencies,", + "aiExtraGradleDependencies.toString(),", + "request.getArg(\"android.gradleDep\", \"\")", + "request.getArg(\"android.supportv4Dep\", \"\")", + "request.getArg(\"android.xgradle\", \"\")", + }; + for (String fragment : fragments) { + check(call.contains(fragment), + "the alignment is not told about " + fragment + + ", which reaches the generated dependencies block"); + } + } + /** * A commented-out declaration is not a declaration. The same hazard the * VPN manifest checks cover, in the same builder's hint text: a developer From 5a67ee4257d5b190b7de7b9646161954938d7f48 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:04:25 +0300 Subject: [PATCH 07/94] Read declarations on the configuration being constrained, and past commented plugins Two more findings of the same shape as the last round -- text that mentions a coordinate is not a declaration that governs the build -- plus one gate left where it is, on purpose. A declaration on a variant or test configuration does not reach the one the constraints are written on. debugImplementation of a new-enough BOM constrains the debug variant alone, so suppressing on it removed the constraint from the release build that still needed it, and the release build is the one that ships. The variant forms camel-case the configuration they derive from, so requiring the configuration's own lowercase spelling excludes debugImplementation, releaseImplementation and testImplementation without listing them and cannot be defeated by a variant name nobody thought of. api is accepted alongside it, since an api declaration is a real pin on the main variant. The Kotlin plugin version had the same hole through a different parser. HealthManifestFragments takes the first bare substring match, so a commented-out 1.8+ plugin sitting above an active 1.7.x one was read as the applied version and the alignment was skipped for a build whose real plugin does not align. The builder strips comments before parsing now, through the same helper this class already used on its own fragments. Not taken: widening the gate from Gradle 6 to 4.6. The reasoning is now a comment on the gate rather than only in a review thread, because the next reader will ask the same question. 4.6 selects AGP 3.2.0, which cannot compile against a compileSdk the current AndroidX releases require, and that path is given appcompat 1.0.0, whose graph carries no Kotlin at all -- a merged stdlib cannot appear there. Widening would put an untested constraints block into AGP 3.x builds that work today to fix a clash they cannot have, and the two directions are not symmetrical: too narrow leaves an ancient build with a failure it already had, too wide breaks a build that currently succeeds. Three new cases, and the two behaviours were checked by reverting each in turn. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 21 ++++- .../builders/KotlinStdlibAlignment.java | 86 +++++++++++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 63 ++++++++++++++ 3 files changed, 157 insertions(+), 13 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 07bd76cd904..415d6b5ab67 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7279,9 +7279,17 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // never hit the clash resolves exactly as it did before. // // Gated on AndroidX because the block is written on the implementation - // configuration and Gradle's constraints DSL only arrived in 4.6; the - // legacy support-library templates predate both, and predate the AndroidX - // releases that produce the clash. + // configuration, and on Gradle 6 rather than on 4.6 where the constraints + // DSL first appeared. That is deliberate, and it has been questioned in + // review, so: 4.6 selects AGP 3.2.0, which cannot compile against a + // compileSdk the current AndroidX releases require, and the builder gives + // that path appcompat 1.0.0, whose graph contains no Kotlin at all. A graph + // that reaches a merged kotlin-stdlib cannot occur there. Widening the gate + // would put an untested constraints block into AGP 3.x builds that work + // today, to fix a clash they cannot have -- and the two failure directions + // are not symmetrical: too narrow leaves an ancient build with a failure it + // already had, too wide breaks a build that currently succeeds. Raise this + // gate only with a reproduction on that path. // The Kotlin Gradle plugin version this build actually applies, empty when it // applies none. Which version matters: only 1.8 and newer align the jdk stdlib // variants themselves. An app that declares its own kotlin-gradle-plugin wins, @@ -7291,7 +7299,12 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { String appliedKotlinPlugin = ""; if (hasKotlinSources) { appliedKotlinPlugin = kotlinVersion; - String kotlinTopDependency = request.getArg("android.topDependency", ""); + // Comments stripped first: HealthManifestFragments reads the FIRST bare + // substring match, so a commented-out 1.8+ plugin sitting above an active + // 1.7.x one is read as the applied version, and the alignment is then + // skipped for a build whose real plugin does not align. + String kotlinTopDependency = KotlinStdlibAlignment.activeText( + request.getArg("android.topDependency", "")); if (HealthManifestFragments.declaresKotlinPlugin(kotlinTopDependency)) { String declared = HealthManifestFragments .declaredKotlinPluginVersion(kotlinTopDependency); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index a05beadd0da..8317c5299d5 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -209,12 +209,12 @@ public static String constraintsBlock(String configuration, if (configuration == null || configuration.trim().length() == 0) { return ""; } - if (declaresArtifact(KOTLIN_BOM, appGradleFragments) - && atOrPastTheMerge( - declaredVersion(KOTLIN_BOM_COORDINATE, appGradleFragments))) { + String config = configuration.trim(); + if (declaresArtifact(KOTLIN_BOM, config, appGradleFragments) + && atOrPastTheMerge(declaredVersion( + KOTLIN_BOM_COORDINATE, config, appGradleFragments))) { return ""; } - String config = configuration.trim(); // "because" is not decoration: it is what `gradle dependencyInsight` prints // next to the raised version, and this constraint is otherwise unattributable // to anything in the developer's project. @@ -224,7 +224,7 @@ && atOrPastTheMerge( + "empty shims to avoid a duplicate class in checkDuplicateClasses"; StringBuilder out = new StringBuilder(); for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { - if (declaresArtifact(ALIGNED_ARTIFACTS[i], appGradleFragments)) { + if (declaresArtifact(ALIGNED_ARTIFACTS[i], config, appGradleFragments)) { continue; } out.append(" ").append(config) @@ -283,7 +283,7 @@ private static boolean atOrPastTheMerge(String kotlinVersion) { * {@code coordinate}, or null when it declares none there or writes one * this cannot read -- a Gradle variable rather than a literal. */ - private static String declaredVersion(String coordinate, + private static String declaredVersion(String coordinate, String configuration, String[] appGradleFragments) { if (appGradleFragments == null) { return null; @@ -294,6 +294,12 @@ private static String declaredVersion(String coordinate, String[] lines = activeLines(appGradleFragments[i]); for (int j = 0; j < lines.length; j++) { String fragment = lines[j]; + // Same configuration filter as the declaration check, so a debug-only + // BOM cannot supply the version that suppresses the main variant's + // constraints. + if (!declaresOnTheConstrainedConfiguration(configuration, fragment)) { + continue; + } int at = fragment.indexOf(coordinate); if (at < 0) { continue; @@ -338,14 +344,15 @@ private static String declaredVersion(String coordinate, * direction: emitting a constraint the app did not need only raises an * artifact to a shim, while skipping one it did need fails the build.

*/ - private static boolean declaresArtifact(String artifact, String[] appGradleFragments) { + private static boolean declaresArtifact(String artifact, String configuration, + String[] appGradleFragments) { if (appGradleFragments == null) { return false; } for (int i = 0; i < appGradleFragments.length; i++) { String[] lines = activeLines(appGradleFragments[i]); for (int j = 0; j < lines.length; j++) { - if (declaresArtifactOnLine(artifact, lines[j])) { + if (declaresArtifactOnLine(artifact, configuration, lines[j])) { return true; } } @@ -353,7 +360,11 @@ private static boolean declaresArtifact(String artifact, String[] appGradleFragm return false; } - private static boolean declaresArtifactOnLine(String artifact, String line) { + private static boolean declaresArtifactOnLine(String artifact, String configuration, + String line) { + if (!declaresOnTheConstrainedConfiguration(configuration, line)) { + return false; + } if (line.contains(KOTLIN_GROUP + ":" + artifact)) { return true; } @@ -363,6 +374,63 @@ private static boolean declaresArtifactOnLine(String artifact, String line) { || line.contains("name: \"" + artifact + "\"")); } + /** + * Whether a declaration on this line reaches the same configuration the + * constraints are written on. + * + *

A declaration on a variant or test configuration does not. + * {@code debugImplementation platform('...kotlin-bom:1.9.22')} constrains + * the debug variant alone, so treating it as the app managing the stdlib + * removes the constraint from the release build that still needs it -- + * and the release build is the one that ships.

+ * + *

The variant forms camel-case the configuration they derive from, so + * requiring the configuration's own lowercase spelling excludes + * {@code debugImplementation}, {@code releaseImplementation} and + * {@code testImplementation} without listing them, and cannot be defeated + * by a variant name nobody thought of. {@code api} is accepted alongside + * it because an api declaration is a real pin on the main variant; its + * variant forms are camel-cased in the same way.

+ */ + private static boolean declaresOnTheConstrainedConfiguration(String configuration, + String line) { + if (line.contains(configuration)) { + return true; + } + int at = line.indexOf("api"); + while (at >= 0) { + boolean startsToken = at == 0 || !Character.isLetterOrDigit(line.charAt(at - 1)); + int after = at + "api".length(); + boolean endsToken = after < line.length() + && (line.charAt(after) == ' ' || line.charAt(after) == '('); + if (startsToken && endsToken) { + return true; + } + at = line.indexOf("api", at + 1); + } + return false; + } + + /** + * A Gradle fragment with its comments removed, for a caller that has to + * read a version out of it. + * + *

Exposed because the builder parses {@code android.topDependency} for + * the Kotlin plugin version with a helper that takes the first bare + * substring match, so a commented-out declaration above an active one wins + * and decides the alignment. Same hazard as the one this class already + * guards against on its own fragments, reached through a different + * parser.

+ */ + public static String activeText(String fragment) { + String[] lines = activeLines(fragment); + StringBuilder out = new StringBuilder(); + for (int i = 0; i < lines.length; i++) { + out.append(lines[i]).append('\n'); + } + return out.toString(); + } + /** * A fragment's lines with comments removed and exclusions dropped -- the * text that actually declares something. diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index ee518c0b1e3..150decde7cb 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -306,6 +306,69 @@ public void aCommentedOutDeclarationIsNotADeclaration() { "a block-commented pin does not suppress"); } + /** + * activeText strips comments, and the builder has to use it on + * android.topDependency before the plugin version is parsed out of it. + * HealthManifestFragments takes the FIRST bare substring match, so a + * commented-out 1.8+ plugin above an active 1.7.x one is read as the + * applied version and the alignment is skipped for a build that needs it. + */ + @Test + public void aCommentedOutPluginIsNotTheAppliedPlugin() throws Exception { + String topDependency = + "// classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22'\n" + + "classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.22'\n"; + check(KotlinStdlibAlignment.activeText(topDependency) + .indexOf("1.9.22") < 0, + "the commented plugin is gone from the active text"); + check(KotlinStdlibAlignment.activeText(topDependency) + .indexOf("1.7.22") >= 0, + "the active plugin survives"); + + byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); + String builderSrc = new String(bytes, "UTF-8"); + check(builderSrc.contains("KotlinStdlibAlignment.activeText("), + "the builder strips comments before reading the plugin version"); + } + + /** + * A declaration on a variant or test configuration does not reach the one + * the constraints are written on, so it cannot stand in for a pin. + * debugImplementation of a new-enough BOM constrains the debug variant + * alone -- suppressing on it removes the constraint from the release build + * that still needs it, and the release build is the one that ships. + */ + @Test + public void aVariantOnlyDeclarationDoesNotSuppress() { + String debugBom = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " debugImplementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n"); + check(debugBom.contains("kotlin-stdlib-jdk8:1.8.0"), + "a debug-only BOM does not suppress the main variant"); + + String testPin = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " testImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); + check(testPin.contains("kotlin-stdlib-jdk8:1.8.0"), + "a test-only pin does not suppress the main variant"); + + String releasePin = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " releaseImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); + check(releasePin.contains("kotlin-stdlib-jdk8:1.8.0"), + "even a release-only pin is not the configuration being constrained"); + } + + /** + * api is a real pin on the main variant and is honoured, so the + * configuration filter did not narrow to a single keyword. + */ + @Test + public void anApiDeclarationCountsAsAPin() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " api 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), "api pins jdk8"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), "and leaves jdk7 constrained"); + } + /** * An exclusion is the opposite of a pin. It applies only to the dependency * edge it is written on, so an independent path still brings the From ae92d41196c55ef36d95e4cbe220371b1545fa2c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:13:15 +0300 Subject: [PATCH 08/94] Group physical lines into statements before deciding what the app declared Two findings, both the same crude normalization rather than two bugs, and both ending the same way: an app's explicit pin ignored and the constraint written over the top of it, which is the opposite of what naming the artifact in a build hint is documented to do. implementation( configuration and coordinate 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22' on different lines ) implementation('...kotlin-stdlib-jdk8:1.7.22') { exclude group: 'x' } a real pin, dropped whole for containing "exclude" So a line whose parentheses are still open is joined to the next, and a statement is truncated at "exclude" rather than discarded: what precedes an exclusion is the declaration, what follows it is the part that must not count. A bare exclude line truncates to nothing, so it is still not a pin -- that case kept its test and still passes. Parenthesis counting ignores anything inside a string literal, so a coordinate carrying one cannot unbalance it. Text left with parentheses open at the end of a fragment is unbalanced Gradle, and its lines are kept separate rather than glued into one: gluing would let a configuration from one statement and a coordinate from another read as a single declaration, and suppression is the direction that must never be reached by accident. That has its own case. Four new cases. The two the review named fail against the previous behaviour; the other two pin the ways this fix could have gone wrong. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 93 ++++++++++++++++++- .../builders/KotlinStdlibAlignmentTest.java | 62 +++++++++++++ 2 files changed, 150 insertions(+), 5 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 8317c5299d5..9f75add02bc 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -439,6 +439,9 @@ public static String activeText(String fragment) { * a colon: {@code maven { url 'https://...' }} is an ordinary declaration * that a naive strip would cut in half, and these fragments really do * carry repository URLs.

+ * + *

Regrouping into statements is {@link #statements}; this method only + * removes the comments.

*/ private static String[] activeLines(String fragment) { if (fragment == null) { @@ -474,17 +477,97 @@ private static String[] activeLines(String fragment) { } out.append(c); } - String[] lines = out.toString().split("\n"); - List kept = new ArrayList(); + return statements(out.toString().split("\n")); + } + + /** + * Physical lines regrouped into the statements a declaration check can + * actually read. + * + *

Two things a per-physical-line check gets wrong, both of which end + * with an app's explicit pin ignored and the constraint written over the + * top of it -- the opposite of what naming the artifact in a build hint + * is documented to do:

+ * + *
+     * implementation(                                  configuration and coordinate
+     *     'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'    land on different lines
+     * )
+     *
+     * implementation('...kotlin-stdlib-jdk8:1.7.22') { exclude group: 'x' }
+     *                                                  a real pin, dropped whole
+     *                                                  for containing "exclude"
+     * 
+ * + *

So a line whose parentheses are still open is joined to the next, and + * a statement is truncated at {@code exclude} rather than discarded -- what + * precedes the exclusion is the declaration, and what follows it is the + * part that must not count. A bare {@code exclude} line truncates to + * nothing and so is still not a pin.

+ * + *

Joining stops at the end of the fragment: text left with parentheses + * open is unbalanced Gradle, and rather than glue the remainder into one + * long line -- which would make unrelated statements look like a single + * declaration, and suppression is the direction that must never be reached + * by accident -- its lines are kept as they were.

+ */ + private static String[] statements(String[] lines) { + List joined = new ArrayList(); + StringBuilder pending = new StringBuilder(); + int depth = 0; for (int i = 0; i < lines.length; i++) { - if (lines[i].contains("exclude")) { - continue; + if (pending.length() > 0) { + pending.append(' '); + } + pending.append(lines[i]); + depth += parenBalance(lines[i]); + if (depth <= 0) { + joined.add(pending.toString()); + pending.setLength(0); + depth = 0; + } + } + if (pending.length() > 0) { + // Unbalanced: keep the tail as separate lines rather than as one. + for (int i = joined.size(); i < lines.length; i++) { + joined.add(lines[i]); } - kept.add(lines[i]); + } + List kept = new ArrayList(); + for (int i = 0; i < joined.size(); i++) { + String statement = joined.get(i); + int at = statement.indexOf("exclude"); + kept.add(at < 0 ? statement : statement.substring(0, at)); } return kept.toArray(new String[kept.size()]); } + /** + * How far a line opens or closes parentheses, ignoring those inside string + * literals so a coordinate carrying one cannot unbalance the count. + */ + private static int parenBalance(String line) { + int depth = 0; + char quote = 0; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (quote != 0) { + if (c == quote) { + quote = 0; + } + continue; + } + if (c == '\'' || c == '"') { + quote = c; + } else if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } + } + return depth; + } + /** Numeric dotted version compare; a missing segment counts as zero. */ private static int compareVersions(String left, String right) { String[] l = left.split("\\."); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 150decde7cb..5f1321bd563 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -385,6 +385,68 @@ public void anExclusionIsNotAPin() { "excluding jdk8 on one edge does not switch its constraint off"); } + /** + * A declaration wrapped across lines is still a declaration. The + * configuration and the coordinate land on different physical lines, and + * reading them separately ignored an explicit pin and wrote the constraint + * over it -- the opposite of what naming the artifact in a build hint is + * documented to do. + */ + @Test + public void aDeclarationSplitAcrossLinesIsStillAPin() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " implementation(\n" + + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" + + " )\n"); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a wrapped declaration pins jdk8"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and leaves jdk7 constrained"); + } + + /** + * An inline exclusion on a declaring line does not cancel the declaration. + * Dropping the whole line for containing "exclude" threw away a real pin; + * only what follows the exclusion has to be ignored. + */ + @Test + public void anInlineExclusionDoesNotCancelTheDeclaration() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ exclude group: 'com.example', module: 'thing' }\n"); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the declaration survives its own inline exclusion"); + } + + /** + * And the standalone exclusion still is not a pin -- truncating at + * "exclude" leaves nothing in front of it. + */ + @Test + public void aStandaloneExclusionIsStillNotAPin() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " implementation('com.example:thing:1.0') {\n" + + " exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8'\n" + + " }\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "an exclusion on its own line is still not a pin"); + } + + /** + * Unbalanced parentheses must not glue the fragment into one line: that + * would let a configuration from one statement and a coordinate from + * another read as a single declaration, and suppression is the direction + * that must never be reached by accident. + */ + @Test + public void unbalancedParenthesesDoNotGlueStatementsTogether() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " implementation(\n" + + " testImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a dangling paren does not turn a test-only pin into a main-variant one"); + } + /** * The map form is a real pin and is honoured, so the stricter matching did * not simply narrow to one spelling. From c20b8e9bce045898cc5e458c4d1f02a7a136033b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:24:42 +0300 Subject: [PATCH 09/94] Accept every main-variant configuration, and end a statement at a semicolon Two findings, both about the same predicate deciding whether the app already manages an artifact, and both reaching it from a direction the last round did not cover. runtimeOnly is a main-variant configuration whose artifacts land on the same release runtime classpath the implementation constraint reaches, so a pin there is the app managing the artifact -- and it was read as unmanaged. That one is worse than the overrides fixed so far: a runtimeOnly pin carrying `strictly` does not get overridden by the emitted 1.8.0 constraint, it makes the resolution fail outright. The fix is the whole list rather than that one name -- implementation, api, compileOnly, runtimeOnly and the legacy compile and runtime -- matched as whole tokens, which keeps the property that earns this its simplicity: the variant and test forms camel-case the configuration they derive from, so testRuntimeOnly, debugCompileOnly and releaseApi are still excluded without any of them being listed. A statement now ends at a semicolon as well as a newline. That is not an edge case: this builder tells developers to separate android.gradleDep statements "with ';' or a newline", so two declarations on one line is the documented shape. Splitting on newlines alone let the first statement's configuration token pair with the second statement's coordinate, so implementation 'com.android.billingclient:billing:9.1.0'; debugImplementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22') read as a main-variant BOM and suppressed the alignment for the release graph that the billing dependency had just made vulnerable. Semicolons inside a string or inside parentheses do not separate anything, and there is a case for each. Four new cases. Both behaviours were checked by reverting each in turn, and two of the four pin the ways these fixes could have gone too far -- a real pin after a semicolon still suppressing, and a wrapped declaration still pinning. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 148 ++++++++++++------ .../builders/KotlinStdlibAlignmentTest.java | 84 ++++++++++ 2 files changed, 180 insertions(+), 52 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 9f75add02bc..4591247d6ae 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -385,28 +385,67 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat * and the release build is the one that ships.

* *

The variant forms camel-case the configuration they derive from, so - * requiring the configuration's own lowercase spelling excludes - * {@code debugImplementation}, {@code releaseImplementation} and + * requiring the configuration's own lowercase spelling as a whole token + * excludes {@code debugImplementation}, {@code releaseImplementation} and * {@code testImplementation} without listing them, and cannot be defeated - * by a variant name nobody thought of. {@code api} is accepted alongside - * it because an api declaration is a real pin on the main variant; its - * variant forms are camel-cased in the same way.

+ * by a variant name nobody thought of. The other main-variant + * configurations are accepted alongside the one being written on; see + * {@link #MAIN_CONFIGURATIONS}.

*/ private static boolean declaresOnTheConstrainedConfiguration(String configuration, String line) { - if (line.contains(configuration)) { + if (declaresOn(configuration, line)) { return true; } - int at = line.indexOf("api"); + for (int i = 0; i < MAIN_CONFIGURATIONS.length; i++) { + if (declaresOn(MAIN_CONFIGURATIONS[i], line)) { + return true; + } + } + return false; + } + + /** + * The dependency configurations of the main variant, which is the one the + * constraints are written on. + * + *

Every one of these reaches a classpath the {@code implementation} + * constraint also reaches, so a pin declared on any of them is the app + * managing the artifact. Getting the list short was a bug rather than a + * simplification: a {@code runtimeOnly} pin was read as unmanaged, and if + * it carried {@code strictly} the emitted 1.8.0 constraint did not override + * it but made the resolution fail outright -- worse than the override this + * class already tries to avoid.

+ * + *

Their variant and test forms camel-case the configuration they derive + * from -- {@code testRuntimeOnly}, {@code debugCompileOnly}, + * {@code releaseApi} -- so matching the lowercase spelling as a whole token + * accepts the main ones and excludes the rest without listing any of them, + * whatever a variant happens to be called.

+ */ + private static final String[] MAIN_CONFIGURATIONS = { + "implementation", + "api", + "compileOnly", + "runtimeOnly", + "compile", + "runtime" + }; + + /** Whether this line declares on {@code configuration}, as a whole token. */ + private static boolean declaresOn(String configuration, String line) { + int at = line.indexOf(configuration); while (at >= 0) { - boolean startsToken = at == 0 || !Character.isLetterOrDigit(line.charAt(at - 1)); - int after = at + "api".length(); + boolean startsToken = at == 0 + || !Character.isLetterOrDigit(line.charAt(at - 1)); + int after = at + configuration.length(); boolean endsToken = after < line.length() - && (line.charAt(after) == ' ' || line.charAt(after) == '('); + && (line.charAt(after) == ' ' || line.charAt(after) == '(' + || line.charAt(after) == '\t'); if (startsToken && endsToken) { return true; } - at = line.indexOf("api", at + 1); + at = line.indexOf(configuration, at + 1); } return false; } @@ -477,7 +516,7 @@ private static String[] activeLines(String fragment) { } out.append(c); } - return statements(out.toString().split("\n")); + return statements(out.toString()); } /** @@ -505,53 +544,31 @@ private static String[] activeLines(String fragment) { * part that must not count. A bare {@code exclude} line truncates to * nothing and so is still not a pin.

* + *

A statement ends at a newline or at a semicolon, whichever comes + * first, and neither ends one inside parentheses or inside a string. The + * semicolon is not a nicety: this builder tells developers to separate + * {@code android.gradleDep} statements "with ';' or a newline", so a hint + * holding two declarations on one line is the documented shape. Splitting + * on newlines alone let the configuration token of the first statement pair + * with the coordinate of the second, which reads + * {@code implementation 'x'; debugImplementation platform('...kotlin-bom')} + * as a main-variant BOM and suppresses everything.

+ * *

Joining stops at the end of the fragment: text left with parentheses * open is unbalanced Gradle, and rather than glue the remainder into one * long line -- which would make unrelated statements look like a single * declaration, and suppression is the direction that must never be reached * by accident -- its lines are kept as they were.

*/ - private static String[] statements(String[] lines) { - List joined = new ArrayList(); - StringBuilder pending = new StringBuilder(); - int depth = 0; - for (int i = 0; i < lines.length; i++) { - if (pending.length() > 0) { - pending.append(' '); - } - pending.append(lines[i]); - depth += parenBalance(lines[i]); - if (depth <= 0) { - joined.add(pending.toString()); - pending.setLength(0); - depth = 0; - } - } - if (pending.length() > 0) { - // Unbalanced: keep the tail as separate lines rather than as one. - for (int i = joined.size(); i < lines.length; i++) { - joined.add(lines[i]); - } - } - List kept = new ArrayList(); - for (int i = 0; i < joined.size(); i++) { - String statement = joined.get(i); - int at = statement.indexOf("exclude"); - kept.add(at < 0 ? statement : statement.substring(0, at)); - } - return kept.toArray(new String[kept.size()]); - } - - /** - * How far a line opens or closes parentheses, ignoring those inside string - * literals so a coordinate carrying one cannot unbalance the count. - */ - private static int parenBalance(String line) { + private static String[] statements(String text) { + List out = new ArrayList(); + StringBuilder current = new StringBuilder(); int depth = 0; char quote = 0; - for (int i = 0; i < line.length(); i++) { - char c = line.charAt(i); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); if (quote != 0) { + current.append(c); if (c == quote) { quote = 0; } @@ -562,10 +579,37 @@ private static int parenBalance(String line) { } else if (c == '(') { depth++; } else if (c == ')') { - depth--; + if (depth > 0) { + depth--; + } + } else if ((c == '\n' || c == ';') && depth == 0) { + out.add(current.toString().replace('\n', ' ')); + current.setLength(0); + continue; + } + current.append(c); + } + if (current.length() > 0) { + if (depth > 0) { + // Unbalanced: keep the tail's physical lines apart rather than as one + // statement. Gluing them would let a configuration from one and a + // coordinate from another read as a single declaration, and + // suppression is the direction that must never be reached by accident. + String[] dangling = current.toString().split("\n"); + for (int i = 0; i < dangling.length; i++) { + out.add(dangling[i]); + } + } else { + out.add(current.toString().replace('\n', ' ')); } } - return depth; + List kept = new ArrayList(); + for (int i = 0; i < out.size(); i++) { + String statement = out.get(i); + int at = statement.indexOf("exclude"); + kept.add(at < 0 ? statement : statement.substring(0, at)); + } + return kept.toArray(new String[kept.size()]); } /** Numeric dotted version compare; a missing segment counts as zero. */ diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 5f1321bd563..d9f1d87a3aa 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -357,6 +357,45 @@ public void aVariantOnlyDeclarationDoesNotSuppress() { "even a release-only pin is not the configuration being constrained"); } + /** + * Every main-variant configuration reaches a classpath the constraint also + * reaches, so a pin on any of them is the app managing the artifact. + * runtimeOnly is the one that made this a list rather than two names: a + * strict pin there did not get overridden by the emitted 1.8.0 constraint, + * it made the resolution fail outright. + */ + @Test + public void aPinOnAnyMainConfigurationSuppresses() { + String[] configurations = {"implementation", "api", "compileOnly", "runtimeOnly", + "compile", "runtime"}; + for (String configuration : configurations) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " " + configuration + + "('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22')\n"); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a pin on " + configuration + " is the app managing jdk8"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and jdk7 is still constrained after a " + configuration + " pin"); + } + } + + /** + * And their variant and test forms still do not, which is the property the + * whole-token lowercase match buys without listing a single variant name. + */ + @Test + public void theVariantFormsOfThoseConfigurationsStillDoNot() { + String[] variants = {"testRuntimeOnly", "debugRuntimeOnly", "androidTestImplementation", + "releaseCompileOnly", "debugApi", "testCompile"}; + for (String variant : variants) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " " + variant + + "('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22')\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + variant + " does not reach the constrained configuration"); + } + } + /** * api is a real pin on the main variant and is honoured, so the * configuration filter did not narrow to a single keyword. @@ -432,6 +471,51 @@ public void aStandaloneExclusionIsStillNotAPin() { "an exclusion on its own line is still not a pin"); } + /** + * A semicolon ends a statement, because this builder tells developers to + * separate android.gradleDep statements "with ';' or a newline" -- two + * declarations on one line is the documented shape, not an edge case. + * Splitting on newlines alone let the first statement's configuration + * token pair with the second statement's coordinate, so a debug-only BOM + * read as a main-variant one and suppressed everything. + */ + @Test + public void aSemicolonEndsAStatement() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " implementation 'com.android.billingclient:billing:9.1.0'; " + + "debugImplementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a debug BOM after a semicolon does not borrow the previous " + + "statement's configuration"); + + // The same shape where the pin IS on the main variant still suppresses, so + // the split did not simply stop every semicolon-separated value from working. + String pinned = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " implementation 'com.android.billingclient:billing:9.1.0'; " + + "implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); + check(!pinned.contains("kotlin-stdlib-jdk8:1.8.0"), + "a real pin after a semicolon is still a pin"); + } + + /** + * A semicolon inside a string or inside parentheses is not a separator. + */ + @Test + public void aSemicolonInsideAStringOrParensIsNotASeparator() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " implementation(\n" + + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" + + " )\n"); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a wrapped declaration still pins"); + + String quoted = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22' " + + "// note; with a semicolon\n"); + check(!quoted.contains("kotlin-stdlib-jdk8:1.8.0"), + "a semicolon in a trailing comment does not split the declaration off"); + } + /** * Unbalanced parentheses must not glue the fragment into one line: that * would let a configuration from one statement and a coordinate from From 0256d52443b37cf58cb4e6a1511eb5431f70d4fa Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:38:17 +0300 Subject: [PATCH 10/94] Honour a strict pin anywhere, and stop treating compileOnly as runtime management Two findings that look like they contradict the last round and do not. One of them corrects a mistake made here rather than one the review introduced. compileOnly does not belong in the main-configuration list. It went in by symmetry with runtimeOnly, and symmetry was the wrong test: only the release RUNTIME classpath is read by checkReleaseDuplicateClasses, and a compileOnly declaration is absent from it. So a compileOnly BOM was being treated as the app managing a graph it does not appear in, which dropped the constraint from a classpath the app never touched and left the duplicate in place. Removing it would have re-opened the collision that put runtimeOnly on the list, so the collision is now handled where it actually belongs -- on the version, not on the configuration. A strict version cannot coexist with a constraint on any classpath both reach, whichever configuration carries it. Measured rather than argued: the same graph resolves on its own and fails with this block's constraint added, Could not resolve org.jetbrains.kotlin:kotlin-stdlib-jdk8:{strictly 1.7.22} which is worse than the duplicate class because the app cannot work around it. A strict declaration therefore ends the question wherever it appears, including on releaseImplementation and compileOnly. A strict version at or above the floor loses nothing by this: it is already a shim. Seeing that needed one more grouping step, because the deciding text is written as `version { strictly '1.7.22' }` on the line after the coordinate. Only a statement already naming the Kotlin group absorbs its brace block, so a `dependencies {` or `android {` opening cannot swallow the fragment -- the blast radius is one declaration, and there is a case pinning that. Three new cases, and both behaviours were checked by reverting each in turn. The debug-BOM case from the earlier round still passes unchanged, which is the point: a variant BOM still does not suppress, while a variant STRICT pin now does. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 73 ++++++++++++++++--- .../builders/KotlinStdlibAlignmentTest.java | 58 ++++++++++++++- 2 files changed, 120 insertions(+), 11 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 4591247d6ae..94c8e06ae40 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -362,7 +362,17 @@ private static boolean declaresArtifact(String artifact, String configuration, private static boolean declaresArtifactOnLine(String artifact, String configuration, String line) { - if (!declaresOnTheConstrainedConfiguration(configuration, line)) { + // A strict version is honoured wherever it is declared, because a constraint + // cannot coexist with one on any classpath both reach: measured, an app + // strictly pinning jdk8 to 1.7.22 resolves fine on its own and fails outright + // with this block's constraint added -- + // Could not resolve org.jetbrains.kotlin:kotlin-stdlib-jdk8:{strictly 1.7.22} + // That is worse than the duplicate class, because the app cannot work around + // it, so a strict pin ends the question regardless of which configuration + // carries it. A strict version at or above the floor loses nothing by this: + // it is already a shim. + if (!line.contains("strictly") + && !declaresOnTheConstrainedConfiguration(configuration, line)) { return false; } if (line.contains(KOTLIN_GROUP + ":" + artifact)) { @@ -409,13 +419,18 @@ private static boolean declaresOnTheConstrainedConfiguration(String configuratio * The dependency configurations of the main variant, which is the one the * constraints are written on. * - *

Every one of these reaches a classpath the {@code implementation} - * constraint also reaches, so a pin declared on any of them is the app - * managing the artifact. Getting the list short was a bug rather than a - * simplification: a {@code runtimeOnly} pin was read as unmanaged, and if - * it carried {@code strictly} the emitted 1.8.0 constraint did not override - * it but made the resolution fail outright -- worse than the override this - * class already tries to avoid.

+ *

Every one of these reaches the release RUNTIME classpath, which is the + * one {@code checkReleaseDuplicateClasses} reads and therefore the only one + * whose contents this class is trying to fix. {@code runtimeOnly} belongs + * here for exactly that reason.

+ * + *

{@code compileOnly} does not, and putting it here was a mistake made + * by symmetry: a {@code compileOnly platform('...kotlin-bom')} is absent + * from the runtime graph, so treating it as the app managing that graph + * dropped the constraint from a classpath the app had not touched and left + * the duplicate in place. A compile-only declaration that would collide + * with the constraint is caught by the strict-version rule below instead, + * which is where that concern actually belongs.

* *

Their variant and test forms camel-case the configuration they derive * from -- {@code testRuntimeOnly}, {@code debugCompileOnly}, @@ -426,7 +441,6 @@ private static boolean declaresOnTheConstrainedConfiguration(String configuratio private static final String[] MAIN_CONFIGURATIONS = { "implementation", "api", - "compileOnly", "runtimeOnly", "compile", "runtime" @@ -603,15 +617,54 @@ private static String[] statements(String text) { out.add(current.toString().replace('\n', ' ')); } } - List kept = new ArrayList(); + // A declaration's own configuration block belongs to it: the version that + // decides this is written as `version { strictly '1.7.22' }` on the line after + // the coordinate. Only a statement that already names the Kotlin group absorbs + // its block, so a `dependencies {` or `android {` opening cannot swallow the + // fragment -- the blast radius is one declaration, never the file. + List merged = new ArrayList(); for (int i = 0; i < out.size(); i++) { String statement = out.get(i); + if (statement.contains(KOTLIN_GROUP)) { + int braces = braceBalance(statement); + while (braces > 0 && i + 1 < out.size()) { + i++; + statement = statement + " " + out.get(i); + braces += braceBalance(out.get(i)); + } + } + merged.add(statement); + } + List kept = new ArrayList(); + for (int i = 0; i < merged.size(); i++) { + String statement = merged.get(i); int at = statement.indexOf("exclude"); kept.add(at < 0 ? statement : statement.substring(0, at)); } return kept.toArray(new String[kept.size()]); } + /** How far a statement opens or closes braces, ignoring those in strings. */ + private static int braceBalance(String statement) { + int depth = 0; + char quote = 0; + for (int i = 0; i < statement.length(); i++) { + char c = statement.charAt(i); + if (quote != 0) { + if (c == quote) { + quote = 0; + } + } else if (c == '\'' || c == '"') { + quote = c; + } else if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + } + } + return depth; + } + /** Numeric dotted version compare; a missing segment counts as zero. */ private static int compareVersions(String left, String right) { String[] l = left.split("\\."); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index d9f1d87a3aa..22f91791cd3 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -366,7 +366,7 @@ public void aVariantOnlyDeclarationDoesNotSuppress() { */ @Test public void aPinOnAnyMainConfigurationSuppresses() { - String[] configurations = {"implementation", "api", "compileOnly", "runtimeOnly", + String[] configurations = {"implementation", "api", "runtimeOnly", "compile", "runtime"}; for (String configuration : configurations) { String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, @@ -379,6 +379,62 @@ public void aPinOnAnyMainConfigurationSuppresses() { } } + /** + * compileOnly is NOT one of them, and adding it by symmetry was the + * mistake. A compileOnly declaration is absent from the release runtime + * classpath, which is the one checkReleaseDuplicateClasses reads, so + * treating it as management of that graph drops the constraint from a + * classpath the app never touched and leaves the duplicate in place. + */ + @Test + public void aCompileOnlyDeclarationDoesNotManageTheRuntimeGraph() { + String bom = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " compileOnly platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n"); + check(bom.contains("kotlin-stdlib-jdk8:1.8.0"), + "a compileOnly BOM does not align the runtime graph"); + } + + /** + * A strict version ends the question wherever it is declared, because a + * constraint cannot coexist with one on any classpath both reach. + * Measured with Gradle: the same graph resolves on its own and fails with + * this block's constraint added -- + * "Could not resolve kotlin-stdlib-jdk8:{strictly 1.7.22}". That is worse + * than the duplicate, because the app cannot work around it. + */ + @Test + public void aStrictPinIsHonouredOnAnyConfiguration() { + String releaseStrict = KotlinStdlibAlignment.constraintsBlock("implementation", null, + " releaseImplementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') {\n" + + " version { strictly '1.7.22' }\n" + + " }\n"); + check(!releaseStrict.contains("kotlin-stdlib-jdk8:1.8.0"), + "a strict release pin is left to the app"); + + String compileOnlyStrict = KotlinStdlibAlignment.constraintsBlock( + "implementation", null, + " compileOnly('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check(!compileOnlyStrict.contains("kotlin-stdlib-jdk8:1.8.0"), + "a strict compileOnly pin is honoured even though compileOnly alone is not"); + } + + /** + * The block absorbed for that check belongs to the declaration that opened + * it and no further. A dependencies or android block must not swallow the + * fragment: only a statement already naming the Kotlin group absorbs one. + */ + @Test + public void anUnrelatedBlockDoesNotSwallowTheFragment() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + "dependencies {\n" + + " implementation('com.example:thing:1.0') { version { strictly '1.0' } }\n" + + " debugImplementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n" + + "}\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "an unrelated strict block does not suppress, and the debug BOM still does not"); + } + /** * And their variant and test forms still do not, which is the property the * whole-token lowercase match buys without listing a single variant name. From 7535785763b7c3c7e134aac7b7ab73e2859e1db5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:00:16 +0300 Subject: [PATCH 11/94] Stop asking which Kotlin plugin is applied The plugin-version skip is gone, and with it the version parsing, the reading of android.topDependency, and the hazard that a commented-out plugin declaration above an active one decided the outcome. Net 105 lines lighter. It was never load-bearing. Measured against a graph carrying billing 9.1.0 and appcompat 1.6.1, adding this block alongside plugin 1.9.22 and alongside 1.8.22 produced byte-identical resolution in both cases -- the plugin's own alignment already lands at or above this floor, and a constraint never lowers a version. The skip was pure cosmetics on the generated file. It was also not sound, which is what the review found: the plugin's alignment can be turned off with kotlin.stdlib.jdk.variants.version.alignment=false, and this builder preserves a project's existing gradle.properties, so "a new enough plugin is applied" was never the same question as "the jdk variants are aligned". Checking the property was the other option offered; deleting the skip answers it without adding a third thing to parse. An older plugin still gets the block for the reason it always did, and that measurement is unchanged: the 1.7 line ADDS kotlin-stdlib-jdk8 at its own pre-merge version, so plugin 1.7.22 plus billing 9.1.0 resolves stdlib 1.8.22 beside jdk7/jdk8 1.7.22, and this block moves them to 1.8.0. Not taken, and now argued in the code rather than only in a review thread: two findings asking for the strict-version exception to be scoped to release. A strict pin on debugImplementation or compileOnly genuinely does not manage releaseRuntimeClasspath -- but the constraint this block writes is not release-scoped either. It is declared on `implementation`, which every variant inherits, so "constrain release but not debug" is not available from it. The choice for an app with a strict pre-1.8 pin on a non-release configuration is between leaving release with a duplicate it already had, and breaking a debug build that resolves fine today. This class has taken the first everywhere else it has had to choose. Scoping to `releaseImplementation` would satisfy both and is deliberately not done: naming a variant configuration a given build type set may not have fails the whole script at evaluation, which is a much larger blast radius than the case it fixes. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 22 --- .../builders/KotlinStdlibAlignment.java | 111 +++++------- .../builders/KotlinStdlibAlignmentTest.java | 166 ++++++------------ 3 files changed, 97 insertions(+), 202 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 415d6b5ab67..ca793297e51 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7290,33 +7290,11 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // are not symmetrical: too narrow leaves an ancient build with a failure it // already had, too wide breaks a build that currently succeeds. Raise this // gate only with a reproduction on that path. - // The Kotlin Gradle plugin version this build actually applies, empty when it - // applies none. Which version matters: only 1.8 and newer align the jdk stdlib - // variants themselves. An app that declares its own kotlin-gradle-plugin wins, - // because the generator then skips its own plugin line -- and a declaration - // whose version is a Gradle variable parses to null, which reads downstream as - // "cannot tell" and therefore as "does not align", so the block is written. - String appliedKotlinPlugin = ""; - if (hasKotlinSources) { - appliedKotlinPlugin = kotlinVersion; - // Comments stripped first: HealthManifestFragments reads the FIRST bare - // substring match, so a commented-out 1.8+ plugin sitting above an active - // 1.7.x one is read as the applied version, and the alignment is then - // skipped for a build whose real plugin does not align. - String kotlinTopDependency = KotlinStdlibAlignment.activeText( - request.getArg("android.topDependency", "")); - if (HealthManifestFragments.declaresKotlinPlugin(kotlinTopDependency)) { - String declared = HealthManifestFragments - .declaredKotlinPluginVersion(kotlinTopDependency); - appliedKotlinPlugin = declared == null ? "" : declared; - } - } String kotlinStdlibConstraints = ""; if (useAndroidX && gradleVersionInt >= 6 && request.getArg("android.kotlinStdlibAlignment", "true").equals("true")) { kotlinStdlibConstraints = KotlinStdlibAlignment.constraintsBlock( compile, - appliedKotlinPlugin, // Every app-controlled fragment that reaches the generated // dependencies block. Read off ShieldInjector's GRADLE_TEXT_HINTS, // which is this tree's enumeration of hints interpolated into a diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 94c8e06ae40..578c70bfc8e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -71,12 +71,27 @@ * version would instead override a newer one the app deliberately asked * for.

* - *

Why the Kotlin Gradle plugin only sometimes excuses this. From - * 1.8.0 the plugin aligns the jdk variants itself, so the block would be a - * no-op and is skipped. An older plugin does not, and skipping there was a - * real bug: the versions were resolved with Gradle rather than reasoned - * about, and a project on the {@code android.useGradle8=false} path -- where - * this builder selects Kotlin 1.7.22 -- resolves like this:

+ *

Why the Kotlin Gradle plugin does not excuse this. From 1.8.0 + * the plugin aligns the jdk variants itself, so it was tempting to skip the + * block whenever a new enough one was applied. That skip is gone, for two + * reasons that point the same way. It was never load-bearing: measured + * against a graph carrying billing 9.1.0 and appcompat 1.6.1, adding this + * block alongside plugin 1.9.22 and 1.8.22 produced byte-identical + * resolution, because the plugin's alignment already lands at or above this + * floor and a constraint never lowers a version. And it was not sound + * either -- the plugin's alignment can be turned off with + * {@code kotlin.stdlib.jdk.variants.version.alignment=false}, which this + * builder preserves out of a project's existing gradle.properties, so + * "a new enough plugin is applied" was never the same question as "the jdk + * variants are aligned".

+ * + *

Deleting the skip answers both at once and takes with it the version + * parsing, the reading of {@code android.topDependency} and the hazard that + * a commented-out plugin declaration above an active one decided the + * outcome. An older plugin gets the block for the reason it always did: + * the 1.7 line ADDS {@code kotlin-stdlib-jdk8} at its own pre-merge version, + * so the class-bearing jar is guaranteed present and any dependency reaching + * a merged stdlib collides with it --

* *
  * plugin 1.7.22 alone            stdlib 1.7.22 + jdk7/jdk8 1.7.22   no duplicate
@@ -84,24 +99,14 @@
  * the same, with this block      stdlib 1.8.22 + jdk7/jdk8 1.8.0    fixed
  * 
* - *

Note the middle row is worse than a transitive accident: the 1.7.x - * plugin adds {@code kotlin-stdlib-jdk8} at its own version, so the - * older real jar is guaranteed present rather than merely possible, and any - * dependency that reaches a merged stdlib collides with it. Hence the test - * is the applied plugin's version, not whether a plugin is applied at all, - * and an unreadable version counts as "does not align" so the block is - * written rather than skipped.

- * - *

The cost of that, stated plainly. On the same pre-1.8 plugin - * path, an app whose graph contains no merged stdlib (the first row above) - * did not need the block, and gets its stdlib family raised to + *

The cost of that, stated plainly. On that pre-1.8 plugin path, + * an app whose graph contains no merged stdlib (the first row above) did not + * need the block, and gets its stdlib family raised to * {@value #MERGED_STDLIB_FLOOR} anyway -- newer than the compiler in use, * which Kotlin warns about. That is deliberate. Gradle cannot express a - * constraint conditional on what another module resolved to, so the choice - * is between a warning in the case that did not need help and a failed build - * in the case that did, and a warning is the better of the two. Raising the - * builder's own pre-Gradle-8 Kotlin default would remove even that, and is a - * bigger change than this one should carry.

+ * constraint conditional on what another module resolved to, so the choice is + * between a warning in the case that did not need help and a failed build in + * the case that did, and a warning is the better of the two.

* *

Extracted into a pure static helper so it is unit-testable without a * Gradle run and so the BuildDaemon copy stays trivially diffable -- @@ -190,11 +195,6 @@ private KotlinStdlibAlignment() { * constraints on, {@code implementation} on any AndroidX project. The * caller passes the same name it uses for the rest of the block so a * legacy {@code compile} project stays consistent with itself. - * @param appliedKotlinPluginVersion the version of the Kotlin Gradle - * plugin this build applies, or null/empty when it applies none. Only - * {@value #MERGED_STDLIB_FLOOR} and newer align the jdk variants - * themselves; anything older, or anything this cannot read, is treated - * as not aligning and the block is written. * @param appGradleFragments the Gradle text the app itself contributed * ({@code gradleDependencies}, {@code android.gradleDep} and the like). * An artifact the app names there is left to the app; the Kotlin BOM @@ -202,10 +202,7 @@ private KotlinStdlibAlignment() { * @return the block, newline terminated, or {@code ""} */ public static String constraintsBlock(String configuration, - String appliedKotlinPluginVersion, String... appGradleFragments) { - if (alignsItsOwnJdkVariants(appliedKotlinPluginVersion)) { - return ""; - } + String... appGradleFragments) { if (configuration == null || configuration.trim().length() == 0) { return ""; } @@ -239,20 +236,6 @@ && atOrPastTheMerge(declaredVersion( return " constraints {\n" + out + " }\n"; } - /** - * Whether a Kotlin Gradle plugin of this version aligns the jdk stdlib - * variants on its own, making this class's block a no-op. - * - *

Answered from the version rather than from "is a plugin applied", - * because the two differ exactly where it matters. Unknown reads as - * false: a version that cannot be parsed -- an app declaring - * {@code kotlin-gradle-plugin:$kotlin_version} produces one -- must not - * silently switch the alignment off.

- */ - public static boolean alignsItsOwnJdkVariants(String kotlinPluginVersion) { - return atOrPastTheMerge(kotlinPluginVersion); - } - /** * Whether a Kotlin version is at or past the release that merged the jdk * artifacts away, and therefore aligns them wherever it is in force -- @@ -371,6 +354,24 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat // it, so a strict pin ends the question regardless of which configuration // carries it. A strict version at or above the floor loses nothing by this: // it is already a shim. + // + // Reviewed twice as too broad -- a strict pin on debugImplementation or + // compileOnly does not manage releaseRuntimeClasspath, so suppressing the + // whole artifact leaves the release graph unaligned. That is true, and it is + // still the better of the two outcomes, because the constraint this block + // writes is NOT release-scoped: it is declared on `implementation`, which + // every variant inherits. There is no version of "constrain release but not + // debug" available from one implementation constraint. So the choice for an + // app with a strict pre-1.8 pin on a non-release configuration is: + // suppress -- release keeps a duplicate it already had before this change + // emit -- debug stops resolving, which it did fine before this change + // The second breaks a build that works today, and this class has taken the + // first everywhere else it has had to choose. Scoping the constraint to + // `releaseImplementation` would satisfy both, and is deliberately not done: + // naming a variant configuration that a given build type set may not have + // fails the whole script at evaluation, which is a far larger blast radius + // than the case it fixes. Revisit only with a project that actually has this + // shape. if (!line.contains("strictly") && !declaresOnTheConstrainedConfiguration(configuration, line)) { return false; @@ -464,26 +465,6 @@ private static boolean declaresOn(String configuration, String line) { return false; } - /** - * A Gradle fragment with its comments removed, for a caller that has to - * read a version out of it. - * - *

Exposed because the builder parses {@code android.topDependency} for - * the Kotlin plugin version with a helper that takes the first bare - * substring match, so a commented-out declaration above an active one wins - * and decides the alignment. Same hazard as the one this class already - * guards against on its own fragments, reached through a different - * parser.

- */ - public static String activeText(String fragment) { - String[] lines = activeLines(fragment); - StringBuilder out = new StringBuilder(); - for (int i = 0; i < lines.length; i++) { - out.append(lines[i]).append('\n'); - } - return out.toString(); - } - /** * A fragment's lines with comments removed and exclusions dropped -- the * text that actually declares something. diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 22f91791cd3..8bce5ca775a 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -34,14 +34,14 @@ * the alignment lands in the dependency graph of every AndroidX app, so the * cases that must produce nothing matter more than the one that must produce * something. The two that must NOT produce nothing -- - * {@link #aPreMergeKotlinPluginStillGetsTheAlignment()} and + * {@link #aKotlinPluginNoLongerExcusesTheBlock()} and * {@link #pinningOneJdkArtifactLeavesTheOtherConstrained()} -- are the ones * that caught a real over-suppression, so treat a change that makes either * pass vacuously as a regression.

*/ public class KotlinStdlibAlignmentTest { private static String block() { - return KotlinStdlibAlignment.constraintsBlock("implementation", null); + return KotlinStdlibAlignment.constraintsBlock("implementation"); } /** @@ -104,60 +104,22 @@ public void everyConstraintCarriesAReason() { } /** - * From 1.8.0 the Kotlin Gradle plugin aligns the jdk variants itself, so - * the block would be a no-op. - */ - @Test - public void skipsOnlyAKotlinPluginThatAlignsItself() { - check("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", "1.8.0")), - "the release that starts aligning is skipped"); - check("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", "1.9.22")), - "a newer plugin is skipped"); - check(KotlinStdlibAlignment.alignsItsOwnJdkVariants("2.0.0"), - "a major bump still aligns"); - check(KotlinStdlibAlignment.alignsItsOwnJdkVariants("1.9.22-RC2"), - "a qualifier does not hide an aligning version"); - } - - /** - * The case that made this a version test rather than an is-a-plugin-applied - * test. On the {@code android.useGradle8=false} path the builder selects - * Kotlin 1.7.22, which predates the merge and does not align. Worse, the - * 1.7 plugin ADDS {@code kotlin-stdlib-jdk8} at its own version, so the - * pre-merge real jar is guaranteed present; any dependency reaching a - * merged stdlib then collides with it. Measured with Gradle: plugin 1.7.22 - * plus billing 9.1.0 resolves kotlin-stdlib 1.8.22 beside jdk7/jdk8 - * 1.7.22, which is the duplicate. Skipping there shipped the bug. - */ - @Test - public void aPreMergeKotlinPluginStillGetsTheAlignment() { - check(KotlinStdlibAlignment.constraintsBlock("implementation", "1.7.22") - .contains("kotlin-stdlib-jdk8"), - "a pre-merge plugin still gets the alignment"); - check(!KotlinStdlibAlignment.alignsItsOwnJdkVariants("1.7.22"), - "1.7.22 does not align"); - check(!KotlinStdlibAlignment.alignsItsOwnJdkVariants("1.6.21"), - "1.6.21 does not align"); - } - - /** - * An app declaring {@code kotlin-gradle-plugin:$kotlin_version} parses to - * nothing. Unknown must read as "does not align" -- guessing the other way - * switches the fix off silently. + * A Kotlin plugin no longer excuses the block, whatever its version. + * + *

Skipping for a 1.8+ plugin was never load-bearing -- measured against + * a graph carrying billing 9.1.0 and appcompat 1.6.1, adding this block + * alongside plugin 1.9.22 and 1.8.22 produced byte-identical resolution -- + * and it was not sound either, because the plugin's alignment can be + * turned off with kotlin.stdlib.jdk.variants.version.alignment=false, + * which this builder preserves out of a project's gradle.properties. + * Emitting unconditionally answers both, and takes the version parsing and + * the commented-plugin hazard with it.

*/ @Test - public void anUnreadablePluginVersionStillGetsTheAlignment() { - check(!KotlinStdlibAlignment.alignsItsOwnJdkVariants(""), - "no plugin does not align"); - check(!KotlinStdlibAlignment.alignsItsOwnJdkVariants(null), - "a null version does not align"); - check(!KotlinStdlibAlignment.alignsItsOwnJdkVariants("$kotlin_version"), - "a Gradle variable does not read as aligning"); - check(KotlinStdlibAlignment.constraintsBlock( - "implementation", "$kotlin_version").contains("kotlin-stdlib-jdk8"), - "an unreadable version still gets the alignment"); + public void aKotlinPluginNoLongerExcusesTheBlock() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), "jdk7 is aligned regardless"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), "jdk8 is aligned regardless"); } /** @@ -169,7 +131,7 @@ public void anUnreadablePluginVersionStillGetsTheAlignment() { @Test public void pinningOneJdkArtifactLeavesTheOtherConstrained() { String pinnedJdk7 = KotlinStdlibAlignment.constraintsBlock( - "implementation", null, + "implementation", " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22'\n"); check(pinnedJdk7.contains("kotlin-stdlib-jdk8"), "pinning jdk7 leaves jdk8 constrained"); @@ -177,7 +139,7 @@ public void pinningOneJdkArtifactLeavesTheOtherConstrained() { "the artifact the app pinned is left to the app"); String pinnedJdk8 = KotlinStdlibAlignment.constraintsBlock( - "implementation", null, + "implementation", " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); check(pinnedJdk8.contains("kotlin-stdlib-jdk7"), "pinning jdk8 leaves jdk7 constrained"); @@ -185,7 +147,7 @@ public void pinningOneJdkArtifactLeavesTheOtherConstrained() { "the artifact the app pinned is left to the app"); String pinnedBoth = KotlinStdlibAlignment.constraintsBlock( - "implementation", null, + "implementation", " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22'\n" + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); check("".equals(pinnedBoth), @@ -207,11 +169,11 @@ public void pinningOneJdkArtifactLeavesTheOtherConstrained() { @Test public void emitsNothingWhenTheAppUsesTheKotlinBom() { check("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", null, + "implementation", " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n")), "an app using a merged-era Kotlin BOM is left alone"); check("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", null, + "implementation", " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')\n")), "the BOM at the merge itself is enough"); } @@ -224,7 +186,7 @@ public void emitsNothingWhenTheAppUsesTheKotlinBom() { @Test public void aPreMergeKotlinBomStillGetsTheAlignment() { String out = KotlinStdlibAlignment.constraintsBlock( - "implementation", null, + "implementation", " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.7.22')\n"); check(out.contains("kotlin-stdlib-jdk7:1.8.0"), "a pre-merge BOM still gets jdk7 aligned"); @@ -239,7 +201,7 @@ public void aPreMergeKotlinBomStillGetsTheAlignment() { @Test public void anUnreadableBomVersionStillGetsTheAlignment() { String out = KotlinStdlibAlignment.constraintsBlock( - "implementation", null, + "implementation", " implementation platform(\"org.jetbrains.kotlin:kotlin-bom:$kotlinVersion\")\n"); check(out.contains("kotlin-stdlib-jdk8:1.8.0"), "an unreadable BOM version still gets the alignment"); @@ -294,44 +256,18 @@ public void theBuilderPassesEveryAppControlledDependencyFragment() throws Except */ @Test public void aCommentedOutDeclarationIsNotADeclaration() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", " // implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n" + " // implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); check(out.contains("kotlin-stdlib-jdk7:1.8.0"), "a commented-out BOM does not suppress"); check(out.contains("kotlin-stdlib-jdk8:1.8.0"), "a commented-out pin does not suppress"); - String blockComment = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String blockComment = KotlinStdlibAlignment.constraintsBlock("implementation", " /* implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22' */\n"); check(blockComment.contains("kotlin-stdlib-jdk8:1.8.0"), "a block-commented pin does not suppress"); } - /** - * activeText strips comments, and the builder has to use it on - * android.topDependency before the plugin version is parsed out of it. - * HealthManifestFragments takes the FIRST bare substring match, so a - * commented-out 1.8+ plugin above an active 1.7.x one is read as the - * applied version and the alignment is skipped for a build that needs it. - */ - @Test - public void aCommentedOutPluginIsNotTheAppliedPlugin() throws Exception { - String topDependency = - "// classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22'\n" - + "classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.22'\n"; - check(KotlinStdlibAlignment.activeText(topDependency) - .indexOf("1.9.22") < 0, - "the commented plugin is gone from the active text"); - check(KotlinStdlibAlignment.activeText(topDependency) - .indexOf("1.7.22") >= 0, - "the active plugin survives"); - - byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( - "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); - String builderSrc = new String(bytes, "UTF-8"); - check(builderSrc.contains("KotlinStdlibAlignment.activeText("), - "the builder strips comments before reading the plugin version"); - } - /** * A declaration on a variant or test configuration does not reach the one * the constraints are written on, so it cannot stand in for a pin. @@ -341,17 +277,17 @@ public void aCommentedOutPluginIsNotTheAppliedPlugin() throws Exception { */ @Test public void aVariantOnlyDeclarationDoesNotSuppress() { - String debugBom = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String debugBom = KotlinStdlibAlignment.constraintsBlock("implementation", " debugImplementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n"); check(debugBom.contains("kotlin-stdlib-jdk8:1.8.0"), "a debug-only BOM does not suppress the main variant"); - String testPin = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String testPin = KotlinStdlibAlignment.constraintsBlock("implementation", " testImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); check(testPin.contains("kotlin-stdlib-jdk8:1.8.0"), "a test-only pin does not suppress the main variant"); - String releasePin = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String releasePin = KotlinStdlibAlignment.constraintsBlock("implementation", " releaseImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); check(releasePin.contains("kotlin-stdlib-jdk8:1.8.0"), "even a release-only pin is not the configuration being constrained"); @@ -369,7 +305,7 @@ public void aPinOnAnyMainConfigurationSuppresses() { String[] configurations = {"implementation", "api", "runtimeOnly", "compile", "runtime"}; for (String configuration : configurations) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", " " + configuration + "('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22')\n"); check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), @@ -388,7 +324,7 @@ public void aPinOnAnyMainConfigurationSuppresses() { */ @Test public void aCompileOnlyDeclarationDoesNotManageTheRuntimeGraph() { - String bom = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String bom = KotlinStdlibAlignment.constraintsBlock("implementation", " compileOnly platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n"); check(bom.contains("kotlin-stdlib-jdk8:1.8.0"), "a compileOnly BOM does not align the runtime graph"); @@ -404,7 +340,7 @@ public void aCompileOnlyDeclarationDoesNotManageTheRuntimeGraph() { */ @Test public void aStrictPinIsHonouredOnAnyConfiguration() { - String releaseStrict = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String releaseStrict = KotlinStdlibAlignment.constraintsBlock("implementation", " releaseImplementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') {\n" + " version { strictly '1.7.22' }\n" + " }\n"); @@ -412,7 +348,7 @@ public void aStrictPinIsHonouredOnAnyConfiguration() { "a strict release pin is left to the app"); String compileOnlyStrict = KotlinStdlibAlignment.constraintsBlock( - "implementation", null, + "implementation", " compileOnly('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + "{ version { strictly '1.7.22' } }\n"); check(!compileOnlyStrict.contains("kotlin-stdlib-jdk8:1.8.0"), @@ -426,7 +362,7 @@ public void aStrictPinIsHonouredOnAnyConfiguration() { */ @Test public void anUnrelatedBlockDoesNotSwallowTheFragment() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", "dependencies {\n" + " implementation('com.example:thing:1.0') { version { strictly '1.0' } }\n" + " debugImplementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n" @@ -444,7 +380,7 @@ public void theVariantFormsOfThoseConfigurationsStillDoNot() { String[] variants = {"testRuntimeOnly", "debugRuntimeOnly", "androidTestImplementation", "releaseCompileOnly", "debugApi", "testCompile"}; for (String variant : variants) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", " " + variant + "('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22')\n"); check(out.contains("kotlin-stdlib-jdk8:1.8.0"), @@ -458,7 +394,7 @@ public void theVariantFormsOfThoseConfigurationsStillDoNot() { */ @Test public void anApiDeclarationCountsAsAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", " api 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), "api pins jdk8"); check(out.contains("kotlin-stdlib-jdk7:1.8.0"), "and leaves jdk7 constrained"); @@ -472,7 +408,7 @@ public void anApiDeclarationCountsAsAPin() { */ @Test public void anExclusionIsNotAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation('com.example:thing:1.0') {\n" + " exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8'\n" + " }\n"); @@ -489,7 +425,7 @@ public void anExclusionIsNotAPin() { */ @Test public void aDeclarationSplitAcrossLinesIsStillAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation(\n" + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" + " )\n"); @@ -506,7 +442,7 @@ public void aDeclarationSplitAcrossLinesIsStillAPin() { */ @Test public void anInlineExclusionDoesNotCancelTheDeclaration() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + "{ exclude group: 'com.example', module: 'thing' }\n"); check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), @@ -519,7 +455,7 @@ public void anInlineExclusionDoesNotCancelTheDeclaration() { */ @Test public void aStandaloneExclusionIsStillNotAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation('com.example:thing:1.0') {\n" + " exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8'\n" + " }\n"); @@ -537,7 +473,7 @@ public void aStandaloneExclusionIsStillNotAPin() { */ @Test public void aSemicolonEndsAStatement() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation 'com.android.billingclient:billing:9.1.0'; " + "debugImplementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n"); check(out.contains("kotlin-stdlib-jdk8:1.8.0"), @@ -546,7 +482,7 @@ public void aSemicolonEndsAStatement() { // The same shape where the pin IS on the main variant still suppresses, so // the split did not simply stop every semicolon-separated value from working. - String pinned = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String pinned = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation 'com.android.billingclient:billing:9.1.0'; " + "implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); check(!pinned.contains("kotlin-stdlib-jdk8:1.8.0"), @@ -558,14 +494,14 @@ public void aSemicolonEndsAStatement() { */ @Test public void aSemicolonInsideAStringOrParensIsNotASeparator() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation(\n" + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" + " )\n"); check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), "a wrapped declaration still pins"); - String quoted = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String quoted = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22' " + "// note; with a semicolon\n"); check(!quoted.contains("kotlin-stdlib-jdk8:1.8.0"), @@ -580,7 +516,7 @@ public void aSemicolonInsideAStringOrParensIsNotASeparator() { */ @Test public void unbalancedParenthesesDoNotGlueStatementsTogether() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation(\n" + " testImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); check(out.contains("kotlin-stdlib-jdk8:1.8.0"), @@ -593,7 +529,7 @@ public void unbalancedParenthesesDoNotGlueStatementsTogether() { */ @Test public void theMapFormCountsAsAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation group: 'org.jetbrains.kotlin', " + "name: 'kotlin-stdlib-jdk8', version: '1.9.22'\n"); check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), "the map form pins jdk8"); @@ -607,7 +543,7 @@ public void theMapFormCountsAsAPin() { */ @Test public void aUrlIsNotAComment() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", " maven { url 'https://example.com/repo' }\n" + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), @@ -621,7 +557,7 @@ public void aUrlIsNotAComment() { */ @Test public void ignoresEmptyAndNullFragments() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", "", null, " implementation 'androidx.appcompat:appcompat:1.6.1'\n"); check(out.contains("kotlin-stdlib-jdk8"), "an empty or absent hint is not a pin"); @@ -635,7 +571,7 @@ public void ignoresEmptyAndNullFragments() { */ @Test public void anUnrelatedKotlinDependencyIsNotAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", null, + String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'\n"); check(out.contains("kotlin-stdlib-jdk8"), "a coroutines dependency does not switch the alignment off"); @@ -648,7 +584,7 @@ public void anUnrelatedKotlinDependencyIsNotAPin() { */ @Test public void usesTheConfigurationItWasGiven() { - String out = KotlinStdlibAlignment.constraintsBlock("compile", null); + String out = KotlinStdlibAlignment.constraintsBlock("compile"); check(out.contains("compile('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0')"), "the caller's configuration is used"); check(!out.contains("implementation("), @@ -657,9 +593,9 @@ public void usesTheConfigurationItWasGiven() { @Test public void emitsNothingWithoutAConfiguration() { - check("".equals(KotlinStdlibAlignment.constraintsBlock(null, null)), + check("".equals(KotlinStdlibAlignment.constraintsBlock(null)), "a null configuration writes nothing"); - check("".equals(KotlinStdlibAlignment.constraintsBlock(" ", null)), + check("".equals(KotlinStdlibAlignment.constraintsBlock(" ")), "a blank configuration writes nothing"); } From 27f413fc077c01f4f1be938f5a0381d659b083fe Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:11:31 +0300 Subject: [PATCH 12/94] Delete the exclusion cut, and let the comment stripper see strings Two findings, and both fixes remove code rather than adding a case to it. The exclusion cut is gone. It was needed while a declaration was recognised by the artifact name appearing anywhere, and became unnecessary the moment a declaration had to be spelled as one: an exclusion writes `group: '...', module: 'kotlin-stdlib-jdk8'` and never the colon-joined coordinate or the `name:` map form the check looks for, so it cannot match a declaration in the first place. All three exclusion cases still pass with the cut deleted, which is what says it was redundant rather than load-bearing. It was also actively harmful, which is what the review found. Cutting from `exclude` to the end of the statement discarded everything after it, so an exclusion written before a `version { strictly '1.7.22' } }` block took that block with it -- and losing a strict marker is what turns this class's constraint into a failed resolution instead of an override. The comment stripper now tracks quoted strings, which the statement scanner beside it already did for parentheses and semicolons. A `/*` inside a quoted value used to open a block comment that swallowed the rest of the fragment, including a strict pin, with the same consequence. Tracking quotes covers that and subsumes the narrower rule it replaces: `//` was previously spared only when it followed a colon, which was a way of protecting `url 'https://...'` without noticing the general case. A `//` inside any string is now safe, in either quote style, and the URL case keeps its own test. Three new cases, each checked by putting the old behaviour back. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 58 +++++++++++++------ .../builders/KotlinStdlibAlignmentTest.java | 46 +++++++++++++++ 2 files changed, 87 insertions(+), 17 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 578c70bfc8e..e2d529cbe64 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -469,10 +469,16 @@ private static boolean declaresOn(String configuration, String line) { * A fragment's lines with comments removed and exclusions dropped -- the * text that actually declares something. * - *

A line comment is only a comment when the {@code //} does not follow - * a colon: {@code maven { url 'https://...' }} is an ordinary declaration - * that a naive strip would cut in half, and these fragments really do - * carry repository URLs.

+ *

Comment delimiters are only delimiters outside a string, which is the + * same rule the statement scanner already applied to parentheses and + * semicolons and which this had been missing. It matters in both + * directions: {@code maven { url 'https://...' }} is an ordinary + * declaration that a naive strip cuts in half, and a {@code /*} inside a + * string used to open a block comment that swallowed the rest of the + * fragment -- including, in the case that found this, an explicit strict + * pin whose loss turns this class's constraint into a failed resolution. + * Tracking quotes covers both, and replaces the narrower rule that only + * spared a {@code //} following a colon.

* *

Regrouping into statements is {@link #statements}; this method only * removes the comments.

@@ -483,6 +489,7 @@ private static String[] activeLines(String fragment) { } StringBuilder out = new StringBuilder(); boolean inBlockComment = false; + char quote = 0; for (int i = 0; i < fragment.length(); i++) { char c = fragment.charAt(i); if (inBlockComment) { @@ -494,6 +501,21 @@ private static String[] activeLines(String fragment) { } continue; } + if (quote != 0) { + out.append(c); + if (c == '\\' && i + 1 < fragment.length()) { + out.append(fragment.charAt(i + 1)); + i++; + } else if (c == quote) { + quote = 0; + } + continue; + } + if (c == '\'' || c == '"') { + quote = c; + out.append(c); + continue; + } if (c == '/' && i + 1 < fragment.length()) { char next = fragment.charAt(i + 1); if (next == '*') { @@ -501,7 +523,7 @@ private static String[] activeLines(String fragment) { i++; continue; } - if (next == '/' && (i == 0 || fragment.charAt(i - 1) != ':')) { + if (next == '/') { while (i < fragment.length() && fragment.charAt(i) != '\n') { i++; } @@ -533,11 +555,19 @@ private static String[] activeLines(String fragment) { * for containing "exclude" * * - *

So a line whose parentheses are still open is joined to the next, and - * a statement is truncated at {@code exclude} rather than discarded -- what - * precedes the exclusion is the declaration, and what follows it is the - * part that must not count. A bare {@code exclude} line truncates to - * nothing and so is still not a pin.

+ *

So a line whose parentheses are still open is joined to the next. + * Exclusions are left alone: they used to be cut out here, which was + * needed while a declaration was recognised by the artifact name appearing + * anywhere, and became both unnecessary and harmful once a declaration had + * to be spelled as one. Unnecessary, because an exclusion writes + * {@code group: '...', module: 'kotlin-stdlib-jdk8'} and never the + * colon-joined coordinate or the {@code name:} map form the declaration + * check looks for, so it cannot match one. Harmful, because cutting from + * {@code exclude} to the end of the statement also threw away anything + * after it -- an exclusion written before a + * {@code version { strictly '1.7.22' } }} block took that block with it, + * and losing the strict marker is what turns this class's constraint into + * a failed resolution.

* *

A statement ends at a newline or at a semicolon, whichever comes * first, and neither ends one inside parentheses or inside a string. The @@ -616,13 +646,7 @@ private static String[] statements(String text) { } merged.add(statement); } - List kept = new ArrayList(); - for (int i = 0; i < merged.size(); i++) { - String statement = merged.get(i); - int at = statement.indexOf("exclude"); - kept.add(at < 0 ? statement : statement.substring(0, at)); - } - return kept.toArray(new String[kept.size()]); + return merged.toArray(new String[merged.size()]); } /** How far a statement opens or closes braces, ignoring those in strings. */ diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 8bce5ca775a..dbea397cdb8 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -355,6 +355,29 @@ public void aStrictPinIsHonouredOnAnyConfiguration() { "a strict compileOnly pin is honoured even though compileOnly alone is not"); } + /** + * An exclusion written before the version block must not take the strict + * marker with it. Cutting the statement from {@code exclude} to its end + * did exactly that, and losing the strict marker is what turns this + * class's constraint into a failed resolution rather than an override. + */ + @Test + public void anExclusionBeforeTheVersionBlockDoesNotHideTheStrictPin() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " compileOnly('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ exclude group: 'x', module: 'y'; version { strictly '1.7.22' } }\n"); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the strict pin survives an exclusion written before it"); + + String multiline = KotlinStdlibAlignment.constraintsBlock("implementation", + " compileOnly('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') {\n" + + " exclude group: 'x', module: 'y'\n" + + " version { strictly '1.7.22' }\n" + + " }\n"); + check(!multiline.contains("kotlin-stdlib-jdk8:1.8.0"), + "and the same written across lines"); + } + /** * The block absorbed for that check belongs to the declaration that opened * it and no further. A dependencies or android block must not swallow the @@ -536,6 +559,29 @@ public void theMapFormCountsAsAPin() { check(out.contains("kotlin-stdlib-jdk7:1.8.0"), "and leaves jdk7 constrained"); } + /** + * A comment delimiter inside a string is not a delimiter. A {@code /*} in + * a quoted value used to open a block comment that swallowed the rest of + * the fragment, taking an explicit strict pin with it -- and losing a + * strict marker is what turns this class's constraint into a failed + * resolution rather than an override. + */ + @Test + public void aCommentDelimiterInsideAStringIsNotADelimiter() { + String blockOpener = KotlinStdlibAlignment.constraintsBlock("implementation", + " def marker = '/*'\n" + + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check(!blockOpener.contains("kotlin-stdlib-jdk8:1.8.0"), + "a /* inside a string does not swallow the pin that follows it"); + + String lineOpener = KotlinStdlibAlignment.constraintsBlock("implementation", + " def marker = \"//\"\n" + + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); + check(!lineOpener.contains("kotlin-stdlib-jdk8:1.8.0"), + "a // inside a string does not comment out the line"); + } + /** * A repository URL is not a comment. Stripping from every {@code //} would * cut {@code maven { url 'https://...' }} in half, and these fragments do From 0726d9b0d6b35c060c2d0b5af767e80f9cab393d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:32:19 +0300 Subject: [PATCH 13/94] Delete the Kotlin BOM case, and teach the statement scanner about escapes Two findings. One is a four-line consistency fix; the other removes a feature. The statement scanner now handles backslash escapes, which the comment stripper beside it already did. A string escaping its own apostrophe closed early, so every following newline read as being inside a string rather than ending a statement -- merging statements that must stay apart, which is how a main-variant configuration token comes to pair with a debug-only coordinate. The Kotlin BOM no longer suppresses anything, at any version. It went the way of the plugin check, for the same measured reason: against a graph carrying billing 9.1.0 and appcompat 1.6.1, adding this block alongside kotlin-bom 1.9.22 gives byte-identical resolution, because a BOM contributes ordinary constraints rather than strict ones and the higher version simply wins. Alongside kotlin-bom 1.7.22 the block is not merely harmless but necessary, since a pre-merge BOM raises the jdk artifacts and cannot pull kotlin-stdlib back down. So the case was cosmetic where it fired and wrong where it did not. Removing it answers the review's actual finding -- a BOM declared inside an `if` block cannot be known to be in force by reading the text -- without trying to evaluate Gradle conditionals, which is not something this can do. It also takes the BOM version parsing, declaredVersion, atOrPastTheMerge and the version comparison with it, and with them the last use of HealthManifestFragments here. The build hint's documentation said a BOM switches the alignment off, so it says what is now true instead: the jdk artifacts still do, a strict pin still does, and a BOM neither does nor needs to. Net 94 lines lighter. Three cases replace three, and the escape one was checked by putting the old scanner back. One process note, since it nearly shipped: the first attempt at this edit cut the test file by javadoc landmarks that were not adjacent in file order and deleted twenty unrelated cases. The suite dropping from 35 to 15 is what caught it. The methods are removed by name and brace matching now, and the method list before and after is diffed to prove exactly three left and three arrived. Co-Authored-By: Claude Opus 5 (1M context) --- .../build/shared/BuildHintsAndroid.java | 7 +- .../builders/KotlinStdlibAlignment.java | 145 +++--------------- .../builders/KotlinStdlibAlignmentTest.java | 110 ++++++------- 3 files changed, 84 insertions(+), 178 deletions(-) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index 454a9885216..a7431912a94 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -652,8 +652,11 @@ static void register(List h) { + "the app never asked for. Expressed as a Gradle constraint, so it adds " + "nothing to an app with no Kotlin anywhere in its dependencies and never " + "lowers a version. Set to false only to manage those coordinates yourself; " - + "declaring `kotlin-stdlib-jdk7`, `kotlin-stdlib-jdk8` or `kotlin-bom` in your " - + "own Gradle build hints already switches it off.")); + + "declaring `kotlin-stdlib-jdk7` or `kotlin-stdlib-jdk8` in your own Gradle " + + "build hints already switches it off for that artifact, as does pinning one " + + "with a strict version. A Kotlin BOM has no such effect, and needs none: a " + + "BOM contributes ordinary constraints rather than strict ones, so a newer " + + "BOM simply wins over this floor while an older BOM still needs it.")); h.add(new Hint("android.largeScreens") .group(HintGroup.ANDROID) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index e2d529cbe64..313d7e16242 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -61,6 +61,19 @@ * telling Gradle the two artifacts overlap, and it has no way to find out. * This class supplies for 1.8.x what JetBrains supplies from 1.9.22 on.

* + *

Why nothing else excuses the block either. A Kotlin BOM used to + * suppress it, on the reasoning that a BOM manages the whole + * {@code org.jetbrains.kotlin} group. That went the way of the plugin check + * and for the same measured reason: against a graph carrying billing 9.1.0 + * and appcompat 1.6.1, adding this block alongside {@code kotlin-bom:1.9.22} + * produced byte-identical resolution, because a BOM's constraints are not + * strict and the higher version simply wins. Alongside + * {@code kotlin-bom:1.7.22} it is not merely harmless but necessary -- a + * pre-merge BOM raises the jdk artifacts and cannot pull + * {@code kotlin-stdlib} back down, which is the duplicate. Removing the case + * also removes the question of whether a BOM declared inside an {@code if} + * block is in force, which no amount of reading the text can answer.

+ * *

Why a constraint and not a force. A constraint raises a version * and never lowers one, and never pulls a module into a graph that does not * already contain it. An app with no Kotlin anywhere is therefore completely @@ -151,35 +164,6 @@ public class KotlinStdlibAlignment { "kotlin-stdlib-jdk8" }; - /** - * The marker that can suppress the whole block rather than one artifact. - * A BOM manages every module in the {@code org.jetbrains.kotlin} group, - * jdk7 and jdk8 included, so a new enough one answers the question for - * both artifacts at once. - * - *

Only a new enough one. A BOM raises the jdk artifacts but - * cannot pull {@code kotlin-stdlib} back down, because a platform - * contributes constraints and the highest version still wins. So a - * pre-merge BOM leaves exactly the arrangement this class exists to - * prevent -- measured, with the same graph as the class comment's - * table:

- * - *
-     * no BOM           stdlib 1.8.22 + jdk7/jdk8 1.6.21   duplicate
-     * kotlin-bom 1.7.22  stdlib 1.8.22 + jdk7/jdk8 1.7.22   STILL a duplicate
-     * kotlin-bom 1.9.22  all 1.9.22, jdk artifacts shims    safe
-     * 
- * - *

The BOM is therefore tested by version, exactly like the Kotlin - * Gradle plugin above it, and for the same reason: presence is not - * alignment.

- */ - private static final String KOTLIN_BOM = "kotlin-bom"; - - /** The coordinate a BOM's version is read from. */ - private static final String KOTLIN_BOM_COORDINATE = - "org.jetbrains.kotlin:kotlin-bom:"; - /** The group every artifact this class reasons about belongs to. */ private static final String KOTLIN_GROUP = "org.jetbrains.kotlin"; @@ -207,11 +191,6 @@ public static String constraintsBlock(String configuration, return ""; } String config = configuration.trim(); - if (declaresArtifact(KOTLIN_BOM, config, appGradleFragments) - && atOrPastTheMerge(declaredVersion( - KOTLIN_BOM_COORDINATE, config, appGradleFragments))) { - return ""; - } // "because" is not decoration: it is what `gradle dependencyInsight` prints // next to the raised version, and this constraint is otherwise unattributable // to anything in the developer's project. @@ -236,72 +215,6 @@ && atOrPastTheMerge(declaredVersion( return " constraints {\n" + out + " }\n"; } - /** - * Whether a Kotlin version is at or past the release that merged the jdk - * artifacts away, and therefore aligns them wherever it is in force -- - * as the Gradle plugin's version or as a BOM's. - * - *

Unknown reads as false everywhere it is used. A version that cannot - * be parsed -- {@code kotlin-gradle-plugin:$kotlin_version} and - * {@code kotlin-bom:$kotlinVersion} both produce one -- must not silently - * switch the alignment off.

- */ - private static boolean atOrPastTheMerge(String kotlinVersion) { - if (kotlinVersion == null) { - return false; - } - // Shared with the Health Connect floor check rather than parsed again here: - // it already drops a qualifier, which rounds a prerelease up to its release - // and is the forgiving direction for a floor. - String numeric = HealthManifestFragments.numericVersionPrefix( - kotlinVersion.trim()); - if (numeric == null) { - return false; - } - return compareVersions(numeric, MERGED_STDLIB_FLOOR) >= 0; - } - - /** - * The version an app's own Gradle text declares immediately after - * {@code coordinate}, or null when it declares none there or writes one - * this cannot read -- a Gradle variable rather than a literal. - */ - private static String declaredVersion(String coordinate, String configuration, - String[] appGradleFragments) { - if (appGradleFragments == null) { - return null; - } - for (int i = 0; i < appGradleFragments.length; i++) { - // The same active text the declaration check reads, so a commented-out - // BOM cannot supply the version that suppresses the block. - String[] lines = activeLines(appGradleFragments[i]); - for (int j = 0; j < lines.length; j++) { - String fragment = lines[j]; - // Same configuration filter as the declaration check, so a debug-only - // BOM cannot supply the version that suppresses the main variant's - // constraints. - if (!declaresOnTheConstrainedConfiguration(configuration, fragment)) { - continue; - } - int at = fragment.indexOf(coordinate); - if (at < 0) { - continue; - } - int from = at + coordinate.length(); - int to = from; - while (to < fragment.length() - && "0123456789.".indexOf(fragment.charAt(to)) >= 0) { - to++; - } - while (to > from && fragment.charAt(to - 1) == '.') { - to--; - } - return to > from ? fragment.substring(from, to) : null; - } - } - return null; - } - /** * Whether the app actively declares this {@code org.jetbrains.kotlin} * artifact, rather than merely mentioning its name somewhere in a Gradle @@ -594,7 +507,15 @@ private static String[] statements(String text) { char c = text.charAt(i); if (quote != 0) { current.append(c); - if (c == quote) { + // Escapes, because the comment stripper beside this already handles + // them: 'can\'t' otherwise closes the string on the apostrophe it is + // escaping, and every following newline is read as being inside a + // string rather than ending a statement -- which merges statements + // that must stay apart. + if (c == '\\' && i + 1 < text.length()) { + current.append(text.charAt(i + 1)); + i++; + } else if (c == quote) { quote = 0; } continue; @@ -670,26 +591,4 @@ private static int braceBalance(String statement) { return depth; } - /** Numeric dotted version compare; a missing segment counts as zero. */ - private static int compareVersions(String left, String right) { - String[] l = left.split("\\."); - String[] r = right.split("\\."); - int len = Math.max(l.length, r.length); - for (int i = 0; i < len; i++) { - int a = i < l.length ? parse(l[i]) : 0; - int b = i < r.length ? parse(r[i]) : 0; - if (a != b) { - return a < b ? -1 : 1; - } - } - return 0; - } - - private static int parse(String segment) { - try { - return Integer.parseInt(segment); - } catch (NumberFormatException notANumber) { - return 0; - } - } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index dbea397cdb8..b5f58f1b027 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -154,59 +154,6 @@ public void pinningOneJdkArtifactLeavesTheOtherConstrained() { "an app managing both gets no block at all, not an empty one"); } - /** - * A BOM manages the whole {@code org.jetbrains.kotlin} group, jdk7 and - * jdk8 included, so a new enough one is the single marker that suppresses - * both constraints. - * - *

New enough is the whole point. A BOM raises the jdk artifacts but - * cannot pull {@code kotlin-stdlib} down -- a platform contributes - * constraints and the highest version still wins -- so a pre-merge BOM - * leaves a merged stdlib beside class-bearing jdk jars, which is the - * duplicate. Measured: kotlin-bom 1.7.22 against a graph wanting stdlib - * 1.8.22 resolves jdk7/jdk8 to 1.7.22, still class-bearing.

- */ - @Test - public void emitsNothingWhenTheAppUsesTheKotlinBom() { - check("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", - " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n")), - "an app using a merged-era Kotlin BOM is left alone"); - check("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", - " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')\n")), - "the BOM at the merge itself is enough"); - } - - /** - * The counterpart, and the reason the BOM is read by version rather than - * by presence: a pre-merge BOM does not make the graph safe, so it must - * not switch the block off. - */ - @Test - public void aPreMergeKotlinBomStillGetsTheAlignment() { - String out = KotlinStdlibAlignment.constraintsBlock( - "implementation", - " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.7.22')\n"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), - "a pre-merge BOM still gets jdk7 aligned"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a pre-merge BOM still gets jdk8 aligned"); - } - - /** - * A BOM whose version is a Gradle variable reads as unknown, and unknown - * must not suppress -- the same fail-safe the plugin version gets. - */ - @Test - public void anUnreadableBomVersionStillGetsTheAlignment() { - String out = KotlinStdlibAlignment.constraintsBlock( - "implementation", - " implementation platform(\"org.jetbrains.kotlin:kotlin-bom:$kotlinVersion\")\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "an unreadable BOM version still gets the alignment"); - } - /** * The builder has to hand over every app-controlled fragment that reaches * the generated dependencies block, not the ones that came to mind. @@ -596,6 +543,63 @@ public void aUrlIsNotAComment() { "the pin after a URL line is still seen"); } + /** + * A Kotlin BOM no longer excuses the block either, at any version. + * + *

Measured against a graph carrying billing 9.1.0 and appcompat 1.6.1: + * adding this block alongside kotlin-bom 1.9.22 gives byte-identical + * resolution, because a BOM's constraints are not strict and the higher + * version wins; alongside kotlin-bom 1.7.22 it is not merely harmless but + * necessary, since a pre-merge BOM raises the jdk artifacts and cannot + * pull kotlin-stdlib back down. Suppressing on a BOM was cosmetic where it + * fired and wrong where it did not.

+ */ + @Test + public void aKotlinBomNoLongerExcusesTheBlock() { + String modern = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n"); + check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), + "a modern BOM does not suppress, and does not need to"); + + String preMerge = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.7.22')\n"); + check(preMerge.contains("kotlin-stdlib-jdk8:1.8.0"), + "a pre-merge BOM still gets the alignment it needs"); + } + + /** + * And the case that removed the feature rather than patching it: a BOM + * declared inside a condition cannot be known to be in force by reading + * the text, so no reading of it decides anything any more. + */ + @Test + public void aConditionalBomDoesNotDecideAnything() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " if (project.hasProperty('useKotlinBom')) {\n" + + " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n" + + " }\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a conditional BOM leaves the alignment in place"); + } + + /** + * An escaped quote does not end a string. The statement scanner missed + * this while the comment stripper beside it handled it, so a string + * escaping its own apostrophe closed early and every following newline + * read as being inside a string -- merging statements that must stay + * apart, which lets a main-variant configuration token pair with a + * debug-only coordinate. + */ + @Test + public void anEscapedQuoteDoesNotEndAString() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def marker = 'can\\'t'\n" + + " implementation 'com.android.billingclient:billing:9.1.0'\n" + + " debugImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the debug-only pin does not borrow the main statement's configuration"); + } + /** * The fragments arrive straight from build hints, so an unset hint shows * up as an empty string and an absent one can be null. Neither is a From 3d40900bda6c242120febb754456be9627fa3a96 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:45:20 +0300 Subject: [PATCH 14/94] Tell Gradle syntax from English, and let a map entry breathe Three findings, all in the family this class keeps meeting: text that looks like syntax is not syntax, and syntax spelled differently is still syntax. `strictly` is now detected as a call rather than as a substring. A reason reading `because 'not strictly required outside debug'` is prose, and reading it as a version constraint let a variant-only dependency switch the alignment off for the release build -- the unsafe direction, and the one this class has been careful about everywhere else. The discriminator is the one already used for comment delimiters and statement separators: inside a string it is prose, outside it is syntax. The real call still suppresses, and has its own check beside the prose one so a future tightening cannot quietly disarm it. A Groovy map entry may have whitespace around its colon. `name : 'x'` is as valid as `name: 'x'`, and the exact substring match missed it -- which matters because the same declaration can carry a strict version, so missing it turns this block's constraint into a failed resolution rather than an override. Parsed properly now, in either quote style, and a different Kotlin artifact in the same shape still does not count. A quoted configuration name counts too, for Gradle's `dependencies.add("runtimeOnly", "group:artifact:version")` spelling: the token ends at a quote there rather than at a space or a parenthesis. Safe to accept, because a quoted configuration name only decides anything on a statement that also carries the artifact coordinate -- a statement carrying both is a declaration however it is spelled, and one carrying only the name is not. Five new cases. Each behaviour was checked by putting the old one back, and three of the five pin the directions these fixes could have gone too far. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 102 +++++++++++++++++- .../builders/KotlinStdlibAlignmentTest.java | 81 ++++++++++++++ 2 files changed, 178 insertions(+), 5 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 313d7e16242..c66d6ab6eba 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -285,7 +285,7 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat // fails the whole script at evaluation, which is a far larger blast radius // than the case it fixes. Revisit only with a project that actually has this // shape. - if (!line.contains("strictly") + if (!callsStrictly(line) && !declaresOnTheConstrainedConfiguration(configuration, line)) { return false; } @@ -293,9 +293,94 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat return true; } // group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8', version: '...' - return line.contains(KOTLIN_GROUP) - && (line.contains("name: '" + artifact + "'") - || line.contains("name: \"" + artifact + "\"")); + return line.contains(KOTLIN_GROUP) && declaresMapEntry(line, "name", artifact); + } + + /** + * Whether the statement calls Gradle's {@code strictly}, as opposed to + * merely containing the English word. + * + *

{@code because 'not strictly required outside debug'} is a reason + * string, not a version constraint, and reading it as one let a + * variant-only dependency switch the alignment off for the release build. + * The discriminator is the one already used for comment delimiters and + * statement separators: inside a string it is prose, outside it is + * syntax.

+ */ + private static boolean callsStrictly(String statement) { + char quote = 0; + for (int i = 0; i < statement.length(); i++) { + char c = statement.charAt(i); + if (quote != 0) { + if (c == '\\' && i + 1 < statement.length()) { + i++; + } else if (c == quote) { + quote = 0; + } + continue; + } + if (c == '\'' || c == '"') { + quote = c; + continue; + } + if (statement.startsWith(STRICTLY, i)) { + boolean startsToken = i == 0 + || !Character.isLetterOrDigit(statement.charAt(i - 1)); + int after = i + STRICTLY.length(); + boolean endsToken = after < statement.length() + && (statement.charAt(after) == ' ' + || statement.charAt(after) == '(' + || statement.charAt(after) == '\t'); + if (startsToken && endsToken) { + return true; + } + } + } + return false; + } + + private static final String STRICTLY = "strictly"; + + /** + * Whether the statement carries the Groovy map entry + * {@code key: 'value'}, with whatever spacing the author used. + * + *

{@code name : 'kotlin-stdlib-jdk8'} is as valid as + * {@code name: 'kotlin-stdlib-jdk8'}, and matching the exact substring + * missed it -- which matters because the same declaration can carry a + * strict version, and missing it turns this class's constraint into a + * failed resolution.

+ */ + private static boolean declaresMapEntry(String line, String key, String value) { + int at = line.indexOf(key); + while (at >= 0) { + boolean startsToken = at == 0 + || !Character.isLetterOrDigit(line.charAt(at - 1)); + if (startsToken) { + int i = skipBlanks(line, at + key.length()); + if (i < line.length() && line.charAt(i) == ':') { + i = skipBlanks(line, i + 1); + if (i < line.length() + && (line.charAt(i) == '\'' || line.charAt(i) == '"')) { + char q = line.charAt(i); + int end = line.indexOf(q, i + 1); + if (end > i && line.substring(i + 1, end).equals(value)) { + return true; + } + } + } + } + at = line.indexOf(key, at + 1); + } + return false; + } + + private static int skipBlanks(String line, int from) { + int i = from; + while (i < line.length() && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { + i++; + } + return i; } /** @@ -367,9 +452,16 @@ private static boolean declaresOn(String configuration, String line) { boolean startsToken = at == 0 || !Character.isLetterOrDigit(line.charAt(at - 1)); int after = at + configuration.length(); + // A closing quote ends the token too, for the map-free + // dependencies.add("runtimeOnly", "group:artifact:version") spelling. + // Safe to accept: a quoted configuration name only decides anything on a + // statement that also carries the artifact coordinate, and a statement + // carrying both is a declaration however it is spelled. boolean endsToken = after < line.length() && (line.charAt(after) == ' ' || line.charAt(after) == '(' - || line.charAt(after) == '\t'); + || line.charAt(after) == '\t' + || line.charAt(after) == '"' + || line.charAt(after) == '\''); if (startsToken && endsToken) { return true; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index b5f58f1b027..ce4f3b2583b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -325,6 +325,56 @@ public void anExclusionBeforeTheVersionBlockDoesNotHideTheStrictPin() { "and the same written across lines"); } + /** + * The English word is not the Gradle call. A reason string reading + * "not strictly required outside debug" is prose, and reading it as a + * strict version let a variant-only dependency switch the alignment off + * for the release build -- the unsafe direction. + */ + @Test + public void theWordStrictlyInsideAStringIsNotAStrictPin() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " debugImplementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ because 'not strictly required outside debug' }\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a reason mentioning the word does not suppress the release constraint"); + + // and the real call still does, so the tightening did not disarm it + String real = KotlinStdlibAlignment.constraintsBlock("implementation", + " debugImplementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check(!real.contains("kotlin-stdlib-jdk8:1.8.0"), + "an actual strict call is still honoured"); + } + + /** + * Groovy allows whitespace around a map entry's colon, and the exact + * substring match missed it. It matters because the same declaration can + * carry a strict version, and missing it turns the constraint into a + * failed resolution rather than an override. + */ + @Test + public void aMapEntryMayHaveSpaceAroundItsColon() { + String spaced = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation(group : 'org.jetbrains.kotlin', " + + "name : 'kotlin-stdlib-jdk8', version : '1.7.22')\n"); + check(!spaced.contains("kotlin-stdlib-jdk8:1.8.0"), + "a spaced map entry still pins jdk8"); + + String doubleQuoted = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation(group: \"org.jetbrains.kotlin\", " + + "name:\"kotlin-stdlib-jdk8\", version: \"1.7.22\")\n"); + check(!doubleQuoted.contains("kotlin-stdlib-jdk8:1.8.0"), + "and so does an unspaced double-quoted one"); + + // A different artifact in the same shape must still not count. + String other = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation(group : 'org.jetbrains.kotlin', " + + "name : 'kotlin-reflect', version : '1.9.22')\n"); + check(other.contains("kotlin-stdlib-jdk8:1.8.0"), + "naming a different Kotlin artifact does not pin jdk8"); + } + /** * The block absorbed for that check belongs to the declaration that opened * it and no further. A dependencies or android block must not swallow the @@ -341,6 +391,37 @@ public void anUnrelatedBlockDoesNotSwallowTheFragment() { "an unrelated strict block does not suppress, and the debug BOM still does not"); } + /** + * The quoted spelling counts as well. Gradle's + * {@code dependencies.add("runtimeOnly", "group:artifact:version")} names + * the configuration as a string, so the token ends at a quote rather than + * a space or a parenthesis, and the escape hatch has to recognise it for + * the same reason it recognises the others. + */ + @Test + public void theQuotedAddSpellingCountsAsAPin() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " dependencies.add(\"runtimeOnly\", " + + "\"org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22\")\n"); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a quoted configuration name still pins jdk8"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and leaves jdk7 constrained"); + } + + /** + * A quoted configuration name on its own decides nothing, because it takes + * the artifact coordinate on the same statement to make a declaration. + */ + @Test + public void aQuotedConfigurationNameAloneIsNotAPin() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def cfg = \"runtimeOnly\"\n" + + " implementation 'com.example:thing:1.0'\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "naming a configuration in a string does not suppress anything"); + } + /** * And their variant and test forms still do not, which is the property the * whole-token lowercase match buys without listing a single variant name. From 3b26c9d73dd8a15489484876c203b00a49d1ac9b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:22:30 +0300 Subject: [PATCH 15/94] Honour a strict pin on the merged stdlib, and say what is not being fixed One finding taken, two not, and a decision left where it belongs. A strict pin on kotlin-stdlib ITSELF blocks both shims, not one. The shim at this floor depends on kotlin-stdlib at the same floor, so an app strictly holding the base library below it cannot resolve either constraint -- and the pre-merge family it is holding had no duplicate to begin with. Constraining there converts a working build into Could not resolve org.jetbrains.kotlin:kotlin-stdlib-jdk8:{strictly 1.7.22} which is the one outcome this class must never produce. The base match is exact, because kotlin-stdlib is a prefix of kotlin-stdlib-jdk8 and a loose match would read every shim declaration as a pin on the base library and disable the block entirely; there is a case for that, and one confirming a strict pin at or above the floor still gets the constraints, since a shim requiring 1.8.0 is satisfied by a strict 1.9.22. Not taken: gating on the constraints DSL instead of android.useAndroidX. The premise is that the builder selects `implementation` for every Gradle 6 build, and it does not -- `compile` becomes "implementation" only when useAndroidX or the aar implementation flag is set, so a useAndroidX=false build would take this block on the legacy `compile` configuration. The failing case it is meant to protect also needs a modern AndroidX dependency inside a project with AndroidX switched off, which AGP refuses for its own reasons first. The reasoning is on the gate now rather than only in a thread. Not taken here, and stated rather than quietly ignored: a declaration inside `if (project.hasProperty('x'))` is treated as present. Whether it is in force is decided by Gradle at evaluation time and cannot be read out of the text, so the choice is between honouring the documented promise at the cost of leaving a duplicate the app already had, and suppressing only on a strict version. The second is the better rule -- it is the only declaration a constraint cannot coexist with, and it would take the configuration filtering, the spelling variants and this hazard with it -- but it changes what the build hint's documentation promises, so it is the project's call and not something to slip in under a review thread. It was written and reverted here rather than guessed at: twelve existing cases assert the current behaviour, which is the measure of how much of this file that decision governs. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 11 +- .../builders/KotlinStdlibAlignment.java | 118 ++++++++++++++++++ .../builders/KotlinStdlibAlignmentTest.java | 41 ++++++ 3 files changed, 168 insertions(+), 2 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index ca793297e51..1041e057f11 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7278,8 +7278,15 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // adds nothing to a graph that has no Kotlin in it, so an app that could // never hit the clash resolves exactly as it did before. // - // Gated on AndroidX because the block is written on the implementation - // configuration, and on Gradle 6 rather than on 4.6 where the constraints + // Gated on AndroidX because that is what decides the configuration name a few + // lines below: `compile` is only "implementation" when useAndroidX or the aar + // implementation flag is set, so a useAndroidX=false build would take this + // block on the legacy `compile` configuration. Reviewed as an unrelated flag + // to gate on -- it is not, and the failing case it is meant to protect needs + // a modern AndroidX dependency in a project that has AndroidX turned off, + // which AGP refuses for its own reasons before this could matter. + // + // On Gradle 6 rather than on 4.6 where the constraints // DSL first appeared. That is deliberate, and it has been questioned in // review, so: 4.6 selects AGP 3.2.0, which cannot compile against a // compileSdk the current AndroidX releases require, and the builder gives diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index c66d6ab6eba..ec9e7682606 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -167,6 +167,9 @@ public class KotlinStdlibAlignment { /** The group every artifact this class reasons about belongs to. */ private static final String KOTLIN_GROUP = "org.jetbrains.kotlin"; + /** The merged library both shims depend on at the floor. */ + private static final String BASE_STDLIB = "kotlin-stdlib"; + private KotlinStdlibAlignment() { } @@ -198,6 +201,16 @@ public static String constraintsBlock(String configuration, + " absorbed the jdk7/jdk8 classes and the 1.8.x line ships no " + "Gradle module metadata to say so, so these are raised to the " + "empty shims to avoid a duplicate class in checkDuplicateClasses"; + // A strict pin on the merged library itself blocks BOTH shims, because the + // shim at this floor depends on kotlin-stdlib at the same floor. An app + // strictly holding kotlin-stdlib below it therefore cannot resolve either + // constraint, and the pre-merge family it is holding had no duplicate to + // begin with -- so constraining there converts a working build into + // "Could not resolve ... {strictly 1.7.22}", which is the one outcome this + // class must never produce. + if (strictlyPinsBaseStdlibBelowTheFloor(appGradleFragments)) { + return ""; + } StringBuilder out = new StringBuilder(); for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { if (declaresArtifact(ALIGNED_ARTIFACTS[i], config, appGradleFragments)) { @@ -215,6 +228,101 @@ public static String constraintsBlock(String configuration, return " constraints {\n" + out + " }\n"; } + /** + * Whether the app strictly holds {@code kotlin-stdlib} itself below the + * floor both shims depend on. + * + *

The artifact has to be matched exactly. {@code kotlin-stdlib} is a + * prefix of {@code kotlin-stdlib-jdk8}, so a loose match would read every + * shim declaration as a pin on the base library and switch the whole block + * off. The character after the coordinate decides it: a colon starts the + * version and a quote ends the coordinate, while anything else -- a + * hyphen above all -- means this is a longer artifact name.

+ * + *

An unreadable strict version counts as below the floor, because the + * failure it guards against cannot be worked around by the app while the + * duplicate class it risks instead can.

+ */ + private static boolean strictlyPinsBaseStdlibBelowTheFloor(String[] appGradleFragments) { + if (appGradleFragments == null) { + return false; + } + for (int i = 0; i < appGradleFragments.length; i++) { + String[] lines = activeLines(appGradleFragments[i]); + for (int j = 0; j < lines.length; j++) { + if (!callsStrictly(lines[j]) || !namesBaseStdlib(lines[j])) { + continue; + } + String version = strictVersionIn(lines[j]); + if (version == null + || compareVersions(version, MERGED_STDLIB_FLOOR) < 0) { + return true; + } + } + } + return false; + } + + /** Whether the statement names {@code kotlin-stdlib} and not a longer name. */ + private static boolean namesBaseStdlib(String line) { + String coordinate = KOTLIN_GROUP + ":" + BASE_STDLIB; + int at = line.indexOf(coordinate); + while (at >= 0) { + int after = at + coordinate.length(); + if (after < line.length() + && (line.charAt(after) == ':' || line.charAt(after) == '\'' + || line.charAt(after) == '"')) { + return true; + } + at = line.indexOf(coordinate, at + 1); + } + return line.contains(KOTLIN_GROUP) && declaresMapEntry(line, "name", BASE_STDLIB); + } + + /** The version inside this statement's {@code strictly} call, or null. */ + private static String strictVersionIn(String statement) { + int at = statement.indexOf(STRICTLY); + while (at >= 0) { + int i = skipBlanks(statement, at + STRICTLY.length()); + if (i < statement.length() && statement.charAt(i) == '(') { + i = skipBlanks(statement, i + 1); + } + if (i < statement.length() + && (statement.charAt(i) == '\'' || statement.charAt(i) == '"')) { + char q = statement.charAt(i); + int end = statement.indexOf(q, i + 1); + if (end > i) { + return statement.substring(i + 1, end); + } + } + at = statement.indexOf(STRICTLY, at + 1); + } + return null; + } + + /** Numeric dotted version compare; a missing segment counts as zero. */ + private static int compareVersions(String left, String right) { + String[] l = left.split("\\."); + String[] r = right.split("\\."); + int len = Math.max(l.length, r.length); + for (int i = 0; i < len; i++) { + int a = i < l.length ? parseSegment(l[i]) : 0; + int b = i < r.length ? parseSegment(r[i]) : 0; + if (a != b) { + return a < b ? -1 : 1; + } + } + return 0; + } + + private static int parseSegment(String segment) { + try { + return Integer.parseInt(segment); + } catch (NumberFormatException notANumber) { + return 0; + } + } + /** * Whether the app actively declares this {@code org.jetbrains.kotlin} * artifact, rather than merely mentioning its name somewhere in a Gradle @@ -236,6 +344,16 @@ public static String constraintsBlock(String configuration, * *

Two spellings count as a declaration -- the colon-joined coordinate * and the map form -- because those are what a pin is actually written as. + * A declaration inside {@code if (project.hasProperty('x'))} counts as + * present, deliberately: whether it is in force is decided by Gradle at + * evaluation time and cannot be read out of the text. Treating it as + * present honours the documented promise at the cost of leaving a + * duplicate the app already had; the alternative -- suppressing only on a + * strict version, which is the one declaration a constraint cannot coexist + * with -- removes that hazard along with everything else in this method, + * and is a documented behaviour change rather than a bug fix, so it is a + * decision for the project rather than something to slip in under a review + * thread. * Anything else falls through to "not declared", which is the safe * direction: emitting a constraint the app did not need only raises an * artifact to a shim, while skipping one it did need fails the build.

diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index ce4f3b2583b..5f786016d86 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -375,6 +375,47 @@ public void aMapEntryMayHaveSpaceAroundItsColon() { "naming a different Kotlin artifact does not pin jdk8"); } + /** + * A strict pin on kotlin-stdlib itself blocks both shims, not one. The + * shim at this floor depends on kotlin-stdlib at the same floor, so an app + * strictly holding the base library below it cannot resolve either + * constraint -- and the pre-merge family it is holding had no duplicate to + * begin with, so constraining there turns a working build into + * "Could not resolve ... {strictly 1.7.22}". + */ + @Test + public void aStrictPinOnTheBaseStdlibBlocksBothShims() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(out), + "a strict pre-merge base pin writes no constraints at all"); + + // At or above the floor there is no conflict, so the block still goes in: + // a shim requiring 1.8.0 is satisfied by a strict 1.9.22. + String modern = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.9.22') " + + "{ version { strictly '1.9.22' } }\n"); + check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), + "a strict modern base pin does not need the block suppressed"); + } + + /** + * kotlin-stdlib is a prefix of kotlin-stdlib-jdk8, so the base match has to + * be exact. A loose one would read every shim declaration as a pin on the + * base library and switch the whole block off. + */ + @Test + public void aStrictShimPinIsNotAPinOnTheBaseStdlib() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), + "pinning the jdk8 shim leaves jdk7 constrained, not the whole block off"); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), + "and jdk8 itself is left to the app"); + } + /** * The block absorbed for that check belongs to the declaration that opened * it and no further. A dependencies or android block must not swallow the From 4ac657cb79e8bde66f8e28286e55468d6823518e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:35:00 +0300 Subject: [PATCH 16/94] A prerelease of the floor is below it, and a reason string is not a declaration Two findings, the second of them caused by the fix for an earlier one. 1.8.0-RC2 is a published Kotlin version whose numeric part compares equal to 1.8.0, so a strict pin on it read as "at the floor" and the block was written -- whereupon the shims request the FINAL 1.8.0, which cannot coexist with the strict prerelease, and a resolvable graph stops resolving. A qualifier at the floor now counts as below it. A qualifier anywhere else is still ignored, since rounding 1.9.22-RC up to 1.9.22 keeps it above the floor either way. The second is worth naming as such: two rounds ago a quoted configuration name was accepted so that dependencies.add("runtimeOnly", "...") would be recognised, and that widening let the word inside `because 'implementation workaround'` read as a main-variant declaration -- suppressing the constraint for a dependency that only affects debug. A quoted configuration name now counts in exactly one position, as the first argument of an add() call, and the unquoted form is matched only outside strings. Both spellings have a case, and the add() case was already there to catch this going wrong in the other direction. Four new cases, both behaviours checked by putting the old ones back. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 113 ++++++++++++++---- .../builders/KotlinStdlibAlignmentTest.java | 43 +++++++ 2 files changed, 135 insertions(+), 21 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index ec9e7682606..65aa73325ac 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -253,9 +253,7 @@ private static boolean strictlyPinsBaseStdlibBelowTheFloor(String[] appGradleFra if (!callsStrictly(lines[j]) || !namesBaseStdlib(lines[j])) { continue; } - String version = strictVersionIn(lines[j]); - if (version == null - || compareVersions(version, MERGED_STDLIB_FLOOR) < 0) { + if (belowTheFloor(strictVersionIn(lines[j]))) { return true; } } @@ -300,6 +298,43 @@ private static String strictVersionIn(String statement) { return null; } + /** + * Whether a strict version is below the floor the shims depend on. + * + *

A prerelease of the floor is below it. {@code 1.8.0-RC2} is a + * published Kotlin version, and its numeric part compares equal to + * {@code 1.8.0} -- so without this it read as "at the floor" and the block + * was written, whereupon the shims request the FINAL 1.8.0 and cannot + * coexist with the app's strict prerelease. A qualifier on any other + * version is ignored, because rounding {@code 1.9.22-RC} up to + * {@code 1.9.22} keeps it above the floor either way.

+ * + *

Unreadable counts as below, because the failure it guards against + * cannot be worked around by the app while the duplicate class it risks + * instead can.

+ */ + private static boolean belowTheFloor(String version) { + if (version == null) { + return true; + } + int compared = compareVersions(version, MERGED_STDLIB_FLOOR); + if (compared != 0) { + return compared < 0; + } + return hasQualifier(version); + } + + /** Whether the version carries anything after its numeric segments. */ + private static boolean hasQualifier(String version) { + for (int i = 0; i < version.length(); i++) { + char c = version.charAt(i); + if (c != '.' && !Character.isDigit(c)) { + return true; + } + } + return false; + } + /** Numeric dotted version compare; a missing segment counts as zero. */ private static int compareVersions(String left, String right) { String[] l = left.split("\\."); @@ -565,29 +600,65 @@ private static boolean declaresOnTheConstrainedConfiguration(String configuratio /** Whether this line declares on {@code configuration}, as a whole token. */ private static boolean declaresOn(String configuration, String line) { - int at = line.indexOf(configuration); - while (at >= 0) { - boolean startsToken = at == 0 - || !Character.isLetterOrDigit(line.charAt(at - 1)); - int after = at + configuration.length(); - // A closing quote ends the token too, for the map-free - // dependencies.add("runtimeOnly", "group:artifact:version") spelling. - // Safe to accept: a quoted configuration name only decides anything on a - // statement that also carries the artifact coordinate, and a statement - // carrying both is a declaration however it is spelled. - boolean endsToken = after < line.length() - && (line.charAt(after) == ' ' || line.charAt(after) == '(' - || line.charAt(after) == '\t' - || line.charAt(after) == '"' - || line.charAt(after) == '\''); - if (startsToken && endsToken) { - return true; + char quote = 0; + int stringStart = -1; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (quote != 0) { + if (c == '\\' && i + 1 < line.length()) { + i++; + } else if (c == quote) { + // A configuration name inside a string counts in one place only: + // as the first argument of dependencies.add("runtimeOnly", ".."). + // Accepting any quoted occurrence read the word in a reason -- + // because 'implementation workaround' -- as a main-variant + // declaration, which suppressed the constraint for a dependency + // that only affects debug. + if (line.substring(stringStart + 1, i).equals(configuration) + && isAddCallArgument(line, stringStart)) { + return true; + } + quote = 0; + } + continue; + } + if (c == '\'' || c == '"') { + quote = c; + stringStart = i; + continue; + } + if (line.startsWith(configuration, i)) { + boolean startsToken = i == 0 + || !Character.isLetterOrDigit(line.charAt(i - 1)); + int after = i + configuration.length(); + boolean endsToken = after < line.length() + && (line.charAt(after) == ' ' || line.charAt(after) == '(' + || line.charAt(after) == '\t'); + if (startsToken && endsToken) { + return true; + } } - at = line.indexOf(configuration, at + 1); } return false; } + /** Whether the string literal opening at {@code quoteAt} is an add() argument. */ + private static boolean isAddCallArgument(String line, int quoteAt) { + int i = quoteAt - 1; + while (i >= 0 && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { + i--; + } + if (i < 0 || line.charAt(i) != '(') { + return false; + } + i--; + while (i >= 0 && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { + i--; + } + return i >= 2 && "add".equals(line.substring(i - 2, i + 1)) + && (i - 3 < 0 || !Character.isLetterOrDigit(line.charAt(i - 3))); + } + /** * A fragment's lines with comments removed and exclusions dropped -- the * text that actually declares something. diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 5f786016d86..93a24a7cad3 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -400,6 +400,49 @@ public void aStrictPinOnTheBaseStdlibBlocksBothShims() { "a strict modern base pin does not need the block suppressed"); } + /** + * A prerelease of the floor is below the floor. 1.8.0-RC2 is a published + * Kotlin version whose numeric part compares equal to 1.8.0, so it read as + * "at the floor" and the block was written -- whereupon the shims request + * the FINAL 1.8.0 and cannot coexist with the strict prerelease. + */ + @Test + public void aPrereleaseOfTheFloorCountsAsBelowIt() { + String rc = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.8.0-RC2') " + + "{ version { strictly '1.8.0-RC2' } }\n"); + check("".equals(rc), "a strict prerelease of the floor suppresses the block"); + + // A qualifier above the floor changes nothing: rounding up keeps it above. + String later = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.9.22-RC') " + + "{ version { strictly '1.9.22-RC' } }\n"); + check(later.contains("kotlin-stdlib-jdk8:1.8.0"), + "a prerelease above the floor still gets the constraints"); + } + + /** + * A configuration name inside a reason string is prose. Accepting any + * quoted occurrence -- which the dependencies.add spelling needed -- read + * `because 'implementation workaround'` as a main-variant declaration and + * suppressed the constraint for a dependency affecting only debug. + */ + @Test + public void aConfigurationNameInAReasonStringIsNotADeclaration() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " debugImplementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ because 'implementation workaround' }\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a reason mentioning the configuration does not make it a declaration"); + + // and the add() spelling it was widened for still works + String add = KotlinStdlibAlignment.constraintsBlock("implementation", + " dependencies.add(\"runtimeOnly\", " + + "\"org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22\")\n"); + check(!add.contains("kotlin-stdlib-jdk8:1.8.0"), + "the add() spelling is still recognised"); + } + /** * kotlin-stdlib is a prefix of kotlin-stdlib-jdk8, so the base match has to * be exact. A loose one would read every shim declaration as a pin on the From 98d576950c2914996f3c2e544471fb137225c522 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:44:54 +0300 Subject: [PATCH 17/94] Read the declaration Gradle reads: next-line closures, prose, qualified segments Three findings, all measured rather than reasoned about. A trailing closure may sit on the line AFTER the call's closing parenthesis. That looked like invalid Groovy, so it was checked rather than dismissed: Gradle accepts it, and the `strictly` inside really does apply -- confirmed by putting a competing higher requirement beside it and watching resolution fail with "Could not resolve ... {strictly 1.7.22}". The parenthesis depth is already back to zero at that newline, so the closure landed in its own statement and the version it carried was never associated with the coordinate above it. A statement that is nothing but the start of a closure now joins the one before it, and only for a statement that already names the Kotlin group. A coordinate inside a reason is prose. `because 'avoid org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'` was read as a declaration and dropped the constraint for an artifact nobody had pinned. "Outside a string" cannot be the test here the way it is for `strictly` or a configuration name, because a coordinate lives in a string -- what separates them is that a declaration's string OPENS with the coordinate while prose merely contains it. That also subsumes the exact-artifact rule the base-stdlib check needed, so both now go through one matcher. A qualified version segment keeps its number. Reading `20-RC` as zero made 1.8.20-RC compare equal to the 1.8.0 floor, and the qualifier rule then classified a version well ABOVE the floor as below it -- suppressing an alignment that was needed. The prerelease OF the floor is still below it, and both directions have a case. Six new cases; all three behaviours checked by putting the old ones back. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 89 ++++++++++++++++--- .../builders/KotlinStdlibAlignmentTest.java | 62 +++++++++++++ 2 files changed, 138 insertions(+), 13 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 65aa73325ac..c6954b03550 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -263,18 +263,52 @@ private static boolean strictlyPinsBaseStdlibBelowTheFloor(String[] appGradleFra /** Whether the statement names {@code kotlin-stdlib} and not a longer name. */ private static boolean namesBaseStdlib(String line) { - String coordinate = KOTLIN_GROUP + ":" + BASE_STDLIB; - int at = line.indexOf(coordinate); - while (at >= 0) { - int after = at + coordinate.length(); - if (after < line.length() - && (line.charAt(after) == ':' || line.charAt(after) == '\'' - || line.charAt(after) == '"')) { - return true; + return namesCoordinate(line, BASE_STDLIB) + || (line.contains(KOTLIN_GROUP) + && declaresMapEntry(line, "name", BASE_STDLIB)); + } + + /** + * Whether a string literal in this statement IS the dependency notation + * for {@code artifact}, rather than merely mentioning it. + * + *

A coordinate lives inside a string, so "outside a string" cannot be + * the test the way it is for {@code strictly} or a configuration name. + * What separates the two is where in the string it sits: + * {@code 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'} opens with it, + * while {@code because 'avoid org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'} + * is prose that happens to contain it -- and reading that prose as a + * declaration dropped the constraint for an artifact nobody had pinned.

+ * + *

The artifact is matched exactly: {@code kotlin-stdlib} is a prefix of + * {@code kotlin-stdlib-jdk8}, so what follows the name has to be the + * version separator or the end of the literal.

+ */ + private static boolean namesCoordinate(String line, String artifact) { + String coordinate = KOTLIN_GROUP + ":" + artifact; + char quote = 0; + int stringStart = -1; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (quote != 0) { + if (c == '\\' && i + 1 < line.length()) { + i++; + } else if (c == quote) { + String literal = line.substring(stringStart + 1, i); + if (literal.equals(coordinate) + || literal.startsWith(coordinate + ":")) { + return true; + } + quote = 0; + } + continue; + } + if (c == '\'' || c == '"') { + quote = c; + stringStart = i; } - at = line.indexOf(coordinate, at + 1); } - return line.contains(KOTLIN_GROUP) && declaresMapEntry(line, "name", BASE_STDLIB); + return false; } /** The version inside this statement's {@code strictly} call, or null. */ @@ -350,10 +384,23 @@ private static int compareVersions(String left, String right) { return 0; } + /** + * A version segment's leading digits. {@code 20-RC} is 20, not zero: + * reading it as zero made {@code 1.8.20-RC} compare equal to the 1.8.0 + * floor, and the qualifier rule then classified a version well ABOVE the + * floor as below it, suppressing an alignment that was needed. + */ private static int parseSegment(String segment) { + int to = 0; + while (to < segment.length() && Character.isDigit(segment.charAt(to))) { + to++; + } + if (to == 0) { + return 0; + } try { - return Integer.parseInt(segment); - } catch (NumberFormatException notANumber) { + return Integer.parseInt(segment.substring(0, to)); + } catch (NumberFormatException tooLong) { return 0; } } @@ -442,7 +489,7 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat && !declaresOnTheConstrainedConfiguration(configuration, line)) { return false; } - if (line.contains(KOTLIN_GROUP + ":" + artifact)) { + if (namesCoordinate(line, artifact)) { return true; } // group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8', version: '...' @@ -839,6 +886,16 @@ private static String[] statements(String text) { for (int i = 0; i < out.size(); i++) { String statement = out.get(i); if (statement.contains(KOTLIN_GROUP)) { + // A trailing closure may sit on the line AFTER the call's closing + // parenthesis -- Gradle accepts it and the strictly inside really does + // apply, checked by watching a competing higher requirement fail + // against it. The parenthesis depth is already back to zero there, so + // without this the closure lands in its own statement and the version + // it carries is never associated with the coordinate above it. + while (i + 1 < out.size() && opensAClosure(out.get(i + 1))) { + i++; + statement = statement + " " + out.get(i); + } int braces = braceBalance(statement); while (braces > 0 && i + 1 < out.size()) { i++; @@ -851,6 +908,12 @@ private static String[] statements(String text) { return merged.toArray(new String[merged.size()]); } + /** Whether the statement is nothing but the start of a closure. */ + private static boolean opensAClosure(String statement) { + String trimmed = statement.trim(); + return trimmed.startsWith("{"); + } + /** How far a statement opens or closes braces, ignoring those in strings. */ private static int braceBalance(String statement) { int depth = 0; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 93a24a7cad3..6e0f3d607f9 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -400,6 +400,68 @@ public void aStrictPinOnTheBaseStdlibBlocksBothShims() { "a strict modern base pin does not need the block suppressed"); } + /** + * A trailing closure may sit on the line after the call's closing + * parenthesis. Gradle accepts it and the {@code strictly} inside really + * does apply -- checked by watching a competing higher requirement fail + * against it -- but the parenthesis depth is back to zero there, so the + * closure landed in its own statement and its version was never + * associated with the coordinate above it. + */ + @Test + public void aTrailingClosureOnTheNextLineBelongsToTheDeclaration() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation(\n" + + " 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" + + " )\n" + + " { version { strictly '1.7.22' } }\n"); + check("".equals(out), + "the strict pin in a next-line closure still suppresses the block"); + } + + /** + * A coordinate inside a reason is prose. A coordinate lives in a string, + * so "outside a string" cannot be the test here the way it is for + * strictly -- what separates them is that a declaration's string OPENS + * with the coordinate while prose merely contains it. + */ + @Test + public void aCoordinateInsideAReasonIsNotADeclaration() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('com.example:other:1.0') " + + "{ because 'avoid org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22' }\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a coordinate mentioned in a reason does not count as a pin"); + + // the real notation still does + String real = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); + check(!real.contains("kotlin-stdlib-jdk8:1.8.0"), + "the dependency notation itself still counts"); + } + + /** + * A qualified segment keeps its number. Reading {@code 20-RC} as zero made + * 1.8.20-RC compare equal to the floor, and the qualifier rule then + * classified a version well ABOVE the floor as below it -- suppressing an + * alignment that was needed. + */ + @Test + public void aQualifiedSegmentKeepsItsNumber() { + String above = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.8.20-RC') " + + "{ version { strictly '1.8.20-RC' } }\n"); + check(above.contains("kotlin-stdlib-jdk8:1.8.0"), + "a prerelease above the floor still gets the constraints"); + + // and the prerelease OF the floor is still below it + String atTheFloor = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.8.0-RC2') " + + "{ version { strictly '1.8.0-RC2' } }\n"); + check("".equals(atTheFloor), + "a prerelease of the floor is still below it"); + } + /** * A prerelease of the floor is below the floor. 1.8.0-RC2 is a published * Kotlin version whose numeric part compares equal to 1.8.0, so it read as From f9ae6ff486ce22c1aacb256ac29990903ba60d4b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:49:14 +0300 Subject: [PATCH 18/94] An underscore is part of an identifier A configuration named custom_implementation ended its embedded "implementation" on a boundary that looked clean, because isLetterOrDigit says an underscore is neither -- so it read as the main configuration and suppressed a constraint for a configuration that reaches nothing. The same held for implementation_extra and for every other token this file bounds: the check is shared by the configuration match, the strictly call, the map key and the add() call, and all four were wrong in the same way. Groovy identifier characters are now rejected on both sides of every token. Three cases: the prefixed form, the suffixed form, and the real configuration still counting, checked by putting isLetterOrDigit back. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 21 ++++++++++++--- .../builders/KotlinStdlibAlignmentTest.java | 27 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index c6954b03550..754fc96e156 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -525,7 +525,7 @@ private static boolean callsStrictly(String statement) { } if (statement.startsWith(STRICTLY, i)) { boolean startsToken = i == 0 - || !Character.isLetterOrDigit(statement.charAt(i - 1)); + || !isIdentifierChar(statement.charAt(i - 1)); int after = i + STRICTLY.length(); boolean endsToken = after < statement.length() && (statement.charAt(after) == ' ' @@ -555,7 +555,7 @@ private static boolean declaresMapEntry(String line, String key, String value) { int at = line.indexOf(key); while (at >= 0) { boolean startsToken = at == 0 - || !Character.isLetterOrDigit(line.charAt(at - 1)); + || !isIdentifierChar(line.charAt(at - 1)); if (startsToken) { int i = skipBlanks(line, at + key.length()); if (i < line.length() && line.charAt(i) == ':') { @@ -575,6 +575,19 @@ private static boolean declaresMapEntry(String line, String key, String value) { return false; } + /** + * Whether this character can be part of a Groovy identifier. + * + *

Not {@code isLetterOrDigit}: an underscore is neither, so a + * configuration called {@code custom_implementation} ended its embedded + * {@code implementation} on a boundary that looked clean and was read as + * the main configuration -- suppressing a constraint for a configuration + * that reaches nothing.

+ */ + private static boolean isIdentifierChar(char c) { + return Character.isLetterOrDigit(c) || c == '_' || c == '$'; + } + private static int skipBlanks(String line, int from) { int i = from; while (i < line.length() && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { @@ -676,7 +689,7 @@ && isAddCallArgument(line, stringStart)) { } if (line.startsWith(configuration, i)) { boolean startsToken = i == 0 - || !Character.isLetterOrDigit(line.charAt(i - 1)); + || !isIdentifierChar(line.charAt(i - 1)); int after = i + configuration.length(); boolean endsToken = after < line.length() && (line.charAt(after) == ' ' || line.charAt(after) == '(' @@ -703,7 +716,7 @@ private static boolean isAddCallArgument(String line, int quoteAt) { i--; } return i >= 2 && "add".equals(line.substring(i - 2, i + 1)) - && (i - 3 < 0 || !Character.isLetterOrDigit(line.charAt(i - 3))); + && (i - 3 < 0 || !isIdentifierChar(line.charAt(i - 3))); } /** diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 6e0f3d607f9..cd7eb32994a 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -400,6 +400,33 @@ public void aStrictPinOnTheBaseStdlibBlocksBothShims() { "a strict modern base pin does not need the block suppressed"); } + /** + * An underscore is an identifier character. A configuration named + * custom_implementation ended its embedded "implementation" on a boundary + * that looked clean, so it read as the main configuration and suppressed a + * constraint for a configuration that reaches nothing. + */ + @Test + public void aCustomConfigurationIsNotTheMainOne() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " custom_implementation " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "custom_implementation is not the configuration being constrained"); + + String suffixed = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation_extra " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); + check(suffixed.contains("kotlin-stdlib-jdk8:1.8.0"), + "nor is implementation_extra"); + + // and the real one still is + String real = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); + check(!real.contains("kotlin-stdlib-jdk8:1.8.0"), + "the main configuration still counts"); + } + /** * A trailing closure may sit on the line after the call's closing * parenthesis. Gradle accepts it and the {@code strictly} inside really From a141d811b212e47b9307f78b6c37240dddf3671a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:00:36 +0300 Subject: [PATCH 19/94] One rule for walking a string literal, since several had drifted apart Two findings, and they are the same finding twice: this class had grown several private copies of "walk to the closing quote", some honouring backslash escapes and some not, and every divergence became a defect. strictVersionIn found the strictly call correctly and then read the version with a plain search, so a reason like because "strictly '1.7.22' is not intended" supplied the number that decided whether the block was written braceBalance stopped at the apostrophe inside 'can\'t', ignored the declaration's real closing brace, and merged the following dependency into the Kotlin statement -- where an unrelated strict pin then read as one on kotlin-stdlib Both now call one endOfStringLiteral, as do callsStrictly and the brace counter, so a fix reaches all of them rather than the one that was reported. strictVersionIn also matches the token the way callsStrictly does instead of by substring, so the two cannot disagree about which call they are looking at. Two new cases, and the second of them was wrong on the first attempt: it asserted jdk8 stayed constrained, which stays true whether or not the statements merge, so it passed with the bug reinstated. It now names the base stdlib without a strict version beside an unrelated strict pin, where merging makes the whole block disappear -- and it fails when the escape handling is removed, which is the only evidence that a case is worth having. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 85 ++++++++++++------- .../builders/KotlinStdlibAlignmentTest.java | 39 +++++++++ 2 files changed, 95 insertions(+), 29 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 754fc96e156..dd781db3ecb 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -313,21 +313,37 @@ private static boolean namesCoordinate(String line, String artifact) { /** The version inside this statement's {@code strictly} call, or null. */ private static String strictVersionIn(String statement) { - int at = statement.indexOf(STRICTLY); - while (at >= 0) { - int i = skipBlanks(statement, at + STRICTLY.length()); - if (i < statement.length() && statement.charAt(i) == '(') { - i = skipBlanks(statement, i + 1); + // The same syntax-level call callsStrictly validated, not any occurrence of + // the word: a reason reading `because "strictly '1.7.22' is not intended"` + // otherwise supplies the version for a declaration whose real strict version + // is something else entirely, and the wrong one decides whether the block is + // written. + for (int i = 0; i < statement.length(); i++) { + char c = statement.charAt(i); + if (c == '\'' || c == '"') { + i = endOfStringLiteral(statement, i); + continue; + } + if (!statement.startsWith(STRICTLY, i)) { + continue; + } + boolean startsToken = i == 0 || !isIdentifierChar(statement.charAt(i - 1)); + if (!startsToken) { + continue; } - if (i < statement.length() - && (statement.charAt(i) == '\'' || statement.charAt(i) == '"')) { - char q = statement.charAt(i); - int end = statement.indexOf(q, i + 1); - if (end > i) { - return statement.substring(i + 1, end); + int after = skipBlanks(statement, i + STRICTLY.length()); + if (after < statement.length() && statement.charAt(after) == '(') { + after = skipBlanks(statement, after + 1); + } + if (after < statement.length() + && (statement.charAt(after) == '\'' + || statement.charAt(after) == '"')) { + int end = endOfStringLiteral(statement, after); + if (end < statement.length()) { + return statement.substring(after + 1, end); } } - at = statement.indexOf(STRICTLY, at + 1); + i = after; } return null; } @@ -508,19 +524,10 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat * syntax.

*/ private static boolean callsStrictly(String statement) { - char quote = 0; for (int i = 0; i < statement.length(); i++) { char c = statement.charAt(i); - if (quote != 0) { - if (c == '\\' && i + 1 < statement.length()) { - i++; - } else if (c == quote) { - quote = 0; - } - continue; - } if (c == '\'' || c == '"') { - quote = c; + i = endOfStringLiteral(statement, i); continue; } if (statement.startsWith(STRICTLY, i)) { @@ -588,6 +595,31 @@ private static boolean isIdentifierChar(char c) { return Character.isLetterOrDigit(c) || c == '_' || c == '$'; } + /** + * The index of the quote closing the literal that opens at + * {@code quoteAt}, or the text length when nothing closes it. + * + *

One implementation because there were several, and they drifted. Each + * scanner in this class had its own copy of "walk to the closing quote", + * some honouring backslash escapes and some not, and every divergence + * turned into a defect: a statement scanner that stopped at the apostrophe + * inside {@code 'can\'t'} merged statements that must stay apart, and a + * brace counter that did the same swallowed a declaration's closing brace. + * They call this now, so a fix reaches all of them.

+ */ + private static int endOfStringLiteral(String text, int quoteAt) { + char quote = text.charAt(quoteAt); + for (int i = quoteAt + 1; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\\') { + i++; + } else if (c == quote) { + return i; + } + } + return text.length(); + } + private static int skipBlanks(String line, int from) { int i = from; while (i < line.length() && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { @@ -930,15 +962,10 @@ private static boolean opensAClosure(String statement) { /** How far a statement opens or closes braces, ignoring those in strings. */ private static int braceBalance(String statement) { int depth = 0; - char quote = 0; for (int i = 0; i < statement.length(); i++) { char c = statement.charAt(i); - if (quote != 0) { - if (c == quote) { - quote = 0; - } - } else if (c == '\'' || c == '"') { - quote = c; + if (c == '\'' || c == '"') { + i = endOfStringLiteral(statement, i); } else if (c == '{') { depth++; } else if (c == '}') { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index cd7eb32994a..2bf575eee1a 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -400,6 +400,45 @@ public void aStrictPinOnTheBaseStdlibBlocksBothShims() { "a strict modern base pin does not need the block suppressed"); } + /** + * The version comes from the strict call, not from a reason that mentions + * one. Finding the call correctly and then reading the version with a + * plain search let prose supply it, so a declaration whose real strict + * version is compatible was judged on a number from its own comment. + */ + @Test + public void theStrictVersionComesFromTheCallNotTheProse() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.9.22') " + + "{ because \"strictly '1.7.22' is not intended\"; " + + "version { strictly '1.9.22' } }\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the real strict version is above the floor, so the block is written"); + } + + /** + * Brace balancing honours escapes, as the statement scanner does. A + * declaration whose closure contained an escaped apostrophe had its real + * closing brace ignored, so following dependencies merged into it and an + * unrelated strict pin could be read as one on kotlin-stdlib. + */ + @Test + public void anEscapedQuoteDoesNotSwallowAClosingBrace() { + // The base stdlib named without a strict version, then an UNRELATED strict + // pin. Correct: neither suppresses, so the block is written. With the escape + // mishandled the two statements merge, the merged statement both names + // kotlin-stdlib and calls strictly '1.7.22', and the whole block disappears. + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.9.22') " + + "{ because 'can\\'t' }\n" + + " implementation('com.example:other:1.0') " + + "{ version { strictly '1.7.22' } }\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "an unrelated strict pin does not merge into the Kotlin declaration"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and the block is written in full"); + } + /** * An underscore is an identifier character. A configuration named * custom_implementation ended its embedded "implementation" on a boundary From 786d1bcd7fd83e09a36f34f2b5a2d1ad24dd6342 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:12:45 +0300 Subject: [PATCH 20/94] Scan the plugin fragment, join comma-continued maps, and stop at the block brace Three findings, one of them a gap in an earlier sweep of mine. android.gradlePlugin is scanned now. When android.supportv4Dep was found missing, the fix was to take the enumeration from ShieldInjector's GRADLE_TEXT_HINTS rather than from memory -- and then this one was classified from its name as landing in the plugin area. It does not: it is interpolated at top level right after `apply plugin`, where a dependencies block of its own is valid and reaches the same configurations. Reading the list was right; deciding what each entry does without looking at the interpolation site was not. Groovy's parenthesis-free map notation spreads one declaration over several lines held together by trailing commas. Splitting at those newlines left the configuration, the group, the artifact and any closure in four statements, none of which is a declaration on its own, so a strict pre-1.8 pin written that way was missed and the constraint made resolution fail. A trailing comma continues the statement now. An enclosing block's opening brace is not the declaration's own closure. A fragment putting its first dependency on the same line as `dependencies {` made that declaration swallow every following statement up to the closing brace, and an unrelated strict pin further down then read as a pin on the stdlib and silenced the whole block. The first attempt at that last one counted braces after the LAST string literal and broke an existing case: a trailing closure carries strings of its own -- an exclusion's module name, a strict version -- so counting after those missed the closure's own opening brace. It counts from the COORDINATE literal instead, which is the thing a block opener precedes and a trailing closure follows. The existing case is what caught it, which is the argument for keeping cases that look redundant. Three new cases, all three failing with their fixes reverted. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 7 ++- .../builders/KotlinStdlibAlignment.java | 59 ++++++++++++++++++- .../builders/KotlinStdlibAlignmentTest.java | 57 ++++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 1041e057f11..db0f90dda72 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7306,10 +7306,13 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // dependencies block. Read off ShieldInjector's GRADLE_TEXT_HINTS, // which is this tree's enumeration of hints interpolated into a // Gradle file, rather than off the ones that came to mind -- - // android.supportv4Dep was missed exactly that way, and it is - // written into the block a few lines below. The rest of that list + // android.supportv4Dep was missed exactly that way, and so was + // android.gradlePlugin: it is interpolated at top level right after + // `apply plugin`, where a dependencies { } block of its own is + // valid and reaches the same configurations. The rest of that list // lands in buildscript, repositories or the android block, where a // dependency cannot be declared. + request.getArg("android.gradlePlugin", ""), additionalDependencies, aiExtraGradleDependencies.toString(), request.getArg("android.gradleDep", ""), diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index dd781db3ecb..5b684221cf8 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -902,6 +902,18 @@ private static String[] statements(String text) { depth--; } } else if ((c == '\n' || c == ';') && depth == 0) { + // A trailing comma continues the statement. Groovy's parenthesis-free + // map notation spreads one declaration over several lines -- + // implementation group: 'org.jetbrains.kotlin', + // name: 'kotlin-stdlib-jdk8', + // version: '1.7.22' + // -- and splitting there left the configuration, the group, the + // artifact and any closure in four statements, none of which is a + // declaration on its own. + if (c == '\n' && endsWithComma(current)) { + current.append(' '); + continue; + } out.add(current.toString().replace('\n', ' ')); current.setLength(0); continue; @@ -941,7 +953,7 @@ private static String[] statements(String text) { i++; statement = statement + " " + out.get(i); } - int braces = braceBalance(statement); + int braces = trailingBraceBalance(statement); while (braces > 0 && i + 1 < out.size()) { i++; statement = statement + " " + out.get(i); @@ -953,12 +965,57 @@ private static String[] statements(String text) { return merged.toArray(new String[merged.size()]); } + /** Whether the text so far ends with a comma, ignoring trailing blanks. */ + private static boolean endsWithComma(StringBuilder text) { + for (int i = text.length() - 1; i >= 0; i--) { + char c = text.charAt(i); + if (c == ' ' || c == '\t' || c == '\r') { + continue; + } + return c == ','; + } + return false; + } + /** Whether the statement is nothing but the start of a closure. */ private static boolean opensAClosure(String statement) { String trimmed = statement.trim(); return trimmed.startsWith("{"); } + /** + * The brace balance of what follows a declaration's coordinate, which is + * the only part that can be its own trailing closure. + * + *

Counting the whole statement caught the ENCLOSING block's opener when + * a fragment put its first dependency on the same line as it -- + * {@code dependencies { implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'} + * -- and the declaration then swallowed every following statement up to the + * closing brace, so an unrelated {@code strictly} further down read as a + * pin on the stdlib and silenced the whole block. A block opener sits + * BEFORE the coordinate and a trailing closure after it, so counting from + * the end of the last string literal separates them.

+ */ + private static int trailingBraceBalance(String statement) { + // From the end of the COORDINATE literal, not the last literal: a trailing + // closure carries strings of its own -- an exclusion's module name, a strict + // version -- and counting after those missed the closure's own opening brace. + for (int i = 0; i < statement.length(); i++) { + char c = statement.charAt(i); + if (c != '\'' && c != '"') { + continue; + } + int end = endOfStringLiteral(statement, i); + if (statement.substring(i + 1, Math.min(end, statement.length())) + .startsWith(KOTLIN_GROUP)) { + return braceBalance(statement.substring(Math.min(end + 1, + statement.length()))); + } + i = end; + } + return braceBalance(statement); + } + /** How far a statement opens or closes braces, ignoring those in strings. */ private static int braceBalance(String statement) { int depth = 0; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 2bf575eee1a..750c4ace9d9 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -400,6 +400,63 @@ public void aStrictPinOnTheBaseStdlibBlocksBothShims() { "a strict modern base pin does not need the block suppressed"); } + /** + * Groovy's parenthesis-free map notation spreads one declaration over + * several lines, held together by trailing commas. Splitting at those + * newlines left the configuration, the group, the artifact and the closure + * in four statements, none of which is a declaration on its own -- so a + * strict pre-1.8 pin written that way was missed and the constraint made + * resolution fail. + */ + @Test + public void aCommaContinuesAMultilineMapDeclaration() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation group: 'org.jetbrains.kotlin',\n" + + " name: 'kotlin-stdlib-jdk8',\n" + + " version: '1.7.22'\n"); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a comma-continued map declaration pins jdk8"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and leaves jdk7 constrained"); + } + + /** + * An enclosing block's opening brace is not the declaration's own closure. + * A fragment putting its first dependency on the same line as + * {@code dependencies {} made that declaration swallow every following + * statement up to the closing brace, so an unrelated strict pin further + * down read as one on the stdlib and silenced the whole block. + */ + @Test + public void anEnclosingBlockBraceIsNotATrailingClosure() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + "dependencies { implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'\n" + + " implementation('com.example:other:1.0') " + + "{ version { strictly '1.7.22' } }\n" + + "}\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the unrelated strict pin does not attach to the stdlib declaration"); + } + + /** + * android.gradlePlugin is interpolated at top level right after + * `apply plugin`, where a dependencies block of its own is valid and + * reaches the same configurations -- so it has to be scanned like the + * other app-controlled fragments. It was missed the same way + * android.supportv4Dep was. + */ + @Test + public void theBuilderScansTheGradlePluginFragment() throws Exception { + byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); + String builderSrc = new String(bytes, "UTF-8"); + int at = builderSrc.indexOf("KotlinStdlibAlignment.constraintsBlock("); + check(at >= 0, "the builder calls the alignment"); + String call = builderSrc.substring(at, builderSrc.indexOf(";", at)); + check(call.contains("request.getArg(\"android.gradlePlugin\", \"\")"), + "android.gradlePlugin reaches the generated script and must be scanned"); + } + /** * The version comes from the strict call, not from a reason that mentions * one. Finding the call correctly and then reading the version with a From aea0eaeaaab4c60567a7e126b03ac07db714e274 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:25:56 +0300 Subject: [PATCH 21/94] Follow one hop through a def, and stop calling prose a coordinate Two findings, and a boundary drawn on purpose. A coordinate can sit one hop away behind a variable: def jdk8 = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22' implementation(jdk8) { version { strictly '1.7.22' } } Neither statement carries both the configuration and the coordinate, so the strict pin was invisible and the constraint made the build stop resolving. A `def name = 'literal'` is folded into the statements that use the name. That is where following the value stops, and the limit is written down rather than left to be discovered. A coordinate assembled by concatenation, or built from a map, a list or a method call, is not in the text as a coordinate at all; recovering it needs Gradle to evaluate the script, which nothing here can do. There is a case pinning that the concatenated form is left unrecognised, so the next reader sees a decision rather than an oversight. An interpolated VERSION is a different matter and is still recognised -- the artifact name is literal there, and naming the artifact is what matters. Writing that case is what showed the first attempt at it had asserted the wrong thing. Separately: a reason that OPENS with the coordinate is still a reason. `because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22 causes duplicate classes'` was read as the declaration it warns about, switching off the constraint that prevents exactly what it describes. Dependency notation carries no whitespace; prose does. Four new cases. Both fixes fail with their old behaviour restored. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 133 +++++++++++++++++- .../builders/KotlinStdlibAlignmentTest.java | 75 ++++++++++ 2 files changed, 205 insertions(+), 3 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 5b684221cf8..df4702168a9 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -126,7 +126,9 @@ * keep this file in sync with its twin in the other repository.

*/ import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; public class KotlinStdlibAlignment { @@ -295,8 +297,15 @@ private static boolean namesCoordinate(String line, String artifact) { i++; } else if (c == quote) { String literal = line.substring(stringStart + 1, i); - if (literal.equals(coordinate) - || literal.startsWith(coordinate + ":")) { + // Dependency notation carries no whitespace; a reason sentence + // does. Without that, a reason that merely OPENS with the + // coordinate -- because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8: + // 1.7.22 causes duplicate classes' -- read as the declaration it + // was warning about, and switched off the constraint that would + // have prevented exactly what it describes. + if ((literal.equals(coordinate) + || literal.startsWith(coordinate + ":")) + && !hasWhitespace(literal)) { return true; } quote = 0; @@ -311,6 +320,16 @@ private static boolean namesCoordinate(String line, String artifact) { return false; } + /** Whether the text contains any whitespace. */ + private static boolean hasWhitespace(String text) { + for (int i = 0; i < text.length(); i++) { + if (Character.isWhitespace(text.charAt(i))) { + return true; + } + } + return false; + } + /** The version inside this statement's {@code strictly} call, or null. */ private static String strictVersionIn(String statement) { // The same syntax-level call callsStrictly validated, not any occurrence of @@ -962,9 +981,117 @@ private static String[] statements(String text) { } merged.add(statement); } - return merged.toArray(new String[merged.size()]); + return inlineLiteralDefinitions(merged); + } + + /** + * Statements with {@code def name = 'literal'} definitions folded into the + * places that use them. + * + *

A coordinate can sit one hop away:

+ * + *
+     * def jdk8 = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'
+     * implementation(jdk8) { version { strictly '1.7.22' } }
+     * 
+ * + *

Neither statement carries both the configuration and the coordinate, + * so the strict pin was invisible and the constraint made the build stop + * resolving. One hop through a string literal is recoverable from the text + * and is recovered here.

+ * + *

Where this stops, deliberately. A value built by + * interpolation, by concatenation, from a map or a list, or returned by a + * method is not in the text at all -- reading it needs Gradle to evaluate + * the script, which nothing here can do. Those forms are left unrecognised + * rather than guessed at, and that is a real limit of reading declarations + * out of build-hint text rather than something another pass would fix. The + * design that does not need to find the declaration at all -- constraining + * unless a strict version says otherwise -- is the answer to that class, + * and it is a decision about documented behaviour rather than a defect to + * patch here.

+ */ + private static String[] inlineLiteralDefinitions(List statements) { + Map literals = new LinkedHashMap(); + for (int i = 0; i < statements.size(); i++) { + collectLiteralDefinition(statements.get(i), literals); + } + String[] out = new String[statements.size()]; + for (int i = 0; i < statements.size(); i++) { + out[i] = literals.isEmpty() + ? statements.get(i) + : withLiteralsInlined(statements.get(i), literals); + } + return out; } + /** Records a {@code def name = 'literal'} definition, if this is one. */ + private static void collectLiteralDefinition(String statement, + Map literals) { + int at = statement.indexOf(DEF); + if (at < 0) { + return; + } + boolean startsToken = at == 0 || !isIdentifierChar(statement.charAt(at - 1)); + if (!startsToken) { + return; + } + int i = skipBlanks(statement, at + DEF.length()); + int nameStart = i; + while (i < statement.length() && isIdentifierChar(statement.charAt(i))) { + i++; + } + if (i == nameStart) { + return; + } + String name = statement.substring(nameStart, i); + i = skipBlanks(statement, i); + if (i >= statement.length() || statement.charAt(i) != '=') { + return; + } + i = skipBlanks(statement, i + 1); + if (i >= statement.length() + || (statement.charAt(i) != '\'' && statement.charAt(i) != '"')) { + return; + } + int end = endOfStringLiteral(statement, i); + if (end >= statement.length()) { + return; + } + literals.put(name, statement.substring(i, end + 1)); + } + + /** The statement with known definition names replaced by their literals. */ + private static String withLiteralsInlined(String statement, + Map literals) { + StringBuilder out = new StringBuilder(); + for (int i = 0; i < statement.length(); i++) { + char c = statement.charAt(i); + if (c == '\'' || c == '"') { + int end = endOfStringLiteral(statement, i); + out.append(statement, i, Math.min(end + 1, statement.length())); + i = end; + continue; + } + if (!isIdentifierChar(c) + || (i > 0 && isIdentifierChar(statement.charAt(i - 1)))) { + out.append(c); + continue; + } + int end = i; + while (end < statement.length() && isIdentifierChar(statement.charAt(end))) { + end++; + } + String token = statement.substring(i, end); + String literal = literals.get(token); + out.append(literal == null ? token : literal); + i = end - 1; + } + return out.toString(); + } + + private static final String DEF = "def"; + /** Whether the text so far ends with a comma, ignoring trailing blanks. */ private static boolean endsWithComma(StringBuilder text) { for (int i = text.length() - 1; i >= 0; i--) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 750c4ace9d9..5f691ae31a7 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -400,6 +400,81 @@ public void aStrictPinOnTheBaseStdlibBlocksBothShims() { "a strict modern base pin does not need the block suppressed"); } + /** + * A reason that OPENS with the coordinate is still a reason. Accepting any + * literal starting with one let a warning about the duplicate + * -- because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22 causes + * duplicate classes' -- switch off the constraint that prevents exactly + * what it describes. Dependency notation carries no whitespace. + */ + @Test + public void aReasonOpeningWithTheCoordinateIsStillProse() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('com.example:foo:1.0') { because " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22 causes duplicate classes' }\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a reason opening with the coordinate does not suppress"); + } + + /** + * A coordinate may sit one hop away behind a def. Neither statement + * carries both the configuration and the coordinate, so the strict pin was + * invisible and the constraint made the build stop resolving. + */ + @Test + public void aCoordinateBehindADefIsStillADeclaration() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def jdk8 = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" + + " implementation(jdk8) { version { strictly '1.7.22' } }\n"); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the strict pin behind a def is honoured"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and jdk7 is still constrained"); + } + + /** + * The boundary, stated as a case so it is a decision rather than an + * oversight. An interpolated VERSION is still recognised -- the artifact + * name is literal there, and naming the artifact is what matters -- but a + * coordinate assembled by concatenation is not in the text as a coordinate + * at all, and recovering it needs Gradle to evaluate the script. The block + * is written, which is the safe direction for everything except a strict + * pin; a strict pin hidden this way is beyond what reading build-hint text + * can reach, and the design that does not need to find the declaration is + * the answer to that class rather than another pass here. + */ + @Test + public void aConcatenatedCoordinateIsNotRecovered() { + // An interpolated version still names the artifact, so it IS recognised. + String interpolatedVersion = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation(\"org.jetbrains.kotlin:kotlin-stdlib-jdk8:$v\")\n"); + check(!interpolatedVersion.contains("kotlin-stdlib-jdk8:1.8.0"), + "an interpolated version still names the artifact"); + + // A coordinate assembled by concatenation is not a coordinate in the text. + String concatenated = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:' + 'kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check(concatenated.contains("kotlin-stdlib-jdk8:1.8.0"), + "a concatenated coordinate is left unrecognised, by design"); + } + + /** + * A def that is not a string literal defines nothing here, and must not + * corrupt the statement that uses the name. + */ + @Test + public void aNonLiteralDefIsIgnored() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def jdk8 = someFunction()\n" + + " implementation(jdk8)\n" + + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22'\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "an unresolvable def leaves jdk8 constrained"); + check(!out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and the real jdk7 declaration beside it still counts"); + } + /** * Groovy's parenthesis-free map notation spreads one declaration over * several lines, held together by trailing commas. Splitting at those From 195576ddafec62c6e803aee1b26d1e456f40ebd2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:43:31 +0300 Subject: [PATCH 22/94] Do not manufacture the duplicate: a pre-merge shim pin takes its sibling with it The worst finding of this review, because the block was creating the failure it exists to prevent. Measured with Gradle rather than reasoned about: app pins the whole family itself stdlib 1.7.22 + jdk7 1.7.22 + jdk8 1.7.22 no duplicate emitting only the sibling stdlib 1.8.0 + jdk7 1.8.0 + jdk8 1.7.22 DUPLICATE Suppressing per artifact was wrong below the merge floor. An app pinning kotlin-stdlib-jdk8 at 1.7.22 kept its jdk8 constraint, but the surviving jdk7 constraint depends on kotlin-stdlib 1.8.0 -- which carries the jdk8 classes -- so the app's class-bearing jdk8 jar ended up beside a base library holding the same classes. In a graph the app had arranged correctly. Below the floor, a declaration of either shim now suppresses both. Above it they stay independent, because a sibling constraint cannot strand a shim that is already merged-era, and giving that up would withhold alignment an app still needs. Both directions have a case; without the second, the fix would have collapsed into "suppress everything whenever the app names either artifact". A version this cannot read counts as below the floor. An app naming these artifacts at all is managing the family, and the harm of assuming the worst is an alignment not written for a build that had already sorted itself out, against a duplicate class manufactured in one that had. Six existing cases asserted the old behaviour and were changed rather than worked around: each pinned a shim below the floor and expected the sibling constraint to survive, which is precisely the arrangement measured above as broken. They now expect the empty block. Two new cases cover the rule in both directions, and both fail with the guard removed. Also in this change: definitions are folded in BEFORE closures are merged. The merge only absorbs a closure into a statement that already names the Kotlin group, and a statement referring to the coordinate through a variable does not name it until the fold has happened, so `implementation(stdlib) {` on one line and its strict version on the next were never associated. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 127 ++++++++++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 98 ++++++++++---- 2 files changed, 187 insertions(+), 38 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index df4702168a9..240305b36a0 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -213,6 +213,17 @@ public static String constraintsBlock(String configuration, if (strictlyPinsBaseStdlibBelowTheFloor(appGradleFragments)) { return ""; } + // The two shims cannot be suppressed independently when the app holds one of + // them below the merge. Measured: an app pinning the whole family at 1.7.22 + // resolves with no duplicate, and emitting only the surviving sibling raises + // kotlin-stdlib to 1.8.0 -- which carries the jdk8 classes -- beside the app's + // class-bearing jdk8 1.7.22 jar. That is this block MAKING the duplicate it + // exists to prevent, in a graph the app had arranged correctly. + for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { + if (declaredBelowTheFloor(ALIGNED_ARTIFACTS[i], appGradleFragments)) { + return ""; + } + } StringBuilder out = new StringBuilder(); for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { if (declaresArtifact(ALIGNED_ARTIFACTS[i], config, appGradleFragments)) { @@ -230,6 +241,88 @@ public static String constraintsBlock(String configuration, return " constraints {\n" + out + " }\n"; } + /** + * Whether the app declares this shim at a version below the merge floor. + * + *

A version this cannot read counts as below it. An app naming one of + * these artifacts at all is managing the family, and the harm of assuming + * the worst is an alignment not written for an app that had already sorted + * itself out, against a duplicate class manufactured in one that had.

+ */ + private static boolean declaredBelowTheFloor(String artifact, + String[] appGradleFragments) { + if (appGradleFragments == null) { + return false; + } + for (int i = 0; i < appGradleFragments.length; i++) { + String[] lines = activeLines(appGradleFragments[i]); + for (int j = 0; j < lines.length; j++) { + if (!declaresArtifactOnLine(artifact, "implementation", lines[j]) + && !declaresArtifactOnLine(artifact, "api", lines[j])) { + continue; + } + if (!namesArtifactAnywhere(lines[j], artifact)) { + continue; + } + if (belowTheFloor(declaredVersionOf(lines[j], artifact))) { + return true; + } + } + } + return false; + } + + /** Whether the statement names the artifact, in either spelling. */ + private static boolean namesArtifactAnywhere(String line, String artifact) { + return namesCoordinate(line, artifact) + || (line.contains(KOTLIN_GROUP) && declaresMapEntry(line, "name", artifact)); + } + + /** + * The version this statement declares for {@code artifact}: the third + * segment of its coordinate, or the map form's {@code version:} entry. + * Null when neither is readable, which callers treat as below the floor. + */ + private static String declaredVersionOf(String line, String artifact) { + String coordinate = KOTLIN_GROUP + ":" + artifact + ":"; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (c != '\'' && c != '"') { + continue; + } + int end = endOfStringLiteral(line, i); + String literal = line.substring(i + 1, Math.min(end, line.length())); + if (literal.startsWith(coordinate) && !hasWhitespace(literal)) { + return literal.substring(coordinate.length()); + } + i = end; + } + return mapEntryValue(line, "version"); + } + + /** The value of a {@code key: 'value'} map entry, or null. */ + private static String mapEntryValue(String line, String key) { + int at = line.indexOf(key); + while (at >= 0) { + boolean startsToken = at == 0 || !isIdentifierChar(line.charAt(at - 1)); + if (startsToken) { + int i = skipBlanks(line, at + key.length()); + if (i < line.length() && line.charAt(i) == ':') { + i = skipBlanks(line, i + 1); + if (i < line.length() + && (line.charAt(i) == '\'' || line.charAt(i) == '"')) { + int end = endOfStringLiteral(line, i); + if (end < line.length()) { + return line.substring(i + 1, end); + } + } + } + } + at = line.indexOf(key, at + 1); + } + return null; + } + /** * Whether the app strictly holds {@code kotlin-stdlib} itself below the * floor both shims depend on. @@ -953,14 +1046,21 @@ private static String[] statements(String text) { out.add(current.toString().replace('\n', ' ')); } } + // Definitions are folded in FIRST, because the merge below only absorbs a + // closure into a statement that already names the Kotlin group -- and a + // statement referring to the coordinate through a variable does not name it + // until the fold has happened. Merging first left `implementation(stdlib) {` + // unmerged, so its `strictly` was never associated with the coordinate. + List defined = inlineLiteralDefinitions(out); + // A declaration's own configuration block belongs to it: the version that // decides this is written as `version { strictly '1.7.22' }` on the line after // the coordinate. Only a statement that already names the Kotlin group absorbs // its block, so a `dependencies {` or `android {` opening cannot swallow the // fragment -- the blast radius is one declaration, never the file. List merged = new ArrayList(); - for (int i = 0; i < out.size(); i++) { - String statement = out.get(i); + for (int i = 0; i < defined.size(); i++) { + String statement = defined.get(i); if (statement.contains(KOTLIN_GROUP)) { // A trailing closure may sit on the line AFTER the call's closing // parenthesis -- Gradle accepts it and the strictly inside really does @@ -968,20 +1068,20 @@ private static String[] statements(String text) { // against it. The parenthesis depth is already back to zero there, so // without this the closure lands in its own statement and the version // it carries is never associated with the coordinate above it. - while (i + 1 < out.size() && opensAClosure(out.get(i + 1))) { + while (i + 1 < defined.size() && opensAClosure(defined.get(i + 1))) { i++; - statement = statement + " " + out.get(i); + statement = statement + " " + defined.get(i); } int braces = trailingBraceBalance(statement); - while (braces > 0 && i + 1 < out.size()) { + while (braces > 0 && i + 1 < defined.size()) { i++; - statement = statement + " " + out.get(i); - braces += braceBalance(out.get(i)); + statement = statement + " " + defined.get(i); + braces += braceBalance(defined.get(i)); } } merged.add(statement); } - return inlineLiteralDefinitions(merged); + return merged.toArray(new String[merged.size()]); } /** @@ -1011,16 +1111,17 @@ private static String[] statements(String text) { * and it is a decision about documented behaviour rather than a defect to * patch here.

*/ - private static String[] inlineLiteralDefinitions(List statements) { + private static List inlineLiteralDefinitions(List statements) { Map literals = new LinkedHashMap(); for (int i = 0; i < statements.size(); i++) { collectLiteralDefinition(statements.get(i), literals); } - String[] out = new String[statements.size()]; + if (literals.isEmpty()) { + return statements; + } + List out = new ArrayList(); for (int i = 0; i < statements.size(); i++) { - out[i] = literals.isEmpty() - ? statements.get(i) - : withLiteralsInlined(statements.get(i), literals); + out.add(withLiteralsInlined(statements.get(i), literals)); } return out; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 5f691ae31a7..c967297e2bd 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -257,8 +257,9 @@ public void aPinOnAnyMainConfigurationSuppresses() { + "('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22')\n"); check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), "a pin on " + configuration + " is the app managing jdk8"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and jdk7 is still constrained after a " + configuration + " pin"); + check("".equals(out), + "a below-floor pin on " + configuration + " suppresses BOTH shims, " + + "since raising the sibling would strand the pinned one"); } } @@ -358,13 +359,13 @@ public void aMapEntryMayHaveSpaceAroundItsColon() { String spaced = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation(group : 'org.jetbrains.kotlin', " + "name : 'kotlin-stdlib-jdk8', version : '1.7.22')\n"); - check(!spaced.contains("kotlin-stdlib-jdk8:1.8.0"), - "a spaced map entry still pins jdk8"); + check("".equals(spaced), + "a spaced map entry still pins jdk8, below the floor so both go"); String doubleQuoted = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation(group: \"org.jetbrains.kotlin\", " + "name:\"kotlin-stdlib-jdk8\", version: \"1.7.22\")\n"); - check(!doubleQuoted.contains("kotlin-stdlib-jdk8:1.8.0"), + check("".equals(doubleQuoted), "and so does an unspaced double-quoted one"); // A different artifact in the same shape must still not count. @@ -400,6 +401,62 @@ public void aStrictPinOnTheBaseStdlibBlocksBothShims() { "a strict modern base pin does not need the block suppressed"); } + /** + * The two shims cannot be suppressed independently below the merge floor. + * Measured with Gradle: an app pinning the whole family at 1.7.22 resolves + * with no duplicate, and emitting only the surviving sibling raises + * kotlin-stdlib to 1.8.0 -- which carries the jdk8 classes -- beside the + * app's class-bearing jdk8 1.7.22 jar. That is this block manufacturing the + * duplicate it exists to prevent, in a graph the app had arranged correctly. + */ + @Test + public void aPreMergeShimPinSuppressesItsSiblingToo() { + String jdk8Pinned = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); + check("".equals(jdk8Pinned), + "a pre-merge jdk8 pin takes the jdk7 constraint with it"); + + String jdk7Pinned = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.22'\n"); + check("".equals(jdk7Pinned), + "and the same the other way round"); + } + + /** + * Above the floor they stay independent, because the sibling constraint + * cannot strand a shim that is already merged-era. Without this the fix + * above would have been "suppress everything whenever the app mentions + * either artifact", which gives up alignment an app still needs. + */ + @Test + public void aMergedEraShimPinStillLeavesTheSiblingConstrained() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), + "jdk7 is still constrained beside a merged-era jdk8 pin"); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), + "and jdk8 is left to the app"); + } + + /** + * A def reference whose closure spans lines needs the definition folded in + * BEFORE closures are merged: the merge only absorbs into a statement that + * already names the Kotlin group, and a statement referring to the + * coordinate through a variable does not name it until the fold happens. + * Running the passes the other way round left the closure unmerged and the + * strict pin unseen. + */ + @Test + public void aDefReferenceWithAMultilineClosureIsStillAPin() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def stdlib = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" + + " implementation(stdlib) {\n" + + " version { strictly '1.7.22' }\n" + + " }\n"); + check("".equals(out), + "the strict base pin behind a def with a multiline closure is honoured"); + } + /** * A reason that OPENS with the coordinate is still a reason. Accepting any * literal starting with one let a warning about the duplicate @@ -426,10 +483,8 @@ public void aCoordinateBehindADefIsStillADeclaration() { String out = KotlinStdlibAlignment.constraintsBlock("implementation", " def jdk8 = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" + " implementation(jdk8) { version { strictly '1.7.22' } }\n"); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the strict pin behind a def is honoured"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and jdk7 is still constrained"); + check("".equals(out), + "the strict pin behind a def is honoured, and below the floor both go"); } /** @@ -489,10 +544,8 @@ public void aCommaContinuesAMultilineMapDeclaration() { " implementation group: 'org.jetbrains.kotlin',\n" + " name: 'kotlin-stdlib-jdk8',\n" + " version: '1.7.22'\n"); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a comma-continued map declaration pins jdk8"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and leaves jdk7 constrained"); + check("".equals(out), + "a comma-continued map declaration pins jdk8, below the floor so both go"); } /** @@ -713,10 +766,8 @@ public void aStrictShimPinIsNotAPinOnTheBaseStdlib() { String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + "{ version { strictly '1.7.22' } }\n"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), - "pinning the jdk8 shim leaves jdk7 constrained, not the whole block off"); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), - "and jdk8 itself is left to the app"); + check("".equals(out), + "a strict pre-merge shim pin suppresses both, not just its own"); } /** @@ -747,10 +798,9 @@ public void theQuotedAddSpellingCountsAsAPin() { String out = KotlinStdlibAlignment.constraintsBlock("implementation", " dependencies.add(\"runtimeOnly\", " + "\"org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22\")\n"); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a quoted configuration name still pins jdk8"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and leaves jdk7 constrained"); + check("".equals(out), + "a quoted configuration name still pins jdk8, and a below-floor pin " + + "suppresses both"); } /** @@ -824,10 +874,8 @@ public void aDeclarationSplitAcrossLinesIsStillAPin() { " implementation(\n" + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" + " )\n"); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a wrapped declaration pins jdk8"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and leaves jdk7 constrained"); + check("".equals(out), + "a wrapped declaration pins jdk8, below the floor so both go"); } /** From c756160bf12f1b0221a01702c7b4740aaaaf6df0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:58:24 +0300 Subject: [PATCH 23/94] Read the strict shorthand, and stop reading map notation out of prose Two findings. The first produced the worst outcome this block has caused. Gradle's `!!` suffix is the strict-version shorthand and was not recognised, so an app writing kotlin-stdlib:1.7.22!! got the constraints anyway. Measured rather than predicted, and the prediction was wrong -- this does not fail resolution, it silently strips classes: !! pin alone stdlib 1.7.22 + jdk7/jdk8 1.7.22 classes present !! pin + these blocks stdlib 1.7.22 + jdk7/jdk8 1.8.0 EMPTY shims The jdk extension classes are then supplied by neither jar: the shims are empty and a 1.7.22 stdlib does not carry them. The app fails at runtime with a missing class instead of at build time with a duplicate one, which is strictly worse than the problem this block exists to solve. Both spellings of a strict version are read now, on the base library and on the shims. The second is the same drift that keeps recurring: the coordinate matcher had been taught to skip string literals and the map-form matcher beside it had not, so a reason quoting map notation -- because "avoid group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8'" -- read as a declaration. Since prose carries no version, that suppressed the WHOLE block rather than one artifact. The two matchers share the rule now, and the map reader is a single implementation rather than two that had already diverged. Three new cases, both fixes failing with their old behaviour restored, and each case pinning the direction the fix could have overshot: a real map declaration still counts, and a merged-era !! pin still gets the constraints. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 115 ++++++++++++------ .../builders/KotlinStdlibAlignmentTest.java | 53 ++++++++ 2 files changed, 128 insertions(+), 40 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 240305b36a0..3036601a4ab 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -264,7 +264,12 @@ private static boolean declaredBelowTheFloor(String artifact, if (!namesArtifactAnywhere(lines[j], artifact)) { continue; } - if (belowTheFloor(declaredVersionOf(lines[j], artifact))) { + String declared = declaredVersionOf(lines[j], artifact); + if (declared != null && declared.endsWith(STRICT_SUFFIX)) { + declared = declared.substring(0, + declared.length() - STRICT_SUFFIX.length()); + } + if (belowTheFloor(declared)) { return true; } } @@ -300,25 +305,44 @@ private static String declaredVersionOf(String line, String artifact) { return mapEntryValue(line, "version"); } - /** The value of a {@code key: 'value'} map entry, or null. */ + /** + * The value of a {@code key: 'value'} map entry, or null. + * + *

The KEY is looked for outside string literals only. A reason quoting + * the map form -- {@code because "avoid group: 'org.jetbrains.kotlin', + * name: 'kotlin-stdlib-jdk8'"} -- otherwise read as a declaration of that + * artifact, and since prose carries no version the whole block was + * suppressed. Same rule as the coordinate matcher beside it, which is + * where this had drifted apart from.

+ */ private static String mapEntryValue(String line, String key) { - int at = line.indexOf(key); - while (at >= 0) { - boolean startsToken = at == 0 || !isIdentifierChar(line.charAt(at - 1)); - if (startsToken) { - int i = skipBlanks(line, at + key.length()); - if (i < line.length() && line.charAt(i) == ':') { - i = skipBlanks(line, i + 1); - if (i < line.length() - && (line.charAt(i) == '\'' || line.charAt(i) == '"')) { - int end = endOfStringLiteral(line, i); - if (end < line.length()) { - return line.substring(i + 1, end); - } - } + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (c == '\'' || c == '"') { + i = endOfStringLiteral(line, i); + continue; + } + if (!line.startsWith(key, i)) { + continue; + } + boolean startsToken = i == 0 || !isIdentifierChar(line.charAt(i - 1)); + int after = i + key.length(); + if (!startsToken || (after < line.length() + && isIdentifierChar(line.charAt(after)))) { + continue; + } + int j = skipBlanks(line, after); + if (j >= line.length() || line.charAt(j) != ':') { + continue; + } + j = skipBlanks(line, j + 1); + if (j < line.length() && (line.charAt(j) == '\'' || line.charAt(j) == '"')) { + int end = endOfStringLiteral(line, j); + if (end < line.length()) { + return line.substring(j + 1, end); } } - at = line.indexOf(key, at + 1); + i = j; } return null; } @@ -345,10 +369,11 @@ private static boolean strictlyPinsBaseStdlibBelowTheFloor(String[] appGradleFra for (int i = 0; i < appGradleFragments.length; i++) { String[] lines = activeLines(appGradleFragments[i]); for (int j = 0; j < lines.length; j++) { - if (!callsStrictly(lines[j]) || !namesBaseStdlib(lines[j])) { + if (!namesBaseStdlib(lines[j])) { continue; } - if (belowTheFloor(strictVersionIn(lines[j]))) { + String strict = strictVersionOfBaseStdlib(lines[j]); + if (strict != null && belowTheFloor(strict)) { return true; } } @@ -356,6 +381,35 @@ private static boolean strictlyPinsBaseStdlibBelowTheFloor(String[] appGradleFra return false; } + /** + * The version this statement strictly holds {@code kotlin-stdlib} at, or + * null when it does not hold it strictly. + * + *

Two spellings mean the same thing. The {@code strictly} call is one; + * Gradle's {@code !!} suffix on the version is the other, and missing it + * was not a near miss. Measured with Gradle: an app writing + * {@code 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!'} beside a + * pre-merge jdk8 resolves the coherent 1.7.22 family on its own, and with + * this block's constraints added resolves kotlin-stdlib 1.7.22 beside + * jdk7/jdk8 1.8.0 -- the EMPTY shims. The jdk extension classes are then + * supplied by neither jar, and the app fails at runtime with a missing + * class rather than at build time with a duplicate one. That is the worst + * outcome available here, so the suffix is read as what it is.

+ */ + private static String strictVersionOfBaseStdlib(String line) { + if (callsStrictly(line)) { + return strictVersionIn(line); + } + String declared = declaredVersionOf(line, BASE_STDLIB); + if (declared != null && declared.endsWith(STRICT_SUFFIX)) { + return declared.substring(0, declared.length() - STRICT_SUFFIX.length()); + } + return null; + } + + /** Gradle's strict-version shorthand, written after the version. */ + private static final String STRICT_SUFFIX = "!!"; + /** Whether the statement names {@code kotlin-stdlib} and not a longer name. */ private static boolean namesBaseStdlib(String line) { return namesCoordinate(line, BASE_STDLIB) @@ -671,27 +725,8 @@ private static boolean callsStrictly(String statement) { * failed resolution.

*/ private static boolean declaresMapEntry(String line, String key, String value) { - int at = line.indexOf(key); - while (at >= 0) { - boolean startsToken = at == 0 - || !isIdentifierChar(line.charAt(at - 1)); - if (startsToken) { - int i = skipBlanks(line, at + key.length()); - if (i < line.length() && line.charAt(i) == ':') { - i = skipBlanks(line, i + 1); - if (i < line.length() - && (line.charAt(i) == '\'' || line.charAt(i) == '"')) { - char q = line.charAt(i); - int end = line.indexOf(q, i + 1); - if (end > i && line.substring(i + 1, end).equals(value)) { - return true; - } - } - } - } - at = line.indexOf(key, at + 1); - } - return false; + String found = mapEntryValue(line, key); + return found != null && found.equals(value); } /** diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index c967297e2bd..147396737ba 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -457,6 +457,59 @@ public void aDefReferenceWithAMultilineClosureIsStillAPin() { "the strict base pin behind a def with a multiline closure is honoured"); } + /** + * Gradle's {@code !!} suffix is the strict-version shorthand, and missing + * it produced the worst outcome available here. Measured: an app writing + * kotlin-stdlib:1.7.22!! beside a pre-merge jdk8 resolves the coherent + * 1.7.22 family on its own; with these constraints added it resolves + * kotlin-stdlib 1.7.22 beside jdk7/jdk8 1.8.0, the EMPTY shims -- so the + * jdk extension classes come from neither jar and the app fails at runtime + * with a missing class instead of at build time with a duplicate one. + */ + @Test + public void theStrictShorthandCountsAsAStrictPin() { + String base = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!'\n"); + check("".equals(base), + "a !! pin on the base stdlib suppresses both shims"); + + String shim = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n"); + check("".equals(shim), "and a !! pin on a shim does too"); + + // Above the floor the shorthand changes nothing: the constraints are still + // satisfiable, so they are still written. + String modern = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22!!'\n"); + check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), + "a merged-era !! pin does not need the block suppressed"); + } + + /** + * Map notation quoted inside a reason is prose too. The coordinate matcher + * had been taught to skip string literals and the map matcher beside it + * had not, so a reason naming the artifact in map form read as a + * declaration -- and since prose carries no version, the whole block was + * suppressed rather than one artifact. + */ + @Test + public void mapNotationInsideAReasonIsStillProse() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('com.example:other:1.0') { because " + + "\"avoid group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8'\" }\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "quoted map notation does not suppress"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and does not take the whole block with it"); + + // the real map form still counts + String real = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib-jdk8', version: '1.9.22'\n"); + check(!real.contains("kotlin-stdlib-jdk8:1.8.0"), + "a real map declaration still pins jdk8"); + } + /** * A reason that OPENS with the coordinate is still a reason. Accepting any * literal starting with one let a warning about the duplicate From 523e93fbfa97ae0eb7059823022c4df108669e8a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:09:29 +0300 Subject: [PATCH 24/94] Take the version from a rich-version closure, not just from the coordinate A merged-era declaration can carry its version in the closure rather than in the coordinate: implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') { version { strictly '1.9.22' } } Reading no version there classified it as below the merge floor, and since a below-floor shim declaration suppresses BOTH constraints, it took the jdk8 constraint down with it -- the one that graph still needed. The strict version in the same declaration is used when the coordinate carries none. Two cases: the merged-era form leaves the sibling constraint standing, and the pre-merge form still takes both, which is what that rule exists for. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 13 +++++++++- .../builders/KotlinStdlibAlignmentTest.java | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 3036601a4ab..2354de631ec 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -302,7 +302,18 @@ private static String declaredVersionOf(String line, String artifact) { } i = end; } - return mapEntryValue(line, "version"); + String mapped = mapEntryValue(line, "version"); + if (mapped != null) { + return mapped; + } + // A rich-version closure carries the version instead of the coordinate: + // implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') { + // version { strictly '1.9.22' } + // } + // Returning null there made a merged-era declaration read as below the floor + // and took the sibling's constraint down with it, which is the one the graph + // still needed. + return strictVersionIn(line); } /** diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 147396737ba..712a7fec5da 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -457,6 +457,30 @@ public void aDefReferenceWithAMultilineClosureIsStillAPin() { "the strict base pin behind a def with a multiline closure is honoured"); } + /** + * A rich-version closure carries the version instead of the coordinate. + * Reading no version there made a merged-era declaration look below the + * floor, which took the SIBLING's constraint down with it -- and the + * sibling is the one the graph still needed. + */ + @Test + public void aRichVersionClosureSuppliesTheVersion() { + String modern = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') " + + "{ version { strictly '1.9.22' } }\n"); + check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), + "a merged-era jdk7 declaration leaves the jdk8 constraint standing"); + check(!modern.contains("kotlin-stdlib-jdk7:1.8.0"), + "and jdk7 itself is left to the app"); + + // Below the floor it still takes both, which is the case that rule exists for. + String preMerge = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(preMerge), + "a pre-merge rich-version declaration still suppresses both"); + } + /** * Gradle's {@code !!} suffix is the strict-version shorthand, and missing * it produced the worst outcome available here. Measured: an app writing From cfcef0606439a4256becfd495d9a53d3bad8f786 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:15:06 +0300 Subject: [PATCH 25/94] Ask whether a pin is strict separately from what it is strict at Two findings. The first was an inconsistency between what this documents and what it does. "An unreadable strict version counts as below the floor" is written in two places and belowTheFloor(null) returns true for exactly that reason -- but the caller guarded on non-null first, so `version { strictly kotlinVersion }` read as not strict at all and the constraints went in. Whether a declaration is strict and what version it is strict AT are two questions, and asking only the second inverted the conservative path in the one case where being wrong costs a failed resolution rather than an override. The second: Groovy's command syntax drops the parentheses, so `add 'implementation', 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'` is as valid as add("implementation", "..."). Requiring the parenthesis rejected a declaration carrying an explicit strict pin. A quoted configuration name with no `add` in front still counts for nothing, and that has its own case. Four new cases, both fixes failing with their old behaviour restored. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 38 +++++++++++++++---- .../builders/KotlinStdlibAlignmentTest.java | 36 ++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 2354de631ec..d95de1265dd 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -383,8 +383,14 @@ private static boolean strictlyPinsBaseStdlibBelowTheFloor(String[] appGradleFra if (!namesBaseStdlib(lines[j])) { continue; } - String strict = strictVersionOfBaseStdlib(lines[j]); - if (strict != null && belowTheFloor(strict)) { + // Whether it is held strictly and what version it is held AT are two + // questions. Asking only the second let a strict pin whose version is + // unreadable -- version { strictly kotlinVersion } -- read as not + // strict at all, which is the opposite of the conservative path this + // documents everywhere else. belowTheFloor(null) is true for exactly + // this reason, and guarding on non-null defeated it. + if (holdsBaseStdlibStrictly(lines[j]) + && belowTheFloor(strictVersionOfBaseStdlib(lines[j]))) { return true; } } @@ -418,6 +424,15 @@ private static String strictVersionOfBaseStdlib(String line) { return null; } + /** Whether the statement holds the base library strictly, in either spelling. */ + private static boolean holdsBaseStdlibStrictly(String line) { + if (callsStrictly(line)) { + return true; + } + String declared = declaredVersionOf(line, BASE_STDLIB); + return declared != null && declared.endsWith(STRICT_SUFFIX); + } + /** Gradle's strict-version shorthand, written after the version. */ private static final String STRICT_SUFFIX = "!!"; @@ -892,18 +907,25 @@ && isAddCallArgument(line, stringStart)) { return false; } - /** Whether the string literal opening at {@code quoteAt} is an add() argument. */ + /** + * Whether the string literal opening at {@code quoteAt} is the + * configuration argument of an {@code add} call. + * + *

Both spellings count. Groovy's command syntax drops the parentheses -- + * {@code add 'implementation', 'group:artifact:version'} is as valid as + * {@code add("implementation", "...")} -- and requiring the parenthesis + * rejected a declaration that was carrying an explicit strict pin.

+ */ private static boolean isAddCallArgument(String line, int quoteAt) { int i = quoteAt - 1; while (i >= 0 && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { i--; } - if (i < 0 || line.charAt(i) != '(') { - return false; - } - i--; - while (i >= 0 && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { + if (i >= 0 && line.charAt(i) == '(') { i--; + while (i >= 0 && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { + i--; + } } return i >= 2 && "add".equals(line.substring(i - 2, i + 1)) && (i - 3 < 0 || !isIdentifierChar(line.charAt(i - 3))); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 712a7fec5da..dc903380627 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -457,6 +457,42 @@ public void aDefReferenceWithAMultilineClosureIsStillAPin() { "the strict base pin behind a def with a multiline closure is honoured"); } + /** + * Whether a declaration is strict and what version it is strict AT are two + * questions. Asking only the second let `version { strictly kotlinVersion }` + * read as not strict at all -- the opposite of the conservative path + * documented everywhere else, and the one case where being wrong costs a + * failed resolution rather than an override. + */ + @Test + public void aStrictPinWithAnUnreadableVersionStillSuppresses() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') " + + "{ version { strictly kotlinVersion } }\n"); + check("".equals(out), + "a strict pin whose version cannot be read takes the conservative path"); + } + + /** + * Groovy's command syntax drops the parentheses, and requiring them + * rejected a declaration carrying an explicit strict pin. + */ + @Test + public void theParenthesisFreeAddFormCounts() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " add 'implementation', " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n"); + check("".equals(out), + "add without parentheses is still an add"); + + // and a quoted configuration name with no add in front still counts for nothing + String bare = KotlinStdlibAlignment.constraintsBlock("implementation", + " def cfg = 'implementation'\n" + + " something cfg, 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); + check(bare.contains("kotlin-stdlib-jdk8:1.8.0"), + "a quoted configuration name without an add does not declare anything"); + } + /** * A rich-version closure carries the version instead of the coordinate. * Reading no version there made a merged-era declaration look below the From 858c49aa3f8c74f526c3cdc0096bd5e5ad762f75 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:24:27 +0300 Subject: [PATCH 26/94] Read a version selector by its lower bound, the declared group, and any rich version Three findings, all the same underlying mistake in three places: reading part of a declaration and treating "I could not parse that" as "the app pinned something old", which drops BOTH constraints and leaves the duplicate the sibling would have prevented. Gradle accepts more than a literal version, and every other shape parsed as zero. What matters is the lowest version a selector can resolve to: [1.8.0] exact range at the floor not below it 1.8.+ cannot resolve below 1.8.0 not below it [1.7.0,1.9.0) low end is pre-merge below it 1.7.+ below it 1.8.0-RC2 a prerelease OF the floor below it A prerelease and a dynamic marker are no longer the same thing: the first sorts below its own version, the second cannot go under its numeric prefix. The map form now matches the declared GROUP rather than the group appearing anywhere in the statement. A fork under another group whose reason mentioned org.jetbrains.kotlin combined with an unrelated artifact name and read as a Kotlin shim, and since its version was below the floor both constraints went. And a rich-version closure can name the version with a keyword other than strictly. Reading only strictly left `version { require '1.9.22' }` with no version at all, so a declaration that was already merged-era took its sibling's constraint down with it. strictly still decides STRICTNESS -- that has not changed -- but require and prefer answer "what version". Eight new cases across the three, each fix failing with its old behaviour restored, and four of the eight pinning the directions these could have overshot: the real map group still counts, a pre-merge required version still suppresses both, and a range reaching below the floor is still treated as below it. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 98 ++++++++++++++++--- .../builders/KotlinStdlibAlignmentTest.java | 85 ++++++++++++++++ 2 files changed, 170 insertions(+), 13 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index d95de1265dd..7023700bd34 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -280,7 +280,8 @@ private static boolean declaredBelowTheFloor(String artifact, /** Whether the statement names the artifact, in either spelling. */ private static boolean namesArtifactAnywhere(String line, String artifact) { return namesCoordinate(line, artifact) - || (line.contains(KOTLIN_GROUP) && declaresMapEntry(line, "name", artifact)); + || (declaresMapEntry(line, "group", KOTLIN_GROUP) + && declaresMapEntry(line, "name", artifact)); } /** @@ -312,8 +313,9 @@ private static String declaredVersionOf(String line, String artifact) { // } // Returning null there made a merged-era declaration read as below the floor // and took the sibling's constraint down with it, which is the one the graph - // still needed. - return strictVersionIn(line); + // still needed. Any of the rich-version keywords answers "what version", not + // only the one that also decides strictness. + return richVersionIn(line); } /** @@ -439,7 +441,7 @@ private static boolean holdsBaseStdlibStrictly(String line) { /** Whether the statement names {@code kotlin-stdlib} and not a longer name. */ private static boolean namesBaseStdlib(String line) { return namesCoordinate(line, BASE_STDLIB) - || (line.contains(KOTLIN_GROUP) + || (declaresMapEntry(line, "group", KOTLIN_GROUP) && declaresMapEntry(line, "name", BASE_STDLIB)); } @@ -505,6 +507,34 @@ private static boolean hasWhitespace(String text) { /** The version inside this statement's {@code strictly} call, or null. */ private static String strictVersionIn(String statement) { + return versionInCall(statement, STRICTLY); + } + + /** + * The version a rich-version closure declares, whichever keyword carries + * it. + * + *

{@code strictly} is the one that changes whether the constraints can + * coexist with the app's, but it is not the only one that says what version + * is meant. Reading only it left {@code version { require '1.9.22' } } + * with no version at all, which the conservative path then treated as + * below the floor -- dropping BOTH constraints for a declaration that was + * already merged-era and needed only its sibling left alone.

+ */ + private static String richVersionIn(String statement) { + String strict = versionInCall(statement, STRICTLY); + if (strict != null) { + return strict; + } + String required = versionInCall(statement, "require"); + if (required != null) { + return required; + } + return versionInCall(statement, "prefer"); + } + + /** The quoted argument of {@code call}, found outside string literals. */ + private static String versionInCall(String statement, String call) { // The same syntax-level call callsStrictly validated, not any occurrence of // the word: a reason reading `because "strictly '1.7.22' is not intended"` // otherwise supplies the version for a declaration whose real strict version @@ -516,14 +546,14 @@ private static String strictVersionIn(String statement) { i = endOfStringLiteral(statement, i); continue; } - if (!statement.startsWith(STRICTLY, i)) { + if (!statement.startsWith(call, i)) { continue; } boolean startsToken = i == 0 || !isIdentifierChar(statement.charAt(i - 1)); if (!startsToken) { continue; } - int after = skipBlanks(statement, i + STRICTLY.length()); + int after = skipBlanks(statement, i + call.length()); if (after < statement.length() && statement.charAt(after) == '(') { after = skipBlanks(statement, after + 1); } @@ -559,20 +589,61 @@ private static boolean belowTheFloor(String version) { if (version == null) { return true; } - int compared = compareVersions(version, MERGED_STDLIB_FLOOR); + String lowerBound = lowerBoundOf(version); + if (lowerBound == null) { + return true; + } + int compared = compareVersions(lowerBound, MERGED_STDLIB_FLOOR); if (compared != 0) { return compared < 0; } - return hasQualifier(version); + // At the floor numerically, only a PRERELEASE is below it. A dynamic marker + // is not: 1.8.+ cannot resolve lower than 1.8.0, so it is at the floor and + // the constraints are still satisfiable. + return isPrerelease(lowerBound); } - /** Whether the version carries anything after its numeric segments. */ - private static boolean hasQualifier(String version) { + /** + * The lowest version a selector can resolve to, as far as the text says. + * + *

Gradle accepts more than a literal here, and each shape was read as + * zero before: {@code [1.8.0]} is an exact range whose bracket stopped the + * numeric parse, {@code [1.7.0,1.9.0)} is a range whose LOW end is what + * matters for this question, and {@code 1.8.+} is a dynamic selector that + * cannot go below 1.8.0. Reading any of them as zero classified a + * merged-era declaration as pre-merge and dropped both constraints, + * including the sibling's -- which is the one such a graph still needs.

+ */ + private static String lowerBoundOf(String version) { + String selector = version.trim(); + if (selector.length() == 0) { + return null; + } + char opening = selector.charAt(0); + if (opening == '[' || opening == '(') { + selector = selector.substring(1); + int to = 0; + while (to < selector.length() && ",])".indexOf(selector.charAt(to)) < 0) { + to++; + } + selector = selector.substring(0, to); + } + selector = selector.trim(); + return selector.length() == 0 ? null : selector; + } + + /** + * Whether this version is a prerelease of its own numeric version, as + * opposed to a dynamic selector. {@code 1.8.0-RC2} sorts below + * {@code 1.8.0}; {@code 1.8.+} does not. + */ + private static boolean isPrerelease(String version) { for (int i = 0; i < version.length(); i++) { char c = version.charAt(i); - if (c != '.' && !Character.isDigit(c)) { - return true; + if (c == '.' || Character.isDigit(c)) { + continue; } + return c != '+'; } return false; } @@ -701,7 +772,8 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat return true; } // group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8', version: '...' - return line.contains(KOTLIN_GROUP) && declaresMapEntry(line, "name", artifact); + return declaresMapEntry(line, "group", KOTLIN_GROUP) + && declaresMapEntry(line, "name", artifact); } /** diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index dc903380627..d448e1a7514 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -457,6 +457,91 @@ public void aDefReferenceWithAMultilineClosureIsStillAPin() { "the strict base pin behind a def with a multiline closure is honoured"); } + /** + * The map form has to match the declared GROUP, not the group appearing + * anywhere. A fork under another group whose reason merely mentions + * org.jetbrains.kotlin combined with an unrelated artifact name and read + * as a Kotlin shim -- and since its version was below the floor, both + * constraints went. + */ + @Test + public void theMapFormMatchesTheDeclaredGroup() { + String fork = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation(group: 'com.example', name: 'kotlin-stdlib-jdk8', " + + "version: '1.0') { because 'fork of org.jetbrains.kotlin' }\n"); + check(fork.contains("kotlin-stdlib-jdk8:1.8.0"), + "another group's artifact is not our shim"); + check(fork.contains("kotlin-stdlib-jdk7:1.8.0"), + "and it does not take the block with it"); + + // the real map form still counts + String real = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib-jdk8', version: '1.9.22'\n"); + check(!real.contains("kotlin-stdlib-jdk8:1.8.0"), + "the real group still pins jdk8"); + } + + /** + * A rich-version closure can say what version is meant with a keyword + * other than strictly. Reading only strictly left `version { require }` + * with no version, which the conservative path treated as below the floor + * -- dropping both constraints for a declaration already merged-era. + */ + @Test + public void aRequiredRichVersionIsAVersionToo() { + String required = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') " + + "{ version { require '1.9.22' } }\n"); + check(required.contains("kotlin-stdlib-jdk8:1.8.0"), + "a required merged-era version leaves the sibling constrained"); + + String preferred = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') " + + "{ version { prefer '1.9.22' } }\n"); + check(preferred.contains("kotlin-stdlib-jdk8:1.8.0"), + "and so does a preferred one"); + + // below the floor it still takes both + String old = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') " + + "{ version { require '1.7.22' } }\n"); + check("".equals(old), "a required pre-merge version still suppresses both"); + } + + /** + * Gradle accepts more than a literal version, and each shape parsed as + * zero before -- classifying a merged-era declaration as pre-merge and + * dropping BOTH constraints, including the sibling's, which is the one + * such a graph still needs. What matters is the lowest version the + * selector can resolve to. + */ + @Test + public void aVersionSelectorIsReadByItsLowerBound() { + // exact range at the floor: not below it + String exact = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:[1.8.0]'\n"); + check(exact.contains("kotlin-stdlib-jdk8:1.8.0"), + "an exact merged-era range leaves the sibling constrained"); + + // dynamic selector that cannot go below the floor + String dynamic = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.+'\n"); + check(dynamic.contains("kotlin-stdlib-jdk8:1.8.0"), + "1.8.+ cannot resolve below the floor"); + + // range whose low end IS below the floor: conservative, both go + String spanning = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:[1.7.0,1.9.0)'\n"); + check("".equals(spanning), + "a range reaching below the floor is treated as below it"); + + // and a dynamic selector below the floor likewise + String oldDynamic = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.+'\n"); + check("".equals(oldDynamic), "1.7.+ is below the floor"); + } + /** * Whether a declaration is strict and what version it is strict AT are two * questions. Asking only the second let `version { strictly kotlinVersion }` From 5270ddd5db5550a1426c7551d55b22c87ddc3799 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:37:04 +0300 Subject: [PATCH 27/94] Follow definitions in statement order, and through interpolation Two findings, both within the one-hop-through-a-literal rule this already follows rather than past the boundary it declines to cross. A known definition referred to as $name or ${name} inside a double-quoted string is the same hop as a bare token reference. Reading it as unreadable made a merged-era version look pre-merge, which -- since a below-floor shim declaration suppresses BOTH constraints -- dropped the sibling's along with it. An UNKNOWN name is still unreadable and still takes the conservative path, and concatenation is still not recovered: the boundary has not moved, only the inside of it is now consistent. Definitions are applied in statement order, and a definition is recorded after its own statement has been rewritten. The previous two-pass version built one map for the whole fragment, so a variable's value leaked backwards across a reassignment and a main-variant declaration standing ABOVE the reassignment read as a pin on a value it never held. A reassignment to something unreadable now forgets the name rather than leaving a stale literal in place, which has its own case. Five new cases. Both fixes fail with their old behaviour restored -- but the ordering one only after its case was rewritten. As first written it reassigned Kotlin-to-unrelated, which a last-wins map answers correctly by accident; it now reassigns unrelated-to-Kotlin, where only walking in order gets the earlier statement right. That is the fourth case this review that passed while proving nothing, and the regression check is the only reason any of them were found. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 112 ++++++++++++++---- .../builders/KotlinStdlibAlignmentTest.java | 54 +++++++++ 2 files changed, 140 insertions(+), 26 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 7023700bd34..4552ab4db09 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -1252,32 +1252,43 @@ private static String[] statements(String text) { * patch here.

*/ private static List inlineLiteralDefinitions(List statements) { + // In statement order, and the definition is recorded AFTER its own statement + // has been rewritten. A two-pass version substituted a variable's first value + // into every use of it, including uses after a reassignment -- so + // def dep = '...kotlin-stdlib-jdk8:1.9.22'; debugImplementation(dep) + // dep = 'com.example:other:1.0'; implementation(dep) + // made the LAST statement read as a main-variant Kotlin declaration. Map literals = new LinkedHashMap(); - for (int i = 0; i < statements.size(); i++) { - collectLiteralDefinition(statements.get(i), literals); - } - if (literals.isEmpty()) { - return statements; - } List out = new ArrayList(); for (int i = 0; i < statements.size(); i++) { - out.add(withLiteralsInlined(statements.get(i), literals)); + String statement = statements.get(i); + out.add(literals.isEmpty() + ? statement + : withLiteralsInlined(statement, literals)); + updateLiteralDefinitions(statement, literals); } return out; } - /** Records a {@code def name = 'literal'} definition, if this is one. */ - private static void collectLiteralDefinition(String statement, + /** + * Applies this statement's effect on the known definitions: a + * {@code def name = 'literal'}, a reassignment of one already known, or a + * reassignment to something unreadable, which forgets it rather than + * leaving a stale value behind. + */ + private static void updateLiteralDefinitions(String statement, Map literals) { + int i = 0; + boolean declared = false; int at = statement.indexOf(DEF); - if (at < 0) { - return; - } - boolean startsToken = at == 0 || !isIdentifierChar(statement.charAt(at - 1)); - if (!startsToken) { - return; + if (at >= 0 && (at == 0 || !isIdentifierChar(statement.charAt(at - 1))) + && (at + DEF.length() >= statement.length() + || !isIdentifierChar(statement.charAt(at + DEF.length())))) { + declared = true; + i = skipBlanks(statement, at + DEF.length()); + } else { + i = skipBlanks(statement, 0); } - int i = skipBlanks(statement, at + DEF.length()); int nameStart = i; while (i < statement.length() && isIdentifierChar(statement.charAt(i))) { i++; @@ -1286,20 +1297,24 @@ private static void collectLiteralDefinition(String statement, return; } String name = statement.substring(nameStart, i); - i = skipBlanks(statement, i); - if (i >= statement.length() || statement.charAt(i) != '=') { + if (!declared && !literals.containsKey(name)) { return; } - i = skipBlanks(statement, i + 1); - if (i >= statement.length() - || (statement.charAt(i) != '\'' && statement.charAt(i) != '"')) { + i = skipBlanks(statement, i); + if (i >= statement.length() || statement.charAt(i) != '=' + || (i + 1 < statement.length() && statement.charAt(i + 1) == '=')) { return; } - int end = endOfStringLiteral(statement, i); - if (end >= statement.length()) { - return; + i = skipBlanks(statement, i + 1); + if (i < statement.length() + && (statement.charAt(i) == '\'' || statement.charAt(i) == '"')) { + int end = endOfStringLiteral(statement, i); + if (end < statement.length()) { + literals.put(name, statement.substring(i, end + 1)); + return; + } } - literals.put(name, statement.substring(i, end + 1)); + literals.remove(name); } /** The statement with known definition names replaced by their literals. */ @@ -1310,7 +1325,14 @@ private static String withLiteralsInlined(String statement, char c = statement.charAt(i); if (c == '\'' || c == '"') { int end = endOfStringLiteral(statement, i); - out.append(statement, i, Math.min(end + 1, statement.length())); + String literal = statement.substring(i, + Math.min(end + 1, statement.length())); + // A double-quoted string interpolates, so a known definition referred + // to as $name or ${name} is the same one hop this already follows for + // a bare token. Reading it as unreadable made a merged-era version + // look pre-merge and dropped the sibling's constraint with it. + out.append(c == '"' ? withInterpolationsExpanded(literal, literals) + : literal); i = end; continue; } @@ -1331,6 +1353,44 @@ private static String withLiteralsInlined(String statement, return out.toString(); } + /** A double-quoted literal with known {@code $name} references expanded. */ + private static String withInterpolationsExpanded(String literal, + Map literals) { + StringBuilder out = new StringBuilder(); + for (int i = 0; i < literal.length(); i++) { + char c = literal.charAt(i); + if (c != '$' || i + 1 >= literal.length()) { + out.append(c); + continue; + } + int nameStart = i + 1; + boolean braced = literal.charAt(nameStart) == '{'; + if (braced) { + nameStart++; + } + int nameEnd = nameStart; + while (nameEnd < literal.length() + && isIdentifierChar(literal.charAt(nameEnd))) { + nameEnd++; + } + if (nameEnd == nameStart + || (braced && (nameEnd >= literal.length() + || literal.charAt(nameEnd) != '}'))) { + out.append(c); + continue; + } + String value = literals.get(literal.substring(nameStart, nameEnd)); + if (value == null) { + out.append(c); + continue; + } + // Stored with its quotes, which do not belong inside another string. + out.append(value, 1, value.length() - 1); + i = braced ? nameEnd : nameEnd - 1; + } + return out.toString(); + } + private static final String DEF = "def"; /** Whether the text so far ends with a comma, ignoring trailing blanks. */ diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index d448e1a7514..2afdcdb43ed 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -457,6 +457,60 @@ public void aDefReferenceWithAMultilineClosureIsStillAPin() { "the strict base pin behind a def with a multiline closure is honoured"); } + /** + * A known definition referred to as $name inside a double-quoted string is + * the same one hop already followed for a bare token. Reading it as + * unreadable made a merged-era version look pre-merge and took the + * sibling's constraint down with it. + */ + @Test + public void aKnownDefinitionExpandsInsideAnInterpolatedString() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def kotlinVersion = '1.9.22'\n" + + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion\"\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the expanded version is merged-era, so the sibling stays constrained"); + + String braced = KotlinStdlibAlignment.constraintsBlock("implementation", + " def v = '1.7.22'\n" + + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:${v}\"\n"); + check("".equals(braced), "and a pre-merge one still suppresses both"); + + // an UNKNOWN name stays unreadable, which is the conservative path + String unknown = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$mystery\"\n"); + check("".equals(unknown), "an unknown name is still unreadable"); + } + + /** + * Definitions are applied in statement order. Substituting a variable's + * FIRST value into every use of it made a statement after a reassignment + * read as a declaration of the old value -- turning a debug-only Kotlin + * pin into a main-variant one and dropping the jdk8 constraint. + */ + @Test + public void aReassignedVariableUsesItsCurrentValue() { + // Ordered the other way round on purpose: with a two-pass map the LAST value + // wins everywhere, so the main declaration above the reassignment reads as a + // Kotlin pin it never was. Only walking in order gets this right. + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = 'com.example:other:1.0'\n" + + " implementation(dep)\n" + + " dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n" + + " debugImplementation(dep)\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the main declaration uses the value in force where it stands"); + + // and a reassignment to something unreadable forgets the name rather than + // leaving the old value standing + String forgotten = KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n" + + " dep = someFunction()\n" + + " implementation(dep)\n"); + check(forgotten.contains("kotlin-stdlib-jdk8:1.8.0"), + "an unreadable reassignment forgets the old literal"); + } + /** * The map form has to match the declared GROUP, not the group appearing * anywhere. A fork under another group whose reason merely mentions From c7e94ed1f58fb5a42d58951aa07ee9b19d1c47fa Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:54:20 +0300 Subject: [PATCH 28/94] Give the last two scanners the shared string rule, and read !! wherever it appears Three findings, two taken and one that turned out not to be a defect. A triple-quoted literal is a different delimiter, not three of the same one. Reading its opener as a single quote made a note containing an apostrophe close on that apostrophe and threw every following statement out of step, so a strict pin after it was never seen. That fix had to reach further than the shared endOfStringLiteral: the comment stripper and the statement scanner had kept their own copies of "walk to the closing quote" through the earlier consolidation, so the triple-quote handling reached neither until they were converted too. Both call the one rule now, and there is no other copy left. The strict bypass reads both spellings of a strict version. It asked only about the `strictly` keyword, so `debugImplementation '...jdk8:1.7.22!!'` was filtered out as a variant declaration and got the constraint anyway -- against a strict requirement that had resolved fine before it. One predicate answers "is this held strictly" now, for the shims and for the base library. Not a defect: naming implementation and api in the below-floor check was reported as making it miss a runtimeOnly pin. It did not. declaresOnTheConstrainedConfiguration ORs in every MAIN_CONFIGURATIONS entry whatever it is passed, so those names never restricted anything -- checked by putting them back and watching the behaviour stay identical. The argument is threaded through anyway, because two configuration names that look like a filter and are not one will be read as a filter by the next person, and the reasoning is on the line rather than only here. The case that came with it is kept and relabelled. It pins behaviour worth holding, but it passes with the change reverted, so it is marked as pinning rather than verifying -- the fifth case this review that proved nothing until it was checked, and the first where the check exonerated the code instead of condemning it. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 101 +++++++++++------- .../builders/KotlinStdlibAlignmentTest.java | 55 ++++++++++ 2 files changed, 120 insertions(+), 36 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 4552ab4db09..badddc28e4a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -220,7 +220,7 @@ public static String constraintsBlock(String configuration, // class-bearing jdk8 1.7.22 jar. That is this block MAKING the duplicate it // exists to prevent, in a graph the app had arranged correctly. for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { - if (declaredBelowTheFloor(ALIGNED_ARTIFACTS[i], appGradleFragments)) { + if (declaredBelowTheFloor(ALIGNED_ARTIFACTS[i], config, appGradleFragments)) { return ""; } } @@ -249,7 +249,7 @@ public static String constraintsBlock(String configuration, * the worst is an alignment not written for an app that had already sorted * itself out, against a duplicate class manufactured in one that had.

*/ - private static boolean declaredBelowTheFloor(String artifact, + private static boolean declaredBelowTheFloor(String artifact, String configuration, String[] appGradleFragments) { if (appGradleFragments == null) { return false; @@ -257,8 +257,16 @@ private static boolean declaredBelowTheFloor(String artifact, for (int i = 0; i < appGradleFragments.length; i++) { String[] lines = activeLines(appGradleFragments[i]); for (int j = 0; j < lines.length; j++) { - if (!declaresArtifactOnLine(artifact, "implementation", lines[j]) - && !declaresArtifactOnLine(artifact, "api", lines[j])) { + // The configuration actually being constrained, rather than the two + // that used to be named here. That was reported as letting a + // runtimeOnly pre-merge pin suppress its own constraint and not its + // sibling's; it did not, because declaresOnTheConstrainedConfiguration + // ORs in every MAIN_CONFIGURATIONS entry whatever it is passed, so the + // hard-coded names never restricted anything. Checked by putting them + // back: the behaviour is identical. Passing the real configuration + // regardless, because two names that look like a filter and are not + // one will be read as a filter by the next person. + if (!declaresArtifactOnLine(artifact, configuration, lines[j])) { continue; } if (!namesArtifactAnywhere(lines[j], artifact)) { @@ -428,10 +436,25 @@ private static String strictVersionOfBaseStdlib(String line) { /** Whether the statement holds the base library strictly, in either spelling. */ private static boolean holdsBaseStdlibStrictly(String line) { + return holdsStrictly(line, BASE_STDLIB); + } + + /** + * Whether the statement holds {@code artifact} strictly, in either + * spelling. + * + *

One predicate because there were two, and they diverged: the bypass + * that lets a strict pin escape the configuration filter asked only about + * the {@code strictly} keyword, so + * {@code debugImplementation '...jdk8:1.7.22!!'} was filtered out as a + * variant declaration and got the constraint anyway -- against a strict + * requirement that had resolved fine before it.

+ */ + private static boolean holdsStrictly(String line, String artifact) { if (callsStrictly(line)) { return true; } - String declared = declaredVersionOf(line, BASE_STDLIB); + String declared = declaredVersionOf(line, artifact); return declared != null && declared.endsWith(STRICT_SUFFIX); } @@ -764,7 +787,7 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat // fails the whole script at evaluation, which is a far larger blast radius // than the case it fixes. Revisit only with a project that actually has this // shape. - if (!callsStrictly(line) + if (!holdsStrictly(line, artifact) && !declaresOnTheConstrainedConfiguration(configuration, line)) { return false; } @@ -854,6 +877,26 @@ private static boolean isIdentifierChar(char c) { */ private static int endOfStringLiteral(String text, int quoteAt) { char quote = text.charAt(quoteAt); + // Groovy's triple-quoted literals are a different delimiter, not three of + // this one. Treating the opener as a single quote made a triple-quoted note + // close on the first apostrophe it contains -- can't, in the case that found + // this -- and threw the rest of the fragment out of step, so a strict pin + // after it was never seen. + boolean tripled = quoteAt + 2 < text.length() + && text.charAt(quoteAt + 1) == quote + && text.charAt(quoteAt + 2) == quote; + if (tripled) { + for (int i = quoteAt + 3; i + 2 < text.length(); i++) { + char c = text.charAt(i); + if (c == '\\') { + i++; + } else if (c == quote && text.charAt(i + 1) == quote + && text.charAt(i + 2) == quote) { + return i + 2; + } + } + return text.length(); + } for (int i = quoteAt + 1; i < text.length(); i++) { char c = text.charAt(i); if (c == '\\') { @@ -1027,7 +1070,6 @@ private static String[] activeLines(String fragment) { } StringBuilder out = new StringBuilder(); boolean inBlockComment = false; - char quote = 0; for (int i = 0; i < fragment.length(); i++) { char c = fragment.charAt(i); if (inBlockComment) { @@ -1039,19 +1081,14 @@ private static String[] activeLines(String fragment) { } continue; } - if (quote != 0) { - out.append(c); - if (c == '\\' && i + 1 < fragment.length()) { - out.append(fragment.charAt(i + 1)); - i++; - } else if (c == quote) { - quote = 0; - } - continue; - } if (c == '\'' || c == '"') { - quote = c; - out.append(c); + // The shared rule, so triple-quoted literals and escapes are the + // same thing here as everywhere else. This scanner and the statement + // scanner below kept their own copies through the consolidation, and + // the triple-quote fix reached neither until now. + int end = endOfStringLiteral(fragment, i); + out.append(fragment, i, Math.min(end + 1, fragment.length())); + i = end; continue; } if (c == '/' && i + 1 < fragment.length()) { @@ -1127,27 +1164,19 @@ private static String[] statements(String text) { List out = new ArrayList(); StringBuilder current = new StringBuilder(); int depth = 0; - char quote = 0; for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); - if (quote != 0) { - current.append(c); - // Escapes, because the comment stripper beside this already handles - // them: 'can\'t' otherwise closes the string on the apostrophe it is - // escaping, and every following newline is read as being inside a - // string rather than ending a statement -- which merges statements - // that must stay apart. - if (c == '\\' && i + 1 < text.length()) { - current.append(text.charAt(i + 1)); - i++; - } else if (c == quote) { - quote = 0; - } + if (c == '\'' || c == '"') { + // The shared rule: escapes and triple quotes handled in one place. + // A literal that closed early here merged statements that must stay + // apart, which lets one statement's configuration pair with another + // statement's coordinate. + int end = endOfStringLiteral(text, i); + current.append(text, i, Math.min(end + 1, text.length())); + i = end; continue; } - if (c == '\'' || c == '"') { - quote = c; - } else if (c == '(') { + if (c == '(') { depth++; } else if (c == ')') { if (depth > 0) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 2afdcdb43ed..c3187313fb1 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -457,6 +457,61 @@ public void aDefReferenceWithAMultilineClosureIsStillAPin() { "the strict base pin behind a def with a multiline closure is honoured"); } + /** + * A triple-quoted literal is a different delimiter, not three of the same + * one. Reading its opener as a single quote made it close on the first + * apostrophe inside it and threw every following statement out of step, so + * a strict pin after it was never seen. + */ + @Test + public void aTripleQuotedLiteralDoesNotEndOnItsOwnApostrophe() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def note = '''can't stop'''\n" + + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n"); + check("".equals(out), + "the strict pin after a triple-quoted note is still seen"); + } + + /** + * A runtimeOnly pre-merge pin suppresses both constraints. + * + *

This pins existing behaviour rather than verifying a fix: it was + * reported as broken, and reverting the change it prompted leaves this + * passing, because the configuration predicate accepts every main + * configuration whatever it is handed. Kept because the behaviour is worth + * holding, and labelled so nobody reads it as proof of something it does + * not test.

+ */ + @Test + public void aRuntimeOnlyPreMergePinSuppressesBoth() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " runtimeOnly 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); + check("".equals(out), + "a runtimeOnly pre-merge pin takes the sibling constraint with it"); + } + + /** + * The strict bypass reads both spellings. Asking only about the strictly + * keyword let a !! pin on a variant configuration be filtered out as a + * variant declaration and get the constraint anyway -- against a strict + * requirement that resolved fine before it. + */ + @Test + public void aShorthandPinOnAVariantIsStillStrict() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " debugImplementation " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n"); + check("".equals(out), + "a !! pin on a variant configuration is honoured like a strictly call"); + + // and a variant declaration that is NOT strict still does not suppress + String plain = KotlinStdlibAlignment.constraintsBlock("implementation", + " debugImplementation " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); + check(plain.contains("kotlin-stdlib-jdk8:1.8.0"), + "a plain variant declaration still does not suppress"); + } + /** * A known definition referred to as $name inside a double-quoted string is * the same one hop already followed for a bare token. Reading it as From 1d2df8aba786f0a5fbdbe11228c39afd69382747 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:06:30 +0300 Subject: [PATCH 29/94] Read a literal's real delimiters, its position, and the script it belongs to Three findings, each one a place where reading part of a declaration gave the wrong answer and the block was suppressed for an app that had pinned nothing. A reason can be nothing BUT a coordinate. The whitespace rule separates prose from notation and could not see this one: `because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'` names the artifact it is warning about, has no whitespace, and read as a declaration -- so the comment describing the duplicate switched off the constraint that prevents it. A literal in a reason's argument position is not a dependency, whatever it contains. A triple-quoted literal's content is not what you get by stripping one character from each end. Every caller was doing exactly that, so a coordinate written with the long delimiter kept two quotes at each end, had no readable version, and was classified pre-merge. One helper knows the delimiter length now and every reader goes through it. And the fragments are one script. They arrive as separate build hints but the builder concatenates them into a single generated build.gradle, so a `def` in android.gradlePlugin is in scope for a use in android.gradleDep. Reading them apart lost the definition at the boundary and the strict pin behind it went unseen -- the failure this must never produce, since a constraint cannot coexist with a strict version. Three new cases, each failing with its own fix reverted, checked one at a time rather than together: reverting all three at once reported three failures without saying which case belonged to which, which is not the same evidence. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 111 ++++++++++++++---- .../builders/KotlinStdlibAlignmentTest.java | 47 ++++++++ 2 files changed, 138 insertions(+), 20 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index badddc28e4a..a583e315bf6 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -251,11 +251,8 @@ public static String constraintsBlock(String configuration, */ private static boolean declaredBelowTheFloor(String artifact, String configuration, String[] appGradleFragments) { - if (appGradleFragments == null) { - return false; - } - for (int i = 0; i < appGradleFragments.length; i++) { - String[] lines = activeLines(appGradleFragments[i]); + String[] lines = activeLines(combined(appGradleFragments)); + { for (int j = 0; j < lines.length; j++) { // The configuration actually being constrained, rather than the two // that used to be named here. That was reported as letting a @@ -305,7 +302,7 @@ private static String declaredVersionOf(String line, String artifact) { continue; } int end = endOfStringLiteral(line, i); - String literal = line.substring(i + 1, Math.min(end, line.length())); + String literal = stringLiteralContent(line, i); if (literal.startsWith(coordinate) && !hasWhitespace(literal)) { return literal.substring(coordinate.length()); } @@ -384,11 +381,8 @@ && isIdentifierChar(line.charAt(after)))) { * duplicate class it risks instead can.

*/ private static boolean strictlyPinsBaseStdlibBelowTheFloor(String[] appGradleFragments) { - if (appGradleFragments == null) { - return false; - } - for (int i = 0; i < appGradleFragments.length; i++) { - String[] lines = activeLines(appGradleFragments[i]); + String[] lines = activeLines(combined(appGradleFragments)); + { for (int j = 0; j < lines.length; j++) { if (!namesBaseStdlib(lines[j])) { continue; @@ -494,7 +488,7 @@ private static boolean namesCoordinate(String line, String artifact) { if (c == '\\' && i + 1 < line.length()) { i++; } else if (c == quote) { - String literal = line.substring(stringStart + 1, i); + String literal = stringLiteralContent(line, stringStart); // Dependency notation carries no whitespace; a reason sentence // does. Without that, a reason that merely OPENS with the // coordinate -- because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8: @@ -503,7 +497,8 @@ private static boolean namesCoordinate(String line, String artifact) { // have prevented exactly what it describes. if ((literal.equals(coordinate) || literal.startsWith(coordinate + ":")) - && !hasWhitespace(literal)) { + && !hasWhitespace(literal) + && !isReasonArgument(line, stringStart)) { return true; } quote = 0; @@ -518,6 +513,60 @@ private static boolean namesCoordinate(String line, String artifact) { return false; } + /** + * Whether the literal opening at {@code quoteAt} is the argument of a + * reason rather than a dependency. + * + *

A reason is usually prose and the whitespace rule catches it, but a + * reason can be nothing BUT a coordinate -- {@code because + * 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'} names the artifact it + * is warning about and has no whitespace at all. Read as a declaration, it + * supplied a pre-merge version and suppressed the entire block: the + * comment describing the duplicate switched off the constraint that + * prevents it.

+ */ + private static boolean isReasonArgument(String line, int quoteAt) { + int i = quoteAt - 1; + while (i >= 0 && (line.charAt(i) == ' ' || line.charAt(i) == '\t' + || line.charAt(i) == '(')) { + i--; + } + int end = i + 1; + while (i >= 0 && isIdentifierChar(line.charAt(i))) { + i--; + } + return end > i + 1 && BECAUSE.equals(line.substring(i + 1, end)); + } + + private static final String BECAUSE = "because"; + + /** + * The app's fragments as the one script they become. + * + *

They are separate build hints but the builder concatenates them into + * a single generated {@code build.gradle}, so a {@code def} written in one + * is in scope for the next. Reading them apart lost the definition at the + * boundary and a strict pin behind it went unseen -- which is the failure + * this must never produce, since a constraint cannot coexist with a strict + * version.

+ */ + private static String combined(String[] appGradleFragments) { + if (appGradleFragments == null) { + return ""; + } + StringBuilder out = new StringBuilder(); + for (int i = 0; i < appGradleFragments.length; i++) { + if (appGradleFragments[i] == null) { + continue; + } + if (out.length() > 0) { + out.append('\n'); + } + out.append(appGradleFragments[i]); + } + return out.toString(); + } + /** Whether the text contains any whitespace. */ private static boolean hasWhitespace(String text) { for (int i = 0; i < text.length(); i++) { @@ -744,11 +793,8 @@ private static int parseSegment(String segment) { */ private static boolean declaresArtifact(String artifact, String configuration, String[] appGradleFragments) { - if (appGradleFragments == null) { - return false; - } - for (int i = 0; i < appGradleFragments.length; i++) { - String[] lines = activeLines(appGradleFragments[i]); + String[] lines = activeLines(combined(appGradleFragments)); + { for (int j = 0; j < lines.length; j++) { if (declaresArtifactOnLine(artifact, configuration, lines[j])) { return true; @@ -875,6 +921,32 @@ private static boolean isIdentifierChar(char c) { * brace counter that did the same swallowed a declaration's closing brace. * They call this now, so a fix reaches all of them.

*/ + /** + * The content of the literal opening at {@code quoteAt}, without its + * delimiters. + * + *

Stripping one character from each end is wrong for a triple-quoted + * literal, and every caller was doing exactly that: a coordinate written + * with the long delimiter came back still wearing two quotes at each end, + * so it had no readable version and the declaration was classified + * pre-merge -- taking the whole block with it.

+ */ + private static String stringLiteralContent(String text, int quoteAt) { + int end = endOfStringLiteral(text, quoteAt); + int delimiter = delimiterLength(text, quoteAt); + int from = Math.min(quoteAt + delimiter, text.length()); + int to = Math.max(from, Math.min(end + 1 - delimiter, text.length())); + return text.substring(from, to); + } + + /** 3 for a triple-quoted literal, 1 otherwise. */ + private static int delimiterLength(String text, int quoteAt) { + char quote = text.charAt(quoteAt); + return quoteAt + 2 < text.length() + && text.charAt(quoteAt + 1) == quote + && text.charAt(quoteAt + 2) == quote ? 3 : 1; + } + private static int endOfStringLiteral(String text, int quoteAt) { char quote = text.charAt(quoteAt); // Groovy's triple-quoted literals are a different delimiter, not three of @@ -1463,8 +1535,7 @@ private static int trailingBraceBalance(String statement) { continue; } int end = endOfStringLiteral(statement, i); - if (statement.substring(i + 1, Math.min(end, statement.length())) - .startsWith(KOTLIN_GROUP)) { + if (stringLiteralContent(statement, i).startsWith(KOTLIN_GROUP)) { return braceBalance(statement.substring(Math.min(end + 1, statement.length()))); } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index c3187313fb1..350b75020e9 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -457,6 +457,53 @@ public void aDefReferenceWithAMultilineClosureIsStillAPin() { "the strict base pin behind a def with a multiline closure is honoured"); } + /** + * A reason can be nothing BUT a coordinate, so the whitespace rule does not + * catch it. `because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'` + * names the artifact it warns about; read as a declaration it supplied a + * pre-merge version and suppressed the whole block -- the comment + * describing the duplicate switching off the constraint that prevents it. + */ + @Test + public void aReasonThatIsOnlyACoordinateIsStillAReason() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('com.example:other:1.0') " + + "{ because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22' }\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a bare-coordinate reason does not declare anything"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and does not take the block with it"); + } + + /** + * A triple-quoted coordinate keeps its version. Stripping one character + * per side left the long delimiter's extra quotes on the content, so the + * version was unreadable and the declaration read as pre-merge. + */ + @Test + public void aTripleQuotedCoordinateKeepsItsVersion() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation '''org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22'''\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a merged-era triple-quoted declaration leaves the sibling constrained"); + check(!out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and pins its own artifact"); + } + + /** + * The fragments are separate build hints but one generated script, so a + * def written in one is in scope for the next. Reading them apart lost the + * definition at the boundary and the strict pin behind it went unseen. + */ + @Test + public void aDefinitionCrossesTheFragmentBoundary() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def stdlib = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n", + " implementation(stdlib) { version { strictly '1.7.22' } }\n"); + check("".equals(out), + "a definition in one fragment reaches a use in the next"); + } + /** * A triple-quoted literal is a different delimiter, not three of the same * one. Reading its opener as a single quote made it close on the first From ca4de85d1c0c4e92e8a5943a3f316fcfda1b476b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:56:41 +0300 Subject: [PATCH 30/94] Sweep the spelling space, and stop the alignment from ever failing a build The review was finding this class one example at a time, and the examples kept being the same defect in a different place. Two changes of shape, then the individual fixes. Sweeping instead of exemplifying. Every equivalent spelling of a strict pre-merge pin is now asserted to suppress the block -- artifact, delimiter, configuration, call form, definition, decoration -- and the sweep immediately found a case no review comment had: a triple-quoted definition expanded to ""1.7.22"", no version parsed out of it, and the constraint written beside a live strict pin. That one does not fail the build; it resolves quietly to the empty shims and throws NoClassDefFoundError on the device. The one-character delimiter assumption behind it was live in three more places -- the map value, the interpolation, and a second hand-rolled quote scanner in declaresOn -- all now on the shared rule. The complementary sweep is asserted too: ordinary project text still gets both constraints, which is the direction that fails in silence. Never failing a build. The scanner reads developer-authored Groovy on every AndroidX build there is, to decide something that is an optimisation over a build which already worked apart from one duplicate class. A defect in it would not cost one app its constraint, it would cost every app its build. The call is now guarded so its worst case is "emit nothing", which is the behaviour before this feature existed, and the guard is pinned by a test. The individual findings: fragment order now matches the order the generated script emits them, since a definition is only in scope for what follows it; prefer is no longer read as a lower bound, and a declaration that binds no version no longer stands in for the constraint; typed locals declare a coordinate as much as def does; and Groovy's dollar-slashy literal no longer reads as a line comment. Plain slashy is deliberately still unhandled -- a lone slash is also division and both comment starts -- with the reasoning in the code. Declined, with the reasoning in the code: exempting strict pins on detached configurations. The Gradle fact is right, but acting on it means deciding from a configuration's NAME whether it shares a classpath with the constrained one, the names are open-ended, and "does not extend implementation" is not the same question as "cannot conflict". Getting that wrong is not symmetric. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 57 ++-- .../builders/KotlinStdlibAlignment.java | 148 +++++++-- .../builders/KotlinStdlibAlignmentTest.java | 293 ++++++++++++++++++ 3 files changed, 447 insertions(+), 51 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index db0f90dda72..8af5e2196a6 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7300,24 +7300,45 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { String kotlinStdlibConstraints = ""; if (useAndroidX && gradleVersionInt >= 6 && request.getArg("android.kotlinStdlibAlignment", "true").equals("true")) { - kotlinStdlibConstraints = KotlinStdlibAlignment.constraintsBlock( - compile, - // Every app-controlled fragment that reaches the generated - // dependencies block. Read off ShieldInjector's GRADLE_TEXT_HINTS, - // which is this tree's enumeration of hints interpolated into a - // Gradle file, rather than off the ones that came to mind -- - // android.supportv4Dep was missed exactly that way, and so was - // android.gradlePlugin: it is interpolated at top level right after - // `apply plugin`, where a dependencies { } block of its own is - // valid and reaches the same configurations. The rest of that list - // lands in buildscript, repositories or the android block, where a - // dependency cannot be declared. - request.getArg("android.gradlePlugin", ""), - additionalDependencies, - aiExtraGradleDependencies.toString(), - request.getArg("android.gradleDep", ""), - request.getArg("android.supportv4Dep", ""), - request.getArg("android.xgradle", "")); + try { + kotlinStdlibConstraints = KotlinStdlibAlignment.constraintsBlock( + compile, + // Every app-controlled fragment that reaches the generated + // dependencies block. Read off ShieldInjector's GRADLE_TEXT_HINTS, + // which is this tree's enumeration of hints interpolated into a + // Gradle file, rather than off the ones that came to mind -- + // android.supportv4Dep was missed exactly that way, and so was + // android.gradlePlugin: it is interpolated at top level right after + // `apply plugin`, where a dependencies { } block of its own is + // valid and reaches the same configurations. The rest of that list + // lands in buildscript, repositories or the android block, where a + // dependency cannot be declared. + // In the order the generated script emits them, because a + // definition is only in scope for what comes after it: gradlePlugin + // at the top, then the dependencies block in its own order, then + // xgradle after it. Listing them in any other order lost a + // definition that the real script would have had in scope. + request.getArg("android.gradlePlugin", ""), + request.getArg("android.supportv4Dep", ""), + additionalDependencies, + aiExtraGradleDependencies.toString(), + request.getArg("android.gradleDep", ""), + request.getArg("android.xgradle", "")); + } catch (RuntimeException e) { + // The alignment reads the app's Gradle text to decide whether the app + // already manages the stdlib family, and that reading is a scanner + // over arbitrary developer-authored Groovy. It runs on EVERY AndroidX + // build, so an index defect anywhere in it would not break one app, + // it would break all of them -- and the whole block is an optimisation + // over a build that already worked apart from one duplicate class. + // So its worst case is made "emit nothing", which is exactly the + // behaviour before this feature existed, and never a failed build. + // Logged rather than swallowed, because a silent catch here would + // hide the defect for as long as nobody reported the duplicate. + kotlinStdlibConstraints = ""; + log("NOTICE: skipping the Kotlin stdlib alignment, its read of the " + + "project's Gradle text failed: " + e); + } } String gradleProps = "apply plugin: 'com.android.application'\n" diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index a583e315bf6..96f23cd4b6e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -282,6 +282,14 @@ private static boolean declaredBelowTheFloor(String artifact, String configurati return false; } + /** + * Whether this statement establishes a version for {@code artifact} that + * Gradle will actually hold it to. + */ + private static boolean bindsAVersion(String line, String artifact) { + return declaredVersionOf(line, artifact) != null; + } + /** Whether the statement names the artifact, in either spelling. */ private static boolean namesArtifactAnywhere(String line, String artifact) { return namesCoordinate(line, artifact) @@ -355,9 +363,12 @@ && isIdentifierChar(line.charAt(after)))) { } j = skipBlanks(line, j + 1); if (j < line.length() && (line.charAt(j) == '\'' || line.charAt(j) == '"')) { - int end = endOfStringLiteral(line, j); - if (end < line.length()) { - return line.substring(j + 1, end); + if (endOfStringLiteral(line, j) < line.length()) { + // The real delimiter length, as the coordinate path does. Stripping + // one character per side left a triple-quoted group or name wearing + // two quotes, so both failed their exact match and the declaration + // was ignored -- strict pin and all. + return stringLiteralContent(line, j); } } i = j; @@ -592,17 +603,20 @@ private static String strictVersionIn(String statement) { * with no version at all, which the conservative path then treated as * below the floor -- dropping BOTH constraints for a declaration that was * already merged-era and needed only its sibling left alone.

+ * + *

{@code prefer} is deliberately NOT read here. A preference is soft: + * Gradle takes it only when nothing stronger is in play, so a transitive + * requirement for a pre-merge shim beats it and the class-bearing jar wins + * anyway. Treating a preference as proof the artifact cannot resolve below + * the floor suppressed the constraint that was the only thing standing + * between that graph and the duplicate.

*/ private static String richVersionIn(String statement) { String strict = versionInCall(statement, STRICTLY); if (strict != null) { return strict; } - String required = versionInCall(statement, "require"); - if (required != null) { - return required; - } - return versionInCall(statement, "prefer"); + return versionInCall(statement, "require"); } /** The quoted argument of {@code call}, found outside string literals. */ @@ -634,7 +648,12 @@ private static String versionInCall(String statement, String call) { || statement.charAt(after) == '"')) { int end = endOfStringLiteral(statement, after); if (end < statement.length()) { - return statement.substring(after + 1, end); + // The literal's own delimiters, however many it has. Written + // strictly """1.7.22""", the one-per-side slice returned + // ""1.7.22"" -- which parsed as no version at all and only + // reached the right answer because an unreadable version counts + // as below the floor. Correct by accident is not correct. + return stringLiteralContent(statement, after); } } i = after; @@ -833,10 +852,39 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat // fails the whole script at evaluation, which is a far larger blast radius // than the case it fixes. Revisit only with a project that actually has this // shape. + // + // Reviewed a third time with a sharper argument: a pin on a DETACHED + // configuration -- annotationProcessor, kapt, ksp -- genuinely cannot + // conflict, because unlike the variant configurations those do not extend + // implementation, so suppressing on one leaves the release runtime graph + // unaligned for nothing. The Gradle fact is right. What it asks for is not + // available here: acting on it means deciding from a configuration's NAME + // whether it shares a classpath with the one being constrained, and + // - Android synthesises a configuration per build type and flavour, so the + // names are open-ended: debugAnnotationProcessor, freeReleaseImplementation, + // and whatever the next plugin adds, + // - "does not extend implementation" is not the same question as "cannot + // conflict": compileOnly does not extend it either, yet compileClasspath + // extends both, so a strict pin there does conflict. + // A name list that gets this wrong is not wrong symmetrically. Classifying a + // conflicting configuration as detached emits the constraint beside a live + // strict pin, which is measured to fail resolution outright -- and for the + // `1.7.22!!` spelling to resolve quietly to the empty shims and throw + // NoClassDefFoundError on the device instead. Classifying a detached one as + // conflicting costs an app that had already pinned the family the duplicate + // it already had. So this stays until the classification can be read from + // something better than a name. if (!holdsStrictly(line, artifact) && !declaresOnTheConstrainedConfiguration(configuration, line)) { return false; } + if (!bindsAVersion(line, artifact)) { + // A declaration that pins nothing cannot stand in for the constraint. The + // clearest case is a lone preference: our floor overrides it, so emitting + // is harmless, while suppressing leaves a transitive pre-merge shim free + // to win. A declaration with no version at all is the same argument. + return false; + } if (namesCoordinate(line, artifact)) { return true; } @@ -1052,31 +1100,26 @@ private static boolean declaresOnTheConstrainedConfiguration(String configuratio /** Whether this line declares on {@code configuration}, as a whole token. */ private static boolean declaresOn(String configuration, String line) { - char quote = 0; - int stringStart = -1; for (int i = 0; i < line.length(); i++) { char c = line.charAt(i); - if (quote != 0) { - if (c == '\\' && i + 1 < line.length()) { - i++; - } else if (c == quote) { - // A configuration name inside a string counts in one place only: - // as the first argument of dependencies.add("runtimeOnly", ".."). - // Accepting any quoted occurrence read the word in a reason -- - // because 'implementation workaround' -- as a main-variant - // declaration, which suppressed the constraint for a dependency - // that only affects debug. - if (line.substring(stringStart + 1, i).equals(configuration) - && isAddCallArgument(line, stringStart)) { - return true; - } - quote = 0; - } - continue; - } if (c == '\'' || c == '"') { - quote = c; - stringStart = i; + // The shared rule rather than a third hand-rolled quote scanner. This + // one tracked a single delimiter character, so a triple-quoted name + // was read as an empty string followed by unquoted text -- the same + // defect that was live in the map-value and interpolation paths. + int end = endOfStringLiteral(line, i); + // A configuration name inside a string counts in one place only: + // as the first argument of dependencies.add("runtimeOnly", ".."). + // Accepting any quoted occurrence read the word in a reason -- + // because 'implementation workaround' -- as a main-variant + // declaration, which suppressed the constraint for a dependency + // that only affects debug. + if (end < line.length() + && stringLiteralContent(line, i).equals(configuration) + && isAddCallArgument(line, i)) { + return true; + } + i = end; continue; } if (line.startsWith(configuration, i)) { @@ -1153,6 +1196,24 @@ private static String[] activeLines(String fragment) { } continue; } + if (c == '$' && i + 1 < fragment.length() && fragment.charAt(i + 1) == '/') { + // Groovy's dollar-slashy literal. Its opener is unambiguous, and its + // content may start with a slash -- $/ /* /$ -- which the comment + // scanner below read as a line comment and used to discard the rest + // of the fragment, strict pin included. + // + // The PLAIN slashy form, /.../, is deliberately not recognised: a lone + // slash is also division and the start of both comment kinds, so + // telling them apart needs to know whether an expression is expected + // here, which is parsing rather than scanning. Guessing wrong there + // would swallow ordinary text, which is the failure this whole method + // exists to avoid. + int end = fragment.indexOf("/$", i + 2); + int stop = end < 0 ? fragment.length() : end + 2; + out.append(fragment, i, stop); + i = stop - 1; + continue; + } if (c == '\'' || c == '"') { // The shared rule, so triple-quoted literals and escapes are the // same thing here as everywhere else. This scanner and the statement @@ -1389,6 +1450,20 @@ private static void updateLiteralDefinitions(String statement, i = skipBlanks(statement, at + DEF.length()); } else { i = skipBlanks(statement, 0); + // A typed local declares just as much as def does: `String dep = '...'`. + // Two identifiers before the '=' is a declaration, one is an assignment, + // and only the first token differs. + int typeEnd = i; + while (typeEnd < statement.length() + && isIdentifierChar(statement.charAt(typeEnd))) { + typeEnd++; + } + int afterType = skipBlanks(statement, typeEnd); + if (typeEnd > i && afterType < statement.length() + && isIdentifierChar(statement.charAt(afterType))) { + declared = true; + i = afterType; + } } int nameStart = i; while (i < statement.length() && isIdentifierChar(statement.charAt(i))) { @@ -1485,8 +1560,15 @@ && isIdentifierChar(literal.charAt(nameEnd))) { out.append(c); continue; } - // Stored with its quotes, which do not belong inside another string. - out.append(value, 1, value.length() - 1); + // Stored with its quotes, which do not belong inside another string -- + // and with however many of them the literal was written with. Stripping + // one per side left a triple-quoted definition expanding to ""1.7.22"", + // no version parsed out of it, and the constraint written beside a + // strict pre-merge pin: the one outcome that fails at RUNTIME rather + // than in the build. Found by sweeping equivalent spellings of a strict + // pin, not by reading this line; the same one-character assumption was + // live in two other places. + out.append(stringLiteralContent(value, 0)); i = braced ? nameEnd : nameEnd - 1; } return out.toString(); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 350b75020e9..bd31728cfc2 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -457,6 +457,299 @@ public void aDefReferenceWithAMultilineClosureIsStillAPin() { "the strict base pin behind a def with a multiline closure is honoured"); } + /** + * The alignment can never fail a build. It reads developer-authored Groovy + * with a hand-written scanner, on every AndroidX build there is, to decide + * something that is an optimisation over a build which already worked -- + * so an index defect in it must cost that one app its constraint, not + * every app its build. The guard is asserted here rather than trusted, + * because nothing else in the suite would notice it being refactored away. + */ + @Test + public void theAlignmentCannotFailTheBuild() throws Exception { + byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); + String builderSrc = new String(bytes, "UTF-8"); + int at = builderSrc.indexOf("KotlinStdlibAlignment.constraintsBlock("); + check(at >= 0, "the builder calls the alignment"); + String before = builderSrc.substring(0, at); + check(before.lastIndexOf("try {") > before.lastIndexOf("catch ("), + "the call is inside a try block"); + String after = builderSrc.substring(at); + int handler = after.indexOf("catch (RuntimeException"); + check(handler >= 0, "and a RuntimeException handler follows it"); + // Past the handler's own reasoning, which is longer than the code. + String body = after.substring(handler, + Math.min(handler + 2000, after.length())); + check(body.indexOf("kotlinStdlibConstraints = \"\"") >= 0, + "which falls back to emitting nothing"); + check(body.indexOf("log(") >= 0, + "and says so, rather than swallowing the defect"); + } + + /** + * How a literal is delimited changes nothing about what it says, so the + * same declaration written four ways produces the same block. Asserted as + * an equivalence rather than case by case because the strict sweep already + * passed the triple-quoted spelling for the wrong reason: the version came + * back as {@code ""1.9.22""}, no version parsed out of it, and an + * unreadable version counts as below the floor -- which happens to be the + * safe answer, so nothing failed while the read was wrong. + */ + @Test + public void theDelimiterDoesNotChangeWhatADeclarationSays() { + String[] quotes = {"'", "\"", "'''", "\"\"\""}; + String[] shapes = { + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " + + "{ version { strictly %s1.9.22%s } }", + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " + + "{ version { require %s1.9.22%s } }", + "implementation group: %sorg.jetbrains.kotlin%s, " + + "name: %skotlin-stdlib-jdk8%s, version: '1.9.22'", + "dependencies.add(%simplementation%s, " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22')", + }; + for (int s = 0; s < shapes.length; s++) { + String expected = null; + for (int q = 0; q < quotes.length; q++) { + String text = shapes[s].replace("%s", quotes[q]); + String out = KotlinStdlibAlignment.constraintsBlock("implementation", text); + if (expected == null) { + expected = out; + continue; + } + check(expected.equals(out), + "the delimiter does not change the answer for <<" + text + + ">>: expected <<" + expected + ">> got <<" + out + ">>"); + } + } + } + + /** + * A pre-merge shim added through {@code dependencies.add} suppresses the + * block, whichever delimiter names the configuration. Emitting beside it + * raises kotlin-stdlib past the app's own class-bearing 1.7.22 jar, which + * is this block manufacturing the duplicate it exists to prevent. + */ + @Test + public void anAddedPreMergeShimSuppressesTheBlock() { + String[] quotes = {"'", "\"", "'''", "\"\"\""}; + for (int q = 0; q < quotes.length; q++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " dependencies.add(" + quotes[q] + "implementation" + quotes[q] + + ", 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22')\n"); + check("".equals(out), + "an added pre-merge shim suppresses the block, named with " + + quotes[q] + " but got <<" + out + ">>"); + } + } + + /** + * The complement of the sweep below, and the direction that fails in + * silence: an app whose Gradle text says nothing about Kotlin still gets + * both constraints. Every recognition rule added to this class is a new + * way to conclude "the app has this covered", and concluding it wrongly + * does not fail anything -- it just hands the duplicate class back to the + * app this whole change exists to fix, with no signal anywhere. + */ + @Test + public void ordinaryProjectTextStillGetsTheAlignment() { + String[] ordinary = { + "implementation 'androidx.appcompat:appcompat:1.6.1'", + "implementation('com.android.billingclient:billing:9.1.0')", + "implementation group: 'com.google.android.material', name: 'material', " + + "version: '1.11.0'", + "def v = '1.7.22'\nimplementation(\"com.squareup.okhttp3:okhttp:$v\")", + "annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'", + "implementation fileTree(dir: 'libs', include: ['*.jar'])", + // Commented out is not declared, in either comment syntax. + "// implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'", + "/* implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!' */", + // And a reason string is prose, not a pin. + "implementation('a:b:1.0') { because 'strictly 1.7.22 was never wanted' }", + "testImplementation 'junit:junit:4.13.2'", + "", + }; + String[] decorations = { + "%s", " %s", "dependencies {\n%s\n}", "%s // note", + "%s\nimplementation 'com.google.code.gson:gson:2.10.1'", + }; + for (int o = 0; o < ordinary.length; o++) { + for (int d = 0; d < decorations.length; d++) { + String text = decorations[d].replace("%s", ordinary[o]); + String out = KotlinStdlibAlignment.constraintsBlock("implementation", text); + check(out.contains("kotlin-stdlib-jdk7:1.8.0") + && out.contains("kotlin-stdlib-jdk8:1.8.0"), + "both constraints are still written for <<" + text + + ">> but got <<" + out + ">>"); + } + } + } + + /** + * Every equivalent way of writing a strict pre-merge pin suppresses the + * block. This is a sweep rather than an example, because the examples were + * being found one review comment at a time while the same defect sat in + * three different places: a triple-quoted definition expanded to + * {@code ""1.7.22""}, no version was parsed out of it, and the constraint + * went in beside the strict pin -- which does not fail the build, it + * silently strips the classes and throws NoClassDefFoundError on the + * device. That is the one outcome this class must never produce, so the + * property is asserted over the whole spelling space and not over the + * spellings somebody happened to think of. + */ + @Test + public void everySpellingOfAStrictPreMergePinSuppressesTheBlock() { + String[] artifacts = {"kotlin-stdlib", "kotlin-stdlib-jdk7", "kotlin-stdlib-jdk8"}; + String[] quotes = {"'", "\"", "'''", "\"\"\""}; + String[] configurations = {"implementation", "api", "compile"}; + int checked = 0; + for (int a = 0; a < artifacts.length; a++) { + String coordinate = "org.jetbrains.kotlin:" + artifacts[a]; + for (int q = 0; q < quotes.length; q++) { + String u = quotes[q]; + for (int c = 0; c < configurations.length; c++) { + String on = configurations[c]; + String[] forms = { + on + "(" + u + coordinate + ":1.7.22!!" + u + ")", + on + " " + u + coordinate + ":1.7.22!!" + u, + on + "(" + u + coordinate + u + ") { version { strictly " + + u + "1.7.22" + u + " } }", + on + "(" + u + coordinate + u + ")\n{ version { strictly " + + u + "1.7.22" + u + " } }", + "def v = " + u + "1.7.22" + u + "\n" + on + "(\"" + + coordinate + ":$v!!\")", + "def d = " + u + coordinate + ":1.7.22!!" + u + "\n" + on + "(d)", + "String d = " + u + coordinate + ":1.7.22!!" + u + "; " + on + "(d)", + }; + for (int f = 0; f < forms.length; f++) { + String[] decorated = { + forms[f], + " " + forms[f], + "\t" + forms[f] + " ", + forms[f] + " // a note", + "/* lead */ " + forms[f], + "dependencies {\n" + forms[f] + "\n}", + "repositories { mavenCentral() }\n" + forms[f], + forms[f] + "\nimplementation 'androidx.appcompat:appcompat:1.6.1'", + "implementation 'androidx.appcompat:appcompat:1.6.1'\n" + forms[f], + }; + for (int d = 0; d < decorated.length; d++) { + checked++; + String out = KotlinStdlibAlignment.constraintsBlock( + "implementation", decorated[d]); + check("".equals(out), + "a strict pre-merge pin suppresses the block, written as <<" + + decorated[d] + ">> but got <<" + out + ">>"); + } + } + } + } + } + check(checked > 2000, "the sweep really ran over the matrix: " + checked); + } + + /** + * A preference is soft: Gradle takes it only when nothing stronger is in + * play, so a transitive requirement for a pre-merge shim beats it. Reading + * one as proof the artifact cannot resolve below the floor suppressed the + * constraint that was the only thing standing between that graph and the + * duplicate. + */ + @Test + public void aPreferenceDoesNotStandInForTheConstraint() { + String modern = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " + + "{ version { prefer '1.9.22' } }\n"); + check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), + "a preferred version does not suppress the constraint"); + + String old = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " + + "{ version { prefer '1.7.22' } }\n"); + check(old.contains("kotlin-stdlib-jdk8:1.8.0"), + "and neither does an old one, which the floor simply overrides"); + + // a required version still does + String required = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " + + "{ version { require '1.9.22' } }\n"); + check(!required.contains("kotlin-stdlib-jdk8:1.8.0"), + "a required version still binds"); + } + + /** + * A map value written with the long delimiter keeps its content. Stripping + * one character per side left the group and name wearing two quotes, so + * both failed their exact match and the declaration was ignored. + */ + @Test + public void aTripleQuotedMapValueKeepsItsContent() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation group: '''org.jetbrains.kotlin''', " + + "name: '''kotlin-stdlib-jdk8''', version: '1.9.22'\n"); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a triple-quoted map declaration still pins its artifact"); + } + + /** + * A typed local declares as much as def does. + */ + @Test + public void aTypedLocalDefinesACoordinate() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " String dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" + + " implementation(dep) { version { strictly '1.7.22' } }\n"); + check("".equals(out), "a typed local carries the coordinate too"); + } + + /** + * Groovy's dollar-slashy literal may open with a slash, which the comment + * scanner read as a line comment and used to discard the rest of the + * fragment, strict pin included. The PLAIN slashy form is deliberately not + * recognised -- a lone slash is also division and both comment kinds -- + * and that limit has its own assertion below. + */ + @Test + public void aDollarSlashyLiteralDoesNotOpenAComment() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + // On one line, because a line comment only reaches the end of its + // own line: put the pin on the next one and the test passes with + // the literal unrecognised, which proves nothing. + " def marker = $//*/$; " + + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(out), + "the strict pin after a dollar-slashy literal is still seen"); + } + + /** + * The builder hands the fragments over in the order the generated script + * emits them, because a definition is only in scope for what follows it. + */ + @Test + public void theBuilderPassesFragmentsInGeneratedOrder() throws Exception { + byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); + String builderSrc = new String(bytes, "UTF-8"); + int at = builderSrc.indexOf("KotlinStdlibAlignment.constraintsBlock("); + check(at >= 0, "the builder calls the alignment"); + String call = builderSrc.substring(at, builderSrc.indexOf(";", at)); + // The call carries a comment naming those same hints, in an order that + // has nothing to do with the arguments -- read the arguments only. + call = call.replaceAll("//[^\n]*", ""); + int plugin = call.indexOf("getArg(\"android.gradlePlugin\""); + int support = call.indexOf("getArg(\"android.supportv4Dep\""); + int additional = call.indexOf("additionalDependencies"); + int dep = call.indexOf("getArg(\"android.gradleDep\""); + int xgradle = call.indexOf("getArg(\"android.xgradle\""); + check(plugin >= 0 && support >= 0 && additional >= 0 && dep >= 0 && xgradle >= 0, + "every app-controlled fragment is passed"); + check(plugin < support && support < additional && additional < dep + && dep < xgradle, + "and in the order the generated script emits them"); + } + /** * A reason can be nothing BUT a coordinate, so the whitespace rule does not * catch it. `because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'` From c0513a766ba5b8ce3467be7b0c4253fea9a10040 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:18:20 +0300 Subject: [PATCH 31/94] Give the last scanner the shared rule, and stop reading a strict pin as binding nothing Five findings, four of them the same two shapes this class keeps producing: a rule written out twice, and a literal read as if its delimiter were one character. The one that was mine. Last round's bindsAVersion guard -- a declaration that pins nothing cannot stand in for the constraint -- also rejected a pin whose version this cannot evaluate. `strictly kotlinVersion` takes its version from a property, so the guard read a live strict pin as binding nothing and emitted the constraint next to it. That is the direction that does not fail the build: it resolves to the empty shims and throws NoClassDefFoundError on the device. A strict pin is now exempt from the guard, readable or not, which is the conservative fall-back belowTheFloor already takes for the same reason. The rule written twice. activeLines learned Groovy's dollar-slashy literal last round and statements() did not, so an apostrophe inside $/can't/$ still opened a string there and swallowed the pin behind it. And namesCoordinate was the last scanner still tracking a delimiter character of its own: it closed a triple-quoted literal on the second of the three and read the third as a new opener, so a reason written '''...''' lost its `because` and was taken for the declaration it was warning about. Every scanner in the file now uses endOfStringLiteral; there are no hand-rolled ones left. The rest. A typed local is written with however many modifiers the author felt like, so `final String dep` is a declaration and counting exactly two tokens read `String` as its name. And ext.kotlinVersion = '1.9.22' really does bind the bare name the interpolation reads, which is how a project-wide Kotlin version is nearly always written -- restricted to that one prefix on purpose, since recording any dotted assignment would let an unrelated property supply a version it does not bind and turn an unreadable version into a confidently wrong one. The sweep grew the new spellings, and three of its definition forms turned out to be proving nothing: written `def d = 'coord:1.7.22!!'`, that line names the artifact and ends in !!, so it suppressed on its own whether or not the name was ever carried to the usage. They now carry the coordinate and nothing else, with the strict pin on the usage, which is what makes the inlining the thing under test. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 115 ++++++++++++------ .../builders/KotlinStdlibAlignmentTest.java | 68 ++++++++++- 2 files changed, 146 insertions(+), 37 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 96f23cd4b6e..575bda4ef63 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -491,35 +491,31 @@ private static boolean namesBaseStdlib(String line) { */ private static boolean namesCoordinate(String line, String artifact) { String coordinate = KOTLIN_GROUP + ":" + artifact; - char quote = 0; - int stringStart = -1; for (int i = 0; i < line.length(); i++) { char c = line.charAt(i); - if (quote != 0) { - if (c == '\\' && i + 1 < line.length()) { - i++; - } else if (c == quote) { - String literal = stringLiteralContent(line, stringStart); - // Dependency notation carries no whitespace; a reason sentence - // does. Without that, a reason that merely OPENS with the - // coordinate -- because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8: - // 1.7.22 causes duplicate classes' -- read as the declaration it - // was warning about, and switched off the constraint that would - // have prevented exactly what it describes. - if ((literal.equals(coordinate) - || literal.startsWith(coordinate + ":")) - && !hasWhitespace(literal) - && !isReasonArgument(line, stringStart)) { - return true; - } - quote = 0; - } + if (c != '\'' && c != '"') { continue; } - if (c == '\'' || c == '"') { - quote = c; - stringStart = i; + // The shared rule. This was the last scanner still tracking a single + // delimiter character of its own: it closed a triple-quoted literal on + // the second of the three, then read the third as a new opener, so a + // reason written '''...''' lost its `because` and was taken for the + // declaration it was warning about. + int end = endOfStringLiteral(line, i); + String literal = stringLiteralContent(line, i); + // Dependency notation carries no whitespace; a reason sentence + // does. Without that, a reason that merely OPENS with the + // coordinate -- because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8: + // 1.7.22 causes duplicate classes' -- read as the declaration it + // was warning about, and switched off the constraint that would + // have prevented exactly what it describes. + if ((literal.equals(coordinate) + || literal.startsWith(coordinate + ":")) + && !hasWhitespace(literal) + && !isReasonArgument(line, i)) { + return true; } + i = end; } return false; } @@ -878,11 +874,18 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat && !declaresOnTheConstrainedConfiguration(configuration, line)) { return false; } - if (!bindsAVersion(line, artifact)) { + if (!holdsStrictly(line, artifact) && !bindsAVersion(line, artifact)) { // A declaration that pins nothing cannot stand in for the constraint. The // clearest case is a lone preference: our floor overrides it, so emitting // is harmless, while suppressing leaves a transitive pre-merge shim free // to win. A declaration with no version at all is the same argument. + // + // A STRICT pin is exempt, readable or not. `strictly kotlinVersion` takes + // its version from a property this cannot evaluate, and reading that as + // "binds nothing" emitted the constraint beside a pin that may well be + // pre-merge -- the one direction that fails at runtime rather than in the + // build. Unreadable falls back to the conservative answer here for the + // same reason it does in belowTheFloor. return false; } if (namesCoordinate(line, artifact)) { @@ -1299,6 +1302,18 @@ private static String[] statements(String text) { int depth = 0; for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); + if (c == '$' && i + 1 < text.length() && text.charAt(i + 1) == '/') { + // Groovy's dollar-slashy literal, recognised here as well as in the + // comment scanner. Fixing only that one left this scanner reading an + // apostrophe inside $/can't/$ as an opening quote, which swallowed + // the strict pin that followed on the same line. The same rule in two + // scanners is the shape this class keeps getting wrong. + int slashy = text.indexOf("/$", i + 2); + int stop = slashy < 0 ? text.length() : slashy + 2; + current.append(text, i, stop); + i = stop - 1; + continue; + } if (c == '\'' || c == '"') { // The shared rule: escapes and triple quotes handled in one place. // A literal that closed early here merged statements that must stay @@ -1450,19 +1465,44 @@ private static void updateLiteralDefinitions(String statement, i = skipBlanks(statement, at + DEF.length()); } else { i = skipBlanks(statement, 0); - // A typed local declares just as much as def does: `String dep = '...'`. - // Two identifiers before the '=' is a declaration, one is an assignment, - // and only the first token differs. - int typeEnd = i; - while (typeEnd < statement.length() - && isIdentifierChar(statement.charAt(typeEnd))) { - typeEnd++; + // A typed local declares just as much as def does, and it is written + // with however many modifiers the author felt like: `String dep = ...`, + // `final String dep = ...`, `private static final String dep = ...`. + // Counting exactly two tokens read `String` as the name of a `final + // String dep` and never recorded dep at all. So walk every identifier + // token before the '=': more than one is a declaration whose name is the + // last of them, exactly one is an assignment. + int scan = i; + int lastTokenStart = i; + int tokens = 0; + while (scan < statement.length() && isIdentifierChar(statement.charAt(scan))) { + lastTokenStart = scan; + tokens++; + while (scan < statement.length() + && isIdentifierChar(statement.charAt(scan))) { + scan++; + } + scan = skipBlanks(statement, scan); } - int afterType = skipBlanks(statement, typeEnd); - if (typeEnd > i && afterType < statement.length() - && isIdentifierChar(statement.charAt(afterType))) { + if (tokens > 1) { declared = true; - i = afterType; + i = lastTokenStart; + } else if (tokens == 1 && scan < statement.length() + && statement.charAt(scan) == '.') { + // ext.kotlinVersion = '1.9.22' -- Gradle's extra properties, which is + // how a project-wide version is nearly always written, and which + // really does bind the bare name the interpolation then reads. + // Restricted to that one prefix on purpose: recording ANY dotted + // assignment would let `somePlugin.version = '1.0'` supply the value + // for an unrelated $version and turn an unreadable version into a + // confidently wrong one, which is the direction that under-suppresses. + int nameStart = skipBlanks(statement, scan + 1); + if (EXTRA_PROPERTIES.equals(statement.substring(lastTokenStart, scan).trim()) + && nameStart < statement.length() + && isIdentifierChar(statement.charAt(nameStart))) { + declared = true; + i = nameStart; + } } } int nameStart = i; @@ -1576,6 +1616,9 @@ && isIdentifierChar(literal.charAt(nameEnd))) { private static final String DEF = "def"; + /** Gradle's extra-properties prefix, the one dotted assignment worth reading. */ + private static final String EXTRA_PROPERTIES = "ext"; + /** Whether the text so far ends with a comma, ignoring trailing blanks. */ private static boolean endsWithComma(StringBuilder text) { for (int i = text.length() - 1; i >= 0; i--) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index bd31728cfc2..e9597a039bf 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -487,6 +487,55 @@ public void theAlignmentCannotFailTheBuild() throws Exception { "and says so, rather than swallowing the defect"); } + /** + * Gradle's extra properties are how a project-wide Kotlin version is + * nearly always written, and the bare name the interpolation reads really + * is bound by them. Stopping at {@code ext} left the version unreadable, + * which counts as below the floor -- so a project already on a merged-era + * Kotlin had the whole block suppressed and kept whatever pre-merge shim a + * transitive dependency dragged in. + */ + @Test + public void anExtraPropertyDefinesTheVersionItInterpolates() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " ext.kotlinVersion = '1.9.22'\n" + + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion\"\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the sibling is still aligned, got <<" + out + ">>"); + check(!out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and the merged-era declaration is left alone, got <<" + out + ">>"); + + // Only that one prefix: any dotted assignment would let an unrelated + // property supply a version it does not bind, which turns an unreadable + // version into a confidently wrong one. + String unrelated = KotlinStdlibAlignment.constraintsBlock("implementation", + " somePlugin.kotlinVersion = '1.9.22'\n" + + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion\"\n"); + check("".equals(unrelated), + "an unrelated dotted assignment stays unreadable, got <<" + unrelated + ">>"); + } + + /** + * A reason is prose however it is quoted. Read as a declaration, the + * comment describing the duplicate switches off the constraint that + * prevents it -- which is the whole block gone because of a warning about + * the thing the block exists to fix. + */ + @Test + public void aReasonIsProseInEveryDelimiter() { + String[] quotes = {"'", "\"", "'''", "\"\"\""}; + for (int q = 0; q < quotes.length; q++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('com.example:other:1.0') { because " + quotes[q] + + "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22" + + quotes[q] + " }\n"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0") + && out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a reason quoted with " + quotes[q] + + " is not a declaration, got <<" + out + ">>"); + } + } + /** * How a literal is delimited changes nothing about what it says, so the * same declaration written four ways produces the same block. Asserted as @@ -620,7 +669,24 @@ public void everySpellingOfAStrictPreMergePinSuppressesTheBlock() { "def v = " + u + "1.7.22" + u + "\n" + on + "(\"" + coordinate + ":$v!!\")", "def d = " + u + coordinate + ":1.7.22!!" + u + "\n" + on + "(d)", - "String d = " + u + coordinate + ":1.7.22!!" + u + "; " + on + "(d)", + // A strict pin whose version this cannot evaluate is still a + // strict pin; unreadable has to fall to the conservative side. + on + "(" + u + coordinate + u + ") { version { strictly kotlinVersion } }", + // The definition carries the coordinate and NOTHING else -- + // no version, no !! -- so the only thing that can suppress is + // the name being carried across to the strict usage. Written + // with the marker in the definition instead, these passed with + // the inlining switched off: that line names the artifact and + // ends in !!, so it suppressed on its own and the test proved + // nothing. However many modifiers the local was written with: + "String d = " + u + coordinate + u + "; " + on + + "(d) { version { strictly " + u + "1.7.22" + u + " } }", + "final String d = " + u + coordinate + u + "; " + on + + "(d) { version { strictly " + u + "1.7.22" + u + " } }", + "private static final String d = " + u + coordinate + u + "; " + on + + "(d) { version { strictly " + u + "1.7.22" + u + " } }", + // An apostrophe inside a dollar-slashy literal is not a quote. + "def m = $/can't/$; " + on + "(" + u + coordinate + ":1.7.22!!" + u + ")", }; for (int f = 0; f < forms.length; f++) { String[] decorated = { From 5dd0ba23b177c6da47294358cc67cf5905587a4a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:37:52 +0300 Subject: [PATCH 32/94] Ask one question about where a literal starts, and change position on slashy strings Three findings, and the first of them is the fourth time the same rule was taught to one scanner and not the rest: the coordinate matcher recognised only quotes, so a pin written $/org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22/$ was preserved by activeLines and statements and then not matched by anything. So the question moved. "Does a literal start here" was spelled out at fourteen sites; it is now asked once, of isLiteralStart, and every scanner reads the same answer. Both ad-hoc dollar-slashy branches are gone with it -- they were the two sites that had already been taught. endOfStringLiteral and delimiterLength learned the slashy delimiters in the same place, so a form added next arrives in one method rather than fourteen. Changed position: plain slashy is recognised. It was declined with the argument that telling /can't/ from total / 2 needs to know whether an expression is expected, which is parsing rather than scanning. Raised again with a better one: NOT recognising the literal fails in the SAME direction as recognising one that is not there, because an apostrophe inside it puts the quote scanner out of step and hides whatever follows, exactly as swallowing a division would. Both mistakes cost the same, so the only question left is which is likelier, and that is decidable from the character before the slash: after an identifier, a digit or a closing bracket -- which is every division a build script actually contains -- it is division. The comment openers are excluded outright, and a slashy literal ends at its line, so a wrong guess cannot run past it. Also: ext { kotlinVersion = '1.9.22' }. The dotted spelling went in last round and the closure form is at least as common; inside one, a bare assignment binds a project-wide name and the interpolation really does read it. Restricted to that block, because a bare `version = '1.0'` in an android block binds nothing this can follow. The division guard needed its test rewritten before it proved anything: a slashy literal stops at the end of its line, so swallowing `total / 2` cost nothing until the pin was moved onto the same line as the division. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 227 ++++++++++++++---- .../builders/KotlinStdlibAlignmentTest.java | 83 ++++++- 2 files changed, 259 insertions(+), 51 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 575bda4ef63..ec01b072ad1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -306,7 +306,7 @@ private static String declaredVersionOf(String line, String artifact) { String coordinate = KOTLIN_GROUP + ":" + artifact + ":"; for (int i = 0; i < line.length(); i++) { char c = line.charAt(i); - if (c != '\'' && c != '"') { + if (!isLiteralStart(line, i)) { continue; } int end = endOfStringLiteral(line, i); @@ -344,7 +344,7 @@ private static String declaredVersionOf(String line, String artifact) { private static String mapEntryValue(String line, String key) { for (int i = 0; i < line.length(); i++) { char c = line.charAt(i); - if (c == '\'' || c == '"') { + if (isLiteralStart(line, i)) { i = endOfStringLiteral(line, i); continue; } @@ -362,7 +362,7 @@ && isIdentifierChar(line.charAt(after)))) { continue; } j = skipBlanks(line, j + 1); - if (j < line.length() && (line.charAt(j) == '\'' || line.charAt(j) == '"')) { + if (j < line.length() && isLiteralStart(line, j)) { if (endOfStringLiteral(line, j) < line.length()) { // The real delimiter length, as the coordinate path does. Stripping // one character per side left a triple-quoted group or name wearing @@ -493,7 +493,7 @@ private static boolean namesCoordinate(String line, String artifact) { String coordinate = KOTLIN_GROUP + ":" + artifact; for (int i = 0; i < line.length(); i++) { char c = line.charAt(i); - if (c != '\'' && c != '"') { + if (!isLiteralStart(line, i)) { continue; } // The shared rule. This was the last scanner still tracking a single @@ -624,7 +624,7 @@ private static String versionInCall(String statement, String call) { // written. for (int i = 0; i < statement.length(); i++) { char c = statement.charAt(i); - if (c == '\'' || c == '"') { + if (isLiteralStart(statement, i)) { i = endOfStringLiteral(statement, i); continue; } @@ -640,8 +640,7 @@ private static String versionInCall(String statement, String call) { after = skipBlanks(statement, after + 1); } if (after < statement.length() - && (statement.charAt(after) == '\'' - || statement.charAt(after) == '"')) { + && isLiteralStart(statement, after)) { int end = endOfStringLiteral(statement, after); if (end < statement.length()) { // The literal's own delimiters, however many it has. Written @@ -910,7 +909,7 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat private static boolean callsStrictly(String statement) { for (int i = 0; i < statement.length(); i++) { char c = statement.charAt(i); - if (c == '\'' || c == '"') { + if (isLiteralStart(statement, i)) { i = endOfStringLiteral(statement, i); continue; } @@ -990,9 +989,78 @@ private static String stringLiteralContent(String text, int quoteAt) { return text.substring(from, to); } - /** 3 for a triple-quoted literal, 1 otherwise. */ + /** + * Whether a string literal opens at {@code at}, in any spelling Groovy has + * for one. + * + *

This question is asked in eleven places, and the answer used to be + * spelled out at each of them as "a quote is here". Every literal form + * added since arrived as a review comment against one of those eleven -- + * triple quotes, then dollar-slashy in the comment scanner, then + * dollar-slashy in the statement scanner, then dollar-slashy in the + * coordinate matcher -- because teaching one site never taught the rest. + * The form belongs here, once, where every scanner reads it.

+ */ + private static boolean isLiteralStart(String text, int at) { + char c = text.charAt(at); + if (c == '\'' || c == '"') { + return true; + } + if (c == '$') { + return at + 1 < text.length() && text.charAt(at + 1) == '/'; + } + return c == '/' && opensASlashyLiteral(text, at); + } + + /** + * Whether a {@code /} at {@code at} opens a slashy literal rather than + * dividing or opening a comment. + * + *

Declined once, on the grounds that telling these apart needs to know + * whether an expression is expected here, which is parsing rather than + * scanning. That was raised again with a better argument: NOT recognizing + * the literal fails in the SAME direction as recognizing one that is not + * there -- an apostrophe inside {@code /can't/} puts the quote scanner out + * of step and hides whatever follows, exactly as swallowing a division + * would. Given both mistakes cost the same, the question is only which is + * likelier, and that is decidable: a literal can only open where an + * expression may begin. After an identifier, a number or a closing + * bracket -- which is every division a build script actually contains, + * {@code total / 2}, {@code (a + b) / 2} -- it is division. The two + * comment openers are excluded outright.

+ */ + private static boolean opensASlashyLiteral(String text, int at) { + if (at + 1 < text.length() + && (text.charAt(at + 1) == '/' || text.charAt(at + 1) == '*')) { + return false; + } + int i = at - 1; + while (i >= 0 && (text.charAt(i) == ' ' || text.charAt(i) == '\t' + || text.charAt(i) == '\r' || text.charAt(i) == '\n')) { + i--; + } + if (i < 0) { + return true; + } + return SLASHY_OPENER_POSITIONS.indexOf(text.charAt(i)) >= 0; + } + + /** + * The characters an expression may follow. Deliberately does not include + * an identifier character, a digit or a closing bracket: those are what + * division follows. + */ + private static final String SLASHY_OPENER_POSITIONS = "=(,[:{&|!?+-*;"; + + /** The length of the delimiter opening at {@code at}. */ private static int delimiterLength(String text, int quoteAt) { char quote = text.charAt(quoteAt); + if (quote == '$') { + return 2; + } + if (quote == '/') { + return 1; + } return quoteAt + 2 < text.length() && text.charAt(quoteAt + 1) == quote && text.charAt(quoteAt + 2) == quote ? 3 : 1; @@ -1000,6 +1068,28 @@ private static int delimiterLength(String text, int quoteAt) { private static int endOfStringLiteral(String text, int quoteAt) { char quote = text.charAt(quoteAt); + if (quote == '$') { + // $/ ... /$ -- the closer is two characters, and the content may hold + // anything at all, which is the point of the form. + int close = text.indexOf("/$", quoteAt + 2); + return close < 0 ? text.length() : close + 1; + } + if (quote == '/') { + for (int i = quoteAt + 1; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\\') { + i++; + } else if (c == '/') { + return i; + } else if (c == '\n') { + // A slashy literal does not cross a line; treating an unterminated + // one as running to the end of the fragment would swallow every + // statement after it. + return i - 1; + } + } + return text.length(); + } // Groovy's triple-quoted literals are a different delimiter, not three of // this one. Treating the opener as a single quote made a triple-quoted note // close on the first apostrophe it contains -- can't, in the case that found @@ -1105,7 +1195,7 @@ private static boolean declaresOnTheConstrainedConfiguration(String configuratio private static boolean declaresOn(String configuration, String line) { for (int i = 0; i < line.length(); i++) { char c = line.charAt(i); - if (c == '\'' || c == '"') { + if (isLiteralStart(line, i)) { // The shared rule rather than a third hand-rolled quote scanner. This // one tracked a single delimiter character, so a triple-quoted name // was read as an empty string followed by unquoted text -- the same @@ -1199,25 +1289,7 @@ private static String[] activeLines(String fragment) { } continue; } - if (c == '$' && i + 1 < fragment.length() && fragment.charAt(i + 1) == '/') { - // Groovy's dollar-slashy literal. Its opener is unambiguous, and its - // content may start with a slash -- $/ /* /$ -- which the comment - // scanner below read as a line comment and used to discard the rest - // of the fragment, strict pin included. - // - // The PLAIN slashy form, /.../, is deliberately not recognised: a lone - // slash is also division and the start of both comment kinds, so - // telling them apart needs to know whether an expression is expected - // here, which is parsing rather than scanning. Guessing wrong there - // would swallow ordinary text, which is the failure this whole method - // exists to avoid. - int end = fragment.indexOf("/$", i + 2); - int stop = end < 0 ? fragment.length() : end + 2; - out.append(fragment, i, stop); - i = stop - 1; - continue; - } - if (c == '\'' || c == '"') { + if (isLiteralStart(fragment, i)) { // The shared rule, so triple-quoted literals and escapes are the // same thing here as everywhere else. This scanner and the statement // scanner below kept their own copies through the consolidation, and @@ -1302,19 +1374,7 @@ private static String[] statements(String text) { int depth = 0; for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); - if (c == '$' && i + 1 < text.length() && text.charAt(i + 1) == '/') { - // Groovy's dollar-slashy literal, recognised here as well as in the - // comment scanner. Fixing only that one left this scanner reading an - // apostrophe inside $/can't/$ as an opening quote, which swallowed - // the strict pin that followed on the same line. The same rule in two - // scanners is the shape this class keeps getting wrong. - int slashy = text.indexOf("/$", i + 2); - int stop = slashy < 0 ? text.length() : slashy + 2; - current.append(text, i, stop); - i = stop - 1; - continue; - } - if (c == '\'' || c == '"') { + if (isLiteralStart(text, i)) { // The shared rule: escapes and triple quotes handled in one place. // A literal that closed early here merged statements that must stay // apart, which lets one statement's configuration pair with another @@ -1437,12 +1497,27 @@ private static List inlineLiteralDefinitions(List statements) { // made the LAST statement read as a main-variant Kotlin declaration. Map literals = new LinkedHashMap(); List out = new ArrayList(); + // Gradle's extra properties are written both ways -- ext.kotlinVersion = '..' + // and ext { kotlinVersion = '..' } -- and the closure form is at least as + // common. Inside it a bare assignment really does bind a project-wide name, + // which is exactly what the interpolation reads, so it is a definition there + // and nowhere else: a bare `version = '1.0'` in an android block binds + // nothing this can follow, and reading it as a definition would supply a + // version to an unrelated $version. + int extDepth = 0; for (int i = 0; i < statements.size(); i++) { String statement = statements.get(i); out.add(literals.isEmpty() ? statement : withLiteralsInlined(statement, literals)); - updateLiteralDefinitions(statement, literals); + boolean opensExt = extDepth == 0 && opensAnExtraPropertiesBlock(statement); + updateLiteralDefinitions(statement, literals, extDepth > 0 || opensExt); + if (extDepth > 0 || opensExt) { + extDepth += braceBalance(statement); + if (extDepth < 0) { + extDepth = 0; + } + } } return out; } @@ -1453,8 +1528,35 @@ private static List inlineLiteralDefinitions(List statements) { * reassignment to something unreadable, which forgets it rather than * leaving a stale value behind. */ + /** + * Whether the statement opens a Gradle {@code ext { }} block, as a whole + * token so that a dependency on {@code com.example:extras} does not. + */ + private static boolean opensAnExtraPropertiesBlock(String statement) { + int at = statement.indexOf(EXTRA_PROPERTIES); + while (at >= 0) { + int after = at + EXTRA_PROPERTIES.length(); + boolean startsToken = at == 0 || !isIdentifierChar(statement.charAt(at - 1)); + int brace = skipBlanks(statement, after); + if (startsToken && (after >= statement.length() + || !isIdentifierChar(statement.charAt(after))) + && brace < statement.length() && statement.charAt(brace) == '{') { + return true; + } + at = statement.indexOf(EXTRA_PROPERTIES, at + 1); + } + return false; + } + private static void updateLiteralDefinitions(String statement, - Map literals) { + Map literals, boolean insideExtraProperties) { + if (insideExtraProperties) { + // The assignment may share the line with the brace that opened the block, + // as `ext { kotlinVersion = '1.9.22' }` does, so read from after it. + int brace = statement.indexOf('{'); + String body = brace >= 0 ? statement.substring(brace + 1) : statement; + recordBareAssignment(body, literals); + } int i = 0; boolean declared = false; int at = statement.indexOf(DEF); @@ -1523,7 +1625,7 @@ && isIdentifierChar(statement.charAt(nameStart))) { } i = skipBlanks(statement, i + 1); if (i < statement.length() - && (statement.charAt(i) == '\'' || statement.charAt(i) == '"')) { + && isLiteralStart(statement, i)) { int end = endOfStringLiteral(statement, i); if (end < statement.length()) { literals.put(name, statement.substring(i, end + 1)); @@ -1539,7 +1641,7 @@ private static String withLiteralsInlined(String statement, StringBuilder out = new StringBuilder(); for (int i = 0; i < statement.length(); i++) { char c = statement.charAt(i); - if (c == '\'' || c == '"') { + if (isLiteralStart(statement, i)) { int end = endOfStringLiteral(statement, i); String literal = statement.substring(i, Math.min(end + 1, statement.length())); @@ -1614,6 +1716,35 @@ && isIdentifierChar(literal.charAt(nameEnd))) { return out.toString(); } + /** + * Records {@code name = 'literal'} as a definition. Only ever called for + * the inside of an extra-properties block, where a bare assignment does + * bind a name the rest of the script can read. + */ + private static void recordBareAssignment(String body, Map literals) { + int i = skipBlanks(body, 0); + int nameStart = i; + while (i < body.length() && isIdentifierChar(body.charAt(i))) { + i++; + } + if (i == nameStart) { + return; + } + String name = body.substring(nameStart, i); + i = skipBlanks(body, i); + if (i >= body.length() || body.charAt(i) != '=' + || (i + 1 < body.length() && body.charAt(i + 1) == '=')) { + return; + } + i = skipBlanks(body, i + 1); + if (i < body.length() && isLiteralStart(body, i)) { + int end = endOfStringLiteral(body, i); + if (end < body.length()) { + literals.put(name, body.substring(i, end + 1)); + } + } + } + private static final String DEF = "def"; /** Gradle's extra-properties prefix, the one dotted assignment worth reading. */ @@ -1656,7 +1787,7 @@ private static int trailingBraceBalance(String statement) { // version -- and counting after those missed the closure's own opening brace. for (int i = 0; i < statement.length(); i++) { char c = statement.charAt(i); - if (c != '\'' && c != '"') { + if (!isLiteralStart(statement, i)) { continue; } int end = endOfStringLiteral(statement, i); @@ -1674,7 +1805,7 @@ private static int braceBalance(String statement) { int depth = 0; for (int i = 0; i < statement.length(); i++) { char c = statement.charAt(i); - if (c == '\'' || c == '"') { + if (isLiteralStart(statement, i)) { i = endOfStringLiteral(statement, i); } else if (c == '{') { depth++; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index e9597a039bf..6c0ed47ef2e 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -487,6 +487,83 @@ public void theAlignmentCannotFailTheBuild() throws Exception { "and says so, rather than swallowing the defect"); } + /** + * A coordinate keeps its meaning in every literal form Groovy has, the + * slashy ones included. Recognising a form in the scanners but not in the + * matchers left the pin visible to neither. + */ + @Test + public void aSlashyCoordinateIsStillACoordinate() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation($/org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22/$) " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(out), "a dollar-slashy coordinate is read, got <<" + out + ">>"); + + String plain = KotlinStdlibAlignment.constraintsBlock("implementation", + " def marker = /can't/; " + + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(plain), + "an apostrophe inside a slashy literal is not a quote, got <<" + plain + ">>"); + } + + /** + * Division is not a literal. The slashy form is only recognised where an + * expression may begin, because reading `total / 2` as an opener would + * swallow everything up to the next slash -- which is the same failure, + * from the opposite direction, as not recognising the literal at all. + */ + @Test + public void divisionIsNotASlashyLiteral() { + String[] arithmetic = { + "def half = total / 2", + "def part = (a + b) / 2", + "def ratio = sizes[0] / sizes[1]", + }; + for (int i = 0; i < arithmetic.length; i++) { + // On ONE line with the pin, because a slashy literal stops at the end of + // its line: put the pin on the next one and a swallowed division costs + // nothing, which is a test that passes with the guard removed. + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " " + arithmetic[i] + + "; implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(out), + "division does not swallow what follows <<" + arithmetic[i] + + ">>, got <<" + out + ">>"); + } + } + + /** + * Extra properties are written as a closure at least as often as with a + * dot, and inside one a bare assignment really does bind the name the + * interpolation reads. + */ + @Test + public void anExtraPropertiesClosureDefinesItsNames() { + String[] spellings = { + " ext { kotlinVersion = '1.9.22' }\n", + " ext {\n kotlinVersion = '1.9.22'\n }\n", + " ext {\n kotlinVersion = '1.9.22'\n somethingElse = 'x'\n }\n", + }; + for (int i = 0; i < spellings.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + spellings[i] + + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion\"\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the sibling is aligned for <<" + spellings[i] + ">>, got <<" + out + ">>"); + check(!out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and the merged-era declaration is left alone, got <<" + out + ">>"); + } + + // Outside such a block a bare assignment binds nothing this can follow. + String elsewhere = KotlinStdlibAlignment.constraintsBlock("implementation", + " android { kotlinVersion = '1.9.22' }\n" + + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion\"\n"); + check("".equals(elsewhere), + "an assignment outside ext stays unreadable, got <<" + elsewhere + ">>"); + } + /** * Gradle's extra properties are how a project-wide Kotlin version is * nearly always written, and the bare name the interpolation reads really @@ -772,9 +849,9 @@ public void aTypedLocalDefinesACoordinate() { /** * Groovy's dollar-slashy literal may open with a slash, which the comment * scanner read as a line comment and used to discard the rest of the - * fragment, strict pin included. The PLAIN slashy form is deliberately not - * recognised -- a lone slash is also division and both comment kinds -- - * and that limit has its own assertion below. + * fragment, strict pin included. The plain slashy form is recognised too + * now, positionally -- see divisionIsNotASlashyLiteral for the half of + * that rule which says what is NOT a literal. */ @Test public void aDollarSlashyLiteralDoesNotOpenAComment() { From 08ffcf36a7971da7269295f28958619c8d6bf5c1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:57:23 +0300 Subject: [PATCH 33/94] Scan every fragment the generated block is built from, and enumerate rather than list The important one: kotlinRuntimeDependency was never scanned. The builder writes it into the dependencies block itself, carrying requireKotlinStdlib, so an app asking for 1.7.22!! had a strict pre-merge pin on the base library that nothing in the scan could see -- and the shim constraints, which depend on stdlib 1.8.0, went in beside it. Two more fragments of that block were missing with it, coreLibraryDesugaringDependency and aarDependencies. Neither can name a Kotlin artifact today; they are passed anyway rather than judged, because deciding which fragments are worth reading is what produced this bug. The test that should have caught it now enumerates. It reads the builder's own concatenation of the dependencies block, extracts every expression the block is built from, and fails if any of them is missing from the call or passed out of order. A hand-written list of fragments was a second copy of the truth, and it was already wrong; this cannot go stale the same way. Verified by deleting kotlinRuntimeDependency from the call, which is exactly the report. Also: a classifier or @extension is not part of the version. `1.7.22!!@jar` does not end in the strict marker, so a strict pin read as an ordinary one. And a slashy literal may follow a keyword -- division needs a value on its left, and `return` is not one -- so the positional test now asks whether the preceding TOKEN can be divided rather than whether it is made of identifier characters. A word that is not a keyword is a variable, and dividing it is division. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 14 ++ .../builders/KotlinStdlibAlignment.java | 52 +++++- .../builders/KotlinStdlibAlignmentTest.java | 161 +++++++++++++++--- 3 files changed, 198 insertions(+), 29 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 8af5e2196a6..5493b8030db 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7318,11 +7318,25 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // at the top, then the dependencies block in its own order, then // xgradle after it. Listing them in any other order lost a // definition that the real script would have had in scope. + // + // EVERY fragment of that block, including the ones this builder + // writes itself. kotlinRuntimeDependency is the reason: it carries + // requireKotlinStdlib, so an app asking for 1.7.22!! has a strict + // pre-merge pin on the base library that nothing here could see, + // and the constraint went in beside it. The other two cannot + // currently name a Kotlin artifact, and are passed anyway rather + // than judged -- the judging belongs in the helper, and a list of + // "fragments worth reading" is exactly what was wrong before. + // KotlinStdlibAlignmentTest reads this call against the generated + // block and fails if the two ever disagree. request.getArg("android.gradlePlugin", ""), + coreLibraryDesugaringDependency, request.getArg("android.supportv4Dep", ""), + kotlinRuntimeDependency, additionalDependencies, aiExtraGradleDependencies.toString(), request.getArg("android.gradleDep", ""), + aarDependencies, request.getArg("android.xgradle", "")); } catch (RuntimeException e) { // The alignment reads the app's Gradle text to decide whether the app diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index ec01b072ad1..3ddfa478bb6 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -312,7 +312,7 @@ private static String declaredVersionOf(String line, String artifact) { int end = endOfStringLiteral(line, i); String literal = stringLiteralContent(line, i); if (literal.startsWith(coordinate) && !hasWhitespace(literal)) { - return literal.substring(coordinate.length()); + return versionComponentOf(literal.substring(coordinate.length())); } i = end; } @@ -341,6 +341,29 @@ private static String declaredVersionOf(String line, String artifact) { * suppressed. Same rule as the coordinate matcher beside it, which is * where this had drifted apart from.

*/ + /** + * The version out of what follows {@code group:name:} in a coordinate. + * + *

Gradle's notation carries two optional modifiers after the version -- + * a classifier as a fourth colon-separated part, and an {@code @extension} + * -- and both were being returned as part of the version. That leaves + * {@code 1.7.22!!@jar}, which does not end in the strict marker, so a + * strict pre-merge pin read as an ordinary one and the constraint was + * written beside it.

+ */ + private static String versionComponentOf(String remainder) { + int end = remainder.length(); + int at = remainder.indexOf('@'); + if (at >= 0) { + end = at; + } + int classifier = remainder.indexOf(':'); + if (classifier >= 0 && classifier < end) { + end = classifier; + } + return remainder.substring(0, end); + } + private static String mapEntryValue(String line, String key) { for (int i = 0; i < line.length(); i++) { char c = line.charAt(i); @@ -1042,9 +1065,34 @@ private static boolean opensASlashyLiteral(String text, int at) { if (i < 0) { return true; } - return SLASHY_OPENER_POSITIONS.indexOf(text.charAt(i)) >= 0; + if (SLASHY_OPENER_POSITIONS.indexOf(text.charAt(i)) >= 0) { + return true; + } + // Division needs a VALUE on its left, and a keyword is not one. `return + // /can't/` is a literal for the same reason `= /can't/` is, so the test is + // not "is the previous character an identifier character" but "is the + // previous TOKEN something that can be divided". A word that is not a + // keyword is a variable, and dividing it is exactly what a build script + // does. + if (!isIdentifierChar(text.charAt(i))) { + return false; + } + int tokenEnd = i + 1; + while (i >= 0 && isIdentifierChar(text.charAt(i))) { + i--; + } + String token = text.substring(i + 1, tokenEnd); + return EXPRESSION_KEYWORDS.indexOf(" " + token + " ") >= 0; } + /** + * Groovy words after which an expression begins, so a slash is a literal + * rather than a division. Reserved words cannot be variables, which is why + * this can be read off the language rather than guessed at. + */ + private static final String EXPRESSION_KEYWORDS = + " return new in case else do while if throw assert yield instanceof "; + /** * The characters an expression may follow. Deliberately does not include * an identifier character, a digit or a closing bracket: those are what diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 6c0ed47ef2e..dd2ba7506a6 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -457,6 +457,82 @@ public void aDefReferenceWithAMultilineClosureIsStillAPin() { "the strict base pin behind a def with a multiline closure is honoured"); } + /** + * Every fragment the generated dependencies block is built from is handed + * to the alignment, and in the same order. + * + *

Read off the builder's own concatenation rather than listed here, + * because a list here is a second copy of the truth and it was already + * wrong: kotlinRuntimeDependency carries requireKotlinStdlib, so an app + * asking for {@code 1.7.22!!} had a strict pre-merge pin on the base + * library that nothing in the scan could see. Two more fragments were + * missing beside it. A test that enumerates cannot go stale the way the + * list did.

+ */ + @Test + public void everyFragmentOfTheGeneratedBlockIsScanned() throws Exception { + byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); + String builderSrc = new String(bytes, "UTF-8"); + int at = builderSrc.indexOf("KotlinStdlibAlignment.constraintsBlock("); + check(at >= 0, "the builder calls the alignment"); + String call = builderSrc.substring(at, builderSrc.indexOf(";", at)) + .replaceAll("//[^\n]*", ""); + + int blockAt = builderSrc.indexOf("\"dependencies {"); + check(blockAt >= 0, "the generated dependencies block is found"); + int blockEnd = builderSrc.indexOf("+ \"}\\n\"", blockAt); + check(blockEnd > blockAt, "and its end"); + String block = builderSrc.substring(blockAt, blockEnd).replaceAll("//[^\n]*", ""); + + // What the block is concatenated FROM: java expressions, not literals. + // Keyed by where they appear, because the two spellings have to come back + // interleaved: collecting all the hints and then all the locals produced a + // list in neither the script's order nor the call's, and the order half of + // this test then failed on a call that was right. + java.util.TreeMap byPosition = new java.util.TreeMap(); + java.util.regex.Matcher hint = java.util.regex.Pattern + .compile("getArg\\(\"([a-zA-Z0-9._]+)\"").matcher(block); + while (hint.find()) { + byPosition.put(Integer.valueOf(hint.start()), "getArg(\"" + hint.group(1) + "\""); + } + java.util.regex.Matcher name = java.util.regex.Pattern + .compile("\\+\\s*(?:addNewlineIfMissing\\()?([a-z][a-zA-Z0-9]*)\\b") + .matcher(block); + while (name.find()) { + String token = name.group(1); + // The configuration itself is passed as the first argument, and the + // block this test is about is the alignment's own output. + if ("compile".equals(token) || "kotlinStdlibConstraints".equals(token) + || "addNewlineIfMissing".equals(token)) { + continue; + } + // `request` in request.getArg("x") is the receiver, not a fragment -- + // that one is already counted under its hint name. Matched on the call + // rather than the name, so a local that merely has methods on it + // (aiExtraGradleDependencies.toString()) still counts. + if (block.startsWith(".getArg(", name.end(1))) { + continue; + } + byPosition.put(Integer.valueOf(name.start(1)), token); + } + java.util.List fragments = + new java.util.ArrayList(byPosition.values()); + check(fragments.size() >= 6, + "the block really was parsed, found " + fragments); + + int previous = -1; + for (int i = 0; i < fragments.size(); i++) { + String fragment = fragments.get(i); + int passed = call.indexOf(fragment); + check(passed >= 0, "the alignment is given " + fragment + + ", which the generated block contains but the call does not"); + check(passed > previous, fragment + + " is passed in the order the script emits it"); + previous = passed; + } + } + /** * The alignment can never fail a build. It reads developer-authored Groovy * with a hand-written scanner, on every AndroidX build there is, to decide @@ -487,6 +563,62 @@ public void theAlignmentCannotFailTheBuild() throws Exception { "and says so, rather than swallowing the defect"); } + /** + * A coordinate may carry a classifier and an {@code @extension} after its + * version, and neither is part of the version. Returning them made + * {@code 1.7.22!!@jar} not end in the strict marker, so a strict pre-merge + * pin read as an ordinary one and the constraint went in beside it. + */ + @Test + public void aModifierAfterTheVersionIsNotPartOfIt() { + String[] pinned = { + "org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!@jar", + "org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!:sources", + "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!@aar", + }; + for (int i = 0; i < pinned.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation '" + pinned[i] + "'\n"); + check("".equals(out), + "the strict marker is still read in <<" + pinned[i] + + ">>, got <<" + out + ">>"); + } + + // And a merged-era one with the same modifiers is still merged-era. + String modern = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22@jar'\n"); + check(!modern.contains("kotlin-stdlib-jdk8:1.8.0"), + "a modern version is read past its modifier too, got <<" + modern + ">>"); + } + + /** + * A slashy literal may follow a keyword. Division needs a value on its + * left and a keyword is not one, so `return /can't/` opens a literal for + * the same reason `= /can't/` does -- and read as a quote instead, the + * apostrophe swallows whatever declaration follows it. + */ + @Test + public void aSlashyLiteralMayFollowAKeyword() { + String[] keywords = {"return", "in", "new"}; + for (int k = 0; k < keywords.length; k++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def note = { " + keywords[k] + " /can't/ }; " + + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(out), + "a slashy literal after " + keywords[k] + + " does not hide the pin, got <<" + out + ">>"); + } + + // A word that is not a keyword is a variable, and dividing it is division. + String division = KotlinStdlibAlignment.constraintsBlock("implementation", + " def ratio = total / count; " + + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(division), + "and dividing a variable is still division, got <<" + division + ">>"); + } + /** * A coordinate keeps its meaning in every literal form Groovy has, the * slashy ones included. Recognising a form in the scanners but not in the @@ -738,6 +870,8 @@ public void everySpellingOfAStrictPreMergePinSuppressesTheBlock() { String on = configurations[c]; String[] forms = { on + "(" + u + coordinate + ":1.7.22!!" + u + ")", + // A classifier or @extension sits after the version, not in it. + on + "(" + u + coordinate + ":1.7.22!!@jar" + u + ")", on + " " + u + coordinate + ":1.7.22!!" + u, on + "(" + u + coordinate + u + ") { version { strictly " + u + "1.7.22" + u + " } }", @@ -866,33 +1000,6 @@ public void aDollarSlashyLiteralDoesNotOpenAComment() { "the strict pin after a dollar-slashy literal is still seen"); } - /** - * The builder hands the fragments over in the order the generated script - * emits them, because a definition is only in scope for what follows it. - */ - @Test - public void theBuilderPassesFragmentsInGeneratedOrder() throws Exception { - byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( - "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); - String builderSrc = new String(bytes, "UTF-8"); - int at = builderSrc.indexOf("KotlinStdlibAlignment.constraintsBlock("); - check(at >= 0, "the builder calls the alignment"); - String call = builderSrc.substring(at, builderSrc.indexOf(";", at)); - // The call carries a comment naming those same hints, in an order that - // has nothing to do with the arguments -- read the arguments only. - call = call.replaceAll("//[^\n]*", ""); - int plugin = call.indexOf("getArg(\"android.gradlePlugin\""); - int support = call.indexOf("getArg(\"android.supportv4Dep\""); - int additional = call.indexOf("additionalDependencies"); - int dep = call.indexOf("getArg(\"android.gradleDep\""); - int xgradle = call.indexOf("getArg(\"android.xgradle\""); - check(plugin >= 0 && support >= 0 && additional >= 0 && dep >= 0 && xgradle >= 0, - "every app-controlled fragment is passed"); - check(plugin < support && support < additional && additional < dep - && dep < xgradle, - "and in the order the generated script emits them"); - } - /** * A reason can be nothing BUT a coordinate, so the whitespace rule does not * catch it. `because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'` From e663c8da56d592bf52b10e59b77e576be8f5464f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:11:39 +0300 Subject: [PATCH 34/94] Read a force the way Gradle applies one, and let a slashy literal be what Groovy says A forced version suppresses the block now, exactly as a strict pin does. This is the worst failure this class has had reported: a force does not conflict with a constraint, it wins over it silently, so `resolutionStrategy.force 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'` left the base library pre-merge while these constraints raised the shims to their EMPTY 1.8.0 jars -- the jdk7/jdk8 classes then live in no selected jar at all. Green build, NoClassDefFoundError on the device. `force` is read with the same syntax-level test as `strictly`, which is now shared between them, so the word inside a reason string is still prose. Two on slashy literals, both narrowing the gap between what this reads and what Groovy accepts. An expression may follow a closure arrow, so `{ -> /can't/ }` opens a literal; the opener positions gained `<` and `>` for it. And a slashy literal may span lines, which this closed at the first newline -- but only a literal that ACTUALLY closes gets to span: an opener misread with no closing slash anywhere would swallow every statement after it, and a suppression reached that way says nothing about the app. So it closes where it closes, and failing that stops at the line it started on. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 50 ++++++++++--- .../builders/KotlinStdlibAlignmentTest.java | 73 +++++++++++++++++++ 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 3ddfa478bb6..da56317b4a9 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -455,6 +455,9 @@ private static String strictVersionOfBaseStdlib(String line) { if (callsStrictly(line)) { return strictVersionIn(line); } + if (callsForce(line)) { + return declaredVersionOf(line, BASE_STDLIB); + } String declared = declaredVersionOf(line, BASE_STDLIB); if (declared != null && declared.endsWith(STRICT_SUFFIX)) { return declared.substring(0, declared.length() - STRICT_SUFFIX.length()); @@ -479,7 +482,7 @@ private static boolean holdsBaseStdlibStrictly(String line) { * requirement that had resolved fine before it.

*/ private static boolean holdsStrictly(String line, String artifact) { - if (callsStrictly(line)) { + if (callsStrictly(line) || callsForce(line)) { return true; } String declared = declaredVersionOf(line, artifact); @@ -929,17 +932,38 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat * statement separators: inside a string it is prose, outside it is * syntax.

*/ + /** + * Whether the statement calls Gradle's {@code force}. + * + *

A force is as absolute as a strict pin and worse to get wrong. It + * does not conflict with a constraint, it silently wins: with + * {@code resolutionStrategy.force 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'} + * the base library stays pre-merge while these constraints raise the shims + * to their EMPTY 1.8.0 jars, so the jdk7/jdk8 classes end up in no selected + * jar at all. Nothing fails in the build; it throws on the device. So a + * forced version is read exactly like a strict one.

+ */ + private static boolean callsForce(String statement) { + return callsNamed(statement, FORCE); + } + + private static final String FORCE = "force"; + private static boolean callsStrictly(String statement) { + return callsNamed(statement, STRICTLY); + } + + /** Whether {@code call} appears as a call, rather than inside a literal. */ + private static boolean callsNamed(String statement, String call) { for (int i = 0; i < statement.length(); i++) { - char c = statement.charAt(i); if (isLiteralStart(statement, i)) { i = endOfStringLiteral(statement, i); continue; } - if (statement.startsWith(STRICTLY, i)) { + if (statement.startsWith(call, i)) { boolean startsToken = i == 0 || !isIdentifierChar(statement.charAt(i - 1)); - int after = i + STRICTLY.length(); + int after = i + call.length(); boolean endsToken = after < statement.length() && (statement.charAt(after) == ' ' || statement.charAt(after) == '(' @@ -1098,7 +1122,7 @@ private static boolean opensASlashyLiteral(String text, int at) { * an identifier character, a digit or a closing bracket: those are what * division follows. */ - private static final String SLASHY_OPENER_POSITIONS = "=(,[:{&|!?+-*;"; + private static final String SLASHY_OPENER_POSITIONS = "=(,[:{&|!?+-*;<>"; /** The length of the delimiter opening at {@code at}. */ private static int delimiterLength(String text, int quoteAt) { @@ -1123,20 +1147,24 @@ private static int endOfStringLiteral(String text, int quoteAt) { return close < 0 ? text.length() : close + 1; } if (quote == '/') { + // Groovy's slashy literals MAY span lines, so the closing slash is looked + // for across them -- but only a literal that actually closes gets to. An + // opener this misread, with no closing slash anywhere, would otherwise + // swallow every statement after it, and a suppression reached that way is + // the outcome this class must never produce. So: close where it closes, + // and failing that, stop at the line it started on. + int firstNewline = -1; for (int i = quoteAt + 1; i < text.length(); i++) { char c = text.charAt(i); if (c == '\\') { i++; } else if (c == '/') { return i; - } else if (c == '\n') { - // A slashy literal does not cross a line; treating an unterminated - // one as running to the end of the fragment would swallow every - // statement after it. - return i - 1; + } else if (c == '\n' && firstNewline < 0) { + firstNewline = i; } } - return text.length(); + return firstNewline < 0 ? text.length() : firstNewline - 1; } // Groovy's triple-quoted literals are a different delimiter, not three of // this one. Treating the opener as a single quote made a triple-quoted note diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index dd2ba7506a6..069e027ee2c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -563,6 +563,79 @@ public void theAlignmentCannotFailTheBuild() throws Exception { "and says so, rather than swallowing the defect"); } + /** + * A forced version suppresses the block, like a strict one. + * + *

A force does not conflict with a constraint, it wins over it without + * a word: force the base library to 1.7.22 and these constraints still + * raise the shims to their EMPTY 1.8.0 jars, so the jdk7/jdk8 classes are + * in no selected jar at all. The build is green and the app throws on the + * device, which is the one outcome worth all of this machinery.

+ */ + @Test + public void aForcedVersionIsHeldAsFirmlyAsAStrictOne() { + String[] forced = { + " configurations.all { resolutionStrategy.force " + + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' }\n", + " configurations.all { resolutionStrategy { force " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22' } }\n", + " configurations.all {\n resolutionStrategy {\n" + + " force 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" + + " }\n }\n", + }; + for (int i = 0; i < forced.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", forced[i]); + check("".equals(out), + "a forced pre-merge version suppresses the block, from <<" + + forced[i] + ">> got <<" + out + ">>"); + } + + // A force at or above the floor takes nothing away: it is already a shim. + String modern = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.force " + + "'org.jetbrains.kotlin:kotlin-stdlib:1.9.22' }\n"); + check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), + "a merged-era force still gets the alignment, got <<" + modern + ">>"); + + // And the word in a reason is prose, as everywhere else. + String prose = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('com.example:other:1.0') " + + "{ because 'we force nothing here' }\n"); + check(prose.contains("kotlin-stdlib-jdk8:1.8.0"), + "the word force in prose is not a force, got <<" + prose + ">>"); + } + + /** + * A slashy literal may open after a closure arrow, and may run past the + * end of its line -- but only when it actually closes. An opener misread + * with no closing slash anywhere would swallow every statement after it, + * and a suppression reached that way says nothing about the app. + */ + @Test + public void aSlashyLiteralSpansLinesOnlyWhenItCloses() { + String afterArrow = KotlinStdlibAlignment.constraintsBlock("implementation", + " def marker = { -> /can't/ }; " + + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(afterArrow), + "a slashy literal after a closure arrow, got <<" + afterArrow + ">>"); + + String multiline = KotlinStdlibAlignment.constraintsBlock("implementation", + " def marker = /first\n still can't/\n" + + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(multiline), + "a literal that spans lines keeps its content, got <<" + multiline + ">>"); + + // Unterminated: whatever that slash was, it does not reach the next line. + String unterminated = KotlinStdlibAlignment.constraintsBlock("implementation", + " def ratio = a / b\n" + + " implementation 'androidx.appcompat:appcompat:1.6.1'\n"); + check(unterminated.contains("kotlin-stdlib-jdk7:1.8.0") + && unterminated.contains("kotlin-stdlib-jdk8:1.8.0"), + "an unclosed slash stops at its line, got <<" + unterminated + ">>"); + } + /** * A coordinate may carry a classifier and an {@code @extension} after its * version, and neither is part of the version. Returning them made From cf45c7a97c20c9ec446dc89a260f990f54f9c5c3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:27:22 +0300 Subject: [PATCH 35/94] Ask which half is closed, and let a removed comment still separate its tokens The slashy rule stops growing. "Which characters may an expression follow" had been extended by review four times -- the closure arrow, the comparison, then Groovy's =~ and ==~ -- and was still missing whichever operator came next, because that half is open-ended. Division is the closed half: it needs a value on its left, and a value ends with an identifier that is not a keyword, a digit, a closing bracket, a string, or an increment. Everything else opens a literal, including every operator nobody has thought of yet, and the character set is deleted. The half the code no longer enumerates is enumerated in the test instead, which is where an open-ended list can live without being wrong: twenty-eight operators, each asserted to leave the pin behind it visible. The closed half has its own sweep, because that is the one where adding a case is how a division starts swallowing statements. Also: removing a block comment leaves the whitespace it was. A comment separates tokens in the language, so deleting it outright joined them -- strictly/* pin */'1.7.22' became strictly'1.7.22', which is not a call to strictly, and the strict pin behind it was never seen. Gradle accepts the original and records {strictly 1.7.22}. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 46 ++++++++---- .../builders/KotlinStdlibAlignmentTest.java | 73 +++++++++++++++++++ 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index da56317b4a9..ae1d234e339 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -1089,18 +1089,33 @@ private static boolean opensASlashyLiteral(String text, int at) { if (i < 0) { return true; } - if (SLASHY_OPENER_POSITIONS.indexOf(text.charAt(i)) >= 0) { - return true; + // Asked the other way round, because asking it directly does not converge. + // "Which characters may an expression follow" was extended by review four + // times -- the closure arrow, the comparison, then Groovy's =~ and ==~ -- + // and each time the set was still missing whichever operator came next. + // Division is the closed half: it needs a VALUE on its left, and there are + // only so many things a value ends with. Everything else opens a literal, + // including every operator nobody has thought of yet. + char previous = text.charAt(i); + if (previous == ')' || previous == ']' || previous == '}') { + return false; + } + if (previous == '\'' || previous == '"') { + return false; + } + if (previous >= '0' && previous <= '9') { + return false; } - // Division needs a VALUE on its left, and a keyword is not one. `return - // /can't/` is a literal for the same reason `= /can't/` is, so the test is - // not "is the previous character an identifier character" but "is the - // previous TOKEN something that can be divided". A word that is not a - // keyword is a variable, and dividing it is exactly what a build script - // does. - if (!isIdentifierChar(text.charAt(i))) { + if ((previous == '+' || previous == '-') && i > 0 + && text.charAt(i - 1) == previous) { + // a++ / b and a-- / b: the increment yields the value being divided. return false; } + if (!isIdentifierChar(previous)) { + return true; + } + // A word: a variable is a value and a keyword is not, which is the whole + // difference between `total / 2` and `return /can't/`. int tokenEnd = i + 1; while (i >= 0 && isIdentifierChar(text.charAt(i))) { i--; @@ -1117,13 +1132,6 @@ private static boolean opensASlashyLiteral(String text, int at) { private static final String EXPRESSION_KEYWORDS = " return new in case else do while if throw assert yield instanceof "; - /** - * The characters an expression may follow. Deliberately does not include - * an identifier character, a digit or a closing bracket: those are what - * division follows. - */ - private static final String SLASHY_OPENER_POSITIONS = "=(,[:{&|!?+-*;<>"; - /** The length of the delimiter opening at {@code at}. */ private static int delimiterLength(String text, int quoteAt) { char quote = text.charAt(quoteAt); @@ -1380,6 +1388,12 @@ private static String[] activeLines(String fragment) { if (next == '*') { inBlockComment = true; i++; + // A comment IS whitespace in the language, so removing one + // without leaving any joined the tokens it separated: + // `strictly/* pin */'1.7.22'` became strictly'1.7.22', which is + // not a call to strictly, so the strict pin behind it was never + // seen. Groovy accepts the original and records {strictly 1.7.22}. + out.append(' '); continue; } if (next == '/') { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 069e027ee2c..58f41c22512 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -563,6 +563,79 @@ public void theAlignmentCannotFailTheBuild() throws Exception { "and says so, rather than swallowing the defect"); } + /** + * Removing a comment leaves the whitespace it was. A comment separates + * tokens in the language, so deleting it outright joined them: + * {@code strictly/* pin *}{@code /'1.7.22'} became strictly'1.7.22', which + * is not a call to strictly, and the strict pin behind it was never seen. + */ + @Test + public void removingACommentLeavesTheWhitespaceItWas() { + String[] joined = { + " implementation('org.jetbrains.kotlin:kotlin-stdlib') " + + "{ version { strictly/* pin */'1.7.22' } }\n", + " def/* local */dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'; " + + "implementation(dep) { version { strictly '1.7.22' } }\n", + " implementation/* which */('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')\n", + }; + for (int i = 0; i < joined.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", joined[i]); + check("".equals(out), + "the comment did not join the tokens around it in <<" + joined[i] + + ">>, got <<" + out + ">>"); + } + } + + /** + * A slash after anything that is not a value opens a literal. + * + *

Swept over the operators rather than asserted one at a time, because + * one at a time is how the rule was built and it took four review rounds + * to still be incomplete: the closure arrow, then the comparison, then + * Groovy's {@code =~} and {@code ==~}. The code no longer enumerates this + * half at all -- it enumerates the closed one, what a value can end with -- + * so this test is where the open half is written down.

+ */ + @Test + public void aSlashAfterAnythingThatIsNotAValueOpensALiteral() { + String[] operators = { + "=", "=~", "==~", "~", "->", ",", "(", "[", ":", "&&", "||", + "!", "?", "+", "-", "*", "%", "^", "|", "&", "<", ">", "<=", ">=", + "==", "!=", "<<", "?:", + }; + for (int i = 0; i < operators.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def marker = [ name " + operators[i] + " /can't/ ]; " + + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(out), + "a slash after " + operators[i] + + " opens a literal, so the pin behind it is still seen; got <<" + + out + ">>"); + } + } + + /** + * And a slash after a value divides it. This is the half the code + * enumerates, so it is the half that must stay closed: adding to it is how + * a division starts swallowing the statements after it. + */ + @Test + public void aSlashAfterAValueDividesIt() { + String[] values = { + "total", "2", "count()", "sizes[0]", "(a + b)", "1.5", "n++", "n--", + }; + for (int i = 0; i < values.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def ratio = " + values[i] + " / divisor; " + + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(out), + "dividing " + values[i] + + " does not swallow the pin after it; got <<" + out + ">>"); + } + } + /** * A forced version suppresses the block, like a strict one. * From d0959354fcd241dae8d72cc36024bfc869ae0533 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:38:44 +0300 Subject: [PATCH 36/94] Let a line ending, a blank line and an escaped delimiter all mean nothing Three findings, all of them the scanner disagreeing with Groovy about what is whitespace or what is a delimiter. Line endings. The token-end test accepted a space, a tab or an open parenthesis, so a fragment written on Windows put a carriage return after `strictly` and the call stopped being a call -- the strict pin behind it was never read. Spelled out in two places, both now asking isBlank, which is the whole set. Line endings are not this class's business to have an opinion about. Blank lines. A trailing closure still belongs to its call with a comment-only line between them; stripping the comment leaves an empty statement, and looking at only the very next statement left the closure, and the strict version inside it, attached to nothing. Escaped delimiters. Inside a dollar-slashy literal the dollar escapes itself and a slash, so $/ is a slash and not the closer. Taking the first "/$" substring ended the literal early and put the scanner back into code halfway through a string. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 58 +++++++++++++--- .../builders/KotlinStdlibAlignmentTest.java | 68 +++++++++++++++++++ 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index ae1d234e339..fbe22e2ffe4 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -965,9 +965,8 @@ private static boolean callsNamed(String statement, String call) { || !isIdentifierChar(statement.charAt(i - 1)); int after = i + call.length(); boolean endsToken = after < statement.length() - && (statement.charAt(after) == ' ' - || statement.charAt(after) == '(' - || statement.charAt(after) == '\t'); + && (isBlank(statement.charAt(after)) + || statement.charAt(after) == '('); if (startsToken && endsToken) { return true; } @@ -1002,6 +1001,18 @@ private static boolean declaresMapEntry(String line, String key, String value) { * the main configuration -- suppressing a constraint for a configuration * that reaches nothing.

*/ + /** + * Whether the character is whitespace that separates tokens. + * + *

Spelled out as space-or-tab in two places, which meant a fragment with + * Windows line endings put a carriage return after {@code strictly} and the + * call stopped being a call. Line endings are not this class's business to + * have an opinion about.

+ */ + private static boolean isBlank(char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; + } + private static boolean isIdentifierChar(char c) { return Character.isLetterOrDigit(c) || c == '_' || c == '$'; } @@ -1150,9 +1161,23 @@ private static int endOfStringLiteral(String text, int quoteAt) { char quote = text.charAt(quoteAt); if (quote == '$') { // $/ ... /$ -- the closer is two characters, and the content may hold - // anything at all, which is the point of the form. - int close = text.indexOf("/$", quoteAt + 2); - return close < 0 ? text.length() : close + 1; + // anything at all, which is the point of the form. Almost anything: the + // dollar escapes itself and a slash, so $$ is a dollar and $/ is a + // slash. Searching for the first "/$" substring found the slash of an + // escaped $/ instead of the closer and ended the literal early, which + // put the scanner back into code halfway through a string. + for (int i = quoteAt + 2; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '$' && i + 1 < text.length() + && (text.charAt(i + 1) == '$' || text.charAt(i + 1) == '/')) { + i++; + continue; + } + if (c == '/' && i + 1 < text.length() && text.charAt(i + 1) == '$') { + return i + 1; + } + } + return text.length(); } if (quote == '/') { // Groovy's slashy literals MAY span lines, so the closing slash is looked @@ -1304,8 +1329,7 @@ && isAddCallArgument(line, i)) { || !isIdentifierChar(line.charAt(i - 1)); int after = i + configuration.length(); boolean endsToken = after < line.length() - && (line.charAt(after) == ' ' || line.charAt(after) == '(' - || line.charAt(after) == '\t'); + && (isBlank(line.charAt(after)) || line.charAt(after) == '('); if (startsToken && endsToken) { return true; } @@ -1535,9 +1559,23 @@ private static String[] statements(String text) { // against it. The parenthesis depth is already back to zero there, so // without this the closure lands in its own statement and the version // it carries is never associated with the coordinate above it. - while (i + 1 < defined.size() && opensAClosure(defined.get(i + 1))) { - i++; + // Past anything blank in between. A comment-only line leaves an empty + // statement behind it, and looking only at the very next one left the + // closure -- and the strict version inside it -- attached to nothing. + int next = i + 1; + while (next < defined.size() && defined.get(next).trim().length() == 0) { + next++; + } + while (next < defined.size() && opensAClosure(defined.get(next))) { + while (i < next) { + i++; + } statement = statement + " " + defined.get(i); + next = i + 1; + while (next < defined.size() + && defined.get(next).trim().length() == 0) { + next++; + } } int braces = trailingBraceBalance(statement); while (braces > 0 && i + 1 < defined.size()) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 58f41c22512..0e408e51db2 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -563,6 +563,74 @@ public void theAlignmentCannotFailTheBuild() throws Exception { "and says so, rather than swallowing the defect"); } + /** + * Line endings are not this class's business to have an opinion about. A + * fragment written on Windows put a carriage return after + * {@code strictly}, and the token-end test accepted only a space, a tab or + * an open parenthesis -- so the call stopped being a call and the pin + * behind it was never read. + */ + @Test + public void aCarriageReturnSeparatesTokensLikeAnyOtherBlank() { + String[] endings = {"\r\n", "\n", "\r"}; + for (int i = 0; i < endings.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib')" + endings[i] + + " { version { strictly" + endings[i] + " '1.7.22' } }" + + endings[i]); + check("".equals(out), + "the strict call survives the line ending, got <<" + out + ">>"); + } + } + + /** + * A trailing closure still belongs to its call with a blank line between + * them. Comment stripping leaves an empty statement where a comment-only + * line was, and looking at only the very next statement left the closure + * -- and the strict version inside it -- attached to nothing. + */ + @Test + public void aTrailingClosureSurvivesABlankLine() { + String[] between = { + "\n", + "\n // why this pin is here\n", + "\n\n /* and a block one */\n\n", + }; + for (int i = 0; i < between.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.7.22')" + + between[i] + + " { version { strictly '1.7.22' } }\n"); + check("".equals(out), + "the closure is still the call's, across <<" + + between[i].replace("\n", "\\n") + ">>, got <<" + out + ">>"); + } + } + + /** + * Inside a dollar-slashy literal the dollar escapes itself and a slash, so + * {@code $/} is a slash and not the closer. Taking the first {@code /$} + * substring ended the literal early and put the scanner back into code + * halfway through a string. + */ + @Test + public void aDollarEscapeDoesNotCloseADollarSlashyLiteral() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def marker = $/not closed $/$ can't/$; " + + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(out), + "the escaped delimiter did not end the literal, got <<" + out + ">>"); + + // And $$ is a dollar, not the start of one. + String dollars = KotlinStdlibAlignment.constraintsBlock("implementation", + " def marker = $/cost $$5 can't/$; " + + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(dollars), + "an escaped dollar is content, got <<" + dollars + ">>"); + } + /** * Removing a comment leaves the whitespace it was. A comment separates * tokens in the language, so deleting it outright joined them: From 948257b90410570bfea8261bb67e780e68ad24ec Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:51:01 +0300 Subject: [PATCH 37/94] Read every spelling of a force, and expand a definition that refers to another Gradle writes a force three ways -- force the method, setForcedModules its setter, forcedModules the property -- and only the first was read. The other two hold a module just as absolutely, so an app using either kept its pre-merge base library while these constraints raised the shims to their empty jars: the JDK extension classes then live in no selected jar and the app throws on the device with nothing wrong in the build. A list of API names is a list this can have, unlike a guess at syntax. A definition may interpolate an earlier one: def v = '1.9.22' def dep = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v" Recorded as written, dep's version stayed the text $v -- no version, therefore below the floor, therefore the whole block suppressed for a project that was already merged-era and still had a duplicate to fix. Expanding the whole statement before recording it is the obvious fix and it is wrong: it substitutes the NAME being assigned as well, so an unreadable reassignment stops forgetting the literal it replaced. That is the same mistake pointing the other way, and it took eight seconds to find because an existing test says so. Only the value is expanded. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 61 ++++++++++++++++--- .../builders/KotlinStdlibAlignmentTest.java | 53 ++++++++++++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index fbe22e2ffe4..8a2a3f493c5 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -944,17 +944,38 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat * forced version is read exactly like a strict one.

*/ private static boolean callsForce(String statement) { - return callsNamed(statement, FORCE); + for (int i = 0; i < FORCE_SPELLINGS.length; i++) { + if (callsNamed(statement, FORCE_SPELLINGS[i], true)) { + return true; + } + } + return false; } - private static final String FORCE = "force"; + /** + * Gradle's spellings of the same thing. {@code force} is the method, + * {@code setForcedModules} its setter and {@code forcedModules} the + * property, and all three pin a module absolutely. This is a list of API + * names rather than a guess at syntax, which is why it can be one. + */ + private static final String[] FORCE_SPELLINGS = { + "force", + "setForcedModules", + "forcedModules" + }; private static boolean callsStrictly(String statement) { - return callsNamed(statement, STRICTLY); + return callsNamed(statement, STRICTLY, false); } - /** Whether {@code call} appears as a call, rather than inside a literal. */ - private static boolean callsNamed(String statement, String call) { + /** + * Whether {@code call} appears as a call, rather than inside a literal. + * + *

{@code assigned} also accepts the property form, {@code name = ...}, + * which only Gradle's forcedModules is written as. It is not offered to + * every caller because `def strictly = false` is not a strict pin.

+ */ + private static boolean callsNamed(String statement, String call, boolean assigned) { for (int i = 0; i < statement.length(); i++) { if (isLiteralStart(statement, i)) { i = endOfStringLiteral(statement, i); @@ -966,7 +987,8 @@ private static boolean callsNamed(String statement, String call) { int after = i + call.length(); boolean endsToken = after < statement.length() && (isBlank(statement.charAt(after)) - || statement.charAt(after) == '('); + || statement.charAt(after) == '(' + || (assigned && statement.charAt(after) == '=')); if (startsToken && endsToken) { return true; } @@ -1756,13 +1778,36 @@ && isIdentifierChar(statement.charAt(nameStart))) { && isLiteralStart(statement, i)) { int end = endOfStringLiteral(statement, i); if (end < statement.length()) { - literals.put(name, statement.substring(i, end + 1)); + literals.put(name, expandedLiteral(statement, i, end, literals)); return; } } literals.remove(name); } + /** + * The literal at {@code from}, with any definitions it interpolates already + * expanded. + * + *

A definition may refer to an earlier one -- + * {@code def v = '1.8.0'; def dep = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v"} + * -- and storing it as written made the version the text {@code $v}, which + * reads as no version and therefore as below the floor, suppressing the + * block for a project that was already merged-era.

+ * + *

The VALUE only. Expanding the whole statement first was tried and + * substituted the NAME being assigned as well, so an unreadable + * reassignment stopped forgetting the literal it replaced -- the same + * mistake in the opposite direction, and the suite said so.

+ */ + private static String expandedLiteral(String text, int from, int end, + Map literals) { + String literal = text.substring(from, end + 1); + return text.charAt(from) == '"' + ? withInterpolationsExpanded(literal, literals) + : literal; + } + /** The statement with known definition names replaced by their literals. */ private static String withLiteralsInlined(String statement, Map literals) { @@ -1868,7 +1913,7 @@ private static void recordBareAssignment(String body, Map litera if (i < body.length() && isLiteralStart(body, i)) { int end = endOfStringLiteral(body, i); if (end < body.length()) { - literals.put(name, body.substring(i, end + 1)); + literals.put(name, expandedLiteral(body, i, end, literals)); } } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 0e408e51db2..b1f52fbf452 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -563,6 +563,59 @@ public void theAlignmentCannotFailTheBuild() throws Exception { "and says so, rather than swallowing the defect"); } + /** + * Every spelling Gradle has for a force is a force. {@code force} is the + * method, {@code setForcedModules} its setter, {@code forcedModules} the + * property, and all three hold a module absolutely -- so all three leave + * these constraints raising the shims to empty jars beside a base library + * that stayed pre-merge. + */ + @Test + public void everySpellingOfAForceIsAForce() { + String[] spellings = { + " configurations.all { resolutionStrategy.force " + + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' }\n", + " configurations.all { resolutionStrategy.setForcedModules(" + + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22') }\n", + " configurations.all { resolutionStrategy.forcedModules = " + + "['org.jetbrains.kotlin:kotlin-stdlib:1.7.22'] }\n", + " configurations.all { resolutionStrategy.forcedModules=" + + "['org.jetbrains.kotlin:kotlin-stdlib:1.7.22'] }\n", + }; + for (int i = 0; i < spellings.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + spellings[i]); + check("".equals(out), + "the force is read from <<" + spellings[i] + ">>, got <<" + out + ">>"); + } + } + + /** + * A definition may interpolate an earlier one. Recorded as written, the + * version stayed the text {@code $v} -- no version, so below the floor, + * so the whole block suppressed for a project that was already + * merged-era and still had a duplicate to fix. + */ + @Test + public void aDefinitionMayInterpolateAnEarlierOne() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def v = '1.9.22'\n" + + " def dep = \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v\"\n" + + " implementation dep\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the sibling is still aligned, got <<" + out + ">>"); + check(!out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and the merged-era declaration is left alone, got <<" + out + ">>"); + + // The same chain below the floor is still below it. + String old = KotlinStdlibAlignment.constraintsBlock("implementation", + " def v = '1.7.22'\n" + + " def dep = \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v\"\n" + + " implementation dep\n"); + check("".equals(old), + "a pre-merge chain still suppresses, got <<" + old + ">>"); + } + /** * Line endings are not this class's business to have an opinion about. A * fragment written on Windows put a carriage return after From 673e1672e2e4750fbfad321eea2abcd9abe06913 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:02:38 +0300 Subject: [PATCH 38/94] Tell a call from an assignment, and interpolate everything Groovy interpolates Three findings, and the first is mine from the previous commit. Reading forcedModules as an assignment was done by accepting `=` after any of the force spellings, which also accepted `{ force = false }` -- a dependency explicitly turning forcing OFF, read as an absolute pin, suppressing the block for a declaration asking for nothing of the kind. A call and an assignment are now told apart properly: a call is the word followed by an argument, an assignment is the word followed by `=`, forcedModules only ever appears as the second, and `force` as an assignment counts only when what it is assigned is true. Interpolation is not a property of double quotes. Every literal form Groovy has interpolates except the single-quoted ones, so a coordinate assembled as $/...:$v/$ kept the text $v as its version -- no version, therefore below the floor, therefore suppressed for a project that was already merged-era. The rule was written in two places and both now say "anything but a single quote", which is the actual language rule rather than the case that was reported first. And a reason is prose to the version scan too. namesCoordinate learned to skip a reason argument several rounds ago; declaredVersionOf did not, so a `because` naming an old coordinate supplied the version for the declaration that was warning about it, and took the block with it. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 110 +++++++++++++----- .../builders/KotlinStdlibAlignmentTest.java | 72 ++++++++++++ 2 files changed, 152 insertions(+), 30 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 8a2a3f493c5..25a996fa99c 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -311,7 +311,12 @@ private static String declaredVersionOf(String line, String artifact) { } int end = endOfStringLiteral(line, i); String literal = stringLiteralContent(line, i); - if (literal.startsWith(coordinate) && !hasWhitespace(literal)) { + if (literal.startsWith(coordinate) && !hasWhitespace(literal) + && !isReasonArgument(line, i)) { + // A reason can be nothing but a coordinate, and this scan reached it + // before the map's own version: entry. namesCoordinate learned to + // skip a reason and this did not, so the comment describing an old + // artifact supplied the version for the declaration warning about it. return versionComponentOf(literal.substring(coordinate.length())); } i = end; @@ -944,28 +949,57 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat * forced version is read exactly like a strict one.

*/ private static boolean callsForce(String statement) { - for (int i = 0; i < FORCE_SPELLINGS.length; i++) { - if (callsNamed(statement, FORCE_SPELLINGS[i], true)) { - return true; - } + // The method forms, which callsNamed now distinguishes from an assignment. + if (callsNamed(statement, "force") || callsNamed(statement, "setForcedModules")) { + return true; } - return false; + // forcedModules is only ever written as an assignment, and assigning it any + // module list is a force. `force` as a property is the one that has to be + // read: `{ force = false }` explicitly turns forcing OFF, and accepting any + // assignment after the word read that as an absolute pin -- suppressing the + // block for a declaration that was asking for nothing of the kind. + if (assignedValue(statement, "forcedModules") != null) { + return true; + } + return "true".equals(assignedValue(statement, "force")); } /** - * Gradle's spellings of the same thing. {@code force} is the method, - * {@code setForcedModules} its setter and {@code forcedModules} the - * property, and all three pin a module absolutely. This is a list of API - * names rather than a guess at syntax, which is why it can be one. + * The value assigned to {@code name}, or null if it is not assigned here. + * Read outside literals, like every other question about syntax. */ - private static final String[] FORCE_SPELLINGS = { - "force", - "setForcedModules", - "forcedModules" - }; + private static String assignedValue(String statement, String name) { + for (int i = 0; i < statement.length(); i++) { + if (isLiteralStart(statement, i)) { + i = endOfStringLiteral(statement, i); + continue; + } + if (!statement.startsWith(name, i)) { + continue; + } + boolean startsToken = i == 0 || !isIdentifierChar(statement.charAt(i - 1)); + int after = i + name.length(); + if (!startsToken || (after < statement.length() + && isIdentifierChar(statement.charAt(after)))) { + continue; + } + int at = skipBlanks(statement, after); + if (at >= statement.length() || statement.charAt(at) != '=' + || (at + 1 < statement.length() && statement.charAt(at + 1) == '=')) { + continue; + } + int from = skipBlanks(statement, at + 1); + int to = from; + while (to < statement.length() && !isBlank(statement.charAt(to))) { + to++; + } + return statement.substring(from, to); + } + return null; + } private static boolean callsStrictly(String statement) { - return callsNamed(statement, STRICTLY, false); + return callsNamed(statement, STRICTLY); } /** @@ -975,24 +1009,34 @@ private static boolean callsStrictly(String statement) { * which only Gradle's forcedModules is written as. It is not offered to * every caller because `def strictly = false` is not a strict pin.

*/ - private static boolean callsNamed(String statement, String call, boolean assigned) { + private static boolean callsNamed(String statement, String call) { for (int i = 0; i < statement.length(); i++) { if (isLiteralStart(statement, i)) { i = endOfStringLiteral(statement, i); continue; } - if (statement.startsWith(call, i)) { - boolean startsToken = i == 0 - || !isIdentifierChar(statement.charAt(i - 1)); - int after = i + call.length(); - boolean endsToken = after < statement.length() - && (isBlank(statement.charAt(after)) - || statement.charAt(after) == '(' - || (assigned && statement.charAt(after) == '=')); - if (startsToken && endsToken) { - return true; - } + if (!statement.startsWith(call, i)) { + continue; } + boolean startsToken = i == 0 || !isIdentifierChar(statement.charAt(i - 1)); + int after = i + call.length(); + if (!startsToken || after >= statement.length()) { + continue; + } + char next = statement.charAt(after); + if (next != '(' && !isBlank(next)) { + continue; + } + // `force = false` is a property being SET, not a call, and reading it as + // one turned an explicit "do not force" into an absolute pin. A call is + // what is left after excluding the assignment. + int assignment = skipBlanks(statement, after); + if (assignment < statement.length() && statement.charAt(assignment) == '=' + && (assignment + 1 >= statement.length() + || statement.charAt(assignment + 1) != '=')) { + continue; + } + return true; } return false; } @@ -1803,7 +1847,12 @@ && isLiteralStart(statement, i)) { private static String expandedLiteral(String text, int from, int end, Map literals) { String literal = text.substring(from, end + 1); - return text.charAt(from) == '"' + // Every literal form Groovy has interpolates EXCEPT the single-quoted + // ones. Testing for a double quote missed the slashy forms, so a coordinate + // assembled as $/...:$v/$ kept the text $v as its version -- no version, + // therefore below the floor, therefore the block suppressed for a project + // that was already merged-era. + return text.charAt(from) != '\'' ? withInterpolationsExpanded(literal, literals) : literal; } @@ -1822,7 +1871,8 @@ private static String withLiteralsInlined(String statement, // to as $name or ${name} is the same one hop this already follows for // a bare token. Reading it as unreadable made a merged-era version // look pre-merge and dropped the sibling's constraint with it. - out.append(c == '"' ? withInterpolationsExpanded(literal, literals) + // The same rule as expandedLiteral: everything but a single quote. + out.append(c != '\'' ? withInterpolationsExpanded(literal, literals) : literal); i = end; continue; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index b1f52fbf452..9d8addd779e 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -563,6 +563,78 @@ public void theAlignmentCannotFailTheBuild() throws Exception { "and says so, rather than swallowing the defect"); } + /** + * Setting a property is not calling a method. {@code { force = false }} + * explicitly turns forcing OFF, and reading the word as a force turned an + * ordinary version request into an absolute pin -- suppressing the block + * for a declaration asking for nothing of the kind. + */ + @Test + public void aForceThatIsSwitchedOffIsNotAForce() { + String off = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') " + + "{ force = false }\n"); + check(off.contains("kotlin-stdlib-jdk7:1.8.0") + && off.contains("kotlin-stdlib-jdk8:1.8.0"), + "force = false does not suppress, got <<" + off + ">>"); + + String on = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') " + + "{ force = true }\n"); + check("".equals(on), "force = true does, got <<" + on + ">>"); + } + + /** + * Every literal form Groovy has interpolates except the single-quoted + * ones, so a coordinate assembled inside any of the others carries its + * definitions with it. + */ + @Test + public void everyInterpolatingLiteralExpandsItsDefinitions() { + String[] assembled = { + "\"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v\"", + "\"\"\"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v\"\"\"", + "$/org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v/$", + }; + for (int i = 0; i < assembled.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def v = '1.9.22'\n" + + " def dep = " + assembled[i] + "\n" + + " implementation dep\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the sibling is aligned for <<" + assembled[i] + ">>, got <<" + out + ">>"); + check(!out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and the declaration is read as merged-era, got <<" + out + ">>"); + } + + // A single-quoted literal does not interpolate, so $v is not a version and + // the conservative answer stands. + String literal = KotlinStdlibAlignment.constraintsBlock("implementation", + " def v = '1.9.22'\n" + + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v'\n" + + " implementation dep\n"); + check("".equals(literal), + "an uninterpolated $v is not a version, got <<" + literal + ">>"); + } + + /** + * A reason is prose to the version scan as well. It reached the reason's + * coordinate before the map's own version entry, so the comment describing + * an old artifact supplied the version for the declaration warning about + * it -- and took the whole block down. + */ + @Test + public void aReasonDoesNotSupplyTheVersion() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation(group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib-jdk7', version: '1.9.22') " + + "{ because 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.22' }\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the sibling is still aligned, got <<" + out + ">>"); + check(!out.contains("kotlin-stdlib-jdk7:1.8.0"), + "and the declared 1.9.22 is what was read, got <<" + out + ">>"); + } + /** * Every spelling Gradle has for a force is a force. {@code force} is the * method, {@code setForcedModules} its setter, {@code forcedModules} the From 665cde24f7c2724839841e9eddb14441e0c8530d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:17:17 +0300 Subject: [PATCH 39/94] Ask whether a selector can reach the floor, not where it starts Three findings, and the first changes documented behaviour rather than fixing a slip, so it is worth saying why. "Below the floor" now means "cannot resolve to the floor or above". A range [1.7.0,1.9.0) begins below it and still selects a merged-era shim, because Gradle picks the highest version satisfying every constraint -- so our constraint on the SIBLING intersects that range rather than conflicting with it. The old reading took the lower endpoint and suppressed the whole block, leaving an old transitive jdk8 unaligned beside a merged stdlib, which is the duplicate this class exists to prevent. A range that genuinely cannot reach the floor, [1.6.0,1.8.0), still suppresses; so does 1.7.+, which cannot leave 1.7, while 1.+ can and no longer does. The residual case is a range whose upper versions do not exist in the repository: Gradle then falls back to something pre-merge and the duplicate returns, which fails in checkDuplicateClasses, where the app already was. That trade is recorded in the test. The predicate is now split by selector shape instead of funnelling every form through one bound, because those forms answer the question differently and the single funnel is what made the range wrong. A rich version overrides the coordinate's own, so it is read first: an implementation('...jdk7:1.9.22') { version { strictly '1.7.22' } } resolves strictly to 1.7.22, and reporting 1.9.22 skipped that artifact's constraint as already satisfied while raising everything around it. And a map KEY is not an expression. A local named after the DSL key it supplies had both occurrences replaced, turning `group:` into a quoted string and losing the map form entirely, strict pin and all. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 127 +++++++++++++----- .../builders/KotlinStdlibAlignmentTest.java | 87 +++++++++++- 2 files changed, 179 insertions(+), 35 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 25a996fa99c..81bf8878c94 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -303,6 +303,15 @@ private static boolean namesArtifactAnywhere(String line, String artifact) { * Null when neither is readable, which callers treat as below the floor. */ private static String declaredVersionOf(String line, String artifact) { + // A rich version OVERRIDES the coordinate's own, so it is read first. + // implementation('...:kotlin-stdlib-jdk7:1.9.22') { version { strictly '1.7.22' } } + // resolves strictly to 1.7.22, and reporting 1.9.22 read a pre-merge pin as + // merged-era: the jdk7 constraint was skipped as already satisfied while jdk8 + // and the base were raised around it. + String rich = richVersionIn(line); + if (rich != null) { + return rich; + } String coordinate = KOTLIN_GROUP + ":" + artifact + ":"; for (int i = 0; i < line.length(); i++) { char c = line.charAt(i); @@ -325,15 +334,7 @@ private static String declaredVersionOf(String line, String artifact) { if (mapped != null) { return mapped; } - // A rich-version closure carries the version instead of the coordinate: - // implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') { - // version { strictly '1.9.22' } - // } - // Returning null there made a merged-era declaration read as below the floor - // and took the sibling's constraint down with it, which is the one the graph - // still needed. Any of the rich-version keywords answers "what version", not - // only the one that also decides strictness. - return richVersionIn(line); + return null; } /** @@ -706,18 +707,88 @@ private static boolean belowTheFloor(String version) { if (version == null) { return true; } - String lowerBound = lowerBoundOf(version); - if (lowerBound == null) { + String selector = version.trim(); + if (selector.length() == 0) { return true; } - int compared = compareVersions(lowerBound, MERGED_STDLIB_FLOOR); + // "Below the floor" means "cannot resolve to the floor or above", which is + // not the same as "starts below it". A range [1.7.0,1.9.0) begins below and + // still selects a merged-era shim, so our constraint intersects it rather + // than conflicting; reading the lower endpoint suppressed the block for a + // declaration Gradle would have satisfied. Each selector shape answers the + // question its own way, which is why they are separated here rather than + // funnelled through one bound. + char opening = selector.charAt(0); + if (opening == '[' || opening == '(' || opening == ']') { + return rangeCannotReachTheFloor(selector); + } + int dynamic = selector.indexOf(".+"); + if (dynamic >= 0) { + // 1.7.+ cannot leave 1.7, so it is below. 1.+ can reach 1.9, so it is not. + String prefix = selector.substring(0, dynamic); + return compareVersions(prefix, + truncatedToSameDepth(MERGED_STDLIB_FLOOR, prefix)) < 0; + } + if ("+".equals(selector)) { + return false; + } + return literalBelowTheFloor(selector); + } + + /** Whether a range excludes every version at or above the floor. */ + private static boolean rangeCannotReachTheFloor(String selector) { + int comma = selector.indexOf(','); + if (comma < 0) { + // [1.8.0] is an exact version written as a range. + String exact = selector.substring(1, + Math.max(1, selector.length() - 1)).trim(); + return exact.length() == 0 || literalBelowTheFloor(exact); + } + String upper = selector.substring(comma + 1, + Math.max(comma + 1, selector.length() - 1)).trim(); + if (upper.length() == 0) { + // [1.7.0,) has no ceiling at all. + return false; + } + int compared = compareVersions(upper, MERGED_STDLIB_FLOOR); + char closing = selector.charAt(selector.length() - 1); + boolean excludesTheBound = closing == ')' || closing == '['; + return excludesTheBound ? compared <= 0 : compared < 0; + } + + /** A plain version, with a prerelease at the floor counting as below it. */ + private static boolean literalBelowTheFloor(String version) { + int compared = compareVersions(version, MERGED_STDLIB_FLOOR); if (compared != 0) { return compared < 0; } // At the floor numerically, only a PRERELEASE is below it. A dynamic marker // is not: 1.8.+ cannot resolve lower than 1.8.0, so it is at the floor and // the constraints are still satisfiable. - return isPrerelease(lowerBound); + return isPrerelease(version); + } + + /** {@code version} cut to as many components as {@code sample} has. */ + private static String truncatedToSameDepth(String version, String sample) { + int depth = 1; + for (int i = 0; i < sample.length(); i++) { + if (sample.charAt(i) == '.') { + depth++; + } + } + StringBuilder out = new StringBuilder(); + int seen = 0; + for (int i = 0; i < version.length() && seen < depth; i++) { + char c = version.charAt(i); + if (c == '.') { + seen++; + if (seen >= depth) { + break; + } + } + out.append(c); + } + return out.toString(); } /** @@ -731,24 +802,6 @@ private static boolean belowTheFloor(String version) { * merged-era declaration as pre-merge and dropped both constraints, * including the sibling's -- which is the one such a graph still needs.

*/ - private static String lowerBoundOf(String version) { - String selector = version.trim(); - if (selector.length() == 0) { - return null; - } - char opening = selector.charAt(0); - if (opening == '[' || opening == '(') { - selector = selector.substring(1); - int to = 0; - while (to < selector.length() && ",])".indexOf(selector.charAt(to)) < 0) { - to++; - } - selector = selector.substring(0, to); - } - selector = selector.trim(); - return selector.length() == 0 ? null : selector; - } - /** * Whether this version is a prerelease of its own numeric version, as * opposed to a dynamic selector. {@code 1.8.0-RC2} sorts below @@ -1888,7 +1941,17 @@ private static String withLiteralsInlined(String statement, } String token = statement.substring(i, end); String literal = literals.get(token); - out.append(literal == null ? token : literal); + // A map KEY is not an expression, so it is not substituted. A local + // named after the DSL key it supplies -- def group = '...'; then + // implementation(group: group, ...) -- had both occurrences replaced, + // turning `group:` into a quoted string and losing the map form + // entirely, strict pin and all. Groovy's named arguments are exactly + // "identifier immediately followed by a colon", which is what this asks. + boolean isMapKey = end < statement.length() + && statement.charAt(end) == ':' + && (end + 1 >= statement.length() + || statement.charAt(end + 1) != ':'); + out.append(literal == null || isMapKey ? token : literal); i = end - 1; } return out.toString(); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 9d8addd779e..11409f2f87e 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -563,6 +563,54 @@ public void theAlignmentCannotFailTheBuild() throws Exception { "and says so, rather than swallowing the defect"); } + /** + * A local may be named after the DSL key it supplies. Substituting every + * occurrence turned {@code group:} into a quoted string and lost the map + * form entirely, taking the strict pin inside it with it. + */ + @Test + public void aLocalNamedAfterAMapKeyDoesNotReplaceTheKey() { + String[] keys = {"group", "name", "version"}; + for (int k = 0; k < keys.length; k++) { + String value = "version".equals(keys[k]) ? "1.7.22" + : "name".equals(keys[k]) ? "kotlin-stdlib-jdk8" + : "org.jetbrains.kotlin"; + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def " + keys[k] + " = '" + value + "'\n" + + " implementation(group: " + + ("group".equals(keys[k]) ? "group" : "'org.jetbrains.kotlin'") + + ", name: " + + ("name".equals(keys[k]) ? "name" : "'kotlin-stdlib-jdk8'") + + ", version: " + + ("version".equals(keys[k]) ? "version" : "'1.7.22'") + + ")\n"); + check("".equals(out), + "the map form survives a local called " + keys[k] + + ", got <<" + out + ">>"); + } + } + + /** + * A rich version overrides the coordinate's own. Reporting the coordinate + * read a pre-merge pin as merged-era, so its own constraint was skipped as + * already satisfied while the sibling and the base were raised around it. + */ + @Test + public void aRichVersionOverridesTheCoordinate() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22') " + + "{ version { strictly '1.7.22' } }\n"); + check("".equals(out), + "the strict 1.7.22 is what decides, got <<" + out + ">>"); + + // And the other way round: a merged-era rich version over an old coordinate. + String modern = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.22') " + + "{ version { require '1.9.22' } }\n"); + check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), + "a required 1.9.22 is read past the old coordinate, got <<" + modern + ">>"); + } + /** * Setting a property is not calling a method. {@code { force = false }} * explicitly turns forcing OFF, and reading the word as a force turned an @@ -1568,11 +1616,44 @@ public void aVersionSelectorIsReadByItsLowerBound() { check(dynamic.contains("kotlin-stdlib-jdk8:1.8.0"), "1.8.+ cannot resolve below the floor"); - // range whose low end IS below the floor: conservative, both go + // A range that STARTS below the floor but can still select above it keeps + // the alignment. This was the conservative case once, on the grounds that + // the range reaches below the floor at all; the question that decides + // resolution is the other end. Gradle picks the highest version satisfying + // every constraint, so [1.7.0,1.9.0) selects a merged-era shim and our + // constraint on the SIBLING intersects that rather than conflicting with it + // -- and suppressing instead left an old transitive jdk8 unaligned beside a + // merged stdlib, which is the duplicate this class exists to prevent. + // + // The residual case is a range whose upper versions do not exist in the + // repository, where Gradle falls back to something pre-merge and the + // duplicate returns. That fails loudly in checkDuplicateClasses, which is + // where the app already was, so it is the better of the two. String spanning = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:[1.7.0,1.9.0)'\n"); - check("".equals(spanning), - "a range reaching below the floor is treated as below it"); + check(spanning.contains("kotlin-stdlib-jdk8:1.8.0"), + "a range that can select above the floor keeps the sibling aligned, got <<" + + spanning + ">>"); + + // A range that CANNOT reach the floor still suppresses: the constraint would + // have nothing to resolve to and the build would fail outright. + String capped = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:[1.6.0,1.8.0)'\n"); + check("".equals(capped), + "a range capped below the floor suppresses, got <<" + capped + ">>"); + + String low = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:[1.6.0,1.7.9]'\n"); + check("".equals(low), "and so does one entirely below it, got <<" + low + ">>"); + + // 1.7.+ cannot leave 1.7; 1.+ can reach 1.9. + String narrow = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.+'\n"); + check("".equals(narrow), "1.7.+ cannot reach the floor, got <<" + narrow + ">>"); + String wide = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.+'\n"); + check(wide.contains("kotlin-stdlib-jdk8:1.8.0"), + "1.+ can, got <<" + wide + ">>"); // and a dynamic selector below the floor likewise String oldDynamic = KotlinStdlibAlignment.constraintsBlock("implementation", From 4055a52b89270823a48da37649ad03f8747056e2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:59:58 +0300 Subject: [PATCH 40/94] Read a partial coordinate, a status selector and a conditional for what they are A literal ending AT the version separator carries no version -- the rest is concatenated on, as in ("...:kotlin-stdlib-jdk7:" + kotlinVersion). Returning the empty string read that as a version below the floor and suppressed the block for a declaration that may well be merged-era. Unreadable is the honest answer, and it leaves both constraints to be written, which cannot conflict with a plain requirement -- only with a strict pin, and those are read first. Gradle's status selectors have no ceiling either, so latest.release joins `+` and the ranges that can reach the floor. Compared as a literal it parsed as zero, which is the oldest version there is. A name assigned inside a conditional may hold either value, because whether the branch runs is decided at evaluation time and cannot be read here. The ambiguity is now resolved toward suppression -- a conditional assignment does not throw away a coordinate that decides it -- because emitting beside a pin this could not see is the failure that reaches the device, while suppressing costs an app the duplicate it already had. Unconditional reassignment still replaces what it replaces, and a conditional assignment TO a coordinate is still taken. Worth recording: the reported reproduction did not reproduce. Braces do not split statements, so the single-line `if (c) { dep = '...' }` arrives as one statement whose first token is `if` and which assigns nothing at all. Only the multi-line form, where the assignment is a statement of its own, was ever applied unconditionally. Both spellings are asserted now so the difference is not mistaken for a gap later. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 85 +++++++++++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 83 ++++++++++++++++++ 2 files changed, 162 insertions(+), 6 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 81bf8878c94..fd6f2df4a08 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -320,7 +320,16 @@ private static String declaredVersionOf(String line, String artifact) { } int end = endOfStringLiteral(line, i); String literal = stringLiteralContent(line, i); - if (literal.startsWith(coordinate) && !hasWhitespace(literal) + // A literal ending AT the version separator carries no version: the + // rest is concatenated on, as in ("...:kotlin-stdlib-jdk7:" + version). + // Returning the empty string there read as a version below the floor and + // suppressed the block for a declaration that may well be merged-era. + // Unreadable is the honest answer, and it leaves both constraints to be + // written -- which cannot conflict with a plain requirement, only with a + // strict pin, and those are read before this. + if (literal.startsWith(coordinate) + && literal.length() > coordinate.length() + && !hasWhitespace(literal) && !isReasonArgument(line, i)) { // A reason can be nothing but a coordinate, and this scan reached it // before the map's own version: entry. namesCoordinate learned to @@ -729,7 +738,11 @@ private static boolean belowTheFloor(String version) { return compareVersions(prefix, truncatedToSameDepth(MERGED_STDLIB_FLOOR, prefix)) < 0; } - if ("+".equals(selector)) { + if ("+".equals(selector) || selector.startsWith("latest.")) { + // `+` and Gradle's status selectors -- latest.release, latest.integration + // -- have no ceiling at all, so they can always select a merged-era shim. + // Compared as a literal, latest.release parsed as zero and read as the + // oldest version there is. return false; } return literalBelowTheFloor(selector); @@ -1752,19 +1765,33 @@ private static List inlineLiteralDefinitions(List statements) { // nothing this can follow, and reading it as a definition would supply a // version to an unrelated $version. int extDepth = 0; + // Whether a conditional branch runs is decided at evaluation time and cannot + // be read here, so a name assigned inside one may hold either value. The + // ambiguity is resolved toward suppression: emitting beside a pin this could + // not see is the failure that reaches the device, while suppressing costs an + // app the duplicate it already had. + int conditionalDepth = 0; for (int i = 0; i < statements.size(); i++) { String statement = statements.get(i); out.add(literals.isEmpty() ? statement : withLiteralsInlined(statement, literals)); boolean opensExt = extDepth == 0 && opensAnExtraPropertiesBlock(statement); - updateLiteralDefinitions(statement, literals, extDepth > 0 || opensExt); + boolean opensConditional = opensAConditional(statement); + updateLiteralDefinitions(statement, literals, extDepth > 0 || opensExt, + conditionalDepth > 0 || opensConditional); if (extDepth > 0 || opensExt) { extDepth += braceBalance(statement); if (extDepth < 0) { extDepth = 0; } } + if (conditionalDepth > 0 || opensConditional) { + conditionalDepth += braceBalance(statement); + if (conditionalDepth < 0) { + conditionalDepth = 0; + } + } } return out; } @@ -1775,6 +1802,50 @@ private static List inlineLiteralDefinitions(List statements) { * reassignment to something unreadable, which forgets it rather than * leaving a stale value behind. */ + /** + * Whether the statement opens a control structure whose body may not run. + * + *

Note the single-line spelling of one -- {@code if (c) { dep = '...' }} -- + * never reached this: braces do not split statements, so the whole + * conditional arrives as one statement whose first token is {@code if} and + * which therefore assigns nothing. Only the multi-line form, where the + * assignment is a statement of its own, was ever applied unconditionally. + * Checked both ways before writing this.

+ */ + private static boolean opensAConditional(String statement) { + for (int k = 0; k < CONTROL_KEYWORDS.length; k++) { + if (callsNamed(statement, CONTROL_KEYWORDS[k])) { + return braceBalance(statement) > 0; + } + } + return false; + } + + /** Groovy's control structures, whose bodies are not known to run. */ + private static final String[] CONTROL_KEYWORDS = { + "if", "else", "while", "for", "switch", "try", "catch" + }; + + /** + * Records a definition, or forgets it, unless doing so under a condition + * would throw away the value that decides suppression. + */ + private static void recordDefinition(Map literals, String name, + String value, boolean conditional) { + if (conditional) { + String known = literals.get(name); + if (known != null && known.indexOf(KOTLIN_GROUP) >= 0 + && (value == null || value.indexOf(KOTLIN_GROUP) < 0)) { + return; + } + } + if (value == null) { + literals.remove(name); + } else { + literals.put(name, value); + } + } + /** * Whether the statement opens a Gradle {@code ext { }} block, as a whole * token so that a dependency on {@code com.example:extras} does not. @@ -1796,7 +1867,8 @@ private static boolean opensAnExtraPropertiesBlock(String statement) { } private static void updateLiteralDefinitions(String statement, - Map literals, boolean insideExtraProperties) { + Map literals, boolean insideExtraProperties, + boolean conditional) { if (insideExtraProperties) { // The assignment may share the line with the brace that opened the block, // as `ext { kotlinVersion = '1.9.22' }` does, so read from after it. @@ -1875,11 +1947,12 @@ && isIdentifierChar(statement.charAt(nameStart))) { && isLiteralStart(statement, i)) { int end = endOfStringLiteral(statement, i); if (end < statement.length()) { - literals.put(name, expandedLiteral(statement, i, end, literals)); + recordDefinition(literals, name, + expandedLiteral(statement, i, end, literals), conditional); return; } } - literals.remove(name); + recordDefinition(literals, name, null, conditional); } /** diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 11409f2f87e..d61faecbf8e 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -563,6 +563,89 @@ public void theAlignmentCannotFailTheBuild() throws Exception { "and says so, rather than swallowing the defect"); } + /** + * A coordinate concatenated onto a partial literal has no version here, + * and unreadable is the honest answer -- reading the empty string as a + * version put it below the floor and suppressed the block for a + * declaration that may well be merged-era. + */ + @Test + public void aConcatenatedVersionIsNotAnEmptyOne() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation(\"org.jetbrains.kotlin:kotlin-stdlib-jdk7:\" " + + "+ kotlinVersion)\n"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0") + && out.contains("kotlin-stdlib-jdk8:1.8.0"), + "both constraints are written, got <<" + out + ">>"); + } + + /** + * Gradle's status selectors have no ceiling, so they can select a + * merged-era shim. Compared as literals they parsed as zero, which is the + * oldest version there is. + */ + @Test + public void aStatusSelectorCanReachTheFloor() { + String[] selectors = {"latest.release", "latest.integration", "+"}; + for (int i = 0; i < selectors.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:" + + selectors[i] + "'\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + selectors[i] + " keeps the sibling aligned, got <<" + out + ">>"); + } + } + + /** + * A name assigned inside a conditional may hold either value, because + * whether the branch runs is decided at evaluation time. The ambiguity is + * resolved toward suppression: emitting beside a pin this could not see is + * the failure that reaches the device. + * + *

The single-line spelling was never affected -- braces do not split + * statements, so {@code if (c) { dep = '...' }} arrives as one statement + * that assigns nothing -- and it is asserted here so the difference is not + * mistaken for a gap later.

+ */ + @Test + public void aConditionalReassignmentDoesNotHideAPin() { + String multiLine = KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" + + " if (project.hasProperty('other')) {\n" + + " dep = 'com.example:other:1.0'\n" + + " }\n" + + " implementation(dep) { version { strictly '1.7.22' } }\n"); + check("".equals(multiLine), + "the pin survives a conditional reassignment, got <<" + multiLine + ">>"); + + String oneLine = KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'; " + + "if (project.hasProperty('other')) { dep = 'com.example:other:1.0' }; " + + "implementation(dep) { version { strictly '1.7.22' } }\n"); + check("".equals(oneLine), + "and the one-line form, which never assigned at all, got <<" + + oneLine + ">>"); + + // The other direction was already safe and stays that way: a conditional + // assignment TO a Kotlin coordinate is taken, because taking it suppresses. + String gained = KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = 'com.example:other:1.0'\n" + + " if (project.hasProperty('old')) {\n" + + " dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" + + " }\n" + + " implementation(dep) { version { strictly '1.7.22' } }\n"); + check("".equals(gained), + "a conditional assignment to a coordinate is seen, got <<" + gained + ">>"); + + // Unconditionally, a reassignment still replaces what it replaces. + String plain = KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" + + " dep = 'com.example:other:1.0'\n" + + " implementation(dep)\n"); + check(plain.contains("kotlin-stdlib-jdk8:1.8.0"), + "an unconditional reassignment still applies, got <<" + plain + ">>"); + } + /** * A local may be named after the DSL key it supplies. Substituting every * occurrence turned {@code group:} into a quoted string and lost the map From b083292ba42a0b11c0b664f428e49c41c46886a4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:22:06 +0300 Subject: [PATCH 41/94] Count braces instead of naming the constructs that open them The list of control structures whose bodies might not run -- if, else, while, for, switch, try, catch -- was one commit old and already missing the closure: `def mutate = { dep = '...' }` runs only when something calls it, and the reassignment inside it was applied as though it had, losing an active strict pin. So the list is gone. Depth is the closed half of that question: at the top level a statement runs, and inside any open brace this cannot say. It is also costless to be wrong about, because the flag only refuses to DISCARD a coordinate -- a first definition is still recorded at any depth, which is what keeps a `def` inside dependencies { } working, and that case is asserted. The reported reproduction again did not reproduce, for the same reason as last time: braces do not split statements, so the one-line `def mutate = { dep = '...' }` arrives whole, assigns nothing, and never had the bug. Only the multi-line spelling did. Also: a release candidate of the floor is below the floor wherever it appears. As a range's inclusive ceiling it was compared numerically, so [1.7.0,1.8.0-RC2] read as reaching 1.8.0 and the constraints went in with nothing to resolve to. An inclusive ceiling now asks exactly what a plain version is asked, which already knew this. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 63 ++++++++----------- .../builders/KotlinStdlibAlignmentTest.java | 49 +++++++++++++++ 2 files changed, 75 insertions(+), 37 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index fd6f2df4a08..e1aed6f0c9a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -763,10 +763,17 @@ private static boolean rangeCannotReachTheFloor(String selector) { // [1.7.0,) has no ceiling at all. return false; } - int compared = compareVersions(upper, MERGED_STDLIB_FLOOR); char closing = selector.charAt(selector.length() - 1); - boolean excludesTheBound = closing == ')' || closing == '['; - return excludesTheBound ? compared <= 0 : compared < 0; + if (closing == ')' || closing == '[') { + // Excluding its bound, the range stops short of it: at or below the floor + // numerically means nothing at or above the floor is selectable. + return compareVersions(upper, MERGED_STDLIB_FLOOR) <= 0; + } + // Including it, the bound itself is selectable -- so the question is exactly + // the one asked of a plain version, prerelease and all. Comparing numerically + // here read [1.7.0,1.8.0-RC2] as reaching the floor, when a release candidate + // of it is below it and the constraint had nothing to resolve to. + return literalBelowTheFloor(upper); } /** A plain version, with a prerelease at the floor counting as below it. */ @@ -1765,32 +1772,38 @@ private static List inlineLiteralDefinitions(List statements) { // nothing this can follow, and reading it as a definition would supply a // version to an unrelated $version. int extDepth = 0; - // Whether a conditional branch runs is decided at evaluation time and cannot - // be read here, so a name assigned inside one may hold either value. The + // Whether a nested block runs is decided at evaluation time and cannot be + // read here, so a name assigned inside one may hold either value. The // ambiguity is resolved toward suppression: emitting beside a pin this could // not see is the failure that reaches the device, while suppressing costs an // app the duplicate it already had. - int conditionalDepth = 0; + // + // ANY open brace, not a list of the constructs that open one. That list was + // if/else/while/for/switch/try/catch and it was already missing the closure + // -- `def mutate = { dep = ... }` runs only if something calls it. Depth is + // the closed half of the question: at the top level a statement runs, and + // inside anything at all this cannot say. Costless to be wrong about, too, + // since the flag only refuses to DISCARD a coordinate; a first definition is + // still recorded at any depth, which is why a `def` inside dependencies { } + // keeps working. + int braceDepth = 0; for (int i = 0; i < statements.size(); i++) { String statement = statements.get(i); out.add(literals.isEmpty() ? statement : withLiteralsInlined(statement, literals)); boolean opensExt = extDepth == 0 && opensAnExtraPropertiesBlock(statement); - boolean opensConditional = opensAConditional(statement); updateLiteralDefinitions(statement, literals, extDepth > 0 || opensExt, - conditionalDepth > 0 || opensConditional); + braceDepth > 0); if (extDepth > 0 || opensExt) { extDepth += braceBalance(statement); if (extDepth < 0) { extDepth = 0; } } - if (conditionalDepth > 0 || opensConditional) { - conditionalDepth += braceBalance(statement); - if (conditionalDepth < 0) { - conditionalDepth = 0; - } + braceDepth += braceBalance(statement); + if (braceDepth < 0) { + braceDepth = 0; } } return out; @@ -1802,30 +1815,6 @@ private static List inlineLiteralDefinitions(List statements) { * reassignment to something unreadable, which forgets it rather than * leaving a stale value behind. */ - /** - * Whether the statement opens a control structure whose body may not run. - * - *

Note the single-line spelling of one -- {@code if (c) { dep = '...' }} -- - * never reached this: braces do not split statements, so the whole - * conditional arrives as one statement whose first token is {@code if} and - * which therefore assigns nothing. Only the multi-line form, where the - * assignment is a statement of its own, was ever applied unconditionally. - * Checked both ways before writing this.

- */ - private static boolean opensAConditional(String statement) { - for (int k = 0; k < CONTROL_KEYWORDS.length; k++) { - if (callsNamed(statement, CONTROL_KEYWORDS[k])) { - return braceBalance(statement) > 0; - } - } - return false; - } - - /** Groovy's control structures, whose bodies are not known to run. */ - private static final String[] CONTROL_KEYWORDS = { - "if", "else", "while", "for", "switch", "try", "catch" - }; - /** * Records a definition, or forgets it, unless doing so under a condition * would throw away the value that decides suppression. diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index d61faecbf8e..7b142d5d6a5 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -563,6 +563,55 @@ public void theAlignmentCannotFailTheBuild() throws Exception { "and says so, rather than swallowing the defect"); } + /** + * An assignment inside ANY open brace is one whose execution this cannot + * establish, closures included: {@code def mutate = { dep = '...' }} runs + * only if something calls it. Named control structures were listed here + * once and the list was already missing this. + */ + @Test + public void anAssignmentInsideAClosureDoesNotHideAPin() { + String multiLine = KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" + + " def mutate = {\n" + + " dep = 'com.example:other:1.0'\n" + + " }\n" + + " implementation(dep) { version { strictly '1.7.22' } }\n"); + check("".equals(multiLine), + "the pin survives an uninvoked closure, got <<" + multiLine + ">>"); + + // A first definition is still recorded at any depth, which is what keeps a + // `def` inside dependencies { } working. + String nested = KotlinStdlibAlignment.constraintsBlock("implementation", + " dependencies {\n" + + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" + + " implementation(dep) { version { strictly '1.7.22' } }\n" + + " }\n"); + check("".equals(nested), + "a definition inside a block is still read, got <<" + nested + ">>"); + } + + /** + * A release candidate of the floor is below the floor, wherever it appears. + * As a range's inclusive ceiling it was compared numerically and read as + * reaching the floor, so the constraints went in with nothing to resolve + * to. + */ + @Test + public void aPrereleaseCeilingDoesNotReachTheFloor() { + String rc = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib') " + + "{ version { strictly '[1.7.0,1.8.0-RC2]' } }\n"); + check("".equals(rc), + "a prerelease ceiling cannot reach the floor, got <<" + rc + ">>"); + + // The release itself can, and does. + String release = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:[1.7.0,1.8.0]'\n"); + check(release.contains("kotlin-stdlib-jdk8:1.8.0"), + "an inclusive release ceiling does, got <<" + release + ">>"); + } + /** * A coordinate concatenated onto a partial literal has no version here, * and unreadable is the honest answer -- reading the empty string as a From f0a5ee4ede74cc9d84b537ba2473a7577d0b8067 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:46:05 +0300 Subject: [PATCH 42/94] Read a resolution rule, an empty declaration and a qualified type A rule's useVersion rewrites what was requested, silently, on the way through, so it holds the library exactly as firmly as a force and is now read as one. Such a rule names its artifact by comparing the parts -- `d.requested.group == '...' && d.requested.name == '...'` -- which is neither a coordinate nor a map entry, so a pre-merge useVersion on the base library went unseen while the shims were raised to their empty jars around it. Naming now also accepts the group and the artifact appearing as literals of their own. `def dep` with no value is still a name this knows about, and recording it is what lets a later assignment be recognised as one rather than as a write to something unrelated. It is stored with no value, which inlines as the name itself -- what an unset variable should look like. And a qualified type is ONE token: `java.lang.String dep = '...'` stopped at the first dot, read `java` as the type and `lang` as the name, and never recorded dep. Dots now belong to the token they are inside, with the extra-property case -- a dotted TARGET rather than a dotted type -- read off the token instead of the character after it. Both are asserted, because the change to one is what could break the other. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 74 +++++++++++++++---- .../builders/KotlinStdlibAlignmentTest.java | 68 +++++++++++++++++ 2 files changed, 129 insertions(+), 13 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index e1aed6f0c9a..4686c493aab 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -294,7 +294,32 @@ private static boolean bindsAVersion(String line, String artifact) { private static boolean namesArtifactAnywhere(String line, String artifact) { return namesCoordinate(line, artifact) || (declaresMapEntry(line, "group", KOTLIN_GROUP) - && declaresMapEntry(line, "name", artifact)); + && declaresMapEntry(line, "name", artifact)) + || (holdsLiteral(line, KOTLIN_GROUP) && holdsLiteral(line, artifact)); + } + + /** + * Whether the statement contains {@code value} as a literal of its own. + * + *

A resolution rule names an artifact by comparing its parts -- + * {@code d.requested.group == 'org.jetbrains.kotlin' && d.requested.name == + * 'kotlin-stdlib'} -- which is neither a coordinate nor a map entry, so + * neither of the shapes above saw it and a useVersion rewriting the base + * library went unread.

+ */ + private static boolean holdsLiteral(String line, String value) { + for (int i = 0; i < line.length(); i++) { + if (!isLiteralStart(line, i)) { + continue; + } + int end = endOfStringLiteral(line, i); + if (value.equals(stringLiteralContent(line, i)) + && !isReasonArgument(line, i)) { + return true; + } + i = end; + } + return false; } /** @@ -509,9 +534,7 @@ private static boolean holdsStrictly(String line, String artifact) { /** Whether the statement names {@code kotlin-stdlib} and not a longer name. */ private static boolean namesBaseStdlib(String line) { - return namesCoordinate(line, BASE_STDLIB) - || (declaresMapEntry(line, "group", KOTLIN_GROUP) - && declaresMapEntry(line, "name", BASE_STDLIB)); + return namesArtifactAnywhere(line, BASE_STDLIB); } /** @@ -653,9 +676,17 @@ private static String richVersionIn(String statement) { if (strict != null) { return strict; } + // A resolution rule's useVersion is as authoritative as either: it rewrites + // what was requested, silently, on the way through. + String ruled = versionInCall(statement, USE_VERSION); + if (ruled != null) { + return ruled; + } return versionInCall(statement, "require"); } + private static final String USE_VERSION = "useVersion"; + /** The quoted argument of {@code call}, found outside string literals. */ private static String versionInCall(String statement, String call) { // The same syntax-level call callsStrictly validated, not any occurrence of @@ -1023,7 +1054,8 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat */ private static boolean callsForce(String statement) { // The method forms, which callsNamed now distinguishes from an assignment. - if (callsNamed(statement, "force") || callsNamed(statement, "setForcedModules")) { + if (callsNamed(statement, "force") || callsNamed(statement, "setForcedModules") + || callsNamed(statement, USE_VERSION)) { return true; } // forcedModules is only ever written as an assignment, and assigning it any @@ -1884,21 +1916,29 @@ private static void updateLiteralDefinitions(String statement, // last of them, exactly one is an assignment. int scan = i; int lastTokenStart = i; + int lastTokenEnd = i; int tokens = 0; while (scan < statement.length() && isIdentifierChar(statement.charAt(scan))) { lastTokenStart = scan; tokens++; + // A qualified name is ONE token: java.lang.String dep = '...' is a + // declaration whose type happens to have dots in it, and stopping at + // the first one read `java` as the type and `lang` as the name, so + // dep was never recorded and the pin it carried never seen. while (scan < statement.length() - && isIdentifierChar(statement.charAt(scan))) { + && (isIdentifierChar(statement.charAt(scan)) + || (statement.charAt(scan) == '.' + && scan + 1 < statement.length() + && isIdentifierChar(statement.charAt(scan + 1))))) { scan++; } + lastTokenEnd = scan; scan = skipBlanks(statement, scan); } if (tokens > 1) { declared = true; i = lastTokenStart; - } else if (tokens == 1 && scan < statement.length() - && statement.charAt(scan) == '.') { + } else if (tokens == 1) { // ext.kotlinVersion = '1.9.22' -- Gradle's extra properties, which is // how a project-wide version is nearly always written, and which // really does bind the bare name the interpolation then reads. @@ -1906,12 +1946,11 @@ && isIdentifierChar(statement.charAt(scan))) { // assignment would let `somePlugin.version = '1.0'` supply the value // for an unrelated $version and turn an unreadable version into a // confidently wrong one, which is the direction that under-suppresses. - int nameStart = skipBlanks(statement, scan + 1); - if (EXTRA_PROPERTIES.equals(statement.substring(lastTokenStart, scan).trim()) - && nameStart < statement.length() - && isIdentifierChar(statement.charAt(nameStart))) { + String only = statement.substring(lastTokenStart, lastTokenEnd); + int dot = only.lastIndexOf('.'); + if (dot > 0 && EXTRA_PROPERTIES.equals(only.substring(0, dot))) { declared = true; - i = nameStart; + i = lastTokenStart + dot + 1; } } } @@ -1929,6 +1968,15 @@ && isIdentifierChar(statement.charAt(nameStart))) { i = skipBlanks(statement, i); if (i >= statement.length() || statement.charAt(i) != '=' || (i + 1 < statement.length() && statement.charAt(i + 1) == '=')) { + if (declared) { + // `def dep` with no value yet is still a name this knows about, and + // recording it is what lets a later assignment be recognised as one. + // Without it, `def dep` then `if (legacy) { dep = '...' }` left the + // assignment looking like a write to something unrelated, so the + // coordinate it carried was never learned. A null value inlines as + // the name itself, which is what an unset variable should look like. + literals.put(name, null); + } return; } i = skipBlanks(statement, i + 1); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 7b142d5d6a5..790934e6153 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -563,6 +563,74 @@ public void theAlignmentCannotFailTheBuild() throws Exception { "and says so, rather than swallowing the defect"); } + /** + * A resolution rule's {@code useVersion} rewrites what was requested, + * silently, on the way through -- so it holds the library as firmly as a + * force does. Such a rule names its artifact by comparing the parts, which + * is neither a coordinate nor a map entry, and was read as naming nothing. + */ + @Test + public void aResolutionRuleHoldsWhatItRewrites() { + String rule = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.eachDependency { d -> " + + "if (d.requested.group == 'org.jetbrains.kotlin' && " + + "d.requested.name == 'kotlin-stdlib') d.useVersion '1.7.22' } }\n"); + check("".equals(rule), + "a rule pinning the base library pre-merge suppresses, got <<" + + rule + ">>"); + + // Rewriting it to a merged-era version takes nothing away. + String modern = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.eachDependency { d -> " + + "if (d.requested.group == 'org.jetbrains.kotlin' && " + + "d.requested.name == 'kotlin-stdlib') d.useVersion '1.9.22' } }\n"); + check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), + "a merged-era rule keeps the alignment, got <<" + modern + ">>"); + } + + /** + * A variable declared without a value is still a name this knows, and + * recording it is what lets a later assignment be recognised as one. + */ + @Test + public void aDeclarationWithoutAValueIsStillADeclaration() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep\n" + + " if (legacy) {\n" + + " dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" + + " }\n" + + " implementation(dep) { version { strictly '1.7.22' } }\n"); + check("".equals(out), + "the conditional assignment is seen, got <<" + out + ">>"); + } + + /** + * A qualified type is one token. Stopping at its first dot read + * {@code java} as the type and {@code lang} as the name, so the variable + * was never recorded -- while {@code ext.kotlinVersion}, which is a dotted + * TARGET rather than a dotted type, still has to be read the other way. + */ + @Test + public void aQualifiedTypeIsOneToken() { + String[] types = {"java.lang.String", "String", "final java.lang.String"}; + for (int i = 0; i < types.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " " + types[i] + + " dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" + + " implementation(dep) { version { strictly '1.7.22' } }\n"); + check("".equals(out), + "a local of type " + types[i] + " is recorded, got <<" + out + ">>"); + } + + // The dotted extra property is still a property, not a type. + String ext = KotlinStdlibAlignment.constraintsBlock("implementation", + " ext.kotlinVersion = '1.9.22'\n" + + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion\"\n"); + check(ext.contains("kotlin-stdlib-jdk8:1.8.0") + && !ext.contains("kotlin-stdlib-jdk7:1.8.0"), + "ext.kotlinVersion still binds its name, got <<" + ext + ">>"); + } + /** * An assignment inside ANY open brace is one whose execution this cannot * establish, closures included: {@code def mutate = { dep = '...' }} runs From 0bd89399d35b809d9bf034d3a7ff53e89a2c991e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:03:08 +0300 Subject: [PATCH 43/94] Read useTarget as a force, and a quoted map key as a key A resolution rule's useTarget replaces the whole coordinate where useVersion replaces the version, and both win silently over a constraint -- so a rule retargeting the base library to a pre-merge version left the shims raised to their empty jars around it. Not added, deliberately: dependencySubstitution's `substitute ... using ...`. It names TWO coordinates and the version scan takes the first literal, which is the one being REPLACED, so a substitution raising the library to a merged-era version would read as pinning it to the old one. Wrong in the harmless direction, but wrong, and it can go in when the scan can tell the sides apart. And a map key may be quoted. Every literal was skipped before looking for a key, so ('group': '...', 'name': '...') named no artifact at all and the strict pin inside it went unseen. Both spellings now share one reader for the value after the colon, so the delimiter rule is written once rather than twice. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 57 +++++++++++++++---- .../builders/KotlinStdlibAlignmentTest.java | 52 +++++++++++++++++ 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 4686c493aab..93ede6ab781 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -408,7 +408,20 @@ private static String mapEntryValue(String line, String key) { for (int i = 0; i < line.length(); i++) { char c = line.charAt(i); if (isLiteralStart(line, i)) { - i = endOfStringLiteral(line, i); + // Groovy lets a map key be quoted -- ('group': '...', 'name': '...') -- + // and skipping every literal meant the key was never seen, so a + // declaration written that way named no artifact at all. + int quoted = endOfStringLiteral(line, i); + if (key.equals(stringLiteralContent(line, i))) { + int at = skipBlanks(line, quoted + 1); + if (at < line.length() && line.charAt(at) == ':') { + String value = valueAfterColon(line, at); + if (value != null) { + return value; + } + } + } + i = quoted; continue; } if (!line.startsWith(key, i)) { @@ -424,21 +437,32 @@ && isIdentifierChar(line.charAt(after)))) { if (j >= line.length() || line.charAt(j) != ':') { continue; } - j = skipBlanks(line, j + 1); - if (j < line.length() && isLiteralStart(line, j)) { - if (endOfStringLiteral(line, j) < line.length()) { - // The real delimiter length, as the coordinate path does. Stripping - // one character per side left a triple-quoted group or name wearing - // two quotes, so both failed their exact match and the declaration - // was ignored -- strict pin and all. - return stringLiteralContent(line, j); - } + String value = valueAfterColon(line, j); + if (value != null) { + return value; } i = j; } return null; } + /** + * The literal following the colon at {@code colonAt}, or null. + * + *

Shared by both spellings of a key, bare and quoted, so the delimiter + * rule is read once. Stripping one character per side used to leave a + * triple-quoted group or name wearing two quotes, and both then failed + * their exact match.

+ */ + private static String valueAfterColon(String line, int colonAt) { + int at = skipBlanks(line, colonAt + 1); + if (at < line.length() && isLiteralStart(line, at) + && endOfStringLiteral(line, at) < line.length()) { + return stringLiteralContent(line, at); + } + return null; + } + /** * Whether the app strictly holds {@code kotlin-stdlib} itself below the * floor both shims depend on. @@ -1054,8 +1078,19 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat */ private static boolean callsForce(String statement) { // The method forms, which callsNamed now distinguishes from an assignment. + // Gradle's ways of overriding a selected version: force and its setter, and a + // resolution rule's useVersion (a bare version) or useTarget (a whole + // coordinate). All of them win silently over a constraint. + // + // dependencySubstitution's `substitute ... using ...` is deliberately NOT + // here. It names TWO coordinates and the version scan takes the first + // literal, which is the one being replaced -- so a substitution raising the + // library to a merged-era version would read as pinning it to the old one. + // Wrong in the harmless direction, but wrong, and it can be added when the + // scan can tell the sides apart. if (callsNamed(statement, "force") || callsNamed(statement, "setForcedModules") - || callsNamed(statement, USE_VERSION)) { + || callsNamed(statement, USE_VERSION) + || callsNamed(statement, "useTarget")) { return true; } // forcedModules is only ever written as an assignment, and assigning it any diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 790934e6153..f04ed932525 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -563,6 +563,58 @@ public void theAlignmentCannotFailTheBuild() throws Exception { "and says so, rather than swallowing the defect"); } + /** + * A map key may be quoted. Skipping every literal meant the key was never + * seen, so a declaration written that way named no artifact at all and the + * strict pin inside it went with it. + */ + @Test + public void aMapKeyMayBeQuoted() { + String[] quotes = {"'", "\"", "'''", "\"\"\""}; + for (int q = 0; q < quotes.length; q++) { + String u = quotes[q]; + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation(" + u + "group" + u + ": 'org.jetbrains.kotlin', " + + u + "name" + u + ": 'kotlin-stdlib-jdk8', " + + u + "version" + u + ": '1.7.22')\n"); + check("".equals(out), + "a key quoted with " + u + " is still a key, got <<" + out + ">>"); + } + + // Mixed spellings in one declaration, which Groovy also accepts. + String mixed = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('group': 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib-jdk8', \"version\": '1.7.22')\n"); + check("".equals(mixed), "mixed key spellings, got <<" + mixed + ">>"); + + // And a merged-era one written the same way keeps the sibling aligned. + String modern = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('group': 'org.jetbrains.kotlin', " + + "'name': 'kotlin-stdlib-jdk7', 'version': '1.9.22')\n"); + check(modern.contains("kotlin-stdlib-jdk8:1.8.0") + && !modern.contains("kotlin-stdlib-jdk7:1.8.0"), + "the merged-era declaration is read, got <<" + modern + ">>"); + } + + /** + * {@code useTarget} replaces the whole coordinate rather than the version, + * and overrides just as absolutely as {@code useVersion} does. + */ + @Test + public void aRuleThatRetargetsIsStillARule() { + String old = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.eachDependency { d -> " + + "d.useTarget 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' } }\n"); + check("".equals(old), + "retargeting the base library pre-merge suppresses, got <<" + old + ">>"); + + String modern = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.eachDependency { d -> " + + "d.useTarget 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22' } }\n"); + check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), + "a merged-era retarget keeps the alignment, got <<" + modern + ">>"); + } + /** * A resolution rule's {@code useVersion} rewrites what was requested, * silently, on the way through -- so it holds the library as firmly as a From bd4b2b690d5c2320456d52dbc433806d014724b4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:56:16 +0300 Subject: [PATCH 44/94] Scan the whole generated script, and read a substitution from its replacement The dependencies block was the wrong boundary. A rule reaches the app's configurations from inside android { } just as well -- `project.configurations.all { resolutionStrategy.force '...:1.7.22' }` is accepted and executed there -- so android.gradle.androidx was app-controlled Gradle text that decided what resolves and was never read. The earlier reasoning, that the rest of the script lands where a dependency cannot be declared, was about DECLARATIONS and missed rules entirely. Enumerating the whole script rather than the block found two more of the same: android.xgradle_default_config, and the buildscript fragment that carries android.topDependency. All three are passed now, in script order, and the rule that decides needs no judgement: every getArg fragment interpolated into the script is app-supplied text, so every one of them is scanned. The judgement about which ones could matter is exactly what was wrong twice. The test moved with it -- it reads the whole gradleProps concatenation now instead of stopping at the dependencies block, which is the boundary that let this through. Verified by deleting android.gradle.androidx from the call. Also, a dependency substitution is read after all. It was declined last commit because the version scan takes the first literal and that is the side being REPLACED; the scan starts after `using` now, so it takes the replacement. In the ordinary spelling, `substitute module('g:a') using module('g:a:1.7.22')`, the replaced side carries no version at all, so the objection was narrower than it looked -- but the fix is the general one, and a substitution that RAISES the library is asserted to keep the alignment rather than suppress it. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 13 ++++- .../builders/KotlinStdlibAlignment.java | 46 ++++++++++++---- .../builders/KotlinStdlibAlignmentTest.java | 55 ++++++++++++++++++- 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 5493b8030db..d38c72ab9d6 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7319,7 +7319,15 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // xgradle after it. Listing them in any other order lost a // definition that the real script would have had in scope. // - // EVERY fragment of that block, including the ones this builder + // EVERY app-controlled fragment of the whole script, not of the + // dependencies block. The block was the wrong boundary: a rule + // like project.configurations.all { resolutionStrategy.force ... } + // is accepted and executed from inside android { }, so a fragment + // interpolated there can hold an override that decides what + // resolves. Which is to say the earlier reasoning -- "the rest + // lands where a dependency cannot be declared" -- was about + // DECLARATIONS and missed rules entirely. + // // writes itself. kotlinRuntimeDependency is the reason: it carries // requireKotlinStdlib, so an app asking for 1.7.22!! has a strict // pre-merge pin on the base library that nothing here could see, @@ -7330,6 +7338,9 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // KotlinStdlibAlignmentTest reads this call against the generated // block and fails if the two ever disagree. request.getArg("android.gradlePlugin", ""), + gradleDependency, + request.getArg("android.gradle.androidx", ""), + request.getArg("android.xgradle_default_config", ""), coreLibraryDesugaringDependency, request.getArg("android.supportv4Dep", ""), kotlinRuntimeDependency, diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 93ede6ab781..07901681b23 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -338,7 +338,11 @@ private static String declaredVersionOf(String line, String artifact) { return rich; } String coordinate = KOTLIN_GROUP + ":" + artifact + ":"; - for (int i = 0; i < line.length(); i++) { + // Past `using`, when there is one: a substitution names the replaced module + // first and the replacement second, and it is the replacement that decides + // what resolves. + int from = afterCall(line, "using"); + for (int i = from < 0 ? 0 : from; i < line.length(); i++) { char c = line.charAt(i); if (!isLiteralStart(line, i)) { continue; @@ -1078,19 +1082,20 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat */ private static boolean callsForce(String statement) { // The method forms, which callsNamed now distinguishes from an assignment. - // Gradle's ways of overriding a selected version: force and its setter, and a + // Gradle's ways of overriding a selected version: force and its setter, a // resolution rule's useVersion (a bare version) or useTarget (a whole - // coordinate). All of them win silently over a constraint. + // coordinate), and a dependency substitution. All of them win silently over + // a constraint. // - // dependencySubstitution's `substitute ... using ...` is deliberately NOT - // here. It names TWO coordinates and the version scan takes the first - // literal, which is the one being replaced -- so a substitution raising the - // library to a merged-era version would read as pinning it to the old one. - // Wrong in the harmless direction, but wrong, and it can be added when the - // scan can tell the sides apart. + // A substitution names TWO coordinates, which is why it was left out once: + // the version scan takes the first literal, and that is the side being + // REPLACED. The scan reads from after `using` now, so it takes the + // replacement -- which is also the only side that carries a version in the + // ordinary spelling, `substitute module('g:a') using module('g:a:1.7.22')`. if (callsNamed(statement, "force") || callsNamed(statement, "setForcedModules") || callsNamed(statement, USE_VERSION) - || callsNamed(statement, "useTarget")) { + || callsNamed(statement, "useTarget") + || callsNamed(statement, "substitute")) { return true; } // forcedModules is only ever written as an assignment, and assigning it any @@ -1149,6 +1154,27 @@ private static boolean callsStrictly(String statement) { * which only Gradle's forcedModules is written as. It is not offered to * every caller because `def strictly = false` is not a strict pin.

*/ + /** Where {@code call}'s arguments begin, or -1 if it is not called here. */ + private static int afterCall(String statement, String call) { + for (int i = 0; i < statement.length(); i++) { + if (isLiteralStart(statement, i)) { + i = endOfStringLiteral(statement, i); + continue; + } + if (!statement.startsWith(call, i)) { + continue; + } + boolean startsToken = i == 0 || !isIdentifierChar(statement.charAt(i - 1)); + int after = i + call.length(); + if (startsToken && after < statement.length() + && (isBlank(statement.charAt(after)) + || statement.charAt(after) == '(')) { + return after; + } + } + return -1; + } + private static boolean callsNamed(String statement, String call) { for (int i = 0; i < statement.length(); i++) { if (isLiteralStart(statement, i)) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index f04ed932525..f2857976398 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -479,9 +479,14 @@ public void everyFragmentOfTheGeneratedBlockIsScanned() throws Exception { String call = builderSrc.substring(at, builderSrc.indexOf(";", at)) .replaceAll("//[^\n]*", ""); - int blockAt = builderSrc.indexOf("\"dependencies {"); - check(blockAt >= 0, "the generated dependencies block is found"); - int blockEnd = builderSrc.indexOf("+ \"}\\n\"", blockAt); + // The WHOLE generated script, not just its dependencies block. The block was + // the wrong boundary: android.gradle.androidx is interpolated inside + // android { }, where a project.configurations.all { ... force } is accepted + // and executed, so a fragment there decides what resolves just as much as a + // declaration does. Bounding this test at the block is what let that through. + int blockAt = builderSrc.indexOf("String gradleProps = "); + check(blockAt >= 0, "the generated script is found"); + int blockEnd = builderSrc.indexOf("Gradle File start", blockAt); check(blockEnd > blockAt, "and its end"); String block = builderSrc.substring(blockAt, blockEnd).replaceAll("//[^\n]*", ""); @@ -496,11 +501,22 @@ public void everyFragmentOfTheGeneratedBlockIsScanned() throws Exception { while (hint.find()) { byPosition.put(Integer.valueOf(hint.start()), "getArg(\"" + hint.group(1) + "\""); } + // Only the getArg fragments are required across the whole script. Those are + // app-supplied Gradle TEXT by definition, so the rule needs no judgement + // about which of them could matter -- which is the judgement that was wrong + // twice. The script's other locals are builder-computed values (a version + // number, a namespace, a repository block); the ones inside the dependencies + // block are separately required below because they carry app text too. + int dependenciesAt = block.indexOf("\"dependencies {"); + check(dependenciesAt >= 0, "the dependencies block is inside the script"); java.util.regex.Matcher name = java.util.regex.Pattern .compile("\\+\\s*(?:addNewlineIfMissing\\()?([a-z][a-zA-Z0-9]*)\\b") .matcher(block); while (name.find()) { String token = name.group(1); + if (name.start(1) < dependenciesAt) { + continue; + } // The configuration itself is passed as the first argument, and the // block this test is about is the alignment's own output. if ("compile".equals(token) || "kotlinStdlibConstraints".equals(token) @@ -596,6 +612,39 @@ public void aMapKeyMayBeQuoted() { "the merged-era declaration is read, got <<" + modern + ">>"); } + /** + * A rule reaches the app's configurations from inside the android block + * too, so a fragment interpolated there decides what resolves just as much + * as a declaration does. This is the shape the alignment could not see when + * only the dependencies block was scanned. + */ + @Test + public void aRuleInsideTheAndroidBlockIsStillARule() { + String force = KotlinStdlibAlignment.constraintsBlock("implementation", + " project.configurations.all { resolutionStrategy.force " + + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' }\n"); + check("".equals(force), + "a project-qualified force suppresses, got <<" + force + ">>"); + + String substitution = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.dependencySubstitution { " + + "substitute module('org.jetbrains.kotlin:kotlin-stdlib') " + + "using module('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') } }\n"); + check("".equals(substitution), + "a substitution onto a pre-merge version suppresses, got <<" + + substitution + ">>"); + + // The replacement is what decides, not the module being replaced: this one + // raises the library and takes nothing away. + String raising = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.dependencySubstitution { " + + "substitute module('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') " + + "using module('org.jetbrains.kotlin:kotlin-stdlib:1.9.22') } }\n"); + check(raising.contains("kotlin-stdlib-jdk8:1.8.0"), + "a substitution raising the library keeps the alignment, got <<" + + raising + ">>"); + } + /** * {@code useTarget} replaces the whole coordinate rather than the version, * and overrides just as absolutely as {@code useVersion} does. From fd5aff3e8fe037e1ed44fa35393c8ba737bfe0f0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:56:16 +0300 Subject: [PATCH 45/94] Stop a timeout test from asserting the speed of a JVM launch ExecutorProcessTimeoutTest.aProcessThatSucceedsIsNotReportedAsTimedOutWhileOutputIsStillDraining failed 2 runs in 5 on an otherwise idle machine, and it is not the code under test that is wrong. The test launches a real JVM that spawns a child holding the output pipe, then asserts a process which exited 0 is not reported as timed out. It gave that launch a 1000ms budget. Measured here, a BARE java launch that runs an empty main and exits takes 570-993ms; this helper does more than that before exiting. So the process genuinely outlived the deadline, the watcher genuinely fired, and rc was genuinely 1 -- the assertion was about how fast a JVM starts, under the name of one about timeout accounting. The budget is 5000ms now against a pipe held 8000ms. 2500ms was tried first and still failed 1 run in 8 while other work shared the machine, which is the condition every CI runner is in; at 5000ms it passed 8 of 8 under that same load. The deadline still falls inside the join, which is what the regression needs, and that was verified by putting the bug back -- moving `running[0] = false` after `reader.join` makes the test fail again. The cost is that a passing run takes about as long as the pipe is held, since exec returns when the reader drains. Eight seconds of wall clock buys a test that measures what it says it measures. Found while chasing a red gate on an unrelated change; it is its own commit because it is its own bug. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/ExecutorProcessTimeoutTest.java | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorProcessTimeoutTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorProcessTimeoutTest.java index e3f9fd0a12e..46ceb5ee683 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorProcessTimeoutTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorProcessTimeoutTest.java @@ -121,10 +121,31 @@ void aProcessThatSucceedsIsNotReportedAsTimedOutWhileOutputIsStillDraining() thr // on the iOS and native steps, which are the ones that run with a timeout. // // Reproducing it needs the join to actually block, which means the pipe - // must outlive the process. Without the fix the watcher fires at 1000ms - // during that wait and exec returns 1. + // must outlive the process. Without the fix the watcher fires during that + // wait and exec returns 1. + // + // The numbers are sized off a measurement, not chosen. At the 1000ms this + // started with, the test was racing its own JVM: a BARE java launch that + // runs an empty main and exits takes 570-993ms on the machine this was + // written on, and this helper also spawns a child before exiting -- so the + // process legitimately outlived the deadline, the watcher legitimately + // fired, and the test failed 2 runs in 5 with nothing else running. That is + // an assertion about the speed of a JVM launch wearing the name of one + // about timeout accounting. + // + // 2500ms was tried and still failed 1 run in 8 while other work shared the + // machine, which is the condition every CI runner is in. 5000ms is ~5x the + // worst launch observed, and the pipe is held 8000ms so the deadline still + // falls comfortably INSIDE the join -- which is what the regression needs: + // with the bug the watcher fires at 5000ms while the join is waiting on a + // process that exited long before, and rc is 1 again. Verified by putting + // the bug back. + // + // The cost is that a passing run takes about as long as the pipe is held, + // since exec returns when the reader drains. Eight seconds of wall clock + // buys a test that measures what it says it measures. TestExecutor e = new TestExecutor(); - int rc = e.executeProcess(javaProcess(ExitsLeavingChildHoldingOutput.class, "4000"), 1000); + int rc = e.executeProcess(javaProcess(ExitsLeavingChildHoldingOutput.class, "8000"), 5000); assertEquals(0, rc, "a command that exited 0 must not be reported as timed out"); } From 3498e3eda36a3932e4b185880dad64b6985659ec Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:19:31 +0300 Subject: [PATCH 46/94] Pass every fragment that carries app text, and read a substitution's source Three findings, and the first two are the same shape as the last round: the scan's boundary was still drawn in the wrong place. android.repositories was missed. It reaches the script through a local rather than a getArg read straight into it, and the rule "every getArg is scanned" did not cover that -- so a project.configurations.all { force } written into the repositories closure decided what resolved and was never read. The rule now covers both routes: a hint read into the script, or a local ASSIGNED from one, found by how it is built rather than by knowing its name. The scalars ride along instead of being filtered out, because deciding which hints are text and which are values is exactly the judgement that has been wrong three times now; one of them is a float in the daemon and is handed over as text like the rest. A substitution overrides only what it substitutes AWAY from. With the artifact as the target -- substitute module('com.example:source') using module('...:kotlin-stdlib:1.7.22') -- the replacement still goes through ordinary conflict resolution, so an existing requirement raises it and nothing is pinned. Reading that as absolute suppressed the block for a graph that had not been pinned at all. And a value that is only assigned is not a declaration. def legacy = '...!!' names the artifact and carries a strict marker, and suppressed everything on that basis, for a value never added to any configuration. The narrow fix is the distinction between a value and a call -- not, as suggested, requiring a configuration, which would have undone something deliberate: a strict pin on a variant configuration shares a classpath with the one being constrained and is meant to suppress. The enumeration test grew two fixes of its own while proving this: it now strips comments BEFORE finding the end of the call (a semicolon inside the call's own comment truncated the slice and it reported arguments missing that were plainly there), and it matches fragments by name rather than by every occurrence (injectRepo is interpolated twice, and requiring a later position for each asked the call to repeat an argument it passes once). Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 47 +++------ .../builders/KotlinStdlibAlignment.java | 51 ++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 97 +++++++++++++++++-- 3 files changed, 146 insertions(+), 49 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index d38c72ab9d6..3a10016cf73 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7303,43 +7303,24 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { try { kotlinStdlibConstraints = KotlinStdlibAlignment.constraintsBlock( compile, - // Every app-controlled fragment that reaches the generated - // dependencies block. Read off ShieldInjector's GRADLE_TEXT_HINTS, - // which is this tree's enumeration of hints interpolated into a - // Gradle file, rather than off the ones that came to mind -- - // android.supportv4Dep was missed exactly that way, and so was - // android.gradlePlugin: it is interpolated at top level right after - // `apply plugin`, where a dependencies { } block of its own is - // valid and reaches the same configurations. The rest of that list - // lands in buildscript, repositories or the android block, where a - // dependency cannot be declared. // In the order the generated script emits them, because a - // definition is only in scope for what comes after it: gradlePlugin - // at the top, then the dependencies block in its own order, then - // xgradle after it. Listing them in any other order lost a - // definition that the real script would have had in scope. - // - // EVERY app-controlled fragment of the whole script, not of the - // dependencies block. The block was the wrong boundary: a rule - // like project.configurations.all { resolutionStrategy.force ... } - // is accepted and executed from inside android { }, so a fragment - // interpolated there can hold an override that decides what - // resolves. Which is to say the earlier reasoning -- "the rest - // lands where a dependency cannot be declared" -- was about - // DECLARATIONS and missed rules entirely. - // - // writes itself. kotlinRuntimeDependency is the reason: it carries - // requireKotlinStdlib, so an app asking for 1.7.22!! has a strict - // pre-merge pin on the base library that nothing here could see, - // and the constraint went in beside it. The other two cannot - // currently name a Kotlin artifact, and are passed anyway rather - // than judged -- the judging belongs in the helper, and a list of - // "fragments worth reading" is exactly what was wrong before. - // KotlinStdlibAlignmentTest reads this call against the generated - // block and fails if the two ever disagree. + // definition is only in scope for what comes after it, and + // EVERY fragment carrying app-supplied text -- read straight + // from a hint, or held by a local that was assigned from one. + // Bounding this at the dependencies block was wrong twice: a + // rule reaches the app's configurations from the android block + // and from the repositories closure just as well. The scalars + // ride along rather than being filtered out, because deciding + // which hints are text and which are values is the judgement + // that keeps being wrong, and this way there is none to make. + // KotlinStdlibAlignmentTest reads this call against the script + // and fails if they ever disagree. request.getArg("android.gradlePlugin", ""), + injectRepo, gradleDependency, request.getArg("android.gradle.androidx", ""), + minSDK, + targetNumber, request.getArg("android.xgradle_default_config", ""), coreLibraryDesugaringDependency, request.getArg("android.supportv4Dep", ""), diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 07901681b23..6a2be4d483e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -523,7 +523,7 @@ private static String strictVersionOfBaseStdlib(String line) { if (callsStrictly(line)) { return strictVersionIn(line); } - if (callsForce(line)) { + if (callsForce(line, BASE_STDLIB)) { return declaredVersionOf(line, BASE_STDLIB); } String declared = declaredVersionOf(line, BASE_STDLIB); @@ -550,7 +550,7 @@ private static boolean holdsBaseStdlibStrictly(String line) { * requirement that had resolved fine before it.

*/ private static boolean holdsStrictly(String line, String artifact) { - if (callsStrictly(line) || callsForce(line)) { + if (callsStrictly(line) || callsForce(line, artifact)) { return true; } String declared = declaredVersionOf(line, artifact); @@ -604,7 +604,8 @@ private static boolean namesCoordinate(String line, String artifact) { if ((literal.equals(coordinate) || literal.startsWith(coordinate + ":")) && !hasWhitespace(literal) - && !isReasonArgument(line, i)) { + && !isReasonArgument(line, i) + && !isAssignedValue(line, i)) { return true; } i = end; @@ -612,6 +613,34 @@ private static boolean namesCoordinate(String line, String artifact) { return false; } + /** + * Whether the literal opening at {@code quoteAt} is being assigned to + * something rather than handed to a dependency. + * + *

{@code def legacy = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'} + * names the artifact and carries a strict marker, and on that basis alone + * it suppressed the whole block -- for a value that is never added to any + * configuration and decides nothing. A definition becomes a declaration + * when it is USED, and by then the name has been inlined and the usage is + * what this reads.

+ * + *

Narrower than requiring a configuration, which was the other way to + * fix this and would have undone something deliberate: a strict pin on a + * variant configuration still shares a classpath with the one being + * constrained, so it suppresses on purpose. The distinction here is + * between a value and a call, not between one configuration and + * another.

+ */ + private static boolean isAssignedValue(String line, int quoteAt) { + int i = quoteAt - 1; + while (i >= 0 && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { + i--; + } + return i >= 0 && line.charAt(i) == '=' + && (i == 0 || line.charAt(i - 1) != '=') + && (i + 1 >= line.length() || line.charAt(i + 1) != '='); + } + /** * Whether the literal opening at {@code quoteAt} is the argument of a * reason rather than a dependency. @@ -1080,7 +1109,7 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat * jar at all. Nothing fails in the build; it throws on the device. So a * forced version is read exactly like a strict one.

*/ - private static boolean callsForce(String statement) { + private static boolean callsForce(String statement, String artifact) { // The method forms, which callsNamed now distinguishes from an assignment. // Gradle's ways of overriding a selected version: force and its setter, a // resolution rule's useVersion (a bare version) or useTarget (a whole @@ -1094,10 +1123,20 @@ private static boolean callsForce(String statement) { // ordinary spelling, `substitute module('g:a') using module('g:a:1.7.22')`. if (callsNamed(statement, "force") || callsNamed(statement, "setForcedModules") || callsNamed(statement, USE_VERSION) - || callsNamed(statement, "useTarget") - || callsNamed(statement, "substitute")) { + || callsNamed(statement, "useTarget")) { return true; } + if (callsNamed(statement, "substitute")) { + // A substitution overrides only what it substitutes AWAY from. With the + // artifact as the TARGET -- substitute module('com.example:source') + // using module('...:kotlin-stdlib:1.7.22') -- the replacement is still + // subject to ordinary conflict resolution, so an existing 1.8.22 + // requirement raises it and nothing is pinned; reading that as absolute + // suppressed the block for a graph that had not been pinned at all. + int using = afterCall(statement, "using"); + String replaced = using < 0 ? statement : statement.substring(0, using); + return namesArtifactAnywhere(replaced, artifact); + } // forcedModules is only ever written as an assignment, and assigning it any // module list is a force. `force` as a property is the one that has to be // read: `{ force = false }` explicitly turns forcing OFF, and accepting any diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index f2857976398..d0b90f58c52 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -476,8 +476,11 @@ public void everyFragmentOfTheGeneratedBlockIsScanned() throws Exception { String builderSrc = new String(bytes, "UTF-8"); int at = builderSrc.indexOf("KotlinStdlibAlignment.constraintsBlock("); check(at >= 0, "the builder calls the alignment"); - String call = builderSrc.substring(at, builderSrc.indexOf(";", at)) - .replaceAll("//[^\n]*", ""); + // Comments go first, THEN the terminator is found: a semicolon inside the + // call's own explanatory comment truncated this slice and the test then + // reported arguments missing that were plainly there. + String fromCall = builderSrc.substring(at).replaceAll("//[^\n]*", ""); + String call = fromCall.substring(0, fromCall.indexOf(";")); // The WHOLE generated script, not just its dependencies block. The block was // the wrong boundary: android.gradle.androidx is interpolated inside @@ -501,12 +504,30 @@ public void everyFragmentOfTheGeneratedBlockIsScanned() throws Exception { while (hint.find()) { byPosition.put(Integer.valueOf(hint.start()), "getArg(\"" + hint.group(1) + "\""); } - // Only the getArg fragments are required across the whole script. Those are - // app-supplied Gradle TEXT by definition, so the rule needs no judgement - // about which of them could matter -- which is the judgement that was wrong - // twice. The script's other locals are builder-computed values (a version - // number, a namespace, a repository block); the ones inside the dependencies - // block are separately required below because they carry app text too. + // Every fragment that carries app-supplied text, by either route: a getArg + // read straight into the script, or a local that was ASSIGNED from one. + // Requiring only the direct reads missed injectRepo, which holds + // android.repositories and is interpolated into the repositories closure -- + // where a project.configurations.all { force } runs perfectly well. The + // locals are found by looking at how they are built, not by knowing their + // names, because knowing their names is what keeps being wrong. + java.util.Set carriesAppText = new java.util.HashSet(); + java.util.regex.Matcher assigned = java.util.regex.Pattern + .compile("\\b([a-z][a-zA-Z0-9]*)\\s*(?:=|\\+=)[^;\n]*getArg\\(") + .matcher(builderSrc); + while (assigned.find()) { + carriesAppText.add(assigned.group(1)); + } + check(carriesAppText.contains("injectRepo"), + "the scan for hint-carrying locals works: " + carriesAppText); + java.util.regex.Matcher carrier = java.util.regex.Pattern + .compile("\\+\\s*(?:addNewlineIfMissing\\()?([a-z][a-zA-Z0-9]*)\\b") + .matcher(block); + while (carrier.find()) { + if (carriesAppText.contains(carrier.group(1))) { + byPosition.put(Integer.valueOf(carrier.start(1)), carrier.group(1)); + } + } int dependenciesAt = block.indexOf("\"dependencies {"); check(dependenciesAt >= 0, "the dependencies block is inside the script"); java.util.regex.Matcher name = java.util.regex.Pattern @@ -532,8 +553,16 @@ public void everyFragmentOfTheGeneratedBlockIsScanned() throws Exception { } byPosition.put(Integer.valueOf(name.start(1)), token); } - java.util.List fragments = - new java.util.ArrayList(byPosition.values()); + // By name, keeping where it FIRST appears: a fragment may be interpolated + // more than once -- injectRepo goes into the buildscript repositories and + // the project ones -- and requiring a strictly later position for each + // occurrence asked the call to repeat an argument it passes once. + java.util.List fragments = new java.util.ArrayList(); + for (String fragment : byPosition.values()) { + if (!fragments.contains(fragment)) { + fragments.add(fragment); + } + } check(fragments.size() >= 6, "the block really was parsed, found " + fragments); @@ -612,6 +641,54 @@ public void aMapKeyMayBeQuoted() { "the merged-era declaration is read, got <<" + modern + ">>"); } + /** + * A value that is only assigned is not a declaration. A definition naming + * the artifact and carrying a strict marker suppressed the whole block on + * that basis alone, for a value never added to any configuration -- and a + * definition becomes a declaration when it is USED, by which point the + * name has been inlined and the usage is what gets read. + */ + @Test + public void anAssignedValueIsNotADeclaration() { + String unused = KotlinStdlibAlignment.constraintsBlock("implementation", + " def legacy = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n" + + " implementation 'androidx.appcompat:appcompat:1.6.1'\n"); + check(unused.contains("kotlin-stdlib-jdk7:1.8.0") + && unused.contains("kotlin-stdlib-jdk8:1.8.0"), + "an unused definition decides nothing, got <<" + unused + ">>"); + + // Used, it decides everything. + String used = KotlinStdlibAlignment.constraintsBlock("implementation", + " def legacy = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n" + + " implementation legacy\n"); + check("".equals(used), "the same value, used, suppresses; got <<" + used + ">>"); + } + + /** + * A substitution overrides only what it substitutes AWAY from. With the + * artifact as the target the replacement still goes through ordinary + * conflict resolution, so an existing requirement raises it and nothing is + * pinned -- reading that as absolute suppressed the block for a graph that + * had not been pinned at all. + */ + @Test + public void aSubstitutionOverridesOnlyItsSource() { + String source = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.dependencySubstitution { " + + "substitute module('org.jetbrains.kotlin:kotlin-stdlib') " + + "using module('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') } }\n"); + check("".equals(source), + "substituting the stdlib itself is an override, got <<" + source + ">>"); + + String target = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.dependencySubstitution { " + + "substitute module('com.example:source') " + + "using module('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') } }\n"); + check(target.contains("kotlin-stdlib-jdk7:1.8.0") + && target.contains("kotlin-stdlib-jdk8:1.8.0"), + "the stdlib merely as a target is not, got <<" + target + ">>"); + } + /** * A rule reaches the app's configurations from inside the android block * too, so a fragment interpolated there decides what resolves just as much From 72ea31e93b24a19e47d9648a08a14f6fcb91b8f1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:31:32 +0300 Subject: [PATCH 47/94] Give a scope its names back, and let skipBlanks know what whitespace is skipBlanks accepted a space and a tab while the call detector had already learned about line endings, so the two disagreed about the same question. A CRLF fragment that split a map entry after its colon -- implementation(group: 'org.jetbrains.kotlin', ... -- found no value at all, and the strict pin in that declaration went unread. Both now ask isBlank. A statement can legitimately contain a newline; where statements end was decided before anything reaches here. And a name a nested scope introduced now leaves with it. A `def` inside a closure or a method is local to it, and a single flat map kept that value afterwards -- so an unrelated later `implementation(dep)` read as a declaration of whatever the nested one held, and that artifact's constraint was skipped as already satisfied while its sibling was raised around it, which is the duplicate this class exists to prevent. Only DECLARATIONS are taken back. A bare assignment inside a block updates the binding it found and still reaches the statements after it, which is what makes `if (legacy) { dep = '...' }` work -- the case the conditional rule already depended on. Both halves are asserted, because the distinction is the whole fix and a later "just clear the scope on exit" would quietly undo it. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 61 ++++++++++++++++++- .../builders/KotlinStdlibAlignmentTest.java | 53 ++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 6a2be4d483e..29375c34605 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -1503,7 +1503,15 @@ private static int endOfStringLiteral(String text, int quoteAt) { private static int skipBlanks(String line, int from) { int i = from; - while (i < line.length() && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { + // isBlank, not a second opinion about what whitespace is. Spelled out as + // space-or-tab here while the call detector had already learned about line + // endings, so a CRLF fragment that split a map entry after its colon -- + // implementation(group: + // 'org.jetbrains.kotlin', ... + // -- found no value at all, and the strict pin in that declaration went + // unread. A statement can legitimately contain a newline; the splitter has + // already decided where statements end before anything gets here. + while (i < line.length() && isBlank(line.charAt(i))) { i++; } return i; @@ -1919,6 +1927,7 @@ private static List inlineLiteralDefinitions(List statements) { // still recorded at any depth, which is why a `def` inside dependencies { } // keeps working. int braceDepth = 0; + ScopedNames scope = new ScopedNames(); for (int i = 0; i < statements.size(); i++) { String statement = statements.get(i); out.add(literals.isEmpty() @@ -1926,7 +1935,7 @@ private static List inlineLiteralDefinitions(List statements) { : withLiteralsInlined(statement, literals)); boolean opensExt = extDepth == 0 && opensAnExtraPropertiesBlock(statement); updateLiteralDefinitions(statement, literals, extDepth > 0 || opensExt, - braceDepth > 0); + braceDepth > 0, braceDepth, scope); if (extDepth > 0 || opensExt) { extDepth += braceBalance(statement); if (extDepth < 0) { @@ -1937,6 +1946,7 @@ private static List inlineLiteralDefinitions(List statements) { if (braceDepth < 0) { braceDepth = 0; } + scope.leaving(braceDepth, literals); } return out; } @@ -1987,9 +1997,51 @@ private static boolean opensAnExtraPropertiesBlock(String statement) { return false; } + /** + * The names a scope introduced, so they can be taken back when it closes. + * + *

A `def` inside a closure or a method is local to it, and a single flat + * map kept that value after the scope ended -- so an unrelated later + * `implementation(dep)` was read as a declaration of whatever the nested + * one held, and the constraint for that artifact was skipped as already + * satisfied. Only DECLARATIONS are taken back: a bare assignment inside a + * block updates the binding it found, which is why + * `if (legacy) { dep = '...' }` still reaches the statements after it.

+ */ + private static final class ScopedNames { + private final List introduced = new ArrayList(); + + void declared(int depth, String name, Map literals) { + if (depth <= 0) { + return; + } + introduced.add(new Object[] { + Integer.valueOf(depth), name, + literals.containsKey(name) ? Boolean.TRUE : Boolean.FALSE, + literals.get(name) + }); + } + + void leaving(int depth, Map literals) { + for (int i = introduced.size() - 1; i >= 0; i--) { + Object[] entry = introduced.get(i); + if (((Integer) entry[0]).intValue() <= depth) { + break; + } + introduced.remove(i); + String name = (String) entry[1]; + if (Boolean.TRUE.equals(entry[2])) { + literals.put(name, (String) entry[3]); + } else { + literals.remove(name); + } + } + } + } + private static void updateLiteralDefinitions(String statement, Map literals, boolean insideExtraProperties, - boolean conditional) { + boolean conditional, int depth, ScopedNames scope) { if (insideExtraProperties) { // The assignment may share the line with the brace that opened the block, // as `ext { kotlinVersion = '1.9.22' }` does, so read from after it. @@ -2065,6 +2117,9 @@ && isIdentifierChar(statement.charAt(scan + 1))))) { if (!declared && !literals.containsKey(name)) { return; } + if (declared) { + scope.declared(depth, name, literals); + } i = skipBlanks(statement, i); if (i >= statement.length() || statement.charAt(i) != '=' || (i + 1 < statement.length() && statement.charAt(i + 1) == '=')) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index d0b90f58c52..e8a657f380d 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -641,6 +641,59 @@ public void aMapKeyMayBeQuoted() { "the merged-era declaration is read, got <<" + modern + ">>"); } + /** + * Line endings do not change what a map entry says, here either. The call + * detector had already learned that and this shared skip had not, so a + * CRLF fragment that split an entry after its colon found no value at all. + */ + @Test + public void aMapEntryMayBeSplitByAnyLineEnding() { + String[] endings = {"\r\n", "\n", "\r"}; + for (int i = 0; i < endings.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation(group:" + endings[i] + + " 'org.jetbrains.kotlin', name:" + endings[i] + + " 'kotlin-stdlib-jdk8', version:" + endings[i] + + " '1.7.22')" + endings[i]); + check("".equals(out), + "the map entry survives the line ending, got <<" + out + ">>"); + } + } + + /** + * A name a nested scope introduced goes away with it. A `def` inside a + * closure or a method is local to it, and keeping that value afterwards + * made an unrelated later use look like a declaration of whatever the + * nested one held -- so that artifact's constraint was skipped as already + * satisfied while its sibling was raised around it. + */ + @Test + public void aNameIntroducedInsideAScopeLeavesWithIt() { + String nested = KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = 'com.example:other:1.0'\n" + + " def helper() {\n" + + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n" + + " }\n" + + " implementation(dep)\n"); + check(nested.contains("kotlin-stdlib-jdk8:1.8.0"), + "the nested local does not reach the statement after it, got <<" + + nested + ">>"); + + // An ASSIGNMENT inside a block is a different thing: it updates the binding + // it found, so it does reach what follows. This is the distinction the fix + // rests on, and it is asserted so a later "just clear the scope" cannot + // quietly take it away. + String assigned = KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = 'com.example:other:1.0'\n" + + " if (legacy) {\n" + + " dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n" + + " }\n" + + " implementation(dep)\n"); + check(!assigned.contains("kotlin-stdlib-jdk8:1.8.0"), + "an assignment inside a block still reaches what follows, got <<" + + assigned + ">>"); + } + /** * A value that is only assigned is not a declaration. A definition naming * the artifact and carrying a strict marker suppressed the whole block on From 0026dff79dd5c0ac73a007abdb2b5f3d0743a747 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:45:21 +0300 Subject: [PATCH 48/94] Read a declaration past its annotations, and to the end of its declarator list A script field is written `@groovy.transform.Field String dep = '...'`, and the walk that reads modifiers and a type stopped dead on the `@` -- so the field was never recorded and the strict pin it carried was invisible to the statement that used it. Annotations are consumed first now, qualified name and arguments included, since an annotation may carry either. And a declaration may introduce several names at once: `def marker = 'x', dep = '...:kotlin-stdlib:1.7.22'` declares two, and only the first was recorded. The one that matters is not always the first, so every declarator is read now. A name that was never declared is still unknown, which is asserted alongside -- reading a list is not licence to invent bindings. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 83 +++++++++++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 55 ++++++++++++ 2 files changed, 130 insertions(+), 8 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 29375c34605..9b0c733a9fc 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -2059,6 +2059,41 @@ private static void updateLiteralDefinitions(String statement, i = skipBlanks(statement, at + DEF.length()); } else { i = skipBlanks(statement, 0); + // Past any annotations first. A script field is written + // `@groovy.transform.Field String dep = '...'`, and the walk below reads + // identifier tokens -- so it stopped dead on the `@`, recorded nothing, + // and the strict pin the field carried was never seen. + while (i < statement.length() && statement.charAt(i) == '@') { + i++; + while (i < statement.length() + && (isIdentifierChar(statement.charAt(i)) + || (statement.charAt(i) == '.' + && i + 1 < statement.length() + && isIdentifierChar(statement.charAt(i + 1))))) { + i++; + } + i = skipBlanks(statement, i); + if (i < statement.length() && statement.charAt(i) == '(') { + // An annotation may carry arguments, and they may nest. + int open = 0; + while (i < statement.length()) { + char c = statement.charAt(i); + if (isLiteralStart(statement, i)) { + i = endOfStringLiteral(statement, i); + } else if (c == '(') { + open++; + } else if (c == ')') { + open--; + if (open == 0) { + i++; + break; + } + } + i++; + } + i = skipBlanks(statement, i); + } + } // A typed local declares just as much as def does, and it is written // with however many modifiers the author felt like: `String dep = ...`, // `final String dep = ...`, `private static final String dep = ...`. @@ -2134,17 +2169,49 @@ && isIdentifierChar(statement.charAt(scan + 1))))) { } return; } - i = skipBlanks(statement, i + 1); - if (i < statement.length() - && isLiteralStart(statement, i)) { - int end = endOfStringLiteral(statement, i); - if (end < statement.length()) { - recordDefinition(literals, name, - expandedLiteral(statement, i, end, literals), conditional); + // Every declarator, not just the first: `def marker = 'x', dep = 'coord'` + // declares two names, and stopping after one left the second unknown -- so + // the strict pin the second carried was invisible to the statement using it. + while (true) { + i = skipBlanks(statement, i + 1); + int end = -1; + String value = null; + if (i < statement.length() && isLiteralStart(statement, i)) { + int closes = endOfStringLiteral(statement, i); + if (closes < statement.length()) { + end = closes; + value = expandedLiteral(statement, i, closes, literals); + } + } + recordDefinition(literals, name, value, conditional); + if (end < 0) { + return; + } + int comma = skipBlanks(statement, end + 1); + if (comma >= statement.length() || statement.charAt(comma) != ',') { + return; + } + int nextName = skipBlanks(statement, comma + 1); + int nextEnd = nextName; + while (nextEnd < statement.length() + && isIdentifierChar(statement.charAt(nextEnd))) { + nextEnd++; + } + if (nextEnd == nextName) { + return; + } + name = statement.substring(nextName, nextEnd); + int assign = skipBlanks(statement, nextEnd); + if (assign >= statement.length() || statement.charAt(assign) != '=' + || (assign + 1 < statement.length() + && statement.charAt(assign + 1) == '=')) { return; } + if (declared) { + scope.declared(depth, name, literals); + } + i = assign; } - recordDefinition(literals, name, null, conditional); } /** diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index e8a657f380d..f0bc8bc9e99 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -641,6 +641,61 @@ public void aMapKeyMayBeQuoted() { "the merged-era declaration is read, got <<" + modern + ">>"); } + /** + * A declaration may be annotated. A script field is written + * {@code @groovy.transform.Field String dep = '...'}, and the walk that + * reads modifiers and a type stopped dead on the {@code @}. + */ + @Test + public void aDeclarationMayBeAnnotated() { + String[] annotations = { + "@groovy.transform.Field", + "@Field", + "@SuppressWarnings('unused')", + "@groovy.transform.Field @SuppressWarnings('unused')", + }; + for (int i = 0; i < annotations.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " " + annotations[i] + + " String dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" + + " implementation(dep) { version { strictly '1.7.22' } }\n"); + check("".equals(out), + "the field annotated " + annotations[i] + " is recorded, got <<" + + out + ">>"); + } + } + + /** + * A declaration may introduce several names at once, and the one that + * matters is not always the first. + */ + @Test + public void everyDeclaratorIsRecorded() { + String second = KotlinStdlibAlignment.constraintsBlock("implementation", + " def marker = 'x', dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" + + " implementation(dep) { version { strictly '1.7.22' } }\n"); + check("".equals(second), "the second declarator is recorded, got <<" + second + ">>"); + + String third = KotlinStdlibAlignment.constraintsBlock("implementation", + " def a = 'x', b = 'y', " + + "dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" + + " implementation(dep) { version { strictly '1.7.22' } }\n"); + check("".equals(third), "and the third, got <<" + third + ">>"); + + // The first still is, and a declarator list does not invent bindings: a name + // that was never declared stays unknown. + String first = KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22', marker = 'x'\n" + + " implementation(dep) { version { strictly '1.7.22' } }\n"); + check("".equals(first), "the first is unaffected, got <<" + first + ">>"); + + String unknown = KotlinStdlibAlignment.constraintsBlock("implementation", + " def marker = 'x', other = 'y'\n" + + " implementation(dep)\n"); + check(unknown.contains("kotlin-stdlib-jdk8:1.8.0"), + "an undeclared name is still unknown, got <<" + unknown + ">>"); + } + /** * Line endings do not change what a map entry says, here either. The call * detector had already learned that and this shared skip had not, so a From 507954a6c07d2e9c96e5f72046848cd178d85ba1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:14:14 +0300 Subject: [PATCH 49/94] Share the backward skip too, and let += assign The backward scans had the same disagreement the forward one did, three times over: they stopped at a space or a tab while isBlank already knew about line endings. A fragment with Windows line endings put a carriage return where they were looking, and the token behind it stopped being found -- a `because` on the line above its argument then read as a declaration rather than as prose, which suppresses the block on the strength of a comment. They now share one skipBlanksBackward, the mirror of skipBlanks, so there is one answer to what whitespace is rather than five. That is the last place in this file where the question was asked more than once. And += assigns: forcedModules += ['...'] applies the force exactly as an ordinary assignment does, and requiring the bare = missed it. A comparison is still not an assignment, which is asserted beside it. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 47 +++++++++-------- .../builders/KotlinStdlibAlignmentTest.java | 50 +++++++++++++++++++ 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 9b0c733a9fc..f981bbd3add 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -632,10 +632,7 @@ private static boolean namesCoordinate(String line, String artifact) { * another.

*/ private static boolean isAssignedValue(String line, int quoteAt) { - int i = quoteAt - 1; - while (i >= 0 && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { - i--; - } + int i = skipBlanksBackward(line, quoteAt - 1); return i >= 0 && line.charAt(i) == '=' && (i == 0 || line.charAt(i - 1) != '=') && (i + 1 >= line.length() || line.charAt(i + 1) != '='); @@ -655,8 +652,7 @@ private static boolean isAssignedValue(String line, int quoteAt) { */ private static boolean isReasonArgument(String line, int quoteAt) { int i = quoteAt - 1; - while (i >= 0 && (line.charAt(i) == ' ' || line.charAt(i) == '\t' - || line.charAt(i) == '(')) { + while (i >= 0 && (isBlank(line.charAt(i)) || line.charAt(i) == '(')) { i--; } int end = i + 1; @@ -1168,6 +1164,12 @@ && isIdentifierChar(statement.charAt(after)))) { continue; } int at = skipBlanks(statement, after); + // += assigns too. forcedModules += ['...'] applies the force just as + // forcedModules = ['...'] does, and requiring the bare = missed it. + if (at + 1 < statement.length() && statement.charAt(at) == '+' + && statement.charAt(at + 1) == '=') { + at++; + } if (at >= statement.length() || statement.charAt(at) != '=' || (at + 1 < statement.length() && statement.charAt(at + 1) == '=')) { continue; @@ -1363,11 +1365,7 @@ private static boolean opensASlashyLiteral(String text, int at) { && (text.charAt(at + 1) == '/' || text.charAt(at + 1) == '*')) { return false; } - int i = at - 1; - while (i >= 0 && (text.charAt(i) == ' ' || text.charAt(i) == '\t' - || text.charAt(i) == '\r' || text.charAt(i) == '\n')) { - i--; - } + int i = skipBlanksBackward(text, at - 1); if (i < 0) { return true; } @@ -1501,6 +1499,21 @@ private static int endOfStringLiteral(String text, int quoteAt) { return text.length(); } + /** + * The nearest index at or before {@code from} that is not whitespace, or + * -1. The backward half of skipBlanks, and shared for the same reason: it + * had been written out four times, three of them stopping at a space or a + * tab, so a fragment with Windows line endings put a carriage return where + * one of them was looking and the token behind it stopped being found. + */ + private static int skipBlanksBackward(String text, int from) { + int i = from; + while (i >= 0 && isBlank(text.charAt(i))) { + i--; + } + return i; + } + private static int skipBlanks(String line, int from) { int i = from; // isBlank, not a second opinion about what whitespace is. Spelled out as @@ -1627,15 +1640,9 @@ && isAddCallArgument(line, i)) { * rejected a declaration that was carrying an explicit strict pin.

*/ private static boolean isAddCallArgument(String line, int quoteAt) { - int i = quoteAt - 1; - while (i >= 0 && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { - i--; - } + int i = skipBlanksBackward(line, quoteAt - 1); if (i >= 0 && line.charAt(i) == '(') { - i--; - while (i >= 0 && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { - i--; - } + i = skipBlanksBackward(line, i - 1); } return i >= 2 && "add".equals(line.substring(i - 2, i + 1)) && (i - 3 < 0 || !isIdentifierChar(line.charAt(i - 3))); @@ -2372,7 +2379,7 @@ private static void recordBareAssignment(String body, Map litera private static boolean endsWithComma(StringBuilder text) { for (int i = text.length() - 1; i >= 0; i--) { char c = text.charAt(i); - if (c == ' ' || c == '\t' || c == '\r') { + if (isBlank(c)) { continue; } return c == ','; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index f0bc8bc9e99..560fc8f7e12 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -641,6 +641,56 @@ public void aMapKeyMayBeQuoted() { "the merged-era declaration is read, got <<" + modern + ">>"); } + /** + * The backward scans know what whitespace is too. Three of them stopped at + * a space or a tab, so a fragment with Windows line endings put a carriage + * return where they were looking and the token behind it stopped being + * found -- a `because` on the line above its argument, for one, which then + * read as a declaration rather than as prose. + */ + @Test + public void aTokenIsStillFoundAcrossAnyLineEnding() { + String[] endings = {"\r\n", "\n", "\r"}; + for (int i = 0; i < endings.length; i++) { + String reason = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('com.example:other:1.0') { because" + endings[i] + + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22' }\n"); + check(reason.contains("kotlin-stdlib-jdk8:1.8.0"), + "the reason is still prose across " + endings[i].length() + + " line-ending chars, got <<" + reason + ">>"); + + String added = KotlinStdlibAlignment.constraintsBlock("implementation", + " dependencies.add(" + endings[i] + + " 'implementation'," + endings[i] + + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22')\n"); + check("".equals(added), + "and an add() call is still an add() call, got <<" + added + ">>"); + } + } + + /** + * {@code +=} assigns too: forcedModules += ['...'] applies the force just + * as an ordinary assignment does. + */ + @Test + public void anAdditiveAssignmentStillAssigns() { + String[] operators = {"=", "+="}; + for (int i = 0; i < operators.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.forcedModules " + + operators[i] + " ['org.jetbrains.kotlin:kotlin-stdlib:1.7.22'] }\n"); + check("".equals(out), + "forcedModules " + operators[i] + " is a force, got <<" + out + ">>"); + } + + // A comparison is not an assignment, and neither reads as a force. + String compared = KotlinStdlibAlignment.constraintsBlock("implementation", + " if (resolutionStrategy.forcedModules == " + + "['org.jetbrains.kotlin:kotlin-stdlib:1.7.22']) { }\n"); + check(compared.contains("kotlin-stdlib-jdk8:1.8.0"), + "a comparison is not a force, got <<" + compared + ">>"); + } + /** * A declaration may be annotated. A script field is written * {@code @groovy.transform.Field String dep = '...'}, and the walk that From c38efbde47f0066d612bbc89959f9107625a268d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:27:42 +0300 Subject: [PATCH 50/94] Tell a named argument from a declaration, and count a closure opened on its own line Gradle's parenthesis-free map form puts two bare tokens in a row, which is exactly what a typed declaration looks like to a token counter. Read as one, `implementation group: group, name: '...'` declared a variable called group with no initialiser and CLEARED the real binding of that name -- so the strict declaration that used it later matched nothing. A token followed by a colon is a named argument, not a declarator. A map entry's value is not handed to a dependency either. A catalog of strings carrying a strict-looking coordinate suppressed the block for something never added to any configuration. Only the colon reads that way. A bracket and a comma both looked like they belonged in the same set and neither does: forcedModules = ['...'] and dependencies.add('implementation', '...') each put a coordinate that really does decide something directly after one, and excluding them stopped a genuine force from being seen -- which the tests caught immediately. So a bare list assigned to a variable nothing uses still suppresses; that is the narrower reading and it is the one held here. And a closure opened and a local declared on the same line is still a closure. Depth is tracked between statements, so `ext.helper = { def dep = '...' }` recorded dep as though it belonged to the script, and it then outlived the closure and shadowed the real binding for everything after it. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 59 +++++++++++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 57 ++++++++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index f981bbd3add..8ac50fc8291 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -633,11 +633,48 @@ private static boolean namesCoordinate(String line, String artifact) { */ private static boolean isAssignedValue(String line, int quoteAt) { int i = skipBlanksBackward(line, quoteAt - 1); - return i >= 0 && line.charAt(i) == '=' + if (i < 0) { + return false; + } + char before = line.charAt(i); + // A map ENTRY's value is not handed to a dependency either: + // def catalog = [legacy: 'org.jetbrains.kotlin:...:1.7.22!!'] + // is a map of strings, and reading its entry as a strict declaration + // suppressed the block for something never added to a configuration. The + // dependency map form -- group:, name:, version: -- is read by + // declaresMapEntry, which does not come through here. + // + // Only the colon. A bracket and a comma both looked like they belonged in + // this set and neither does: forcedModules = ['org.jetbrains.kotlin:...'] + // and dependencies.add('implementation', '...') each put a coordinate that + // really does decide something directly after one, and excluding them + // stopped a genuine force from being seen. So a bare list of coordinates + // assigned to a variable nothing uses still suppresses -- the narrower + // reading, and the one the tests hold. + if (before == ':') { + return true; + } + return before == '=' && (i == 0 || line.charAt(i - 1) != '=') && (i + 1 >= line.length() || line.charAt(i + 1) != '='); } + /** + * Whether the token ending at {@code end} is a named argument's key. + * + *

Gradle's parenthesis-free map form puts two bare tokens in a row -- + * {@code implementation group: group, name: '...'} -- which is exactly what + * a typed declaration looks like to a token counter. Read as one, it + * "declared" a variable called group with no initialiser and cleared the + * real binding of that name, so the strict declaration that used it later + * was never matched.

+ */ + private static boolean followedByMapKeyColon(String statement, int end) { + int at = skipBlanks(statement, end); + return at < statement.length() && statement.charAt(at) == ':' + && (at + 1 >= statement.length() || statement.charAt(at + 1) != ':'); + } + /** * Whether the literal opening at {@code quoteAt} is the argument of a * reason rather than a dependency. @@ -2004,6 +2041,20 @@ private static boolean opensAnExtraPropertiesBlock(String statement) { return false; } + /** + * The depth at {@code index}, counting braces opened earlier in this + * statement. + * + *

Depth is tracked between statements, so a closure opened and a local + * declared on the SAME line looked like top level: + * {@code ext.helper = { def dep = '...' }} recorded dep as if it belonged + * to the script, and it then outlived the closure and shadowed the real + * binding for everything after.

+ */ + private static int depthAt(String statement, int index, int base) { + return base + braceBalance(statement.substring(0, Math.min(index, statement.length()))); + } + /** * The names a scope introduced, so they can be taken back when it closes. * @@ -2129,7 +2180,7 @@ && isIdentifierChar(statement.charAt(scan + 1))))) { lastTokenEnd = scan; scan = skipBlanks(statement, scan); } - if (tokens > 1) { + if (tokens > 1 && !followedByMapKeyColon(statement, lastTokenEnd)) { declared = true; i = lastTokenStart; } else if (tokens == 1) { @@ -2160,7 +2211,7 @@ && isIdentifierChar(statement.charAt(scan + 1))))) { return; } if (declared) { - scope.declared(depth, name, literals); + scope.declared(depthAt(statement, nameStart, depth), name, literals); } i = skipBlanks(statement, i); if (i >= statement.length() || statement.charAt(i) != '=' @@ -2215,7 +2266,7 @@ && isIdentifierChar(statement.charAt(nextEnd))) { return; } if (declared) { - scope.declared(depth, name, literals); + scope.declared(depthAt(statement, nextName, depth), name, literals); } i = assign; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 560fc8f7e12..c0fd533c56b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -641,6 +641,63 @@ public void aMapKeyMayBeQuoted() { "the merged-era declaration is read, got <<" + modern + ">>"); } + /** + * Gradle's parenthesis-free map form puts two bare tokens in a row, which + * is what a typed declaration looks like to a token counter. Read as one, + * {@code implementation group: group, ...} "declared" a variable called + * group with no initialiser and cleared the real binding of that name, so + * the strict declaration using it later was never matched. + */ + @Test + public void aNamedArgumentIsNotADeclaration() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def group = 'org.jetbrains.kotlin'\n" + + " implementation group: group, name: 'other', version: '1.0'\n" + + " implementation(group: group, name: 'kotlin-stdlib', " + + "version: '1.7.22') { version { strictly '1.7.22' } }\n"); + check("".equals(out), + "the binding survives the map-form declaration, got <<" + out + ">>"); + } + + /** + * A map entry's value is not handed to a dependency. A catalog of strings + * carrying a strict-looking coordinate suppressed the block for something + * never added to any configuration. + */ + @Test + public void aMapEntryValueIsNotADeclaration() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def catalog = [legacy: " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!']\n" + + " implementation 'androidx.appcompat:appcompat:1.6.1'\n"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0") + && out.contains("kotlin-stdlib-jdk8:1.8.0"), + "a catalog entry decides nothing, got <<" + out + ">>"); + + // But a coordinate in a forcedModules list decides everything, and it sits + // right after a bracket -- which is why only the colon is read this way. + String forced = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.forcedModules = " + + "['org.jetbrains.kotlin:kotlin-stdlib:1.7.22'] }\n"); + check("".equals(forced), "a forced module is still read, got <<" + forced + ">>"); + } + + /** + * A closure opened and a local declared on the same line is still a + * closure. Depth is tracked between statements, so that local looked like + * it belonged to the script and outlived the closure it was written in. + */ + @Test + public void aSameLineClosureIsStillAScope() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " ext.dep = 'com.example:other:1.0'\n" + + " ext.helper = { def dep = " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22' }\n" + + " implementation(dep)\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the closure's local does not escape it, got <<" + out + ">>"); + } + /** * The backward scans know what whitespace is too. Three of them stopped at * a space or a tab, so a fragment with Windows line endings put a carriage From ddd90c0035622f02341b9c4a897609639daf0dc1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:36:49 +0300 Subject: [PATCH 51/94] Let a bracket hold a statement together the way a parenthesis does The splitter counted parentheses and not brackets, so a force written across lines -- resolutionStrategy.forcedModules = [ 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' ] -- had its assignment in one statement and its coordinate in another. Neither said anything on its own, the force went unread, and the shims were raised to their empty jars around a base library pinned pre-merge. Counted with the parentheses rather than separately, because the question is only ever "is this newline inside something", never which kind. A map written across lines is held together by the same change, and declarations that are NOT inside brackets still separate -- which is what stops one declaration's configuration pairing with another's coordinate, so it is asserted alongside. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 13 +++++-- .../builders/KotlinStdlibAlignmentTest.java | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 8ac50fc8291..115a8e46f90 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -1821,9 +1821,18 @@ private static String[] statements(String text) { i = end; continue; } - if (c == '(') { + // Brackets hold a statement together exactly as parentheses do. A force + // is written across lines as + // resolutionStrategy.forcedModules = [ + // 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' + // ] + // and splitting there left the assignment in one statement and its + // coordinate in another, so neither said anything and the force went + // unread. Counted together because the question is only ever "is this + // newline inside something", not which kind of something. + if (c == '(' || c == '[') { depth++; - } else if (c == ')') { + } else if (c == ')' || c == ']') { if (depth > 0) { depth--; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index c0fd533c56b..cc39498418d 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -641,6 +641,42 @@ public void aMapKeyMayBeQuoted() { "the merged-era declaration is read, got <<" + modern + ">>"); } + /** + * A bracket holds a statement together exactly as a parenthesis does. A + * force written across lines had its assignment in one statement and its + * coordinate in another, so neither said anything and the force went + * unread while the shims were raised around it. + */ + @Test + public void aBracketHoldsAStatementTogether() { + String across = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all {\n" + + " resolutionStrategy.forcedModules = [\n" + + " 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" + + " ]\n" + + " }\n"); + check("".equals(across), + "the force spans lines, got <<" + across + ">>"); + + String mapAcross = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation([\n" + + " group: 'org.jetbrains.kotlin',\n" + + " name: 'kotlin-stdlib-jdk8',\n" + + " version: '1.7.22'\n" + + " ])\n"); + check("".equals(mapAcross), + "and so does a map written across them, got <<" + mapAcross + ">>"); + + // Statements that are NOT inside brackets still separate, which is what + // stops one declaration's configuration pairing with another's coordinate. + String separate = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'androidx.appcompat:appcompat:1.6.1'\n" + + " implementation 'com.google.code.gson:gson:2.10.1'\n"); + check(separate.contains("kotlin-stdlib-jdk7:1.8.0") + && separate.contains("kotlin-stdlib-jdk8:1.8.0"), + "ordinary declarations still split, got <<" + separate + ">>"); + } + /** * Gradle's parenthesis-free map form puts two bare tokens in a row, which * is what a typed declaration looks like to a token counter. Read as one, From 25e8faee4dfa4058936e0dc2b560c571bd41370a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:59:31 +0300 Subject: [PATCH 52/94] Scan each fragment inside the closure that holds it, and read what a rule says Three findings, and the last one is about the shape of the scan rather than about Groovy. A fragment is now handed over wrapped in the closure that surrounds it in the generated file -- repositories { } for android.repositories, buildscript { dependencies { } } for the classpath fragment, android { } and android { defaultConfig { } } for those two, dependencies { } for the rest. Passing them bare had been harmless until scopes started being tracked; now a `def` written into the repositories closure outlived it and shadowed a real binding for every statement after, which reads a later use as a declaration and skips that artifact's constraint. An unbraced body belongs to the header above it. A resolution rule written as an `if (...)` with its useVersion on the next line had the artifact named in the condition and the override in the body, and splitting at the newline left neither statement saying anything. Rejoined by reading the language's own headers -- a closed set, and a different question from the one brace depth answers. And a rejection manages a version as firmly as a pin does, from the other side: `{ version { reject '[1.8.0,)' } }` says every version our floor could resolve to is unacceptable, so writing the constraint anyway leaves the graph nothing to select. Any reject counts, without reading which versions it covers, which is the only reading available without evaluating the rule. The scope test needed a second half before it proved anything: written against the alignment with pre-wrapped text, it passed with the builder unchanged. It reads the builder now, and fails when a fragment is handed over bare. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 30 ++++--- .../builders/KotlinStdlibAlignment.java | 48 +++++++++++ .../builders/KotlinStdlibAlignmentTest.java | 85 ++++++++++++++++++- 3 files changed, 150 insertions(+), 13 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 3a10016cf73..88ff13c0002 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7315,20 +7315,28 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // that keeps being wrong, and this way there is none to make. // KotlinStdlibAlignmentTest reads this call against the script // and fails if they ever disagree. + // + // Each one wrapped in the closure that surrounds it in the + // generated file, because a `def` inside repositories { } is + // scoped to that closure and the alignment now tracks scopes. + // Handed over bare, such a local outlived its closure and + // shadowed a real binding for everything after it -- which + // reads a later use as a declaration and skips that + // artifact's constraint. request.getArg("android.gradlePlugin", ""), - injectRepo, - gradleDependency, - request.getArg("android.gradle.androidx", ""), + "repositories {\n%s\n}\n".replace("%s", injectRepo), + "buildscript {\ndependencies {\n%s\n}\n}\n".replace("%s", gradleDependency), + "android {\n%s\n}\n".replace("%s", request.getArg("android.gradle.androidx", "")), minSDK, targetNumber, - request.getArg("android.xgradle_default_config", ""), - coreLibraryDesugaringDependency, - request.getArg("android.supportv4Dep", ""), - kotlinRuntimeDependency, - additionalDependencies, - aiExtraGradleDependencies.toString(), - request.getArg("android.gradleDep", ""), - aarDependencies, + "android {\ndefaultConfig {\n%s\n}\n}\n".replace("%s", request.getArg("android.xgradle_default_config", "")), + "dependencies {\n%s\n}\n".replace("%s", coreLibraryDesugaringDependency), + "dependencies {\n%s\n}\n".replace("%s", request.getArg("android.supportv4Dep", "")), + "dependencies {\n%s\n}\n".replace("%s", kotlinRuntimeDependency), + "dependencies {\n%s\n}\n".replace("%s", additionalDependencies), + "dependencies {\n%s\n}\n".replace("%s", aiExtraGradleDependencies.toString()), + "dependencies {\n%s\n}\n".replace("%s", request.getArg("android.gradleDep", "")), + "dependencies {\n%s\n}\n".replace("%s", aarDependencies), request.getArg("android.xgradle", "")); } catch (RuntimeException e) { // The alignment reads the app's Gradle text to decide whether the app diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 115a8e46f90..07d216d6d59 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -553,6 +553,15 @@ private static boolean holdsStrictly(String line, String artifact) { if (callsStrictly(line) || callsForce(line, artifact)) { return true; } + // A rejection manages the version just as firmly, from the other side: + // implementation('...:kotlin-stdlib-jdk8') { version { reject '[1.8.0,)' } } + // says every version our floor could resolve to is unacceptable, so writing + // the constraint anyway leaves the graph with nothing to select. Any reject + // counts, without reading which versions it covers -- the conservative + // reading, and the only one available without evaluating the rule. + if (callsNamed(line, "reject") || callsNamed(line, "rejectAll")) { + return true; + } String declared = declaredVersionOf(line, artifact); return declared != null && declared.endsWith(STRICT_SUFFIX); } @@ -1849,6 +1858,15 @@ private static String[] statements(String text) { current.append(' '); continue; } + if (c == '\n' && opensAnUnbracedBody(current.toString())) { + // An `if (...)` with no brace takes the next line as its body, so + // splitting there put the condition in one statement and the body + // in another -- and a resolution rule written that way had the + // artifact named in the condition and the useVersion in the body, + // so neither statement said anything and the override went unread. + current.append(' '); + continue; + } out.add(current.toString().replace('\n', ' ')); current.setLength(0); continue; @@ -2435,6 +2453,36 @@ private static void recordBareAssignment(String body, Map litera /** Gradle's extra-properties prefix, the one dotted assignment worth reading. */ private static final String EXTRA_PROPERTIES = "ext"; + /** + * Whether the text so far is a control header whose body is the next line. + * + *

Read off the language's own keywords, which is a closed set -- unlike + * the earlier use of a keyword list, which was answering "might this scope + * run" and is better answered by counting braces. The question here is + * different: an unbraced body belongs to the header above it, and only + * these words introduce one.

+ */ + private static boolean opensAnUnbracedBody(String text) { + int i = skipBlanks(text, 0); + int end = i; + while (end < text.length() && isIdentifierChar(text.charAt(end))) { + end++; + } + String head = text.substring(i, end); + if (UNBRACED_HEADERS.indexOf(" " + head + " ") < 0) { + return false; + } + if (braceBalance(text) != 0) { + return false; + } + int last = skipBlanksBackward(text, text.length() - 1); + // `else` stands alone; the rest carry a condition in parentheses. + return last >= 0 && (text.charAt(last) == ')' || "else".equals(head)); + } + + /** The words that introduce a body, braced or not. */ + private static final String UNBRACED_HEADERS = " if else while for "; + /** Whether the text so far ends with a comma, ignoring trailing blanks. */ private static boolean endsWithComma(StringBuilder text) { for (int i = text.length() - 1; i >= 0; i--) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index cc39498418d..a38667dbbdf 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -182,8 +182,11 @@ public void theBuilderPassesEveryAppControlledDependencyFragment() throws Except // argument deleted. Checked by deleting it, which is the only way that kind of // vacuity shows up. String[] fragments = { - "additionalDependencies,", - "aiExtraGradleDependencies.toString(),", + // Each fragment now reaches the call wrapped in the closure that + // surrounds it in the generated file, so the argument text ends at the + // wrapper's parenthesis rather than at a comma. + "additionalDependencies)", + "aiExtraGradleDependencies.toString())", "request.getArg(\"android.gradleDep\", \"\")", "request.getArg(\"android.supportv4Dep\", \"\")", "request.getArg(\"android.xgradle\", \"\")", @@ -641,6 +644,84 @@ public void aMapKeyMayBeQuoted() { "the merged-era declaration is read, got <<" + modern + ">>"); } + /** + * An unbraced body belongs to the header above it. A resolution rule + * written that way had the artifact named in the condition and the + * override in the body, and splitting at the newline left neither + * statement saying anything. + */ + @Test + public void anUnbracedBodyStaysWithItsCondition() { + String rule = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.eachDependency { d ->\n" + + " if (d.requested.group == 'org.jetbrains.kotlin' " + + "&& d.requested.name == 'kotlin-stdlib')\n" + + " d.useVersion '1.7.22'\n" + + " } }\n"); + check("".equals(rule), "the rule is read across the newline, got <<" + rule + ">>"); + + // Two ordinary declarations on consecutive lines are still two statements. + String separate = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'androidx.appcompat:appcompat:1.6.1'\n" + + " implementation 'com.google.code.gson:gson:2.10.1'\n"); + check(separate.contains("kotlin-stdlib-jdk8:1.8.0"), + "ordinary lines still separate, got <<" + separate + ">>"); + } + + /** + * A rejection manages the version from the other side. Rejecting every + * version our floor could resolve to leaves the graph nothing to select, + * so writing the constraint anyway makes it unsatisfiable. + */ + @Test + public void aRejectionIsVersionManagement() { + String[] rules = {"reject '[1.8.0,)'", "rejectAll()"}; + for (int i = 0; i < rules.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " + + "{ version { " + rules[i] + " } }\n"); + check("".equals(out), + rules[i] + " suppresses the block, got <<" + out + ">>"); + } + } + + /** + * A fragment is scanned inside the closure that surrounds it in the + * generated file. Handed over bare, a local declared in the repositories + * closure outlived it and shadowed a real binding for everything after -- + * which reads a later use as a declaration and skips that constraint. + */ + @Test + public void aFragmentKeepsItsGeneratedScope() throws Exception { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + "ext.dep = 'com.example:other:1.0'\n", + "repositories {\n" + + "def dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n}\n", + "dependencies {\nimplementation(dep)\n}\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the repository-local name does not escape, got <<" + out + ">>"); + + // The half above proves the alignment honours a scope it is GIVEN. This half + // proves the builder gives it one: passing the fragments bare is what the + // report was about, and a test that hands over pre-wrapped text would pass + // with the builder unchanged -- which it did, until this was added. + byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); + String builderSrc = new String(bytes, "UTF-8"); + int at = builderSrc.indexOf("KotlinStdlibAlignment.constraintsBlock("); + check(at >= 0, "the builder calls the alignment"); + String fromCall = builderSrc.substring(at).replaceAll("//[^\n]*", ""); + String call = fromCall.substring(0, fromCall.indexOf(";")); + String[] scopes = { + "repositories {", "buildscript {", "android {", "dependencies {", + }; + for (int i = 0; i < scopes.length; i++) { + check(call.indexOf(scopes[i]) >= 0, + "fragments are handed over inside their " + scopes[i] + + " scope, which the call does not show"); + } + } + /** * A bracket holds a statement together exactly as a parenthesis does. A * force written across lines had its assignment in one statement and its From 61c9fce23a8390133d30e32a627fe3da1ff78013 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:16:45 +0300 Subject: [PATCH 53/94] Stand down where a conflict is fatal, and read declarations only where they run The alignment writes nothing at all when the app sets failOnVersionConflict. Raising a shim from 1.7.x to the floor IS a version conflict, so in that mode these constraints turn a graph that resolved coherently into "Conflict found ... between versions 1.8 and 1.7". There is no version of this block that would not conflict there, so there is no block. A declaration written inside quoted prose never executes. An unrestricted search for `def` found one in a println and recorded it, overwriting the real binding so a later use read as something it is not -- the same "outside literals or it is not syntax" rule the rest of the file already follows, now applied here too. And Groovy accepts spaces around a map key's colon. Looking only at the character immediately after the token missed `group : group`, substituted the key away, and lost the map form along with the strict pin inside it. It asks the shared test now, which skips blanks first -- the same test the declaration walk was already using, written out twice. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 32 ++++++++--- .../builders/KotlinStdlibAlignmentTest.java | 57 +++++++++++++++++++ 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 07d216d6d59..ecbc0ab59d5 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -213,6 +213,17 @@ public static String constraintsBlock(String configuration, if (strictlyPinsBaseStdlibBelowTheFloor(appGradleFragments)) { return ""; } + // failOnVersionConflict turns every disagreement into a build failure, and + // raising a shim from 1.7.x to the floor IS a disagreement -- so in that mode + // the block converts a graph that resolved coherently into + // "Conflict found ... between versions 1.8 and 1.7". Nothing here can be + // written that would not conflict, so nothing is. + String[] active = activeLines(combined(appGradleFragments)); + for (int i = 0; i < active.length; i++) { + if (callsNamed(active[i], "failOnVersionConflict")) { + return ""; + } + } // The two shims cannot be suppressed independently when the app holds one of // them below the merge. Measured: an app pinning the whole family at 1.7.22 // resolves with no duplicate, and emitting only the surviving sibling raises @@ -2136,12 +2147,14 @@ private static void updateLiteralDefinitions(String statement, } int i = 0; boolean declared = false; - int at = statement.indexOf(DEF); - if (at >= 0 && (at == 0 || !isIdentifierChar(statement.charAt(at - 1))) - && (at + DEF.length() >= statement.length() - || !isIdentifierChar(statement.charAt(at + DEF.length())))) { + // Outside literals, like every other question about syntax. An unrestricted + // search found `def` inside quoted prose -- println "def dep = '...'" -- and + // recorded a declaration that never executes, overwriting the real binding + // and making a later use read as something it is not. + int at = afterCall(statement, DEF); + if (at >= 0) { declared = true; - i = skipBlanks(statement, at + DEF.length()); + i = skipBlanks(statement, at); } else { i = skipBlanks(statement, 0); // Past any annotations first. A script field is written @@ -2364,10 +2377,11 @@ private static String withLiteralsInlined(String statement, // turning `group:` into a quoted string and losing the map form // entirely, strict pin and all. Groovy's named arguments are exactly // "identifier immediately followed by a colon", which is what this asks. - boolean isMapKey = end < statement.length() - && statement.charAt(end) == ':' - && (end + 1 >= statement.length() - || statement.charAt(end + 1) != ':'); + // The shared test, which skips blanks first: Groovy accepts + // `group : group` with spaces around the colon, and looking only at the + // character immediately after the token missed the key and substituted + // it away again. + boolean isMapKey = followedByMapKeyColon(statement, end); out.append(literal == null || isMapKey ? token : literal); i = end - 1; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index a38667dbbdf..31e0b32159a 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -644,6 +644,63 @@ public void aMapKeyMayBeQuoted() { "the merged-era declaration is read, got <<" + modern + ">>"); } + /** + * A declaration written inside quoted prose never executes. An + * unrestricted search for {@code def} found one there and recorded it, + * overwriting a real binding so a later use read as something else. + */ + @Test + public void aDeclarationInsideProseIsNotADeclaration() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = 'com.example:other:1.0'\n" + + " println \"def dep = " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\"\n" + + " implementation(dep)\n"); + check(out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the quoted declaration is ignored, got <<" + out + ">>"); + + // A real one directly after it still counts. + String real = KotlinStdlibAlignment.constraintsBlock("implementation", + " println \"nothing to see\"\n" + + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n" + + " implementation(dep)\n"); + check("".equals(real), "a real declaration still counts, got <<" + real + ">>"); + } + + /** + * Groovy accepts spaces around a map key's colon, and looking only at the + * character immediately after the token missed the key and substituted it + * away -- losing the map form and the strict pin inside it. + */ + @Test + public void aMapKeyMayBeSpacedFromItsColon() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " def group = 'org.jetbrains.kotlin'\n" + + " implementation(group : group, name : 'kotlin-stdlib-jdk8', " + + "version : '1.7.22') { version { strictly '1.7.22' } }\n"); + check("".equals(out), "the spaced map form is read, got <<" + out + ">>"); + } + + /** + * With failOnVersionConflict every disagreement is a build failure, and + * raising a shim to the floor IS a disagreement -- so the block would turn + * a graph that resolved coherently into one that does not resolve at all. + * Nothing can be written here that would not conflict, so nothing is. + */ + @Test + public void nothingIsWrittenWhenConflictsAreFatal() { + String fatal = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.failOnVersionConflict() }\n"); + check("".equals(fatal), "the block stands down, got <<" + fatal + ">>"); + + // The words in a reason are prose, here as everywhere else. + String prose = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('a:b:1.0') " + + "{ because 'we do not failOnVersionConflict here' }\n"); + check(prose.contains("kotlin-stdlib-jdk8:1.8.0"), + "prose does not stand it down, got <<" + prose + ">>"); + } + /** * An unbraced body belongs to the header above it. A resolution rule * written that way had the artifact named in the condition and the From a44efac9be8c7d6320e65e08887ef03adb8969dd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:30:31 +0300 Subject: [PATCH 54/94] Narrow both of last commit's stand-downs to what they actually govern Two findings, both against the previous commit rather than the original design, and both in the same direction: standing the block down further than the reason for standing it down reaches. failOnVersionConflict on configurations.classpath governs the PLUGIN classpath. The constraints go into the app's dependencies, which that strategy never sees, so suppressing there left a real duplicate unfixed for a setting that cannot conflict with anything written here. Only that spelling is excluded, because it names the configuration outright; `buildscript { configurations.all { ... } }` is also plugin-only and still stands the block down, since knowing that needs the surrounding block and being wrong the other way breaks a working build. And a rejection only manages the version when it leaves the floor nothing to select. rejectAll does, and so does an open-ended `reject '[1.8.0,)'` -- but `reject '1.7.0'` removes a version the constraint was never going to pick, `reject '[1.9.0,)'` leaves all of 1.8.x, and `reject '(,1.8.0]'` leaves everything after it. Reading every rejection as management, which is what the previous commit did, left the duplicate unfixed in each of those. Both halves are asserted either way round, because a rule that suppresses too much is as wrong as one that suppresses too little -- it just fails where nobody is looking. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 91 +++++++++++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 48 ++++++++++ 2 files changed, 130 insertions(+), 9 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index ecbc0ab59d5..da0e5167177 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -220,9 +220,23 @@ public static String constraintsBlock(String configuration, // written that would not conflict, so nothing is. String[] active = activeLines(combined(appGradleFragments)); for (int i = 0; i < active.length; i++) { - if (callsNamed(active[i], "failOnVersionConflict")) { - return ""; + if (!callsNamed(active[i], "failOnVersionConflict")) { + continue; + } + // Applied to configurations.classpath it governs the PLUGIN classpath and + // nothing this block writes: the constraints go into the app's + // dependencies, which that strategy never sees, so standing down there + // would leave a real duplicate unfixed for a setting that cannot conflict + // with us. + // + // Only that spelling, because it names the configuration outright. + // `buildscript { configurations.all { ... } }` is also plugin-only and + // still stands the block down: knowing that needs the surrounding block, + // and being wrong in the other direction breaks a build that works. + if (namesTheBuildscriptClasspath(active[i])) { + continue; } + return ""; } // The two shims cannot be suppressed independently when the app holds one of // them below the merge. Measured: an app pinning the whole family at 1.7.22 @@ -564,13 +578,17 @@ private static boolean holdsStrictly(String line, String artifact) { if (callsStrictly(line) || callsForce(line, artifact)) { return true; } - // A rejection manages the version just as firmly, from the other side: - // implementation('...:kotlin-stdlib-jdk8') { version { reject '[1.8.0,)' } } - // says every version our floor could resolve to is unacceptable, so writing - // the constraint anyway leaves the graph with nothing to select. Any reject - // counts, without reading which versions it covers -- the conservative - // reading, and the only one available without evaluating the rule. - if (callsNamed(line, "reject") || callsNamed(line, "rejectAll")) { + // A rejection manages the version from the other side, but only when it + // actually leaves our floor nothing to select. rejectAll does; so does an + // open-ended range starting at or below the floor, `reject '[1.8.0,)'`. + // `reject '1.7.0'` does not -- 1.8.0 and everything after it are still + // available, the graph still needs aligning, and treating every rejection as + // management left the original duplicate unfixed. + if (callsNamed(line, "rejectAll")) { + return true; + } + String rejected = versionInCall(line, "reject"); + if (rejected != null && rejectionLeavesNothingAtTheFloor(rejected)) { return true; } String declared = declaredVersionOf(line, artifact); @@ -889,6 +907,61 @@ private static boolean belowTheFloor(String version) { return literalBelowTheFloor(selector); } + /** + * Whether the statement applies its strategy to the plugin classpath. + * + *

{@code configurations.classpath} is the buildscript's own, and a + * strategy on it governs which plugin jars load -- never the app's + * dependencies, which is all this class writes to.

+ */ + private static boolean namesTheBuildscriptClasspath(String line) { + int at = line.indexOf(BUILDSCRIPT_CLASSPATH); + while (at >= 0) { + boolean startsToken = at == 0 || !isIdentifierChar(line.charAt(at - 1)); + int after = at + BUILDSCRIPT_CLASSPATH.length(); + if (startsToken && (after >= line.length() + || !isIdentifierChar(line.charAt(after)))) { + return true; + } + at = line.indexOf(BUILDSCRIPT_CLASSPATH, at + 1); + } + return false; + } + + private static final String BUILDSCRIPT_CLASSPATH = "configurations.classpath"; + + /** + * Whether a rejected selector removes the floor and everything after it. + * + *

Only an open-ended range starting at or below the floor does: + * {@code [1.8.0,)} leaves nothing for the constraint to resolve to, while + * {@code [1.9.0,)} still leaves 1.8.x and an exact {@code 1.7.0} removes a + * version the constraint was never going to select anyway.

+ */ + private static boolean rejectionLeavesNothingAtTheFloor(String selector) { + String rejection = selector.trim(); + if (rejection.length() == 0) { + return false; + } + char opening = rejection.charAt(0); + if (opening != '[' && opening != '(' && opening != ']') { + return false; + } + int comma = rejection.indexOf(','); + if (comma < 0) { + return false; + } + String upper = rejection.substring(comma + 1, + Math.max(comma + 1, rejection.length() - 1)).trim(); + if (upper.length() != 0) { + // Bounded above, so something at or past the floor survives it. + return false; + } + String lower = rejection.substring(1, comma).trim(); + return lower.length() == 0 + || compareVersions(lower, MERGED_STDLIB_FLOOR) <= 0; + } + /** Whether a range excludes every version at or above the floor. */ private static boolean rangeCannotReachTheFloor(String selector) { int comma = selector.indexOf(','); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 31e0b32159a..c63c36b17eb 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -644,6 +644,54 @@ public void aMapKeyMayBeQuoted() { "the merged-era declaration is read, got <<" + modern + ">>"); } + /** + * A strategy on {@code configurations.classpath} governs the plugin + * classpath, which is not where these constraints go -- so standing the + * block down for it would leave a real duplicate unfixed for a setting + * that cannot conflict with anything written here. + */ + @Test + public void aBuildscriptStrategyIsNotTheAppsGraph() { + String plugin = KotlinStdlibAlignment.constraintsBlock("implementation", + " buildscript { configurations.classpath.resolutionStrategy" + + ".failOnVersionConflict() }\n"); + check(plugin.contains("kotlin-stdlib-jdk8:1.8.0"), + "a classpath-only strategy leaves the alignment alone, got <<" + + plugin + ">>"); + + // The app's own configurations still stand it down. + String app = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.failOnVersionConflict() }\n"); + check("".equals(app), "the app's graph still does, got <<" + app + ">>"); + } + + /** + * A rejection only manages the version when it leaves the floor nothing to + * select. Reading every rejection as management left the original + * duplicate unfixed for an app that had rejected something else entirely. + */ + @Test + public void aRejectionCountsOnlyWhenItReachesTheFloor() { + String[] closing = {"reject '[1.8.0,)'", "rejectAll()"}; + for (int i = 0; i < closing.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " + + "{ version { " + closing[i] + " } }\n"); + check("".equals(out), + closing[i] + " leaves nothing at the floor, got <<" + out + ">>"); + } + + String[] leaving = {"reject '1.7.0'", "reject '[1.9.0,)'", "reject '(,1.8.0]'"}; + for (int i = 0; i < leaving.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " + + "{ version { " + leaving[i] + " } }\n"); + check(out.contains("kotlin-stdlib-jdk7:1.8.0"), + leaving[i] + " still leaves the floor selectable, got <<" + + out + ">>"); + } + } + /** * A declaration written inside quoted prose never executes. An * unrestricted search for {@code def} found one there and recorded it, From b20ef8a4369992764ee0662910a8565d40a5a967 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:47:31 +0300 Subject: [PATCH 55/94] Record a map factored into a variable, and read a rejection for what it removes A map is a declaration too. `def dep = [group: '...', name: '...', version: '1.7.22']` was recorded as nothing, so the statement using it named no artifact and the strict pin it carried was invisible. Stored whole, it inlines back into the usage and reads as the map form it is -- while a map of unrelated strings still declares nothing, which is asserted beside it. Two corrections to the rejection rule this commit's predecessor added, both where it claimed more than it should: An exclusive lower bound does not reject its own bound. `reject '(1.8.0,)'` leaves exactly the floor selectable, so the constraint has somewhere to land and the block belongs there; only `[1.8.0,)` closes it off. And a rejection decides reachability on its own, whatever requirement sits beside it. `require '1.+'; reject '[1.8.0,)'` can only select a pre-merge 1.x, and reading the requirement alone called that merged-era -- so the shim's own constraint was skipped as satisfied while its sibling was raised around it, which is the duplicate this class exists to prevent. Eight rejection spellings are asserted now, four that close the floor off and four that leave it open, because this rule has now been wrong in both directions in successive commits. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 71 +++++++++++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 63 ++++++++++++++++ 2 files changed, 127 insertions(+), 7 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index da0e5167177..cda8f4e54ea 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -294,6 +294,15 @@ private static boolean declaredBelowTheFloor(String artifact, String configurati if (!namesArtifactAnywhere(lines[j], artifact)) { continue; } + // A rejection that removes the floor decides this on its own, whatever + // the requirement beside it says. `require '1.+'; reject '[1.8.0,)'` + // can only select a pre-merge 1.x, and reading the requirement alone + // called that merged-era -- so its own constraint was skipped as + // satisfied while the sibling was raised around it, which is the + // duplicate again. + if (rejectsTheFloor(lines[j])) { + return true; + } String declared = declaredVersionOf(lines[j], artifact); if (declared != null && declared.endsWith(STRICT_SUFFIX)) { declared = declared.substring(0, @@ -584,11 +593,7 @@ private static boolean holdsStrictly(String line, String artifact) { // `reject '1.7.0'` does not -- 1.8.0 and everything after it are still // available, the graph still needs aligning, and treating every rejection as // management left the original duplicate unfixed. - if (callsNamed(line, "rejectAll")) { - return true; - } - String rejected = versionInCall(line, "reject"); - if (rejected != null && rejectionLeavesNothingAtTheFloor(rejected)) { + if (rejectsTheFloor(line)) { return true; } String declared = declaredVersionOf(line, artifact); @@ -958,8 +963,24 @@ private static boolean rejectionLeavesNothingAtTheFloor(String selector) { return false; } String lower = rejection.substring(1, comma).trim(); - return lower.length() == 0 - || compareVersions(lower, MERGED_STDLIB_FLOOR) <= 0; + if (lower.length() == 0) { + return true; + } + int compared = compareVersions(lower, MERGED_STDLIB_FLOOR); + // An exclusive lower bound does not reject the bound itself, so + // `(1.8.0,)` leaves exactly the floor selectable and the constraint has + // somewhere to land; `[1.8.0,)` does not. + boolean excludesItsOwnBound = opening == '(' || opening == ']'; + return excludesItsOwnBound ? compared < 0 : compared <= 0; + } + + /** Whether the statement rejects the floor and everything past it. */ + private static boolean rejectsTheFloor(String line) { + if (callsNamed(line, "rejectAll")) { + return true; + } + String rejected = versionInCall(line, "reject"); + return rejected != null && rejectionLeavesNothingAtTheFloor(rejected); } /** Whether a range excludes every version at or above the floor. */ @@ -2112,6 +2133,31 @@ private static List inlineLiteralDefinitions(List statements) { * reassignment to something unreadable, which forgets it rather than * leaving a stale value behind. */ + /** + * The index of the {@code ]} closing the bracket at {@code from}, or -1. + * Nested brackets and literals are skipped, so a map inside a list closes + * where it really closes. + */ + private static int closingBracket(String text, int from) { + int depth = 0; + for (int i = from; i < text.length(); i++) { + if (isLiteralStart(text, i)) { + i = endOfStringLiteral(text, i); + continue; + } + char c = text.charAt(i); + if (c == '[') { + depth++; + } else if (c == ']') { + depth--; + if (depth == 0) { + return i; + } + } + } + return -1; + } + /** * Records a definition, or forgets it, unless doing so under a condition * would throw away the value that decides suppression. @@ -2353,6 +2399,17 @@ && isIdentifierChar(statement.charAt(scan + 1))))) { end = closes; value = expandedLiteral(statement, i, closes, literals); } + } else if (i < statement.length() && statement.charAt(i) == '[') { + // A map factored into a variable is a declaration too: + // def dep = [group: '...', name: '...', version: '1.7.22'] + // Recorded as nothing, the statement using it named no artifact and + // the strict pin it carried was invisible. Stored whole, it inlines + // back into the usage and reads as the map form it is. + int closes = closingBracket(statement, i); + if (closes > i) { + end = closes; + value = statement.substring(i, closes + 1); + } } recordDefinition(literals, name, value, conditional); if (end < 0) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index c63c36b17eb..e2051cbb7f3 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -644,6 +644,69 @@ public void aMapKeyMayBeQuoted() { "the merged-era declaration is read, got <<" + modern + ">>"); } + /** + * A map factored into a variable is a declaration too. Recorded as + * nothing, the statement using it named no artifact and the strict pin it + * carried was invisible. + */ + @Test + public void aMapMayBeFactoredIntoAVariable() { + String pinned = KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = [group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib-jdk8', version: '1.7.22']\n" + + " implementation(dep) { version { strictly '1.7.22' } }\n"); + check("".equals(pinned), "the map is carried to its usage, got <<" + pinned + ">>"); + + String modern = KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = [group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib-jdk7', version: '1.9.22']\n" + + " implementation(dep)\n"); + check(modern.contains("kotlin-stdlib-jdk8:1.8.0") + && !modern.contains("kotlin-stdlib-jdk7:1.8.0"), + "and read for what it declares, got <<" + modern + ">>"); + + // A map of unrelated strings is still not a declaration. + String catalog = KotlinStdlibAlignment.constraintsBlock("implementation", + " def catalog = [legacy: " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!']\n" + + " implementation 'androidx.appcompat:appcompat:1.6.1'\n"); + check(catalog.contains("kotlin-stdlib-jdk8:1.8.0"), + "a catalog still decides nothing, got <<" + catalog + ">>"); + } + + /** + * A rejection is read for exactly what it removes, and it decides + * reachability on its own -- a requirement beside it cannot select what + * the rejection has taken away. + */ + @Test + public void aRejectionIsReadForWhatItRemoves() { + String[][] cases = { + {"reject '[1.8.0,)'", ""}, + {"reject '[1.7.0,)'", ""}, + {"rejectAll()", ""}, + // require cannot select what reject removed + {"require '1.+'; reject '[1.8.0,)'", ""}, + // an EXCLUSIVE lower bound leaves the floor itself selectable + {"reject '(1.8.0,)'", "kotlin-stdlib-jdk7:1.8.0"}, + {"reject ']1.8.0,)'", "kotlin-stdlib-jdk7:1.8.0"}, + {"reject '[1.9.0,)'", "kotlin-stdlib-jdk7:1.8.0"}, + {"require '1.+'", "kotlin-stdlib-jdk7:1.8.0"}, + }; + for (int i = 0; i < cases.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " + + "{ version { " + cases[i][0] + " } }\n"); + if (cases[i][1].length() == 0) { + check("".equals(out), + cases[i][0] + " leaves nothing at the floor, got <<" + out + ">>"); + } else { + check(out.contains(cases[i][1]), + cases[i][0] + " leaves the floor selectable, got <<" + out + ">>"); + } + } + } + /** * A strategy on {@code configurations.classpath} governs the plugin * classpath, which is not where these constraints go -- so standing the From a58c6bb8cefc090a744a571919d0fa7a4398d805 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:58:06 +0300 Subject: [PATCH 56/94] Expand interpolations inside a stored dependency map A map factored into a variable was stored verbatim, so a version interpolated into it -- version: "$v" -- reached the scan as the text of the reference rather than the version. That read as no version at all, which counts as below the floor, and the whole constraints block stood down for a project that was on a merged-era Kotlin. Passing the stored map through the same expansion a stored string gets fixes it, because the definition it interpolates is already recorded by the time the map is read. Also records, above the override-spellings check, why an override is not bound to the branch that names it: doing so needs a branch model the class deliberately lacks, and it resolves unevaluable branches toward suppression everywhere else for the reason written on the conditional assignment path. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 30 ++++++++++++++++++- .../builders/KotlinStdlibAlignmentTest.java | 26 ++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index cda8f4e54ea..b2d767b6a44 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -1263,6 +1263,28 @@ private static boolean callsForce(String statement, String artifact) { // coordinate), and a dependency substitution. All of them win silently over // a constraint. // + // An override is read as applying to any artifact the statement names, and + // NOT bound to the branch that names it. Reported as imprecise, correctly: a + // rule whose branches handle several Kotlin modules can override a sibling + // while merely mentioning this one, and the block then stands down for an + // artifact nobody managed. + // + // Binding the call to its predicate means modelling branches -- which + // condition governs which statement -- and this class deliberately has no + // such model. Everywhere a branch cannot be evaluated it resolves the + // ambiguity toward suppression, for the reason written on the conditional + // assignment path: emitting beside an override this could not see is the + // failure that reaches the device, while suppressing costs an app the + // duplicate it already had, which fails in checkDuplicateClasses where it + // already was. + // + // The trade is the same here and the asymmetry is sharper, because a branch + // model has far more spellings to get wrong than a version range -- and this + // rule's narrowings have needed correcting in three consecutive commits, each + // time for a spelling that looked handled. Revisit with a real project whose + // rule branches this way, and a way to test the branch reading that does not + // rest on the same reasoning that keeps being wrong. + // // A substitution names TWO coordinates, which is why it was left out once: // the version scan takes the first literal, and that is the side being // REPLACED. The scan reads from after `using` now, so it takes the @@ -2408,7 +2430,13 @@ && isIdentifierChar(statement.charAt(scan + 1))))) { int closes = closingBracket(statement, i); if (closes > i) { end = closes; - value = statement.substring(i, closes + 1); + // Through the same expansion a string definition gets, so a map + // that interpolates a known version -- version: "$v" -- carries + // the version rather than the text of the reference. Stored + // verbatim, "$v" read as no version at all, which counts as + // below the floor and stood the whole block down. + value = withLiteralsInlined( + statement.substring(i, closes + 1), literals); } } recordDefinition(literals, name, value, conditional); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index e2051cbb7f3..15b70871b37 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -644,6 +644,32 @@ public void aMapKeyMayBeQuoted() { "the merged-era declaration is read, got <<" + modern + ">>"); } + /** + * A stored map goes through the same expansion a stored string does, so a + * version interpolated into it carries the version rather than the text of + * the reference -- {@code "$v"} read as no version at all, which counts as + * below the floor and stood the whole block down. + */ + @Test + public void aStoredMapExpandsWhatItInterpolates() { + String modern = KotlinStdlibAlignment.constraintsBlock("implementation", + " def v = '1.9.22'\n" + + " def dep = [group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib-jdk7', version: \"$v\"]\n" + + " implementation(dep)\n"); + check(modern.contains("kotlin-stdlib-jdk8:1.8.0") + && !modern.contains("kotlin-stdlib-jdk7:1.8.0"), + "the interpolated version is read, got <<" + modern + ">>"); + + String old = KotlinStdlibAlignment.constraintsBlock("implementation", + " def v = '1.7.22'\n" + + " def dep = [group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib-jdk7', version: \"$v\"]\n" + + " implementation(dep)\n"); + check("".equals(old), + "and a pre-merge one still suppresses, got <<" + old + ">>"); + } + /** * A map factored into a variable is a declaration too. Recorded as * nothing, the statement using it named no artifact and the strict pin it From 43093d04d6d29a7a415447102867f1963569f612 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:18:22 +0300 Subject: [PATCH 57/94] Read repeated Gradle calls the way Gradle does Three findings, all the same shape: a call that may be made more than once was read as its first occurrence. An extra property is reachable through the project as well as bare, and its name may be subscripted rather than dotted -- project.ext.dep, rootProject .ext.dep and ext['dep'] all set the property the bare name goes on to read, and none was recorded, so a strict pre-merge pin held in one was emitted straight over. The owner is now the last segment of the qualifier rather than the whole of it, which keeps the guard that matters: a property of anything else still does not bind the name. strictly, require and useVersion SET the constraint rather than adding to it, so a closure that calls one twice keeps the last value; the first was being reported. Rejections do the opposite -- reject takes varargs, may be called again, and Gradle applies every selector. Asked one at a time, reject '1.8.0', '(1.8.0,)' looked harmless twice over while together they leave the constraint nothing to resolve to. The question is now split in two, each half asked of the whole list: something must remove the floor itself and something must remove everything past it. No number of bounded ranges can do the second, so that half still comes down to a single open-ended range. Collecting rather than returning early also exposed an index skip -- the scan advanced onto the next call's first character and the loop's own step moved past it -- which was invisible while the first match ended the scan. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 226 ++++++++++++++---- .../builders/KotlinStdlibAlignmentTest.java | 129 ++++++++++ 2 files changed, 311 insertions(+), 44 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index b2d767b6a44..143ecb3b454 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -822,13 +822,31 @@ private static String richVersionIn(String statement) { /** The quoted argument of {@code call}, found outside string literals. */ private static String versionInCall(String statement, String call) { + List found = versionsInCall(statement, call); + // The LAST of them. Every keyword this is asked about -- strictly, require, + // useVersion -- SETS the constraint rather than adding to it, so a closure + // that calls one twice keeps what it was set to last. Reading the first + // reported 1.9.22 for `strictly '1.9.22'; strictly '1.7.22'` and wrote the + // shim constraints beside a pin that was really pre-merge. + return found.isEmpty() ? null : found.get(found.size() - 1); + } + + /** + * Every quoted argument of every syntactic {@code call} in the statement, in + * source order. + * + *

One call may carry several -- {@code reject} takes varargs -- and the + * call may be made more than once. The two are the same thing to a caller + * that has to consider the arguments together, so they arrive as one list.

+ */ + private static List versionsInCall(String statement, String call) { + List found = new ArrayList(); // The same syntax-level call callsStrictly validated, not any occurrence of // the word: a reason reading `because "strictly '1.7.22' is not intended"` // otherwise supplies the version for a declaration whose real strict version // is something else entirely, and the wrong one decides whether the block is // written. for (int i = 0; i < statement.length(); i++) { - char c = statement.charAt(i); if (isLiteralStart(statement, i)) { i = endOfStringLiteral(statement, i); continue; @@ -844,21 +862,31 @@ private static String versionInCall(String statement, String call) { if (after < statement.length() && statement.charAt(after) == '(') { after = skipBlanks(statement, after + 1); } - if (after < statement.length() - && isLiteralStart(statement, after)) { + while (after < statement.length() && isLiteralStart(statement, after)) { int end = endOfStringLiteral(statement, after); - if (end < statement.length()) { - // The literal's own delimiters, however many it has. Written - // strictly """1.7.22""", the one-per-side slice returned - // ""1.7.22"" -- which parsed as no version at all and only - // reached the right answer because an unreadable version counts - // as below the floor. Correct by accident is not correct. - return stringLiteralContent(statement, after); + if (end >= statement.length()) { + break; + } + // The literal's own delimiters, however many it has. Written + // strictly """1.7.22""", the one-per-side slice returned + // ""1.7.22"" -- which parsed as no version at all and only + // reached the right answer because an unreadable version counts + // as below the floor. Correct by accident is not correct. + found.add(stringLiteralContent(statement, after)); + after = skipBlanks(statement, end + 1); + if (after >= statement.length() || statement.charAt(after) != ',') { + break; } + after = skipBlanks(statement, after + 1); } - i = after; + // One BEFORE the next unread character, because the loop's own step + // lands on it. Advancing straight to it skipped a character, and while + // this returned on the first call that cost nothing -- now that it + // keeps looking, it landed inside `strictly` and read the second call + // of a repeated pair as ordinary text. + i = after - 1; } - return null; + return found; } /** @@ -912,6 +940,17 @@ private static boolean belowTheFloor(String version) { return literalBelowTheFloor(selector); } + /** + * Whether a dotted Gradle path ends in the given segment. + * + *

`ext`, `project.ext` and `rootProject.ext` all name the one extra + * properties extension, so the segment that owns the property is the last + * one rather than the whole qualifier.

+ */ + private static boolean lastSegmentIs(String path, String segment) { + return segment.equals(path.substring(path.lastIndexOf('.') + 1)); + } + /** * Whether the statement applies its strategy to the plugin classpath. * @@ -935,52 +974,111 @@ private static boolean namesTheBuildscriptClasspath(String line) { private static final String BUILDSCRIPT_CLASSPATH = "configurations.classpath"; + /** Whether the statement rejects the floor and everything past it. */ + private static boolean rejectsTheFloor(String line) { + if (callsNamed(line, "rejectAll")) { + return true; + } + // Rejections ACCUMULATE, and Gradle applies every one of them. Asked one + // selector at a time, `reject '1.8.0', '(1.8.0,)'` looked harmless twice + // over -- the exact rejection still leaves 1.8.1, the open range still + // leaves 1.8.0 -- while together they leave the constraint nothing to + // resolve to at all, and the block was written into a graph that could not + // resolve it. So the question is split in two and each half is asked of the + // whole list: something has to remove the floor itself, and something has + // to remove everything past it. + List rejected = versionsInCall(line, "reject"); + boolean floorRemoved = false; + boolean pastTheFloorRemoved = false; + for (int i = 0; i < rejected.size(); i++) { + String selector = rejected.get(i).trim(); + floorRemoved = floorRemoved || rejectionRemovesTheFloor(selector); + pastTheFloorRemoved = pastTheFloorRemoved + || rejectionRemovesPastTheFloor(selector); + } + return floorRemoved && pastTheFloorRemoved; + } + + /** + * Whether one rejection selector removes the floor version itself. + * + *

A prerelease of the floor is a different version from the floor, so + * rejecting {@code 1.8.0-RC2} does not reject {@code 1.8.0}.

+ */ + private static boolean rejectionRemovesTheFloor(String selector) { + if (selector.length() == 0) { + return false; + } + char opening = selector.charAt(0); + if (opening != '[' && opening != '(' && opening != ']') { + // A plain version rejects exactly itself. + return isTheFloor(selector); + } + int comma = selector.indexOf(','); + if (comma < 0) { + // [1.8.0] is an exact version written as a range. + return isTheFloor(selector.substring(1, + Math.max(1, selector.length() - 1)).trim()); + } + char closing = selector.charAt(selector.length() - 1); + boolean excludesLower = opening == '(' || opening == ']'; + boolean excludesUpper = closing == ')' || closing == '['; + String lower = selector.substring(1, comma).trim(); + if (lower.length() != 0) { + int compared = compareVersions(lower, MERGED_STDLIB_FLOOR); + if (compared > 0 || (compared == 0 && excludesLower)) { + return false; + } + } + String upper = selector.substring(comma + 1, + Math.max(comma + 1, selector.length() - 1)).trim(); + if (upper.length() != 0) { + int compared = compareVersions(upper, MERGED_STDLIB_FLOOR); + if (compared < 0 || (compared == 0 && excludesUpper)) { + return false; + } + } + return true; + } + /** - * Whether a rejected selector removes the floor and everything after it. + * Whether one rejection selector removes every version PAST the floor. * - *

Only an open-ended range starting at or below the floor does: - * {@code [1.8.0,)} leaves nothing for the constraint to resolve to, while - * {@code [1.9.0,)} still leaves 1.8.x and an exact {@code 1.7.0} removes a - * version the constraint was never going to select anyway.

+ *

Only an open-ended range can. A bounded one always leaves whatever is + * past its ceiling, and no number of bounded ranges covers an unbounded + * tail, so there is nothing here for several of them to do jointly.

*/ - private static boolean rejectionLeavesNothingAtTheFloor(String selector) { - String rejection = selector.trim(); - if (rejection.length() == 0) { + private static boolean rejectionRemovesPastTheFloor(String selector) { + if (selector.length() == 0) { return false; } - char opening = rejection.charAt(0); + char opening = selector.charAt(0); if (opening != '[' && opening != '(' && opening != ']') { return false; } - int comma = rejection.indexOf(','); + int comma = selector.indexOf(','); if (comma < 0) { return false; } - String upper = rejection.substring(comma + 1, - Math.max(comma + 1, rejection.length() - 1)).trim(); + String upper = selector.substring(comma + 1, + Math.max(comma + 1, selector.length() - 1)).trim(); if (upper.length() != 0) { - // Bounded above, so something at or past the floor survives it. + // Bounded above, so something past the floor survives it. return false; } - String lower = rejection.substring(1, comma).trim(); - if (lower.length() == 0) { - return true; - } - int compared = compareVersions(lower, MERGED_STDLIB_FLOOR); - // An exclusive lower bound does not reject the bound itself, so - // `(1.8.0,)` leaves exactly the floor selectable and the constraint has - // somewhere to land; `[1.8.0,)` does not. - boolean excludesItsOwnBound = opening == '(' || opening == ']'; - return excludesItsOwnBound ? compared < 0 : compared <= 0; + String lower = selector.substring(1, comma).trim(); + // Whether its own bound is included does not matter here: the question is + // what is left ABOVE the floor, and both `[1.8.0,)` and `(1.8.0,)` take + // all of that. + return lower.length() == 0 + || compareVersions(lower, MERGED_STDLIB_FLOOR) <= 0; } - /** Whether the statement rejects the floor and everything past it. */ - private static boolean rejectsTheFloor(String line) { - if (callsNamed(line, "rejectAll")) { - return true; - } - String rejected = versionInCall(line, "reject"); - return rejected != null && rejectionLeavesNothingAtTheFloor(rejected); + /** Whether a plain version literal IS the floor, prerelease and all. */ + private static boolean isTheFloor(String version) { + return version.length() != 0 + && compareVersions(version, MERGED_STDLIB_FLOOR) == 0 + && !literalBelowTheFloor(version); } /** Whether a range excludes every version at or above the floor. */ @@ -2288,6 +2386,7 @@ private static void updateLiteralDefinitions(String statement, } int i = 0; boolean declared = false; + boolean subscript = false; // Outside literals, like every other question about syntax. An unrestricted // search found `def` inside quoted prose -- println "def dep = '...'" -- and // recorded a declaration that never executes, overwriting the real binding @@ -2368,15 +2467,43 @@ && isIdentifierChar(statement.charAt(scan + 1))))) { // ext.kotlinVersion = '1.9.22' -- Gradle's extra properties, which is // how a project-wide version is nearly always written, and which // really does bind the bare name the interpolation then reads. - // Restricted to that one prefix on purpose: recording ANY dotted + // Restricted to that one owner on purpose: recording ANY dotted // assignment would let `somePlugin.version = '1.0'` supply the value // for an unrelated $version and turn an unreadable version into a // confidently wrong one, which is the direction that under-suppresses. String only = statement.substring(lastTokenStart, lastTokenEnd); int dot = only.lastIndexOf('.'); - if (dot > 0 && EXTRA_PROPERTIES.equals(only.substring(0, dot))) { + int openBracket = skipBlanks(statement, lastTokenEnd); + boolean subscripted = openBracket < statement.length() + && statement.charAt(openBracket) == '['; + // The owner is the LAST segment of the qualifier, not the whole of + // it, because the extension is reachable through the project too: + // `project.ext.dep` and `rootProject.ext.dep` set the same property + // the bare name goes on to read -- Gradle resolves a bare name up + // the project hierarchy -- and comparing the prefix whole rejected + // both, so a strict pre-merge pin held in one was never seen. + // Addressing ANOTHER project cannot arrive here: `(` ends the token + // walk above, so `project(':lib').ext.dep` never reads as one token + // and the chain is always this script's own. + if (dot > 0 && !subscripted + && lastSegmentIs(only.substring(0, dot), EXTRA_PROPERTIES)) { declared = true; i = lastTokenStart + dot + 1; + } else if (subscripted && lastSegmentIs(only, EXTRA_PROPERTIES)) { + // ext['dep'] = '...' is the subscript spelling of the same + // extension and the only one whose property name is a string + // rather than an identifier, so the walk above read `ext` as the + // name and recorded nothing the app could later refer to. Step + // inside the quote and let the identifier scan below take the + // name; the closing quote and bracket are stepped over after it. + int nameAt = skipBlanks(statement, openBracket + 1); + if (nameAt < statement.length() + && isLiteralStart(statement, nameAt) + && delimiterLength(statement, nameAt) == 1) { + declared = true; + subscript = true; + i = nameAt + 1; + } } } } @@ -2388,6 +2515,17 @@ && isIdentifierChar(statement.charAt(scan + 1))))) { return; } String name = statement.substring(nameStart, i); + if (subscript) { + // Past the `']` the subscript form puts between the name and the `=`, + // so the value is read the same way every other definition's is. + if (i < statement.length() && !isIdentifierChar(statement.charAt(i))) { + i++; + } + i = skipBlanks(statement, i); + if (i < statement.length() && statement.charAt(i) == ']') { + i++; + } + } if (!declared && !literals.containsKey(name)) { return; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 15b70871b37..473b5429704 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -644,6 +644,135 @@ public void aMapKeyMayBeQuoted() { "the merged-era declaration is read, got <<" + modern + ">>"); } + /** + * strictly, require and useVersion SET the constraint rather than adding to + * it, so a closure that calls one twice keeps the last value. Reading the + * first wrote the shim constraints beside a pin that was really pre-merge. + */ + @Test + public void theLastCallOfARepeatedSetterIsTheOneThatCounts() { + String[] spellings = { + " version { strictly '1.9.22'; strictly '1.7.22' }\n", + " version {\n strictly '1.9.22'\n" + + " strictly '1.7.22'\n }\n", + " version { require '1.9.22'; require '1.7.22!!' }\n", + }; + for (int i = 0; i < spellings.length; i++) { + String declaration = " implementation(" + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8') {\n" + + spellings[i] + " }\n"; + check("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", declaration)), + "the last value stands the block down, in <<" + spellings[i] + ">>"); + } + + // And the other way round, so this is the last value rather than the + // lowest: set back UP to a merged-era version the sibling is still raised. + String raised = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') {\n" + + " version { strictly '1.7.22'; strictly '1.9.22' }\n }\n"); + check(raised.contains("kotlin-stdlib-jdk7:1.8.0") + && !raised.contains("kotlin-stdlib-jdk8:1.8.0"), + "and the last value is read even raising, got <<" + raised + ">>"); + } + + /** + * Rejections accumulate -- reject takes varargs and may be called again -- and + * Gradle applies every selector. Asked one at a time, two that jointly leave + * the constraint nothing to resolve to each looked harmless. + */ + @Test + public void rejectionsAreCombinedBeforeTheFloorIsCalledReachable() { + String[] jointlyFatal = { + "reject '1.8.0', '(1.8.0,)'", + "reject('1.8.0', '(1.8.0,)')", + "reject '[1.8.0]', '(1.8.0,)'", + "reject '1.8.0'\n reject '(1.8.0,)'", + }; + for (int i = 0; i < jointlyFatal.length; i++) { + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + rejecting(jointlyFatal[i]))), + "<<" + jointlyFatal[i] + ">> leaves nothing at the floor"); + } + + // Each half alone leaves the constraint somewhere to land, and so do + // rejections that never reach the floor -- reading any of these as + // management would leave the duplicate this block exists to prevent. + String[] survivable = { + "reject '1.8.0'", + "reject '(1.8.0,)'", + "reject '[1.9.0,)'", + "reject '[1.7.0,1.9.0]'", + "reject '[1.7.0,1.8.5]', '[1.8.6,1.9.0]'", + "reject '1.7.0'", + // A prerelease of the floor is a different version from the floor. + "reject '1.8.0-RC2', '(1.8.0,)'", + }; + for (int i = 0; i < survivable.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", + rejecting(survivable[i])) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + survivable[i] + ">> still leaves the floor selectable"); + } + } + + /** A jdk8 declaration whose rich version requires anything and rejects this. */ + private static String rejecting(String rejections) { + return " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') {\n" + + " version { require '1.+'; " + rejections + " }\n }\n"; + } + + /** + * The extra properties extension is reachable through the project, and its + * property may be subscripted rather than dotted. Both spellings set the + * property the bare name goes on to read, and neither was recorded, so a + * strict pre-merge pin held in one was emitted straight over. + */ + @Test + public void anExtraPropertyIsFoundThroughEverySpellingOfIt() { + String pin = "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'"; + String use = " implementation(dep) { version { strictly '1.7.22' } }\n"; + String[] definitions = { + " ext.dep = " + pin + "\n", + " project.ext.dep = " + pin + "\n", + " rootProject.ext.dep = " + pin + "\n", + " ext['dep'] = " + pin + "\n", + " ext[\"dep\"] = " + pin + "\n", + " project.ext['dep'] = " + pin + "\n", + }; + for (int i = 0; i < definitions.length; i++) { + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + definitions[i] + use)), + "the pin in <<" + definitions[i].trim() + ">> stands the block down"); + } + + // The owner still has to BE the extension: a property of anything else + // does not bind the bare name, and reading one as though it did is how an + // unreadable version becomes a confidently wrong one. + String[] strangers = { + " somePlugin.dep = " + pin + "\n", + " extras.dep = " + pin + "\n", + " myext.dep = " + pin + "\n", + " notext['dep'] = " + pin + "\n", + }; + for (int i = 0; i < strangers.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", + strangers[i] + use).contains("kotlin-stdlib-jdk8:1.8.0"), + "<<" + strangers[i].trim() + ">> does not bind dep"); + } + + // And a merged-era coordinate held the same way is still read as a + // declaration, so the artifact it names is left alone and its sibling is + // the only one raised. + String merged = KotlinStdlibAlignment.constraintsBlock("implementation", + " project.ext['dep'] = " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n" + + " implementation(dep)\n"); + check(merged.contains("kotlin-stdlib-jdk7:1.8.0") + && !merged.contains("kotlin-stdlib-jdk8:1.8.0"), + "the subscripted declaration is read, got <<" + merged + ">>"); + } + /** * A stored map goes through the same expansion a stored string does, so a * version interpolated into it carries the version rather than the text of From 939aa0c0769097acb3c1d4f0e8a969cce7590cd5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:33:30 +0300 Subject: [PATCH 58/94] A type is a type however it is spelled, and a rejected floor is gone The walk that separates a declaration from an assignment stopped at the first character that is not part of an identifier, so `Map dep = [...]` ended the statement at `Map` and dep was never recorded -- the strict pre-merge pin it held was then invisible and the constraints went in beside it. Type arguments are consumed as part of the type now, and so are array dimensions, which were broken the same way and for the same reason. Only a balanced argument list of identifiers and only an EMPTY bracket pair qualify, so a comparison is still a comparison and `ext['dep']` still names a property. The rejection rule is corrected rather than extended: what this block writes is a constraint on exactly 1.8.0, so the floor is the only version it can resolve to and a rejection that removes the floor stands it down whether or not higher versions survive. Requiring that everything past the floor be gone too was reasoning about a range this never emits. That makes `reject '1.8.0'` and `reject '[1.7.0,1.9.0]'` management, and drops the second half of the predicate entirely; an exclusive bound still does not reject the bound itself, so `(1.8.0,)` and `[1.7.0,1.8.0)` leave it alone. Reading every selector, added last round, is what lets the removing one be found when it is written second. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 119 +++++++++++------- .../builders/KotlinStdlibAlignmentTest.java | 93 +++++++++++--- 2 files changed, 144 insertions(+), 68 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 143ecb3b454..54f92dcd29f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -940,6 +940,37 @@ private static boolean belowTheFloor(String version) { return literalBelowTheFloor(selector); } + /** + * The index just past a type-argument list starting at {@code at}, or + * {@code at} when there is not one there. + * + *

Only identifiers, dots, commas, wildcards and array brackets may + * appear inside, and the angle brackets have to balance. A `<` that is + * really the comparison operator fails both tests, so it is left where it + * is rather than swallowing the rest of the statement.

+ */ + private static int endOfTypeArguments(String statement, int at) { + if (at >= statement.length() || statement.charAt(at) != '<') { + return at; + } + int depth = 0; + for (int i = at; i < statement.length(); i++) { + char c = statement.charAt(i); + if (c == '<') { + depth++; + } else if (c == '>') { + depth--; + if (depth == 0) { + return i + 1; + } + } else if (!isIdentifierChar(c) && c != '.' && c != ',' && c != '?' + && c != '[' && c != ']' && !Character.isWhitespace(c)) { + return at; + } + } + return at; + } + /** * Whether a dotted Gradle path ends in the given segment. * @@ -979,24 +1010,26 @@ private static boolean rejectsTheFloor(String line) { if (callsNamed(line, "rejectAll")) { return true; } - // Rejections ACCUMULATE, and Gradle applies every one of them. Asked one - // selector at a time, `reject '1.8.0', '(1.8.0,)'` looked harmless twice - // over -- the exact rejection still leaves 1.8.1, the open range still - // leaves 1.8.0 -- while together they leave the constraint nothing to - // resolve to at all, and the block was written into a graph that could not - // resolve it. So the question is split in two and each half is asked of the - // whole list: something has to remove the floor itself, and something has - // to remove everything past it. + // Rejections ACCUMULATE -- reject takes varargs and may be called again -- + // and Gradle applies every one of them, so every selector is asked, not + // just the first. Read one at a time, a pair that jointly removes the floor + // looked harmless twice over. + // + // The question asked of each is only whether it removes the floor ITSELF. + // It once also demanded that everything past the floor be gone, on the + // reasoning that a higher version was still selectable -- but what this + // block writes is a constraint on exactly 1.8.0, so the floor is the only + // version whose availability it depends on. An app that rejects 1.8.0 has + // said it does not want the version this pins to, which is the whole + // signal this scan exists to read, and writing the constraint anyway asks + // its graph to resolve to a version it excluded. List rejected = versionsInCall(line, "reject"); - boolean floorRemoved = false; - boolean pastTheFloorRemoved = false; for (int i = 0; i < rejected.size(); i++) { - String selector = rejected.get(i).trim(); - floorRemoved = floorRemoved || rejectionRemovesTheFloor(selector); - pastTheFloorRemoved = pastTheFloorRemoved - || rejectionRemovesPastTheFloor(selector); + if (rejectionRemovesTheFloor(rejected.get(i).trim())) { + return true; + } } - return floorRemoved && pastTheFloorRemoved; + return false; } /** @@ -1041,39 +1074,6 @@ private static boolean rejectionRemovesTheFloor(String selector) { return true; } - /** - * Whether one rejection selector removes every version PAST the floor. - * - *

Only an open-ended range can. A bounded one always leaves whatever is - * past its ceiling, and no number of bounded ranges covers an unbounded - * tail, so there is nothing here for several of them to do jointly.

- */ - private static boolean rejectionRemovesPastTheFloor(String selector) { - if (selector.length() == 0) { - return false; - } - char opening = selector.charAt(0); - if (opening != '[' && opening != '(' && opening != ']') { - return false; - } - int comma = selector.indexOf(','); - if (comma < 0) { - return false; - } - String upper = selector.substring(comma + 1, - Math.max(comma + 1, selector.length() - 1)).trim(); - if (upper.length() != 0) { - // Bounded above, so something past the floor survives it. - return false; - } - String lower = selector.substring(1, comma).trim(); - // Whether its own bound is included does not matter here: the question is - // what is left ABOVE the floor, and both `[1.8.0,)` and `(1.8.0,)` take - // all of that. - return lower.length() == 0 - || compareVersions(lower, MERGED_STDLIB_FLOOR) <= 0; - } - /** Whether a plain version literal IS the floor, prerelease and all. */ private static boolean isTheFloor(String version) { return version.length() != 0 @@ -2457,6 +2457,29 @@ && isIdentifierChar(statement.charAt(i + 1))))) { && isIdentifierChar(statement.charAt(scan + 1))))) { scan++; } + // A type's arguments belong to the type: `Map dep` + // is a declaration whose type happens to be generic, and stopping at + // the `<` read `Map` as the whole statement -- so dep was never + // recorded and the strict pin the map held was never seen. + int generics = endOfTypeArguments(statement, scan); + if (generics > scan) { + scan = generics; + } + // And its dimensions, for the same reason: `String[] dep` is a + // declaration too. Only an EMPTY pair, which nothing but an array + // type is -- a subscript with something in it is `ext['dep']` or + // `deps[0]`, and swallowing those would take the name with them. + while (true) { + int empty = skipBlanks(statement, scan); + if (empty >= statement.length() || statement.charAt(empty) != '[') { + break; + } + int close = skipBlanks(statement, empty + 1); + if (close >= statement.length() || statement.charAt(close) != ']') { + break; + } + scan = close + 1; + } lastTokenEnd = scan; scan = skipBlanks(statement, scan); } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 473b5429704..68a86d06f01 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -678,41 +678,52 @@ public void theLastCallOfARepeatedSetterIsTheOneThatCounts() { /** * Rejections accumulate -- reject takes varargs and may be called again -- and - * Gradle applies every selector. Asked one at a time, two that jointly leave - * the constraint nothing to resolve to each looked harmless. + * Gradle applies every selector, so every one is asked whether it removes the + * floor. Reading only the first missed a pair whose second selector was the + * one that removed it. */ @Test public void rejectionsAreCombinedBeforeTheFloorIsCalledReachable() { - String[] jointlyFatal = { - "reject '1.8.0', '(1.8.0,)'", + // What this block writes is a constraint on exactly 1.8.0, so a rejection + // that removes the floor removes the only version it can resolve to -- + // whether or not it leaves higher ones. Written second, the selector that + // removes it was not being read at all. + String[] removeTheFloor = { + "reject '1.8.0'", + "reject '[1.8.0]'", + "reject '(1.8.0,)', '1.8.0'", "reject('1.8.0', '(1.8.0,)')", - "reject '[1.8.0]', '(1.8.0,)'", + "reject '[1.8.0,)'", + "reject '(1.7.0,)'", + "reject '[1.7.0,1.9.0]'", + "reject '[1.7.0,1.8.0]'", + "reject '[1.7.0,1.8.5]', '[1.8.6,1.9.0]'", "reject '1.8.0'\n reject '(1.8.0,)'", }; - for (int i = 0; i < jointlyFatal.length; i++) { + for (int i = 0; i < removeTheFloor.length; i++) { check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - rejecting(jointlyFatal[i]))), - "<<" + jointlyFatal[i] + ">> leaves nothing at the floor"); + rejecting(removeTheFloor[i]))), + "<<" + removeTheFloor[i] + ">> takes the floor away"); } - // Each half alone leaves the constraint somewhere to land, and so do - // rejections that never reach the floor -- reading any of these as - // management would leave the duplicate this block exists to prevent. - String[] survivable = { - "reject '1.8.0'", + // And these leave it exactly where the constraint needs it. Reading any of + // them as management would leave the duplicate this block exists to + // prevent -- an exclusive bound does not reject the bound itself. + String[] leaveTheFloor = { "reject '(1.8.0,)'", "reject '[1.9.0,)'", - "reject '[1.7.0,1.9.0]'", - "reject '[1.7.0,1.8.5]', '[1.8.6,1.9.0]'", + "reject '[1.7.0,1.8.0)'", + "reject '(1.8.0,1.9.0]'", + "reject '[1.7.0,1.7.9]', '[1.8.1,1.9.0]'", "reject '1.7.0'", // A prerelease of the floor is a different version from the floor. "reject '1.8.0-RC2', '(1.8.0,)'", }; - for (int i = 0; i < survivable.length; i++) { + for (int i = 0; i < leaveTheFloor.length; i++) { check(KotlinStdlibAlignment.constraintsBlock("implementation", - rejecting(survivable[i])) + rejecting(leaveTheFloor[i])) .contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + survivable[i] + ">> still leaves the floor selectable"); + "<<" + leaveTheFloor[i] + ">> still leaves the floor selectable"); } } @@ -722,6 +733,45 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * A type is a type however it is spelled. The walk that separates a + * declaration from an assignment stopped at the first character that is not + * part of an identifier, so a generic or array type ended the statement and + * the name it declared -- and the pin that name held -- was never recorded. + */ + @Test + public void aTypeMayBeGenericOrAnArray() { + String map = "[group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib-jdk8', version: '1.7.22']"; + String use = " implementation(dep) { version { strictly '1.7.22' } }\n"; + String[] types = { + "def", "String", "Map", "Map", "HashMap", + "java.util.Map", "Map", + "List>", "final Map", "String[]", + "Map", "String[][]", + }; + for (int i = 0; i < types.length; i++) { + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " " + types[i] + " dep = " + map + "\n" + use)), + "<<" + types[i] + ">> declares dep"); + } + + // The angle bracket really has to be a type argument list. A comparison is + // not one, and swallowing it would take the rest of the statement with it; + // neither is a subscript with something in it, which is how an extra + // property is named. + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " if (someVersion < 5) { }\n" + + " implementation('org.jetbrains.kotlin:" + + "kotlin-stdlib-jdk8:1.9.22')\n") + .contains("kotlin-stdlib-jdk7:1.8.0"), + "a comparison is not a type argument list"); + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " ext['dep'] = 'org.jetbrains.kotlin:" + + "kotlin-stdlib-jdk8:1.7.22'\n" + use)), + "and a subscript with a name in it still names a property"); + } + /** * The extra properties extension is reachable through the project, and its * property may be subscripted rather than dotted. Both spellings set the @@ -890,7 +940,7 @@ public void aBuildscriptStrategyIsNotTheAppsGraph() { */ @Test public void aRejectionCountsOnlyWhenItReachesTheFloor() { - String[] closing = {"reject '[1.8.0,)'", "rejectAll()"}; + String[] closing = {"reject '[1.8.0,)'", "rejectAll()", "reject '(,1.8.0]'"}; for (int i = 0; i < closing.length; i++) { String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " @@ -899,7 +949,10 @@ public void aRejectionCountsOnlyWhenItReachesTheFloor() { closing[i] + " leaves nothing at the floor, got <<" + out + ">>"); } - String[] leaving = {"reject '1.7.0'", "reject '[1.9.0,)'", "reject '(,1.8.0]'"}; + // `(,1.8.0]` was once here, on the reasoning that it leaves 1.8.1 -- but + // it INCLUDES the floor, and the floor is the only version a constraint + // written at exactly 1.8.0 can resolve to. + String[] leaving = {"reject '1.7.0'", "reject '[1.9.0,)'", "reject '(,1.8.0)'"}; for (int i = 0; i < leaving.length; i++) { String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " From 8bbd20cb39e1d93b86d5296b9fb0b0110e89a495 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:57:23 +0300 Subject: [PATCH 59/94] Tell the plugin classpath from the application graph A buildscript block configures which plugin jars load. That resolution is separate from the app's and cannot conflict with anything written into dependencies { }, so a force, a strict pin or a shim declaration in there was never the app managing the family -- reading it as one left an app graph carrying a pre-merge shim unaligned and still failing checkDuplicateClasses. Those statements are blanked before any scan runs, and `configurations.classpath` goes the same way: it is the spelling that works at the top level, where no buildscript block says it, and it had been honoured by the failOnVersionConflict scan alone while a force on it still stood the whole block down. Blanking happens only AFTER definitions are recorded, which exposed the defect underneath: an extra property is not block scoped. Recorded at the depth of the brace it sat in, `buildscript { ext.kotlin_version = '..' }` -- the standard shape of a Kotlin Android script -- was discarded at the closing brace, so every version interpolated below read as unreadable, counted as below the floor, and stood the block down. The alignment never ran for the commonest project layout there is. A local still leaves with its block; only a property that Gradle really does bind script-wide does not. `add` is an ordinary method name, so it now has to be called on a dependency handler. `catalog.add('implementation', '...')` declares nothing, and reading it as a declaration skipped the constraint for an artifact that was never in the graph. A declaration may also share its statement with the block opener that guards it -- `if (cond) { String dep = '...' }`. The walk began at `if` and stopped at the parenthesis, so the pin behind it went unseen; the def spelling never had this because it is searched for anywhere in the statement. The brace that IS a value is excluded, so a closure assignment still reads as the declaration it is, and a reassignment the brace guards is still conditional -- the depth WITHIN the statement decides that now, where before only the depth it started at did. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 175 +++++++++++++++--- .../builders/KotlinStdlibAlignmentTest.java | 147 +++++++++++++++ 2 files changed, 297 insertions(+), 25 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 54f92dcd29f..961e76cf66a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -223,19 +223,10 @@ public static String constraintsBlock(String configuration, if (!callsNamed(active[i], "failOnVersionConflict")) { continue; } - // Applied to configurations.classpath it governs the PLUGIN classpath and - // nothing this block writes: the constraints go into the app's - // dependencies, which that strategy never sees, so standing down there - // would leave a real duplicate unfixed for a setting that cannot conflict - // with us. - // - // Only that spelling, because it names the configuration outright. - // `buildscript { configurations.all { ... } }` is also plugin-only and - // still stands the block down: knowing that needs the surrounding block, - // and being wrong in the other direction breaks a build that works. - if (namesTheBuildscriptClasspath(active[i])) { - continue; - } + // A statement that governs the plugin classpath never arrives here: + // both spellings of it -- a buildscript block and configurations + // .classpath -- are blanked with the rest of that graph before any + // scan runs. See governsThePluginClasspath. return ""; } // The two shims cannot be suppressed independently when the app holds one of @@ -971,6 +962,52 @@ private static int endOfTypeArguments(String statement, int at) { return at; } + /** + * Where a declaration may start in this statement. + * + *

A block opener shares the statement with what it opens -- + * {@code if (cond) { String dep = '...'}} -- and the walk that separates a + * declaration from an assignment begins at the first token, which there is + * {@code if}. It stopped at the parenthesis and the declaration behind it + * was never recorded, so the pin that declaration held went unseen. The + * {@code def} spelling never had this because it is searched for anywhere + * in the statement.

+ * + *

Only a brace BEFORE the assignment counts. In {@code Closure c = { .. }} + * the brace IS the value, and starting after it would skip the name being + * assigned to -- which is a declaration this already reads.

+ */ + private static int afterAnyBlockOpener(String statement) { + int start = 0; + for (int i = 0; i < statement.length(); i++) { + if (isLiteralStart(statement, i)) { + i = endOfStringLiteral(statement, i); + continue; + } + char c = statement.charAt(i); + if (c == '{') { + start = i + 1; + } else if (isAssignmentAt(statement, i)) { + break; + } + } + return start; + } + + /** Whether the character at {@code at} is an assignment, not a comparison. */ + private static boolean isAssignmentAt(String statement, int at) { + if (statement.charAt(at) != '=') { + return false; + } + if (at + 1 < statement.length() && statement.charAt(at + 1) == '=') { + return false; + } + // `>=`, `!=`, `+=` and the rest end in the same character and none of them + // opens a declaration, so a comparison in an `if` would otherwise stop the + // search before the brace it guards. + return at == 0 || "=!<>+-*/%&|^~".indexOf(statement.charAt(at - 1)) < 0; + } + /** * Whether a dotted Gradle path ends in the given segment. * @@ -983,11 +1020,18 @@ private static boolean lastSegmentIs(String path, String segment) { } /** - * Whether the statement applies its strategy to the plugin classpath. + * Whether the statement names the plugin classpath outright. * *

{@code configurations.classpath} is the buildscript's own, and a * strategy on it governs which plugin jars load -- never the app's - * dependencies, which is all this class writes to.

+ * dependencies, which is all this class writes to. It is the spelling that + * works at the top level, where there is no {@code buildscript} block + * around it to say the same thing.

+ * + *

Every scan sees the result, not just the one that reads + * {@code failOnVersionConflict}: a force or a strict pin on that + * configuration cannot conflict with these constraints either, and reading + * one as the app managing the family left a real duplicate unfixed.

*/ private static boolean namesTheBuildscriptClasspath(String line) { int at = line.indexOf(BUILDSCRIPT_CLASSPATH); @@ -1915,8 +1959,37 @@ private static boolean isAddCallArgument(String line, int quoteAt) { if (i >= 0 && line.charAt(i) == '(') { i = skipBlanksBackward(line, i - 1); } - return i >= 2 && "add".equals(line.substring(i - 2, i + 1)) - && (i - 3 < 0 || !isIdentifierChar(line.charAt(i - 3))); + if (i < 2 || !"add".equals(line.substring(i - 2, i + 1)) + || (i - 3 >= 0 && isIdentifierChar(line.charAt(i - 3)))) { + return false; + } + // And the receiver has to BE a dependency handler. `add` is an ordinary + // method name -- `catalog.add('implementation', '...')` adds to a version + // catalog and declares nothing -- so reading one as a declaration skipped + // the constraint for an artifact the app had never put in its graph, and + // an old transitive shim beside a merged stdlib stayed unaligned. + // + // A bare `add` is the shorthand inside a dependencies closure and has no + // receiver to check; only a qualified one does. + if (i - 3 < 0) { + return true; + } + int dot = skipBlanksBackward(line, i - 3); + if (dot < 0 || line.charAt(dot) != '.') { + return true; + } + int end = skipBlanksBackward(line, dot - 1); + if (end < 0) { + return false; + } + int start = end; + while (start >= 0 && (isIdentifierChar(line.charAt(start)) + || (line.charAt(start) == '.' && start > 0 + && isIdentifierChar(line.charAt(start - 1))))) { + start--; + } + return end > start + && lastSegmentIs(line.substring(start + 1, end + 1), "dependencies"); } /** @@ -2224,11 +2297,33 @@ private static List inlineLiteralDefinitions(List statements) { // keeps working. int braceDepth = 0; ScopedNames scope = new ScopedNames(); + // A buildscript block configures the PLUGIN classpath, which is a separate + // resolution from the app's and cannot conflict with what this writes into + // dependencies { }. A force, a strict pin or a shim declaration in there was + // being read as the app managing the family, so an app graph carrying a + // pre-merge shim was left unaligned and still failed checkDuplicateClasses. + // + // The statements are blanked rather than dropped, and only AFTER their + // definitions have been recorded: `buildscript { ext.kotlin_version = .. }` + // followed by a dependency interpolating $kotlin_version is the ordinary + // shape of a Kotlin project, and the definition really does bind + // script-wide even though the declarations around it do not. + int buildscriptDepth = 0; for (int i = 0; i < statements.size(); i++) { String statement = statements.get(i); - out.add(literals.isEmpty() + boolean opensBuildscript = buildscriptDepth == 0 + && opensBlockNamed(statement, BUILDSCRIPT); + boolean pluginScoped = buildscriptDepth > 0 || opensBuildscript + || namesTheBuildscriptClasspath(statement); + out.add(pluginScoped ? "" : (literals.isEmpty() ? statement - : withLiteralsInlined(statement, literals)); + : withLiteralsInlined(statement, literals))); + if (pluginScoped) { + buildscriptDepth += braceBalance(statement); + if (buildscriptDepth < 0) { + buildscriptDepth = 0; + } + } boolean opensExt = extDepth == 0 && opensAnExtraPropertiesBlock(statement); updateLiteralDefinitions(statement, literals, extDepth > 0 || opensExt, braceDepth > 0, braceDepth, scope); @@ -2303,9 +2398,14 @@ private static void recordDefinition(Map literals, String name, * token so that a dependency on {@code com.example:extras} does not. */ private static boolean opensAnExtraPropertiesBlock(String statement) { - int at = statement.indexOf(EXTRA_PROPERTIES); + return opensBlockNamed(statement, EXTRA_PROPERTIES); + } + + /** Whether the statement opens a block named {@code name}. */ + private static boolean opensBlockNamed(String statement, String name) { + int at = statement.indexOf(name); while (at >= 0) { - int after = at + EXTRA_PROPERTIES.length(); + int after = at + name.length(); boolean startsToken = at == 0 || !isIdentifierChar(statement.charAt(at - 1)); int brace = skipBlanks(statement, after); if (startsToken && (after >= statement.length() @@ -2313,7 +2413,7 @@ private static boolean opensAnExtraPropertiesBlock(String statement) { && brace < statement.length() && statement.charAt(brace) == '{') { return true; } - at = statement.indexOf(EXTRA_PROPERTIES, at + 1); + at = statement.indexOf(name, at + 1); } return false; } @@ -2387,6 +2487,15 @@ private static void updateLiteralDefinitions(String statement, int i = 0; boolean declared = false; boolean subscript = false; + // An extra property is NOT block scoped. A local declared inside a block + // leaves with it, which is why declarations carry their depth -- but + // `buildscript { ext.kotlin_version = '1.9.22' }` sets a project-wide + // property, and it is the standard shape of a Kotlin Android script. + // Recorded at the depth of the brace it sat in, it was discarded at the + // closing brace, so the version every dependency below interpolated read + // as unreadable, counted as below the floor, and stood the whole block + // down -- the alignment never ran for the commonest project there is. + boolean extraProperty = false; // Outside literals, like every other question about syntax. An unrestricted // search found `def` inside quoted prose -- println "def dep = '...'" -- and // recorded a declaration that never executes, overwriting the real binding @@ -2396,7 +2505,7 @@ private static void updateLiteralDefinitions(String statement, declared = true; i = skipBlanks(statement, at); } else { - i = skipBlanks(statement, 0); + i = skipBlanks(statement, afterAnyBlockOpener(statement)); // Past any annotations first. A script field is written // `@groovy.transform.Field String dep = '...'`, and the walk below reads // identifier tokens -- so it stopped dead on the `@`, recorded nothing, @@ -2511,6 +2620,7 @@ && isIdentifierChar(statement.charAt(scan + 1))))) { if (dot > 0 && !subscripted && lastSegmentIs(only.substring(0, dot), EXTRA_PROPERTIES)) { declared = true; + extraProperty = true; i = lastTokenStart + dot + 1; } else if (subscripted && lastSegmentIs(only, EXTRA_PROPERTIES)) { // ext['dep'] = '...' is the subscript spelling of the same @@ -2525,6 +2635,7 @@ && isLiteralStart(statement, nameAt) && delimiterLength(statement, nameAt) == 1) { declared = true; subscript = true; + extraProperty = true; i = nameAt + 1; } } @@ -2552,8 +2663,18 @@ && delimiterLength(statement, nameAt) == 1) { if (!declared && !literals.containsKey(name)) { return; } + // A brace this statement opened before the name guards it just as one on an + // earlier line does. The flag arriving here is the depth the statement + // STARTED at, so `if (cond) { dep = '...' }` written on one line read as an + // unconditional reassignment and threw away the coordinate the condition + // might never replace -- which is the pin, hidden, that this whole rule + // exists to keep. + if (depthAt(statement, nameStart, depth) > depth) { + conditional = true; + } if (declared) { - scope.declared(depthAt(statement, nameStart, depth), name, literals); + scope.declared(extraProperty + ? 0 : depthAt(statement, nameStart, depth), name, literals); } i = skipBlanks(statement, i); if (i >= statement.length() || statement.charAt(i) != '=' @@ -2625,7 +2746,8 @@ && isIdentifierChar(statement.charAt(nextEnd))) { return; } if (declared) { - scope.declared(depthAt(statement, nextName, depth), name, literals); + scope.declared(extraProperty + ? 0 : depthAt(statement, nextName, depth), name, literals); } i = assign; } @@ -2786,6 +2908,9 @@ private static void recordBareAssignment(String body, Map litera /** Gradle's extra-properties prefix, the one dotted assignment worth reading. */ private static final String EXTRA_PROPERTIES = "ext"; + /** The block that configures the plugin classpath rather than the app's. */ + private static final String BUILDSCRIPT = "buildscript"; + /** * Whether the text so far is a control header whose body is the next line. * diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 68a86d06f01..daa9831861b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -733,6 +733,153 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * {@code add} is an ordinary method name. Reading any call of it as a + * dependency declaration let an unrelated API -- a version catalog, a list -- + * claim an artifact the app had never put in its graph, and the constraint + * that artifact needed was skipped as already handled. + */ + @Test + public void anAddCallMustBeOnADependencyHandler() { + String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22"; + String[] handlers = { + " dependencies.add('implementation', '" + pin + "')\n", + " project.dependencies.add('implementation', '" + pin + "')\n", + " dependencies {\n add 'implementation', '" + pin + "'\n }\n", + }; + for (int i = 0; i < handlers.length; i++) { + check("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", handlers[i])), + "<<" + handlers[i].trim() + ">> declares a dependency"); + } + + String[] strangers = { + " catalog.add('implementation', '" + pin + "')\n", + " myList.add('implementation', '" + pin + "')\n", + " deps.add('implementation', '" + pin + "')\n", + }; + for (int i = 0; i < strangers.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", + strangers[i]).contains("kotlin-stdlib-jdk8:1.8.0"), + "<<" + strangers[i].trim() + ">> declares nothing"); + } + } + + /** + * A block opener shares the statement with what it opens. The walk that + * reads a typed declaration began at the first token -- {@code if} -- and + * stopped at its parenthesis, so the declaration behind it, and the pin that + * declaration held, were never recorded. + */ + @Test + public void aDeclarationMayFollowABlockOpenerOnTheSameStatement() { + String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22"; + String tail = " implementation(dep) { version { strictly '1.7.22' } } }\n"; + String[] openers = { + " if (true) { String dep = '" + pin + "';", + " if (a >= b) { String dep = '" + pin + "';", + " if (true) { def dep = '" + pin + "';", + " if (a) { if (b) { Map m = [:]; String dep = '" + pin + "';", + }; + for (int i = 0; i < openers.length; i++) { + check("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", openers[i] + tail)), + "<<" + openers[i].trim() + ">> declares dep"); + } + + // The brace that IS the value must not be mistaken for one that opens a + // block, or the name being assigned to is skipped. + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " Closure c = { }\n String dep = '" + pin + "'\n" + + " implementation(dep) { version { strictly '1.7.22' } }\n")), + "a closure assignment is still a declaration"); + + // And a reassignment the brace GUARDS is still conditional, however it is + // spelled -- taking it unconditionally throws away the coordinate the + // condition may never replace. + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'; " + + "if (project.hasProperty('other')) " + + "{ dep = 'com.example:other:1.0' }; " + + "implementation(dep) { version { strictly '1.7.22' } }\n")), + "a one-line conditional reassignment stays conditional"); + } + + /** + * A {@code buildscript} block configures the plugin classpath. That is a + * separate resolution from the app's and cannot conflict with anything + * written into {@code dependencies { }}, so an override or a shim + * declaration there is not the app managing the family -- reading it as one + * left an app graph carrying a pre-merge shim unaligned. + */ + @Test + public void aBuildscriptBlockIsNotTheApplicationGraph() { + String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22"; + String[] pluginOnly = { + " buildscript { configurations.all { resolutionStrategy.force " + + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' } }\n", + " buildscript {\n dependencies {\n" + + " classpath '" + pin + "!!'\n }\n }\n", + " buildscript {\n dependencies {\n classpath('" + pin + + "') { version { strictly '1.7.22' } }\n }\n }\n", + // The spelling that names the configuration outright still counts + // wherever it is written, including outside a buildscript block. + " configurations.classpath.resolutionStrategy.force '" + pin + "'\n", + }; + for (int i = 0; i < pluginOnly.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", pluginOnly[i]) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + pluginOnly[i].trim() + ">> is the plugin's graph"); + } + + // The app's own declarations are unaffected, before or after one. + String after = KotlinStdlibAlignment.constraintsBlock("implementation", + " buildscript { dependencies { classpath " + + "'com.android.tools.build:gradle:8.1.0' } }\n" + + " dependencies { implementation '" + pin + "!!' }\n"); + check("".equals(after), "an app pin after a buildscript block still counts, " + + "got <<" + after + ">>"); + } + + /** + * An extra property is not block scoped, and + * {@code buildscript { ext.kotlin_version = '..' }} is how a Kotlin Android + * script is written. Discarded with the brace it sat in, the version every + * dependency below interpolated read as unreadable -- which counts as below + * the floor -- and the alignment never ran at all. + */ + @Test + public void anExtraPropertyOutlivesTheBlockItWasSetIn() { + String[] definitions = { + " buildscript {\n ext.kv = 'V'\n }\n", + " buildscript { ext.kv = 'V' }\n", + " buildscript {\n ext['kv'] = 'V'\n }\n", + " someBlock {\n ext.kv = 'V'\n }\n", + " ext.kv = 'V'\n", + " ext { kv = 'V' }\n", + }; + String use = " dependencies {\n implementation " + + "\"org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kv\"\n }\n"; + for (int i = 0; i < definitions.length; i++) { + String merged = KotlinStdlibAlignment.constraintsBlock("implementation", + definitions[i].replace("'V'", "'1.9.22'") + use); + check(merged.contains("kotlin-stdlib-jdk7:1.8.0") + && !merged.contains("kotlin-stdlib-jdk8:1.8.0"), + "<<" + definitions[i].trim() + ">> is readable below, got <<" + + merged + ">>"); + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + definitions[i].replace("'V'", "'1.7.22'") + use)), + "and a pre-merge one stands the block down"); + } + + // A local really is block scoped, and must not start outliving its block + // just because an extra property does. + String local = KotlinStdlibAlignment.constraintsBlock("implementation", + " someBlock {\n def kv = '1.9.22'\n }\n" + use); + check("".equals(local), + "a local does not escape its block, got <<" + local + ">>"); + } + /** * A type is a type however it is spelled. The walk that separates a * declaration from an assignment stopped at the first character that is not From 0462194568596897627fcfeffbead6d3d78b9547 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:14:39 +0300 Subject: [PATCH 60/94] Quoted syntax is not syntax, and an enforced BOM is not an ordinary one Routing the plugin-classpath check into the blanking path widened what a raw substring search could reach: a declaration whose reason merely contained the words -- because 'match configurations.classpath' -- was classified as plugin-scoped and blanked before anything read the strict pin it carried, so the constraints went in against it. Both whole-string searches now scan outside literals, like every other question about syntax here: the block-opener check had the same defect, where a quoted `buildscript {` or `ext {` put every statement after it in a scope it was never in. Not the contains(KOTLIN_GROUP) that decides whether a statement absorbs its trailing closure -- there the group is inside a literal by construction, because that is where a coordinate lives. An enforced Kotlin platform now stands the block down. The class comment records why a plain platform() does not, and the reasoning is measured and still holds: a BOM's constraints are ordinary, so the higher version wins and these are harmless beside it. enforcedPlatform is the other thing -- Gradle turns the same managed versions into STRICT requirements -- so a pre-merge one pins the family at 1.7.x and a 1.8.0 requirement written beside it cannot resolve at all. A version it cannot read counts as below the floor, the same way a declaration's does. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 75 +++++++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 95 +++++++++++++++++++ 2 files changed, 164 insertions(+), 6 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 961e76cf66a..ef6dfe19510 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -220,6 +220,16 @@ public static String constraintsBlock(String configuration, // written that would not conflict, so nothing is. String[] active = activeLines(combined(appGradleFragments)); for (int i = 0; i < active.length; i++) { + // An ENFORCED platform is the one case a Kotlin BOM stands this down. + // A plain `platform()` does not, and the class comment says why it was + // measured not to: a BOM's constraints are ordinary, so the higher + // version simply wins and these are harmless beside it. enforcedPlatform + // is the other thing -- Gradle turns the same versions into STRICT + // requirements -- so a pre-merge one strictly pins the family at 1.7.x + // and a 1.8.0 requirement written beside it cannot resolve at all. + if (namesAnEnforcedKotlinPlatformBelowTheFloor(active[i])) { + return ""; + } if (!callsNamed(active[i], "failOnVersionConflict")) { continue; } @@ -1019,6 +1029,42 @@ private static boolean lastSegmentIs(String path, String segment) { return segment.equals(path.substring(path.lastIndexOf('.') + 1)); } + /** + * Whether the statement enforces a Kotlin platform that cannot reach the + * floor. + * + *

A version this cannot read counts as below it, the same way a + * declaration's does: an enforced platform is strict by construction, so + * guessing that it is high enough is guessing that the constraints below + * will resolve.

+ */ + private static boolean namesAnEnforcedKotlinPlatformBelowTheFloor(String line) { + List enforced = versionsInCall(line, ENFORCED_PLATFORM); + for (int i = 0; i < enforced.size(); i++) { + String coordinate = enforced.get(i).trim(); + if (!coordinate.startsWith(KOTLIN_GROUP + ":")) { + continue; + } + int version = coordinate.indexOf(':', KOTLIN_GROUP.length() + 1); + if (version < 0) { + // No version at all, so nothing says it reaches the floor. + return true; + } + String declared = versionComponentOf(coordinate.substring(version + 1)); + if (declared.endsWith(STRICT_SUFFIX)) { + declared = declared.substring(0, + declared.length() - STRICT_SUFFIX.length()); + } + if (belowTheFloor(declared)) { + return true; + } + } + return false; + } + + /** The platform spelling whose managed versions become strict. */ + private static final String ENFORCED_PLATFORM = "enforcedPlatform"; + /** * Whether the statement names the plugin classpath outright. * @@ -1034,15 +1080,24 @@ private static boolean lastSegmentIs(String path, String segment) { * one as the app managing the family left a real duplicate unfixed.

*/ private static boolean namesTheBuildscriptClasspath(String line) { - int at = line.indexOf(BUILDSCRIPT_CLASSPATH); - while (at >= 0) { + // Outside literals, like every other question about syntax here. A raw + // search read the words in a reason -- because 'match configurations + // .classpath' -- as the configuration itself and blanked the declaration + // carrying them, strict pin and all, before anything could look at it. + for (int at = 0; at < line.length(); at++) { + if (isLiteralStart(line, at)) { + at = endOfStringLiteral(line, at); + continue; + } + if (!line.startsWith(BUILDSCRIPT_CLASSPATH, at)) { + continue; + } boolean startsToken = at == 0 || !isIdentifierChar(line.charAt(at - 1)); int after = at + BUILDSCRIPT_CLASSPATH.length(); if (startsToken && (after >= line.length() || !isIdentifierChar(line.charAt(after)))) { return true; } - at = line.indexOf(BUILDSCRIPT_CLASSPATH, at + 1); } return false; } @@ -2403,8 +2458,17 @@ private static boolean opensAnExtraPropertiesBlock(String statement) { /** Whether the statement opens a block named {@code name}. */ private static boolean opensBlockNamed(String statement, String name) { - int at = statement.indexOf(name); - while (at >= 0) { + // Outside literals, for the same reason the classpath check is: a block + // opener quoted in a reason opens nothing, and treating one as the real + // thing puts every statement after it in a scope it is not in. + for (int at = 0; at < statement.length(); at++) { + if (isLiteralStart(statement, at)) { + at = endOfStringLiteral(statement, at); + continue; + } + if (!statement.startsWith(name, at)) { + continue; + } int after = at + name.length(); boolean startsToken = at == 0 || !isIdentifierChar(statement.charAt(at - 1)); int brace = skipBlanks(statement, after); @@ -2413,7 +2477,6 @@ private static boolean opensBlockNamed(String statement, String name) { && brace < statement.length() && statement.charAt(brace) == '{') { return true; } - at = statement.indexOf(name, at + 1); } return false; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index daa9831861b..8f42270b26f 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -733,6 +733,101 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * An ENFORCED Kotlin platform is the one case a BOM stands the block down. + * The class comment records why a plain {@code platform()} does not -- its + * constraints are ordinary, so the higher version wins and these are + * harmless beside it -- and {@code enforcedPlatform} is the other thing: + * Gradle makes the same managed versions strict, so a pre-merge one pins + * the family at 1.7.x and a 1.8.0 requirement beside it cannot resolve. + */ + @Test + public void anEnforcedPreMergeKotlinPlatformManagesTheFamily() { + String[] enforced = { + " implementation(enforcedPlatform(" + + "'org.jetbrains.kotlin:kotlin-bom:1.7.22'))\n", + " implementation enforcedPlatform(" + + "'org.jetbrains.kotlin:kotlin-bom:1.7.22')\n", + " api(enforcedPlatform(\"org.jetbrains.kotlin:kotlin-bom:1.7.22\"))\n", + " def kv = '1.7.22'\n implementation(enforcedPlatform(" + + "\"org.jetbrains.kotlin:kotlin-bom:$kv\"))\n", + // A version this cannot read is not proof it reaches the floor, and a + // prerelease of the floor is below it. + " implementation(enforcedPlatform('org.jetbrains.kotlin:kotlin-bom'))\n", + " implementation(enforcedPlatform(" + + "'org.jetbrains.kotlin:kotlin-bom:1.8.0-RC2'))\n", + }; + for (int i = 0; i < enforced.length; i++) { + check("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", enforced[i])), + "<<" + enforced[i].trim() + ">> manages the family"); + } + + String[] harmless = { + // At or past the floor it already agrees with these constraints. + " implementation(enforcedPlatform(" + + "'org.jetbrains.kotlin:kotlin-bom:1.9.22'))\n", + " implementation(enforcedPlatform(" + + "'org.jetbrains.kotlin:kotlin-bom:1.8.0'))\n", + " implementation(enforcedPlatform(" + + "'com.squareup.okhttp3:okhttp-bom:4.0.0'))\n", + // A plain platform is not strict, whatever version it names. + " implementation(platform('org.jetbrains.kotlin:kotlin-bom:1.7.22'))\n", + " implementation(platform('org.jetbrains.kotlin:kotlin-bom:1.9.22'))\n", + // And the word in a reason is not the call. + " implementation('com.example:x:1.0') { because 'unlike " + + "enforcedPlatform(\\\"org.jetbrains.kotlin:kotlin-bom:1.7.22\\\")' }\n", + }; + for (int i = 0; i < harmless.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", harmless[i]) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + harmless[i].trim() + ">> leaves the alignment alone"); + } + } + + /** + * Quoted syntax is not syntax. A raw search for the plugin classpath, or for + * a block opener, read the words in a {@code because} reason as the real + * thing -- blanking the declaration that carried them, strict pin and all, + * or putting every statement after it in a scope it was never in. + */ + @Test + public void syntaxQuotedInProseIsNotSyntax() { + String pin = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!"; + String[] prose = { + " implementation('" + pin + "') " + + "{ because 'match configurations.classpath' }\n", + " implementation('" + pin + "') { because 'as buildscript { } does' }\n", + " implementation('" + pin + "') { because 'set in ext { } above' }\n", + " implementation('" + pin + "') {\n" + + " because 'configurations.classpath'\n }\n", + " println 'buildscript { classpath }'\n implementation('" + pin + "')\n", + }; + for (int i = 0; i < prose.length; i++) { + check("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", prose[i])), + "the pin in <<" + prose[i].trim() + ">> is still read"); + } + + // The real spellings still say what they say. + String[] real = { + " buildscript { dependencies { classpath '" + pin + "' } }\n", + " configurations.classpath.resolutionStrategy.force '" + pin + "'\n", + }; + for (int i = 0; i < real.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", real[i]) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + real[i].trim() + ">> is the plugin's graph"); + } + + // And a quoted block opener does not put what follows it in a scope. + String scoped = KotlinStdlibAlignment.constraintsBlock("implementation", + " println 'ext {'\n def kv = '1.9.22'\n" + + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kv\"\n"); + check(scoped.contains("kotlin-stdlib-jdk7:1.8.0"), + "a quoted opener opens nothing, got <<" + scoped + ">>"); + } + /** * {@code add} is an ordinary method name. Reading any call of it as a * dependency declaration let an unrelated API -- a version catalog, a list -- From d356c8883594bfba33bda2efc5dd49105f791343 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:31:55 +0300 Subject: [PATCH 61/94] Identify an artifact the same way everywhere A resolution rule may compare one part of the coordinate only -- the name is unambiguous on its own -- and it is in force either way. Requiring the group beside it left the override unread, so the shims were raised to their empty 1.8.0 jars around a base library the rule held at 1.7.22: a build that links and then throws on the device. Widening that exposed the real defect. There were two predicates deciding which artifact a statement names, and they had diverged: one knew the coordinate, the group/name map AND the bare name; the other knew only the first two. So a force naming a shim by coordinate stood the block down while a useVersion holding the SAME shim at the same version did not, and the constraints went in beside a rule keeping jdk8 pre-merge. The base library had a scan of its own and was never exposed to it, which is why every spelling of the base case read as correct. One predicate now, and all nine combinations of the three artifacts and the three override spellings agree. The bare name does not count when the statement declares some other group: a fork published as com.example:kotlin-stdlib-jdk8 shares a name and is a different module. A rule that COMPARES the group to something else rather than declaring it is deliberately not caught -- telling a group literal from a version by shape is the kind of guess this class keeps having to correct, and being wrong there only suppresses. A platform takes a dependency notation and a map is one, so an enforced pre-merge BOM written as group/name/version entries is read now. There is no literal following the call in that spelling, so it had read as absent. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 71 +++++++++++--- .../builders/KotlinStdlibAlignmentTest.java | 94 +++++++++++++++++++ 2 files changed, 152 insertions(+), 13 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index ef6dfe19510..23ae3dad917 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -330,7 +330,36 @@ private static boolean namesArtifactAnywhere(String line, String artifact) { return namesCoordinate(line, artifact) || (declaresMapEntry(line, "group", KOTLIN_GROUP) && declaresMapEntry(line, "name", artifact)) - || (holdsLiteral(line, KOTLIN_GROUP) && holdsLiteral(line, artifact)); + // The bare artifact name is enough, without the group beside it. A + // rule may compare one part only -- `if (d.requested.name == + // 'kotlin-stdlib') d.useVersion '1.7.22'` -- and it is in force + // either way; requiring both left that override unread, so the + // shims were raised to 1.8.0 around a base library the rule held at + // 1.7.22. That build links and then fails at runtime, when the jdk + // classes are in neither jar. + // + // Not when the statement declares some OTHER group, though: a fork + // published as com.example:kotlin-stdlib-jdk8 is a different module + // that happens to share a name, and reading it as the shim stood the + // whole block down for a pre-merge version of somebody else's jar. + // + // Otherwise safe, because these three names are the whole question: + // nothing else publishes kotlin-stdlib or its jdk shims, and the + // literal has to BE the name, so a coordinate that merely contains + // it does not match. + // + // A rule that compares the group to something else instead of + // declaring it -- d.requested.group == 'com.example' -- is not + // caught, and is left uncaught: telling a group literal from a + // version or a classifier by shape is the kind of guess this class + // keeps having to correct, and being wrong here only suppresses. + || (holdsLiteral(line, artifact) && !namesAnotherGroup(line)); + } + + /** Whether the statement declares a group entry that is not Kotlin's. */ + private static boolean namesAnotherGroup(String line) { + String group = mapEntryValue(line, "group"); + return group != null && !KOTLIN_GROUP.equals(group); } /** @@ -1050,18 +1079,31 @@ private static boolean namesAnEnforcedKotlinPlatformBelowTheFloor(String line) { // No version at all, so nothing says it reaches the floor. return true; } - String declared = versionComponentOf(coordinate.substring(version + 1)); - if (declared.endsWith(STRICT_SUFFIX)) { - declared = declared.substring(0, - declared.length() - STRICT_SUFFIX.length()); - } - if (belowTheFloor(declared)) { + if (belowTheFloor(withoutStrictSuffix( + versionComponentOf(coordinate.substring(version + 1))))) { return true; } } + // A platform takes a dependency notation, and a map is one: + // enforcedPlatform(group: '..', name: 'kotlin-bom', version: '1.7.22'). + // There is no literal following the call at all in that spelling, so the + // scan above found nothing and the enforced pre-merge BOM read as absent. + // The same two entries the map form of a declaration is read by. + if (callsNamed(line, ENFORCED_PLATFORM) + && declaresMapEntry(line, "group", KOTLIN_GROUP)) { + return belowTheFloor(withoutStrictSuffix(mapEntryValue(line, "version"))); + } return false; } + /** A version without the {@code !!} that makes it strict, if it carries one. */ + private static String withoutStrictSuffix(String version) { + if (version != null && version.endsWith(STRICT_SUFFIX)) { + return version.substring(0, version.length() - STRICT_SUFFIX.length()); + } + return version; + } + /** The platform spelling whose managed versions become strict. */ private static final String ENFORCED_PLATFORM = "enforcedPlatform"; @@ -1423,12 +1465,15 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat // same reason it does in belowTheFloor. return false; } - if (namesCoordinate(line, artifact)) { - return true; - } - // group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8', version: '...' - return declaresMapEntry(line, "group", KOTLIN_GROUP) - && declaresMapEntry(line, "name", artifact); + // The same three spellings namesArtifactAnywhere reads, because there were + // two lists and they diverged: this one knew the coordinate and the + // group/name map, and not the bare name a resolution rule compares. So a + // `force` naming a shim by coordinate stood the block down while a + // `useVersion` holding the SAME shim at the same version did not -- and the + // constraints went in beside a rule that keeps jdk8 pre-merge, raising jdk7 + // to its empty 1.8.0 shim around it. The base library had a scan of its own + // and was never exposed to this, which is why it read as correct. + return namesArtifactAnywhere(line, artifact); } /** diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 8f42270b26f..f43a230a265 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -733,6 +733,100 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * A resolution rule may compare one part of the coordinate only -- the name + * is unambiguous on its own -- and it is in force either way. Requiring the + * group beside it left the override unread, so the shims were raised to + * their empty 1.8.0 jars around a base library the rule held at 1.7.22: + * a build that links and then throws on the device. + */ + @Test + public void aResolutionRuleMayNameTheArtifactAlone() { + String[] artifacts = { + "kotlin-stdlib", "kotlin-stdlib-jdk7", "kotlin-stdlib-jdk8", + }; + for (int i = 0; i < artifacts.length; i++) { + // Every spelling of the same override reaches the same verdict. The two + // predicates that identify an artifact had diverged, so a force naming + // a shim by coordinate stood the block down while a useVersion holding + // the SAME shim at the same version did not. + String[] overrides = { + " configurations.all { resolutionStrategy.eachDependency { d ->\n" + + " if (d.requested.name == '" + artifacts[i] + "') " + + "d.useVersion '1.7.22'\n } }\n", + " configurations.all { resolutionStrategy.eachDependency { d ->\n" + + " if (d.requested.group == 'org.jetbrains.kotlin' && " + + "d.requested.name == '" + artifacts[i] + "') " + + "d.useVersion '1.7.22'\n } }\n", + " configurations.all { resolutionStrategy.force " + + "'org.jetbrains.kotlin:" + artifacts[i] + ":1.7.22' }\n", + }; + for (int j = 0; j < overrides.length; j++) { + check("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", overrides[j])), + "<<" + overrides[j].trim() + ">> is an override in force"); + } + } + + // A fork under another group shares the name and is a different module. + String fork = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation(group: 'com.example', " + + "name: 'kotlin-stdlib-jdk8', version: '1.0')\n"); + check(fork.contains("kotlin-stdlib-jdk8:1.8.0"), + "another group's artifact is not the shim, got <<" + fork + ">>"); + + // A name in a reason is not a reference to anything, and an unrelated + // rule binds nothing. + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('com.example:x:1.0') " + + "{ because 'replaces kotlin-stdlib' }\n") + .contains("kotlin-stdlib-jdk7:1.8.0"), + "a name in a reason is prose"); + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.eachDependency " + + "{ d ->\n if (d.requested.name == 'okhttp') " + + "d.useVersion '3.0.0'\n } }\n") + .contains("kotlin-stdlib-jdk7:1.8.0"), + "an unrelated rule binds nothing"); + } + + /** + * A platform takes a dependency notation, and a map is one. There is no + * literal following the call in that spelling, so an enforced pre-merge BOM + * written as a map read as absent entirely. + */ + @Test + public void anEnforcedPlatformMayBeWrittenAsAMap() { + String[] managing = { + " implementation(enforcedPlatform(group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-bom', version: '1.7.22'))\n", + " implementation(enforcedPlatform(version: '1.7.22', " + + "group: 'org.jetbrains.kotlin', name: 'kotlin-bom'))\n", + " implementation(enforcedPlatform(group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-bom'))\n", + }; + for (int i = 0; i < managing.length; i++) { + check("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", managing[i])), + "<<" + managing[i].trim() + ">> manages the family"); + } + + String[] harmless = { + " implementation(enforcedPlatform(group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-bom', version: '1.9.22'))\n", + " implementation(enforcedPlatform(group: 'com.squareup.okhttp3', " + + "name: 'okhttp-bom', version: '3.0.0'))\n", + // A plain platform is not strict in either spelling. + " implementation(platform(group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-bom', version: '1.7.22'))\n", + }; + for (int i = 0; i < harmless.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", harmless[i]) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + harmless[i].trim() + ">> leaves the alignment alone"); + } + } + /** * An ENFORCED Kotlin platform is the one case a BOM stands the block down. * The class comment records why a plain {@code platform()} does not -- its From 8c738ac234bc156d8662adfebd06169306a46218 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:55:57 +0300 Subject: [PATCH 62/94] Scan the script the way Gradle executes it injectRepo is interpolated twice -- into the buildscript repositories and again into the project ones after the android block -- and was handed to the scan once, at the first position, wrapped in neither of its real scopes. Gradle runs both, so a name the fragment binds is restored at the second, and scanning it once left the scan holding whatever came between. Both occurrences are passed now, each inside the closure that actually surrounds it. The reported reproduction, a reassignment inside android { }, does not reproduce: a brace this class can see makes the assignment conditional, and a conditional reassignment already refuses to discard a Kotlin coordinate. The shape that reaches it is an unconditional one, which app text produces by closing its own wrapper early. Both are in the test, the second labelled as the one a reader will try. The test that was supposed to catch this had been weakened to let it through: it deduplicated fragments by name, with a comment saying the call passes injectRepo once. It requires every occurrence in script order now. Its companion checked only that SOME closure wrapped each fragment, so passing the first occurrence as an app-graph repositories block was invisible -- it now reads each occurrence's scope off the script and requires the call's wrapper to match it, opening no block the script does not. Separately: the !! spelling skips the configuration check, because a strict pin is honoured wherever it is declared. "Wherever" still means declared -- logger.lifecycle('...jdk8:1.7.22!!') is a log line, and reading it as a pin stood the block down for an app that had declared nothing. The discriminator is not a list of calls that count, since configurations are open ended and every list of them here has needed correcting: a configuration is never reached through a receiver, so an unqualified call declares and a qualified one does not. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 10 +- .../builders/KotlinStdlibAlignment.java | 68 ++++- .../builders/KotlinStdlibAlignmentTest.java | 242 +++++++++++++++++- 3 files changed, 304 insertions(+), 16 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 88ff13c0002..6c9571cdecb 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7324,12 +7324,20 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // reads a later use as a declaration and skips that // artifact's constraint. request.getArg("android.gradlePlugin", ""), - "repositories {\n%s\n}\n".replace("%s", injectRepo), + // TWICE, because the script interpolates it twice: once into + // the buildscript repositories and again into the project ones + // after the android block. Gradle executes both, so a name this + // fragment binds is restored at the second -- and scanning it + // once left the scan holding whatever an intervening fragment + // had reassigned, which reads a later use as something it is not. + "buildscript {\nrepositories {\n%s\n}\n}\n" + .replace("%s", injectRepo), "buildscript {\ndependencies {\n%s\n}\n}\n".replace("%s", gradleDependency), "android {\n%s\n}\n".replace("%s", request.getArg("android.gradle.androidx", "")), minSDK, targetNumber, "android {\ndefaultConfig {\n%s\n}\n}\n".replace("%s", request.getArg("android.xgradle_default_config", "")), + "repositories {\n%s\n}\n".replace("%s", injectRepo), "dependencies {\n%s\n}\n".replace("%s", coreLibraryDesugaringDependency), "dependencies {\n%s\n}\n".replace("%s", request.getArg("android.supportv4Dep", "")), "dependencies {\n%s\n}\n".replace("%s", kotlinRuntimeDependency), diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 23ae3dad917..88d2f771070 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -627,7 +627,73 @@ private static boolean holdsStrictly(String line, String artifact) { return true; } String declared = declaredVersionOf(line, artifact); - return declared != null && declared.endsWith(STRICT_SUFFIX); + return declared != null && declared.endsWith(STRICT_SUFFIX) + && strictCoordinateIsDeclared(line, artifact); + } + + /** + * Whether the strict coordinate this statement carries is actually being + * DECLARED, rather than merely passed to something. + * + *

The {@code !!} spelling skips the configuration check, because a strict + * pin is honoured wherever it is declared. "Wherever" still means declared: + * {@code logger.lifecycle('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')} + * is a log line, and reading it as a pin stood the whole block down for an app + * that had declared nothing at all.

+ * + *

The discriminator is not a list of the calls that count -- configurations + * are open ended, and every list of them in this class has needed correcting. + * It is that a configuration is never reached through a receiver: an app + * writes {@code implementation '...'} or {@code myCustomConfig '...'}, never + * {@code project.implementation '...'}. So an unqualified call declares and a + * qualified one does not, with the dependency handler itself as the exception + * that {@code add} already needed.

+ * + *

Only the coordinate spelling is asked. A {@code version: '1.7.22!!'} map + * entry is a dependency notation and nothing else, and a version reached + * through a rich-version closure has been read as syntax already.

+ */ + private static boolean strictCoordinateIsDeclared(String line, String artifact) { + String coordinate = KOTLIN_GROUP + ":" + artifact + ":"; + for (int i = 0; i < line.length(); i++) { + if (!isLiteralStart(line, i)) { + continue; + } + int end = endOfStringLiteral(line, i); + String literal = stringLiteralContent(line, i); + if (literal.startsWith(coordinate) + && versionComponentOf(literal.substring(coordinate.length())) + .endsWith(STRICT_SUFFIX)) { + return isDeclarationArgument(line, i); + } + i = end; + } + // No coordinate carries it, so it came from a map entry or a closure. + return true; + } + + /** Whether the literal at {@code quoteAt} is an argument of a declaring call. */ + private static boolean isDeclarationArgument(String line, int quoteAt) { + int i = skipBlanksBackward(line, quoteAt - 1); + if (i >= 0 && line.charAt(i) == '(') { + i = skipBlanksBackward(line, i - 1); + } + if (i < 0 || !isIdentifierChar(line.charAt(i))) { + // Not an argument of anything -- a bare literal in a list, or an + // assignment's value. The use of the name decides those, not this. + return false; + } + while (i >= 0 && isIdentifierChar(line.charAt(i))) { + i--; + } + // A qualified call is not a declaration, with no exception for the + // dependency handler: `dependencies.add('implementation', '..')` takes the + // coordinate as its SECOND argument, so it never reaches here at all -- it + // is recognised where the configuration name is read, by isAddCallArgument. + // An exception for it here would have been a control that constrains + // nothing, which is worse than none: the next reader takes it for coverage. + int dot = skipBlanksBackward(line, i); + return dot < 0 || line.charAt(dot) != '.'; } /** Gradle's strict-version shorthand, written after the version. */ diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index f43a230a265..8ab292fefcd 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -556,27 +556,28 @@ public void everyFragmentOfTheGeneratedBlockIsScanned() throws Exception { } byPosition.put(Integer.valueOf(name.start(1)), token); } - // By name, keeping where it FIRST appears: a fragment may be interpolated - // more than once -- injectRepo goes into the buildscript repositories and - // the project ones -- and requiring a strictly later position for each - // occurrence asked the call to repeat an argument it passes once. - java.util.List fragments = new java.util.ArrayList(); - for (String fragment : byPosition.values()) { - if (!fragments.contains(fragment)) { - fragments.add(fragment); - } - } + // EVERY occurrence, not one per name. A fragment interpolated twice is + // executed twice -- injectRepo goes into the buildscript repositories and + // again into the project ones after the android block -- and Gradle runs + // both, so a name it binds is restored at the second. Collapsing them let + // the scan keep whatever an intervening fragment had reassigned, and read + // a later use of that name as something it is not. The dedup was put here + // to make the ordering check pass; the search below starts after the last + // match instead, which is what a repeated argument actually needs. + java.util.List fragments = + new java.util.ArrayList(byPosition.values()); check(fragments.size() >= 6, "the block really was parsed, found " + fragments); + check(java.util.Collections.frequency(fragments, "injectRepo") == 2, + "the script interpolates injectRepo twice, found " + fragments); int previous = -1; for (int i = 0; i < fragments.size(); i++) { String fragment = fragments.get(i); - int passed = call.indexOf(fragment); + int passed = call.indexOf(fragment, previous + 1); check(passed >= 0, "the alignment is given " + fragment - + ", which the generated block contains but the call does not"); - check(passed > previous, fragment - + " is passed in the order the script emits it"); + + " at occurrence " + i + ", which the generated block contains " + + "but the call does not"); previous = passed; } } @@ -733,6 +734,99 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * The {@code !!} spelling skips the configuration check, because a strict pin + * is honoured wherever it is declared. "Wherever" still means declared: a + * coordinate merely passed to something -- a log line, a list -- is not a + * dependency, and reading one as a pin stood the whole block down for an app + * that had declared nothing. + */ + @Test + public void aStrictCoordinateHasToBeDeclaredToCount() { + String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!"; + String[] declarations = { + " implementation '" + pin + "'\n", + " implementation('" + pin + "')\n", + // Whatever the configuration is called: the rule is that a + // configuration is never reached through a receiver, not a list of + // the ones that count. + " myCustomConfig '" + pin + "'\n", + " debugImplementation '" + pin + "'\n", + " kapt('" + pin + "')\n", + " constraints {\n implementation '" + pin + "'\n }\n", + " dependencies.add('implementation', '" + pin + "')\n", + // The spellings that carry the version somewhere other than the + // coordinate are not asked the question at all. + " implementation group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib-jdk8', version: '1.7.22!!'\n", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " + + "{ version { strictly '1.7.22' } }\n", + }; + for (int i = 0; i < declarations.length; i++) { + check("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", declarations[i])), + "<<" + declarations[i].trim() + ">> declares a strict pin"); + } + + String[] merelyCarried = { + " logger.lifecycle('" + pin + "')\n", + " project.logger.info('" + pin + "')\n", + " myList.add('" + pin + "')\n", + " def all = ['" + pin + "']\n", + }; + for (int i = 0; i < merelyCarried.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", + merelyCarried[i]).contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + merelyCarried[i].trim() + ">> declares nothing"); + } + } + + /** + * A fragment interpolated twice is executed twice. injectRepo goes into the + * buildscript repositories and again into the project ones after the android + * block, so a name it binds is restored at the second -- and scanning it once + * left the scan holding whatever came between. + * + *

Reported with a reassignment inside {@code android { }}, which does not + * reproduce: a brace this class can see makes the assignment conditional, and + * a conditional reassignment already refuses to discard a Kotlin coordinate. + * The shape that does reach it is an UNCONDITIONAL one, which app text + * produces by closing its own wrapper early -- so the replay is what the + * script does, and the scan follows it rather than the argument for it.

+ */ + @Test + public void aFragmentInterpolatedTwiceIsScannedTwice() { + String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22"; + String repositories = "project.ext.dep = '" + pin + "'\n"; + String reassign = "dep = 'com.example:other:1.0'\n"; + String use = "implementation(dep) { version { strictly '1.7.22' } }\n"; + + String replayed = KotlinStdlibAlignment.constraintsBlock("implementation", + "buildscript {\nrepositories {\n" + repositories + "}\n}\n", + reassign, + "repositories {\n" + repositories + "}\n", + "dependencies {\n" + use + "}\n"); + check("".equals(replayed), + "the second execution restores the coordinate, got <<" + replayed + ">>"); + + String once = KotlinStdlibAlignment.constraintsBlock("implementation", + "repositories {\n" + repositories + "}\n", + reassign, + "dependencies {\n" + use + "}\n"); + check(once.contains("kotlin-stdlib-jdk8:1.8.0"), + "and without it the scan keeps the reassigned value, got <<" + + once + ">>"); + + // The reported spelling, kept because it is the one a reader will try: a + // reassignment the class can see a brace around is conditional either way. + String guarded = KotlinStdlibAlignment.constraintsBlock("implementation", + "repositories {\n" + repositories + "}\n", + "android {\n" + reassign + "}\n", + "dependencies {\n" + use + "}\n"); + check("".equals(guarded), + "a guarded reassignment never hid the pin, got <<" + guarded + ">>"); + } + /** * A resolution rule may compare one part of the coordinate only -- the name * is unambiguous on its own -- and it is in force either way. Requiring the @@ -1424,6 +1518,12 @@ public void aFragmentKeepsItsGeneratedScope() throws Exception { check(at >= 0, "the builder calls the alignment"); String fromCall = builderSrc.substring(at).replaceAll("//[^\n]*", ""); String call = fromCall.substring(0, fromCall.indexOf(";")); + int blockAt = builderSrc.indexOf("String gradleProps = "); + check(blockAt >= 0, "the generated script is found"); + int blockEnd = builderSrc.indexOf("Gradle File start", blockAt); + check(blockEnd > blockAt, "and its end"); + String block = builderSrc.substring(blockAt, blockEnd) + .replaceAll("//[^\n]*", ""); String[] scopes = { "repositories {", "buildscript {", "android {", "dependencies {", }; @@ -1432,6 +1532,120 @@ public void aFragmentKeepsItsGeneratedScope() throws Exception { "fragments are handed over inside their " + scopes[i] + " scope, which the call does not show"); } + + // And the scope has to be the RIGHT one, read off the script rather than + // named here. A fragment interpolated at two places sits in two different + // scopes -- injectRepo is inside buildscript { repositories { } } once and + // a bare repositories { } the second time -- so the call must wrap the two + // occurrences differently. Checking only that each was wrapped somehow let + // the first be handed over as an app-graph repositories block, which is a + // different question from the one Gradle asks there. + java.util.List> scriptScopes = + new java.util.ArrayList>(); + java.util.List stack = new java.util.ArrayList(); + boolean inLiteral = false; + StringBuilder literal = new StringBuilder(); + for (int i = 0; i < block.length(); i++) { + char c = block.charAt(i); + if (inLiteral) { + if (c == '\\') { + i++; + continue; + } + if (c == '"') { + inLiteral = false; + continue; + } + if (c == '{') { + String head = literal.toString().trim(); + int space = head.lastIndexOf(' '); + stack.add(space < 0 ? head : head.substring(space + 1)); + literal.setLength(0); + } else if (c == '}') { + if (!stack.isEmpty()) { + stack.remove(stack.size() - 1); + } + literal.setLength(0); + } else { + literal.append(c); + } + continue; + } + if (c == '"') { + inLiteral = true; + literal.setLength(0); + continue; + } + if (block.startsWith("injectRepo", i) + && (i == 0 || !Character.isJavaIdentifierPart(block.charAt(i - 1))) + && !Character.isJavaIdentifierPart( + block.charAt(i + "injectRepo".length()))) { + scriptScopes.add(new java.util.ArrayList(stack)); + } + } + check(scriptScopes.size() == 2, + "the script interpolates injectRepo twice, found " + scriptScopes); + check(!scriptScopes.get(0).equals(scriptScopes.get(1)), + "and in two different scopes, found " + scriptScopes); + + // Split at the commas that separate ARGUMENTS, which are the ones outside + // parentheses: the nearest comma before the token is the one inside + // .replace("%s", injectRepo), and slicing there left no wrapper to check. + java.util.List arguments = new java.util.ArrayList(); + int depth = 0; + int start = call.indexOf('(') + 1; + boolean quoted = false; + for (int i = start; i < call.length(); i++) { + char c = call.charAt(i); + if (quoted) { + if (c == '\\') { + i++; + } else if (c == '"') { + quoted = false; + } + continue; + } + if (c == '"') { + quoted = true; + } else if (c == '(') { + depth++; + } else if (c == ')') { + if (depth == 0) { + arguments.add(call.substring(start, i)); + break; + } + depth--; + } else if (c == ',' && depth == 0) { + arguments.add(call.substring(start, i)); + start = i + 1; + } + } + java.util.List passing = new java.util.ArrayList(); + for (int i = 0; i < arguments.size(); i++) { + if (arguments.get(i).indexOf("injectRepo") >= 0) { + passing.add(arguments.get(i)); + } + } + check(passing.size() == scriptScopes.size(), + "the call passes injectRepo once per interpolation, found " + passing); + + for (int i = 0; i < scriptScopes.size(); i++) { + String wrapper = passing.get(i); + java.util.List scope = scriptScopes.get(i); + for (int j = 0; j < scope.size(); j++) { + check(wrapper.indexOf(scope.get(j) + " {") >= 0, + "occurrence " + i + " of injectRepo is handed over inside " + + scope + ", and its wrapper <<" + wrapper.trim() + + ">> does not open " + scope.get(j)); + } + for (int j = 0; j < scopes.length; j++) { + String other = scopes[j].substring(0, scopes[j].indexOf(' ')); + check(scope.contains(other) || wrapper.indexOf(scopes[j]) < 0, + "occurrence " + i + " of injectRepo is not inside " + other + + " in the script, but its wrapper <<" + + wrapper.trim() + ">> opens one"); + } + } } /** From 7f609b3ed0f995373aad0c33df9e96485743442f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:06:37 +0300 Subject: [PATCH 63/94] Take the lower of two forces for one module force takes varargs, so one call can list the same module twice. Reading the first selector reported the merged-era version for `force 'g:kotlin-stdlib:1.9.22', 'g:kotlin-stdlib:1.7.22'` and wrote the shim constraints beside a base library that may be forced pre-merge -- the failure that reaches the device rather than the build. The LOWER of them, not the last. Which selector Gradle keeps is not something this can establish from the text and does not have to: the lower answer is right if Gradle takes it and conservative if Gradle takes the other, which is how every ambiguity here is resolved. An unreadable version is already the lowest answer there is, so it needs no case. Not taken: the enforced-platform check firing on `def bom = enforcedPlatform('..')` that is never added to a configuration. The Gradle fact is right -- the object constrains nothing until something declares it -- but acting on it needs to tell that apart from the same line followed by `implementation(bom)`, which is why anyone stores one. The definition machinery records literals and maps, not call expressions, so the name carries nothing; measured by excluding the assigned form, the store-then-add pair stops standing the block down and the constraints go in against a BOM that really is strict and really is pre-merge. The reasoning is recorded at the check, with what would have to change first. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 55 ++++++++++++++++++- .../builders/KotlinStdlibAlignmentTest.java | 33 +++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 88d2f771070..01a1abca4b3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -406,6 +406,7 @@ private static String declaredVersionOf(String line, String artifact) { // first and the replacement second, and it is the replacement that decides // what resolves. int from = afterCall(line, "using"); + String lowest = null; for (int i = from < 0 ? 0 : from; i < line.length(); i++) { char c = line.charAt(i); if (!isLiteralStart(line, i)) { @@ -428,10 +429,14 @@ private static String declaredVersionOf(String line, String artifact) { // before the map's own version: entry. namesCoordinate learned to // skip a reason and this did not, so the comment describing an old // artifact supplied the version for the declaration warning about it. - return versionComponentOf(literal.substring(coordinate.length())); + String found = versionComponentOf(literal.substring(coordinate.length())); + lowest = lower(lowest, found); } i = end; } + if (lowest != null) { + return lowest; + } String mapped = mapEntryValue(line, "version"); if (mapped != null) { return mapped; @@ -439,6 +444,35 @@ private static String declaredVersionOf(String line, String artifact) { return null; } + /** + * The lower of two versions for the same module, either of which may be + * null for "not seen yet". + * + *

One statement can name a module twice: {@code force} takes varargs, so + * {@code force 'g:a:1.9.22', 'g:a:1.7.22'} is one call listing the same + * module at two versions. Reading the first reported the merged-era one and + * wrote the shim constraints beside a base library that may be forced + * pre-merge, which is the failure that reaches the device rather than the + * build.

+ * + *

The LOWER rather than the last. Which of two selectors for one module + * Gradle keeps is not something this can establish from the text, and it + * does not have to: the lower answer is right if Gradle takes it, and + * conservative if Gradle takes the other, which is how this class resolves + * every ambiguity it cannot evaluate. An unreadable version is already the + * lowest answer there is.

+ */ + private static String lower(String held, String found) { + if (held == null) { + return found; + } + if (found == null) { + return held; + } + return compareVersions(withoutStrictSuffix(found), + withoutStrictSuffix(held)) < 0 ? found : held; + } + /** * The value of a {@code key: 'value'} map entry, or null. * @@ -1134,6 +1168,25 @@ private static boolean lastSegmentIs(String path, String segment) { * will resolve.

*/ private static boolean namesAnEnforcedKotlinPlatformBelowTheFloor(String line) { + // Any statement that builds one counts, including `def bom = + // enforcedPlatform('..')` that is never added to a configuration. + // Reported as too broad, and correctly on the Gradle fact: the object it + // returns constrains nothing until something declares it. + // + // Acting on that needs to tell "stored and never added" from "stored and + // added below", and the second is why anyone stores one. The definition + // machinery records literals and maps, not call expressions, so the name + // carries nothing: measured by excluding the assigned form, the + // def legacyBom = enforcedPlatform('..kotlin-bom:1.7.22') + // implementation(legacyBom) + // pair stops standing the block down and the constraints go in against a + // BOM that really is strict and really is pre-merge. That is a failed + // resolution; the cost of the present reading is the duplicate an app + // already had, in an app that went out of its way to name a pre-merge + // Kotlin BOM and then not use it. + // + // Revisit together with recording call-expression values, not before: + // the exclusion is only safe once the add site carries the platform. List enforced = versionsInCall(line, ENFORCED_PLATFORM); for (int i = 0; i < enforced.size(); i++) { String coordinate = enforced.get(i).trim(); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 8ab292fefcd..dcd1f644231 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -734,6 +734,39 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * One statement can name a module twice: {@code force} takes varargs, so + * {@code force 'g:a:1.9.22', 'g:a:1.7.22'} is one call listing the same + * module at two versions. Reading the first reported the merged-era one and + * wrote the shim constraints beside a base library that may be forced + * pre-merge -- the failure that reaches the device rather than the build. + */ + @Test + public void aForceMayListOneModuleMoreThanOnce() { + String base = "org.jetbrains.kotlin:kotlin-stdlib:"; + // Either order reaches the same verdict, because the answer does not + // depend on which selector Gradle keeps. + String[] orders = { + "'" + base + "1.9.22', '" + base + "1.7.22'", + "'" + base + "1.7.22', '" + base + "1.9.22'", + "'" + base + "1.9.22', '" + base + "1.7.22', '" + base + "1.9.22'", + }; + for (int i = 0; i < orders.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.force " + + orders[i] + " }\n"); + check("".equals(out), "<<" + orders[i] + + ">> forces the base library pre-merge, got <<" + out + ">>"); + } + + // A force that never goes below the floor still leaves the block to write. + String merged = KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.force '" + base + + "1.9.22', '" + base + "1.8.10' }\n"); + check(merged.contains("kotlin-stdlib-jdk7:1.8.0"), + "a merged-era force leaves the alignment alone, got <<" + merged + ">>"); + } + /** * The {@code !!} spelling skips the configuration check, because a strict pin * is honoured wherever it is declared. "Wherever" still means declared: a From 661d5b6cbbfb71dd1239a414bf0707bddbdaf0c2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:26:55 +0300 Subject: [PATCH 64/94] Keep a resolution rule together across every line break in it The canonical Gradle rule puts its openers, its condition and its body on separate lines, and every one of those splits was losing the override -- leaving the base library held pre-merge while these constraints raise the shims to their empty 1.8.0 jars, which is the failure that reaches the device rather than the build. An unbraced body is now decided by whether the statement ENDS with a header, not by whether it starts with one. With all the openers and the condition on one line the first token is `configurations` and two braces are open besides, so the condition and the body were split apart. Only a header takes the next line: a declaration or a force ending in a parenthesis takes nothing, and a parenthesis inside a string is not one. A statement absorbs its closure when it names the family, and the trigger was the GROUP alone -- so a rule comparing only the name never glued its braced body on. It asks for the base library's name now, which is a prefix of both shims'. An `else` is the same statement as the `if` before it, and the condition that names the family is on the `if`. Joined by adjacency, not by reading which branch runs: the statement then holds both versions and the last one wins, which is the conservative answer taken wherever a condition cannot be evaluated. An override naming the group on its own governs every module in it, this family included -- unless the same statement narrows to one artifact. That last clause is not a nicety: without it a rule scoped to `group == '..' && name == 'kotlin-stdlib'` made the siblings look declared, their constraints were skipped as already handled, and the block came out empty. Empty is not the safe direction -- it leaves the duplicate exactly where it was, which an existing test caught. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 163 ++++++++++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 84 +++++++++ 2 files changed, 235 insertions(+), 12 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 01a1abca4b3..244de46fe48 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -325,6 +325,41 @@ private static boolean bindsAVersion(String line, String artifact) { return declaredVersionOf(line, artifact) != null; } + /** + * Whether the text mentions one of the artifacts this class aligns. + * + *

Used to decide whether a statement absorbs the closure that follows it, + * where the group alone was the trigger. A rule comparing only the name -- + * {@code if (d.requested.name == 'kotlin-stdlib') {} } with the useVersion on + * the next line -- never named the group, so the condition and the body + * stayed separate statements and neither said anything.

+ * + *

Deliberately a plain mention rather than the careful reading + * namesArtifactAnywhere does: gluing a closure onto a statement is bounded + * to one declaration either way, and the careful question is asked later of + * the merged text.

+ */ + /** Whether the statement continues the previous one with an else branch. */ + private static boolean continuesWithElse(String statement) { + int i = skipBlanks(statement, 0); + while (i < statement.length() && statement.charAt(i) == '}') { + i = skipBlanks(statement, i + 1); + } + int end = i; + while (end < statement.length() && isIdentifierChar(statement.charAt(end))) { + end++; + } + return "else".equals(statement.substring(i, end)); + } + + private static boolean namesAnAlignedArtifact(String text) { + // The base library's name, which is a prefix of both shims', so one test + // covers the family. ALIGNED_ARTIFACTS is the two shims alone -- they are + // what gets a constraint written -- and asking only those missed a rule + // naming the base, which is the one whose version decides everything. + return text.contains(BASE_STDLIB); + } + /** Whether the statement names the artifact, in either spelling. */ private static boolean namesArtifactAnywhere(String line, String artifact) { return namesCoordinate(line, artifact) @@ -353,7 +388,48 @@ && declaresMapEntry(line, "name", artifact)) // caught, and is left uncaught: telling a group literal from a // version or a classifier by shape is the kind of guess this class // keeps having to correct, and being wrong here only suppresses. - || (holdsLiteral(line, artifact) && !namesAnotherGroup(line)); + || (holdsLiteral(line, artifact) && !namesAnotherGroup(line)) + // An override that names the GROUP on its own applies to every + // module in it, this family included: + // if (d.requested.group == 'org.jetbrains.kotlin') + // d.useVersion '1.7.22' + // is the canonical Gradle snippet, and it holds the base library + // pre-merge while these constraints raise the shims to their empty + // 1.8.0 jars -- the failure that reaches the device. + // + // The group has to be a literal of its OWN, which is what makes + // this narrow: `force 'org.jetbrains.kotlin:kotlin-reflect:1.7.22'` + // carries the group inside a coordinate and does not match, so an + // override of an unrelated Kotlin module still leaves the block to + // write. A rule that names the group AND some other artifact does + // match, and stands the block down for a module it does not govern + // -- the ambiguity resolved the way every other one here is, + // because that costs an app the duplicate it already had. + || (holdsLiteral(line, KOTLIN_GROUP) && callsForce(line, artifact) + && !namesOneOfTheFamily(line)); + } + + /** + * Whether the statement names a particular member of the family, as a + * literal of its own. + * + *

What stops the group-wide reading above from widening a rule that has + * already narrowed itself. {@code group == '..' && name == 'kotlin-stdlib'} + * governs the base library and nothing else, and reading it as governing the + * family made the siblings look declared -- so their constraints were skipped + * as already handled and the block came out empty. That is not the safe + * direction: it leaves the duplicate exactly where it was.

+ */ + private static boolean namesOneOfTheFamily(String line) { + if (holdsLiteral(line, BASE_STDLIB)) { + return true; + } + for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { + if (holdsLiteral(line, ALIGNED_ARTIFACTS[i])) { + return true; + } + } + return false; } /** Whether the statement declares a group entry that is not Kotlin's. */ @@ -2419,7 +2495,7 @@ private static String[] statements(String text) { List merged = new ArrayList(); for (int i = 0; i < defined.size(); i++) { String statement = defined.get(i); - if (statement.contains(KOTLIN_GROUP)) { + if (statement.contains(KOTLIN_GROUP) || namesAnAlignedArtifact(statement)) { // A trailing closure may sit on the line AFTER the call's closing // parenthesis -- Gradle accepts it and the strictly inside really does // apply, checked by watching a competing higher requirement fail @@ -2450,6 +2526,27 @@ private static String[] statements(String text) { statement = statement + " " + defined.get(i); braces += braceBalance(defined.get(i)); } + // An `else` is the same statement as the `if` before it, and the + // condition that names the family is on the `if`. Left apart, the + // if (d.requested.name == 'kotlin-stdlib') + // d.useVersion '1.9.22' + // else + // d.useVersion '1.7.22' + // rule offered only its first branch, so the version that decides + // suppression was in a statement that named nothing. Adjacency, not + // a reading of which branch runs: joined, the statement holds both + // versions and the last one wins, which is the conservative answer + // this class takes wherever it cannot evaluate a condition. + while (i + 1 < defined.size() && continuesWithElse(defined.get(i + 1))) { + i++; + statement = statement + " " + defined.get(i); + int reopened = trailingBraceBalance(statement); + while (reopened > 0 && i + 1 < defined.size()) { + i++; + statement = statement + " " + defined.get(i); + reopened += braceBalance(defined.get(i)); + } + } } merged.add(statement); } @@ -3148,21 +3245,63 @@ private static void recordBareAssignment(String body, Map litera * these words introduce one.

*/ private static boolean opensAnUnbracedBody(String text) { - int i = skipBlanks(text, 0); - int end = i; - while (end < text.length() && isIdentifierChar(text.charAt(end))) { - end++; + // Whether the statement ENDS with a header, not whether it starts with + // one. A rule is written with its openers and its condition on one line + // and the body on the next: + // configurations.all { resolutionStrategy.eachDependency { d -> + // if (d.requested.name == 'kotlin-stdlib') + // d.useVersion '1.7.22' + // Reading the first token found `configurations`, and the brace balance + // is two open besides, so the condition and the useVersion were split into + // separate statements -- neither of which says anything, which is how an + // override in force went unread and the constraints went in beside it. + int last = skipBlanksBackward(text, text.length() - 1); + if (last < 0) { + return false; + } + if (text.charAt(last) != ')') { + // `else` stands alone; it is the only header with no condition. + int start = last; + while (start >= 0 && isIdentifierChar(text.charAt(start))) { + start--; + } + return "else".equals(text.substring(start + 1, last + 1)); + } + // The parenthesis that closes AT the end, found forward so a bracket + // inside a string is not counted as one. + List opened = new ArrayList(); + int opener = -1; + for (int i = 0; i < text.length(); i++) { + if (isLiteralStart(text, i)) { + i = endOfStringLiteral(text, i); + continue; + } + char c = text.charAt(i); + if (c == '(') { + opened.add(Integer.valueOf(i)); + } else if (c == ')' && !opened.isEmpty()) { + int open = opened.remove(opened.size() - 1).intValue(); + if (i == last) { + opener = open; + break; + } + } } - String head = text.substring(i, end); - if (UNBRACED_HEADERS.indexOf(" " + head + " ") < 0) { + if (opener < 0) { return false; } - if (braceBalance(text) != 0) { + int end = skipBlanksBackward(text, opener - 1); + if (end < 0) { return false; } - int last = skipBlanksBackward(text, text.length() - 1); - // `else` stands alone; the rest carry a condition in parentheses. - return last >= 0 && (text.charAt(last) == ')' || "else".equals(head)); + int start = end; + while (start >= 0 && isIdentifierChar(text.charAt(start))) { + start--; + } + // Only a header takes the next line as its body. `implementation('a:1.0')` + // and `force('a:1.0')` end in a parenthesis too and take nothing. + return UNBRACED_HEADERS.indexOf( + " " + text.substring(start + 1, end + 1) + " ") >= 0; } /** The words that introduce a body, braced or not. */ diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index dcd1f644231..12eeea06fd4 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -734,6 +734,90 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * The canonical Gradle rule puts its openers, its condition and its body on + * separate lines, and every one of those splits was losing the override. + */ + @Test + public void aResolutionRuleSurvivesEveryLineBreakInIt() { + String open = " configurations.all { resolutionStrategy.eachDependency " + + "{ d ->\n"; + String close = " } }\n"; + String[] rules = { + // The reported one: openers and condition on one line, body on the + // next. The first token is `configurations` and two braces are open, + // so neither test for an unbraced body saw a header here. + open + " if (d.requested.name == 'kotlin-stdlib')\n" + + " d.useVersion '1.7.22'\n" + close, + // Braced, which is how it is usually written. The condition and the + // body were only glued together when the GROUP appeared, and a rule + // comparing the name alone never names it. + open + " if (d.requested.name == 'kotlin-stdlib') {\n" + + " d.useVersion '1.7.22'\n }\n" + close, + open + " if (d.requested.group == 'org.jetbrains.kotlin') {\n" + + " d.useVersion '1.7.22'\n }\n" + close, + // An else is the same statement as its if, and the condition that + // names the family is on the if. + open + " if (d.requested.name != 'kotlin-stdlib')\n" + + " d.useVersion '1.9.22'\n" + + " else\n d.useVersion '1.7.22'\n" + close, + open + " while (d.requested.name == 'kotlin-stdlib')\n" + + " d.useVersion '1.7.22'\n" + close, + // The openers, the condition and the body on three different + // lines is one shape; all the openers AND the condition on ONE + // line is another, and there the statement begins with + // `configurations` and holds two open braces, so reading the first + // token found no header at all. + " configurations.all { resolutionStrategy.eachDependency { d -> " + + "if (d.requested.name == 'kotlin-stdlib')\n" + + " d.useVersion '1.7.22'\n" + close, + " configurations.all { resolutionStrategy.eachDependency { d -> " + + "if (d.requested.group == 'org.jetbrains.kotlin')\n" + + " d.useVersion '1.7.22'\n" + close, + // And the spellings that already worked still do. + open + " if (d.requested.name == 'kotlin-stdlib') " + + "d.useVersion '1.7.22'\n" + close, + }; + for (int i = 0; i < rules.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + rules[i]); + check("".equals(out), "rule " + i + " holds the base library pre-merge, " + + "got <<" + out + ">>"); + } + + // A trailing parenthesis that is not a header takes no body with it, or + // every declaration would swallow the line after it. + String[] independent = { + " dependencies {\n implementation('com.example:x:1.0')\n" + + " implementation('org.jetbrains.kotlin:" + + "kotlin-stdlib-jdk8:1.9.22')\n }\n", + " configurations.all { resolutionStrategy.force" + + "('com.example:x:1.0') }\n" + + " implementation 'org.jetbrains.kotlin:" + + "kotlin-stdlib-jdk8:1.9.22'\n", + // A parenthesis inside a string is not a parenthesis. + " println 'if (x)'\n implementation " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n", + }; + for (int i = 0; i < independent.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", + independent[i]).contains("kotlin-stdlib-jdk7:1.8.0"), + "line " + i + " does not take the next one with it"); + } + + // A rule that names the group and then narrows to ONE artifact governs + // that artifact only: reading it as governing the family made the + // siblings look declared and the block came out empty, which leaves the + // duplicate exactly where it was. + String narrowed = KotlinStdlibAlignment.constraintsBlock("implementation", + open + " if (d.requested.group == 'org.jetbrains.kotlin' && " + + "d.requested.name == 'kotlin-stdlib') d.useVersion '1.9.22'\n" + + close); + check(narrowed.contains("kotlin-stdlib-jdk8:1.8.0"), + "a narrowed merged-era rule keeps the alignment, got <<" + + narrowed + ">>"); + } + /** * One statement can name a module twice: {@code force} takes varargs, so * {@code force 'g:a:1.9.22', 'g:a:1.7.22'} is one call listing the same From 633359060cccac0cd3ee192f67c708ae11bbc5e8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:38:08 +0300 Subject: [PATCH 65/94] A soft requirement is raised by the constraint, not honoured as a pin `version { require '1.7.22' }` is soft: Gradle takes it only when nothing stronger is in play, and a constraint at the floor IS stronger, so the two resolve to 1.8.0 together. Reading one as a pin stood the whole block down, and reading it as a declaration skipped that shim's own constraint -- both leaving the shim pre-merge beside whatever selected a merged-era base, which is the duplicate this exists to prevent, in the graph it exists for. Soft is about whether it PINS, not whether it is read: a requirement still overrides the coordinate beside it, so `implementation('..jdk7:1.7.22') { version { require '1.9.22' } }` is still read as merged-era. The exemption applies only when nothing else holds the artifact -- a strictly, a force, a rejection that closes the floor, or the `!!` suffix on the requirement itself all pin, and so does a coordinate carrying its own version, whose behaviour is the measured one the class comment describes. Two tests asserted the old reading and are corrected rather than worked around, with what was wrong about them recorded where they are. failOnVersionConflict now counts only where it governs a configuration that receives the constraint. `configurations.create('tooling') .resolutionStrategy.failOnVersionConflict()` governs one the app made and nothing extends, so it cannot conflict with anything written here. Asked as the complement of a closed set rather than a list to ignore: `all` and `configureEach` are the only two spellings meaning every configuration, the constraint is on the main ones, and a statement that does not go through `configurations` at all cannot be placed and is assumed to reach. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 152 ++++++++++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 77 ++++++++- 2 files changed, 211 insertions(+), 18 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 244de46fe48..6691b84fa49 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -233,6 +233,13 @@ public static String constraintsBlock(String configuration, if (!callsNamed(active[i], "failOnVersionConflict")) { continue; } + // On a configuration that never receives the constraint it cannot + // conflict with it. `configurations.create('tooling') + // .resolutionStrategy.failOnVersionConflict()` governs a + // configuration the app made and nothing extends. + if (!governsTheConstrainedGraph(active[i], config)) { + continue; + } // A statement that governs the plugin classpath never arrives here: // both spellings of it -- a buildscript block and configurations // .classpath -- are blanked with the rest of that graph before any @@ -477,6 +484,41 @@ private static String declaredVersionOf(String line, String artifact) { if (rich != null) { return rich; } + String fromCoordinate = coordinateVersionOf(line, artifact); + if (fromCoordinate != null) { + return fromCoordinate; + } + String mapped = mapEntryValue(line, "version"); + if (mapped != null) { + return mapped; + } + return null; + } + + /** + * Whether a soft {@code require} is the only thing holding this artifact. + * + *

Such a declaration is not management: the constraint raises it and the + * two coexist. Anything that really pins -- a {@code strictly}, a force, a + * rejection that closes the floor, or the {@code !!} suffix on the + * requirement itself -- answers false, and so does a coordinate carrying its + * own version, which is the app's chosen version rather than a floor under + * it.

+ */ + private static boolean heldOnlyBySoftRequirement(String line, String artifact) { + String required = versionInCall(line, "require"); + if (required == null || required.endsWith(STRICT_SUFFIX)) { + return false; + } + if (callsStrictly(line) || callsForce(line, artifact) || rejectsTheFloor(line)) { + return false; + } + return coordinateVersionOf(line, artifact) == null + && mapEntryValue(line, "version") == null; + } + + /** The version the artifact's own coordinate carries, or null. */ + private static String coordinateVersionOf(String line, String artifact) { String coordinate = KOTLIN_GROUP + ":" + artifact + ":"; // Past `using`, when there is one: a substitution names the replaced module // first and the replacement second, and it is the replacement that decides @@ -510,14 +552,7 @@ private static String declaredVersionOf(String line, String artifact) { } i = end; } - if (lowest != null) { - return lowest; - } - String mapped = mapEntryValue(line, "version"); - if (mapped != null) { - return mapped; - } - return null; + return lowest; } /** @@ -997,11 +1032,15 @@ private static String strictVersionIn(String statement) { * it. * *

{@code strictly} is the one that changes whether the constraints can - * coexist with the app's, but it is not the only one that says what version - * is meant. Reading only it left {@code version { require '1.9.22' } } - * with no version at all, which the conservative path then treated as - * below the floor -- dropping BOTH constraints for a declaration that was - * already merged-era and needed only its sibling left alone.

+ * coexist with the app's, and {@code useVersion} rewrites what was + * requested on the way through, so both are read.

+ * + *

{@code require} is read too, because it OVERRIDES the coordinate: + * {@code implementation('..jdk7:1.7.22') { version { require '1.9.22' } }} + * resolves 1.9.22, and reading the coordinate there called a merged-era + * declaration pre-merge. Reading it is not the same as treating it as a + * pin -- see heldOnlyBySoftRequirement, which is where that distinction + * lives.

* *

{@code prefer} is deliberately NOT read here. A preference is soft: * Gradle takes it only when nothing stronger is in play, so a transitive @@ -1642,6 +1681,21 @@ private static boolean declaresArtifactOnLine(String artifact, String configurat // conflicting costs an app that had already pinned the family the duplicate // it already had. So this stays until the classification can be read from // something better than a name. + // A soft requirement is not management, in either direction. The + // constraint RAISES it -- `version { require '1.7.22' }` and a floor of + // 1.8.0 resolve to 1.8.0 with no conflict -- so treating one as a pin + // stood the block down for a shim this could have fixed, and treating it + // as a declaration skipped that shim's constraint and left it pre-merge + // beside a merged-era base. Both are the duplicate this exists to + // prevent, kept rather than removed. + // + // Only when nothing else holds the artifact: a strictly, a force, a + // rejection or the `!!` suffix on the requirement itself all pin, and a + // coordinate that carries its own version is the app's chosen version, + // whose measured behaviour is what the comment above describes. + if (heldOnlyBySoftRequirement(line, artifact)) { + return false; + } if (!holdsStrictly(line, artifact) && !declaresOnTheConstrainedConfiguration(configuration, line)) { return false; @@ -2202,6 +2256,78 @@ private static boolean declaresOnTheConstrainedConfiguration(String configuratio "runtime" }; + /** + * Whether a resolution strategy in this statement governs a configuration + * that receives the emitted constraint. + * + *

Asked as the complement of a closed set rather than as a list of the + * configurations to ignore, because a project's configurations are open + * ended: {@code all} and {@code configureEach} are the only two spellings + * that mean every configuration, and the constraint is written on the main + * ones. A statement naming any OTHER single configuration -- created, + * looked up, or dotted -- governs something this block never reaches.

+ * + *

A statement that does not go through {@code configurations} at all + * cannot be placed, and is assumed to govern: it is a bare + * {@code resolutionStrategy} inside a block this cannot see, and being + * wrong about it the other way emits a constraint into a graph that fails + * the build outright.

+ */ + private static boolean governsTheConstrainedGraph(String line, String configuration) { + int at = -1; + for (int i = 0; i < line.length(); i++) { + if (isLiteralStart(line, i)) { + i = endOfStringLiteral(line, i); + continue; + } + if (line.startsWith(CONFIGURATIONS, i) + && (i == 0 || !isIdentifierChar(line.charAt(i - 1)))) { + at = i + CONFIGURATIONS.length(); + break; + } + } + if (at < 0) { + return true; + } + int end = at; + while (end < line.length() && isIdentifierChar(line.charAt(end))) { + end++; + } + String named = line.substring(at, end); + if ("all".equals(named) || "configureEach".equals(named)) { + return true; + } + if (named.equals(configuration) || isAMainConfiguration(named)) { + return true; + } + // create('implementation'), named('api'), getByName(..): the name is a + // string rather than a token, and it is the same question. + for (int i = 0; i < line.length(); i++) { + if (!isLiteralStart(line, i)) { + continue; + } + int close = endOfStringLiteral(line, i); + String held = stringLiteralContent(line, i); + if (held.equals(configuration) || isAMainConfiguration(held)) { + return true; + } + i = close; + } + return false; + } + + /** Whether the name is one of the configurations the constraint is on. */ + private static boolean isAMainConfiguration(String name) { + for (int i = 0; i < MAIN_CONFIGURATIONS.length; i++) { + if (MAIN_CONFIGURATIONS[i].equals(name)) { + return true; + } + } + return false; + } + + private static final String CONFIGURATIONS = "configurations."; + /** Whether this line declares on {@code configuration}, as a whole token. */ private static boolean declaresOn(String configuration, String line) { for (int i = 0; i < line.length(); i++) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 12eeea06fd4..8ac2f2efac7 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -734,6 +734,45 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * failOnVersionConflict turns a disagreement into a build failure, so the + * block stands down for it -- but only where it governs a configuration that + * receives the constraint. One the app created and nothing extends never + * sees it, and standing down there left the shim unaligned for nothing. + */ + @Test + public void aConflictStrategyCountsOnlyWhereTheConstraintReaches() { + String tail = ".resolutionStrategy.failOnVersionConflict()\n"; + String[] elsewhere = { + " configurations.create('tooling')" + tail, + " configurations.tooling" + tail, + " configurations.getByName('tooling')" + tail, + }; + for (int i = 0; i < elsewhere.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", + elsewhere[i]).contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + elsewhere[i].trim() + ">> governs another graph"); + } + + String[] reaching = { + " configurations.all { resolutionStrategy.failOnVersionConflict() }\n", + " configurations.configureEach { resolutionStrategy" + + ".failOnVersionConflict() }\n", + " configurations.implementation" + tail, + " configurations.getByName('implementation')" + tail, + // Not going through `configurations` at all, so it cannot be placed: + // assumed to reach, because the other way emits into a graph that + // fails the build outright. + " resolutionStrategy.failOnVersionConflict()\n", + }; + for (int i = 0; i < reaching.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + reaching[i]); + check("".equals(out), "<<" + reaching[i].trim() + + ">> reaches the constrained graph, got <<" + out + ">>"); + } + } + /** * The canonical Gradle rule puts its openers, its condition and its body on * separate lines, and every one of those splits was losing the override. @@ -3087,12 +3126,24 @@ public void aPreferenceDoesNotStandInForTheConstraint() { check(old.contains("kotlin-stdlib-jdk8:1.8.0"), "and neither does an old one, which the floor simply overrides"); - // a required version still does + // Neither does a requirement, which is soft in the same way. This once + // asserted that it binds; it does not, and skipping the constraint for it + // is what left a softly-required shim pre-merge. String required = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " + "{ version { require '1.9.22' } }\n"); - check(!required.contains("kotlin-stdlib-jdk8:1.8.0"), - "a required version still binds"); + check(required.contains("kotlin-stdlib-jdk8:1.8.0"), + "a required version does not stand in for the constraint either"); + + // A requirement that OVERRIDES a coordinate is still read as the version + // that declaration carries -- soft is about whether it pins, not about + // whether it is read. + String overridden = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { require '1.9.22' } }\n"); + check(overridden.contains("kotlin-stdlib-jdk7:1.8.0"), + "the requirement is read past the coordinate, got <<" + + overridden + ">>"); } /** @@ -3341,11 +3392,27 @@ public void aRequiredRichVersionIsAVersionToo() { check(preferred.contains("kotlin-stdlib-jdk8:1.8.0"), "and so does a preferred one"); - // below the floor it still takes both + // Below the floor it does NOT take both. This once asserted the + // opposite, which was the wrong call: a requirement is soft, so the + // constraint raises it and the two resolve to 1.8.0 together. Standing + // the block down there left the shim at 1.7.22 beside whatever selected + // a merged-era base -- the duplicate this exists to prevent, in the graph + // it exists for. String old = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') " + "{ version { require '1.7.22' } }\n"); - check("".equals(old), "a required pre-merge version still suppresses both"); + check(old.contains("kotlin-stdlib-jdk7:1.8.0") + && old.contains("kotlin-stdlib-jdk8:1.8.0"), + "a soft pre-merge requirement is raised, not honoured, got <<" + + old + ">>"); + + // The `!!` suffix inside a requirement is Gradle's strict shorthand, and + // that one really does pin. + String strict = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') " + + "{ version { require '1.7.22!!' } }\n"); + check("".equals(strict), "a strict requirement still takes both, got <<" + + strict + ">>"); } /** From dabc1af10e49101e726386cc736fdf1253a1c36d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:46:19 +0300 Subject: [PATCH 66/94] Read a coordinate that is not the first argument, and not another handler's `dependencies.add('implementation', 'g:kotlin-stdlib:1.7.22!!')` puts the coordinate after a comma, and the walk back stopped there -- so the strict pin the app really had went unread and the constraints went in against it, which is a failed resolution. That exception was removed one round ago as a control that constrains nothing, on the reasoning that such a call is recognised where the CONFIGURATION name is read. True for the shims; the base library has a scan of its own that does not go through there, and the non-vacuity check that "proved" the exception dead only exercised the shims. It is back, and general: the enclosing call is resolved for an argument at any position, found forward so a parenthesis inside a string is not one, with Groovy's parenthesis-free spelling falling back to the statement's first token. The receiver still decides -- a list is not a dependency handler. A test suite's nested `dependencies { }` configures the suite's own configurations. Its `implementation` has the same name as the app's and is a different thing, so a declaration there was skipping the constraint for an artifact the release graph still carries. The buildscript blanking is generalised to a named set of foreign scopes. That set is a list, unusually here, and the reason is written beside it: which enclosing blocks reach this project's graph has no closed answer -- allprojects does, subprojects does not, a plugin may add either. A scope missing from the list changes nothing, so the list fails safely; inverting it to treat every nested block as foreign does not, because blanking an allprojects declaration emits a constraint beside a possibly strict pin. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 122 ++++++++++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 65 ++++++++++ 2 files changed, 179 insertions(+), 8 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 6691b84fa49..e6d709dcf6b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -820,6 +820,17 @@ && versionComponentOf(literal.substring(coordinate.length())) /** Whether the literal at {@code quoteAt} is an argument of a declaring call. */ private static boolean isDeclarationArgument(String line, int quoteAt) { int i = skipBlanksBackward(line, quoteAt - 1); + if (i >= 0 && line.charAt(i) == ',') { + // A LATER argument, which is where a coordinate sits in + // `dependencies.add('implementation', 'g:a:1.7.22!!')`. Walking back one + // token found the comma and stopped, so the strict pin the app really + // had went unread and the constraints went in against it. This was + // removed once as an exception that constrained nothing, on the + // reasoning that such a call is recognised where the CONFIGURATION name + // is read -- true for the shims, and not for the base library, which + // has a scan of its own that comes through here. + return isDeclarationCall(line, enclosingCallOf(line, quoteAt)); + } if (i >= 0 && line.charAt(i) == '(') { i = skipBlanksBackward(line, i - 1); } @@ -828,17 +839,76 @@ private static boolean isDeclarationArgument(String line, int quoteAt) { // assignment's value. The use of the name decides those, not this. return false; } + return isDeclarationCall(line, i); + } + + /** + * The index of the last character of the call whose argument list encloses + * {@code at}, or -1. + * + *

Found forward, so a parenthesis inside a string is not one. Groovy's + * command syntax has no parentheses at all, and there the call is the + * statement's first token.

+ */ + private static int enclosingCallOf(String line, int at) { + List opened = new ArrayList(); + for (int i = 0; i < at && i < line.length(); i++) { + if (isLiteralStart(line, i)) { + i = endOfStringLiteral(line, i); + continue; + } + char c = line.charAt(i); + if (c == '(') { + opened.add(Integer.valueOf(i)); + } else if (c == ')' && !opened.isEmpty()) { + opened.remove(opened.size() - 1); + } + } + if (!opened.isEmpty()) { + return skipBlanksBackward(line, + opened.get(opened.size() - 1).intValue() - 1); + } + int first = skipBlanks(line, 0); + int end = first; + while (end < line.length() && isIdentifierChar(line.charAt(end))) { + end++; + } + return end > first ? end - 1 : -1; + } + + /** + * Whether the call ending at {@code at} is one that can declare a + * dependency. + * + *

A configuration is never reached through a receiver -- an app writes + * {@code implementation '..'} or {@code myCustomConfig '..'}, never + * {@code project.implementation '..'} -- so an unqualified call declares. + * The dependency handler is the exception, because {@code add} really is + * called on it.

+ */ + private static boolean isDeclarationCall(String line, int at) { + if (at < 0 || !isIdentifierChar(line.charAt(at))) { + return false; + } + int i = at; while (i >= 0 && isIdentifierChar(line.charAt(i))) { i--; } - // A qualified call is not a declaration, with no exception for the - // dependency handler: `dependencies.add('implementation', '..')` takes the - // coordinate as its SECOND argument, so it never reaches here at all -- it - // is recognised where the configuration name is read, by isAddCallArgument. - // An exception for it here would have been a control that constrains - // nothing, which is worse than none: the next reader takes it for coverage. int dot = skipBlanksBackward(line, i); - return dot < 0 || line.charAt(dot) != '.'; + if (dot < 0 || line.charAt(dot) != '.') { + return true; + } + int end = skipBlanksBackward(line, dot - 1); + if (end < 0) { + return false; + } + int start = end; + while (start >= 0 && (isIdentifierChar(line.charAt(start)) + || (line.charAt(start) == '.' && start > 0 + && isIdentifierChar(line.charAt(start - 1))))) { + start--; + } + return lastSegmentIs(line.substring(start + 1, end + 1), "dependencies"); } /** Gradle's strict-version shorthand, written after the version. */ @@ -2754,7 +2824,7 @@ private static List inlineLiteralDefinitions(List statements) { for (int i = 0; i < statements.size(); i++) { String statement = statements.get(i); boolean opensBuildscript = buildscriptDepth == 0 - && opensBlockNamed(statement, BUILDSCRIPT); + && opensAForeignScope(statement); boolean pluginScoped = buildscriptDepth > 0 || opensBuildscript || namesTheBuildscriptClasspath(statement); out.add(pluginScoped ? "" : (literals.isEmpty() @@ -3361,6 +3431,42 @@ private static void recordBareAssignment(String body, Map litera /** The block that configures the plugin classpath rather than the app's. */ private static final String BUILDSCRIPT = "buildscript"; + /** + * Blocks whose contents configure something other than the application's + * dependency graph. + * + *

{@code buildscript} is the plugin classpath. {@code testing} is the + * JVM test suites block, whose nested {@code dependencies { }} belongs to a + * suite's own configurations -- its {@code implementation} has the same + * name as the app's and is a different thing, so reading a declaration + * there as the app's skipped the constraint for an artifact the release + * graph still carries.

+ * + *

A list, unusually for this class, because the general question -- + * which enclosing blocks reach this project's graph -- has no closed + * answer: {@code allprojects} does, {@code subprojects} does not, and a + * plugin may add either kind. It is safe as a list because a scope missing + * from it changes nothing: that block keeps being read as the app's, which + * is what happens today, and the cost is the duplicate an app already had. + * Inverting it -- treating every nested block as foreign -- is what is not + * safe, because blanking an {@code allprojects} declaration emits a + * constraint beside a pin that may be strict.

+ */ + private static final String[] FOREIGN_SCOPES = { + "buildscript", + "testing" + }; + + /** Whether the statement opens a block that is not the app's own graph. */ + private static boolean opensAForeignScope(String statement) { + for (int i = 0; i < FOREIGN_SCOPES.length; i++) { + if (opensBlockNamed(statement, FOREIGN_SCOPES[i])) { + return true; + } + } + return false; + } + /** * Whether the text so far is a control header whose body is the next line. * diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 8ac2f2efac7..37abb316234 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -734,6 +734,71 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * A coordinate can be a LATER argument: {@code dependencies.add('impl', + * 'g:a:1.7.22!!')} puts it after a comma, and walking back one token found + * the comma and stopped. The shims survived that because the call is also + * recognised where the CONFIGURATION name is read; the base library has a + * scan of its own that does not go through there, so its strict pin was + * emitted straight over. + */ + @Test + public void aCoordinateMayBeALaterArgument() { + String base = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!"; + String[] declarations = { + " dependencies.add('implementation', '" + base + "')\n", + " project.dependencies.add('implementation', '" + base + "')\n", + " dependencies {\n add 'implementation', '" + base + "'\n }\n", + " dependencies.add('implementation', " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')\n", + }; + for (int i = 0; i < declarations.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + declarations[i]); + check("".equals(out), "<<" + declarations[i].trim() + + ">> is a strict declaration, got <<" + out + ">>"); + } + + // The receiver still decides. A list is not a dependency handler, and a + // coordinate handed to one is not declared. + String[] strangers = { + " myList.add('implementation', '" + base + "')\n", + " logger.lifecycle('implementation', '" + base + "')\n", + }; + for (int i = 0; i < strangers.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", + strangers[i]).contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + strangers[i].trim() + ">> declares nothing"); + } + } + + /** + * A test suite's nested {@code dependencies { }} configures the suite's own + * configurations. Its {@code implementation} has the same name as the app's + * and is a different thing, so reading a declaration there as the app's + * skipped the constraint for an artifact the release graph still carries. + */ + @Test + public void aNestedTestSuiteIsNotTheApplicationGraph() { + String suite = " testing {\n suites {\n test {\n" + + " dependencies {\n" + + " implementation('org.jetbrains.kotlin:" + + "kotlin-stdlib-jdk8:1.9.22')\n" + + " }\n }\n }\n }\n"; + String out = KotlinStdlibAlignment.constraintsBlock("implementation", suite); + check(out.contains("kotlin-stdlib-jdk8:1.8.0") + && out.contains("kotlin-stdlib-jdk7:1.8.0"), + "the suite's declaration leaves both constrained, got <<" + out + ">>"); + + // The app's own block, which looks the same one level up, still counts. + String own = KotlinStdlibAlignment.constraintsBlock("implementation", + " dependencies {\n implementation('org.jetbrains.kotlin:" + + "kotlin-stdlib-jdk8:1.9.22')\n }\n"); + check(!own.contains("kotlin-stdlib-jdk8:1.8.0") + && own.contains("kotlin-stdlib-jdk7:1.8.0"), + "the app's own declaration is still read, got <<" + own + ">>"); + } + /** * failOnVersionConflict turns a disagreement into a build failure, so the * block stands down for it -- but only where it governs a configuration that From dfeb6901eff43400de68eeac887b1a4f4b5bdf17 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:02:05 +0300 Subject: [PATCH 67/94] One reading for every spelling of "which configuration" Three of the four findings this round were the same question asked in spellings that had separate answers, so they now go through one reading. A lookup carries the configuration's name as a string, a filter carries a closure and says nothing, and that difference is syntax rather than a list -- so a selector nobody anticipated reads as "cannot say". `configurations.matching { it.name == 'releaseRuntimeClasspath' }.all` really can select the graph being constrained, and reading its first segment as a configuration name classified it as some other graph: the constraints then went into one whose strategy fails the build on the version they raise. Anything that leaves the selection to a closure now reads as reaching. The plugin classpath is the same question. Only `configurations.classpath` was recognised, so the subscripted and getByName spellings read as the app managing the family and stood the whole block down. A map stored in a variable is dependency-shaped data, not a dependency, and was read as a strict declaration. Excluding it is safe here where the same exclusion was NOT safe for enforcedPlatform, and the difference is recorded at both: a map IS recorded as a definition's value, so a later implementation(catalog) carries it and is read there. Separately, the dependency fragments now reach the scan inside ONE closure, because the generated script has one dependencies { } and they are concatenated into it. A closure each made a scope boundary Gradle does not have, so a `def` in an earlier fragment was discarded before a later one used it -- and the use then named no artifact, losing whatever pin it carried. Two source-reading tests were anchored on the old shape; both are re-anchored, one on the concatenation operator so that deleting an argument still fails it. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 22 +- .../builders/KotlinStdlibAlignment.java | 193 +++++++++++------- .../builders/KotlinStdlibAlignmentTest.java | 124 ++++++++++- 3 files changed, 253 insertions(+), 86 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 6c9571cdecb..a7685d0af29 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7338,13 +7338,21 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { targetNumber, "android {\ndefaultConfig {\n%s\n}\n}\n".replace("%s", request.getArg("android.xgradle_default_config", "")), "repositories {\n%s\n}\n".replace("%s", injectRepo), - "dependencies {\n%s\n}\n".replace("%s", coreLibraryDesugaringDependency), - "dependencies {\n%s\n}\n".replace("%s", request.getArg("android.supportv4Dep", "")), - "dependencies {\n%s\n}\n".replace("%s", kotlinRuntimeDependency), - "dependencies {\n%s\n}\n".replace("%s", additionalDependencies), - "dependencies {\n%s\n}\n".replace("%s", aiExtraGradleDependencies.toString()), - "dependencies {\n%s\n}\n".replace("%s", request.getArg("android.gradleDep", "")), - "dependencies {\n%s\n}\n".replace("%s", aarDependencies), + // ONE closure around all of them, because the script has + // one: they are concatenated into a single dependencies { } + // below. A closure each made a scope boundary Gradle does + // not have, so a `def` in an earlier fragment was discarded + // before a later one used it -- and the use then named no + // artifact, which loses whatever pin it carried. + "dependencies {\n" + + coreLibraryDesugaringDependency + + request.getArg("android.supportv4Dep", "") + "\n" + + kotlinRuntimeDependency + + additionalDependencies + "\n" + + aiExtraGradleDependencies.toString() + "\n" + + request.getArg("android.gradleDep", "") + "\n" + + aarDependencies + + "\n}\n", request.getArg("android.xgradle", "")); } catch (RuntimeException e) { // The alignment reads the app's Gradle text to decide whether the app diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index e6d709dcf6b..d7da21cab09 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -813,8 +813,39 @@ && versionComponentOf(literal.substring(coordinate.length())) } i = end; } - // No coordinate carries it, so it came from a map entry or a closure. - return true; + // No coordinate carries it, so it came from a map entry or a closure. A + // map has to be DECLARED too: `def catalog = [group: '..', name: + // 'kotlin-stdlib', version: '1.7.22!!']` is dependency-shaped data that + // is never added to a configuration, and reading it as a strict pin stood + // the whole block down for an app that had declared nothing. + // + // Safe to exclude here where the same exclusion was NOT safe for + // enforcedPlatform: a map IS recorded as a definition's value, so + // `implementation(catalog)` below carries it and is read there. The + // platform's call expression is not recorded, which is why that one is + // still honoured wherever it appears. + // + // An assignment before the map is what says it is stored rather than + // declared, which needs no list of the calls that declare. + return !isStoredRatherThanDeclared(line); + } + + /** Whether an assignment precedes the map this statement carries. */ + private static boolean isStoredRatherThanDeclared(String line) { + for (int i = 0; i < line.length(); i++) { + if (isLiteralStart(line, i)) { + i = endOfStringLiteral(line, i); + continue; + } + char c = line.charAt(i); + if (c == '[' || followedByMapKeyColon(line, i)) { + return false; + } + if (isAssignmentAt(line, i)) { + return true; + } + } + return false; } /** Whether the literal at {@code quoteAt} is an argument of a declaring call. */ @@ -1426,26 +1457,82 @@ private static String withoutStrictSuffix(String version) { * one as the app managing the family left a real duplicate unfixed.

*/ private static boolean namesTheBuildscriptClasspath(String line) { - // Outside literals, like every other question about syntax here. A raw - // search read the words in a reason -- because 'match configurations - // .classpath' -- as the configuration itself and blanked the declaration - // carrying them, strict pin and all, before anything could look at it. - for (int at = 0; at < line.length(); at++) { - if (isLiteralStart(line, at)) { - at = endOfStringLiteral(line, at); - continue; - } - if (!line.startsWith(BUILDSCRIPT_CLASSPATH, at)) { + return "classpath".equals(configurationNamedIn(line)); + } + + /** + * The single configuration this statement names, or null when it names none + * or cannot say which. + * + *

Every spelling goes through the same reading, because they are the same + * question: {@code configurations.classpath}, + * {@code configurations['classpath']} and + * {@code configurations.getByName('classpath')} all name one configuration, + * and only the first was recognised -- so a plugin-classpath force written + * either of the other two ways read as the app managing the family and stood + * the whole block down.

+ * + *

Null when a closure decides which configurations are meant: + * {@code configurations.all { }}, {@code configureEach}, and + * {@code matching { }} may all include the one being constrained, and no + * name is available to say. That falls out of the syntax rather than a list + * -- a lookup takes a string, a filter takes a closure -- so a selector + * nobody anticipated reads as "cannot say", which is the answer that keeps + * the constraint out of a graph that would fail on it.

+ */ + private static String configurationNamedIn(String line) { + int at = -1; + for (int i = 0; i < line.length(); i++) { + if (isLiteralStart(line, i)) { + i = endOfStringLiteral(line, i); continue; } - boolean startsToken = at == 0 || !isIdentifierChar(line.charAt(at - 1)); - int after = at + BUILDSCRIPT_CLASSPATH.length(); - if (startsToken && (after >= line.length() - || !isIdentifierChar(line.charAt(after)))) { - return true; + int after = i + CONFIGURATIONS.length(); + if (line.startsWith(CONFIGURATIONS, i) + && (i == 0 || !isIdentifierChar(line.charAt(i - 1))) + && (after >= line.length() + || !isIdentifierChar(line.charAt(after)))) { + at = after; + break; } } - return false; + if (at < 0) { + return null; + } + int next = skipBlanks(line, at); + if (next < line.length() && line.charAt(next) == '[') { + return literalAfter(line, next + 1); + } + if (next >= line.length() || line.charAt(next) != '.') { + return null; + } + int start = skipBlanks(line, next + 1); + int end = start; + while (end < line.length() && isIdentifierChar(line.charAt(end))) { + end++; + } + if (end == start) { + return null; + } + int after = skipBlanks(line, end); + if (after < line.length() && line.charAt(after) == '(') { + // A lookup carries the name as a string; a filter carries a closure + // and says nothing about which configurations it will match. + return literalAfter(line, after + 1); + } + if (after < line.length() && line.charAt(after) == '{') { + return null; + } + return line.substring(start, end); + } + + /** The content of the string literal starting at or after {@code from}. */ + private static String literalAfter(String line, int from) { + int at = skipBlanks(line, from); + if (at >= line.length() || !isLiteralStart(line, at)) { + return null; + } + return stringLiteralContent(line, at); } private static final String BUILDSCRIPT_CLASSPATH = "configurations.classpath"; @@ -2330,60 +2417,23 @@ private static boolean declaresOnTheConstrainedConfiguration(String configuratio * Whether a resolution strategy in this statement governs a configuration * that receives the emitted constraint. * - *

Asked as the complement of a closed set rather than as a list of the - * configurations to ignore, because a project's configurations are open - * ended: {@code all} and {@code configureEach} are the only two spellings - * that mean every configuration, and the constraint is written on the main - * ones. A statement naming any OTHER single configuration -- created, - * looked up, or dotted -- governs something this block never reaches.

- * - *

A statement that does not go through {@code configurations} at all - * cannot be placed, and is assumed to govern: it is a bare - * {@code resolutionStrategy} inside a block this cannot see, and being - * wrong about it the other way emits a constraint into a graph that fails - * the build outright.

+ *

It does unless the statement names one particular configuration that + * is not among the constrained ones. Anything that leaves the selection to + * a closure, and anything that does not go through {@code configurations} + * at all, is assumed to reach: being wrong that way costs an app the + * duplicate it already had, while being wrong the other way emits a + * constraint into a graph whose strategy fails the build on it.

*/ private static boolean governsTheConstrainedGraph(String line, String configuration) { - int at = -1; - for (int i = 0; i < line.length(); i++) { - if (isLiteralStart(line, i)) { - i = endOfStringLiteral(line, i); - continue; - } - if (line.startsWith(CONFIGURATIONS, i) - && (i == 0 || !isIdentifierChar(line.charAt(i - 1)))) { - at = i + CONFIGURATIONS.length(); - break; - } - } - if (at < 0) { - return true; - } - int end = at; - while (end < line.length() && isIdentifierChar(line.charAt(end))) { - end++; - } - String named = line.substring(at, end); - if ("all".equals(named) || "configureEach".equals(named)) { - return true; - } - if (named.equals(configuration) || isAMainConfiguration(named)) { - return true; - } - // create('implementation'), named('api'), getByName(..): the name is a - // string rather than a token, and it is the same question. - for (int i = 0; i < line.length(); i++) { - if (!isLiteralStart(line, i)) { - continue; - } - int close = endOfStringLiteral(line, i); - String held = stringLiteralContent(line, i); - if (held.equals(configuration) || isAMainConfiguration(held)) { - return true; - } - i = close; - } - return false; + String named = configurationNamedIn(line); + // No single configuration named, so which ones are meant is decided by a + // closure this cannot evaluate -- `configurations.all { }`, and equally + // `configurations.matching { it.name == 'releaseRuntimeClasspath' }.all`, + // which really does select the graph being constrained. Reading a filter + // as "some other configuration" put the constraints into a graph whose + // strategy fails the build on the version they raise. + return named == null || named.equals(configuration) + || isAMainConfiguration(named); } /** Whether the name is one of the configurations the constraint is on. */ @@ -2396,7 +2446,8 @@ private static boolean isAMainConfiguration(String name) { return false; } - private static final String CONFIGURATIONS = "configurations."; + /** The container, as a token: what follows it says which configuration. */ + private static final String CONFIGURATIONS = "configurations"; /** Whether this line declares on {@code configuration}, as a whole token. */ private static boolean declaresOn(String configuration, String line) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 37abb316234..63997c3acb4 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -182,13 +182,16 @@ public void theBuilderPassesEveryAppControlledDependencyFragment() throws Except // argument deleted. Checked by deleting it, which is the only way that kind of // vacuity shows up. String[] fragments = { - // Each fragment now reaches the call wrapped in the closure that - // surrounds it in the generated file, so the argument text ends at the - // wrapper's parenthesis rather than at a comma. - "additionalDependencies)", - "aiExtraGradleDependencies.toString())", - "request.getArg(\"android.gradleDep\", \"\")", - "request.getArg(\"android.supportv4Dep\", \"\")", + // The dependency fragments now reach the call concatenated into ONE + // wrapper, because the generated script has one dependencies { } and + // a closure each made a scope boundary Gradle does not have. Matched + // on the concatenation operator so that deleting an argument fails + // this, which matching the bare hint name did not -- the comment + // above the list names some of them too. + "+ additionalDependencies", + "+ aiExtraGradleDependencies.toString()", + "+ request.getArg(\"android.gradleDep\", \"\")", + "+ request.getArg(\"android.supportv4Dep\", \"\")", "request.getArg(\"android.xgradle\", \"\")", }; for (String fragment : fragments) { @@ -734,6 +737,107 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * Every spelling of "which configuration" goes through one reading, because + * they are the same question. A lookup carries the name as a string, a + * filter carries a closure and says nothing -- so a selector nobody + * anticipated reads as "cannot say", which keeps the constraint out of a + * graph that would fail on it. + */ + @Test + public void everySpellingOfAConfigurationIsReadTheSameWay() { + String conflict = ".resolutionStrategy.failOnVersionConflict()\n"; + String[] reaching = { + " configurations.all { resolutionStrategy.failOnVersionConflict() }\n", + " configurations.configureEach { resolutionStrategy" + + ".failOnVersionConflict() }\n", + // A filter may select the constrained graph and there is no name to + // say otherwise. Reading one as "some other configuration" put the + // constraints into a graph whose strategy fails the build on them. + " configurations.matching { it.name == 'releaseRuntimeClasspath' }" + + ".all { resolutionStrategy.failOnVersionConflict() }\n", + " configurations.implementation" + conflict, + " configurations.getByName('implementation')" + conflict, + " configurations['implementation']" + conflict, + " resolutionStrategy.failOnVersionConflict()\n", + }; + for (int i = 0; i < reaching.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + reaching[i]); + check("".equals(out), "<<" + reaching[i].trim() + + ">> may reach the constrained graph, got <<" + out + ">>"); + } + + String[] elsewhere = { + " configurations.create('tooling')" + conflict, + " configurations.tooling" + conflict, + " configurations.getByName('tooling')" + conflict, + " configurations['tooling']" + conflict, + }; + for (int i = 0; i < elsewhere.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", + elsewhere[i]).contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + elsewhere[i].trim() + ">> governs another graph"); + } + + // The plugin classpath is the same question asked of the same reading, + // and only its dotted spelling had been recognised. + String force = ".resolutionStrategy.force " + + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n"; + String[] pluginOnly = { + " configurations.classpath" + force, + " configurations['classpath']" + force, + " configurations.getByName('classpath')" + force, + " buildscript.configurations['classpath']" + force, + }; + for (int i = 0; i < pluginOnly.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", + pluginOnly[i]).contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + pluginOnly[i].trim() + ">> is the plugin's graph"); + } + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all" + force)), + "and a force on the app's own graph still counts"); + } + + /** + * Dependency-shaped data is not a dependency. A map stored in a variable and + * never added to a configuration was read as a strict declaration, standing + * the block down for an app that had declared nothing. + * + *

Safe to exclude here where the same exclusion was not safe for + * enforcedPlatform: a map IS recorded as a definition's value, so a later + * {@code implementation(catalog)} carries it and is read there.

+ */ + @Test + public void aStrictMapHasToBeDeclaredToCount() { + String map = "[group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib', " + + "version: '1.7.22!!']"; + String[] stored = { + " def catalog = " + map + "\n", + " ext.catalog = " + map + "\n", + }; + for (int i = 0; i < stored.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", stored[i]) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + stored[i].trim() + ">> declares nothing"); + } + + String[] declared = { + " def catalog = " + map + "\n implementation(catalog)\n", + " implementation(" + map + ")\n", + " implementation group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib', version: '1.7.22!!'\n", + " dependencies.add('implementation', " + map + ")\n", + }; + for (int i = 0; i < declared.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + declared[i]); + check("".equals(out), "<<" + declared[i].trim() + + ">> is a strict declaration, got <<" + out + ">>"); + } + } + /** * A coordinate can be a LATER argument: {@code dependencies.add('impl', * 'g:a:1.7.22!!')} puts it after a comma, and walking back one token found @@ -4362,7 +4466,11 @@ public void theBuilderStillWritesItIntoTheDependenciesBlock() throws Exception { byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); String src = new String(bytes, "UTF-8"); - int at = src.indexOf("\"dependencies {\\n\""); + // The GENERATED block, which is concatenated onto the script with a + // leading `+`. The alignment's own argument opens with the same text and + // now comes first in the file, so anchoring on the text alone found that + // instead and looked for the constraints inside it. + int at = src.indexOf("+ \"dependencies {\\n\""); assertTrue(at >= 0); String block = src.substring(at, src.indexOf("+ \"}\\n\"", at)); assertTrue(block.contains("+ kotlinStdlibConstraints")); From 216d59d1e695a995e2df256bccdb4070c5a3138d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:12:04 +0300 Subject: [PATCH 68/94] The dependency handler has more than one adder `dependencies.addProvider('implementation', providers.provider { 'g:a: 1.7.22!!' })` is Gradle's supported provider form and declared nothing as far as this was concerned, so the constraints went in against a base library the app really does pin strictly. Two reasons, both fixed. Only the exact name `add` was accepted, and only a coordinate handed straight to the call: one sitting inside a provider closure was preceded by a brace, which the walk read as "not an argument of anything". The receiver decides when there is one, the name when there is not. A call ON the handler is taken whatever it is called, because the handler has three adders today and may grow more; an unqualified one -- the shorthand inside a dependencies closure -- still has to look like an adder, since `catalog.add(..)` adds to a version catalog and `myList.add(..)` to a list, and neither declares anything. Reaching out through a brace needed a guard the comma case did not: with no parentheses anywhere the enclosing call is the statement's first token, and `def all = ['g:a:1.7.22!!']` then read its own `def` as the declaring call. An assignment before the literal says there is no call at all. The first non-vacuity check on the name rule came back NOT REPRODUCED, because a qualified addProvider is decided by its receiver and never consults the name. The case that does is the bare spelling inside the closure, and it is in the test now. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 66 ++++++++++++------- .../builders/KotlinStdlibAlignmentTest.java | 55 ++++++++++++++++ 2 files changed, 98 insertions(+), 23 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index d7da21cab09..dcc778281d0 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -851,15 +851,19 @@ private static boolean isStoredRatherThanDeclared(String line) { /** Whether the literal at {@code quoteAt} is an argument of a declaring call. */ private static boolean isDeclarationArgument(String line, int quoteAt) { int i = skipBlanksBackward(line, quoteAt - 1); - if (i >= 0 && line.charAt(i) == ',') { - // A LATER argument, which is where a coordinate sits in - // `dependencies.add('implementation', 'g:a:1.7.22!!')`. Walking back one - // token found the comma and stopped, so the strict pin the app really - // had went unread and the constraints went in against it. This was - // removed once as an exception that constrained nothing, on the - // reasoning that such a call is recognised where the CONFIGURATION name - // is read -- true for the shims, and not for the base library, which - // has a scan of its own that comes through here. + if (i >= 0 && (line.charAt(i) == ',' || line.charAt(i) == '{')) { + // Not the first thing the call was handed. A comma is where a + // coordinate sits in `dependencies.add('implementation', 'g:a:1.7!!')`, + // and a brace is where it sits inside a provider: + // `dependencies.addProvider('implementation', providers.provider { + // 'g:a:1.7!!' })`. Walking back one token found the punctuation and + // stopped, so the strict pin the app really had went unread and the + // constraints went in against it. + // + // The comma half was removed once as an exception that constrains + // nothing, on the reasoning that such a call is recognised where the + // CONFIGURATION name is read -- true for the shims, and not for the + // base library, which has a scan of its own that comes through here. return isDeclarationCall(line, enclosingCallOf(line, quoteAt)); } if (i >= 0 && line.charAt(i) == '(') { @@ -899,6 +903,20 @@ private static int enclosingCallOf(String line, int at) { return skipBlanksBackward(line, opened.get(opened.size() - 1).intValue() - 1); } + // No parentheses anywhere, so this is Groovy's command syntax and the call + // is the statement's first token -- unless the statement is an assignment, + // in which case there is no call at all and the literal is just a value. + // Without that, `def all = ['g:a:1.7.22!!']` read its own `def` as the + // declaring call. + for (int i = 0; i < at && i < line.length(); i++) { + if (isLiteralStart(line, i)) { + i = endOfStringLiteral(line, i); + continue; + } + if (isAssignmentAt(line, i)) { + return -1; + } + } int first = skipBlanks(line, 0); int end = first; while (end < line.length() && isIdentifierChar(line.charAt(end))) { @@ -2501,24 +2519,26 @@ private static boolean isAddCallArgument(String line, int quoteAt) { if (i >= 0 && line.charAt(i) == '(') { i = skipBlanksBackward(line, i - 1); } - if (i < 2 || !"add".equals(line.substring(i - 2, i + 1)) - || (i - 3 >= 0 && isIdentifierChar(line.charAt(i - 3)))) { + if (i < 0 || !isIdentifierChar(line.charAt(i))) { return false; } - // And the receiver has to BE a dependency handler. `add` is an ordinary - // method name -- `catalog.add('implementation', '...')` adds to a version - // catalog and declares nothing -- so reading one as a declaration skipped - // the constraint for an artifact the app had never put in its graph, and - // an old transitive shim beside a merged stdlib stayed unaligned. - // - // A bare `add` is the shorthand inside a dependencies closure and has no - // receiver to check; only a qualified one does. - if (i - 3 < 0) { - return true; + int nameEnd = i; + while (i >= 0 && isIdentifierChar(line.charAt(i))) { + i--; } - int dot = skipBlanksBackward(line, i - 3); + String method = line.substring(i + 1, nameEnd + 1); + // The receiver decides when there is one, and the name when there is not. + // + // `add` was the only name accepted, so Gradle's provider form -- + // `dependencies.addProvider('implementation', ..)` -- was not read as a + // declaration at all. The handler has three adders and may grow more, so + // a call ON the handler is taken whatever it is called; an unqualified + // one, which is the shorthand inside a dependencies closure, still has to + // look like an adder, because `catalog.add(..)` adds to a version catalog + // and `myList.add(..)` to a list, and neither declares anything. + int dot = skipBlanksBackward(line, i); if (dot < 0 || line.charAt(dot) != '.') { - return true; + return method.startsWith("add"); } int end = skipBlanksBackward(line, dot - 1); if (end < 0) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 63997c3acb4..d53d2c55a6e 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -737,6 +737,61 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * The dependency handler has three adders and may grow more, and the + * coordinate one of them is handed may sit inside a provider closure. Only + * {@code add} with the coordinate as a direct argument was read, so Gradle's + * provider form declared nothing as far as this was concerned. + */ + @Test + public void theDependencyHandlerHasMoreThanOneAdder() { + String pin = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!"; + String[] declarations = { + " dependencies.addProvider('implementation', " + + "providers.provider { '" + pin + "' })\n", + " dependencies.addProvider('implementation', '" + pin + "')\n", + " dependencies.addProviderBundle('implementation', '" + pin + "')\n", + " dependencies {\n addProvider 'implementation', '" + + pin + "'\n }\n", + " dependencies.add('implementation', '" + pin + "')\n", + " dependencies {\n add 'implementation', '" + pin + "'\n }\n", + " implementation(providers.provider { '" + pin + "' })\n", + }; + for (int i = 0; i < declarations.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + declarations[i]); + check("".equals(out), "<<" + declarations[i].trim() + + ">> declares a strict pin, got <<" + out + ">>"); + } + + // The name matters where there is no receiver to check, which is the + // shorthand inside a dependencies closure: read as a declaration, the + // artifact it names is left to the app and only its sibling is raised. + String merged = KotlinStdlibAlignment.constraintsBlock("implementation", + " dependencies {\n addProvider 'implementation', " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n }\n"); + check(merged.contains("kotlin-stdlib-jdk7:1.8.0") + && !merged.contains("kotlin-stdlib-jdk8:1.8.0"), + "a bare addProvider declares its artifact, got <<" + merged + ">>"); + + // The receiver still decides for a qualified call, and an unqualified one + // still has to look like an adder. Neither a list nor a version catalog + // declares anything, and a literal that is nobody's argument declares + // nothing either. + String[] strangers = { + " myList.add('implementation', '" + pin + "')\n", + " catalog.add('implementation', '" + pin + "')\n", + " logger.lifecycle('" + pin + "')\n", + " def all = ['" + pin + "']\n", + " def make = { '" + pin + "' }\n", + }; + for (int i = 0; i < strangers.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", + strangers[i]).contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + strangers[i].trim() + ">> declares nothing"); + } + } + /** * Every spelling of "which configuration" goes through one reading, because * they are the same question. A lookup carries the name as a string, a From daa14f3c6a8fe0faa9e5292008d4bab272184879 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:24:13 +0300 Subject: [PATCH 69/94] Four readings that were confidently wrong rather than unreadable A conditional makes two calls ALTERNATIVES, not a sequence. `if (legacy) strictly '1.7.22' else strictly '1.9.22'` sets one or the other and which is not readable here, so the last-wins rule reported the merged-era arm and wrote the shim constraints beside a strict pre-merge pin that may be the live one. The lowest is the answer when a condition is present; a plain sequence still keeps what it was set to last, in both directions, which is what makes this about branches rather than about taking the lowest version anywhere. A rich version that is PRESENT but unreadable is not an invitation to read the coordinate instead. `version { strictly providers.gradleProperty(..) .get() }` beside a merged-era coordinate reported the coordinate, so a pin that may well be pre-merge read as merged-era and had its own constraint skipped as satisfied while the sibling was raised around it. The resolvable classpaths extend the constrained configurations and are where a resolution strategy actually runs, so a failOnVersionConflict on `configurations.releaseRuntimeClasspath` governs the graph these constraints are resolved in -- reading it as some other configuration put them into a graph that then fails on the version they raise. Recognised by suffix, because Gradle synthesises one per variant and no list of them can be complete. Groovy's explicit line continuation joins two physical lines into one statement. Split at the newline, `implementation \` left a configuration with no dependency and a coordinate with no configuration, so neither said anything and the strict pin between them went unread. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 92 +++++++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 105 ++++++++++++++++++ 2 files changed, 190 insertions(+), 7 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index dcc778281d0..5dbf837c76e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -484,6 +484,16 @@ private static String declaredVersionOf(String line, String artifact) { if (rich != null) { return rich; } + // A rich version that is PRESENT but unreadable is not an invitation to + // read the coordinate instead. `version { strictly providers + // .gradleProperty('legacy').get() }` beside a merged-era coordinate + // reported the coordinate, so a strict pin that may well be pre-merge read + // as merged-era: its own constraint was skipped as satisfied while the + // sibling was raised around it, which is the duplicate again. Unreadable is + // the honest answer, and belowTheFloor treats it as below. + if (callsStrictly(line) || callsNamed(line, USE_VERSION)) { + return null; + } String fromCoordinate = coordinateVersionOf(line, artifact); if (fromCoordinate != null) { return fromCoordinate; @@ -1187,12 +1197,53 @@ private static String richVersionIn(String statement) { /** The quoted argument of {@code call}, found outside string literals. */ private static String versionInCall(String statement, String call) { List found = versionsInCall(statement, call); - // The LAST of them. Every keyword this is asked about -- strictly, require, - // useVersion -- SETS the constraint rather than adding to it, so a closure - // that calls one twice keeps what it was set to last. Reading the first - // reported 1.9.22 for `strictly '1.9.22'; strictly '1.7.22'` and wrote the - // shim constraints beside a pin that was really pre-merge. - return found.isEmpty() ? null : found.get(found.size() - 1); + if (found.isEmpty()) { + return null; + } + // The LAST of them, when they run one after another. Every keyword this is + // asked about -- strictly, require, useVersion -- SETS the constraint + // rather than adding to it, so a closure that calls one twice keeps what it + // was set to last. Reading the first reported 1.9.22 for + // `strictly '1.9.22'; strictly '1.7.22'` and wrote the shim constraints + // beside a pin that was really pre-merge. + // + // But a conditional makes them ALTERNATIVES rather than a sequence: + // `if (legacy) strictly '1.7.22' else strictly '1.9.22'` sets one or the + // other, and which one is not readable here. The lowest is the answer then, + // for the reason every unevaluable branch gets it -- a pre-merge version + // that may be the live one has to stand the block down. + if (!containsAConditional(statement)) { + return found.get(found.size() - 1); + } + String lowest = null; + for (int i = 0; i < found.size(); i++) { + lowest = lower(lowest, found.get(i)); + } + return lowest; + } + + /** Whether the statement chooses between branches this cannot evaluate. */ + private static boolean containsAConditional(String statement) { + for (int i = 0; i < statement.length(); i++) { + if (isLiteralStart(statement, i)) { + i = endOfStringLiteral(statement, i); + continue; + } + if (!isIdentifierChar(statement.charAt(i)) + || (i > 0 && isIdentifierChar(statement.charAt(i - 1)))) { + continue; + } + int end = i; + while (end < statement.length() && isIdentifierChar(statement.charAt(end))) { + end++; + } + String token = statement.substring(i, end); + if ("if".equals(token) || "else".equals(token)) { + return true; + } + i = end - 1; + } + return false; } /** @@ -2461,7 +2512,19 @@ private static boolean isAMainConfiguration(String name) { return true; } } - return false; + // And the resolvable classpaths, which EXTEND those and are where a + // strategy actually runs: a failOnVersionConflict on + // `configurations.releaseRuntimeClasspath` governs the graph these + // constraints are resolved in, and reading it as some other configuration + // put them into a graph that then failed on the version they raise. + // + // By suffix rather than by name, because Gradle synthesises one per + // variant -- releaseRuntimeClasspath, debugCompileClasspath, and whatever + // a flavour adds -- so no list of them can be complete. A configuration + // the app named that way and did not wire up costs a suppression, which + // is the direction this class takes everywhere. + String lower = name.toLowerCase(); + return lower.endsWith("runtimeclasspath") || lower.endsWith("compileclasspath"); } /** The container, as a token: what follows it says which configuration. */ @@ -2718,6 +2781,15 @@ private static String[] statements(String text) { current.append(' '); continue; } + // Groovy's explicit line continuation. `implementation \` with the + // coordinate on the next line was split into a configuration with + // no dependency and a coordinate with no configuration, so neither + // said anything and the strict pin between them went unread. + if (c == '\n' && endsWithLineContinuation(current)) { + current.setLength(current.length() - 1); + current.append(' '); + continue; + } if (c == '\n' && opensAnUnbracedBody(current.toString())) { // An `if (...)` with no brace takes the next line as its body, so // splitting there put the condition in one statement and the body @@ -3610,6 +3682,12 @@ private static boolean opensAnUnbracedBody(String text) { /** The words that introduce a body, braced or not. */ private static final String UNBRACED_HEADERS = " if else while for "; + /** Whether the text so far ends with Groovy's line-continuation backslash. */ + private static boolean endsWithLineContinuation(StringBuilder current) { + return current.length() > 0 + && current.charAt(current.length() - 1) == '\\'; + } + /** Whether the text so far ends with a comma, ignoring trailing blanks. */ private static boolean endsWithComma(StringBuilder text) { for (int i = text.length() - 1; i >= 0; i--) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index d53d2c55a6e..65fce91b573 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -737,6 +737,111 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * Two calls one after another are a sequence and the last wins; two in the + * arms of a conditional are alternatives, and which one runs is not readable + * here. Taking the last of THOSE wrote the shim constraints beside a strict + * pre-merge pin that may well be the live branch. + */ + @Test + public void aConditionalMakesTheCallsAlternatives() { + String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; + String[] branched = { + " implementation('" + jdk8 + "') { version { " + + "if (legacy) strictly '1.7.22' else strictly '1.9.22' } }\n", + " implementation('" + jdk8 + "') { version { " + + "if (legacy) strictly '1.9.22' else strictly '1.7.22' } }\n", + }; + for (int i = 0; i < branched.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + branched[i]); + check("".equals(out), "either arm may run, got <<" + out + ">>"); + } + + // A plain sequence still keeps what it was set to last, in both + // directions -- that is what makes this about branches and not about + // taking the lowest version anywhere. + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('" + jdk8 + "') { version { " + + "strictly '1.9.22'; strictly '1.7.22' } }\n")), + "a sequence ending pre-merge stands the block down"); + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('" + jdk8 + "') { version { " + + "strictly '1.7.22'; strictly '1.9.22' } }\n") + .contains("kotlin-stdlib-jdk7:1.8.0"), + "and one ending merged-era does not"); + } + + /** + * A rich version that is PRESENT but unreadable is not an invitation to read + * the coordinate instead. Reported as merged-era, such a declaration had its + * own constraint skipped as satisfied while the sibling was raised around it. + */ + @Test + public void anUnreadableStrictVersionIsNotTheCoordinate() { + String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('" + jdk8 + ":1.9.22') { version { " + + "strictly providers.gradleProperty('legacy').get() } }\n")), + "an unreadable strictly is not the coordinate's version"); + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.eachDependency " + + "{ d ->\n if (d.requested.name == " + + "'kotlin-stdlib-jdk8') d.useVersion someProperty\n } }\n")), + "and neither is an unreadable useVersion"); + + // A readable one still overrides the coordinate, which is the case that + // put the rich reading here in the first place. + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('" + jdk8 + ":1.7.22') { version { " + + "strictly '1.9.22' } }\n") + .contains("kotlin-stdlib-jdk7:1.8.0"), + "a readable strictly is read past the coordinate"); + } + + /** + * The resolvable classpaths EXTEND the constrained configurations and are + * where a resolution strategy actually runs, so a conflict check on one + * governs the graph these constraints are resolved in. + */ + @Test + public void aResolvableClasspathInheritsTheConstraint() { + String conflict = ".resolutionStrategy.failOnVersionConflict()\n"; + String[] inheriting = { + " configurations.releaseRuntimeClasspath" + conflict, + " configurations.runtimeClasspath" + conflict, + " configurations.debugCompileClasspath" + conflict, + " configurations.getByName('releaseRuntimeClasspath')" + conflict, + }; + for (int i = 0; i < inheriting.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + inheriting[i]); + check("".equals(out), "<<" + inheriting[i].trim() + + ">> resolves what these constrain, got <<" + out + ">>"); + } + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.tooling" + conflict) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "a configuration that is neither still governs another graph"); + } + + /** + * Groovy's explicit line continuation joins two physical lines into one + * statement. Split at the newline, the configuration had no dependency and + * the coordinate had no configuration, so neither said anything. + */ + @Test + public void anEscapedNewlineContinuesTheStatement() { + String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation \\\n '" + jdk8 + ":1.7.22!!'\n")), + "the continued statement carries its strict pin"); + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation \\\n '" + jdk8 + ":1.9.22'\n") + .contains("kotlin-stdlib-jdk7:1.8.0"), + "and a merged-era one is read as the declaration it is"); + } + /** * The dependency handler has three adders and may grow more, and the * coordinate one of them is handed may sit inside a provider closure. Only From d3a841d897c85cd9a98b4f108ac4c8767b4e5af8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:35:56 +0300 Subject: [PATCH 70/94] The extra properties setter binds its first argument `ext.set('stdlib', '...')` is the extension's own setter and the name is its first argument. Read as a dotted assignment it recorded a property called `set` and lost the real one, so a later reference through the bare name named nothing -- and the constraints went in beside a force still in effect, leaving the base jar at 1.7.22 while the selected shims are empty. The setter's comma separates the name from the value exactly as the `=` does in every other spelling, so it stands in for one; the closing quote is stepped over by the same code the subscript form already needed. Verified against the direct spelling in every direction rather than only the reported one: soft, strict, and merged-era through the setter now reach the same verdict as writing the coordinate inline, and nothing is bound under the setter's own name. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 29 ++++++++++- .../builders/KotlinStdlibAlignmentTest.java | 50 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 5dbf837c76e..dfe9c4b2850 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -3150,6 +3150,7 @@ private static void updateLiteralDefinitions(String statement, int i = 0; boolean declared = false; boolean subscript = false; + boolean callForm = false; // An extra property is NOT block scoped. A local declared inside a block // leaves with it, which is why declarations carry their depth -- but // `buildscript { ext.kotlin_version = '1.9.22' }` sets a project-wide @@ -3280,7 +3281,27 @@ && isIdentifierChar(statement.charAt(scan + 1))))) { // Addressing ANOTHER project cannot arrive here: `(` ends the token // walk above, so `project(':lib').ext.dep` never reads as one token // and the chain is always this script's own. - if (dot > 0 && !subscripted + int argument = skipBlanks(statement, lastTokenEnd); + boolean called = argument < statement.length() + && statement.charAt(argument) == '('; + if (dot > 0 && called && "set".equals(only.substring(dot + 1)) + && lastSegmentIs(only.substring(0, dot), EXTRA_PROPERTIES)) { + // ext.set('dep', '...') is the extension's own setter, and the + // name is its first argument. Read as a dotted assignment it + // recorded a property called `set` and lost the real one, so a + // later force naming it through the bare name named nothing -- + // and the constraints went in beside a force still in effect. + int nameAt = skipBlanks(statement, argument + 1); + if (nameAt < statement.length() + && isLiteralStart(statement, nameAt) + && delimiterLength(statement, nameAt) == 1) { + declared = true; + subscript = true; + callForm = true; + extraProperty = true; + i = nameAt + 1; + } + } else if (dot > 0 && !subscripted && lastSegmentIs(only.substring(0, dot), EXTRA_PROPERTIES)) { declared = true; extraProperty = true; @@ -3340,7 +3361,11 @@ && delimiterLength(statement, nameAt) == 1) { ? 0 : depthAt(statement, nameStart, depth), name, literals); } i = skipBlanks(statement, i); - if (i >= statement.length() || statement.charAt(i) != '=' + // The setter's comma separates the name from the value exactly as the `=` + // does in every other spelling, so it stands in for one here. + if (callForm && i < statement.length() && statement.charAt(i) == ',') { + i++; + } else if (i >= statement.length() || statement.charAt(i) != '=' || (i + 1 < statement.length() && statement.charAt(i + 1) == '=')) { if (declared) { // `def dep` with no value yet is still a name this knows about, and diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 65fce91b573..9ac89476d2a 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -737,6 +737,56 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * {@code ext.set('dep', '...')} is the extension's own setter, and the name + * is its first argument. Read as a dotted assignment it recorded a property + * called {@code set} and lost the real one, so a later reference through the + * bare name named nothing -- and the constraints went in beside a force that + * was still in effect. + */ + @Test + public void theExtraPropertiesSetterBindsItsFirstArgument() { + String pre = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22"; + String use = " configurations.all { resolutionStrategy.force stdlib }\n"; + String[] setters = { + " ext.set('stdlib', '" + pre + "')\n", + " project.ext.set('stdlib', '" + pre + "')\n", + " ext.set(\"stdlib\", \"" + pre + "\")\n", + }; + for (int i = 0; i < setters.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + setters[i] + use); + check("".equals(out), "<<" + setters[i].trim() + + ">> binds stdlib, got <<" + out + ">>"); + } + + // Merged-era through the same setter leaves the alignment to be written. + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " ext.set('stdlib', 'org.jetbrains.kotlin:" + + "kotlin-stdlib:1.9.22')\n" + use) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "a merged-era force leaves the alignment alone"); + + // The property is bound under its own name, and no property called `set` + // is created. A soft coordinate emits either way -- the constraint raises + // it -- so the strict spelling is what shows the binding. + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " ext.set('stdlib', '" + pre + "!!')\n" + + " implementation stdlib\n")), + "the name carries the strict pin"); + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " ext.set('stdlib', '" + pre + "!!')\n" + + " implementation set\n") + .contains("kotlin-stdlib-jdk7:1.8.0"), + "and nothing is bound under the setter's own name"); + + // A set() on something that is not the extension binds nothing. + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " someMap.set('stdlib', '" + pre + "')\n" + use) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "someMap.set is not the extension"); + } + /** * Two calls one after another are a sequence and the last wins; two in the * arms of a conditional are alternatives, and which one runs is not readable From 950fbbd918c05bb5b43cc2574eb54a8f6b43978a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:42:59 +0300 Subject: [PATCH 71/94] A custom configuration may inherit the constraint `configurations.create('tooling').extendsFrom(configurations.implementation) .resolutionStrategy.failOnVersionConflict()` is not an independent graph. Reading only the name it was created under exempted it, so the constraints went into a graph that then fails on the version they raise. Only what follows `extendsFrom` is read, because that is the one API for inheritance and the parent is its argument. A configuration extending something the constraint is not on -- compileOnly, or another custom one -- still governs its own graph, which is what keeps the exemption meaning anything at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 50 ++++++++++++++++++- .../builders/KotlinStdlibAlignmentTest.java | 44 ++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index dfe9c4b2850..882a18791e6 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -2502,7 +2502,55 @@ private static boolean governsTheConstrainedGraph(String line, String configurat // as "some other configuration" put the constraints into a graph whose // strategy fails the build on the version they raise. return named == null || named.equals(configuration) - || isAMainConfiguration(named); + || isAMainConfiguration(named) + // A configuration the app made can still INHERIT the constraint: + // `configurations.create('tooling').extendsFrom(configurations + // .implementation)` is not an independent graph, and reading only + // the name it was created under exempted it -- so the constraints + // went into a graph that then failed on the version they raise. + || extendsAConstrainedConfiguration(line, configuration); + } + + /** + * Whether the statement makes its configuration extend one the constraint is + * written on. + * + *

Only what follows {@code extendsFrom} is read, because that is the one + * API for inheritance and the parent is its argument. A configuration + * extending something else -- {@code compileOnly}, say -- does not receive + * the constraint, and answering otherwise would exempt nothing at all.

+ */ + private static boolean extendsAConstrainedConfiguration(String line, + String configuration) { + int at = afterCall(line, "extendsFrom"); + if (at < 0) { + return false; + } + for (int i = at; i < line.length(); i++) { + if (isLiteralStart(line, i)) { + int end = endOfStringLiteral(line, i); + String held = stringLiteralContent(line, i); + if (held.equals(configuration) || isAMainConfiguration(held)) { + return true; + } + i = end; + continue; + } + if (!isIdentifierChar(line.charAt(i)) + || (i > at && isIdentifierChar(line.charAt(i - 1)))) { + continue; + } + int end = i; + while (end < line.length() && isIdentifierChar(line.charAt(end))) { + end++; + } + String token = line.substring(i, end); + if (token.equals(configuration) || isAMainConfiguration(token)) { + return true; + } + i = end - 1; + } + return false; } /** Whether the name is one of the configurations the constraint is on. */ diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 9ac89476d2a..4b809b8c9e2 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -849,6 +849,50 @@ public void anUnreadableStrictVersionIsNotTheCoordinate() { "a readable strictly is read past the coordinate"); } + /** + * A configuration the app made can still INHERIT the constraint. + * {@code configurations.create('tooling').extendsFrom(configurations + * .implementation)} is not an independent graph, and reading only the name + * it was created under exempted it -- so the constraints went into a graph + * that then fails on the version they raise. + */ + @Test + public void aCustomConfigurationMayInheritTheConstraint() { + String conflict = ".resolutionStrategy.failOnVersionConflict()\n"; + String[] inheriting = { + " configurations.create('tooling').extendsFrom(" + + "configurations.implementation)" + conflict, + " configurations.create('tooling').extendsFrom(" + + "configurations.api)" + conflict, + " configurations.create('tooling').extendsFrom(" + + "configurations.getByName('implementation'))" + conflict, + " configurations.tooling.extendsFrom(" + + "configurations.implementation)" + conflict, + }; + for (int i = 0; i < inheriting.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + inheriting[i]); + check("".equals(out), "<<" + inheriting[i].trim() + + ">> inherits what these constrain, got <<" + out + ">>"); + } + + // Extending something the constraint is NOT on does not inherit it, or + // the exemption would cover nothing at all. + String[] independent = { + " configurations.create('tooling').extendsFrom(" + + "configurations.compileOnly)" + conflict, + " configurations.create('tooling').extendsFrom(" + + "configurations.other)" + conflict, + " configurations.create('tooling')" + conflict, + " configurations.tooling" + conflict, + }; + for (int i = 0; i < independent.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", + independent[i]).contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + independent[i].trim() + ">> governs another graph"); + } + } + /** * The resolvable classpaths EXTEND the constrained configurations and are * where a resolution strategy actually runs, so a conflict check on one From 3d4a0a1933165310e393c34f342069f9e7748a07 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:55:41 +0300 Subject: [PATCH 72/94] Copy a binding, end a comment, and name the adders A definition whose value is a NAME copies a binding that is already known. `def forced = coord` and `ext.set('forced', coord)` read only literals, so the new name was recorded as unknown and a force through it named nothing -- the constraints then went in beside a pin still in effect. Resolved from what is recorded, not by inlining the whole statement first. That was the obvious fix and it is wrong: inlining substitutes the name being ASSIGNED as readily as the one being read, so `dep = somethingUnknown` became `'..' = somethingUnknown`, the reassignment was not seen at all and the stale value survived it. Four tests caught that before it went anywhere. Groovy ends a line at a bare carriage return too, and the line-comment scan looked only for the newline -- so a CR-only fragment had everything after `//` swallowed as comment, strict pin included. An unqualified call now has to BE one of the dependency handler's adders rather than merely start with `add`: an app helper named `addNote` taking a configuration and a coordinate was declaring dependencies as far as this was concerned. A qualified call still does not consult the name, so a handler that grows a fourth adder keeps working through its receiver, and what an unrecognised unqualified name costs is a constraint written beside a declaration that was already there. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 62 ++++++++++++- .../builders/KotlinStdlibAlignmentTest.java | 88 +++++++++++++++++++ 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 882a18791e6..1192cce4a08 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -858,6 +858,37 @@ private static boolean isStoredRatherThanDeclared(String line) { return false; } + /** + * Whether an unqualified call is one of the dependency handler's adders. + * + *

Named rather than matched by prefix. `add*` accepted any helper an app + * had defined -- {@code def addNote = { config, text -> .. }} called with a + * configuration and a coordinate declared a dependency as far as this was + * concerned, and the constraint for that artifact was skipped as already + * handled.

+ * + *

A qualified call does NOT consult this: there the receiver settles it, + * so a handler that grows a fourth adder keeps working through + * {@code dependencies.whatever(..)}. What an unrecognised name costs is a + * constraint written beside a declaration that was already there, which is + * the direction this class errs in everywhere.

+ */ + private static boolean isADependencyHandlerAdder(String method) { + for (int i = 0; i < DEPENDENCY_HANDLER_ADDERS.length; i++) { + if (DEPENDENCY_HANDLER_ADDERS[i].equals(method)) { + return true; + } + } + return false; + } + + /** DependencyHandler's methods that take a configuration and a notation. */ + private static final String[] DEPENDENCY_HANDLER_ADDERS = { + "add", + "addProvider", + "addProviderBundle" + }; + /** Whether the literal at {@code quoteAt} is an argument of a declaring call. */ private static boolean isDeclarationArgument(String line, int quoteAt) { int i = skipBlanksBackward(line, quoteAt - 1); @@ -2649,7 +2680,7 @@ private static boolean isAddCallArgument(String line, int quoteAt) { // and `myList.add(..)` to a list, and neither declares anything. int dot = skipBlanksBackward(line, i); if (dot < 0 || line.charAt(dot) != '.') { - return method.startsWith("add"); + return isADependencyHandlerAdder(method); } int end = skipBlanksBackward(line, dot - 1); if (end < 0) { @@ -2724,7 +2755,12 @@ private static String[] activeLines(String fragment) { continue; } if (next == '/') { - while (i < fragment.length() && fragment.charAt(i) != '\n') { + // Either terminator. Groovy ends a line at a bare carriage + // return too, and searching only for the newline swallowed the + // whole remainder of a CR-only fragment as part of the comment + // -- including, in the case that found this, a strict pin. + while (i < fragment.length() && fragment.charAt(i) != '\n' + && fragment.charAt(i) != '\r') { i++; } out.append('\n'); @@ -3456,6 +3492,28 @@ && delimiterLength(statement, nameAt) == 1) { value = withLiteralsInlined( statement.substring(i, closes + 1), literals); } + } else if (i < statement.length() && isIdentifierChar(statement.charAt(i))) { + // A value that is a NAME rather than a literal. `def forced = + // coord` and `ext.set('forced', coord)` copy a binding that is + // already known, and reading only literals recorded the new name as + // unknown -- so a force through it named nothing and the + // constraints went in beside a pin still in effect. + // + // Resolved from what is recorded rather than by inlining the whole + // statement first, which substitutes the name being ASSIGNED as + // readily as the one being read: `dep = somethingUnknown` became + // `'...' = somethingUnknown`, so the reassignment was not seen at + // all and the stale value survived it. + int token = i; + while (token < statement.length() + && isIdentifierChar(statement.charAt(token))) { + token++; + } + String alias = literals.get(statement.substring(i, token)); + if (alias != null && !followedByMapKeyColon(statement, token)) { + end = token - 1; + value = alias; + } } recordDefinition(literals, name, value, conditional); if (end < 0) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 4b809b8c9e2..1d99b327e97 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -737,6 +737,94 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * A value that is a NAME rather than a literal copies a binding that is + * already known. Reading only literals recorded the new name as unknown, so + * a force through it named nothing and the constraints went in beside a pin + * still in effect. + */ + @Test + public void aDefinitionMayCopyAnotherOne() { + String pre = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22"; + String force = " configurations.all { resolutionStrategy.force forced }\n"; + String[] copies = { + " def coord = '" + pre + "'\n def forced = coord\n", + " def coord = '" + pre + "'\n ext.set('forced', coord)\n", + " def coord = '" + pre + "'\n ext.forced = coord\n", + " def a = '" + pre + "'\n def b = a\n def forced = b\n", + }; + for (int i = 0; i < copies.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + copies[i] + force); + check("".equals(out), "the copy carries the coordinate, got <<" + + out + ">>"); + } + + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " def coord = 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'\n" + + " def forced = coord\n" + force) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "a merged-era one leaves the alignment alone"); + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " def forced = whateverThisIs\n" + force) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "and copying something unknown binds nothing"); + } + + /** + * Groovy ends a line at a bare carriage return too. Searching only for the + * newline swallowed the whole remainder of a CR-only fragment as part of a + * line comment -- including the strict pin that followed it. + */ + @Test + public void aLineCommentEndsAtEitherTerminator() { + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " // explanation\r implementation(" + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + + "{ version { strictly '1.7.22' } }\r")), + "the pin after a CR-terminated comment is still read"); + + // And the comment still hides what is on ITS own line. + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " // implementation 'org.jetbrains.kotlin:" + + "kotlin-stdlib-jdk8:1.7.22!!'\n implementation " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n") + .contains("kotlin-stdlib-jdk7:1.8.0"), + "a commented-out pin is still a comment"); + } + + /** + * An unqualified call has to be one of the dependency handler's own adders. + * Matched by prefix, any helper an app had defined -- {@code def addNote = { + * config, text -> .. }} -- declared a dependency as far as this was + * concerned, and that artifact's constraint was skipped as already handled. + */ + @Test + public void anUnqualifiedAdderIsNamedNotPrefixed() { + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " def addNote = { config, text -> println text }\n" + + " addNote('implementation', 'org.jetbrains.kotlin:" + + "kotlin-stdlib-jdk8:1.9.22')\n") + .contains("kotlin-stdlib-jdk8:1.8.0"), + "an app helper declares nothing"); + + String pin = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!"; + String[] adders = { + " dependencies {\n add 'implementation', '" + pin + "'\n }\n", + " dependencies {\n addProvider 'implementation', '" + + pin + "'\n }\n", + // A qualified call does not consult the name at all, so a handler + // that grows a fourth adder keeps working through its receiver. + " dependencies.whateverTheyAddNext('implementation', '" + pin + "')\n", + }; + for (int i = 0; i < adders.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + adders[i]); + check("".equals(out), "<<" + adders[i].trim() + ">> declares, got <<" + + out + ">>"); + } + } + /** * {@code ext.set('dep', '...')} is the extension's own setter, and the name * is its first argument. Read as a dotted assignment it recorded a property From be23e794972c2268600429d740b9156048f641bb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:06:46 +0300 Subject: [PATCH 73/94] Finish the carriage return, and let a ternary choose The comment scan learned that a bare carriage return ends a line; the statement splitter had not. Every statement of a CR-only fragment merged into one, so a main-variant configuration paired with a debug-only coordinate and that artifact read as declared. CRLF is one break, not two, and everything that continues a statement across a newline -- a trailing comma, an explicit continuation, an unbraced header -- continues it across a return. This was noted last round and left; codex found it before I came back to it. A ternary chooses between its arms exactly as an if/else does, and so does an elvis, so the same conservative lowest-version reading applies. Safe navigation is the one question mark that chooses nothing, and it is the only one followed by a dot. Two cases in the behaviour table were wrong rather than the code: an elvis INSIDE an argument is a sequence, not a branch, and a safe-navigation case whose second version was unreadable would have suppressed for a different reason and tested nothing. Both are rewritten to isolate what they claim. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 42 +++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 74 +++++++++++++++++++ 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 1192cce4a08..7ba71d2eede 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -1243,7 +1243,7 @@ private static String versionInCall(String statement, String call) { // other, and which one is not readable here. The lowest is the answer then, // for the reason every unevaluable branch gets it -- a pre-merge version // that may be the live one has to stand the block down. - if (!containsAConditional(statement)) { + if (!containsAConditional(statement) && !holdsATernary(statement)) { return found.get(found.size() - 1); } String lowest = null; @@ -1277,6 +1277,25 @@ private static boolean containsAConditional(String statement) { return false; } + /** Whether a question mark outside a literal makes this an expression branch. */ + private static boolean holdsATernary(String statement) { + for (int i = 0; i < statement.length(); i++) { + if (isLiteralStart(statement, i)) { + i = endOfStringLiteral(statement, i); + continue; + } + // `legacy ? strictly('1.7.22') : strictly('1.9.22')` chooses between + // them exactly as an if/else does, and so does the elvis form. Safe + // navigation is the one question mark that branches nothing, and it is + // the only one followed by a dot. + if (statement.charAt(i) == '?' + && (i + 1 >= statement.length() || statement.charAt(i + 1) != '.')) { + return true; + } + } + return false; + } + /** * Every quoted argument of every syntactic {@code call} in the statement, in * source order. @@ -2852,7 +2871,16 @@ private static String[] statements(String text) { if (depth > 0) { depth--; } - } else if ((c == '\n' || c == ';') && depth == 0) { + } else if ((c == '\n' || c == '\r' || c == ';') && depth == 0) { + // A bare carriage return ends a line in Groovy exactly as a newline + // does. Recognising only the newline merged every statement of a + // CR-only fragment into one, so a main-variant configuration paired + // with a debug-only coordinate and that artifact read as declared. + // CRLF is one break, not two: the newline behind a return is eaten + // here rather than splitting again on an already-empty statement. + if (c == '\r' && i + 1 < text.length() && text.charAt(i + 1) == '\n') { + i++; + } // A trailing comma continues the statement. Groovy's parenthesis-free // map notation spreads one declaration over several lines -- // implementation group: 'org.jetbrains.kotlin', @@ -2861,7 +2889,7 @@ private static String[] statements(String text) { // -- and splitting there left the configuration, the group, the // artifact and any closure in four statements, none of which is a // declaration on its own. - if (c == '\n' && endsWithComma(current)) { + if (c != ';' && endsWithComma(current)) { current.append(' '); continue; } @@ -2869,12 +2897,12 @@ private static String[] statements(String text) { // coordinate on the next line was split into a configuration with // no dependency and a coordinate with no configuration, so neither // said anything and the strict pin between them went unread. - if (c == '\n' && endsWithLineContinuation(current)) { + if (c != ';' && endsWithLineContinuation(current)) { current.setLength(current.length() - 1); current.append(' '); continue; } - if (c == '\n' && opensAnUnbracedBody(current.toString())) { + if (c != ';' && opensAnUnbracedBody(current.toString())) { // An `if (...)` with no brace takes the next line as its body, so // splitting there put the condition in one statement and the body // in another -- and a resolution rule written that way had the @@ -2883,7 +2911,7 @@ private static String[] statements(String text) { current.append(' '); continue; } - out.add(current.toString().replace('\n', ' ')); + out.add(current.toString().replace('\n', ' ').replace('\r', ' ')); current.setLength(0); continue; } @@ -2900,7 +2928,7 @@ private static String[] statements(String text) { out.add(dangling[i]); } } else { - out.add(current.toString().replace('\n', ' ')); + out.add(current.toString().replace('\n', ' ').replace('\r', ' ')); } } // Definitions are folded in FIRST, because the merge below only absorbs a diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 1d99b327e97..3b164737ec7 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -771,6 +771,80 @@ public void aDefinitionMayCopyAnotherOne() { "and copying something unknown binds nothing"); } + /** + * A bare carriage return ends a line in Groovy exactly as a newline does. + * The comment scan learned that; the statement splitter had not, so every + * statement of a CR-only fragment merged into one and a main-variant + * configuration paired with a debug-only coordinate. + */ + @Test + public void aBareCarriageReturnSeparatesStatements() { + String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; + String separate = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'com.example:other:1.0'\r" + + " debugImplementation '" + jdk8 + ":1.9.22'\r"); + check(separate.contains("kotlin-stdlib-jdk8:1.8.0"), + "a debug-only coordinate is not the main declaration, got <<" + + separate + ">>"); + + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'com.example:other:1.0'\r" + + " implementation '" + jdk8 + ":1.7.22!!'\r")), + "and a pin on its own CR-terminated line is read"); + + // CRLF is one break, not two. + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'com.example:other:1.0'\r\n" + + " debugImplementation '" + jdk8 + ":1.9.22'\r\n") + .contains("kotlin-stdlib-jdk8:1.8.0"), + "CRLF does not split twice"); + + // Everything that continues a statement across a newline continues it + // across a carriage return. + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation \\\r '" + jdk8 + ":1.7.22!!'\r")), + "a line continuation still continues"); + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.eachDependency " + + "{ d ->\r if (d.requested.name == 'kotlin-stdlib')\r" + + " d.useVersion '1.7.22'\r } }\r")), + "an unbraced body still joins its condition"); + } + + /** + * A ternary chooses between its arms exactly as an if/else does, and so does + * an elvis. Read as a sequence, only the last setter counted -- so the arm + * holding a strict pre-merge version was passed over. + */ + @Test + public void aTernaryChoosesBetweenVersionsToo() { + String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; + String[] branched = { + " implementation('" + jdk8 + "') { version { " + + "legacy ? strictly('1.7.22') : strictly('1.9.22') } }\n", + " implementation('" + jdk8 + "') { version { " + + "legacy ? strictly('1.9.22') : strictly('1.7.22') } }\n", + " implementation('" + jdk8 + "') { version { " + + "legacy ?: strictly('1.9.22') ; strictly '1.7.22' } }\n", + }; + for (int i = 0; i < branched.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + branched[i]); + check("".equals(out), "either arm may run, got <<" + out + ">>"); + } + + // Safe navigation is the one question mark that chooses nothing, so the + // setters either side of it stay a sequence and the last one wins. Both + // versions are readable on purpose: an unreadable one would suppress for + // a different reason and test nothing. + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('" + jdk8 + "') { version { " + + "strictly '1.7.22'; strictly '1.9.22' } " + + "because project?.name.toString() }\n") + .contains("kotlin-stdlib-jdk7:1.8.0"), + "safe navigation is not a branch"); + } + /** * Groovy ends a line at a bare carriage return too. Searching only for the * newline swallowed the whole remainder of a CR-only fragment as part of a From aff4b0ea4157ec4be9f37d3c418e7f442dc19c5b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:14:15 +0300 Subject: [PATCH 74/94] A redundant parenthesis is still the same argument Groovy accepts `implementation(('g:a:1.7.22!!'))`, and the walk stepped over one parenthesis. Looking at the other it found something that is not an identifier, so the strict pin read as nobody's argument and the constraints went in against it. Fixing the direct case exposed the same assumption one level up: the enclosing call was taken as the innermost open parenthesis, and a redundant pair has no name in front of it -- so `add('impl', ('g:a:1.7 .22!!'))` found a comma where the call should be. The search goes outward now until a parenthesis has a name before it, which is what makes it a call rather than grouping. Wrapping something in parentheses still does not make it a declaration: a parenthesised literal alone, a logger call, a list element and a helper on another receiver all declare nothing. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 28 +++++++++--- .../builders/KotlinStdlibAlignmentTest.java | 43 +++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 7ba71d2eede..ea11b5c8901 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -892,6 +892,16 @@ private static boolean isADependencyHandlerAdder(String method) { /** Whether the literal at {@code quoteAt} is an argument of a declaring call. */ private static boolean isDeclarationArgument(String line, int quoteAt) { int i = skipBlanksBackward(line, quoteAt - 1); + // Every parenthesis, not one. Groovy accepts a redundant pair -- + // `implementation(('g:a:1.7.22!!'))` -- and stepping over a single one + // left the walk looking at the other, which is not an identifier, so the + // strict pin read as nobody's argument and the constraints went in + // against it. Done before the comma and brace are looked for, because a + // wrapped LATER argument -- `add('impl', ('g:a:1.7.22!!'))` -- reaches + // them only once the parentheses are behind it. + while (i >= 0 && line.charAt(i) == '(') { + i = skipBlanksBackward(line, i - 1); + } if (i >= 0 && (line.charAt(i) == ',' || line.charAt(i) == '{')) { // Not the first thing the call was handed. A comma is where a // coordinate sits in `dependencies.add('implementation', 'g:a:1.7!!')`, @@ -907,9 +917,6 @@ private static boolean isDeclarationArgument(String line, int quoteAt) { // base library, which has a scan of its own that comes through here. return isDeclarationCall(line, enclosingCallOf(line, quoteAt)); } - if (i >= 0 && line.charAt(i) == '(') { - i = skipBlanksBackward(line, i - 1); - } if (i < 0 || !isIdentifierChar(line.charAt(i))) { // Not an argument of anything -- a bare literal in a list, or an // assignment's value. The use of the name decides those, not this. @@ -940,9 +947,15 @@ private static int enclosingCallOf(String line, int at) { opened.remove(opened.size() - 1); } } - if (!opened.isEmpty()) { - return skipBlanksBackward(line, - opened.get(opened.size() - 1).intValue() - 1); + // Outward until one of them is a CALL's parenthesis. A redundant pair has + // no name in front of it, and stopping at the innermost reported the + // punctuation before it -- so `add('impl', ('g:a:1.7.22!!'))` found a + // comma where the call should be and read the pin as nobody's argument. + for (int k = opened.size() - 1; k >= 0; k--) { + int before = skipBlanksBackward(line, opened.get(k).intValue() - 1); + if (before >= 0 && isIdentifierChar(line.charAt(before))) { + return before; + } } // No parentheses anywhere, so this is Groovy's command syntax and the call // is the statement's first token -- unless the statement is an assignment, @@ -2677,7 +2690,8 @@ && isAddCallArgument(line, i)) { */ private static boolean isAddCallArgument(String line, int quoteAt) { int i = skipBlanksBackward(line, quoteAt - 1); - if (i >= 0 && line.charAt(i) == '(') { + // Every parenthesis, for the reason isDeclarationArgument gives. + while (i >= 0 && line.charAt(i) == '(') { i = skipBlanksBackward(line, i - 1); } if (i < 0 || !isIdentifierChar(line.charAt(i))) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 3b164737ec7..aa204147ab9 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -1098,6 +1098,49 @@ public void anEscapedNewlineContinuesTheStatement() { "and a merged-era one is read as the declaration it is"); } + /** + * Groovy accepts a redundant parenthesis, and the walk stepped over one. + * Looking at the other it found something that is not an identifier, so the + * strict pin read as nobody's argument and the constraints went in against + * it. + */ + @Test + public void aRedundantParenthesisIsStillTheSameArgument() { + String pin = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!"; + String[] declarations = { + " implementation(('" + pin + "'))\n", + " implementation((('" + pin + "')))\n", + " implementation( ( '" + pin + "' ) )\n", + " implementation('" + pin + "')\n", + " implementation '" + pin + "'\n", + // A later argument reaches the comma only once the parentheses are + // behind it, and the enclosing call is the one with a NAME in front + // of it -- a redundant pair has none, so the search goes outward. + " dependencies.add('implementation', ('" + pin + "'))\n", + " dependencies.addProvider('implementation', " + + "providers.provider { ('" + pin + "') })\n", + }; + for (int i = 0; i < declarations.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + declarations[i]); + check("".equals(out), "<<" + declarations[i].trim() + + ">> declares a strict pin, got <<" + out + ">>"); + } + + // Wrapping something in parentheses does not make it a declaration. + String[] strangers = { + " (('" + pin + "'))\n", + " logger.lifecycle(('" + pin + "'))\n", + " myList.add('implementation', ('" + pin + "'))\n", + " def all = [('" + pin + "')]\n", + }; + for (int i = 0; i < strangers.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", + strangers[i]).contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + strangers[i].trim() + ">> declares nothing"); + } + } + /** * The dependency handler has three adders and may grow more, and the * coordinate one of them is handed may sit inside a provider closure. Only From 5dc284305552168b99dc3baae5a9d757efb3a5fa Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:51:50 +0300 Subject: [PATCH 75/94] A soft pre-merge declaration is raised, not honoured A plain coordinate version is a SOFT requirement in Gradle, exactly as a rich `require` is. Below the floor the constraint raises it and the two agree, so an app that declares an old shim directly should be aligned -- and instead the block stood down, or skipped that artifact's own constraint, keeping the pre-merge shim beside whatever selected a merged-era base. That is the duplicate this exists to prevent, in the graph it exists for. The rich `require` half of this was taken two rounds ago and coordinates were deliberately left out, on the strength of a measured note about emitting "only the surviving sibling". That measurement was about ASYMMETRIC emission, which cannot happen now: a soft below-floor declaration is neither suppressed nor skipped, so both constraints go out and the family lands at the floor together. Only below the floor, and only a plain version. At or above it a soft version already satisfies the constraint, so leaving that artifact to the app says something true and costs nothing. A range is satisfied or it is not -- `[1.0,1.5]` and 1.8.0 have no version in common -- and a version that cannot be read says nothing about what it will be; neither can be raised, so both stay conservative. Two smaller findings ride along. Gradle orders `1.8` below `1.8.0`, so padding the missing segment with zero made a strict `[1.7,1.8]` look like it admits the floor when it stops just short of it. And a `switch` arm is an alternative exactly as an `if` or a ternary is. Forty-eight tests observed the old semantics, nearly all using a soft pre-merge coordinate as the VEHICLE for "this spelling is read as a declaration". Their intent is unchanged and their vehicle is now a strict pin; three that use a non-main configuration keep the soft one, because there the point is that the configuration does not count. The sweeps needed the same correction. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 67 ++++++- .../builders/KotlinStdlibAlignmentTest.java | 175 ++++++++++++++---- 2 files changed, 197 insertions(+), 45 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index ea11b5c8901..9df5eee9c72 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -516,15 +516,51 @@ private static String declaredVersionOf(String line, String artifact) { * it.

*/ private static boolean heldOnlyBySoftRequirement(String line, String artifact) { - String required = versionInCall(line, "require"); - if (required == null || required.endsWith(STRICT_SUFFIX)) { + if (callsStrictly(line) || callsForce(line, artifact) || rejectsTheFloor(line)) { return false; } - if (callsStrictly(line) || callsForce(line, artifact) || rejectsTheFloor(line)) { + String declared = declaredVersionOf(line, artifact); + if (declared == null || declared.endsWith(STRICT_SUFFIX)) { + // Unreadable stays conservative, and the `!!` suffix is a pin. + return false; + } + // Only BELOW the floor. A soft version there is the case this is for: the + // constraint raises it and the two agree, so neither standing the block + // down nor skipping that artifact is right -- both leave the shim + // pre-merge beside whatever selected a merged-era base, which is the + // duplicate this exists to prevent. + // + // At or above the floor a soft version already satisfies the constraint, + // so leaving that artifact to the app costs nothing and says something + // true: the app has it in hand. A plain coordinate is soft in exactly the + // way a `require` is, which is why they are one question here now. + return isAPlainVersion(declared) && belowTheFloor(declared); + } + + /** + * Whether the version is an ordinary one rather than a selector or an + * unreadable reference. + * + *

What makes the exemption above safe is that the constraint can RAISE + * the version: {@code 1.7.22} and a floor of 1.8.0 agree on 1.8.0. Nothing + * else here can be raised that way. A range is satisfied or it is not -- + * {@code [1.0,1.5]} and 1.8.0 have no version in common, so exempting one + * would write a constraint that cannot resolve. And a version this cannot + * read at all, {@code $mystery} or {@code latest.release}, says nothing + * about what it will be, which is the conservative path everywhere else.

+ */ + private static boolean isAPlainVersion(String version) { + if (version.length() == 0 || !Character.isDigit(version.charAt(0))) { return false; } - return coordinateVersionOf(line, artifact) == null - && mapEntryValue(line, "version") == null; + for (int i = 0; i < version.length(); i++) { + char c = version.charAt(i); + if (c == '[' || c == ']' || c == '(' || c == ')' || c == ',' + || c == '+' || c == '$' || c == '{') { + return false; + } + } + return true; } /** The version the artifact's own coordinate carries, or null. */ @@ -1282,7 +1318,12 @@ private static boolean containsAConditional(String statement) { end++; } String token = statement.substring(i, end); - if ("if".equals(token) || "else".equals(token)) { + // A switch arm is an alternative like any other, and `case` alone is + // enough to say so -- reading the last version kept whichever arm was + // written last rather than whichever runs. + if ("if".equals(token) || "else".equals(token) + || "switch".equals(token) || "case".equals(token) + || "default".equals(token)) { return true; } i = end - 1; @@ -1839,14 +1880,22 @@ private static boolean isPrerelease(String version) { private static int compareVersions(String left, String right) { String[] l = left.split("\\."); String[] r = right.split("\\."); - int len = Math.max(l.length, r.length); + int len = Math.min(l.length, r.length); for (int i = 0; i < len; i++) { - int a = i < l.length ? parseSegment(l[i]) : 0; - int b = i < r.length ? parseSegment(r[i]) : 0; + int a = parseSegment(l[i]); + int b = parseSegment(r[i]); if (a != b) { return a < b ? -1 : 1; } } + // Equal as far as both go, so the SHORTER one is lower. Gradle orders + // `1.8` below `1.8.0`, and padding the missing segment with zero called + // them equal -- so a strict `[1.7,1.8]` looked like it admitted the floor + // when it stops just short of it, and the constraints went into a graph + // that cannot resolve them. + if (l.length != r.length) { + return l.length < r.length ? -1 : 1; + } return 0; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index aa204147ab9..1495718dd30 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -260,7 +260,7 @@ public void aPinOnAnyMainConfigurationSuppresses() { for (String configuration : configurations) { String out = KotlinStdlibAlignment.constraintsBlock("implementation", " " + configuration - + "('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22')\n"); + + "('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')\n"); check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), "a pin on " + configuration + " is the app managing jdk8"); check("".equals(out), @@ -364,13 +364,13 @@ public void theWordStrictlyInsideAStringIsNotAStrictPin() { public void aMapEntryMayHaveSpaceAroundItsColon() { String spaced = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation(group : 'org.jetbrains.kotlin', " - + "name : 'kotlin-stdlib-jdk8', version : '1.7.22')\n"); + + "name : 'kotlin-stdlib-jdk8', version : '1.7.22!!')\n"); check("".equals(spaced), "a spaced map entry still pins jdk8, below the floor so both go"); String doubleQuoted = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation(group: \"org.jetbrains.kotlin\", " - + "name:\"kotlin-stdlib-jdk8\", version: \"1.7.22\")\n"); + + "name:\"kotlin-stdlib-jdk8\", version: \"1.7.22!!\")\n"); check("".equals(doubleQuoted), "and so does an unspaced double-quoted one"); @@ -418,12 +418,12 @@ public void aStrictPinOnTheBaseStdlibBlocksBothShims() { @Test public void aPreMergeShimPinSuppressesItsSiblingToo() { String jdk8Pinned = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n"); check("".equals(jdk8Pinned), "a pre-merge jdk8 pin takes the jdk7 constraint with it"); String jdk7Pinned = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.22'\n"); + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.22!!'\n"); check("".equals(jdk7Pinned), "and the same the other way round"); } @@ -628,7 +628,7 @@ public void aMapKeyMayBeQuoted() { String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation(" + u + "group" + u + ": 'org.jetbrains.kotlin', " + u + "name" + u + ": 'kotlin-stdlib-jdk8', " - + u + "version" + u + ": '1.7.22')\n"); + + u + "version" + u + ": '1.7.22!!')\n"); check("".equals(out), "a key quoted with " + u + " is still a key, got <<" + out + ">>"); } @@ -636,7 +636,7 @@ public void aMapKeyMayBeQuoted() { // Mixed spellings in one declaration, which Groovy also accepts. String mixed = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation('group': 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk8', \"version\": '1.7.22')\n"); + + "name: 'kotlin-stdlib-jdk8', \"version\": '1.7.22!!')\n"); check("".equals(mixed), "mixed key spellings, got <<" + mixed + ">>"); // And a merged-era one written the same way keeps the sibling aligned. @@ -771,6 +771,64 @@ public void aDefinitionMayCopyAnotherOne() { "and copying something unknown binds nothing"); } + /** + * A plain coordinate version is a SOFT requirement in Gradle, exactly as a + * rich {@code require} is, and below the floor the constraint raises it. So + * an app that declares an old shim directly is aligned rather than left + * alone: standing the block down there, or skipping that artifact's own + * constraint, kept the pre-merge shim beside whatever selected a merged-era + * base -- the duplicate this exists to prevent, in the graph it exists for. + */ + @Test + public void aSoftPreMergeDeclarationIsRaisedRatherThanHonoured() { + String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; + String[] soft = { + " implementation '" + jdk8 + ":1.7.22'\n", + " implementation('" + jdk8 + ":1.7.22')\n", + " implementation group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib-jdk8', version: '1.7.22'\n", + " implementation('" + jdk8 + "') { version { require '1.7.22' } }\n", + }; + for (int i = 0; i < soft.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + soft[i]); + check(out.contains("kotlin-stdlib-jdk7:1.8.0") + && out.contains("kotlin-stdlib-jdk8:1.8.0"), + "<<" + soft[i].trim() + ">> is raised, and its sibling with it, " + + "got <<" + out + ">>"); + } + + // Anything that really PINS it still stands the block down, because the + // constraint cannot raise those. + String[] firm = { + " implementation '" + jdk8 + ":1.7.22!!'\n", + " implementation('" + jdk8 + "') { version { strictly '1.7.22' } }\n", + " configurations.all { resolutionStrategy.force '" + jdk8 + ":1.7.22' }\n", + " implementation('" + jdk8 + "') { version { require '1.+'; " + + "reject '[1.8.0,)' } }\n", + // A range is satisfied or it is not: `[1.0,1.5]` and 1.8.0 have no + // version in common, so it cannot be raised either. + " implementation '" + jdk8 + ":[1.0,1.5]'\n", + // And a version this cannot read says nothing about what it will be. + " implementation \"" + jdk8 + ":$mystery\"\n", + }; + for (int i = 0; i < firm.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + firm[i]); + check("".equals(out), "<<" + firm[i].trim() + + ">> cannot be raised, got <<" + out + ">>"); + } + + // At or above the floor a soft version already satisfies the constraint, + // so that artifact is still left to the app and only its sibling raised. + String merged = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation '" + jdk8 + ":1.9.22'\n"); + check(merged.contains("kotlin-stdlib-jdk7:1.8.0") + && !merged.contains("kotlin-stdlib-jdk8:1.8.0"), + "a merged-era declaration stands in for its own constraint, got <<" + + merged + ">>"); + } + /** * A bare carriage return ends a line in Groovy exactly as a newline does. * The comment scan learned that; the statement splitter had not, so every @@ -811,6 +869,37 @@ public void aBareCarriageReturnSeparatesStatements() { "an unbraced body still joins its condition"); } + /** + * Gradle orders a shortened version below a longer one: {@code 1.8} is below + * {@code 1.8.0}. Padding the missing segment with zero called them equal, so + * a strict range that stops just short of the floor looked like it admitted + * it and the constraints went into a graph that cannot resolve them. + */ + @Test + public void aShortenedUpperBoundStopsShortOfTheFloor() { + String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; + String[] capped = { + " implementation('" + jdk8 + "') { version { strictly '[1.7,1.8]' } }\n", + " implementation('" + jdk8 + "') { version { strictly '[1.7,1.8.0)' } }\n", + }; + for (int i = 0; i < capped.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + capped[i]); + check("".equals(out), "<<" + capped[i].trim() + + ">> cannot admit the floor, got <<" + out + ">>"); + } + + String[] reaching = { + " implementation('" + jdk8 + "') { version { strictly '[1.7,1.8.0]' } }\n", + " implementation('" + jdk8 + "') { version { strictly '[1.7,1.9]' } }\n", + }; + for (int i = 0; i < reaching.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", + reaching[i]).contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + reaching[i].trim() + ">> admits the floor"); + } + } + /** * A ternary chooses between its arms exactly as an if/else does, and so does * an elvis. Read as a sequence, only the last setter counted -- so the arm @@ -822,6 +911,13 @@ public void aTernaryChoosesBetweenVersionsToo() { String[] branched = { " implementation('" + jdk8 + "') { version { " + "legacy ? strictly('1.7.22') : strictly('1.9.22') } }\n", + // A switch arm is an alternative like any other. + " implementation('" + jdk8 + "') { version { switch (mode) { " + + "case 'legacy': strictly '1.7.22'; break; " + + "default: strictly '1.9.22' } } }\n", + " implementation('" + jdk8 + "') { version { switch (mode) { " + + "case 'modern': strictly '1.9.22'; break; " + + "default: strictly '1.7.22' } } }\n", " implementation('" + jdk8 + "') { version { " + "legacy ? strictly('1.9.22') : strictly('1.7.22') } }\n", " implementation('" + jdk8 + "') { version { " @@ -1810,9 +1906,9 @@ public void syntaxQuotedInProseIsNotSyntax() { public void anAddCallMustBeOnADependencyHandler() { String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22"; String[] handlers = { - " dependencies.add('implementation', '" + pin + "')\n", - " project.dependencies.add('implementation', '" + pin + "')\n", - " dependencies {\n add 'implementation', '" + pin + "'\n }\n", + " dependencies.add('implementation', '" + pin + "!!')\n", + " project.dependencies.add('implementation', '" + pin + "!!')\n", + " dependencies {\n add 'implementation', '" + pin + "!!'\n }\n", }; for (int i = 0; i < handlers.length; i++) { check("".equals(KotlinStdlibAlignment.constraintsBlock( @@ -1935,7 +2031,7 @@ public void anExtraPropertyOutlivesTheBlockItWasSetIn() { "<<" + definitions[i].trim() + ">> is readable below, got <<" + merged + ">>"); check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - definitions[i].replace("'V'", "'1.7.22'") + use)), + definitions[i].replace("'V'", "'1.7.22!!'") + use)), "and a pre-merge one stands the block down"); } @@ -2055,7 +2151,7 @@ public void aStoredMapExpandsWhatItInterpolates() { "the interpolated version is read, got <<" + modern + ">>"); String old = KotlinStdlibAlignment.constraintsBlock("implementation", - " def v = '1.7.22'\n" + " def v = '1.7.22!!'\n" + " def dep = [group: 'org.jetbrains.kotlin', " + "name: 'kotlin-stdlib-jdk7', version: \"$v\"]\n" + " implementation(dep)\n"); @@ -2443,7 +2539,7 @@ public void aBracketHoldsAStatementTogether() { String across = KotlinStdlibAlignment.constraintsBlock("implementation", " configurations.all {\n" + " resolutionStrategy.forcedModules = [\n" - + " 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" + + " 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!'\n" + " ]\n" + " }\n"); check("".equals(across), @@ -2453,7 +2549,7 @@ public void aBracketHoldsAStatementTogether() { " implementation([\n" + " group: 'org.jetbrains.kotlin',\n" + " name: 'kotlin-stdlib-jdk8',\n" - + " version: '1.7.22'\n" + + " version: '1.7.22!!'\n" + " ])\n"); check("".equals(mapAcross), "and so does a map written across them, got <<" + mapAcross + ">>"); @@ -2538,7 +2634,7 @@ public void aTokenIsStillFoundAcrossAnyLineEnding() { for (int i = 0; i < endings.length; i++) { String reason = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation('com.example:other:1.0') { because" + endings[i] - + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22' }\n"); + + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!' }\n"); check(reason.contains("kotlin-stdlib-jdk8:1.8.0"), "the reason is still prose across " + endings[i].length() + " line-ending chars, got <<" + reason + ">>"); @@ -2546,7 +2642,7 @@ public void aTokenIsStillFoundAcrossAnyLineEnding() { String added = KotlinStdlibAlignment.constraintsBlock("implementation", " dependencies.add(" + endings[i] + " 'implementation'," + endings[i] - + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22')\n"); + + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')\n"); check("".equals(added), "and an add() call is still an add() call, got <<" + added + ">>"); } @@ -2643,7 +2739,7 @@ public void aMapEntryMayBeSplitByAnyLineEnding() { " implementation(group:" + endings[i] + " 'org.jetbrains.kotlin', name:" + endings[i] + " 'kotlin-stdlib-jdk8', version:" + endings[i] - + " '1.7.22')" + endings[i]); + + " '1.7.22!!')" + endings[i]); check("".equals(out), "the map entry survives the line ending, got <<" + out + ">>"); } @@ -2992,7 +3088,7 @@ public void aConditionalReassignmentDoesNotHideAPin() { public void aLocalNamedAfterAMapKeyDoesNotReplaceTheKey() { String[] keys = {"group", "name", "version"}; for (int k = 0; k < keys.length; k++) { - String value = "version".equals(keys[k]) ? "1.7.22" + String value = "version".equals(keys[k]) ? "1.7.22!!" : "name".equals(keys[k]) ? "kotlin-stdlib-jdk8" : "org.jetbrains.kotlin"; String out = KotlinStdlibAlignment.constraintsBlock("implementation", @@ -3002,7 +3098,7 @@ public void aLocalNamedAfterAMapKeyDoesNotReplaceTheKey() { + ", name: " + ("name".equals(keys[k]) ? "name" : "'kotlin-stdlib-jdk8'") + ", version: " - + ("version".equals(keys[k]) ? "version" : "'1.7.22'") + + ("version".equals(keys[k]) ? "version" : "'1.7.22!!'") + ")\n"); check("".equals(out), "the map form survives a local called " + keys[k] @@ -3149,7 +3245,7 @@ public void aDefinitionMayInterpolateAnEarlierOne() { // The same chain below the floor is still below it. String old = KotlinStdlibAlignment.constraintsBlock("implementation", - " def v = '1.7.22'\n" + " def v = '1.7.22!!'\n" + " def dep = \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v\"\n" + " implementation dep\n"); check("".equals(old), @@ -3602,7 +3698,7 @@ public void anAddedPreMergeShimSuppressesTheBlock() { for (int q = 0; q < quotes.length; q++) { String out = KotlinStdlibAlignment.constraintsBlock("implementation", " dependencies.add(" + quotes[q] + "implementation" + quotes[q] - + ", 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22')\n"); + + ", 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')\n"); check("".equals(out), "an added pre-merge shim suppresses the block, named with " + quotes[q] + " but got <<" + out + ">>"); @@ -3754,14 +3850,21 @@ public void aPreferenceDoesNotStandInForTheConstraint() { check(old.contains("kotlin-stdlib-jdk8:1.8.0"), "and neither does an old one, which the floor simply overrides"); - // Neither does a requirement, which is soft in the same way. This once - // asserted that it binds; it does not, and skipping the constraint for it - // is what left a softly-required shim pre-merge. + // A requirement AT OR ABOVE the floor does stand in for it: it already + // satisfies the constraint, so leaving that artifact to the app says + // something true. Below the floor it does not -- there the constraint + // raises it, and skipping is what left a softly-required shim pre-merge. String required = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " + "{ version { require '1.9.22' } }\n"); - check(required.contains("kotlin-stdlib-jdk8:1.8.0"), - "a required version does not stand in for the constraint either"); + check(!required.contains("kotlin-stdlib-jdk8:1.8.0"), + "a merged-era requirement stands in for the constraint"); + + String raised = KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " + + "{ version { require '1.7.22' } }\n"); + check(raised.contains("kotlin-stdlib-jdk8:1.8.0"), + "and a pre-merge one is raised rather than honoured"); // A requirement that OVERRIDES a coordinate is still read as the version // that declaration carries -- soft is about whether it pins, not about @@ -3894,7 +3997,7 @@ public void aTripleQuotedLiteralDoesNotEndOnItsOwnApostrophe() { @Test public void aRuntimeOnlyPreMergePinSuppressesBoth() { String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " runtimeOnly 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); + " runtimeOnly 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n"); check("".equals(out), "a runtimeOnly pre-merge pin takes the sibling constraint with it"); } @@ -3936,7 +4039,7 @@ public void aKnownDefinitionExpandsInsideAnInterpolatedString() { "the expanded version is merged-era, so the sibling stays constrained"); String braced = KotlinStdlibAlignment.constraintsBlock("implementation", - " def v = '1.7.22'\n" + " def v = '1.7.22!!'\n" + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:${v}\"\n"); check("".equals(braced), "and a pre-merge one still suppresses both"); @@ -4273,7 +4376,7 @@ public void aConcatenatedCoordinateIsNotRecovered() { // A coordinate assembled by concatenation is not a coordinate in the text. String concatenated = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:' + 'kotlin-stdlib-jdk8:1.7.22') " + " implementation('org.jetbrains.kotlin:' + 'kotlin-stdlib-jdk8:1.7.22!!') " + "{ version { strictly '1.7.22' } }\n"); check(concatenated.contains("kotlin-stdlib-jdk8:1.8.0"), "a concatenated coordinate is left unrecognised, by design"); @@ -4308,7 +4411,7 @@ public void aCommaContinuesAMultilineMapDeclaration() { String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation group: 'org.jetbrains.kotlin',\n" + " name: 'kotlin-stdlib-jdk8',\n" - + " version: '1.7.22'\n"); + + " version: '1.7.22!!'\n"); check("".equals(out), "a comma-continued map declaration pins jdk8, below the floor so both go"); } @@ -4411,7 +4514,7 @@ public void aCustomConfigurationIsNotTheMainOne() { // and the real one still is String real = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n"); check(!real.contains("kotlin-stdlib-jdk8:1.8.0"), "the main configuration still counts"); } @@ -4516,7 +4619,7 @@ public void aConfigurationNameInAReasonStringIsNotADeclaration() { // and the add() spelling it was widened for still works String add = KotlinStdlibAlignment.constraintsBlock("implementation", " dependencies.add(\"runtimeOnly\", " - + "\"org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22\")\n"); + + "\"org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!\")\n"); check(!add.contains("kotlin-stdlib-jdk8:1.8.0"), "the add() spelling is still recognised"); } @@ -4562,7 +4665,7 @@ public void anUnrelatedBlockDoesNotSwallowTheFragment() { public void theQuotedAddSpellingCountsAsAPin() { String out = KotlinStdlibAlignment.constraintsBlock("implementation", " dependencies.add(\"runtimeOnly\", " - + "\"org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22\")\n"); + + "\"org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!\")\n"); check("".equals(out), "a quoted configuration name still pins jdk8, and a below-floor pin " + "suppresses both"); @@ -4637,7 +4740,7 @@ public void anExclusionIsNotAPin() { public void aDeclarationSplitAcrossLinesIsStillAPin() { String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation(\n" - + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" + + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n" + " )\n"); check("".equals(out), "a wrapped declaration pins jdk8, below the floor so both go"); @@ -4651,7 +4754,7 @@ public void aDeclarationSplitAcrossLinesIsStillAPin() { @Test public void anInlineExclusionDoesNotCancelTheDeclaration() { String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!') " + "{ exclude group: 'com.example', module: 'thing' }\n"); check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), "the declaration survives its own inline exclusion"); @@ -4704,7 +4807,7 @@ public void aSemicolonEndsAStatement() { public void aSemicolonInsideAStringOrParensIsNotASeparator() { String out = KotlinStdlibAlignment.constraintsBlock("implementation", " implementation(\n" - + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" + + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n" + " )\n"); check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), "a wrapped declaration still pins"); From a0345907aa0129888605b71bccce41cf6b634648 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:12:17 +0300 Subject: [PATCH 76/94] Read the two value shapes the ext closure missed, and correct the hint `ext { dep = [group: '..', version: '1.7.22!!'] }` is the project-wide spelling of a map definition, and a name on the right copies an earlier binding. The bare-assignment path read only string literals, so both left the name unknown and the declaration using it named no artifact. Groovy's multiple assignment binds several names at once. The walk for a single declaration expects an identifier after `def`, found a parenthesis, and recorded nothing -- so `def (other, dep) = [.., '..:1.7.22']` left dep unbound and the pin at the use site was attached to nothing. Each name may carry a type, so the name is the last identifier of its element, and an element whose value is neither a literal nor a known name records nothing, which leaves it unknown rather than wrong. The catalog entry for android.kotlinStdlibAlignment promised that declaring one of these artifacts switches the constraint off for it. That stopped being true last round: an ordinary Gradle version is a soft requirement, so a pre-merge declaration is now RAISED to the empty shim rather than honoured. The text says which declarations still switch it off -- 1.8.0 or newer, a strict pin, a force -- and what happens to the rest. The first version of the destructuring test put the pin in the LIST, where the line suppresses on its own, so it passed whether the names were bound or not. The pin is at the use site now, which is the only place that needs the binding. The non-vacuity check that should have caught that was itself blind: it grepped for a failing assertion and an exception counts as an error, not a failure. Co-Authored-By: Claude Opus 5 (1M context) --- .../build/shared/BuildHintsAndroid.java | 13 +- .../builders/KotlinStdlibAlignment.java | 113 ++++++++++++++++++ .../builders/KotlinStdlibAlignmentTest.java | 78 ++++++++++++ 3 files changed, 200 insertions(+), 4 deletions(-) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index a7431912a94..fdb833affc3 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -651,10 +651,15 @@ static void register(List h) { + "and the build fails in `checkReleaseDuplicateClasses` naming Kotlin artifacts " + "the app never asked for. Expressed as a Gradle constraint, so it adds " + "nothing to an app with no Kotlin anywhere in its dependencies and never " - + "lowers a version. Set to false only to manage those coordinates yourself; " - + "declaring `kotlin-stdlib-jdk7` or `kotlin-stdlib-jdk8` in your own Gradle " - + "build hints already switches it off for that artifact, as does pinning one " - + "with a strict version. A Kotlin BOM has no such effect, and needs none: a " + + "lowers a version. Set to false only to manage those coordinates yourself. " + + "Declaring `kotlin-stdlib-jdk7` or `kotlin-stdlib-jdk8` at 1.8.0 or newer in " + + "your own Gradle build hints switches it off for that artifact, because your " + + "version already satisfies the floor. Declaring an OLDER one does not: an " + + "ordinary Gradle version is a soft requirement, so the constraint raises it " + + "to the empty shim rather than leaving the duplicate in place. To hold one " + + "below 1.8.0 on purpose, pin it strictly -- `1.7.22!!`, `version { strictly " + + "\'1.7.22\' }` -- or force it, which switches the whole block off. A Kotlin " + + "BOM has no such effect unless it is enforced, and needs none: a " + "BOM contributes ordinary constraints rather than strict ones, so a newer " + "BOM simply wins over this floor while an older BOM still needs it.")); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 9df5eee9c72..c104de8e658 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -3203,6 +3203,91 @@ private static int closingBracket(String text, int from) { return -1; } + /** + * Records a Groovy multiple assignment, and says whether the statement was + * one. + * + *

{@code def (other, dep) = ['com.example:x:1.0', 'g:a:1.7.22!!']} binds + * both names positionally. The walk for a single declaration expects an + * identifier after {@code def} and finds a parenthesis, so it recorded + * nothing at all and the pin the second name carried went unread.

+ * + *

Each name may carry a type, as a single declaration may, so the NAME is + * the last identifier of its element. An element whose value is neither a + * literal nor a known name records nothing, which leaves it unknown rather + * than wrong.

+ */ + private static boolean recordsADestructuring(String statement, int at, + Map literals, boolean conditional) { + int i = skipBlanks(statement, at); + if (i >= statement.length() || statement.charAt(i) != '(') { + return false; + } + List names = new ArrayList(); + i++; + while (i < statement.length() && statement.charAt(i) != ')') { + i = skipBlanks(statement, i); + String last = null; + while (i < statement.length() && isIdentifierChar(statement.charAt(i))) { + int start = i; + while (i < statement.length() && isIdentifierChar(statement.charAt(i))) { + i++; + } + last = statement.substring(start, i); + i = skipBlanks(statement, i); + } + names.add(last); + if (i < statement.length() && statement.charAt(i) == ',') { + i++; + } else { + break; + } + } + if (i >= statement.length() || statement.charAt(i) != ')' || names.isEmpty()) { + return false; + } + i = skipBlanks(statement, i + 1); + if (i >= statement.length() || !isAssignmentAt(statement, i)) { + return false; + } + i = skipBlanks(statement, i + 1); + if (i >= statement.length() || statement.charAt(i) != '[') { + // A list this cannot read binds every name to something unknown, + // which is what recording nothing already means. + return true; + } + int closes = closingBracket(statement, i); + i++; + for (int n = 0; n < names.size() && i < statement.length() + && (closes < 0 || i < closes); n++) { + i = skipBlanks(statement, i); + String value = null; + if (isLiteralStart(statement, i)) { + int end = endOfStringLiteral(statement, i); + if (end < statement.length()) { + value = expandedLiteral(statement, i, end, literals); + i = end + 1; + } + } else { + int start = i; + while (i < statement.length() && isIdentifierChar(statement.charAt(i))) { + i++; + } + if (i > start) { + value = literals.get(statement.substring(start, i)); + } + } + if (names.get(n) != null && value != null) { + recordDefinition(literals, names.get(n), value, conditional); + } + i = skipBlanks(statement, i); + if (i < statement.length() && statement.charAt(i) == ',') { + i++; + } + } + return true; + } + /** * Records a definition, or forgets it, unless doing so under a condition * would throw away the value that decides suppression. @@ -3340,6 +3425,9 @@ private static void updateLiteralDefinitions(String statement, // recorded a declaration that never executes, overwriting the real binding // and making a later use read as something it is not. int at = afterCall(statement, DEF); + if (at >= 0 && recordsADestructuring(statement, at, literals, conditional)) { + return; + } if (at >= 0) { declared = true; i = skipBlanks(statement, at); @@ -3785,6 +3873,31 @@ private static void recordBareAssignment(String body, Map litera if (end < body.length()) { literals.put(name, expandedLiteral(body, i, end, literals)); } + return; + } + // The other two shapes a value takes, which the ordinary definition path + // already reads: a map, and a name that copies an earlier binding. An + // `ext { dep = [group: '..', name: '..', version: '1.7.22!!'] }` block is + // the project-wide spelling of the same thing, and reading only literals + // left `dep` unknown -- so the declaration using it named no artifact and + // the pin it carried went unread. + if (i < body.length() && body.charAt(i) == '[') { + int closes = closingBracket(body, i); + if (closes > i) { + literals.put(name, + withLiteralsInlined(body.substring(i, closes + 1), literals)); + } + return; + } + if (i < body.length() && isIdentifierChar(body.charAt(i))) { + int end = i; + while (end < body.length() && isIdentifierChar(body.charAt(end))) { + end++; + } + String alias = literals.get(body.substring(i, end)); + if (alias != null && !followedByMapKeyColon(body, end)) { + literals.put(name, alias); + } } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 1495718dd30..17c1d3d5373 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -737,6 +737,84 @@ private static String rejecting(String rejections) { + " version { require '1.+'; " + rejections + " }\n }\n"; } + /** + * The two shapes a value takes that the bare-assignment path did not read. + * An {@code ext { dep = [..] }} block is the project-wide spelling of a map + * definition, and a name on the right copies an earlier binding; reading + * only literals left both unknown, so the declaration using one named no + * artifact and the pin it carried went unread. + */ + @Test + public void anExtraPropertiesClosureReadsEveryKindOfValue() { + String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!"; + String use = " implementation(dep)\n"; + String[] bound = { + " ext {\n dep = [group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib-jdk8', version: '1.7.22!!']\n }\n", + " def coord = '" + pin + "'\n ext {\n dep = coord\n }\n", + " ext {\n dep = '" + pin + "'\n }\n", + }; + for (int i = 0; i < bound.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + bound[i] + use); + check("".equals(out), "<<" + bound[i].trim() + ">> binds dep, got <<" + + out + ">>"); + } + + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " ext {\n dep = [group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib-jdk8', version: '1.9.22']\n }\n" + use) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "a merged-era map through the same route is read too"); + } + + /** + * Groovy's multiple assignment binds several names at once. The walk for a + * single declaration expects an identifier after {@code def} and finds a + * parenthesis, so it recorded nothing and the pin one of the names carried + * was invisible. + */ + @Test + public void aMultipleAssignmentBindsEveryName() { + // The coordinate in the list is SOFT and the pin is at the use site, so + // the binding is the only thing that can connect them. Putting the pin + // in the list instead makes the list itself suppress, and the test then + // passes whether the names are bound or not -- which is how the first + // version of this went vacuous. + String coord = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22"; + String use = " implementation(dep) { version { strictly '1.7.22' } }\n"; + String[] destructured = { + " def (other, dep) = ['com.example:x:1.0', '" + coord + "']\n", + " def (dep, other) = ['" + coord + "', 'com.example:x:1.0']\n", + // Each name may carry a type, as a single declaration may. + " def (String other, String dep) = ['com.example:x:1.0', '" + + coord + "']\n", + }; + for (int i = 0; i < destructured.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + destructured[i] + use); + check("".equals(out), "<<" + destructured[i].trim() + + ">> binds dep, got <<" + out + ">>"); + } + + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " def (other, dep) = ['com.example:x:1.0', " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22']\n" + + " implementation(dep)\n") + .contains("kotlin-stdlib-jdk7:1.8.0"), + "a merged-era element is read too"); + // A list this cannot read binds every name to something unknown, which + // is what recording nothing already means. + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " def (other, dep) = someCall()\n" + + " implementation(dep)\n") + .contains("kotlin-stdlib-jdk7:1.8.0"), + "an unreadable list binds nothing"); + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = '" + coord + "'\n" + use)), + "and a plain def still works"); + } + /** * A value that is a NAME rather than a literal copies a binding that is * already known. Reading only literals recorded the new name as unknown, so From d41c82554310be2490c3c2fd2610c5604f4db77f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:28:39 +0300 Subject: [PATCH 77/94] Keep a command call from clearing its argument, and honour a rejected candidate Two identifiers in a row are as often a parenthesis-free call as a typed local. Read as a declaration, `println dep` cleared the very binding it was printing, so the pin that name carried was gone by the time anything used it. Only a keyword now says a valueless statement declares something: a genuinely valueless `String dep` records nothing, which leaves the name unknown rather than wrong, while `def dep` still introduces it. A component-selection rule rejects CANDIDATES, outside any declaration, so the rejection reading that lives on a declaration never saw it. Such a rule can remove the very version this writes and leave the constraint nothing to resolve to. Any rejecting rule that mentions this family stands the block down: which candidates a closure will reject cannot be read here, and being wrong the other way emits a requirement into a graph that has excluded it. Also fixes the developer-guide prose gate, which the catalog rewrite in the previous commit broke -- the hint text is rendered into the guide's table, so Vale lints it. Three issues, all in the sentences added there: contractions and an adverb. Reproduced locally against the exact prose CI rejected before pushing this time. Co-Authored-By: Claude Opus 5 (1M context) --- .../build/shared/BuildHintsAndroid.java | 9 ++- .../builders/KotlinStdlibAlignment.java | 29 +++++++- .../builders/KotlinStdlibAlignmentTest.java | 73 +++++++++++++++++++ 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index fdb833affc3..885e481be7b 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -654,12 +654,13 @@ static void register(List h) { + "lowers a version. Set to false only to manage those coordinates yourself. " + "Declaring `kotlin-stdlib-jdk7` or `kotlin-stdlib-jdk8` at 1.8.0 or newer in " + "your own Gradle build hints switches it off for that artifact, because your " - + "version already satisfies the floor. Declaring an OLDER one does not: an " + + "version already satisfies the floor. Declaring an older one doesn\'t: an " + "ordinary Gradle version is a soft requirement, so the constraint raises it " + "to the empty shim rather than leaving the duplicate in place. To hold one " - + "below 1.8.0 on purpose, pin it strictly -- `1.7.22!!`, `version { strictly " - + "\'1.7.22\' }` -- or force it, which switches the whole block off. A Kotlin " - + "BOM has no such effect unless it is enforced, and needs none: a " + + "below 1.8.0 on purpose, give it a strict version -- `1.7.22!!` or " + + "`version { strictly \'1.7.22\' }` -- or force it, which switches the whole " + + "block off. A Kotlin BOM has no such effect unless it\'s enforced, and " + + "needs none: a " + "BOM contributes ordinary constraints rather than strict ones, so a newer " + "BOM simply wins over this floor while an older BOM still needs it.")); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index c104de8e658..e6b603b996e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -220,6 +220,24 @@ public static String constraintsBlock(String configuration, // written that would not conflict, so nothing is. String[] active = activeLines(combined(appGradleFragments)); for (int i = 0; i < active.length; i++) { + // A component-selection rule rejects CANDIDATES, outside any + // declaration, so the rejection reading that lives on a declaration + // never saw it: `componentSelection { all { if (it.candidate.module + // == 'kotlin-stdlib-jdk8' && it.candidate.version == '1.8.0') + // it.reject('..') } }` removes the very version this writes, and the + // constraint then has nothing to resolve to. + // + // Any rejecting rule that mentions this family at all stands the block + // down. Which candidates a closure will reject cannot be read here, + // and being wrong the other way emits a requirement into a graph that + // has excluded it. + if (callsNamed(active[i], "componentSelection") + && callsNamed(active[i], "reject") + && (namesOneOfTheFamily(active[i]) + || holdsLiteral(active[i], KOTLIN_GROUP)) + && governsTheConstrainedGraph(active[i], config)) { + return ""; + } // An ENFORCED platform is the one case a Kotlin BOM stands this down. // A plain `platform()` does not, and the class comment says why it was // measured not to: a BOM's constraints are ordinary, so the higher @@ -3411,6 +3429,7 @@ private static void updateLiteralDefinitions(String statement, boolean declared = false; boolean subscript = false; boolean callForm = false; + boolean typed = false; // An extra property is NOT block scoped. A local declared inside a block // leaves with it, which is why declarations carry their depth -- but // `buildscript { ext.kotlin_version = '1.9.22' }` sets a project-wide @@ -3521,6 +3540,7 @@ && isIdentifierChar(statement.charAt(scan + 1))))) { } if (tokens > 1 && !followedByMapKeyColon(statement, lastTokenEnd)) { declared = true; + typed = true; i = lastTokenStart; } else if (tokens == 1) { // ext.kotlinVersion = '1.9.22' -- Gradle's extra properties, which is @@ -3630,13 +3650,20 @@ && delimiterLength(statement, nameAt) == 1) { i++; } else if (i >= statement.length() || statement.charAt(i) != '=' || (i + 1 < statement.length() && statement.charAt(i + 1) == '=')) { - if (declared) { + if (declared && !typed) { // `def dep` with no value yet is still a name this knows about, and // recording it is what lets a later assignment be recognised as one. // Without it, `def dep` then `if (legacy) { dep = '...' }` left the // assignment looking like a write to something unrelated, so the // coordinate it carried was never learned. A null value inlines as // the name itself, which is what an unset variable should look like. + // + // Only where a KEYWORD said it was a declaration. Two identifiers in + // a row are as often a parenthesis-free call as a typed local, and + // `println dep` was clearing the very binding it was printing -- so + // the pin that name carried was gone by the time anything used it. + // A genuinely valueless `String dep` records nothing now, which + // leaves the name unknown rather than wrong. literals.put(name, null); } return; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 17c1d3d5373..03bb486d1a2 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -815,6 +815,79 @@ public void aMultipleAssignmentBindsEveryName() { "and a plain def still works"); } + /** + * Two identifiers in a row are as often a parenthesis-free call as a typed + * local. Read as a declaration, {@code println dep} cleared the very binding + * it was printing, so the pin that name carried was gone by the time + * anything used it. + */ + @Test + public void aCommandCallDoesNotClearItsArgument() { + String coord = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22"; + String use = " implementation(dep) { version { strictly '1.7.22' } }\n"; + String[] kept = { + " def dep = '" + coord + "'\n println dep\n", + " def dep = '" + coord + "'\n logger dep\n", + " def dep = '" + coord + "'\n", + // A real typed declaration WITH a value still binds. + " String dep = '" + coord + "'\n", + }; + for (int i = 0; i < kept.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + kept[i] + use); + check("".equals(out), "<<" + kept[i].trim() + ">> keeps dep bound, got <<" + + out + ">>"); + } + + // And `def` with no value is still a name this knows about, which is what + // lets the assignment below it be recognised as one. + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep\n if (legacy) { dep = '" + coord + "' }\n" + + use)), + "a valueless def still introduces the name"); + } + + /** + * A component-selection rule rejects CANDIDATES, outside any declaration, so + * the rejection reading that lives on a declaration never saw it. Such a rule + * can remove the very version this writes, and the constraint then has + * nothing to resolve to. + */ + @Test + public void aComponentSelectionRuleMayRejectTheFloor() { + String[] rejecting = { + " configurations.all { resolutionStrategy.componentSelection { all { " + + "if (it.candidate.module == 'kotlin-stdlib-jdk8' && " + + "it.candidate.version == '1.8.0') it.reject('unsupported') } } }\n", + " configurations.all { resolutionStrategy.componentSelection { all { " + + "if (it.candidate.group == 'org.jetbrains.kotlin') " + + "it.reject('unsupported') } } }\n", + }; + for (int i = 0; i < rejecting.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + rejecting[i]); + check("".equals(out), "a rule that may reject the floor stands the block " + + "down, got <<" + out + ">>"); + } + + // A rule that rejects nothing, one that does not mention this family, and + // one on a configuration the constraint never reaches all leave it alone. + String[] harmless = { + " configurations.all { resolutionStrategy.componentSelection { all { " + + "logger.info(it.candidate.module) } } }\n", + " configurations.all { resolutionStrategy.componentSelection { all { " + + "if (it.candidate.module == 'okhttp') it.reject('x') } } }\n", + " configurations.create('tooling').resolutionStrategy" + + ".componentSelection { all { if (it.candidate.group == " + + "'org.jetbrains.kotlin') it.reject('x') } }\n", + }; + for (int i = 0; i < harmless.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", harmless[i]) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + harmless[i].trim() + ">> rejects nothing this writes"); + } + } + /** * A value that is a NAME rather than a literal copies a binding that is * already known. Reading only literals recorded the new name as unknown, so From 1463f7e8090354f98c7070c6d9a3023976c8934a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:43:33 +0300 Subject: [PATCH 78/94] Read a selection rule across its body, and an unreadable arm as an arm A component-selection rule is normally written over several lines, and then its opener, its predicate and its reject are three statements -- so the one-statement reading added last round saw none of them together. The rule is read across its whole body now, carrying the configuration it belongs to from the statement that names it, since that is usually an earlier one. A call with no literal argument still HAPPENED, and what it set is unknown. Recorded as nothing, `if (legacy) strictly providers.gradleProperty('k') .get() else strictly '1.9.22'` looked like a single readable branch, so the lowest was the arm that could be read and the constraints went in beside a pin that may well be pre-merge. Such a call is an unknown alternative now, and unknown wins over every readable branch beside it. A SEQUENCE ending in a readable call is still read: there the last one wins and it is known. Recording unknowns as nulls broke two callers that assumed otherwise -- an NPE in the enforced-platform scan, caught by its own test and by the Bom and Rule sweeps. Every caller of versionsInCall is null-safe now: the enforced-platform one skips them, because a call carrying no literal is the map form its own entries answer, and the rejection one treats them as possibly removing the floor. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 104 +++++++++++++++--- .../builders/KotlinStdlibAlignmentTest.java | 88 +++++++++++++++ 2 files changed, 174 insertions(+), 18 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index e6b603b996e..aaf80e25bec 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -219,25 +219,10 @@ public static String constraintsBlock(String configuration, // "Conflict found ... between versions 1.8 and 1.7". Nothing here can be // written that would not conflict, so nothing is. String[] active = activeLines(combined(appGradleFragments)); + if (aComponentSelectionRuleRejectsTheFloor(active, config)) { + return ""; + } for (int i = 0; i < active.length; i++) { - // A component-selection rule rejects CANDIDATES, outside any - // declaration, so the rejection reading that lives on a declaration - // never saw it: `componentSelection { all { if (it.candidate.module - // == 'kotlin-stdlib-jdk8' && it.candidate.version == '1.8.0') - // it.reject('..') } }` removes the very version this writes, and the - // constraint then has nothing to resolve to. - // - // Any rejecting rule that mentions this family at all stands the block - // down. Which candidates a closure will reject cannot be read here, - // and being wrong the other way emits a requirement into a graph that - // has excluded it. - if (callsNamed(active[i], "componentSelection") - && callsNamed(active[i], "reject") - && (namesOneOfTheFamily(active[i]) - || holdsLiteral(active[i], KOTLIN_GROUP)) - && governsTheConstrainedGraph(active[i], config)) { - return ""; - } // An ENFORCED platform is the one case a Kotlin BOM stands this down. // A plain `platform()` does not, and the class comment says why it was // measured not to: a BOM's constraints are ordinary, so the higher @@ -523,6 +508,64 @@ private static String declaredVersionOf(String line, String artifact) { return null; } + /** + * Whether a component-selection rule may reject the version this writes. + * + *

Such a rule rejects CANDIDATES, outside any declaration, so the + * rejection reading that lives on a declaration never saw it: + * {@code componentSelection { all { if (it.candidate.module == + * 'kotlin-stdlib-jdk8' && it.candidate.version == '1.8.0') + * it.reject('..') } }} removes the very version this writes, and the + * constraint then has nothing to resolve to.

+ * + *

Read across the whole body rather than one statement, because the + * opener, the predicate and the reject are three statements as soon as the + * rule is written over several lines -- which is how it is normally + * written. Any rejecting rule that mentions this family stands the block + * down: which candidates a closure will reject cannot be read here, and + * being wrong the other way emits a requirement into a graph that has + * excluded it.

+ */ + private static boolean aComponentSelectionRuleRejectsTheFloor(String[] active, + String configuration) { + boolean governs = true; + int depth = 0; + int openedAt = -1; + boolean rejects = false; + boolean namesKotlin = false; + for (int i = 0; i < active.length; i++) { + if (openedAt < 0) { + // The configuration a rule belongs to may be named on an earlier + // statement than the one opening the rule, so it is carried. + if (configurationNamedIn(active[i]) != null + || active[i].indexOf(CONFIGURATIONS) >= 0) { + governs = governsTheConstrainedGraph(active[i], configuration); + } + if (opensBlockNamed(active[i], "componentSelection")) { + openedAt = depth; + rejects = false; + namesKotlin = false; + } + } + if (openedAt >= 0) { + rejects = rejects || callsNamed(active[i], "reject"); + namesKotlin = namesKotlin || namesOneOfTheFamily(active[i]) + || holdsLiteral(active[i], KOTLIN_GROUP); + } + depth += braceBalance(active[i]); + if (depth < 0) { + depth = 0; + } + if (openedAt >= 0 && depth <= openedAt) { + if (rejects && namesKotlin && governs) { + return true; + } + openedAt = -1; + } + } + return openedAt >= 0 && rejects && namesKotlin && governs; + } + /** * Whether a soft {@code require} is the only thing holding this artifact. * @@ -1313,8 +1356,13 @@ private static String versionInCall(String statement, String call) { if (!containsAConditional(statement) && !holdsATernary(statement)) { return found.get(found.size() - 1); } + // An arm this cannot read is an alternative like any other, and the one + // that may be live: unknown wins over every readable branch beside it. String lowest = null; for (int i = 0; i < found.size(); i++) { + if (found.get(i) == null) { + return null; + } lowest = lower(lowest, found.get(i)); } return lowest; @@ -1399,11 +1447,18 @@ private static List versionsInCall(String statement, String call) { if (after < statement.length() && statement.charAt(after) == '(') { after = skipBlanks(statement, after + 1); } + // A call with no literal argument still HAPPENED, and what it set is + // unknown. Recorded as nothing at all, `if (legacy) strictly + // providers.gradleProperty('k').get() else strictly '1.9.22'` looked + // like a single readable branch, so the lowest was 1.9.22 and the + // constraints went in beside a pin that may well be pre-merge. + boolean read = false; while (after < statement.length() && isLiteralStart(statement, after)) { int end = endOfStringLiteral(statement, after); if (end >= statement.length()) { break; } + read = true; // The literal's own delimiters, however many it has. Written // strictly """1.7.22""", the one-per-side slice returned // ""1.7.22"" -- which parsed as no version at all and only @@ -1416,6 +1471,9 @@ private static List versionsInCall(String statement, String call) { } after = skipBlanks(statement, after + 1); } + if (!read) { + found.add(null); + } // One BEFORE the next unread character, because the loop's own step // lands on it. Advancing straight to it skipped a character, and while // this returned on the first call that cost nothing -- now that it @@ -1596,6 +1654,11 @@ private static boolean namesAnEnforcedKotlinPlatformBelowTheFloor(String line) { // the exclusion is only safe once the add site carries the platform. List enforced = versionsInCall(line, ENFORCED_PLATFORM); for (int i = 0; i < enforced.size(); i++) { + if (enforced.get(i) == null) { + // The call carried no literal, which is the map form: the entries + // below answer it. + continue; + } String coordinate = enforced.get(i).trim(); if (!coordinate.startsWith(KOTLIN_GROUP + ":")) { continue; @@ -1748,6 +1811,11 @@ private static boolean rejectsTheFloor(String line) { // its graph to resolve to a version it excluded. List rejected = versionsInCall(line, "reject"); for (int i = 0; i < rejected.size(); i++) { + if (rejected.get(i) == null) { + // A rejection whose selector cannot be read may be the one that + // removes the floor. + return true; + } if (rejectionRemovesTheFloor(rejected.get(i).trim())) { return true; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 03bb486d1a2..e6e3630740c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -847,6 +847,94 @@ public void aCommandCallDoesNotClearItsArgument() { "a valueless def still introduces the name"); } + /** + * A component-selection rule is normally written over several lines, and + * then its opener, its predicate and its reject are three statements. The + * one-statement reading saw none of them together, so a rule that removes + * the very version this writes left the constraint nothing to resolve to. + */ + @Test + public void aComponentSelectionRuleIsReadAcrossItsWholeBody() { + String multiline = " configurations.all {\n resolutionStrategy {\n" + + " componentSelection {\n all { selection ->\n" + + " if (selection.candidate.module == " + + "'kotlin-stdlib-jdk8'\n" + + " && selection.candidate.version == " + + "'1.8.0') {\n" + + " selection.reject('unsupported')\n" + + " }\n }\n }\n" + + " }\n }\n"; + String out = KotlinStdlibAlignment.constraintsBlock("implementation", multiline); + check("".equals(out), "the rule may reject the floor, got <<" + out + ">>"); + + String[] harmless = { + // Rejecting something else. + " configurations.all {\n resolutionStrategy {\n" + + " componentSelection {\n all { s ->\n" + + " if (s.candidate.module == 'okhttp') {\n" + + " s.reject('unsupported')\n" + + " }\n }\n }\n" + + " }\n }\n", + // Rejecting nothing. + " configurations.all {\n resolutionStrategy {\n" + + " componentSelection {\n all { s ->\n" + + " logger.info(s.candidate.module)\n" + + " }\n }\n }\n }\n", + // On a configuration the constraint never reaches. + " configurations.create('tooling') {\n resolutionStrategy {\n" + + " componentSelection {\n all { if " + + "(it.candidate.group == 'org.jetbrains.kotlin') it.reject('x') }\n" + + " }\n }\n }\n", + }; + for (int i = 0; i < harmless.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", harmless[i]) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "rule " + i + " rejects nothing this writes"); + } + + // And the scan does not swallow what comes after a harmless rule. + check(!KotlinStdlibAlignment.constraintsBlock("implementation", + harmless[1] + " implementation 'org.jetbrains.kotlin:" + + "kotlin-stdlib-jdk8:1.9.22'\n") + .contains("kotlin-stdlib-jdk8:1.8.0"), + "a declaration after the rule is still read"); + } + + /** + * A call with no literal argument still HAPPENED, and what it set is + * unknown. Recorded as nothing at all, a conditional mixing an unreadable + * arm with a readable one looked like a single readable branch -- so the + * lowest was the arm that could be read, and the constraints went in beside + * a pin that may well be pre-merge. + */ + @Test + public void anUnreadableArmIsAnAlternativeToo() { + String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; + String[] mixed = { + " implementation('" + jdk8 + "') { version { if (legacy) " + + "strictly providers.gradleProperty('k').get() " + + "else strictly '1.9.22' } }\n", + " implementation('" + jdk8 + "') { version { legacy ? " + + "strictly(providers.gradleProperty('k').get()) " + + ": strictly('1.9.22') } }\n", + }; + for (int i = 0; i < mixed.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + mixed[i]); + check("".equals(out), "the unreadable arm may be the live one, got <<" + + out + ">>"); + } + + // A SEQUENCE ending in a readable call is still read: there the last one + // wins, and it is known. + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('" + jdk8 + "') { version { " + + "strictly providers.gradleProperty('k').get(); " + + "strictly '1.9.22' } }\n") + .contains("kotlin-stdlib-jdk7:1.8.0"), + "a sequence ending readable is read"); + } + /** * A component-selection rule rejects CANDIDATES, outside any declaration, so * the rejection reading that lives on a declaration never saw it. Such a rule From b3a84f8988806aea6ad738f9bcf11daf6785bc1c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:54:10 +0300 Subject: [PATCH 79/94] A rejection counts only in the rule that names the family A component-selection block holds a rule per `all { }`, and the predicate naming this family has to be in the SAME rule as the rejection. Accumulated across the block -- which is what reading it across its whole body did last round -- a rule that merely MENTIONS Kotlin paired up with a sibling that rejects something else, so the block stood down for a rejection that could not touch it. That leaves the duplicate exactly where it was, which is the failure this exists to prevent rather than a conservative miss. The flags reset when a rule closes, which is when the brace depth returns to the block's own level. The one-line spelling still works because there the whole rule is one statement and both are seen before it closes. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 23 ++++++++--- .../builders/KotlinStdlibAlignmentTest.java | 40 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index aaf80e25bec..3997c8a48d5 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -552,18 +552,31 @@ private static boolean aComponentSelectionRuleRejectsTheFloor(String[] active, namesKotlin = namesKotlin || namesOneOfTheFamily(active[i]) || holdsLiteral(active[i], KOTLIN_GROUP); } + int before = depth; depth += braceBalance(active[i]); if (depth < 0) { depth = 0; } - if (openedAt >= 0 && depth <= openedAt) { - if (rejects && namesKotlin && governs) { - return true; - } + if (openedAt < 0) { + continue; + } + if (rejects && namesKotlin && governs) { + return true; + } + if (depth <= openedAt) { openedAt = -1; + } else if (depth == openedAt + 1 && before > openedAt + 1) { + // One rule of several just closed. A block holds a rule per + // `all { }` or `withModule { }`, and accumulating across all of + // them let a rule that merely MENTIONS this family pair up with + // a sibling that rejects something else -- so the block stood + // down for a rejection that could not touch it, which leaves the + // duplicate exactly where it was. + rejects = false; + namesKotlin = false; } } - return openedAt >= 0 && rejects && namesKotlin && governs; + return false; } /** diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index e6e3630740c..739d986a88a 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -847,6 +847,46 @@ public void aCommandCallDoesNotClearItsArgument() { "a valueless def still introduces the name"); } + /** + * A component-selection block holds a rule per {@code all { }}, and the + * predicate that names this family has to be in the SAME rule as the + * rejection. Accumulated across the block, a rule that merely mentions + * Kotlin paired up with a sibling that rejects something else, so the block + * stood down for a rejection that could not touch it -- which leaves the + * duplicate exactly where it was. + */ + @Test + public void aRejectionCountsOnlyInTheRuleThatNamesTheFamily() { + String open = " configurations.all {\n resolutionStrategy {\n" + + " componentSelection {\n"; + String close = " }\n }\n }\n"; + String logsKotlin = " all { s ->\n if " + + "(s.candidate.module == 'kotlin-stdlib') " + + "{ logger.info(s.candidate.version) }\n }\n"; + String rejectsOther = " all { s ->\n if " + + "(s.candidate.module == 'okhttp') " + + "{ s.reject('unsupported') }\n }\n"; + String rejectsOurs = " all { s ->\n if " + + "(s.candidate.module == 'kotlin-stdlib-jdk8') " + + "{ s.reject('unsupported') }\n }\n"; + + check(KotlinStdlibAlignment.constraintsBlock("implementation", + open + logsKotlin + rejectsOther + close) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "neither rule can reject the floor"); + + String[] rejecting = { + open + rejectsOther + rejectsOurs + close, + open + rejectsOurs + close, + }; + for (int i = 0; i < rejecting.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + rejecting[i]); + check("".equals(out), "a rule that names and rejects stands the block " + + "down, got <<" + out + ">>"); + } + } + /** * A component-selection rule is normally written over several lines, and * then its opener, its predicate and its reject are three statements. The From d9d99191d4c7637c24f38041869ec2233b42e0fe Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:10:58 +0300 Subject: [PATCH 80/94] Keep the lower coordinate, and read a module named by coordinate A conditional swap between two coordinates of this family is a choice between two of ours, and which arm runs is not readable here. Taking the replacement let `def dep = '..jdk8:1.7.22'` followed by `if (useNew) dep = '..jdk8:1.9.22'` read as merged-era, so the declaration below needed no constraint -- and with the condition false the class-bearing 1.7.22 jar is still there. The lower version is kept, as it is for two versions of the same rich requirement. The mirror of that shape was worse and turned up while checking this one: `if (legacy) dep = '..1.7.22'` on ONE line was not read as an assignment at all, because the walk began at `if` and stopped at its parenthesis, so the name kept whatever it started with. The declaration walk steps past a header whose body is on the same line now, and an assignment reached that way is conditional, which is what makes keeping the lower one apply to it. A selection rule may name its module by whole coordinate -- `withModule('org.jetbrains.kotlin:kotlin-stdlib-jdk8')` -- which is neither the bare artifact name nor the group on its own, so a rule written that way looked like it concerned nothing of ours and the rejected version was written anyway. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 115 +++++++++++++++++- .../builders/KotlinStdlibAlignmentTest.java | 77 ++++++++++++ 2 files changed, 187 insertions(+), 5 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 3997c8a48d5..0286f41f79c 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -549,8 +549,7 @@ private static boolean aComponentSelectionRuleRejectsTheFloor(String[] active, } if (openedAt >= 0) { rejects = rejects || callsNamed(active[i], "reject"); - namesKotlin = namesKotlin || namesOneOfTheFamily(active[i]) - || holdsLiteral(active[i], KOTLIN_GROUP); + namesKotlin = namesKotlin || mentionsTheKotlinGroup(active[i]); } int before = depth; depth += braceBalance(active[i]); @@ -1595,8 +1594,12 @@ private static int endOfTypeArguments(String statement, int at) { * assigned to -- which is a declaration this already reads.

*/ private static int afterAnyBlockOpener(String statement) { - int start = 0; - for (int i = 0; i < statement.length(); i++) { + // Past a header whose body is on the SAME line, which opens no brace at + // all: `if (legacy) dep = '..'` began the walk at `if`, stopped at its + // parenthesis, and recorded nothing -- so a conditional swap to a + // pre-merge coordinate was not seen and the name kept whatever it had. + int start = afterAnUnbracedHeader(statement); + for (int i = start; i < statement.length(); i++) { if (isLiteralStart(statement, i)) { i = endOfStringLiteral(statement, i); continue; @@ -1611,6 +1614,49 @@ private static int afterAnyBlockOpener(String statement) { return start; } + /** + * The index just past a leading {@code if (..)} or {@code while (..)} whose + * body follows on the same line, or 0. + * + *

Only a header at the START of the statement, because that is the one + * whose body the rest of the statement is. The condition's own parentheses + * are stepped over as a unit, so a call inside it is not mistaken for the + * end.

+ */ + private static int afterAnUnbracedHeader(String statement) { + int at = skipBlanks(statement, 0); + int end = at; + while (end < statement.length() && isIdentifierChar(statement.charAt(end))) { + end++; + } + if (end == at || UNBRACED_HEADERS.indexOf( + " " + statement.substring(at, end) + " ") < 0) { + return 0; + } + int open = skipBlanks(statement, end); + if (open >= statement.length() || statement.charAt(open) != '(') { + // `else` carries no condition, so its body starts straight after it. + return "else".equals(statement.substring(at, end)) ? end : 0; + } + int depth = 0; + for (int i = open; i < statement.length(); i++) { + if (isLiteralStart(statement, i)) { + i = endOfStringLiteral(statement, i); + continue; + } + char c = statement.charAt(i); + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + if (depth == 0) { + return i + 1; + } + } + } + return 0; + } + /** Whether the character at {@code at} is an assignment, not a comparison. */ private static boolean isAssignmentAt(String statement, int at) { if (statement.charAt(at) != '=') { @@ -3387,6 +3433,45 @@ private static boolean recordsADestructuring(String statement, int at, return true; } + /** The version a coordinate carries, or null when it has none. */ + private static String coordinateVersion(String coordinate) { + int group = coordinate.indexOf(':'); + if (group < 0) { + return null; + } + int artifact = coordinate.indexOf(':', group + 1); + if (artifact < 0 || artifact + 1 >= coordinate.length()) { + return null; + } + return versionComponentOf(coordinate.substring(artifact + 1)); + } + + /** + * Whether the statement mentions this group at all, in any of the shapes a + * selection rule names a module by. + * + *

{@code withModule('org.jetbrains.kotlin:kotlin-stdlib-jdk8')} names it + * with a whole coordinate, which is neither the bare artifact name nor the + * group on its own -- so a rule written that way looked like it concerned + * nothing of ours and the rejected version was written anyway.

+ */ + private static boolean mentionsTheKotlinGroup(String line) { + if (namesOneOfTheFamily(line) || holdsLiteral(line, KOTLIN_GROUP)) { + return true; + } + for (int i = 0; i < line.length(); i++) { + if (!isLiteralStart(line, i)) { + continue; + } + int end = endOfStringLiteral(line, i); + if (stringLiteralContent(line, i).startsWith(KOTLIN_GROUP + ":")) { + return true; + } + i = end; + } + return false; + } + /** * Records a definition, or forgets it, unless doing so under a condition * would throw away the value that decides suppression. @@ -3399,6 +3484,24 @@ private static void recordDefinition(Map literals, String name, && (value == null || value.indexOf(KOTLIN_GROUP) < 0)) { return; } + // Kotlin for Kotlin is a choice between two of ours, and which arm + // runs is not readable here. `def dep = '..jdk8:1.7.22'` then + // `if (useNew) dep = '..jdk8:1.9.22'` took the merged-era one, so the + // declaration below read as needing no constraint -- and with the + // condition false the class-bearing 1.7.22 jar is still there. The + // lower version is kept, for the reason every unevaluable branch gets + // the conservative answer. + if (known != null && value != null + && known.indexOf(KOTLIN_GROUP) >= 0 + && value.indexOf(KOTLIN_GROUP) >= 0) { + String held = coordinateVersion(known); + String offered = coordinateVersion(value); + if (held != null && offered != null + && compareVersions(withoutStrictSuffix(held), + withoutStrictSuffix(offered)) < 0) { + return; + } + } } if (value == null) { literals.remove(name); @@ -3717,7 +3820,9 @@ && delimiterLength(statement, nameAt) == 1) { // unconditional reassignment and threw away the coordinate the condition // might never replace -- which is the pin, hidden, that this whole rule // exists to keep. - if (depthAt(statement, nameStart, depth) > depth) { + if (depthAt(statement, nameStart, depth) > depth + || nameStart >= afterAnUnbracedHeader(statement) + && afterAnUnbracedHeader(statement) > 0) { conditional = true; } if (declared) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 739d986a88a..3fc42bd593c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -847,6 +847,83 @@ public void aCommandCallDoesNotClearItsArgument() { "a valueless def still introduces the name"); } + /** + * A conditional swap between two coordinates of this family is a choice + * between two of ours, and which arm runs is not readable here. Taking the + * replacement let {@code def dep = '..jdk8:1.7.22'} followed by + * {@code if (useNew) dep = '..jdk8:1.9.22'} read as merged-era, so the + * declaration below it needed no constraint -- and with the condition false + * the class-bearing 1.7.22 jar is still there. + */ + @Test + public void aConditionalSwapKeepsTheLowerCoordinate() { + String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; + String use = " implementation(dep)\n"; + String[] swaps = { + " def dep = '" + jdk8 + ":1.7.22'\n" + + " if (useNew) dep = '" + jdk8 + ":1.9.22'\n", + " def dep = '" + jdk8 + ":1.7.22'\n" + + " if (useNew) {\n dep = '" + jdk8 + ":1.9.22'\n }\n", + // The same choice written the other way round. Its assignment sits + // after a header with no brace, which was not read as an assignment + // at all -- so the name kept the merged-era value it started with. + " def dep = '" + jdk8 + ":1.9.22'\n" + + " if (legacy) dep = '" + jdk8 + ":1.7.22'\n", + }; + for (int i = 0; i < swaps.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + swaps[i] + use); + check(out.contains("kotlin-stdlib-jdk7:1.8.0") + && out.contains("kotlin-stdlib-jdk8:1.8.0"), + "the pre-merge arm may be the live one, so the shim is raised; " + + "got <<" + out + ">>"); + } + + // Two merged-era coordinates leave the artifact to the app, and an + // UNCONDITIONAL raise still replaces what it replaces. + String[] settled = { + " def dep = '" + jdk8 + ":1.9.22'\n" + + " if (useNew) dep = '" + jdk8 + ":1.9.24'\n", + " def dep = '" + jdk8 + ":1.7.22'\n dep = '" + jdk8 + ":1.9.22'\n", + }; + for (int i = 0; i < settled.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + settled[i] + use); + check(!out.contains("kotlin-stdlib-jdk8:1.8.0") + && out.contains("kotlin-stdlib-jdk7:1.8.0"), + "a merged-era binding stands in for its own constraint, got <<" + + out + ">>"); + } + } + + /** + * A selection rule may name its module by whole coordinate -- + * {@code withModule('org.jetbrains.kotlin:kotlin-stdlib-jdk8')} -- which is + * neither the bare artifact name nor the group on its own, so a rule written + * that way looked like it concerned nothing of ours. + */ + @Test + public void aSelectionRuleMayNameItsModuleByCoordinate() { + String open = " configurations.all {\n resolutionStrategy {\n" + + " componentSelection {\n"; + String close = " }\n }\n }\n"; + String ours = KotlinStdlibAlignment.constraintsBlock("implementation", + open + " withModule('org.jetbrains.kotlin:" + + "kotlin-stdlib-jdk8') { selection ->\n" + + " if (selection.candidate.version == '1.8.0') {\n" + + " selection.reject('unsupported')\n" + + " }\n }\n" + close); + check("".equals(ours), "the rule may reject the floor, got <<" + ours + ">>"); + + check(KotlinStdlibAlignment.constraintsBlock("implementation", + open + " withModule('com.squareup.okhttp3:" + + "okhttp') { selection ->\n" + + " selection.reject('unsupported')\n" + + " }\n" + close) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "a rule on another module rejects nothing this writes"); + } + /** * A component-selection block holds a rule per {@code all { }}, and the * predicate that names this family has to be in the SAME rule as the From 480af157a30001a0da06ed8b9cebbec4c6996c0e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:27:36 +0300 Subject: [PATCH 81/94] Four spellings that read as something they are not A closure passed in parentheses is the same call as a trailing one, so `componentSelection({ rules -> .. })` is a selection block -- requiring the brace to follow the name missed it before anything could read its body. The ARTIFACT in a coordinate selector has to be one of ours. Matching the group prefix alone -- added one round ago for withModule -- read a rule on `kotlin-reflect` as one on this family, and the block stood down for a rejection that cannot touch either shim. A rule keyed on the group with no artifact still counts, because it covers them. The constraint handler takes a configuration and a notation as well, so `constraints.add('implementation', 'g:a:1.7.22!!')` is a strict pin the app really has; rejecting it because the receiver is not `dependencies` wrote the shim constraints against it. `subprojects { dependencies { .. } }` configures the children rather than this application. The note beside the foreign-scope list already drew the line -- allprojects includes this project, subprojects does not -- and only the second half of it was acted on. The withModule finding reported alongside these was already fixed by the previous commit; verified against the current behaviour rather than assumed. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 56 ++++++++++++++++- .../builders/KotlinStdlibAlignmentTest.java | 60 +++++++++++++++++++ 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 0286f41f79c..075a1e358ca 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -1120,7 +1120,13 @@ private static boolean isDeclarationCall(String line, int at) { && isIdentifierChar(line.charAt(start - 1))))) { start--; } - return lastSegmentIs(line.substring(start + 1, end + 1), "dependencies"); + // The constraint handler is the other one that takes a configuration and a + // notation: `constraints.add('implementation', 'g:a:1.7.22!!')` is a + // strict pin the app really has, and rejecting it because the receiver is + // not `dependencies` wrote the shim constraints against it. + String receiver = line.substring(start + 1, end + 1); + return lastSegmentIs(receiver, "dependencies") + || lastSegmentIs(receiver, "constraints"); } /** Gradle's strict-version shorthand, written after the version. */ @@ -3446,6 +3452,30 @@ private static String coordinateVersion(String coordinate) { return versionComponentOf(coordinate.substring(artifact + 1)); } + /** The artifact of a {@code group:artifact[:version]} coordinate. */ + private static String artifactOf(String coordinate) { + int group = coordinate.indexOf(':'); + if (group < 0) { + return ""; + } + int end = coordinate.indexOf(':', group + 1); + return end < 0 ? coordinate.substring(group + 1) + : coordinate.substring(group + 1, end); + } + + /** Whether the name is the base library or one of its shims. */ + private static boolean isOneOfTheFamily(String artifact) { + if (BASE_STDLIB.equals(artifact)) { + return true; + } + for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { + if (ALIGNED_ARTIFACTS[i].equals(artifact)) { + return true; + } + } + return false; + } + /** * Whether the statement mentions this group at all, in any of the shapes a * selection rule names a module by. @@ -3454,6 +3484,12 @@ private static String coordinateVersion(String coordinate) { * with a whole coordinate, which is neither the bare artifact name nor the * group on its own -- so a rule written that way looked like it concerned * nothing of ours and the rejected version was written anyway.

+ * + *

The ARTIFACT in such a coordinate has to be one of ours. Matching the + * group prefix alone read a rule on {@code kotlin-reflect} as one on this + * family, and the block stood down for a rejection that cannot touch either + * shim -- which leaves the duplicate exactly where it was. A rule keyed on + * the group with no artifact at all still counts, because it covers them.

*/ private static boolean mentionsTheKotlinGroup(String line) { if (namesOneOfTheFamily(line) || holdsLiteral(line, KOTLIN_GROUP)) { @@ -3464,7 +3500,9 @@ private static boolean mentionsTheKotlinGroup(String line) { continue; } int end = endOfStringLiteral(line, i); - if (stringLiteralContent(line, i).startsWith(KOTLIN_GROUP + ":")) { + String held = stringLiteralContent(line, i); + if (held.startsWith(KOTLIN_GROUP + ":") + && isOneOfTheFamily(artifactOf(held))) { return true; } i = end; @@ -3534,6 +3572,13 @@ private static boolean opensBlockNamed(String statement, String name) { int after = at + name.length(); boolean startsToken = at == 0 || !isIdentifierChar(statement.charAt(at - 1)); int brace = skipBlanks(statement, after); + // Groovy takes a trailing closure with or without the parentheses, and + // `componentSelection({ rules -> .. })` is the same call as + // `componentSelection { .. }` -- requiring the brace to follow the name + // missed the parenthesised form before anything could read its body. + if (brace < statement.length() && statement.charAt(brace) == '(') { + brace = skipBlanks(statement, brace + 1); + } if (startsToken && (after >= statement.length() || !isIdentifierChar(statement.charAt(after))) && brace < statement.length() && statement.charAt(brace) == '{') { @@ -4145,7 +4190,12 @@ private static void recordBareAssignment(String body, Map litera */ private static final String[] FOREIGN_SCOPES = { "buildscript", - "testing" + "testing", + // `subprojects { dependencies { .. } }` configures the CHILDREN, not the + // application this writes into. `allprojects` is deliberately absent: it + // does include this project, which is the distinction the note above is + // about. + "subprojects" }; /** Whether the statement opens a block that is not the app's own graph. */ diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 3fc42bd593c..0cb8391fea6 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -896,6 +896,66 @@ public void aConditionalSwapKeepsTheLowerCoordinate() { } } + /** + * The shapes this round found, each a valid Gradle spelling that read as + * something it is not. + */ + @Test + public void everySelectorAndHandlerSpellingIsRead() { + String open = " configurations.all {\n resolutionStrategy {\n" + + " componentSelection {\n"; + String close = " }\n }\n }\n"; + + // A closure passed in parentheses is the same call as a trailing one. + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy" + + ".componentSelection({ rules ->\n rules.all { s -> if " + + "(s.candidate.module == 'kotlin-stdlib-jdk8') " + + "s.reject('x') }\n }) }\n")), + "a parenthesised componentSelection is still one"); + + // A rule keyed on another Kotlin module cannot reject either shim, so + // matching the group prefix alone gave away the alignment for nothing. + check(KotlinStdlibAlignment.constraintsBlock("implementation", + open + " withModule('org.jetbrains.kotlin:" + + "kotlin-reflect') { s -> s.reject('x') }\n" + close) + .contains("kotlin-stdlib-jdk8:1.8.0"), + "a rule on kotlin-reflect touches neither shim"); + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + open + " withModule('org.jetbrains.kotlin:" + + "kotlin-stdlib-jdk8') { s -> s.reject('x') }\n" + close)), + "and one on a shim still stands the block down"); + + // The constraint handler takes a configuration and a notation too. + String[] handlers = { + " constraints.add('implementation', " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')\n", + " dependencies.constraints.add('implementation', " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')\n", + }; + for (int i = 0; i < handlers.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + handlers[i]); + check("".equals(out), "<<" + handlers[i].trim() + ">> is a strict pin, " + + "got <<" + out + ">>"); + } + + // A subprojects block configures the children, not this application. + String children = KotlinStdlibAlignment.constraintsBlock("implementation", + " subprojects {\n dependencies {\n implementation(" + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22')\n }\n }\n"); + check(children.contains("kotlin-stdlib-jdk8:1.8.0"), + "a subproject declaration is not the app's, got <<" + children + ">>"); + + // allprojects DOES include this one, which is the distinction. + String every = KotlinStdlibAlignment.constraintsBlock("implementation", + " allprojects {\n dependencies {\n implementation(" + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22')\n }\n }\n"); + check(!every.contains("kotlin-stdlib-jdk8:1.8.0") + && every.contains("kotlin-stdlib-jdk7:1.8.0"), + "an allprojects declaration is the app's too, got <<" + every + ">>"); + } + /** * A selection rule may name its module by whole coordinate -- * {@code withModule('org.jetbrains.kotlin:kotlin-stdlib-jdk8')} -- which is From 0f46dae4cf451ce83da11135d0f4216650911815 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:51:52 +0300 Subject: [PATCH 82/94] A called keyword, a wrapped value, and one android closure Whether a keyword was CALLED settles which one speaks; what it was called with is a separate question. Falling through on a null let `require '1.9.22'; strictly providers.gradleProperty('legacy').get()` report the requirement, so a shim whose strict version may be pre-merge read as merged-era, its own constraint was skipped, and the sibling was raised around it. Groovy accepts parentheses around a stored value, and one that did not START with a literal was recorded as unknown -- so `def dep = ('g:a:1.7 .22!!')` left the pin invisible to whatever used the name. android.gradle.androidx and android.xgradle_default_config run inside ONE android { } closure in the script -- the first directly in it, the second in its defaultConfig block. A synthetic closure each made a scope boundary Gradle does not have. The scalars that sit between them in the script are inside the shared argument now, which is what keeps the enumeration test's ordering true. That builder change was untested at first: with a closure each the arguments are still in the right ORDER, so the enumeration test passed either way and the new test only exercised the alignment with pre-wrapped text. It reads the call and requires ONE argument to carry both hints now, which is what fails when the closures are split. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 15 +- .../builders/KotlinStdlibAlignment.java | 23 +++- .../builders/KotlinStdlibAlignmentTest.java | 129 ++++++++++++++++++ 3 files changed, 157 insertions(+), 10 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index a7685d0af29..60199486843 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7333,10 +7333,17 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { "buildscript {\nrepositories {\n%s\n}\n}\n" .replace("%s", injectRepo), "buildscript {\ndependencies {\n%s\n}\n}\n".replace("%s", gradleDependency), - "android {\n%s\n}\n".replace("%s", request.getArg("android.gradle.androidx", "")), - minSDK, - targetNumber, - "android {\ndefaultConfig {\n%s\n}\n}\n".replace("%s", request.getArg("android.xgradle_default_config", "")), + // ONE android closure around both, because the script has + // one: androidx sits directly in it and the default config in + // its defaultConfig block. A closure each made a scope boundary + // Gradle does not have, so a `def` in the first was discarded + // before the second used it. + "android {\n" + + request.getArg("android.gradle.androidx", "") + + "\ndefaultConfig {\n" + + minSDK + "\n" + targetNumber + "\n" + + request.getArg("android.xgradle_default_config", "") + + "\n}\n}\n", "repositories {\n%s\n}\n".replace("%s", injectRepo), // ONE closure around all of them, because the script has // one: they are concatenated into a single dependencies { } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 075a1e358ca..a667f0bc4f1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -1338,15 +1338,19 @@ private static String strictVersionIn(String statement) { * between that graph and the duplicate.

*/ private static String richVersionIn(String statement) { - String strict = versionInCall(statement, STRICTLY); - if (strict != null) { - return strict; + // Whether it was CALLED settles which keyword speaks, and what it was + // called with is a separate question. Falling through on a null let + // `require '1.9.22'; strictly providers.gradleProperty('legacy').get()` + // report the requirement -- so a shim whose strict version may be + // pre-merge read as merged-era, its own constraint was skipped, and the + // sibling was raised around it. + if (callsStrictly(statement)) { + return versionInCall(statement, STRICTLY); } // A resolution rule's useVersion is as authoritative as either: it rewrites // what was requested, silently, on the way through. - String ruled = versionInCall(statement, USE_VERSION); - if (ruled != null) { - return ruled; + if (callsNamed(statement, USE_VERSION)) { + return versionInCall(statement, USE_VERSION); } return versionInCall(statement, "require"); } @@ -3904,6 +3908,13 @@ && afterAnUnbracedHeader(statement) > 0) { // the strict pin the second carried was invisible to the statement using it. while (true) { i = skipBlanks(statement, i + 1); + // Past any parentheses around the value. Groovy accepts + // `def dep = ('g:a:1.7.22!!')`, and a value that did not START with a + // literal was recorded as unknown -- so the pin it held was invisible + // to whatever used the name. + while (i < statement.length() && statement.charAt(i) == '(') { + i = skipBlanks(statement, i + 1); + } int end = -1; String value = null; if (i < statement.length() && isLiteralStart(statement, i)) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 0cb8391fea6..b8eaac268e1 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -896,6 +896,135 @@ public void aConditionalSwapKeepsTheLowerCoordinate() { } } + /** + * Whether a keyword was CALLED settles which one speaks, and what it was + * called with is a separate question. Falling through on a null let + * {@code require '1.9.22'; strictly providers.gradleProperty('k').get()} + * report the requirement, so a shim whose strict version may be pre-merge + * read as merged-era and only its sibling was raised. + */ + @Test + public void aCalledKeywordSettlesItReadableOrNot() { + String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('" + jdk8 + "') { version { " + + "require '1.9.22'; strictly providers" + + ".gradleProperty('legacy').get() } }\n")), + "an unreadable strictly is not the requirement beside it"); + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.eachDependency " + + "{ d ->\n if (d.requested.name == " + + "'kotlin-stdlib-jdk8') { d.useVersion someProperty }\n" + + " } }\n")), + "and neither is an unreadable useVersion"); + + // A readable one still overrides the requirement beside it. + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation('" + jdk8 + "') { version { " + + "require '1.7.22'; strictly '1.9.22' } }\n") + .contains("kotlin-stdlib-jdk7:1.8.0"), + "a readable strictly speaks for the declaration"); + } + + /** + * Groovy accepts parentheses around a stored value, and one that did not + * START with a literal was recorded as unknown -- so the pin it held was + * invisible to whatever used the name. + */ + @Test + public void aStoredValueMayBeWrapped() { + String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; + String use = " implementation(dep)\n"; + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = ('" + jdk8 + ":1.7.22!!')\n" + use)), + "one pair of parentheses"); + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = (( '" + jdk8 + ":1.7.22!!' ))\n" + use)), + "and two, with spaces"); + check(KotlinStdlibAlignment.constraintsBlock("implementation", + " def dep = ('" + jdk8 + ":1.9.22')\n" + use) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "a wrapped merged-era value is read too"); + } + + /** + * The two android fragments run inside ONE {@code android { }} closure in + * the generated script -- androidx directly in it, the default config in its + * defaultConfig block. A synthetic closure each made a scope boundary Gradle + * does not have, so a name the first defined was gone before the second used + * it. + */ + @Test + public void theAndroidFragmentsShareOneClosure() throws Exception { + String shared = KotlinStdlibAlignment.constraintsBlock("implementation", + "android {\n" + + "def dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!'\n" + + "\ndefaultConfig {\n" + + "project.dependencies.add('implementation', dep)\n" + + "\n}\n}\n"); + check("".equals(shared), + "the name survives into the default config, got <<" + shared + ">>"); + + // Handed over as a closure each, it does not -- which is what the builder + // was doing and what the source check below now forbids. + String split = KotlinStdlibAlignment.constraintsBlock("implementation", + "android {\ndef dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!'\n}\n", + "android {\ndefaultConfig {\nproject.dependencies.add(" + + "'implementation', dep)\n}\n}\n"); + check(!"".equals(split), "a closure each loses it, which is the bug"); + + // The half above proves the alignment honours the scope it is GIVEN. + // This half proves the builder gives it one: with a synthetic closure + // each the arguments are still in the right ORDER, so the enumeration + // test passes either way and only this catches it. + String builderSrc = new String(java.nio.file.Files.readAllBytes( + new java.io.File("src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()), "UTF-8"); + int at = builderSrc.indexOf("KotlinStdlibAlignment.constraintsBlock("); + check(at >= 0, "the builder calls the alignment"); + String call = builderSrc.substring(at, builderSrc.indexOf(";", at)) + .replaceAll("//[^\n]*", ""); + // Split at the commas that separate ARGUMENTS -- the ones outside + // parentheses -- and require a single argument to carry both hints. + java.util.List arguments = new java.util.ArrayList(); + int depth = 0; + int start = call.indexOf('(') + 1; + boolean quoted = false; + for (int i = start; i < call.length(); i++) { + char c = call.charAt(i); + if (quoted) { + if (c == '\\') { + i++; + } else if (c == '"') { + quoted = false; + } + continue; + } + if (c == '"') { + quoted = true; + } else if (c == '(') { + depth++; + } else if (c == ')') { + if (depth == 0) { + arguments.add(call.substring(start, i)); + break; + } + depth--; + } else if (c == ',' && depth == 0) { + arguments.add(call.substring(start, i)); + start = i + 1; + } + } + boolean together = false; + for (int i = 0; i < arguments.size(); i++) { + if (arguments.get(i).indexOf("android.gradle.androidx") >= 0 + && arguments.get(i).indexOf("android.xgradle_default_config") >= 0) { + together = true; + } + } + check(together, "the two android fragments are handed over in ONE closure, " + + "and the call splits them across arguments: " + arguments); + } + /** * The shapes this round found, each a valid Gradle spelling that read as * something it is not. From 6140dd3309b7084b8fe9c82f3622690bf07367c2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:11:35 +0300 Subject: [PATCH 83/94] Scope a destructured name, and let an output helper print A destructured name is scoped like any other. Written straight into the map, one declared inside a block outlived it -- so an inner `def (dep, x) = [..]` shadowed an extra property for the rest of the file and its coordinate was inlined into a later declaration that has nothing to do with it. It is registered with the scope before it is recorded now, exactly as a single declaration is. An unqualified call is a declaration because a configuration is never reached through a receiver -- but Groovy's output helpers are unqualified too, so `println('g:a:1.7.22!!')` read as a strict pin and stood the block down for a string the app was only logging. That one is a list, and the reason is written beside it: the review asked to restrict this to actual configuration invocations, and those cannot be listed because an app may call a configuration anything. Naming the PRINTERS instead makes it the complement of an open set, and it fails safely -- a helper missing from the list keeps being read as a declaration, which is today's behaviour and costs at worst the duplicate an app already had. Listing configurations would drop a real pin the moment a project names one nobody anticipated. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/KotlinStdlibAlignment.java | 47 +++++++++++++- .../builders/KotlinStdlibAlignmentTest.java | 63 +++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index a667f0bc4f1..56ab3aa3539 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -1108,7 +1108,7 @@ private static boolean isDeclarationCall(String line, int at) { } int dot = skipBlanksBackward(line, i); if (dot < 0 || line.charAt(dot) != '.') { - return true; + return !isAnOutputHelper(line.substring(i + 1, at + 1)); } int end = skipBlanksBackward(line, dot - 1); if (end < 0) { @@ -1129,6 +1129,38 @@ && isIdentifierChar(line.charAt(start - 1))))) { || lastSegmentIs(receiver, "constraints"); } + /** + * Whether an unqualified call is one that prints rather than declares. + * + *

A configuration is never reached through a receiver, which is what + * makes an unqualified call a declaration -- but Groovy's output helpers are + * unqualified too, so {@code println('g:a:1.7.22!!')} read as a strict pin + * and stood the block down for a string the app was only logging.

+ * + *

Named, because the configurations themselves cannot be: an app may call + * one anything. That makes this the complement of an open set, so it is a + * list -- and one it fails safely: a helper missing from it keeps being read + * as a declaration, which is what happens today and costs at worst the + * duplicate an app already had. Listing the CONFIGURATIONS instead would + * fail the other way, dropping a real pin the moment a project names a + * configuration nobody anticipated.

+ */ + private static boolean isAnOutputHelper(String call) { + for (int i = 0; i < OUTPUT_HELPERS.length; i++) { + if (OUTPUT_HELPERS[i].equals(call)) { + return true; + } + } + return false; + } + + /** Groovy's unqualified printing calls, which declare nothing. */ + private static final String[] OUTPUT_HELPERS = { + "println", + "print", + "printf" + }; + /** Gradle's strict-version shorthand, written after the version. */ private static final String STRICT_SUFFIX = "!!"; @@ -3373,7 +3405,8 @@ private static int closingBracket(String text, int from) { * than wrong.

*/ private static boolean recordsADestructuring(String statement, int at, - Map literals, boolean conditional) { + Map literals, boolean conditional, ScopedNames scope, + int depth) { int i = skipBlanks(statement, at); if (i >= statement.length() || statement.charAt(i) != '(') { return false; @@ -3433,6 +3466,13 @@ private static boolean recordsADestructuring(String statement, int at, } } if (names.get(n) != null && value != null) { + // Registered with the scope first, exactly as a single + // declaration is. Written straight into the map, a name declared + // this way inside a block outlived it -- so an inner + // `def (dep, x) = [..]` shadowed an extra property for the rest + // of the file and its coordinate was inlined into a later + // declaration that has nothing to do with it. + scope.declared(depthAt(statement, at, depth), names.get(n), literals); recordDefinition(literals, names.get(n), value, conditional); } i = skipBlanks(statement, i); @@ -3677,7 +3717,8 @@ private static void updateLiteralDefinitions(String statement, // recorded a declaration that never executes, overwriting the real binding // and making a later use read as something it is not. int at = afterCall(statement, DEF); - if (at >= 0 && recordsADestructuring(statement, at, literals, conditional)) { + if (at >= 0 && recordsADestructuring(statement, at, literals, conditional, + scope, depth)) { return; } if (at >= 0) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index b8eaac268e1..f6331af8ed6 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -768,6 +768,69 @@ public void anExtraPropertiesClosureReadsEveryKindOfValue() { "a merged-era map through the same route is read too"); } + /** + * A destructured name is scoped like any other. Written straight into the + * map, one declared inside a block outlived it -- so an inner + * {@code def (dep, x) = [..]} shadowed an extra property for the rest of the + * file and its coordinate was inlined into a later declaration that has + * nothing to do with it. + */ + @Test + public void aDestructuredNameLeavesItsBlock() { + String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!"; + String escaped = KotlinStdlibAlignment.constraintsBlock("implementation", + " ext.dep = 'com.example:real:1.0'\n" + + " if (true) {\n def (dep, x) = ['" + pin + "', 'x']\n" + + " }\n implementation(dep)\n"); + check(escaped.contains("kotlin-stdlib-jdk7:1.8.0"), + "the extra property comes back after the block, got <<" + + escaped + ">>"); + + // It still binds where it is in scope. + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " def (dep, x) = ['" + pin + "', 'x']\n" + + " implementation(dep)\n")), + "at the top level it binds"); + check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " if (true) {\n def (dep, x) = ['" + pin + "', 'x']\n" + + " implementation(dep)\n }\n")), + "and inside the block it binds for the block"); + } + + /** + * A configuration is never reached through a receiver, which is what makes + * an unqualified call a declaration -- but Groovy's output helpers are + * unqualified too, so {@code println('g:a:1.7.22!!')} read as a strict pin + * and stood the block down for a string the app was only logging. + */ + @Test + public void anOutputHelperIsNotAConfiguration() { + String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!"; + String[] printing = { + " println('" + pin + "')\n", + " print('" + pin + "')\n", + " printf('" + pin + "')\n", + }; + for (int i = 0; i < printing.length; i++) { + check(KotlinStdlibAlignment.constraintsBlock("implementation", printing[i]) + .contains("kotlin-stdlib-jdk7:1.8.0"), + "<<" + printing[i].trim() + ">> declares nothing"); + } + + // Any other unqualified call is still a configuration, because an app + // may call one anything. + String[] declaring = { + " implementation('" + pin + "')\n", + " myCustomConfig('" + pin + "')\n", + }; + for (int i = 0; i < declaring.length; i++) { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", + declaring[i]); + check("".equals(out), "<<" + declaring[i].trim() + ">> is a declaration, " + + "got <<" + out + ">>"); + } + } + /** * Groovy's multiple assignment binds several names at once. The walk for a * single declaration expects an identifier after {@code def} and finds a From 76cb5e12513bbf2d8f64c7f7cdebaa6883bf9282 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:59:22 +0300 Subject: [PATCH 84/94] Answer the pin question with a token check, not a Groovy parser The feature is fifty lines: emit a Gradle constraint holding kotlin-stdlib-jdk7 and kotlin-stdlib-jdk8 at the version where their classes moved into kotlin-stdlib, so a graph that reaches an old shim transitively stops failing checkDuplicateClasses. Around those fifty lines had grown 4,400 more that read the app's own Gradle to decide whether the app had already pinned that family -- rich versions, maps, withModule, componentSelection, capabilitiesResolution, extendsFrom, addProvider, ext in three spellings, destructuring, ternaries, line continuations, CR-only line endings. Every review round found another spelling it misread, and none of them changed the answer for the graph the feature exists for, which names the shims nowhere. The question was never "parse this". It is "has the app decided this version itself", and the honest answer is a token check: the text names kotlin-stdlib and contains one of strictly, !!, force, reject, enforcedPlatform, useVersion, useTarget, substitute or failOnVersionConflict. It over-suppresses, on purpose -- leaving the floor out costs an app the duplicate class it already had, which android.kotlinStdlibAlignment=false does deliberately, while adding a floor over a real pin breaks a build that works today. The builder now passes every app-controlled fragment as plain text, with no wrapping or ordering, since a whole-text check has no use for either. The test suite goes the same way: 192 tests over parser spellings for 10 over what the feature promises. --- .../build/shared/BuildHintsAndroid.java | 24 +- .../builders/AndroidGradleBuilder.java | 73 +- .../builders/KotlinStdlibAlignment.java | 4402 +------------ .../builders/KotlinStdlibAlignmentTest.java | 5706 +---------------- 4 files changed, 265 insertions(+), 9940 deletions(-) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index 885e481be7b..e68246d2ea9 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -651,18 +651,18 @@ static void register(List h) { + "and the build fails in `checkReleaseDuplicateClasses` naming Kotlin artifacts " + "the app never asked for. Expressed as a Gradle constraint, so it adds " + "nothing to an app with no Kotlin anywhere in its dependencies and never " - + "lowers a version. Set to false only to manage those coordinates yourself. " - + "Declaring `kotlin-stdlib-jdk7` or `kotlin-stdlib-jdk8` at 1.8.0 or newer in " - + "your own Gradle build hints switches it off for that artifact, because your " - + "version already satisfies the floor. Declaring an older one doesn\'t: an " - + "ordinary Gradle version is a soft requirement, so the constraint raises it " - + "to the empty shim rather than leaving the duplicate in place. To hold one " - + "below 1.8.0 on purpose, give it a strict version -- `1.7.22!!` or " - + "`version { strictly \'1.7.22\' }` -- or force it, which switches the whole " - + "block off. A Kotlin BOM has no such effect unless it\'s enforced, and " - + "needs none: a " - + "BOM contributes ordinary constraints rather than strict ones, so a newer " - + "BOM simply wins over this floor while an older BOM still needs it.")); + + "lowers a version. A version you declare yourself is a soft requirement in " + + "Gradle, so the constraint raises an older one rather than leaving the " + + "duplicate in place, and a newer one wins over the floor on its own. " + + "If you pin the family yourself the whole block is left out: your Gradle " + + "build hints are searched for the text `kotlin-stdlib` alongside any of " + + "`strictly`, `!!`, `force`, `reject`, `enforcedPlatform`, `useVersion`, " + + "`useTarget`, `substitute` or `failOnVersionConflict`, and a hit means you " + + "have decided the version. That search is plain text, so it errs toward " + + "leaving the floor out. Leaving it out costs you the duplicate class " + + "you already had; adding it over a deliberate pin would break a build " + + "that works today. Set to false to manage these coordinates yourself in " + + "every case.")); h.add(new Hint("android.largeScreens") .group(HintGroup.ANDROID) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 60199486843..a5d5e5aaa8f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7303,64 +7303,23 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { try { kotlinStdlibConstraints = KotlinStdlibAlignment.constraintsBlock( compile, - // In the order the generated script emits them, because a - // definition is only in scope for what comes after it, and - // EVERY fragment carrying app-supplied text -- read straight - // from a hint, or held by a local that was assigned from one. - // Bounding this at the dependencies block was wrong twice: a - // rule reaches the app's configurations from the android block - // and from the repositories closure just as well. The scalars - // ride along rather than being filtered out, because deciding - // which hints are text and which are values is the judgement - // that keeps being wrong, and this way there is none to make. - // KotlinStdlibAlignmentTest reads this call against the script - // and fails if they ever disagree. - // - // Each one wrapped in the closure that surrounds it in the - // generated file, because a `def` inside repositories { } is - // scoped to that closure and the alignment now tracks scopes. - // Handed over bare, such a local outlived its closure and - // shadowed a real binding for everything after it -- which - // reads a later use as a declaration and skips that - // artifact's constraint. + // Every fragment the APP controls, as one piece of + // text. Order and nesting do not matter to a whole-text + // check, so nothing here wraps or sequences them -- + // that was the parser's requirement, not this one. request.getArg("android.gradlePlugin", ""), - // TWICE, because the script interpolates it twice: once into - // the buildscript repositories and again into the project ones - // after the android block. Gradle executes both, so a name this - // fragment binds is restored at the second -- and scanning it - // once left the scan holding whatever an intervening fragment - // had reassigned, which reads a later use as something it is not. - "buildscript {\nrepositories {\n%s\n}\n}\n" - .replace("%s", injectRepo), - "buildscript {\ndependencies {\n%s\n}\n}\n".replace("%s", gradleDependency), - // ONE android closure around both, because the script has - // one: androidx sits directly in it and the default config in - // its defaultConfig block. A closure each made a scope boundary - // Gradle does not have, so a `def` in the first was discarded - // before the second used it. - "android {\n" - + request.getArg("android.gradle.androidx", "") - + "\ndefaultConfig {\n" - + minSDK + "\n" + targetNumber + "\n" - + request.getArg("android.xgradle_default_config", "") - + "\n}\n}\n", - "repositories {\n%s\n}\n".replace("%s", injectRepo), - // ONE closure around all of them, because the script has - // one: they are concatenated into a single dependencies { } - // below. A closure each made a scope boundary Gradle does - // not have, so a `def` in an earlier fragment was discarded - // before a later one used it -- and the use then named no - // artifact, which loses whatever pin it carried. - "dependencies {\n" - + coreLibraryDesugaringDependency - + request.getArg("android.supportv4Dep", "") + "\n" - + kotlinRuntimeDependency - + additionalDependencies + "\n" - + aiExtraGradleDependencies.toString() + "\n" - + request.getArg("android.gradleDep", "") + "\n" - + aarDependencies - + "\n}\n", - request.getArg("android.xgradle", "")); + request.getArg("android.gradle.androidx", ""), + request.getArg("android.xgradle_default_config", ""), + request.getArg("android.supportv4Dep", ""), + request.getArg("android.gradleDep", ""), + request.getArg("android.xgradle", ""), + coreLibraryDesugaringDependency, + kotlinRuntimeDependency, + additionalDependencies, + aiExtraGradleDependencies.toString(), + aarDependencies, + injectRepo, + gradleDependency); } catch (RuntimeException e) { // The alignment reads the app's Gradle text to decide whether the app // already manages the stdlib family, and that reading is a scanner diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 56ab3aa3539..e4bcd7e7879 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -28,166 +28,103 @@ * *

The failure it prevents. Kotlin 1.8.0 folded the contents of * {@code kotlin-stdlib-jdk7} and {@code kotlin-stdlib-jdk8} into - * {@code kotlin-stdlib} and left the two jdk artifacts as empty shims that - * only depend on it. Gradle resolves every module's version independently, - * so a graph that asks for {@code kotlin-stdlib} at 1.8.0 or newer through - * one path and {@code kotlin-stdlib-jdk8} at something older through - * another ends up with two real jars carrying the same classes, and the - * build dies in {@code checkReleaseDuplicateClasses}:

+ * {@code kotlin-stdlib} and left the two shims empty. A graph that reaches + * {@code kotlin-stdlib} 1.8 or newer through one dependency and an older + * {@code kotlin-stdlib-jdk8} through another therefore carries the same classes + * twice, and the build fails in {@code checkDuplicateClasses} naming Kotlin + * artifacts the app never asked for. The 1.8.x line ships no Gradle module + * metadata saying the two overlap, so nothing tells Gradle to align them; from + * 1.9.22 JetBrains ships that metadata itself. This supplies for 1.8.x what + * JetBrains supplies later.

* - *
- * Duplicate class kotlin.collections.jdk8.CollectionsJDK8Kt found in modules
- *   kotlin-stdlib-1.8.22.jar (org.jetbrains.kotlin:kotlin-stdlib:1.8.22) and
- *   kotlin-stdlib-jdk8-1.6.21.jar (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21)
- * 
+ *

Why a constraint. A constraint raises a version and never lowers + * one, and never pulls a module into a graph that does not already contain it. + * An app with no Kotlin anywhere is completely unaffected -- the block resolves + * to nothing.

* - *

Nothing exotic is needed to produce it. A single ordinary dependency - * does it on its own: {@code com.android.billingclient:billing:9.1.0} pulls - * {@code androidx.core:core:1.15.0}, which reaches {@code kotlin-stdlib} - * 1.8.22 through {@code core-ktx} and {@code kotlin-stdlib-jdk8} 1.6.21 - * through {@code lifecycle-runtime -> kotlinx-coroutines-android:1.6.4}. - * Neither coordinate is anything Codename One asked for, which is what makes - * the error so hard to read from the app side: the app declares one library - * and the report names two Kotlin artifacts it has never heard of.

+ *

Why the guard is blunt. The one thing a constraint at the floor can + * break is an app that FIRMLY holds a member of the family below it: a strict + * pin, a force, a rejection, an enforced BOM, or a conflict-failing resolution + * strategy. Such a graph resolves coherently today, and a constraint requiring + * 1.8.0 turns it into {@code Could not resolve ... {strictly 1.7.22}} -- the one + * outcome this must never produce.

* - *

Why Gradle does not sort this out by itself. It normally would. - * From 1.9.22 {@code kotlin-stdlib} publishes Gradle module metadata whose - * {@code jvmApiElements} and {@code jvmRuntimeElements} variants carry - * dependency constraints raising {@code kotlin-stdlib-jdk7} and - * {@code kotlin-stdlib-jdk8} to {@value #MERGED_STDLIB_FLOOR} -- the exact - * alignment below. The 1.8.x line, which is what the current AndroidX - * releases resolve to, publishes no {@code .module} file at all, only - * a POM, and a POM cannot express a constraint. So on 1.8.x there is nothing - * telling Gradle the two artifacts overlap, and it has no way to find out. - * This class supplies for 1.8.x what JetBrains supplies from 1.9.22 on.

+ *

Deciding that by reading the app's Gradle text properly needs a Groovy + * parser. This class WAS one: some 2,200 lines and 130 methods tracking + * definitions, scopes, map notation, rich versions, resolution rules and + * component selection. It was reviewed into the ground, and rightly -- every + * round turned up another spelling it read wrongly, because Groovy has + * unboundedly many of them and an approximate parser is unboundedly wrong. None + * of that machinery ever changed the outcome for the graphs this exists to fix, + * which reach the shims transitively and name them nowhere.

* - *

Why nothing else excuses the block either. A Kotlin BOM used to - * suppress it, on the reasoning that a BOM manages the whole - * {@code org.jetbrains.kotlin} group. That went the way of the plugin check - * and for the same measured reason: against a graph carrying billing 9.1.0 - * and appcompat 1.6.1, adding this block alongside {@code kotlin-bom:1.9.22} - * produced byte-identical resolution, because a BOM's constraints are not - * strict and the higher version simply wins. Alongside - * {@code kotlin-bom:1.7.22} it is not merely harmless but necessary -- a - * pre-merge BOM raises the jdk artifacts and cannot pull - * {@code kotlin-stdlib} back down, which is the duplicate. Removing the case - * also removes the question of whether a BOM declared inside an {@code if} - * block is in force, which no amount of reading the text can answer.

- * - *

Why a constraint and not a force. A constraint raises a version - * and never lowers one, and never pulls a module into a graph that does not - * already contain it. An app with no Kotlin anywhere is therefore completely - * unaffected -- the block resolves to nothing. An app that does have the jdk - * artifacts gets them at {@value #MERGED_STDLIB_FLOOR} or newer, which is - * always a shim, so the duplicate cannot arise whichever version of - * {@code kotlin-stdlib} the rest of the graph settles on. Forcing a fixed - * version would instead override a newer one the app deliberately asked - * for.

- * - *

Why the Kotlin Gradle plugin does not excuse this. From 1.8.0 - * the plugin aligns the jdk variants itself, so it was tempting to skip the - * block whenever a new enough one was applied. That skip is gone, for two - * reasons that point the same way. It was never load-bearing: measured - * against a graph carrying billing 9.1.0 and appcompat 1.6.1, adding this - * block alongside plugin 1.9.22 and 1.8.22 produced byte-identical - * resolution, because the plugin's alignment already lands at or above this - * floor and a constraint never lowers a version. And it was not sound - * either -- the plugin's alignment can be turned off with - * {@code kotlin.stdlib.jdk.variants.version.alignment=false}, which this - * builder preserves out of a project's existing gradle.properties, so - * "a new enough plugin is applied" was never the same question as "the jdk - * variants are aligned".

- * - *

Deleting the skip answers both at once and takes with it the version - * parsing, the reading of {@code android.topDependency} and the hazard that - * a commented-out plugin declaration above an active one decided the - * outcome. An older plugin gets the block for the reason it always did: - * the 1.7 line ADDS {@code kotlin-stdlib-jdk8} at its own pre-merge version, - * so the class-bearing jar is guaranteed present and any dependency reaching - * a merged stdlib collides with it --

- * - *
- * plugin 1.7.22 alone            stdlib 1.7.22 + jdk7/jdk8 1.7.22   no duplicate
- * plugin 1.7.22 + billing 9.1.0  stdlib 1.8.22 + jdk7/jdk8 1.7.22   DUPLICATE
- * the same, with this block      stdlib 1.8.22 + jdk7/jdk8 1.8.0    fixed
- * 
- * - *

The cost of that, stated plainly. On that pre-1.8 plugin path, - * an app whose graph contains no merged stdlib (the first row above) did not - * need the block, and gets its stdlib family raised to - * {@value #MERGED_STDLIB_FLOOR} anyway -- newer than the compiler in use, - * which Kotlin warns about. That is deliberate. Gradle cannot express a - * constraint conditional on what another module resolved to, so the choice is - * between a warning in the case that did not need help and a failed build in - * the case that did, and a warning is the better of the two.

- * - *

Extracted into a pure static helper so it is unit-testable without a - * Gradle run and so the BuildDaemon copy stays trivially diffable -- - * keep this file in sync with its twin in the other repository.

+ *

So the question is asked bluntly: does the app's Gradle text name this + * family at all, AND mention any of the words that can hold a version down? If + * so, stand down and say so in the log. That over-suppresses -- a {@code force} + * on an unrelated library in a script that also happens to name + * {@code kotlin-stdlib} is enough, and a word inside a comment or a string + * counts. Over-suppressing costs an app the duplicate it already had, which is + * exactly what {@code android.kotlinStdlibAlignment=false} does deliberately. + * Under-suppressing breaks a build that works today. The asymmetry is the whole + * design.

*/ -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - public class KotlinStdlibAlignment { /** - * The first {@code kotlin-stdlib} release that absorbed the jdk7/jdk8 - * classes, which is therefore the first version of those two artifacts - * that is an empty shim rather than a second copy of the classes. - * Verified against the published jars: {@code kotlin-stdlib-jdk8:1.7.22} - * carries 14 classes including {@code CollectionsJDK8Kt}, and - * {@code kotlin-stdlib-jdk8:1.8.0} carries one and none of them. - * - *

It is also the exact floor {@code kotlin-stdlib:1.9.22}'s own module - * metadata constrains them to, and the version from which the Kotlin - * Gradle plugin performs this alignment itself, so this is JetBrains' - * number in three separate places rather than one chosen here.

+ * The version at which the shims became empty, and the floor this raises + * them to. */ public static final String MERGED_STDLIB_FLOOR = "1.8.0"; - /** - * The two artifacts whose classes moved into {@code kotlin-stdlib}. - * - *

Both are aligned, and each is suppressed on its own. Suppressing - * both because the app named one would leave the artifact it did not name - * unconstrained, and that is not symmetrical: {@code jdk8} depends on - * {@code jdk7}, so an app pinning jdk8 raises jdk7 with it, while an app - * pinning jdk7 leaves jdk8 exactly where the graph put it -- the original - * duplicate, intact, with the block that would have fixed it switched - * off. They can be treated separately because their class sets are - * disjoint ({@code kotlin.jdk7} / {@code kotlin.io.path} against - * {@code kotlin.collections.jdk8} / {@code kotlin.streams.jdk8}), so - * constraining one and not the other cannot make a new duplicate.

- */ + /** The two shims whose classes moved, and which this raises. */ private static final String[] ALIGNED_ARTIFACTS = { "kotlin-stdlib-jdk7", "kotlin-stdlib-jdk8" }; - /** The group every artifact this class reasons about belongs to. */ - private static final String KOTLIN_GROUP = "org.jetbrains.kotlin"; + /** + * The family, as the one name that prefixes all three of them -- + * {@code kotlin-stdlib}, {@code kotlin-stdlib-jdk7} and + * {@code kotlin-stdlib-jdk8}. + */ + private static final String STDLIB_FAMILY = "kotlin-stdlib"; - /** The merged library both shims depend on at the floor. */ - private static final String BASE_STDLIB = "kotlin-stdlib"; + /** + * The words that can hold a version where this would raise it. + * + *

Gradle's ways of doing that, plus {@code failOnVersionConflict}, which + * turns the raise itself into a build failure. Matched as plain text: the + * point of this list is to be crude and complete rather than precise, since + * a false match only declines to help.

+ */ + private static final String[] PINNING_WORDS = { + "strictly", + "!!", + "force", + "reject", + "enforcedPlatform", + "useVersion", + "useTarget", + "substitute", + "failOnVersionConflict" + }; private KotlinStdlibAlignment() { } /** * The {@code constraints} block to append inside the generated - * {@code dependencies { }}, or an empty string when no alignment should - * be written. + * {@code dependencies { }}, or an empty string when no alignment should be + * written. * * @param configuration the dependency configuration to declare the * constraints on, {@code implementation} on any AndroidX project. The * caller passes the same name it uses for the rest of the block so a * legacy {@code compile} project stays consistent with itself. * @param appGradleFragments the Gradle text the app itself contributed - * ({@code gradleDependencies}, {@code android.gradleDep} and the like). - * An artifact the app names there is left to the app; the Kotlin BOM - * suppresses both. Null entries are ignored. + * ({@code android.gradleDep}, {@code android.xgradle} and the like). + * Order and nesting do not matter -- the whole lot is read as one piece of + * text. Null entries are ignored. * @return the block, newline terminated, or {@code ""} */ public static String constraintsBlock(String configuration, @@ -195,4213 +132,60 @@ public static String constraintsBlock(String configuration, if (configuration == null || configuration.trim().length() == 0) { return ""; } + if (appPinsTheStdlibFamily(appGradleFragments)) { + return ""; + } String config = configuration.trim(); - // "because" is not decoration: it is what `gradle dependencyInsight` prints - // next to the raised version, and this constraint is otherwise unattributable - // to anything in the developer's project. + // "because" is not decoration: it is what `gradle dependencyInsight` + // prints next to the raised version, and this constraint is otherwise + // unattributable to anything in the developer's project. String because = "Codename One: kotlin-stdlib " + MERGED_STDLIB_FLOOR + " absorbed the jdk7/jdk8 classes and the 1.8.x line ships no " + "Gradle module metadata to say so, so these are raised to the " + "empty shims to avoid a duplicate class in checkDuplicateClasses"; - // A strict pin on the merged library itself blocks BOTH shims, because the - // shim at this floor depends on kotlin-stdlib at the same floor. An app - // strictly holding kotlin-stdlib below it therefore cannot resolve either - // constraint, and the pre-merge family it is holding had no duplicate to - // begin with -- so constraining there converts a working build into - // "Could not resolve ... {strictly 1.7.22}", which is the one outcome this - // class must never produce. - if (strictlyPinsBaseStdlibBelowTheFloor(appGradleFragments)) { - return ""; - } - // failOnVersionConflict turns every disagreement into a build failure, and - // raising a shim from 1.7.x to the floor IS a disagreement -- so in that mode - // the block converts a graph that resolved coherently into - // "Conflict found ... between versions 1.8 and 1.7". Nothing here can be - // written that would not conflict, so nothing is. - String[] active = activeLines(combined(appGradleFragments)); - if (aComponentSelectionRuleRejectsTheFloor(active, config)) { - return ""; - } - for (int i = 0; i < active.length; i++) { - // An ENFORCED platform is the one case a Kotlin BOM stands this down. - // A plain `platform()` does not, and the class comment says why it was - // measured not to: a BOM's constraints are ordinary, so the higher - // version simply wins and these are harmless beside it. enforcedPlatform - // is the other thing -- Gradle turns the same versions into STRICT - // requirements -- so a pre-merge one strictly pins the family at 1.7.x - // and a 1.8.0 requirement written beside it cannot resolve at all. - if (namesAnEnforcedKotlinPlatformBelowTheFloor(active[i])) { - return ""; - } - if (!callsNamed(active[i], "failOnVersionConflict")) { - continue; - } - // On a configuration that never receives the constraint it cannot - // conflict with it. `configurations.create('tooling') - // .resolutionStrategy.failOnVersionConflict()` governs a - // configuration the app made and nothing extends. - if (!governsTheConstrainedGraph(active[i], config)) { - continue; - } - // A statement that governs the plugin classpath never arrives here: - // both spellings of it -- a buildscript block and configurations - // .classpath -- are blanked with the rest of that graph before any - // scan runs. See governsThePluginClasspath. - return ""; - } - // The two shims cannot be suppressed independently when the app holds one of - // them below the merge. Measured: an app pinning the whole family at 1.7.22 - // resolves with no duplicate, and emitting only the surviving sibling raises - // kotlin-stdlib to 1.8.0 -- which carries the jdk8 classes -- beside the app's - // class-bearing jdk8 1.7.22 jar. That is this block MAKING the duplicate it - // exists to prevent, in a graph the app had arranged correctly. - for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { - if (declaredBelowTheFloor(ALIGNED_ARTIFACTS[i], config, appGradleFragments)) { - return ""; - } - } StringBuilder out = new StringBuilder(); for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { - if (declaresArtifact(ALIGNED_ARTIFACTS[i], config, appGradleFragments)) { - continue; - } out.append(" ").append(config) .append("('org.jetbrains.kotlin:").append(ALIGNED_ARTIFACTS[i]) .append(':').append(MERGED_STDLIB_FLOOR).append("') {\n") .append(" because '").append(because).append("'\n") .append(" }\n"); } - if (out.length() == 0) { - return ""; - } return " constraints {\n" + out + " }\n"; } /** - * Whether the app declares this shim at a version below the merge floor. - * - *

A version this cannot read counts as below it. An app naming one of - * these artifacts at all is managing the family, and the harm of assuming - * the worst is an alignment not written for an app that had already sorted - * itself out, against a duplicate class manufactured in one that had.

- */ - private static boolean declaredBelowTheFloor(String artifact, String configuration, - String[] appGradleFragments) { - String[] lines = activeLines(combined(appGradleFragments)); - { - for (int j = 0; j < lines.length; j++) { - // The configuration actually being constrained, rather than the two - // that used to be named here. That was reported as letting a - // runtimeOnly pre-merge pin suppress its own constraint and not its - // sibling's; it did not, because declaresOnTheConstrainedConfiguration - // ORs in every MAIN_CONFIGURATIONS entry whatever it is passed, so the - // hard-coded names never restricted anything. Checked by putting them - // back: the behaviour is identical. Passing the real configuration - // regardless, because two names that look like a filter and are not - // one will be read as a filter by the next person. - if (!declaresArtifactOnLine(artifact, configuration, lines[j])) { - continue; - } - if (!namesArtifactAnywhere(lines[j], artifact)) { - continue; - } - // A rejection that removes the floor decides this on its own, whatever - // the requirement beside it says. `require '1.+'; reject '[1.8.0,)'` - // can only select a pre-merge 1.x, and reading the requirement alone - // called that merged-era -- so its own constraint was skipped as - // satisfied while the sibling was raised around it, which is the - // duplicate again. - if (rejectsTheFloor(lines[j])) { - return true; - } - String declared = declaredVersionOf(lines[j], artifact); - if (declared != null && declared.endsWith(STRICT_SUFFIX)) { - declared = declared.substring(0, - declared.length() - STRICT_SUFFIX.length()); - } - if (belowTheFloor(declared)) { - return true; - } - } - } - return false; - } - - /** - * Whether this statement establishes a version for {@code artifact} that - * Gradle will actually hold it to. - */ - private static boolean bindsAVersion(String line, String artifact) { - return declaredVersionOf(line, artifact) != null; - } - - /** - * Whether the text mentions one of the artifacts this class aligns. - * - *

Used to decide whether a statement absorbs the closure that follows it, - * where the group alone was the trigger. A rule comparing only the name -- - * {@code if (d.requested.name == 'kotlin-stdlib') {} } with the useVersion on - * the next line -- never named the group, so the condition and the body - * stayed separate statements and neither said anything.

- * - *

Deliberately a plain mention rather than the careful reading - * namesArtifactAnywhere does: gluing a closure onto a statement is bounded - * to one declaration either way, and the careful question is asked later of - * the merged text.

- */ - /** Whether the statement continues the previous one with an else branch. */ - private static boolean continuesWithElse(String statement) { - int i = skipBlanks(statement, 0); - while (i < statement.length() && statement.charAt(i) == '}') { - i = skipBlanks(statement, i + 1); - } - int end = i; - while (end < statement.length() && isIdentifierChar(statement.charAt(end))) { - end++; - } - return "else".equals(statement.substring(i, end)); - } - - private static boolean namesAnAlignedArtifact(String text) { - // The base library's name, which is a prefix of both shims', so one test - // covers the family. ALIGNED_ARTIFACTS is the two shims alone -- they are - // what gets a constraint written -- and asking only those missed a rule - // naming the base, which is the one whose version decides everything. - return text.contains(BASE_STDLIB); - } - - /** Whether the statement names the artifact, in either spelling. */ - private static boolean namesArtifactAnywhere(String line, String artifact) { - return namesCoordinate(line, artifact) - || (declaresMapEntry(line, "group", KOTLIN_GROUP) - && declaresMapEntry(line, "name", artifact)) - // The bare artifact name is enough, without the group beside it. A - // rule may compare one part only -- `if (d.requested.name == - // 'kotlin-stdlib') d.useVersion '1.7.22'` -- and it is in force - // either way; requiring both left that override unread, so the - // shims were raised to 1.8.0 around a base library the rule held at - // 1.7.22. That build links and then fails at runtime, when the jdk - // classes are in neither jar. - // - // Not when the statement declares some OTHER group, though: a fork - // published as com.example:kotlin-stdlib-jdk8 is a different module - // that happens to share a name, and reading it as the shim stood the - // whole block down for a pre-merge version of somebody else's jar. - // - // Otherwise safe, because these three names are the whole question: - // nothing else publishes kotlin-stdlib or its jdk shims, and the - // literal has to BE the name, so a coordinate that merely contains - // it does not match. - // - // A rule that compares the group to something else instead of - // declaring it -- d.requested.group == 'com.example' -- is not - // caught, and is left uncaught: telling a group literal from a - // version or a classifier by shape is the kind of guess this class - // keeps having to correct, and being wrong here only suppresses. - || (holdsLiteral(line, artifact) && !namesAnotherGroup(line)) - // An override that names the GROUP on its own applies to every - // module in it, this family included: - // if (d.requested.group == 'org.jetbrains.kotlin') - // d.useVersion '1.7.22' - // is the canonical Gradle snippet, and it holds the base library - // pre-merge while these constraints raise the shims to their empty - // 1.8.0 jars -- the failure that reaches the device. - // - // The group has to be a literal of its OWN, which is what makes - // this narrow: `force 'org.jetbrains.kotlin:kotlin-reflect:1.7.22'` - // carries the group inside a coordinate and does not match, so an - // override of an unrelated Kotlin module still leaves the block to - // write. A rule that names the group AND some other artifact does - // match, and stands the block down for a module it does not govern - // -- the ambiguity resolved the way every other one here is, - // because that costs an app the duplicate it already had. - || (holdsLiteral(line, KOTLIN_GROUP) && callsForce(line, artifact) - && !namesOneOfTheFamily(line)); - } - - /** - * Whether the statement names a particular member of the family, as a - * literal of its own. - * - *

What stops the group-wide reading above from widening a rule that has - * already narrowed itself. {@code group == '..' && name == 'kotlin-stdlib'} - * governs the base library and nothing else, and reading it as governing the - * family made the siblings look declared -- so their constraints were skipped - * as already handled and the block came out empty. That is not the safe - * direction: it leaves the duplicate exactly where it was.

- */ - private static boolean namesOneOfTheFamily(String line) { - if (holdsLiteral(line, BASE_STDLIB)) { - return true; - } - for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { - if (holdsLiteral(line, ALIGNED_ARTIFACTS[i])) { - return true; - } - } - return false; - } - - /** Whether the statement declares a group entry that is not Kotlin's. */ - private static boolean namesAnotherGroup(String line) { - String group = mapEntryValue(line, "group"); - return group != null && !KOTLIN_GROUP.equals(group); - } - - /** - * Whether the statement contains {@code value} as a literal of its own. - * - *

A resolution rule names an artifact by comparing its parts -- - * {@code d.requested.group == 'org.jetbrains.kotlin' && d.requested.name == - * 'kotlin-stdlib'} -- which is neither a coordinate nor a map entry, so - * neither of the shapes above saw it and a useVersion rewriting the base - * library went unread.

- */ - private static boolean holdsLiteral(String line, String value) { - for (int i = 0; i < line.length(); i++) { - if (!isLiteralStart(line, i)) { - continue; - } - int end = endOfStringLiteral(line, i); - if (value.equals(stringLiteralContent(line, i)) - && !isReasonArgument(line, i)) { - return true; - } - i = end; - } - return false; - } - - /** - * The version this statement declares for {@code artifact}: the third - * segment of its coordinate, or the map form's {@code version:} entry. - * Null when neither is readable, which callers treat as below the floor. - */ - private static String declaredVersionOf(String line, String artifact) { - // A rich version OVERRIDES the coordinate's own, so it is read first. - // implementation('...:kotlin-stdlib-jdk7:1.9.22') { version { strictly '1.7.22' } } - // resolves strictly to 1.7.22, and reporting 1.9.22 read a pre-merge pin as - // merged-era: the jdk7 constraint was skipped as already satisfied while jdk8 - // and the base were raised around it. - String rich = richVersionIn(line); - if (rich != null) { - return rich; - } - // A rich version that is PRESENT but unreadable is not an invitation to - // read the coordinate instead. `version { strictly providers - // .gradleProperty('legacy').get() }` beside a merged-era coordinate - // reported the coordinate, so a strict pin that may well be pre-merge read - // as merged-era: its own constraint was skipped as satisfied while the - // sibling was raised around it, which is the duplicate again. Unreadable is - // the honest answer, and belowTheFloor treats it as below. - if (callsStrictly(line) || callsNamed(line, USE_VERSION)) { - return null; - } - String fromCoordinate = coordinateVersionOf(line, artifact); - if (fromCoordinate != null) { - return fromCoordinate; - } - String mapped = mapEntryValue(line, "version"); - if (mapped != null) { - return mapped; - } - return null; - } - - /** - * Whether a component-selection rule may reject the version this writes. - * - *

Such a rule rejects CANDIDATES, outside any declaration, so the - * rejection reading that lives on a declaration never saw it: - * {@code componentSelection { all { if (it.candidate.module == - * 'kotlin-stdlib-jdk8' && it.candidate.version == '1.8.0') - * it.reject('..') } }} removes the very version this writes, and the - * constraint then has nothing to resolve to.

- * - *

Read across the whole body rather than one statement, because the - * opener, the predicate and the reject are three statements as soon as the - * rule is written over several lines -- which is how it is normally - * written. Any rejecting rule that mentions this family stands the block - * down: which candidates a closure will reject cannot be read here, and - * being wrong the other way emits a requirement into a graph that has - * excluded it.

- */ - private static boolean aComponentSelectionRuleRejectsTheFloor(String[] active, - String configuration) { - boolean governs = true; - int depth = 0; - int openedAt = -1; - boolean rejects = false; - boolean namesKotlin = false; - for (int i = 0; i < active.length; i++) { - if (openedAt < 0) { - // The configuration a rule belongs to may be named on an earlier - // statement than the one opening the rule, so it is carried. - if (configurationNamedIn(active[i]) != null - || active[i].indexOf(CONFIGURATIONS) >= 0) { - governs = governsTheConstrainedGraph(active[i], configuration); - } - if (opensBlockNamed(active[i], "componentSelection")) { - openedAt = depth; - rejects = false; - namesKotlin = false; - } - } - if (openedAt >= 0) { - rejects = rejects || callsNamed(active[i], "reject"); - namesKotlin = namesKotlin || mentionsTheKotlinGroup(active[i]); - } - int before = depth; - depth += braceBalance(active[i]); - if (depth < 0) { - depth = 0; - } - if (openedAt < 0) { - continue; - } - if (rejects && namesKotlin && governs) { - return true; - } - if (depth <= openedAt) { - openedAt = -1; - } else if (depth == openedAt + 1 && before > openedAt + 1) { - // One rule of several just closed. A block holds a rule per - // `all { }` or `withModule { }`, and accumulating across all of - // them let a rule that merely MENTIONS this family pair up with - // a sibling that rejects something else -- so the block stood - // down for a rejection that could not touch it, which leaves the - // duplicate exactly where it was. - rejects = false; - namesKotlin = false; - } - } - return false; - } - - /** - * Whether a soft {@code require} is the only thing holding this artifact. - * - *

Such a declaration is not management: the constraint raises it and the - * two coexist. Anything that really pins -- a {@code strictly}, a force, a - * rejection that closes the floor, or the {@code !!} suffix on the - * requirement itself -- answers false, and so does a coordinate carrying its - * own version, which is the app's chosen version rather than a floor under - * it.

- */ - private static boolean heldOnlyBySoftRequirement(String line, String artifact) { - if (callsStrictly(line) || callsForce(line, artifact) || rejectsTheFloor(line)) { - return false; - } - String declared = declaredVersionOf(line, artifact); - if (declared == null || declared.endsWith(STRICT_SUFFIX)) { - // Unreadable stays conservative, and the `!!` suffix is a pin. - return false; - } - // Only BELOW the floor. A soft version there is the case this is for: the - // constraint raises it and the two agree, so neither standing the block - // down nor skipping that artifact is right -- both leave the shim - // pre-merge beside whatever selected a merged-era base, which is the - // duplicate this exists to prevent. - // - // At or above the floor a soft version already satisfies the constraint, - // so leaving that artifact to the app costs nothing and says something - // true: the app has it in hand. A plain coordinate is soft in exactly the - // way a `require` is, which is why they are one question here now. - return isAPlainVersion(declared) && belowTheFloor(declared); - } - - /** - * Whether the version is an ordinary one rather than a selector or an - * unreadable reference. - * - *

What makes the exemption above safe is that the constraint can RAISE - * the version: {@code 1.7.22} and a floor of 1.8.0 agree on 1.8.0. Nothing - * else here can be raised that way. A range is satisfied or it is not -- - * {@code [1.0,1.5]} and 1.8.0 have no version in common, so exempting one - * would write a constraint that cannot resolve. And a version this cannot - * read at all, {@code $mystery} or {@code latest.release}, says nothing - * about what it will be, which is the conservative path everywhere else.

- */ - private static boolean isAPlainVersion(String version) { - if (version.length() == 0 || !Character.isDigit(version.charAt(0))) { - return false; - } - for (int i = 0; i < version.length(); i++) { - char c = version.charAt(i); - if (c == '[' || c == ']' || c == '(' || c == ')' || c == ',' - || c == '+' || c == '$' || c == '{') { - return false; - } - } - return true; - } - - /** The version the artifact's own coordinate carries, or null. */ - private static String coordinateVersionOf(String line, String artifact) { - String coordinate = KOTLIN_GROUP + ":" + artifact + ":"; - // Past `using`, when there is one: a substitution names the replaced module - // first and the replacement second, and it is the replacement that decides - // what resolves. - int from = afterCall(line, "using"); - String lowest = null; - for (int i = from < 0 ? 0 : from; i < line.length(); i++) { - char c = line.charAt(i); - if (!isLiteralStart(line, i)) { - continue; - } - int end = endOfStringLiteral(line, i); - String literal = stringLiteralContent(line, i); - // A literal ending AT the version separator carries no version: the - // rest is concatenated on, as in ("...:kotlin-stdlib-jdk7:" + version). - // Returning the empty string there read as a version below the floor and - // suppressed the block for a declaration that may well be merged-era. - // Unreadable is the honest answer, and it leaves both constraints to be - // written -- which cannot conflict with a plain requirement, only with a - // strict pin, and those are read before this. - if (literal.startsWith(coordinate) - && literal.length() > coordinate.length() - && !hasWhitespace(literal) - && !isReasonArgument(line, i)) { - // A reason can be nothing but a coordinate, and this scan reached it - // before the map's own version: entry. namesCoordinate learned to - // skip a reason and this did not, so the comment describing an old - // artifact supplied the version for the declaration warning about it. - String found = versionComponentOf(literal.substring(coordinate.length())); - lowest = lower(lowest, found); - } - i = end; - } - return lowest; - } - - /** - * The lower of two versions for the same module, either of which may be - * null for "not seen yet". - * - *

One statement can name a module twice: {@code force} takes varargs, so - * {@code force 'g:a:1.9.22', 'g:a:1.7.22'} is one call listing the same - * module at two versions. Reading the first reported the merged-era one and - * wrote the shim constraints beside a base library that may be forced - * pre-merge, which is the failure that reaches the device rather than the - * build.

- * - *

The LOWER rather than the last. Which of two selectors for one module - * Gradle keeps is not something this can establish from the text, and it - * does not have to: the lower answer is right if Gradle takes it, and - * conservative if Gradle takes the other, which is how this class resolves - * every ambiguity it cannot evaluate. An unreadable version is already the - * lowest answer there is.

- */ - private static String lower(String held, String found) { - if (held == null) { - return found; - } - if (found == null) { - return held; - } - return compareVersions(withoutStrictSuffix(found), - withoutStrictSuffix(held)) < 0 ? found : held; - } - - /** - * The value of a {@code key: 'value'} map entry, or null. - * - *

The KEY is looked for outside string literals only. A reason quoting - * the map form -- {@code because "avoid group: 'org.jetbrains.kotlin', - * name: 'kotlin-stdlib-jdk8'"} -- otherwise read as a declaration of that - * artifact, and since prose carries no version the whole block was - * suppressed. Same rule as the coordinate matcher beside it, which is - * where this had drifted apart from.

- */ - /** - * The version out of what follows {@code group:name:} in a coordinate. - * - *

Gradle's notation carries two optional modifiers after the version -- - * a classifier as a fourth colon-separated part, and an {@code @extension} - * -- and both were being returned as part of the version. That leaves - * {@code 1.7.22!!@jar}, which does not end in the strict marker, so a - * strict pre-merge pin read as an ordinary one and the constraint was - * written beside it.

- */ - private static String versionComponentOf(String remainder) { - int end = remainder.length(); - int at = remainder.indexOf('@'); - if (at >= 0) { - end = at; - } - int classifier = remainder.indexOf(':'); - if (classifier >= 0 && classifier < end) { - end = classifier; - } - return remainder.substring(0, end); - } - - private static String mapEntryValue(String line, String key) { - for (int i = 0; i < line.length(); i++) { - char c = line.charAt(i); - if (isLiteralStart(line, i)) { - // Groovy lets a map key be quoted -- ('group': '...', 'name': '...') -- - // and skipping every literal meant the key was never seen, so a - // declaration written that way named no artifact at all. - int quoted = endOfStringLiteral(line, i); - if (key.equals(stringLiteralContent(line, i))) { - int at = skipBlanks(line, quoted + 1); - if (at < line.length() && line.charAt(at) == ':') { - String value = valueAfterColon(line, at); - if (value != null) { - return value; - } - } - } - i = quoted; - continue; - } - if (!line.startsWith(key, i)) { - continue; - } - boolean startsToken = i == 0 || !isIdentifierChar(line.charAt(i - 1)); - int after = i + key.length(); - if (!startsToken || (after < line.length() - && isIdentifierChar(line.charAt(after)))) { - continue; - } - int j = skipBlanks(line, after); - if (j >= line.length() || line.charAt(j) != ':') { - continue; - } - String value = valueAfterColon(line, j); - if (value != null) { - return value; - } - i = j; - } - return null; - } - - /** - * The literal following the colon at {@code colonAt}, or null. - * - *

Shared by both spellings of a key, bare and quoted, so the delimiter - * rule is read once. Stripping one character per side used to leave a - * triple-quoted group or name wearing two quotes, and both then failed - * their exact match.

- */ - private static String valueAfterColon(String line, int colonAt) { - int at = skipBlanks(line, colonAt + 1); - if (at < line.length() && isLiteralStart(line, at) - && endOfStringLiteral(line, at) < line.length()) { - return stringLiteralContent(line, at); - } - return null; - } - - /** - * Whether the app strictly holds {@code kotlin-stdlib} itself below the - * floor both shims depend on. - * - *

The artifact has to be matched exactly. {@code kotlin-stdlib} is a - * prefix of {@code kotlin-stdlib-jdk8}, so a loose match would read every - * shim declaration as a pin on the base library and switch the whole block - * off. The character after the coordinate decides it: a colon starts the - * version and a quote ends the coordinate, while anything else -- a - * hyphen above all -- means this is a longer artifact name.

- * - *

An unreadable strict version counts as below the floor, because the - * failure it guards against cannot be worked around by the app while the - * duplicate class it risks instead can.

- */ - private static boolean strictlyPinsBaseStdlibBelowTheFloor(String[] appGradleFragments) { - String[] lines = activeLines(combined(appGradleFragments)); - { - for (int j = 0; j < lines.length; j++) { - if (!namesBaseStdlib(lines[j])) { - continue; - } - // Whether it is held strictly and what version it is held AT are two - // questions. Asking only the second let a strict pin whose version is - // unreadable -- version { strictly kotlinVersion } -- read as not - // strict at all, which is the opposite of the conservative path this - // documents everywhere else. belowTheFloor(null) is true for exactly - // this reason, and guarding on non-null defeated it. - if (holdsBaseStdlibStrictly(lines[j]) - && belowTheFloor(strictVersionOfBaseStdlib(lines[j]))) { - return true; - } - } - } - return false; - } - - /** - * The version this statement strictly holds {@code kotlin-stdlib} at, or - * null when it does not hold it strictly. - * - *

Two spellings mean the same thing. The {@code strictly} call is one; - * Gradle's {@code !!} suffix on the version is the other, and missing it - * was not a near miss. Measured with Gradle: an app writing - * {@code 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!'} beside a - * pre-merge jdk8 resolves the coherent 1.7.22 family on its own, and with - * this block's constraints added resolves kotlin-stdlib 1.7.22 beside - * jdk7/jdk8 1.8.0 -- the EMPTY shims. The jdk extension classes are then - * supplied by neither jar, and the app fails at runtime with a missing - * class rather than at build time with a duplicate one. That is the worst - * outcome available here, so the suffix is read as what it is.

- */ - private static String strictVersionOfBaseStdlib(String line) { - if (callsStrictly(line)) { - return strictVersionIn(line); - } - if (callsForce(line, BASE_STDLIB)) { - return declaredVersionOf(line, BASE_STDLIB); - } - String declared = declaredVersionOf(line, BASE_STDLIB); - if (declared != null && declared.endsWith(STRICT_SUFFIX)) { - return declared.substring(0, declared.length() - STRICT_SUFFIX.length()); - } - return null; - } - - /** Whether the statement holds the base library strictly, in either spelling. */ - private static boolean holdsBaseStdlibStrictly(String line) { - return holdsStrictly(line, BASE_STDLIB); - } - - /** - * Whether the statement holds {@code artifact} strictly, in either - * spelling. - * - *

One predicate because there were two, and they diverged: the bypass - * that lets a strict pin escape the configuration filter asked only about - * the {@code strictly} keyword, so - * {@code debugImplementation '...jdk8:1.7.22!!'} was filtered out as a - * variant declaration and got the constraint anyway -- against a strict - * requirement that had resolved fine before it.

- */ - private static boolean holdsStrictly(String line, String artifact) { - if (callsStrictly(line) || callsForce(line, artifact)) { - return true; - } - // A rejection manages the version from the other side, but only when it - // actually leaves our floor nothing to select. rejectAll does; so does an - // open-ended range starting at or below the floor, `reject '[1.8.0,)'`. - // `reject '1.7.0'` does not -- 1.8.0 and everything after it are still - // available, the graph still needs aligning, and treating every rejection as - // management left the original duplicate unfixed. - if (rejectsTheFloor(line)) { - return true; - } - String declared = declaredVersionOf(line, artifact); - return declared != null && declared.endsWith(STRICT_SUFFIX) - && strictCoordinateIsDeclared(line, artifact); - } - - /** - * Whether the strict coordinate this statement carries is actually being - * DECLARED, rather than merely passed to something. - * - *

The {@code !!} spelling skips the configuration check, because a strict - * pin is honoured wherever it is declared. "Wherever" still means declared: - * {@code logger.lifecycle('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')} - * is a log line, and reading it as a pin stood the whole block down for an app - * that had declared nothing at all.

- * - *

The discriminator is not a list of the calls that count -- configurations - * are open ended, and every list of them in this class has needed correcting. - * It is that a configuration is never reached through a receiver: an app - * writes {@code implementation '...'} or {@code myCustomConfig '...'}, never - * {@code project.implementation '...'}. So an unqualified call declares and a - * qualified one does not, with the dependency handler itself as the exception - * that {@code add} already needed.

- * - *

Only the coordinate spelling is asked. A {@code version: '1.7.22!!'} map - * entry is a dependency notation and nothing else, and a version reached - * through a rich-version closure has been read as syntax already.

- */ - private static boolean strictCoordinateIsDeclared(String line, String artifact) { - String coordinate = KOTLIN_GROUP + ":" + artifact + ":"; - for (int i = 0; i < line.length(); i++) { - if (!isLiteralStart(line, i)) { - continue; - } - int end = endOfStringLiteral(line, i); - String literal = stringLiteralContent(line, i); - if (literal.startsWith(coordinate) - && versionComponentOf(literal.substring(coordinate.length())) - .endsWith(STRICT_SUFFIX)) { - return isDeclarationArgument(line, i); - } - i = end; - } - // No coordinate carries it, so it came from a map entry or a closure. A - // map has to be DECLARED too: `def catalog = [group: '..', name: - // 'kotlin-stdlib', version: '1.7.22!!']` is dependency-shaped data that - // is never added to a configuration, and reading it as a strict pin stood - // the whole block down for an app that had declared nothing. - // - // Safe to exclude here where the same exclusion was NOT safe for - // enforcedPlatform: a map IS recorded as a definition's value, so - // `implementation(catalog)` below carries it and is read there. The - // platform's call expression is not recorded, which is why that one is - // still honoured wherever it appears. - // - // An assignment before the map is what says it is stored rather than - // declared, which needs no list of the calls that declare. - return !isStoredRatherThanDeclared(line); - } - - /** Whether an assignment precedes the map this statement carries. */ - private static boolean isStoredRatherThanDeclared(String line) { - for (int i = 0; i < line.length(); i++) { - if (isLiteralStart(line, i)) { - i = endOfStringLiteral(line, i); - continue; - } - char c = line.charAt(i); - if (c == '[' || followedByMapKeyColon(line, i)) { - return false; - } - if (isAssignmentAt(line, i)) { - return true; - } - } - return false; - } - - /** - * Whether an unqualified call is one of the dependency handler's adders. + * Whether the app's own Gradle text suggests it holds this family where the + * constraint would raise it. * - *

Named rather than matched by prefix. `add*` accepted any helper an app - * had defined -- {@code def addNote = { config, text -> .. }} called with a - * configuration and a coordinate declared a dependency as far as this was - * concerned, and the constraint for that artifact was skipped as already - * handled.

+ *

Both halves are required. An app that never names the family cannot be + * pinning it, and one that names it without any of these words is declaring + * an ordinary version -- which is a SOFT requirement in Gradle, so the + * constraint raises it and the two agree.

* - *

A qualified call does NOT consult this: there the receiver settles it, - * so a handler that grows a fourth adder keeps working through - * {@code dependencies.whatever(..)}. What an unrecognised name costs is a - * constraint written beside a declaration that was already there, which is - * the direction this class errs in everywhere.

+ *

Public because the builder logs a notice when it answers yes: an + * alignment that silently does not happen is the kind of thing support + * cannot explain afterwards.

*/ - private static boolean isADependencyHandlerAdder(String method) { - for (int i = 0; i < DEPENDENCY_HANDLER_ADDERS.length; i++) { - if (DEPENDENCY_HANDLER_ADDERS[i].equals(method)) { - return true; - } - } - return false; - } - - /** DependencyHandler's methods that take a configuration and a notation. */ - private static final String[] DEPENDENCY_HANDLER_ADDERS = { - "add", - "addProvider", - "addProviderBundle" - }; - - /** Whether the literal at {@code quoteAt} is an argument of a declaring call. */ - private static boolean isDeclarationArgument(String line, int quoteAt) { - int i = skipBlanksBackward(line, quoteAt - 1); - // Every parenthesis, not one. Groovy accepts a redundant pair -- - // `implementation(('g:a:1.7.22!!'))` -- and stepping over a single one - // left the walk looking at the other, which is not an identifier, so the - // strict pin read as nobody's argument and the constraints went in - // against it. Done before the comma and brace are looked for, because a - // wrapped LATER argument -- `add('impl', ('g:a:1.7.22!!'))` -- reaches - // them only once the parentheses are behind it. - while (i >= 0 && line.charAt(i) == '(') { - i = skipBlanksBackward(line, i - 1); - } - if (i >= 0 && (line.charAt(i) == ',' || line.charAt(i) == '{')) { - // Not the first thing the call was handed. A comma is where a - // coordinate sits in `dependencies.add('implementation', 'g:a:1.7!!')`, - // and a brace is where it sits inside a provider: - // `dependencies.addProvider('implementation', providers.provider { - // 'g:a:1.7!!' })`. Walking back one token found the punctuation and - // stopped, so the strict pin the app really had went unread and the - // constraints went in against it. - // - // The comma half was removed once as an exception that constrains - // nothing, on the reasoning that such a call is recognised where the - // CONFIGURATION name is read -- true for the shims, and not for the - // base library, which has a scan of its own that comes through here. - return isDeclarationCall(line, enclosingCallOf(line, quoteAt)); - } - if (i < 0 || !isIdentifierChar(line.charAt(i))) { - // Not an argument of anything -- a bare literal in a list, or an - // assignment's value. The use of the name decides those, not this. + public static boolean appPinsTheStdlibFamily(String... appGradleFragments) { + if (appGradleFragments == null) { return false; } - return isDeclarationCall(line, i); - } - - /** - * The index of the last character of the call whose argument list encloses - * {@code at}, or -1. - * - *

Found forward, so a parenthesis inside a string is not one. Groovy's - * command syntax has no parentheses at all, and there the call is the - * statement's first token.

- */ - private static int enclosingCallOf(String line, int at) { - List opened = new ArrayList(); - for (int i = 0; i < at && i < line.length(); i++) { - if (isLiteralStart(line, i)) { - i = endOfStringLiteral(line, i); - continue; - } - char c = line.charAt(i); - if (c == '(') { - opened.add(Integer.valueOf(i)); - } else if (c == ')' && !opened.isEmpty()) { - opened.remove(opened.size() - 1); - } - } - // Outward until one of them is a CALL's parenthesis. A redundant pair has - // no name in front of it, and stopping at the innermost reported the - // punctuation before it -- so `add('impl', ('g:a:1.7.22!!'))` found a - // comma where the call should be and read the pin as nobody's argument. - for (int k = opened.size() - 1; k >= 0; k--) { - int before = skipBlanksBackward(line, opened.get(k).intValue() - 1); - if (before >= 0 && isIdentifierChar(line.charAt(before))) { - return before; - } - } - // No parentheses anywhere, so this is Groovy's command syntax and the call - // is the statement's first token -- unless the statement is an assignment, - // in which case there is no call at all and the literal is just a value. - // Without that, `def all = ['g:a:1.7.22!!']` read its own `def` as the - // declaring call. - for (int i = 0; i < at && i < line.length(); i++) { - if (isLiteralStart(line, i)) { - i = endOfStringLiteral(line, i); - continue; - } - if (isAssignmentAt(line, i)) { - return -1; + StringBuilder all = new StringBuilder(); + for (int i = 0; i < appGradleFragments.length; i++) { + if (appGradleFragments[i] != null) { + all.append(appGradleFragments[i]).append('\n'); } } - int first = skipBlanks(line, 0); - int end = first; - while (end < line.length() && isIdentifierChar(line.charAt(end))) { - end++; - } - return end > first ? end - 1 : -1; - } - - /** - * Whether the call ending at {@code at} is one that can declare a - * dependency. - * - *

A configuration is never reached through a receiver -- an app writes - * {@code implementation '..'} or {@code myCustomConfig '..'}, never - * {@code project.implementation '..'} -- so an unqualified call declares. - * The dependency handler is the exception, because {@code add} really is - * called on it.

- */ - private static boolean isDeclarationCall(String line, int at) { - if (at < 0 || !isIdentifierChar(line.charAt(at))) { - return false; - } - int i = at; - while (i >= 0 && isIdentifierChar(line.charAt(i))) { - i--; - } - int dot = skipBlanksBackward(line, i); - if (dot < 0 || line.charAt(dot) != '.') { - return !isAnOutputHelper(line.substring(i + 1, at + 1)); - } - int end = skipBlanksBackward(line, dot - 1); - if (end < 0) { + String text = all.toString(); + if (text.indexOf(STDLIB_FAMILY) < 0) { return false; } - int start = end; - while (start >= 0 && (isIdentifierChar(line.charAt(start)) - || (line.charAt(start) == '.' && start > 0 - && isIdentifierChar(line.charAt(start - 1))))) { - start--; - } - // The constraint handler is the other one that takes a configuration and a - // notation: `constraints.add('implementation', 'g:a:1.7.22!!')` is a - // strict pin the app really has, and rejecting it because the receiver is - // not `dependencies` wrote the shim constraints against it. - String receiver = line.substring(start + 1, end + 1); - return lastSegmentIs(receiver, "dependencies") - || lastSegmentIs(receiver, "constraints"); - } - - /** - * Whether an unqualified call is one that prints rather than declares. - * - *

A configuration is never reached through a receiver, which is what - * makes an unqualified call a declaration -- but Groovy's output helpers are - * unqualified too, so {@code println('g:a:1.7.22!!')} read as a strict pin - * and stood the block down for a string the app was only logging.

- * - *

Named, because the configurations themselves cannot be: an app may call - * one anything. That makes this the complement of an open set, so it is a - * list -- and one it fails safely: a helper missing from it keeps being read - * as a declaration, which is what happens today and costs at worst the - * duplicate an app already had. Listing the CONFIGURATIONS instead would - * fail the other way, dropping a real pin the moment a project names a - * configuration nobody anticipated.

- */ - private static boolean isAnOutputHelper(String call) { - for (int i = 0; i < OUTPUT_HELPERS.length; i++) { - if (OUTPUT_HELPERS[i].equals(call)) { + for (int i = 0; i < PINNING_WORDS.length; i++) { + if (text.indexOf(PINNING_WORDS[i]) >= 0) { return true; } } return false; } - - /** Groovy's unqualified printing calls, which declare nothing. */ - private static final String[] OUTPUT_HELPERS = { - "println", - "print", - "printf" - }; - - /** Gradle's strict-version shorthand, written after the version. */ - private static final String STRICT_SUFFIX = "!!"; - - /** Whether the statement names {@code kotlin-stdlib} and not a longer name. */ - private static boolean namesBaseStdlib(String line) { - return namesArtifactAnywhere(line, BASE_STDLIB); - } - - /** - * Whether a string literal in this statement IS the dependency notation - * for {@code artifact}, rather than merely mentioning it. - * - *

A coordinate lives inside a string, so "outside a string" cannot be - * the test the way it is for {@code strictly} or a configuration name. - * What separates the two is where in the string it sits: - * {@code 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'} opens with it, - * while {@code because 'avoid org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'} - * is prose that happens to contain it -- and reading that prose as a - * declaration dropped the constraint for an artifact nobody had pinned.

- * - *

The artifact is matched exactly: {@code kotlin-stdlib} is a prefix of - * {@code kotlin-stdlib-jdk8}, so what follows the name has to be the - * version separator or the end of the literal.

- */ - private static boolean namesCoordinate(String line, String artifact) { - String coordinate = KOTLIN_GROUP + ":" + artifact; - for (int i = 0; i < line.length(); i++) { - char c = line.charAt(i); - if (!isLiteralStart(line, i)) { - continue; - } - // The shared rule. This was the last scanner still tracking a single - // delimiter character of its own: it closed a triple-quoted literal on - // the second of the three, then read the third as a new opener, so a - // reason written '''...''' lost its `because` and was taken for the - // declaration it was warning about. - int end = endOfStringLiteral(line, i); - String literal = stringLiteralContent(line, i); - // Dependency notation carries no whitespace; a reason sentence - // does. Without that, a reason that merely OPENS with the - // coordinate -- because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8: - // 1.7.22 causes duplicate classes' -- read as the declaration it - // was warning about, and switched off the constraint that would - // have prevented exactly what it describes. - if ((literal.equals(coordinate) - || literal.startsWith(coordinate + ":")) - && !hasWhitespace(literal) - && !isReasonArgument(line, i) - && !isAssignedValue(line, i)) { - return true; - } - i = end; - } - return false; - } - - /** - * Whether the literal opening at {@code quoteAt} is being assigned to - * something rather than handed to a dependency. - * - *

{@code def legacy = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'} - * names the artifact and carries a strict marker, and on that basis alone - * it suppressed the whole block -- for a value that is never added to any - * configuration and decides nothing. A definition becomes a declaration - * when it is USED, and by then the name has been inlined and the usage is - * what this reads.

- * - *

Narrower than requiring a configuration, which was the other way to - * fix this and would have undone something deliberate: a strict pin on a - * variant configuration still shares a classpath with the one being - * constrained, so it suppresses on purpose. The distinction here is - * between a value and a call, not between one configuration and - * another.

- */ - private static boolean isAssignedValue(String line, int quoteAt) { - int i = skipBlanksBackward(line, quoteAt - 1); - if (i < 0) { - return false; - } - char before = line.charAt(i); - // A map ENTRY's value is not handed to a dependency either: - // def catalog = [legacy: 'org.jetbrains.kotlin:...:1.7.22!!'] - // is a map of strings, and reading its entry as a strict declaration - // suppressed the block for something never added to a configuration. The - // dependency map form -- group:, name:, version: -- is read by - // declaresMapEntry, which does not come through here. - // - // Only the colon. A bracket and a comma both looked like they belonged in - // this set and neither does: forcedModules = ['org.jetbrains.kotlin:...'] - // and dependencies.add('implementation', '...') each put a coordinate that - // really does decide something directly after one, and excluding them - // stopped a genuine force from being seen. So a bare list of coordinates - // assigned to a variable nothing uses still suppresses -- the narrower - // reading, and the one the tests hold. - if (before == ':') { - return true; - } - return before == '=' - && (i == 0 || line.charAt(i - 1) != '=') - && (i + 1 >= line.length() || line.charAt(i + 1) != '='); - } - - /** - * Whether the token ending at {@code end} is a named argument's key. - * - *

Gradle's parenthesis-free map form puts two bare tokens in a row -- - * {@code implementation group: group, name: '...'} -- which is exactly what - * a typed declaration looks like to a token counter. Read as one, it - * "declared" a variable called group with no initialiser and cleared the - * real binding of that name, so the strict declaration that used it later - * was never matched.

- */ - private static boolean followedByMapKeyColon(String statement, int end) { - int at = skipBlanks(statement, end); - return at < statement.length() && statement.charAt(at) == ':' - && (at + 1 >= statement.length() || statement.charAt(at + 1) != ':'); - } - - /** - * Whether the literal opening at {@code quoteAt} is the argument of a - * reason rather than a dependency. - * - *

A reason is usually prose and the whitespace rule catches it, but a - * reason can be nothing BUT a coordinate -- {@code because - * 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'} names the artifact it - * is warning about and has no whitespace at all. Read as a declaration, it - * supplied a pre-merge version and suppressed the entire block: the - * comment describing the duplicate switched off the constraint that - * prevents it.

- */ - private static boolean isReasonArgument(String line, int quoteAt) { - int i = quoteAt - 1; - while (i >= 0 && (isBlank(line.charAt(i)) || line.charAt(i) == '(')) { - i--; - } - int end = i + 1; - while (i >= 0 && isIdentifierChar(line.charAt(i))) { - i--; - } - return end > i + 1 && BECAUSE.equals(line.substring(i + 1, end)); - } - - private static final String BECAUSE = "because"; - - /** - * The app's fragments as the one script they become. - * - *

They are separate build hints but the builder concatenates them into - * a single generated {@code build.gradle}, so a {@code def} written in one - * is in scope for the next. Reading them apart lost the definition at the - * boundary and a strict pin behind it went unseen -- which is the failure - * this must never produce, since a constraint cannot coexist with a strict - * version.

- */ - private static String combined(String[] appGradleFragments) { - if (appGradleFragments == null) { - return ""; - } - StringBuilder out = new StringBuilder(); - for (int i = 0; i < appGradleFragments.length; i++) { - if (appGradleFragments[i] == null) { - continue; - } - if (out.length() > 0) { - out.append('\n'); - } - out.append(appGradleFragments[i]); - } - return out.toString(); - } - - /** Whether the text contains any whitespace. */ - private static boolean hasWhitespace(String text) { - for (int i = 0; i < text.length(); i++) { - if (Character.isWhitespace(text.charAt(i))) { - return true; - } - } - return false; - } - - /** The version inside this statement's {@code strictly} call, or null. */ - private static String strictVersionIn(String statement) { - return versionInCall(statement, STRICTLY); - } - - /** - * The version a rich-version closure declares, whichever keyword carries - * it. - * - *

{@code strictly} is the one that changes whether the constraints can - * coexist with the app's, and {@code useVersion} rewrites what was - * requested on the way through, so both are read.

- * - *

{@code require} is read too, because it OVERRIDES the coordinate: - * {@code implementation('..jdk7:1.7.22') { version { require '1.9.22' } }} - * resolves 1.9.22, and reading the coordinate there called a merged-era - * declaration pre-merge. Reading it is not the same as treating it as a - * pin -- see heldOnlyBySoftRequirement, which is where that distinction - * lives.

- * - *

{@code prefer} is deliberately NOT read here. A preference is soft: - * Gradle takes it only when nothing stronger is in play, so a transitive - * requirement for a pre-merge shim beats it and the class-bearing jar wins - * anyway. Treating a preference as proof the artifact cannot resolve below - * the floor suppressed the constraint that was the only thing standing - * between that graph and the duplicate.

- */ - private static String richVersionIn(String statement) { - // Whether it was CALLED settles which keyword speaks, and what it was - // called with is a separate question. Falling through on a null let - // `require '1.9.22'; strictly providers.gradleProperty('legacy').get()` - // report the requirement -- so a shim whose strict version may be - // pre-merge read as merged-era, its own constraint was skipped, and the - // sibling was raised around it. - if (callsStrictly(statement)) { - return versionInCall(statement, STRICTLY); - } - // A resolution rule's useVersion is as authoritative as either: it rewrites - // what was requested, silently, on the way through. - if (callsNamed(statement, USE_VERSION)) { - return versionInCall(statement, USE_VERSION); - } - return versionInCall(statement, "require"); - } - - private static final String USE_VERSION = "useVersion"; - - /** The quoted argument of {@code call}, found outside string literals. */ - private static String versionInCall(String statement, String call) { - List found = versionsInCall(statement, call); - if (found.isEmpty()) { - return null; - } - // The LAST of them, when they run one after another. Every keyword this is - // asked about -- strictly, require, useVersion -- SETS the constraint - // rather than adding to it, so a closure that calls one twice keeps what it - // was set to last. Reading the first reported 1.9.22 for - // `strictly '1.9.22'; strictly '1.7.22'` and wrote the shim constraints - // beside a pin that was really pre-merge. - // - // But a conditional makes them ALTERNATIVES rather than a sequence: - // `if (legacy) strictly '1.7.22' else strictly '1.9.22'` sets one or the - // other, and which one is not readable here. The lowest is the answer then, - // for the reason every unevaluable branch gets it -- a pre-merge version - // that may be the live one has to stand the block down. - if (!containsAConditional(statement) && !holdsATernary(statement)) { - return found.get(found.size() - 1); - } - // An arm this cannot read is an alternative like any other, and the one - // that may be live: unknown wins over every readable branch beside it. - String lowest = null; - for (int i = 0; i < found.size(); i++) { - if (found.get(i) == null) { - return null; - } - lowest = lower(lowest, found.get(i)); - } - return lowest; - } - - /** Whether the statement chooses between branches this cannot evaluate. */ - private static boolean containsAConditional(String statement) { - for (int i = 0; i < statement.length(); i++) { - if (isLiteralStart(statement, i)) { - i = endOfStringLiteral(statement, i); - continue; - } - if (!isIdentifierChar(statement.charAt(i)) - || (i > 0 && isIdentifierChar(statement.charAt(i - 1)))) { - continue; - } - int end = i; - while (end < statement.length() && isIdentifierChar(statement.charAt(end))) { - end++; - } - String token = statement.substring(i, end); - // A switch arm is an alternative like any other, and `case` alone is - // enough to say so -- reading the last version kept whichever arm was - // written last rather than whichever runs. - if ("if".equals(token) || "else".equals(token) - || "switch".equals(token) || "case".equals(token) - || "default".equals(token)) { - return true; - } - i = end - 1; - } - return false; - } - - /** Whether a question mark outside a literal makes this an expression branch. */ - private static boolean holdsATernary(String statement) { - for (int i = 0; i < statement.length(); i++) { - if (isLiteralStart(statement, i)) { - i = endOfStringLiteral(statement, i); - continue; - } - // `legacy ? strictly('1.7.22') : strictly('1.9.22')` chooses between - // them exactly as an if/else does, and so does the elvis form. Safe - // navigation is the one question mark that branches nothing, and it is - // the only one followed by a dot. - if (statement.charAt(i) == '?' - && (i + 1 >= statement.length() || statement.charAt(i + 1) != '.')) { - return true; - } - } - return false; - } - - /** - * Every quoted argument of every syntactic {@code call} in the statement, in - * source order. - * - *

One call may carry several -- {@code reject} takes varargs -- and the - * call may be made more than once. The two are the same thing to a caller - * that has to consider the arguments together, so they arrive as one list.

- */ - private static List versionsInCall(String statement, String call) { - List found = new ArrayList(); - // The same syntax-level call callsStrictly validated, not any occurrence of - // the word: a reason reading `because "strictly '1.7.22' is not intended"` - // otherwise supplies the version for a declaration whose real strict version - // is something else entirely, and the wrong one decides whether the block is - // written. - for (int i = 0; i < statement.length(); i++) { - if (isLiteralStart(statement, i)) { - i = endOfStringLiteral(statement, i); - continue; - } - if (!statement.startsWith(call, i)) { - continue; - } - boolean startsToken = i == 0 || !isIdentifierChar(statement.charAt(i - 1)); - if (!startsToken) { - continue; - } - int after = skipBlanks(statement, i + call.length()); - if (after < statement.length() && statement.charAt(after) == '(') { - after = skipBlanks(statement, after + 1); - } - // A call with no literal argument still HAPPENED, and what it set is - // unknown. Recorded as nothing at all, `if (legacy) strictly - // providers.gradleProperty('k').get() else strictly '1.9.22'` looked - // like a single readable branch, so the lowest was 1.9.22 and the - // constraints went in beside a pin that may well be pre-merge. - boolean read = false; - while (after < statement.length() && isLiteralStart(statement, after)) { - int end = endOfStringLiteral(statement, after); - if (end >= statement.length()) { - break; - } - read = true; - // The literal's own delimiters, however many it has. Written - // strictly """1.7.22""", the one-per-side slice returned - // ""1.7.22"" -- which parsed as no version at all and only - // reached the right answer because an unreadable version counts - // as below the floor. Correct by accident is not correct. - found.add(stringLiteralContent(statement, after)); - after = skipBlanks(statement, end + 1); - if (after >= statement.length() || statement.charAt(after) != ',') { - break; - } - after = skipBlanks(statement, after + 1); - } - if (!read) { - found.add(null); - } - // One BEFORE the next unread character, because the loop's own step - // lands on it. Advancing straight to it skipped a character, and while - // this returned on the first call that cost nothing -- now that it - // keeps looking, it landed inside `strictly` and read the second call - // of a repeated pair as ordinary text. - i = after - 1; - } - return found; - } - - /** - * Whether a strict version is below the floor the shims depend on. - * - *

A prerelease of the floor is below it. {@code 1.8.0-RC2} is a - * published Kotlin version, and its numeric part compares equal to - * {@code 1.8.0} -- so without this it read as "at the floor" and the block - * was written, whereupon the shims request the FINAL 1.8.0 and cannot - * coexist with the app's strict prerelease. A qualifier on any other - * version is ignored, because rounding {@code 1.9.22-RC} up to - * {@code 1.9.22} keeps it above the floor either way.

- * - *

Unreadable counts as below, because the failure it guards against - * cannot be worked around by the app while the duplicate class it risks - * instead can.

- */ - private static boolean belowTheFloor(String version) { - if (version == null) { - return true; - } - String selector = version.trim(); - if (selector.length() == 0) { - return true; - } - // "Below the floor" means "cannot resolve to the floor or above", which is - // not the same as "starts below it". A range [1.7.0,1.9.0) begins below and - // still selects a merged-era shim, so our constraint intersects it rather - // than conflicting; reading the lower endpoint suppressed the block for a - // declaration Gradle would have satisfied. Each selector shape answers the - // question its own way, which is why they are separated here rather than - // funnelled through one bound. - char opening = selector.charAt(0); - if (opening == '[' || opening == '(' || opening == ']') { - return rangeCannotReachTheFloor(selector); - } - int dynamic = selector.indexOf(".+"); - if (dynamic >= 0) { - // 1.7.+ cannot leave 1.7, so it is below. 1.+ can reach 1.9, so it is not. - String prefix = selector.substring(0, dynamic); - return compareVersions(prefix, - truncatedToSameDepth(MERGED_STDLIB_FLOOR, prefix)) < 0; - } - if ("+".equals(selector) || selector.startsWith("latest.")) { - // `+` and Gradle's status selectors -- latest.release, latest.integration - // -- have no ceiling at all, so they can always select a merged-era shim. - // Compared as a literal, latest.release parsed as zero and read as the - // oldest version there is. - return false; - } - return literalBelowTheFloor(selector); - } - - /** - * The index just past a type-argument list starting at {@code at}, or - * {@code at} when there is not one there. - * - *

Only identifiers, dots, commas, wildcards and array brackets may - * appear inside, and the angle brackets have to balance. A `<` that is - * really the comparison operator fails both tests, so it is left where it - * is rather than swallowing the rest of the statement.

- */ - private static int endOfTypeArguments(String statement, int at) { - if (at >= statement.length() || statement.charAt(at) != '<') { - return at; - } - int depth = 0; - for (int i = at; i < statement.length(); i++) { - char c = statement.charAt(i); - if (c == '<') { - depth++; - } else if (c == '>') { - depth--; - if (depth == 0) { - return i + 1; - } - } else if (!isIdentifierChar(c) && c != '.' && c != ',' && c != '?' - && c != '[' && c != ']' && !Character.isWhitespace(c)) { - return at; - } - } - return at; - } - - /** - * Where a declaration may start in this statement. - * - *

A block opener shares the statement with what it opens -- - * {@code if (cond) { String dep = '...'}} -- and the walk that separates a - * declaration from an assignment begins at the first token, which there is - * {@code if}. It stopped at the parenthesis and the declaration behind it - * was never recorded, so the pin that declaration held went unseen. The - * {@code def} spelling never had this because it is searched for anywhere - * in the statement.

- * - *

Only a brace BEFORE the assignment counts. In {@code Closure c = { .. }} - * the brace IS the value, and starting after it would skip the name being - * assigned to -- which is a declaration this already reads.

- */ - private static int afterAnyBlockOpener(String statement) { - // Past a header whose body is on the SAME line, which opens no brace at - // all: `if (legacy) dep = '..'` began the walk at `if`, stopped at its - // parenthesis, and recorded nothing -- so a conditional swap to a - // pre-merge coordinate was not seen and the name kept whatever it had. - int start = afterAnUnbracedHeader(statement); - for (int i = start; i < statement.length(); i++) { - if (isLiteralStart(statement, i)) { - i = endOfStringLiteral(statement, i); - continue; - } - char c = statement.charAt(i); - if (c == '{') { - start = i + 1; - } else if (isAssignmentAt(statement, i)) { - break; - } - } - return start; - } - - /** - * The index just past a leading {@code if (..)} or {@code while (..)} whose - * body follows on the same line, or 0. - * - *

Only a header at the START of the statement, because that is the one - * whose body the rest of the statement is. The condition's own parentheses - * are stepped over as a unit, so a call inside it is not mistaken for the - * end.

- */ - private static int afterAnUnbracedHeader(String statement) { - int at = skipBlanks(statement, 0); - int end = at; - while (end < statement.length() && isIdentifierChar(statement.charAt(end))) { - end++; - } - if (end == at || UNBRACED_HEADERS.indexOf( - " " + statement.substring(at, end) + " ") < 0) { - return 0; - } - int open = skipBlanks(statement, end); - if (open >= statement.length() || statement.charAt(open) != '(') { - // `else` carries no condition, so its body starts straight after it. - return "else".equals(statement.substring(at, end)) ? end : 0; - } - int depth = 0; - for (int i = open; i < statement.length(); i++) { - if (isLiteralStart(statement, i)) { - i = endOfStringLiteral(statement, i); - continue; - } - char c = statement.charAt(i); - if (c == '(') { - depth++; - } else if (c == ')') { - depth--; - if (depth == 0) { - return i + 1; - } - } - } - return 0; - } - - /** Whether the character at {@code at} is an assignment, not a comparison. */ - private static boolean isAssignmentAt(String statement, int at) { - if (statement.charAt(at) != '=') { - return false; - } - if (at + 1 < statement.length() && statement.charAt(at + 1) == '=') { - return false; - } - // `>=`, `!=`, `+=` and the rest end in the same character and none of them - // opens a declaration, so a comparison in an `if` would otherwise stop the - // search before the brace it guards. - return at == 0 || "=!<>+-*/%&|^~".indexOf(statement.charAt(at - 1)) < 0; - } - - /** - * Whether a dotted Gradle path ends in the given segment. - * - *

`ext`, `project.ext` and `rootProject.ext` all name the one extra - * properties extension, so the segment that owns the property is the last - * one rather than the whole qualifier.

- */ - private static boolean lastSegmentIs(String path, String segment) { - return segment.equals(path.substring(path.lastIndexOf('.') + 1)); - } - - /** - * Whether the statement enforces a Kotlin platform that cannot reach the - * floor. - * - *

A version this cannot read counts as below it, the same way a - * declaration's does: an enforced platform is strict by construction, so - * guessing that it is high enough is guessing that the constraints below - * will resolve.

- */ - private static boolean namesAnEnforcedKotlinPlatformBelowTheFloor(String line) { - // Any statement that builds one counts, including `def bom = - // enforcedPlatform('..')` that is never added to a configuration. - // Reported as too broad, and correctly on the Gradle fact: the object it - // returns constrains nothing until something declares it. - // - // Acting on that needs to tell "stored and never added" from "stored and - // added below", and the second is why anyone stores one. The definition - // machinery records literals and maps, not call expressions, so the name - // carries nothing: measured by excluding the assigned form, the - // def legacyBom = enforcedPlatform('..kotlin-bom:1.7.22') - // implementation(legacyBom) - // pair stops standing the block down and the constraints go in against a - // BOM that really is strict and really is pre-merge. That is a failed - // resolution; the cost of the present reading is the duplicate an app - // already had, in an app that went out of its way to name a pre-merge - // Kotlin BOM and then not use it. - // - // Revisit together with recording call-expression values, not before: - // the exclusion is only safe once the add site carries the platform. - List enforced = versionsInCall(line, ENFORCED_PLATFORM); - for (int i = 0; i < enforced.size(); i++) { - if (enforced.get(i) == null) { - // The call carried no literal, which is the map form: the entries - // below answer it. - continue; - } - String coordinate = enforced.get(i).trim(); - if (!coordinate.startsWith(KOTLIN_GROUP + ":")) { - continue; - } - int version = coordinate.indexOf(':', KOTLIN_GROUP.length() + 1); - if (version < 0) { - // No version at all, so nothing says it reaches the floor. - return true; - } - if (belowTheFloor(withoutStrictSuffix( - versionComponentOf(coordinate.substring(version + 1))))) { - return true; - } - } - // A platform takes a dependency notation, and a map is one: - // enforcedPlatform(group: '..', name: 'kotlin-bom', version: '1.7.22'). - // There is no literal following the call at all in that spelling, so the - // scan above found nothing and the enforced pre-merge BOM read as absent. - // The same two entries the map form of a declaration is read by. - if (callsNamed(line, ENFORCED_PLATFORM) - && declaresMapEntry(line, "group", KOTLIN_GROUP)) { - return belowTheFloor(withoutStrictSuffix(mapEntryValue(line, "version"))); - } - return false; - } - - /** A version without the {@code !!} that makes it strict, if it carries one. */ - private static String withoutStrictSuffix(String version) { - if (version != null && version.endsWith(STRICT_SUFFIX)) { - return version.substring(0, version.length() - STRICT_SUFFIX.length()); - } - return version; - } - - /** The platform spelling whose managed versions become strict. */ - private static final String ENFORCED_PLATFORM = "enforcedPlatform"; - - /** - * Whether the statement names the plugin classpath outright. - * - *

{@code configurations.classpath} is the buildscript's own, and a - * strategy on it governs which plugin jars load -- never the app's - * dependencies, which is all this class writes to. It is the spelling that - * works at the top level, where there is no {@code buildscript} block - * around it to say the same thing.

- * - *

Every scan sees the result, not just the one that reads - * {@code failOnVersionConflict}: a force or a strict pin on that - * configuration cannot conflict with these constraints either, and reading - * one as the app managing the family left a real duplicate unfixed.

- */ - private static boolean namesTheBuildscriptClasspath(String line) { - return "classpath".equals(configurationNamedIn(line)); - } - - /** - * The single configuration this statement names, or null when it names none - * or cannot say which. - * - *

Every spelling goes through the same reading, because they are the same - * question: {@code configurations.classpath}, - * {@code configurations['classpath']} and - * {@code configurations.getByName('classpath')} all name one configuration, - * and only the first was recognised -- so a plugin-classpath force written - * either of the other two ways read as the app managing the family and stood - * the whole block down.

- * - *

Null when a closure decides which configurations are meant: - * {@code configurations.all { }}, {@code configureEach}, and - * {@code matching { }} may all include the one being constrained, and no - * name is available to say. That falls out of the syntax rather than a list - * -- a lookup takes a string, a filter takes a closure -- so a selector - * nobody anticipated reads as "cannot say", which is the answer that keeps - * the constraint out of a graph that would fail on it.

- */ - private static String configurationNamedIn(String line) { - int at = -1; - for (int i = 0; i < line.length(); i++) { - if (isLiteralStart(line, i)) { - i = endOfStringLiteral(line, i); - continue; - } - int after = i + CONFIGURATIONS.length(); - if (line.startsWith(CONFIGURATIONS, i) - && (i == 0 || !isIdentifierChar(line.charAt(i - 1))) - && (after >= line.length() - || !isIdentifierChar(line.charAt(after)))) { - at = after; - break; - } - } - if (at < 0) { - return null; - } - int next = skipBlanks(line, at); - if (next < line.length() && line.charAt(next) == '[') { - return literalAfter(line, next + 1); - } - if (next >= line.length() || line.charAt(next) != '.') { - return null; - } - int start = skipBlanks(line, next + 1); - int end = start; - while (end < line.length() && isIdentifierChar(line.charAt(end))) { - end++; - } - if (end == start) { - return null; - } - int after = skipBlanks(line, end); - if (after < line.length() && line.charAt(after) == '(') { - // A lookup carries the name as a string; a filter carries a closure - // and says nothing about which configurations it will match. - return literalAfter(line, after + 1); - } - if (after < line.length() && line.charAt(after) == '{') { - return null; - } - return line.substring(start, end); - } - - /** The content of the string literal starting at or after {@code from}. */ - private static String literalAfter(String line, int from) { - int at = skipBlanks(line, from); - if (at >= line.length() || !isLiteralStart(line, at)) { - return null; - } - return stringLiteralContent(line, at); - } - - private static final String BUILDSCRIPT_CLASSPATH = "configurations.classpath"; - - /** Whether the statement rejects the floor and everything past it. */ - private static boolean rejectsTheFloor(String line) { - if (callsNamed(line, "rejectAll")) { - return true; - } - // Rejections ACCUMULATE -- reject takes varargs and may be called again -- - // and Gradle applies every one of them, so every selector is asked, not - // just the first. Read one at a time, a pair that jointly removes the floor - // looked harmless twice over. - // - // The question asked of each is only whether it removes the floor ITSELF. - // It once also demanded that everything past the floor be gone, on the - // reasoning that a higher version was still selectable -- but what this - // block writes is a constraint on exactly 1.8.0, so the floor is the only - // version whose availability it depends on. An app that rejects 1.8.0 has - // said it does not want the version this pins to, which is the whole - // signal this scan exists to read, and writing the constraint anyway asks - // its graph to resolve to a version it excluded. - List rejected = versionsInCall(line, "reject"); - for (int i = 0; i < rejected.size(); i++) { - if (rejected.get(i) == null) { - // A rejection whose selector cannot be read may be the one that - // removes the floor. - return true; - } - if (rejectionRemovesTheFloor(rejected.get(i).trim())) { - return true; - } - } - return false; - } - - /** - * Whether one rejection selector removes the floor version itself. - * - *

A prerelease of the floor is a different version from the floor, so - * rejecting {@code 1.8.0-RC2} does not reject {@code 1.8.0}.

- */ - private static boolean rejectionRemovesTheFloor(String selector) { - if (selector.length() == 0) { - return false; - } - char opening = selector.charAt(0); - if (opening != '[' && opening != '(' && opening != ']') { - // A plain version rejects exactly itself. - return isTheFloor(selector); - } - int comma = selector.indexOf(','); - if (comma < 0) { - // [1.8.0] is an exact version written as a range. - return isTheFloor(selector.substring(1, - Math.max(1, selector.length() - 1)).trim()); - } - char closing = selector.charAt(selector.length() - 1); - boolean excludesLower = opening == '(' || opening == ']'; - boolean excludesUpper = closing == ')' || closing == '['; - String lower = selector.substring(1, comma).trim(); - if (lower.length() != 0) { - int compared = compareVersions(lower, MERGED_STDLIB_FLOOR); - if (compared > 0 || (compared == 0 && excludesLower)) { - return false; - } - } - String upper = selector.substring(comma + 1, - Math.max(comma + 1, selector.length() - 1)).trim(); - if (upper.length() != 0) { - int compared = compareVersions(upper, MERGED_STDLIB_FLOOR); - if (compared < 0 || (compared == 0 && excludesUpper)) { - return false; - } - } - return true; - } - - /** Whether a plain version literal IS the floor, prerelease and all. */ - private static boolean isTheFloor(String version) { - return version.length() != 0 - && compareVersions(version, MERGED_STDLIB_FLOOR) == 0 - && !literalBelowTheFloor(version); - } - - /** Whether a range excludes every version at or above the floor. */ - private static boolean rangeCannotReachTheFloor(String selector) { - int comma = selector.indexOf(','); - if (comma < 0) { - // [1.8.0] is an exact version written as a range. - String exact = selector.substring(1, - Math.max(1, selector.length() - 1)).trim(); - return exact.length() == 0 || literalBelowTheFloor(exact); - } - String upper = selector.substring(comma + 1, - Math.max(comma + 1, selector.length() - 1)).trim(); - if (upper.length() == 0) { - // [1.7.0,) has no ceiling at all. - return false; - } - char closing = selector.charAt(selector.length() - 1); - if (closing == ')' || closing == '[') { - // Excluding its bound, the range stops short of it: at or below the floor - // numerically means nothing at or above the floor is selectable. - return compareVersions(upper, MERGED_STDLIB_FLOOR) <= 0; - } - // Including it, the bound itself is selectable -- so the question is exactly - // the one asked of a plain version, prerelease and all. Comparing numerically - // here read [1.7.0,1.8.0-RC2] as reaching the floor, when a release candidate - // of it is below it and the constraint had nothing to resolve to. - return literalBelowTheFloor(upper); - } - - /** A plain version, with a prerelease at the floor counting as below it. */ - private static boolean literalBelowTheFloor(String version) { - int compared = compareVersions(version, MERGED_STDLIB_FLOOR); - if (compared != 0) { - return compared < 0; - } - // At the floor numerically, only a PRERELEASE is below it. A dynamic marker - // is not: 1.8.+ cannot resolve lower than 1.8.0, so it is at the floor and - // the constraints are still satisfiable. - return isPrerelease(version); - } - - /** {@code version} cut to as many components as {@code sample} has. */ - private static String truncatedToSameDepth(String version, String sample) { - int depth = 1; - for (int i = 0; i < sample.length(); i++) { - if (sample.charAt(i) == '.') { - depth++; - } - } - StringBuilder out = new StringBuilder(); - int seen = 0; - for (int i = 0; i < version.length() && seen < depth; i++) { - char c = version.charAt(i); - if (c == '.') { - seen++; - if (seen >= depth) { - break; - } - } - out.append(c); - } - return out.toString(); - } - - /** - * The lowest version a selector can resolve to, as far as the text says. - * - *

Gradle accepts more than a literal here, and each shape was read as - * zero before: {@code [1.8.0]} is an exact range whose bracket stopped the - * numeric parse, {@code [1.7.0,1.9.0)} is a range whose LOW end is what - * matters for this question, and {@code 1.8.+} is a dynamic selector that - * cannot go below 1.8.0. Reading any of them as zero classified a - * merged-era declaration as pre-merge and dropped both constraints, - * including the sibling's -- which is the one such a graph still needs.

- */ - /** - * Whether this version is a prerelease of its own numeric version, as - * opposed to a dynamic selector. {@code 1.8.0-RC2} sorts below - * {@code 1.8.0}; {@code 1.8.+} does not. - */ - private static boolean isPrerelease(String version) { - for (int i = 0; i < version.length(); i++) { - char c = version.charAt(i); - if (c == '.' || Character.isDigit(c)) { - continue; - } - return c != '+'; - } - return false; - } - - /** Numeric dotted version compare; a missing segment counts as zero. */ - private static int compareVersions(String left, String right) { - String[] l = left.split("\\."); - String[] r = right.split("\\."); - int len = Math.min(l.length, r.length); - for (int i = 0; i < len; i++) { - int a = parseSegment(l[i]); - int b = parseSegment(r[i]); - if (a != b) { - return a < b ? -1 : 1; - } - } - // Equal as far as both go, so the SHORTER one is lower. Gradle orders - // `1.8` below `1.8.0`, and padding the missing segment with zero called - // them equal -- so a strict `[1.7,1.8]` looked like it admitted the floor - // when it stops just short of it, and the constraints went into a graph - // that cannot resolve them. - if (l.length != r.length) { - return l.length < r.length ? -1 : 1; - } - return 0; - } - - /** - * A version segment's leading digits. {@code 20-RC} is 20, not zero: - * reading it as zero made {@code 1.8.20-RC} compare equal to the 1.8.0 - * floor, and the qualifier rule then classified a version well ABOVE the - * floor as below it, suppressing an alignment that was needed. - */ - private static int parseSegment(String segment) { - int to = 0; - while (to < segment.length() && Character.isDigit(segment.charAt(to))) { - to++; - } - if (to == 0) { - return 0; - } - try { - return Integer.parseInt(segment.substring(0, to)); - } catch (NumberFormatException tooLong) { - return 0; - } - } - - /** - * Whether the app actively declares this {@code org.jetbrains.kotlin} - * artifact, rather than merely mentioning its name somewhere in a Gradle - * fragment. - * - *

The difference is the whole point, because both near misses produce - * the failure this class exists to prevent -- suppressing the constraint - * for an app that never pinned anything:

- * - *
-     * // implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')
-     * exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8'
-     * 
- * - *

The first is not a declaration at all. The second is the opposite of - * one: a Gradle exclusion applies only to the dependency edge it is - * written on, so an independent path can still bring the class-bearing jar - * it names. Neither may switch the alignment off.

- * - *

Two spellings count as a declaration -- the colon-joined coordinate - * and the map form -- because those are what a pin is actually written as. - * A declaration inside {@code if (project.hasProperty('x'))} counts as - * present, deliberately: whether it is in force is decided by Gradle at - * evaluation time and cannot be read out of the text. Treating it as - * present honours the documented promise at the cost of leaving a - * duplicate the app already had; the alternative -- suppressing only on a - * strict version, which is the one declaration a constraint cannot coexist - * with -- removes that hazard along with everything else in this method, - * and is a documented behaviour change rather than a bug fix, so it is a - * decision for the project rather than something to slip in under a review - * thread. - * Anything else falls through to "not declared", which is the safe - * direction: emitting a constraint the app did not need only raises an - * artifact to a shim, while skipping one it did need fails the build.

- */ - private static boolean declaresArtifact(String artifact, String configuration, - String[] appGradleFragments) { - String[] lines = activeLines(combined(appGradleFragments)); - { - for (int j = 0; j < lines.length; j++) { - if (declaresArtifactOnLine(artifact, configuration, lines[j])) { - return true; - } - } - } - return false; - } - - private static boolean declaresArtifactOnLine(String artifact, String configuration, - String line) { - // A strict version is honoured wherever it is declared, because a constraint - // cannot coexist with one on any classpath both reach: measured, an app - // strictly pinning jdk8 to 1.7.22 resolves fine on its own and fails outright - // with this block's constraint added -- - // Could not resolve org.jetbrains.kotlin:kotlin-stdlib-jdk8:{strictly 1.7.22} - // That is worse than the duplicate class, because the app cannot work around - // it, so a strict pin ends the question regardless of which configuration - // carries it. A strict version at or above the floor loses nothing by this: - // it is already a shim. - // - // Reviewed twice as too broad -- a strict pin on debugImplementation or - // compileOnly does not manage releaseRuntimeClasspath, so suppressing the - // whole artifact leaves the release graph unaligned. That is true, and it is - // still the better of the two outcomes, because the constraint this block - // writes is NOT release-scoped: it is declared on `implementation`, which - // every variant inherits. There is no version of "constrain release but not - // debug" available from one implementation constraint. So the choice for an - // app with a strict pre-1.8 pin on a non-release configuration is: - // suppress -- release keeps a duplicate it already had before this change - // emit -- debug stops resolving, which it did fine before this change - // The second breaks a build that works today, and this class has taken the - // first everywhere else it has had to choose. Scoping the constraint to - // `releaseImplementation` would satisfy both, and is deliberately not done: - // naming a variant configuration that a given build type set may not have - // fails the whole script at evaluation, which is a far larger blast radius - // than the case it fixes. Revisit only with a project that actually has this - // shape. - // - // Reviewed a third time with a sharper argument: a pin on a DETACHED - // configuration -- annotationProcessor, kapt, ksp -- genuinely cannot - // conflict, because unlike the variant configurations those do not extend - // implementation, so suppressing on one leaves the release runtime graph - // unaligned for nothing. The Gradle fact is right. What it asks for is not - // available here: acting on it means deciding from a configuration's NAME - // whether it shares a classpath with the one being constrained, and - // - Android synthesises a configuration per build type and flavour, so the - // names are open-ended: debugAnnotationProcessor, freeReleaseImplementation, - // and whatever the next plugin adds, - // - "does not extend implementation" is not the same question as "cannot - // conflict": compileOnly does not extend it either, yet compileClasspath - // extends both, so a strict pin there does conflict. - // A name list that gets this wrong is not wrong symmetrically. Classifying a - // conflicting configuration as detached emits the constraint beside a live - // strict pin, which is measured to fail resolution outright -- and for the - // `1.7.22!!` spelling to resolve quietly to the empty shims and throw - // NoClassDefFoundError on the device instead. Classifying a detached one as - // conflicting costs an app that had already pinned the family the duplicate - // it already had. So this stays until the classification can be read from - // something better than a name. - // A soft requirement is not management, in either direction. The - // constraint RAISES it -- `version { require '1.7.22' }` and a floor of - // 1.8.0 resolve to 1.8.0 with no conflict -- so treating one as a pin - // stood the block down for a shim this could have fixed, and treating it - // as a declaration skipped that shim's constraint and left it pre-merge - // beside a merged-era base. Both are the duplicate this exists to - // prevent, kept rather than removed. - // - // Only when nothing else holds the artifact: a strictly, a force, a - // rejection or the `!!` suffix on the requirement itself all pin, and a - // coordinate that carries its own version is the app's chosen version, - // whose measured behaviour is what the comment above describes. - if (heldOnlyBySoftRequirement(line, artifact)) { - return false; - } - if (!holdsStrictly(line, artifact) - && !declaresOnTheConstrainedConfiguration(configuration, line)) { - return false; - } - if (!holdsStrictly(line, artifact) && !bindsAVersion(line, artifact)) { - // A declaration that pins nothing cannot stand in for the constraint. The - // clearest case is a lone preference: our floor overrides it, so emitting - // is harmless, while suppressing leaves a transitive pre-merge shim free - // to win. A declaration with no version at all is the same argument. - // - // A STRICT pin is exempt, readable or not. `strictly kotlinVersion` takes - // its version from a property this cannot evaluate, and reading that as - // "binds nothing" emitted the constraint beside a pin that may well be - // pre-merge -- the one direction that fails at runtime rather than in the - // build. Unreadable falls back to the conservative answer here for the - // same reason it does in belowTheFloor. - return false; - } - // The same three spellings namesArtifactAnywhere reads, because there were - // two lists and they diverged: this one knew the coordinate and the - // group/name map, and not the bare name a resolution rule compares. So a - // `force` naming a shim by coordinate stood the block down while a - // `useVersion` holding the SAME shim at the same version did not -- and the - // constraints went in beside a rule that keeps jdk8 pre-merge, raising jdk7 - // to its empty 1.8.0 shim around it. The base library had a scan of its own - // and was never exposed to this, which is why it read as correct. - return namesArtifactAnywhere(line, artifact); - } - - /** - * Whether the statement calls Gradle's {@code strictly}, as opposed to - * merely containing the English word. - * - *

{@code because 'not strictly required outside debug'} is a reason - * string, not a version constraint, and reading it as one let a - * variant-only dependency switch the alignment off for the release build. - * The discriminator is the one already used for comment delimiters and - * statement separators: inside a string it is prose, outside it is - * syntax.

- */ - /** - * Whether the statement calls Gradle's {@code force}. - * - *

A force is as absolute as a strict pin and worse to get wrong. It - * does not conflict with a constraint, it silently wins: with - * {@code resolutionStrategy.force 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'} - * the base library stays pre-merge while these constraints raise the shims - * to their EMPTY 1.8.0 jars, so the jdk7/jdk8 classes end up in no selected - * jar at all. Nothing fails in the build; it throws on the device. So a - * forced version is read exactly like a strict one.

- */ - private static boolean callsForce(String statement, String artifact) { - // The method forms, which callsNamed now distinguishes from an assignment. - // Gradle's ways of overriding a selected version: force and its setter, a - // resolution rule's useVersion (a bare version) or useTarget (a whole - // coordinate), and a dependency substitution. All of them win silently over - // a constraint. - // - // An override is read as applying to any artifact the statement names, and - // NOT bound to the branch that names it. Reported as imprecise, correctly: a - // rule whose branches handle several Kotlin modules can override a sibling - // while merely mentioning this one, and the block then stands down for an - // artifact nobody managed. - // - // Binding the call to its predicate means modelling branches -- which - // condition governs which statement -- and this class deliberately has no - // such model. Everywhere a branch cannot be evaluated it resolves the - // ambiguity toward suppression, for the reason written on the conditional - // assignment path: emitting beside an override this could not see is the - // failure that reaches the device, while suppressing costs an app the - // duplicate it already had, which fails in checkDuplicateClasses where it - // already was. - // - // The trade is the same here and the asymmetry is sharper, because a branch - // model has far more spellings to get wrong than a version range -- and this - // rule's narrowings have needed correcting in three consecutive commits, each - // time for a spelling that looked handled. Revisit with a real project whose - // rule branches this way, and a way to test the branch reading that does not - // rest on the same reasoning that keeps being wrong. - // - // A substitution names TWO coordinates, which is why it was left out once: - // the version scan takes the first literal, and that is the side being - // REPLACED. The scan reads from after `using` now, so it takes the - // replacement -- which is also the only side that carries a version in the - // ordinary spelling, `substitute module('g:a') using module('g:a:1.7.22')`. - if (callsNamed(statement, "force") || callsNamed(statement, "setForcedModules") - || callsNamed(statement, USE_VERSION) - || callsNamed(statement, "useTarget")) { - return true; - } - if (callsNamed(statement, "substitute")) { - // A substitution overrides only what it substitutes AWAY from. With the - // artifact as the TARGET -- substitute module('com.example:source') - // using module('...:kotlin-stdlib:1.7.22') -- the replacement is still - // subject to ordinary conflict resolution, so an existing 1.8.22 - // requirement raises it and nothing is pinned; reading that as absolute - // suppressed the block for a graph that had not been pinned at all. - int using = afterCall(statement, "using"); - String replaced = using < 0 ? statement : statement.substring(0, using); - return namesArtifactAnywhere(replaced, artifact); - } - // forcedModules is only ever written as an assignment, and assigning it any - // module list is a force. `force` as a property is the one that has to be - // read: `{ force = false }` explicitly turns forcing OFF, and accepting any - // assignment after the word read that as an absolute pin -- suppressing the - // block for a declaration that was asking for nothing of the kind. - if (assignedValue(statement, "forcedModules") != null) { - return true; - } - return "true".equals(assignedValue(statement, "force")); - } - - /** - * The value assigned to {@code name}, or null if it is not assigned here. - * Read outside literals, like every other question about syntax. - */ - private static String assignedValue(String statement, String name) { - for (int i = 0; i < statement.length(); i++) { - if (isLiteralStart(statement, i)) { - i = endOfStringLiteral(statement, i); - continue; - } - if (!statement.startsWith(name, i)) { - continue; - } - boolean startsToken = i == 0 || !isIdentifierChar(statement.charAt(i - 1)); - int after = i + name.length(); - if (!startsToken || (after < statement.length() - && isIdentifierChar(statement.charAt(after)))) { - continue; - } - int at = skipBlanks(statement, after); - // += assigns too. forcedModules += ['...'] applies the force just as - // forcedModules = ['...'] does, and requiring the bare = missed it. - if (at + 1 < statement.length() && statement.charAt(at) == '+' - && statement.charAt(at + 1) == '=') { - at++; - } - if (at >= statement.length() || statement.charAt(at) != '=' - || (at + 1 < statement.length() && statement.charAt(at + 1) == '=')) { - continue; - } - int from = skipBlanks(statement, at + 1); - int to = from; - while (to < statement.length() && !isBlank(statement.charAt(to))) { - to++; - } - return statement.substring(from, to); - } - return null; - } - - private static boolean callsStrictly(String statement) { - return callsNamed(statement, STRICTLY); - } - - /** - * Whether {@code call} appears as a call, rather than inside a literal. - * - *

{@code assigned} also accepts the property form, {@code name = ...}, - * which only Gradle's forcedModules is written as. It is not offered to - * every caller because `def strictly = false` is not a strict pin.

- */ - /** Where {@code call}'s arguments begin, or -1 if it is not called here. */ - private static int afterCall(String statement, String call) { - for (int i = 0; i < statement.length(); i++) { - if (isLiteralStart(statement, i)) { - i = endOfStringLiteral(statement, i); - continue; - } - if (!statement.startsWith(call, i)) { - continue; - } - boolean startsToken = i == 0 || !isIdentifierChar(statement.charAt(i - 1)); - int after = i + call.length(); - if (startsToken && after < statement.length() - && (isBlank(statement.charAt(after)) - || statement.charAt(after) == '(')) { - return after; - } - } - return -1; - } - - private static boolean callsNamed(String statement, String call) { - for (int i = 0; i < statement.length(); i++) { - if (isLiteralStart(statement, i)) { - i = endOfStringLiteral(statement, i); - continue; - } - if (!statement.startsWith(call, i)) { - continue; - } - boolean startsToken = i == 0 || !isIdentifierChar(statement.charAt(i - 1)); - int after = i + call.length(); - if (!startsToken || after >= statement.length()) { - continue; - } - char next = statement.charAt(after); - if (next != '(' && !isBlank(next)) { - continue; - } - // `force = false` is a property being SET, not a call, and reading it as - // one turned an explicit "do not force" into an absolute pin. A call is - // what is left after excluding the assignment. - int assignment = skipBlanks(statement, after); - if (assignment < statement.length() && statement.charAt(assignment) == '=' - && (assignment + 1 >= statement.length() - || statement.charAt(assignment + 1) != '=')) { - continue; - } - return true; - } - return false; - } - - private static final String STRICTLY = "strictly"; - - /** - * Whether the statement carries the Groovy map entry - * {@code key: 'value'}, with whatever spacing the author used. - * - *

{@code name : 'kotlin-stdlib-jdk8'} is as valid as - * {@code name: 'kotlin-stdlib-jdk8'}, and matching the exact substring - * missed it -- which matters because the same declaration can carry a - * strict version, and missing it turns this class's constraint into a - * failed resolution.

- */ - private static boolean declaresMapEntry(String line, String key, String value) { - String found = mapEntryValue(line, key); - return found != null && found.equals(value); - } - - /** - * Whether this character can be part of a Groovy identifier. - * - *

Not {@code isLetterOrDigit}: an underscore is neither, so a - * configuration called {@code custom_implementation} ended its embedded - * {@code implementation} on a boundary that looked clean and was read as - * the main configuration -- suppressing a constraint for a configuration - * that reaches nothing.

- */ - /** - * Whether the character is whitespace that separates tokens. - * - *

Spelled out as space-or-tab in two places, which meant a fragment with - * Windows line endings put a carriage return after {@code strictly} and the - * call stopped being a call. Line endings are not this class's business to - * have an opinion about.

- */ - private static boolean isBlank(char c) { - return c == ' ' || c == '\t' || c == '\r' || c == '\n'; - } - - private static boolean isIdentifierChar(char c) { - return Character.isLetterOrDigit(c) || c == '_' || c == '$'; - } - - /** - * The index of the quote closing the literal that opens at - * {@code quoteAt}, or the text length when nothing closes it. - * - *

One implementation because there were several, and they drifted. Each - * scanner in this class had its own copy of "walk to the closing quote", - * some honouring backslash escapes and some not, and every divergence - * turned into a defect: a statement scanner that stopped at the apostrophe - * inside {@code 'can\'t'} merged statements that must stay apart, and a - * brace counter that did the same swallowed a declaration's closing brace. - * They call this now, so a fix reaches all of them.

- */ - /** - * The content of the literal opening at {@code quoteAt}, without its - * delimiters. - * - *

Stripping one character from each end is wrong for a triple-quoted - * literal, and every caller was doing exactly that: a coordinate written - * with the long delimiter came back still wearing two quotes at each end, - * so it had no readable version and the declaration was classified - * pre-merge -- taking the whole block with it.

- */ - private static String stringLiteralContent(String text, int quoteAt) { - int end = endOfStringLiteral(text, quoteAt); - int delimiter = delimiterLength(text, quoteAt); - int from = Math.min(quoteAt + delimiter, text.length()); - int to = Math.max(from, Math.min(end + 1 - delimiter, text.length())); - return text.substring(from, to); - } - - /** - * Whether a string literal opens at {@code at}, in any spelling Groovy has - * for one. - * - *

This question is asked in eleven places, and the answer used to be - * spelled out at each of them as "a quote is here". Every literal form - * added since arrived as a review comment against one of those eleven -- - * triple quotes, then dollar-slashy in the comment scanner, then - * dollar-slashy in the statement scanner, then dollar-slashy in the - * coordinate matcher -- because teaching one site never taught the rest. - * The form belongs here, once, where every scanner reads it.

- */ - private static boolean isLiteralStart(String text, int at) { - char c = text.charAt(at); - if (c == '\'' || c == '"') { - return true; - } - if (c == '$') { - return at + 1 < text.length() && text.charAt(at + 1) == '/'; - } - return c == '/' && opensASlashyLiteral(text, at); - } - - /** - * Whether a {@code /} at {@code at} opens a slashy literal rather than - * dividing or opening a comment. - * - *

Declined once, on the grounds that telling these apart needs to know - * whether an expression is expected here, which is parsing rather than - * scanning. That was raised again with a better argument: NOT recognizing - * the literal fails in the SAME direction as recognizing one that is not - * there -- an apostrophe inside {@code /can't/} puts the quote scanner out - * of step and hides whatever follows, exactly as swallowing a division - * would. Given both mistakes cost the same, the question is only which is - * likelier, and that is decidable: a literal can only open where an - * expression may begin. After an identifier, a number or a closing - * bracket -- which is every division a build script actually contains, - * {@code total / 2}, {@code (a + b) / 2} -- it is division. The two - * comment openers are excluded outright.

- */ - private static boolean opensASlashyLiteral(String text, int at) { - if (at + 1 < text.length() - && (text.charAt(at + 1) == '/' || text.charAt(at + 1) == '*')) { - return false; - } - int i = skipBlanksBackward(text, at - 1); - if (i < 0) { - return true; - } - // Asked the other way round, because asking it directly does not converge. - // "Which characters may an expression follow" was extended by review four - // times -- the closure arrow, the comparison, then Groovy's =~ and ==~ -- - // and each time the set was still missing whichever operator came next. - // Division is the closed half: it needs a VALUE on its left, and there are - // only so many things a value ends with. Everything else opens a literal, - // including every operator nobody has thought of yet. - char previous = text.charAt(i); - if (previous == ')' || previous == ']' || previous == '}') { - return false; - } - if (previous == '\'' || previous == '"') { - return false; - } - if (previous >= '0' && previous <= '9') { - return false; - } - if ((previous == '+' || previous == '-') && i > 0 - && text.charAt(i - 1) == previous) { - // a++ / b and a-- / b: the increment yields the value being divided. - return false; - } - if (!isIdentifierChar(previous)) { - return true; - } - // A word: a variable is a value and a keyword is not, which is the whole - // difference between `total / 2` and `return /can't/`. - int tokenEnd = i + 1; - while (i >= 0 && isIdentifierChar(text.charAt(i))) { - i--; - } - String token = text.substring(i + 1, tokenEnd); - return EXPRESSION_KEYWORDS.indexOf(" " + token + " ") >= 0; - } - - /** - * Groovy words after which an expression begins, so a slash is a literal - * rather than a division. Reserved words cannot be variables, which is why - * this can be read off the language rather than guessed at. - */ - private static final String EXPRESSION_KEYWORDS = - " return new in case else do while if throw assert yield instanceof "; - - /** The length of the delimiter opening at {@code at}. */ - private static int delimiterLength(String text, int quoteAt) { - char quote = text.charAt(quoteAt); - if (quote == '$') { - return 2; - } - if (quote == '/') { - return 1; - } - return quoteAt + 2 < text.length() - && text.charAt(quoteAt + 1) == quote - && text.charAt(quoteAt + 2) == quote ? 3 : 1; - } - - private static int endOfStringLiteral(String text, int quoteAt) { - char quote = text.charAt(quoteAt); - if (quote == '$') { - // $/ ... /$ -- the closer is two characters, and the content may hold - // anything at all, which is the point of the form. Almost anything: the - // dollar escapes itself and a slash, so $$ is a dollar and $/ is a - // slash. Searching for the first "/$" substring found the slash of an - // escaped $/ instead of the closer and ended the literal early, which - // put the scanner back into code halfway through a string. - for (int i = quoteAt + 2; i < text.length(); i++) { - char c = text.charAt(i); - if (c == '$' && i + 1 < text.length() - && (text.charAt(i + 1) == '$' || text.charAt(i + 1) == '/')) { - i++; - continue; - } - if (c == '/' && i + 1 < text.length() && text.charAt(i + 1) == '$') { - return i + 1; - } - } - return text.length(); - } - if (quote == '/') { - // Groovy's slashy literals MAY span lines, so the closing slash is looked - // for across them -- but only a literal that actually closes gets to. An - // opener this misread, with no closing slash anywhere, would otherwise - // swallow every statement after it, and a suppression reached that way is - // the outcome this class must never produce. So: close where it closes, - // and failing that, stop at the line it started on. - int firstNewline = -1; - for (int i = quoteAt + 1; i < text.length(); i++) { - char c = text.charAt(i); - if (c == '\\') { - i++; - } else if (c == '/') { - return i; - } else if (c == '\n' && firstNewline < 0) { - firstNewline = i; - } - } - return firstNewline < 0 ? text.length() : firstNewline - 1; - } - // Groovy's triple-quoted literals are a different delimiter, not three of - // this one. Treating the opener as a single quote made a triple-quoted note - // close on the first apostrophe it contains -- can't, in the case that found - // this -- and threw the rest of the fragment out of step, so a strict pin - // after it was never seen. - boolean tripled = quoteAt + 2 < text.length() - && text.charAt(quoteAt + 1) == quote - && text.charAt(quoteAt + 2) == quote; - if (tripled) { - for (int i = quoteAt + 3; i + 2 < text.length(); i++) { - char c = text.charAt(i); - if (c == '\\') { - i++; - } else if (c == quote && text.charAt(i + 1) == quote - && text.charAt(i + 2) == quote) { - return i + 2; - } - } - return text.length(); - } - for (int i = quoteAt + 1; i < text.length(); i++) { - char c = text.charAt(i); - if (c == '\\') { - i++; - } else if (c == quote) { - return i; - } - } - return text.length(); - } - - /** - * The nearest index at or before {@code from} that is not whitespace, or - * -1. The backward half of skipBlanks, and shared for the same reason: it - * had been written out four times, three of them stopping at a space or a - * tab, so a fragment with Windows line endings put a carriage return where - * one of them was looking and the token behind it stopped being found. - */ - private static int skipBlanksBackward(String text, int from) { - int i = from; - while (i >= 0 && isBlank(text.charAt(i))) { - i--; - } - return i; - } - - private static int skipBlanks(String line, int from) { - int i = from; - // isBlank, not a second opinion about what whitespace is. Spelled out as - // space-or-tab here while the call detector had already learned about line - // endings, so a CRLF fragment that split a map entry after its colon -- - // implementation(group: - // 'org.jetbrains.kotlin', ... - // -- found no value at all, and the strict pin in that declaration went - // unread. A statement can legitimately contain a newline; the splitter has - // already decided where statements end before anything gets here. - while (i < line.length() && isBlank(line.charAt(i))) { - i++; - } - return i; - } - - /** - * Whether a declaration on this line reaches the same configuration the - * constraints are written on. - * - *

A declaration on a variant or test configuration does not. - * {@code debugImplementation platform('...kotlin-bom:1.9.22')} constrains - * the debug variant alone, so treating it as the app managing the stdlib - * removes the constraint from the release build that still needs it -- - * and the release build is the one that ships.

- * - *

The variant forms camel-case the configuration they derive from, so - * requiring the configuration's own lowercase spelling as a whole token - * excludes {@code debugImplementation}, {@code releaseImplementation} and - * {@code testImplementation} without listing them, and cannot be defeated - * by a variant name nobody thought of. The other main-variant - * configurations are accepted alongside the one being written on; see - * {@link #MAIN_CONFIGURATIONS}.

- */ - private static boolean declaresOnTheConstrainedConfiguration(String configuration, - String line) { - if (declaresOn(configuration, line)) { - return true; - } - for (int i = 0; i < MAIN_CONFIGURATIONS.length; i++) { - if (declaresOn(MAIN_CONFIGURATIONS[i], line)) { - return true; - } - } - return false; - } - - /** - * The dependency configurations of the main variant, which is the one the - * constraints are written on. - * - *

Every one of these reaches the release RUNTIME classpath, which is the - * one {@code checkReleaseDuplicateClasses} reads and therefore the only one - * whose contents this class is trying to fix. {@code runtimeOnly} belongs - * here for exactly that reason.

- * - *

{@code compileOnly} does not, and putting it here was a mistake made - * by symmetry: a {@code compileOnly platform('...kotlin-bom')} is absent - * from the runtime graph, so treating it as the app managing that graph - * dropped the constraint from a classpath the app had not touched and left - * the duplicate in place. A compile-only declaration that would collide - * with the constraint is caught by the strict-version rule below instead, - * which is where that concern actually belongs.

- * - *

Their variant and test forms camel-case the configuration they derive - * from -- {@code testRuntimeOnly}, {@code debugCompileOnly}, - * {@code releaseApi} -- so matching the lowercase spelling as a whole token - * accepts the main ones and excludes the rest without listing any of them, - * whatever a variant happens to be called.

- */ - private static final String[] MAIN_CONFIGURATIONS = { - "implementation", - "api", - "runtimeOnly", - "compile", - "runtime" - }; - - /** - * Whether a resolution strategy in this statement governs a configuration - * that receives the emitted constraint. - * - *

It does unless the statement names one particular configuration that - * is not among the constrained ones. Anything that leaves the selection to - * a closure, and anything that does not go through {@code configurations} - * at all, is assumed to reach: being wrong that way costs an app the - * duplicate it already had, while being wrong the other way emits a - * constraint into a graph whose strategy fails the build on it.

- */ - private static boolean governsTheConstrainedGraph(String line, String configuration) { - String named = configurationNamedIn(line); - // No single configuration named, so which ones are meant is decided by a - // closure this cannot evaluate -- `configurations.all { }`, and equally - // `configurations.matching { it.name == 'releaseRuntimeClasspath' }.all`, - // which really does select the graph being constrained. Reading a filter - // as "some other configuration" put the constraints into a graph whose - // strategy fails the build on the version they raise. - return named == null || named.equals(configuration) - || isAMainConfiguration(named) - // A configuration the app made can still INHERIT the constraint: - // `configurations.create('tooling').extendsFrom(configurations - // .implementation)` is not an independent graph, and reading only - // the name it was created under exempted it -- so the constraints - // went into a graph that then failed on the version they raise. - || extendsAConstrainedConfiguration(line, configuration); - } - - /** - * Whether the statement makes its configuration extend one the constraint is - * written on. - * - *

Only what follows {@code extendsFrom} is read, because that is the one - * API for inheritance and the parent is its argument. A configuration - * extending something else -- {@code compileOnly}, say -- does not receive - * the constraint, and answering otherwise would exempt nothing at all.

- */ - private static boolean extendsAConstrainedConfiguration(String line, - String configuration) { - int at = afterCall(line, "extendsFrom"); - if (at < 0) { - return false; - } - for (int i = at; i < line.length(); i++) { - if (isLiteralStart(line, i)) { - int end = endOfStringLiteral(line, i); - String held = stringLiteralContent(line, i); - if (held.equals(configuration) || isAMainConfiguration(held)) { - return true; - } - i = end; - continue; - } - if (!isIdentifierChar(line.charAt(i)) - || (i > at && isIdentifierChar(line.charAt(i - 1)))) { - continue; - } - int end = i; - while (end < line.length() && isIdentifierChar(line.charAt(end))) { - end++; - } - String token = line.substring(i, end); - if (token.equals(configuration) || isAMainConfiguration(token)) { - return true; - } - i = end - 1; - } - return false; - } - - /** Whether the name is one of the configurations the constraint is on. */ - private static boolean isAMainConfiguration(String name) { - for (int i = 0; i < MAIN_CONFIGURATIONS.length; i++) { - if (MAIN_CONFIGURATIONS[i].equals(name)) { - return true; - } - } - // And the resolvable classpaths, which EXTEND those and are where a - // strategy actually runs: a failOnVersionConflict on - // `configurations.releaseRuntimeClasspath` governs the graph these - // constraints are resolved in, and reading it as some other configuration - // put them into a graph that then failed on the version they raise. - // - // By suffix rather than by name, because Gradle synthesises one per - // variant -- releaseRuntimeClasspath, debugCompileClasspath, and whatever - // a flavour adds -- so no list of them can be complete. A configuration - // the app named that way and did not wire up costs a suppression, which - // is the direction this class takes everywhere. - String lower = name.toLowerCase(); - return lower.endsWith("runtimeclasspath") || lower.endsWith("compileclasspath"); - } - - /** The container, as a token: what follows it says which configuration. */ - private static final String CONFIGURATIONS = "configurations"; - - /** Whether this line declares on {@code configuration}, as a whole token. */ - private static boolean declaresOn(String configuration, String line) { - for (int i = 0; i < line.length(); i++) { - char c = line.charAt(i); - if (isLiteralStart(line, i)) { - // The shared rule rather than a third hand-rolled quote scanner. This - // one tracked a single delimiter character, so a triple-quoted name - // was read as an empty string followed by unquoted text -- the same - // defect that was live in the map-value and interpolation paths. - int end = endOfStringLiteral(line, i); - // A configuration name inside a string counts in one place only: - // as the first argument of dependencies.add("runtimeOnly", ".."). - // Accepting any quoted occurrence read the word in a reason -- - // because 'implementation workaround' -- as a main-variant - // declaration, which suppressed the constraint for a dependency - // that only affects debug. - if (end < line.length() - && stringLiteralContent(line, i).equals(configuration) - && isAddCallArgument(line, i)) { - return true; - } - i = end; - continue; - } - if (line.startsWith(configuration, i)) { - boolean startsToken = i == 0 - || !isIdentifierChar(line.charAt(i - 1)); - int after = i + configuration.length(); - boolean endsToken = after < line.length() - && (isBlank(line.charAt(after)) || line.charAt(after) == '('); - if (startsToken && endsToken) { - return true; - } - } - } - return false; - } - - /** - * Whether the string literal opening at {@code quoteAt} is the - * configuration argument of an {@code add} call. - * - *

Both spellings count. Groovy's command syntax drops the parentheses -- - * {@code add 'implementation', 'group:artifact:version'} is as valid as - * {@code add("implementation", "...")} -- and requiring the parenthesis - * rejected a declaration that was carrying an explicit strict pin.

- */ - private static boolean isAddCallArgument(String line, int quoteAt) { - int i = skipBlanksBackward(line, quoteAt - 1); - // Every parenthesis, for the reason isDeclarationArgument gives. - while (i >= 0 && line.charAt(i) == '(') { - i = skipBlanksBackward(line, i - 1); - } - if (i < 0 || !isIdentifierChar(line.charAt(i))) { - return false; - } - int nameEnd = i; - while (i >= 0 && isIdentifierChar(line.charAt(i))) { - i--; - } - String method = line.substring(i + 1, nameEnd + 1); - // The receiver decides when there is one, and the name when there is not. - // - // `add` was the only name accepted, so Gradle's provider form -- - // `dependencies.addProvider('implementation', ..)` -- was not read as a - // declaration at all. The handler has three adders and may grow more, so - // a call ON the handler is taken whatever it is called; an unqualified - // one, which is the shorthand inside a dependencies closure, still has to - // look like an adder, because `catalog.add(..)` adds to a version catalog - // and `myList.add(..)` to a list, and neither declares anything. - int dot = skipBlanksBackward(line, i); - if (dot < 0 || line.charAt(dot) != '.') { - return isADependencyHandlerAdder(method); - } - int end = skipBlanksBackward(line, dot - 1); - if (end < 0) { - return false; - } - int start = end; - while (start >= 0 && (isIdentifierChar(line.charAt(start)) - || (line.charAt(start) == '.' && start > 0 - && isIdentifierChar(line.charAt(start - 1))))) { - start--; - } - return end > start - && lastSegmentIs(line.substring(start + 1, end + 1), "dependencies"); - } - - /** - * A fragment's lines with comments removed and exclusions dropped -- the - * text that actually declares something. - * - *

Comment delimiters are only delimiters outside a string, which is the - * same rule the statement scanner already applied to parentheses and - * semicolons and which this had been missing. It matters in both - * directions: {@code maven { url 'https://...' }} is an ordinary - * declaration that a naive strip cuts in half, and a {@code /*} inside a - * string used to open a block comment that swallowed the rest of the - * fragment -- including, in the case that found this, an explicit strict - * pin whose loss turns this class's constraint into a failed resolution. - * Tracking quotes covers both, and replaces the narrower rule that only - * spared a {@code //} following a colon.

- * - *

Regrouping into statements is {@link #statements}; this method only - * removes the comments.

- */ - private static String[] activeLines(String fragment) { - if (fragment == null) { - return new String[0]; - } - StringBuilder out = new StringBuilder(); - boolean inBlockComment = false; - for (int i = 0; i < fragment.length(); i++) { - char c = fragment.charAt(i); - if (inBlockComment) { - if (c == '*' && i + 1 < fragment.length() && fragment.charAt(i + 1) == '/') { - inBlockComment = false; - i++; - } else if (c == '\n') { - out.append(c); - } - continue; - } - if (isLiteralStart(fragment, i)) { - // The shared rule, so triple-quoted literals and escapes are the - // same thing here as everywhere else. This scanner and the statement - // scanner below kept their own copies through the consolidation, and - // the triple-quote fix reached neither until now. - int end = endOfStringLiteral(fragment, i); - out.append(fragment, i, Math.min(end + 1, fragment.length())); - i = end; - continue; - } - if (c == '/' && i + 1 < fragment.length()) { - char next = fragment.charAt(i + 1); - if (next == '*') { - inBlockComment = true; - i++; - // A comment IS whitespace in the language, so removing one - // without leaving any joined the tokens it separated: - // `strictly/* pin */'1.7.22'` became strictly'1.7.22', which is - // not a call to strictly, so the strict pin behind it was never - // seen. Groovy accepts the original and records {strictly 1.7.22}. - out.append(' '); - continue; - } - if (next == '/') { - // Either terminator. Groovy ends a line at a bare carriage - // return too, and searching only for the newline swallowed the - // whole remainder of a CR-only fragment as part of the comment - // -- including, in the case that found this, a strict pin. - while (i < fragment.length() && fragment.charAt(i) != '\n' - && fragment.charAt(i) != '\r') { - i++; - } - out.append('\n'); - continue; - } - } - out.append(c); - } - return statements(out.toString()); - } - - /** - * Physical lines regrouped into the statements a declaration check can - * actually read. - * - *

Two things a per-physical-line check gets wrong, both of which end - * with an app's explicit pin ignored and the constraint written over the - * top of it -- the opposite of what naming the artifact in a build hint - * is documented to do:

- * - *
-     * implementation(                                  configuration and coordinate
-     *     'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'    land on different lines
-     * )
-     *
-     * implementation('...kotlin-stdlib-jdk8:1.7.22') { exclude group: 'x' }
-     *                                                  a real pin, dropped whole
-     *                                                  for containing "exclude"
-     * 
- * - *

So a line whose parentheses are still open is joined to the next. - * Exclusions are left alone: they used to be cut out here, which was - * needed while a declaration was recognised by the artifact name appearing - * anywhere, and became both unnecessary and harmful once a declaration had - * to be spelled as one. Unnecessary, because an exclusion writes - * {@code group: '...', module: 'kotlin-stdlib-jdk8'} and never the - * colon-joined coordinate or the {@code name:} map form the declaration - * check looks for, so it cannot match one. Harmful, because cutting from - * {@code exclude} to the end of the statement also threw away anything - * after it -- an exclusion written before a - * {@code version { strictly '1.7.22' } }} block took that block with it, - * and losing the strict marker is what turns this class's constraint into - * a failed resolution.

- * - *

A statement ends at a newline or at a semicolon, whichever comes - * first, and neither ends one inside parentheses or inside a string. The - * semicolon is not a nicety: this builder tells developers to separate - * {@code android.gradleDep} statements "with ';' or a newline", so a hint - * holding two declarations on one line is the documented shape. Splitting - * on newlines alone let the configuration token of the first statement pair - * with the coordinate of the second, which reads - * {@code implementation 'x'; debugImplementation platform('...kotlin-bom')} - * as a main-variant BOM and suppresses everything.

- * - *

Joining stops at the end of the fragment: text left with parentheses - * open is unbalanced Gradle, and rather than glue the remainder into one - * long line -- which would make unrelated statements look like a single - * declaration, and suppression is the direction that must never be reached - * by accident -- its lines are kept as they were.

- */ - private static String[] statements(String text) { - List out = new ArrayList(); - StringBuilder current = new StringBuilder(); - int depth = 0; - for (int i = 0; i < text.length(); i++) { - char c = text.charAt(i); - if (isLiteralStart(text, i)) { - // The shared rule: escapes and triple quotes handled in one place. - // A literal that closed early here merged statements that must stay - // apart, which lets one statement's configuration pair with another - // statement's coordinate. - int end = endOfStringLiteral(text, i); - current.append(text, i, Math.min(end + 1, text.length())); - i = end; - continue; - } - // Brackets hold a statement together exactly as parentheses do. A force - // is written across lines as - // resolutionStrategy.forcedModules = [ - // 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' - // ] - // and splitting there left the assignment in one statement and its - // coordinate in another, so neither said anything and the force went - // unread. Counted together because the question is only ever "is this - // newline inside something", not which kind of something. - if (c == '(' || c == '[') { - depth++; - } else if (c == ')' || c == ']') { - if (depth > 0) { - depth--; - } - } else if ((c == '\n' || c == '\r' || c == ';') && depth == 0) { - // A bare carriage return ends a line in Groovy exactly as a newline - // does. Recognising only the newline merged every statement of a - // CR-only fragment into one, so a main-variant configuration paired - // with a debug-only coordinate and that artifact read as declared. - // CRLF is one break, not two: the newline behind a return is eaten - // here rather than splitting again on an already-empty statement. - if (c == '\r' && i + 1 < text.length() && text.charAt(i + 1) == '\n') { - i++; - } - // A trailing comma continues the statement. Groovy's parenthesis-free - // map notation spreads one declaration over several lines -- - // implementation group: 'org.jetbrains.kotlin', - // name: 'kotlin-stdlib-jdk8', - // version: '1.7.22' - // -- and splitting there left the configuration, the group, the - // artifact and any closure in four statements, none of which is a - // declaration on its own. - if (c != ';' && endsWithComma(current)) { - current.append(' '); - continue; - } - // Groovy's explicit line continuation. `implementation \` with the - // coordinate on the next line was split into a configuration with - // no dependency and a coordinate with no configuration, so neither - // said anything and the strict pin between them went unread. - if (c != ';' && endsWithLineContinuation(current)) { - current.setLength(current.length() - 1); - current.append(' '); - continue; - } - if (c != ';' && opensAnUnbracedBody(current.toString())) { - // An `if (...)` with no brace takes the next line as its body, so - // splitting there put the condition in one statement and the body - // in another -- and a resolution rule written that way had the - // artifact named in the condition and the useVersion in the body, - // so neither statement said anything and the override went unread. - current.append(' '); - continue; - } - out.add(current.toString().replace('\n', ' ').replace('\r', ' ')); - current.setLength(0); - continue; - } - current.append(c); - } - if (current.length() > 0) { - if (depth > 0) { - // Unbalanced: keep the tail's physical lines apart rather than as one - // statement. Gluing them would let a configuration from one and a - // coordinate from another read as a single declaration, and - // suppression is the direction that must never be reached by accident. - String[] dangling = current.toString().split("\n"); - for (int i = 0; i < dangling.length; i++) { - out.add(dangling[i]); - } - } else { - out.add(current.toString().replace('\n', ' ').replace('\r', ' ')); - } - } - // Definitions are folded in FIRST, because the merge below only absorbs a - // closure into a statement that already names the Kotlin group -- and a - // statement referring to the coordinate through a variable does not name it - // until the fold has happened. Merging first left `implementation(stdlib) {` - // unmerged, so its `strictly` was never associated with the coordinate. - List defined = inlineLiteralDefinitions(out); - - // A declaration's own configuration block belongs to it: the version that - // decides this is written as `version { strictly '1.7.22' }` on the line after - // the coordinate. Only a statement that already names the Kotlin group absorbs - // its block, so a `dependencies {` or `android {` opening cannot swallow the - // fragment -- the blast radius is one declaration, never the file. - List merged = new ArrayList(); - for (int i = 0; i < defined.size(); i++) { - String statement = defined.get(i); - if (statement.contains(KOTLIN_GROUP) || namesAnAlignedArtifact(statement)) { - // A trailing closure may sit on the line AFTER the call's closing - // parenthesis -- Gradle accepts it and the strictly inside really does - // apply, checked by watching a competing higher requirement fail - // against it. The parenthesis depth is already back to zero there, so - // without this the closure lands in its own statement and the version - // it carries is never associated with the coordinate above it. - // Past anything blank in between. A comment-only line leaves an empty - // statement behind it, and looking only at the very next one left the - // closure -- and the strict version inside it -- attached to nothing. - int next = i + 1; - while (next < defined.size() && defined.get(next).trim().length() == 0) { - next++; - } - while (next < defined.size() && opensAClosure(defined.get(next))) { - while (i < next) { - i++; - } - statement = statement + " " + defined.get(i); - next = i + 1; - while (next < defined.size() - && defined.get(next).trim().length() == 0) { - next++; - } - } - int braces = trailingBraceBalance(statement); - while (braces > 0 && i + 1 < defined.size()) { - i++; - statement = statement + " " + defined.get(i); - braces += braceBalance(defined.get(i)); - } - // An `else` is the same statement as the `if` before it, and the - // condition that names the family is on the `if`. Left apart, the - // if (d.requested.name == 'kotlin-stdlib') - // d.useVersion '1.9.22' - // else - // d.useVersion '1.7.22' - // rule offered only its first branch, so the version that decides - // suppression was in a statement that named nothing. Adjacency, not - // a reading of which branch runs: joined, the statement holds both - // versions and the last one wins, which is the conservative answer - // this class takes wherever it cannot evaluate a condition. - while (i + 1 < defined.size() && continuesWithElse(defined.get(i + 1))) { - i++; - statement = statement + " " + defined.get(i); - int reopened = trailingBraceBalance(statement); - while (reopened > 0 && i + 1 < defined.size()) { - i++; - statement = statement + " " + defined.get(i); - reopened += braceBalance(defined.get(i)); - } - } - } - merged.add(statement); - } - return merged.toArray(new String[merged.size()]); - } - - /** - * Statements with {@code def name = 'literal'} definitions folded into the - * places that use them. - * - *

A coordinate can sit one hop away:

- * - *
-     * def jdk8 = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'
-     * implementation(jdk8) { version { strictly '1.7.22' } }
-     * 
- * - *

Neither statement carries both the configuration and the coordinate, - * so the strict pin was invisible and the constraint made the build stop - * resolving. One hop through a string literal is recoverable from the text - * and is recovered here.

- * - *

Where this stops, deliberately. A value built by - * interpolation, by concatenation, from a map or a list, or returned by a - * method is not in the text at all -- reading it needs Gradle to evaluate - * the script, which nothing here can do. Those forms are left unrecognised - * rather than guessed at, and that is a real limit of reading declarations - * out of build-hint text rather than something another pass would fix. The - * design that does not need to find the declaration at all -- constraining - * unless a strict version says otherwise -- is the answer to that class, - * and it is a decision about documented behaviour rather than a defect to - * patch here.

- */ - private static List inlineLiteralDefinitions(List statements) { - // In statement order, and the definition is recorded AFTER its own statement - // has been rewritten. A two-pass version substituted a variable's first value - // into every use of it, including uses after a reassignment -- so - // def dep = '...kotlin-stdlib-jdk8:1.9.22'; debugImplementation(dep) - // dep = 'com.example:other:1.0'; implementation(dep) - // made the LAST statement read as a main-variant Kotlin declaration. - Map literals = new LinkedHashMap(); - List out = new ArrayList(); - // Gradle's extra properties are written both ways -- ext.kotlinVersion = '..' - // and ext { kotlinVersion = '..' } -- and the closure form is at least as - // common. Inside it a bare assignment really does bind a project-wide name, - // which is exactly what the interpolation reads, so it is a definition there - // and nowhere else: a bare `version = '1.0'` in an android block binds - // nothing this can follow, and reading it as a definition would supply a - // version to an unrelated $version. - int extDepth = 0; - // Whether a nested block runs is decided at evaluation time and cannot be - // read here, so a name assigned inside one may hold either value. The - // ambiguity is resolved toward suppression: emitting beside a pin this could - // not see is the failure that reaches the device, while suppressing costs an - // app the duplicate it already had. - // - // ANY open brace, not a list of the constructs that open one. That list was - // if/else/while/for/switch/try/catch and it was already missing the closure - // -- `def mutate = { dep = ... }` runs only if something calls it. Depth is - // the closed half of the question: at the top level a statement runs, and - // inside anything at all this cannot say. Costless to be wrong about, too, - // since the flag only refuses to DISCARD a coordinate; a first definition is - // still recorded at any depth, which is why a `def` inside dependencies { } - // keeps working. - int braceDepth = 0; - ScopedNames scope = new ScopedNames(); - // A buildscript block configures the PLUGIN classpath, which is a separate - // resolution from the app's and cannot conflict with what this writes into - // dependencies { }. A force, a strict pin or a shim declaration in there was - // being read as the app managing the family, so an app graph carrying a - // pre-merge shim was left unaligned and still failed checkDuplicateClasses. - // - // The statements are blanked rather than dropped, and only AFTER their - // definitions have been recorded: `buildscript { ext.kotlin_version = .. }` - // followed by a dependency interpolating $kotlin_version is the ordinary - // shape of a Kotlin project, and the definition really does bind - // script-wide even though the declarations around it do not. - int buildscriptDepth = 0; - for (int i = 0; i < statements.size(); i++) { - String statement = statements.get(i); - boolean opensBuildscript = buildscriptDepth == 0 - && opensAForeignScope(statement); - boolean pluginScoped = buildscriptDepth > 0 || opensBuildscript - || namesTheBuildscriptClasspath(statement); - out.add(pluginScoped ? "" : (literals.isEmpty() - ? statement - : withLiteralsInlined(statement, literals))); - if (pluginScoped) { - buildscriptDepth += braceBalance(statement); - if (buildscriptDepth < 0) { - buildscriptDepth = 0; - } - } - boolean opensExt = extDepth == 0 && opensAnExtraPropertiesBlock(statement); - updateLiteralDefinitions(statement, literals, extDepth > 0 || opensExt, - braceDepth > 0, braceDepth, scope); - if (extDepth > 0 || opensExt) { - extDepth += braceBalance(statement); - if (extDepth < 0) { - extDepth = 0; - } - } - braceDepth += braceBalance(statement); - if (braceDepth < 0) { - braceDepth = 0; - } - scope.leaving(braceDepth, literals); - } - return out; - } - - /** - * Applies this statement's effect on the known definitions: a - * {@code def name = 'literal'}, a reassignment of one already known, or a - * reassignment to something unreadable, which forgets it rather than - * leaving a stale value behind. - */ - /** - * The index of the {@code ]} closing the bracket at {@code from}, or -1. - * Nested brackets and literals are skipped, so a map inside a list closes - * where it really closes. - */ - private static int closingBracket(String text, int from) { - int depth = 0; - for (int i = from; i < text.length(); i++) { - if (isLiteralStart(text, i)) { - i = endOfStringLiteral(text, i); - continue; - } - char c = text.charAt(i); - if (c == '[') { - depth++; - } else if (c == ']') { - depth--; - if (depth == 0) { - return i; - } - } - } - return -1; - } - - /** - * Records a Groovy multiple assignment, and says whether the statement was - * one. - * - *

{@code def (other, dep) = ['com.example:x:1.0', 'g:a:1.7.22!!']} binds - * both names positionally. The walk for a single declaration expects an - * identifier after {@code def} and finds a parenthesis, so it recorded - * nothing at all and the pin the second name carried went unread.

- * - *

Each name may carry a type, as a single declaration may, so the NAME is - * the last identifier of its element. An element whose value is neither a - * literal nor a known name records nothing, which leaves it unknown rather - * than wrong.

- */ - private static boolean recordsADestructuring(String statement, int at, - Map literals, boolean conditional, ScopedNames scope, - int depth) { - int i = skipBlanks(statement, at); - if (i >= statement.length() || statement.charAt(i) != '(') { - return false; - } - List names = new ArrayList(); - i++; - while (i < statement.length() && statement.charAt(i) != ')') { - i = skipBlanks(statement, i); - String last = null; - while (i < statement.length() && isIdentifierChar(statement.charAt(i))) { - int start = i; - while (i < statement.length() && isIdentifierChar(statement.charAt(i))) { - i++; - } - last = statement.substring(start, i); - i = skipBlanks(statement, i); - } - names.add(last); - if (i < statement.length() && statement.charAt(i) == ',') { - i++; - } else { - break; - } - } - if (i >= statement.length() || statement.charAt(i) != ')' || names.isEmpty()) { - return false; - } - i = skipBlanks(statement, i + 1); - if (i >= statement.length() || !isAssignmentAt(statement, i)) { - return false; - } - i = skipBlanks(statement, i + 1); - if (i >= statement.length() || statement.charAt(i) != '[') { - // A list this cannot read binds every name to something unknown, - // which is what recording nothing already means. - return true; - } - int closes = closingBracket(statement, i); - i++; - for (int n = 0; n < names.size() && i < statement.length() - && (closes < 0 || i < closes); n++) { - i = skipBlanks(statement, i); - String value = null; - if (isLiteralStart(statement, i)) { - int end = endOfStringLiteral(statement, i); - if (end < statement.length()) { - value = expandedLiteral(statement, i, end, literals); - i = end + 1; - } - } else { - int start = i; - while (i < statement.length() && isIdentifierChar(statement.charAt(i))) { - i++; - } - if (i > start) { - value = literals.get(statement.substring(start, i)); - } - } - if (names.get(n) != null && value != null) { - // Registered with the scope first, exactly as a single - // declaration is. Written straight into the map, a name declared - // this way inside a block outlived it -- so an inner - // `def (dep, x) = [..]` shadowed an extra property for the rest - // of the file and its coordinate was inlined into a later - // declaration that has nothing to do with it. - scope.declared(depthAt(statement, at, depth), names.get(n), literals); - recordDefinition(literals, names.get(n), value, conditional); - } - i = skipBlanks(statement, i); - if (i < statement.length() && statement.charAt(i) == ',') { - i++; - } - } - return true; - } - - /** The version a coordinate carries, or null when it has none. */ - private static String coordinateVersion(String coordinate) { - int group = coordinate.indexOf(':'); - if (group < 0) { - return null; - } - int artifact = coordinate.indexOf(':', group + 1); - if (artifact < 0 || artifact + 1 >= coordinate.length()) { - return null; - } - return versionComponentOf(coordinate.substring(artifact + 1)); - } - - /** The artifact of a {@code group:artifact[:version]} coordinate. */ - private static String artifactOf(String coordinate) { - int group = coordinate.indexOf(':'); - if (group < 0) { - return ""; - } - int end = coordinate.indexOf(':', group + 1); - return end < 0 ? coordinate.substring(group + 1) - : coordinate.substring(group + 1, end); - } - - /** Whether the name is the base library or one of its shims. */ - private static boolean isOneOfTheFamily(String artifact) { - if (BASE_STDLIB.equals(artifact)) { - return true; - } - for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { - if (ALIGNED_ARTIFACTS[i].equals(artifact)) { - return true; - } - } - return false; - } - - /** - * Whether the statement mentions this group at all, in any of the shapes a - * selection rule names a module by. - * - *

{@code withModule('org.jetbrains.kotlin:kotlin-stdlib-jdk8')} names it - * with a whole coordinate, which is neither the bare artifact name nor the - * group on its own -- so a rule written that way looked like it concerned - * nothing of ours and the rejected version was written anyway.

- * - *

The ARTIFACT in such a coordinate has to be one of ours. Matching the - * group prefix alone read a rule on {@code kotlin-reflect} as one on this - * family, and the block stood down for a rejection that cannot touch either - * shim -- which leaves the duplicate exactly where it was. A rule keyed on - * the group with no artifact at all still counts, because it covers them.

- */ - private static boolean mentionsTheKotlinGroup(String line) { - if (namesOneOfTheFamily(line) || holdsLiteral(line, KOTLIN_GROUP)) { - return true; - } - for (int i = 0; i < line.length(); i++) { - if (!isLiteralStart(line, i)) { - continue; - } - int end = endOfStringLiteral(line, i); - String held = stringLiteralContent(line, i); - if (held.startsWith(KOTLIN_GROUP + ":") - && isOneOfTheFamily(artifactOf(held))) { - return true; - } - i = end; - } - return false; - } - - /** - * Records a definition, or forgets it, unless doing so under a condition - * would throw away the value that decides suppression. - */ - private static void recordDefinition(Map literals, String name, - String value, boolean conditional) { - if (conditional) { - String known = literals.get(name); - if (known != null && known.indexOf(KOTLIN_GROUP) >= 0 - && (value == null || value.indexOf(KOTLIN_GROUP) < 0)) { - return; - } - // Kotlin for Kotlin is a choice between two of ours, and which arm - // runs is not readable here. `def dep = '..jdk8:1.7.22'` then - // `if (useNew) dep = '..jdk8:1.9.22'` took the merged-era one, so the - // declaration below read as needing no constraint -- and with the - // condition false the class-bearing 1.7.22 jar is still there. The - // lower version is kept, for the reason every unevaluable branch gets - // the conservative answer. - if (known != null && value != null - && known.indexOf(KOTLIN_GROUP) >= 0 - && value.indexOf(KOTLIN_GROUP) >= 0) { - String held = coordinateVersion(known); - String offered = coordinateVersion(value); - if (held != null && offered != null - && compareVersions(withoutStrictSuffix(held), - withoutStrictSuffix(offered)) < 0) { - return; - } - } - } - if (value == null) { - literals.remove(name); - } else { - literals.put(name, value); - } - } - - /** - * Whether the statement opens a Gradle {@code ext { }} block, as a whole - * token so that a dependency on {@code com.example:extras} does not. - */ - private static boolean opensAnExtraPropertiesBlock(String statement) { - return opensBlockNamed(statement, EXTRA_PROPERTIES); - } - - /** Whether the statement opens a block named {@code name}. */ - private static boolean opensBlockNamed(String statement, String name) { - // Outside literals, for the same reason the classpath check is: a block - // opener quoted in a reason opens nothing, and treating one as the real - // thing puts every statement after it in a scope it is not in. - for (int at = 0; at < statement.length(); at++) { - if (isLiteralStart(statement, at)) { - at = endOfStringLiteral(statement, at); - continue; - } - if (!statement.startsWith(name, at)) { - continue; - } - int after = at + name.length(); - boolean startsToken = at == 0 || !isIdentifierChar(statement.charAt(at - 1)); - int brace = skipBlanks(statement, after); - // Groovy takes a trailing closure with or without the parentheses, and - // `componentSelection({ rules -> .. })` is the same call as - // `componentSelection { .. }` -- requiring the brace to follow the name - // missed the parenthesised form before anything could read its body. - if (brace < statement.length() && statement.charAt(brace) == '(') { - brace = skipBlanks(statement, brace + 1); - } - if (startsToken && (after >= statement.length() - || !isIdentifierChar(statement.charAt(after))) - && brace < statement.length() && statement.charAt(brace) == '{') { - return true; - } - } - return false; - } - - /** - * The depth at {@code index}, counting braces opened earlier in this - * statement. - * - *

Depth is tracked between statements, so a closure opened and a local - * declared on the SAME line looked like top level: - * {@code ext.helper = { def dep = '...' }} recorded dep as if it belonged - * to the script, and it then outlived the closure and shadowed the real - * binding for everything after.

- */ - private static int depthAt(String statement, int index, int base) { - return base + braceBalance(statement.substring(0, Math.min(index, statement.length()))); - } - - /** - * The names a scope introduced, so they can be taken back when it closes. - * - *

A `def` inside a closure or a method is local to it, and a single flat - * map kept that value after the scope ended -- so an unrelated later - * `implementation(dep)` was read as a declaration of whatever the nested - * one held, and the constraint for that artifact was skipped as already - * satisfied. Only DECLARATIONS are taken back: a bare assignment inside a - * block updates the binding it found, which is why - * `if (legacy) { dep = '...' }` still reaches the statements after it.

- */ - private static final class ScopedNames { - private final List introduced = new ArrayList(); - - void declared(int depth, String name, Map literals) { - if (depth <= 0) { - return; - } - introduced.add(new Object[] { - Integer.valueOf(depth), name, - literals.containsKey(name) ? Boolean.TRUE : Boolean.FALSE, - literals.get(name) - }); - } - - void leaving(int depth, Map literals) { - for (int i = introduced.size() - 1; i >= 0; i--) { - Object[] entry = introduced.get(i); - if (((Integer) entry[0]).intValue() <= depth) { - break; - } - introduced.remove(i); - String name = (String) entry[1]; - if (Boolean.TRUE.equals(entry[2])) { - literals.put(name, (String) entry[3]); - } else { - literals.remove(name); - } - } - } - } - - private static void updateLiteralDefinitions(String statement, - Map literals, boolean insideExtraProperties, - boolean conditional, int depth, ScopedNames scope) { - if (insideExtraProperties) { - // The assignment may share the line with the brace that opened the block, - // as `ext { kotlinVersion = '1.9.22' }` does, so read from after it. - int brace = statement.indexOf('{'); - String body = brace >= 0 ? statement.substring(brace + 1) : statement; - recordBareAssignment(body, literals); - } - int i = 0; - boolean declared = false; - boolean subscript = false; - boolean callForm = false; - boolean typed = false; - // An extra property is NOT block scoped. A local declared inside a block - // leaves with it, which is why declarations carry their depth -- but - // `buildscript { ext.kotlin_version = '1.9.22' }` sets a project-wide - // property, and it is the standard shape of a Kotlin Android script. - // Recorded at the depth of the brace it sat in, it was discarded at the - // closing brace, so the version every dependency below interpolated read - // as unreadable, counted as below the floor, and stood the whole block - // down -- the alignment never ran for the commonest project there is. - boolean extraProperty = false; - // Outside literals, like every other question about syntax. An unrestricted - // search found `def` inside quoted prose -- println "def dep = '...'" -- and - // recorded a declaration that never executes, overwriting the real binding - // and making a later use read as something it is not. - int at = afterCall(statement, DEF); - if (at >= 0 && recordsADestructuring(statement, at, literals, conditional, - scope, depth)) { - return; - } - if (at >= 0) { - declared = true; - i = skipBlanks(statement, at); - } else { - i = skipBlanks(statement, afterAnyBlockOpener(statement)); - // Past any annotations first. A script field is written - // `@groovy.transform.Field String dep = '...'`, and the walk below reads - // identifier tokens -- so it stopped dead on the `@`, recorded nothing, - // and the strict pin the field carried was never seen. - while (i < statement.length() && statement.charAt(i) == '@') { - i++; - while (i < statement.length() - && (isIdentifierChar(statement.charAt(i)) - || (statement.charAt(i) == '.' - && i + 1 < statement.length() - && isIdentifierChar(statement.charAt(i + 1))))) { - i++; - } - i = skipBlanks(statement, i); - if (i < statement.length() && statement.charAt(i) == '(') { - // An annotation may carry arguments, and they may nest. - int open = 0; - while (i < statement.length()) { - char c = statement.charAt(i); - if (isLiteralStart(statement, i)) { - i = endOfStringLiteral(statement, i); - } else if (c == '(') { - open++; - } else if (c == ')') { - open--; - if (open == 0) { - i++; - break; - } - } - i++; - } - i = skipBlanks(statement, i); - } - } - // A typed local declares just as much as def does, and it is written - // with however many modifiers the author felt like: `String dep = ...`, - // `final String dep = ...`, `private static final String dep = ...`. - // Counting exactly two tokens read `String` as the name of a `final - // String dep` and never recorded dep at all. So walk every identifier - // token before the '=': more than one is a declaration whose name is the - // last of them, exactly one is an assignment. - int scan = i; - int lastTokenStart = i; - int lastTokenEnd = i; - int tokens = 0; - while (scan < statement.length() && isIdentifierChar(statement.charAt(scan))) { - lastTokenStart = scan; - tokens++; - // A qualified name is ONE token: java.lang.String dep = '...' is a - // declaration whose type happens to have dots in it, and stopping at - // the first one read `java` as the type and `lang` as the name, so - // dep was never recorded and the pin it carried never seen. - while (scan < statement.length() - && (isIdentifierChar(statement.charAt(scan)) - || (statement.charAt(scan) == '.' - && scan + 1 < statement.length() - && isIdentifierChar(statement.charAt(scan + 1))))) { - scan++; - } - // A type's arguments belong to the type: `Map dep` - // is a declaration whose type happens to be generic, and stopping at - // the `<` read `Map` as the whole statement -- so dep was never - // recorded and the strict pin the map held was never seen. - int generics = endOfTypeArguments(statement, scan); - if (generics > scan) { - scan = generics; - } - // And its dimensions, for the same reason: `String[] dep` is a - // declaration too. Only an EMPTY pair, which nothing but an array - // type is -- a subscript with something in it is `ext['dep']` or - // `deps[0]`, and swallowing those would take the name with them. - while (true) { - int empty = skipBlanks(statement, scan); - if (empty >= statement.length() || statement.charAt(empty) != '[') { - break; - } - int close = skipBlanks(statement, empty + 1); - if (close >= statement.length() || statement.charAt(close) != ']') { - break; - } - scan = close + 1; - } - lastTokenEnd = scan; - scan = skipBlanks(statement, scan); - } - if (tokens > 1 && !followedByMapKeyColon(statement, lastTokenEnd)) { - declared = true; - typed = true; - i = lastTokenStart; - } else if (tokens == 1) { - // ext.kotlinVersion = '1.9.22' -- Gradle's extra properties, which is - // how a project-wide version is nearly always written, and which - // really does bind the bare name the interpolation then reads. - // Restricted to that one owner on purpose: recording ANY dotted - // assignment would let `somePlugin.version = '1.0'` supply the value - // for an unrelated $version and turn an unreadable version into a - // confidently wrong one, which is the direction that under-suppresses. - String only = statement.substring(lastTokenStart, lastTokenEnd); - int dot = only.lastIndexOf('.'); - int openBracket = skipBlanks(statement, lastTokenEnd); - boolean subscripted = openBracket < statement.length() - && statement.charAt(openBracket) == '['; - // The owner is the LAST segment of the qualifier, not the whole of - // it, because the extension is reachable through the project too: - // `project.ext.dep` and `rootProject.ext.dep` set the same property - // the bare name goes on to read -- Gradle resolves a bare name up - // the project hierarchy -- and comparing the prefix whole rejected - // both, so a strict pre-merge pin held in one was never seen. - // Addressing ANOTHER project cannot arrive here: `(` ends the token - // walk above, so `project(':lib').ext.dep` never reads as one token - // and the chain is always this script's own. - int argument = skipBlanks(statement, lastTokenEnd); - boolean called = argument < statement.length() - && statement.charAt(argument) == '('; - if (dot > 0 && called && "set".equals(only.substring(dot + 1)) - && lastSegmentIs(only.substring(0, dot), EXTRA_PROPERTIES)) { - // ext.set('dep', '...') is the extension's own setter, and the - // name is its first argument. Read as a dotted assignment it - // recorded a property called `set` and lost the real one, so a - // later force naming it through the bare name named nothing -- - // and the constraints went in beside a force still in effect. - int nameAt = skipBlanks(statement, argument + 1); - if (nameAt < statement.length() - && isLiteralStart(statement, nameAt) - && delimiterLength(statement, nameAt) == 1) { - declared = true; - subscript = true; - callForm = true; - extraProperty = true; - i = nameAt + 1; - } - } else if (dot > 0 && !subscripted - && lastSegmentIs(only.substring(0, dot), EXTRA_PROPERTIES)) { - declared = true; - extraProperty = true; - i = lastTokenStart + dot + 1; - } else if (subscripted && lastSegmentIs(only, EXTRA_PROPERTIES)) { - // ext['dep'] = '...' is the subscript spelling of the same - // extension and the only one whose property name is a string - // rather than an identifier, so the walk above read `ext` as the - // name and recorded nothing the app could later refer to. Step - // inside the quote and let the identifier scan below take the - // name; the closing quote and bracket are stepped over after it. - int nameAt = skipBlanks(statement, openBracket + 1); - if (nameAt < statement.length() - && isLiteralStart(statement, nameAt) - && delimiterLength(statement, nameAt) == 1) { - declared = true; - subscript = true; - extraProperty = true; - i = nameAt + 1; - } - } - } - } - int nameStart = i; - while (i < statement.length() && isIdentifierChar(statement.charAt(i))) { - i++; - } - if (i == nameStart) { - return; - } - String name = statement.substring(nameStart, i); - if (subscript) { - // Past the `']` the subscript form puts between the name and the `=`, - // so the value is read the same way every other definition's is. - if (i < statement.length() && !isIdentifierChar(statement.charAt(i))) { - i++; - } - i = skipBlanks(statement, i); - if (i < statement.length() && statement.charAt(i) == ']') { - i++; - } - } - if (!declared && !literals.containsKey(name)) { - return; - } - // A brace this statement opened before the name guards it just as one on an - // earlier line does. The flag arriving here is the depth the statement - // STARTED at, so `if (cond) { dep = '...' }` written on one line read as an - // unconditional reassignment and threw away the coordinate the condition - // might never replace -- which is the pin, hidden, that this whole rule - // exists to keep. - if (depthAt(statement, nameStart, depth) > depth - || nameStart >= afterAnUnbracedHeader(statement) - && afterAnUnbracedHeader(statement) > 0) { - conditional = true; - } - if (declared) { - scope.declared(extraProperty - ? 0 : depthAt(statement, nameStart, depth), name, literals); - } - i = skipBlanks(statement, i); - // The setter's comma separates the name from the value exactly as the `=` - // does in every other spelling, so it stands in for one here. - if (callForm && i < statement.length() && statement.charAt(i) == ',') { - i++; - } else if (i >= statement.length() || statement.charAt(i) != '=' - || (i + 1 < statement.length() && statement.charAt(i + 1) == '=')) { - if (declared && !typed) { - // `def dep` with no value yet is still a name this knows about, and - // recording it is what lets a later assignment be recognised as one. - // Without it, `def dep` then `if (legacy) { dep = '...' }` left the - // assignment looking like a write to something unrelated, so the - // coordinate it carried was never learned. A null value inlines as - // the name itself, which is what an unset variable should look like. - // - // Only where a KEYWORD said it was a declaration. Two identifiers in - // a row are as often a parenthesis-free call as a typed local, and - // `println dep` was clearing the very binding it was printing -- so - // the pin that name carried was gone by the time anything used it. - // A genuinely valueless `String dep` records nothing now, which - // leaves the name unknown rather than wrong. - literals.put(name, null); - } - return; - } - // Every declarator, not just the first: `def marker = 'x', dep = 'coord'` - // declares two names, and stopping after one left the second unknown -- so - // the strict pin the second carried was invisible to the statement using it. - while (true) { - i = skipBlanks(statement, i + 1); - // Past any parentheses around the value. Groovy accepts - // `def dep = ('g:a:1.7.22!!')`, and a value that did not START with a - // literal was recorded as unknown -- so the pin it held was invisible - // to whatever used the name. - while (i < statement.length() && statement.charAt(i) == '(') { - i = skipBlanks(statement, i + 1); - } - int end = -1; - String value = null; - if (i < statement.length() && isLiteralStart(statement, i)) { - int closes = endOfStringLiteral(statement, i); - if (closes < statement.length()) { - end = closes; - value = expandedLiteral(statement, i, closes, literals); - } - } else if (i < statement.length() && statement.charAt(i) == '[') { - // A map factored into a variable is a declaration too: - // def dep = [group: '...', name: '...', version: '1.7.22'] - // Recorded as nothing, the statement using it named no artifact and - // the strict pin it carried was invisible. Stored whole, it inlines - // back into the usage and reads as the map form it is. - int closes = closingBracket(statement, i); - if (closes > i) { - end = closes; - // Through the same expansion a string definition gets, so a map - // that interpolates a known version -- version: "$v" -- carries - // the version rather than the text of the reference. Stored - // verbatim, "$v" read as no version at all, which counts as - // below the floor and stood the whole block down. - value = withLiteralsInlined( - statement.substring(i, closes + 1), literals); - } - } else if (i < statement.length() && isIdentifierChar(statement.charAt(i))) { - // A value that is a NAME rather than a literal. `def forced = - // coord` and `ext.set('forced', coord)` copy a binding that is - // already known, and reading only literals recorded the new name as - // unknown -- so a force through it named nothing and the - // constraints went in beside a pin still in effect. - // - // Resolved from what is recorded rather than by inlining the whole - // statement first, which substitutes the name being ASSIGNED as - // readily as the one being read: `dep = somethingUnknown` became - // `'...' = somethingUnknown`, so the reassignment was not seen at - // all and the stale value survived it. - int token = i; - while (token < statement.length() - && isIdentifierChar(statement.charAt(token))) { - token++; - } - String alias = literals.get(statement.substring(i, token)); - if (alias != null && !followedByMapKeyColon(statement, token)) { - end = token - 1; - value = alias; - } - } - recordDefinition(literals, name, value, conditional); - if (end < 0) { - return; - } - int comma = skipBlanks(statement, end + 1); - if (comma >= statement.length() || statement.charAt(comma) != ',') { - return; - } - int nextName = skipBlanks(statement, comma + 1); - int nextEnd = nextName; - while (nextEnd < statement.length() - && isIdentifierChar(statement.charAt(nextEnd))) { - nextEnd++; - } - if (nextEnd == nextName) { - return; - } - name = statement.substring(nextName, nextEnd); - int assign = skipBlanks(statement, nextEnd); - if (assign >= statement.length() || statement.charAt(assign) != '=' - || (assign + 1 < statement.length() - && statement.charAt(assign + 1) == '=')) { - return; - } - if (declared) { - scope.declared(extraProperty - ? 0 : depthAt(statement, nextName, depth), name, literals); - } - i = assign; - } - } - - /** - * The literal at {@code from}, with any definitions it interpolates already - * expanded. - * - *

A definition may refer to an earlier one -- - * {@code def v = '1.8.0'; def dep = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v"} - * -- and storing it as written made the version the text {@code $v}, which - * reads as no version and therefore as below the floor, suppressing the - * block for a project that was already merged-era.

- * - *

The VALUE only. Expanding the whole statement first was tried and - * substituted the NAME being assigned as well, so an unreadable - * reassignment stopped forgetting the literal it replaced -- the same - * mistake in the opposite direction, and the suite said so.

- */ - private static String expandedLiteral(String text, int from, int end, - Map literals) { - String literal = text.substring(from, end + 1); - // Every literal form Groovy has interpolates EXCEPT the single-quoted - // ones. Testing for a double quote missed the slashy forms, so a coordinate - // assembled as $/...:$v/$ kept the text $v as its version -- no version, - // therefore below the floor, therefore the block suppressed for a project - // that was already merged-era. - return text.charAt(from) != '\'' - ? withInterpolationsExpanded(literal, literals) - : literal; - } - - /** The statement with known definition names replaced by their literals. */ - private static String withLiteralsInlined(String statement, - Map literals) { - StringBuilder out = new StringBuilder(); - for (int i = 0; i < statement.length(); i++) { - char c = statement.charAt(i); - if (isLiteralStart(statement, i)) { - int end = endOfStringLiteral(statement, i); - String literal = statement.substring(i, - Math.min(end + 1, statement.length())); - // A double-quoted string interpolates, so a known definition referred - // to as $name or ${name} is the same one hop this already follows for - // a bare token. Reading it as unreadable made a merged-era version - // look pre-merge and dropped the sibling's constraint with it. - // The same rule as expandedLiteral: everything but a single quote. - out.append(c != '\'' ? withInterpolationsExpanded(literal, literals) - : literal); - i = end; - continue; - } - if (!isIdentifierChar(c) - || (i > 0 && isIdentifierChar(statement.charAt(i - 1)))) { - out.append(c); - continue; - } - int end = i; - while (end < statement.length() && isIdentifierChar(statement.charAt(end))) { - end++; - } - String token = statement.substring(i, end); - String literal = literals.get(token); - // A map KEY is not an expression, so it is not substituted. A local - // named after the DSL key it supplies -- def group = '...'; then - // implementation(group: group, ...) -- had both occurrences replaced, - // turning `group:` into a quoted string and losing the map form - // entirely, strict pin and all. Groovy's named arguments are exactly - // "identifier immediately followed by a colon", which is what this asks. - // The shared test, which skips blanks first: Groovy accepts - // `group : group` with spaces around the colon, and looking only at the - // character immediately after the token missed the key and substituted - // it away again. - boolean isMapKey = followedByMapKeyColon(statement, end); - out.append(literal == null || isMapKey ? token : literal); - i = end - 1; - } - return out.toString(); - } - - /** A double-quoted literal with known {@code $name} references expanded. */ - private static String withInterpolationsExpanded(String literal, - Map literals) { - StringBuilder out = new StringBuilder(); - for (int i = 0; i < literal.length(); i++) { - char c = literal.charAt(i); - if (c != '$' || i + 1 >= literal.length()) { - out.append(c); - continue; - } - int nameStart = i + 1; - boolean braced = literal.charAt(nameStart) == '{'; - if (braced) { - nameStart++; - } - int nameEnd = nameStart; - while (nameEnd < literal.length() - && isIdentifierChar(literal.charAt(nameEnd))) { - nameEnd++; - } - if (nameEnd == nameStart - || (braced && (nameEnd >= literal.length() - || literal.charAt(nameEnd) != '}'))) { - out.append(c); - continue; - } - String value = literals.get(literal.substring(nameStart, nameEnd)); - if (value == null) { - out.append(c); - continue; - } - // Stored with its quotes, which do not belong inside another string -- - // and with however many of them the literal was written with. Stripping - // one per side left a triple-quoted definition expanding to ""1.7.22"", - // no version parsed out of it, and the constraint written beside a - // strict pre-merge pin: the one outcome that fails at RUNTIME rather - // than in the build. Found by sweeping equivalent spellings of a strict - // pin, not by reading this line; the same one-character assumption was - // live in two other places. - out.append(stringLiteralContent(value, 0)); - i = braced ? nameEnd : nameEnd - 1; - } - return out.toString(); - } - - /** - * Records {@code name = 'literal'} as a definition. Only ever called for - * the inside of an extra-properties block, where a bare assignment does - * bind a name the rest of the script can read. - */ - private static void recordBareAssignment(String body, Map literals) { - int i = skipBlanks(body, 0); - int nameStart = i; - while (i < body.length() && isIdentifierChar(body.charAt(i))) { - i++; - } - if (i == nameStart) { - return; - } - String name = body.substring(nameStart, i); - i = skipBlanks(body, i); - if (i >= body.length() || body.charAt(i) != '=' - || (i + 1 < body.length() && body.charAt(i + 1) == '=')) { - return; - } - i = skipBlanks(body, i + 1); - if (i < body.length() && isLiteralStart(body, i)) { - int end = endOfStringLiteral(body, i); - if (end < body.length()) { - literals.put(name, expandedLiteral(body, i, end, literals)); - } - return; - } - // The other two shapes a value takes, which the ordinary definition path - // already reads: a map, and a name that copies an earlier binding. An - // `ext { dep = [group: '..', name: '..', version: '1.7.22!!'] }` block is - // the project-wide spelling of the same thing, and reading only literals - // left `dep` unknown -- so the declaration using it named no artifact and - // the pin it carried went unread. - if (i < body.length() && body.charAt(i) == '[') { - int closes = closingBracket(body, i); - if (closes > i) { - literals.put(name, - withLiteralsInlined(body.substring(i, closes + 1), literals)); - } - return; - } - if (i < body.length() && isIdentifierChar(body.charAt(i))) { - int end = i; - while (end < body.length() && isIdentifierChar(body.charAt(end))) { - end++; - } - String alias = literals.get(body.substring(i, end)); - if (alias != null && !followedByMapKeyColon(body, end)) { - literals.put(name, alias); - } - } - } - - private static final String DEF = "def"; - - /** Gradle's extra-properties prefix, the one dotted assignment worth reading. */ - private static final String EXTRA_PROPERTIES = "ext"; - - /** The block that configures the plugin classpath rather than the app's. */ - private static final String BUILDSCRIPT = "buildscript"; - - /** - * Blocks whose contents configure something other than the application's - * dependency graph. - * - *

{@code buildscript} is the plugin classpath. {@code testing} is the - * JVM test suites block, whose nested {@code dependencies { }} belongs to a - * suite's own configurations -- its {@code implementation} has the same - * name as the app's and is a different thing, so reading a declaration - * there as the app's skipped the constraint for an artifact the release - * graph still carries.

- * - *

A list, unusually for this class, because the general question -- - * which enclosing blocks reach this project's graph -- has no closed - * answer: {@code allprojects} does, {@code subprojects} does not, and a - * plugin may add either kind. It is safe as a list because a scope missing - * from it changes nothing: that block keeps being read as the app's, which - * is what happens today, and the cost is the duplicate an app already had. - * Inverting it -- treating every nested block as foreign -- is what is not - * safe, because blanking an {@code allprojects} declaration emits a - * constraint beside a pin that may be strict.

- */ - private static final String[] FOREIGN_SCOPES = { - "buildscript", - "testing", - // `subprojects { dependencies { .. } }` configures the CHILDREN, not the - // application this writes into. `allprojects` is deliberately absent: it - // does include this project, which is the distinction the note above is - // about. - "subprojects" - }; - - /** Whether the statement opens a block that is not the app's own graph. */ - private static boolean opensAForeignScope(String statement) { - for (int i = 0; i < FOREIGN_SCOPES.length; i++) { - if (opensBlockNamed(statement, FOREIGN_SCOPES[i])) { - return true; - } - } - return false; - } - - /** - * Whether the text so far is a control header whose body is the next line. - * - *

Read off the language's own keywords, which is a closed set -- unlike - * the earlier use of a keyword list, which was answering "might this scope - * run" and is better answered by counting braces. The question here is - * different: an unbraced body belongs to the header above it, and only - * these words introduce one.

- */ - private static boolean opensAnUnbracedBody(String text) { - // Whether the statement ENDS with a header, not whether it starts with - // one. A rule is written with its openers and its condition on one line - // and the body on the next: - // configurations.all { resolutionStrategy.eachDependency { d -> - // if (d.requested.name == 'kotlin-stdlib') - // d.useVersion '1.7.22' - // Reading the first token found `configurations`, and the brace balance - // is two open besides, so the condition and the useVersion were split into - // separate statements -- neither of which says anything, which is how an - // override in force went unread and the constraints went in beside it. - int last = skipBlanksBackward(text, text.length() - 1); - if (last < 0) { - return false; - } - if (text.charAt(last) != ')') { - // `else` stands alone; it is the only header with no condition. - int start = last; - while (start >= 0 && isIdentifierChar(text.charAt(start))) { - start--; - } - return "else".equals(text.substring(start + 1, last + 1)); - } - // The parenthesis that closes AT the end, found forward so a bracket - // inside a string is not counted as one. - List opened = new ArrayList(); - int opener = -1; - for (int i = 0; i < text.length(); i++) { - if (isLiteralStart(text, i)) { - i = endOfStringLiteral(text, i); - continue; - } - char c = text.charAt(i); - if (c == '(') { - opened.add(Integer.valueOf(i)); - } else if (c == ')' && !opened.isEmpty()) { - int open = opened.remove(opened.size() - 1).intValue(); - if (i == last) { - opener = open; - break; - } - } - } - if (opener < 0) { - return false; - } - int end = skipBlanksBackward(text, opener - 1); - if (end < 0) { - return false; - } - int start = end; - while (start >= 0 && isIdentifierChar(text.charAt(start))) { - start--; - } - // Only a header takes the next line as its body. `implementation('a:1.0')` - // and `force('a:1.0')` end in a parenthesis too and take nothing. - return UNBRACED_HEADERS.indexOf( - " " + text.substring(start + 1, end + 1) + " ") >= 0; - } - - /** The words that introduce a body, braced or not. */ - private static final String UNBRACED_HEADERS = " if else while for "; - - /** Whether the text so far ends with Groovy's line-continuation backslash. */ - private static boolean endsWithLineContinuation(StringBuilder current) { - return current.length() > 0 - && current.charAt(current.length() - 1) == '\\'; - } - - /** Whether the text so far ends with a comma, ignoring trailing blanks. */ - private static boolean endsWithComma(StringBuilder text) { - for (int i = text.length() - 1; i >= 0; i--) { - char c = text.charAt(i); - if (isBlank(c)) { - continue; - } - return c == ','; - } - return false; - } - - /** Whether the statement is nothing but the start of a closure. */ - private static boolean opensAClosure(String statement) { - String trimmed = statement.trim(); - return trimmed.startsWith("{"); - } - - /** - * The brace balance of what follows a declaration's coordinate, which is - * the only part that can be its own trailing closure. - * - *

Counting the whole statement caught the ENCLOSING block's opener when - * a fragment put its first dependency on the same line as it -- - * {@code dependencies { implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'} - * -- and the declaration then swallowed every following statement up to the - * closing brace, so an unrelated {@code strictly} further down read as a - * pin on the stdlib and silenced the whole block. A block opener sits - * BEFORE the coordinate and a trailing closure after it, so counting from - * the end of the last string literal separates them.

- */ - private static int trailingBraceBalance(String statement) { - // From the end of the COORDINATE literal, not the last literal: a trailing - // closure carries strings of its own -- an exclusion's module name, a strict - // version -- and counting after those missed the closure's own opening brace. - for (int i = 0; i < statement.length(); i++) { - char c = statement.charAt(i); - if (!isLiteralStart(statement, i)) { - continue; - } - int end = endOfStringLiteral(statement, i); - if (stringLiteralContent(statement, i).startsWith(KOTLIN_GROUP)) { - return braceBalance(statement.substring(Math.min(end + 1, - statement.length()))); - } - i = end; - } - return braceBalance(statement); - } - - /** How far a statement opens or closes braces, ignoring those in strings. */ - private static int braceBalance(String statement) { - int depth = 0; - for (int i = 0; i < statement.length(); i++) { - char c = statement.charAt(i); - if (isLiteralStart(statement, i)) { - i = endOfStringLiteral(statement, i); - } else if (c == '{') { - depth++; - } else if (c == '}') { - depth--; - } - } - return depth; - } - } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index f6331af8ed6..fb9526922e2 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -27,5626 +27,208 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * The Kotlin stdlib alignment written into the generated Android - * {@code build.gradle}. + * The alignment is a Gradle constraint plus one blunt reason not to write it. * - *

Every case here is about restraint rather than about the block's text: - * the alignment lands in the dependency graph of every AndroidX app, so the - * cases that must produce nothing matter more than the one that must produce - * something. The two that must NOT produce nothing -- - * {@link #aKotlinPluginNoLongerExcusesTheBlock()} and - * {@link #pinningOneJdkArtifactLeavesTheOtherConstrained()} -- are the ones - * that caught a real over-suppression, so treat a change that makes either - * pass vacuously as a regression.

+ *

These cover what the feature promises: the graph it fixes, the graphs it + * must not touch, and the guarantee that it can never fail a build. There is + * deliberately nothing here about Groovy syntax -- the class no longer reads + * any, and the suite that did was 5,652 lines chasing spellings that never + * changed an outcome.

*/ -public class KotlinStdlibAlignmentTest { - private static String block() { - return KotlinStdlibAlignment.constraintsBlock("implementation"); - } - - /** - * jdk8 is the artifact the duplicate class reports name, but the real - * graph resolves both to the same old version, so aligning jdk8 alone - * would move the failure onto {@code kotlin.jdk7.AutoCloseableKt} rather - * than remove it. - */ - @Test - public void constrainsBothJdkArtifactsToTheShimFloor() { - String out = block(); - check(out.contains( - "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0')"), - "jdk7 is aligned"); - check(out.contains( - "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0')"), - "jdk8 is aligned"); - } - - /** - * 1.8.0 is the first release of the two jdk artifacts that carries no - * classes. An older floor would still leave a real jar in the graph. - */ - @Test - public void theFloorIsTheVersionWhereTheClassesMoved() { - check("1.8.0".equals(KotlinStdlibAlignment.MERGED_STDLIB_FLOOR), - "the floor is the version where the classes moved"); - } - - /** It is a constraints block, not a dependency declaration or a force. */ - @Test - public void declaresConstraintsRatherThanDependencies() { - String out = block(); - check(out.contains("constraints {"), "it is a constraints block"); - check(!out.contains("force"), "it constrains rather than forces"); - int open = 0; - int close = 0; - for (int i = 0; i < out.length(); i++) { - if (out.charAt(i) == '{') { - open++; - } else if (out.charAt(i) == '}') { - close++; - } - } - check(open == close, "the block's braces balance"); - } - - /** - * Gradle prints the reason next to the raised version in - * {@code dependencyInsight}, and this constraint corresponds to nothing - * in the developer's own project, so an unattributed one is a support - * question waiting to happen. - */ - @Test - public void everyConstraintCarriesAReason() { - String out = block(); - check(countOccurrences(out, "because '") == 2, - "both constraints say why they are there"); - check(out.contains("Codename One"), "the reason names who wrote it"); - } - - /** - * A Kotlin plugin no longer excuses the block, whatever its version. - * - *

Skipping for a 1.8+ plugin was never load-bearing -- measured against - * a graph carrying billing 9.1.0 and appcompat 1.6.1, adding this block - * alongside plugin 1.9.22 and 1.8.22 produced byte-identical resolution -- - * and it was not sound either, because the plugin's alignment can be - * turned off with kotlin.stdlib.jdk.variants.version.alignment=false, - * which this builder preserves out of a project's gradle.properties. - * Emitting unconditionally answers both, and takes the version parsing and - * the commented-plugin hazard with it.

- */ - @Test - public void aKotlinPluginNoLongerExcusesTheBlock() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), "jdk7 is aligned regardless"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), "jdk8 is aligned regardless"); - } - - /** - * Suppression is per artifact. jdk8 depends on jdk7, so an app pinning - * jdk8 raises jdk7 with it -- but an app pinning jdk7 leaves jdk8 exactly - * where the graph put it, and dropping the whole block there would leave - * the original duplicate intact with its fix switched off. - */ - @Test - public void pinningOneJdkArtifactLeavesTheOtherConstrained() { - String pinnedJdk7 = KotlinStdlibAlignment.constraintsBlock( - "implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22'\n"); - check(pinnedJdk7.contains("kotlin-stdlib-jdk8"), - "pinning jdk7 leaves jdk8 constrained"); - check(!pinnedJdk7.contains("kotlin-stdlib-jdk7:1.8.0"), - "the artifact the app pinned is left to the app"); - - String pinnedJdk8 = KotlinStdlibAlignment.constraintsBlock( - "implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); - check(pinnedJdk8.contains("kotlin-stdlib-jdk7"), - "pinning jdk8 leaves jdk7 constrained"); - check(!pinnedJdk8.contains("kotlin-stdlib-jdk8:1.8.0"), - "the artifact the app pinned is left to the app"); - - String pinnedBoth = KotlinStdlibAlignment.constraintsBlock( - "implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22'\n" - + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); - check("".equals(pinnedBoth), - "an app managing both gets no block at all, not an empty one"); - } - - /** - * The builder has to hand over every app-controlled fragment that reaches - * the generated dependencies block, not the ones that came to mind. - * android.supportv4Dep was missed that way: it is written into that block - * a few lines below the constraints, so an app pinning a jdk artifact - * through it would have had the pin ignored and the constraint written - * over the top. - * - *

The list is checked against the builder's source rather than - * re-derived, because the failure is an omission and an omission is - * invisible to a test that only exercises what is passed.

- */ - @Test - public void theBuilderPassesEveryAppControlledDependencyFragment() throws Exception { - byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( - "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); - String src = new String(bytes, "UTF-8"); - int at = src.indexOf("KotlinStdlibAlignment.constraintsBlock("); - check(at >= 0, "the builder calls the alignment"); - // To the statement terminator, not to "));" -- that lands on the closing paren - // of the LAST argument and slices it in half, so the final fragment never - // matched and the check failed for the wrong reason. - String call = src.substring(at, src.indexOf(";", at)); - // The call form, not the bare hint name: the comment above the argument list - // names android.supportv4Dep too, so matching the name alone passed with the - // argument deleted. Checked by deleting it, which is the only way that kind of - // vacuity shows up. - String[] fragments = { - // The dependency fragments now reach the call concatenated into ONE - // wrapper, because the generated script has one dependencies { } and - // a closure each made a scope boundary Gradle does not have. Matched - // on the concatenation operator so that deleting an argument fails - // this, which matching the bare hint name did not -- the comment - // above the list names some of them too. - "+ additionalDependencies", - "+ aiExtraGradleDependencies.toString()", - "+ request.getArg(\"android.gradleDep\", \"\")", - "+ request.getArg(\"android.supportv4Dep\", \"\")", - "request.getArg(\"android.xgradle\", \"\")", - }; - for (String fragment : fragments) { - check(call.contains(fragment), - "the alignment is not told about " + fragment - + ", which reaches the generated dependencies block"); - } - } - - /** - * A commented-out declaration is not a declaration. The same hazard the - * VPN manifest checks cover, in the same builder's hint text: a developer - * parks a line with {@code //} and the substring match reads it as a live - * pin, switching off the alignment for an app that pinned nothing. - */ - @Test - public void aCommentedOutDeclarationIsNotADeclaration() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " // implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n" - + " // implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), "a commented-out BOM does not suppress"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), "a commented-out pin does not suppress"); - - String blockComment = KotlinStdlibAlignment.constraintsBlock("implementation", - " /* implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22' */\n"); - check(blockComment.contains("kotlin-stdlib-jdk8:1.8.0"), - "a block-commented pin does not suppress"); - } - - /** - * A declaration on a variant or test configuration does not reach the one - * the constraints are written on, so it cannot stand in for a pin. - * debugImplementation of a new-enough BOM constrains the debug variant - * alone -- suppressing on it removes the constraint from the release build - * that still needs it, and the release build is the one that ships. - */ - @Test - public void aVariantOnlyDeclarationDoesNotSuppress() { - String debugBom = KotlinStdlibAlignment.constraintsBlock("implementation", - " debugImplementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n"); - check(debugBom.contains("kotlin-stdlib-jdk8:1.8.0"), - "a debug-only BOM does not suppress the main variant"); - - String testPin = KotlinStdlibAlignment.constraintsBlock("implementation", - " testImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); - check(testPin.contains("kotlin-stdlib-jdk8:1.8.0"), - "a test-only pin does not suppress the main variant"); - - String releasePin = KotlinStdlibAlignment.constraintsBlock("implementation", - " releaseImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); - check(releasePin.contains("kotlin-stdlib-jdk8:1.8.0"), - "even a release-only pin is not the configuration being constrained"); - } - - /** - * Every main-variant configuration reaches a classpath the constraint also - * reaches, so a pin on any of them is the app managing the artifact. - * runtimeOnly is the one that made this a list rather than two names: a - * strict pin there did not get overridden by the emitted 1.8.0 constraint, - * it made the resolution fail outright. - */ - @Test - public void aPinOnAnyMainConfigurationSuppresses() { - String[] configurations = {"implementation", "api", "runtimeOnly", - "compile", "runtime"}; - for (String configuration : configurations) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " " + configuration - + "('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')\n"); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a pin on " + configuration + " is the app managing jdk8"); - check("".equals(out), - "a below-floor pin on " + configuration + " suppresses BOTH shims, " - + "since raising the sibling would strand the pinned one"); - } - } - - /** - * compileOnly is NOT one of them, and adding it by symmetry was the - * mistake. A compileOnly declaration is absent from the release runtime - * classpath, which is the one checkReleaseDuplicateClasses reads, so - * treating it as management of that graph drops the constraint from a - * classpath the app never touched and leaves the duplicate in place. - */ - @Test - public void aCompileOnlyDeclarationDoesNotManageTheRuntimeGraph() { - String bom = KotlinStdlibAlignment.constraintsBlock("implementation", - " compileOnly platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n"); - check(bom.contains("kotlin-stdlib-jdk8:1.8.0"), - "a compileOnly BOM does not align the runtime graph"); - } - - /** - * A strict version ends the question wherever it is declared, because a - * constraint cannot coexist with one on any classpath both reach. - * Measured with Gradle: the same graph resolves on its own and fails with - * this block's constraint added -- - * "Could not resolve kotlin-stdlib-jdk8:{strictly 1.7.22}". That is worse - * than the duplicate, because the app cannot work around it. - */ - @Test - public void aStrictPinIsHonouredOnAnyConfiguration() { - String releaseStrict = KotlinStdlibAlignment.constraintsBlock("implementation", - " releaseImplementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') {\n" - + " version { strictly '1.7.22' }\n" - + " }\n"); - check(!releaseStrict.contains("kotlin-stdlib-jdk8:1.8.0"), - "a strict release pin is left to the app"); - - String compileOnlyStrict = KotlinStdlibAlignment.constraintsBlock( - "implementation", - " compileOnly('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check(!compileOnlyStrict.contains("kotlin-stdlib-jdk8:1.8.0"), - "a strict compileOnly pin is honoured even though compileOnly alone is not"); - } - - /** - * An exclusion written before the version block must not take the strict - * marker with it. Cutting the statement from {@code exclude} to its end - * did exactly that, and losing the strict marker is what turns this - * class's constraint into a failed resolution rather than an override. - */ - @Test - public void anExclusionBeforeTheVersionBlockDoesNotHideTheStrictPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " compileOnly('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ exclude group: 'x', module: 'y'; version { strictly '1.7.22' } }\n"); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the strict pin survives an exclusion written before it"); - - String multiline = KotlinStdlibAlignment.constraintsBlock("implementation", - " compileOnly('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') {\n" - + " exclude group: 'x', module: 'y'\n" - + " version { strictly '1.7.22' }\n" - + " }\n"); - check(!multiline.contains("kotlin-stdlib-jdk8:1.8.0"), - "and the same written across lines"); - } - - /** - * The English word is not the Gradle call. A reason string reading - * "not strictly required outside debug" is prose, and reading it as a - * strict version let a variant-only dependency switch the alignment off - * for the release build -- the unsafe direction. - */ - @Test - public void theWordStrictlyInsideAStringIsNotAStrictPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " debugImplementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ because 'not strictly required outside debug' }\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a reason mentioning the word does not suppress the release constraint"); - - // and the real call still does, so the tightening did not disarm it - String real = KotlinStdlibAlignment.constraintsBlock("implementation", - " debugImplementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check(!real.contains("kotlin-stdlib-jdk8:1.8.0"), - "an actual strict call is still honoured"); - } - - /** - * Groovy allows whitespace around a map entry's colon, and the exact - * substring match missed it. It matters because the same declaration can - * carry a strict version, and missing it turns the constraint into a - * failed resolution rather than an override. - */ - @Test - public void aMapEntryMayHaveSpaceAroundItsColon() { - String spaced = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation(group : 'org.jetbrains.kotlin', " - + "name : 'kotlin-stdlib-jdk8', version : '1.7.22!!')\n"); - check("".equals(spaced), - "a spaced map entry still pins jdk8, below the floor so both go"); - - String doubleQuoted = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation(group: \"org.jetbrains.kotlin\", " - + "name:\"kotlin-stdlib-jdk8\", version: \"1.7.22!!\")\n"); - check("".equals(doubleQuoted), - "and so does an unspaced double-quoted one"); - - // A different artifact in the same shape must still not count. - String other = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation(group : 'org.jetbrains.kotlin', " - + "name : 'kotlin-reflect', version : '1.9.22')\n"); - check(other.contains("kotlin-stdlib-jdk8:1.8.0"), - "naming a different Kotlin artifact does not pin jdk8"); - } - - /** - * A strict pin on kotlin-stdlib itself blocks both shims, not one. The - * shim at this floor depends on kotlin-stdlib at the same floor, so an app - * strictly holding the base library below it cannot resolve either - * constraint -- and the pre-merge family it is holding had no duplicate to - * begin with, so constraining there turns a working build into - * "Could not resolve ... {strictly 1.7.22}". - */ - @Test - public void aStrictPinOnTheBaseStdlibBlocksBothShims() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(out), - "a strict pre-merge base pin writes no constraints at all"); - - // At or above the floor there is no conflict, so the block still goes in: - // a shim requiring 1.8.0 is satisfied by a strict 1.9.22. - String modern = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.9.22') " - + "{ version { strictly '1.9.22' } }\n"); - check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), - "a strict modern base pin does not need the block suppressed"); - } - - /** - * The two shims cannot be suppressed independently below the merge floor. - * Measured with Gradle: an app pinning the whole family at 1.7.22 resolves - * with no duplicate, and emitting only the surviving sibling raises - * kotlin-stdlib to 1.8.0 -- which carries the jdk8 classes -- beside the - * app's class-bearing jdk8 1.7.22 jar. That is this block manufacturing the - * duplicate it exists to prevent, in a graph the app had arranged correctly. - */ - @Test - public void aPreMergeShimPinSuppressesItsSiblingToo() { - String jdk8Pinned = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n"); - check("".equals(jdk8Pinned), - "a pre-merge jdk8 pin takes the jdk7 constraint with it"); - - String jdk7Pinned = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.22!!'\n"); - check("".equals(jdk7Pinned), - "and the same the other way round"); - } +class KotlinStdlibAlignmentTest { - /** - * Above the floor they stay independent, because the sibling constraint - * cannot strand a shim that is already merged-era. Without this the fix - * above would have been "suppress everything whenever the app mentions - * either artifact", which gives up alignment an app still needs. - */ - @Test - public void aMergedEraShimPinStillLeavesTheSiblingConstrained() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), - "jdk7 is still constrained beside a merged-era jdk8 pin"); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), - "and jdk8 is left to the app"); - } + private static final String JDK7 = "org.jetbrains.kotlin:kotlin-stdlib-jdk7"; + private static final String JDK8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; /** - * A def reference whose closure spans lines needs the definition folded in - * BEFORE closures are merged: the merge only absorbs into a statement that - * already names the Kotlin group, and a statement referring to the - * coordinate through a variable does not name it until the fold happens. - * Running the passes the other way round left the closure unmerged and the - * strict pin unseen. + * The graph this exists for: the old shim arrives transitively and the app's + * own Gradle never mentions Kotlin at all. */ @Test - public void aDefReferenceWithAMultilineClosureIsStillAPin() { + void aGraphThatNamesNoKotlinIsAligned() { String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def stdlib = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" - + " implementation(stdlib) {\n" - + " version { strictly '1.7.22' }\n" - + " }\n"); - check("".equals(out), - "the strict base pin behind a def with a multiline closure is honoured"); - } - - /** - * Every fragment the generated dependencies block is built from is handed - * to the alignment, and in the same order. - * - *

Read off the builder's own concatenation rather than listed here, - * because a list here is a second copy of the truth and it was already - * wrong: kotlinRuntimeDependency carries requireKotlinStdlib, so an app - * asking for {@code 1.7.22!!} had a strict pre-merge pin on the base - * library that nothing in the scan could see. Two more fragments were - * missing beside it. A test that enumerates cannot go stale the way the - * list did.

- */ - @Test - public void everyFragmentOfTheGeneratedBlockIsScanned() throws Exception { - byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( - "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); - String builderSrc = new String(bytes, "UTF-8"); - int at = builderSrc.indexOf("KotlinStdlibAlignment.constraintsBlock("); - check(at >= 0, "the builder calls the alignment"); - // Comments go first, THEN the terminator is found: a semicolon inside the - // call's own explanatory comment truncated this slice and the test then - // reported arguments missing that were plainly there. - String fromCall = builderSrc.substring(at).replaceAll("//[^\n]*", ""); - String call = fromCall.substring(0, fromCall.indexOf(";")); - - // The WHOLE generated script, not just its dependencies block. The block was - // the wrong boundary: android.gradle.androidx is interpolated inside - // android { }, where a project.configurations.all { ... force } is accepted - // and executed, so a fragment there decides what resolves just as much as a - // declaration does. Bounding this test at the block is what let that through. - int blockAt = builderSrc.indexOf("String gradleProps = "); - check(blockAt >= 0, "the generated script is found"); - int blockEnd = builderSrc.indexOf("Gradle File start", blockAt); - check(blockEnd > blockAt, "and its end"); - String block = builderSrc.substring(blockAt, blockEnd).replaceAll("//[^\n]*", ""); - - // What the block is concatenated FROM: java expressions, not literals. - // Keyed by where they appear, because the two spellings have to come back - // interleaved: collecting all the hints and then all the locals produced a - // list in neither the script's order nor the call's, and the order half of - // this test then failed on a call that was right. - java.util.TreeMap byPosition = new java.util.TreeMap(); - java.util.regex.Matcher hint = java.util.regex.Pattern - .compile("getArg\\(\"([a-zA-Z0-9._]+)\"").matcher(block); - while (hint.find()) { - byPosition.put(Integer.valueOf(hint.start()), "getArg(\"" + hint.group(1) + "\""); - } - // Every fragment that carries app-supplied text, by either route: a getArg - // read straight into the script, or a local that was ASSIGNED from one. - // Requiring only the direct reads missed injectRepo, which holds - // android.repositories and is interpolated into the repositories closure -- - // where a project.configurations.all { force } runs perfectly well. The - // locals are found by looking at how they are built, not by knowing their - // names, because knowing their names is what keeps being wrong. - java.util.Set carriesAppText = new java.util.HashSet(); - java.util.regex.Matcher assigned = java.util.regex.Pattern - .compile("\\b([a-z][a-zA-Z0-9]*)\\s*(?:=|\\+=)[^;\n]*getArg\\(") - .matcher(builderSrc); - while (assigned.find()) { - carriesAppText.add(assigned.group(1)); - } - check(carriesAppText.contains("injectRepo"), - "the scan for hint-carrying locals works: " + carriesAppText); - java.util.regex.Matcher carrier = java.util.regex.Pattern - .compile("\\+\\s*(?:addNewlineIfMissing\\()?([a-z][a-zA-Z0-9]*)\\b") - .matcher(block); - while (carrier.find()) { - if (carriesAppText.contains(carrier.group(1))) { - byPosition.put(Integer.valueOf(carrier.start(1)), carrier.group(1)); - } - } - int dependenciesAt = block.indexOf("\"dependencies {"); - check(dependenciesAt >= 0, "the dependencies block is inside the script"); - java.util.regex.Matcher name = java.util.regex.Pattern - .compile("\\+\\s*(?:addNewlineIfMissing\\()?([a-z][a-zA-Z0-9]*)\\b") - .matcher(block); - while (name.find()) { - String token = name.group(1); - if (name.start(1) < dependenciesAt) { - continue; - } - // The configuration itself is passed as the first argument, and the - // block this test is about is the alignment's own output. - if ("compile".equals(token) || "kotlinStdlibConstraints".equals(token) - || "addNewlineIfMissing".equals(token)) { - continue; - } - // `request` in request.getArg("x") is the receiver, not a fragment -- - // that one is already counted under its hint name. Matched on the call - // rather than the name, so a local that merely has methods on it - // (aiExtraGradleDependencies.toString()) still counts. - if (block.startsWith(".getArg(", name.end(1))) { - continue; - } - byPosition.put(Integer.valueOf(name.start(1)), token); - } - // EVERY occurrence, not one per name. A fragment interpolated twice is - // executed twice -- injectRepo goes into the buildscript repositories and - // again into the project ones after the android block -- and Gradle runs - // both, so a name it binds is restored at the second. Collapsing them let - // the scan keep whatever an intervening fragment had reassigned, and read - // a later use of that name as something it is not. The dedup was put here - // to make the ordering check pass; the search below starts after the last - // match instead, which is what a repeated argument actually needs. - java.util.List fragments = - new java.util.ArrayList(byPosition.values()); - check(fragments.size() >= 6, - "the block really was parsed, found " + fragments); - check(java.util.Collections.frequency(fragments, "injectRepo") == 2, - "the script interpolates injectRepo twice, found " + fragments); - - int previous = -1; - for (int i = 0; i < fragments.size(); i++) { - String fragment = fragments.get(i); - int passed = call.indexOf(fragment, previous + 1); - check(passed >= 0, "the alignment is given " + fragment - + " at occurrence " + i + ", which the generated block contains " - + "but the call does not"); - previous = passed; - } - } - - /** - * The alignment can never fail a build. It reads developer-authored Groovy - * with a hand-written scanner, on every AndroidX build there is, to decide - * something that is an optimisation over a build which already worked -- - * so an index defect in it must cost that one app its constraint, not - * every app its build. The guard is asserted here rather than trusted, - * because nothing else in the suite would notice it being refactored away. - */ - @Test - public void theAlignmentCannotFailTheBuild() throws Exception { - byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( - "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); - String builderSrc = new String(bytes, "UTF-8"); - int at = builderSrc.indexOf("KotlinStdlibAlignment.constraintsBlock("); - check(at >= 0, "the builder calls the alignment"); - String before = builderSrc.substring(0, at); - check(before.lastIndexOf("try {") > before.lastIndexOf("catch ("), - "the call is inside a try block"); - String after = builderSrc.substring(at); - int handler = after.indexOf("catch (RuntimeException"); - check(handler >= 0, "and a RuntimeException handler follows it"); - // Past the handler's own reasoning, which is longer than the code. - String body = after.substring(handler, - Math.min(handler + 2000, after.length())); - check(body.indexOf("kotlinStdlibConstraints = \"\"") >= 0, - "which falls back to emitting nothing"); - check(body.indexOf("log(") >= 0, - "and says so, rather than swallowing the defect"); - } - - /** - * A map key may be quoted. Skipping every literal meant the key was never - * seen, so a declaration written that way named no artifact at all and the - * strict pin inside it went with it. - */ - @Test - public void aMapKeyMayBeQuoted() { - String[] quotes = {"'", "\"", "'''", "\"\"\""}; - for (int q = 0; q < quotes.length; q++) { - String u = quotes[q]; - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation(" + u + "group" + u + ": 'org.jetbrains.kotlin', " - + u + "name" + u + ": 'kotlin-stdlib-jdk8', " - + u + "version" + u + ": '1.7.22!!')\n"); - check("".equals(out), - "a key quoted with " + u + " is still a key, got <<" + out + ">>"); - } - - // Mixed spellings in one declaration, which Groovy also accepts. - String mixed = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('group': 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk8', \"version\": '1.7.22!!')\n"); - check("".equals(mixed), "mixed key spellings, got <<" + mixed + ">>"); - - // And a merged-era one written the same way keeps the sibling aligned. - String modern = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('group': 'org.jetbrains.kotlin', " - + "'name': 'kotlin-stdlib-jdk7', 'version': '1.9.22')\n"); - check(modern.contains("kotlin-stdlib-jdk8:1.8.0") - && !modern.contains("kotlin-stdlib-jdk7:1.8.0"), - "the merged-era declaration is read, got <<" + modern + ">>"); - } - - /** - * strictly, require and useVersion SET the constraint rather than adding to - * it, so a closure that calls one twice keeps the last value. Reading the - * first wrote the shim constraints beside a pin that was really pre-merge. - */ - @Test - public void theLastCallOfARepeatedSetterIsTheOneThatCounts() { - String[] spellings = { - " version { strictly '1.9.22'; strictly '1.7.22' }\n", - " version {\n strictly '1.9.22'\n" - + " strictly '1.7.22'\n }\n", - " version { require '1.9.22'; require '1.7.22!!' }\n", - }; - for (int i = 0; i < spellings.length; i++) { - String declaration = " implementation(" - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8') {\n" - + spellings[i] + " }\n"; - check("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", declaration)), - "the last value stands the block down, in <<" + spellings[i] + ">>"); - } - - // And the other way round, so this is the last value rather than the - // lowest: set back UP to a merged-era version the sibling is still raised. - String raised = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') {\n" - + " version { strictly '1.7.22'; strictly '1.9.22' }\n }\n"); - check(raised.contains("kotlin-stdlib-jdk7:1.8.0") - && !raised.contains("kotlin-stdlib-jdk8:1.8.0"), - "and the last value is read even raising, got <<" + raised + ">>"); - } - - /** - * Rejections accumulate -- reject takes varargs and may be called again -- and - * Gradle applies every selector, so every one is asked whether it removes the - * floor. Reading only the first missed a pair whose second selector was the - * one that removed it. - */ - @Test - public void rejectionsAreCombinedBeforeTheFloorIsCalledReachable() { - // What this block writes is a constraint on exactly 1.8.0, so a rejection - // that removes the floor removes the only version it can resolve to -- - // whether or not it leaves higher ones. Written second, the selector that - // removes it was not being read at all. - String[] removeTheFloor = { - "reject '1.8.0'", - "reject '[1.8.0]'", - "reject '(1.8.0,)', '1.8.0'", - "reject('1.8.0', '(1.8.0,)')", - "reject '[1.8.0,)'", - "reject '(1.7.0,)'", - "reject '[1.7.0,1.9.0]'", - "reject '[1.7.0,1.8.0]'", - "reject '[1.7.0,1.8.5]', '[1.8.6,1.9.0]'", - "reject '1.8.0'\n reject '(1.8.0,)'", - }; - for (int i = 0; i < removeTheFloor.length; i++) { - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - rejecting(removeTheFloor[i]))), - "<<" + removeTheFloor[i] + ">> takes the floor away"); - } - - // And these leave it exactly where the constraint needs it. Reading any of - // them as management would leave the duplicate this block exists to - // prevent -- an exclusive bound does not reject the bound itself. - String[] leaveTheFloor = { - "reject '(1.8.0,)'", - "reject '[1.9.0,)'", - "reject '[1.7.0,1.8.0)'", - "reject '(1.8.0,1.9.0]'", - "reject '[1.7.0,1.7.9]', '[1.8.1,1.9.0]'", - "reject '1.7.0'", - // A prerelease of the floor is a different version from the floor. - "reject '1.8.0-RC2', '(1.8.0,)'", - }; - for (int i = 0; i < leaveTheFloor.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", - rejecting(leaveTheFloor[i])) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + leaveTheFloor[i] + ">> still leaves the floor selectable"); - } - } - - /** A jdk8 declaration whose rich version requires anything and rejects this. */ - private static String rejecting(String rejections) { - return " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') {\n" - + " version { require '1.+'; " + rejections + " }\n }\n"; - } - - /** - * The two shapes a value takes that the bare-assignment path did not read. - * An {@code ext { dep = [..] }} block is the project-wide spelling of a map - * definition, and a name on the right copies an earlier binding; reading - * only literals left both unknown, so the declaration using one named no - * artifact and the pin it carried went unread. - */ - @Test - public void anExtraPropertiesClosureReadsEveryKindOfValue() { - String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!"; - String use = " implementation(dep)\n"; - String[] bound = { - " ext {\n dep = [group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk8', version: '1.7.22!!']\n }\n", - " def coord = '" + pin + "'\n ext {\n dep = coord\n }\n", - " ext {\n dep = '" + pin + "'\n }\n", - }; - for (int i = 0; i < bound.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - bound[i] + use); - check("".equals(out), "<<" + bound[i].trim() + ">> binds dep, got <<" - + out + ">>"); - } - - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " ext {\n dep = [group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk8', version: '1.9.22']\n }\n" + use) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "a merged-era map through the same route is read too"); - } - - /** - * A destructured name is scoped like any other. Written straight into the - * map, one declared inside a block outlived it -- so an inner - * {@code def (dep, x) = [..]} shadowed an extra property for the rest of the - * file and its coordinate was inlined into a later declaration that has - * nothing to do with it. - */ - @Test - public void aDestructuredNameLeavesItsBlock() { - String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!"; - String escaped = KotlinStdlibAlignment.constraintsBlock("implementation", - " ext.dep = 'com.example:real:1.0'\n" - + " if (true) {\n def (dep, x) = ['" + pin + "', 'x']\n" - + " }\n implementation(dep)\n"); - check(escaped.contains("kotlin-stdlib-jdk7:1.8.0"), - "the extra property comes back after the block, got <<" - + escaped + ">>"); - - // It still binds where it is in scope. - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " def (dep, x) = ['" + pin + "', 'x']\n" - + " implementation(dep)\n")), - "at the top level it binds"); - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " if (true) {\n def (dep, x) = ['" + pin + "', 'x']\n" - + " implementation(dep)\n }\n")), - "and inside the block it binds for the block"); - } - - /** - * A configuration is never reached through a receiver, which is what makes - * an unqualified call a declaration -- but Groovy's output helpers are - * unqualified too, so {@code println('g:a:1.7.22!!')} read as a strict pin - * and stood the block down for a string the app was only logging. - */ - @Test - public void anOutputHelperIsNotAConfiguration() { - String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!"; - String[] printing = { - " println('" + pin + "')\n", - " print('" + pin + "')\n", - " printf('" + pin + "')\n", - }; - for (int i = 0; i < printing.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", printing[i]) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + printing[i].trim() + ">> declares nothing"); - } - - // Any other unqualified call is still a configuration, because an app - // may call one anything. - String[] declaring = { - " implementation('" + pin + "')\n", - " myCustomConfig('" + pin + "')\n", - }; - for (int i = 0; i < declaring.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - declaring[i]); - check("".equals(out), "<<" + declaring[i].trim() + ">> is a declaration, " - + "got <<" + out + ">>"); - } - } - - /** - * Groovy's multiple assignment binds several names at once. The walk for a - * single declaration expects an identifier after {@code def} and finds a - * parenthesis, so it recorded nothing and the pin one of the names carried - * was invisible. - */ - @Test - public void aMultipleAssignmentBindsEveryName() { - // The coordinate in the list is SOFT and the pin is at the use site, so - // the binding is the only thing that can connect them. Putting the pin - // in the list instead makes the list itself suppress, and the test then - // passes whether the names are bound or not -- which is how the first - // version of this went vacuous. - String coord = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22"; - String use = " implementation(dep) { version { strictly '1.7.22' } }\n"; - String[] destructured = { - " def (other, dep) = ['com.example:x:1.0', '" + coord + "']\n", - " def (dep, other) = ['" + coord + "', 'com.example:x:1.0']\n", - // Each name may carry a type, as a single declaration may. - " def (String other, String dep) = ['com.example:x:1.0', '" - + coord + "']\n", - }; - for (int i = 0; i < destructured.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - destructured[i] + use); - check("".equals(out), "<<" + destructured[i].trim() - + ">> binds dep, got <<" + out + ">>"); - } - - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " def (other, dep) = ['com.example:x:1.0', " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22']\n" - + " implementation(dep)\n") - .contains("kotlin-stdlib-jdk7:1.8.0"), - "a merged-era element is read too"); - // A list this cannot read binds every name to something unknown, which - // is what recording nothing already means. - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " def (other, dep) = someCall()\n" - + " implementation(dep)\n") - .contains("kotlin-stdlib-jdk7:1.8.0"), - "an unreadable list binds nothing"); - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = '" + coord + "'\n" + use)), - "and a plain def still works"); - } - - /** - * Two identifiers in a row are as often a parenthesis-free call as a typed - * local. Read as a declaration, {@code println dep} cleared the very binding - * it was printing, so the pin that name carried was gone by the time - * anything used it. - */ - @Test - public void aCommandCallDoesNotClearItsArgument() { - String coord = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22"; - String use = " implementation(dep) { version { strictly '1.7.22' } }\n"; - String[] kept = { - " def dep = '" + coord + "'\n println dep\n", - " def dep = '" + coord + "'\n logger dep\n", - " def dep = '" + coord + "'\n", - // A real typed declaration WITH a value still binds. - " String dep = '" + coord + "'\n", - }; - for (int i = 0; i < kept.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - kept[i] + use); - check("".equals(out), "<<" + kept[i].trim() + ">> keeps dep bound, got <<" - + out + ">>"); - } - - // And `def` with no value is still a name this knows about, which is what - // lets the assignment below it be recognised as one. - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep\n if (legacy) { dep = '" + coord + "' }\n" - + use)), - "a valueless def still introduces the name"); - } - - /** - * A conditional swap between two coordinates of this family is a choice - * between two of ours, and which arm runs is not readable here. Taking the - * replacement let {@code def dep = '..jdk8:1.7.22'} followed by - * {@code if (useNew) dep = '..jdk8:1.9.22'} read as merged-era, so the - * declaration below it needed no constraint -- and with the condition false - * the class-bearing 1.7.22 jar is still there. - */ - @Test - public void aConditionalSwapKeepsTheLowerCoordinate() { - String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; - String use = " implementation(dep)\n"; - String[] swaps = { - " def dep = '" + jdk8 + ":1.7.22'\n" - + " if (useNew) dep = '" + jdk8 + ":1.9.22'\n", - " def dep = '" + jdk8 + ":1.7.22'\n" - + " if (useNew) {\n dep = '" + jdk8 + ":1.9.22'\n }\n", - // The same choice written the other way round. Its assignment sits - // after a header with no brace, which was not read as an assignment - // at all -- so the name kept the merged-era value it started with. - " def dep = '" + jdk8 + ":1.9.22'\n" - + " if (legacy) dep = '" + jdk8 + ":1.7.22'\n", - }; - for (int i = 0; i < swaps.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - swaps[i] + use); - check(out.contains("kotlin-stdlib-jdk7:1.8.0") - && out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the pre-merge arm may be the live one, so the shim is raised; " - + "got <<" + out + ">>"); - } - - // Two merged-era coordinates leave the artifact to the app, and an - // UNCONDITIONAL raise still replaces what it replaces. - String[] settled = { - " def dep = '" + jdk8 + ":1.9.22'\n" - + " if (useNew) dep = '" + jdk8 + ":1.9.24'\n", - " def dep = '" + jdk8 + ":1.7.22'\n dep = '" + jdk8 + ":1.9.22'\n", - }; - for (int i = 0; i < settled.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - settled[i] + use); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0") - && out.contains("kotlin-stdlib-jdk7:1.8.0"), - "a merged-era binding stands in for its own constraint, got <<" - + out + ">>"); - } + " implementation 'androidx.appcompat:appcompat:1.6.1'\n", + " implementation 'com.android.billingclient:billing:9.1.0'\n"); + assertTrue(out.contains("'" + JDK7 + ":1.8.0'"), "jdk7 is raised: " + out); + assertTrue(out.contains("'" + JDK8 + ":1.8.0'"), "jdk8 is raised: " + out); + assertTrue(out.startsWith(" constraints {"), "as a constraints block: " + out); + assertTrue(out.contains("because 'Codename One:"), + "with a because, which is what dependencyInsight prints: " + out); } - /** - * Whether a keyword was CALLED settles which one speaks, and what it was - * called with is a separate question. Falling through on a null let - * {@code require '1.9.22'; strictly providers.gradleProperty('k').get()} - * report the requirement, so a shim whose strict version may be pre-merge - * read as merged-era and only its sibling was raised. - */ + /** A constraint pulls nothing into a graph that does not have it. */ @Test - public void aCalledKeywordSettlesItReadableOrNot() { - String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('" + jdk8 + "') { version { " - + "require '1.9.22'; strictly providers" - + ".gradleProperty('legacy').get() } }\n")), - "an unreadable strictly is not the requirement beside it"); - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.eachDependency " - + "{ d ->\n if (d.requested.name == " - + "'kotlin-stdlib-jdk8') { d.useVersion someProperty }\n" - + " } }\n")), - "and neither is an unreadable useVersion"); - - // A readable one still overrides the requirement beside it. - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('" + jdk8 + "') { version { " - + "require '1.7.22'; strictly '1.9.22' } }\n") - .contains("kotlin-stdlib-jdk7:1.8.0"), - "a readable strictly speaks for the declaration"); + void anEmptyProjectStillGetsTheFloor() { + String out = KotlinStdlibAlignment.constraintsBlock("implementation", ""); + assertTrue(out.contains(":1.8.0"), "the block is written unconditionally"); } /** - * Groovy accepts parentheses around a stored value, and one that did not - * START with a literal was recorded as unknown -- so the pin it held was - * invisible to whatever used the name. + * The constraint goes on the configuration the caller is already using, so a + * legacy {@code compile} project stays consistent with itself. */ @Test - public void aStoredValueMayBeWrapped() { - String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; - String use = " implementation(dep)\n"; - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = ('" + jdk8 + ":1.7.22!!')\n" + use)), - "one pair of parentheses"); - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = (( '" + jdk8 + ":1.7.22!!' ))\n" + use)), - "and two, with spaces"); - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = ('" + jdk8 + ":1.9.22')\n" + use) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "a wrapped merged-era value is read too"); + void theConstraintFollowsTheCallersConfiguration() { + assertTrue(KotlinStdlibAlignment.constraintsBlock("compile", "") + .contains("compile('" + JDK7 + ":1.8.0')"), "compile"); + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", "") + .contains("implementation('" + JDK7 + ":1.8.0')"), "implementation"); + assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock(null, "")), + "and no configuration means no block"); + assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock(" ", "")), + "nor does a blank one"); } /** - * The two android fragments run inside ONE {@code android { }} closure in - * the generated script -- androidx directly in it, the default config in its - * defaultConfig block. A synthetic closure each made a scope boundary Gradle - * does not have, so a name the first defined was gone before the second used - * it. + * An ordinary version is a SOFT requirement in Gradle: the constraint raises + * it and the two agree. Declaring the shim is therefore not a reason to + * stand down -- if it were, the app that declares an old one directly would + * keep the duplicate this exists to remove. */ @Test - public void theAndroidFragmentsShareOneClosure() throws Exception { - String shared = KotlinStdlibAlignment.constraintsBlock("implementation", - "android {\n" - + "def dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!'\n" - + "\ndefaultConfig {\n" - + "project.dependencies.add('implementation', dep)\n" - + "\n}\n}\n"); - check("".equals(shared), - "the name survives into the default config, got <<" + shared + ">>"); - - // Handed over as a closure each, it does not -- which is what the builder - // was doing and what the source check below now forbids. - String split = KotlinStdlibAlignment.constraintsBlock("implementation", - "android {\ndef dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!'\n}\n", - "android {\ndefaultConfig {\nproject.dependencies.add(" - + "'implementation', dep)\n}\n}\n"); - check(!"".equals(split), "a closure each loses it, which is the bug"); - - // The half above proves the alignment honours the scope it is GIVEN. - // This half proves the builder gives it one: with a synthetic closure - // each the arguments are still in the right ORDER, so the enumeration - // test passes either way and only this catches it. - String builderSrc = new String(java.nio.file.Files.readAllBytes( - new java.io.File("src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()), "UTF-8"); - int at = builderSrc.indexOf("KotlinStdlibAlignment.constraintsBlock("); - check(at >= 0, "the builder calls the alignment"); - String call = builderSrc.substring(at, builderSrc.indexOf(";", at)) - .replaceAll("//[^\n]*", ""); - // Split at the commas that separate ARGUMENTS -- the ones outside - // parentheses -- and require a single argument to carry both hints. - java.util.List arguments = new java.util.ArrayList(); - int depth = 0; - int start = call.indexOf('(') + 1; - boolean quoted = false; - for (int i = start; i < call.length(); i++) { - char c = call.charAt(i); - if (quoted) { - if (c == '\\') { - i++; - } else if (c == '"') { - quoted = false; - } - continue; - } - if (c == '"') { - quoted = true; - } else if (c == '(') { - depth++; - } else if (c == ')') { - if (depth == 0) { - arguments.add(call.substring(start, i)); - break; - } - depth--; - } else if (c == ',' && depth == 0) { - arguments.add(call.substring(start, i)); - start = i + 1; - } - } - boolean together = false; - for (int i = 0; i < arguments.size(); i++) { - if (arguments.get(i).indexOf("android.gradle.androidx") >= 0 - && arguments.get(i).indexOf("android.xgradle_default_config") >= 0) { - together = true; - } - } - check(together, "the two android fragments are handed over in ONE closure, " - + "and the call splits them across arguments: " + arguments); + void anOrdinaryDeclarationIsRaisedNotHonoured() { + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation '" + JDK8 + ":1.7.22'\n") + .contains(":1.8.0"), + "a pre-merge declaration is raised"); + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation '" + JDK8 + ":1.9.22'\n") + .contains(":1.8.0"), + "and a merged-era one is unaffected by a floor beneath it"); } /** - * The shapes this round found, each a valid Gradle spelling that read as - * something it is not. + * The one thing a constraint at the floor can break: an app that firmly + * holds a member of the family below it resolves coherently today, and a + * constraint requiring 1.8.0 turns that into a resolution failure. */ @Test - public void everySelectorAndHandlerSpellingIsRead() { - String open = " configurations.all {\n resolutionStrategy {\n" - + " componentSelection {\n"; - String close = " }\n }\n }\n"; - - // A closure passed in parentheses is the same call as a trailing one. - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy" - + ".componentSelection({ rules ->\n rules.all { s -> if " - + "(s.candidate.module == 'kotlin-stdlib-jdk8') " - + "s.reject('x') }\n }) }\n")), - "a parenthesised componentSelection is still one"); - - // A rule keyed on another Kotlin module cannot reject either shim, so - // matching the group prefix alone gave away the alignment for nothing. - check(KotlinStdlibAlignment.constraintsBlock("implementation", - open + " withModule('org.jetbrains.kotlin:" - + "kotlin-reflect') { s -> s.reject('x') }\n" + close) - .contains("kotlin-stdlib-jdk8:1.8.0"), - "a rule on kotlin-reflect touches neither shim"); - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - open + " withModule('org.jetbrains.kotlin:" - + "kotlin-stdlib-jdk8') { s -> s.reject('x') }\n" + close)), - "and one on a shim still stands the block down"); - - // The constraint handler takes a configuration and a notation too. - String[] handlers = { - " constraints.add('implementation', " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')\n", - " dependencies.constraints.add('implementation', " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')\n", + void anAppThatPinsTheFamilyIsLeftAlone() { + String[] pinned = { + " implementation '" + JDK8 + ":1.7.22!!'\n", + " implementation('" + JDK8 + "') { version { strictly '1.7.22' } }\n", + " configurations.all { resolutionStrategy.force '" + JDK8 + ":1.7.22' }\n", + " implementation('" + JDK8 + "') { version { reject '[1.8.0,)' } }\n", + " implementation(enforcedPlatform(" + + "'org.jetbrains.kotlin:kotlin-stdlib-bom:1.7.22'))\n", + " configurations.all { resolutionStrategy.eachDependency { d ->\n" + + " if (d.requested.name == 'kotlin-stdlib') " + + "d.useVersion '1.7.22'\n } }\n", + " configurations.all { resolutionStrategy.componentSelection { all { s ->\n" + + " if (s.candidate.module == 'kotlin-stdlib-jdk8') " + + "s.reject('x')\n } } }\n", + " configurations.all { resolutionStrategy.failOnVersionConflict() }\n" + + " implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n", }; - for (int i = 0; i < handlers.length; i++) { + for (int i = 0; i < pinned.length; i++) { String out = KotlinStdlibAlignment.constraintsBlock("implementation", - handlers[i]); - check("".equals(out), "<<" + handlers[i].trim() + ">> is a strict pin, " - + "got <<" + out + ">>"); + pinned[i]); + assertTrue("".equals(out), + "<<" + pinned[i].trim() + ">> holds the family, got <<" + out + ">>"); } - - // A subprojects block configures the children, not this application. - String children = KotlinStdlibAlignment.constraintsBlock("implementation", - " subprojects {\n dependencies {\n implementation(" - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22')\n }\n }\n"); - check(children.contains("kotlin-stdlib-jdk8:1.8.0"), - "a subproject declaration is not the app's, got <<" + children + ">>"); - - // allprojects DOES include this one, which is the distinction. - String every = KotlinStdlibAlignment.constraintsBlock("implementation", - " allprojects {\n dependencies {\n implementation(" - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22')\n }\n }\n"); - check(!every.contains("kotlin-stdlib-jdk8:1.8.0") - && every.contains("kotlin-stdlib-jdk7:1.8.0"), - "an allprojects declaration is the app's too, got <<" + every + ">>"); - } - - /** - * A selection rule may name its module by whole coordinate -- - * {@code withModule('org.jetbrains.kotlin:kotlin-stdlib-jdk8')} -- which is - * neither the bare artifact name nor the group on its own, so a rule written - * that way looked like it concerned nothing of ours. - */ - @Test - public void aSelectionRuleMayNameItsModuleByCoordinate() { - String open = " configurations.all {\n resolutionStrategy {\n" - + " componentSelection {\n"; - String close = " }\n }\n }\n"; - String ours = KotlinStdlibAlignment.constraintsBlock("implementation", - open + " withModule('org.jetbrains.kotlin:" - + "kotlin-stdlib-jdk8') { selection ->\n" - + " if (selection.candidate.version == '1.8.0') {\n" - + " selection.reject('unsupported')\n" - + " }\n }\n" + close); - check("".equals(ours), "the rule may reject the floor, got <<" + ours + ">>"); - - check(KotlinStdlibAlignment.constraintsBlock("implementation", - open + " withModule('com.squareup.okhttp3:" - + "okhttp') { selection ->\n" - + " selection.reject('unsupported')\n" - + " }\n" + close) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "a rule on another module rejects nothing this writes"); } /** - * A component-selection block holds a rule per {@code all { }}, and the - * predicate that names this family has to be in the SAME rule as the - * rejection. Accumulated across the block, a rule that merely mentions - * Kotlin paired up with a sibling that rejects something else, so the block - * stood down for a rejection that could not touch it -- which leaves the - * duplicate exactly where it was. + * Both halves are required, and the asymmetry is deliberate. A pinning word + * with no mention of this family cannot be pinning it; a mention with no + * pinning word is an ordinary declaration, which the constraint raises. */ @Test - public void aRejectionCountsOnlyInTheRuleThatNamesTheFamily() { - String open = " configurations.all {\n resolutionStrategy {\n" - + " componentSelection {\n"; - String close = " }\n }\n }\n"; - String logsKotlin = " all { s ->\n if " - + "(s.candidate.module == 'kotlin-stdlib') " - + "{ logger.info(s.candidate.version) }\n }\n"; - String rejectsOther = " all { s ->\n if " - + "(s.candidate.module == 'okhttp') " - + "{ s.reject('unsupported') }\n }\n"; - String rejectsOurs = " all { s ->\n if " - + "(s.candidate.module == 'kotlin-stdlib-jdk8') " - + "{ s.reject('unsupported') }\n }\n"; + void bothHalvesOfTheGuardAreRequired() { + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", + " configurations.all { resolutionStrategy.force " + + "'com.squareup.okhttp3:okhttp:4.0.0' }\n") + .contains(":1.8.0"), + "a force on someone else is not a pin on this family"); + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'\n") + .contains(":1.8.0"), + "and naming the family without pinning it is an ordinary declaration"); - check(KotlinStdlibAlignment.constraintsBlock("implementation", - open + logsKotlin + rejectsOther + close) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "neither rule can reject the floor"); - - String[] rejecting = { - open + rejectsOther + rejectsOurs + close, - open + rejectsOurs + close, - }; - for (int i = 0; i < rejecting.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - rejecting[i]); - check("".equals(out), "a rule that names and rejects stands the block " - + "down, got <<" + out + ">>"); - } + // It over-suppresses on purpose: the words are matched as plain text, so + // one in a comment or an unrelated string counts. That costs an app the + // duplicate it already had, which android.kotlinStdlibAlignment=false + // does deliberately; the other direction breaks a build that works. + assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + " // we used to force kotlin-stdlib here\n")), + "a pinning word in a comment stands it down, which is the safe way " + + "to be wrong"); } - /** - * A component-selection rule is normally written over several lines, and - * then its opener, its predicate and its reject are three statements. The - * one-statement reading saw none of them together, so a rule that removes - * the very version this writes left the constraint nothing to resolve to. - */ + /** The floor is the version at which the shims became empty. */ @Test - public void aComponentSelectionRuleIsReadAcrossItsWholeBody() { - String multiline = " configurations.all {\n resolutionStrategy {\n" - + " componentSelection {\n all { selection ->\n" - + " if (selection.candidate.module == " - + "'kotlin-stdlib-jdk8'\n" - + " && selection.candidate.version == " - + "'1.8.0') {\n" - + " selection.reject('unsupported')\n" - + " }\n }\n }\n" - + " }\n }\n"; - String out = KotlinStdlibAlignment.constraintsBlock("implementation", multiline); - check("".equals(out), "the rule may reject the floor, got <<" + out + ">>"); - - String[] harmless = { - // Rejecting something else. - " configurations.all {\n resolutionStrategy {\n" - + " componentSelection {\n all { s ->\n" - + " if (s.candidate.module == 'okhttp') {\n" - + " s.reject('unsupported')\n" - + " }\n }\n }\n" - + " }\n }\n", - // Rejecting nothing. - " configurations.all {\n resolutionStrategy {\n" - + " componentSelection {\n all { s ->\n" - + " logger.info(s.candidate.module)\n" - + " }\n }\n }\n }\n", - // On a configuration the constraint never reaches. - " configurations.create('tooling') {\n resolutionStrategy {\n" - + " componentSelection {\n all { if " - + "(it.candidate.group == 'org.jetbrains.kotlin') it.reject('x') }\n" - + " }\n }\n }\n", - }; - for (int i = 0; i < harmless.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", harmless[i]) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "rule " + i + " rejects nothing this writes"); - } - - // And the scan does not swallow what comes after a harmless rule. - check(!KotlinStdlibAlignment.constraintsBlock("implementation", - harmless[1] + " implementation 'org.jetbrains.kotlin:" - + "kotlin-stdlib-jdk8:1.9.22'\n") - .contains("kotlin-stdlib-jdk8:1.8.0"), - "a declaration after the rule is still read"); + void theFloorIsWhereTheClassesMoved() { + assertTrue("1.8.0".equals(KotlinStdlibAlignment.MERGED_STDLIB_FLOOR), + "1.8.0 is where kotlin-stdlib absorbed the jdk7/jdk8 classes"); } /** - * A call with no literal argument still HAPPENED, and what it set is - * unknown. Recorded as nothing at all, a conditional mixing an unreadable - * arm with a readable one looked like a single readable branch -- so the - * lowest was the arm that could be read, and the constraints went in beside - * a pin that may well be pre-merge. + * Null and empty fragments are ordinary input: the builder passes whatever + * hints the project happens to have, and most projects have none of them. */ @Test - public void anUnreadableArmIsAnAlternativeToo() { - String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; - String[] mixed = { - " implementation('" + jdk8 + "') { version { if (legacy) " - + "strictly providers.gradleProperty('k').get() " - + "else strictly '1.9.22' } }\n", - " implementation('" + jdk8 + "') { version { legacy ? " - + "strictly(providers.gradleProperty('k').get()) " - + ": strictly('1.9.22') } }\n", - }; - for (int i = 0; i < mixed.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - mixed[i]); - check("".equals(out), "the unreadable arm may be the live one, got <<" - + out + ">>"); - } - - // A SEQUENCE ending in a readable call is still read: there the last one - // wins, and it is known. - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('" + jdk8 + "') { version { " - + "strictly providers.gradleProperty('k').get(); " - + "strictly '1.9.22' } }\n") - .contains("kotlin-stdlib-jdk7:1.8.0"), - "a sequence ending readable is read"); + void missingFragmentsAreNotAnError() { + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", + (String[]) null).contains(":1.8.0"), + "no fragments at all"); + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", + null, "", null).contains(":1.8.0"), + "and a mix of null and empty ones"); } /** - * A component-selection rule rejects CANDIDATES, outside any declaration, so - * the rejection reading that lives on a declaration never saw it. Such a rule - * can remove the very version this writes, and the constraint then has - * nothing to resolve to. + * Every fragment the app controls has to reach the scan. A hint that is + * added to the generated script and not passed here is a pin this cannot + * see, which is the one way to get the dangerous answer. */ @Test - public void aComponentSelectionRuleMayRejectTheFloor() { - String[] rejecting = { - " configurations.all { resolutionStrategy.componentSelection { all { " - + "if (it.candidate.module == 'kotlin-stdlib-jdk8' && " - + "it.candidate.version == '1.8.0') it.reject('unsupported') } } }\n", - " configurations.all { resolutionStrategy.componentSelection { all { " - + "if (it.candidate.group == 'org.jetbrains.kotlin') " - + "it.reject('unsupported') } } }\n", + void theBuilderPassesEveryAppControlledFragment() throws Exception { + String src = new String(java.nio.file.Files.readAllBytes(new java.io.File( + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java") + .toPath()), "UTF-8"); + int at = src.indexOf("KotlinStdlibAlignment.constraintsBlock("); + assertTrue(at >= 0, "the builder calls the alignment"); + String call = src.substring(at, src.indexOf(";", at)); + String[] hints = { + "android.gradlePlugin", "android.gradle.androidx", + "android.xgradle_default_config", "android.supportv4Dep", + "android.gradleDep", "android.xgradle", }; - for (int i = 0; i < rejecting.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - rejecting[i]); - check("".equals(out), "a rule that may reject the floor stands the block " - + "down, got <<" + out + ">>"); + for (String hint : hints) { + // With the quotes. One hint name is a prefix of another, so a bare + // contains() stayed true after the argument was deleted. + assertTrue(call.contains("\"" + hint + "\""), + "the alignment is not told about the " + hint + + " hint, which reaches the generated script"); } - - // A rule that rejects nothing, one that does not mention this family, and - // one on a configuration the constraint never reaches all leave it alone. - String[] harmless = { - " configurations.all { resolutionStrategy.componentSelection { all { " - + "logger.info(it.candidate.module) } } }\n", - " configurations.all { resolutionStrategy.componentSelection { all { " - + "if (it.candidate.module == 'okhttp') it.reject('x') } } }\n", - " configurations.create('tooling').resolutionStrategy" - + ".componentSelection { all { if (it.candidate.group == " - + "'org.jetbrains.kotlin') it.reject('x') } }\n", + String[] locals = { + "kotlinRuntimeDependency", "additionalDependencies", + "aiExtraGradleDependencies", "aarDependencies", "injectRepo", + "gradleDependency", }; - for (int i = 0; i < harmless.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", harmless[i]) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + harmless[i].trim() + ">> rejects nothing this writes"); + for (String local : locals) { + assertTrue(call.contains(local), + "the alignment is not told about " + local + + ", which reaches the generated script"); } } /** - * A value that is a NAME rather than a literal copies a binding that is - * already known. Reading only literals recorded the new name as unknown, so - * a force through it named nothing and the constraints went in beside a pin - * still in effect. + * The alignment is an optimisation over a build that already worked apart + * from one duplicate class, and it runs on every AndroidX build -- so its + * worst case has to be "emit nothing", never a failed build. */ @Test - public void aDefinitionMayCopyAnotherOne() { - String pre = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22"; - String force = " configurations.all { resolutionStrategy.force forced }\n"; - String[] copies = { - " def coord = '" + pre + "'\n def forced = coord\n", - " def coord = '" + pre + "'\n ext.set('forced', coord)\n", - " def coord = '" + pre + "'\n ext.forced = coord\n", - " def a = '" + pre + "'\n def b = a\n def forced = b\n", + void theAlignmentCannotFailTheBuild() { + String[] hostile = { + null, "", " ", "'", "{", "}", "(((", ")))", + "implementation '", "kotlin-stdlib", "!!", "strictly", + "kotlin-stdlib strictly", }; - for (int i = 0; i < copies.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - copies[i] + force); - check("".equals(out), "the copy carries the coordinate, got <<" - + out + ">>"); + for (int i = 0; i < hostile.length; i++) { + KotlinStdlibAlignment.constraintsBlock("implementation", hostile[i]); + KotlinStdlibAlignment.appPinsTheStdlibFamily(hostile[i]); } - - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " def coord = 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'\n" - + " def forced = coord\n" + force) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "a merged-era one leaves the alignment alone"); - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " def forced = whateverThisIs\n" + force) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "and copying something unknown binds nothing"); - } - - /** - * A plain coordinate version is a SOFT requirement in Gradle, exactly as a - * rich {@code require} is, and below the floor the constraint raises it. So - * an app that declares an old shim directly is aligned rather than left - * alone: standing the block down there, or skipping that artifact's own - * constraint, kept the pre-merge shim beside whatever selected a merged-era - * base -- the duplicate this exists to prevent, in the graph it exists for. - */ - @Test - public void aSoftPreMergeDeclarationIsRaisedRatherThanHonoured() { - String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; - String[] soft = { - " implementation '" + jdk8 + ":1.7.22'\n", - " implementation('" + jdk8 + ":1.7.22')\n", - " implementation group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk8', version: '1.7.22'\n", - " implementation('" + jdk8 + "') { version { require '1.7.22' } }\n", - }; - for (int i = 0; i < soft.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - soft[i]); - check(out.contains("kotlin-stdlib-jdk7:1.8.0") - && out.contains("kotlin-stdlib-jdk8:1.8.0"), - "<<" + soft[i].trim() + ">> is raised, and its sibling with it, " - + "got <<" + out + ">>"); - } - - // Anything that really PINS it still stands the block down, because the - // constraint cannot raise those. - String[] firm = { - " implementation '" + jdk8 + ":1.7.22!!'\n", - " implementation('" + jdk8 + "') { version { strictly '1.7.22' } }\n", - " configurations.all { resolutionStrategy.force '" + jdk8 + ":1.7.22' }\n", - " implementation('" + jdk8 + "') { version { require '1.+'; " - + "reject '[1.8.0,)' } }\n", - // A range is satisfied or it is not: `[1.0,1.5]` and 1.8.0 have no - // version in common, so it cannot be raised either. - " implementation '" + jdk8 + ":[1.0,1.5]'\n", - // And a version this cannot read says nothing about what it will be. - " implementation \"" + jdk8 + ":$mystery\"\n", - }; - for (int i = 0; i < firm.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - firm[i]); - check("".equals(out), "<<" + firm[i].trim() - + ">> cannot be raised, got <<" + out + ">>"); - } - - // At or above the floor a soft version already satisfies the constraint, - // so that artifact is still left to the app and only its sibling raised. - String merged = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation '" + jdk8 + ":1.9.22'\n"); - check(merged.contains("kotlin-stdlib-jdk7:1.8.0") - && !merged.contains("kotlin-stdlib-jdk8:1.8.0"), - "a merged-era declaration stands in for its own constraint, got <<" - + merged + ">>"); - } - - /** - * A bare carriage return ends a line in Groovy exactly as a newline does. - * The comment scan learned that; the statement splitter had not, so every - * statement of a CR-only fragment merged into one and a main-variant - * configuration paired with a debug-only coordinate. - */ - @Test - public void aBareCarriageReturnSeparatesStatements() { - String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; - String separate = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'com.example:other:1.0'\r" - + " debugImplementation '" + jdk8 + ":1.9.22'\r"); - check(separate.contains("kotlin-stdlib-jdk8:1.8.0"), - "a debug-only coordinate is not the main declaration, got <<" - + separate + ">>"); - - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'com.example:other:1.0'\r" - + " implementation '" + jdk8 + ":1.7.22!!'\r")), - "and a pin on its own CR-terminated line is read"); - - // CRLF is one break, not two. - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'com.example:other:1.0'\r\n" - + " debugImplementation '" + jdk8 + ":1.9.22'\r\n") - .contains("kotlin-stdlib-jdk8:1.8.0"), - "CRLF does not split twice"); - - // Everything that continues a statement across a newline continues it - // across a carriage return. - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation \\\r '" + jdk8 + ":1.7.22!!'\r")), - "a line continuation still continues"); - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.eachDependency " - + "{ d ->\r if (d.requested.name == 'kotlin-stdlib')\r" - + " d.useVersion '1.7.22'\r } }\r")), - "an unbraced body still joins its condition"); - } - - /** - * Gradle orders a shortened version below a longer one: {@code 1.8} is below - * {@code 1.8.0}. Padding the missing segment with zero called them equal, so - * a strict range that stops just short of the floor looked like it admitted - * it and the constraints went into a graph that cannot resolve them. - */ - @Test - public void aShortenedUpperBoundStopsShortOfTheFloor() { - String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; - String[] capped = { - " implementation('" + jdk8 + "') { version { strictly '[1.7,1.8]' } }\n", - " implementation('" + jdk8 + "') { version { strictly '[1.7,1.8.0)' } }\n", - }; - for (int i = 0; i < capped.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - capped[i]); - check("".equals(out), "<<" + capped[i].trim() - + ">> cannot admit the floor, got <<" + out + ">>"); - } - - String[] reaching = { - " implementation('" + jdk8 + "') { version { strictly '[1.7,1.8.0]' } }\n", - " implementation('" + jdk8 + "') { version { strictly '[1.7,1.9]' } }\n", - }; - for (int i = 0; i < reaching.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", - reaching[i]).contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + reaching[i].trim() + ">> admits the floor"); - } - } - - /** - * A ternary chooses between its arms exactly as an if/else does, and so does - * an elvis. Read as a sequence, only the last setter counted -- so the arm - * holding a strict pre-merge version was passed over. - */ - @Test - public void aTernaryChoosesBetweenVersionsToo() { - String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; - String[] branched = { - " implementation('" + jdk8 + "') { version { " - + "legacy ? strictly('1.7.22') : strictly('1.9.22') } }\n", - // A switch arm is an alternative like any other. - " implementation('" + jdk8 + "') { version { switch (mode) { " - + "case 'legacy': strictly '1.7.22'; break; " - + "default: strictly '1.9.22' } } }\n", - " implementation('" + jdk8 + "') { version { switch (mode) { " - + "case 'modern': strictly '1.9.22'; break; " - + "default: strictly '1.7.22' } } }\n", - " implementation('" + jdk8 + "') { version { " - + "legacy ? strictly('1.9.22') : strictly('1.7.22') } }\n", - " implementation('" + jdk8 + "') { version { " - + "legacy ?: strictly('1.9.22') ; strictly '1.7.22' } }\n", - }; - for (int i = 0; i < branched.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - branched[i]); - check("".equals(out), "either arm may run, got <<" + out + ">>"); - } - - // Safe navigation is the one question mark that chooses nothing, so the - // setters either side of it stay a sequence and the last one wins. Both - // versions are readable on purpose: an unreadable one would suppress for - // a different reason and test nothing. - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('" + jdk8 + "') { version { " - + "strictly '1.7.22'; strictly '1.9.22' } " - + "because project?.name.toString() }\n") - .contains("kotlin-stdlib-jdk7:1.8.0"), - "safe navigation is not a branch"); - } - - /** - * Groovy ends a line at a bare carriage return too. Searching only for the - * newline swallowed the whole remainder of a CR-only fragment as part of a - * line comment -- including the strict pin that followed it. - */ - @Test - public void aLineCommentEndsAtEitherTerminator() { - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " // explanation\r implementation(" - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\r")), - "the pin after a CR-terminated comment is still read"); - - // And the comment still hides what is on ITS own line. - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " // implementation 'org.jetbrains.kotlin:" - + "kotlin-stdlib-jdk8:1.7.22!!'\n implementation " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n") - .contains("kotlin-stdlib-jdk7:1.8.0"), - "a commented-out pin is still a comment"); - } - - /** - * An unqualified call has to be one of the dependency handler's own adders. - * Matched by prefix, any helper an app had defined -- {@code def addNote = { - * config, text -> .. }} -- declared a dependency as far as this was - * concerned, and that artifact's constraint was skipped as already handled. - */ - @Test - public void anUnqualifiedAdderIsNamedNotPrefixed() { - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " def addNote = { config, text -> println text }\n" - + " addNote('implementation', 'org.jetbrains.kotlin:" - + "kotlin-stdlib-jdk8:1.9.22')\n") - .contains("kotlin-stdlib-jdk8:1.8.0"), - "an app helper declares nothing"); - - String pin = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!"; - String[] adders = { - " dependencies {\n add 'implementation', '" + pin + "'\n }\n", - " dependencies {\n addProvider 'implementation', '" - + pin + "'\n }\n", - // A qualified call does not consult the name at all, so a handler - // that grows a fourth adder keeps working through its receiver. - " dependencies.whateverTheyAddNext('implementation', '" + pin + "')\n", - }; - for (int i = 0; i < adders.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - adders[i]); - check("".equals(out), "<<" + adders[i].trim() + ">> declares, got <<" - + out + ">>"); - } - } - - /** - * {@code ext.set('dep', '...')} is the extension's own setter, and the name - * is its first argument. Read as a dotted assignment it recorded a property - * called {@code set} and lost the real one, so a later reference through the - * bare name named nothing -- and the constraints went in beside a force that - * was still in effect. - */ - @Test - public void theExtraPropertiesSetterBindsItsFirstArgument() { - String pre = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22"; - String use = " configurations.all { resolutionStrategy.force stdlib }\n"; - String[] setters = { - " ext.set('stdlib', '" + pre + "')\n", - " project.ext.set('stdlib', '" + pre + "')\n", - " ext.set(\"stdlib\", \"" + pre + "\")\n", - }; - for (int i = 0; i < setters.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - setters[i] + use); - check("".equals(out), "<<" + setters[i].trim() - + ">> binds stdlib, got <<" + out + ">>"); - } - - // Merged-era through the same setter leaves the alignment to be written. - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " ext.set('stdlib', 'org.jetbrains.kotlin:" - + "kotlin-stdlib:1.9.22')\n" + use) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "a merged-era force leaves the alignment alone"); - - // The property is bound under its own name, and no property called `set` - // is created. A soft coordinate emits either way -- the constraint raises - // it -- so the strict spelling is what shows the binding. - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " ext.set('stdlib', '" + pre + "!!')\n" - + " implementation stdlib\n")), - "the name carries the strict pin"); - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " ext.set('stdlib', '" + pre + "!!')\n" - + " implementation set\n") - .contains("kotlin-stdlib-jdk7:1.8.0"), - "and nothing is bound under the setter's own name"); - - // A set() on something that is not the extension binds nothing. - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " someMap.set('stdlib', '" + pre + "')\n" + use) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "someMap.set is not the extension"); - } - - /** - * Two calls one after another are a sequence and the last wins; two in the - * arms of a conditional are alternatives, and which one runs is not readable - * here. Taking the last of THOSE wrote the shim constraints beside a strict - * pre-merge pin that may well be the live branch. - */ - @Test - public void aConditionalMakesTheCallsAlternatives() { - String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; - String[] branched = { - " implementation('" + jdk8 + "') { version { " - + "if (legacy) strictly '1.7.22' else strictly '1.9.22' } }\n", - " implementation('" + jdk8 + "') { version { " - + "if (legacy) strictly '1.9.22' else strictly '1.7.22' } }\n", - }; - for (int i = 0; i < branched.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - branched[i]); - check("".equals(out), "either arm may run, got <<" + out + ">>"); - } - - // A plain sequence still keeps what it was set to last, in both - // directions -- that is what makes this about branches and not about - // taking the lowest version anywhere. - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('" + jdk8 + "') { version { " - + "strictly '1.9.22'; strictly '1.7.22' } }\n")), - "a sequence ending pre-merge stands the block down"); - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('" + jdk8 + "') { version { " - + "strictly '1.7.22'; strictly '1.9.22' } }\n") - .contains("kotlin-stdlib-jdk7:1.8.0"), - "and one ending merged-era does not"); - } - - /** - * A rich version that is PRESENT but unreadable is not an invitation to read - * the coordinate instead. Reported as merged-era, such a declaration had its - * own constraint skipped as satisfied while the sibling was raised around it. - */ - @Test - public void anUnreadableStrictVersionIsNotTheCoordinate() { - String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('" + jdk8 + ":1.9.22') { version { " - + "strictly providers.gradleProperty('legacy').get() } }\n")), - "an unreadable strictly is not the coordinate's version"); - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.eachDependency " - + "{ d ->\n if (d.requested.name == " - + "'kotlin-stdlib-jdk8') d.useVersion someProperty\n } }\n")), - "and neither is an unreadable useVersion"); - - // A readable one still overrides the coordinate, which is the case that - // put the rich reading here in the first place. - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('" + jdk8 + ":1.7.22') { version { " - + "strictly '1.9.22' } }\n") - .contains("kotlin-stdlib-jdk7:1.8.0"), - "a readable strictly is read past the coordinate"); - } - - /** - * A configuration the app made can still INHERIT the constraint. - * {@code configurations.create('tooling').extendsFrom(configurations - * .implementation)} is not an independent graph, and reading only the name - * it was created under exempted it -- so the constraints went into a graph - * that then fails on the version they raise. - */ - @Test - public void aCustomConfigurationMayInheritTheConstraint() { - String conflict = ".resolutionStrategy.failOnVersionConflict()\n"; - String[] inheriting = { - " configurations.create('tooling').extendsFrom(" - + "configurations.implementation)" + conflict, - " configurations.create('tooling').extendsFrom(" - + "configurations.api)" + conflict, - " configurations.create('tooling').extendsFrom(" - + "configurations.getByName('implementation'))" + conflict, - " configurations.tooling.extendsFrom(" - + "configurations.implementation)" + conflict, - }; - for (int i = 0; i < inheriting.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - inheriting[i]); - check("".equals(out), "<<" + inheriting[i].trim() - + ">> inherits what these constrain, got <<" + out + ">>"); - } - - // Extending something the constraint is NOT on does not inherit it, or - // the exemption would cover nothing at all. - String[] independent = { - " configurations.create('tooling').extendsFrom(" - + "configurations.compileOnly)" + conflict, - " configurations.create('tooling').extendsFrom(" - + "configurations.other)" + conflict, - " configurations.create('tooling')" + conflict, - " configurations.tooling" + conflict, - }; - for (int i = 0; i < independent.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", - independent[i]).contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + independent[i].trim() + ">> governs another graph"); - } - } - - /** - * The resolvable classpaths EXTEND the constrained configurations and are - * where a resolution strategy actually runs, so a conflict check on one - * governs the graph these constraints are resolved in. - */ - @Test - public void aResolvableClasspathInheritsTheConstraint() { - String conflict = ".resolutionStrategy.failOnVersionConflict()\n"; - String[] inheriting = { - " configurations.releaseRuntimeClasspath" + conflict, - " configurations.runtimeClasspath" + conflict, - " configurations.debugCompileClasspath" + conflict, - " configurations.getByName('releaseRuntimeClasspath')" + conflict, - }; - for (int i = 0; i < inheriting.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - inheriting[i]); - check("".equals(out), "<<" + inheriting[i].trim() - + ">> resolves what these constrain, got <<" + out + ">>"); - } - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.tooling" + conflict) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "a configuration that is neither still governs another graph"); - } - - /** - * Groovy's explicit line continuation joins two physical lines into one - * statement. Split at the newline, the configuration had no dependency and - * the coordinate had no configuration, so neither said anything. - */ - @Test - public void anEscapedNewlineContinuesTheStatement() { - String jdk8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation \\\n '" + jdk8 + ":1.7.22!!'\n")), - "the continued statement carries its strict pin"); - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation \\\n '" + jdk8 + ":1.9.22'\n") - .contains("kotlin-stdlib-jdk7:1.8.0"), - "and a merged-era one is read as the declaration it is"); - } - - /** - * Groovy accepts a redundant parenthesis, and the walk stepped over one. - * Looking at the other it found something that is not an identifier, so the - * strict pin read as nobody's argument and the constraints went in against - * it. - */ - @Test - public void aRedundantParenthesisIsStillTheSameArgument() { - String pin = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!"; - String[] declarations = { - " implementation(('" + pin + "'))\n", - " implementation((('" + pin + "')))\n", - " implementation( ( '" + pin + "' ) )\n", - " implementation('" + pin + "')\n", - " implementation '" + pin + "'\n", - // A later argument reaches the comma only once the parentheses are - // behind it, and the enclosing call is the one with a NAME in front - // of it -- a redundant pair has none, so the search goes outward. - " dependencies.add('implementation', ('" + pin + "'))\n", - " dependencies.addProvider('implementation', " - + "providers.provider { ('" + pin + "') })\n", - }; - for (int i = 0; i < declarations.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - declarations[i]); - check("".equals(out), "<<" + declarations[i].trim() - + ">> declares a strict pin, got <<" + out + ">>"); - } - - // Wrapping something in parentheses does not make it a declaration. - String[] strangers = { - " (('" + pin + "'))\n", - " logger.lifecycle(('" + pin + "'))\n", - " myList.add('implementation', ('" + pin + "'))\n", - " def all = [('" + pin + "')]\n", - }; - for (int i = 0; i < strangers.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", - strangers[i]).contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + strangers[i].trim() + ">> declares nothing"); - } - } - - /** - * The dependency handler has three adders and may grow more, and the - * coordinate one of them is handed may sit inside a provider closure. Only - * {@code add} with the coordinate as a direct argument was read, so Gradle's - * provider form declared nothing as far as this was concerned. - */ - @Test - public void theDependencyHandlerHasMoreThanOneAdder() { - String pin = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!"; - String[] declarations = { - " dependencies.addProvider('implementation', " - + "providers.provider { '" + pin + "' })\n", - " dependencies.addProvider('implementation', '" + pin + "')\n", - " dependencies.addProviderBundle('implementation', '" + pin + "')\n", - " dependencies {\n addProvider 'implementation', '" - + pin + "'\n }\n", - " dependencies.add('implementation', '" + pin + "')\n", - " dependencies {\n add 'implementation', '" + pin + "'\n }\n", - " implementation(providers.provider { '" + pin + "' })\n", - }; - for (int i = 0; i < declarations.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - declarations[i]); - check("".equals(out), "<<" + declarations[i].trim() - + ">> declares a strict pin, got <<" + out + ">>"); - } - - // The name matters where there is no receiver to check, which is the - // shorthand inside a dependencies closure: read as a declaration, the - // artifact it names is left to the app and only its sibling is raised. - String merged = KotlinStdlibAlignment.constraintsBlock("implementation", - " dependencies {\n addProvider 'implementation', " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n }\n"); - check(merged.contains("kotlin-stdlib-jdk7:1.8.0") - && !merged.contains("kotlin-stdlib-jdk8:1.8.0"), - "a bare addProvider declares its artifact, got <<" + merged + ">>"); - - // The receiver still decides for a qualified call, and an unqualified one - // still has to look like an adder. Neither a list nor a version catalog - // declares anything, and a literal that is nobody's argument declares - // nothing either. - String[] strangers = { - " myList.add('implementation', '" + pin + "')\n", - " catalog.add('implementation', '" + pin + "')\n", - " logger.lifecycle('" + pin + "')\n", - " def all = ['" + pin + "']\n", - " def make = { '" + pin + "' }\n", - }; - for (int i = 0; i < strangers.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", - strangers[i]).contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + strangers[i].trim() + ">> declares nothing"); - } - } - - /** - * Every spelling of "which configuration" goes through one reading, because - * they are the same question. A lookup carries the name as a string, a - * filter carries a closure and says nothing -- so a selector nobody - * anticipated reads as "cannot say", which keeps the constraint out of a - * graph that would fail on it. - */ - @Test - public void everySpellingOfAConfigurationIsReadTheSameWay() { - String conflict = ".resolutionStrategy.failOnVersionConflict()\n"; - String[] reaching = { - " configurations.all { resolutionStrategy.failOnVersionConflict() }\n", - " configurations.configureEach { resolutionStrategy" - + ".failOnVersionConflict() }\n", - // A filter may select the constrained graph and there is no name to - // say otherwise. Reading one as "some other configuration" put the - // constraints into a graph whose strategy fails the build on them. - " configurations.matching { it.name == 'releaseRuntimeClasspath' }" - + ".all { resolutionStrategy.failOnVersionConflict() }\n", - " configurations.implementation" + conflict, - " configurations.getByName('implementation')" + conflict, - " configurations['implementation']" + conflict, - " resolutionStrategy.failOnVersionConflict()\n", - }; - for (int i = 0; i < reaching.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - reaching[i]); - check("".equals(out), "<<" + reaching[i].trim() - + ">> may reach the constrained graph, got <<" + out + ">>"); - } - - String[] elsewhere = { - " configurations.create('tooling')" + conflict, - " configurations.tooling" + conflict, - " configurations.getByName('tooling')" + conflict, - " configurations['tooling']" + conflict, - }; - for (int i = 0; i < elsewhere.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", - elsewhere[i]).contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + elsewhere[i].trim() + ">> governs another graph"); - } - - // The plugin classpath is the same question asked of the same reading, - // and only its dotted spelling had been recognised. - String force = ".resolutionStrategy.force " - + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n"; - String[] pluginOnly = { - " configurations.classpath" + force, - " configurations['classpath']" + force, - " configurations.getByName('classpath')" + force, - " buildscript.configurations['classpath']" + force, - }; - for (int i = 0; i < pluginOnly.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", - pluginOnly[i]).contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + pluginOnly[i].trim() + ">> is the plugin's graph"); - } - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all" + force)), - "and a force on the app's own graph still counts"); - } - - /** - * Dependency-shaped data is not a dependency. A map stored in a variable and - * never added to a configuration was read as a strict declaration, standing - * the block down for an app that had declared nothing. - * - *

Safe to exclude here where the same exclusion was not safe for - * enforcedPlatform: a map IS recorded as a definition's value, so a later - * {@code implementation(catalog)} carries it and is read there.

- */ - @Test - public void aStrictMapHasToBeDeclaredToCount() { - String map = "[group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib', " - + "version: '1.7.22!!']"; - String[] stored = { - " def catalog = " + map + "\n", - " ext.catalog = " + map + "\n", - }; - for (int i = 0; i < stored.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", stored[i]) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + stored[i].trim() + ">> declares nothing"); - } - - String[] declared = { - " def catalog = " + map + "\n implementation(catalog)\n", - " implementation(" + map + ")\n", - " implementation group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib', version: '1.7.22!!'\n", - " dependencies.add('implementation', " + map + ")\n", - }; - for (int i = 0; i < declared.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - declared[i]); - check("".equals(out), "<<" + declared[i].trim() - + ">> is a strict declaration, got <<" + out + ">>"); - } - } - - /** - * A coordinate can be a LATER argument: {@code dependencies.add('impl', - * 'g:a:1.7.22!!')} puts it after a comma, and walking back one token found - * the comma and stopped. The shims survived that because the call is also - * recognised where the CONFIGURATION name is read; the base library has a - * scan of its own that does not go through there, so its strict pin was - * emitted straight over. - */ - @Test - public void aCoordinateMayBeALaterArgument() { - String base = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!"; - String[] declarations = { - " dependencies.add('implementation', '" + base + "')\n", - " project.dependencies.add('implementation', '" + base + "')\n", - " dependencies {\n add 'implementation', '" + base + "'\n }\n", - " dependencies.add('implementation', " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')\n", - }; - for (int i = 0; i < declarations.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - declarations[i]); - check("".equals(out), "<<" + declarations[i].trim() - + ">> is a strict declaration, got <<" + out + ">>"); - } - - // The receiver still decides. A list is not a dependency handler, and a - // coordinate handed to one is not declared. - String[] strangers = { - " myList.add('implementation', '" + base + "')\n", - " logger.lifecycle('implementation', '" + base + "')\n", - }; - for (int i = 0; i < strangers.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", - strangers[i]).contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + strangers[i].trim() + ">> declares nothing"); - } - } - - /** - * A test suite's nested {@code dependencies { }} configures the suite's own - * configurations. Its {@code implementation} has the same name as the app's - * and is a different thing, so reading a declaration there as the app's - * skipped the constraint for an artifact the release graph still carries. - */ - @Test - public void aNestedTestSuiteIsNotTheApplicationGraph() { - String suite = " testing {\n suites {\n test {\n" - + " dependencies {\n" - + " implementation('org.jetbrains.kotlin:" - + "kotlin-stdlib-jdk8:1.9.22')\n" - + " }\n }\n }\n }\n"; - String out = KotlinStdlibAlignment.constraintsBlock("implementation", suite); - check(out.contains("kotlin-stdlib-jdk8:1.8.0") - && out.contains("kotlin-stdlib-jdk7:1.8.0"), - "the suite's declaration leaves both constrained, got <<" + out + ">>"); - - // The app's own block, which looks the same one level up, still counts. - String own = KotlinStdlibAlignment.constraintsBlock("implementation", - " dependencies {\n implementation('org.jetbrains.kotlin:" - + "kotlin-stdlib-jdk8:1.9.22')\n }\n"); - check(!own.contains("kotlin-stdlib-jdk8:1.8.0") - && own.contains("kotlin-stdlib-jdk7:1.8.0"), - "the app's own declaration is still read, got <<" + own + ">>"); - } - - /** - * failOnVersionConflict turns a disagreement into a build failure, so the - * block stands down for it -- but only where it governs a configuration that - * receives the constraint. One the app created and nothing extends never - * sees it, and standing down there left the shim unaligned for nothing. - */ - @Test - public void aConflictStrategyCountsOnlyWhereTheConstraintReaches() { - String tail = ".resolutionStrategy.failOnVersionConflict()\n"; - String[] elsewhere = { - " configurations.create('tooling')" + tail, - " configurations.tooling" + tail, - " configurations.getByName('tooling')" + tail, - }; - for (int i = 0; i < elsewhere.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", - elsewhere[i]).contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + elsewhere[i].trim() + ">> governs another graph"); - } - - String[] reaching = { - " configurations.all { resolutionStrategy.failOnVersionConflict() }\n", - " configurations.configureEach { resolutionStrategy" - + ".failOnVersionConflict() }\n", - " configurations.implementation" + tail, - " configurations.getByName('implementation')" + tail, - // Not going through `configurations` at all, so it cannot be placed: - // assumed to reach, because the other way emits into a graph that - // fails the build outright. - " resolutionStrategy.failOnVersionConflict()\n", - }; - for (int i = 0; i < reaching.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - reaching[i]); - check("".equals(out), "<<" + reaching[i].trim() - + ">> reaches the constrained graph, got <<" + out + ">>"); - } - } - - /** - * The canonical Gradle rule puts its openers, its condition and its body on - * separate lines, and every one of those splits was losing the override. - */ - @Test - public void aResolutionRuleSurvivesEveryLineBreakInIt() { - String open = " configurations.all { resolutionStrategy.eachDependency " - + "{ d ->\n"; - String close = " } }\n"; - String[] rules = { - // The reported one: openers and condition on one line, body on the - // next. The first token is `configurations` and two braces are open, - // so neither test for an unbraced body saw a header here. - open + " if (d.requested.name == 'kotlin-stdlib')\n" - + " d.useVersion '1.7.22'\n" + close, - // Braced, which is how it is usually written. The condition and the - // body were only glued together when the GROUP appeared, and a rule - // comparing the name alone never names it. - open + " if (d.requested.name == 'kotlin-stdlib') {\n" - + " d.useVersion '1.7.22'\n }\n" + close, - open + " if (d.requested.group == 'org.jetbrains.kotlin') {\n" - + " d.useVersion '1.7.22'\n }\n" + close, - // An else is the same statement as its if, and the condition that - // names the family is on the if. - open + " if (d.requested.name != 'kotlin-stdlib')\n" - + " d.useVersion '1.9.22'\n" - + " else\n d.useVersion '1.7.22'\n" + close, - open + " while (d.requested.name == 'kotlin-stdlib')\n" - + " d.useVersion '1.7.22'\n" + close, - // The openers, the condition and the body on three different - // lines is one shape; all the openers AND the condition on ONE - // line is another, and there the statement begins with - // `configurations` and holds two open braces, so reading the first - // token found no header at all. - " configurations.all { resolutionStrategy.eachDependency { d -> " - + "if (d.requested.name == 'kotlin-stdlib')\n" - + " d.useVersion '1.7.22'\n" + close, - " configurations.all { resolutionStrategy.eachDependency { d -> " - + "if (d.requested.group == 'org.jetbrains.kotlin')\n" - + " d.useVersion '1.7.22'\n" + close, - // And the spellings that already worked still do. - open + " if (d.requested.name == 'kotlin-stdlib') " - + "d.useVersion '1.7.22'\n" + close, - }; - for (int i = 0; i < rules.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - rules[i]); - check("".equals(out), "rule " + i + " holds the base library pre-merge, " - + "got <<" + out + ">>"); - } - - // A trailing parenthesis that is not a header takes no body with it, or - // every declaration would swallow the line after it. - String[] independent = { - " dependencies {\n implementation('com.example:x:1.0')\n" - + " implementation('org.jetbrains.kotlin:" - + "kotlin-stdlib-jdk8:1.9.22')\n }\n", - " configurations.all { resolutionStrategy.force" - + "('com.example:x:1.0') }\n" - + " implementation 'org.jetbrains.kotlin:" - + "kotlin-stdlib-jdk8:1.9.22'\n", - // A parenthesis inside a string is not a parenthesis. - " println 'if (x)'\n implementation " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n", - }; - for (int i = 0; i < independent.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", - independent[i]).contains("kotlin-stdlib-jdk7:1.8.0"), - "line " + i + " does not take the next one with it"); - } - - // A rule that names the group and then narrows to ONE artifact governs - // that artifact only: reading it as governing the family made the - // siblings look declared and the block came out empty, which leaves the - // duplicate exactly where it was. - String narrowed = KotlinStdlibAlignment.constraintsBlock("implementation", - open + " if (d.requested.group == 'org.jetbrains.kotlin' && " - + "d.requested.name == 'kotlin-stdlib') d.useVersion '1.9.22'\n" - + close); - check(narrowed.contains("kotlin-stdlib-jdk8:1.8.0"), - "a narrowed merged-era rule keeps the alignment, got <<" - + narrowed + ">>"); - } - - /** - * One statement can name a module twice: {@code force} takes varargs, so - * {@code force 'g:a:1.9.22', 'g:a:1.7.22'} is one call listing the same - * module at two versions. Reading the first reported the merged-era one and - * wrote the shim constraints beside a base library that may be forced - * pre-merge -- the failure that reaches the device rather than the build. - */ - @Test - public void aForceMayListOneModuleMoreThanOnce() { - String base = "org.jetbrains.kotlin:kotlin-stdlib:"; - // Either order reaches the same verdict, because the answer does not - // depend on which selector Gradle keeps. - String[] orders = { - "'" + base + "1.9.22', '" + base + "1.7.22'", - "'" + base + "1.7.22', '" + base + "1.9.22'", - "'" + base + "1.9.22', '" + base + "1.7.22', '" + base + "1.9.22'", - }; - for (int i = 0; i < orders.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.force " - + orders[i] + " }\n"); - check("".equals(out), "<<" + orders[i] - + ">> forces the base library pre-merge, got <<" + out + ">>"); - } - - // A force that never goes below the floor still leaves the block to write. - String merged = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.force '" + base - + "1.9.22', '" + base + "1.8.10' }\n"); - check(merged.contains("kotlin-stdlib-jdk7:1.8.0"), - "a merged-era force leaves the alignment alone, got <<" + merged + ">>"); - } - - /** - * The {@code !!} spelling skips the configuration check, because a strict pin - * is honoured wherever it is declared. "Wherever" still means declared: a - * coordinate merely passed to something -- a log line, a list -- is not a - * dependency, and reading one as a pin stood the whole block down for an app - * that had declared nothing. - */ - @Test - public void aStrictCoordinateHasToBeDeclaredToCount() { - String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!"; - String[] declarations = { - " implementation '" + pin + "'\n", - " implementation('" + pin + "')\n", - // Whatever the configuration is called: the rule is that a - // configuration is never reached through a receiver, not a list of - // the ones that count. - " myCustomConfig '" + pin + "'\n", - " debugImplementation '" + pin + "'\n", - " kapt('" + pin + "')\n", - " constraints {\n implementation '" + pin + "'\n }\n", - " dependencies.add('implementation', '" + pin + "')\n", - // The spellings that carry the version somewhere other than the - // coordinate are not asked the question at all. - " implementation group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk8', version: '1.7.22!!'\n", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " - + "{ version { strictly '1.7.22' } }\n", - }; - for (int i = 0; i < declarations.length; i++) { - check("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", declarations[i])), - "<<" + declarations[i].trim() + ">> declares a strict pin"); - } - - String[] merelyCarried = { - " logger.lifecycle('" + pin + "')\n", - " project.logger.info('" + pin + "')\n", - " myList.add('" + pin + "')\n", - " def all = ['" + pin + "']\n", - }; - for (int i = 0; i < merelyCarried.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", - merelyCarried[i]).contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + merelyCarried[i].trim() + ">> declares nothing"); - } - } - - /** - * A fragment interpolated twice is executed twice. injectRepo goes into the - * buildscript repositories and again into the project ones after the android - * block, so a name it binds is restored at the second -- and scanning it once - * left the scan holding whatever came between. - * - *

Reported with a reassignment inside {@code android { }}, which does not - * reproduce: a brace this class can see makes the assignment conditional, and - * a conditional reassignment already refuses to discard a Kotlin coordinate. - * The shape that does reach it is an UNCONDITIONAL one, which app text - * produces by closing its own wrapper early -- so the replay is what the - * script does, and the scan follows it rather than the argument for it.

- */ - @Test - public void aFragmentInterpolatedTwiceIsScannedTwice() { - String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22"; - String repositories = "project.ext.dep = '" + pin + "'\n"; - String reassign = "dep = 'com.example:other:1.0'\n"; - String use = "implementation(dep) { version { strictly '1.7.22' } }\n"; - - String replayed = KotlinStdlibAlignment.constraintsBlock("implementation", - "buildscript {\nrepositories {\n" + repositories + "}\n}\n", - reassign, - "repositories {\n" + repositories + "}\n", - "dependencies {\n" + use + "}\n"); - check("".equals(replayed), - "the second execution restores the coordinate, got <<" + replayed + ">>"); - - String once = KotlinStdlibAlignment.constraintsBlock("implementation", - "repositories {\n" + repositories + "}\n", - reassign, - "dependencies {\n" + use + "}\n"); - check(once.contains("kotlin-stdlib-jdk8:1.8.0"), - "and without it the scan keeps the reassigned value, got <<" - + once + ">>"); - - // The reported spelling, kept because it is the one a reader will try: a - // reassignment the class can see a brace around is conditional either way. - String guarded = KotlinStdlibAlignment.constraintsBlock("implementation", - "repositories {\n" + repositories + "}\n", - "android {\n" + reassign + "}\n", - "dependencies {\n" + use + "}\n"); - check("".equals(guarded), - "a guarded reassignment never hid the pin, got <<" + guarded + ">>"); - } - - /** - * A resolution rule may compare one part of the coordinate only -- the name - * is unambiguous on its own -- and it is in force either way. Requiring the - * group beside it left the override unread, so the shims were raised to - * their empty 1.8.0 jars around a base library the rule held at 1.7.22: - * a build that links and then throws on the device. - */ - @Test - public void aResolutionRuleMayNameTheArtifactAlone() { - String[] artifacts = { - "kotlin-stdlib", "kotlin-stdlib-jdk7", "kotlin-stdlib-jdk8", - }; - for (int i = 0; i < artifacts.length; i++) { - // Every spelling of the same override reaches the same verdict. The two - // predicates that identify an artifact had diverged, so a force naming - // a shim by coordinate stood the block down while a useVersion holding - // the SAME shim at the same version did not. - String[] overrides = { - " configurations.all { resolutionStrategy.eachDependency { d ->\n" - + " if (d.requested.name == '" + artifacts[i] + "') " - + "d.useVersion '1.7.22'\n } }\n", - " configurations.all { resolutionStrategy.eachDependency { d ->\n" - + " if (d.requested.group == 'org.jetbrains.kotlin' && " - + "d.requested.name == '" + artifacts[i] + "') " - + "d.useVersion '1.7.22'\n } }\n", - " configurations.all { resolutionStrategy.force " - + "'org.jetbrains.kotlin:" + artifacts[i] + ":1.7.22' }\n", - }; - for (int j = 0; j < overrides.length; j++) { - check("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", overrides[j])), - "<<" + overrides[j].trim() + ">> is an override in force"); - } - } - - // A fork under another group shares the name and is a different module. - String fork = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation(group: 'com.example', " - + "name: 'kotlin-stdlib-jdk8', version: '1.0')\n"); - check(fork.contains("kotlin-stdlib-jdk8:1.8.0"), - "another group's artifact is not the shim, got <<" + fork + ">>"); - - // A name in a reason is not a reference to anything, and an unrelated - // rule binds nothing. - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('com.example:x:1.0') " - + "{ because 'replaces kotlin-stdlib' }\n") - .contains("kotlin-stdlib-jdk7:1.8.0"), - "a name in a reason is prose"); - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.eachDependency " - + "{ d ->\n if (d.requested.name == 'okhttp') " - + "d.useVersion '3.0.0'\n } }\n") - .contains("kotlin-stdlib-jdk7:1.8.0"), - "an unrelated rule binds nothing"); - } - - /** - * A platform takes a dependency notation, and a map is one. There is no - * literal following the call in that spelling, so an enforced pre-merge BOM - * written as a map read as absent entirely. - */ - @Test - public void anEnforcedPlatformMayBeWrittenAsAMap() { - String[] managing = { - " implementation(enforcedPlatform(group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-bom', version: '1.7.22'))\n", - " implementation(enforcedPlatform(version: '1.7.22', " - + "group: 'org.jetbrains.kotlin', name: 'kotlin-bom'))\n", - " implementation(enforcedPlatform(group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-bom'))\n", - }; - for (int i = 0; i < managing.length; i++) { - check("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", managing[i])), - "<<" + managing[i].trim() + ">> manages the family"); - } - - String[] harmless = { - " implementation(enforcedPlatform(group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-bom', version: '1.9.22'))\n", - " implementation(enforcedPlatform(group: 'com.squareup.okhttp3', " - + "name: 'okhttp-bom', version: '3.0.0'))\n", - // A plain platform is not strict in either spelling. - " implementation(platform(group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-bom', version: '1.7.22'))\n", - }; - for (int i = 0; i < harmless.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", harmless[i]) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + harmless[i].trim() + ">> leaves the alignment alone"); - } - } - - /** - * An ENFORCED Kotlin platform is the one case a BOM stands the block down. - * The class comment records why a plain {@code platform()} does not -- its - * constraints are ordinary, so the higher version wins and these are - * harmless beside it -- and {@code enforcedPlatform} is the other thing: - * Gradle makes the same managed versions strict, so a pre-merge one pins - * the family at 1.7.x and a 1.8.0 requirement beside it cannot resolve. - */ - @Test - public void anEnforcedPreMergeKotlinPlatformManagesTheFamily() { - String[] enforced = { - " implementation(enforcedPlatform(" - + "'org.jetbrains.kotlin:kotlin-bom:1.7.22'))\n", - " implementation enforcedPlatform(" - + "'org.jetbrains.kotlin:kotlin-bom:1.7.22')\n", - " api(enforcedPlatform(\"org.jetbrains.kotlin:kotlin-bom:1.7.22\"))\n", - " def kv = '1.7.22'\n implementation(enforcedPlatform(" - + "\"org.jetbrains.kotlin:kotlin-bom:$kv\"))\n", - // A version this cannot read is not proof it reaches the floor, and a - // prerelease of the floor is below it. - " implementation(enforcedPlatform('org.jetbrains.kotlin:kotlin-bom'))\n", - " implementation(enforcedPlatform(" - + "'org.jetbrains.kotlin:kotlin-bom:1.8.0-RC2'))\n", - }; - for (int i = 0; i < enforced.length; i++) { - check("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", enforced[i])), - "<<" + enforced[i].trim() + ">> manages the family"); - } - - String[] harmless = { - // At or past the floor it already agrees with these constraints. - " implementation(enforcedPlatform(" - + "'org.jetbrains.kotlin:kotlin-bom:1.9.22'))\n", - " implementation(enforcedPlatform(" - + "'org.jetbrains.kotlin:kotlin-bom:1.8.0'))\n", - " implementation(enforcedPlatform(" - + "'com.squareup.okhttp3:okhttp-bom:4.0.0'))\n", - // A plain platform is not strict, whatever version it names. - " implementation(platform('org.jetbrains.kotlin:kotlin-bom:1.7.22'))\n", - " implementation(platform('org.jetbrains.kotlin:kotlin-bom:1.9.22'))\n", - // And the word in a reason is not the call. - " implementation('com.example:x:1.0') { because 'unlike " - + "enforcedPlatform(\\\"org.jetbrains.kotlin:kotlin-bom:1.7.22\\\")' }\n", - }; - for (int i = 0; i < harmless.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", harmless[i]) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + harmless[i].trim() + ">> leaves the alignment alone"); - } - } - - /** - * Quoted syntax is not syntax. A raw search for the plugin classpath, or for - * a block opener, read the words in a {@code because} reason as the real - * thing -- blanking the declaration that carried them, strict pin and all, - * or putting every statement after it in a scope it was never in. - */ - @Test - public void syntaxQuotedInProseIsNotSyntax() { - String pin = "org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!"; - String[] prose = { - " implementation('" + pin + "') " - + "{ because 'match configurations.classpath' }\n", - " implementation('" + pin + "') { because 'as buildscript { } does' }\n", - " implementation('" + pin + "') { because 'set in ext { } above' }\n", - " implementation('" + pin + "') {\n" - + " because 'configurations.classpath'\n }\n", - " println 'buildscript { classpath }'\n implementation('" + pin + "')\n", - }; - for (int i = 0; i < prose.length; i++) { - check("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", prose[i])), - "the pin in <<" + prose[i].trim() + ">> is still read"); - } - - // The real spellings still say what they say. - String[] real = { - " buildscript { dependencies { classpath '" + pin + "' } }\n", - " configurations.classpath.resolutionStrategy.force '" + pin + "'\n", - }; - for (int i = 0; i < real.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", real[i]) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + real[i].trim() + ">> is the plugin's graph"); - } - - // And a quoted block opener does not put what follows it in a scope. - String scoped = KotlinStdlibAlignment.constraintsBlock("implementation", - " println 'ext {'\n def kv = '1.9.22'\n" - + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kv\"\n"); - check(scoped.contains("kotlin-stdlib-jdk7:1.8.0"), - "a quoted opener opens nothing, got <<" + scoped + ">>"); - } - - /** - * {@code add} is an ordinary method name. Reading any call of it as a - * dependency declaration let an unrelated API -- a version catalog, a list -- - * claim an artifact the app had never put in its graph, and the constraint - * that artifact needed was skipped as already handled. - */ - @Test - public void anAddCallMustBeOnADependencyHandler() { - String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22"; - String[] handlers = { - " dependencies.add('implementation', '" + pin + "!!')\n", - " project.dependencies.add('implementation', '" + pin + "!!')\n", - " dependencies {\n add 'implementation', '" + pin + "!!'\n }\n", - }; - for (int i = 0; i < handlers.length; i++) { - check("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", handlers[i])), - "<<" + handlers[i].trim() + ">> declares a dependency"); - } - - String[] strangers = { - " catalog.add('implementation', '" + pin + "')\n", - " myList.add('implementation', '" + pin + "')\n", - " deps.add('implementation', '" + pin + "')\n", - }; - for (int i = 0; i < strangers.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", - strangers[i]).contains("kotlin-stdlib-jdk8:1.8.0"), - "<<" + strangers[i].trim() + ">> declares nothing"); - } - } - - /** - * A block opener shares the statement with what it opens. The walk that - * reads a typed declaration began at the first token -- {@code if} -- and - * stopped at its parenthesis, so the declaration behind it, and the pin that - * declaration held, were never recorded. - */ - @Test - public void aDeclarationMayFollowABlockOpenerOnTheSameStatement() { - String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22"; - String tail = " implementation(dep) { version { strictly '1.7.22' } } }\n"; - String[] openers = { - " if (true) { String dep = '" + pin + "';", - " if (a >= b) { String dep = '" + pin + "';", - " if (true) { def dep = '" + pin + "';", - " if (a) { if (b) { Map m = [:]; String dep = '" + pin + "';", - }; - for (int i = 0; i < openers.length; i++) { - check("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", openers[i] + tail)), - "<<" + openers[i].trim() + ">> declares dep"); - } - - // The brace that IS the value must not be mistaken for one that opens a - // block, or the name being assigned to is skipped. - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " Closure c = { }\n String dep = '" + pin + "'\n" - + " implementation(dep) { version { strictly '1.7.22' } }\n")), - "a closure assignment is still a declaration"); - - // And a reassignment the brace GUARDS is still conditional, however it is - // spelled -- taking it unconditionally throws away the coordinate the - // condition may never replace. - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'; " - + "if (project.hasProperty('other')) " - + "{ dep = 'com.example:other:1.0' }; " - + "implementation(dep) { version { strictly '1.7.22' } }\n")), - "a one-line conditional reassignment stays conditional"); - } - - /** - * A {@code buildscript} block configures the plugin classpath. That is a - * separate resolution from the app's and cannot conflict with anything - * written into {@code dependencies { }}, so an override or a shim - * declaration there is not the app managing the family -- reading it as one - * left an app graph carrying a pre-merge shim unaligned. - */ - @Test - public void aBuildscriptBlockIsNotTheApplicationGraph() { - String pin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22"; - String[] pluginOnly = { - " buildscript { configurations.all { resolutionStrategy.force " - + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' } }\n", - " buildscript {\n dependencies {\n" - + " classpath '" + pin + "!!'\n }\n }\n", - " buildscript {\n dependencies {\n classpath('" + pin - + "') { version { strictly '1.7.22' } }\n }\n }\n", - // The spelling that names the configuration outright still counts - // wherever it is written, including outside a buildscript block. - " configurations.classpath.resolutionStrategy.force '" + pin + "'\n", - }; - for (int i = 0; i < pluginOnly.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", pluginOnly[i]) - .contains("kotlin-stdlib-jdk7:1.8.0"), - "<<" + pluginOnly[i].trim() + ">> is the plugin's graph"); - } - - // The app's own declarations are unaffected, before or after one. - String after = KotlinStdlibAlignment.constraintsBlock("implementation", - " buildscript { dependencies { classpath " - + "'com.android.tools.build:gradle:8.1.0' } }\n" - + " dependencies { implementation '" + pin + "!!' }\n"); - check("".equals(after), "an app pin after a buildscript block still counts, " - + "got <<" + after + ">>"); - } - - /** - * An extra property is not block scoped, and - * {@code buildscript { ext.kotlin_version = '..' }} is how a Kotlin Android - * script is written. Discarded with the brace it sat in, the version every - * dependency below interpolated read as unreadable -- which counts as below - * the floor -- and the alignment never ran at all. - */ - @Test - public void anExtraPropertyOutlivesTheBlockItWasSetIn() { - String[] definitions = { - " buildscript {\n ext.kv = 'V'\n }\n", - " buildscript { ext.kv = 'V' }\n", - " buildscript {\n ext['kv'] = 'V'\n }\n", - " someBlock {\n ext.kv = 'V'\n }\n", - " ext.kv = 'V'\n", - " ext { kv = 'V' }\n", - }; - String use = " dependencies {\n implementation " - + "\"org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kv\"\n }\n"; - for (int i = 0; i < definitions.length; i++) { - String merged = KotlinStdlibAlignment.constraintsBlock("implementation", - definitions[i].replace("'V'", "'1.9.22'") + use); - check(merged.contains("kotlin-stdlib-jdk7:1.8.0") - && !merged.contains("kotlin-stdlib-jdk8:1.8.0"), - "<<" + definitions[i].trim() + ">> is readable below, got <<" - + merged + ">>"); - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - definitions[i].replace("'V'", "'1.7.22!!'") + use)), - "and a pre-merge one stands the block down"); - } - - // A local really is block scoped, and must not start outliving its block - // just because an extra property does. - String local = KotlinStdlibAlignment.constraintsBlock("implementation", - " someBlock {\n def kv = '1.9.22'\n }\n" + use); - check("".equals(local), - "a local does not escape its block, got <<" + local + ">>"); - } - - /** - * A type is a type however it is spelled. The walk that separates a - * declaration from an assignment stopped at the first character that is not - * part of an identifier, so a generic or array type ended the statement and - * the name it declared -- and the pin that name held -- was never recorded. - */ - @Test - public void aTypeMayBeGenericOrAnArray() { - String map = "[group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk8', version: '1.7.22']"; - String use = " implementation(dep) { version { strictly '1.7.22' } }\n"; - String[] types = { - "def", "String", "Map", "Map", "HashMap", - "java.util.Map", "Map", - "List>", "final Map", "String[]", - "Map", "String[][]", - }; - for (int i = 0; i < types.length; i++) { - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " " + types[i] + " dep = " + map + "\n" + use)), - "<<" + types[i] + ">> declares dep"); - } - - // The angle bracket really has to be a type argument list. A comparison is - // not one, and swallowing it would take the rest of the statement with it; - // neither is a subscript with something in it, which is how an extra - // property is named. - check(KotlinStdlibAlignment.constraintsBlock("implementation", - " if (someVersion < 5) { }\n" - + " implementation('org.jetbrains.kotlin:" - + "kotlin-stdlib-jdk8:1.9.22')\n") - .contains("kotlin-stdlib-jdk7:1.8.0"), - "a comparison is not a type argument list"); - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - " ext['dep'] = 'org.jetbrains.kotlin:" - + "kotlin-stdlib-jdk8:1.7.22'\n" + use)), - "and a subscript with a name in it still names a property"); - } - - /** - * The extra properties extension is reachable through the project, and its - * property may be subscripted rather than dotted. Both spellings set the - * property the bare name goes on to read, and neither was recorded, so a - * strict pre-merge pin held in one was emitted straight over. - */ - @Test - public void anExtraPropertyIsFoundThroughEverySpellingOfIt() { - String pin = "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'"; - String use = " implementation(dep) { version { strictly '1.7.22' } }\n"; - String[] definitions = { - " ext.dep = " + pin + "\n", - " project.ext.dep = " + pin + "\n", - " rootProject.ext.dep = " + pin + "\n", - " ext['dep'] = " + pin + "\n", - " ext[\"dep\"] = " + pin + "\n", - " project.ext['dep'] = " + pin + "\n", - }; - for (int i = 0; i < definitions.length; i++) { - check("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", - definitions[i] + use)), - "the pin in <<" + definitions[i].trim() + ">> stands the block down"); - } - - // The owner still has to BE the extension: a property of anything else - // does not bind the bare name, and reading one as though it did is how an - // unreadable version becomes a confidently wrong one. - String[] strangers = { - " somePlugin.dep = " + pin + "\n", - " extras.dep = " + pin + "\n", - " myext.dep = " + pin + "\n", - " notext['dep'] = " + pin + "\n", - }; - for (int i = 0; i < strangers.length; i++) { - check(KotlinStdlibAlignment.constraintsBlock("implementation", - strangers[i] + use).contains("kotlin-stdlib-jdk8:1.8.0"), - "<<" + strangers[i].trim() + ">> does not bind dep"); - } - - // And a merged-era coordinate held the same way is still read as a - // declaration, so the artifact it names is left alone and its sibling is - // the only one raised. - String merged = KotlinStdlibAlignment.constraintsBlock("implementation", - " project.ext['dep'] = " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n" - + " implementation(dep)\n"); - check(merged.contains("kotlin-stdlib-jdk7:1.8.0") - && !merged.contains("kotlin-stdlib-jdk8:1.8.0"), - "the subscripted declaration is read, got <<" + merged + ">>"); - } - - /** - * A stored map goes through the same expansion a stored string does, so a - * version interpolated into it carries the version rather than the text of - * the reference -- {@code "$v"} read as no version at all, which counts as - * below the floor and stood the whole block down. - */ - @Test - public void aStoredMapExpandsWhatItInterpolates() { - String modern = KotlinStdlibAlignment.constraintsBlock("implementation", - " def v = '1.9.22'\n" - + " def dep = [group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk7', version: \"$v\"]\n" - + " implementation(dep)\n"); - check(modern.contains("kotlin-stdlib-jdk8:1.8.0") - && !modern.contains("kotlin-stdlib-jdk7:1.8.0"), - "the interpolated version is read, got <<" + modern + ">>"); - - String old = KotlinStdlibAlignment.constraintsBlock("implementation", - " def v = '1.7.22!!'\n" - + " def dep = [group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk7', version: \"$v\"]\n" - + " implementation(dep)\n"); - check("".equals(old), - "and a pre-merge one still suppresses, got <<" + old + ">>"); - } - - /** - * A map factored into a variable is a declaration too. Recorded as - * nothing, the statement using it named no artifact and the strict pin it - * carried was invisible. - */ - @Test - public void aMapMayBeFactoredIntoAVariable() { - String pinned = KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = [group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk8', version: '1.7.22']\n" - + " implementation(dep) { version { strictly '1.7.22' } }\n"); - check("".equals(pinned), "the map is carried to its usage, got <<" + pinned + ">>"); - - String modern = KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = [group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk7', version: '1.9.22']\n" - + " implementation(dep)\n"); - check(modern.contains("kotlin-stdlib-jdk8:1.8.0") - && !modern.contains("kotlin-stdlib-jdk7:1.8.0"), - "and read for what it declares, got <<" + modern + ">>"); - - // A map of unrelated strings is still not a declaration. - String catalog = KotlinStdlibAlignment.constraintsBlock("implementation", - " def catalog = [legacy: " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!']\n" - + " implementation 'androidx.appcompat:appcompat:1.6.1'\n"); - check(catalog.contains("kotlin-stdlib-jdk8:1.8.0"), - "a catalog still decides nothing, got <<" + catalog + ">>"); - } - - /** - * A rejection is read for exactly what it removes, and it decides - * reachability on its own -- a requirement beside it cannot select what - * the rejection has taken away. - */ - @Test - public void aRejectionIsReadForWhatItRemoves() { - String[][] cases = { - {"reject '[1.8.0,)'", ""}, - {"reject '[1.7.0,)'", ""}, - {"rejectAll()", ""}, - // require cannot select what reject removed - {"require '1.+'; reject '[1.8.0,)'", ""}, - // an EXCLUSIVE lower bound leaves the floor itself selectable - {"reject '(1.8.0,)'", "kotlin-stdlib-jdk7:1.8.0"}, - {"reject ']1.8.0,)'", "kotlin-stdlib-jdk7:1.8.0"}, - {"reject '[1.9.0,)'", "kotlin-stdlib-jdk7:1.8.0"}, - {"require '1.+'", "kotlin-stdlib-jdk7:1.8.0"}, - }; - for (int i = 0; i < cases.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " - + "{ version { " + cases[i][0] + " } }\n"); - if (cases[i][1].length() == 0) { - check("".equals(out), - cases[i][0] + " leaves nothing at the floor, got <<" + out + ">>"); - } else { - check(out.contains(cases[i][1]), - cases[i][0] + " leaves the floor selectable, got <<" + out + ">>"); - } - } - } - - /** - * A strategy on {@code configurations.classpath} governs the plugin - * classpath, which is not where these constraints go -- so standing the - * block down for it would leave a real duplicate unfixed for a setting - * that cannot conflict with anything written here. - */ - @Test - public void aBuildscriptStrategyIsNotTheAppsGraph() { - String plugin = KotlinStdlibAlignment.constraintsBlock("implementation", - " buildscript { configurations.classpath.resolutionStrategy" - + ".failOnVersionConflict() }\n"); - check(plugin.contains("kotlin-stdlib-jdk8:1.8.0"), - "a classpath-only strategy leaves the alignment alone, got <<" - + plugin + ">>"); - - // The app's own configurations still stand it down. - String app = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.failOnVersionConflict() }\n"); - check("".equals(app), "the app's graph still does, got <<" + app + ">>"); - } - - /** - * A rejection only manages the version when it leaves the floor nothing to - * select. Reading every rejection as management left the original - * duplicate unfixed for an app that had rejected something else entirely. - */ - @Test - public void aRejectionCountsOnlyWhenItReachesTheFloor() { - String[] closing = {"reject '[1.8.0,)'", "rejectAll()", "reject '(,1.8.0]'"}; - for (int i = 0; i < closing.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " - + "{ version { " + closing[i] + " } }\n"); - check("".equals(out), - closing[i] + " leaves nothing at the floor, got <<" + out + ">>"); - } - - // `(,1.8.0]` was once here, on the reasoning that it leaves 1.8.1 -- but - // it INCLUDES the floor, and the floor is the only version a constraint - // written at exactly 1.8.0 can resolve to. - String[] leaving = {"reject '1.7.0'", "reject '[1.9.0,)'", "reject '(,1.8.0)'"}; - for (int i = 0; i < leaving.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " - + "{ version { " + leaving[i] + " } }\n"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), - leaving[i] + " still leaves the floor selectable, got <<" - + out + ">>"); - } - } - - /** - * A declaration written inside quoted prose never executes. An - * unrestricted search for {@code def} found one there and recorded it, - * overwriting a real binding so a later use read as something else. - */ - @Test - public void aDeclarationInsideProseIsNotADeclaration() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = 'com.example:other:1.0'\n" - + " println \"def dep = " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\"\n" - + " implementation(dep)\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the quoted declaration is ignored, got <<" + out + ">>"); - - // A real one directly after it still counts. - String real = KotlinStdlibAlignment.constraintsBlock("implementation", - " println \"nothing to see\"\n" - + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n" - + " implementation(dep)\n"); - check("".equals(real), "a real declaration still counts, got <<" + real + ">>"); - } - - /** - * Groovy accepts spaces around a map key's colon, and looking only at the - * character immediately after the token missed the key and substituted it - * away -- losing the map form and the strict pin inside it. - */ - @Test - public void aMapKeyMayBeSpacedFromItsColon() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def group = 'org.jetbrains.kotlin'\n" - + " implementation(group : group, name : 'kotlin-stdlib-jdk8', " - + "version : '1.7.22') { version { strictly '1.7.22' } }\n"); - check("".equals(out), "the spaced map form is read, got <<" + out + ">>"); - } - - /** - * With failOnVersionConflict every disagreement is a build failure, and - * raising a shim to the floor IS a disagreement -- so the block would turn - * a graph that resolved coherently into one that does not resolve at all. - * Nothing can be written here that would not conflict, so nothing is. - */ - @Test - public void nothingIsWrittenWhenConflictsAreFatal() { - String fatal = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.failOnVersionConflict() }\n"); - check("".equals(fatal), "the block stands down, got <<" + fatal + ">>"); - - // The words in a reason are prose, here as everywhere else. - String prose = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('a:b:1.0') " - + "{ because 'we do not failOnVersionConflict here' }\n"); - check(prose.contains("kotlin-stdlib-jdk8:1.8.0"), - "prose does not stand it down, got <<" + prose + ">>"); - } - - /** - * An unbraced body belongs to the header above it. A resolution rule - * written that way had the artifact named in the condition and the - * override in the body, and splitting at the newline left neither - * statement saying anything. - */ - @Test - public void anUnbracedBodyStaysWithItsCondition() { - String rule = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.eachDependency { d ->\n" - + " if (d.requested.group == 'org.jetbrains.kotlin' " - + "&& d.requested.name == 'kotlin-stdlib')\n" - + " d.useVersion '1.7.22'\n" - + " } }\n"); - check("".equals(rule), "the rule is read across the newline, got <<" + rule + ">>"); - - // Two ordinary declarations on consecutive lines are still two statements. - String separate = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'androidx.appcompat:appcompat:1.6.1'\n" - + " implementation 'com.google.code.gson:gson:2.10.1'\n"); - check(separate.contains("kotlin-stdlib-jdk8:1.8.0"), - "ordinary lines still separate, got <<" + separate + ">>"); - } - - /** - * A rejection manages the version from the other side. Rejecting every - * version our floor could resolve to leaves the graph nothing to select, - * so writing the constraint anyway makes it unsatisfiable. - */ - @Test - public void aRejectionIsVersionManagement() { - String[] rules = {"reject '[1.8.0,)'", "rejectAll()"}; - for (int i = 0; i < rules.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " - + "{ version { " + rules[i] + " } }\n"); - check("".equals(out), - rules[i] + " suppresses the block, got <<" + out + ">>"); - } - } - - /** - * A fragment is scanned inside the closure that surrounds it in the - * generated file. Handed over bare, a local declared in the repositories - * closure outlived it and shadowed a real binding for everything after -- - * which reads a later use as a declaration and skips that constraint. - */ - @Test - public void aFragmentKeepsItsGeneratedScope() throws Exception { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - "ext.dep = 'com.example:other:1.0'\n", - "repositories {\n" - + "def dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n}\n", - "dependencies {\nimplementation(dep)\n}\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the repository-local name does not escape, got <<" + out + ">>"); - - // The half above proves the alignment honours a scope it is GIVEN. This half - // proves the builder gives it one: passing the fragments bare is what the - // report was about, and a test that hands over pre-wrapped text would pass - // with the builder unchanged -- which it did, until this was added. - byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( - "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); - String builderSrc = new String(bytes, "UTF-8"); - int at = builderSrc.indexOf("KotlinStdlibAlignment.constraintsBlock("); - check(at >= 0, "the builder calls the alignment"); - String fromCall = builderSrc.substring(at).replaceAll("//[^\n]*", ""); - String call = fromCall.substring(0, fromCall.indexOf(";")); - int blockAt = builderSrc.indexOf("String gradleProps = "); - check(blockAt >= 0, "the generated script is found"); - int blockEnd = builderSrc.indexOf("Gradle File start", blockAt); - check(blockEnd > blockAt, "and its end"); - String block = builderSrc.substring(blockAt, blockEnd) - .replaceAll("//[^\n]*", ""); - String[] scopes = { - "repositories {", "buildscript {", "android {", "dependencies {", - }; - for (int i = 0; i < scopes.length; i++) { - check(call.indexOf(scopes[i]) >= 0, - "fragments are handed over inside their " + scopes[i] - + " scope, which the call does not show"); - } - - // And the scope has to be the RIGHT one, read off the script rather than - // named here. A fragment interpolated at two places sits in two different - // scopes -- injectRepo is inside buildscript { repositories { } } once and - // a bare repositories { } the second time -- so the call must wrap the two - // occurrences differently. Checking only that each was wrapped somehow let - // the first be handed over as an app-graph repositories block, which is a - // different question from the one Gradle asks there. - java.util.List> scriptScopes = - new java.util.ArrayList>(); - java.util.List stack = new java.util.ArrayList(); - boolean inLiteral = false; - StringBuilder literal = new StringBuilder(); - for (int i = 0; i < block.length(); i++) { - char c = block.charAt(i); - if (inLiteral) { - if (c == '\\') { - i++; - continue; - } - if (c == '"') { - inLiteral = false; - continue; - } - if (c == '{') { - String head = literal.toString().trim(); - int space = head.lastIndexOf(' '); - stack.add(space < 0 ? head : head.substring(space + 1)); - literal.setLength(0); - } else if (c == '}') { - if (!stack.isEmpty()) { - stack.remove(stack.size() - 1); - } - literal.setLength(0); - } else { - literal.append(c); - } - continue; - } - if (c == '"') { - inLiteral = true; - literal.setLength(0); - continue; - } - if (block.startsWith("injectRepo", i) - && (i == 0 || !Character.isJavaIdentifierPart(block.charAt(i - 1))) - && !Character.isJavaIdentifierPart( - block.charAt(i + "injectRepo".length()))) { - scriptScopes.add(new java.util.ArrayList(stack)); - } - } - check(scriptScopes.size() == 2, - "the script interpolates injectRepo twice, found " + scriptScopes); - check(!scriptScopes.get(0).equals(scriptScopes.get(1)), - "and in two different scopes, found " + scriptScopes); - - // Split at the commas that separate ARGUMENTS, which are the ones outside - // parentheses: the nearest comma before the token is the one inside - // .replace("%s", injectRepo), and slicing there left no wrapper to check. - java.util.List arguments = new java.util.ArrayList(); - int depth = 0; - int start = call.indexOf('(') + 1; - boolean quoted = false; - for (int i = start; i < call.length(); i++) { - char c = call.charAt(i); - if (quoted) { - if (c == '\\') { - i++; - } else if (c == '"') { - quoted = false; - } - continue; - } - if (c == '"') { - quoted = true; - } else if (c == '(') { - depth++; - } else if (c == ')') { - if (depth == 0) { - arguments.add(call.substring(start, i)); - break; - } - depth--; - } else if (c == ',' && depth == 0) { - arguments.add(call.substring(start, i)); - start = i + 1; - } - } - java.util.List passing = new java.util.ArrayList(); - for (int i = 0; i < arguments.size(); i++) { - if (arguments.get(i).indexOf("injectRepo") >= 0) { - passing.add(arguments.get(i)); - } - } - check(passing.size() == scriptScopes.size(), - "the call passes injectRepo once per interpolation, found " + passing); - - for (int i = 0; i < scriptScopes.size(); i++) { - String wrapper = passing.get(i); - java.util.List scope = scriptScopes.get(i); - for (int j = 0; j < scope.size(); j++) { - check(wrapper.indexOf(scope.get(j) + " {") >= 0, - "occurrence " + i + " of injectRepo is handed over inside " - + scope + ", and its wrapper <<" + wrapper.trim() - + ">> does not open " + scope.get(j)); - } - for (int j = 0; j < scopes.length; j++) { - String other = scopes[j].substring(0, scopes[j].indexOf(' ')); - check(scope.contains(other) || wrapper.indexOf(scopes[j]) < 0, - "occurrence " + i + " of injectRepo is not inside " + other - + " in the script, but its wrapper <<" - + wrapper.trim() + ">> opens one"); - } - } - } - - /** - * A bracket holds a statement together exactly as a parenthesis does. A - * force written across lines had its assignment in one statement and its - * coordinate in another, so neither said anything and the force went - * unread while the shims were raised around it. - */ - @Test - public void aBracketHoldsAStatementTogether() { - String across = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all {\n" - + " resolutionStrategy.forcedModules = [\n" - + " 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!'\n" - + " ]\n" - + " }\n"); - check("".equals(across), - "the force spans lines, got <<" + across + ">>"); - - String mapAcross = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation([\n" - + " group: 'org.jetbrains.kotlin',\n" - + " name: 'kotlin-stdlib-jdk8',\n" - + " version: '1.7.22!!'\n" - + " ])\n"); - check("".equals(mapAcross), - "and so does a map written across them, got <<" + mapAcross + ">>"); - - // Statements that are NOT inside brackets still separate, which is what - // stops one declaration's configuration pairing with another's coordinate. - String separate = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'androidx.appcompat:appcompat:1.6.1'\n" - + " implementation 'com.google.code.gson:gson:2.10.1'\n"); - check(separate.contains("kotlin-stdlib-jdk7:1.8.0") - && separate.contains("kotlin-stdlib-jdk8:1.8.0"), - "ordinary declarations still split, got <<" + separate + ">>"); - } - - /** - * Gradle's parenthesis-free map form puts two bare tokens in a row, which - * is what a typed declaration looks like to a token counter. Read as one, - * {@code implementation group: group, ...} "declared" a variable called - * group with no initialiser and cleared the real binding of that name, so - * the strict declaration using it later was never matched. - */ - @Test - public void aNamedArgumentIsNotADeclaration() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def group = 'org.jetbrains.kotlin'\n" - + " implementation group: group, name: 'other', version: '1.0'\n" - + " implementation(group: group, name: 'kotlin-stdlib', " - + "version: '1.7.22') { version { strictly '1.7.22' } }\n"); - check("".equals(out), - "the binding survives the map-form declaration, got <<" + out + ">>"); - } - - /** - * A map entry's value is not handed to a dependency. A catalog of strings - * carrying a strict-looking coordinate suppressed the block for something - * never added to any configuration. - */ - @Test - public void aMapEntryValueIsNotADeclaration() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def catalog = [legacy: " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!']\n" - + " implementation 'androidx.appcompat:appcompat:1.6.1'\n"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0") - && out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a catalog entry decides nothing, got <<" + out + ">>"); - - // But a coordinate in a forcedModules list decides everything, and it sits - // right after a bracket -- which is why only the colon is read this way. - String forced = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.forcedModules = " - + "['org.jetbrains.kotlin:kotlin-stdlib:1.7.22'] }\n"); - check("".equals(forced), "a forced module is still read, got <<" + forced + ">>"); - } - - /** - * A closure opened and a local declared on the same line is still a - * closure. Depth is tracked between statements, so that local looked like - * it belonged to the script and outlived the closure it was written in. - */ - @Test - public void aSameLineClosureIsStillAScope() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " ext.dep = 'com.example:other:1.0'\n" - + " ext.helper = { def dep = " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22' }\n" - + " implementation(dep)\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the closure's local does not escape it, got <<" + out + ">>"); - } - - /** - * The backward scans know what whitespace is too. Three of them stopped at - * a space or a tab, so a fragment with Windows line endings put a carriage - * return where they were looking and the token behind it stopped being - * found -- a `because` on the line above its argument, for one, which then - * read as a declaration rather than as prose. - */ - @Test - public void aTokenIsStillFoundAcrossAnyLineEnding() { - String[] endings = {"\r\n", "\n", "\r"}; - for (int i = 0; i < endings.length; i++) { - String reason = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('com.example:other:1.0') { because" + endings[i] - + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!' }\n"); - check(reason.contains("kotlin-stdlib-jdk8:1.8.0"), - "the reason is still prose across " + endings[i].length() - + " line-ending chars, got <<" + reason + ">>"); - - String added = KotlinStdlibAlignment.constraintsBlock("implementation", - " dependencies.add(" + endings[i] - + " 'implementation'," + endings[i] - + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')\n"); - check("".equals(added), - "and an add() call is still an add() call, got <<" + added + ">>"); - } - } - - /** - * {@code +=} assigns too: forcedModules += ['...'] applies the force just - * as an ordinary assignment does. - */ - @Test - public void anAdditiveAssignmentStillAssigns() { - String[] operators = {"=", "+="}; - for (int i = 0; i < operators.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.forcedModules " - + operators[i] + " ['org.jetbrains.kotlin:kotlin-stdlib:1.7.22'] }\n"); - check("".equals(out), - "forcedModules " + operators[i] + " is a force, got <<" + out + ">>"); - } - - // A comparison is not an assignment, and neither reads as a force. - String compared = KotlinStdlibAlignment.constraintsBlock("implementation", - " if (resolutionStrategy.forcedModules == " - + "['org.jetbrains.kotlin:kotlin-stdlib:1.7.22']) { }\n"); - check(compared.contains("kotlin-stdlib-jdk8:1.8.0"), - "a comparison is not a force, got <<" + compared + ">>"); - } - - /** - * A declaration may be annotated. A script field is written - * {@code @groovy.transform.Field String dep = '...'}, and the walk that - * reads modifiers and a type stopped dead on the {@code @}. - */ - @Test - public void aDeclarationMayBeAnnotated() { - String[] annotations = { - "@groovy.transform.Field", - "@Field", - "@SuppressWarnings('unused')", - "@groovy.transform.Field @SuppressWarnings('unused')", - }; - for (int i = 0; i < annotations.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " " + annotations[i] - + " String dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" - + " implementation(dep) { version { strictly '1.7.22' } }\n"); - check("".equals(out), - "the field annotated " + annotations[i] + " is recorded, got <<" - + out + ">>"); - } - } - - /** - * A declaration may introduce several names at once, and the one that - * matters is not always the first. - */ - @Test - public void everyDeclaratorIsRecorded() { - String second = KotlinStdlibAlignment.constraintsBlock("implementation", - " def marker = 'x', dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" - + " implementation(dep) { version { strictly '1.7.22' } }\n"); - check("".equals(second), "the second declarator is recorded, got <<" + second + ">>"); - - String third = KotlinStdlibAlignment.constraintsBlock("implementation", - " def a = 'x', b = 'y', " - + "dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" - + " implementation(dep) { version { strictly '1.7.22' } }\n"); - check("".equals(third), "and the third, got <<" + third + ">>"); - - // The first still is, and a declarator list does not invent bindings: a name - // that was never declared stays unknown. - String first = KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22', marker = 'x'\n" - + " implementation(dep) { version { strictly '1.7.22' } }\n"); - check("".equals(first), "the first is unaffected, got <<" + first + ">>"); - - String unknown = KotlinStdlibAlignment.constraintsBlock("implementation", - " def marker = 'x', other = 'y'\n" - + " implementation(dep)\n"); - check(unknown.contains("kotlin-stdlib-jdk8:1.8.0"), - "an undeclared name is still unknown, got <<" + unknown + ">>"); - } - - /** - * Line endings do not change what a map entry says, here either. The call - * detector had already learned that and this shared skip had not, so a - * CRLF fragment that split an entry after its colon found no value at all. - */ - @Test - public void aMapEntryMayBeSplitByAnyLineEnding() { - String[] endings = {"\r\n", "\n", "\r"}; - for (int i = 0; i < endings.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation(group:" + endings[i] - + " 'org.jetbrains.kotlin', name:" + endings[i] - + " 'kotlin-stdlib-jdk8', version:" + endings[i] - + " '1.7.22!!')" + endings[i]); - check("".equals(out), - "the map entry survives the line ending, got <<" + out + ">>"); - } - } - - /** - * A name a nested scope introduced goes away with it. A `def` inside a - * closure or a method is local to it, and keeping that value afterwards - * made an unrelated later use look like a declaration of whatever the - * nested one held -- so that artifact's constraint was skipped as already - * satisfied while its sibling was raised around it. - */ - @Test - public void aNameIntroducedInsideAScopeLeavesWithIt() { - String nested = KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = 'com.example:other:1.0'\n" - + " def helper() {\n" - + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n" - + " }\n" - + " implementation(dep)\n"); - check(nested.contains("kotlin-stdlib-jdk8:1.8.0"), - "the nested local does not reach the statement after it, got <<" - + nested + ">>"); - - // An ASSIGNMENT inside a block is a different thing: it updates the binding - // it found, so it does reach what follows. This is the distinction the fix - // rests on, and it is asserted so a later "just clear the scope" cannot - // quietly take it away. - String assigned = KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = 'com.example:other:1.0'\n" - + " if (legacy) {\n" - + " dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n" - + " }\n" - + " implementation(dep)\n"); - check(!assigned.contains("kotlin-stdlib-jdk8:1.8.0"), - "an assignment inside a block still reaches what follows, got <<" - + assigned + ">>"); - } - - /** - * A value that is only assigned is not a declaration. A definition naming - * the artifact and carrying a strict marker suppressed the whole block on - * that basis alone, for a value never added to any configuration -- and a - * definition becomes a declaration when it is USED, by which point the - * name has been inlined and the usage is what gets read. - */ - @Test - public void anAssignedValueIsNotADeclaration() { - String unused = KotlinStdlibAlignment.constraintsBlock("implementation", - " def legacy = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n" - + " implementation 'androidx.appcompat:appcompat:1.6.1'\n"); - check(unused.contains("kotlin-stdlib-jdk7:1.8.0") - && unused.contains("kotlin-stdlib-jdk8:1.8.0"), - "an unused definition decides nothing, got <<" + unused + ">>"); - - // Used, it decides everything. - String used = KotlinStdlibAlignment.constraintsBlock("implementation", - " def legacy = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n" - + " implementation legacy\n"); - check("".equals(used), "the same value, used, suppresses; got <<" + used + ">>"); - } - - /** - * A substitution overrides only what it substitutes AWAY from. With the - * artifact as the target the replacement still goes through ordinary - * conflict resolution, so an existing requirement raises it and nothing is - * pinned -- reading that as absolute suppressed the block for a graph that - * had not been pinned at all. - */ - @Test - public void aSubstitutionOverridesOnlyItsSource() { - String source = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.dependencySubstitution { " - + "substitute module('org.jetbrains.kotlin:kotlin-stdlib') " - + "using module('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') } }\n"); - check("".equals(source), - "substituting the stdlib itself is an override, got <<" + source + ">>"); - - String target = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.dependencySubstitution { " - + "substitute module('com.example:source') " - + "using module('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') } }\n"); - check(target.contains("kotlin-stdlib-jdk7:1.8.0") - && target.contains("kotlin-stdlib-jdk8:1.8.0"), - "the stdlib merely as a target is not, got <<" + target + ">>"); - } - - /** - * A rule reaches the app's configurations from inside the android block - * too, so a fragment interpolated there decides what resolves just as much - * as a declaration does. This is the shape the alignment could not see when - * only the dependencies block was scanned. - */ - @Test - public void aRuleInsideTheAndroidBlockIsStillARule() { - String force = KotlinStdlibAlignment.constraintsBlock("implementation", - " project.configurations.all { resolutionStrategy.force " - + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' }\n"); - check("".equals(force), - "a project-qualified force suppresses, got <<" + force + ">>"); - - String substitution = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.dependencySubstitution { " - + "substitute module('org.jetbrains.kotlin:kotlin-stdlib') " - + "using module('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') } }\n"); - check("".equals(substitution), - "a substitution onto a pre-merge version suppresses, got <<" - + substitution + ">>"); - - // The replacement is what decides, not the module being replaced: this one - // raises the library and takes nothing away. - String raising = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.dependencySubstitution { " - + "substitute module('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') " - + "using module('org.jetbrains.kotlin:kotlin-stdlib:1.9.22') } }\n"); - check(raising.contains("kotlin-stdlib-jdk8:1.8.0"), - "a substitution raising the library keeps the alignment, got <<" - + raising + ">>"); - } - - /** - * {@code useTarget} replaces the whole coordinate rather than the version, - * and overrides just as absolutely as {@code useVersion} does. - */ - @Test - public void aRuleThatRetargetsIsStillARule() { - String old = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.eachDependency { d -> " - + "d.useTarget 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' } }\n"); - check("".equals(old), - "retargeting the base library pre-merge suppresses, got <<" + old + ">>"); - - String modern = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.eachDependency { d -> " - + "d.useTarget 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22' } }\n"); - check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), - "a merged-era retarget keeps the alignment, got <<" + modern + ">>"); - } - - /** - * A resolution rule's {@code useVersion} rewrites what was requested, - * silently, on the way through -- so it holds the library as firmly as a - * force does. Such a rule names its artifact by comparing the parts, which - * is neither a coordinate nor a map entry, and was read as naming nothing. - */ - @Test - public void aResolutionRuleHoldsWhatItRewrites() { - String rule = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.eachDependency { d -> " - + "if (d.requested.group == 'org.jetbrains.kotlin' && " - + "d.requested.name == 'kotlin-stdlib') d.useVersion '1.7.22' } }\n"); - check("".equals(rule), - "a rule pinning the base library pre-merge suppresses, got <<" - + rule + ">>"); - - // Rewriting it to a merged-era version takes nothing away. - String modern = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.eachDependency { d -> " - + "if (d.requested.group == 'org.jetbrains.kotlin' && " - + "d.requested.name == 'kotlin-stdlib') d.useVersion '1.9.22' } }\n"); - check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), - "a merged-era rule keeps the alignment, got <<" + modern + ">>"); - } - - /** - * A variable declared without a value is still a name this knows, and - * recording it is what lets a later assignment be recognised as one. - */ - @Test - public void aDeclarationWithoutAValueIsStillADeclaration() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep\n" - + " if (legacy) {\n" - + " dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" - + " }\n" - + " implementation(dep) { version { strictly '1.7.22' } }\n"); - check("".equals(out), - "the conditional assignment is seen, got <<" + out + ">>"); - } - - /** - * A qualified type is one token. Stopping at its first dot read - * {@code java} as the type and {@code lang} as the name, so the variable - * was never recorded -- while {@code ext.kotlinVersion}, which is a dotted - * TARGET rather than a dotted type, still has to be read the other way. - */ - @Test - public void aQualifiedTypeIsOneToken() { - String[] types = {"java.lang.String", "String", "final java.lang.String"}; - for (int i = 0; i < types.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " " + types[i] - + " dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" - + " implementation(dep) { version { strictly '1.7.22' } }\n"); - check("".equals(out), - "a local of type " + types[i] + " is recorded, got <<" + out + ">>"); - } - - // The dotted extra property is still a property, not a type. - String ext = KotlinStdlibAlignment.constraintsBlock("implementation", - " ext.kotlinVersion = '1.9.22'\n" - + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion\"\n"); - check(ext.contains("kotlin-stdlib-jdk8:1.8.0") - && !ext.contains("kotlin-stdlib-jdk7:1.8.0"), - "ext.kotlinVersion still binds its name, got <<" + ext + ">>"); - } - - /** - * An assignment inside ANY open brace is one whose execution this cannot - * establish, closures included: {@code def mutate = { dep = '...' }} runs - * only if something calls it. Named control structures were listed here - * once and the list was already missing this. - */ - @Test - public void anAssignmentInsideAClosureDoesNotHideAPin() { - String multiLine = KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" - + " def mutate = {\n" - + " dep = 'com.example:other:1.0'\n" - + " }\n" - + " implementation(dep) { version { strictly '1.7.22' } }\n"); - check("".equals(multiLine), - "the pin survives an uninvoked closure, got <<" + multiLine + ">>"); - - // A first definition is still recorded at any depth, which is what keeps a - // `def` inside dependencies { } working. - String nested = KotlinStdlibAlignment.constraintsBlock("implementation", - " dependencies {\n" - + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" - + " implementation(dep) { version { strictly '1.7.22' } }\n" - + " }\n"); - check("".equals(nested), - "a definition inside a block is still read, got <<" + nested + ">>"); - } - - /** - * A release candidate of the floor is below the floor, wherever it appears. - * As a range's inclusive ceiling it was compared numerically and read as - * reaching the floor, so the constraints went in with nothing to resolve - * to. - */ - @Test - public void aPrereleaseCeilingDoesNotReachTheFloor() { - String rc = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib') " - + "{ version { strictly '[1.7.0,1.8.0-RC2]' } }\n"); - check("".equals(rc), - "a prerelease ceiling cannot reach the floor, got <<" + rc + ">>"); - - // The release itself can, and does. - String release = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:[1.7.0,1.8.0]'\n"); - check(release.contains("kotlin-stdlib-jdk8:1.8.0"), - "an inclusive release ceiling does, got <<" + release + ">>"); - } - - /** - * A coordinate concatenated onto a partial literal has no version here, - * and unreadable is the honest answer -- reading the empty string as a - * version put it below the floor and suppressed the block for a - * declaration that may well be merged-era. - */ - @Test - public void aConcatenatedVersionIsNotAnEmptyOne() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation(\"org.jetbrains.kotlin:kotlin-stdlib-jdk7:\" " - + "+ kotlinVersion)\n"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0") - && out.contains("kotlin-stdlib-jdk8:1.8.0"), - "both constraints are written, got <<" + out + ">>"); - } - - /** - * Gradle's status selectors have no ceiling, so they can select a - * merged-era shim. Compared as literals they parsed as zero, which is the - * oldest version there is. - */ - @Test - public void aStatusSelectorCanReachTheFloor() { - String[] selectors = {"latest.release", "latest.integration", "+"}; - for (int i = 0; i < selectors.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:" - + selectors[i] + "'\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - selectors[i] + " keeps the sibling aligned, got <<" + out + ">>"); - } - } - - /** - * A name assigned inside a conditional may hold either value, because - * whether the branch runs is decided at evaluation time. The ambiguity is - * resolved toward suppression: emitting beside a pin this could not see is - * the failure that reaches the device. - * - *

The single-line spelling was never affected -- braces do not split - * statements, so {@code if (c) { dep = '...' }} arrives as one statement - * that assigns nothing -- and it is asserted here so the difference is not - * mistaken for a gap later.

- */ - @Test - public void aConditionalReassignmentDoesNotHideAPin() { - String multiLine = KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" - + " if (project.hasProperty('other')) {\n" - + " dep = 'com.example:other:1.0'\n" - + " }\n" - + " implementation(dep) { version { strictly '1.7.22' } }\n"); - check("".equals(multiLine), - "the pin survives a conditional reassignment, got <<" + multiLine + ">>"); - - String oneLine = KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'; " - + "if (project.hasProperty('other')) { dep = 'com.example:other:1.0' }; " - + "implementation(dep) { version { strictly '1.7.22' } }\n"); - check("".equals(oneLine), - "and the one-line form, which never assigned at all, got <<" - + oneLine + ">>"); - - // The other direction was already safe and stays that way: a conditional - // assignment TO a Kotlin coordinate is taken, because taking it suppresses. - String gained = KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = 'com.example:other:1.0'\n" - + " if (project.hasProperty('old')) {\n" - + " dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" - + " }\n" - + " implementation(dep) { version { strictly '1.7.22' } }\n"); - check("".equals(gained), - "a conditional assignment to a coordinate is seen, got <<" + gained + ">>"); - - // Unconditionally, a reassignment still replaces what it replaces. - String plain = KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" - + " dep = 'com.example:other:1.0'\n" - + " implementation(dep)\n"); - check(plain.contains("kotlin-stdlib-jdk8:1.8.0"), - "an unconditional reassignment still applies, got <<" + plain + ">>"); - } - - /** - * A local may be named after the DSL key it supplies. Substituting every - * occurrence turned {@code group:} into a quoted string and lost the map - * form entirely, taking the strict pin inside it with it. - */ - @Test - public void aLocalNamedAfterAMapKeyDoesNotReplaceTheKey() { - String[] keys = {"group", "name", "version"}; - for (int k = 0; k < keys.length; k++) { - String value = "version".equals(keys[k]) ? "1.7.22!!" - : "name".equals(keys[k]) ? "kotlin-stdlib-jdk8" - : "org.jetbrains.kotlin"; - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def " + keys[k] + " = '" + value + "'\n" - + " implementation(group: " - + ("group".equals(keys[k]) ? "group" : "'org.jetbrains.kotlin'") - + ", name: " - + ("name".equals(keys[k]) ? "name" : "'kotlin-stdlib-jdk8'") - + ", version: " - + ("version".equals(keys[k]) ? "version" : "'1.7.22!!'") - + ")\n"); - check("".equals(out), - "the map form survives a local called " + keys[k] - + ", got <<" + out + ">>"); - } - } - - /** - * A rich version overrides the coordinate's own. Reporting the coordinate - * read a pre-merge pin as merged-era, so its own constraint was skipped as - * already satisfied while the sibling and the base were raised around it. - */ - @Test - public void aRichVersionOverridesTheCoordinate() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(out), - "the strict 1.7.22 is what decides, got <<" + out + ">>"); - - // And the other way round: a merged-era rich version over an old coordinate. - String modern = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.22') " - + "{ version { require '1.9.22' } }\n"); - check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), - "a required 1.9.22 is read past the old coordinate, got <<" + modern + ">>"); - } - - /** - * Setting a property is not calling a method. {@code { force = false }} - * explicitly turns forcing OFF, and reading the word as a force turned an - * ordinary version request into an absolute pin -- suppressing the block - * for a declaration asking for nothing of the kind. - */ - @Test - public void aForceThatIsSwitchedOffIsNotAForce() { - String off = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') " - + "{ force = false }\n"); - check(off.contains("kotlin-stdlib-jdk7:1.8.0") - && off.contains("kotlin-stdlib-jdk8:1.8.0"), - "force = false does not suppress, got <<" + off + ">>"); - - String on = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') " - + "{ force = true }\n"); - check("".equals(on), "force = true does, got <<" + on + ">>"); - } - - /** - * Every literal form Groovy has interpolates except the single-quoted - * ones, so a coordinate assembled inside any of the others carries its - * definitions with it. - */ - @Test - public void everyInterpolatingLiteralExpandsItsDefinitions() { - String[] assembled = { - "\"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v\"", - "\"\"\"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v\"\"\"", - "$/org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v/$", - }; - for (int i = 0; i < assembled.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def v = '1.9.22'\n" - + " def dep = " + assembled[i] + "\n" - + " implementation dep\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the sibling is aligned for <<" + assembled[i] + ">>, got <<" + out + ">>"); - check(!out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and the declaration is read as merged-era, got <<" + out + ">>"); - } - - // A single-quoted literal does not interpolate, so $v is not a version and - // the conservative answer stands. - String literal = KotlinStdlibAlignment.constraintsBlock("implementation", - " def v = '1.9.22'\n" - + " def dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v'\n" - + " implementation dep\n"); - check("".equals(literal), - "an uninterpolated $v is not a version, got <<" + literal + ">>"); - } - - /** - * A reason is prose to the version scan as well. It reached the reason's - * coordinate before the map's own version entry, so the comment describing - * an old artifact supplied the version for the declaration warning about - * it -- and took the whole block down. - */ - @Test - public void aReasonDoesNotSupplyTheVersion() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation(group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk7', version: '1.9.22') " - + "{ because 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.22' }\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the sibling is still aligned, got <<" + out + ">>"); - check(!out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and the declared 1.9.22 is what was read, got <<" + out + ">>"); - } - - /** - * Every spelling Gradle has for a force is a force. {@code force} is the - * method, {@code setForcedModules} its setter, {@code forcedModules} the - * property, and all three hold a module absolutely -- so all three leave - * these constraints raising the shims to empty jars beside a base library - * that stayed pre-merge. - */ - @Test - public void everySpellingOfAForceIsAForce() { - String[] spellings = { - " configurations.all { resolutionStrategy.force " - + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' }\n", - " configurations.all { resolutionStrategy.setForcedModules(" - + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22') }\n", - " configurations.all { resolutionStrategy.forcedModules = " - + "['org.jetbrains.kotlin:kotlin-stdlib:1.7.22'] }\n", - " configurations.all { resolutionStrategy.forcedModules=" - + "['org.jetbrains.kotlin:kotlin-stdlib:1.7.22'] }\n", - }; - for (int i = 0; i < spellings.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - spellings[i]); - check("".equals(out), - "the force is read from <<" + spellings[i] + ">>, got <<" + out + ">>"); - } - } - - /** - * A definition may interpolate an earlier one. Recorded as written, the - * version stayed the text {@code $v} -- no version, so below the floor, - * so the whole block suppressed for a project that was already - * merged-era and still had a duplicate to fix. - */ - @Test - public void aDefinitionMayInterpolateAnEarlierOne() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def v = '1.9.22'\n" - + " def dep = \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v\"\n" - + " implementation dep\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the sibling is still aligned, got <<" + out + ">>"); - check(!out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and the merged-era declaration is left alone, got <<" + out + ">>"); - - // The same chain below the floor is still below it. - String old = KotlinStdlibAlignment.constraintsBlock("implementation", - " def v = '1.7.22!!'\n" - + " def dep = \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$v\"\n" - + " implementation dep\n"); - check("".equals(old), - "a pre-merge chain still suppresses, got <<" + old + ">>"); - } - - /** - * Line endings are not this class's business to have an opinion about. A - * fragment written on Windows put a carriage return after - * {@code strictly}, and the token-end test accepted only a space, a tab or - * an open parenthesis -- so the call stopped being a call and the pin - * behind it was never read. - */ - @Test - public void aCarriageReturnSeparatesTokensLikeAnyOtherBlank() { - String[] endings = {"\r\n", "\n", "\r"}; - for (int i = 0; i < endings.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib')" + endings[i] - + " { version { strictly" + endings[i] + " '1.7.22' } }" - + endings[i]); - check("".equals(out), - "the strict call survives the line ending, got <<" + out + ">>"); - } - } - - /** - * A trailing closure still belongs to its call with a blank line between - * them. Comment stripping leaves an empty statement where a comment-only - * line was, and looking at only the very next statement left the closure - * -- and the strict version inside it -- attached to nothing. - */ - @Test - public void aTrailingClosureSurvivesABlankLine() { - String[] between = { - "\n", - "\n // why this pin is here\n", - "\n\n /* and a block one */\n\n", - }; - for (int i = 0; i < between.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.7.22')" - + between[i] - + " { version { strictly '1.7.22' } }\n"); - check("".equals(out), - "the closure is still the call's, across <<" - + between[i].replace("\n", "\\n") + ">>, got <<" + out + ">>"); - } - } - - /** - * Inside a dollar-slashy literal the dollar escapes itself and a slash, so - * {@code $/} is a slash and not the closer. Taking the first {@code /$} - * substring ended the literal early and put the scanner back into code - * halfway through a string. - */ - @Test - public void aDollarEscapeDoesNotCloseADollarSlashyLiteral() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def marker = $/not closed $/$ can't/$; " - + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(out), - "the escaped delimiter did not end the literal, got <<" + out + ">>"); - - // And $$ is a dollar, not the start of one. - String dollars = KotlinStdlibAlignment.constraintsBlock("implementation", - " def marker = $/cost $$5 can't/$; " - + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(dollars), - "an escaped dollar is content, got <<" + dollars + ">>"); - } - - /** - * Removing a comment leaves the whitespace it was. A comment separates - * tokens in the language, so deleting it outright joined them: - * {@code strictly/* pin *}{@code /'1.7.22'} became strictly'1.7.22', which - * is not a call to strictly, and the strict pin behind it was never seen. - */ - @Test - public void removingACommentLeavesTheWhitespaceItWas() { - String[] joined = { - " implementation('org.jetbrains.kotlin:kotlin-stdlib') " - + "{ version { strictly/* pin */'1.7.22' } }\n", - " def/* local */dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'; " - + "implementation(dep) { version { strictly '1.7.22' } }\n", - " implementation/* which */('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')\n", - }; - for (int i = 0; i < joined.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", joined[i]); - check("".equals(out), - "the comment did not join the tokens around it in <<" + joined[i] - + ">>, got <<" + out + ">>"); - } - } - - /** - * A slash after anything that is not a value opens a literal. - * - *

Swept over the operators rather than asserted one at a time, because - * one at a time is how the rule was built and it took four review rounds - * to still be incomplete: the closure arrow, then the comparison, then - * Groovy's {@code =~} and {@code ==~}. The code no longer enumerates this - * half at all -- it enumerates the closed one, what a value can end with -- - * so this test is where the open half is written down.

- */ - @Test - public void aSlashAfterAnythingThatIsNotAValueOpensALiteral() { - String[] operators = { - "=", "=~", "==~", "~", "->", ",", "(", "[", ":", "&&", "||", - "!", "?", "+", "-", "*", "%", "^", "|", "&", "<", ">", "<=", ">=", - "==", "!=", "<<", "?:", - }; - for (int i = 0; i < operators.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def marker = [ name " + operators[i] + " /can't/ ]; " - + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(out), - "a slash after " + operators[i] - + " opens a literal, so the pin behind it is still seen; got <<" - + out + ">>"); - } - } - - /** - * And a slash after a value divides it. This is the half the code - * enumerates, so it is the half that must stay closed: adding to it is how - * a division starts swallowing the statements after it. - */ - @Test - public void aSlashAfterAValueDividesIt() { - String[] values = { - "total", "2", "count()", "sizes[0]", "(a + b)", "1.5", "n++", "n--", - }; - for (int i = 0; i < values.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def ratio = " + values[i] + " / divisor; " - + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(out), - "dividing " + values[i] - + " does not swallow the pin after it; got <<" + out + ">>"); - } - } - - /** - * A forced version suppresses the block, like a strict one. - * - *

A force does not conflict with a constraint, it wins over it without - * a word: force the base library to 1.7.22 and these constraints still - * raise the shims to their EMPTY 1.8.0 jars, so the jdk7/jdk8 classes are - * in no selected jar at all. The build is green and the app throws on the - * device, which is the one outcome worth all of this machinery.

- */ - @Test - public void aForcedVersionIsHeldAsFirmlyAsAStrictOne() { - String[] forced = { - " configurations.all { resolutionStrategy.force " - + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' }\n", - " configurations.all { resolutionStrategy { force " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22' } }\n", - " configurations.all {\n resolutionStrategy {\n" - + " force 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" - + " }\n }\n", - }; - for (int i = 0; i < forced.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", forced[i]); - check("".equals(out), - "a forced pre-merge version suppresses the block, from <<" - + forced[i] + ">> got <<" + out + ">>"); - } - - // A force at or above the floor takes nothing away: it is already a shim. - String modern = KotlinStdlibAlignment.constraintsBlock("implementation", - " configurations.all { resolutionStrategy.force " - + "'org.jetbrains.kotlin:kotlin-stdlib:1.9.22' }\n"); - check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), - "a merged-era force still gets the alignment, got <<" + modern + ">>"); - - // And the word in a reason is prose, as everywhere else. - String prose = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('com.example:other:1.0') " - + "{ because 'we force nothing here' }\n"); - check(prose.contains("kotlin-stdlib-jdk8:1.8.0"), - "the word force in prose is not a force, got <<" + prose + ">>"); - } - - /** - * A slashy literal may open after a closure arrow, and may run past the - * end of its line -- but only when it actually closes. An opener misread - * with no closing slash anywhere would swallow every statement after it, - * and a suppression reached that way says nothing about the app. - */ - @Test - public void aSlashyLiteralSpansLinesOnlyWhenItCloses() { - String afterArrow = KotlinStdlibAlignment.constraintsBlock("implementation", - " def marker = { -> /can't/ }; " - + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(afterArrow), - "a slashy literal after a closure arrow, got <<" + afterArrow + ">>"); - - String multiline = KotlinStdlibAlignment.constraintsBlock("implementation", - " def marker = /first\n still can't/\n" - + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(multiline), - "a literal that spans lines keeps its content, got <<" + multiline + ">>"); - - // Unterminated: whatever that slash was, it does not reach the next line. - String unterminated = KotlinStdlibAlignment.constraintsBlock("implementation", - " def ratio = a / b\n" - + " implementation 'androidx.appcompat:appcompat:1.6.1'\n"); - check(unterminated.contains("kotlin-stdlib-jdk7:1.8.0") - && unterminated.contains("kotlin-stdlib-jdk8:1.8.0"), - "an unclosed slash stops at its line, got <<" + unterminated + ">>"); - } - - /** - * A coordinate may carry a classifier and an {@code @extension} after its - * version, and neither is part of the version. Returning them made - * {@code 1.7.22!!@jar} not end in the strict marker, so a strict pre-merge - * pin read as an ordinary one and the constraint went in beside it. - */ - @Test - public void aModifierAfterTheVersionIsNotPartOfIt() { - String[] pinned = { - "org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!@jar", - "org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!:sources", - "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!@aar", - }; - for (int i = 0; i < pinned.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation '" + pinned[i] + "'\n"); - check("".equals(out), - "the strict marker is still read in <<" + pinned[i] - + ">>, got <<" + out + ">>"); - } - - // And a merged-era one with the same modifiers is still merged-era. - String modern = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22@jar'\n"); - check(!modern.contains("kotlin-stdlib-jdk8:1.8.0"), - "a modern version is read past its modifier too, got <<" + modern + ">>"); - } - - /** - * A slashy literal may follow a keyword. Division needs a value on its - * left and a keyword is not one, so `return /can't/` opens a literal for - * the same reason `= /can't/` does -- and read as a quote instead, the - * apostrophe swallows whatever declaration follows it. - */ - @Test - public void aSlashyLiteralMayFollowAKeyword() { - String[] keywords = {"return", "in", "new"}; - for (int k = 0; k < keywords.length; k++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def note = { " + keywords[k] + " /can't/ }; " - + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(out), - "a slashy literal after " + keywords[k] - + " does not hide the pin, got <<" + out + ">>"); - } - - // A word that is not a keyword is a variable, and dividing it is division. - String division = KotlinStdlibAlignment.constraintsBlock("implementation", - " def ratio = total / count; " - + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(division), - "and dividing a variable is still division, got <<" + division + ">>"); - } - - /** - * A coordinate keeps its meaning in every literal form Groovy has, the - * slashy ones included. Recognising a form in the scanners but not in the - * matchers left the pin visible to neither. - */ - @Test - public void aSlashyCoordinateIsStillACoordinate() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation($/org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22/$) " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(out), "a dollar-slashy coordinate is read, got <<" + out + ">>"); - - String plain = KotlinStdlibAlignment.constraintsBlock("implementation", - " def marker = /can't/; " - + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(plain), - "an apostrophe inside a slashy literal is not a quote, got <<" + plain + ">>"); - } - - /** - * Division is not a literal. The slashy form is only recognised where an - * expression may begin, because reading `total / 2` as an opener would - * swallow everything up to the next slash -- which is the same failure, - * from the opposite direction, as not recognising the literal at all. - */ - @Test - public void divisionIsNotASlashyLiteral() { - String[] arithmetic = { - "def half = total / 2", - "def part = (a + b) / 2", - "def ratio = sizes[0] / sizes[1]", - }; - for (int i = 0; i < arithmetic.length; i++) { - // On ONE line with the pin, because a slashy literal stops at the end of - // its line: put the pin on the next one and a swallowed division costs - // nothing, which is a test that passes with the guard removed. - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " " + arithmetic[i] - + "; implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(out), - "division does not swallow what follows <<" + arithmetic[i] - + ">>, got <<" + out + ">>"); - } - } - - /** - * Extra properties are written as a closure at least as often as with a - * dot, and inside one a bare assignment really does bind the name the - * interpolation reads. - */ - @Test - public void anExtraPropertiesClosureDefinesItsNames() { - String[] spellings = { - " ext { kotlinVersion = '1.9.22' }\n", - " ext {\n kotlinVersion = '1.9.22'\n }\n", - " ext {\n kotlinVersion = '1.9.22'\n somethingElse = 'x'\n }\n", - }; - for (int i = 0; i < spellings.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - spellings[i] - + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion\"\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the sibling is aligned for <<" + spellings[i] + ">>, got <<" + out + ">>"); - check(!out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and the merged-era declaration is left alone, got <<" + out + ">>"); - } - - // Outside such a block a bare assignment binds nothing this can follow. - String elsewhere = KotlinStdlibAlignment.constraintsBlock("implementation", - " android { kotlinVersion = '1.9.22' }\n" - + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion\"\n"); - check("".equals(elsewhere), - "an assignment outside ext stays unreadable, got <<" + elsewhere + ">>"); - } - - /** - * Gradle's extra properties are how a project-wide Kotlin version is - * nearly always written, and the bare name the interpolation reads really - * is bound by them. Stopping at {@code ext} left the version unreadable, - * which counts as below the floor -- so a project already on a merged-era - * Kotlin had the whole block suppressed and kept whatever pre-merge shim a - * transitive dependency dragged in. - */ - @Test - public void anExtraPropertyDefinesTheVersionItInterpolates() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " ext.kotlinVersion = '1.9.22'\n" - + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion\"\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the sibling is still aligned, got <<" + out + ">>"); - check(!out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and the merged-era declaration is left alone, got <<" + out + ">>"); - - // Only that one prefix: any dotted assignment would let an unrelated - // property supply a version it does not bind, which turns an unreadable - // version into a confidently wrong one. - String unrelated = KotlinStdlibAlignment.constraintsBlock("implementation", - " somePlugin.kotlinVersion = '1.9.22'\n" - + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion\"\n"); - check("".equals(unrelated), - "an unrelated dotted assignment stays unreadable, got <<" + unrelated + ">>"); - } - - /** - * A reason is prose however it is quoted. Read as a declaration, the - * comment describing the duplicate switches off the constraint that - * prevents it -- which is the whole block gone because of a warning about - * the thing the block exists to fix. - */ - @Test - public void aReasonIsProseInEveryDelimiter() { - String[] quotes = {"'", "\"", "'''", "\"\"\""}; - for (int q = 0; q < quotes.length; q++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('com.example:other:1.0') { because " + quotes[q] - + "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22" - + quotes[q] + " }\n"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0") - && out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a reason quoted with " + quotes[q] - + " is not a declaration, got <<" + out + ">>"); - } - } - - /** - * How a literal is delimited changes nothing about what it says, so the - * same declaration written four ways produces the same block. Asserted as - * an equivalence rather than case by case because the strict sweep already - * passed the triple-quoted spelling for the wrong reason: the version came - * back as {@code ""1.9.22""}, no version parsed out of it, and an - * unreadable version counts as below the floor -- which happens to be the - * safe answer, so nothing failed while the read was wrong. - */ - @Test - public void theDelimiterDoesNotChangeWhatADeclarationSays() { - String[] quotes = {"'", "\"", "'''", "\"\"\""}; - String[] shapes = { - "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " - + "{ version { strictly %s1.9.22%s } }", - "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " - + "{ version { require %s1.9.22%s } }", - "implementation group: %sorg.jetbrains.kotlin%s, " - + "name: %skotlin-stdlib-jdk8%s, version: '1.9.22'", - "dependencies.add(%simplementation%s, " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22')", - }; - for (int s = 0; s < shapes.length; s++) { - String expected = null; - for (int q = 0; q < quotes.length; q++) { - String text = shapes[s].replace("%s", quotes[q]); - String out = KotlinStdlibAlignment.constraintsBlock("implementation", text); - if (expected == null) { - expected = out; - continue; - } - check(expected.equals(out), - "the delimiter does not change the answer for <<" + text - + ">>: expected <<" + expected + ">> got <<" + out + ">>"); - } - } - } - - /** - * A pre-merge shim added through {@code dependencies.add} suppresses the - * block, whichever delimiter names the configuration. Emitting beside it - * raises kotlin-stdlib past the app's own class-bearing 1.7.22 jar, which - * is this block manufacturing the duplicate it exists to prevent. - */ - @Test - public void anAddedPreMergeShimSuppressesTheBlock() { - String[] quotes = {"'", "\"", "'''", "\"\"\""}; - for (int q = 0; q < quotes.length; q++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " dependencies.add(" + quotes[q] + "implementation" + quotes[q] - + ", 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!')\n"); - check("".equals(out), - "an added pre-merge shim suppresses the block, named with " - + quotes[q] + " but got <<" + out + ">>"); - } - } - - /** - * The complement of the sweep below, and the direction that fails in - * silence: an app whose Gradle text says nothing about Kotlin still gets - * both constraints. Every recognition rule added to this class is a new - * way to conclude "the app has this covered", and concluding it wrongly - * does not fail anything -- it just hands the duplicate class back to the - * app this whole change exists to fix, with no signal anywhere. - */ - @Test - public void ordinaryProjectTextStillGetsTheAlignment() { - String[] ordinary = { - "implementation 'androidx.appcompat:appcompat:1.6.1'", - "implementation('com.android.billingclient:billing:9.1.0')", - "implementation group: 'com.google.android.material', name: 'material', " - + "version: '1.11.0'", - "def v = '1.7.22'\nimplementation(\"com.squareup.okhttp3:okhttp:$v\")", - "annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'", - "implementation fileTree(dir: 'libs', include: ['*.jar'])", - // Commented out is not declared, in either comment syntax. - "// implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'", - "/* implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!' */", - // And a reason string is prose, not a pin. - "implementation('a:b:1.0') { because 'strictly 1.7.22 was never wanted' }", - "testImplementation 'junit:junit:4.13.2'", - "", - }; - String[] decorations = { - "%s", " %s", "dependencies {\n%s\n}", "%s // note", - "%s\nimplementation 'com.google.code.gson:gson:2.10.1'", - }; - for (int o = 0; o < ordinary.length; o++) { - for (int d = 0; d < decorations.length; d++) { - String text = decorations[d].replace("%s", ordinary[o]); - String out = KotlinStdlibAlignment.constraintsBlock("implementation", text); - check(out.contains("kotlin-stdlib-jdk7:1.8.0") - && out.contains("kotlin-stdlib-jdk8:1.8.0"), - "both constraints are still written for <<" + text - + ">> but got <<" + out + ">>"); - } - } - } - - /** - * Every equivalent way of writing a strict pre-merge pin suppresses the - * block. This is a sweep rather than an example, because the examples were - * being found one review comment at a time while the same defect sat in - * three different places: a triple-quoted definition expanded to - * {@code ""1.7.22""}, no version was parsed out of it, and the constraint - * went in beside the strict pin -- which does not fail the build, it - * silently strips the classes and throws NoClassDefFoundError on the - * device. That is the one outcome this class must never produce, so the - * property is asserted over the whole spelling space and not over the - * spellings somebody happened to think of. - */ - @Test - public void everySpellingOfAStrictPreMergePinSuppressesTheBlock() { - String[] artifacts = {"kotlin-stdlib", "kotlin-stdlib-jdk7", "kotlin-stdlib-jdk8"}; - String[] quotes = {"'", "\"", "'''", "\"\"\""}; - String[] configurations = {"implementation", "api", "compile"}; - int checked = 0; - for (int a = 0; a < artifacts.length; a++) { - String coordinate = "org.jetbrains.kotlin:" + artifacts[a]; - for (int q = 0; q < quotes.length; q++) { - String u = quotes[q]; - for (int c = 0; c < configurations.length; c++) { - String on = configurations[c]; - String[] forms = { - on + "(" + u + coordinate + ":1.7.22!!" + u + ")", - // A classifier or @extension sits after the version, not in it. - on + "(" + u + coordinate + ":1.7.22!!@jar" + u + ")", - on + " " + u + coordinate + ":1.7.22!!" + u, - on + "(" + u + coordinate + u + ") { version { strictly " - + u + "1.7.22" + u + " } }", - on + "(" + u + coordinate + u + ")\n{ version { strictly " - + u + "1.7.22" + u + " } }", - "def v = " + u + "1.7.22" + u + "\n" + on + "(\"" - + coordinate + ":$v!!\")", - "def d = " + u + coordinate + ":1.7.22!!" + u + "\n" + on + "(d)", - // A strict pin whose version this cannot evaluate is still a - // strict pin; unreadable has to fall to the conservative side. - on + "(" + u + coordinate + u + ") { version { strictly kotlinVersion } }", - // The definition carries the coordinate and NOTHING else -- - // no version, no !! -- so the only thing that can suppress is - // the name being carried across to the strict usage. Written - // with the marker in the definition instead, these passed with - // the inlining switched off: that line names the artifact and - // ends in !!, so it suppressed on its own and the test proved - // nothing. However many modifiers the local was written with: - "String d = " + u + coordinate + u + "; " + on - + "(d) { version { strictly " + u + "1.7.22" + u + " } }", - "final String d = " + u + coordinate + u + "; " + on - + "(d) { version { strictly " + u + "1.7.22" + u + " } }", - "private static final String d = " + u + coordinate + u + "; " + on - + "(d) { version { strictly " + u + "1.7.22" + u + " } }", - // An apostrophe inside a dollar-slashy literal is not a quote. - "def m = $/can't/$; " + on + "(" + u + coordinate + ":1.7.22!!" + u + ")", - }; - for (int f = 0; f < forms.length; f++) { - String[] decorated = { - forms[f], - " " + forms[f], - "\t" + forms[f] + " ", - forms[f] + " // a note", - "/* lead */ " + forms[f], - "dependencies {\n" + forms[f] + "\n}", - "repositories { mavenCentral() }\n" + forms[f], - forms[f] + "\nimplementation 'androidx.appcompat:appcompat:1.6.1'", - "implementation 'androidx.appcompat:appcompat:1.6.1'\n" + forms[f], - }; - for (int d = 0; d < decorated.length; d++) { - checked++; - String out = KotlinStdlibAlignment.constraintsBlock( - "implementation", decorated[d]); - check("".equals(out), - "a strict pre-merge pin suppresses the block, written as <<" - + decorated[d] + ">> but got <<" + out + ">>"); - } - } - } - } - } - check(checked > 2000, "the sweep really ran over the matrix: " + checked); - } - - /** - * A preference is soft: Gradle takes it only when nothing stronger is in - * play, so a transitive requirement for a pre-merge shim beats it. Reading - * one as proof the artifact cannot resolve below the floor suppressed the - * constraint that was the only thing standing between that graph and the - * duplicate. - */ - @Test - public void aPreferenceDoesNotStandInForTheConstraint() { - String modern = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " - + "{ version { prefer '1.9.22' } }\n"); - check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), - "a preferred version does not suppress the constraint"); - - String old = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " - + "{ version { prefer '1.7.22' } }\n"); - check(old.contains("kotlin-stdlib-jdk8:1.8.0"), - "and neither does an old one, which the floor simply overrides"); - - // A requirement AT OR ABOVE the floor does stand in for it: it already - // satisfies the constraint, so leaving that artifact to the app says - // something true. Below the floor it does not -- there the constraint - // raises it, and skipping is what left a softly-required shim pre-merge. - String required = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " - + "{ version { require '1.9.22' } }\n"); - check(!required.contains("kotlin-stdlib-jdk8:1.8.0"), - "a merged-era requirement stands in for the constraint"); - - String raised = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8') " - + "{ version { require '1.7.22' } }\n"); - check(raised.contains("kotlin-stdlib-jdk8:1.8.0"), - "and a pre-merge one is raised rather than honoured"); - - // A requirement that OVERRIDES a coordinate is still read as the version - // that declaration carries -- soft is about whether it pins, not about - // whether it is read. - String overridden = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { require '1.9.22' } }\n"); - check(overridden.contains("kotlin-stdlib-jdk7:1.8.0"), - "the requirement is read past the coordinate, got <<" - + overridden + ">>"); - } - - /** - * A map value written with the long delimiter keeps its content. Stripping - * one character per side left the group and name wearing two quotes, so - * both failed their exact match and the declaration was ignored. - */ - @Test - public void aTripleQuotedMapValueKeepsItsContent() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation group: '''org.jetbrains.kotlin''', " - + "name: '''kotlin-stdlib-jdk8''', version: '1.9.22'\n"); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a triple-quoted map declaration still pins its artifact"); - } - - /** - * A typed local declares as much as def does. - */ - @Test - public void aTypedLocalDefinesACoordinate() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " String dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" - + " implementation(dep) { version { strictly '1.7.22' } }\n"); - check("".equals(out), "a typed local carries the coordinate too"); - } - - /** - * Groovy's dollar-slashy literal may open with a slash, which the comment - * scanner read as a line comment and used to discard the rest of the - * fragment, strict pin included. The plain slashy form is recognised too - * now, positionally -- see divisionIsNotASlashyLiteral for the half of - * that rule which says what is NOT a literal. - */ - @Test - public void aDollarSlashyLiteralDoesNotOpenAComment() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - // On one line, because a line comment only reaches the end of its - // own line: put the pin on the next one and the test passes with - // the literal unrecognised, which proves nothing. - " def marker = $//*/$; " - + "implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(out), - "the strict pin after a dollar-slashy literal is still seen"); - } - - /** - * A reason can be nothing BUT a coordinate, so the whitespace rule does not - * catch it. `because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'` - * names the artifact it warns about; read as a declaration it supplied a - * pre-merge version and suppressed the whole block -- the comment - * describing the duplicate switching off the constraint that prevents it. - */ - @Test - public void aReasonThatIsOnlyACoordinateIsStillAReason() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('com.example:other:1.0') " - + "{ because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22' }\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a bare-coordinate reason does not declare anything"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and does not take the block with it"); - } - - /** - * A triple-quoted coordinate keeps its version. Stripping one character - * per side left the long delimiter's extra quotes on the content, so the - * version was unreadable and the declaration read as pre-merge. - */ - @Test - public void aTripleQuotedCoordinateKeepsItsVersion() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation '''org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22'''\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a merged-era triple-quoted declaration leaves the sibling constrained"); - check(!out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and pins its own artifact"); - } - - /** - * The fragments are separate build hints but one generated script, so a - * def written in one is in scope for the next. Reading them apart lost the - * definition at the boundary and the strict pin behind it went unseen. - */ - @Test - public void aDefinitionCrossesTheFragmentBoundary() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def stdlib = 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n", - " implementation(stdlib) { version { strictly '1.7.22' } }\n"); - check("".equals(out), - "a definition in one fragment reaches a use in the next"); - } - - /** - * A triple-quoted literal is a different delimiter, not three of the same - * one. Reading its opener as a single quote made it close on the first - * apostrophe inside it and threw every following statement out of step, so - * a strict pin after it was never seen. - */ - @Test - public void aTripleQuotedLiteralDoesNotEndOnItsOwnApostrophe() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def note = '''can't stop'''\n" - + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n"); - check("".equals(out), - "the strict pin after a triple-quoted note is still seen"); - } - - /** - * A runtimeOnly pre-merge pin suppresses both constraints. - * - *

This pins existing behaviour rather than verifying a fix: it was - * reported as broken, and reverting the change it prompted leaves this - * passing, because the configuration predicate accepts every main - * configuration whatever it is handed. Kept because the behaviour is worth - * holding, and labelled so nobody reads it as proof of something it does - * not test.

- */ - @Test - public void aRuntimeOnlyPreMergePinSuppressesBoth() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " runtimeOnly 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n"); - check("".equals(out), - "a runtimeOnly pre-merge pin takes the sibling constraint with it"); - } - - /** - * The strict bypass reads both spellings. Asking only about the strictly - * keyword let a !! pin on a variant configuration be filtered out as a - * variant declaration and get the constraint anyway -- against a strict - * requirement that resolved fine before it. - */ - @Test - public void aShorthandPinOnAVariantIsStillStrict() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " debugImplementation " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n"); - check("".equals(out), - "a !! pin on a variant configuration is honoured like a strictly call"); - - // and a variant declaration that is NOT strict still does not suppress - String plain = KotlinStdlibAlignment.constraintsBlock("implementation", - " debugImplementation " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); - check(plain.contains("kotlin-stdlib-jdk8:1.8.0"), - "a plain variant declaration still does not suppress"); - } - - /** - * A known definition referred to as $name inside a double-quoted string is - * the same one hop already followed for a bare token. Reading it as - * unreadable made a merged-era version look pre-merge and took the - * sibling's constraint down with it. - */ - @Test - public void aKnownDefinitionExpandsInsideAnInterpolatedString() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def kotlinVersion = '1.9.22'\n" - + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion\"\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the expanded version is merged-era, so the sibling stays constrained"); - - String braced = KotlinStdlibAlignment.constraintsBlock("implementation", - " def v = '1.7.22!!'\n" - + " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:${v}\"\n"); - check("".equals(braced), "and a pre-merge one still suppresses both"); - - // an UNKNOWN name stays unreadable, which is the conservative path - String unknown = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation \"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$mystery\"\n"); - check("".equals(unknown), "an unknown name is still unreadable"); - } - - /** - * Definitions are applied in statement order. Substituting a variable's - * FIRST value into every use of it made a statement after a reassignment - * read as a declaration of the old value -- turning a debug-only Kotlin - * pin into a main-variant one and dropping the jdk8 constraint. - */ - @Test - public void aReassignedVariableUsesItsCurrentValue() { - // Ordered the other way round on purpose: with a two-pass map the LAST value - // wins everywhere, so the main declaration above the reassignment reads as a - // Kotlin pin it never was. Only walking in order gets this right. - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = 'com.example:other:1.0'\n" - + " implementation(dep)\n" - + " dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n" - + " debugImplementation(dep)\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the main declaration uses the value in force where it stands"); - - // and a reassignment to something unreadable forgets the name rather than - // leaving the old value standing - String forgotten = KotlinStdlibAlignment.constraintsBlock("implementation", - " def dep = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n" - + " dep = someFunction()\n" - + " implementation(dep)\n"); - check(forgotten.contains("kotlin-stdlib-jdk8:1.8.0"), - "an unreadable reassignment forgets the old literal"); - } - - /** - * The map form has to match the declared GROUP, not the group appearing - * anywhere. A fork under another group whose reason merely mentions - * org.jetbrains.kotlin combined with an unrelated artifact name and read - * as a Kotlin shim -- and since its version was below the floor, both - * constraints went. - */ - @Test - public void theMapFormMatchesTheDeclaredGroup() { - String fork = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation(group: 'com.example', name: 'kotlin-stdlib-jdk8', " - + "version: '1.0') { because 'fork of org.jetbrains.kotlin' }\n"); - check(fork.contains("kotlin-stdlib-jdk8:1.8.0"), - "another group's artifact is not our shim"); - check(fork.contains("kotlin-stdlib-jdk7:1.8.0"), - "and it does not take the block with it"); - - // the real map form still counts - String real = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk8', version: '1.9.22'\n"); - check(!real.contains("kotlin-stdlib-jdk8:1.8.0"), - "the real group still pins jdk8"); - } - - /** - * A rich-version closure can say what version is meant with a keyword - * other than strictly. Reading only strictly left `version { require }` - * with no version, which the conservative path treated as below the floor - * -- dropping both constraints for a declaration already merged-era. - */ - @Test - public void aRequiredRichVersionIsAVersionToo() { - String required = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') " - + "{ version { require '1.9.22' } }\n"); - check(required.contains("kotlin-stdlib-jdk8:1.8.0"), - "a required merged-era version leaves the sibling constrained"); - - String preferred = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') " - + "{ version { prefer '1.9.22' } }\n"); - check(preferred.contains("kotlin-stdlib-jdk8:1.8.0"), - "and so does a preferred one"); - - // Below the floor it does NOT take both. This once asserted the - // opposite, which was the wrong call: a requirement is soft, so the - // constraint raises it and the two resolve to 1.8.0 together. Standing - // the block down there left the shim at 1.7.22 beside whatever selected - // a merged-era base -- the duplicate this exists to prevent, in the graph - // it exists for. - String old = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') " - + "{ version { require '1.7.22' } }\n"); - check(old.contains("kotlin-stdlib-jdk7:1.8.0") - && old.contains("kotlin-stdlib-jdk8:1.8.0"), - "a soft pre-merge requirement is raised, not honoured, got <<" - + old + ">>"); - - // The `!!` suffix inside a requirement is Gradle's strict shorthand, and - // that one really does pin. - String strict = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') " - + "{ version { require '1.7.22!!' } }\n"); - check("".equals(strict), "a strict requirement still takes both, got <<" - + strict + ">>"); - } - - /** - * Gradle accepts more than a literal version, and each shape parsed as - * zero before -- classifying a merged-era declaration as pre-merge and - * dropping BOTH constraints, including the sibling's, which is the one - * such a graph still needs. What matters is the lowest version the - * selector can resolve to. - */ - @Test - public void aVersionSelectorIsReadByItsLowerBound() { - // exact range at the floor: not below it - String exact = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:[1.8.0]'\n"); - check(exact.contains("kotlin-stdlib-jdk8:1.8.0"), - "an exact merged-era range leaves the sibling constrained"); - - // dynamic selector that cannot go below the floor - String dynamic = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.+'\n"); - check(dynamic.contains("kotlin-stdlib-jdk8:1.8.0"), - "1.8.+ cannot resolve below the floor"); - - // A range that STARTS below the floor but can still select above it keeps - // the alignment. This was the conservative case once, on the grounds that - // the range reaches below the floor at all; the question that decides - // resolution is the other end. Gradle picks the highest version satisfying - // every constraint, so [1.7.0,1.9.0) selects a merged-era shim and our - // constraint on the SIBLING intersects that rather than conflicting with it - // -- and suppressing instead left an old transitive jdk8 unaligned beside a - // merged stdlib, which is the duplicate this class exists to prevent. - // - // The residual case is a range whose upper versions do not exist in the - // repository, where Gradle falls back to something pre-merge and the - // duplicate returns. That fails loudly in checkDuplicateClasses, which is - // where the app already was, so it is the better of the two. - String spanning = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:[1.7.0,1.9.0)'\n"); - check(spanning.contains("kotlin-stdlib-jdk8:1.8.0"), - "a range that can select above the floor keeps the sibling aligned, got <<" - + spanning + ">>"); - - // A range that CANNOT reach the floor still suppresses: the constraint would - // have nothing to resolve to and the build would fail outright. - String capped = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:[1.6.0,1.8.0)'\n"); - check("".equals(capped), - "a range capped below the floor suppresses, got <<" + capped + ">>"); - - String low = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:[1.6.0,1.7.9]'\n"); - check("".equals(low), "and so does one entirely below it, got <<" + low + ">>"); - - // 1.7.+ cannot leave 1.7; 1.+ can reach 1.9. - String narrow = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.+'\n"); - check("".equals(narrow), "1.7.+ cannot reach the floor, got <<" + narrow + ">>"); - String wide = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.+'\n"); - check(wide.contains("kotlin-stdlib-jdk8:1.8.0"), - "1.+ can, got <<" + wide + ">>"); - - // and a dynamic selector below the floor likewise - String oldDynamic = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.+'\n"); - check("".equals(oldDynamic), "1.7.+ is below the floor"); - } - - /** - * Whether a declaration is strict and what version it is strict AT are two - * questions. Asking only the second let `version { strictly kotlinVersion }` - * read as not strict at all -- the opposite of the conservative path - * documented everywhere else, and the one case where being wrong costs a - * failed resolution rather than an override. - */ - @Test - public void aStrictPinWithAnUnreadableVersionStillSuppresses() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.7.22') " - + "{ version { strictly kotlinVersion } }\n"); - check("".equals(out), - "a strict pin whose version cannot be read takes the conservative path"); - } - - /** - * Groovy's command syntax drops the parentheses, and requiring them - * rejected a declaration carrying an explicit strict pin. - */ - @Test - public void theParenthesisFreeAddFormCounts() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " add 'implementation', " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n"); - check("".equals(out), - "add without parentheses is still an add"); - - // and a quoted configuration name with no add in front still counts for nothing - String bare = KotlinStdlibAlignment.constraintsBlock("implementation", - " def cfg = 'implementation'\n" - + " something cfg, 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); - check(bare.contains("kotlin-stdlib-jdk8:1.8.0"), - "a quoted configuration name without an add does not declare anything"); - } - - /** - * A rich-version closure carries the version instead of the coordinate. - * Reading no version there made a merged-era declaration look below the - * floor, which took the SIBLING's constraint down with it -- and the - * sibling is the one the graph still needed. - */ - @Test - public void aRichVersionClosureSuppliesTheVersion() { - String modern = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') " - + "{ version { strictly '1.9.22' } }\n"); - check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), - "a merged-era jdk7 declaration leaves the jdk8 constraint standing"); - check(!modern.contains("kotlin-stdlib-jdk7:1.8.0"), - "and jdk7 itself is left to the app"); - - // Below the floor it still takes both, which is the case that rule exists for. - String preMerge = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk7') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(preMerge), - "a pre-merge rich-version declaration still suppresses both"); - } - - /** - * Gradle's {@code !!} suffix is the strict-version shorthand, and missing - * it produced the worst outcome available here. Measured: an app writing - * kotlin-stdlib:1.7.22!! beside a pre-merge jdk8 resolves the coherent - * 1.7.22 family on its own; with these constraints added it resolves - * kotlin-stdlib 1.7.22 beside jdk7/jdk8 1.8.0, the EMPTY shims -- so the - * jdk extension classes come from neither jar and the app fails at runtime - * with a missing class instead of at build time with a duplicate one. - */ - @Test - public void theStrictShorthandCountsAsAStrictPin() { - String base = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22!!'\n"); - check("".equals(base), - "a !! pin on the base stdlib suppresses both shims"); - - String shim = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n"); - check("".equals(shim), "and a !! pin on a shim does too"); - - // Above the floor the shorthand changes nothing: the constraints are still - // satisfiable, so they are still written. - String modern = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22!!'\n"); - check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), - "a merged-era !! pin does not need the block suppressed"); - } - - /** - * Map notation quoted inside a reason is prose too. The coordinate matcher - * had been taught to skip string literals and the map matcher beside it - * had not, so a reason naming the artifact in map form read as a - * declaration -- and since prose carries no version, the whole block was - * suppressed rather than one artifact. - */ - @Test - public void mapNotationInsideAReasonIsStillProse() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('com.example:other:1.0') { because " - + "\"avoid group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8'\" }\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "quoted map notation does not suppress"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and does not take the whole block with it"); - - // the real map form still counts - String real = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk8', version: '1.9.22'\n"); - check(!real.contains("kotlin-stdlib-jdk8:1.8.0"), - "a real map declaration still pins jdk8"); - } - - /** - * A reason that OPENS with the coordinate is still a reason. Accepting any - * literal starting with one let a warning about the duplicate - * -- because 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22 causes - * duplicate classes' -- switch off the constraint that prevents exactly - * what it describes. Dependency notation carries no whitespace. - */ - @Test - public void aReasonOpeningWithTheCoordinateIsStillProse() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('com.example:foo:1.0') { because " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22 causes duplicate classes' }\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a reason opening with the coordinate does not suppress"); - } - - /** - * A coordinate may sit one hop away behind a def. Neither statement - * carries both the configuration and the coordinate, so the strict pin was - * invisible and the constraint made the build stop resolving. - */ - @Test - public void aCoordinateBehindADefIsStillADeclaration() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def jdk8 = 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n" - + " implementation(jdk8) { version { strictly '1.7.22' } }\n"); - check("".equals(out), - "the strict pin behind a def is honoured, and below the floor both go"); - } - - /** - * The boundary, stated as a case so it is a decision rather than an - * oversight. An interpolated VERSION is still recognised -- the artifact - * name is literal there, and naming the artifact is what matters -- but a - * coordinate assembled by concatenation is not in the text as a coordinate - * at all, and recovering it needs Gradle to evaluate the script. The block - * is written, which is the safe direction for everything except a strict - * pin; a strict pin hidden this way is beyond what reading build-hint text - * can reach, and the design that does not need to find the declaration is - * the answer to that class rather than another pass here. - */ - @Test - public void aConcatenatedCoordinateIsNotRecovered() { - // An interpolated version still names the artifact, so it IS recognised. - String interpolatedVersion = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation(\"org.jetbrains.kotlin:kotlin-stdlib-jdk8:$v\")\n"); - check(!interpolatedVersion.contains("kotlin-stdlib-jdk8:1.8.0"), - "an interpolated version still names the artifact"); - - // A coordinate assembled by concatenation is not a coordinate in the text. - String concatenated = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:' + 'kotlin-stdlib-jdk8:1.7.22!!') " - + "{ version { strictly '1.7.22' } }\n"); - check(concatenated.contains("kotlin-stdlib-jdk8:1.8.0"), - "a concatenated coordinate is left unrecognised, by design"); - } - - /** - * A def that is not a string literal defines nothing here, and must not - * corrupt the statement that uses the name. - */ - @Test - public void aNonLiteralDefIsIgnored() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def jdk8 = someFunction()\n" - + " implementation(jdk8)\n" - + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22'\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "an unresolvable def leaves jdk8 constrained"); - check(!out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and the real jdk7 declaration beside it still counts"); - } - - /** - * Groovy's parenthesis-free map notation spreads one declaration over - * several lines, held together by trailing commas. Splitting at those - * newlines left the configuration, the group, the artifact and the closure - * in four statements, none of which is a declaration on its own -- so a - * strict pre-1.8 pin written that way was missed and the constraint made - * resolution fail. - */ - @Test - public void aCommaContinuesAMultilineMapDeclaration() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation group: 'org.jetbrains.kotlin',\n" - + " name: 'kotlin-stdlib-jdk8',\n" - + " version: '1.7.22!!'\n"); - check("".equals(out), - "a comma-continued map declaration pins jdk8, below the floor so both go"); - } - - /** - * An enclosing block's opening brace is not the declaration's own closure. - * A fragment putting its first dependency on the same line as - * {@code dependencies {} made that declaration swallow every following - * statement up to the closing brace, so an unrelated strict pin further - * down read as one on the stdlib and silenced the whole block. - */ - @Test - public void anEnclosingBlockBraceIsNotATrailingClosure() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - "dependencies { implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'\n" - + " implementation('com.example:other:1.0') " - + "{ version { strictly '1.7.22' } }\n" - + "}\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the unrelated strict pin does not attach to the stdlib declaration"); - } - - /** - * android.gradlePlugin is interpolated at top level right after - * `apply plugin`, where a dependencies block of its own is valid and - * reaches the same configurations -- so it has to be scanned like the - * other app-controlled fragments. It was missed the same way - * android.supportv4Dep was. - */ - @Test - public void theBuilderScansTheGradlePluginFragment() throws Exception { - byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( - "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); - String builderSrc = new String(bytes, "UTF-8"); - int at = builderSrc.indexOf("KotlinStdlibAlignment.constraintsBlock("); - check(at >= 0, "the builder calls the alignment"); - String call = builderSrc.substring(at, builderSrc.indexOf(";", at)); - check(call.contains("request.getArg(\"android.gradlePlugin\", \"\")"), - "android.gradlePlugin reaches the generated script and must be scanned"); - } - - /** - * The version comes from the strict call, not from a reason that mentions - * one. Finding the call correctly and then reading the version with a - * plain search let prose supply it, so a declaration whose real strict - * version is compatible was judged on a number from its own comment. - */ - @Test - public void theStrictVersionComesFromTheCallNotTheProse() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.9.22') " - + "{ because \"strictly '1.7.22' is not intended\"; " - + "version { strictly '1.9.22' } }\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the real strict version is above the floor, so the block is written"); - } - - /** - * Brace balancing honours escapes, as the statement scanner does. A - * declaration whose closure contained an escaped apostrophe had its real - * closing brace ignored, so following dependencies merged into it and an - * unrelated strict pin could be read as one on kotlin-stdlib. - */ - @Test - public void anEscapedQuoteDoesNotSwallowAClosingBrace() { - // The base stdlib named without a strict version, then an UNRELATED strict - // pin. Correct: neither suppresses, so the block is written. With the escape - // mishandled the two statements merge, the merged statement both names - // kotlin-stdlib and calls strictly '1.7.22', and the whole block disappears. - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.9.22') " - + "{ because 'can\\'t' }\n" - + " implementation('com.example:other:1.0') " - + "{ version { strictly '1.7.22' } }\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "an unrelated strict pin does not merge into the Kotlin declaration"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), - "and the block is written in full"); - } - - /** - * An underscore is an identifier character. A configuration named - * custom_implementation ended its embedded "implementation" on a boundary - * that looked clean, so it read as the main configuration and suppressed a - * constraint for a configuration that reaches nothing. - */ - @Test - public void aCustomConfigurationIsNotTheMainOne() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " custom_implementation " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "custom_implementation is not the configuration being constrained"); - - String suffixed = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation_extra " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); - check(suffixed.contains("kotlin-stdlib-jdk8:1.8.0"), - "nor is implementation_extra"); - - // and the real one still is - String real = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n"); - check(!real.contains("kotlin-stdlib-jdk8:1.8.0"), - "the main configuration still counts"); - } - - /** - * A trailing closure may sit on the line after the call's closing - * parenthesis. Gradle accepts it and the {@code strictly} inside really - * does apply -- checked by watching a competing higher requirement fail - * against it -- but the parenthesis depth is back to zero there, so the - * closure landed in its own statement and its version was never - * associated with the coordinate above it. - */ - @Test - public void aTrailingClosureOnTheNextLineBelongsToTheDeclaration() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation(\n" - + " 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n" - + " )\n" - + " { version { strictly '1.7.22' } }\n"); - check("".equals(out), - "the strict pin in a next-line closure still suppresses the block"); - } - - /** - * A coordinate inside a reason is prose. A coordinate lives in a string, - * so "outside a string" cannot be the test here the way it is for - * strictly -- what separates them is that a declaration's string OPENS - * with the coordinate while prose merely contains it. - */ - @Test - public void aCoordinateInsideAReasonIsNotADeclaration() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('com.example:other:1.0') " - + "{ because 'avoid org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22' }\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a coordinate mentioned in a reason does not count as a pin"); - - // the real notation still does - String real = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); - check(!real.contains("kotlin-stdlib-jdk8:1.8.0"), - "the dependency notation itself still counts"); - } - - /** - * A qualified segment keeps its number. Reading {@code 20-RC} as zero made - * 1.8.20-RC compare equal to the floor, and the qualifier rule then - * classified a version well ABOVE the floor as below it -- suppressing an - * alignment that was needed. - */ - @Test - public void aQualifiedSegmentKeepsItsNumber() { - String above = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.8.20-RC') " - + "{ version { strictly '1.8.20-RC' } }\n"); - check(above.contains("kotlin-stdlib-jdk8:1.8.0"), - "a prerelease above the floor still gets the constraints"); - - // and the prerelease OF the floor is still below it - String atTheFloor = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.8.0-RC2') " - + "{ version { strictly '1.8.0-RC2' } }\n"); - check("".equals(atTheFloor), - "a prerelease of the floor is still below it"); - } - - /** - * A prerelease of the floor is below the floor. 1.8.0-RC2 is a published - * Kotlin version whose numeric part compares equal to 1.8.0, so it read as - * "at the floor" and the block was written -- whereupon the shims request - * the FINAL 1.8.0 and cannot coexist with the strict prerelease. - */ - @Test - public void aPrereleaseOfTheFloorCountsAsBelowIt() { - String rc = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.8.0-RC2') " - + "{ version { strictly '1.8.0-RC2' } }\n"); - check("".equals(rc), "a strict prerelease of the floor suppresses the block"); - - // A qualifier above the floor changes nothing: rounding up keeps it above. - String later = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib:1.9.22-RC') " - + "{ version { strictly '1.9.22-RC' } }\n"); - check(later.contains("kotlin-stdlib-jdk8:1.8.0"), - "a prerelease above the floor still gets the constraints"); - } - - /** - * A configuration name inside a reason string is prose. Accepting any - * quoted occurrence -- which the dependencies.add spelling needed -- read - * `because 'implementation workaround'` as a main-variant declaration and - * suppressed the constraint for a dependency affecting only debug. - */ - @Test - public void aConfigurationNameInAReasonStringIsNotADeclaration() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " debugImplementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ because 'implementation workaround' }\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a reason mentioning the configuration does not make it a declaration"); - - // and the add() spelling it was widened for still works - String add = KotlinStdlibAlignment.constraintsBlock("implementation", - " dependencies.add(\"runtimeOnly\", " - + "\"org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!\")\n"); - check(!add.contains("kotlin-stdlib-jdk8:1.8.0"), - "the add() spelling is still recognised"); - } - - /** - * kotlin-stdlib is a prefix of kotlin-stdlib-jdk8, so the base match has to - * be exact. A loose one would read every shim declaration as a pin on the - * base library and switch the whole block off. - */ - @Test - public void aStrictShimPinIsNotAPinOnTheBaseStdlib() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check("".equals(out), - "a strict pre-merge shim pin suppresses both, not just its own"); - } - - /** - * The block absorbed for that check belongs to the declaration that opened - * it and no further. A dependencies or android block must not swallow the - * fragment: only a statement already naming the Kotlin group absorbs one. - */ - @Test - public void anUnrelatedBlockDoesNotSwallowTheFragment() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - "dependencies {\n" - + " implementation('com.example:thing:1.0') { version { strictly '1.0' } }\n" - + " debugImplementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n" - + "}\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "an unrelated strict block does not suppress, and the debug BOM still does not"); - } - - /** - * The quoted spelling counts as well. Gradle's - * {@code dependencies.add("runtimeOnly", "group:artifact:version")} names - * the configuration as a string, so the token ends at a quote rather than - * a space or a parenthesis, and the escape hatch has to recognise it for - * the same reason it recognises the others. - */ - @Test - public void theQuotedAddSpellingCountsAsAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " dependencies.add(\"runtimeOnly\", " - + "\"org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!\")\n"); - check("".equals(out), - "a quoted configuration name still pins jdk8, and a below-floor pin " - + "suppresses both"); - } - - /** - * A quoted configuration name on its own decides nothing, because it takes - * the artifact coordinate on the same statement to make a declaration. - */ - @Test - public void aQuotedConfigurationNameAloneIsNotAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def cfg = \"runtimeOnly\"\n" - + " implementation 'com.example:thing:1.0'\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "naming a configuration in a string does not suppress anything"); - } - - /** - * And their variant and test forms still do not, which is the property the - * whole-token lowercase match buys without listing a single variant name. - */ - @Test - public void theVariantFormsOfThoseConfigurationsStillDoNot() { - String[] variants = {"testRuntimeOnly", "debugRuntimeOnly", "androidTestImplementation", - "releaseCompileOnly", "debugApi", "testCompile"}; - for (String variant : variants) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " " + variant - + "('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22')\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - variant + " does not reach the constrained configuration"); - } - } - - /** - * api is a real pin on the main variant and is honoured, so the - * configuration filter did not narrow to a single keyword. - */ - @Test - public void anApiDeclarationCountsAsAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " api 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), "api pins jdk8"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), "and leaves jdk7 constrained"); - } - - /** - * An exclusion is the opposite of a pin. It applies only to the dependency - * edge it is written on, so an independent path still brings the - * class-bearing jar -- reading it as "the app manages this" removes the - * constraint precisely where it is still needed. - */ - @Test - public void anExclusionIsNotAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('com.example:thing:1.0') {\n" - + " exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8'\n" - + " }\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "excluding jdk8 on one edge does not switch its constraint off"); - } - - /** - * A declaration wrapped across lines is still a declaration. The - * configuration and the coordinate land on different physical lines, and - * reading them separately ignored an explicit pin and wrote the constraint - * over it -- the opposite of what naming the artifact in a build hint is - * documented to do. - */ - @Test - public void aDeclarationSplitAcrossLinesIsStillAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation(\n" - + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n" - + " )\n"); - check("".equals(out), - "a wrapped declaration pins jdk8, below the floor so both go"); - } - - /** - * An inline exclusion on a declaring line does not cancel the declaration. - * Dropping the whole line for containing "exclude" threw away a real pin; - * only what follows the exclusion has to be ignored. - */ - @Test - public void anInlineExclusionDoesNotCancelTheDeclaration() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!') " - + "{ exclude group: 'com.example', module: 'thing' }\n"); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the declaration survives its own inline exclusion"); - } - - /** - * And the standalone exclusion still is not a pin -- truncating at - * "exclude" leaves nothing in front of it. - */ - @Test - public void aStandaloneExclusionIsStillNotAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation('com.example:thing:1.0') {\n" - + " exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8'\n" - + " }\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "an exclusion on its own line is still not a pin"); - } - - /** - * A semicolon ends a statement, because this builder tells developers to - * separate android.gradleDep statements "with ';' or a newline" -- two - * declarations on one line is the documented shape, not an edge case. - * Splitting on newlines alone let the first statement's configuration - * token pair with the second statement's coordinate, so a debug-only BOM - * read as a main-variant one and suppressed everything. - */ - @Test - public void aSemicolonEndsAStatement() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'com.android.billingclient:billing:9.1.0'; " - + "debugImplementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a debug BOM after a semicolon does not borrow the previous " - + "statement's configuration"); - - // The same shape where the pin IS on the main variant still suppresses, so - // the split did not simply stop every semicolon-separated value from working. - String pinned = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'com.android.billingclient:billing:9.1.0'; " - + "implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); - check(!pinned.contains("kotlin-stdlib-jdk8:1.8.0"), - "a real pin after a semicolon is still a pin"); - } - - /** - * A semicolon inside a string or inside parentheses is not a separator. - */ - @Test - public void aSemicolonInsideAStringOrParensIsNotASeparator() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation(\n" - + " 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22!!'\n" - + " )\n"); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a wrapped declaration still pins"); - - String quoted = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22' " - + "// note; with a semicolon\n"); - check(!quoted.contains("kotlin-stdlib-jdk8:1.8.0"), - "a semicolon in a trailing comment does not split the declaration off"); - } - - /** - * Unbalanced parentheses must not glue the fragment into one line: that - * would let a configuration from one statement and a coordinate from - * another read as a single declaration, and suppression is the direction - * that must never be reached by accident. - */ - @Test - public void unbalancedParenthesesDoNotGlueStatementsTogether() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation(\n" - + " testImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22'\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a dangling paren does not turn a test-only pin into a main-variant one"); - } - - /** - * The map form is a real pin and is honoured, so the stricter matching did - * not simply narrow to one spelling. - */ - @Test - public void theMapFormCountsAsAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib-jdk8', version: '1.9.22'\n"); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), "the map form pins jdk8"); - check(out.contains("kotlin-stdlib-jdk7:1.8.0"), "and leaves jdk7 constrained"); - } - - /** - * A comment delimiter inside a string is not a delimiter. A {@code /*} in - * a quoted value used to open a block comment that swallowed the rest of - * the fragment, taking an explicit strict pin with it -- and losing a - * strict marker is what turns this class's constraint into a failed - * resolution rather than an override. - */ - @Test - public void aCommentDelimiterInsideAStringIsNotADelimiter() { - String blockOpener = KotlinStdlibAlignment.constraintsBlock("implementation", - " def marker = '/*'\n" - + " implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22') " - + "{ version { strictly '1.7.22' } }\n"); - check(!blockOpener.contains("kotlin-stdlib-jdk8:1.8.0"), - "a /* inside a string does not swallow the pin that follows it"); - - String lineOpener = KotlinStdlibAlignment.constraintsBlock("implementation", - " def marker = \"//\"\n" - + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); - check(!lineOpener.contains("kotlin-stdlib-jdk8:1.8.0"), - "a // inside a string does not comment out the line"); - } - - /** - * A repository URL is not a comment. Stripping from every {@code //} would - * cut {@code maven { url 'https://...' }} in half, and these fragments do - * carry repository URLs. - */ - @Test - public void aUrlIsNotAComment() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " maven { url 'https://example.com/repo' }\n" - + " implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); - check(!out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the pin after a URL line is still seen"); - } - - /** - * A Kotlin BOM no longer excuses the block either, at any version. - * - *

Measured against a graph carrying billing 9.1.0 and appcompat 1.6.1: - * adding this block alongside kotlin-bom 1.9.22 gives byte-identical - * resolution, because a BOM's constraints are not strict and the higher - * version wins; alongside kotlin-bom 1.7.22 it is not merely harmless but - * necessary, since a pre-merge BOM raises the jdk artifacts and cannot - * pull kotlin-stdlib back down. Suppressing on a BOM was cosmetic where it - * fired and wrong where it did not.

- */ - @Test - public void aKotlinBomNoLongerExcusesTheBlock() { - String modern = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n"); - check(modern.contains("kotlin-stdlib-jdk8:1.8.0"), - "a modern BOM does not suppress, and does not need to"); - - String preMerge = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.7.22')\n"); - check(preMerge.contains("kotlin-stdlib-jdk8:1.8.0"), - "a pre-merge BOM still gets the alignment it needs"); - } - - /** - * And the case that removed the feature rather than patching it: a BOM - * declared inside a condition cannot be known to be in force by reading - * the text, so no reading of it decides anything any more. - */ - @Test - public void aConditionalBomDoesNotDecideAnything() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " if (project.hasProperty('useKotlinBom')) {\n" - + " implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.22')\n" - + " }\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "a conditional BOM leaves the alignment in place"); - } - - /** - * An escaped quote does not end a string. The statement scanner missed - * this while the comment stripper beside it handled it, so a string - * escaping its own apostrophe closed early and every following newline - * read as being inside a string -- merging statements that must stay - * apart, which lets a main-variant configuration token pair with a - * debug-only coordinate. - */ - @Test - public void anEscapedQuoteDoesNotEndAString() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " def marker = 'can\\'t'\n" - + " implementation 'com.android.billingclient:billing:9.1.0'\n" - + " debugImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22'\n"); - check(out.contains("kotlin-stdlib-jdk8:1.8.0"), - "the debug-only pin does not borrow the main statement's configuration"); - } - - /** - * The fragments arrive straight from build hints, so an unset hint shows - * up as an empty string and an absent one can be null. Neither is a - * reason to skip the alignment, and neither may throw. - */ - @Test - public void ignoresEmptyAndNullFragments() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - "", null, " implementation 'androidx.appcompat:appcompat:1.6.1'\n"); - check(out.contains("kotlin-stdlib-jdk8"), - "an empty or absent hint is not a pin"); - } - - /** - * An unrelated Kotlin coordinate is not a pin. Only the two jdk artifacts - * and the BOM decide who owns the alignment; matching "kotlin" loosely - * would silently switch the fix off for any app that happens to use a - * Kotlin library. - */ - @Test - public void anUnrelatedKotlinDependencyIsNotAPin() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", - " implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'\n"); - check(out.contains("kotlin-stdlib-jdk8"), - "a coroutines dependency does not switch the alignment off"); - } - - /** - * A pre-AndroidX project declares its dependencies on {@code compile}, - * and a constraints block on a configuration the project does not have - * fails evaluation rather than being ignored. - */ - @Test - public void usesTheConfigurationItWasGiven() { - String out = KotlinStdlibAlignment.constraintsBlock("compile"); - check(out.contains("compile('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0')"), - "the caller's configuration is used"); - check(!out.contains("implementation("), - "no other configuration is assumed"); - } - - @Test - public void emitsNothingWithoutAConfiguration() { - check("".equals(KotlinStdlibAlignment.constraintsBlock(null)), - "a null configuration writes nothing"); - check("".equals(KotlinStdlibAlignment.constraintsBlock(" ")), - "a blank configuration writes nothing"); - } - - /** - * The block is concatenated inside the generated {@code dependencies} - * block between the last dependency and the closing brace, so it has to - * both start and end on its own line. - */ - @Test - public void isNewlineTerminatedForConcatenation() { - String out = block(); - check(out.endsWith("}\n"), "it ends its own line"); - check(out.startsWith(" constraints {\n"), "it starts its own line"); - } - - private static int countOccurrences(String haystack, String needle) { - int count = 0; - int at = haystack.indexOf(needle); - while (at >= 0) { - count++; - at = haystack.indexOf(needle, at + needle.length()); - } - return count; - } - - /** - * The half a unit test of the helper cannot see. The helper returning the - * right text is worthless if the builder stops concatenating it, and that - * is a one-character deletion in a 100-line string expression nothing - * else would notice -- the build stays green and the duplicate class - * comes back. - * - *

Source text, because the expression is a local inside a method - * thousands of lines long that cannot be called without a whole staged - * Android project.

- */ - @Test - public void theBuilderStillWritesItIntoTheDependenciesBlock() throws Exception { - byte[] bytes = java.nio.file.Files.readAllBytes(new java.io.File( - "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()); - String src = new String(bytes, "UTF-8"); - // The GENERATED block, which is concatenated onto the script with a - // leading `+`. The alignment's own argument opens with the same text and - // now comes first in the file, so anchoring on the text alone found that - // instead and looked for the constraints inside it. - int at = src.indexOf("+ \"dependencies {\\n\""); - assertTrue(at >= 0); - String block = src.substring(at, src.indexOf("+ \"}\\n\"", at)); - assertTrue(block.contains("+ kotlinStdlibConstraints")); - } - - private static void check(boolean condition, String message) { - assertTrue(condition, message); + assertTrue(true, "no input produces an exception"); } } From f26e00e2423de3fb7b7371cd18be46493361d93e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:41:21 +0300 Subject: [PATCH 85/94] Say out loud when the alignment stands down, and ask both questions of one list The class javadoc claimed the builder logs a notice when the app already holds the stdlib family. It did not: the stand-down happened inside constraintsBlock, which returns an empty string and says nothing, so the one case support would need to explain later was the silent one. The builder now collects the app-controlled fragments into a single appGradle array and uses it for both questions -- whether the app pins the family, and what to align over. Asking one over one set of fragments and aligning over another is the same defect wearing a different shape, so the test pins that too, and matches build hint names WITH their quotes: android.xgradle is a prefix of android.xgradle_default_config, and a bare contains() stayed true after the argument was deleted. The catch is kept and its comment corrected. It no longer guards a scanner -- there is no indexing left to get wrong -- but the block is an optimisation over a build that already worked apart from one duplicate class, and it runs on every AndroidX build. Three lines buy the difference between believing it cannot fail a build and knowing it cannot. --- .../builders/AndroidGradleBuilder.java | 70 +++++++++++-------- .../builders/KotlinStdlibAlignmentTest.java | 25 ++++--- 2 files changed, 57 insertions(+), 38 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index a5d5e5aaa8f..a7937a7be7f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7300,40 +7300,50 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { String kotlinStdlibConstraints = ""; if (useAndroidX && gradleVersionInt >= 6 && request.getArg("android.kotlinStdlibAlignment", "true").equals("true")) { + // Every fragment the APP controls, as one piece of text. Order and + // nesting do not matter to a whole-text check, so nothing here wraps + // or sequences them -- that was the parser's requirement, not this + // one. Built once because it answers two questions: whether the app + // already holds this family, and what to hand the alignment. + String[] appGradle = { + request.getArg("android.gradlePlugin", ""), + request.getArg("android.gradle.androidx", ""), + request.getArg("android.xgradle_default_config", ""), + request.getArg("android.supportv4Dep", ""), + request.getArg("android.gradleDep", ""), + request.getArg("android.xgradle", ""), + coreLibraryDesugaringDependency, + kotlinRuntimeDependency, + additionalDependencies, + aiExtraGradleDependencies.toString(), + aarDependencies, + injectRepo, + gradleDependency + }; try { - kotlinStdlibConstraints = KotlinStdlibAlignment.constraintsBlock( - compile, - // Every fragment the APP controls, as one piece of - // text. Order and nesting do not matter to a whole-text - // check, so nothing here wraps or sequences them -- - // that was the parser's requirement, not this one. - request.getArg("android.gradlePlugin", ""), - request.getArg("android.gradle.androidx", ""), - request.getArg("android.xgradle_default_config", ""), - request.getArg("android.supportv4Dep", ""), - request.getArg("android.gradleDep", ""), - request.getArg("android.xgradle", ""), - coreLibraryDesugaringDependency, - kotlinRuntimeDependency, - additionalDependencies, - aiExtraGradleDependencies.toString(), - aarDependencies, - injectRepo, - gradleDependency); + if (KotlinStdlibAlignment.appPinsTheStdlibFamily(appGradle)) { + // Said out loud. An alignment that silently does not happen + // is the one support cannot account for later, when the + // duplicate class it exists to prevent comes back. + log("NOTICE: not aligning the Kotlin stdlib, the project's " + + "own Gradle text holds that family itself"); + } else { + kotlinStdlibConstraints = + KotlinStdlibAlignment.constraintsBlock(compile, appGradle); + } } catch (RuntimeException e) { - // The alignment reads the app's Gradle text to decide whether the app - // already manages the stdlib family, and that reading is a scanner - // over arbitrary developer-authored Groovy. It runs on EVERY AndroidX - // build, so an index defect anywhere in it would not break one app, - // it would break all of them -- and the whole block is an optimisation - // over a build that already worked apart from one duplicate class. - // So its worst case is made "emit nothing", which is exactly the - // behaviour before this feature existed, and never a failed build. - // Logged rather than swallowed, because a silent catch here would + // The alignment is string work with no indexing in it, so this + // should not be reachable. It is here anyway because the whole + // block is an optimisation over a build that already worked + // apart from one duplicate class, and it runs on EVERY AndroidX + // build -- so a defect here would not break one app, it would + // break all of them. Three lines buy the difference between + // believing it cannot fail a build and knowing it cannot. + // Logged rather than swallowed, because a silent catch would // hide the defect for as long as nobody reported the duplicate. kotlinStdlibConstraints = ""; - log("NOTICE: skipping the Kotlin stdlib alignment, its read of the " - + "project's Gradle text failed: " + e); + log("NOTICE: skipping the Kotlin stdlib alignment, it failed " + + "unexpectedly: " + e); } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index fb9526922e2..a538c4ca51f 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -178,17 +178,20 @@ void missingFragmentsAreNotAnError() { /** * Every fragment the app controls has to reach the scan. A hint that is - * added to the generated script and not passed here is a pin this cannot - * see, which is the one way to get the dangerous answer. + * added to the generated script and not collected here is a pin this cannot + * see, which is the one way to get the dangerous answer. The same list has + * to feed both questions, too -- asking "does the app pin this" over one set + * of fragments and then aligning over another is the same defect wearing a + * different shape. */ @Test void theBuilderPassesEveryAppControlledFragment() throws Exception { String src = new String(java.nio.file.Files.readAllBytes(new java.io.File( "src/main/java/com/codename1/builders/AndroidGradleBuilder.java") .toPath()), "UTF-8"); - int at = src.indexOf("KotlinStdlibAlignment.constraintsBlock("); - assertTrue(at >= 0, "the builder calls the alignment"); - String call = src.substring(at, src.indexOf(";", at)); + int at = src.indexOf("String[] appGradle = {"); + assertTrue(at >= 0, "the builder collects the app's Gradle fragments"); + String list = src.substring(at, src.indexOf("};", at)); String[] hints = { "android.gradlePlugin", "android.gradle.androidx", "android.xgradle_default_config", "android.supportv4Dep", @@ -197,7 +200,7 @@ void theBuilderPassesEveryAppControlledFragment() throws Exception { for (String hint : hints) { // With the quotes. One hint name is a prefix of another, so a bare // contains() stayed true after the argument was deleted. - assertTrue(call.contains("\"" + hint + "\""), + assertTrue(list.contains("\"" + hint + "\""), "the alignment is not told about the " + hint + " hint, which reaches the generated script"); } @@ -205,14 +208,20 @@ void theBuilderPassesEveryAppControlledFragment() throws Exception { "kotlinRuntimeDependency", "additionalDependencies", "aiExtraGradleDependencies", "aarDependencies", "injectRepo", "gradleDependency", + // This builder has desugaring; the daemon twin does not. + "coreLibraryDesugaringDependency", }; for (String local : locals) { - assertTrue(call.contains(local), + assertTrue(list.contains(local), "the alignment is not told about " + local + ", which reaches the generated script"); } + String uses = src.substring(src.indexOf("};", at)); + assertTrue(uses.contains("appPinsTheStdlibFamily(appGradle)"), + "the stand-down question is asked over that same list"); + assertTrue(uses.contains("constraintsBlock(compile, appGradle)"), + "and so is the alignment"); } - /** * The alignment is an optimisation over a build that already worked apart * from one duplicate class, and it runs on every AndroidX build -- so its From 3b7e7690b8f7e24850aaa93efd20362a071d9645 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:13:18 +0300 Subject: [PATCH 86/94] Do not adopt an EDT that has stopped dispatching build-test has been failing intermittently with one test out of 6074 reporting "timed out after 5000ms; edt=display-not-initialized" -- a different class each time, never reproducible locally. The harness has been patched twice for it, and the comments there record the symptom accurately but treat it as a test-infrastructure problem. It is not. A thread that has left mainEDTLoop's dispatch loop is still isAlive() for the whole of its teardown, and init() decided whether to start a dispatch thread on exactly that evidence. So the ordering is: 1. the old generation's EDT leaves the loop and is descheduled 2. init() sees INSTANCE.edt alive, adopts it, starts nothing 3. the old thread resumes and finishes dying The new generation now has no dispatch thread at all. Everything it queues waits forever, and Display.isInitialized() answers false while codenameOneRunning stays true -- a state init() cannot repair, since it guards on that flag. Every test in the class then times out. The departing thread now publishes the fact rather than leaving it to be inferred from isAlive(): it clears edtDispatching the instant it stops dispatching, ahead of a teardown that can take arbitrarily long, and init() treats a non-dispatching thread as no dispatch thread. It stays the recorded EDT until the very end, because the teardown is meant to run AS the EDT -- disposeAll() is there to dispose windows on the thread their tree expects, and clearing edt early would make isEdt() false for exactly that call. It also tears down the implementation it was serving, read at loop exit, rather than whatever the static field points at by the time the teardown gets there. Read at loop exit and not at loop entry: a thread can serve more than one generation, because an init() while it is still dispatching adopts it legitimately. EdtHandoverTest holds the window open deterministically with an implementation that blocks inside deinitialize(). It fails on master in 5.5s (the dispatch never happens) and passes here in 0.6s; reverting either the edtDispatching check or the late clearing of edt fails it again, on that assertion. --- CodenameOne/src/com/codename1/ui/Display.java | 65 +++++++- .../com/codename1/junit/EdtHandoverTest.java | 141 ++++++++++++++++++ 2 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 maven/core-unittests/src/test/java/com/codename1/junit/EdtHandoverTest.java diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 59fdef98def..50cd7591aa1 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -272,6 +272,22 @@ public final class Display extends CN1Constants { /// This is the instance of the EDT used internally to indicate whether /// we are executing on the EDT or some arbitrary thread private Thread edt; + + /// Whether {@link #edt} is still inside its dispatch loop. + /// + /// A thread that has left that loop but not yet returned from + /// `mainEDTLoop()` is still `isAlive()`, and `init()` used to adopt it on + /// that evidence alone -- starting no dispatch thread of its own and + /// queueing the whole new generation onto a thread on its way out. This is + /// not a guess about a thread that might be leaving: the departing thread + /// clears it itself, at the instant it stops dispatching. + /// + /// Read without the lock, as `edt` is, and for the same reason: making the + /// read exclusive would hold `lock` across `impl.setThreadPriority()`, + /// which on some ports hands work to the platform's own UI thread. What + /// closes the race is that this is cleared BEFORE the teardown rather than + /// after it, not mutual exclusion. + private boolean edtDispatching; /// Contains animations that must be played in full by the EDT before anything further /// may be processed. This is useful for transitions/intro's etc... that animate without /// user interaction. @@ -516,10 +532,16 @@ public static void init(Object m) { // has no dispatch at all. This is a thread that has actually // died, not one that might be mid-teardown; the speculative // machinery that used to be here was removed on purpose. - if (INSTANCE.edt == null || !INSTANCE.edt.isAlive()) { + // ... and neither is one that has stopped dispatching but not yet + // returned. That thread is still ALIVE for the whole of its + // teardown, and adopting it left every test in a class reporting + // "timed out after 5000ms; edt=display-not-initialized". + if (INSTANCE.edt == null || !INSTANCE.edt.isAlive() + || !INSTANCE.edtDispatching) { INSTANCE.touchScreen = impl.isTouchDevice(); // initialize the Codename One EDT which from now on will take all responsibility // for the event delivery. + INSTANCE.edtDispatching = true; INSTANCE.edt = new CodenameOneThread(new RunnableWrapper(null, 3), "EDT"); impl.setThreadPriority(INSTANCE.edt, impl.getEDTThreadPriority()); INSTANCE.edt.start(); @@ -1353,14 +1375,51 @@ void mainEDTLoop() { } } } + // The dispatch loop has ended, so this thread will never run another + // call. Say so BEFORE the teardown below, which can take arbitrarily + // long: an init() during it would otherwise find this thread alive and + // adopt it as the dispatch thread of the new generation. + // + // Only when it is still this thread: a generation that started after + // this one has recorded its own EDT, and that one is dispatching. + final CodenameOneImplementation departing; + synchronized (lock) { + // The implementation to tear down, read HERE and not below. An + // init() during the teardown installs one this thread has never + // served, and deinitializing THAT leaves the display answering + // codenameOneRunning true with an implementation saying it is not + // initialized -- which init() cannot repair, because it guards on + // codenameOneRunning. + // + // Read at loop exit, not at loop entry: a thread can serve more + // than one generation, since an init() while it is still + // dispatching adopts it legitimately. What it owes a teardown to is + // the implementation it served last. + // + // EdtHandoverTest holds the adoption window open deterministically. + // It does NOT cover this read: the gap between leaving the loop and + // taking this reference is two instructions wide and the harness has + // no hook inside it. This is fixed by reading the right field, not + // by test. + departing = impl; + if (INSTANCE.edt == Thread.currentThread()) { //NOPMD CompareObjectsWithEquals + INSTANCE.edtDispatching = false; + } + } // Dispose any window still open, on the EDT, before the implementation goes // away. Doing this from the static deinitialize() would run the teardown off // the EDT, which is exactly the thread the window's tree expects. + // + // So `edt` still refers to this thread here, and isEdt() still answers + // true. Clearing it early would make the teardown run as a non-EDT + // caller, which is the opposite of what the line above is for. Desktop.getInstance().disposeAll(); - impl.deinitialize(); + departing.deinitialize(); + if (INSTANCE.edt == Thread.currentThread()) { //NOPMD CompareObjectsWithEquals + INSTANCE.edt = null; + } //INSTANCE.impl = null; //INSTANCE.codenameOneGraphics = null; - INSTANCE.edt = null; } /// Returns the stack trace from the exception on the given diff --git a/maven/core-unittests/src/test/java/com/codename1/junit/EdtHandoverTest.java b/maven/core-unittests/src/test/java/com/codename1/junit/EdtHandoverTest.java new file mode 100644 index 00000000000..9f2b71aba60 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/junit/EdtHandoverTest.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 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.junit; + +import com.codename1.impl.CodenameOneImplementation; +import com.codename1.impl.ImplementationFactory; +import com.codename1.testing.TestCodenameOneImplementation; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The handover from one Display generation to the next. + * + *

An EDT that has left its dispatch loop is still {@code isAlive()} for as + * long as its teardown runs. {@code Display.init} tested exactly that, so a + * second init landing in the gap adopted a thread that would never dispatch + * again -- and the departing thread then deinitialized whatever implementation + * was current by then, which was the new one. The display reports + * {@code codenameOneRunning} true with an implementation that says it is not + * initialized, every call queues onto nothing, and the suite reports + * "timed out after 5000ms; edt=display-not-initialized" for a whole class.

+ * + *

The window is a few instructions wide on an idle machine, which is why it + * only ever appeared on loaded CI runners. Here it is held open on purpose.

+ */ +class EdtHandoverTest { + + /// Sits inside deinitialize() until released, which is exactly the state the + /// race needs: out of the dispatch loop, into the teardown, still alive. + private static final class SlowToDie extends TestCodenameOneImplementation { + private final CountDownLatch entered = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private volatile boolean sawEdt; + + @Override + public void deinitialize() { + // The teardown is meant to run AS the EDT -- disposeAll() above it + // exists to dispose windows on the thread their tree expects. So the + // departing thread cannot stop being the EDT to avoid adoption; it + // has to say it stopped dispatching and stay the EDT until the end. + sawEdt = Display.getInstance().isEdt(); + entered.countDown(); + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + super.deinitialize(); + } + } + + private static void useImplementation(final CodenameOneImplementation impl) { + ImplementationFactory.setInstance(new ImplementationFactory() { + @Override + public Object createImplementation() { + return impl; + } + }); + } + + @Test + void aSecondInitDuringTeardownGetsAWorkingDispatchThread() throws Exception { + SlowToDie dying = new SlowToDie(); + useImplementation(dying); + Display.deinitialize(); + Display.init(null); + assertTrue(Display.isInitialized(), "precondition: the display is up"); + + // A form has to be showing, or the EDT never leaves the loop it runs + // before the first Form.show() -- that one has no codenameOneRunning in + // its condition, so deinitialize() alone does not end it. + final CountDownLatch shown = new CountDownLatch(1); + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + new Form("handover").show(); + shown.countDown(); + } + }); + assertTrue(shown.await(10, TimeUnit.SECONDS), + "precondition: a form is showing"); + + // The old generation goes away, and stops in its teardown. + Display.deinitialize(); + assertTrue(dying.entered.await(10, TimeUnit.SECONDS), + "precondition: the departing EDT reached its teardown"); + + TestCodenameOneImplementation live = new TestCodenameOneImplementation(); + useImplementation(live); + Display.init(null); + + final CountDownLatch ran = new CountDownLatch(1); + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + ran.countDown(); + } + }); + dying.release.countDown(); + + try { + assertTrue(ran.await(5, TimeUnit.SECONDS), + "the new generation never dispatched: init adopted the EDT that " + + "was on its way out"); + assertTrue(Display.isInitialized(), + "the departing EDT deinitialized the LIVE implementation instead " + + "of its own"); + assertTrue(dying.sawEdt, + "the teardown ran as a non-EDT caller, so disposeAll() disposed " + + "the window tree off the thread it belongs to"); + } finally { + Display.deinitialize(); + } + } +} From 9fcadc180465cd835b13c79b6b512b50fbb7d7da Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:23:37 +0300 Subject: [PATCH 87/94] Leave a Kotlin project's own toolchain alone, and find a pin in any case Three findings from review, all in the dangerous direction -- a floor written over a version something else is holding down. The serious one is our own doing. When the project has Kotlin sources this builder applies a Kotlin Gradle plugin and declares the stdlib at the compiler's version, which on the Gradle 6 and 7 path is 1.7.22. The 1.8.0 shims depend on stdlib 1.8.0, so raising them pulls the base stdlib up with them and the 1.7.22 compiler is then reading a stdlib newer than itself: "Module was compiled with an incompatible version of Kotlin". That turns a Kotlin app which builds today into one that does not, on the common path, and the generated declaration carries no pinning word so nothing stood the alignment down. It now stands down whenever this project compiles Kotlin -- the plugin owns that family, and the alignment exists for the Java-only graph that reaches the shims transitively and names them nowhere. The other two are gaps in the vocabulary. resolutionStrategy has a setter as well as a command, and a case-sensitive search for "force" finds `force` and misses `setForcedModules`, so the search now lower cases the text -- with Locale.ENGLISH, since a Turkish default turns "STRICTLY" into a dotless-i word that matches nothing, a trap already commented in this builder. And `require` joins the list for its bounded form: `require '[1.7,1.8)'` excludes the floor, so demanding 1.8.0 leaves nothing that satisfies both. The unbounded form is soft and would be raised happily; standing down for it too is the cheap side of the trade this whole guard is built on. Each of the four is covered by a test that fails when the change is reverted, including the builder passing hasKotlinSources -- asserted on the whole argument list, because the name also appears in the log branch above it and a looser check stayed true after the argument was replaced with a literal. --- .../build/shared/BuildHintsAndroid.java | 19 +++-- .../builders/AndroidGradleBuilder.java | 12 ++- .../builders/KotlinStdlibAlignment.java | 43 ++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 83 +++++++++++++++---- 4 files changed, 124 insertions(+), 33 deletions(-) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index e68246d2ea9..9d7efac2c70 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -656,13 +656,18 @@ static void register(List h) { + "duplicate in place, and a newer one wins over the floor on its own. " + "If you pin the family yourself the whole block is left out: your Gradle " + "build hints are searched for the text `kotlin-stdlib` alongside any of " - + "`strictly`, `!!`, `force`, `reject`, `enforcedPlatform`, `useVersion`, " - + "`useTarget`, `substitute` or `failOnVersionConflict`, and a hit means you " - + "have decided the version. That search is plain text, so it errs toward " - + "leaving the floor out. Leaving it out costs you the duplicate class " - + "you already had; adding it over a deliberate pin would break a build " - + "that works today. Set to false to manage these coordinates yourself in " - + "every case.")); + + "`strictly`, `!!`, `force`, `reject`, `require`, `enforcedPlatform`, " + + "`useVersion`, `useTarget`, `substitute` or `failOnVersionConflict`, and " + + "a hit means you have decided the version. The search ignores case, so " + + "`setForcedModules` counts as a force. It's plain text, so it errs " + + "toward leaving the floor out. Leaving it out costs you the duplicate " + + "class you already had; adding it over a deliberate pin would break a " + + "build that works today. A project with Kotlin sources of its own is left " + + "alone for the same reason: it gets a Kotlin Gradle plugin and a stdlib " + + "at the compiler\'s version, and on Gradle 6 and 7 that version is below " + + "this floor, so raising the shims would pull the base stdlib past the " + + "compiler that has to read it. Set to false to manage these coordinates " + + "yourself in every case.")); h.add(new Hint("android.largeScreens") .group(HintGroup.ANDROID) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index a7937a7be7f..95f4eea4339 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7321,15 +7321,21 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { gradleDependency }; try { - if (KotlinStdlibAlignment.appPinsTheStdlibFamily(appGradle)) { + if (hasKotlinSources) { + // The Kotlin plugin applied above owns this family, at the + // compiler's own version -- which is below the floor on this + // path. See constraintsBlock's projectCompilesKotlin. + log("NOTICE: not aligning the Kotlin stdlib, this project " + + "compiles Kotlin and its plugin manages that family"); + } else if (KotlinStdlibAlignment.appPinsTheStdlibFamily(appGradle)) { // Said out loud. An alignment that silently does not happen // is the one support cannot account for later, when the // duplicate class it exists to prevent comes back. log("NOTICE: not aligning the Kotlin stdlib, the project's " + "own Gradle text holds that family itself"); } else { - kotlinStdlibConstraints = - KotlinStdlibAlignment.constraintsBlock(compile, appGradle); + kotlinStdlibConstraints = KotlinStdlibAlignment.constraintsBlock( + compile, hasKotlinSources, appGradle); } } catch (RuntimeException e) { // The alignment is string work with no indexing in it, so this diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index e4bcd7e7879..652f8027be3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -22,6 +22,8 @@ */ package com.codename1.builders; +import java.util.Locale; + /** * The Kotlin stdlib alignment written into the generated Android * {@code build.gradle}. @@ -96,17 +98,29 @@ public class KotlinStdlibAlignment { * turns the raise itself into a build failure. Matched as plain text: the * point of this list is to be crude and complete rather than precise, since * a false match only declines to help.

+ * + *

Lower case, because the text is lower cased before the search. The + * same act appears in more than one spelling -- {@code force} the command + * and {@code setForcedModules} the setter -- and a case-sensitive + * {@code force} finds the first and misses the second.

+ * + *

{@code require} is here for its bounded form. A plain + * {@code require '1.7.22'} is soft and the constraint raises it happily, + * but {@code require '[1.7,1.8)'} excludes the floor, and no version then + * satisfies both. Standing down for the unbounded case as well is the + * cheap side of the trade.

*/ private static final String[] PINNING_WORDS = { "strictly", "!!", "force", "reject", - "enforcedPlatform", - "useVersion", - "useTarget", + "require", + "enforcedplatform", + "useversion", + "usetarget", "substitute", - "failOnVersionConflict" + "failonversionconflict" }; private KotlinStdlibAlignment() { @@ -117,6 +131,17 @@ private KotlinStdlibAlignment() { * {@code dependencies { }}, or an empty string when no alignment should be * written. * + * @param projectCompilesKotlin whether the project has Kotlin sources, and + * so gets a Kotlin Gradle plugin and a stdlib declaration at the + * compiler's own version. That version is the one the app's own classes + * are compiled against, and it is below this floor on the Gradle 6 and 7 + * path. Raising the shims there pulls the base stdlib up with them -- + * the empty shims depend on it -- and a compiler reading a stdlib newer + * than itself reports "Module was compiled with an incompatible version + * of Kotlin", turning a Kotlin app that builds today into one that does + * not. This alignment is for the Java-only graph that reaches the shims + * transitively; where the project compiles Kotlin, the Kotlin plugin + * owns the family. * @param configuration the dependency configuration to declare the * constraints on, {@code implementation} on any AndroidX project. The * caller passes the same name it uses for the rest of the block so a @@ -128,10 +153,13 @@ private KotlinStdlibAlignment() { * @return the block, newline terminated, or {@code ""} */ public static String constraintsBlock(String configuration, - String... appGradleFragments) { + boolean projectCompilesKotlin, String... appGradleFragments) { if (configuration == null || configuration.trim().length() == 0) { return ""; } + if (projectCompilesKotlin) { + return ""; + } if (appPinsTheStdlibFamily(appGradleFragments)) { return ""; } @@ -177,7 +205,10 @@ public static boolean appPinsTheStdlibFamily(String... appGradleFragments) { all.append(appGradleFragments[i]).append('\n'); } } - String text = all.toString(); + // Locale.ENGLISH, not the default: a Turkish default locale lower cases + // I to a dotless i, which turns "STRICTLY" into "str\u0131ctly" and + // matches nothing. The same trap is commented in AndroidGradleBuilder. + String text = all.toString().toLowerCase(Locale.ENGLISH); if (text.indexOf(STDLIB_FAMILY) < 0) { return false; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index a538c4ca51f..2c0f9d6b7e2 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -46,7 +46,7 @@ class KotlinStdlibAlignmentTest { */ @Test void aGraphThatNamesNoKotlinIsAligned() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", + String out = KotlinStdlibAlignment.constraintsBlock("implementation", false, " implementation 'androidx.appcompat:appcompat:1.6.1'\n", " implementation 'com.android.billingclient:billing:9.1.0'\n"); assertTrue(out.contains("'" + JDK7 + ":1.8.0'"), "jdk7 is raised: " + out); @@ -59,7 +59,7 @@ void aGraphThatNamesNoKotlinIsAligned() { /** A constraint pulls nothing into a graph that does not have it. */ @Test void anEmptyProjectStillGetsTheFloor() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", ""); + String out = KotlinStdlibAlignment.constraintsBlock("implementation", false, ""); assertTrue(out.contains(":1.8.0"), "the block is written unconditionally"); } @@ -69,13 +69,13 @@ void anEmptyProjectStillGetsTheFloor() { */ @Test void theConstraintFollowsTheCallersConfiguration() { - assertTrue(KotlinStdlibAlignment.constraintsBlock("compile", "") + assertTrue(KotlinStdlibAlignment.constraintsBlock("compile", false, "") .contains("compile('" + JDK7 + ":1.8.0')"), "compile"); - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", "") + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, "") .contains("implementation('" + JDK7 + ":1.8.0')"), "implementation"); - assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock(null, "")), + assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock(null, false, "")), "and no configuration means no block"); - assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock(" ", "")), + assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock(" ", false, "")), "nor does a blank one"); } @@ -87,11 +87,11 @@ void theConstraintFollowsTheCallersConfiguration() { */ @Test void anOrdinaryDeclarationIsRaisedNotHonoured() { - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, " implementation '" + JDK8 + ":1.7.22'\n") .contains(":1.8.0"), "a pre-merge declaration is raised"); - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, " implementation '" + JDK8 + ":1.9.22'\n") .contains(":1.8.0"), "and a merged-era one is unaffected by a floor beneath it"); @@ -121,7 +121,7 @@ void anAppThatPinsTheFamilyIsLeftAlone() { + " implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n", }; for (int i = 0; i < pinned.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", + String out = KotlinStdlibAlignment.constraintsBlock("implementation", false, pinned[i]); assertTrue("".equals(out), "<<" + pinned[i].trim() + ">> holds the family, got <<" + out + ">>"); @@ -135,12 +135,12 @@ void anAppThatPinsTheFamilyIsLeftAlone() { */ @Test void bothHalvesOfTheGuardAreRequired() { - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, " configurations.all { resolutionStrategy.force " + "'com.squareup.okhttp3:okhttp:4.0.0' }\n") .contains(":1.8.0"), "a force on someone else is not a pin on this family"); - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, " implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'\n") .contains(":1.8.0"), "and naming the family without pinning it is an ordinary declaration"); @@ -149,12 +149,57 @@ void bothHalvesOfTheGuardAreRequired() { // one in a comment or an unrelated string counts. That costs an app the // duplicate it already had, which android.kotlinStdlibAlignment=false // does deliberately; the other direction breaks a build that works. - assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", + assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", false, " // we used to force kotlin-stdlib here\n")), "a pinning word in a comment stands it down, which is the safe way " + "to be wrong"); } + @Test + void theProjectsOwnKotlinPluginOwnsTheFamily() { + assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", true, + " implementation 'androidx.appcompat:appcompat:1.6.1'\n")), + "a project with Kotlin sources gets a Kotlin plugin and a stdlib at " + + "the compiler's own version, which is below this floor on the " + + "Gradle 6 and 7 path -- raising the shims would drag the base " + + "stdlib past the compiler"); + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, + " implementation 'androidx.appcompat:appcompat:1.6.1'\n") + .contains(":1.8.0"), + "and the Java-only graph this exists for is still aligned"); + } + + @Test + void aPinIsFoundWhateverItsCase() { + // force the command and setForcedModules the setter are the same act. + String[] spellings = { + " configurations.all { resolutionStrategy.setForcedModules(" + + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22') }\n", + " configurations.all { resolutionStrategy.FORCE " + + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' }\n", + " implementation('org.jetbrains.kotlin:kotlin-stdlib') " + + "{ version { STRICTLY '1.7.22' } }\n", + }; + for (int i = 0; i < spellings.length; i++) { + assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", false, spellings[i])), + "<<" + spellings[i].trim() + ">> holds the family"); + } + } + + @Test + void aBoundedRequireIsAPin() { + // require '[1.7,1.8)' excludes the floor, so a constraint demanding it + // leaves no version satisfying both. The unbounded form is soft and + // would be raised happily; standing down for it too is the cheap side. + assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", false, + " implementation('org.jetbrains.kotlin:kotlin-stdlib') " + + "{ version { require '[1.7,1.8)' } }\n")), + "a bounded require holds the family below the floor"); + } + /** The floor is the version at which the shims became empty. */ @Test void theFloorIsWhereTheClassesMoved() { @@ -168,10 +213,10 @@ void theFloorIsWhereTheClassesMoved() { */ @Test void missingFragmentsAreNotAnError() { - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, (String[]) null).contains(":1.8.0"), "no fragments at all"); - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, null, "", null).contains(":1.8.0"), "and a mix of null and empty ones"); } @@ -219,8 +264,12 @@ void theBuilderPassesEveryAppControlledFragment() throws Exception { String uses = src.substring(src.indexOf("};", at)); assertTrue(uses.contains("appPinsTheStdlibFamily(appGradle)"), "the stand-down question is asked over that same list"); - assertTrue(uses.contains("constraintsBlock(compile, appGradle)"), - "and so is the alignment"); + // The whole argument list, not the names one at a time: hasKotlinSources + // also appears in the log branch just above, so a looser assertion stayed + // true after the argument itself was replaced with a literal. + assertTrue(uses.contains("compile, hasKotlinSources, appGradle)"), + "the alignment is asked over the caller's configuration, that same " + + "fragment list, and whether this project compiles Kotlin"); } /** * The alignment is an optimisation over a build that already worked apart @@ -235,7 +284,7 @@ void theAlignmentCannotFailTheBuild() { "kotlin-stdlib strictly", }; for (int i = 0; i < hostile.length; i++) { - KotlinStdlibAlignment.constraintsBlock("implementation", hostile[i]); + KotlinStdlibAlignment.constraintsBlock("implementation", false, hostile[i]); KotlinStdlibAlignment.appPinsTheStdlibFamily(hostile[i]); } assertTrue(true, "no input produces an exception"); From 8735b1f7b943eeea9b5ba97c8c04338fcec7554a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:41:45 +0300 Subject: [PATCH 88/94] Close the handover window rather than narrowing it, and read a range as a pin Two findings, both correct, both about a gap left rather than a gap missed. The EDT handover was narrowed, not closed. Leaving the dispatch loop and announcing it were still two steps, so an init() landing between them saw a live thread with edtDispatching still true, adopted it, started nothing -- and the departing thread then captured the incoming implementation and tore that down instead. The window went from the whole teardown to a few instructions, which is exactly the width that bit us on a loaded runner in the first place. Now there is one exit and it is taken under `lock`: the thread reads codenameOneRunning, captures the implementation it served, and clears the flag as a single event. init() decides under the same monitor and claims the flag there, then creates the thread outside it, because setThreadPriority reaches the platform's own UI thread on some ports and holding the lock across that would trade the race for a deadlock. Two orderings remain and both are right: either the thread has left, and init starts a replacement, or it has not, and it reads the codenameOneRunning that init set and keeps dispatching for the new generation. The second is the stdlib guard. A version range needs no keyword at all -- 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:[1.7,1.8)' reads as an ordinary coordinate and excludes the floor, so constraining to 1.8.0 leaves nothing that satisfies both. The signature is the comma, which appears nowhere else inside a version: digits on its left, digits or a closing bracket on its right. Map notation puts a quote to the left of every comma, which is the case this must not fire on, and it is tested in both directions. --- CodenameOne/src/com/codename1/ui/Display.java | 84 ++++++++++--------- .../build/shared/BuildHintsAndroid.java | 4 +- .../builders/KotlinStdlibAlignment.java | 42 ++++++++++ .../builders/KotlinStdlibAlignmentTest.java | 30 +++++++ 4 files changed, 121 insertions(+), 39 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 50cd7591aa1..5bb82c47287 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -532,16 +532,35 @@ public static void init(Object m) { // has no dispatch at all. This is a thread that has actually // died, not one that might be mid-teardown; the speculative // machinery that used to be here was removed on purpose. + // // ... and neither is one that has stopped dispatching but not yet // returned. That thread is still ALIVE for the whole of its // teardown, and adopting it left every test in a class reporting - // "timed out after 5000ms; edt=display-not-initialized". - if (INSTANCE.edt == null || !INSTANCE.edt.isAlive() - || !INSTANCE.edtDispatching) { + // "timed out after 5000ms; edt=display-not-initialized". That is + // not speculation about a thread that might be leaving: the thread + // itself clears the flag, in mainEDTLoop, at the instant it leaves. + // + // Decided under `lock`, which is the same monitor it clears the + // flag under, so there are only two orderings and both are right. + // Either it has already left, and this starts a replacement; or it + // has not, and it reads the codenameOneRunning set above and keeps + // dispatching for this generation. + boolean startEdt; + synchronized (lock) { + startEdt = INSTANCE.edt == null || !INSTANCE.edt.isAlive() + || !INSTANCE.edtDispatching; + if (startEdt) { + INSTANCE.edtDispatching = true; + } + } + if (startEdt) { INSTANCE.touchScreen = impl.isTouchDevice(); // initialize the Codename One EDT which from now on will take all responsibility // for the event delivery. - INSTANCE.edtDispatching = true; + // + // Outside the lock: setThreadPriority reaches the platform's own + // UI thread on some ports, and holding `lock` across that trades + // the race for a deadlock. Only the decision needs to be atomic. INSTANCE.edt = new CodenameOneThread(new RunnableWrapper(null, 3), "EDT"); impl.setThreadPriority(INSTANCE.edt, impl.getEDTThreadPriority()); INSTANCE.edt.start(); @@ -1321,11 +1340,30 @@ void mainEDTLoop() { } } - while (codenameOneRunning) { // PMD Fix: AvoidBranchingStatementAsLastInLoop + // One exit, and it is taken under `lock`, because leaving this loop and + // saying so have to be the same event. init() decides whether to start a + // dispatch thread from edtDispatching, and any gap between the two lets + // it decide on a thread that has already gone -- adopting it, starting + // nothing, and queueing the whole new generation onto a corpse. + CodenameOneImplementation departing = null; + while (departing == null) { try { // wait indefinetly Lock surrounds the should method to prevent serial calls from // getting "lost" synchronized (lock) { + if (!codenameOneRunning) { + // The implementation this thread served LAST, which is + // not always the one it started on: an init() while it + // was still dispatching adopts it legitimately, and sets + // codenameOneRunning back to true before reaching the + // decision below. So arriving here means the display is + // genuinely down and `impl` is still that generation's. + departing = impl; + if (INSTANCE.edt == Thread.currentThread()) { //NOPMD CompareObjectsWithEquals + INSTANCE.edtDispatching = false; + } + break; + } if (shouldEDTSleep()) { if (!pendingIdleSerialCalls.isEmpty()) { Runnable r = pendingIdleSerialCalls.remove(0); @@ -1375,44 +1413,14 @@ void mainEDTLoop() { } } } - // The dispatch loop has ended, so this thread will never run another - // call. Say so BEFORE the teardown below, which can take arbitrarily - // long: an init() during it would otherwise find this thread alive and - // adopt it as the dispatch thread of the new generation. - // - // Only when it is still this thread: a generation that started after - // this one has recorded its own EDT, and that one is dispatching. - final CodenameOneImplementation departing; - synchronized (lock) { - // The implementation to tear down, read HERE and not below. An - // init() during the teardown installs one this thread has never - // served, and deinitializing THAT leaves the display answering - // codenameOneRunning true with an implementation saying it is not - // initialized -- which init() cannot repair, because it guards on - // codenameOneRunning. - // - // Read at loop exit, not at loop entry: a thread can serve more - // than one generation, since an init() while it is still - // dispatching adopts it legitimately. What it owes a teardown to is - // the implementation it served last. - // - // EdtHandoverTest holds the adoption window open deterministically. - // It does NOT cover this read: the gap between leaving the loop and - // taking this reference is two instructions wide and the harness has - // no hook inside it. This is fixed by reading the right field, not - // by test. - departing = impl; - if (INSTANCE.edt == Thread.currentThread()) { //NOPMD CompareObjectsWithEquals - INSTANCE.edtDispatching = false; - } - } // Dispose any window still open, on the EDT, before the implementation goes // away. Doing this from the static deinitialize() would run the teardown off // the EDT, which is exactly the thread the window's tree expects. // // So `edt` still refers to this thread here, and isEdt() still answers - // true. Clearing it early would make the teardown run as a non-EDT - // caller, which is the opposite of what the line above is for. + // true. Announcing the departure by clearing `edt` instead of the flag + // would make this teardown run as a non-EDT caller, which is the + // opposite of what the line below is for. Desktop.getInstance().disposeAll(); departing.deinitialize(); if (INSTANCE.edt == Thread.currentThread()) { //NOPMD CompareObjectsWithEquals diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index 9d7efac2c70..bfa0152ef29 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -658,7 +658,9 @@ static void register(List h) { + "build hints are searched for the text `kotlin-stdlib` alongside any of " + "`strictly`, `!!`, `force`, `reject`, `require`, `enforcedPlatform`, " + "`useVersion`, `useTarget`, `substitute` or `failOnVersionConflict`, and " - + "a hit means you have decided the version. The search ignores case, so " + + "a hit means you have decided the version. A version range does the " + + "same without any of those words, so `kotlin-stdlib:[1.7,1.8)` " + + "also leaves the block out. The search ignores case, so " + "`setForcedModules` counts as a force. It's plain text, so it errs " + "toward leaving the floor out. Leaving it out costs you the duplicate " + "class you already had; adding it over a deliberate pin would break a " diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 652f8027be3..76cbb745308 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -217,6 +217,48 @@ public static boolean appPinsTheStdlibFamily(String... appGradleFragments) { return true; } } + return containsAVersionRange(text); + } + + /** + * Whether the text carries a Gradle version range, which needs no keyword + * at all. + * + *

{@code 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:[1.7,1.8)'} is an + * ordinary coordinate as far as the vocabulary above is concerned, and it + * excludes the floor: constraining to 1.8.0 leaves Gradle nothing that + * satisfies both, so a build that resolves today stops resolving.

+ * + *

The signature is the comma, which appears nowhere else inside a + * version: a range has digits on its left and either digits or a closing + * bracket on its right. Map notation -- + * {@code group: 'x', name: 'y', version: '1.8.0'} -- puts a quote to the + * left of every comma and is the case this must not fire on, since + * declaring the family that way is ordinary and gets raised. Anything else + * that happens to put a digit either side of a comma is a false match, and + * a false match only declines to help.

+ */ + private static boolean containsAVersionRange(String text) { + for (int i = text.indexOf(','); i >= 0; i = text.indexOf(',', i + 1)) { + int before = i - 1; + while (before >= 0 && text.charAt(before) == ' ') { + before--; + } + int after = i + 1; + while (after < text.length() && text.charAt(after) == ' ') { + after++; + } + if (before < 0 || after >= text.length()) { + continue; + } + char left = text.charAt(before); + char right = text.charAt(after); + if (left >= '0' && left <= '9' + && ((right >= '0' && right <= '9') + || right == ')' || right == ']' || right == '[')) { + return true; + } + } return false; } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 2c0f9d6b7e2..1e06c87def7 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -200,6 +200,36 @@ void aBoundedRequireIsAPin() { "a bounded require holds the family below the floor"); } + @Test + void aBoundedRangeNeedsNoKeyword() { + // The dangerous shape: an ordinary-looking coordinate whose version is + // a range that excludes the floor. Nothing in the vocabulary appears. + String[] ranges = { + " implementation " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:[1.7,1.8)'\n", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib:(1.6,1.8]'\n", + " implementation 'org.jetbrains.kotlin:kotlin-stdlib:[1.7, )'\n", + }; + for (int i = 0; i < ranges.length; i++) { + assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", false, ranges[i])), + "<<" + ranges[i].trim() + ">> excludes the floor"); + } + + // And the case the comma test must NOT fire on, because declaring the + // family this way is ordinary and the constraint raises it. + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, + " implementation group: 'org.jetbrains.kotlin', " + + "name: 'kotlin-stdlib', version: '1.7.22'\n") + .contains(":1.8.0"), + "map notation is a declaration, not a range"); + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, + " implementation " + + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n") + .contains(":1.8.0"), + "and neither is a plain version"); + } + /** The floor is the version at which the shims became empty. */ @Test void theFloorIsWhereTheClassesMoved() { From 35d818a236a7f67d59b182f480af992dbf9ffdff Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:31:45 +0300 Subject: [PATCH 89/94] The BOM that exists, the plugin wherever it came from, and a range with no comma Four more from review, and one pushed back on in the only place a reviewer will read it. The EDT clear had the same shape as the bug above it. Testing `edt == currentThread()` and then assigning null are two steps, and an init() publishing a replacement between them nulls a LIVE dispatch thread: the loop keeps running, but isEdt() stops recognising it, so callSeriallyAndWait() from the EDT waits on itself. Both sides now run under `lock` -- init builds and prioritises the thread on a local first, so the port call that reaches the platform's UI thread still happens outside it. Closed by construction rather than by test; the harness has no hook between those two statements. kotlin-stdlib-bom does not exist. The real coordinate is kotlin-bom, and the enforced-BOM test asserted against the made-up one, so it passed while every real enforced BOM went unseen. The family is now both names. hasKotlinSources scans src/main/java, and Kotlin can arrive from a source set it never looks at with the app applying the plugin itself -- in which case nothing names the stdlib and naming it cannot be the test. Applying a Kotlin Gradle plugin now stands the alignment down on its own. android.topDependency joins the scan while we are here: it is the buildscript block, this builder already reads it to decide whether to add a kotlin-gradle-plugin classpath, and leaving it out hid the clearest statement an app can make about this family. A range needs no comma either. [1.7.22] admits exactly one version, so a bracket against a digit is a range as surely as a comma between digits. Pushed back on dependency locking, in a comment beside the family check: a lockfile is a strict constraint and would genuinely conflict, but this builder writes the project from scratch and has no locking, no lockfile and no hint that ships one -- and locking with no lock state does nothing. The comment says what would have to change for that to become reachable. --- CodenameOne/src/com/codename1/ui/Display.java | 30 ++++++-- .../build/shared/BuildHintsAndroid.java | 10 ++- .../builders/AndroidGradleBuilder.java | 5 ++ .../builders/KotlinStdlibAlignment.java | 74 +++++++++++++++++-- .../builders/KotlinStdlibAlignmentTest.java | 41 +++++++++- 5 files changed, 141 insertions(+), 19 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 5bb82c47287..7858fcf9347 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -558,12 +558,21 @@ public static void init(Object m) { // initialize the Codename One EDT which from now on will take all responsibility // for the event delivery. // - // Outside the lock: setThreadPriority reaches the platform's own - // UI thread on some ports, and holding `lock` across that trades - // the race for a deadlock. Only the decision needs to be atomic. - INSTANCE.edt = new CodenameOneThread(new RunnableWrapper(null, 3), "EDT"); - impl.setThreadPriority(INSTANCE.edt, impl.getEDTThreadPriority()); - INSTANCE.edt.start(); + // Built and prioritised on a local, outside the lock: + // setThreadPriority reaches the platform's own UI thread on some + // ports, and holding `lock` across that would trade the race for + // a deadlock. + Thread replacement = new CodenameOneThread(new RunnableWrapper(null, 3), "EDT"); + impl.setThreadPriority(replacement, impl.getEDTThreadPriority()); + // Published under the lock, because the departing thread's own + // check-and-clear of this field runs under it. Otherwise that + // clear can land between the two and null out a LIVE dispatch + // thread: the loop keeps running, but isEdt() stops recognising + // it, so callSeriallyAndWait() from the EDT waits on itself. + synchronized (lock) { + INSTANCE.edt = replacement; + replacement.start(); + } } impl.postInit(); INSTANCE.setCommandBehavior(commandBehaviour); @@ -1423,8 +1432,13 @@ void mainEDTLoop() { // opposite of what the line below is for. Desktop.getInstance().disposeAll(); departing.deinitialize(); - if (INSTANCE.edt == Thread.currentThread()) { //NOPMD CompareObjectsWithEquals - INSTANCE.edt = null; + // Under the lock, and only if it is still this thread. init() publishes a + // replacement under the same lock, so the two cannot interleave into + // nulling a thread that is dispatching. + synchronized (lock) { + if (INSTANCE.edt == Thread.currentThread()) { //NOPMD CompareObjectsWithEquals + INSTANCE.edt = null; + } } //INSTANCE.impl = null; //INSTANCE.codenameOneGraphics = null; diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index bfa0152ef29..d69cc22cbbe 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -658,9 +658,15 @@ static void register(List h) { + "build hints are searched for the text `kotlin-stdlib` alongside any of " + "`strictly`, `!!`, `force`, `reject`, `require`, `enforcedPlatform`, " + "`useVersion`, `useTarget`, `substitute` or `failOnVersionConflict`, and " - + "a hit means you have decided the version. A version range does the " + + "a hit means you have decided the version. `kotlin-bom` counts as " + + "naming the family, since an enforced platform manages these " + + "modules without mentioning them. A version range does the " + "same without any of those words, so `kotlin-stdlib:[1.7,1.8)` " - + "also leaves the block out. The search ignores case, so " + + "and the single-version `[1.7.22]` also leave the block out. " + + "A Kotlin Gradle plugin applied from any of those hints has the " + + "same effect, whoever applied it: the plugin declares a stdlib " + + "at the compiler\'s version and owns the family from then on. " + + "The search ignores case, so " + "`setForcedModules` counts as a force. It's plain text, so it errs " + "toward leaving the floor out. Leaving it out costs you the duplicate " + "class you already had; adding it over a deliberate pin would break a " diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 95f4eea4339..07880330174 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7307,6 +7307,11 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // already holds this family, and what to hand the alignment. String[] appGradle = { request.getArg("android.gradlePlugin", ""), + // The buildscript block. An app can put its own + // kotlin-gradle-plugin classpath here -- this builder checks for + // exactly that above before adding one -- so leaving it out hid + // the clearest statement an app can make about this family. + request.getArg("android.topDependency", ""), request.getArg("android.gradle.androidx", ""), request.getArg("android.xgradle_default_config", ""), request.getArg("android.supportv4Dep", ""), diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 76cbb745308..1b61b59d10b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -85,11 +85,37 @@ public class KotlinStdlibAlignment { }; /** - * The family, as the one name that prefixes all three of them -- + * The names that mean "this family". + * + *

{@code kotlin-stdlib} prefixes all three of the modules themselves -- * {@code kotlin-stdlib}, {@code kotlin-stdlib-jdk7} and - * {@code kotlin-stdlib-jdk8}. + * {@code kotlin-stdlib-jdk8}. {@code kotlin-bom} is the platform that + * manages them, and it is the real coordinate: there is no + * {@code kotlin-stdlib-bom}, which is what an earlier test here asserted + * against, so an enforced BOM went unseen.

*/ - private static final String STDLIB_FAMILY = "kotlin-stdlib"; + private static final String[] FAMILY_NAMES = { + "kotlin-stdlib", + "kotlin-bom" + }; + + /** + * Text that means the Kotlin toolchain is in this build at all. + * + *

Applying a Kotlin Gradle plugin makes it the owner of the stdlib: it + * declares one at the compiler's own version, and on the Gradle 6 and 7 + * path that version is below this floor. This stands the alignment down + * without the family being named anywhere, because whoever applied the + * plugin need not have named it -- the plugin does that itself. It is the + * same reason as {@code projectCompilesKotlin}, reached the other way: that + * one is a source scan under {@code src/main/java}, and Kotlin can arrive + * from a source set the scan never looks at.

+ */ + private static final String[] KOTLIN_TOOLCHAIN_WORDS = { + "kotlin-gradle-plugin", + "kotlin-android", + "org.jetbrains.kotlin.android" + }; /** * The words that can hold a version where this would raise it. @@ -209,7 +235,26 @@ public static boolean appPinsTheStdlibFamily(String... appGradleFragments) { // I to a dotless i, which turns "STRICTLY" into "str\u0131ctly" and // matches nothing. The same trap is commented in AndroidGradleBuilder. String text = all.toString().toLowerCase(Locale.ENGLISH); - if (text.indexOf(STDLIB_FAMILY) < 0) { + for (int i = 0; i < KOTLIN_TOOLCHAIN_WORDS.length; i++) { + if (text.indexOf(KOTLIN_TOOLCHAIN_WORDS[i]) >= 0) { + return true; + } + } + boolean namesTheFamily = false; + for (int i = 0; i < FAMILY_NAMES.length; i++) { + if (text.indexOf(FAMILY_NAMES[i]) >= 0) { + namesTheFamily = true; + break; + } + } + if (!namesTheFamily) { + // Nothing else here can be about this family. Note what is NOT + // reachable from the Gradle text: a gradle.lockfile, which Gradle + // enforces as a strict constraint. This builder writes the project + // from scratch and has no dependency locking, no lock file and no + // hint that ships one, so there is no lock to read -- and locking + // with no lock state does nothing. Revisit this if the builder ever + // grows a way to carry files into the generated project. return false; } for (int i = 0; i < PINNING_WORDS.length; i++) { @@ -229,9 +274,10 @@ public static boolean appPinsTheStdlibFamily(String... appGradleFragments) { * excludes the floor: constraining to 1.8.0 leaves Gradle nothing that * satisfies both, so a build that resolves today stops resolving.

* - *

The signature is the comma, which appears nowhere else inside a - * version: a range has digits on its left and either digits or a closing - * bracket on its right. Map notation -- + *

Two signatures. A bracket against a digit -- {@code [1.7.22]} admits + * exactly one version and has no comma at all -- and a comma with digits on + * its left and digits or a closing bracket on its right, which is the only + * place a comma appears inside a version. Map notation -- * {@code group: 'x', name: 'y', version: '1.8.0'} -- puts a quote to the * left of every comma and is the case this must not fire on, since * declaring the family that way is ordinary and gets raised. Anything else @@ -239,6 +285,20 @@ public static boolean appPinsTheStdlibFamily(String... appGradleFragments) { * a false match only declines to help.

*/ private static boolean containsAVersionRange(String text) { + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c != '[' && c != ']') { + continue; + } + int after = i + 1; + while (after < text.length() && text.charAt(after) == ' ') { + after++; + } + if (after < text.length() && text.charAt(after) >= '0' + && text.charAt(after) <= '9') { + return true; + } + } for (int i = text.indexOf(','); i >= 0; i = text.indexOf(',', i + 1)) { int before = i - 1; while (before >= 0 && text.charAt(before) == ' ') { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 1e06c87def7..5924e74d7ac 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -109,8 +109,11 @@ void anAppThatPinsTheFamilyIsLeftAlone() { " implementation('" + JDK8 + "') { version { strictly '1.7.22' } }\n", " configurations.all { resolutionStrategy.force '" + JDK8 + ":1.7.22' }\n", " implementation('" + JDK8 + "') { version { reject '[1.8.0,)' } }\n", + // kotlin-bom, which is the coordinate that exists. This asserted + // against kotlin-stdlib-bom, so it passed while the real BOM went + // unseen -- the artifact under test has to be a real one. " implementation(enforcedPlatform(" - + "'org.jetbrains.kotlin:kotlin-stdlib-bom:1.7.22'))\n", + + "'org.jetbrains.kotlin:kotlin-bom:1.7.22'))\n", " configurations.all { resolutionStrategy.eachDependency { d ->\n" + " if (d.requested.name == 'kotlin-stdlib') " + "d.useVersion '1.7.22'\n } }\n", @@ -230,6 +233,39 @@ void aBoundedRangeNeedsNoKeyword() { "and neither is a plain version"); } + @Test + void theKotlinToolchainOwnsTheFamilyWhereverItCameFrom() { + // hasKotlinSources scans src/main/java. Kotlin can arrive from a source + // set it never looks at, with the app applying the plugin itself -- and + // then nothing names the stdlib, so naming it cannot be the test. + String[] applied = { + " classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.22'\n", + "apply plugin: 'kotlin-android'\n", + " id 'org.jetbrains.kotlin.android' version '1.7.22'\n", + }; + for (int i = 0; i < applied.length; i++) { + assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", false, applied[i])), + "<<" + applied[i].trim() + ">> puts the Kotlin toolchain in " + + "this build, and it declares the stdlib itself"); + } + } + + @Test + void aSingleVersionRangeIsARange() { + // [1.7.22] admits exactly one version and contains no comma at all. + assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock( + "implementation", false, + " implementation " + + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:[1.7.22]'\n")), + "a single-version range admits nothing else"); + assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, + " implementation " + + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n") + .contains(":1.8.0"), + "and a plain version is still raised"); + } + /** The floor is the version at which the shims became empty. */ @Test void theFloorIsWhereTheClassesMoved() { @@ -268,7 +304,8 @@ void theBuilderPassesEveryAppControlledFragment() throws Exception { assertTrue(at >= 0, "the builder collects the app's Gradle fragments"); String list = src.substring(at, src.indexOf("};", at)); String[] hints = { - "android.gradlePlugin", "android.gradle.androidx", + "android.gradlePlugin", "android.topDependency", + "android.gradle.androidx", "android.xgradle_default_config", "android.supportv4Dep", "android.gradleDep", "android.xgradle", }; From 13d84203418ef1ab80e8fba97ff126fdd3757128 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:20:47 +0300 Subject: [PATCH 90/94] Drop the Display change; it does not belong in this PR This PR is a Kotlin stdlib build hint. It had no business editing the EDT dispatch loop in the core framework, and the intermittent edt=display-not-initialized failure it was chasing is on master, not caused by anything here. Reverting Display.java and removing EdtHandoverTest keeps this change to the builders. --- CodenameOne/src/com/codename1/ui/Display.java | 95 +----------- .../com/codename1/junit/EdtHandoverTest.java | 141 ------------------ 2 files changed, 7 insertions(+), 229 deletions(-) delete mode 100644 maven/core-unittests/src/test/java/com/codename1/junit/EdtHandoverTest.java diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 7858fcf9347..59fdef98def 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -272,22 +272,6 @@ public final class Display extends CN1Constants { /// This is the instance of the EDT used internally to indicate whether /// we are executing on the EDT or some arbitrary thread private Thread edt; - - /// Whether {@link #edt} is still inside its dispatch loop. - /// - /// A thread that has left that loop but not yet returned from - /// `mainEDTLoop()` is still `isAlive()`, and `init()` used to adopt it on - /// that evidence alone -- starting no dispatch thread of its own and - /// queueing the whole new generation onto a thread on its way out. This is - /// not a guess about a thread that might be leaving: the departing thread - /// clears it itself, at the instant it stops dispatching. - /// - /// Read without the lock, as `edt` is, and for the same reason: making the - /// read exclusive would hold `lock` across `impl.setThreadPriority()`, - /// which on some ports hands work to the platform's own UI thread. What - /// closes the race is that this is cleared BEFORE the teardown rather than - /// after it, not mutual exclusion. - private boolean edtDispatching; /// Contains animations that must be played in full by the EDT before anything further /// may be processed. This is useful for transitions/intro's etc... that animate without /// user interaction. @@ -532,47 +516,13 @@ public static void init(Object m) { // has no dispatch at all. This is a thread that has actually // died, not one that might be mid-teardown; the speculative // machinery that used to be here was removed on purpose. - // - // ... and neither is one that has stopped dispatching but not yet - // returned. That thread is still ALIVE for the whole of its - // teardown, and adopting it left every test in a class reporting - // "timed out after 5000ms; edt=display-not-initialized". That is - // not speculation about a thread that might be leaving: the thread - // itself clears the flag, in mainEDTLoop, at the instant it leaves. - // - // Decided under `lock`, which is the same monitor it clears the - // flag under, so there are only two orderings and both are right. - // Either it has already left, and this starts a replacement; or it - // has not, and it reads the codenameOneRunning set above and keeps - // dispatching for this generation. - boolean startEdt; - synchronized (lock) { - startEdt = INSTANCE.edt == null || !INSTANCE.edt.isAlive() - || !INSTANCE.edtDispatching; - if (startEdt) { - INSTANCE.edtDispatching = true; - } - } - if (startEdt) { + if (INSTANCE.edt == null || !INSTANCE.edt.isAlive()) { INSTANCE.touchScreen = impl.isTouchDevice(); // initialize the Codename One EDT which from now on will take all responsibility // for the event delivery. - // - // Built and prioritised on a local, outside the lock: - // setThreadPriority reaches the platform's own UI thread on some - // ports, and holding `lock` across that would trade the race for - // a deadlock. - Thread replacement = new CodenameOneThread(new RunnableWrapper(null, 3), "EDT"); - impl.setThreadPriority(replacement, impl.getEDTThreadPriority()); - // Published under the lock, because the departing thread's own - // check-and-clear of this field runs under it. Otherwise that - // clear can land between the two and null out a LIVE dispatch - // thread: the loop keeps running, but isEdt() stops recognising - // it, so callSeriallyAndWait() from the EDT waits on itself. - synchronized (lock) { - INSTANCE.edt = replacement; - replacement.start(); - } + INSTANCE.edt = new CodenameOneThread(new RunnableWrapper(null, 3), "EDT"); + impl.setThreadPriority(INSTANCE.edt, impl.getEDTThreadPriority()); + INSTANCE.edt.start(); } impl.postInit(); INSTANCE.setCommandBehavior(commandBehaviour); @@ -1349,30 +1299,11 @@ void mainEDTLoop() { } } - // One exit, and it is taken under `lock`, because leaving this loop and - // saying so have to be the same event. init() decides whether to start a - // dispatch thread from edtDispatching, and any gap between the two lets - // it decide on a thread that has already gone -- adopting it, starting - // nothing, and queueing the whole new generation onto a corpse. - CodenameOneImplementation departing = null; - while (departing == null) { + while (codenameOneRunning) { // PMD Fix: AvoidBranchingStatementAsLastInLoop try { // wait indefinetly Lock surrounds the should method to prevent serial calls from // getting "lost" synchronized (lock) { - if (!codenameOneRunning) { - // The implementation this thread served LAST, which is - // not always the one it started on: an init() while it - // was still dispatching adopts it legitimately, and sets - // codenameOneRunning back to true before reaching the - // decision below. So arriving here means the display is - // genuinely down and `impl` is still that generation's. - departing = impl; - if (INSTANCE.edt == Thread.currentThread()) { //NOPMD CompareObjectsWithEquals - INSTANCE.edtDispatching = false; - } - break; - } if (shouldEDTSleep()) { if (!pendingIdleSerialCalls.isEmpty()) { Runnable r = pendingIdleSerialCalls.remove(0); @@ -1425,23 +1356,11 @@ void mainEDTLoop() { // Dispose any window still open, on the EDT, before the implementation goes // away. Doing this from the static deinitialize() would run the teardown off // the EDT, which is exactly the thread the window's tree expects. - // - // So `edt` still refers to this thread here, and isEdt() still answers - // true. Announcing the departure by clearing `edt` instead of the flag - // would make this teardown run as a non-EDT caller, which is the - // opposite of what the line below is for. Desktop.getInstance().disposeAll(); - departing.deinitialize(); - // Under the lock, and only if it is still this thread. init() publishes a - // replacement under the same lock, so the two cannot interleave into - // nulling a thread that is dispatching. - synchronized (lock) { - if (INSTANCE.edt == Thread.currentThread()) { //NOPMD CompareObjectsWithEquals - INSTANCE.edt = null; - } - } + impl.deinitialize(); //INSTANCE.impl = null; //INSTANCE.codenameOneGraphics = null; + INSTANCE.edt = null; } /// Returns the stack trace from the exception on the given diff --git a/maven/core-unittests/src/test/java/com/codename1/junit/EdtHandoverTest.java b/maven/core-unittests/src/test/java/com/codename1/junit/EdtHandoverTest.java deleted file mode 100644 index 9f2b71aba60..00000000000 --- a/maven/core-unittests/src/test/java/com/codename1/junit/EdtHandoverTest.java +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright (c) 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.junit; - -import com.codename1.impl.CodenameOneImplementation; -import com.codename1.impl.ImplementationFactory; -import com.codename1.testing.TestCodenameOneImplementation; -import com.codename1.ui.Display; -import com.codename1.ui.Form; -import org.junit.jupiter.api.Test; - -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * The handover from one Display generation to the next. - * - *

An EDT that has left its dispatch loop is still {@code isAlive()} for as - * long as its teardown runs. {@code Display.init} tested exactly that, so a - * second init landing in the gap adopted a thread that would never dispatch - * again -- and the departing thread then deinitialized whatever implementation - * was current by then, which was the new one. The display reports - * {@code codenameOneRunning} true with an implementation that says it is not - * initialized, every call queues onto nothing, and the suite reports - * "timed out after 5000ms; edt=display-not-initialized" for a whole class.

- * - *

The window is a few instructions wide on an idle machine, which is why it - * only ever appeared on loaded CI runners. Here it is held open on purpose.

- */ -class EdtHandoverTest { - - /// Sits inside deinitialize() until released, which is exactly the state the - /// race needs: out of the dispatch loop, into the teardown, still alive. - private static final class SlowToDie extends TestCodenameOneImplementation { - private final CountDownLatch entered = new CountDownLatch(1); - private final CountDownLatch release = new CountDownLatch(1); - private volatile boolean sawEdt; - - @Override - public void deinitialize() { - // The teardown is meant to run AS the EDT -- disposeAll() above it - // exists to dispose windows on the thread their tree expects. So the - // departing thread cannot stop being the EDT to avoid adoption; it - // has to say it stopped dispatching and stay the EDT until the end. - sawEdt = Display.getInstance().isEdt(); - entered.countDown(); - try { - release.await(10, TimeUnit.SECONDS); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - } - super.deinitialize(); - } - } - - private static void useImplementation(final CodenameOneImplementation impl) { - ImplementationFactory.setInstance(new ImplementationFactory() { - @Override - public Object createImplementation() { - return impl; - } - }); - } - - @Test - void aSecondInitDuringTeardownGetsAWorkingDispatchThread() throws Exception { - SlowToDie dying = new SlowToDie(); - useImplementation(dying); - Display.deinitialize(); - Display.init(null); - assertTrue(Display.isInitialized(), "precondition: the display is up"); - - // A form has to be showing, or the EDT never leaves the loop it runs - // before the first Form.show() -- that one has no codenameOneRunning in - // its condition, so deinitialize() alone does not end it. - final CountDownLatch shown = new CountDownLatch(1); - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - new Form("handover").show(); - shown.countDown(); - } - }); - assertTrue(shown.await(10, TimeUnit.SECONDS), - "precondition: a form is showing"); - - // The old generation goes away, and stops in its teardown. - Display.deinitialize(); - assertTrue(dying.entered.await(10, TimeUnit.SECONDS), - "precondition: the departing EDT reached its teardown"); - - TestCodenameOneImplementation live = new TestCodenameOneImplementation(); - useImplementation(live); - Display.init(null); - - final CountDownLatch ran = new CountDownLatch(1); - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - ran.countDown(); - } - }); - dying.release.countDown(); - - try { - assertTrue(ran.await(5, TimeUnit.SECONDS), - "the new generation never dispatched: init adopted the EDT that " - + "was on its way out"); - assertTrue(Display.isInitialized(), - "the departing EDT deinitialized the LIVE implementation instead " - + "of its own"); - assertTrue(dying.sawEdt, - "the teardown ran as a non-EDT caller, so disposeAll() disposed " - + "the window tree off the thread it belongs to"); - } finally { - Display.deinitialize(); - } - } -} From 7492d38d5a88727b9035b7a6da9031d002076dc7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:28:00 +0300 Subject: [PATCH 91/94] State the overlap as a capability, so no version has to move Measured against a real Gradle 6.5 and 8.5 resolving from Maven Central, which is what this change should have started with. The graph the feature exists for -- kotlin-stdlib 1.8.10 with kotlin-stdlib-jdk8 1.6.21 -- resolves to both jars and duplicates classes, reproduced. The constraint this emitted did fix that. It also turned a strict pin on the shim into "Could not resolve ... {strictly 1.6.21}", and a reject into the same: a build that resolved before the alignment and not after it. That is what the guard was for, and why it kept growing: a constraint RAISES a version, an app can be holding one down, and enumerating the ways it might be doing so from Gradle text has no end. A capability states a fact instead -- from 1.8.0 up, kotlin-stdlib provides what the shims provide -- and Gradle drops the redundant shim. Nothing moves, so there is nothing to conflict with and nothing to detect. The strict pin and the reject both resolve now. An enforced BOM, a force, and a bounded range resolve as they always did. An all-1.7 project is untouched, because the capability is only declared from the floor up, which is also why a Kotlin compiler older than 1.8 cannot be affected. A graph with no Kotlin is inert, and applying the rule twice is harmless. failOnVersionConflict with an old shim still fails -- and fails identically with no script at all, so that graph is already broken. The measurements also killed things I had implemented on reasoning alone. A bounded range does NOT exclude the floor: Gradle raised kotlin-stdlib-jdk8:[1.6,1.8) to 1.8.0 and resolved. Neither does a single-version range or a bounded require. force and enforcedPlatform simply won over the constraint with no failure. All of that detection is gone, along with the word list, the Kotlin toolchain scan and the android.topDependency read, because the class now takes no input at all. Test 5 of 7 asserts the property this rests on: the script requires no version of anything. --- .../build/shared/BuildHintsAndroid.java | 50 +-- .../builders/AndroidGradleBuilder.java | 71 +--- .../builders/KotlinStdlibAlignment.java | 357 +++++----------- .../builders/KotlinStdlibAlignmentTest.java | 387 +++++------------- 4 files changed, 219 insertions(+), 646 deletions(-) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index d69cc22cbbe..b7790ae0603 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -643,39 +643,23 @@ static void register(List h) { .type(HintType.BOOLEAN) .def("true") .platform("android") - .doc("Boolean true/false defaults to true. Keeps `kotlin-stdlib-jdk7` and " - + "`kotlin-stdlib-jdk8` at 1.8.0 or newer, the versions where both became empty " - + "shims because their classes moved into `kotlin-stdlib`. Without it a graph " - + "that reaches `kotlin-stdlib` 1.8 or newer through one dependency and an older " - + "`kotlin-stdlib-jdk8` through another gets two jars carrying the same classes, " - + "and the build fails in `checkReleaseDuplicateClasses` naming Kotlin artifacts " - + "the app never asked for. Expressed as a Gradle constraint, so it adds " - + "nothing to an app with no Kotlin anywhere in its dependencies and never " - + "lowers a version. A version you declare yourself is a soft requirement in " - + "Gradle, so the constraint raises an older one rather than leaving the " - + "duplicate in place, and a newer one wins over the floor on its own. " - + "If you pin the family yourself the whole block is left out: your Gradle " - + "build hints are searched for the text `kotlin-stdlib` alongside any of " - + "`strictly`, `!!`, `force`, `reject`, `require`, `enforcedPlatform`, " - + "`useVersion`, `useTarget`, `substitute` or `failOnVersionConflict`, and " - + "a hit means you have decided the version. `kotlin-bom` counts as " - + "naming the family, since an enforced platform manages these " - + "modules without mentioning them. A version range does the " - + "same without any of those words, so `kotlin-stdlib:[1.7,1.8)` " - + "and the single-version `[1.7.22]` also leave the block out. " - + "A Kotlin Gradle plugin applied from any of those hints has the " - + "same effect, whoever applied it: the plugin declares a stdlib " - + "at the compiler\'s version and owns the family from then on. " - + "The search ignores case, so " - + "`setForcedModules` counts as a force. It's plain text, so it errs " - + "toward leaving the floor out. Leaving it out costs you the duplicate " - + "class you already had; adding it over a deliberate pin would break a " - + "build that works today. A project with Kotlin sources of its own is left " - + "alone for the same reason: it gets a Kotlin Gradle plugin and a stdlib " - + "at the compiler\'s version, and on Gradle 6 and 7 that version is below " - + "this floor, so raising the shims would pull the base stdlib past the " - + "compiler that has to read it. Set to false to manage these coordinates " - + "yourself in every case.")); + .doc("Boolean true/false defaults to true. Kotlin 1.8.0 moved the contents of " + + "`kotlin-stdlib-jdk7` and `kotlin-stdlib-jdk8` into `kotlin-stdlib` " + + "and left the two shims empty. A build that reaches `kotlin-stdlib` " + + "1.8 or newer through one dependency and an older " + + "`kotlin-stdlib-jdk8` through another then carries the same classes " + + "twice and fails in `checkReleaseDuplicateClasses`, naming Kotlin " + + "artifacts you never asked for. The 1.8.x line ships no Gradle " + + "module metadata to say the two overlap; from 1.9.22 JetBrains " + + "ships it. This adds that missing statement, as a Gradle " + + "capability: from 1.8.0 up, `kotlin-stdlib` provides what the shims " + + "provide, so Gradle drops the redundant shim. It moves no version, " + + "which is what keeps it out of your way -- a version pin, a force, " + + "an enforced BOM, a range or a Kotlin compiler older than 1.8 all " + + "resolve exactly as they did without it. Below 1.8.0 nothing " + + "happens at all, because there the shims still hold the only copy " + + "of their classes. Set to false to manage these coordinates " + + "yourself.")); h.add(new Hint("android.largeScreens") .group(HintGroup.ANDROID) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 07880330174..0b0937d3162 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7297,65 +7297,15 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // are not symmetrical: too narrow leaves an ancient build with a failure it // already had, too wide breaks a build that currently succeeds. Raise this // gate only with a reproduction on that path. - String kotlinStdlibConstraints = ""; + // No inputs. This used to collect every Gradle fragment the app + // controls and search it for signs that the app was holding a stdlib + // version down, because the alignment RAISED one and could then break a + // build that resolved. It declares a capability now, which raises + // nothing, so there is nothing to search for -- see KotlinStdlibAlignment. + String kotlinStdlibAlignment = ""; if (useAndroidX && gradleVersionInt >= 6 && request.getArg("android.kotlinStdlibAlignment", "true").equals("true")) { - // Every fragment the APP controls, as one piece of text. Order and - // nesting do not matter to a whole-text check, so nothing here wraps - // or sequences them -- that was the parser's requirement, not this - // one. Built once because it answers two questions: whether the app - // already holds this family, and what to hand the alignment. - String[] appGradle = { - request.getArg("android.gradlePlugin", ""), - // The buildscript block. An app can put its own - // kotlin-gradle-plugin classpath here -- this builder checks for - // exactly that above before adding one -- so leaving it out hid - // the clearest statement an app can make about this family. - request.getArg("android.topDependency", ""), - request.getArg("android.gradle.androidx", ""), - request.getArg("android.xgradle_default_config", ""), - request.getArg("android.supportv4Dep", ""), - request.getArg("android.gradleDep", ""), - request.getArg("android.xgradle", ""), - coreLibraryDesugaringDependency, - kotlinRuntimeDependency, - additionalDependencies, - aiExtraGradleDependencies.toString(), - aarDependencies, - injectRepo, - gradleDependency - }; - try { - if (hasKotlinSources) { - // The Kotlin plugin applied above owns this family, at the - // compiler's own version -- which is below the floor on this - // path. See constraintsBlock's projectCompilesKotlin. - log("NOTICE: not aligning the Kotlin stdlib, this project " - + "compiles Kotlin and its plugin manages that family"); - } else if (KotlinStdlibAlignment.appPinsTheStdlibFamily(appGradle)) { - // Said out loud. An alignment that silently does not happen - // is the one support cannot account for later, when the - // duplicate class it exists to prevent comes back. - log("NOTICE: not aligning the Kotlin stdlib, the project's " - + "own Gradle text holds that family itself"); - } else { - kotlinStdlibConstraints = KotlinStdlibAlignment.constraintsBlock( - compile, hasKotlinSources, appGradle); - } - } catch (RuntimeException e) { - // The alignment is string work with no indexing in it, so this - // should not be reachable. It is here anyway because the whole - // block is an optimisation over a build that already worked - // apart from one duplicate class, and it runs on EVERY AndroidX - // build -- so a defect here would not break one app, it would - // break all of them. Three lines buy the difference between - // believing it cannot fail a build and knowing it cannot. - // Logged rather than swallowed, because a silent catch would - // hide the defect for as long as nobody reported the duplicate. - kotlinStdlibConstraints = ""; - log("NOTICE: skipping the Kotlin stdlib alignment, it failed " - + "unexpectedly: " + e); - } + kotlinStdlibAlignment = KotlinStdlibAlignment.alignmentScript(); } String gradleProps = "apply plugin: 'com.android.application'\n" @@ -7449,8 +7399,13 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { + addNewlineIfMissing(aiExtraGradleDependencies.toString()) + addNewlineIfMissing(request.getArg("android.gradleDep", "")) + addNewlineIfMissing(aarDependencies) - + kotlinStdlibConstraints + "}\n" + // After the dependencies block, not inside it: the alignment + // needs a component metadata rule (which lives in dependencies) + // AND a resolution strategy (which does not), so it brings its + // own dependencies block rather than being spliced into two + // places. + + kotlinStdlibAlignment + request.getArg("android.xgradle", ""); debug("Gradle File start\n-------\n"); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 1b61b59d10b..ddf5d1f2783 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -22,8 +22,6 @@ */ package com.codename1.builders; -import java.util.Locale; - /** * The Kotlin stdlib alignment written into the generated Android * {@code build.gradle}. @@ -35,290 +33,127 @@ * {@code kotlin-stdlib-jdk8} through another therefore carries the same classes * twice, and the build fails in {@code checkDuplicateClasses} naming Kotlin * artifacts the app never asked for. The 1.8.x line ships no Gradle module - * metadata saying the two overlap, so nothing tells Gradle to align them; from - * 1.9.22 JetBrains ships that metadata itself. This supplies for 1.8.x what - * JetBrains supplies later.

+ * metadata saying the two overlap; from 1.9.22 JetBrains ships it.

* - *

Why a constraint. A constraint raises a version and never lowers - * one, and never pulls a module into a graph that does not already contain it. - * An app with no Kotlin anywhere is completely unaffected -- the block resolves - * to nothing.

+ *

Why a capability and not a version constraint. This was a + * constraint raising both shims to the floor, and a constraint raises a + * version -- which is a thing an app can be holding down. Measured against + * a real Gradle, a strict pin or a {@code reject} on a shim turns into + * {@code Could not resolve ... {strictly 1.6.21}}: a build that resolved before + * the alignment and does not after it. Guarding that by reading the app's own + * Gradle for signs of a pin is an unbounded problem, and every round of review + * found another spelling it missed.

* - *

Why the guard is blunt. The one thing a constraint at the floor can - * break is an app that FIRMLY holds a member of the family below it: a strict - * pin, a force, a rejection, an enforced BOM, or a conflict-failing resolution - * strategy. Such a graph resolves coherently today, and a constraint requiring - * 1.8.0 turns it into {@code Could not resolve ... {strictly 1.7.22}} -- the one - * outcome this must never produce.

+ *

Declaring the overlap as a capability has no such failure mode. It + * states a fact -- from the floor up, {@code kotlin-stdlib} provides what the + * shims provide -- and lets Gradle drop the redundant shim. No version moves, + * so there is nothing for a pin, a force, an enforced BOM, a range, a lock or a + * Kotlin compiler version to conflict with, and nothing to detect. That is why + * this class has no inputs.

* - *

Deciding that by reading the app's Gradle text properly needs a Groovy - * parser. This class WAS one: some 2,200 lines and 130 methods tracking - * definitions, scopes, map notation, rich versions, resolution rules and - * component selection. It was reviewed into the ground, and rightly -- every - * round turned up another spelling it read wrongly, because Groovy has - * unboundedly many of them and an approximate parser is unboundedly wrong. None - * of that machinery ever changed the outcome for the graphs this exists to fix, - * which reach the shims transitively and name them nowhere.

+ *

Measured, not reasoned. The emitted script was run against real + * Gradle 6.5 (the builder's default) and 8.5, resolving against Maven Central:

* - *

So the question is asked bluntly: does the app's Gradle text name this - * family at all, AND mention any of the words that can hold a version down? If - * so, stand down and say so in the log. That over-suppresses -- a {@code force} - * on an unrelated library in a script that also happens to name - * {@code kotlin-stdlib} is enough, and a word inside a comment or a string - * counts. Over-suppressing costs an app the duplicate it already had, which is - * exactly what {@code android.kotlinStdlibAlignment=false} does deliberately. - * Under-suppressing breaks a build that works today. The asymmetry is the whole - * design.

+ *
    + *
  • stdlib 1.8.10 with {@code kotlin-stdlib-jdk8:1.6.21} -- the duplicate, + * reproduced; the shims are dropped and it resolves.
  • + *
  • the same, with the shim pinned {@code strictly}, or with + * {@code reject '[1.8.0,)'} -- resolves. The constraint version failed + * both.
  • + *
  • an all-1.7 project -- untouched, because the capability is only + * declared from the floor up, so shims that still carry real classes stay. + * This is also why a Kotlin compiler older than the floor is not a + * problem: nothing raises the stdlib under it.
  • + *
  • stdlib 1.9.22, a graph with no Kotlin at all, and this same rule applied + * twice -- all inert or clean.
  • + *
  • {@code failOnVersionConflict} with an old shim fails identically with + * this script and with no script at all: that graph is already broken.
  • + *
*/ public class KotlinStdlibAlignment { /** - * The version at which the shims became empty, and the floor this raises - * them to. + * The version at which the shims became empty, and the version from which + * {@code kotlin-stdlib} is declared to provide their capabilities. */ public static final String MERGED_STDLIB_FLOOR = "1.8.0"; - /** The two shims whose classes moved, and which this raises. */ + /** The two shims whose classes moved into {@code kotlin-stdlib}. */ private static final String[] ALIGNED_ARTIFACTS = { "kotlin-stdlib-jdk7", "kotlin-stdlib-jdk8" }; - /** - * The names that mean "this family". - * - *

{@code kotlin-stdlib} prefixes all three of the modules themselves -- - * {@code kotlin-stdlib}, {@code kotlin-stdlib-jdk7} and - * {@code kotlin-stdlib-jdk8}. {@code kotlin-bom} is the platform that - * manages them, and it is the real coordinate: there is no - * {@code kotlin-stdlib-bom}, which is what an earlier test here asserted - * against, so an enforced BOM went unseen.

- */ - private static final String[] FAMILY_NAMES = { - "kotlin-stdlib", - "kotlin-bom" - }; - - /** - * Text that means the Kotlin toolchain is in this build at all. - * - *

Applying a Kotlin Gradle plugin makes it the owner of the stdlib: it - * declares one at the compiler's own version, and on the Gradle 6 and 7 - * path that version is below this floor. This stands the alignment down - * without the family being named anywhere, because whoever applied the - * plugin need not have named it -- the plugin does that itself. It is the - * same reason as {@code projectCompilesKotlin}, reached the other way: that - * one is a source scan under {@code src/main/java}, and Kotlin can arrive - * from a source set the scan never looks at.

- */ - private static final String[] KOTLIN_TOOLCHAIN_WORDS = { - "kotlin-gradle-plugin", - "kotlin-android", - "org.jetbrains.kotlin.android" - }; - - /** - * The words that can hold a version where this would raise it. - * - *

Gradle's ways of doing that, plus {@code failOnVersionConflict}, which - * turns the raise itself into a build failure. Matched as plain text: the - * point of this list is to be crude and complete rather than precise, since - * a false match only declines to help.

- * - *

Lower case, because the text is lower cased before the search. The - * same act appears in more than one spelling -- {@code force} the command - * and {@code setForcedModules} the setter -- and a case-sensitive - * {@code force} finds the first and misses the second.

- * - *

{@code require} is here for its bounded form. A plain - * {@code require '1.7.22'} is soft and the constraint raises it happily, - * but {@code require '[1.7,1.8)'} excludes the floor, and no version then - * satisfies both. Standing down for the unbounded case as well is the - * cheap side of the trade.

- */ - private static final String[] PINNING_WORDS = { - "strictly", - "!!", - "force", - "reject", - "require", - "enforcedplatform", - "useversion", - "usetarget", - "substitute", - "failonversionconflict" - }; - private KotlinStdlibAlignment() { } /** - * The {@code constraints} block to append inside the generated - * {@code dependencies { }}, or an empty string when no alignment should be - * written. + * The alignment, as a self-contained script to append after the generated + * {@code dependencies { }} block. * - * @param projectCompilesKotlin whether the project has Kotlin sources, and - * so gets a Kotlin Gradle plugin and a stdlib declaration at the - * compiler's own version. That version is the one the app's own classes - * are compiled against, and it is below this floor on the Gradle 6 and 7 - * path. Raising the shims there pulls the base stdlib up with them -- - * the empty shims depend on it -- and a compiler reading a stdlib newer - * than itself reports "Module was compiled with an incompatible version - * of Kotlin", turning a Kotlin app that builds today into one that does - * not. This alignment is for the Java-only graph that reaches the shims - * transitively; where the project compiles Kotlin, the Kotlin plugin - * owns the family. - * @param configuration the dependency configuration to declare the - * constraints on, {@code implementation} on any AndroidX project. The - * caller passes the same name it uses for the rest of the block so a - * legacy {@code compile} project stays consistent with itself. - * @param appGradleFragments the Gradle text the app itself contributed - * ({@code android.gradleDep}, {@code android.xgradle} and the like). - * Order and nesting do not matter -- the whole lot is read as one piece of - * text. Null entries are ignored. - * @return the block, newline terminated, or {@code ""} - */ - public static String constraintsBlock(String configuration, - boolean projectCompilesKotlin, String... appGradleFragments) { - if (configuration == null || configuration.trim().length() == 0) { - return ""; - } - if (projectCompilesKotlin) { - return ""; - } - if (appPinsTheStdlibFamily(appGradleFragments)) { - return ""; - } - String config = configuration.trim(); - // "because" is not decoration: it is what `gradle dependencyInsight` - // prints next to the raised version, and this constraint is otherwise - // unattributable to anything in the developer's project. - String because = "Codename One: kotlin-stdlib " + MERGED_STDLIB_FLOOR - + " absorbed the jdk7/jdk8 classes and the 1.8.x line ships no " - + "Gradle module metadata to say so, so these are raised to the " - + "empty shims to avoid a duplicate class in checkDuplicateClasses"; - StringBuilder out = new StringBuilder(); - for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { - out.append(" ").append(config) - .append("('org.jetbrains.kotlin:").append(ALIGNED_ARTIFACTS[i]) - .append(':').append(MERGED_STDLIB_FLOOR).append("') {\n") - .append(" because '").append(because).append("'\n") - .append(" }\n"); - } - return " constraints {\n" + out + " }\n"; - } - - /** - * Whether the app's own Gradle text suggests it holds this family where the - * constraint would raise it. + *

Self-contained because it needs two different scopes: the component + * metadata rule belongs inside {@code dependencies}, the resolution + * strategy outside it. It opens its own {@code dependencies} block rather + * than making the caller splice two pieces into two places.

* - *

Both halves are required. An app that never names the family cannot be - * pinning it, and one that names it without any of these words is declaring - * an ordinary version -- which is a SOFT requirement in Gradle, so the - * constraint raises it and the two agree.

- * - *

Public because the builder logs a notice when it answers yes: an - * alignment that silently does not happen is the kind of thing support - * cannot explain afterwards.

+ * @return the script, newline terminated */ - public static boolean appPinsTheStdlibFamily(String... appGradleFragments) { - if (appGradleFragments == null) { - return false; - } - StringBuilder all = new StringBuilder(); - for (int i = 0; i < appGradleFragments.length; i++) { - if (appGradleFragments[i] != null) { - all.append(appGradleFragments[i]).append('\n'); - } - } - // Locale.ENGLISH, not the default: a Turkish default locale lower cases - // I to a dotless i, which turns "STRICTLY" into "str\u0131ctly" and - // matches nothing. The same trap is commented in AndroidGradleBuilder. - String text = all.toString().toLowerCase(Locale.ENGLISH); - for (int i = 0; i < KOTLIN_TOOLCHAIN_WORDS.length; i++) { - if (text.indexOf(KOTLIN_TOOLCHAIN_WORDS[i]) >= 0) { - return true; - } - } - boolean namesTheFamily = false; - for (int i = 0; i < FAMILY_NAMES.length; i++) { - if (text.indexOf(FAMILY_NAMES[i]) >= 0) { - namesTheFamily = true; - break; - } - } - if (!namesTheFamily) { - // Nothing else here can be about this family. Note what is NOT - // reachable from the Gradle text: a gradle.lockfile, which Gradle - // enforces as a strict constraint. This builder writes the project - // from scratch and has no dependency locking, no lock file and no - // hint that ships one, so there is no lock to read -- and locking - // with no lock state does nothing. Revisit this if the builder ever - // grows a way to carry files into the generated project. - return false; - } - for (int i = 0; i < PINNING_WORDS.length; i++) { - if (text.indexOf(PINNING_WORDS[i]) >= 0) { - return true; - } - } - return containsAVersionRange(text); - } + public static String alignmentScript() { + String floorMajor = MERGED_STDLIB_FLOOR.substring( + 0, MERGED_STDLIB_FLOOR.indexOf('.')); + String rest = MERGED_STDLIB_FLOOR.substring( + MERGED_STDLIB_FLOOR.indexOf('.') + 1); + String floorMinor = rest.substring(0, rest.indexOf('.')); - /** - * Whether the text carries a Gradle version range, which needs no keyword - * at all. - * - *

{@code 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:[1.7,1.8)'} is an - * ordinary coordinate as far as the vocabulary above is concerned, and it - * excludes the floor: constraining to 1.8.0 leaves Gradle nothing that - * satisfies both, so a build that resolves today stops resolving.

- * - *

Two signatures. A bracket against a digit -- {@code [1.7.22]} admits - * exactly one version and has no comma at all -- and a comma with digits on - * its left and digits or a closing bracket on its right, which is the only - * place a comma appears inside a version. Map notation -- - * {@code group: 'x', name: 'y', version: '1.8.0'} -- puts a quote to the - * left of every comma and is the case this must not fire on, since - * declaring the family that way is ordinary and gets raised. Anything else - * that happens to put a digit either side of a comma is a false match, and - * a false match only declines to help.

- */ - private static boolean containsAVersionRange(String text) { - for (int i = 0; i < text.length(); i++) { - char c = text.charAt(i); - if (c != '[' && c != ']') { - continue; - } - int after = i + 1; - while (after < text.length() && text.charAt(after) == ' ') { - after++; - } - if (after < text.length() && text.charAt(after) >= '0' - && text.charAt(after) <= '9') { - return true; - } + StringBuilder out = new StringBuilder(); + out.append("\n") + .append("// Codename One: kotlin-stdlib ").append(MERGED_STDLIB_FLOOR) + .append(" absorbed the kotlin-stdlib-jdk7 and kotlin-stdlib-jdk8\n") + .append("// classes and the 1.8.x line ships no Gradle module metadata saying so,\n") + .append("// so a graph holding both carries the same classes twice and fails\n") + .append("// checkDuplicateClasses. Declaring the overlap as a capability lets\n") + .append("// Gradle drop the redundant shim. It raises no version, so it cannot\n") + .append("// conflict with a pin, a force, a BOM or the Kotlin compiler in use.\n") + .append("// Turn it off with the build hint android.kotlinStdlibAlignment=false.\n") + .append("dependencies {\n") + .append(" components.withModule('org.jetbrains.kotlin:kotlin-stdlib') { details ->\n") + .append(" try {\n") + .append(" def parts = details.id.version.split('[.-]')\n") + .append(" def major = parts[0].toInteger()\n") + .append(" def minor = parts[1].toInteger()\n") + .append(" if (major > ").append(floorMajor) + .append(" || (major == ").append(floorMajor) + .append(" && minor >= ").append(floorMinor).append(")) {\n") + .append(" allVariants {\n") + .append(" withCapabilities {\n"); + for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { + out.append(" addCapability('org.jetbrains.kotlin', '") + .append(ALIGNED_ARTIFACTS[i]) + .append("', details.id.version)\n"); } - for (int i = text.indexOf(','); i >= 0; i = text.indexOf(',', i + 1)) { - int before = i - 1; - while (before >= 0 && text.charAt(before) == ' ') { - before--; - } - int after = i + 1; - while (after < text.length() && text.charAt(after) == ' ') { - after++; - } - if (before < 0 || after >= text.length()) { - continue; - } - char left = text.charAt(before); - char right = text.charAt(after); - if (left >= '0' && left <= '9' - && ((right >= '0' && right <= '9') - || right == ')' || right == ']' || right == '[')) { - return true; - } + out.append(" }\n") + .append(" }\n") + .append(" }\n") + .append(" } catch (Exception ignored) {\n") + .append(" // A version this cannot read is left alone. Doing nothing leaves\n") + .append(" // the duplicate the app already had; guessing could drop a shim\n") + .append(" // whose classes are still the only copy.\n") + .append(" }\n") + .append(" }\n") + .append("}\n") + .append("configurations.all {\n") + .append(" resolutionStrategy.capabilitiesResolution {\n"); + for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { + out.append(" withCapability('org.jetbrains.kotlin:") + .append(ALIGNED_ARTIFACTS[i]).append("') {\n") + .append(" def stdlib = candidates.find { it.id.module == 'kotlin-stdlib' }\n") + .append(" if (stdlib != null) {\n") + .append(" select(stdlib)\n") + .append(" }\n") + .append(" }\n"); } - return false; + out.append(" }\n") + .append("}\n"); + return out.toString(); } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index 5924e74d7ac..c7369c026be 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -27,333 +27,132 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * The alignment is a Gradle constraint plus one blunt reason not to write it. + * The alignment emits one script and takes no input, so there is little here. * - *

These cover what the feature promises: the graph it fixes, the graphs it - * must not touch, and the guarantee that it can never fail a build. There is - * deliberately nothing here about Groovy syntax -- the class no longer reads - * any, and the suite that did was 5,652 lines chasing spellings that never - * changed an outcome.

+ *

What the script MEANS was measured against a real Gradle 6.5 and 8.5 + * resolving from Maven Central -- the duplicate graph, a strict pin, a reject, a + * force, an enforced BOM, a range, an all-1.7 project and a Kotlin-free one. No + * unit test can see resolution, so these pin the properties that make those + * outcomes hold, and the class javadoc records the runs.

*/ class KotlinStdlibAlignmentTest { - private static final String JDK7 = "org.jetbrains.kotlin:kotlin-stdlib-jdk7"; - private static final String JDK8 = "org.jetbrains.kotlin:kotlin-stdlib-jdk8"; - - /** - * The graph this exists for: the old shim arrives transitively and the app's - * own Gradle never mentions Kotlin at all. - */ - @Test - void aGraphThatNamesNoKotlinIsAligned() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", false, - " implementation 'androidx.appcompat:appcompat:1.6.1'\n", - " implementation 'com.android.billingclient:billing:9.1.0'\n"); - assertTrue(out.contains("'" + JDK7 + ":1.8.0'"), "jdk7 is raised: " + out); - assertTrue(out.contains("'" + JDK8 + ":1.8.0'"), "jdk8 is raised: " + out); - assertTrue(out.startsWith(" constraints {"), "as a constraints block: " + out); - assertTrue(out.contains("because 'Codename One:"), - "with a because, which is what dependencyInsight prints: " + out); - } - - /** A constraint pulls nothing into a graph that does not have it. */ + /** The overlap is stated as a capability, for both shims. */ @Test - void anEmptyProjectStillGetsTheFloor() { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", false, ""); - assertTrue(out.contains(":1.8.0"), "the block is written unconditionally"); + void theScriptStatesTheOverlapAsACapability() { + String s = KotlinStdlibAlignment.alignmentScript(); + assertTrue(s.contains("components.withModule('org.jetbrains.kotlin:kotlin-stdlib')"), + "the rule is on kotlin-stdlib, which is what gained the classes"); + assertTrue(s.contains("addCapability('org.jetbrains.kotlin', 'kotlin-stdlib-jdk7'"), + "jdk7"); + assertTrue(s.contains("addCapability('org.jetbrains.kotlin', 'kotlin-stdlib-jdk8'"), + "jdk8"); } /** - * The constraint goes on the configuration the caller is already using, so a - * legacy {@code compile} project stays consistent with itself. + * NO VERSION IS EVER RAISED. This is the whole reason the class has no + * inputs: a constraint raises a version, which an app can be holding down, + * and detecting that from Gradle text is unbounded. A capability moves + * nothing, so a shim version must appear nowhere as a requested version. */ @Test - void theConstraintFollowsTheCallersConfiguration() { - assertTrue(KotlinStdlibAlignment.constraintsBlock("compile", false, "") - .contains("compile('" + JDK7 + ":1.8.0')"), "compile"); - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, "") - .contains("implementation('" + JDK7 + ":1.8.0')"), "implementation"); - assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock(null, false, "")), - "and no configuration means no block"); - assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock(" ", false, "")), - "nor does a blank one"); + void theScriptRequiresNoVersionOfAnything() { + // The DIRECTIVES, not the prose: the script's own comment explains that + // it cannot conflict with a force, and matching that read as the script + // issuing one. + StringBuilder code = new StringBuilder(); + String[] lines = KotlinStdlibAlignment.alignmentScript().split("\n"); + for (int i = 0; i < lines.length; i++) { + if (!lines[i].trim().startsWith("//")) { + code.append(lines[i]).append('\n'); + } + } + String s = code.toString(); + assertTrue(!s.contains("constraints {"), + "a constraints block would raise a version: " + s); + assertTrue(!s.contains("kotlin-stdlib-jdk7:" + KotlinStdlibAlignment.MERGED_STDLIB_FLOOR) + && !s.contains("kotlin-stdlib-jdk8:" + + KotlinStdlibAlignment.MERGED_STDLIB_FLOOR), + "no shim is asked for at a version"); + assertTrue(!s.contains("strictly") && !s.contains("force") + && !s.contains("substitute"), + "and nothing else moves a version either"); } /** - * An ordinary version is a SOFT requirement in Gradle: the constraint raises - * it and the two agree. Declaring the shim is therefore not a reason to - * stand down -- if it were, the app that declares an old one directly would - * keep the duplicate this exists to remove. + * The capability is declared only from the floor up. Below it the shims + * still carry the only copy of their classes, so dropping one would remove + * them -- and a project compiling against an older Kotlin keeps its own + * stdlib untouched, which is why the compiler version cannot be a problem. */ @Test - void anOrdinaryDeclarationIsRaisedNotHonoured() { - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, - " implementation '" + JDK8 + ":1.7.22'\n") - .contains(":1.8.0"), - "a pre-merge declaration is raised"); - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, - " implementation '" + JDK8 + ":1.9.22'\n") - .contains(":1.8.0"), - "and a merged-era one is unaffected by a floor beneath it"); + void theCapabilityStartsWhereTheClassesMoved() { + String s = KotlinStdlibAlignment.alignmentScript(); + assertTrue("1.8.0".equals(KotlinStdlibAlignment.MERGED_STDLIB_FLOOR), + "1.8.0 is where kotlin-stdlib absorbed the jdk7/jdk8 classes"); + assertTrue(s.contains("major > 1 || (major == 1 && minor >= 8)"), + "the guard is derived from that floor: " + s); } - /** - * The one thing a constraint at the floor can break: an app that firmly - * holds a member of the family below it resolves coherently today, and a - * constraint requiring 1.8.0 turns that into a resolution failure. - */ + /** The conflict resolves to the stdlib, never to whichever version is higher. */ @Test - void anAppThatPinsTheFamilyIsLeftAlone() { - String[] pinned = { - " implementation '" + JDK8 + ":1.7.22!!'\n", - " implementation('" + JDK8 + "') { version { strictly '1.7.22' } }\n", - " configurations.all { resolutionStrategy.force '" + JDK8 + ":1.7.22' }\n", - " implementation('" + JDK8 + "') { version { reject '[1.8.0,)' } }\n", - // kotlin-bom, which is the coordinate that exists. This asserted - // against kotlin-stdlib-bom, so it passed while the real BOM went - // unseen -- the artifact under test has to be a real one. - " implementation(enforcedPlatform(" - + "'org.jetbrains.kotlin:kotlin-bom:1.7.22'))\n", - " configurations.all { resolutionStrategy.eachDependency { d ->\n" - + " if (d.requested.name == 'kotlin-stdlib') " - + "d.useVersion '1.7.22'\n } }\n", - " configurations.all { resolutionStrategy.componentSelection { all { s ->\n" - + " if (s.candidate.module == 'kotlin-stdlib-jdk8') " - + "s.reject('x')\n } } }\n", - " configurations.all { resolutionStrategy.failOnVersionConflict() }\n" - + " implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n", - }; - for (int i = 0; i < pinned.length; i++) { - String out = KotlinStdlibAlignment.constraintsBlock("implementation", false, - pinned[i]); - assertTrue("".equals(out), - "<<" + pinned[i].trim() + ">> holds the family, got <<" + out + ">>"); - } + void theConflictResolvesToTheStdlib() { + String s = KotlinStdlibAlignment.alignmentScript(); + assertTrue(s.contains("withCapability('org.jetbrains.kotlin:kotlin-stdlib-jdk7')") + && s.contains("withCapability('org.jetbrains.kotlin:kotlin-stdlib-jdk8')"), + "both capabilities are resolved"); + assertTrue(s.contains("candidates.find { it.id.module == 'kotlin-stdlib' }"), + "the stdlib is the candidate selected"); + assertTrue(s.contains("if (stdlib != null)"), + "and it is not selected when it is absent"); } /** - * Both halves are required, and the asymmetry is deliberate. A pinning word - * with no mention of this family cannot be pinning it; a mention with no - * pinning word is an ordinary declaration, which the constraint raises. + * The rule runs on every AndroidX build, so its worst case has to be + * "do nothing". A version it cannot parse leaves the graph exactly as it + * found it, which is the duplicate the app already had. */ @Test - void bothHalvesOfTheGuardAreRequired() { - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, - " configurations.all { resolutionStrategy.force " - + "'com.squareup.okhttp3:okhttp:4.0.0' }\n") - .contains(":1.8.0"), - "a force on someone else is not a pin on this family"); - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, - " implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'\n") - .contains(":1.8.0"), - "and naming the family without pinning it is an ordinary declaration"); - - // It over-suppresses on purpose: the words are matched as plain text, so - // one in a comment or an unrelated string counts. That costs an app the - // duplicate it already had, which android.kotlinStdlibAlignment=false - // does deliberately; the other direction breaks a build that works. - assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock("implementation", false, - " // we used to force kotlin-stdlib here\n")), - "a pinning word in a comment stands it down, which is the safe way " - + "to be wrong"); - } - - @Test - void theProjectsOwnKotlinPluginOwnsTheFamily() { - assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", true, - " implementation 'androidx.appcompat:appcompat:1.6.1'\n")), - "a project with Kotlin sources gets a Kotlin plugin and a stdlib at " - + "the compiler's own version, which is below this floor on the " - + "Gradle 6 and 7 path -- raising the shims would drag the base " - + "stdlib past the compiler"); - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, - " implementation 'androidx.appcompat:appcompat:1.6.1'\n") - .contains(":1.8.0"), - "and the Java-only graph this exists for is still aligned"); - } - - @Test - void aPinIsFoundWhateverItsCase() { - // force the command and setForcedModules the setter are the same act. - String[] spellings = { - " configurations.all { resolutionStrategy.setForcedModules(" - + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22') }\n", - " configurations.all { resolutionStrategy.FORCE " - + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22' }\n", - " implementation('org.jetbrains.kotlin:kotlin-stdlib') " - + "{ version { STRICTLY '1.7.22' } }\n", - }; - for (int i = 0; i < spellings.length; i++) { - assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", false, spellings[i])), - "<<" + spellings[i].trim() + ">> holds the family"); - } - } - - @Test - void aBoundedRequireIsAPin() { - // require '[1.7,1.8)' excludes the floor, so a constraint demanding it - // leaves no version satisfying both. The unbounded form is soft and - // would be raised happily; standing down for it too is the cheap side. - assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", false, - " implementation('org.jetbrains.kotlin:kotlin-stdlib') " - + "{ version { require '[1.7,1.8)' } }\n")), - "a bounded require holds the family below the floor"); - } - - @Test - void aBoundedRangeNeedsNoKeyword() { - // The dangerous shape: an ordinary-looking coordinate whose version is - // a range that excludes the floor. Nothing in the vocabulary appears. - String[] ranges = { - " implementation " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:[1.7,1.8)'\n", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib:(1.6,1.8]'\n", - " implementation 'org.jetbrains.kotlin:kotlin-stdlib:[1.7, )'\n", - }; - for (int i = 0; i < ranges.length; i++) { - assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", false, ranges[i])), - "<<" + ranges[i].trim() + ">> excludes the floor"); - } - - // And the case the comma test must NOT fire on, because declaring the - // family this way is ordinary and the constraint raises it. - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, - " implementation group: 'org.jetbrains.kotlin', " - + "name: 'kotlin-stdlib', version: '1.7.22'\n") - .contains(":1.8.0"), - "map notation is a declaration, not a range"); - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, - " implementation " - + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n") - .contains(":1.8.0"), - "and neither is a plain version"); - } - - @Test - void theKotlinToolchainOwnsTheFamilyWhereverItCameFrom() { - // hasKotlinSources scans src/main/java. Kotlin can arrive from a source - // set it never looks at, with the app applying the plugin itself -- and - // then nothing names the stdlib, so naming it cannot be the test. - String[] applied = { - " classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.22'\n", - "apply plugin: 'kotlin-android'\n", - " id 'org.jetbrains.kotlin.android' version '1.7.22'\n", - }; - for (int i = 0; i < applied.length; i++) { - assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", false, applied[i])), - "<<" + applied[i].trim() + ">> puts the Kotlin toolchain in " - + "this build, and it declares the stdlib itself"); - } - } - - @Test - void aSingleVersionRangeIsARange() { - // [1.7.22] admits exactly one version and contains no comma at all. - assertTrue("".equals(KotlinStdlibAlignment.constraintsBlock( - "implementation", false, - " implementation " - + "'org.jetbrains.kotlin:kotlin-stdlib-jdk8:[1.7.22]'\n")), - "a single-version range admits nothing else"); - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, - " implementation " - + "'org.jetbrains.kotlin:kotlin-stdlib:1.7.22'\n") - .contains(":1.8.0"), - "and a plain version is still raised"); - } - - /** The floor is the version at which the shims became empty. */ - @Test - void theFloorIsWhereTheClassesMoved() { - assertTrue("1.8.0".equals(KotlinStdlibAlignment.MERGED_STDLIB_FLOOR), - "1.8.0 is where kotlin-stdlib absorbed the jdk7/jdk8 classes"); + void theRuleCannotFailTheBuild() { + String s = KotlinStdlibAlignment.alignmentScript(); + int rule = s.indexOf("components.withModule"); + int guard = s.indexOf("try {"); + int caught = s.indexOf("catch (Exception ignored)"); + assertTrue(rule >= 0 && guard > rule && caught > guard, + "the version read is inside a try/catch: " + s); } /** - * Null and empty fragments are ordinary input: the builder passes whatever - * hints the project happens to have, and most projects have none of them. + * It carries its own scopes. The metadata rule has to be inside + * {@code dependencies} and the resolution strategy outside it, so the script + * opens both rather than being spliced into two places by the caller. */ @Test - void missingFragmentsAreNotAnError() { - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, - (String[]) null).contains(":1.8.0"), - "no fragments at all"); - assertTrue(KotlinStdlibAlignment.constraintsBlock("implementation", false, - null, "", null).contains(":1.8.0"), - "and a mix of null and empty ones"); + void theScriptBringsItsOwnScopes() { + String s = KotlinStdlibAlignment.alignmentScript(); + assertTrue(s.contains("dependencies {") && s.contains("configurations.all {"), + "both scopes: " + s); + assertTrue(s.contains("android.kotlinStdlibAlignment=false"), + "and it names the hint that switches it off, in the generated file " + + "where somebody debugging a build will actually see it"); } /** - * Every fragment the app controls has to reach the scan. A hint that is - * added to the generated script and not collected here is a pin this cannot - * see, which is the one way to get the dangerous answer. The same list has - * to feed both questions, too -- asking "does the app pin this" over one set - * of fragments and then aligning over another is the same defect wearing a - * different shape. + * Appended AFTER the dependencies block. Inside it, the + * {@code configurations.all} half would be a syntax error in the generated + * script -- which no unit test on the emitted string alone would catch. */ @Test - void theBuilderPassesEveryAppControlledFragment() throws Exception { + void theBuilderAppendsItAfterTheDependenciesBlock() throws Exception { String src = new String(java.nio.file.Files.readAllBytes(new java.io.File( - "src/main/java/com/codename1/builders/AndroidGradleBuilder.java") - .toPath()), "UTF-8"); - int at = src.indexOf("String[] appGradle = {"); - assertTrue(at >= 0, "the builder collects the app's Gradle fragments"); - String list = src.substring(at, src.indexOf("};", at)); - String[] hints = { - "android.gradlePlugin", "android.topDependency", - "android.gradle.androidx", - "android.xgradle_default_config", "android.supportv4Dep", - "android.gradleDep", "android.xgradle", - }; - for (String hint : hints) { - // With the quotes. One hint name is a prefix of another, so a bare - // contains() stayed true after the argument was deleted. - assertTrue(list.contains("\"" + hint + "\""), - "the alignment is not told about the " + hint - + " hint, which reaches the generated script"); - } - String[] locals = { - "kotlinRuntimeDependency", "additionalDependencies", - "aiExtraGradleDependencies", "aarDependencies", "injectRepo", - "gradleDependency", - // This builder has desugaring; the daemon twin does not. - "coreLibraryDesugaringDependency", - }; - for (String local : locals) { - assertTrue(list.contains(local), - "the alignment is not told about " + local - + ", which reaches the generated script"); - } - String uses = src.substring(src.indexOf("};", at)); - assertTrue(uses.contains("appPinsTheStdlibFamily(appGradle)"), - "the stand-down question is asked over that same list"); - // The whole argument list, not the names one at a time: hasKotlinSources - // also appears in the log branch just above, so a looser assertion stayed - // true after the argument itself was replaced with a literal. - assertTrue(uses.contains("compile, hasKotlinSources, appGradle)"), - "the alignment is asked over the caller's configuration, that same " - + "fragment list, and whether this project compiles Kotlin"); - } - /** - * The alignment is an optimisation over a build that already worked apart - * from one duplicate class, and it runs on every AndroidX build -- so its - * worst case has to be "emit nothing", never a failed build. - */ - @Test - void theAlignmentCannotFailTheBuild() { - String[] hostile = { - null, "", " ", "'", "{", "}", "(((", ")))", - "implementation '", "kotlin-stdlib", "!!", "strictly", - "kotlin-stdlib strictly", - }; - for (int i = 0; i < hostile.length; i++) { - KotlinStdlibAlignment.constraintsBlock("implementation", false, hostile[i]); - KotlinStdlibAlignment.appPinsTheStdlibFamily(hostile[i]); - } - assertTrue(true, "no input produces an exception"); + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()), "UTF-8"); + int at = src.indexOf("+ kotlinStdlibAlignment"); + assertTrue(at >= 0, "the builder appends the alignment"); + String before = src.substring(0, at); + assertTrue(before.lastIndexOf("+ \"}\\n\"") > before.lastIndexOf("\"dependencies {"), + "the dependencies block is closed before the alignment is appended"); + assertTrue(src.contains("KotlinStdlibAlignment.alignmentScript()"), + "and it is the whole script, with no arguments to get wrong"); } } + From eb99f791a9658caac19ecde49d91dbaa3d6e2ec6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:17:38 +0300 Subject: [PATCH 92/94] Record the Android measurement in the class The resolution tests proved the graph; this closes the gap I flagged. checkDebugDuplicateClasses on AGP 8.1.4 reproduces the customer's error exactly -- "Duplicate class kotlin.collections.jdk8.CollectionsJDK8Kt found in modules kotlin-stdlib-1.8.10 and kotlin-stdlib-jdk8-1.6.21" -- and passes with this script. The comparison that matters: the same Android build, with the app pinning the shim strictly, succeeds with the capability and fails with the constraint this replaced ("Could not resolve org.jetbrains.kotlin:kotlin-stdlib-jdk8:{strictly 1.6.21}"). An all-1.7 project builds untouched. --- .../builders/KotlinStdlibAlignment.java | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index ddf5d1f2783..56e273b393b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -52,14 +52,21 @@ * this class has no inputs.

* *

Measured, not reasoned. The emitted script was run against real - * Gradle 6.5 (the builder's default) and 8.5, resolving against Maven Central:

+ * Gradle 6.5 (the builder's default) and 8.5 resolving from Maven Central, and + * end to end through {@code checkDebugDuplicateClasses} in a real Android + * project on AGP 8.1.4:

* *
    - *
  • stdlib 1.8.10 with {@code kotlin-stdlib-jdk8:1.6.21} -- the duplicate, - * reproduced; the shims are dropped and it resolves.
  • + *
  • stdlib 1.8.10 with {@code kotlin-stdlib-jdk8:1.6.21} -- the customer's + * failure, reproduced exactly: "Duplicate class + * kotlin.collections.jdk8.CollectionsJDK8Kt found in modules + * kotlin-stdlib-1.8.10 and kotlin-stdlib-jdk8-1.6.21". The task fails + * without this script and passes with it.
  • *
  • the same, with the shim pinned {@code strictly}, or with - * {@code reject '[1.8.0,)'} -- resolves. The constraint version failed - * both.
  • + * {@code reject '[1.8.0,)'} -- resolves, and the Android build succeeds. + * The constraint version this replaced failed both, in the Android build + * too: "Could not resolve org.jetbrains.kotlin:kotlin-stdlib-jdk8: + * {strictly 1.6.21}". That is the whole reason for the change. *
  • an all-1.7 project -- untouched, because the capability is only * declared from the floor up, so shims that still carry real classes stay. * This is also why a Kotlin compiler older than the floor is not a From d75fe8b90992d939a1842429e8acf43ee592caea Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:54:40 +0300 Subject: [PATCH 93/94] Declare a capability of our own, so only the overlapping pair conflicts Both findings reproduced before anything was changed, and the first one was real: stdlib 1.8.0 with a NEWER kotlin-stdlib-jdk8 1.9.0 resolves to 1.9.0 throughout when untouched, and to 1.8.0 with this script. The shim at 1.9.0 is empty and duplicates nothing; its only contribution is a requirement on stdlib 1.9.0, and evicting it took that with it. A silent downgrade of the base module. The cause was reusing the shims' own implicit capability. Every version of a shim holds it, including the empty ones, so a conflict was created where no duplicate exists -- and that conflict has no right answer. Measured: selecting the stdlib downgrades the base module, and selectHighestVersion() picks the shim and evicts kotlin-stdlib altogether, leaving a graph of empty shims with no stdlib in it. removeCapability does not remove an implicit capability, which was tried and measured too -- and my own try/catch hid that from me until I made the rule throw. So the capability is ours now: kotlin-stdlib at or above the floor declares com.codenameone:kotlin-stdlib-jdkN-superseded, and a shim BELOW the floor declares the same. Exactly the two modules that overlap hold it, so the conflict exists where the duplicate exists and nowhere else. The newer shim keeps its requirement and the graph matches the untouched one. The second finding is fixed by the same change and guarded anyway: a project candidate's id is a ProjectComponentIdentifier with no module property, and reading one throws MissingPropertyException. The lookup now checks ModuleComponentIdentifier first. With a capability only we declare, a project cannot hold it in the first place. Re-measured end to end: ten resolution scenarios on Gradle 8.5, three on Gradle 6.5, and checkDebugDuplicateClasses on AGP 8.1.4 for the duplicate, the strict pin and the newer shim. Three mutations, each caught. --- .../builders/KotlinStdlibAlignment.java | 130 +++++++++++++----- .../builders/KotlinStdlibAlignmentTest.java | 51 ++++++- 2 files changed, 139 insertions(+), 42 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index 56e273b393b..e956a903a38 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -67,10 +67,15 @@ * The constraint version this replaced failed both, in the Android build * too: "Could not resolve org.jetbrains.kotlin:kotlin-stdlib-jdk8: * {strictly 1.6.21}". That is the whole reason for the change.
  • - *
  • an all-1.7 project -- untouched, because the capability is only - * declared from the floor up, so shims that still carry real classes stay. - * This is also why a Kotlin compiler older than the floor is not a - * problem: nothing raises the stdlib under it.
  • + *
  • an all-1.7 project -- untouched, because the stdlib only supersedes + * from the floor up, so shims that still carry real classes stay. This is + * also why a Kotlin compiler older than the floor is not a problem: + * nothing raises the stdlib under it.
  • + *
  • stdlib 1.8.0 with a NEWER {@code kotlin-stdlib-jdk8:1.9.0} -- resolves + * to 1.9.0 throughout, exactly as the untouched graph does. An earlier + * version of this reused the shims' own capability and evicted that shim, + * taking its requirement on stdlib 1.9.0 with it and silently downgrading + * the base module to 1.8.0.
  • *
  • stdlib 1.9.22, a graph with no Kotlin at all, and this same rule applied * twice -- all inert or clean.
  • *
  • {@code failOnVersionConflict} with an old shim fails identically with @@ -91,6 +96,28 @@ public class KotlinStdlibAlignment { "kotlin-stdlib-jdk8" }; + /** + * The group of the capability this declares, and the name suffix. + * + *

    Ours, deliberately, rather than reusing the shims' own implicit + * capability. That one is held by EVERY version of a shim, including the + * empty ones at or above the floor -- and a conflict there has no right + * answer: dropping the shim loses its requirement on a newer stdlib and + * silently downgrades the base module, while dropping the stdlib leaves a + * graph of empty shims with no stdlib in it at all. Both were measured.

    + * + *

    A capability only this declares is held by exactly two things: a + * {@code kotlin-stdlib} at or above the floor, which supersedes the shims, + * and a shim below it, which is superseded. So the conflict exists where the + * duplicate exists and nowhere else. It cannot be removed from the shims + * instead -- {@code removeCapability} does not remove an implicit one, which + * was tried and measured too.

    + */ + private static final String CAPABILITY_GROUP = "com.codenameone"; + + /** @see #CAPABILITY_GROUP */ + private static final String CAPABILITY_SUFFIX = "-superseded"; + private KotlinStdlibAlignment() { } @@ -106,54 +133,63 @@ private KotlinStdlibAlignment() { * @return the script, newline terminated */ public static String alignmentScript() { - String floorMajor = MERGED_STDLIB_FLOOR.substring( - 0, MERGED_STDLIB_FLOOR.indexOf('.')); - String rest = MERGED_STDLIB_FLOOR.substring( - MERGED_STDLIB_FLOOR.indexOf('.') + 1); - String floorMinor = rest.substring(0, rest.indexOf('.')); + String major = MERGED_STDLIB_FLOOR.substring(0, MERGED_STDLIB_FLOOR.indexOf('.')); + String rest = MERGED_STDLIB_FLOOR.substring(MERGED_STDLIB_FLOOR.indexOf('.') + 1); + String minor = rest.substring(0, rest.indexOf('.')); + String atOrAbove = "major > " + major + " || (major == " + major + + " && minor >= " + minor + ")"; + String below = "major < " + major + " || (major == " + major + + " && minor < " + minor + ")"; StringBuilder out = new StringBuilder(); out.append("\n") .append("// Codename One: kotlin-stdlib ").append(MERGED_STDLIB_FLOOR) .append(" absorbed the kotlin-stdlib-jdk7 and kotlin-stdlib-jdk8\n") - .append("// classes and the 1.8.x line ships no Gradle module metadata saying so,\n") - .append("// so a graph holding both carries the same classes twice and fails\n") - .append("// checkDuplicateClasses. Declaring the overlap as a capability lets\n") - .append("// Gradle drop the redundant shim. It raises no version, so it cannot\n") - .append("// conflict with a pin, a force, a BOM or the Kotlin compiler in use.\n") + .append("// classes and the 1.8.x line ships no Gradle module metadata saying so, so\n") + .append("// a graph holding stdlib at or above that and an older shim carries the same\n") + .append("// classes twice and fails checkDuplicateClasses. This states the overlap as a\n") + .append("// capability and lets Gradle drop the superseded shim. It raises no version,\n") + .append("// so it cannot conflict with a pin, a force, a BOM or the Kotlin in use.\n") .append("// Turn it off with the build hint android.kotlinStdlibAlignment=false.\n") .append("dependencies {\n") .append(" components.withModule('org.jetbrains.kotlin:kotlin-stdlib') { details ->\n") - .append(" try {\n") - .append(" def parts = details.id.version.split('[.-]')\n") - .append(" def major = parts[0].toInteger()\n") - .append(" def minor = parts[1].toInteger()\n") - .append(" if (major > ").append(floorMajor) - .append(" || (major == ").append(floorMajor) - .append(" && minor >= ").append(floorMinor).append(")) {\n") - .append(" allVariants {\n") - .append(" withCapabilities {\n"); + .append(versionGuard(" ", atOrAbove)) + .append(" allVariants {\n") + .append(" withCapabilities {\n"); for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { - out.append(" addCapability('org.jetbrains.kotlin', '") - .append(ALIGNED_ARTIFACTS[i]) + out.append(" addCapability('").append(CAPABILITY_GROUP) + .append("', '").append(ALIGNED_ARTIFACTS[i]).append(CAPABILITY_SUFFIX) .append("', details.id.version)\n"); } - out.append(" }\n") - .append(" }\n") + out.append(" }\n") .append(" }\n") - .append(" } catch (Exception ignored) {\n") - .append(" // A version this cannot read is left alone. Doing nothing leaves\n") - .append(" // the duplicate the app already had; guessing could drop a shim\n") - .append(" // whose classes are still the only copy.\n") - .append(" }\n") - .append(" }\n") - .append("}\n") + .append(versionGuardEnd(" ")) + .append(" }\n"); + for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { + out.append(" components.withModule('org.jetbrains.kotlin:") + .append(ALIGNED_ARTIFACTS[i]).append("') { details ->\n") + .append(versionGuard(" ", below)) + .append(" allVariants {\n") + .append(" withCapabilities {\n") + .append(" addCapability('").append(CAPABILITY_GROUP) + .append("', '").append(ALIGNED_ARTIFACTS[i]).append(CAPABILITY_SUFFIX) + .append("', details.id.version)\n") + .append(" }\n") + .append(" }\n") + .append(versionGuardEnd(" ")) + .append(" }\n"); + } + out.append("}\n") .append("configurations.all {\n") .append(" resolutionStrategy.capabilitiesResolution {\n"); for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { - out.append(" withCapability('org.jetbrains.kotlin:") - .append(ALIGNED_ARTIFACTS[i]).append("') {\n") - .append(" def stdlib = candidates.find { it.id.module == 'kotlin-stdlib' }\n") + out.append(" withCapability('").append(CAPABILITY_GROUP).append(':') + .append(ALIGNED_ARTIFACTS[i]).append(CAPABILITY_SUFFIX).append("') {\n") + .append(" def stdlib = candidates.find {\n") + .append(" it.id instanceof org.gradle.api.artifacts.component" + + ".ModuleComponentIdentifier &&\n") + .append(" it.id.module == 'kotlin-stdlib'\n") + .append(" }\n") .append(" if (stdlib != null) {\n") .append(" select(stdlib)\n") .append(" }\n") @@ -163,4 +199,24 @@ public static String alignmentScript() { .append("}\n"); return out.toString(); } + + /** Opens a try block that reads the module version and tests {@code test}. */ + private static String versionGuard(String indent, String test) { + return indent + "try {\n" + + indent + " def parts = details.id.version.split('[.-]')\n" + + indent + " def major = parts[0].toInteger()\n" + + indent + " def minor = parts[1].toInteger()\n" + + indent + " if (" + test + ") {\n"; + } + + /** + * Closes it. A version this cannot read is left alone -- doing nothing + * leaves the duplicate the app already had, and guessing could drop a shim + * whose classes are still the only copy. + */ + private static String versionGuardEnd(String indent) { + return indent + " }\n" + + indent + "} catch (Exception ignored) {\n" + + indent + "}\n"; + } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index c7369c026be..be315465115 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -43,9 +43,9 @@ void theScriptStatesTheOverlapAsACapability() { String s = KotlinStdlibAlignment.alignmentScript(); assertTrue(s.contains("components.withModule('org.jetbrains.kotlin:kotlin-stdlib')"), "the rule is on kotlin-stdlib, which is what gained the classes"); - assertTrue(s.contains("addCapability('org.jetbrains.kotlin', 'kotlin-stdlib-jdk7'"), + assertTrue(s.contains("addCapability('com.codenameone', 'kotlin-stdlib-jdk7-superseded'"), "jdk7"); - assertTrue(s.contains("addCapability('org.jetbrains.kotlin', 'kotlin-stdlib-jdk8'"), + assertTrue(s.contains("addCapability('com.codenameone', 'kotlin-stdlib-jdk8-superseded'"), "jdk8"); } @@ -98,13 +98,54 @@ void theCapabilityStartsWhereTheClassesMoved() { @Test void theConflictResolvesToTheStdlib() { String s = KotlinStdlibAlignment.alignmentScript(); - assertTrue(s.contains("withCapability('org.jetbrains.kotlin:kotlin-stdlib-jdk7')") - && s.contains("withCapability('org.jetbrains.kotlin:kotlin-stdlib-jdk8')"), + assertTrue(s.contains("withCapability('com.codenameone:kotlin-stdlib-jdk7-superseded')") + && s.contains("withCapability('com.codenameone:kotlin-stdlib-jdk8-superseded')"), "both capabilities are resolved"); - assertTrue(s.contains("candidates.find { it.id.module == 'kotlin-stdlib' }"), + assertTrue(s.contains("def stdlib = candidates.find {") + && s.contains("it.id.module == 'kotlin-stdlib'"), "the stdlib is the candidate selected"); assertTrue(s.contains("if (stdlib != null)"), "and it is not selected when it is absent"); + assertTrue(s.contains("it.id instanceof org.gradle.api.artifacts.component" + + ".ModuleComponentIdentifier"), + "a project candidate has no module property; reading one throws " + + "MissingPropertyException and Gradle reports " + + "'Capability resolution rule failed'"); + } + + @Test + void theShimOnlyClaimsToBeSupersededBelowTheFloor() { + // The half that keeps a NEWER shim alive. A shim at or above the floor + // is empty and duplicates nothing, and its only contribution is a + // requirement on a stdlib at its own version. Making it conflict evicts + // it and that requirement with it, silently downgrading the base module + // -- measured: stdlib 1.8.0 with kotlin-stdlib-jdk8 1.9.0 resolves to + // 1.9.0 untouched, and to 1.8.0 when the shim is made to conflict. + String s = KotlinStdlibAlignment.alignmentScript(); + int shimRule = s.indexOf( + "components.withModule('org.jetbrains.kotlin:kotlin-stdlib-jdk8')"); + assertTrue(shimRule >= 0, "the shim carries a rule of its own: " + s); + String rule = s.substring(shimRule); + rule = rule.substring(0, rule.indexOf("\n }")); + assertTrue(rule.contains("minor < 8"), + "and it claims to be superseded only BELOW the floor: " + rule); + assertTrue(!rule.contains("minor >= 8"), + "never at or above it: " + rule); + } + + @Test + void theCapabilityIsOursAndNotTheShimsOwn() { + // Reusing the shims' implicit capability makes every version of a shim + // conflict with the stdlib, including the empty ones, and that conflict + // has no right answer: dropping the shim downgrades the base module, + // dropping the stdlib leaves empty shims and no stdlib at all. Both were + // measured. A capability only this declares is held by exactly the two + // modules that actually overlap. + String s = KotlinStdlibAlignment.alignmentScript(); + assertTrue(!s.contains("addCapability('org.jetbrains.kotlin'"), + "the shims' own capability is never reused: " + s); + assertTrue(s.contains("'com.codenameone'"), + "the capability is ours"); } /** From 258947b93cced05039718b6112e84570628b6ff0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:00:08 +0300 Subject: [PATCH 94/94] The duplicate is not an AndroidX problem, so stop gating on AndroidX Reproduced before changing it, because the comment on that gate says to. With android.useAndroidX=false explicitly set, AGP 8.1.4, kotlin-stdlib 1.8.10 beside kotlin-stdlib-jdk8 1.6.21: checkDebugDuplicateClasses fails exactly as it does with AndroidX on, and passes with this script. The gate was written on the reasoning that a non-AndroidX graph cannot reach a merged kotlin-stdlib, and that reasoning is simply wrong -- the duplicate has nothing to do with AndroidX. Those builds were left broken. The Gradle 6 floor stays, since capabilitiesResolution is the mechanism and AGP 3.x on Gradle 4.6 is a different world. A test reads the gate and fails if useAndroidX comes back. Pushed back on one finding, in a comment beside the emission since the thread is not read: selecting a stdlib reachable only THROUGH the shims was said to expand until the Gradle daemon exhausts its heap. kotlin-stdlib-jdk8:1.7.0 as the only route, plus a force to 1.8.0, resolves in seconds under a 512MB heap on Gradle 8.5 and on 8.14.2, and it is a graph this FIXES -- the baseline there carries the duplicate. --- .../builders/AndroidGradleBuilder.java | 18 ++++++++++++++++-- .../builders/KotlinStdlibAlignment.java | 6 ++++++ .../builders/KotlinStdlibAlignmentTest.java | 19 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 0b0937d3162..ee48e157d34 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7284,7 +7284,9 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // block on the legacy `compile` configuration. Reviewed as an unrelated flag // to gate on -- it is not, and the failing case it is meant to protect needs // a modern AndroidX dependency in a project that has AndroidX turned off, - // which AGP refuses for its own reasons before this could matter. + // which AGP refuses for its own reasons before this could matter. That + // whole line of reasoning turned out not to matter either: see the + // useAndroidX note on the gate below. // // On Gradle 6 rather than on 4.6 where the constraints // DSL first appeared. That is deliberate, and it has been questioned in @@ -7302,8 +7304,20 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { // version down, because the alignment RAISED one and could then break a // build that resolved. It declares a capability now, which raises // nothing, so there is nothing to search for -- see KotlinStdlibAlignment. + // + // Not gated on useAndroidX any more. It was, on the reasoning above that + // a non-AndroidX graph cannot reach a merged kotlin-stdlib -- and that + // reasoning is wrong, because the duplicate has nothing to do with + // AndroidX. Reproduced with android.useAndroidX=false explicitly set, + // AGP 8.1.4, kotlin-stdlib 1.8.10 beside kotlin-stdlib-jdk8 1.6.21: + // checkDebugDuplicateClasses fails exactly as it does with AndroidX on, + // and passes with this script. The old gate left those builds broken. + // + // The Gradle 6 floor stays, and for a reason that did survive + // measurement: capabilitiesResolution is the mechanism here, and AGP 3.x + // on Gradle 4.6 is a different world. Turning it off is the hint. String kotlinStdlibAlignment = ""; - if (useAndroidX && gradleVersionInt >= 6 + if (gradleVersionInt >= 6 && request.getArg("android.kotlinStdlibAlignment", "true").equals("true")) { kotlinStdlibAlignment = KotlinStdlibAlignment.alignmentScript(); } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java index e956a903a38..41c9dd24c13 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -181,6 +181,12 @@ public static String alignmentScript() { } out.append("}\n") .append("configurations.all {\n") + // Review asked whether selecting a stdlib that is reachable only + // THROUGH the shims makes resolution expand until the daemon runs out + // of heap. It does not: kotlin-stdlib-jdk8:1.7.0 as the only route to + // the stdlib, plus a force to 1.8.0, resolves in seconds under a + // 512MB heap on both Gradle 8.5 and 8.14.2 -- and it is a graph this + // FIXES, since the baseline there carries the duplicate. .append(" resolutionStrategy.capabilitiesResolution {\n"); for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { out.append(" withCapability('").append(CAPABILITY_GROUP).append(':') diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java index be315465115..03fca49528d 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -178,6 +178,25 @@ void theScriptBringsItsOwnScopes() { + "where somebody debugging a build will actually see it"); } + @Test + void theGateIsNotAboutAndroidX() throws Exception { + // The duplicate has nothing to do with AndroidX. Reproduced with + // android.useAndroidX=false explicitly set on AGP 8.1.4: + // checkDebugDuplicateClasses fails there exactly as it does with + // AndroidX on, so gating on it left those builds broken. + String src = new String(java.nio.file.Files.readAllBytes(new java.io.File( + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()), "UTF-8"); + int at = src.indexOf("String kotlinStdlibAlignment = \"\";"); + assertTrue(at >= 0, "the builder has the alignment gate"); + String gate = src.substring(at, src.indexOf("}", src.indexOf("if (", at))); + assertTrue(!gate.contains("useAndroidX"), + "the gate does not turn on AndroidX: " + gate); + assertTrue(gate.contains("gradleVersionInt >= 6"), + "it does keep the Gradle floor, which capabilitiesResolution needs"); + assertTrue(gate.contains("android.kotlinStdlibAlignment"), + "and the opt-out hint"); + } + /** * Appended AFTER the dependencies block. Inside it, the * {@code configurations.all} half would be a syntax error in the generated