@salesforce/afv-skills 1.55.0 → 1.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/package.json +1 -1
  2. package/skills/agentforce-generate/SKILL.md +8 -0
  3. package/skills/agentforce-generate/references/actions-reference.md +2 -2
  4. package/skills/agentforce-generate/references/voice-modality-reference.md +51 -15
  5. package/skills/agentforce-generate/references/voice-telephony-cli.md +193 -0
  6. package/skills/agentforce-observe/SKILL.md +13 -9
  7. package/skills/agentforce-observe/references/ahm-alerts.md +193 -14
  8. package/skills/automation-sandbox-post-copy-config-generate/SKILL.md +24 -15
  9. package/skills/automation-sandbox-post-copy-config-generate/assets/config_template.json +11 -0
  10. package/skills/automation-sandbox-post-copy-config-generate/assets/json_schema.json +33 -1
  11. package/skills/automation-sandbox-post-copy-config-generate/references/configuration_catalog.md +32 -1
  12. package/skills/automation-sandbox-post-copy-configure/SKILL.md +75 -71
  13. package/skills/automation-sandbox-post-copy-configure/assets/scheduled_apex_template.apex +13 -0
  14. package/skills/automation-sandbox-post-copy-configure/examples/sample_scheduled_apex_config.json +13 -0
  15. package/skills/automation-sandbox-post-copy-configure/references/scheduled_apex_path.md +155 -0
  16. package/skills/automation-sandbox-post-copy-configure/scripts/build-scheduled-apex.mjs +62 -0
  17. package/skills/automation-sandbox-post-copy-configure/scripts/soql-escape-job-name.mjs +28 -0
  18. package/skills/consumer-goods-accruals-datakit-deploy/SKILL.md +383 -0
  19. package/skills/consumer-goods-accruals-datakit-deploy/scripts/setup.js +324 -0
  20. package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/00-check-prerequisites.js +63 -0
  21. package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/01-download-static-resource.js +100 -0
  22. package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/02-replace-org-id.js +46 -0
  23. package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/03-deploy-metadata.js +46 -0
  24. package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/04-deploy-engine.js +198 -0
  25. package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/05-deploy-tpm-accruals.js +231 -0
  26. package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/06-create-dataspace.js +82 -0
  27. package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/07-deploy-accruals-reports.js +517 -0
  28. package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/08-deploy-ui.js +416 -0
  29. package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/09-completion.js +44 -0
  30. package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/utils.js +514 -0
  31. package/skills/consumer-goods-sync-management-configure/SKILL.md +137 -0
  32. package/skills/consumer-goods-sync-management-configure/examples/scenarios.md +294 -0
  33. package/skills/consumer-goods-sync-management-configure/references/act1-setup-sync.md +333 -0
  34. package/skills/consumer-goods-sync-management-configure/references/act2-assign-users.md +324 -0
  35. package/skills/consumer-goods-sync-management-configure/references/act3-plan-verify.md +113 -0
  36. package/skills/consumer-goods-sync-management-configure/references/readiness-and-enablement.md +164 -0
  37. package/skills/consumer-goods-sync-management-configure/references/sync-management-app-install-backbone.md +159 -0
  38. package/skills/consumer-goods-sync-management-configure/references/transports-and-namespace.md +220 -0
  39. package/skills/consumer-goods-sync-management-configure/references/verify-and-smoke.md +175 -0
  40. package/skills/dx-org-analyze/SKILL.md +0 -1
  41. package/skills/dx-org-analyze/scripts/collect_org_data.py +2 -2
  42. package/skills/dx-org-analyze/scripts/compute_diff.py +2 -4
  43. package/skills/dx-org-analyze/scripts/introspect_org.py +4 -40
  44. package/skills/dx-org-manage/SKILL.md +6 -50
  45. package/skills/dx-org-manage/examples/README.md +10 -16
  46. package/skills/dx-org-manage/references/scratch-org-create.md +1 -1
  47. package/skills/dx-org-manage/references/scratch-org-operations.md +1 -1
  48. package/skills/dx-org-shape-manage/SKILL.md +4 -3
  49. package/skills/education-cloud-domain-configure/references/gotchas-extended.md +1 -0
  50. package/skills/experience-accessibility-validate/SKILL.md +8 -5
  51. package/skills/experience-accessibility-validate/references/reviewers/sc-1-1-1-non-text-content.md +1 -4
  52. package/skills/experience-accessibility-validate/references/reviewers/sc-1-3-1-i-lists.md +0 -4
  53. package/skills/experience-accessibility-validate/references/reviewers/sc-1-3-1-ii-tables.md +20 -85
  54. package/skills/experience-accessibility-validate/references/reviewers/sc-1-3-1-iii-form-labels.md +4 -4
  55. package/skills/experience-accessibility-validate/references/reviewers/sc-1-3-1-iv-regions.md +0 -4
  56. package/skills/experience-accessibility-validate/references/reviewers/sc-1-3-1-v-groups.md +2 -4
  57. package/skills/experience-accessibility-validate/references/reviewers/sc-1-3-5-identify-input.md +0 -4
  58. package/skills/experience-accessibility-validate/references/reviewers/sc-1-4-3-contrast.md +4 -14
  59. package/skills/experience-accessibility-validate/references/reviewers/sc-2-1-1-keyboard.md +9 -5
  60. package/skills/experience-accessibility-validate/references/reviewers/sc-2-4-4-link-purpose.md +1 -4
  61. package/skills/experience-accessibility-validate/references/reviewers/sc-2-4-6-headings-labels.md +0 -4
  62. package/skills/experience-accessibility-validate/references/reviewers/sc-2-5-1-pointer-gestures.md +0 -4
  63. package/skills/experience-accessibility-validate/references/reviewers/sc-2-5-2-pointer-cancellation.md +0 -4
  64. package/skills/experience-accessibility-validate/references/reviewers/sc-2-5-3-label-in-name.md +0 -4
  65. package/skills/experience-accessibility-validate/references/reviewers/sc-2-5-7-dragging-movement.md +0 -4
  66. package/skills/experience-accessibility-validate/references/reviewers/sc-3-2-1-on-focus.md +0 -4
  67. package/skills/experience-accessibility-validate/references/reviewers/sc-3-2-2-on-input.md +1 -4
  68. package/skills/experience-accessibility-validate/references/reviewers/sc-3-3-1-error-identification.md +1 -4
  69. package/skills/experience-accessibility-validate/references/reviewers/sc-3-3-2-labels-instructions.md +1 -4
  70. package/skills/experience-accessibility-validate/references/reviewers/sc-3-3-3-error-suggestion.md +1 -4
  71. package/skills/experience-accessibility-validate/references/reviewers/sc-4-1-2-i-name.md +4 -5
  72. package/skills/experience-accessibility-validate/references/reviewers/sc-4-1-2-ii-role.md +1 -4
  73. package/skills/experience-accessibility-validate/references/reviewers/sc-4-1-2-iii-value.md +0 -4
  74. package/skills/experience-lwc-accessibility-jest-run/SKILL.md +104 -25
  75. package/skills/experience-lwc-accessibility-jest-run/assets/jest.config.js +4 -0
  76. package/skills/experience-lwc-accessibility-jest-run/assets/sa11y-jest-setup.js +5 -0
  77. package/skills/experience-ui-bundle-app-coordinate/SKILL.md +5 -5
  78. package/skills/experience-ui-bundle-features-generate/SKILL.md +65 -36
  79. package/skills/experience-ui-bundle-features-generate/references/angular/features.md +74 -0
  80. package/skills/experience-ui-bundle-features-generate/references/react/features.md +59 -0
  81. package/skills/experience-ui-bundle-features-generate/scripts/detect-framework.sh +73 -0
  82. package/skills/experience-ui-bundle-frontend-generate/SKILL.md +2 -2
  83. package/skills/experience-ui-bundle-metadata-generate/SKILL.md +26 -14
  84. package/skills/experience-ui-bundle-metadata-generate/references/angular-metadata-generate.md +37 -0
  85. package/skills/experience-ui-bundle-metadata-generate/references/react-metadata-generate.md +32 -0
  86. package/skills/experience-ui-bundle-metadata-generate/scripts/detect-framework.sh +73 -0
  87. package/skills/experience-ui-bundle-metadata-generate/scripts/verify-bundle-location.sh +30 -8
  88. package/skills/field-service-data-capture-migrate/SKILL.md +391 -0
  89. package/skills/field-service-data-capture-migrate/assets/sample-legacy-flow.flow-meta.xml +163 -0
  90. package/skills/field-service-data-capture-migrate/references/architecture-notes.md +40 -0
  91. package/skills/field-service-data-capture-migrate/references/component-mapping.md +278 -0
  92. package/skills/field-service-data-capture-migrate/references/converter-builder-contract.md +183 -0
  93. package/skills/field-service-data-capture-migrate/references/dc-platform-rules.md +130 -0
  94. package/skills/field-service-data-capture-migrate/references/fsm-dc-capability-catalog.md +215 -0
  95. package/skills/field-service-data-capture-migrate/references/known-deploy-errors.md +36 -0
  96. package/skills/field-service-data-capture-migrate/references/migration-checklist.md +121 -0
  97. package/skills/field-service-data-capture-migrate/references/transformer-rules.md +132 -0
  98. package/skills/field-service-data-capture-migrate/scripts/analyze_flow.py +277 -0
  99. package/skills/field-service-data-capture-migrate/scripts/convert_to_dc_spec.py +1311 -0
  100. package/skills/field-service-data-capture-migrate/scripts/cud_analysis.py +402 -0
  101. package/skills/field-service-data-capture-migrate/scripts/deploy_flow.sh +230 -0
  102. package/skills/field-service-data-capture-migrate/scripts/discover_subflow_tree.py +230 -0
  103. package/skills/field-service-data-capture-migrate/scripts/flow_input.py +186 -0
  104. package/skills/field-service-data-capture-migrate/scripts/flow_xml_utils.py +39 -0
  105. package/skills/field-service-data-capture-migrate/scripts/fsm_dc_catalog.py +413 -0
  106. package/skills/field-service-data-capture-migrate/scripts/generate_summary.py +199 -0
  107. package/skills/field-service-data-capture-migrate/scripts/json_to_flow_xml.py +146 -0
  108. package/skills/field-service-data-capture-migrate/scripts/manual_review_flags.py +42 -0
  109. package/skills/field-service-data-capture-migrate/scripts/retrieve_flow_rest.sh +155 -0
  110. package/skills/field-service-data-capture-migrate/scripts/subflow_names.py +49 -0
  111. package/skills/field-service-data-capture-migrate/scripts/transform_flow.py +1661 -0
  112. package/skills/field-service-data-capture-migrate/scripts/validate_flow.sh +147 -0
  113. package/skills/field-service-data-capture-migrate/scripts/vendor/build_flow.py +1258 -0
  114. package/skills/life-sciences-fieldsalesrep-coordinate/SKILL.md +1 -1
  115. package/skills/life-sciences-fieldsalesrep-coordinate/references/orchestration-flow.md +1 -1
  116. package/skills/life-sciences-kam-coordinate/references/stage-5-data-and-plan-templates-overview.md +14 -1
  117. package/skills/mobile-apps-create/SKILL.md +1 -1
  118. package/skills/{platform-agentsetup-categories-fetch → platform-agenticsetup-categories-get}/SKILL.md +7 -7
  119. package/skills/platform-sandbox-configure/SKILL.md +108 -133
  120. package/skills/platform-sandbox-configure/references/api-response-shapes.md +39 -0
  121. package/skills/platform-sandbox-configure/references/definition-file-approach.md +73 -0
  122. package/skills/platform-widget-generate/SKILL.md +26 -18
  123. package/skills/platform-widget-generate/examples/formulas.json +191 -0
  124. package/skills/platform-widget-generate/references/widget-formulas.md +123 -0
  125. package/skills/platform-widget-generate/references/widget-meta-directives.md +4 -4
  126. package/skills/sales-call-scoring-configure/SKILL.md +318 -0
  127. package/skills/sales-call-scoring-configure/assets/best-practice-competencies.json +67 -0
  128. package/skills/sales-call-scoring-configure/assets/ootb-competencies.json +58 -0
  129. package/skills/sales-call-scoring-configure/references/admin-communication.md +79 -0
  130. package/skills/sales-call-scoring-configure/references/competency-crud.md +130 -0
  131. package/skills/sales-call-scoring-configure/scripts/create-competency.sh +166 -0
  132. package/skills/sales-call-scoring-configure/scripts/create-custom-competency.sh +166 -0
  133. package/skills/sales-call-scoring-configure/scripts/edit-competency.sh +185 -0
  134. package/skills/sales-call-scoring-configure/scripts/edit-custom-competency.sh +190 -0
  135. package/skills/sales-call-scoring-configure/scripts/enable-call-scoring.sh +436 -0
  136. package/skills/sales-call-scoring-configure/scripts/get-competency.sh +72 -0
  137. package/skills/sales-call-scoring-configure/scripts/get-custom-competency.sh +78 -0
  138. package/skills/sales-call-scoring-configure/scripts/install-best-practice-competencies.sh +223 -0
  139. package/skills/sales-call-scoring-configure/scripts/install-ootb-competencies.sh +237 -0
  140. package/skills/sales-call-scoring-configure/scripts/list-competencies.sh +99 -0
  141. package/skills/sales-call-scoring-configure/scripts/shared/auth.sh +116 -0
  142. package/skills/sales-call-scoring-configure/scripts/shared/cap.sh +86 -0
  143. package/skills/sales-call-scoring-configure/scripts/shared/soap.sh +267 -0
  144. package/skills/sales-call-scoring-configure/scripts/shared/soql.sh +25 -0
  145. package/skills/sales-call-scoring-configure/scripts/toggle-competency.sh +112 -0
  146. package/skills/sales-call-scoring-configure/scripts/toggle-custom-competency.sh +125 -0
  147. package/skills/sales-call-scoring-configure/scripts/verify-ootb-competencies.sh +116 -0
  148. package/skills/service-concierge-portal-generate/references/portal-deploy-runbook.md +1 -0
  149. package/skills/service-de-channel-activate/SKILL.md +6 -5
  150. package/skills/service-de-channel-create/SKILL.md +3 -3
  151. package/skills/service-de-channel-routing-configure/SKILL.md +3 -3
  152. package/skills/service-de-channel-settings-configure/SKILL.md +195 -0
  153. package/skills/service-de-channel-settings-configure/references/channel-settings-facets.md +152 -0
  154. package/skills/{service-de-channel-consent-configure → service-de-channel-settings-configure}/references/gotchas.md +3 -3
  155. package/skills/{service-de-channel-consent-configure → service-de-channel-settings-configure}/references/language-keywords.md +4 -4
  156. package/skills/{service-de-channel-consent-configure → service-de-channel-settings-configure}/references/worked-examples.md +14 -14
  157. package/skills/service-de-headless-channel-configure/SKILL.md +6 -6
  158. package/skills/service-de-headless-channel-configure/references/worked-examples.md +1 -1
  159. package/skills/service-helpagent-coordinate/SKILL.md +3 -2
  160. package/skills/service-helpagent-coordinate/references/channel-voice.md +15 -4
  161. package/skills/service-itsm-agentic-setup-agentforce-coordinate/SKILL.md +49 -31
  162. package/skills/service-itsm-agentic-setup-agentforce-coordinate/examples/output-templates.md +71 -6
  163. package/skills/service-itsm-agentic-setup-agentforce-coordinate/scripts/verify-child-verdict.mjs +26 -4
  164. package/skills/service-itsm-agentic-setup-cmdb-access-assign/SKILL.md +35 -10
  165. package/skills/service-itsm-agentic-setup-cmdb-access-assign/references/mcp-invocation.md +25 -5
  166. package/skills/service-itsm-agentic-setup-cmdb-bundle-deploy/SKILL.md +1 -1
  167. package/skills/service-itsm-agentic-setup-cmdb-configure/SKILL.md +5 -5
  168. package/skills/service-itsm-agentic-setup-cmdb-configure/references/mcp-invocation.md +1 -1
  169. package/skills/service-itsm-agentic-setup-cmdb-coordinate/SKILL.md +2 -2
  170. package/skills/service-itsm-agentic-setup-cmdb-coordinate/examples/output-templates.md +1 -1
  171. package/skills/service-itsm-agentic-setup-cmdb-discovery-configure/SKILL.md +25 -15
  172. package/skills/service-itsm-agentic-setup-employee-agent-configure/SKILL.md +8 -5
  173. package/skills/service-itsm-agentic-setup-employee-agent-configure/references/report-format.md +4 -2
  174. package/skills/service-itsm-agentic-setup-employee-agent-configure/references/workflow-detail.md +1 -1
  175. package/skills/service-itsm-agentic-setup-employee-agent-configure/scripts/render-report.mjs +29 -16
  176. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/SKILL.md +8 -5
  177. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/references/report-format.md +4 -2
  178. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/references/workflow-detail.md +1 -1
  179. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/render-report.mjs +29 -16
  180. package/skills/service-itsm-agentic-setup-incident-management/SKILL.md +138 -68
  181. package/skills/service-itsm-agentic-setup-incident-management/examples/output-templates.md +31 -15
  182. package/skills/service-itsm-agentic-setup-incident-management/references/incident-persona-psg.md +156 -0
  183. package/skills/service-itsm-agentic-setup-incident-management/references/incident-preferences.md +148 -0
  184. package/skills/service-itsm-agentic-setup-incident-management/references/major-incident-management.md +162 -0
  185. package/skills/service-itsm-agentic-setup-incident-management/references/service-mgmt-privilege.md +149 -0
  186. package/skills/service-itsm-agentic-setup-incident-sla-configure/SKILL.md +103 -89
  187. package/skills/service-itsm-agentic-setup-incident-sla-configure/assets/attach-milestone.json +2 -2
  188. package/skills/service-itsm-agentic-setup-incident-sla-configure/assets/predefined-incident-policy.json +21 -39
  189. package/skills/service-itsm-agentic-setup-incident-sla-configure/examples/milestone-patterns.md +3 -3
  190. package/skills/service-itsm-agentic-setup-incident-sla-configure/examples/output-templates.md +18 -1
  191. package/skills/service-itsm-agentic-setup-incident-sla-configure/references/mcp-invocation.md +63 -37
  192. package/skills/service-omni-attribute-routing-configure/SKILL.md +53 -0
  193. package/skills/service-omni-attribute-routing-configure/references/api-notes.md +7 -0
  194. package/skills/service-omni-attribute-routing-configure/scripts/configure-and-report.sh +224 -0
  195. package/skills/service-omni-attribute-routing-configure/scripts/tests/test_attribute_routing_contracts.py +137 -0
  196. package/skills/service-omni-channel-inventory-analyze/SKILL.md +96 -0
  197. package/skills/service-omni-channel-inventory-analyze/references/api-notes.md +52 -0
  198. package/skills/service-omni-channel-inventory-analyze/scripts/analyze.sh +185 -0
  199. package/skills/service-omni-channel-inventory-analyze/scripts/tests/_bootstrap.py +84 -0
  200. package/skills/service-omni-channel-inventory-analyze/scripts/tests/test_inventory_analyze_contracts.py +141 -0
  201. package/skills/service-omni-channel-limits-analyze/SKILL.md +70 -0
  202. package/skills/service-omni-channel-limits-analyze/references/api-notes.md +22 -0
  203. package/skills/service-omni-channel-limits-analyze/scripts/analyze.sh +159 -0
  204. package/skills/service-omni-channel-limits-analyze/scripts/tests/_bootstrap.py +87 -0
  205. package/skills/service-omni-channel-limits-analyze/scripts/tests/test_limits_analyze_contracts.py +99 -0
  206. package/skills/service-omni-channel-setup-coordinate/SKILL.md +12 -20
  207. package/skills/service-omni-channel-setup-coordinate/scripts/integration-driver.sh +55 -5
  208. package/skills/service-omni-channel-setup-coordinate/scripts/tests/test_omni_contracts.py +38 -0
  209. package/skills/service-omni-command-center-analyze/SKILL.md +2 -2
  210. package/skills/service-omni-command-center-analyze/scripts/analyze.sh +3 -3
  211. package/skills/dx-org-manage/examples/snapshots/error_output.json +0 -9
  212. package/skills/dx-org-manage/examples/snapshots/success_output.json +0 -15
  213. package/skills/dx-org-manage/references/cli_flags.md +0 -67
  214. package/skills/dx-org-manage/references/creating-snapshot.md +0 -103
  215. package/skills/dx-org-manage/references/snapshot_usage.md +0 -74
  216. package/skills/experience-accessibility-validate/scripts/contrast-ratio.py +0 -153
  217. package/skills/experience-lwc-accessibility-jest-run/references/running-sa11y-jest-tests.md +0 -205
  218. package/skills/experience-ui-bundle-features-generate/scripts/verify-react-bundle.sh +0 -14
  219. /package/skills/experience-ui-bundle-features-generate/references/{conflict-resolution-schema.json → common/conflict-resolution-schema.json} +0 -0
  220. /package/skills/{platform-agentsetup-categories-fetch → platform-agenticsetup-categories-get}/references/api-response-schema.md +0 -0
@@ -0,0 +1,1311 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Convert a legacy FieldServiceMobile flow to a Data Capture spec JSON.
4
+
5
+ Usage:
6
+ python3 convert_to_dc_spec.py <input_flow.xml> <output_spec.json>
7
+
8
+ Outputs a JSON spec compatible with scripts/vendor/build_flow.py's input contract.
9
+ Handles both legacy FieldServiceMobile (InputField) and modern dc* component formats.
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import json
15
+ import xml.etree.ElementTree as ET
16
+ from typing import Dict, List, Any, Optional
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19
+ import flow_input # noqa: E402
20
+ from flow_xml_utils import NS, _findtext, _findall, _findall_deep # noqa: E402,F401
21
+ import cud_analysis # noqa: E402
22
+
23
+ MAX_SECTION_DEPTH = 5
24
+
25
+
26
+ def _extract_right_value(cond_el: ET.Element) -> Optional[Dict[str, Any]]:
27
+ """Return the typed rightValue of a <conditions> element as a single-key dict.
28
+
29
+ One of {booleanValue|numberValue|stringValue|elementReference}, or None.
30
+ """
31
+ rv = _findall(cond_el, 'rightValue')
32
+ if not rv:
33
+ return None
34
+ rv_el = rv[0]
35
+ bool_txt = _findtext(rv_el, 'booleanValue')
36
+ if bool_txt is not None:
37
+ return {'booleanValue': bool_txt == 'true'}
38
+ num_txt = _findtext(rv_el, 'numberValue')
39
+ if num_txt is not None:
40
+ return {'numberValue': float(num_txt)}
41
+ ref_txt = _findtext(rv_el, 'elementReference')
42
+ if ref_txt is not None:
43
+ return {'elementReference': ref_txt}
44
+ str_txt = _findtext(rv_el, 'stringValue')
45
+ if str_txt is not None:
46
+ return {'stringValue': str_txt}
47
+ return None
48
+
49
+
50
+ def extract_decisions(root: ET.Element) -> List[Dict[str, Any]]:
51
+ """Extract source <decisions> into the spec-level decisions list.
52
+
53
+ Faithful structural extraction in document order. No interpretation
54
+ (collapse-to-visibility is 6b; section inference is 6c).
55
+ """
56
+ result: List[Dict[str, Any]] = []
57
+ for dec in _findall_deep(root, 'decisions'):
58
+ name = _findtext(dec, 'name')
59
+ if not name:
60
+ continue
61
+ entry: Dict[str, Any] = {
62
+ 'name': name,
63
+ 'label': _findtext(dec, 'label') or name,
64
+ 'defaultConnector': None,
65
+ 'defaultLabel': _findtext(dec, 'defaultConnectorLabel'),
66
+ 'rules': [],
67
+ }
68
+ default_conn = _findall(dec, 'defaultConnector')
69
+ if default_conn:
70
+ entry['defaultConnector'] = _findtext(default_conn[0], 'targetReference')
71
+
72
+ for rule in _findall(dec, 'rules'):
73
+ rule_name = _findtext(rule, 'name')
74
+ if not rule_name:
75
+ continue
76
+ conditions: List[Dict[str, Any]] = []
77
+ for cond in _findall(rule, 'conditions'):
78
+ left = _findtext(cond, 'leftValueReference')
79
+ op = _findtext(cond, 'operator')
80
+ cond_entry: Dict[str, Any] = {
81
+ 'leftValueReference': left,
82
+ 'operator': op or 'EqualTo',
83
+ }
84
+ rv = _extract_right_value(cond)
85
+ if rv is not None:
86
+ cond_entry['rightValue'] = rv
87
+ conditions.append(cond_entry)
88
+
89
+ connects_to = None
90
+ conn = _findall(rule, 'connector')
91
+ if conn:
92
+ connects_to = _findtext(conn[0], 'targetReference')
93
+
94
+ entry['rules'].append({
95
+ 'name': rule_name,
96
+ 'label': _findtext(rule, 'label') or rule_name,
97
+ 'conditionLogic': _findtext(rule, 'conditionLogic') or 'and',
98
+ 'conditions': conditions,
99
+ 'connectsTo': connects_to,
100
+ })
101
+ result.append(entry)
102
+ return result
103
+
104
+
105
+ def extract_field_type(field_element: ET.Element) -> tuple:
106
+ """Determine Data Capture field type from legacy field element."""
107
+ ext_name = _findtext(field_element, 'extensionName')
108
+ field_type_elem = _findtext(field_element, 'fieldType')
109
+ data_type = _findtext(field_element, 'dataType')
110
+
111
+ # Classification is keyed on the REAL FSM vocabulary: fieldType first, and
112
+ # extensionName only for ComponentInstance. In a genuine FSM source a
113
+ # `lightning:*` / `c:*` name never appears as a fieldType — it only ever
114
+ # shows up as a ComponentInstance <extensionName>, and core (FieldService-
115
+ # MobileFlowValidator) whitelists exactly one such extension:
116
+ # forceContent:fileUpload. See reference/fsm-dc-capability-catalog.md.
117
+
118
+ # Already a DC component (partially hand-migrated source) — pass through.
119
+ if ext_name and 'runtime_service_fieldservice:dc' in ext_name:
120
+ comp = ext_name.split(':')[-1]
121
+ dc_map = {
122
+ 'dcTextInput': 'ShortText', 'dcLongText': 'LongText',
123
+ 'dcEmail': 'Email', 'dcPhone': 'Phone', 'dcNumeric': 'Numeric',
124
+ 'dcCounter': 'Counter', 'dcDate': 'Date', 'dcDateTime': 'DateTime',
125
+ 'dcCheckbox': 'Checkbox', 'dcToggle': 'Toggle', 'dcAddress': 'Address',
126
+ 'dcLookup': 'Lookup', 'dcPicklist': 'Picklist', 'dcRbGroup': 'Radio',
127
+ 'dcCbGroup': 'CheckboxGroup', 'dcSignature': 'Signature',
128
+ 'dcUpFile': 'UploadFile', 'dcUpImage': 'UploadImage',
129
+ 'dcFileView': 'FileView', 'dcName': 'Name', 'dcMatrix': 'Matrix',
130
+ }
131
+ return dc_map.get(comp, 'ShortText'), {}
132
+
133
+ # ComponentInstance with the only FSM-whitelisted screen extension.
134
+ if ext_name == 'forceContent:fileUpload':
135
+ return 'UploadFile', {}
136
+ if ext_name == 'forceContent:imageUpload':
137
+ return 'UploadImage', {}
138
+
139
+ # Known standard extensions that legitimately appear on an FSM screen but
140
+ # have no DC equivalent — name them specifically. "Advanced CSV Import"
141
+ # (Industries CSV Data Import / DPE) is the common one.
142
+ known_unsupported = {
143
+ 'runtime_industries_csv_dataimport:advCsv':
144
+ 'Advanced CSV Import (Industries CSV Data Import / DPE bulk import) — '
145
+ 'no DC equivalent. Rebuild the data-loading step outside the flow '
146
+ '(e.g. Data Processing Engine) or drop it; it cannot be auto-migrated.',
147
+ 'runtime_industries_csv_dataimport:impBeg':
148
+ 'CSV Import (Industries CSV Data Import, beginner variant) — no DC '
149
+ 'equivalent; cannot be auto-migrated.',
150
+ }
151
+ if ext_name in known_unsupported:
152
+ return 'DisplayText', {'unsupported': True,
153
+ 'unsupportedType': 'customComponent',
154
+ 'note': known_unsupported[ext_name]}
155
+
156
+ # Any other ComponentInstance extension (custom c:*, non-whitelisted
157
+ # lightning:*) has no DC extension point — flag as unmigratable.
158
+ if ext_name:
159
+ return 'DisplayText', {
160
+ 'unsupported': True,
161
+ 'unsupportedType': 'customComponent',
162
+ 'note': f'Custom/unsupported component "{ext_name}" — no DC equivalent; '
163
+ f'rebuild with a native dc* component in Flow Builder.'
164
+ }
165
+
166
+ # FieldServiceMobile InputField — map by dataType.
167
+ if field_type_elem == 'InputField':
168
+ # Keys mirror fsm_dc_catalog.py FIELD_TYPE_MAP / transform_flow.py; values are
169
+ # build_flow.py TYPE_MAP spec-type names. Keep in sync with those three.
170
+ dtype_map = {
171
+ 'Boolean': 'Checkbox', 'String': 'ShortText', 'Number': 'Numeric',
172
+ 'Double': 'Numeric', 'Currency': 'Numeric', 'Date': 'Date',
173
+ 'DateTime': 'DateTime', 'Email': 'Email', 'Phone': 'Phone',
174
+ 'Picklist': 'Picklist', 'MultiselectPicklist': 'CheckboxGroup',
175
+ 'TextArea': 'LongText', 'LongTextArea': 'LongText',
176
+ 'Name': 'Name', 'Address': 'Address', 'Toggle': 'Toggle',
177
+ 'Lookup': 'Lookup', 'Counter': 'Counter', 'Matrix': 'Matrix',
178
+ }
179
+ key = data_type or 'String'
180
+ if key in dtype_map:
181
+ return dtype_map[key], {}
182
+ return 'DisplayText', {
183
+ 'unsupported': True, 'unsupportedType': 'unknownField',
184
+ 'note': f'InputField dataType={data_type!r} has no DC mapping; '
185
+ f'emitted as a read-only placeholder. Rebuild in Flow Builder.'
186
+ }
187
+
188
+ # Standalone FSM choice field types (real FSM vocabulary).
189
+ if field_type_elem == 'DropdownBox':
190
+ return 'Picklist', {'needsChoices': True}
191
+ if field_type_elem in ('RadioButtons', 'RadioButtonGroup'):
192
+ return 'Radio', {'needsChoices': True}
193
+ if field_type_elem in ('MultiSelectCheckboxes', 'MultiSelectPicklist'):
194
+ return 'CheckboxGroup', {'needsChoices': True}
195
+ if field_type_elem == 'LargeTextArea':
196
+ return 'LongText', {}
197
+
198
+ if field_type_elem == 'PlainButtons':
199
+ return 'DisplayText', {
200
+ 'unsupported': True, 'unsupportedType': 'PlainButtons',
201
+ 'note': 'CAPABILITY GAP: Data Capture has no user-facing action buttons '
202
+ '(only AGX_FLOW actions). No migration path — remove or redesign '
203
+ 'in Flow Builder.'
204
+ }
205
+
206
+ if field_type_elem in ('DisplayText', 'DisplayRichText'):
207
+ return 'DisplayText', {}
208
+
209
+ if field_type_elem in ('ComponentChoice', 'ComponentMultiChoice'):
210
+ return 'Picklist', {'needsChoices': True}
211
+
212
+ if field_type_elem == 'Repeater':
213
+ return 'DisplayText', {'note': 'Repeater — review manually in Flow Builder'}
214
+
215
+ if field_type_elem in ('RegionContainer', 'Region'):
216
+ return None, {'skip': True} # Layout container, skip
217
+
218
+ return 'DisplayText', {
219
+ 'unsupported': True, 'unsupportedType': 'unknownField',
220
+ 'note': f'Unknown fieldType={field_type_elem!r} (ext={ext_name!r}) has no DC '
221
+ f'equivalent; emitted as a read-only placeholder. Rebuild in Flow Builder.'
222
+ }
223
+
224
+
225
+ def extract_label(field_element: ET.Element) -> str:
226
+ """Extract field label from various possible locations."""
227
+ # Try fieldText first
228
+ label = _findtext(field_element, 'fieldText')
229
+ if label:
230
+ # Strip HTML tags for cleaner labels
231
+ import re
232
+ label = re.sub(r'<[^>]+>', ' ', label).strip()
233
+ label = ' '.join(label.split())
234
+ if label:
235
+ return label[:120] # cap length
236
+
237
+ # Try label inputParameter
238
+ for param in _findall_deep(field_element, 'inputParameters'):
239
+ if _findtext(param, 'name') == 'label':
240
+ val = _findtext(param, 'stringValue')
241
+ if val:
242
+ return val
243
+
244
+ return _findtext(field_element, 'name') or 'Untitled Field'
245
+
246
+
247
+ def extract_required(field_element: ET.Element) -> bool:
248
+ """Extract required flag from field."""
249
+ required_text = _findtext(field_element, 'isRequired')
250
+ return required_text == 'true' if required_text else False
251
+
252
+
253
+ def extract_choices(field_element: ET.Element, root: ET.Element) -> Optional[List[str]]:
254
+ """Extract choice values for picklist/radio/checkbox components."""
255
+ choice_refs = []
256
+ for choice_ref in _findall_deep(field_element, 'choiceReferences'):
257
+ if choice_ref.text:
258
+ choice_refs.append(choice_ref.text)
259
+
260
+ if not choice_refs:
261
+ return None
262
+
263
+ choices = []
264
+ for choice_name in choice_refs:
265
+ for choice in _findall_deep(root, 'choices'):
266
+ if _findtext(choice, 'name') == choice_name:
267
+ label = _findtext(choice, 'choiceText') or _findtext(choice, 'label')
268
+ if label:
269
+ choices.append(label)
270
+
271
+ return choices if choices else None
272
+
273
+
274
+ def _cud_element_names(root: ET.Element) -> set:
275
+ names = set()
276
+ for tag in ('recordCreates', 'recordUpdates', 'recordDeletes'):
277
+ for el in _findall_deep(root, tag):
278
+ n = _findtext(el, 'name')
279
+ if n:
280
+ names.add(n)
281
+ return names
282
+
283
+
284
+ def _source_input_assignments(cud_el: ET.Element) -> List[Dict[str, Any]]:
285
+ """Map a source <recordCreates> element's <inputAssignments> to builder shape.
286
+
287
+ Builder (_render_value) recognises value|valueRef|boolean|number keys.
288
+ """
289
+ out: List[Dict[str, Any]] = []
290
+ for ia in _findall(cud_el, 'inputAssignments'):
291
+ field_name = _findtext(ia, 'field')
292
+ if not field_name:
293
+ continue
294
+ entry: Dict[str, Any] = {'field': field_name}
295
+ val_els = _findall(ia, 'value')
296
+ if val_els:
297
+ v = val_els[0]
298
+ ref = _findtext(v, 'elementReference')
299
+ boolv = _findtext(v, 'booleanValue')
300
+ numv = _findtext(v, 'numberValue')
301
+ strv = _findtext(v, 'stringValue')
302
+ if ref is not None:
303
+ entry['valueRef'] = ref
304
+ elif boolv is not None:
305
+ entry['boolean'] = (boolv == 'true')
306
+ elif numv is not None:
307
+ entry['number'] = float(numv)
308
+ elif strv is not None:
309
+ entry['value'] = strv
310
+ # Skip hollow inputAssignment entries (field only, no value)
311
+ if len(entry) > 1:
312
+ out.append(entry)
313
+ return out
314
+
315
+
316
+ def _build_postscreen_creates(root: ET.Element,
317
+ consolidated: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
318
+ """Build postScreen.recordCreates entries for the consolidated safe creates."""
319
+ by_name = {_findtext(el, 'name'): el for el in _findall_deep(root, 'recordCreates')}
320
+ entries: List[Dict[str, Any]] = []
321
+ names = [c['node'] for c in consolidated]
322
+ for i, name in enumerate(names):
323
+ el = by_name.get(name)
324
+ if el is None:
325
+ continue
326
+ entry: Dict[str, Any] = {
327
+ 'name': name,
328
+ 'label': _findtext(el, 'label') or name,
329
+ 'object': _findtext(el, 'object') or '',
330
+ 'inputAssignments': _source_input_assignments(el),
331
+ }
332
+ entries.append(entry)
333
+ # Chain emitted entries in order to prevent dangling connectors
334
+ for j in range(len(entries) - 1):
335
+ entries[j]['next'] = entries[j + 1]['name']
336
+ return entries
337
+
338
+
339
+ _FLOW_ELEMENT_TAGS = (
340
+ 'screens', 'decisions', 'assignments', 'recordCreates', 'recordUpdates',
341
+ 'recordDeletes', 'recordLookups', 'subflows', 'loops',
342
+ )
343
+
344
+
345
+ def detect_unsupported_elements(root: ET.Element) -> List[Dict[str, str]]:
346
+ """Flow-level constructs that DataCaptureFlow cannot run, in document order.
347
+
348
+ Each entry is {type, name, reason} with type in {'apex','subflow','faultPath'}.
349
+ Field-level unsupported components (custom / unknown) are collected separately
350
+ during the screen loop, not here. Never silently dropped — this is the
351
+ partial-migration (WI-7) surface.
352
+ """
353
+ out: List[Dict[str, str]] = []
354
+
355
+ for el in _findall_deep(root, 'actionCalls'):
356
+ name = _findtext(el, 'name')
357
+ if name:
358
+ out.append({'type': 'apex', 'name': name,
359
+ 'reason': 'Apex / action call is not supported at DataCaptureFlow '
360
+ 'runtime (offline-first). Rebuild as a record lookup or remove.'})
361
+
362
+ for el in _findall_deep(root, 'subflows'):
363
+ name = _findtext(el, 'name')
364
+ if name:
365
+ out.append({'type': 'subflow', 'name': name,
366
+ 'reason': 'Subflow invocation — a DataCaptureFlow subflow must be '
367
+ 'DataCaptureFlow-type and contain no CUD. Inline or convert; '
368
+ 'not auto-migrated.'})
369
+
370
+ for tag in _FLOW_ELEMENT_TAGS:
371
+ for el in _findall_deep(root, tag):
372
+ if _findall(el, 'faultConnector'):
373
+ name = _findtext(el, 'name')
374
+ if name:
375
+ out.append({'type': 'faultPath', 'name': name,
376
+ 'reason': 'Fault-path connector — DataCaptureFlow has no fault '
377
+ 'paths; fault handling is stripped. Review manually.'})
378
+ return out
379
+
380
+
381
+ def _local(tag: str) -> str:
382
+ """Strip an XML namespace from a tag: '{ns}screens' -> 'screens'."""
383
+ return tag.rsplit('}', 1)[-1]
384
+
385
+
386
+ def _element_index(root: ET.Element) -> Dict[str, tuple]:
387
+ """Map element name -> (localTagName, element) for named flow elements."""
388
+ idx: Dict[str, tuple] = {}
389
+ for tag in _FLOW_ELEMENT_TAGS:
390
+ for el in _findall_deep(root, tag):
391
+ name = _findtext(el, 'name')
392
+ if name and name not in idx:
393
+ idx[name] = (tag, el)
394
+ return idx
395
+
396
+
397
+ def _connector_targets_of(el: ET.Element) -> List[str]:
398
+ """All connector targetReferences carried by one element (incl. rules)."""
399
+ targets: List[str] = []
400
+ for conn_tag in ('connector', 'defaultConnector', 'nextValueConnector',
401
+ 'noMoreValuesConnector'):
402
+ for conn in _findall(el, conn_tag):
403
+ t = _findtext(conn, 'targetReference')
404
+ if t:
405
+ targets.append(t)
406
+ for rule in _findall(el, 'rules'):
407
+ for conn in _findall(rule, 'connector'):
408
+ t = _findtext(conn, 'targetReference')
409
+ if t:
410
+ targets.append(t)
411
+ return targets
412
+
413
+
414
+ def detect_ceiling_violations(root: ET.Element) -> List[Dict[str, str]]:
415
+ """Flag DC deploy-time ceilings decision translation can hit, in document order.
416
+
417
+ Three classes, each an independent {name, reason}:
418
+ 1. Any element reachable from a CUD node via connectors (conservative: not
419
+ de-duplicated against pre-CUD paths).
420
+ 2. Any element carrying a <faultConnector> (DC has no fault paths).
421
+ 3. Any <loops> (DC loop composition is constrained; flagged conservatively).
422
+ Never silently dropped — this is the WI-7 surface.
423
+ """
424
+ flags: List[Dict[str, str]] = []
425
+ idx = _element_index(root)
426
+ cud = _cud_element_names(root)
427
+
428
+ # 1. Post-CUD reachability: BFS from each CUD node over connectors; any element
429
+ # reached (other than a CUD node itself) is a post-CUD violation.
430
+ reached: set = set()
431
+ frontier = [t for name in cud if name in idx
432
+ for t in _connector_targets_of(idx[name][1])]
433
+ while frontier:
434
+ nxt = frontier.pop()
435
+ if nxt in reached or nxt not in idx:
436
+ continue
437
+ reached.add(nxt)
438
+ frontier.extend(_connector_targets_of(idx[nxt][1]))
439
+ for name in _ordered_element_names(root):
440
+ if name in reached and name not in cud:
441
+ tag = idx[name][0]
442
+ flags.append({'name': name,
443
+ 'reason': f'{_local_tag_singular(tag)} "{name}" runs after a CUD '
444
+ f'node; DC forbids elements after create/update/delete '
445
+ f'(CUD-at-end). DC will reject elements sequenced after a CUD node — '
446
+ f'emitted but flagged for WI-7; restructure before deploy.'})
447
+
448
+ # 2. Fault connectors.
449
+ for name in _ordered_element_names(root):
450
+ el = idx[name][1]
451
+ if _findall(el, 'faultConnector'):
452
+ flags.append({'name': name,
453
+ 'reason': f'element "{name}" has a fault-path connector; DC has no '
454
+ f'fault paths (fault handling is stripped). Flagged for WI-7.'})
455
+
456
+ # 3. Loops (conservative: any loop is surfaced for manual review).
457
+ for lp in _findall_deep(root, 'loops'):
458
+ name = _findtext(lp, 'name')
459
+ if name:
460
+ flags.append({'name': name,
461
+ 'reason': f'loop "{name}" composition is not supported in DC; '
462
+ f'review manually. Flagged for WI-7.'})
463
+ return flags
464
+
465
+
466
+ def _ordered_element_names(root: ET.Element) -> List[str]:
467
+ """Named flow elements in document order across the flow-element tags."""
468
+ names: List[str] = []
469
+ for tag in _FLOW_ELEMENT_TAGS:
470
+ for el in _findall_deep(root, tag):
471
+ n = _findtext(el, 'name')
472
+ if n:
473
+ names.append(n)
474
+ return names
475
+
476
+
477
+ def _local_tag_singular(tag: str) -> str:
478
+ return {'decisions': 'decision', 'screens': 'screen', 'recordLookups': 'lookup',
479
+ 'subflows': 'subflow', 'assignments': 'assignment', 'loops': 'loop',
480
+ 'recordCreates': 'create', 'recordUpdates': 'update',
481
+ 'recordDeletes': 'delete'}.get(tag, tag)
482
+
483
+
484
+ def _inbound_counts(root: ET.Element) -> Dict[str, int]:
485
+ """Count, per target element name, how many connectors point at it."""
486
+ counts: Dict[str, int] = {}
487
+ for tag in _FLOW_ELEMENT_TAGS:
488
+ for el in _findall_deep(root, tag):
489
+ for t in _connector_targets_of(el):
490
+ counts[t] = counts.get(t, 0) + 1
491
+ return counts
492
+
493
+
494
+ def _screen_migratable_fields(screen_el: ET.Element) -> List[str]:
495
+ """Field names on a screen that survive extract_field_type filtering."""
496
+ names: List[str] = []
497
+ for field in _findall(screen_el, 'fields'):
498
+ fname = _findtext(field, 'name')
499
+ if not fname:
500
+ continue
501
+ ftype, meta = extract_field_type(field)
502
+ if meta.get('skip') or ftype is None:
503
+ continue
504
+ names.append(fname)
505
+ return names
506
+
507
+
508
+ def _resolve_left(ref: Optional[str], emitted_field_names: set) -> bool:
509
+ """A condition left-value resolves iff its base (before any '.') is an
510
+ emitted form field."""
511
+ if not ref:
512
+ return False
513
+ return ref.split('.')[0] in emitted_field_names
514
+
515
+
516
+ def _conditions_resolvable(rule: Dict[str, Any], emitted_field_names: set) -> bool:
517
+ # Enforces spec collapse-condition 6 (conditions reference emitted form
518
+ # fields). We check membership, not screen ordering: the spec's "positioned
519
+ # before the field" clause is guaranteed structurally, not verified here —
520
+ # the gating decision is only reached after its upstream screens have run,
521
+ # so any field its condition references is necessarily emitted earlier in a
522
+ # valid flow. A forward reference could only arise in an already-broken
523
+ # source flow, which is out of scope.
524
+ for cond in rule.get('conditions') or []:
525
+ if not _resolve_left(cond.get('leftValueReference'), emitted_field_names):
526
+ return False
527
+ rv = cond.get('rightValue') or {}
528
+ if 'elementReference' in rv and not _resolve_left(
529
+ rv['elementReference'], emitted_field_names):
530
+ return False
531
+ return True
532
+
533
+
534
+ def _field_dict_by_name(spec_screens: Dict[str, Any], field_name: str) -> Optional[Dict[str, Any]]:
535
+ for fields in spec_screens.values():
536
+ for fd in fields:
537
+ if fd.get('fieldName') == field_name:
538
+ return fd
539
+ return None
540
+
541
+
542
+ def _screen_label_by_name(root: ET.Element) -> Dict[str, str]:
543
+ """Map each screen element NAME -> the display label the spec uses (deduped)."""
544
+ out: Dict[str, str] = {}
545
+ counts: Dict[str, int] = {}
546
+ for i, s in enumerate(_findall_deep(root, 'screens'), 1):
547
+ name = _findtext(s, 'name')
548
+ raw = _findtext(s, 'label') or f'Screen {i}'
549
+ c = counts.get(raw, 0) + 1
550
+ counts[raw] = c
551
+ label = raw if c == 1 else f'{raw} {c}'
552
+ if name:
553
+ out[name] = label
554
+ return out
555
+
556
+
557
+ def _control_field(visibility: Dict[str, Any]) -> Optional[str]:
558
+ conds = visibility.get('conditions') or []
559
+ if not conds:
560
+ return None
561
+ ref = conds[0].get('leftValueReference') or ''
562
+ return ref.split('.')[0] or None
563
+
564
+
565
+ def _find_enclosing_section(items: List[Any], control: str, depth: int) -> Optional[Dict[str, Any]]:
566
+ """DFS for a section sentinel whose fields include `control`, honoring the
567
+ depth cap. Returns the sentinel dict to nest into, or None."""
568
+ if depth >= MAX_SECTION_DEPTH:
569
+ return None
570
+ for it in items:
571
+ if isinstance(it, dict) and 'section' in it and 'fields' in it:
572
+ names = {f.get('fieldName') for f in it['fields']
573
+ if isinstance(f, dict) and 'fieldName' in f}
574
+ if control in names:
575
+ return it
576
+ deeper = _find_enclosing_section(it['fields'], control, depth + 1)
577
+ if deeper is not None:
578
+ return deeper
579
+ return None
580
+
581
+
582
+ def _inject_section(spec_screens: Dict[str, Any], gated_label: Optional[str],
583
+ merge_label: str, section_label: str,
584
+ field_names: List[str], visibility: Dict[str, Any]) -> Optional[Dict[str, Any]]:
585
+ if gated_label is None or gated_label not in spec_screens:
586
+ return None
587
+ gated_fields = spec_screens[gated_label]
588
+ moved_fields = [f for f in gated_fields if f.get('fieldName') in set(field_names)]
589
+ if len(moved_fields) != len(field_names):
590
+ return None
591
+ cleared: List[str] = []
592
+ for f in moved_fields:
593
+ if f.get('isRequired'):
594
+ f['isRequired'] = False
595
+ cleared.append(f['fieldName'])
596
+ sentinel = {'section': section_label, 'visibility': visibility, 'fields': moved_fields}
597
+ del spec_screens[gated_label]
598
+
599
+ control = _control_field(visibility)
600
+ enclosing = (_find_enclosing_section(spec_screens[merge_label], control, 0)
601
+ if control else None)
602
+ if enclosing is not None:
603
+ # Nest inside the enclosing section, right after its own fields.
604
+ enclosing['fields'].append(sentinel)
605
+ nested = True
606
+ else:
607
+ spec_screens[merge_label] = [sentinel] + spec_screens[merge_label]
608
+ nested = False
609
+ return {'label': section_label, 'fields': [f['fieldName'] for f in moved_fields],
610
+ 'cleared': cleared, 'nested': nested}
611
+
612
+
613
+ def _reconverges_transitively(root: ET.Element, screen_el: ET.Element, default_target: str,
614
+ idx: Dict[str, tuple], depth: int = 0,
615
+ visited: Optional[set] = None) -> bool:
616
+ """Check if screen_el's onward path reaches default_target, possibly through
617
+ intermediate collapsible gates. Bounded by MAX_SECTION_DEPTH and cycle-safe."""
618
+ if visited is None:
619
+ visited = set()
620
+ if depth >= MAX_SECTION_DEPTH:
621
+ return False
622
+
623
+ targets = _connector_targets_of(screen_el)
624
+ if len(targets) != 1:
625
+ return False
626
+ next_name = targets[0]
627
+
628
+ # Direct reconvergence (6b single-hop case)
629
+ if next_name == default_target:
630
+ return True
631
+
632
+ # Cycle check
633
+ if next_name in visited:
634
+ return False
635
+ visited.add(next_name)
636
+
637
+ # If next hop is not in index or is a side-effect element, fail
638
+ if next_name not in idx:
639
+ return False
640
+ tag, el = idx[next_name]
641
+
642
+ # If it's a screen, follow its connector recursively
643
+ if tag == 'screens':
644
+ return _reconverges_transitively(root, el, default_target, idx, depth + 1, visited)
645
+
646
+ # If it's a decision, check if it's a collapsible gate with the same merge target
647
+ if tag == 'decisions':
648
+ # Must have exactly one rule + default
649
+ rules = _findall(el, 'rules')
650
+ default_conn_els = _findall(el, 'defaultConnector')
651
+ if len(rules) != 1 or not default_conn_els:
652
+ return False
653
+ default_conn = _findtext(default_conn_els[0], 'targetReference')
654
+ if not default_conn or default_conn != default_target:
655
+ return False
656
+ # Get rule's target
657
+ conn_els = _findall(rules[0], 'connector')
658
+ if not conn_els:
659
+ return False
660
+ rule_target = _findtext(conn_els[0], 'targetReference')
661
+ if not rule_target or rule_target not in idx:
662
+ return False
663
+ rule_tag, rule_el = idx[rule_target]
664
+ if rule_tag != 'screens':
665
+ return False
666
+ # Check if the rule's screen eventually reaches default_target
667
+ return _reconverges_transitively(root, rule_el, default_target, idx, depth + 1, visited)
668
+
669
+ # Other element types (assignment, recordCreate, etc.) are side effects → fail
670
+ return False
671
+
672
+
673
+ # Single-condition operator inverses. IsNull is NOT here: it has no inverse
674
+ # operator, so it is negated by flipping its rightValue boolean instead.
675
+ _OPERATOR_INVERSE = {
676
+ 'EqualTo': 'NotEqualTo',
677
+ 'NotEqualTo': 'EqualTo',
678
+ 'GreaterThan': 'LessThanOrEqualTo',
679
+ 'GreaterThanOrEqualTo': 'LessThan',
680
+ 'LessThan': 'GreaterThanOrEqualTo',
681
+ 'LessThanOrEqualTo': 'GreaterThan',
682
+ }
683
+
684
+
685
+ def _negate_condition(cond: Dict[str, Any]) -> Optional[Dict[str, Any]]:
686
+ """Negate a single condition, or None if its operator has no safe inverse.
687
+
688
+ `Contains`/`StartsWith`/`EndsWith` have no negated Flow operator, so they
689
+ return None (the whole rule then stays a flagged decision).
690
+ """
691
+ op = cond.get('operator') or 'EqualTo'
692
+ if op == 'IsNull':
693
+ rv = cond.get('rightValue') or {}
694
+ if 'booleanValue' not in rv:
695
+ return None # Malformed IsNull: bail rather than mis-negate
696
+ current = bool(rv.get('booleanValue'))
697
+ out = dict(cond)
698
+ out['rightValue'] = {'booleanValue': not current}
699
+ return out
700
+ inverse = _OPERATOR_INVERSE.get(op)
701
+ if inverse is None:
702
+ return None
703
+ out = dict(cond)
704
+ out['operator'] = inverse
705
+ return out
706
+
707
+
708
+ def negate_rule(conditions: List[Dict[str, Any]],
709
+ condition_logic: str) -> Optional[tuple]:
710
+ """Return (negated_conditions, negated_logic) or None if not provably safe.
711
+
712
+ De Morgan: NOT(A and B) == (NOT A) or (NOT B). Negate each condition and
713
+ flip bare `and`<->`or` only if multiple conditions. Custom conditionLogic
714
+ (anything other than bare and/or, e.g. '1 AND (2 OR 3)') is not attempted
715
+ -> None. Any condition with a non-invertible operator -> None.
716
+ """
717
+ if not conditions:
718
+ return None
719
+ logic = (condition_logic or 'and').strip().lower()
720
+ if logic not in ('and', 'or'):
721
+ return None
722
+ negated: List[Dict[str, Any]] = []
723
+ for cond in conditions:
724
+ neg = _negate_condition(cond)
725
+ if neg is None:
726
+ return None
727
+ negated.append(neg)
728
+ # De Morgan: flip logic only if there are multiple conditions
729
+ if len(conditions) > 1:
730
+ flipped = 'or' if logic == 'and' else 'and'
731
+ else:
732
+ flipped = logic
733
+ return negated, flipped
734
+
735
+
736
+ def normalize_inverted_gates(root: ET.Element,
737
+ decisions: List[Dict[str, Any]]) -> List[str]:
738
+ """Rewrite inverted single-target gates into equivalent positive gates.
739
+
740
+ Inverted shape: the DEFAULT branch targets a screen carrying the gated
741
+ field(s); the RULE branch skips to the reconvergence point. The field is
742
+ thus visible when the rule is FALSE. We negate the rule condition and swap
743
+ connectors, so the decision becomes byte-for-byte a positive gate that the
744
+ unchanged collapse_gating_decisions pass then handles (field or section).
745
+
746
+ A decision is rewritten iff (all): exactly one rule + a defaultConnector;
747
+ the DEFAULT target is a screen with >=1 migratable field; that screen has a
748
+ single inbound connector; its onward path reconverges at the RULE's target;
749
+ and negate_rule succeeds. Anything else is left untouched (falls through to
750
+ collapse, which leaves it a decision/flag). Deterministic: document order.
751
+
752
+ Returns the names of the decisions it normalized.
753
+ """
754
+ idx = _element_index(root)
755
+ inbound = _inbound_counts(root)
756
+ normalized: List[str] = []
757
+
758
+ for dec in decisions:
759
+ rules = dec.get('rules') or []
760
+ default = dec.get('defaultConnector')
761
+ if len(rules) != 1 or not default:
762
+ continue
763
+ rule = rules[0]
764
+ rule_target = rule.get('connectsTo')
765
+ if not rule_target:
766
+ continue
767
+ # The GATED screen is the DEFAULT target (inverted shape).
768
+ if default not in idx or idx[default][0] != 'screens':
769
+ continue
770
+ gated_el = idx[default][1]
771
+ if len(_screen_migratable_fields(gated_el)) == 0:
772
+ continue
773
+ # Single inbound to the gated screen (only this default connector).
774
+ if inbound.get(default, 0) != 1:
775
+ continue
776
+ # Reconvergence: gated screen's onward path reaches the RULE's target.
777
+ if not _reconverges_transitively(root, gated_el, rule_target, idx):
778
+ continue
779
+ negated = negate_rule(rule.get('conditions') or [],
780
+ rule.get('conditionLogic') or 'and')
781
+ if negated is None:
782
+ continue
783
+ neg_conditions, neg_logic = negated
784
+ # Rewrite in place: rule now takes the gated branch; default the merge.
785
+ rule['connectsTo'] = default
786
+ rule['conditions'] = neg_conditions
787
+ rule['conditionLogic'] = neg_logic
788
+ dec['defaultConnector'] = rule_target
789
+ normalized.append(dec['name'])
790
+
791
+ return normalized
792
+
793
+
794
+ def collapse_gating_decisions(
795
+ root: ET.Element,
796
+ decisions: List[Dict[str, Any]],
797
+ emitted_field_names: set,
798
+ spec_screens: Dict[str, Any],
799
+ screen_label_by_name: Dict[str, str],
800
+ ) -> tuple:
801
+ """Collapse pure single-field gating decisions into field visibility.
802
+
803
+ A decision collapses iff (all): exactly one rule + a defaultConnector; the
804
+ rule targets a screen with exactly one migratable field; that screen's
805
+ onward connector == the decision's defaultConnector (reconvergence); the
806
+ gated screen has a single inbound connector (only this rule); and every
807
+ condition resolves to an emitted form field. On collapse, the rule's
808
+ conditions/logic are attached verbatim as a shape-(b) visibility block on
809
+ the gated field, and the decision is dropped from the returned list.
810
+
811
+ Returns (remaining_decisions, notes) where notes entries are
812
+ {decision, field} on success or {decision, reason} for a near-miss.
813
+ """
814
+ idx = _element_index(root)
815
+ inbound = _inbound_counts(root)
816
+ remaining: List[Dict[str, Any]] = []
817
+ notes: List[Dict[str, str]] = []
818
+
819
+ for dec in decisions:
820
+ rules = dec.get('rules') or []
821
+ default = dec.get('defaultConnector')
822
+ # 1. exactly one rule + a default connector
823
+ if len(rules) != 1 or not default:
824
+ remaining.append(dec)
825
+ if len(rules) != 1:
826
+ notes.append({'decision': dec['name'],
827
+ 'reason': f'decision has {len(rules)} rules '
828
+ f'(multi-way routing); left as decision'})
829
+ else:
830
+ notes.append({'decision': dec['name'],
831
+ 'reason': 'decision has no defaultConnector; left as decision'})
832
+ continue
833
+ rule = rules[0]
834
+ target = rule.get('connectsTo')
835
+ # 4. taken branch must be a screen (no assignment/record/subflow hop)
836
+ if not target or target not in idx or idx[target][0] != 'screens':
837
+ remaining.append(dec)
838
+ notes.append({'decision': dec['name'],
839
+ 'reason': 'gate target is not a direct screen '
840
+ '(side effect on branch); left as decision'})
841
+ continue
842
+ screen_tag, screen_el = idx[target]
843
+ migratable = _screen_migratable_fields(screen_el)
844
+ # 2. field count: 0 -> not a gate; 1 -> field visibility (6b); >=2 -> section (6c).
845
+ if len(migratable) == 0:
846
+ remaining.append(dec)
847
+ notes.append({'decision': dec['name'],
848
+ 'reason': 'gated screen has no migratable fields; left as decision'})
849
+ continue
850
+ # 3. reconvergence: screen's onward path reaches default target (transitive)
851
+ if not _reconverges_transitively(root, screen_el, default, idx):
852
+ remaining.append(dec)
853
+ notes.append({'decision': dec['name'],
854
+ 'reason': 'branches do not reconverge; left as decision'})
855
+ continue
856
+ # 5. single inbound to the gated screen
857
+ if inbound.get(target, 0) != 1:
858
+ remaining.append(dec)
859
+ notes.append({'decision': dec['name'],
860
+ 'reason': 'gated screen has other inbound routes; left as decision'})
861
+ continue
862
+ # 6. conditions resolvable to emitted form fields
863
+ if not _conditions_resolvable(rule, emitted_field_names):
864
+ remaining.append(dec)
865
+ notes.append({'decision': dec['name'],
866
+ 'reason': 'condition references data not emitted in the '
867
+ 'form (e.g. a record field); collapse deferred to 6e'})
868
+ continue
869
+
870
+ vis = {
871
+ 'conditionLogic': rule.get('conditionLogic') or 'and',
872
+ 'conditions': rule.get('conditions') or [],
873
+ }
874
+ if len(migratable) == 1:
875
+ # 6b field path (unchanged).
876
+ field_name = migratable[0]
877
+ fd = _field_dict_by_name(spec_screens, field_name)
878
+ if fd is None:
879
+ remaining.append(dec)
880
+ notes.append({'decision': dec['name'],
881
+ 'reason': f'gated field {field_name} not found in spec screens; left as decision'})
882
+ continue
883
+ fd['visibility'] = vis
884
+ notes.append({'decision': dec['name'], 'field': field_name})
885
+ else:
886
+ # 6c section path: >=2 fields.
887
+ merge_label = screen_label_by_name.get(default)
888
+ gated_label = screen_label_by_name.get(target)
889
+ if merge_label is None or merge_label not in spec_screens:
890
+ remaining.append(dec)
891
+ notes.append({'decision': dec['name'],
892
+ 'reason': 'multi-field gate reconverges to a non-screen '
893
+ 'element; left as decision'})
894
+ continue
895
+ moved = _inject_section(spec_screens, gated_label, merge_label,
896
+ gated_label,
897
+ migratable, vis)
898
+ if moved is None:
899
+ remaining.append(dec)
900
+ notes.append({'decision': dec['name'],
901
+ 'reason': 'gated section fields not found in spec screens; left as decision'})
902
+ continue
903
+ notes.append({'decision': dec['name'], 'section': moved['label'],
904
+ 'fields': moved['fields'], 'clearedRequired': moved['cleared']})
905
+
906
+ return remaining, notes
907
+
908
+
909
+ def _decision_targets(decision: Dict[str, Any]) -> List[str]:
910
+ targets = []
911
+ if decision.get('defaultConnector'):
912
+ targets.append(decision['defaultConnector'])
913
+ for rule in decision.get('rules') or []:
914
+ if rule.get('connectsTo'):
915
+ targets.append(rule['connectsTo'])
916
+ return targets
917
+
918
+
919
+ def gate_decisions(
920
+ decisions: List[Dict[str, Any]],
921
+ screen_connectors: Dict[str, str],
922
+ cud_names: set,
923
+ ) -> Dict[str, Any]:
924
+ """Partition decisions into emit vs. flag by deployability.
925
+
926
+ A DC ``<decisions>`` element only deploys if every ``<targetReference>`` it
927
+ carries names an element the builder actually emits, and the decision itself
928
+ is reachable from the flow's connector graph. 6a builds screens and decisions
929
+ but does NOT wire the assignment/CUD/lookup targets that legacy decisions
930
+ route to (that is 6b/6c). So the only target 6a can resolve is *another
931
+ surviving decision*, whose api-name the builder preserves verbatim.
932
+
933
+ The gate has two transitive passes:
934
+
935
+ 1. **Resolvability** — a decision survives only if every target is itself a
936
+ surviving decision (fixpoint; a decision pointing at a dropped decision
937
+ is itself dropped).
938
+ 2. **Reachability** — of the resolvable set, keep only decisions reachable
939
+ from a screen connector, propagated across decision-to-decision edges.
940
+ An unreachable decision is an orphan and fails deploy in an
941
+ auto-layout canvas.
942
+
943
+ Returns ``{'kept': [...], 'flags': [...], 'screen_connectors': {...}}`` where
944
+ ``screen_connectors`` is filtered to those targeting an emitted decision
945
+ (all others fall through to the next screen).
946
+ """
947
+ names = {d['name'] for d in decisions}
948
+
949
+ # Pass 1: resolvability fixpoint. CUD-routing decisions are unresolvable
950
+ # by definition (CUD is never a decision), so they drop out here too, but
951
+ # we track them separately to preserve the specific CUD-at-end reason.
952
+ cud_flagged = {
953
+ d['name']: sorted({t for t in _decision_targets(d) if t in cud_names})
954
+ for d in decisions
955
+ if any(t in cud_names for t in _decision_targets(d))
956
+ }
957
+ resolvable = set(names)
958
+ changed = True
959
+ while changed:
960
+ changed = False
961
+ for d in decisions:
962
+ if d['name'] not in resolvable:
963
+ continue
964
+ if any(t not in resolvable for t in _decision_targets(d)):
965
+ resolvable.discard(d['name'])
966
+ changed = True
967
+
968
+ # Pass 2: reachability from screen connectors, propagated across surviving
969
+ # decision-to-decision edges.
970
+ reached = {t for t in screen_connectors.values() if t in resolvable}
971
+ changed = True
972
+ while changed:
973
+ changed = False
974
+ for d in decisions:
975
+ if d['name'] in reached:
976
+ for t in _decision_targets(d):
977
+ if t in resolvable and t not in reached:
978
+ reached.add(t)
979
+ changed = True
980
+
981
+ emitted = resolvable & reached
982
+ kept: List[Dict[str, Any]] = []
983
+ flags: List[Dict[str, str]] = []
984
+ for d in decisions:
985
+ if d['name'] in emitted:
986
+ kept.append(d)
987
+ elif d['name'] in cud_flagged:
988
+ flags.append({
989
+ 'name': d['name'],
990
+ 'reason': (f"Decision routes directly to CUD element(s) {cud_flagged[d['name']]}; "
991
+ f"DC forbids Decision→CUD (CUD-at-end rule). Flagged for WI-7, not emitted."),
992
+ })
993
+ else:
994
+ flags.append({
995
+ 'name': d['name'],
996
+ 'reason': ("Decision target(s) not yet wired (routes to screens/assignments/lookups "
997
+ "6a does not build, or is unreachable); deferred to 6b/6c, not emitted."),
998
+ })
999
+
1000
+ kept_connectors = {
1001
+ src: tgt for src, tgt in screen_connectors.items() if tgt in emitted
1002
+ }
1003
+ return {'kept': kept, 'flags': flags, 'screen_connectors': kept_connectors}
1004
+
1005
+
1006
+ def convert_flow_to_spec(root: ET.Element) -> Dict[str, Any]:
1007
+ """Convert a validated legacy flow root to a Data Capture spec.
1008
+
1009
+ Input is the <Flow> element returned by flow_input.load_and_validate_flow;
1010
+ parsing and validation happen there, not here.
1011
+ """
1012
+ flow_label = _findtext(root, 'label') or 'Migrated Flow'
1013
+
1014
+ # build_flow.py expects: screens = {screen_label: [field_dict, ...]}
1015
+ # field_dict keys: fieldName, fieldLabel, fieldType, isRequired, options
1016
+ spec: Dict[str, Any] = {
1017
+ 'formTitle': flow_label,
1018
+ 'formType': 'DataCaptureFlow',
1019
+ 'apiVersion': _findtext(root, 'apiVersion') or '(unspecified)',
1020
+ 'screens': {}
1021
+ }
1022
+
1023
+ screens = _findall_deep(root, 'screens')
1024
+ screen_label_counts: Dict[str, int] = {}
1025
+ screen_connectors: Dict[str, str] = {}
1026
+ field_level_unsupported: List[Dict[str, str]] = []
1027
+
1028
+ for screen_idx, screen in enumerate(screens, 1):
1029
+ raw_label = _findtext(screen, 'label') or f'Screen {screen_idx}'
1030
+ # Deduplicate screen labels
1031
+ count = screen_label_counts.get(raw_label, 0) + 1
1032
+ screen_label_counts[raw_label] = count
1033
+ screen_label = raw_label if count == 1 else f'{raw_label} {count}'
1034
+
1035
+ fields_list = []
1036
+
1037
+ for field in _findall(screen, 'fields'):
1038
+ field_name = _findtext(field, 'name')
1039
+ if not field_name:
1040
+ continue
1041
+
1042
+ field_type, meta = extract_field_type(field)
1043
+
1044
+ if meta.get('skip') or field_type is None:
1045
+ continue
1046
+
1047
+ label = extract_label(field)
1048
+ required = extract_required(field)
1049
+
1050
+ field_spec: Dict[str, Any] = {
1051
+ 'fieldName': field_name,
1052
+ 'fieldLabel': label,
1053
+ 'fieldType': field_type,
1054
+ 'isRequired': required,
1055
+ }
1056
+
1057
+ if meta.get('needsChoices'):
1058
+ choices = extract_choices(field, root)
1059
+ if choices:
1060
+ field_spec['options'] = choices
1061
+
1062
+ if meta.get('note'):
1063
+ field_spec['_migrationNote'] = meta['note']
1064
+
1065
+ if meta.get('unsupported'):
1066
+ reason = meta.get('note', '')
1067
+ field_spec['fieldLabel'] = f'[UNSUPPORTED: {field_name} — {reason}]'
1068
+ field_level_unsupported.append({
1069
+ 'type': meta.get('unsupportedType', 'unknownField'),
1070
+ 'name': field_name,
1071
+ 'reason': reason,
1072
+ })
1073
+
1074
+ fields_list.append(field_spec)
1075
+
1076
+ if fields_list:
1077
+ spec['screens'][screen_label] = fields_list
1078
+
1079
+ # Only emitted screens (those with migratable fields) can carry a
1080
+ # connector in the output; recording a field-less screen's connector
1081
+ # would seed reachability from a screen that never exists, letting an
1082
+ # orphan decision slip through the deployability gate.
1083
+ conn = _findall(screen, 'connector')
1084
+ if conn:
1085
+ target = _findtext(conn[0], 'targetReference')
1086
+ if target:
1087
+ screen_connectors[screen_label] = target
1088
+
1089
+ all_decisions = extract_decisions(root)
1090
+ inverted_gate_names = normalize_inverted_gates(root, all_decisions)
1091
+ emitted_field_names = {
1092
+ f['fieldName'] for fields in spec['screens'].values() for f in fields
1093
+ }
1094
+ remaining_decisions, collapse_notes = collapse_gating_decisions(
1095
+ root, all_decisions, emitted_field_names, spec['screens'],
1096
+ _screen_label_by_name(root))
1097
+ summary: List[str] = []
1098
+ collapsed = [n for n in collapse_notes if 'field' in n or 'section' in n]
1099
+ if collapsed:
1100
+ spec['_collapsedDecisions'] = collapsed
1101
+ # _invertedGates must reflect gates that ACTUALLY collapsed, not just normalized
1102
+ collapsed_decision_names = {n['decision'] for n in collapsed}
1103
+ actually_inverted = [name for name in inverted_gate_names
1104
+ if name in collapsed_decision_names]
1105
+ if actually_inverted:
1106
+ spec['_invertedGates'] = actually_inverted
1107
+ for n in collapsed:
1108
+ if 'section' in n:
1109
+ summary.append(
1110
+ f"Decision {n['decision']} collapsed into section '{n['section']}' "
1111
+ f"visibility (fields: {', '.join(n['fields'])}).")
1112
+ for cf in n.get('clearedRequired') or []:
1113
+ summary.append(
1114
+ f"Cleared isRequired on '{cf}' (inside gated section "
1115
+ f"'{n['section']}'); add a conditional validation rule in Flow "
1116
+ f"Builder if it must be required when shown.")
1117
+ else:
1118
+ summary.append(
1119
+ f"Decision {n['decision']} collapsed into visibility on field "
1120
+ f"{n['field']}.")
1121
+ if n['decision'] in actually_inverted:
1122
+ item = 'section' if 'section' in n else 'field'
1123
+ summary.append(
1124
+ f"Decision {n['decision']} was an inverted-shape gate; its "
1125
+ f"condition was negated so the {item} shows on the same inputs "
1126
+ f"as the original flow.")
1127
+
1128
+ cud_names = _cud_element_names(root)
1129
+ gated = gate_decisions(remaining_decisions, screen_connectors, cud_names)
1130
+ if gated['kept']:
1131
+ spec['decisions'] = gated['kept']
1132
+ if gated['flags']:
1133
+ spec['_decisionFlags'] = gated['flags']
1134
+ if gated['screen_connectors']:
1135
+ spec['_screenConnectors'] = gated['screen_connectors']
1136
+
1137
+ # WI-7 structural ceilings (post-CUD / fault / loop) go in their OWN key.
1138
+ # _decisionFlags is decision-routing only; ceilings are a distinct concern
1139
+ # and the same element name legitimately appears in both surfaces, so
1140
+ # merging them would collide (name-keyed lookups would return the wrong
1141
+ # reason). Both keys plus _migrationSummary honour "flag, never drop".
1142
+ ceiling = detect_ceiling_violations(root)
1143
+ if ceiling:
1144
+ spec['_ceilingFlags'] = ceiling
1145
+ for c in ceiling:
1146
+ summary.append(f"{c['name']}: {c['reason']}")
1147
+
1148
+ # WI-8: ordinal CUD-placement analysis replaces the old count-based note.
1149
+ cud_report = cud_analysis.analyze_cud_placement(root)
1150
+ consolidated: List[Dict[str, Any]] = []
1151
+ manual: List[Dict[str, Any]] = []
1152
+ for node in cud_report.cud_nodes:
1153
+ if node.verdict == "compliant":
1154
+ continue
1155
+ if node.verdict == "safe_consolidate" and node.op == "create":
1156
+ consolidated.append({
1157
+ 'node': node.name, 'op': node.op,
1158
+ 'from': f'mid-flow (position {node.position})', 'to': 'postScreen'})
1159
+ summary.append(
1160
+ f"CUD create '{node.name}' auto-consolidated to end-of-flow "
1161
+ f"(postScreen); was mid-flow at position {node.position}.")
1162
+ elif node.verdict == "safe_consolidate":
1163
+ reason = (f"Record {node.op} '{node.name}' is data-safe to move to "
1164
+ f"end-of-flow, but there is no automated update/delete "
1165
+ f"postScreen emission path — move it manually after the last "
1166
+ f"screen.")
1167
+ manual.append({'node': node.name, 'op': node.op,
1168
+ 'position': node.position, 'reason': reason,
1169
+ 'blockers': list(node.blockers)})
1170
+ summary.append(f"{node.name}: {reason}")
1171
+ else: # unsafe_manual
1172
+ reason = (f"Record {node.op} '{node.name}' at position {node.position} "
1173
+ f"cannot be auto-consolidated: {'; '.join(node.blockers)}. "
1174
+ f"Restructure manually so all CUD runs after the last screen.")
1175
+ manual.append({'node': node.name, 'op': node.op,
1176
+ 'position': node.position, 'reason': reason,
1177
+ 'blockers': list(node.blockers)})
1178
+ summary.append(f"{node.name}: {reason}")
1179
+
1180
+ cud_elements_present = bool(cud_report.cud_nodes)
1181
+ if cud_elements_present:
1182
+ spec['_cudAnalysis'] = {
1183
+ 'consolidated': consolidated,
1184
+ 'manualRestructure': manual,
1185
+ 'hasMidFlowCud': cud_report.has_midflow,
1186
+ }
1187
+ if consolidated:
1188
+ ps_creates = _build_postscreen_creates(root, consolidated)
1189
+ if ps_creates:
1190
+ post = spec.get('postScreen')
1191
+ if not isinstance(post, dict):
1192
+ post = {}
1193
+ post['recordCreates'] = ps_creates
1194
+ spec['postScreen'] = post
1195
+ if cud_report.has_midflow:
1196
+ spec['_automationNote'] = (
1197
+ 'Mid-flow CUD detected — DataCaptureFlow requires all create/update/'
1198
+ 'delete at end-of-flow. See _cudAnalysis for per-node consolidation '
1199
+ 'and manual-restructure guidance.'
1200
+ )
1201
+
1202
+ subflows = _findall_deep(root, 'subflows')
1203
+
1204
+ if subflows:
1205
+ spec['_subflowNote'] = (
1206
+ f'{len(subflows)} subflow call(s) detected. '
1207
+ f'DataCaptureFlow subflows cannot contain CUD operations. '
1208
+ f'Inline any CUD logic from subflows into the main flow.'
1209
+ )
1210
+
1211
+ incomplete = detect_unsupported_elements(root) + field_level_unsupported
1212
+ if incomplete:
1213
+ spec['_incompleteMigration'] = incomplete
1214
+
1215
+ if summary:
1216
+ spec['_migrationSummary'] = summary
1217
+
1218
+ return spec
1219
+
1220
+
1221
+ def main():
1222
+ if len(sys.argv) != 3:
1223
+ print(f"[ERROR] Missing required argument. "
1224
+ f"Usage: {sys.argv[0]} <input_flow.xml> <output_spec.json>\n"
1225
+ f"Example: {sys.argv[0]} "
1226
+ f"force-app/main/default/flows/MyFlow.flow-meta.xml /tmp/spec.json",
1227
+ file=sys.stderr)
1228
+ sys.exit(2)
1229
+
1230
+ input_path = sys.argv[1]
1231
+ output_path = sys.argv[2]
1232
+
1233
+ try:
1234
+ root, warnings = flow_input.load_and_validate_flow(input_path)
1235
+ except flow_input.FlowValidationError as e:
1236
+ print(e.message, file=sys.stderr)
1237
+ sys.exit(1)
1238
+
1239
+ for w in warnings:
1240
+ print(w.message, file=sys.stderr)
1241
+
1242
+ spec = convert_flow_to_spec(root)
1243
+
1244
+ # Validate: build_flow.py requires at least one screen with at least one field
1245
+ if not spec['screens']:
1246
+ spec['screens'] = {'Migrated Screen': [
1247
+ {'fieldName': 'placeholder',
1248
+ 'fieldLabel': '[Auto-migrated — no screen fields detected. Review in Flow Builder.]',
1249
+ 'fieldType': 'DisplayText', 'isRequired': False}
1250
+ ]}
1251
+ spec['_migrationNote'] = 'No screen fields detected in source flow. Flow may be automation-only or use unsupported field types. Review and replace placeholder in Flow Builder.'
1252
+
1253
+ with open(output_path, 'w') as f:
1254
+ json.dump(spec, f, indent=2)
1255
+
1256
+ print(f"\n{'='*60}")
1257
+ print(f"Conversion Complete: {spec['formTitle']}")
1258
+ print(f"{'='*60}")
1259
+ print(f"API Version: {spec['apiVersion']}")
1260
+ print(f"Screens: {len(spec['screens'])}")
1261
+ total_fields = sum(len(fields) for fields in spec['screens'].values())
1262
+ print(f"Fields: {total_fields}")
1263
+
1264
+ notes = []
1265
+ for screen_label, fields in spec['screens'].items():
1266
+ for field in fields:
1267
+ if '_migrationNote' in field:
1268
+ notes.append(f" - {field['fieldName']}: {field['_migrationNote']}")
1269
+
1270
+ if notes:
1271
+ print(f"\nManual Configuration Required:")
1272
+ for note in notes:
1273
+ print(note)
1274
+
1275
+ if '_automationNote' in spec:
1276
+ print(f"\nAutomation Review: {spec['_automationNote']}")
1277
+
1278
+ if '_subflowNote' in spec:
1279
+ print(f"\nSubflow Warning: {spec['_subflowNote']}")
1280
+
1281
+ if '_collapsedDecisions' in spec:
1282
+ print(f"\nCollapsed gating decisions -> visibility:")
1283
+ for n in spec['_collapsedDecisions']:
1284
+ if 'section' in n:
1285
+ print(f" - {n['decision']} collapsed into section "
1286
+ f"'{n['section']}' visibility "
1287
+ f"(fields: {', '.join(n['fields'])})")
1288
+ else:
1289
+ print(f" - {n['decision']} collapsed into visibility on "
1290
+ f"field {n['field']}")
1291
+
1292
+ if '_migrationSummary' in spec:
1293
+ print(f"\nMigration Summary:")
1294
+ for line in spec['_migrationSummary']:
1295
+ print(f" - {line}")
1296
+
1297
+ if '_incompleteMigration' in spec:
1298
+ entries = spec['_incompleteMigration']
1299
+ print(f"\nIncomplete Migration ({len(entries)} unsupported element(s) "
1300
+ f"— see the comment block at the top of the built XML):")
1301
+ for e in entries:
1302
+ print(f" - [{e['type']}] {e['name']}: {e['reason']}")
1303
+
1304
+ if '_migrationNote' in spec:
1305
+ print(f"\nNote: {spec['_migrationNote']}")
1306
+
1307
+ print(f"\nSpec written to: {output_path}\n")
1308
+
1309
+
1310
+ if __name__ == '__main__':
1311
+ main()