Swift (iOS)
iOS applications.
What this is
iOS applications. Its BLOCKER tier is almost entirely the concurrency and safety rules Swift's structured concurrency exists to prevent violations of: UI mutated off the main actor, the main thread blocked synchronously, an unstructured Task {} left uncancelled, retain cycles from self captured strongly in stored closures, force-unwrap and try! in production paths. A smaller thread pushes toward async/await over completion handlers and Keychain over UserDefaults for anything sensitive.
How to onboard it
A repository picks up this stack by onboarding on a profile that includes it. redline init detects the profile from what is in the repository, so in most cases this is automatic:
$ npx redlinegate init # detects the profile $ npx redlinegate init --profile mobile-ios # or name one
One profile pulls these rules in: mobile-ios.
Once onboarded, your files match this stack when they fit any of these globs:
**/*.swift
How to use it — 21 rules
Nothing to run. Once your profile includes Swift (iOS), redline init renders these rules into your repository's AI tooling and the reviewer applies them on every pull request. When a review comment cites one of these ids, this table is where to look up what it catches and why.
| Rule id | Severity | Catches |
|---|---|---|
swift/force-operations | BLOCKER | Force operations in production paths: `!` force-unwrap, `try!`, `as!` — restructure with `guard let`, `if let`, `try?` + handling, or fix the optionality. |
swift/retain-cycle | BLOCKER | Retain cycles: `self` captured strongly in `@escaping` closures stored by the object (handlers, subscriptions, timers) — require `[weak self]` and early-return pattern. |
swift/ui-off-main-actor | BLOCKER | UI mutation off the main actor — UIKit/SwiftUI state must be touched on `@MainActor` / main queue; no fire-and-forget background completion touching views. |
swift/blocking-main-thread | BLOCKER | Blocking the main thread: synchronous network/disk on main, `DispatchQueue.main.sync` from the main queue (deadlock). |
swift/uncancelled-task | BLOCKER | Unstructured `Task {}` in views without cancellation — tie to `.task {}` modifier or store and cancel; leaked tasks outlive the screen. |
swift/sensitive-data-in-userdefaults | BLOCKER | Sensitive data in `UserDefaults` — tokens/credentials belong in Keychain. |
swift/unconfined-singleton-state | HIGH | Singletons with mutable state and no actor/queue confinement — convert to `actor` or confine. |
swift/completion-handler-in-new-code | HIGH | Completion-handler APIs in new code where async/await is available. |
swift/missing-mainactor | HIGH | Missing `@MainActor` on ObservableObject/ViewModel classes driving UI. |
swift/unremoved-observer | HIGH | `NotificationCenter` observers without removal (pre-iOS 9-style APIs) or Combine subscriptions without `cancellables` storage. |
swift/oversized-view | HIGH | Massive view controllers / SwiftUI views over ~300 lines — extract subviews and view models. |
swift/stringly-typed-identifiers | HIGH | Stringly-typed identifiers (segues, notification names, userInfo keys) — use enums/constants. |
swift/swallowed-decode-failure | HIGH | `Codable` decode failures swallowed with `try?` where the failure matters. |
swift/concurrency-checking-opt-out | HIGH | Concurrency checking switched off to silence the compiler — `@unchecked Sendable`, `nonisolated(unsafe)`, `@preconcurrency import`, `MainActor.assumeIsolated`. Each removes data-race checking, so `final class Cache: @unchecked Sendable { var items = [Key: Value]() }` corrupts its dictionary under concurrent writes and crashes intermittently. Name the lock, queue or invariant that makes it safe, inline, with a ticket. Not when every mutable member really is guarded — say by what. |
swift/unowned-capture | HIGH | `unowned` in a closure capture list. `[unowned self]` in an escaping closure crashes when the closure runs after its owner is deallocated ("Attempted to read an unowned reference…"), and `unowned(unsafe)` is undefined behaviour. Use `[weak self]` with `guard let self else { return }`. Not in a non-escaping closure or a `lazy var` initialiser. |
swift/strong-delegate | HIGH | A delegate held strongly. A stored `var delegate: SomeDelegate?` without `weak` forms a cycle with the owner that sets itself as delegate, so every push and pop of the screen leaks the controller and it keeps receiving callbacks. Declare it `weak var` and constrain the protocol to `AnyObject`. |
swift/implicitly-unwrapped-declaration | HIGH | An implicitly unwrapped optional outside `@IBOutlet`. `var viewModel: ProfileViewModel!` force-unwraps on every read with no `!` at the use site, so a navigation path that forgets to set it crashes far from the cause. Inject a non-optional through `init`, or use a real optional and handle it. Not for `@IBOutlet` or XCTest fixtures set in `setUp`. |
swift/copying-reduce-accumulator | HIGH | `reduce` with a copy-on-write accumulator — `reduce([]) { $0 + [$1] }`, `reduce("") { $0 + … }`, `reduce([K: V]())`. Each element copies the whole accumulator, so the loop is quadratic: hex-encoding a 1 MB payload stalls the main thread. Use `reduce(into:)`, `flatMap`, `joined()` or `Dictionary(grouping:)`. |
swift/prefer-value-types | SUGGESTION | Prefer `struct` value types; classes only for identity or reference semantics. |
swift/prefer-typed-throws | SUGGESTION | `Result` grab-bags where typed `throws` is clearer. |
swift/prefer-guard-early-exit | SUGGESTION | Prefer `guard` early-exit over nested `if let` pyramids. |
Expected output
A finding from this file, and every finding Redline produces, opens with a machine-readable first line — severity, then the rule id in brackets, then the problem in one line:
Redline/BLOCKER [swift/force-operations]: <one-line problem>Ids are aggregated per rule, which is how the organisation finds out which rules earn their place and which only generate noise — so a finding without a valid id cannot be measured and counts as untagged. Of the 21 rules here, 6 BLOCKER, 12 HIGH and 3 SUGGESTION. Only a BLOCKER must not merge; a SUGGESTION may be dismissed without justification, and is never upgraded to get attention.
If a rule here fires constantly on code your team has deliberately decided to allow, that is the signal to raise with the standards owner — the rule id is what makes that conversation measurable — not to argue it away comment by comment.
How to edit it
Rules are edited in standards/stacks/swift.md and nowhere else. The rendered copies in AGENTS.md, .github/copilot-instructions.md and .github/instructions/ are generated and are overwritten by the next render. A change here propagates to every onboarded repository as a pull request, so treat it as a production change.
- Edit the markdownstandards/ is the only place a human edits a rule. Everything under AGENTS.md, .github/copilot-instructions.md and .github/instructions/ is rendered from it and is overwritten by the next render.
- node scripts/assign-rule-ids.mjsAssigns a permanent <stack>/<slug> id to any new rule bullet and rewrites the file in place. Do not invent an id by hand. CI runs the same script with --check and fails if a rule is missing one.
- node scripts/render-self.mjsRe-renders this repository's own artifacts from the edited source. CI runs it with --check, so stale checked-in output fails the build.
- Bump standards/manifest.json → versionRequired in the same pull request as the rule change. Sync pull requests quote the version, so a repository's rendered artifacts always name where they came from.
- Add a CHANGELOG.md entryAlso in the same pull request. A standards change with no measurement is an opinion — record the seed score alongside it.
- node scripts/validate.mjsThe bundle self-check CI runs: manifest integrity, well-formed rule ids, the severity output contract surviving your edit, glob portability.
Rule ids are permanent. Rewording a rule is fine and keeps its id; renaming or removing an id orphans every historical telemetry record that cited it.
The full file
# Swift (iOS) Review Rules
## BLOCKER — request changes
- `swift/force-operations` — **Force operations in production paths**: `!` force-unwrap, `try!`, `as!` — restructure with `guard let`, `if let`, `try?` + handling, or fix the optionality.
- `swift/retain-cycle` — **Retain cycles**: `self` captured strongly in `@escaping` closures stored by the object (handlers, subscriptions, timers) — require `[weak self]` and early-return pattern.
- `swift/ui-off-main-actor` — **UI mutation off the main actor** — UIKit/SwiftUI state must be touched on `@MainActor` / main queue; no fire-and-forget background completion touching views.
- `swift/blocking-main-thread` — **Blocking the main thread**: synchronous network/disk on main, `DispatchQueue.main.sync` from the main queue (deadlock).
- `swift/uncancelled-task` — **Unstructured `Task {}` in views without cancellation** — tie to `.task {}` modifier or store and cancel; leaked tasks outlive the screen.
- `swift/sensitive-data-in-userdefaults` — **Sensitive data in `UserDefaults`** — tokens/credentials belong in Keychain.
## HIGH
- `swift/unconfined-singleton-state` — Singletons with mutable state and no actor/queue confinement — convert to `actor` or confine.
- `swift/completion-handler-in-new-code` — Completion-handler APIs in new code where async/await is available.
- `swift/missing-mainactor` — Missing `@MainActor` on ObservableObject/ViewModel classes driving UI.
- `swift/unremoved-observer` — `NotificationCenter` observers without removal (pre-iOS 9-style APIs) or Combine subscriptions without `cancellables` storage.
- `swift/oversized-view` — Massive view controllers / SwiftUI views over ~300 lines — extract subviews and view models.
- `swift/stringly-typed-identifiers` — Stringly-typed identifiers (segues, notification names, userInfo keys) — use enums/constants.
- `swift/swallowed-decode-failure` — `Codable` decode failures swallowed with `try?` where the failure matters.
- `swift/concurrency-checking-opt-out` — **Concurrency checking switched off to silence the compiler** —
`@unchecked Sendable`, `nonisolated(unsafe)`, `@preconcurrency import`, `MainActor.assumeIsolated`. Each
removes data-race checking, so `final class Cache: @unchecked Sendable { var items = [Key: Value]() }`
corrupts its dictionary under concurrent writes and crashes intermittently. Name the lock, queue or invariant
that makes it safe, inline, with a ticket. Not when every mutable member really is guarded — say by what.
- `swift/unowned-capture` — **`unowned` in a closure capture list.** `[unowned self]` in an escaping closure
crashes when the closure runs after its owner is deallocated ("Attempted to read an unowned reference…"), and
`unowned(unsafe)` is undefined behaviour. Use `[weak self]` with `guard let self else { return }`. Not in a
non-escaping closure or a `lazy var` initialiser.
- `swift/strong-delegate` — **A delegate held strongly.** A stored `var delegate: SomeDelegate?` without `weak`
forms a cycle with the owner that sets itself as delegate, so every push and pop of the screen leaks the
controller and it keeps receiving callbacks. Declare it `weak var` and constrain the protocol to `AnyObject`.
- `swift/implicitly-unwrapped-declaration` — **An implicitly unwrapped optional outside `@IBOutlet`.** `var
viewModel: ProfileViewModel!` force-unwraps on every read with no `!` at the use site, so a navigation path
that forgets to set it crashes far from the cause. Inject a non-optional through `init`, or use a real
optional and handle it. Not for `@IBOutlet` or XCTest fixtures set in `setUp`.
- `swift/copying-reduce-accumulator` — **`reduce` with a copy-on-write accumulator** — `reduce([]) { $0 + [$1]
}`, `reduce("") { $0 + … }`, `reduce([K: V]())`. Each element copies the whole accumulator, so the loop is
quadratic: hex-encoding a 1 MB payload stalls the main thread. Use `reduce(into:)`, `flatMap`, `joined()` or
`Dictionary(grouping:)`.
## SUGGESTION
- `swift/prefer-value-types` — Prefer `struct` value types; classes only for identity or reference semantics.
- `swift/prefer-typed-throws` — `Result` grab-bags where typed `throws` is clearer.
- `swift/prefer-guard-early-exit` — Prefer `guard` early-exit over nested `if let` pyramids.