@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.
@@ -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 + 43 sub-command skills)
35
- Step 2b: CODEX Claude Code -> Codex CLI (1 router skill + 43 specs as refs + 8 agent TOML)
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
- **43 commands are synced** (canonical inventory - must match `cross-cli-contract.md` section 1; drift = contract violation):
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.