@mmerterden/multi-agent-pipeline 20.8.0 → 20.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/docs/facts.json +1 -1
  3. package/manifest.json +63 -62
  4. package/package.json +2 -2
  5. package/pipeline/lib/redact.mjs +3 -2
  6. package/pipeline/scripts/gen-skills-index.mjs +13 -1
  7. package/pipeline/scripts/pre-commit-check.sh +4 -0
  8. package/pipeline/skills/.skill-manifest.json +21 -21
  9. package/pipeline/skills/shared/README.md +64 -64
  10. package/pipeline/skills/shared/external/alarmkit/SKILL.md +2 -2
  11. package/pipeline/skills/shared/external/alarmkit/evals/evals.json +2 -2
  12. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +6 -0
  13. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +3 -0
  14. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +3 -0
  15. package/pipeline/skills/shared/external/app-store-review/SKILL.md +3 -4
  16. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +5 -3
  17. package/pipeline/skills/shared/external/authentication/SKILL.md +29 -17
  18. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +3 -1
  19. package/pipeline/skills/shared/external/background-processing/SKILL.md +10 -8
  20. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +7 -7
  21. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +4 -2
  22. package/pipeline/skills/shared/external/core-data/SKILL.md +12 -2
  23. package/pipeline/skills/shared/external/coreml/SKILL.md +1 -1
  24. package/pipeline/skills/shared/external/cryptokit/SKILL.md +1 -1
  25. package/pipeline/skills/shared/external/device-integrity/SKILL.md +11 -5
  26. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
  27. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +1 -1
  28. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +8 -7
  29. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
  30. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +5 -2
  31. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +100 -0
  32. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +45 -26
  33. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
  34. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +12 -5
  35. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +2 -1
  36. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +6 -5
  37. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +44 -18
  38. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +5 -2
  39. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +10 -11
  40. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +4 -33
  41. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +12 -59
  42. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +4 -2
  43. package/pipeline/skills/shared/external/skill-creator/template.md +7 -1
  44. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +6 -1
  45. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +3 -2
  46. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +1 -1
  47. package/pipeline/skills/shared/external/swift-security/SKILL.md +10 -8
  48. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +8 -5
  49. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +13 -9
  50. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +7 -6
  51. package/pipeline/skills/shared/external/swift-testing/SKILL.md +8 -5
  52. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +1 -1
  53. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +3 -2
  54. package/pipeline/skills/shared/external/swiftdata/SKILL.md +5 -3
  55. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +17 -9
  56. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +4 -2
  57. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +13 -6
  58. package/pipeline/skills/shared/external/weatherkit/SKILL.md +8 -5
  59. package/pipeline/skills/shared/external/widgetkit/SKILL.md +15 -7
  60. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +8 -6
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: authentication
3
- description: "iOS sign-in with AuthenticationServices and LocalAuthentication: Sign in with Apple, passkey (WebAuthn) registration and login via ASAuthorizationPlatformPublicKeyCredentialProvider, ASAuthorizationController credential state and revocation, ASWebAuthenticationSession OAuth and third-party login, Password AutoFill, server-side identity-token validation, LAContext biometric re-authentication. Use when adding or reviewing account flows."
3
+ description: "iOS sign-in with AuthenticationServices and LocalAuthentication: Sign in with Apple, passkey (WebAuthn) sign-up and login via ASAuthorizationPlatformPublicKeyCredentialProvider, ASAuthorizationController credential state and revocation, ASWebAuthenticationSession OAuth, third-party login, Password AutoFill, server identity-token validation, LAContext biometric re-auth prompts and policy. Use when adding or reviewing account flows."
4
4
  metadata:
5
5
  source: multi-agent-pipeline
6
6
  ---
@@ -44,7 +44,7 @@ extension WelcomeViewController: ASAuthorizationControllerPresentationContextPro
44
44
  guard let window = view.window else {
45
45
  preconditionFailure("Start Sign in with Apple only while this screen is on screen")
46
46
  }
47
- return window // ASPresentationAnchor() with no scene is deprecated in iOS 26
47
+ return window // ASPresentationAnchor is UIWindow; UIWindow() is deprecated in iOS 26
48
48
  }
49
49
  }
50
50
  ```
@@ -104,22 +104,30 @@ People can revoke the app at any time under Settings > Apple Account >
104
104
  Sign-In & Security, so check on every launch:
105
105
 
106
106
  ```swift
107
- func verifyAppleCredential(for userID: String) {
107
+ @MainActor
108
+ func verifyAppleCredential(for userID: String) async {
108
109
  let provider = ASAuthorizationAppleIDProvider()
109
- provider.getCredentialState(forUserID: userID) { credentialState, _ in
110
- DispatchQueue.main.async {
111
- switch credentialState {
112
- case .authorized: SessionStore.shared.resume()
113
- case .revoked: SessionStore.shared.signOutAndWipe()
114
- case .notFound: SessionStore.shared.presentLogin()
115
- case .transferred: SessionStore.shared.migrateTransferredUser(userID)
116
- @unknown default: SessionStore.shared.presentLogin()
117
- }
118
- }
110
+ let credentialState: ASAuthorizationAppleIDProvider.CredentialState
111
+ do {
112
+ credentialState = try await provider.credentialState(forUserID: userID)
113
+ } catch {
114
+ SessionStore.shared.presentLogin()
115
+ return
116
+ }
117
+ switch credentialState {
118
+ case .authorized: SessionStore.shared.resume()
119
+ case .revoked: SessionStore.shared.signOutAndWipe()
120
+ case .notFound: SessionStore.shared.presentLogin()
121
+ case .transferred: SessionStore.shared.migrateTransferredUser(userID)
122
+ @unknown default: SessionStore.shared.presentLogin()
119
123
  }
120
124
  }
121
125
  ```
122
126
 
127
+ The function runs on the main actor and awaits the async form of
128
+ `getCredentialState(forUserID:completion:)`, so the session calls need no hop
129
+ back to the main queue.
130
+
123
131
  `.transferred` means the app moved to a different developer team and the user
124
132
  identifier must be migrated. Also subscribe to
125
133
  `ASAuthorizationAppleIDProvider.credentialRevokedNotification` and sign the
@@ -133,6 +141,7 @@ backend and keep only what it returns:
133
141
  ```swift
134
142
  enum SessionAPI {
135
143
  struct Session: Decodable { let accessToken: String }
144
+ private static let transport = URLSession(configuration: .ephemeral)
136
145
 
137
146
  static func exchange(token: Data?, code: Data?) async throws {
138
147
  guard let token, let code,
@@ -144,7 +153,7 @@ enum SessionAPI {
144
153
  call.httpMethod = "POST"
145
154
  call.addValue("application/json", forHTTPHeaderField: "Content-Type")
146
155
  let body = try JSONEncoder().encode(["identity_token": jwt, "authorization_code": grant])
147
- let (bytes, reply) = try await URLSession(configuration: .ephemeral).upload(for: call, from: body)
156
+ let (bytes, reply) = try await transport.upload(for: call, from: body)
148
157
  let status = (reply as? HTTPURLResponse)?.statusCode ?? 0
149
158
  if status != 200 { throw URLError(.userAuthenticationRequired) }
150
159
 
@@ -312,8 +321,11 @@ func confirmOwner() async -> Bool {
312
321
  }
313
322
  ```
314
323
 
315
- Info.plist must include `NSFaceIDUsageDescription`; without it the app crashes
316
- on Face ID hardware.
324
+ Info.plist must include `NSFaceIDUsageDescription`, the same rule the
325
+ `swift-security` skill sets for biometric keychain items. The `LAContext` header
326
+ asks apps to supply it: its string is shown the first time the app uses Face ID,
327
+ and once the user denies Face ID for the app, evaluations fail with
328
+ `LAError.biometryNotAvailable`.
317
329
 
318
330
  ## Security boundaries
319
331
 
@@ -326,7 +338,7 @@ Keychain, and `LAContext.evaluatePolicy` on its own must never unlock a
326
338
  protected secret.
327
339
 
328
340
  Hand off to `swift-security`: Keychain architecture and migration,
329
- access-control policy design, CryptoKit, Secure Enclave, certificate pinning
341
+ biometric-gated keychain items and their access-control policy, CryptoKit, Secure Enclave, certificate pinning
330
342
  and trust evaluation, sharing items between apps, hardening local storage, and
331
343
  mapping against OWASP MASVS/MASTG. App and device attestation belongs to `device-integrity`.
332
344
 
@@ -104,7 +104,9 @@ func unlockSettings(reason: String) async throws -> Bool {
104
104
  ### Info.plist
105
105
 
106
106
  Add `NSFaceIDUsageDescription` with a sentence explaining why the app uses
107
- Face ID. Face ID devices crash the app if it is missing.
107
+ Face ID. The `LAContext` header asks for this key; the system shows the string
108
+ the first time the app uses Face ID, and evaluations fail with
109
+ `LAError.biometryNotAvailable` once the user denies Face ID for the app.
108
110
 
109
111
  ### Tuning the context
110
112
 
@@ -113,10 +113,7 @@ func perform(_ task: BGTask, _ job: @escaping @Sendable () async throws -> Void)
113
113
  let finished = (try? await job()) != nil
114
114
  task.setTaskCompleted(success: finished && !Task.isCancelled)
115
115
  }
116
- task.expirationHandler = {
117
- work.cancel()
118
- task.setTaskCompleted(success: false)
119
- }
116
+ task.expirationHandler = { work.cancel() }
120
117
  }
121
118
  ```
122
119
 
@@ -124,7 +121,11 @@ func perform(_ task: BGTask, _ job: @escaping @Sendable () async throws -> Void)
124
121
  processing example below reuses `perform`. Two habits matter: schedule the
125
122
  next run first, so a crash or expiry does not break the chain, and always
126
123
  install an `expirationHandler`, because the system can take the time back at
127
- any moment.
124
+ any moment. The handler only cancels; the work `Task` is the one completion
125
+ path, so `setTaskCompleted(success:)` runs exactly once, with `false` after an
126
+ expiry. That relies on the job honouring cancellation (`URLSession` async calls
127
+ and `Task.checkCancellation()` throw), so the `Task` ends soon after the
128
+ handler fires.
128
129
 
129
130
  `BGTask` is not `Sendable`, yet both the work `Task` and the expiration
130
131
  handler need it, and the handler runs on a queue the framework picks. The
@@ -165,7 +166,7 @@ the app. It differs from the other two types:
165
166
  - Under resource pressure the system may end it, and tasks reporting little
166
167
  progress go first.
167
168
  - The user or the system can cancel it; the `expirationHandler` must stop the
168
- work and remove partial output before completing.
169
+ work and remove partial output, and the cancelled work then completes once.
169
170
  - Its handler is exempt from the register-at-launch rule. `submit` expects
170
171
  an identifier that already has a handler, so register the job's identifier
171
172
  right before submitting it.
@@ -346,7 +347,8 @@ func application(_ app: UIApplication,
346
347
  - **Skipping `setTaskCompleted(success:)`.** The system schedules the app less
347
348
  often afterwards. Complete on every path, errors included.
348
349
  - **No expiration handler.** The task is killed abruptly instead of stopping
349
- cleanly. Cancel the work and complete from the handler.
350
+ cleanly. Cancel the work from the handler and let the cancelled work report
351
+ `success: false`, so completion happens once.
350
352
  - **Asking too often.** A refresh every minute gets throttled hard. Use 15
351
353
  minutes or more, and remember `earliestBeginDate` is a hint.
352
354
  - **Too much work per run.** A ten-minute job inside a refresh task will not
@@ -359,7 +361,7 @@ func application(_ app: UIApplication,
359
361
  - [ ] `UIBackgroundModes` has `fetch` and/or `processing` as needed
360
362
  - [ ] Refresh and processing handlers are registered before launch completes;
361
363
  continued processing handlers are registered before their `submit`
362
- - [ ] `setTaskCompleted(success:)` runs on every path
364
+ - [ ] `setTaskCompleted(success:)` runs exactly once on every path
363
365
  - [ ] `expirationHandler` cancels in-flight work
364
366
  - [ ] The handler schedules the next task
365
367
  - [ ] `earliestBeginDate` is sensible and read as a lower bound only
@@ -94,14 +94,14 @@ func backUp(_ task: BGProcessingTask, assets: [PhotoAsset]) {
94
94
  task.setTaskCompleted(success: false)
95
95
  }
96
96
  }
97
- task.expirationHandler = {
98
- work.cancel()
99
- task.setTaskCompleted(success: false)
100
- }
97
+ task.expirationHandler = { work.cancel() }
101
98
  }
102
99
  ```
103
100
 
104
- When the window closes, the saved cursor lets the next run skip what is done.
101
+ When the window closes, the handler cancels the work, `checkCancellation()`
102
+ throws, and the `catch` reports `success: false`: one completion path, as in
103
+ `perform` and `handleExport`. The saved cursor lets the next run skip what is
104
+ done.
105
105
  The `nonisolated(unsafe)` copy lets the non-`Sendable` `BGTask` be shared by
106
106
  the work `Task` and the expiration handler under Swift 6.
107
107
 
@@ -257,8 +257,8 @@ listed. GPU use also depends on the Background GPU Access entitlement.
257
257
  ### Cancellation from the Live Activity
258
258
 
259
259
  The user can stop the job from the system Live Activity. That arrives through
260
- `expirationHandler`: cancel the work, delete partial output, and complete with
261
- `success: false`.
260
+ `expirationHandler`: cancel the work and delete partial output there; the
261
+ cancelled work then completes with `success: false`, as `handleExport` does.
262
262
 
263
263
  ### Progress drives survival
264
264
 
@@ -33,8 +33,10 @@ There is no "request Bluetooth permission" call. The system asks the first
33
33
  time the app creates a manager, so create one only when the user reaches the
34
34
  feature that needs it. Then look at two things:
35
35
 
36
- - `manager.authorization`: `.denied` and `.restricted` stay that way until the
37
- user changes Settings, so show guidance rather than retrying.
36
+ - `CBManager.authorization` (a class property from iOS 13.1; the instance
37
+ property was deprecated after 13.0): `.denied` and `.restricted` stay that way
38
+ until the user changes Settings, so show guidance rather than retrying. Being a
39
+ class property, it can be read before any manager is created.
38
40
  - `manager.state`: do nothing (scan, connect, advertise, add services) until it
39
41
  is `.poweredOn`.
40
42
 
@@ -22,7 +22,7 @@ once and share it.
22
22
  ```swift
23
23
  import CoreData
24
24
 
25
- final class LibraryStore: @unchecked Sendable {
25
+ final class LibraryStore: Sendable {
26
26
  static let shared = LibraryStore()
27
27
  let container: NSPersistentContainer
28
28
 
@@ -42,6 +42,8 @@ final class LibraryStore: @unchecked Sendable {
42
42
  }
43
43
  ```
44
44
 
45
+ `LibraryStore` is checked `Sendable` with no `@unchecked`: its only stored
46
+ property is a `let` of `NSPersistentContainer`, which the SDK marks `Sendable`.
45
47
  Replace `fatalError` with real recovery in shipping code. When the store syncs
46
48
  through iCloud, create an `NSPersistentCloudKitContainer` instead.
47
49
 
@@ -215,11 +217,13 @@ container.persistentStoreDescriptions = [description]
215
217
  ```swift
216
218
  final class HistoryReader: NSObject, @unchecked Sendable {
217
219
  private let container: NSPersistentContainer
220
+ private let worker: NSManagedObjectContext
218
221
  private let tokenKey: String // one per target: "history.app", "history.widget"
219
222
  private var lastToken: NSPersistentHistoryToken?
220
223
 
221
224
  init(container: NSPersistentContainer, target: String) {
222
225
  self.container = container
226
+ self.worker = container.newBackgroundContext()
223
227
  self.tokenKey = "history.\(target)"
224
228
  super.init()
225
229
  lastToken = Self.loadToken(forKey: tokenKey)
@@ -229,7 +233,6 @@ final class HistoryReader: NSObject, @unchecked Sendable {
229
233
  }
230
234
 
231
235
  @objc private func storeChanged(_ note: Notification) {
232
- let worker = container.newBackgroundContext()
233
236
  worker.perform { [self] in
234
237
  let fetch = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
235
238
  let history = try? worker.execute(fetch) as? NSPersistentHistoryResult
@@ -263,6 +266,13 @@ final class HistoryReader: NSObject, @unchecked Sendable {
263
266
  }
264
267
  ```
265
268
 
269
+ `@unchecked Sendable` rests on one guarantee: after `init` (which sets
270
+ `lastToken` before the observer is registered), `lastToken` is read and written
271
+ only inside `worker.perform`, and one context runs its `perform` blocks one at a
272
+ time on its private queue. Creating a new background context per notification
273
+ would break that, because two notifications could then run on two queues at
274
+ once.
275
+
266
276
  The static merge call is safe from the worker queue; calling
267
277
  `viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())`
268
278
  inside `viewContext.perform` is the equivalent instance form.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: coreml
3
- description: "Swift-side Core ML: loading .mlmodel, .mlpackage and .mlmodelc, predictions via generated classes and MLFeatureProvider, compute units, async and stateful prediction with MLState, MLTensor, MLMultiArray, Vision CoreMLRequest and VNCoreMLRequest, MLComputePlan, multi-model pipelines, deployment and profiling. Use when integrating, reviewing, deploying or profiling a Core ML model in an app. Not for Python conversion (apple-on-device-ai)."
3
+ description: "Swift-side Core ML: loading .mlmodel, .mlpackage and .mlmodelc, predictions via generated classes and MLFeatureProvider, compute units, async and stateful prediction with MLState, MLTensor, MLMultiArray, MLComputePlan, multi-model pipelines, deployment and profiling. Use when integrating, reviewing, deploying or profiling a Core ML model in an app. Not for Python conversion (apple-on-device-ai) or Vision requests (vision-framework)."
4
4
  metadata:
5
5
  source: multi-agent-pipeline
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: cryptokit
3
- description: "CryptoKit primitives in Swift: SHA-2 and SHA-3, HMAC, AES-GCM and ChaChaPoly, P256, P384, P521, Curve25519 and ML-DSA signatures, ECDH with HKDF, HPKE, ML-KEM and X-Wing, Secure Enclave keys. Use when hashing, encrypting, signing or exchanging keys, or migrating from CommonCrypto. Not for Keychain policy (swift-security)."
3
+ description: "CryptoKit in Swift: SHA-2, SHA-3, HMAC, AES-GCM, ChaChaPoly, P256, P384, P521, Curve25519 and ML-DSA signatures, ECDH with HKDF, HPKE, ML-KEM, X-Wing, SecureEnclave.P256 signing and ECDH. Use when hashing, encrypting, signing or exchanging keys, or migrating from CommonCrypto. Not for Keychain policy (swift-security)."
4
4
  metadata:
5
5
  source: multi-agent-pipeline
6
6
  ---
@@ -138,12 +138,16 @@ actor AttestKeyStore {
138
138
  }
139
139
 
140
140
  private func saveKeyID(_ value: String) throws {
141
- SecItemDelete(baseQuery as CFDictionary)
141
+ let data = Data(value.utf8)
142
142
  var item = baseQuery
143
- item[kSecValueData as String] = Data(value.utf8)
143
+ item[kSecValueData as String] = data
144
144
  item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
145
145
  let status = SecItemAdd(item as CFDictionary, nil)
146
- guard status == errSecSuccess else { throw IntegrityError.keyNotGenerated }
146
+ if status == errSecSuccess { return }
147
+ guard status == errSecDuplicateItem else { throw IntegrityError.keyNotGenerated }
148
+ let updated = SecItemUpdate(baseQuery as CFDictionary,
149
+ [kSecValueData as String: data] as CFDictionary)
150
+ guard updated == errSecSuccess else { throw IntegrityError.keyNotGenerated }
147
151
  }
148
152
 
149
153
  private func loadKeyID() -> String? {
@@ -158,8 +162,10 @@ actor AttestKeyStore {
158
162
  }
159
163
  ```
160
164
 
161
- Save, load and delete all build on the same `baseQuery`, so the account
162
- attribute always carries the account suffix. The delete path in the
165
+ `saveKeyID` adds the item and falls back to `SecItemUpdate` on
166
+ `errSecDuplicateItem`, the same add-or-update rule the `swift-security` skill
167
+ sets for every keychain save. Save, load and delete all build on the same
168
+ `baseQuery`, so the account attribute always carries the account suffix. The delete path in the
163
169
  [rejected-key section](references/device-integrity-patterns.md#rejected-keys)
164
170
  reuses it for that reason.
165
171
 
@@ -18,7 +18,7 @@ Scope rules that hold for every run:
18
18
  - **CMS copy is additive.** The annotation holds the copy the content team signed off. It sits in its own columns and never overwrites `new`. When it differs from `new`, the difference is shown with a warning mark, not hidden.
19
19
  - **Figma live first.** CMS annotations and overlay geometry are read from Figma, over REST or through Figma MCP. The design export kept in the repo may be out of date, so it is used only if neither live route works.
20
20
  - **Read-only everywhere** except two writes: the Confluence page and, optionally, a pull request that adds `localizations/<slug>/` to the specs repo.
21
- - **Components are mapped once.** When a reusable component owns a key, the row is flagged `(owned by <Component>)` instead of being mapped again on each screen where the component appears.
21
+ - **Components are mapped once.** When a reusable component owns a key, the row's `newKey` carries `(owned by <Component>)` instead of the key being mapped again on each screen where the component appears.
22
22
  - **Chrome language.** Author-written `element` labels and `note` text follow `--ui-lang`; keys and translation values stay verbatim.
23
23
 
24
24
  It is not a key-minting tool: new keys and their values belong to the resource-authoring flow.
@@ -53,7 +53,7 @@ The helpers in `scripts/` use nothing beyond the Python 3 standard library, apar
53
53
  1. **Enumerate new keys.** Take the union of three sources: the design export's `components-used.md` across every state frame, each component's registry `localizationKeys`, and the keys the screen code wires. Tag each key screen-specific, component-owned or shared. Every key is confirmed by comparing sources, never lifted from one of them.
54
54
  2. **Catch what the cross-reference misses.** Run `scan-screen-keys.py --screen-path <dir>`. Each `error` and `dynamic` hit becomes a mapping row; compare the `static` hits with the union from step 1.
55
55
  3. **New values.** Run `resolve-new-values.py --keys "<all>" --resources-root <res> --langs all --catalog <shipped catalog>`; `--catalog` is never optional.
56
- 4. **CMS copy.** Run `fetch-annotations.py --mapping <map>.json`, falling back from REST to `--from-mcp` and then `--local`. Fill `cms` and `cmsNodeId` on each row, and send any TR without EN back to the content team.
56
+ 4. **CMS copy.** Run `fetch-annotations.py --mapping <map>.json [--local <export-screen-dir>]`: it reads REST and falls back to `--local` when REST fails. When REST is rate-limited or has no token, dump the annotations through Figma MCP and run it again with `--from-mcp <dump>.json`. Fill `cms` and `cmsNodeId` on each row, and send any TR without EN back to the content team.
57
57
  5. **Trace legacy keys** per platform: spec, then view controller, view model and cell presentation models on iOS; layout `@string/` on Android; call sites on web.
58
58
  6. **Legacy values.** Run `resolve-legacy-values.py --keys "<legacy>" --snapshot-root <res>/Localization/Legacy --langs all`; if the snapshot is out of date, `fetch-legacy-labels.py` refreshes it. A value that cannot be found stays blank and gets a note.
59
59
  7. **Assemble the mapping JSON**: one row per element, plus `screenshot`, `figmaFileKey` and `legacyKeyPrefix` where they apply. Schema: [format-and-output](reference/format-and-output.md).
@@ -76,7 +76,7 @@ When the Figma frame carries no annotations, say so, explain that keys will be m
76
76
 
77
77
  Edge cases:
78
78
 
79
- - **Component-owned key**: note `(owned by <Component>)`; do not map it twice.
79
+ - **Component-owned key**: suffix the `newKey` with `(owned by <Component>)`, where the gate looks for it (a note alone does not exempt the row); do not map it twice.
80
80
  - **Legacy keys differ across platforms**: keep every platform column that has a key, default to `review`.
81
81
  - **`tr` equals `en`**: note it as untranslated whatever the verdict.
82
82
  - **CMS present and different from `new`**: the renderer adds the warning mark; mention it in the note. CMS copy is context, not an input to the verdict.
@@ -74,7 +74,7 @@
74
74
  },
75
75
  {
76
76
  "element": "Password strength hint (owned by PasswordField)",
77
- "newKey": "PasswordField.StrengthHint",
77
+ "newKey": "PasswordField.StrengthHint (owned by PasswordField)",
78
78
  "new": {
79
79
  "en": "Use at least 8 characters",
80
80
  "tr": "En az 8 karakter kullan"
@@ -45,7 +45,7 @@ Keep `new` and `legacy` fully eight-keyed. The summary table shows old and new `
45
45
  | 1 | Nav title with no iOS legacy key (`(nav title)` marker) | review |
46
46
  | 2 | One shared key, identical on all three platforms | reuse |
47
47
  | 3 | Placeholder with no legacy counterpart | new |
48
- | 4 | Component-owned `PasswordField.StrengthHint` with cross-platform key drift | review |
48
+ | 4 | Component-owned `PasswordField.StrengthHint (owned by PasswordField)` with cross-platform key drift | review |
49
49
  | 5 | Duplicate-email error found only by the scanner; the Android key has a typo | review |
50
50
  | 6 | CTA whose web key is namespaced (`signup.continue_button`) | review |
51
51
  | 7 | `tr` equal to `en` in the redesign source | reuse, flagged untranslated |
@@ -72,7 +72,7 @@ The `--ui-lang` switch (`en` default, `tr`) changes headings, legend, labels and
72
72
 
73
73
  ## Overlay and keyshots
74
74
 
75
- - `render-overlay.py` writes `<slug>.overlay.png` (or `<slug>.overlay.html` when no Chrome, Chromium or Edge is installed) and `<slug>.overlay.manifest.json` with `screen`, `file`, `fileKey`, `nodeId`, `cardCount`, `annotatedCount`, `width`, `height`, `scale`. Pink cards carry CMS copy; gray cards show the on-screen text and await copy.
75
+ - `render-overlay.py` writes `<slug>.overlay.png` (or `<slug>.overlay.html` when no Chrome, Chromium or Edge is installed) and `<slug>.overlay.manifest.json` with `screen`, `file`, `fileKey`, `nodeId`, `cardCount`, `annotatedCount`, `width`, `height`, `scale`. Pink cards carry CMS copy; gray cards show the on-screen text and await copy. `--ui-lang en/tr` (default `en`) sets the card labels.
76
76
  - `render-key-shots.py` writes `keyshots/keyshot__<NN>__<key>.png` (or `.html` crops with `"fallback": "html"`) and `<slug>.keyshots.manifest.json` with `screen`, `slug`, `pad`, `scale`, `dir`, `shots[]` (`row`, `key`, `element`, `file`, `pageNode`, `cropW`, `cropH`) and `missing[]` (`row`, `key`, `reason`).
77
77
 
78
78
  ## CMS import spreadsheet
@@ -84,7 +84,8 @@ Nine fixed columns, spelled exactly like this:
84
84
  `Channel`, `Property Group`, `Property Module`, `Key`, `EN Value`, `TR Value`, `AR Value`, `Anotation EN`, `Anotation TR`
85
85
 
86
86
  - `Channel` comes from `--channel` (default `Mobile`).
87
- - `Key` is `newKey` with any `(owned by ...)` suffix removed.
87
+ - `Key` is `newKey` with its trailing marker removed: any parenthesised suffix such as `(owned by ...)` or `(component: ...)`.
88
+ - A row whose key is empty after that, starts with `(dynamic`, or contains a `<placeholder>` has no literal key to import: it is left out of the sheet and listed on stderr.
88
89
  - Values come from `new.en/tr/ar`, annotations from `cms.en/tr`; missing means blank.
89
90
  - Property Group and Module: row overrides win (each fills its own half). Otherwise the key decides, in order: contains `error` gives Errors; `lookup` gives Lookups; `validation`, `invalid` or a `.valid` ending gives Core / Validation; `field`, `placeholder`, `hint` or `label` gives Core / Fields; a first namespace segment or any word matching a domain list gives the domain group (`Domains`) and the domain name; anything else is Core / Common.
90
91
  - Default domains: auth (auth, login, signin, sign-in, register, signup, otp, password), account (account, profile, settings, preferences), checkout (checkout, cart, payment, billing, order), search (search, filter, results). A `--taxonomy` JSON object is merged one level deep over these defaults.
@@ -95,14 +96,14 @@ Nine fixed columns, spelled exactly like this:
95
96
 
96
97
  | Check | Hard? |
97
98
  |---|---|
98
- | Each `newKey` is wired in the screen code (exact match), or exempt as component-owned or dynamic. A key whose last segment only appears as a word is `weak`; neither is `unverified` | unverified: hard |
99
+ | Each `newKey` is wired in the screen code (exact match), or exempt as component-owned or dynamic. A `LocalizationStringKey` chain counts as a key only with two or more segments, so `extension LocalizationStringKey.Checkin` is not one. A key whose last segment only appears as a word is `weak`; neither is `unverified` | unverified: hard |
99
100
  | Keys the code wires that no row maps (`codeKeysNotInMap`) | hard |
100
101
  | `reuse` / `review` row without any legacy `en` or `tr` value | hard |
101
- | Non-`new` row with an empty `new.tr` | soft |
102
+ | Non-`new` row with an empty or whitespace-only `new.tr` | soft |
102
103
  | `new.tr` equal to `new.en` (untranslated) | soft |
103
104
  | With `--resources-root`: no `Suggested/<Key>.json` in the snapshot or the source layout | soft |
104
105
 
105
- A row is dynamic-exempt when its key starts with `(dynamic`, contains a `<placeholder>`, its element mentions dynamic, or its note says "enumerate at runtime". Exit 2 when any hard issue exists; publish only after fixing them (drop or fix UNVERIFIED keys, add rows for wired-but-unmapped keys) or after a conscious, stated waiver.
106
+ A row is component-exempt when its `newKey` carries a marker naming the owner: `(owned by <Component>)`, `(component ...)` or the Turkish synonym `(bilesen ...)` written with a Turkish s-cedilla (U+015F); the element and note text do not count. A row is dynamic-exempt when its key starts with `(dynamic`, contains a `<placeholder>`, its element mentions dynamic (or the Turkish synonym "dinamik"), or its note says "enumerate at runtime". Folders named `.git` or `Generated` are skipped when scanning; `.github` and similar names are read. Exit 2 when any hard issue exists; publish only after fixing them (drop or fix UNVERIFIED keys, add rows for wired-but-unmapped keys) or after a conscious, stated waiver.
106
107
 
107
108
  ## Script reference
108
109
 
@@ -113,7 +114,7 @@ A row is dynamic-exempt when its key starts with `(dynamic`, contains a `<placeh
113
114
  | `resolve-legacy-values.py` | `--keys` (required), `--langs`, `--snapshot-root`, `--live`, `--endpoint`, `--header K=V`, `--headers-file`, `--env`, `--status-field`, `--fail-status CODE=message`, `--timeout`, `--plist-root`, `--prefix` |
114
115
  | `fetch-legacy-labels.py` | `--out`, `--endpoint` (both required), `--header`, `--headers-file`, `--langs` (default `all`), `--env`, `--status-field`, `--fail-status`, `--timeout` |
115
116
  | `fetch-annotations.py` | `--file`, `--nodes`, `--mapping`, `--from-mcp`, `--local`, `--source auto/rest/mcp/local`, `--token`, `--ca-file`, `--out` |
116
- | `render-overlay.py` | `--mapping` (required), `--spec`, `--file`, `--nodes`, `--scale`, `--token`, `--ca-file`, `--out`, `--slug` |
117
+ | `render-overlay.py` | `--mapping` (required), `--spec`, `--file`, `--nodes`, `--scale`, `--token`, `--ca-file`, `--out`, `--slug`, `--ui-lang en/tr` |
117
118
  | `render-key-shots.py` | `--mapping` (required), `--spec` (repeatable), `--file`, `--nodes`, `--pad` (default 150), `--scale`, `--token`, `--ca-file`, `--out`, `--slug` |
118
119
  | `build-artifact.py` | `mapping` (or `-`), `--out`, `--slug`, `--ui-lang en/tr`, `--docx`, `--pdf`, `--all`, `--print-upload` |
119
120
  | `build-spreadsheet.py` | `mapping` (or `-`), `--out`, `--slug`, `--channel`, `--taxonomy`, `--csv`, `--csv-only` |
@@ -55,11 +55,11 @@ python3 scripts/publish-confluence.py \
55
55
  - **Transport**: `curl` carries every request. The Authorization header is written to a temporary config file with mode 600, handed over with `-K` and removed at the end; JSON bodies also travel through a temporary file.
56
56
  - **Idempotent**: the lookup is `GET /rest/api/content?spaceKey=&title=&type=page&expand=version&limit=5`, which reads the database rather than the CQL index. A hit is updated with `PUT` and the next version number, reported as `updated (vN->vN+1)`; a miss is created with `POST` below the parent and reported as `created`. A second run lands on the same page id.
57
57
  - **Attachments**: the screenshot first, then the overlay, then each `.png` listed in the keyshots manifest, then every `--attach` file. When a file of that name is already attached, a new version is uploaded to `.../child/attachment/<id>/data` instead of adding a second copy.
58
- - **Inline spreadsheet**: an `--attach` file with the extension `.xlsx`, `.xls`, `.csv`, `.docx` or `.pdf` is additionally shown on the page: a heading "CMS Aktarım Dosyası" is appended, then one `view-file` macro per such file.
58
+ - **Inline spreadsheet**: an `--attach` file with the extension `.xlsx`, `.xls`, `.csv`, `.docx` or `.pdf` is additionally shown on the page: a heading is appended (`--embed-heading`, default "CMS Import File"; pass the team's own wording, for example a Turkish title, to change it), then one `view-file` macro per such file.
59
59
  - **Dry run**: with `--dry-run` nothing is sent; the script prints base, space, parent, title, body size, the attachment list and the action it would take.
60
60
  - **Output**: a JSON object with `action`, `id`, `title`, `attachments` and `url`.
61
61
 
62
- Running `build-artifact.py --print-upload` shows the publish command that fits the mapping, plus a hand-written Bearer `curl` version of it that pulls the token out of the keychain. When neither route works, paste the storage XML into the Confluence editor and attach the images under their file names; the XML already points at them by name.
62
+ Running `build-artifact.py --print-upload` shows the publish command that fits the mapping, plus a hand-written Bearer `curl` version of it that pulls the token out of the keychain. File paths in the command are joined with `--out` (overlay, screenshot and keyshots manifest are looked up next to the mapping first), every argument is shell-quoted, and the curl config is created under `umask 077`. When neither route works, paste the storage XML into the Confluence editor and attach the images under their file names; the XML already points at them by name.
63
63
 
64
64
  ## Option B: specs-repo pull request
65
65
 
@@ -46,7 +46,7 @@ python3 scripts/resolve-new-values.py --keys "<k1>,<k2>" --resources-root <res>
46
46
  --catalog <path/to/shipped/Localizable.xcstrings>
47
47
  ```
48
48
 
49
- Always pass `--catalog`. A missing Suggested file means "not in this snapshot", not "unauthored": the snapshot can lag the shipped catalog. With the catalog the value still resolves and the script names the stale snapshot so it can be refreshed with `snapshot-resources.sh`. Only a key absent from both the snapshot and the catalog is truly unauthored. Never open an authoring request for a key the catalog resolved.
49
+ Always pass `--catalog`. A missing Suggested file means "not in this snapshot", not "unauthored": the snapshot can lag the shipped catalog. With the catalog the value still resolves and the script names the stale snapshot so it can be refreshed with `snapshot-resources.sh`. Only a key absent from both the snapshot and the catalog is truly unauthored. A catalog entry that is empty, or has no value in any requested language, counts as absent: the lookup then tries the normalised key form and otherwise lists the key as unresolved. Never open an authoring request for a key the catalog resolved.
50
50
 
51
51
  ## CMS copy (Figma annotations)
52
52
 
@@ -58,6 +58,8 @@ python3 scripts/fetch-annotations.py --out _annotations.json --from-mcp <raw-mcp
58
58
  python3 scripts/fetch-annotations.py --out _annotations.json --local <design-export>/screens/<frame>
59
59
  ```
60
60
 
61
+ The default source is REST; when it fails and `--local` is given, the export's `tree.json` is read instead. `--from-mcp` is never tried automatically: after a rate limit (HTTP 429), dump the annotations through Figma MCP and rerun with that file. Node-ids in the URL form `1024-4150` are read as `1024:4150`; an annotation without a node-id is kept as its own entry, never merged with another.
62
+
61
63
  Pair every annotation with its row through `nodeId`: the id is saved in the row as `cmsNodeId`, and the copy goes into `cms: {tr, en}`. An element without an annotation keeps a blank CMS cell. The script lists nodes that have TR but no EN; send those back to the content team.
62
64
 
63
65
  ## Legacy keys: trace, do not skim
@@ -91,6 +93,7 @@ python3 scripts/resolve-legacy-values.py --keys "Continue,EmailAddress" \
91
93
 
92
94
  - Reading the snapshot needs no network. `fetch-legacy-labels.py` refreshes it and is the only step that goes online.
93
95
  - `--live` checks against the service directly; if the live call fails, the script falls back to the snapshot, then to the plists.
96
+ - With none of `--snapshot-root`, `--plist-root` or `--endpoint`, the script stops with a message naming them.
94
97
  - `--plist-root` reads the bundled `<lang>.lproj/language.plist` as a last resort. It is usually a stale subset, so a hit from it deserves a note.
95
98
  - **Backend prefix.** When the backend stores `Mobile-Continue` but the app asks for `Continue`, pass `--prefix Mobile-`, keep the mapping keys bare, and set the mapping's `legacyKeyPrefix` so the table shows the full stored key.
96
99
 
@@ -108,7 +111,7 @@ The overlay does not depend on annotations. With zero annotations every keyed el
108
111
  "nodes": [{"id": "n1", "x": 16, "y": 64, "w": 220, "h": 28, "side": "left"}]}]}
109
112
  ```
110
113
 
111
- `image` is either a data URL or a PNG path, resolved from the folder of the spec file. `side` is `left` (default) or `right`: it puts the card on the side where its target sits, which two-column layouts need. Each side is numbered and stacked independently, and right-side connectors are mirrored.
114
+ Every page needs `frame` with numeric `w` and `h`; a page without it stops the script with an error naming the page. `render-overlay.py` draws only the first page and warns when there are more; `render-key-shots.py` reads every page. `image` is either a data URL or a PNG path, resolved from the folder of the spec file. `side` is `left` (default) or `right`: it puts the card on the side where its target sits, which two-column layouts need. Each side is numbered and stacked independently, and right-side connectors are mirrored.
112
115
 
113
116
  The export's `tree.json` stops at component instances and carries no geometry per label, so take only the frame node-id from it.
114
117
 
@@ -0,0 +1,100 @@
1
+ """Key patterns, mapping-row marker rules and text helpers shared by the scripts.
2
+
3
+ Every script that scans keys, names output files or orders languages imports
4
+ this module, so the scanner, the gate, the CMS sheet and the overlay agree on
5
+ what a wired key is, which rows are component-owned or dynamic, which files are
6
+ source, how a screen name becomes a file slug and which order languages take.
7
+ """
8
+ import os
9
+ import re
10
+
11
+ EXTENSIONS = {".swift", ".kt", ".kts", ".xml", ".ts", ".tsx", ".js", ".jsx", ".vue"}
12
+ SKIP_DIRS = {".git", "Generated"}
13
+ LANGS = ["en", "tr", "ar"] + sorted(["ru", "it", "fr", "es", "de"])
14
+
15
+ WORD = "A-Za-z0-9_"
16
+ IDENT = rf"[A-Za-z_][{WORD}]*"
17
+ LSK = re.compile(rf"LocalizationStringKey((?:\.{IDENT})+)")
18
+ LITERAL_KEY = re.compile(rf"\"([{WORD}.]+)\"\.localized\b")
19
+ WEB_T = re.compile(r"(?<![\w$])t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
20
+ WEB_DOLLAR_T = re.compile(r"\$t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
21
+ INTERPOLATION = re.compile(r"\\\(|\$\{|\$[A-Za-z_]")
22
+
23
+ TRAILING_MARKER = re.compile(r"\s*\([^()]*\)\s*$")
24
+ COMPONENT_MARKER = re.compile(r"\([^)]*(owned by|bile\u015fen|component)[^)]*\)", re.IGNORECASE)
25
+ PLACEHOLDER = re.compile(r"<[^<>]+>")
26
+ DYNAMIC_WORDS = ("dynamic", "dinamik")
27
+ RUNTIME_NOTE = "enumerate at runtime"
28
+
29
+ XML_INVALID = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f]")
30
+
31
+
32
+ def strip_accessor(chain):
33
+ segments = chain.strip(".").split(".")
34
+ while segments and segments[-1].startswith("localized"):
35
+ segments.pop()
36
+ return ".".join(segments)
37
+
38
+
39
+ def lsk_keys(text):
40
+ for hit in LSK.finditer(text):
41
+ key = strip_accessor(hit.group(1))
42
+ if key.count(".") >= 1:
43
+ yield key
44
+
45
+
46
+ def is_interpolated(literal):
47
+ return bool(INTERPOLATION.search(literal))
48
+
49
+
50
+ def normalize_key(key):
51
+ return ".".join(seg[:1].upper() + seg[1:] for seg in key.split("."))
52
+
53
+
54
+ def source_files(root):
55
+ if os.path.isfile(root):
56
+ yield root
57
+ return
58
+ for current, dirs, files in os.walk(root):
59
+ relative = os.path.relpath(current, root)
60
+ parts = [] if relative == os.curdir else relative.split(os.sep)
61
+ if SKIP_DIRS.intersection(parts):
62
+ dirs[:] = []
63
+ continue
64
+ for name in sorted(files):
65
+ if os.path.splitext(name)[1] in EXTENSIONS:
66
+ yield os.path.join(current, name)
67
+
68
+
69
+ def raw_key(row):
70
+ return str(row.get("newKey") or "").strip()
71
+
72
+
73
+ def clean_key(new_key):
74
+ text = str(new_key or "").strip()
75
+ if text.startswith("(dynamic"):
76
+ return text
77
+ return TRAILING_MARKER.sub("", text)
78
+
79
+
80
+ def is_component(row):
81
+ return bool(COMPONENT_MARKER.search(raw_key(row)))
82
+
83
+
84
+ def key_is_dynamic(key):
85
+ return key.startswith("(dynamic") or bool(PLACEHOLDER.search(key))
86
+
87
+
88
+ def is_dynamic(row, key):
89
+ element = str(row.get("element") or "").lower()
90
+ note = str(row.get("note") or "").lower()
91
+ return key_is_dynamic(key) or any(word in element for word in DYNAMIC_WORDS) or RUNTIME_NOTE in note
92
+
93
+
94
+ def xml_text(value):
95
+ return XML_INVALID.sub("", str(value))
96
+
97
+
98
+ def slug(text):
99
+ value = re.sub(r"[\W_]+", "-", str(text or "").strip().lower()).strip("-")
100
+ return value or "screen"