swift-code-reviewer-skill 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +76 -444
- package/SKILL.md +97 -7
- package/package.json +1 -1
- package/references/architecture-patterns.md +275 -0
- package/references/performance-review.md +193 -0
- package/references/review-workflow.md +121 -0
- package/references/swiftui-review-checklist.md +738 -0
|
@@ -493,6 +493,86 @@ struct WaterfallLayout: Layout {
|
|
|
493
493
|
}
|
|
494
494
|
```
|
|
495
495
|
|
|
496
|
+
### 3.5 Scroll Container Selection
|
|
497
|
+
|
|
498
|
+
**Check for:**
|
|
499
|
+
- [ ] Correct scroll container chosen for the use case
|
|
500
|
+
- [ ] No nested scroll views on the same axis
|
|
501
|
+
- [ ] `List` used for system-style rows with swipe actions; `LazyVStack` for custom layouts
|
|
502
|
+
- [ ] Plain `VStack` only for small, fixed-count content
|
|
503
|
+
|
|
504
|
+
**Decision Table:**
|
|
505
|
+
|
|
506
|
+
| Use Case | Container | Why |
|
|
507
|
+
|----------|-----------|-----|
|
|
508
|
+
| System-style rows, swipe actions, sections | `List` | Built-in lazy loading, cell reuse |
|
|
509
|
+
| Custom row layout, >20 items | `ScrollView + LazyVStack` | Lazy + full layout control |
|
|
510
|
+
| Grid of items | `LazyVGrid` | 2D lazy loading |
|
|
511
|
+
| Small static list (<10 items) | `VStack` | Simplest, no lazy overhead |
|
|
512
|
+
| Horizontal scrolling carousel | `ScrollView(.horizontal) + LazyHStack` | Horizontal lazy |
|
|
513
|
+
|
|
514
|
+
**Examples:**
|
|
515
|
+
|
|
516
|
+
❌ **Bad: VStack for large dynamic list**
|
|
517
|
+
```swift
|
|
518
|
+
ScrollView {
|
|
519
|
+
VStack { // ❌ All views created immediately — bad for 100+ items
|
|
520
|
+
ForEach(posts) { post in
|
|
521
|
+
PostRow(post: post)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
❌ **Bad: Nested ScrollViews on same axis**
|
|
528
|
+
```swift
|
|
529
|
+
ScrollView(.vertical) {
|
|
530
|
+
LazyVStack {
|
|
531
|
+
ForEach(sections) { section in
|
|
532
|
+
ScrollView(.vertical) { // ❌ Nested vertical scroll — broken UX + perf
|
|
533
|
+
ForEach(section.items) { item in
|
|
534
|
+
ItemRow(item: item)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
✅ **Good: List for system-style content**
|
|
543
|
+
```swift
|
|
544
|
+
List(posts) { post in // ✅ Lazy by default, swipe actions, separators
|
|
545
|
+
PostRow(post: post)
|
|
546
|
+
.swipeActions { Button("Delete", role: .destructive) { delete(post) } }
|
|
547
|
+
}
|
|
548
|
+
.listStyle(.plain)
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
✅ **Good: LazyVStack for custom layout feeds**
|
|
552
|
+
```swift
|
|
553
|
+
ScrollView {
|
|
554
|
+
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { // ✅ Lazy + custom layout
|
|
555
|
+
ForEach(posts) { post in
|
|
556
|
+
PostCardView(post: post)
|
|
557
|
+
.padding(.horizontal)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
✅ **Good: LazyVGrid for photo grid**
|
|
564
|
+
```swift
|
|
565
|
+
let columns = [GridItem(.adaptive(minimum: 100))]
|
|
566
|
+
|
|
567
|
+
ScrollView {
|
|
568
|
+
LazyVGrid(columns: columns, spacing: 2) { // ✅ 2D lazy grid
|
|
569
|
+
ForEach(photos) { photo in
|
|
570
|
+
PhotoThumbnail(photo: photo)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
496
576
|
---
|
|
497
577
|
|
|
498
578
|
## 4. Image Performance
|
|
@@ -859,6 +939,115 @@ VStack {
|
|
|
859
939
|
|
|
860
940
|
---
|
|
861
941
|
|
|
942
|
+
## 8. Narrow Observation Scope
|
|
943
|
+
|
|
944
|
+
### 8.1 Read Only What You Display
|
|
945
|
+
|
|
946
|
+
**Check for:**
|
|
947
|
+
- [ ] Views read only the properties they actually display (not entire `@Observable` objects passed unnecessarily)
|
|
948
|
+
- [ ] Subviews receive minimal data needed (IDs or value types when possible, not full observable models)
|
|
949
|
+
- [ ] `@Observable` objects not passed to descendants that don't observe them
|
|
950
|
+
|
|
951
|
+
**Examples:**
|
|
952
|
+
|
|
953
|
+
❌ **Bad: Passing entire observable to unrelated subview**
|
|
954
|
+
```swift
|
|
955
|
+
@Observable
|
|
956
|
+
final class FeedViewModel {
|
|
957
|
+
var posts: [Post] = []
|
|
958
|
+
var isLoading: Bool = false
|
|
959
|
+
var currentUser: User = .placeholder
|
|
960
|
+
var unreadCount: Int = 0
|
|
961
|
+
// ... many more properties
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
struct FeedView: View {
|
|
965
|
+
let viewModel: FeedViewModel
|
|
966
|
+
|
|
967
|
+
var body: some View {
|
|
968
|
+
List(viewModel.posts) { post in
|
|
969
|
+
PostRow(viewModel: viewModel, post: post) // ❌ Passes entire ViewModel
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
struct PostRow: View {
|
|
975
|
+
let viewModel: FeedViewModel // ❌ Observes ALL viewModel properties, re-renders on any change
|
|
976
|
+
let post: Post
|
|
977
|
+
|
|
978
|
+
var body: some View {
|
|
979
|
+
Text(post.title) // Only uses post, but re-renders on isLoading, unreadCount changes
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
✅ **Good: Pass only what the subview needs**
|
|
985
|
+
```swift
|
|
986
|
+
struct FeedView: View {
|
|
987
|
+
let viewModel: FeedViewModel
|
|
988
|
+
|
|
989
|
+
var body: some View {
|
|
990
|
+
List(viewModel.posts) { post in
|
|
991
|
+
PostRow(post: post) // ✅ Pass only the value, not the entire observable
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
struct PostRow: View {
|
|
997
|
+
let post: Post // ✅ Only observes/depends on this post
|
|
998
|
+
|
|
999
|
+
var body: some View {
|
|
1000
|
+
Text(post.title)
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
### 8.2 Lazy Containers for Feeds
|
|
1006
|
+
|
|
1007
|
+
**Check for:**
|
|
1008
|
+
- [ ] Feeds with >20 items use lazy containers (`List`, `LazyVStack`, `LazyVGrid`)
|
|
1009
|
+
- [ ] Row views are lightweight (no heavy init, no async work in init)
|
|
1010
|
+
- [ ] `.task` or `.onAppear` on rows used for per-row async loading (e.g., avatar images)
|
|
1011
|
+
|
|
1012
|
+
**Examples:**
|
|
1013
|
+
|
|
1014
|
+
❌ **Bad: Eager loading all rows**
|
|
1015
|
+
```swift
|
|
1016
|
+
ScrollView {
|
|
1017
|
+
VStack { // ❌ Instantiates all PostRow views immediately
|
|
1018
|
+
ForEach(viewModel.posts) { post in
|
|
1019
|
+
PostRow(post: post)
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
✅ **Good: Lazy container with lightweight rows**
|
|
1026
|
+
```swift
|
|
1027
|
+
List(viewModel.posts) { post in // ✅ Lazy — only creates visible rows
|
|
1028
|
+
PostRow(post: post)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
struct PostRow: View {
|
|
1032
|
+
let post: Post
|
|
1033
|
+
@State private var avatar: Image?
|
|
1034
|
+
|
|
1035
|
+
var body: some View {
|
|
1036
|
+
HStack {
|
|
1037
|
+
(avatar ?? Image(systemName: "person.circle"))
|
|
1038
|
+
.resizable()
|
|
1039
|
+
.frame(width: 40, height: 40)
|
|
1040
|
+
Text(post.content)
|
|
1041
|
+
}
|
|
1042
|
+
.task { // ✅ Load avatar lazily only when row appears
|
|
1043
|
+
avatar = await loadAvatar(url: post.avatarURL)
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
---
|
|
1050
|
+
|
|
862
1051
|
## Quick Performance Checklist
|
|
863
1052
|
|
|
864
1053
|
### Critical (Fix Immediately)
|
|
@@ -873,6 +1062,8 @@ VStack {
|
|
|
873
1062
|
- [ ] AsyncImage for remote images
|
|
874
1063
|
- [ ] LazyVStack/LazyHStack for large lists
|
|
875
1064
|
- [ ] Minimal GeometryReader usage
|
|
1065
|
+
- [ ] Correct scroll container selected (List vs LazyVStack vs LazyVGrid vs VStack)
|
|
1066
|
+
- [ ] No nested scroll views on the same axis
|
|
876
1067
|
|
|
877
1068
|
### Medium Priority
|
|
878
1069
|
- [ ] Computed properties for derived data
|
|
@@ -880,6 +1071,8 @@ VStack {
|
|
|
880
1071
|
- [ ] Proper task cancellation
|
|
881
1072
|
- [ ] Efficient image sizing
|
|
882
1073
|
- [ ] Resource cleanup in deinit
|
|
1074
|
+
- [ ] Narrow observation scope (subviews receive only needed data, not full @Observable)
|
|
1075
|
+
- [ ] @Observable not propagated to unrelated descendants
|
|
883
1076
|
|
|
884
1077
|
### Low Priority
|
|
885
1078
|
- [ ] Animation performance optimization
|
|
@@ -521,6 +521,127 @@ Button("Log In") {
|
|
|
521
521
|
- [ ] Keyboard navigation support
|
|
522
522
|
- [ ] VoiceOver tested (if critical UI)
|
|
523
523
|
|
|
524
|
+
### Step 2.2B: Navigation & UI Architecture Check
|
|
525
|
+
|
|
526
|
+
**Reference**: `swiftui-ui-patterns` skill, `references/swiftui-review-checklist.md` (Sections 8–14)
|
|
527
|
+
|
|
528
|
+
Apply this step when reviewing views that contain navigation, sheets, TabView, or async state loading.
|
|
529
|
+
|
|
530
|
+
#### Route Enum & RouterPath
|
|
531
|
+
```swift
|
|
532
|
+
// ✅ Good: Typed route enum + RouterPath
|
|
533
|
+
enum AppRoute: Hashable {
|
|
534
|
+
case userDetail(userID: UUID)
|
|
535
|
+
case settings
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
@Observable final class RouterPath {
|
|
539
|
+
var path: [AppRoute] = []
|
|
540
|
+
func navigate(to route: AppRoute) { path.append(route) }
|
|
541
|
+
}
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
**Checks:**
|
|
545
|
+
- [ ] Route destinations defined as typed `Hashable` enum (not String/Int raw values)
|
|
546
|
+
- [ ] `RouterPath` `@Observable` owns the navigation path (not ad-hoc `@State var path`)
|
|
547
|
+
- [ ] Single `.navigationDestination(for: Route.self)` per `NavigationStack` (in root view, not child views)
|
|
548
|
+
|
|
549
|
+
#### Sheet Routing
|
|
550
|
+
```swift
|
|
551
|
+
// ✅ Good: Item-driven + SheetDestination enum
|
|
552
|
+
enum SheetDestination: Identifiable { case compose; case profile(UUID); var id: String { ... } }
|
|
553
|
+
|
|
554
|
+
@State private var sheetDestination: SheetDestination?
|
|
555
|
+
.sheet(item: $sheetDestination) { destination in
|
|
556
|
+
switch destination { case .compose: ...; case .profile(let id): ... }
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
**Checks:**
|
|
561
|
+
- [ ] `.sheet(item:)` preferred over `.sheet(isPresented:)` when a model is selected
|
|
562
|
+
- [ ] Multiple sheets use a single `SheetDestination` `Identifiable` enum (not multiple `@State Bool`)
|
|
563
|
+
- [ ] No multiple `.sheet(isPresented:)` modifiers on the same view for different destinations
|
|
564
|
+
|
|
565
|
+
#### TabView Architecture
|
|
566
|
+
```swift
|
|
567
|
+
// ✅ Good: Independent RouterPath per tab
|
|
568
|
+
@Observable final class AppTabRouter {
|
|
569
|
+
var homeRouter = RouterPath()
|
|
570
|
+
var searchRouter = RouterPath()
|
|
571
|
+
func router(for tab: AppTab) -> RouterPath { ... }
|
|
572
|
+
}
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
**Checks:**
|
|
576
|
+
- [ ] Each tab has its own `RouterPath` (not a single shared `NavigationPath`)
|
|
577
|
+
- [ ] Tab switching preserves navigation history in each tab
|
|
578
|
+
- [ ] Action tabs (compose, post) handled via side effect (modal), not actual tab destination
|
|
579
|
+
|
|
580
|
+
#### Deep Link Handling
|
|
581
|
+
```swift
|
|
582
|
+
// ✅ Good: Centralized at root
|
|
583
|
+
.onOpenURL { url in router.handle(url: url) } // In root view only
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
**Checks:**
|
|
587
|
+
- [ ] `.onOpenURL` applied at app root (not in feature views)
|
|
588
|
+
- [ ] URL parsing handled in router/coordinator, not in views
|
|
589
|
+
- [ ] Deep link routes to the correct tab router when per-tab navigation is used
|
|
590
|
+
|
|
591
|
+
#### Theming
|
|
592
|
+
```swift
|
|
593
|
+
// ✅ Good: Semantic colors via Theme
|
|
594
|
+
@Environment(Theme.self) private var theme
|
|
595
|
+
Text(title).foregroundStyle(theme.labelPrimary)
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
**Checks:**
|
|
599
|
+
- [ ] No raw `Color.blue`, `Color.white`, `Color.red` when a project `Theme` object exists
|
|
600
|
+
- [ ] Colors accessed via `@Environment(Theme.self)` or equivalent design token
|
|
601
|
+
- [ ] `Theme` provided at app root via `.environment(theme)`
|
|
602
|
+
|
|
603
|
+
#### Async State Patterns
|
|
604
|
+
```swift
|
|
605
|
+
// ✅ Good: .task(id:) with debounce + CancellationError silenced
|
|
606
|
+
.task(id: query) {
|
|
607
|
+
do {
|
|
608
|
+
try await Task.sleep(for: .milliseconds(300)) // Debounce
|
|
609
|
+
results = try await search(query)
|
|
610
|
+
} catch is CancellationError { } // Silenced
|
|
611
|
+
catch { /* real error */ }
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ✅ Good: LoadState enum
|
|
615
|
+
enum LoadState<T> { case idle, loading, loaded(T), error(Error) }
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
**Checks:**
|
|
619
|
+
- [ ] `.task(id:)` used for input-driven async work (not `.onChange + Task { }`)
|
|
620
|
+
- [ ] `CancellationError` silenced when using `.task(id:)` (not shown to user)
|
|
621
|
+
- [ ] `LoadState<T>` enum used instead of multiple `isLoading`/`hasError` booleans
|
|
622
|
+
|
|
623
|
+
#### Focus and Input
|
|
624
|
+
```swift
|
|
625
|
+
// ✅ Good: FocusField enum with chaining
|
|
626
|
+
enum FocusField { case email, password }
|
|
627
|
+
@FocusState private var focusedField: FocusField?
|
|
628
|
+
|
|
629
|
+
TextField("Email", text: $email)
|
|
630
|
+
.focused($focusedField, equals: .email)
|
|
631
|
+
.onSubmit { focusedField = .password } // Chain to next
|
|
632
|
+
|
|
633
|
+
SecureField("Password", text: $password)
|
|
634
|
+
.focused($focusedField, equals: .password)
|
|
635
|
+
.onSubmit { submitForm() } // Last field submits
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
**Checks:**
|
|
639
|
+
- [ ] `FocusField` enum used with `@FocusState` (not multiple `@FocusState private var isFocused: Bool`)
|
|
640
|
+
- [ ] `.onSubmit` advances focus to the next field in sequence
|
|
641
|
+
- [ ] Last field's `.onSubmit` triggers the primary action (login, search, etc.)
|
|
642
|
+
|
|
643
|
+
---
|
|
644
|
+
|
|
524
645
|
### Step 2.3: Performance Check
|
|
525
646
|
|
|
526
647
|
**Reference**: `swiftui-performance-audit`
|