sf-decomposer
Version:
Split large Salesforce metadata files into version-control-friendly pieces and rebuild deployment-ready files.
732 lines (534 loc) • 46.3 kB
Markdown
# sf-decomposer
[](https://www.npmjs.com/package/sf-decomposer)
[](https://npmjs.org/package/sf-decomposer)
[](https://raw.githubusercontent.com/mcarvin8/sf-decomposer/main/LICENSE.md)
[](https://qlty.sh/gh/mcarvin8/projects/sf-decomposer)
[](https://codecov.io/gh/mcarvin8/sf-decomposer)
[](https://dashboard.stryker-mutator.io/reports/github.com/mcarvin8/sf-decomposer/main)
[](https://mcarvin8.github.io/sf-decomposer/dev/bench/runtime/)
A Salesforce CLI plugin that **decomposes** large metadata XML files into smaller, version-control–friendly files (XML, JSON, YAML, JSON5), and **recomposes** them back into deployment-ready metadata.
<!-- TABLE OF CONTENTS -->
<details>
<summary>Table of Contents</summary>
- [Setup](#setup)
- [1. Requirements](#1-requirements)
- [2. Install the Plugin](#2-install-the-plugin)
- [3. Configure .forceignore](#3-configure-forceignore)
- [4. Configure Hooks](#4-configure-hooks-recommended)
- [Daily Workflow](#daily-workflow)
- [Reference](#reference)
- [Commands](#commands)
- [Decompose Strategies](#decompose-strategies)
- [Supported Metadata](#supported-metadata)
- [Manifest-scoped Runs](#manifest-scoped-runs)
- [Per-Type & Per-Component Overrides](#per-type--per-component-overrides)
- [Ignore Files](#ignore-files)
- [Troubleshooting](#troubleshooting)
- [Built With](#built-with)
- [Migrating from Salesforce Native Decomposition](#migrating-from-salesforce-native-decomposition)
- [Contributing](#contributing)
- [License](#license)
</details>
---
## Setup
Complete these steps once per project. After setup, see [Daily Workflow](#daily-workflow).
### 1. Requirements
- [Salesforce CLI](https://developer.salesforce.com/tools/sfdxcli) (`sf`) installed
- Node.js 20.x or later
- A Salesforce DX project with `sfdx-project.json` and package directories
**Supported Platforms**
sf-decomposer depends on [config-disassembler-node](https://github.com/mcarvin8/config-disassembler-node), which ships prebuilt native binaries as platform-specific optional npm packages — your package manager installs only the one matching your `os` / `cpu` / `libc`:
| Platform | Architectures |
| ----------- | ------------------------------------ |
| **macOS** | x64 (Intel), arm64 (Apple Silicon) |
| **Linux** | x64 (gnu + musl), arm64 (gnu + musl) |
| **Windows** | x64, arm64, ia32 |
If your platform or architecture is not listed, open an [issue](https://github.com/mcarvin8/sf-decomposer/issues).
### 2. Install the Plugin
```bash
sf plugins install sf-decomposer@x.y.z
```
### 3. Configure .forceignore
**Required.** The Salesforce CLI must ignore decomposed files or `sf` commands will fail. Configure this before running any decompose or retrieve commands.
Copy the [sample .forceignore](https://raw.githubusercontent.com/mcarvin8/sf-decomposer/main/examples/.forceignore) into your project root and adjust the extension patterns for your chosen format (`.xml`, `.json`, `.yaml`, etc.).
### 4. Configure Hooks (Recommended)
Hooks auto-decompose after `sf project retrieve start` and auto-recompose before `sf project deploy start` / `validate` — eliminating manual steps entirely.
Add `.sfdecomposer.config.json` to your project root. Copy and customize one of the sample configs:
- [Basic sample](https://raw.githubusercontent.com/mcarvin8/sf-decomposer/main/examples/.sfdecomposer.config.json) — one format and strategy for all types
- [Sample with overrides](https://raw.githubusercontent.com/mcarvin8/sf-decomposer/main/examples/.sfdecomposer.config.overrides.json) — vary format/strategy per metadata type or component
| Option | Required | Description |
| ---------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `metadataSuffixes` | Conditional | Comma-separated metadata suffixes to decompose/recompose. Required unless `manifest` is set; when both are set, run is scoped to the intersection. |
| `manifest` | Conditional | Path (relative to project root) to a `package.xml` manifest. When set, only listed components are decomposed/recomposed. |
| `ignorePackageDirectories` | No | Comma-separated package directories to skip. |
| `prePurge` | No | Remove existing decomposed files before decomposing (default: false). |
| `postPurge` | No | After decompose: remove originals; after recompose: remove decomposed files (default: false). |
| `decomposedFormat` | No | `xml`, `json`, `json5`, or `yaml` (default: xml). |
| `strategy` | No | `unique-id` \| `grouped-by-tag` (default: unique-id). |
| `decomposeNestedPermissions` | No | With `grouped-by-tag`, set `true` to further decompose permission set and muting permission set object/field permissions. |
| `overrides` | No | Array of per-type and/or per-component overrides. See [Per-Type & Per-Component Overrides](#per-type--per-component-overrides). |
---
## Daily Workflow
**With hooks configured** (recommended):
```
retrieve → auto-decomposes → review & commit → deploy → auto-recomposes
```
```bash
sf project retrieve start # hooks decompose automatically
git add . && git commit -m "..." # commit decomposed files
sf project deploy start # hooks recompose automatically
```
**Without hooks** (manual):
```bash
# After retrieve: decompose
sf decomposer decompose -m "flow" -m "labels" --postpurge
# Before deploy: recompose, then deploy
sf decomposer recompose -m "flow" -m "labels"
sf project deploy start
```
Pass `-x manifest/package.xml` to both `decompose` and `recompose` (and `deploy`) to scope a run to just the components in a deploy manifest.
---
## Reference
### Commands
| Command | Description |
| ------------------------- | ----------------------------------------------------------------------------------- |
| `sf decomposer decompose` | Decompose metadata in package directories into smaller files. |
| `sf decomposer recompose` | Recompose decomposed files back into deployment-ready metadata. |
| `sf decomposer verify` | Round-trip check: decompose + recompose in a temp directory and diff the originals. |
#### sf decomposer decompose
Decomposes metadata in all local package directories (from `sfdx-project.json`) into smaller files.
```
USAGE
$ sf decomposer decompose [-m <value>] [-x <value>] [-f <value>] [-i <value>] [-s <value>] [--prepurge --postpurge -p -c --json]
FLAGS
-m, --metadata-type=<value> Metadata suffix to process (e.g. flow, labels). Repeatable. Optional when --manifest is provided.
-x, --manifest=<value> Path to a package.xml manifest. When provided, only the components listed in the manifest are decomposed.
-f, --format=<value> Output format: xml | yaml | json | json5 [default: xml]
-i, --ignore-package-directory=<value> Package directory to skip (as in sfdx-project.json). Repeatable.
-s, --strategy=<value> unique-id | grouped-by-tag [default: unique-id]
--prepurge Remove existing decomposed files before decomposing [default: false]
--postpurge Remove original metadata files after decomposing [default: false]
-p, --decompose-nested-permissions With grouped-by-tag, further decompose permission set and muting permission set object/field permissions
-c, --config Load per-type and per-component overrides from .sfdecomposer.config.json in the repo root. Only the "overrides" array is consumed. [default: false]
GLOBAL FLAGS
--json Output as JSON.
```
> At least one of `--metadata-type` or `--manifest` is required. When both are supplied, the run is scoped to their intersection.
**Examples**
```bash
# Decompose flows (XML), purge before/after
sf decomposer decompose -m "flow" -f "xml" --prepurge --postpurge
# Decompose flows and labels in YAML
sf decomposer decompose -m "flow" -m "labels" -f "yaml" --prepurge --postpurge
# Decompose flows, excluding the force-app package
sf decomposer decompose -m "flow" -i "force-app"
# Decompose only the components listed in a manifest
sf decomposer decompose -x "manifest/package.xml" --prepurge
# Restrict a manifest run to a single metadata type
sf decomposer decompose -x "manifest/package.xml" -m "permissionset"
```
#### sf decomposer recompose
Recomposes decomposed files into deployment-compatible metadata.
```
USAGE
$ sf decomposer recompose [-m <value>] [-x <value>] [-i <value>] [--postpurge --json]
FLAGS
-m, --metadata-type=<value> Metadata suffix to process (e.g. flow, labels). Repeatable. Optional when --manifest is provided.
-x, --manifest=<value> Path to a package.xml manifest. When provided, only the components listed in the manifest are recomposed.
-i, --ignore-package-directory=<value> Package directory to skip. Repeatable.
--postpurge Remove decomposed files after recomposing [default: false]
GLOBAL FLAGS
--json Output as JSON.
```
> At least one of `--metadata-type` or `--manifest` is required. When both are supplied, the run is scoped to their intersection.
**Examples**
```bash
sf decomposer recompose -m "flow" --postpurge
sf decomposer recompose -m "flow" -i "force-app"
# Recompose only the components listed in a deploy manifest before deploying
sf decomposer recompose -x "manifest/package.xml"
sf project deploy start -x "manifest/package.xml"
```
#### sf decomposer verify
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.
```
USAGE
$ sf decomposer verify [-m <value>] [-x <value>] [-f <value>] [-i <value>] [-s <value>] [-p -c --json]
FLAGS
-m, --metadata-type=<value> Metadata suffix to verify (e.g. flow, labels). Repeatable. Optional when --manifest is provided.
-x, --manifest=<value> Path to a package.xml manifest. When provided, only the components listed in the manifest are verified.
-f, --format=<value> Output format used for the round-trip decompose: xml | yaml | json | json5 [default: xml]
-i, --ignore-package-directory=<value> Package directory to skip. Repeatable.
-s, --strategy=<value> unique-id | grouped-by-tag [default: unique-id]
-p, --decompose-nested-permissions With grouped-by-tag, further decompose permission set and muting permission set object/field permissions.
-c, --config Load per-type and per-component overrides from .sfdecomposer.config.json in the repo root. [default: false]
GLOBAL FLAGS
--json Output as JSON.
```
> At least one of `--metadata-type` or `--manifest` is required. When both are supplied, the run is scoped to their intersection.
**Examples**
```bash
# Verify two metadata types round-trip cleanly with defaults
sf decomposer verify -m "permissionset" -m "profile"
# Verify a different strategy + nested-perms split before committing the change
sf decomposer verify -m "permissionset" -s "grouped-by-tag" -p
# CI gate: verify just the components in a deploy manifest, using the repo-root config
sf decomposer verify -x "manifest/package.xml" --config
```
Files whose **only** delta is sibling or attribute ordering are reported as informational notices, not drift. Salesforce treats metadata as order-agnostic, so the deploy is safe — the notice warns that committing the post-recompose output will show a git diff even though the metadata is functionally identical.
---
### Decompose Strategies
> **Tip:** A single decompose run can mix strategies and formats across metadata types — and even across components within the same type — through the `overrides` array (see [Per-Type & Per-Component Overrides](#per-type--per-component-overrides)). Recompose is deterministic from the on-disk sidecar, so any combination round-trips. When switching strategies for an existing component, pass `--prepurge` (or set `prePurge: true`) so leftover files from the previous strategy are removed before the new ones are written.
- **unique-id** (default): Each nested element goes to its own file, named by unique-id fields or content hash. Leaf elements stay in a file named like the original XML.
- **grouped-by-tag**: All elements with the same tag (e.g. `<fieldPermissions>`) go into one file named after the tag (e.g. `fieldPermissions.xml`). Leaf elements are still grouped in the original-named file.
**Permission set – unique-id**
```
permissionsets/
└── HR_Admin/
├── HR_Admin.permissionset-meta.xml ← leaf properties (label, description, userLicense, ...)
├── .key_order.json ← preserves original element order
├── applicationVisibilities/
│ └── JobApps__Recruiting.applicationVisibilities-meta.xml
├── classAccesses/
│ └── Send_Email_Confirmation.classAccesses-meta.xml
├── fieldPermissions/
│ ├── Job_Request__c.SalaryPay__c.fieldPermissions-meta.xml
│ └── Job_Request__c.Salary__c.fieldPermissions-meta.xml
├── objectPermissions/
│ └── Job_Request__c.objectPermissions-meta.xml
├── pageAccesses/
│ └── Job_Request_Web_Form.pageAccesses-meta.xml
├── recordTypeVisibilities/
│ └── Recruiting.DevManager.recordTypeVisibilities-meta.xml
├── tabSettings/
│ └── Job_Request__c.tabSettings-meta.xml
└── userPermissions/
└── APIEnabled.userPermissions-meta.xml
```
**Permission set – grouped-by-tag**
```
permissionsets/
└── HR_Admin/
├── HR_Admin.permissionset-meta.xml ← leaf properties only
├── .key_order.json
├── applicationVisibilities.xml ← all applicationVisibilities entries
├── classAccesses.xml ← all classAccesses entries
├── fieldPermissions.xml ← all fieldPermissions entries
├── objectPermissions.xml
├── pageAccesses.xml
├── recordTypeVisibilities.xml
├── tabSettings.xml
└── userPermissions.xml
```
#### Filename safety (unique-id)
Two safety nets apply automatically to every shard filename emitted by the **unique-id** strategy. Neither requires configuration:
- **Path-segment sanitization (silent).** Characters illegal or reserved on at least one supported filesystem — path separators (`/`, `\`), Windows-reserved chars (`:`, `*`, `?`, `"`, `<`, `>`, `|`), and ASCII control bytes — are replaced with `_`; trailing `.` and spaces are stripped. Sanitized filenames are byte-stable across platforms.
- **Sibling-collision fallback (emits `WARN`).** When two or more siblings of the same parent tag would resolve to the same filename (the configured unique-id elements are too narrow, or sanitization folded two distinct values together), every sibling in the colliding group is written to its own per-element SHA-256 shard instead. No row is silently overwritten.
If you see a hash-named shard and want to know whether it came from a collision (vs. a missing UID), set `RUST_LOG=warn` and rerun — see [Rust crate logging](#xml-disassemble-output-rust-crate).
#### Custom Labels Decomposition
Custom labels are always decomposed with `unique-id` (grouped-by-tag would be a no-op since every element shares the same tag). Each label is written to its own file:
```
labels/
├── CustomLabels.labels-meta.xml ← original file (safe to delete after decompose)
├── quoteAuto.label-meta.xml ← one file per <labels> entry, named by fullName
└── quoteManual.label-meta.xml
```
#### Additional Permission Set Decomposition
With **grouped-by-tag**, use `--decompose-nested-permissions` (`-p`) to further decompose permission sets and muting permission sets:
- Write each `<objectPermissions>` to its own file under `objectPermissions/`.
- Group `<fieldPermissions>` by object under `fieldPermissions/`.
Similar to Salesforce's `decomposePermissionSetBeta2`, with more control and format options. Muting permission sets extend the permission set metadata type and support the same decomposition.
```bash
sf decomposer decompose -m "permissionset" -s "grouped-by-tag" -p
sf decomposer decompose -m "mutingpermissionset" -s "grouped-by-tag" -p
```
```
permissionsets/
└── HR_Admin/
├── HR_Admin.permissionset-meta.xml ← leaf properties
├── .key_order.json
├── applicationVisibilities.xml ← grouped-by-tag stays grouped
├── classAccesses.xml
├── pageAccesses.xml
├── recordTypeVisibilities.xml
├── tabSettings.xml
├── userPermissions.xml
├── fieldPermissions/ ← grouped per object (decompose-nested-permissions)
│ └── Job_Request__c.fieldPermissions-meta.xml
└── objectPermissions/ ← one file per object
└── Job_Request__c.objectPermissions-meta.xml
```
#### Loyalty Program Setup Decomposition
`loyaltyProgramSetup` is always decomposed with `unique-id`, with a built-in `multiLevel` default that splits `<programProcesses>` into per-process folders containing per-`<parameters>` / per-`<rules>` files.
> Recompose for `loyaltyProgramSetup` always removes the decomposed tree, with or without `--postpurge`. Rely on version control if you need to inspect it after a deploy.
```
loyaltyProgramSetups/
└── Cloud_Kicks_Inner_Circle/
├── Cloud_Kicks_Inner_Circle.loyaltyProgramSetup-meta.xml ← leaf properties (e.g. label)
├── .key_order.json
├── .multi_level.json ← required for recompose; do not hand-edit
└── programProcesses/ ← one folder per process, named by processName
├── Manual Points Adjustments/
│ ├── Manual Points Adjustments.xml ← process leaf properties
│ ├── .key_order.json
│ ├── parameters/ ← one file per parameter, named by parameterName
│ │ ├── EA_PerAdjustmentRewardTracking.parameters-meta.xml
│ │ ├── EventType.parameters-meta.xml
│ │ └── ...
│ └── rules/ ← one file per rule, named by ruleName
│ ├── Bulk Voucher Upload.rules-meta.xml
│ ├── Finalize.rules-meta.xml
│ └── Set Up Step.rules-meta.xml
├── Member Enrollment Process/
│ └── ... ← same shape per process
└── ...
```
> **Tip:** This three-level layout (`programProcesses` → `parameters`/`rules`) is the multi-level decomposition pattern. The same pattern powers Bots, Flexipages, and Layouts via opt-in `multiLevel` overrides — see the [admin handbook](https://github.com/mcarvin8/sf-decomposer/blob/main/HANDBOOK.md) for those recipes.
---
### Supported Metadata
All parent metadata types from this plugin's version of **@salesforce/source-deploy-retrieve** (SDR) are supported, except where noted below.
Use the metadata **suffix** for `-m` / `--metadata-type`, as in [SDR's metadataRegistry.json](https://github.com/forcedotcom/source-deploy-retrieve/blob/main/src/registry/metadataRegistry.json), or infer from the file name: `*.{suffix}-meta.xml`.
| Metadata Type | CLI value | Notes |
| --------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Custom Labels | `labels` | Strategy overridden to `unique-id` if `grouped-by-tag` is provided (grouping labels by tag would be no different from the original file). |
| Workflows | `workflow` | |
| Profiles | `profile` | |
| Permission Sets | `permissionset` | Supports `--decompose-nested-permissions` with grouped-by-tag. |
| Muting Permission Sets | `mutingpermissionset` | Extends permission set metadata type. Supports `--decompose-nested-permissions` with grouped-by-tag. |
| AI Scoring Model Definition | `aiScoringModelDefinition` | |
| Decision Matrix Definition | `decisionMatrixDefinition` | |
| Bot | `bot` | |
| Marketing App Extension | `marketingappextension` | |
| Loyalty Program Setup | `loyaltyProgramSetup` | Only `unique-id` strategy supported; `grouped-by-tag` is overridden. Automatically decomposed further (see [Loyalty Program Setup](#loyalty-program-setup-decomposition)). |
For a comprehensive breakdown of supported, leaf-only, and unsupported metadata types — including multi-level decomposition patterns, Salesforce native decomposition conflicts, and adapter strategy limitations — see [**METADATA_SUPPORT.md**](./METADATA_SUPPORT.md).
#### Exceptions
| Situation | Message |
| ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `botVersion` used directly | `botVersion suffix should not be used. Please use bot to decompose/recompose bot and bot version files.` |
| Custom Objects | `Custom Objects are not supported by this plugin.` |
| Unsupported SDR strategies (e.g. matchingContentFile, digitalExperience, mixedContent, bundle) | `Metadata types with [matchingContentFile, digitalExperience, mixedContent, bundle] strategies are not supported by this plugin.` |
| Child types (e.g. custom fields) or invalid suffix | `Metadata type not found for the given suffix: field.` |
---
### Manifest-scoped Runs
`-x` / `--manifest` is supported by every `sf decomposer` command and accepts the same `package.xml` you pass to `sf project deploy start -x`. Only the listed components are decomposed/recomposed; everything else is left alone.
- Wildcards (`<members>*</members>`) expand against your local source.
- Folder members (e.g. `MyFolder/MyReport`) resolve by walking the folder.
- Types the plugin does not support (e.g. `CustomObject`, `ApexClass`) are skipped with a warning, so the same manifest can drive both deploys and decomposer runs.
- If both `--metadata-type` and `--manifest` are supplied, the run is scoped to their intersection.
Example manifest:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<Package xmlns="http://soap.sforce.com/2006/04/metadata">
<types>
<members>HR_Admin</members>
<name>PermissionSet</name>
</types>
<types>
<members>Case</members>
<name>Workflow</name>
</types>
<version>58.0</version>
</Package>
```
```bash
sf decomposer recompose -x "manifest/package.xml"
sf project deploy start -x "manifest/package.xml"
```
---
### Per-Type & Per-Component Overrides
Overrides apply to **decompose only**. Recompose is a deterministic round-trip — it auto-detects format from the on-disk files and does not depend on strategy — so it ignores the `overrides` array.
By default, a single decompose run uses one format and one strategy across every metadata type. The optional `overrides` array in `.sfdecomposer.config.json` lets you vary a small set of options per metadata suffix (**type-scope**) or per individual SDR component (**component-scope**) without splitting the run into multiple invocations.
```json
{
"metadataSuffixes": "labels,workflow,profile,flow,permissionset",
"ignorePackageDirectories": "force-app,examples",
"prePurge": true,
"postPurge": true,
"decomposedFormat": "xml",
"strategy": "unique-id",
"overrides": [
{ "metadataTypes": ["flow"], "decomposedFormat": "yaml" },
{
"metadataTypes": ["permissionset", "mutingpermissionset"],
"strategy": "grouped-by-tag",
"decomposeNestedPermissions": true
},
{
"components": ["permissionset:HR_Admin", "permissionset:Big_PermSet"],
"strategy": "grouped-by-tag",
"decomposeNestedPermissions": true
}
]
}
```
#### What can be overridden
| Field | Notes |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
| `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. |
| `decomposedFormat` | `xml` \| `json` \| `json5` \| `yaml`. |
| `strategy` | `unique-id` \| `grouped-by-tag`. Hard rules still win — `labels` and `loyaltyProgramSetup` are always treated as `unique-id`. |
| `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. |
| `splitTags` | Custom `splitTags` spec for `grouped-by-tag` strategy. See [splitTags grammar](#splittags-grammar). Ignored when the resolved strategy is not `grouped-by-tag`. |
| `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. |
| `uniqueIdElements` | Comma-separated list of XML element names (or compound `+`-joined keys) used to derive stable filenames for `unique-id` decomposition. When set, replaces the built-in per-type registry entry for the targeted scope. See [uniqueIdElements grammar](#uniqueidelements-grammar). |
| `prePurge` | Per-scope prePurge (decompose). Component-scope `prePurge` only purges the named component's decomposed directory. |
| `postPurge` | Per-scope postPurge (decompose: remove originals after decomposing). |
Run-scope options (`metadataSuffixes`, `manifest`, `ignorePackageDirectories`) are **not** valid inside an override; the plugin will throw if they are present.
#### Component key conventions
The `<fullName>` part of a component key is the SDR fullName for the component, matching the basename of the decomposed directory:
- **Plain types** (e.g. `permissionset`, `flow`, `profile`, `workflow`): use the file stem, e.g. `permissionset:HR_Admin` for `permissionsets/HR_Admin.permissionset-meta.xml`.
- **Strict-directory types** (e.g. `bot`): use the bot directory name, e.g. `bot:My_Bot` for `bots/My_Bot/My_Bot.bot-meta.xml`.
- **Folder-typed metadata** (e.g. `report`, `dashboard`, `email`, `document`): the unit of decomposition is the folder; use the folder name, e.g. `report:MyFolder` to scope every report inside `reports/MyFolder/`.
- **`labels`**: there is exactly one labels file per labels directory, so component-scope keys are not meaningful — use the type-scope `metadataTypes: ["labels"]` instead.
Component overrides are not a filter. If `--metadata` / `metadataSuffixes` includes `permissionset`, every permission set is still decomposed; the override only changes how the named ones are decomposed. Use `--manifest` / the hook's `manifest` field if you want to scope the run itself to a subset of components.
#### Precedence
For each component, each option is resolved independently in this order (highest first):
1. The component-scope override value (matching `<suffix>:<fullName>` in `components`), if set.
2. The type-scope override value (matching `<suffix>` in `metadataTypes`), if set.
3. The run-wide value (CLI flag, hook config top-level field, or built-in default).
4. Hard plugin rules (e.g. `labels` and `loyaltyProgramSetup` forced to `unique-id`) override all of the above.
#### splitTags grammar
`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.
**Spec:** Comma-separated rules. Each rule has 3 or 4 colon-separated parts:
- `<tag>:<mode>:<field>` — read array items from the top-level `<tag>`.
- `<tag>:<path>:<mode>:<field>` — read array items from the nested `<path>` (defaults to `<tag>`).
`<mode>` is one of:
- **`split`** — write one file per array item, named after the value of `<field>` on each item.
- **`group`** — group array items by the value of `<field>`, writing one file per group.
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.
**Examples:**
```json
"overrides": [
{
"metadataTypes": ["permissionset", "mutingpermissionset"],
"strategy": "grouped-by-tag",
"splitTags": "objectPermissions:split:object,fieldPermissions:group:field"
},
{
"metadataTypes": ["profile"],
"strategy": "grouped-by-tag",
"splitTags": "objectPermissions:split:object,fieldPermissions:group:field,layoutAssignments:group:layout"
}
]
```
> **Caveat:** With `mode: split`, the chosen `<field>` must produce a unique value across every array item — otherwise two items map to the same filename. If items can share a field value, use `mode: group` instead.
See the [admin handbook](https://github.com/mcarvin8/sf-decomposer/blob/main/HANDBOOK.md) for additional `splitTags` and `multiLevel` recipes (flows, workflows, layouts, flexipages, bots).
#### multiLevel grammar
`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.
**Spec:** Each rule has exactly 3 colon-separated parts (the third part is itself a comma-separated list):
```
<file_pattern>:<root_to_strip>:<unique_id_elements>
```
- **`<file_pattern>`** — basename pattern that selects which inner-level files get the second decomposition pass (e.g. `programProcesses`).
- **`<root_to_strip>`** — XML root tag to strip from each matched file before splitting.
- **`<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.
A scope may target several nested sections by passing **multiple rules**. Three input shapes are supported:
- a single rule string (legacy, unchanged behaviour);
- a JSON `string[]` of rules (preferred — clearest intent, easiest to diff);
- a single `;`-separated string of rules (compact form, also accepted).
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.
```json
"overrides": [
{
"metadataTypes": ["dashboard"],
"multiLevel": "components:components:title"
},
{
"metadataTypes": ["layout"],
"multiLevel": [
"layoutSections:layoutSections:label",
"layoutItems:layoutItems:field,customLink,emptySpace"
]
}
]
```
> **Built-in defaults.** `bot` and `loyaltyProgramSetup` ship with built-in `multiLevel` rules, so you do not need an override to get the canonical layout — supply your own only to replace the default. Full registry: [`src/metadata/multiLevelDefaults.ts`](https://github.com/mcarvin8/sf-decomposer/blob/main/src/metadata/multiLevelDefaults.ts).
>
> **Pass all rules at once.** Sequential single-rule decomposes rewrite `.multi_level.json` and only the last rule survives — bundle every rule for a given component into one override. Use [`sf decomposer verify`](#sf-decomposer-verify) to confirm a new config round-trips before committing it.
#### uniqueIdElements grammar
`uniqueIdElements` lets you specify which XML element names the disassembler crate uses to derive stable, human-readable filenames during `unique-id` decomposition. The plugin ships with a built-in registry covering the most common metadata types ([`src/metadata/uniqueIdElements.ts`](https://github.com/mcarvin8/sf-decomposer/blob/main/src/metadata/uniqueIdElements.ts)); use this override when a type is missing from the registry or when the built-in selection produces collisions for your org's data.
**When to use:**
- A metadata type released after the last plugin update is not in the built-in registry and produces SHA-256 hash filenames (`abc1234.mytype-meta.xml`) instead of readable ones.
- You see `RUST_LOG=warn` collision warnings for an existing type and want to add a tiebreaker compound key without waiting for a plugin release.
- You want to replace the built-in element list for a specific type or component with a narrower or wider set.
**Spec:** Comma-separated list of element names. Each entry is either a simple name or a compound key whose fields are joined by `+`. The disassembler tries each entry in order; the first one that resolves to a non-empty, unique value within the parent element wins. The global defaults `fullName` and `name` are always prepended regardless — you do not need to include them.
```
<element>[+<element>...][,<element>[+<element>...]...]
```
**Error behaviour:**
- An empty string or an entry with empty comma slots is rejected at **config-load time** — the command fails immediately before any decomposition starts.
- Element names that pass format validation but do not exist in the XML are silently ignored by the disassembler crate; it falls back to SHA-256 hash filenames for the affected elements (the same behaviour as today when no registry entry matches). The plugin does not throw an error and continues decomposing all remaining files.
**Examples:**
```json
"overrides": [
{
"metadataTypes": ["myNewSalesforceType"],
"uniqueIdElements": "developerName"
},
{
"metadataTypes": ["serviceChannel"],
"uniqueIdElements": "type+value,value"
},
{
"components": ["app:My_App"],
"uniqueIdElements": "actionName+pageOrSobjectType+formFactor+profile+recordType,actionName+pageOrSobjectType+formFactor+profile,actionName+pageOrSobjectType+formFactor+recordType,actionName+pageOrSobjectType+formFactor"
}
]
```
> **Tip:** If you resolve a collision by adding a compound key and it works, consider opening an issue or PR to add it to the built-in registry so other orgs benefit automatically.
#### Opting in from the CLI
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`):
```bash
sf decomposer decompose -m "flow" -m "permissionset" -c
```
When `--config` is set, **only** the `overrides` array is consumed from the file. Top-level fields like `decomposedFormat`, `strategy`, `metadataSuffixes`, etc. are ignored — the CLI flags remain the source of truth for run-wide values. This keeps direct CLI behavior predictable and lets you reuse the same config file as the post-retrieve hook without any surprises.
If `--config` is set but `.sfdecomposer.config.json` is missing from the repo root, the command fails with a clear error.
`recompose` does not accept `--config` because it does not need the override information — format is auto-detected from the decomposed files on disk and recompose does not depend on strategy.
The post-retrieve hook automatically picks up `overrides` from `.sfdecomposer.config.json` — no extra setup required. Existing config files without an `overrides` field continue to behave exactly as before.
---
### Ignore Files
#### .forceignore
The Salesforce CLI must **ignore** decomposed files and **allow** recomposed files. Use the [sample .forceignore](https://raw.githubusercontent.com/mcarvin8/sf-decomposer/main/examples/.forceignore) and set patterns for the extensions you use (`.xml`, `.json`, `.yaml`, etc.).
#### .sfdecomposerignore
Optional. In the project root, list paths/patterns to skip when **decomposing** (same syntax as [.gitignore 2.22.1](https://git-scm.com/docs/gitignore)). Ignored files are not recomposed from.
#### .gitignore
Optional. Ignore recomposed metadata so it isn't committed. See the [sample .gitignore](https://raw.githubusercontent.com/mcarvin8/sf-decomposer/main/examples/.gitignore).
---
### Troubleshooting
#### Missing sfdx-project.json
The plugin looks for `sfdx-project.json` from the current directory up to the drive root. If it's not found:
```
Error (1): sfdx-project.json not found in any parent directory.
```
#### Package Directories Not Found for Given Metadata Type
This plugin relies on the @salesforce/source-deploy-retrieve metadata registry to map each metadata type to its expected directory name.
If you provide a metadata type whose corresponding directory does not exist in any of your package directories, the plugin will fail with:
```
No directories named ${metadataTypeEntry.directoryName} were found in any package directory.
```
For example, if you attempt to decompose Custom Labels but none of your package directories contain a "labels" folder, the plugin will throw this error.
#### XML disassemble output (Rust crate)
The underlying Rust crate logs through [env_logger](https://docs.rs/env_logger). Set `RUST_LOG` to opt into more verbosity:
| Level | What it covers |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RUST_LOG=error` | Default. Parse errors and skipped files (leaf-only XML — primitives only, nothing to decompose). |
| `RUST_LOG=warn` | Adds [sibling-collision fallback](#filename-safety-unique-id) signals — one line per colliding group (parent tag, collided id, sibling count). **Recommended in CI** when shipping overrides. |
Example `WARN` (CustomApplication where four `actionOverrides` siblings shared the action name `View`):
```
[2026-05-04T15:21:09Z WARN config_disassembler::xml::builders::build_disassembled_files]
uniqueIdElements collision: <actionOverrides> id "View" matched 4 sibling elements;
falling back to SHA-256 content hashes for the colliding group.
Consider adding more discriminating fields to uniqueIdElements for this metadata type.
```
---
### Built With
- [config-disassembler-node](https://github.com/mcarvin8/config-disassembler-node) – Disassemble XML (and other config formats) into smaller, manageable files and reassemble when needed. Node.js + Rust (NAPI-RS).
- [@salesforce/source-deploy-retrieve](https://github.com/forcedotcom/source-deploy-retrieve) – JavaScript toolkit for working with Salesforce metadata.
---
## Migrating from Salesforce Native Decomposition
Already using `decomposePermissionSetBeta2`, `decomposeCustomLabelsBeta`, or similar flags? See [MIGRATION.md](https://github.com/mcarvin8/sf-decomposer/blob/main/MIGRATION.md) before installing sf-decomposer on that project.
## Contributing
Contributions are welcome. See [CONTRIBUTING.md](https://github.com/mcarvin8/sf-decomposer/blob/main/CONTRIBUTING.md).
## License
[MIT](https://raw.githubusercontent.com/mcarvin8/sf-decomposer/main/LICENSE.md)