From 0b76a9dc03390883629f76f03cfafc7fa6ae3bd9 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 22 Mar 2026 00:00:25 +0000 Subject: [PATCH] Detect native dependency version conflicts between plugins Extends the existing plugin conflict detection to catch version mismatches in Gradle, CocoaPods, and Swift Package dependencies before they cause opaque build failures. Co-Authored-By: Claude Opus 4.6 --- config/nativephp.php | 2 +- src/Commands/PluginRegisterCommand.php | 96 +++++++++ src/Exceptions/PluginConflictException.php | 12 +- src/Plugins/PluginRegistry.php | 104 ++++++++++ tests/Feature/Plugins/PluginRegistryTest.php | 194 +++++++++++++++++++ 5 files changed, 402 insertions(+), 6 deletions(-) diff --git a/config/nativephp.php b/config/nativephp.php index 7ae7ca0..f3ab13f 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -128,7 +128,7 @@ 'storage/framework/sessions', 'storage/framework/cache', 'storage/framework/testing', - 'storage/logs/laravel.log' + 'storage/logs/laravel.log', ], /* diff --git a/src/Commands/PluginRegisterCommand.php b/src/Commands/PluginRegisterCommand.php index a85ebaf..5f6e62b 100644 --- a/src/Commands/PluginRegisterCommand.php +++ b/src/Commands/PluginRegisterCommand.php @@ -292,6 +292,102 @@ protected function detectConflicts(Plugin $newPlugin): array } } + // Check native dependency version conflicts + foreach ($this->registry->all() as $existing) { + $conflicts = array_merge($conflicts, $this->detectDependencyConflictsBetween($newPlugin, $existing)); + } + return $conflicts; } + + /** + * Detect native dependency version conflicts between two plugins. + * + * @return array Conflict messages + */ + protected function detectDependencyConflictsBetween(Plugin $newPlugin, Plugin $existing): array + { + $conflicts = []; + + // Android Gradle dependencies + $newAndroidDeps = $this->parseAndroidDeps($newPlugin); + $existingAndroidDeps = $this->parseAndroidDeps($existing); + + foreach ($newAndroidDeps as $artifact => $version) { + if (isset($existingAndroidDeps[$artifact]) && $existingAndroidDeps[$artifact] !== $version) { + $conflicts[] = "Android dependency '{$artifact}' version conflict: {$version} vs {$existingAndroidDeps[$artifact]} (from '{$existing->name}')"; + } + } + + // CocoaPods + $newPods = $this->parsePodDeps($newPlugin); + $existingPods = $this->parsePodDeps($existing); + + foreach ($newPods as $podName => $version) { + if ($version !== null && isset($existingPods[$podName]) && $existingPods[$podName] !== null && $existingPods[$podName] !== $version) { + $conflicts[] = "CocoaPods dependency '{$podName}' version conflict: {$version} vs {$existingPods[$podName]} (from '{$existing->name}')"; + } + } + + // Swift Packages + $newSwiftPkgs = $this->parseSwiftPackageDeps($newPlugin); + $existingSwiftPkgs = $this->parseSwiftPackageDeps($existing); + + foreach ($newSwiftPkgs as $url => $version) { + if ($version !== null && isset($existingSwiftPkgs[$url]) && $existingSwiftPkgs[$url] !== null && $existingSwiftPkgs[$url] !== $version) { + $conflicts[] = "Swift Package '{$url}' version conflict: {$version} vs {$existingSwiftPkgs[$url]} (from '{$existing->name}')"; + } + } + + return $conflicts; + } + + /** + * Parse Android Gradle dependencies into artifact => version map. + */ + protected function parseAndroidDeps(Plugin $plugin): array + { + $deps = []; + foreach ($plugin->getAndroidDependencies() as $type => $libraries) { + foreach ($libraries as $library) { + $parts = explode(':', $library); + if (count($parts) >= 3) { + $deps[$parts[0].':'.$parts[1]] = $parts[2]; + } + } + } + + return $deps; + } + + /** + * Parse CocoaPods dependencies into name => version map. + */ + protected function parsePodDeps(Plugin $plugin): array + { + $deps = []; + foreach ($plugin->getIosDependencies()['pods'] ?? [] as $pod) { + if (isset($pod['name'])) { + $deps[$pod['name']] = $pod['version'] ?? null; + } + } + + return $deps; + } + + /** + * Parse Swift Package dependencies into normalizedUrl => version map. + */ + protected function parseSwiftPackageDeps(Plugin $plugin): array + { + $deps = []; + foreach ($plugin->getIosDependencies()['swift_packages'] ?? [] as $pkg) { + if (isset($pkg['url'])) { + $normalizedUrl = rtrim(preg_replace('/\.git$/', '', $pkg['url']), '/'); + $deps[$normalizedUrl] = $pkg['version'] ?? null; + } + } + + return $deps; + } } diff --git a/src/Exceptions/PluginConflictException.php b/src/Exceptions/PluginConflictException.php index b5cf768..891ff70 100644 --- a/src/Exceptions/PluginConflictException.php +++ b/src/Exceptions/PluginConflictException.php @@ -15,11 +15,13 @@ public function __construct(array $conflicts) $messages = []; foreach ($conflicts as $conflict) { $plugins = implode(' and ', $conflict['plugins']); - if ($conflict['type'] === 'namespace') { - $messages[] = "Namespace '{$conflict['value']}' is used by both {$plugins}"; - } else { - $messages[] = "Bridge function '{$conflict['value']}' is registered by both {$plugins}"; - } + $messages[] = match ($conflict['type']) { + 'namespace' => "Namespace '{$conflict['value']}' is used by both {$plugins}", + 'android_dependency' => "Android dependency '{$conflict['value']}' has version conflicts between {$plugins}", + 'ios_pod_dependency' => "CocoaPods dependency '{$conflict['value']}' has version conflicts between {$plugins}", + 'ios_swift_package_dependency' => "Swift Package '{$conflict['value']}' has version conflicts between {$plugins}", + default => "Bridge function '{$conflict['value']}' is registered by both {$plugins}", + }; } parent::__construct( diff --git a/src/Plugins/PluginRegistry.php b/src/Plugins/PluginRegistry.php index cf7c9d6..55ddc2e 100644 --- a/src/Plugins/PluginRegistry.php +++ b/src/Plugins/PluginRegistry.php @@ -214,6 +214,110 @@ public function detectConflicts(): array } } + return array_merge($conflicts, $this->detectDependencyConflicts($plugins)); + } + + /** + * Detect native dependency version conflicts between plugins. + * + * @return array}> + */ + protected function detectDependencyConflicts(Collection $plugins): array + { + $conflicts = []; + + // Collect all dependencies grouped by artifact identifier + $androidDeps = []; // keyed by "group:artifact" + $podDeps = []; // keyed by pod name + $swiftPkgDeps = []; // keyed by normalized URL + + foreach ($plugins as $plugin) { + // Android Gradle dependencies + foreach ($plugin->getAndroidDependencies() as $type => $libraries) { + foreach ($libraries as $library) { + $parts = explode(':', $library); + if (count($parts) >= 3) { + $artifact = $parts[0].':'.$parts[1]; + $version = $parts[2]; + $androidDeps[$artifact][] = [ + 'version' => $version, + 'plugin' => $plugin->name, + ]; + } + } + } + + // iOS dependencies + $iosDeps = $plugin->getIosDependencies(); + + // CocoaPods + foreach ($iosDeps['pods'] ?? [] as $pod) { + if (! isset($pod['name'])) { + continue; + } + $podDeps[$pod['name']][] = [ + 'version' => $pod['version'] ?? null, + 'plugin' => $plugin->name, + ]; + } + + // Swift Packages + foreach ($iosDeps['swift_packages'] ?? [] as $pkg) { + if (! isset($pkg['url'])) { + continue; + } + $normalizedUrl = rtrim(preg_replace('/\.git$/', '', $pkg['url']), '/'); + $swiftPkgDeps[$normalizedUrl][] = [ + 'version' => $pkg['version'] ?? null, + 'plugin' => $plugin->name, + ]; + } + } + + // Check for Android dependency version conflicts + foreach ($androidDeps as $artifact => $entries) { + $versions = array_unique(array_column($entries, 'version')); + if (count($versions) > 1) { + $pluginNames = array_unique(array_column($entries, 'plugin')); + $versionList = implode(' vs ', $versions); + $conflicts[] = [ + 'type' => 'android_dependency', + 'value' => "{$artifact} ({$versionList})", + 'plugins' => array_values($pluginNames), + ]; + } + } + + // Check for CocoaPods version conflicts + foreach ($podDeps as $podName => $entries) { + $versioned = array_filter($entries, fn ($e) => $e['version'] !== null); + $versions = array_unique(array_column($versioned, 'version')); + if (count($versions) > 1) { + $pluginNames = array_unique(array_column($entries, 'plugin')); + $versionList = implode(' vs ', $versions); + $conflicts[] = [ + 'type' => 'ios_pod_dependency', + 'value' => "{$podName} ({$versionList})", + 'plugins' => array_values($pluginNames), + ]; + } + } + + // Check for Swift Package version conflicts + foreach ($swiftPkgDeps as $url => $entries) { + $versioned = array_filter($entries, fn ($e) => $e['version'] !== null); + $versions = array_unique(array_column($versioned, 'version')); + if (count($versions) > 1) { + $pluginNames = array_unique(array_column($entries, 'plugin')); + $versionList = implode(' vs ', $versions); + $conflicts[] = [ + 'type' => 'ios_swift_package_dependency', + 'value' => "{$url} ({$versionList})", + 'plugins' => array_values($pluginNames), + ]; + } + } + return $conflicts; } diff --git a/tests/Feature/Plugins/PluginRegistryTest.php b/tests/Feature/Plugins/PluginRegistryTest.php index fee577d..05a300c 100644 --- a/tests/Feature/Plugins/PluginRegistryTest.php +++ b/tests/Feature/Plugins/PluginRegistryTest.php @@ -473,6 +473,200 @@ public function it_can_be_accessed_via_facade(): void $this->assertInstanceOf(PluginRegistry::class, app(PluginRegistry::class)); } + /** + * @test + * + * Should detect Android dependency version conflicts between plugins. + */ + public function it_detects_android_dependency_version_conflicts(): void + { + $pluginA = $this->createMockPlugin('vendor/plugin-a', [ + 'android' => [ + 'dependencies' => ['implementation' => ['com.google.firebase:firebase-messaging:23.1.0']], + ], + ]); + $pluginB = $this->createMockPlugin('vendor/plugin-b', [ + 'android' => [ + 'dependencies' => ['implementation' => ['com.google.firebase:firebase-messaging:24.0.0']], + ], + ]); + + $this->mockDiscovery + ->shouldReceive('discover') + ->andReturn(collect([$pluginA, $pluginB])); + + $conflicts = $this->registry->detectConflicts(); + + $this->assertNotEmpty($conflicts); + $depConflicts = array_filter($conflicts, fn ($c) => $c['type'] === 'android_dependency'); + $this->assertCount(1, $depConflicts); + + $conflict = array_values($depConflicts)[0]; + $this->assertStringContainsString('com.google.firebase:firebase-messaging', $conflict['value']); + $this->assertContains('vendor/plugin-a', $conflict['plugins']); + $this->assertContains('vendor/plugin-b', $conflict['plugins']); + } + + /** + * @test + * + * Should detect iOS CocoaPods version conflicts between plugins. + */ + public function it_detects_ios_pod_version_conflicts(): void + { + $pluginA = $this->createMockPlugin('vendor/plugin-a', [ + 'ios' => [ + 'dependencies' => [ + 'pods' => [['name' => 'FirebaseMessaging', 'version' => '10.0']], + ], + ], + ]); + $pluginB = $this->createMockPlugin('vendor/plugin-b', [ + 'ios' => [ + 'dependencies' => [ + 'pods' => [['name' => 'FirebaseMessaging', 'version' => '11.0']], + ], + ], + ]); + + $this->mockDiscovery + ->shouldReceive('discover') + ->andReturn(collect([$pluginA, $pluginB])); + + $conflicts = $this->registry->detectConflicts(); + + $depConflicts = array_filter($conflicts, fn ($c) => $c['type'] === 'ios_pod_dependency'); + $this->assertCount(1, $depConflicts); + + $conflict = array_values($depConflicts)[0]; + $this->assertStringContainsString('FirebaseMessaging', $conflict['value']); + } + + /** + * @test + * + * Should detect iOS Swift Package version conflicts between plugins. + */ + public function it_detects_ios_swift_package_version_conflicts(): void + { + $pluginA = $this->createMockPlugin('vendor/plugin-a', [ + 'ios' => [ + 'dependencies' => [ + 'swift_packages' => [ + ['url' => 'https://github.com/example/package.git', 'version' => '1.0.0'], + ], + ], + ], + ]); + $pluginB = $this->createMockPlugin('vendor/plugin-b', [ + 'ios' => [ + 'dependencies' => [ + 'swift_packages' => [ + ['url' => 'https://github.com/example/package', 'version' => '2.0.0'], + ], + ], + ], + ]); + + $this->mockDiscovery + ->shouldReceive('discover') + ->andReturn(collect([$pluginA, $pluginB])); + + $conflicts = $this->registry->detectConflicts(); + + $depConflicts = array_filter($conflicts, fn ($c) => $c['type'] === 'ios_swift_package_dependency'); + $this->assertCount(1, $depConflicts); + + $conflict = array_values($depConflicts)[0]; + $this->assertStringContainsString('github.com/example/package', $conflict['value']); + } + + /** + * @test + * + * Should allow identical dependencies across plugins without conflict. + */ + public function it_allows_identical_dependencies_across_plugins(): void + { + $pluginA = $this->createMockPlugin('vendor/plugin-a', [ + 'android' => [ + 'dependencies' => ['implementation' => ['com.google.firebase:firebase-core:21.0.0']], + ], + 'ios' => [ + 'dependencies' => [ + 'pods' => [['name' => 'FirebaseCore', 'version' => '10.0']], + 'swift_packages' => [ + ['url' => 'https://github.com/example/shared', 'version' => '1.0.0'], + ], + ], + ], + ]); + $pluginB = $this->createMockPlugin('vendor/plugin-b', [ + 'android' => [ + 'dependencies' => ['implementation' => ['com.google.firebase:firebase-core:21.0.0']], + ], + 'ios' => [ + 'dependencies' => [ + 'pods' => [['name' => 'FirebaseCore', 'version' => '10.0']], + 'swift_packages' => [ + ['url' => 'https://github.com/example/shared', 'version' => '1.0.0'], + ], + ], + ], + ]); + + $this->mockDiscovery + ->shouldReceive('discover') + ->andReturn(collect([$pluginA, $pluginB])); + + $conflicts = $this->registry->detectConflicts(); + + $depConflicts = array_filter($conflicts, fn ($c) => in_array($c['type'], [ + 'android_dependency', 'ios_pod_dependency', 'ios_swift_package_dependency', + ])); + $this->assertEmpty($depConflicts); + } + + /** + * @test + * + * Should not conflict when pods have no version constraints. + */ + public function it_ignores_dependencies_without_versions(): void + { + $pluginA = $this->createMockPlugin('vendor/plugin-a', [ + 'ios' => [ + 'dependencies' => [ + 'pods' => [['name' => 'SomePod']], + 'swift_packages' => [ + ['url' => 'https://github.com/example/pkg'], + ], + ], + ], + ]); + $pluginB = $this->createMockPlugin('vendor/plugin-b', [ + 'ios' => [ + 'dependencies' => [ + 'pods' => [['name' => 'SomePod']], + 'swift_packages' => [ + ['url' => 'https://github.com/example/pkg'], + ], + ], + ], + ]); + + $this->mockDiscovery + ->shouldReceive('discover') + ->andReturn(collect([$pluginA, $pluginB])); + + $conflicts = $this->registry->detectConflicts(); + + $depConflicts = array_filter($conflicts, fn ($c) => in_array($c['type'], [ + 'ios_pod_dependency', 'ios_swift_package_dependency', + ])); + $this->assertEmpty($depConflicts); + } + /** * Helper method to create a mock Plugin instance. */