10 KiB
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 version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>My_Field__c</fullName>
...
</CustomField>
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:
//*[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:
// ❌ 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:
// @Text='ModifyAllData' matches the TEXT NODE inside <name>ModifyAllData</name>
// To get the <name> element: go up one level
//*[@Text='ModifyAllData']/..
// To get the <userPermissions> 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":
//*[@Text='TargetValue']/../..
[local-name()='ParentElement']
[.//*[@Text='ConditionValue']]
Example — find userPermissions where name=ModifyAllData AND enabled=true:
//*[@Text='ModifyAllData']/../..
[local-name()='userPermissions']
[.//*[@Text='true']]
AST Dump for Metadata Files
Dump the AST to see the exact XML structure:
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 version="1.0" encoding="UTF-8"?>
<ruleset name="MetadataGovernanceRules"
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
<description>Custom rules for Salesforce metadata governance</description>
<rule name="MyMetadataRule"
language="xml"
message="Violation message here"
class="net.sourceforge.pmd.lang.rule.xpath.XPathRule">
<description>What this rule enforces</description>
<priority>3</priority>
<properties>
<property name="xpath">
<value><![CDATA[
//*[local-name()='YourElement'][your-condition]
]]></value>
</property>
</properties>
</rule>
</ruleset>
Configuration
# 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)
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>Revenue__c</fullName>
<label>Revenue</label>
<type>Currency</type>
<required>false</required>
<description>Annual revenue</description>
</CustomField>
Permission Set (.permissionset-meta.xml)
<PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
<label>Admin Access</label>
<userPermissions>
<enabled>true</enabled>
<name>ModifyAllData</name>
</userPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Account.Revenue__c</field>
<readable>true</readable>
</fieldPermissions>
</PermissionSet>
Profile (.profile-meta.xml)
<Profile xmlns="http://soap.sforce.com/2006/04/metadata">
<userPermissions>
<enabled>true</enabled>
<name>ViewSetup</name>
</userPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Account.SSN__c</field>
</fieldPermissions>
</Profile>
Flow (.flow-meta.xml)
<Flow xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>58.0</apiVersion>
<label>My Flow</label>
<processType>AutoLaunchedFlow</processType>
<status>Active</status>
<processMetadataValues>
<name>CanvasMode</name>
<value><stringValue>AUTO_LAYOUT_CANVAS</stringValue></value>
</processMetadataValues>
<recordCreates>
<name>Create_Account</name>
<object>Account</object>
</recordCreates>
<loops>
<name>Loop_Records</name>
<collectionReference>Get_Records</collectionReference>
</loops>
</Flow>
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:
//*[local-name()='CustomField'][not(*[local-name()='description'])]
Missing OR empty description (catches <description></description> too):
//*[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 <description> 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
//*[@Text='ModifyAllData' or @Text='ViewAllData']/../..
[local-name()='userPermissions']
[.//*[@Text='true']]
Enforce minimum API version (60.0+)
//*[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)
//*[local-name()='Profile']/*[local-name()='fieldPermissions']
(No text matching needed — structural only.)
Flag dangerous permissions (ModifyAllData only, enabled)
//*[@Text='ModifyAllData']/../..
[local-name()='userPermissions']
[.//*[@Text='true']]
Flag DML (recordCreates/recordUpdates/recordDeletes) inside Flow loops
//*[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.
# 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:
//*[@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 |