# Metadata XML Rules (PMD with language="xml") Write PMD XPath rules that target Salesforce metadata XML files (`.field-meta.xml`, `.permissionset-meta.xml`, `.profile-meta.xml`, `.flow-meta.xml`, etc.) to enforce org governance. ## How It Works PMD's `language="xml"` mode parses any XML file into a DOM-like AST. You write XPath expressions against element/attribute names in the XML structure — same mechanism as Apex rules, but the AST is the XML DOM. ## Critical: PMD 7 XML XPath Rules ### Rule 1: Namespace — use `local-name()` Salesforce metadata files declare a namespace: ```xml My_Field__c ... ``` This namespace **breaks standard XPath** because `//fullName` won't match — PMD sees it as `//metadata:fullName` internally. **Solution:** Use `local-name()` to match element names regardless of namespace: ```xpath //*[local-name()='CustomField']/*[local-name()='fullName'] ``` **This applies to ALL Salesforce metadata XPath rules.** Never use bare element names without `local-name()`. ### Rule 2: Text content — use `@Text` (NOT `text()`) ⚠️ **PMD 7's XML language does NOT support `text()` for value comparison.** The `text()` function returns 0 matches regardless of content. This is a PMD 7 change from PMD 6. **Use the `@Text` attribute instead:** ```xpath // ❌ WRONG — does not work in PMD 7: //*[local-name()='name'][text()='ModifyAllData'] // ✅ CORRECT — use @Text attribute: //*[@Text='ModifyAllData'] ``` ### Rule 3: Navigation — text nodes require `../..` In PMD 7 XML, `@Text` matches TEXT NODES (child nodes of elements), not the elements themselves. To navigate from a matched text node to its parent element hierarchy: ```xpath // @Text='ModifyAllData' matches the TEXT NODE inside ModifyAllData // To get the element: go up one level //*[@Text='ModifyAllData']/.. // To get the parent: go up two levels //*[@Text='ModifyAllData']/../.. // To check a sibling's text value from the parent: //*[@Text='ModifyAllData']/../..[.//*[@Text='true']] ``` ### Complete Pattern for Checking Parent + Sibling Text Values The canonical PMD 7 pattern for "find element X that contains child with text A AND child with text B": ```xpath //*[@Text='TargetValue']/../.. [local-name()='ParentElement'] [.//*[@Text='ConditionValue']] ``` Example — find `userPermissions` where name=ModifyAllData AND enabled=true: ```xpath //*[@Text='ModifyAllData']/../.. [local-name()='userPermissions'] [.//*[@Text='true']] ``` ## AST Dump for Metadata Files Dump the AST to see the exact XML structure: ```bash sf code-analyzer ast-dump --file force-app/main/default/objects/Account/fields/MyField__c.field-meta.xml --language xml ``` The output shows the DOM tree — element nodes, text nodes, and attributes. Use this to confirm element names before writing XPath. ## Ruleset XML Template for Metadata Rules ```xml Custom rules for Salesforce metadata governance What this rule enforces 3 ``` ## Configuration ```yaml # code-analyzer.yml engines: pmd: custom_rulesets: - "custom-rules/metadata-governance.xml" file_extensions: xml: [".xml"] ``` ⚠️ **IMPORTANT:** PMD's XML language only scans `.xml` files by default. You MUST add `file_extensions: { xml: [".xml"] }` under `engines.pmd` in your config. This single entry covers ALL Salesforce metadata compound extensions (`.permissionset-meta.xml`, `.field-meta.xml`, `.flow-meta.xml`, etc.) because the system reads the final `.xml` portion of the filename. ⚠️ **Do NOT add compound extensions** like `.permissionset-meta.xml` to the list — Code Analyzer's validator only accepts single-segment extensions matching `/^[.][a-zA-Z0-9]+$/` and will reject them with a config error. ## Common Metadata File Structures ### Custom Field (`.field-meta.xml`) ```xml Revenue__c Currency false Annual revenue ``` ### Permission Set (`.permissionset-meta.xml`) ```xml true ModifyAllData true Account.Revenue__c true ``` ### Profile (`.profile-meta.xml`) ```xml true ViewSetup true Account.SSN__c ``` ### Flow (`.flow-meta.xml`) ```xml 58.0 AutoLaunchedFlow Active CanvasMode AUTO_LAYOUT_CANVAS Create_Account Account Loop_Records Get_Records ``` ## XPath Patterns for Metadata (PMD 7) All patterns below use `@Text` for text content matching (NOT `text()` which is broken in PMD 7). ### Require description on Custom Fields **Missing description entirely:** ```xpath //*[local-name()='CustomField'][not(*[local-name()='description'])] ``` **Missing OR empty description (catches `` too):** ```xpath //*[local-name()='CustomField'][not(*[local-name()='description']) or *[local-name()='description'][not(.//*[@Text])]] ``` Note: `.//*[@Text]` checks for any descendant text node with content. Do NOT put `[not(@Text)]` directly on the `` element — `@Text` only exists on child text nodes, not on elements themselves. Putting it on the element always evaluates to true (false positive). ### Flag ModifyAllData / ViewAllData in Permission Sets ```xpath //*[@Text='ModifyAllData' or @Text='ViewAllData']/../.. [local-name()='userPermissions'] [.//*[@Text='true']] ``` ### Enforce minimum API version (60.0+) ```xpath //*[local-name()='apiVersion']/*[number(@Text) < 60] ``` Note: `/*` navigates to the **child text node** where `@Text` lives. Do NOT put `@Text` directly on the element — it won't match because `@Text` only exists on text nodes (children), not on elements themselves. ### Flag field permissions in Profiles (should be in Perm Sets) ```xpath //*[local-name()='Profile']/*[local-name()='fieldPermissions'] ``` (No text matching needed — structural only.) ### Flag dangerous permissions (ModifyAllData only, enabled) ```xpath //*[@Text='ModifyAllData']/../.. [local-name()='userPermissions'] [.//*[@Text='true']] ``` ### Flag DML (recordCreates/recordUpdates/recordDeletes) inside Flow loops ```xpath //*[local-name()='loops'][ following-sibling::*[local-name()='recordCreates' or local-name()='recordUpdates' or local-name()='recordDeletes'] ] ``` (No text matching needed — structural only.) ## Targeting Specific File Types PMD only scans `.xml` files by default. You must add `.xml` to the config. This single extension covers ALL Salesforce compound metadata extensions (`.permissionset-meta.xml`, `.field-meta.xml`, etc.) because `path.extname()` returns `.xml` from these filenames. ```yaml # code-analyzer.yml engines: pmd: file_extensions: xml: [".xml"] ``` ⚠️ Do NOT add compound extensions — the validator rejects anything that doesn't match `/^[.][a-zA-Z0-9]+$/`. Additionally, scope your XPath to the correct root element to avoid false positives across file types: ```xpath //*[@Text='ModifyAllData']/../..[local-name()='userPermissions'] [ancestor::*[local-name()='PermissionSet']] ``` This ensures the rule only fires on permission set files, not on profile or other metadata files. ## Gotchas | Issue | Resolution | |-------|------------| | XPath matches nothing | Three causes: (1) Forgot `local-name()`. (2) Used `text()` — broken in PMD 7, use `@Text`. (3) File extension not in `file_extensions.xml` config. | | Rule fires on wrong metadata type | Add root element scope: `[ancestor::*[local-name()='PermissionSet']]` | | Can't match text content | **Use `@Text` attribute** (NOT `text()`). Example: `//*[@Text='ModifyAllData']` | | `@Text` matches but parent navigation fails | `@Text` matches TEXT NODES. Go up with `../..`: text→element→parent. | | Number comparison fails | `@Text` lives on the **child text node**, NOT on the element. Use: `//*[local-name()='element']/*[number(@Text) < N]` — the `/*` navigates to the text node. Do NOT put `[@Text]` predicate directly on the element (returns 0 matches). | | Too many violations on large orgs | Scope with `file_extensions` in PMD config or add `ignores.files` patterns | | Rule doesn't fire on metadata files | Add `file_extensions: { xml: [".xml"] }` under `engines.pmd` — this covers all compound extensions. Do NOT add `.permissionset-meta.xml` etc. (validator rejects them). | | ast-dump fails with "XmlEncoding" error | Known issue. Read the raw XML file — the file content IS the DOM structure | | ast-dump shows different structure | XML AST is the literal DOM — what you see in the file is what you get |