diff --git a/.claude/commands/version-bump.md b/.claude/commands/version-bump.md index f4a5c84f..5b736bdb 100644 --- a/.claude/commands/version-bump.md +++ b/.claude/commands/version-bump.md @@ -39,6 +39,10 @@ Bump the plugin version to `$ARGUMENTS` across all 4 files that must stay in syn Tests: passed/failed ``` +### Note — native SDK pins are separate + +This command bumps the **plugin** version across the 4 surface files only. A **native-SDK** pin change is a different operation: it updates the podspec constants block (`APPSFLYER_IOS_SDK_VERSION` / `APPSFLYER_PURCHASE_CONNECTOR_VERSION`), which feeds both the CocoaPods `s.dependency` lines and the SPM `spm_dependency` calls. It does not touch the atomic-4 plugin-version files. See `.claude/rules/release-versioning.md` §8. + ### Fail-closed guardrail If any file cannot be read, or the version pattern is not found in any file, STOP and report which file failed. Do NOT partially update — all 4 files must be updated atomically or none. diff --git a/.claude/rules/release-versioning.md b/.claude/rules/release-versioning.md index ccb6c861..4db6aacf 100644 --- a/.claude/rules/release-versioning.md +++ b/.claude/rules/release-versioning.md @@ -77,13 +77,17 @@ Pre-6.x: tags use `v` prefix (`v1.2.0` through `v5.4.40`). The 6.x series has no ## 8. Native SDK dependency update +The iOS native-SDK pin lives in the podspec constants block, not in scattered literals. `react-native-appsflyer.podspec` defines `APPSFLYER_IOS_SDK_VERSION` and `APPSFLYER_PURCHASE_CONNECTOR_VERSION` at the top. Both the CocoaPods `s.dependency` lines and the SPM `spm_dependency` calls consume these constants, so a single edit keeps both resolution paths aligned by construction. Update the constant — never the inline version strings. The pod pin and the SPM pin must stay identical for each dependency. Verify with `ruby scripts/spm/verify_podspec_spm_parity.rb` (manual check — not wired into CI while the SPM path is early-adopter/opt-in). + When updating the native SDK version: -1. Update `react-native-appsflyer.podspec` dependency version +1. Update `APPSFLYER_IOS_SDK_VERSION` / `APPSFLYER_PURCHASE_CONNECTOR_VERSION` in `react-native-appsflyer.podspec` (single source of truth for both the pod and SPM paths) 2. Update `android/build.gradle` dependency version 3. Test that all existing bridge methods still compile against new headers 4. Check CHANGELOG of native SDK for breaking changes that affect the bridge 5. If native SDK added new APIs, decide whether to bridge them (minor bump if yes) +A native-SDK pin change is independent of the plugin-version 4-file surface (§1) — bumping the SDK constant does not touch `package.json`, `s.version`, `kAppsFlyerPluginVersion`, or `PLUGIN_VERSION`. + ## 9. Release checklist 1. All 4 version constants updated and matching diff --git a/Docs/RN_Installation.md b/Docs/RN_Installation.md index 11110c7e..3281fd36 100644 --- a/Docs/RN_Installation.md +++ b/Docs/RN_Installation.md @@ -172,3 +172,8 @@ In v6.8.0 of the AppsFlyer SDK, we added the normal permission com.google.androi to allow the SDK to collect the Android Advertising ID on apps targeting API 33. If your app is targeting children, you need to revoke this permission to comply with Google's Data policy. You can read more about it [here](https://dev.appsflyer.com/hc/docs/install-android-sdk#the-ad_id-permission). + +## Experimental: resolve the native iOS SDK via Swift Package Manager (early adopter) +CocoaPods is the supported default. For early adopters who want the native AppsFlyer iOS SDK resolved through **Swift Package Manager** instead, the plugin offers an **opt-in** path — it requires React Native 0.75+ and dynamic frameworks, and is not recommended for production yet. + +See **[Native AppsFlyer iOS SDK via SPM](/Docs/early-adopter/RN_NativeSDK_SPM.md)** for setup, requirements, and limitations. diff --git a/Docs/early-adopter/RN_NativeSDK_SPM.md b/Docs/early-adopter/RN_NativeSDK_SPM.md new file mode 100644 index 00000000..fdde7ed5 --- /dev/null +++ b/Docs/early-adopter/RN_NativeSDK_SPM.md @@ -0,0 +1,110 @@ +# Native AppsFlyer iOS SDK via SPM (early adopter) + +> Private early-adopter guide. Not linked from the public README. Supported only for selected early adopters on validated RN/Xcode setups — not an unofficial GA feature. + +## TL;DR + +An opt-in Podfile flag makes CocoaPods resolve the **native AppsFlyer iOS SDK binary** through Swift Package Manager (`spm_dependency`) instead of the `AppsFlyerFramework` pod. The plugin itself still installs the normal way (npm/yarn → CocoaPods → autolinking). CocoaPods stays the default and the fallback. Requires RN 0.75+ and dynamic frameworks. + +## What this changes (and what it doesn't) + +This does **not** install the React Native plugin via SPM. Only the native SDK binary's resolution changes: + +- **Changes:** with the flag set, CocoaPods resolves the native SDK through SPM (`spm_dependency`) instead of the `AppsFlyerFramework` pod. +- **Unchanged:** your JS API, the bridge, and autolinking. + +CocoaPods remains the default and fallback indefinitely. Nothing is auto-enabled. + +## Why this matters + +CocoaPods is in maintenance mode and the iOS ecosystem is moving to SPM: + +- Google discontinues CocoaPods support for its iOS SDKs immediately after **Q2 2026**, publishing exclusively via SPM. +- Sentry ends CocoaPods publishing for its SDKs at the **end of June 2026**. +- React Native is officially moving to SPM. The `spm_dependency` helper landed in **0.75**; first-class SPM for RN libraries (including local package refs) is targeted for **0.84**. + +As an app's *other* SDKs go SPM-only, that app may drop CocoaPods entirely. This mode is the on-ramp: it lets the app keep resolving the AppsFlyer native SDK while CocoaPods is phased out, without waiting for full plugin-as-SPM support in RN core. + +This matches where the RN ecosystem is converging — not a one-off. Sentry's RN SDK ships the identical pattern: opt-in (`SENTRY_USE_SPM=1`), `spm_dependency` in the podspec (not a standalone `Package.swift`), CocoaPods as the unchanged default, RN 0.75+. See [sentry-react-native PR #6182](https://github.com/getsentry/sentry-react-native/pull/6182) and tracking issue [#5780](https://github.com/getsentry/sentry-react-native/issues/5780). + +Full plugin-as-SPM (a `Package.swift` that vends the plugin itself) is **not** yet possible for RN libraries, because React Native does not publish itself as a Swift package. That's gated on RN 0.84+ ([proposal #587](https://github.com/react-native-community/discussions-and-proposals/issues/587)). + +## Setup + +Add to your `ios/Podfile`: + +```ruby +$RNAppsFlyerUseNativeSDKSPM = true +use_frameworks! :linkage => :dynamic +# optional — only if you use the Purchase Connector: +$AppsFlyerPurchaseConnector = true +``` + +Then add the embed helper below (also a Podfile change), and run `cd ios && pod install` once. + +## Required: embed the SPM framework + +CocoaPods embeds *pod* frameworks but **not** *SPM* products. Because the AppsFlyer SDK is now an SPM product, it is **not** copied into your app, and the app crashes at launch: + +``` +dyld: Library not loaded: @rpath/AppsFlyerLib.framework/AppsFlyerLib +``` + +The plugin ships a one-line Podfile helper that embeds it for you. Two additions to your `ios/Podfile`: + +```ruby +# 1. near the top, next to the existing react_native_pods require: +require Pod::Executable.execute_command('node', ['-p', + 'require.resolve("react-native-appsflyer/scripts/appsflyer_podfile.rb", {paths: [process.argv[1]]})', __dir__]).strip + +# 2. inside post_install, after react_native_post_install(...): +appsflyer_embed_native_sdk_spm!(installer) +``` + +Then `cd ios && pod install`. The helper auto-detects your app target, is idempotent, re-signs the framework on device builds, and is a **no-op when the SPM flag is off** — so it's safe to leave in your Podfile permanently. (This embedding is the cost of the CocoaPods-bridged SPM path; a future plugin-as-SPM build on RN 0.84+ would use the static product and need no embedding.) + +## Requirements + +| Requirement | Detail | +|---|---| +| RN 0.75+ | `spm_dependency` landed in 0.75.0. Below 0.75 the flag is ignored — the plugin warns and falls back to CocoaPods. No hard failure. | +| Dynamic frameworks | `spm_dependency` forces dynamic frameworks. Set `use_frameworks! :linkage => :dynamic`. Static linking with SPM packages causes duplicate-symbol errors. | +| Not Strict mode | Unsupported in v1. See below. | +| Not Expo | Unsupported in v1. See below. | + +### Strict mode is unsupported in v1 + +If `$RNAppsFlyerStrictMode` is enabled, the entire native dependency path — core **and** Purchase Connector — stays on CocoaPods, even when `$RNAppsFlyerUseNativeSDKSPM` is true. `AppsFlyerFramework/Strict` ships static-only, so it can't link under forced dynamic frameworks. A hybrid SPM-core + CocoaPods-Strict graph is unsafe, so Strict disables the whole SPM path. A Strict early adopter gets no SPM benefit in v1. + +### Expo is unsupported in v1 + +The config plugin (`expo/withAppsFlyerIos.js`) does not yet set the Podfile global flag or the dynamic-framework linkage during `expo prebuild`, so the SPM path is never configured on an Expo-managed project. Use bare CocoaPods if you need this mode. + +## Troubleshooting + +**Duplicate symbols.** This is the known failure mode of `spm_dependency` under dynamic frameworks. Disable the flag and return to CocoaPods (see [Rollback](#rollback)). If it persists on a validated setup, report the configuration to the AppsFlyer team. + +## Rollback + +Disable the flag in your Podfile: + +```ruby +# Podfile — remove or set to false +$RNAppsFlyerUseNativeSDKSPM = false +``` + +Then clear CocoaPods state and reinstall: + +```bash +cd ios && rm -rf Pods Podfile.lock && pod install +``` + +This returns you fully to the CocoaPods-resolved native SDK. + +## Privacy manifest + +The dynamic SPM xcframeworks bundle `PrivacyInfo.xcprivacy` inside each `.framework` (the Apple-scannable location), so the SPM/dynamic path is privacy-manifest-friendly as a side effect. This is **not** "the ITMS-91064 fix" — treat it only as a byproduct of dynamic linking. Before any App Store submission, still confirm the privacy manifest reaches the final app archive. + +## Known caveat — Purchase Connector binary drift + +Pinning the SPM Purchase Connector to `6.18.1` currently links the **6.18.0** binary: the upstream `PurchaseConnector-Dynamic` 6.18.1 tag points its binary target at the 6.18.0 asset (upstream tag/binary drift). The version string is correct; the linked binary is one patch behind. This is tracked with the AppsFlyer SDK team and will be re-checked on the next Purchase Connector bump. diff --git a/react-native-appsflyer.podspec b/react-native-appsflyer.podspec index c2da2019..39a1051d 100644 --- a/react-native-appsflyer.podspec +++ b/react-native-appsflyer.podspec @@ -1,7 +1,20 @@ require 'json' pkg = JSON.parse(File.read("package.json")) +# `unless defined?` guards keep CocoaPods from warning "already initialized constant" +# when it evaluates the podspec more than once during a single `pod install`. +APPSFLYER_IOS_SDK_VERSION = '6.18.0' unless defined?(APPSFLYER_IOS_SDK_VERSION) +APPSFLYER_PURCHASE_CONNECTOR_VERSION = '6.18.1' unless defined?(APPSFLYER_PURCHASE_CONNECTOR_VERSION) +APPSFLYER_SPM_CORE_DYNAMIC_URL = 'https://github.com/AppsFlyerSDK/AppsFlyerFramework-Dynamic' unless defined?(APPSFLYER_SPM_CORE_DYNAMIC_URL) +APPSFLYER_SPM_PURCHASE_CONNECTOR_DYNAMIC_URL = 'https://github.com/AppsFlyerSDK/PurchaseConnector-Dynamic' unless defined?(APPSFLYER_SPM_PURCHASE_CONNECTOR_DYNAMIC_URL) + Pod::Spec.new do |s| + spm_requested = defined?($RNAppsFlyerUseNativeSDKSPM) && $RNAppsFlyerUseNativeSDKSPM == true + spm_available = defined?(spm_dependency) ? true : false + strict_mode = defined?($RNAppsFlyerStrictMode) && $RNAppsFlyerStrictMode == true + # SPM resolves the native SDK only for the default (non-Strict) core, when requested and available. + use_spm = spm_requested && spm_available && !strict_mode + s.name = pkg["name"] s.version = pkg["version"] s.summary = pkg["description"] @@ -12,9 +25,17 @@ Pod::Spec.new do |s| s.source = { :git => pkg["repository"]["url"] } s.source_files = 'ios/**/*.{h,m,swift}' s.platform = :ios, "12.0" - s.static_framework = true + s.static_framework = !use_spm s.swift_version = '5.0' - s.dependency 'React' + # React dependency. The SPM path builds this pod as its own dynamic framework + # (use_frameworks! :linkage => :dynamic), where it must link React-Core so RCTEventEmitter + # (the base of RNAppsFlyer/PCAppsFlyer) resolves. install_modules_dependencies (RN 0.71+) + # wires React-Core and the linker config for that; the legacy static path keeps `React`. + if use_spm && defined?(install_modules_dependencies) + install_modules_dependencies(s) + else + s.dependency 'React' + end s.exclude_files = [ "ios/AFAdRevenueData.h", "ios/AppsFlyerConsent.h", @@ -26,23 +47,38 @@ Pod::Spec.new do |s| "ios/AppsFlyerShareInviteHelper.h", "ios/AppsFlyerLib.h" ] - - # AppsFlyerPurchaseConnector - if defined?($AppsFlyerPurchaseConnector) && ($AppsFlyerPurchaseConnector == true) - Pod::UI.puts "#{s.name}: Including PurchaseConnector." - s.dependency 'PurchaseConnector', '6.18.1' + + if spm_requested && !spm_available + Pod::UI.warn "#{s.name}: $RNAppsFlyerUseNativeSDKSPM set but React Native's spm_dependency helper is unavailable (requires RN 0.75+). Falling back to CocoaPods for the native SDK." + end + if spm_requested && strict_mode + Pod::UI.puts "#{s.name}: $RNAppsFlyerUseNativeSDKSPM is ignored because $RNAppsFlyerStrictMode is enabled. AppsFlyerFramework/Strict is static-only and unsupported through SPM in this release. Falling back to CocoaPods for the native SDK." end - # AppsFlyerFramework - if defined?($RNAppsFlyerStrictMode) && ($RNAppsFlyerStrictMode == true) + # AppsFlyer core SDK. Strict mode forces the CocoaPods path (static-only); the default core can use SPM when opted in. + if strict_mode Pod::UI.puts "#{s.name}: Using AppsFlyerFramework/Strict mode" - s.dependency 'AppsFlyerFramework/Strict', '6.18.0' + s.dependency 'AppsFlyerFramework/Strict', APPSFLYER_IOS_SDK_VERSION s.xcconfig = {'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) AFSDK_NO_IDFA=1' } else if !defined?($RNAppsFlyerStrictMode) Pod::UI.puts "#{s.name}: Using default AppsFlyerFramework. You may require App Tracking Transparency. Not allowed for Kids apps." Pod::UI.puts "#{s.name}: You may set variable `$RNAppsFlyerStrictMode=true` in Podfile to use strict mode for kids apps." end - s.dependency 'AppsFlyerFramework', '6.18.0' + if use_spm + spm_dependency(s, url: APPSFLYER_SPM_CORE_DYNAMIC_URL, requirement: { kind: 'exactVersion', version: APPSFLYER_IOS_SDK_VERSION }, products: ['AppsFlyerLib-Dynamic']) + else + s.dependency 'AppsFlyerFramework', APPSFLYER_IOS_SDK_VERSION + end + end + + # PurchaseConnector (optional). PurchaseConnector-Dynamic has no transitive core dependency; the core block above declares the dynamic core. + if defined?($AppsFlyerPurchaseConnector) && ($AppsFlyerPurchaseConnector == true) + Pod::UI.puts "#{s.name}: Including PurchaseConnector." + if use_spm + spm_dependency(s, url: APPSFLYER_SPM_PURCHASE_CONNECTOR_DYNAMIC_URL, requirement: { kind: 'exactVersion', version: APPSFLYER_PURCHASE_CONNECTOR_VERSION }, products: ['PurchaseConnector-Dynamic']) + else + s.dependency 'PurchaseConnector', APPSFLYER_PURCHASE_CONNECTOR_VERSION + end end end diff --git a/scripts/appsflyer_podfile.rb b/scripts/appsflyer_podfile.rb new file mode 100644 index 00000000..fc66d5cd --- /dev/null +++ b/scripts/appsflyer_podfile.rb @@ -0,0 +1,75 @@ +# AppsFlyer React Native — Podfile helper for the opt-in SPM native-SDK path. +# +# Usage (ios/Podfile): +# require Pod::Executable.execute_command('node', ['-p', +# 'require.resolve("react-native-appsflyer/scripts/appsflyer_podfile.rb", {paths: [process.argv[1]]})', +# __dir__]).strip +# ... +# post_install do |installer| +# react_native_post_install(installer, config[:reactNativePath], :mac_catalyst_enabled => false) +# appsflyer_embed_native_sdk_spm!(installer) # <-- one line +# end +# +# Why this is needed: when $RNAppsFlyerUseNativeSDKSPM is enabled, the AppsFlyer +# iOS SDK is resolved as an SPM product. CocoaPods embeds *pod* frameworks into the +# app but not *SPM* products, so without this the app crashes at launch with +# "dyld: Library not loaded: @rpath/AppsFlyerLib.framework/AppsFlyerLib". +# +# This helper adds an "Embed AppsFlyer SPM Frameworks" Run Script build phase to the +# app target that copies (and, on device builds, re-signs) the SPM frameworks into the +# app bundle. It is a no-op unless the SPM flag is enabled, and is idempotent. +require 'xcodeproj' + +AF_SPM_EMBED_PHASE_NAME = 'Embed AppsFlyer SPM Frameworks'.freeze + +AF_SPM_EMBED_SCRIPT = <<~'SH'.freeze + set -e + DEST="${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" + for fw in AppsFlyerLib PurchaseConnector; do + SRC="${BUILT_PRODUCTS_DIR}/${fw}.framework" + if [ -d "$SRC" ]; then + mkdir -p "$DEST" + rsync -a --delete "$SRC/" "$DEST/${fw}.framework/" + if [ "${CODE_SIGNING_REQUIRED:-NO}" != "NO" ] && [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ]; then + codesign --force --sign "${EXPANDED_CODE_SIGN_IDENTITY}" "$DEST/${fw}.framework" + fi + fi + done +SH + +# Adds the embed build phase to the app target(s). Pass the `installer` from post_install +# so we modify the SAME user-project object CocoaPods saves (modifying a separately-opened +# copy gets clobbered by CocoaPods' own integration save). +def appsflyer_embed_native_sdk_spm!(installer) + return unless defined?($RNAppsFlyerUseNativeSDKSPM) && $RNAppsFlyerUseNativeSDKSPM == true + + found_app_target = false + modified_projects = [] + + installer.aggregate_targets.each do |aggregate| + project = aggregate.user_project + next if project.nil? + + aggregate.user_targets.each do |app_target| + next unless app_target.respond_to?(:product_type) && + app_target.product_type == 'com.apple.product-type.application' + + found_app_target = true + next if app_target.shell_script_build_phases.any? { |p| p.name == AF_SPM_EMBED_PHASE_NAME } # idempotent + + phase = app_target.new_shell_script_build_phase(AF_SPM_EMBED_PHASE_NAME) + phase.shell_script = AF_SPM_EMBED_SCRIPT + modified_projects << project + Pod::UI.puts "[AppsFlyer] Embedded SPM frameworks into target '#{app_target.name}'." + end + end + + # Save only the projects we actually modified, once each. + modified_projects.uniq.each(&:save) + + # Warn only when there is genuinely no app target — NOT on idempotent re-runs + # where the phase already exists (found_app_target is true but nothing changed). + unless found_app_target + Pod::UI.warn('[AppsFlyer] SPM embed: no application target found; framework not embedded.') + end +end diff --git a/scripts/spm/verify_podspec_spm_parity.rb b/scripts/spm/verify_podspec_spm_parity.rb new file mode 100755 index 00000000..6689b2cf --- /dev/null +++ b/scripts/spm/verify_podspec_spm_parity.rb @@ -0,0 +1,275 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# ============================================================================= +# Manual check: podspec SPM <-> CocoaPods parity for the AppsFlyer native SDK +# ============================================================================= +# +# Run manually before releasing the (early-adopter) SPM-native-SDK feature, or +# whenever the native SDK pin changes: +# +# ruby scripts/spm/verify_podspec_spm_parity.rb +# +# Asserts the statically-checkable SPM <-> CocoaPods parity invariants for the +# AppsFlyer native SDK. It does NOT build anything and is NOT wired into CI +# (the feature is opt-in/early-adopter; parity is verified by hand for now). +# Feature overview: Docs/early-adopter/RN_NativeSDK_SPM.md +# +# Checks: +# 1. Core SPM URL points to AppsFlyerFramework-Dynamic (never a -Static package); +# PurchaseConnector SPM URL points to PurchaseConnector-Dynamic. +# 2. No SPM URL references a -Static package. +# 3. The version used for each spm_dependency matches the corresponding +# s.dependency version. Parity is expressed by both referencing the SAME +# top-of-file constant (APPSFLYER_IOS_SDK_VERSION / APPSFLYER_PURCHASE_CONNECTOR_VERSION). +# A hardcoded version literal in any dependency line that diverges from the +# constant is a failure. +# +# Design: the podspec is Ruby, but we deliberately DO NOT `Pod::Spec.new` it +# (no CocoaPods load, no side effects, runs anywhere with plain ruby). We +# regex-extract the constants and the dependency / spm_dependency lines. +# +# Graceful degradation: a podspec with NO spm_dependency lines is valid (pod-only, +# legacy state) -> parity trivially holds, exit 0 with an informational note. +# +# Exit codes: 0 = parity holds (or pod-only). Non-zero = mismatch (message printed). +# +# TODO(spm-release-smoke): This guard is statically-checkable invariants only. +# The expensive build/smoke matrix (dynamic-frameworks build, duplicate-symbol +# linkage, New-Architecture in SPM mode, behavioral deep-link/conversion/logEvent +# parity, core-header-resolution) lives in the separate release-blocking smoke job +# (not here). Do NOT add those here; this job must stay fast. +# ============================================================================= + +DEFAULT_PODSPEC = File.expand_path('../../react-native-appsflyer.podspec', __dir__) + +EXPECTED_CONSTANTS = { + 'APPSFLYER_SPM_CORE_DYNAMIC_URL' => + 'https://github.com/AppsFlyerSDK/AppsFlyerFramework-Dynamic', + 'APPSFLYER_SPM_PURCHASE_CONNECTOR_DYNAMIC_URL' => + 'https://github.com/AppsFlyerSDK/PurchaseConnector-Dynamic' +}.freeze + +CORE_VERSION_CONST = 'APPSFLYER_IOS_SDK_VERSION' +PC_VERSION_CONST = 'APPSFLYER_PURCHASE_CONNECTOR_VERSION' + +class ParityError < StandardError; end + +# Extracts `NAME = 'value'` / `NAME = "value"` top-level constant assignments. +def extract_constants(source) + consts = {} + source.each_line do |line| + next unless (m = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(['"])(.*?)\2/)) + + consts[m[1]] = m[3] + end + consts +end + +# Returns the bare argument text of each `spm_dependency(...)` CALL as a string. +# Matches only a real invocation: the token must be `spm_dependency` immediately +# followed by `(`, NOT preceded by an identifier char (so `af_spm_dependency_...` +# is skipped) and NOT the bare `defined?(spm_dependency)` reference (no `(` after). +# Handles multi-line calls by scanning from the opening paren to its match. +def extract_spm_dependency_args(source) + calls = [] + source.to_enum(:scan, /(? not the canonical one; treat as mismatch + # unless it resolves to the same value (we only know our own constants). + false +end + +def run(podspec_path) + unless File.file?(podspec_path) + raise ParityError, "podspec not found: #{podspec_path}" + end + + source = File.read(podspec_path) + consts = extract_constants(source) + spm_calls = extract_spm_dependency_args(source) + + if spm_calls.empty? + puts "[spm-parity] No spm_dependency calls found in #{File.basename(podspec_path)}." + puts '[spm-parity] Pod-only (legacy) podspec — SPM parity trivially holds. PASS.' + return + end + + errors = [] + + # --- URLs: must use the canonical Dynamic constants, and no -Static anywhere. + EXPECTED_CONSTANTS.each do |name, expected_url| + actual = consts[name] + if actual.nil? + errors << "Constant #{name} is not defined (expected #{expected_url})." + elsif actual != expected_url + errors << "Constant #{name} = #{actual.inspect}, expected #{expected_url.inspect}." + end + end + + consts.each do |name, value| + next unless name.start_with?('APPSFLYER_SPM_') && name.end_with?('_URL') + + errors << "SPM URL constant #{name} references a -Static package: #{value.inspect}." if value =~ /-Static\b/ + end + + spm_calls.each do |args| + url = resolve_value(parse_kw(args, 'url'), consts) + errors << "spm_dependency uses a -Static URL: #{url.inspect}." if url && url =~ /-Static/ + products = parse_kw(args, 'products') + errors << "spm_dependency products reference a -Static product: #{products.inspect}." if products && products =~ /-Static/ + end + + # --- Version parity: each spm_dependency requirement must reference the same + # constant as the matching s.dependency pod line. + pod_deps = extract_pod_dependencies(source) + + # Default AND Strict core variants both pin APPSFLYER_IOS_SDK_VERSION — check every one. + core_pods = pod_deps.select { |name, _| name == 'AppsFlyerFramework' || name == 'AppsFlyerFramework/Strict' } + pc_pod = pod_deps.find { |name, _| name == 'PurchaseConnector' } + + core_pods.each do |name, version| + next if version_token_matches_constant?(version, CORE_VERSION_CONST, consts[CORE_VERSION_CONST]) + + errors << "Pod dependency '#{name}' pins #{version.inspect}, " \ + "expected reference to #{CORE_VERSION_CONST} (= #{consts[CORE_VERSION_CONST].inspect})." + end + if pc_pod && !version_token_matches_constant?(pc_pod[1], PC_VERSION_CONST, consts[PC_VERSION_CONST]) + errors << "PurchaseConnector pod dependency pins #{pc_pod[1].inspect}, " \ + "expected reference to #{PC_VERSION_CONST} (= #{consts[PC_VERSION_CONST].inspect})." + end + + spm_calls.each do |args| + url = resolve_value(parse_kw(args, 'url'), consts) + req = parse_kw(args, 'requirement') + next if url.nil? || req.nil? + + const_name = + if url.include?('AppsFlyerFramework-Dynamic') + CORE_VERSION_CONST + elsif url.include?('PurchaseConnector-Dynamic') + PC_VERSION_CONST + end + if const_name.nil? + errors << "spm_dependency url #{url.inspect} is neither the core nor PurchaseConnector Dynamic package." + next + end + + # Product must be the Dynamic product for this URL (a -Static or bare static + # product, e.g. 'AppsFlyerLib', must NOT pass). + products = parse_kw(args, 'products') + expected_product = const_name == CORE_VERSION_CONST ? 'AppsFlyerLib-Dynamic' : 'PurchaseConnector-Dynamic' + unless products && products.include?(expected_product) + errors << "spm_dependency for #{const_name} must use the Dynamic product #{expected_product.inspect}, got #{products.inspect}." + end + + # Requirement must be an EXACT pin whose version is the matching top-of-file + # constant — not merely a string that mentions the constant (a range that + # name-drops the constant must NOT pass). + kind = resolve_value(parse_kw(req, 'kind'), consts) + version_tok = parse_kw(req, 'version') + if kind != 'exactVersion' + errors << "spm_dependency for #{const_name} must use { kind: 'exactVersion', ... }; got requirement #{req.inspect}." + elsif version_tok.nil? || !version_token_matches_constant?(version_tok, const_name, consts[const_name]) + errors << "spm_dependency for #{const_name} version #{version_tok.inspect} does not match #{const_name} (= #{consts[const_name].inspect})." + end + end + + unless errors.empty? + raise ParityError, "podspec SPM parity FAILED:\n - #{errors.join("\n - ")}" + end + + puts "[spm-parity] #{spm_calls.length} spm_dependency call(s) checked against pod pins. PASS." +end + +if $PROGRAM_NAME == __FILE__ + path = ARGV[0] || DEFAULT_PODSPEC + begin + run(path) + rescue ParityError => e + warn "[spm-parity] ERROR: #{e.message}" + exit 1 + end +end