You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/09/02 16:53:29 UTC
(maven) 01/01: Fix consumer POM leaving extension-contributed user properties uninterpolated
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch backport/12981-consumer-pom-interpolation
in repository https://gitbox.apache.org/repos/asf/maven.git
commit de1a10987e1239cc547db356f730dbc1a641e607
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Wed Sep 2 18:52:49 2026 +0200
Fix consumer POM leaving extension-contributed user properties uninterpolated
When a Maven extension contributes user properties via the PropertyContributor SPI (e.g. Nisse's nisse.jgit.dynamicVersion), buildPom() used the raw model (uninterpolated) for the consumer POM. Since these properties are not in any POM's <properties> section or parent chain, they remained as dangling ${...} references in the installed/deployed POM.
The fix adds interpolatePomVersions() which resolves only extension-contributed properties while preserving POM/parent chain and built-in property references. Profile-level dependencies are also handled.
Fixes #12981
---
.../impl/DefaultConsumerPomBuilder.java | 229 +++++++++
.../impl/ConsumerPomBuilderTest.java | 521 +++++++++++++++++++++
2 files changed, 750 insertions(+)
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/DefaultConsumerPomBuilder.java b/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/DefaultConsumerPomBuilder.java
index 2010abbe14..72a1208ad8 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/DefaultConsumerPomBuilder.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/DefaultConsumerPomBuilder.java
@@ -144,9 +144,238 @@ protected Model buildPom(RepositorySystemSession session, MavenProject project,
throws ModelBuilderException {
ModelBuilderResult result = buildModel(session, project, src);
Model model = result.getRawModel();
+ Model effectiveModel = result.getEffectiveModel();
+ model = interpolatePomVersions(model, effectiveModel);
return transformPom(model, project);
}
+ /**
+ * Interpolates version references in the raw model's dependencies and dependency
+ * management that cannot be resolved by downstream consumers through the POM's
+ * own {@code <properties>} section or parent chain.
+ * <p>
+ * The {@code buildPom()} path uses the raw model to preserve the parent reference
+ * in the consumer POM. Properties defined in the POM or inherited from the parent
+ * chain are available to consumers and should be left as {@code ${...}} references.
+ * However, properties contributed by Maven extensions (via {@code PropertyContributor}
+ * SPI) exist only as user properties during the build session — they are not part of
+ * any POM's {@code <properties>} section or parent chain. Without this interpolation
+ * step, such references (e.g. {@code ${nisse.jgit.dynamicVersion}}) would remain
+ * unresolved in the installed/deployed consumer POM, making it unusable.
+ * <p>
+ * The effective model's {@code getProperties()} returns exactly the properties from the
+ * POM and parent chain (the model builder's {@code merge()} method only overrides
+ * existing model properties — it never adds new user-property keys). A version
+ * reference is interpolated only when it contains at least one property name that
+ * is absent from that set, meaning a downstream consumer would be unable to resolve
+ * it.
+ *
+ * @param rawModel the raw model (no inheritance, no interpolation)
+ * @param effectiveModel the effective model (inheritance + full interpolation)
+ * @return the raw model with non-resolvable version references resolved
+ * @see <a href="https://github.com/apache/maven/issues/12981">GH-12981</a>
+ */
+ static Model interpolatePomVersions(Model rawModel, Model effectiveModel) {
+ // Build the set of properties available to downstream consumers.
+ // effectiveModel.getProperties() contains properties from the POM and parent chain.
+ // However, BUILD_CONSUMER does not re-activate profiles, and the model builder's
+ // merge() only overrides existing keys — it never adds new user-property keys.
+ // So profile-defined properties (e.g. from <activeByDefault> profiles) may be
+ // absent from the effective model's properties map even though transformPom()
+ // preserves those profiles in the consumer POM and consumers CAN resolve them.
+ // We must include properties from ALL profiles in the raw model to avoid
+ // incorrectly interpolating resolvable references like ${junit.version}.
+ Map<String, String> consumerProperties = new LinkedHashMap<>(effectiveModel.getProperties());
+ for (Profile profile : rawModel.getProfiles()) {
+ consumerProperties.putAll(profile.getProperties());
+ }
+ Map<String, String> modelProperties = consumerProperties;
+
+ // Build lookups from the effective model's resolved dependency entries
+ Map<String, Dependency> effectiveManagedDeps = new LinkedHashMap<>();
+ if (effectiveModel.getDependencyManagement() != null) {
+ for (Dependency dep : effectiveModel.getDependencyManagement().getDependencies()) {
+ effectiveManagedDeps.put(getDependencyKey(dep), dep);
+ }
+ }
+ Map<String, Dependency> effectiveDeps = new LinkedHashMap<>();
+ for (Dependency dep : effectiveModel.getDependencies()) {
+ effectiveDeps.put(getDependencyKey(dep), dep);
+ }
+
+ // Interpolate dependency management versions
+ if (rawModel.getDependencyManagement() != null
+ && !rawModel.getDependencyManagement().getDependencies().isEmpty()) {
+ List<Dependency> interpolatedDeps = new ArrayList<>();
+ boolean dmChanged = false;
+ for (Dependency dep : rawModel.getDependencyManagement().getDependencies()) {
+ if (dep.getVersion() != null
+ && dep.getVersion().contains("${")
+ && hasNonModelProperties(dep.getVersion(), modelProperties)) {
+ String key = getDependencyKey(dep);
+ Dependency effectiveDep = effectiveManagedDeps.get(key);
+ if (effectiveDep != null && !effectiveDep.getVersion().contains("${")) {
+ dep = dep.withVersion(effectiveDep.getVersion());
+ dmChanged = true;
+ }
+ }
+ interpolatedDeps.add(dep);
+ }
+ if (dmChanged) {
+ rawModel = rawModel.withDependencyManagement(
+ rawModel.getDependencyManagement().withDependencies(interpolatedDeps));
+ }
+ }
+
+ // Interpolate direct dependency versions
+ if (!rawModel.getDependencies().isEmpty()) {
+ List<Dependency> interpolatedDeps = new ArrayList<>();
+ boolean depsChanged = false;
+ for (Dependency dep : rawModel.getDependencies()) {
+ if (dep.getVersion() != null
+ && dep.getVersion().contains("${")
+ && hasNonModelProperties(dep.getVersion(), modelProperties)) {
+ String key = getDependencyKey(dep);
+ Dependency effectiveDep = effectiveDeps.get(key);
+ if (effectiveDep != null && !effectiveDep.getVersion().contains("${")) {
+ dep = dep.withVersion(effectiveDep.getVersion());
+ depsChanged = true;
+ }
+ }
+ interpolatedDeps.add(dep);
+ }
+ if (depsChanged) {
+ rawModel = rawModel.withDependencies(interpolatedDeps);
+ }
+ }
+
+ // Interpolate profile-level dependency and dependency management versions.
+ // Profiles are preserved in the consumer POM (transformPom keeps them), but
+ // extension-contributed property references in profile dependencies would remain
+ // unresolvable for downstream consumers. We use the same effectiveDeps /
+ // effectiveManagedDeps lookups because the effective model merges active profile
+ // deps into the main sections — giving us the resolved values we need.
+ if (!rawModel.getProfiles().isEmpty()) {
+ List<Profile> updatedProfiles = new ArrayList<>();
+ boolean profilesChanged = false;
+ for (Profile profile : rawModel.getProfiles()) {
+ Profile updatedProfile = profile;
+
+ // Interpolate profile dependency management
+ if (profile.getDependencyManagement() != null
+ && !profile.getDependencyManagement().getDependencies().isEmpty()) {
+ List<Dependency> interpolatedDeps = new ArrayList<>();
+ boolean dmChanged = false;
+ for (Dependency dep : profile.getDependencyManagement().getDependencies()) {
+ if (dep.getVersion() != null
+ && dep.getVersion().contains("${")
+ && hasNonModelProperties(dep.getVersion(), modelProperties)) {
+ String key = getDependencyKey(dep);
+ Dependency effectiveDep = effectiveManagedDeps.get(key);
+ if (effectiveDep != null
+ && !effectiveDep.getVersion().contains("${")) {
+ dep = dep.withVersion(effectiveDep.getVersion());
+ dmChanged = true;
+ }
+ }
+ interpolatedDeps.add(dep);
+ }
+ if (dmChanged) {
+ updatedProfile = updatedProfile.withDependencyManagement(
+ updatedProfile.getDependencyManagement().withDependencies(interpolatedDeps));
+ profilesChanged = true;
+ }
+ }
+
+ // Interpolate profile direct dependencies
+ if (!profile.getDependencies().isEmpty()) {
+ List<Dependency> interpolatedDeps = new ArrayList<>();
+ boolean depsChanged = false;
+ for (Dependency dep : profile.getDependencies()) {
+ if (dep.getVersion() != null
+ && dep.getVersion().contains("${")
+ && hasNonModelProperties(dep.getVersion(), modelProperties)) {
+ String key = getDependencyKey(dep);
+ Dependency effectiveDep = effectiveDeps.get(key);
+ if (effectiveDep != null
+ && !effectiveDep.getVersion().contains("${")) {
+ dep = dep.withVersion(effectiveDep.getVersion());
+ depsChanged = true;
+ }
+ }
+ interpolatedDeps.add(dep);
+ }
+ if (depsChanged) {
+ updatedProfile = updatedProfile.withDependencies(interpolatedDeps);
+ profilesChanged = true;
+ }
+ }
+
+ updatedProfiles.add(updatedProfile);
+ }
+ if (profilesChanged) {
+ rawModel = rawModel.withProfiles(updatedProfiles);
+ }
+ }
+
+ return rawModel;
+ }
+
+ /**
+ * Returns {@code true} when the given version string contains at least one
+ * {@code ${name}} reference whose {@code name} is <em>not</em> a key in the
+ * supplied model properties and is not a well-known built-in property
+ * (e.g. {@code project.version}, {@code project.groupId}).
+ * <p>
+ * Such references originate from extension-contributed user properties
+ * (e.g. {@code PropertyContributor}) and would be unresolvable by downstream
+ * consumers that do not have the extension installed.
+ */
+ static boolean hasNonModelProperties(String version, Map<String, String> modelProperties) {
+ int start = 0;
+ while ((start = version.indexOf("${", start)) >= 0) {
+ int end = version.indexOf('}', start + 2);
+ if (end < 0) {
+ break;
+ }
+ String name = version.substring(start + 2, end);
+ if (!modelProperties.containsKey(name) && !isBuiltInProperty(name)) {
+ return true;
+ }
+ start = end + 1;
+ }
+ return false;
+ }
+
+ /**
+ * Returns {@code true} when the given property name is a Maven built-in that
+ * downstream consumers can always resolve without any additional POM properties.
+ * <p>
+ * The intentionally scoped set of recognized prefixes is:
+ * <ul>
+ * <li>{@code project.*} — standard project coordinate properties
+ * (e.g. {@code project.version}, {@code project.groupId})</li>
+ * <li>{@code pom.*} — legacy alias for {@code project.*}</li>
+ * <li>{@code version} — bare alias for {@code project.version}</li>
+ * <li>{@code env.*} — environment variables (e.g. {@code env.PATH});
+ * always available from the consumer's runtime environment</li>
+ * <li>{@code settings.*} — Maven settings properties
+ * (e.g. {@code settings.localRepository}); always available from
+ * the consumer's Maven installation</li>
+ * </ul>
+ * Properties contributed by Maven extensions via {@code PropertyContributor}
+ * are NOT built-ins — they must be interpolated before the consumer POM is
+ * published, because consumers that do not have the extension installed cannot
+ * resolve them.
+ */
+ private static boolean isBuiltInProperty(String name) {
+ return name.startsWith("project.")
+ || name.startsWith("pom.")
+ || name.equals("version")
+ || name.startsWith("env.")
+ || name.startsWith("settings.");
+ }
+
protected Model buildBom(RepositorySystemSession session, MavenProject project, ModelSource src)
throws ModelBuilderException {
ModelBuilderResult result = buildModel(session, project, src);
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomBuilderTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomBuilderTest.java
index 5cc7c12f08..5baae0a862 100644
--- a/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomBuilderTest.java
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomBuilderTest.java
@@ -976,4 +976,525 @@ void testTransformPomStripsExecutableCondition() throws Exception {
result.getProfiles().get(0).getActivation(),
"executable() condition should be stripped from transformPom profiles");
}
+
+ // ── GH-12981: extension-contributed user property interpolation ──────────
+
+ /**
+ * Verifies that {@code interpolatePomVersions} resolves extension-contributed
+ * property references (not present in the effective model's properties) in
+ * dependency management versions.
+ * <p>
+ * This is the core fix for GH-12981: properties from {@code PropertyContributor}
+ * extensions are NOT added to the model's {@code <properties>} (the model
+ * builder's merge only overrides existing keys). Downstream consumers cannot
+ * resolve them through the parent chain, so they must be interpolated.
+ */
+ @Test
+ void testInterpolatePomVersionsResolvesExtensionProperties() {
+ // Raw model: versions reference an extension-contributed property
+ // that is NOT in the effective model's properties
+ Dependency rawDep1 = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("common")
+ .version("${ext.dynamicVersion}")
+ .build();
+ Dependency rawDep2 = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("app")
+ .version("${ext.dynamicVersion}")
+ .build();
+
+ Model rawModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("bom")
+ .version("1.0.0")
+ .packaging("pom")
+ .dependencyManagement(DependencyManagement.newBuilder()
+ .dependencies(List.of(rawDep1, rawDep2))
+ .build())
+ .build();
+
+ // Effective model: versions resolved, but properties does NOT contain ext.dynamicVersion
+ // (it came from an extension, not the POM)
+ Dependency effectiveDep1 = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("common")
+ .version("1.0.0")
+ .build();
+ Dependency effectiveDep2 = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("app")
+ .version("1.0.0")
+ .build();
+
+ Model effectiveModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("bom")
+ .version("1.0.0")
+ .packaging("pom")
+ .dependencyManagement(DependencyManagement.newBuilder()
+ .dependencies(List.of(effectiveDep1, effectiveDep2))
+ .build())
+ .build();
+
+ Model result = DefaultConsumerPomBuilder.interpolatePomVersions(rawModel, effectiveModel);
+
+ List<Dependency> deps = result.getDependencyManagement().getDependencies();
+ assertEquals(2, deps.size());
+ assertEquals("1.0.0", deps.get(0).getVersion(), "Extension property should be interpolated");
+ assertEquals("1.0.0", deps.get(1).getVersion(), "Extension property should be interpolated");
+ }
+
+ /**
+ * Verifies that properties defined in the POM or parent chain are NOT
+ * interpolated — downstream consumers can resolve them through the parent
+ * reference preserved in the consumer POM.
+ */
+ @Test
+ void testInterpolatePomVersionsPreservesModelProperties() {
+ // Raw model: version references a property defined in the POM
+ Dependency rawDep = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("lib")
+ .version("${my.version}")
+ .build();
+
+ Model rawModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("parent")
+ .version("1.0.0")
+ .packaging("pom")
+ .properties(Map.of("my.version", "2.0.0"))
+ .dependencies(List.of(rawDep))
+ .build();
+
+ // Effective model: version resolved, AND the property IS in properties
+ Dependency effectiveDep = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("lib")
+ .version("2.0.0")
+ .build();
+
+ Model effectiveModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("parent")
+ .version("1.0.0")
+ .packaging("pom")
+ .properties(Map.of("my.version", "2.0.0"))
+ .dependencies(List.of(effectiveDep))
+ .build();
+
+ Model result = DefaultConsumerPomBuilder.interpolatePomVersions(rawModel, effectiveModel);
+
+ assertEquals(1, result.getDependencies().size());
+ assertEquals(
+ "${my.version}",
+ result.getDependencies().get(0).getVersion(),
+ "Model-defined property should be preserved as ${...} for consumers to resolve");
+ }
+
+ /**
+ * Verifies that {@code project.*} built-in properties are treated as
+ * resolvable and left as {@code ${...}} references.
+ */
+ @Test
+ void testInterpolatePomVersionsPreservesBuiltInProperties() {
+ Dependency rawDep = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("sibling")
+ .version("${project.version}")
+ .build();
+
+ Model rawModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("parent")
+ .version("1.0.0")
+ .packaging("pom")
+ .dependencyManagement(DependencyManagement.newBuilder()
+ .dependencies(List.of(rawDep))
+ .build())
+ .build();
+
+ Dependency effectiveDep = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("sibling")
+ .version("1.0.0")
+ .build();
+
+ Model effectiveModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("parent")
+ .version("1.0.0")
+ .packaging("pom")
+ .dependencyManagement(DependencyManagement.newBuilder()
+ .dependencies(List.of(effectiveDep))
+ .build())
+ .build();
+
+ Model result = DefaultConsumerPomBuilder.interpolatePomVersions(rawModel, effectiveModel);
+
+ assertEquals(
+ "${project.version}",
+ result.getDependencyManagement().getDependencies().get(0).getVersion(),
+ "Built-in ${project.version} should be preserved");
+ }
+
+ /**
+ * Verifies that mixed dependencies — some using model-defined properties,
+ * some using extension properties — are handled correctly: only extension
+ * properties are interpolated.
+ */
+ @Test
+ void testInterpolatePomVersionsMixedModelAndExtensionProperties() {
+ Dependency rawModelProp = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("stable")
+ .version("${my.version}")
+ .build();
+ Dependency rawExtProp = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("dynamic")
+ .version("${ext.version}")
+ .build();
+
+ Model rawModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("parent")
+ .version("1.0.0")
+ .packaging("pom")
+ .dependencyManagement(DependencyManagement.newBuilder()
+ .dependencies(List.of(rawModelProp, rawExtProp))
+ .build())
+ .build();
+
+ Dependency effectiveModelProp = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("stable")
+ .version("3.0.0")
+ .build();
+ Dependency effectiveExtProp = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("dynamic")
+ .version("4.2.1")
+ .build();
+
+ // Effective model has "my.version" in properties (from POM/parent)
+ // but NOT "ext.version" (from extension)
+ Model effectiveModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("parent")
+ .version("1.0.0")
+ .packaging("pom")
+ .properties(Map.of("my.version", "3.0.0"))
+ .dependencyManagement(DependencyManagement.newBuilder()
+ .dependencies(List.of(effectiveModelProp, effectiveExtProp))
+ .build())
+ .build();
+
+ Model result = DefaultConsumerPomBuilder.interpolatePomVersions(rawModel, effectiveModel);
+
+ List<Dependency> deps = result.getDependencyManagement().getDependencies();
+ assertEquals(2, deps.size());
+ assertEquals("${my.version}", deps.get(0).getVersion(), "Model-defined property should be preserved");
+ assertEquals("4.2.1", deps.get(1).getVersion(), "Extension property should be interpolated");
+ }
+
+ /**
+ * Verifies that an empty model is handled without errors.
+ */
+ @Test
+ void testInterpolatePomVersionsEmptyModel() {
+ Model rawModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("empty")
+ .version("1.0.0")
+ .packaging("pom")
+ .build();
+
+ Model effectiveModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("empty")
+ .version("1.0.0")
+ .packaging("pom")
+ .build();
+
+ Model result = DefaultConsumerPomBuilder.interpolatePomVersions(rawModel, effectiveModel);
+
+ assertTrue(result.getDependencies().isEmpty());
+ assertNull(result.getDependencyManagement());
+ }
+
+ /**
+ * Verifies that extension-contributed properties in direct dependencies
+ * are interpolated.
+ */
+ @Test
+ void testInterpolatePomVersionsResolvesDirectDependencyExtensionProperties() {
+ Dependency rawDep = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("lib")
+ .version("${ext.version}")
+ .build();
+
+ Model rawModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("parent")
+ .version("1.0.0")
+ .packaging("pom")
+ .dependencies(List.of(rawDep))
+ .build();
+
+ Dependency effectiveDep = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("lib")
+ .version("2.5.0")
+ .build();
+
+ Model effectiveModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("parent")
+ .version("1.0.0")
+ .packaging("pom")
+ .dependencies(List.of(effectiveDep))
+ .build();
+
+ Model result = DefaultConsumerPomBuilder.interpolatePomVersions(rawModel, effectiveModel);
+
+ assertEquals(1, result.getDependencies().size());
+ assertEquals("2.5.0", result.getDependencies().get(0).getVersion());
+ }
+
+ /**
+ * Verifies that properties defined in profiles of the raw model are treated as
+ * consumer-resolvable, even when they are absent from the effective model's
+ * properties (because BUILD_CONSUMER does not re-activate profiles and the
+ * model builder's merge() never adds new user-property keys).
+ * This is the exact scenario from MavenITmng8709ProfileDependencyVersionTest:
+ * a profile with {@code <activeByDefault>true</activeByDefault>} defines
+ * {@code junit.version}, and the consumer POM must preserve {@code ${junit.version}}.
+ */
+ @Test
+ void testInterpolatePomVersionsPreservesProfileProperties() {
+ Dependency rawDep = Dependency.newBuilder()
+ .groupId("org.junit.jupiter")
+ .artifactId("junit-jupiter-api")
+ .version("${junit.version}")
+ .build();
+
+ // Raw model has the property in a profile, NOT in top-level <properties>
+ Profile profile = Profile.newBuilder()
+ .id("default-versions")
+ .activation(Activation.newBuilder().activeByDefault(true).build())
+ .properties(Map.of("junit.version", "5.11.0"))
+ .build();
+
+ Model rawModel = Model.newBuilder()
+ .groupId("org.apache.maven.its.mng8709")
+ .artifactId("profile-version")
+ .version("1.0")
+ .profiles(List.of(profile))
+ .dependencies(List.of(rawDep))
+ .build();
+
+ // Effective model: version is resolved, but junit.version is NOT in
+ // effectiveModel.getProperties() because BUILD_CONSUMER didn't activate
+ // the profile and merge() doesn't add new user-property keys
+ Dependency effectiveDep = Dependency.newBuilder()
+ .groupId("org.junit.jupiter")
+ .artifactId("junit-jupiter-api")
+ .version("5.11.0")
+ .build();
+
+ Model effectiveModel = Model.newBuilder()
+ .groupId("org.apache.maven.its.mng8709")
+ .artifactId("profile-version")
+ .version("1.0")
+ // No "junit.version" in properties — simulates BUILD_CONSUMER behavior
+ .dependencies(List.of(effectiveDep))
+ .build();
+
+ Model result = DefaultConsumerPomBuilder.interpolatePomVersions(rawModel, effectiveModel);
+
+ assertEquals(1, result.getDependencies().size());
+ assertEquals(
+ "${junit.version}",
+ result.getDependencies().get(0).getVersion(),
+ "Profile-defined property should be preserved as ${...} for consumers to resolve");
+ }
+
+ // ── hasNonModelProperties unit tests ─────────────────────────────────────
+
+ @Test
+ void testHasNonModelPropertiesReturnsTrueForExtensionProperty() {
+ assertTrue(
+ DefaultConsumerPomBuilder.hasNonModelProperties("${ext.version}", Map.of()),
+ "Extension property not in model should return true");
+ }
+
+ @Test
+ void testHasNonModelPropertiesReturnsFalseForModelProperty() {
+ assertFalse(
+ DefaultConsumerPomBuilder.hasNonModelProperties("${my.version}", Map.of("my.version", "1.0")),
+ "Property defined in model should return false");
+ }
+
+ @Test
+ void testHasNonModelPropertiesReturnsFalseForBuiltInProjectProperty() {
+ assertFalse(
+ DefaultConsumerPomBuilder.hasNonModelProperties("${project.version}", Map.of()),
+ "Built-in project.version should return false");
+ }
+
+ @Test
+ void testHasNonModelPropertiesReturnsTrueForMixedProperties() {
+ assertTrue(
+ DefaultConsumerPomBuilder.hasNonModelProperties(
+ "${my.version}-${ext.qualifier}", Map.of("my.version", "1.0")),
+ "Should return true when at least one property is not in model");
+ }
+
+ @Test
+ void testHasNonModelPropertiesReturnsFalseForAllModelProperties() {
+ assertFalse(
+ DefaultConsumerPomBuilder.hasNonModelProperties(
+ "${major}.${minor}", Map.of("major", "1", "minor", "0")),
+ "Should return false when all properties are in model");
+ }
+
+ // ── Profile-level dependency interpolation (fix: handle profile deps) ────
+
+ /**
+ * Verifies that a profile-level direct dependency whose version uses an
+ * extension-contributed property gets interpolated. The effective model merges
+ * active profile deps into the top-level dependencies list, so
+ * {@code effectiveDeps} is used as the lookup source.
+ */
+ @Test
+ void testInterpolatePomVersionsResolvesProfileDirectDependencyExtensionProperty() {
+ Dependency profileDep = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("profile-lib")
+ .version("${ext.dynamicVersion}")
+ .build();
+
+ Profile profile = Profile.newBuilder()
+ .id("ext-profile")
+ .dependencies(List.of(profileDep))
+ .build();
+
+ Model rawModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("parent")
+ .version("1.0.0")
+ .packaging("pom")
+ .profiles(List.of(profile))
+ .build();
+
+ // The effective model merges active profile deps into the top-level deps list
+ Dependency effectiveDep = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("profile-lib")
+ .version("3.7.0")
+ .build();
+
+ Model effectiveModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("parent")
+ .version("1.0.0")
+ .packaging("pom")
+ .dependencies(List.of(effectiveDep))
+ .build();
+
+ Model result = DefaultConsumerPomBuilder.interpolatePomVersions(rawModel, effectiveModel);
+
+ assertEquals(1, result.getProfiles().size());
+ List<Dependency> profileDeps = result.getProfiles().get(0).getDependencies();
+ assertEquals(1, profileDeps.size());
+ assertEquals(
+ "3.7.0",
+ profileDeps.get(0).getVersion(),
+ "Extension property in profile direct dependency should be interpolated");
+ }
+
+ /**
+ * Verifies that a profile-level dependency management entry whose version uses
+ * an extension-contributed property gets interpolated. The effective model merges
+ * active profile depMgmt into the top-level dependency management, so
+ * {@code effectiveManagedDeps} is used as the lookup source.
+ */
+ @Test
+ void testInterpolatePomVersionsResolvesProfileDependencyManagementExtensionProperty() {
+ Dependency profileManagedDep = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("profile-managed")
+ .version("${ext.dynamicVersion}")
+ .build();
+
+ Profile profile = Profile.newBuilder()
+ .id("ext-profile")
+ .dependencyManagement(DependencyManagement.newBuilder()
+ .dependencies(List.of(profileManagedDep))
+ .build())
+ .build();
+
+ Model rawModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("parent")
+ .version("1.0.0")
+ .packaging("pom")
+ .profiles(List.of(profile))
+ .build();
+
+ // The effective model merges active profile depMgmt into top-level depMgmt
+ Dependency effectiveManagedDep = Dependency.newBuilder()
+ .groupId("com.example")
+ .artifactId("profile-managed")
+ .version("5.2.1")
+ .build();
+
+ Model effectiveModel = Model.newBuilder()
+ .groupId("com.example")
+ .artifactId("parent")
+ .version("1.0.0")
+ .packaging("pom")
+ .dependencyManagement(DependencyManagement.newBuilder()
+ .dependencies(List.of(effectiveManagedDep))
+ .build())
+ .build();
+
+ Model result = DefaultConsumerPomBuilder.interpolatePomVersions(rawModel, effectiveModel);
+
+ assertEquals(1, result.getProfiles().size());
+ assertNotNull(result.getProfiles().get(0).getDependencyManagement());
+ List<Dependency> managedDeps =
+ result.getProfiles().get(0).getDependencyManagement().getDependencies();
+ assertEquals(1, managedDeps.size());
+ assertEquals(
+ "5.2.1",
+ managedDeps.get(0).getVersion(),
+ "Extension property in profile dependency management should be interpolated");
+ }
+
+ // ── isBuiltInProperty: env.* and settings.* coverage ────────────────────
+
+ /**
+ * Verifies that {@code env.*} and {@code settings.*} properties are treated as
+ * built-in (i.e. always resolvable by consumers) and are therefore NOT
+ * interpolated.
+ */
+ @Test
+ void testHasNonModelPropertiesReturnsFalseForEnvAndSettingsProperties() {
+ assertFalse(
+ DefaultConsumerPomBuilder.hasNonModelProperties("${env.JAVA_HOME}", Map.of()),
+ "env.* property should be treated as built-in and not interpolated");
+
+ assertFalse(
+ DefaultConsumerPomBuilder.hasNonModelProperties("${settings.localRepository}", Map.of()),
+ "settings.* property should be treated as built-in and not interpolated");
+
+ // Mixed: one env.* and one extension property — should return true because ext.foo is NOT built-in
+ assertTrue(
+ DefaultConsumerPomBuilder.hasNonModelProperties("${env.HOME}-${ext.qualifier}", Map.of()),
+ "Should return true when at least one property is not built-in or in the model");
+ }
}