@clawhub-anderskev-60f39d7981
Reviews SwiftData code for model design, queries, concurrency, and migrations. Use when reviewing .swift files with import SwiftData, @Model, @Query, @ModelA...
---
name: swiftdata-code-review
description: Reviews SwiftData code for model design, queries, concurrency, and migrations. Use when reviewing .swift files with import SwiftData, @Model, @Query, @ModelActor, or VersionedSchema.
---
# SwiftData Code Review
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| @Model, @Attribute, @Relationship, delete rules | [references/model-design.md](references/model-design.md) |
| @Query, #Predicate, FetchDescriptor, #Index | [references/queries.md](references/queries.md) |
| @ModelActor, ModelContext, background operations | [references/concurrency.md](references/concurrency.md) |
| VersionedSchema, MigrationStage, lightweight/custom | [references/migrations.md](references/migrations.md) |
## Hard gates (before reporting findings)
Run in order; do not assert an issue until the gate for that issue passes.
1. **Scope — pass when:** You have the target `.swift` path(s) and confirmed SwiftData surface in scope (e.g. `import SwiftData`, `@Model`, `@Query`, `@ModelActor`, `VersionedSchema`, or migration types). If none apply, stop or narrow scope with one sentence.
2. **Reference — pass when:** For each checklist area you evaluate (models, queries, concurrency, migrations), you opened the matching `references/*.md` from the Quick Reference table **or** wrote `N/A: no <area> in this review` with a one-line reason.
3. **Evidence — pass when:** Every finding uses the `[FILE:LINE] ISSUE_TITLE` header (line range allowed) from the file you read; no finding without a cite.
4. **Report — pass when:** Findings list cites first (or inline) using `[FILE:LINE] ISSUE_TITLE`, then severity or checklist grouping—no uncited assertions.
## Review Checklist
- [ ] Models marked `final` (subclassing crashes)
- [ ] @Relationship decorator on ONE side only (not both)
- [ ] Delete rules explicitly set (not relying on default .nullify)
- [ ] Relationships initialized to empty arrays, not default objects
- [ ] Batch operations used for bulk inserts (`append(contentsOf:)`)
- [ ] @Query not loading thousands of items on main thread
- [ ] External values in predicates captured in local variables
- [ ] Scalar comparisons in predicates (not object references)
- [ ] @ModelActor used for background operations
- [ ] PersistentIdentifier/DTOs used to pass data between actors
- [ ] VersionedSchema defined for each shipped version
- [ ] MigrationPlan passed to ModelContainer
## When to Load References
- Reviewing @Model or relationships -> model-design.md
- Reviewing @Query or #Predicate -> queries.md
- Reviewing @ModelActor or background work -> concurrency.md
- Reviewing schema changes or migrations -> migrations.md
## Review Questions
1. Could this relationship assignment cause NULL foreign keys?
2. Is @Relationship on both sides creating circular references?
3. Could this @Query block the main thread with large datasets?
4. Are model objects being passed between actors unsafely?
5. Would schema changes require a migration plan?
FILE:references/concurrency.md
# SwiftData Concurrency
## Best Practices
| Practice | Description |
|----------|-------------|
| @ModelActor for background work | Proper thread isolation for SwiftData |
| PersistentIdentifier for cross-actor | Model objects are NOT Sendable |
| Sendable DTOs for data exchange | Create separate types for transfer |
| Task.detached for background | Ensures actor runs off main thread |
| Explicit `save()` in Task | Autosave may not execute in time |
| @Query for display, @ModelActor for mutations | Separate concerns |
## @ModelActor Pattern
```swift
@ModelActor
actor DataHandler {
func importItems(_ data: [ImportData]) throws {
for item in data {
modelContext.insert(Item(name: item.name))
}
try modelContext.save()
}
func updateItem(id: PersistentIdentifier, name: String) throws {
guard let item = self[id, as: Item.self] else { return }
item.name = name
try modelContext.save()
}
}
```
## Sendable DTO Pattern
```swift
struct ItemDTO: Sendable, Identifiable {
let id: PersistentIdentifier
let name: String
let timestamp: Date
}
@ModelActor
actor DataService {
func fetchItems() throws -> [ItemDTO] {
try modelContext.fetch(FetchDescriptor<Item>())
.map { ItemDTO(id: $0.persistentModelID, name: $0.name, timestamp: $0.timestamp) }
}
}
```
## Background Actor Creation
```swift
// GOOD: Ensures background execution
Task.detached {
let handler = DataHandler(modelContainer: container)
try await handler.importLargeDataset(data)
}
// Factory method alternative
@ModelActor
actor DataService {
static nonisolated func createBackground(container: ModelContainer) async -> DataService {
await Task.detached { DataService(modelContainer: container) }.value
}
}
```
## Critical Anti-Patterns
### Passing Model Objects Between Actors
```swift
// BAD: Model objects are not Sendable
func getItem(id: UUID) throws -> Item { // Crash or undefined behavior
try modelContext.fetch(...).first!
}
let item = try await dataHandler.getItem(id: someId)
item.name = "New" // Wrong thread!
// GOOD: Return DTO or use identifier
func getItemDTO(id: UUID) throws -> ItemDTO { ... }
func updateItem(id: PersistentIdentifier, name: String) throws { ... }
```
### Creating Actor on Main Thread
```swift
// BAD: Actor runs on main thread
@MainActor
func setup() {
let handler = DataHandler(modelContainer: container)
}
// GOOD: Create off main actor
func setup() async {
await Task.detached {
let handler = DataHandler(modelContainer: container)
}.value
}
```
### Single Actor Bottleneck
```swift
// BAD: All operations serialize
Task { try await sharedHandler.heavyImport1() }
Task { try await sharedHandler.heavyImport2() } // Waits for import1!
// GOOD: Separate actors for independent work
Task.detached {
let handler1 = DataHandler(modelContainer: container)
try await handler1.heavyImport1()
}
Task.detached {
let handler2 = DataHandler(modelContainer: container)
try await handler2.heavyImport2()
}
```
### Modifying After Actor Boundary
```swift
// BAD: Retaining models from background
let items = try await backgroundActor.fetchAllItems() // [Item]
items[0].name = "Changed" // CRASH - wrong context!
// GOOD: Use identifiers to load locally
let identifier = try await backgroundActor.getItemIdentifier()
await MainActor.run {
let item = mainContext.model(for: identifier) as? Item
item?.name = "Changed"
}
```
### Missing @MainActor on Observable
```swift
// BAD: UI updates may happen off main thread
@Observable
class ViewModel {
var items: [Item] = []
func load() async {
items = await dataService.fetchItems() // May update from background
}
}
// GOOD: Explicit main actor isolation
@Observable @MainActor
class ViewModel {
var items: [Item] = []
}
```
## Review Questions
- [ ] Is @ModelActor used for heavy data operations?
- [ ] Are model objects passed between actors? (They shouldn't be)
- [ ] Is Task.detached used for background actor creation?
- [ ] Is explicit `save()` called in Task contexts?
- [ ] Are ViewModels marked @Observable @MainActor?
- [ ] Are Sendable DTOs used for cross-actor data?
- [ ] Could a single actor become a bottleneck?
- [ ] Are PersistentIdentifiers used for cross-context references?
FILE:references/migrations.md
# SwiftData Migrations
## Best Practices
| Practice | Description |
|----------|-------------|
| VersionedSchema from start | Even before shipping, makes future migrations easier |
| Semantic versioning | `Schema.Version(1, 0, 0)` format |
| Typealias for latest | `typealias User = UsersSchemaV2.User` |
| Enums for schema versions | Won't be instantiated directly |
| Stages for ALL versions | Even lightweight ones |
| Chronological order | In `SchemaMigrationPlan.schemas` |
| `@Attribute(originalName:)` | When renaming properties to preserve data |
| Keep originalName annotation | For future installs from old versions |
## VersionedSchema Setup
```swift
enum UsersSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [User.self] }
@Model
class User {
var name: String
init(name: String) { self.name = name }
}
}
enum UsersSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [User.self] }
@Model
class User {
@Attribute(originalName: "name") var fullName: String // Renamed
@Attribute(.unique) var email: String // Added
init(fullName: String, email: String) { ... }
}
}
typealias User = UsersSchemaV2.User
```
## Migration Plan
```swift
enum UsersMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[UsersSchemaV1.self, UsersSchemaV2.self]
}
static var stages: [MigrationStage] { [migrateV1toV2] }
// Custom: clean duplicates before adding .unique
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: UsersSchemaV1.self,
toVersion: UsersSchemaV2.self,
willMigrate: { context in
let users = try context.fetch(FetchDescriptor<UsersSchemaV1.User>())
var seen = Set<String>()
for user in users where seen.contains(user.email) {
context.delete(user)
}
try context.save()
},
didMigrate: nil
)
// Lightweight: simple changes
static let migrateV2toV3 = MigrationStage.lightweight(
fromVersion: UsersSchemaV2.self,
toVersion: UsersSchemaV3.self
)
}
```
## ModelContainer Configuration
```swift
let container = try ModelContainer(
for: User.self,
migrationPlan: UsersMigrationPlan.self
)
```
## Critical Anti-Patterns
### Renaming Without originalName
```swift
// BAD: Data LOST
var fullName: String // Was "name", no mapping
// GOOD: Data preserved
@Attribute(originalName: "name") var fullName: String
```
### Adding .unique to Duplicated Data
```swift
// BAD: Crash if duplicates exist
@Attribute(.unique) var email: String
// GOOD: Clean duplicates in willMigrate first
willMigrate: { context in
// Remove duplicates before schema applies .unique
}
```
### Custom Migrations with CloudKit
```swift
// BAD: Crashes with CloudKit
MigrationStage.custom(willMigrate: { ... }, didMigrate: { ... })
// GOOD: Lightweight only for CloudKit apps
MigrationStage.lightweight(fromVersion:, toVersion:)
// Handle complex logic in app initialization
```
### Wrong Schema in Migration Closure
```swift
// BAD: V2 not available in willMigrate
willMigrate: { context in
try context.fetch(FetchDescriptor<SchemaV2.User>()) // WRONG
}
// GOOD: V1 in willMigrate, V2 in didMigrate
willMigrate: { context in
try context.fetch(FetchDescriptor<SchemaV1.User>()) // Correct
}
didMigrate: { context in
try context.fetch(FetchDescriptor<SchemaV2.User>()) // Correct
}
```
### Missing MigrationPlan
```swift
// BAD: Migration not applied
let container = try ModelContainer(for: User.self)
// GOOD: Migration plan specified
let container = try ModelContainer(
for: User.self,
migrationPlan: UsersMigrationPlan.self
)
```
## Lightweight vs Custom
**Lightweight migrations support:**
- Adding properties with default values
- Renaming with `@Attribute(originalName:)`
- Deleting properties
- Adjusting delete rules
**Custom migrations needed for:**
- Data transformation
- Deduplication before `.unique`
- Complex relationship changes
- Default value calculation from existing data
## Review Questions
- [ ] Is every shipped schema wrapped in VersionedSchema?
- [ ] Does each schema have a unique versionIdentifier?
- [ ] Are schemas listed in chronological order?
- [ ] Is there a MigrationStage for each version transition?
- [ ] Is MigrationPlan passed to ModelContainer?
- [ ] Do renamed properties use `@Attribute(originalName:)`?
- [ ] If adding `.unique`, are duplicates handled in willMigrate?
- [ ] Does willMigrate only access old schema models?
- [ ] Is CloudKit enabled? (Custom migrations will crash)
- [ ] Is `context.save()` called in migration closures?
FILE:references/model-design.md
# SwiftData Model Design
## Best Practices
| Practice | Description |
|----------|-------------|
| Mark models `final` | Subclassing causes runtime crashes |
| Explicit initializers | Required even with default values |
| `var` for relationships | `let` causes runtime crashes |
| Avoid `description` property | Reserved word; use `details` or `content` |
| @Relationship on ONE side | Both sides causes circular reference errors |
| Explicit delete rules | Don't rely on default `.nullify` |
| Optional relationships | Non-optional with `.nullify` crashes |
| Empty array init | `= []` not default objects |
| Batch operations | `append(contentsOf:)` not individual `append()` |
## @Attribute Options
| Option | When to Use |
|--------|-------------|
| `.unique` | Natural identifiers (NOT with CloudKit) |
| `.externalStorage` | Large binary data (images, files) |
| `.spotlight` | User-searchable text content |
| `.transformable` | Custom types needing serialization |
| `.allowsCloudEncryption` | Sensitive synced data |
| `.transient` | Computed/cached values |
## Delete Rules
```swift
@Relationship(deleteRule: .cascade) // Deletes children (owned relationships)
@Relationship(deleteRule: .nullify) // Sets to nil (default, fragile)
@Relationship(deleteRule: .deny) // Prevents deletion if children exist
@Relationship(deleteRule: .noAction) // Does nothing (dangerous!)
```
## Critical Anti-Patterns
### Decorating Both Sides of Relationship
```swift
// BAD: Circular reference error
@Model class Student {
@Relationship(inverse: \TestResult.student) var testResults: [TestResult]
}
@Model class TestResult {
@Relationship(inverse: \Student.testResults) var student: Student?
}
// GOOD: @Relationship on one side only
@Model class Student {
@Relationship(deleteRule: .cascade, inverse: \TestResult.student)
var testResults: [TestResult] = []
}
@Model class TestResult {
var student: Student? // No decorator
}
```
### Assignment in Initializer
```swift
// BAD: Foreign keys become NULL
init(floors: [Floor]) {
self.floors = floors // Bypasses tracking!
}
// GOOD: Use append
init(floors: [Floor]) {
self.floors.append(contentsOf: floors)
}
```
### Default Values for Relationships
```swift
// BAD: Runtime crash
var tag: Tag = Tag(name: "default")
// GOOD: Optional or set after insertion
var tag: Tag?
```
### Individual Appends in Loop
```swift
// BAD: 700x slower than Core Data
for i in 0..<1000 {
item.tags.append(Tag(name: "\(i)"))
}
// GOOD: Batch operation
var tags = (0..<1000).map { Tag(name: "\($0)") }
item.tags.append(contentsOf: tags)
```
### Arrays for Searchable Data
```swift
// BAD: Stored as blob, not searchable
var tags: [String]
// GOOD: Relationship model
@Relationship(deleteRule: .cascade) var tags: [Tag] = []
```
### Unique with CloudKit
```swift
// BAD: Breaks CloudKit sync
@Attribute(.unique) var email: String
// GOOD: Handle uniqueness programmatically
var email: String
```
## Review Questions
- [ ] Is the model marked `final`?
- [ ] Does it have an explicit initializer?
- [ ] Are relationships `var`, not `let`?
- [ ] Is @Relationship on only ONE side of bidirectional relationships?
- [ ] Does delete rule match optionality?
- [ ] Are relationships initialized to `= []`?
- [ ] Are batch operations used for bulk additions?
- [ ] If using CloudKit, are there any `.unique` attributes?
FILE:references/queries.md
# SwiftData Queries
## Best Practices
| Practice | Description |
|----------|-------------|
| Local variables for external values | Copy `Date.now` before using in predicate |
| Compare scalars, not objects | Use `id` (UUID), not object references |
| Restrictive checks first | Most selective conditions first |
| `localizedStandardContains()` | Case-insensitive text search |
| `starts(with:)` | `hasPrefix()` is NOT supported |
| `fetchCount()` for counts | Never fetch array just for `.count` |
| `fetchLimit`/`fetchOffset` | Pagination for large datasets |
| #Index for filtered properties | iOS 18+ performance optimization |
## @Query Patterns
```swift
// Static query
@Query var items: [Item]
// Sorted query
@Query(sort: \Item.timestamp, order: .reverse) var items: [Item]
// Multi-field sort
@Query(sort: [SortDescriptor(\Item.priority, order: .reverse),
SortDescriptor(\Item.name)]) var items: [Item]
// Dynamic filtering in init
init(showCompleted: Bool) {
let completed = showCompleted // Capture external value
_items = Query(filter: #Predicate<Item> { item in
completed || !item.isCompleted
})
}
```
## FetchDescriptor Patterns
```swift
var descriptor = FetchDescriptor<Item>(
predicate: #Predicate { $0.createdAt > cutoffDate },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = 50
descriptor.fetchOffset = page * 50
descriptor.propertiesToFetch = [\.id, \.title]
let count = try context.fetchCount(descriptor) // Count without fetching
let items = try context.fetch(descriptor)
```
## #Index (iOS 18+)
```swift
@Model final class Order {
#Index<Order>(
[\.status], // Filter by status
[\.createdAt], // Sort by date
[\.status, \.createdAt] // Compound index
)
var status: String
var createdAt: Date
}
```
## Critical Anti-Patterns
### External Values Directly in @Query
```swift
// BAD: Date.now evaluated at macro expansion
@Query(filter: #Predicate<Event> { $0.date > Date.now })
var events: [Event]
// GOOD: Capture in local variable
static var now: Date { Date.now }
@Query(filter: #Predicate<Event> { $0.date > now })
var events: [Event]
```
### Object Comparison in Predicate
```swift
// BAD: Runtime crash
let predicate = #Predicate<Post> { $0.author == selectedUser }
// GOOD: Compare identifier
let userId = selectedUser.id
let predicate = #Predicate<Post> { $0.author.id == userId }
```
### Unsupported String Methods
```swift
// BAD: These crash
item.name.hasPrefix("A") // Use starts(with:)
item.name.hasSuffix("z") // Not supported
item.name.uppercased() // Not translatable
// GOOD: Supported methods
item.name.starts(with: "A")
item.name.localizedStandardContains("test")
```
### Wrong Boolean Comparison
```swift
// BAD: Runtime crash
#Predicate<Movie> { $0.cast.isEmpty == false }
// GOOD: Negation operator
#Predicate<Movie> { !$0.cast.isEmpty }
```
### Fetching Just to Count
```swift
// BAD: Loads all objects into memory
let count = try context.fetch(FetchDescriptor<Item>()).count
// GOOD: Dedicated count method
let count = try context.fetchCount(FetchDescriptor<Item>())
```
### Heavy @Query on Main Thread
```swift
// BAD: Freezes UI
@Query var allPhotos: [Photo] // 10,000+ items
// GOOD: Pagination in view model
var descriptor = FetchDescriptor<Photo>()
descriptor.fetchLimit = 50
descriptor.fetchOffset = offset
```
## Review Questions
- [ ] Is @Query loading only what the view needs?
- [ ] Are external values captured in local variables?
- [ ] Are comparisons using scalars, not object references?
- [ ] Are string methods limited to supported ones?
- [ ] Is `!isEmpty` used instead of `isEmpty == false`?
- [ ] Is `fetchCount()` used instead of fetching for counts?
- [ ] Are `fetchLimit`/`fetchOffset` used for pagination?
- [ ] Are frequently filtered properties indexed (iOS 18+)?
Reviews Swift Testing code for proper use of
---
name: swift-testing-code-review
description: Reviews Swift Testing code for proper use of #expect/#require, parameterized tests, async testing, and organization. Use when reviewing .swift files with import Testing, @Test, #expect, @Suite, or confirmation patterns.
---
# Swift Testing Code Review
## Hard gates
Complete **in order** before recording Swift Testing review findings. Stack with `beagle-ios:review-verification-protocol` for universal review rules.
1. **Scope:** You have an explicit list of `.swift` paths under review (or a user-named single file). **Pass:** Paths captured in working notes **or** one line: `No Swift files in scope` — then stop with no findings.
2. **Swift Testing surface:** For each path you treat as Swift Testing code, confirm `import Testing` **or** `@Test` / `#expect` / `#require` / `@Suite` appears in that file (open or search). **Pass:** At least one match per critiqued file, or you exclude that file from Swift Testing review with a one-line reason (e.g. XCTest-only).
3. **Evidence + protocol:** Load `beagle-ios:review-verification-protocol` before asserting any issue. **Pass:** Each finding meets that skill’s anchor rules; any violated [Review Checklist](#review-checklist) item cites `[FILE:LINE]` evidence. If you report zero issues, state `Protocol applied; no Swift Testing issues` (or equivalent) in the review summary.
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| #expect vs #require, expression capture, error testing | [references/expect-macro.md](references/expect-macro.md) |
| @Test with arguments, traits, zip() pitfalls | [references/parameterized.md](references/parameterized.md) |
| confirmation, async sequences, completion handlers | [references/async-testing.md](references/async-testing.md) |
| @Suite, tags, parallel execution, .serialized | [references/organization.md](references/organization.md) |
## Review Checklist
- [ ] Expressions embedded directly in `#expect` (not pre-computed booleans)
- [ ] `#require` used only for preconditions, `#expect` for assertions
- [ ] Error tests check specific types (not generic `(any Error).self`)
- [ ] Parameterized tests with pairs use `zip()` (not Cartesian product)
- [ ] No logic mirroring implementation in parameterized expected values
- [ ] Async sequences tested with `confirmation(expectedCount:)`
- [ ] Completion handlers use `withCheckedContinuation`, not `confirmation`
- [ ] `.serialized` applied only where necessary (shared resources)
- [ ] Sibling serialized suites nested under parent if mutually exclusive
- [ ] No assumption of state persistence between `@Test` functions
- [ ] Disabled tests have explanations and bug links
## When to Load References
- Reviewing #expect or #require usage -> expect-macro.md
- Reviewing @Test with arguments or traits -> parameterized.md
- Reviewing confirmation or async testing -> async-testing.md
- Reviewing @Suite or test organization -> organization.md
## Review Questions
1. Could pre-computed booleans in `#expect` lose diagnostic context?
2. Is `#require` stopping tests prematurely instead of revealing all failures?
3. Are multi-argument parameterized tests creating accidental Cartesian products?
4. Could `zip()` silently drop test cases due to unequal array lengths?
5. Are completion handlers incorrectly tested with `confirmation`?
FILE:references/async-testing.md
# Async Testing
## Critical Anti-Patterns
### 1. Using confirmation for Completion Handlers
```swift
// BAD - Closure exits before callback fires
@Test func badCallbackTest() async {
await confirmation { confirm in
networkService.fetch { _ in
confirm() // Never reached in time!
}
}
}
// GOOD - Use withCheckedContinuation instead
@Test func goodCallbackTest() async {
await withCheckedContinuation { continuation in
networkService.fetch { result in
#expect(result.isSuccess)
continuation.resume()
}
}
}
```
### 2. Unsafe Counter Variables
```swift
// BAD - Counter variable causes Swift 6 concurrency error
@Test func badStreamTest() async {
var count = 0 // Unsafe in concurrent context
for await _ in generator {
count += 1
}
#expect(count == 10)
}
// GOOD - Thread-safe confirmation
@Test func goodStreamTest() async {
await confirmation(expectedCount: 10) { confirm in
for await _ in generator {
confirm()
}
}
}
```
### 3. Tasks Execute Immediately Assumption
```swift
// BAD - Task hasn't executed yet
@Test func badTaskTest() {
sut.refreshData() // Creates Task internally
#expect(mockRepo.loadCallCount == 1) // Still 0!
}
// GOOD - Wait for task completion
@Test func goodTaskTest() async {
mockRepo.stubResponse = .success([])
let exp = expectation(description: #function)
mockRepo.didLoad = { exp.fulfill() }
sut.refreshData()
await fulfillment(of: [exp], timeout: 1)
#expect(mockRepo.loadCallCount == 1)
}
```
### 4. Using sleep() in Tests
```swift
// BAD - Slow, flaky, arbitrary timing
@Test func badSleepTest() async throws {
startLongOperation()
try await Task.sleep(for: .seconds(2)) // Arbitrary delay
#expect(operationCompleted)
}
// GOOD - Use proper async/await or confirmations
@Test func goodAsyncTest() async throws {
let result = try await performOperation()
#expect(result.isSuccess)
}
```
### 5. Blocking with DispatchSemaphore/DispatchGroup
```swift
// BAD - Risk of deadlock, especially on main thread
@Test func badBlockingTest() async {
let semaphore = DispatchSemaphore(value: 0)
Task {
await asyncOperation()
semaphore.signal()
}
semaphore.wait() // Deadlock risk!
}
// GOOD - Use structured async/await
@Test func goodAsyncTest() async {
await asyncOperation()
#expect(result.isSuccess)
}
```
## confirmation API Usage
The `confirmation` API verifies callbacks/events occur a specific number of times.
```swift
// Default: expects exactly one confirmation
await confirmation { confirm in
for await event in eventStream {
confirm()
}
}
// Custom count
await confirmation(expectedCount: 10) { confirm in
for await _ in generator {
confirm()
}
}
// Verify something never happens
await confirmation(expectedCount: 0) { confirm in
// If confirm() is called, test fails
}
```
**Critical**: All `confirm()` calls MUST execute before the `confirmation` closure returns (eager evaluation). Unlike XCTest's `XCTestExpectation` with `fulfillment(of:timeout:)`, confirmations do not suspend waiting for future events.
For completion-handler APIs:
- **Option A**: Convert to async/await and `await` the async work inside the confirmation closure
- **Option B**: Use `withCheckedContinuation` (shown in Anti-Pattern #1 above)
- **Option C**: For callback-style tests that need waiting, use XCTest's `fulfillment(of:timeout:)` instead
## Time Limits
```swift
// Test-level time limit
@Test(.timeLimit(.minutes(1)))
func loadNames() async {
let viewModel = ViewModel()
await viewModel.loadNames()
#expect(viewModel.names.isEmpty == false)
}
// Suite-level (shorter of suite/test wins)
@Suite(.timeLimit(.minutes(2)))
struct NetworkTests {
@Test(.timeLimit(.minutes(1))) // 1 minute wins
func fastTest() async { }
}
```
## Best Practices
- **Use async/await directly** when available
- **Use `confirmation`** for async sequences and streams
- **Use `withCheckedContinuation`** for completion handler APIs
- **Store Task references** for testing unstructured concurrency
- **Use `withKnownIssue`** for flaky tests
- **Use `.timeLimit`** trait for tests with external dependencies
## Review Questions
1. Are completion handlers being tested with `withCheckedContinuation`, not `confirmation`?
2. Are async sequences tested with `confirmation(expectedCount:)`?
3. Are mutable counters in concurrent contexts replaced with confirmations?
4. Are unstructured Tasks being awaited before assertions?
5. Is `sleep()` being used instead of proper async patterns?
FILE:references/expect-macro.md
# #expect Macro
## Critical Anti-Patterns
### 1. Computing Booleans Outside #expect
```swift
// BAD - Loses expression capture and diagnostic context
let passed = user.age >= 18 && user.hasVerifiedEmail
#expect(passed)
// Failure shows: Expectation failed: passed
// GOOD - Full expression capture
#expect(user.age >= 18, "User must be adult")
#expect(user.hasVerifiedEmail, "Email verification required")
// Failure shows: (user.age → 16) >= 18
```
### 2. Overusing #require Instead of #expect
```swift
// BAD - Stops at first failure, hides other issues
@Test func testUserProfile() throws {
let user = try #require(fetchUser())
try #require(user.name == "Alice") // Stops here if fails
try #require(user.isActive) // Never checked
}
// GOOD - #require only for preconditions, #expect for assertions
@Test func testUserProfile() throws {
let user = try #require(fetchUser()) // Required to proceed
#expect(user.name == "Alice") // Soft check - continues
#expect(user.isActive) // Also checked
}
```
### 3. Mixing XCTest and Swift Testing
```swift
// BAD - Frameworks incompatible
@Test func testMixedFrameworks() {
XCTAssertEqual(value, expected) // WRONG
#expect(otherValue == expected)
}
// GOOD - Use one framework per test
@Test func testWithSwiftTesting() {
#expect(value == expected)
#expect(otherValue == expected)
}
```
### 4. Generic Error Testing
```swift
// BAD - Overly generic, masks specific failures
#expect(throws: (any Error).self) { try validate(input) }
// GOOD - Specific error case
#expect(throws: ValidationError.invalidFormat) { try validate(input) }
// GOOD - Custom validation for associated values
#expect(performing: { try validate(input) }, throws: { error in
guard let validationError = error as? ValidationError,
validationError.code == 400 else { return false }
return true
})
```
### 5. Force Unwrap After nil Check
```swift
// BAD - Assertion followed by force unwrap
#expect(optionalValue != nil)
let value = optionalValue!
// GOOD - Combine unwrap and assertion
let value = try #require(optionalValue)
```
## Best Practices
- **Embed expressions directly** in `#expect` for full diagnostic capture
- **Use `#expect`** for assertions (soft fail, continues), **`#require`** for preconditions (hard fail, stops)
- **Include descriptive messages** when failure reason isn't obvious
- **Test specific error cases** rather than generic `(any Error).self`
- **Implement `CustomTestStringConvertible`** for complex types to improve failure messages
## Migration from XCTest
| XCTest | Swift Testing |
|--------|---------------|
| `XCTAssertTrue(value)` | `#expect(value)` |
| `XCTAssertFalse(value)` | `#expect(!value)` |
| `XCTAssertNil(value)` | `#expect(value == nil)` |
| `XCTAssertNotNil(value)` | `#expect(value != nil)` |
| `XCTAssertEqual(a, b)` | `#expect(a == b)` |
| `XCTUnwrap(optional)` | `try #require(optional)` |
| `XCTFail(message)` | `Issue.record(message)` |
## Review Questions
1. Are expressions embedded directly in `#expect` for full capture?
2. Is `#require` used only for essential preconditions, not all assertions?
3. Are error tests checking specific error types, not generic `(any Error).self`?
4. Do complex types implement `CustomTestStringConvertible`?
5. Are assertion messages provided when failure reason isn't self-evident?
FILE:references/organization.md
# Test Organization
## Critical Anti-Patterns
### 1. Expecting State Persistence Between Tests
```swift
// BAD - Each test gets fresh instance, state doesn't persist
@Suite(.serialized)
struct StatefulTests {
var value = 0
@Test mutating func step1() { value = 42 }
@Test func step2() { #expect(value == 42) } // Fails! Fresh instance
}
// GOOD - Use init() for setup
struct Tests {
let value: Int
init() { value = 42 }
@Test func verify() { #expect(value == 42) }
}
// GOOD - Combine into one test for dependent steps
@Test func completeFlow() {
var value = 0
value = 42
#expect(value == 42)
}
```
### 2. Serializing Everything
```swift
// BAD - Slow, defeats parallelism
@Suite(.serialized)
struct AllTests {
// 200 tests that could run in parallel
}
// GOOD - Only serialize what needs it
struct AllTests {
struct FastTests { } // Parallel by default
@Suite(.serialized) struct DatabaseTests { } // Only these
}
```
### 3. Incorrect Nested Serialization
```swift
// BAD - Suites run in parallel with each other!
@Suite(.serialized) struct Suite1 { @Test func a() {} }
@Suite(.serialized) struct Suite2 { @Test func b() {} }
// GOOD - Nested under parent
@Suite(.serialized) struct DatabaseSuites {
@Suite struct Suite1 { @Test func a() {} }
@Suite struct Suite2 { @Test func b() {} } // Waits for Suite1
}
```
### 4. Using Static State for Sharing
```swift
// BAD - Race conditions with parallel tests
struct UnsafeTests {
static var shared = ""
@Test func create() { Self.shared = "value" }
@Test func check() { #expect(Self.shared == "value") } // Race!
}
// GOOD - Fresh instance per test
final class SafeTests {
let database: Database
init() throws { database = try Database(path: UUID().uuidString) }
deinit { try? database.cleanup() }
@Test func query() { } // Own database instance
}
```
### 5. Silent Test Skip Without Explanation
```swift
// BAD - No explanation why disabled
@Test(.disabled())
func flakyTest() {}
// GOOD - Reason and bug link
@Test(.disabled("Waiting for backend fix"), .bug("PROJ-123"))
func flakyTest() {}
```
## @Suite Fundamentals
Any type containing `@Test` functions is implicitly a suite. Use explicit `@Suite` for:
- Display names: `@Suite("User Validation Tests")`
- Traits: `@Suite(.serialized)`
- Nested organization
```swift
@Suite("Dessert Tests")
struct DessertTests {
@Suite struct WarmDesserts {
@Test func applePieCrustLayers() { }
}
@Suite struct ColdDesserts {
@Test func cheesecakeBakingStrategy() { }
}
}
```
**Supported types**: `struct` (preferred), `class` (for `deinit` teardown), `actor`
**NOT supported**: `enum` (cannot contain tests directly)
## Tags
Declare tags as extensions on `Tag`:
```swift
extension Tag {
@Tag static var unitTests: Self
@Tag static var integrationTests: Self
@Tag static var networking: Self
}
```
Apply to tests or suites (traits cascade to nested items):
```swift
@Suite(.tags(.database))
struct DatabaseTests {
@Test func testInsert() { } // Inherits .database
@Test(.tags(.critical)) func testTransaction() { } // .database + .critical
}
```
## Parallel Execution
Swift Testing runs all tests in parallel by default. Key implications:
- Each `@Test` gets its own fresh suite instance
- Global/static state causes race conditions
- Use `.serialized` only when necessary (shared resources, external services)
## Lifecycle
```swift
final class DatabaseServiceTests {
let sut: DatabaseService
let tempDirectory: URL
init() throws { // Setup - runs before EACH test
self.tempDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
self.sut = DatabaseService(database: TestDatabase(storageURL: tempDirectory))
}
deinit { // Teardown - runs after EACH test (requires class)
try? FileManager.default.removeItem(at: tempDirectory)
}
}
```
## Review Questions
1. Are tests assuming state persists between test functions?
2. Is `.serialized` applied only where necessary, not everywhere?
3. Are sibling `.serialized` suites nested under a parent if they must be mutually exclusive?
4. Is static/global state avoided in tests?
5. Do disabled tests have explanations and bug links?
FILE:references/parameterized.md
# Parameterized Tests
## Critical Anti-Patterns
### 1. Accidental Cartesian Product
```swift
// BAD - Creates 25 tests (5x5) when you need 5 pairs
@Test(arguments: [18, 30, 50, 70, 80], [77.0, 73, 65, 61, 55])
func verifyNormalHeartRate(age: Int, bpm: Double) { }
// GOOD - Creates exactly 5 paired tests with zip
@Test(arguments: zip([18, 30, 50, 70, 80], [77.0, 73, 65, 61, 55]))
func verifyNormalHeartRate(age: Int, bpm: Double) { }
```
### 2. Logic Mirroring Implementation
```swift
// BAD - Test logic mirrors implementation, masks bugs
@Test(arguments: Day.allCases)
func greeting(day: Day) {
#expect(greeting(of: day) == "Happy \(day.rawValue)!")
}
// GOOD - Explicit expected values
@Test(arguments: [
(Day.monday, "Happy Monday!"),
(Day.tuesday, "Happy Tuesday!")
])
func greeting(day: Day, expected: String) {
#expect(greeting(of: day) == expected)
}
```
### 3. Silent Drops with zip()
```swift
// BAD - If arrays have different lengths, extras are silently dropped
@Test(arguments: zip(Ingredient.allCases, Dish.allCases))
func cook(_ ingredient: Ingredient, into dish: Dish) { }
// If Ingredient has 5 cases but Dish has 4, one ingredient goes untested!
// GOOD - Explicit array ensures complete coverage
@Test(arguments: [
(Ingredient.rice, Dish.onigiri),
(Ingredient.potato, Dish.fries),
(Ingredient.tomato, Dish.salad)
])
func cook(_ ingredient: Ingredient, into dish: Dish) { }
```
### 4. CaseIterable Order Dependency
```swift
// BAD - Breaks if enum cases are reordered
@Test(arguments: zip(Status.allCases, ["P", "A", "C"]))
func statusCode(status: Status, code: String) { }
// GOOD - Explicit mapping immune to reordering
@Test(arguments: [
(Status.pending, "P"),
(Status.active, "A"),
(Status.completed, "C")
])
func statusCode(status: Status, code: String) { }
```
### 5. For-Loops Instead of Parameterized Tests
```swift
// BAD - Stops at first failure, unclear which value failed
@Test func doesNotContainNuts() throws {
for flavor in [Flavor.vanilla, .chocolate] {
try #require(!flavor.containsNuts)
}
}
// GOOD - Each value is independent test case
@Test(arguments: [Flavor.vanilla, .chocolate])
func doesNotContainNuts(flavor: Flavor) throws {
try #require(!flavor.containsNuts)
}
```
### 6. Missing .serialized for Shared Resources
```swift
// BAD - Random failures when server limits connections
@Test(arguments: [1, 2, 3, 4, 5])
func uploadFile(id: Int) async {
await server.upload(fileId: id)
}
// GOOD - Sequential execution for resource-constrained tests
@Test(.serialized, arguments: [1, 2, 3, 4, 5])
func uploadFile(id: Int) async {
await server.upload(fileId: id)
}
```
## Best Practices
- **Use explicit tuple arrays** over `zip(allCases, allCases)` for clarity
- **Use `zip()`** only when intentionally pairing two sequences
- **Prefer `#expect`** over `#require` in parameterized tests to see all failures
- **Implement `CustomTestStringConvertible`** for readable test names
- **Use `.serialized`** when tests share limited resources
## Available Traits
| Trait | Purpose |
|-------|---------|
| `.disabled(_:)` | Skip with explanation |
| `.disabled(if:_:)` | Conditional skip |
| `.enabled(if:)` | Execute only when condition met |
| `.bug(_:)` | Link to bug tracker |
| `.timeLimit(_:)` | Set max runtime (per test case) |
| `.serialized` | Force sequential execution |
| `.tags(_:)` | Classify for selective execution |
## Review Questions
1. Are multi-argument tests using `zip()` for pairs, or accidentally creating Cartesian products?
2. Do parameterized tests use explicit expected values, or mirror implementation logic?
3. Could unequal-length `zip()` silently drop test cases?
4. Are tests that access shared resources using `.serialized`?
5. Is `CustomTestStringConvertible` implemented for complex parameter types?
Reviews Swift code for concurrency safety, error handling, memory management, and common mistakes. Use when reviewing .swift files for async/await patterns,...
---
name: swift-code-review
description: Reviews Swift code for concurrency safety, error handling, memory management, and common mistakes. Use when reviewing .swift files for async/await patterns, actor isolation, Sendable conformance, or general Swift best practices.
---
# Swift Code Review
## Review Workflow
Follow this sequence **in order**. Do not emit findings until every **Pass** below is satisfied.
1. **Swift / toolchain baseline** — Establish language and tooling context: `Package.swift` `// swift-tools-version` and any per-target Swift language version or `swiftSettings` in the manifest; for Xcode, `SWIFT_VERSION` (or equivalent) in project or target build settings; note if review is single-file only.
**Pass:** You state a concrete Swift language version or mode (e.g. Swift 6 language mode, tools 5.10) *before* advice that depends on strict concurrency, migration-only syntax, or SDK availability.
2. **Read surrounding code** — For each changed `.swift` file, read the full enclosing type, function, method, or property that contains the edits, not only the diff hunk.
**Pass:** At least one full enclosing symbol (type or member) containing the change was read per changed file.
3. **Scope the checklist** — Using [Quick Reference](#quick-reference), decide which [Review Checklist](#review-checklist) rows and [references](#when-to-load-references) apply; open those reference files; skip rows clearly unrelated to the diff.
**Pass:** The review (or working notes) lists which checklist areas you applied, or marks areas N/A with a one-line reason tied to the diff (e.g. “no SwiftUI / @Observable in change”).
4. **Pre-report verification** — Load and follow [review-verification-protocol](../review-verification-protocol/SKILL.md).
**Pass:** That skill’s **Hard gates (sequenced)** are satisfied for each finding you will report (full symbol read, usage search before “unused”, caller checked before “missing handling”, severity calibrated, `[FILE:LINE]` proof).
## Hard gates (same sequence, shorter)
| Step | Objective pass condition |
| --- | --- |
| 1 | Swift version/mode (or explicit single-file limitation) recorded before version- or SDK-gated advice. |
| 2 | Full enclosing symbol read per changed file, not diff-only. |
| 3 | Checklist areas + references listed or N/A with diff-tied reason. |
| 4 | `review-verification-protocol` completed for every reported issue. |
## Output format
Report findings as:
```text
[FILE:LINE] ISSUE_TITLE
Severity: Critical | Major | Minor | Informational
Description of the issue and why it matters.
```
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| async/await, actors, Sendable, Task | [references/concurrency.md](references/concurrency.md) |
| @Observable, @ObservationIgnored, @Bindable | [references/observable.md](references/observable.md) |
| throws, Result, try?, typed throws | [references/error-handling.md](references/error-handling.md) |
| Force unwraps, retain cycles, naming | [references/common-mistakes.md](references/common-mistakes.md) |
## Review Checklist
- [ ] No force unwraps (`!`) on runtime data (network, user input, files)
- [ ] Closures stored as properties use `[weak self]`
- [ ] Delegate properties are `weak`
- [ ] Independent async operations use `async let` or `TaskGroup`
- [ ] Long-running Tasks check `Task.isCancelled`
- [ ] Actors have mutable state to protect (no stateless actors)
- [ ] Sendable types are truly thread-safe (beware `@unchecked`)
- [ ] Errors handled explicitly (no empty catch blocks)
- [ ] Custom errors conform to `LocalizedError` with descriptive messages
- [ ] Nested @Observable objects are also marked @Observable
- [ ] @Bindable used for two-way bindings to Observable objects
## When to Load References
- Reviewing async/await, actors, or TaskGroups → concurrency.md
- Reviewing @Observable or SwiftUI state → observable.md
- Reviewing error handling or throws → error-handling.md
- General Swift review → common-mistakes.md
## Review Questions
1. Are async operations that could run concurrently using `async let`?
2. Could actor state change across suspension points (reentrancy bug)?
3. Is `@unchecked Sendable` backed by actual synchronization?
4. Are errors logged and presented with helpful context?
5. Could any closure or delegate create a retain cycle?
FILE:references/common-mistakes.md
# Swift Common Mistakes
## Critical Anti-Patterns
### 1. Force Unwrapping Runtime Data
```swift
// BAD - crashes on invalid input
let url = URL(string: userProvidedString)!
let first = response.items.first!
let value = dictionary["key"]!
// GOOD - safe unwrapping
guard let url = URL(string: userProvidedString) else {
showError("Invalid URL")
return
}
let first = response.items.first ?? defaultItem
let value = dictionary["key", default: fallback]
// ACCEPTABLE force unwrap - compile-time verifiable
let url = URL(string: "https://apple.com")!
let image = UIImage(named: "AppIcon")!
```
### 2. Retain Cycles in Closures
```swift
// BAD - closure captures self strongly
class ViewController {
var onComplete: (() -> Void)?
func setup() {
onComplete = { self.updateUI() } // Retain cycle!
}
}
// GOOD - weak capture
onComplete = { [weak self] in
guard let self else { return }
self.updateUI()
}
```
### 3. Delegate Without Weak
```swift
// BAD - strong delegate causes cycle
var delegate: SomeDelegate?
// GOOD - delegates are always weak
weak var delegate: SomeDelegate?
```
### 4. Nil Check Then Force Unwrap
```swift
// BAD - dangerous pattern
if optionalString != nil {
print(optionalString!.count)
}
// GOOD - optional binding
if let string = optionalString {
print(string.count)
}
// Swift 5.7+ shorthand
if let optionalString {
print(optionalString.count)
}
```
### 5. Unnecessary Optionals
```swift
// BAD - always has value but declared optional
struct Person {
let name: String? // Set in init, never nil
init(name: String) { self.name = name }
}
// GOOD - non-optional when always present
struct Person {
let name: String
init(name: String) { self.name = name }
}
```
### 6. Unchecked Array Access
```swift
// BAD - crashes if out of bounds
let item = items[index]
let first = items[0] // Crashes if empty
// GOOD - bounds checking
if items.indices.contains(index) {
let item = items[index]
}
let first = items.first // Returns optional
// Safe subscript extension
extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
```
### 7. Implicitly Unwrapped Optionals
```swift
// BAD - IUO for regular properties
class UserProfile {
var name: String!
var avatar: UIImage! // Never set - crash!
}
// GOOD - proper optionals or non-optionals
class UserProfile {
let name: String
var avatar: UIImage? // Truly optional
init(name: String) { self.name = name }
}
// ACCEPTABLE IUO - @IBOutlet only
@IBOutlet weak var titleLabel: UILabel!
```
## Naming Conventions
```swift
// BAD naming
func chkPwd(_ p: String) -> Bool // Unclear abbreviation
let stringName: String // Type in name
var enabled: Bool // Missing is/has prefix
func sort() -> [Int] // Reads like mutation
// GOOD naming
func checkPassword(_ password: String) -> Bool
let userName: String // Name by role
var isEnabled: Bool // Predicate prefix
func sorted() -> [Int] // Non-mutating returns new value
mutating func sort() // Mutating is imperative verb
```
## Best Practices Summary
| Topic | Best Practice |
|-------|---------------|
| Force Unwrap | Only for compile-time verifiable constants |
| Retain Cycles | `weak` delegates, `[weak self]` in closures |
| Optionals | Use binding, not nil-check + force-unwrap |
| Collections | Use `.first`, `.last`, or safe subscript |
| IUOs | Only for @IBOutlet |
## Review Questions
1. Is this force unwrap (`!`) backed by compile-time certainty?
2. Could this closure stored as a property cause a retain cycle?
3. Are delegate properties marked `weak`?
4. Is this optional really necessary, or is it always set?
5. Is collection access bounds-checked?
FILE:references/concurrency.md
# Swift Concurrency
## Critical Anti-Patterns
### 1. Sequential Execution When Concurrent Is Possible
```swift
// BAD - sequential awaits on independent operations
let user = await fetchUser()
let avatar = await fetchAvatar(for: user.id) // Waits unnecessarily
let prefs = await fetchPreferences(for: user.id) // Waits unnecessarily
// GOOD - async let for independent operations
let user = await fetchUser()
async let avatar = fetchAvatar(for: user.id)
async let prefs = fetchPreferences(for: user.id)
return Profile(user: user, avatar: try await avatar, prefs: try await prefs)
```
### 2. Memory Leaks from Task Self-Capture
```swift
// BAD - self captured indefinitely in async sequence
Task {
for await notification in stream {
handleNotification(notification) // implicit self
}
}
// GOOD - weak self with guard inside the loop
Task { [weak self] in
for await notification in stream {
guard let self else { return }
self.handleNotification(notification)
}
}
```
### 3. Actor Reentrancy Bugs
```swift
// BAD - state check before await, mutation after
actor BankAccount {
var balance: Double = 1000
func withdraw(_ amount: Double) async -> Bool {
guard balance >= amount else { return false }
await recordTransaction(amount) // state can change here!
balance -= amount // may go negative
return true
}
}
// GOOD - mutate state before await
func withdraw(_ amount: Double) async -> Bool {
guard balance >= amount else { return false }
balance -= amount // safe - done before suspension
await recordTransaction(amount)
return true
}
```
### 4. Stateless Actors
```swift
// BAD - actor with nothing to protect
actor NetworkService {
func fetchData(from url: URL) async throws -> Data {
try await URLSession.shared.data(from: url).0
}
}
// GOOD - use enum or struct for stateless operations
enum NetworkService {
static func fetchData(from url: URL) async throws -> Data {
try await URLSession.shared.data(from: url).0
}
}
```
### 5. Ignoring Task Cancellation
```swift
// BAD - long loop ignores cancellation
for item in items {
await process(item)
}
// GOOD - check cancellation
for item in items {
try Task.checkCancellation()
await process(item)
}
```
### 6. Non-Sendable Types Across Actors
```swift
// BAD - mutable class crossing boundaries
class Session { var token: String? }
// GOOD - immutable struct
struct Session: Sendable { let token: String }
// GOOD - @unchecked with actual lock
final class Session: @unchecked Sendable {
private let lock = NSLock()
private var _token: String?
var token: String? {
get { lock.withLock { _token } }
set { lock.withLock { _token = newValue } }
}
}
```
### 7. Errors Silently Ignored in Tasks
```swift
// BAD - error lost
Task { try await database.save(data) }
// GOOD - handle explicitly
Task {
do { try await database.save(data) }
catch { logger.error("Save failed: \(error)") }
}
```
## Best Practices
- **Use `async let`** for 2-3 independent operations
- **Use `TaskGroup`** for dynamic number of concurrent tasks with result aggregation
- **Apply `@MainActor` at type level** for ViewModels, not scattered `MainActor.run`
- **Use `.task` modifier** in SwiftUI instead of `Task` in `onAppear`
- **Use `nonisolated`** for pure functions in @MainActor types
- **Limit TaskGroup concurrency** with iterator pattern for large workloads
## Review Questions
1. Are independent async operations running concurrently?
2. Could actor state change across suspension points (reentrancy)?
3. Is `@unchecked Sendable` backed by actual synchronization?
4. Are long-running Tasks checking `Task.isCancelled`?
5. Are errors in Task closures being handled or silently lost?
FILE:references/error-handling.md
# Swift Error Handling
## Critical Anti-Patterns
### 1. Force Try in Production Code
```swift
// BAD - crashes if file missing or JSON invalid
let data = try! Data(contentsOf: configURL)
let config = try! JSONDecoder().decode(Config.self, from: data)
// GOOD - handle failures
do {
let data = try Data(contentsOf: configURL)
let config = try JSONDecoder().decode(Config.self, from: data)
} catch {
logger.error("Failed to load config: \(error)")
return Config.default
}
```
### 2. Silencing Errors Without Logging
```swift
// BAD - error context lost
let user = try? fetchUser(id: userId)
if user == nil { showError("Something went wrong") }
// GOOD - log the actual error
do {
let user = try fetchUser(id: userId)
display(user)
} catch {
logger.error("Failed to fetch user \(userId): \(error)")
showError(error.localizedDescription)
}
```
### 3. Empty Catch Blocks
```swift
// BAD - user thinks save succeeded
do { try saveDocument() } catch { }
// GOOD - inform user of failure
do {
try saveDocument()
showSuccess("Document saved")
} catch {
showError("Failed to save: \(error.localizedDescription)")
}
```
### 4. Generic Error Messages
```swift
// BAD - cryptic error display
enum NetworkError: Error {
case requestFailed
}
// GOOD - LocalizedError with descriptions
enum NetworkError: LocalizedError {
case requestFailed(statusCode: Int)
var errorDescription: String? {
switch self {
case .requestFailed(let code):
return "Request failed with status \(code)"
}
}
}
```
### 5. Losing Error Context When Wrapping
```swift
// BAD - original error lost
catch { throw ProfileError.loadFailed }
// GOOD - preserve underlying error
enum ProfileError: LocalizedError {
case networkFailed(underlying: Error)
var errorDescription: String? {
switch self {
case .networkFailed(let error):
return "Network error: \(error.localizedDescription)"
}
}
}
```
### 6. Completion Handler Not Called on All Paths
```swift
// BAD - completion never called on guard failure
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
guard let url = buildURL() else { return } // Bug!
// ...
}
// GOOD - always call completion
guard let url = buildURL() else {
completion(.failure(NetworkError.invalidURL))
return
}
```
## try, try?, try! Guidelines
| Variant | Use When |
|---------|----------|
| `try` | Need to handle specific errors with recovery logic |
| `try?` | Error details unimportant, just need success/failure |
| `try!` | Compile-time certainty only: hardcoded URLs, bundled assets |
```swift
// Acceptable try!
let url = URL(string: "https://api.example.com")! // Hardcoded, verified
// Never try! with runtime data
let url = URL(string: userInput)! // CRASH RISK
```
## Swift 6 Typed Throws
```swift
// Typed throws - compiler enforces error type
func readFile(at path: String) throws(FileError) -> Data {
guard fileExists(path) else { throw .notFound }
// ...
}
// Benefits: self-documenting API, shorthand .case syntax
// Avoid for: public APIs (locks you into error contract)
```
## Result Type vs throws
| Use `throws` | Use `Result` |
|--------------|--------------|
| Synchronous code | Completion handlers |
| async/await code | Storing error state |
| Complex recovery | Delaying handling |
## Review Questions
1. Are all `try!` usages backed by compile-time certainty?
2. Are errors logged with enough context to diagnose issues?
3. Do custom errors conform to `LocalizedError`?
4. Are completion handlers called on every code path?
5. Is the underlying error preserved when wrapping?
FILE:references/observable.md
# Swift Observation Framework
## Critical Anti-Patterns
### 1. Incorrect @State Initialization
```swift
// BAD - model recreated on view reconstruction
struct ContentView: View {
@State private var viewModel = ExpensiveViewModel() // init() runs repeatedly
}
// GOOD - initialize at App level
@main
struct MyApp: App {
@State private var viewModel = ExpensiveViewModel()
var body: some Scene {
WindowGroup { ContentView(viewModel: viewModel) }
}
}
```
### 2. Using @State in Child Views
```swift
// BAD - child ignores parent's instance changes
struct ChildView: View {
@State var model: Model // Wrong! Preserves first instance
var body: some View { Text(model.name) }
}
// GOOD - child receives model directly
struct ChildView: View {
var model: Model // No wrapper - updates when parent changes
var body: some View { Text(model.name) }
}
```
### 3. Missing @Bindable for Two-Way Bindings
```swift
// BAD - cannot create binding
struct EditView: View {
var user: User
var body: some View {
TextField("Name", text: $user.name) // Error: cannot find '$user'
}
}
// GOOD - use @Bindable
struct EditView: View {
@Bindable var user: User
var body: some View {
TextField("Name", text: $user.name) // Works
}
}
```
### 4. Property Wrappers Without @ObservationIgnored
```swift
// BAD - property wrapper conflicts with @Observable
@Observable class ViewModel {
@Injected var repository: Repository // Error
}
// GOOD - exclude from observation
@Observable class ViewModel {
@ObservationIgnored
@Injected var repository: Repository
}
```
### 5. Nested Objects Not Observable
```swift
// BAD - nested object changes don't trigger updates
@Observable class Store {
var items: [Item] = [] // Item is regular class
}
class Item { var name: String = "" }
// GOOD - nested types also @Observable
@Observable class Store {
var items: [Item] = []
}
@Observable class Item { var name: String = "" }
```
### 6. Combining @Environment with @Bindable
```swift
// BAD - cannot combine property wrappers
struct SettingsView: View {
@Bindable @Environment(AppSettings.self) var settings // Error
}
// GOOD - create local @Bindable
struct SettingsView: View {
@Environment(AppSettings.self) var settings
var body: some View {
@Bindable var settings = settings
Toggle("Dark Mode", isOn: $settings.darkMode)
}
}
```
### 7. withObservationTracking willSet Semantics
```swift
// BAD - onChange gets OLD value
withObservationTracking {
_ = model.name
} onChange: {
print(model.name) // Prints old value!
}
// GOOD - dispatch to get new value
withObservationTracking {
_ = model.name
} onChange: {
DispatchQueue.main.async {
print(model.name) // Now has new value
}
}
```
## Migration from ObservableObject
| Before (Combine) | After (Observation) |
|------------------|---------------------|
| `class: ObservableObject` | `@Observable class` |
| `@Published var` | `var` (automatic) |
| `@StateObject` | `@State` |
| `@ObservedObject` | Direct property or `@Bindable` |
| `@EnvironmentObject` | `@Environment(Type.self)` |
## When to Use Each Wrapper
| Wrapper | Use Case |
|---------|----------|
| `@State` | View owns/creates the observable |
| `@Bindable` | Need two-way binding to properties |
| `@Environment` | Access observable from environment |
| `@ObservationIgnored` | Exclude from tracking (DI, Combine, timers) |
## Review Questions
1. Is `@State` only used in the view that creates the object?
2. Are nested observable objects also marked `@Observable`?
3. Is `@Bindable` used when two-way bindings are needed?
4. Are property wrappers marked with `@ObservationIgnored`?
5. Does `init()` have expensive side effects that could repeat?
Mandatory verification steps for all code reviews to reduce false positives. Load this skill before reporting ANY code review findings.
---
name: review-verification-protocol
description: Mandatory verification steps for all code reviews to reduce false positives. Load this skill before reporting ANY code review findings.
user-invocable: false
---
# Review Verification Protocol
This protocol MUST be followed before reporting any code review finding. Skipping these steps leads to false positives that waste developer time and erode trust in reviews.
## Hard gates (sequenced)
Complete these **in order** before you add a finding. Skip a gate only when it clearly does not apply (e.g. skip the usages gate if the finding is not about dead code or “unused”).
1. **Read scope** — **Pass:** You name the exact file path(s) and the function, `impl`, or `macro_rules!` block you read in full (not only a diff hunk or partial snippet).
2. **Usages (dead / unused)** — **Pass:** You ran a repo-wide reference search (`rg`, IDE references, or equivalent) and either state zero matches for the symbol you call unused, or list each match and why it still supports the finding.
3. **Surrounding behavior** — **Pass:** You checked callers, trait impls, `#[cfg]`, or error propagation that could make the pattern intentional; note one concrete checked location (path + rough location) or state “none relevant after search.”
4. **Edition and API** — **Pass:** You opened the relevant `Cargo.toml` for the crate under review and either quote the `[package] edition = "..."` line or state the default edition applies and name the manifest path you checked.
5. **Wrong vs style** — **Pass:** In one sentence, you explain why the code is incorrect, unsound, or risky for this project—not merely a different valid style.
## Pre-Report Verification Checklist
Before flagging ANY issue, verify:
- [ ] **I read the actual code** - Not just the diff context, but the full function/impl block
- [ ] **I searched for usages** - Before claiming "unused", searched all references
- [ ] **I checked surrounding code** - The issue may be handled elsewhere (trait impls, error propagation)
- [ ] **I verified syntax against current docs** - Rust edition, crate versions, and API changes
- [ ] **I checked the project's Rust edition** - Edition 2021 vs 2024 changes what is required vs optional (see [Edition-Aware Review](#edition-aware-review))
- [ ] **I distinguished "wrong" from "different style"** - Both approaches may be valid
- [ ] **I considered intentional design** - Checked comments, CLAUDE.md, architectural context
## Verification by Issue Type
### "Unused Variable/Function"
**Before flagging**, you MUST:
1. Search for ALL references in the codebase (grep/find)
2. Check if it's `pub` and used by other crates in the workspace
3. Check if it's used via derive macros, trait implementations, or conditional compilation (`#[cfg]`)
4. Verify it's not a trait method required by the trait definition
**Common false positives:**
- Trait implementations where the method is defined by the trait
- `#[cfg(test)]` items only used in test builds
- Derive-generated code that uses struct fields
- Types used via `From`/`Into` conversions
### "Missing Error Handling"
**Before flagging**, you MUST:
1. Check if the error is handled at a higher level (caller propagates with `?`)
2. Check if the crate has a top-level error type that wraps this error
3. Verify the `unwrap()` isn't in test code or after a safety-ensuring check
**Common false positives:**
- `unwrap()` in tests and examples (expected pattern)
- `expect("reason")` after validation (e.g., `regex::Regex::new` on a literal)
- Error propagation via `?` (the caller handles it)
- `let _ = tx.send(...)` — intentional when receiver may have dropped
### "Unnecessary Lifetime" / RPIT Capture (Edition 2024)
**Before flagging**, you MUST:
1. Check the project's Rust edition in `Cargo.toml`
2. In edition 2024, `-> impl Trait` captures ALL in-scope lifetimes by default
3. A lifetime that appears "unnecessary" may be implicitly captured — the code is correct
4. If the author uses `+ use<'a>` syntax, this is precise capture control, not a mistake
**Common false positives:**
- Lifetime parameters on functions returning `impl Trait` — edition 2024 captures them implicitly
- `+ use<'a, T>` syntax — this is the new precise capturing syntax, not an error
- Removing an explicit lifetime bound that edition 2024 now provides automatically
### "Missing Unsafe Block" (Edition 2024)
**Before flagging**, you MUST:
1. Check if the code is inside an `unsafe fn`
2. In edition 2024, `unsafe_op_in_unsafe_fn` is deny-by-default — unsafe operations inside `unsafe fn` REQUIRE explicit `unsafe {}` blocks
3. This is edition-required behavior, not unnecessary verbosity
**Common false positives:**
- `unsafe {}` blocks inside `unsafe fn` — REQUIRED in edition 2024, not redundant
- `unsafe extern "C" {}` — REQUIRED in edition 2024, not optional
- `#[unsafe(no_mangle)]` / `#[unsafe(export_name)]` — REQUIRED in edition 2024
### "Unnecessary Clone"
**Before flagging**, you MUST:
1. Confirm the clone is actually avoidable (borrow checker may require it)
2. Check if the value needs to be moved into a closure/thread/task
3. Verify the type isn't `Copy` (clone on Copy types is a no-op)
4. Check if the clone is in a hot path (test/setup code cloning is fine)
**Common false positives:**
- `Arc::clone(&arc)` — this is the recommended explicit clone for Arc
- Clone before `tokio::spawn` — required for `'static` bound
- Clone in test setup — clarity over performance
### "Potential Race Condition"
**Before flagging**, you MUST:
1. Verify the data is actually shared across threads/tasks
2. Check if `Mutex`, `RwLock`, or atomic operations protect the access
3. Confirm the type doesn't already guarantee thread safety (e.g., `Arc<Mutex<T>>`)
4. Check if the "race" is actually benign (e.g., logging, metrics)
**Common false positives:**
- `Arc<Mutex<T>>` — already thread-safe
- Tokio channel operations — inherently synchronized
- `std::sync::atomic` operations — designed for concurrent access
### "Performance Issue"
**Before flagging**, you MUST:
1. Confirm the code runs frequently enough to matter
2. Verify the optimization would have measurable impact
3. Check if the compiler already optimizes this (iterator fusion, inlining)
**Do NOT flag:**
- Allocations in startup/initialization code
- String formatting in error paths
- Clone in test code
- `.collect()` on small iterators
## Severity Calibration
### Critical (Block Merge)
**ONLY use for:**
- `unsafe` code with unsound invariants
- SQL injection via string interpolation
- Use-after-free or memory safety violations
- Data races (concurrent mutation without synchronization)
- Panics in production code paths on user input
### Major (Should Fix)
**Use for:**
- Missing error context across module boundaries
- Blocking operations in async runtime
- Mutex guards held across await points
- Missing transaction for multi-statement database writes
### Minor (Consider Fixing)
**Use for:**
- Missing doc comments on public items
- `String` parameters where `&str` would work
- Suboptimal iterator patterns
- Missing `#[must_use]` on functions with important return values
### Informational (No Action Required)
**Use for:**
- Suggestions for newtypes, builder patterns, or type state
- Performance optimizations without measured impact
- Suggestions to add `#[non_exhaustive]`
- Refactoring ideas for trait design
**These are NOT review blockers.**
### Do NOT Flag At All
- Style preferences where both approaches are valid (e.g., `if let` vs `match` for single variant)
- Optimizations with no measurable benefit
- Test code not meeting production standards
- Generated code or macro output
- Clippy lints that the project has intentionally suppressed
## Valid Patterns (Do NOT Flag)
### Rust
| Pattern | Why It's Valid |
|---------|----------------|
| `unwrap()` in tests | Standard test behavior — panics on unexpected errors |
| `.clone()` in test setup | Clarity over performance |
| `use super::*` in test modules | Standard pattern for accessing parent items |
| `Box<dyn Error>` in binaries | Not every app needs custom error types |
| `String` fields in structs | Owned data is correct for struct fields |
| `Arc::clone(&x)` | Explicit Arc cloning is idiomatic and recommended |
| `#[allow(clippy::...)]` with reason | Intentional suppression is valid |
| `#[expect(lint)]` instead of `#[allow]` | Self-cleaning suppression (stable since 1.81) — warns when lint no longer triggers |
| `unsafe {}` inside `unsafe fn` | Required in edition 2024 (`unsafe_op_in_unsafe_fn` = deny) |
| `unsafe extern "C" {}` | Required in edition 2024 for extern blocks |
| `#[unsafe(no_mangle)]` | Required in edition 2024 for safety-relevant attributes |
| `#[unsafe(export_name = "...")]` | Required in edition 2024 for safety-relevant attributes |
| `+ use<'a, T>` on `impl Trait` returns | Precise capture syntax for edition 2024 RPIT |
| `r#gen` as identifier | `gen` is reserved in edition 2024 |
| `LazyLock` / `LazyCell` | Standard library replacements for `once_cell`/`lazy_static` (stable since 1.80) |
| `async fn` in trait definitions | No longer needs `async-trait` crate (stable since 1.75) |
| `#[diagnostic::on_unimplemented]` | Custom trait error messages (stable since 1.78) |
### Async/Tokio
| Pattern | Why It's Valid |
|---------|----------------|
| `std::sync::Mutex` for short critical sections | Tokio docs recommend this for non-async locks |
| `tokio::spawn` without join | Valid for background tasks with shutdown signaling |
| `select!` with `default` branch | Non-blocking check, intentional pattern |
| `#[tokio::test]` without multi_thread | Default single-thread is fine for most tests |
### Testing
| Pattern | Why It's Valid |
|---------|----------------|
| `expect()` in tests | Acceptable for test setup/assertions |
| `#[should_panic]` with `expected` | Valid for testing panic behavior |
| Large test functions | Integration tests can be long |
| `let _ = ...` in test cleanup | Cleanup errors are often unactionable |
### General
| Pattern | Why It's Valid |
|---------|----------------|
| `todo!()` in new code | Valid placeholder during development |
| `#[allow(dead_code)]` during development | Common during iteration |
| Multiple `impl` blocks for one type | Organized by trait or concern |
| Type aliases for complex types | Reduces boilerplate, improves readability |
## Context-Sensitive Rules
### Ownership
Flag unnecessary `.clone()` **ONLY IF**:
- [ ] In a hot path (not test/setup code)
- [ ] A borrow or reference would work
- [ ] The clone is not required for `Send`/`'static` bounds
- [ ] The type is not `Copy`
### Error Handling
Flag missing error context **ONLY IF**:
- [ ] Error crosses a module boundary
- [ ] The error type doesn't already carry context (thiserror messages)
- [ ] Not in test code
- [ ] The bare `?` loses meaningful information about what operation failed
### Unsafe Code
Flag unsafe **ONLY IF**:
- [ ] Safety comment is missing or doesn't explain the invariant
- [ ] The unsafe block is broader than necessary
- [ ] The invariant is not actually upheld by surrounding code
- [ ] A safe alternative exists with equivalent performance
**Edition 2024 unsafe changes** — check `Cargo.toml` edition before flagging:
- `unsafe {}` inside `unsafe fn` is **required** (not style) in edition 2024
- `unsafe extern "C" {}` is **required** in edition 2024 — bare `extern "C" {}` is a compile error
- `#[unsafe(no_mangle)]` and `#[unsafe(export_name)]` are **required** in edition 2024
- In edition 2021, these patterns are optional style choices — do not require them
## Edition-Aware Review
**BEFORE flagging any edition-specific pattern**, check `Cargo.toml` for the project's edition:
```toml
[package]
edition = "2024" # or "2021", "2018"
```
Edition 2024 changes that affect review findings:
| Change | Edition 2021 | Edition 2024 |
|--------|--------------|--------------|
| `unsafe` inside `unsafe fn` | Optional style | Required (`unsafe_op_in_unsafe_fn` = deny) |
| `extern "C" {}` | Valid | Must be `unsafe extern "C" {}` |
| `#[no_mangle]` | Valid | Must be `#[unsafe(no_mangle)]` |
| `#[export_name]` | Valid | Must be `#[unsafe(export_name)]` |
| `-> impl Trait` lifetime capture | Explicit only | Captures all in-scope lifetimes |
| `gen` as identifier | Valid | Reserved keyword (use `r#gen`) |
| `!` type fallback | Falls back to `()` | Falls back to `!` |
| `if let` temporaries | Dropped at end of block | Dropped earlier (end of `if let`) |
| Tail expression temporaries | Dropped after locals | Dropped before local variables |
| `Box<[T]>` iteration | Needs explicit `.iter()` | Has `IntoIterator` impl |
**If edition is not specified**, Rust defaults to edition 2015. Most modern projects use 2021 or later.
**Cross-reference**: The `beagle-rust:rust-code-review` and `beagle-rust:rust-best-practices` skills provide edition-specific code review guidance and idiomatic patterns.
## Macro-Specific Verification
### "Macro Hygiene Issue"
**Before flagging**, you MUST:
1. Verify the identifier actually leaks — types, modules, and functions are NOT hygienic in `macro_rules!`
2. Check if `$crate` is used correctly for exported macros (not `crate` or `self`)
3. Confirm `::core::` / `::alloc::` paths are needed (only for macros used in no_std contexts)
4. Check whether the macro is internal-only or `#[macro_export]`
**Common false positives:**
- Non-hygienic type names in internal macros — only matters for exported macros
- `$crate` not used in macros that are only `pub(crate)` — `$crate` is for cross-crate usage
- Using `::std::` in macros for std-only crates — only flag if crate supports no_std
### "Procedural Macro Performance"
**Before flagging**, you MUST:
1. Verify the macro is actually in a proc-macro crate (check `Cargo.toml` for `proc-macro = true`)
2. Check if `syn` features are minimized (full `syn` with `"full"` feature vs selective features)
3. Confirm compile-time impact is meaningful (proc macros used across many files vs one-off)
### "Wrong Fragment Type"
**Before flagging**, you MUST:
1. Verify the suggested fragment type actually works in that position
2. Check if `:tt` is intentionally used for flexibility (common in TT munching patterns)
3. Confirm `:expr` greediness issues actually manifest (test with the macro's actual call sites)
## FFI-Specific Verification
### "Missing repr(C)"
**Before flagging**, you MUST:
1. Confirm the type actually crosses the FFI boundary (passed to/from C code)
2. Check if the type is only used on the Rust side of the FFI wrapper
3. Verify there isn't a `#[repr(transparent)]` wrapper instead
**Common false positives:**
- Internal Rust types that are converted before FFI call — only the FFI-facing type needs `repr(C)`
- Types used with `repr(transparent)` newtype wrappers — the wrapper handles layout
- Opaque pointer types (`*mut c_void`) — no layout guarantee needed
### "FFI Safety"
**Before flagging**, you MUST:
1. Check if the unsafe FFI call has a SAFETY comment documenting invariants
2. Verify ownership transfer is actually ambiguous (check for `Box::into_raw`/`Box::from_raw` pairs)
3. Confirm CString lifetime issues are real (the CString must outlive the pointer passed to C)
4. Check if callback unwinding is actually possible (pure data functions can't panic across FFI)
**Common false positives:**
- `extern "C" fn` callbacks that never panic — `catch_unwind` not needed
- `*const c_char` from CStr::as_ptr() held within the same scope — lifetime is fine
- Bindgen-generated code with `unsafe` — bindgen output is inherently unsafe-heavy by design
## Concurrency-Specific Verification
### "Memory Ordering Too Weak"
**Before flagging**, you MUST:
1. Verify the atomic is actually shared between threads that need synchronization
2. Check if `Relaxed` is sufficient (counters, flags with no dependent data)
3. Confirm `Acquire/Release` vs `SeqCst` choice matters (most code doesn't need SeqCst)
**Common false positives:**
- `Relaxed` on simple counters/metrics — no ordering needed for independent values
- `Relaxed` on boolean flags polled in a loop — the loop provides eventual visibility
- `SeqCst` used "for safety" — not wrong, just potentially over-synchronized
## Before Submitting Review
**Submission gate** — **Pass:** Every finding uses `[FILE:LINE] ISSUE_TITLE` and includes the exact line (or minimal contiguous lines) that demonstrates the issue, so a reader can jump to the proof without trusting memory.
Final verification:
1. Re-read each finding and ask: "Did I verify this is actually an issue?"
2. For each finding, can you point to the specific line that proves the issue exists?
3. Would a Rust domain expert agree this is a problem, or is it a style preference?
4. Does fixing this provide real value, or is it busywork?
5. Format every finding as: `[FILE:LINE] ISSUE_TITLE`
6. For each finding, ask: "Does this fix existing code, or does it request entirely new code that didn't exist before?" If the latter, downgrade to Informational.
7. If this is a re-review: ONLY verify previous fixes. Do not introduce new findings.
If uncertain about any finding, either:
- Remove it from the review
- Mark it as a question rather than an issue
- Verify by reading more code context
Write Swift animation code using Apple's latest frameworks — SwiftUI animations, Core Animation, and UIKit. Prefer first-party APIs over third-party librarie...
---
name: ios-animation-implementation
description: Write Swift animation code using Apple's latest frameworks — SwiftUI animations, Core Animation, and UIKit. Prefer first-party APIs over third-party libraries. Use when implementing iOS animations, writing animation code, building transitions, creating gesture-driven interactions, or converting animation specs/designs into working Swift code. Covers iOS 18 through iOS 26 APIs including KeyframeAnimator, PhaseAnimator, custom Transition protocol, zoom navigation transitions, matchedGeometryEffect, symbol effects, mesh gradients, and SwiftUI-UIKit animation bridging.
---
# iOS Animation Implementation
Write animation code that uses Apple's frameworks directly. Third-party animation libraries add dependency risk and often lag behind new OS releases — Apple's APIs are well-optimized for the render pipeline and get free improvements with each iOS version.
## Before Writing Custom Animation
Check whether the system already handles the motion you need. Apple's HIG: "Many system components automatically include motion, letting you offer familiar and consistent experiences throughout your app." System components also automatically adjust for accessibility settings and input methods — Liquid Glass (iOS 26) responds with greater emphasis to direct touch and produces subdued effects for trackpad. Custom animation can't match this adaptiveness for free, so prefer system-provided motion when it exists.
**Skip custom animation when:**
- Standard navigation transitions cover your case (push, pop, sheet, fullScreenCover)
- SF Symbol `.symbolEffect` provides the feedback you need
- `.contentTransition(.numericText)` handles your data change
- The system's default spring on `withAnimation` is sufficient
**Write custom animation when:**
- The system doesn't provide the spatial relationship you need (hero transitions, custom gestures)
- You need coordinated multi-property choreography
- The animation is a signature moment that defines the app's identity
- Gesture-driven interaction requires custom progress mapping
## API Selection
Choose the right API for the job. Start with SwiftUI animations (simplest, most declarative), drop to UIKit when you need interactive control, and reach for Core Animation only when you need layer-level precision.
| Need | API | Why |
|------|-----|-----|
| State-driven property changes | `withAnimation` / `.animation(_:value:)` | Declarative, automatic interpolation |
| Multi-step sequenced animation | `PhaseAnimator` | Discrete phases with per-phase timing |
| Per-property timeline control | `KeyframeAnimator` | Independent keyframe tracks per property |
| Hero transitions between views | `matchedGeometryEffect` + `Namespace` | Geometry matching across view identity |
| Navigation push/pop with zoom | `.navigationTransition(.zoom)` | iOS 18+ built-in zoom transition |
| Custom view insertion/removal | `Transition` protocol conformance | `TransitionPhase`-based modifier |
| In-view content swap | `.contentTransition()` | Numeric text, interpolation, opacity |
| Scroll-position-based effects | `.scrollTransition` | Phase-driven scroll-linked animation |
| SF Symbol animation | `.symbolEffect()` | Bounce, pulse, wiggle, breathe, rotate |
| Interactive/interruptible (UIKit) | `UIViewPropertyAnimator` | Pause, resume, reverse, scrub |
| Per-layer property animation | `CABasicAnimation` / `CASpringAnimation` | Shadow, border, cornerRadius animation |
| Complex choreography (layers) | `CAKeyframeAnimation` + `CAAnimationGroup` | Multi-property layer animation |
| Physics simulation | `UIDynamicAnimator` | Gravity, collision, snap, attachment |
| Haptic feedback paired with animation | `.sensoryFeedback` modifier | Tied to value changes |
| Animated background gradients | `MeshGradient` | 2D grid of positioned, animated colors |
## Implementation by Category
Detailed patterns and code examples live in the reference files. Load the one that matches your task:
| Task | Reference |
|------|-----------|
| SwiftUI declarative animations (withAnimation, springs, phase, keyframe) | [references/swiftui-animations.md](references/swiftui-animations.md) |
| View transitions (navigation, modal, custom Transition protocol) | [references/transitions.md](references/transitions.md) |
| Gesture-driven interactive animations | [references/gesture-animations.md](references/gesture-animations.md) |
| Core Animation and UIKit animation patterns | [references/core-animation.md](references/core-animation.md) |
## When to Load References
- Writing `withAnimation`, spring parameters, `PhaseAnimator`, or `KeyframeAnimator` → swiftui-animations.md
- Building navigation transitions, modal presentations, `matchedGeometryEffect`, or custom `Transition` → transitions.md
- Implementing drag-to-dismiss, swipe actions, pinch/rotate, or scroll-linked effects → gesture-animations.md
- Working with `CABasicAnimation`, `UIViewPropertyAnimator`, layer animations, or bridging SwiftUI↔UIKit → core-animation.md
## Spring Parameters Quick Reference
Springs are the default animation type in modern SwiftUI. Use `duration` and `bounce` — not mass/stiffness/damping unless bridging to UIKit/CA.
| Preset | Duration | Bounce | Use Case |
|--------|----------|--------|----------|
| `.smooth` | 0.5 | 0.0 | Default transitions, most state changes |
| `.snappy` | 0.3 | 0.15 | Micro-interactions, toggles, quick feedback |
| `.bouncy` | 0.5 | 0.3 | Playful moments, attention-drawing |
| `.interactiveSpring` | 0.15 | 0.0 | Gesture tracking, drag following |
| Custom | varies | varies | `.spring(duration: 0.4, bounce: 0.2)` |
## Accessibility & Multimodal Feedback
Apple's HIG: "Make motion optional" and "supplement visual feedback by also using alternatives like haptics and audio to communicate." Every animation must handle Reduce Motion, and important state changes should use multiple feedback channels — not animation alone.
```swift
@Environment(\.accessibilityReduceMotion) private var reduceMotion
// Pattern 1: Conditional animation
withAnimation(reduceMotion ? .none : .spring()) {
isExpanded.toggle()
}
// Pattern 2: Simplified alternative
.animation(reduceMotion ? .easeOut(duration: 0.15) : .spring(duration: 0.5, bounce: 0.3), value: isActive)
// Pattern 3: Skip entirely
if !reduceMotion {
view.phaseAnimator(phases) { /* ... */ }
}
```
Reduce Motion fallback options (from most to least graceful):
1. **Crossfade** — replace motion with opacity transition
2. **Shortened** — same animation, much faster (0.1–0.15s), no bounce
3. **Instant** — `.animation(.none)` or skip the animation block entirely
## Cancellation & Interruptibility
Apple's HIG: "Don't make people wait for an animation to complete before they can do anything, especially if they have to experience the animation more than once." Every animation must be interruptible.
- Spring animations retarget automatically — this is the default and almost always what you want
- For gesture-driven animations, the user is always in control — let them cancel mid-flight
- For sequenced animations (KeyframeAnimator, PhaseAnimator with trigger), ensure the UI remains interactive during playback
- Never disable user interaction during an animation unless there's a critical reason (e.g., destructive action confirmation)
## Performance Checklist
- Animate on the render server when possible — Core Animation runs off the main thread, SwiftUI's `drawingGroup()` moves rendering to Metal
- Avoid animating view identity changes (`.id()` modifier) — this destroys and recreates the view
- Use `geometryGroup()` when parent geometry changes cause child layout anomalies during animation
- Provide explicit `shadowPath` when animating shadows — without it, the system recalculates the path every frame
- In lists and scroll views, avoid per-item blur/shadow animations — these cause offscreen rendering for each cell
- Keep `PhaseAnimator` and looping animations lightweight — they run continuously
- For frequent interactions, prefer system-provided animation over custom motion — Apple's HIG: "generally avoid adding motion to UI interactions that occur frequently"
- Profile with Instruments → "Animation Hitches" template to find frame drops
## Gates (before marking work complete)
Run in order; satisfy each **Pass** before treating the task as done.
1. **API fit** — Choose the mechanism from the API Selection table for this task. **Pass:** You can state in one sentence which “Need” row and API you applied (not a different layer “just because”).
2. **Reduce Motion** — Custom timing must follow Accessibility & Multimodal Feedback. **Pass:** Non-trivial motion branches on `accessibilityReduceMotion` (or you only used system defaults / no custom timing).
3. **Interruptibility** — Matches Cancellation & Interruptibility. **Pass:** No blanket `allowsHitTesting(false)` for the whole animation unless the task explicitly requires it and a short comment says why.
4. **Heavy or continuous motion** — Loops, `PhaseAnimator` always-on, per-cell blur/shadow, or other Performance Checklist red flags. **Pass:** You ran Instruments (Animation Hitches) and captured a note or path to the trace, **or** you simplified the pattern first and can point to the change.
FILE:references/core-animation.md
# Core Animation & UIKit
## When to Use Core Animation
Drop to Core Animation when you need:
- Layer property animation (shadow, border, cornerRadius) that SwiftUI doesn't directly expose
- Precise timing control synchronized with external events
- Animation on non-UIView layers (CAShapeLayer, CAGradientLayer, CAEmitterLayer)
- Complex layer hierarchies with independent animation timelines
## CABasicAnimation
Single-value interpolation from `fromValue` to `toValue`.
```swift
let animation = CABasicAnimation(keyPath: "shadowOpacity")
animation.fromValue = 0
animation.toValue = 0.5
animation.duration = 0.3
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
layer.add(animation, forKey: "shadowFadeIn")
// Set the model value to match the animation end state
layer.shadowOpacity = 0.5
```
Always set the model value to the final state. Without this, the layer snaps back when the animation completes and is removed.
## CASpringAnimation
Spring physics at the layer level. Subclass of `CABasicAnimation`.
```swift
let spring = CASpringAnimation(keyPath: "transform.scale")
spring.fromValue = 0.8
spring.toValue = 1.0
spring.mass = 1.0
spring.stiffness = 200
spring.damping = 15
spring.initialVelocity = 5
spring.duration = spring.settlingDuration // let the spring calculate its own duration
layer.add(spring, forKey: "scaleSpring")
layer.transform = CATransform3DIdentity
```
Use `settlingDuration` as the `duration` — it calculates how long the spring needs based on its parameters.
## CAKeyframeAnimation
Multi-value animation with per-segment timing control.
```swift
let keyframe = CAKeyframeAnimation(keyPath: "position")
keyframe.values = [
CGPoint(x: 100, y: 100),
CGPoint(x: 200, y: 50),
CGPoint(x: 300, y: 100)
]
keyframe.keyTimes = [0, 0.4, 1.0] // timing as fraction of duration
keyframe.timingFunctions = [
CAMediaTimingFunction(name: .easeOut),
CAMediaTimingFunction(name: .easeIn)
]
keyframe.duration = 0.6
layer.add(keyframe, forKey: "pathAnimation")
layer.position = CGPoint(x: 300, y: 100)
```
### Path-Based Animation
Animate along a bezier path.
```swift
let pathAnimation = CAKeyframeAnimation(keyPath: "position")
let path = UIBezierPath()
path.move(to: startPoint)
path.addCurve(to: endPoint, controlPoint1: cp1, controlPoint2: cp2)
pathAnimation.path = path.cgPath
pathAnimation.duration = 0.5
pathAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
pathAnimation.rotationMode = .rotateAuto // orient along path
layer.add(pathAnimation, forKey: "curvedPath")
```
## CAAnimationGroup
Combine multiple animations with shared timing.
```swift
let group = CAAnimationGroup()
let scale = CABasicAnimation(keyPath: "transform.scale")
scale.fromValue = 1.0
scale.toValue = 1.2
let opacity = CABasicAnimation(keyPath: "opacity")
opacity.fromValue = 1.0
opacity.toValue = 0.0
group.animations = [scale, opacity]
group.duration = 0.3
group.fillMode = .forwards
group.isRemovedOnCompletion = false
layer.add(group, forKey: "scaleAndFade")
```
## CATransaction
Control implicit animation parameters or group explicit changes.
```swift
// Disable implicit animations
CATransaction.begin()
CATransaction.setDisableActions(true)
layer.position = newPosition
CATransaction.commit()
// Custom duration for implicit animations
CATransaction.begin()
CATransaction.setAnimationDuration(0.5)
CATransaction.setCompletionBlock {
print("Animation complete")
}
layer.opacity = 0
CATransaction.commit()
```
## CAShapeLayer Animation
Animate `strokeEnd` for drawing effects, `path` for morphing.
```swift
// Draw-on animation
let shapeLayer = CAShapeLayer()
shapeLayer.path = circlePath.cgPath
shapeLayer.strokeEnd = 0
shapeLayer.lineWidth = 3
shapeLayer.strokeColor = UIColor.blue.cgColor
shapeLayer.fillColor = nil
view.layer.addSublayer(shapeLayer)
let draw = CABasicAnimation(keyPath: "strokeEnd")
draw.fromValue = 0
draw.toValue = 1
draw.duration = 1.0
draw.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
shapeLayer.strokeEnd = 1
shapeLayer.add(draw, forKey: "drawCircle")
```
## UIView.animate
Block-based UIView animation. Simple and sufficient for most UIKit needs.
```swift
UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseOut]) {
view.alpha = 0
view.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
} completion: { _ in
view.removeFromSuperview()
}
// Spring
UIView.animate(
withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 0.7,
initialSpringVelocity: 0.5,
options: []
) {
view.center = targetPoint
}
```
## iOS 18: SwiftUI Animation in UIKit
Use SwiftUI `Animation` types directly in UIKit.
```swift
// Direct usage
UIView.animate(.spring(duration: 0.5, bounce: 0.2)) {
view.center = newCenter
}
// In UIViewRepresentable — bridging SwiftUI transaction animations
struct BridgedView: UIViewRepresentable {
var offset: CGFloat
func updateUIView(_ uiView: UIView, context: Context) {
context.animate {
uiView.transform = CGAffineTransform(translationX: offset, y: 0)
}
}
}
```
The `context.animate` call automatically uses whatever animation is active in the SwiftUI transaction — if the parent used `withAnimation(.spring())`, the UIView change inherits that spring.
## UIViewPropertyAnimator
Full lifecycle control: create, start, pause, resume, reverse, scrub.
```swift
let animator = UIViewPropertyAnimator(
duration: 0.4,
timingParameters: UISpringTimingParameters(dampingRatio: 0.8)
)
animator.addAnimations {
view.transform = CGAffineTransform(translationX: 0, y: -200)
view.alpha = 0
}
animator.addCompletion { position in
if position == .end {
view.removeFromSuperview()
}
}
// Interactive scrubbing
animator.pauseAnimation()
animator.fractionComplete = gestureProgress // 0.0 to 1.0
// Resume with spring
animator.continueAnimation(
withTimingParameters: UISpringTimingParameters(dampingRatio: 0.85),
durationFactor: 0.5 // fraction of remaining duration
)
```
## UIDynamicAnimator
Physics-based animations using behavior composition.
```swift
let animator = UIDynamicAnimator(referenceView: containerView)
// Snap behavior — spring to a point
let snap = UISnapBehavior(item: cardView, snapTo: targetPoint)
snap.damping = 0.5
animator.addBehavior(snap)
// Gravity + collision for falling effect
let gravity = UIGravityBehavior(items: [cardView])
let collision = UICollisionBehavior(items: [cardView])
collision.translatesReferenceBoundsIntoBoundary = true
animator.addBehavior(gravity)
animator.addBehavior(collision)
// Item properties
let behavior = UIDynamicItemBehavior(items: [cardView])
behavior.elasticity = 0.6
behavior.friction = 0.2
animator.addBehavior(behavior)
```
Use sparingly — physics simulations are powerful but can produce unpredictable results if behaviors conflict. For most cases, spring animations achieve a similar feel with more control.
## Performance Notes
- Core Animation runs on the render server (separate process), not the main thread
- Avoid animating properties that trigger offscreen rendering: `cornerRadius` + `masksToBounds`, complex `shadowPath`, `shouldRasterize` on frequently changing layers
- Provide explicit `shadowPath` — without it, the system must calculate the shadow from the layer's composited alpha every frame
- `shouldRasterize = true` caches the layer's rendered content — good for static complex layers, bad for layers that change frequently (the cache gets invalidated)
- Group related layer changes in `CATransaction` to batch commits
FILE:references/gesture-animations.md
# Gesture-Driven Animations
## Gesture + Spring Completion Pattern
The core pattern: track gesture state, apply to view, animate to final position on release.
```swift
struct DraggableCard: View {
@State private var offset: CGSize = .zero
@State private var isDragging = false
var body: some View {
CardView()
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
isDragging = true
}
.onEnded { value in
let threshold: CGFloat = 150
let velocity = value.predictedEndTranslation
if abs(value.translation.width) > threshold ||
abs(velocity.width) > 500 {
// Dismiss
withAnimation(.spring(duration: 0.3, bounce: 0.0)) {
offset = CGSize(
width: velocity.width > 0 ? 500 : -500,
height: value.translation.height
)
}
} else {
// Snap back
withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
offset = .zero
}
}
isDragging = false
}
)
.animation(.interactiveSpring, value: isDragging)
}
}
```
## GestureState for Transient Values
`@GestureState` automatically resets when the gesture ends — useful for tracking intermediate state.
```swift
@GestureState private var dragOffset: CGSize = .zero
var body: some View {
CardView()
.offset(dragOffset)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation
}
)
.animation(.interactiveSpring, value: dragOffset)
}
```
Limitation: `@GestureState` resets instantly. If you need animated return-to-origin, use `@State` with explicit `onEnded` spring.
## Velocity-Preserving Completion
Capture gesture velocity and pass it to the completion spring for natural-feeling release.
```swift
.onEnded { value in
let dx = targetOffset - offset.width
let dy = targetOffset - offset.height
let velocity = CGVector(
dx: abs(dx) > 1 ? value.velocity.width / dx : 0,
dy: abs(dy) > 1 ? value.velocity.height / dy : 0
)
withAnimation(.interpolatingSpring(
stiffness: 200,
damping: 25,
initialVelocity: sqrt(velocity.dx * velocity.dx + velocity.dy * velocity.dy)
)) {
offset = targetOffset
}
}
```
## Rubber-Banding
When dragging past bounds, resistance increases logarithmically.
```swift
func rubberBand(_ offset: CGFloat, limit: CGFloat, coefficient: CGFloat = 0.55) -> CGFloat {
let absOffset = abs(offset)
guard absOffset > limit else { return offset }
let overflow = absOffset - limit
let dampened = limit + coefficient * overflow / (1 + coefficient * overflow / limit)
return offset > 0 ? dampened : -dampened
}
// Usage
.offset(y: rubberBand(dragOffset.height, limit: maxDrag))
```
## Interactive Dismiss with Progress
Map gesture progress to a 0–1 dismissal progress for visual feedback.
```swift
struct InteractiveDismiss: View {
@State private var offset: CGFloat = 0
@Environment(\.dismiss) private var dismiss
private var progress: CGFloat {
min(1, max(0, offset / 300))
}
var body: some View {
ContentView()
.offset(y: offset)
.scaleEffect(1 - progress * 0.1)
.opacity(1 - progress * 0.3)
.background {
Color.black.opacity(0.5 * (1 - progress))
.ignoresSafeArea()
}
.gesture(
DragGesture()
.onChanged { value in
offset = max(0, value.translation.height)
}
.onEnded { value in
if progress > 0.4 || value.velocity.height > 600 {
withAnimation(.spring(duration: 0.3)) {
offset = 1000 // large enough to animate offscreen
}
// No withAnimation completion API in SwiftUI — asyncAfter
// is the pragmatic approach. Keep duration in sync with spring above.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
dismiss()
}
} else {
withAnimation(.spring(duration: 0.4, bounce: 0.15)) {
offset = 0
}
}
}
)
}
}
```
## Magnification (Pinch to Zoom)
```swift
struct ZoomableImage: View {
@State private var currentScale: CGFloat = 1.0
@GestureState private var gestureScale: CGFloat = 1.0
private var effectiveScale: CGFloat {
max(1.0, min(5.0, currentScale * gestureScale))
}
var body: some View {
Image("photo")
.resizable()
.scaledToFit()
.scaleEffect(effectiveScale)
.gesture(
MagnifyGesture()
.updating($gestureScale) { value, state, _ in
state = value.magnification
}
.onEnded { value in
withAnimation(.spring(duration: 0.3, bounce: 0.1)) {
currentScale = max(1.0, min(5.0, currentScale * value.magnification))
}
}
)
.onTapGesture(count: 2) {
withAnimation(.spring(duration: 0.35, bounce: 0.15)) {
currentScale = currentScale > 1.0 ? 1.0 : 2.0
}
}
}
}
```
## Rotation Gesture
```swift
@State private var rotation: Angle = .zero
@GestureState private var gestureRotation: Angle = .zero
var body: some View {
DialView()
.rotationEffect(rotation + gestureRotation)
.gesture(
RotateGesture()
.updating($gestureRotation) { value, state, _ in
state = value.rotation
}
.onEnded { value in
withAnimation(.spring(duration: 0.3)) {
// Snap to nearest 45°
let total = (rotation + value.rotation).degrees
let snapped = (total / 45).rounded() * 45
rotation = .degrees(snapped)
}
}
)
}
```
## Scroll-Linked Animations
### scrollTransition
Phase-based animation tied to scroll position.
```swift
.scrollTransition(.animated(.spring(duration: 0.3))) { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.4)
.scaleEffect(phase.isIdentity ? 1 : 0.9)
.rotationEffect(.degrees(phase.isIdentity ? 0 : phase.value * 5))
}
```
### Parallax Effect
Different scroll speeds for background vs foreground.
```swift
ScrollView {
GeometryReader { geo in
let minY = geo.frame(in: .global).minY
Image("hero")
.resizable()
.scaledToFill()
.offset(y: minY > 0 ? -minY * 0.5 : 0) // parallax factor
.frame(height: 300 + (minY > 0 ? minY : 0))
}
.frame(height: 300)
// Regular content below
ContentView()
}
```
## UIKit: UIViewPropertyAnimator for Interactive Animations
When you need interactive scrubbing or need to bridge with UIKit navigation.
```swift
class InteractiveDismissAnimator {
private var animator: UIViewPropertyAnimator?
func beginInteraction() {
animator = UIViewPropertyAnimator(
duration: 0.5,
dampingRatio: 0.9
) {
self.presentedView.transform = CGAffineTransform(
translationX: 0,
y: self.presentedView.bounds.height
)
self.dimmingView.alpha = 0
}
animator?.pauseAnimation()
}
func updateInteraction(progress: CGFloat) {
animator?.fractionComplete = progress
}
func endInteraction(shouldComplete: Bool) {
if shouldComplete {
animator?.continueAnimation(
withTimingParameters: UISpringTimingParameters(dampingRatio: 0.85),
durationFactor: 0.5
)
} else {
animator?.isReversed = true
animator?.continueAnimation(
withTimingParameters: UISpringTimingParameters(dampingRatio: 0.9),
durationFactor: 0.3
)
}
}
}
```
FILE:references/swiftui-animations.md
# SwiftUI Animations
## withAnimation vs .animation(_:value:)
`withAnimation` wraps a state change — all views affected by that state animate. Use when the animation is tied to an event (tap, toggle, data load).
`.animation(_:value:)` attaches to a specific view and animates whenever `value` changes. Use when the animation should happen every time a binding or state property updates, regardless of what triggered it.
```swift
// BAD — deprecated form without value:
.animation(.spring())
// GOOD — explicit value binding
.animation(.spring(), value: isExpanded)
// GOOD — event-driven
Button("Toggle") {
withAnimation(.snappy) {
isExpanded.toggle()
}
}
```
### Scoping withAnimation
`withAnimation` should wrap the minimal state mutation, not the entire action.
```swift
// BAD — wraps unrelated work
withAnimation {
viewModel.loadData() // network call doesn't need animation
isLoading = false
items = newItems
}
// GOOD — only animate the state change
viewModel.loadData()
withAnimation(.smooth) {
isLoading = false
items = newItems
}
```
## Spring Animations
Springs are the default in iOS 17+. Specify with `duration` and `bounce`.
```swift
// Named presets
withAnimation(.smooth) { } // duration: 0.5, bounce: 0.0
withAnimation(.snappy) { } // duration: 0.3, bounce: 0.15
withAnimation(.bouncy) { } // duration: 0.5, bounce: 0.3
// Custom tuning
withAnimation(.spring(duration: 0.4, bounce: 0.2)) { }
// With extra bounce on a preset
withAnimation(.snappy(extraBounce: 0.1)) { }
```
## Custom Timing Curves
For precise easing control beyond the built-in presets, define a cubic Bézier curve with two control points.
```swift
// Cubic Bézier — control points (x1, y1) and (x2, y2)
withAnimation(.timingCurve(0.68, -0.55, 0.27, 1.55, duration: 0.4)) { }
// Equivalent to CSS ease-in-out
withAnimation(.timingCurve(0.42, 0, 0.58, 1, duration: 0.3)) { }
```
The built-in easing functions are shorthand for specific curves:
| Preset | Bézier Approximation |
|--------|---------------------|
| `.easeIn` | `(0.42, 0, 1, 1)` |
| `.easeOut` | `(0, 0, 0.58, 1)` |
| `.easeInOut` | `(0.42, 0, 0.58, 1)` |
| `.linear` | `(0, 0, 1, 1)` |
## Repeating Animations
Chain `.repeatCount(_:autoreverses:)` or `.repeatForever(autoreverses:)` onto any animation to loop it.
```swift
// Pulse 3 times then stop
withAnimation(.easeInOut(duration: 0.3).repeatCount(3, autoreverses: true)) {
isPulsing.toggle()
}
// Continuous rotation (no autoreverse for smooth loop)
withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) {
rotationAngle = .degrees(360)
}
// Bouncy repeat with autoreverse
.animation(
.spring(duration: 0.5, bounce: 0.3).repeatCount(2, autoreverses: true),
value: isActive
)
```
`autoreverses: true` (default) plays the animation forward then backward per cycle. Set to `false` for one-directional loops like rotation or progress.
## Speed and Delay
Modify animation timing without changing the curve itself.
```swift
// Half-speed spring (takes twice as long)
withAnimation(.spring().speed(0.5)) {
isExpanded.toggle()
}
// Delay start by 0.3s (useful for staggered sequences)
withAnimation(.snappy.delay(0.3)) {
showSecondElement = true
}
// Combined — staggered cards
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
CardView(item: item)
.animation(
.spring(duration: 0.4).delay(Double(index) * 0.05),
value: isVisible
)
}
```
## Transaction Control
`Transaction` lets you override or suppress animations at the point of a state change, useful when you need different animation behavior than what the view's `.animation()` modifier provides.
```swift
// Suppress all animations for a state change
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
selectedItem = newItem // updates instantly, no animation
}
// Override with a custom animation
var transaction = Transaction(animation: .spring(duration: 0.6, bounce: 0.2))
withTransaction(transaction) {
isExpanded = true
}
// Inside a view modifier — read and modify the current transaction
.transaction { transaction in
if skipAnimation {
transaction.animation = nil
}
}
```
Use `withTransaction` over `withAnimation` when you need to disable animations or override them for a specific state change without affecting the view's default animation modifiers.
## PhaseAnimator
Cycles through discrete phases. Each transition between phases is a separate animation. Use for multi-step sequences.
```swift
enum PulsePhase: CaseIterable {
case idle, scale, reset
}
Circle()
.phaseAnimator(PulsePhase.allCases) { content, phase in
content
.scaleEffect(phase == .scale ? 1.3 : 1.0)
.opacity(phase == .scale ? 0.7 : 1.0)
} animation: { phase in
switch phase {
case .idle: .easeOut(duration: 0.3)
case .scale: .spring(duration: 0.4, bounce: 0.3)
case .reset: .easeInOut(duration: 0.5)
}
}
```
### Triggered PhaseAnimator
Pass a `trigger` value to run the sequence once per change instead of continuously.
```swift
.phaseAnimator(PulsePhase.allCases, trigger: tapCount) { content, phase in
// ...
}
```
## KeyframeAnimator
Per-property timeline control. Each property gets its own `KeyframeTrack` with independent timing curves and durations.
```swift
struct AnimationValues {
var scale: Double = 1.0
var rotation: Angle = .zero
var yOffset: Double = 0
}
Text("🎉")
.keyframeAnimator(initialValue: AnimationValues(), trigger: celebrate) { content, value in
content
.scaleEffect(value.scale)
.rotationEffect(value.rotation)
.offset(y: value.yOffset)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.5, duration: 0.2)
SpringKeyframe(1.0, duration: 0.3)
}
KeyframeTrack(\.rotation) {
LinearKeyframe(.degrees(-15), duration: 0.1)
SpringKeyframe(.degrees(15), duration: 0.15)
SpringKeyframe(.zero, duration: 0.25)
}
KeyframeTrack(\.yOffset) {
SpringKeyframe(-20, duration: 0.2)
SpringKeyframe(0, duration: 0.3)
}
}
```
### Keyframe Types
| Type | Interpolation | Use Case |
|------|---------------|----------|
| `LinearKeyframe` | Constant rate | Rotation, progress bars |
| `SpringKeyframe` | Spring physics | Most property animations |
| `CubicKeyframe` | Bezier curve | Precise easing control |
| `MoveKeyframe` | Instant jump | Reset to new position without interpolation |
## Content Transitions
Animate content changes within a view (not insertion/removal).
```swift
// Animated number change
Text(score, format: .number)
.contentTransition(.numericText(countsDown: score < previousScore))
// Symbol replacement
Image(systemName: isFavorite ? "heart.fill" : "heart")
.contentTransition(.symbolEffect(.replace))
// General interpolation
Text(statusMessage)
.contentTransition(.interpolate)
```
## Symbol Effects
SF Symbols 5+ (iOS 17) and SF Symbols 6 (iOS 18) animations.
```swift
// Discrete (trigger once)
Image(systemName: "bell")
.symbolEffect(.bounce, value: notificationCount)
// Continuous
Image(systemName: "network")
.symbolEffect(.pulse)
// iOS 18
Image(systemName: "arrow.trianglehead.clockwise")
.symbolEffect(.rotate)
Image(systemName: "bell")
.symbolEffect(.wiggle, value: hasAlert)
Image(systemName: "circle")
.symbolEffect(.breathe)
```
## Scroll Transitions
Animate views based on their position in a scroll view.
```swift
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemCard(item: item)
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.3)
.scaleEffect(phase.isIdentity ? 1 : 0.85)
.blur(radius: phase.isIdentity ? 0 : 2)
}
}
}
}
```
`ScrollTransitionPhase`: `.topLeading` (entering from top/leading), `.identity` (fully visible), `.bottomTrailing` (exiting to bottom/trailing).
## Mesh Gradients (iOS 18+)
Animated background gradients using a 2D control point grid.
```swift
struct AnimatedMeshBackground: View {
@State private var phase: CGFloat = 0
var body: some View {
MeshGradient(
width: 3, height: 3,
points: [
[0, 0], [0.5, 0], [1, 0],
[0, 0.5], [0.5 + 0.1 * sin(phase), 0.5 + 0.1 * cos(phase)], [1, 0.5],
[0, 1], [0.5, 1], [1, 1]
],
colors: [.blue, .purple, .indigo, .cyan, .mint, .teal, .blue, .purple, .indigo]
)
.onAppear {
withAnimation(.linear(duration: 5).repeatForever(autoreverses: false)) {
phase = .pi * 2
}
}
}
}
```
## Sensory Feedback
Pair haptics with animations for reinforcement.
```swift
Button("Like") {
withAnimation(.bouncy) { isLiked.toggle() }
}
.sensoryFeedback(.impact(flexibility: .soft, intensity: 0.7), trigger: isLiked)
// Other useful feedback types
.sensoryFeedback(.selection, trigger: selectedTab) // tab switch
.sensoryFeedback(.success, trigger: taskCompleted) // completion
.sensoryFeedback(.warning, trigger: errorOccurred) // alert
.sensoryFeedback(.increase, trigger: count) // increment
```
## Custom Animation Protocol (iOS 17+)
For animation behaviors not covered by built-in types.
```swift
struct DecayAnimation: CustomAnimation {
let decayRate: Double
func animate<V: VectorArithmetic>(value: V, time: Double, context: inout AnimationContext<V>) -> V? {
let factor = pow(decayRate, time)
if factor < 0.001 { return nil } // animation complete
return value.scaled(by: factor)
}
}
extension Animation {
static func decay(rate: Double = 0.998) -> Animation {
Animation(DecayAnimation(decayRate: rate))
}
}
```
FILE:references/transitions.md
# Transitions
## Custom Transition Protocol (iOS 17+)
Define reusable view insertion/removal animations.
```swift
struct SlideAndFade: Transition {
var edge: Edge
func body(content: Content, phase: TransitionPhase) -> some View {
content
.opacity(phase.isIdentity ? 1 : 0)
.offset(
x: phase == .willAppear ? (edge == .leading ? -50 : 50) :
phase == .didDisappear ? (edge == .leading ? -50 : 50) : 0
)
}
}
extension AnyTransition {
static func slideAndFade(from edge: Edge) -> AnyTransition {
.init(SlideAndFade(edge: edge))
}
}
// Usage
if showDetail {
DetailView()
.transition(.slideAndFade(from: .trailing))
}
```
### TransitionPhase
| Phase | Meaning | Typical Use |
|-------|---------|-------------|
| `.willAppear` | View is about to insert | Set "before" state (offscreen, transparent) |
| `.identity` | View is fully presented | Normal appearance |
| `.didDisappear` | View is about to remove | Set "after" state (offscreen, transparent) |
### Asymmetric Transitions
Different animations for insertion vs removal.
```swift
.transition(.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .scale(scale: 0.8).combined(with: .opacity)
))
```
## matchedGeometryEffect
Hero transitions between views using shared geometry.
```swift
struct ContentView: View {
@Namespace private var animation
@State private var isExpanded = false
var body: some View {
if isExpanded {
ExpandedCard(namespace: animation)
.onTapGesture {
withAnimation(.spring(duration: 0.45, bounce: 0.15)) {
isExpanded = false
}
}
} else {
CompactCard(namespace: animation)
.onTapGesture {
withAnimation(.spring(duration: 0.45, bounce: 0.15)) {
isExpanded = true
}
}
}
}
}
struct CompactCard: View {
var namespace: Namespace.ID
var body: some View {
RoundedRectangle(cornerRadius: 12)
.fill(.blue)
.matchedGeometryEffect(id: "card", in: namespace)
.frame(width: 100, height: 100)
}
}
struct ExpandedCard: View {
var namespace: Namespace.ID
var body: some View {
RoundedRectangle(cornerRadius: 24)
.fill(.blue)
.matchedGeometryEffect(id: "card", in: namespace)
.frame(maxWidth: .infinity, maxHeight: 400)
}
}
```
### Common Pitfalls
1. **Both views visible simultaneously** — only one view with a given matched ID should be in the hierarchy at a time. Use `if/else`, not opacity toggling.
2. **Unstable IDs** — the `id` parameter must be the same value on both sides. Use a model ID, not an index.
3. **Missing `isSource`** — when matching multiple properties (position and size separately), set `isSource: true` on the view that defines the geometry, `isSource: false` on the one that follows.
4. **Missing `geometryGroup()`** — when the parent's geometry is also changing (e.g., the parent frame animates), wrap the parent with `.geometryGroup()` to isolate layout resolution.
## Zoom Navigation Transition (iOS 18+)
Built-in zoom-style push/pop for NavigationStack.
```swift
struct GridView: View {
@Namespace private var namespace
var body: some View {
NavigationStack {
LazyVGrid(columns: columns) {
ForEach(items) { item in
NavigationLink {
DetailView(item: item)
.navigationTransition(.zoom(sourceID: item.id, in: namespace))
} label: {
ItemCell(item: item)
.matchedTransitionSource(id: item.id, in: namespace)
}
}
}
}
}
}
```
Works with:
- `NavigationStack` push/pop
- `.fullScreenCover`
- `.sheet`
Automatically handles back-gesture interruption and is continuously interactive.
## Full-Screen Cover Transitions
`.fullScreenCover` uses system-managed presentation animation. The `.transition()` modifier does **not** affect modal presentation — it only applies to views conditionally inserted/removed within the same hierarchy.
To customize full-screen cover appearance, animate content **inside** the cover view (e.g., in `.onAppear`), or use `navigationTransition(.zoom)` on iOS 18+.
For zoom-style cover transitions on iOS 18+:
```swift
.fullScreenCover(isPresented: $showPhoto) {
PhotoViewer(photo: selectedPhoto)
.navigationTransition(.zoom(sourceID: selectedPhoto.id, in: namespace))
}
```
## Sheet Presentation Transitions
Sheets have system-managed presentation animation. To customize the content appearance within the sheet:
```swift
.sheet(isPresented: $showSettings) {
SettingsView()
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
```
Sheet content that changes state can animate internally with standard `withAnimation`.
## Tab View Transitions
Animate tab switches with content transitions.
```swift
TabView(selection: $selectedTab) {
// tabs
}
// Custom transition between tab content
.onChange(of: selectedTab) {
withAnimation(.snappy) {
// trigger any state changes for the new tab
}
}
```
For custom tab-like views, use `matchedGeometryEffect` on the tab indicator:
```swift
HStack {
ForEach(tabs) { tab in
Button(tab.title) { selectedTab = tab }
.background {
if selectedTab == tab {
Capsule()
.fill(.blue)
.matchedGeometryEffect(id: "indicator", in: namespace)
}
}
}
}
```
## NavigationStack Path Animation
Animate programmatic navigation changes.
```swift
@State private var path = NavigationPath()
NavigationStack(path: $path) { /* ... */ }
// Animated push
withAnimation(.smooth) {
path.append(destination)
}
// Animated pop to root
withAnimation(.smooth) {
path.removeLast(path.count)
}
```
Design and plan iOS animations with structured specs covering transitions, micro-interactions, gesture-driven motion, and loading states. Use when the user a...
---
name: ios-animation-design
description: Design and plan iOS animations with structured specs covering transitions, micro-interactions, gesture-driven motion, and loading states. Use when the user asks to plan, design, or spec out animations for an iOS app — including screen transitions, navigation animations, interactive gestures, onboarding flows, or any motion design work. Also use when the user wants animation recommendations or wants to decide between animation approaches before writing code.
---
# iOS Animation Design
Plan animations that feel intentional, not decorative. Apple's HIG is clear: "Don't add motion for the sake of adding motion. Gratuitous or excessive animation can distract people and may make them feel disconnected or physically uncomfortable." Every animation must serve a purpose — guide attention, communicate state changes, reinforce spatial relationships, or provide feedback.
Before adding any custom animation, ask: does the system already handle this? Many system components include motion automatically — Liquid Glass (iOS 26) responds to touch with greater emphasis and produces more subdued effects for trackpad interaction. Standard controls, navigation transitions, and sheets already animate. Custom motion should fill gaps the system doesn't cover, not replace what it already does well.
## Design Process
### Gates (sequenced)
Advance only after each pass condition is met in the working artifact (chat or doc). Do not rationalize compliance without evidence.
1. **Gate — Context captured** — **Pass when** there are written answers for: what triggers the motion, its purpose, where it lives in the app, how often it runs, minimum iOS version, and primary input methods (or explicit **N/A** where not applicable). **Pass when** you have stated whether system-provided motion already covers this or which gap custom animation fills.
2. **Gate — Options differ meaningfully** — **Pass when** there are 2–3 approaches and each differs in **at least two** of: named API/technique, motion character, complexity band, or iOS floor — not minor timing tweaks of the same idea.
3. **Gate — Spec is implementation-ready** — **Pass when** the compiled spec includes concrete entries for **Trigger**, **Interruption** (cancel/reverse/queue), **Reduce Motion** fallback, **Haptics** (or explicit none with rationale), and **Recommended API**.
### Step 1: Understand the Animation Context
Before proposing options, gather context about what needs to animate and why:
- **What triggers it?** User action (tap, swipe, drag), state change (data loaded, error), or lifecycle event (appear, disappear)?
- **What's the purpose?** Feedback, spatial orientation, content transition, delight, or status communication?
- **Where in the app?** Navigation flow, in-screen state change, overlay/modal, or background ambient?
- **How frequent?** Once per session (onboarding), every interaction (tab switch), or continuous (progress indicator)? Apple's HIG warns: "In apps, generally avoid adding motion to UI interactions that occur frequently. The system already provides subtle animations for interactions with standard interface elements."
- **Deployment target?** Which iOS version floor determines available APIs.
- **Input methods?** Touch, trackpad, keyboard, VoiceOver? iOS 26's Liquid Glass adapts motion intensity based on input — direct touch gets more emphasis, indirect input is more subdued. Custom animations should follow the same principle.
### Step 2: Present 2-3 Animation Approaches
For each animation need, present 2-3 distinct approaches. Each option should feel meaningfully different — not minor variations of the same idea. Structure each option as:
```markdown
### Option [N]: [Name]
**Approach**: [1-2 sentences describing the motion design]
**Technique**: [Which Apple API — SwiftUI animation, KeyframeAnimator, matchedGeometryEffect, etc.]
**Character**: [How it feels — snappy, playful, elegant, subtle, dramatic]
**Complexity**: [Low / Medium / High — implementation and maintenance cost]
**iOS floor**: [Minimum iOS version required]
```
Then provide a **Recommendation** with rationale tied to the gathered context. The recommendation should consider:
- API availability relative to the deployment target
- Complexity budget — simpler is better unless the animation is a signature moment
- Whether the system already handles it — prefer system-provided motion over custom implementations
- Consistency with existing app motion language
- Cancellability — can users interrupt or skip it? ("Don't make people wait for an animation to complete before they can do anything" — Apple HIG)
- Accessibility (can it gracefully degrade with Reduce Motion?)
- Multimodal feedback — animation alone shouldn't be the only signal. "Supplement visual feedback by also using alternatives like haptics and audio" (Apple HIG)
### Step 3: Compile the Animation Spec
Once the user selects an approach (or confirms the recommendation), produce a structured spec. This spec is the contract between design and implementation — it should contain everything needed to write the code without ambiguity.
## Animation Spec Format
```markdown
# Animation Spec: [Name]
## Overview
[1-2 sentences: what this animation does and why it exists]
## Trigger
- **Event**: [What initiates the animation — tap, state change, appear, gesture, etc.]
- **Direction**: [Forward / Reverse / Bidirectional]
## Motion Design
### Properties
| Property | From | To | Curve | Duration |
|----------|------|----|-------|----------|
| opacity | 0 | 1 | .easeOut | 0.25s |
| scale | 0.8 | 1.0 | .spring(duration: 0.5, bounce: 0.3) | — |
| offset.y | 20 | 0 | .spring(duration: 0.5, bounce: 0.3) | — |
### Timing
- **Total duration**: [end-to-end time]
- **Stagger**: [if multiple elements, delay between each]
- **Interruption**: [What happens if triggered again mid-animation — cancel, reverse, queue]
### Gesture Binding (if interactive)
- **Gesture type**: [drag, long press, rotation, magnification]
- **Progress mapping**: [How gesture progress maps to animation progress]
- **Threshold**: [When the animation commits vs. cancels]
- **Velocity handling**: [How release velocity affects completion]
## Accessibility & Multimodal Feedback
- **Reduce Motion**: [What happens — crossfade, instant, simplified version]
- **VoiceOver**: [Any announcement needed for the state change]
- **Haptics**: [Which sensoryFeedback type pairs with this animation — .impact, .selection, .success, etc.]
- **Audio**: [Optional sound cue if the state change is important enough]
- **Dynamic Type**: [Does layout shift affect the animation?]
## Implementation Notes
- **Recommended API**: [SwiftUI withAnimation, KeyframeAnimator, PhaseAnimator, matchedGeometryEffect, UIViewPropertyAnimator, etc.]
- **State model**: [What @State/@Binding drives this animation]
- **Extractable component**: [Yes/No — should this be a reusable ViewModifier or View?]
```
## Animation Categories
When designing, think in terms of these categories. Each has different expectations for timing, easing, and purpose.
### Navigation & Scene Transitions
Screen-to-screen movement. Users expect spatial consistency — where did I come from, where am I going? These should feel fast and confident.
- Push/pop with hero elements (`matchedGeometryEffect`, `navigationTransition(.zoom)`)
- Full-screen covers and sheets (custom `Transition` protocol)
- Tab switches (crossfade, slide, or matched geometry)
- Onboarding flows (page-based with progressive reveal)
Timing: 0.3–0.5s. Easing: spring-based (`.snappy` or `.smooth`). Interruption: must handle back-gesture gracefully.
### Micro-Interactions
Small, immediate feedback for user actions. Apple's HIG emphasizes brevity: "When animated feedback is brief and precise, it tends to feel lightweight and unobtrusive, and it can often convey information more effectively than prominent animation." These should be near-instant and never block interaction. For frequent interactions, strongly consider whether the system's built-in animation is sufficient before adding custom motion.
- Button press states (scale + haptic)
- Toggle/switch animations
- Like/favorite/bookmark responses
- Pull-to-refresh indicators
- Text field focus transitions
- Swipe action reveals
Timing: 0.1–0.3s. Easing: `.snappy` or `.spring(duration: 0.2, bounce: 0.4)`. Always pair with `sensoryFeedback` — haptics reinforce the visual feedback and communicate to users who can't see the animation.
### Content Transitions
When data changes within a view — numbers updating, content swapping, list reordering.
- Numeric text transitions (`.contentTransition(.numericText)`)
- Image crossfades
- List item insertion/removal
- Skeleton-to-content reveal
- Error/empty/loaded state switches
Timing: 0.2–0.35s. Easing: `.smooth` or `.easeInOut`. Use `animation(_:value:)` tied to the changing data.
### Gesture-Driven Animations
Interactive animations where the user directly controls progress. These need to feel physically connected to the finger — no lag, no disconnection.
- Card dismiss (swipe to remove)
- Drawer/sheet drag
- Pinch-to-zoom
- Rotation interactions
- Scroll-linked parallax (`scrollTransition`)
Spring-based completion is essential. Track velocity on release. Use `UIViewPropertyAnimator` for UIKit or `GestureState` + spring for SwiftUI.
### Loading & Progress
Communicate waiting and progress. Should feel alive without being distracting.
- Skeleton screens (shimmer with gradient mask)
- Indeterminate spinners (SF Symbol `.symbolEffect(.pulse)`)
- Determinate progress (animated bar/ring)
- Content placeholder pulse (`PhaseAnimator`)
Keep looping animations lightweight — they run continuously and must not drain battery or cause hitches.
### Ambient & Decorative
Background motion that establishes mood. Use sparingly — these are the easiest to overdo.
- Animated gradients (`MeshGradient` with timer-driven point shifts)
- Floating particle effects
- Subtle parallax on device tilt
- Background blur transitions
Always disable or simplify with Reduce Motion. These are the first to cut for performance.
## Principles
1. **Purpose over polish** — If you can't articulate why something animates, it shouldn't. Apple's HIG: "Don't add motion for the sake of adding motion."
2. **System first** — Many system components already include motion (Liquid Glass, navigation transitions, sheets, SF Symbol effects). Check whether the system handles it before designing custom motion. Custom animation should fill gaps, not duplicate the system.
3. **Brevity over spectacle** — "Aim for brevity and precision in feedback animations" (Apple HIG). Brief animations convey information more effectively than prominent ones. A succinct response tied to the action beats an elaborate sequence.
4. **Springs over curves** — Spring animations feel physical. Use `duration` + `bounce` parameters, not bezier curves, unless you have a specific reason.
5. **Reduce Motion is not optional** — Every animation spec must include a Reduce Motion fallback. Apple's HIG: "Make motion optional. Not everyone can or wants to experience the motion in your app." This also means never using animation as the only way to communicate important information.
6. **Multimodal feedback** — Supplement animation with haptics (`.sensoryFeedback`) and audio where appropriate. Animation alone shouldn't carry critical state changes.
7. **Cancellation is a right** — "Don't make people wait for an animation to complete before they can do anything, especially if they have to experience the animation more than once" (Apple HIG). Every animation must be interruptible.
8. **Realistic spatial feedback** — Motion should follow the user's gesture and expectations. If someone slides a view down, they expect to dismiss it by sliding down, not sideways. Feedback that defies spatial logic disorients people.
9. **Speed earns trust** — Animations under 0.3s feel responsive. Over 0.5s feels sluggish unless it's a signature moment. When in doubt, go faster.
10. **Consistency compounds** — Use the same spring parameters across similar interactions. A consistent motion language makes the whole app feel cohesive. Define a small set of timing presets and reuse them.
FILE:references/animation-patterns.md
# Animation Pattern Library
## Navigation Patterns
### Hero Element Transition
Element from source screen flies to its position in destination screen. Creates spatial continuity between list and detail views.
**When to use**: Tapping a card/cell to open detail. Photo grids. Product listings.
**API**: `matchedGeometryEffect` (iOS 14+), `.navigationTransition(.zoom)` (iOS 18+)
**Timing**: 0.35–0.5s spring (duration: 0.45, bounce: 0.15)
**Reduce Motion**: Crossfade (0.2s)
### Shared Axis Transition
Views slide along a shared spatial axis — horizontal for peer navigation, vertical for parent-child hierarchy.
**When to use**: Tab switches, onboarding steps, wizard flows.
**API**: Custom `Transition` with `offset` + `opacity`
**Timing**: 0.3s `.snappy`
**Reduce Motion**: Crossfade
### Full-Screen Cover with Zoom
Source element zooms to fill the screen. Built-in to iOS 18+ navigation.
**When to use**: Image viewers, media playback, detail views from grids.
**API**: `.navigationTransition(.zoom(sourceID:in:))` + `.matchedTransitionSource`
**Timing**: System-managed spring
**Reduce Motion**: System handles automatically
## Micro-Interaction Patterns
### Press Scale
Button scales down slightly on press, returns with bounce on release. Pairs with haptic.
**Spec**:
- Press: scale 0.95, opacity 0.8, duration 0.1s
- Release: scale 1.0, opacity 1.0, spring(duration: 0.3, bounce: 0.4)
- Haptic: `.sensoryFeedback(.impact(flexibility: .soft))`
### Toggle State
Binary state switch with smooth property interpolation.
**Spec**:
- Properties: background color, icon rotation/swap, track position
- Duration: 0.2s `.snappy`
- Content swap: `.contentTransition(.symbolEffect(.replace))`
### Like/Favorite Burst
Celebratory response to a positive action. Should feel rewarding without being slow.
**Spec**:
- Icon: scale 1.0 → 1.3 → 1.0, with `.symbolEffect(.bounce)`
- Optional: particle burst using overlay + `PhaseAnimator`
- Haptic: `.sensoryFeedback(.success)`
- Duration: 0.4s total
### Pull to Refresh
Overscroll triggers refresh indicator. Progress maps to pull distance.
**Spec**:
- Threshold: 80pt pull
- Indicator: rotation tied to scroll offset, then continuous spin on release
- Completion: scale down + fade (0.2s)
## Content Transition Patterns
### Number Counter
Animated number changes — scores, prices, quantities.
**API**: `.contentTransition(.numericText(countsDown:))`
**Timing**: System-managed
**Reduce Motion**: Instant update (system handles)
### Skeleton to Content
Placeholder shimmer replaced by actual content on load.
**Spec**:
- Skeleton: gradient mask sweep using `PhaseAnimator` with 2 phases (offset -1.0, +1.0)
- Reveal: opacity 0→1, offset.y 8→0, spring 0.35s
- Stagger: 0.05s between items
### List Reorder
Items slide to new positions when sort changes.
**API**: `withAnimation(.spring()) { items.sort(...) }` with `ForEach` + stable IDs
**Timing**: 0.35s `.smooth`
**Reduce Motion**: Instant reorder
### State Switch (Empty/Error/Loaded)
Full-view content swap between states.
**Spec**:
- Outgoing: opacity 1→0, scale 1.0→0.95 (0.15s)
- Incoming: opacity 0→1, scale 1.05→1.0, offset.y 10→0 (spring 0.35s)
- Custom `Transition` recommended for reuse
## Gesture-Driven Patterns
### Card Swipe Dismiss
Drag card horizontally past threshold to dismiss, or snap back.
**Spec**:
- Track: offset follows finger with 1:1 mapping
- Rotation: slight tilt proportional to horizontal offset (±5°)
- Threshold: 150pt or velocity > 500pt/s
- Commit: fly off-screen in drag direction, spring completion
- Cancel: spring back to origin (duration: 0.5, bounce: 0.2)
- Opacity: fade as distance increases (1.0 at center, 0.5 at threshold)
### Bottom Sheet Drag
Pull sheet between detents with rubber-banding at limits.
**Spec**:
- Detents: collapsed (100pt), half (50%), full (90%)
- Between detents: 1:1 finger tracking
- Past limits: rubber-band (0.3:1 ratio)
- Release: snap to nearest detent using spring(duration: 0.4, bounce: 0.15)
- Velocity: release velocity determines target detent
### Pinch to Zoom
Scale content interactively with gesture, settle to discrete zoom levels on release.
**Spec**:
- Track: scale follows `MagnificationGesture` value
- Min/max: 1.0x–5.0x, rubber-band beyond
- Release: spring to nearest integer zoom (or back to 1.0 if below threshold)
- Double-tap: toggle between 1.0x and 2.0x (spring 0.35s)
## Ambient & Loading Patterns
### Mesh Gradient Background
Slowly shifting color field for visual atmosphere.
**API**: `MeshGradient` (iOS 18+) with timer-driven point interpolation
**Spec**:
- Grid: 3×3 minimum
- Animation: shift 2–3 control points by small random offsets
- Cycle: 3–5s per shift, continuous
- Reduce Motion: static gradient (no animation)
### Pulsing Activity Indicator
Subtle pulse for ongoing background activity.
**API**: `.symbolEffect(.pulse)` on SF Symbol or `PhaseAnimator` for custom
**Spec**:
- Opacity: 0.4 ↔ 1.0
- Scale: 0.95 ↔ 1.0
- Cycle: 1.5s per pulse
- Reduce Motion: static icon at full opacity
### Shimmer Loading
Gradient sweep across placeholder shapes.
**Spec**:
- Gradient: 3-stop (background → highlight → background)
- Sweep: linear move from leading to trailing, 1.2s cycle
- Angle: 15° for visual interest
- Reduce Motion: static gray placeholders (no sweep)
FILE:references/timing-guidelines.md
# Timing & Easing Guidelines
## Duration Ranges by Purpose
| Category | Range | Default | Notes |
|----------|-------|---------|-------|
| Micro-interaction | 0.1–0.25s | 0.15s | Button press, toggle, icon swap |
| Content transition | 0.2–0.35s | 0.25s | Data change, state switch |
| Navigation transition | 0.3–0.5s | 0.4s | Push/pop, modal present |
| Hero animation | 0.35–0.55s | 0.45s | Matched geometry, zoom |
| Elaborate choreography | 0.5–0.8s | 0.6s | Onboarding reveal, celebration |
| Looping/ambient | 1.0–5.0s | 2.0s | Shimmer, pulse, gradient shift |
Animations over 0.5s should be rare and intentional. If an animation feels slow, it probably is.
## Spring Presets
### When to Use Each
| Preset | Character | Best For |
|--------|-----------|----------|
| `.smooth` | No bounce, critically damped | Most transitions, navigation, content changes |
| `.snappy` | Quick with slight bounce | Micro-interactions, toggles, quick feedback |
| `.bouncy` | Noticeable bounce | Celebratory moments, playful UI, attention-drawing |
| `.interactiveSpring` | Very fast, no bounce | Gesture tracking, drag following |
### Custom Spring Tuning
```
.spring(duration: D, bounce: B)
```
- `duration`: Time to settle. Shorter = snappier.
- `bounce`: 0.0 = no bounce (critically damped). 0.5 = very bouncy. Negative = overdamped.
| Feel | Duration | Bounce |
|------|----------|--------|
| Crisp and precise | 0.2–0.3 | 0.0–0.1 |
| Responsive with life | 0.3–0.4 | 0.15–0.25 |
| Playful and springy | 0.4–0.6 | 0.3–0.5 |
| Gentle and soft | 0.5–0.7 | 0.1–0.2 |
## Stagger Timing
When animating multiple items sequentially (list appearance, grid reveal):
| Item Count | Stagger Delay | Total Spread |
|------------|---------------|--------------|
| 3–5 items | 0.05–0.08s | 0.15–0.32s |
| 6–10 items | 0.03–0.05s | 0.15–0.45s |
| 10+ items | 0.02–0.03s | Cap at 0.5s total |
Rules:
- Cap total stagger spread at 0.5s — after that, later items feel delayed rather than choreographed
- Only stagger visible items. Items below the fold animate individually when they scroll into view.
- First item should start immediately (0s delay)
## Easing Curves
Springs are preferred for almost everything in modern iOS. Use bezier curves only when you need precise timing control (synchronized with audio, video, or external events).
| Curve | Use Case |
|-------|----------|
| `.easeOut` | Elements entering — fast start, gentle settle |
| `.easeIn` | Elements leaving — slow start, accelerate away |
| `.easeInOut` | Looping animations, symmetrical motion |
| `.linear` | Progress indicators, synchronized timing |
## Interruption Strategies
What happens when an animation is re-triggered before it completes:
| Strategy | Behavior | When to Use |
|----------|----------|-------------|
| **Retarget** | Redirect to new end value, preserving velocity | Default for springs — most interactions |
| **Replace** | Stop current, start new from current position | Discrete state changes |
| **Queue** | Wait for current to finish | Sequential choreography |
| **Ignore** | Let current animation complete | One-shot celebrations |
SwiftUI springs retarget by default — this is almost always what you want. For explicit control in `KeyframeAnimator`, use the `trigger` parameter.
## Reduce Motion Timing
When `accessibilityReduceMotion` is enabled:
| Original | Reduce Motion Alternative |
|----------|--------------------------|
| Spring with bounce | `.easeOut(duration: 0.15)` or `.none` |
| 0.3–0.5s transition | Crossfade 0.15–0.2s |
| Gesture-driven interactive | Keep interactive (user controls it), remove decorative completion |
| Looping animation | Static (no animation) |
| Staggered reveal | All items appear simultaneously with fade |
The goal is not to remove all animation — it's to remove vestibular-triggering motion (large movement, zoom, rotation, bouncing) while keeping simple opacity changes that communicate state.
## Defining App-Wide Presets
Define a small set of named timing presets and reuse them everywhere. Consistency in timing makes the app feel polished.
```
enum AnimationPreset {
/// Micro-interactions: button press, toggle, icon swap
static let quick: Animation = .spring(duration: 0.2, bounce: 0.15)
/// Standard transitions: state changes, content swaps
static let standard: Animation = .spring(duration: 0.35, bounce: 0.1)
/// Navigation: push/pop, modal present/dismiss
static let navigation: Animation = .spring(duration: 0.45, bounce: 0.12)
/// Celebratory: like burst, achievement unlock
static let playful: Animation = .spring(duration: 0.5, bounce: 0.35)
/// Reduced alternative
static let reduced: Animation = .easeOut(duration: 0.15)
}
```
Three to five presets is plenty. More than that and you lose consistency.
Reviews iOS animation code for correctness, performance, accessibility, and Apple API best practices. Use when reviewing .swift files containing animation co...
---
name: ios-animation-code-review
description: Reviews iOS animation code for correctness, performance, accessibility, and Apple API best practices. Use when reviewing .swift files containing animation code — withAnimation, .animation(), PhaseAnimator, KeyframeAnimator, matchedGeometryEffect, navigationTransition, CABasicAnimation, CASpringAnimation, UIViewPropertyAnimator, UIDynamicAnimator, symbolEffect, scrollTransition, contentTransition, or custom Transition conformances.
---
# iOS Animation Code Review
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| Spring parameters, withAnimation misuse, phase/keyframe bugs | [references/swiftui-animation-patterns.md](references/swiftui-animation-patterns.md) |
| Frame drops, offscreen rendering, main thread blocking | [references/performance.md](references/performance.md) |
| Reduce Motion, VoiceOver, motion sensitivity | [references/accessibility.md](references/accessibility.md) |
| Transition protocol, matchedGeometryEffect, navigation transitions | [references/transitions.md](references/transitions.md) |
## Hard gates (sequence)
Complete **in order** for the files in scope. If a step fails, **omit** the finding, **re-anchor**, or **downgrade** to a question—do not ship accusations without meeting the pass condition.
| Step | What you do | Pass condition (objective) |
|------|-------------|----------------------------|
| **1. Inventory** | List each file under review and where animation APIs appear (line ranges or symbol names: `withAnimation`, `.animation`, `matchedGeometryEffect`, `PhaseAnimator`, UIKit/CA animators, etc.). | A written list exists; files with **no** animation APIs are explicitly marked out of scope. |
| **2. Anchor** | Re-read the cited region in the current file or diff hunk before naming an issue. | Each `[FILE:LINE]` still shows the behavior; stale line numbers are fixed or the finding is dropped. |
| **3. Evidence** | For framework-specific claims (spring curves, `Transition` conformance, Reduce Motion), cross-check the matching row in [Quick Reference](#quick-reference) against `references/*.md`. | The finding’s detail names the reference file used, or states **inline-only** (structural/readability with no framework rule). |
| **4. Report** | Emit findings using [Output Format](#output-format). | Headers match `[FILE:LINE] ISSUE_TITLE`; checklist items below are applied only where gates 1–2 covered that code. |
## Output Format
Report each finding as:
```
[FILE:LINE] ISSUE_TITLE
```
Example: `[AnimatedCard.swift:42] Missing Reduce Motion fallback for spring animation`
All details, code suggestions, and rationale follow after the header line.
## Review Checklist
- [ ] `@Environment(\.accessibilityReduceMotion)` checked — animations have Reduce Motion fallback
- [ ] Animation is not the sole feedback channel — important state changes pair with haptics (`.sensoryFeedback`) or audio
- [ ] Custom animation isn't duplicating system-provided motion (standard nav transitions, sheet presentation, SF Symbol effects)
- [ ] Animations on frequent interactions are brief and unobtrusive — or absent (system handles it)
- [ ] All animations are interruptible — user is never forced to wait for completion before interacting
- [ ] Spring animations use `duration`/`bounce` parameters (not raw mass/stiffness/damping unless UIKit/CA)
- [ ] No deprecated `.animation()` without `value:` parameter
- [ ] `withAnimation` wraps state changes, not view declarations
- [ ] `matchedGeometryEffect` IDs are stable and unique within the namespace
- [ ] `geometryGroup()` used when parent geometry animates with child views appearing
- [ ] Looping animations (`PhaseAnimator`, `symbolEffect`) have finite phases or appropriate trigger
- [ ] No `CATransaction.setAnimationDuration()` in UIView-backed layers (use UIView.animate instead)
- [ ] Interactive animations handle interruption (re-trigger mid-flight doesn't break state)
- [ ] Shadow animations provide explicit `shadowPath` (avoids per-frame recalculation)
- [ ] Gesture-driven animations preserve velocity on release for natural completion
- [ ] Gesture-driven feedback follows spatial expectations (dismiss direction matches reveal direction)
- [ ] No animation of `.id()` modifier (destroys view identity — use `transition` or `matchedGeometryEffect` instead)
## When to Load References
- Incorrect spring setup or `withAnimation` scope issues → swiftui-animation-patterns.md
- Hitches, dropped frames, or expensive animations in scroll views → performance.md
- Missing Reduce Motion handling or motion accessibility → accessibility.md
- `matchedGeometryEffect` glitches or custom `Transition` bugs → transitions.md
## Review Questions
1. Does every animation have a Reduce Motion fallback that preserves the information conveyed? Is animation the only feedback channel, or are haptics/audio supplementing it?
2. Is this custom animation necessary, or does the system already provide it (standard transitions, SF Symbol effects, Liquid Glass)?
3. Could this animation cause frame drops — is it animating expensive properties (blur, shadow without path, mask) in a list or scroll view?
4. Are all animations interruptible? Can the user act without waiting for completion? Does gesture-driven feedback follow spatial expectations?
5. Is `withAnimation` scoped to the minimal state change needed, or is it wrapping unrelated mutations?
6. For `matchedGeometryEffect` — are source and destination using the same ID and namespace, and is only one visible at a time?
FILE:references/accessibility.md
# Animation Accessibility
## Core HIG Principle
Apple's Human Interface Guidelines state: "Make motion optional. Not everyone can or wants to experience the motion in your app or game, so it's essential to avoid using it as the only way to communicate important information. To help everyone enjoy your app or game, supplement visual feedback by also using alternatives like haptics and audio to communicate."
This means two things: (1) every animation needs a Reduce Motion fallback, and (2) animation should never be the sole feedback channel for important state changes.
## Reduce Motion
Users with vestibular disorders, motion sensitivity, or preference for reduced visual complexity enable Settings → Accessibility → Motion → Reduce Motion. This is not a niche setting — a meaningful percentage of users have it enabled.
Every animation must have a Reduce Motion path. Not "should have" — must have.
### SwiftUI
```swift
@Environment(\.accessibilityReduceMotion) private var reduceMotion
// Pattern 1: Skip animation
withAnimation(reduceMotion ? .none : .spring()) {
isExpanded.toggle()
}
// Pattern 2: Simplified animation (crossfade instead of movement)
.animation(reduceMotion ? .easeOut(duration: 0.15) : .spring(duration: 0.5, bounce: 0.3), value: isActive)
// Pattern 3: Conditional modifier
.modifier(ReduceMotionModifier(
standard: .offset(y: isVisible ? 0 : 20).combined(with: .opacity),
reduced: .opacity
))
```
### UIKit
```swift
if UIAccessibility.isReduceMotionEnabled {
// Instant or simple fade
UIView.animate(withDuration: 0.15) {
view.alpha = targetAlpha
}
} else {
// Full spring animation
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0) {
view.alpha = targetAlpha
view.transform = targetTransform
}
}
// Listen for changes
NotificationCenter.default.addObserver(
forName: UIAccessibility.reduceMotionStatusDidChangeNotification,
object: nil, queue: .main
) { _ in
// Update running animations
}
```
## What to Reduce
Not all animation needs removal with Reduce Motion. The guideline is: remove vestibular-triggering motion while preserving state communication.
| Keep | Remove/Simplify |
|------|-----------------|
| Opacity changes (fade in/out) | Large positional movement (slide, fly) |
| Instant state transitions | Zoom/scale transitions |
| Brief, small-scale changes | Bouncing, wobbling, shaking |
| User-controlled gestures | Auto-playing motion (parallax, ambient) |
| Progress indicators (non-bouncing) | Spring overshoot (bounce parameter) |
### Reduce Motion Replacement Strategies
| Original Animation | Reduced Alternative |
|--------------------|---------------------|
| Slide in from edge | Crossfade (opacity 0→1) |
| Spring with bounce | Linear ease-out, 0.15s |
| Zoom navigation transition | System handles (crossfade) |
| Parallax scroll effect | Static (no parallax) |
| Staggered item entrance | All items appear simultaneously, fade |
| Rotation/flip | Crossfade |
| Animated gradient (MeshGradient) | Static gradient |
| Pulsing indicator | Static indicator at full opacity |
## Missing Reduce Motion — Review Signals
Code patterns that likely need Reduce Motion handling:
```swift
// Any of these without @Environment(\.accessibilityReduceMotion) is a flag:
withAnimation(.spring(duration: 0.5, bounce: 0.3)) { }
.phaseAnimator(phases) { }
.keyframeAnimator(initialValue: values) { }
.matchedGeometryEffect(id: "hero", in: namespace)
.scrollTransition { content, phase in }
.offset(y: isVisible ? 0 : 50)
.scaleEffect(isActive ? 1 : 0.5)
.rotationEffect(.degrees(isFlipped ? 180 : 0))
```
Exceptions (these don't need Reduce Motion handling):
- `.contentTransition(.numericText())` — system handles it
- `.symbolEffect` — system respects Reduce Motion automatically
- `.navigationTransition(.zoom)` — system manages
- `.sensoryFeedback` — haptics, not visual motion
## VoiceOver and Animation
### State Change Announcements
If an animation communicates a state change, VoiceOver users need an equivalent announcement.
```swift
// Visual-only feedback (VoiceOver users miss this)
withAnimation(.bouncy) {
isLiked.toggle()
}
// With announcement
withAnimation(.bouncy) {
isLiked.toggle()
}
AccessibilityNotification.Announcement(isLiked ? "Added to favorites" : "Removed from favorites").post()
```
### Animation Blocking Interaction
Animations that block touch interaction (e.g., a loading overlay that animates in) must be announced to VoiceOver so users understand why controls became unresponsive.
### Focus Management After Transition
After animated navigation transitions, verify VoiceOver focus moves to the appropriate element in the new view.
```swift
.accessibilityFocused($isFocused)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isFocused = true
}
}
```
## Dynamic Type Considerations
Animations involving layout (position, size, offset) may need adjustment for larger text sizes. Fixed pixel offsets that look right at default text size may be too small or too large at accessibility sizes.
```swift
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
// Scale animation distances with type size
let slideDistance: CGFloat = dynamicTypeSize.isAccessibilitySize ? 40 : 20
```
## Prefer Cross-Dissolve for Reduce Motion
When in doubt about what to use as a Reduce Motion replacement, a simple opacity cross-dissolve at 0.15–0.2s is almost always appropriate. It communicates change without triggering vestibular response.
## Critical Anti-Patterns
| Pattern | Issue |
|---------|-------|
| No `@Environment(\.accessibilityReduceMotion)` with spring/bounce | Motion-sensitive users see full animation |
| Reduce Motion check only at top level | Individual animated components also need checks |
| Removing all animation with Reduce Motion | Over-correction — keep fades and instant transitions |
| Animated state change with no VoiceOver announcement | VoiceOver users miss the state change |
| Fixed offset values that don't scale with Dynamic Type | Animation looks wrong at accessibility sizes |
## Review Questions
1. Does every spring/bounce/movement animation check `accessibilityReduceMotion`?
2. Is the Reduce Motion fallback appropriate (crossfade, not just removal of all animation)?
3. Are animated state changes communicated to VoiceOver via announcements?
4. Do gesture-driven animations still work correctly with VoiceOver (or provide an accessible alternative)?
5. Are fixed animation distances reasonable at larger Dynamic Type sizes?
FILE:references/performance.md
# Animation Performance
## Frame Budget
iOS targets 60fps (16.67ms per frame) or 120fps on ProMotion devices (8.33ms). Animations that exceed the frame budget cause visible hitches — dropped frames where the UI visibly stutters.
## Offscreen Rendering Triggers
These properties force the GPU to render to an offscreen buffer before compositing, doubling the rendering cost:
| Property | When Expensive |
|----------|---------------|
| `cornerRadius` + `masksToBounds` | Always — clips content to rounded rect |
| `shadow` without explicit `shadowPath` | Every frame — system must compute shadow from layer alpha |
| `.blur()` in SwiftUI | Always — Gaussian blur is computationally expensive |
| `mask` / `.mask()` modifier | Always — requires compositing pass |
| `shouldRasterize = true` on changing layer | Cache invalidation every frame defeats the purpose |
### Shadow Path Fix
```swift
// BAD — shadow path recalculated every frame
layer.shadowOpacity = 0.3
layer.shadowRadius = 10
// No shadowPath set
// GOOD — explicit path, cached by GPU
layer.shadowPath = UIBezierPath(roundedRect: layer.bounds, cornerRadius: 12).cgPath
```
In SwiftUI, `.shadow()` doesn't expose path control. For animated shadows in lists, consider using a separate `RoundedRectangle` with `.shadow()` behind the content rather than applying shadow to the content itself.
## Animations in Scroll Views and Lists
Per-item animations in scrolling containers are the most common source of hitches.
```swift
// BAD — blur per cell in a list
ForEach(items) { item in
ItemView(item: item)
.blur(radius: 5) // Offscreen render per cell, every frame while scrolling
}
// BAD — shadow without path per cell
ForEach(items) { item in
ItemView(item: item)
.shadow(radius: 10) // Recomputed per cell
}
// GOOD — use opacity or overlay for visual depth instead
ForEach(items) { item in
ItemView(item: item)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.shadow(.drop(radius: 5)))
)
}
```
## View Identity and Animation Cost
Changing a view's structural identity (`.id()` modifier, conditional `if/else`) creates a new view from scratch. This is far more expensive than animating an existing view's properties.
```swift
// EXPENSIVE — destroys and recreates view
if isExpanded {
ExpandedView().id("expanded")
} else {
CompactView().id("compact")
}
// CHEAPER — animate properties of the same view
ContentView()
.frame(height: isExpanded ? 400 : 100)
.animation(.spring(), value: isExpanded)
```
When you must swap views (different content), use `transition` instead of relying on implicit animation of `id` changes.
## geometryGroup() for Layout Anomalies
When a parent's geometry changes (position, size) are animated and new child views appear during that animation, the children can render at incorrect positions. `geometryGroup()` fixes this by resolving the parent's geometry before passing it to children.
```swift
// Without geometryGroup — children may appear at wrong position during parent animation
VStack {
if showContent {
ContentView() // May animate from wrong origin
}
}
.frame(height: expanded ? 400 : 200)
.animation(.spring(), value: expanded)
// With geometryGroup — parent geometry resolved first
VStack {
if showContent {
ContentView()
}
}
.frame(height: expanded ? 400 : 200)
.geometryGroup()
.animation(.spring(), value: expanded)
```
## drawingGroup() for Complex View Hierarchies
Flattens a SwiftUI view hierarchy into a single Metal-rendered layer. Reduces compositing overhead for complex but static-ish view trees.
```swift
// Complex animated overlay — flatten to single texture
ZStack {
ForEach(particles) { particle in
Circle()
.fill(particle.color)
.frame(width: particle.size)
.offset(particle.offset)
}
}
.drawingGroup() // Rendered as single Metal texture
```
Use when: many overlapping views with shared animation. Don't use when: views need independent hit testing or accessibility.
## Looping Animation Cost
`PhaseAnimator` without a trigger, `.symbolEffect(.pulse)`, and `repeatForever` animations run continuously. Each consumes render cycles even when the view isn't visible (unless the view is removed from the hierarchy).
```swift
// This runs forever, even if scrolled off screen in a LazyVStack
// (LazyVStack keeps some buffer views alive)
.phaseAnimator([false, true]) { content, phase in
content.opacity(phase ? 1 : 0.5)
}
```
For items in scroll views, prefer triggered animations (`.symbolEffect(.bounce, value: trigger)`) over continuous ones.
## Profiling Tools
| Tool | What It Shows |
|------|---------------|
| Instruments → Animation Hitches | Frame drops with call stacks |
| Instruments → Core Animation | FPS, offscreen rendering, blending |
| Debug → Color Blended Layers | Green = normal, red = blended (expensive) |
| Debug → Color Offscreen-Rendered | Yellow highlight on offscreen renders |
| Xcode GPU Report | Frame time breakdown |
## Critical Anti-Patterns
| Pattern | Issue |
|---------|-------|
| `.blur()` in ForEach/List | Offscreen render per cell |
| Shadow without `shadowPath` | Path recalculated every frame |
| Continuous `PhaseAnimator` in scroll cells | Battery drain, hitch source |
| Animating `.id()` change | View recreation instead of property animation |
| `shouldRasterize` on frequently changing layers | Cache invalidation overhead |
| `drawingGroup()` on interactive views | Breaks hit testing and accessibility |
## Review Questions
1. Are blur, shadow, or mask applied to views inside a scroll view or list?
2. Do shadow layers have an explicit `shadowPath`?
3. Are looping animations (PhaseAnimator, repeatForever) appropriate for this context — or should they be triggered?
4. Could `geometryGroup()` fix layout anomalies where children appear at wrong positions during parent geometry animation?
5. Are expensive animations profiled with Instruments Animation Hitches template?
FILE:references/swiftui-animation-patterns.md
# SwiftUI Animation Patterns
## withAnimation Scope Issues
`withAnimation` should wrap only the state mutation that drives the animation, not unrelated logic.
```swift
// BAD — animation wraps network call and unrelated state
withAnimation {
Task { await viewModel.fetchData() }
selectedTab = .home
showBanner = false
}
// GOOD — only the visual state change is animated
Task { await viewModel.fetchData() }
withAnimation(.snappy) {
selectedTab = .home
}
showBanner = false
```
## Deprecated .animation() Without Value
The parameterless `.animation(_:)` modifier is deprecated. It animates every state change in the view, causing unexpected animations on unrelated properties.
```swift
// BAD — deprecated, animates everything
Text(name)
.animation(.spring())
// GOOD — explicit value binding
Text(name)
.animation(.spring(), value: isExpanded)
```
## Spring Parameter Anti-Patterns
| Pattern | Problem |
|---------|---------|
| `.spring(mass:stiffness:damping:)` in SwiftUI | Old API — use `duration`/`bounce` instead |
| `duration: 0` on a spring | Undefined behavior — use `.none` for instant |
| `bounce: 1.0` or higher | Extremely bouncy, likely unintentional — values above 0.5 are unusual |
| `.easeInOut(duration: 0.25)` for interactive feedback | Springs feel more natural for user interactions |
## PhaseAnimator Issues
### Infinite Loop Without Trigger
`PhaseAnimator` without a `trigger` parameter loops continuously. This is intentional for ambient animations but a bug if used for one-shot effects.
```swift
// This loops forever — intentional?
.phaseAnimator([false, true]) { content, phase in
content.opacity(phase ? 1 : 0.5)
}
// One-shot: add trigger
.phaseAnimator([false, true], trigger: tapCount) { content, phase in
content.opacity(phase ? 1 : 0.5)
}
```
### Single Phase
A `PhaseAnimator` with only one phase does nothing.
```swift
// BUG — needs at least 2 phases
.phaseAnimator([Phase.idle]) { /* never transitions */ }
```
## KeyframeAnimator Issues
### Mismatched Track Properties
Keyframe tracks must reference properties that exist on the `initialValue` type.
```swift
// BUG — if AnimationValues doesn't have .blur, this crashes
KeyframeTrack(\.blur) { ... }
```
### Missing Duration on Keyframes
Every keyframe needs a `duration`. Omitting it defaults to 0, making the keyframe instant.
```swift
// BAD — instant jump, probably unintentional
SpringKeyframe(1.5)
// GOOD — explicit duration
SpringKeyframe(1.5, duration: 0.3)
```
## Content Transition Misuse
`.contentTransition` is for content changes within a view, not view insertion/removal.
```swift
// BAD — contentTransition on a conditional view
if isVisible {
Text("Hello")
.contentTransition(.opacity) // Wrong — use .transition()
}
// GOOD — for content that changes in place
Text(count, format: .number)
.contentTransition(.numericText())
```
## Symbol Effect on Non-SF-Symbols
`.symbolEffect` only works with SF Symbols (system images). Applied to custom images, it silently does nothing.
```swift
// No effect — custom image, not SF Symbol
Image("custom-icon")
.symbolEffect(.bounce, value: count)
// Works — SF Symbol
Image(systemName: "bell.badge")
.symbolEffect(.bounce, value: count)
```
## Animation Identity vs. Transition
Animating the `.id()` modifier destroys and recreates the view. This is almost never what you want for smooth animation.
```swift
// BAD — destroys view identity, no smooth animation
Text(name)
.id(currentUser.id) // View is destroyed and recreated on change
.animation(.spring(), value: currentUser.id)
// GOOD — transition for view swap
Text(name)
.transition(.opacity)
.id(currentUser.id)
// GOOD — matchedGeometryEffect for position/size animation
Text(name)
.matchedGeometryEffect(id: "title", in: namespace)
```
## Critical Anti-Patterns
| Pattern | Issue |
|---------|-------|
| `.animation()` without `value:` | Deprecated, animates all state changes unpredictably |
| `withAnimation` wrapping `Task { }` | Async work doesn't need animation wrapping |
| `PhaseAnimator` with 1 phase | Does nothing — needs ≥ 2 phases to transition |
| `.symbolEffect` on custom images | Silently fails — only works with SF Symbols |
| Animating `.id()` modifier changes | Destroys view — use `transition` or `matchedGeometryEffect` |
| `spring(bounce: > 0.5)` | Excessively bouncy — verify this is intentional |
## Review Questions
1. Is `.animation()` always paired with a `value:` parameter?
2. Does `withAnimation` scope include only the state mutation, not side effects?
3. Are `PhaseAnimator` phase counts ≥ 2 and triggered appropriately (continuous vs. one-shot)?
4. Are `KeyframeAnimator` track properties matching the `initialValue` struct?
5. Is `.symbolEffect` only applied to SF Symbols?
FILE:references/transitions.md
# Transition Review Patterns
## matchedGeometryEffect Issues
### Duplicate IDs in Same Namespace
Only one view with a given ID should be in the view hierarchy at a time. Two visible views sharing an ID causes undefined geometry matching.
```swift
// BUG — both views visible with same ID
ZStack {
SourceView()
.matchedGeometryEffect(id: "card", in: namespace)
.opacity(isExpanded ? 0 : 1) // Opacity 0 still counts as "in hierarchy"
DestinationView()
.matchedGeometryEffect(id: "card", in: namespace)
.opacity(isExpanded ? 1 : 0)
}
// GOOD — conditional, only one in hierarchy
if isExpanded {
DestinationView()
.matchedGeometryEffect(id: "card", in: namespace)
} else {
SourceView()
.matchedGeometryEffect(id: "card", in: namespace)
}
```
### Unstable IDs
The matched ID must be stable across the transition. Using array indices or computed values that change during the animation breaks the match.
```swift
// BAD — index changes when array mutates
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
ItemView(item: item)
.matchedGeometryEffect(id: index, in: namespace) // Index shifts on delete
}
// GOOD — stable model ID
ForEach(items) { item in
ItemView(item: item)
.matchedGeometryEffect(id: item.id, in: namespace)
}
```
### Missing isSource
When matching position and size separately (rare but valid), one side must be `isSource: true` and the other `isSource: false`. When matching both together (common case), both default to `isSource: true` which works correctly.
### Namespace Scope
`@Namespace` must be declared in the common ancestor view that contains both the source and destination. Passing namespaces between unrelated view hierarchies doesn't work.
## Custom Transition Protocol
### TransitionPhase Misunderstanding
`TransitionPhase` has three cases. `.willAppear` is the "before" state for insertion, `.didDisappear` is the "after" state for removal, and `.identity` is the normal presented state.
```swift
// BUG — this makes the view invisible in its identity state
struct BrokenTransition: Transition {
func body(content: Content, phase: TransitionPhase) -> some View {
content
.opacity(phase == .willAppear ? 0 : 1)
// Removal: .didDisappear gets opacity 1, then view disappears without fade
}
}
// GOOD — both insertion and removal handled
struct CorrectTransition: Transition {
func body(content: Content, phase: TransitionPhase) -> some View {
content
.opacity(phase.isIdentity ? 1 : 0) // 0 for both willAppear and didDisappear
.scaleEffect(phase.isIdentity ? 1 : 0.8)
}
}
```
### Asymmetric Behavior
If insertion and removal should look different, use `.asymmetric()` or check the specific phase.
```swift
struct AsymmetricSlide: Transition {
func body(content: Content, phase: TransitionPhase) -> some View {
content
.offset(
x: phase == .willAppear ? 100 : // Enter from right
phase == .didDisappear ? -100 : // Exit to left
0 // Identity
)
.opacity(phase.isIdentity ? 1 : 0)
}
}
```
## Zoom Navigation Transition (iOS 18+)
### Missing matchedTransitionSource
`.navigationTransition(.zoom(sourceID:in:))` on the destination requires `.matchedTransitionSource(id:in:)` on the source. Without it, the zoom has no origin point and falls back to a standard push.
```swift
// BUG — missing source
NavigationLink {
DetailView()
.navigationTransition(.zoom(sourceID: item.id, in: namespace))
} label: {
ItemCell()
// No .matchedTransitionSource — zoom won't work
}
// GOOD
NavigationLink {
DetailView()
.navigationTransition(.zoom(sourceID: item.id, in: namespace))
} label: {
ItemCell()
.matchedTransitionSource(id: item.id, in: namespace)
}
```
### ID Mismatch
The `sourceID` in `.navigationTransition(.zoom)` must exactly match the `id` in `.matchedTransitionSource`. Type must also match (both String, both Int, etc.).
### Outside NavigationStack
Zoom transitions require `NavigationStack`. They don't work with the deprecated `NavigationView` or with custom navigation implementations.
## View Transition Timing
### Missing Animation Wrapper
Conditional view changes need `withAnimation` or `.animation(_:value:)` to animate transitions.
```swift
// BUG — no animation, views snap in/out
if showDetail {
DetailView()
.transition(.slide) // Transition defined but never animated
}
// GOOD — wrapped in animation
Button("Show") {
withAnimation(.spring()) {
showDetail = true
}
}
if showDetail {
DetailView()
.transition(.slide)
}
```
### Transition on Wrong View
`.transition()` must be on the view being inserted/removed, not the parent.
```swift
// BAD — transition on container, not on the conditional view
VStack {
if showBanner {
BannerView()
}
}
.transition(.slide) // Does nothing — VStack is always present
// GOOD — transition on the conditional view
VStack {
if showBanner {
BannerView()
.transition(.slide)
}
}
```
## NavigationStack Path Animation
Programmatic navigation changes should be wrapped in `withAnimation` for smooth transitions.
```swift
// No animation — instant view swap
path.append(destination)
// Animated
withAnimation(.smooth) {
path.append(destination)
}
```
## Critical Anti-Patterns
| Pattern | Issue |
|---------|-------|
| Two views with same matchedGeometryEffect ID visible simultaneously | Undefined geometry matching |
| `matchedGeometryEffect` with array index as ID | ID shifts when array mutates |
| `.transition()` on a view that's always in the hierarchy | No effect — transitions only fire on insertion/removal |
| `.navigationTransition(.zoom)` without `.matchedTransitionSource` | Falls back to standard push, no zoom |
| Missing `withAnimation` around conditional view state change | Transition defined but never animated |
| Custom Transition that only handles `.willAppear` | Removal has no animation |
## Review Questions
1. For `matchedGeometryEffect` — is only one view with that ID in the hierarchy at a time?
2. Does the custom `Transition` handle both `.willAppear` and `.didDisappear` phases?
3. For zoom navigation — does every `.navigationTransition(.zoom)` have a matching `.matchedTransitionSource` with the same ID?
4. Is the view state change that triggers the transition wrapped in `withAnimation`?
5. Is `.transition()` placed on the conditional view, not its always-present parent?
Reviews HealthKit code for authorization patterns, query usage, background delivery, and data type handling. Use when reviewing code with import HealthKit, H...
---
name: healthkit-code-review
description: Reviews HealthKit code for authorization patterns, query usage, background delivery, and data type handling. Use when reviewing code with import HealthKit, HKHealthStore, HKSampleQuery, HKObserverQuery, or HKQuantityType.
---
# HealthKit Code Review
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| HKHealthStore, permissions, status checks, privacy | [references/authorization.md](references/authorization.md) |
| HKQuery types, predicates, anchored queries, statistics | [references/queries.md](references/queries.md) |
| Background delivery, observer queries, completion handlers | [references/background.md](references/background.md) |
| HKQuantityType, HKCategoryType, workouts, units | [references/data-types.md](references/data-types.md) |
## Review Checklist
- [ ] `HKHealthStore.isHealthDataAvailable()` called before any HealthKit operations
- [ ] Authorization requested only for needed data types (minimal permissions)
- [ ] `requestAuthorization` completion handler not misinterpreted as permission granted
- [ ] No attempt to determine read permission status (privacy by design)
- [ ] Query results dispatched to main thread for UI updates
- [ ] `HKObjectQueryNoLimit` used only with bounded predicates
- [ ] `HKStatisticsQuery` used for aggregations instead of manual summing
- [ ] Observer query `completionHandler()` always called (use `defer`)
- [ ] Background delivery registered in `application(_:didFinishLaunchingWithOptions:)`
- [ ] Background delivery entitlement added (iOS 15+)
- [ ] Correct units used for quantity types (e.g., `count/min` for heart rate)
- [ ] Long-running queries stored as properties and stopped in `deinit`
## When to Load References
- Reviewing authorization/permissions flow -> authorization.md
- Reviewing HKSampleQuery, HKAnchoredObjectQuery, or predicates -> queries.md
- Reviewing HKObserverQuery or `enableBackgroundDelivery` -> background.md
- Reviewing HKQuantityType, HKCategoryType, or HKWorkout -> data-types.md
## Review gates
Run in order. **Do not state a finding in a later step until the pass condition for the current step is satisfied** (each pass condition is answerable from the codebase under review).
1. **Scope** — **Pass:** Name the file path(s) and types/symbols using `HealthKit`, `HKHealthStore`, or `HK*` APIs (or state clearly that the diff touches none).
2. **Availability and store** — **Pass:** Cite the call site of `isHealthDataAvailable()` before HealthKit use, or document why omission is acceptable for the scoped code; cite where `HKHealthStore` is created or injected.
3. **Authorization semantics** — **Pass:** For each `requestAuthorization` / `getRequestStatusForAuthorization`, cite handler branches per [references/authorization.md](references/authorization.md) (e.g. success does not prove read access); do not infer read permission from `authorizationStatus` alone.
4. **Queries and limits** — **Pass:** For each query, cite predicate + limit (`HKObjectQueryNoLimit` only with a bounded predicate); for totals/aggregates, cite `HKStatisticsQuery` / collection vs manual summing per [references/queries.md](references/queries.md).
5. **Observers and background** — **Pass:** If `HKObserverQuery` or `enableBackgroundDelivery` appears, cite where the observer is started/stopped and where background delivery is registered; cite entitlements/Info.plist or flag missing config per [references/background.md](references/background.md). If absent, **Pass:** one line “no observer/background in scope.”
6. **Threading and lifecycle** — **Pass:** Cite main-queue (or documented pattern) for UI updates from query callbacks; cite retention/`stop()`/`deinit` for long-running queries per checklist above.
## Review Questions
1. Is `isHealthDataAvailable()` checked before creating HKHealthStore?
2. Does the code gracefully handle denied permissions (empty results)?
3. Are observer query completion handlers called in all code paths?
4. Is work in background handlers minimal (~15 second limit)?
5. Are HKQueryAnchors persisted per sample type (not shared)?
FILE:references/authorization.md
# HealthKit Authorization
## Authorization Model
HealthKit uses **per-data-type, separate read/write authorization**. Users grant access individually for each health data type.
### Required Setup
1. **Entitlement**: `com.apple.developer.healthkit` capability
2. **Info.plist Keys**:
- `NSHealthShareUsageDescription` - Why app needs to **read** health data
- `NSHealthUpdateUsageDescription` - Why app needs to **write** health data
### HKAuthorizationStatus (Write Only)
| Status | Meaning |
|--------|---------|
| `.notDetermined` | User hasn't been asked yet |
| `.sharingAuthorized` | User granted write access |
| `.sharingDenied` | User explicitly denied write access |
**Critical**: Read permission status is intentionally hidden for privacy.
### Key Methods
| Method | Purpose |
|--------|---------|
| `isHealthDataAvailable()` | Check device support (not on iPad pre-iPadOS 17) |
| `authorizationStatus(for:)` | Check **write** permission only |
| `getRequestStatusForAuthorization(toShare:read:)` | Check if auth sheet would appear |
| `requestAuthorization(toShare:read:)` | Request permissions from user |
## Critical Anti-Patterns
### 1. Misinterpreting requestAuthorization Success
```swift
// BAD: success does NOT mean permission granted
healthStore.requestAuthorization(toShare: types, read: types) { success, error in
if success {
self.startRecordingWorkout() // May fail if permission denied!
}
}
// GOOD: success means dialog flow completed
healthStore.requestAuthorization(toShare: types, read: types) { success, error in
if success {
// Check specific type for write operations
if self.healthStore.authorizationStatus(for: workoutType) == .sharingAuthorized {
self.startRecordingWorkout()
}
}
}
```
### 2. Checking Read Permission Status
```swift
// BAD: Cannot determine read permission status
let status = healthStore.authorizationStatus(for: heartRateType)
if status == .sharingAuthorized { // This only checks WRITE status!
displayHeartRateData()
}
// GOOD: Just attempt to fetch - empty results if denied
func fetchHeartRateData() async {
let results = try? await healthStore.execute(query)
// Handle empty results gracefully
}
```
### 3. Not Checking Device Availability
```swift
// BAD: HealthKit not available on iPad (pre-iPadOS 17)
func requestHealthKitAccess() {
healthStore.requestAuthorization(toShare: types, read: types) { _, _ in }
}
// GOOD: Always check availability first
func requestHealthKitAccess() {
guard HKHealthStore.isHealthDataAvailable() else {
showHealthKitNotAvailableMessage()
return
}
healthStore.requestAuthorization(toShare: types, read: types) { _, _ in }
}
```
### 4. Widgets Requesting Authorization
```swift
// BAD: Widgets cannot present authorization UI
struct HealthWidget: Widget {
func getTimeline(...) {
healthStore.requestAuthorization(...) // Will silently fail
}
}
// GOOD: Use getRequestStatusForAuthorization in widgets
struct HealthWidget: Widget {
func getTimeline(...) {
let status = try? await healthStore.statusForAuthorizationRequest(toShare: [], read: types)
if status == .shouldRequest {
showOpenAppPrompt()
} else {
displayHealthData() // May be empty if denied
}
}
}
```
### 5. Accessing Data Before Authorization Completes
```swift
// BAD: Race condition (HKError code 5)
func initialize() {
healthStore.requestAuthorization(toShare: types, read: types) { _, _ in }
fetchHealthData() // Called before authorization completes!
}
// GOOD: Wait for authorization
func initialize() async {
do {
try await healthStore.requestAuthorization(toShare: types, read: types)
await fetchHealthData()
} catch {
handleAuthorizationError(error)
}
}
```
## HKError Codes
| Code | Constant | Meaning |
|------|----------|---------|
| - | `.errorAuthorizationDenied` | User denied permission |
| - | `.errorAuthorizationNotDetermined` | App hasn't requested yet |
| 4 | - | Missing HealthKit entitlement |
| 5 | - | Transaction failed (often premature data access) |
## Review Questions
1. Is `isHealthDataAvailable()` called before any HealthKit operations?
2. Is the `requestAuthorization` completion handler correctly interpreted?
3. Is there any attempt to determine read permission status? (anti-pattern)
4. Are all Info.plist usage description keys present with meaningful text?
5. Do widgets avoid calling `requestAuthorization`?
6. Is authorization status re-checked before write operations (not cached)?
FILE:references/background.md
# HealthKit Background Delivery
## Overview
Background delivery allows apps to receive HealthKit updates without user launching the app. Requires:
1. **`enableBackgroundDelivery(for:frequency:)`** - Register for notifications
2. **`HKObserverQuery`** - Long-running query that monitors changes
## Required Configuration
### Entitlements (iOS 15+/Xcode 13+)
```xml
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.background-delivery</key>
<true/>
```
### Capabilities
- HealthKit > Background Delivery
- Background Modes > Background Processing
## Update Frequencies
| Frequency | Behavior | Reality |
|-----------|----------|---------|
| `.immediate` | Wake on every change | Some types enforce hourly max (stepCount) |
| `.hourly` | At most once per hour | iOS may defer based on battery/CPU |
| `.daily` | At most once per day | Advisory, not guaranteed |
| `.weekly` | At most once per week | Advisory, not guaranteed |
**iOS has full discretion** to defer based on CPU, battery, connectivity, Low Power Mode.
## Background Execution Constraints
- **Time limit**: ~15 seconds for simple queries
- **3 strikes rule**: After 3 failed completions, delivery stops
- **watchOS budget**: 4 updates/hour (shared with WKApplicationRefreshBackgroundTask)
## Required Setup Pattern
```swift
// AppDelegate.swift - MUST be in didFinishLaunchingWithOptions
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [...]) -> Bool {
setupHealthKitBackgroundDelivery()
return true
}
func setupHealthKitBackgroundDelivery() {
let stepType = HKQuantityType(.stepCount)
// 1. Create observer query
let query = HKObserverQuery(sampleType: stepType, predicate: nil) {
query, completionHandler, error in
defer { completionHandler() } // MUST always call
guard error == nil else { return }
self.fetchNewData() // Keep minimal - 15 sec limit
}
// 2. Execute query
healthStore.execute(query)
// 3. Enable background delivery
healthStore.enableBackgroundDelivery(for: stepType, frequency: .immediate) { _, _ in }
}
```
## Critical Anti-Patterns
### 1. Not Calling completionHandler
```swift
// BAD: completionHandler not called on error path
let query = HKObserverQuery(...) { query, completionHandler, error in
if let error = error {
print("Error: \(error)")
return // completionHandler not called!
}
self.fetchData()
completionHandler()
}
// GOOD: Use defer to ensure it's always called
let query = HKObserverQuery(...) { query, completionHandler, error in
defer { completionHandler() } // Always called
guard error == nil else {
print("Error: \(error!)")
return
}
self.fetchData()
}
```
### 2. Not Re-registering on App Launch
```swift
// BAD: Registering only once
class HealthManager {
private var hasRegistered = false
func setupBackgroundDelivery() {
guard !hasRegistered else { return } // Won't work after app update!
hasRegistered = true
// ...
}
}
// GOOD: Always register in didFinishLaunchingWithOptions
// Registration must happen every app launch
func application(_ app: UIApplication, didFinishLaunchingWithOptions: [...]) -> Bool {
healthManager.setupBackgroundDelivery() // Called every launch
return true
}
```
### 3. Local Variable Query Gets Deallocated
```swift
// BAD: Query goes out of scope
func setupObserver() {
let query = HKObserverQuery(...)
healthStore.execute(query)
} // query deallocated!
// GOOD: Store as property
class HealthManager {
private var observerQueries: [HKObserverQuery] = []
func setupObserver() {
let query = HKObserverQuery(...)
observerQueries.append(query) // Keep reference
healthStore.execute(query)
}
}
```
### 4. Assuming Callback Means New Data
```swift
// BAD: Callback fires even without data changes
let query = HKObserverQuery(...) { query, completionHandler, error in
defer { completionHandler() }
self.processNewData() // May process nothing - called on foreground too
}
// GOOD: Use HKAnchoredObjectQuery to get actual changes
func fetchChanges(completion: @escaping () -> Void) {
let query = HKAnchoredObjectQuery(type: type, anchor: savedAnchor, ...) {
query, samples, deleted, newAnchor, error in
defer { completion() }
guard let samples = samples, let newAnchor = newAnchor else { return }
self.anchor = newAnchor // Persist
// Only process actual new samples
self.process(samples: samples, deleted: deleted ?? [])
}
healthStore.execute(query)
}
```
### 5. Long-Running Work in Background Handler
```swift
// BAD: May exceed 15-second time limit
let query = HKObserverQuery(...) { query, completionHandler, error in
self.downloadFromServer()
self.processAllHistoricalData()
self.syncToCloudKit()
completionHandler()
}
// GOOD: Minimal work, schedule full sync for later
let query = HKObserverQuery(...) { query, completionHandler, error in
defer { completionHandler() } // Complete quickly
// Just fetch latest
self.fetchLatestSample { sample in
self.pendingSamples.append(sample)
// Schedule full sync when app is active
DispatchQueue.main.async {
if UIApplication.shared.applicationState == .active {
self.performFullSync()
}
}
}
}
```
### 6. Missing Required Entitlement
```xml
<!-- BAD: Missing background delivery entitlement -->
<key>com.apple.developer.healthkit</key>
<true/>
<!-- GOOD: All required entitlements -->
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.background-delivery</key>
<true/>
```
## Review Questions
1. Is `enableBackgroundDelivery` called in `application(_:didFinishLaunchingWithOptions:)`?
2. Are all required entitlements configured (especially iOS 15+)?
3. Are observer queries stored as instance properties?
4. Is `completionHandler()` called in ALL code paths?
5. Is work in the observer callback minimal (~15 seconds)?
6. Does code handle that callbacks may fire without actual data changes?
7. Are anchors persisted to track processed data?
8. Is the update frequency appropriate for the data type?
FILE:references/data-types.md
# HealthKit Data Types
## Data Type Hierarchy
| Type | Class | Purpose |
|------|-------|---------|
| Quantity | `HKQuantityType` / `HKQuantitySample` | Measurable numeric values with units |
| Category | `HKCategoryType` / `HKCategorySample` | Enumerated categorical values |
| Correlation | `HKCorrelationType` / `HKCorrelation` | Groups of related samples |
| Characteristic | `HKCharacteristicType` | Static, immutable user data |
| Workout | `HKWorkout` / `HKWorkoutBuilder` | Exercise sessions |
## HKQuantityType - Measurable Data
Common quantity types and their units:
| Type | Unit |
|------|------|
| `.stepCount` | `.count()` |
| `.heartRate` | `HKUnit(from: "count/min")` |
| `.distanceWalkingRunning` | `.meter()` |
| `.activeEnergyBurned` | `.kilocalorie()` |
| `.bloodPressureSystolic` | `.millimeterOfMercury()` |
| `.oxygenSaturation` | `.percent()` |
### Creating HKQuantitySample
```swift
// 1. Get quantity type
guard let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount) else { return }
// 2. Create quantity with unit
let quantity = HKQuantity(unit: .count(), doubleValue: 10000)
// 3. Create sample with metadata
let metadata: [String: Any] = [
HKMetadataKeyTimeZone: TimeZone.current.identifier,
HKMetadataKeyWasUserEntered: false
]
let sample = HKQuantitySample(type: stepType, quantity: quantity,
start: startDate, end: endDate, metadata: metadata)
// 4. Save
healthStore.save(sample) { success, error in }
```
## HKCategoryType - Enumerated Data
### Sleep Analysis (iOS 16+)
```swift
let sleepType = HKCategoryType(.sleepAnalysis)
// Create both in-bed and asleep samples
let inBedSample = HKCategorySample(
type: sleepType,
value: HKCategoryValueSleepAnalysis.inBed.rawValue,
start: bedTime, end: wakeTime
)
let asleepSample = HKCategorySample(
type: sleepType,
value: HKCategoryValueSleepAnalysis.asleepCore.rawValue, // iOS 16+
start: fallAsleepTime, end: actualWakeTime
)
healthStore.save([inBedSample, asleepSample]) { _, _ in }
```
## HKCorrelation - Blood Pressure
Blood pressure requires a correlation with both systolic and diastolic:
```swift
// Create systolic sample
let systolicType = HKQuantityType(.bloodPressureSystolic)
let systolicSample = HKQuantitySample(
type: systolicType,
quantity: HKQuantity(unit: .millimeterOfMercury(), doubleValue: 120),
start: date, end: date
)
// Create diastolic sample
let diastolicType = HKQuantityType(.bloodPressureDiastolic)
let diastolicSample = HKQuantitySample(
type: diastolicType,
quantity: HKQuantity(unit: .millimeterOfMercury(), doubleValue: 80),
start: date, end: date
)
// Create correlation
let bpType = HKCorrelationType(.bloodPressure)
let bpCorrelation = HKCorrelation(
type: bpType, start: date, end: date,
objects: [systolicSample, diastolicSample]
)
```
**Important**: Request permissions on systolic/diastolic types, NOT the correlation type.
## HKCharacteristicType - Static Data
Read-only characteristics set by user in Health app:
```swift
do {
let biologicalSex = try healthStore.biologicalSex().biologicalSex
switch biologicalSex {
case .female, .male, .other: // handle
case .notSet: // user hasn't set
@unknown default: break
}
} catch {
// Permission denied or not set
}
```
## HKWorkoutBuilder Pattern
```swift
let config = HKWorkoutConfiguration()
config.activityType = .running
config.locationType = .outdoor
let builder = HKWorkoutBuilder(healthStore: healthStore, configuration: config, device: .local())
try await builder.beginCollection(at: startDate)
// Add samples during workout
try await builder.addSamples([heartRateSample, distanceSample])
// Finish
try await builder.endCollection(at: endDate)
let workout = try await builder.finishWorkout()
```
## Critical Anti-Patterns
### 1. Wrong Units for Quantity Types
```swift
// BAD: Incompatible units
let heartRate = HKQuantity(unit: .count(), doubleValue: 72) // Wrong!
let distance = HKQuantity(unit: .kilocalorie(), doubleValue: 5000) // Wrong!
// GOOD: Correct compatible units
let heartRate = HKQuantity(unit: HKUnit(from: "count/min"), doubleValue: 72)
let distance = HKQuantity(unit: .meter(), doubleValue: 5000)
```
### 2. Force Unwrapping Quantity Types
```swift
// BAD: Can crash
let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
// GOOD: Safe unwrapping
guard let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount) else {
print("Step count type unavailable")
return
}
```
### 3. Requesting Correlation Type Permission
```swift
// BAD: Cannot request permission for correlation types
let bpType = HKCorrelationType(.bloodPressure)
let typesToRead: Set<HKObjectType> = [bpType] // Will fail!
// GOOD: Request underlying quantity types
let systolicType = HKQuantityType(.bloodPressureSystolic)
let diastolicType = HKQuantityType(.bloodPressureDiastolic)
let typesToRead: Set<HKObjectType> = [systolicType, diastolicType]
```
### 4. Not Including Time Zone Metadata
```swift
// BAD: No time zone
let sample = HKQuantitySample(type: type, quantity: quantity, start: start, end: end)
// GOOD: Include time zone
let metadata: [String: Any] = [HKMetadataKeyTimeZone: TimeZone.current.identifier]
let sample = HKQuantitySample(type: type, quantity: quantity,
start: start, end: end, metadata: metadata)
```
### 5. Reading Characteristics Without Error Handling
```swift
// BAD: Force try
let sex = try! healthStore.biologicalSex().biologicalSex
// GOOD: Handle errors
do {
let sex = try healthStore.biologicalSex().biologicalSex
// Check for .notSet
} catch {
// Handle permission denied or not set
}
```
### 6. Only Creating InBed Sleep Sample
```swift
// BAD: Missing actual sleep sample
let inBedSample = HKCategorySample(type: sleepType,
value: HKCategoryValueSleepAnalysis.inBed.rawValue, ...)
// No asleep sample!
// GOOD: Create both
let inBedSample = HKCategorySample(type: sleepType,
value: HKCategoryValueSleepAnalysis.inBed.rawValue,
start: bedTime, end: wakeTime)
let asleepSample = HKCategorySample(type: sleepType,
value: HKCategoryValueSleepAnalysis.asleepCore.rawValue,
start: sleepTime, end: wakeTime)
healthStore.save([inBedSample, asleepSample]) { _, _ in }
```
## Unit Conversion
```swift
// HealthKit handles conversion automatically
let distanceInMeters = sample.quantity.doubleValue(for: .meter())
let distanceInMiles = sample.quantity.doubleValue(for: .mile())
// User preferred units
healthStore.preferredUnits(for: [stepType]) { units, error in
let preferredUnit = units[stepType]
}
```
## Review Questions
1. Is `isHealthDataAvailable()` checked before creating HKHealthStore?
2. Are quantity types used with compatible units?
3. Are correlation types (blood pressure) created with all required sub-samples?
4. Are permissions requested on underlying types, not correlation types?
5. Are characteristics read with proper error handling for `.notSet`?
6. Is metadata included (time zone, sync identifier)?
7. Are sleep samples created correctly (both inBed and asleep)?
8. Is `HKWorkoutBuilder` used instead of deprecated HKWorkout init?
FILE:references/queries.md
# HealthKit Queries
## Query Types Overview
| Query Type | Use Case | Long-Running | Returns Deletions |
|------------|----------|--------------|-------------------|
| `HKSampleQuery` | One-time snapshot | No | No |
| `HKAnchoredObjectQuery` | Incremental sync, change tracking | Yes (with updateHandler) | Yes |
| `HKStatisticsQuery` | Single aggregation (sum, avg, min, max) | No | N/A |
| `HKStatisticsCollectionQuery` | Time-series aggregations | Yes | N/A |
| `HKActivitySummaryQuery` | Activity rings data | Yes (optional) | No |
## HKSampleQuery
Basic one-time fetch with sorting:
```swift
let query = HKSampleQuery(
sampleType: sampleType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)]
) { (query, samples, error) in
DispatchQueue.main.async {
// Handle results - update UI
}
}
healthStore.execute(query)
```
## HKAnchoredObjectQuery
For incremental sync with deletion tracking:
```swift
let query = HKAnchoredObjectQuery(
type: sampleType,
predicate: predicate,
anchor: savedAnchor, // nil for first fetch, persisted for subsequent
limit: HKObjectQueryNoLimit
) { (query, samples, deletedObjects, newAnchor, error) in
// Process samples AND deletedObjects
self.saveAnchor(newAnchor) // Persist for next query
}
// Optional: continuous monitoring
query.updateHandler = { (query, samples, deleted, newAnchor, error) in }
healthStore.execute(query)
```
**Important**: HKQueryAnchor cannot be reused across different sample types.
## HKStatisticsQuery
For aggregations - use correct options per data type:
| Data Type | Valid Options |
|-----------|---------------|
| Cumulative (steps, distance) | `.cumulativeSum` |
| Discrete (weight, heart rate) | `.discreteAverage`, `.discreteMin`, `.discreteMax` |
```swift
let query = HKStatisticsQuery(
quantityType: stepCountType,
quantitySamplePredicate: predicate,
options: .cumulativeSum // Use .discreteAverage for body mass
) { (query, statistics, error) in
let sum = statistics?.sumQuantity()?.doubleValue(for: .count())
}
```
## Predicate Building
```swift
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: endDate,
options: [.strictStartDate, .strictEndDate]
)
// .strictStartDate: Sample start >= startDate
// .strictEndDate: Sample end <= endDate
// [] (empty): Sample overlaps with range
```
## Critical Anti-Patterns
### 1. HKObjectQueryNoLimit Without Predicates
```swift
// BAD: May fetch millions of samples - memory exhaustion
let query = HKSampleQuery(
sampleType: stepCountType,
predicate: nil,
limit: HKObjectQueryNoLimit,
sortDescriptors: nil
) { ... }
// GOOD: Always bound with predicates
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate)
let query = HKSampleQuery(
sampleType: stepCountType,
predicate: predicate,
limit: 1000, // Safety net
sortDescriptors: [...]
) { ... }
```
### 2. Manual Summing Instead of Statistics Query
```swift
// BAD: Inefficient for cumulative data
let query = HKSampleQuery(...) { query, samples, error in
var total = 0.0
for sample in samples as? [HKQuantitySample] ?? [] {
total += sample.quantity.doubleValue(for: .count())
}
}
// GOOD: Use HKStatisticsQuery
let query = HKStatisticsQuery(
quantityType: stepCountType,
quantitySamplePredicate: predicate,
options: .cumulativeSum
) { query, statistics, error in
let total = statistics?.sumQuantity()?.doubleValue(for: .count())
}
```
### 3. Not Handling Deleted Samples
```swift
// BAD: Ignoring deletions in anchored query
let query = HKAnchoredObjectQuery(...) { query, samples, deletedObjects, newAnchor, error in
for sample in samples ?? [] {
self.syncToServer(sample)
}
// Missing deletion handling!
}
// GOOD: Process both additions and deletions
let query = HKAnchoredObjectQuery(...) { query, samples, deletedObjects, newAnchor, error in
for sample in samples ?? [] {
self.syncToServer(sample)
}
for deleted in deletedObjects ?? [] {
self.deleteFromServer(deleted.uuid) // Critical for data integrity
}
self.saveAnchor(newAnchor)
}
```
### 4. Reusing Anchors Across Sample Types
```swift
// BAD: Single anchor for all types
var anchor: HKQueryAnchor?
func syncWorkouts() { HKAnchoredObjectQuery(type: workoutType, anchor: anchor, ...) }
func syncSteps() { HKAnchoredObjectQuery(type: stepType, anchor: anchor, ...) } // Wrong!
// GOOD: Separate anchor per type
var anchors: [String: HKQueryAnchor] = [:]
func sync(type: HKSampleType) {
let query = HKAnchoredObjectQuery(type: type, anchor: anchors[type.identifier], ...) {
query, samples, deleted, newAnchor, error in
self.anchors[type.identifier] = newAnchor
}
}
```
### 5. UI Updates on Background Thread
```swift
// BAD: Query handler runs on background thread
let query = HKSampleQuery(...) { query, samples, error in
self.tableView.reloadData() // Crash or undefined behavior
}
// GOOD: Dispatch to main thread
let query = HKSampleQuery(...) { query, samples, error in
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
```
### 6. Not Stopping Long-Running Queries
```swift
// BAD: Query never stopped - memory leak
class HealthVC: UIViewController {
var query: HKObserverQuery?
override func viewDidLoad() {
query = HKObserverQuery(...)
healthStore.execute(query!)
} // Query continues forever
}
// GOOD: Stop in deinit
class HealthVC: UIViewController {
var query: HKObserverQuery?
override func viewDidLoad() {
query = HKObserverQuery(...)
healthStore.execute(query!)
}
deinit {
if let query = query { healthStore.stop(query) }
}
}
```
## Review Questions
1. Is the correct query type used for the use case?
2. Are date predicates used to bound query results?
3. Are UI updates dispatched to the main thread?
4. Is `HKObjectQueryNoLimit` used with appropriate predicates?
5. Are deleted objects handled in anchored queries?
6. Are anchors persisted separately per sample type?
7. Are long-running queries stopped when no longer needed?
8. Are correct statistics options used for the data type?
Reviews Combine framework code for memory leaks, operator misuse, and error handling. Use when reviewing code with import Combine, AnyPublisher, @Published,...
---
name: combine-code-review
description: Reviews Combine framework code for memory leaks, operator misuse, and error handling. Use when reviewing code with import Combine, AnyPublisher, @Published, PassthroughSubject, or CurrentValueSubject.
---
# Combine Code Review
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| Publishers, Subjects, AnyPublisher | [references/publishers.md](references/publishers.md) |
| map, flatMap, combineLatest, switchToLatest | [references/operators.md](references/operators.md) |
| AnyCancellable, retain cycles, [weak self] | [references/memory.md](references/memory.md) |
| tryMap, catch, replaceError, Never | [references/error-handling.md](references/error-handling.md) |
## Review Checklist
- [ ] All `sink` closures use `[weak self]` when self owns cancellable
- [ ] No `assign(to:on:self)` usage (use `assign(to: &$property)` or sink)
- [ ] All AnyCancellables stored in Set or property (not discarded)
- [ ] Subjects exposed as `AnyPublisher` via `eraseToAnyPublisher()`
- [ ] `flatMap` used correctly (not when `map + switchToLatest` needed)
- [ ] Error handling inside `flatMap` to keep main chain alive
- [ ] `tryMap` followed by `mapError` to restore error types
- [ ] `receive(on: DispatchQueue.main)` before UI updates
- [ ] PassthroughSubject for events, CurrentValueSubject for state
- [ ] Future wrapped in Deferred when used with retry
## When to Load References
- Reviewing Subjects or publisher selection → publishers.md
- Reviewing operator chains or combining publishers → operators.md
- Reviewing subscriptions or memory issues → memory.md
- Reviewing error handling or try* operators → error-handling.md
## Hard gates (before you report findings)
Complete in order. Do not skip ahead while a prior gate is open.
1. **Scope** — **Pass:** You name at least one file or type under review that imports Combine or uses APIs from the Quick Reference (e.g. `AnyPublisher`, `@Published`, `PassthroughSubject`). If none apply, stop with “out of scope.”
2. **Subscription retention** — **Pass:** For each `sink`, `assign`, and `store(in:)` in scope, you state where the `AnyCancellable` is retained (property, `Set`, task lifetime) or mark **ephemeral** with a one-line reason (e.g. synchronous one-shot that cannot outlive caller). If you cannot tell from the snippet, say **unknown** and ask for surrounding storage, do not assume safe.
3. **Retain-cycle claim** — **Pass:** **Confirmed** leak findings state the capture chain (e.g. self → stored cancellable → closure strongly capturing self). Label suspected cases **risk** / **verify**, not confirmed leaks. When arguing safety, cite `[weak self]`, `[unowned self]`, or non-capturing patterns you relied on.
4. **UI / main thread** — **Pass:** For updates to UIKit/SwiftUI from a chain, you either point to `receive(on: DispatchQueue.main)`, `@MainActor`, or equivalent before the UI work, **or** flag missing scheduling with `file:line`.
5. **Severity and checklist** — **Pass:** Every **high** or **critical** item includes `file:line` (or exact pasted lines) and names which **Review Checklist** row it breaks. Lower-severity notes may omit line numbers but must still be reproducible from named files.
## Review Questions
1. Are all subscriptions being retained? (Check for discarded AnyCancellables)
2. Could any sink or assign create a retain cycle with self?
3. Does flatMap need to be switchToLatest for search/autocomplete?
4. What happens when this publisher fails? (Will it kill the main chain?)
5. Are error types preserved or properly mapped after try* operators?
FILE:references/error-handling.md
# Combine Error Handling
## Error Types in Combine
Every publisher declares `Publisher<Output, Failure>`. Unlike other reactive frameworks, Combine enforces error types at compile time.
### The `Never` Type
- `Failure == Never` means the publisher can never fail
- Required for `assign(to:on:)` - must convert failable publishers first
- Created by `replaceError(with:)` or `catch` with infallible fallback
### Converting Error Types
```swift
// setFailureType: Never → CustomError
Just("Hello")
.setFailureType(to: APIError.self)
// mapError: URLError → APIError
urlSession.dataTaskPublisher(for: url)
.mapError { .networkError($0) }
```
## try* Operators
The `try`-prefixed operators allow throwing but **erase error type to `Swift.Error`**.
| Operator | Preserves Failure Type | Can Throw |
|----------|----------------------|-----------|
| `map` | Yes | No |
| `tryMap` | No (erases to `Error`) | Yes |
| `filter` | Yes | No |
| `tryFilter` | No (erases to `Error`) | Yes |
**Always follow tryMap with mapError:**
```swift
publisher
.tryMap { try JSONDecoder().decode(User.self, from: $0) }
.mapError { $0 as? APIError ?? .unknown($0) }
```
## catch vs replaceError
| Aspect | `catch` | `replaceError(with:)` |
|--------|---------|----------------------|
| Returns | New publisher | Single value |
| Can inspect error | Yes | No |
| Post-error values | Multiple possible | One then completes |
| Result Failure type | Depends on fallback | `Never` |
```swift
// replaceError: Simple fallback value
imagePublisher
.replaceError(with: placeholderImage)
// catch: Inspect error, provide fallback publisher
primaryAPI
.catch { error -> AnyPublisher<Data, Never> in
if case .notFound = error {
return fallbackAPI.replaceError(with: Data())
}
return Just(Data()).eraseToAnyPublisher()
}
```
## Critical Anti-Patterns
### 1. Error Handling in Main Chain Kills Publisher
```swift
// BAD: Main chain dies after first error
searchText
.flatMap { query in networkRequest(query) }
.replaceError(with: []) // Publisher dead after one error!
.sink { results in ... }
// GOOD: Handle errors inside flatMap
searchText
.flatMap { query in
networkRequest(query)
.replaceError(with: []) // Inner publisher handles error
}
.sink { results in ... } // Main chain stays alive
```
### 2. Using tryMap Without mapError
```swift
// BAD: Loses specific error type
publisher.tryMap { try decode($0) }
// Failure is now plain Error
// GOOD: Restore error type
publisher.tryMap { try decode($0) }
.mapError { $0 as? APIError ?? .unknown($0) }
```
### 3. assertNoFailure in Production
```swift
// BAD: Crashes app on network error
networkPublisher
.assertNoFailure() // Fatal error!
// GOOD: Handle expected errors
networkPublisher
.catch { _ in Just(defaultValue) }
```
### 4. assign(to:on:) with Failable Publishers
```swift
// COMPILE ERROR: Failure must be Never
networkPublisher // Failure: URLError
.assign(to: \.data, on: viewModel)
// FIXED: Handle errors first
networkPublisher
.replaceError(with: defaultData) // Now Failure is Never
.assign(to: \.data, on: viewModel)
```
### 5. Not Handling Errors Before Long-Lived Subscriptions
```swift
// BAD: First error kills subscription permanently
dataPublisher
.receive(on: DispatchQueue.main)
.assign(to: \.items, on: viewModel) // Dead after first error
// GOOD: Error handling preserves subscription
dataPublisher
.catch { _ in Just([]) }
.receive(on: DispatchQueue.main)
.assign(to: \.items, on: viewModel)
```
## Review Questions
1. Is error type preserved through the pipeline? (Check for naked tryMap)
2. Will this publisher survive its first error? (Check where catch/replaceError is)
3. Is `assertNoFailure` used for expected errors? (Should only be programming errors)
4. Are error types unified at API boundaries with mapError?
5. Is the publisher infallible (`Failure == Never`) before assign(to:on:)?
FILE:references/memory.md
# Combine Memory Management
## AnyCancellable Lifecycle
`AnyCancellable` is a type-erasing wrapper that **automatically calls `cancel()` when deallocated**.
**Critical behavior:** If not retained, the subscription cancels immediately. This often manifests as `NSURLErrorDomain -999` errors.
```swift
// BAD: Subscription cancels immediately
func fetchData() {
publisher.sink { data in self.data = data }
// AnyCancellable not stored - immediately released!
}
// GOOD: Store in Set
var cancellables = Set<AnyCancellable>()
func fetchData() {
publisher.sink { [weak self] data in
self?.data = data
}.store(in: &cancellables)
}
```
## The Retain Cycle Pattern
Retain cycles occur when:
1. `self` owns the cancellable (via `cancellables` Set)
2. The cancellable owns the closure
3. The closure captures `self` strongly
```
self → cancellables → closure → self (CYCLE)
```
## Critical Anti-Patterns
### 1. Strong Self in sink()
```swift
// RETAIN CYCLE
publisher.sink { value in
self.property = value // Strong capture
}.store(in: &cancellables)
// FIXED
publisher.sink { [weak self] value in
self?.property = value
}.store(in: &cancellables)
```
### 2. assign(to:on:) with self
`assign(to:on:)` **always** captures its target strongly. No weak option exists.
```swift
// RETAIN CYCLE - ALWAYS
publisher
.assign(to: \.property, on: self)
.store(in: &cancellables)
// FIX 1: Use sink with weak self
publisher.sink { [weak self] value in
self?.property = value
}.store(in: &cancellables)
// FIX 2: Use assign(to:) with @Published (iOS 14+)
@Published var property: Value
publisher.assign(to: &$property) // No AnyCancellable returned
```
### 3. Not Storing the Cancellable
```swift
// BUG: Subscription dies immediately
func subscribe() {
publisher.sink { print($0) } // Discarded!
}
// FIXED
var cancellables = Set<AnyCancellable>()
func subscribe() {
publisher.sink { print($0) }
.store(in: &cancellables)
}
```
### 4. Nested Closures Missing Weak Captures
```swift
// Each closure needs its own [weak self]
publisher
.flatMap { [weak self] value in
self?.transform(value) ?? Empty()
}
.sink { [weak self] result in // Need [weak self] again!
self?.handle(result)
}
.store(in: &cancellables)
```
### 5. Long-Lived Subscriptions Without Weak Self
```swift
// MEMORY LEAK: Timer keeps self alive forever
Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.sink { _ in
self.updateUI() // Strong capture
}
.store(in: &cancellables)
// FIXED
Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
self?.updateUI()
}
.store(in: &cancellables)
```
## Single Cancellable Pattern
For auto-cancelling previous subscriptions (search debouncing):
```swift
private var searchCancellable: AnyCancellable?
func search(_ query: String) {
// Previous subscription automatically cancelled
searchCancellable = searchPublisher(query)
.sink { [weak self] results in
self?.results = results
}
}
```
## Review Questions
1. Is every `sink()` and `assign()` result stored?
2. Does `sink()` use `[weak self]` when self owns the cancellable?
3. Is `assign(to:on:)` used with `self`? (Always a leak)
4. Are there nested closures missing weak captures?
5. Are long-lived subscriptions (timers, notifications) using weak self?
FILE:references/operators.md
# Combine Operators
## Key Operators by Category
### Transforming
| Operator | Purpose |
|----------|---------|
| `map` | Transform each value 1:1 |
| `tryMap` | Transform with throwing closure (erases error type) |
| `flatMap` | Transform to new publisher, flatten nested publishers |
| `compactMap` | Transform and filter out nil values |
| `scan` | Accumulate values over time (emits each step) |
### Combining
| Operator | Purpose |
|----------|---------|
| `merge` | Interleave values from publishers of same type |
| `combineLatest` | Emit tuple of latest values when any emits |
| `zip` | Pair values by index (waits for all to emit) |
| `switchToLatest` | Switch to latest inner publisher, cancel previous |
### Timing
| Operator | Purpose |
|----------|---------|
| `debounce` | Wait for pause in emissions |
| `throttle` | Limit rate of emissions |
| `delay` | Shift emissions forward in time |
| `timeout` | Fail if no value within time limit |
## map vs flatMap vs switchToLatest
| Scenario | Use |
|----------|-----|
| Transform value: `String` → `Int` | `map` |
| Transform to publisher: `URL` → `Publisher<Data>` | `flatMap` |
| Transform to publisher, cancel previous | `map` + `switchToLatest` |
```swift
// map: Simple transformation
publisher.map { $0.uppercased() }
// flatMap: Transformation produces a publisher
publisher.flatMap { url in
URLSession.shared.dataTaskPublisher(for: url)
}
// switchToLatest: Cancel previous (search/autocomplete)
searchText
.map { query in searchAPI(query) }
.switchToLatest() // Cancels previous request
```
## combineLatest vs merge vs zip
| Aspect | merge | combineLatest | zip |
|--------|-------|---------------|-----|
| **Output** | Same type | Tuple | Tuple |
| **Emits when** | Any emits | Any (after all emit once) | All emit new value |
| **Use for** | Multiple event sources | Form validation | Parallel requests |
```swift
// merge: Combine same-type streams
let allTaps = buttonA.merge(with: buttonB)
// combineLatest: React to any change (form validation)
Publishers.CombineLatest(emailValid, passwordValid)
.map { $0 && $1 }
// zip: Wait for both (parallel requests)
Publishers.Zip(fetchUser, fetchPreferences)
```
## Critical Anti-Patterns
### 1. flatMap Instead of switchToLatest for Search
```swift
// BAD: All requests execute, results arrive out of order
searchText.flatMap { query in search(query) }
// GOOD: Cancel previous requests
searchText
.map { query in search(query) }
.switchToLatest()
```
### 2. Wrong Threading Operator
```swift
// BAD: subscribe(on:) doesn't affect where values received
URLSession.shared.dataTaskPublisher(for: url)
.subscribe(on: DispatchQueue.main) // WRONG!
.sink { /* NOT on main thread */ }
// GOOD: Use receive(on:) for downstream
URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main)
.sink { /* On main thread */ }
```
### 3. combineLatest with Publisher That Never Emits
```swift
// BUG: Won't emit until ALL publishers emit at least once
Publishers.CombineLatest(requiredField, optionalAction)
// If optionalAction never fires, stream never starts
```
### 4. Using tryMap Without mapError
```swift
// BAD: Erases error type to plain Error
publisher.tryMap { try decode($0) }
// GOOD: Restore specific error type
publisher.tryMap { try decode($0) }
.mapError { $0 as? APIError ?? .unknown($0) }
```
## Review Questions
1. Is `flatMap` appropriate, or should it be `map + switchToLatest`?
2. Are `combineLatest` publishers guaranteed to emit at least once?
3. Is `receive(on:)` used before UI updates (not `subscribe(on:)`)?
4. Are try* operators followed by `mapError` for type safety?
5. Could `debounce` or `throttle` reduce unnecessary work?
FILE:references/publishers.md
# Combine Publishers
## Built-in Publishers
| Publisher | Use Case |
|-----------|----------|
| `Just` | Single synchronous value, placeholders |
| `Future` | Converting callback-based APIs (executes once, caches result) |
| `Deferred` | Lazy publisher creation, wrap Future for retry support |
| `Empty` | No-op placeholder, completing immediately |
| `Fail` | Immediate error emission, testing error paths |
| `Sequence.publisher` | `[1,2,3].publisher` emits each element |
| `Timer.Publisher` | Periodic events (requires `autoconnect()`) |
| `DataTaskPublisher` | Network requests via URLSession |
## Subject Types
### PassthroughSubject - Use for Events
```swift
let buttonTaps = PassthroughSubject<Void, Never>()
buttonTaps.send(()) // Subscribers only notified if already subscribed
```
- No initial value required
- No `.value` property
- New subscribers receive only future values
- Best for: button taps, user actions, transient events
### CurrentValueSubject - Use for State
```swift
let loadingState = CurrentValueSubject<LoadingState, Never>(.idle)
print(loadingState.value) // Can query current state
```
- Initial value required
- `.value` property for direct access
- New subscribers receive current value immediately
- Best for: settings, loading state, toggles
## Critical Anti-Patterns
### 1. Exposing Subjects Publicly
```swift
// BAD: External code can call loginSubject.send(...)
class AuthManager {
let loginSubject = PassthroughSubject<User, Error>()
}
// GOOD: Expose as read-only publisher
class AuthManager {
private let loginSubject = PassthroughSubject<User, Error>()
var loginPublisher: AnyPublisher<User, Error> {
loginSubject.eraseToAnyPublisher()
}
}
```
### 2. Using Just for Arrays When Sequence Intended
```swift
// BAD: Emits entire array as one value
Just([1, 2, 3]).sink { print($0) } // prints: [1, 2, 3]
// GOOD: Emits each element
[1, 2, 3].publisher.sink { print($0) } // prints: 1, 2, 3
```
### 3. Future Without Deferred for Retry
```swift
// BAD: Retry reuses cached failure
Future { promise in networkCall(completion: promise) }
.retry(3) // Same cached result retried!
// GOOD: Wrap in Deferred
Deferred {
Future { promise in networkCall(completion: promise) }
}.retry(3) // New Future created each retry
```
### 4. Wrong Subject Type for Use Case
```swift
// BAD: PassthroughSubject for state (late subscribers miss value)
let isLoggedIn = PassthroughSubject<Bool, Never>()
// GOOD: CurrentValueSubject for state
let isLoggedIn = CurrentValueSubject<Bool, Never>(false)
```
## Review Questions
1. Are Subjects exposed publicly or converted to AnyPublisher?
2. Is the correct Subject type used (events vs state)?
3. Is Future used with retry without Deferred wrapper?
4. Are built-in publishers preferred over custom implementations?
5. Is `.value` access needed? (Requires CurrentValueSubject)
Reviews CloudKit code for container setup, record handling, subscriptions, and sharing patterns. Use when reviewing code with import CloudKit, CKContainer, C...
---
name: cloudkit-code-review
description: Reviews CloudKit code for container setup, record handling, subscriptions, and sharing patterns. Use when reviewing code with import CloudKit, CKContainer, CKRecord, CKShare, or CKSubscription.
---
# CloudKit Code Review
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| CKContainer, databases, zones, entitlements | [references/container-setup.md](references/container-setup.md) |
| CKRecord, references, assets, batch operations | [references/records.md](references/records.md) |
| CKSubscription, push notifications, silent sync | [references/subscriptions.md](references/subscriptions.md) |
| CKShare, participants, permissions, acceptance | [references/sharing.md](references/sharing.md) |
## Review Checklist
- [ ] Account status checked before private/shared database operations
- [ ] Custom zones used (not default zone) for production data
- [ ] All CloudKit errors handled with `retryAfterSeconds` respected
- [ ] `serverRecordChanged` conflicts handled with proper merge logic
- [ ] `CKErrorPartialFailure` parsed for individual record errors
- [ ] Batch operations used (`CKModifyRecordsOperation`) not individual saves
- [ ] Large binary data stored as `CKAsset` (records have 1MB limit)
- [ ] Record keys type-safe (enums) not string literals
- [ ] UI updates dispatched to main thread from callbacks
- [ ] `CKAccountChangedNotification` observed for account switches
- [ ] Subscriptions have unique IDs to prevent duplicates
- [ ] CKShare uses custom zone (sharing requires custom zones)
## When to Load References
- Reviewing container/database setup or zones -> container-setup.md
- Reviewing record CRUD or relationships -> records.md
- Reviewing push notifications or sync triggers -> subscriptions.md
- Reviewing sharing or collaboration features -> sharing.md
## Output Format
Report issues using: `[FILE:LINE] ISSUE_TITLE`
Examples:
- `[AppDelegate.swift:24] CKContainer not in custom zone`
- `[SyncManager.swift:156] Unhandled CKErrorPartialFailure`
- `[DataStore.swift:89] Missing retryAfterSeconds backoff`
## Review Questions
1. What happens when the user is signed out of iCloud?
2. Does error handling respect rate limiting (`retryAfterSeconds`)?
3. Are conflicts resolved or does data get overwritten silently?
4. Is the schema deployed to production before App Store release?
5. Are shared records in custom zones (required for CKShare)?
## Hard gates (before reporting)
Complete **in order** for each finding you intend to report. Do not advance until the pass condition is satisfied.
1. **Location artifact** — The finding includes `[FILE:LINE]` (or a line range) copied from the current file contents; the path resolves in this repo.
2. **Scope read** — You read the full surrounding unit: the type or function that owns the CloudKit work (for example the `CKOperation` subclass usage, completion handler chain, or `CKRecord` lifecycle), not only a diff hunk or isolated snippet.
3. **CloudKit or deployment claim** (only if the finding depends on container identifiers, public vs private database choice, custom zone requirement, iCloud account state, entitlements, or production schema) — You name one concrete artifact you inspected (for example `com.apple.developer.icloud-container-environment` or container ID in the entitlements file, `CKContainer.default()` vs custom identifier in source, `Info.plist` / target capability, or evidence that schema is deployed) **or** you downgrade the item to an open question in [Review Questions](#review-questions).
4. **Protocol** — Pre-report steps in [review-verification-protocol](../review-verification-protocol/SKILL.md) are satisfied for this item (no finding if they are not).
Use the issue format `[FILE:LINE] ISSUE_TITLE` for each reported finding. Hard gate 4 is the full pre-report checklist for this skill’s review type.
FILE:references/container-setup.md
# CloudKit Container Setup
## Container Architecture
```swift
// Default container (matches app's bundle identifier)
let container = CKContainer.default()
// Custom container (explicit identifier - recommended)
let container = CKContainer(identifier: "iCloud.com.company.appname")
```
**Container identifiers cannot be deleted once created** - verify naming before creation.
## Database Types
| Database | Access | Custom Zones | Use Case |
|----------|--------|--------------|----------|
| **Private** | User-only (requires iCloud) | Yes | Personal data |
| **Public** | Read: anyone; Write: signed-in | No (default only) | App-wide content |
| **Shared** | Invited users only | Yes (one per sharer) | Collaboration |
```swift
let privateDB = container.privateCloudDatabase
let publicDB = container.publicCloudDatabase
let sharedDB = container.sharedCloudDatabase
```
## Custom Zones
Custom zones provide atomic operations, sharing, and change tokens. **Required for production apps.**
```swift
let zoneID = CKRecordZone.ID(zoneName: "MyZone", ownerName: CKCurrentUserDefaultName)
let zone = CKRecordZone(zoneID: zoneID)
let operation = CKModifyRecordZonesOperation(recordZonesToSave: [zone], recordZoneIDsToDelete: nil)
privateDB.add(operation)
```
## Critical Anti-Patterns
### 1. Using Default Zone for Production
```swift
// BAD: Default zone lacks atomic operations and sharing
let record = CKRecord(recordType: "Note")
privateDB.save(record) { _, _ in }
// GOOD: Use custom zone
let zoneID = CKRecordZone.ID(zoneName: "NotesZone", ownerName: CKCurrentUserDefaultName)
let recordID = CKRecord.ID(recordName: UUID().uuidString, zoneID: zoneID)
let record = CKRecord(recordType: "Note", recordID: recordID)
```
### 2. Missing Account Status Check
```swift
// BAD: Assumes iCloud is available
func saveUserData() {
container.privateCloudDatabase.save(record) { _, _ in }
}
// GOOD: Check account status first
container.accountStatus { status, error in
guard status == .available else {
// Handle: .noAccount, .restricted, .couldNotDetermine
return
}
self.container.privateCloudDatabase.save(record) { _, _ in }
}
```
### 3. Not Observing Account Changes
```swift
// BAD: Assumes account persists
class DataManager {
let container = CKContainer.default()
}
// GOOD: Observe account changes
NotificationCenter.default.addObserver(
forName: .CKAccountChanged,
object: nil,
queue: .main
) { _ in
// Re-check account status, clear private data cache if user changed
}
```
### 4. Missing NSPersistentCloudKitContainer Options
```swift
// BAD: Missing required options
let container = NSPersistentCloudKitContainer(name: "Model")
container.loadPersistentStores { _, _ in }
// GOOD: Enable required tracking
let description = container.persistentStoreDescriptions.first!
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.company.app"
)
```
## Required Entitlements
```xml
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.company.appname</string>
</array>
```
For production:
```xml
<key>com.apple.developer.icloud-container-environment</key>
<string>Production</string>
```
## Review Questions
1. Is the container identifier explicitly specified or relying on `.default()`?
2. Is account status checked before accessing private/shared databases?
3. Are custom zones used for production features?
4. Is `CKAccountChanged` notification observed?
5. Are entitlements configured for both app and extensions?
6. Is the production environment entitlement set for release builds?
FILE:references/records.md
# CloudKit Records
## CKRecord Basics
**Supported field types:**
- `String`, `NSNumber`, `Data`, `Date`, `CLLocation`
- `CKRecord.Reference` - links to other records
- `CKAsset` - binary files (images, audio, documents)
- Arrays of any above type (same-type elements only)
**Size limits:**
| Constraint | Limit |
|------------|-------|
| Single record (excluding assets) | 1 MB |
| Single asset | 250 MB (native) |
| Batch operations per request | ~400 records |
## CKRecord.Reference
```swift
// Child points to parent with cascade delete
let parentRef = CKRecord.Reference(recordID: parentRecord.recordID, action: .deleteSelf)
childRecord["parentRef"] = parentRef
```
**Actions:**
- `.deleteSelf` - Child deleted when parent deleted
- `.none` - Child becomes orphan when parent deleted
## CKAsset
```swift
let fileURL = getLocalFileURL()
let asset = CKAsset(fileURL: fileURL)
record["attachment"] = asset
```
Assets stored separately, don't count toward 1MB record limit.
## Critical Anti-Patterns
### 1. Storing Child Arrays in Parent
```swift
// BAD: Causes conflict resolution nightmares
let parentRecord = CKRecord(recordType: "Album")
parentRecord["photoIDs"] = photoIDs as CKRecordValue
// GOOD: Child references parent
let photoRecord = CKRecord(recordType: "Photo")
let albumRef = CKRecord.Reference(recordID: albumRecord.recordID, action: .deleteSelf)
photoRecord["album"] = albumRef
```
### 2. Ignoring Errors
```swift
// BAD
database.save(record) { _, error in
self.updateUI() // Ignores error!
}
// GOOD
database.save(record) { _, error in
if let error = error as? CKError {
switch error.code {
case .serverRecordChanged:
self.resolveConflict(error: error)
case .networkUnavailable, .networkFailure:
if let retry = error.userInfo[CKErrorRetryAfterKey] as? Double {
DispatchQueue.main.asyncAfter(deadline: .now() + retry) {
self.retrySave(record)
}
}
default:
self.handleError(error)
}
return
}
DispatchQueue.main.async { self.updateUI() }
}
```
### 3. String Literals for Keys
```swift
// BAD: Typos won't be caught
record["titel"] = title
// GOOD: Type-safe keys
enum RecordKeys: String {
case title, createdAt, category
}
record[RecordKeys.title.rawValue] = title
```
### 4. Individual Saves Instead of Batch
```swift
// BAD: Separate network call for each record
for record in records {
database.save(record) { _, _ in }
}
// GOOD: Single batch operation
let operation = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: nil)
operation.modifyRecordsResultBlock = { result in }
database.add(operation)
```
### 5. Exceeding Record Size
```swift
// BAD: May exceed 1MB limit
record["imageData"] = largeImageData as CKRecordValue
// GOOD: Use CKAsset for binary data
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("temp.jpg")
try imageData.write(to: tempURL)
record["image"] = CKAsset(fileURL: tempURL)
```
### 6. UI Updates on Background Thread
```swift
// BAD: CloudKit callbacks are on background thread
database.fetch(withRecordID: recordID) { record, error in
self.titleLabel.text = record?["title"] as? String // Crash!
}
// GOOD
database.fetch(withRecordID: recordID) { record, error in
DispatchQueue.main.async {
self.titleLabel.text = record?["title"] as? String
}
}
```
### 7. Downloading All Fields
```swift
// BAD: Downloads everything including large assets
let query = CKQuery(recordType: "Photo", predicate: predicate)
database.perform(query, inZoneWith: nil) { records, error in }
// GOOD: Only fetch needed fields
let operation = CKQueryOperation(query: query)
operation.desiredKeys = ["title", "timestamp"]
database.add(operation)
```
## Error Handling Table
| Error Code | Common Mistake | Correct Handling |
|------------|----------------|------------------|
| `partialFailure` | Treat as complete failure | Parse `partialErrorsByItemID` |
| `serverRecordChanged` | Retry with client record | Merge using server record from error |
| `requestRateLimited` | Immediate retry | Use `retryAfterSeconds` |
| `limitExceeded` | Fail operation | Split batch and retry |
| `quotaExceeded` | Silent failure | Alert user |
## Review Questions
1. Are custom record IDs used that match local storage identifiers?
2. Is record data under 1MB with large files as CKAssets?
3. Are relationships using back-references (child->parent) not arrays?
4. Is `.deleteSelf` used appropriately for cascade delete needs?
5. Are all CloudKit callbacks dispatching UI updates to main thread?
6. Is `desiredKeys` specified to avoid downloading unnecessary data?
FILE:references/sharing.md
# CloudKit Sharing
## Sharing Models
| Model | Use Case | CKShare Creation |
|-------|----------|------------------|
| **Record Sharing** | Individual records with hierarchy | `CKShare(rootRecord: record)` |
| **Zone Sharing** | All records in custom zone | `CKShare(recordZoneID: zone.zoneID)` |
## Permission Levels
| Permission | Description |
|------------|-------------|
| `.none` | No access (default for `publicPermission`) |
| `.readOnly` | Can read shared records |
| `.readWrite` | Can read and modify shared records |
## Database Architecture
```
Private Database (Owner)
└── Custom Zone (required!)
├── Root Record
├── Child Records (auto-shared)
└── CKShare Record
Shared Database (Participants)
└── [View into owner's private database]
```
## Critical Anti-Patterns
### 1. Using Default Zone for Sharing
```swift
// BAD: CKShare cannot be saved in Default Zone
let record = CKRecord(recordType: "Item") // Uses default zone
let share = CKShare(rootRecord: record)
try await privateDatabase.save(share) // ERROR!
// GOOD: Use custom zone
let zoneID = CKRecordZone.ID(zoneName: "SharedItems", ownerName: CKCurrentUserDefaultName)
let recordID = CKRecord.ID(recordName: UUID().uuidString, zoneID: zoneID)
let record = CKRecord(recordType: "Item", recordID: recordID)
let share = CKShare(rootRecord: record)
try await privateDatabase.modifyRecords(saving: [record, share], deleting: [])
```
### 2. Saving CKShare Without Root Record
```swift
// BAD: Even if record exists, must save together
let share = CKShare(rootRecord: existingRecord)
try await privateDatabase.save(share) // ERROR!
// GOOD: Save both together
try await privateDatabase.modifyRecords(saving: [existingRecord, share], deleting: [])
```
### 3. Creating New Shares for Already-Shared Records
```swift
// BAD: Revokes existing share, removes all participants
func shareContact(_ contact: CKRecord) async throws {
let share = CKShare(rootRecord: contact) // Creates NEW share!
try await privateDatabase.modifyRecords(saving: [contact, share], deleting: [])
}
// GOOD: Check for existing share first
func shareContact(_ contact: CKRecord) async throws -> CKShare {
if let existingShareRef = contact.share {
return try await privateDatabase.record(for: existingShareRef.recordID) as! CKShare
}
let share = CKShare(rootRecord: contact)
try await privateDatabase.modifyRecords(saving: [contact, share], deleting: [])
return share
}
```
### 4. Not Verifying Permissions Before Modification
```swift
// BAD: Assumes write access
func updateSharedRecord(_ record: CKRecord) async throws {
record["name"] = "Updated"
try await sharedDatabase.save(record) // Fails if readOnly!
}
// GOOD: Check permission first
func canModify(share: CKShare) -> Bool {
guard let participant = share.currentUserParticipant else { return false }
return participant.permission == .readWrite || share.owner == participant
}
```
### 5. Missing CKSharingSupported in Info.plist
```xml
<!-- Required for share acceptance callbacks -->
<key>CKSharingSupported</key>
<true/>
```
Without this, `userDidAcceptCloudKitShareWith` is never called.
### 6. Not Handling Share Acceptance
```swift
// BAD: Share links won't work
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Missing implementation
}
// GOOD
func windowScene(
_ windowScene: UIWindowScene,
userDidAcceptCloudKitShareWith metadata: CKShare.Metadata
) {
let container = CKContainer(identifier: metadata.containerIdentifier)
Task {
do {
try await container.accept(metadata)
} catch {
// Handle error
}
}
}
```
### 7. Not Setting Share Metadata
```swift
// BAD: Email invitations show no context
let share = CKShare(rootRecord: record)
// GOOD: Set title for user-friendly invitations
let share = CKShare(rootRecord: record)
share[CKShare.SystemFieldKey.title] = "Shopping List"
share[CKShare.SystemFieldKey.shareType] = "com.app.shoppinglist"
share[CKShare.SystemFieldKey.thumbnailImageData] = thumbnailData
```
### 8. Multiple CKShare per Zone
```swift
// BAD: Only ONE CKShare allowed per zone
let share1 = CKShare(recordZoneID: zoneID)
try await privateDatabase.save(share1)
let share2 = CKShare(recordZoneID: zoneID) // ERROR on save!
// GOOD: Check for existing zone share
func getOrCreateZoneShare(for zoneID: CKRecordZone.ID) async throws -> CKShare {
let shareID = CKRecord.ID(recordName: CKRecordNameZoneWideShare, zoneID: zoneID)
do {
return try await privateDatabase.record(for: shareID) as! CKShare
} catch {
let share = CKShare(recordZoneID: zoneID)
try await privateDatabase.save(share)
return share
}
}
```
## UICloudSharingController Integration
```swift
func presentShareController(for share: CKShare, record: CKRecord) {
// Pre-fetch share before presenting
let controller = UICloudSharingController(share: share, container: container)
controller.delegate = self
present(controller, animated: true)
}
// Required delegate methods
extension ViewController: UICloudSharingControllerDelegate {
func cloudSharingController(_ csc: UICloudSharingController, failedToSaveShareWithError error: Error) {
// Handle error
}
func itemTitle(for csc: UICloudSharingController) -> String? {
return "Shared Item"
}
}
```
## Review Questions
1. Is CKShare saved to a **custom zone** (not Default Zone)?
2. Is root record saved **together** with CKShare in same operation?
3. Does code check for **existing shares** before creating new ones?
4. Is **CKSharingSupported** enabled in Info.plist?
5. Is `userDidAcceptCloudKitShareWith` implemented?
6. Are shared records accessed from **sharedDatabase** (not privateDatabase)?
7. Does code verify **permissions** before attempting modifications?
8. Is **share metadata** (title, type) set for user-friendly invitations?
FILE:references/subscriptions.md
# CloudKit Subscriptions
## Subscription Types
| Type | Use Case | Database Support |
|------|----------|------------------|
| **CKQuerySubscription** | Records matching predicate | Public, Private (default zone) |
| **CKRecordZoneSubscription** | All changes in custom zone | Private only |
| **CKDatabaseSubscription** | All changes across database | Private, Shared |
**Recommendation:** Start with `CKDatabaseSubscription` unless only using default zone.
## Notification Configuration
```swift
let info = CKSubscription.NotificationInfo()
// Visible notification
info.alertBody = "New record available"
info.soundName = "default"
info.shouldBadge = true
// Silent notification (background sync)
info.shouldSendContentAvailable = true
// Leave alertBody, soundName, shouldBadge unset
```
## Critical Anti-Patterns
### 1. Creating Duplicate Subscriptions
```swift
// BAD: Creates duplicate on every app launch
func application(_ application: UIApplication, didFinishLaunchingWithOptions...) {
let subscription = CKQuerySubscription(recordType: "Item", predicate: predicate, options: .firesOnRecordCreation)
database.save(subscription) { _, _ in }
}
// GOOD: Check before creating, use consistent ID
let subscriptionID = "item-creation-subscription"
database.fetch(withSubscriptionID: subscriptionID) { subscription, error in
if subscription == nil {
let newSubscription = CKQuerySubscription(
recordType: "Item",
predicate: predicate,
subscriptionID: subscriptionID,
options: .firesOnRecordCreation
)
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true
newSubscription.notificationInfo = info
database.save(newSubscription) { _, _ in }
}
}
```
### 2. Missing NotificationInfo
```swift
// BAD: Subscription will fail to save
let subscription = CKQuerySubscription(recordType: "Item", predicate: predicate, options: .firesOnRecordCreation)
database.save(subscription) { _, error in } // Error!
// GOOD: Always configure notificationInfo
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true
subscription.notificationInfo = info
```
### 3. Wrong Subscription Type for Shared Database
```swift
// BAD: CKQuerySubscription doesn't work with shared database
let sharedDB = CKContainer.default().sharedCloudDatabase
let subscription = CKQuerySubscription(recordType: "SharedItem", predicate: predicate, options: .firesOnRecordCreation)
sharedDB.save(subscription) { _, error in } // Error!
// GOOD: Use CKDatabaseSubscription for shared database
let subscription = CKDatabaseSubscription(subscriptionID: "shared-db-subscription")
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true
subscription.notificationInfo = info
sharedDB.save(subscription) { _, _ in }
```
### 4. Relying Solely on Push for Sync
```swift
// BAD: Only syncing when push arrives
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) {
syncData() // Only sync trigger
}
// GOOD: Multiple sync triggers
func applicationDidBecomeActive(_ application: UIApplication) {
syncData() // On app launch
}
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) {
syncData() // On notification
}
// Also implement background fetch
```
### 5. Not Indexing Predicate Fields
```swift
// BAD: Field not indexed in CloudKit Dashboard
let predicate = NSPredicate(format: "category == %@", "news")
// Error: CKError.invalidArguments when saving subscription
// FIX: Enable "Query" indexing for field in CloudKit Dashboard
```
## Registration Flow
```swift
// 1. Request authorization
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
// 2. Register for remote notifications
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
// 3. Create subscription after registration
subscriptionManager.ensureSubscriptionExists()
```
## Subscription Manager Pattern
```swift
class SubscriptionManager {
private let subscriptionKey = "cloudkit.subscription.created"
func ensureSubscriptionExists() {
guard !UserDefaults.standard.bool(forKey: subscriptionKey) else { return }
let subscription = CKDatabaseSubscription(subscriptionID: "all-changes")
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true
subscription.notificationInfo = info
let operation = CKModifySubscriptionsOperation(
subscriptionsToSave: [subscription],
subscriptionIDsToDelete: nil
)
operation.modifySubscriptionsCompletionBlock = { saved, _, error in
if error == nil {
UserDefaults.standard.set(true, forKey: self.subscriptionKey)
}
}
database.add(operation)
}
}
```
## Review Questions
1. Is a specific `subscriptionID` used to prevent duplicates?
2. Is `notificationInfo` properly configured before saving?
3. Is the correct subscription type used for the database (shared needs CKDatabaseSubscription)?
4. Are predicate fields indexed in CloudKit Dashboard?
5. Is `shouldSendContentAvailable` set for silent notifications (without alertBody)?
6. Does the app handle coalesced notifications (not 1:1 with changes)?
7. Is there fallback sync logic for when notifications don't arrive?
8. Is the schema deployed to production before App Store release?
Reviews App Intents code for intent structure, entities, shortcuts, and parameters. Use when reviewing code with import AppIntents, @AppIntent, AppEntity, Ap...
---
name: app-intents-code-review
description: Reviews App Intents code for intent structure, entities, shortcuts, and parameters. Use when reviewing code with import AppIntents, @AppIntent, AppEntity, AppShortcutsProvider, or @Parameter.
---
# App Intents Code Review
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| AppIntent protocol, perform(), return types | [references/intent-structure.md](references/intent-structure.md) |
| AppEntity, EntityQuery, identifiers | [references/entities.md](references/entities.md) |
| AppShortcutsProvider, phrases, discovery | [references/shortcuts.md](references/shortcuts.md) |
| @Parameter, validation, dynamic options | [references/parameters.md](references/parameters.md) |
## Review Checklist
- [ ] `perform()` marked with `@MainActor` if accessing UI/main thread resources
- [ ] `perform()` completes within 30-second timeout (no heavy downloads/processing)
- [ ] Custom errors conform to `CustomLocalizedStringResourceConvertible`
- [ ] `EntityQuery.entities(for:)` handles missing identifiers gracefully
- [ ] `EntityStringQuery` used if Siri voice input needed (not plain `EntityQuery`)
- [ ] `suggestedEntities()` returns reasonable defaults for disambiguation
- [ ] `AppShortcut` phrases include `.applicationName` parameter
- [ ] Non-optional `@Parameter` has sensible defaults or uses `requestValue()`
- [ ] `@IntentParameterDependency` not used on iOS 16 targets (crashes)
- [ ] Phrases localized in `AppShortcuts.strings`, not `Localizable.strings`
- [ ] App Intents defined in app bundle, not Swift Package (pre-iOS 17)
- [ ] `isDiscoverable = false` for internal/widget-only intents
## When to Load References
- AppIntent protocol implementation -> intent-structure.md
- Entity queries, identifiers, Spotlight -> entities.md
- App Shortcuts, phrases, discovery -> shortcuts.md
- Parameter validation, dynamic options -> parameters.md
## Review Questions
1. Does `perform()` handle timeout limits for long-running operations?
2. Are entity queries self-contained (no `@Dependency` injection in Siri context)?
3. Do phrases read naturally and include the app name?
4. Are SwiftData models passed by `persistentModelID`, not directly?
5. Would migrating from SiriKit break existing user shortcuts?
## Hard gates (before reporting)
Complete **in order** for each finding you intend to report. Do not advance until the pass condition is satisfied.
1. **Location artifact** — The finding includes `[FILE:LINE]` (or a line range) copied from the current file contents; the path resolves in this repo.
2. **Scope read** — You read the full surrounding type: the `AppIntent` / `AppEntity` / `EntityQuery` / `AppShortcutsProvider` (or equivalent) that contains the flagged code, not only a diff hunk or snippet.
3. **Platform or integration claim** (only if the finding depends on minimum iOS, Swift Package vs app target, `@IntentParameterDependency` availability, SiriKit migration, or `isDiscoverable` / extension placement) — You name one concrete artifact you inspected (for example `IPHONEOS_DEPLOYMENT_TARGET` or target membership in the Xcode project, `Package.swift` `platforms`, entitlements, or where the intent file lives) **or** you drop or downgrade the finding to an open question.
4. **Protocol** — Pre-report steps in [review-verification-protocol](../review-verification-protocol/SKILL.md) are satisfied for this item (no finding if they are not).
Use the issue format `[FILE:LINE] ISSUE_TITLE` for each reported finding. Hard gate 4 is the full pre-report checklist for this skill’s review type.
FILE:references/entities.md
# Entities and Queries
## AppEntity Protocol
Represents domain objects for use in App Intents:
```swift
struct BookEntity: AppEntity, Identifiable {
var id: UUID // Must be stable and persistent
@Property(title: "Title")
var title: String
@Property(title: "Author")
var author: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(title)", subtitle: "\(author)")
}
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Book"
static var defaultQuery = BookQuery()
}
```
| Requirement | Purpose |
|-------------|---------|
| `id` | Stable identifier for persistence across sessions |
| `displayRepresentation` | How entity appears in Siri/Shortcuts UI |
| `typeDisplayRepresentation` | Human-readable type name ("Book", "Task") |
| `defaultQuery` | Associated query for lookups |
## EntityQuery Protocol
Basic lookup by identifier:
```swift
struct BookQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
identifiers.compactMap { Database.shared.book(for: $0) }
}
}
```
## EntityStringQuery Protocol
Required for Siri voice input with free-form search:
```swift
struct BookQuery: EntityStringQuery {
func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
identifiers.compactMap { Database.shared.book(for: $0) }
}
func suggestedEntities() async throws -> [BookEntity] {
Database.shared.recentBooks // Shown in picker UI
}
func entities(matching string: String) async throws -> [BookEntity] {
Database.shared.books.filter { $0.title.localizedCaseInsensitiveContains(string) }
}
}
```
**Critical**: Using plain `EntityQuery` with Siri causes infinite parameter request loops. Use `EntityStringQuery` for voice-driven disambiguation.
## EnumerableEntityQuery Protocol (iOS 17+)
For small datasets, return all entities and let system filter:
```swift
struct ShelfQuery: EnumerableEntityQuery {
func allEntities() async throws -> [ShelfEntity] {
Shelf.allCases.map { ShelfEntity($0) }
}
}
```
Best for: Enums, small fixed sets (<100 items)
## EntityPropertyQuery Protocol
For large datasets with property-based filtering:
```swift
struct BookQuery: EntityPropertyQuery {
static var sortingOptions = SortingOptions {
SortableBy(\BookEntity.$title)
SortableBy(\BookEntity.$dateAdded)
}
static var properties = QueryProperties {
Property(\BookEntity.$title) { EqualTo, Contains }
Property(\BookEntity.$author) { EqualTo, Contains }
}
func entities(matching predicates: QueryPredicate<BookEntity>,
mode: ComparatorMode,
sortedBy: [EntitySortingOptions<BookEntity>],
limit: Int?) async throws -> [BookEntity] {
// Build NSPredicate from QueryPredicate
}
}
```
## iOS 18 Entity Features
**IndexedEntity**: Spotlight semantic search
```swift
struct BookEntity: AppEntity, IndexedEntity {
var attributeSet: CSSearchableItemAttributeSet {
let attrs = CSSearchableItemAttributeSet()
attrs.displayName = title
attrs.contentDescription = summary
return attrs
}
}
```
**URLRepresentable**: Deep linking
```swift
struct BookEntity: AppEntity, URLRepresentable {
static var urlRepresentation: URLRepresentation {
"myapp://book/\(.id)"
}
}
```
## SwiftData Integration
```swift
// BAD: Passing model directly (not Sendable)
func perform() async throws -> some IntentResult {
let book = fetchBook() // @Model type
processBook(book) // Data race risk
}
// GOOD: Pass by ID, fetch in context
func perform() async throws -> some IntentResult {
let bookID = persistentModelID
let context = ModelContext(container)
let book = context.model(for: bookID) as? Book
}
```
## Dependency Injection Limitation
`@Dependency` and `@AppDependency` **do not work** in Siri/Shortcuts context. Queries must be self-contained:
```swift
// BAD: Dependency injection in query
struct BookQuery: EntityQuery {
@Dependency var database: Database // nil in Siri
func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
database.books(for: identifiers) // Crashes
}
}
// GOOD: Self-contained query
struct BookQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
Database.shared.books(for: identifiers) // Singleton access
}
}
```
## Critical Anti-Patterns
```swift
// BAD: Plain EntityQuery with Siri voice input
struct BookQuery: EntityQuery { ... } // Infinite prompt loop
// GOOD: EntityStringQuery for voice input
struct BookQuery: EntityStringQuery {
func entities(matching string: String) async throws -> [BookEntity] { ... }
}
```
```swift
// BAD: Empty suggested entities
func suggestedEntities() async throws -> [BookEntity] { [] }
// GOOD: Provide reasonable defaults
func suggestedEntities() async throws -> [BookEntity] {
Database.shared.recentBooks.prefix(10)
}
```
```swift
// BAD: Non-persistent ID
struct BookEntity: AppEntity {
var id = UUID() // New ID each time!
}
// GOOD: Stable ID from data source
struct BookEntity: AppEntity {
let id: UUID // From database record
}
```
## Review Questions
1. **Is `EntityStringQuery` used for Siri voice input?** Plain `EntityQuery` causes infinite loops.
2. **Does `suggestedEntities()` return useful defaults?** Empty results break disambiguation.
3. **Are entity IDs stable and persistent?** New IDs each instantiation break continuity.
4. **Is the query self-contained?** `@Dependency` fails in Siri/Shortcuts context.
5. **Is `EnumerableEntityQuery` used only for small sets?** Large sets should use `EntityPropertyQuery`.
FILE:references/intent-structure.md
# Intent Structure
## AppIntent Protocol
Required conformance for all App Intents:
```swift
struct OpenCurrentlyReading: AppIntent {
static var title: LocalizedStringResource = "Open Currently Reading"
static var openAppWhenRun: Bool = true // Optional: default false
@MainActor
func perform() async throws -> some IntentResult {
Navigator.shared.openShelf(.currentlyReading)
return .result()
}
}
```
| Property | Required | Default | Purpose |
|----------|----------|---------|---------|
| `title` | Yes | - | Localized display name |
| `openAppWhenRun` | No | `false` | Launch app before execution |
| `isDiscoverable` | No | `true` | Show in Shortcuts app (iOS 17+) |
## Return Types
`perform()` returns `some IntentResult` with optional protocol conformances:
| Protocol | Purpose | Example |
|----------|---------|---------|
| `ReturnsValue<T>` | Pass data to next intent | `.result(value: book)` |
| `ProvidesDialog` | Siri voice/text response | `.result(dialog: "Done!")` |
| `ShowsSnippetView` | SwiftUI visual feedback | `.result(view: SuccessView())` |
| `OpensIntent` | Chain to another intent | `.result(opensIntent: NextIntent())` |
```swift
// Combined return type
func perform() async throws -> some IntentResult & ReturnsValue<BookEntity> & ProvidesDialog {
return .result(value: book, dialog: "Added \(book.title) to Library!")
}
```
## Threading
- `perform()` runs on arbitrary background queue by default
- Mark with `@MainActor` for UI operations or main thread access
- Long operations must complete within ~30 seconds or time out
## Error Handling
Custom errors must provide localized messages:
```swift
enum BookIntentError: Error, CustomLocalizedStringResourceConvertible {
case notFound
case networkError(String)
var localizedStringResource: LocalizedStringResource {
switch self {
case .notFound: return "Book not found"
case .networkError(let msg): return "Network error: \(msg)"
}
}
}
```
Use `AppIntentError` for standard cases:
- `.insufficientAccount` - Needs sign-in
- `.entityNotFound` - Entity missing
- `.needsValue` - Parameter required
## iOS 17+ Protocols
**ForegroundContinuableIntent**: Continue in app with custom UI
```swift
throw needsToContinueInForegroundError() // Stop and require user action
try await requestToContinueInForeground() // Continue with user input
```
**ProgressReportingIntent**: Long-running operations
```swift
func perform() async throws -> some IntentResult {
progress.totalUnitCount = 100
for i in 0..<100 {
progress.completedUnitCount = Int64(i)
// ... work
}
return .result()
}
```
## Critical Anti-Patterns
```swift
// BAD: Heavy work without timeout consideration
func perform() async throws -> some IntentResult {
let data = try await downloadLargeFile() // May exceed 30s limit
return .result()
}
// GOOD: Open app for long operations
static var openAppWhenRun = true
func perform() async throws -> some IntentResult {
// App handles long operation with proper UI
}
```
```swift
// BAD: Generic error without localization
throw NSError(domain: "app", code: 1, userInfo: nil)
// GOOD: Localized error message
throw BookIntentError.notFound
```
```swift
// BAD: UI work without @MainActor
func perform() async throws -> some IntentResult {
UIApplication.shared.open(url) // Crashes
}
// GOOD: Mark for main thread
@MainActor
func perform() async throws -> some IntentResult {
UIApplication.shared.open(url)
}
```
## Review Questions
1. **Does `perform()` complete within 30 seconds?** Long downloads/processing should open app.
2. **Is `@MainActor` used for UI operations?** Intents run on background queues by default.
3. **Do custom errors provide localized messages?** Raw `Error` gives poor Siri feedback.
4. **Is `openAppWhenRun` set appropriately?** Background-capable intents should stay `false`.
5. **Is `isDiscoverable = false` for internal intents?** Widget-only intents shouldn't clutter Shortcuts.
FILE:references/parameters.md
# Parameters
## @Parameter Property Wrapper
Declares user-configurable inputs:
```swift
struct OpenBook: AppIntent {
@Parameter(title: "Book")
var book: BookEntity
@Parameter(title: "Page", default: 1)
var page: Int
@Parameter(title: "Read Aloud")
var readAloud: Bool? // Optional = not required
}
```
| Option | Purpose |
|--------|---------|
| `title` | Localized display name (required) |
| `default` | Default value for parameter |
| `description` | Help text for parameter |
| `requestValueDialog` | Prompt when requesting value |
## Supported Types
- **Primitives**: `Int`, `Double`, `Bool`, `String`, `Date`, `URL`
- **Collections**: `[T]` where T is supported
- **Enums**: Must conform to `AppEnum`
- **Entities**: Must conform to `AppEntity`
- **Files**: `IntentFile` for file handling
## AppEnum for Fixed Values
```swift
enum Priority: String, AppEnum {
case low, medium, high
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Priority"
static var caseDisplayRepresentations: [Priority: DisplayRepresentation] = [
.low: "Low",
.medium: "Medium",
.high: "High"
]
}
```
## ParameterSummary
Natural language description with embedded parameters:
```swift
static var parameterSummary: some ParameterSummary {
Summary("Open \(\.$book) at page \(\.$page)")
}
```
iOS 17+: Conditional summaries based on widget family:
```swift
static var parameterSummary: some ParameterSummary {
When(\.$includeDetails, .equalTo, true) {
Summary("Show \(\.$book) with details")
} otherwise: {
Summary("Show \(\.$book)")
}
}
```
## Dynamic Options
Provide runtime-computed options:
```swift
struct BookParameter: DynamicOptionsProvider {
func results() async throws -> [BookEntity] {
Database.shared.availableBooks
}
func defaultResult() async -> BookEntity? {
Database.shared.lastOpenedBook
}
}
@Parameter(title: "Book", optionsProvider: BookParameter())
var book: BookEntity
```
## @IntentParameterDependency (iOS 17+)
Access other parameters in options provider:
```swift
struct ChapterParameter: DynamicOptionsProvider {
@IntentParameterDependency<OpenBook>(\.book)
var bookDependency
func results() async throws -> [ChapterEntity] {
guard let book = bookDependency?.book else { return [] }
return book.chapters
}
}
```
**Warning**: `@IntentParameterDependency` crashes on iOS 16. Guard with availability:
```swift
if #available(iOS 17, *) {
// Use dependency
}
```
## User Interaction
Request values or disambiguation during `perform()`:
```swift
func perform() async throws -> some IntentResult {
// Request missing value
let book = try await $book.requestValue("Which book?")
// Disambiguation from options
let chapter = try await $chapter.requestDisambiguation(
among: book.chapters,
dialog: "Which chapter?"
)
// Confirmation
let confirmed = try await $book.requestConfirmation(
for: book,
dialog: "Open \(book.title)?"
)
}
```
**Note**: User cancellation throws an error - handle gracefully.
## Validation
Validate parameters before use:
```swift
func perform() async throws -> some IntentResult {
guard page > 0 && page <= book.pageCount else {
throw BookIntentError.invalidPage
}
// ...
}
```
For complex validation, use `requestValue()` with specific prompts.
## Critical Anti-Patterns
```swift
// BAD: Non-optional parameter without default
@Parameter(title: "Count")
var count: Int // Required with no default - user must always provide
// GOOD: Optional or has default
@Parameter(title: "Count", default: 10)
var count: Int
```
```swift
// BAD: @IntentParameterDependency on iOS 16 target
@IntentParameterDependency<MyIntent>(\.param)
var dependency // Crashes on iOS 16
// GOOD: Guard with availability
@available(iOS 17, *)
@IntentParameterDependency<MyIntent>(\.param)
var dependency
```
```swift
// BAD: Ignoring requestConfirmation cancellation
func perform() async throws -> some IntentResult {
try await $action.requestConfirmation(for: action) // Throws on cancel
performAction() // Runs even if canceled?
}
// GOOD: Handle cancellation
func perform() async throws -> some IntentResult {
do {
try await $action.requestConfirmation(for: action)
performAction()
} catch {
// User canceled - graceful exit
return .result()
}
}
```
```swift
// BAD: Missing defaultResult in DynamicOptionsProvider
struct BookParameter: DynamicOptionsProvider {
func results() async throws -> [BookEntity] { ... }
// No defaultResult - non-optional params fail without explicit selection
}
// GOOD: Provide default
struct BookParameter: DynamicOptionsProvider {
func results() async throws -> [BookEntity] { ... }
func defaultResult() async -> BookEntity? {
Database.shared.lastOpenedBook
}
}
```
## Review Questions
1. **Do non-optional parameters have defaults or use `requestValue()`?**
2. **Is `@IntentParameterDependency` guarded for iOS 17+?** Crashes on iOS 16.
3. **Are user cancellations from `requestConfirmation` handled?** They throw errors.
4. **Does `DynamicOptionsProvider` implement `defaultResult()`?** Required for non-optional params.
5. **Are parameter summaries written as natural sentences?**
FILE:references/shortcuts.md
# Shortcuts Integration
## AppShortcutsProvider
Registers intents for automatic discovery in Shortcuts app and Siri:
```swift
struct LibraryAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: OpenCurrentlyReading(),
phrases: [
"Open Currently Reading in \(.applicationName)",
"Show my reading list in \(.applicationName)"
],
shortTitle: "Open Reading List",
systemImageName: "books.vertical.fill"
)
}
}
```
| Property | Required | Purpose |
|----------|----------|---------|
| `intent` | Yes | The AppIntent instance to invoke |
| `phrases` | Yes | Siri trigger phrases (must include app name) |
| `shortTitle` | Yes | Brief description for UI |
| `systemImageName` | Yes | SF Symbol for visual display |
## Phrase Requirements
**Critical**: Every phrase MUST include `.applicationName`:
```swift
// BAD: Missing app name
phrases: ["Open my books", "Show reading list"] // Won't be discoverable
// GOOD: Includes app name
phrases: [
"Open my books in \(.applicationName)",
"Show reading list with \(.applicationName)"
]
```
**Limits**:
- Maximum 1,000 total phrases per app (including parameter variations)
- Use natural language that reads well when spoken
## Localization
Phrases must be in `AppShortcuts.strings` (or `AppShortcuts.xcstrings` for iOS 18+):
```strings
// AppShortcuts.strings
"Open Currently Reading in applicationName" = "Open Currently Reading in applicationName";
```
**Critical**: Using `Localizable.strings` for phrases does NOT work.
## Parameterized Phrases
Include parameters using `\(.$parameterName)`:
```swift
AppShortcut(
intent: OpenBook(),
phrases: [
"Open \(\.$book) in \(.applicationName)",
"Read \(\.$book) with \(.applicationName)"
],
shortTitle: "Open Book",
systemImageName: "book"
)
```
**Warning**: Custom `AppEntity` parameters in phrases may prevent shortcuts from appearing. Test thoroughly.
## iOS 17+ Extensions
Define `AppShortcutsProvider` in App Intents extensions (not main app) for faster startup:
```swift
// In App Intents Extension target
struct BookShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] { ... }
}
```
Extensions skip UI, analytics, and non-critical initialization.
## Discovery Issues
Common reasons shortcuts don't appear:
| Issue | Solution |
|-------|----------|
| Missing in Shortcuts app | Check Project Target > General > Supported Intents |
| Xcode version mismatch | Try Xcode beta or release; use `xcode-select` |
| App Intents in Swift Package | Move to main app bundle (pre-iOS 17) |
| Release build issues | Mark all App Intents as `public` |
| Metadata processor failure | Simplify custom types; check build logs |
## Migration from SiriKit
When migrating from INIntent to AppIntent:
```swift
struct OpenBookIntent: AppIntent {
// Conform for migration
static var intentClassName: String? = "OpenBookIntent"
}
```
**Warning**: `CustomIntentMigratedAppIntent` conformance breaks iOS 16 even with availability annotations.
## Multilingual Considerations
- App names in different languages than Siri's language cause recognition failures
- Test with Siri language matching app language settings
- Consider region-specific phrase variations
## Critical Anti-Patterns
```swift
// BAD: Phrase without app name
AppShortcut(
intent: OpenBook(),
phrases: ["Open my book"], // Not discoverable by Siri
...
)
// GOOD: App name included
AppShortcut(
intent: OpenBook(),
phrases: ["Open my book in \(.applicationName)"],
...
)
```
```swift
// BAD: Localization in wrong file
// Localizable.strings - WRONG FILE
"Open book" = "Open book";
// GOOD: Use AppShortcuts.strings
// AppShortcuts.strings - CORRECT FILE
"Open book in applicationName" = "Open book in applicationName";
```
```swift
// BAD: Complex entity parameter in phrase (may fail)
AppShortcut(
intent: ProcessBook(),
phrases: ["Process \(\.$complexEntity) in \(.applicationName)"],
...
)
// GOOD: Simple parameters or none
AppShortcut(
intent: ProcessBook(),
phrases: ["Process current book in \(.applicationName)"],
...
)
```
## Review Questions
1. **Do all phrases include `.applicationName`?** Required for Siri discovery.
2. **Are phrases in `AppShortcuts.strings`?** `Localizable.strings` doesn't work.
3. **Is the app bundle correct?** Swift Package intents won't appear (pre-iOS 17).
4. **Are custom entity parameters tested in phrases?** Complex entities may break discovery.
5. **Is migration handled carefully?** `CustomIntentMigratedAppIntent` breaks iOS 16.
Reviews Phoenix LiveView code for lifecycle patterns, assigns/streams usage, components, and security. Use when reviewing LiveView modules, .heex templates,...
---
name: liveview-code-review
description: Reviews Phoenix LiveView code for lifecycle patterns, assigns/streams usage, components, and security. Use when reviewing LiveView modules, .heex templates, or LiveComponents.
---
# LiveView Code Review
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| mount, handle_params, handle_event, handle_async | [references/lifecycle.md](references/lifecycle.md) |
| When to use assigns vs streams, AsyncResult | [references/assigns-streams.md](references/assigns-streams.md) |
| Function vs LiveComponent, slots, attrs | [references/components.md](references/components.md) |
| Authorization per event, phx-value trust | [references/security.md](references/security.md) |
## Review Checklist
### Critical Issues
- [ ] No socket copying into async functions (extract values first)
- [ ] Every handle_event validates authorization
- [ ] No sensitive data in assigns (visible in DOM)
- [ ] phx-value data is validated (user-modifiable)
### Lifecycle
- [ ] Subscriptions wrapped in `connected?(socket)`
- [ ] handle_params used for URL-based state
- [ ] handle_async handles :loading and :error states
### Data Management
- [ ] Streams used for large collections (100+ items)
- [ ] temporary_assigns for data not needed after render
- [ ] AsyncResult patterns for loading states
### Components
- [ ] Function components preferred over LiveComponents
- [ ] LiveComponents preserve :inner_block in update/2
- [ ] Slots use proper attr declarations
- [ ] phx-debounce on text inputs
## Valid Patterns (Do NOT Flag)
- **Empty mount returning {:ok, socket}** - Valid for simple LiveViews
- **Using assigns for small lists** - Streams only needed for 100+ items
- **LiveComponent without update/2** - Default update/2 assigns all
- **phx-click without phx-value** - Event may not need data
- **Inline function in heex** - Valid for simple transforms
## Context-Sensitive Rules
| Issue | Flag ONLY IF |
|-------|--------------|
| Missing debounce | Input is text/textarea AND triggers server event |
| Use streams | Collection has 100+ items OR is paginated |
| Missing auth check | Event modifies data AND no auth in mount |
## Critical Anti-Patterns
### Socket Copying (MOST IMPORTANT)
```elixir
# BAD - socket copied into async function
def handle_event("load", _, socket) do
Task.async(fn ->
user = socket.assigns.user # Socket copied!
fetch_data(user.id)
end)
{:noreply, socket}
end
# GOOD - extract values first
def handle_event("load", _, socket) do
user_id = socket.assigns.user.id
Task.async(fn ->
fetch_data(user_id) # Only primitive copied
end)
{:noreply, socket}
end
```
### Missing Authorization
```elixir
# BAD - trusts phx-value without auth
def handle_event("delete", %{"id" => id}, socket) do
Posts.delete_post!(id) # Anyone can delete any post!
{:noreply, socket}
end
# GOOD - verify authorization
def handle_event("delete", %{"id" => id}, socket) do
post = Posts.get_post!(id)
if post.user_id == socket.assigns.current_user.id do
Posts.delete_post!(post)
{:noreply, stream_delete(socket, :posts, post)}
else
{:noreply, put_flash(socket, :error, "Unauthorized")}
end
end
```
## Hard gates (sequence)
Advance only when each **pass condition** is objectively true (prevents reporting without evidence):
| Gate | Pass condition |
|------|----------------|
| **G1 — Files in evidence** | You have an explicit list of paths under review (e.g. `*.ex`, `*.heex`, or the paths the user named). **Every** finding names a file from that list. |
| **G2 — Verification protocol** | You loaded [review-verification-protocol](../review-verification-protocol/SKILL.md) and applied its Pre-Report Verification (and issue-type sections where relevant) **before** treating something as a finding. |
| **G3 — Line anchors** | Each finding uses `[FILE:LINE]` where that line exists in the current file (confirmed by read/grep output, not inferred). |
| **G4 — Valid-pattern screen** | You checked the finding against **Valid Patterns (Do NOT Flag)** and **Context-Sensitive Rules**; if it matches a “do not flag” case or fails a “Flag ONLY IF,” you **do not** report it. |
## Issue format
Use `[FILE:LINE] ISSUE_TITLE` for each finding.
FILE:references/assigns-streams.md
# Assigns and Streams
## When to Use Each
| Use Case | Solution |
|----------|----------|
| Small list (< 100 items) | assigns |
| Large list (100+ items) | streams |
| Paginated/infinite scroll | streams |
| Data not needed after render | temporary_assigns |
| Async loading with states | AsyncResult |
## Streams
### Basic Usage
```elixir
def mount(_params, _session, socket) do
{:ok, stream(socket, :posts, Posts.list_posts())}
end
def handle_event("delete", %{"id" => id}, socket) do
post = Posts.get_post!(id)
Posts.delete_post!(post)
{:noreply, stream_delete(socket, :posts, post)}
end
```
### In Templates
```heex
<div id="posts" phx-update="stream">
<div :for={{dom_id, post} <- @streams.posts} id={dom_id}>
<%= post.title %>
<button phx-click="delete" phx-value-id={post.id}>Delete</button>
</div>
</div>
```
### Stream Operations
```elixir
# Insert at end (default)
stream_insert(socket, :posts, new_post)
# Insert at beginning
stream_insert(socket, :posts, new_post, at: 0)
# Delete
stream_delete(socket, :posts, post)
# Delete by DOM ID
stream_delete_by_dom_id(socket, :posts, "posts-123")
# Reset entire stream
stream(socket, :posts, new_list, reset: true)
```
## AsyncResult
### assign_async
```elixir
def mount(_params, _session, socket) do
{:ok,
socket
|> assign_async(:user, fn ->
{:ok, %{user: Accounts.get_user!(1)}}
end)
|> assign_async([:posts, :comments], fn ->
{:ok, %{posts: Posts.list(), comments: Comments.list()}}
end)}
end
```
### Template Handling
```heex
<%# Using async_result component %>
<.async_result :let={user} assign={@user}>
<:loading>
<div class="animate-pulse">Loading...</div>
</:loading>
<:failed :let={{:error, reason}}>
<div class="text-red-500">Failed: <%= reason %></div>
</:failed>
<div><%= user.name %></div>
</.async_result>
<%# Manual pattern matching %>
<%= case @user do %>
<% %AsyncResult{ok?: true, result: user} -> %>
<%= user.name %>
<% %AsyncResult{loading: true} -> %>
Loading...
<% %AsyncResult{failed: reason} -> %>
Error: <%= inspect(reason) %>
<% end %>
```
## Temporary Assigns
### For Large Rendered Data
```elixir
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:messages, load_messages())
|> assign(:form, to_form(%{})),
temporary_assigns: [messages: []]}
end
```
**Important**: Temporary assigns are cleared after render. Only use for data that doesn't need to persist in socket state.
## Common Mistakes
### Assigns for Large Lists
```elixir
# BAD - 10k items in assigns
def mount(_, _, socket) do
{:ok, assign(socket, items: Repo.all(Item))} # All 10k in memory!
end
# GOOD - stream with pagination
def mount(_, _, socket) do
{:ok,
socket
|> assign(:page, 1)
|> stream(:items, load_page(1))}
end
```
### Not Handling AsyncResult States
```elixir
# BAD - assumes result exists
<%= @user.result.name %>
# GOOD - handle all states
<%= if @user.ok?, do: @user.result.name, else: "Loading..." %>
```
## Review Questions
1. Are streams used for large or paginated collections?
2. Do AsyncResult templates handle loading and error states?
3. Are temporary_assigns used appropriately (not for needed state)?
4. Is stream DOM properly configured (id, phx-update)?
FILE:references/components.md
# LiveView Components
## Function Components vs LiveComponents
### Prefer Function Components
```elixir
# GOOD - stateless, simple
defmodule MyAppWeb.Components do
use Phoenix.Component
attr :user, :map, required: true
def user_card(assigns) do
~H"""
<div class="card">
<h3><%= @user.name %></h3>
<p><%= @user.email %></p>
</div>
"""
end
end
```
### Use LiveComponent When Needed
Only use LiveComponent when you need:
- Component-local state
- Component-local event handling
- Lifecycle callbacks (mount, update)
```elixir
defmodule MyAppWeb.LiveComponents.EditableField do
use MyAppWeb, :live_component
def mount(socket) do
{:ok, assign(socket, editing: false)}
end
def handle_event("toggle_edit", _, socket) do
{:noreply, assign(socket, editing: !socket.assigns.editing)}
end
def render(assigns) do
~H"""
<div phx-click="toggle_edit" phx-target={@myself}>
<%= if @editing do %>
<input value={@value} />
<% else %>
<%= @value %>
<% end %>
</div>
"""
end
end
```
## Slots
### Basic Slots
```elixir
slot :inner_block, required: true
def card(assigns) do
~H"""
<div class="card">
<%= render_slot(@inner_block) %>
</div>
"""
end
# Usage
<.card>
<p>Card content</p>
</.card>
```
### Named Slots
```elixir
slot :header
slot :inner_block, required: true
slot :footer
def modal(assigns) do
~H"""
<div class="modal">
<header :if={@header != []}>
<%= render_slot(@header) %>
</header>
<main>
<%= render_slot(@inner_block) %>
</main>
<footer :if={@footer != []}>
<%= render_slot(@footer) %>
</footer>
</div>
"""
end
# Usage
<.modal>
<:header>Title</:header>
Main content
<:footer>
<button>Close</button>
</:footer>
</.modal>
```
### Slots with Arguments
```elixir
slot :col, doc: "Table columns" do
attr :label, :string, required: true
end
attr :rows, :list, required: true
def table(assigns) do
~H"""
<table>
<thead>
<tr>
<th :for={col <- @col}><%= col.label %></th>
</tr>
</thead>
<tbody>
<tr :for={row <- @rows}>
<td :for={col <- @col}>
<%= render_slot(col, row) %>
</td>
</tr>
</tbody>
</table>
"""
end
# Usage
<.table rows={@users}>
<:col :let={user} label="Name"><%= user.name %></:col>
<:col :let={user} label="Email"><%= user.email %></:col>
</.table>
```
## LiveComponent Gotchas
### Preserve inner_block in update/2
```elixir
# BAD - loses inner_block
def update(assigns, socket) do
{:ok, assign(socket, field: assigns.field)}
end
# GOOD - preserve inner_block
def update(assigns, socket) do
{:ok,
socket
|> assign(:field, assigns.field)
|> assign(:inner_block, assigns[:inner_block])}
end
# BETTER - assign all and override
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign(:computed, compute(assigns.field))}
end
```
### Target Events Correctly
```elixir
# Event goes to LiveComponent
<button phx-click="save" phx-target={@myself}>Save</button>
# Event goes to parent LiveView
<button phx-click="close">Close</button>
```
## Review Questions
1. Are function components used for stateless UI?
2. Do LiveComponents actually need component-local state?
3. Are slots properly declared with attr/slot?
4. Is inner_block preserved in update/2?
FILE:references/lifecycle.md
# LiveView Lifecycle
## Mount
### connected?/1 for Subscriptions
```elixir
def mount(_params, _session, socket) do
# Only subscribe when actually connected (not during static render)
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "updates")
end
{:ok, assign(socket, items: [])}
end
```
### Expensive Operations
```elixir
# BAD - blocks initial render
def mount(_params, _session, socket) do
items = Repo.all(Item) # Blocks!
{:ok, assign(socket, items: items)}
end
# GOOD - defer with assign_async
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Items")
|> assign_async(:items, fn -> {:ok, %{items: Repo.all(Item)}} end)}
end
```
## handle_params
### URL-Based State
```elixir
def handle_params(%{"page" => page}, _uri, socket) do
page =
case Integer.parse(page) do
{n, ""} when n > 0 -> n
_ -> 1
end
{:noreply, assign(socket, page: page, items: load_page(page))}
end
def handle_params(_params, _uri, socket) do
{:noreply, assign(socket, page: 1, items: load_page(1))}
end
```
### Live Navigation
```elixir
# Triggers handle_params, not full remount
<.link patch={~p"/items?page=2"}>Page 2</.link>
# Full remount (different LiveView)
<.link navigate={~p"/other"}>Other Page</.link>
```
## handle_event
### Pattern Match Events
```elixir
def handle_event("save", %{"form" => form_params}, socket) do
# Handle save
end
def handle_event("delete", %{"id" => id}, socket) do
# Handle delete
end
def handle_event("toggle", %{"value" => value}, socket) do
# Handle toggle
end
```
### Form Events
```elixir
def handle_event("validate", %{"user" => params}, socket) do
changeset =
socket.assigns.user
|> User.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, changeset: changeset)}
end
def handle_event("save", %{"user" => params}, socket) do
case Accounts.update_user(socket.assigns.user, params) do
{:ok, user} ->
{:noreply,
socket
|> put_flash(:info, "Saved!")
|> push_navigate(to: ~p"/users/#{user}")}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
```
## handle_async
### With assign_async
```elixir
def mount(_params, _session, socket) do
{:ok,
socket
|> assign_async(:user, fn -> {:ok, %{user: load_user()}} end)}
end
# In template - handle all states
<.async_result :let={user} assign={@user}>
<:loading>Loading user...</:loading>
<:failed :let={reason}>Error: <%= inspect(reason) %></:failed>
<%= user.name %>
</.async_result>
```
### With start_async
```elixir
def handle_event("refresh", _, socket) do
{:noreply, start_async(socket, :refresh, fn -> fetch_data() end)}
end
def handle_async(:refresh, {:ok, data}, socket) do
{:noreply, assign(socket, data: data)}
end
def handle_async(:refresh, {:exit, reason}, socket) do
{:noreply, put_flash(socket, :error, "Refresh failed")}
end
```
## Review Questions
1. Are PubSub subscriptions wrapped in connected?(socket)?
2. Is handle_params used for URL-based state changes?
3. Do async operations handle both success and failure?
4. Is expensive loading deferred with assign_async?
FILE:references/security.md
# LiveView Security
## Event Authorization
### Every Event Must Authorize
```elixir
# BAD - trusts phx-value blindly
def handle_event("delete", %{"id" => id}, socket) do
Post.delete!(id) # Anyone can delete any post!
{:noreply, socket}
end
# GOOD - verify ownership
def handle_event("delete", %{"id" => id}, socket) do
post = Posts.get_post!(id)
if authorized?(socket.assigns.current_user, :delete, post) do
Posts.delete_post!(post)
{:noreply, stream_delete(socket, :posts, post)}
else
{:noreply, put_flash(socket, :error, "Not authorized")}
end
end
defp authorized?(user, :delete, post) do
user.id == post.user_id || user.admin
end
```
### Don't Trust phx-value
```heex
<%# This is user-modifiable in browser DevTools! %>
<button phx-click="edit" phx-value-id={@post.id} phx-value-role="admin">
Edit
</button>
```
Always validate on server:
```elixir
def handle_event("edit", %{"id" => id, "role" => _role}, socket) do
# Ignore client-provided role, check actual user
if socket.assigns.current_user.admin do
# Allow edit
end
end
```
## Sensitive Data in Assigns
### What Goes in Assigns is Visible
LiveView assigns can be inspected:
- In browser DevTools (morphdom payloads)
- In crash reports
- In logs
```elixir
# BAD - sensitive data in assigns
socket
|> assign(:user, %{
email: "[email protected]",
password_hash: "...", # Sensitive!
api_token: "secret123" # Sensitive!
})
# GOOD - only needed fields
socket
|> assign(:user, %{
id: user.id,
name: user.name,
email: user.email
})
```
### Use Session for Sensitive State
```elixir
# In mount, fetch from session
def mount(_params, session, socket) do
user_id = session["user_id"]
user = Accounts.get_user!(user_id)
{:ok, assign(socket, current_user: %{id: user.id, name: user.name})}
end
```
## Input Validation
### Validate All User Input
```elixir
def handle_event("update", %{"quantity" => qty}, socket) do
# BAD - no validation
{:noreply, assign(socket, quantity: String.to_integer(qty))}
end
def handle_event("update", %{"quantity" => qty}, socket) do
# GOOD - validate
case Integer.parse(qty) do
{n, ""} when n > 0 and n <= 100 ->
{:noreply, assign(socket, quantity: n)}
_ ->
{:noreply, put_flash(socket, :error, "Invalid quantity")}
end
end
```
### Use Changesets
```elixir
def handle_event("save", %{"post" => params}, socket) do
changeset = Post.changeset(%Post{}, params)
if changeset.valid? do
# Proceed
else
{:noreply, assign(socket, changeset: changeset)}
end
end
```
## CSRF Protection
LiveView has built-in CSRF protection via the socket token. Ensure:
```elixir
# In app.js
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken} # Required!
})
```
## File Uploads
### Validate File Types
```elixir
allow_upload(socket, :avatar,
accept: ~w(.jpg .jpeg .png), # Whitelist extensions
max_file_size: 5_000_000, # 5MB limit
max_entries: 1
)
```
### Validate in consume_uploaded_entries
```elixir
def handle_event("save", _, socket) do
uploaded_files =
consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry ->
# Validate actual file content, not just extension
case ExImageInfo.info(File.read!(path)) do
{"image/jpeg", _, _} -> {:ok, save_file(path)}
{"image/png", _, _} -> {:ok, save_file(path)}
_ -> {:error, :invalid_file_type}
end
end)
end
```
## Review Questions
1. Does every handle_event validate authorization?
2. Is phx-value data treated as untrusted?
3. Are sensitive fields excluded from assigns?
4. Are file uploads validated by content, not just extension?
Reviews ExUnit test code for proper patterns, boundary mocking with Mox, and test adapter usage. Use when reviewing _test.exs files or test helper configurat...
---
name: exunit-code-review
description: Reviews ExUnit test code for proper patterns, boundary mocking with Mox, and test adapter usage. Use when reviewing _test.exs files or test helper configurations.
---
# ExUnit Code Review
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| Async tests, setup, describe, tags | [references/exunit-patterns.md](references/exunit-patterns.md) |
| Behavior-based mocking, expectations | [references/mox-boundaries.md](references/mox-boundaries.md) |
| Bypass, Swoosh, Oban testing | [references/test-adapters.md](references/test-adapters.md) |
| What to mock vs real, Ecto sandbox | [references/integration-tests.md](references/integration-tests.md) |
## Mock Boundary Philosophy
**Mock at external boundaries:**
- HTTP clients, external APIs, third-party services
- Slow resources: file system, email, job queues
- Non-deterministic: DateTime.utc_now(), :rand
**DO NOT mock internal code:**
- Contexts, schemas, GenServers
- Internal modules, PubSub
- Anything you wrote
## Review Checklist
### Test Structure
- [ ] Tests are `async: true` unless sharing database state
- [ ] Describe-blocks group related tests
- [ ] Setup extracts common test data
- [ ] Tests have clear arrange/act/assert structure
### Mocking
- [ ] Mox used for external boundaries (HTTP, APIs)
- [ ] Behaviors defined for mockable interfaces
- [ ] No mocking of internal modules
- [ ] verify_on_exit! in setup for strict mocking
### Test Adapters
- [ ] Bypass for HTTP endpoint mocking
- [ ] Swoosh.TestAdapter for email testing
- [ ] Oban.Testing for background job assertions
### Database
- [ ] Ecto.Adapters.SQL.Sandbox for isolation
- [ ] Async tests don't share database state
- [ ] Fixtures/factories used consistently
## Valid Patterns (Do NOT Flag)
- **Mock in unit test, real in integration** - Different test levels have different needs
- **Not mocking database in integration tests** - Database is internal
- **Simple inline test data** - Not everything needs factories
- **Testing private functions via public API** - Correct approach
## Context-Sensitive Rules
| Issue | Flag ONLY IF |
|-------|--------------|
| Not async | Test actually needs shared state |
| Missing mock | External call exists AND no mock/bypass |
| Mock internal | Module being mocked is internal code |
## Gates (sequence)
Complete **in order**. Do not emit a finding until the prior step passes for that issue.
1. **Evidence from the file** — Open the test module (or helper) and tie the claim to concrete lines.
- **Pass when:** Each prospective finding includes `[FILE:LINE]` **and** a one-line factual description of what is on that line (or an adjacent line you name), not a generic style complaint.
2. **ExUnit false-positive veto** — Check this skill’s **Valid Patterns** and **Context-Sensitive Rules** for the case.
- **Pass when:** You can state “not covered by Do NOT Flag / Flag ONLY IF” in one sentence, or you drop the finding.
3. **Cross-protocol verification** — Apply [review-verification-protocol](../review-verification-protocol/SKILL.md) (e.g. read full function/block, search usages before “unused” claims) to that same finding.
- **Pass when:** At least one protocol check relevant to the claim type is satisfied and would appear in your rationale if challenged.
## Before Submitting Findings
Use `[FILE:LINE] ISSUE_TITLE` per finding after **Gates (sequence)** and the linked protocol are satisfied.
FILE:references/exunit-patterns.md
# ExUnit Patterns
## Async Tests
### Default to Async
```elixir
# GOOD - isolated test
defmodule MyApp.CalculatorTest do
use ExUnit.Case, async: true
test "adds numbers" do
assert Calculator.add(1, 2) == 3
end
end
```
### When to Disable Async
```elixir
# Sharing database with other tests
defmodule MyApp.UserTest do
use MyApp.DataCase # Sets async: false if needed
test "creates user" do
assert {:ok, _} = Accounts.create_user(%{email: "[email protected]"})
end
end
```
## Describe Blocks
### Group Related Tests
```elixir
defmodule MyApp.UserTest do
use MyApp.DataCase
describe "create_user/1" do
test "with valid attrs creates user" do
assert {:ok, user} = Accounts.create_user(valid_attrs())
assert user.email == "[email protected]"
end
test "with invalid email returns error" do
assert {:error, changeset} = Accounts.create_user(%{email: "invalid"})
assert "is invalid" in errors_on(changeset).email
end
test "with duplicate email returns error" do
Accounts.create_user(valid_attrs())
assert {:error, changeset} = Accounts.create_user(valid_attrs())
assert "has already been taken" in errors_on(changeset).email
end
end
describe "authenticate_user/2" do
# ...
end
end
```
## Setup
### Shared Setup
```elixir
defmodule MyApp.PostTest do
use MyApp.DataCase
setup do
user = insert(:user)
{:ok, user: user}
end
test "creates post for user", %{user: user} do
assert {:ok, post} = Posts.create_post(user, %{title: "Test"})
assert post.user_id == user.id
end
end
```
### Setup per Describe
```elixir
describe "admin functions" do
setup do
admin = insert(:user, role: :admin)
{:ok, admin: admin}
end
test "admin can delete", %{admin: admin} do
# ...
end
end
describe "user functions" do
setup do
user = insert(:user, role: :user)
{:ok, user: user}
end
test "user cannot delete", %{user: user} do
# ...
end
end
```
## Tags
### Skip Tests
```elixir
@tag :skip
test "not implemented yet" do
end
# Run: mix test --exclude skip
```
### Slow Tests
```elixir
@tag :slow
test "integration with external service" do
end
# Run: mix test --exclude slow
# Or: mix test --only slow
```
### Custom Tags
```elixir
@tag :integration
test "full workflow" do
end
# In test_helper.exs
ExUnit.configure(exclude: [:integration])
# Run: mix test --include integration
```
## Assertions
### Pattern Matching
```elixir
# GOOD - precise matching
assert {:ok, %User{email: "[email protected]"}} = Accounts.create_user(attrs)
# GOOD - extract for further assertions
assert {:ok, user} = Accounts.create_user(attrs)
assert user.email == "[email protected]"
assert user.confirmed_at == nil
```
### Refute
```elixir
test "does not include deleted" do
refute deleted_user in Accounts.list_active_users()
end
```
### Assert Raise
```elixir
test "raises on invalid input" do
assert_raise ArgumentError, ~r/must be positive/, fn ->
Calculator.sqrt(-1)
end
end
```
## Review Questions
1. Are tests async when possible?
2. Are describe blocks used to group related tests?
3. Is setup used to reduce duplication?
4. Are assertions using pattern matching effectively?
FILE:references/integration-tests.md
# Integration Tests
## What to Mock vs Real
### Mock at External Boundaries Only
| Layer | Integration Test |
|-------|-----------------|
| HTTP endpoints | Mock with Bypass |
| Email delivery | Swoosh.TestAdapter |
| Payment processing | Mock |
| Database | Real (sandbox) |
| Contexts | Real |
| GenServers | Real |
| PubSub | Real |
### Example Integration Test
```elixir
defmodule MyAppWeb.RegistrationFlowTest do
use MyAppWeb.ConnCase
use Swoosh.TestAssertions
setup %{conn: conn} do
bypass = Bypass.open()
# Mock only external HTTP calls
Application.put_env(:my_app, :verification_api_url, "http://localhost:#{bypass.port}")
{:ok, conn: conn, bypass: bypass}
end
test "full registration flow", %{conn: conn, bypass: bypass} do
# Mock external email verification API
Bypass.expect_once(bypass, "POST", "/verify", fn conn ->
Plug.Conn.resp(conn, 200, ~s({"valid": true}))
end)
# Test real controller -> context -> repo flow
conn = post(conn, ~p"/register", %{
user: %{email: "[email protected]", password: "password123"}
})
assert redirected_to(conn) == ~p"/welcome"
# Verify real database state
assert user = Repo.get_by(User, email: "[email protected]")
assert user.confirmed_at == nil
# Verify real email was "sent" via test adapter
assert_email_sent(to: "[email protected]", subject: "Confirm your account")
end
end
```
## Ecto Sandbox
### Configuration
```elixir
# test/support/data_case.ex
defmodule MyApp.DataCase do
use ExUnit.CaseTemplate
using do
quote do
alias MyApp.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import MyApp.DataCase
end
end
setup tags do
MyApp.DataCase.setup_sandbox(tags)
:ok
end
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end
end
```
### Async vs Shared Mode
```elixir
# Async - each test gets its own transaction
defmodule MyApp.FastTest do
use MyApp.DataCase, async: true # Isolated, can run parallel
end
# Shared - tests share database connection
defmodule MyApp.SlowTest do
use MyApp.DataCase, async: false # Sequential, shared state
end
```
### Allowing Processes
```elixir
test "async process accesses database" do
# Allow spawned process to use test's database connection
Ecto.Adapters.SQL.Sandbox.allow(Repo, self(), some_pid)
# Or use :shared mode
Ecto.Adapters.SQL.Sandbox.mode(Repo, :shared)
end
```
## Test Data
### Fixtures vs Factories
```elixir
# Fixture - simple helper functions
defmodule MyApp.TestHelpers do
def user_fixture(attrs \\ %{}) do
{:ok, user} =
attrs
|> Enum.into(%{
email: "test#{System.unique_integer()}@example.com",
password: "password123"
})
|> Accounts.create_user()
user
end
end
# Factory - with ex_machina
defmodule MyApp.Factory do
use ExMachina.Ecto, repo: MyApp.Repo
def user_factory do
%MyApp.Accounts.User{
email: sequence(:email, &"user#{&1}@example.com"),
password_hash: Bcrypt.hash_pwd_salt("password123")
}
end
end
```
## LiveView Integration Tests
### Testing Async Assigns
```elixir
defmodule MyAppWeb.DashboardLiveTest do
use MyAppWeb.ConnCase
import Phoenix.LiveViewTest
test "loads data asynchronously", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/dashboard")
# Initial render shows loading state
assert html =~ "Loading..."
# Wait for async to complete
assert render_async(view) =~ "Dashboard Data"
end
end
```
### Testing Events
```elixir
test "handles form submission", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/posts/new")
view
|> form("#post-form", post: %{title: "Test", body: "Content"})
|> render_submit()
assert_redirect(view, ~p"/posts")
assert Repo.get_by(Post, title: "Test")
end
```
## Review Questions
1. Are only external boundaries mocked in integration tests?
2. Is Ecto sandbox properly configured?
3. Are async tests truly isolated?
4. Is test data created consistently (fixtures or factories)?
FILE:references/mox-boundaries.md
# Mox Boundaries
## Core Principle
Mock at **external boundaries**, not internal code.
| Mock | Don't Mock |
|------|------------|
| HTTP clients | Contexts |
| External APIs | Schemas |
| Email delivery | GenServers |
| Payment processors | Internal modules |
| Time/randomness | PubSub |
## Setting Up Mox
### Define Behaviors
```elixir
# lib/my_app/http_client.ex
defmodule MyApp.HTTPClient do
@callback get(String.t()) :: {:ok, map()} | {:error, term()}
@callback post(String.t(), map()) :: {:ok, map()} | {:error, term()}
end
# lib/my_app/http_client/hackney.ex
defmodule MyApp.HTTPClient.Hackney do
@behaviour MyApp.HTTPClient
@impl true
def get(url), do: # real implementation
@impl true
def post(url, body), do: # real implementation
end
```
### Configure Mock
```elixir
# test/support/mocks.ex
Mox.defmock(MyApp.HTTPClientMock, for: MyApp.HTTPClient)
# config/test.exs
config :my_app, http_client: MyApp.HTTPClientMock
# config/prod.exs
config :my_app, http_client: MyApp.HTTPClient.Hackney
```
### Inject Dependency
```elixir
defmodule MyApp.ExternalService do
@http_client Application.compile_env(:my_app, :http_client)
def fetch_data(id) do
@http_client.get("/api/data/#{id}")
end
end
```
## Writing Tests with Mox
### Basic Expectation
```elixir
defmodule MyApp.ExternalServiceTest do
use ExUnit.Case, async: true
import Mox
setup :set_mox_from_context
setup :verify_on_exit!
test "fetches data successfully" do
expect(MyApp.HTTPClientMock, :get, fn "/api/data/123" ->
{:ok, %{"name" => "Test"}}
end)
assert {:ok, %{"name" => "Test"}} = ExternalService.fetch_data(123)
end
end
```
### Multiple Calls
```elixir
test "retries on failure" do
MyApp.HTTPClientMock
|> expect(:get, fn _ -> {:error, :timeout} end)
|> expect(:get, fn _ -> {:ok, %{}} end)
assert {:ok, _} = ExternalService.fetch_with_retry(123)
end
```
### Stub for Default Behavior
```elixir
setup do
stub(MyApp.HTTPClientMock, :get, fn _ -> {:ok, %{}} end)
:ok
end
```
### Allow for Async
```elixir
test "async process uses mock" do
parent = self()
ref = make_ref()
expect(MyApp.HTTPClientMock, :get, fn _ ->
send(parent, {ref, :called})
{:ok, %{}}
end)
# Start the task first to get its pid
task = Task.async(fn ->
ExternalService.fetch_data(123)
end)
# Parent grants permission to the child process BEFORE it uses the mock
Mox.allow(MyApp.HTTPClientMock, parent, task.pid)
Task.await(task)
assert_receive {^ref, :called}
end
```
## Anti-Patterns
### DON'T Mock Internal Modules
```elixir
# BAD - mocking your own context
defmock(MyApp.AccountsMock, for: MyApp.Accounts)
test "controller creates user" do
expect(MyApp.AccountsMock, :create_user, fn _ -> {:ok, %User{}} end)
# This tests nothing meaningful!
end
# GOOD - test the real context
test "controller creates user" do
conn = post(conn, ~p"/users", %{user: valid_attrs()})
assert %{"id" => _} = json_response(conn, 201)
assert Repo.get_by(User, email: "[email protected]")
end
```
### DON'T Mock Database
```elixir
# BAD
defmock(MyApp.RepoMock, for: Ecto.Repo)
# GOOD - use Ecto.Adapters.SQL.Sandbox
use MyApp.DataCase
```
## Review Questions
1. Are only external boundaries being mocked?
2. Are behaviors defined for mockable interfaces?
3. Is verify_on_exit! used in setup?
4. Are internal modules tested with real implementations?
FILE:references/test-adapters.md
# Test Adapters
## Bypass for HTTP
### Setup
```elixir
# mix.exs
{:bypass, "~> 2.1", only: :test}
```
### Basic Usage
```elixir
defmodule MyApp.APIClientTest do
use ExUnit.Case, async: true
setup do
bypass = Bypass.open()
{:ok, bypass: bypass}
end
test "fetches user data", %{bypass: bypass} do
Bypass.expect_once(bypass, "GET", "/users/123", fn conn ->
Plug.Conn.resp(conn, 200, ~s({"id": 123, "name": "Test"}))
end)
assert {:ok, user} = APIClient.get_user("http://localhost:#{bypass.port}", 123)
assert user.name == "Test"
end
test "handles server error", %{bypass: bypass} do
Bypass.expect_once(bypass, "GET", "/users/123", fn conn ->
Plug.Conn.resp(conn, 500, "Internal Server Error")
end)
assert {:error, :server_error} = APIClient.get_user("http://localhost:#{bypass.port}", 123)
end
end
```
### Verify Request Body
```elixir
test "sends correct payload", %{bypass: bypass} do
Bypass.expect_once(bypass, "POST", "/webhooks", fn conn ->
{:ok, body, conn} = Plug.Conn.read_body(conn)
assert %{"event" => "user.created"} = Jason.decode!(body)
Plug.Conn.resp(conn, 200, "OK")
end)
Webhooks.notify(:user_created, %{id: 1})
end
```
## Swoosh for Email
### Configuration
```elixir
# config/test.exs
config :my_app, MyApp.Mailer, adapter: Swoosh.Adapters.Test
```
### Testing Emails
```elixir
defmodule MyApp.NotificationsTest do
use ExUnit.Case, async: true
use Swoosh.TestAssertions
test "sends welcome email" do
user = %{email: "[email protected]", name: "Test"}
Notifications.send_welcome(user)
assert_email_sent(
to: "[email protected]",
subject: "Welcome!"
)
end
test "includes user name in body" do
user = %{email: "[email protected]", name: "Alice"}
Notifications.send_welcome(user)
assert_email_sent(fn email ->
assert email.html_body =~ "Hello, Alice"
end)
end
end
```
## Oban for Jobs
### Configuration
```elixir
# config/test.exs
config :my_app, Oban, testing: :inline # Jobs run immediately
# OR
config :my_app, Oban, testing: :manual # Control when jobs run
```
### Testing Job Enqueue
```elixir
defmodule MyApp.WorkerTest do
use MyApp.DataCase
use Oban.Testing, repo: MyApp.Repo
test "enqueues email job" do
Notifications.schedule_email(user_id: 123)
assert_enqueued(worker: MyApp.Workers.EmailWorker, args: %{user_id: 123})
end
test "job processes correctly" do
assert :ok = perform_job(MyApp.Workers.EmailWorker, %{user_id: 123})
end
end
```
### Testing with :manual mode
```elixir
# config/test.exs
config :my_app, Oban, testing: :manual
test "job side effects" do
# Enqueue job
Notifications.schedule_email(user_id: 123)
# Job not yet run
refute_email_sent()
# Manually drain the queue
Oban.drain_queue(queue: :mailers)
# Now email sent
assert_email_sent(to: "[email protected]")
end
```
## DateTime Mocking
### Simple Approach
```elixir
# In code, accept optional time
def expires_at(duration, now \\ DateTime.utc_now()) do
DateTime.add(now, duration, :second)
end
# In test
test "calculates expiry" do
now = ~U[2024-01-01 12:00:00Z]
assert Token.expires_at(3600, now) == ~U[2024-01-01 13:00:00Z]
end
```
### With Mox
```elixir
# Define behavior
defmodule MyApp.Clock do
@callback utc_now() :: DateTime.t()
end
# Production implementation
defmodule MyApp.Clock.System do
@behaviour MyApp.Clock
def utc_now, do: DateTime.utc_now()
end
# Test mock
Mox.defmock(MyApp.ClockMock, for: MyApp.Clock)
# In test
expect(MyApp.ClockMock, :utc_now, fn -> ~U[2024-01-01 12:00:00Z] end)
```
## Review Questions
1. Is Bypass used for HTTP endpoint mocking?
2. Is Swoosh.TestAdapter configured for email tests?
3. Is Oban.Testing used for job assertions?
4. Are time-dependent tests properly controlled?
Configures ExDoc for Elixir projects including mix.exs setup, extras, groups, cheatsheets, and livebooks. Use when setting up or modifying ExDoc documentatio...
---
name: exdoc-config
description: Configures ExDoc for Elixir projects including mix.exs setup, extras, groups, cheatsheets, and livebooks. Use when setting up or modifying ExDoc documentation generation.
---
# ExDoc Configuration
## Quick Reference
| Topic | Reference |
|-------|-----------|
| Markdown, cheatsheets (.cheatmd), livebooks (.livemd) | [references/extras-formats.md](references/extras-formats.md) |
| Custom head/body tags, syntax highlighting, nesting, annotations | [references/advanced-config.md](references/advanced-config.md) |
## Gates
Use this sequence before claiming ExDoc is wired correctly or that docs build:
1. **Dependencies resolved** — Run `mix deps.get` from the project root. **Pass:** exit code `0`, and `mix.exs` includes `ex_doc` as in [Dependency Setup](#dependency-setup) (or the project’s equivalent dev-only docs dep).
2. **Extra paths real** — For every path string in `extras/0` (and in `groups_for_extras/0` if used), confirm that path exists in the repo **or** you have just created that file. **Pass:** no stale or typo paths remain when you run `mix docs`.
3. **Docs build** — Run `mix docs`. **Pass:** exit code `0`, and the HTML entry exists at `<output>/index.html` (default `<project>/doc/index.html`; use `docs: [output: ...]` if you changed `output`).
For cheatsheets, livebooks, or custom head/body assets, follow the same “path exists before listing” rule; see [When to Load References](#when-to-load-references).
## Dependency Setup
Add ExDoc to `mix.exs` deps:
```elixir
defp deps do
[
{:ex_doc, "~> 0.34", only: :dev, runtime: false}
]
end
```
## Project Configuration
Configure your `project/0` function in `mix.exs`:
```elixir
def project do
[
app: :weather_station,
version: "0.1.0",
elixir: "~> 1.17",
start_permanent: Mix.env() == :prod,
deps: deps(),
# ExDoc
name: "WeatherStation",
source_url: "https://github.com/acme/weather_station",
homepage_url: "https://acme.github.io/weather_station",
docs: docs()
]
end
```
## The docs/0 Function
Define a private `docs/0` function to keep project config clean:
```elixir
defp docs do
[
main: "readme",
logo: "priv/static/images/logo.png",
output: "doc",
formatters: ["html", "epub"],
source_ref: "v#{@version}",
extras: extras(),
groups_for_modules: groups_for_modules(),
groups_for_extras: groups_for_extras()
]
end
```
### Key Options
| Option | Default | Description |
|--------|---------|-------------|
| `main` | `"api-reference"` | Landing page module name or extra filename (without extension) |
| `logo` | `nil` | Path to logo image displayed in sidebar |
| `output` | `"doc"` | Output directory for generated docs |
| `formatters` | `["html"]` | List of output formats (`"html"`, `"epub"`) |
| `source_ref` | `"main"` | Git ref used for "View Source" links |
| `assets` | `nil` | Map of source directory to target directory for static assets |
| `deps` | `[]` | Links to dependency documentation |
### Setting the Landing Page
The `main` option controls what users see first:
```elixir
# Use the README as the landing page (most common)
docs: [main: "readme"]
# Use a specific module as the landing page
docs: [main: "WeatherStation"]
# Use a custom guide
docs: [main: "getting-started"]
```
The value matches the extra filename without its extension, or a module name.
## Extras
Extras are additional pages beyond the API reference. Add them as a list of file paths:
```elixir
defp extras do
[
"README.md",
"CHANGELOG.md",
"LICENSE.md",
"guides/getting-started.md",
"guides/configuration.md",
"guides/deployment.md",
"cheatsheets/query-syntax.cheatmd",
"notebooks/data-pipeline.livemd"
]
end
```
### Controlling Extra Titles
By default, ExDoc uses the first `h1` heading as the title. Override with a keyword tuple:
```elixir
defp extras do
[
{"README.md", [title: "Overview"]},
{"CHANGELOG.md", [title: "Changelog"]},
"guides/getting-started.md"
]
end
```
### Ordering
Extras appear in the sidebar in the order listed. Put the most important pages first:
```elixir
defp extras do
[
"README.md",
"guides/getting-started.md",
"guides/architecture.md",
"guides/deployment.md",
"CHANGELOG.md"
]
end
```
## Grouping
### Grouping Modules
Organize modules into logical sections in the sidebar:
```elixir
defp groups_for_modules do
[
"Sensors": [
WeatherStation.Sensor,
WeatherStation.Sensor.Temperature,
WeatherStation.Sensor.Humidity,
WeatherStation.Sensor.Pressure
],
"Data Processing": [
WeatherStation.Pipeline,
WeatherStation.Pipeline.Transform,
WeatherStation.Pipeline.Aggregate
],
"Storage": [
WeatherStation.Repo,
WeatherStation.Schema.Reading,
WeatherStation.Schema.Station
]
]
end
```
Use regex to group by pattern:
```elixir
defp groups_for_modules do
[
"Sensors": [~r/Sensor/],
"Schemas": [~r/Schema/],
"Pipeline": [~r/Pipeline/]
]
end
```
Modules not matching any group appear under a default "Modules" heading.
### Grouping Functions
Group functions within a module using `groups_for_docs`:
```elixir
defp docs do
[
groups_for_docs: [
"Lifecycle": &(&1[:section] == :lifecycle),
"Queries": &(&1[:section] == :queries),
"Mutations": &(&1[:section] == :mutations)
]
]
end
```
Tag functions in your module with `@doc` metadata:
```elixir
@doc section: :lifecycle
def start_link(opts), do: GenServer.start_link(__MODULE__, opts)
@doc section: :queries
def get_reading(station_id), do: Repo.get(Reading, station_id)
```
### Grouping Extras
Organize guides, cheatsheets, and notebooks in the sidebar:
```elixir
defp groups_for_extras do
[
"Guides": [
"guides/getting-started.md",
"guides/configuration.md",
"guides/deployment.md"
],
"Cheatsheets": [
"cheatsheets/query-syntax.cheatmd",
"cheatsheets/ecto-types.cheatmd"
],
"Tutorials": [
"notebooks/data-pipeline.livemd",
"notebooks/sensor-setup.livemd"
]
]
end
```
Use glob patterns for convenience:
```elixir
defp groups_for_extras do
[
"Guides": ~r/guides\/.*/,
"Cheatsheets": ~r/cheatsheets\/.*/,
"Tutorials": ~r/notebooks\/.*/
]
end
```
## Dependency Doc Links
Link to documentation for your dependencies so ExDoc cross-references resolve:
```elixir
defp docs do
[
deps: [
ecto: "https://hexdocs.pm/ecto",
phoenix: "https://hexdocs.pm/phoenix",
plug: "https://hexdocs.pm/plug"
]
]
end
```
This enables references like `t:Ecto.Schema.t/0` to link directly to the dependency docs.
## Generating Docs
```bash
# Generate HTML docs
mix docs
# Open in browser
open doc/index.html
```
## Complete mix.exs Example
```elixir
defmodule WeatherStation.MixProject do
use Mix.Project
@version "1.3.0"
@source_url "https://github.com/acme/weather_station"
def project do
[
app: :weather_station,
version: @version,
elixir: "~> 1.17",
start_permanent: Mix.env() == :prod,
deps: deps(),
name: "WeatherStation",
source_url: @source_url,
homepage_url: "https://acme.github.io/weather_station",
docs: docs()
]
end
defp docs do
[
main: "readme",
logo: "priv/static/images/logo.png",
source_ref: "v#{@version}",
formatters: ["html"],
extras: extras(),
groups_for_modules: groups_for_modules(),
groups_for_extras: groups_for_extras(),
deps: [
ecto: "https://hexdocs.pm/ecto",
phoenix: "https://hexdocs.pm/phoenix"
]
]
end
defp extras do
[
"README.md",
"CHANGELOG.md",
"guides/getting-started.md",
"guides/configuration.md",
"guides/deployment.md",
"cheatsheets/query-syntax.cheatmd",
"notebooks/data-pipeline.livemd"
]
end
defp groups_for_modules do
[
"Sensors": [~r/Sensor/],
"Data Processing": [~r/Pipeline/],
"Storage": [~r/Schema|Repo/]
]
end
defp groups_for_extras do
[
"Guides": ~r/guides\/.*/,
"Cheatsheets": ~r/cheatsheets\/.*/,
"Tutorials": ~r/notebooks\/.*/
]
end
defp deps do
[
{:phoenix, "~> 1.7"},
{:ecto_sql, "~> 3.12"},
{:ex_doc, "~> 0.34", only: :dev, runtime: false}
]
end
end
```
## When to Load References
- Setting up cheatsheets or livebooks as extras -> extras-formats.md
- Injecting custom CSS/JS, configuring syntax highlighting, or tuning module nesting -> advanced-config.md
FILE:references/advanced-config.md
# Advanced Configuration
## Injecting Custom HTML
### before_closing_head_tag
Inject CSS or meta tags into the `<head>` section. Accepts a function that receives the format (`:html` or `:epub`):
```elixir
defp docs do
[
before_closing_head_tag: &before_closing_head_tag/1
]
end
defp before_closing_head_tag(:html) do
"""
<style>
.content-inner {
max-width: 900px;
}
.deprecated .detail-header {
background-color: #fff3cd;
}
</style>
"""
end
defp before_closing_head_tag(:epub), do: ""
```
### before_closing_body_tag
Inject JavaScript before the closing `</body>` tag. Useful for analytics, custom interactions, or additional syntax highlighting:
```elixir
defp docs do
[
before_closing_body_tag: &before_closing_body_tag/1
]
end
defp before_closing_body_tag(:html) do
"""
<script>
document.querySelectorAll('pre code').forEach((block) => {
block.addEventListener('click', () => {
navigator.clipboard.writeText(block.innerText);
});
});
</script>
"""
end
defp before_closing_body_tag(:epub), do: ""
```
### Format-Specific Injection
Both hooks receive the format atom, allowing different content per output:
```elixir
defp before_closing_head_tag(:html) do
"""
<link rel="stylesheet" href="assets/custom.css">
"""
end
defp before_closing_head_tag(:epub) do
"""
<style>
/* epub-specific overrides */
.content { font-size: 14pt; }
</style>
"""
end
```
## Syntax Highlighting
ExDoc uses the [Makeup](https://hexdocs.pm/makeup) library for syntax highlighting. Elixir and Erlang are included by default.
### Adding Language Support
Add Makeup lexer packages to your deps for additional languages:
```elixir
defp deps do
[
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
{:makeup_html, ">= 0.0.0", only: :dev, runtime: false},
{:makeup_json, ">= 0.0.0", only: :dev, runtime: false},
{:makeup_diff, ">= 0.0.0", only: :dev, runtime: false},
{:makeup_sql, ">= 0.0.0", only: :dev, runtime: false}
]
end
```
Available Makeup lexers:
| Package | Languages |
|---------|-----------|
| `makeup_elixir` | Elixir (included by default) |
| `makeup_erlang` | Erlang (included by default) |
| `makeup_html` | HTML |
| `makeup_json` | JSON |
| `makeup_diff` | Diff/patch |
| `makeup_sql` | SQL |
| `makeup_eex` | EEx templates |
| `makeup_c` | C |
| `makeup_rust` | Rust |
Languages without a Makeup lexer fall back to plain text rendering.
## Module Nesting
### Automatic Nesting
ExDoc automatically nests modules based on their naming hierarchy. For example:
- `WeatherStation.Sensor` appears as a top-level module
- `WeatherStation.Sensor.Temperature` nests under `WeatherStation.Sensor`
- `WeatherStation.Sensor.Temperature.Calibration` nests under `WeatherStation.Sensor.Temperature`
This creates a collapsible tree in the sidebar.
### nest_modules_by_prefix
Control which prefixes trigger nesting. By default, ExDoc nests all modules. Use `nest_modules_by_prefix` to restrict it:
```elixir
defp docs do
[
nest_modules_by_prefix: [
WeatherStation.Sensor,
WeatherStation.Pipeline
]
]
end
```
With this config:
- `WeatherStation.Sensor.Temperature` nests under `WeatherStation.Sensor`
- `WeatherStation.Pipeline.Transform` nests under `WeatherStation.Pipeline`
- `WeatherStation.Schema.Reading` stays at the top level (prefix not listed)
Set to an empty list to disable all nesting:
```elixir
nest_modules_by_prefix: []
```
## The api-reference Page
ExDoc generates an `api-reference` page by default, listing all documented modules. This is the default landing page unless you set `main` to something else.
The page groups modules according to `groups_for_modules` and shows a brief description from each module's `@moduledoc`.
To make it the explicit landing page:
```elixir
docs: [main: "api-reference"]
```
## Suppressing Warnings
### skip_undefined_reference_warnings_on
Suppress warnings about undefined references in specific pages. Useful for changelogs and guides that mention modules or functions from other projects:
```elixir
defp docs do
[
skip_undefined_reference_warnings_on: [
"CHANGELOG.md",
"guides/migration-from-v1.md"
]
]
end
```
### skip_code_autolink_to
Prevent ExDoc from auto-linking specific terms that look like module or function references but are not:
```elixir
defp docs do
[
skip_code_autolink_to: [
"Ecto.Schema",
"Phoenix.Controller",
"mix phx.gen.schema"
]
]
end
```
Use this when you reference external modules that are not in your deps, or when backticked terms like `` `Config` `` should not link to a module.
## Annotations
Add version or status annotations that appear next to module names in the sidebar:
```elixir
defp docs do
[
annotations_for_docs: fn metadata ->
cond do
metadata[:since] -> "since #{metadata[:since]}"
metadata[:deprecated] -> "deprecated"
true -> nil
end
end
]
end
```
Tag functions in your code:
```elixir
@doc since: "1.2.0"
def stream_readings(station_id, opts \\ []) do
# ...
end
@doc deprecated: "Use stream_readings/2 instead"
def poll_readings(station_id) do
# ...
end
```
## Static Assets
Include images, CSS, or other static files in your generated docs:
```elixir
defp docs do
[
assets: %{
"guides/images" => "images",
"guides/diagrams" => "diagrams"
}
]
end
```
This copies files from the source directory (key) to the target directory (value) inside the generated docs. Reference them in your extras:
```markdown
## System Architecture

## Sensor Placement

```
### Asset Path Rules
- Source paths are relative to the project root
- Target paths are relative to the doc output directory
- Files are copied as-is (no processing)
- Use consistent directory names between source and your markdown references
## Complete Advanced Example
```elixir
defp docs do
[
main: "readme",
logo: "priv/static/images/logo.png",
source_ref: "v#{@version}",
extras: extras(),
groups_for_modules: groups_for_modules(),
groups_for_extras: groups_for_extras(),
nest_modules_by_prefix: [
WeatherStation.Sensor,
WeatherStation.Pipeline,
WeatherStation.Schema
],
skip_undefined_reference_warnings_on: ["CHANGELOG.md"],
skip_code_autolink_to: ["Config"],
assets: %{"guides/images" => "images"},
before_closing_head_tag: &before_closing_head_tag/1,
before_closing_body_tag: &before_closing_body_tag/1,
deps: [
ecto: "https://hexdocs.pm/ecto",
phoenix: "https://hexdocs.pm/phoenix"
]
]
end
```
FILE:references/extras-formats.md
# Extras Formats
ExDoc supports three formats for extra pages: Markdown, Cheatsheets, and Livebooks.
## Markdown (.md)
Standard Markdown files for long-form documentation. Use for conceptual guides, architecture overviews, getting started guides, and changelogs.
### Structure
```markdown
# Getting Started with WeatherStation
## Prerequisites
- Elixir 1.17+
- PostgreSQL 15+
## Installation
Add `weather_station` to your dependencies:
{:weather_station, "~> 1.3"}
## Configuration
Configure your sensor endpoints in `config/config.exs`:
config :weather_station,
sensors: ["temp_01", "humidity_01"],
poll_interval: :timer.seconds(30)
```
### Tips
- Use the first `h1` heading as the page title (ExDoc picks it up automatically)
- Fenced code blocks with language tags get syntax highlighting
- Relative links between extras work: `[Configuration](configuration.md)`
- Link to modules with backticks: `` `WeatherStation.Sensor` ``
- Link to functions: `` `WeatherStation.Sensor.read/1` ``
## Cheatsheets (.cheatmd)
Quick-reference cards rendered in a visual card layout. Use for syntax summaries, common patterns, and lookup tables.
### Basic Structure
A cheatsheet uses specific heading levels to create the card layout:
- `h1` (`#`) -- Page title
- `h2` (`##`) -- Section heading (rendered as a card group)
- `h3` (`###`) -- Individual card within a section
- Content under each `h3` becomes the card body
### Example Cheatsheet
```markdown
# Ecto Query Syntax
## Basic Queries
### Select all records
```elixir
Repo.all(User)
```
### Filter with where
```elixir
from u in User,
where: u.active == true,
select: u
```
### Limit and offset
```elixir
from u in User,
limit: 10,
offset: 20
```
## Associations
### Preload associations
```elixir
Repo.all(User) |> Repo.preload(:posts)
# Or in the query
from u in User, preload: [:posts]
```
### Join and select
```elixir
from u in User,
join: p in assoc(u, :posts),
where: p.published == true,
select: {u.name, p.title}
```
```
### Card Layout Rules
- Each `h2` section becomes a visually distinct group with a header
- Each `h3` under an `h2` becomes a card in that group
- Code blocks inside cards are rendered as styled examples
- Keep card content concise -- cheatsheets are for quick scanning
- Plain text under `h2` (before any `h3`) appears as an intro for that section
- Content before the first `h2` appears as a page introduction
- Cheatsheets support only a limited subset of Markdown -- headings, plain text, fenced code blocks, and inline attributes
### Layout Attributes
ExDoc provides inline attributes on `h2` and `h3` headers to control card layout:
**Column layouts (on `h2` sections):**
- `{: .col-2}` -- Two equal columns
- `{: .col-3}` -- Three equal columns
- `{: .col-2-left}` -- Two columns, left column wider
**List layouts (on `h3` cards):**
- `{: .list-4}` -- Four-column list
- `{: .list-6}` -- Six-column list
**Width control (on `h2` sections):**
- `{: .width-50}` -- Half-width section
```markdown
## API
{: .col-2}
### Functions
{: .list-6}
* `foo/1`
* `bar/2`
* `baz/3`
```
### When to Use Cheatsheets
- API quick reference (common function calls)
- Syntax summaries (Ecto queries, Phoenix routes)
- Configuration option lookup tables
- Migration from another library (side-by-side comparisons)
## Livebooks (.livemd)
Interactive Livebook notebooks that render as rich documentation pages. Use for tutorials, data exploration walkthroughs, and interactive examples.
### How They Render in ExDoc
ExDoc renders `.livemd` files as static documentation pages with:
- Code cells displayed as syntax-highlighted code blocks
- Markdown cells rendered normally
- A "Run in Livebook" badge linking to the raw `.livemd` file so readers can open it interactively
- Mermaid diagrams rendered if present
- Output cells are **not** included -- only source and markdown cells render
### Example Livebook Structure
```markdown
# Data Pipeline Tutorial
## Setup
```elixir
Mix.install([
{:weather_station, "~> 1.3"},
{:kino, "~> 0.14"}
])
```
## Connecting to Sensors
First, start the sensor supervisor:
```elixir
{:ok, pid} = WeatherStation.Sensor.Supervisor.start_link(
sensors: ["temp_01", "humidity_01"]
)
```
## Reading Data
Poll the sensors and inspect the results:
```elixir
readings = WeatherStation.Sensor.read_all()
Kino.DataTable.new(readings)
```
```
### Best Practices for Livebooks in ExDoc
- Include `Mix.install/1` in the first code cell so readers can run the notebook standalone
- Write Livebooks that make sense both as static docs and interactive sessions
- Use Markdown cells to explain what each code cell does
- Keep notebooks focused on a single workflow or concept
- Place livebooks in a dedicated directory (e.g., `notebooks/` or `livebooks/`)
## Organizing Extras
### Directory Conventions
```text
project/
README.md
CHANGELOG.md
guides/
getting-started.md
configuration.md
deployment.md
architecture.md
cheatsheets/
query-syntax.cheatmd
router-helpers.cheatmd
notebooks/
data-pipeline.livemd
sensor-setup.livemd
```
### Ordering in Sidebar
Extras appear in the order they are listed in the `extras` option. Group related pages together:
```elixir
defp extras do
[
"README.md",
"guides/getting-started.md",
"guides/configuration.md",
"guides/architecture.md",
"guides/deployment.md",
"cheatsheets/query-syntax.cheatmd",
"cheatsheets/router-helpers.cheatmd",
"notebooks/data-pipeline.livemd",
"CHANGELOG.md"
]
end
```
### Naming Conventions
- Use kebab-case for filenames: `getting-started.md`, not `Getting Started.md`
- The first `h1` heading becomes the sidebar title (override with the keyword tuple form)
- Keep filenames short -- they become part of the URL in hosted docs
- Prefix with numbers if you need explicit ordering without `groups_for_extras`: `01-getting-started.md`
### Grouping with groups_for_extras
Combine ordering with grouping for the best sidebar organization:
```elixir
defp groups_for_extras do
[
"Introduction": [
"README.md"
],
"Guides": [
"guides/getting-started.md",
"guides/configuration.md",
"guides/architecture.md",
"guides/deployment.md"
],
"Cheatsheets": [
"cheatsheets/query-syntax.cheatmd",
"cheatsheets/router-helpers.cheatmd"
],
"Tutorials": [
"notebooks/data-pipeline.livemd",
"notebooks/sensor-setup.livemd"
]
]
end
```
Extras not matching any group appear in a default section at the bottom of the sidebar.
Guides writing Elixir documentation with @moduledoc, @doc, @typedoc, doctests, cross-references, and metadata. Use when adding or improving documentation in...
---
name: elixir-writing-docs
description: Guides writing Elixir documentation with @moduledoc, @doc, @typedoc, doctests, cross-references, and metadata. Use when adding or improving documentation in .ex files.
---
# Elixir Writing Docs
## Quick Reference
| Topic | Reference |
|-------|-----------|
| Doctests: syntax, gotchas, when to use | [references/doctests.md](references/doctests.md) |
| Cross-references and linking syntax | [references/cross-references.md](references/cross-references.md) |
| Admonitions, formatting, tabs | [references/admonitions-and-formatting.md](references/admonitions-and-formatting.md) |
## First-Line Summary Rule
ExDoc and tools like `mix docs` extract the first paragraph of `@moduledoc` and `@doc` as a summary. Keep the opening line concise and self-contained.
```elixir
# GOOD - first line works as a standalone summary
@moduledoc """
Handles payment processing through Stripe and local ledger reconciliation.
Wraps the Stripe API client and ensures each charge is recorded in the
local ledger before returning a confirmation to the caller.
"""
# BAD - first line is vague, forces reader to continue
@moduledoc """
This module contains various functions related to payments.
It uses Stripe and also updates the ledger.
"""
```
The same rule applies to `@doc`:
```elixir
# GOOD
@doc """
Charges a customer's default payment method for the given amount in cents.
Returns `{:ok, charge}` on success or `{:error, reason}` when the payment
gateway rejects the request.
"""
# BAD
@doc """
This function is used to charge a customer.
"""
```
## @moduledoc Structure
A well-structured `@moduledoc` follows this pattern:
```elixir
defmodule MyApp.Inventory do
@moduledoc """
Tracks warehouse stock levels and triggers replenishment orders.
This module maintains an ETS-backed cache of current quantities and
exposes functions for atomic stock adjustments. It is designed to be
started under a supervisor and will restore state from the database
on init.
## Examples
iex> {:ok, pid} = MyApp.Inventory.start_link(warehouse: :east)
iex> MyApp.Inventory.current_stock(pid, "SKU-1042")
{:ok, 350}
## Configuration
Expects the following in `config/runtime.exs`:
config :my_app, MyApp.Inventory,
repo: MyApp.Repo,
low_stock_threshold: 50
"""
end
```
**Key points:**
- First paragraph is the summary (one to two sentences).
- `## Examples` shows realistic usage. Use doctests when the example is runnable.
- `## Configuration` documents required config keys. Omit this section if the module takes no config.
- Use second-level headings (`##`) only. First-level (`#`) is reserved for the module name in ExDoc output.
### Documenting Behaviour Modules
When defining a behaviour, document the expected callbacks:
```elixir
defmodule MyApp.PaymentGateway do
@moduledoc """
Behaviour for payment gateway integrations.
Implementations must handle charging, refunding, and status checks.
See `MyApp.PaymentGateway.Stripe` for a reference implementation.
## Callbacks
* `charge/2` - Initiate a charge for a given amount
* `refund/2` - Refund a previously completed charge
* `status/1` - Check the status of a transaction
"""
@callback charge(amount :: pos_integer(), currency :: atom()) ::
{:ok, transaction_id :: String.t()} | {:error, term()}
@callback refund(transaction_id :: String.t(), amount :: pos_integer()) ::
:ok | {:error, term()}
@callback status(transaction_id :: String.t()) ::
{:pending | :completed | :failed, map()}
end
```
## @doc Structure
```elixir
@doc """
Reserves the given quantity of an item, decrementing available stock.
Returns `{:ok, reservation_id}` when stock is available, or
`{:error, :insufficient_stock}` when the requested quantity exceeds
what is on hand.
## Examples
iex> MyApp.Inventory.reserve("SKU-1042", 5)
{:ok, "res_abc123"}
iex> MyApp.Inventory.reserve("SKU-9999", 1)
{:error, :not_found}
## Options
* `:warehouse` - Target warehouse atom. Defaults to `:primary`.
* `:timeout` - Timeout in milliseconds. Defaults to `5_000`.
"""
@spec reserve(String.t(), pos_integer(), keyword()) ::
{:ok, String.t()} | {:error, :insufficient_stock | :not_found}
def reserve(sku, quantity, opts \\ []) do
# ...
end
```
**Guidelines:**
- State what the function does, then what it returns.
- Document each option in a bulleted `## Options` section when the function accepts a keyword list.
- Place `@spec` between `@doc` and `def`. This is the conventional ordering.
- Include doctests for pure functions. Skip them for side-effecting functions (see [references/doctests.md](references/doctests.md)).
## @typedoc
Document custom types defined with `@type` or `@opaque`:
```elixir
@typedoc """
A positive integer representing an amount in the smallest currency unit (e.g., cents).
"""
@type amount :: pos_integer()
@typedoc """
Reservation status returned by `status/1`.
* `:held` - Stock is reserved but not yet shipped
* `:released` - Reservation was cancelled and stock restored
* `:fulfilled` - Items have shipped
"""
@type reservation_status :: :held | :released | :fulfilled
@typedoc """
Opaque handle returned by `connect/1`. Do not pattern-match on this value.
"""
@opaque connection :: %__MODULE__{socket: port(), buffer: binary()}
```
For `@opaque` types, the `@typedoc` is especially important because callers cannot inspect the structure.
## Metadata
### @doc since and @doc deprecated
```elixir
@doc since: "1.3.0"
@doc """
Transfers stock between two warehouses.
"""
def transfer(from, to, sku, quantity), do: # ...
@doc deprecated: "Use transfer/4 instead"
@doc """
Moves items between locations. Deprecated in favor of `transfer/4`
which supports cross-region transfers.
"""
def move_stock(from, to, sku, quantity), do: # ...
```
You can combine metadata and the docstring in one attribute:
```elixir
@doc since: "2.0.0", deprecated: "Use bulk_reserve/2 instead"
@doc """
Reserves multiple items in a single call.
"""
def batch_reserve(items), do: # ...
```
`@moduledoc since:` works the same way for modules:
```elixir
@moduledoc since: "1.2.0"
@moduledoc """
Handles webhook signature verification for Stripe events.
"""
```
## When to Use @doc false / @moduledoc false
Suppress documentation when the module or function is not part of the public API:
```elixir
# Private implementation module — internal to the application
defmodule MyApp.Inventory.StockCache do
@moduledoc false
# ...
end
# Protocol implementation — documented at the protocol level
defimpl String.Chars, for: MyApp.Money do
@moduledoc false
# ...
end
# Callback implementation — documented at the behaviour level
@doc false
def handle_info(:refresh, state) do
# ...
end
# Helper used only inside the module
@doc false
def do_format(value), do: # ...
```
**Do NOT use `@doc false` on genuinely public functions.** If a function is exported and callers depend on it, document it. If it should not be called externally, make it private with `defp`.
## Documentation vs Code Comments
| | Documentation (`@moduledoc`, `@doc`) | Code Comments (`#`) |
|---|---|---|
| **Audience** | Users of your API | Developers reading source |
| **Purpose** | Contract: what it does, what it returns | Why a particular implementation choice was made |
| **Rendered** | Yes, by ExDoc in HTML/epub | No, visible only in source |
| **Required** | All public modules and functions | Only where code intent is non-obvious |
```elixir
@doc """
Validates that the given coupon code is active and has remaining uses.
"""
@spec validate_coupon(String.t()) :: {:ok, Coupon.t()} | {:error, :expired | :exhausted}
def validate_coupon(code) do
# We query the read replica here to avoid adding load to the
# primary during high-traffic discount events.
Repo.replica().get_by(Coupon, code: code)
|> check_expiry()
|> check_remaining_uses()
end
```
The `@doc` tells the caller what `validate_coupon/1` does and returns. The inline comment explains an implementation decision that would otherwise be surprising.
## Completing documentation (gates)
Finish with these **sequenced** checks. Skip a step when it does not apply.
1. **Doctests added or changed?** Run the project’s doctest verification (usually `mix test` for affected modules or the full suite). **Pass:** no doctest failures.
2. **Cross-references, backticks, or `m:` links added or edited?** Run `mix docs`. **Pass:** the command completes; resolve ExDoc warnings about missing modules, callbacks, or bad links.
3. **New or changed public API?** **Pass:** every exported `def` / `defmacro` has an intentional `@doc` or `@doc false`, and every public module has `@moduledoc` or `@moduledoc false`, consistent with your project’s policy.
## When to Load References
- Writing doctests or debugging doctest failures --> [references/doctests.md](references/doctests.md)
- Adding links between modules, functions, types --> [references/cross-references.md](references/cross-references.md)
- Using admonition blocks, tabs, or formatting in docs --> [references/admonitions-and-formatting.md](references/admonitions-and-formatting.md)
FILE:references/admonitions-and-formatting.md
# Admonitions and Formatting
## Admonition Blocks
Admonitions are callout boxes rendered by ExDoc. They use blockquote syntax with a special heading:
```markdown
> #### Watch out for atom exhaustion {: .warning}
>
> Calling `String.to_atom/1` on user input can exhaust the atom table.
> Use `String.to_existing_atom/1` instead.
```
### Structure
1. Start with `> ####` followed by the admonition title
2. Add `{: .class}` at the end of the title line
3. Follow with `>` blank line and `>` content lines
### Available Classes
| Class | Use for | Rendered appearance |
|-------|---------|---------------------|
| `.warning` | Potential pitfalls, breaking changes | Yellow/amber box |
| `.error` | Dangerous operations, common mistakes | Red box |
| `.info` | Additional context, background | Blue box |
| `.tip` | Best practices, performance hints | Green box |
| `.neutral` | General callouts without urgency | Grey box |
### Examples
```elixir
@moduledoc """
Manages database connections for the application.
> #### Requires database access {: .info}
>
> This module expects a running PostgreSQL instance. See the
> [setup guide](setup.md) for local development configuration.
> #### Connection pooling {: .tip}
>
> For high-throughput workloads, increase the pool size in
> `config/runtime.exs`:
>
> config :my_app, MyApp.Repo,
> pool_size: 20
> #### Do not call at compile time {: .error}
>
> Functions in this module require the application to be started.
> Calling them in module attributes or at compile time will raise.
"""
```
### Multi-Paragraph Admonitions
Continue with `>` on each line:
```elixir
@doc """
> #### Migration required {: .warning}
>
> After upgrading to v2.0, run the following migration:
>
> mix ecto.migrate
>
> This adds the `archived_at` column used by the new soft-delete
> feature. Existing rows will have `NULL` in this column, which
> the query functions treat as "not archived."
"""
```
## Heading Levels
In `@moduledoc` and `@doc`, use **second-level headings** (`##`) as the highest level. First-level headings (`#`) are reserved for the module or function name in ExDoc output.
```elixir
# GOOD
@moduledoc """
Handles webhook delivery and retry logic.
## Retry Strategy
Failed deliveries are retried with exponential backoff.
## Configuration
Set the maximum retry count in your config.
"""
# BAD - # will clash with ExDoc's page title
@moduledoc """
# Webhook Delivery
Handles webhook delivery and retry logic.
"""
```
Within reference documentation and extra pages, `#` is acceptable as a page title.
## Tabbed Content
ExDoc supports tabbed content blocks using HTML comments and third-level headings:
```elixir
@moduledoc """
## Installation
<!-- tabs-open -->
### Mix
Add to your `mix.exs` dependencies:
{:my_library, "~> 1.0"}
### Rebar3
Add to your `rebar.config`:
{deps, [{my_library, "1.0.0"}]}.
### Erlang.mk
Add to your `Makefile`:
dep_my_library = hex 1.0.0
<!-- tabs-close -->
"""
```
**Rules:**
- Open with `<!-- tabs-open -->`
- Each tab is a `###` heading
- Close with `<!-- tabs-close -->`
- Content between `###` headings becomes that tab's body
- Tabs work in `@moduledoc`, `@doc`, and extra pages
### Realistic Example
```elixir
@doc """
Serializes a struct to a transport format.
## Examples
<!-- tabs-open -->
### JSON
iex> MyApp.Serializer.encode(%User{name: "Alice"}, :json)
{:ok, ~s({"name":"Alice"})}
### MessagePack
iex> MyApp.Serializer.encode(%User{name: "Alice"}, :msgpack)
{:ok, <<129, 164, 110, 97, 109, 101, 165, 65, 108, 105, 99, 101>>}
<!-- tabs-close -->
"""
```
## Code Blocks
Use fenced code blocks with a language tag for syntax highlighting:
````elixir
@moduledoc """
## Usage
```elixir
{:ok, conn} = MyApp.Connection.open("localhost", 5432)
MyApp.Connection.query(conn, "SELECT 1")
```
Configuration in `config/runtime.exs`:
```elixir
config :my_app, MyApp.Connection,
hostname: System.get_env("DB_HOST", "localhost"),
port: String.to_integer(System.get_env("DB_PORT", "5432"))
```
"""
````
For shell commands:
````elixir
@moduledoc """
## Getting Started
```bash
mix deps.get
mix ecto.setup
mix phx.server
```
"""
````
### Indented Code Blocks in Doctests
Within `## Examples` sections, use four-space indentation (not fenced blocks) so that ExDoc can detect and run doctests:
```elixir
@doc """
## Examples
iex> MyApp.Math.add(2, 3)
5
"""
```
## Lists
### Unordered Lists
```elixir
@doc """
Supported formats:
* `:json` - JSON encoding via Jason
* `:msgpack` - MessagePack via Msgpax
* `:csv` - CSV encoding via NimbleCSV
"""
```
### Ordered Lists
```elixir
@doc """
Processing pipeline:
1. Validate input against the schema
2. Transform to internal representation
3. Persist to the database
4. Broadcast change event
"""
```
### Nested Lists
```elixir
@doc """
Options:
* `:format` - Output format
* `:json` - Default
* `:csv` - Comma-separated
* `:compress` - Whether to gzip the output
* `true` - Enable compression
* `false` - Default, no compression
"""
```
## Tables
```elixir
@moduledoc """
## HTTP Status Mapping
| Status | Atom | Description |
|--------|------|-------------|
| 200 | `:ok` | Successful request |
| 201 | `:created` | Resource created |
| 400 | `:bad_request` | Invalid input |
| 404 | `:not_found` | Resource missing |
| 422 | `:unprocessable_entity` | Validation failed |
"""
```
Tables must have a header row and a separator row. Alignment colons (`:---`, `:---:`, `---:`) are supported.
## Inline Formatting
| Syntax | Renders as | Use for |
|--------|-----------|---------|
| `` `code` `` | `code` | Module names, functions, atoms, options |
| `**bold**` | **bold** | Emphasis on key terms |
| `*italic*` | *italic* | Titles, introducing terms |
| `[text](url)` | link | External URLs |
| `` [`text`](`Module`) `` | code link | Cross-references (see cross-references.md) |
## Combining Formatting Techniques
A well-formatted `@moduledoc` uses several of these elements together:
```elixir
defmodule MyApp.RateLimiter do
@moduledoc """
Token-bucket rate limiter backed by ETS.
Limits are configured per endpoint and enforced in the
`MyApp.Plugs.RateLimit` plug.
> #### Production configuration {: .tip}
>
> Set limits based on your capacity planning. Start conservative
> and adjust based on metrics from `MyApp.Telemetry`.
## Examples
iex> {:ok, limiter} = MyApp.RateLimiter.start_link(name: :api)
iex> MyApp.RateLimiter.check(limiter, "user:42", :search)
:allow
## Configuration
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `:window_ms` | `pos_integer()` | `60_000` | Window duration |
| `:max_requests` | `pos_integer()` | `100` | Requests per window |
| `:ban_duration_ms` | `pos_integer()` | `300_000` | Ban duration after exceeding limit |
## Architecture
<!-- tabs-open -->
### Single Node
Uses a local ETS table. Suitable for development and single-instance
deployments.
### Distributed
Wraps the ETS table with `:pg`-based synchronization. Each node
maintains its own counter and periodically reconciles with peers.
<!-- tabs-close -->
"""
end
```
FILE:references/cross-references.md
# Cross-References and Linking
ExDoc auto-links identifiers written in backtick-delimited code spans. This reference covers every linking syntax available.
## Module Links
Reference another module by writing its name in backticks:
```elixir
@moduledoc """
See `MyApp.Accounts` for user management functions.
"""
```
If the module name collides with a local function or could be ambiguous, use the `m:` prefix:
```elixir
@doc """
Delegates to `m:MyApp.Accounts` for persistence.
"""
```
The `m:` prefix is also useful when linking to modules whose names look like function calls.
## Function Links
### Remote Functions (Other Modules)
```elixir
@doc """
Similar to `MyApp.Accounts.get_user/1` but raises on failure.
Accepts the same options as `MyApp.Accounts.list_users/2`.
"""
```
### Local Functions (Same Module)
Omit the module name to link to a function in the current module:
```elixir
@doc """
The bang variant of `fetch_config/1`. Raises `KeyError` if the key is missing.
"""
```
### Operators
```elixir
@doc """
Works like the `Kernel.<>/2` operator but for lists.
"""
```
### Function Arity
Always include the arity. `function/1` and `function/2` are distinct links:
```elixir
@doc """
See `transform/1` for the single-argument version, or
`transform/2` to pass options.
"""
```
## Type Links
Use the `t:` prefix to link to types:
```elixir
@doc """
Returns a `t:MyApp.Money.amount/0` representing the balance.
Accepts any `t:Enumerable.t/0` as input.
"""
```
For types in the same module:
```elixir
@doc """
Returns a `t:result/0` tuple.
"""
```
## Callback Links
Use the `c:` prefix to link to behaviour callbacks:
```elixir
@doc """
Invoked by the framework. See `c:GenServer.init/1` for details.
Implementations must satisfy `c:MyApp.PaymentGateway.charge/2`.
"""
```
## Erlang Module and Function Links
### Erlang Modules
Use `m:` with the atom syntax:
```elixir
@doc """
Uses `m::ets` for fast in-memory lookups.
"""
```
### Erlang Functions
Use the atom syntax directly:
```elixir
@doc """
Wraps `:erlang.system_info/1` to fetch VM metrics.
Delegates to `:timer.send_interval/2` for periodic messages.
"""
```
### Erlang Types
```elixir
@doc """
Returns a `t::erlang.reference/0`.
"""
```
## Custom Text Links
When you want link text that differs from the identifier, use markdown link syntax with a backtick-delimited destination:
```elixir
@doc """
Returns a [money amount](`MyApp.Money.amount/0`) in the given currency.
See the [payment gateway behaviour](`MyApp.PaymentGateway`) for the
full contract.
Read the [validation rules](`MyApp.Validation.validate/2`) for details.
"""
```
## Cross-Application References
Link to documentation in other Hex packages or OTP apps using `e:`:
```elixir
@doc """
Follows the patterns described in the
[Elixir writing documentation guide](`e:elixir:writing-documentation.html`).
See [Plug.Conn](`e:plug:Plug.Conn.html`) for the full struct reference.
"""
```
For functions in other apps:
```elixir
@doc """
Wraps [`Ecto.Repo.transaction/2`](`e:ecto:Ecto.Repo.html#c:transaction/2`).
"""
```
## Linking to Extra Pages
If your project includes extra markdown pages in the ExDoc configuration, link to them by filename:
```elixir
@moduledoc """
For deployment instructions, see the [Operations Guide](operations-guide.md).
Architecture decisions are documented in [ADR-001](adr/001-event-sourcing.md).
"""
```
## Summary of Prefixes
| Prefix | Links to | Example |
|--------|----------|---------|
| *(none)* | Module or function | `` `MyApp.Repo` ``, `` `fetch/1` `` |
| `m:` | Module (explicit) | `` `m:MyApp.Repo` `` |
| `t:` | Type | `` `t:String.t/0` `` |
| `c:` | Callback | `` `c:GenServer.init/1` `` |
| `e:` | Cross-app page | `` `e:elixir:writing-documentation.html` `` |
## Common Mistakes
```elixir
# BAD - missing arity
@doc "See `MyApp.Accounts.get_user` for details."
# GOOD - include arity
@doc "See `MyApp.Accounts.get_user/1` for details."
# BAD - using URL-style links for internal modules
@doc "See [MyApp.Accounts](https://hexdocs.pm/my_app/MyApp.Accounts.html)."
# GOOD - let ExDoc resolve the link
@doc "See `MyApp.Accounts`."
# BAD - linking to private functions (ExDoc will warn)
@doc "Uses `do_internal_parse/2` under the hood."
# GOOD - only link to public API
@doc "Uses an internal parser to process the input."
```
FILE:references/doctests.md
# Doctests
## When to Use Doctests
Doctests serve double duty: they are runnable examples in your documentation **and** lightweight tests. Use them for:
- **Pure functions** with deterministic output
- **String/data transformations** where the input and output are easy to read
- **Simple calculations** and formatting helpers
- **Demonstrating API usage** to new developers
```elixir
@doc """
Converts a price in cents to a formatted dollar string.
## Examples
iex> MyApp.Format.price_in_dollars(1050)
"$10.50"
iex> MyApp.Format.price_in_dollars(0)
"$0.00"
iex> MyApp.Format.price_in_dollars(7)
"$0.07"
"""
def price_in_dollars(cents) when is_integer(cents) and cents >= 0 do
"$#{div(cents, 100)}.#{cents |> rem(100) |> Integer.to_string() |> String.pad_leading(2, "0")}"
end
```
## When NOT to Use Doctests
Skip doctests when the function:
- **Touches the database** -- results depend on test state
- **Makes HTTP requests** or calls external services
- **Depends on time** -- `DateTime.utc_now/0`, timers, TTLs
- **Produces random output** -- UUIDs, tokens, nonces
- **Has side effects** -- sends emails, writes files, publishes messages
- **Returns large or complex structures** -- hard to read and brittle
```elixir
# BAD - database dependency
@doc """
iex> MyApp.Accounts.create_user(%{email: "[email protected]"})
{:ok, %User{}}
"""
# BAD - time dependent
@doc """
iex> MyApp.Token.generate_expiring()
%{token: "abc", expires_at: ~U[2025-01-01 12:00:00Z]}
"""
# GOOD - write a regular ExUnit test instead
test "create_user/1 persists a valid user" do
assert {:ok, %User{email: "[email protected]"}} =
MyApp.Accounts.create_user(%{email: "[email protected]"})
end
```
## iex> Syntax Basics
### Single-Line Expressions
Each doctest begins with `iex>` followed by a space and the expression. The expected result goes on the next line, unindented relative to `iex>`:
```elixir
@doc """
iex> String.upcase("hello")
"HELLO"
"""
```
### Multi-Line Expressions
Use `...>` for continuation lines. The result still follows on the next line:
```elixir
@doc """
iex> %{name: "Alice", role: :admin}
...> |> MyApp.Accounts.display_name()
"Alice (admin)"
"""
```
### Multiple Examples in One Docstring
Separate independent examples with a blank line:
```elixir
@doc """
iex> MyApp.Math.clamp(15, 0, 10)
10
iex> MyApp.Math.clamp(-3, 0, 10)
0
iex> MyApp.Math.clamp(5, 0, 10)
5
"""
```
### Binding Variables Across Lines
Variables bound in one `iex>` line carry forward within the same example block:
```elixir
@doc """
iex> list = [3, 1, 4, 1, 5]
iex> Enum.sort(list)
[1, 1, 3, 4, 5]
"""
```
## Testing Error Tuples
Return error tuples directly:
```elixir
@doc """
iex> MyApp.Validation.parse_age("not a number")
{:error, :invalid_integer}
iex> MyApp.Validation.parse_age("-5")
{:error, :must_be_positive}
iex> MyApp.Validation.parse_age("25")
{:ok, 25}
"""
```
## Testing Exceptions
Use `** (ExceptionModule)` syntax:
```elixir
@doc """
iex> MyApp.Validation.parse_age!(nil)
** (ArgumentError) expected a string, got: nil
"""
```
The message after the exception module name is matched as a prefix, so you do not need the full message if it is long. However, the exception module must match exactly.
```elixir
# Matches any FunctionClauseError regardless of message
@doc """
iex> MyApp.Math.factorial(-1)
** (FunctionClauseError)
"""
```
## Doctests with Structs
### Inspect-Based Output
When a struct implements the `Inspect` protocol with a custom format, match against that format:
```elixir
@doc """
iex> MyApp.Money.new(1099, :USD)
#MyApp.Money<$10.99 USD>
"""
```
### Default Struct Inspect
By default, structs inspect as `%Module{}`:
```elixir
@doc """
iex> MyApp.Coordinate.origin()
%MyApp.Coordinate{x: 0, y: 0}
"""
```
### Partial Matching with Pattern Variables
When a struct has fields you cannot predict (like IDs or timestamps), avoid doctests. Write a regular test instead, or only test the fields you control:
```elixir
# Instead of a doctest, use a regular test:
test "build/1 sets the correct defaults" do
coord = MyApp.Coordinate.build(%{x: 5})
assert coord.x == 5
assert coord.y == 0
end
```
## Setting Up Doctests in Test Files
Add `doctest` to any ExUnit test file:
```elixir
defmodule MyApp.FormatTest do
use ExUnit.Case, async: true
# Run all doctests in the module
doctest MyApp.Format
# Additional unit tests
test "price_in_dollars/1 handles large values" do
assert MyApp.Format.price_in_dollars(1_000_000) == "$10000.00"
end
end
```
You can also place doctests in a dedicated file:
```elixir
defmodule MyApp.DoctestTest do
use ExUnit.Case, async: true
doctest MyApp.Format
doctest MyApp.Math
doctest MyApp.Validation
end
```
### Running Specific Doctests
```bash
# Run all tests in a file containing doctests
mix test test/my_app/format_test.exs
# Run a specific doctest by line number (line of the iex> prompt in source)
mix test test/my_app/format_test.exs:14
```
## Common Gotchas
### Whitespace Sensitivity
The expected output must match **exactly**, including whitespace. Trailing spaces will cause failures that are hard to spot:
```elixir
# This will FAIL if inspect output has no trailing space
@doc """
iex> inspect(%{a: 1})
"%{a: 1} "
"""
```
### Map Key Ordering
Maps with atom keys are printed in alphabetical order by `inspect/1`. Match that order:
```elixir
# GOOD - keys in alphabetical order
@doc """
iex> MyApp.Config.defaults()
%{host: "localhost", port: 4000, scheme: :https}
"""
# BAD - keys in insertion order (will fail)
@doc """
iex> MyApp.Config.defaults()
%{scheme: :https, host: "localhost", port: 4000}
"""
```
### String Escaping
Strings containing special characters must match the inspected form:
```elixir
@doc """
iex> MyApp.CSV.escape("value with \\"quotes\\"")
"\\"value with \\\\\\\"quotes\\\\\\\"\\""
"""
```
When string escaping gets complex, skip the doctest and write a regular test for clarity.
### Large or Multiline Output
If output spans many lines, it becomes brittle. Prefer regular tests:
```elixir
# BAD - long output makes doctest fragile and hard to read
@doc """
iex> MyApp.Report.generate(:monthly)
%{
title: "Monthly Report",
sections: [
%{name: "Revenue", ...},
...
]
}
"""
# GOOD - test what matters in a regular test
test "generate/1 returns a report with expected sections" do
report = MyApp.Report.generate(:monthly)
assert report.title == "Monthly Report"
assert length(report.sections) == 3
end
```
### Opaque Types
You cannot match on the internals of an `@opaque` type in a doctest. Instead, show usage patterns:
```elixir
@doc """
iex> conn = MyApp.Connection.open("localhost", 5432)
iex> is_struct(conn, MyApp.Connection)
true
"""
```
Reviews Elixir code for security vulnerabilities including code injection, atom exhaustion, and secret handling. Use when reviewing code handling user input,...
---
name: elixir-security-review
description: Reviews Elixir code for security vulnerabilities including code injection, atom exhaustion, and secret handling. Use when reviewing code handling user input, external data, or sensitive configuration.
---
# Elixir Security Review
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| Code.eval_string, binary_to_term | [references/code-injection.md](references/code-injection.md) |
| String.to_atom dangers | [references/atom-exhaustion.md](references/atom-exhaustion.md) |
| Config, environment variables | [references/secrets.md](references/secrets.md) |
| ETS visibility, process dictionary | [references/process-exposure.md](references/process-exposure.md) |
## Review Checklist
### Critical (Block Merge)
- [ ] No `Code.eval_string/1` on user input
- [ ] No `:erlang.binary_to_term/1` without `:safe` on untrusted data
- [ ] No `String.to_atom/1` on external input
- [ ] No hardcoded secrets in source code
### Major
- [ ] ETS tables use appropriate access controls
- [ ] No sensitive data in process dictionary
- [ ] No dynamic module creation from user input
- [ ] Path traversal prevented in file operations
### Configuration
- [ ] Secrets loaded from environment
- [ ] No secrets in config/*.exs committed to git
- [ ] Runtime config used for deployment secrets
## Valid Patterns (Do NOT Flag)
- **String.to_atom on compile-time constants** - Atoms created at compile time are safe
- **Code.eval_string in dev/test** - May be needed for tooling
- **ETS :public tables** - Valid when intentionally shared
- **binary_to_term with :safe** - Explicitly safe option used
## Context-Sensitive Rules
| Issue | Flag ONLY IF |
|-------|--------------|
| String.to_atom | Input comes from external source (user, API, file) |
| binary_to_term | Data comes from untrusted source |
| ETS :public | Contains sensitive data |
## Hard gates (before reporting)
Complete **in order** for each finding you intend to report. Do not advance until the pass condition is satisfied.
1. **Location artifact** — The finding includes `[FILE:LINE]` (or a line range) that you copied from the current file contents; the path resolves in this repo.
2. **Scope read** — You read the full surrounding function or module section that contains the flagged code, not only a diff hunk or summary.
3. **External-data claim** (only if the finding depends on “user/untrusted input”) — You can name one concrete ingress (for example `conn.params`, `Jason.decode!/1` result, uploaded file path, message from another node) **or** you drop the finding because the value is compile-time, test-only, or internal per Context-Sensitive Rules.
4. **Protocol** — Pre-report steps in `beagle-elixir:review-verification-protocol` ([skill](../review-verification-protocol/SKILL.md)) are satisfied for this item (no finding if they are not).
## Before Submitting Findings
Use the issue format: `[FILE:LINE] ISSUE_TITLE` for each finding.
Hard gate 4 requires `beagle-elixir:review-verification-protocol` ([skill](../review-verification-protocol/SKILL.md)); use it as the full pre-report checklist and issue-type verification (it extends beyond this skill’s summary).
FILE:references/atom-exhaustion.md
# Atom Exhaustion
## The Problem
Atoms are never garbage collected. The VM has a limit (default ~1M atoms). Creating atoms from user input can crash the system.
```elixir
# VULNERABILITY - DoS via atom exhaustion
def process_field(field_name) do
key = String.to_atom(field_name) # Each unique input creates new atom
Map.get(data, key)
end
# Attacker sends: field_1, field_2, ... field_1000000 -> VM crash
```
## Dangerous Functions
```elixir
# NEVER use on external input:
String.to_atom(user_input)
List.to_atom(user_input)
:erlang.binary_to_atom(user_input, :utf8)
:erlang.list_to_atom(user_input)
```
## Safe Alternatives
### String.to_existing_atom
```elixir
# Only converts to atoms that already exist
def process_field(field_name) do
try do
key = String.to_existing_atom(field_name)
Map.get(data, key)
rescue
ArgumentError -> {:error, :invalid_field}
end
end
```
### Whitelist Approach
```elixir
@valid_fields ~w(name email phone)a
def process_field(field_name) do
atom = String.to_existing_atom(field_name)
if atom in @valid_fields do
{:ok, Map.get(data, atom)}
else
{:error, :invalid_field}
end
rescue
ArgumentError -> {:error, :invalid_field}
end
```
### Use Strings as Keys
```elixir
# SAFE - no atom creation
def process_json(json_map) do
# JSON keys are already strings
name = Map.get(json_map, "name")
email = Map.get(json_map, "email")
%{name: name, email: email}
end
```
### Atom Whitelist in Module Attribute
```elixir
defmodule API do
@allowed_actions [:create, :read, :update, :delete]
def dispatch(action_string) do
action = String.to_existing_atom(action_string)
if action in @allowed_actions do
perform(action)
else
{:error, :unauthorized_action}
end
rescue
ArgumentError -> {:error, :invalid_action}
end
end
```
## Safe Contexts
Atom creation is safe when:
- Input is compile-time constant
- Input comes from trusted internal source
- Input is validated against whitelist first
```elixir
# SAFE - compile-time
@fields [:name, :email]
# SAFE - internal message
def handle_info({:internal, action}, state) when is_atom(action) do
# action is already an atom from trusted code
end
```
## Review Questions
1. Is String.to_atom used on any external input?
2. Is there a whitelist for dynamic atom conversion?
3. Could String.to_existing_atom be used instead?
4. Would using strings as keys work instead?
FILE:references/code-injection.md
# Code Injection
## Code.eval_string
### The Danger
```elixir
# CRITICAL VULNERABILITY
def calculate(user_expression) do
{result, _} = Code.eval_string(user_expression)
result
end
# Attacker input: "System.cmd(\"rm\", [\"-rf\", \"/\"])"
```
### Safe Alternatives
**1. Parse and validate expressions:**
```elixir
# Safe math expression parser
defmodule SafeMath do
def evaluate(expr) when is_binary(expr) do
with {:ok, tokens} <- tokenize(expr),
{:ok, ast} <- parse(tokens),
:ok <- validate_ast(ast) do
{:ok, eval_ast(ast)}
end
end
defp validate_ast({op, _, _}) when op in [:+, :-, :*, :/], do: :ok
defp validate_ast(n) when is_number(n), do: :ok
defp validate_ast(_), do: {:error, :invalid_expression}
end
```
**2. Whitelist allowed operations:**
```elixir
@allowed_ops %{
"add" => &Kernel.+/2,
"subtract" => &Kernel.-/2,
"multiply" => &Kernel.*/2
}
def calculate(op, a, b) when is_map_key(@allowed_ops, op) do
@allowed_ops[op].(a, b)
end
```
## :erlang.binary_to_term
### The Danger
```elixir
# CRITICAL VULNERABILITY
def deserialize(data) do
:erlang.binary_to_term(data) # Can create atoms, execute code!
end
```
Malicious binary can:
- Create unlimited atoms (DoS)
- Reference functions that get called
- Contain malicious data structures
### Safe Alternative
```elixir
# SAFE - only allows existing atoms, no function references
def deserialize(data) do
:erlang.binary_to_term(data, [:safe])
rescue
ArgumentError -> {:error, :invalid_term}
end
```
### Prefer JSON for External Data
```elixir
# External API data - use JSON
def parse_api_response(body) do
Jason.decode(body)
end
# Internal Erlang-to-Erlang - binary_to_term with :safe may be ok
def parse_internal_message(data) do
:erlang.binary_to_term(data, [:safe])
end
```
## Dynamic Module Creation
### The Danger
```elixir
# DANGEROUS
def load_handler(module_name) do
module = String.to_existing_atom("Elixir.Handlers.#{module_name}")
module.handle()
end
# Attacker: "../../System" -> calls System.handle() if exists
```
### Safe Pattern
```elixir
@handlers %{
"email" => Handlers.Email,
"sms" => Handlers.SMS
}
def load_handler(name) do
case Map.fetch(@handlers, name) do
{:ok, module} -> module.handle()
:error -> {:error, :unknown_handler}
end
end
```
## Review Questions
1. Is Code.eval_string used on any external input?
2. Is binary_to_term used without :safe option?
3. Are modules loaded dynamically from user input?
4. Is there a whitelist for dynamic operations?
FILE:references/process-exposure.md
# Process Exposure
## ETS Visibility
### Access Levels
```elixir
# :public - any process can read/write
# :protected - owner writes, anyone reads (default)
# :private - only owner can access
# DANGEROUS if contains sensitive data
:ets.new(:sessions, [:public]) # Any process can read sessions!
# BETTER - protected access
:ets.new(:sessions, [:protected]) # Only owner can write
```
### Sensitive Data in ETS
```elixir
# BAD - tokens visible to all processes
:ets.insert(:cache, {:user_123, %{token: "secret_token"}})
# GOOD - store reference, not secret
:ets.insert(:cache, {:user_123, %{token_id: "ref_abc"}})
# Actual token in secure storage with access controls
```
## Process Dictionary
### Dangers
The process dictionary is:
- Visible via `Process.info(pid, :dictionary)`
- Included in crash reports
- Not access controlled
```elixir
# BAD - secret in process dictionary
Process.put(:api_token, "secret123")
# After crash, token visible in error reports!
```
### Safe Alternatives
```elixir
# Use GenServer state (not in crash reports by default)
defmodule SecureWorker do
use GenServer
def init(token) do
{:ok, %{token: token}} # In state, not dictionary
end
end
# Or dedicated secret storage
defmodule Vault do
def store(key, secret) do
# Encrypted storage or external secret manager
end
end
```
## Registered Process Names
### Enumerable
```elixir
# All registered names are visible
Process.registered() # Returns list of all registered names
# Don't encode secrets in names
# BAD
Process.register(self(), :"worker_secret_token_abc123")
# GOOD
Process.register(self(), :worker_1)
```
## Observer / Remote Shell
In production:
- Observer can inspect all processes
- Remote shell has full access
- Limit who can connect
```elixir
# Restrict remote shell in production
config :my_app, MyAppWeb.Endpoint,
server: true
# Use firewall rules to limit epmd/distribution ports
```
## Crash Reports
### Sensitive Data Redaction
```elixir
# Custom formatting to redact secrets
defmodule MyApp.ErrorReporter do
def format_state(state) do
state
|> Map.update(:token, "[REDACTED]", fn _ -> "[REDACTED]" end)
|> Map.update(:password, "[REDACTED]", fn _ -> "[REDACTED]" end)
end
end
# In GenServer
def format_status(_reason, [_pdict, state]) do
[data: [{'State', MyApp.ErrorReporter.format_state(state)}]]
end
```
## Review Questions
1. Do ETS tables with sensitive data use :private?
2. Is sensitive data stored in process dictionary?
3. Are crash reports configured to redact secrets?
4. Is production remote access properly restricted?
FILE:references/secrets.md
# Secrets and configuration
## What to verify in review
1. **No literals in application code** — API keys, tokens, database URLs, and private keys must not appear as string literals in `.ex` files shipped to production.
2. **Runtime configuration** — Prefer `config/runtime.exs` (or release-time env) for values that differ per environment. `Application.get_env/2` should read secrets supplied via environment or secret stores, not checked-in files.
3. **Committed config** — `config/*.exs` may set non-secret defaults; deployment secrets belong in env vars or external secret managers, not in git history.
4. **`.env` and similar** — Ensure `.env*` is in `.gitignore` when used locally; never commit files that contain real credentials.
## Useful checks
- Search for common patterns: `api_key`, `secret`, `password`, `BEGIN PRIVATE KEY`, connection strings with embedded credentials.
- Confirm `Mix.env()` does not accidentally enable dev-only shortcuts that log or return secrets in production paths.
## Valid patterns (do not flag without evidence)
- Placeholders like `System.get_env("DATABASE_URL")` with documentation that ops sets the var.
- Test fixtures with obviously fake credentials in `test/support` only.
Reviews Elixir code for performance issues including GenServer bottlenecks, memory usage, and concurrency patterns. Use when reviewing high-throughput code o...
---
name: elixir-performance-review
description: Reviews Elixir code for performance issues including GenServer bottlenecks, memory usage, and concurrency patterns. Use when reviewing high-throughput code or investigating performance issues.
---
# Elixir Performance Review
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| Mailbox overflow, blocking calls | [references/genserver-bottlenecks.md](references/genserver-bottlenecks.md) |
| When to use ETS, read/write concurrency | [references/ets-patterns.md](references/ets-patterns.md) |
| Binary handling, large messages | [references/memory.md](references/memory.md) |
| Task patterns, flow control | [references/concurrency.md](references/concurrency.md) |
## Review Checklist
### GenServer
- [ ] Not a single-process bottleneck for all requests
- [ ] No blocking operations in handle_call/cast
- [ ] Proper timeout configuration
- [ ] Consider ETS for read-heavy state
### Memory
- [ ] Large binaries not copied between processes
- [ ] Streams used for large data transformations
- [ ] No unbounded data accumulation
### Concurrency
- [ ] Task.Supervisor for dynamic tasks (not raw Task.async)
- [ ] No unbounded process spawning
- [ ] Proper backpressure for message producers
### Database
- [ ] Preloading to avoid N+1 queries
- [ ] Pagination for large result sets
- [ ] Indexes for frequent queries
## Valid Patterns (Do NOT Flag)
- **Single GenServer for low-throughput** - Not all state needs horizontal scaling
- **Synchronous calls for critical paths** - Consistency may require it
- **In-memory state without ETS** - ETS has overhead for small state
- **Enum over Stream for small collections** - Stream overhead not worth it
## Context-Sensitive Rules
| Issue | Flag ONLY IF |
|-------|--------------|
| GenServer bottleneck | Handles > 1000 req/sec OR blocking I/O in callbacks |
| Use streams | Processing > 10k items OR reading large files |
| Use ETS | Read:write ratio > 10:1 AND concurrent access |
## Gates — before reporting
Do these **in order** for the performance review. Do not publish findings until each step passes.
1. **Protocol loaded** — Read [review-verification-protocol](../review-verification-protocol/SKILL.md) and apply its checks for each finding (hot paths, concurrency, resource use). **Pass:** For every substantive finding, you can name which protocol subsection you satisfied or state **N/A** with reason (e.g. pure reference to this skill’s Valid Patterns).
2. **Anchored evidence** — **Pass:** Each finding includes a concrete locator: `path:line` (or line range), or `Module.function/arity` plus a short quoted snippet from the file.
3. **Performance claims** — For anything under [Context-Sensitive Rules](#context-sensitive-rules), or any claim of bottleneck, N+1, unbounded growth, or heavy memory/binary cost, **Pass:** You state the **observed or measured** fact that meets “Flag ONLY IF” (e.g. rate, item count, ratio), or attach an artifact (profiler output, SQL/log excerpt, `grep`/search scope)—otherwise downgrade to **question** / **suspected** with what was not verified.
## Before Submitting Findings
Complete **Gates — before reporting** (section above) first; the verification protocol is mandatory input to those gates.
FILE:references/concurrency.md
# Concurrency Patterns
## Task Patterns
### Use Task.Supervisor for Dynamic Tasks
```elixir
# BAD - unlinked task, crashes silently
Task.start(fn -> risky_work() end)
# BAD - linked task, crashes caller if task crashes
Task.async(fn -> risky_work() end) |> Task.await()
# GOOD - supervised, restartable
Task.Supervisor.async_nolink(MyTaskSupervisor, fn ->
risky_work()
end)
```
### Parallel Processing
```elixir
# Process items concurrently with limit
Task.Supervisor.async_stream_nolink(
MyTaskSupervisor,
items,
fn item -> process(item) end,
max_concurrency: 10,
ordered: false
)
|> Enum.to_list()
```
### Timeout Handling
```elixir
task = Task.Supervisor.async_nolink(MySup, fn -> slow_work() end)
case Task.yield(task, 5_000) || Task.shutdown(task) do
{:ok, result} -> {:ok, result}
nil -> {:error, :timeout}
{:exit, reason} -> {:error, reason}
end
```
## Backpressure
### GenStage / Broadway for Backpressure
```elixir
# Producer-consumer with demand
defmodule MyConsumer do
use GenStage
def handle_events(events, _from, state) do
process(events)
{:noreply, [], state} # Demand more when ready
end
end
```
### Manual Backpressure
```elixir
# Limit concurrent operations
defmodule RateLimiter do
use GenServer
def init(_) do
{:ok, %{active: 0, max: 10, queue: :queue.new()}}
end
def handle_call(:acquire, from, %{active: n, max: max} = state) when n < max do
{:reply, :ok, %{state | active: n + 1}}
end
def handle_call(:acquire, from, state) do
{:noreply, %{state | queue: :queue.in(from, state.queue)}}
end
def handle_cast(:release, %{queue: queue, active: n} = state) do
case :queue.out(queue) do
{{:value, from}, queue} ->
GenServer.reply(from, :ok)
{:noreply, %{state | queue: queue}}
{:empty, _} ->
{:noreply, %{state | active: n - 1}}
end
end
end
```
## Process Spawning
### Don't Spawn Unbounded
```elixir
# BAD - spawns process per request
def handle_request(req) do
spawn(fn -> process(req) end) # Unbounded!
end
# GOOD - use pool
def handle_request(req) do
:poolboy.transaction(:worker_pool, fn pid ->
Worker.process(pid, req)
end)
end
```
### DynamicSupervisor for Bounded Children
```elixir
defmodule MyDynamicSup do
use DynamicSupervisor
def start_link(_) do
DynamicSupervisor.start_link(__MODULE__, [],
name: __MODULE__,
max_children: 100 # Bounded!
)
end
end
```
## Review Questions
1. Are dynamic tasks under a Task.Supervisor?
2. Is there backpressure for high-volume producers?
3. Is process spawning bounded?
4. Are timeouts configured for async operations?
FILE:references/ets-patterns.md
# ETS Patterns
## When to Use ETS
| Use Case | ETS? |
|----------|------|
| Read-heavy cache | Yes |
| Write-heavy with consistency | No (use GenServer) |
| Shared state across processes | Yes |
| Small, single-process state | No (use GenServer) |
## Table Types
```elixir
# :set - one value per key (default)
:ets.new(:cache, [:set])
# :bag - multiple values per key
:ets.new(:events, [:bag])
# :ordered_set - sorted by key
:ets.new(:timeline, [:ordered_set])
```
## Concurrency Options
```elixir
# Read-heavy workload
:ets.new(:cache, [:set, :public, :named_table,
read_concurrency: true
])
# Write-heavy workload
:ets.new(:counters, [:set, :public, :named_table,
write_concurrency: true
])
# Both
:ets.new(:mixed, [:set, :public, :named_table,
read_concurrency: true,
write_concurrency: true
])
```
## Common Patterns
### Cache with TTL
```elixir
defmodule TTLCache do
def put(key, value, ttl_ms) do
expires_at = System.monotonic_time(:millisecond) + ttl_ms
:ets.insert(:cache, {key, value, expires_at})
end
def get(key) do
case :ets.lookup(:cache, key) do
[{^key, value, expires_at}] ->
if System.monotonic_time(:millisecond) < expires_at do
{:ok, value}
else
:ets.delete(:cache, key)
:expired
end
[] ->
:not_found
end
end
end
```
### Counter
```elixir
# Atomic counter updates
:ets.update_counter(:stats, :requests, 1, {:requests, 0})
```
### Match Specifications
```elixir
# Find all users with role :admin
:ets.select(:users, [
{{:"$1", %{role: :admin}}, [], [:"$1"]}
])
# Using match
:ets.match(:users, {:"$1", %{role: :admin, name: :"$2"}})
# Returns [[id1, name1], [id2, name2], ...]
```
## Access Control
```elixir
# :public - any process can read/write
# :protected - owner writes, any reads (default)
# :private - only owner
:ets.new(:shared, [:public]) # Multi-process cache
:ets.new(:config, [:protected]) # Owner updates, all read
:ets.new(:internal, [:private]) # Single process only
```
## Ownership and Lifecycle
```elixir
# ETS table dies with owner process
# Use a dedicated process to own long-lived tables
defmodule TableOwner do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init(_) do
table = :ets.new(:my_table, [:public, :named_table])
{:ok, table}
end
end
```
## Review Questions
1. Is ETS appropriate for this use case (read vs write ratio)?
2. Are concurrency options set correctly?
3. Is table ownership properly managed?
4. Are access controls appropriate?
FILE:references/genserver-bottlenecks.md
# GenServer Bottlenecks
## Single Process Bottleneck
### The Problem
```elixir
# BAD - all requests through one process
defmodule Cache do
use GenServer
def get(key), do: GenServer.call(__MODULE__, {:get, key})
def put(key, val), do: GenServer.call(__MODULE__, {:put, key, val})
end
```
Every request queues in the GenServer's mailbox. Under load:
- Mailbox grows unbounded
- Latency increases linearly
- Memory pressure from queued messages
### Solutions
**1. Use ETS for read-heavy workloads:**
```elixir
defmodule Cache do
def init do
:ets.new(:cache, [:set, :public, :named_table, read_concurrency: true])
end
def get(key), do: :ets.lookup(:cache, key)
def put(key, val), do: :ets.insert(:cache, {key, val})
end
```
**2. Partition by key:**
```elixir
defmodule PartitionedCache do
@partitions 16
def get(key) do
partition = :erlang.phash2(key, @partitions)
GenServer.call(:"cache_#{partition}", {:get, key})
end
end
```
**3. Use Registry for dynamic workers:**
```elixir
defmodule WorkerPool do
def get_worker(key) do
case Registry.lookup(MyRegistry, key) do
[{pid, _}] -> pid
[] -> start_worker(key)
end
end
end
```
## Blocking Operations
### The Problem
```elixir
# BAD - blocks entire GenServer
def handle_call(:fetch_external, _from, state) do
result = HTTPClient.get!(url) # 500ms+ network call
{:reply, result, state}
end
```
All other messages wait during the HTTP call.
### Solutions
**1. Use Task.Supervisor for async work:**
```elixir
def handle_call(:fetch_external, from, state) do
task = Task.Supervisor.async_nolink(MyApp.TaskSupervisor, fn ->
HTTPClient.get!(url)
end)
{:noreply, Map.put(state, :pending, {from, task.ref})}
end
def handle_info({ref, result}, %{pending: {from, ref}} = state) do
Process.demonitor(ref, [:flush])
GenServer.reply(from, result)
{:noreply, Map.delete(state, :pending)}
end
def handle_info({:DOWN, ref, :process, _pid, reason}, %{pending: {from, ref}} = state) do
GenServer.reply(from, {:error, reason})
{:noreply, Map.delete(state, :pending)}
end
```
**2. Use handle_continue for expensive init:**
```elixir
def init(args) do
{:ok, %{}, {:continue, :load_data}}
end
def handle_continue(:load_data, state) do
data = expensive_load()
{:noreply, %{state | data: data}}
end
```
## Timeouts
### Configure Appropriately
```elixir
# Client-side timeout (use catch, not rescue - timeouts are exit signals)
def fetch(pid) do
try do
GenServer.call(pid, :fetch, 10_000) # 10 second timeout
catch
:exit, {:timeout, _} -> {:error, :timeout}
end
end
# Server-side timeout for idle
def handle_info(:timeout, state) do
{:stop, :normal, state}
end
def handle_call(:work, _from, state) do
{:reply, :ok, state, 30_000} # 30s idle timeout
end
```
## Review Questions
1. Is this GenServer a potential bottleneck under load?
2. Are there blocking I/O operations in callbacks?
3. Would ETS be more appropriate for this use case?
4. Are timeouts configured appropriately?
FILE:references/memory.md
# Memory Patterns
## Binary Handling
### Large Binaries Are Reference Counted
Binaries > 64 bytes are stored on shared heap. Copying between processes is cheap (reference copy).
```elixir
# Efficient - only reference copied
send(pid, large_binary)
# But beware of sub-binaries holding reference to large binary
<<header::binary-size(100), _rest::binary>> = large_binary
# header still references entire large_binary!
```
### Force Copy When Needed
```elixir
# Release reference to large binary
header = :binary.copy(<<header::binary-size(100), _::binary>> = large_binary)
```
## Process Heap
### Large State = Large GC
Each process has its own heap. Large state means:
- Longer GC pauses
- More memory per process
```elixir
# BAD - accumulating large state
def handle_cast({:add, item}, state) do
{:noreply, [item | state.items]} # Grows forever!
end
# GOOD - bounded state
def handle_cast({:add, item}, state) do
items = Enum.take([item | state.items], @max_items)
{:noreply, %{state | items: items}}
end
```
### Use ETS for Large Shared State
```elixir
# BAD - large map in GenServer
defmodule BigCache do
use GenServer
def init(_), do: {:ok, %{}} # Millions of entries here
end
# GOOD - ETS for large state
defmodule BigCache do
def init do
:ets.new(:cache, [:set, :public, :named_table])
end
end
```
## Message Passing
### Avoid Large Message Copies
```elixir
# BAD - copies entire list to each process
Enum.each(workers, fn pid ->
send(pid, {:process, large_list})
end)
# GOOD - send reference or key
Enum.each(workers, fn pid ->
send(pid, {:process, :ets.whereis(:data), key})
end)
```
## Streams for Large Data
### Use Streams to Avoid Loading All in Memory
```elixir
# BAD - loads entire file
File.read!("large.csv")
|> String.split("\n")
|> Enum.map(&parse_line/1)
# GOOD - streams line by line
File.stream!("large.csv")
|> Stream.map(&parse_line/1)
|> Enum.to_list() # Or process incrementally
```
### Database Streams
```elixir
# BAD - loads all records
Repo.all(User)
|> Enum.map(&process/1)
# GOOD - streams from database
User
|> Repo.stream()
|> Stream.map(&process/1)
|> Stream.run()
```
## Detecting Memory Issues
```elixir
# Process memory
Process.info(self(), :memory)
# System memory
:erlang.memory()
# Binary memory specifically
:erlang.memory(:binary)
```
## Review Questions
1. Are large binaries being unnecessarily copied?
2. Is process state bounded or growing unbounded?
3. Are streams used for large data processing?
4. Is shared state in ETS rather than process heap?
Reviews Elixir documentation for completeness, quality, and ExDoc best practices. Use when auditing @moduledoc, @doc, @spec coverage, doctest correctness, an...
---
name: elixir-docs-review
description: Reviews Elixir documentation for completeness, quality, and ExDoc best practices. Use when auditing @moduledoc, @doc, @spec coverage, doctest correctness, and cross-reference usage in .ex files.
---
# Elixir Documentation Review
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| @moduledoc, @doc quality, anti-patterns | [references/doc-quality.md](references/doc-quality.md) |
| @spec, @type, @typedoc coverage | [references/spec-coverage.md](references/spec-coverage.md) |
## Review Checklist
### Module Documentation
- [ ] All public modules have @moduledoc
- [ ] First-line summary is concise (one line, used by tools as summary)
- [ ] @moduledoc includes ## Examples where appropriate
- [ ] @moduledoc false only on internal/implementation modules
### Function Documentation
- [ ] All public functions have @doc
- [ ] All public functions have @spec
- [ ] @doc describes return values clearly
- [ ] Multi-clause functions documented before first clause
- [ ] Function head declared when arg names need clarification
### Doctests
- [ ] Doctests present for pure, deterministic functions
- [ ] No doctests for side-effectful operations (DB, HTTP, etc.)
- [ ] Doctests actually run (module included in test file)
### Cross-References
- [ ] Module references use backtick auto-linking (`MyModule`)
- [ ] Function refs use proper arity format (`function/2`)
- [ ] Type refs use t: prefix (`t:typename/0`)
- [ ] No plain-text references where auto-links are possible
### Metadata
- [ ] @since annotations on new public API additions
- [ ] @deprecated with migration guidance where appropriate
## Valid Patterns (Do NOT Flag)
- **@doc false on callback implementations** - Documented at behaviour level
- **@doc false on protocol implementations** - Protocol docs cover the intent
- **Missing @spec on private functions** - @spec optional for internals
- **Short @moduledoc without ## Examples on simple utility modules** - Not every module needs examples
- **Using @impl true without separate @doc** - Inherits documentation from behaviour
## Context-Sensitive Rules
| Issue | Flag ONLY IF |
|-------|--------------|
| Missing @moduledoc | Module is public AND not a protocol impl |
| Missing @spec | Function is public AND exported |
| Missing doctests | Function is pure AND deterministic |
| Generic @doc | Doc restates function name without adding value |
## Gates (sequenced — do not skip)
Work in order. **Do not draft or ship a finding until the prior step passes.**
1. **Scope lock** — **Pass when:** You listed the exact `.ex`/`.exs` file paths (or `Module` names) under review; no vague “the project” scope.
2. **Full-context read** — **Pass when:** For each candidate issue, you read the full surrounding definition (all clauses for multi-clause functions; full `@moduledoc` block for module-level claims), not only a diff hunk or search snippet.
3. **Evidence bundle** — **Pass when:** Every draft finding uses the `[FILE:LINE] ISSUE_TITLE` header (line range allowed) **and** includes a verbatim quote or pointer to the `@doc` / `@spec` / doctest text in question. `Module.function/arity` may appear as supporting context but does not replace the `[FILE:LINE]` anchor. For “doctest fails” claims, **Pass when:** you cite `mix test` output for the relevant file or line, or the exact error string.
4. **Protocol before report** — **Pass when:** You loaded and followed [review-verification-protocol](../review-verification-protocol/SKILL.md) (its Pre-Report checklist) **before** finalizing the issue list—not after.
## When to Load References
- Reviewing @moduledoc or @doc quality, seeing anti-patterns -> doc-quality.md
- Reviewing @spec, @type, or @typedoc coverage -> spec-coverage.md
FILE:references/doc-quality.md
# Documentation Quality
## What Makes Good Module Docs
A well-documented module tells the reader four things: what it does, when to use it, how to use it, and how to configure it.
### Structure
```elixir
defmodule MyApp.RateLimiter do
@moduledoc """
Token bucket rate limiter for API endpoints.
Use this module to throttle incoming requests per client. It tracks
request counts in ETS and supports configurable burst and refill rates.
## Examples
iex> {:ok, limiter} = RateLimiter.start_link(rate: 100, interval: :timer.seconds(1))
iex> RateLimiter.allow?(limiter, "client-123")
true
## Configuration
Expects the following options:
* `:rate` - Maximum requests per interval (required)
* `:interval` - Refill interval in milliseconds (default: 1000)
* `:burst` - Maximum burst size (default: same as `:rate`)
"""
end
```
The first line ("Token bucket rate limiter for API endpoints.") is critical -- ExDoc uses it as the module summary in sidebar listings and search results. Keep it to one sentence.
## What Makes Good Function Docs
Good function docs answer: what does it do, what are the inputs, what does it return, and what are the edge cases.
```elixir
@doc """
Checks whether a client is allowed to make a request.
Decrements the token count for the given `client_id` and returns
whether the request should proceed. When tokens are exhausted,
returns `false` until the next refill interval.
Returns `{:ok, remaining}` with the remaining token count, or
`{:error, :rate_limited}` when the limit is exceeded.
## Examples
iex> RateLimiter.check("client-123")
{:ok, 99}
iex> RateLimiter.check("exhausted-client")
{:error, :rate_limited}
"""
@spec check(client_id :: String.t()) :: {:ok, non_neg_integer()} | {:error, :rate_limited}
def check(client_id) do
# ...
end
```
## Anti-Patterns
### Empty @moduledoc String
```elixir
# BAD - empty string still shows in ExDoc as a blank page
defmodule MyApp.Internal.Parser do
@moduledoc ""
end
# GOOD - explicitly hidden from ExDoc output
defmodule MyApp.Internal.Parser do
@moduledoc false
end
```
If you want to hide a module from documentation, use `@moduledoc false`. An empty string creates a confusing blank entry in generated docs.
### Restating the Function Name
```elixir
# BAD - tells the reader nothing they didn't already know
@doc "Gets the user."
@spec get_user(integer()) :: User.t() | nil
def get_user(id), do: Repo.get(User, id)
# GOOD - explains behavior, return semantics, edge cases
@doc """
Fetches a user by primary key.
Returns the `%User{}` struct if found, or `nil` if no user exists
with the given `id`. Does not raise on missing records.
"""
@spec get_user(integer()) :: User.t() | nil
def get_user(id), do: Repo.get(User, id)
```
### Missing Return Value Documentation
```elixir
# BAD - what does it return on success? On failure?
@doc "Processes the payment."
def process_payment(order), do: # ...
# GOOD - return values are explicit
@doc """
Submits a payment for the given order to the payment gateway.
Returns `{:ok, %Transaction{}}` on successful charge, or
`{:error, %PaymentError{}}` if the charge is declined or
the gateway is unavailable.
"""
def process_payment(order), do: # ...
```
### Wrong or Outdated Doctest Examples
```elixir
# BAD - doctest will fail because the function now returns a tuple
@doc """
Formats a price in cents as a dollar string.
## Examples
iex> format_price(1999)
"$19.99"
"""
def format_price(cents) do
{:ok, "$#{cents / 100}"} # Return type changed but doctest wasn't updated
end
# GOOD - doctest matches actual return value
@doc """
Formats a price in cents as a dollar string.
## Examples
iex> format_price(1999)
{:ok, "$19.99"}
"""
def format_price(cents) do
{:ok, "$#{cents / 100}"}
end
```
### Documenting Obvious Params but Not Edge Cases
```elixir
# BAD - documents obvious params, ignores what matters
@doc """
Divides `a` by `b`.
## Parameters
* `a` - The numerator
* `b` - The denominator
"""
def divide(a, b), do: a / b
# GOOD - documents the interesting behavior
@doc """
Divides `a` by `b`.
Raises `ArithmeticError` when `b` is zero. Returns a float
even when both arguments are integers.
## Examples
iex> divide(10, 3)
3.3333333333333335
iex> divide(10, 0)
** (ArithmeticError) bad argument in arithmetic expression
"""
def divide(a, b), do: a / b
```
### Using @doc When @impl true Would Suffice
```elixir
# BAD - redundant doc that duplicates the behaviour's documentation
defmodule MyApp.Cache do
@behaviour MyApp.Store
@doc "Initializes the store."
@impl true
def init(opts), do: # ...
end
# GOOD - @impl true inherits docs from the behaviour
defmodule MyApp.Cache do
@behaviour MyApp.Store
@impl true
def init(opts), do: # ...
end
```
When a module implements a behaviour, using `@impl true` signals that the function's contract is defined by the behaviour. Adding a separate `@doc` that just restates the behaviour's docs creates maintenance burden with no benefit. Only add `@doc` on `@impl true` callbacks when the implementation has important details the behaviour docs don't cover.
## The "Write for the Reader" Principle
Documentation is read by developers who don't have your current context. Ask yourself:
1. **Would a new team member understand this module's purpose from @moduledoc alone?**
2. **Would a caller know what to pass and what to expect back from @doc alone?**
3. **Would someone debugging a failure understand the error cases from the docs?**
If the answer to any of these is no, the docs need improvement -- regardless of whether they technically exist.
## Review Questions
1. Does the @moduledoc first line work as a standalone summary?
2. Do @doc blocks describe return values and error cases?
3. Are doctests current and matching actual function behavior?
4. Do docs add value beyond what the function name and @spec already convey?
FILE:references/spec-coverage.md
# Spec Coverage
## Common @spec Patterns
### Basic Types
```elixir
@spec greet(String.t()) :: String.t()
def greet(name), do: "Hello, #{name}!"
@spec count_items(list()) :: non_neg_integer()
def count_items(items), do: length(items)
@spec enabled?() :: boolean()
def enabled?, do: Application.get_env(:my_app, :enabled, false)
```
### Union Types
```elixir
@spec fetch_account(integer()) :: {:ok, Account.t()} | {:error, :not_found | :suspended}
def fetch_account(id) do
case Repo.get(Account, id) do
nil -> {:error, :not_found}
%Account{status: :suspended} = account -> {:error, :suspended}
account -> {:ok, account}
end
end
```
### Custom Types
```elixir
@type id :: pos_integer()
@type reason :: :not_found | :unauthorized | :timeout
@type result :: {:ok, t()} | {:error, reason()}
@spec find(id()) :: result()
def find(id), do: # ...
```
### Keyword Options
```elixir
@type option :: {:timeout, pos_integer()} | {:retries, non_neg_integer()}
@spec request(String.t(), [option()]) :: {:ok, Response.t()} | {:error, term()}
def request(url, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 5_000)
retries = Keyword.get(opts, :retries, 3)
# ...
end
```
### When Clauses
```elixir
@spec transform(list(a), (a -> b)) :: list(b) when a: term(), b: term()
def transform(items, func), do: Enum.map(items, func)
@spec wrap(value) :: [value] when value: term()
def wrap(value), do: [value]
```
## @type and @typedoc
Use custom types to name domain concepts and reduce repetition.
```elixir
defmodule MyApp.Shipping do
@typedoc "Weight in grams."
@type weight :: non_neg_integer()
@typedoc "A geographic coordinate pair."
@type coordinates :: {latitude :: float(), longitude :: float()}
@typedoc "Shipping status throughout the delivery lifecycle."
@type status :: :pending | :in_transit | :delivered | :returned
@spec estimate_cost(weight(), coordinates(), coordinates()) :: {:ok, Decimal.t()}
def estimate_cost(weight, origin, destination) do
# ...
end
end
```
Benefits:
- `weight()` communicates intent better than `non_neg_integer()`
- `status()` centralizes valid values -- add a new status in one place
- `@typedoc` appears in ExDoc, making types self-documenting
## When @spec Is Required
All public exported functions should have @spec. This includes:
```elixir
defmodule MyApp.Accounts do
# Required: public function
@spec create_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def create_user(attrs), do: # ...
# Required: public function with default args
@spec list_users(keyword()) :: [User.t()]
def list_users(opts \\ []), do: # ...
# Required: public function used as callback
@spec child_spec(keyword()) :: Supervisor.child_spec()
def child_spec(opts), do: # ...
end
```
## When @spec Is Optional
```elixir
defmodule MyApp.Accounts do
# Optional: private function
defp normalize_email(email), do: String.downcase(email)
# Optional: macro-generated functions (e.g., Ecto schema fields)
# These are generated by `schema` and `field` macros
# Optional: @impl true callbacks where the behaviour defines the spec
@impl true
def handle_call(:ping, _from, state), do: {:reply, :pong, state}
end
```
## Common @spec Mistakes
### Overly Broad term() or any()
```elixir
# BAD - term() hides what the function actually accepts
@spec process(term()) :: term()
def process(%Order{} = order), do: # ...
# GOOD - spec reflects the actual types
@spec process(Order.t()) :: {:ok, Receipt.t()} | {:error, String.t()}
def process(%Order{} = order), do: # ...
```
Using `term()` or `any()` defeats the purpose of specs. If you know the type, declare it.
### Missing Union Branches
```elixir
# BAD - forgets the nil case from Repo.get
@spec find_user(integer()) :: {:ok, User.t()}
def find_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found} # Not reflected in spec!
user -> {:ok, user}
end
end
# GOOD - all return paths represented
@spec find_user(integer()) :: {:ok, User.t()} | {:error, :not_found}
def find_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
```
### Not Using Custom Types for Repeated Patterns
```elixir
# BAD - same tuple pattern repeated across many functions
@spec create(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
@spec update(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
@spec delete(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
# GOOD - define a type once, reuse it
@type changeset_result :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
@spec create(map()) :: changeset_result()
@spec update(User.t(), map()) :: changeset_result()
@spec delete(User.t()) :: changeset_result()
```
### Specs That Don't Match Function Clauses
```elixir
# BAD - spec says it only accepts String.t() but function also handles nil
@spec normalize(String.t()) :: String.t()
def normalize(nil), do: ""
def normalize(value), do: String.trim(value)
# GOOD - spec covers all clauses
@spec normalize(String.t() | nil) :: String.t()
def normalize(nil), do: ""
def normalize(value), do: String.trim(value)
```
### Using list() When a Specific Element Type Is Known
```elixir
# BAD - list() tells the caller nothing about contents
@spec active_users() :: list()
def active_users, do: Repo.all(from u in User, where: u.active == true)
# GOOD - caller knows what's in the list
@spec active_users() :: [User.t()]
def active_users, do: Repo.all(from u in User, where: u.active == true)
```
## Dialyzer-Friendly Specs
Dialyzer uses @spec to perform success typing analysis. Specs that help Dialyzer catch real bugs:
```elixir
# Dialyzer can catch callers passing wrong types
@spec send_notification(User.t(), String.t()) :: :ok | {:error, :delivery_failed}
def send_notification(%User{email: email}, message) do
# ...
end
# Dialyzer can verify pattern match exhaustiveness
@type role :: :admin | :editor | :viewer
@spec permissions(role()) :: [atom()]
def permissions(:admin), do: [:read, :write, :delete]
def permissions(:editor), do: [:read, :write]
def permissions(:viewer), do: [:read]
# Dialyzer warns if a new role is added to the type but not handled here
```
Tips for Dialyzer compatibility:
- Avoid `@spec function() :: no_return()` unless the function truly never returns (e.g., raises always)
- Use `String.t()` instead of `binary()` for text data -- they're equivalent to Dialyzer but `String.t()` communicates intent
- Declare `@opaque` types when internal representation should not leak to callers
## Review Questions
1. Do all public functions have @spec?
2. Do specs accurately reflect all return paths (including error tuples)?
3. Are custom @type definitions used for repeated patterns?
4. Are specs specific enough to catch real bugs (no unnecessary term() or any())?
Reviews Elixir code for idiomatic patterns, OTP basics, and documentation. Use when reviewing .ex/.exs files, checking pattern matching, GenServer usage, or...
---
name: elixir-code-review
description: Reviews Elixir code for idiomatic patterns, OTP basics, and documentation. Use when reviewing .ex/.exs files, checking pattern matching, GenServer usage, or module documentation.
---
# Elixir Code Review
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| Naming, formatting, module structure | [references/code-style.md](references/code-style.md) |
| With clauses, guards, destructuring | [references/pattern-matching.md](references/pattern-matching.md) |
| GenServer, Supervisor, Application | [references/otp-basics.md](references/otp-basics.md) |
| @moduledoc, @doc, @spec, doctests | [references/documentation.md](references/documentation.md) |
## Review Checklist
### Code Style
- [ ] Module names are CamelCase, function names are snake_case
- [ ] Pipe chains start with raw data, not function calls
- [ ] Private functions grouped after public functions
- [ ] No unnecessary parentheses in function calls without arguments
### Pattern Matching
- [ ] Functions use pattern matching over conditionals where appropriate
- [ ] With clauses have else handling for error cases
- [ ] Guards used instead of runtime checks where possible
- [ ] Destructuring used in function heads, not body
### OTP Basics
- [ ] GenServers use handle_continue for expensive init work
- [ ] Supervisors use appropriate restart strategies
- [ ] No blocking calls in GenServer callbacks
- [ ] Proper use of call vs cast (sync vs async)
### Documentation
- [ ] All public functions have @doc and @spec
- [ ] Modules have @moduledoc describing purpose
- [ ] Doctests for pure functions where appropriate
- [ ] No @doc false on genuinely public functions
### Security
- [ ] No `String.to_atom/1` on user input (use `to_existing_atom/1`)
- [ ] No `Code.eval_string/1` on untrusted input
- [ ] No `:erlang.binary_to_term/1` without `:safe` option
## Valid Patterns (Do NOT Flag)
- **Empty function clause for pattern match** - `def foo(nil), do: nil` is valid guard
- **Using `|>` with single transformation** - Readability choice, not wrong
- **`@doc false` on callback implementations** - Callbacks documented at behaviour level
- **Private functions without @spec** - @spec optional for internals
- **Using `Kernel.apply/3`** - Valid for dynamic dispatch with known module/function
## Context-Sensitive Rules
| Issue | Flag ONLY IF |
|-------|--------------|
| Missing @spec | Function is public AND exported |
| Generic rescue | Specific exception types available |
| Nested case/cond | More than 2 levels deep |
## When to Load References
- Reviewing module/function naming → code-style.md
- Reviewing with/case/cond statements → pattern-matching.md
- Reviewing GenServer/Supervisor code → otp-basics.md
- Reviewing @doc/@moduledoc → documentation.md
## Gates — before reporting
Do these **in order** for the review batch. Do not publish findings until each step passes.
1. **Protocol loaded** — Read [review-verification-protocol](../review-verification-protocol/SKILL.md) and apply its checks for each finding category you use (unused, validation, security, performance, etc.). **Pass:** For every substantive finding, you can name which protocol subsection you satisfied or state **N/A** with reason (pure style).
2. **Anchored evidence** — **Pass:** Each finding includes a concrete locator: `path:line` (or line range), or `Module.function/arity` plus a short quoted snippet from the file.
3. **Claims backed by artifacts** — For assertions like unused code, missing validation, or security risk, **Pass:** You attach the supporting artifact (e.g. search results, file read scope) or downgrade the item to an explicit **question** / **uncertain** with what you did not verify.
## Before Submitting Findings
Complete **Gates — before reporting** (section above) first; the verification protocol is mandatory input to those gates.
FILE:references/code-style.md
# Elixir Code Style
## Naming Conventions
### Modules
- CamelCase: `MyApp.UserAccount`
- Acronyms as words: `MyApp.HTTPClient` not `MyApp.HttpClient`
### Functions
- snake_case: `fetch_user`, `parse_response`
- Predicate functions end with `?`: `valid?`, `empty?`
- Dangerous functions end with `!`: `save!`, `fetch!`
### Variables
- snake_case: `user_name`, `total_count`
- Unused variables prefixed with `_`: `_ignored`
## Formatting
### Pipe Chains
```elixir
# BAD - starts with function call
String.trim(input)
|> String.downcase()
|> String.split()
# GOOD - starts with data
input
|> String.trim()
|> String.downcase()
|> String.split()
```
### Function Ordering
```elixir
defmodule MyModule do
# 1. Module attributes
@moduledoc "..."
@behaviour SomeBehaviour
# 2. use/import/alias/require
use GenServer
import Guards
alias MyApp.User
require Logger
# 3. Module attributes (constants)
@timeout 5000
# 4. Struct definition
defstruct [:field]
# 5. Public functions
def public_function, do: ...
# 6. Callback implementations
@impl true
def handle_call(...), do: ...
# 7. Private functions
defp private_helper, do: ...
end
```
### Multi-clause Functions
```elixir
# GOOD - clauses grouped together
def process(nil), do: {:error, :nil_input}
def process([]), do: {:ok, []}
def process(list) when is_list(list), do: {:ok, Enum.map(list, &transform/1)}
# BAD - clauses separated by other code
def process(nil), do: {:error, :nil_input}
defp helper, do: ...
def process([]), do: {:ok, []} # Should be with other process/1 clauses
```
## Review Questions
1. Do module names follow CamelCase convention?
2. Do function names follow snake_case with appropriate suffixes?
3. Do pipe chains start with data, not function calls?
4. Are public functions grouped before private functions?
5. Are multi-clause functions grouped together?
FILE:references/documentation.md
# Documentation
## Module Documentation
### @moduledoc
```elixir
defmodule MyApp.UserManager do
@moduledoc """
Manages user lifecycle operations including creation, updates, and deletion.
This module provides the primary interface for user management and delegates
to the appropriate subsystems for persistence and notification.
## Examples
iex> UserManager.create(%{name: "Alice", email: "[email protected]"})
{:ok, %User{}}
## Configuration
Requires `:user_manager` config with `:repo` key.
"""
end
```
### When to Use @moduledoc false
```elixir
# Valid uses of @moduledoc false:
# 1. Private implementation modules
defmodule MyApp.Internal.Helper do
@moduledoc false
# ...
end
# 2. Protocol implementations
defimpl Jason.Encoder, for: MyStruct do
@moduledoc false
# ...
end
```
## Function Documentation
### @doc with @spec
```elixir
@doc """
Fetches a user by their unique identifier.
Returns `{:ok, user}` if found, `{:error, :not_found}` otherwise.
## Examples
iex> fetch_user(123)
{:ok, %User{id: 123}}
iex> fetch_user(-1)
{:error, :not_found}
"""
@spec fetch_user(pos_integer()) :: {:ok, User.t()} | {:error, :not_found}
def fetch_user(id) when is_integer(id) and id > 0 do
# ...
end
```
### @spec Patterns
```elixir
# Basic types
@spec add(integer(), integer()) :: integer()
# Union types
@spec parse(String.t()) :: {:ok, map()} | {:error, term()}
# Custom types
@type result :: {:ok, t()} | {:error, reason()}
@spec fetch(id()) :: result()
# Keyword options
@spec start_link(keyword()) :: GenServer.on_start()
# When clauses for type variables
@spec map(list(a), (a -> b)) :: list(b) when a: term(), b: term()
```
## Doctests
### When to Use
```elixir
# GOOD - pure function, predictable output
@doc """
Calculates the factorial of n.
## Examples
iex> Math.factorial(0)
1
iex> Math.factorial(5)
120
"""
def factorial(0), do: 1
def factorial(n), do: n * factorial(n - 1)
```
### When NOT to Use
```elixir
# BAD - side effects, unpredictable
@doc """
Creates a user in the database.
## Examples
iex> create_user(%{name: "Test"}) # Don't doctest DB operations!
{:ok, %User{}}
"""
```
## Review Questions
1. Do all public modules have @moduledoc?
2. Do all public functions have @doc and @spec?
3. Are doctests used for pure, deterministic functions?
4. Do @specs accurately reflect function signatures?
FILE:references/otp-basics.md
# OTP Basics
## GenServer
### Use handle_continue for Expensive Init
```elixir
# BAD - blocks supervisor during init
def init(args) do
data = expensive_operation() # Blocks!
{:ok, data}
end
# GOOD - defers expensive work
def init(args) do
{:ok, %{data: nil}, {:continue, :load_data}}
end
@impl true
def handle_continue(:load_data, state) do
data = expensive_operation()
{:noreply, %{state | data: data}}
end
```
### Call vs Cast
```elixir
# call - synchronous, returns result
def get_value(pid) do
GenServer.call(pid, :get_value)
end
# cast - asynchronous, fire-and-forget
def increment(pid) do
GenServer.cast(pid, :increment)
end
```
**When to use each:**
- `call` - Need the result, need confirmation, queries
- `cast` - Fire-and-forget, notifications, can't block caller
### Timeouts
```elixir
# Always consider timeouts for calls
def fetch_data(pid) do
GenServer.call(pid, :fetch_data, 10_000) # 10 second timeout
end
# Handle timeout in caller
case GenServer.call(pid, :fetch, 5_000) do
{:ok, data} -> data
{:error, reason} -> handle_error(reason)
rescue
exit -> {:error, :timeout}
end
```
## Supervisor
### Restart Strategies
| Strategy | When to Use |
|----------|-------------|
| `:one_for_one` | Children are independent |
| `:one_for_all` | Children are interdependent |
| `:rest_for_one` | Later children depend on earlier |
### Child Specs
```elixir
# GOOD - explicit child spec
children = [
{MyWorker, [name: :worker, arg: value]},
{DynamicSupervisor, name: MyApp.DynamicSup, strategy: :one_for_one}
]
Supervisor.init(children, strategy: :one_for_one)
```
## Common Anti-Patterns
### Blocking in Callbacks
```elixir
# BAD - blocks the GenServer
@impl true
def handle_call(:fetch_external, _from, state) do
result = HTTPClient.get!(url) # Blocks all other messages!
{:reply, result, state}
end
# GOOD - use Task for async work
@impl true
def handle_call(:fetch_external, from, state) do
Task.async(fn -> HTTPClient.get!(url) end)
{:noreply, %{state | pending: from}}
end
@impl true
def handle_info({ref, result}, %{pending: from} = state) do
GenServer.reply(from, result)
{:noreply, %{state | pending: nil}}
end
```
### Single Process Bottleneck
```elixir
# BAD - all requests through one GenServer
defmodule Cache do
use GenServer
def get(key), do: GenServer.call(__MODULE__, {:get, key})
def put(key, val), do: GenServer.call(__MODULE__, {:put, key, val})
end
# GOOD - use ETS for read-heavy workloads
defmodule Cache do
def get(key), do: :ets.lookup(:cache, key)
def put(key, val), do: :ets.insert(:cache, {key, val})
end
```
## Review Questions
1. Does GenServer init do expensive work synchronously?
2. Are call/cast used appropriately (sync vs async)?
3. Is there a single GenServer becoming a bottleneck?
4. Do supervisors use appropriate restart strategies?
FILE:references/pattern-matching.md
# Pattern Matching
## With Clauses
### Always Handle Errors
```elixir
# BAD - no else clause
with {:ok, user} <- fetch_user(id),
{:ok, account} <- fetch_account(user) do
{:ok, account}
end
# Returns {:error, reason} tuple unhandled!
# GOOD - explicit error handling
with {:ok, user} <- fetch_user(id),
{:ok, account} <- fetch_account(user) do
{:ok, account}
else
{:error, :not_found} -> {:error, :user_not_found}
{:error, reason} -> {:error, reason}
end
```
### Use Tagged Tuples for Clarity
```elixir
# BAD - ambiguous which step failed
with {:ok, user} <- fetch_user(id),
{:ok, posts} <- fetch_posts(user) do
{:ok, posts}
else
{:error, reason} -> {:error, reason} # Which operation failed?
end
# GOOD - tagged for clarity with helper
defp tag_error({:error, reason}, tag), do: {:error, tag, reason}
defp tag_error(other, _tag), do: other
with {:ok, user} <- tag_error(fetch_user(id), :user),
{:ok, posts} <- tag_error(fetch_posts(user), :posts) do
{:ok, posts}
else
{:error, :user, reason} -> {:error, {:user_fetch_failed, reason}}
{:error, :posts, reason} -> {:error, {:posts_fetch_failed, reason}}
end
```
## Guards
### Prefer Guards Over Runtime Checks
```elixir
# BAD - runtime check
def process(value) do
if is_binary(value) do
String.upcase(value)
else
raise ArgumentError
end
end
# GOOD - guard clause
def process(value) when is_binary(value) do
String.upcase(value)
end
```
### Multiple Guards
```elixir
# GOOD - multiple function heads with guards
def categorize(n) when n < 0, do: :negative
def categorize(0), do: :zero
def categorize(n) when n > 0, do: :positive
```
## Destructuring
### In Function Heads
```elixir
# BAD - destructure in body
def process(user) do
name = user.name
email = user.email
# ...
end
# GOOD - destructure in head
def process(%{name: name, email: email} = user) do
# name and email available, plus full user if needed
end
```
### In Case Statements
```elixir
# GOOD - pattern match extracts what you need
case fetch_user(id) do
{:ok, %User{name: name, active: true}} ->
{:ok, "Active user: #{name}"}
{:ok, %User{active: false}} ->
{:error, :inactive}
{:error, reason} ->
{:error, reason}
end
```
## Review Questions
1. Do with statements have else clauses handling all error cases?
2. Are guards used instead of runtime type checks?
3. Is destructuring done in function heads where possible?
4. Are pattern matches exhaustive (no unhandled cases)?
Tutorial patterns for documentation - learning-oriented guides that teach through guided doing
---
name: tutorial-docs
description: Tutorial patterns for documentation - learning-oriented guides that teach through guided doing
user-invocable: false
autoContext:
whenUserAsks:
- tutorial
- tutorials
- learning guide
- getting started guide
- onboarding guide
- beginner guide
- introductory guide
- learn by doing
- hands-on guide
dependencies:
- docs-style
---
# Tutorial Documentation Skill
This skill provides patterns for writing effective tutorials following the Diataxis framework. Tutorials are learning-oriented content where the reader learns by doing under the guidance of a teacher.
## Purpose & Audience
**Target readers:**
- Complete beginners with no prior experience
- Users who want to learn, not accomplish a specific task
- People who need a successful first experience with the product
- Learners who benefit from guided, hands-on practice
**Tutorials are NOT:**
- How-To guides (which help accomplish specific tasks)
- Explanations (which provide understanding)
- Reference docs (which describe the system)
## Core Principles (Diataxis Framework)
### 1. Learn by Doing, Not by Reading
Tutorials teach through action, not explanation. The reader should be doing something at every moment.
| Avoid | Prefer |
|-------|--------|
| "REST APIs use HTTP methods to..." | "Run this command to make your first API call:" |
| "Authentication is important because..." | "Add your API key to authenticate:" |
| "The dashboard contains several sections..." | "Click **Create Project** in the dashboard." |
### 2. Deliver Visible Results at Every Step
After each action, tell readers exactly what they should see. This confirms success and builds confidence.
```markdown
Run the development server:
```bash
npm run dev
```
You should see:
```
> Local: http://localhost:3000
> Ready in 500ms
```
Open http://localhost:3000 in your browser. You should see a welcome page with "Hello, World!" displayed.
```
### 3. One Clear Path, Minimize Choices
Tutorials should not offer alternatives. Pick one way and guide the reader through it completely.
| Avoid | Prefer |
|-------|--------|
| "You can use npm, yarn, or pnpm..." | "Install the dependencies:" |
| "There are several ways to configure..." | "Create a config file:" |
| "Optionally, you might want to..." | [Omit optional steps entirely] |
### 4. The Teacher Takes Responsibility
If the reader fails, the tutorial failed. Anticipate problems and prevent them. Never blame the reader.
```markdown
<Warning>
Make sure you're in the project directory before running this command.
If you see "command not found", return to Step 2 to verify the installation.
</Warning>
```
### 5. Permit Repetition to Build Confidence
Repeating similar actions in slightly different contexts helps cement learning. Don't try to be efficient.
## Tutorial Template
Use this structure for all tutorials:
```markdown
---
title: "Build your first [thing]"
description: "Learn the basics of [product] by building a working [thing]"
---
# Build Your First [Thing]
In this tutorial, you'll build a [concrete deliverable]. By the end, you'll have a working [thing] that [does something visible].
<Note>
This tutorial takes approximately [X] minutes to complete.
</Note>
## What you'll build
[Screenshot or diagram of the end result]
A [brief description of the concrete deliverable] that:
- [Visible capability 1]
- [Visible capability 2]
- [Visible capability 3]
## Prerequisites
Before starting, make sure you have:
- [Minimal requirement 1 - link to install guide if needed]
- [Minimal requirement 2]
<Tip>
New to [prerequisite]? [Link to external resource] has a quick setup guide.
</Tip>
## Step 1: [Set up your project]
[First action - always start with something that produces visible output]
```bash
[command]
```
You should see:
```
[expected output]
```
[Brief confirmation of what this means]
## Step 2: [Create your first thing]
[Next action with clear instruction]
```code
[code to add or modify]
```
Save the file. You should see [visible change].
<Note>
[Optional tip to prevent common mistakes]
</Note>
## Step 3: [Continue building]
[Continue with more steps, each producing visible output]
## Step 4: [Add the final piece]
[Bring it together with a final step]
You should now see [final visible result].
[Screenshot of completed project]
## What you've learned
In this tutorial, you:
- [Concrete skill 1 - what they can now do]
- [Concrete skill 2]
- [Concrete skill 3]
## Next steps
Now that you have a working [thing], you can:
- **[Tutorial 2 title]** - Continue learning by [next learning goal]
- **[How-to guide]** - Learn how to [specific task] with your [thing]
- **[Concepts page]** - Understand [concept] in more depth
```
## Writing Principles
### Title Conventions
- **Start with action outcomes**: "Build your first...", "Create a...", "Deploy your..."
- Focus on what they'll make, not what they'll learn
- Be concrete: "Build a chat application" not "Learn about real-time messaging"
### Step Structure
1. **Lead with the action** - don't explain before doing
2. **Show exactly what to type or click** - no ambiguity
3. **Confirm success after every step** - "You should see..."
4. **Keep steps small** - one visible change per step
### Managing Prerequisites
Tutorials are for beginners, so minimize prerequisites:
```markdown
## Prerequisites
- A computer with macOS, Windows, or Linux
- A text editor (we recommend VS Code)
- 15 minutes of time
<Tip>
You don't need any programming experience. This tutorial explains everything as we go.
</Tip>
```
### The "You should see" Pattern
This is the most important pattern in tutorial writing. Use it constantly:
```markdown
Click **Save**. You should see a green checkmark appear next to the filename.
Run the test:
```bash
npm test
```
You should see:
```
PASS src/app.test.js
✓ renders welcome message (23ms)
Tests: 1 passed, 1 total
```
```
### Handling Errors Gracefully
Anticipate failures and guide readers back on track:
```markdown
<Warning>
If you see "Module not found", make sure you saved the file from Step 2.
Return to Step 2 and verify the import statement matches exactly.
</Warning>
```
## Components for Tutorials
### Frame Component for Screenshots
Show what success looks like:
```markdown
<Frame caption="Your completed dashboard should look like this">

</Frame>
```
### Steps Component for Procedures
For numbered sequences within a step:
```markdown
<Steps>
<Step title="Open the settings panel">
Click the gear icon in the top right corner.
</Step>
<Step title="Find the API section">
Scroll down to **Developer Settings**.
</Step>
<Step title="Generate a key">
Click **Create New Key** and copy the value shown.
</Step>
</Steps>
```
### Callouts for Guidance
```markdown
<Note>
Don't worry if the colors look different on your screen.
We'll customize the theme in the next step.
</Note>
<Warning>
Make sure to save the file before continuing.
The next step won't work without this change.
</Warning>
<Tip>
You can press Cmd+S (Mac) or Ctrl+S (Windows) to save quickly.
</Tip>
```
### Code with Highlighted Lines
Draw attention to what matters:
```markdown
```javascript {3-4}
function App() {
return (
<h1>Hello, World!</h1>
<p>Welcome to your first app.</p>
);
}
```
```
## Example Tutorial
See [references/example-weather-api.md](references/example-weather-api.md) for a complete example tutorial demonstrating all principles above. The example builds a weather dashboard that fetches real API data.
## Checklist for Tutorials
Before publishing, verify:
- [ ] Title describes what they'll build, not what they'll learn
- [ ] Introduction shows the concrete end result
- [ ] Prerequisites are minimal (beginners don't have much)
- [ ] Every step produces visible output
- [ ] "You should see" appears after each significant action
- [ ] No choices offered - one clear path only
- [ ] No explanations of why things work (save for docs)
- [ ] Potential failures are anticipated with recovery guidance
- [ ] "What you've learned" summarizes concrete skills gained
- [ ] Next steps guide to continued learning
- [ ] Pre-publish gates (below) completed in order—not only self-reviewed
## Pre-publish gates
Run these **in order**. Start the next gate only after the previous **pass** is satisfied.
1. **Draft artifact** — The tutorial exists at a concrete path (file, branch, or CMS location). **Pass:** the artifact opens without guesswork.
2. **Observable outcomes** — **Pass:** every procedural step states what the reader should see next (command output, UI change, or named file)—not only what to do.
3. **Single path** — **Pass:** aside from prerequisite install links, the body does not branch into equivalent alternatives (“npm or yarn…”) unless you split into separate tutorials.
4. **Independent run** — Someone who did not write the draft follows the tutorial from a clean starting point. **Pass:** each step matches its promised outcome; any mismatch is fixed in the doc before publish (see “The Teacher Takes Responsibility” above).
## When to Use Tutorial vs Other Doc Types
| User's mindset | Doc type | Example |
|---------------|----------|---------|
| "I want to learn" | **Tutorial** | "Build your first chatbot" |
| "I want to do X" | How-To | "How to configure SSO" |
| "I want to understand" | Explanation | "How our caching works" |
| "I need to look up Y" | Reference | "API endpoint reference" |
### Tutorial vs How-To: Key Differences
| Aspect | Tutorial | How-To |
|--------|----------|--------|
| **Purpose** | Learning through doing | Accomplishing a specific task |
| **Audience** | Complete beginners | Users with some experience |
| **Structure** | Linear journey with one path | Steps to achieve a goal |
| **Choices** | None - one prescribed way | May show alternatives |
| **Explanations** | Minimal - action over theory | Minimal - focus on steps |
| **Success** | Reader learns and gains confidence | Reader completes their task |
| **Length** | Longer, more hand-holding | Shorter, more direct |
## Related Skills
- **docs-style**: Core writing conventions and components
- **howto-docs**: How-To guide patterns for task-oriented content
- **reference-docs**: Reference documentation patterns
- **explanation-docs**: Conceptual documentation patterns
FILE:references/example-weather-api.md
# Example Tutorial: Weather API Integration
This is a complete example tutorial demonstrating all principles from the tutorial-docs skill.
```markdown
---
title: "Build your first API integration"
description: "Learn the basics of our API by building a working weather dashboard"
---
# Build Your First API Integration
In this tutorial, you'll build a weather dashboard that fetches real data from our API. By the end, you'll have a working page that displays current weather for any city.
<Note>
This tutorial takes approximately 20 minutes to complete.
</Note>
## What you'll build
<Frame caption="The completed weather dashboard">

</Frame>
A simple weather dashboard that:
- Accepts a city name as input
- Fetches real weather data from our API
- Displays temperature and conditions
## Prerequisites
Before starting, make sure you have:
- Node.js 18 or later installed ([download here](https://nodejs.org))
- A free account ([sign up](https://example.com/signup))
## Step 1: Create your project
Open your terminal and create a new project folder:
```bash
mkdir weather-dashboard
cd weather-dashboard
npm init -y
```
You should see:
```
Wrote to /weather-dashboard/package.json
```
This creates a new project with default settings.
## Step 2: Install the SDK
Install our JavaScript SDK:
```bash
npm install @example/weather-sdk
```
You should see output ending with:
```
added 1 package in 2s
```
## Step 3: Get your API key
<Steps>
<Step title="Open the dashboard">
Go to [dashboard.example.com](https://dashboard.example.com) and sign in.
</Step>
<Step title="Navigate to API keys">
Click **Settings** in the sidebar, then **API Keys**.
</Step>
<Step title="Create a key">
Click **Create Key**, name it "weather-tutorial", and click **Generate**.
</Step>
<Step title="Copy the key">
Copy the key shown. You'll need it in the next step.
</Step>
</Steps>
<Warning>
Keep this key secret. Don't share it or commit it to version control.
</Warning>
## Step 4: Write your first API call
Create a new file called `weather.js`:
```javascript
const Weather = require('@example/weather-sdk');
const client = new Weather({
apiKey: 'your-api-key-here' // Replace with your key from Step 3
});
async function getWeather(city) {
const data = await client.current(city);
console.log(`Weather in city:`);
console.log(` Temperature: data.temp°F`);
console.log(` Conditions: data.conditions`);
}
getWeather('San Francisco');
```
Replace `'your-api-key-here'` with the API key you copied in Step 3.
Save the file.
## Step 5: Run your dashboard
Run your script:
```bash
node weather.js
```
You should see:
```
Weather in San Francisco:
Temperature: 62°F
Conditions: Partly cloudy
```
You've just made your first API call.
<Note>
The temperature will vary based on current conditions.
Any valid output means your integration is working.
</Note>
## Step 6: Try another city
Change the last line of `weather.js`:
```javascript
getWeather('Tokyo');
```
Run it again:
```bash
node weather.js
```
You should see weather data for Tokyo:
```
Weather in Tokyo:
Temperature: 75°F
Conditions: Clear
```
## What you've learned
In this tutorial, you:
- Created a new Node.js project
- Installed and configured our SDK
- Generated an API key
- Made API calls to fetch weather data
## Next steps
Now that you have a working API integration, you can:
- **[Build a weather CLI](/tutorials/weather-cli)** - Continue learning by adding command-line arguments
- **[How to handle API errors](/how-to/handle-api-errors)** - Learn to handle rate limits and network issues
- **[API reference](/reference/weather-api)** - Explore all available weather endpoints
```
Explanation documentation patterns for understanding-oriented content - conceptual guides that explain why things work the way they do
---
name: explanation-docs
description: Explanation documentation patterns for understanding-oriented content - conceptual guides that explain why things work the way they do
user-invocable: false
autoContext:
whenUserAsks:
- explanation doc
- explanation documentation
- conceptual guide
- conceptual documentation
- understanding doc
- background doc
- design rationale
- architecture explanation
- how does it work
- why does it work
dependencies:
- docs-style
---
# Explanation Documentation Skill
This skill provides patterns for writing effective explanation documents. Explanations are understanding-oriented content for readers who want to know why things work the way they do.
## Purpose & Audience
**Target readers:**
- Users who want to understand concepts deeply, not just use them
- Architects and technical leads evaluating design decisions
- Team members onboarding to a codebase or system
- Anyone asking "why?" or "how does this work?"
**Explanations are for reading away from the keyboard.** Unlike tutorials or how-to guides, readers aren't trying to accomplish a task while reading. They're building mental models.
**Explanations are NOT:**
- Tutorials (which teach through hands-on doing)
- How-To guides (which accomplish specific goals)
- Reference docs (which look up precise details)
## Explanation Document Template
Use this structure for all explanation documents:
```markdown
---
title: "[Concept/System Name] Explained"
description: "Understand how [concept] works and why it was designed this way"
---
# Understanding [Concept]
Brief intro (2-3 sentences): What this document explains and why it matters. Set expectations for what the reader will understand after reading.
## Overview
High-level summary of the concept. What is it? What problem does it solve? This should be understandable without deep technical knowledge.
## Background and Context
### The Problem
What situation or challenge led to this design? What were users or developers struggling with?
### Historical Context
How did we get here? What came before? This helps readers understand why alternatives were rejected or why certain constraints exist.
## How It Works
### Core Concepts
Explain the fundamental ideas. Use analogies to connect to concepts readers already understand.
<Note>
Use diagrams or visual aids when explaining complex relationships or flows.
</Note>
### The Mechanism
Walk through how the system actually operates. This is conceptual, not procedural - explain the "what happens" rather than "what to do."
### Key Components
Break down the major parts and how they interact. For each component:
- What role does it play?
- How does it relate to other components?
## Design Decisions and Trade-offs
### Why This Approach?
Explain the reasoning behind key design choices. What goals drove these decisions?
### Trade-offs Made
Every design involves trade-offs. Be explicit about:
- What was prioritized
- What was sacrificed
- Under what conditions this design excels or struggles
### Constraints and Assumptions
What constraints shaped the design? What assumptions does it rely on?
## Alternatives Considered
### [Alternative Approach 1]
Brief description of an alternative approach. Why wasn't it chosen? Under what circumstances might it be better?
### [Alternative Approach 2]
Another alternative. Comparing alternatives helps readers understand the design space.
## Implications and Consequences
What does this design mean for:
- Performance?
- Scalability?
- Developer experience?
- Future extensibility?
## Related Concepts
- [Related Concept 1](/concepts/related-1) - How it connects to this topic
- [Related Concept 2](/concepts/related-2) - Another related area
- [Deeper Technical Reference](/reference/detail) - For implementation specifics
```
## Writing Principles
### Focus on Understanding, Not Doing
Explanations answer "why?" and "how does it work?" rather than "how do I?"
| Explanation (good) | How-To (wrong context) |
|-------------------|------------------------|
| "The cache uses LRU eviction because memory is limited and recent items are more likely to be accessed again." | "To configure the cache, set the `maxSize` parameter." |
| "Authentication tokens expire to limit the damage if they're compromised." | "Refresh your token by calling the `/refresh` endpoint." |
### Use Analogies and Mental Models
Connect unfamiliar concepts to things readers already know.
```markdown
<!-- Good: Relatable analogy -->
Think of the message queue like a post office. Messages (letters) are dropped off
by senders and held until recipients pick them up. The post office doesn't care
about the content - it just ensures reliable delivery.
<!-- Avoid: Jumping straight to technical details -->
The message queue implements a FIFO buffer with configurable persistence
and at-least-once delivery semantics.
```
### Explain the "Why" Behind Design Decisions
Don't just describe what exists - explain why it exists that way.
```markdown
<!-- Good: Explains rationale -->
We chose eventual consistency over strong consistency because our read-heavy
workload (100:1 read-to-write ratio) benefits more from low latency than from
immediate consistency. Most users never notice the brief delay.
<!-- Avoid: Just states facts -->
The system uses eventual consistency with a 500ms propagation window.
```
### Discuss Trade-offs Honestly
Every design choice has costs. Acknowledging them builds trust and helps readers make informed decisions.
```markdown
## Trade-offs
This architecture optimizes for **write throughput** at the cost of:
- **Read latency**: Queries may need to hit multiple partitions
- **Complexity**: Developers must understand partition keys
- **Cost**: More storage due to denormalization
This trade-off makes sense for our use case (high-volume event ingestion)
but may not suit read-heavy analytics workloads.
```
### Structure for Reflection, Not Action
Explanations are read linearly, away from the keyboard. Structure them like essays, not manuals.
- **Use flowing prose** more than bullet points
- **Build concepts progressively** - each section prepares for the next
- **Allow for depth** - it's okay if sections are longer than in how-to guides
- **Include context** that would be distracting in task-focused docs
### Connect to the Bigger Picture
Show how this concept relates to other parts of the system or to broader industry patterns.
```markdown
## Related Concepts
Our event sourcing approach is part of our broader CQRS (Command Query
Responsibility Segregation) architecture. Understanding event sourcing
helps explain:
- Why our read models are eventually consistent
- How we achieve audit logging "for free"
- Why replaying events is central to our testing strategy
For more on CQRS, see [Understanding Our Architecture](/concepts/cqrs-architecture).
```
## Components for Explanations
### Diagrams and Visuals
Explanations benefit heavily from visual aids:
```markdown
## System Architecture
The following diagram shows how requests flow through the system:
```mermaid
graph LR
A[Client] --> B[Load Balancer]
B --> C[API Gateway]
C --> D[Service A]
C --> E[Service B]
D --> F[(Database)]
E --> F
```
The load balancer distributes traffic across API gateway instances...
```
### Comparison Tables
Tables work well for comparing approaches:
```markdown
## Comparing Approaches
| Aspect | Monolith | Microservices |
|--------|----------|---------------|
| Deployment | Single unit, simpler | Independent, more complex |
| Scaling | Vertical | Horizontal per service |
| Team autonomy | Lower | Higher |
| Operational overhead | Lower | Higher |
We chose microservices because team autonomy was critical for our
100+ engineer organization...
```
### Callouts for Key Insights
```markdown
<Note>
This is a common source of confusion: the "eventual" in eventual consistency
doesn't mean "maybe" - it means "not immediately, but guaranteed eventually."
</Note>
<Warning>
This design assumes network partitions are rare. In environments with
unreliable networks, consider stronger consistency guarantees.
</Warning>
```
### Expandable Sections for Depth
Use expandables for tangential but valuable details:
```markdown
<Expandable title="Historical note: Why we migrated from Redis">
Our original implementation used Redis for caching. In 2023, we migrated
to a custom solution because...
This context explains why some older code references Redis patterns
even though we no longer use it directly.
</Expandable>
```
## Example Explanation Document
```markdown
---
title: "Understanding Our Authentication System"
description: "Learn how authentication works in our platform and why we designed it this way"
---
# Understanding Our Authentication System
This document explains how our authentication system works and the reasoning
behind its design. After reading, you'll understand the flow from login to
API access and why we made the architectural choices we did.
## Overview
Our authentication system uses short-lived access tokens with long-lived refresh
tokens. This pattern, sometimes called "token rotation," balances security with
user experience by limiting exposure while avoiding frequent re-authentication.
## Background and Context
### The Problem
Modern web applications face competing demands: security teams want frequent
credential rotation, while users expect seamless experiences without constant
logins. Traditional session-based authentication requires server-side state,
complicating horizontal scaling.
### Historical Context
We originally used server-side sessions stored in Redis. As we scaled to
multiple regions, session synchronization became a bottleneck. JWT tokens
emerged as an industry standard for stateless authentication, and we adopted
them in 2022.
## How It Works
### Core Concepts
**Access tokens** are like day passes at a conference. They grant entry for a
limited time and are checked at each door (API endpoint). If someone steals
your day pass, they can only use it until it expires.
**Refresh tokens** are like the registration confirmation you used to get your
day pass. You don't carry it around, but you can use it to get a new day pass
when yours expires.
### The Authentication Flow
When a user logs in:
1. They provide credentials to the authentication service
2. If valid, they receive both an access token (15-minute expiry) and
a refresh token (7-day expiry)
3. The access token is used for API requests
4. When the access token expires, the refresh token obtains a new one
5. The old refresh token is invalidated, and a new one is issued
This rotation means that even if a refresh token is compromised, it can only
be used once before the legitimate user's next refresh invalidates it.
### Key Components
**Authentication Service**: Issues and validates tokens. Stateless for access
tokens, maintains a denylist for revoked refresh tokens.
**API Gateway**: Validates access tokens on every request. Rejects expired or
malformed tokens before requests reach backend services.
**Token Store**: Maintains refresh token metadata for revocation. Uses Redis
with regional replication.
## Design Decisions and Trade-offs
### Why Short-Lived Access Tokens?
We chose 15-minute expiry based on our threat model. Shorter expiry limits the
window for stolen token abuse, but more frequent refreshes increase latency
and auth service load. Our analysis showed 15 minutes balances these concerns
for our traffic patterns.
### Trade-offs Made
**Prioritized**: Horizontal scalability, security through token rotation
**Sacrificed**: Immediate revocation of access tokens, simplicity
Access tokens remain valid until expiry even after logout. For most use cases,
15 minutes of continued access is acceptable. For high-security operations
(password changes, large transfers), we require re-authentication.
### Constraints and Assumptions
- Clients can securely store refresh tokens (HttpOnly cookies for web)
- Clock skew between servers is under 30 seconds
- Redis is available for refresh token validation
## Alternatives Considered
### Server-Side Sessions
Traditional sessions would allow immediate revocation but require sticky
sessions or distributed session storage. We rejected this due to scaling
complexity and regional latency concerns.
### Longer Access Token Expiry
Longer-lived tokens reduce auth service load but increase risk from token
theft. Given our security requirements, we prioritized shorter windows.
## Implications and Consequences
**Performance**: Auth service handles ~10K refresh requests per minute. Token
validation is CPU-bound (signature verification), so we scale horizontally.
**Developer Experience**: Services never need database access for auth - they
just validate JWT signatures. This simplifies service development.
**User Experience**: Most users never notice token refresh. Mobile apps
refresh proactively to avoid mid-action expiry.
## Related Concepts
- [API Gateway Architecture](/concepts/api-gateway) - How the gateway validates tokens
- [Token Security Best Practices](/concepts/token-security) - Secure storage guidance
- [Authentication API Reference](/reference/auth-api) - Endpoint documentation
```
## Checklist for Explanations
Before publishing, verify:
- [ ] Title indicates this explains a concept (not a how-to)
- [ ] Introduction sets expectations for what reader will understand
- [ ] Background section provides context and history
- [ ] Core concepts explained with analogies or mental models
- [ ] Design decisions include rationale, not just facts
- [ ] Trade-offs discussed honestly
- [ ] Alternatives mentioned and compared
- [ ] Implications for different concerns addressed
- [ ] Related concepts linked
- [ ] Written for reading away from keyboard (no tasks to follow)
- [ ] Progressive structure builds understanding step by step
## Gates before "done" (sequenced)
Run in order. **Do not skip ahead**; each step has an objective pass condition.
1. **Classify** — Decide whether this document is an explanation (vs tutorial, how-to, or reference) using [When to Use Explanation vs Other Doc Types](#when-to-use-explanation-vs-other-doc-types). **Pass if** the dominant reader question matches an Explanation row in that table *and* the introduction states what understanding the reader will gain (one short sentence is enough).
2. **Skeleton** — Align the draft with [Explanation Document Template](#explanation-document-template). **Pass if** the draft includes substantive content (not placeholders) for **Overview**, **How It Works**, and at least one of **Design Decisions and Trade-offs** or **Alternatives Considered**.
3. **Rationale** — **Pass if** every major design or architecture claim includes either why it was chosen or what trade-off it accepts (not only what exists).
4. **Checklist** — Complete [Checklist for Explanations](#checklist-for-explanations). **Pass if** every item is satisfied or explicitly waived in prose (with reason).
## When to Use Explanation vs Other Doc Types
| Reader's Question | Doc Type | Focus |
|------------------|----------|-------|
| "How do I do X?" | How-To Guide | Steps to accomplish a goal |
| "Teach me about X" | Tutorial | Learning through guided doing |
| "What is the API for X?" | Reference | Precise technical details |
| "Why does X work this way?" | **Explanation** | Understanding and context |
| "What are the trade-offs of X?" | **Explanation** | Design rationale |
| "How does X relate to Y?" | **Explanation** | Conceptual connections |
### Explanation Signals
Write an explanation when users:
- Ask "why" questions
- Need to make architectural decisions
- Are evaluating whether something fits their use case
- Want to understand design philosophy
- Need context before diving into implementation
### Not an Explanation
If users need to accomplish something while reading, it's not an explanation:
- "How to configure caching" - How-To Guide
- "Cache API reference" - Reference Doc
- "Build a caching layer tutorial" - Tutorial
- "How caching works and why we use LRU" - **Explanation**
## Related Skills
- **docs-style**: Core writing conventions and components
- **howto-docs**: How-To guide patterns for task-oriented content
- **reference-docs**: Reference documentation patterns for lookups
- **tutorial-docs**: Tutorial patterns for learning-oriented content
Core technical documentation writing principles for voice, tone, structure, and LLM-friendly patterns. Use when writing or reviewing any documentation.
---
name: docs-style
description: Core technical documentation writing principles for voice, tone, structure, and LLM-friendly patterns. Use when writing or reviewing any documentation.
user-invocable: false
---
# Documentation Style Guide
Apply these principles when writing or reviewing documentation to ensure clarity, consistency, and accessibility for both human readers and LLMs.
## Voice and Tone
### Use Second Person
Address the reader directly as "you" rather than "the user" or "developers."
```markdown
<!-- Good -->
You can configure the API by setting environment variables.
<!-- Avoid -->
The user can configure the API by setting environment variables.
Developers should configure the API by setting environment variables.
```
### Prefer Active Voice
Write sentences where the subject performs the action. Active voice is clearer and more direct.
```markdown
<!-- Good -->
Create a configuration file in the root directory.
The function returns an array of user objects.
<!-- Avoid -->
A configuration file should be created in the root directory.
An array of user objects is returned by the function.
```
### Be Concise
Cut unnecessary words. Every word should earn its place.
```markdown
<!-- Good -->
Run the install command.
<!-- Avoid -->
In order to proceed, you will need to run the install command.
```
```markdown
<!-- Good -->
This endpoint returns user data.
<!-- Avoid -->
This endpoint is used for the purpose of returning user data.
```
Common phrases to simplify:
| Instead of | Use |
|------------|-----|
| in order to | to |
| for the purpose of | to, for |
| in the event that | if |
| at this point in time | now |
| due to the fact that | because |
| it is necessary to | you must |
| is able to | can |
| make use of | use |
## Document Structure
### Write Clear, Descriptive Headings
Headings should tell readers exactly what the section contains. Avoid clever or vague titles.
```markdown
<!-- Good -->
## Install the CLI
## Configure Authentication
## Handle Rate Limits
<!-- Avoid -->
## Getting Started (vague)
## The Fun Part (clever)
## Misc (uninformative)
```
### Create Self-Contained Pages
Assume readers may land on any page directly from search. Each page should:
- Explain what the feature/concept is
- State prerequisites clearly
- Provide complete context for the topic
```markdown
<!-- Good: Self-contained -->
# Webhooks
Webhooks let you receive real-time notifications when events occur in your account.
## Prerequisites
- An active API key with webhook permissions
- A publicly accessible HTTPS endpoint
## Create a Webhook
...
```
### Use Semantic Markup
Choose the right format for the content type:
- **Headings**: Follow proper hierarchy (h1 > h2 > h3, never skip levels)
- **Lists**: Use for multiple related items
- **Tables**: Use for structured data with consistent attributes
- **Code blocks**: Use for any code, commands, or file paths
```markdown
<!-- Good: Table for structured data -->
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| api_key | string | Yes | Your API key |
| timeout | integer | No | Request timeout in seconds |
<!-- Good: List for steps or options -->
To authenticate, you can:
- Use an API key in the header
- Use OAuth 2.0
- Use a service account
```
### Make Content Skimmable
Break dense paragraphs into digestible chunks:
- Keep paragraphs to 3-4 sentences maximum
- Use bullet points for lists of items
- Add subheadings to long sections
- Put key information first (inverted pyramid)
```markdown
<!-- Good: Skimmable -->
## Error Handling
The API returns standard HTTP status codes.
### Common Errors
- **400 Bad Request**: Invalid parameters. Check the request body.
- **401 Unauthorized**: Invalid or missing API key.
- **429 Too Many Requests**: Rate limit exceeded. Wait and retry.
### Retry Strategy
For 429 errors, use exponential backoff starting at 1 second.
```
## Consistency
### Use One Term Per Concept
Pick a term and use it consistently. Switching terms confuses readers.
```markdown
<!-- Good: Consistent terminology -->
Generate an API key in the dashboard. Use your API key in the Authorization header.
<!-- Avoid: Inconsistent terminology -->
Generate an API key in the dashboard. Use your API token in the Authorization header.
```
Document your terminology choices:
| Concept | Use | Don't use |
|---------|-----|-----------|
| Authentication credential | API key | API token, secret key, access key |
| Configuration file | config file | settings file, preferences file |
| Command line | CLI | terminal, command prompt, shell |
### Apply Consistent Formatting
Use the same formatting for similar content types:
- **UI elements**: Bold (Click **Save**)
- **Code/commands**: Backticks (`npm install`)
- **File paths**: Backticks (`/etc/config.yaml`)
- **Key terms on first use**: Bold or italics
- **Placeholders**: SCREAMING_CASE or angle brackets (`YOUR_API_KEY` or `<api-key>`)
## LLM-Friendly Patterns
### State Prerequisites Explicitly
List what users need before starting. This helps both humans and LLMs understand context.
```markdown
## Prerequisites
Before you begin, ensure you have:
- Node.js 18 or later installed
- An active account with admin permissions
- Your API key (find it in **Settings > API**)
```
### Define Acronyms on First Use
Spell out acronyms the first time they appear on a page.
```markdown
<!-- Good -->
The CLI (Command Line Interface) provides tools for managing your resources.
Subsequent uses can just say "CLI."
<!-- Avoid -->
The CLI provides tools for managing your resources.
```
### Provide Complete, Runnable Code Examples
Code examples should work when copied. Include:
- All necessary imports
- Realistic placeholder values
- Expected output (when helpful)
```markdown
<!-- Good: Complete example -->
```python
import requests
API_KEY = "your-api-key"
BASE_URL = "https://api.example.com/v1"
response = requests.get(
f"{BASE_URL}/users",
headers={"Authorization": f"Bearer {API_KEY}"}
)
print(response.json())
# Output: {"users": [{"id": 1, "name": "Alice"}, ...]}
```
<!-- Avoid: Incomplete snippet -->
```python
response = requests.get(url, headers=headers)
```
```
### Write Descriptive Titles and Meta Descriptions
Page titles and descriptions help with search and LLM understanding.
```markdown
---
title: "Authentication - API Reference"
description: "Learn how to authenticate API requests using API keys, OAuth 2.0, or service accounts."
---
```
## Pitfalls to Avoid
### Don't Use Product-Centric Language
Orient documentation around user goals, not product features.
```markdown
<!-- Good: User-goal oriented -->
# Send Emails
Send transactional emails to your users with delivery tracking.
<!-- Avoid: Product-centric -->
# Email Service
Our powerful email service provides enterprise-grade delivery.
```
### Skip Obvious Instructions
Don't document self-explanatory UI actions.
```markdown
<!-- Good: Meaningful instruction -->
Enter your webhook URL. The URL must use HTTPS and be publicly accessible.
<!-- Avoid: Obvious instruction -->
Click in the text field. Type your webhook URL. Click the Save button.
```
### Avoid Colloquialisms
Colloquialisms hurt clarity and localization.
```markdown
<!-- Good -->
This approach significantly improves performance.
<!-- Avoid -->
This approach is a game-changer for performance.
This will blow your mind.
Let's dive in!
```
## Quick Reference Checklist
When writing documentation, verify:
- [ ] Using "you" instead of "the user"
- [ ] Active voice throughout
- [ ] No unnecessary words
- [ ] Headings are descriptive
- [ ] Page is self-contained
- [ ] Proper heading hierarchy
- [ ] One term per concept
- [ ] Prerequisites listed
- [ ] Acronyms defined
- [ ] Code examples are complete
- [ ] No product-centric language
- [ ] No colloquialisms
## Applying This Skill
Use these principles when:
1. **Writing new documentation**: Apply all principles from the start
2. **Reviewing documentation**: Check against the quick reference checklist
3. **Editing existing docs**: Prioritize voice/tone, then structure, then consistency
4. **Creating code examples**: Ensure they are complete and runnable
Use when comparing two or more code implementations against a spec or requirements doc. Triggers on "which repo is better", "compare these implementations",...
---
name: llm-judge
description: "Use when comparing two or more code implementations against a spec or requirements doc. Triggers on \"which repo is better\", \"compare these implementations\", \"evaluate both solutions\", \"rank these codebases\", or \"judge which approach wins\". Also covers choosing between competing PRs or vendor submissions solving the same problem. Does NOT review a single codebase for quality \u2014 use code review skills instead. Does NOT evaluate strategy docs \u2014 use strategy-review. Requires a spec file and 2+ repo paths."
disable-model-invocation: true
---
# LLM Judge
Compare code implementations across multiple repositories using structured evaluation.
## Usage
```bash
/beagle-analysis:llm-judge <spec> <repo1> <repo2> [repo3...] [--labels=...] [--weights=...] [--branch=...]
```
## Arguments
| Argument | Required | Description |
|----------|----------|-------------|
| `spec` | Yes | Path to spec/requirements document |
| `repos` | Yes | 2+ paths to repositories to compare |
| `--labels` | No | Comma-separated labels (default: directory names) |
| `--weights` | No | Override weights, e.g. `functionality:40,security:30` |
| `--branch` | No | Branch to compare against main (default: `main`) |
## Workflow
1. Parse `$ARGUMENTS` into `spec_path`, `repo_paths`, `labels`, `weights`, and `branch`.
2. Validate the spec file, each repo path, and the minimum repo count.
3. Read the spec document into memory.
4. Load this skill and the supporting reference files.
5. Spawn one Phase 1 repo agent per repository to gather facts only.
6. Validate the repo-agent JSON results before proceeding.
7. Spawn one Phase 2 judge agent per dimension.
8. Aggregate scores, compute weighted totals, rank repos, and write the report.
9. Display the markdown summary and verify the JSON report.
## Hard gates
Sequenced workflow: **do not start the next phase until the current gate passes.** Each pass condition must be checkable (file on disk, non-empty content, or `json.load` succeeds)—not “I reviewed internally.”
| Gate | Pass condition | Unblocks |
|------|----------------|----------|
| **A — Inputs** | `spec_path` is a readable file and non-empty; `len(repo_paths) ≥ 2`; each path contains `.git`. | Phase 1 repo agents |
| **B — Phase 1 facts** | For **each** repo agent output: stdin/stdout parses as JSON; required keys/shape match `references/fact-schema.md`. | Phase 2 judge agents |
| **C — Phase 2 scores** | **Five** judge outputs (one per dimension) each parse as JSON; each includes a score (and justification) for **every** repo label. | Aggregation |
| **D — Report file** | `.beagle/llm-judge-report.json` exists; `python3 -c "import json; json.load(open('.beagle/llm-judge-report.json'))"` exits 0. | Markdown summary to the user |
| **E — Consistency** | Summary table and verdict use the same labels, weights, and per-dimension scores as the JSON report. | Mark task complete |
Parallelism is allowed **within** a phase (all Phase 1 tasks together; all Phase 2 tasks together), but Phase 2 must not start until Gate B passes, and the user-visible summary must not precede Gate D.
## Command Workflow
### Step 1: Parse Arguments
Parse `$ARGUMENTS` to extract:
- `spec_path`: first positional argument
- `repo_paths`: remaining positional arguments (must be 2+)
- `labels`: from `--labels` or derived from directory names
- `weights`: from `--weights` or defaults
- `branch`: from `--branch` or `main`
**Default Weights:**
```json
{
"functionality": 30,
"security": 25,
"tests": 20,
"overengineering": 15,
"dead_code": 10
}
```
### Step 2: Validate Inputs
```bash
[ -f "$SPEC_PATH" ] || { echo "Error: Spec file not found: $SPEC_PATH"; exit 1; }
for repo in "REPO_PATHS[@]"; do
[ -d "$repo/.git" ] || { echo "Error: Not a git repository: $repo"; exit 1; }
done
[ #REPO_PATHS[@] -ge 2 ] || { echo "Error: Need at least 2 repositories to compare"; exit 1; }
```
### Step 3: Read Spec Document
```bash
SPEC_CONTENT=$(cat "$SPEC_PATH") || { echo "Error: Failed to read spec file: $SPEC_PATH"; exit 1; }
[ -z "$SPEC_CONTENT" ] && { echo "Error: Spec file is empty: $SPEC_PATH"; exit 1; }
```
### Step 4: Load the Skill
Load the llm-judge skill: `Skill(skill: "beagle-analysis:llm-judge")`
### Step 5: Phase 1 - Spawn Repo Agents
Spawn one Task per repo:
```text
You are a Phase 1 Repo Agent for the LLM Judge evaluation.
**Your Repo:** $LABEL at $REPO_PATH
**Spec Document:**
$SPEC_CONTENT
**Instructions:**
1. Load skill: Skill(skill: "beagle-analysis:llm-judge")
2. Read references/repo-agent.md for detailed instructions
3. Read references/fact-schema.md for the output format
4. Load Skill(skill: "beagle-core:llm-artifacts-detection") for analysis
Explore the repository and gather facts. Return ONLY valid JSON following the fact schema.
Do NOT score or judge. Only gather facts.
```
Collect all repo outputs into `ALL_FACTS`.
### Step 6: Validate Phase 1 Results
```bash
echo "$FACTS" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null || { echo "Error: Invalid JSON from $LABEL"; exit 1; }
```
### Step 7: Phase 2 - Spawn Judge Agents
Spawn five judge agents, one per dimension:
```text
You are the $DIMENSION Judge for the LLM Judge evaluation.
**Spec Document:**
$SPEC_CONTENT
**Facts from all repos:**
$ALL_FACTS_JSON
**Instructions:**
1. Load skill: Skill(skill: "beagle-analysis:llm-judge")
2. Read references/judge-agents.md for detailed instructions
3. Read references/scoring-rubrics.md for the $DIMENSION rubric
Score each repo on $DIMENSION. Return ONLY valid JSON with scores and justifications.
```
### Step 8: Aggregate Scores
```python
for repo_label in labels:
scores[repo_label] = {}
for dimension in dimensions:
scores[repo_label][dimension] = judge_outputs[dimension]['scores'][repo_label]
weighted_total = sum(
scores[repo_label][dim]['score'] * weights[dim] / 100
for dim in dimensions
)
scores[repo_label]['weighted_total'] = round(weighted_total, 2)
ranking = sorted(labels, key=lambda l: scores[l]['weighted_total'], reverse=True)
```
### Step 9: Generate Verdict
Name the winner, explain why they won, and note any close calls or trade-offs.
### Step 10: Write JSON Report
```bash
mkdir -p .beagle
```
Write `.beagle/llm-judge-report.json` with version, timestamp, repo metadata, weights, scores, ranking, and verdict.
### Step 11: Display Summary
Render a markdown summary with the scores table, ranking, verdict, and detailed justifications.
### Step 12: Verification
```bash
python3 -c "import json; json.load(open('.beagle/llm-judge-report.json'))" && echo "Valid report"
```
### Output Shape
The generated report should include:
- repo labels and paths
- per-dimension scores and justifications
- weighted totals and ranking
- a verdict explaining the winner
## Reference Files
| File | Purpose |
|------|---------|
| [references/fact-schema.md](references/fact-schema.md) | JSON schema for Phase 1 facts |
| [references/scoring-rubrics.md](references/scoring-rubrics.md) | Detailed rubrics for each dimension |
| [references/repo-agent.md](references/repo-agent.md) | Instructions for Phase 1 agents |
| [references/judge-agents.md](references/judge-agents.md) | Instructions for Phase 2 judges |
## Scoring Model
| Dimension | Default Weight | Evaluates |
|-----------|----------------|-----------|
| Functionality | 30% | Spec compliance, test pass rate |
| Security | 25% | Vulnerabilities, security patterns |
| Test Quality | 20% | Coverage, DRY, mock boundaries |
| Overengineering | 15% | Unnecessary complexity |
| Dead Code | 10% | Unused code, TODOs |
## Scoring Scale
| Score | Meaning |
|-------|---------|
| 5 | Excellent - Exceeds expectations |
| 4 | Good - Meets requirements, minor issues |
| 3 | Average - Functional but notable gaps |
| 2 | Below Average - Significant issues |
| 1 | Poor - Fails basic requirements |
## Phase 1: Spawning Repo Agents
For each repository, spawn a Task agent with:
```text
You are a Phase 1 Repo Agent for the LLM Judge evaluation.
**Your Repo:** $REPO_LABEL at $REPO_PATH
**Spec Document:**
$SPEC_CONTENT
**Instructions:** Read @beagle:llm-judge references/repo-agent.md
Gather facts and return a JSON object following the schema in references/fact-schema.md.
Load @beagle:llm-artifacts-detection for dead code and overengineering analysis.
Return ONLY valid JSON, no markdown or explanations.
```
Collect all repo-agent outputs into `ALL_FACTS`.
## Phase 2: Spawning Judge Agents
After all Phase 1 agents complete, spawn 5 judge agents, one per dimension:
```text
You are the $DIMENSION Judge for the LLM Judge evaluation.
**Spec Document:**
$SPEC_CONTENT
**Facts from all repos:**
$ALL_FACTS_JSON
**Instructions:** Read @beagle:llm-judge references/judge-agents.md
Score each repo on $DIMENSION using the rubric in references/scoring-rubrics.md.
Return ONLY valid JSON following the judge output schema.
```
## Aggregation
1. Collect the five judge outputs.
2. Compute each repo's weighted total with the configured weights.
3. Rank repos by weighted total in descending order.
4. Generate a verdict that explains the result and any close calls.
5. Write `.beagle/llm-judge-report.json`.
## Output
Display a markdown summary with scores, ranking, verdict, and detailed justifications.
## Verification
Before completing (maps to **Hard gates** D and E):
1. **Gate D:** `.beagle/llm-judge-report.json` exists and `json.load` succeeds.
2. **Gate E / completeness:** Every repo label has scores for every dimension; each `weighted_total` equals the sum over dimensions of `(score × weight / 100)` using the configured weights; markdown summary matches the JSON report.
## Rules
- Always validate inputs before proceeding
- Spawn Phase 1 agents in parallel, then wait before Phase 2
- Spawn Phase 2 agents in parallel, one per dimension
- Every score must have a justification
- Write the JSON report before displaying the summary
FILE:references/fact-schema.md
# Fact Schema
JSON schema for structured facts gathered by Phase 1 Repo Agents.
## Full Schema
```json
{
"repo_label": "string - Display name for this repo",
"repo_path": "string - Absolute path to repo",
"git_info": {
"branch": "string - Current branch name",
"base": "string - Base branch (usually main)",
"files_changed": "number - Count of changed files",
"additions": "number - Lines added",
"deletions": "number - Lines deleted",
"diff_summary": "string - Brief description of changes"
},
"functionality": {
"spec_requirements": ["array of requirement strings extracted from spec"],
"implemented": ["array of requirements found implemented"],
"missing": ["array of requirements not found"],
"partially_implemented": ["array of requirements with incomplete implementation"],
"test_results": {
"ran": "boolean - Whether tests were executed",
"framework": "string - pytest, jest, go test, etc.",
"passed": "number",
"failed": "number",
"skipped": "number",
"error_summary": "string - Brief description of failures if any"
}
},
"security": {
"findings": [
{
"file": "string - File path",
"line": "number - Line number",
"issue": "string - Description of security issue",
"severity": "high | medium | low",
"category": "string - OWASP category if applicable"
}
],
"patterns_observed": ["array of positive security patterns found"]
},
"tests": {
"test_count": "number - Total test count",
"coverage_estimate": "none | low | moderate | high",
"dry_violations": [
{
"file": "string",
"line": "number",
"description": "string"
}
],
"mocking_approach": "string - Description of mocking strategy",
"test_quality_notes": "string - General observations"
},
"overengineering": {
"abstractions": [
{
"file": "string",
"line": "number",
"issue": "string - Description of over-abstraction"
}
],
"defensive_code": [
{
"file": "string",
"line": "number",
"issue": "string"
}
],
"config_complexity": "low | medium | high"
},
"dead_code": {
"unused_imports": ["array of file:line references"],
"unused_functions": ["array of file:line references"],
"unused_variables": ["array of file:line references"],
"todo_comments": "number - Count of TODO/FIXME",
"commented_code_blocks": "number - Count of commented code"
}
}
```
## Example
```json
{
"repo_label": "Claude",
"repo_path": "/path/to/repo-a",
"git_info": {
"branch": "main",
"base": "main",
"files_changed": 42,
"additions": 1250,
"deletions": 380,
"diff_summary": "Adds auth flow and data export features"
},
"functionality": {
"spec_requirements": ["auth flow", "data export", "rate limiting"],
"implemented": ["auth flow", "data export"],
"missing": ["rate limiting"],
"partially_implemented": [],
"test_results": {
"ran": true,
"framework": "pytest",
"passed": 45,
"failed": 2,
"skipped": 1,
"error_summary": "2 tests fail on edge case validation"
}
},
"security": {
"findings": [
{
"file": "src/api.py",
"line": 42,
"issue": "SQL string concatenation instead of parameterized query",
"severity": "high",
"category": "Injection"
}
],
"patterns_observed": ["input validation present", "no secrets in code", "HTTPS enforced"]
},
"tests": {
"test_count": 48,
"coverage_estimate": "moderate",
"dry_violations": [
{
"file": "tests/test_api.py",
"line": 15,
"description": "Setup code repeated in 5 test functions"
}
],
"mocking_approach": "Mocks at adapter boundary, uses pytest fixtures",
"test_quality_notes": "Good isolation, some DRY issues"
},
"overengineering": {
"abstractions": [
{
"file": "src/factory.py",
"line": 1,
"issue": "Factory pattern for single implementation"
}
],
"defensive_code": [],
"config_complexity": "low"
},
"dead_code": {
"unused_imports": ["src/utils.py:3"],
"unused_functions": [],
"unused_variables": [],
"todo_comments": 2,
"commented_code_blocks": 1
}
}
```
FILE:references/judge-agents.md
# Judge Agent Instructions
Instructions for Phase 2 agents that score implementations on a single dimension.
## Role
You are a scoring judge. You receive facts gathered from ALL repositories and score each one on YOUR specific dimension using the rubrics in [scoring-rubrics.md](scoring-rubrics.md).
## Inputs You Receive
1. **Spec Document**: The original requirements
2. **Facts Array**: JSON facts from all repos (output of Phase 1)
3. **Your Dimension**: One of: functionality, security, tests, overengineering, dead_code
## Your Task
Produce a JSON object with scores and justifications for each repo.
## Output Schema
```json
{
"dimension": "functionality",
"scores": {
"RepoLabel1": {
"score": 4,
"justification": "Clear explanation of why this score was assigned",
"evidence": ["Specific facts that support this score"]
},
"RepoLabel2": {
"score": 5,
"justification": "...",
"evidence": ["..."]
}
},
"ranking": ["RepoLabel2", "RepoLabel1"],
"notes": "Optional comparative notes"
}
```
## Scoring Process
1. Read the rubric for your dimension from [scoring-rubrics.md](scoring-rubrics.md)
2. For each repo's facts:
- Extract the relevant section (e.g., `facts.functionality` for functionality judge)
- Apply the rubric criteria
- Assign a 1-5 score
- Write a clear justification citing specific evidence
3. Rank the repos by score (highest first)
## Dimension-Specific Instructions
### Functionality Judge
Focus on `facts.functionality`:
- Compare `spec_requirements` to `implemented` and `missing`
- Weight test results heavily (`test_results.passed` vs `failed`)
- Consider `partially_implemented` as half credit
### Security Judge
Focus on `facts.security`:
- Count and weight `findings` by severity
- High severity = major deduction
- Positive `patterns_observed` can offset minor issues
### Tests Judge
Focus on `facts.tests`:
- Evaluate `coverage_estimate`
- Count `dry_violations` (more = worse)
- Consider `mocking_approach` quality
- Raw `test_count` relative to codebase size
### Overengineering Judge
Focus on `facts.overengineering`:
- Count `abstractions` issues
- Count `defensive_code` issues
- Consider `config_complexity`
- FEWER issues = HIGHER score (inverse)
### Dead Code Judge
Focus on `facts.dead_code`:
- Sum all unused items
- Weight `unused_functions` > `unused_imports`
- Count `todo_comments` and `commented_code_blocks`
- FEWER issues = HIGHER score (inverse)
## Important Rules
1. **Use the rubric** - Don't invent criteria
2. **Be consistent** - Apply the same standards to all repos
3. **Cite evidence** - Every score needs justification from facts
4. **Be comparative** - Rankings should reflect relative quality
5. **Valid JSON only** - Output must be parseable
FILE:references/repo-agent.md
# Repo Agent Instructions
Instructions for Phase 1 agents that gather facts from a single repository.
## Role
You are a fact-gathering agent. Your job is to explore a repository and extract structured facts WITHOUT making judgments or assigning scores. Scoring happens in Phase 2 by separate judge agents.
## Inputs You Receive
1. **Spec Document**: The requirements/plan that was given to the LLM to implement
2. **Repo Path**: Absolute path to the repository you're analyzing
3. **Repo Label**: Display name for this repo (e.g., "Claude", "GPT-4")
4. **Branch Info**: Which branch to compare (default: current vs main)
## Your Task
Produce a JSON object following the schema in [fact-schema.md](fact-schema.md).
## Step-by-Step Process
### 1. Gather Git Info
```bash
# Get branch name
git -C $REPO_PATH rev-parse --abbrev-ref HEAD
# Get diff stats
git -C $REPO_PATH diff --stat main...HEAD
# Count files changed
git -C $REPO_PATH diff --name-only main...HEAD | wc -l
```
### 2. Analyze Functionality
1. Read the spec document carefully
2. Extract discrete requirements as a list
3. Explore the codebase to determine which requirements are implemented
4. Run tests if available:
```bash
# Detect and run tests
cd $REPO_PATH
# Python
if [ -f pytest.ini ] || [ -f pyproject.toml ] || [ -d tests ]; then
pytest --tb=short 2>&1
fi
# JavaScript/TypeScript
if [ -f package.json ]; then
npm test 2>&1 || yarn test 2>&1
fi
# Go
if [ -f go.mod ]; then
go test ./... 2>&1
fi
```
### 3. Analyze Security
Look for common vulnerabilities:
- SQL injection (string concatenation in queries)
- Command injection (unsanitized shell commands)
- XSS (unsanitized user input in HTML)
- Hardcoded secrets (API keys, passwords)
- Missing input validation
- Insecure deserialization
Also note positive patterns:
- Input validation present
- Parameterized queries
- Authentication checks
- Rate limiting
### 4. Analyze Tests
- Count test files and test functions
- Look for DRY violations (repeated setup code)
- Assess mocking strategy
- Estimate coverage (file count ratio, critical paths tested)
### 5. Analyze Overengineering
Use patterns from `@beagle:llm-artifacts-detection`:
- Unnecessary abstractions (interfaces with single impl)
- Factory patterns for simple objects
- Excessive defensive coding
- Over-configuration
### 6. Analyze Dead Code
- Unused imports (grep for imports, check usage)
- TODO/FIXME comments
- Commented-out code blocks
- Unused functions/variables
## Output Format
Return ONLY the JSON object. No markdown, no explanations. The JSON must be valid and follow [fact-schema.md](fact-schema.md).
## Important Rules
1. **Do not score** - Only gather facts
2. **Be thorough** - Check all changed files
3. **Be specific** - Include file:line references
4. **Be objective** - Report what you find, not opinions
5. **Use the skill** - Load `@beagle:llm-artifacts-detection` for dead code/overengineering
FILE:references/scoring-rubrics.md
# Scoring Rubrics
Detailed rubrics for each of the 5 judging dimensions. Judges use these to assign consistent 1-5 scores.
## General Scoring Scale
| Score | Meaning | General Criteria |
|-------|---------|------------------|
| 5 | Excellent | Exceeds expectations, best practices throughout |
| 4 | Good | Meets all requirements, minor issues only |
| 3 | Average | Functional but notable gaps or issues |
| 2 | Below Average | Significant issues affecting quality |
| 1 | Poor | Fails to meet basic requirements |
---
## Functionality (30% weight)
Evaluates whether the implementation meets the spec requirements and works correctly.
| Score | Criteria |
|-------|----------|
| 5 | All spec requirements implemented. All tests pass. No obvious bugs. |
| 4 | All requirements implemented. Tests pass with minor failures (< 5%). Edge cases may be missing. |
| 3 | Most requirements implemented (> 75%). Some test failures. Core functionality works. |
| 2 | Partial implementation (50-75%). Significant test failures. Core features have bugs. |
| 1 | Minimal implementation (< 50%). Tests fail or don't exist. Core functionality broken. |
**Key Evidence:**
- `functionality.implemented` vs `functionality.spec_requirements`
- `functionality.test_results.passed` vs `functionality.test_results.failed`
- `functionality.missing` and `functionality.partially_implemented`
---
## Security (25% weight)
Evaluates security posture and absence of vulnerabilities.
| Score | Criteria |
|-------|----------|
| 5 | No security findings. Positive security patterns present. OWASP Top 10 addressed. |
| 4 | No high-severity findings. 1-2 low/medium issues. Good security hygiene. |
| 3 | 1-2 medium-severity issues OR 3+ low-severity. Basic security present. |
| 2 | 1+ high-severity issue OR 3+ medium. Security gaps evident. |
| 1 | Multiple high-severity issues. Critical vulnerabilities. No security consideration. |
**Severity Weights:**
- High: SQL injection, command injection, auth bypass, secrets in code
- Medium: XSS, CSRF, insecure deserialization, missing input validation
- Low: Information disclosure, verbose errors, missing security headers
**Key Evidence:**
- `security.findings` (count and severity)
- `security.patterns_observed`
---
## Test Quality (20% weight)
Evaluates test coverage, DRY adherence, and testing practices.
| Score | Criteria |
|-------|----------|
| 5 | High coverage. No DRY violations. Good mock boundaries. Tests are maintainable. |
| 4 | Moderate-high coverage. Minor DRY issues (1-2). Good testing practices. |
| 3 | Moderate coverage. Some DRY violations (3-5). Acceptable mocking. |
| 2 | Low coverage. Significant DRY violations. Poor mock boundaries. |
| 1 | Minimal/no tests. Severe DRY problems. Tests don't follow best practices. |
**Key Evidence:**
- `tests.coverage_estimate`
- `tests.dry_violations` (count)
- `tests.mocking_approach`
- `tests.test_count` relative to codebase size
---
## Overengineering (15% weight)
Evaluates simplicity and absence of unnecessary complexity.
| Score | Criteria |
|-------|----------|
| 5 | Clean, simple code. No unnecessary abstractions. YAGNI followed. |
| 4 | Mostly simple. 1-2 minor over-abstractions. Code is readable. |
| 3 | Some complexity. 3-5 abstraction issues. Config complexity medium. |
| 2 | Significant over-engineering. 6+ abstraction issues. Unnecessary patterns. |
| 1 | Severely over-engineered. Abstractions everywhere. Simple tasks made complex. |
**Key Evidence:**
- `overengineering.abstractions` (count)
- `overengineering.defensive_code` (count)
- `overengineering.config_complexity`
---
## Dead Code (10% weight)
Evaluates cleanliness and absence of unused/obsolete code.
| Score | Criteria |
|-------|----------|
| 5 | No dead code. No TODOs. Clean codebase. |
| 4 | 1-3 minor issues (unused imports). No significant dead code. |
| 3 | 4-6 issues. Some unused functions or TODOs. |
| 2 | 7-10 issues. Unused functions/classes. Multiple TODOs. |
| 1 | 10+ issues. Significant dead code. Many TODOs/commented blocks. |
**Key Evidence:**
- `dead_code.unused_imports` (count)
- `dead_code.unused_functions` (count)
- `dead_code.todo_comments`
- `dead_code.commented_code_blocks`
Use when auditing an agent codebase against the 12-Factor Agents methodology, reviewing LLM-powered system architecture, or assessing agentic app compliance....
---
name: agent-architecture-analysis
description: "Use when auditing an agent codebase against the 12-Factor Agents methodology, reviewing LLM-powered system architecture, or assessing agentic app compliance. Triggers on \"analyze agent architecture\", \"12-factor audit\", \"how compliant is this agent\", or \"evaluate this LLM app\". Also applies when comparing frameworks or planning agent improvements. Not for quick checklists \u2014 this performs deep per-factor codebase analysis with file-level evidence."
---
# 12-Factor Agents Compliance Analysis
> Reference: [12-Factor Agents](https://github.com/humanlayer/12-factor-agents)
## Input Parameters
| Parameter | Description | Required |
|-----------|-------------|----------|
| `docs_path` | Path to documentation directory (for existing analyses) | Optional |
| `codebase_path` | Root path of the codebase to analyze | Required |
## Analysis Framework
### Factor 1: Natural Language to Tool Calls
**Principle:** Convert natural language inputs into structured, deterministic tool calls using schema-validated outputs.
**Search Patterns:**
```bash
# Look for Pydantic schemas
grep -r "class.*BaseModel" --include="*.py"
grep -r "TaskDAG\|TaskResponse\|ToolCall" --include="*.py"
# Look for JSON schema generation
grep -r "model_json_schema\|json_schema" --include="*.py"
# Look for structured output generation
grep -r "output_type\|response_model" --include="*.py"
```
**File Patterns:** `**/agents/*.py`, `**/schemas/*.py`, `**/models/*.py`
**Compliance Criteria:**
| Level | Criteria |
|-------|----------|
| **Strong** | All LLM outputs use Pydantic/dataclass schemas with validators |
| **Partial** | Some outputs typed, but dict returns or unvalidated strings exist |
| **Weak** | LLM returns raw strings parsed manually or with regex |
**Anti-patterns:**
- `json.loads(llm_response)` without schema validation
- `output.split()` or regex parsing of LLM responses
- `dict[str, Any]` return types from agents
- No validation between LLM output and handler execution
---
### Factor 2: Own Your Prompts
**Principle:** Treat prompts as first-class code you control, version, and iterate on.
**Search Patterns:**
```bash
# Look for embedded prompts
grep -r "SYSTEM_PROMPT\|system_prompt" --include="*.py"
grep -r '""".*You are' --include="*.py"
# Look for template systems
grep -r "jinja\|Jinja\|render_template" --include="*.py"
find . -name "*.jinja2" -o -name "*.j2"
# Look for prompt directories
find . -type d -name "prompts"
```
**File Patterns:** `**/prompts/**`, `**/templates/**`, `**/agents/*.py`
**Compliance Criteria:**
| Level | Criteria |
|-------|----------|
| **Strong** | Prompts in separate files, templated (Jinja2), versioned |
| **Partial** | Prompts as module constants, some parameterization |
| **Weak** | Prompts hardcoded inline in functions, f-strings only |
**Anti-patterns:**
- `f"You are a {role}..."` inline in agent methods
- Prompts mixed with business logic
- No way to iterate on prompts without code changes
- No prompt versioning or A/B testing capability
---
### Factor 3: Own Your Context Window
**Principle:** Control how history, state, and tool results are formatted for the LLM.
**Search Patterns:**
```bash
# Look for context/message management
grep -r "AgentMessage\|ChatMessage\|messages" --include="*.py"
grep -r "context_window\|context_compiler" --include="*.py"
# Look for custom serialization
grep -r "to_xml\|to_context\|serialize" --include="*.py"
# Look for token management
grep -r "token_count\|max_tokens\|truncate" --include="*.py"
```
**File Patterns:** `**/context/*.py`, `**/state/*.py`, `**/core/*.py`
**Compliance Criteria:**
| Level | Criteria |
|-------|----------|
| **Strong** | Custom context format, token optimization, typed events, compaction |
| **Partial** | Basic message history with some structure |
| **Weak** | Raw message accumulation, standard OpenAI format only |
**Anti-patterns:**
- Unbounded message accumulation
- Large artifacts embedded inline (diffs, files)
- No agent-specific context filtering
- Same context for all agent types
---
### Factor 4: Tools Are Structured Outputs
**Principle:** Tools produce schema-validated JSON that triggers deterministic code, not magic function calls.
**Search Patterns:**
```bash
# Look for tool/response schemas
grep -r "class.*Response.*BaseModel" --include="*.py"
grep -r "ToolResult\|ToolOutput" --include="*.py"
# Look for deterministic handlers
grep -r "def handle_\|def execute_" --include="*.py"
# Look for validation layer
grep -r "model_validate\|parse_obj" --include="*.py"
```
**File Patterns:** `**/tools/*.py`, `**/handlers/*.py`, `**/agents/*.py`
**Compliance Criteria:**
| Level | Criteria |
|-------|----------|
| **Strong** | All tool outputs schema-validated, handlers type-safe |
| **Partial** | Most tools typed, some loose dict returns |
| **Weak** | Tools return arbitrary dicts, no validation layer |
**Anti-patterns:**
- Tool handlers that directly execute LLM output
- `eval()` or `exec()` on LLM-generated code
- No separation between decision (LLM) and execution (code)
- Magic method dispatch based on string matching
---
### Factor 5: Unify Execution State
**Principle:** Merge execution state (step, retries) with business state (messages, results).
**Search Patterns:**
```bash
# Look for state models
grep -r "ExecutionState\|WorkflowState\|Thread" --include="*.py"
# Look for dual state systems
grep -r "checkpoint\|MemorySaver" --include="*.py"
grep -r "sqlite\|database\|repository" --include="*.py"
# Look for state reconstruction
grep -r "load_state\|restore\|reconstruct" --include="*.py"
```
**File Patterns:** `**/state/*.py`, `**/models/*.py`, `**/database/*.py`
**Compliance Criteria:**
| Level | Criteria |
|-------|----------|
| **Strong** | Single serializable state object with all execution metadata |
| **Partial** | State exists but split across systems (memory + DB) |
| **Weak** | Execution state scattered, requires multiple queries to reconstruct |
**Anti-patterns:**
- Retry count stored separately from task state
- Error history in logs but not in state
- LangGraph checkpoints + separate database storage
- No unified event thread
---
### Factor 6: Launch/Pause/Resume
**Principle:** Agents support simple APIs for launching, pausing at any point, and resuming.
**Search Patterns:**
```bash
# Look for REST endpoints
grep -r "@router.post\|@app.post" --include="*.py"
grep -r "start_workflow\|pause\|resume" --include="*.py"
# Look for interrupt mechanisms
grep -r "interrupt_before\|interrupt_after" --include="*.py"
# Look for webhook handlers
grep -r "webhook\|callback" --include="*.py"
```
**File Patterns:** `**/routes/*.py`, `**/api/*.py`, `**/orchestrator/*.py`
**Compliance Criteria:**
| Level | Criteria |
|-------|----------|
| **Strong** | REST API + webhook resume, pause at any point including mid-tool |
| **Partial** | Launch/pause/resume exists but only at coarse-grained points |
| **Weak** | CLI-only launch, no pause/resume capability |
**Anti-patterns:**
- Blocking `input()` or `confirm()` calls
- No way to resume after process restart
- Approval only at plan level, not per-tool
- No webhook-based resume from external systems
---
### Factor 7: Contact Humans with Tools
**Principle:** Human contact is a tool call with question, options, and urgency.
**Search Patterns:**
```bash
# Look for human input mechanisms
grep -r "typer.confirm\|input(\|prompt(" --include="*.py"
grep -r "request_human_input\|human_contact" --include="*.py"
# Look for approval patterns
grep -r "approval\|approve\|reject" --include="*.py"
# Look for structured question formats
grep -r "question.*options\|HumanInputRequest" --include="*.py"
```
**File Patterns:** `**/agents/*.py`, `**/tools/*.py`, `**/orchestrator/*.py`
**Compliance Criteria:**
| Level | Criteria |
|-------|----------|
| **Strong** | `request_human_input` tool with question/options/urgency/format |
| **Partial** | Approval gates exist but hardcoded in graph structure |
| **Weak** | Blocking CLI prompts, no tool-based human contact |
**Anti-patterns:**
- `typer.confirm()` in agent code
- Human contact hardcoded at specific graph nodes
- No way for agents to ask clarifying questions
- Single response format (yes/no only)
---
### Factor 8: Own Your Control Flow
**Principle:** Custom control flow, not framework defaults. Full control over routing, retries, compaction.
**Search Patterns:**
```bash
# Look for routing logic
grep -r "add_conditional_edges\|route_\|should_continue" --include="*.py"
# Look for custom loops
grep -r "while True\|for.*in.*range" --include="*.py" | grep -v test
# Look for execution mode control
grep -r "execution_mode\|agentic\|structured" --include="*.py"
```
**File Patterns:** `**/orchestrator/*.py`, `**/graph/*.py`, `**/core/*.py`
**Compliance Criteria:**
| Level | Criteria |
|-------|----------|
| **Strong** | Custom routing functions, conditional edges, execution mode control |
| **Partial** | Framework control flow with some customization |
| **Weak** | Default framework loop with no custom routing |
**Anti-patterns:**
- Single path through graph with no branching
- No distinction between tool types (all treated same)
- Framework-default error handling only
- No rate limiting or resource management
---
### Factor 9: Compact Errors into Context
**Principle:** Errors in context enable self-healing. Track consecutive errors, escalate after threshold.
**Search Patterns:**
```bash
# Look for error handling
grep -r "except.*Exception\|error_history\|consecutive_errors" --include="*.py"
# Look for retry logic
grep -r "retry\|backoff\|max_attempts" --include="*.py"
# Look for escalation
grep -r "escalate\|human_escalation" --include="*.py"
```
**File Patterns:** `**/agents/*.py`, `**/orchestrator/*.py`, `**/core/*.py`
**Compliance Criteria:**
| Level | Criteria |
|-------|----------|
| **Strong** | Errors in context, retry with threshold, automatic escalation |
| **Partial** | Errors logged and returned, no automatic retry loop |
| **Weak** | Errors logged only, not fed back to LLM, task fails immediately |
**Anti-patterns:**
- `logger.error()` without adding to context
- No retry mechanism (fail immediately)
- No consecutive error tracking
- No escalation to humans after repeated failures
---
### Factor 10: Small, Focused Agents
**Principle:** Each agent has narrow responsibility, 3-10 steps max.
**Search Patterns:**
```bash
# Look for agent classes
grep -r "class.*Agent\|class.*Architect\|class.*Developer" --include="*.py"
# Look for step definitions
grep -r "steps\|tasks" --include="*.py" | head -20
# Count methods per agent
grep -r "async def\|def " agents/*.py 2>/dev/null | wc -l
```
**File Patterns:** `**/agents/*.py`
**Compliance Criteria:**
| Level | Criteria |
|-------|----------|
| **Strong** | 3+ specialized agents, each with single responsibility, step limits |
| **Partial** | Multiple agents but some have broad scope |
| **Weak** | Single "god" agent that handles everything |
**Anti-patterns:**
- Single agent with 20+ tools
- Agent with unbounded step count
- Mixed responsibilities (planning + execution + review)
- No step or time limits on agent execution
---
### Factor 11: Trigger from Anywhere
**Principle:** Workflows triggerable from CLI, REST, WebSocket, Slack, webhooks, etc.
**Search Patterns:**
```bash
# Look for entry points
grep -r "@cli.command\|@router.post\|@app.post" --include="*.py"
# Look for WebSocket support
grep -r "WebSocket\|websocket" --include="*.py"
# Look for external integrations
grep -r "slack\|discord\|webhook" --include="*.py" -i
```
**File Patterns:** `**/routes/*.py`, `**/cli/*.py`, `**/main.py`
**Compliance Criteria:**
| Level | Criteria |
|-------|----------|
| **Strong** | CLI + REST + WebSocket + webhooks + chat integrations |
| **Partial** | CLI + REST API available |
| **Weak** | CLI only, no programmatic access |
**Anti-patterns:**
- Only `if __name__ == "__main__"` entry point
- No REST API for external systems
- No event streaming for real-time updates
- Trigger logic tightly coupled to execution
---
### Factor 12: Stateless Reducer
**Principle:** Agents as pure functions: (state, input) -> (state, output). No side effects in agent logic.
**Search Patterns:**
```bash
# Look for state mutation patterns
grep -r "\.status = \|\.field = " --include="*.py"
# Look for immutable updates
grep -r "model_copy\|\.copy(\|with_" --include="*.py"
# Look for side effects in agents
grep -r "write_file\|subprocess\|requests\." agents/*.py 2>/dev/null
```
**File Patterns:** `**/agents/*.py`, `**/nodes/*.py`
**Compliance Criteria:**
| Level | Criteria |
|-------|----------|
| **Strong** | Immutable state updates, side effects isolated to tools/handlers |
| **Partial** | Mostly immutable, some in-place mutations |
| **Weak** | State mutated in place, side effects mixed with agent logic |
**Anti-patterns:**
- `state.field = new_value` (mutation)
- File writes inside agent methods
- HTTP calls inside agent decision logic
- Shared mutable state between agents
---
### Factor 13: Pre-fetch Context
**Principle:** Fetch likely-needed data upfront rather than mid-workflow.
**Search Patterns:**
```bash
# Look for context pre-fetching
grep -r "pre_fetch\|prefetch\|fetch_context" --include="*.py"
# Look for RAG/embedding systems
grep -r "embedding\|vector\|semantic_search" --include="*.py"
# Look for related file discovery
grep -r "related_tests\|similar_\|find_relevant" --include="*.py"
```
**File Patterns:** `**/context/*.py`, `**/retrieval/*.py`, `**/rag/*.py`
**Compliance Criteria:**
| Level | Criteria |
|-------|----------|
| **Strong** | Automatic pre-fetch of related tests, files, docs before planning |
| **Partial** | Manual context passing, design doc support |
| **Weak** | No pre-fetching, LLM must request all context via tools |
**Anti-patterns:**
- Architect starts with issue only, no codebase context
- No semantic search for similar past work
- Related tests/files discovered only during execution
- No RAG or document retrieval system
---
## Output Format
**Gate order:** Do not assign Strong / Partial / Weak or treat recommendations as observed facts until **Hard gates** (after [Analysis Workflow](#analysis-workflow)) are satisfied for the factors in scope.
### Executive Summary Table
```markdown
| Factor | Status | Notes |
|--------|--------|-------|
| 1. Natural Language -> Tool Calls | **Strong/Partial/Weak** | [Key finding] |
| 2. Own Your Prompts | **Strong/Partial/Weak** | [Key finding] |
| ... | ... | ... |
| 13. Pre-fetch Context | **Strong/Partial/Weak** | [Key finding] |
**Overall**: X Strong, Y Partial, Z Weak
```
### Per-Factor Analysis
For each factor, provide:
1. **Current Implementation**
- Evidence with file:line references
- Code snippets showing patterns
2. **Compliance Level**
- Strong/Partial/Weak with justification
3. **Gaps**
- What's missing vs. 12-Factor ideal
4. **Recommendations**
- Actionable improvements with code examples
---
## Analysis Workflow
1. **Initial Scan**
- Run search patterns for all factors
- Identify key files for each factor
- Note any existing compliance documentation
2. **Deep Dive** (per factor)
- Read identified files
- Evaluate against compliance criteria
- Document evidence with file paths
3. **Gap Analysis**
- Compare current vs. 12-Factor ideal
- Identify anti-patterns present
- Prioritize by impact
4. **Recommendations**
- Provide actionable improvements
- Include before/after code examples
- Reference roadmap if exists
5. **Summary**
- Compile executive summary table
- Highlight strengths and critical gaps
- Suggest priority order for improvements
---
## Hard gates (evidence before scores)
Run these in order. Do not skip ahead: each **Pass** is an objective condition you can check (paths on disk, citations present), not internal certainty.
1. **Scan gate** — After the initial scan (workflow step 1), **Pass:** for every factor (1–13) you have either (a) ≥1 repo-relative path or glob hit to inspect, or (b) a one-line note with rationale (e.g. search command/output, or “no matches — codebase may omit this concern”). Empty hand-waving (“looks fine”) fails this gate.
2. **Evidence gate (per factor)** — Before writing Strong / Partial / Weak for that factor, **Pass:** “Current Implementation” includes ≥1 citation with **file path** plus **line range or short quoted snippet** from `codebase_path`, or an explicit **no evidence located** statement after targeted reads. If evidence is missing after search, default that factor to **Weak** unless the criterion is clearly N/A (say why).
3. **Synthesis gate** — Executive summary table and per-factor analysis sections, **Pass:** only after gates 1–2 are satisfied for the factors in scope. Recommendations may name new files or patterns only as proposals; they must not be presented as observed facts without matching citations from step 2.
---
## Quick Reference: Compliance Scoring
| Score | Meaning | Action |
|-------|---------|--------|
| **Strong** | Fully implements principle | Maintain, minor optimizations |
| **Partial** | Some implementation, significant gaps | Planned improvements |
| **Weak** | Minimal or no implementation | High priority for roadmap |
## When to Use This Skill
- Evaluating new LLM-powered systems
- Reviewing agent architecture decisions
- Auditing production agentic applications
- Planning improvements to existing agents
- Comparing frameworks or implementations