Skip to content

feat: support transitive dependencies in BGP (RNC-CLI path) - #458

Open
KisaneNeko wants to merge 17 commits into
callstack:mainfrom
KisaneNeko:feat/bgp-transitive-dependencies-rnc
Open

KisaneNeko wants to merge 17 commits into
callstack:mainfrom
KisaneNeko:feat/bgp-transitive-dependencies-rnc

Conversation

@KisaneNeko

Copy link
Copy Markdown

Summary

Adds an opt-in includeTransitiveDependencies option to the Brownfield Gradle Plugin. When a vanilla (non-Expo) brownfield app turns it on, BGP discovers the real third-party dependencies of its embedded native modules and publishes them into the AAR's POM/Gradle Module Metadata, so a consuming native app resolves them automatically instead of the host team having to hand-declare them. Mirrors the mechanism Expo projects already get unconditionally.

Notion ticket: Brownfield: support transitive dependencies in BGP

What's in this PR

  • New includeTransitiveDependencies option on reactBrownfield { }
  • Shared POM/module.json injector extracted out of the Expo-only code path (no behavior change for Expo)
  • New discoverer for the RNC path, with day-1 handling for dynamic/versionless dependency coordinates
  • Enabled in the RNApp demo, hand-rolled workaround removed
  • Also fixes a real, pre-existing bug on the Expo side: appendExpoTransitiveDependenciesFromGradle was checking for a Gradle configuration named "runtime", which doesn't actually exist on modern AGP modules (the real name is "runtimeOnly") — so that fallback path has been silently skipping runtimeOnly dependencies since feat: expo config plugin #223. Fixed and added a regression test for it.

Testing

Verified locally: real Maven-local publish for both RNApp and ExpoApp57, inspected the generated POM/module.json (confirmed a genuinely new dependency gets injected, embedded modules stay excluded, dynamic-version coordinates get filtered), built the vanilla AndroidApp flavor against it, ran the existing Detox suites for both vanilla and Expo — all green.

Added unit tests for this, not the usual pattern in this repo, but they cover an actual bug I spotted and fixed in the Expo implementation so maybe they are worth to keep. Let me know what you think.

Radoslaw Nowacki and others added 17 commits September 4, 2026 11:28
…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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iance)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…overerTest

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 <noreply@anthropic.com>
…ollision

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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.
- 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 <noreply@anthropic.com>
… discovery

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 (callstack#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 <noreply@anthropic.com>
…iscovery

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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A critical compilation issue and unresolved dependency discovery and publication correctness issues remain.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds opt-in transitive dependency discovery and publication for vanilla Brownfield projects, shares publishing logic with Expo, and fixes Expo runtimeOnly fallback handling.

Changes:

  • Adds RNC dependency discovery, filtering, mediation, and metadata injection.
  • Enables the option in RNApp and removes the manual workaround.
  • Adds tests, documentation, and CI coverage.
File summaries
File Reviewed changes
gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt Tests the option default.
gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt Tests dependency version mediation.
gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt Tests dependency coordinate filtering.
gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelperGradleFallbackTest.kt Covers runtimeOnly discovery.
gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt Tests RNC dependency discovery.
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt Adds the opt-in configuration option.
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySet.kt Provides shared dependency mediation.
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt Injects dependencies into publication metadata. Final note: moderate (2 votes)—the metadata path is publication-name specific.
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt Collects Gradle dependencies. Final note: moderate (1 vote)—runtimeOnly is published as compile.
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyPublishability.kt Filters dependency versions. Final note: moderate (3 votes)—latest.* and Maven ranges are not rejected.
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyInfo.kt Defines shared dependency data.
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt Updates shared imports.
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt Integrates discovery and metadata publishing. Final notes: critical (1 vote)—nullable captured var does not compile; moderate (1 vote)—embedded projects may not be evaluated; nit (1 vote)—Expo discovery runs twice.
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPublishingInfo.kt Restores Expo publishing data.
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt Uses shared collection and discovery logic.
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt Discovers vanilla module dependencies. Final notes: moderate (1 vote)—embedded-project evaluation may race collection; moderate (1 vote)—same-coordinate dependencies can be skipped before mediation.
gradle-plugins/react/brownfield/gradle/libs.versions.toml Adds JUnit dependency versions.
gradle-plugins/react/brownfield/build.gradle.kts Configures unit testing.
docs/docs/docs/getting-started/android.mdx Documents the new option. Final note: nit (1 vote)—the troubleshooting guide also needs updating.
apps/RNApp/android/BrownfieldLib/build.gradle.kts Enables transitive publishing and removes the workaround.
.gitignore Ignores internal artifacts.
.github/workflows/gradle-plugin-lint.yml Runs Gradle plugin tests.
.github/workflows/ci.yml Includes plugin changes in app builds.
Review details

Suppressed comments (6)

docs/docs/docs/getting-started/android.mdx:346

  • The new opt-in is not reflected in docs/docs/docs/guides/troubleshooting.mdx, which still says bare React Native transitive dependencies are not auto-resolved and instructs users to hand-declare them. Update that guide so users are directed to includeTransitiveDependencies and the manual workaround is reserved for projects that intentionally leave the option disabled.
> **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 above — the plugin now performs the equivalent removal *and* injects your modules' real dependencies for you:
>
> ```kotlin
> reactBrownfield {
>     includeTransitiveDependencies = true
> }

gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt:43

  • This filters only by group/artifact, so it drops an embedded module's higher requirement whenever BrownfieldLib already declares the same coordinate at a lower or dynamic version. For example, an existing appcompat:1.6.0 declaration causes a module's appcompat:1.7.1 to be discarded before VersionMediatingDependencySet can mediate it, leaving the published POM at 1.6.0 and allowing a consumer to resolve below the module's requirement. Compare versions here, or merge and replace the existing publication entry with the mediated version instead of skipping unconditionally.
            .filterNot { isAlreadyDeclaredByConsumer(it.groupId, it.artifactId) }
            .forEach { discovered.add(it) }

gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt:43

  • afterEvaluate only guarantees that the Brownfield project has finished evaluation; it does not wait for each autolinked moduleProject returned by findProject. If a native module is evaluated later in the multi-project build, its implementation/api declarations are still absent here and the dependency is silently omitted from the published metadata. Ensure the embedded projects are evaluated before collection (or defer discovery until their evaluation callbacks have run).
        collectPublishableGradleDependencies(moduleProject)
            .filterNot { isAlreadyDeclaredByConsumer(it.groupId, it.artifactId) }
            .forEach { discovered.add(it) }

gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt:84

  • ExpoPublishingHelper.configure() still calls discoverAllExpoTransitiveDependencies for logging, and this new callback calls the same discovery again. Expo builds therefore parse the same POMs/configurations twice during configuration, adding avoidable work and duplicating any discovery failures; cache the first result or remove one of the calls.
                val expoTransitiveDeps = expoPublishingHelper.discoverAllExpoTransitiveDependencies(expoProjects)

gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt:90

  • This discovery runs from afterEvaluate on the Brownfield library, but findProject does not evaluate the embedded module. If an autolinked module is evaluated later, its implementation/api/runtimeOnly declarations are still unpopulated here and the code silently publishes no dependencies for that module. Ensure the embedded projects are evaluated before collecting their configurations, or defer discovery until project evaluation is complete.
            if (extension.includeTransitiveDependencies) {
                val rncTransitiveDeps = RncTransitiveDependencyDiscoverer(project).discover(artifacts)
                Logging.log("Merged ${rncTransitiveDeps.size} transitive dependencies discovered by the RNC discoverer")

gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt:34

  • fromGradleDep hard-codes every discovered dependency to compile, so the newly supported runtimeOnly configuration is published in the POM as a compile dependency. That unnecessarily puts runtime-only libraries on the consuming app's compile classpath and changes their intended API visibility; preserve the configuration's runtime scope when constructing DependencyInfo.
    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)
  • Files reviewed: 22/23 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +83 to +87
if (isExpoProject && expoPublishingHelper != null) {
val expoTransitiveDeps = expoPublishingHelper.discoverAllExpoTransitiveDependencies(expoProjects)
Logging.log("Merged ${expoTransitiveDeps.size} transitive dependencies discovered from Expo")
transitiveDeps.addAll(expoTransitiveDeps)
}
fun isPublishableCoordinate(dependency: DependencyInfo): Boolean {
val version = dependency.version
if (version.isNullOrBlank()) return false
if (version.contains("+")) return false
Comment on lines +31 to +32
File("$moduleBuildDir/publications/mavenAar/module.json").run {
val json = inputStream().use { JsonSlurper().parse(it) as Map<*, *> }

@hurali97 hurali97 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work with the PR 🚀 Left some comments, please also review the Copilot's comments.

Comment on lines +19 to +21
class PublishingMetadataInjector(private val project: Project) {
@Suppress("LongMethod")
fun reconfigureGradleModuleJSON(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This register a task in afterEvaluate which in most cases is discouraged. From the implementation here, we can see that it reads a file and then applies operations. It also uses a doLast block to conduct this operation.

I believe this provides us structure and enough information to register this task with inputs in the configuration phase, so before/outside of afterEvaluate.

* Default is `false`. Expo projects already get equivalent behavior unconditionally;
* this option only affects non-Expo (RNC CLI) projects.
*/
var includeTransitiveDependencies = false

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's default to true and rename/mark this as experimental.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants