diff --git a/common/docs/e2e.md b/common/docs/e2e.md index 4ff6839d0..784ce2fad 100644 --- a/common/docs/e2e.md +++ b/common/docs/e2e.md @@ -29,6 +29,8 @@ Common utility tests cover argument escaping, argument-file conversion, version constants, classpath/JAR resource scanning, resource configuration serialization, and schema validation. This protects [§FS-common-libraries.1](functional-spec.md#1-shared-native-image-utilities), [§FS-common-libraries.2](functional-spec.md#2-resource-configuration), and [§FS-common-libraries.7](functional-spec.md#7-schema-validation). +They also verify deterministic `-H:Preserve` rendering for resolved paths, including paths with +spaces and platform-specific separators. [§FS-common-libraries.1](functional-spec.md#1-shared-native-image-utilities). ### 2.2 Tracing-agent modes and metadata post-processing diff --git a/common/docs/functional-spec.md b/common/docs/functional-spec.md index dff4e104c..a2aa5c294 100644 --- a/common/docs/functional-spec.md +++ b/common/docs/functional-spec.md @@ -32,16 +32,18 @@ regardless of its vendor label and retaining compatibility behavior when that re identified; and centralize Native Image configuration file names and metadata directory names used by plugins and tests. -Layer creation uses an immutable, build-tool-neutral artifact selection containing an `all` -selection, ordered module names, ordered package names, and ordered resolved paths. The shared -renderer owns the complete `-H:LayerCreate` grammar: `all` renders as an unqualified layer-create -argument whose contents come from the plugin-supplied classpath, while narrower selections render -`module=`, `package=`, and `path=` selectors. It rejects blank names and selector values, rejects -names outside `[A-Za-z0-9._-]+`, and preserves selector order. `all` may be combined with module or -package selectors; the plugin-supplied classpath still represents the complete dependency graph. -Native Image version detection recognizes vendor suffixes when applying layer-consumption gates. -Gradle and Maven must -resolve their dependency models to paths before calling it. +Native Image classpath selection uses an immutable, build-tool-neutral artifact value containing +an `all` selection, ordered module names, ordered package names, and ordered resolved paths. The +shared layer renderer owns the complete `-H:LayerCreate` grammar: `all` renders as an unqualified +layer-create argument whose contents come from the plugin-supplied classpath, while narrower +selections render `module=`, `package=`, and `path=` selectors. The shared Preserve renderer owns +the equivalent `-H:Preserve` selector grammar and rejects an empty selection. Both renderers +preserve deterministic module, package, then path order and platform path spelling. The selection +rejects blank selector values; layer rendering additionally rejects blank names and names outside +`[A-Za-z0-9._-]+`. `all` may be combined with narrower selectors. Native Image version detection +recognizes vendor suffixes when applying layer-consumption gates. Gradle and Maven must resolve +their dependency models to paths before calling either renderer. This shared Preserve behavior +implements [§root/FS-native-builds.7](../../docs/spec/functional/native-image-builds.md#7-dependency-preservation). [§REQ-no-buildtool-apis](requirements.md#req-no-buildtool-apis-common-runtime-libraries-do-not-depend-on-gradle-or-maven-apis). ## 2. Resource configuration diff --git a/common/utils/src/main/java/org/graalvm/buildtools/model/resources/NativeImageFlags.java b/common/utils/src/main/java/org/graalvm/buildtools/model/resources/NativeImageFlags.java index 40f8c1c9a..dfbb3cb82 100644 --- a/common/utils/src/main/java/org/graalvm/buildtools/model/resources/NativeImageFlags.java +++ b/common/utils/src/main/java/org/graalvm/buildtools/model/resources/NativeImageFlags.java @@ -50,6 +50,8 @@ public abstract class NativeImageFlags { public static final String LAYER_USE = "-H:LayerUse"; public static final String NO_FALLBACK = "--no-fallback"; public static final String PGO_INSTRUMENT = "--pgo-instrument"; + // Dependency Preserve selection is rendered once in common for both adapters. §FS-common-libraries.1. + public static final String PRESERVE = "-H:Preserve"; public static final String QUICK_BUILD = "-Ob"; public static final String SHARED = "--shared"; public static final String UNLOCK_EXPERIMENTAL_VMOPTIONS = "-H:+UnlockExperimentalVMOptions"; diff --git a/common/utils/src/main/java/org/graalvm/buildtools/utils/ArtifactSelection.java b/common/utils/src/main/java/org/graalvm/buildtools/utils/ArtifactSelection.java index f400a9a5d..a5a67b4e4 100644 --- a/common/utils/src/main/java/org/graalvm/buildtools/utils/ArtifactSelection.java +++ b/common/utils/src/main/java/org/graalvm/buildtools/utils/ArtifactSelection.java @@ -45,7 +45,7 @@ import java.util.Objects; /** - * Immutable build-tool-neutral selection of artifacts for a Native Image layer. + * Immutable build-tool-neutral selection of Native Image classpath artifacts. * §FS-common-libraries.1. */ public final class ArtifactSelection { @@ -60,7 +60,7 @@ public ArtifactSelection(boolean all, List modules, List package this.packages = validatedStrings("package", packages); this.paths = List.copyOf(Objects.requireNonNull(paths, "paths")); if (this.paths.stream().anyMatch(Objects::isNull)) { - throw new IllegalArgumentException("Layer paths must not contain null values"); + throw new IllegalArgumentException("Artifact paths must not contain null values"); } } @@ -71,7 +71,7 @@ public static ArtifactSelection empty() { private static List validatedStrings(String kind, List values) { List copy = List.copyOf(Objects.requireNonNull(values, kind + "s")); if (copy.stream().anyMatch(value -> value == null || value.isBlank())) { - throw new IllegalArgumentException("Layer " + kind + " selectors must not be blank"); + throw new IllegalArgumentException("Artifact " + kind + " selectors must not be blank"); } return copy; } diff --git a/common/utils/src/main/java/org/graalvm/buildtools/utils/NativeImagePreserveArguments.java b/common/utils/src/main/java/org/graalvm/buildtools/utils/NativeImagePreserveArguments.java new file mode 100644 index 000000000..adf76f9d3 --- /dev/null +++ b/common/utils/src/main/java/org/graalvm/buildtools/utils/NativeImagePreserveArguments.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.utils; + +import org.graalvm.buildtools.model.resources.NativeImageFlags; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Shared rendering of Native Image Preserve arguments. §FS-common-libraries.1. + */ +public final class NativeImagePreserveArguments { + private NativeImagePreserveArguments() { + } + + public static String renderPreserve(ArtifactSelection selection) { + Objects.requireNonNull(selection, "selection"); + if (selection.isEmpty()) { + throw new IllegalArgumentException("Preserve selection must not be empty"); + } + if (selection.isAll() && selection.getModules().isEmpty() + && selection.getPackages().isEmpty() && selection.getPaths().isEmpty()) { + return NativeImageFlags.PRESERVE + "=all"; + } + List selectors = new ArrayList<>(); + if (selection.isAll()) { + selectors.add("all"); + } + selection.getModules().forEach(module -> selectors.add("module=" + module)); + selection.getPackages().forEach(packageName -> selectors.add("package=" + packageName)); + selection.getPaths().forEach(path -> selectors.add("path=" + path)); + return NativeImageFlags.PRESERVE + "=" + String.join(",", selectors); + } +} diff --git a/common/utils/src/test/java/org/graalvm/buildtools/utils/NativeImagePreserveArgumentsTest.java b/common/utils/src/test/java/org/graalvm/buildtools/utils/NativeImagePreserveArgumentsTest.java new file mode 100644 index 000000000..a80fe6d13 --- /dev/null +++ b/common/utils/src/test/java/org/graalvm/buildtools/utils/NativeImagePreserveArgumentsTest.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.utils; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the shared Preserve selector grammar and validation. §FS-common-libraries.1. §FS-common-libraries.8. + */ +class NativeImagePreserveArgumentsTest { + @TempDir + Path testDirectory; + + @Test + void rendersAllAndMixedSelectorsDeterministically() { + assertEquals("-H:Preserve=all", NativeImagePreserveArguments.renderPreserve( + new ArtifactSelection(true, List.of(), List.of(), List.of()))); + + ArtifactSelection selection = new ArtifactSelection(true, + List.of("java.base", "java.logging"), + List.of("com.example", "org.example.*"), + List.of(Path.of("libs", "one.jar"), Path.of("libs", "two with spaces.jar"))); + + assertEquals("-H:Preserve=all,module=java.base,module=java.logging," + + "package=com.example,package=org.example.*,path=libs/one.jar,path=libs/two with spaces.jar", + NativeImagePreserveArguments.renderPreserve(selection).replace('\\', '/')); + } + + @Test + void rendersPathOnlyPlatformSelections() { + String argument = NativeImagePreserveArguments.renderPreserve( + new ArtifactSelection(false, List.of(), List.of(), List.of(Path.of("build", "dependency.jar")))); + + assertTrue(argument.endsWith("path=" + Path.of("build", "dependency.jar"))); + } + + @Test + void rejectsEmptySelections() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> NativeImagePreserveArguments.renderPreserve(ArtifactSelection.empty())); + + assertEquals("Preserve selection must not be empty", error.getMessage()); + } + + @Test + void preservesOneSelectorExpressionThroughAnArgumentFile() throws Exception { + String argument = NativeImagePreserveArguments.renderPreserve(new ArtifactSelection(false, + List.of(), List.of(), List.of(testDirectory.resolve("dependency with spaces.jar")))); + + List converted = NativeImageUtils.convertToArgsFile(List.of(argument), testDirectory, null); + Path argsFile = Path.of(converted.get(0).substring(1)); + + assertEquals(List.of(NativeImageUtils.escapeArg(argument)), Files.readAllLines(argsFile)); + } +} diff --git a/docs/spec/decisions/README.md b/docs/spec/decisions/README.md index e69de29bb..fe158629f 100644 --- a/docs/spec/decisions/README.md +++ b/docs/spec/decisions/README.md @@ -0,0 +1,3 @@ +# Decisions + +- [§DEC-layer-model](layer-model.md#dec-layer-model-native-image-artifact-selection-is-shared-while-build-tool-wiring-stays-native) — Native Image artifact selection is shared while build-tool wiring stays native diff --git a/docs/spec/decisions/layer-model.md b/docs/spec/decisions/layer-model.md index 70abd0e1a..d793851de 100644 --- a/docs/spec/decisions/layer-model.md +++ b/docs/spec/decisions/layer-model.md @@ -1,17 +1,20 @@ -# DEC-layer-model: Native Image layers use shared selection and build-tool-native wiring +# DEC-layer-model: Native Image artifact selection is shared while build-tool wiring stays native -Native Image layer selection is represented once in `common`, while Gradle and Maven own their -native task and artifact graphs. This implements [§FS-native-builds.6](../functional/native-image-builds.md#6-layered-images), +Native Image artifact selection is represented once in `common`, while Gradle and Maven own their +native task and artifact graphs. Layers and dependency preservation consume the same neutral value. +This implements [§FS-native-builds.6](../functional/native-image-builds.md#6-layered-images), +[§FS-native-builds.7](../functional/native-image-builds.md#7-dependency-preservation), [§common/FS-common-libraries.1](../../../common/docs/functional-spec.md#1-shared-native-image-utilities), [§gradle/FS-plugin-model.2](../../../native-gradle-plugin/docs/functional/plugin-model.md#2-extension-surface), and -[§maven/FS-goal-surface.6](../../../native-maven-plugin/docs/functional/goal-surface.md#6-layer-creation). +[§maven/FS-config-model.8](../../../native-maven-plugin/docs/functional/configuration-model.md#8-preserve-dependency-selection). ## 1. Decision Gradle exposes named layers outside the binary container and binaries consume typed layer objects. Maven exposes a `layer-create` goal that attaches a `nil` artifact and compile goals resolve declared `nil` dependencies. Both adapters resolve dependency models to paths before using the -shared immutable artifact selection and renderer. +shared immutable artifact selection and renderer. Binary Preserve configuration also resolves +dependency coordinates to paths before using that selection, without adding raw selector mirrors. ## 2. Compatibility @@ -22,9 +25,11 @@ opt-in and does not alter existing builds. ## 3. Boundary -The shared model does not contain Gradle, Maven, Aether, task, project, configuration, or artifact -types. The plugins do not duplicate dependency resolution: they use Gradle artifact views and -Maven's project/repository model respectively. +The shared model is a Native Image selector value, not a layer-owned task model, and may be consumed +by layer creation, Preserve rendering, and future build-tool-neutral selector grammars. It does not +contain Gradle, Maven, Aether, task, project, configuration, or artifact types. Layer task and +artifact wiring remains owned by the layer adapters. The plugins do not duplicate dependency +resolution: they use Gradle artifact views and Maven's project/repository model respectively. [§NGOAL-no-buildtool-duplicates](../non-goals.md#ngoal-no-buildtool-duplicates-the-plugins-do-not-reimplement-capabilities-that-gradle-or-maven-already-provide). ## 4. GraalVM release support diff --git a/docs/spec/functional/native-image-builds.md b/docs/spec/functional/native-image-builds.md index ddf8096d8..61080878b 100644 --- a/docs/spec/functional/native-image-builds.md +++ b/docs/spec/functional/native-image-builds.md @@ -89,3 +89,25 @@ permitted but unsupported and must warn that it proceeds at the user's own risk; alone does not warn. Native Image layers remain experimental upstream ([§DEC-layer-model.4](../decisions/layer-model.md#4-graalvm-release-support)). [§GOAL-plugin-parity](../goals.md#goal-plugin-parity-shared-native-image-behavior-remains-consistent-across-gradle-and-maven). + +## 7. Dependency preservation + +Both plugins must let their approved native-image scopes preserve selected Gradle or Maven +dependencies by resolving build-tool dependency coordinates, optionally with their transitive +closure, to concrete classpath paths and rendering one `-H:Preserve=path=...` argument through +common utilities. The selection is opt-in, preserves transitive dependencies by default, and must +fail before Native Image starts when coordinates are blank, unresolved, ambiguous, empty, or have +no usable output. The generated Preserve argument precedes user build arguments so explicit +pass-through arguments retain their normal precedence. + +The first-class build-tool surface is limited to dependency selection because coordinate-to-path +resolution adds behavior unavailable through static arguments. Users must continue to pass +`all`, `module=`, `package=`, and explicit `path=` Preserve selectors through normal build arguments, +protecting [§NGOAL-no-flag-mirroring](../non-goals.md#ngoal-no-flag-mirroring-the-plugins-do-not-add-build-tool-flags-that-only-forward-to-native-image-flags). +Gradle exposes the selection on every binary option object; Maven exposes it on the +`compile-no-fork` hierarchy (`compile`, `compile-no-fork`, the deprecated `build` alias, and +`write-args-file`), not native-test or layer-create goals. Preserve is available with GraalVM 25 and later and does not require the +experimental-option unlock sequence. Specific dependency selectors are preferred because +preservation can increase analysis work and image size. +[§GOAL-plugin-parity](../goals.md#goal-plugin-parity-shared-native-image-behavior-remains-consistent-across-gradle-and-maven), +[§REQ-backwards-compatibility.2](../requirements.md#2-configuration-compatibility). diff --git a/docs/spec/functional/plugin-common.md b/docs/spec/functional/plugin-common.md index b77eec4c2..31446ae9c 100644 --- a/docs/spec/functional/plugin-common.md +++ b/docs/spec/functional/plugin-common.md @@ -21,6 +21,7 @@ and actionable under [§GOAL-concise-actionable-output](../goals.md#goal-concise | Inspect missing metadata | [§gradle/FS-resources-and-metadata](../../../native-gradle-plugin/docs/functional/resources-and-metadata.md#fs-resources-and-metadata-gradle-tasks-generate-resources-and-consume-reachability-metadata) | [§maven/FS-resources-and-metadata](../../../native-maven-plugin/docs/functional/resources-and-metadata.md#fs-resources-and-metadata-maven-goals-generate-resources-and-consume-reachability-metadata) | [§FS-resources-and-metadata.3](resources-and-metadata.md#3-missing-metadata-reports) | | Collect agent output | [§gradle/FS-tracing-agent](../../../native-gradle-plugin/docs/functional/tracing-agent.md#fs-tracing-agent-gradle-tasks-attach-and-post-process-native-image-tracing-agent-metadata) | [§maven/FS-tracing-agent](../../../native-maven-plugin/docs/functional/tracing-agent.md#fs-tracing-agent-maven-goals-attach-and-post-process-native-image-tracing-agent-metadata) | [§FS-tracing-agent](tracing-agent.md#fs-tracing-agent-both-plugins-attach-the-native-image-tracing-agent-and-post-process-its-output) | | Create and consume layers | [§gradle/FS-plugin-model.2](../../../native-gradle-plugin/docs/functional/plugin-model.md#2-extension-surface) | [§maven/FS-goal-surface.6](../../../native-maven-plugin/docs/functional/goal-surface.md#6-layer-creation) | [§FS-native-builds.6](native-image-builds.md#6-layered-images) | +| Preserve selected dependencies | [§gradle/FS-plugin-model.2](../../../native-gradle-plugin/docs/functional/plugin-model.md#2-extension-surface) | [§maven/FS-config-model.8](../../../native-maven-plugin/docs/functional/configuration-model.md#8-preserve-dependency-selection) | [§FS-native-builds.7](native-image-builds.md#7-dependency-preservation) | ```mermaid sequenceDiagram @@ -58,6 +59,7 @@ capability impossible or intentionally different: - Native Image version-dependent behavior ([§FS-native-builds.4](native-image-builds.md#4-version-and-schema-gates)) - predictable option precedence ([§FS-option-precedence](option-precedence.md#fs-option-precedence-command-line-input-and-durable-configuration-produce-one-option-state)) - named layer creation and consumption through build-tool-native dependency wiring ([§FS-native-builds.6](native-image-builds.md#6-layered-images)) +- dependency-coordinate preservation through build-tool-native resolution ([§FS-native-builds.7](native-image-builds.md#7-dependency-preservation)) When a capability is intentionally different between Gradle and Maven, the product-specific specs must explain the difference at the point where each plugin adapts this common contract. diff --git a/docs/src/docs/asciidoc/changelog.adoc b/docs/src/docs/asciidoc/changelog.adoc index 65fe264e1..8f3e8d38e 100644 --- a/docs/src/docs/asciidoc/changelog.adoc +++ b/docs/src/docs/asciidoc/changelog.adoc @@ -3,6 +3,8 @@ == Next release +- Added dependency-coordinate configuration for GraalVM 25's `-H:Preserve` option to + Gradle binary builds and the Maven `compile-no-fork` hierarchy. - Added first-class named Native Image layers to the Gradle plugin and Maven `layer-create` / `useLayers` support with resolvable `nil` artifacts and platform runtime bundles. Layer consumption is supported on GraalVM 25.1+; 25.0.x consumption warns and remains unsupported. diff --git a/docs/src/docs/asciidoc/gradle-plugin.adoc b/docs/src/docs/asciidoc/gradle-plugin.adoc index 328802fc7..6a7109b06 100644 --- a/docs/src/docs/asciidoc/gradle-plugin.adoc +++ b/docs/src/docs/asciidoc/gradle-plugin.adoc @@ -185,6 +185,49 @@ buildArgs.addAll( ) ---- +[[preserve-dependencies]] +=== Preserving dependency code + +GraalVM 25 and later can preserve code from selected dependencies even when static analysis cannot +see a call path to it. Configure dependency coordinates on each binary that needs the code: + +[source,groovy,role="multi-language-sample"] +---- +graalvmNative { + binaries.main { + preserve { + dependencies('org.example:extension-api:1.0.0') + dependencies('org.example:extension-runtime:1.0.0') { + transitive = false + } + } + } +} +---- + +[source,kotlin,role="multi-language-sample"] +---- +graalvmNative { + binaries.named("main") { + preserve { + dependencies("org.example:extension-api:1.0.0") + dependencies("org.example:extension-runtime:1.0.0") { + transitive = false + } + } + } +} +---- + +Dependency preservation includes transitive dependencies by default. Set `transitive = false` to +preserve only the selected artifact. The DSL accepts ordinary dependency notation and version- +catalog providers. It is available on main, test, custom executable, and shared-library binaries. +The plugin resolves the selected artifacts lazily and emits one path-based `-H:Preserve` option. + +The Preserve option requires GraalVM 25 or later and does not require experimental-option +unlocking. Preserving more code can increase build time and image size. Native Image's raw `all`, +`module`, `package`, and explicit `path` selector forms remain available through `buildArgs`. + [[layered-images]] === Layered Native Images diff --git a/docs/src/docs/asciidoc/maven-plugin.adoc b/docs/src/docs/asciidoc/maven-plugin.adoc index 8381448f4..b4abfccbd 100644 --- a/docs/src/docs/asciidoc/maven-plugin.adoc +++ b/docs/src/docs/asciidoc/maven-plugin.adoc @@ -197,6 +197,33 @@ dependencies, add: path/to/dir ---- +``:: + On GraalVM 25 and later, preserves code from selected dependencies even when static analysis + cannot see a call path to it. Dependency preservation applies to `compile`, `compile-no-fork`, + the deprecated `build` goal, and `write-args-file`: +[source,xml, role="multi-language-sample"] +---- + + + + org.example:extension-api:1.0.0 + + + org.example:extension-runtime:1.0.0 + false + + + +---- ++ +Transitive dependencies are preserved by default. Set `false` to preserve +only the selected artifact. The plugin resolves project dependencies in application compile scopes +and emits one path-based `-H:Preserve` option. The option requires GraalVM 25 or later and does not +require experimental-option unlocking. Preserving more code can increase build time and image size. ++ +This configuration does not apply to `test`, `integration-test`, or `layer-create`. Native Image's +raw `all`, `module`, `package`, and explicit `path` selector forms remain available through +``. ``:: Configuration of the <>. See <> and <> for details. diff --git a/native-gradle-plugin/docs/architecture.md b/native-gradle-plugin/docs/architecture.md index ef4e43001..a28d65e6d 100644 --- a/native-gradle-plugin/docs/architecture.md +++ b/native-gradle-plugin/docs/architecture.md @@ -34,7 +34,7 @@ fork behavior from the underlying `NativeImageOptions` object. Named layers are separate domain objects with dedicated producer tasks; binaries consume their provider-backed outputs. The compatibility boundary and cross-tool artifact model follow -[§root/DEC-layer-model](../../docs/spec/decisions/layer-model.md#dec-layer-model-native-image-layers-use-shared-selection-and-build-tool-native-wiring). +[§root/DEC-layer-model](../../docs/spec/decisions/layer-model.md#dec-layer-model-native-image-artifact-selection-is-shared-while-build-tool-wiring-stays-native). ## 3. Task graph architecture diff --git a/native-gradle-plugin/docs/e2e.md b/native-gradle-plugin/docs/e2e.md index 3c5797ccd..a127f428b 100644 --- a/native-gradle-plugin/docs/e2e.md +++ b/native-gradle-plugin/docs/e2e.md @@ -77,6 +77,10 @@ protects [§FS-native-tasks.4](functional/native-image-tasks.md#4-command-line-o Its layer scenarios cover module, package, explicit-path, and `all` selectors; executable, native-test (including additional test suites), and shared-library consumers; and provider duplicate validation before a producer task starts. +`PreserveDependencyFunctionalTest` verifies that dependency-coordinate configuration selects one +specific transitive library non-transitively and preserves a class which an equivalent control image cannot load. +It also covers lazy resolution and configuration-cache reuse. [§FS-plugin-model.2](functional/plugin-model.md#2-extension-surface), +[§FS-native-invocation.3](functional/native-image-invocation.md#3-command-line-construction). Layer-consumption scenarios that exercise the `all` selector, custom shared-library consumers, application distributions, or native tests are skipped on GraalVM 25.0.x because Native Image can fail after `-H:LayerUse` loads a valid layer. diff --git a/native-gradle-plugin/docs/functional/native-image-invocation.md b/native-gradle-plugin/docs/functional/native-image-invocation.md index 45510d658..fe1a8985a 100644 --- a/native-gradle-plugin/docs/functional/native-image-invocation.md +++ b/native-gradle-plugin/docs/functional/native-image-invocation.md @@ -35,6 +35,12 @@ omit the plugin-generated argument when GraalVM 25.1 or later is positively iden GraalVM release cannot be identified, the invocation must retain the argument for compatibility. Explicit user build arguments remain unchanged. +When a binary configures Preserve dependencies, the command line must append one shared-rendered +path-only Preserve argument before user build arguments without adding an experimental-option +unlock sequence. Resolved dependency files remain lazy task inputs. The invocation must contain no +generated Preserve argument when the block is absent. Empty selections fail before process +execution. [§root/FS-native-builds.7](../../../docs/spec/functional/native-image-builds.md#7-dependency-preservation). + For a layer created from declared JARs, the command line must use those JARs as its classpath so the layer input remains limited to the declaration. A layer created from packages must instead retain the binary classpath, which supplies the classes selected by those package names. diff --git a/native-gradle-plugin/docs/functional/native-image-tasks.md b/native-gradle-plugin/docs/functional/native-image-tasks.md index 2106ba688..62796ee98 100644 --- a/native-gradle-plugin/docs/functional/native-image-tasks.md +++ b/native-gradle-plugin/docs/functional/native-image-tasks.md @@ -9,7 +9,7 @@ outputs. `nativeCompile` builds the `main` binary. It consumes the binary classpath, main class or shared-library setting, build arguments, configuration directories, generated resources, reachability metadata, optional classpath JAR, argument-file setting, layer and PGO options, -environment variables, system properties, and JVM arguments. +dependency Preserve selection, environment variables, system properties, and JVM arguments. `nativeTestCompile` builds the native test binary described by [§root/FS-native-tests](../../../docs/spec/functional/native-tests.md#fs-native-tests-both-plugins-compile-and-execute-junit-tests-as-a-native-image). It uses compiled test classes, test resources, the test runtime classpath, JUnit native support, diff --git a/native-gradle-plugin/docs/functional/plugin-model.md b/native-gradle-plugin/docs/functional/plugin-model.md index a93c6ed01..ce0b7b642 100644 --- a/native-gradle-plugin/docs/functional/plugin-model.md +++ b/native-gradle-plugin/docs/functional/plugin-model.md @@ -76,6 +76,16 @@ only through normalized names, output-file providers, and file collections. `fromConfiguration(...)` and `all` include resolved external and project dependencies. Dependency selectors also accept Gradle providers, including version-catalog accessors. +Every binary option object exposes a nested `preserve` block containing dependency selectors only. +String coordinates and version-catalog providers resolve lazily through detached Gradle +configurations; transitive resolution is enabled by default and may be disabled per selector. The +resolved files are task inputs and become path-only shared artifact selections. A configured but +empty block, blank notation, or failed resolution fails with a Preserve-specific diagnostic before +Native Image starts. Main, test, custom, and shared-library binaries use the same binary-scoped +model, while binaries without `preserve` remain unchanged. Raw `all`, module, package, and explicit +path Preserve forms remain build arguments under +[§root/FS-native-builds.7](../../../docs/spec/functional/native-image-builds.md#7-dependency-preservation). + The previous binary-scoped `createLayer`, string-based `useLayer`, and `externalDependenciesOf(...)` surfaces remain as deprecated compatibility adapters with their original naming and external-module-only semantics. Only legacy layer declarations retain the @@ -122,7 +132,8 @@ application { The plugin must also create a `test` binary connected to the default `test` task and `test` source set so `nativeTest` can build and run native JUnit tests without a separate binary declaration. Test and shared-library binaries use the same layer-consumption model as executable binaries; -layer selection is explicit on each binary and is not inherited from `binaries.main`. +layer and Preserve selection are explicit on each binary and are not inherited from +`binaries.main`. ## 4. Custom binaries diff --git a/native-gradle-plugin/src/functionalTest/groovy/org/graalvm/buildtools/gradle/PreserveDependencyFunctionalTest.groovy b/native-gradle-plugin/src/functionalTest/groovy/org/graalvm/buildtools/gradle/PreserveDependencyFunctionalTest.groovy new file mode 100644 index 000000000..83dcbf777 --- /dev/null +++ b/native-gradle-plugin/src/functionalTest/groovy/org/graalvm/buildtools/gradle/PreserveDependencyFunctionalTest.groovy @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.gradle + +import org.graalvm.buildtools.gradle.fixtures.AbstractFunctionalTest +import org.graalvm.buildtools.gradle.fixtures.GraalVMSupport +import org.graalvm.buildtools.utils.NativeImageUtils +import spock.lang.Requires + +// Proves dependency-coordinate Preserve behavior against an equivalent control image. §E2E-functional-tests.3.6. +class PreserveDependencyFunctionalTest extends AbstractFunctionalTest { + private static final String TRANSITIVE_CLASS = 'org.apache.hc.core5.http.HttpEntity' + + def "does not resolve preserve dependencies for an unrelated task"() { + given: + withSample('java-application') + buildFile << ''' + graalvmNative.binaries.main.preserve { + dependencies('invalid:unresolvable:1.0') + } + '''.stripIndent() + + when: + runAndReloadConfigurationCache 'help' + + then: + tasks { + succeeded ':help' + } + } + + @Requires({ NativeImageUtils.getMajorJDKVersion(GraalVMSupport.getGraalVMHomeVersionString()) >= 25 }) + def "preserves a class from a transitive dependency"() { + given: + withSample('java-application') + buildFile << ''' + dependencies { + implementation 'org.apache.httpcomponents.client5:httpclient5:5.4.1' + } + + graalvmNative { + binaries { + create('control') { + imageName = 'control' + mainClass = 'org.graalvm.demo.Application' + } + main { + preserve { + dependencies('org.apache.httpcomponents.core5:httpcore5:5.3.1') { + transitive = false + } + } + } + } + } + '''.stripIndent() + file('src/main/java/org/graalvm/demo/Application.java').text = ''' + package org.graalvm.demo; + + public class Application { + public static void main(String[] args) throws Exception { + System.out.println(Class.forName(args[0]).getName()); + } + } + '''.stripIndent() + + when: + run 'nativeControlCompile', 'nativeCompile' + + then: + tasks { + succeeded ':nativeControlCompile', ':nativeCompile' + } + outputContains "'-H:Preserve' (origin(s): command line)" + + when: + def control = executeWithArgument( + getExecutableFile('build/native/nativeControlCompile/control'), TRANSITIVE_CLASS) + def preserved = executeWithArgument( + getExecutableFile('build/native/nativeCompile/java-application'), TRANSITIVE_CLASS) + + then: + control.exitCode != 0 + preserved.exitCode == 0 + preserved.output.contains(TRANSITIVE_CLASS) + } + + private static Map executeWithArgument(File executable, String argument) { + def process = [executable.absolutePath, argument].execute(null, executable.parentFile) + def output = new StringWriter() + def error = new StringWriter() + process.waitForProcessOutput(output, error) + [exitCode: process.exitValue(), output: output.toString(), error: error.toString()] + } +} diff --git a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/NativeImagePlugin.java b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/NativeImagePlugin.java index 693757b9d..edaf4da02 100644 --- a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/NativeImagePlugin.java +++ b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/NativeImagePlugin.java @@ -581,6 +581,7 @@ private void configureLayerTasks(Project project, project.getObjects(), project.getProviders(), project.getExtensions().findByType(JavaToolchainService.class), + project, layerRegistry, "lib" + layer.getName() ); @@ -933,6 +934,7 @@ private GraalVMExtension registerGraalVMExtension(Project project) { project.getObjects(), project.getProviders(), project.getExtensions().findByType(JavaToolchainService.class), + project, layerRegistry, project.getName()) ); diff --git a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/dsl/NativeImageCompileOptions.java b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/dsl/NativeImageCompileOptions.java index 1bdc380e3..481c22181 100644 --- a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/dsl/NativeImageCompileOptions.java +++ b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/dsl/NativeImageCompileOptions.java @@ -251,6 +251,17 @@ public interface NativeImageCompileOptions { @Nested DomainObjectSet getLayers(); + /** + * Optional dependency-only Preserve selection for this binary. §FS-plugin-model.2. + * + * @return configured Preserve dependency selection + */ + @Nested + @Optional + Property getPreserve(); + + void preserve(Action spec); + /** * Internal normalized layer-create model used by dedicated layer tasks and the legacy adapter. * §FS-plugin-model.2. diff --git a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/dsl/PreserveConfiguration.java b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/dsl/PreserveConfiguration.java new file mode 100644 index 000000000..66a5ab5d9 --- /dev/null +++ b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/dsl/PreserveConfiguration.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.gradle.dsl; + +import org.gradle.api.Action; +import org.gradle.api.InvalidUserDataException; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.Dependency; +import org.gradle.api.artifacts.MinimalExternalModuleDependency; +import org.gradle.api.artifacts.ModuleDependency; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.FileCollection; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Nested; + +import javax.inject.Inject; +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Lazy dependency-only Preserve configuration for one Gradle binary. §FS-plugin-model.2. + */ +public abstract class PreserveConfiguration { + private final transient Project project; + private final ConfigurableFileCollection files; + + @Classpath + public FileCollection getFiles() { + return files; + } + + @Nested + public abstract ListProperty getDependencies(); + + @Inject + public PreserveConfiguration(Project project) { + this.project = project; + this.files = project.files(); + getDependencies().convention(List.of()); + } + + public void dependencies(String notation) { + addDependency(notation, true); + } + + public void dependencies(String notation, Action action) { + PreserveDependencySpec spec = new PreserveDependencySpec(); + action.execute(spec); + addDependency(notation, spec.isTransitive()); + } + + public void dependencies(Provider dependency) { + dependencies(dependency, spec -> { + }); + } + + public void dependencies(Provider dependency, + Action action) { + PreserveDependencySpec spec = new PreserveDependencySpec(); + action.execute(spec); + Provider notation = dependency.map(value -> + value.getModule().toString() + ":" + value.getVersionConstraint().getRequiredVersion()); + getDependencies().add(notation.map(value -> new PreserveDependency(value, spec.isTransitive()))); + files.from(notation.flatMap(value -> resolvedArtifactsOf(detachedConfiguration(value, spec.isTransitive())))); + } + + private void addDependency(String notation, boolean transitive) { + PreserveDependency selection = new PreserveDependency(notation, transitive); + getDependencies().add(selection); + files.from(resolvedArtifactsOf(detachedConfiguration(notation, transitive))); + } + + private Configuration detachedConfiguration(String notation, boolean transitive) { + Dependency dependency; + try { + dependency = project.getDependencies().create(notation); + } catch (RuntimeException ex) { + throw new InvalidUserDataException("Invalid Preserve dependency notation '" + notation + "'", ex); + } + if (dependency instanceof ModuleDependency) { + ((ModuleDependency) dependency).setTransitive(transitive); + } + return project.getConfigurations().detachedConfiguration(dependency); + } + + private Provider> resolvedArtifactsOf(Configuration configuration) { + return configuration.getIncoming() + .artifactView(view -> view.setLenient(false)) + .getArtifacts() + .getResolvedArtifacts() + .map(artifacts -> { + List files = new ArrayList<>(); + // Avoid streams because Gradle artifact results have provider-sensitive iteration semantics. §FS-plugin-model.2. + for (var artifact : artifacts) { + files.add(artifact.getFile()); + } + return files; + }); + } +} diff --git a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/dsl/PreserveDependency.java b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/dsl/PreserveDependency.java new file mode 100644 index 000000000..ffff42708 --- /dev/null +++ b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/dsl/PreserveDependency.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.gradle.dsl; + +import org.gradle.api.tasks.Input; + +import java.io.Serializable; + +/** + * Gradle dependency selector used by binary Preserve configuration. §FS-plugin-model.2. + */ +public final class PreserveDependency implements Serializable { + private final String notation; + private final boolean transitive; + + public PreserveDependency(String notation, boolean transitive) { + if (notation == null || notation.isBlank()) { + throw new IllegalArgumentException("Preserve dependency notation must not be blank"); + } + this.notation = notation; + this.transitive = transitive; + } + + @Input + public String getNotation() { + return notation; + } + + @Input + public boolean isTransitive() { + return transitive; + } +} diff --git a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/dsl/PreserveDependencySpec.java b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/dsl/PreserveDependencySpec.java new file mode 100644 index 000000000..4a35a9726 --- /dev/null +++ b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/dsl/PreserveDependencySpec.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.gradle.dsl; + +/** + * Mutable DSL adapter for one Preserve dependency selector. §FS-plugin-model.2. + */ +public final class PreserveDependencySpec { + private boolean transitive = true; + + public boolean isTransitive() { + return transitive; + } + + public void setTransitive(boolean transitive) { + this.transitive = transitive; + } +} diff --git a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/internal/BaseNativeImageOptions.java b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/internal/BaseNativeImageOptions.java index a51462d82..7fd59bfa1 100644 --- a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/internal/BaseNativeImageOptions.java +++ b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/internal/BaseNativeImageOptions.java @@ -44,6 +44,7 @@ import org.graalvm.buildtools.gradle.dsl.NativeImageOptions; import org.graalvm.buildtools.gradle.dsl.NativeImageLayer; import org.graalvm.buildtools.gradle.dsl.NativeResourcesOptions; +import org.graalvm.buildtools.gradle.dsl.PreserveConfiguration; import org.graalvm.buildtools.gradle.dsl.agent.DeprecatedAgentOptions; import org.graalvm.buildtools.gradle.tasks.BuildNativeImageTask; import org.graalvm.buildtools.gradle.tasks.CreateLayerOptions; @@ -53,6 +54,7 @@ import org.graalvm.buildtools.utils.NativeImageLayerArguments; import org.gradle.api.Action; import org.gradle.api.DomainObjectSet; +import org.gradle.api.Project; import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.ProjectLayout; @@ -99,6 +101,7 @@ public abstract class BaseNativeImageOptions implements NativeImageOptions { private final transient TaskContainer tasks; private final ObjectFactory objects; private final ProviderFactory providers; + private final transient Project project; private final transient NativeImageLayerRegistry layerRegistry; @Override @@ -266,6 +269,7 @@ public BaseNativeImageOptions(String name, ProviderFactory providers, JavaToolchainService toolchains, TaskContainer tasks, + Project project, NativeImageLayerRegistry layerRegistry, String defaultImageName) { this.name = name; @@ -294,6 +298,7 @@ public BaseNativeImageOptions(String name, this.tasks = tasks; this.objects = objectFactory; this.providers = providers; + this.project = project; this.layerRegistry = layerRegistry; } @@ -440,6 +445,17 @@ public void layers(Action> spec) { spec.execute(layers); } + @Override + public void preserve(Action spec) { + // Preserve is created only when configured so binaries that omit it keep no resolution inputs. §FS-plugin-model.2. + PreserveConfiguration preserve = getPreserve().getOrNull(); + if (preserve == null) { + preserve = objects.newInstance(PreserveConfiguration.class, project); + } + spec.execute(preserve); + getPreserve().set(preserve); + } + @Override public void useLayer(String name) { // Keep the deprecated string adapter operational while directing callers to typed layer objects. §FS-plugin-model.2. diff --git a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/internal/DelegatingCompileOptions.java b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/internal/DelegatingCompileOptions.java index 1c8b27e30..5e67cf962 100644 --- a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/internal/DelegatingCompileOptions.java +++ b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/internal/DelegatingCompileOptions.java @@ -43,6 +43,7 @@ import org.graalvm.buildtools.gradle.dsl.NativeImageCompileOptions; import org.graalvm.buildtools.gradle.dsl.NativeImageLayer; import org.graalvm.buildtools.gradle.dsl.NativeResourcesOptions; +import org.graalvm.buildtools.gradle.dsl.PreserveConfiguration; import org.graalvm.buildtools.gradle.dsl.agent.DeprecatedAgentOptions; import org.graalvm.buildtools.gradle.tasks.CreateLayerOptions; import org.graalvm.buildtools.gradle.tasks.LayerOptions; @@ -183,6 +184,17 @@ public DomainObjectSet getLayers() { return options.getLayers(); } + @Override + public Property getPreserve() { + // Forward Preserve inputs to every concrete binary task. §FS-native-invocation.3. + return options.getPreserve(); + } + + @Override + public void preserve(Action spec) { + options.preserve(spec); + } + @Override public Property getLayerCreate() { // Forward layer-create inputs without changing their invocation semantics. §FS-native-invocation.3. diff --git a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/internal/NativeImageCommandLineProvider.java b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/internal/NativeImageCommandLineProvider.java index e408e3c6e..cb33f4785 100644 --- a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/internal/NativeImageCommandLineProvider.java +++ b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/internal/NativeImageCommandLineProvider.java @@ -42,10 +42,12 @@ package org.graalvm.buildtools.gradle.internal; import org.graalvm.buildtools.gradle.dsl.NativeImageOptions; +import org.graalvm.buildtools.gradle.dsl.PreserveConfiguration; import org.graalvm.buildtools.gradle.tasks.CreateLayerOptions; import org.graalvm.buildtools.model.resources.NativeImageFlags; import org.graalvm.buildtools.utils.ArtifactSelection; import org.graalvm.buildtools.utils.NativeImageLayerArguments; +import org.graalvm.buildtools.utils.NativeImagePreserveArguments; import org.graalvm.buildtools.utils.NativeImageUtils; import org.gradle.api.Transformer; import org.gradle.api.file.ConfigurableFileCollection; @@ -132,11 +134,14 @@ public List asArguments() { NativeImageOptions options = getOptions().get(); List cliArgs = new ArrayList<>(20); boolean hasLayers = options.getLayerCreate().isPresent() || !options.getLayerFiles().isEmpty(); + boolean hasPreserve = options.getPreserve().isPresent(); String layerCreateName = null; ConfigurableFileCollection jarsClasspath = null; ConfigurableFileCollection layerClasspath = null; if (hasLayers) { cliArgs.add(NativeImageFlags.UNLOCK_EXPERIMENTAL_VMOPTIONS); + } + if (hasLayers) { if (options.getLayerCreate().isPresent()) { CreateLayerOptions create = options.getLayerCreate().get(); layerCreateName = create.getLayerName().get(); @@ -217,6 +222,13 @@ public List asArguments() { cliArgs.add("--pgo=" + profile); } } + if (hasPreserve) { + PreserveConfiguration preserve = options.getPreserve().get(); + ArtifactSelection selection = new ArtifactSelection(false, List.of(), List.of(), + preserve.getFiles().getFiles().stream().map(File::toPath).toList()); + // Dependency coordinates become one path-only Preserve argument before user build args. §FS-native-invocation.3. + cliArgs.add(NativeImagePreserveArguments.renderPreserve(selection)); + } cliArgs.addAll(options.getBuildArgs().get()); List actualCliArgs; diff --git a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/tasks/BuildNativeImageTask.java b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/tasks/BuildNativeImageTask.java index b5659f0ad..9a1c4033c 100644 --- a/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/tasks/BuildNativeImageTask.java +++ b/native-gradle-plugin/src/main/java/org/graalvm/buildtools/gradle/tasks/BuildNativeImageTask.java @@ -307,6 +307,7 @@ private List buildActualCommandLineArgs(int majorJDKVersion, boolean fal public void exec() { NativeImageOptions options = getOptions().get(); validateLayerConfiguration(options); + validatePreserveConfiguration(options); GraalVMLogger logger = GraalVMLogger.of(getLogger()); File executablePath = NativeImageExecutableLocator.findNativeImageExecutable( @@ -384,6 +385,23 @@ static void validateLayerConfiguration(NativeImageOptions options) { } } + static void validatePreserveConfiguration(NativeImageOptions options) { + if (!options.getPreserve().isPresent()) { + return; + } + boolean empty; + try { + empty = options.getPreserve().get().getFiles().isEmpty(); + } catch (RuntimeException ex) { + // Dependency failures must identify Preserve before Native Image lookup or execution. §FS-native-invocation.3. + throw new GradleException("Could not resolve Preserve dependencies: " + ex.getMessage(), ex); + } + if (empty) { + throw new GradleException( + "Preserve has no resolved dependencies; configure at least one dependency coordinate."); + } + } + public static String getVersionString(ExecOperations execOperations, File executablePath) { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ExecResult execResult = execOperations.exec(spec -> { diff --git a/native-gradle-plugin/src/test/groovy/org/graalvm/buildtools/gradle/tasks/NativeImageCommandLineProviderTest.groovy b/native-gradle-plugin/src/test/groovy/org/graalvm/buildtools/gradle/tasks/NativeImageCommandLineProviderTest.groovy index d52275218..fefa6cc41 100644 --- a/native-gradle-plugin/src/test/groovy/org/graalvm/buildtools/gradle/tasks/NativeImageCommandLineProviderTest.groovy +++ b/native-gradle-plugin/src/test/groovy/org/graalvm/buildtools/gradle/tasks/NativeImageCommandLineProviderTest.groovy @@ -305,6 +305,75 @@ class NativeImageCommandLineProviderTest extends AbstractPluginTest { args.contains("${NativeImageFlags.LAYER_USE}=${layerFile.absolutePath}".toString()) } + // Dependency Preserve paths are shared-rendered before user build arguments. §FS-native-invocation.3. + @Issue("https://github.com/graalvm/native-build-tools/issues/978") + def "renders one path-only Preserve argument for a configured binary"() { + given: + def project = newProject() + project.plugins.apply(ApplicationPlugin) + project.plugins.apply(NativeImagePlugin) + def repositoryDirectory = testDirectory.resolve("repository with spaces").toFile() + repositoryDirectory.mkdirs() + def directJar = new File(repositoryDirectory, "direct-dependency.jar") + def transitiveJar = new File(repositoryDirectory, "transitive-dependency.jar") + directJar.text = "direct" + transitiveJar.text = "transitive" + project.repositories.flatDir { repository -> + repository.dirs(repositoryDirectory) + } + def options = project.extensions.getByType(GraalVMExtension).binaries.getByName("main") + options.preserve { + it.dependencies("test:direct:dependency") + it.dependencies("test:transitive:dependency") + } + options.buildArgs.add("--user-option") + options.excludeConfigArgs.set([]) + options.configurationFileDirectories.setFrom([]) + + when: + def args = commandLineArguments(project, options, "main") + def preserve = args.find { it.startsWith(NativeImageFlags.PRESERVE + "=") } + + then: + !args.contains(NativeImageFlags.UNLOCK_EXPERIMENTAL_VMOPTIONS) + preserve.contains("path=${directJar}") + preserve.contains("path=${transitiveJar}") + args.indexOf(preserve) < args.indexOf("--user-option") + } + + def "omits Preserve arguments when the binary does not configure Preserve"() { + given: + def project = newProject() + project.plugins.apply(ApplicationPlugin) + project.plugins.apply(NativeImagePlugin) + def options = project.extensions.getByType(GraalVMExtension).binaries.getByName("main") + options.excludeConfigArgs.set([]) + options.configurationFileDirectories.setFrom([]) + + when: + def args = commandLineArguments(project, options, "main") + + then: + !args.any { it.startsWith(NativeImageFlags.PRESERVE + "=") } + !args.contains(NativeImageFlags.UNLOCK_EXPERIMENTAL_VMOPTIONS) + } + + def "rejects a configured empty Preserve selection before execution"() { + given: + def project = newProject() + project.plugins.apply(ApplicationPlugin) + project.plugins.apply(NativeImagePlugin) + def options = project.extensions.getByType(GraalVMExtension).binaries.getByName("main") + options.preserve { } + + when: + BuildNativeImageTask.validatePreserveConfiguration(options) + + then: + def error = thrown(org.gradle.api.GradleException) + error.message.contains("Preserve has no resolved dependencies") + } + // Retains the deprecated binary-scoped layer producer and string consumer as a working adapter. §FS-native-invocation.3. def "legacy layer adapters wire producer and consumer and render layer arguments"() { given: diff --git a/native-gradle-plugin/src/test/groovy/org/graalvm/buildtools/gradle/tasks/PreserveConfigurationTest.groovy b/native-gradle-plugin/src/test/groovy/org/graalvm/buildtools/gradle/tasks/PreserveConfigurationTest.groovy new file mode 100644 index 000000000..d5a893757 --- /dev/null +++ b/native-gradle-plugin/src/test/groovy/org/graalvm/buildtools/gradle/tasks/PreserveConfigurationTest.groovy @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.gradle.tasks + +import org.graalvm.buildtools.gradle.NativeImagePlugin +import org.graalvm.buildtools.gradle.dsl.GraalVMExtension +import org.graalvm.buildtools.gradle.dsl.PreserveDependency +import org.gradle.api.artifacts.MinimalExternalModuleDependency +import org.gradle.api.artifacts.ModuleIdentifier +import org.gradle.api.artifacts.VersionConstraint +import org.gradle.api.plugins.ApplicationPlugin +import org.gradle.api.InvalidUserDataException +import spock.lang.Issue + +// Verifies binary-scoped Preserve DSL wiring and provider-safe dependency notation. §FS-plugin-model.2. +class PreserveConfigurationTest extends AbstractPluginTest { + @Issue("https://github.com/graalvm/native-build-tools/issues/978") + def "dependency selection is transitive by default and configurable without eager resolution"() { + given: + def project = newProject() + project.plugins.apply(ApplicationPlugin) + project.plugins.apply(NativeImagePlugin) + def options = project.extensions.getByType(GraalVMExtension).binaries.getByName("main") + + when: + options.preserve { + it.dependencies("com.acme:extension:1.0") + it.dependencies("com.acme:standalone:1.0") { dependency -> + dependency.transitive = false + } + } + + then: + options.preserve.present + options.preserve.get().dependencies.get()*.notation == [ + "com.acme:extension:1.0", "com.acme:standalone:1.0" + ] + options.preserve.get().dependencies.get()*.transitive == [true, false] + } + + def "accepts provider-backed version catalog dependencies"() { + given: + def project = newProject() + project.plugins.apply(ApplicationPlugin) + project.plugins.apply(NativeImagePlugin) + def module = Stub(ModuleIdentifier) { + toString() >> "com.acme:catalog-extension" + } + def version = Stub(VersionConstraint) { + getRequiredVersion() >> "2.0" + } + def dependency = Stub(MinimalExternalModuleDependency) { + getModule() >> module + getVersionConstraint() >> version + } + def options = project.extensions.getByType(GraalVMExtension).binaries.getByName("main") + + when: + options.preserve { + it.dependencies(project.providers.provider { dependency }) { selection -> + selection.transitive = false + } + } + + then: + options.preserve.get().dependencies.get()[0].notation == "com.acme:catalog-extension:2.0" + !options.preserve.get().dependencies.get()[0].transitive + } + + def "rejects blank dependency notation with a Preserve-specific diagnostic"() { + when: + new PreserveDependency(" ", true) + + then: + def error = thrown(IllegalArgumentException) + error.message == "Preserve dependency notation must not be blank" + } + + def "reports malformed and unresolved dependencies as Preserve configuration errors"() { + given: + def project = newProject() + project.plugins.apply(ApplicationPlugin) + project.plugins.apply(NativeImagePlugin) + def options = project.extensions.getByType(GraalVMExtension).binaries.getByName("main") + + when: + options.preserve { it.dependencies("malformed") } + + then: + def malformed = thrown(InvalidUserDataException) + malformed.message.contains("Invalid Preserve dependency notation 'malformed'") + + when: + options.preserve { it.dependencies("com.acme:missing:1.0") } + BuildNativeImageTask.validatePreserveConfiguration(options) + + then: + def unresolved = thrown(org.gradle.api.GradleException) + unresolved.message.contains("Could not resolve Preserve dependencies") + } + + def "exposes Preserve on main test custom and shared-library binaries"() { + given: + def project = newProject() + project.plugins.apply(ApplicationPlugin) + project.plugins.apply(NativeImagePlugin) + def binaries = project.extensions.getByType(GraalVMExtension).binaries + def custom = binaries.create("worker") + def shared = binaries.create("nativeLibrary") + shared.sharedLibrary.set(true) + + when: + [binaries.getByName("main"), binaries.getByName("test"), custom, shared].eachWithIndex { binary, index -> + binary.preserve { it.dependencies("com.acme:dependency:${index + 1}") } + } + + then: + [binaries.getByName("main"), binaries.getByName("test"), custom, shared].every { + it.preserve.present && it.preserve.get().dependencies.get().size() == 1 + } + } +} diff --git a/native-maven-plugin/docs/e2e.md b/native-maven-plugin/docs/e2e.md index 6ea035c38..0d751a23c 100644 --- a/native-maven-plugin/docs/e2e.md +++ b/native-maven-plugin/docs/e2e.md @@ -102,6 +102,14 @@ Layer-consumption scenarios that exercise `all` selection, explicit paths, nativ libraries are skipped on GraalVM 25.0.x because Native Image can fail after `-H:LayerUse` loads a valid layer. +### 3.9 Dependency preservation + +`PreserveDependencyFunctionalTest` verifies that an application compile goal selects one specific +transitive library non-transitively, emits one path-based Preserve option, and retains a class +which an equivalent control image cannot load. This protects [§FS-native-builds.3](functional/native-image-builds.md#3-classpath-and-scopes), +[§FS-config-model.8](functional/configuration-model.md#8-preserve-dependency-selection), and +[§FS-goal-surface.1](functional/goal-surface.md#1-build-goals). + When adding behavior that a user can observe through a Maven goal, plugin parameter, generated file, lifecycle binding, or Native Image invocation, add or update a functional test in the closest scenario family. diff --git a/native-maven-plugin/docs/functional/configuration-model.md b/native-maven-plugin/docs/functional/configuration-model.md index 86e12c56b..29cfab4fa 100644 --- a/native-maven-plugin/docs/functional/configuration-model.md +++ b/native-maven-plugin/docs/functional/configuration-model.md @@ -72,3 +72,19 @@ before Native Image is invoked. Loading the plugin with `true...`. +Each entry selects `groupId:artifactId[:version]` and includes its resolved transitive dependency +trail by default; `false` limits it to the matched root. Selection uses the +resolved Maven project graph, preserves stable first-seen path order, and accepts reactor class +outputs when a packaged artifact file is not yet available. Blank, malformed, missing, ambiguous, +empty, and fileless selections fail as normal Maven execution errors before Native Image starts. + +The parameter belongs to the `compile-no-fork` mojo hierarchy, so it applies to `compile`, +`compile-no-fork`, the deprecated `build` alias, and `write-args-file`. It is not exposed by +`native:test`, `native:integration-test`, or `layer-create`. The first-class XML surface contains +dependencies only; raw `all`, module, package, and explicit-path Preserve selectors remain `` under +[§root/FS-native-builds.7](../../../docs/spec/functional/native-image-builds.md#7-dependency-preservation). +[§REQ-maven-model](../requirements.md#req-maven-model-the-maven-plugin-preserves-maven-model-compatibility). diff --git a/native-maven-plugin/docs/functional/goal-surface.md b/native-maven-plugin/docs/functional/goal-surface.md index 27bde6f52..828742d2e 100644 --- a/native-maven-plugin/docs/functional/goal-surface.md +++ b/native-maven-plugin/docs/functional/goal-surface.md @@ -24,6 +24,10 @@ mvn -Pnative -DquickBuild -DskipTests package The deprecated `native:build` goal may remain as a compatibility alias, but it must warn users and point to `native:compile-no-fork`, protecting [§REQ-goal-surface](../requirements.md#req-goal-surface-maven-goal-and-parameter-names-remain-stable-across-compatible-releases). +All three application build entry points and `native:write-args-file` share dependency Preserve +configuration and invocation behavior from [§FS-config-model.8](configuration-model.md#8-preserve-dependency-selection). +Native test, integration-test, and layer-create goals do not expose that parameter. + ## 2. Test goal `native:test` compiles the Maven test classpath into a native test image and executes that image diff --git a/native-maven-plugin/docs/functional/native-image-builds.md b/native-maven-plugin/docs/functional/native-image-builds.md index a6b50c367..aa129a412 100644 --- a/native-maven-plugin/docs/functional/native-image-builds.md +++ b/native-maven-plugin/docs/functional/native-image-builds.md @@ -44,6 +44,13 @@ Every consuming Maven execution must retain the classpath entries used by produc consumers therefore declare the same dependencies; local explicit paths are build-local inputs and cannot be represented by a published `nil` artifact alone. +Application compile goals may preserve selected project dependencies. Each configured coordinate +resolves against the compile/runtime dependency graph, includes its transitive trail by default, +and contributes one de-duplicated path-only selection. The shared Preserve renderer emits one +`-H:Preserve` argument before user build arguments without adding an experimental-option unlock +sequence. Native-test and layer-create goals do not inherit this application compile parameter. +[§root/FS-native-builds.7](../../../docs/spec/functional/native-image-builds.md#7-dependency-preservation). + ### Layer support matrix | Selector or consumer | Maven XML support | Executable evidence | diff --git a/native-maven-plugin/src/functionalTest/groovy/org/graalvm/buildtools/maven/PreserveDependencyFunctionalTest.groovy b/native-maven-plugin/src/functionalTest/groovy/org/graalvm/buildtools/maven/PreserveDependencyFunctionalTest.groovy new file mode 100644 index 000000000..96b82b8e5 --- /dev/null +++ b/native-maven-plugin/src/functionalTest/groovy/org/graalvm/buildtools/maven/PreserveDependencyFunctionalTest.groovy @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.maven + +import org.graalvm.buildtools.utils.NativeImageUtils +import spock.lang.Requires + +import static org.graalvm.buildtools.utils.SharedConstants.EXECUTABLE_EXTENSION + +// Proves application-goal Preserve behavior against an equivalent control image. §E2E-functional-tests.3.9. +class PreserveDependencyFunctionalTest extends AbstractGraalVMMavenFunctionalTest { + private static final String TRANSITIVE_CLASS = 'org.apache.hc.core5.http.HttpEntity' + + @Requires({ NativeImageUtils.getMajorJDKVersion(GraalVMSupport.getGraalVMHomeVersionString()) >= 25 }) + def "preserves a class from a transitive dependency"() { + given: + withSample('java-application') + def pom = file('pom.xml') + pom.text = pom.text.replaceFirst(/\s*/, ''' + + org.apache.httpcomponents.client5 + httpclient5 + 5.4.1 + + ''') + file('src/main/java/org/graalvm/demo/Application.java').text = ''' + package org.graalvm.demo; + + public class Application { + public static void main(String[] args) throws Exception { + System.out.println(Class.forName(args[0]).getName()); + } + } + '''.stripIndent() + + when: + mvn '-Pnative', '-DquickBuild', '-DskipTests', '-DimageName=control', 'package' + + then: + buildSucceeded + def control = executeWithArgument(nativeExecutable('control'), TRANSITIVE_CLASS) + control.exitCode != 0 + + when: + pom.text = pom.text.replace(''' false''', ''' false + + + + org.apache.httpcomponents.core5:httpcore5:5.3.1 + false + + + ''') + mvn '-Pnative', '-DquickBuild', '-DskipTests', '-DimageName=preserved', 'package' + + then: + buildSucceeded + outputContains '-H:Preserve=path=' + def preserved = executeWithArgument(nativeExecutable('preserved'), TRANSITIVE_CLASS) + preserved.exitCode == 0 + preserved.output.contains(TRANSITIVE_CLASS) + } + + private static Map executeWithArgument(File executable, String argument) { + def process = [executable.absolutePath, argument].execute(null, executable.parentFile) + def output = new StringWriter() + def error = new StringWriter() + process.waitForProcessOutput(output, error) + [exitCode: process.exitValue(), output: output.toString(), error: error.toString()] + } + + private File nativeExecutable(String name) { + file("target/${name}${EXECUTABLE_EXTENSION}") + } +} diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/AbstractNativeImageMojo.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/AbstractNativeImageMojo.java index aaefa0748..f0a1aed53 100644 --- a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/AbstractNativeImageMojo.java +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/AbstractNativeImageMojo.java @@ -56,12 +56,16 @@ import org.eclipse.aether.resolution.ArtifactRequest; import org.eclipse.aether.resolution.ArtifactResolutionException; import org.graalvm.buildtools.maven.config.ExcludeConfigConfiguration; +import org.graalvm.buildtools.maven.config.PreserveConfiguration; +import org.graalvm.buildtools.maven.config.PreserveDependencyConfiguration; import org.graalvm.buildtools.maven.config.UseLayerConfiguration; import org.graalvm.buildtools.model.resources.NativeImageFlags; +import org.graalvm.buildtools.utils.ArtifactSelection; import org.graalvm.buildtools.utils.NativeImageConfigurationUtils; import org.graalvm.buildtools.utils.NativeImageUtils; import org.graalvm.buildtools.utils.NativeImageLayerArguments; import org.graalvm.buildtools.utils.NativeImageLayerRuntime; +import org.graalvm.buildtools.utils.NativeImagePreserveArguments; import org.graalvm.buildtools.utils.SchemaValidationUtils; import org.graalvm.buildtools.utils.SharedConstants; import org.graalvm.reachability.internal.FileSystemRepository; @@ -301,10 +305,17 @@ protected List getBuildArgs() throws MojoExecutionException { } List layerUseArgs = resolveLayerUseArguments(); + String preserveArgument = resolvePreserveArgument(); if (!layerUseArgs.isEmpty()) { cliArgs.add(NativeImageFlags.UNLOCK_EXPERIMENTAL_VMOPTIONS); + } + if (!layerUseArgs.isEmpty()) { cliArgs.addAll(layerUseArgs); } + if (preserveArgument != null) { + // Maven dependency coordinates become one path-only Preserve argument before user build args. §FS-config-model.8. + cliArgs.add(preserveArgument); + } if (buildArgs != null && !buildArgs.isEmpty()) { cliArgs.addAll(processBuildArgs(buildArgs)); @@ -542,6 +553,61 @@ private List resolveLayerUseArguments() throws MojoExecutionException { .toList(); } + protected PreserveConfiguration preserveConfiguration() { + return null; + } + + private String resolvePreserveArgument() throws MojoExecutionException { + PreserveConfiguration preserve = preserveConfiguration(); + if (preserve == null) { + return null; + } + List dependencies = preserve.getDependencies(); + if (dependencies == null || dependencies.isEmpty()) { + throw new MojoExecutionException( + "Preserve has no dependencies; configure at least one dependency coordinate"); + } + + Set paths = new LinkedHashSet<>(); + for (PreserveDependencyConfiguration dependency : dependencies) { + String selector = dependency == null ? null : dependency.getArtifact(); + if (selector == null || selector.isBlank()) { + throw new MojoExecutionException("Preserve dependency coordinates must not be blank"); + } + String[] coordinate = MavenDependencySelector.parse(selector, "Preserve dependency"); + List eligible = project.getArtifacts().stream() + .filter(artifact -> getDependencyScopes().contains(artifact.getScope())) + .filter(artifact -> !"nil".equals(artifact.getType())) + .toList(); + List roots = eligible.stream() + .filter(artifact -> MavenDependencySelector.matchesExactly(artifact, coordinate)) + .toList(); + if (roots.isEmpty()) { + throw new MojoExecutionException( + "Preserve dependency '" + selector + "' was not found in the resolved project dependencies"); + } + if (roots.size() > 1) { + throw new MojoExecutionException("Preserve dependency '" + selector + "' is ambiguous: " + roots); + } + for (Artifact artifact : eligible) { + if (!MavenDependencySelector.matches(artifact, coordinate, dependency.isTransitive())) { + continue; + } + Path path = processSupportedArtifacts(artifact); + if (path == null || !Files.exists(path)) { + throw new MojoExecutionException( + "Preserve dependency '" + selector + "' selected " + artifact + " without a resolved file"); + } + paths.add(path.toAbsolutePath()); + } + } + if (paths.isEmpty()) { + throw new MojoExecutionException("Preserve dependency selection resolved no usable paths"); + } + ArtifactSelection selection = new ArtifactSelection(false, List.of(), List.of(), new ArrayList<>(paths)); + return NativeImagePreserveArguments.renderPreserve(selection); + } + /** * Directories that contain layer libraries consumed by this image. §FS-native-builds.3. */ @@ -654,13 +720,7 @@ private void warnAboutUnreferencedLayers(List artifacts) { } static String[] parseLayerCoordinate(String selector) throws MojoExecutionException { - String[] parts = selector.split(":", -1); - if (parts.length < 2 || parts.length > 3 - || Arrays.stream(parts).anyMatch(String::isBlank)) { - throw new MojoExecutionException( - "Layer dependency must use groupId:artifactId[:version]: " + selector); - } - return parts; + return MavenDependencySelector.parse(selector, "Layer dependency"); } private static boolean matchesLayerCoordinate(Artifact artifact, String[] parts) { diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/DeprecatedNativeBuildMojo.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/DeprecatedNativeBuildMojo.java index d944f4cd4..25bfc046d 100644 --- a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/DeprecatedNativeBuildMojo.java +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/DeprecatedNativeBuildMojo.java @@ -55,7 +55,6 @@ requiresDependencyResolution = ResolutionScope.RUNTIME, requiresDependencyCollection = ResolutionScope.RUNTIME) public class DeprecatedNativeBuildMojo extends NativeCompileNoForkMojo { - @Override protected void executeInternal() throws MojoExecutionException { logger.warn("'native:build' goal is deprecated. Use 'native:compile-no-fork' instead."); diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/LayerCreateMojo.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/LayerCreateMojo.java index a1e0c695c..66540af97 100644 --- a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/LayerCreateMojo.java +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/LayerCreateMojo.java @@ -175,7 +175,7 @@ List resolveSelectedPaths() throws MojoExecutionException { if (selector == null || selector.isBlank()) { throw new MojoExecutionException("Layer dependency coordinates must not be blank"); } - String[] coordinate = AbstractNativeImageMojo.parseLayerCoordinate(selector); + String[] coordinate = MavenDependencySelector.parse(selector, "Layer dependency"); boolean matched = false; for (Artifact artifact : project.getArtifacts()) { if (matches(artifact, coordinate, dependency.isTransitive())) { @@ -202,21 +202,6 @@ private List runtimeArtifactPaths() { } static boolean matches(Artifact artifact, String[] parts, boolean transitive) { - boolean exact = artifact.getGroupId().equals(parts[0]) - && artifact.getArtifactId().equals(parts[1]) - && (parts.length == 2 || artifact.getVersion().equals(parts[2])); - if (exact || !transitive || artifact.getDependencyTrail() == null) { - return exact; - } - // A version-qualified root must match exactly before its transitive trail is selected. §FS-config-model.7. - return artifact.getDependencyTrail().stream().anyMatch(entry -> matchesTrailCoordinate(entry, parts)); - } - - private static boolean matchesTrailCoordinate(String entry, String[] parts) { - String[] trailParts = entry.split(":", -1); - return trailParts.length >= 4 - && trailParts[0].equals(parts[0]) - && trailParts[1].equals(parts[1]) - && (parts.length == 2 || trailParts[trailParts.length - 1].equals(parts[2])); + return MavenDependencySelector.matches(artifact, parts, transitive); } } diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/MavenDependencySelector.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/MavenDependencySelector.java new file mode 100644 index 000000000..f57f10e47 --- /dev/null +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/MavenDependencySelector.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.maven; + +import org.apache.maven.artifact.Artifact; +import org.apache.maven.plugin.MojoExecutionException; + +import java.util.Arrays; + +/** + * Shared Maven coordinate parsing and dependency-trail matching for layers and Preserve. + * §FS-config-model.7. §FS-config-model.8. + */ +final class MavenDependencySelector { + private MavenDependencySelector() { + } + + static String[] parse(String selector, String feature) throws MojoExecutionException { + String[] parts = selector == null ? new String[0] : selector.split(":", -1); + if (parts.length < 2 || parts.length > 3 || Arrays.stream(parts).anyMatch(String::isBlank)) { + throw new MojoExecutionException( + feature + " must use groupId:artifactId[:version]: " + selector); + } + return parts; + } + + static boolean matchesExactly(Artifact artifact, String[] parts) { + return artifact.getGroupId().equals(parts[0]) + && artifact.getArtifactId().equals(parts[1]) + && (parts.length == 2 || artifact.getVersion().equals(parts[2])); + } + + static boolean matches(Artifact artifact, String[] parts, boolean transitive) { + boolean exact = matchesExactly(artifact, parts); + if (exact || !transitive || artifact.getDependencyTrail() == null) { + return exact; + } + // Version-qualified roots must match the same resolved trail before their closure is selected. §FS-config-model.8. + return artifact.getDependencyTrail().stream().anyMatch(entry -> matchesTrailCoordinate(entry, parts)); + } + + private static boolean matchesTrailCoordinate(String entry, String[] parts) { + String[] trailParts = entry.split(":", -1); + return trailParts.length >= 4 + && trailParts[0].equals(parts[0]) + && trailParts[1].equals(parts[1]) + && (parts.length == 2 || trailParts[trailParts.length - 1].equals(parts[2])); + } +} diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeCompileNoForkMojo.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeCompileNoForkMojo.java index d6939eeb2..83e832b07 100644 --- a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeCompileNoForkMojo.java +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeCompileNoForkMojo.java @@ -53,6 +53,7 @@ import org.apache.maven.plugins.annotations.ResolutionScope; import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluationException; import org.codehaus.plexus.util.xml.Xpp3Dom; +import org.graalvm.buildtools.maven.config.PreserveConfiguration; import org.graalvm.buildtools.maven.sbom.SBOMGenerator; import org.graalvm.buildtools.utils.NativeImageUtils; import org.twdata.maven.mojoexecutor.MojoExecutor; @@ -73,6 +74,10 @@ requiresDependencyCollection = ResolutionScope.RUNTIME) public class NativeCompileNoForkMojo extends AbstractNativeImageMojo { + // The compile-no-fork hierarchy shares dependency Preserve selection. §FS-config-model.8. + @Parameter + private PreserveConfiguration preserve; + @Parameter(property = "skipNativeBuild", defaultValue = "false") private boolean skip; @@ -89,6 +94,11 @@ public class NativeCompileNoForkMojo extends AbstractNativeImageMojo { private PluginParameterExpressionEvaluator evaluator; + @Override + protected PreserveConfiguration preserveConfiguration() { + return preserve; + } + @Override protected List getDependencyScopes() { return Arrays.asList(Artifact.SCOPE_COMPILE, diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/config/PreserveConfiguration.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/config/PreserveConfiguration.java new file mode 100644 index 000000000..e61788977 --- /dev/null +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/config/PreserveConfiguration.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.maven.config; + +import java.util.ArrayList; +import java.util.List; + +/** + * Maven dependency-only Preserve configuration for the compile-no-fork hierarchy. §FS-config-model.8. + */ +public class PreserveConfiguration { + private List dependencies = new ArrayList<>(); + + public List getDependencies() { + return dependencies; + } + + public void setDependencies(List dependencies) { + this.dependencies = dependencies; + } +} diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/config/PreserveDependencyConfiguration.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/config/PreserveDependencyConfiguration.java new file mode 100644 index 000000000..b46c7e517 --- /dev/null +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/config/PreserveDependencyConfiguration.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.maven.config; + +/** + * Maven coordinate selector for application-image Preserve configuration. §FS-config-model.8. + */ +public class PreserveDependencyConfiguration { + private String artifact; + private boolean transitive = true; + + public String getArtifact() { + return artifact; + } + + public void setArtifact(String artifact) { + this.artifact = artifact; + } + + public boolean isTransitive() { + return transitive; + } + + public void setTransitive(boolean transitive) { + this.transitive = transitive; + } +} diff --git a/native-maven-plugin/src/test/groovy/org/graalvm/buildtools/maven/AbstractNativeImageMojoTest.groovy b/native-maven-plugin/src/test/groovy/org/graalvm/buildtools/maven/AbstractNativeImageMojoTest.groovy index 9d59b3eff..56e3c8197 100644 --- a/native-maven-plugin/src/test/groovy/org/graalvm/buildtools/maven/AbstractNativeImageMojoTest.groovy +++ b/native-maven-plugin/src/test/groovy/org/graalvm/buildtools/maven/AbstractNativeImageMojoTest.groovy @@ -10,6 +10,8 @@ import org.apache.maven.project.MavenProject import org.codehaus.plexus.logging.Logger import org.eclipse.aether.resolution.ArtifactRequest import org.graalvm.buildtools.maven.config.UseLayerConfiguration +import org.graalvm.buildtools.maven.config.PreserveConfiguration +import org.graalvm.buildtools.maven.config.PreserveDependencyConfiguration import org.graalvm.buildtools.model.resources.NativeImageFlags import org.graalvm.buildtools.utils.NativeImageLayerRuntime import spock.lang.Issue @@ -166,6 +168,170 @@ class AbstractNativeImageMojoTest extends Specification { args.count { it == NativeImageFlags.NO_FALLBACK } == 1 } + // Maven resolves one dependency closure to a shared path-only Preserve argument. §FS-config-model.8. + @Issue("https://github.com/graalvm/native-build-tools/issues/978") + def "renders Preserve dependencies before user build arguments"() { + given: + def directDirectory = testDirectory.resolve("direct dependency").toFile() + def transitiveDirectory = testDirectory.resolve("transitive").toFile() + directDirectory.mkdirs() + transitiveDirectory.mkdirs() + def direct = artifact("com.acme", "extension", "1.0", directDirectory, + ["org.example:application:jar:1.0", "com.acme:extension:jar:1.0"]) + def transitive = artifact("com.acme", "support", "2.0", transitiveDirectory, + ["org.example:application:jar:1.0", "com.acme:extension:jar:1.0", "com.acme:support:jar:2.0"]) + def mojo = newMojo(["--user-option"]) + mojo.imageClasspath.add(testDirectory.resolve("application.jar")) + mojo.project.artifacts = [direct, transitive] as Set + mojo.preserve = preserve("com.acme:extension") + + when: + def args = mojo.getBuildArgs() + def preserveArgument = args.find { it.startsWith(NativeImageFlags.PRESERVE + "=") } + + then: + !args.contains(NativeImageFlags.UNLOCK_EXPERIMENTAL_VMOPTIONS) + preserveArgument.contains("path=${directDirectory.absolutePath}") + preserveArgument.contains("path=${transitiveDirectory.absolutePath}") + args.indexOf(preserveArgument) < args.indexOf("--user-option") + } + + def "supports non-transitive Preserve selection"() { + given: + def directDirectory = testDirectory.resolve("direct").toFile() + def transitiveDirectory = testDirectory.resolve("transitive").toFile() + directDirectory.mkdirs() + transitiveDirectory.mkdirs() + def direct = artifact("com.acme", "extension", "1.0", directDirectory, + ["org.example:application:jar:1.0", "com.acme:extension:jar:1.0"]) + def transitive = artifact("com.acme", "support", "2.0", transitiveDirectory, + ["org.example:application:jar:1.0", "com.acme:extension:jar:1.0", "com.acme:support:jar:2.0"]) + def mojo = newMojo([]) + mojo.imageClasspath.add(testDirectory.resolve("application.jar")) + mojo.project.artifacts = [direct, transitive] as Set + mojo.preserve = preserve("com.acme:extension", false) + + when: + def argument = mojo.getBuildArgs().find { it.startsWith(NativeImageFlags.PRESERVE + "=") } + + then: + argument.contains("path=${directDirectory.absolutePath}") + !argument.contains("path=${transitiveDirectory.absolutePath}") + } + + def "keeps version-qualified Preserve closure order and de-duplicates overlapping selectors"() { + given: + def directDirectory = testDirectory.resolve("extension-1").toFile() + def transitiveDirectory = testDirectory.resolve("support").toFile() + def otherVersionDirectory = testDirectory.resolve("extension-2").toFile() + [directDirectory, transitiveDirectory, otherVersionDirectory]*.mkdirs() + def direct = artifact("com.acme", "extension", "1.0", directDirectory, + ["org.example:application:jar:1.0", "com.acme:extension:jar:1.0"]) + def transitive = artifact("com.acme", "support", "2.0", transitiveDirectory, + ["org.example:application:jar:1.0", "com.acme:extension:jar:1.0", "com.acme:support:jar:2.0"]) + def otherVersion = artifact("com.acme", "extension", "2.0", otherVersionDirectory, + ["org.example:application:jar:1.0", "com.acme:extension:jar:2.0"]) + def mojo = newMojo([]) + mojo.imageClasspath.add(testDirectory.resolve("application.jar")) + mojo.project.artifacts = [direct, transitive, otherVersion] as Set + mojo.preserve = new PreserveConfiguration(dependencies: [ + new PreserveDependencyConfiguration(artifact: "com.acme:extension:1.0"), + new PreserveDependencyConfiguration(artifact: "com.acme:support:2.0", transitive: false) + ]) + + when: + def argument = mojo.getBuildArgs().find { it.startsWith(NativeImageFlags.PRESERVE + "=") } + + then: + argument.indexOf("path=${directDirectory.absolutePath}") < + argument.indexOf("path=${transitiveDirectory.absolutePath}") + argument.count("path=${directDirectory.absolutePath}") == 1 + argument.count("path=${transitiveDirectory.absolutePath}") == 1 + !argument.contains("path=${otherVersionDirectory.absolutePath}") + } + + def "reports invalid Preserve selection as a Maven execution error"() { + given: + def mojo = newMojo([]) + mojo.imageClasspath.add(testDirectory.resolve("application.jar")) + mojo.preserve = configuration + + when: + mojo.getBuildArgs() + + then: + def error = thrown(MojoExecutionException) + error.message.contains(expected) + + where: + configuration | expected + new PreserveConfiguration() | "Preserve has no dependencies" + preserve(" ") | "must not be blank" + preserve("malformed") | "groupId:artifactId[:version]" + preserve("com.acme:missing") | "was not found" + } + + def "reports a fileless Preserve root"() { + given: + def mojo = newMojo([]) + mojo.imageClasspath.add(testDirectory.resolve("application.jar")) + mojo.project.artifacts = [artifact("com.acme", "extension", "1.0", null, null)] as Set + mojo.preserve = preserve("com.acme:extension") + + when: + mojo.getBuildArgs() + + then: + def error = thrown(MojoExecutionException) + error.message.contains("without a resolved file") + } + + def "reports an ambiguous Preserve root"() { + given: + def one = testDirectory.resolve("one").toFile() + def two = testDirectory.resolve("two").toFile() + one.mkdirs() + two.mkdirs() + def mojo = newMojo([]) + mojo.imageClasspath.add(testDirectory.resolve("application.jar")) + mojo.project.artifacts = [ + artifact("com.acme", "extension", "1.0", one, null), + artifact("com.acme", "extension", "2.0", two, null) + ] as Set + mojo.preserve = preserve("com.acme:extension") + + when: + mojo.getBuildArgs() + + then: + def error = thrown(MojoExecutionException) + error.message.contains("is ambiguous") + } + + def "uses reactor classes for a Preserve dependency without a packaged artifact"() { + given: + def classesDirectory = testDirectory.resolve("reactor-classes") + classesDirectory.toFile().mkdirs() + def selected = artifact("com.acme", "extension", "1.0", null, + ["org.example:application:jar:1.0", "com.acme:extension:jar:1.0"]) + def reactorProject = new MavenProject() + reactorProject.groupId = "com.acme" + reactorProject.artifactId = "extension" + reactorProject.version = "1.0" + reactorProject.build.outputDirectory = classesDirectory.toString() + def mojo = newMojo([]) + mojo.imageClasspath.add(testDirectory.resolve("application.jar")) + mojo.project.artifacts = [selected] as Set + mojo.session.allProjects = [reactorProject] + mojo.preserve = preserve("com.acme:extension") + + when: + def argument = mojo.getBuildArgs().find { it.startsWith(NativeImageFlags.PRESERVE + "=") } + + then: + argument.contains("path=${classesDirectory.toAbsolutePath()}") + } + void "it allows empty classpath for layer-create builds"() { given: def mojo = newMojo([layerCreateArg]) @@ -300,13 +466,29 @@ class AbstractNativeImageMojoTest extends Specification { .setSystemProperties(systemProperties) .setInteractiveMode(true) mojo.session = new MavenSession(null, null, request, new DefaultMavenExecutionResult()) + mojo.session.allProjects = [] mojo } + private static PreserveConfiguration preserve(String selector, boolean transitive = true) { + new PreserveConfiguration(dependencies: [ + new PreserveDependencyConfiguration(artifact: selector, transitive: transitive) + ]) + } + + private static DefaultArtifact artifact(String group, String name, String version, File file, List trail) { + def artifact = new DefaultArtifact(group, name, version, "runtime", "jar", null, + new DefaultArtifactHandler("jar")) + artifact.file = file + artifact.dependencyTrail = trail + artifact + } + private static class TestNativeImageMojo extends AbstractNativeImageMojo { boolean fallbackRemoved int nativeImageMajorVersion = 25 Path layerRuntimeArchive + PreserveConfiguration preserve @Override protected void executeInternal() { @@ -314,7 +496,12 @@ class AbstractNativeImageMojoTest extends Specification { @Override protected List getDependencyScopes() { - Collections.emptyList() + Collections.singletonList(org.apache.maven.artifact.Artifact.SCOPE_RUNTIME) + } + + @Override + protected PreserveConfiguration preserveConfiguration() { + preserve } @Override diff --git a/native-maven-plugin/src/test/groovy/org/graalvm/buildtools/maven/PreserveConfigurationTest.groovy b/native-maven-plugin/src/test/groovy/org/graalvm/buildtools/maven/PreserveConfigurationTest.groovy new file mode 100644 index 000000000..9f9f09a84 --- /dev/null +++ b/native-maven-plugin/src/test/groovy/org/graalvm/buildtools/maven/PreserveConfigurationTest.groovy @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.maven + +import org.graalvm.buildtools.maven.config.PreserveConfiguration +import org.graalvm.buildtools.maven.config.PreserveDependencyConfiguration +import spock.lang.Specification + +// Verifies Maven XML bean semantics and compile-no-fork hierarchy ownership for Preserve. §FS-config-model.8. +class PreserveConfigurationTest extends Specification { + def "dependency selection is transitive by default and configurable"() { + given: + def dependency = new PreserveDependencyConfiguration(artifact: "com.acme:extension") + + expect: + dependency.transitive + + when: + dependency.transitive = false + + then: + !dependency.transitive + } + + def "configuration exposes dependencies only"() { + expect: + PreserveConfiguration.declaredFields.findAll { !it.synthetic }*.name == ["dependencies"] + } + + def "parameter belongs to the compile-no-fork hierarchy"() { + expect: + NativeCompileNoForkMojo.getDeclaredField("preserve") + NativeCompileMojo.superclass == NativeCompileNoForkMojo + DeprecatedNativeBuildMojo.superclass == NativeCompileNoForkMojo + WriteArgsFileMojo.superclass == NativeCompileNoForkMojo + AbstractNativeImageMojo.declaredMethods*.name.contains("preserveConfiguration") + !AbstractNativeImageMojo.declaredMethods*.name.contains("getPreserveConfiguration") + !NativeTestMojo.declaredFields*.name.contains("preserve") + !NativeIntegrationTestMojo.declaredFields*.name.contains("preserve") + !LayerCreateMojo.declaredFields*.name.contains("preserve") + } +}