Skip to content

iOS / Swift profile rules

Core section references below now live in skills: Fundamental Rules in engineering-workflow, Documentation in project-documentation, and Local Checks Match CI in working-with-lacquer. Load the named skill when needed.

Synced into the CLAUDE.md of any component declaring the ios profile. These rules assume an Xcode project under {{COMPONENT_PREFIX}}. Where a value is project-specific (your app name, scheme, bundle id), substitute your own — the project’s own identity lives in its root CLAUDE.md, not here. Replace <YourApp> / <YourScheme> below with your target and scheme names.

  • NEVER hand-edit .pbxproj or .xcodeproj/ contents. Create .swift/resource files only. How a new file joins the target depends on how the project file is produced, so check in this order:

    1. project.yml at the component root → the project is generated by XcodeGen. The .pbxproj is a build artifact that happens to be committed. Do NOT hand-edit it, and do NOT add the file through Xcode — the next xcodegen generate overwrites both. If sources: declares a directory (the usual case), a new file inside it needs no project.yml change at all: run xcodegen generate and commit the regenerated .pbxproj in the same change as the file that caused it. Adding a new target does need a project.yml edit first.
    2. PBXFileSystemSynchronizedRootGroup present in the .pbxproj → Xcode 16 synchronized groups. Files placed under a synced folder are auto-included in the target — do NOT add target membership manually and do NOT edit the .pbxproj.
    3. Neither → ask the user to add the file to the target in Xcode rather than editing the project file.

    Check the generator FIRST, because the two are not mutually exclusive and the synchronized-groups test answers the wrong question for a generated project: an XcodeGen project reports no synchronized groups, so testing for those alone routes you to step 3 — and adding a file through Xcode is the one action guaranteed not to survive xcodegen generate. Most of this fleet is XcodeGen.

  • NEVER modify .xcworkspace contents.

  • NEVER add Swift Package Manager dependencies without explicit user permission. To bump existing deps (no new deps added), use flowdeck project packages update — it re-resolves Package.resolved to the latest versions allowed by the existing upToNextMajorVersion constraints without touching the .pbxproj; build + test afterward. Commit the Package.resolved it writes: CI and the release resolve strictly from it (-onlyUsePackageVersionsFromResolvedFile) and fail when it is missing or no longer satisfies the project’s package requirements.

  • NEVER change the deployment target without explicit user request.

  • NEVER modify .entitlements files without explicit user request.

  • Model observable state with @Observable, held in @State — not ObservableObject, @StateObject, or @Published; @Observable properties publish automatically.

  • Navigate with NavigationStack, not NavigationView.

View (SwiftUI) → ViewModel (@Observable, @MainActor) → Service → Repository → DataSource

Key patterns:

  • All ViewModels: @Observable + @MainActor.
  • All service/repository protocols: Sendable.
  • Stateless services: final class; stateful services: actor.
  • Async operations: async/await and AsyncStream.
  • Constructor injection for dependencies.

Project structure:

{{COMPONENT_PREFIX}}<YourApp>/
├── App/ # App entry point, dependency container
├── Features/ # Feature modules (one folder per feature)
├── Core/ # Services, Repositories, Models, Networking
├── Shared/ # Components, Extensions, Utilities
└── Resources/

Layer rule: ViewModels MUST NOT depend directly on Repository protocols. Inject Service protocols instead.

If this project imports SwiftData, lacquer sync suggests the dpearson2699/swift-ios-skills@swiftdata skill (see internal/skillsuggest) — it covers CloudKit-compatible schema constraints. Install it with skills add if it wasn’t suggested.

Swift Testing is the standard for all new test files. Use @Test, @Suite, and #expect. XCTest is legacy — only modify existing XCTest files when touched for other reasons. Never create new XCTest files.

import Testing
@testable import <YourApp>
@Suite("Feature Tests", .serialized)
@MainActor
struct FeatureTests {
private var mockService = MockService()
private var sut: FeatureViewModel {
FeatureViewModel(service: mockService)
}
@Test func testBehavior() async {
// arrange, act, assert
}
}

During RED/GREEN, run targeted tests only (-only-testing:<YourApp>Tests/SomeSuite/someTest) — never a full self-run. The full suite runs at pre-commit and again in CI (fresh checkout). Treat SwiftLint warnings as errors — fix the code, never suppress (see core Fundamental Rule #7).

Test support: waitUntil (no Task.sleep in tests)

Section titled “Test support: waitUntil (no Task.sleep in tests)”

The no_task_sleep_in_tests lint rule bans arbitrary Task.sleep delays in tests — they cause flaky failures. See the swift-testing-wait-until skill for the polling helper to add to your test target instead.

DocC is a requirement, not a nicety — see core “Documentation” for the rule and the relaxation mechanism. This section is the Swift half.

Every declaration above private carries a /// doc comment. That half is checked by the local pre-commit hook (docs-hook.sh → swiftlint-docs), and it is the only half still checked anywhere: the DocC archive build was a CI job, the job and the script behind it are both gone, and nothing replaced them.

/// Loads the user's saved sessions, newest first.
///
/// - Parameter limit: The maximum number of sessions to return. Pass `nil` for
/// all of them.
/// - Returns: The sessions, or an empty array when the user has none.
/// - Throws: ``SessionError/unauthorized`` when the session token has expired.
///
/// Runs off the main actor — the decode is large enough to drop frames.
nonisolated func loadSessions(limit: Int?) async throws -> [Session]

Use - Parameter / - Returns / - Throws for anything a caller must know, and double-backtick symbol links (``SessionError/unauthorized``) rather than plain-text type names — a link is a reference a DocC build can verify, prose is not. Nothing verifies it today, which makes it a convention rather than a gate; write them anyway, or the first docbuild anyone runs opens with a backlog of broken links.

Terminal window
swiftlint --strict --config .swiftlint-docs.yml . # every declaration documented

.swiftlint-docs.yml is a separate config from .swiftlint.yml on purpose: the documentation baseline is the one rule set a project may relax, and mixing it into the main config would either hand every style rule an escape hatch or leave this one without the escape hatch the standard promises.

A [baseline.relax] entry does not take effect until it is COMMITTED. pre-commit stashes unstaged changes before running hooks, so the hook reads .lacquer.toml as it exists at HEAD. Add a relaxation, leave it unstaged, and the very next commit still fails the gate you just relaxed — with no hint that the file it read was not the file you edited. Stage and commit the manifest change on its own, or pass --no-verify for that one commit, then let the following commit be gated normally. “I added the relaxation and it did not take” is the obvious first experience, and the cause is invisible from the error.

Three settings, if the DocC build is ever reinstated

Section titled “Three settings, if the DocC build is ever reinstated”

A DocC build that gets any of these three wrong reports green while checking nothing.

  • DOCC_MINIMUM_ACCESS_LEVEL=internal. DocC extracts public and above by default, and an app target’s code is internal by default — so without this an app builds an archive containing essentially nothing, succeeds, and reports as a pass. Verified on a real app target: 3 symbols at the default, 6 with internal.
  • OTHER_DOCC_FLAGS=--warnings-as-errors. DOCC_FLAGS is also a real build setting name — it is in Xcode’s own xcspecs — and it is silently ignored. A docbuild with DOCC_FLAGS=--warnings-as-errors and a deliberately broken symbol link still exits 0; the same input with OTHER_DOCC_FLAGS exits 65. Picking the obvious-looking name turns the gate off without turning the job red.
  • DOCC_TRANSFORM_FOR_STATIC_HOSTING=YES. Without it the build produces an archive with no index.html — an artifact only Xcode can open. The old script failed on a missing index.html for that reason: a build that succeeds having staged nothing a browser can render is the same silent pass the other two settings guard against. Its companion DOCC_HOSTING_BASE_PATH is baked into every asset URL and matters only when something serves the archive under a sub-path; nothing serves it now, and a locally built archive should not carry a prefix at all.

A .docc catalog next to the sources adds landing pages, articles, and tutorials beyond the symbol reference — a <Target>.md root page is the highest-value addition, because it is what a reader lands on. Symbol links from an article are checked by the same build, so a catalog raises the value of the gate rather than working around it.

Premium / Subscription Gating (if monetized)

Section titled “Premium / Subscription Gating (if monetized)”

If this project imports StoreKit, lacquer sync suggests the dpearson2699/swift-ios-skills@storekit skill (see internal/skillsuggest) — it covers paywall/entitlement architecture. Install it with skills add if it wasn’t suggested.