Skip to content

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

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

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

Conversation

@KisaneNeko

@KisaneNeko KisaneNeko commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

What this does

Adds experimentalIncludeTransitiveDependencies to the Brownfield Gradle Plugin.

A brownfield AAR bundles your native modules, but it doesn't tell the consuming app about the third party libraries those modules need, so today the host team has to declare them by hand. This makes BGP find them and write them into the published POM and Gradle Module Metadata, so Gradle resolves them on its own. Expo projects already worked this way, this brings the same thing to RNC CLI projects.

It's on by default.

Notion ticket: Brownfield: support transitive dependencies in BGP

What it means if you already use Brownfield

Your build keeps working, but the metadata you publish changes:

  • Versions you pinned by hand can go up if an embedded module needs something newer. They never go down.
  • Dependencies you never declared will show up in your POM.
  • If you have the removeDependenciesFromModuleFile block from the old setup docs, you can keep it or delete it. It no longer clashes with the plugin.

Probably worth calling out in the changelog.

Bugs fixed along the way

Most of these came out of review.

  • The plugin registered a task called removeDependenciesFromModuleFile, which is the same name our docs tell people to register themselves. The plugin applies first, so just bumping the version broke the build with a duplicate task error, and setting the flag to false didn't help. Renamed it to brownfieldRemoveDependenciesFromModuleFile.
  • Discovery ran in afterEvaluate, which only waits for the current project to be configured. The embedded modules usually weren't ready yet, so it found nothing at all. Moved to taskGraph.whenReady.
  • If your app declared something like androidx.core:core-ktx:1.+, that 1.+ could win and get published.
  • A dependency declared without a version (BOM or platform()) ended up in the POM twice.
  • Publishing a library that has no dependencies crashed.
  • On the Expo side, appendExpoTransitiveDependenciesFromGradle looked for a configuration called runtime, but modern AGP calls it runtimeOnly, so it had been quietly skipping those since feat: expo config plugin #223.

One thing I left alone

Every dependency goes into every variant, so runtime only ones end up visible at compile time to Gradle consumers. The obvious fix is to skip runtime scoped deps for api variants, but that doesn't work. Gradle writes runtime for a plain implementation when it generates a POM, so on the Expo side 73 of 84 dependencies look runtime scoped, and filtering on that would drop react-android and appcompat for Expo consumers. It needs a different approach and its own PR. There's a comment in the code with the details.

Testing

Published RNApp and ExpoApp57 to maven local and read through the generated POM and module.json: new dependencies get added, embedded modules stay out, dynamic versions get filtered, nothing shows up twice, everything has a real version. Built AndroidApp against it and ran Detox for both vanilla and Expo.

There are 33 unit tests, which isn't really the pattern in this repo, but this code edits publishing metadata in ways that are easy to get subtly wrong and several of them cover actual bugs from the list above. For each fix I reverted it and checked that its test went red. One of them is a TestKit test that sets a project up the old way and reproduces the duplicate task error end to end.

🤖 Generated with Claude Code

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.

Radoslaw Nowacki and others added 5 commits September 15, 2026 11:54
…tion

- isPublishableCoordinate now also rejects latest.release/latest.integration
  and Maven version ranges, not just + wildcards
- PublishingMetadataInjector derives the module.json path per publication
  from the GenerateModuleMetadata task's own output file, instead of
  hardcoding "mavenAar" — works regardless of publication name
- RncTransitiveDependencyDiscoverer no longer silently drops a module's
  higher version requirement when the consumer already declares the same
  coordinate at a lower version; mediates and supersedes the stale entry
  instead
- runtimeOnly-sourced dependencies are now published under Maven's
  "runtime" scope instead of always "compile"
- removed the redundant, premature Expo transitive-dependency discovery
  call in ExpoPublishingHelper.configure() (ran before afterEvaluate,
  duplicating the real discovery)
- point the troubleshooting guide at includeTransitiveDependencies instead
  of only the manual workaround
- gitignore docs/superpowers/ (untracked design-doc workspace artifacts)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…, address human review

- Move transitive-dependency discovery/injection from project.afterEvaluate to
  gradle.taskGraph.whenReady: afterEvaluate only guarantees this project has
  finished configuring, not that embedded native module projects have — and
  they hadn't. Confirmed empirically that every embedded module in this repo's
  own demo app was reporting 'NOT EXECUTED' with empty implementation/api/
  runtimeOnly configs at discovery time, meaning the discoverer had never
  actually found anything in any prior testing. taskGraph.whenReady only
  fires once the full task graph is resolved, which requires every project
  this one's tasks depend on (including every embedded module, since their
  compiled output is bundled into the AAR) to already be configured.
- Remove BrownfieldLib's hand-declared androidx.core/appcompat/material
  dependencies in the RNApp demo, which happened to mask the above bug by
  duplicating what react-native-screens needs. Verified end-to-end: with
  them removed, the published POM now correctly contains react-native-screens'
  actual dependencies (appcompat, fragment-ktx, transition-ktx,
  coordinatorlayout, swiperefreshlayout, material, core-ktx) plus gson from
  another embedded module, discovered entirely through the plugin itself.
- PublishingMetadataInjector: register its Gradle Module Metadata injector
  task and POM withXml hook eagerly during configuration instead of from
  afterEvaluate, per human review feedback. Only supplying the resolved
  dependency set to it still needs to wait, via a new configure() method.
  First attempt at this (a companion task registered lazily from inside a
  GenerateModuleMetadata configureEach callback) crashed with
  "DefaultTaskContainer#register(String) ... cannot be executed in the
  current context" when Gradle realized that task mid task-graph-resolution;
  fixed by using a single eagerly-registered task that walks every
  GenerateModuleMetadata task's own output file at execution time instead.
- Rename includeTransitiveDependencies to experimentalIncludeTransitiveDependencies
  and flip its default to true, per human review feedback, now that the
  discovery bug above is fixed. Non-Expo consumers get automatic transitive-
  dependency publishing without any opt-in; set it to false to fall back to
  hand-declaring dependencies.
- Update docs to describe the option as on-by-default with an opt-out,
  instead of opt-in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PublishingMetadataInjector appended the mediated/injected dependency entries
before removing entries matching the exclusion predicate. Since the predicate
matches by coordinate only, a coordinate that is both already present (e.g. a
consumer's own hand-declared dependency from before this feature existed) and
marked "superseded" by RncTransitiveDependencyDiscoverer's version mediation
would have both its stale and freshly-appended entries deleted by the same
pass, leaving the dependency missing entirely from the published POM and
module.json — worse than a duplicate.

This was live but unreachable before the evaluation-order fix (superseded
coordinates were never populated, since discovery never found anything), and
became a real regression risk the moment includeTransitiveDependencies
defaulted to true: any existing Brownfield user who already hand-declares a
dependency that overlaps with what an embedded module needs would silently
lose that dependency on upgrade.

Fixed by reordering to remove stale matches before appending. Extracted the
POM/module.json mutation logic into standalone, directly testable functions
(mutatePomDependenciesNode, mutateModuleJsonVariants) and added regression
tests for both, verified to actually fail against the old ordering.

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

The Expo Android E2E build jobs were failing in CI trying to resolve
expo.modules.* / host.exp.exponent:* coordinates as external Maven
dependencies of the published brownfieldlib AAR — they don't exist as
standalone Maven artifacts, since Expo's own modules are embedded, not
published separately.

Root cause: experimentalIncludeTransitiveDependencies now defaults to true
with nothing gating it to skip Expo projects, so the RNC discoverer ran
there too. Unlike the Expo-specific discovery path, RncTransitiveDependencyDiscoverer
has no awareness of the Expo blacklist — it happily walks embedded non-Expo
modules' own dependencies, one of which can legitimately declare a
dependency directly on an Expo module's own coordinate. Combined with the
earlier remove-before-append fix (which only strips pre-existing matching
nodes, not filtering what gets newly appended), those coordinates got
injected into the POM/module.json unfiltered.

Reproduced locally end-to-end: published ExpoApp56's brownfieldlib AAR and
found all 14 of the exact coordinates that broke CI in the resulting POM.

Fixed at the root rather than only patching the symptom: the RNC discoverer
now never runs on Expo projects at all (isExpoProject gate), restoring Expo
to its exact pre-PR discovery behavior. Expo's own discovery already covers
every embedded module (not just Expo's own packages), so this isn't a
regression in coverage — verified the same real build now produces zero
expo.*/host.exp.exponent entries while all other legitimate dependencies
(Compose, Glide, Room, OkHttp, AndroidX, etc.) remain correctly present, and
that RNC discovery's own log line no longer appears for this project at all.

Also keeps the hard-exclude filtering (dropHardExcludedDependencies) as a
safety net for the RNC-only path, with regression tests verified to fail
against a broken implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@KisaneNeko
KisaneNeko marked this pull request as draft September 17, 2026 10:58
Radoslaw Nowacki and others added 2 commits September 17, 2026 15:55
…very

Addresses two rounds of code review on the transitive-dependency work.

Blocker: the plugin registered a task named `removeDependenciesFromModuleFile`
during apply(), which is the exact name the published setup docs tell every
bare-RN consumer to register in their own build script. Since apply() runs
before the consumer's script body, bumping the plugin version and changing
nothing else failed configuration with "Cannot add task '...' as a task with
that name already exists". This was not gated by
`experimentalIncludeTransitiveDependencies`, so opting out did not avoid it.
On main the same registration existed but was only reached for Expo projects,
which is why bare-RN consumers never hit it before.

The plugin's task is now namespaced as
`brownfieldRemoveDependenciesFromModuleFile`, leaving the documented name free.
An upgrading app can keep its hand-written block: the two tasks both finalize
metadata generation, run sequentially, and the legacy one only removes entries
the plugin never injects. A log note tells upgraders the block is now redundant;
it deliberately does not fail the build.

Correctness fixes in the RNC discoverer and injector:

- A consumer-declared version bypassed `isPublishableCoordinate`, so a dynamic
  declaration such as `androidx.core:core-ktx:1.+` could win mediation and be
  published as `<version>1.+</version>`. Consumer versions now take part in
  mediation only when publishable.
- A versionless consumer declaration (BOM/platform) never populated
  `supersededCoordinates`, leaving the base publication's entry in place
  alongside the injected one - two nodes for the same coordinate. Superseding
  now keys on the declaration existing, not on it carrying a version.
- A publication with no dependencies generates a POM with no `<dependencies>`
  element, where `NodeList.first()` threw. Resolution is extracted as
  `resolveOrCreateDependenciesNode` so tests exercise the production path
  rather than reimplementing the fallback.

Tests: adds a TestKit fixture that configures a project still registering the
documented legacy task, since no in-repo app has that shape any more; adds
regression cases for the dynamic-version and versionless-declaration paths and
for both branches of the `<dependencies>` resolution. Each fix was verified by
reverting it and confirming the corresponding test goes red.

Docs: the upgrade note in android.mdx now says the legacy block can stay, and
troubleshooting.mdx records that mediation can raise (never lower) a pinned
version.

Not fixed here, documented in place: every dependency is added to every module
metadata variant, so a runtime-scoped dependency is compile-visible to Gradle
consumers. A predicate keyed on `DependencyInfo.scope` would be wrong, not just
broad - `scope == "runtime"` means `implementation` on the Expo POM path but
`runtimeOnly` on the RNC path (measured: 73 runtime vs 11 compile across 14
upstream Expo POMs). A correct fix keys on provenance and belongs in its own PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comments had drifted into review justification: empirical narratives, restated
rationale, and prose that repeated what the code says. Cuts the added comment
volume roughly in half, keeping the load-bearing ones (why whenReady rather
than afterEvaluate, why the task name is namespaced, why removal precedes
appending, why a scope-keyed variant filter would be wrong).

No behavior change; suite unchanged at 33 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@KisaneNeko

KisaneNeko commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

@hurali97 this was previously passing but false-positive. I've now removed the deps from demo app and implemented fixes. Since you suggested flipping the flag to true by default I also revisited the scenarios of bumping the library version without removing the dependencies and that led me to some more issues that are now addressed.

This has been through multiple back-and-forth AI reviews, and tests are more useful than they used to be in the first version of this PR - should be ready for human review.

@KisaneNeko
KisaneNeko marked this pull request as ready for review September 17, 2026 14:22
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