sf-decomposer 6.13.0 → 6.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@
5
5
 
6
6
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
7
7
 
8
+ ## [6.15.0](https://github.com/mcarvin8/sf-decomposer/compare/v6.14.0...v6.15.0) (2026-04-30)
9
+
10
+
11
+ ### Features
12
+
13
+ * support multi-rule multiLevel overrides ([#419](https://github.com/mcarvin8/sf-decomposer/issues/419)) ([5b36f74](https://github.com/mcarvin8/sf-decomposer/commit/5b36f745ab998509b2b7a86a1a6d0a65ebc91a45))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **bot:** round-trip bots with nested botSteps multi-level rule ([#421](https://github.com/mcarvin8/sf-decomposer/issues/421)) ([38e8f9e](https://github.com/mcarvin8/sf-decomposer/commit/38e8f9e944b6ba85d724c479c28f9f3de1e9361a))
19
+
20
+ ## [6.14.0](https://github.com/mcarvin8/sf-decomposer/compare/v6.13.0...v6.14.0) (2026-04-30)
21
+
22
+
23
+ ### Features
24
+
25
+ * add multiLevel override and round-trip verify command ([#418](https://github.com/mcarvin8/sf-decomposer/issues/418)) ([5f06265](https://github.com/mcarvin8/sf-decomposer/commit/5f06265333e9a0523154d0c745e83e0571938f98))
26
+ * **decompose:** expose splitTags as an override field ([#416](https://github.com/mcarvin8/sf-decomposer/issues/416)) ([eedf0f2](https://github.com/mcarvin8/sf-decomposer/commit/eedf0f23d248fdd028a097435aa9493449269954))
27
+
8
28
  ## [6.13.0](https://github.com/mcarvin8/sf-decomposer/compare/v6.12.0...v6.13.0) (2026-04-30)
9
29
 
10
30
 
package/HANDBOOK.md ADDED
@@ -0,0 +1,286 @@
1
+ # Admin Handbook — Decomposing Tricky Salesforce Metadata
2
+
3
+ This handbook collects ready-to-paste `.sfdecomposer.config.json` recipes for the metadata types where Salesforce's native source format either doesn't decompose at all or decomposes too coarsely to diff well in version control. Every recipe in this guide:
4
+
5
+ - decomposes the original file into per-piece units that survive `git diff` cleanly,
6
+ - recomposes back to a **byte-identical** XML deployable to any org,
7
+ - is verified by `sf decomposer verify` before you commit it.
8
+
9
+ If you want the underlying option grammar instead of recipes, see the [main README](./README.md#per-type--per-component-overrides).
10
+
11
+ ## Contents
12
+
13
+ - [Choosing a strategy](#choosing-a-strategy)
14
+ - [Bots (Agentforce and Einstein)](#bots-agentforce-and-einstein)
15
+ - [Flexipages (Lightning App / Record / Home pages)](#flexipages-lightning-app--record--home-pages)
16
+ - [Layouts (page layouts)](#layouts-page-layouts)
17
+ - [Other deeply-nested types](#other-deeply-nested-types)
18
+ - [The verification workflow](#the-verification-workflow)
19
+ - [Common pitfalls](#common-pitfalls)
20
+
21
+ ## Choosing a strategy
22
+
23
+ Three knobs cover almost every case:
24
+
25
+ | Symptom of the source XML | Reach for |
26
+ | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
27
+ | One repeating section at the top level (e.g. `<labels>` of a custom-labels file). | `strategy: unique-id` (default). |
28
+ | Lots of small repeatable tags, you want one file per tag-name, not per-instance. | `strategy: grouped-by-tag`. |
29
+ | `grouped-by-tag`, but a few specific tags (e.g. `objectPermissions`) need finer-grained diff. | Add `splitTags`. |
30
+ | A deeply-nested repeatable block lives _inside_ another repeatable block (the bot pattern). | Add `multiLevel`. If there are several such nested blocks, pass them as an array — see Bots below. |
31
+
32
+ Hard rules the plugin always enforces (so you don't have to):
33
+
34
+ - `labels` and `loyaltyProgramSetup` are always treated as `unique-id` regardless of any override.
35
+ - `loyaltyProgramSetup` automatically gets `multiLevel: programProcesses:programProcesses:parameterName,ruleName` when run under `unique-id` — you can replace it but you don't have to set it.
36
+
37
+ Everything else in this handbook is opt-in.
38
+
39
+ ## Bots (Agentforce and Einstein)
40
+
41
+ **Why this is hard.** Agentforce and Einstein bots both ship as `.bot-meta.xml`, but their internals diverge:
42
+
43
+ - The bot **header** (`Bot.bot-meta.xml`) is small — a few leaves, a few `botUserList` entries.
44
+ - The bot **versions** (`vN.botVersion-meta.xml`) are the painful ones. A single `BotVersion` typically contains:
45
+ - `botDialogGroups` (logical groupings),
46
+ - `botDialogs` (the dialogs themselves), and inside each dialog,
47
+ - `botSteps` (often nested 2–3 levels deep — message, collect, transfer, condition, ...),
48
+ - `mlIntents`, `conversationVariables`, `botStepConditions`, ...
49
+
50
+ A flat `unique-id` decomposition of a `BotVersion` produces one giant per-dialog file with hundreds of nested steps inside. That's only marginally easier to review than the original.
51
+
52
+ **Recipe — two-rule multi-level decomposition.**
53
+
54
+ ```json
55
+ {
56
+ "metadataSuffixes": "bot",
57
+ "strategy": "unique-id",
58
+ "decomposedFormat": "xml",
59
+ "overrides": [
60
+ {
61
+ "metadataTypes": ["bot"],
62
+ "multiLevel": ["botDialogs:botDialogs:developerName", "botSteps:botSteps:type"]
63
+ }
64
+ ]
65
+ }
66
+ ```
67
+
68
+ What this produces on disk for `Sample_Chat_Bot/v1.botVersion-meta.xml` (an Einstein chat bot from the plugin's own fixtures):
69
+
70
+ ```
71
+ bots/
72
+ └── Sample_Chat_Bot/
73
+ ├── Sample_Chat_Bot.bot-meta.xml ← bot header (untouched)
74
+ ├── v1/
75
+ │ ├── nlpProviders/
76
+ │ │ └── EinsteinAi.nlpProviders-meta.xml
77
+ │ ├── botDialogs/ ← outer rule lands here
78
+ │ │ ├── Welcome/ ← one directory per dialog (named by developerName)
79
+ │ │ │ ├── Welcome.xml ← dialog leaf properties
80
+ │ │ │ └── botSteps/ ← inner rule lands here
81
+ │ │ │ ├── 853b6432/ ← step with nested content -> own subdir
82
+ │ │ │ │ ├── 853b6432.xml ← step leaf properties
83
+ │ │ │ │ └── botNavigation/ ← nested step content broken out
84
+ │ │ │ │ └── Redirect.botNavigation-meta.xml
85
+ │ │ │ └── dc35b789/
86
+ │ │ │ ├── dc35b789.xml
87
+ │ │ │ └── botMessages/
88
+ │ │ │ └── dc35b789.botMessages-meta.xml
89
+ │ │ ├── End_Chat/
90
+ │ │ │ ├── End_Chat.xml
91
+ │ │ │ └── botSteps/
92
+ │ │ │ ├── 9d031e75.botSteps-meta.xml ← step with no nested content -> single leaf file
93
+ │ │ │ └── a7afda99/ ← step with nested content -> subdir
94
+ │ │ │ └── ...
95
+ │ │ └── ...
96
+ │ ├── .multi_level.json ← required for recompose; do not hand-edit
97
+ │ └── v1.botVersion-meta.xml ← leaf-only outer wrapper
98
+ └── ...
99
+ ```
100
+
101
+ A few things worth knowing before you commit this:
102
+
103
+ - **Dialog folders are named by developerName**, the way the recipe asks for. So `Welcome`, `End_Chat`, etc. are stable and review-friendly across deploys.
104
+ - **Step folders/files are content-hashed.** The inner rule's `:type` segment tells the disassembler what to look for, but each step's nested content is what determines the on-disk name (`853b6432`, `dc35b789`, ...). The hashes are stable across runs as long as the step content doesn't change, so they diff cleanly. Don't try to "rename them to something nicer" — they'll regenerate on the next decompose.
105
+ - **Each step is one of two shapes**: a leaf `<hash>.botSteps-meta.xml` file when the step has no nested content (e.g. a `Wait` step), or a `<hash>/` directory containing `<hash>.xml` plus subdirectories for the nested content (e.g. a `Message` step with `<botMessages>`, a `Navigation` step with `<botNavigation>`, an Agentforce `Action` step with `<botFlowInvocation>`). Both shapes recompose back to identical `<botSteps>` XML.
106
+ - **The two rules do different things.** The outer `botDialogs` rule is what gives you per-dialog folders — that's the headline win for review-ability. The inner `botSteps` rule additionally splits each step's nested content out of the per-dialog file into per-step subdirectories. For small Einstein bots with shallow steps you can drop the inner rule and still get the dialog-level split; for heavier Agentforce bots the inner rule is the difference between a 50-line per-step subtree and a 500-line per-dialog file.
107
+
108
+ **When to use a single rule instead.** If your bots have shallow steps (most Einstein chat bots, or pre-Agentforce bots), `multiLevel: ["botDialogs:botDialogs:developerName"]` alone is enough. Each dialog gets its own folder and steps live as flat `*.botSteps-meta.xml` files inside. Recompose is byte-identical either way; the choice is purely about how granular you want the per-step diff to be.
109
+
110
+ > **Per-bot precision.** Scope the override to a single component when one bot in your repo has a different shape than the rest. The plugin's own bot fixture suite uses this pattern:
111
+ >
112
+ > ```json
113
+ > {
114
+ > "components": ["bot:Sample_Chat_Bot", "bot:Internal_Copilot_Sample"],
115
+ > "multiLevel": ["botDialogs:botDialogs:developerName", "botSteps:botSteps:type"]
116
+ > }
117
+ > ```
118
+
119
+ > **Agentforce vs Einstein.** Both share the `bot` suffix, so a single override entry covers both bot families. The structural difference (Agentforce uses richer `botFlowInvocation` and `genAi*` blocks where Einstein uses `botNavigation` and `mlIntents`) is invisible to this recipe — `multiLevel` rules only target the repeating sections that exist on each side. The plugin ships an `Internal_Copilot_Sample` fixture that exercises the Agentforce-style shape and an Einstein-style `Sample_Chat_Bot` fixture; both round-trip with the same recipe.
120
+
121
+ > **Sibling order inside `botSteps`.** Recompose orders `<botSteps>` siblings alphabetically by their on-disk filename, not by their original document position. For bot deploys this is a no-op (Salesforce doesn't rely on step order at the XML level), but a freshly-recomposed file may show step entries shuffled compared to the source you originally pulled. The committed fixtures in this repo are baked from the recompose output for that reason — `sf decomposer verify` will treat the baked output as the source of truth.
122
+
123
+ ## Flexipages (Lightning App / Record / Home pages)
124
+
125
+ **Why this is hard.** A non-trivial flexipage looks like:
126
+
127
+ ```xml
128
+ <FlexiPage>
129
+ <flexiPageRegions>
130
+ <itemInstances>
131
+ <componentInstance>
132
+ <componentInstanceProperties>...</componentInstanceProperties>
133
+ <componentName>...</componentName>
134
+ </componentInstance>
135
+ </itemInstances>
136
+ <itemInstances>...</itemInstances>
137
+ ...
138
+ </flexiPageRegions>
139
+ <flexiPageRegions>...</flexiPageRegions>
140
+ </FlexiPage>
141
+ ```
142
+
143
+ Two repeating layers (`flexiPageRegions → itemInstances`) and component identity is buried inside `componentInstance` — the surrounding wrappers don't have a stable name, so a naive `unique-id` pass produces hash-named files that churn whenever a component is reordered.
144
+
145
+ **Recipe — flexiPageRegions out, then itemInstances per region.**
146
+
147
+ ```json
148
+ {
149
+ "overrides": [
150
+ {
151
+ "metadataTypes": ["flexipage"],
152
+ "strategy": "unique-id",
153
+ "multiLevel": ["flexiPageRegions:flexiPageRegions:name", "itemInstances:itemInstances:componentName,facetId"]
154
+ }
155
+ ]
156
+ }
157
+ ```
158
+
159
+ What you'll see on disk:
160
+
161
+ ```
162
+ flexipages/
163
+ └── Account_Record_Page/
164
+ ├── flexiPageRegions/
165
+ │ ├── header.flexiPageRegions-meta.xml
166
+ │ ├── main.flexiPageRegions-meta.xml
167
+ │ └── main/
168
+ │ └── itemInstances/
169
+ │ ├── force_highlightsPanel.itemInstances-meta.xml
170
+ │ ├── runtime_sales_activities__activitiesComponent.itemInstances-meta.xml
171
+ │ └── ...
172
+ └── Account_Record_Page.flexipage-meta.xml
173
+ ```
174
+
175
+ **When to adjust.**
176
+
177
+ - Flexipages where regions are addressed by `regionId` instead of `name` — swap the first rule to `flexiPageRegions:flexiPageRegions:regionId,name`.
178
+ - If the same `componentName` appears multiple times in one region (common for blank `force:emptySpace`), include `facetId` (already in the recipe) and, if needed, a stable property: `itemInstances:itemInstances:componentName,facetId,componentInstanceProperties.value`.
179
+
180
+ ## Layouts (page layouts)
181
+
182
+ **Why this is hard.** Layouts have three nested repeatables:
183
+
184
+ ```xml
185
+ <Layout>
186
+ <layoutSections>
187
+ <layoutColumns>
188
+ <layoutItems>
189
+ <field>...</field>
190
+ </layoutItems>
191
+ </layoutColumns>
192
+ </layoutSections>
193
+ </Layout>
194
+ ```
195
+
196
+ For a fat object (Account, Opportunity), this often runs to thousands of lines. Reviewing a "moved one field from column 1 to column 2" change in the raw XML is painful.
197
+
198
+ **Recipe — section out, item per field.**
199
+
200
+ ```json
201
+ {
202
+ "overrides": [
203
+ {
204
+ "metadataTypes": ["layout"],
205
+ "strategy": "unique-id",
206
+ "multiLevel": ["layoutSections:layoutSections:label", "layoutItems:layoutItems:field,customLink,emptySpace"]
207
+ }
208
+ ]
209
+ }
210
+ ```
211
+
212
+ What you'll see on disk:
213
+
214
+ ```
215
+ layouts/
216
+ └── Account-Account_Layout/
217
+ ├── layoutSections/
218
+ │ ├── Account_Information.layoutSections-meta.xml
219
+ │ ├── Address_Information.layoutSections-meta.xml
220
+ │ └── Account_Information/
221
+ │ └── layoutItems/
222
+ │ ├── Name.layoutItems-meta.xml
223
+ │ ├── Type.layoutItems-meta.xml
224
+ │ └── ...
225
+ └── Account-Account_Layout.layout-meta.xml
226
+ ```
227
+
228
+ **Caveats.**
229
+
230
+ - Empty-space layout items (`<emptySpace>true</emptySpace>` with no `field`) all collapse to the same key. The recipe above falls through to `customLink` and then `emptySpace`, but if you have many empty-space spacers per section you'll get hash-named tiebreakers. That's fine for diffs (they only churn when the layout changes), just be aware.
231
+ - Sections with duplicate labels are unusual but legal (e.g. two "Custom Links" sections). If you hit collisions, add a stable secondary like `style`: `layoutSections:layoutSections:label,style`.
232
+
233
+ ## Other deeply-nested types
234
+
235
+ These follow the same pattern; pick the rules that match your repo's data. None of these are decomposed natively by Salesforce.
236
+
237
+ | Metadata type | Suggested override |
238
+ | ------------------------------------------ | -------------------------------------------------------------------------------------------------- |
239
+ | `flow` | `multiLevel: ["actionCalls:actionCalls:name", "decisions:decisions:name", "rules:rules:name"]` |
240
+ | `globalValueSet` | `multiLevel: ["customValue:customValue:fullName"]` — handy when value sets have hundreds of picks. |
241
+ | `marketingappextension` | `multiLevel: ["activityDefinitions:activityDefinitions:apiName"]` |
242
+ | `cmsDeliveryChannel` (and other CMS types) | `strategy: grouped-by-tag` plus `splitTags` for any wide repeatable tag. |
243
+ | `dashboard` | `multiLevel: ["components:components:title"]` — one file per dashboard widget. |
244
+
245
+ If a metadata type has a single deeply-nested repeatable block, a one-rule `multiLevel` is enough. Reach for the array form only when you have **two or more** distinct nested sections you want addressable on disk.
246
+
247
+ ## The verification workflow
248
+
249
+ Always verify a new override before committing it:
250
+
251
+ ```bash
252
+ # 1. Stash any uncommitted source first.
253
+ git stash --include-untracked
254
+
255
+ # 2. Decompose with the new override.
256
+ sf decomposer decompose -t bot --config
257
+
258
+ # 3. Recompose back from the decomposed tree.
259
+ sf decomposer recompose -t bot
260
+
261
+ # 4. Check the round-trip didn't drift.
262
+ sf decomposer verify -t bot --config
263
+ ```
264
+
265
+ `sf decomposer verify` is non-destructive: it decomposes into a temp dir, recomposes from the temp dir, and compares the result to your committed source. If anything drifts (content, missing file, sibling reorder) it tells you exactly which paths broke. Treat any drift as a blocker — fix the override (or fall back to the previous one) before committing.
266
+
267
+ ## Common pitfalls
268
+
269
+ **1. "I added two `multiLevel` rules but only one survived."**
270
+ You probably ran two `decompose` invocations back-to-back, one rule each. Don't. The disassembler rewrites `.multi_level.json` on every run, so each call replaces the prior one. Pass every rule for a given component in **one** override entry, in array form.
271
+
272
+ **2. "My `multiLevel` rule is correct but recompose produces a smaller file."**
273
+ Almost always a unique-id collision. Two array items resolved to the same filename and one overwrote the other on disk. Add a tiebreaker to `unique_id_elements` (e.g. `developerName,id`) and re-decompose with `prePurge: true`.
274
+
275
+ **3. "Component-scope override fields look ignored."**
276
+ Component-scope wins over type-scope, but only for fields **the component override explicitly sets**. Fields it leaves out fall through to the type-scope value, then to the run-wide default. If you set `decomposedFormat: "yaml"` on a type and `strategy: "grouped-by-tag"` on the component, the component still gets `decomposedFormat: "yaml"` from the type override.
277
+
278
+ **4. "Reassembly removed my decomposed directory even though I didn't pass `postPurge`."**
279
+ That's by design for `multiLevel` types only. Multi-level recompose has to clean up inner-level directories so the next level can merge their reassembled XML. If you want the decomposed tree preserved for inspection, copy it before running `recompose`.
280
+
281
+ **5. "Decompose succeeded but my decomposed files all have hash names."**
282
+ Your `unique_id_elements` (or the rule's third part) didn't resolve to a non-empty value on those items. Check the source XML for the elements you listed — names are case-sensitive and live at the immediate child level of each repeating item. The plugin only walks one level deep when picking a UID.
283
+
284
+ ---
285
+
286
+ When in doubt: write the override, run `verify`, read the diff. Every recipe in this handbook was derived that way.
package/README.md CHANGED
@@ -17,7 +17,8 @@ A Salesforce CLI plugin that **decomposes** large metadata XML files into smalle
17
17
  - [Commands](#commands)
18
18
  - [sf decomposer decompose](#sf-decomposer-decompose)
19
19
  - [sf decomposer recompose](#sf-decomposer-recompose)
20
- - [Manifest-scoped runs](#manifest-scoped-runs)
20
+ - [sf decomposer verify](#sf-decomposer-verify)
21
+ - [Manifest-scoped runs](#manifest-scoped-runs)
21
22
  - [Decompose Strategies](#decompose-strategies)
22
23
  - [Custom Labels](#custom-labels-decomposition)
23
24
  - [Permission Sets (grouped-by-tag)](#additional-permission-set-decomposition)
@@ -27,6 +28,8 @@ A Salesforce CLI plugin that **decomposes** large metadata XML files into smalle
27
28
  - [Troubleshooting](#troubleshooting)
28
29
  - [Hooks](#hooks)
29
30
  - [Per-Type & Per-Component Overrides](#per-type--per-component-overrides)
31
+ - [splitTags grammar](#splittags-grammar)
32
+ - [multiLevel grammar](#multilevel-grammar)
30
33
  - [Ignore Files](#ignore-files)
31
34
  - [.forceignore](#forceignore)
32
35
  - [.sfdecomposerignore](#sfdecomposerignore)
@@ -113,10 +116,11 @@ Salesforce’s built-in decomposition is limited. sf-decomposer gives admins and
113
116
 
114
117
  ## Commands
115
118
 
116
- | Command | Description |
117
- | ------------------------- | --------------------------------------------------------------- |
118
- | `sf decomposer decompose` | Decompose metadata in package directories into smaller files. |
119
- | `sf decomposer recompose` | Recompose decomposed files back into deployment-ready metadata. |
119
+ | Command | Description |
120
+ | ------------------------- | ----------------------------------------------------------------------------------- |
121
+ | `sf decomposer decompose` | Decompose metadata in package directories into smaller files. |
122
+ | `sf decomposer recompose` | Recompose decomposed files back into deployment-ready metadata. |
123
+ | `sf decomposer verify` | Round-trip check: decompose + recompose in a temp directory and diff the originals. |
120
124
 
121
125
  ### sf decomposer decompose
122
126
 
@@ -193,9 +197,49 @@ sf decomposer recompose -x "manifest/package.xml"
193
197
  sf project deploy start -x "manifest/package.xml"
194
198
  ```
195
199
 
196
- ### Manifest-scoped runs
200
+ ### sf decomposer verify
197
201
 
198
- The `-x` / `--manifest` flag accepts any standard Salesforce `package.xml` and limits the work to just the components it lists. This is especially useful for CI/CD pipelines that deploy a subset of metadata per change.
202
+ Non-destructive round-trip check: copies your package directories into a temp directory under your OS's `tmpdir()`, runs decompose then recompose there, and diffs the rebuilt parents against the originals using **structural XML equality** (sibling and attribute order are ignored). Exits non-zero on any drift; your working tree is never modified.
203
+
204
+ ```
205
+ USAGE
206
+ $ sf decomposer verify [-m <value>] [-x <value>] [-f <value>] [-i <value>] [-s <value>] [-p -c --json]
207
+
208
+ FLAGS
209
+ -m, --metadata-type=<value> Metadata suffix to verify (e.g. flow, labels). Repeatable. Optional when --manifest is provided.
210
+ -x, --manifest=<value> Path to a package.xml manifest. When provided, only the components listed in the manifest are verified.
211
+ -f, --format=<value> Output format used for the round-trip decompose: xml | yaml | json | json5 [default: xml]
212
+ -i, --ignore-package-directory=<value> Package directory to skip. Repeatable.
213
+ -s, --strategy=<value> unique-id | grouped-by-tag [default: unique-id]
214
+ -p, --decompose-nested-permissions With grouped-by-tag, further decompose permission set and muting permission set object/field permissions.
215
+ -c, --config Load per-type and per-component overrides from .sfdecomposer.config.json in the repo root, the same as `decompose --config`. [default: false]
216
+
217
+ GLOBAL FLAGS
218
+ --json Output as JSON.
219
+ ```
220
+
221
+ > At least one of `--metadata-type` or `--manifest` is required. When both are supplied, the run is scoped to the intersection of the two.
222
+
223
+ **Examples**
224
+
225
+ ```bash
226
+ # Verify two metadata types round-trip cleanly with defaults
227
+ sf decomposer verify -m "permissionset" -m "profile"
228
+
229
+ # Verify a different strategy + nested-perms split before committing the change
230
+ sf decomposer verify -m "permissionset" -s "grouped-by-tag" -p
231
+
232
+ # CI gate: verify just the components in a deploy manifest, using the repo-root config
233
+ sf decomposer verify -x "manifest/package.xml" --config
234
+ ```
235
+
236
+ Files where the **only** delta is sibling or attribute ordering are surfaced separately as informational notices ("Note: N file(s) round-tripped semantically but with sibling/attribute reordering") rather than as drift. This is safe — Salesforce treats metadata as order-agnostic and `config-disassembler` does not preserve original sibling order — but it tells you up front that committing the post-recompose output will produce a diff in git even though the metadata is functionally identical.
237
+
238
+ ---
239
+
240
+ ## Manifest-scoped runs
241
+
242
+ The `-x` / `--manifest` flag is supported by every `sf decomposer` command (`decompose`, `recompose`, `verify`) and accepts any standard Salesforce `package.xml`, limiting the work to just the components it lists. This is especially useful for CI/CD pipelines that deploy a subset of metadata per change.
199
243
 
200
244
  How it works:
201
245
 
@@ -407,15 +451,17 @@ By default, a single decompose run uses one format and one strategy across every
407
451
 
408
452
  ### What can be overridden
409
453
 
410
- | Field | Notes |
411
- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
412
- | `metadataTypes` | Optional (required if `components` is omitted). Array of metadata suffixes (same vocabulary as `--metadata-type` / `metadataSuffixes`). Each suffix may appear in at most one override. |
413
- | `components` | Optional (required if `metadataTypes` is omitted). Array of `<metadataSuffix>:<fullName>` keys (e.g. `permissionset:HR_Admin`, `report:MyFolder/MyReport`). Each component may appear in at most one override. |
414
- | `decomposedFormat` | `xml` \| `json` \| `json5` \| `yaml`. |
415
- | `strategy` | `unique-id` \| `grouped-by-tag`. Hard rules still win — `labels` and `loyaltyProgramSetup` are always treated as `unique-id`. |
416
- | `decomposeNestedPermissions` | Only applies to `permissionset` / `mutingpermissionset` with `grouped-by-tag`. |
417
- | `prePurge` | Per-scope prePurge (decompose). Component-scope `prePurge` only purges the named component's decomposed directory. |
418
- | `postPurge` | Per-scope postPurge (decompose: remove originals after decomposing). |
454
+ | Field | Notes |
455
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
456
+ | `metadataTypes` | Optional (required if `components` is omitted). Array of metadata suffixes (same vocabulary as `--metadata-type` / `metadataSuffixes`). Each suffix may appear in at most one override. |
457
+ | `components` | Optional (required if `metadataTypes` is omitted). Array of `<metadataSuffix>:<fullName>` keys (e.g. `permissionset:HR_Admin`, `report:MyFolder/MyReport`). Each component may appear in at most one override. |
458
+ | `decomposedFormat` | `xml` \| `json` \| `json5` \| `yaml`. |
459
+ | `strategy` | `unique-id` \| `grouped-by-tag`. Hard rules still win — `labels` and `loyaltyProgramSetup` are always treated as `unique-id`. |
460
+ | `decomposeNestedPermissions` | Only applies to `permissionset` / `mutingpermissionset` with `grouped-by-tag`. Sets a known-good `splitTags` default; ignored if `splitTags` is also set in the same scope. |
461
+ | `splitTags` | Custom `splitTags` spec for `grouped-by-tag` strategy. See [splitTags grammar](#splittags-grammar). Ignored when the resolved strategy is not `grouped-by-tag`. |
462
+ | `multiLevel` | One or more `multiLevel` specs for nested-array decomposition. Pass a string, a `string[]`, or a `;`-separated string. See [multiLevel grammar](#multilevel-grammar). When set, replaces the hardcoded `loyaltyProgramSetup` default for the targeted scope. |
463
+ | `prePurge` | Per-scope prePurge (decompose). Component-scope `prePurge` only purges the named component's decomposed directory. |
464
+ | `postPurge` | Per-scope postPurge (decompose: remove originals after decomposing). |
419
465
 
420
466
  Run-scope options (`metadataSuffixes`, `manifest`, `ignorePackageDirectories`) are **not** valid inside an override; the plugin will throw if they are present.
421
467
 
@@ -439,6 +485,95 @@ For each component, each option is resolved independently in this order (highest
439
485
  3. The run-wide value (CLI flag, hook config top-level field, or built-in default).
440
486
  4. Hard plugin rules (e.g. `labels` and `loyaltyProgramSetup` forced to `unique-id`) override all of the above.
441
487
 
488
+ ### splitTags grammar
489
+
490
+ `splitTags` lets you control how `grouped-by-tag` writes nested arrays for any metadata type. The plugin already applies a known-good default for permission sets when `decomposeNestedPermissions: true` is set; setting `splitTags` directly takes precedence and works for any metadata type.
491
+
492
+ **Spec:** Comma-separated rules. Each rule has 3 or 4 colon-separated parts:
493
+
494
+ - `<tag>:<mode>:<field>` — read array items from the top-level `<tag>`.
495
+ - `<tag>:<path>:<mode>:<field>` — read array items from the nested `<path>` (defaults to `<tag>`).
496
+
497
+ `<mode>` is one of:
498
+
499
+ - **`split`** — write one file per array item, named after the value of `<field>` on each item.
500
+ - **`group`** — group array items by the value of `<field>`, writing one file per group.
501
+
502
+ Each `<tag>` may appear at most once in a spec. The plugin validates the grammar at config-load time. Deeper checks (e.g. unknown tag names for the metadata type) are surfaced by the underlying disassembler crate at runtime.
503
+
504
+ #### splitTags cookbook
505
+
506
+ ```json
507
+ "overrides": [
508
+ {
509
+ "metadataTypes": ["permissionset", "mutingpermissionset"],
510
+ "strategy": "grouped-by-tag",
511
+ "splitTags": "objectPermissions:split:object,fieldPermissions:group:field"
512
+ },
513
+ {
514
+ "metadataTypes": ["profile"],
515
+ "strategy": "grouped-by-tag",
516
+ "splitTags": "objectPermissions:split:object,fieldPermissions:group:field,layoutAssignments:group:layout"
517
+ },
518
+ {
519
+ "metadataTypes": ["flow"],
520
+ "strategy": "grouped-by-tag",
521
+ "splitTags": "actionCalls:split:name,decisions:split:name,assignments:split:name"
522
+ },
523
+ {
524
+ "metadataTypes": ["workflow"],
525
+ "strategy": "grouped-by-tag",
526
+ "splitTags": "rules:split:fullName,alerts:split:fullName,fieldUpdates:split:fullName,tasks:split:fullName"
527
+ }
528
+ ]
529
+ ```
530
+
531
+ > **Caveat:** When using `mode: split`, the chosen `<field>` must produce a unique value for every array item — otherwise two items would map to the same filename. If two items share a field value, prefer `mode: group` instead, which is designed for that case.
532
+
533
+ ### multiLevel grammar
534
+
535
+ `multiLevel` enables a second decomposition pass on inner-level files for metadata types whose XML has deeply nested repeatable blocks (e.g. `loyaltyProgramSetup`'s `programProcesses → parameters → ...`, or a Bot's `botVersion → botDialogs → botSteps`). The plugin already applies a known-good default for `loyaltyProgramSetup` when running the `unique-id` strategy; setting `multiLevel` directly takes precedence and works for any metadata type.
536
+
537
+ **Spec:** Each rule has exactly 3 colon-separated parts (the third part is itself a comma-separated list):
538
+
539
+ ```
540
+ <file_pattern>:<root_to_strip>:<unique_id_elements>
541
+ ```
542
+
543
+ - **`<file_pattern>`** — basename pattern that selects which inner-level files get the second decomposition pass (e.g. `programProcesses`).
544
+ - **`<root_to_strip>`** — XML root tag to strip from each matched file before splitting.
545
+ - **`<unique_id_elements>`** — comma-separated list of element names used to derive a stable filename for each inner-level item (e.g. `parameterName,ruleName`). The first element that resolves to a non-empty value wins.
546
+
547
+ A scope may target several nested sections by passing **multiple rules**. Three input shapes are supported:
548
+
549
+ - a single rule string (legacy, unchanged behaviour);
550
+ - a JSON `string[]` of rules (preferred — clearest intent, easiest to diff);
551
+ - a single `;`-separated string of rules (compact form, also accepted).
552
+
553
+ Within one scope, the `(file_pattern, root_to_strip)` pair must be unique across rules. The plugin validates the grammar at config-load time; deeper checks (whether a file pattern matches anything, whether the unique-id elements actually appear on the inner XML) are surfaced by the underlying disassembler crate at runtime.
554
+
555
+ ```json
556
+ "overrides": [
557
+ {
558
+ "metadataTypes": ["loyaltyProgramSetup"],
559
+ "multiLevel": "programProcesses:programProcesses:parameterName,ruleName"
560
+ },
561
+ {
562
+ "components": ["bot:Assessment_Bot"],
563
+ "multiLevel": [
564
+ "botDialogs:botDialogs:developerName",
565
+ "botSteps:botSteps:type"
566
+ ]
567
+ }
568
+ ]
569
+ ```
570
+
571
+ > **Why one call:** Pass every rule for a given component in a single override. Sequential single-rule decompositions rewrite the on-disk `.multi_level.json` and only the last rule survives — so multi-rule scenarios must travel together.
572
+
573
+ > **Tip:** Use [`sf decomposer verify`](#sf-decomposer-verify) to non-destructively confirm a new override config still round-trips before committing it.
574
+
575
+ > **Tip:** See the [admin handbook](https://github.com/mcarvin8/sf-decomposer/blob/main/HANDBOOK.md) for end-to-end recipes for Bots, Flexipages, Layouts, and other deeply-nested metadata.
576
+
442
577
  ### Opting in from the CLI
443
578
 
444
579
  CLI users can opt into overrides on `decompose` with the boolean `--config` (`-c`) flag. When set, the plugin reads `.sfdecomposer.config.json` from the repo root (the nearest ancestor directory that contains `sfdx-project.json`):
@@ -0,0 +1,17 @@
1
+ import { SfCommand } from '@salesforce/sf-plugins-core';
2
+ import { VerifyResult } from '../../helpers/types.js';
3
+ export default class DecomposerVerify extends SfCommand<VerifyResult> {
4
+ static readonly summary: string;
5
+ static readonly description: string;
6
+ static readonly examples: string[];
7
+ static readonly flags: {
8
+ 'metadata-type': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ manifest: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ format: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ 'ignore-package-directory': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ strategy: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
+ 'decompose-nested-permissions': import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ config: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ };
16
+ run(): Promise<VerifyResult>;
17
+ }
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+ import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
3
+ import { Messages } from '@salesforce/core';
4
+ import { DECOMPOSED_FILE_TYPES, DECOMPOSED_STRATEGIES } from '../../helpers/constants.js';
5
+ import { verifyMetadataTypes } from '../../core/verifyMetadataTypes.js';
6
+ import { loadOverridesFromConfig, resolveDefaultConfigPath } from '../../helpers/configOverrides.js';
7
+ Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
8
+ const messages = Messages.loadMessages('sf-decomposer', 'decomposer.verify');
9
+ export default class DecomposerVerify extends SfCommand {
10
+ static summary = messages.getMessage('summary');
11
+ static description = messages.getMessage('description');
12
+ static examples = messages.getMessages('examples');
13
+ static flags = {
14
+ 'metadata-type': Flags.string({
15
+ summary: messages.getMessage('flags.metadata-type.summary'),
16
+ char: 'm',
17
+ multiple: true,
18
+ required: false,
19
+ }),
20
+ manifest: Flags.file({
21
+ summary: messages.getMessage('flags.manifest.summary'),
22
+ char: 'x',
23
+ required: false,
24
+ exists: true,
25
+ }),
26
+ format: Flags.string({
27
+ summary: messages.getMessage('flags.format.summary'),
28
+ char: 'f',
29
+ required: true,
30
+ multiple: false,
31
+ default: 'xml',
32
+ options: DECOMPOSED_FILE_TYPES,
33
+ }),
34
+ 'ignore-package-directory': Flags.directory({
35
+ summary: messages.getMessage('flags.ignore-package-directory.summary'),
36
+ char: 'i',
37
+ required: false,
38
+ multiple: true,
39
+ }),
40
+ strategy: Flags.string({
41
+ summary: messages.getMessage('flags.strategy.summary'),
42
+ char: 's',
43
+ required: true,
44
+ multiple: false,
45
+ default: 'unique-id',
46
+ options: DECOMPOSED_STRATEGIES,
47
+ }),
48
+ 'decompose-nested-permissions': Flags.boolean({
49
+ summary: messages.getMessage('flags.decompose-nested-permissions.summary'),
50
+ char: 'p',
51
+ required: false,
52
+ default: false,
53
+ }),
54
+ config: Flags.boolean({
55
+ summary: messages.getMessage('flags.config.summary'),
56
+ char: 'c',
57
+ required: false,
58
+ default: false,
59
+ }),
60
+ };
61
+ async run() {
62
+ const { flags } = await this.parse(DecomposerVerify);
63
+ if (!flags['metadata-type'] && !flags['manifest']) {
64
+ throw messages.createError('error.missingMetadataOrManifest');
65
+ }
66
+ const overrides = flags['config'] ? await loadOverridesFromConfig(await resolveDefaultConfigPath()) : undefined;
67
+ const result = await verifyMetadataTypes({
68
+ metadataTypes: flags['metadata-type'],
69
+ format: flags['format'],
70
+ ignoreDirs: flags['ignore-package-directory'],
71
+ strategy: flags['strategy'],
72
+ decomposeNestedPerms: flags['decompose-nested-permissions'],
73
+ manifest: flags['manifest'],
74
+ overrides,
75
+ log: this.log.bind(this),
76
+ });
77
+ if (result.drift.length > 0) {
78
+ throw messages.createError('error.driftDetected', [String(result.drift.length)]);
79
+ }
80
+ return result;
81
+ }
82
+ }
83
+ //# sourceMappingURL=verify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify.js","sourceRoot":"","sources":["../../../src/commands/decomposer/verify.ts"],"names":[],"mappings":"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"}