feat: support transitive dependencies in BGP (RNC-CLI path) - #458
KisaneNeko wants to merge 17 commits into
Conversation
…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>
There was a problem hiding this comment.
🟡 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 toincludeTransitiveDependenciesand 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.0declaration causes a module'sappcompat:1.7.1to be discarded beforeVersionMediatingDependencySetcan 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
afterEvaluateonly guarantees that the Brownfield project has finished evaluation; it does not wait for each autolinkedmoduleProjectreturned byfindProject. If a native module is evaluated later in the multi-project build, itsimplementation/apideclarations 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 callsdiscoverAllExpoTransitiveDependenciesfor 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
afterEvaluateon the Brownfield library, butfindProjectdoes not evaluate the embedded module. If an autolinked module is evaluated later, itsimplementation/api/runtimeOnlydeclarations 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
fromGradleDephard-codes every discovered dependency tocompile, so the newly supportedruntimeOnlyconfiguration 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 constructingDependencyInfo.
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.
| 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 |
| File("$moduleBuildDir/publications/mavenAar/module.json").run { | ||
| val json = inputStream().use { JsonSlurper().parse(it) as Map<*, *> } |
hurali97
left a comment
There was a problem hiding this comment.
Great work with the PR 🚀 Left some comments, please also review the Copilot's comments.
| class PublishingMetadataInjector(private val project: Project) { | ||
| @Suppress("LongMethod") | ||
| fun reconfigureGradleModuleJSON( |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Let's default to true and rename/mark this as experimental.
Summary
Adds an opt-in
includeTransitiveDependenciesoption 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
includeTransitiveDependenciesoption onreactBrownfield { }RNAppdemo, hand-rolled workaround removedappendExpoTransitiveDependenciesFromGradlewas 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
RNAppandExpoApp57, inspected the generated POM/module.json (confirmed a genuinely new dependency gets injected, embedded modules stay excluded, dynamic-version coordinates get filtered), built the vanillaAndroidAppflavor 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.