Conditional Compilation On Project Build Flags

ID

swift.conditional_compilation

Severity

high

Remediation Complexity

medium

Remediation Risk

low

Remediation Effort

medium

Resource

Code Smell

Language

Swift

Tags

code-style, maintainability

Description

Reports an #if / #elseif directive whose condition tests a project-specific build flag. Platform and toolchain conditions — os(…​), arch(…​), swift(…​), compiler(…​), canImport(…​), targetEnvironment(…​) — are never reported, and the conventional configuration flags are allow-listed.

Rationale

#if MY_FEATURE_FLAG
func experimental() {
    resetEverything()
}
#endif

A project-specific build flag splits the source into variants that are never all compiled together. Whichever branch the current build settings do not select is not type-checked, not covered by tests, and not seen by the reader who only knows the default configuration: it can reference a symbol that was renamed months ago and nothing will say so until somebody flips the flag. The flag itself lives in the build system, far from the code it silently changes, so what a file does can no longer be read from the file.

The cost grows multiplicatively. Two independent flags describe four builds, only one of which anybody runs; a bug reproducible in one of the other three has no test that could have caught it.

This is not an argument against #if as such. Conditional compilation is the correct — and often the only — way to write code that spans platforms, SDK versions or Swift language versions. The distinction the rule draws is between a condition that describes where the code is being compiled, which the compiler and the reader can both evaluate, and a flag the project invented, which neither can.

Notes

The trigger is narrowed twice so that idiomatic code never fires:

  • Platform and toolchain conditions are never reported, structurally rather than by configuration: os(…​), arch(…​), swift(…​), compiler(…​), canImport(…​) and targetEnvironment(…​) are recognised as platform conditions, and identifiers inside one — the module name of a canImport(…​), for instance — are skipped. No property can switch this exemption off by accident.

  • Conventional flags are allow-listed through allowedFlags. DEBUG and RELEASE are idiomatic Swift for build-configuration differences; SWIFT_PACKAGE is defined by the package manager itself rather than by the project.

A block is reported once, at the first clause carrying such a flag. #elseif clauses are checked as well as the leading #if, and the #if form that guards switch cases is covered too.

Configuration

  • allowedFlags (default [DEBUG, RELEASE, SWIFT_PACKAGE]) — build-flag names that are never reported, matched exactly (Swift build flags are case-sensitive). Add the flags your project has agreed to keep; anything not listed is reported.

Remediation

Prefer a runtime decision the compiler can always check, and the tests can exercise in both states:

enum FeatureFlags {
    static var experimentalEnabled: Bool {
        return ProcessInfo.processInfo.environment["EXPERIMENTAL"] != nil
    }
}

func experimental() {
    guard FeatureFlags.experimentalEnabled else { return }
    resetEverything()
}

Both branches now compile in every build, so a rename cannot rot the disabled path, and a test can run the code with the flag on and off.

When the difference genuinely belongs at build time — a platform-specific API, an SDK that only exists on one OS — say so with a platform condition, which this rule never reports:

#if canImport(UIKit)
import UIKit
#endif

When the flag must stay, keep it in the allow-list rather than leaving it unreviewed, so the decision is recorded once instead of being re-litigated at each finding.

© 2026 Xygeni. All rights reserved