@mmerterden/multi-agent-pipeline 13.1.0 → 13.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +257 -0
- package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
- package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
- package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +258 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
- package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +78 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/STANDARD.md +445 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/lint-local.sh +160 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +1163 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/swiftlint.draft.yml +371 -0
- package/pipeline/skills/shared/external/ios-simulator/SKILL.md +1 -1
- package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +1 -1
- package/pipeline/skills/shared/external/swiftlint/SKILL.md +1 -1
|
@@ -31,8 +31,8 @@ Run all steps automatically:
|
|
|
31
31
|
|
|
32
32
|
```
|
|
33
33
|
Step 1: DETECT Compare timestamps, find stale targets
|
|
34
|
-
Step 2: COPILOT Claude Code -> Copilot CLI (instructions +
|
|
35
|
-
Step 2b: CODEX Claude Code -> Codex CLI (1 router skill +
|
|
34
|
+
Step 2: COPILOT Claude Code -> Copilot CLI (instructions + 44 sub-command skills)
|
|
35
|
+
Step 2b: CODEX Claude Code -> Codex CLI (1 router skill + 44 specs as refs + 8 agent TOML)
|
|
36
36
|
Step 3: REPO Claude Code -> pipeline repo (genericized, personal data scrub)
|
|
37
37
|
Step 3d: DEV-TOOLKIT Companion MCP server -> detect movement, ship gates, commit + publish
|
|
38
38
|
Step 4: WEBSITE Version + phase/model counts -> {website-host} (i18n + projects.ts)
|
|
@@ -223,12 +223,12 @@ When invoked with the `release` argument:
|
|
|
223
223
|
|-------------|-------------|
|
|
224
224
|
| `~/.claude/commands/multi-agent/{cmd}.md` | `~/.copilot/skills/multi-agent-{cmd}/SKILL.md` |
|
|
225
225
|
|
|
226
|
-
**
|
|
226
|
+
**44 commands are synced** (canonical inventory - must match `cross-cli-contract.md` section 1; drift = contract violation):
|
|
227
227
|
|
|
228
228
|
```
|
|
229
229
|
analysis, analysis-resolve, autopilot, build-optimize, channels, create-jira, design-check, dev,
|
|
230
230
|
dev-autopilot, dev-local, dev-local-autopilot, diff-explain, finish, forget, garbage-collect,
|
|
231
|
-
help, issue, jira, kill, language, local,
|
|
231
|
+
help, ios-coding-standard, issue, jira, kill, language, local,
|
|
232
232
|
local-autopilot, log, manual-test, prune-logs, purge, refactor, resume, review, review-issue, review-jira,
|
|
233
233
|
routines, save, scan, search, setup, stack, status, sync, test, testflight-validation, uninstall, update
|
|
234
234
|
```
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ios-coding-standard
|
|
3
|
+
description: "The iOS coding-standard rule registry: 95 stable-ID rules across readability, security, service layer, concurrency, testing, module boundaries, naming and visibility, each with a severity and enforcement kind. Use when writing or reviewing Swift and you need the project rule rather than an opinion, or on a persistence or logging question."
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# iOS coding standard
|
|
8
|
+
|
|
9
|
+
The registry is `references/rules.yml`. It is the source of truth, and it is
|
|
10
|
+
the reason to load this skill: a rule cited by ID is checkable and a remembered rule
|
|
11
|
+
is not.
|
|
12
|
+
|
|
13
|
+
| File | What it is | When to read it |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `references/rules.yml` | 95 rules with stable IDs, severity, enforcement kind and a `check` describing what counts as a violation | before asserting that something is or is not a violation |
|
|
16
|
+
| `references/STANDARD.md` | the same rules taught with before/after Swift, for a human | when you need the reasoning or an example, not just the rule |
|
|
17
|
+
| `references/swiftlint.draft.yml` | the mechanically-enforceable subset as a SwiftLint config | when wiring lint into a project |
|
|
18
|
+
| `references/lint-local.sh` | runs that config over one module, with a baseline mode | when grandfathering existing violations so only new ones surface |
|
|
19
|
+
|
|
20
|
+
## How to use it
|
|
21
|
+
|
|
22
|
+
1. **Cite, do not paraphrase.** Reference `SEC-04`, `READ-01`, `SVC-02`. A finding
|
|
23
|
+
without an ID is an opinion, and the author cannot look it up to disagree.
|
|
24
|
+
2. **A rule not in the registry is not a rule.** If a review wants one, propose it
|
|
25
|
+
as `status: proposed` with a rationale rather than enforcing it silently.
|
|
26
|
+
3. **Severity decides whether it blocks.** `blocking` stops the change,
|
|
27
|
+
`important` is fixed in the same pass, `suggestion` is optional. Do not promote a
|
|
28
|
+
suggestion to blocking because it happens to bother you.
|
|
29
|
+
4. **Enforcement kind decides who checks it.** `format` belongs to the formatter,
|
|
30
|
+
`lint` to the linter, `scan` to a tool, `judgement` to a person or an audit run.
|
|
31
|
+
Reviewing a `format` rule by hand wastes the review.
|
|
32
|
+
5. **Exceptions are marked in code, with an expiry:**
|
|
33
|
+
`// standard:exception(<RULE-ID>) <reason> <expiry:YYYY-MM-DD>`. An unmarked
|
|
34
|
+
deviation is a finding; a marked one is a decision.
|
|
35
|
+
|
|
36
|
+
## Two decisions the registry answers, and code usually gets wrong
|
|
37
|
+
|
|
38
|
+
**Persistence.** Read `references/rules.yml → persistence_decision` before reaching for
|
|
39
|
+
storage. The ladder starts at "does this value need to outlive the current flow?"
|
|
40
|
+
with the default answer *no*. The Keychain answers "where does a persisted secret
|
|
41
|
+
live", not "this value is sensitive" - most sensitive values in a flow never need to
|
|
42
|
+
persist, and persisting them is the more expensive mistake, because the item
|
|
43
|
+
outlives the flow and the cleanup belongs to nobody.
|
|
44
|
+
|
|
45
|
+
**What a mapper may do.** A mapper lowers one shape onto another. Unwrapping an
|
|
46
|
+
optional wire field to an empty value is lowering. Arithmetic and unit conversion, a
|
|
47
|
+
clamp or threshold, choosing a screen state, and policy defaults are **not** - each
|
|
48
|
+
belongs to the view model, or to a named domain rule when several screens share it.
|
|
49
|
+
Carry the wire value with its unit in the name and convert where it is read.
|
|
50
|
+
|
|
51
|
+
## Rule families
|
|
52
|
+
|
|
53
|
+
`READ` readability and section structure · `SEC` secrets, logging, storage ·
|
|
54
|
+
`SVC` service layer and mapping · `TEST` testability seams · `MOD` module
|
|
55
|
+
boundaries and imports · `NAME` naming · `FLEX` flexibility and extension points ·
|
|
56
|
+
`CONC` concurrency · `VIS` visibility and access level · `UI` view construction ·
|
|
57
|
+
`PERF` performance · `DEPR` deprecation and retirement.
|
|
58
|
+
|
|
59
|
+
## Scope note
|
|
60
|
+
|
|
61
|
+
The registry is project-configurable, not project-specific: rule bodies describe
|
|
62
|
+
shapes (a mapper doing arithmetic, a logger interpolating a token) rather than named
|
|
63
|
+
modules. A project adds its own vocabulary through a `modules/<Module>.yml` overlay
|
|
64
|
+
beside `references/rules.yml` - the module's terms, its allowed dependencies, its validation
|
|
65
|
+
rules - which the audit binds on top of the shared registry. Nothing in the shared
|
|
66
|
+
registry names a module, so it applies unchanged to any SwiftUI codebase.
|
|
67
|
+
|
|
68
|
+
`references/swiftlint.draft.yml` carries one placeholder worth changing per project: the remote
|
|
69
|
+
image-host allowlist is set to `example.com`. Point it at your own CDN, or the rule
|
|
70
|
+
flags every remote image.
|
|
71
|
+
|
|
72
|
+
## Running a full audit
|
|
73
|
+
|
|
74
|
+
For a module-wide pass with a plan and a handoff, the pipeline ships
|
|
75
|
+
`/multi-agent:ios-coding-standard`, which builds the module registry, binds the
|
|
76
|
+
overlay, applies the surviving rules and produces a remediation plan. This skill is
|
|
77
|
+
the registry that audit reads; use the skill directly when you are writing or
|
|
78
|
+
reviewing code rather than auditing a whole module.
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
# iOS Coding Standard
|
|
2
|
+
|
|
3
|
+
The one document to read before your first PR. Rule IDs in brackets point at `rules.yml`, which
|
|
4
|
+
carries severity, tooling and rationale for each; `EXAMPLES.md` carries a worked ✗/✓ pair for
|
|
5
|
+
every rule a tool cannot decide — look one up by ID when a review cites it. Your module's own docs
|
|
6
|
+
win where they differ — this is the floor, not the ceiling.
|
|
7
|
+
|
|
8
|
+
Five principles, in the order they break things: **security · testability · readability ·
|
|
9
|
+
flexibility · consistency.**
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 0. The ten lines
|
|
14
|
+
|
|
15
|
+
1. Sensitive data goes in the Keychain, never in `UserDefaults`, and never into a log. `[SEC-01, SEC-03]`
|
|
16
|
+
2. Never reach for the environment — inject time, storage, randomness, session. `[TEST-01]`
|
|
17
|
+
3. One type per file; the file is named after it. `[STRUCT-01]`
|
|
18
|
+
4. A screen is a known set of files, always the same set. `[STRUCT-02]`
|
|
19
|
+
5. Where a type lives is decided by how many things use it. `[STRUCT-05]`
|
|
20
|
+
6. A feature module never imports another feature module. `[MOD-01]`
|
|
21
|
+
7. Everything is `private` and `final` until something forces otherwise. `[VIS-01, VIS-02]`
|
|
22
|
+
8. One request in, one result out — `async`, no completion handlers, no `throws`. `[SVC-01]`
|
|
23
|
+
9. Split concerns with `// MARK:` — business rules, service calls and UI never share a section. `[READ-01]`
|
|
24
|
+
10. Variants are configuration, not `if` trees. `[FLEX-02]`
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 1. Security
|
|
29
|
+
|
|
30
|
+
Work out which **data class** a value belongs to before you decide where it goes.
|
|
31
|
+
`rules.yml → sensitive_data_classes` is the list: auth token, credential, government ID, travel
|
|
32
|
+
document, booking reference, membership identity, payment instrument, personal contact,
|
|
33
|
+
biometric/health, precise location. The class decides the storage, not convenience.
|
|
34
|
+
|
|
35
|
+
### Storage `[SEC-01]` — ask "does it persist?" before "where does it go?"
|
|
36
|
+
|
|
37
|
+
Keychain answers *where a persisted secret lives*. It does not answer *this value is sensitive*.
|
|
38
|
+
Most sensitive values in a flow are used and dropped, and those must not be persisted at all.
|
|
39
|
+
|
|
40
|
+
```swift
|
|
41
|
+
// ✗ over-persistence — a value the flow throws away in 30 seconds now outlives logout
|
|
42
|
+
// and nobody owns deleting it
|
|
43
|
+
try credentialStore.save(oneTimeCode, for: .verificationCode)
|
|
44
|
+
UserDefaults.standard.set(passportNumber, forKey: "apisPassport")
|
|
45
|
+
```
|
|
46
|
+
```swift
|
|
47
|
+
// ✓ transient: held by the flow's model, gone when the flow ends
|
|
48
|
+
@Observable final class APISFormViewModel {
|
|
49
|
+
private var passportNumber: String = "" // never leaves memory
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
```swift
|
|
53
|
+
// ✓ persisted, because it must survive an app restart — Keychain, explicit accessibility,
|
|
54
|
+
// no iCloud sync
|
|
55
|
+
try credentialStore.save(
|
|
56
|
+
membershipToken,
|
|
57
|
+
for: .authToken,
|
|
58
|
+
accessibility: .whenUnlockedThisDeviceOnly
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**The ladder, in order:**
|
|
63
|
+
|
|
64
|
+
1. **Does it need to outlive this flow?** Assume no. In-flow data — a form field, a scanned
|
|
65
|
+
document number, a one-time code, a draft — stays in memory and dies with the flow.
|
|
66
|
+
2. **If yes: survive what?** Backgrounding → still just model state. App restart → Keychain.
|
|
67
|
+
Reinstall → a product decision someone signs off, never a storage default.
|
|
68
|
+
3. **Which class is it?** `payment-instrument`, `biometric-or-health` and `precise-location`
|
|
69
|
+
stay transient no matter what step 2 said — the answer to "it must survive" there is a
|
|
70
|
+
server-side or system token, not local storage.
|
|
71
|
+
|
|
72
|
+
**Why both directions are findings:** `UserDefaults` is a plist in the app container — it lands in
|
|
73
|
+
unencrypted backups and outlives the session, so under-protection is obvious. Over-persistence is
|
|
74
|
+
the subtler one: an unnecessary Keychain item survives the flow, survives logout unless someone
|
|
75
|
+
remembers to delete it, and creates a cleanup obligation with no owner. "Put it in the Keychain to
|
|
76
|
+
be safe" is not a safe default.
|
|
77
|
+
|
|
78
|
+
**Transient is not unregulated.** A value held only in memory is still never logged `[SEC-03]`,
|
|
79
|
+
still cleared when the session drops and hidden from the app-switcher snapshot `[SEC-05]`, and
|
|
80
|
+
still never sent to analytics `[SEC-06]`.
|
|
81
|
+
|
|
82
|
+
### Logging `[SEC-03]`
|
|
83
|
+
|
|
84
|
+
```swift
|
|
85
|
+
// ✗ three violations: print, a token in the message, a raw body
|
|
86
|
+
print("order response: \(response)")
|
|
87
|
+
logger.debug("token=\(token)")
|
|
88
|
+
```
|
|
89
|
+
```swift
|
|
90
|
+
// ✓ private by default; only provably non-sensitive values are public
|
|
91
|
+
logger.debug("order completed for \(itemCount, privacy: .public) items")
|
|
92
|
+
```
|
|
93
|
+
**Why:** device logs are readable by other tooling and are collected in diagnostics. Defaulting to
|
|
94
|
+
private means a forgotten annotation fails safe; defaulting to public means it fails open.
|
|
95
|
+
|
|
96
|
+
### The rest, briefly
|
|
97
|
+
|
|
98
|
+
- No secret in source — anything committed is already leaked. `[SEC-02]`
|
|
99
|
+
- HTTPS only; an ATS exception carries a written reason and an expiry date. `[SEC-04]`
|
|
100
|
+
- Sensitive data has a lifetime: cleared on logout, hidden from the app-switcher snapshot,
|
|
101
|
+
not cached to disk by default. `[SEC-05]`
|
|
102
|
+
- Analytics events, user properties and crash breadcrumbs are redacted — check the parameter
|
|
103
|
+
list of every event you add. `[SEC-06]`
|
|
104
|
+
- Permissions are least-privilege with honest purpose strings; the privacy manifest matches what
|
|
105
|
+
you actually collect. `[SEC-07, SEC-08]`
|
|
106
|
+
- Debug menus, mock launch arguments and redirect shortcuts are **compiled out** of release, not
|
|
107
|
+
hidden behind a flag. `[SEC-09]`
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 2. Testability
|
|
112
|
+
|
|
113
|
+
Testability is a property of the production code. You cannot add it later by writing tests.
|
|
114
|
+
|
|
115
|
+
### Inject the environment `[TEST-01]`
|
|
116
|
+
|
|
117
|
+
```swift
|
|
118
|
+
// ✗ untestable by construction — the assertion depends on today's date
|
|
119
|
+
struct BoardingEligibility {
|
|
120
|
+
func canCheckIn(flight: Flight) -> Bool {
|
|
121
|
+
Date() > flight.departure.addingTimeInterval(-24 * 3600)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
```swift
|
|
126
|
+
// ✓ the seam is one parameter wide
|
|
127
|
+
struct BoardingEligibility {
|
|
128
|
+
let now: () -> Date
|
|
129
|
+
|
|
130
|
+
func canCheckIn(flight: Flight) -> Bool {
|
|
131
|
+
now() > flight.departure.addingTimeInterval(-24 * 3600)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
**Why:** the rule is not "avoid `Date()`" — it is that anything the outside world decides
|
|
136
|
+
(time, randomness, identifiers, locale, storage, session, feature flags) must be something the
|
|
137
|
+
test can decide instead. A type that reaches for it has no seam, and no test discipline recovers.
|
|
138
|
+
|
|
139
|
+
### Keep the rule callable `[TEST-02, TEST-03]`
|
|
140
|
+
|
|
141
|
+
```swift
|
|
142
|
+
// ✗ the rule is trapped inside the view model, behind a network call and a singleton
|
|
143
|
+
func submit() async {
|
|
144
|
+
guard Session.shared.isLoggedIn, passengers.allSatisfy(\.hasDocument) else { return }
|
|
145
|
+
...
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
```swift
|
|
149
|
+
// ✓ the decision is a value-returning function; the view model orchestrates
|
|
150
|
+
func submitGate(for passengers: [Passenger], isLoggedIn: Bool) -> SubmitGate {
|
|
151
|
+
guard isLoggedIn else { return .requiresLogin }
|
|
152
|
+
guard passengers.allSatisfy(\.hasDocument) else { return .missingDocuments }
|
|
153
|
+
return .allowed
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
**Why:** the second version is one line to test and reads as the business rule it is. The first
|
|
157
|
+
needs a session, a network stub and a view model instance to answer "what happens when a document
|
|
158
|
+
is missing".
|
|
159
|
+
|
|
160
|
+
Also: name test doubles for what they do — stub, spy, fake, mock — one kind per file, and keep
|
|
161
|
+
the signature identical to the real type, because a drifted double is the first thing the next
|
|
162
|
+
person copies. `[TEST-04, SVC-02]`
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 3. Readability
|
|
167
|
+
|
|
168
|
+
### Separate concerns with MARKs `[READ-01]`
|
|
169
|
+
|
|
170
|
+
Business rules, service calls and UI never share a section. A reader must find each without
|
|
171
|
+
reading the file. ViewModel order: `Properties → Init → Derived state → Flow → Gate → Intents →
|
|
172
|
+
Error handling`. Scene order: `State → Init → Body →` one `@ViewBuilder` per visual section.
|
|
173
|
+
|
|
174
|
+
### Extract by call-site count, not by feel `[READ-04]`
|
|
175
|
+
|
|
176
|
+
Two or more call sites → its own file with its own configuration. Exactly one call site and bound
|
|
177
|
+
to the screen's state → a `private @ViewBuilder` in a MARK'd extension. Pushing a state-coupled
|
|
178
|
+
fragment into its own file to shrink the screen trades one long file for a file plus a binding
|
|
179
|
+
tangle — that reads worse, and it is a finding in the same way the opposite is.
|
|
180
|
+
|
|
181
|
+
### A pure transform is a shared helper `[READ-04d]`
|
|
182
|
+
|
|
183
|
+
```swift
|
|
184
|
+
// ✗ a date formatter living on the scene that happened to need it first
|
|
185
|
+
extension PassengerAndFlightSelectionScene {
|
|
186
|
+
func formattedDate(_ raw: String?) -> String { ... } // and again, later, in a mapper
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
```swift
|
|
190
|
+
// ✓ one home for value transforms
|
|
191
|
+
enum OrderBFFFormatters {
|
|
192
|
+
static func displayDate(fromISODay raw: String?) -> String { ... }
|
|
193
|
+
static func initials(from name: String?) -> String? { ... }
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
**Why:** these have no screen state, so nothing ties them to a screen — and left where they were
|
|
197
|
+
typed they get written a second time somewhere else, with a slightly different edge case.
|
|
198
|
+
|
|
199
|
+
### A view fragment that renders a thing is a component file `[READ-04b]`
|
|
200
|
+
|
|
201
|
+
```swift
|
|
202
|
+
// ✗ a component hiding as a computed property: cannot be previewed, cannot be reused,
|
|
203
|
+
// and it reads the whole view model so it never could be
|
|
204
|
+
private var legSwitcher: some View {
|
|
205
|
+
HStack {
|
|
206
|
+
ForEach(viewModel.segments) { segment in
|
|
207
|
+
Button { Task { await viewModel.onSegmentSelected(segment.segmentIndex) } } label: { ... }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
```swift
|
|
213
|
+
// ✓ SeatMapLegSwitcher.swift — data in, callbacks out, previewable on its own
|
|
214
|
+
struct SeatMapLegSwitcher: View {
|
|
215
|
+
let segments: [SeatMapSegment]
|
|
216
|
+
let activeSegmentIndex: Int
|
|
217
|
+
let onSelect: (Int) -> Void
|
|
218
|
+
var body: some View { ... }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#Preview {
|
|
222
|
+
SeatMapLegSwitcher(segments: [.gidis, .donus], activeSegmentIndex: 0, onSelect: { _ in })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ✓ the scene keeps the composition — which component shows, in what order
|
|
226
|
+
@ViewBuilder
|
|
227
|
+
var content: some View {
|
|
228
|
+
if viewModel.loadError { errorRetryView } else { SeatMapLegSwitcher(...) }
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
**Why:** the canvas is the fastest way to check a visual piece, and it only works when the piece
|
|
232
|
+
takes data. A fragment bound to a view model needs the DI container no preview configures — so
|
|
233
|
+
it never gets looked at until the whole flow is run on a device.
|
|
234
|
+
|
|
235
|
+
### One type per file; nest only owned details `[STRUCT-01]`
|
|
236
|
+
|
|
237
|
+
```swift
|
|
238
|
+
// ✗ a response model nested inside another — invisible to a filename search,
|
|
239
|
+
// and moving it later renames every reference
|
|
240
|
+
struct OrderResponseModel {
|
|
241
|
+
struct PassengerModel { ... }
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
```swift
|
|
245
|
+
// ✓ data types are top level, one per file
|
|
246
|
+
struct OrderResponseModel { let items: [OrderItemModel] } // OrderResponseModel.swift
|
|
247
|
+
struct PassengerModel { ... } // PassengerModel.swift
|
|
248
|
+
|
|
249
|
+
// ✓ still fine — an owned detail with exactly one owner
|
|
250
|
+
@Observable final class SeatMapViewModel {
|
|
251
|
+
enum ViewState { case loading, loaded, failed }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ✓ also fine — a pure constants namespace; the nesting IS the grouping
|
|
255
|
+
enum AppConstant {
|
|
256
|
+
enum Phone { static let defaultDialCode = "+90" }
|
|
257
|
+
enum DeepLink { static let scheme = "myapp" }
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
**Why:** entities and transport models get looked up by name, move between placement tiers as
|
|
261
|
+
consumers change, and are referenced from mappers and tests. A `ViewState` or a `static let`
|
|
262
|
+
literal does none of that — flattening `AppConstant.Phone` to `AppConstantPhone` loses the
|
|
263
|
+
grouping and buys no discoverability.
|
|
264
|
+
|
|
265
|
+
### A method that wraps one service is named after it `[SVC-07]`
|
|
266
|
+
|
|
267
|
+
```swift
|
|
268
|
+
// ✗ transport verbs invented by the client — the name says what the code does
|
|
269
|
+
// (which the signature already says), not which service will fire
|
|
270
|
+
func fetchOpenStatus(_ request: CheckOpenStatusRequestModel) async -> Result
|
|
271
|
+
func loadPassengers(...) async -> Result
|
|
272
|
+
```
|
|
273
|
+
```swift
|
|
274
|
+
// ✓ `send` + the endpoint path, segments in the backend's own order
|
|
275
|
+
func sendCheckOpenStatus(_ request: CheckOpenStatusRequestModel) async -> Result // check-open-status
|
|
276
|
+
func sendOrderItemsSave(...) async -> Result // order/items/save
|
|
277
|
+
func sendSeatExtend() async -> Result // seat/extend
|
|
278
|
+
|
|
279
|
+
// ✗ the generated client's method name is not the service name: `get` is the generator's
|
|
280
|
+
// HTTP-verb prefix and it reorders the path the backend chose
|
|
281
|
+
func sendGetSeatMapPageInfo() async -> Result // seat/map-page-info -> sendSeatMapPageInfo
|
|
282
|
+
|
|
283
|
+
// ✓ variants over one service: the single caller is named, the variants sit above it
|
|
284
|
+
func sendGetCountryList() async throws -> CountryLookupResponse // private, the one call
|
|
285
|
+
func fetchNationalityList() async -> Result // standard:exception(SVC-07) screen variant
|
|
286
|
+
func fetchAreaCodeList() async -> Result // standard:exception(SVC-07) screen variant
|
|
287
|
+
```
|
|
288
|
+
**Why:** one grep from the endpoint reaches every layer that touches it, and the call site
|
|
289
|
+
tells you which service fires without opening the repository. It also survives the rename the
|
|
290
|
+
other way round: when the backend renames an endpoint, the compiler shows you every screen.
|
|
291
|
+
|
|
292
|
+
### A mapper moves values; it never decides `[SVC-08]`
|
|
293
|
+
|
|
294
|
+
```swift
|
|
295
|
+
// ✗ three decisions hiding in a lowering: a unit conversion, a clamp, and a screen state
|
|
296
|
+
struct SummaryMapper: Sendable {
|
|
297
|
+
func map(dto: SummaryDto) -> SummaryData {
|
|
298
|
+
SummaryData(
|
|
299
|
+
sessionTimeout: dto.sessionTimeout.map { TimeInterval($0) / 1000.0 },
|
|
300
|
+
variant: dto.info?.status == .error ? .flightError : .standard,
|
|
301
|
+
otpTimeout: dto.timeout ?? 180
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
```swift
|
|
307
|
+
// ✓ the mapper carries the wire facts, unit in the name
|
|
308
|
+
SummaryData(sessionTimeoutMs: dto.sessionTimeout, isFailure: dto.info?.status == .error,
|
|
309
|
+
otpTimeout: dto.timeout)
|
|
310
|
+
|
|
311
|
+
// ✓ the view model decides, in its own business-rules section
|
|
312
|
+
var variant: SummaryVariant {
|
|
313
|
+
if data.isFailure { return anyApisRedirect ? .apisError : .flightError }
|
|
314
|
+
return data.isMultiSegment ? .oneStop : .standard
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
**Why:** the mapper is the one type with no screen context. A rule buried in it is invisible
|
|
318
|
+
from the view model that owns the behaviour, untestable without hand-building a DTO, and quietly
|
|
319
|
+
duplicated the next time another screen needs the same decision. `?? ""` on an optional wire
|
|
320
|
+
field is not a decision — it is the lowering itself.
|
|
321
|
+
|
|
322
|
+
### Signatures read as the contract `[SVC-01]`
|
|
323
|
+
|
|
324
|
+
```swift
|
|
325
|
+
// ✗ the parameter list is a request model nobody wrote
|
|
326
|
+
func savePassengers(pnr: String, surname: String, passengers: [Passenger],
|
|
327
|
+
contact: ContactInfo?, acceptsTerms: Bool) async throws -> Bool
|
|
328
|
+
```
|
|
329
|
+
```swift
|
|
330
|
+
// ✓
|
|
331
|
+
public func savePassengers(
|
|
332
|
+
_ request: SaveOrderItemsRequestModel
|
|
333
|
+
) async -> OrderServiceResult<SaveOrderItemsResponseModel> {
|
|
334
|
+
```
|
|
335
|
+
**Why:** one request model means adding a field touches one type instead of every caller; one
|
|
336
|
+
result family means one error channel instead of `throws` plus a result plus an optional. Past
|
|
337
|
+
~2 parameters at a service boundary, the parameters want to be a model.
|
|
338
|
+
|
|
339
|
+
### Visibility is documentation `[VIS-01, VIS-02, VIS-04]`
|
|
340
|
+
|
|
341
|
+
Everything `private` and `final` until something forces otherwise. `public` only on what another
|
|
342
|
+
module actually imports — and every surviving `public`, plus every cross-module contract, carries
|
|
343
|
+
a doc comment. That is the one place the "no unnecessary comments" rule inverts: an
|
|
344
|
+
implementation detail explains itself through naming, a contract between two teams cannot.
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## 4. Flexibility
|
|
349
|
+
|
|
350
|
+
### A screen is a known file manifest `[STRUCT-02, STRUCT-03]`
|
|
351
|
+
|
|
352
|
+
`Scene · ViewModel · LocalizedText · CoordinatorEvent · AnalyticsTracking · UseCase · Repository
|
|
353
|
+
(+protocol +mock) · Mapper + models` — each present when its responsibility exists, absent when it
|
|
354
|
+
does not. An empty `LocalizedText` on a screen with no copy is noise, not compliance. Every screen
|
|
355
|
+
sits at the same depth with the same internal grouping, because people navigate by muscle memory.
|
|
356
|
+
|
|
357
|
+
### Placement is a consumer count `[STRUCT-05]`
|
|
358
|
+
|
|
359
|
+
| Consumers | Home |
|
|
360
|
+
|---|---|
|
|
361
|
+
| 2+ modules | cross-module shared tier |
|
|
362
|
+
| 2+ screens | the module's shared entities |
|
|
363
|
+
| one screen | that screen's own folder — **not** the shared tier |
|
|
364
|
+
| one type | its own file beside that type |
|
|
365
|
+
|
|
366
|
+
Both directions are findings. A single-consumer type parked in the shared tier inflates the shared
|
|
367
|
+
surface and makes the next reader think it is load-bearing.
|
|
368
|
+
|
|
369
|
+
### Modules do not know each other `[MOD-01, MOD-05, MOD-06]`
|
|
370
|
+
|
|
371
|
+
A feature module never imports a sibling feature. Cross-feature needs go through the seam layer
|
|
372
|
+
(contracts / bridges / adapters). DI resolves a protocol declared in core or the seam — resolving
|
|
373
|
+
another feature's concrete type is a compile-time dependency in a runtime disguise. Only the
|
|
374
|
+
composition root knows the module list.
|
|
375
|
+
|
|
376
|
+
The test: **removing this module should touch only the composition root.** If a sibling feature
|
|
377
|
+
mentions it, it is not a module, it is a folder.
|
|
378
|
+
|
|
379
|
+
### Variants are configuration `[FLEX-02, FLEX-03]`
|
|
380
|
+
|
|
381
|
+
```swift
|
|
382
|
+
// ✗ branches that differ only in tokens and copy
|
|
383
|
+
if isCompact { Text(title).font(.body).padding(8) }
|
|
384
|
+
else { Text(title).font(.title).padding(16) }
|
|
385
|
+
```
|
|
386
|
+
```swift
|
|
387
|
+
// ✓ one path, a configuration value
|
|
388
|
+
Text(title)
|
|
389
|
+
.typographyStyle(style.typography)
|
|
390
|
+
.padding(style.padding)
|
|
391
|
+
```
|
|
392
|
+
**Why:** adding a third variant costs a case instead of a branch, and the component stays open for
|
|
393
|
+
extension — the next design change does not edit its body.
|
|
394
|
+
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+
## 5. Concurrency
|
|
398
|
+
|
|
399
|
+
Applies where the module is in Swift 6 language mode; the compiler already blocks the unsafe
|
|
400
|
+
cases, so these rules are about the model being *legible*.
|
|
401
|
+
|
|
402
|
+
- The isolation policy is one decision applied everywhere. A reader must know where a function
|
|
403
|
+
runs from its declaration, without tracing callers. `[CONC-01]`
|
|
404
|
+
- State `Sendable` where it is load-bearing, consistently. `[CONC-02]`
|
|
405
|
+
- `@preconcurrency`, `nonisolated(unsafe)` and `@unchecked Sendable` are migration tools. Each
|
|
406
|
+
needs a reason and a removal condition — they are counted, and a rising count means the module
|
|
407
|
+
is quietly returning to pre-Swift-6 guarantees while the build stays green. `[CONC-03]`
|
|
408
|
+
- One model: no `DispatchQueue`, semaphore or completion handler layered onto `async`. `[CONC-04]`
|
|
409
|
+
- Every task has an owner and a cancellation story. An unowned task outlives its screen and writes
|
|
410
|
+
to dead state. `[CONC-05]`
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## 6. Accessibility
|
|
415
|
+
|
|
416
|
+
Identifier from the shared source on every interactive element `[A11Y-01]` · localized VoiceOver
|
|
417
|
+
label, plus a hint when the action is not obvious from the label `[A11Y-02]` · 44×44 minimum tap
|
|
418
|
+
target, and grouped content exposes one meaningful element rather than five fragments `[A11Y-03]`
|
|
419
|
+
· Dynamic Type survives the largest accessibility sizes — no fixed-height container around
|
|
420
|
+
scalable text `[A11Y-04]` · RTL mirrors, so `leading`/`trailing`, never `left`/`right` `[A11Y-05]`.
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
## 7. Performance — the four that are also readability
|
|
425
|
+
|
|
426
|
+
No expensive computation in a view body `[PERF-01]` · lazy containers with stable identity, never
|
|
427
|
+
index-as-id `[PERF-02]` · no blocking work at init or on the main actor `[PERF-03]` · no formatter,
|
|
428
|
+
calendar or regex constructed per render `[PERF-04]`.
|
|
429
|
+
|
|
430
|
+
**Explicitly out of scope of this standard:** Instruments-driven optimisation, launch-time budgets,
|
|
431
|
+
memory profiling. Those belong to a performance workflow — measure before optimising, and do not
|
|
432
|
+
let a style document push you into speculative tuning.
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## 8. Exceptions
|
|
437
|
+
|
|
438
|
+
A rule you cannot follow is fine; an undocumented one is not. Mark it in code:
|
|
439
|
+
|
|
440
|
+
```swift
|
|
441
|
+
// standard:exception(SEC-04) legacy partner endpoint pending TLS migration 2026-12-31
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
The linter honours the marker, the audit counts it, and the count is reviewed. Unwritten
|
|
445
|
+
exceptions become silent decay; counted ones become a backlog.
|