@mmerterden/multi-agent-pipeline 13.0.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.
Files changed (25) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/package.json +1 -1
  3. package/pipeline/commands/multi-agent/dev/SKILL.md +21 -0
  4. package/pipeline/commands/multi-agent/dev-local/SKILL.md +21 -0
  5. package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +257 -0
  6. package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
  7. package/pipeline/multi-agent-refs/component-dispatch.md +40 -7
  8. package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
  9. package/pipeline/multi-agent-refs/phases/phase-0-init.md +29 -0
  10. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +24 -1
  11. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +32 -0
  12. package/pipeline/multi-agent-refs/phases/phase-4-review.md +26 -0
  13. package/pipeline/scripts/phase0-exit-gate.mjs +185 -0
  14. package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +21 -0
  15. package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +21 -0
  16. package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +258 -0
  17. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
  18. package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +78 -0
  19. package/pipeline/skills/shared/external/ios-coding-standard/references/STANDARD.md +445 -0
  20. package/pipeline/skills/shared/external/ios-coding-standard/references/lint-local.sh +160 -0
  21. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +1163 -0
  22. package/pipeline/skills/shared/external/ios-coding-standard/references/swiftlint.draft.yml +371 -0
  23. package/pipeline/skills/shared/external/ios-simulator/SKILL.md +1 -1
  24. package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +1 -1
  25. package/pipeline/skills/shared/external/swiftlint/SKILL.md +1 -1
@@ -0,0 +1,1163 @@
1
+ version: 1.0.0
2
+ updated: 2026-07-26
3
+ owner: iOS platform
4
+ description: >
5
+ The rule registry. STANDARD.md teaches these to a newcomer with examples; SKILL.md audits
6
+ against them. IDs are stable and never renumbered — a rule is retired by status, not deletion.
7
+ severity_levels: [blocking, important, suggestion]
8
+ enforcement_kinds:
9
+ format: the formatter owns it; not reviewed by humans
10
+ lint: a linter decides it mechanically
11
+ scan: a tool measures it (dead code, coverage, graph)
12
+ judgement: requires a human or an audit run
13
+ exception_marker: "// standard:exception(<RULE-ID>) <reason> <expiry:YYYY-MM-DD>"
14
+
15
+ persistence_decision:
16
+ description: >
17
+ Answer this BEFORE reaching for storage. Keychain is the answer to "where does a persisted
18
+ secret live", not to "this value is sensitive". Most sensitive values in a flow never need to
19
+ persist at all, and persisting them is the more expensive mistake.
20
+ ladder:
21
+ - step: 1
22
+ question: Does this value need to outlive the current flow?
23
+ default: "No — assume transient until a requirement says otherwise"
24
+ if_no: >
25
+ Keep it in memory for the duration of the flow and drop it when the flow ends. Do NOT
26
+ write it to the Keychain: an unnecessary keychain item survives the flow, survives logout
27
+ unless someone remembers to delete it, and creates a cleanup obligation nobody owns.
28
+ Over-persisting is itself a finding, not a safe default.
29
+ if_yes: go to step 2
30
+ - step: 2
31
+ question: What does it need to survive — app backgrounding, app restart, or reinstall?
32
+ guidance: >
33
+ Backgrounding only -> in-memory state owned by the flow's model is still correct.
34
+ App restart -> Keychain with the accessibility class the data demands.
35
+ Reinstall -> a deliberate product decision that someone signs off; never a storage default.
36
+ - step: 3
37
+ question: Which data class is it?
38
+ guidance: >
39
+ The class (below) decides the storage tier and the logging rule. never-persist-locally
40
+ classes stay transient no matter what step 2 said — the answer to "it needs to survive"
41
+ for a payment instrument or a biometric is a server-side or system-provided token, not
42
+ local storage.
43
+ transient_obligations: >
44
+ Transient does not mean unregulated. A value held only in memory still obeys SEC-03 (never
45
+ logged), SEC-05 (cleared when the flow ends or the session drops, hidden from the app-switcher
46
+ snapshot), and SEC-06 (never sent to analytics).
47
+
48
+ sensitive_data_classes:
49
+ description: >
50
+ The SEC rules are written against these CLASSES, never against one module's field names.
51
+ Each audited module resolves its own concrete instances into a sensitive-data inventory
52
+ (SKILL.md Phase 2d), and the lint mechanisms are generated from that inventory rather than
53
+ hardcoded. A class with no instance in the module simply yields no rule.
54
+ The at_rest value below applies ONLY to values that passed the persistence ladder — it says
55
+ where a value goes IF it must persist, never that it must.
56
+ classes:
57
+ - id: auth-token
58
+ covers: session, access, refresh and bearer tokens; loyalty or membership auth tokens; API credentials
59
+ at_rest: keychain-required
60
+ loggable: never
61
+ - id: credential
62
+ covers: password, PIN, passcode, security answer, one-time code
63
+ at_rest: keychain-required
64
+ loggable: never
65
+ note: prefer never persisting at all; a one-time code has no reason to outlive its use
66
+ - id: government-id
67
+ covers: national identity number, tax number, driving licence number
68
+ at_rest: keychain-required
69
+ loggable: never
70
+ - id: travel-document
71
+ covers: passport number and expiry, visa data, residence permit
72
+ at_rest: keychain-required
73
+ loggable: never
74
+ - id: booking-reference
75
+ covers: reservation code, ticket number, boarding pass payload, barcode data
76
+ at_rest: keychain-or-protected
77
+ loggable: never
78
+ note: individually low-value, but it authorises access to a passenger record
79
+ - id: membership-identity
80
+ covers: loyalty or programme membership number, tier, account identifier
81
+ at_rest: keychain-or-protected
82
+ loggable: hashed-or-truncated-only
83
+ - id: payment-instrument
84
+ covers: card number, expiry, CVV, tokenised card reference, billing address
85
+ at_rest: never-persist-locally
86
+ loggable: never
87
+ - id: personal-contact
88
+ covers: full name, date of birth, email, phone, postal address
89
+ at_rest: protected-storage
90
+ loggable: never
91
+ note: name alone is low risk; name paired with date of birth or a document number is not
92
+ - id: biometric-or-health
93
+ covers: any biometric template, health or accessibility need, special-category data
94
+ at_rest: never-persist-locally
95
+ loggable: never
96
+ - id: precise-location
97
+ covers: device coordinates beyond city granularity
98
+ at_rest: never-persist-locally
99
+ loggable: never
100
+
101
+ rules:
102
+
103
+ # ── READ — format & readability ───────────────────────────────────────────
104
+ - id: READ-01
105
+ title: MARK sections separate concerns — business rules, service calls, UI, one group each
106
+ severity: important
107
+ enforcement: judgement
108
+ rationale: readability
109
+ check: >
110
+ A file mixing a service call, a business rule and a view fragment under one MARK (or none)
111
+ is a finding even when short. Section order follows the module's conventions doc.
112
+ In a use case or repository implementation the sections are named after WHAT EACH TALKS TO
113
+ — `Service requests`, `On-device cache`, and so on — so a reader sees at a glance which
114
+ calls leave the device and which do not.
115
+
116
+ - id: READ-02
117
+ title: Comments are clear statements; no AI or tool attribution anywhere
118
+ severity: blocking
119
+ enforcement: lint
120
+ mechanism: 'custom regex: (?i)(generated by|copilot|co-authored-by:.*(claude|anthropic|ai))'
121
+ rationale: readability
122
+ check: >
123
+ Trim a comment by deleting what the code already says, the design-frame archaeology and the
124
+ historical aside — never the decision. If a sentence records WHY a shape was chosen, or what
125
+ breaks without it, it stays however long the block ends up.
126
+
127
+ Carve-out — a comment that IS data is not prose and is not trimmed: a trigger/prefix table,
128
+ a state-transition matrix, a wire-contract enumeration. Those exist nowhere else in the
129
+ codebase, so shortening them destroys the only copy. Judge a long block by whether it
130
+ repeats the code or replaces a missing document.
131
+
132
+ - id: READ-03
133
+ title: Size thresholds with carve-outs — screen 600 / file 400 / function 40 lines
134
+ severity: important
135
+ enforcement: lint
136
+ mechanism: swiftlint file_length, function_body_length, type_body_length
137
+ rationale: readability
138
+ exempt: [generated sources, mock/fixture data files]
139
+ tooling_limitation: >
140
+ SwiftLint's built-in length rules take no per-rule `excluded`, so the mock/fixture carve-out
141
+ cannot be expressed in the config — those files still report. Honour the exemption in the
142
+ audit pass and in the baseline; do not "fix" a fixture file to satisfy a line count.
143
+ check: File over 120 lines with zero MARK is a separate finding — a reader has no map.
144
+
145
+ - id: READ-04
146
+ title: Component placement is decided by call-site count, not by feel
147
+ severity: important
148
+ enforcement: judgement
149
+ rationale: readability
150
+ check: >
151
+ 2+ call sites -> own file with its own configuration in the shared layer. Exactly one call
152
+ site and bound to the screen's state -> private view builder in a MARK'd extension.
153
+ Both directions are findings.
154
+
155
+ - id: READ-04e
156
+ title: Header chrome belongs to the header, not to the scroll content
157
+ severity: suggestion
158
+ enforcement: judgement
159
+ rationale: readability
160
+ check: >
161
+ A bar that reads as part of the screen's header — a route chip, a countdown, a step bar —
162
+ goes inside the header component's add-on slot, not as the first row of the scroll
163
+ content. Rendered below the header it squares off the header's rounded bottom corners and
164
+ scrolls away from the title it belongs to. The header component has the slot; use it.
165
+
166
+ - id: READ-04b
167
+ title: A view fragment that renders a thing is a component file with a preview
168
+ severity: important
169
+ enforcement: judgement
170
+ rationale: testability
171
+ check: >
172
+ A `private var x: some View` inside a scene is a component in disguise. If it renders a
173
+ THING — a switcher, a chip row, a banner, a bar, a legend, a card — extract it to its own
174
+ file under the screen's `Presentation/Components/`, as a `struct` that takes DATA and
175
+ CALLBACKS, never the view model, and give it a `#Preview`.
176
+ Taking data instead of the view model is what makes the preview possible at all: a
177
+ component holding a view model needs the DI container a canvas preview never configures,
178
+ and the workaround (a preview-only fixture type shipped in production sources) is itself
179
+ a finding.
180
+ What stays a `@ViewBuilder` on the scene: the composition itself — the piece that orders
181
+ the components, branches on loaded / error / empty, and reads the view model to decide
182
+ WHICH component shows. That is business-rule display and it belongs with the screen.
183
+ The test is what the fragment DOES, not how long it is. A row of icon + two labels + a
184
+ button is a component even at ten lines; a three-line `if loaded { a } else { b }` is
185
+ composition. A fragment that renders a thing AND takes no parameters is still a component —
186
+ "it needs no arguments" means the data is hardcoded or read off the view model, and both
187
+ are reasons to extract, not to keep it.
188
+ A component the design system already owns (the shared UI package) needs no wrapper and
189
+ no preview here; render it inline and let the design system own its previews.
190
+
191
+ - id: READ-04c
192
+ title: A business rule is a view-model member; the view reads it, never computes it
193
+ severity: important
194
+ enforcement: judgement
195
+ rationale: testability
196
+ check: >
197
+ A `private var x: Bool/String/Int` in a scene that reads the view model and decides
198
+ something — "is the header shown", "which leg is active", "what does the CTA say" — is a
199
+ business rule in the view layer. It belongs on the view model, in its business-rules
200
+ section, where a unit test can reach it. The scene reads `viewModel.x`.
201
+ Not this rule: building a design-system `Configuration` value. Moving those to the view
202
+ model drags UI types across the boundary; they belong inside the component that renders
203
+ them (READ-04b), which is where they disappear once the component takes data.
204
+
205
+ - id: READ-04d
206
+ title: A pure transform is a shared helper, not a method on the view
207
+ severity: important
208
+ enforcement: judgement
209
+ rationale: flexibility
210
+ check: >
211
+ A function in a scene that touches no view-model state and returns no view — a date
212
+ format, a duration split, initials from a name, a unit conversion — is a value transform.
213
+ It belongs in the module's formatter / extension home (`Common/Formatters`, a typed
214
+ `X+Extension`), not on whichever screen needed it first.
215
+ The tell is duplication: the same `initials(_:)` written once in a scene and again in a
216
+ mapper is the normal outcome of leaving these where they were typed. Move it on the first
217
+ sighting, not the second.
218
+ **A domain-local formatter type is the second choice, not the first.** Search the shared
219
+ layer BEFORE writing one: a date/number/string transform with no domain vocabulary in it
220
+ is not a domain concern, and the app already has a home for it (`CoreExtensions`,
221
+ `CoreFormatters`). Grep the behaviour, not the name — the existing helper will be spelled
222
+ differently (`Date.localizedBFFDate` vs a hand-rolled `displayDate`), and a per-domain
223
+ `XFormatters` full of re-implementations of it is how three domains end up with three
224
+ subtly different renderings of one contract field. When the shared helper is 90% right,
225
+ extend IT (a new `Format` case, a defaulted parameter) rather than forking it.
226
+ A domain-local formatter type is correct only when it adds domain vocabulary on top of the
227
+ shared primitives — naming which two formats a screen's pickers exchange, for instance —
228
+ and implements no formatting of its own.
229
+ Not this rule: a function that builds a design-system `Configuration` from screen data.
230
+ That is the component's own lowering and belongs inside the component (READ-04b).
231
+
232
+ - id: READ-05
233
+ title: Multi-line signature style — open paren at line end, one parameter per line
234
+ severity: suggestion
235
+ enforcement: format
236
+ mechanism: swiftformat wrapArguments
237
+ rationale: readability
238
+
239
+ - id: READ-06
240
+ title: One extension per protocol conformance
241
+ severity: suggestion
242
+ enforcement: judgement
243
+ rationale: readability
244
+
245
+ - id: READ-07
246
+ title: Casing, boolean prefixes, guard-early / return-early
247
+ severity: suggestion
248
+ enforcement: lint
249
+ mechanism: swiftlint identifier_name, type_name, cyclomatic_complexity
250
+ rationale: readability
251
+
252
+ - id: READ-08
253
+ title: Forbidden constructs — force unwrap, force cast, IUO, magic numbers, raw colors/fonts, print
254
+ severity: blocking
255
+ enforcement: lint
256
+ mechanism: swiftlint force_unwrapping, force_cast, force_try, implicitly_unwrapped_optional; custom no_print, no_raw_hex, no_raw_font
257
+ rationale: security
258
+
259
+ - id: READ-09
260
+ title: Standard file header — own target module, and the author's FULL git identity
261
+ check: >
262
+ The author line carries the identity exactly as git records it, department included
263
+ ("NAME - <Department> Mudurlugu"). A bare name does not say which team owns the file, and
264
+ the same person appears under several spellings once the department is dropped.
265
+ severity: suggestion
266
+ enforcement: lint
267
+ mechanism: swiftlint file_header
268
+ rationale: readability
269
+
270
+ # ── STRUCT — project structure ────────────────────────────────────────────
271
+ - id: STRUCT-01
272
+ title: One primary top-level type per file; nesting only for owned details
273
+ severity: important
274
+ enforcement: judgement
275
+ rationale: readability
276
+ check: >
277
+ Forbidden nested: entity, domain model, request/response payload, list element. Allowed
278
+ nested: a configuration/style/state helper with exactly one owner and no second reference
279
+ site; a nested type a MACRO or an external design mapping owns — a `@FormSection`-generated
280
+ `X.Section`, a Code Connect-mapped `X.State` — where flattening silently renames a
281
+ contract the generator or the design file still refers to (the compiler catches the macro
282
+ case, the design mapping fails silently); and a pure constants namespace — a caseless enum
283
+ whose members are only `static let`
284
+ literals (`AppConstant.Phone.defaultDialCode`), where the nesting IS the grouping and
285
+ flattening to `AppConstantPhone` buys nothing. Decide by reference count, never by keyword.
286
+
287
+ - id: STRUCT-02
288
+ title: A screen is a known file manifest, not a pile
289
+ severity: important
290
+ enforcement: judgement
291
+ rationale: readability
292
+ check: >
293
+ Scene, ViewModel, LocalizedText, CoordinatorEvent, AnalyticsTracking, UseCase, Repository
294
+ (+protocol +mock), Mapper + models — each present when its responsibility exists. Report a
295
+ missing file whose responsibility leaked elsewhere AND a ceremonial empty file.
296
+
297
+ - id: STRUCT-03
298
+ title: Every screen sits at the same depth with the same internal grouping
299
+ severity: important
300
+ enforcement: judgement
301
+ rationale: readability
302
+
303
+ - id: STRUCT-04
304
+ title: Nothing lives outside the layout — no loose root type, no Utils/Helpers/Misc bucket
305
+ severity: important
306
+ enforcement: judgement
307
+ rationale: readability
308
+
309
+ - id: STRUCT-05
310
+ title: Type placement follows a consumer-count ladder
311
+ severity: important
312
+ enforcement: judgement
313
+ rationale: flexibility
314
+ check: >
315
+ 2+ modules -> cross-module shared tier. 2+ screens -> module shared entities. One screen ->
316
+ that screen's own domain folder, in the sub-folder its KIND belongs to: an enum in
317
+ `Domain/Enums/`, a value object in `Domain/Entities/`. A screen-behaviour enum (NAME-05) is
318
+ a domain type, not a presentation one — it does not live beside the Scene.
319
+ Both over- and under-hoisting are findings: a single-consumer type in the shared tier
320
+ inflates the shared surface and reads as load-bearing when it is not.
321
+
322
+ # ── NAME ──────────────────────────────────────────────────────────────────
323
+ - id: NAME-01
324
+ title: One name per role — the navigation handler is never called something else
325
+ severity: blocking
326
+ enforcement: lint
327
+ mechanism: 'custom regex scoped to Scene/ViewModel: \boutput\b as a handler property or parameter'
328
+ rationale: readability
329
+
330
+ - id: NAME-02
331
+ title: Our models use RequestModel/ResponseModel; transport suffixes stop at the data layer
332
+ severity: blocking
333
+ enforcement: lint
334
+ mechanism: 'custom regex: Dto\b in Presentation paths'
335
+ rationale: flexibility
336
+
337
+ - id: NAME-03
338
+ title: Module naming scheme — forbidden affixes come from the module's own conventions doc
339
+ severity: important
340
+ enforcement: lint
341
+ mechanism: custom regex per module (Fetch prefix, Flow suffix, Manager/Helper/Util where banned)
342
+ rationale: readability
343
+
344
+ - id: NAME-04
345
+ title: Fixed value sets are enums with tolerant decoding, not raw strings
346
+ severity: blocking
347
+ enforcement: judgement
348
+ rationale: security
349
+ check: >
350
+ An unrecognised server value must land on a known-unknown case rather than failing or
351
+ silently carrying an arbitrary string into the UI.
352
+
353
+ - id: NAME-05
354
+ title: Screen behaviour is driven by an enum state, not by a spread of booleans
355
+ severity: important
356
+ enforcement: judgement
357
+ rationale: readability
358
+ check: >
359
+ Where the wire already carries the state (a status, a redirect, a form type), map it into
360
+ an enum and branch on that — NAME-04 covers the decoding side. Where it does not, but the
361
+ screen still renders materially different variants, declare the enum ON THE SCREEN and
362
+ derive it from whatever inputs decide it. A variant set spread across three or four
363
+ independent booleans is the finding: nothing states which combinations are legal, the
364
+ reader has to enumerate them by hand, and `switch` stops telling the compiler to check
365
+ exhaustiveness.
366
+
367
+ Two shapes qualify as a finding:
368
+ 1. Three or more booleans read together to decide one visual outcome.
369
+ 2. A boolean pair whose illegal combination is only prevented by call order.
370
+
371
+ The enum belongs to the view model as a derived value, not to stored state — deriving it
372
+ keeps a single source of truth, whereas a stored copy drifts from the inputs it mirrors.
373
+ Two independent booleans that never interact are fine; do not enum-ify for its own sake.
374
+
375
+ - id: STRUCT-07
376
+ title: A scene is one type — no inner view struct wrapping it
377
+ severity: important
378
+ enforcement: judgement
379
+ rationale: readability
380
+ check: >
381
+ `struct XScene: View { var body: some View { XView(viewModel: viewModel) } }` with the real
382
+ body in a second struct in the same file is two types where the reader expects one: the
383
+ scene's name is the one in the coordinator and the file, and everything about the screen
384
+ should be under it. Fold the inner view's body and state into the scene. The exception is a
385
+ genuinely reusable view with its own consumers — and that one belongs in
386
+ `Presentation/Components/` under READ-04b, not beside the scene.
387
+
388
+ - id: STRUCT-08
389
+ title: Each live implementation sits in its own file beside its protocol
390
+ severity: important
391
+ enforcement: judgement
392
+ rationale: readability
393
+ check: >
394
+ `XUseCase.swift` holds the protocol; `XUseCaseLive.swift` sits next to it. Same for a
395
+ repository. A reader opening the protocol sees the contract without scrolling past an
396
+ implementation, and the implementation file is where the collaborators are declared.
397
+
398
+ - id: STRUCT-06
399
+ title: A validation rule is a shared type until measurement says otherwise
400
+ severity: important
401
+ enforcement: judgement
402
+ rationale: flexibility
403
+ check: >
404
+ Form/validation rules are the classic silent duplication: each one is small enough to
405
+ re-type in ten seconds, so every module does, and the copies then drift. Before adding one,
406
+ grep the shared rule library AND every sibling module for the CONCEPT, not the name — the
407
+ same rule appears as `EmailFormatRule` / `EmailRule`, `TCKNRule` / `TCKNChecksumRule` /
408
+ `NationalIdRule`, `PnrRule` / `PnrOrETicketFormatRule` / `<Screen>PnrOrETicketFormatRule`.
409
+ Name-matching alone finds none of those.
410
+
411
+ Placement follows STRUCT-05, with one addition: the error copy is NOT part of the rule.
412
+ A rule takes its error as an init parameter, so one shared implementation serves every
413
+ module while each keeps its own localized message — that is what makes hoisting cheap.
414
+
415
+ Report per concept: implementations · owning modules · whether the shared library already
416
+ has one · and any BEHAVIOURAL divergence between copies. Divergence outranks the
417
+ duplication itself: two same-named rules that accept different input mean a value valid on
418
+ one screen is rejected on another, and nothing in either module says so. Merging them is a
419
+ behaviour decision needing its own tests, never a silent move.
420
+
421
+ # ── SVC — service surface ─────────────────────────────────────────────────
422
+ - id: SVC-01
423
+ title: One request model in, one result out
424
+ severity: important
425
+ enforcement: judgement
426
+ rationale: readability
427
+ check: >
428
+ A boundary method past ~2 parameters is a finding — the parameters want to be a request
429
+ model. async, never a completion handler. No throws alongside the result family. Naming
430
+ is governed by SVC-07, which overrides "pick a domain verb" for anything that wraps a
431
+ single service call.
432
+
433
+ - id: SVC-02
434
+ title: Protocol, live implementation and test double share one signature
435
+ severity: important
436
+ enforcement: judgement
437
+ rationale: testability
438
+
439
+ - id: SVC-03
440
+ title: The call site reads as one awaited statement, handled in the data-loading section
441
+ severity: suggestion
442
+ enforcement: judgement
443
+ rationale: readability
444
+
445
+ - id: SVC-04
446
+ title: Generated networking is never hand-edited; generated types are touched only in the mapper
447
+ severity: blocking
448
+ enforcement: lint
449
+ mechanism: CI diff check on the generated path + custom regex for generated type names outside Mapper
450
+ rationale: flexibility
451
+
452
+ - id: SVC-05
453
+ title: One result and error family per module, built through one factory
454
+ severity: blocking
455
+ enforcement: judgement
456
+ rationale: testability
457
+
458
+ - id: SVC-06
459
+ title: Spec discipline — nothing hand-written around the generator, no endpoint in two specs
460
+ severity: important
461
+ enforcement: scan
462
+ rationale: flexibility
463
+
464
+ - id: SVC-07
465
+ title: A method that wraps one service call is named after that service
466
+ severity: important
467
+ enforcement: lint
468
+ mechanism: custom regex — non-`send` funcs in repository protocols surface for an explicit marker
469
+ rationale: readability
470
+ check: >
471
+ `send` + the endpoint path, segments in their own order, camel-cased, path parameters
472
+ dropped: `check-open-status` → `sendCheckOpenStatus`, `order/items/save` →
473
+ `sendOrderItemsSave`, `session/extend` → `sendSessionExtend`,
474
+ `lookup/{airlineAlliance}/airline` → `sendLookupAirline`.
475
+ The path is the name, not the generated client method. A generator prefixes GET
476
+ endpoints with `get` and appends parameter suffixes (`getSeatMapPageInfo`,
477
+ `getAirlineListByAirlineAlliance`) — both are artifacts of the generator, and `get`
478
+ also reorders the segments the backend chose. Copying the generated name silently
479
+ renames the service; copying the path keeps one spelling on both sides of the wire.
480
+ The HTTP verb is never part of the name: the endpoint already implies it, and `send`
481
+ already says a request leaves the device. The name
482
+ carries down the whole chain — repository protocol, live implementation, mock, test
483
+ double, and any use case that is a straight pass-through — so one grep from the
484
+ endpoint reaches every layer that touches it, and a reader of the call site knows
485
+ which service will fire without opening the repository.
486
+ Transport verbs invented by the client (`fetch…`, `load…`, `get…`) are the violation:
487
+ they name what the code does, which the signature already says, instead of what the
488
+ backend is being asked, which nothing else says.
489
+ Two exceptions, and both must be visible:
490
+ - **Several methods over one service.** When screen-scoped variants share a call
491
+ (three country pickers filtering one `lookup/country`; an OTP path and a no-OTP path
492
+ over one `add-fqtv-number`), the single private caller takes `send<ServiceName>` and
493
+ the variants keep their screen names above it.
494
+ - **No service behind it yet.** A placeholder whose endpoint does not exist keeps its
495
+ domain name.
496
+ Both carry `standard:exception(SVC-07)` with the reason, so the exception is a line in
497
+ the file rather than an inconsistency a reader has to explain to themselves.
498
+ A use case that aggregates, transforms, or merges sources is NOT a pass-through and
499
+ keeps its domain name (SVC-01).
500
+
501
+ - id: SVC-08
502
+ title: A business rule lives in the view model or on its own struct — nowhere else
503
+ severity: important
504
+ enforcement: lint
505
+ mechanism: custom regex — arithmetic and conditional expressions inside Mapper paths
506
+ rationale: testability
507
+ check: >
508
+ There are exactly two homes for a business rule: the **view model** that owns the
509
+ behaviour, or a **computed property on the struct the rule is about** (under a
510
+ `// MARK: - Derived`), when more than one screen asks the same question.
511
+ Every other layer is transport. A mapper, a `UseCaseLive`, a repository, a DTO mirror, a
512
+ coordinator, a data source — each moves values between two shapes and decides nothing. A
513
+ `UseCaseLive` in particular reads as a tempting home because it already knows the domain
514
+ vocabulary: it does not get one. Its body is call → map → return, plus cache read/write;
515
+ the moment it branches on a status code, merges two responses by a rule, or defaults a
516
+ value nobody can derive from the payload, that rule has been hidden from the view model
517
+ that lives with it and from every test that would have covered it.
518
+ A mapper lowers one shape onto another and nothing else. Unwrapping an optional wire
519
+ field to its empty value (`?? ""`, `?? false`, `?? []`) is lowering and is fine — the
520
+ domain type is non-optional and the wire type is not.
521
+ These are NOT lowering, and each one belongs to the view model (or, when several screens
522
+ share it, a named domain rule the view models call):
523
+ - **Arithmetic and unit conversion.** `TimeInterval(ms) / 1000`, a currency scale, a
524
+ percentage. Carry the wire value with its unit in the name (`sessionTimeoutMs`) and
525
+ convert where it is read.
526
+ - **A clamp or a threshold.** `max(0, remaining)`, `count > 1`, "negative means none".
527
+ A past deadline meaning "no countdown" is a product decision, not a cast.
528
+ - **Choosing a screen state.** Resolving a variant, an enum case, or an error message
529
+ from a combination of flags. Carry the flags; decide in the view model.
530
+ - **A policy default.** `?? 180` seconds, `?? passengers.count`. A number nobody can
531
+ derive from the payload is policy and belongs next to the code that lives with it.
532
+ - **Dropping or promoting records.** Filtering a code out of a list, or turning one code
533
+ into a different field, encodes what that code MEANS. That meaning belongs on the
534
+ entity when several screens read it, and in the view model when one does.
535
+ Why the mapper is the wrong home: it is the one type with no screen context, and a rule
536
+ buried in it is invisible from the view model that owns the behaviour, untestable without
537
+ constructing a DTO, and silently duplicated the next time another screen needs it.
538
+ A shape read the mapper legitimately makes: choosing which array element the requested
539
+ index refers to, or grouping rows by a key. It moves values into position; it does not
540
+ decide what they mean.
541
+ When the SAME lowering appears in more than one screen's mapper, it does not become a
542
+ shared mapper type in a folder of its own — it moves onto the entity it produces, as an
543
+ `init(_ dto:)` in an extension under a `// MARK: - Wire Mapping` in the entity's own file.
544
+ The entity is where a reader looks for "how is this built"; a `FooMapper` parked at the top
545
+ of the module is a second place to look and belongs to no screen.
546
+
547
+ **Moving a rule OUT of a mapper is two edits, not one.** The reason these rules survive
548
+ review is that deleting the decision also deletes the data it was computed from: the mapper
549
+ stored `isFailure: Bool` and dropped `info.status`, so no later layer can re-derive it.
550
+ So: first make the entity CARRY the raw inputs the decision reads (`info: Info?`,
551
+ `hasPrevention: Bool`, `apisCompletions: [Bool]`), then express the decision as a computed
552
+ property. Where that property lives:
553
+ - **On the entity**, under a `// MARK: - Derived` — when more than one screen, or the
554
+ screen AND its mocks, ask the same question. This is also what keeps the mock
555
+ repositories honest: a mock can no longer hand-set a state the real payload could never
556
+ produce, because the state is computed from the same inputs in both.
557
+ - **In the view model** — when the question is that one screen's, or when the answer needs
558
+ anything the entity does not hold (localized copy, a feature flag, live user state).
559
+ Never in the mapper, and never as a stored field the mapper fills, because a stored field
560
+ is indistinguishable from a wire value at every call site that reads it.
561
+
562
+ Auditing a module for this is a grep, but a noisy one. These MATCH the search and are NOT
563
+ violations — do not "fix" them: `init(x: T? = nil)` default-nil signatures, `?? ""` /
564
+ `?? false` / `?? []` lowering of an optional wire field onto a non-optional domain one,
565
+ `.first { $0.index == requested }` and `Dictionary(grouping:)` shape reads, and
566
+ `.filter { !$0.isEmpty }.joined()` name assembly. What IS a violation: a comparison that
567
+ names a code or a state (`== "TK"`, `== .error`, `status ?? "USABLE"`), a conditional that
568
+ emits or drops a field, an aggregation whose result is a screen state
569
+ (`contains { ... } ? .invalid : .valid`), and any localized string.
570
+
571
+ - id: PLAT-01
572
+ title: Platform affordances go through the app's own wrapper, not the OS API
573
+ severity: blocking
574
+ enforcement: lint
575
+ mechanism: custom regex — raw SwiftUI presentation APIs outside the shared wrapper's own module
576
+ rationale: flexibility
577
+ check: >
578
+ When the app ships a wrapper for a platform affordance — a sheet, a date picker, a toast —
579
+ a feature module calls the wrapper, never the OS API underneath. The wrapper exists
580
+ because the OS changed the affordance once already and will again: iOS 26 forced a
581
+ Liquid-Glass inset onto partial-detent sheets with no opt-out, which put a gap down the
582
+ side and bottom of every raw `.sheet` and swallowed a pushed picker's selection.
583
+ The tell that this rule was skipped: one module's sheets look different from the rest of
584
+ the app after an OS upgrade, and the fix already exists in the shared layer.
585
+ Exception: an affordance the OS itself owns end to end — a share sheet, an Add-to-Siri
586
+ sheet, a photo picker — stays on the system API. Wrapping those changes nothing and
587
+ breaks their behaviour.
588
+
589
+ - id: PLAT-02
590
+ title: A self-sizing presentation only works if the content reports its size
591
+ severity: important
592
+ enforcement: judgement
593
+ rationale: readability
594
+ check: >
595
+ A content-sized detent asks the content how tall it is. A plain geometry read inside the
596
+ presentation answers with the frame the presentation already gave it — so the sheet
597
+ latches onto its own current height and never grows. Either publish the height the way
598
+ the framework expects (per region, the way the shared scaffold does) or use a fixed
599
+ height. Do not mix: a content detent with no reporter is a sheet that opens at the wrong
600
+ size in one locale and looks right in another.
601
+ The mirror-image mistake is worse, because it looks like a fix: pre-sizing the content so
602
+ it "reports a better height". A `fixedSize(vertical:)` at the presentation root measures
603
+ the content against an unconstrained WIDTH proposal — every line of copy lays out on one
604
+ line, and the height that comes back is a fraction of the real one. The sheet then opens
605
+ at two lines tall with the text clipped. When the presentation already measures its
606
+ content (an intrinsic-size host, a per-region scaffold), the content must add NOTHING:
607
+ no `fixedSize`, no geometry read, no frame. Check which key the engine actually observes
608
+ before publishing one — publishing to a key nothing reads is dead code that reads as a fix.
609
+ A content-sized presentation is also only as good as WHEN it measures. If the panel reads
610
+ the content's intrinsic size during the present animation — before the hosting view has its
611
+ final width — copy that wraps reports a fraction of its real height and the sheet opens
612
+ short. It cannot recover on its own when the follow-up measurement reads the RENDERED
613
+ height, because the short panel is already clamping that: the wrong size is a fixed point
614
+ until the user drags the sheet, which is exactly what the bug report will describe. The fix
615
+ belongs in the presentation engine — one re-read after the transition settles — not in each
616
+ caller. The tell that it is a measurement-timing bug and not a detent choice: a wheel picker
617
+ in the same engine is fine, because its ideal height does not depend on width.
618
+ A fixed detent is not the workaround either. `[.medium, .large]` on short content leaves
619
+ dead space, and on tall content clips the primary button. If the content has a natural
620
+ height, say so with a content detent and one shared cap.
621
+ When a presentation moves to a different engine, DELETE the previous engine's modifiers
622
+ from the content. `presentationDetents` / `presentationDragIndicator` /
623
+ `presentationBackground` left on a view that a UIKit-hosted panel now presents are silent
624
+ no-ops, and the next reader will believe the sheet is sized there and debug the wrong file.
625
+ A content-sized panel asks its content one question: how tall do you want to be? The
626
+ content must be able to answer it. Two answers that are not answers, and each produces the
627
+ opposite symptom:
628
+ - **A trailing `Spacer`** answers "all of it". The panel opens at its cap with the real
629
+ content stranded at the top. A content-sized sheet's root stack has no `Spacer` — its own
630
+ height IS the answer; use padding for the bottom clearance.
631
+ - **Copy with no width** answers "one line per paragraph". Height that is entirely a
632
+ function of where lines break cannot be measured without a width, so the sheet opens
633
+ short and clips. Hand the presenting screen's measured width down to the content.
634
+ Diagnose by comparing against a sheet in the same engine that works. If one is fine and
635
+ another is not, the engine is not the bug — and a shared-engine change to fix one screen
636
+ puts every other domain's sheets at risk for a fix nobody has verified. Fix it where the
637
+ content is.
638
+ When a presentation moves to a different engine, DELETE the previous engine's modifiers
639
+ from the content. `presentationDetents` / `presentationDragIndicator` /
640
+ `presentationBackground` left on a view that a UIKit-hosted panel now presents are silent
641
+ no-ops, and the next reader will believe the sheet is sized there and debug the wrong file.
642
+ A fixed detent is not the workaround either: `[.medium, .large]` on short content leaves
643
+ dead space, and on tall content clips the primary button.
644
+ Verify presentation sizing on a device or simulator before calling it done. This is not a
645
+ thing a diff shows, and it is not a thing to guess at twice.
646
+
647
+ - id: PLAT-03
648
+ title: Assets come from the app's own CDN
649
+ severity: blocking
650
+ enforcement: lint
651
+ mechanism: 'custom regex: https?:// host not on the app''s own domains, in a non-test source'
652
+ rationale: security
653
+ check: >
654
+ An image or asset URL pointing at a third-party host is a finding even when it renders
655
+ correctly: the host is outside our control and versioning, it can change or disappear
656
+ under us, and every render leaks a request to a party the user never agreed to. The
657
+ carrier logo, the flag, the banner — they all exist on the app's own CDN. Put the URL in
658
+ the module's constants namespace so there is one place to change it.
659
+
660
+ # ── SAFE ──────────────────────────────────────────────────────────────────
661
+ - id: SAFE-01
662
+ title: Escaping closures capture self weakly and unwrap immediately
663
+ severity: important
664
+ enforcement: lint
665
+ mechanism: 'custom regex: escaping closure body referencing self without a weak capture list'
666
+ rationale: security
667
+
668
+ - id: SAFE-02
669
+ title: A calendar day sent to a service uses the shared calendar-day helper
670
+ severity: blocking
671
+ enforcement: lint
672
+ mechanism: 'custom regex: DateFormatter constructed in a mapper or repository path'
673
+ rationale: security
674
+
675
+ # ── VIS — declaration & visibility ────────────────────────────────────────
676
+ - id: VIS-01
677
+ title: Every class is final unless a real subclass exists in the module
678
+ severity: important
679
+ enforcement: lint
680
+ mechanism: 'custom regex: ^\s*(public |internal )?class\b not preceded by final'
681
+ rationale: readability
682
+
683
+ - id: VIS-02
684
+ title: Explicit access level, private by default; internal handled consistently
685
+ severity: important
686
+ enforcement: lint
687
+ mechanism: swiftlint private_outlet, explicit_acl (opt-in) — scoped to the module's convention
688
+ rationale: readability
689
+
690
+ - id: VIS-03
691
+ title: Mutability and conformance declared as narrowly as the type allows
692
+ severity: suggestion
693
+ enforcement: lint
694
+ mechanism: swiftlint prefer_let, prefer_self_in_static_references
695
+ rationale: testability
696
+
697
+ - id: VIS-04
698
+ title: The surviving public surface and every seam contract carries a doc comment
699
+ severity: important
700
+ enforcement: lint
701
+ mechanism: swiftlint missing_docs scoped to public declarations
702
+ rationale: flexibility
703
+ check: The one place the no-unnecessary-comments rule inverts — a cross-module contract cannot explain itself through naming.
704
+
705
+ # ── UI ────────────────────────────────────────────────────────────────────
706
+ - id: UI-01
707
+ title: A screen's composite view is never consumed by another screen
708
+ severity: blocking
709
+ enforcement: judgement
710
+ rationale: flexibility
711
+
712
+ - id: UI-02
713
+ title: No new component is added to a UI target the module marks as frozen
714
+ severity: important
715
+ enforcement: lint
716
+ mechanism: CI check on new files under the frozen target path
717
+ rationale: flexibility
718
+
719
+ - id: UI-03
720
+ title: Every screen with user actions has its analytics surface; no direct generated-event calls
721
+ severity: important
722
+ enforcement: judgement
723
+ rationale: readability
724
+
725
+ - id: UI-04
726
+ title: All user copy goes through the screen's copy surface
727
+ severity: important
728
+ enforcement: lint
729
+ mechanism: 'custom regex: raw localization key access inside Scene/ViewModel'
730
+ rationale: flexibility
731
+
732
+ # ── MOD — modularity ──────────────────────────────────────────────────────
733
+ - id: MOD-01
734
+ title: A feature module never imports another feature module
735
+ severity: blocking
736
+ enforcement: lint
737
+ mechanism: 'custom regex per module: ^import (<sibling feature names>)'
738
+ rationale: flexibility
739
+ check: A feature that imports many siblings to compose them has promoted itself to a second composition root — highest severity in this section.
740
+
741
+ - id: MOD-02
742
+ title: The manifest graph is one-way and acyclic; core never depends on a feature
743
+ severity: blocking
744
+ enforcement: scan
745
+ rationale: flexibility
746
+ check: Check as policy, not by trusting the build — a package-level cycle can still build. Report dead edges (declared, never imported).
747
+
748
+ - id: MOD-03
749
+ title: Shared values walk the placement ladder, cheapest rung first, with a stated reason to climb
750
+ severity: important
751
+ enforcement: judgement
752
+ rationale: flexibility
753
+
754
+ - id: MOD-04
755
+ title: Removability delta — removing the module touches only the composition root
756
+ severity: blocking
757
+ enforcement: scan
758
+ rationale: flexibility
759
+ metric: "files touched to remove the module (target: composition root only)"
760
+
761
+ - id: MOD-05
762
+ title: DI is by abstraction; each module wires only itself
763
+ severity: blocking
764
+ enforcement: judgement
765
+ rationale: testability
766
+ check: >
767
+ Resolving a concrete type owned by another feature module is a compile-time dependency in a
768
+ runtime disguise. The composition root is the only place that knows the module list.
769
+
770
+ - id: MOD-06
771
+ title: The public surface is the plug — entry types only, everything else internal
772
+ severity: important
773
+ enforcement: scan
774
+ rationale: flexibility
775
+ metric: "public declarations divided by externally consumed symbols (target ~1)"
776
+
777
+ - id: MOD-07
778
+ title: No shared mutable state across the boundary; the module owns its own assets and copy
779
+ severity: blocking
780
+ enforcement: judgement
781
+ rationale: flexibility
782
+
783
+ # ── CONC — concurrency ────────────────────────────────────────────────────
784
+ - id: CONC-01
785
+ title: The isolation policy is one declared decision applied everywhere
786
+ severity: important
787
+ enforcement: judgement
788
+ rationale: readability
789
+ applies_when: module is in Swift 6 language mode (otherwise suggestion)
790
+ check: A reader must know where a function runs from its declaration, without tracing callers.
791
+
792
+ - id: CONC-02
793
+ title: Sendable conformance is stated where it is load-bearing, consistently
794
+ severity: suggestion
795
+ enforcement: judgement
796
+ rationale: readability
797
+
798
+ - id: CONC-03
799
+ title: Escape hatches are justified and counted
800
+ severity: important
801
+ enforcement: lint
802
+ mechanism: 'custom regex: @preconcurrency|nonisolated\(unsafe\)|@unchecked Sendable'
803
+ rationale: security
804
+ metric: "escape-hatch count by kind (target: flat or falling)"
805
+ check: A growing count silently returns the module to pre-Swift-6 guarantees while the build stays green.
806
+
807
+ - id: CONC-04
808
+ title: One concurrency model — no GCD, semaphore or completion handler layered onto async
809
+ severity: important
810
+ enforcement: lint
811
+ mechanism: 'custom regex: DispatchQueue|DispatchSemaphore|DispatchGroup in async-adopted paths'
812
+ rationale: readability
813
+
814
+ - id: CONC-05
815
+ title: Every task has an owner and a cancellation story
816
+ severity: important
817
+ enforcement: judgement
818
+ rationale: testability
819
+
820
+ # ── TEST — testability by design ──────────────────────────────────────────
821
+ - id: TEST-01
822
+ title: The environment is injected, never reached for
823
+ severity: blocking
824
+ enforcement: lint
825
+ mechanism: 'custom regex scoped to logic paths: Date\(\)|UUID\(\)|UserDefaults|\.random|Locale\.current'
826
+ rationale: testability
827
+ check: >
828
+ Time, calendar, randomness, UUID, locale, storage, network, feature flags and session state
829
+ arrive through an abstraction. A logic type calling these directly is untestable by
830
+ construction — no amount of test-writing discipline fixes it.
831
+
832
+ - id: TEST-02
833
+ title: Business rules are callable without a view, navigation or network
834
+ severity: important
835
+ enforcement: judgement
836
+ rationale: testability
837
+ check: Decision logic returns a value rather than mutating far-away state.
838
+
839
+ - id: TEST-03
840
+ title: No static or singleton access from the logic layer
841
+ severity: important
842
+ enforcement: lint
843
+ mechanism: 'custom regex: \.shared\b|\.main\.container outside composition/DI paths'
844
+ rationale: testability
845
+
846
+ - id: TEST-04
847
+ title: Test doubles follow one named taxonomy — stub, spy, fake, mock
848
+ severity: suggestion
849
+ enforcement: judgement
850
+ rationale: testability
851
+ check: One kind per file, name states the kind, signature parity with the real type (SVC-02).
852
+
853
+ - id: TEST-05
854
+ title: Async behaviour is testable — no unstructured task in logic, no sleep, injected clock
855
+ severity: important
856
+ enforcement: lint
857
+ mechanism: 'custom regex: Task\s*\{ in logic paths; sleep\( in test paths'
858
+ rationale: testability
859
+
860
+ - id: TEST-06
861
+ title: Tests mirror the source tree across the module's declared layers
862
+ severity: important
863
+ enforcement: scan
864
+ rationale: testability
865
+ metric: "screens with production logic and no mirrored test folder (target 0)"
866
+
867
+ - id: TEST-07
868
+ title: The module's test target compiles with no sibling feature present
869
+ severity: blocking
870
+ enforcement: scan
871
+ rationale: testability
872
+
873
+ # ── FLEX — intra-module flexibility ───────────────────────────────────────
874
+ - id: FLEX-01
875
+ title: Layers meet through protocols; no concrete cross-layer type in a signature
876
+ severity: important
877
+ enforcement: judgement
878
+ rationale: testability
879
+
880
+ - id: FLEX-02
881
+ title: Variants are configuration, not branching
882
+ severity: important
883
+ enforcement: judgement
884
+ rationale: flexibility
885
+ check: A conditional whose branches differ only in tokens or copy is a finding.
886
+
887
+ - id: FLEX-03
888
+ title: Components are open for extension — a new variant does not edit the existing body
889
+ severity: important
890
+ enforcement: judgement
891
+ rationale: flexibility
892
+
893
+ - id: FLEX-04
894
+ title: Feature flags are resolved at the boundary and carry a removal condition
895
+ severity: important
896
+ enforcement: lint
897
+ mechanism: 'custom regex: flag access inside a view body'
898
+ rationale: flexibility
899
+
900
+ - id: FLEX-05
901
+ title: No layer's vocabulary leaks into another — presentation types never travel downward
902
+ severity: important
903
+ enforcement: judgement
904
+ rationale: flexibility
905
+
906
+ # ── SEC — security & privacy ──────────────────────────────────────────────
907
+ - id: SEC-01
908
+ title: Sensitive data persists only when it must, and then only in the Keychain
909
+ severity: blocking
910
+ enforcement: lint
911
+ mechanism: >
912
+ custom regex generated from the module's sensitive-data inventory, in two directions:
913
+ (a) a resolved sensitive symbol written to UserDefaults, a plist, a file or the local
914
+ database; (b) a symbol the inventory marks transient appearing in ANY persistence call,
915
+ keychain included
916
+ rationale: security
917
+ applies_to_classes: all
918
+ check: >
919
+ Walk persistence_decision first. Most sensitive values in a flow are used and dropped —
920
+ those stay in memory, and writing them to the Keychain is itself a finding, because an
921
+ unnecessary keychain item outlives the flow, survives logout unless someone deletes it, and
922
+ creates a cleanup obligation with no owner. Only a value that must survive app restart is
923
+ persisted, and then: Keychain only, with an explicit accessibility class matching the data
924
+ (device-only unless a documented reason exists), no iCloud sync, biometric or passcode
925
+ gating where policy requires. never-persist-locally classes stay transient regardless.
926
+ Report both over-persistence and under-protection — they are equally findings.
927
+
928
+ - id: SEC-02
929
+ title: No hardcoded secrets — anything in source is treated as already leaked
930
+ severity: blocking
931
+ enforcement: lint
932
+ mechanism: 'custom regex: (api[_-]?key|secret|password|bearer|private[_-]?key)\s*[:=]\s*"'
933
+ rationale: security
934
+
935
+ - id: SEC-03
936
+ title: Logging never carries sensitive data; log interpolation defaults to private
937
+ severity: blocking
938
+ enforcement: lint
939
+ mechanism: >
940
+ custom regex generated from the module's sensitive-data inventory: any resolved symbol
941
+ interpolated into a log call, plus a blanket ban on print( and on logging a raw
942
+ request/response body
943
+ rationale: security
944
+ applies_to_classes: all
945
+ check: >
946
+ Every class marked loggable:never is redacted; membership-identity is hashed or truncated
947
+ where it must appear at all. Interpolated values are private by default and only values
948
+ explicitly known to be non-sensitive are made public — the default must fail safe, because
949
+ the cost of a missed annotation is a logged credential.
950
+
951
+ - id: SEC-04
952
+ title: Transport is HTTPS and ATS-compliant; an exception needs a written reason and an expiry
953
+ severity: blocking
954
+ enforcement: lint
955
+ mechanism: Info.plist ATS key scan + custom regex for http:// literals
956
+ rationale: security
957
+
958
+ - id: SEC-05
959
+ title: Sensitive data has a lifetime
960
+ severity: important
961
+ enforcement: judgement
962
+ rationale: security
963
+ check: >
964
+ Cleared on logout and session end; sensitive screens hidden from the app-switcher snapshot;
965
+ pasteboard writes explicit and expiring; nothing sensitive cached to disk by default.
966
+
967
+ - id: SEC-06
968
+ title: Analytics and crash payloads are redacted
969
+ severity: blocking
970
+ enforcement: judgement
971
+ rationale: security
972
+ check: Cross-check every analytics event's parameter list, user properties, breadcrumbs and non-fatal payloads for PII.
973
+
974
+ - id: SEC-07
975
+ title: Permissions are least-privilege with accurate purpose strings
976
+ severity: important
977
+ enforcement: scan
978
+ rationale: security
979
+ check: A permission requested but unused is a finding — a privacy problem and an App Review risk.
980
+
981
+ - id: SEC-08
982
+ title: The privacy manifest is complete and honest
983
+ severity: blocking
984
+ enforcement: scan
985
+ rationale: security
986
+ check: Declared data types match what the module actually collects; required-reason APIs carry a valid reason code.
987
+
988
+ - id: SEC-09
989
+ title: Debug and mock affordances are excluded from the store build by a real mechanism
990
+ severity: blocking
991
+ enforcement: lint
992
+ mechanism: >
993
+ custom regex for the commented-out compilation directive only (a "// #if" is not a guard —
994
+ the code inside it ships). Whether an affordance is actually gated is JUDGEMENT: once the
995
+ gate is a resolved capability rather than a compile-time condition, no regex can see it —
996
+ a pattern that looked for "#if DEBUG nearby" flags the gated call site and the debug type's
997
+ own declaration alike, which is noise, not signal.
998
+ rationale: security
999
+ check: >
1000
+ The rule is store-build exclusion, not "#if DEBUG" specifically. Internal, TestFlight and
1001
+ enterprise distributions may legitimately carry a debug menu; the store build must not. The
1002
+ question to answer per affordance is "what removes this from the store build?", and any
1003
+ mechanism that actually fires there is acceptable.
1004
+
1005
+ Three failure shapes, all findings:
1006
+ 1. No gate — the affordance is unconditionally compiled and reachable.
1007
+ 2. A commented-out gate — "// #if DEBUG" reads like protection and provides none. Usually a
1008
+ symptom of shape 3 rather than carelessness.
1009
+ 3. A gate that fires in the wrong builds — "#if DEBUG" in a Release-configured internal
1010
+ build strips the affordance from exactly the testers who need it, so someone eventually
1011
+ comments it out and it reaches the store. Diagnose this as a missing mechanism, never as
1012
+ "restore the directive": restoring it re-breaks the internal build.
1013
+
1014
+ Know the constraint before proposing a fix. A SwiftPM module does not inherit the app
1015
+ target's SWIFT_ACTIVE_COMPILATION_CONDITIONS, and SwiftPM's .when(configuration:) predicate
1016
+ distinguishes only debug from release — a custom Xcode configuration is invisible inside the
1017
+ package. Where a module needs a three-way distinction (local / internal / store), a
1018
+ compile-time condition alone cannot express it.
1019
+
1020
+ Prefer, in order: (a) the composition root resolves the distribution channel once and
1021
+ injects it as a capability the module consumes through an abstraction, so the module never
1022
+ knows how it was distributed — this also satisfies MOD-05; (b) a build-injected Info.plist
1023
+ flag read at startup; (c) a runtime channel probe such as the absence of an embedded
1024
+ provisioning profile. Whichever is chosen it must be ONE shared mechanism: one per module
1025
+ means nobody can answer "is this in the store build?" without reading every call site.
1026
+
1027
+ # ── DEPR — deprecation debt ───────────────────────────────────────────────
1028
+ - id: DEPR-01
1029
+ title: No call site of a deprecated API — platform or in-repo
1030
+ severity: important
1031
+ enforcement: scan
1032
+ rationale: flexibility
1033
+ metric: "deprecated call sites (target 0, and never rising)"
1034
+ check: >
1035
+ Two sources, both count. Platform deprecations surface only in a build log, so harvest them
1036
+ from the build the module is actually verified with (WARN-01) rather than guessing. In-repo
1037
+ deprecations are greppable: find every `@available(*, deprecated)` declaration across the
1038
+ repo, then count this module's call sites of each.
1039
+ A deprecation nobody migrates is worse than no deprecation — it trains readers to ignore the
1040
+ warning, and it hides the one that matters. Report per deprecated symbol: declaration site ·
1041
+ replacement named in the message · this module's call-site count · migration owner.
1042
+
1043
+ - id: DEPR-02
1044
+ title: Our own deprecations name a replacement and have a removal condition
1045
+ severity: important
1046
+ enforcement: lint
1047
+ mechanism: 'custom regex: @available(*, deprecated) without a message:, and a deprecated declaration still referenced in-module'
1048
+ rationale: flexibility
1049
+ check: >
1050
+ A deprecation without a stated replacement is a complaint, not a migration. Every
1051
+ `@available(*, deprecated, message:)` says what to use instead and, in the plan, who owns
1052
+ the removal and when. A deprecated symbol with zero remaining call sites is deleted, not
1053
+ left as furniture.
1054
+
1055
+ - id: DEPR-03
1056
+ title: Governance docs never prescribe a deprecated API
1057
+ severity: blocking
1058
+ enforcement: judgement
1059
+ rationale: readability
1060
+ check: >
1061
+ Cross-check the module's own docs against the deprecation list. When a doc mandates a
1062
+ pattern whose API is deprecated, every new screen built to the doc adds fresh debt, and the
1063
+ developer following the standard is punished for it. This outranks the individual call
1064
+ sites: fix the doc first, then migrate, or the count grows faster than the migration.
1065
+
1066
+ - id: DEPR-04
1067
+ title: Availability gates below the deployment target are removed
1068
+ severity: suggestion
1069
+ enforcement: lint
1070
+ mechanism: 'custom regex: @available / #available naming a version at or below the package deployment target'
1071
+ rationale: readability
1072
+ check: >
1073
+ An `if #available(iOS 16)` in a module that already requires iOS 17 is dead branching a
1074
+ reader must still evaluate. Forward gates (above the target) are legitimate and stay.
1075
+
1076
+ # ── WARN — warning debt ───────────────────────────────────────────────────
1077
+ - id: WARN-01
1078
+ title: The module's build produces zero warnings, and the count never rises
1079
+ severity: important
1080
+ enforcement: scan
1081
+ rationale: readability
1082
+ metric: "compiler warnings for this module (target 0, hard requirement: not increasing)"
1083
+ check: >
1084
+ Harvest from the module's real verification build (an xcresult or a build log — the one the
1085
+ team actually runs, since some targets cannot be built from the CLI). Group by warning kind
1086
+ and report the top kinds, not just a total: one repeated warning across 40 files is a single
1087
+ fix, and a total hides that.
1088
+ Warnings are the canary for deprecation, concurrency and unused-code debt at once. A build
1089
+ with 300 warnings has no working warning channel — the next real one is invisible, which is
1090
+ the actual cost. Treat the count as a ratchet even when zero is out of reach today.
1091
+
1092
+ - id: WARN-02
1093
+ title: A TODO carries an owner or a ticket, and no FIXME survives a release
1094
+ severity: suggestion
1095
+ enforcement: lint
1096
+ mechanism: 'custom regex: TODO or FIXME without a bracketed tag or ticket reference'
1097
+ rationale: readability
1098
+ metric: "untagged TODO count (target 0), FIXME count (target 0)"
1099
+ check: >
1100
+ `// TODO[SWAGGER-208458421]: …` is a tracked decision; a bare `// TODO:` is a note to a
1101
+ person who has left. Tagging is cheap and makes the debt countable. FIXME means "known
1102
+ broken" and belongs in the tracker, not the source.
1103
+
1104
+ # ── A11Y ──────────────────────────────────────────────────────────────────
1105
+ - id: A11Y-01
1106
+ title: An identifier from the shared source on every interactive element
1107
+ severity: important
1108
+ enforcement: lint
1109
+ mechanism: custom regex for interactive modifiers without an accessibility identifier
1110
+ rationale: testability
1111
+
1112
+ - id: A11Y-02
1113
+ title: Localized VoiceOver label, plus a hint where the action is not obvious
1114
+ severity: important
1115
+ enforcement: judgement
1116
+ rationale: accessibility
1117
+
1118
+ - id: A11Y-03
1119
+ title: Minimum 44x44 tap target; grouped content exposes one meaningful element
1120
+ severity: important
1121
+ enforcement: judgement
1122
+ rationale: accessibility
1123
+
1124
+ - id: A11Y-04
1125
+ title: Dynamic Type does not break layout at the largest accessibility sizes
1126
+ severity: important
1127
+ enforcement: judgement
1128
+ rationale: accessibility
1129
+ check: No fixed-height container holding scalable text.
1130
+
1131
+ - id: A11Y-05
1132
+ title: RTL mirrors correctly; no leading/trailing hardcoded as left/right
1133
+ severity: important
1134
+ enforcement: lint
1135
+ mechanism: 'custom regex: \.left|\.right in alignment and padding edges'
1136
+ rationale: accessibility
1137
+
1138
+ # ── PERF — minimal set; the boundary is declared in STANDARD.md ───────────
1139
+ - id: PERF-01
1140
+ title: No expensive computation inside a view body
1141
+ severity: important
1142
+ enforcement: judgement
1143
+ rationale: performance
1144
+
1145
+ - id: PERF-02
1146
+ title: Lists and grids use lazy containers with stable identity
1147
+ severity: important
1148
+ enforcement: lint
1149
+ mechanism: 'custom regex: ForEach over indices as identity; VStack over a large collection'
1150
+ rationale: performance
1151
+
1152
+ - id: PERF-03
1153
+ title: No blocking work at init or on the main actor — decoding, file I/O, JSON
1154
+ severity: important
1155
+ enforcement: judgement
1156
+ rationale: performance
1157
+
1158
+ - id: PERF-04
1159
+ title: No per-render construction of formatters, calendars or regexes
1160
+ severity: suggestion
1161
+ enforcement: lint
1162
+ mechanism: 'custom regex: DateFormatter(|NumberFormatter(|Regex( inside a view body'
1163
+ rationale: performance