From 7bca3ac65ceaaced467e881af87906746666fdd8 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 4 Sep 2026 11:28:17 +0200 Subject: [PATCH 01/17] refactor(bgp): relocate DependencyInfo/VersionMediatingDependencySet out of expo package, add test infra Move DependencyInfo and VersionMediatingDependencySet from expo.utils to shared package to make them available for transitive dependency handling in the BGP. Add JUnit 5 test infrastructure and initial regression test for VersionMediatingDependencySet. Co-Authored-By: Claude Sonnet 5 --- .../react/brownfield/build.gradle.kts | 6 +++ .../brownfield/gradle/libs.versions.toml | 2 + .../brownfield/expo/ExpoPublishingHelper.kt | 4 +- .../expo/utils/BrownfieldPrimitives.kt | 24 ----------- .../react/brownfield/shared/Constants.kt | 1 - .../react/brownfield/shared/DependencyInfo.kt | 25 +++++++++++ .../VersionMediatingDependencySet.kt | 2 +- .../VersionMediatingDependencySetTest.kt | 43 +++++++++++++++++++ 8 files changed, 79 insertions(+), 28 deletions(-) create mode 100644 gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyInfo.kt rename gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/{expo/utils => shared}/VersionMediatingDependencySet.kt (98%) create mode 100644 gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt diff --git a/gradle-plugins/react/brownfield/build.gradle.kts b/gradle-plugins/react/brownfield/build.gradle.kts index ad2a04a7..8afb2e20 100644 --- a/gradle-plugins/react/brownfield/build.gradle.kts +++ b/gradle-plugins/react/brownfield/build.gradle.kts @@ -108,6 +108,12 @@ dependencies { implementation(libs.common) implementation(libs.asm.commons) implementation(libs.versioncompare) + testImplementation(libs.junit.jupiter) + testImplementation(gradleTestKit()) +} + +tasks.test { + useJUnitPlatform() } tasks.named("detekt").configure { diff --git a/gradle-plugins/react/brownfield/gradle/libs.versions.toml b/gradle-plugins/react/brownfield/gradle/libs.versions.toml index b1b59971..74ecb054 100644 --- a/gradle-plugins/react/brownfield/gradle/libs.versions.toml +++ b/gradle-plugins/react/brownfield/gradle/libs.versions.toml @@ -6,6 +6,7 @@ agp = "8.5.2" common = "31.2.2" # do not bump it for now, as it throws an error for incompatible AGP used asm-commons = "9.7" versioncompare = "1.5.0" +junit = "5.11.4" [plugins] kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinJvm" } @@ -17,3 +18,4 @@ agp = { module = "com.android.tools.build:gradle", name = "agp", version.ref = " common = { module = "com.android.tools:common", version.ref = "common" } asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "asm-commons" } versioncompare = { module = "io.github.g00fy2:versioncompare", version.ref = "versioncompare" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt index 72c4288b..127998b7 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt @@ -1,13 +1,13 @@ package com.callstack.react.brownfield.expo import com.android.utils.forEach -import com.callstack.react.brownfield.expo.utils.DependencyInfo import com.callstack.react.brownfield.expo.utils.ExpoGradleProjectProjection import com.callstack.react.brownfield.expo.utils.LocalMavenUtils -import com.callstack.react.brownfield.expo.utils.VersionMediatingDependencySet import com.callstack.react.brownfield.expo.utils.asExpoGradleProjectProjection import com.callstack.react.brownfield.shared.Constants +import com.callstack.react.brownfield.shared.DependencyInfo import com.callstack.react.brownfield.shared.Logging +import com.callstack.react.brownfield.shared.VersionMediatingDependencySet import groovy.json.JsonOutput import groovy.json.JsonSlurper import groovy.util.NodeList diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPrimitives.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPrimitives.kt index e051a820..10ef2ae2 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPrimitives.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPrimitives.kt @@ -5,27 +5,3 @@ data class BrownfieldPublishingInfo( val artifactId: String, val version: String, ) - -data class DependencyInfo( - val groupId: String, - val artifactId: String, - val version: String?, - val scope: String, - val optional: Boolean, -) { - companion object { - fun fromGradleDep( - groupId: String, - artifactId: String, - version: String?, - ): DependencyInfo { - return DependencyInfo( - groupId = groupId, - artifactId = artifactId, - version = version, - scope = "compile", - optional = false, - ) - } - } -} diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt index d6a5537f..b7c82f29 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt @@ -1,6 +1,5 @@ package com.callstack.react.brownfield.shared -import com.callstack.react.brownfield.expo.utils.DependencyInfo import com.callstack.react.brownfield.utils.StringMatcher /** diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyInfo.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyInfo.kt new file mode 100644 index 00000000..351fe758 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyInfo.kt @@ -0,0 +1,25 @@ +package com.callstack.react.brownfield.shared + +data class DependencyInfo( + val groupId: String, + val artifactId: String, + val version: String?, + val scope: String, + val optional: Boolean, +) { + companion object { + fun fromGradleDep( + groupId: String, + artifactId: String, + version: String?, + ): DependencyInfo { + return DependencyInfo( + groupId = groupId, + artifactId = artifactId, + version = version, + scope = "compile", + optional = false, + ) + } + } +} diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/VersionMediatingDependencySet.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySet.kt similarity index 98% rename from gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/VersionMediatingDependencySet.kt rename to gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySet.kt index b7dc2bc7..c8677784 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/VersionMediatingDependencySet.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySet.kt @@ -1,4 +1,4 @@ -package com.callstack.react.brownfield.expo.utils +package com.callstack.react.brownfield.shared import io.github.g00fy2.versioncompare.Version diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt new file mode 100644 index 00000000..fea563cd --- /dev/null +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt @@ -0,0 +1,43 @@ +package com.callstack.react.brownfield.shared + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class VersionMediatingDependencySetTest { + + @Test + fun `keeps the higher version when the same coordinate is added twice`() { + val set = VersionMediatingDependencySet() + + set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.6.0", "compile", false)) + set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false)) + + assertEquals(1, set.size) + assertTrue(set.contains(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false))) + } + + @Test + fun `does not downgrade when a lower version is added second`() { + val set = VersionMediatingDependencySet() + + set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false)) + set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.6.0", "compile", false)) + + val kept = set.first { it.groupId == "androidx.appcompat" } + assertEquals("1.7.1", kept.version) + } + + @Test + fun `removeAll removes matching entries and returns them`() { + val set = VersionMediatingDependencySet() + set.add(DependencyInfo("host.exp.exponent", "expo", "1.0.0", "compile", false)) + set.add(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false)) + + val removed = set.removeAll { it.groupId == "host.exp.exponent" } + + assertEquals(1, removed.size) + assertEquals(1, set.size) + assertTrue(set.contains(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false))) + } +} From 8c7830066eaeb85061f335f30b992179249304c0 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 4 Sep 2026 11:37:29 +0200 Subject: [PATCH 02/17] fix(bgp): add kotlin test dependency, fix ktlint violations - Add kotlin("test") dependency to support kotlin.test.* imports in tests - Rename BrownfieldPrimitives.kt to BrownfieldPublishingInfo.kt per ktlint single-class-per-file rule - Fix test class formatting per ktlint standard:no-empty-first-line-in-class-body All tests pass: 3/3 VersionMediatingDependencySetTest tests pass Build: BUILD SUCCESSFUL Co-Authored-By: Claude Sonnet 5 --- gradle-plugins/react/brownfield/build.gradle.kts | 1 + .../{BrownfieldPrimitives.kt => BrownfieldPublishingInfo.kt} | 0 .../react/brownfield/shared/VersionMediatingDependencySetTest.kt | 1 - 3 files changed, 1 insertion(+), 1 deletion(-) rename gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/{BrownfieldPrimitives.kt => BrownfieldPublishingInfo.kt} (100%) diff --git a/gradle-plugins/react/brownfield/build.gradle.kts b/gradle-plugins/react/brownfield/build.gradle.kts index 8afb2e20..b73354d7 100644 --- a/gradle-plugins/react/brownfield/build.gradle.kts +++ b/gradle-plugins/react/brownfield/build.gradle.kts @@ -110,6 +110,7 @@ dependencies { implementation(libs.versioncompare) testImplementation(libs.junit.jupiter) testImplementation(gradleTestKit()) + testImplementation(kotlin("test")) } tasks.test { diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPrimitives.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPublishingInfo.kt similarity index 100% rename from gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPrimitives.kt rename to gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPublishingInfo.kt diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt index fea563cd..927f8dc5 100644 --- a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt @@ -5,7 +5,6 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class VersionMediatingDependencySetTest { - @Test fun `keeps the higher version when the same coordinate is added twice`() { val set = VersionMediatingDependencySet() From d3b4ae8b555ac574cc4774c9024bf96183a8b45c Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 4 Sep 2026 11:42:41 +0200 Subject: [PATCH 03/17] refactor(bgp): extract PublishingMetadataInjector from ExpoPublishingHelper --- .../brownfield/expo/ExpoPublishingHelper.kt | 182 +----------------- .../shared/PublishingMetadataInjector.kt | 121 ++++++++++++ 2 files changed, 126 insertions(+), 177 deletions(-) create mode 100644 gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt index 127998b7..74cdacee 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt @@ -7,14 +7,9 @@ import com.callstack.react.brownfield.expo.utils.asExpoGradleProjectProjection import com.callstack.react.brownfield.shared.Constants import com.callstack.react.brownfield.shared.DependencyInfo import com.callstack.react.brownfield.shared.Logging +import com.callstack.react.brownfield.shared.PublishingMetadataInjector import com.callstack.react.brownfield.shared.VersionMediatingDependencySet -import groovy.json.JsonOutput -import groovy.json.JsonSlurper -import groovy.util.NodeList import org.gradle.api.Project -import org.gradle.api.publish.PublishingExtension -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.api.publish.tasks.GenerateModuleMetadata import org.w3c.dom.Node import java.io.File import javax.xml.parsers.DocumentBuilderFactory @@ -55,13 +50,14 @@ open class ExpoPublishingHelper(val brownfieldAppProject: Project) { ) } - reconfigurePOM(expoTransitiveDependencies) - reconfigureGradleModuleJSON(expoTransitiveDependencies) + val injector = PublishingMetadataInjector(brownfieldAppProject) + injector.reconfigurePOM(expoTransitiveDependencies, ::shouldExcludeDependency) + injector.reconfigureGradleModuleJSON(expoTransitiveDependencies, ::shouldExcludeDependency) return discoverableExpoProjects } - protected fun shouldExcludeDependency( + internal fun shouldExcludeDependency( groupId: String, artifactId: String, ): Boolean { @@ -78,174 +74,6 @@ open class ExpoPublishingHelper(val brownfieldAppProject: Project) { return (isRootProjectArtifact || isExpoArtifact) } - /** - * Modifies the generated Gradle Module Metadata file to inject Expo transitive dependencies. - * @param discoveredExpoTransitiveDependencies Set of DependencyInfo - * representing Expo transitive dependencies to add. - */ - @Suppress("LongMethod") - protected fun reconfigureGradleModuleJSON(discoveredExpoTransitiveDependencies: VersionMediatingDependencySet) { - val removeDependenciesFromModuleFileTask = - brownfieldAppProject.tasks.register("removeDependenciesFromModuleFile") - removeDependenciesFromModuleFileTask.configure { task -> - task.doLast { - val moduleBuildDir = brownfieldAppProject.layout.buildDirectory.get() - - File("$moduleBuildDir/publications/mavenAar/module.json").run { - val json = inputStream().use { JsonSlurper().parse(it) as Map<*, *> } - - discoveredExpoTransitiveDependencies.forEach { dependencyToAdd -> - @Suppress("UNCHECKED_CAST") - (json["variants"] as? List>)?.forEach { variant -> - Logging.log( - "Injecting dependency to Gradle module JSON for variant " + - "'${variant["name"]}': ${dependencyToAdd.groupId}:" + - "${dependencyToAdd.artifactId}:${dependencyToAdd.version}", - ) - - (variant["dependencies"] as? MutableList>)?.add( - mutableMapOf( - "group" to dependencyToAdd.groupId, - "module" to dependencyToAdd.artifactId, - ).apply { - dependencyToAdd.version?.let { version -> - put( - "version", - mapOf( - "requires" to version, - ), - ) - } - }, - ) - } - } - - @Suppress("UNCHECKED_CAST") - (json["variants"] as? List>)?.forEach { variant -> - (variant["dependencies"] as? MutableList>)?.removeAll { - val group = it["group"] as String - val module = it["module"] as String - - val shouldBeExcluded = - shouldExcludeDependency( - groupId = group, - artifactId = module, - ) - - if (shouldBeExcluded) { - Logging.log( - "Removing excluded dependency from Gradle module JSON: $group:$module", - ) - } - - shouldBeExcluded - } - - writer().use { - it.write( - JsonOutput.prettyPrint( - JsonOutput.toJson( - json, - ), - ), - ) - } - } - } - } - } - - brownfieldAppProject.tasks.withType(GenerateModuleMetadata::class.java) - .configureEach { - it.finalizedBy(removeDependenciesFromModuleFileTask.get()) - } - } - - /** - * Modifies the generated Maven POM file to inject Expo transitive dependencies. - * @param discoveredExpoTransitiveDependencies Set of DependencyInfo - * representing Expo transitive dependencies to add. - */ - @Suppress("LongMethod") - protected fun reconfigurePOM(discoveredExpoTransitiveDependencies: VersionMediatingDependencySet) { - brownfieldAppProject.pluginManager.withPlugin("maven-publish") { - brownfieldAppProject.extensions.configure(PublishingExtension::class.java) { publishing -> - publishing.publications.withType(MavenPublication::class.java) - .configureEach { pub -> - Logging.log( - "Configuring POM for publication '${pub.name}' to include Expo transitive dependencies", - ) - - pub.pom.withXml { - val root = it.asNode() - - // below: obtains a view of the node(s) - // inside the POM XML; in practice, there should be only one such node - val dependenciesNodeList = - root.get("dependencies") as NodeList - val dependenciesNode = - dependenciesNodeList.first() as groovy.util.Node - - // below: inject the discovered Expo transitive dependencies - // into the POM's node - discoveredExpoTransitiveDependencies.forEach { dependencyToAdd -> - Logging.log( - "Injecting dependency to POM: ${dependencyToAdd.groupId}:" + - "${dependencyToAdd.artifactId}:${dependencyToAdd.version}", - ) - - val childTags = - mutableMapOf( - "groupId" to dependencyToAdd.groupId, - "artifactId" to dependencyToAdd.artifactId, - "scope" to dependencyToAdd.scope, - "optional" to dependencyToAdd.optional.toString(), - ) - - if (dependencyToAdd.version?.isNotBlank() == true) { - childTags["version"] = dependencyToAdd.version - } - - dependenciesNode.appendNode("dependency").let { newDepNode -> - childTags.forEach { (tagName, tagValue) -> - newDepNode.appendNode(tagName, tagValue) - } - } - } - - // below: filter out dependencies that should be excluded - dependenciesNode.children() - .filterIsInstance() - .filter { dependency -> - val groupId = - (dependency["groupId"] as NodeList).text() - val artifactId = - (dependency["artifactId"] as NodeList).text() - - val shouldBeExcluded = - shouldExcludeDependency( - groupId = groupId, - artifactId = artifactId, - ) - - if (shouldBeExcluded) { - Logging.log( - "Removing excluded dependency from POM: $groupId:$artifactId", - ) - } - - shouldBeExcluded - } - .forEach { dependency -> - dependenciesNode.remove(dependency) - } - } - } - } - } - } - fun discoverAllExpoTransitiveDependencies(expoProjects: Iterable): VersionMediatingDependencySet { var discoveredExpoTransitiveDependencies = VersionMediatingDependencySet() expoProjects.forEach { expoProj -> diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt new file mode 100644 index 00000000..15000c98 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt @@ -0,0 +1,121 @@ +package com.callstack.react.brownfield.shared + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import groovy.util.NodeList +import org.gradle.api.Project +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.publish.tasks.GenerateModuleMetadata +import java.io.File + +/** + * Injects a resolved set of transitive dependencies into the generated Maven POM and + * Gradle Module Metadata (`module.json`) for every `MavenPublication` on [project], and + * removes any existing entry (from the base publication or previously injected) that + * [shouldExclude] matches. Used identically by the Expo and RNC-CLI transitive-dependency + * paths — see docs/superpowers/specs/2026-09-04-bgp-transitive-dependencies-design.md §4.1/§4.3. + */ +class PublishingMetadataInjector(private val project: Project) { + @Suppress("LongMethod") + fun reconfigureGradleModuleJSON( + dependencies: VersionMediatingDependencySet, + shouldExclude: (groupId: String, artifactId: String) -> Boolean, + ) { + val removeDependenciesFromModuleFileTask = + project.tasks.register("removeDependenciesFromModuleFile") + removeDependenciesFromModuleFileTask.configure { task -> + task.doLast { + val moduleBuildDir = project.layout.buildDirectory.get() + + File("$moduleBuildDir/publications/mavenAar/module.json").run { + val json = inputStream().use { JsonSlurper().parse(it) as Map<*, *> } + + dependencies.forEach { dependencyToAdd -> + @Suppress("UNCHECKED_CAST") + (json["variants"] as? List>)?.forEach { variant -> + (variant["dependencies"] as? MutableList>)?.add( + mutableMapOf( + "group" to dependencyToAdd.groupId, + "module" to dependencyToAdd.artifactId, + ).apply { + dependencyToAdd.version?.let { version -> + put("version", mapOf("requires" to version)) + } + }, + ) + } + } + + @Suppress("UNCHECKED_CAST") + (json["variants"] as? List>)?.forEach { variant -> + (variant["dependencies"] as? MutableList>)?.removeAll { + val group = it["group"] as String + val module = it["module"] as String + shouldExclude(group, module) + } + + writer().use { + it.write(JsonOutput.prettyPrint(JsonOutput.toJson(json))) + } + } + } + } + } + + project.tasks.withType(GenerateModuleMetadata::class.java) + .configureEach { + it.finalizedBy(removeDependenciesFromModuleFileTask.get()) + } + } + + @Suppress("LongMethod") + fun reconfigurePOM( + dependencies: VersionMediatingDependencySet, + shouldExclude: (groupId: String, artifactId: String) -> Boolean, + ) { + project.pluginManager.withPlugin("maven-publish") { + project.extensions.configure(PublishingExtension::class.java) { publishing -> + publishing.publications.withType(MavenPublication::class.java) + .configureEach { pub -> + pub.pom.withXml { + val root = it.asNode() + val dependenciesNodeList = root.get("dependencies") as NodeList + val dependenciesNode = dependenciesNodeList.first() as groovy.util.Node + + dependencies.forEach { dependencyToAdd -> + val childTags = + mutableMapOf( + "groupId" to dependencyToAdd.groupId, + "artifactId" to dependencyToAdd.artifactId, + "scope" to dependencyToAdd.scope, + "optional" to dependencyToAdd.optional.toString(), + ) + + if (dependencyToAdd.version?.isNotBlank() == true) { + childTags["version"] = dependencyToAdd.version + } + + dependenciesNode.appendNode("dependency").let { newDepNode -> + childTags.forEach { (tagName, tagValue) -> + newDepNode.appendNode(tagName, tagValue) + } + } + } + + dependenciesNode.children() + .filterIsInstance() + .filter { dependency -> + val groupId = (dependency["groupId"] as NodeList).text() + val artifactId = (dependency["artifactId"] as NodeList).text() + shouldExclude(groupId, artifactId) + } + .forEach { dependency -> + dependenciesNode.remove(dependency) + } + } + } + } + } + } +} From 0692a9aa66cb1e6e411d6a9caf2b77fde5c2d03b Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 4 Sep 2026 11:46:43 +0200 Subject: [PATCH 04/17] feat(bgp): add dynamic-version/versionless dependency filter Co-Authored-By: Claude Sonnet 5 --- .../shared/DependencyPublishability.kt | 16 ++++++++ .../shared/DependencyPublishabilityTest.kt | 38 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyPublishability.kt create mode 100644 gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyPublishability.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyPublishability.kt new file mode 100644 index 00000000..8af425c6 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyPublishability.kt @@ -0,0 +1,16 @@ +package com.callstack.react.brownfield.shared + +/** + * Whether [dependency] is safe to publish as-is in a POM/Gradle Module Metadata dependency + * entry. Rejects dynamic (`+`) versions and missing/blank versions — both produce either a + * non-reproducible resolution for consumers or (for a blank version) a `` node + * with no version at all, which is exactly the shape of problem the pre-existing + * `kotlin-build-tools-impl` entry in [Constants.BROWNFIELD_EXPO_TRANSITIVE_DEPS_ARTIFACTS_BLACKLIST] + * exists to work around on the Expo side. + */ +fun isPublishableCoordinate(dependency: DependencyInfo): Boolean { + val version = dependency.version + if (version.isNullOrBlank()) return false + if (version.contains("+")) return false + return true +} diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt new file mode 100644 index 00000000..c42cbe9b --- /dev/null +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt @@ -0,0 +1,38 @@ +package com.callstack.react.brownfield.shared + +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DependencyPublishabilityTest { + + @Test + fun `rejects a fully dynamic version`() { + val dep = DependencyInfo("com.facebook.react", "react-native", "+", "compile", false) + assertFalse(isPublishableCoordinate(dep)) + } + + @Test + fun `rejects a partially dynamic version`() { + val dep = DependencyInfo("androidx.core", "core-ktx", "1.+", "compile", false) + assertFalse(isPublishableCoordinate(dep)) + } + + @Test + fun `rejects a null version`() { + val dep = DependencyInfo("com.facebook.react", "react-android", null, "compile", false) + assertFalse(isPublishableCoordinate(dep)) + } + + @Test + fun `rejects a blank version`() { + val dep = DependencyInfo("com.facebook.react", "react-android", " ", "compile", false) + assertFalse(isPublishableCoordinate(dep)) + } + + @Test + fun `accepts a normal pinned version`() { + val dep = DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false) + assertTrue(isPublishableCoordinate(dep)) + } +} From 13811316ce03af96ed7d99d710625cf89e164761 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 4 Sep 2026 11:48:22 +0200 Subject: [PATCH 05/17] fix(bgp-task-3): remove blank line from test class body (ktlint compliance) Co-Authored-By: Claude Sonnet 5 --- .../react/brownfield/shared/DependencyPublishabilityTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt index c42cbe9b..4eadc4a0 100644 --- a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt @@ -5,7 +5,6 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class DependencyPublishabilityTest { - @Test fun `rejects a fully dynamic version`() { val dep = DependencyInfo("com.facebook.react", "react-native", "+", "compile", false) From 68a4f6a506a75132c2cf82df4c786bfea1657870 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 4 Sep 2026 11:53:16 +0200 Subject: [PATCH 06/17] feat(bgp): add RncTransitiveDependencyDiscoverer Co-Authored-By: Claude Sonnet 5 --- .../RncTransitiveDependencyDiscoverer.kt | 72 +++++++++++++++++++ .../RncTransitiveDependencyDiscovererTest.kt | 57 +++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt create mode 100644 gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt new file mode 100644 index 00000000..72922cfc --- /dev/null +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt @@ -0,0 +1,72 @@ +package com.callstack.react.brownfield.artifacts + +import com.callstack.react.brownfield.shared.DependencyInfo +import com.callstack.react.brownfield.shared.UnresolvedArtifactInfo +import com.callstack.react.brownfield.shared.VersionMediatingDependencySet +import com.callstack.react.brownfield.shared.isPublishableCoordinate +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import org.gradle.api.internal.artifacts.dependencies.DefaultProjectDependency + +/** + * Discovers the real third-party (non-project) dependencies of the native module projects + * embedded into the fat AAR, for publication into the AAR's own POM/module metadata. + * Mirrors ExpoPublishingHelper.appendExpoTransitiveDependenciesFromGradle for the RNC-CLI + * ("vanilla") path — see docs/superpowers/specs/2026-09-04-bgp-transitive-dependencies-design.md §4.2. + */ +class RncTransitiveDependencyDiscoverer(private val project: Project) { + private val configNames = listOf("implementation", "api", "runtimeOnly") + + fun discover(artifacts: List): VersionMediatingDependencySet { + val discovered = VersionMediatingDependencySet() + + artifacts + .filter { it.isExpoPublishDependency != true } + .forEach { artifact -> discoverFromArtifact(artifact, discovered) } + + return discovered + } + + private fun discoverFromArtifact( + artifact: UnresolvedArtifactInfo, + discovered: VersionMediatingDependencySet, + ) { + val moduleProject = project.rootProject.findProject(":${artifact.moduleName}") ?: return + configNames.forEach { configName -> + val configuration = moduleProject.configurations.findByName(configName) ?: return@forEach + collectFromConfiguration(configuration, discovered) + } + } + + private fun collectFromConfiguration( + configuration: Configuration, + discovered: VersionMediatingDependencySet, + ) { + configuration.dependencies.forEach { dependency -> + if (dependency is DefaultProjectDependency) return@forEach + val group = dependency.group ?: return@forEach + + val info = DependencyInfo.fromGradleDep(group, dependency.name, dependency.version) + if (!isPublishableCoordinate(info)) return@forEach + if (isAlreadyDeclaredByConsumer(group, dependency.name)) return@forEach + + discovered.add(info) + } + } + + /** + * Injection-time dedup only (spec §4.3(B)) — NOT the removal predicate passed to + * PublishingMetadataInjector. Prevents double-declaring a coordinate the consumer + * project (e.g. BrownfieldLib) already declares explicitly itself. + */ + private fun isAlreadyDeclaredByConsumer( + groupId: String, + artifactId: String, + ): Boolean { + return configNames.any { configName -> + project.configurations.findByName(configName)?.dependencies?.any { + it.group == groupId && it.name == artifactId + } ?: false + } + } +} diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt new file mode 100644 index 00000000..6ca59e5c --- /dev/null +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt @@ -0,0 +1,57 @@ +package com.callstack.react.brownfield.artifacts + +import com.callstack.react.brownfield.shared.DependencyInfo +import com.callstack.react.brownfield.shared.UnresolvedArtifactInfo +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RncTransitiveDependencyDiscovererTest { + @Test + fun `discovers a module's direct external dependencies, skipping project deps and non-publishable coordinates`() { + val root = ProjectBuilder.builder().build() + val consumer = ProjectBuilder.builder().withParent(root).withName("BrownfieldLib").build() + val embeddedModule = ProjectBuilder.builder().withParent(root).withName("react-native-fake-module").build() + val siblingProject = ProjectBuilder.builder().withParent(root).withName("some-other-module").build() + + embeddedModule.configurations.create("implementation") + embeddedModule.configurations.create("api") + embeddedModule.configurations.create("runtimeOnly") + + embeddedModule.dependencies.add("implementation", "androidx.appcompat:appcompat:1.7.1") + embeddedModule.dependencies.add("api", "com.facebook.react:hermes-android:0.87.0") + embeddedModule.dependencies.add("implementation", "com.facebook.react:react-native:+") + embeddedModule.dependencies.add( + "implementation", + embeddedModule.dependencies.project(mapOf("path" to siblingProject.path)), + ) + + consumer.configurations.create("api") + consumer.dependencies.add("api", "com.facebook.react:hermes-android:0.87.0") + + val artifacts = + listOf( + UnresolvedArtifactInfo( + moduleGroup = root.name, + moduleName = "react-native-fake-module", + moduleVersion = "unspecified", + file = null, + isExpoPublishDependency = false, + ), + ) + + val discovered = RncTransitiveDependencyDiscoverer(consumer).discover(artifacts) + + assertEquals(1, discovered.size) + assertTrue( + discovered.contains( + DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false), + ), + ) + assertFalse(discovered.contains(DependencyInfo("com.facebook.react", "hermes-android", "0.87.0", "compile", false))) + assertFalse(discovered.contains(DependencyInfo("com.facebook.react", "react-native", "+", "compile", false))) + assertFalse(discovered.contains(DependencyInfo(root.name, "some-other-module", "unspecified", "compile", false))) + } +} From 87de5f080b818754b6fba295ef66da7e0f65621f Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 4 Sep 2026 12:02:48 +0200 Subject: [PATCH 07/17] test(bgp): cover runtimeOnly discovery in RncTransitiveDependencyDiscovererTest Adds a real dependency to the runtimeOnly configuration and asserts it is discovered, closing a mutation-testing gap where deleting "runtimeOnly" from configNames left the test green. Co-Authored-By: Claude Sonnet 5 --- .../artifacts/RncTransitiveDependencyDiscovererTest.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt index 6ca59e5c..484bbcb8 100644 --- a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt @@ -23,6 +23,7 @@ class RncTransitiveDependencyDiscovererTest { embeddedModule.dependencies.add("implementation", "androidx.appcompat:appcompat:1.7.1") embeddedModule.dependencies.add("api", "com.facebook.react:hermes-android:0.87.0") embeddedModule.dependencies.add("implementation", "com.facebook.react:react-native:+") + embeddedModule.dependencies.add("runtimeOnly", "androidx.annotation:annotation:1.9.1") embeddedModule.dependencies.add( "implementation", embeddedModule.dependencies.project(mapOf("path" to siblingProject.path)), @@ -44,12 +45,17 @@ class RncTransitiveDependencyDiscovererTest { val discovered = RncTransitiveDependencyDiscoverer(consumer).discover(artifacts) - assertEquals(1, discovered.size) + assertEquals(2, discovered.size) assertTrue( discovered.contains( DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false), ), ) + assertTrue( + discovered.contains( + DependencyInfo("androidx.annotation", "annotation", "1.9.1", "compile", false), + ), + ) assertFalse(discovered.contains(DependencyInfo("com.facebook.react", "hermes-android", "0.87.0", "compile", false))) assertFalse(discovered.contains(DependencyInfo("com.facebook.react", "react-native", "+", "compile", false))) assertFalse(discovered.contains(DependencyInfo(root.name, "some-other-module", "unspecified", "compile", false))) From 3b48e703c042185f0a0ccd64af9de56a4f316890 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 4 Sep 2026 12:04:55 +0200 Subject: [PATCH 08/17] feat(bgp): add includeTransitiveDependencies extension option --- .../com/callstack/react/brownfield/utils/Extension.kt | 11 +++++++++++ .../callstack/react/brownfield/utils/ExtensionTest.kt | 11 +++++++++++ 2 files changed, 22 insertions(+) create mode 100644 gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt index 5bebbfac..524edba4 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt @@ -53,4 +53,15 @@ open class Extension { * listOf("libdatadog-ndk.so") */ var ignoreEmbeddedLibs = listOf() + + /** + * Whether to discover and publish the transitive (third-party) dependencies of + * embedded native modules into the generated POM and Gradle Module Metadata, so a + * consuming native app resolves them automatically via Maven/Gradle instead of having + * to declare them by hand. + * + * Default is `false`. Expo projects already get equivalent behavior unconditionally; + * this option only affects non-Expo (RNC CLI) projects. + */ + var includeTransitiveDependencies = false } diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt new file mode 100644 index 00000000..92fb3776 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt @@ -0,0 +1,11 @@ +package com.callstack.react.brownfield.utils + +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse + +class ExtensionTest { + @Test + fun `includeTransitiveDependencies defaults to false`() { + assertFalse(Extension().includeTransitiveDependencies) + } +} From 94f917c7a1b896e285c712f0bba43086d5f4da91 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 4 Sep 2026 12:08:17 +0200 Subject: [PATCH 09/17] feat(bgp): wire RNC transitive dependency discovery into RNBrownfieldPlugin --- .../brownfield/expo/ExpoPublishingHelper.kt | 5 --- .../brownfield/plugin/RNBrownfieldPlugin.kt | 39 ++++++++++++++++++- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt index 74cdacee..42d1f52d 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt @@ -7,7 +7,6 @@ import com.callstack.react.brownfield.expo.utils.asExpoGradleProjectProjection import com.callstack.react.brownfield.shared.Constants import com.callstack.react.brownfield.shared.DependencyInfo import com.callstack.react.brownfield.shared.Logging -import com.callstack.react.brownfield.shared.PublishingMetadataInjector import com.callstack.react.brownfield.shared.VersionMediatingDependencySet import org.gradle.api.Project import org.w3c.dom.Node @@ -50,10 +49,6 @@ open class ExpoPublishingHelper(val brownfieldAppProject: Project) { ) } - val injector = PublishingMetadataInjector(brownfieldAppProject) - injector.reconfigurePOM(expoTransitiveDependencies, ::shouldExcludeDependency) - injector.reconfigureGradleModuleJSON(expoTransitiveDependencies, ::shouldExcludeDependency) - return discoverableExpoProjects } diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt index a408f586..1e216a74 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt @@ -3,6 +3,7 @@ package com.callstack.react.brownfield.plugin import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.api.variant.LibraryVariant import com.callstack.react.brownfield.artifacts.ArtifactsResolver +import com.callstack.react.brownfield.artifacts.RncTransitiveDependencyDiscoverer import com.callstack.react.brownfield.expo.ExpoPublishingHelper import com.callstack.react.brownfield.expo.utils.ExpoGradleProjectProjection import com.callstack.react.brownfield.processors.AssetTaskProcessor @@ -17,7 +18,9 @@ import com.callstack.react.brownfield.processors.VariantTaskProvider import com.callstack.react.brownfield.shared.BaseProject import com.callstack.react.brownfield.shared.Constants.PROJECT_ID import com.callstack.react.brownfield.shared.Logging +import com.callstack.react.brownfield.shared.PublishingMetadataInjector import com.callstack.react.brownfield.shared.UnresolvedArtifactInfo +import com.callstack.react.brownfield.shared.VersionMediatingDependencySet import com.callstack.react.brownfield.utils.AndroidArchiveLibrary import com.callstack.react.brownfield.utils.DirectoryManager import com.callstack.react.brownfield.utils.Extension @@ -54,8 +57,9 @@ class RNBrownfieldPlugin : Plugin { } var expoProjects = listOf() + var expoPublishingHelper: ExpoPublishingHelper? = null if (this.isExpoProject) { - val expoPublishingHelper = ExpoPublishingHelper(brownfieldAppProject = project) + expoPublishingHelper = ExpoPublishingHelper(brownfieldAppProject = project) expoProjects = expoPublishingHelper.configure() } @@ -65,6 +69,39 @@ class RNBrownfieldPlugin : Plugin { val artifactsResolver = ArtifactsResolver(project, isExpoProject) val artifacts = artifactsResolver.processDefaultDependencies(expoProjects) + /** + * Discovers and publishes transitive (third-party) dependencies of embedded + * native modules into this project's POM/Gradle Module Metadata, so a consuming + * native app resolves them automatically. Deferred to afterEvaluate: `extension` + * is created eagerly in initializers() above, before the build script's own + * `reactBrownfield { }` block has configured it — reading `extension.includeTransitiveDependencies` + * any earlier than this would always observe its default `false`. + * + * See docs/superpowers/specs/2026-09-04-bgp-transitive-dependencies-design.md §4.4. + */ + project.afterEvaluate { + val transitiveDeps = VersionMediatingDependencySet() + + if (isExpoProject && expoPublishingHelper != null) { + transitiveDeps.addAll(expoPublishingHelper.discoverAllExpoTransitiveDependencies(expoProjects)) + } + if (extension.includeTransitiveDependencies) { + transitiveDeps.addAll(RncTransitiveDependencyDiscoverer(project).discover(artifacts)) + } + + if (isExpoProject || extension.includeTransitiveDependencies) { + val embeddedModuleNames = artifacts.map { it.moduleName }.toSet() + val removalPredicate: (String, String) -> Boolean = { groupId, artifactId -> + (expoPublishingHelper?.shouldExcludeDependency(groupId, artifactId) ?: (groupId == project.rootProject.name)) || + embeddedModuleNames.contains(artifactId) + } + + val injector = PublishingMetadataInjector(project) + injector.reconfigurePOM(transitiveDeps, removalPredicate) + injector.reconfigureGradleModuleJSON(transitiveDeps, removalPredicate) + } + } + val variantTaskProvider = VariantTaskProvider(project) val androidComponents = project.extensions.getByType(LibraryAndroidComponentsExtension::class.java) From 503856eeb2a96b9549d5ac5bfd90150ea5cc152a Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 4 Sep 2026 12:18:10 +0200 Subject: [PATCH 10/17] docs: describe includeTransitiveDependencies option, flag task-name collision Co-Authored-By: Claude Sonnet 5 --- docs/docs/docs/getting-started/android.mdx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/docs/docs/getting-started/android.mdx b/docs/docs/docs/getting-started/android.mdx index 0f55091c..61865950 100644 --- a/docs/docs/docs/getting-started/android.mdx +++ b/docs/docs/docs/getting-started/android.mdx @@ -313,6 +313,8 @@ publishing { } } +Skip this task-registration block entirely if you plan to use the `includeTransitiveDependencies` option described below. + val moduleBuildDir: Directory = layout.buildDirectory.get() tasks.register("removeDependenciesFromModuleFile") { @@ -332,6 +334,18 @@ tasks.named("generateMetadataFileForMavenAarPublication") { } ``` +> **Transitive dependencies:** the snippet above strips embedded-module entries from the generated POM by hand. If you don't need your embedded modules' own third-party dependencies to be resolvable by the app consuming this AAR, this manual snippet is all you need — skip the rest of this note. +> +> If you *do* want that (e.g. your embedded native modules pull in AndroidX libraries the consuming app should get automatically via Maven), set `includeTransitiveDependencies = true` in this module's `reactBrownfield { }` block instead of hand-rolling the JSON-manipulation task below — the plugin now performs the equivalent removal *and* injects your modules' real dependencies for you: +> +> ```kotlin +> reactBrownfield { +> includeTransitiveDependencies = true +> } +> ``` +> +> Do **not** combine this option with a hand-written `tasks.register("removeDependenciesFromModuleFile")` in the same module — the plugin registers a task with that exact name once the option is enabled, and Gradle throws `task 'removeDependenciesFromModuleFile' already exists` if both are present. + ## 7. Create a Brownfield Configuration Create `brownfield.config.json` in your project root: From a26fa2c36cb1bac7f514ab6bb9dad0ce5913be93 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 4 Sep 2026 12:19:48 +0200 Subject: [PATCH 11/17] fix: move warning sentence outside code fence in docs The sentence 'Skip this task-registration block entirely...' was incorrectly placed inside the kotlin code fence. Moved it outside as a separate paragraph before the fence opens to ensure proper rendering and syntax highlighting. Co-Authored-By: Claude Sonnet 5 --- docs/docs/docs/getting-started/android.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/docs/getting-started/android.mdx b/docs/docs/docs/getting-started/android.mdx index 61865950..57095aae 100644 --- a/docs/docs/docs/getting-started/android.mdx +++ b/docs/docs/docs/getting-started/android.mdx @@ -282,6 +282,8 @@ plugins { Add publishing configuration: +Skip this task-registration block entirely if you plan to use the `includeTransitiveDependencies` option described below. + ```kotlin import groovy.json.JsonOutput import groovy.json.JsonSlurper @@ -313,8 +315,6 @@ publishing { } } -Skip this task-registration block entirely if you plan to use the `includeTransitiveDependencies` option described below. - val moduleBuildDir: Directory = layout.buildDirectory.get() tasks.register("removeDependenciesFromModuleFile") { From 0f5b7b85dd1d3b341b2f5f5d95e282ac3b8307df Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 4 Sep 2026 12:20:04 +0200 Subject: [PATCH 12/17] docs(task-7): document code fence fix in report Added comprehensive documentation of the critical fix that relocated the warning sentence outside the kotlin code fence to ensure proper rendering and syntax highlighting. Co-Authored-By: Claude Sonnet 5 --- .../task-7-report.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 .superpowers/sdd/2026-09-04-bgp-transitive-dependencies/task-7-report.md diff --git a/.superpowers/sdd/2026-09-04-bgp-transitive-dependencies/task-7-report.md b/.superpowers/sdd/2026-09-04-bgp-transitive-dependencies/task-7-report.md new file mode 100644 index 00000000..c6b4785d --- /dev/null +++ b/.superpowers/sdd/2026-09-04-bgp-transitive-dependencies/task-7-report.md @@ -0,0 +1,156 @@ +# Task 7: Fix the getting-started docs task-name collision - Report + +## What was done + +Successfully updated the Android getting-started documentation to address the task-name collision when both the manual `tasks.register("removeDependenciesFromModuleFile")` block and the new `includeTransitiveDependencies` option are used together. + +### Implementation details + +**File modified:** `docs/docs/docs/getting-started/android.mdx` + +**Changes made:** + +1. **Added warning sentence (line 316):** Inserted a plain-text sentence before the existing task registration block: + - "Skip this task-registration block entirely if you plan to use the `includeTransitiveDependencies` option described below." + +2. **Added blockquote documentation (after line 335):** Inserted a comprehensive blockquote explaining: + - The purpose of the manual task (stripping embedded-module dependencies from the POM) + - When to skip it (if using the new option) + - The alternative approach using `includeTransitiveDependencies = true` + - A warning against combining both approaches (which causes the `task ... already exists` error) + - A code example showing the proper `reactBrownfield { }` configuration + +3. **Preserved existing content:** The original `tasks.register("removeDependenciesFromModuleFile")` block and its `finalizedBy` wiring remain unchanged and in place, since users who don't use the new option still need this manual configuration. + +## Exact diff + +```diff +diff --git a/docs/docs/docs/getting-started/android.mdx b/docs/docs/docs/getting-started/android.mdx +index 0f55091..6186595 100644 +--- a/docs/docs/docs/getting-started/android.mdx ++++ b/docs/docs/docs/getting-started/android.mdx +@@ -313,6 +313,8 @@ publishing { + } + } + ++Skip this task-registration block entirely if you plan to use the `includeTransitiveDependencies` option described below. ++ + val moduleBuildDir: Directory = layout.buildDirectory.get() + + tasks.register("removeDependenciesFromModuleFile") { +@@ -332,6 +334,18 @@ tasks.named("generateMetadataFileForMavenAarPublication") { + } + ``` + ++> **Transitive dependencies:** the snippet above strips embedded-module entries from the generated POM by hand. If you don't need your embedded modules' own third-party dependencies to be resolvable by the app consuming this AAR, this manual snippet is all you need — skip the rest of this note. ++> ++> If you *do* want that (e.g. your embedded native modules pull in AndroidX libraries the consuming app should get automatically via Maven), set `includeTransitiveDependencies = true` in this module's `reactBrownfield { }` block instead of hand-rolling the JSON-manipulation task below — the plugin now performs the equivalent removal *and* injects your modules' real dependencies for you: ++> ++> ```kotlin ++> reactBrownfield { ++> includeTransitiveDependencies = true ++> } ++> ``` ++> ++> Do **not** combine this option with a hand-written `tasks.register("removeDependenciesFromModuleFile")` in the same module — the plugin registers a task with that exact name once the option is enabled, and Gradle throws `task 'removeDependenciesFromModuleFile' already exists` if both are present. ++ + ## 7. Create a Brownfield Configuration + + Create `brownfield.config.json` in your project root: +``` + +## Verification performed + +Since Node.js and Yarn are not available in this sandbox environment, a manual syntax verification was performed instead of running the actual build command (`cd docs && yarn build`). + +### Manual MDX syntax verification checks: + +1. **Code fence integrity:** + - Opening fence (line 285): ` ```kotlin ` ✓ + - Closing fence (line 335): ` ``` ` ✓ + - Nested code fence within blockquote (lines 341, 345): ` ```kotlin ` and ` ``` ` ✓ + - All fences properly opened and closed + +2. **Blockquote structure:** + - All blockquote lines (337-347) correctly marked with `>` at line start ✓ + - Blockquote properly nested code example ✓ + - Blockquote naturally terminates before next heading (line 349: `## 7. Create a Brownfield Configuration`) ✓ + +3. **Markdown formatting:** + - Bold formatting: `**Transitive dependencies:**`, `**not**` ✓ + - Code formatting: backticks for inline code and ` ``` ` fences ✓ + - Inline backtick for `includeTransitiveDependencies`, `tasks.register("removeDependenciesFromModuleFile")`, etc. ✓ + - Proper em-dash characters (`—`) in long sentences ✓ + +4. **Overall structure:** + - Warning sentence placed before task block ✓ + - Original task block preserved unchanged ✓ + - Documentation blockquote placed after task block ✓ + - Next section heading properly separated ✓ + - No unclosed fences or mismatched markers ✓ + +All MDX syntax elements are correctly formed. No obvious syntax errors were found. + +## Commit information + +**Commit hash:** `503856e` +**Commit message:** `docs: describe includeTransitiveDependencies option, flag task-name collision` +**Branch:** `feat/bgp-transitive-dependencies-rnc` + +## Critical fix applied + +### Issue identified +During initial review, a critical structural error was discovered: the warning sentence "Skip this task-registration block entirely if you plan to use the `includeTransitiveDependencies` option described below." was incorrectly placed **inside** the `````kotlin` code fence (opened at line 287, closed at line 335 in the corrected version). + +This caused three problems: +1. The sentence rendered as Kotlin code with broken syntax highlighting +2. The backticks around `includeTransitiveDependencies` did not render as inline code—they appeared as literal backtick characters +3. The code block was contaminated with non-code prose + +### Fix applied +Commit `a26fa2c`: The warning sentence was relocated from inside the fence (original position between `}` and `val moduleBuildDir...`) to **outside the fence**, as a standalone paragraph immediately after the "Add publishing configuration:" heading and before the ` ```kotlin ` fence opens. + +**New structure (correct):** +``` +Add publishing configuration: + +Skip this task-registration block entirely if you plan to use the `includeTransitiveDependencies` option described below. + +```kotlin +[pure Kotlin code, no prose] +``` +``` + +### Verification of fix +Grep output confirming fence boundaries: +``` +283: Add publishing configuration: +285: Skip this task-registration block entirely... (OUTSIDE fence) +287: ```kotlin (fence opens) +288-334: [pure Kotlin code] +335: ``` (fence closes) +337: > **Transitive dependencies:** (blockquote follows) +341: > ```kotlin (nested fence within blockquote) +345: > ``` (nested fence closes) +``` + +The warning sentence is now genuine prose outside any code fence, ensuring: +- Proper Markdown rendering +- Inline code backticks render correctly +- Code fence contains only valid Kotlin syntax +- Clear, readable documentation structure + +## Testing notes + +The documentation changes directly address the issue described in Task 2: when a user enables `includeTransitiveDependencies = true`, the plugin registers a task named `removeDependenciesFromModuleFile`. If a user follows both the old manual instructions AND enables the new option, Gradle throws an error. This update now: + +1. Makes the collision clear to users +2. Explains when to use each approach +3. Prevents accidental misconfiguration +4. Maintains backward compatibility by keeping the manual approach available for those who don't use the new option + +## Final commit + +**Commit hash:** `a26fa2c` +**Commit message:** `fix: move warning sentence outside code fence in docs` +**Branch:** `feat/bgp-transitive-dependencies-rnc` From ed31d7e453c186f1f46c048529389fceb2095815 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 4 Sep 2026 12:23:51 +0200 Subject: [PATCH 13/17] feat(rnapp): enable includeTransitiveDependencies, remove hand-rolled POM filter Turns on the plugin's includeTransitiveDependencies option in the RNApp demo's BrownfieldLib module and deletes the hand-rolled pom.withXml / module.json post-processing task that predates this feature, now that the plugin itself strips embedded-module entries and injects real transitive dependencies. --- .../android/BrownfieldLib/build.gradle.kts | 46 ++----------------- 1 file changed, 4 insertions(+), 42 deletions(-) diff --git a/apps/RNApp/android/BrownfieldLib/build.gradle.kts b/apps/RNApp/android/BrownfieldLib/build.gradle.kts index e876cc24..35f566c0 100644 --- a/apps/RNApp/android/BrownfieldLib/build.gradle.kts +++ b/apps/RNApp/android/BrownfieldLib/build.gradle.kts @@ -1,6 +1,3 @@ -import groovy.json.JsonOutput -import groovy.json.JsonSlurper - plugins { id("com.android.library") id("org.jetbrains.kotlin.android") @@ -18,22 +15,6 @@ publishing { afterEvaluate { from(components.getByName("default")) } - - pom { - withXml { - /** - * As a result of `from(components.getByName("default"))` all of the project - * dependencies are added to `pom.xml` file. We do not need the react-native - * third party dependencies to be a part of it as we embed those dependencies. - */ - val dependenciesNode = - (asNode().get("dependencies") as groovy.util.NodeList).first() as groovy.util.Node - dependenciesNode.children() - .filterIsInstance() - .filter { (it.get("groupId") as groovy.util.NodeList).text() == rootProject.name } - .forEach { dependenciesNode.remove(it) } - } - } } } @@ -42,33 +23,14 @@ publishing { } } -val moduleBuildDir: Directory = layout.buildDirectory.get() - -/** - * As a result of `from(components.getByName("default"))` all of the project - * dependencies are added to `module.json` file. We do not need the react-native - * third party dependencies to be a part of it as we embed those dependencies. - */ -tasks.register("removeDependenciesFromModuleFile") { - doLast { - file("$moduleBuildDir/publications/mavenAar/module.json").run { - val json = inputStream().use { JsonSlurper().parse(it) as Map } - (json["variants"] as? List>)?.forEach { variant -> - (variant["dependencies"] as? MutableList>)?.removeAll { it["group"] == rootProject.name } - } - writer().use { it.write(JsonOutput.prettyPrint(JsonOutput.toJson(json))) } - } - } -} - -tasks.named("generateMetadataFileForMavenAarPublication") { - finalizedBy("removeDependenciesFromModuleFile") -} - react { autolinkLibrariesWithApp() } +reactBrownfield { + includeTransitiveDependencies = true +} + android { namespace = "com.rnapp.brownfieldlib" compileSdk = 37 From 5a71da0208438b55c6a8c91d3f163e97b2da872b Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 4 Sep 2026 12:40:28 +0200 Subject: [PATCH 14/17] fix: apply final review fixes for BGP transitive dependencies branch - ci: add gradle-plugins path filter to Expo Android road-test job gates - ci: run gradle-plugins unit tests in the ktlint/detekt lint workflow - docs: split publishing/task-registration code fences so the "skip this block" note describes only the skippable part, and fix a stale below/above reference - plugin: tighten removalPredicate to require matching group AND artifact name, avoiding over-exclusion of unrelated third-party POM entries - plugin: restore diagnostic Logging.log() calls at the centralized transitive-dependency merge/injection call site - untrack accidentally-committed task-7-report.md workspace artifact and ignore .superpowers/ going forward Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 4 + .github/workflows/gradle-plugin-lint.yml | 4 + .gitignore | 3 + .../task-7-report.md | 156 ------------------ docs/docs/docs/getting-started/android.mdx | 10 +- .../brownfield/plugin/RNBrownfieldPlugin.kt | 20 ++- 6 files changed, 33 insertions(+), 164 deletions(-) delete mode 100644 .superpowers/sdd/2026-09-04-bgp-transitive-dependencies/task-7-report.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 229ba5dc..bc0c9acc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,6 +143,7 @@ jobs: needs.filter.outputs.expo56 == 'true' || needs.filter.outputs.androidapp == 'true' || needs.filter.outputs.packages == 'true' || + needs.filter.outputs.gradle-plugins == 'true' || needs.filter.outputs.ci == 'true' ) && (needs.build-lint.result == 'success' || needs.build-lint.result == 'skipped') @@ -172,6 +173,7 @@ jobs: needs.filter.outputs.expo56 == 'true' || needs.filter.outputs.androidapp == 'true' || needs.filter.outputs.packages == 'true' || + needs.filter.outputs.gradle-plugins == 'true' || needs.filter.outputs.ci == 'true' ) && (needs.build-lint.result == 'success' || needs.build-lint.result == 'skipped') && @@ -202,6 +204,7 @@ jobs: needs.filter.outputs.expo57 == 'true' || needs.filter.outputs.androidapp == 'true' || needs.filter.outputs.packages == 'true' || + needs.filter.outputs.gradle-plugins == 'true' || needs.filter.outputs.ci == 'true' ) && (needs.build-lint.result == 'success' || needs.build-lint.result == 'skipped') @@ -231,6 +234,7 @@ jobs: needs.filter.outputs.expo57 == 'true' || needs.filter.outputs.androidapp == 'true' || needs.filter.outputs.packages == 'true' || + needs.filter.outputs.gradle-plugins == 'true' || needs.filter.outputs.ci == 'true' ) && (needs.build-lint.result == 'success' || needs.build-lint.result == 'skipped') && diff --git a/.github/workflows/gradle-plugin-lint.yml b/.github/workflows/gradle-plugin-lint.yml index 81298b6e..f0d2de0c 100644 --- a/.github/workflows/gradle-plugin-lint.yml +++ b/.github/workflows/gradle-plugin-lint.yml @@ -29,3 +29,7 @@ jobs: - name: Run KtLint working-directory: gradle-plugins/react/brownfield run: ./gradlew ktlintCheck --no-daemon --stacktrace + + - name: Run Unit Tests + working-directory: gradle-plugins/react/brownfield + run: ./gradlew test --no-daemon --stacktrace diff --git a/.gitignore b/.gitignore index a05d1915..5eac6ba7 100644 --- a/.gitignore +++ b/.gitignore @@ -91,4 +91,7 @@ packages/react-native-brownfield/ios/swiftpm/.build/ # skillgym .skillgym-results/ +# internal process artifacts (should never be part of the PR diff) +.superpowers/ + .cursor diff --git a/.superpowers/sdd/2026-09-04-bgp-transitive-dependencies/task-7-report.md b/.superpowers/sdd/2026-09-04-bgp-transitive-dependencies/task-7-report.md deleted file mode 100644 index c6b4785d..00000000 --- a/.superpowers/sdd/2026-09-04-bgp-transitive-dependencies/task-7-report.md +++ /dev/null @@ -1,156 +0,0 @@ -# Task 7: Fix the getting-started docs task-name collision - Report - -## What was done - -Successfully updated the Android getting-started documentation to address the task-name collision when both the manual `tasks.register("removeDependenciesFromModuleFile")` block and the new `includeTransitiveDependencies` option are used together. - -### Implementation details - -**File modified:** `docs/docs/docs/getting-started/android.mdx` - -**Changes made:** - -1. **Added warning sentence (line 316):** Inserted a plain-text sentence before the existing task registration block: - - "Skip this task-registration block entirely if you plan to use the `includeTransitiveDependencies` option described below." - -2. **Added blockquote documentation (after line 335):** Inserted a comprehensive blockquote explaining: - - The purpose of the manual task (stripping embedded-module dependencies from the POM) - - When to skip it (if using the new option) - - The alternative approach using `includeTransitiveDependencies = true` - - A warning against combining both approaches (which causes the `task ... already exists` error) - - A code example showing the proper `reactBrownfield { }` configuration - -3. **Preserved existing content:** The original `tasks.register("removeDependenciesFromModuleFile")` block and its `finalizedBy` wiring remain unchanged and in place, since users who don't use the new option still need this manual configuration. - -## Exact diff - -```diff -diff --git a/docs/docs/docs/getting-started/android.mdx b/docs/docs/docs/getting-started/android.mdx -index 0f55091..6186595 100644 ---- a/docs/docs/docs/getting-started/android.mdx -+++ b/docs/docs/docs/getting-started/android.mdx -@@ -313,6 +313,8 @@ publishing { - } - } - -+Skip this task-registration block entirely if you plan to use the `includeTransitiveDependencies` option described below. -+ - val moduleBuildDir: Directory = layout.buildDirectory.get() - - tasks.register("removeDependenciesFromModuleFile") { -@@ -332,6 +334,18 @@ tasks.named("generateMetadataFileForMavenAarPublication") { - } - ``` - -+> **Transitive dependencies:** the snippet above strips embedded-module entries from the generated POM by hand. If you don't need your embedded modules' own third-party dependencies to be resolvable by the app consuming this AAR, this manual snippet is all you need — skip the rest of this note. -+> -+> If you *do* want that (e.g. your embedded native modules pull in AndroidX libraries the consuming app should get automatically via Maven), set `includeTransitiveDependencies = true` in this module's `reactBrownfield { }` block instead of hand-rolling the JSON-manipulation task below — the plugin now performs the equivalent removal *and* injects your modules' real dependencies for you: -+> -+> ```kotlin -+> reactBrownfield { -+> includeTransitiveDependencies = true -+> } -+> ``` -+> -+> Do **not** combine this option with a hand-written `tasks.register("removeDependenciesFromModuleFile")` in the same module — the plugin registers a task with that exact name once the option is enabled, and Gradle throws `task 'removeDependenciesFromModuleFile' already exists` if both are present. -+ - ## 7. Create a Brownfield Configuration - - Create `brownfield.config.json` in your project root: -``` - -## Verification performed - -Since Node.js and Yarn are not available in this sandbox environment, a manual syntax verification was performed instead of running the actual build command (`cd docs && yarn build`). - -### Manual MDX syntax verification checks: - -1. **Code fence integrity:** - - Opening fence (line 285): ` ```kotlin ` ✓ - - Closing fence (line 335): ` ``` ` ✓ - - Nested code fence within blockquote (lines 341, 345): ` ```kotlin ` and ` ``` ` ✓ - - All fences properly opened and closed - -2. **Blockquote structure:** - - All blockquote lines (337-347) correctly marked with `>` at line start ✓ - - Blockquote properly nested code example ✓ - - Blockquote naturally terminates before next heading (line 349: `## 7. Create a Brownfield Configuration`) ✓ - -3. **Markdown formatting:** - - Bold formatting: `**Transitive dependencies:**`, `**not**` ✓ - - Code formatting: backticks for inline code and ` ``` ` fences ✓ - - Inline backtick for `includeTransitiveDependencies`, `tasks.register("removeDependenciesFromModuleFile")`, etc. ✓ - - Proper em-dash characters (`—`) in long sentences ✓ - -4. **Overall structure:** - - Warning sentence placed before task block ✓ - - Original task block preserved unchanged ✓ - - Documentation blockquote placed after task block ✓ - - Next section heading properly separated ✓ - - No unclosed fences or mismatched markers ✓ - -All MDX syntax elements are correctly formed. No obvious syntax errors were found. - -## Commit information - -**Commit hash:** `503856e` -**Commit message:** `docs: describe includeTransitiveDependencies option, flag task-name collision` -**Branch:** `feat/bgp-transitive-dependencies-rnc` - -## Critical fix applied - -### Issue identified -During initial review, a critical structural error was discovered: the warning sentence "Skip this task-registration block entirely if you plan to use the `includeTransitiveDependencies` option described below." was incorrectly placed **inside** the `````kotlin` code fence (opened at line 287, closed at line 335 in the corrected version). - -This caused three problems: -1. The sentence rendered as Kotlin code with broken syntax highlighting -2. The backticks around `includeTransitiveDependencies` did not render as inline code—they appeared as literal backtick characters -3. The code block was contaminated with non-code prose - -### Fix applied -Commit `a26fa2c`: The warning sentence was relocated from inside the fence (original position between `}` and `val moduleBuildDir...`) to **outside the fence**, as a standalone paragraph immediately after the "Add publishing configuration:" heading and before the ` ```kotlin ` fence opens. - -**New structure (correct):** -``` -Add publishing configuration: - -Skip this task-registration block entirely if you plan to use the `includeTransitiveDependencies` option described below. - -```kotlin -[pure Kotlin code, no prose] -``` -``` - -### Verification of fix -Grep output confirming fence boundaries: -``` -283: Add publishing configuration: -285: Skip this task-registration block entirely... (OUTSIDE fence) -287: ```kotlin (fence opens) -288-334: [pure Kotlin code] -335: ``` (fence closes) -337: > **Transitive dependencies:** (blockquote follows) -341: > ```kotlin (nested fence within blockquote) -345: > ``` (nested fence closes) -``` - -The warning sentence is now genuine prose outside any code fence, ensuring: -- Proper Markdown rendering -- Inline code backticks render correctly -- Code fence contains only valid Kotlin syntax -- Clear, readable documentation structure - -## Testing notes - -The documentation changes directly address the issue described in Task 2: when a user enables `includeTransitiveDependencies = true`, the plugin registers a task named `removeDependenciesFromModuleFile`. If a user follows both the old manual instructions AND enables the new option, Gradle throws an error. This update now: - -1. Makes the collision clear to users -2. Explains when to use each approach -3. Prevents accidental misconfiguration -4. Maintains backward compatibility by keeping the manual approach available for those who don't use the new option - -## Final commit - -**Commit hash:** `a26fa2c` -**Commit message:** `fix: move warning sentence outside code fence in docs` -**Branch:** `feat/bgp-transitive-dependencies-rnc` diff --git a/docs/docs/docs/getting-started/android.mdx b/docs/docs/docs/getting-started/android.mdx index 57095aae..87913e45 100644 --- a/docs/docs/docs/getting-started/android.mdx +++ b/docs/docs/docs/getting-started/android.mdx @@ -282,8 +282,6 @@ plugins { Add publishing configuration: -Skip this task-registration block entirely if you plan to use the `includeTransitiveDependencies` option described below. - ```kotlin import groovy.json.JsonOutput import groovy.json.JsonSlurper @@ -314,7 +312,11 @@ publishing { mavenLocal() } } +``` +Skip this task-registration block entirely if you plan to use the `includeTransitiveDependencies` option described below. + +```kotlin val moduleBuildDir: Directory = layout.buildDirectory.get() tasks.register("removeDependenciesFromModuleFile") { @@ -334,9 +336,9 @@ tasks.named("generateMetadataFileForMavenAarPublication") { } ``` -> **Transitive dependencies:** the snippet above strips embedded-module entries from the generated POM by hand. If you don't need your embedded modules' own third-party dependencies to be resolvable by the app consuming this AAR, this manual snippet is all you need — skip the rest of this note. +> **Transitive dependencies:** the task-registration block above strips embedded-module entries from the generated POM by hand. If you don't need your embedded modules' own third-party dependencies to be resolvable by the app consuming this AAR, that manual block is all you need — skip the rest of this note. > -> If you *do* want that (e.g. your embedded native modules pull in AndroidX libraries the consuming app should get automatically via Maven), set `includeTransitiveDependencies = true` in this module's `reactBrownfield { }` block instead of hand-rolling the JSON-manipulation task below — the plugin now performs the equivalent removal *and* injects your modules' real dependencies for you: +> If you *do* want that (e.g. your embedded native modules pull in AndroidX libraries the consuming app should get automatically via Maven), set `includeTransitiveDependencies = true` in this module's `reactBrownfield { }` block instead of hand-rolling the JSON-manipulation task above — the plugin now performs the equivalent removal *and* injects your modules' real dependencies for you: > > ```kotlin > reactBrownfield { diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt index 1e216a74..4609e3b3 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt @@ -83,22 +83,34 @@ class RNBrownfieldPlugin : Plugin { val transitiveDeps = VersionMediatingDependencySet() if (isExpoProject && expoPublishingHelper != null) { - transitiveDeps.addAll(expoPublishingHelper.discoverAllExpoTransitiveDependencies(expoProjects)) + val expoTransitiveDeps = expoPublishingHelper.discoverAllExpoTransitiveDependencies(expoProjects) + Logging.log("Merged ${expoTransitiveDeps.size} transitive dependencies discovered from Expo") + transitiveDeps.addAll(expoTransitiveDeps) } if (extension.includeTransitiveDependencies) { - transitiveDeps.addAll(RncTransitiveDependencyDiscoverer(project).discover(artifacts)) + val rncTransitiveDeps = RncTransitiveDependencyDiscoverer(project).discover(artifacts) + Logging.log("Merged ${rncTransitiveDeps.size} transitive dependencies discovered by the RNC discoverer") + transitiveDeps.addAll(rncTransitiveDeps) } if (isExpoProject || extension.includeTransitiveDependencies) { - val embeddedModuleNames = artifacts.map { it.moduleName }.toSet() + Logging.log( + "Total of ${transitiveDeps.size} unique transitive dependencies merged for POM/module.json injection", + ) + val removalPredicate: (String, String) -> Boolean = { groupId, artifactId -> (expoPublishingHelper?.shouldExcludeDependency(groupId, artifactId) ?: (groupId == project.rootProject.name)) || - embeddedModuleNames.contains(artifactId) + artifacts.any { it.moduleGroup == groupId && it.moduleName == artifactId } } val injector = PublishingMetadataInjector(project) injector.reconfigurePOM(transitiveDeps, removalPredicate) injector.reconfigureGradleModuleJSON(transitiveDeps, removalPredicate) + Logging.log("PublishingMetadataInjector ran: injected merged transitive dependencies into POM and Gradle Module Metadata") + } else { + Logging.log( + "PublishingMetadataInjector skipped: project is not an Expo project and includeTransitiveDependencies is disabled", + ) } } From 23ac2c95d73f5eeba3973db141291f8d69065f1e Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Wed, 9 Sep 2026 08:22:02 +0200 Subject: [PATCH 15/17] fix(bgp): correct runtime -> runtimeOnly config name in Expo fallback discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExpoPublishingHelper.appendExpoTransitiveDependenciesFromGradle enumerated "implementation", "api", "runtime" — but plain "runtime" isn't a real configuration on modern AGP/Gradle library modules (legacy Java-plugin name; the correct one is "runtimeOnly"). That leg has silently been a no-op since this code was introduced (#223). Found while building the equivalent RNC-CLI discoverer for this branch, which correctly used "runtimeOnly" from the start. Fixing here as a separate, standalone bug fix rather than folding it into the feature commits — this method is only a fallback path (used when an Expo module's POM file can't be found on disk), so the blast radius is narrow, but it's a confirmed real bug worth closing while we're here. Co-Authored-By: Claude Sonnet 5 --- .../com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt index 42d1f52d..0b3d9c14 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt @@ -195,7 +195,7 @@ open class ExpoPublishingHelper(val brownfieldAppProject: Project) { * Not accounting for variant specific configurations as Expo packages are not * using it. Should we face any issues/needs to account for it, we can do it here. */ - listOf("implementation", "api", "runtime").forEach { + listOf("implementation", "api", "runtimeOnly").forEach { val configuration = pkgProject.configurations.findByName(it) configuration?.dependencies?.forEach { dep -> if (dep.group != null) { From dd128c8b0232f712741683f6d96d0ef6cdb09698 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Wed, 9 Sep 2026 08:30:50 +0200 Subject: [PATCH 16/17] test(bgp): add regression test for Expo Gradle-fallback runtimeOnly discovery Mutation-tested manually: fails against the pre-fix "runtime" typo, passes against the "runtimeOnly" fix from the previous commit. Ran the real ExpoApp57 build with the fix applied too -- discovered-dependency counts for the 4 modules that actually exercise this fallback path (expo, expo-constants, expo-modules-core, expo-updates) are unchanged (6/2/11/11 before and after), so the bug has no observable impact on this repo's current Expo dependency set. This test is what actually proves the fix, independent of whether any current module happens to trigger it. Co-Authored-By: Claude Sonnet 5 --- .../ExpoPublishingHelperGradleFallbackTest.kt | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelperGradleFallbackTest.kt diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelperGradleFallbackTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelperGradleFallbackTest.kt new file mode 100644 index 00000000..3e03884a --- /dev/null +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelperGradleFallbackTest.kt @@ -0,0 +1,41 @@ +package com.callstack.react.brownfield.expo + +import com.callstack.react.brownfield.shared.DependencyInfo +import com.callstack.react.brownfield.shared.VersionMediatingDependencySet +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import kotlin.test.assertTrue + +class ExpoPublishingHelperGradleFallbackTest { + @Test + fun `Gradle-fallback discovery picks up runtimeOnly dependencies`() { + val root = ProjectBuilder.builder().build() + val expoPkgProject = ProjectBuilder.builder().withParent(root).withName("expo-fake-module").build() + + expoPkgProject.configurations.create("implementation") + expoPkgProject.configurations.create("api") + expoPkgProject.configurations.create("runtimeOnly") + expoPkgProject.dependencies.add("runtimeOnly", "androidx.annotation:annotation:1.9.1") + + val helper = + object : ExpoPublishingHelper(brownfieldAppProject = root) { + fun exposedAppend( + pkgProject: Project, + deps: VersionMediatingDependencySet, + ) { + appendExpoTransitiveDependenciesFromGradle(pkgProject, deps) + } + } + + val discovered = VersionMediatingDependencySet() + helper.exposedAppend(expoPkgProject, discovered) + + assertTrue( + discovered.contains( + DependencyInfo("androidx.annotation", "annotation", "1.9.1", "compile", false), + ), + "expected the runtimeOnly dependency to be discovered via the Gradle fallback path", + ) + } +} From baa9c8bfa0089099ec16ebeab2b4fae151687d25 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Wed, 9 Sep 2026 14:59:32 +0200 Subject: [PATCH 17/17] fix(bgp): dedupe transitive-dependency discovery, drop dangling spec refs Extracts the Gradle-configuration-walking logic shared by the Expo Gradle-fallback and RNC-CLI discoverers into collectPublishableGradleDependencies, so the isPublishableCoordinate filter (rejecting dynamic/blank versions) now applies to both paths instead of only the RNC one. Also logs a warning when RncTransitiveDependencyDiscoverer can't resolve an embedded module's Gradle project, instead of silently skipping it, and removes code comments referencing a design-spec doc that was never committed to this branch. Verified with ktlintCheck + unit tests, and end-to-end via the RNApp -> AndroidApp vanilla Detox suite (built AAR with includeTransitiveDependencies enabled, inspected the generated POM/module.json for correct injection, all 4 Detox tests passed). Co-Authored-By: Claude Sonnet 5 --- .../RncTransitiveDependencyDiscoverer.kt | 45 +++++++------------ .../brownfield/expo/ExpoPublishingHelper.kt | 20 +-------- .../brownfield/plugin/RNBrownfieldPlugin.kt | 2 - .../shared/GradleDependencyCollector.kt | 39 ++++++++++++++++ .../shared/PublishingMetadataInjector.kt | 2 +- 5 files changed, 59 insertions(+), 49 deletions(-) create mode 100644 gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt index 72922cfc..5e188153 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt @@ -1,22 +1,19 @@ package com.callstack.react.brownfield.artifacts -import com.callstack.react.brownfield.shared.DependencyInfo +import com.callstack.react.brownfield.shared.Logging +import com.callstack.react.brownfield.shared.TRANSITIVE_DEPENDENCY_CONFIG_NAMES import com.callstack.react.brownfield.shared.UnresolvedArtifactInfo import com.callstack.react.brownfield.shared.VersionMediatingDependencySet -import com.callstack.react.brownfield.shared.isPublishableCoordinate +import com.callstack.react.brownfield.shared.collectPublishableGradleDependencies import org.gradle.api.Project -import org.gradle.api.artifacts.Configuration -import org.gradle.api.internal.artifacts.dependencies.DefaultProjectDependency /** * Discovers the real third-party (non-project) dependencies of the native module projects * embedded into the fat AAR, for publication into the AAR's own POM/module metadata. * Mirrors ExpoPublishingHelper.appendExpoTransitiveDependenciesFromGradle for the RNC-CLI - * ("vanilla") path — see docs/superpowers/specs/2026-09-04-bgp-transitive-dependencies-design.md §4.2. + * ("vanilla") path. */ class RncTransitiveDependencyDiscoverer(private val project: Project) { - private val configNames = listOf("implementation", "api", "runtimeOnly") - fun discover(artifacts: List): VersionMediatingDependencySet { val discovered = VersionMediatingDependencySet() @@ -31,31 +28,23 @@ class RncTransitiveDependencyDiscoverer(private val project: Project) { artifact: UnresolvedArtifactInfo, discovered: VersionMediatingDependencySet, ) { - val moduleProject = project.rootProject.findProject(":${artifact.moduleName}") ?: return - configNames.forEach { configName -> - val configuration = moduleProject.configurations.findByName(configName) ?: return@forEach - collectFromConfiguration(configuration, discovered) + val moduleProject = project.rootProject.findProject(":${artifact.moduleName}") + if (moduleProject == null) { + Logging.log( + "WARNING: Could not discover transitive dependencies for embedded module " + + "'${artifact.moduleName}' - no Gradle project found at " + + "':${artifact.moduleName}' in the root project", + ) + return } - } - private fun collectFromConfiguration( - configuration: Configuration, - discovered: VersionMediatingDependencySet, - ) { - configuration.dependencies.forEach { dependency -> - if (dependency is DefaultProjectDependency) return@forEach - val group = dependency.group ?: return@forEach - - val info = DependencyInfo.fromGradleDep(group, dependency.name, dependency.version) - if (!isPublishableCoordinate(info)) return@forEach - if (isAlreadyDeclaredByConsumer(group, dependency.name)) return@forEach - - discovered.add(info) - } + collectPublishableGradleDependencies(moduleProject) + .filterNot { isAlreadyDeclaredByConsumer(it.groupId, it.artifactId) } + .forEach { discovered.add(it) } } /** - * Injection-time dedup only (spec §4.3(B)) — NOT the removal predicate passed to + * Injection-time dedup only — NOT the removal predicate passed to * PublishingMetadataInjector. Prevents double-declaring a coordinate the consumer * project (e.g. BrownfieldLib) already declares explicitly itself. */ @@ -63,7 +52,7 @@ class RncTransitiveDependencyDiscoverer(private val project: Project) { groupId: String, artifactId: String, ): Boolean { - return configNames.any { configName -> + return TRANSITIVE_DEPENDENCY_CONFIG_NAMES.any { configName -> project.configurations.findByName(configName)?.dependencies?.any { it.group == groupId && it.name == artifactId } ?: false diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt index 0b3d9c14..c81c85f6 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt @@ -8,6 +8,7 @@ import com.callstack.react.brownfield.shared.Constants import com.callstack.react.brownfield.shared.DependencyInfo import com.callstack.react.brownfield.shared.Logging import com.callstack.react.brownfield.shared.VersionMediatingDependencySet +import com.callstack.react.brownfield.shared.collectPublishableGradleDependencies import org.gradle.api.Project import org.w3c.dom.Node import java.io.File @@ -191,24 +192,7 @@ open class ExpoPublishingHelper(val brownfieldAppProject: Project) { pkgProject: Project, dependencies: VersionMediatingDependencySet, ) { - /** - * Not accounting for variant specific configurations as Expo packages are not - * using it. Should we face any issues/needs to account for it, we can do it here. - */ - listOf("implementation", "api", "runtimeOnly").forEach { - val configuration = pkgProject.configurations.findByName(it) - configuration?.dependencies?.forEach { dep -> - if (dep.group != null) { - dependencies.add( - DependencyInfo.fromGradleDep( - groupId = dep.group!!, - artifactId = dep.name, - version = dep.version, - ), - ) - } - } - } + dependencies.addAll(collectPublishableGradleDependencies(pkgProject)) } /** diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt index 4609e3b3..4ef96b4d 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt @@ -76,8 +76,6 @@ class RNBrownfieldPlugin : Plugin { * is created eagerly in initializers() above, before the build script's own * `reactBrownfield { }` block has configured it — reading `extension.includeTransitiveDependencies` * any earlier than this would always observe its default `false`. - * - * See docs/superpowers/specs/2026-09-04-bgp-transitive-dependencies-design.md §4.4. */ project.afterEvaluate { val transitiveDeps = VersionMediatingDependencySet() diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt new file mode 100644 index 00000000..1a15f4a9 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt @@ -0,0 +1,39 @@ +package com.callstack.react.brownfield.shared + +import org.gradle.api.Project +import org.gradle.api.internal.artifacts.dependencies.DefaultProjectDependency + +/** + * Configuration names inspected when discovering a Gradle project's direct external + * dependencies for publication into a consumer's POM/Gradle Module Metadata. + * + * Not variant/flavor-aware: only these plain configuration names are inspected, not e.g. + * `releaseImplementation`. Fine for Expo packages (confirmed not to use flavor-specific + * configurations); an RNC-CLI native module that does use them will have those dependencies + * missed here. Extend this list if that turns out to matter in practice. + */ +val TRANSITIVE_DEPENDENCY_CONFIG_NAMES = listOf("implementation", "api", "runtimeOnly") + +/** + * Collects [project]'s direct external (non-project) dependencies declared on + * [TRANSITIVE_DEPENDENCY_CONFIG_NAMES], dropping any coordinate [isPublishableCoordinate] + * rejects. Shared by the Expo Gradle-fallback and RNC-CLI transitive-dependency discovery + * paths so both get the same publishability guarantees. + */ +fun collectPublishableGradleDependencies(project: Project): List { + val result = mutableListOf() + + TRANSITIVE_DEPENDENCY_CONFIG_NAMES.forEach { configName -> + val configuration = project.configurations.findByName(configName) ?: return@forEach + + configuration.dependencies.forEach { dependency -> + if (dependency is DefaultProjectDependency) return@forEach + val group = dependency.group ?: return@forEach + + val info = DependencyInfo.fromGradleDep(group, dependency.name, dependency.version) + if (isPublishableCoordinate(info)) result.add(info) + } + } + + return result +} diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt index 15000c98..90f08fa8 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt @@ -14,7 +14,7 @@ import java.io.File * Gradle Module Metadata (`module.json`) for every `MavenPublication` on [project], and * removes any existing entry (from the base publication or previously injected) that * [shouldExclude] matches. Used identically by the Expo and RNC-CLI transitive-dependency - * paths — see docs/superpowers/specs/2026-09-04-bgp-transitive-dependencies-design.md §4.1/§4.3. + * paths. */ class PublishingMetadataInjector(private val project: Project) { @Suppress("LongMethod")