@horka/app-forge 0.1.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.
- package/LICENSE +32 -0
- package/README.md +99 -0
- package/bin/cli.js +371 -0
- package/bin/cli.test.js +91 -0
- package/package.json +43 -0
- package/templates/core/CLAUDE.md +36 -0
- package/templates/core/claude/memory/ARCHITECTURE.md +20 -0
- package/templates/core/claude/memory/COMMANDS.md +13 -0
- package/templates/core/claude/memory/DECISIONS.md +5 -0
- package/templates/core/claude/memory/NEXT_STEPS.md +11 -0
- package/templates/core/claude/memory/PROJECT_STATE.md +24 -0
- package/templates/core/claude/skills/kickoff/SKILL.md +84 -0
- package/templates/core/claude/skills/product-owner/SKILL.md +58 -0
- package/templates/core/claude/skills/restore-context/SKILL.md +29 -0
- package/templates/core/claude/skills/save-context/SKILL.md +35 -0
- package/templates/core/docs-architecture/ANTI_PATTERNS.md +180 -0
- package/templates/core/docs-architecture/ARCHITECTURE_PRINCIPLES.md +134 -0
- package/templates/core/docs-architecture/DELIVERY.md +68 -0
- package/templates/core/docs-architecture/DOCS_PLACEMENT.md +151 -0
- package/templates/core/docs-architecture/MULTI_REPO_CONTRACT.md +158 -0
- package/templates/core/docs-architecture/SDK_CONTRACT.md +214 -0
- package/templates/core/docs-architecture/SECURITY_USER_URLS.md +152 -0
- package/templates/core/gitignore +15 -0
- package/templates/core/mcp.json +8 -0
- package/templates/packs/nuxt-web/CLAUDE.md +74 -0
- package/templates/packs/nuxt-web/app/app.vue +5 -0
- package/templates/packs/nuxt-web/app/assets/css/main.css +18 -0
- package/templates/packs/nuxt-web/app/assets/css/tokens.css +41 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/components/DSButton.vue +70 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/index.ts +4 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/tests/DSButton.spec.ts +34 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/types/dsButton.ts +5 -0
- package/templates/packs/nuxt-web/app/domain/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/features/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/pages/index.vue +36 -0
- package/templates/packs/nuxt-web/app/utils/.gitkeep +0 -0
- package/templates/packs/nuxt-web/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/nuxt-web/docs-architecture/ARCHITECTURE.md +169 -0
- package/templates/packs/nuxt-web/docs-architecture/CONVENTIONS.md +140 -0
- package/templates/packs/nuxt-web/docs-architecture/I18N.md +102 -0
- package/templates/packs/nuxt-web/docs-architecture/OPS_WEB.md +176 -0
- package/templates/packs/nuxt-web/docs-architecture/SEO_AND_ROUTING.md +118 -0
- package/templates/packs/nuxt-web/gitignore +18 -0
- package/templates/packs/nuxt-web/nuxt.config.ts +49 -0
- package/templates/packs/nuxt-web/pack.json +11 -0
- package/templates/packs/nuxt-web/package.json +31 -0
- package/templates/packs/nuxt-web/playwright.config.ts +39 -0
- package/templates/packs/nuxt-web/server/api/health.get.ts +7 -0
- package/templates/packs/nuxt-web/tests/e2e/home.spec.ts +19 -0
- package/templates/packs/nuxt-web/tsconfig.json +4 -0
- package/templates/packs/nuxt-web/vitest.config.ts +23 -0
- package/templates/packs/swift-ios/CLAUDE.md +64 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Package.swift +21 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Sources/DataLayer/DataLayer.swift +11 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Package.swift +20 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Domain/SampleItem.swift +15 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Engine/SampleEngine.swift +14 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Repository/SampleItemRepository.swift +27 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Tests/{{PROJECT_NAME}}CoreTests/SampleEngineTests.swift +32 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Package.swift +17 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Color+DS.swift +18 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Components/DSCard.swift +22 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DS.swift +36 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DSFont.swift +26 -0
- package/templates/packs/swift-ios/claude/memory/COMMANDS.md +18 -0
- package/templates/packs/swift-ios/docs-architecture/ARCHITECTURE.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/CLOUDKIT_GUIDE.md +224 -0
- package/templates/packs/swift-ios/docs-architecture/CONVENTIONS.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/DESIGN_SYSTEM.md +272 -0
- package/templates/packs/swift-ios/docs-architecture/NAVIGATION.md +241 -0
- package/templates/packs/swift-ios/docs-architecture/TESTING.md +176 -0
- package/templates/packs/swift-ios/docs-architecture/WORKFLOW.md +165 -0
- package/templates/packs/swift-ios/github/workflows/ci.yml +48 -0
- package/templates/packs/swift-ios/gitignore +5 -0
- package/templates/packs/swift-ios/mcp.json +8 -0
- package/templates/packs/swift-ios/pack.json +11 -0
- package/templates/packs/swift-ios/project.yml +33 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/App.swift +32 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/AppNamespace.swift +4 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Module/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Store/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Tools/.gitkeep +0 -0
- package/templates/packs/ts-sdk/CHANGELOG.md +9 -0
- package/templates/packs/ts-sdk/CLAUDE.md +72 -0
- package/templates/packs/ts-sdk/MIGRATION.md +28 -0
- package/templates/packs/ts-sdk/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/ts-sdk/docs-architecture/ARCHITECTURE.md +132 -0
- package/templates/packs/ts-sdk/docs-architecture/CONVENTIONS_TS.md +152 -0
- package/templates/packs/ts-sdk/gitignore +6 -0
- package/templates/packs/ts-sdk/pack.json +11 -0
- package/templates/packs/ts-sdk/package.json +55 -0
- package/templates/packs/ts-sdk/scripts/verify-dist.mjs +67 -0
- package/templates/packs/ts-sdk/src/clients/AuthClient.ts +168 -0
- package/templates/packs/ts-sdk/src/core/HttpClient.ts +85 -0
- package/templates/packs/ts-sdk/src/core/Logger.ts +27 -0
- package/templates/packs/ts-sdk/src/core/SDKContext.ts +40 -0
- package/templates/packs/ts-sdk/src/core/withTimeout.ts +19 -0
- package/templates/packs/ts-sdk/src/errors/ApiError.ts +93 -0
- package/templates/packs/ts-sdk/src/index.ts +62 -0
- package/templates/packs/ts-sdk/src/types/index.ts +33 -0
- package/templates/packs/ts-sdk/tests/apiError.test.ts +58 -0
- package/templates/packs/ts-sdk/tests/httpClient.test.ts +60 -0
- package/templates/packs/ts-sdk/tests/singleFlight.test.ts +191 -0
- package/templates/packs/ts-sdk/tsconfig.json +15 -0
- package/templates/packs/ts-sdk/tsup.config.ts +22 -0
- package/templates/packs/ts-sdk/vitest.config.ts +8 -0
- package/templates/packs/vapor-api/CLAUDE.md +73 -0
- package/templates/packs/vapor-api/Dockerfile +80 -0
- package/templates/packs/vapor-api/Package.swift +68 -0
- package/templates/packs/vapor-api/Sources/App/App.swift +5 -0
- package/templates/packs/vapor-api/Sources/App/Configure/AppConfig.swift +108 -0
- package/templates/packs/vapor-api/Sources/App/Configure/configure.swift +74 -0
- package/templates/packs/vapor-api/Sources/App/Configure/entrypoint.swift +47 -0
- package/templates/packs/vapor-api/Sources/App/Configure/routes.swift +21 -0
- package/templates/packs/vapor-api/Sources/App/Error/Failed.swift +73 -0
- package/templates/packs/vapor-api/Sources/App/Error/FailedMiddleware.swift +56 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/AppItem.swift +38 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Controllers/ItemControllersCrud.swift +41 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/DTO/ItemDTO.swift +22 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Entities/ItemEntity.swift +30 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Migrations/ItemMigrationCreate.swift +25 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Repositories/ItemRepository.swift +32 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Services/ItemService.swift +57 -0
- package/templates/packs/vapor-api/Sources/App/Registry/ControllersRegister.swift +17 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MiddlewaresRegister.swift +15 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MigrationsRegister.swift +18 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Logging/JSONLogHandler.swift +59 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Middleware/HTTPLoggingMiddleware.swift +50 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Monitoring.swift +110 -0
- package/templates/packs/vapor-api/Sources/{{PROJECT_NAME}}Foundation/String+Trimmed.swift +15 -0
- package/templates/packs/vapor-api/Tests/AppTests/AppTests.swift +155 -0
- package/templates/packs/vapor-api/claude/memory/COMMANDS.md +30 -0
- package/templates/packs/vapor-api/docs-architecture/ARCHITECTURE.md +144 -0
- package/templates/packs/vapor-api/docs-architecture/CONVENTIONS.md +121 -0
- package/templates/packs/vapor-api/docs-architecture/GOTCHAS_LINUX_SWIFT.md +109 -0
- package/templates/packs/vapor-api/docs-architecture/OPS.md +102 -0
- package/templates/packs/vapor-api/env_dist +29 -0
- package/templates/packs/vapor-api/gitignore +7 -0
- package/templates/packs/vapor-api/pack.json +11 -0
- package/templates/packs/vapor-api/scripts/generate-error-codes.sh +73 -0
- package/templates/packs/vapor-api/scripts/validate-env-vars.sh +72 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# NAVIGATION.md — Navigation Architecture
|
|
2
|
+
|
|
3
|
+
No navigation library, no global Coordinator. Three layers:
|
|
4
|
+
1. **Root gating** — `RootView` switches on store state (splash → onboarding → tabs).
|
|
5
|
+
2. **Per-tab `NavigationStack(path:)`** — value-based destinations, path mutated by Interactors.
|
|
6
|
+
3. **External-event routing** — a dedicated `PushRouter` object owned by the AppDelegate, with a weak store reference and a pending-event queue for cold-launch races.
|
|
7
|
+
|
|
8
|
+
## 1. Root gating
|
|
9
|
+
|
|
10
|
+
`RootView` is a pure state switch. No `NavigationStack` at the root — stacks live inside tabs.
|
|
11
|
+
|
|
12
|
+
```swift
|
|
13
|
+
@main
|
|
14
|
+
struct App: SwiftUI.App {
|
|
15
|
+
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
|
16
|
+
@State private var store = ItemStore()
|
|
17
|
+
|
|
18
|
+
var body: some Scene {
|
|
19
|
+
WindowGroup {
|
|
20
|
+
RootView()
|
|
21
|
+
.environment(store)
|
|
22
|
+
.task {
|
|
23
|
+
appDelegate.pushRouter.store = store // wire BEFORE bootstrap
|
|
24
|
+
await store.bootstrap()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
struct RootView: View {
|
|
31
|
+
@Environment(ItemStore.self) private var store
|
|
32
|
+
@Environment(\.scenePhase) private var scenePhase
|
|
33
|
+
|
|
34
|
+
var body: some View {
|
|
35
|
+
Group {
|
|
36
|
+
if !store.isReady {
|
|
37
|
+
App.Splash() // first load — no flash of empty/default data
|
|
38
|
+
} else if store.needsOnboarding {
|
|
39
|
+
App.Welcome() // first launch — complete profile before entering
|
|
40
|
+
} else {
|
|
41
|
+
tabs
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
.onChange(of: scenePhase) { _, phase in
|
|
45
|
+
if phase == .active { Task { await store.handleForeground() } }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private var tabs: some View {
|
|
50
|
+
TabView {
|
|
51
|
+
Tab("Feed", systemImage: "house.fill") { App.Feed() } // owns its stack
|
|
52
|
+
Tab("Map", systemImage: "map.fill") { NavigationStack { App.Map() } }
|
|
53
|
+
Tab("Groups", systemImage: "person.2.fill") { App.Groups() } // owns its stack
|
|
54
|
+
Tab("Profile", systemImage: "person.crop.circle.fill") { NavigationStack { App.Profile() } }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Rules:
|
|
61
|
+
- Gate on `store.isReady`, never on "data is empty" — empty is a valid state, loading is not.
|
|
62
|
+
- Tabs that push destinations own their `NavigationStack` *inside* the tab view (so the path lives in that tab's ViewModel). Tabs that never push get a plain `NavigationStack { }` wrapper in `RootView` for the nav bar.
|
|
63
|
+
- App-wide errors surface as one alert on `RootView` bound to `store.lastError`.
|
|
64
|
+
|
|
65
|
+
> ⚠️ **Gotcha:** an alert attached to the presenter cannot appear over a presented sheet. Symptom: errors thrown from a sheet's save action silently never show. Cause: SwiftUI presents one thing per branch; the root alert is occluded by the sheet. Fix: sheets that can fail keep their **own** local alert.
|
|
66
|
+
|
|
67
|
+
## 2. Per-tab stacks: typed paths + Interactor-driven navigation
|
|
68
|
+
|
|
69
|
+
Each tab follows View / ViewModel / Interactor. The **path is state on the ViewModel**; the **Interactor mutates it**. Views never use raw `NavigationLink` to other features and never know sibling screens exist.
|
|
70
|
+
|
|
71
|
+
```swift
|
|
72
|
+
extension App.Feed {
|
|
73
|
+
@Observable
|
|
74
|
+
final class ViewModel {
|
|
75
|
+
/// Navigation path for this tab's stack — driven by the Interactor.
|
|
76
|
+
var path: [ItemGroup] = []
|
|
77
|
+
/// Drives the anchored popover for the tapped badge.
|
|
78
|
+
var selectedBadgeID: String?
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
struct Interactor {
|
|
82
|
+
let store: ItemStore
|
|
83
|
+
let viewModel: ViewModel
|
|
84
|
+
|
|
85
|
+
/// Navigation goes through the Interactor (no NavigationLink in the View).
|
|
86
|
+
func openGroup(_ group: ItemGroup) {
|
|
87
|
+
viewModel.path.append(group)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
extension App.Feed {
|
|
93
|
+
var body: some View {
|
|
94
|
+
NavigationStack(path: $viewModel.path) {
|
|
95
|
+
ScrollView { /* sections */ }
|
|
96
|
+
.navigationDestination(for: ItemGroup.self) { App.GroupDetail(group: $0) }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
- Single destination type per tab → path is `[ItemGroup]` directly. Multiple destination types → declare a tab-local `enum Route: Hashable { case group(ItemGroup), item(Item.ID) }`, path is `[Route]`, one `navigationDestination(for: Route.self)` switch.
|
|
103
|
+
- Pop = `viewModel.path.removeLast()`; pop-to-root = `viewModel.path = []`. Both testable without UI.
|
|
104
|
+
- Cross-tab "navigation" does not exist. If an external event must land on a specific screen, the store exposes state (e.g. `pendingInviteGroupID`) and the owning tab reacts to it — never reach into another tab's path.
|
|
105
|
+
- Name the domain type `ItemGroup`, not `Group`: `Group` collides with `SwiftUI.Group` in every view file.
|
|
106
|
+
|
|
107
|
+
## 3. External-event routing: `PushRouter`
|
|
108
|
+
|
|
109
|
+
One small `@MainActor` object bridges system callbacks (silent pushes, share/deeplink acceptance) to store refreshes. The AppDelegate owns it; the SwiftUI scene wires the store after launch.
|
|
110
|
+
|
|
111
|
+
```swift
|
|
112
|
+
@MainActor
|
|
113
|
+
final class PushRouter {
|
|
114
|
+
weak var store: ItemStore? {
|
|
115
|
+
didSet {
|
|
116
|
+
// Cold-launch race: a share accepted before the scene wired the store
|
|
117
|
+
// would otherwise be lost. Flush the queued event once the store connects.
|
|
118
|
+
if store != nil, pendingShareAccept {
|
|
119
|
+
pendingShareAccept = false
|
|
120
|
+
Task { await didAcceptShare() }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
private var pendingShareAccept = false
|
|
125
|
+
|
|
126
|
+
/// Silent CloudKit push → targeted refresh (payload only says "something changed").
|
|
127
|
+
func handle(_ notification: CKNotification) async {
|
|
128
|
+
switch notification.notificationType {
|
|
129
|
+
case .database: await store?.refreshGroups()
|
|
130
|
+
case .recordZone: await store?.refresh()
|
|
131
|
+
default: await store?.refresh(); await store?.refreshGroups()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func didAcceptShare() async {
|
|
136
|
+
guard let store else { pendingShareAccept = true; return } // queue, don't drop
|
|
137
|
+
await store.refreshGroups()
|
|
138
|
+
try? await Task.sleep(for: .seconds(2)) // server-side propagation delay
|
|
139
|
+
await store.refreshGroups() // second pass picks up the new shared zone
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
> ⚠️ **Gotcha (cold-launch race):** symptom — accepting a share invite while the app is cold-launched does nothing; the joined group never appears. Cause — `userDidAcceptCloudKitShareWith` fires *before* the SwiftUI scene runs `.task` and wires `pushRouter.store`, so the event hits a `nil` store and is dropped. Fix — set a `pending` flag in the guard and flush it in `store`'s `didSet` (shown above). Apply this queue-and-flush pattern to **every** external entry point (deeplinks, notification taps).
|
|
145
|
+
|
|
146
|
+
> ⚠️ **Gotcha (propagation delay):** symptom — after accepting a share, the first refresh returns no new data. Cause — the backend needs time to surface the newly shared zone after `accept`. Fix — refresh immediately *and* again after ~2s. Never rely on a single post-accept fetch.
|
|
147
|
+
|
|
148
|
+
## 4. Scene delegate: required even with SwiftUI lifecycle
|
|
149
|
+
|
|
150
|
+
In a SwiftUI App-lifecycle app, some system callbacks are delivered to the **scene** delegate, not the app delegate — CloudKit share acceptance (`userDidAcceptCloudKitShareWith`) is the canonical example. SwiftUI still creates and owns the window; you only *declare* the delegate class:
|
|
151
|
+
|
|
152
|
+
```swift
|
|
153
|
+
final class AppDelegate: NSObject, UIApplicationDelegate {
|
|
154
|
+
let pushRouter = PushRouter()
|
|
155
|
+
|
|
156
|
+
/// Declare the scene delegate class. SwiftUI keeps ownership of the window.
|
|
157
|
+
func application(_ application: UIApplication,
|
|
158
|
+
configurationForConnecting session: UISceneSession,
|
|
159
|
+
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
|
160
|
+
let config = UISceneConfiguration(name: nil, sessionRole: session.role)
|
|
161
|
+
if session.role == .windowApplication {
|
|
162
|
+
config.delegateClass = SceneDelegate.self
|
|
163
|
+
}
|
|
164
|
+
return config
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
final class SceneDelegate: NSObject, UIWindowSceneDelegate {
|
|
169
|
+
func windowScene(_ windowScene: UIWindowScene,
|
|
170
|
+
userDidAcceptCloudKitShareWith metadata: CKShare.Metadata) {
|
|
171
|
+
let pushRouter = (UIApplication.shared.delegate as? AppDelegate)?.pushRouter
|
|
172
|
+
Task {
|
|
173
|
+
try? await ShareAcceptance.accept(metadata) // metadata is non-Sendable: consume it here
|
|
174
|
+
await pushRouter?.didAcceptShare()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Rules:
|
|
181
|
+
- Do **not** implement `scene(_:willConnectTo:)` — that would take window ownership away from SwiftUI.
|
|
182
|
+
- Implement the share-accept callback in **both** delegates (app delegate gets it on some OS paths, scene delegate on others). Both funnel into the same `PushRouter` method, so duplication is one line.
|
|
183
|
+
- `CKShare.Metadata` is non-Sendable: accept it inside one `@MainActor` helper and only let Sendable results (IDs) escape.
|
|
184
|
+
|
|
185
|
+
> ⚠️ **Gotcha:** symptom — tapped invite links open the app but nothing happens; the app-delegate hook never fires. Cause — with the SwiftUI lifecycle the system delivers share acceptance to the *scene* delegate, and no scene delegate exists by default. Fix — the `configurationForConnecting` + `delegateClass` wiring above. Without it the callback is silently lost.
|
|
186
|
+
|
|
187
|
+
## 5. Popovers & sheets
|
|
188
|
+
|
|
189
|
+
Anchored detail (map markers, badge cells) uses `.popover` + `.presentationCompactAdaptation(.popover)` (anchored glass popover on iPhone instead of a sheet). One selection at a time via a `selectedID` + derived `Binding<Bool>`:
|
|
190
|
+
|
|
191
|
+
```swift
|
|
192
|
+
@State private var selectedID: UUID?
|
|
193
|
+
|
|
194
|
+
marker(item)
|
|
195
|
+
.onTapGesture { selectedID = item.id }
|
|
196
|
+
.popover(isPresented: binding(for: item.id)) {
|
|
197
|
+
detail(item).presentationCompactAdaptation(.popover)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private func binding(for id: UUID) -> Binding<Bool> {
|
|
201
|
+
Binding(
|
|
202
|
+
get: { selectedID == id },
|
|
203
|
+
set: { isPresented in
|
|
204
|
+
if isPresented { selectedID = id }
|
|
205
|
+
else if selectedID == id { selectedID = nil } // only clear if still ours
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
In the `set:` closure, guard `selectedID == id` before clearing — a stale dismiss from a previous popover must not wipe a newer selection.
|
|
212
|
+
|
|
213
|
+
> ⚠️ **Gotcha (orphaned popover — real production bug):** symptom — after deleting an item while its popover was open, tapping any other marker did nothing; the map was stuck. Cause — the item vanished from the list but `selectedID` still pointed to it, so SwiftUI kept trying to re-present a popover anchored to a non-existent annotation on every camera change, swallowing new selections. Two-part fix:
|
|
214
|
+
> 1. **Dismiss before mutating** in the detail view's destructive action:
|
|
215
|
+
> ```swift
|
|
216
|
+
> Button("Delete", role: .destructive) {
|
|
217
|
+
> dismiss() // close the popover FIRST so it can't orphan
|
|
218
|
+
> onDelete?()
|
|
219
|
+
> }
|
|
220
|
+
> ```
|
|
221
|
+
> 2. **Prune stale selection** defensively in the container:
|
|
222
|
+
> ```swift
|
|
223
|
+
> .onChange(of: items.map(\.id)) { _, ids in
|
|
224
|
+
> if let selected = selectedID, !ids.contains(selected) { selectedID = nil }
|
|
225
|
+
> }
|
|
226
|
+
> ```
|
|
227
|
+
> Do both. Never mutate a list while a presentation anchored to one of its rows is open.
|
|
228
|
+
|
|
229
|
+
Sheets (create/edit flows) follow the same discipline: a `Bool` or optional-item flag on the ViewModel (`isCreating`, `pendingInvite: GroupShare?`), toggled by the Interactor on success — the View never decides when a flow ends.
|
|
230
|
+
|
|
231
|
+
## Decision summary
|
|
232
|
+
|
|
233
|
+
| Concern | Pattern |
|
|
234
|
+
|---|---|
|
|
235
|
+
| Root routing | State switch in `RootView` (splash / onboarding / tabs) |
|
|
236
|
+
| In-tab navigation | `NavigationStack(path:)`, path on ViewModel, mutated by Interactor |
|
|
237
|
+
| Cross-feature navigation | Forbidden; react to store state instead |
|
|
238
|
+
| External events | `PushRouter` (weak store + pending queue flushed in `didSet`) |
|
|
239
|
+
| Share/deeplink entry | App delegate **and** scene delegate → same `PushRouter` |
|
|
240
|
+
| Anchored detail | `.popover` + `presentationCompactAdaptation(.popover)` + `selectedID` binding |
|
|
241
|
+
| Delete-while-presented | `dismiss()` first, then mutate; prune `selectedID` on list change |
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# TESTING — Strategy & Patterns
|
|
2
|
+
|
|
3
|
+
Swift Testing (`@Test` / `#expect`), not XCTest — the only exception is XCUITest UI automation, which the framework forces onto `XCTestCase`. All domain tests live in the Core package and run with plain `swift test` — no simulator, no Xcode project, < 10 s.
|
|
4
|
+
|
|
5
|
+
## 1. Test Pyramid
|
|
6
|
+
|
|
7
|
+
| Layer | Coverage | How |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| **Core package** (engines, services, models) | Exhaustive unit tests | `swift test --package-path Packages/{{PROJECT_NAME}}Core`. Pure logic, fast, deterministic. This is where ~95% of tests live. |
|
|
10
|
+
| **DataLayer** (CloudKit repositories) | Mapping & persistence-format tests, offline | Gate `swift build --package-path Packages/DataLayer` today (the skeleton ships no test target — `swift test` would exit 1, "no tests found"). Add a `DataLayerTests` target and switch to `swift test` once you have `CKRecord`↔domain round-trips, change-token store, merge policy — all in memory, no iCloud account. Real cloud IO (push, share accept) is validated manually on device. `InMemory*Repository` actors in Core keep every Store/Service testable without a database. |
|
|
11
|
+
| **UI** (SwiftUI views) | Thin XCUITest smoke/flow tests + simulator-MCP validation | XCUITests launch the app on the in-memory backend (`-uitest-mock` launch arg) so flows are deterministic and instant. Keep them few — views stay dumb; logic worth testing belongs in Core. |
|
|
12
|
+
|
|
13
|
+
What makes this work: engines are `public enum` with only `static` pure functions, `nonisolated` by nature, with `Calendar` (and `now: Date`) injected. No singletons, no system-clock reads, no I/O. Testable by construction.
|
|
14
|
+
|
|
15
|
+
```swift
|
|
16
|
+
public enum AchievementEngine {
|
|
17
|
+
public static func satisfiedIDs(
|
|
18
|
+
profile: Profile,
|
|
19
|
+
items: [Item],
|
|
20
|
+
calendar: Calendar = .current // injected — tests pass a fixed UTC calendar
|
|
21
|
+
) -> Set<String> { ... }
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 2. Swift Testing Patterns
|
|
26
|
+
|
|
27
|
+
One `@Suite struct` per engine/service; `@Test` functions; `#expect` for assertions; `await #expect(throws: SomeError.x)` for async error paths.
|
|
28
|
+
|
|
29
|
+
### Fixture helpers (`TestSupport.swift`)
|
|
30
|
+
|
|
31
|
+
A single `Fixture` enum provides builder functions with defaults, so each test only spells out what matters:
|
|
32
|
+
|
|
33
|
+
```swift
|
|
34
|
+
extension Calendar {
|
|
35
|
+
/// Deterministic calendar for tests (Gregorian, UTC) so day/hour boundaries are stable.
|
|
36
|
+
static var utcTest: Calendar {
|
|
37
|
+
var calendar = Calendar(identifier: .gregorian)
|
|
38
|
+
calendar.timeZone = TimeZone(identifier: "UTC")!
|
|
39
|
+
return calendar
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
enum Fixture {
|
|
44
|
+
static let calendar = Calendar.utcTest
|
|
45
|
+
|
|
46
|
+
static func date(_ year: Int, _ month: Int, _ day: Int,
|
|
47
|
+
_ hour: Int = 12, _ minute: Int = 0) -> Date {
|
|
48
|
+
var c = DateComponents()
|
|
49
|
+
(c.year, c.month, c.day, c.hour, c.minute) = (year, month, day, hour, minute)
|
|
50
|
+
return calendar.date(from: c)!
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static func item(lat: Double = 48.85, lon: Double = 2.35,
|
|
54
|
+
country: String? = "FR", rating: Int = 3,
|
|
55
|
+
at occurredAt: Date) -> Item {
|
|
56
|
+
Item(coordinate: Coordinate(latitude: lat, longitude: lon),
|
|
57
|
+
countryCode: country, rating: rating,
|
|
58
|
+
occurredAt: occurredAt, createdAt: occurredAt)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Rules:
|
|
64
|
+
- **Explicit dates always.** Every test passes `Fixture.date(2026, 6, 1)` — never `Date()`, never `.now`. Domain logic that reads the system clock is untestable; pass `now: Date` as a parameter instead.
|
|
65
|
+
- **Vary coordinates to isolate rules.** When a test needs N distinct places, use `lat: Double(index)` so place-dedup rules don't interfere with the rule under test.
|
|
66
|
+
- **Test both sides of every boundary.** A rule "hour in 5..<8" gets one test at 6 (unlocks) and one at 8 (must NOT unlock — exclusive bound).
|
|
67
|
+
|
|
68
|
+
## 3. Catalog-Consistency Tests
|
|
69
|
+
|
|
70
|
+
Whenever content lives in a catalog (achievements, ranks, themes) AND in engine rules, add one test that locks them together:
|
|
71
|
+
|
|
72
|
+
```swift
|
|
73
|
+
@Test func catalogIsConsistent() {
|
|
74
|
+
let ids = AchievementCatalog.all.map(\.id)
|
|
75
|
+
#expect(Set(ids).count == ids.count) // no duplicate ids
|
|
76
|
+
#expect(ids.allSatisfy { AchievementID(rawValue: $0) != nil }) // no orphan slug / typo
|
|
77
|
+
#expect(AchievementCatalog.all.count == AchievementID.allCases.count) // full coverage
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Pair it with a typed-ID enum inside the engine: rules insert `AchievementID` cases (typo = compile error) and convert to `String` slugs only at the return boundary.
|
|
82
|
+
|
|
83
|
+
> ⚠️ **Gotcha:** A new catalog entry shipped with no engine rule — visible in the UI, permanently locked, zero test failures. Cause: catalog and engine were two unlinked lists. Fix: the consistency test above. **Never hardcode counts** (`#expect(ids.count == 37)`); derive from `CaseIterable.allCases.count` so adding content can't silently desync, and the test never needs editing.
|
|
84
|
+
|
|
85
|
+
## 4. Service Tests via InMemory Repositories
|
|
86
|
+
|
|
87
|
+
Every repository protocol gets a thread-safe `actor InMemory*Repository` in the Core package itself (also reused by SwiftUI previews). Services are then tested end-to-end without persistence:
|
|
88
|
+
|
|
89
|
+
```swift
|
|
90
|
+
@Suite struct ItemServiceTests {
|
|
91
|
+
let calendar = Fixture.calendar
|
|
92
|
+
|
|
93
|
+
private func makeService(profile: Profile = Profile(id: "u"), items: [Item] = []) -> ItemService {
|
|
94
|
+
ItemService(repository: InMemoryItemRepository(profile: profile, items: items),
|
|
95
|
+
calendar: calendar)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@Test func addFirstItemAwardsXPAndFirstBadge() async throws {
|
|
99
|
+
let service = makeService()
|
|
100
|
+
let result = try await service.addItem(
|
|
101
|
+
ItemDraft(coordinate: .init(latitude: 48, longitude: 2),
|
|
102
|
+
occurredAt: Fixture.date(2026, 6, 1)),
|
|
103
|
+
now: Fixture.date(2026, 6, 1)) // `now` injected, never read inside
|
|
104
|
+
#expect(result.xpGained == 25)
|
|
105
|
+
#expect(result.newAchievements.contains { $0.id == "first_item" })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Test the *invariants*, not just happy paths: delete reverses item XP but keeps earned achievements; recompute is idempotent (`second.newAchievements.isEmpty`); best-streak is monotonic across deletions; validation errors throw (`await #expect(throws: GroupValidationError.emptyName) { ... }`).
|
|
111
|
+
|
|
112
|
+
## 5. Concurrency Tests (actor reentrancy)
|
|
113
|
+
|
|
114
|
+
> ⚠️ **Gotcha:** Concurrent `addItem` calls lost writes despite the service being an `actor`. Cause: actors are **reentrant across `await`** — two read-modify-write sequences interleaved at the repository `await` and the second save clobbered the first. Fix: a `serialized()` operation queue inside the service; proven by tests, not by reading the code:
|
|
115
|
+
|
|
116
|
+
```swift
|
|
117
|
+
@Test func concurrentAddsLoseNoWrites() async throws {
|
|
118
|
+
let service = makeService()
|
|
119
|
+
await withTaskGroup(of: Void.self) { group in
|
|
120
|
+
for i in 0..<25 {
|
|
121
|
+
group.addTask { _ = try? await service.addItem(draft(index: i), now: day) }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
let snapshot = try await service.snapshot()
|
|
125
|
+
#expect(snapshot.items.count == 25) // no lost updates
|
|
126
|
+
// persisted XP == one deterministic from-scratch recompute over the final item set
|
|
127
|
+
#expect(snapshot.profile.xp == ProgressEngine.recompute(
|
|
128
|
+
identity: Profile(id: "u"), items: snapshot.items, calendar: calendar).profile.xp)
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The "converges to a deterministic recompute" assertion catches both double-counting and lost increments — stronger than counting alone.
|
|
133
|
+
|
|
134
|
+
## 6. Merge / Sync Logic Tests
|
|
135
|
+
|
|
136
|
+
For local↔server merge (`Profile.merged(with:)`), test the field-by-field policy explicitly:
|
|
137
|
+
- Monotonic fields (`xp`, `streakBest`, unlocked-achievement set): max / union — **never regress**, and merge must be **symmetric** (`a.merged(b) == b.merged(a)` on those fields — test it).
|
|
138
|
+
- Non-monotonic fields (`streakCurrent`, `lastActiveDay`): follow the more recent day, **even if the value shrinks** — write a test documenting that shrinking is intentional, or someone will "fix" it.
|
|
139
|
+
|
|
140
|
+
## 7. Determinism Gotchas
|
|
141
|
+
|
|
142
|
+
> ⚠️ **Gotcha:** Time-window rules (e.g. "22:00–05:00") passed locally, failed on CI. Cause: `Calendar.current` uses the machine's timezone; an hour-boundary test is a different wall-clock hour elsewhere. Fix: inject `Calendar` into every engine function; tests always pass `Calendar.utcTest` (Gregorian + UTC).
|
|
143
|
+
|
|
144
|
+
> ⚠️ **Gotcha:** "Most visited place" flickered between app launches. Cause: tie between two equally-visited places resolved by `Dictionary` iteration order, which is nondeterministic. Fix: stable tie-break (smaller key wins) + a test computing stats on `items` and `items.reversed()` and asserting equal output.
|
|
145
|
+
|
|
146
|
+
> ⚠️ **Gotcha:** Two coordinates centimeters apart counted as two distinct places. Cause: place keys built by stringifying rounded doubles — `-0.0` and `0.0` stringify differently around the equator/meridian. Fix: integer quantization before keying; regression test asserts `placeKey` of `+0.00001` and `-0.00001` are both `"0,0"` and contain no `"-0"`.
|
|
147
|
+
|
|
148
|
+
> ⚠️ **Gotcha:** UI showed a "7-day streak" days after it died. Cause: `currentStreak` is frozen at the last activity day; nothing recomputes it until the next write. Fix: a separate `liveStreak` derived with an explicit `now:` parameter (0 if last activity > 1 day ago); tested at now = same day, next day (still alive — user can still extend), and +2 days (dead).
|
|
149
|
+
|
|
150
|
+
General rules:
|
|
151
|
+
- `sorted()` before comparing collections or picking "first" from grouped data — `Set`/`Dictionary` order is never stable.
|
|
152
|
+
- Engines never call `Date()`, `Calendar.current`, `TimeZone.current`, or `Locale.current` internally. All injected.
|
|
153
|
+
- Weekday/weekend logic must be locale-independent: match `calendar.component(.weekday, ...)` values (1 = Sunday, 7 = Saturday), never rely on `firstWeekday`.
|
|
154
|
+
|
|
155
|
+
## 8. How AI Agents Must Use Tests
|
|
156
|
+
|
|
157
|
+
1. **Run `swift test --package-path Packages/{{PROJECT_NAME}}Core` after EVERY engine/domain change.** It's seconds. No "I'll run it at the end".
|
|
158
|
+
2. **Every new rule ships with its tests** in the same change: the unlock case, the just-below-threshold case, and the boundary case. A rule without a test does not exist.
|
|
159
|
+
3. **New catalog entry?** The consistency test fails until you add the matching enum case + engine rule. That failure is the workflow — don't weaken the test.
|
|
160
|
+
4. **Tests are the spec.** When behavior is ambiguous, the test file is the authority (e.g. "merge can shrink current streak" is documented intent, not a bug). Read the relevant suite before changing an engine.
|
|
161
|
+
5. **Never edit an existing assertion to make your change pass** without understanding why it was written — most encode a fixed production bug. If a behavior change is intentional, update the test *and* its doc comment.
|
|
162
|
+
|
|
163
|
+
## UI / snapshot tests — when to add them (V2, deliberately not V1)
|
|
164
|
+
MVP skips UI tests on purpose: layouts churn too fast and brittle tests slow every slice. Add them
|
|
165
|
+
when (a) a screen has survived 3+ slices unchanged, or (b) a visual regression actually bit you.
|
|
166
|
+
Then prefer snapshot tests of MODULE bricks (L4 — stable contracts, no navigation) over full-screen
|
|
167
|
+
flows, and keep them in the app target, not the packages. Until then: the simulator screenshot at
|
|
168
|
+
every slice gate IS the UI regression net — actually look at it.
|
|
169
|
+
|
|
170
|
+
## Concurrency testing (Swift)
|
|
171
|
+
- The Thread Sanitizer is the truth serum: `swift test --sanitize=thread` on packages periodically
|
|
172
|
+
(it's slow — not every run; before each release at minimum).
|
|
173
|
+
- A concurrency bug fixed = a regression test that reproduces the race (e.g. N concurrent calls via
|
|
174
|
+
`TaskGroup` asserting a single side effect) — same discipline as any other gotcha.
|
|
175
|
+
- Swift 6 strict concurrency catches data races at compile time; never silence it with
|
|
176
|
+
`@unchecked Sendable` to make a test pass — fix the isolation instead.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# WORKFLOW — Dev Loop & AI-Agent Operating Manual
|
|
2
|
+
|
|
3
|
+
How to ship features in {{PROJECT_NAME}}. Prescriptive. Follow exactly.
|
|
4
|
+
|
|
5
|
+
## 1. Slice-Based Delivery
|
|
6
|
+
|
|
7
|
+
Ship **vertical slices**: thin end-to-end features (UI → domain → persistence), never horizontal layers across the whole app. One slice = one shippable increment validated on simulator.
|
|
8
|
+
|
|
9
|
+
**Per slice:**
|
|
10
|
+
1. **Blueprint first** — write the slice's blueprint as a section of `docs/SLICES.md` before any
|
|
11
|
+
code (single canonical file — per-slice standalone files drift). Must contain:
|
|
12
|
+
- The key architectural decision for the slice (e.g. "one CKRecordZone per Group, root record carries the CKShare") and why alternatives were rejected.
|
|
13
|
+
- Phases mapped to layers, each with an explicit file list (`NEW`/`MODIFY`) and signatures.
|
|
14
|
+
- A "Gotchas" section (known traps for the APIs involved).
|
|
15
|
+
- A production checklist (schema deploys, entitlements, device-only validations).
|
|
16
|
+
2. **Implement layer by layer**, gating each phase:
|
|
17
|
+
- **Phase A — Core package** (pure Swift, zero IO): domain types, engines, repository *protocols*, `InMemory*Repository`, service actors. Gate: `swift test` green.
|
|
18
|
+
- **Phase B — DataLayer package** (CloudKit/IO, depends on Core): record mapping, repositories as actors. Gate: `swift build` green today (DataLayer ships no test target yet); `swift test` once you add one for the mapping round-trips (they run offline, in memory).
|
|
19
|
+
- **Phase C — Module + App**: reusable UI components in `Module/`, then screens (`View / @Observable ViewModel / struct Interactor`). Gate: `xcodebuild build` green + app launched + screens rendered from sample data.
|
|
20
|
+
3. **Validate on simulator** (screenshots, tapping through flows — see §3).
|
|
21
|
+
4. **Update memory files** (§5) before considering the slice done.
|
|
22
|
+
|
|
23
|
+
Track slice status with checkboxes in `NEXT_STEPS.md`; log what was actually built (with test counts and verification status) in `PROJECT_STATE.md`.
|
|
24
|
+
|
|
25
|
+
## 2. Build & Test Loop (agents: this exact order)
|
|
26
|
+
|
|
27
|
+
**Packages FIRST. Always.** `swift test` on a pure package takes seconds and gives precise errors; `xcodebuild` takes minutes and buries them.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 1. Per-package, fast, no simulator needed
|
|
31
|
+
swift test --package-path Packages/{{PROJECT_NAME}}Core
|
|
32
|
+
swift build --package-path Packages/DataLayer # swift test once DataLayer has a test target (CKRecord mapping round-trips)
|
|
33
|
+
swift build --package-path Packages/{{PROJECT_NAME}}DS
|
|
34
|
+
|
|
35
|
+
# 2. App target — ONLY when all packages are green (xcodegen puts the .xcodeproj at the repo root)
|
|
36
|
+
xcodebuild -project {{PROJECT_NAME}}.xcodeproj -scheme {{PROJECT_NAME}} \
|
|
37
|
+
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
|
|
38
|
+
-derivedDataPath /tmp/{{PROJECT_NAME}}_dd build 2>&1 | grep -E "error:|warning:.*deprecated|BUILD"
|
|
39
|
+
|
|
40
|
+
# 3. App-target tests (only for app-layer logic: stores, formatting, UI tests)
|
|
41
|
+
xcodebuild -project {{PROJECT_NAME}}.xcodeproj -scheme {{PROJECT_NAME}} \
|
|
42
|
+
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' test 2>&1 | grep -E "error:|Test Suite|passed|failed"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- Pin `-derivedDataPath /tmp/{{PROJECT_NAME}}_dd` so the built `.app` is at a known path for install.
|
|
46
|
+
- Always pipe through `grep -E "error:|BUILD"` — never dump full xcodebuild output into context.
|
|
47
|
+
- Quote paths: a `&` or space in a directory name breaks zsh silently.
|
|
48
|
+
- New code that is testable without UI goes in a package, not the app target — that's the whole point of the split.
|
|
49
|
+
|
|
50
|
+
> ⚠️ **Gotcha — XcodeGen owns the project, not the pbxproj:** this project is generated by XcodeGen from `project.yml`, and the `.xcodeproj` is gitignored. Source files are picked up from the `sources:` globs in `project.yml` and packages from its `packages:`/`dependencies:` — adding a new `.swift` file under the app folder is included automatically on the next `xcodegen generate` (NOT by Xcode synchronized folders — those aren't in play here). Adding a *new local package* means editing `project.yml` (a `packages:` entry + a `dependencies:` line on the target), then re-running `xcodegen generate`. Never hand-edit the `.xcodeproj` — it's regenerated and your edit is lost.
|
|
51
|
+
|
|
52
|
+
### Simulator install / launch / visual check
|
|
53
|
+
|
|
54
|
+
After a green build, install and drive the app via the iOS-simulator MCP tools:
|
|
55
|
+
|
|
56
|
+
1. Rebuild with `-derivedDataPath /tmp/{{PROJECT_NAME}}_dd`.
|
|
57
|
+
2. `install_app` with `/tmp/{{PROJECT_NAME}}_dd/Build/Products/Debug-iphonesimulator/{{PROJECT_NAME}}.app`.
|
|
58
|
+
3. `launch_app` with terminate-running enabled.
|
|
59
|
+
4. `screenshot` to verify rendering; `ui_describe_all` for element coords; `ui_tap` / `ui_type` / `ui_swipe` to drive flows end-to-end.
|
|
60
|
+
|
|
61
|
+
`screenshot` / `launch_app` / `record_video` ride on `simctl` and always work. `ui_tap` / `ui_describe_all` / `ui_find_element` require **idb**: `brew install facebook/fb/idb-companion` + `uv tool install fb-idb`, verify with `idb list-targets`.
|
|
62
|
+
|
|
63
|
+
> ⚠️ **Gotcha — stale simulator binary:** the app on the simulator can be an old build (you tap a button that "doesn't exist"). **Always reinstall the current build before visual verification.** Symptom: feature missing on screen that compiles fine. Cause: you launched yesterday's binary. Fix: rebuild → `install_app` → `launch_app`, every time.
|
|
64
|
+
|
|
65
|
+
> ⚠️ **Gotcha — SwiftUI toolbar items invisible to accessibility tooling:** navigation-bar items (back, "+") are collapsed into an unexposed "Nav bar" group, so `ui_find_element` never finds them. Tap visual coordinates instead (vertical center of the nav bar ≈ y=88 on current iPhones).
|
|
66
|
+
|
|
67
|
+
> ⚠️ **Gotcha — CloudKit account probe stalls UI tests:** `CKContainer.accountStatus` can hang ~150 s on a freshly cloned simulator, freezing app startup. Fix: a launch argument (e.g. `-uitest-mock`) that makes the store bootstrap force the in-memory backend instantly; pass it in every UITest and agent-driven launch.
|
|
68
|
+
|
|
69
|
+
## 3. Device Debugging: `os.Logger`, never `print()`
|
|
70
|
+
|
|
71
|
+
`print()` output is only visible when Xcode's debugger is attached. On a real device launched from the home screen — exactly when CloudKit, push, and share-acceptance bugs appear — `print()` goes nowhere. Use `os.Logger`:
|
|
72
|
+
|
|
73
|
+
```swift
|
|
74
|
+
import os
|
|
75
|
+
|
|
76
|
+
let logger = Logger(subsystem: "{{BUNDLE_ID}}", category: "Store")
|
|
77
|
+
|
|
78
|
+
logger.info("account=\(status.rawValue, privacy: .public) backend=\(backendName, privacy: .public)")
|
|
79
|
+
logger.error("bootstrap failed: \(error.localizedDescription, privacy: .public)")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- Interpolated values are `<private>` by default — add `privacy: .public` to every value you actually need to read, or the log is useless.
|
|
83
|
+
- Read logs in Xcode (Window → Devices, or the console while running) or Console.app filtered by subsystem. Logs persist even when the app was launched without a debugger.
|
|
84
|
+
- Log at every backend decision point: account status, chosen backend (cloud vs in-memory), and raw server errors.
|
|
85
|
+
|
|
86
|
+
> ⚠️ **Gotcha — bug invisible on simulator, found only via device logs:** real-device sync "silently" loaded 0 items while the simulator (in-memory fallback) looked fine. Device logs showed CloudKit error 12/2006: *"cannot use an empty list to initialize a new field"*. Cause: CloudKit (Development) infers a List field's type at first save and **rejects an empty array** — so any fresh user whose record had `unlockedIDs = []` failed its very first save. Fix: in `CKRecord` mapping, **omit list fields when empty** (reads default to `[]`; the schema field is created on the first non-empty save). Without device logging this is undiagnosable.
|
|
87
|
+
|
|
88
|
+
> ⚠️ **Gotcha — CKShare acceptance never fires in AppDelegate:** with the SwiftUI App lifecycle, `userDidAcceptCloudKitShareWith` is delivered to the **scene delegate**, not the app delegate. Provide a `SceneDelegate` via `application(_:configurationForConnecting:options:)` (`delegateClass`) and implement `windowScene(_:userDidAcceptCloudKitShareWith:)`. Log in both handlers to see which fires. Also: invite links show "you need a newer version of this app" unless Info.plist contains `CKSharingSupported = true`.
|
|
89
|
+
|
|
90
|
+
## 4. Memory System (anti-hallucination)
|
|
91
|
+
|
|
92
|
+
Persistent context lives in `.claude/memory/`. **Restore at session start** (read all five files before doing anything). **Update after every significant change** — a session whose work isn't in memory didn't happen, because the next session can't see it.
|
|
93
|
+
|
|
94
|
+
| File | Contains |
|
|
95
|
+
|------|----------|
|
|
96
|
+
| `PROJECT_STATE.md` | What the app is, stack, package list with test counts, per-slice done log, **session-by-session changelog with verification status**, gotchas discovered, known remaining issues. The priority file. |
|
|
97
|
+
| `ARCHITECTURE.md` | Technical structure: layers, patterns (VVM-I, actors), package dependency graph, data flow. |
|
|
98
|
+
| `DECISIONS.md` | Numbered table (`D1, D2, …`): decision + one-line *why*. Append-only; reference IDs (e.g. "per D11") instead of re-litigating. |
|
|
99
|
+
| `NEXT_STEPS.md` | Roadmap with checkboxes per slice/phase, deferred findings, tech debt with file:line pointers. |
|
|
100
|
+
| `COMMANDS.md` | Real, verified commands (build, test, simulator, scripts) + environment quirks. Copy-paste ready. |
|
|
101
|
+
|
|
102
|
+
Rules:
|
|
103
|
+
- **Never invent project facts.** If memory doesn't cover it, read the code or ask.
|
|
104
|
+
- Record *negative* results too ("clustering: SwiftUI `Map` still has no native API even on iOS 26 — verified against docs; hand-rolled instead"). They prevent the next session from re-exploring dead ends.
|
|
105
|
+
- Track unfixable-offline issues explicitly (e.g. a "needs 2 iCloud accounts / real device" list) so they aren't silently forgotten before release.
|
|
106
|
+
|
|
107
|
+
## 5. Validation Etiquette (non-negotiable)
|
|
108
|
+
|
|
109
|
+
1. **Never claim "done" without building.** "It should work" is not a status. Minimum bar: packages `swift test` green + app `xcodebuild build` green.
|
|
110
|
+
2. **UI changes require a screenshot.** Rebuild → reinstall → launch → screenshot (and tap through the flow for interactions). "Build succeeded" says nothing about rendering.
|
|
111
|
+
3. **Logic changes require tests.** New domain rules get unit tests in the owning package, including non-regression tests for every fixed bug (the empty-list fix above shipped with one).
|
|
112
|
+
4. **Report status honestly, in tiers:**
|
|
113
|
+
- *Tested* — unit tests cover it, green.
|
|
114
|
+
- *Verified on simulator* — seen working via screenshot/taps.
|
|
115
|
+
- *Builds, unverified* — compiles; behavior not observed.
|
|
116
|
+
- *Device-only, NOT validated* — push, share acceptance, real iCloud accounts. Say so explicitly and keep a running list; never imply these work because the simulator fallback ran.
|
|
117
|
+
5. **Run adversarial review before release-grade milestones.** Confirmed findings get fixed *and re-validated* (tests + build + simulator); deferred findings go to `NEXT_STEPS.md` with the reason.
|
|
118
|
+
|
|
119
|
+
> ⚠️ **Gotcha — orphaned popover after deleting its anchor:** deleting an item from its own anchored popover left the popover floating (anchor gone), blocked selecting other items, and made the deleted marker "re-pop" while panning (SwiftUI recreated the annotation to serve the anchor). Fix: the detail card dismisses itself via `@Environment(\.dismiss)` on delete, AND the map clears `selectedID` in `onChange(of: items.ids)` when the selected item disappears. Verify deletion flows by actually deleting on the simulator — this class of bug is invisible to builds and unit tests.
|
|
120
|
+
|
|
121
|
+
> ⚠️ **Gotcha — UITests welded to display copy:** UITests matching localized button text break on every rewording. Put stable `accessibilityIdentifier`s on test-critical controls (FABs, tabs, primary CTAs, markers) and match those; keep `accessibilityLabel` for VoiceOver.
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
## Tooling gate (run BEFORE Phase 1 of any kickoff — hard stop, not a parting note)
|
|
125
|
+
```bash
|
|
126
|
+
xcodegen --version # required to generate the .xcodeproj
|
|
127
|
+
xcrun simctl list runtimes | grep iOS # a runtime must match project.yml's deploymentTarget
|
|
128
|
+
```
|
|
129
|
+
Missing tool → tell the user the one-line install NOW (`brew install xcodegen`) and agree on the
|
|
130
|
+
degraded-proof plan below before writing any code. Never discover this mid-build.
|
|
131
|
+
|
|
132
|
+
## Degraded-proof ladder (no .xcodeproj / no simulator available)
|
|
133
|
+
L5 app-target sources cannot be left "written but never compiled" — that shipped a broken screen
|
|
134
|
+
once. When `xcodegen`/simulator are unavailable, the MINIMUM proof is:
|
|
135
|
+
```bash
|
|
136
|
+
# 1. Build each package for the simulator (works WITHOUT an .xcodeproj — xcodebuild understands SPM):
|
|
137
|
+
cd Packages/{{PROJECT_NAME}}DS && xcodebuild -scheme {{PROJECT_NAME}}DS \
|
|
138
|
+
-destination 'generic/platform=iOS Simulator' -derivedDataPath /tmp/dd build
|
|
139
|
+
# (repeat for Core and DataLayer)
|
|
140
|
+
# 2. Typecheck the app-target sources against those products:
|
|
141
|
+
xcrun swiftc -typecheck -parse-as-library -swift-version 6 \
|
|
142
|
+
-default-isolation MainActor -module-name {{PROJECT_NAME}} \
|
|
143
|
+
-sdk $(xcrun --sdk iphonesimulator --show-sdk-path) \
|
|
144
|
+
-target arm64-apple-ios26.0-simulator \
|
|
145
|
+
-I /tmp/dd/Build/Products/Debug-iphonesimulator $(find {{PROJECT_NAME}} -name '*.swift')
|
|
146
|
+
# -parse-as-library (required for @main) · -default-isolation MainActor (mirrors the app
|
|
147
|
+
# target's SWIFT_DEFAULT_ACTOR_ISOLATION) · without these the typecheck fails on valid sources
|
|
148
|
+
```
|
|
149
|
+
Report which rung was reached (simulator screenshot > app build > typecheck > package tests).
|
|
150
|
+
A lower rung is acceptable ONLY if stated explicitly in the slice report and logged as debt.
|
|
151
|
+
|
|
152
|
+
> ⚠️ **Gotcha:** Symptom — a demo value ("~12 min", "5 items") presented as "computed by the
|
|
153
|
+
> engine" turns out fabricated. Rule — any number/string attributed to code must come from
|
|
154
|
+
> actually executed output (test log, REPL, app run). If you didn't run it, label it an estimate.
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
## Launch checklist (before ANY external testers)
|
|
158
|
+
- CloudKit: schema deployed Dev→Prod, `aps-environment` = production (see CLOUDKIT_GUIDE).
|
|
159
|
+
- User-generated content (shared groups, names, notes): report + block + ≤24h takedown path
|
|
160
|
+
(App Store Guideline 1.2 — rejection otherwise).
|
|
161
|
+
- Age rating honest to the content; age gate in-app if 17+/18+.
|
|
162
|
+
- Privacy: policy URL + App Privacy labels; precise location = sensitive (GDPR Art. 9 when
|
|
163
|
+
combined with intimate-life data) — collect the minimum, state the purpose.
|
|
164
|
+
- Shared-data privacy review: what does a group member SEE about others (stable user ids?
|
|
165
|
+
exact coordinates?) — decide precision/pseudonymization consciously, log it in DECISIONS.md.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# CI for {{PROJECT_NAME}} — proof over claims, automated from the first push.
|
|
2
|
+
name: CI
|
|
3
|
+
on:
|
|
4
|
+
push: { branches: [main, develop] }
|
|
5
|
+
pull_request:
|
|
6
|
+
jobs:
|
|
7
|
+
packages:
|
|
8
|
+
runs-on: macos-latest
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- name: Select newest Xcode (templates use swift-tools 6.2 → Xcode 26+)
|
|
12
|
+
# macos-latest defaults to an older Xcode whose Swift can't build swift-tools 6.2.
|
|
13
|
+
# Pick the newest installed Xcode (mirrors the AppForge scaffold-swift job).
|
|
14
|
+
run: |
|
|
15
|
+
set -o pipefail
|
|
16
|
+
LATEST="$(ls -d /Applications/Xcode*.app | sort -V | tail -n 1)"
|
|
17
|
+
echo "Using $LATEST"
|
|
18
|
+
sudo xcode-select -s "$LATEST"
|
|
19
|
+
swift --version
|
|
20
|
+
- name: Core tests (the spec)
|
|
21
|
+
run: swift test --package-path Packages/{{PROJECT_NAME}}Core
|
|
22
|
+
- name: DS builds
|
|
23
|
+
run: swift build --package-path Packages/{{PROJECT_NAME}}DS
|
|
24
|
+
- name: DataLayer builds
|
|
25
|
+
run: swift build --package-path Packages/DataLayer
|
|
26
|
+
- name: L5 typecheck ladder (no .xcodeproj needed)
|
|
27
|
+
# Build EVERY package for the simulator into ONE shared derivedData, then typecheck the
|
|
28
|
+
# app sources against ALL of their products — app code imports DS + Core + DataLayer, so a
|
|
29
|
+
# single-package -I would fail 'no such module'. A shared -derivedDataPath collects every
|
|
30
|
+
# .swiftmodule under one Products dir. pipefail makes `... | tail -1` surface a failed build
|
|
31
|
+
# instead of masking it behind tail's exit 0.
|
|
32
|
+
run: |
|
|
33
|
+
set -o pipefail
|
|
34
|
+
ROOT="$PWD"
|
|
35
|
+
DD=/tmp/dd
|
|
36
|
+
for pkg in {{PROJECT_NAME}}DS {{PROJECT_NAME}}Core DataLayer; do
|
|
37
|
+
( cd "$ROOT/Packages/$pkg" && \
|
|
38
|
+
xcodebuild -scheme "$pkg" \
|
|
39
|
+
-destination 'generic/platform=iOS Simulator' \
|
|
40
|
+
-derivedDataPath "$DD" build 2>&1 | tail -1 )
|
|
41
|
+
done
|
|
42
|
+
PRODUCTS="$DD/Build/Products/Debug-iphonesimulator"
|
|
43
|
+
xcrun swiftc -typecheck -parse-as-library -swift-version 6 \
|
|
44
|
+
-default-isolation MainActor -module-name {{PROJECT_NAME}} \
|
|
45
|
+
-sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \
|
|
46
|
+
-target arm64-apple-ios26.0-simulator \
|
|
47
|
+
-I "$PRODUCTS" \
|
|
48
|
+
$(find {{PROJECT_NAME}} -name '*.swift')
|