@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.
- package/package.json +1 -1
- package/skills/agentforce-generate/SKILL.md +8 -0
- package/skills/agentforce-generate/references/actions-reference.md +2 -2
- package/skills/agentforce-generate/references/voice-modality-reference.md +51 -15
- package/skills/agentforce-generate/references/voice-telephony-cli.md +193 -0
- package/skills/agentforce-observe/SKILL.md +13 -9
- package/skills/agentforce-observe/references/ahm-alerts.md +193 -14
- package/skills/automation-sandbox-post-copy-config-generate/SKILL.md +24 -15
- package/skills/automation-sandbox-post-copy-config-generate/assets/config_template.json +11 -0
- package/skills/automation-sandbox-post-copy-config-generate/assets/json_schema.json +33 -1
- package/skills/automation-sandbox-post-copy-config-generate/references/configuration_catalog.md +32 -1
- package/skills/automation-sandbox-post-copy-configure/SKILL.md +75 -71
- package/skills/automation-sandbox-post-copy-configure/assets/scheduled_apex_template.apex +13 -0
- package/skills/automation-sandbox-post-copy-configure/examples/sample_scheduled_apex_config.json +13 -0
- package/skills/automation-sandbox-post-copy-configure/references/scheduled_apex_path.md +155 -0
- package/skills/automation-sandbox-post-copy-configure/scripts/build-scheduled-apex.mjs +62 -0
- package/skills/automation-sandbox-post-copy-configure/scripts/soql-escape-job-name.mjs +28 -0
- package/skills/consumer-goods-accruals-datakit-deploy/SKILL.md +383 -0
- package/skills/consumer-goods-accruals-datakit-deploy/scripts/setup.js +324 -0
- package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/00-check-prerequisites.js +63 -0
- package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/01-download-static-resource.js +100 -0
- package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/02-replace-org-id.js +46 -0
- package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/03-deploy-metadata.js +46 -0
- package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/04-deploy-engine.js +198 -0
- package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/05-deploy-tpm-accruals.js +231 -0
- package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/06-create-dataspace.js +82 -0
- package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/07-deploy-accruals-reports.js +517 -0
- package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/08-deploy-ui.js +416 -0
- package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/09-completion.js +44 -0
- package/skills/consumer-goods-accruals-datakit-deploy/scripts/steps/utils.js +514 -0
- package/skills/consumer-goods-sync-management-configure/SKILL.md +137 -0
- package/skills/consumer-goods-sync-management-configure/examples/scenarios.md +294 -0
- package/skills/consumer-goods-sync-management-configure/references/act1-setup-sync.md +333 -0
- package/skills/consumer-goods-sync-management-configure/references/act2-assign-users.md +324 -0
- package/skills/consumer-goods-sync-management-configure/references/act3-plan-verify.md +113 -0
- package/skills/consumer-goods-sync-management-configure/references/readiness-and-enablement.md +164 -0
- package/skills/consumer-goods-sync-management-configure/references/sync-management-app-install-backbone.md +159 -0
- package/skills/consumer-goods-sync-management-configure/references/transports-and-namespace.md +220 -0
- package/skills/consumer-goods-sync-management-configure/references/verify-and-smoke.md +175 -0
- package/skills/dx-org-analyze/SKILL.md +0 -1
- package/skills/dx-org-analyze/scripts/collect_org_data.py +2 -2
- package/skills/dx-org-analyze/scripts/compute_diff.py +2 -4
- package/skills/dx-org-analyze/scripts/introspect_org.py +4 -40
- package/skills/dx-org-manage/SKILL.md +6 -50
- package/skills/dx-org-manage/examples/README.md +10 -16
- package/skills/dx-org-manage/references/scratch-org-create.md +1 -1
- package/skills/dx-org-manage/references/scratch-org-operations.md +1 -1
- package/skills/dx-org-shape-manage/SKILL.md +4 -3
- package/skills/education-cloud-domain-configure/references/gotchas-extended.md +1 -0
- package/skills/experience-accessibility-validate/SKILL.md +8 -5
- package/skills/experience-accessibility-validate/references/reviewers/sc-1-1-1-non-text-content.md +1 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-1-3-1-i-lists.md +0 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-1-3-1-ii-tables.md +20 -85
- package/skills/experience-accessibility-validate/references/reviewers/sc-1-3-1-iii-form-labels.md +4 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-1-3-1-iv-regions.md +0 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-1-3-1-v-groups.md +2 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-1-3-5-identify-input.md +0 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-1-4-3-contrast.md +4 -14
- package/skills/experience-accessibility-validate/references/reviewers/sc-2-1-1-keyboard.md +9 -5
- package/skills/experience-accessibility-validate/references/reviewers/sc-2-4-4-link-purpose.md +1 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-2-4-6-headings-labels.md +0 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-2-5-1-pointer-gestures.md +0 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-2-5-2-pointer-cancellation.md +0 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-2-5-3-label-in-name.md +0 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-2-5-7-dragging-movement.md +0 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-3-2-1-on-focus.md +0 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-3-2-2-on-input.md +1 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-3-3-1-error-identification.md +1 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-3-3-2-labels-instructions.md +1 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-3-3-3-error-suggestion.md +1 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-4-1-2-i-name.md +4 -5
- package/skills/experience-accessibility-validate/references/reviewers/sc-4-1-2-ii-role.md +1 -4
- package/skills/experience-accessibility-validate/references/reviewers/sc-4-1-2-iii-value.md +0 -4
- package/skills/experience-lwc-accessibility-jest-run/SKILL.md +104 -25
- package/skills/experience-lwc-accessibility-jest-run/assets/jest.config.js +4 -0
- package/skills/experience-lwc-accessibility-jest-run/assets/sa11y-jest-setup.js +5 -0
- package/skills/experience-ui-bundle-app-coordinate/SKILL.md +5 -5
- package/skills/experience-ui-bundle-features-generate/SKILL.md +65 -36
- package/skills/experience-ui-bundle-features-generate/references/angular/features.md +74 -0
- package/skills/experience-ui-bundle-features-generate/references/react/features.md +59 -0
- package/skills/experience-ui-bundle-features-generate/scripts/detect-framework.sh +73 -0
- package/skills/experience-ui-bundle-frontend-generate/SKILL.md +2 -2
- package/skills/experience-ui-bundle-metadata-generate/SKILL.md +26 -14
- package/skills/experience-ui-bundle-metadata-generate/references/angular-metadata-generate.md +37 -0
- package/skills/experience-ui-bundle-metadata-generate/references/react-metadata-generate.md +32 -0
- package/skills/experience-ui-bundle-metadata-generate/scripts/detect-framework.sh +73 -0
- package/skills/experience-ui-bundle-metadata-generate/scripts/verify-bundle-location.sh +30 -8
- package/skills/field-service-data-capture-migrate/SKILL.md +391 -0
- package/skills/field-service-data-capture-migrate/assets/sample-legacy-flow.flow-meta.xml +163 -0
- package/skills/field-service-data-capture-migrate/references/architecture-notes.md +40 -0
- package/skills/field-service-data-capture-migrate/references/component-mapping.md +278 -0
- package/skills/field-service-data-capture-migrate/references/converter-builder-contract.md +183 -0
- package/skills/field-service-data-capture-migrate/references/dc-platform-rules.md +130 -0
- package/skills/field-service-data-capture-migrate/references/fsm-dc-capability-catalog.md +215 -0
- package/skills/field-service-data-capture-migrate/references/known-deploy-errors.md +36 -0
- package/skills/field-service-data-capture-migrate/references/migration-checklist.md +121 -0
- package/skills/field-service-data-capture-migrate/references/transformer-rules.md +132 -0
- package/skills/field-service-data-capture-migrate/scripts/analyze_flow.py +277 -0
- package/skills/field-service-data-capture-migrate/scripts/convert_to_dc_spec.py +1311 -0
- package/skills/field-service-data-capture-migrate/scripts/cud_analysis.py +402 -0
- package/skills/field-service-data-capture-migrate/scripts/deploy_flow.sh +230 -0
- package/skills/field-service-data-capture-migrate/scripts/discover_subflow_tree.py +230 -0
- package/skills/field-service-data-capture-migrate/scripts/flow_input.py +186 -0
- package/skills/field-service-data-capture-migrate/scripts/flow_xml_utils.py +39 -0
- package/skills/field-service-data-capture-migrate/scripts/fsm_dc_catalog.py +413 -0
- package/skills/field-service-data-capture-migrate/scripts/generate_summary.py +199 -0
- package/skills/field-service-data-capture-migrate/scripts/json_to_flow_xml.py +146 -0
- package/skills/field-service-data-capture-migrate/scripts/manual_review_flags.py +42 -0
- package/skills/field-service-data-capture-migrate/scripts/retrieve_flow_rest.sh +155 -0
- package/skills/field-service-data-capture-migrate/scripts/subflow_names.py +49 -0
- package/skills/field-service-data-capture-migrate/scripts/transform_flow.py +1661 -0
- package/skills/field-service-data-capture-migrate/scripts/validate_flow.sh +147 -0
- package/skills/field-service-data-capture-migrate/scripts/vendor/build_flow.py +1258 -0
- package/skills/life-sciences-fieldsalesrep-coordinate/SKILL.md +1 -1
- package/skills/life-sciences-fieldsalesrep-coordinate/references/orchestration-flow.md +1 -1
- package/skills/life-sciences-kam-coordinate/references/stage-5-data-and-plan-templates-overview.md +14 -1
- package/skills/mobile-apps-create/SKILL.md +1 -1
- package/skills/{platform-agentsetup-categories-fetch → platform-agenticsetup-categories-get}/SKILL.md +7 -7
- package/skills/platform-sandbox-configure/SKILL.md +108 -133
- package/skills/platform-sandbox-configure/references/api-response-shapes.md +39 -0
- package/skills/platform-sandbox-configure/references/definition-file-approach.md +73 -0
- package/skills/platform-widget-generate/SKILL.md +26 -18
- package/skills/platform-widget-generate/examples/formulas.json +191 -0
- package/skills/platform-widget-generate/references/widget-formulas.md +123 -0
- package/skills/platform-widget-generate/references/widget-meta-directives.md +4 -4
- package/skills/sales-call-scoring-configure/SKILL.md +318 -0
- package/skills/sales-call-scoring-configure/assets/best-practice-competencies.json +67 -0
- package/skills/sales-call-scoring-configure/assets/ootb-competencies.json +58 -0
- package/skills/sales-call-scoring-configure/references/admin-communication.md +79 -0
- package/skills/sales-call-scoring-configure/references/competency-crud.md +130 -0
- package/skills/sales-call-scoring-configure/scripts/create-competency.sh +166 -0
- package/skills/sales-call-scoring-configure/scripts/create-custom-competency.sh +166 -0
- package/skills/sales-call-scoring-configure/scripts/edit-competency.sh +185 -0
- package/skills/sales-call-scoring-configure/scripts/edit-custom-competency.sh +190 -0
- package/skills/sales-call-scoring-configure/scripts/enable-call-scoring.sh +436 -0
- package/skills/sales-call-scoring-configure/scripts/get-competency.sh +72 -0
- package/skills/sales-call-scoring-configure/scripts/get-custom-competency.sh +78 -0
- package/skills/sales-call-scoring-configure/scripts/install-best-practice-competencies.sh +223 -0
- package/skills/sales-call-scoring-configure/scripts/install-ootb-competencies.sh +237 -0
- package/skills/sales-call-scoring-configure/scripts/list-competencies.sh +99 -0
- package/skills/sales-call-scoring-configure/scripts/shared/auth.sh +116 -0
- package/skills/sales-call-scoring-configure/scripts/shared/cap.sh +86 -0
- package/skills/sales-call-scoring-configure/scripts/shared/soap.sh +267 -0
- package/skills/sales-call-scoring-configure/scripts/shared/soql.sh +25 -0
- package/skills/sales-call-scoring-configure/scripts/toggle-competency.sh +112 -0
- package/skills/sales-call-scoring-configure/scripts/toggle-custom-competency.sh +125 -0
- package/skills/sales-call-scoring-configure/scripts/verify-ootb-competencies.sh +116 -0
- package/skills/service-concierge-portal-generate/references/portal-deploy-runbook.md +1 -0
- package/skills/service-de-channel-activate/SKILL.md +6 -5
- package/skills/service-de-channel-create/SKILL.md +3 -3
- package/skills/service-de-channel-routing-configure/SKILL.md +3 -3
- package/skills/service-de-channel-settings-configure/SKILL.md +195 -0
- package/skills/service-de-channel-settings-configure/references/channel-settings-facets.md +152 -0
- package/skills/{service-de-channel-consent-configure → service-de-channel-settings-configure}/references/gotchas.md +3 -3
- package/skills/{service-de-channel-consent-configure → service-de-channel-settings-configure}/references/language-keywords.md +4 -4
- package/skills/{service-de-channel-consent-configure → service-de-channel-settings-configure}/references/worked-examples.md +14 -14
- package/skills/service-de-headless-channel-configure/SKILL.md +6 -6
- package/skills/service-de-headless-channel-configure/references/worked-examples.md +1 -1
- package/skills/service-helpagent-coordinate/SKILL.md +3 -2
- package/skills/service-helpagent-coordinate/references/channel-voice.md +15 -4
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/SKILL.md +49 -31
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/examples/output-templates.md +71 -6
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/scripts/verify-child-verdict.mjs +26 -4
- package/skills/service-itsm-agentic-setup-cmdb-access-assign/SKILL.md +35 -10
- package/skills/service-itsm-agentic-setup-cmdb-access-assign/references/mcp-invocation.md +25 -5
- package/skills/service-itsm-agentic-setup-cmdb-bundle-deploy/SKILL.md +1 -1
- package/skills/service-itsm-agentic-setup-cmdb-configure/SKILL.md +5 -5
- package/skills/service-itsm-agentic-setup-cmdb-configure/references/mcp-invocation.md +1 -1
- package/skills/service-itsm-agentic-setup-cmdb-coordinate/SKILL.md +2 -2
- package/skills/service-itsm-agentic-setup-cmdb-coordinate/examples/output-templates.md +1 -1
- package/skills/service-itsm-agentic-setup-cmdb-discovery-configure/SKILL.md +25 -15
- package/skills/service-itsm-agentic-setup-employee-agent-configure/SKILL.md +8 -5
- package/skills/service-itsm-agentic-setup-employee-agent-configure/references/report-format.md +4 -2
- package/skills/service-itsm-agentic-setup-employee-agent-configure/references/workflow-detail.md +1 -1
- package/skills/service-itsm-agentic-setup-employee-agent-configure/scripts/render-report.mjs +29 -16
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/SKILL.md +8 -5
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/references/report-format.md +4 -2
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/references/workflow-detail.md +1 -1
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/render-report.mjs +29 -16
- package/skills/service-itsm-agentic-setup-incident-management/SKILL.md +138 -68
- package/skills/service-itsm-agentic-setup-incident-management/examples/output-templates.md +31 -15
- package/skills/service-itsm-agentic-setup-incident-management/references/incident-persona-psg.md +156 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/incident-preferences.md +148 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/major-incident-management.md +162 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/service-mgmt-privilege.md +149 -0
- package/skills/service-itsm-agentic-setup-incident-sla-configure/SKILL.md +103 -89
- package/skills/service-itsm-agentic-setup-incident-sla-configure/assets/attach-milestone.json +2 -2
- package/skills/service-itsm-agentic-setup-incident-sla-configure/assets/predefined-incident-policy.json +21 -39
- package/skills/service-itsm-agentic-setup-incident-sla-configure/examples/milestone-patterns.md +3 -3
- package/skills/service-itsm-agentic-setup-incident-sla-configure/examples/output-templates.md +18 -1
- package/skills/service-itsm-agentic-setup-incident-sla-configure/references/mcp-invocation.md +63 -37
- package/skills/service-omni-attribute-routing-configure/SKILL.md +53 -0
- package/skills/service-omni-attribute-routing-configure/references/api-notes.md +7 -0
- package/skills/service-omni-attribute-routing-configure/scripts/configure-and-report.sh +224 -0
- package/skills/service-omni-attribute-routing-configure/scripts/tests/test_attribute_routing_contracts.py +137 -0
- package/skills/service-omni-channel-inventory-analyze/SKILL.md +96 -0
- package/skills/service-omni-channel-inventory-analyze/references/api-notes.md +52 -0
- package/skills/service-omni-channel-inventory-analyze/scripts/analyze.sh +185 -0
- package/skills/service-omni-channel-inventory-analyze/scripts/tests/_bootstrap.py +84 -0
- package/skills/service-omni-channel-inventory-analyze/scripts/tests/test_inventory_analyze_contracts.py +141 -0
- package/skills/service-omni-channel-limits-analyze/SKILL.md +70 -0
- package/skills/service-omni-channel-limits-analyze/references/api-notes.md +22 -0
- package/skills/service-omni-channel-limits-analyze/scripts/analyze.sh +159 -0
- package/skills/service-omni-channel-limits-analyze/scripts/tests/_bootstrap.py +87 -0
- package/skills/service-omni-channel-limits-analyze/scripts/tests/test_limits_analyze_contracts.py +99 -0
- package/skills/service-omni-channel-setup-coordinate/SKILL.md +12 -20
- package/skills/service-omni-channel-setup-coordinate/scripts/integration-driver.sh +55 -5
- package/skills/service-omni-channel-setup-coordinate/scripts/tests/test_omni_contracts.py +38 -0
- package/skills/service-omni-command-center-analyze/SKILL.md +2 -2
- package/skills/service-omni-command-center-analyze/scripts/analyze.sh +3 -3
- package/skills/dx-org-manage/examples/snapshots/error_output.json +0 -9
- package/skills/dx-org-manage/examples/snapshots/success_output.json +0 -15
- package/skills/dx-org-manage/references/cli_flags.md +0 -67
- package/skills/dx-org-manage/references/creating-snapshot.md +0 -103
- package/skills/dx-org-manage/references/snapshot_usage.md +0 -74
- package/skills/experience-accessibility-validate/scripts/contrast-ratio.py +0 -153
- package/skills/experience-lwc-accessibility-jest-run/references/running-sa11y-jest-tests.md +0 -205
- package/skills/experience-ui-bundle-features-generate/scripts/verify-react-bundle.sh +0 -14
- /package/skills/experience-ui-bundle-features-generate/references/{conflict-resolution-schema.json → common/conflict-resolution-schema.json} +0 -0
- /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()
|