PureXML/third_party/rules-swift/exp/dependency-management.md
Mihaela Mihaljevic c7ef8abfd9 chore(rules): pin rules-swift corpus, add gates and CI; drop redundant docs/rules mirror
Adopt the rules-swift pin-and-codify strategy in snapshot mode (third_party/rules-swift
@97dc70f + sha256), add codified gates, a conformance audit ledger, and
.github/workflows/rules-swift.yml (corpus-pin integrity required, full gate report mode).
Remove the docs/rules mirror it supersedes (repo-specific files kept under docs/). Style
and leak gates exclude the vendored corpus.
2026-06-30 14:02:40 +02:00

6.5 KiB

Dependency Management

Unidirectional flow, circular-dependency prevention, and platform-specific dependency separation.

Unidirectional Flow

Foundation (SharedModels, AppColors, AppFont)
    ↓
Design System (AppTheme = AppColors + AppFont)
    ↓
Infrastructure (ApiClient, ApiServer, Middleware, SharedViews)
    ↓
Components (Components [core], SharedComponents, AppComponents)
    ↓
Features (AuthFeature, AppFeature, etc.)
    ↓
Apps (iosApp, macApp)

Dependencies only flow downward. NEVER import from a higher layer.

Circular Dependency Prevention

❌ DON'T:

// AuthFeature → AppFeature
// AppFeature → AuthFeature
// CIRCULAR DEPENDENCY!

✅ DO:

// Extract shared code to new package
// Both features depend on: SharedAuthModels

Platform-Specific Dependencies

Separate platform-specific products and targets using #if os() conditionals.

Products Separation

// ---------- Base Products (All Platforms) ----------
let baseProducts: [Product] = [
    .singleTargetLibrary("ApiShared"),
    .singleTargetLibrary("ApiClient"),
    .singleTargetLibrary("ApiServer"),
    .singleTargetLibrary("SharedModels"),
    .singleTargetLibrary("OpenAPICachingMiddleware"),
    .executable(name: "apiserverapp", targets: ["ApiServerApp"]),
]

// ---------- Apple-Only Products (iOS + macOS) ----------
#if os(iOS) || os(macOS)
let appleOnlyProducts: [Product] = [
    .singleTargetLibrary("AppTheme"),
    .singleTargetLibrary("SharedViews"),
    .singleTargetLibrary("AuthFeature"),
    .singleTargetLibrary("AppFeature"),
    .singleTargetLibrary("AppFont"),
    .singleTargetLibrary("BetaSettingsFeature"),
    .singleTargetLibrary("DemoAppFeature"),
    .singleTargetLibrary("SharedComponents"),
    .singleTargetLibrary("Components"),
    .singleTargetLibrary("BenefitsComponents"),
    .singleTargetLibrary("AllComponents"),
]
#else
let appleOnlyProducts: [Product] = []
#endif

// ---------- Combine All Products ----------
let allProducts = baseProducts + appleOnlyProducts + [
    .singleTargetLibrary("PlaybookFeature"),  // Always exposed for Xcode scheme visibility
]

Targets Separation

let targets: [Target] = {
    // ---------- Base Targets (All Platforms) ----------
    let sharedModelsTarget = Target.target(
        name: "SharedModels",
        dependencies: []
    )
    let apiClientTarget = Target.target(
        name: "ApiClient",
        dependencies: ["ApiShared", "SharedModels"]
    )
    let baseTargets = [
        sharedModelsTarget,
        apiClientTarget,
    ]

    // ---------- Apple-Only Targets (iOS + macOS) ----------
    #if os(iOS) || os(macOS)
    let appColorsTarget = Target.target(
        name: "AppColors",
        dependencies: []  // Foundation: zero dependencies
    )
    let appThemeTarget = Target.target(
        name: "AppTheme",
        dependencies: [
            "AppColors",  // Design system combines colors + fonts
            "AppFont",
        ]
    )
    let sharedViewsTarget = Target.target(
        name: "SharedViews",
        dependencies: [
            "AppColors",
            "AppFont",
            .product(name: "Inject", package: "Inject"),
        ]
    )
    let authFeatureTarget = Target.target(
        name: "AuthFeature",
        dependencies: ["SharedModels", "SharedViews", "AppColors", "AppFont"]
    )
    let appleTargets = [
        appColorsTarget,
        appThemeTarget,
        sharedViewsTarget,
        authFeatureTarget,
    ]
    #else
    let appleTargets: [Target] = []
    #endif

    // ---------- PlaybookFeature (Always Defined, Conditionally Linked) ----------
    let playbookTarget = Target.target(
        name: "PlaybookFeature",
        dependencies: [
            "Components",
            "AppComponents",
            "SharedModels",
            "ApiClient",
            .product(name: "Inject", package: "Inject"),
            .product(
                name: "Playbook",
                package: "playbook-ios",
                condition: .when(platforms: [.iOS])  // ← Platform-specific dependency
            ),
            .product(
                name: "PlaybookUI",
                package: "playbook-ios",
                condition: .when(platforms: [.iOS])
            ),
        ]
    )

    return baseTargets + appleTargets + [playbookTarget]
}()

Why Separate Platforms?

Benefits:

  • ✅ Server targets build on Linux CI without Apple SDKs
  • ✅ UI targets only compile on Apple platforms
  • ✅ Clear separation between backend and frontend code
  • ✅ Prevents accidental dependencies on Apple frameworks in server code
  • ✅ Faster CI builds (Linux can skip UI packages)

When to Use:

  • UI components, SwiftUI views → #if os(iOS) || os(macOS)
  • Shared models, API contracts → No conditional (base products)
  • Server code, Vapor endpoints → No conditional (base products)
  • Apple-only frameworks (UIKit, AppKit) → #if os(iOS) || os(macOS)

Use #if os() for platform detection in Package.swift, never #if canImport(UIKit). #if canImport is evaluated lazily and silently breaks the manifest on Linux CI; #if os() is evaluated at manifest-parse time and gives deterministic builds across all platforms. This is the most common SPM landmine on cross-platform packages.

Acceptance check

Conforms when:

  • Manifest platform detection uses #if os(), never #if canImport. This is the named SPM landmine: #if canImport(UIKit) silently breaks the manifest on Linux CI.
grep -n '#if canImport' Packages/Package.swift Package.swift

Output MUST be empty; platform splits in the manifest use #if os(iOS) || os(macOS).

  • No dependency cycle. Resolution and build succeed, proving the circular-dependency-prevention rule's floor:
swift package resolve && swift build

Both MUST exit 0.

  • Observable checklist for the flow rules, each a clear pass/fail: dependencies flow only downward through the foundation, design-system, infrastructure, components, features, apps layering, with no import from a higher layer; when two packages would depend on each other, the shared code is extracted to a new package rather than creating a cycle; Apple-only products and targets sit behind #if os(iOS) || os(macOS) while shared models, API contracts, and server code stay unconditional (base); and a platform-specific dependency is attached with condition: .when(platforms:) rather than duplicated. A server target that builds on Linux CI without Apple SDKs is the observable signal that the separation holds.