shortcuts-playground 1.2.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 (68) hide show
  1. package/README.md +54 -0
  2. package/agents/shortcut-builder.md +172 -0
  3. package/agents/shortcut-remixer.md +239 -0
  4. package/commands/build-shortcut.md +32 -0
  5. package/commands/remix-shortcut.md +34 -0
  6. package/opencode.json +30 -0
  7. package/package.json +47 -0
  8. package/plugin/validator.ts +179 -0
  9. package/skills/shortcuts-playground/ACTIONS.md +713 -0
  10. package/skills/shortcuts-playground/APPINTENTS.md +2760 -0
  11. package/skills/shortcuts-playground/AUTOMATION_TRIGGERS.md +133 -0
  12. package/skills/shortcuts-playground/BEST_PRACTICES.md +357 -0
  13. package/skills/shortcuts-playground/CHANGELOG.md +585 -0
  14. package/skills/shortcuts-playground/CONTROL_FLOW.md +777 -0
  15. package/skills/shortcuts-playground/DATE_TIME.md +75 -0
  16. package/skills/shortcuts-playground/EXAMPLES.md +738 -0
  17. package/skills/shortcuts-playground/FILTERS.md +697 -0
  18. package/skills/shortcuts-playground/HEALTHKIT.md +317 -0
  19. package/skills/shortcuts-playground/ICONS_AND_COLORS.md +89 -0
  20. package/skills/shortcuts-playground/JAVASCRIPT_WEBPAGE.md +51 -0
  21. package/skills/shortcuts-playground/PARAMETER_TYPES.md +1117 -0
  22. package/skills/shortcuts-playground/PLIST_FORMAT.md +296 -0
  23. package/skills/shortcuts-playground/README.md +77 -0
  24. package/skills/shortcuts-playground/SKILL.md +459 -0
  25. package/skills/shortcuts-playground/THIRD_PARTY_ACTIONS.md +77 -0
  26. package/skills/shortcuts-playground/TOOLKIT_SNAPSHOT.md +62 -0
  27. package/skills/shortcuts-playground/URL_SCHEMES.md +59 -0
  28. package/skills/shortcuts-playground/VARIABLES.md +569 -0
  29. package/skills/shortcuts-playground/assets/shortcuts-small.svg +14 -0
  30. package/skills/shortcuts-playground/assets/shortcuts.png +0 -0
  31. package/skills/shortcuts-playground/data/healthkit-ios26.2-reference.json +2603 -0
  32. package/skills/shortcuts-playground/data/macos27-shortpy-grounding.json +4085 -0
  33. package/skills/shortcuts-playground/data/macos27-workflow-trigger-samples.json +1 -0
  34. package/skills/shortcuts-playground/data/shortcuts-glyph-synonyms.json +5102 -0
  35. package/skills/shortcuts-playground/data/shortcuts-icon-colors.json +107 -0
  36. package/skills/shortcuts-playground/data/shortcuts-official-glyph-mapping.json +509 -0
  37. package/skills/shortcuts-playground/data/toolkit-v63-tool-ids.json +1806 -0
  38. package/skills/shortcuts-playground/data/toolkit-v78-first-party-enum-cases.json +1 -0
  39. package/skills/shortcuts-playground/data/toolkit-v78-first-party-parameter-keys.json +1 -0
  40. package/skills/shortcuts-playground/data/toolkit-v78-ios27-tool-ids.json +1222 -0
  41. package/skills/shortcuts-playground/data/toolkit-v78-tool-ids.json +2745 -0
  42. package/skills/shortcuts-playground/data/toolkit-v78-trigger-parameter-keys.json +1051 -0
  43. package/skills/shortcuts-playground/golden-shortcuts/index.jsonl +19 -0
  44. package/skills/shortcuts-playground/golden-shortcuts/xml/1be4dde95b794253bf82438e201b33e7.xml +174 -0
  45. package/skills/shortcuts-playground/golden-shortcuts/xml/2e0fb675e45948aaacee7e534f910492.xml +1016 -0
  46. package/skills/shortcuts-playground/golden-shortcuts/xml/332c12a0060043b388b22b806be7ab58.xml +1924 -0
  47. package/skills/shortcuts-playground/golden-shortcuts/xml/3dd4ee24e43f464f92adaa70a0311eaa.xml +1177 -0
  48. package/skills/shortcuts-playground/golden-shortcuts/xml/4d102301c6e646faa7a8a221f4f4ec98.xml +321 -0
  49. package/skills/shortcuts-playground/golden-shortcuts/xml/51cc4e26d1044893a0c3f2f3630cf2d2.xml +1933 -0
  50. package/skills/shortcuts-playground/golden-shortcuts/xml/623e7f1ca5f948e2bd53811fec63e544.xml +136 -0
  51. package/skills/shortcuts-playground/golden-shortcuts/xml/6a18b76843ac45c384ac3400f3740997.xml +1791 -0
  52. package/skills/shortcuts-playground/golden-shortcuts/xml/71f0cacb0f604b399b76c5dcb7286e7c.xml +2228 -0
  53. package/skills/shortcuts-playground/golden-shortcuts/xml/807525ed9f974829bc8494defac923a8.xml +259 -0
  54. package/skills/shortcuts-playground/golden-shortcuts/xml/8ab0d39826ab4249be58672763caa3ba.xml +420 -0
  55. package/skills/shortcuts-playground/golden-shortcuts/xml/91c45fd3fd3b427897d9ba485efb1227.xml +800 -0
  56. package/skills/shortcuts-playground/golden-shortcuts/xml/97be626bb25c41709d175646a7f6d8f2.xml +424 -0
  57. package/skills/shortcuts-playground/golden-shortcuts/xml/ae59e10d409348f9bd33894f03f9beb4.xml +568 -0
  58. package/skills/shortcuts-playground/golden-shortcuts/xml/afa83b6be811483b9c32189c41eb9312.xml +391 -0
  59. package/skills/shortcuts-playground/golden-shortcuts/xml/cfdb083b857e4ac189629fa386d27cdc.xml +193 -0
  60. package/skills/shortcuts-playground/golden-shortcuts/xml/e01cebe192d64b2fbca80204d03d92ab.xml +111 -0
  61. package/skills/shortcuts-playground/golden-shortcuts/xml/ef669bcf8bad489d9ef4b88bfaf5772f.xml +780 -0
  62. package/skills/shortcuts-playground/golden-shortcuts/xml/f44f5caf5e3e48d4817e73af450c4404.xml +570 -0
  63. package/skills/shortcuts-playground/scripts/generate_healthkit_reference.py +394 -0
  64. package/skills/shortcuts-playground/scripts/lookup_action_grounding.py +1164 -0
  65. package/skills/shortcuts-playground/scripts/select_shortcut_icon_color.py +597 -0
  66. package/skills/shortcuts-playground/scripts/test_random_mixed_shortcuts.py +1067 -0
  67. package/skills/shortcuts-playground/scripts/test_wiring_regressions.py +2247 -0
  68. package/skills/shortcuts-playground/scripts/validate_shortcut.py +4526 -0
@@ -0,0 +1,4526 @@
1
+ #!/usr/bin/env python3
2
+ """Validate Shortcuts XML/.shortcut outputs for common structural errors.
3
+
4
+ Usage:
5
+ python3 scripts/validate_shortcut.py /path/to/Shortcut.xml
6
+ python3 scripts/validate_shortcut.py /path/to/Shortcut.shortcut
7
+
8
+ Exit code:
9
+ 0 = pass
10
+ 1 = fail
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import plistlib
17
+ import re
18
+ import subprocess
19
+ import sys
20
+ import urllib.parse
21
+ from pathlib import Path
22
+ from typing import Optional, Tuple
23
+
24
+ ALLOWED_EMPTY_KEYS = {
25
+ # User-configurable or device-specific fields that may be empty at build time
26
+ "WFHomeAccessory",
27
+ "WFHomeAction",
28
+ "WFHomeScene",
29
+ "WFHomeService",
30
+ "WFHomeRoom",
31
+ "WFHomeZone",
32
+ "WFHome",
33
+ "WFContact",
34
+ "WFContacts",
35
+ "WFPerson",
36
+ "WFPeople",
37
+ "WFRecipient",
38
+ "WFRecipients",
39
+ }
40
+
41
+ # Keys where whitespace is meaningful and should not be treated as empty.
42
+ WHITESPACE_SIGNIFICANT_KEYS = {
43
+ "WFTextCustomSeparator",
44
+ "WFReplaceTextFind",
45
+ }
46
+
47
+ # Keys where an empty string can be intentional and valid.
48
+ ALLOW_EMPTY_STRING_KEYS = {
49
+ "WFReplaceTextReplace",
50
+ }
51
+
52
+ # Matches a UUID where every hex character is the same — e.g.
53
+ # 11111111-1111-1111-1111-111111111111 or AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA.
54
+ # These are placeholder sequences that agents used to produce instead of real
55
+ # random UUIDs. They're formally valid (36 chars, right shape, uppercase) but
56
+ # break cross-shortcut uniqueness and are a tell that the generator cheated.
57
+ # The agent prompt now requires uuidgen; this regex is the backstop.
58
+ #
59
+ # Segment lengths: 8-4-4-4-12 hex characters. Backreference counts after the
60
+ # initial capture: 7 + 4 + 4 + 4 + 12 = 31 more chars matching the captured
61
+ # hex char, 32 total, plus 4 dashes = 36-char UUID.
62
+ REPEATING_UUID_RE = re.compile(
63
+ r"\b([0-9A-F])\1{7}-\1{4}-\1{4}-\1{4}-\1{12}\b"
64
+ )
65
+ UUID_RE = re.compile(
66
+ r"^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$"
67
+ )
68
+
69
+ # Verified against an Apple-built sample shortcut covering all condition codes.
70
+ # All conditional codes use explicit WFInput as a Type=Variable wrapper. There
71
+ # is no "implicit input" mode; the previously documented numeric-implicit pattern
72
+ # was incorrect.
73
+
74
+ STRING_CONDITIONS = {
75
+ "Equals",
76
+ "Does Not Equal",
77
+ "Contains",
78
+ "Does Not Contain",
79
+ "Begins With",
80
+ "Ends With",
81
+ }
82
+
83
+ # Codes whose WFCondition value requires a non-empty WFConditionalActionString
84
+ # literal alongside the WFInput variable reference.
85
+ STRING_CONDITION_CODES = {
86
+ 4, # is (string equals)
87
+ 5, # is not (string inequality)
88
+ 8, # begins with
89
+ 9, # ends with
90
+ 99, # contains
91
+ 999, # does not contain
92
+ }
93
+
94
+ NUMBER_CONDITIONS = {
95
+ "Is",
96
+ "Is Not",
97
+ "Is Greater Than",
98
+ "Is Greater Than Or Equal To",
99
+ "Is Less Than",
100
+ "Is Less Than Or Equal To",
101
+ "Is Between",
102
+ }
103
+
104
+ # Codes whose WFCondition value requires WFNumberValue. Code 1003 also requires
105
+ # WFAnotherNumber for the upper bound of a "between" check.
106
+ NUMBER_CONDITION_CODES = {
107
+ 0, # is less than
108
+ 1, # is less than or equal to
109
+ 2, # is greater than
110
+ 3, # is greater than or equal to
111
+ 1003, # is between (also requires WFAnotherNumber)
112
+ }
113
+
114
+ # Codes that test for variable existence and need NEITHER WFConditionalActionString
115
+ # nor WFNumberValue. They still require WFInput.
116
+ EXISTENCE_CONDITION_CODES = {
117
+ 100, # has any value
118
+ 101, # does not have any value
119
+ }
120
+
121
+ # All codes the validator recognizes. Anything outside this set is rejected.
122
+ ALL_CONDITION_CODES = STRING_CONDITION_CODES | NUMBER_CONDITION_CODES | EXISTENCE_CONDITION_CODES
123
+
124
+ LIST_PRODUCING_ACTIONS = {
125
+ "is.workflow.actions.list",
126
+ "is.workflow.actions.additemtolist",
127
+ }
128
+ ADD_ITEM_TO_LIST_POSITIONS = {
129
+ "Beginning",
130
+ "End",
131
+ "Index",
132
+ }
133
+ CHOOSE_FROM_LIST_BOOLEAN_KEYS = {
134
+ "WFChooseFromListActionSelectAll",
135
+ "WFChooseFromListActionSelectMultiple",
136
+ }
137
+
138
+ STORED_CONTENT_ACTIONS = {
139
+ "is.workflow.actions.deletestoredcontent",
140
+ "is.workflow.actions.getstoredcontent",
141
+ "is.workflow.actions.setstoredcontent",
142
+ }
143
+
144
+ ON_SCREEN_CONTEXT_SCOPES = {
145
+ "All Visible",
146
+ "Focused App Only",
147
+ }
148
+
149
+ VPN_OPERATIONS = {
150
+ "Connect",
151
+ "Disconnect",
152
+ "Set On Demand",
153
+ "Toggle",
154
+ "Toggle On Demand",
155
+ }
156
+ VPN_SORT_PROPERTIES = {
157
+ "Name",
158
+ "Random",
159
+ "Server Address",
160
+ }
161
+ VPN_COMPOUND_TYPES = {
162
+ 0,
163
+ 1,
164
+ "0",
165
+ "1",
166
+ "Any",
167
+ "All",
168
+ }
169
+ FIND_PLACES_SORT_ORDERS = {
170
+ "Distance",
171
+ "Relevance",
172
+ }
173
+ APPEND_NOTE_OPERATIONS = {
174
+ "append",
175
+ "prepend",
176
+ }
177
+ MULTITASKING_MODES = {
178
+ "fullScreenApps",
179
+ "stageManager",
180
+ "windowedApps",
181
+ }
182
+ MATH_OPERATIONS = {
183
+ "-",
184
+ "×",
185
+ "÷",
186
+ "…",
187
+ }
188
+ OPEN_APP_WINDOWING_FORMATS = {
189
+ "Full Screen",
190
+ "Left",
191
+ "Right",
192
+ "Top",
193
+ "Bottom",
194
+ "Top Leading",
195
+ "Top Trailing",
196
+ "Bottom Leading",
197
+ "Bottom Trailing",
198
+ "Left Third",
199
+ "Middle Third",
200
+ "Right Third",
201
+ }
202
+
203
+ TOOLKIT_SNAPSHOT_MIN_MACOS_MAJOR = {
204
+ # ToolKit v78 was captured from macOS 27 Golden Gate. Keep this gated so
205
+ # older macOS hosts do not accidentally validate shortcuts they cannot run.
206
+ "toolkit-v78": 27,
207
+ # iOS Simulator v78 metadata is also OS 27-era. It remains target-gated so
208
+ # iOS-only identifiers do not validate for older target versions.
209
+ "toolkit-v78-ios27": 27,
210
+ }
211
+ TARGET_MACOS_ENV_VARS = (
212
+ "SHORTCUTS_PLAYGROUND_TARGET_MACOS",
213
+ "CLAUDE_PLUGIN_OPTION_TARGET_MACOS",
214
+ )
215
+ TARGET_PLATFORM_ENV_VARS = (
216
+ "SHORTCUTS_PLAYGROUND_TARGET_PLATFORM",
217
+ "CLAUDE_PLUGIN_OPTION_TARGET_PLATFORM",
218
+ )
219
+ OS27_PARAMETER_KEYS_BY_ACTION = {
220
+ # Cross-checked against the Automators OS 26 -> 27 action delta and local
221
+ # ToolKit v78 metadata. These action identifiers may exist on older OSes,
222
+ # but the listed top-level parameter keys are OS 27-era metadata.
223
+ "com.apple.Safari.CreateNewTabGroup": {"contents"},
224
+ "com.apple.mobilesafari.CreateNewTabGroup": {"contents"},
225
+ "com.apple.mobilenotes.SharingExtension": {"interpretAsMarkdown"},
226
+ "is.workflow.actions.appendnote": {
227
+ "ignoreWhitespace",
228
+ "interpretAsMarkdown",
229
+ "section",
230
+ },
231
+ "is.workflow.actions.askllm": {"FollowUp", "WFAllowWebSearch"},
232
+ "is.workflow.actions.extracttextfromimage": {"imageFile"},
233
+ "is.workflow.actions.getdistance": {"WFAvoidHighways", "WFAvoidTolls"},
234
+ "is.workflow.actions.gettraveltime": {"WFAvoidHighways", "WFAvoidTolls"},
235
+ "is.workflow.actions.hide.app": {"WFAppsExcept"},
236
+ "is.workflow.actions.quit.app": {"WFAppsExcept"},
237
+ "is.workflow.actions.scanbarcode": {"imageFile"},
238
+ }
239
+ OS27_PARAMETER_REASON = "macOS 27+ (toolkit-v78 parameter)"
240
+ OS27_BOOLEAN_PARAMETER_KEYS_BY_ACTION = {
241
+ "com.apple.ShortcutsActions.SetBatteryChargeLimitAction": {"setUntilTomorrow"},
242
+ "com.apple.ShortcutsActions.SetMultitaskingModeAction": {
243
+ "automaticallyShowAndHideDock",
244
+ "showRecentApps",
245
+ },
246
+ "com.apple.mobilenotes.SharingExtension": {"interpretAsMarkdown"},
247
+ "is.workflow.actions.addnewreminder": {"WFUrgent"},
248
+ "is.workflow.actions.appendnote": {"ignoreWhitespace", "interpretAsMarkdown"},
249
+ "is.workflow.actions.askllm": {"FollowUp", "WFAllowWebSearch"},
250
+ "is.workflow.actions.getdistance": {"WFAvoidHighways", "WFAvoidTolls"},
251
+ "is.workflow.actions.gettraveltime": {"WFAvoidHighways", "WFAvoidTolls"},
252
+ }
253
+ GET_DISTANCE_ROUTE_MODES = {"Driving", "Walking", "Biking", "Direct"}
254
+ GET_TRAVEL_TIME_ROUTE_MODES = {"Driving", "Walking", "Biking", "Transit"}
255
+ GET_DISTANCE_UNITS = {"Miles", "Kilometers"}
256
+ GET_DISTANCE_ACCURACY_VALUES = {
257
+ "Best",
258
+ "NearestTenMeters",
259
+ "HundredMeters",
260
+ "Kilometer",
261
+ "ThreeKilometers",
262
+ }
263
+ APP_ACTION_MODES = {"App", "All Apps"}
264
+ CONTENT_ITEM_INPUT_PARAMETERS = {"Library"}
265
+ TOOLKIT_PARAMETER_CATALOG_MIN_MACOS_MAJOR = 27
266
+ WORKFLOW_TRIGGER_CATALOG_MIN_MACOS_MAJOR = 27
267
+ TOOLKIT_PARAMETER_STRUCTURAL_KEYS = {
268
+ # Shortcuts plist metadata keys, not action parameters in ToolKit.
269
+ "AppIntentDescriptor",
270
+ "CustomOutputName",
271
+ "UUID",
272
+ }
273
+
274
+ REQUIRED_INPUT_ACTIONS = {
275
+ # Actions that should always have explicit input wired
276
+ "is.workflow.actions.openurl",
277
+ "is.workflow.actions.detect.link",
278
+ "is.workflow.actions.choosefromlist",
279
+ "is.workflow.actions.base64encode",
280
+ "is.workflow.actions.text.replace",
281
+ "is.workflow.actions.setclipboard",
282
+ "is.workflow.actions.count",
283
+ "is.workflow.actions.math",
284
+ "is.workflow.actions.round",
285
+ "is.workflow.actions.detect.dictionary",
286
+ "is.workflow.actions.image.convert",
287
+ "is.workflow.actions.properties.weather.conditions",
288
+ "is.workflow.actions.gettimebetweendates",
289
+ }
290
+
291
+ EDITOR_VISIBLE_INPUT_ACTIONS = {
292
+ # Inputs that commonly render blank unless represented as a tokenized string
293
+ "is.workflow.actions.detect.link",
294
+ "is.workflow.actions.text.replace",
295
+ }
296
+
297
+ TEXT_INPUT_KEY_ACTIONS = {
298
+ # Actions whose primary input parameter key is "text", not WFInput
299
+ "is.workflow.actions.text.changecase",
300
+ "is.workflow.actions.text.split",
301
+ }
302
+
303
+ VCARD_MARKER = "ALLOW_VCARD"
304
+ TOKEN_FILE_MARKER = "ALLOW_TOKEN_FILE"
305
+ ALLOW_DATETIME_FORMAT_MARKER = "ALLOW_DATETIME_FORMAT"
306
+ ALLOW_MANUAL_UNIT_CONVERSION_MARKER = "ALLOW_MANUAL_UNIT_CONVERSION"
307
+ TODOIST_TASKS_PREFIX = "https://api.todoist.com/rest/v2/tasks/"
308
+ COMMENT_INTERNAL_WF_KEY_RE = re.compile(r"\bWF[A-Z][A-Za-z0-9]*\b")
309
+ COMMENT_ALLOWED_MARKERS = {
310
+ VCARD_MARKER,
311
+ TOKEN_FILE_MARKER,
312
+ ALLOW_DATETIME_FORMAT_MARKER,
313
+ ALLOW_MANUAL_UNIT_CONVERSION_MARKER,
314
+ }
315
+ COMMENT_WF_FRIENDLY_LABELS = {
316
+ "WFInput": "Input",
317
+ "WFDate": "Date",
318
+ "WFImage": "Image",
319
+ "WFURL": "URL",
320
+ "WFDuration": "Duration",
321
+ "WFAskActionPrompt": "Prompt",
322
+ "WFVariableName": "Variable Name",
323
+ "WFCondition": "Condition",
324
+ "WFContentItemPropertyName": "Detail",
325
+ "WFRequestVariable": "Request Body",
326
+ "WFJSONValues": "JSON",
327
+ "WFTextActionText": "Text",
328
+ "WFCommentActionText": "Comment",
329
+ }
330
+
331
+ FILE_ACTION_PREFIXES = (
332
+ "is.workflow.actions.file.",
333
+ )
334
+
335
+ FILE_ACTION_IDS = {
336
+ "is.workflow.actions.getfile",
337
+ "is.workflow.actions.documentpicker.open",
338
+ "is.workflow.actions.documentpicker.save",
339
+ }
340
+
341
+ TOKEN_HINT_RE = re.compile(
342
+ r"\b(api[\s_-]?(?:key|token)|bearer[\s_-]?token|access[\s_-]?token|secret[\s_-]?(?:key|token)|auth[\s_-]?token)\b",
343
+ re.IGNORECASE,
344
+ )
345
+ LANG_CODE_RE = re.compile(r"^[a-z]{2,3}(-[A-Za-z0-9]+)?$")
346
+ IDENTIFIER_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
347
+ THIRD_PARTY_IDENTIFIER_RE = re.compile(r"^[a-z0-9]+(\.[A-Za-z0-9_-]+){2,}$")
348
+ UNSAFE_ERROR_DOT_PATH_RE = re.compile(r"^error\.", re.IGNORECASE)
349
+
350
+ DATE_DELTA_UNITS = {
351
+ "seconds",
352
+ "minutes",
353
+ "hours",
354
+ "days",
355
+ "weeks",
356
+ "months",
357
+ "years",
358
+ "second",
359
+ "minute",
360
+ "hour",
361
+ "day",
362
+ "week",
363
+ "month",
364
+ "year",
365
+ # Common Shortcuts aliases seen in exported payloads.
366
+ "sec",
367
+ "secs",
368
+ "min",
369
+ "mins",
370
+ "hr",
371
+ "hrs",
372
+ "wk",
373
+ "wks",
374
+ "mo",
375
+ "mos",
376
+ "yr",
377
+ "yrs",
378
+ }
379
+
380
+ CONTROL_FLOW_TOOLKIT_EXCEPTIONS = {
381
+ "is.workflow.actions.conditional",
382
+ "is.workflow.actions.repeat.count",
383
+ "is.workflow.actions.repeat.each",
384
+ "is.workflow.actions.choosefrommenu",
385
+ }
386
+
387
+ KNOWN_AGGRANDIZEMENT_TYPES = {
388
+ "WFCoercionVariableAggrandizement",
389
+ "WFPropertyVariableAggrandizement",
390
+ "WFDictionaryValueVariableAggrandizement",
391
+ "WFDateFormatVariableAggrandizement",
392
+ "WFUnitVariableAggrandizement",
393
+ }
394
+
395
+ NOTES_CREATE_ACTIONS = {
396
+ "com.apple.mobilenotes.SharingExtension",
397
+ "com.apple.mobilenotes.CreateNoteLinkAction",
398
+ "com.apple.Notes.CreateNoteFromMarkdownLinkAction",
399
+ "com.apple.Notes.CreateNoteLinkAction",
400
+ }
401
+
402
+ NOTES_TITLE_KEYS = {
403
+ "wfnotetitle",
404
+ "title",
405
+ "name",
406
+ }
407
+
408
+ NOTES_CONTENT_KEYS = {
409
+ "wfnotecontentitem",
410
+ "wfcreatenoteinput",
411
+ "markdown",
412
+ "markdowncontents",
413
+ "content",
414
+ "text",
415
+ }
416
+
417
+ REQUIRED_URL_ACTIONS = {
418
+ "is.workflow.actions.url",
419
+ }
420
+
421
+ REQUIRED_URL_INPUT_ACTIONS = {
422
+ "is.workflow.actions.downloadurl",
423
+ }
424
+
425
+ SHORTCUTS_URL_ACTIONS = {
426
+ "is.workflow.actions.url",
427
+ "is.workflow.actions.openurl",
428
+ "is.workflow.actions.openxcallbackurl",
429
+ }
430
+
431
+ WEATHER_SOURCE_ACTIONS = {
432
+ "is.workflow.actions.weather.currentconditions",
433
+ "is.workflow.actions.weather.forecast",
434
+ }
435
+
436
+ LOCATION_SOURCE_ACTIONS = {
437
+ "is.workflow.actions.getcurrentlocation",
438
+ "is.workflow.actions.location",
439
+ }
440
+
441
+ WEATHER_DETAIL_PLACEHOLDER_VALUES = {
442
+ "detail",
443
+ }
444
+
445
+ WEATHER_DETAIL_SUPPORTED_VALUES = {
446
+ "Date",
447
+ "Location",
448
+ "Temperature",
449
+ "Low",
450
+ "High",
451
+ "Feels Like",
452
+ "Condition",
453
+ "Visibility",
454
+ "Dewpoint",
455
+ "Humidity",
456
+ "Pressure",
457
+ "Precipitation Amount",
458
+ "Precipitation Chance",
459
+ "Wind Speed",
460
+ "Wind Direction",
461
+ "UV Index",
462
+ "Sunrise Time",
463
+ "Sunset Time",
464
+ "Air Quality Index",
465
+ "Air Quality Category",
466
+ "Air Pollutants",
467
+ "Name",
468
+ }
469
+
470
+ WEATHER_DETAIL_LIST_VALUES = {
471
+ "Sunrise Time",
472
+ "Sunset Time",
473
+ }
474
+
475
+ LOCATION_PARAMETER_KEYS = {
476
+ "WFLocation",
477
+ "WFWeatherCustomLocation",
478
+ # Legacy export key still seen in some installed shortcuts.
479
+ "WFWeatherLocation",
480
+ }
481
+
482
+ DESTRUCTIVE_FILE_ACTIONS = {
483
+ "is.workflow.actions.file.delete": "Delete File",
484
+ "is.workflow.actions.file.move": "Move File",
485
+ }
486
+
487
+ RENAMED_COPY_OUTPUT_ACTIONS = {
488
+ "is.workflow.actions.documentpicker.save": "Save File",
489
+ "is.workflow.actions.share": "Share",
490
+ }
491
+
492
+ UNUSED_OUTPUT_ACTIONS = {
493
+ "is.workflow.actions.gettext",
494
+ "is.workflow.actions.number",
495
+ "is.workflow.actions.url",
496
+ "is.workflow.actions.getvalueforkey",
497
+ "is.workflow.actions.format.date",
498
+ "is.workflow.actions.ask",
499
+ "is.workflow.actions.count",
500
+ "is.workflow.actions.math",
501
+ }
502
+
503
+ HEALTH_FIND_SAMPLES_ACTION = "is.workflow.actions.filter.health.quantity"
504
+ HEALTH_LOG_SAMPLE_ACTION = "is.workflow.actions.health.quantity.log"
505
+ HEALTH_LOG_WORKOUT_ACTION = "is.workflow.actions.health.workout.log"
506
+ HEALTH_SAMPLE_DETAIL_ACTION = "is.workflow.actions.properties.health.quantity"
507
+
508
+ HEALTH_IOS_ONLY_ACTIONS = {
509
+ HEALTH_FIND_SAMPLES_ACTION,
510
+ HEALTH_LOG_SAMPLE_ACTION,
511
+ HEALTH_LOG_WORKOUT_ACTION,
512
+ HEALTH_SAMPLE_DETAIL_ACTION,
513
+ }
514
+
515
+ HEALTH_SAMPLE_SOURCE_ACTIONS = {
516
+ HEALTH_FIND_SAMPLES_ACTION,
517
+ HEALTH_LOG_SAMPLE_ACTION,
518
+ }
519
+
520
+ HEALTH_SAMPLE_DETAIL_PROPERTIES = {
521
+ "Type",
522
+ "Value",
523
+ "Unit",
524
+ "Start Date",
525
+ "End Date",
526
+ "Duration",
527
+ "Source",
528
+ "Name",
529
+ }
530
+
531
+ HEALTH_QUANTITY_FIELD_KEYS = {
532
+ "WFQuantitySampleQuantity",
533
+ "WFQuantitySampleAdditionalQuantity",
534
+ "WFWorkoutDuration",
535
+ "WFWorkoutCaloriesQuantity",
536
+ "WFWorkoutDistanceQuantity",
537
+ }
538
+
539
+ HEALTH_REQUIRED_MAIN_QUANTITY_KEYS = {
540
+ "WFQuantitySampleQuantity",
541
+ "WFWorkoutDuration",
542
+ "WFWorkoutCaloriesQuantity",
543
+ "WFWorkoutDistanceQuantity",
544
+ }
545
+
546
+
547
+ def _load_healthkit_reference_sets() -> dict[str, set[str]]:
548
+ out = {
549
+ "quantity_types": set(),
550
+ "find_sample_types": set(),
551
+ "sample_types": set(),
552
+ "category_values": set(),
553
+ "workouts": set(),
554
+ "units": set(),
555
+ }
556
+ ref_path = Path(__file__).resolve().parents[1] / "data/healthkit-ios26.2-reference.json"
557
+ if not ref_path.exists():
558
+ return out
559
+ try:
560
+ payload = json.loads(ref_path.read_text(encoding="utf-8"))
561
+ except (json.JSONDecodeError, OSError):
562
+ return out
563
+
564
+ def labels_from_rows(rows) -> set[str]:
565
+ labels: set[str] = set()
566
+ if not isinstance(rows, list):
567
+ return labels
568
+ for row in rows:
569
+ if not isinstance(row, dict):
570
+ continue
571
+ for key in ("shortcut_label_guess", "sdk_suffix"):
572
+ value = row.get(key)
573
+ if isinstance(value, str) and value:
574
+ labels.add(value)
575
+ observed = row.get("observed_shortcuts_labels")
576
+ if isinstance(observed, list):
577
+ labels.update(item for item in observed if isinstance(item, str) and item)
578
+ return labels
579
+
580
+ def find_sample_labels_from_rows(rows) -> set[str]:
581
+ labels: set[str] = set()
582
+ if not isinstance(rows, list):
583
+ return labels
584
+ for row in rows:
585
+ if not isinstance(row, dict):
586
+ continue
587
+ observed_find = row.get("observed_find_samples_labels")
588
+ if isinstance(observed_find, list) and observed_find:
589
+ labels.update(item for item in observed_find if isinstance(item, str) and item)
590
+ continue
591
+ for key in ("shortcut_label_guess", "sdk_suffix"):
592
+ value = row.get(key)
593
+ if isinstance(value, str) and value:
594
+ labels.add(value)
595
+ observed = row.get("observed_shortcuts_labels")
596
+ if isinstance(observed, list):
597
+ labels.update(item for item in observed if isinstance(item, str) and item)
598
+ return labels
599
+
600
+ quantity_labels = labels_from_rows(payload.get("quantity_types"))
601
+ category_labels = labels_from_rows(payload.get("category_types"))
602
+ out["find_sample_types"].update(find_sample_labels_from_rows(payload.get("quantity_types")))
603
+ out["find_sample_types"].update(find_sample_labels_from_rows(payload.get("category_types")))
604
+ out["quantity_types"].update(quantity_labels)
605
+ out["sample_types"].update(quantity_labels | category_labels)
606
+ out["workouts"].update(labels_from_rows(payload.get("workout_activity_types")))
607
+
608
+ category_values = payload.get("category_values")
609
+ if isinstance(category_values, dict):
610
+ for rows in category_values.values():
611
+ if not isinstance(rows, list):
612
+ continue
613
+ for row in rows:
614
+ if not isinstance(row, dict):
615
+ continue
616
+ for key in ("shortcut_label_guess", "symbol"):
617
+ value = row.get(key)
618
+ if isinstance(value, str) and value:
619
+ out["category_values"].add(value)
620
+
621
+ for row in payload.get("quantity_units", []):
622
+ if isinstance(row, dict):
623
+ unit = row.get("unit")
624
+ if isinstance(unit, str) and unit:
625
+ out["units"].add(unit)
626
+ for unit in payload.get("quantity_unit_hints", []):
627
+ if isinstance(unit, str) and unit:
628
+ out["units"].add(unit)
629
+
630
+ return out
631
+
632
+
633
+ HEALTH_REFERENCE_SETS = _load_healthkit_reference_sets()
634
+
635
+ BUILTIN_VARIABLES = {
636
+ "Repeat Item",
637
+ "Repeat Item 2",
638
+ "Repeat Item 3",
639
+ "Repeat Item 4",
640
+ "Repeat Results",
641
+ "Repeat Results 2",
642
+ "Repeat Results 3",
643
+ "Repeat Index",
644
+ "Repeat Index 2",
645
+ "Repeat Index 3",
646
+ "Shortcut Input",
647
+ "Clipboard",
648
+ "Current Date",
649
+ "Current Location",
650
+ }
651
+
652
+ ACTION_ALIAS_HINTS: dict[str, str] = {
653
+ "is.workflow.actions.text": "is.workflow.actions.gettext",
654
+ "is.workflow.actions.translate": "is.workflow.actions.text.translate",
655
+ "is.workflow.actions.getshortcutinput": "is.workflow.actions.input",
656
+ "is.workflow.actions.sendmail": "is.workflow.actions.sendemail",
657
+ "is.workflow.actions.runscript": "is.workflow.actions.runshellscript (or runapplescript / runjavascriptforautomation)",
658
+ "is.workflow.actions.properties.podcastepisode": "is.workflow.actions.properties.podcast or is.workflow.actions.properties.podcastshow",
659
+ }
660
+
661
+ UNIT_KEYWORD_PATTERNS: list[re.Pattern] = [
662
+ re.compile(r"\bmiles?\b", re.I),
663
+ re.compile(r"\bkilometers?\b", re.I),
664
+ re.compile(r"\bkilometres?\b", re.I),
665
+ re.compile(r"\bkm\b", re.I),
666
+ re.compile(r"\bmeters?\b", re.I),
667
+ re.compile(r"\bmetres?\b", re.I),
668
+ re.compile(r"\bcm\b", re.I),
669
+ re.compile(r"\bmm\b", re.I),
670
+ re.compile(r"\bfeet\b", re.I),
671
+ re.compile(r"\bfoot\b", re.I),
672
+ re.compile(r"\bft\b", re.I),
673
+ re.compile(r"\byards?\b", re.I),
674
+ re.compile(r"\binches?\b", re.I),
675
+ re.compile(r"\bpounds?\b", re.I),
676
+ re.compile(r"\blbs?\b", re.I),
677
+ re.compile(r"\blb\b", re.I),
678
+ re.compile(r"\bkilograms?\b", re.I),
679
+ re.compile(r"\bkg\b", re.I),
680
+ re.compile(r"\boz\b", re.I),
681
+ re.compile(r"\bounces?\b", re.I),
682
+ re.compile(r"\bgallons?\b", re.I),
683
+ re.compile(r"\bliters?\b", re.I),
684
+ re.compile(r"\blitres?\b", re.I),
685
+ re.compile(r"\bmilliliters?\b", re.I),
686
+ re.compile(r"\bmillilitres?\b", re.I),
687
+ re.compile(r"\bml\b", re.I),
688
+ re.compile(r"°f", re.I),
689
+ re.compile(r"°c", re.I),
690
+ re.compile(r"\bfahrenheit\b", re.I),
691
+ re.compile(r"\bcelsius\b", re.I),
692
+ ]
693
+
694
+ UNIT_KEYWORD_IGNORED_KEYS = {
695
+ "WFDateFormat",
696
+ "WFDateFormatString",
697
+ "WFTimeFormatStyle",
698
+ "WFRelativeDateFormatStyle",
699
+ "WFMatchTextPattern",
700
+ # Key used inside WFDateFormatVariableAggrandizement payloads — matches
701
+ # the literal plist key, not the WF-prefixed parameter. Date format strings
702
+ # like "yyyy-MM-dd" contain "MM" which otherwise trips \bmm\b.
703
+ "DateFormat",
704
+ # Unit values inside WFQuantityFieldValue are structured metadata, not
705
+ # evidence of a manual unit conversion.
706
+ "Unit",
707
+ }
708
+
709
+
710
+ def _coerce_control_flow_mode(params: dict) -> int | None:
711
+ """Extract WFControlFlowMode as int, coercing string digits."""
712
+ mode = params.get("WFControlFlowMode")
713
+ if isinstance(mode, str) and mode.isdigit():
714
+ return int(mode)
715
+ return mode
716
+
717
+
718
+ def _collect_variable_append_counts(actions: list[dict]) -> dict[str, int]:
719
+ counts: dict[str, int] = {}
720
+ for act in actions:
721
+ if act.get("WFWorkflowActionIdentifier") != "is.workflow.actions.appendvariable":
722
+ continue
723
+ params = act.get("WFWorkflowActionParameters") or {}
724
+ name = params.get("WFVariableName")
725
+ if isinstance(name, str) and name.strip():
726
+ counts[name] = counts.get(name, 0) + 1
727
+ return counts
728
+
729
+
730
+ def read_text(path: Path) -> str:
731
+ return path.read_text(encoding="utf-8", errors="ignore")
732
+
733
+
734
+ def parse_actions_md(text: str) -> set[str]:
735
+ """Parse ACTIONS.md and return the set of valid action identifiers.
736
+
737
+ Only rows inside tables whose first column header is "Identifier" are
738
+ treated as action rows. Parameter/property tables (first column
739
+ "Parameter", "Property", "Class Name", etc.) are skipped so their
740
+ backticked cells do not pollute the allowlist — a prior version wrongly
741
+ injected entries like `is.workflow.actions.UUID` / `is.workflow.actions.Album`.
742
+ """
743
+
744
+ actions: set[str] = set()
745
+ in_identifier_table = False
746
+
747
+ def add_action_id(token: str):
748
+ if not IDENTIFIER_TOKEN_RE.match(token):
749
+ return
750
+ if token.startswith("is.workflow.actions."):
751
+ actions.add(token)
752
+ return
753
+ if token.startswith(("WF", "com.apple.", "io.")):
754
+ return
755
+ actions.add(f"is.workflow.actions.{token}")
756
+
757
+ header_re = re.compile(r"^\|\s*([^|`]+?)\s*\|")
758
+ separator_re = re.compile(r"^\|\s*[-:]+")
759
+ row_re = re.compile(r"\|\s*`([^`]+)`\s*\|")
760
+
761
+ for line in text.splitlines():
762
+ stripped = line.strip()
763
+ if stripped.startswith("#"):
764
+ in_identifier_table = False
765
+ continue
766
+ if separator_re.match(line):
767
+ continue
768
+ if stripped.startswith("|"):
769
+ # Heuristic: if the first column is plain text (no backticks) it's
770
+ # a header row. Switch mode based on whether header == "Identifier".
771
+ header_m = header_re.match(line)
772
+ backtick_m = row_re.match(line)
773
+ if header_m and not backtick_m:
774
+ col = header_m.group(1).strip().lower()
775
+ in_identifier_table = col == "identifier"
776
+ continue
777
+ if in_identifier_table and backtick_m:
778
+ add_action_id(backtick_m.group(1).strip())
779
+ return actions
780
+
781
+
782
+ def parse_appintents_md(text: str) -> set[str]:
783
+ intents: set[str] = set()
784
+
785
+ def add_intent_id(token: str):
786
+ if "." not in token:
787
+ return
788
+ if not IDENTIFIER_TOKEN_RE.match(token):
789
+ return
790
+ if token.startswith(("com.apple.", "is.workflow.actions.")):
791
+ intents.add(token)
792
+
793
+ for line in text.splitlines():
794
+ m = re.match(r"\|\s*`([^`]+)`\s*\|", line)
795
+ if m:
796
+ add_intent_id(m.group(1).strip())
797
+ return intents
798
+
799
+
800
+ def parse_third_party_md(text: str) -> set[str]:
801
+ ids: set[str] = set()
802
+ for match in re.finditer(r"`([^`]+)`", text):
803
+ ident = match.group(1).strip()
804
+ if "." in ident and IDENTIFIER_TOKEN_RE.match(ident):
805
+ ids.add(ident)
806
+ return ids
807
+
808
+
809
+ def detect_host_macos_major() -> int | None:
810
+ """Return the running macOS major version, or None outside macOS."""
811
+
812
+ try:
813
+ proc = subprocess.run(
814
+ ["sw_vers", "-productVersion"],
815
+ text=True,
816
+ capture_output=True,
817
+ check=False,
818
+ )
819
+ except (OSError, ValueError):
820
+ return None
821
+ if proc.returncode != 0:
822
+ return None
823
+ version = proc.stdout.strip()
824
+ if not version:
825
+ return None
826
+ first = version.split(".", 1)[0]
827
+ try:
828
+ return int(first)
829
+ except ValueError:
830
+ return None
831
+
832
+
833
+ def resolve_target_macos_major(raw: str | None = None) -> int | None:
834
+ """Resolve CLI/env target macOS.
835
+
836
+ Returns None for "latest"/"all" because that intentionally includes every
837
+ packaged snapshot. "auto" uses the host macOS version and falls back to the
838
+ conservative macOS 26 target when the host version cannot be detected.
839
+ """
840
+
841
+ value = raw
842
+ if value is None:
843
+ for env_name in TARGET_MACOS_ENV_VARS:
844
+ env_value = os.environ.get(env_name)
845
+ if env_value:
846
+ value = env_value
847
+ break
848
+ if value is None:
849
+ value = "auto"
850
+ normalized = value.strip().lower()
851
+ if normalized in {"", "auto", "host"}:
852
+ detected = detect_host_macos_major()
853
+ return detected if detected is not None else 26
854
+ if normalized in {"latest", "all", "any"}:
855
+ return None
856
+ if normalized.startswith("macos"):
857
+ normalized = normalized.removeprefix("macos").strip()
858
+ try:
859
+ return int(normalized.split(".", 1)[0])
860
+ except ValueError:
861
+ return None
862
+
863
+
864
+ def resolve_target_platform(raw: str | None = None) -> str | None:
865
+ """Resolve target platform.
866
+
867
+ Returns "macos" or "ios" for platform-specific validation, and None for
868
+ "all"/"any" when the caller intentionally wants every packaged platform.
869
+ The default is macOS because this plugin signs on macOS and most generated
870
+ shortcuts are Mac-imported unless the user explicitly targets iOS/iPadOS.
871
+ """
872
+
873
+ value = raw
874
+ if value is None:
875
+ for env_name in TARGET_PLATFORM_ENV_VARS:
876
+ env_value = os.environ.get(env_name)
877
+ if env_value:
878
+ value = env_value
879
+ break
880
+ if value is None:
881
+ value = "macos"
882
+ normalized = value.strip().lower().replace("_", "-")
883
+ if normalized in {"", "auto", "host", "mac", "macos", "mac-os"}:
884
+ return "macos"
885
+ if normalized in {"ios", "ipados", "iphone", "ipad"}:
886
+ return "ios"
887
+ if normalized in {"latest", "all", "any"}:
888
+ return None
889
+ return "macos"
890
+
891
+
892
+ def _toolkit_snapshot_min_macos_major(payload: dict, path: Path) -> int:
893
+ version = payload.get("version")
894
+ if isinstance(version, str) and version in TOOLKIT_SNAPSHOT_MIN_MACOS_MAJOR:
895
+ return TOOLKIT_SNAPSHOT_MIN_MACOS_MAJOR[version]
896
+ if isinstance(version, str):
897
+ match = re.search(r"v(\d+)", version)
898
+ if match and int(match.group(1)) >= 78:
899
+ return 27
900
+ return 0
901
+
902
+
903
+ def _toolkit_snapshot_platform_label(payload: dict, version: str) -> str:
904
+ platform_value = payload.get("platform")
905
+ platform_text = platform_value if isinstance(platform_value, str) else version
906
+ if "ios" in platform_text.lower():
907
+ return "iOS"
908
+ return "macOS"
909
+
910
+
911
+ def _target_platform_label(target_platform: str | None) -> str:
912
+ if target_platform == "ios":
913
+ return "iOS/iPadOS"
914
+ if target_platform == "macos":
915
+ return "macOS"
916
+ return "all platforms"
917
+
918
+
919
+ def _snapshot_matches_target_platform(platform_label: str, target_platform: str | None) -> bool:
920
+ if target_platform is None:
921
+ return True
922
+ if platform_label == "iOS":
923
+ return target_platform == "ios"
924
+ return target_platform == "macos"
925
+
926
+
927
+ def _load_packaged_toolkit_snapshots(skill_dir: Path) -> list[tuple[str, int, str, set[str]]]:
928
+ """Load bundled ToolKit snapshots as (version, minimum OS, platform, ids)."""
929
+
930
+ data_dir = skill_dir / "data"
931
+ candidates = sorted(data_dir.glob("toolkit-v*-tool-ids.json"))
932
+ snapshots: list[tuple[str, int, str, set[str]]] = []
933
+ for path in candidates:
934
+ if not path.exists():
935
+ continue
936
+ try:
937
+ payload = json.loads(path.read_text(encoding="utf-8"))
938
+ except (json.JSONDecodeError, OSError):
939
+ continue
940
+ if not isinstance(payload, dict):
941
+ continue
942
+ version = payload.get("version")
943
+ if not isinstance(version, str) or not version:
944
+ version = path.stem
945
+ ids_for_snapshot: set[str] = set()
946
+ ids = payload.get("ids") if isinstance(payload, dict) else None
947
+ if isinstance(ids, list):
948
+ for item in ids:
949
+ if isinstance(item, str) and item:
950
+ ids_for_snapshot.add(item)
951
+ exceptions = (
952
+ payload.get("control_flow_exceptions_missing_from_tools_table")
953
+ if isinstance(payload, dict)
954
+ else None
955
+ )
956
+ if isinstance(exceptions, list):
957
+ for item in exceptions:
958
+ if isinstance(item, str) and item:
959
+ ids_for_snapshot.add(item)
960
+ snapshots.append(
961
+ (
962
+ version,
963
+ _toolkit_snapshot_min_macos_major(payload, path),
964
+ _toolkit_snapshot_platform_label(payload, version),
965
+ ids_for_snapshot,
966
+ )
967
+ )
968
+ return snapshots
969
+
970
+
971
+ def load_packaged_toolkit_ids(
972
+ skill_dir: Path,
973
+ target_macos_major: int | None = None,
974
+ target_platform: str | None = "macos",
975
+ ) -> set[str]:
976
+ """Load bundled ToolKit ID snapshots for the requested target macOS.
977
+
978
+ Each snapshot is a portable allowlist extracted from Apple's ToolKit SQLite
979
+ metadata. The validator intentionally reads packaged JSON only; it should
980
+ not depend on the user's live Shortcuts database during normal validation.
981
+ """
982
+
983
+ allowed: set[str] = set()
984
+ for _, min_macos_major, platform_label, ids in _load_packaged_toolkit_snapshots(skill_dir):
985
+ if target_macos_major is not None and min_macos_major > target_macos_major:
986
+ continue
987
+ if not _snapshot_matches_target_platform(platform_label, target_platform):
988
+ continue
989
+ allowed |= ids
990
+ allowed |= CONTROL_FLOW_TOOLKIT_EXCEPTIONS
991
+ allowed |= HEALTH_IOS_ONLY_ACTIONS
992
+ return allowed
993
+
994
+
995
+ def load_future_toolkit_id_reasons(
996
+ skill_dir: Path,
997
+ target_macos_major: int | None,
998
+ target_platform: str | None = "macos",
999
+ ) -> dict[str, str]:
1000
+ """Return IDs excluded by target OS/platform, with a human-readable reason."""
1001
+
1002
+ if target_macos_major is None and target_platform is None:
1003
+ return {}
1004
+ snapshots = _load_packaged_toolkit_snapshots(skill_dir)
1005
+ included: set[str] = set()
1006
+ for _, min_macos_major, platform_label, ids in snapshots:
1007
+ if target_macos_major is not None and min_macos_major > target_macos_major:
1008
+ continue
1009
+ if not _snapshot_matches_target_platform(platform_label, target_platform):
1010
+ continue
1011
+ included |= ids
1012
+ future: dict[str, str] = {}
1013
+ for version, min_macos_major, platform_label, ids in snapshots:
1014
+ if target_macos_major is not None and min_macos_major > target_macos_major:
1015
+ reason = f"{platform_label} {min_macos_major}+ ({version})"
1016
+ elif not _snapshot_matches_target_platform(platform_label, target_platform):
1017
+ reason = (
1018
+ f"{platform_label}-only ({version}); "
1019
+ f"target platform is {_target_platform_label(target_platform)}"
1020
+ )
1021
+ else:
1022
+ continue
1023
+ for ident in ids - included:
1024
+ future[ident] = reason
1025
+ return future
1026
+
1027
+
1028
+ def load_future_parameter_key_reasons(target_macos_major: int | None) -> dict[str, dict[str, str]]:
1029
+ """Return OS 27-only parameter keys excluded by the target macOS."""
1030
+
1031
+ if target_macos_major is None or target_macos_major >= 27:
1032
+ return {}
1033
+ return {
1034
+ identifier: {key: OS27_PARAMETER_REASON for key in keys}
1035
+ for identifier, keys in OS27_PARAMETER_KEYS_BY_ACTION.items()
1036
+ }
1037
+
1038
+
1039
+ def _catalog_platforms_match_target(platforms: list[str], target_platform: str | None) -> bool:
1040
+ if target_platform is None:
1041
+ return True
1042
+ for platform_name in platforms:
1043
+ if _catalog_platform_name_matches_target(platform_name, target_platform):
1044
+ return True
1045
+ return False
1046
+
1047
+
1048
+ def _catalog_platform_name_matches_target(platform_name: str, target_platform: str | None) -> bool:
1049
+ if target_platform is None:
1050
+ return True
1051
+ if target_platform == "ios" and platform_name.startswith("iOS"):
1052
+ return True
1053
+ if target_platform == "macos" and platform_name.startswith("macOS"):
1054
+ return True
1055
+ return False
1056
+
1057
+
1058
+ def _catalog_parameter_matches_target(
1059
+ parameter: dict,
1060
+ target_platform: str | None,
1061
+ ) -> bool:
1062
+ platforms = parameter.get("platforms")
1063
+ if not isinstance(platforms, list) or not platforms:
1064
+ return True
1065
+ return _catalog_platforms_match_target(
1066
+ [platform for platform in platforms if isinstance(platform, str)],
1067
+ target_platform,
1068
+ )
1069
+
1070
+
1071
+ def load_toolkit_parameter_schemas(
1072
+ skill_dir: Path,
1073
+ target_macos_major: int | None = None,
1074
+ target_platform: str | None = "macos",
1075
+ ) -> dict[str, set[str]]:
1076
+ """Load target-gated v78 parameter schemas for first-party AppIntent IDs.
1077
+
1078
+ The v78 ToolKit parameter catalog is useful for com.apple AppIntent-style
1079
+ actions, where top-level keys map cleanly to AppIntent parameters. It is not
1080
+ complete enough for every legacy WF action because those can include UI
1081
+ state keys that are valid in plists but absent from ToolKit metadata.
1082
+ """
1083
+
1084
+ if (
1085
+ target_macos_major is not None
1086
+ and target_macos_major < TOOLKIT_PARAMETER_CATALOG_MIN_MACOS_MAJOR
1087
+ ):
1088
+ return {}
1089
+ path = skill_dir / "data/toolkit-v78-first-party-parameter-keys.json"
1090
+ if not path.exists():
1091
+ return {}
1092
+ with path.open("r", encoding="utf-8") as handle:
1093
+ payload = json.load(handle)
1094
+ schemas: dict[str, set[str]] = {}
1095
+ for identifier, entry in (payload.get("tools") or {}).items():
1096
+ if not isinstance(identifier, str) or not identifier.startswith("com.apple."):
1097
+ continue
1098
+ if not isinstance(entry, dict):
1099
+ continue
1100
+ platforms = entry.get("platforms") or []
1101
+ if not _catalog_platforms_match_target(platforms, target_platform):
1102
+ continue
1103
+ keys = {
1104
+ parameter.get("key")
1105
+ for parameter in (entry.get("parameters") or [])
1106
+ if isinstance(parameter, dict)
1107
+ and isinstance(parameter.get("key"), str)
1108
+ and _catalog_parameter_matches_target(parameter, target_platform)
1109
+ }
1110
+ schemas[identifier] = keys
1111
+ return schemas
1112
+
1113
+
1114
+ def _parameter_type_python_names(
1115
+ parameter: dict,
1116
+ target_platform: str | None = None,
1117
+ ) -> list[str]:
1118
+ if target_platform is not None:
1119
+ by_platform = parameter.get("typePythonNamesByPlatform")
1120
+ if isinstance(by_platform, dict):
1121
+ names_by_target: list[str] = []
1122
+ for platform_name, type_names in by_platform.items():
1123
+ if not isinstance(platform_name, str) or not _catalog_platform_name_matches_target(
1124
+ platform_name,
1125
+ target_platform,
1126
+ ):
1127
+ continue
1128
+ if isinstance(type_names, list):
1129
+ names_by_target.extend(
1130
+ name for name in type_names if isinstance(name, str) and name
1131
+ )
1132
+ if names_by_target:
1133
+ return sorted(set(names_by_target))
1134
+ names: list[str] = []
1135
+ single = parameter.get("typePythonName")
1136
+ if isinstance(single, str) and single:
1137
+ names.append(single)
1138
+ multi = parameter.get("typePythonNames")
1139
+ if isinstance(multi, list):
1140
+ names.extend(name for name in multi if isinstance(name, str) and name)
1141
+ return sorted(set(names))
1142
+
1143
+
1144
+ def load_toolkit_parameter_enum_cases(
1145
+ skill_dir: Path,
1146
+ target_macos_major: int | None = None,
1147
+ target_platform: str | None = "macos",
1148
+ ) -> dict[str, dict[str, set[str]]]:
1149
+ """Load target-gated literal enum cases for simple first-party params.
1150
+
1151
+ This intentionally validates only parameters with exactly one enum type. If
1152
+ ToolKit reports a union of enum and non-enum types, or several possible enum
1153
+ types, the validator skips that parameter to avoid rejecting valid picker or
1154
+ entity states that cannot be represented by a simple literal string. The
1155
+ enum checks are safe for both AppIntents and classic WF actions because they
1156
+ only run when a known enum key is present; unlike AppIntent schemas, they do
1157
+ not reject extra top-level keys on classic WF actions.
1158
+ """
1159
+
1160
+ if (
1161
+ target_macos_major is not None
1162
+ and target_macos_major < TOOLKIT_PARAMETER_CATALOG_MIN_MACOS_MAJOR
1163
+ ):
1164
+ return {}
1165
+ parameter_path = skill_dir / "data/toolkit-v78-first-party-parameter-keys.json"
1166
+ enum_path = skill_dir / "data/toolkit-v78-first-party-enum-cases.json"
1167
+ if not parameter_path.exists() or not enum_path.exists():
1168
+ return {}
1169
+ with parameter_path.open("r", encoding="utf-8") as handle:
1170
+ parameter_payload = json.load(handle)
1171
+ with enum_path.open("r", encoding="utf-8") as handle:
1172
+ enum_payload = json.load(handle)
1173
+
1174
+ enum_types = enum_payload.get("types") or {}
1175
+ schemas: dict[str, dict[str, set[str]]] = {}
1176
+ for identifier, entry in (parameter_payload.get("tools") or {}).items():
1177
+ if not isinstance(identifier, str) or not (
1178
+ identifier.startswith("com.apple.")
1179
+ or identifier.startswith("is.workflow.actions.")
1180
+ ):
1181
+ continue
1182
+ if not isinstance(entry, dict):
1183
+ continue
1184
+ platforms = entry.get("platforms") or []
1185
+ if not _catalog_platforms_match_target(platforms, target_platform):
1186
+ continue
1187
+ parameter_cases: dict[str, set[str]] = {}
1188
+ for parameter in entry.get("parameters") or []:
1189
+ if not isinstance(parameter, dict):
1190
+ continue
1191
+ if not _catalog_parameter_matches_target(parameter, target_platform):
1192
+ continue
1193
+ key = parameter.get("key")
1194
+ if not isinstance(key, str):
1195
+ continue
1196
+ type_names = _parameter_type_python_names(parameter, target_platform)
1197
+ if len(type_names) != 1:
1198
+ continue
1199
+ enum_entry = enum_types.get(type_names[0])
1200
+ if not isinstance(enum_entry, dict):
1201
+ continue
1202
+ case_ids = {
1203
+ case.get("id")
1204
+ for case in (enum_entry.get("cases") or [])
1205
+ if isinstance(case, dict) and isinstance(case.get("id"), str)
1206
+ }
1207
+ if case_ids:
1208
+ parameter_cases[key] = case_ids
1209
+ if parameter_cases:
1210
+ schemas[identifier] = parameter_cases
1211
+ return schemas
1212
+
1213
+
1214
+ def load_toolkit_parameter_boolean_keys(
1215
+ skill_dir: Path,
1216
+ target_macos_major: int | None = None,
1217
+ target_platform: str | None = "macos",
1218
+ ) -> dict[str, set[str]]:
1219
+ """Load target-gated boolean parameter keys for first-party actions."""
1220
+
1221
+ if (
1222
+ target_macos_major is not None
1223
+ and target_macos_major < TOOLKIT_PARAMETER_CATALOG_MIN_MACOS_MAJOR
1224
+ ):
1225
+ return {}
1226
+ path = skill_dir / "data/toolkit-v78-first-party-parameter-keys.json"
1227
+ if not path.exists():
1228
+ return {}
1229
+ with path.open("r", encoding="utf-8") as handle:
1230
+ payload = json.load(handle)
1231
+ schemas: dict[str, set[str]] = {}
1232
+ for identifier, entry in (payload.get("tools") or {}).items():
1233
+ if not isinstance(identifier, str) or not (
1234
+ identifier.startswith("com.apple.")
1235
+ or identifier.startswith("is.workflow.actions.")
1236
+ ):
1237
+ continue
1238
+ if not isinstance(entry, dict):
1239
+ continue
1240
+ platforms = entry.get("platforms") or []
1241
+ if not _catalog_platforms_match_target(platforms, target_platform):
1242
+ continue
1243
+ keys: set[str] = set()
1244
+ for parameter in entry.get("parameters") or []:
1245
+ if not isinstance(parameter, dict):
1246
+ continue
1247
+ if not _catalog_parameter_matches_target(parameter, target_platform):
1248
+ continue
1249
+ key = parameter.get("key")
1250
+ if not isinstance(key, str):
1251
+ continue
1252
+ if _parameter_type_python_names(parameter, target_platform) == ["bool"]:
1253
+ keys.add(key)
1254
+ if keys:
1255
+ schemas[identifier] = keys
1256
+ return schemas
1257
+
1258
+
1259
+ def load_workflow_trigger_catalog(
1260
+ skill_dir: Path,
1261
+ target_macos_major: int | None = None,
1262
+ target_platform: str | None = "macos",
1263
+ ) -> dict[str, object]:
1264
+ """Load exported OS 27 WFWorkflowTriggers samples for structural validation."""
1265
+
1266
+ if (
1267
+ target_macos_major is not None
1268
+ and target_macos_major < WORKFLOW_TRIGGER_CATALOG_MIN_MACOS_MAJOR
1269
+ ):
1270
+ return {
1271
+ "workflow_identifiers": set(),
1272
+ "serialized_keys_by_identifier": {},
1273
+ }
1274
+ path = skill_dir / "data/macos27-workflow-trigger-samples.json"
1275
+ if not path.exists():
1276
+ return {
1277
+ "workflow_identifiers": set(),
1278
+ "serialized_keys_by_identifier": {},
1279
+ }
1280
+ with path.open("r", encoding="utf-8") as handle:
1281
+ payload = json.load(handle)
1282
+ workflow_identifiers: set[str] = set()
1283
+ serialized_keys_by_identifier: dict[str, set[str]] = {}
1284
+ for entry in (payload.get("triggers") or {}).values():
1285
+ if not isinstance(entry, dict):
1286
+ continue
1287
+ platforms = entry.get("platforms") or []
1288
+ if not _catalog_platforms_match_target(platforms, target_platform):
1289
+ continue
1290
+ workflow_trigger = entry.get("workflowTrigger")
1291
+ if not isinstance(workflow_trigger, dict):
1292
+ continue
1293
+ wf_identifier = workflow_trigger.get("WFTriggerIdentifier")
1294
+ if not isinstance(wf_identifier, str) or not wf_identifier:
1295
+ continue
1296
+ workflow_identifiers.add(wf_identifier)
1297
+ keys = serialized_keys_by_identifier.setdefault(wf_identifier, set())
1298
+ for key in entry.get("serializedParameterKeys") or []:
1299
+ if isinstance(key, str) and key:
1300
+ keys.add(key)
1301
+ for key in entry.get("toolkitParameterKeys") or []:
1302
+ if isinstance(key, str) and key:
1303
+ keys.add(key)
1304
+ return {
1305
+ "workflow_identifiers": workflow_identifiers,
1306
+ "serialized_keys_by_identifier": serialized_keys_by_identifier,
1307
+ }
1308
+
1309
+
1310
+ def load_allowed_ids(
1311
+ skill_dir: Path,
1312
+ target_macos_major: int | None = None,
1313
+ target_platform: str | None = "macos",
1314
+ ) -> set[str]:
1315
+ allowed = set()
1316
+ allowed |= load_packaged_toolkit_ids(skill_dir, target_macos_major, target_platform)
1317
+
1318
+ # Keep markdown references as additive fallback, especially for backup-only third-party IDs.
1319
+ actions_md = skill_dir / "ACTIONS.md"
1320
+ appintents_md = skill_dir / "APPINTENTS.md"
1321
+ third_party_md = skill_dir / "THIRD_PARTY_ACTIONS.md"
1322
+
1323
+ if actions_md.exists():
1324
+ allowed |= parse_actions_md(read_text(actions_md))
1325
+ if appintents_md.exists():
1326
+ allowed |= parse_appintents_md(read_text(appintents_md))
1327
+ if third_party_md.exists():
1328
+ allowed |= parse_third_party_md(read_text(third_party_md))
1329
+
1330
+ allowed |= CONTROL_FLOW_TOOLKIT_EXCEPTIONS
1331
+ allowed |= HEALTH_IOS_ONLY_ACTIONS
1332
+ future_ids = load_future_toolkit_id_reasons(skill_dir, target_macos_major, target_platform)
1333
+ allowed -= set(future_ids)
1334
+ return allowed
1335
+
1336
+
1337
+ def load_icon_metadata(skill_dir: Path) -> tuple[set[int], set[int]]:
1338
+ glyph_ids: set[int] = set()
1339
+ color_values: set[int] = set()
1340
+
1341
+ glyph_path = skill_dir / "data/shortcuts-official-glyph-mapping.json"
1342
+ color_path = skill_dir / "data/shortcuts-icon-colors.json"
1343
+
1344
+ try:
1345
+ if glyph_path.exists():
1346
+ with glyph_path.open("r", encoding="utf-8") as handle:
1347
+ payload = json.load(handle)
1348
+ if isinstance(payload, dict):
1349
+ for key in payload.keys():
1350
+ try:
1351
+ glyph_ids.add(int(key))
1352
+ except (TypeError, ValueError):
1353
+ continue
1354
+ except json.JSONDecodeError:
1355
+ pass
1356
+
1357
+ try:
1358
+ if color_path.exists():
1359
+ with color_path.open("r", encoding="utf-8") as handle:
1360
+ payload = json.load(handle)
1361
+ if isinstance(payload, list):
1362
+ for row in payload:
1363
+ if not isinstance(row, dict):
1364
+ continue
1365
+ value = row.get("value")
1366
+ if isinstance(value, (int, float)):
1367
+ color_values.add(int(value))
1368
+ for alias in row.get("aliases", []):
1369
+ if isinstance(alias, (int, float)):
1370
+ color_values.add(int(alias))
1371
+ except json.JSONDecodeError:
1372
+ pass
1373
+
1374
+ return glyph_ids, color_values
1375
+
1376
+
1377
+ def load_plist(path: Path):
1378
+ with path.open("rb") as f:
1379
+ return plistlib.load(f)
1380
+
1381
+
1382
+ def iter_empty_strings(obj, path=()):
1383
+ if isinstance(obj, dict):
1384
+ for k, v in obj.items():
1385
+ new_path = path + (k,)
1386
+ if isinstance(v, str):
1387
+ # Some text fields legitimately preserve whitespace and should only
1388
+ # fail when truly empty.
1389
+ if k in WHITESPACE_SIGNIFICANT_KEYS:
1390
+ if v == "":
1391
+ yield new_path
1392
+ elif k in ALLOW_EMPTY_STRING_KEYS:
1393
+ continue
1394
+ elif v.strip() == "" and k not in ALLOWED_EMPTY_KEYS:
1395
+ yield new_path
1396
+ else:
1397
+ yield from iter_empty_strings(v, new_path)
1398
+ elif isinstance(obj, list):
1399
+ for i, v in enumerate(obj):
1400
+ yield from iter_empty_strings(v, path + (f"[{i}]",))
1401
+
1402
+
1403
+ def iter_strings(obj, path=()):
1404
+ if isinstance(obj, dict):
1405
+ for k, v in obj.items():
1406
+ new_path = path + (k,)
1407
+ if isinstance(v, str):
1408
+ yield new_path, v
1409
+ else:
1410
+ yield from iter_strings(v, new_path)
1411
+ elif isinstance(obj, list):
1412
+ for i, v in enumerate(obj):
1413
+ yield from iter_strings(v, path + (f"[{i}]",))
1414
+
1415
+
1416
+ def iter_text_token_strings(obj):
1417
+ if isinstance(obj, dict):
1418
+ if obj.get("WFSerializationType") == "WFTextTokenString":
1419
+ yield obj
1420
+ for v in obj.values():
1421
+ yield from iter_text_token_strings(v)
1422
+ elif isinstance(obj, list):
1423
+ for v in obj:
1424
+ yield from iter_text_token_strings(v)
1425
+
1426
+
1427
+ def _validate_shortcuts_url_literal(url: str, idx: int) -> list[str]:
1428
+ errors: list[str] = []
1429
+ parsed = urllib.parse.urlparse(url)
1430
+ if parsed.scheme != "shortcuts":
1431
+ return errors
1432
+
1433
+ query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
1434
+
1435
+ def has_nonempty(name: str) -> bool:
1436
+ return bool(query.get(name) and query[name][0])
1437
+
1438
+ def validate_run_params(context: str) -> None:
1439
+ if not has_nonempty("name"):
1440
+ errors.append(f"{context} missing required name parameter at index {idx}")
1441
+ input_values = query.get("input", [])
1442
+ if input_values:
1443
+ input_value = input_values[0]
1444
+ if input_value not in {"text", "clipboard"}:
1445
+ errors.append(
1446
+ f"{context} input must be 'text' or 'clipboard', got '{input_value}' at index {idx}"
1447
+ )
1448
+ if input_value == "text" and not has_nonempty("text"):
1449
+ errors.append(f"{context} uses input=text but has no text parameter at index {idx}")
1450
+
1451
+ host = parsed.netloc
1452
+ path = parsed.path
1453
+
1454
+ if host == "" and path in {"", "/"} and not parsed.query:
1455
+ return errors
1456
+ if host == "create-shortcut" and path in {"", "/"}:
1457
+ if parsed.query:
1458
+ errors.append(f"shortcuts://create-shortcut does not take query parameters at index {idx}")
1459
+ return errors
1460
+ if host == "open-shortcut" and path in {"", "/"}:
1461
+ if not has_nonempty("name"):
1462
+ errors.append(f"shortcuts://open-shortcut missing required name parameter at index {idx}")
1463
+ return errors
1464
+ if host == "run-shortcut" and path in {"", "/"}:
1465
+ validate_run_params("shortcuts://run-shortcut")
1466
+ return errors
1467
+ if host == "gallery":
1468
+ if path in {"", "/"}:
1469
+ if parsed.query:
1470
+ errors.append(f"shortcuts://gallery does not take query parameters at index {idx}")
1471
+ return errors
1472
+ if path == "/search":
1473
+ if not has_nonempty("query"):
1474
+ errors.append(f"shortcuts://gallery/search missing required query parameter at index {idx}")
1475
+ return errors
1476
+ if host == "x-callback-url" and path == "/run-shortcut":
1477
+ validate_run_params("shortcuts://x-callback-url/run-shortcut")
1478
+ callbacks = ["x-success", "x-cancel", "x-error"]
1479
+ if not any(has_nonempty(name) for name in callbacks):
1480
+ errors.append(
1481
+ f"shortcuts://x-callback-url/run-shortcut should include at least one callback URL at index {idx}"
1482
+ )
1483
+ for name in callbacks:
1484
+ if has_nonempty(name):
1485
+ callback = query[name][0]
1486
+ callback_parsed = urllib.parse.urlparse(callback)
1487
+ if not callback_parsed.scheme:
1488
+ errors.append(f"{name} callback is not an absolute URL at index {idx}")
1489
+ return errors
1490
+
1491
+ errors.append(f"Unsupported Apple-documented Shortcuts URL route '{host}{path}' at index {idx}")
1492
+ return errors
1493
+
1494
+
1495
+ def _utf16_units(s: str) -> list[int]:
1496
+ data = s.encode("utf-16-le")
1497
+ return [data[i] + (data[i + 1] << 8) for i in range(0, len(data), 2)]
1498
+
1499
+
1500
+ def _placeholder_position_hint(string: str) -> str:
1501
+ positions = [f"{{{idx}, 1}}" for idx, unit in enumerate(_utf16_units(string)) if unit == 0xFFFC]
1502
+ if not positions:
1503
+ return "expected no placeholder positions"
1504
+ return "expected placeholder position(s): " + ", ".join(positions)
1505
+
1506
+
1507
+ def iter_action_output_refs(obj):
1508
+ if isinstance(obj, dict):
1509
+ if obj.get("Type") == "ActionOutput":
1510
+ uuid = obj.get("OutputUUID")
1511
+ if uuid:
1512
+ yield uuid
1513
+ for v in obj.values():
1514
+ yield from iter_action_output_refs(v)
1515
+ elif isinstance(obj, list):
1516
+ for v in obj:
1517
+ yield from iter_action_output_refs(v)
1518
+
1519
+
1520
+ def iter_action_output_refs_with_name(obj):
1521
+ if isinstance(obj, dict):
1522
+ if obj.get("Type") == "ActionOutput":
1523
+ uuid = obj.get("OutputUUID")
1524
+ name = obj.get("OutputName")
1525
+ if uuid or name:
1526
+ yield uuid, name
1527
+ for v in obj.values():
1528
+ yield from iter_action_output_refs_with_name(v)
1529
+ elif isinstance(obj, list):
1530
+ for v in obj:
1531
+ yield from iter_action_output_refs_with_name(v)
1532
+
1533
+
1534
+ def iter_variable_names(obj):
1535
+ if isinstance(obj, dict):
1536
+ if obj.get("Type") == "Variable":
1537
+ name = obj.get("VariableName")
1538
+ if isinstance(name, str):
1539
+ yield name
1540
+ for v in obj.values():
1541
+ yield from iter_variable_names(v)
1542
+ elif isinstance(obj, list):
1543
+ for v in obj:
1544
+ yield from iter_variable_names(v)
1545
+
1546
+
1547
+ def _comment_has_bullets(text: str) -> bool:
1548
+ for line in text.splitlines():
1549
+ stripped = line.lstrip()
1550
+ if stripped.startswith(("-", "•", "–")):
1551
+ return True
1552
+ return False
1553
+
1554
+
1555
+ def _friendly_comment_field_name(token: str) -> str:
1556
+ friendly = COMMENT_WF_FRIENDLY_LABELS.get(token)
1557
+ if friendly:
1558
+ return friendly
1559
+ if token.startswith("WF") and len(token) > 2:
1560
+ return token[2:]
1561
+ return token
1562
+
1563
+
1564
+ def _comment_internal_wf_terms(text: str) -> list[str]:
1565
+ found: set[str] = set()
1566
+ for raw_line in text.splitlines():
1567
+ line = raw_line.strip()
1568
+ if not line:
1569
+ continue
1570
+ # Skip quoted user prompt lines in the required prompt Comment block.
1571
+ if line.startswith(">"):
1572
+ continue
1573
+ upper = line.upper()
1574
+ if any(marker in upper for marker in COMMENT_ALLOWED_MARKERS):
1575
+ continue
1576
+ for token in COMMENT_INTERNAL_WF_KEY_RE.findall(line):
1577
+ found.add(token)
1578
+ return sorted(found)
1579
+
1580
+
1581
+ def _text_has_unit_keywords(text: str) -> bool:
1582
+ for pat in UNIT_KEYWORD_PATTERNS:
1583
+ if pat.search(text):
1584
+ return True
1585
+ return False
1586
+
1587
+
1588
+ def iter_extension_input_refs(obj):
1589
+ if isinstance(obj, dict):
1590
+ if obj.get("Type") == "ExtensionInput":
1591
+ yield True
1592
+ for v in obj.values():
1593
+ yield from iter_extension_input_refs(v)
1594
+ elif isinstance(obj, list):
1595
+ for v in obj:
1596
+ yield from iter_extension_input_refs(v)
1597
+
1598
+
1599
+ def _snippet(params: dict) -> str:
1600
+ simple = {}
1601
+ for k, v in params.items():
1602
+ if isinstance(v, (str, int, float, bool)) and v != "":
1603
+ simple[k] = v
1604
+ if not simple:
1605
+ simple = {"keys": sorted(params.keys())}
1606
+ s = repr(simple)
1607
+ if len(s) > 240:
1608
+ s = s[:237] + "..."
1609
+ return s
1610
+
1611
+
1612
+ def _literal_enum_values(value) -> list[str]:
1613
+ if isinstance(value, bool):
1614
+ return []
1615
+ if isinstance(value, str):
1616
+ stripped = value.strip()
1617
+ return [stripped] if stripped else []
1618
+ if isinstance(value, int):
1619
+ return [str(value)]
1620
+ if isinstance(value, dict):
1621
+ values: list[str] = []
1622
+ for key in ("value", "identifier"):
1623
+ item = value.get(key)
1624
+ if isinstance(item, str) and item.strip():
1625
+ values.append(item.strip())
1626
+ return sorted(set(values))
1627
+ if isinstance(value, list):
1628
+ values: list[str] = []
1629
+ for item in value:
1630
+ values.extend(_literal_enum_values(item))
1631
+ return values
1632
+ return []
1633
+
1634
+
1635
+ def _format_allowed_values(values: set[str], limit: int = 20) -> str:
1636
+ ordered = sorted(values)
1637
+ preview = ", ".join(repr(value) for value in ordered[:limit])
1638
+ if len(ordered) > limit:
1639
+ preview += f", ... (+{len(ordered) - limit} more)"
1640
+ return preview
1641
+
1642
+
1643
+ def _is_allowed_toolkit_enum_alias(ident: str, key: str, value: str, allowed_values: set[str]) -> bool:
1644
+ if (
1645
+ ident == "is.workflow.actions.filter.vpns"
1646
+ and key == "WFCompoundType"
1647
+ and {"0", "1"}.issubset(allowed_values)
1648
+ ):
1649
+ return value in {"Any", "All"}
1650
+ return False
1651
+
1652
+
1653
+ def _input_is_attached(value) -> bool:
1654
+ if not isinstance(value, dict):
1655
+ return False
1656
+ if value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict):
1657
+ return _input_is_attached(value.get("Variable"))
1658
+ if value.get("WFSerializationType") in {"WFTextTokenAttachment", "WFTextTokenString"}:
1659
+ return True
1660
+ return False
1661
+
1662
+
1663
+ def _parameter_is_dynamic_attachment(value) -> bool:
1664
+ if not isinstance(value, dict):
1665
+ return False
1666
+ if value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict):
1667
+ return _parameter_is_dynamic_attachment(value.get("Variable"))
1668
+ if value.get("WFSerializationType") == "WFTextTokenAttachment":
1669
+ token_value = value.get("Value")
1670
+ return isinstance(token_value, dict) and bool(token_value.get("Type"))
1671
+ if value.get("WFSerializationType") == "WFTextTokenString":
1672
+ token_value = value.get("Value")
1673
+ if not isinstance(token_value, dict):
1674
+ return False
1675
+ attachments = token_value.get("attachmentsByRange")
1676
+ return isinstance(attachments, dict) and bool(attachments)
1677
+ return False
1678
+
1679
+
1680
+ def _conditional_input_is_wrapped(value) -> bool:
1681
+ return isinstance(value, dict) and value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict)
1682
+
1683
+
1684
+ def _wrapped_variable_contains_action_output(value) -> bool:
1685
+ if not isinstance(value, dict):
1686
+ return False
1687
+ if value.get("Type") != "Variable":
1688
+ return False
1689
+ inner = value.get("Variable")
1690
+ if not isinstance(inner, dict):
1691
+ return False
1692
+ ser = inner.get("WFSerializationType")
1693
+ val = inner.get("Value")
1694
+ if ser == "WFTextTokenAttachment":
1695
+ return isinstance(val, dict) and val.get("Type") == "ActionOutput"
1696
+ if ser == "WFTextTokenString" and isinstance(val, dict):
1697
+ attachments = val.get("attachmentsByRange")
1698
+ if isinstance(attachments, dict):
1699
+ for att in attachments.values():
1700
+ if isinstance(att, dict) and att.get("Type") == "ActionOutput":
1701
+ return True
1702
+ return False
1703
+
1704
+
1705
+ def _input_is_token_string(value) -> bool:
1706
+ if not isinstance(value, dict):
1707
+ return False
1708
+ if value.get("WFSerializationType") != "WFTextTokenString":
1709
+ return False
1710
+ inner = value.get("Value")
1711
+ if not isinstance(inner, dict):
1712
+ return False
1713
+ attachments = inner.get("attachmentsByRange")
1714
+ return isinstance(attachments, dict) and bool(attachments)
1715
+
1716
+
1717
+ def _input_is_single_placeholder_token_string(value) -> bool:
1718
+ if not _input_is_token_string(value):
1719
+ return False
1720
+ inner = value.get("Value")
1721
+ if not isinstance(inner, dict):
1722
+ return False
1723
+ if inner.get("string") != "\ufffc":
1724
+ return False
1725
+ attachments = inner.get("attachmentsByRange")
1726
+ return isinstance(attachments, dict) and list(attachments.keys()) == ["{0, 1}"]
1727
+
1728
+
1729
+ def _input_is_editor_visible(value) -> bool:
1730
+ return _conditional_input_is_wrapped(value) or _input_is_token_string(value)
1731
+
1732
+
1733
+ def _token_param_is_empty(value) -> bool:
1734
+ if value is None:
1735
+ return True
1736
+ if isinstance(value, str):
1737
+ return value.strip() == ""
1738
+ if isinstance(value, (int, float, bool)):
1739
+ return False
1740
+ if not isinstance(value, dict):
1741
+ return False
1742
+ ser = value.get("WFSerializationType")
1743
+ if ser == "WFTextTokenString":
1744
+ inner = value.get("Value")
1745
+ if not isinstance(inner, dict):
1746
+ return True
1747
+ string = inner.get("string")
1748
+ attachments = inner.get("attachmentsByRange")
1749
+ if isinstance(attachments, dict) and attachments:
1750
+ return False
1751
+ return not isinstance(string, str) or string.strip() == ""
1752
+ if ser == "WFTextTokenAttachment":
1753
+ inner = value.get("Value")
1754
+ if not isinstance(inner, dict):
1755
+ return True
1756
+ token_type = inner.get("Type")
1757
+ if isinstance(token_type, str) and token_type.strip():
1758
+ return False
1759
+ return True
1760
+ return False
1761
+
1762
+
1763
+ def _validate_health_quantity_field(
1764
+ value,
1765
+ *,
1766
+ key: str,
1767
+ idx: int,
1768
+ errors: list[str],
1769
+ require_magnitude: bool = True,
1770
+ ) -> None:
1771
+ if not isinstance(value, dict):
1772
+ errors.append(f"Health quantity field {key} must be a dict at index {idx}")
1773
+ return
1774
+ if value.get("WFSerializationType") != "WFQuantityFieldValue":
1775
+ errors.append(
1776
+ f"Health quantity field {key} must use WFQuantityFieldValue serialization at index {idx}"
1777
+ )
1778
+ return
1779
+ inner = value.get("Value")
1780
+ if not isinstance(inner, dict):
1781
+ errors.append(f"Health quantity field {key} missing Value dict at index {idx}")
1782
+ return
1783
+
1784
+ magnitude = inner.get("Magnitude")
1785
+ unit = inner.get("Unit")
1786
+ if require_magnitude and _token_param_is_empty(magnitude):
1787
+ errors.append(f"Health quantity field {key} missing Magnitude at index {idx}")
1788
+ if not isinstance(unit, str) or unit.strip() == "":
1789
+ errors.append(f"Health quantity field {key} missing Unit at index {idx}")
1790
+ elif HEALTH_REFERENCE_SETS["units"] and unit not in HEALTH_REFERENCE_SETS["units"]:
1791
+ errors.append(f"Health quantity field {key} uses unknown Unit '{unit}' at index {idx}")
1792
+
1793
+
1794
+ def _validate_health_date_like(value, *, key: str, idx: int, errors: list[str]) -> None:
1795
+ if _token_param_is_empty(value):
1796
+ errors.append(f"Health date field {key} is empty at index {idx}")
1797
+
1798
+
1799
+ def _validate_health_filter_template(
1800
+ filter_value,
1801
+ *,
1802
+ action_label: str,
1803
+ idx: int,
1804
+ errors: list[str],
1805
+ ) -> None:
1806
+ if not isinstance(filter_value, dict):
1807
+ errors.append(f"{action_label} missing WFContentItemFilter at index {idx}")
1808
+ return
1809
+ if filter_value.get("WFSerializationType") != "WFContentPredicateTableTemplate":
1810
+ errors.append(
1811
+ f"{action_label} WFContentItemFilter must use WFContentPredicateTableTemplate at index {idx}"
1812
+ )
1813
+ return
1814
+ inner = filter_value.get("Value")
1815
+ if not isinstance(inner, dict):
1816
+ errors.append(f"{action_label} WFContentItemFilter missing Value dict at index {idx}")
1817
+ return
1818
+ prefix = inner.get("WFActionParameterFilterPrefix")
1819
+ if prefix not in (0, 1):
1820
+ errors.append(
1821
+ f"{action_label} WFContentItemFilter prefix must be 0 (Any) or 1 (All) at index {idx}"
1822
+ )
1823
+ templates = inner.get("WFActionParameterFilterTemplates")
1824
+ if not isinstance(templates, list) or not templates:
1825
+ errors.append(f"{action_label} WFContentItemFilter has no templates at index {idx}")
1826
+ return
1827
+ for tidx, template in enumerate(templates):
1828
+ if not isinstance(template, dict):
1829
+ errors.append(f"{action_label} filter template {tidx} is not a dict at index {idx}")
1830
+ continue
1831
+ if _token_param_is_empty(template.get("Property")):
1832
+ errors.append(f"{action_label} filter template {tidx} missing Property at index {idx}")
1833
+ if "Operator" not in template:
1834
+ errors.append(f"{action_label} filter template {tidx} missing Operator at index {idx}")
1835
+ if "Values" in template and not isinstance(template.get("Values"), dict):
1836
+ errors.append(f"{action_label} filter template {tidx} Values must be a dict at index {idx}")
1837
+
1838
+
1839
+ def _health_filter_type_value(filter_value) -> tuple[Optional[str], Optional[int], bool]:
1840
+ if not isinstance(filter_value, dict):
1841
+ return None, None, False
1842
+ inner = filter_value.get("Value")
1843
+ if not isinstance(inner, dict):
1844
+ return None, None, False
1845
+ templates = inner.get("WFActionParameterFilterTemplates")
1846
+ if not isinstance(templates, list):
1847
+ return None, None, False
1848
+
1849
+ malformed_legacy_value_row = False
1850
+ type_value: Optional[str] = None
1851
+ type_operator: Optional[int] = None
1852
+ for template in templates:
1853
+ if not isinstance(template, dict):
1854
+ continue
1855
+ prop = template.get("Property")
1856
+ if prop == "Value":
1857
+ malformed_legacy_value_row = True
1858
+ continue
1859
+ if prop != "Type":
1860
+ continue
1861
+ type_operator = template.get("Operator")
1862
+ if template.get("Bounded") is not True or template.get("Removable") is not False:
1863
+ return None, type_operator, True
1864
+ values = template.get("Values")
1865
+ if not isinstance(values, dict):
1866
+ return None, type_operator, True
1867
+ enum_state = values.get("Enumeration")
1868
+ if not isinstance(enum_state, dict):
1869
+ return None, type_operator, True
1870
+ if enum_state.get("WFSerializationType") != "WFStringSubstitutableState":
1871
+ return None, type_operator, True
1872
+ enum_value = enum_state.get("Value")
1873
+ if isinstance(enum_value, str) and enum_value:
1874
+ type_value = enum_value
1875
+ continue
1876
+ return None, type_operator, True
1877
+ return type_value, type_operator, malformed_legacy_value_row
1878
+
1879
+
1880
+ def _lang_value_is_code(value) -> bool:
1881
+ if isinstance(value, str):
1882
+ return bool(LANG_CODE_RE.match(value)) and " " not in value
1883
+ if isinstance(value, dict) and value.get("WFSerializationType") == "WFTextTokenString":
1884
+ inner = value.get("Value")
1885
+ if isinstance(inner, dict):
1886
+ s = inner.get("string")
1887
+ if isinstance(s, str) and "" not in s:
1888
+ return bool(LANG_CODE_RE.match(s)) and " " not in s
1889
+ return False
1890
+
1891
+
1892
+ def _extract_input_variable_name(value) -> Optional[str]:
1893
+ if not isinstance(value, dict):
1894
+ return None
1895
+ if value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict):
1896
+ value = value.get("Variable")
1897
+ if not isinstance(value, dict):
1898
+ return None
1899
+ # WFTextTokenAttachment with direct variable
1900
+ val = value.get("Value")
1901
+ if isinstance(val, dict) and val.get("Type") == "Variable":
1902
+ name = val.get("VariableName")
1903
+ if isinstance(name, str) and name.strip():
1904
+ return name
1905
+ # WFTextTokenString with variable attachment
1906
+ if value.get("WFSerializationType") == "WFTextTokenString":
1907
+ val = value.get("Value")
1908
+ if isinstance(val, dict):
1909
+ attachments = val.get("attachmentsByRange")
1910
+ if isinstance(attachments, dict):
1911
+ for att in attachments.values():
1912
+ if isinstance(att, dict) and att.get("Type") == "Variable":
1913
+ name = att.get("VariableName")
1914
+ if isinstance(name, str) and name.strip():
1915
+ return name
1916
+ return None
1917
+
1918
+
1919
+ def _extract_input_variable_names(value) -> set[str]:
1920
+ return {name for name in iter_variable_names(value) if isinstance(name, str) and name.strip()}
1921
+
1922
+
1923
+ def _reassigned_list_variable_name(value, set_counts: dict[str, int], list_set_counts: dict[str, int]) -> str | None:
1924
+ name = _extract_input_variable_name(value)
1925
+ if (
1926
+ name
1927
+ and set_counts.get(name, 0) > 1
1928
+ and list_set_counts.get(name, 0) > 1
1929
+ ):
1930
+ return name
1931
+ return None
1932
+
1933
+
1934
+ def _input_action_output_uuids(value) -> list[str]:
1935
+ if not isinstance(value, dict):
1936
+ return []
1937
+ if value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict):
1938
+ value = value.get("Variable")
1939
+ ser = value.get("WFSerializationType")
1940
+ if ser == "WFTextTokenAttachment":
1941
+ val = value.get("Value")
1942
+ if isinstance(val, dict) and val.get("Type") == "ActionOutput":
1943
+ uuid = val.get("OutputUUID")
1944
+ return [uuid] if uuid else []
1945
+ if ser == "WFTextTokenString":
1946
+ val = value.get("Value") if isinstance(value.get("Value"), dict) else {}
1947
+ attachments = val.get("attachmentsByRange")
1948
+ if isinstance(attachments, dict):
1949
+ out = []
1950
+ for att in attachments.values():
1951
+ if isinstance(att, dict) and att.get("Type") == "ActionOutput":
1952
+ uuid = att.get("OutputUUID")
1953
+ if uuid:
1954
+ out.append(uuid)
1955
+ return out
1956
+ return []
1957
+
1958
+
1959
+ def _input_has_current_date_token(value) -> bool:
1960
+ if not isinstance(value, dict):
1961
+ return False
1962
+ if value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict):
1963
+ value = value.get("Variable")
1964
+ ser = value.get("WFSerializationType")
1965
+ if ser == "WFTextTokenAttachment":
1966
+ val = value.get("Value")
1967
+ return isinstance(val, dict) and val.get("Type") == "CurrentDate"
1968
+ if ser == "WFTextTokenString":
1969
+ val = value.get("Value") if isinstance(value.get("Value"), dict) else {}
1970
+ attachments = val.get("attachmentsByRange")
1971
+ if isinstance(attachments, dict):
1972
+ return any(
1973
+ isinstance(att, dict) and att.get("Type") == "CurrentDate"
1974
+ for att in attachments.values()
1975
+ )
1976
+ return False
1977
+
1978
+
1979
+ def _collect_send_message_empty_text_spacer_uuids(actions: list[dict]) -> set[str]:
1980
+ """Empty Text outputs are allowed only as Send Message payload spacers."""
1981
+ send_message_vars: set[str] = set()
1982
+ empty_text_uuids: set[str] = set()
1983
+
1984
+ for act in actions:
1985
+ ident = act.get("WFWorkflowActionIdentifier")
1986
+ params = act.get("WFWorkflowActionParameters") or {}
1987
+ if ident == "is.workflow.actions.sendmessage":
1988
+ name = _extract_input_variable_name(params.get("WFSendMessageContent"))
1989
+ if name:
1990
+ send_message_vars.add(name)
1991
+ elif ident == "is.workflow.actions.gettext":
1992
+ uuid = params.get("UUID")
1993
+ if isinstance(uuid, str) and params.get("WFTextActionText") == "":
1994
+ empty_text_uuids.add(uuid)
1995
+
1996
+ if not send_message_vars or not empty_text_uuids:
1997
+ return set()
1998
+
1999
+ spacer_uuids: set[str] = set()
2000
+ for act in actions:
2001
+ if act.get("WFWorkflowActionIdentifier") != "is.workflow.actions.appendvariable":
2002
+ continue
2003
+ params = act.get("WFWorkflowActionParameters") or {}
2004
+ if params.get("WFVariableName") not in send_message_vars:
2005
+ continue
2006
+ for uuid in _input_action_output_uuids(params.get("WFInput")):
2007
+ if uuid in empty_text_uuids:
2008
+ spacer_uuids.add(uuid)
2009
+ return spacer_uuids
2010
+
2011
+
2012
+ def _input_has_reference(value) -> bool:
2013
+ if _extract_input_variable_name(value) or _input_action_output_uuids(value):
2014
+ return True
2015
+ if not isinstance(value, dict):
2016
+ return False
2017
+ if value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict):
2018
+ value = value.get("Variable")
2019
+ if not isinstance(value, dict):
2020
+ return False
2021
+ if value.get("WFSerializationType") == "WFTextTokenAttachment":
2022
+ val = value.get("Value")
2023
+ return isinstance(val, dict) and val.get("Type") == "ExtensionInput"
2024
+ if value.get("WFSerializationType") == "WFTextTokenString":
2025
+ val = value.get("Value")
2026
+ if isinstance(val, dict):
2027
+ attachments = val.get("attachmentsByRange")
2028
+ if isinstance(attachments, dict):
2029
+ for att in attachments.values():
2030
+ if isinstance(att, dict) and att.get("Type") == "ExtensionInput":
2031
+ return True
2032
+ return False
2033
+
2034
+
2035
+ def _math_operand_has_reference(value) -> bool:
2036
+ if value is None:
2037
+ return False
2038
+ if isinstance(value, (int, float)):
2039
+ return True
2040
+ if isinstance(value, str) and value.strip() != "":
2041
+ return True
2042
+ if isinstance(value, dict):
2043
+ if value.get("WFSerializationType") in {"WFTextTokenAttachment", "WFTextTokenString"}:
2044
+ val = value.get("Value")
2045
+ if isinstance(val, dict):
2046
+ if val.get("Type") == "Variable" and val.get("VariableName"):
2047
+ return True
2048
+ if val.get("Type") == "ActionOutput" and val.get("OutputUUID"):
2049
+ return True
2050
+ if isinstance(val.get("string"), str) and val.get("string").strip() != "":
2051
+ return True
2052
+ return False
2053
+
2054
+
2055
+ def _math_operand_literal_string(value) -> Optional[str]:
2056
+ if isinstance(value, (int, float)):
2057
+ return str(value)
2058
+ if isinstance(value, str):
2059
+ stripped = value.strip()
2060
+ return stripped or None
2061
+ if isinstance(value, dict):
2062
+ val = value.get("Value")
2063
+ if isinstance(val, dict):
2064
+ string_value = val.get("string")
2065
+ if isinstance(string_value, str):
2066
+ stripped = string_value.strip()
2067
+ return stripped or None
2068
+ return None
2069
+
2070
+
2071
+ def iter_aggrandizements(obj):
2072
+ """Yield all Aggrandizement dicts found recursively in a parameter tree."""
2073
+ if isinstance(obj, dict):
2074
+ aggs = obj.get("Aggrandizements")
2075
+ if isinstance(aggs, list):
2076
+ for item in aggs:
2077
+ if isinstance(item, dict):
2078
+ yield item
2079
+ for v in obj.values():
2080
+ yield from iter_aggrandizements(v)
2081
+ elif isinstance(obj, list):
2082
+ for v in obj:
2083
+ yield from iter_aggrandizements(v)
2084
+
2085
+
2086
+ WORKFLOW_TRIGGER_KEYS = {
2087
+ "WFTriggerIdentifier",
2088
+ "WFTriggerSerializedParameters",
2089
+ "WFTriggerUUID",
2090
+ }
2091
+ WORKFLOW_TRIGGER_IDENTIFIERS_REQUIRING_EDITOR_CONFIGURATION = {
2092
+ # OS 27 exports these as bare WFWorkflowTriggers dictionaries, but the
2093
+ # Shortcuts editor marks those imports invalid until picker state is created
2094
+ # inside the app. The file/folder/drive selection is stored outside the
2095
+ # portable trigger dictionary, so XML generation cannot safely synthesize it.
2096
+ "WFDiskMountTrigger",
2097
+ "WFFileTrigger",
2098
+ "WFFolderTrigger",
2099
+ }
2100
+
2101
+
2102
+ def _contains_catalog_placeholder(value) -> bool:
2103
+ if isinstance(value, dict):
2104
+ if "$placeholder" in value:
2105
+ return True
2106
+ return any(_contains_catalog_placeholder(item) for item in value.values())
2107
+ if isinstance(value, list):
2108
+ return any(_contains_catalog_placeholder(item) for item in value)
2109
+ return False
2110
+
2111
+
2112
+ def _validate_workflow_triggers(
2113
+ plist,
2114
+ errors: list[str],
2115
+ workflow_trigger_catalog: dict[str, object],
2116
+ target_macos_major: int | None,
2117
+ ) -> None:
2118
+ if "WFWorkflowTriggers" not in plist:
2119
+ return
2120
+ if target_macos_major is not None and target_macos_major < WORKFLOW_TRIGGER_CATALOG_MIN_MACOS_MAJOR:
2121
+ errors.append(
2122
+ "WFWorkflowTriggers requires macOS/iOS 27+. "
2123
+ f"Target macOS is {target_macos_major}; remove the trigger header or validate with --target-macos 27."
2124
+ )
2125
+ triggers = plist.get("WFWorkflowTriggers")
2126
+ if not isinstance(triggers, list):
2127
+ errors.append("WFWorkflowTriggers must be an array")
2128
+ return
2129
+ workflow_identifiers = workflow_trigger_catalog.get("workflow_identifiers")
2130
+ if not isinstance(workflow_identifiers, set):
2131
+ workflow_identifiers = set()
2132
+ serialized_keys_by_identifier = workflow_trigger_catalog.get(
2133
+ "serialized_keys_by_identifier"
2134
+ )
2135
+ if not isinstance(serialized_keys_by_identifier, dict):
2136
+ serialized_keys_by_identifier = {}
2137
+
2138
+ for index, trigger in enumerate(triggers):
2139
+ prefix = f"WFWorkflowTriggers[{index}]"
2140
+ if not isinstance(trigger, dict):
2141
+ errors.append(f"{prefix} must be a dictionary")
2142
+ continue
2143
+ missing = sorted(WORKFLOW_TRIGGER_KEYS - set(trigger))
2144
+ if missing:
2145
+ errors.append(f"{prefix} missing required key(s): {', '.join(missing)}")
2146
+ extra = sorted(set(trigger) - WORKFLOW_TRIGGER_KEYS)
2147
+ if extra:
2148
+ errors.append(f"{prefix} has unknown key(s): {', '.join(extra)}")
2149
+
2150
+ wf_identifier = trigger.get("WFTriggerIdentifier")
2151
+ if not isinstance(wf_identifier, str) or not wf_identifier.strip():
2152
+ errors.append(f"{prefix}.WFTriggerIdentifier must be a non-empty string")
2153
+ elif workflow_identifiers and wf_identifier not in workflow_identifiers:
2154
+ errors.append(
2155
+ f"{prefix}.WFTriggerIdentifier is not in the exported OS 27 trigger catalog: "
2156
+ f"{wf_identifier}"
2157
+ )
2158
+
2159
+ trigger_uuid = trigger.get("WFTriggerUUID")
2160
+ if not isinstance(trigger_uuid, str) or not trigger_uuid.strip():
2161
+ errors.append(f"{prefix}.WFTriggerUUID must be a non-empty UUID string")
2162
+ elif not UUID_RE.match(trigger_uuid):
2163
+ errors.append(
2164
+ f"{prefix}.WFTriggerUUID must be an uppercase UUID generated with uuidgen: "
2165
+ f"{trigger_uuid!r}"
2166
+ )
2167
+ elif REPEATING_UUID_RE.search(trigger_uuid):
2168
+ errors.append(
2169
+ f"{prefix}.WFTriggerUUID is a repeating placeholder UUID: {trigger_uuid}"
2170
+ )
2171
+
2172
+ serialized = trigger.get("WFTriggerSerializedParameters")
2173
+ if not isinstance(serialized, dict):
2174
+ errors.append(f"{prefix}.WFTriggerSerializedParameters must be a dictionary")
2175
+ continue
2176
+ if _contains_catalog_placeholder(serialized):
2177
+ errors.append(
2178
+ f"{prefix}.WFTriggerSerializedParameters still contains catalog placeholder values; "
2179
+ "replace local picker payloads with user-provided/exported values before signing."
2180
+ )
2181
+ if isinstance(wf_identifier, str):
2182
+ if wf_identifier in WORKFLOW_TRIGGER_IDENTIFIERS_REQUIRING_EDITOR_CONFIGURATION:
2183
+ errors.append(
2184
+ f"{prefix}.{wf_identifier} cannot currently be generated as portable "
2185
+ "WFWorkflowTriggers XML. macOS 27 exports a lossy empty trigger dictionary, "
2186
+ "but Shortcuts stores the required picker state outside that dictionary and "
2187
+ "imports the bare header as an invalid automation."
2188
+ )
2189
+ expected_keys = serialized_keys_by_identifier.get(wf_identifier, set())
2190
+ if isinstance(expected_keys, set) and expected_keys:
2191
+ unknown = sorted(key for key in serialized if key not in expected_keys)
2192
+ if unknown:
2193
+ expected = ", ".join(sorted(expected_keys))
2194
+ errors.append(
2195
+ f"{prefix}.WFTriggerSerializedParameters has unknown key(s) for "
2196
+ f"{wf_identifier}: {', '.join(unknown)}. Exported/ToolKit keys: {expected}."
2197
+ )
2198
+
2199
+
2200
+ def validate(
2201
+ plist,
2202
+ allowed_ids: set[str],
2203
+ allowed_glyph_ids: Optional[set[int]] = None,
2204
+ allowed_icon_colors: Optional[set[int]] = None,
2205
+ unavailable_ids: Optional[dict[str, str]] = None,
2206
+ unavailable_parameter_keys: Optional[dict[str, dict[str, str]]] = None,
2207
+ toolkit_parameter_schemas: Optional[dict[str, set[str]]] = None,
2208
+ toolkit_parameter_enum_cases: Optional[dict[str, dict[str, set[str]]]] = None,
2209
+ toolkit_parameter_boolean_keys: Optional[dict[str, set[str]]] = None,
2210
+ workflow_trigger_catalog: Optional[dict[str, object]] = None,
2211
+ target_macos_major: int | None = None,
2212
+ ) -> Tuple[list[str], Optional[Tuple[int, str, str]]]:
2213
+ errors: list[str] = []
2214
+ first_error: Optional[Tuple[int, str, str]] = None
2215
+ unavailable_ids = unavailable_ids or {}
2216
+ unavailable_parameter_keys = unavailable_parameter_keys or {}
2217
+ toolkit_parameter_schemas = toolkit_parameter_schemas or {}
2218
+ toolkit_parameter_enum_cases = toolkit_parameter_enum_cases or {}
2219
+ toolkit_parameter_boolean_keys = toolkit_parameter_boolean_keys or {}
2220
+ workflow_trigger_catalog = workflow_trigger_catalog or {}
2221
+ actions = plist.get("WFWorkflowActions", [])
2222
+ comments: list[str] = []
2223
+ uuid_to_ident: dict[str, str] = {}
2224
+ uuid_to_params: dict[str, dict] = {}
2225
+ append_counts = _collect_variable_append_counts(actions)
2226
+ send_message_empty_text_spacer_uuids = _collect_send_message_empty_text_spacer_uuids(actions)
2227
+ used_output_uuids: set[str] = set()
2228
+ repeat_stack: list[dict] = []
2229
+ repeat_end_uuids: dict[str, str] = {}
2230
+ defined_vars: set[str] = set()
2231
+ setvariable_counts: dict[str, int] = {}
2232
+ list_variable_set_counts: dict[str, int] = {}
2233
+ uuid_to_var_name: dict[str, str] = {}
2234
+ var_format_dates: dict[str, dict] = {}
2235
+ cents_vars: set[str] = set()
2236
+ divide_by_100_vars: set[str] = set()
2237
+ divide_by_100_uuids: set[str] = set()
2238
+ sleep_duration_divide_by_60_uuids: set[str] = set()
2239
+ sleep_duration_divide_by_60_round_uuids: set[str] = set()
2240
+ cents_context_steps = 0
2241
+ unit_text_found = False
2242
+ has_measurement_convert = False
2243
+ weather_source_vars: set[str] = set()
2244
+ location_source_vars: set[str] = set()
2245
+ health_sample_source_vars: set[str] = set()
2246
+ rename_file_source_vars: dict[str, int] = {}
2247
+ rename_file_source_uuids: dict[str, int] = {}
2248
+ rename_file_output_uuids: dict[str, int] = {}
2249
+
2250
+ _validate_workflow_triggers(
2251
+ plist,
2252
+ errors,
2253
+ workflow_trigger_catalog,
2254
+ target_macos_major,
2255
+ )
2256
+
2257
+ for idx, act in enumerate(actions):
2258
+ ident = act.get("WFWorkflowActionIdentifier")
2259
+ if ident == "is.workflow.actions.comment":
2260
+ params = act.get("WFWorkflowActionParameters") or {}
2261
+ text = params.get("WFCommentActionText")
2262
+ if isinstance(text, str):
2263
+ comments.append(text)
2264
+ wf_terms = _comment_internal_wf_terms(text)
2265
+ if wf_terms:
2266
+ hints = ", ".join(
2267
+ f"{token}->{_friendly_comment_field_name(token)}" for token in wf_terms
2268
+ )
2269
+ errors.append(
2270
+ f"Comment uses internal WF parameter names at index {idx}: {hints}. "
2271
+ "Use Shortcuts UI wording (for example: Input, Date, Provided Input, Repeat Item)."
2272
+ )
2273
+ if not unit_text_found and _text_has_unit_keywords(text):
2274
+ unit_text_found = True
2275
+ if ident == "is.workflow.actions.measurement.convert":
2276
+ has_measurement_convert = True
2277
+ params = act.get("WFWorkflowActionParameters") or {}
2278
+ parameter_reasons = unavailable_parameter_keys.get(ident) if ident else None
2279
+ if parameter_reasons:
2280
+ for key in sorted(parameter_reasons):
2281
+ if key in params:
2282
+ errors.append(
2283
+ f"Parameter '{key}' on {ident} requires {parameter_reasons[key]} "
2284
+ f"at index {idx}. Set --target-macos 27 or "
2285
+ "SHORTCUTS_PLAYGROUND_TARGET_MACOS=27 only when building for Golden Gate."
2286
+ )
2287
+ if isinstance(ident, str) and ident.startswith("com.apple."):
2288
+ descriptor = params.get("AppIntentDescriptor")
2289
+ if not isinstance(descriptor, dict):
2290
+ errors.append(
2291
+ f"AppIntent action missing AppIntentDescriptor at index {idx}: {ident}"
2292
+ )
2293
+ elif not descriptor.get("AppIntentIdentifier"):
2294
+ errors.append(
2295
+ f"AppIntentDescriptor missing AppIntentIdentifier at index {idx}: {ident}"
2296
+ )
2297
+ expected_toolkit_keys = toolkit_parameter_schemas.get(ident) if ident else None
2298
+ if expected_toolkit_keys is not None:
2299
+ unknown_keys = sorted(
2300
+ key
2301
+ for key in params
2302
+ if key not in expected_toolkit_keys
2303
+ and key not in TOOLKIT_PARAMETER_STRUCTURAL_KEYS
2304
+ )
2305
+ if unknown_keys:
2306
+ expected = ", ".join(sorted(expected_toolkit_keys)) or "no action parameters"
2307
+ errors.append(
2308
+ f"Unknown AppIntent parameter key(s) for {ident} at index {idx}: "
2309
+ f"{', '.join(unknown_keys)}. ToolKit v78 expects: {expected}."
2310
+ )
2311
+ expected_enum_cases = toolkit_parameter_enum_cases.get(ident) if ident else None
2312
+ if expected_enum_cases:
2313
+ enum_error_kind = "AppIntent" if str(ident).startswith("com.apple.") else "ToolKit"
2314
+ for key, allowed_values in sorted(expected_enum_cases.items()):
2315
+ if key not in params:
2316
+ continue
2317
+ literal_values = _literal_enum_values(params.get(key))
2318
+ for literal_value in literal_values:
2319
+ if literal_value not in allowed_values and not _is_allowed_toolkit_enum_alias(
2320
+ ident,
2321
+ key,
2322
+ literal_value,
2323
+ allowed_values,
2324
+ ):
2325
+ errors.append(
2326
+ f"Invalid {enum_error_kind} enum value for {ident}.{key} at index {idx}: "
2327
+ f"{literal_value!r}. ToolKit v78 allows: "
2328
+ f"{_format_allowed_values(allowed_values)}."
2329
+ )
2330
+ expected_boolean_keys = toolkit_parameter_boolean_keys.get(ident) if ident else None
2331
+ if expected_boolean_keys:
2332
+ boolean_error_kind = "AppIntent" if str(ident).startswith("com.apple.") else "ToolKit"
2333
+ for key in sorted(expected_boolean_keys):
2334
+ value = params.get(key)
2335
+ if (
2336
+ key in params
2337
+ and not isinstance(value, bool)
2338
+ and not _parameter_is_dynamic_attachment(value)
2339
+ ):
2340
+ errors.append(
2341
+ f"Invalid {boolean_error_kind} boolean value for {ident}.{key} at index {idx}: "
2342
+ "expected a plist boolean."
2343
+ )
2344
+ used_output_uuids.update(iter_action_output_refs(params))
2345
+ if not unit_text_found:
2346
+ for path, s in iter_strings(params):
2347
+ if path and path[-1] in UNIT_KEYWORD_IGNORED_KEYS:
2348
+ continue
2349
+ if isinstance(s, str) and s and _text_has_unit_keywords(s):
2350
+ unit_text_found = True
2351
+ break
2352
+ uuid = params.get("UUID")
2353
+ if uuid and ident:
2354
+ uuid_to_ident[uuid] = ident
2355
+ uuid_to_params[uuid] = params
2356
+
2357
+ allow_vcard = any(VCARD_MARKER in text.upper() for text in comments)
2358
+ allow_token_file = any(TOKEN_FILE_MARKER in text.upper() for text in comments)
2359
+ allow_datetime_format = any(ALLOW_DATETIME_FORMAT_MARKER in text.upper() for text in comments)
2360
+ allow_manual_unit_conversion = any(
2361
+ ALLOW_MANUAL_UNIT_CONVERSION_MARKER in text.upper() for text in comments
2362
+ )
2363
+
2364
+ # --- GroupingIdentifier consistency check ---
2365
+ grouping_start: dict[str, tuple[int, str]] = {} # grouping_id -> (idx, ident)
2366
+ grouping_seen_end: set[str] = set()
2367
+
2368
+ for idx, act in enumerate(actions):
2369
+ ident = act.get("WFWorkflowActionIdentifier")
2370
+ if ident not in CONTROL_FLOW_TOOLKIT_EXCEPTIONS:
2371
+ continue
2372
+ params = act.get("WFWorkflowActionParameters") or {}
2373
+ mode = _coerce_control_flow_mode(params)
2374
+ if mode is None:
2375
+ mode = 0
2376
+ grouping = params.get("GroupingIdentifier")
2377
+ if not grouping:
2378
+ continue
2379
+ if mode == 0:
2380
+ if grouping in grouping_start:
2381
+ errors.append(
2382
+ f"Duplicate GroupingIdentifier '{grouping[:12]}...' for start action at index {idx}; "
2383
+ f"first seen at index {grouping_start[grouping][0]}"
2384
+ )
2385
+ else:
2386
+ grouping_start[grouping] = (idx, ident or "")
2387
+ elif mode in (1, 2):
2388
+ if grouping not in grouping_start:
2389
+ errors.append(
2390
+ f"Control-flow middle/end at index {idx} has GroupingIdentifier with no matching start (mode 0)"
2391
+ )
2392
+ else:
2393
+ start_ident = grouping_start[grouping][1]
2394
+ if ident != start_ident:
2395
+ errors.append(
2396
+ f"GroupingIdentifier mismatch at index {idx}: action '{ident}' "
2397
+ f"does not match start action '{start_ident}'"
2398
+ )
2399
+ if mode == 2:
2400
+ grouping_seen_end.add(grouping)
2401
+
2402
+ for grouping, (start_idx, start_ident) in grouping_start.items():
2403
+ if grouping not in grouping_seen_end:
2404
+ errors.append(
2405
+ f"Control-flow start '{start_ident}' at index {start_idx} has no matching end (mode 2)"
2406
+ )
2407
+
2408
+ # --- Menu case count and title matching check ---
2409
+ menu_items_by_grouping: dict[str, list[str]] = {}
2410
+ menu_cases_by_grouping: dict[str, list[tuple[int, str]]] = {}
2411
+
2412
+ for idx, act in enumerate(actions):
2413
+ if act.get("WFWorkflowActionIdentifier") != "is.workflow.actions.choosefrommenu":
2414
+ continue
2415
+ params = act.get("WFWorkflowActionParameters") or {}
2416
+ mode = _coerce_control_flow_mode(params)
2417
+ if mode is None:
2418
+ mode = 0
2419
+ grouping = params.get("GroupingIdentifier")
2420
+ if not grouping:
2421
+ continue
2422
+ if mode == 0:
2423
+ items = params.get("WFMenuItems")
2424
+ if isinstance(items, list):
2425
+ # Menu items can be plain strings or rich dicts with WFValue
2426
+ extracted = []
2427
+ for i in items:
2428
+ if isinstance(i, str):
2429
+ extracted.append(i)
2430
+ elif isinstance(i, dict):
2431
+ extracted.append(str(i)) # count it even if rich format
2432
+ menu_items_by_grouping[grouping] = extracted
2433
+ elif mode == 1:
2434
+ title = params.get("WFMenuItemTitle", "")
2435
+ menu_cases_by_grouping.setdefault(grouping, []).append((idx, title))
2436
+
2437
+ for grouping, items in menu_items_by_grouping.items():
2438
+ cases = menu_cases_by_grouping.get(grouping, [])
2439
+ if len(cases) != len(items):
2440
+ errors.append(
2441
+ f"Choose from Menu: {len(items)} item(s) in WFMenuItems but {len(cases)} case action(s) found"
2442
+ )
2443
+ else:
2444
+ # Only validate title matching when items are plain strings
2445
+ raw_items = menu_items_by_grouping.get(grouping, [])
2446
+ original_list = None
2447
+ for idx2, act2 in enumerate(actions):
2448
+ if act2.get("WFWorkflowActionIdentifier") == "is.workflow.actions.choosefrommenu":
2449
+ p2 = act2.get("WFWorkflowActionParameters") or {}
2450
+ if p2.get("GroupingIdentifier") == grouping:
2451
+ m2 = p2.get("WFControlFlowMode")
2452
+ if isinstance(m2, str) and m2.isdigit():
2453
+ m2 = int(m2)
2454
+ if m2 is None or m2 == 0:
2455
+ original_list = p2.get("WFMenuItems", [])
2456
+ break
2457
+ all_strings = original_list and all(isinstance(i, str) for i in original_list)
2458
+ if all_strings:
2459
+ for order_idx, ((case_act_idx, case_title), expected) in enumerate(zip(cases, items)):
2460
+ if case_title != expected:
2461
+ errors.append(
2462
+ f"Choose from Menu case {order_idx} at index {case_act_idx}: "
2463
+ f"WFMenuItemTitle '{case_title}' does not match WFMenuItems[{order_idx}] '{expected}'"
2464
+ )
2465
+
2466
+ input_classes = plist.get("WFWorkflowInputContentItemClasses") or []
2467
+ uses_javascript_webpage = any(
2468
+ act.get("WFWorkflowActionIdentifier") == "is.workflow.actions.runjavascriptonwebpage"
2469
+ for act in actions
2470
+ )
2471
+ uses_input = False
2472
+ for act in actions:
2473
+ ident = act.get("WFWorkflowActionIdentifier")
2474
+ params = act.get("WFWorkflowActionParameters") or {}
2475
+ if ident == "is.workflow.actions.input":
2476
+ uses_input = True
2477
+ break
2478
+ for name in iter_variable_names(params):
2479
+ if name == "Shortcut Input":
2480
+ uses_input = True
2481
+ break
2482
+ if uses_input:
2483
+ break
2484
+ for _, out_name in iter_action_output_refs_with_name(params):
2485
+ if out_name == "Shortcut Input":
2486
+ uses_input = True
2487
+ break
2488
+ if uses_input:
2489
+ break
2490
+ if any(iter_extension_input_refs(params)):
2491
+ uses_input = True
2492
+ break
2493
+ if not input_classes and uses_input:
2494
+ errors.append(
2495
+ "Shortcut uses Shortcut Input but WFWorkflowInputContentItemClasses is empty (causes Stop and Respond)."
2496
+ )
2497
+ if input_classes and not uses_input and not uses_javascript_webpage:
2498
+ errors.append(
2499
+ "WFWorkflowInputContentItemClasses is set but Shortcut Input is unused; remove input types or use Shortcut Input."
2500
+ )
2501
+
2502
+ workflow_types = plist.get("WFWorkflowTypes") or []
2503
+ if uses_javascript_webpage:
2504
+ if "ActionExtension" not in workflow_types:
2505
+ errors.append(
2506
+ "Run JavaScript on Webpage requires ActionExtension in WFWorkflowTypes so it can run from Safari share sheet."
2507
+ )
2508
+ if "WFSafariWebPageContentItem" not in input_classes:
2509
+ errors.append(
2510
+ "Run JavaScript on Webpage requires WFSafariWebPageContentItem in WFWorkflowInputContentItemClasses."
2511
+ )
2512
+ extra_classes = [cls for cls in input_classes if cls != "WFSafariWebPageContentItem"]
2513
+ if extra_classes:
2514
+ errors.append(
2515
+ "Run JavaScript on Webpage shortcuts should scope share-sheet input to Safari webpages only; "
2516
+ f"remove extra input classes: {', '.join(extra_classes)}."
2517
+ )
2518
+
2519
+ icon_dict = plist.get("WFWorkflowIcon")
2520
+ if not isinstance(icon_dict, dict):
2521
+ errors.append("Missing WFWorkflowIcon root dictionary")
2522
+ else:
2523
+ glyph_number = icon_dict.get("WFWorkflowIconGlyphNumber")
2524
+ start_color = icon_dict.get("WFWorkflowIconStartColor")
2525
+
2526
+ if glyph_number is None:
2527
+ errors.append("WFWorkflowIcon missing WFWorkflowIconGlyphNumber")
2528
+ else:
2529
+ try:
2530
+ glyph_int = int(glyph_number)
2531
+ except (TypeError, ValueError):
2532
+ errors.append("WFWorkflowIconGlyphNumber must be an integer")
2533
+ else:
2534
+ if allowed_glyph_ids and glyph_int not in allowed_glyph_ids:
2535
+ errors.append(
2536
+ f"WFWorkflowIconGlyphNumber {glyph_int} is not in the official 507 glyph mapping"
2537
+ )
2538
+
2539
+ if start_color is None:
2540
+ errors.append("WFWorkflowIcon missing WFWorkflowIconStartColor")
2541
+ else:
2542
+ try:
2543
+ color_int = int(start_color)
2544
+ except (TypeError, ValueError):
2545
+ errors.append("WFWorkflowIconStartColor must be an integer")
2546
+ else:
2547
+ if allowed_icon_colors and color_int not in allowed_icon_colors:
2548
+ errors.append(
2549
+ f"WFWorkflowIconStartColor {color_int} is not in the known Shortcuts color palette"
2550
+ )
2551
+
2552
+ if actions:
2553
+ if len(actions) < 2:
2554
+ errors.append("Missing required two leading Comment actions")
2555
+ else:
2556
+ first = actions[0]
2557
+ second = actions[1]
2558
+ if first.get("WFWorkflowActionIdentifier") != "is.workflow.actions.comment":
2559
+ errors.append("First action must be a Comment describing the shortcut")
2560
+ else:
2561
+ first_text = (first.get("WFWorkflowActionParameters") or {}).get("WFCommentActionText")
2562
+ if not first_text:
2563
+ errors.append("First Comment is empty")
2564
+ if second.get("WFWorkflowActionIdentifier") != "is.workflow.actions.comment":
2565
+ errors.append("Second action must be the prompt Comment block")
2566
+ else:
2567
+ second_text = (second.get("WFWorkflowActionParameters") or {}).get("WFCommentActionText", "")
2568
+ if "Shortcuts generated by Shortcuts Playground." not in second_text:
2569
+ errors.append("Second Comment missing required Shortcuts Playground prompt text")
2570
+
2571
+ # Comment density requirements
2572
+ comment_count = len(comments)
2573
+ min_comments = 2
2574
+ if len(actions) >= 24:
2575
+ min_comments = 5
2576
+ elif len(actions) >= 16:
2577
+ min_comments = 4
2578
+ elif len(actions) >= 8:
2579
+ min_comments = 3
2580
+ if comment_count < min_comments:
2581
+ errors.append(
2582
+ f"Insufficient Comment blocks: {comment_count} found, require {min_comments} for {len(actions)} actions"
2583
+ )
2584
+
2585
+ if unit_text_found and not has_measurement_convert and not allow_manual_unit_conversion:
2586
+ errors.append(
2587
+ "Unit conversion detected but no measurement.convert action; use measurement.create + measurement.convert or add ALLOW_MANUAL_UNIT_CONVERSION comment."
2588
+ )
2589
+
2590
+ if errors and actions and first_error is None:
2591
+ first_action = actions[0]
2592
+ first_params = first_action.get("WFWorkflowActionParameters") or {}
2593
+ first_error = (0, first_action.get("WFWorkflowActionIdentifier") or "UNKNOWN", _snippet(first_params))
2594
+
2595
+ for idx, act in enumerate(actions):
2596
+ ident = act.get("WFWorkflowActionIdentifier")
2597
+ params = act.get("WFWorkflowActionParameters") or {}
2598
+ uuid = params.get("UUID")
2599
+
2600
+ # WFControlFlowMode must be <integer>, not <string>
2601
+ if ident in CONTROL_FLOW_TOOLKIT_EXCEPTIONS:
2602
+ raw_mode = params.get("WFControlFlowMode")
2603
+ if isinstance(raw_mode, str):
2604
+ errors.append(
2605
+ f"WFControlFlowMode must be <integer> not <string> at index {idx}: "
2606
+ f"{ident} (found: {raw_mode!r})"
2607
+ )
2608
+
2609
+ # Aggrandizement type validation
2610
+ for agg in iter_aggrandizements(params):
2611
+ agg_type = agg.get("Type")
2612
+ if agg_type is None:
2613
+ errors.append(f"Aggrandizement missing Type field at index {idx}: {ident}")
2614
+ elif agg_type not in KNOWN_AGGRANDIZEMENT_TYPES:
2615
+ errors.append(
2616
+ f"Unknown aggrandizement Type '{agg_type}' at index {idx}: {ident}"
2617
+ )
2618
+
2619
+ # Enforce descriptive Comment immediately before control-flow starts
2620
+ if ident in {
2621
+ "is.workflow.actions.repeat.each",
2622
+ "is.workflow.actions.repeat.count",
2623
+ "is.workflow.actions.conditional",
2624
+ "is.workflow.actions.choosefrommenu",
2625
+ }:
2626
+ mode = _coerce_control_flow_mode(params)
2627
+ if mode == 0:
2628
+ if idx == 0 or actions[idx - 1].get("WFWorkflowActionIdentifier") != "is.workflow.actions.comment":
2629
+ errors.append(
2630
+ f"Missing descriptive Comment immediately before control-flow start at index {idx}"
2631
+ )
2632
+ else:
2633
+ prev_params = actions[idx - 1].get("WFWorkflowActionParameters") or {}
2634
+ prev_text = prev_params.get("WFCommentActionText", "")
2635
+ if not _comment_has_bullets(prev_text):
2636
+ errors.append(
2637
+ f"Control-flow Comment must include a bulleted wiring list at index {idx - 1}"
2638
+ )
2639
+
2640
+ if ident == "is.workflow.actions.comment":
2641
+ text = params.get("WFCommentActionText")
2642
+ if isinstance(text, str) and "cents" in text.lower():
2643
+ cents_context_steps = 10
2644
+
2645
+ # Track repeat nesting (repeat.each and repeat.count)
2646
+ if ident in {"is.workflow.actions.repeat.each", "is.workflow.actions.repeat.count"}:
2647
+ mode = _coerce_control_flow_mode(params)
2648
+ if mode is None:
2649
+ mode = 0
2650
+ grouping = params.get("GroupingIdentifier")
2651
+ if mode == 0:
2652
+ repeat_stack.append({"group": grouping, "uuid": uuid, "kind": ident})
2653
+ elif mode == 2 and repeat_stack:
2654
+ if grouping:
2655
+ repeat_end_uuids[grouping] = uuid
2656
+ repeat_stack.pop()
2657
+
2658
+ # Validate repeat variables inside nested loops
2659
+ if repeat_stack:
2660
+ innermost = repeat_stack[-1]
2661
+ innermost_uuid = innermost.get("uuid")
2662
+ loop_label = "Repeat with Each" if innermost.get("kind") == "is.workflow.actions.repeat.each" else "Repeat (count)"
2663
+ repeat_uuids = {item.get("uuid") for item in repeat_stack if item.get("uuid")}
2664
+ active_groups = {item.get("group") for item in repeat_stack if item.get("group")}
2665
+ active_end_uuids = {repeat_end_uuids.get(g) for g in active_groups if repeat_end_uuids.get(g)}
2666
+ for out_uuid, out_name in iter_action_output_refs_with_name(params):
2667
+ if out_name and out_name.startswith("Repeat Results"):
2668
+ errors.append(
2669
+ f"Repeat Results referenced inside {loop_label} at index {idx}"
2670
+ )
2671
+ if out_uuid in repeat_uuids:
2672
+ if out_name == "Repeat Results":
2673
+ errors.append(
2674
+ f"Repeat Results referenced inside {loop_label} at index {idx}"
2675
+ )
2676
+ elif out_name in {"Repeat Item", "Repeat Index"} and out_uuid != innermost_uuid:
2677
+ errors.append(
2678
+ f"Nested repeat uses outer {out_name} at index {idx}; use innermost Repeat Item/Index"
2679
+ )
2680
+ # Even for innermost, prefer Variable references (not ActionOutput) to avoid UI showing Repeat Results
2681
+ if out_name in {"Repeat Item", "Repeat Index"}:
2682
+ errors.append(
2683
+ f"Repeat Item/Index should be referenced as a Variable (not ActionOutput) inside {loop_label} at index {idx}"
2684
+ )
2685
+ if out_uuid in active_end_uuids:
2686
+ if out_name in {"Repeat Item", "Repeat Index"}:
2687
+ errors.append(
2688
+ f"{out_name} should be referenced as a Variable (not ActionOutput) inside {loop_label} at index {idx}"
2689
+ )
2690
+ else:
2691
+ errors.append(
2692
+ f"Repeat Results (end output) used inside {loop_label} at index {idx}"
2693
+ )
2694
+ for name in iter_variable_names(params):
2695
+ if name.startswith("Repeat Results"):
2696
+ errors.append(
2697
+ f"Repeat Results variable used inside {loop_label} at index {idx}"
2698
+ )
2699
+
2700
+ # Track variable definitions
2701
+ if ident == "is.workflow.actions.setvariable":
2702
+ name = params.get("WFVariableName")
2703
+ if isinstance(name, str) and name.strip():
2704
+ defined_vars.add(name)
2705
+ setvariable_counts[name] = setvariable_counts.get(name, 0) + 1
2706
+ if "cents" in name.lower():
2707
+ cents_vars.add(name)
2708
+ wfinput = params.get("WFInput")
2709
+ out_uuids = _input_action_output_uuids(wfinput)
2710
+ source_var_name = _extract_input_variable_name(wfinput)
2711
+ list_source = False
2712
+ weather_source = bool(source_var_name and source_var_name in weather_source_vars)
2713
+ location_source = bool(source_var_name and source_var_name in location_source_vars)
2714
+ health_sample_source = bool(
2715
+ source_var_name and source_var_name in health_sample_source_vars
2716
+ )
2717
+ for source_uuid in out_uuids:
2718
+ source_ident = uuid_to_ident.get(source_uuid)
2719
+ if source_ident in LIST_PRODUCING_ACTIONS:
2720
+ list_source = True
2721
+ if source_ident == "is.workflow.actions.format.date":
2722
+ var_format_dates[name] = uuid_to_params.get(source_uuid, {})
2723
+ if source_ident in WEATHER_SOURCE_ACTIONS:
2724
+ weather_source = True
2725
+ if source_ident in LOCATION_SOURCE_ACTIONS:
2726
+ location_source = True
2727
+ if source_ident in HEALTH_SAMPLE_SOURCE_ACTIONS:
2728
+ health_sample_source = True
2729
+ if weather_source:
2730
+ weather_source_vars.add(name)
2731
+ if location_source:
2732
+ location_source_vars.add(name)
2733
+ if health_sample_source:
2734
+ health_sample_source_vars.add(name)
2735
+ if list_source:
2736
+ list_variable_set_counts[name] = list_variable_set_counts.get(name, 0) + 1
2737
+ if (
2738
+ "hour" in name.lower()
2739
+ and any(
2740
+ source_uuid in sleep_duration_divide_by_60_uuids
2741
+ or source_uuid in sleep_duration_divide_by_60_round_uuids
2742
+ for source_uuid in out_uuids
2743
+ )
2744
+ ):
2745
+ errors.append(
2746
+ f"Sleep duration math divides by 60 but stores hours in '{name}' at index {idx}; "
2747
+ "Health Duration math is seconds, so divide by 3600 for decimal hours or label the result as minutes"
2748
+ )
2749
+
2750
+ if ident == "is.workflow.actions.getvariable":
2751
+ wfvar = params.get("WFVariable")
2752
+ if isinstance(wfvar, dict):
2753
+ val = wfvar.get("Value")
2754
+ if isinstance(val, dict):
2755
+ name = val.get("VariableName")
2756
+ if isinstance(name, str) and name.strip() and uuid:
2757
+ uuid_to_var_name[uuid] = name
2758
+
2759
+ # Unknown actions
2760
+ if ident:
2761
+ if ident.startswith("is.workflow.actions."):
2762
+ if ident not in allowed_ids:
2763
+ unavailable_reason = unavailable_ids.get(ident)
2764
+ if unavailable_reason:
2765
+ errors.append(
2766
+ f"Action identifier requires {unavailable_reason} at index {idx}: {ident}. "
2767
+ "Set --target-macos 27 only for Golden Gate-only IDs, or "
2768
+ "--target-platform ios/all only when intentionally targeting iOS/iPadOS metadata."
2769
+ )
2770
+ else:
2771
+ hint = ACTION_ALIAS_HINTS.get(ident)
2772
+ if hint:
2773
+ errors.append(
2774
+ f"Unknown action identifier at index {idx}: {ident} (use {hint})"
2775
+ )
2776
+ else:
2777
+ errors.append(f"Unknown action identifier at index {idx}: {ident}")
2778
+ elif ident.startswith("com.apple."):
2779
+ if ident not in allowed_ids:
2780
+ unavailable_reason = unavailable_ids.get(ident)
2781
+ if unavailable_reason:
2782
+ errors.append(
2783
+ f"AppIntent identifier requires {unavailable_reason} at index {idx}: {ident}. "
2784
+ "Set --target-macos 27 only for Golden Gate-only IDs, or "
2785
+ "--target-platform ios/all only when intentionally targeting iOS/iPadOS metadata."
2786
+ )
2787
+ else:
2788
+ errors.append(f"Unknown AppIntent identifier at index {idx}: {ident}")
2789
+ else:
2790
+ # Accept unknown vendor-prefixed third-party identifiers so shared
2791
+ # skills remain usable without local ToolKit extraction.
2792
+ if ident not in allowed_ids and not THIRD_PARTY_IDENTIFIER_RE.match(ident):
2793
+ errors.append(f"Unknown third-party identifier at index {idx}: {ident}")
2794
+
2795
+ if ident in NOTES_CREATE_ACTIONS:
2796
+ title_val = None
2797
+ content_val = None
2798
+ for key, value in (params or {}).items():
2799
+ if key.lower() in NOTES_TITLE_KEYS and title_val is None:
2800
+ title_val = value
2801
+ if key.lower() in NOTES_CONTENT_KEYS and content_val is None:
2802
+ content_val = value
2803
+ if title_val is None:
2804
+ errors.append(f"Notes create action missing title/name parameter at index {idx}")
2805
+ elif _token_param_is_empty(title_val):
2806
+ errors.append(f"Notes create action title/name is empty at index {idx}")
2807
+ if content_val is None:
2808
+ errors.append(f"Notes create action missing content/markdown parameter at index {idx}")
2809
+ elif _token_param_is_empty(content_val):
2810
+ errors.append(f"Notes create action content/markdown is empty at index {idx}")
2811
+
2812
+ # Empty strings in parameters
2813
+ for path in iter_empty_strings(params):
2814
+ if (
2815
+ ident == "is.workflow.actions.gettext"
2816
+ and tuple(path) == ("WFTextActionText",)
2817
+ and params.get("UUID") in send_message_empty_text_spacer_uuids
2818
+ ):
2819
+ continue
2820
+ errors.append(f"Empty parameter at index {idx}: {ident} -> {'/'.join(path)}")
2821
+
2822
+ # Validate WFTextTokenString placeholders (Shortcuts uses UTF-16 indices)
2823
+ for token in iter_text_token_strings(params):
2824
+ value = token.get("Value") if isinstance(token, dict) else None
2825
+ if not isinstance(value, dict):
2826
+ errors.append(f"WFTextTokenString missing Value dict at index {idx}")
2827
+ continue
2828
+ string = value.get("string")
2829
+ attachments = value.get("attachmentsByRange")
2830
+ if not isinstance(string, str):
2831
+ errors.append(f"WFTextTokenString missing string at index {idx}")
2832
+ continue
2833
+ if attachments is None:
2834
+ if "" in string:
2835
+ errors.append(f"WFTextTokenString missing attachmentsByRange at index {idx}")
2836
+ continue
2837
+ if not isinstance(attachments, dict):
2838
+ errors.append(f"WFTextTokenString attachmentsByRange not a dict at index {idx}")
2839
+ continue
2840
+ units = _utf16_units(string)
2841
+ total_len = 0
2842
+ for key in attachments.keys():
2843
+ m = re.match(r"^\{(\d+),\s*(\d+)\}$", str(key))
2844
+ if not m:
2845
+ errors.append(f"Invalid attachmentsByRange key at index {idx}: {key}")
2846
+ continue
2847
+ start = int(m.group(1))
2848
+ length = int(m.group(2))
2849
+ total_len += length
2850
+ if start < 0 or start + length > len(units):
2851
+ errors.append(
2852
+ f"attachmentsByRange out of bounds at index {idx}: got {key}; "
2853
+ f"UTF-16 length is {len(units)}; {_placeholder_position_hint(string)}"
2854
+ )
2855
+ continue
2856
+ if any(units[start + j] != 0xFFFC for j in range(length)):
2857
+ errors.append(
2858
+ f"attachmentsByRange does not match placeholder at index {idx}: got {key}; "
2859
+ f"{_placeholder_position_hint(string)}"
2860
+ )
2861
+ if string.count("") != total_len:
2862
+ errors.append(
2863
+ f"Placeholder count mismatch at index {idx}: string has {string.count('')} "
2864
+ f"placeholder(s), attachments cover {total_len}; {_placeholder_position_hint(string)}"
2865
+ )
2866
+
2867
+ # vCard/VCF usage should be explicit
2868
+ if ident != "is.workflow.actions.comment" and not allow_vcard:
2869
+ for _, value in iter_strings(params):
2870
+ upper = value.upper()
2871
+ if "BEGIN:VCARD" in upper or "VCARD" in upper:
2872
+ errors.append(f"vCard/VCF content detected at index {idx}: {ident}")
2873
+ break
2874
+
2875
+ # Token file usage should be explicit
2876
+ if not allow_token_file and ident:
2877
+ if ident in FILE_ACTION_IDS or any(ident.startswith(p) for p in FILE_ACTION_PREFIXES):
2878
+ for path, value in iter_strings(params):
2879
+ if "WFSerializationType" in path:
2880
+ continue
2881
+ if value in {
2882
+ "WFTextTokenString",
2883
+ "WFTextTokenAttachment",
2884
+ "WFTokenAttachmentParameterState",
2885
+ }:
2886
+ continue
2887
+ if TOKEN_HINT_RE.search(value or ""):
2888
+ errors.append(f"API token loaded from file at index {idx}: {ident}")
2889
+ break
2890
+
2891
+ # TODOIST update URL should include task ID placeholder
2892
+ if ident != "is.workflow.actions.comment":
2893
+ for _, value in iter_strings(params):
2894
+ if TODOIST_TASKS_PREFIX in value:
2895
+ suffix = value.split(TODOIST_TASKS_PREFIX, 1)[1]
2896
+ if "" not in suffix:
2897
+ errors.append(f"Todoist task update URL missing task ID placeholder at index {idx}")
2898
+ break
2899
+
2900
+ # Conditional tests must be fully specified for If starts and macOS 27's
2901
+ # Otherwise If middles. Plain Otherwise is also mode 1, but has no
2902
+ # WFCondition/WFInput fields.
2903
+ if ident == "is.workflow.actions.conditional":
2904
+ mode = _coerce_control_flow_mode(params)
2905
+ if mode is None:
2906
+ mode = 0
2907
+ if mode == 2:
2908
+ continue
2909
+ condition_keys = {
2910
+ "WFCondition",
2911
+ "WFInput",
2912
+ "WFConditions",
2913
+ "WFConditionalActionString",
2914
+ "WFNumberValue",
2915
+ "WFAnotherNumber",
2916
+ }
2917
+ has_condition_fields = any(key in params for key in condition_keys)
2918
+ if mode == 1 and not has_condition_fields:
2919
+ continue
2920
+ if mode not in (0, 1):
2921
+ continue
2922
+
2923
+ cond = params.get("WFCondition")
2924
+ inp = params.get("WFInput")
2925
+ wfconds = params.get("WFConditions")
2926
+ if mode == 1 and cond is None and wfconds is None:
2927
+ # Apple may preserve WFInput on imported plain Otherwise blocks,
2928
+ # but they intentionally have no condition or comparison literal.
2929
+ literal_keys = {
2930
+ "WFConditionalActionString",
2931
+ "WFNumberValue",
2932
+ "WFAnotherNumber",
2933
+ }
2934
+ if not any(key in params for key in literal_keys):
2935
+ continue
2936
+
2937
+ # Multi-condition pattern: WFConditions holds an array of templates,
2938
+ # each template is its own (WFCondition + WFInput + literal). When
2939
+ # this is set, the action does NOT have top-level WFCondition/WFInput.
2940
+ if wfconds is not None:
2941
+ if not isinstance(wfconds, dict):
2942
+ errors.append(f"Conditional WFConditions must be a dict at index {idx}")
2943
+ else:
2944
+ serialization = wfconds.get("WFSerializationType")
2945
+ if serialization != "WFContentPredicateTableTemplate":
2946
+ errors.append(
2947
+ f"Conditional WFConditions WFSerializationType must be "
2948
+ f"WFContentPredicateTableTemplate at index {idx}"
2949
+ )
2950
+ value = wfconds.get("Value")
2951
+ templates = None
2952
+ prefix = None
2953
+ if isinstance(value, dict):
2954
+ templates = value.get("WFActionParameterFilterTemplates")
2955
+ prefix = value.get("WFActionParameterFilterPrefix")
2956
+ if not templates:
2957
+ errors.append(
2958
+ f"Conditional WFConditions has empty filter templates at index {idx}"
2959
+ )
2960
+ elif not isinstance(templates, list):
2961
+ errors.append(
2962
+ f"Conditional WFConditions templates must be a list at index {idx}"
2963
+ )
2964
+ else:
2965
+ if prefix not in (0, 1):
2966
+ errors.append(
2967
+ f"Conditional WFConditions WFActionParameterFilterPrefix must be "
2968
+ f"0 (Any are true) or 1 (All are true) at index {idx}"
2969
+ )
2970
+ for tidx, template in enumerate(templates):
2971
+ if not isinstance(template, dict):
2972
+ errors.append(
2973
+ f"Conditional template {tidx} is not a dict at index {idx}"
2974
+ )
2975
+ continue
2976
+ tcond = template.get("WFCondition")
2977
+ tinp = template.get("WFInput")
2978
+ if tcond is None or tinp is None:
2979
+ errors.append(
2980
+ f"Conditional template {tidx} missing WFCondition or WFInput at index {idx}"
2981
+ )
2982
+ continue
2983
+ if not isinstance(tcond, int):
2984
+ errors.append(
2985
+ f"Conditional template {tidx} WFCondition must be integer at index {idx}"
2986
+ )
2987
+ continue
2988
+ if tcond not in ALL_CONDITION_CODES:
2989
+ errors.append(
2990
+ f"Conditional template {tidx} uses unknown WFCondition {tcond} at index {idx}"
2991
+ )
2992
+ continue
2993
+ if not _input_is_attached(tinp) or not _conditional_input_is_wrapped(tinp):
2994
+ errors.append(
2995
+ f"Conditional template {tidx} WFInput must be a Type=Variable "
2996
+ f"wrapper at index {idx}"
2997
+ )
2998
+ if tcond in STRING_CONDITION_CODES and not template.get("WFConditionalActionString"):
2999
+ errors.append(
3000
+ f"Conditional template {tidx} (string code {tcond}) missing "
3001
+ f"WFConditionalActionString at index {idx}"
3002
+ )
3003
+ if tcond == 99:
3004
+ reassigned_name = _reassigned_list_variable_name(
3005
+ tinp,
3006
+ setvariable_counts,
3007
+ list_variable_set_counts,
3008
+ )
3009
+ if reassigned_name:
3010
+ errors.append(
3011
+ f"Conditional template {tidx} checks list variable "
3012
+ f"'{reassigned_name}' after it was set multiple times at index {idx}; "
3013
+ "macOS 27 imports this list-contains pattern with a blank comparison value. "
3014
+ "Reference the final List/Add to List action output directly, or assign it "
3015
+ "once to a fresh final variable name before the If."
3016
+ )
3017
+ if tcond in NUMBER_CONDITION_CODES and template.get("WFNumberValue") is None:
3018
+ errors.append(
3019
+ f"Conditional template {tidx} (numeric code {tcond}) missing "
3020
+ f"WFNumberValue at index {idx}"
3021
+ )
3022
+ if tcond == 1003 and template.get("WFAnotherNumber") is None:
3023
+ errors.append(
3024
+ f"Conditional template {tidx} (between, code 1003) missing "
3025
+ f"WFAnotherNumber upper bound at index {idx}"
3026
+ )
3027
+ # Multi-condition is mutually exclusive with top-level WFCondition/WFInput.
3028
+ if cond is not None or inp is not None:
3029
+ errors.append(
3030
+ f"Conditional has both WFConditions multi-condition block and "
3031
+ f"top-level WFCondition/WFInput at index {idx}; remove the top-level fields"
3032
+ )
3033
+ continue
3034
+
3035
+ # Single-condition pattern: top-level WFCondition + WFInput + literal field.
3036
+ # Use `is None` (not truthiness) — code 0 (less than) is a valid integer.
3037
+ if cond is None or inp is None:
3038
+ errors.append(f"Conditional missing WFInput/WFCondition at index {idx}")
3039
+ continue
3040
+ if not isinstance(cond, int):
3041
+ errors.append(
3042
+ f"Conditional WFCondition should use integer code for runtime stability at index {idx}"
3043
+ )
3044
+ continue
3045
+ if cond not in ALL_CONDITION_CODES:
3046
+ errors.append(
3047
+ f"Conditional uses unknown WFCondition code {cond} at index {idx}; see CONTROL_FLOW.md"
3048
+ )
3049
+ continue
3050
+ if not _input_is_attached(inp):
3051
+ errors.append(f"Conditional WFInput is not a token attachment at index {idx}")
3052
+ continue
3053
+ is_wrapped = _conditional_input_is_wrapped(inp)
3054
+ if not is_wrapped:
3055
+ errors.append(
3056
+ f"Conditional WFInput must use Type=Variable wrapper for editor visibility at index {idx}"
3057
+ )
3058
+ # Per-code field requirements.
3059
+ if cond in STRING_CONDITION_CODES:
3060
+ if not params.get("WFConditionalActionString"):
3061
+ errors.append(
3062
+ f"Conditional (string code {cond}) missing WFConditionalActionString at index {idx}"
3063
+ )
3064
+ else:
3065
+ for out_uuid in _input_action_output_uuids(inp):
3066
+ ident_inp = uuid_to_ident.get(out_uuid)
3067
+ if ident_inp == "is.workflow.actions.getvalueforkey":
3068
+ errors.append(
3069
+ f"Conditional compares Dictionary Value directly at index {idx}; wrap in Text first"
3070
+ )
3071
+ if cond == 99:
3072
+ reassigned_name = _reassigned_list_variable_name(
3073
+ inp,
3074
+ setvariable_counts,
3075
+ list_variable_set_counts,
3076
+ )
3077
+ if reassigned_name:
3078
+ errors.append(
3079
+ f"Conditional checks list variable '{reassigned_name}' after it was set "
3080
+ f"{setvariable_counts.get(reassigned_name, 0)} times at index {idx}; "
3081
+ "macOS 27 imports this list-contains pattern with a blank comparison value. "
3082
+ "Reference the final List/Add to List action output directly, or assign it "
3083
+ "once to a fresh final variable name before the If."
3084
+ )
3085
+ elif cond in NUMBER_CONDITION_CODES:
3086
+ if params.get("WFNumberValue") is None:
3087
+ errors.append(
3088
+ f"Conditional (numeric code {cond}) missing WFNumberValue at index {idx}"
3089
+ )
3090
+ if cond == 1003 and params.get("WFAnotherNumber") is None:
3091
+ errors.append(
3092
+ f"Conditional (between, code 1003) missing WFAnotherNumber upper bound at index {idx}"
3093
+ )
3094
+ elif cond in EXISTENCE_CONDITION_CODES:
3095
+ # Existence checks (100/101) take no literal — only WFInput.
3096
+ if params.get("WFConditionalActionString") is not None:
3097
+ errors.append(
3098
+ f"Conditional (existence code {cond}) must not set WFConditionalActionString at index {idx}"
3099
+ )
3100
+ if params.get("WFNumberValue") is not None:
3101
+ errors.append(
3102
+ f"Conditional (existence code {cond}) must not set WFNumberValue at index {idx}"
3103
+ )
3104
+
3105
+ # Choose from Menu start requires items
3106
+ if ident == "is.workflow.actions.choosefrommenu":
3107
+ mode = _coerce_control_flow_mode(params)
3108
+ if mode is None:
3109
+ mode = 0
3110
+ if mode == 0:
3111
+ items = params.get("WFMenuItems")
3112
+ if not items:
3113
+ errors.append(f"Choose from Menu has no items at index {idx}")
3114
+ prompt = params.get("WFMenuPrompt")
3115
+ if isinstance(prompt, str) and prompt == "":
3116
+ errors.append(f"Choose from Menu has empty prompt at index {idx}")
3117
+
3118
+ # Ask for Input must have a prompt
3119
+ if ident == "is.workflow.actions.ask":
3120
+ if "WFAskActionPrompt" not in params or not params.get("WFAskActionPrompt"):
3121
+ errors.append(f"Ask for Input missing prompt at index {idx}")
3122
+ if params.get("WFAskActionDefaultAnswer") == "":
3123
+ errors.append(f"Ask for Input has empty default answer at index {idx}")
3124
+
3125
+ if ident == "is.workflow.actions.input":
3126
+ errors.append(
3127
+ f"Shortcut Input action is not runtime-safe on iOS at index {idx}; use ExtensionInput attachment instead"
3128
+ )
3129
+
3130
+ if ident == "is.workflow.actions.additemtolist":
3131
+ if _token_param_is_empty(params.get("WFListItem")):
3132
+ errors.append(f"Add Item to List missing WFListItem at index {idx}")
3133
+ if _token_param_is_empty(params.get("WFListVariable")):
3134
+ errors.append(f"Add Item to List missing WFListVariable at index {idx}")
3135
+ insert_position = params.get("WFInsertPosition")
3136
+ if not _token_param_is_empty(insert_position) and insert_position not in ADD_ITEM_TO_LIST_POSITIONS:
3137
+ errors.append(
3138
+ f"Add Item to List has unknown WFInsertPosition at index {idx}: {insert_position!r}"
3139
+ )
3140
+ if insert_position == "Index":
3141
+ item_index = params.get("WFItemIndex")
3142
+ if _token_param_is_empty(item_index):
3143
+ errors.append(f"Add Item to List position Index missing WFItemIndex at index {idx}")
3144
+ else:
3145
+ try:
3146
+ numeric_index = int(item_index)
3147
+ except (TypeError, ValueError):
3148
+ errors.append(
3149
+ f"Add Item to List WFItemIndex must be an integer at index {idx}: {item_index!r}"
3150
+ )
3151
+ else:
3152
+ if numeric_index < 1:
3153
+ errors.append(f"Add Item to List WFItemIndex must be 1 or greater at index {idx}")
3154
+ elif "WFItemIndex" in params and not _token_param_is_empty(params.get("WFItemIndex")):
3155
+ errors.append(
3156
+ f"Add Item to List WFItemIndex is only valid when WFInsertPosition is Index at index {idx}"
3157
+ )
3158
+
3159
+ if ident == "is.workflow.actions.choosefromlist":
3160
+ for key in CHOOSE_FROM_LIST_BOOLEAN_KEYS:
3161
+ value = params.get(key)
3162
+ if value is not None and not isinstance(value, bool):
3163
+ errors.append(f"{key} must be boolean at index {idx}: {ident}")
3164
+ if params.get("WFChooseFromListActionSelectAll") is True and params.get(
3165
+ "WFChooseFromListActionSelectMultiple"
3166
+ ) is not True:
3167
+ errors.append(
3168
+ f"WFChooseFromListActionSelectAll requires WFChooseFromListActionSelectMultiple at index {idx}"
3169
+ )
3170
+
3171
+ if ident in SHORTCUTS_URL_ACTIONS:
3172
+ for _, value in iter_strings(params):
3173
+ if value.startswith("shortcuts://"):
3174
+ errors.extend(_validate_shortcuts_url_literal(value, idx))
3175
+
3176
+ if ident == "is.workflow.actions.runjavascriptonwebpage":
3177
+ script_values = [
3178
+ value
3179
+ for path, value in iter_strings(params)
3180
+ if path and path[-1] != "UUID" and value.strip()
3181
+ ]
3182
+ combined_script = "\n".join(script_values)
3183
+ if "completion(" not in combined_script:
3184
+ errors.append(
3185
+ f"Run JavaScript on Webpage script must call completion(...) or completion() at index {idx}"
3186
+ )
3187
+ if re.search(r"\b(?:window\.)?(?:alert|prompt|confirm)\s*\(", combined_script):
3188
+ errors.append(
3189
+ f"Run JavaScript on Webpage script uses blocking dialog APIs at index {idx}; avoid alert/prompt/confirm"
3190
+ )
3191
+ if re.search(r"setTimeout\s*\([^,]+,\s*(?:[5-9]\d{3,}|\d{5,})", combined_script):
3192
+ errors.append(
3193
+ f"Run JavaScript on Webpage script uses a long timeout at index {idx}; keep execution short"
3194
+ )
3195
+
3196
+ # Text action should not be blank
3197
+ if ident == "is.workflow.actions.gettext":
3198
+ if "WFTextActionText" not in params:
3199
+ errors.append(f"Text action missing text at index {idx}")
3200
+ else:
3201
+ text = params.get("WFTextActionText")
3202
+ if text == "" and params.get("UUID") not in send_message_empty_text_spacer_uuids:
3203
+ errors.append(f"Text action has empty text at index {idx}")
3204
+
3205
+ if ident == "is.workflow.actions.number":
3206
+ number = params.get("WFNumberActionNumber")
3207
+ if number is None or number == "" or number == {}:
3208
+ errors.append(f"Number action missing WFNumberActionNumber at index {idx}")
3209
+
3210
+ # URL action should have URL
3211
+ if ident in REQUIRED_URL_ACTIONS:
3212
+ if not params.get("WFURLActionURL"):
3213
+ errors.append(f"URL action missing WFURLActionURL at index {idx}")
3214
+
3215
+ # Require explicit input for selected actions
3216
+ if ident in REQUIRED_INPUT_ACTIONS:
3217
+ wfinput = params.get("WFInput")
3218
+ if not wfinput:
3219
+ errors.append(f"Missing WFInput at index {idx}: {ident}")
3220
+ elif not _input_is_attached(wfinput):
3221
+ errors.append(f"WFInput is not a token attachment at index {idx}: {ident}")
3222
+ elif not _input_has_reference(wfinput):
3223
+ errors.append(f"WFInput has no variable/output reference at index {idx}: {ident}")
3224
+ else:
3225
+ for out_uuid in _input_action_output_uuids(wfinput):
3226
+ if out_uuid not in uuid_to_ident:
3227
+ errors.append(f"WFInput references unknown OutputUUID at index {idx}: {ident}")
3228
+ if ident in EDITOR_VISIBLE_INPUT_ACTIONS and not _input_is_editor_visible(wfinput):
3229
+ errors.append(
3230
+ f"WFInput should use WFTextTokenString (placeholder) or wrapped Variable for editor visibility at index {idx}: {ident}"
3231
+ )
3232
+
3233
+ if ident in STORED_CONTENT_ACTIONS:
3234
+ if _token_param_is_empty(params.get("WFStoredContentKey")):
3235
+ errors.append(f"Stored Content action missing WFStoredContentKey at index {idx}: {ident}")
3236
+ global_value = params.get("WFStoredContentGlobalValue")
3237
+ if global_value is not None and not isinstance(global_value, bool):
3238
+ errors.append(
3239
+ f"WFStoredContentGlobalValue must be boolean at index {idx}: {ident}"
3240
+ )
3241
+ if ident == "is.workflow.actions.setstoredcontent":
3242
+ wfinput = params.get("WFInput")
3243
+ if not wfinput:
3244
+ errors.append(f"Missing WFInput at index {idx}: {ident}")
3245
+ elif not _input_is_single_placeholder_token_string(wfinput):
3246
+ errors.append(
3247
+ f"Store Content WFInput must be a WFTextTokenString with exactly one object placeholder "
3248
+ f"at index {idx}; bare or wrapped token attachments import as an empty Content parameter"
3249
+ )
3250
+ elif not _input_has_reference(wfinput):
3251
+ errors.append(f"Store Content WFInput has no variable/output reference at index {idx}")
3252
+ else:
3253
+ for out_uuid in _input_action_output_uuids(wfinput):
3254
+ if out_uuid not in uuid_to_ident:
3255
+ errors.append(f"Store Content WFInput references unknown OutputUUID at index {idx}")
3256
+
3257
+ if ident == "is.workflow.actions.getonscreencontext":
3258
+ scope = params.get("WFOnScreenContextScope")
3259
+ if not _token_param_is_empty(scope) and scope not in ON_SCREEN_CONTEXT_SCOPES:
3260
+ errors.append(
3261
+ f"Get What's On Screen has unknown WFOnScreenContextScope at index {idx}: {scope!r}"
3262
+ )
3263
+ limit_enabled = params.get("WFOnScreenContextLimitEnabled")
3264
+ if limit_enabled is not None and not isinstance(limit_enabled, bool):
3265
+ errors.append(
3266
+ f"WFOnScreenContextLimitEnabled must be boolean at index {idx}"
3267
+ )
3268
+ limit_value = params.get("WFOnScreenContextLimit")
3269
+ if limit_enabled is True:
3270
+ if _token_param_is_empty(limit_value):
3271
+ errors.append(f"Get What's On Screen limit is enabled but missing WFOnScreenContextLimit at index {idx}")
3272
+ else:
3273
+ try:
3274
+ numeric_limit = float(limit_value)
3275
+ except (TypeError, ValueError):
3276
+ errors.append(
3277
+ f"WFOnScreenContextLimit must be numeric at index {idx}: {limit_value!r}"
3278
+ )
3279
+ else:
3280
+ if numeric_limit <= 0:
3281
+ errors.append(
3282
+ f"WFOnScreenContextLimit must be greater than 0 at index {idx}"
3283
+ )
3284
+
3285
+ for key in OS27_BOOLEAN_PARAMETER_KEYS_BY_ACTION.get(ident, set()):
3286
+ value = params.get(key)
3287
+ if value is not None and not isinstance(value, bool):
3288
+ errors.append(f"{key} must be boolean at index {idx}: {ident}")
3289
+
3290
+ if ident == "is.workflow.actions.appendnote":
3291
+ operation = params.get("operation")
3292
+ if not _token_param_is_empty(operation) and operation not in APPEND_NOTE_OPERATIONS:
3293
+ errors.append(
3294
+ f"Append to Note has unknown operation at index {idx}: {operation!r}"
3295
+ )
3296
+
3297
+ if ident in {"is.workflow.actions.getdistance", "is.workflow.actions.gettraveltime"}:
3298
+ route_mode = params.get("WFGetDirectionsActionMode")
3299
+ valid_modes = (
3300
+ GET_DISTANCE_ROUTE_MODES
3301
+ if ident == "is.workflow.actions.getdistance"
3302
+ else GET_TRAVEL_TIME_ROUTE_MODES
3303
+ )
3304
+ if not _token_param_is_empty(route_mode) and route_mode not in valid_modes:
3305
+ errors.append(
3306
+ f"{ident} has unknown WFGetDirectionsActionMode at index {idx}: {route_mode!r}"
3307
+ )
3308
+ if ident == "is.workflow.actions.getdistance":
3309
+ distance_unit = params.get("WFDistanceUnit")
3310
+ if not _token_param_is_empty(distance_unit) and distance_unit not in GET_DISTANCE_UNITS:
3311
+ errors.append(
3312
+ f"Get Distance has unknown WFDistanceUnit at index {idx}: {distance_unit!r}"
3313
+ )
3314
+ accuracy = params.get("Accuracy")
3315
+ if not _token_param_is_empty(accuracy) and accuracy not in GET_DISTANCE_ACCURACY_VALUES:
3316
+ errors.append(
3317
+ f"Get Distance has unknown Accuracy at index {idx}: {accuracy!r}"
3318
+ )
3319
+
3320
+ if ident == "is.workflow.actions.searchlocalbusinesses":
3321
+ sort_order = params.get("WFSearchSortOrder")
3322
+ if not _token_param_is_empty(sort_order) and sort_order not in FIND_PLACES_SORT_ORDERS:
3323
+ errors.append(
3324
+ f"Find Places has unknown WFSearchSortOrder at index {idx}: {sort_order!r}"
3325
+ )
3326
+
3327
+ if ident == "com.apple.ShortcutsActions.SetMultitaskingModeAction":
3328
+ mode = params.get("mode")
3329
+ if not _token_param_is_empty(mode) and not _parameter_is_dynamic_attachment(mode):
3330
+ mode_values = _literal_enum_values(mode)
3331
+ if not mode_values:
3332
+ errors.append(
3333
+ f"Set Multitasking Mode has unsupported mode shape at index {idx}: {mode!r}"
3334
+ )
3335
+ else:
3336
+ for mode_value in mode_values:
3337
+ if mode_value not in MULTITASKING_MODES:
3338
+ errors.append(
3339
+ f"Set Multitasking Mode has unknown mode at index {idx}: {mode_value!r}"
3340
+ )
3341
+
3342
+ if ident == "is.workflow.actions.openapp":
3343
+ windowing_format = params.get("WFWindowingFormat")
3344
+ if (
3345
+ not _token_param_is_empty(windowing_format)
3346
+ and windowing_format not in OPEN_APP_WINDOWING_FORMATS
3347
+ ):
3348
+ errors.append(
3349
+ f"Open App has unknown WFWindowingFormat at index {idx}: {windowing_format!r}"
3350
+ )
3351
+
3352
+ if ident == "is.workflow.actions.scanbarcode":
3353
+ image_file = params.get("imageFile")
3354
+ if _token_param_is_empty(image_file):
3355
+ errors.append(f"Scan QR or Barcode missing imageFile at index {idx}")
3356
+ elif not _input_is_attached(image_file):
3357
+ errors.append(f"Scan QR or Barcode imageFile is not a token attachment at index {idx}")
3358
+ elif not _input_has_reference(image_file):
3359
+ errors.append(f"Scan QR or Barcode imageFile has no variable/output reference at index {idx}")
3360
+ else:
3361
+ for out_uuid in _input_action_output_uuids(image_file):
3362
+ if out_uuid not in uuid_to_ident:
3363
+ errors.append(
3364
+ f"Scan QR or Barcode imageFile references unknown OutputUUID at index {idx}"
3365
+ )
3366
+
3367
+ if ident in {
3368
+ "com.apple.Safari.CreateNewTabGroup",
3369
+ "com.apple.mobilesafari.CreateNewTabGroup",
3370
+ } and "contents" in params:
3371
+ if _token_param_is_empty(params.get("contents")):
3372
+ errors.append(f"Create Tab Group has empty contents at index {idx}; omit contents or provide URLs/tabs")
3373
+
3374
+ if ident in {"is.workflow.actions.hide.app", "is.workflow.actions.quit.app"}:
3375
+ mode_key = "WFHideAppMode" if ident == "is.workflow.actions.hide.app" else "WFQuitAppMode"
3376
+ mode = params.get(mode_key)
3377
+ if not _token_param_is_empty(mode) and mode not in APP_ACTION_MODES:
3378
+ errors.append(f"{ident} has unknown {mode_key} at index {idx}: {mode!r}")
3379
+ if mode == "App" and _token_param_is_empty(params.get("WFApp")):
3380
+ errors.append(f"{ident} mode App missing WFApp at index {idx}")
3381
+ apps_except = params.get("WFAppsExcept")
3382
+ if "WFAppsExcept" in params and _token_param_is_empty(apps_except):
3383
+ errors.append(f"{ident} has empty WFAppsExcept at index {idx}; omit it or provide apps to keep open")
3384
+ ask_to_save = params.get("WFAskToSaveChanges")
3385
+ if ask_to_save is not None and not isinstance(ask_to_save, bool):
3386
+ errors.append(f"WFAskToSaveChanges must be boolean at index {idx}: {ident}")
3387
+
3388
+ if ident == "is.workflow.actions.filter.vpns":
3389
+ input_parameter = params.get("WFContentItemInputParameter")
3390
+ if not _token_param_is_empty(input_parameter) and input_parameter not in CONTENT_ITEM_INPUT_PARAMETERS:
3391
+ errors.append(
3392
+ f"Find VPNs has unknown WFContentItemInputParameter at index {idx}: {input_parameter!r}"
3393
+ )
3394
+ sort_property = params.get("WFContentItemSortProperty")
3395
+ if not _token_param_is_empty(sort_property) and sort_property not in VPN_SORT_PROPERTIES:
3396
+ errors.append(
3397
+ f"Find VPNs has unknown WFContentItemSortProperty at index {idx}: {sort_property!r}"
3398
+ )
3399
+ compound_type = params.get("WFCompoundType")
3400
+ if compound_type is not None and compound_type not in VPN_COMPOUND_TYPES:
3401
+ errors.append(
3402
+ f"Find VPNs has unknown WFCompoundType at index {idx}: {compound_type!r}"
3403
+ )
3404
+ limit_enabled = params.get("WFContentItemLimitEnabled")
3405
+ if limit_enabled is not None and not isinstance(limit_enabled, bool):
3406
+ errors.append(
3407
+ f"Find VPNs WFContentItemLimitEnabled must be boolean at index {idx}"
3408
+ )
3409
+ limit_number = params.get("WFContentItemLimitNumber")
3410
+ if limit_enabled is True:
3411
+ if _token_param_is_empty(limit_number):
3412
+ errors.append(f"Find VPNs limit is enabled but missing WFContentItemLimitNumber at index {idx}")
3413
+ else:
3414
+ try:
3415
+ numeric_limit = float(limit_number)
3416
+ except (TypeError, ValueError):
3417
+ errors.append(
3418
+ f"Find VPNs WFContentItemLimitNumber must be numeric at index {idx}: {limit_number!r}"
3419
+ )
3420
+ else:
3421
+ if numeric_limit <= 0:
3422
+ errors.append(
3423
+ f"Find VPNs WFContentItemLimitNumber must be greater than 0 at index {idx}"
3424
+ )
3425
+
3426
+ if ident == "is.workflow.actions.vpn.set":
3427
+ operation = params.get("WFVPNOperation")
3428
+ if _token_param_is_empty(operation):
3429
+ errors.append(f"Set VPN missing WFVPNOperation at index {idx}")
3430
+ elif operation not in VPN_OPERATIONS:
3431
+ errors.append(f"Set VPN has unknown WFVPNOperation at index {idx}: {operation!r}")
3432
+ if _token_param_is_empty(params.get("WFVPN")):
3433
+ errors.append(f"Set VPN missing WFVPN at index {idx}")
3434
+ on_demand_value = params.get("WFOnDemandValue")
3435
+ if on_demand_value is not None and not isinstance(on_demand_value, bool):
3436
+ errors.append(f"WFOnDemandValue must be boolean at index {idx}: {ident}")
3437
+ if operation == "Set On Demand" and on_demand_value is None:
3438
+ errors.append(f"Set VPN operation Set On Demand missing WFOnDemandValue at index {idx}")
3439
+
3440
+ if ident == "is.workflow.actions.location" and "WFLocation" not in params:
3441
+ errors.append(f"Location action missing WFLocation at index {idx}")
3442
+
3443
+ for location_key in LOCATION_PARAMETER_KEYS:
3444
+ if location_key not in params:
3445
+ continue
3446
+ location_value = params.get(location_key)
3447
+ if _token_param_is_empty(location_value):
3448
+ errors.append(
3449
+ f"Location parameter {location_key} is empty at index {idx}: {ident}"
3450
+ )
3451
+ continue
3452
+ if not _input_is_attached(location_value):
3453
+ errors.append(
3454
+ f"Location parameter {location_key} is not a token attachment at index {idx}: {ident}"
3455
+ )
3456
+ continue
3457
+ if (
3458
+ isinstance(location_value, dict)
3459
+ and location_value.get("WFSerializationType") != "WFTextTokenAttachment"
3460
+ ):
3461
+ errors.append(
3462
+ f"Location parameter {location_key} should use WFTextTokenAttachment (token strings can import as empty) at index {idx}: {ident}"
3463
+ )
3464
+ continue
3465
+ if not _input_has_reference(location_value):
3466
+ errors.append(
3467
+ f"Location parameter {location_key} has no variable/output reference at index {idx}: {ident}"
3468
+ )
3469
+ continue
3470
+ input_var_name = _extract_input_variable_name(location_value)
3471
+ if input_var_name:
3472
+ if input_var_name not in location_source_vars:
3473
+ errors.append(
3474
+ f"Location parameter {location_key} variable '{input_var_name}' does not resolve to a location source variable at index {idx}: {ident}"
3475
+ )
3476
+ continue
3477
+ for out_uuid in _input_action_output_uuids(location_value):
3478
+ source_ident = uuid_to_ident.get(out_uuid)
3479
+ if source_ident is None:
3480
+ errors.append(
3481
+ f"Location parameter {location_key} references unknown OutputUUID at index {idx}: {ident}"
3482
+ )
3483
+ elif source_ident not in LOCATION_SOURCE_ACTIONS:
3484
+ errors.append(
3485
+ f"Location parameter {location_key} should reference output from Get Current Location/Location action at index {idx}: {ident}"
3486
+ )
3487
+
3488
+ wfinput = params.get("WFInput")
3489
+ if (
3490
+ wfinput
3491
+ and ident != "is.workflow.actions.conditional"
3492
+ and _wrapped_variable_contains_action_output(wfinput)
3493
+ ):
3494
+ errors.append(
3495
+ f"WFInput wraps ActionOutput in Type=Variable wrapper at index {idx}: {ident}"
3496
+ )
3497
+
3498
+ # Data-producing actions should have their outputs referenced later
3499
+ if ident in UNUSED_OUTPUT_ACTIONS and uuid and uuid not in used_output_uuids:
3500
+ if idx != len(actions) - 1:
3501
+ errors.append(
3502
+ f"Output from {ident} at index {idx} is unused; likely missing variable wiring"
3503
+ )
3504
+
3505
+ # Download URL should have WFURL or WFInput
3506
+ if ident in REQUIRED_URL_INPUT_ACTIONS:
3507
+ if not params.get("WFURL") and not params.get("WFInput"):
3508
+ errors.append(f"Get Contents of URL missing WFURL/WFInput at index {idx}")
3509
+
3510
+ if ident == "is.workflow.actions.count":
3511
+ if "Input" not in params:
3512
+ errors.append(f"Count action should include Input mirror of WFInput at index {idx}")
3513
+ elif params.get("WFInput") and params.get("Input") != params.get("WFInput"):
3514
+ errors.append(f"Count action Input must match WFInput reference at index {idx}")
3515
+
3516
+ # Action-specific required fields
3517
+ if ident == "is.workflow.actions.getvalueforkey":
3518
+ wfinput = params.get("WFInput")
3519
+ if not wfinput:
3520
+ errors.append(f"Get Dictionary Value missing input at index {idx}")
3521
+ elif not _input_is_attached(wfinput):
3522
+ errors.append(f"Get Dictionary Value WFInput is not a token attachment at index {idx}")
3523
+ elif not _input_has_reference(wfinput):
3524
+ errors.append(f"Get Dictionary Value WFInput has no variable/output reference at index {idx}")
3525
+ else:
3526
+ for out_uuid in _input_action_output_uuids(wfinput):
3527
+ if out_uuid not in uuid_to_ident:
3528
+ errors.append(f"Get Dictionary Value references unknown OutputUUID at index {idx}")
3529
+
3530
+ value_type = params.get("WFGetDictionaryValueType")
3531
+ if value_type in (None, "", "Value"):
3532
+ key_param = params.get("WFDictionaryKey")
3533
+ if _token_param_is_empty(key_param):
3534
+ errors.append(f"Get Dictionary Value missing key at index {idx}")
3535
+ elif isinstance(key_param, str):
3536
+ if UNSAFE_ERROR_DOT_PATH_RE.match(key_param.strip()):
3537
+ errors.append(
3538
+ f"Get Dictionary Value uses unsafe direct key path '{key_param}' at index {idx}; extract 'error' first, then read nested keys inside a guarded If block"
3539
+ )
3540
+
3541
+ if ident == "is.workflow.actions.image.convert":
3542
+ if _token_param_is_empty(params.get("WFImageFormat")):
3543
+ errors.append(f"Convert Image missing WFImageFormat at index {idx}")
3544
+
3545
+ if ident == "is.workflow.actions.properties.weather.conditions":
3546
+ prop_name = params.get("WFContentItemPropertyName")
3547
+ if _token_param_is_empty(prop_name):
3548
+ errors.append(
3549
+ f"Get Detail of Weather Conditions missing WFContentItemPropertyName at index {idx}"
3550
+ )
3551
+ elif (
3552
+ isinstance(prop_name, str)
3553
+ and prop_name.strip().lower() in WEATHER_DETAIL_PLACEHOLDER_VALUES
3554
+ ):
3555
+ errors.append(
3556
+ f"Get Detail of Weather Conditions uses placeholder detail name at index {idx}: {prop_name!r}"
3557
+ )
3558
+ elif isinstance(prop_name, str) and prop_name not in WEATHER_DETAIL_SUPPORTED_VALUES:
3559
+ errors.append(
3560
+ f"Get Detail of Weather Conditions uses unsupported detail name at index {idx}: {prop_name!r}; use one of {', '.join(sorted(WEATHER_DETAIL_SUPPORTED_VALUES))}"
3561
+ )
3562
+ weather_input = params.get("WFInput")
3563
+ if _token_param_is_empty(weather_input):
3564
+ errors.append(f"Get Detail of Weather Conditions missing WFInput at index {idx}")
3565
+ elif not _input_is_attached(weather_input):
3566
+ errors.append(
3567
+ f"Get Detail of Weather Conditions WFInput is not a token attachment at index {idx}"
3568
+ )
3569
+ elif not _input_has_reference(weather_input):
3570
+ errors.append(
3571
+ f"Get Detail of Weather Conditions WFInput has no variable/output reference at index {idx}"
3572
+ )
3573
+ else:
3574
+ input_var_name = _extract_input_variable_name(weather_input)
3575
+ if input_var_name:
3576
+ errors.append(
3577
+ f"Get Detail of Weather Conditions WFInput should reference weather action output directly (variable '{input_var_name}' can import as generic Detail) at index {idx}"
3578
+ )
3579
+
3580
+ out_uuids = _input_action_output_uuids(weather_input)
3581
+ if not out_uuids:
3582
+ errors.append(
3583
+ f"Get Detail of Weather Conditions WFInput should reference output from Get Weather/Forecast directly at index {idx}"
3584
+ )
3585
+ else:
3586
+ has_weather_source = False
3587
+ for out_uuid in out_uuids:
3588
+ source_ident = uuid_to_ident.get(out_uuid)
3589
+ if source_ident is None:
3590
+ errors.append(
3591
+ f"Get Detail of Weather Conditions references unknown OutputUUID at index {idx}"
3592
+ )
3593
+ elif source_ident in WEATHER_SOURCE_ACTIONS:
3594
+ has_weather_source = True
3595
+ if not has_weather_source:
3596
+ errors.append(
3597
+ f"Get Detail of Weather Conditions WFInput should reference output from Get Weather/Forecast at index {idx}"
3598
+ )
3599
+
3600
+ if ident == "is.workflow.actions.getitemfromlist":
3601
+ wfinput = params.get("WFInput")
3602
+ for out_uuid in _input_action_output_uuids(wfinput):
3603
+ source_ident = uuid_to_ident.get(out_uuid)
3604
+ source_params = uuid_to_params.get(out_uuid, {})
3605
+ source_prop = source_params.get("WFContentItemPropertyName")
3606
+ if (
3607
+ source_ident == "is.workflow.actions.properties.weather.conditions"
3608
+ and source_prop in WEATHER_DETAIL_LIST_VALUES
3609
+ ):
3610
+ specifier = params.get("WFItemSpecifier")
3611
+ if source_prop == "Sunrise Time" and specifier not in (None, "", "First Item"):
3612
+ errors.append(
3613
+ f"Sunrise Time returns a list; Get Item from List should use First Item at index {idx}"
3614
+ )
3615
+ if source_prop == "Sunset Time" and specifier != "Last Item":
3616
+ errors.append(
3617
+ f"Sunset Time returns a list; Get Item from List should use Last Item at index {idx}"
3618
+ )
3619
+
3620
+ if ident == "is.workflow.actions.gettimebetweendates":
3621
+ date_keys = ("WFDate", "WFTimeUntilCustomDate", "WFTimeUntilFromDate")
3622
+ non_empty_date_refs: list[tuple[str, object]] = []
3623
+ for key in date_keys:
3624
+ if key not in params:
3625
+ continue
3626
+ value = params.get(key)
3627
+ if _token_param_is_empty(value):
3628
+ errors.append(
3629
+ f"Get Time Between Dates has empty {key} at index {idx}; omit unused date keys"
3630
+ )
3631
+ else:
3632
+ non_empty_date_refs.append((key, value))
3633
+
3634
+ if not non_empty_date_refs:
3635
+ errors.append(
3636
+ f"Get Time Between Dates missing WFDate/WFTimeUntilCustomDate/WFTimeUntilFromDate at index {idx}"
3637
+ )
3638
+ elif len(non_empty_date_refs) > 1:
3639
+ used = ", ".join(key for key, _ in non_empty_date_refs)
3640
+ errors.append(
3641
+ f"Get Time Between Dates should set exactly one non-empty date operand (found: {used}) at index {idx}"
3642
+ )
3643
+
3644
+ for key, reference in non_empty_date_refs:
3645
+ if not _input_is_attached(reference):
3646
+ errors.append(
3647
+ f"Get Time Between Dates {key} is not a token attachment at index {idx}"
3648
+ )
3649
+ continue
3650
+ if _input_has_current_date_token(reference):
3651
+ errors.append(
3652
+ f"Get Time Between Dates {key} cannot use CurrentDate magic token directly at index {idx}; "
3653
+ "insert a Date action set to Current Date and reference that action output"
3654
+ )
3655
+ continue
3656
+ if not _input_is_token_string(reference):
3657
+ errors.append(
3658
+ f"Get Time Between Dates {key} should use WFTextTokenString with a placeholder at index {idx}"
3659
+ )
3660
+ if not _input_has_reference(reference):
3661
+ errors.append(
3662
+ f"Get Time Between Dates {key} has no variable/output reference at index {idx}"
3663
+ )
3664
+ continue
3665
+ for out_uuid in _input_action_output_uuids(reference):
3666
+ if out_uuid not in uuid_to_ident:
3667
+ errors.append(
3668
+ f"Get Time Between Dates {key} references unknown OutputUUID at index {idx}"
3669
+ )
3670
+
3671
+ wfinput = params.get("WFInput")
3672
+ if _input_has_current_date_token(wfinput):
3673
+ errors.append(
3674
+ f"Get Time Between Dates WFInput cannot use CurrentDate magic token directly at index {idx}; "
3675
+ "insert a Date action set to Current Date and reference that action output"
3676
+ )
3677
+ elif wfinput and not _input_is_token_string(wfinput):
3678
+ errors.append(
3679
+ f"Get Time Between Dates WFInput should use WFTextTokenString with a placeholder at index {idx}"
3680
+ )
3681
+
3682
+ unit = params.get("WFTimeUntilUnit")
3683
+ if not _token_param_is_empty(unit) and isinstance(unit, str) and unit.strip().lower() not in DATE_DELTA_UNITS:
3684
+ errors.append(f"Get Time Between Dates has unsupported WFTimeUntilUnit '{unit}' at index {idx}")
3685
+
3686
+ if ident == "is.workflow.actions.extracttextfromimage":
3687
+ image_keys = ("imageFile", "WFImage", "WFInput")
3688
+ non_empty_image_inputs: list[tuple[str, object]] = []
3689
+ for key in image_keys:
3690
+ if key not in params:
3691
+ continue
3692
+ value = params.get(key)
3693
+ if _token_param_is_empty(value):
3694
+ errors.append(
3695
+ f"Extract Text from Image has empty {key} at index {idx}; omit unused image-input keys"
3696
+ )
3697
+ else:
3698
+ non_empty_image_inputs.append((key, value))
3699
+
3700
+ if not non_empty_image_inputs:
3701
+ errors.append(
3702
+ f"Extract Text from Image missing imageFile/WFImage/WFInput at index {idx}"
3703
+ )
3704
+ elif len(non_empty_image_inputs) > 1:
3705
+ used = ", ".join(key for key, _ in non_empty_image_inputs)
3706
+ errors.append(
3707
+ f"Extract Text from Image should set exactly one non-empty image input key (found: {used}) at index {idx}"
3708
+ )
3709
+
3710
+ for key, value in non_empty_image_inputs:
3711
+ if not _input_is_attached(value):
3712
+ errors.append(
3713
+ f"Extract Text from Image {key} is not a token attachment at index {idx}"
3714
+ )
3715
+ continue
3716
+ if not _input_has_reference(value):
3717
+ errors.append(
3718
+ f"Extract Text from Image {key} has no variable/output reference at index {idx}"
3719
+ )
3720
+ continue
3721
+ for out_uuid in _input_action_output_uuids(value):
3722
+ if out_uuid not in uuid_to_ident:
3723
+ errors.append(
3724
+ f"Extract Text from Image {key} references unknown OutputUUID at index {idx}"
3725
+ )
3726
+
3727
+ if ident == "is.workflow.actions.setvalueforkey":
3728
+ if not params.get("WFDictionary"):
3729
+ errors.append(f"Set Dictionary Value missing WFDictionary at index {idx}")
3730
+
3731
+ if ident == "is.workflow.actions.text.match":
3732
+ if "WFInput" in params:
3733
+ errors.append(
3734
+ f"Match Text should use 'text' parameter (not WFInput) at index {idx}"
3735
+ )
3736
+ text_param = params.get("text")
3737
+ if text_param is None:
3738
+ errors.append(f"Match Text missing 'text' parameter at index {idx}")
3739
+ elif _token_param_is_empty(text_param):
3740
+ errors.append(f"Match Text has empty 'text' parameter at index {idx}")
3741
+
3742
+ if ident in TEXT_INPUT_KEY_ACTIONS:
3743
+ action_name = "Change Case" if ident == "is.workflow.actions.text.changecase" else "Split Text"
3744
+ text_param = params.get("text")
3745
+ if text_param is None:
3746
+ if "WFInput" in params:
3747
+ errors.append(
3748
+ f"{action_name} should use 'text' parameter (not WFInput) at index {idx}"
3749
+ )
3750
+ else:
3751
+ errors.append(f"{action_name} missing 'text' parameter at index {idx}")
3752
+ else:
3753
+ if _token_param_is_empty(text_param):
3754
+ errors.append(f"{action_name} has empty 'text' parameter at index {idx}")
3755
+ elif isinstance(text_param, dict) and not _input_is_attached(text_param):
3756
+ errors.append(f"{action_name} 'text' parameter is not a token attachment at index {idx}")
3757
+ if "WFInput" in params:
3758
+ errors.append(
3759
+ f"{action_name} should not include WFInput; wire input via 'text' parameter at index {idx}"
3760
+ )
3761
+ if ident == "is.workflow.actions.text.split":
3762
+ sep = params.get("WFTextSeparator")
3763
+ if sep == "Custom":
3764
+ custom_sep = params.get("WFTextCustomSeparator")
3765
+ # A single space is a valid custom separator; only reject missing/empty-string.
3766
+ if custom_sep is None:
3767
+ errors.append(f"Split Text missing WFTextCustomSeparator for Custom separator at index {idx}")
3768
+ elif isinstance(custom_sep, str) and custom_sep == "":
3769
+ errors.append(f"Split Text missing WFTextCustomSeparator for Custom separator at index {idx}")
3770
+ elif isinstance(custom_sep, dict) and _token_param_is_empty(custom_sep):
3771
+ errors.append(f"Split Text missing WFTextCustomSeparator for Custom separator at index {idx}")
3772
+
3773
+ if ident == "is.workflow.actions.text.replace":
3774
+ if "text" in params and "WFInput" not in params:
3775
+ errors.append(
3776
+ f"Replace Text should use WFInput (not 'text') at index {idx}"
3777
+ )
3778
+
3779
+ if ident == "is.workflow.actions.filter.notes":
3780
+ filt = params.get("WFContentItemFilter")
3781
+ if not isinstance(filt, dict):
3782
+ errors.append(f"Find Notes missing WFContentItemFilter at index {idx}")
3783
+ else:
3784
+ if filt.get("WFSerializationType") != "WFContentPredicateTableTemplate":
3785
+ errors.append(
3786
+ f"Find Notes WFContentItemFilter must use WFContentPredicateTableTemplate at index {idx}"
3787
+ )
3788
+ fval = filt.get("Value")
3789
+ if not isinstance(fval, dict):
3790
+ errors.append(f"Find Notes WFContentItemFilter missing Value dict at index {idx}")
3791
+ else:
3792
+ templates = fval.get("WFActionParameterFilterTemplates")
3793
+ if not isinstance(templates, list) or not templates:
3794
+ errors.append(f"Find Notes filter has no templates at index {idx}")
3795
+ else:
3796
+ for tidx, template in enumerate(templates):
3797
+ if not isinstance(template, dict):
3798
+ errors.append(
3799
+ f"Find Notes filter template {tidx} is not a dict at index {idx}"
3800
+ )
3801
+ continue
3802
+ prop = template.get("Property")
3803
+ values = template.get("Values")
3804
+ if not isinstance(values, dict):
3805
+ errors.append(
3806
+ f"Find Notes filter template '{prop}' missing Values dict at index {idx}"
3807
+ )
3808
+ continue
3809
+ if prop == "Name":
3810
+ string_state = values.get("String")
3811
+ if _token_param_is_empty(string_state):
3812
+ errors.append(
3813
+ f"Find Notes Name filter has empty String value at index {idx}"
3814
+ )
3815
+ elif isinstance(string_state, dict) and string_state.get(
3816
+ "WFSerializationType"
3817
+ ) != "WFTextTokenString":
3818
+ errors.append(
3819
+ f"Find Notes Name filter should use WFTextTokenString for String value at index {idx}"
3820
+ )
3821
+ if prop == "Folder":
3822
+ enum_state = values.get("Enumeration")
3823
+ if not isinstance(enum_state, dict):
3824
+ errors.append(
3825
+ f"Find Notes Folder filter missing Enumeration value at index {idx}"
3826
+ )
3827
+ else:
3828
+ if (
3829
+ enum_state.get("WFSerializationType")
3830
+ != "WFLinkDynamicOptionSubstitutableState"
3831
+ ):
3832
+ errors.append(
3833
+ f"Find Notes Folder filter should use WFLinkDynamicOptionSubstitutableState at index {idx}"
3834
+ )
3835
+ enum_value = enum_state.get("Value")
3836
+ if _token_param_is_empty(enum_value):
3837
+ errors.append(
3838
+ f"Find Notes Folder filter Enumeration value is empty at index {idx}"
3839
+ )
3840
+
3841
+ if ident == "is.workflow.actions.filter.calendarevents":
3842
+ filt = params.get("WFContentItemFilter")
3843
+ if not isinstance(filt, dict):
3844
+ errors.append(f"Find Calendar Events missing WFContentItemFilter at index {idx}")
3845
+ elif filt.get("WFSerializationType") != "WFContentPredicateTableTemplate":
3846
+ errors.append(
3847
+ f"Find Calendar Events WFContentItemFilter must use WFContentPredicateTableTemplate at index {idx}"
3848
+ )
3849
+ else:
3850
+ inner = filt.get("Value")
3851
+ templates = inner.get("WFActionParameterFilterTemplates") if isinstance(inner, dict) else None
3852
+ if not isinstance(templates, list) or not templates:
3853
+ errors.append(f"Find Calendar Events WFContentItemFilter has no templates at index {idx}")
3854
+ else:
3855
+ for tidx, template in enumerate(templates):
3856
+ if not isinstance(template, dict):
3857
+ errors.append(
3858
+ f"Find Calendar Events filter template {tidx} is not a dict at index {idx}"
3859
+ )
3860
+ continue
3861
+ prop = template.get("Property")
3862
+ op = template.get("Operator")
3863
+ if prop in {"Start Date", "End Date"} and op == 3:
3864
+ errors.append(
3865
+ f"Find Calendar Events {prop} filter uses numeric operator 3 at index {idx}; "
3866
+ "use date operator 2 for 'is after', 1002 for 'is today', or 1003 for 'is between'"
3867
+ )
3868
+ if prop in {"Start Date", "End Date"} and op in {2, 1003}:
3869
+ values = template.get("Values")
3870
+ if not isinstance(values, dict):
3871
+ errors.append(
3872
+ f"Find Calendar Events {prop} date filter missing Values dict at index {idx}"
3873
+ )
3874
+ elif op == 2 and "Date" not in values:
3875
+ errors.append(
3876
+ f"Find Calendar Events {prop} 'is after' filter missing Values.Date at index {idx}"
3877
+ )
3878
+ elif op == 1003 and ("Date" not in values or "AnotherDate" not in values):
3879
+ errors.append(
3880
+ f"Find Calendar Events {prop} 'is between' filter needs Values.Date and Values.AnotherDate at index {idx}"
3881
+ )
3882
+
3883
+ if ident == HEALTH_FIND_SAMPLES_ACTION:
3884
+ if "WFHealthQuantityType" in params:
3885
+ errors.append(
3886
+ f"Find Health Samples uses obsolete WFHealthQuantityType at index {idx}; put the sample kind in a non-removable Type filter row"
3887
+ )
3888
+ _validate_health_filter_template(
3889
+ params.get("WFContentItemFilter"),
3890
+ action_label="Find Health Samples",
3891
+ idx=idx,
3892
+ errors=errors,
3893
+ )
3894
+ health_type, type_operator, malformed_type = _health_filter_type_value(
3895
+ params.get("WFContentItemFilter")
3896
+ )
3897
+ if malformed_type or _token_param_is_empty(health_type):
3898
+ errors.append(f"Find Health Samples missing or malformed Type filter at index {idx}")
3899
+ elif type_operator != 4:
3900
+ errors.append(
3901
+ f"Find Health Samples Type filter must use operator 4 at index {idx}"
3902
+ )
3903
+ elif (
3904
+ isinstance(health_type, str)
3905
+ and HEALTH_REFERENCE_SETS["find_sample_types"]
3906
+ and health_type not in HEALTH_REFERENCE_SETS["find_sample_types"]
3907
+ ):
3908
+ errors.append(
3909
+ f"Find Health Samples uses unknown Type filter value '{health_type}' at index {idx}"
3910
+ )
3911
+ if "WFContentItemLimitEnabled" in params and not isinstance(
3912
+ params.get("WFContentItemLimitEnabled"), bool
3913
+ ):
3914
+ errors.append(
3915
+ f"Find Health Samples WFContentItemLimitEnabled must be boolean at index {idx}"
3916
+ )
3917
+ if params.get("WFContentItemLimitEnabled") is True:
3918
+ limit_number = params.get("WFContentItemLimitNumber")
3919
+ if limit_number is None or limit_number == "":
3920
+ errors.append(
3921
+ f"Find Health Samples limit is enabled but WFContentItemLimitNumber is missing at index {idx}"
3922
+ )
3923
+
3924
+ if ident == HEALTH_SAMPLE_DETAIL_ACTION:
3925
+ prop_name = params.get("WFContentItemPropertyName")
3926
+ if _token_param_is_empty(prop_name):
3927
+ errors.append(
3928
+ f"Get Details of Health Sample missing WFContentItemPropertyName at index {idx}"
3929
+ )
3930
+ elif isinstance(prop_name, str) and prop_name not in HEALTH_SAMPLE_DETAIL_PROPERTIES:
3931
+ errors.append(
3932
+ f"Get Details of Health Sample uses unknown detail '{prop_name}' at index {idx}"
3933
+ )
3934
+
3935
+ health_input = params.get("WFInput")
3936
+ if _token_param_is_empty(health_input):
3937
+ errors.append(f"Get Details of Health Sample missing WFInput at index {idx}")
3938
+ elif not _input_is_attached(health_input):
3939
+ errors.append(
3940
+ f"Get Details of Health Sample WFInput is not a token attachment at index {idx}"
3941
+ )
3942
+ elif not _input_has_reference(health_input):
3943
+ errors.append(
3944
+ f"Get Details of Health Sample WFInput has no variable/output reference at index {idx}"
3945
+ )
3946
+ else:
3947
+ input_var_name = _extract_input_variable_name(health_input)
3948
+ if input_var_name:
3949
+ if input_var_name not in health_sample_source_vars:
3950
+ errors.append(
3951
+ f"Get Details of Health Sample variable '{input_var_name}' does not resolve to Health Samples at index {idx}"
3952
+ )
3953
+ for out_uuid in _input_action_output_uuids(health_input):
3954
+ source_ident = uuid_to_ident.get(out_uuid)
3955
+ if source_ident is None:
3956
+ errors.append(
3957
+ f"Get Details of Health Sample references unknown OutputUUID at index {idx}"
3958
+ )
3959
+ elif source_ident not in HEALTH_SAMPLE_SOURCE_ACTIONS:
3960
+ errors.append(
3961
+ f"Get Details of Health Sample should reference Find Health Samples/Log Health Sample output at index {idx}"
3962
+ )
3963
+
3964
+ if ident == HEALTH_LOG_SAMPLE_ACTION:
3965
+ sample_type = params.get("WFQuantitySampleType")
3966
+ if _token_param_is_empty(sample_type):
3967
+ errors.append(f"Log Health Sample missing WFQuantitySampleType at index {idx}")
3968
+ elif (
3969
+ isinstance(sample_type, str)
3970
+ and HEALTH_REFERENCE_SETS["sample_types"]
3971
+ and sample_type not in HEALTH_REFERENCE_SETS["sample_types"]
3972
+ ):
3973
+ errors.append(
3974
+ f"Log Health Sample uses unknown WFQuantitySampleType '{sample_type}' at index {idx}"
3975
+ )
3976
+
3977
+ has_quantity = "WFQuantitySampleQuantity" in params
3978
+ has_category = (
3979
+ "WFCategorySampleEnumeration" in params
3980
+ or "WFCategorySampleAdditionalEnumerationKey" in params
3981
+ )
3982
+ if not has_quantity and not has_category:
3983
+ errors.append(
3984
+ f"Log Health Sample missing sample value: use WFQuantitySampleQuantity or WFCategorySampleEnumeration at index {idx}"
3985
+ )
3986
+
3987
+ for key in HEALTH_QUANTITY_FIELD_KEYS:
3988
+ if key not in params:
3989
+ continue
3990
+ require_magnitude = key in HEALTH_REQUIRED_MAIN_QUANTITY_KEYS
3991
+ if key == "WFQuantitySampleAdditionalQuantity":
3992
+ # The bundled Caffeine XML example includes only Unit for
3993
+ # the secondary quantity field, so Magnitude stays optional.
3994
+ require_magnitude = False
3995
+ _validate_health_quantity_field(
3996
+ params.get(key),
3997
+ key=key,
3998
+ idx=idx,
3999
+ errors=errors,
4000
+ require_magnitude=require_magnitude,
4001
+ )
4002
+
4003
+ for key in (
4004
+ "WFCategorySampleEnumeration",
4005
+ "WFCategorySampleAdditionalEnumerationKey",
4006
+ "WFQuantitySampleAdditionalEnumeration",
4007
+ ):
4008
+ if key in params and _token_param_is_empty(params.get(key)):
4009
+ errors.append(f"Log Health Sample {key} is empty at index {idx}")
4010
+ elif (
4011
+ key in params
4012
+ and isinstance(params.get(key), str)
4013
+ and HEALTH_REFERENCE_SETS["category_values"]
4014
+ and params.get(key) not in HEALTH_REFERENCE_SETS["category_values"]
4015
+ ):
4016
+ errors.append(
4017
+ f"Log Health Sample {key} uses unknown category value '{params.get(key)}' at index {idx}"
4018
+ )
4019
+
4020
+ for key in ("WFQuantitySampleDate", "WFSampleEndDate"):
4021
+ if key in params:
4022
+ _validate_health_date_like(params.get(key), key=key, idx=idx, errors=errors)
4023
+
4024
+ if ident == HEALTH_LOG_WORKOUT_ACTION:
4025
+ workout_type = params.get("WFWorkoutReadableActivityType")
4026
+ if _token_param_is_empty(workout_type):
4027
+ errors.append(f"Log Workout missing WFWorkoutReadableActivityType at index {idx}")
4028
+ elif (
4029
+ isinstance(workout_type, str)
4030
+ and HEALTH_REFERENCE_SETS["workouts"]
4031
+ and workout_type not in HEALTH_REFERENCE_SETS["workouts"]
4032
+ ):
4033
+ errors.append(
4034
+ f"Log Workout uses unknown WFWorkoutReadableActivityType '{workout_type}' at index {idx}"
4035
+ )
4036
+ for key in ("WFWorkoutDate",):
4037
+ if key in params:
4038
+ _validate_health_date_like(params.get(key), key=key, idx=idx, errors=errors)
4039
+ for key in ("WFWorkoutDuration", "WFWorkoutCaloriesQuantity", "WFWorkoutDistanceQuantity"):
4040
+ if key in params:
4041
+ _validate_health_quantity_field(
4042
+ params.get(key),
4043
+ key=key,
4044
+ idx=idx,
4045
+ errors=errors,
4046
+ require_magnitude=True,
4047
+ )
4048
+
4049
+ if ident == "is.workflow.actions.text.translate":
4050
+ if "WFInputText" not in params:
4051
+ if "WFInput" in params:
4052
+ errors.append(
4053
+ f"Translate Text should use WFInputText (not WFInput) at index {idx}"
4054
+ )
4055
+ else:
4056
+ errors.append(f"Translate Text missing WFInputText at index {idx}")
4057
+ lang = params.get("WFSelectedLanguage")
4058
+ if lang is None or lang == "":
4059
+ errors.append(f"Translate Text missing WFSelectedLanguage at index {idx}")
4060
+ elif _lang_value_is_code(lang):
4061
+ errors.append(
4062
+ f"Translate Text WFSelectedLanguage should use display name (not code) at index {idx}"
4063
+ )
4064
+
4065
+ if ident == "is.workflow.actions.ask":
4066
+ if "WFAskActionDefaultAnswer" in params:
4067
+ default = params.get("WFAskActionDefaultAnswer")
4068
+ if default is None or (isinstance(default, str) and default.strip() == ""):
4069
+ errors.append(f"Ask for Input has empty default answer at index {idx}; omit WFAskActionDefaultAnswer")
4070
+
4071
+ if ident == "is.workflow.actions.setvariable":
4072
+ wfinput = params.get("WFInput")
4073
+ if not wfinput:
4074
+ errors.append(f"Set Variable missing WFInput at index {idx}")
4075
+ elif not _input_is_attached(wfinput):
4076
+ errors.append(f"Set Variable WFInput is not a token attachment at index {idx}")
4077
+
4078
+ if ident == "is.workflow.actions.sendmessage":
4079
+ # Send Message content should be sent via an appended variable list,
4080
+ # even for single-type payloads, to preserve the importable list shape.
4081
+ content = params.get("WFSendMessageContent")
4082
+ attachments = params.get("WFSendMessageAttachments")
4083
+ if not content and not attachments and not params.get("WFInput"):
4084
+ errors.append(f"Send Message missing content/attachments at index {idx}")
4085
+ if content and attachments:
4086
+ errors.append(
4087
+ f"Send Message mixes content + attachments; use Append Variable list and pass single variable at index {idx}"
4088
+ )
4089
+ if isinstance(content, dict):
4090
+ name = _extract_input_variable_name(content)
4091
+ if name and append_counts.get(name, 0) < 2:
4092
+ errors.append(
4093
+ f"Send Message content variable '{name}' should be built with Append Variable (2+ appends, even for single-type content) at index {idx}"
4094
+ )
4095
+ # Disallow ActionOutput attachments for Send Message content
4096
+ out_uuids = _input_action_output_uuids(content)
4097
+ if out_uuids:
4098
+ errors.append(
4099
+ f"Send Message content should reference a named variable (not ActionOutput) at index {idx}"
4100
+ )
4101
+ # Require WFTextTokenString for variable content to avoid blank UI
4102
+ if content.get("WFSerializationType") == "WFTextTokenAttachment":
4103
+ errors.append(
4104
+ f"Send Message content should use WFTextTokenString with variable placeholder at index {idx}"
4105
+ )
4106
+
4107
+ if ident == "is.workflow.actions.sendemail":
4108
+ for key in (params or {}).keys():
4109
+ if key.startswith("WFSendMailAction"):
4110
+ errors.append(
4111
+ f"Send Email uses legacy SendMail parameter keys; use WFSendEmailAction* at index {idx}"
4112
+ )
4113
+
4114
+ if ident == "is.workflow.actions.setitemname":
4115
+ wf_input = params.get("WFInput")
4116
+ if _token_param_is_empty(wf_input):
4117
+ errors.append(f"Set Name missing WFInput at index {idx}")
4118
+ elif not _input_is_attached(wf_input):
4119
+ errors.append(f"Set Name WFInput is not a token attachment at index {idx}")
4120
+ elif not _input_has_reference(wf_input):
4121
+ errors.append(f"Set Name WFInput has no variable/output reference at index {idx}")
4122
+ else:
4123
+ for out_uuid in _input_action_output_uuids(wf_input):
4124
+ if out_uuid not in uuid_to_ident:
4125
+ errors.append(f"Set Name WFInput references unknown OutputUUID at index {idx}")
4126
+
4127
+ if _token_param_is_empty(params.get("WFName")):
4128
+ errors.append(f"Set Name missing WFName at index {idx}")
4129
+
4130
+ if ident == "is.workflow.actions.file.rename":
4131
+ wf_file = params.get("WFFile")
4132
+ if _token_param_is_empty(wf_file):
4133
+ errors.append(f"Rename File missing WFFile at index {idx}")
4134
+ elif not _input_is_attached(wf_file):
4135
+ errors.append(f"Rename File WFFile is not a token attachment at index {idx}")
4136
+ elif not _input_has_reference(wf_file):
4137
+ errors.append(f"Rename File WFFile has no variable/output reference at index {idx}")
4138
+ else:
4139
+ for out_uuid in _input_action_output_uuids(wf_file):
4140
+ if out_uuid not in uuid_to_ident:
4141
+ errors.append(f"Rename File WFFile references unknown OutputUUID at index {idx}")
4142
+ else:
4143
+ rename_file_source_uuids[out_uuid] = idx
4144
+ for var_name in _extract_input_variable_names(wf_file):
4145
+ rename_file_source_vars[var_name] = idx
4146
+
4147
+ if _token_param_is_empty(params.get("WFNewFilename")):
4148
+ errors.append(f"Rename File missing WFNewFilename at index {idx}")
4149
+
4150
+ rename_uuid = params.get("UUID")
4151
+ if isinstance(rename_uuid, str) and rename_uuid.strip():
4152
+ rename_file_output_uuids[rename_uuid] = idx
4153
+
4154
+ if ident == "is.workflow.actions.format.date":
4155
+ if not params.get("WFDate"):
4156
+ errors.append(f"Format Date missing WFDate at index {idx}")
4157
+ else:
4158
+ wfdate = params.get("WFDate")
4159
+ if not _input_is_editor_visible(wfdate):
4160
+ errors.append(f"Format Date WFDate should use WFTextTokenString with placeholder at index {idx}")
4161
+ for out_uuid in _input_action_output_uuids(wfdate):
4162
+ source_ident = uuid_to_ident.get(out_uuid)
4163
+ source_params = uuid_to_params.get(out_uuid, {})
4164
+ source_prop = source_params.get("WFContentItemPropertyName")
4165
+ if (
4166
+ source_ident == "is.workflow.actions.properties.weather.conditions"
4167
+ and source_prop in WEATHER_DETAIL_LIST_VALUES
4168
+ ):
4169
+ expected = "First Item" if source_prop == "Sunrise Time" else "Last Item"
4170
+ errors.append(
4171
+ f"{source_prop} returns a list; use Get Item from List ({expected}) before Format Date at index {idx}"
4172
+ )
4173
+ if params.get("WFDateFormat") == "Custom" and not params.get("WFDateFormatString"):
4174
+ errors.append(f"Format Date custom format is empty at index {idx}")
4175
+ if params.get("WFDateFormat") == "Custom" and params.get("WFDateFormatString"):
4176
+ if params.get("WFDateFormatStyle") != "Custom":
4177
+ errors.append(f"Format Date custom style must be set to Custom at index {idx}")
4178
+ custom_name = params.get("CustomOutputName")
4179
+ enforce_custom_style = custom_name in {"Start Date", "End Date"}
4180
+ if params.get("WFDateFormat") == "Custom":
4181
+ fmt = params.get("WFDateFormatString", "")
4182
+ if enforce_custom_style:
4183
+ if fmt and not allow_datetime_format and re.search(r"[HhmsZ]|'T'", fmt):
4184
+ errors.append(
4185
+ f"Custom date format includes time; use date-only or add ALLOW_DATETIME_FORMAT at index {idx}"
4186
+ )
4187
+ if "00:00:00" in fmt or "23:59:59" in fmt:
4188
+ errors.append(
4189
+ f"Custom date format hardcodes start/end of day; use date-only yyyy-MM-dd at index {idx}"
4190
+ )
4191
+
4192
+ # Heuristic: Start/End Date variables should use Custom format (API-friendly)
4193
+ if ident == "is.workflow.actions.setvariable":
4194
+ name = params.get("WFVariableName")
4195
+ if isinstance(name, str) and name.strip() and name in {"Start Date", "End Date"}:
4196
+ fmt_params = var_format_dates.get(name)
4197
+ if fmt_params is not None:
4198
+ fmt_key = fmt_params.get("WFDateFormat")
4199
+ fmt_str = fmt_params.get("WFDateFormatString", "")
4200
+ # Accept Custom or a direct date-only format (e.g., yyyy-MM-dd)
4201
+ effective = fmt_str if fmt_key == "Custom" else (fmt_key or fmt_str)
4202
+ if not allow_datetime_format and isinstance(effective, str) and re.search(r"[HhmsZ]|'T'", effective):
4203
+ errors.append(f"{name} should use date-only format (yyyy-MM-dd)")
4204
+
4205
+ if ident in DESTRUCTIVE_FILE_ACTIONS:
4206
+ wfinput = params.get("WFInput")
4207
+ destructive_name = DESTRUCTIVE_FILE_ACTIONS[ident]
4208
+ for var_name in _extract_input_variable_names(wfinput):
4209
+ rename_idx = rename_file_source_vars.get(var_name)
4210
+ if rename_idx is not None:
4211
+ errors.append(
4212
+ f"{destructive_name} reuses variable '{var_name}' after Rename File at index {rename_idx}; Rename File changes the original file in place, so use Set Name (is.workflow.actions.setitemname) for renamed-copy workflows at index {idx}"
4213
+ )
4214
+ for out_uuid in _input_action_output_uuids(wfinput):
4215
+ rename_idx = rename_file_source_uuids.get(out_uuid)
4216
+ if rename_idx is not None:
4217
+ errors.append(
4218
+ f"{destructive_name} reuses the same file output after Rename File at index {rename_idx}; Rename File changes the original file in place, so use Set Name (is.workflow.actions.setitemname) for renamed-copy workflows at index {idx}"
4219
+ )
4220
+
4221
+ if ident in RENAMED_COPY_OUTPUT_ACTIONS:
4222
+ wfinput = params.get("WFInput")
4223
+ consumer_name = RENAMED_COPY_OUTPUT_ACTIONS[ident]
4224
+ for out_uuid in _input_action_output_uuids(wfinput):
4225
+ rename_idx = rename_file_output_uuids.get(out_uuid)
4226
+ if rename_idx is not None:
4227
+ errors.append(
4228
+ f"{consumer_name} consumes Rename File output from index {rename_idx}; Rename File mutates the original file in place. Use Set Name (is.workflow.actions.setitemname with WFInput and WFName) to create a renamed file object for saving or sharing at index {idx}"
4229
+ )
4230
+
4231
+ if ident == "is.workflow.actions.math":
4232
+ if not params.get("WFMathOperand"):
4233
+ errors.append(f"Math action missing WFMathOperand at index {idx}")
4234
+ elif not _math_operand_has_reference(params.get("WFMathOperand")):
4235
+ errors.append(f"Math operand is empty or unreferenced at index {idx}")
4236
+ # If the math input is a variable, ensure it exists
4237
+ wfinput = params.get("WFInput")
4238
+ var_name = _extract_input_variable_name(wfinput)
4239
+ if var_name and var_name not in defined_vars and var_name not in BUILTIN_VARIABLES:
4240
+ errors.append(f"Math input references undefined variable '{var_name}' at index {idx}")
4241
+ input_name = var_name
4242
+ if input_name is None and isinstance(wfinput, dict):
4243
+ val = wfinput.get("Value")
4244
+ if isinstance(val, dict) and val.get("Type") == "ActionOutput":
4245
+ out_uuid = val.get("OutputUUID")
4246
+ if out_uuid in uuid_to_var_name:
4247
+ input_name = uuid_to_var_name[out_uuid]
4248
+ else:
4249
+ output_name = val.get("OutputName")
4250
+ if isinstance(output_name, str):
4251
+ input_name = output_name
4252
+ op = params.get("WFMathOperation")
4253
+ if op == "+":
4254
+ errors.append(
4255
+ f"Math action uses WFMathOperation '+' at index {idx}; omit WFMathOperation for addition"
4256
+ )
4257
+ elif op == "*":
4258
+ errors.append(
4259
+ f"Math action uses ASCII '*' at index {idx}; use '×' (U+00D7) for multiplication"
4260
+ )
4261
+ elif op == "/":
4262
+ errors.append(
4263
+ f"Math action uses ASCII '/' at index {idx}; use '÷' (U+00F7) for division"
4264
+ )
4265
+ elif op is not None and op not in MATH_OPERATIONS:
4266
+ errors.append(f"Math action has unknown WFMathOperation at index {idx}: {op!r}")
4267
+ operand = params.get("WFMathOperand")
4268
+ literal_operand = _math_operand_literal_string(operand)
4269
+ if (
4270
+ op in {"/", "÷"}
4271
+ and literal_operand == "60"
4272
+ and input_name
4273
+ and "sleep" in input_name.lower()
4274
+ and uuid
4275
+ ):
4276
+ sleep_duration_divide_by_60_uuids.add(uuid)
4277
+ if (
4278
+ op in {"/", "÷"}
4279
+ and literal_operand == "1000"
4280
+ and input_name
4281
+ and "distance" in input_name.lower()
4282
+ ):
4283
+ errors.append(
4284
+ f"Health distance math divides '{input_name}' by 1000 at index {idx}; "
4285
+ "do not assume Walking + Running Distance values are meters. Sum the Health Value directly or use Convert Measurement with an explicit source unit"
4286
+ )
4287
+ is_100 = False
4288
+ if isinstance(operand, int) and operand == 100:
4289
+ is_100 = True
4290
+ elif isinstance(operand, dict):
4291
+ val = operand.get("Value")
4292
+ if isinstance(val, dict):
4293
+ if isinstance(val.get("string"), str) and val.get("string").strip() == "100":
4294
+ is_100 = True
4295
+ if op == "/" and is_100:
4296
+ divide_by_100_uuids.add(uuid)
4297
+ if input_name:
4298
+ divide_by_100_vars.add(input_name)
4299
+ # If operating on cents with literal 100, must use divide
4300
+ if is_100 and input_name and "cents" in input_name.lower() and op not in {"/", "÷"}:
4301
+ errors.append(f"Math uses {op} with 100 on cents; must divide by 100 at index {idx}")
4302
+ if is_100 and cents_context_steps > 0 and op not in {"/", "÷"}:
4303
+ errors.append(f"Math uses {op} with 100 in cents context; must divide by 100 at index {idx}")
4304
+
4305
+ if ident == "is.workflow.actions.round":
4306
+ wfinput = params.get("WFInput")
4307
+ if not wfinput:
4308
+ errors.append(f"Round Number missing WFInput at index {idx}")
4309
+ else:
4310
+ for out_uuid in _input_action_output_uuids(wfinput):
4311
+ if out_uuid in sleep_duration_divide_by_60_uuids and uuid:
4312
+ sleep_duration_divide_by_60_round_uuids.add(uuid)
4313
+ used_divide = False
4314
+ for out_uuid in _input_action_output_uuids(wfinput):
4315
+ if out_uuid in divide_by_100_uuids:
4316
+ used_divide = True
4317
+ break
4318
+ if not used_divide:
4319
+ var_name = _extract_input_variable_name(wfinput)
4320
+ if var_name and "cents" in var_name.lower():
4321
+ errors.append(f"Round used on cents without divide-by-100 at index {idx}")
4322
+
4323
+ if ident == "is.workflow.actions.adjustdate":
4324
+ date_keys = ("WFDate", "WFInput")
4325
+ non_empty_date_refs: list[tuple[str, object]] = []
4326
+ for key in date_keys:
4327
+ if key not in params:
4328
+ continue
4329
+ value = params.get(key)
4330
+ if _token_param_is_empty(value):
4331
+ errors.append(f"Adjust Date has empty {key} at index {idx}")
4332
+ else:
4333
+ non_empty_date_refs.append((key, value))
4334
+
4335
+ if not non_empty_date_refs:
4336
+ errors.append(f"Adjust Date missing WFDate/WFInput at index {idx}")
4337
+ else:
4338
+ for key, reference in non_empty_date_refs:
4339
+ if not _input_is_attached(reference):
4340
+ errors.append(f"Adjust Date {key} is not a token attachment at index {idx}")
4341
+ continue
4342
+ if not _input_has_reference(reference):
4343
+ errors.append(f"Adjust Date {key} has no variable/output reference at index {idx}")
4344
+ continue
4345
+ for out_uuid in _input_action_output_uuids(reference):
4346
+ if out_uuid not in uuid_to_ident:
4347
+ errors.append(f"Adjust Date {key} references unknown OutputUUID at index {idx}")
4348
+
4349
+ has_offset_picker = "WFAdjustOffsetPicker" in params
4350
+ has_legacy_op = "WFAdjustOperation" in params
4351
+ has_legacy_duration = "WFDuration" in params
4352
+ if not has_legacy_duration:
4353
+ if has_offset_picker:
4354
+ errors.append(
4355
+ f"Adjust Date offset-picker-only payload is unreliable on iOS import; include WFDuration at index {idx}"
4356
+ )
4357
+ else:
4358
+ errors.append(f"Adjust Date missing WFDuration at index {idx}")
4359
+ if has_offset_picker:
4360
+ offset = params.get("WFAdjustOffsetPicker")
4361
+ if not isinstance(offset, dict) or offset.get("WFSerializationType") != "WFTimeOffsetValue":
4362
+ errors.append(
4363
+ f"Adjust Date WFAdjustOffsetPicker must be WFTimeOffsetValue at index {idx}"
4364
+ )
4365
+ if has_legacy_op:
4366
+ op = params.get("WFAdjustOperation")
4367
+ if _token_param_is_empty(op):
4368
+ errors.append(f"Adjust Date has empty WFAdjustOperation at index {idx}")
4369
+ elif isinstance(op, str) and op not in {"Add", "Subtract"}:
4370
+ errors.append(f"Adjust Date WFAdjustOperation should be Add or Subtract at index {idx}")
4371
+ if has_legacy_duration:
4372
+ duration = params.get("WFDuration")
4373
+ if duration is None:
4374
+ errors.append(f"Adjust Date missing WFDuration at index {idx}")
4375
+ elif not isinstance(duration, dict):
4376
+ errors.append(f"Adjust Date WFDuration must be a dict at index {idx}")
4377
+ else:
4378
+ if duration.get("WFSerializationType") != "WFQuantityFieldValue":
4379
+ errors.append(
4380
+ f"Adjust Date WFDuration must use WFQuantityFieldValue serialization at index {idx}"
4381
+ )
4382
+ dur_val = duration.get("Value")
4383
+ if not isinstance(dur_val, dict):
4384
+ errors.append(f"Adjust Date WFDuration missing Value dict at index {idx}")
4385
+ else:
4386
+ magnitude = dur_val.get("Magnitude")
4387
+ unit = dur_val.get("Unit")
4388
+ if magnitude is None or (isinstance(magnitude, str) and magnitude.strip() == ""):
4389
+ errors.append(f"Adjust Date WFDuration missing Magnitude at index {idx}")
4390
+ if not isinstance(unit, str) or unit.strip() == "":
4391
+ errors.append(f"Adjust Date WFDuration missing Unit at index {idx}")
4392
+ elif unit.strip().lower() not in DATE_DELTA_UNITS:
4393
+ errors.append(f"Adjust Date WFDuration has unsupported Unit '{unit}' at index {idx}")
4394
+
4395
+ if errors and first_error is None:
4396
+ first_error = (idx, ident or "UNKNOWN", _snippet(params))
4397
+
4398
+ if cents_context_steps > 0:
4399
+ cents_context_steps -= 1
4400
+
4401
+ # If cents variables exist but no divide-by-100 conversion is present, flag it
4402
+ for name in sorted(cents_vars):
4403
+ if name not in divide_by_100_vars:
4404
+ errors.append(f"Cents variable '{name}' is never divided by 100 (missing cents→dollars conversion)")
4405
+ if first_error is None:
4406
+ first_error = (0, "VALIDATION", "Missing cents→dollars conversion")
4407
+
4408
+ return errors, first_error
4409
+
4410
+
4411
+ def main() -> int:
4412
+ parser = argparse.ArgumentParser(description="Validate Shortcuts output for empty params and unknown actions")
4413
+ parser.add_argument("shortcut", help="Path to .xml or .shortcut file")
4414
+ parser.add_argument(
4415
+ "--target-macos",
4416
+ default=None,
4417
+ help=(
4418
+ "Target macOS major version for bundled ToolKit availability. "
4419
+ "Use 26, 27, auto (default), or latest/all to include every packaged snapshot."
4420
+ ),
4421
+ )
4422
+ parser.add_argument(
4423
+ "--target-platform",
4424
+ default=None,
4425
+ help=(
4426
+ "Target platform for bundled ToolKit availability. "
4427
+ "Use macos (default), ios/ipados, or all to include every packaged platform."
4428
+ ),
4429
+ )
4430
+ args = parser.parse_args()
4431
+
4432
+ shortcut_path = Path(args.shortcut).expanduser().resolve()
4433
+ if not shortcut_path.exists():
4434
+ print(f"File not found: {shortcut_path}", file=sys.stderr)
4435
+ return 1
4436
+
4437
+ skill_dir = Path(__file__).resolve().parents[1]
4438
+ target_macos_major = resolve_target_macos_major(args.target_macos)
4439
+ target_platform = resolve_target_platform(args.target_platform)
4440
+ allowed_ids = load_allowed_ids(skill_dir, target_macos_major, target_platform)
4441
+ unavailable_ids = load_future_toolkit_id_reasons(
4442
+ skill_dir,
4443
+ target_macos_major,
4444
+ target_platform,
4445
+ )
4446
+ unavailable_parameter_keys = load_future_parameter_key_reasons(target_macos_major)
4447
+ toolkit_parameter_schemas = load_toolkit_parameter_schemas(
4448
+ skill_dir,
4449
+ target_macos_major,
4450
+ target_platform,
4451
+ )
4452
+ toolkit_parameter_enum_cases = load_toolkit_parameter_enum_cases(
4453
+ skill_dir,
4454
+ target_macos_major,
4455
+ target_platform,
4456
+ )
4457
+ toolkit_parameter_boolean_keys = load_toolkit_parameter_boolean_keys(
4458
+ skill_dir,
4459
+ target_macos_major,
4460
+ target_platform,
4461
+ )
4462
+ workflow_trigger_catalog = load_workflow_trigger_catalog(
4463
+ skill_dir,
4464
+ target_macos_major,
4465
+ target_platform,
4466
+ )
4467
+ allowed_glyph_ids, allowed_icon_colors = load_icon_metadata(skill_dir)
4468
+
4469
+ try:
4470
+ plist = load_plist(shortcut_path)
4471
+ except Exception as e:
4472
+ print(f"Failed to read plist: {e}", file=sys.stderr)
4473
+ return 1
4474
+
4475
+ errors, first_error = validate(
4476
+ plist,
4477
+ allowed_ids,
4478
+ allowed_glyph_ids,
4479
+ allowed_icon_colors,
4480
+ unavailable_ids,
4481
+ unavailable_parameter_keys,
4482
+ toolkit_parameter_schemas,
4483
+ toolkit_parameter_enum_cases,
4484
+ toolkit_parameter_boolean_keys,
4485
+ workflow_trigger_catalog,
4486
+ target_macos_major,
4487
+ )
4488
+
4489
+ # Repeating-hex UUID check (agent placeholder detection). Runs on the raw
4490
+ # file text so we catch UUIDs in WFWorkflowActionParameters.UUID,
4491
+ # OutputUUID references, GroupingIdentifier, and anywhere else a repeating
4492
+ # placeholder could hide. Deduplicated so we surface each offending UUID
4493
+ # once with a single remediation hint.
4494
+ try:
4495
+ raw_text = shortcut_path.read_text(encoding="utf-8")
4496
+ except (OSError, UnicodeDecodeError):
4497
+ raw_text = ""
4498
+ repeating_uuids = sorted({
4499
+ match.group(0) for match in REPEATING_UUID_RE.finditer(raw_text)
4500
+ })
4501
+ if repeating_uuids:
4502
+ examples = ", ".join(repeating_uuids[:3])
4503
+ more = f" (+{len(repeating_uuids) - 3} more)" if len(repeating_uuids) > 3 else ""
4504
+ errors.append(
4505
+ f"Found {len(repeating_uuids)} repeating-hex placeholder UUID(s): {examples}{more}. "
4506
+ f"Every action UUID must be generated via `uuidgen | tr '[:lower:]' '[:upper:]'`, "
4507
+ f"not hand-picked sequences. Re-mint ALL offending UUIDs and update every "
4508
+ f"OutputUUID / GroupingIdentifier reference to match."
4509
+ )
4510
+
4511
+ if errors:
4512
+ print("Validation failed:\n")
4513
+ if first_error:
4514
+ idx, ident, snippet = first_error
4515
+ print(f"First failing action: index {idx} ({ident})")
4516
+ print(f"Snippet: {snippet}\n")
4517
+ for err in errors:
4518
+ print(f"- {err}")
4519
+ return 1
4520
+
4521
+ print("Validation passed.")
4522
+ return 0
4523
+
4524
+
4525
+ if __name__ == "__main__":
4526
+ raise SystemExit(main())