@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.
- package/CHANGELOG.md +111 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/dev/SKILL.md +21 -0
- package/pipeline/commands/multi-agent/dev-local/SKILL.md +21 -0
- package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +257 -0
- package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
- package/pipeline/multi-agent-refs/component-dispatch.md +40 -7
- package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +29 -0
- package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +24 -1
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +32 -0
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +26 -0
- package/pipeline/scripts/phase0-exit-gate.mjs +185 -0
- package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +21 -0
- package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +21 -0
- package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +258 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
- package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +78 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/STANDARD.md +445 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/lint-local.sh +160 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +1163 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/swiftlint.draft.yml +371 -0
- package/pipeline/skills/shared/external/ios-simulator/SKILL.md +1 -1
- package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +1 -1
- package/pipeline/skills/shared/external/swiftlint/SKILL.md +1 -1
|
@@ -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
|