Skip to content

Appendix F.3: Custom PMD Java Rules

The XPath rules in Appendix F.2 see one thing: the syntax tree of the file PMD is checking, as PMD exposes it to XPath. Some of this site's standards need more than that. The access mode standard covers every database operation, and the access mode of a DML statement (insert as user) is recorded by PMD's parser but not exposed to XPath (pmd/pmd#7108). A rule written in Java can call the parser's API directly, so the {N} access mode rules here are Java. They are the whole of the access mode standard: RequireAccessMode and FlagSystemMode cover the same four kinds of database operation, and there is no XPath or regex version of either. {N} more rules need what is not in the file at all: RequireInvocableNoArgConstructor reads the class's API version from the -meta.xml file beside it, NoCredentialNamedField reads a field's object from the file's path, and MultipleTriggersOnObject reads the headers of the other triggers in the project.

This page describes each rule and shows its source. The source is the real project: every block below comes from docs/samples/pmd-java-rules, a small Maven project that builds the JAR and runs the tests. It is a reference implementation of one philosophy, not a package to drop into an org unchanged: the group id is a placeholder, and the priorities are this site's.

The Project

pmd-java-rules/
├── pom.xml
├── THIRD_PARTY_LICENSES.md
└── src/
    ├── main/
    │   ├── java/devsecops/pmd/rules/
    │   └── resources/rulesets/
    │       └── devsecops-java.xml
    └── test/
        ├── java/devsecops/pmd/rules/
        │   ├── RulesTest.java
        │   ├── MultipleTriggersOnObjectRuleTest.java
        │   ├── NoCredentialNamedFieldRuleTest.java
        │   └── RequireInvocableNoArgConstructorRuleTest.java
        └── resources/devsecops/pmd/rules/
            ├── xml/
            └── fixtures/
File or folder What it holds
pom.xml The Maven build: the PMD version, and the dependencies that build the JAR and run the tests
THIRD_PARTY_LICENSES.md The licence of the code that one rule adapts
src/main/java/devsecops/pmd/rules/ One class per rule, and ApexMetaFile.java, which reads a class's API version from its -meta.xml file
src/main/resources/rulesets/devsecops-java.xml The ruleset that names the rule classes
RulesTest.java PMD's rule-test harness, for the Apex rules except MultipleTriggersOnObject
MultipleTriggersOnObjectRuleTest.java A run of PMD on a fixture project of triggers, and on a folder with no project file
NoCredentialNamedFieldRuleTest.java A run of PMD on a fixture tree of objects and fields
RequireInvocableNoArgConstructorRuleTest.java A run of PMD on fixture classes at different API versions
src/test/resources/devsecops/pmd/rules/xml/ One XML file of test cases for each Apex rule except MultipleTriggersOnObject
src/test/resources/devsecops/pmd/rules/fixtures/ The objects and fields, the classes with their -meta.xml files, and the trigger project that {N} of the rules are tested on

mvn package compiles the rules, runs the test cases and writes target/pmd-java-rules-1.0.0.jar. The ruleset XML travels inside the JAR, so Code Analyzer needs two lines: the JAR on the Java classpath, and the ruleset by its resource name.

engines:
  pmd:
    java_classpath_entries:
      - config/pmd/pmd-java-rules-1.0.0.jar
    custom_rulesets:
      - rulesets/devsecops-java.xml

The sample code-analyzer.yml has these {N} entries in place, with the other {N} rulesets after the JAR's and a severity override for each rule.

Verified on Code Analyzer 5.16.0 (PMD 7.26.0): the {N} rules list under the DevSecOpsJavaRules tag, the {N} access mode rules report the same on a service class, a test class, and a trigger at API 66.0 as at 67.0, RequireInvocableNoArgConstructor reads the -meta.xml beside a class and reports a class at API 66.0 and 67.0 and not one at 65.0 or one with no -meta.xml, MultipleTriggersOnObject reports the same two triggers of its fixture project as PMD alone, and the XML rule reports the same fields through Code Analyzer as through PMD alone. Keep the pmd.version in the pom.xml aligned with the PMD that Code Analyzer bundles, and override each rule's severity in code-analyzer.yml as Appendix E describes.

pom.xml
<?xml version="1.0" encoding="UTF-8" ?>
<project
    xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- Replace the group id with your organisation's. -->
    <groupId>org.example.devsecops</groupId>
    <artifactId>pmd-java-rules</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>
    <name>DevSecOps reference: custom PMD rules written in Java</name>

    <properties>
        <!-- The PMD version Salesforce Code Analyzer bundles; keep them aligned. -->
        <pmd.version>7.26.0</pmd.version>
        <maven.compiler.release>17</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- provided: Code Analyzer and the PMD CLI supply PMD at run time; only our classes go in the JAR. -->
        <!-- pmd-test brings its own JUnit (6.x for PMD 7.26.0); do not pin another. -->
        <dependency>
            <groupId>net.sourceforge.pmd</groupId>
            <artifactId>pmd-apex</artifactId>
            <version>${pmd.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>net.sourceforge.pmd</groupId>
            <artifactId>pmd-xml</artifactId>
            <version>${pmd.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>net.sourceforge.pmd</groupId>
            <artifactId>pmd-test</artifactId>
            <version>${pmd.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.6.1</version>
            </plugin>
        </plugins>
    </build>
</project>

What the Java Rules Cover

Standard Rule Priority Is there an XPath rule for it?
Every system-mode operation is flagged for review FlagSystemMode 2 No
Every database operation states its access mode RequireAccessMode 2 No
No credential is kept in a field of any object NoCredentialNamedField 2 No
From API 66.0, an invocable class has a visible no-argument constructor RequireInvocableNoArgConstructor 1 No
Each object has at most one trigger MultipleTriggersOnObject 1 No

The {N} Apex rules look at the same four kinds of database operation: inline SOQL, inline SOSL, the Database and Search methods that accept an AccessLevel, and DML statements. RequireAccessMode requires that a mode is stated, and FlagSystemMode flags the mode when it is system mode. They are Java because both read the access mode of a DML statement from the parser, which XPath cannot see.

The Ruleset

Every rule carries an explicit priority, and code-analyzer.yml overrides the severity that Code Analyzer derives from it with the same number (the Priority Reference).

The rules are listed by group, beside the XPath rules, in The Custom Rules on the Appendix F landing page.

devsecops-java.xml
<?xml version="1.0" encoding="UTF-8" ?>
<ruleset
    name="DevSecOps Reference Rules"
    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 PMD rules written in Java, for the checks XPath cannot express:
        the access mode of every database operation, DML included, which the
        parser records but XPath cannot see, a class's API version, which is in
        its -meta.xml and not in the class, a field's object, which is in
        the file's path and not in the file, and the other triggers on an
        object, which are in other files. Every rule carries an explicit
        priority. Code Analyzer by default assigns a severity that can differ
        from a rule's PMD priority, so code-analyzer.yml must explicitly specify
        the rule's severity in order to keep the same level.
    </description>

    <!-- ============================================================ -->
    <!-- CUSTOM RULES: APEX ACCESS, SHARING, AND ACCESS MODE          -->
    <!-- ============================================================ -->

    <rule
        name="RequireAccessMode"
        language="apex"
        message="State the access mode on every database operation: WITH USER_MODE, AccessLevel.USER_MODE, or as user"
        class="devsecops.pmd.rules.RequireAccessModeRule">
        <description>Every database operation must state its access mode: inline SOQL and SOSL, the Database and Search methods that accept an AccessLevel, and DML statements, including in test classes.</description>
        <priority>2</priority>
    </rule>

    <rule
        name="FlagSystemMode"
        language="apex"
        message="System-mode database operation: give the reason in a comment above it or in the method header"
        class="devsecops.pmd.rules.FlagSystemModeRule">
        <description>A system-mode database operation bypasses object and field permissions, so using system mode must be a deliberate, reviewed choice: inline SOQL and SOSL WITH SYSTEM_MODE, Database and Search methods given AccessLevel.SYSTEM_MODE, and DML run as system. The reportInTestClasses property decides whether test classes are reported.</description>
        <priority>2</priority>
        <properties>
            <property name="reportInTestClasses" value="true" />
        </properties>
    </rule>

    <!-- ============================================================ -->
    <!-- CUSTOM RULES: APEX ERROR PRONE                               -->
    <!-- ============================================================ -->

    <rule
        name="RequireInvocableNoArgConstructor"
        language="apex"
        message="From API 66.0, give this class a public no-argument constructor (global in a global class), so that a Flow can use it"
        class="devsecops.pmd.rules.RequireInvocableNoArgConstructorRule">
        <description>From API version 66.0, a class with an @InvocableVariable member and an explicit constructor also has a visible no-argument constructor: public in a class outside a package, global in a global class. The rule reads the class's API version from its -meta.xml and reports only from the version in the minApiVersion property; a class whose version cannot be read is left to the stock rule InvocableClassNoArgConstructor.</description>
        <priority>1</priority>
    </rule>

    <!-- ============================================================ -->
    <!-- CUSTOM RULES: APEX TRIGGERS                                  -->
    <!-- ============================================================ -->

    <rule
        name="MultipleTriggersOnObject"
        language="apex"
        message="{0} has another trigger: {1}. Keep one trigger for each object"
        class="devsecops.pmd.rules.MultipleTriggersOnObjectRule">
        <description>Each object has at most one trigger, because the order in which two triggers on one object run is not guaranteed. The rule reads the header of every other trigger in the project, in every package directory below the folder with the sfdx-project.json file, and reports each trigger on an object that has another. A trigger whose name contains two underscores is from a managed package, and is neither reported nor counted.</description>
        <priority>1</priority>
    </rule>

    <!-- ============================================================ -->
    <!-- CUSTOM RULES: SECRETS IN FIELDS                              -->
    <!-- ============================================================ -->

    <rule
        name="NoCredentialNamedField"
        language="xml"
        message="Do not keep a credential in a field of any object, custom metadata type, or custom setting: use an External Credential"
        class="devsecops.pmd.rules.NoCredentialNamedFieldRule">
        <description>A custom field whose name suggests a credential, on any object, standard or custom, and on any custom metadata type or custom setting, whatever its visibility: outside a managed package, a protected custom metadata type behaves like a public one, and a secret is kept in an External Credential. The rule reads the field's name, never its value, so a name such as Assigned_To__c is reported too and the owning team accepts the report. The credentialWords property holds the words.</description>
        <priority>2</priority>
    </rule>
</ruleset>

FlagSystemMode

A system-mode database operation bypasses object and field permissions, so using system mode must be a deliberate, reviewed choice. The rule covers the same four kinds of operation as RequireAccessMode: inline SOQL and SOSL with WITH SYSTEM_MODE, a Database or Search method given AccessLevel.SYSTEM_MODE, and DML run as system. Priority 2: the operation blocks the pull request until a reviewer accepts it, and the standard asks for a comment in the code that gives the reason (General: Comments).

Test classes are included, because a test's database operations should run as a user created for that test class or test method, so a test should rarely need system mode. The reportInTestClasses property controls this. It is true by default; set it to false and system-mode operations in a test class are not flagged.

The property is set in a ruleset, the only place Code Analyzer reads a property from: change the value in rulesets/devsecops-java.xml before building the JAR, or reference the rule from another ruleset and set the property there. Its name follows PMD's own convention for a property that widens or narrows what a rule reports, such as reportInForLoopInitializer on the stock Apex rule OneDeclarationPerLine.

FlagSystemModeRule.java
package devsecops.pmd.rules;

import java.util.Locale;
import java.util.regex.Pattern;

import net.sourceforge.pmd.lang.apex.ast.ASTMethodCallExpression;
import net.sourceforge.pmd.lang.apex.ast.ASTReferenceExpression;
import net.sourceforge.pmd.lang.apex.ast.ASTSoqlExpression;
import net.sourceforge.pmd.lang.apex.ast.ASTSoslExpression;
import net.sourceforge.pmd.lang.apex.ast.ASTUserClass;
import net.sourceforge.pmd.lang.apex.ast.ASTUserTrigger;
import net.sourceforge.pmd.lang.apex.ast.ASTVariableExpression;
import net.sourceforge.pmd.lang.apex.ast.AbstractDmlStatement;
import net.sourceforge.pmd.lang.apex.ast.ApexNode;
import net.sourceforge.pmd.lang.apex.rule.AbstractApexRule;
import net.sourceforge.pmd.properties.PropertyDescriptor;
import net.sourceforge.pmd.properties.PropertyFactory;

/**
 * A system-mode database operation bypasses object and field permissions, so
 * using system mode must be a deliberate, reviewed choice. One rule covers
 * every kind: inline SOQL and SOSL (WITH SYSTEM_MODE), the Database and Search
 * methods given AccessLevel.SYSTEM_MODE, and DML run "as system". DML is the
 * part only Java can see: the parser records the mode but does not expose it
 * to XPath (pmd/pmd#7108). Test classes are included, because a test's
 * database operations should run as a user the test creates; set the
 * {@code reportInTestClasses} property to false to skip them.
 */
public class FlagSystemModeRule extends AbstractApexRule {

    private static final PropertyDescriptor<Boolean> REPORT_IN_TEST_CLASSES = PropertyFactory.booleanProperty("reportInTestClasses")
        .desc("If false, system-mode operations in test classes are not flagged.")
        .defaultValue(true)
        .build();

    private static final Pattern SYSTEM_MODE_CLAUSE = Pattern.compile("WITH\\s+SYSTEM_MODE", Pattern.CASE_INSENSITIVE);

    private static final Pattern DATABASE_OR_SEARCH_METHOD = Pattern.compile("^(database|search)\\.");

    public FlagSystemModeRule() {
        definePropertyDescriptor(REPORT_IN_TEST_CLASSES);
    }

    @Override
    public Object visit(ASTUserClass node, Object data) {
        if (!node.isNested() && (getProperty(REPORT_IN_TEST_CLASSES) || !node.getModifiers().isTest())) {
            reportSystemMode(node, data);
        }
        return super.visit(node, data);
    }

    @Override
    public Object visit(ASTUserTrigger node, Object data) {
        reportSystemMode(node, data);
        return super.visit(node, data);
    }

    /** Reports every system-mode operation under the node, inner classes included. */
    private void reportSystemMode(ApexNode<?> root, Object data) {
        // [SELECT Id FROM Account WITH SYSTEM_MODE]
        for (ASTSoqlExpression soql : root.descendants(ASTSoqlExpression.class).crossFindBoundaries()) {
            if (SYSTEM_MODE_CLAUSE.matcher(soql.getQuery()).find()) {
                asCtx(data).addViolation(soql);
            }
        }
        // [FIND 'x' IN ALL FIELDS RETURNING Account WITH SYSTEM_MODE]
        for (ASTSoslExpression sosl : root.descendants(ASTSoslExpression.class).crossFindBoundaries()) {
            if (SYSTEM_MODE_CLAUSE.matcher(sosl.getQuery()).find()) {
                asCtx(data).addViolation(sosl);
            }
        }
        // Database.insert(accts, AccessLevel.SYSTEM_MODE), Search.query(sosl, AccessLevel.SYSTEM_MODE)
        for (ASTMethodCallExpression call : root.descendants(ASTMethodCallExpression.class).crossFindBoundaries()) {
            String name = call.getFullMethodName().toLowerCase(Locale.ROOT);
            if (DATABASE_OR_SEARCH_METHOD.matcher(name).find() && passesSystemMode(call)) {
                asCtx(data).addViolation(call);
            }
        }
        // insert as system accts; and the same for update, upsert, delete, undelete and merge
        for (AbstractDmlStatement dml : root.descendants(AbstractDmlStatement.class).crossFindBoundaries()) {
            if (dml.getRunAsMode().filter(mode -> mode.toUpperCase(Locale.ROOT).contains("SYSTEM")).isPresent()) {
                asCtx(data).addViolation(dml);
            }
        }
    }

    /** True when AccessLevel.SYSTEM_MODE is an argument of this call itself, not of a call nested inside it. */
    private static boolean passesSystemMode(ASTMethodCallExpression call) {
        return call.descendants(ASTVariableExpression.class)
            .any(variable -> "SYSTEM_MODE".equalsIgnoreCase(variable.getImage())
                && variable.children(ASTReferenceExpression.class).any(ref -> "accesslevel".equalsIgnoreCase(ref.getImage()))
                && variable.ancestors(ASTMethodCallExpression.class).first() == call);
    }
}
FlagSystemMode.xml
<?xml version="1.0" encoding="UTF-8" ?>
<test-data
    xmlns="http://pmd.sourceforge.net/rule-tests"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://pmd.sourceforge.net/rule-tests http://pmd.sourceforge.net/rule-tests_1_0_0.xsd">
    <test-code>
        <description>Every system-mode operation is flagged: SOQL, SOSL, a Database method, and DML; user-mode and unstated are not this rule's</description>
        <expected-problems>4</expected-problems>
        <expected-linenumbers>3,4,5,6</expected-linenumbers>
        <code><![CDATA[
public with sharing class Foo {
    public void run(List<Account> accts) {
        List<Account> a = [SELECT Id FROM Account WITH SYSTEM_MODE];
        List<List<SObject>> s = [FIND 'x' IN ALL FIELDS RETURNING Account(Id) WITH SYSTEM_MODE];
        Database.insert(accts, AccessLevel.SYSTEM_MODE);
        update as system accts;
        List<Account> b = [SELECT Id FROM Account WITH USER_MODE];
        Database.update(accts, AccessLevel.USER_MODE);
        insert as user accts;
        List<Account> c = [SELECT Id FROM Account];
        delete accts;
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>Database.query and Search.query given SYSTEM_MODE are flagged; a call is flagged for its own argument, not for a call nested inside it</description>
        <expected-problems>3</expected-problems>
        <expected-linenumbers>3,4,5</expected-linenumbers>
        <code><![CDATA[
public with sharing class Foo {
    public void run(String soql, String sosl) {
        List<Account> a = Database.query(soql, AccessLevel.SYSTEM_MODE);
        List<List<SObject>> s = Search.query(sosl, AccessLevel.SYSTEM_MODE);
        Database.update(Database.query(soql, AccessLevel.SYSTEM_MODE), AccessLevel.USER_MODE);
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>An inner class is reported once, with its outer class</description>
        <expected-problems>1</expected-problems>
        <expected-linenumbers>4</expected-linenumbers>
        <code><![CDATA[
public with sharing class Foo {
    private class Inner {
        private List<Account> read() {
            return [SELECT Id FROM Account WITH SYSTEM_MODE];
        }
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>Test classes are reported by default</description>
        <expected-problems>3</expected-problems>
        <expected-linenumbers>5,6,7</expected-linenumbers>
        <code><![CDATA[
@IsTest
private with sharing class FooTest {
    @IsTest
    private static void run() {
        List<Account> a = [SELECT Id FROM Account WITH SYSTEM_MODE];
        Database.insert(new Account(Name = 'a'), AccessLevel.SYSTEM_MODE);
        insert as system new Account(Name = 'b');
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>reportInTestClasses false skips test classes, inner classes included</description>
        <rule-property name="reportInTestClasses">false</rule-property>
        <expected-problems>0</expected-problems>
        <code><![CDATA[
@IsTest
private with sharing class FooTest {
    @IsTest
    private static void run() {
        List<Account> a = [SELECT Id FROM Account WITH SYSTEM_MODE];
        Database.insert(new Account(Name = 'a'), AccessLevel.SYSTEM_MODE);
        insert as system new Account(Name = 'b');
    }

    private class Helper {
        private List<Account> read() {
            return [SELECT Id FROM Account WITH SYSTEM_MODE];
        }
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>reportInTestClasses false still reports other classes</description>
        <rule-property name="reportInTestClasses">false</rule-property>
        <expected-problems>1</expected-problems>
        <expected-linenumbers>3</expected-linenumbers>
        <code><![CDATA[
public with sharing class Foo {
    public void run(List<Account> accts) {
        update as system accts;
    }
}
        ]]></code>
    </test-code>
</test-data>

RequireAccessMode

Every database operation states its access mode: inline SOQL and SOSL (WITH USER_MODE), the Database and Search methods that accept an AccessLevel, and DML (insert as user), including in test classes. The mode that an unstated operation runs in depends on the API version of its class, so the rule has the author write it, at any version (Apex: Be Explicit Everywhere). Priority 2: a missing access mode blocks the pull request.

One rule covers all DML operations, required because XPath cannot see DML statements. Each violation carries a message for its kind of operation, which names what to add.

RequireAccessModeRule.java
package devsecops.pmd.rules;

import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;

import net.sourceforge.pmd.lang.apex.ast.ASTMethodCallExpression;
import net.sourceforge.pmd.lang.apex.ast.ASTReferenceExpression;
import net.sourceforge.pmd.lang.apex.ast.ASTSoqlExpression;
import net.sourceforge.pmd.lang.apex.ast.ASTSoslExpression;
import net.sourceforge.pmd.lang.apex.ast.ASTUserClass;
import net.sourceforge.pmd.lang.apex.ast.ASTUserTrigger;
import net.sourceforge.pmd.lang.apex.ast.AbstractDmlStatement;
import net.sourceforge.pmd.lang.apex.ast.ApexNode;
import net.sourceforge.pmd.lang.apex.rule.AbstractApexRule;

/**
 * Every database operation states its access mode: inline SOQL and SOSL, the
 * Database and Search methods that accept an AccessLevel, and DML statements,
 * including in test classes. The mode an unstated operation runs in depends on
 * the API version of its class, so the rule makes the author write it, and a
 * reader never has to look the version up. DML is the part only Java can see:
 * the parser records the mode but does not expose it to XPath (pmd/pmd#7108).
 */
public class RequireAccessModeRule extends AbstractApexRule {

    private static final Pattern ACCESS_MODE_CLAUSE = Pattern.compile("WITH\\s+(USER_MODE|SYSTEM_MODE|SECURITY_ENFORCED)", Pattern.CASE_INSENSITIVE);

    private static final Set<String> ACCESS_LEVEL_METHODS = Set.of(
        "database.query", "database.getquerylocator", "database.countquery", "database.getcursor",
        "database.getpaginationcursor", "search.query", "database.convertlead");

    private static final Pattern ACCESS_LEVEL_DML_METHODS = Pattern.compile("^database\\.(insert|update|upsert|merge|delete|undelete)(immediate|async)?$");

    @Override
    public Object visit(ASTUserClass node, Object data) {
        if (!node.isNested()) {
            reportMissingAccessModes(node, data);
        }
        return super.visit(node, data);
    }

    @Override
    public Object visit(ASTUserTrigger node, Object data) {
        reportMissingAccessModes(node, data);
        return super.visit(node, data);
    }

    /**
     * Reports every database operation under the node that does not state its
     * access mode: inline SOQL and SOSL without a WITH USER_MODE or WITH
     * SYSTEM_MODE clause, Database and Search methods that accept an AccessLevel
     * but are not given one, and DML statements without "as user" or "as system".
     * The search crosses into inner classes, which PMD's descendants() stops at
     * unless told otherwise, so an inner class is checked with its outer class.
     */
    private void reportMissingAccessModes(ApexNode<?> root, Object data) {
        // [SELECT Id FROM Account] with no WITH clause for the mode
        for (ASTSoqlExpression soql : root.descendants(ASTSoqlExpression.class).crossFindBoundaries()) {
            if (!ACCESS_MODE_CLAUSE.matcher(soql.getQuery()).find()) {
                asCtx(data).addViolationWithMessage(soql, "State the access mode on this query: add WITH USER_MODE");
            }
        }
        // [FIND 'x' IN ALL FIELDS RETURNING Account] with no WITH clause for the mode
        for (ASTSoslExpression sosl : root.descendants(ASTSoslExpression.class).crossFindBoundaries()) {
            if (!ACCESS_MODE_CLAUSE.matcher(sosl.getQuery()).find()) {
                asCtx(data).addViolationWithMessage(sosl, "State the access mode on this search: add WITH USER_MODE");
            }
        }
        // Database.query(soql), Database.insert(accts): no AccessLevel argument
        for (ASTMethodCallExpression call : root.descendants(ASTMethodCallExpression.class).crossFindBoundaries()) {
            String name = call.getFullMethodName().toLowerCase(Locale.ROOT);
            boolean takesAccessLevel = ACCESS_LEVEL_METHODS.contains(name) || ACCESS_LEVEL_DML_METHODS.matcher(name).matches();
            if (takesAccessLevel && !passesAccessLevel(call)) {
                asCtx(data).addViolationWithMessage(call, "State the access mode on this method call: pass AccessLevel.USER_MODE");
            }
        }
        // insert accts; with neither "as user" nor "as system", and the same for the other DML keywords
        for (AbstractDmlStatement dml : root.descendants(AbstractDmlStatement.class).crossFindBoundaries()) {
            if (dml.getRunAsMode().isEmpty()) {
                asCtx(data).addViolationWithMessage(dml, "State the access mode on this DML statement: " + keyword(dml) + " as user");
            }
        }
    }

    /**
     * The keyword of a DML statement, such as "insert" or "delete", so that the
     * message names the statement the developer wrote. PMD names each node for
     * its keyword: DmlInsertStatement, DmlDeleteStatement, and so on.
     */
    private static String keyword(AbstractDmlStatement dml) {
        String name = dml.getXPathNodeName();
        return name.substring("Dml".length(), name.length() - "Statement".length()).toLowerCase(Locale.ROOT);
    }

    private static boolean passesAccessLevel(ASTMethodCallExpression call) {
        return call.descendants(ASTReferenceExpression.class)
            .any(ref -> "accesslevel".equalsIgnoreCase(ref.getImage()));
    }
}
RequireAccessMode.xml
<?xml version="1.0" encoding="UTF-8" ?>
<test-data
    xmlns="http://pmd.sourceforge.net/rule-tests"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://pmd.sourceforge.net/rule-tests http://pmd.sourceforge.net/rule-tests_1_0_0.xsd">
    <test-code>
        <description>Every operation without a mode is reported: SOQL, SOSL, Database.query, DML, and DML after other code on the same line</description>
        <expected-problems>5</expected-problems>
        <expected-linenumbers>3,4,5,6,7</expected-linenumbers>
        <code><![CDATA[
public with sharing class Foo {
    public void run(List<Account> accts, Boolean done) {
        List<Account> a = [SELECT Id FROM Account];
        List<List<SObject>> s = [FIND 'x' IN ALL FIELDS RETURNING Account(Id)];
        List<Account> q = Database.query('SELECT Id FROM Account');
        insert accts;
        if (done) { update accts; }
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>Every operation with a mode passes</description>
        <expected-problems>0</expected-problems>
        <code><![CDATA[
public with sharing class Foo {
    public void run(List<Account> accts) {
        List<Account> a = [SELECT Id FROM Account WITH USER_MODE];
        List<Account> q = Database.query('SELECT Id FROM Account', AccessLevel.USER_MODE);
        Database.SaveResult[] r = Database.insert(accts, false, AccessLevel.USER_MODE);
        insert as user accts;
        update as system accts;
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>A test class is held to the same rule</description>
        <expected-problems>4</expected-problems>
        <expected-linenumbers>5,6,7,8</expected-linenumbers>
        <code><![CDATA[
@IsTest
private with sharing class FooTest {
    @IsTest
    private static void run() {
        List<Account> a = [SELECT Id FROM Account];
        List<List<SObject>> s = [FIND 'x' IN ALL FIELDS RETURNING Account(Id)];
        List<Account> q = Database.query('SELECT Id FROM Account');
        insert a;
        List<Account> ok1 = [SELECT Id FROM Account WITH USER_MODE];
        List<Account> ok2 = Database.query('SELECT Id FROM Account', AccessLevel.USER_MODE);
        update as user a;
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>A trigger body is checked as a class is</description>
        <expected-problems>2</expected-problems>
        <expected-linenumbers>2,3</expected-linenumbers>
        <code><![CDATA[
trigger FooTrigger on Account (before insert) {
    List<Account> a = [SELECT Id FROM Account];
    update a;
    List<Account> ok = [SELECT Id FROM Account WITH USER_MODE];
}
        ]]></code>
    </test-code>
    <test-code>
        <description>An inner class is checked with its outer class, and reported once</description>
        <expected-problems>2</expected-problems>
        <expected-linenumbers>4,5</expected-linenumbers>
        <code><![CDATA[
public with sharing class Foo {
    private class Inner {
        private List<Account> read(List<Account> accts) {
            update accts;
            return [SELECT Id FROM Account];
        }
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>The message names the DML statement that was written</description>
        <expected-problems>6</expected-problems>
        <expected-messages>
            <message>State the access mode on this DML statement: insert as user</message>
            <message>State the access mode on this DML statement: update as user</message>
            <message>State the access mode on this DML statement: upsert as user</message>
            <message>State the access mode on this DML statement: delete as user</message>
            <message>State the access mode on this DML statement: undelete as user</message>
            <message>State the access mode on this DML statement: merge as user</message>
        </expected-messages>
        <code><![CDATA[
public with sharing class Foo {
    public void run(List<Account> accts, Account master, Account duplicate) {
        insert accts;
        update accts;
        upsert accts;
        delete accts;
        undelete accts;
        merge master duplicate;
    }
}
        ]]></code>
    </test-code>
</test-data>

NoCredentialNamedField

A credential does not belong in a field of any object (Objects and Fields: Secrets in Fields). The stock Code Analyzer rule ProtectSensitiveData reports a credential-named field on a custom object only: it reads the object's name from the folder the field's file sits in, and reports when that name ends in __c, so Api_Token__c on Account is never reported. This rule makes the same check on every object, standard or custom, and reports only a custom field, one whose own name ends in __c. It also reports the field on a custom metadata type or a custom setting, whatever its visibility: outside a managed package, a protected custom metadata type behaves like a public one, so a secret is kept in an External Credential. Priority 2: the field blocks the pull request.

It is Java because the object's name is in the file's path and not in the file: an XPath rule sees the field's XML alone. The rule reads the field's name, never its value, so it is a screen with the false alarms of one: Credit_Limit__c is reported for CRED, and Assigned_To__c for SIGN, and the owning team accepts the report. The credentialWords property holds the words, which are the stock rule's; set it in the ruleset to narrow or widen the screen.

The word list and the shape of the object check are adapted from the stock rule's source, under its BSD 3-Clause licence, which THIRD_PARTY_LICENSES.md in the project carries.

NoCredentialNamedFieldRule.java
package devsecops.pmd.rules;

import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Locale;

import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathFactory;

import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

import net.sourceforge.pmd.lang.ast.Node;
import net.sourceforge.pmd.lang.xml.rule.AbstractXmlRule;
import net.sourceforge.pmd.properties.PropertyDescriptor;
import net.sourceforge.pmd.properties.PropertyFactory;
import net.sourceforge.pmd.reporting.RuleContext;

/**
 * A credential does not belong in a field of any object. This rule reports a
 * custom field whose name suggests a credential, on every object, standard or
 * custom. The stock Code Analyzer rule ProtectSensitiveData makes the same
 * check and reports only a field of a custom object, because it reads the
 * object's name and reports when that name ends in __c, so a field such as
 * Api_Token__c on Account is never reported by it. A field on a custom
 * metadata type or a custom setting is reported too, whatever the type's
 * visibility: outside a managed package, a protected custom metadata type
 * behaves like a public one, and a secret is kept in an External Credential.
 *
 * The rule reads source format only: objects/ObjectName/fields/Field__c.field-meta.xml,
 * with the object's own file beside the fields folder. It reads the field's
 * name, never its value, so it has the false alarms of a screen: a name that
 * contains one of its words, such as Assigned_To__c (SIGN) or Credit_Limit__c
 * (CRED), is reported, and the owning team accepts the report. The
 * {@code credentialWords} property holds the words; change it to narrow or widen
 * the screen.
 *
 * Adapted from DetectSecretsInCustomObjects.java and SecretsInPackageUtils.java
 * in forcedotcom/code-analyzer-core, Copyright (c) 2024, Salesforce.com, Inc.,
 * under the BSD 3-Clause License; the licence is in THIRD_PARTY_LICENSES.md
 * beside this project's pom.xml. The word list is theirs. The object check is
 * this rule's own: every object, not custom objects alone.
 */
public class NoCredentialNamedFieldRule extends AbstractXmlRule {

    private static final String FIELD_FILE_SUFFIX = ".field-meta.xml";
    private static final String OBJECT_FILE_SUFFIX = ".object-meta.xml";
    private static final String CUSTOM_SUFFIX = "__c";
    private static final String CUSTOM_METADATA_SUFFIX = "__mdt";

    private static final PropertyDescriptor<List<String>> CREDENTIAL_WORDS = PropertyFactory.stringListProperty("credentialWords")
        .desc("The words that make a field's name suggest a credential; the comparison ignores case.")
        .defaultValues("KEY", "ACCESS", "PASS", "ENCRYPT", "TOKEN", "HASH", "SECRET", "SIGNATURE", "SIGN", "AUTH", "AUTHORIZATION",
            "AUTHENTICATION", "AUTHENTICATE", "BEARER", "CRED", "REFRESH", "CERT", "PRIVATE", "PUBLIC", "JWT")
        .build();

    public NoCredentialNamedFieldRule() {
        definePropertyDescriptor(CREDENTIAL_WORDS);
    }

    @Override
    public void apply(Node target, RuleContext ctx) {
        String fieldFile = target.getReportLocation().getFileId().getAbsolutePath();
        if (!fieldFile.endsWith(FIELD_FILE_SUFFIX)) {
            return;
        }
        String fieldName = new File(fieldFile).getName().replaceFirst(FIELD_FILE_SUFFIX + "$", "");
        if (!fieldName.endsWith(CUSTOM_SUFFIX) || !suggestsCredential(fieldName)) {
            return;
        }
        File objectDir = new File(fieldFile).getParentFile() == null ? null : new File(fieldFile).getParentFile().getParentFile();
        String objectName = objectDir == null ? "" : objectDir.getName();
        File objectFile = objectDir == null ? null : new File(objectDir, objectName + OBJECT_FILE_SUFFIX);
        if (objectName.endsWith(CUSTOM_METADATA_SUFFIX)) {
            ctx.addViolationWithMessage(target, fieldName + " suggests a credential, on the " + objectName + " custom metadata type");
        } else if (isCustomSetting(objectFile)) {
            ctx.addViolationWithMessage(target, fieldName + " suggests a credential, on the " + objectName + " custom setting");
        } else {
            ctx.addViolationWithMessage(target, fieldName + " suggests a credential, on the " + objectName + " object");
        }
    }

    private boolean suggestsCredential(String fieldName) {
        String upper = fieldName.toUpperCase(Locale.ROOT);
        return getProperty(CREDENTIAL_WORDS).stream().anyMatch(word -> upper.contains(word.toUpperCase(Locale.ROOT)));
    }

    private static boolean isCustomSetting(File objectFile) {
        return hasNode(objectFile, "/CustomObject/customSettingsType");
    }

    /** True when the object's file exists and the XPath finds a node in it; false when the file is missing or unreadable. */
    private static boolean hasNode(File objectFile, String xpath) {
        if (objectFile == null || !objectFile.isFile()) {
            return false;
        }
        try {
            Document document = parse(objectFile);
            NodeList nodes = (NodeList) XPathFactory.newInstance().newXPath().compile(xpath).evaluate(document, XPathConstants.NODESET);
            return nodes.getLength() > 0;
        } catch (ParserConfigurationException | SAXException | IOException | javax.xml.xpath.XPathExpressionException e) {
            return false;
        }
    }

    private static Document parse(File file) throws ParserConfigurationException, SAXException, IOException {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
        factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
        factory.setExpandEntityReferences(false);
        return factory.newDocumentBuilder().parse(file);
    }
}

PMD's rule-test harness checks a snippet with no path, so it cannot test this rule. Its test runs PMD on a fixture tree of objects and fields and checks which fields are reported: the credential name on a standard object, the screen's false alarm, the custom object field, the fields on a public and a protected custom metadata type, and the field on a protected custom setting, and not the plain name or the standard field.

NoCredentialNamedFieldRuleTest.java
package devsecops.pmd.rules;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.stream.Collectors;

import org.junit.jupiter.api.Test;

import net.sourceforge.pmd.PMDConfiguration;
import net.sourceforge.pmd.PmdAnalysis;
import net.sourceforge.pmd.reporting.Report;
import net.sourceforge.pmd.reporting.RuleViolation;

/**
 * The rule reads a field's object from the file's path, so PMD's rule-test
 * harness, which checks a snippet with no path, cannot test it. This test runs
 * PMD on the fixture tree under src/test/resources/devsecops/pmd/rules/fixtures
 * and checks which fields it reports.
 */
class NoCredentialNamedFieldRuleTest {

    @Test
    void reportsACredentialNamedCustomFieldOnAnyObjectAndAnyCustomMetadataTypeOrCustomSetting() throws Exception {
        Path fixtures = Paths.get(getClass().getResource("/devsecops/pmd/rules/fixtures/objects").toURI());
        PMDConfiguration config = new PMDConfiguration();
        config.addRuleSet("rulesets/devsecops-java.xml");
        config.addInputPath(fixtures);
        config.setIgnoreIncrementalAnalysis(true);
        List<String> reported;
        try (PmdAnalysis pmd = PmdAnalysis.create(config)) {
            Report report = pmd.performAnalysisAndCollectReport();
            reported = report.getViolations().stream()
                .filter(v -> "NoCredentialNamedField".equals(v.getRule().getName()))
                .map(RuleViolation::getFileId)
                .map(id -> id.getFileName())
                .sorted()
                .collect(Collectors.toList());
        }
        // Reported: a credential name on a standard object (Api_Token__c), the screen's false alarm (Credit_Limit__c, CRED),
        // a custom object (Secret_Key__c), a public and a protected custom metadata type (Auth_Token__c on Setting__mdt and
        // on Private__mdt), and a protected custom setting (Password__c on Config__c). Not reported: a plain name and a
        // standard field (Name).
        assertEquals(List.of("Api_Token__c.field-meta.xml", "Auth_Token__c.field-meta.xml", "Auth_Token__c.field-meta.xml", "Credit_Limit__c.field-meta.xml", "Password__c.field-meta.xml", "Secret_Key__c.field-meta.xml"), reported);
    }
}

RequireInvocableNoArgConstructor

From API version 66.0, a class with an @InvocableVariable member and an explicit constructor also has a visible no-argument constructor: public in a class outside a package, and global in a global class (Apex: Constructors). A Flow creates the class through that constructor, and the compiler accepts the class without it, so a missing one is found only when a Flow uses the class. Priority 1: from API 66.0 the requirement does not bend.

The stock rule InvocableClassNoArgConstructor reports the same classes at every API version, at priority 2, because it cannot read the version. This rule reads it from the class's -meta.xml file and reports only from the version in its minApiVersion property, 66 by default. A class whose version cannot be read, because no -meta.xml sits beside it, is not reported here, and the stock rule still reports it. So a class at API 66.0 or later is reported twice, on the same line: at priority 1 by this rule (with its API check) and at priority 2 by the stock rule (which is not API-aware). The two reports describe one fault, and one no-argument constructor clears both. A constructor with no modifier is private in Apex, so it does not count as visible.

It is Java because the version is in the -meta.xml file and not in the class: an XPath rule sees the Apex class alone. The reader of the version is a Java class of its own, to allow reuse (a code principle).

RequireInvocableNoArgConstructorRule.java
package devsecops.pmd.rules;

import java.util.List;
import java.util.OptionalInt;

import net.sourceforge.pmd.lang.apex.ast.ASTAnnotation;
import net.sourceforge.pmd.lang.apex.ast.ASTField;
import net.sourceforge.pmd.lang.apex.ast.ASTMethod;
import net.sourceforge.pmd.lang.apex.ast.ASTModifierNode;
import net.sourceforge.pmd.lang.apex.ast.ASTProperty;
import net.sourceforge.pmd.lang.apex.ast.ASTUserClass;
import net.sourceforge.pmd.lang.apex.rule.AbstractApexRule;
import net.sourceforge.pmd.properties.PropertyDescriptor;
import net.sourceforge.pmd.properties.PropertyFactory;

/**
 * From API version 66.0, a class used for invocable action parameters must have
 * a visible no-argument constructor: public in a class outside a package, global
 * in a global class. A class with an {@code @InvocableVariable} member and an
 * explicit constructor has no default one, and the compiler accepts it without
 * the no-argument constructor, so the fault shows only when a Flow uses it.
 * The stock rule InvocableClassNoArgConstructor reports the same classes at
 * every version, because it cannot read the version; this rule reads it from the
 * {@code -meta.xml} beside the class and reports only from the version set in
 * minApiVersion, so that it can carry a higher priority there. A class whose
 * version cannot be read is left to the stock rule.
 */
public class RequireInvocableNoArgConstructorRule extends AbstractApexRule {

    private static final PropertyDescriptor<Integer> MIN_API_VERSION = PropertyFactory.intProperty("minApiVersion")
        .desc("The first API version at which a class with @InvocableVariable members needs a visible no-argument constructor")
        .defaultValue(66)
        .build();

    private static final PropertyDescriptor<Integer> ASSUMED_API_VERSION = PropertyFactory.intProperty("assumedApiVersion")
        .desc("API version to assume when no -meta.xml is found beside the class (0 means unknown, and the class is not reported)")
        .defaultValue(0)
        .build();

    public RequireInvocableNoArgConstructorRule() {
        definePropertyDescriptor(MIN_API_VERSION);
        definePropertyDescriptor(ASSUMED_API_VERSION);
    }

    @Override
    public Object visit(ASTUserClass node, Object data) {
        if (hasInvocableVariable(node) && isAtOrAboveMinVersion(node) && lacksVisibleNoArgConstructor(node)) {
            asCtx(data).addViolation(node);
        }
        return super.visit(node, data);
    }

    /** True when the class's API version is known and is at least minApiVersion. */
    private boolean isAtOrAboveMinVersion(ASTUserClass node) {
        OptionalInt fromMeta = ApexMetaFile.apiVersion(node);
        int version = fromMeta.isPresent() ? fromMeta.getAsInt() : getProperty(ASSUMED_API_VERSION);
        return version > 0 && version >= getProperty(MIN_API_VERSION);
    }

    /**
     * True when the class declares a constructor and none of them is a visible
     * no-argument constructor. A class with no constructor has the default one,
     * which is visible. Only this class's own constructors count: PMD's
     * descendants() stops at a nested class, which is visited on its own.
     */
    private static boolean lacksVisibleNoArgConstructor(ASTUserClass node) {
        List<ASTMethod> constructors = node.descendants(ASTMethod.class).filter(ASTMethod::isConstructor).toList();
        if (constructors.isEmpty()) {
            return false;
        }
        boolean globalClass = isGlobal(node.firstChild(ASTModifierNode.class));
        for (ASTMethod constructor : constructors) {
            if (constructor.getArity() == 0 && isVisible(constructor.firstChild(ASTModifierNode.class), globalClass)) {
                return false;
            }
        }
        return true;
    }

    /** Public or global in a class outside a package; global in a global class. */
    private static boolean isVisible(ASTModifierNode modifiers, boolean globalClass) {
        if (modifiers == null) {
            return false;
        }
        return globalClass ? modifiers.isGlobal() : modifiers.isPublic() || modifiers.isGlobal();
    }

    private static boolean hasInvocableVariable(ASTUserClass node) {
        for (ASTField field : node.descendants(ASTField.class)) {
            if (hasInvocableAnnotation(field.getModifiers())) {
                return true;
            }
        }
        for (ASTProperty property : node.descendants(ASTProperty.class)) {
            if (hasInvocableAnnotation(property.getModifiers())) {
                return true;
            }
        }
        return false;
    }

    private static boolean hasInvocableAnnotation(ASTModifierNode modifiers) {
        if (modifiers == null) {
            return false;
        }
        for (ASTAnnotation annotation : modifiers.children(ASTAnnotation.class)) {
            if ("InvocableVariable".equalsIgnoreCase(annotation.getName())) {
                return true;
            }
        }
        return false;
    }

    private static boolean isGlobal(ASTModifierNode modifiers) {
        return modifiers != null && modifiers.isGlobal();
    }
}
ApexMetaFile.java
package devsecops.pmd.rules;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.OptionalInt;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import net.sourceforge.pmd.lang.apex.ast.ApexNode;

/**
 * Reads the API version of the class or trigger that contains a node, from the
 * {@code -meta.xml} file that sits beside the source file. PMD analyses the
 * {@code .cls} file alone; the version lives only in the companion file, which is
 * why these checks need Java rather than XPath.
 */
public final class ApexMetaFile {

    private static final Pattern API_VERSION = Pattern.compile("<apiVersion>\\s*(\\d+)(?:\\.\\d+)?\\s*</apiVersion>");

    private ApexMetaFile() {
    }

    /** The major API version from the companion file, or empty when the file is absent or has none. */
    public static OptionalInt apiVersion(ApexNode<?> node) {
        Path meta = Paths.get(node.getTextDocument().getFileId().getAbsolutePath() + "-meta.xml");
        if (!Files.isRegularFile(meta)) {
            return OptionalInt.empty();
        }
        try {
            Matcher m = API_VERSION.matcher(Files.readString(meta, StandardCharsets.UTF_8));
            return m.find() ? OptionalInt.of(Integer.parseInt(m.group(1))) : OptionalInt.empty();
        } catch (IOException e) {
            return OptionalInt.empty();
        }
    }
}

PMD's rule-test harness checks a snippet with no file, so the cases set the assumedApiVersion property in place of a -meta.xml file:

RequireInvocableNoArgConstructor.xml
<?xml version="1.0" encoding="UTF-8" ?>
<test-data
    xmlns="http://pmd.sourceforge.net/rule-tests"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://pmd.sourceforge.net/rule-tests http://pmd.sourceforge.net/rule-tests_1_0_0.xsd">
    <test-code>
        <description>At API 66.0, a class with an InvocableVariable and only a constructor with arguments is reported, and so is one whose no-argument constructor is private or has no modifier</description>
        <rule-property name="assumedApiVersion">66</rule-property>
        <expected-problems>3</expected-problems>
        <expected-linenumbers>2,9,17</expected-linenumbers>
        <code><![CDATA[
public with sharing class Requests {
    public class OnlyArgs {
        @InvocableVariable(label='Name' required=true)
        public String name;
        public OnlyArgs(String name) {
            this.name = name;
        }
    }
    public class PrivateNoArg {
        @InvocableVariable(label='Name')
        public String name;
        private PrivateNoArg() {
        }
        public PrivateNoArg(String name) {
        }
    }
    public class NoModifier {
        @InvocableVariable(label='Name')
        public String name;
        NoModifier() {
        }
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>At API 67.0, a class with a public no-argument constructor, a class with no constructor, and a class with no InvocableVariable are not reported</description>
        <rule-property name="assumedApiVersion">67</rule-property>
        <expected-problems>0</expected-problems>
        <code><![CDATA[
public with sharing class Requests {
    public class Both {
        @InvocableVariable(label='Name')
        public String name;
        public Both() {
        }
        public Both(String name) {
            this.name = name;
        }
    }
    public class NoConstructor {
        @InvocableVariable(label='Name')
        public String name;
    }
    public class NotInvocable {
        public String name;
        public NotInvocable(String name) {
            this.name = name;
        }
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>In a global class, the no-argument constructor is global; a private one is reported</description>
        <rule-property name="assumedApiVersion">66</rule-property>
        <expected-problems>1</expected-problems>
        <expected-linenumbers>1</expected-linenumbers>
        <code><![CDATA[
global with sharing class GlobalRequest {
    @InvocableVariable(label='Name')
    global String name;
    private GlobalRequest() {
    }
    global GlobalRequest(String name) {
        this.name = name;
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>Below API 66.0, and when the version is unknown, nothing is reported: the stock rule covers those classes</description>
        <rule-property name="assumedApiVersion">65</rule-property>
        <expected-problems>0</expected-problems>
        <code><![CDATA[
public with sharing class OnlyArgs {
    @InvocableVariable(label='Name' required=true)
    public String name;
    public OnlyArgs(String name) {
        this.name = name;
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>With no -meta.xml and no assumed version, the version is unknown and nothing is reported</description>
        <expected-problems>0</expected-problems>
        <code><![CDATA[
public with sharing class OnlyArgs {
    @InvocableVariable(label='Name' required=true)
    public String name;
    public OnlyArgs(String name) {
        this.name = name;
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>A property with InvocableVariable counts as an invocable member, as in the stock rule</description>
        <rule-property name="assumedApiVersion">66</rule-property>
        <expected-problems>1</expected-problems>
        <expected-linenumbers>1</expected-linenumbers>
        <code><![CDATA[
public with sharing class PropertyRequest {
    @InvocableVariable(label='Name')
    public String name { get; set; }
    public PropertyRequest(String name) {
        this.name = name;
    }
}
        ]]></code>
    </test-code>
    <test-code>
        <description>The minApiVersion property moves the first version reported</description>
        <rule-property name="assumedApiVersion">66</rule-property>
        <rule-property name="minApiVersion">67</rule-property>
        <expected-problems>0</expected-problems>
        <code><![CDATA[
public with sharing class OnlyArgs {
    @InvocableVariable(label='Name' required=true)
    public String name;
    public OnlyArgs(String name) {
        this.name = name;
    }
}
        ]]></code>
    </test-code>
</test-data>

A second test runs PMD on the same class at API 65.0, 66.0, and 67.0, each with its -meta.xml, and with no -meta.xml, and checks that only the classes at 66.0 and 67.0 are reported:

RequireInvocableNoArgConstructorRuleTest.java
package devsecops.pmd.rules;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.stream.Collectors;

import org.junit.jupiter.api.Test;

import net.sourceforge.pmd.PMDConfiguration;
import net.sourceforge.pmd.PmdAnalysis;
import net.sourceforge.pmd.reporting.Report;
import net.sourceforge.pmd.reporting.RuleViolation;

/**
 * The rule reads the class's API version from the -meta.xml beside it, which
 * PMD's rule-test harness, which checks a snippet with no file, cannot supply.
 * This test runs PMD on the fixture classes under
 * src/test/resources/devsecops/pmd/rules/fixtures/classes, the same class at
 * API 65.0, 66.0, and 67.0 and with no -meta.xml, and checks which it reports.
 */
class RequireInvocableNoArgConstructorRuleTest {

    @Test
    void reportsFromApi66AndLeavesAnOlderOrUnknownVersionToTheStockRule() throws Exception {
        Path fixtures = Paths.get(getClass().getResource("/devsecops/pmd/rules/fixtures/classes").toURI());
        PMDConfiguration config = new PMDConfiguration();
        config.addRuleSet("rulesets/devsecops-java.xml");
        config.addInputPath(fixtures);
        config.setIgnoreIncrementalAnalysis(true);
        List<String> reported;
        try (PmdAnalysis pmd = PmdAnalysis.create(config)) {
            Report report = pmd.performAnalysisAndCollectReport();
            reported = report.getViolations().stream()
                .filter(v -> "RequireInvocableNoArgConstructor".equals(v.getRule().getName()))
                .map(RuleViolation::getFileId)
                .map(id -> id.getFileName())
                .sorted()
                .collect(Collectors.toList());
        }
        // Reported: the class at API 66.0 and at 67.0. Not reported: the class at 65.0, and the one with no -meta.xml.
        assertEquals(List.of("Inv66.cls", "Inv67.cls"), reported);
    }
}

MultipleTriggersOnObject

Each object has at most one trigger (Apex: Triggers). When an object has two triggers for the same event, Salesforce does not guarantee the order in which they run (Triggers and Order of Execution), and a trigger framework loses its single entry point. Priority 1: a second trigger blocks the pull request.

It is Java because the other triggers are in other files: PMD gives a rule one file at a time. When the rule visits a trigger, it finds the project, the nearest folder above the trigger with an sfdx-project.json file, and reads the header of every .trigger file below it, so that every package directory is searched. With no sfdx-project.json, it reads the trigger's own folder. It skips hidden folders and node_modules, reads each project once, and removes comments before it reads a header, so a commented-out trigger does not count. It compares object names without regard to case, as Apex does. It reports each trigger on an object that has another, and names the others in the message. So a scan of the changed files reports a new trigger alone, and a full scan also reports the trigger that was there first.

A run of PMD with its analysis cache (--cache) takes a file that did not change from the cache. So when a second trigger is added, a cached run reports the new trigger and not the first one.

A trigger whose name contains two underscores (ns__AccountTrigger) is from a managed package. Salesforce does not allow two underscores in a row in a name that a developer gives a trigger, so such a trigger is never reported and never counts as a second trigger. A trigger that is only in the org, and not in the repository, is outside any static check.

MultipleTriggersOnObjectRule.java
package devsecops.pmd.rules;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import net.sourceforge.pmd.lang.apex.ast.ASTUserTrigger;
import net.sourceforge.pmd.lang.apex.rule.AbstractApexRule;

/**
 * Each object has at most one trigger. When an object has two, the order in
 * which they run is not guaranteed, and a trigger framework's single entry
 * point is lost. PMD checks one file at a time, so this rule reads the header
 * of every other trigger in the project: the project is the nearest folder
 * above the trigger that holds an {@code sfdx-project.json}, so every package
 * directory is searched, or the trigger's own folder when there is none. The
 * read changes nothing. A trigger whose name contains {@code __} is from a
 * managed package: it is never reported and never counts as a second trigger.
 */
public class MultipleTriggersOnObjectRule extends AbstractApexRule {

    private static final Pattern COMMENT = Pattern.compile("/\\*.*?\\*/|//[^\\n]*", Pattern.DOTALL);
    private static final Pattern HEADER = Pattern.compile("\\btrigger\\s+(\\w+)\\s+on\\s+(\\w+)\\s*\\(", Pattern.CASE_INSENSITIVE);
    private static final String MANAGED_MARK = "__";

    /** The triggers of each project folder by object, lower case, read once for each folder. */
    private final Map<Path, Map<String, List<TriggerFile>>> triggersByRoot = new ConcurrentHashMap<>();

    @Override
    public Object visit(ASTUserTrigger node, Object data) {
        if (!node.getSimpleName().contains(MANAGED_MARK)) {
            Path file = Paths.get(node.getTextDocument().getFileId().getAbsolutePath()).toAbsolutePath().normalize();
            List<String> others = otherTriggers(file, node.getTargetName());
            if (!others.isEmpty()) {
                asCtx(data).addViolation(node, node.getTargetName(), String.join(", ", others));
            }
        }
        return super.visit(node, data);
    }

    /** The names of the other triggers on the same object, managed package triggers left out. */
    private List<String> otherTriggers(Path file, String object) {
        Path folder = file.getParent();
        if (folder == null || !Files.isDirectory(folder)) {
            return List.of();
        }
        Map<String, List<TriggerFile>> byObject = triggersByRoot.computeIfAbsent(projectRoot(folder), MultipleTriggersOnObjectRule::readTriggers);
        return byObject.getOrDefault(object.toLowerCase(Locale.ROOT), List.of()).stream()
            .filter(t -> !t.path.equals(file) && !t.name.contains(MANAGED_MARK))
            .map(t -> t.name)
            .sorted(String.CASE_INSENSITIVE_ORDER)
            .collect(Collectors.toList());
    }

    /** The nearest folder at or above the trigger's folder with an sfdx-project.json, or the trigger's folder. */
    private static Path projectRoot(Path folder) {
        for (Path dir = folder; dir != null; dir = dir.getParent()) {
            if (Files.isRegularFile(dir.resolve("sfdx-project.json"))) {
                return dir;
            }
        }
        return folder;
    }

    /** Every .trigger file below the root, by object; hidden folders and node_modules are skipped. */
    private static Map<String, List<TriggerFile>> readTriggers(Path root) {
        Map<String, List<TriggerFile>> byObject = new HashMap<>();
        try {
            Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
                @Override
                public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
                    String name = dir.getFileName() == null ? "" : dir.getFileName().toString();
                    boolean skip = !dir.equals(root) && (name.startsWith(".") || "node_modules".equals(name));
                    return skip ? FileVisitResult.SKIP_SUBTREE : FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) {
                    if (path.getFileName().toString().endsWith(".trigger")) {
                        readHeader(path).ifPresent(t -> byObject.computeIfAbsent(t.object, k -> new ArrayList<>()).add(t));
                    }
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult visitFileFailed(Path path, IOException e) {
                    return FileVisitResult.CONTINUE;
                }
            });
        } catch (IOException e) {
            // A folder that cannot be read adds no trigger.
        }
        return byObject;
    }

    /** The trigger's name and object from its header, read after the comments are removed. */
    private static Optional<TriggerFile> readHeader(Path path) {
        try {
            String source = COMMENT.matcher(Files.readString(path, StandardCharsets.UTF_8)).replaceAll(" ");
            Matcher m = HEADER.matcher(source);
            if (m.find()) {
                return Optional.of(new TriggerFile(path.toAbsolutePath().normalize(), m.group(1), m.group(2).toLowerCase(Locale.ROOT)));
            }
        } catch (IOException e) {
            // A file that cannot be read adds no trigger.
        }
        return Optional.empty();
    }

    private static final class TriggerFile {
        private final Path path;
        private final String name;
        private final String object;

        private TriggerFile(Path path, String name, String object) {
            this.path = path;
            this.name = name;
            this.object = object;
        }
    }
}

PMD's rule-test harness checks a snippet with no file, so it cannot test this rule. Its test runs PMD on a fixture project with two package directories and checks which triggers are reported and what each message says: the two triggers on Account, one in each package directory, one of them with the object in lower case. Not reported: a trigger on Contact whose comments name Account, a trigger on Lead whose only other trigger is a managed package trigger, and the two managed package triggers. A second test runs PMD on a folder with no sfdx-project.json.

MultipleTriggersOnObjectRuleTest.java
package devsecops.pmd.rules;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.stream.Collectors;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import net.sourceforge.pmd.PMDConfiguration;
import net.sourceforge.pmd.PmdAnalysis;
import net.sourceforge.pmd.reporting.Report;

/**
 * The rule reads the other trigger files of the project, which PMD's rule-test
 * harness, which checks a snippet with no file, cannot supply. The first test
 * runs PMD on the fixture project under
 * src/test/resources/devsecops/pmd/rules/fixtures/triggers, which has an
 * sfdx-project.json with two package directories. The second runs it on a
 * folder with no sfdx-project.json.
 */
class MultipleTriggersOnObjectRuleTest {

    @Test
    void reportsEachTriggerOnAnObjectWithAnotherAcrossPackageDirectories() throws Exception {
        Path fixtures = Paths.get(getClass().getResource("/devsecops/pmd/rules/fixtures/triggers").toURI());
        // Reported: the two triggers on Account, in two package directories, one of them naming the object in lower case.
        // Not reported: ContactTrigger, whose comments name Account; LeadTrigger, whose only other trigger is a managed
        // package trigger; and the two managed package triggers, which are not counted either.
        assertEquals(List.of(
            "AccountAudit.trigger: account has another trigger: AccountTrigger. Keep one trigger for each object",
            "AccountTrigger.trigger: Account has another trigger: AccountAudit. Keep one trigger for each object"),
            reported(fixtures));
    }

    @Test
    void searchesTheTriggersOwnFolderWhenThereIsNoProjectFile(@TempDir Path folder) throws Exception {
        Files.writeString(folder.resolve("CaseTrigger.trigger"), "trigger CaseTrigger on Case(before insert) {\n}\n");
        Files.writeString(folder.resolve("CaseAudit.trigger"), "trigger CaseAudit on Case(after insert) {\n}\n");
        assertEquals(List.of(
            "CaseAudit.trigger: Case has another trigger: CaseTrigger. Keep one trigger for each object",
            "CaseTrigger.trigger: Case has another trigger: CaseAudit. Keep one trigger for each object"),
            reported(folder));
    }

    private static List<String> reported(Path input) throws Exception {
        PMDConfiguration config = new PMDConfiguration();
        config.addRuleSet("rulesets/devsecops-java.xml");
        config.addInputPath(input);
        config.setIgnoreIncrementalAnalysis(true);
        try (PmdAnalysis pmd = PmdAnalysis.create(config)) {
            Report report = pmd.performAnalysisAndCollectReport();
            return report.getViolations().stream()
                .filter(v -> "MultipleTriggersOnObject".equals(v.getRule().getName()))
                .map(v -> v.getFileId().getFileName() + ": " + v.getDescription())
                .sorted()
                .collect(Collectors.toList());
        }
    }
}

Running the Tests

PMD's own harness, pmd-test, runs each XML file of cases against its rule: a snippet of Apex, the expected number of violations, and the expected line numbers. The test class lists the Apex rules, except MultipleTriggersOnObject, which has no XML cases; the XML files sit beside it under xml/. {N} rules have a test of their own, above, because they read a file other than the one PMD checks.

RulesTest.java
package devsecops.pmd.rules;

import net.sourceforge.pmd.test.SimpleAggregatorTst;

/**
 * Runs the XML test cases under src/test/resources/devsecops/pmd/rules/xml, one
 * file per rule, through PMD's own rule-test harness.
 */
class RulesTest extends SimpleAggregatorTst {

    private static final String RULESET = "rulesets/devsecops-java.xml";

    @Override
    protected void setUp() {
        addRule(RULESET, "RequireAccessMode");
        addRule(RULESET, "FlagSystemMode");
        addRule(RULESET, "RequireInvocableNoArgConstructor");
    }
}

mvn test runs all 19 cases and the four fixture tests. Writing the cases first is the fastest way to write a rule: each case is a sentence of the standard turned into code.