slash-do 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -76,6 +76,7 @@ When compacting during this workflow, always preserve:
76
76
  - `PHASE_4C_START_SHA` (needed for FILE_OWNER_MAP update in Phase 4c.3)
77
77
  - `VACUOUS_TESTS_FIXED`, `WEAK_TESTS_STRENGTHENED`, `NEW_TEST_CASES`, `NEW_TEST_FILES`
78
78
  - `CREATED_CATEGORY_SLUGS` (list of branch slugs created in Phase 5)
79
+ - `GOTCHA_ENTRIES_IN_SCOPE` (list of swift-gotchas catalogue entry numbers relevant to this project, recorded in Phase 0e)
79
80
 
80
81
 
81
82
  ## Phase 0: Discovery & Setup
@@ -115,6 +116,12 @@ Detect additional Swift project characteristics:
115
116
  - Combine usage (`import Combine`, `@Published`, `AnyPublisher`)
116
117
  - Swift concurrency adoption (`async`, `await`, `actor`, `@MainActor`)
117
118
  - Widget extensions, App Intents, or other extension targets
119
+ - **CloudKit usage** (`import CloudKit`, `CKContainer`, `cloudKitDatabase:` in `ModelConfiguration`) — flag for Agent 5 lazy-init audit
120
+ - **iCloud entitlements** (`com.apple.developer.icloud-container-identifiers` in `.entitlements`) — flag for Agent 6 ubiquity container audit
121
+ - **Localization** (`Localizable.xcstrings` file present, `String(localized:)` calls, `LocalizedStringKey` parameters) — flag for Agent 6 localization audit
122
+ - **StoreKit / IAPs** (`import StoreKit`, `.storekit` config file, `Product.products(for:)`) — flag for Agent 6 IAP audit
123
+ - **CI/CD release path** (`.github/workflows/*.yml` referencing `apple-actions/upload-testflight-build` or `xcrun altool`) — flag for Agent 6 TestFlight upload validation audit
124
+ - **Code signing in CI** (CI workflow uses `CODE_SIGNING_ALLOWED=NO` for tests) — Agent 5 must aggressively check CloudKit eager-init crash patterns
118
125
 
119
126
  Record as `PROJECT_TYPE` = "SwiftUI" with characteristics map.
120
127
 
@@ -172,6 +179,29 @@ Record as `BUILD_CMD` and `TEST_CMD`.
172
179
  - Check for `.changelogs/` or `.changelog/` directory → `HAS_CHANGELOG`
173
180
  - Check for existing `../better-*` worktrees: `git worktree list`. If found, inform the user and ask whether to resume (use existing worktree) or clean up (remove it and start fresh)
174
181
 
182
+ ### 0e: Known Gotchas Catalogue
183
+
184
+ This command ships with a catalogue of real-world Swift / iOS / macOS failure modes at `~/.claude/lib/swift-gotchas.md`. Each entry documents trigger conditions, root cause, the verified fix, and verification steps for a bug that has shipped to production at least once.
185
+
186
+ Before launching audit agents in Phase 1, scan the project for these signals and record which catalogue entries are in scope. Pass this list to each downstream audit agent so they know which entries to consult.
187
+
188
+ | Entry | Catalogue # | Triggers when project has | Audit agent that uses it |
189
+ |-------|-------------|---------------------------|--------------------------|
190
+ | CKContainer eager-init crash | 1 | CloudKit + CI runs `xcodebuild test ... CODE_SIGNING_ALLOWED=NO` | Agent 5 (Bugs) |
191
+ | SwiftData missing inverse relationship | 2 | `@Model` with `@Relationship` properties | Agent 5 (Bugs) + Agent 7 (Tests) |
192
+ | SwiftData CloudKit cross-Apple-ID sharing gap | 3 | SwiftData + `cloudKitDatabase: .automatic` + household/team/share keywords | Agent 4 (Architecture) + Agent 5 (Bugs) |
193
+ | iCloud ubiquity container silent failure | 4 | iCloud entitlement + `url(forUbiquityContainerIdentifier:)` | Agent 5 (Bugs) + Agent 6 (Platform) |
194
+ | iCloud symlink content corruption | 5 | Code mirrors content into `~/Library/Mobile Documents/` paths | Agent 5 (Bugs) |
195
+ | SwiftUI xcstrings localization | 6 | `Localizable.xcstrings` OR `String(localized:)` calls | Agent 6 (Platform) |
196
+ | XcodeGen project generation | 7 | `project.yml` present | Agent 6 (Platform) |
197
+ | TestFlight upload validation | 8 | CI workflow uses `apple-actions/upload-testflight-build` or `xcrun altool` | Agent 6 (Platform) |
198
+ | App Group provisioning auth failure | 9 | App Groups, Push, or extension targets in `.entitlements` | Agent 6 (Platform) |
199
+ | iOS first-IAP submission rejection | 10 | `import StoreKit` AND `Product.products(for:)` calls | Agent 6 (Platform) |
200
+ | `.foregroundStyle(.accentColor)` compile failure | 11 | SwiftUI code using `.foregroundStyle(.accentColor)` | Agent 5 (Bugs) |
201
+ | Keychain test failures (CryptoKit) | 12 | `SecItemAdd`/`SecItemCopyMatching` + symmetric key generation | Agent 5 (Bugs) |
202
+
203
+ Record the matching entry numbers as `GOTCHA_ENTRIES_IN_SCOPE` (e.g., `[1, 2, 6, 7, 8, 11]`). Audit agents in Phase 1 will be instructed to `Read ~/.claude/lib/swift-gotchas.md` once and check each in-scope entry's trigger conditions against the codebase.
204
+
175
205
 
176
206
  <audit_instructions>
177
207
 
@@ -179,6 +209,12 @@ Record as `BUILD_CMD` and `TEST_CMD`.
179
209
 
180
210
  Project conventions are already in your context. Pass relevant conventions to each agent.
181
211
 
212
+ Before launching audit agents, load the gotcha catalogue into your context so you can pass relevant entries to each agent:
213
+
214
+ !`cat ~/.claude/lib/swift-gotchas.md`
215
+
216
+ Use `GOTCHA_ENTRIES_IN_SCOPE` (recorded in Phase 0e) to filter which entries are relevant for this project. Pass each downstream agent ONLY the entries that match its category (per the table in Phase 0e), not the whole catalogue.
217
+
182
218
  Launch 7 Explore agents in two batches. Each agent must report findings in this format:
183
219
  ```
184
220
  - **[CRITICAL/HIGH/MEDIUM/LOW]** `file:line` - Description. Suggested fix: ... Complexity: Simple/Medium/Complex
@@ -286,6 +322,10 @@ Skip step 4 if steps 1-3 reveal the code is correct.
286
322
  - `Task.detached` with `[self]` (strong capture) — use `[weak self]` for cancelable work
287
323
  - Keychain operations (`SecItemAdd`/`SecItemCopyMatching`) that silently fail in Simulator test environments — add in-memory key cache as fallback so encrypt-then-decrypt roundtrips don't break in tests
288
324
  - `.foregroundStyle(.accentColor)` doesn't compile — `ShapeStyle` has no `.accentColor`; use `Color.accentColor` explicitly
325
+ - **CloudKit eager-init crash in unsigned test builds — gotcha catalogue #1 (CRITICAL):** `CKContainer(identifier:)` does NOT throw or return nil when the iCloud entitlement is missing — it OS-faults via `EXC_BREAKPOINT`/`SIGTRAP`. `CODE_SIGNING_ALLOWED=NO` strips entitlements from sim builds. Any stored property like `private let container = CKContainer(identifier: "iCloud.foo.bar")` runs at object construction, so the moment any code touches a CloudKit singleton (even just to hold a reference), the host app crashes during XCTest bootstrap with "Early unexpected exit, operation never finished bootstrapping." Feature flags do NOT protect against this — construction happens before the flag check. Fix: convert all `CKContainer`/`CKDatabase`/`CKQuerySubscription` stored properties to `lazy var`. Audit all `@MainActor` singletons and `DataStore`-style references for stored CloudKit properties. Severity: **[CRITICAL]** if CI uses `CODE_SIGNING_ALLOWED=NO`. Full catalogue entry: `~/.claude/lib/swift-gotchas.md` § 1.
326
+ - **SwiftData missing inverse relationship crash — gotcha catalogue #2 (CRITICAL):** every `@Relationship` property must have a matching inverse on the target model. Missing inverse causes `ModelContainer` init to throw `SwiftDataError.loadIssueModelContainer` — and crucially, this fails for BOTH persistent AND in-memory configurations. The error message does NOT identify which relationship is broken. Map every `@Relationship` across all `@Model` classes; if a child model declares `var parent: Parent?` then the parent must declare a matching `var children: [Child]? = nil`. SwiftData CAN auto-infer inverses when both sides declare relationships, but it CANNOT create the inverse when only one side declares it. CloudKit (`cloudKitDatabase: .automatic`) requires inverses to be explicit. Full catalogue entry: `~/.claude/lib/swift-gotchas.md` § 2.
327
+ - **iCloud ubiquity container silent failure — gotcha catalogue #4:** `FileManager.url(forUbiquityContainerIdentifier:)` returning a non-nil URL does NOT mean the container is accessible — it only means the entitlement is configured. Pattern to flag: `if let iCloudURL = fm.url(forUbiquityContainerIdentifier: ...) { ... try? fm.createDirectory(...) ... self.dataDirectory = iCloudURL ... }` — the `try?` swallows permission errors and the app silently operates on an inaccessible directory. Required pattern: after `createDirectory`, verify accessibility via `contentsOfDirectory(at:includingPropertiesForKeys:)` inside `do/catch` (not `try?`), and fall back to local Documents on any failure. Full catalogue entry: `~/.claude/lib/swift-gotchas.md` § 4.
328
+ - **SwiftData CloudKit cross-Apple-ID sharing gap — gotcha catalogue #3:** `ModelConfiguration(cloudKitDatabase:)` only has `.private(...)` and `.automatic` — there is NO `.shared` case. Apps that need cross-user collaboration (family/team apps) will silently sync only across the user's own devices. Flag any app that has `cloudKitDatabase: .automatic` AND mentions "household", "family", "team", "shared", or "invite" in code/comments — they likely need a `CKShare`-on-custom-zone overlay pattern. Common compile errors that indicate this gap: `type 'CKShare.Metadata' has no member 'activityType'`, `(saved, _) = try await db.modifyRecords(...)` (modern async API returns dictionaries, not tuples of arrays). Full catalogue entry: `~/.claude/lib/swift-gotchas.md` § 3.
289
329
 
290
330
  ### Batch 2 (2 agents after Batch 1 completes):
291
331
 
@@ -312,14 +352,40 @@ Skip step 4 if steps 1-3 reveal the code is correct.
312
352
 
313
353
  **Build system & project configuration (when XcodeGen/Tuist detected):**
314
354
  - `GENERATE_INFOPLIST_FILE: false` with custom Info.plist missing standard keys (`CFBundleIdentifier`, `CFBundleExecutable`, `CFBundlePackageType`) — causes "Missing bundle ID" on simulator install despite correct `PRODUCT_BUNDLE_IDENTIFIER`. Fix: set `GENERATE_INFOPLIST_FILE: true` to let Xcode merge custom keys with generated ones
315
- - Preview Content directory with `buildPhase: none` excluding Swift files that are needed at runtime (e.g., `PreviewSampleData.swift` used via launch arguments) — only exclude the `.xcassets`, not the whole directory
316
- - `UILaunchScreen` key manually added to Info.plist but lost on `xcodegen generate` — XcodeGen regenerates the plist from `info.properties` only; put `UILaunchScreen: {}` in `project.yml` not the plist file. Missing this causes iOS letterbox/compatibility mode
317
- - Info.plist keys required for TestFlight upload that don't cause build failures: `UISupportedInterfaceOrientations` must include all 4 orientations for iPad multitasking (or declare `UIRequiresFullScreen`), and `CFBundleDocumentTypes` requires `LSSupportsOpeningDocumentsInPlace` these are rejected server-side by `altool`, not at build time
318
- - CI upload actions (`apple-actions/upload-testflight-build`) that report success even when `altool` returns "UPLOAD FAILED" in XML plist output — always check raw upload logs, not just job status
355
+ - Preview Content directory with `buildPhase: none` excluding Swift files that are needed at runtime (e.g., `PreviewSampleData.swift` used via launch arguments) — only exclude the `.xcassets`, not the whole directory. In Release builds on CI, `DEVELOPMENT_ASSET_PATHS` files may be stripped — move runtime-needed Swift files OUT of `Preview Content/` into the main source tree
356
+ - `UILaunchScreen` key manually added to Info.plist but lost on `xcodegen generate` — XcodeGen regenerates the plist from `info.properties` only; put `UILaunchScreen: {}` in `project.yml` not the plist file. Missing this causes iOS letterbox/compatibility mode (tiny centered window, large black borders). Also remove `INFOPLIST_KEY_UILaunchScreen_Generation: true` if both are present — they create a nested `UILaunchScreen > UILaunchScreen` structure
357
+ - **Never manually edit the Info.plist file when using XcodeGen's `info.path`** `xcodegen generate` overwrites the plist from scratch using only `info.properties`. Any custom keys must live in `project.yml` or they're silently deleted
358
+ - Info.plist keys required for TestFlight upload that don't cause build failures (these are rejected SERVER-SIDE by `altool`, not at build time):
359
+ - `UISupportedInterfaceOrientations` must include all 4 orientations for iPad multitasking, OR declare `UIRequiresFullScreen: true` (even iPhone-only apps need this — error code 409). For XcodeGen: set `INFOPLIST_KEY_UISupportedInterfaceOrientations` and `INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad` build settings
360
+ - `CFBundleDocumentTypes` declared without `LSSupportsOpeningDocumentsInPlace` — currently a warning (code 90737), may become a fatal error
361
+ - CI upload actions (`apple-actions/upload-testflight-build@v3`) that report success even when `altool` returns "UPLOAD FAILED" in XML plist output — flag any release workflow that doesn't grep raw upload logs for `UPLOAD FAILED|product-errors|product-warnings`. Always check TestFlight after CI completes to confirm the build actually arrived
362
+ - **Adding new capabilities to an existing app — gotcha catalogue #9:** `xcodebuild archive -allowProvisioningUpdates` may fail with misleading "Authentication failed: Make sure a bearer token was provided" when adding App Groups, Push, etc. — the App Store Connect API key has upload permissions but NOT provisioning profile management permissions. Flag CI workflows that add new entitlements without corresponding documentation that someone has manually fixed provisioning in Xcode GUI first
363
+ - For XcodeGen multi-platform targets with widget extensions, the widget dependency needs `platformFilter: iOS` since widgets are primarily iOS
364
+ - Build system & TestFlight gotchas — full catalogue entries: `~/.claude/lib/swift-gotchas.md` § 7 (XcodeGen), § 8 (TestFlight upload), § 9 (App Group provisioning)
319
365
 
320
366
  **iCloud & data persistence (when iCloud entitlements detected):**
321
367
  - `url(forUbiquityContainerIdentifier:)` returning non-nil does NOT mean the container is accessible — always verify with `createDirectory` + `contentsOfDirectory` using `do/catch` (not `try?`) and fall back to local Documents directory on failure
322
368
  - `try?` on iCloud file operations silently swallowing permission errors — app appears to work but reads/writes to inaccessible path with empty results
369
+ - Sparse/dehydrated iCloud files: any directory that was symlinked or rsynced into iCloud Drive can produce files with `st_size > 0` but `st_blocks = 0` that read as empty. Flag any code that mirrors content into iCloud paths without integrity verification (see catalogue § 5)
370
+
371
+ **Localization & String Catalogs (when `Localizable.xcstrings` or `String(localized:)` detected):**
372
+ - **`Text(someStringVariable)` silent failure** — `Text("literal")` accepts `LocalizedStringKey` and auto-localizes from the string catalog, but `Text(someVariable)` where `someVariable: String` does NOT — it renders raw. Any reusable component accepting `title: String` and passing it to `Text(title)` ships untranslated UI unless callers pre-localize via `String(localized:)`. Either change the parameter type to `LocalizedStringKey` (for literal-only callers) or document that callers must pre-localize. This is the #1 silent localization failure
373
+ - **`String(localized:)` ignores SwiftUI environment locale** — it uses `Bundle.main`'s preferred language, which is controlled by `UserDefaults["AppleLanguages"]`, not `.environment(\.locale, ...)`. Apps with in-app language pickers must either (a) restart after writing to `AppleLanguages`, or (b) build an `appLocalized()` helper that passes the user's chosen locale explicitly via `String(localized: key, locale: .app, comment: ...)`
374
+ - **AGA `^[...](inflect: true)` requires `LocalizedStringKey`** — Apple's Automatic Grammar Agreement only fires when rendered as `Text(LocalizedStringKey)`. `String(localized: "^[\(count) horse](inflect: true)")` returns a plain `String`, strips the AGA pipeline, and renders the markup literally. AGA `inflect: true` is also unreliable for non-English locales — use explicit `variations.plural` (`one`/`other` keys) in xcstrings for ALL locales including English
375
+ - **Static cached `DateFormatter` for locale-dependent formats** — `private static let formatter = DateFormatter()` captures the locale at first init and never updates; in-app language switches won't change rendered dates. Use `Date.FormatStyle` (e.g., `date.formatted(.dateTime.month().day())`) which respects current locale, OR construct a fresh formatter per call. Static caching IS safe for locale-independent formats like `yyyyMMdd` (set `locale = Locale(identifier: "en_US_POSIX")` to prevent calendar interference)
376
+ - **`date.formatted(...)` vs `Text(date, format:)` in views** — the former returns a `String` using `Locale.current` (system locale, non-reactive); the latter uses the SwiftUI environment locale and IS reactive to `.environment(\.locale, ...)` changes. Prefer `Text(date, format: ...)` in view bodies
377
+ - **Localized strings stored in SwiftData/CoreData** — flag any `@Model` property that holds a translated display string instead of a raw enum value. Storing `"Lektion"` in German and reading in French gives mixed-language UI. Always store raw values (`categoryRaw: String = "lesson"`) and compute `displayName` at render time
378
+ - **Missing `comment:` parameter on `String(localized:)` calls** — translators have no context, and the xcstrings extraction tool won't auto-populate hints
379
+ - **Missing `en` entry in xcstrings when other languages are present** — causes English strings to display with raw `^[...]` markup or fall back to keys
380
+ - **Tab bar / sidebar / navigation labels not translating** — usually caused by Pattern 1 vs 2 confusion: `Label(category.displayName, ...)` won't translate unless `displayName` returns a pre-localized `String`
381
+ - Full catalogue entry with all 8 sub-gotchas and the `appLocalized()` helper template: `~/.claude/lib/swift-gotchas.md` § 6
382
+
383
+ **In-App Purchases & StoreKit (when StoreKit imports detected):**
384
+ - **Missing "Restore Purchases" button (Guideline 3.1.1 — auto-rejection):** apps that offer IAPs MUST include a distinct, user-tappable Restore button on the same screen where IAPs are shown, not buried in deep settings. Implementation: `try await AppStore.sync()` (StoreKit 2), then refresh purchase state. Show loading state during restore, alerts on success/failure
385
+ - **Hardcoded fallback price in PurchaseButton** — `Text(price ?? "$0.99")` shows a tappable button when products haven't loaded, leading to "Unable to make IAP purchases" in App Review. Fix: show `ProgressView()` until `Product.products(for:)` resolves, then enable the button
386
+ - **First-time IAPs in TestFlight cannot be tested** — `product.purchase()` will fail with "Unable to Complete Request" for IAPs that have never been approved by App Review, even though `Product.products(for:)` returns prices correctly. This is a sandbox limitation, not a code bug. Flag any project README or testing doc that says "test IAPs in TestFlight" without mentioning the local `.storekit` configuration file workflow (only works in Xcode debug runs, not archived/TestFlight builds)
387
+ - **`@available` annotations not matching actual StoreKit version** — StoreKit 2 (`Product`, `Transaction`, `AppStore.sync()`) requires iOS 15+/macOS 12+. Mixing StoreKit 1 (`SKProduct`, `SKPaymentQueue`) and StoreKit 2 in the same purchase flow without clear version gating produces surprising bugs
388
+ - Full catalogue entry with submission flow (clear "Rejected" IAP localizations, submit IAPs alongside an app version, local `.storekit` testing): `~/.claude/lib/swift-gotchas.md` § 10
323
389
 
324
390
  **SwiftUI best practices (ALL projects):**
325
391
  - Deprecated APIs: `NavigationView` (use `NavigationStack`/`NavigationSplitView`), `onChange(of:perform:)` one-parameter form (use two-parameter), `.onAppear` for async work (use `.task`)
@@ -365,6 +431,18 @@ Skip step 4 if steps 1-3 reveal the code is correct.
365
431
  - Missing `XCUITest` for critical navigation flows and platform-specific interactions
366
432
  - Missing preview coverage: all views should have `#Preview` for each platform × Dark Mode × Dynamic Type extremes
367
433
  - Missing error path tests for network failures, decode failures, and permission denials
434
+ - **Missing `testModelContainerSchemaIsValid()` test** when `@Model` classes are present — every project using SwiftData should construct an in-memory `ModelContainer` with ALL model types in a unit test. This catches missing inverse relationships before they reach production (the actual error message gives no hint which relationship is broken). Required pattern:
435
+ ```swift
436
+ func testModelContainerSchemaIsValid() throws {
437
+ _ = try ModelContainer(
438
+ for: ModelA.self, ModelB.self, /* every @Model type */,
439
+ configurations: ModelConfiguration(isStoredInMemoryOnly: true)
440
+ )
441
+ }
442
+ ```
443
+ - **Missing CloudKit lazy-init verification test** — when the project uses CloudKit, add a smoke test that runs the host app under `CODE_SIGNING_ALLOWED=NO` and verifies tests bootstrap successfully. If `CKContainer(identifier:)` is held in any stored property, the host app traps before any test runs
444
+ - **Missing localization round-trip tests** when `Localizable.xcstrings` is present — for each enum with a `displayName` property, verify it returns a non-empty `String` (not the raw key) for at least one supported locale. This catches `Text(stringVariable)` vs `Text(LocalizedStringKey)` regressions
445
+ - **Missing IAP product loading test** when StoreKit is imported — verify `Product.products(for: identifiers)` returns the expected set against a `.storekit` configuration file. This catches typos in product identifiers before they reach App Review
368
446
 
369
447
  **Vacuous tests (tests that don't actually test anything):**
370
448
  - Tests that assert on mocked return values instead of real behavior (testing the mock, not the code)
@@ -528,6 +606,25 @@ SWIFT-SPECIFIC GUARDRAILS:
528
606
  - Use Swift concurrency (async/await, actors) over GCD/Combine for new code
529
607
  - Never introduce AnyView — use @ViewBuilder or Group instead
530
608
  - Test both light and dark color schemes when modifying colors
609
+
610
+ GOTCHA CATALOGUE — REQUIRED READING:
611
+ Before fixing any issue tagged with a gotcha catalogue entry number (#1–#12), READ the corresponding entry in `~/.claude/lib/swift-gotchas.md`. Each entry documents the verified fix from a prior project that actually shipped — apply the fix as written rather than improvising. The relevant entries for this project are: {GOTCHA_ENTRIES_IN_SCOPE}.
612
+
613
+ The catalogue covers:
614
+ #1 CKContainer eager-init crash in unsigned test builds (CloudKit + CI)
615
+ #2 SwiftData missing inverse relationship crash (@Model + @Relationship)
616
+ #3 SwiftData CloudKit cross-Apple-ID sharing gap (.automatic + sharing)
617
+ #4 iCloud ubiquity container silent failure (iCloud entitlement)
618
+ #5 iCloud symlink content corruption (iCloud Drive mirroring)
619
+ #6 SwiftUI xcstrings localization silent failures (Localizable.xcstrings)
620
+ #7 XcodeGen project generation gotchas (project.yml)
621
+ #8 TestFlight upload validation gotchas (CI release path)
622
+ #9 xcodebuild App Group provisioning auth failure (new entitlements)
623
+ #10 iOS first-IAP submission rejection (StoreKit / IAPs)
624
+ #11 .foregroundStyle(.accentColor) compile failure (any SwiftUI)
625
+ #12 Keychain test failures in simulator (CryptoKit) (SecItem* + symmetric keys)
626
+
627
+ When a finding cites a catalogue entry, READ that entry in `~/.claude/lib/swift-gotchas.md` first, then apply the FIX section as the remediation. Do not paraphrase the fix — these are load-bearing patterns where minor variations regress the bug.
531
628
  ```
532
629
 
533
630
  ### Conflict avoidance:
@@ -33,9 +33,10 @@ Print the detected workflow: `Detected release flow: {source} → {target}`
33
33
  ## Pre-Release Checks
34
34
 
35
35
  1. **Ensure you're on the source branch** — checkout if needed
36
- 2. **Pull latest** — `git pull --rebase --autostash`
37
- 3. **Run tests** — execute the project's test suite (per project conventions already in context, or check package.json)
38
- 4. **Run build** — execute the project's build command if one exists
36
+ 2. **Pull latest source** — `git pull --rebase --autostash`
37
+ 3. **Pull latest target** — `git fetch origin {target} && (git show-ref --verify --quiet refs/heads/{target} && git checkout {target} || git checkout -b {target} --track origin/{target}) && git pull --rebase --autostash origin {target} && git checkout {source}` — this ensures the local target branch matches `origin/{target}` before any diff or PR creation, even on a fresh clone where the target branch may only exist on the remote. Without this, the diff may be stale or include already-released changes.
38
+ 4. **Run tests** — execute the project's test suite (per project conventions already in context, or check package.json)
39
+ 5. **Run build** — execute the project's build command if one exists
39
40
 
40
41
  ## Determine Version and Finalize Changelog
41
42
 
package/install.sh CHANGED
@@ -55,7 +55,7 @@ OLD_COMMANDS=(cam good makegoals makegood optimize-md)
55
55
 
56
56
  LIBS=(
57
57
  code-review-checklist copilot-review-loop graphql-escaping
58
- remediation-agent-template swift-review-checklist
58
+ remediation-agent-template swift-review-checklist swift-gotchas
59
59
  review-surface-scan review-security-audit review-cross-file-tracing
60
60
  )
61
61
 
@@ -0,0 +1,658 @@
1
+ <!--
2
+ swift-gotchas.md — Reference catalogue of real-world Swift / iOS / macOS failure modes
3
+ consumed by /do:better-swift audit and remediation agents.
4
+
5
+ Each entry is self-contained: TRIGGER (how to detect it in code/CI),
6
+ ROOT CAUSE (why it happens), FIX (the verified remediation), and
7
+ VERIFY (how to confirm the fix works).
8
+
9
+ Audit agents: scan the project for the TRIGGER signals listed in each entry.
10
+ Remediation agents: when you encounter an issue matching one of these entries,
11
+ apply the FIX as written — do not improvise unless the code has diverged.
12
+
13
+ These are NOT speculative best practices. Every entry below is a bug that
14
+ shipped to production at least once and caused real damage. Treat them as
15
+ load-bearing checks, not style preferences.
16
+ -->
17
+
18
+ # Swift / iOS / macOS Gotcha Catalogue
19
+
20
+ ## Quick index
21
+
22
+ | # | Failure mode | Detect when |
23
+ |---|---|---|
24
+ | 1 | CKContainer eager-init crash in unsigned test builds | Project uses CloudKit AND CI runs `xcodebuild test ... CODE_SIGNING_ALLOWED=NO` |
25
+ | 2 | SwiftData missing inverse relationship crash | Project has `@Model` classes with `@Relationship` properties |
26
+ | 3 | SwiftData CloudKit cross-Apple-ID sharing gap | SwiftData with `cloudKitDatabase: .automatic` AND household/team/family/share keywords |
27
+ | 4 | iCloud ubiquity container silent failure | iCloud entitlement AND `url(forUbiquityContainerIdentifier:)` calls |
28
+ | 5 | iCloud symlink content corruption | Code mirrors content into `~/Library/Mobile Documents/` paths |
29
+ | 6 | SwiftUI xcstrings localization silent failures | `Localizable.xcstrings` file present OR `String(localized:)` calls |
30
+ | 7 | XcodeGen project generation gotchas | `project.yml` (XcodeGen) detected |
31
+ | 8 | TestFlight upload validation gotchas | CI workflow uploads to TestFlight via `apple-actions/upload-testflight-build` or `xcrun altool` |
32
+ | 9 | xcodebuild App Group provisioning auth failure | App Groups, Push, or extension targets in `.entitlements` |
33
+ | 10 | iOS first-IAP submission rejection | `import StoreKit` AND `Product.products(for:)` calls |
34
+ | 11 | `.foregroundStyle(.accentColor)` compile failure | Any SwiftUI code using `.foregroundStyle(.accentColor)` |
35
+ | 12 | Keychain test failures in simulator | `SecItemAdd` / `SecItemCopyMatching` used for symmetric keys |
36
+
37
+ ---
38
+
39
+ ## 1. CKContainer eager-init crash in unsigned test builds
40
+
41
+ ### Trigger
42
+ - `xcodebuild test ... CODE_SIGNING_ALLOWED=NO` fails with: `Early unexpected exit, operation never finished bootstrapping - no restart will be attempted` and `The test runner crashed before establishing connection`
43
+ - Crash report shows `EXC_BREAKPOINT (SIGTRAP)` with frames in `CKContainer.__allocating_init(identifier:)`
44
+ - CloudKit emits a "Significant issue" log: `In order to use CloudKit, your process must have a com.apple.developer.icloud-services entitlement`
45
+ - Tests pass with full code signing but fail with `CODE_SIGNING_ALLOWED=NO`
46
+ - All CloudKit usage IS gated behind a feature flag, yet the app still crashes at launch
47
+
48
+ ### Root cause
49
+ `CKContainer(identifier:)` does **not** throw or return nil when the iCloud entitlement is missing — it traps the process via an OS-level fault (`brk 1`). `CODE_SIGNING_ALLOWED=NO` strips entitlements from the simulator build. A stored property like `private let container = CKContainer(...)` runs its initializer the moment the enclosing object is constructed, so any code that touches the singleton (even just to hold a reference) triggers the trap. Feature flags do not protect against this — construction happens before any flag check.
50
+
51
+ ### Fix
52
+ Convert every CloudKit stored property to `lazy var`:
53
+
54
+ ```swift
55
+ final class CloudKitSync {
56
+ static let shared = CloudKitSync()
57
+
58
+ // Lazy so CKContainer is only constructed when CloudKit is actually used.
59
+ // CKContainer(identifier:) traps when the iCloud entitlement is missing
60
+ // (e.g. unsigned test builds with CODE_SIGNING_ALLOWED=NO).
61
+ private lazy var container = CKContainer(identifier: "iCloud.foo.bar")
62
+ private var privateDB: CKDatabase { container.privateCloudDatabase }
63
+
64
+ private init() {}
65
+ }
66
+ ```
67
+
68
+ `lazy` requires `var`, not `let`. Safe because the property is private and the singleton is `@MainActor`-isolated. Same fix applies to `CKDatabase`, `CKQuerySubscription`, `CKRecordZone`, and any other CloudKit type whose initializer touches the container.
69
+
70
+ Audit checklist:
71
+ - Every stored property of type `CKContainer`, `CKDatabase`, `CKQuerySubscription`, `CKRecordZone` must be `lazy var` or computed
72
+ - No app-launch code path (`App.init`, `@StateObject` default value, eager singleton ref) calls a method on the singleton that touches the lazy container
73
+
74
+ ### Verify
75
+ ```bash
76
+ # Pick an available iOS Simulator dynamically (no hardcoded device name)
77
+ SIM_DEST=$(xcrun simctl list devices available -j \
78
+ | python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; \
79
+ runtimes=sorted([k for k in d if "iOS" in k], reverse=True); \
80
+ sims=next((d[k] for k in runtimes if d[k]), []); \
81
+ print("platform=iOS Simulator,id=" + sims[0]["udid"]) if sims else sys.exit(1)')
82
+
83
+ xcodebuild test \
84
+ -project YourApp.xcodeproj \
85
+ -scheme YourApp \
86
+ -destination "$SIM_DEST" \
87
+ -configuration Debug \
88
+ CODE_SIGNING_ALLOWED=NO \
89
+ -only-testing:YourAppTests
90
+ ```
91
+ Tests should build, launch, and execute. Production signed builds remain unaffected — the `lazy` change is transparent.
92
+
93
+ ### Notes
94
+ - `try?` around `CKContainer(identifier:)` does NOT help — the init is not `throws`, it OS-faults
95
+ - This crash is silent in `-quiet` mode — re-run without `-quiet` and grep for `CK]`, `EXC_`, or `Significant issue`
96
+
97
+ ---
98
+
99
+ ## 2. SwiftData missing inverse relationship crash
100
+
101
+ ### Trigger
102
+ - `ModelContainer(for:configurations:)` throws `SwiftDataError._Error.loadIssueModelContainer`
103
+ - The error occurs for BOTH persistent AND in-memory `ModelConfiguration` (rules out migration / CloudKit / corrupted store)
104
+ - Recently added a new `@Model` class with a `@Relationship` to an existing model
105
+ - The error message does NOT identify which model or relationship is broken
106
+
107
+ ### Root cause
108
+ SwiftData CAN auto-infer inverse relationships when both sides declare them, but it CANNOT create the inverse property when only one side does. Every `@Relationship` property must have a matching declaration on the target model. CloudKit (`cloudKitDatabase: .automatic`) makes the requirement even stricter — all relationships must have explicit inverses.
109
+
110
+ ### Fix
111
+ 1. Map all relationships across every `@Model` class:
112
+
113
+ | Model A property | Model B property | Status |
114
+ |---|---|---|
115
+ | `Horse.vetRecords: [VetRecord]?` | `VetRecord.horse: Horse?` | OK — auto-inferred |
116
+ | `BarnNote.horse: Horse?` | _(nothing on Horse)_ | BROKEN — missing inverse |
117
+
118
+ 2. Add the missing inverse on the target model:
119
+ ```swift
120
+ // Horse.swift — ADD THIS:
121
+ @Relationship(deleteRule: .cascade)
122
+ var barnNotes: [BarnNote]? = nil
123
+ ```
124
+
125
+ 3. Delete the app from the simulator/device to clear any corrupted local store, then rebuild.
126
+
127
+ 4. Add this unit test so the issue can never silently regress:
128
+ ```swift
129
+ func testModelContainerSchemaIsValid() throws {
130
+ _ = try ModelContainer(
131
+ for: ModelA.self, ModelB.self, /* every @Model type */,
132
+ configurations: ModelConfiguration(isStoredInMemoryOnly: true)
133
+ )
134
+ }
135
+ ```
136
+
137
+ ### Verify
138
+ The unit test above passes. If the in-memory container initializes successfully, the schema is valid — any remaining persistent-store errors are migration issues, not schema issues.
139
+
140
+ ### Diagnostic flow when you see `loadIssueModelContainer`
141
+ 1. It's NOT a migration issue (in-memory stores don't migrate)
142
+ 2. It's NOT a CloudKit issue (in-memory stores don't use CloudKit)
143
+ 3. It IS a schema definition issue — check ALL `@Relationship` properties
144
+ 4. Use `git log` to find the commit that added the new model and verify both sides were updated
145
+
146
+ ---
147
+
148
+ ## 3. SwiftData CloudKit cross-Apple-ID sharing gap
149
+
150
+ ### Trigger
151
+ - App uses `cloudKitDatabase: .automatic` and needs spouse / family member / teammate (different Apple ID) to collaborate
152
+ - Compiler error: `type 'CKShare.Metadata' has no member 'activityType'` or `activityTypeKey`
153
+ - Compiler error: `(saved, _) = try await db.modifyRecords(...)` doesn't compile
154
+ - `ModelConfiguration` has no `cloudKitDatabase: .shared` case
155
+ - Spouse/teammate logs in on a different Apple ID and sees an empty database despite the inviter having data
156
+
157
+ ### Root cause
158
+ SwiftData's `cloudKitDatabase: .automatic` only syncs the user's own private database across the user's own devices. There is no `.shared` option. Apple's official answer is "drop SwiftData and use `NSPersistentCloudKitContainer` directly" — but you can keep SwiftData and overlay a `CKShare` on a custom `CKRecordZone` instead.
159
+
160
+ ### Fix
161
+ Architecture: keep SwiftData as the local source of truth, overlay a `CKShare` on a custom `CKRecordZone` per shareable root, and let the CKShare handshake piggyback on the same CloudKit container SwiftData is already using.
162
+
163
+ Step 1 — add a stable zone identifier to your shareable root model:
164
+ ```swift
165
+ @Model
166
+ final class Household {
167
+ var name: String = ""
168
+ var cloudZoneName: String = "" // ← stable per-household zone ID
169
+ var ownerUserRecordName: String? // nil = local user owns it
170
+ var shareIsActive: Bool = false
171
+
172
+ init(name: String) {
173
+ self.name = name
174
+ self.cloudZoneName = "Household-\(UUID().uuidString)"
175
+ }
176
+ var isOwner: Bool { ownerUserRecordName == nil }
177
+ }
178
+ ```
179
+
180
+ Step 2 — sharing service. Two non-obvious bits:
181
+ 1. The modern async `db.modifyRecords(saving:deleting:)` returns DICTIONARIES, not arrays:
182
+ ```swift
183
+ let result = try await privateDB.modifyRecords(saving: [rootRecord, share], deleting: [])
184
+ // result.saveResults is [CKRecord.ID: Result<CKRecord, Error>]
185
+ let savedShare = result.saveResults.values.compactMap { res -> CKShare? in
186
+ if case .success(let record) = res { return record as? CKShare }
187
+ return nil
188
+ }.first
189
+ ```
190
+ 2. `container.accept([metadata])` returns a value the compiler will warn about — bind it explicitly:
191
+ ```swift
192
+ _ = try await container.accept([metadata])
193
+ ```
194
+
195
+ Step 3 — accept incoming shares. The activity type is the well-known string `"com.apple.CloudKit.ShareMetadata"` and the metadata lives under `"CKShareMetadata"` in `userInfo`. **There is NO `CKShare.Metadata.activityType` constant** even though older Apple sample code references one. Hardcode the strings:
196
+ ```swift
197
+ WindowGroup {
198
+ ContentView()
199
+ .modelContainer(container)
200
+ .onContinueUserActivity("com.apple.CloudKit.ShareMetadata") { activity in
201
+ guard let metadata = activity.userInfo?["CKShareMetadata"] as? CKShare.Metadata else { return }
202
+ Task { @MainActor in
203
+ _ = try? await CloudKitSharingService.shared.acceptShare(metadata: metadata)
204
+ }
205
+ }
206
+ }
207
+ ```
208
+
209
+ Step 4 — SceneDelegate fallback. The SwiftUI `onContinueUserActivity` callback is unreliable on cold launch (iOS 17/18). Implement BOTH paths if your app supports cold-launch share acceptance:
210
+ ```swift
211
+ // UIWindowSceneDelegate
212
+ func windowScene(_ windowScene: UIWindowScene,
213
+ userDidAcceptCloudKitShareWith metadata: CKShare.Metadata) {
214
+ Task { @MainActor in
215
+ try? await CloudKitSharingService.shared.acceptShare(metadata: metadata)
216
+ }
217
+ }
218
+ ```
219
+ On macOS: `application(_:userDidAcceptCloudKitShareWith:)` on `NSApplicationDelegate`.
220
+
221
+ Step 5 — owner records live in `privateCloudDatabase`, accepted shares live in `sharedCloudDatabase`. SwiftData's CloudKit mirror handles both automatically once accepted, but if you query CKRecords directly you must pick the right database.
222
+
223
+ ### Verify
224
+ 1. Owner-side: create a household, tap Invite. `UICloudSharingController` opens with a share URL. CloudKit Dashboard → Zones shows a new `Household-<UUID>` zone with a `HouseholdRoot` record containing a `cloudkit.share` field.
225
+ 2. Member-side: send the URL to a different Apple ID via Messages. Tap the URL. Your `onContinueUserActivity` (or scene delegate) callback fires with metadata.
226
+ 3. Sync test: owner adds a child entity → member sees it within ~10 seconds. (No manual record mirroring.)
227
+ 4. To ship to TestFlight you must hit **Deploy Schema Changes…** in CloudKit Console to promote custom record types to PRODUCTION.
228
+
229
+ ### Notes
230
+ - `@MainActor` deinit gotcha: if you make the sharing service `@MainActor`, you cannot reference any main-actor-isolated stored properties from `deinit`. Just delete the deinit (singletons live forever).
231
+
232
+ ---
233
+
234
+ ## 4. iCloud ubiquity container silent failure
235
+
236
+ ### Trigger
237
+ - App has `com.apple.developer.icloud-container-identifiers` entitlement
238
+ - `FileManager.url(forUbiquityContainerIdentifier:)` returns a non-nil URL
239
+ - App shows empty state (0 items) despite files in local Documents directory
240
+ - Import operations appear to succeed but data is never persisted
241
+ - Removing `try?` reveals: "You don't have permission to save the file 'foo' in the folder 'Documents'"
242
+
243
+ ### Root cause
244
+ `url(forUbiquityContainerIdentifier:)` returning non-nil only means the entitlement is **configured** — NOT that the container is ready for use. `try?` then swallows the actual permission error, leaving the app silently operating on an inaccessible directory.
245
+
246
+ ### Fix
247
+ Verify-then-use pattern with fallback. Never trust the URL alone:
248
+ ```swift
249
+ private init() {
250
+ let fm = FileManager.default
251
+ let resolvedDir: URL
252
+ let resolvedICloud: Bool
253
+
254
+ if let iCloudURL = fm.url(forUbiquityContainerIdentifier: "iCloud.com.example.App") {
255
+ let targetDir = iCloudURL.appendingPathComponent("Documents/data")
256
+ var iCloudWorks = false
257
+ do {
258
+ try fm.createDirectory(at: targetDir, withIntermediateDirectories: true)
259
+ // Critical: verify the directory is actually accessible
260
+ _ = try fm.contentsOfDirectory(at: targetDir, includingPropertiesForKeys: nil)
261
+ iCloudWorks = true
262
+ } catch {
263
+ // iCloud container exists but isn't accessible (permission denied, etc.)
264
+ }
265
+ if iCloudWorks {
266
+ resolvedDir = targetDir
267
+ resolvedICloud = true
268
+ } else {
269
+ // Fall back to local Documents
270
+ let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0]
271
+ let local = docs.appendingPathComponent("data")
272
+ try? fm.createDirectory(at: local, withIntermediateDirectories: true)
273
+ resolvedDir = local
274
+ resolvedICloud = false
275
+ }
276
+ } else {
277
+ let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0]
278
+ let local = docs.appendingPathComponent("data")
279
+ try? fm.createDirectory(at: local, withIntermediateDirectories: true)
280
+ resolvedDir = local
281
+ resolvedICloud = false
282
+ }
283
+
284
+ self.dataDirectory = resolvedDir
285
+ self.isICloud = resolvedICloud
286
+ }
287
+ ```
288
+
289
+ Anti-pattern to flag during audit:
290
+ ```swift
291
+ // BAD: assumes non-nil URL means the directory is usable
292
+ if let iCloudURL = fm.url(forUbiquityContainerIdentifier: "...") {
293
+ let dir = iCloudURL.appendingPathComponent("Documents/data")
294
+ try? fm.createDirectory(at: dir, withIntermediateDirectories: true) // silently fails!
295
+ self.dataDirectory = dir
296
+ }
297
+ ```
298
+
299
+ ### Verify
300
+ 1. Add temporary logging of the resolved path
301
+ 2. Verify `contentsOfDirectory` succeeds on the chosen path
302
+ 3. Write a test file and read it back
303
+
304
+ ### Notes
305
+ - Common in: Debug builds from Xcode without full iCloud provisioning, capability added but container not created in Apple Developer portal, first launch before iCloud sync initialized, user not signed into iCloud
306
+ - In sandboxed macOS apps, `FileManager.urls(for: .documentDirectory)` returns `~/Library/Containers/<bundleId>/Data/Documents/`, not the user's Documents folder
307
+
308
+ ---
309
+
310
+ ## 5. iCloud symlink content corruption (sparse / dehydrated files)
311
+
312
+ ### Trigger
313
+ - `stat -f '%z' file` shows size > 0 but `stat -f '%b' file` shows `0` blocks
314
+ - Files open and read successfully but return empty content
315
+ - `JSONDecoder` fails on files that "exist" with non-zero reported size
316
+ - Content directory was symlinked or rsynced into iCloud Drive
317
+
318
+ ### Root cause
319
+ When you migrate content from local storage to iCloud Drive (symlink or rsync), iCloud may create "dehydrated" placeholder files: non-zero `st_size` but `st_blocks=0`. The files appear to exist but read as empty. This silently corrupts data.
320
+
321
+ ### Fix
322
+ 1. Detect sparse files (macOS `stat` syntax) — null-delimited so filenames with spaces/newlines are handled correctly:
323
+ ```bash
324
+ find /path/to/content -type f -print0 | while IFS= read -r -d '' f; do
325
+ size=$(stat -f '%z' "$f" 2>/dev/null)
326
+ blocks=$(stat -f '%b' "$f" 2>/dev/null)
327
+ if [ "$size" -gt 0 ] 2>/dev/null && [ "$blocks" = "0" ]; then
328
+ echo "SPARSE: $f"
329
+ fi
330
+ done
331
+ ```
332
+ 2. Recovery priority order: git history (only for previously-tracked files) → Time Machine → manual reconstruction.
333
+ 3. Delete irrecoverable sparse placeholders — they waste space and cause misleading `stat` results.
334
+ 4. **Never rsync local → iCloud with `--delete`** if you suspect corruption — the backup mirrors the corruption.
335
+
336
+ ### Audit checks
337
+ Flag any code that mirrors content into `~/Library/Mobile Documents/` paths without integrity verification (e.g., post-write `stat` or hash check). Binary files (images, video) that were never in git are completely irrecoverable from history.
338
+
339
+ ---
340
+
341
+ ## 6. SwiftUI xcstrings localization silent failures
342
+
343
+ A connected family of bugs. Each is silent — the app builds and runs, but UI ships in the wrong language.
344
+
345
+ ### 6.1 — `Text(stringVariable)` does NOT auto-localize
346
+ **Trigger:** strings appear in English in the UI; xcstrings has correct translations; device language is set correctly.
347
+ **Cause:** `Text("literal")` accepts `LocalizedStringKey` and auto-localizes. `Text(someVar)` where `someVar: String` does NOT — it renders raw. Any reusable component accepting `title: String` and passing it to `Text(title)` ships untranslated UI.
348
+ **Fix:** Either change the parameter type to `LocalizedStringKey` (for literal-only callers), OR document that callers must pre-localize via `String(localized:)`.
349
+
350
+ ### 6.2 — `String(localized:)` ignores SwiftUI environment locale
351
+ **Trigger:** in-app language picker changes formatting (dates/numbers) but not string translations.
352
+ **Cause:** `String(localized:)` uses `Bundle.main.preferredLocalizations`, controlled by `UserDefaults["AppleLanguages"]` — NOT by `.environment(\.locale)`. The SwiftUI environment locale only affects formatting, not catalog lookups.
353
+ **Fix:** Either (a) write to `AppleLanguages` and prompt for restart, OR (b) build an `appLocalized()` helper that passes the user's chosen locale explicitly:
354
+ ```swift
355
+ extension Locale {
356
+ static var app: Locale {
357
+ let id = UserDefaults.standard.string(forKey: "com.example.appLocale") ?? ""
358
+ return id.isEmpty ? .current : Locale(identifier: id)
359
+ }
360
+ }
361
+
362
+ func appLocalized(_ key: String.LocalizationValue, comment: StaticString = "") -> String {
363
+ String(localized: key, locale: .app, comment: comment)
364
+ }
365
+ ```
366
+ Use `appLocalized()` everywhere instead of `String(localized:)`. Always pass `comment:` so the xcstrings extractor populates context for translators.
367
+
368
+ ### 6.3 — AGA `^[...](inflect: true)` requires `LocalizedStringKey`
369
+ **Trigger:** Badge or label shows `^[0 horse](inflect: true)` literally instead of "0 horses".
370
+ **Cause:** Apple's Automatic Grammar Agreement only fires when rendered as `Text(LocalizedStringKey)`. `String(localized: "^[\(count) horse](inflect: true)")` returns a plain `String`, strips the AGA pipeline, and renders the markup literally. AGA `inflect: true` is also unreliable for non-English locales.
371
+ **Fix:** Use xcstrings `variations.plural` for ALL locales including English:
372
+ ```json
373
+ "^[%lld horse](inflect: true)": {
374
+ "localizations": {
375
+ "en": {
376
+ "variations": { "plural": {
377
+ "one": { "stringUnit": { "state": "translated", "value": "%lld horse" } },
378
+ "other": { "stringUnit": { "state": "translated", "value": "%lld horses" } }
379
+ }}
380
+ }
381
+ }
382
+ }
383
+ ```
384
+
385
+ ### 6.4 — Static cached `DateFormatter` breaks locale switching
386
+ **Trigger:** Dates render in the original device language even after the user switches in-app language.
387
+ **Cause:** Static `DateFormatter` instances capture locale at creation time and never update.
388
+ **Fix:** Use `Date.FormatStyle` (respects current locale, Foundation caches internally), OR construct a fresh formatter per call. Static caching IS safe for locale-independent formats like `yyyyMMdd` — set `locale = Locale(identifier: "en_US_POSIX")` to prevent calendar interference.
389
+
390
+ ### 6.5 — `date.formatted(...)` vs `Text(date, format:)` in views
391
+ **Cause:** `date.formatted(...)` returns a `String` using `Locale.current` (system locale, non-reactive). `Text(date, format: ...)` uses the SwiftUI environment locale and IS reactive.
392
+ **Fix:** Prefer `Text(date, format: .dateTime.weekday(.abbreviated))` in view bodies.
393
+
394
+ ### 6.6 — Localized strings stored in SwiftData / CoreData
395
+ **Cause:** Storing `"Lektion"` (German) and reading in French gives mixed-language UI.
396
+ **Fix:** Always store raw enum values; compute `displayName` at render time:
397
+ ```swift
398
+ @Model final class Transaction {
399
+ var categoryRaw: String = "lesson" // raw enum value
400
+ var category: TransactionCategory {
401
+ get { TransactionCategory(rawValue: categoryRaw) ?? .other }
402
+ set { categoryRaw = newValue.rawValue }
403
+ }
404
+ }
405
+ ```
406
+ Same rule for dates: store `Date`, format for display only. Use `ISO8601DateFormatter` for export/import.
407
+
408
+ ### 6.7 — Missing `comment:` parameter on `String(localized:)`
409
+ Translators have no context, and the xcstrings extractor won't auto-populate hints. Always pass `comment:`.
410
+
411
+ ### 6.8 — Missing `en` entry in xcstrings when other languages are present
412
+ Causes English UI to display with raw `^[...]` markup or fall back to keys.
413
+
414
+ ---
415
+
416
+ ## 7. XcodeGen project generation gotchas
417
+
418
+ ### 7.1 — Missing bundle ID on simulator install
419
+ **Trigger:** `xcodebuild test` fails with "Simulator device failed to install the application. Missing bundle ID." despite `PRODUCT_BUNDLE_IDENTIFIER` being correctly set.
420
+ **Cause:** `GENERATE_INFOPLIST_FILE: false` with a custom Info.plist that contains only app-specific keys. Xcode expects the plist to contain ALL required keys (`CFBundleIdentifier`, `CFBundleExecutable`, `CFBundlePackageType`).
421
+ **Fix:** Set `GENERATE_INFOPLIST_FILE: true` even when providing a custom Info.plist. Xcode will merge your custom keys with auto-generated standard keys.
422
+ ```yaml
423
+ settings:
424
+ base:
425
+ INFOPLIST_FILE: MyApp/Info.plist
426
+ GENERATE_INFOPLIST_FILE: true # merges custom + standard keys
427
+ ```
428
+
429
+ ### 7.2 — Preview Content Swift files not compiling
430
+ **Trigger:** Build fails with "cannot find 'PreviewSampleData' in scope".
431
+ **Cause:** `buildPhase: none` on the entire `Preview Content/` directory excludes ALL files including Swift sources. Correct for asset catalogs, wrong for Swift files needed at runtime (e.g., `PreviewSampleData.swift` used via `-SeedSampleData` launch argument).
432
+ **Fix:** Only exclude the asset catalog, not the entire directory:
433
+ ```yaml
434
+ # CORRECT
435
+ sources:
436
+ - path: MyApp
437
+ excludes:
438
+ - Preview Content/PreviewAssets.xcassets
439
+ - path: MyApp/Preview Content/PreviewAssets.xcassets
440
+ buildPhase: none
441
+ ```
442
+ Note: in Release builds on CI, `DEVELOPMENT_ASSET_PATHS` files may be stripped — for runtime-needed Swift files, MOVE them OUT of `Preview Content/` into the main source tree.
443
+
444
+ ### 7.3 — UILaunchScreen disappears after `xcodegen generate` (iOS letterbox mode)
445
+ **Trigger:** iOS app renders in a tiny letterboxed/compatibility window with large black borders. App was full-screen before but regressed after `xcodegen generate`.
446
+ **Cause:** When using XcodeGen's `info.path`, `xcodegen generate` **overwrites the entire plist file** from scratch using only the keys in `info.properties`. Any keys you manually added to the plist (like `UILaunchScreen`) are silently deleted. Without `UILaunchScreen` (even as an empty dict), iOS falls back to legacy compatibility mode.
447
+ **Fix:** Move `UILaunchScreen` into `project.yml`'s `info.properties`:
448
+ ```yaml
449
+ targets:
450
+ MyApp:
451
+ info:
452
+ path: MyApp/Info.plist
453
+ properties:
454
+ UILaunchScreen: {} # Empty dict = auto-generated launch screen
455
+ CFBundleDisplayName: MyApp
456
+ ```
457
+ Also REMOVE `INFOPLIST_KEY_UILaunchScreen_Generation: true` from build settings if both are present — they create a nested `UILaunchScreen > UILaunchScreen` structure. After fixing, **uninstall the app from the simulator** before reinstalling — the simulator caches the old launch screen configuration.
458
+
459
+ ### 7.4 — General rule
460
+ **Never manually edit the Info.plist file when using XcodeGen's `info.path`.** All custom keys must go in `info.properties` in `project.yml`, or they will be lost on next `xcodegen generate`.
461
+
462
+ ---
463
+
464
+ ## 8. TestFlight upload validation gotchas
465
+
466
+ These all happen SERVER-SIDE at Apple, not during local build/archive. CI shows green and the build succeeds, but the IPA never appears in TestFlight.
467
+
468
+ ### 8.1 — iPad multitasking interface orientations (code 409, fatal)
469
+ **Symptom:** `Invalid bundle. The "UIInterfaceOrientationPortrait" orientations were provided ... but you need to include all of the [4 orientations] to support iPad multitasking.`
470
+ **Cause:** Apple requires all 4 interface orientations declared in `UISupportedInterfaceOrientations`, OR the app must opt out of iPad multitasking via `UIRequiresFullScreen: true`. Applies even to iPhone-only apps.
471
+ **Fix:** Add to Info.plist:
472
+ ```xml
473
+ <key>UISupportedInterfaceOrientations</key>
474
+ <array>
475
+ <string>UIInterfaceOrientationPortrait</string>
476
+ <string>UIInterfaceOrientationPortraitUpsideDown</string>
477
+ <string>UIInterfaceOrientationLandscapeLeft</string>
478
+ <string>UIInterfaceOrientationLandscapeRight</string>
479
+ </array>
480
+ <key>UIRequiresFullScreen</key>
481
+ <true/>
482
+ ```
483
+ Or as XcodeGen build settings:
484
+ ```yaml
485
+ INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
486
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
487
+ INFOPLIST_KEY_UIRequiresFullScreen: YES
488
+ ```
489
+
490
+ ### 8.2 — Missing document configuration (code 90737, warning today / fatal future)
491
+ **Symptom:** `Missing Document Configuration. By declaring the CFBundleDocumentTypes key ... you've indicated that your app is able to open documents. Please set the LSSupportsOpeningDocumentsInPlace key to YES (recommended) or NO.`
492
+ **Cause:** Declaring `CFBundleDocumentTypes` requires also declaring how the app handles document access.
493
+ **Fix:**
494
+ ```xml
495
+ <key>LSSupportsOpeningDocumentsInPlace</key>
496
+ <true/>
497
+ ```
498
+
499
+ ### 8.3 — `apple-actions/upload-testflight-build` reports success on UPLOAD FAILED
500
+ **Cause:** The action wraps `xcrun altool`, which outputs validation errors in XML plist format. The action exit code can still be 0 even when the plist contains `UPLOAD FAILED`.
501
+ **Fix:** In CI release workflows, always grep raw upload logs:
502
+ ```bash
503
+ gh run view --job=<JOB_ID> --log 2>&1 | grep -i -E '(UPLOAD FAILED|product-errors|product-warnings)'
504
+ ```
505
+ Always check TestFlight after CI completes to confirm the build actually arrived. Don't trust the green checkmark.
506
+
507
+ ---
508
+
509
+ ## 9. xcodebuild App Group provisioning auth failure
510
+
511
+ ### Trigger
512
+ - `xcodebuild archive -allowProvisioningUpdates` fails with: `Authentication failed: Make sure a bearer token was provided, it is properly configured and signed, and it has not expired.`
513
+ - AND: `Provisioning profile "iOS Team Provisioning Profile: <bundle-id>" doesn't include the App Groups capability`
514
+ - The same API key WORKS for `xcrun altool --upload-app`
515
+ - Common scenario: adding a WidgetKit extension with shared App Group to an existing app
516
+
517
+ ### Root cause
518
+ The App Store Connect API key (`.p8`) has different permission scopes:
519
+ - **Upload / App Management**: works via `altool` for submitting builds
520
+ - **Provisioning Profile Management**: requires Xcode GUI session OR an API key with Admin/Developer role and certificate/profile management permissions
521
+
522
+ When `xcodebuild` encounters a new capability not in the existing provisioning profile, it tries to register the App Group identifier and regenerate profiles via the Apple Developer Portal API. This requires higher permissions than upload-only.
523
+
524
+ The error message ("bearer token not provided") is misleading — it's actually a permissions issue.
525
+
526
+ ### Fix
527
+ Manually fix provisioning ONCE in Xcode GUI before committing to CI:
528
+ 1. `open YourProject.xcodeproj`
529
+ 2. Select the main app target → Signing & Capabilities
530
+ 3. Click "Fix Issue" / "Register" for each capability
531
+ 4. Repeat for the widget/extension target
532
+ 5. Once Xcode builds successfully, the CLI deploy script will work
533
+
534
+ Alternative: register the App Group + bundle ID capabilities manually via the Apple Developer Portal at `https://developer.apple.com/account/resources/identifiers/`.
535
+
536
+ For XcodeGen multi-platform targets with widget extensions: use `platformFilter: iOS` on the dependency since widgets are primarily iOS.
537
+
538
+ ---
539
+
540
+ ## 10. iOS first-IAP submission rejection (Guideline 2.1b / 3.1.1)
541
+
542
+ ### Trigger
543
+ - App rejected with "Unable to make IAP purchases" in sandbox review
544
+ - All IAPs show "Developer Action Needed" / "Rejected" status
545
+ - Error in app: "Purchase failed: Unable to Complete Request"
546
+ - App rejected for missing "Restore Purchases" button (Guideline 3.1.1)
547
+
548
+ ### Root causes (often combined)
549
+ 1. **Missing Restore button:** apps that offer IAPs MUST include a distinct, user-tappable Restore button on the same screen where IAPs are shown. Not buried in deep settings.
550
+ 2. **Hardcoded fallback price:** `Text(price ?? "$0.99")` shows a tappable button before products load, which the App Reviewer taps and gets a failed purchase.
551
+ 3. **First-time IAPs CANNOT be tested in TestFlight sandbox.** Products will load (prices display correctly) but `product.purchase()` will fail with "Unable to Complete Request". This is NOT a code bug — it's a sandbox limitation for unapproved IAPs.
552
+
553
+ ### Fixes
554
+
555
+ **Restore button (StoreKit 2):**
556
+ ```swift
557
+ func restorePurchases() async -> Bool {
558
+ do {
559
+ try await AppStore.sync()
560
+ } catch {
561
+ purchaseError = "Failed to restore: \(error.localizedDescription)"
562
+ return false
563
+ }
564
+ await refreshPurchaseState()
565
+ return true
566
+ }
567
+ ```
568
+ Place the button on the same screen where IAPs are shown AND in Settings for discoverability. Show loading state during restore, error/success alerts.
569
+
570
+ **PurchaseButton UX — never show a hardcoded fallback price:**
571
+ ```swift
572
+ // BAD: shows tappable $0.99 even when product isn't loaded
573
+ Text(price ?? "$0.99")
574
+
575
+ // GOOD: ProgressView until product resolves
576
+ if let price {
577
+ Button(action: action) { Text(price) }
578
+ } else {
579
+ ProgressView()
580
+ }
581
+ ```
582
+
583
+ **Local StoreKit testing:** add a `.storekit` configuration file and reference it in the scheme's `LaunchAction` (`storeKitConfigurationFileReference`). Run from Xcode — purchases use the local test environment with no App Store Connect dependency. Note: only affects debug runs; archived/TestFlight builds use real sandbox.
584
+
585
+ **Submitting first-time IAPs alongside the app version (the App Store Connect UI changed):**
586
+ 1. Go to the app version page → click "Add for Review" — this creates a Draft Submission
587
+ 2. Go to each individual IAP page → click "Submit for Review" on each — adds it to the same draft
588
+ 3. Open the Draft Submission (bottom of any page) → click "Submit for Review"
589
+
590
+ **Clearing rejected IAP localizations:** for each IAP, edit the English (U.S.) localization (any minor edit), Save → status changes from "Rejected" to "Prepare for Submission". This can be automated with Playwright since App Store Connect is a web app.
591
+
592
+ ### Notes
593
+ - Sandbox tester accounts (Users and Access > Sandbox) now require an existing Apple Account
594
+ - For TestFlight, sandbox purchases use the user's real Apple ID automatically — no separate sandbox account needed
595
+ - Bank account "Processing" status does NOT block sandbox purchases for approved IAPs
596
+
597
+ ---
598
+
599
+ ## 11. `.foregroundStyle(.accentColor)` compile failure
600
+
601
+ ### Trigger
602
+ SwiftUI compile error on `.foregroundStyle(.accentColor)`.
603
+
604
+ ### Cause
605
+ `ShapeStyle` has no `.accentColor` member.
606
+
607
+ ### Fix
608
+ ```swift
609
+ // WRONG
610
+ .foregroundStyle(.accentColor)
611
+
612
+ // CORRECT
613
+ .foregroundStyle(Color.accentColor)
614
+ ```
615
+
616
+ ---
617
+
618
+ ## 12. Keychain test failures in simulator (CryptoKit)
619
+
620
+ ### Trigger
621
+ - AES-GCM encryption with key stored in Keychain via CryptoKit
622
+ - `SecItemAdd` and `SecItemCopyMatching` silently return non-success in test environment
623
+ - Encrypt-then-decrypt roundtrip test fails because a new key is generated on each `getOrCreateKey()` call
624
+
625
+ ### Fix
626
+ Add an in-memory key cache as fallback so tests don't depend on Keychain persistence:
627
+ ```swift
628
+ private static var cachedKey: SymmetricKey?
629
+
630
+ private static func getOrCreateKey() -> SymmetricKey? {
631
+ if let existingKey = loadKeyFromKeychain() {
632
+ cachedKey = existingKey
633
+ return existingKey
634
+ }
635
+ if let cached = cachedKey {
636
+ return cached
637
+ }
638
+ let newKey = SymmetricKey(size: .bits256)
639
+ cachedKey = newKey
640
+ saveKeyToKeychain(newKey)
641
+ return newKey
642
+ }
643
+ ```
644
+
645
+ ---
646
+
647
+ ## How audit agents should use this catalogue
648
+
649
+ 1. In Phase 0, the orchestrator detects project characteristics (CloudKit, SwiftData, iCloud, xcstrings, StoreKit, XcodeGen, CI release path) and lists the relevant entries from this catalogue's quick index for each downstream audit agent.
650
+ 2. Audit agents grep / read for the **TRIGGER** signals in each relevant entry. When a trigger matches, the agent files a finding referencing the entry number and severity.
651
+ 3. Remediation agents receive this catalogue as part of their context. When fixing an issue that matches an entry, they apply the FIX **as written** rather than improvising. The fixes here have all shipped in real projects.
652
+ 4. Test enhancement (Phase 4c) uses the test patterns embedded in entries 1, 2, 6, and 10 (CloudKit smoke test, `testModelContainerSchemaIsValid`, localization round-trip, IAP product-loading test) when the relevant project characteristics are present.
653
+
654
+ Severity guidance:
655
+ - **CRITICAL**: app-launch crashes (#1, #2), App Store rejection (#10 missing Restore), data loss (#5)
656
+ - **HIGH**: silent data corruption (#4, #6.6), build/release pipeline failures (#7.1, #7.3, #8.1), compile failures (#11)
657
+ - **MEDIUM**: UX bugs that ship to users (#6.1–6.5, #6.7), warnings that may become fatal (#8.2)
658
+ - **LOW**: code clarity / convention drift
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slash-do",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "Curated slash commands for AI coding assistants — Claude Code, OpenCode, Gemini CLI, and Codex",
5
5
  "author": "Adam Eivy <adam@eivy.com>",
6
6
  "license": "MIT",
package/uninstall.sh CHANGED
@@ -32,7 +32,7 @@ OLD_COMMANDS=(cam good makegoals makegood optimize-md)
32
32
 
33
33
  LIBS=(
34
34
  code-review-checklist copilot-review-loop graphql-escaping
35
- remediation-agent-template swift-review-checklist
35
+ remediation-agent-template swift-review-checklist swift-gotchas
36
36
  review-surface-scan review-security-audit review-cross-file-tracing
37
37
  )
38
38