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.
iOS / Swift profile rules
Section titled “iOS / Swift profile rules”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.
Xcode-Specific Prohibitions
Section titled “Xcode-Specific Prohibitions”-
NEVER hand-edit
.pbxprojor.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:project.ymlat the component root → the project is generated by XcodeGen. The.pbxprojis a build artifact that happens to be committed. Do NOT hand-edit it, and do NOT add the file through Xcode — the nextxcodegen generateoverwrites both. Ifsources:declares a directory (the usual case), a new file inside it needs noproject.ymlchange at all: runxcodegen generateand commit the regenerated.pbxprojin the same change as the file that caused it. Adding a new target does need aproject.ymledit first.PBXFileSystemSynchronizedRootGrouppresent 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.- 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
.xcworkspacecontents. -
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-resolvesPackage.resolvedto the latest versions allowed by the existingupToNextMajorVersionconstraints without touching the.pbxproj; build + test afterward. Commit thePackage.resolvedit 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
.entitlementsfiles without explicit user request. -
Model observable state with
@Observable, held in@State— notObservableObject,@StateObject, or@Published;@Observableproperties publish automatically. -
Navigate with
NavigationStack, notNavigationView.
Architecture
Section titled “Architecture”View (SwiftUI) → ViewModel (@Observable, @MainActor) → Service → Repository → DataSourceKey patterns:
- All ViewModels:
@Observable+@MainActor. - All service/repository protocols:
Sendable. - Stateless services:
final class; stateful services:actor. - Async operations:
async/awaitandAsyncStream. - 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.
SwiftData + CloudKit
Section titled “SwiftData + CloudKit”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.
Testing
Section titled “Testing”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)@MainActorstruct FeatureTests { private var mockService = MockService() private var sut: FeatureViewModel { FeatureViewModel(service: mockService) }
@Test func testBehavior() async { // arrange, act, assert }}Targeted tests during development
Section titled “Targeted tests during development”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.
Documentation (DocC)
Section titled “Documentation (DocC)”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.
Run the check locally
Section titled “Run the check locally”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 extractspublicand above by default, and an app target’s code isinternalby 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 withinternal.OTHER_DOCC_FLAGS=--warnings-as-errors.DOCC_FLAGSis also a real build setting name — it is in Xcode’s own xcspecs — and it is silently ignored. A docbuild withDOCC_FLAGS=--warnings-as-errorsand a deliberately broken symbol link still exits 0; the same input withOTHER_DOCC_FLAGSexits 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 noindex.html— an artifact only Xcode can open. The old script failed on a missingindex.htmlfor 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 companionDOCC_HOSTING_BASE_PATHis 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.
Articles and catalogs
Section titled “Articles and catalogs”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.