diff --git a/messages/DefaultRuleManager.js b/messages/DefaultRuleManager.js index 1f4fc5ede..aed6fcb9f 100644 --- a/messages/DefaultRuleManager.js +++ b/messages/DefaultRuleManager.js @@ -3,5 +3,8 @@ module.exports = { "targetSkipped": "The specified target wasn't processed by any engines. Use the --engine parameter to select a different engine or specify a different target. Specified target: %s.", "targetsSkipped": "The specified targets weren't processed by any engines: %s. Review your target and engine combinations and try again.", "pathsDoubleProcessed": "One or more files were processed by eslint and eslint-lwc simultaneously. To remove possible duplicate violations, customize the targetPatterns property for eslint and eslint-lwc engines in %s on these files: %s.", + }, + "error": { + "cannotRunDfaAndNonDfaConcurrently": "DFA engines %s cannot be run concurrently with non-DFA engines %s" } } diff --git a/messages/RunOutputProcessor.js b/messages/RunOutputProcessor.js new file mode 100644 index 000000000..7340a2432 --- /dev/null +++ b/messages/RunOutputProcessor.js @@ -0,0 +1,9 @@ +module.exports = { + "output": { + "engineSummaryTemplate": "Executed %s, found %s violation(s) across %s file(s).", + "noViolationsDetected": "Executed engines: %s. No rule violations found.", + "sevThresholdSummary": "Rule violations of severity %s or more severe were detected.", + "writtenToConsole": "Rule violations were logged to the console.", + "writtenToOutFile": "Rule violations were written to %s." + } +} diff --git a/messages/SfgeEngine.js b/messages/SfgeEngine.js index 719947304..1b3cd6ba3 100644 --- a/messages/SfgeEngine.js +++ b/messages/SfgeEngine.js @@ -1,4 +1,9 @@ module.exports = { - "spinnerStart": "Analyzing with Salesforce Graph Engine. See %s for details.", - "pleaseWait": "Please wait" + "messages": { + "pleaseWait": "Please wait", + "spinnerStart": "Analyzing with Salesforce Graph Engine. See %s for details." + }, + "errors": { + "failedWithoutProjectDir": `The --projectdir|-p flag is missing. Rerun your command with --projectdir|-p to allow Graph Engine to run, or with --engine|-e to exclude Graph Engine from execution.` + } }; diff --git a/messages/run-common.js b/messages/run-common.js new file mode 100644 index 000000000..5ee655cc6 --- /dev/null +++ b/messages/run-common.js @@ -0,0 +1,23 @@ +module.exports = { + "flags": { + "formatDescription": "specify results output format", + "formatDescriptionLong": "Specifies results output format written directly to the console.", + "normalizesevDescription": "return normalized severity 1 (high), 2 (moderate), and 3 (low), and the engine-specific severity", + "normalizesevDescriptionLong": "Returns normalized severity 1 (high), 2 (moderate), and 3 (low), and the engine-specific severity. For the html option, the normalized severity is displayed instead of the engine severity.", + "outfileDescription": "write output to a file", + "outfileDescriptionLong": "Writes output to a file.", + "projectdirDescription": "provide root directory of project", + "projectdirDescriptionLong": "Provides the relative or absolute root project directory used to set the context for Graph Engine's analysis. Project directory must be a path, not a glob. Specify multiple values as a comma-separated list.", + "sevthresholdDescription": "throw an error when a violation threshold is reached, the --normalize-severity is invoked, and severity levels are reset to the baseline", + "sevthresholdDescriptionLong": "Throws an error when violations are found with equal or greater severity than the provided value. Values are 1 (high), 2 (moderate), and 3 (low). Exit code is the most severe violation. Using this flag also invokes the --normalize-severity flag." + }, + "validations": { + "cannotWriteTableToFile": "Format 'table' can't be written to a file. Specify a different format.", + "outfileFormatMismatch": "The selected output format doesn't match the output file type. Output format: %s. Output file type: %s.", + "outfileMustBeValid": "--outfile must be a well-formed filepath.", + "outfileMustBeSupportedType": "--outfile must be of a supported type: .csv; .xml; .json; .html; .sarif.", + "projectdirCannotBeGlob": "--projectdir cannot specify globs", + "projectdirMustBeDir": "--projectdir must specify directories", + "projectdirMustExist": "--projectdir must specify existing paths" + } +} diff --git a/messages/run-dfa.js b/messages/run-dfa.js index 55740907c..414e0d08e 100644 --- a/messages/run-dfa.js +++ b/messages/run-dfa.js @@ -3,22 +3,12 @@ module.exports = { "commandDescriptionLong": `Scans codebase with all DFA rules by default. Specify the format of output and print results directly or as contents of a file that you provide with --outfile flag.`, "flags": { - "formatDescription": "specify results output format", - "formatDescriptionLong": "Specifies results output format written directly to the console.", - "normalizesevDescription": "return normalized severity in addition to the engine-specific severity", - "normalizesevDescriptionLong": "Returns normalized severity 1 (high), 2 (moderate), and 3 (low) and the engine-specific severity. For the html option, normalized severity is displayed instead of the engine severity.", - "outfileDescription": "write output to a file", - "outfileDescriptionLong": "Writes output to a file.", - "projectdirDescription": "provide root directory of project", - "projectdirDescriptionLong": "Provides the relative or absolute root project directory used to set the context for Graph Engine's analysis. Project directory must be a path, not a glob. Specify multiple values as a comma-separated list.", "ruledisablewarningviolationDescription": "disable warning violations from Salesforce Graph Engine. Alternatively, set value using environment variable `SFGE_RULE_DISABLE_WARNING_VIOLATION`", "ruledisablewarningviolationDescriptionLong": "Disables warning violations, such as those on StripInaccessible READ access, to get only high-severity violations (default: false). Inherits value from SFGE_RULE_DISABLE_WARNING_VIOLATION env-var if set.", "rulethreadcountDescription": "specify number of threads that evaluate DFA rules. Alternatively, set value using environment variable `SFGE_RULE_THREAD_COUNT`. Default is 4", "rulethreadcountDescriptionLong": "Specifies number of rule evaluation threads, or how many entrypoints can be evaluated concurrently. Inherits value from SFGE_RULE_THREAD_COUNT env-var, if set. Default is 4.", "rulethreadtimeoutDescription": "specify timeout for individual rule threads in milliseconds. Alternatively, set the timeout value using environment variable `SFGE_RULE_THREAD_TIMEOUT`. Default: 90000 ms", "rulethreadtimeoutDescriptionLong": "Specifies time limit for evaluating a single entrypoint in milliseconds. Inherits value from SFGE_RULE_THREAD_TIMEOUT env-var if set. Default is 900,000 ms, or 15 minutes.", - "sevthresholdDescription": "throw an error when violations of specific or higher severity are detected, and invoke --normalize-severity", - "sevthresholdDescriptionLong": "Throws an error when violations are found with equal or greater severity than provided value. Values are 1 (high), 2 (moderate), and 3 (low). Exit code is the most severe violation. Using this flag also invokes the --normalize-severity flag.", "sfgejvmargsDescription": "specify Java Virtual Machine (JVM) arguments to optimize Salesforce Graph Engine execution to your system (optional)", "sfgejvmargsDescriptionLong": "Specifies Java Virtual Machine arguments to override system defaults while executing Salesforce Graph Engine. For multiple arguments, add them to the same string separated by space.", "targetDescription": "return location of source code", @@ -27,12 +17,10 @@ module.exports = { "validations": { "methodLevelTargetCannotBeGlob": "Method-level targets supplied to --target cannot be globs", "methodLevelTargetMustBeRealFile": "Method-level target %s must be a real file", - "projectdirCannotBeGlob": "--projectdir cannot specify globs", - "projectdirMustBeDir": "--projectdir must specify directories", - "projectdirMustExist": "--projectdir must specify existing paths" + "projectdirIsRequired": "--projectdir is required for this command.", }, "examples": `The paths specified for --projectdir must contain all files specified through --target cumulatively. - $ sfdx sacnner:run:dfa --target "./myproject/main/default/classes/*.cls" --projectdir "./myproject/" + $ sfdx scanner:run:dfa --target "./myproject/main/default/classes/*.cls" --projectdir "./myproject/" $ sfdx scanner:run:dfa --target "./**/*.cls" --projectdir "./" $ sfdx scanner:run:dfa --target "./dir1/file1.cls,./dir2/file2.cls" --projectdir "./dir1/,./dir2/" This example fails because the set of files included in --target is larger than that contained in --projectdir: diff --git a/messages/run.js b/messages/run-pathless.js similarity index 74% rename from messages/run.js rename to messages/run-pathless.js index 5812aeaae..cba4b3264 100644 --- a/messages/run.js +++ b/messages/run-pathless.js @@ -8,19 +8,11 @@ module.exports = { "rulesetDescriptionLong": "[deprecated] One or more rulesets to run. Specify multiple values as a comma-separated list.", "targetDescription": "source code location", "targetDescriptionLong": "Source code location. May use glob patterns. Specify multiple values as a comma-separated list.", - "formatDescription": "specify results output format", - "formatDescriptionLong": "Specifies output format with results written directly to the console.", - "outfileDescription": "write output to a file", - "outfileDescriptionLong": "Writes output to a file.", "envDescription": "[deprecated] override ESLint's default environment variables, in JSON-formatted string", "envDescriptionLong": "[deprecated] Overrides ESLint's default environmental variables, in JSON-formatted string.", "envParamDeprecationWarning": "--env parameter is being deprecated, and will be removed in a future release.", "tsconfigDescription": "location of tsconfig.json file", "tsconfigDescriptionLong": "Location of tsconfig.json file used by eslint-typescript engine.", - "stDescription": "throw an error when a violation threshold is reached, the --normalize-severity is invoked, and severity levels are reset to the baseline", - "stDescriptionLong": "Throws an error when violations are found with equal or greater severity than the provided value. --normalize-severity is invoked and severity levels are reset to the baseline. Normalized severity values are: 1 (high), 2 (moderate), and 3 (low). Exit code is the most severe violation.", - "nsDescription": "return normalized severity 1 (high), 2 (moderate), and 3 (low), and the engine-specific severity", - "nsDescriptionLong": "Returns normalized severity 1 (high), 2 (moderate), and 3 (low), and the engine-specific severity. For the html option, the normalized severity is displayed instead of the engine severity.", 'engineDescription': "specify which engines to run", 'engineDescriptionLong': "Specifies one or more engines to run. Submit multiple values as a comma-separated list.", 'eslintConfigDescription': 'specify the location of eslintrc config to customize eslint engine', @@ -28,24 +20,14 @@ module.exports = { 'pmdConfigDescription': 'specify location of PMD rule reference XML file to customize rule selection', 'pmdConfigDescriptionLong': 'Specifies the location of PMD rule reference XML file to customize rule selection.', "verboseViolationsDescription": "return retire-js violation message details", - "verboseViolationsDescriptionLong": "Returns retire-js violation messages details about each vulnerability, including summary, Common Vulnerabilities and Exposures (CVE), and URLs." + "verboseViolationsDescriptionLong": "Returns retire-js violation messages details about each vulnerability, including summary, Common Vulnerabilities and Exposures (CVE), and URLs." }, "validations": { "methodLevelTargetingDisallowed": "The target '%s' is invalid because method-level targeting isn't supported with this command.", - "outfileFormatMismatch": "The selected output format doesn't match the output file type. Output format: %s. Output file type: %s.", - "outfileMustBeValid": "--outfile must be a well-formed filepath.", - "outfileMustBeSupportedType": "--outfile must be of a supported type: .csv; .xml; .json; .html; .sarif.", - "cannotWriteTableToFile": "Format 'table' can't be written to a file. Specify a different format.", "tsConfigEslintConfigExclusive": "A --tsconfig flag can't be specified with an --eslintconfig flag. Review your tsconfig path in the eslint config file under 'parseOptions.project'.", }, "output": { - "noViolationsDetected": "Executed engines: %s. No rule violations found.", "invalidEnvJson": "--env parameter must be a well-formed JSON.", - "engineSummaryTemplate": "Executed %s, found %s violation(s) across %s file(s).", - "writtenToOutFile": "Rule violations were written to %s.", - "writtenToConsole": "Rule violations were logged to the console.", - "sevThresholdSummary": "Rule violations of severity %s or more severe were detected.", - "pleaseSeeAbove": "Review the logs.", "filtersIgnoredCustom": "Rule filters will be ignored by engines that are run with custom config using --pmdconfig or --eslintconfig flags. Modify your config file to include your filters." }, "rulesetDeprecation": "The 'ruleset' command parameter is deprecated. Use 'category' instead.", @@ -96,5 +78,13 @@ This example uses --normalize-severity to output normalized severity and engine- This example uses --severity-threshold to throw a non-zero exit code when rule violations of normalized severity 2 or greater are found. If any violations with the specified severity (or greater) are found, the exit code equals the severity of the most severe violation. $ sfdx scanner:run --target "/some-project/" --severity-threshold 2 + +The paths specified for --projectdir must contain all files specified through --target cumulatively. + $ sfdx scanner:run --target "./myproject/main/default/classes/*.cls" --projectdir "./myproject/" + $ sfdx scanner:run --target "./**/*.cls" --projectdir "./" + $ sfdx scanner:run --target "./dir1/file1.cls,./dir2/file2.cls" --projectdir "./dir1/,./dir2/" + +This example fails because the set of files included in --target is larger than that contained in --projectdir: + $ sfdx scanner:run --target "./**/*.cls" --projectdir "./myproject/" ` }; diff --git a/package.json b/package.json index 4aa2a4ddb..3b428b019 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/sfdx-scanner", "description": "Static code scanner that applies quality and security rules to Apex code, and provides feedback.", - "version": "3.7.1", + "version": "3.8.0", "author": "ISV SWAT", "bugs": "https://github.com/forcedotcom/sfdx-scanner/issues", "dependencies": { @@ -22,7 +22,7 @@ "eslint": "^8.10.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jest": "^26.1.1", - "find-java-home": "1.1.0", + "find-java-home": "1.2.2", "globby": "^11.0.0", "html-escaper": "^3.0.0", "is-zip": "^1.0.0", diff --git a/sfge/src/main/java/com/salesforce/Main.java b/sfge/src/main/java/com/salesforce/Main.java index 9b7e7ed6a..9ffb38b63 100644 --- a/sfge/src/main/java/com/salesforce/Main.java +++ b/sfge/src/main/java/com/salesforce/Main.java @@ -17,7 +17,6 @@ import com.salesforce.rules.AbstractRule; import com.salesforce.rules.AbstractRuleRunner; import com.salesforce.rules.RuleRunner; -import com.salesforce.rules.RuleUtil; import com.salesforce.rules.Violation; import com.salesforce.rules.ops.ProgressListenerProvider; import java.util.Arrays; @@ -28,30 +27,28 @@ import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; /** - * The main class, invoked by sfdx-scanner. `catalog` flow lists all of the available rules in a - * standardized format. - * - *

The `execute` flow accepts as a parameter the name of a file whose contents are a JSON with - * the following structure: + * The main class, invoked by sfdx-scanner. The first arg should be either `catalog` or `execute`, + * and determines which flow runs.
+ * For `catalog`, the second arg should be either `pathless` or `dfa`. Enabled rules matching that + * type will be logged as a JSON. No meaningful environment variables exist for this flow. Exit code + * 0 means success. Any other exit code means failure.
+ * For `execute`, the second arg should be the path to a JSON file whose contents are structured as: * *

    - *
  1. rulesToRun: An array of rule names. - *
  2. projectDirs: An array of directories from which the graph should be built. - *
  3. targets: An array of objects with a `targetFile` property indicating the file to be - * analyzed and a `targetMethods` property indicating individual methods. + *
  4. rulesToRun: an array of rule names. + *
  5. projectDirs: an array of directories from which the graph should be built. + *
  6. targets: an array of objects with a `targetFile` property indicating the file to be + * analyzed and a `targetMethods` property that may optionally indicate individual methods + * within that file. *
* - *

Exit codes: - * - *

+ * The following exit codes are possible: * - *

Usage: mvn exec:java -Dexec.mainClass=com.salesforce.Main -Dexec.args="catalog" OR mvn - * exec:java -Dexec.mainClass=com.salesforce.Main -Dexec.args="execute [path to file listing - * targets] [path to file listing sources] [comma-separated rules]" + *

    + *
  1. 0: Successful run without violations. + *
  2. 1: Internal error with no violations. + *
  3. 4: Successful run with violations.5: Internal error with some violations.> + *
*/ @SuppressWarnings( "PMD.SystemPrintln") // Since println is currently used to communicate to outer layer @@ -97,7 +94,7 @@ int process(String... args) { switch (action) { case CATALOG: - return catalog(); + return catalog(args); case EXECUTE: return execute(args); default: @@ -105,11 +102,14 @@ int process(String... args) { } } - private int catalog() { + /** Expectations for args documented in class header above. */ + private int catalog(String... args) { LOGGER.info("Invoked CATALOG flow"); + CliArgParser.CatalogArgParser cap = new CliArgParser.CatalogArgParser(); List rules; try { - rules = RuleUtil.getEnabledRules(); + cap.parseArgs(args); + rules = cap.getSelectedRules(); } catch (SfgeException | SfgeRuntimeException ex) { dependencies.printError(ex.getMessage()); return EXIT_WITH_INTERNAL_ERROR_NO_VIOLATIONS; @@ -119,6 +119,7 @@ private int catalog() { return EXIT_GOOD_RUN_NO_VIOLATIONS; } + /** Expectations for args documented in class header above. */ private int execute(String... args) { LOGGER.info("Invoked EXECUTE flow"); // Parse the arguments with our delegate class. diff --git a/sfge/src/main/java/com/salesforce/apex/jorje/ASTConstants.java b/sfge/src/main/java/com/salesforce/apex/jorje/ASTConstants.java index 37a862002..7ca20dd14 100644 --- a/sfge/src/main/java/com/salesforce/apex/jorje/ASTConstants.java +++ b/sfge/src/main/java/com/salesforce/apex/jorje/ASTConstants.java @@ -27,6 +27,7 @@ import apex.jorje.semantic.ast.expression.ReferenceExpression; import apex.jorje.semantic.ast.expression.SoqlExpression; import apex.jorje.semantic.ast.expression.SoslExpression; +import apex.jorje.semantic.ast.expression.SuperMethodCallExpression; import apex.jorje.semantic.ast.expression.ThisMethodCallExpression; import apex.jorje.semantic.ast.expression.ThisVariableExpression; import apex.jorje.semantic.ast.expression.TriggerVariableExpression; @@ -145,6 +146,8 @@ public static String getVertexLabel(Class clazz) { public static final String REFERENCE_EXPRESSION = getVertexLabel(ReferenceExpression.class); public static final String RETURN_STATEMENT = getVertexLabel(ReturnStatement.class); public static final String STANDARD_CONDITION = getVertexLabel(StandardCondition.class); + public static final String SUPER_METHOD_CALL_EXPRESSION = + getVertexLabel(SuperMethodCallExpression.class); public static final String THIS_METHOD_CALL_EXPRESSION = getVertexLabel(ThisMethodCallExpression.class); public static final String THIS_VARIABLE_EXPRESSION = diff --git a/sfge/src/main/java/com/salesforce/cli/CliArgParser.java b/sfge/src/main/java/com/salesforce/cli/CliArgParser.java index 9b0558bca..679f8a822 100644 --- a/sfge/src/main/java/com/salesforce/cli/CliArgParser.java +++ b/sfge/src/main/java/com/salesforce/cli/CliArgParser.java @@ -41,6 +41,52 @@ public CLI_ACTION getCliAction(String... args) { } } + public static class CatalogArgParser { + private static final int ARG_COUNT = 2; + // NOTE: This value must match the one for the RuleType enum declared in Constants.ts. + private static final String PATHLESS = "pathless"; + // NOTE: This value must match the one for the RuleType enum declared in Constants.ts. + private static final String DFA = "dfa"; + + private List selectedRules; + + public CatalogArgParser() { + selectedRules = new ArrayList<>(); + } + + /** + * See the documentation of {@link com.salesforce.Main} for information about the + * expectations for args. + */ + public void parseArgs(String... args) throws RuleUtil.RuleNotFoundException { + if (LOGGER.isInfoEnabled()) { + LOGGER.info("CLI args received: " + Arrays.toString(args)); + } + // Make sure we have the right number of arguments. + if (args.length != ARG_COUNT) { + throw new InvocationException( + String.format( + "Wrong number of arguments. Expected %d; received %d", + ARG_COUNT, args.length)); + } + switch (args[1]) { + case PATHLESS: + selectedRules = RuleUtil.getEnabledStaticRules(); + break; + case DFA: + selectedRules = RuleUtil.getEnabledPathBasedRules(); + break; + default: + selectedRules = RuleUtil.getEnabledRules(); + break; + } + } + + public List getSelectedRules() { + return selectedRules; + } + } + public static class ExecuteArgParser { private static int ARG_COUNT = 2; @@ -62,6 +108,10 @@ public ExecuteArgParser(Dependencies dependencies) { this.dependencies = dependencies; } + /** + * See the documentation of {@link com.salesforce.Main} for information about the + * expectations for args. + */ public void parseArgs(String... args) { if (LOGGER.isInfoEnabled()) { LOGGER.info("CLI args received: " + Arrays.toString(args)); @@ -92,28 +142,6 @@ public List getSelectedRules() { return selectedRules; } - private void identifyProjectDirs(String inputFile) { - try { - projectDirs.addAll(readFile(inputFile)); - } catch (IOException ex) { - throw new InvocationException( - "Could not read source-list file " + inputFile + ": " + ex.getMessage(), - ex); - } - } - - private void identifyTargetFiles(String inputFile) { - try { - String targetJson = String.join("\n", readFile(inputFile)); - Gson gson = new Gson(); - targets.addAll(Arrays.asList(gson.fromJson(targetJson, RuleRunnerTarget[].class))); - } catch (IOException ex) { - throw new InvocationException( - "Could not read target-list file " + inputFile + ": " + ex.getMessage(), - ex); - } - } - private ExecuteInput readInputFile(String fileName) { try { String inputJson = String.join("\n", readFile(fileName)); diff --git a/sfge/src/main/java/com/salesforce/config/UserFacingMessages.java b/sfge/src/main/java/com/salesforce/config/UserFacingMessages.java index ad670ec93..c13b58b19 100644 --- a/sfge/src/main/java/com/salesforce/config/UserFacingMessages.java +++ b/sfge/src/main/java/com/salesforce/config/UserFacingMessages.java @@ -20,6 +20,9 @@ public final class UserFacingMessages { public static final String UNREACHABLE_CODE = "Remove unreachable code to proceed with the analysis: %s,%s:%d"; + public static final String VARIABLE_DECLARED_MULTIPLE_TIMES = + "Rename or remove reused variable to proceed with analysis: %s,%s:%d"; + /** CRUD/FLS Violation messages * */ // format: "CRUD" or "FLS", DML operation, Object type, Field information public static final String VIOLATION_MESSAGE_TEMPLATE = diff --git a/sfge/src/main/java/com/salesforce/exception/UserActionException.java b/sfge/src/main/java/com/salesforce/exception/UserActionException.java index 621faea32..bc44d3115 100644 --- a/sfge/src/main/java/com/salesforce/exception/UserActionException.java +++ b/sfge/src/main/java/com/salesforce/exception/UserActionException.java @@ -8,4 +8,17 @@ public final class UserActionException extends SfgeRuntimeException { public UserActionException(String message) { super(message); } + + /** + * Construct structured user action exception + * + * @param messageTemplate typically from {@link com.salesforce.config.UserFacingMessages} + * @param filename where the issue was noticed + * @param definingType class name to fix + * @param lineNumber line number where the problem is + */ + public UserActionException( + String messageTemplate, String filename, String definingType, int lineNumber) { + this(String.format(messageTemplate, filename, definingType, lineNumber)); + } } diff --git a/sfge/src/main/java/com/salesforce/graph/Schema.java b/sfge/src/main/java/com/salesforce/graph/Schema.java index f5b011cd1..1c839a6f9 100644 --- a/sfge/src/main/java/com/salesforce/graph/Schema.java +++ b/sfge/src/main/java/com/salesforce/graph/Schema.java @@ -23,11 +23,15 @@ public class Schema { public static final String FIRST_CHILD = "FirstChild"; public static final String FULL_METHOD_NAME = "FullMethodName"; public static final String GLOBAL = "Global"; + public static final String HANDLE_INBOUND_EMAIL = "handleInboundEmail"; public static final String HAS_GETTER_METHOD_BLOCK = "HasGetterMethodBlock"; public static final String HAS_SETTER_METHOD_BLOCK = "HasSetterMethodBlock"; public static final String IDENTIFIER = "Identifier"; public static final String IMPLEMENTATION_OF = "ImplementationOf"; public static final String IMPLEMENTED_BY = "ImplementedBy"; + public static final String INBOUND_EMAIL_HANDLER = "Messaging.InboundEmailHandler"; + public static final String INBOUND_EMAIL_RESULT = "Messaging.InboundEmailResult"; + public static final String INSTANCE_CONSTRUCTOR_CANONICAL_NAME = ""; public static final String INTERFACE_DEFINING_TYPES = "InterfaceDefiningTypes"; public static final String INTERFACE_NAMES = "InterfaceNames"; public static final String INVOCABLE_METHOD = "InvocableMethod"; @@ -49,11 +53,13 @@ public class Schema { public static final String NAMESPACE_ACCESSIBLE = "NamespaceAccessible"; public static final String OPERATOR = "Operator"; public static final String OVERRIDE = "Override"; + public static final String PAGE_REFERENCE = "PageReference"; public static final String REFERENCE_TYPE = "ReferenceType"; public static final String REMOTE_ACTION = "RemoteAction"; public static final String RETURN_TYPE = "ReturnType"; public static final String RULE_NAMES = "RulesNames"; public static final String STATIC = "Static"; + public static final String STATIC_CONSTRUCTOR_CANONICAL_NAME = ""; public static final String SUPER_CLASS_NAME = "SuperClassName"; public static final String SUPER_INTERFACE_NAME = "SuperInterfaceName"; public static final String TYPE = "Type"; diff --git a/sfge/src/main/java/com/salesforce/graph/build/StaticBlockUtil.java b/sfge/src/main/java/com/salesforce/graph/build/StaticBlockUtil.java index e7ae15e86..290fbc769 100644 --- a/sfge/src/main/java/com/salesforce/graph/build/StaticBlockUtil.java +++ b/sfge/src/main/java/com/salesforce/graph/build/StaticBlockUtil.java @@ -5,7 +5,6 @@ import com.salesforce.collections.CollectionUtil; import com.salesforce.exception.ProgrammingException; import com.salesforce.graph.Schema; -import com.salesforce.graph.ops.MethodUtil; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -342,7 +341,7 @@ private static void addProperties( */ private static boolean isClinitMethod(JorjeNode node) { return ASTConstants.NodeType.METHOD.equals(node.getLabel()) - && MethodUtil.STATIC_CONSTRUCTOR_CANONICAL_NAME.equals( + && Schema.STATIC_CONSTRUCTOR_CANONICAL_NAME.equals( node.getProperties().get(Schema.NAME)); } diff --git a/sfge/src/main/java/com/salesforce/graph/ops/MethodUtil.java b/sfge/src/main/java/com/salesforce/graph/ops/MethodUtil.java index 946b83d4e..b212e0318 100644 --- a/sfge/src/main/java/com/salesforce/graph/ops/MethodUtil.java +++ b/sfge/src/main/java/com/salesforce/graph/ops/MethodUtil.java @@ -2,7 +2,6 @@ import static com.salesforce.apex.jorje.ASTConstants.PROPERTY_METHOD_PREFIX; import static com.salesforce.graph.ops.TypeableUtil.NOT_A_MATCH; -import static org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__.has; import static org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__.out; import com.salesforce.apex.jorje.ASTConstants; @@ -55,22 +54,18 @@ import com.salesforce.graph.visitor.DefaultNoOpPathVertexVisitor; import com.salesforce.messaging.CliMessager; import com.salesforce.messaging.EventKey; -import com.salesforce.metainfo.MetaInfoCollectorProvider; import com.salesforce.rules.AbstractRuleRunner.RuleRunnerTarget; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; -import java.util.Set; import java.util.TreeMap; import java.util.stream.Collectors; import org.apache.commons.lang3.tuple.Pair; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.apache.tinkerpop.gremlin.process.traversal.Order; import org.apache.tinkerpop.gremlin.process.traversal.P; -import org.apache.tinkerpop.gremlin.process.traversal.Scope; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversal; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__; @@ -80,13 +75,6 @@ public final class MethodUtil { private static final Logger LOGGER = LogManager.getLogger(MethodUtil.class); - private static final String PAGE_REFERENCE = "PageReference"; - private static final String INBOUND_EMAIL_HANDLER = "Messaging.InboundEmailHandler"; - private static final String HANDLE_INBOUND_EMAIL = "handleInboundEmail"; - private static final String INBOUND_EMAIL_RESULT = "Messaging.InboundEmailResult"; - public static final String INSTANCE_CONSTRUCTOR_CANONICAL_NAME = ""; - public static final String STATIC_CONSTRUCTOR_CANONICAL_NAME = ""; - public static List getTargetedMethods( GraphTraversalSource g, List targets) { // The targets passed into this method should exclusively be ones that target specific @@ -163,175 +151,6 @@ private static void addMessagesForTarget(RuleRunnerTarget target, List getAuraEnabledMethods( - GraphTraversalSource g, List targetFiles) { - return getMethodsWithAnnotation(g, targetFiles, Schema.AURA_ENABLED); - } - - /** - * Returns non-test methods in the target files with a @NamespaceAccessible annotation. An empty - * list implicitly includes all files. - */ - public static List getNamespaceAccessibleMethods( - GraphTraversalSource g, List targetFiles) { - return getMethodsWithAnnotation(g, targetFiles, Schema.NAMESPACE_ACCESSIBLE); - } - - /** - * Returns non-test methods in the target files with a @RemoteAction annotation. An empty list - * implicitly includes all files. - */ - public static List getRemoteActionMethods( - GraphTraversalSource g, List targetFiles) { - return getMethodsWithAnnotation(g, targetFiles, Schema.REMOTE_ACTION); - } - - /** - * Returns non-test methods in the target files with an @InvocableMethod annotation. An empty - * list implicitly includes all files. - */ - public static List getInvocableMethodMethods( - GraphTraversalSource g, List targetFiles) { - return getMethodsWithAnnotation(g, targetFiles, Schema.INVOCABLE_METHOD); - } - - static List getMethodsWithAnnotation( - GraphTraversalSource g, List targetFiles, String annotation) { - return SFVertexFactory.loadVertices( - g, - rootMethodTraversal(g, targetFiles) - .where( - out(Schema.CHILD) - .hasLabel(NodeType.MODIFIER_NODE) - .out(Schema.CHILD) - .where(H.has(NodeType.ANNOTATION, Schema.NAME, annotation))) - .order(Scope.global) - .by(Schema.DEFINING_TYPE, Order.asc) - .by(Schema.NAME, Order.asc)); - } - - /** - * Returns non-test methods in the target files whose return type is a PageReference. An empty - * list implicitly includes all files. - */ - public static List getPageReferenceMethods( - GraphTraversalSource g, List targetFiles) { - return SFVertexFactory.loadVertices( - g, - rootMethodTraversal(g, targetFiles) - .where(H.has(NodeType.METHOD, Schema.RETURN_TYPE, PAGE_REFERENCE)) - .order(Scope.global) - .by(Schema.DEFINING_TYPE, Order.asc) - .by(Schema.NAME, Order.asc)); - } - - private static GraphTraversal rootMethodTraversal( - GraphTraversalSource g, List targetFiles) { - // Only look at UserClass vertices. Not interested in Enums, Interfaces, or Triggers - final String[] labels = new String[] {NodeType.USER_CLASS}; - return TraversalUtil.fileRootTraversal(g, labels, targetFiles) - .not(has(Schema.IS_TEST, true)) - .repeat(__.out(Schema.CHILD)) - .until(__.hasLabel(NodeType.METHOD)) - .not(has(Schema.IS_TEST, true)); - } - - /** - * Returns non-test methods in the target files whose modifier scope is `global`. An empty list - * implicitly includes all files. - */ - public static List getGlobalMethods( - GraphTraversalSource g, List targetFiles) { - // Get all methods in the target files. - return SFVertexFactory.loadVertices( - g, - rootMethodTraversal(g, targetFiles) - .filter( - __.and( - // If a method has at least one block statement, then it is - // definitely actually declared, as - // opposed to being an implicit method. - out(Schema.CHILD) - .hasLabel(NodeType.BLOCK_STATEMENT) - .count() - .is(P.gte(1)), - // We only want global methods. - out(Schema.CHILD) - .hasLabel(NodeType.MODIFIER_NODE) - .has(Schema.GLOBAL, true), - // Ignore any standard methods, otherwise will get a ton of - // extra results. - __.not(__.has(Schema.IS_STANDARD, true))))); - } - - public static List getInboundEmailHandlerMethods( - GraphTraversalSource g, List targetFiles) { - return SFVertexFactory.loadVertices( - g, - // Get any target class that implements the email handler interface. - TraversalUtil.traverseImplementationsOf(g, targetFiles, INBOUND_EMAIL_HANDLER) - // Get every implementation of the handle email method. - .out(Schema.CHILD) - .where(H.has(NodeType.METHOD, Schema.NAME, HANDLE_INBOUND_EMAIL)) - // Filter the results by return type and arity to limit the possibility of - // getting unnecessary results. - .where(H.has(NodeType.METHOD, Schema.RETURN_TYPE, INBOUND_EMAIL_RESULT)) - .has(Schema.ARITY, 2)); - } - - /** - * Returns all non-test public- and global-scoped methods in controllers referenced by - * VisualForce pages, filtered by target file list. An empty list implicitly includes all files. - * - * @param g - * @param targetFiles - * @return - */ - public static List getExposedControllerMethods( - GraphTraversalSource g, List targetFiles) { - Set referencedVfControllers = - MetaInfoCollectorProvider.getVisualForceHandler().getMetaInfoCollected(); - // If none of the VF files referenced an Apex class, we can just return an empty list. - if (referencedVfControllers.isEmpty()) { - return new ArrayList<>(); - } - List allControllerMethods = - SFVertexFactory.loadVertices( - g, - TraversalUtil.fileRootTraversal(g, targetFiles) - // Only outer classes can be VF controllers, so we should restrict - // our query to UserClasses. - .where( - H.hasWithin( - NodeType.USER_CLASS, - Schema.DEFINING_TYPE, - referencedVfControllers)) - .repeat(__.out(Schema.CHILD)) - .until(__.hasLabel(NodeType.METHOD)) - .not(has(Schema.IS_TEST, true)) - // We want to ignore constructors. - .where( - __.not( - H.hasWithin( - NodeType.METHOD, - Schema.NAME, - INSTANCE_CONSTRUCTOR_CANONICAL_NAME, - STATIC_CONSTRUCTOR_CANONICAL_NAME)))); - // Gremlin isn't sophisticated enough to perform this kind of filtering in the actual query. - // So we'll just do it - // manually here. - return allControllerMethods.stream() - .filter( - methodVertex -> - methodVertex.getModifierNode().isPublic() - || methodVertex.getModifierNode().isGlobal()) - .collect(Collectors.toList()); - } - public static Optional getInvoked( GraphTraversalSource g, final String definingType, @@ -439,7 +258,7 @@ public static Optional getInvoked( H.has( NodeType.METHOD, Schema.NAME, - INSTANCE_CONSTRUCTOR_CANONICAL_NAME)) + Schema.INSTANCE_CONSTRUCTOR_CANONICAL_NAME)) .has(Schema.CONSTRUCTOR, true) .has(Schema.ARITY, vertex.getParameters().size())); @@ -464,7 +283,7 @@ public static Optional getNoArgConstructor( H.has( NodeType.METHOD, Schema.NAME, - INSTANCE_CONSTRUCTOR_CANONICAL_NAME)) + Schema.INSTANCE_CONSTRUCTOR_CANONICAL_NAME)) .has(Schema.CONSTRUCTOR, true) .has(Schema.ARITY, 0))); } @@ -482,7 +301,7 @@ public static List getNonDefaultConstructors( H.has( NodeType.METHOD, Schema.NAME, - INSTANCE_CONSTRUCTOR_CANONICAL_NAME)) + Schema.INSTANCE_CONSTRUCTOR_CANONICAL_NAME)) .has(Schema.CONSTRUCTOR, true) // User defined constructors have a block statement .where(out(Schema.CHILD).hasLabel(NodeType.BLOCK_STATEMENT))); @@ -513,7 +332,7 @@ public static Optional getInvoked( H.has( NodeType.METHOD, Schema.NAME, - INSTANCE_CONSTRUCTOR_CANONICAL_NAME)) + Schema.INSTANCE_CONSTRUCTOR_CANONICAL_NAME)) .has(Schema.CONSTRUCTOR, true) .has(Schema.ARITY, vertex.getParameters().size())); if (methods.isEmpty()) { @@ -541,7 +360,7 @@ public static Optional getInvoked( H.has( NodeType.METHOD, Schema.NAME, - INSTANCE_CONSTRUCTOR_CANONICAL_NAME)) + Schema.INSTANCE_CONSTRUCTOR_CANONICAL_NAME)) .has(Schema.CONSTRUCTOR, true) .has(Schema.ARITY, vertex.getParameters().size())); diff --git a/sfge/src/main/java/com/salesforce/graph/ops/PathEntryPointUtil.java b/sfge/src/main/java/com/salesforce/graph/ops/PathEntryPointUtil.java new file mode 100644 index 000000000..504a48525 --- /dev/null +++ b/sfge/src/main/java/com/salesforce/graph/ops/PathEntryPointUtil.java @@ -0,0 +1,323 @@ +package com.salesforce.graph.ops; + +import static org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__.has; +import static org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__.out; + +import com.salesforce.apex.jorje.ASTConstants; +import com.salesforce.graph.Schema; +import com.salesforce.graph.build.CaseSafePropertyUtil; +import com.salesforce.graph.vertex.MethodVertex; +import com.salesforce.graph.vertex.SFVertexFactory; +import com.salesforce.graph.vertex.UserClassVertex; +import com.salesforce.metainfo.MetaInfoCollectorProvider; +import com.salesforce.rules.AbstractRuleRunner; +import java.util.*; +import java.util.stream.Collectors; +import org.apache.tinkerpop.gremlin.process.traversal.Order; +import org.apache.tinkerpop.gremlin.process.traversal.P; +import org.apache.tinkerpop.gremlin.process.traversal.Scope; +import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversal; +import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; +import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__; +import org.apache.tinkerpop.gremlin.structure.Vertex; + +/** + * Util class for identifying and interacting with path entry points. A path entry point being + * defined as a point with which an external actor can interact, thus starting code execution. + */ +public final class PathEntryPointUtil { + + /** + * Indicates whether a method vertex is a path entry point, e.g., a point where path analysis + * can begin. + */ + public static boolean isPathEntryPoint(MethodVertex methodVertex) { + // Global methods are entry points. + if (methodVertex.getModifierNode().isGlobal()) { + return true; + } + // Methods that return PageReference objects are entry points. + if (methodVertex.getReturnType().equalsIgnoreCase(Schema.PAGE_REFERENCE)) { + return true; + } + // Certain annotations can designate a method as an entry point. + String[] entryPointAnnotations = + new String[] { + Schema.AURA_ENABLED, + Schema.NAMESPACE_ACCESSIBLE, + Schema.REMOTE_ACTION, + Schema.INVOCABLE_METHOD + }; + for (String annotation : entryPointAnnotations) { + if (methodVertex.hasAnnotation(annotation)) { + return true; + } + } + // Exposed methods on VF controllers are entry points. + Set vfControllers = + MetaInfoCollectorProvider.getVisualForceHandler().getMetaInfoCollected().stream() + .map(String::toLowerCase) + .collect(Collectors.toSet()); + if (vfControllers.contains(methodVertex.getDefiningType().toLowerCase())) { + return true; + } + + // InboundEmailHandler methods are entry points. + // NOTE: This is a pretty cursory check and may struggle with nested inheritance. This isn't + // likely to happen, but if it does, we can make the check more robust. + Optional parentClass = methodVertex.getParentClass(); + return parentClass.isPresent() + && parentClass.get().getInterfaceNames().stream() + .map(String::toLowerCase) + .collect(Collectors.toSet()) + // Does the parent class implement InboundEmailHandler? + .contains(Schema.INBOUND_EMAIL_HANDLER.toLowerCase()) + // Does the method return an InboundEmailResult? + && methodVertex.getReturnType().equalsIgnoreCase(Schema.INBOUND_EMAIL_RESULT) + // Is the method named handleInboundEmail? + && methodVertex.getName().equalsIgnoreCase(Schema.HANDLE_INBOUND_EMAIL); + } + + /** Load all path entry points in the graph. */ + static List getPathEntryPoints(GraphTraversalSource g) { + return getPathEntryPoints(g, new ArrayList<>()); + } + + /** + * Load all path entry points specified by the target objects. An empty list implicitly includes + * all files. + */ + public static List getPathEntryPoints( + GraphTraversalSource g, List targets) { + // Sort the list of targets into full-file targets and method-level targets. + List fileLevelTargets = + targets.stream() + .filter(t -> t.getTargetMethods().isEmpty()) + .map(AbstractRuleRunner.RuleRunnerTarget::getTargetFile) + .collect(Collectors.toList()); + List methodLevelTargets = + targets.stream() + .filter(t -> !t.getTargetMethods().isEmpty()) + .collect(Collectors.toList()); + + // Internally, we'll use a Set to preserve uniqueness. + Set methods = new HashSet<>(); + + // If there are any explicitly targeted files, we must process them. If there are no + // explicit targets of any kind, + // then all files are implicitly targeted. + if (!fileLevelTargets.isEmpty() || targets.isEmpty()) { + // Use the file-level targets to get aura-enabled methods... + methods.addAll(getAuraEnabledMethods(g, fileLevelTargets)); + // ...and NamespaceAccessible methods... + methods.addAll(getNamespaceAccessibleMethods(g, fileLevelTargets)); + // ...and RemoteAction methods... + methods.addAll(getRemoteActionMethods(g, fileLevelTargets)); + // ...and InvocableMethod methods... + methods.addAll(getInvocableMethodMethods(g, fileLevelTargets)); + // ...and PageReference methods... + methods.addAll(getPageReferenceMethods(g, fileLevelTargets)); + // ...and global-exposed methods... + methods.addAll(getGlobalMethods(g, fileLevelTargets)); + // ...and implementations of Messaging.InboundEmailHandler#handleInboundEmail... + methods.addAll(getInboundEmailHandlerMethods(g, fileLevelTargets)); + // ...and exposed methods on VF controllers. + methods.addAll(getExposedControllerMethods(g, fileLevelTargets)); + } + + // Also, if there are any specifically targeted methods, they should be included. + if (!methodLevelTargets.isEmpty()) { + methods.addAll(MethodUtil.getTargetedMethods(g, methodLevelTargets)); + } + // Turn the Set into a List so we can return it. + return new ArrayList<>(methods); + } + + /** + * Returns non-test methods in the target files with an @AuraEnabled annotation. An empty list + * implicitly includes all files. + */ + private static List getAuraEnabledMethods( + GraphTraversalSource g, List targetFiles) { + return getMethodsWithAnnotation(g, targetFiles, Schema.AURA_ENABLED); + } + + /** + * Returns non-test methods in the target files with a @NamespaceAccessible annotation. An empty + * list implicitly includes all files. + */ + private static List getNamespaceAccessibleMethods( + GraphTraversalSource g, List targetFiles) { + return getMethodsWithAnnotation(g, targetFiles, Schema.NAMESPACE_ACCESSIBLE); + } + + /** + * Returns non-test methods in the target files with a @RemoteAction annotation. An empty list + * implicitly includes all files. + */ + private static List getRemoteActionMethods( + GraphTraversalSource g, List targetFiles) { + return getMethodsWithAnnotation(g, targetFiles, Schema.REMOTE_ACTION); + } + + /** + * Returns non-test methods in the target files with an @InvocableMethod annotation. An empty + * list implicitly includes all files. + */ + private static List getInvocableMethodMethods( + GraphTraversalSource g, List targetFiles) { + return getMethodsWithAnnotation(g, targetFiles, Schema.INVOCABLE_METHOD); + } + + static List getMethodsWithAnnotation( + GraphTraversalSource g, List targetFiles, String annotation) { + return SFVertexFactory.loadVertices( + g, + rootMethodTraversal(g, targetFiles) + .where( + out(Schema.CHILD) + .hasLabel(ASTConstants.NodeType.MODIFIER_NODE) + .out(Schema.CHILD) + .where( + CaseSafePropertyUtil.H.has( + ASTConstants.NodeType.ANNOTATION, + Schema.NAME, + annotation))) + .order(Scope.global) + .by(Schema.DEFINING_TYPE, Order.asc) + .by(Schema.NAME, Order.asc)); + } + + /** + * Returns non-test methods in the target files whose return type is a PageReference. An empty + * list implicitly includes all files. + */ + static List getPageReferenceMethods( + GraphTraversalSource g, List targetFiles) { + return SFVertexFactory.loadVertices( + g, + rootMethodTraversal(g, targetFiles) + .where( + CaseSafePropertyUtil.H.has( + ASTConstants.NodeType.METHOD, + Schema.RETURN_TYPE, + Schema.PAGE_REFERENCE)) + .order(Scope.global) + .by(Schema.DEFINING_TYPE, Order.asc) + .by(Schema.NAME, Order.asc)); + } + + private static GraphTraversal rootMethodTraversal( + GraphTraversalSource g, List targetFiles) { + // Only look at UserClass vertices. Not interested in Enums, Interfaces, or Triggers + final String[] labels = new String[] {ASTConstants.NodeType.USER_CLASS}; + return TraversalUtil.fileRootTraversal(g, labels, targetFiles) + .not(has(Schema.IS_TEST, true)) + .repeat(__.out(Schema.CHILD)) + .until(__.hasLabel(ASTConstants.NodeType.METHOD)) + .not(has(Schema.IS_TEST, true)); + } + + /** + * Returns non-test methods in the target files whose modifier scope is `global`. An empty list + * implicitly includes all files. + */ + static List getGlobalMethods(GraphTraversalSource g, List targetFiles) { + // Get all methods in the target files. + return SFVertexFactory.loadVertices( + g, + rootMethodTraversal(g, targetFiles) + .filter( + __.and( + // If a method has at least one block statement, then it is + // definitely actually declared, as + // opposed to being an implicit method. + out(Schema.CHILD) + .hasLabel(ASTConstants.NodeType.BLOCK_STATEMENT) + .count() + .is(P.gte(1)), + // We only want global methods. + out(Schema.CHILD) + .hasLabel(ASTConstants.NodeType.MODIFIER_NODE) + .has(Schema.GLOBAL, true), + // Ignore any standard methods, otherwise will get a ton of + // extra results. + __.not(__.has(Schema.IS_STANDARD, true))))); + } + + static List getInboundEmailHandlerMethods( + GraphTraversalSource g, List targetFiles) { + return SFVertexFactory.loadVertices( + g, + // Get any target class that implements the email handler interface. + TraversalUtil.traverseImplementationsOf( + g, targetFiles, Schema.INBOUND_EMAIL_HANDLER) + // Get every implementation of the handle email method. + .out(Schema.CHILD) + .where( + CaseSafePropertyUtil.H.has( + ASTConstants.NodeType.METHOD, + Schema.NAME, + Schema.HANDLE_INBOUND_EMAIL)) + // Filter the results by return type and arity to limit the possibility of + // getting unnecessary results. + .where( + CaseSafePropertyUtil.H.has( + ASTConstants.NodeType.METHOD, + Schema.RETURN_TYPE, + Schema.INBOUND_EMAIL_RESULT)) + .has(Schema.ARITY, 2)); + } + + /** + * Returns all non-test public- and global-scoped methods in controllers referenced by + * VisualForce pages, filtered by target file list. An empty list implicitly includes all files. + * + * @param g + * @param targetFiles + * @return + */ + static List getExposedControllerMethods( + GraphTraversalSource g, List targetFiles) { + Set referencedVfControllers = + MetaInfoCollectorProvider.getVisualForceHandler().getMetaInfoCollected(); + // If none of the VF files referenced an Apex class, we can just return an empty list. + if (referencedVfControllers.isEmpty()) { + return new ArrayList<>(); + } + List allControllerMethods = + SFVertexFactory.loadVertices( + g, + TraversalUtil.fileRootTraversal(g, targetFiles) + // Only outer classes can be VF controllers, so we should restrict + // our query to UserClasses. + .where( + CaseSafePropertyUtil.H.hasWithin( + ASTConstants.NodeType.USER_CLASS, + Schema.DEFINING_TYPE, + referencedVfControllers)) + .repeat(__.out(Schema.CHILD)) + .until(__.hasLabel(ASTConstants.NodeType.METHOD)) + .not(has(Schema.IS_TEST, true)) + // We want to ignore constructors. + .where( + __.not( + CaseSafePropertyUtil.H.hasWithin( + ASTConstants.NodeType.METHOD, + Schema.NAME, + Schema.INSTANCE_CONSTRUCTOR_CANONICAL_NAME, + Schema + .STATIC_CONSTRUCTOR_CANONICAL_NAME)))); + // Gremlin isn't sophisticated enough to perform this kind of filtering in the actual query. + // So we'll just do it + // manually here. + return allControllerMethods.stream() + .filter( + methodVertex -> + methodVertex.getModifierNode().isPublic() + || methodVertex.getModifierNode().isGlobal()) + .collect(Collectors.toList()); + } + + private PathEntryPointUtil() {} +} diff --git a/sfge/src/main/java/com/salesforce/graph/ops/TypeableUtil.java b/sfge/src/main/java/com/salesforce/graph/ops/TypeableUtil.java index 076a87f0a..5f6374e7a 100644 --- a/sfge/src/main/java/com/salesforce/graph/ops/TypeableUtil.java +++ b/sfge/src/main/java/com/salesforce/graph/ops/TypeableUtil.java @@ -22,10 +22,14 @@ public final class TypeableUtil { private static final Logger LOGGER = LogManager.getLogger(TypeableUtil.class); - private static final String LIST_PATTERN_STR = "List<(.*)>"; + private static final String LIST_PATTERN_STR = "list<\\s*([^\\s]*)\\s*>"; private static final Pattern LIST_PATTERN = Pattern.compile(LIST_PATTERN_STR, Pattern.CASE_INSENSITIVE); + private static final String SET_PATTERN_STR = "set<\\s*([^\\s]*)\\s*>"; + private static final Pattern SET_PATTERN = + Pattern.compile(SET_PATTERN_STR, Pattern.CASE_INSENSITIVE); + private TypeableUtil() {} /** @@ -111,9 +115,25 @@ private static OrderedTreeSet getListTypeHierarchy(String definingType) { * string */ public static Optional getListSubType(String definingType) { - final Matcher listPatternMatcher = LIST_PATTERN.matcher(definingType); - if (listPatternMatcher.find()) { - return Optional.of(listPatternMatcher.group(1)); + return getSubType(LIST_PATTERN, definingType, 1); + } + + public static Optional getSetSubType(String definingType) { + return getSubType(SET_PATTERN, definingType, 1); + } + + private static Optional getSubType( + Pattern subTypePattern, String definingType, int groupCountExpected) { + final Matcher subTypeMatcher = subTypePattern.matcher(definingType); + + if (subTypeMatcher.find()) { + if (subTypeMatcher.groupCount() != groupCountExpected) { + throw new UnexpectedException( + String.format( + "Unexpected number of subtypes in %s. Expected: %d, Actual: %d", + definingType, groupCountExpected, subTypeMatcher.groupCount())); + } + return Optional.of(subTypeMatcher.group(1)); } return Optional.empty(); } diff --git a/sfge/src/main/java/com/salesforce/graph/symbols/ClassStaticScope.java b/sfge/src/main/java/com/salesforce/graph/symbols/ClassStaticScope.java index df046f8bf..8187bcd53 100644 --- a/sfge/src/main/java/com/salesforce/graph/symbols/ClassStaticScope.java +++ b/sfge/src/main/java/com/salesforce/graph/symbols/ClassStaticScope.java @@ -96,7 +96,7 @@ private static List getFieldDeclarations( g.V(userClass.getId()) .out(Schema.CHILD) .hasLabel(NodeType.METHOD) - .has(Schema.NAME, MethodUtil.STATIC_CONSTRUCTOR_CANONICAL_NAME) + .has(Schema.NAME, Schema.STATIC_CONSTRUCTOR_CANONICAL_NAME) // This is a static initializer block, not a constructor .has(Schema.CONSTRUCTOR, false) .has(Schema.ARITY, 0) diff --git a/sfge/src/main/java/com/salesforce/graph/symbols/PathScopeVisitor.java b/sfge/src/main/java/com/salesforce/graph/symbols/PathScopeVisitor.java index 15007bf5e..1c53347bf 100644 --- a/sfge/src/main/java/com/salesforce/graph/symbols/PathScopeVisitor.java +++ b/sfge/src/main/java/com/salesforce/graph/symbols/PathScopeVisitor.java @@ -2,24 +2,16 @@ import com.salesforce.apex.jorje.ASTConstants; import com.salesforce.collections.CollectionUtil; +import com.salesforce.config.UserFacingMessages; import com.salesforce.exception.TodoException; import com.salesforce.exception.UnexpectedException; +import com.salesforce.exception.UserActionException; import com.salesforce.graph.ops.ApexClassUtil; import com.salesforce.graph.ops.ApexStandardLibraryUtil; import com.salesforce.graph.ops.ApexValueUtil; import com.salesforce.graph.ops.CloneUtil; import com.salesforce.graph.ops.MethodUtil; -import com.salesforce.graph.symbols.apex.ApexClassInstanceValue; -import com.salesforce.graph.symbols.apex.ApexForLoopValue; -import com.salesforce.graph.symbols.apex.ApexListValue; -import com.salesforce.graph.symbols.apex.ApexSingleValue; -import com.salesforce.graph.symbols.apex.ApexSoqlValue; -import com.salesforce.graph.symbols.apex.ApexStandardValue; -import com.salesforce.graph.symbols.apex.ApexStringValue; -import com.salesforce.graph.symbols.apex.ApexValue; -import com.salesforce.graph.symbols.apex.ApexValueBuilder; -import com.salesforce.graph.symbols.apex.ComplexAssignable; -import com.salesforce.graph.symbols.apex.ValueStatus; +import com.salesforce.graph.symbols.apex.*; import com.salesforce.graph.vertex.ArrayLoadExpressionVertex; import com.salesforce.graph.vertex.AssignmentExpressionVertex; import com.salesforce.graph.vertex.BaseSFVertex; @@ -1600,7 +1592,11 @@ public boolean visit(VariableDeclarationVertex vertex) { final String key = vertex.getName(); if (apexValueStack.peek().containsKey(key)) { // The variable was defined multiple times - throw new UnexpectedException(vertex); + throw new UserActionException( + UserFacingMessages.VARIABLE_DECLARED_MULTIPLE_TIMES, + vertex.getFileName(), + vertex.getDefiningType(), + vertex.getBeginLine()); } ChainedVertex rhs = vertex.getRhs().orElse(null); @@ -1654,6 +1650,7 @@ public boolean visit(VariableExpressionVertex.ForLoop vertex) { trackVisited(vertex); String key = vertex.getName(); ApexValue apexValue = getApexValue(key).get(); + ApexValue newValue = ApexValueBuilder.get(this) .declarationVertex(apexValue.getTypeVertex().get()) diff --git a/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexForLoopValue.java b/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexForLoopValue.java index f88390866..66127d637 100644 --- a/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexForLoopValue.java +++ b/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexForLoopValue.java @@ -7,12 +7,7 @@ import com.salesforce.graph.symbols.DeepCloneContextProvider; import com.salesforce.graph.symbols.ScopeUtil; import com.salesforce.graph.symbols.SymbolProvider; -import com.salesforce.graph.vertex.ChainedVertex; -import com.salesforce.graph.vertex.InvocableVertex; -import com.salesforce.graph.vertex.MethodCallExpressionVertex; -import com.salesforce.graph.vertex.NewListInitExpressionVertex; -import com.salesforce.graph.vertex.NewListLiteralExpressionVertex; -import com.salesforce.graph.vertex.VariableExpressionVertex; +import com.salesforce.graph.vertex.*; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -31,19 +26,23 @@ public final class ApexForLoopValue extends ApexPropertiesValue(); setValue(value); if (valueVertex != null && !(valueVertex instanceof VariableExpressionVertex.ForLoop) && !(valueVertex instanceof NewListInitExpressionVertex) - && !(valueVertex instanceof NewListLiteralExpressionVertex)) { + && !(valueVertex instanceof NewListLiteralExpressionVertex) + && !(valueVertex instanceof NewSetInitExpressionVertex) + && !(valueVertex instanceof NewSetLiteralExpressionVertex)) { throw new UnexpectedException(valueVertex); } } @@ -83,30 +82,36 @@ public List> getForLoopValues() { private void setValue(@Nullable ChainedVertex valueVertex, SymbolProvider symbolProvider) { this.items.clear(); - ApexListValue apexListValue = null; + ApexIterableCollectionValue collectionValue = null; if (valueVertex instanceof VariableExpressionVertex.ForLoop) { ChainedVertex forLoopValues = ((VariableExpressionVertex.ForLoop) valueVertex).getForLoopValues(); ApexValue apexValue = ScopeUtil.resolveToApexValue(symbolProvider, forLoopValues).orElse(null); - if (apexValue instanceof ApexListValue) { - apexListValue = (ApexListValue) apexValue; + if (apexValue instanceof ApexIterableCollectionValue) { + collectionValue = (ApexIterableCollectionValue) apexValue; } } else if (valueVertex instanceof NewListLiteralExpressionVertex) { - apexListValue = + collectionValue = ApexValueBuilder.get(symbolProvider).valueVertex(valueVertex).buildList(); + } else if (valueVertex instanceof NewSetLiteralExpressionVertex) { + collectionValue = + ApexValueBuilder.get(symbolProvider).valueVertex(valueVertex).buildSet(); } - if (apexListValue != null) { - setValue(apexListValue); + if (collectionValue != null) { + setValue(collectionValue); } } - private void setValue(ApexListValue apexListValue) { + private void setValue(ApexIterableCollectionValue collectionValue) { // Pass on sanitization information - AbstractSanitizableValue.copySanitization(apexListValue, this); + if (collectionValue instanceof AbstractSanitizableValue) { + AbstractSanitizableValue.copySanitization( + (AbstractSanitizableValue) collectionValue, this); + } // Add items in the list - for (ApexValue item : apexListValue.getValues()) { + for (ApexValue item : collectionValue.getValues()) { items.add(item); } } @@ -125,10 +130,21 @@ public Optional> apply(MethodCallExpressionVertex vertex, SymbolPro // This will check for null access and throw an exception if this would never // continue in production apexValue.checkForNullAccess(vertex, symbols); - ApexValue applied = apexValue.apply(vertex, symbols).orElse(null); - final ApexValue valueToAdd = applied != null ? applied : apexValue.deepClone(); - - result.items.add(valueToAdd); + Optional> optApplied = apexValue.apply(vertex, symbols); + ApexValue valueToAdd; + if (optApplied.isPresent()) { + valueToAdd = optApplied.get(); + } else { + // TODO: path expander needs to expand on this method call and return a value + // For now, we clone the same apexValue as a temporary bandage + // to handle cases where we don't know what value a method call returns. + // NOTE: The returned value is incorrect until we fix this. + valueToAdd = apexValue.deepClone(); + } + + if (valueToAdd != null) { + result.items.add(valueToAdd); + } } } diff --git a/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexIterableCollectionValue.java b/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexIterableCollectionValue.java new file mode 100644 index 000000000..03f482bd7 --- /dev/null +++ b/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexIterableCollectionValue.java @@ -0,0 +1,19 @@ +package com.salesforce.graph.symbols.apex; + +import com.salesforce.graph.vertex.Typeable; +import java.util.Collection; +import java.util.Optional; + +/** Represents ApexValues that hold an iterable collection of values. */ +public interface ApexIterableCollectionValue { + /** + * @return values that the collection holds + */ + Collection> getValues(); + + /** + * @return SubType of the collection value. For example, List would return a String + * Typeable + */ + Optional getSubType(); +} diff --git a/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexListValue.java b/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexListValue.java index ce4ce92d2..e1b09275a 100644 --- a/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexListValue.java +++ b/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexListValue.java @@ -9,6 +9,7 @@ import com.salesforce.graph.DeepCloneable; import com.salesforce.graph.ops.ApexValueUtil; import com.salesforce.graph.ops.CloneUtil; +import com.salesforce.graph.ops.TypeableUtil; import com.salesforce.graph.symbols.ScopeUtil; import com.salesforce.graph.symbols.SymbolProvider; import com.salesforce.graph.vertex.ChainedVertex; @@ -24,7 +25,6 @@ import java.util.Optional; import java.util.TreeMap; import java.util.function.Function; -import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.Nullable; import org.apache.commons.lang3.tuple.Pair; @@ -36,7 +36,7 @@ * https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex_methods_system_list.htm */ public final class ApexListValue extends AbstractSanitizableValue - implements DeepCloneable { + implements DeepCloneable, ApexIterableCollectionValue { private static final Logger LOGGER = LogManager.getLogger(ApexListValue.class); public static final String METHOD_ADD = "add"; @@ -100,6 +100,7 @@ public U accept(ApexValueVisitor visitor) { return visitor.visit(this); } + @Override public List> getValues() { return Collections.unmodifiableList(values); } @@ -361,19 +362,20 @@ private static Optional identifyType(@Nullable Typeable typeable) { } final String canonicalType = typeable.getCanonicalType(); - final Matcher matcher = TYPE_PATTERN.matcher(canonicalType); - if (matcher.find()) { - if (matcher.groupCount() != 1) { - throw new UnexpectedException( - "Expected to find only one Type in list declaration: " + typeable); - } - return Optional.of(SyntheticTypedVertex.get(matcher.group(1))); + Optional optSubType = TypeableUtil.getListSubType(canonicalType); + if (optSubType.isPresent()) { + return Optional.of(SyntheticTypedVertex.get(optSubType.get())); } // If we don't find a match, we are still okay. return Optional.empty(); } + @Override + public Optional getSubType() { + return Optional.ofNullable(this.listType); + } + @Override public void markSanitized( MethodBasedSanitization.SanitizerMechanism sanitizerMechanism, diff --git a/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexSetValue.java b/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexSetValue.java index a2c020649..fee616fe3 100644 --- a/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexSetValue.java +++ b/sfge/src/main/java/com/salesforce/graph/symbols/apex/ApexSetValue.java @@ -8,13 +8,10 @@ import com.salesforce.graph.DeepCloneable; import com.salesforce.graph.ops.ApexValueUtil; import com.salesforce.graph.ops.CloneUtil; +import com.salesforce.graph.ops.TypeableUtil; import com.salesforce.graph.symbols.ScopeUtil; import com.salesforce.graph.symbols.SymbolProvider; -import com.salesforce.graph.vertex.ChainedVertex; -import com.salesforce.graph.vertex.InvocableVertex; -import com.salesforce.graph.vertex.MethodCallExpressionVertex; -import com.salesforce.graph.vertex.NewSetInitExpressionVertex; -import com.salesforce.graph.vertex.NewSetLiteralExpressionVertex; +import com.salesforce.graph.vertex.*; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -30,7 +27,8 @@ // constructor // invokes ApexListValue.getValues(). Since ApexListValue is final, the method can't be overriden. // Hence the violation is incorrect. -public class ApexSetValue extends ApexValue implements DeepCloneable { +public class ApexSetValue extends AbstractSanitizableValue + implements DeepCloneable, ApexIterableCollectionValue { private static final String METHOD_ADD = "add"; private static final String METHOD_CLEAR = "clear"; private static final String METHOD_CLONE = "clone"; @@ -39,17 +37,21 @@ public class ApexSetValue extends ApexValue implements DeepCloneab protected static final String METHOD_SIZE = "size"; private final NonNullHashSet> values; + private final Typeable type; public ApexSetValue(ApexValueBuilder builder) { super(builder); this.values = CollectionUtil.newNonNullHashSet(); setValue(builder.getValueVertex(), builder.getSymbolProvider()); + final Typeable typeable = builder.getMostSpecificTypedVertex().orElse(null); + this.type = identifyType(typeable).orElse(null); } /** DeepCloneable#deepClone constructor */ private ApexSetValue(ApexSetValue other) { super(other, other.getReturnedFrom().orElse(null), other.getInvocable().orElse(null)); this.values = CloneUtil.cloneNonNullHashSet(other.values); + this.type = other.type; } /** @@ -64,6 +66,7 @@ private ApexSetValue( @Nullable InvocableVertex invocable) { super(other, returnedFrom, invocable); this.values = values; + this.type = other.type; } @Override @@ -188,6 +191,7 @@ public void add(ApexValue value) { this.values.add(value); } + @Override public List> getValues() { return Collections.unmodifiableList(values.stream().collect(Collectors.toList())); } @@ -196,4 +200,24 @@ public List> getValues() { public Optional getDefiningType() { return Optional.empty(); } + + // TODO: consolidate logic with ApexListValue + private static Optional identifyType(@Nullable Typeable typeable) { + if (typeable == null) { + return Optional.empty(); + } + + final Optional optSubType = TypeableUtil.getSetSubType(typeable.getCanonicalType()); + if (optSubType.isPresent()) { + return Optional.of(SyntheticTypedVertex.get(optSubType.get())); + } + + // If we don't find a match, we are still okay. + return Optional.empty(); + } + + @Override + public Optional getSubType() { + return Optional.ofNullable(this.type); + } } diff --git a/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/DescribeFieldResult.java b/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/DescribeFieldResult.java index a049eff89..b93284acc 100644 --- a/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/DescribeFieldResult.java +++ b/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/DescribeFieldResult.java @@ -5,13 +5,7 @@ import com.salesforce.graph.ops.ApexValueUtil; import com.salesforce.graph.ops.CloneUtil; import com.salesforce.graph.symbols.SymbolProvider; -import com.salesforce.graph.symbols.apex.ApexForLoopValue; -import com.salesforce.graph.symbols.apex.ApexSingleValue; -import com.salesforce.graph.symbols.apex.ApexStandardValue; -import com.salesforce.graph.symbols.apex.ApexStringValue; -import com.salesforce.graph.symbols.apex.ApexValue; -import com.salesforce.graph.symbols.apex.ApexValueBuilder; -import com.salesforce.graph.symbols.apex.ApexValueVisitor; +import com.salesforce.graph.symbols.apex.*; import com.salesforce.graph.vertex.InvocableWithParametersVertex; import com.salesforce.graph.vertex.MethodCallExpressionVertex; import com.salesforce.graph.vertex.MethodVertex; @@ -121,7 +115,16 @@ public Optional> getFieldName() { @Override public Optional> apply(MethodCallExpressionVertex vertex, SymbolProvider symbols) { - return Optional.empty(); + final String methodName = vertex.getMethodName(); + ApexValueBuilder builder = ApexValueBuilder.get(symbols).returnedFrom(this, vertex); + + ApexValue apexValue; + if (SystemNames.DML_FIELD_ACCESS_METHODS.contains(methodName)) { + apexValue = builder.withStatus(ValueStatus.INDETERMINANT).buildBoolean(); + } else { + apexValue = _applyMethod(vertex, builder, methodName).orElse(null); + } + return Optional.ofNullable(apexValue); } @Override @@ -136,6 +139,20 @@ public Optional> executeMethod( .methodVertex(method); String methodName = method.getName(); + Optional> optApexValue = + _applyMethod(invocableExpression, builder, methodName); + + if (!optApexValue.isPresent()) { + optApexValue = Optional.of(ApexValueUtil.synthesizeReturnedValue(builder, method)); + } + + return optApexValue; + } + + private Optional> _applyMethod( + InvocableWithParametersVertex invocableExpression, + ApexValueBuilder builder, + String methodName) { if (METHOD_GET_NAME.equalsIgnoreCase(methodName)) { if (fieldName != null && fieldName.isDeterminant()) { if (fieldName instanceof ApexStringValue) { @@ -163,9 +180,8 @@ public Optional> executeMethod( builder.buildSObjectField( describeSObjectResult.getSObjectType().get(), fieldName)); } - } else { - return Optional.of(ApexValueUtil.synthesizeReturnedValue(builder, method)); } + return Optional.empty(); } @Override diff --git a/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/DescribeSObjectResult.java b/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/DescribeSObjectResult.java index 68213a678..d3fe85d71 100644 --- a/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/DescribeSObjectResult.java +++ b/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/DescribeSObjectResult.java @@ -6,12 +6,7 @@ import com.salesforce.graph.ops.ApexValueUtil; import com.salesforce.graph.ops.CloneUtil; import com.salesforce.graph.symbols.SymbolProvider; -import com.salesforce.graph.symbols.apex.ApexStandardValue; -import com.salesforce.graph.symbols.apex.ApexStringValue; -import com.salesforce.graph.symbols.apex.ApexValue; -import com.salesforce.graph.symbols.apex.ApexValueBuilder; -import com.salesforce.graph.symbols.apex.ApexValueVisitor; -import com.salesforce.graph.symbols.apex.ValueStatus; +import com.salesforce.graph.symbols.apex.*; import com.salesforce.graph.vertex.InvocableWithParametersVertex; import com.salesforce.graph.vertex.MethodCallExpressionVertex; import com.salesforce.graph.vertex.MethodVertex; @@ -79,34 +74,40 @@ public Optional> apply(MethodCallExpressionVertex vertex, SymbolPro String methodName = vertex.getMethodName(); List chainedNames = vertex.getChainedNames(); + ApexValue apexValue = null; + if (METHOD_GET_MAP.equalsIgnoreCase(methodName) && !chainedNames.isEmpty()) { String mapType = chainedNames.get(chainedNames.size() - 1); // objectDescribe.fields.getMap() if (FIELDS.equalsIgnoreCase(mapType)) { validateParameterSize(vertex, 0); if (sObjectType != null) { - return Optional.of(builder.buildApexFieldDescribeMapValue(sObjectType)); + apexValue = builder.buildApexFieldDescribeMapValue(sObjectType); } else { - return Optional.of( + apexValue = builder.buildApexFieldDescribeMapValue( - builder.deepClone().buildSObjectType())); + builder.deepClone().buildSObjectType()); } // objectDescribe.fieldSets.getMap() } else if (FIELD_SETS.equalsIgnoreCase(mapType)) { validateParameterSize(vertex, 0); if (sObjectType != null) { - return Optional.of(builder.buildApexFieldSetDescribeMapValue(sObjectType)); + apexValue = builder.buildApexFieldSetDescribeMapValue(sObjectType); } else { - return Optional.of( + apexValue = builder.buildApexFieldSetDescribeMapValue( - builder.deepClone().buildSObjectType())); + builder.deepClone().buildSObjectType()); } } else { throw new UnexpectedException(vertex); } } - return Optional.empty(); + if (apexValue == null) { + apexValue = _applyMethod(vertex, builder, methodName).orElse(null); + } + + return Optional.ofNullable(apexValue); } @Override @@ -120,6 +121,19 @@ public Optional> executeMethod( .methodVertex(method); String methodName = method.getName(); + Optional> optApexValue = + _applyMethod(invocableExpression, builder, methodName); + + if (!optApexValue.isPresent()) { + optApexValue = Optional.of(ApexValueUtil.synthesizeReturnedValue(builder, method)); + } + return optApexValue; + } + + private Optional> _applyMethod( + InvocableWithParametersVertex invocableExpression, + ApexValueBuilder builder, + String methodName) { if (METHOD_GET_NAME.equalsIgnoreCase(methodName)) { if (sObjectType != null && ApexValueUtil.isDeterminant(sObjectType.getType())) { ApexValue value = sObjectType.getType().get(); @@ -153,9 +167,10 @@ public Optional> executeMethod( } else { return Optional.of(builder.buildSObjectType()); } - } else { - return Optional.of(ApexValueUtil.synthesizeReturnedValue(builder, method)); + } else if (SystemNames.DML_OBJECT_ACCESS_METHODS.contains(methodName)) { + return Optional.of(builder.withStatus(ValueStatus.INDETERMINANT).buildBoolean()); } + return Optional.empty(); } @Override diff --git a/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/FieldSet.java b/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/FieldSet.java index b0230d92f..d4950761d 100644 --- a/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/FieldSet.java +++ b/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/FieldSet.java @@ -16,6 +16,7 @@ import com.salesforce.graph.vertex.MethodCallExpressionVertex; import com.salesforce.graph.vertex.MethodVertex; import com.salesforce.graph.vertex.SyntheticTypedVertex; +import java.util.Objects; import java.util.Optional; public final class FieldSet extends ApexStandardValue
implements DeepCloneable
{ @@ -98,7 +99,10 @@ public Optional> getFieldSetName() { @Override public Optional> apply(MethodCallExpressionVertex vertex, SymbolProvider symbols) { - return Optional.empty(); + ApexValueBuilder builder = ApexValueBuilder.get(symbols).returnedFrom(this, vertex); + final String methodName = vertex.getMethodName(); + + return _applyMethod(vertex, builder, methodName); } @Override @@ -113,6 +117,19 @@ public Optional> executeMethod( String methodName = method.getName(); + ApexValue apexValue = + _applyMethod(invocableExpression, builder, methodName).orElse(null); + + if (apexValue == null) { + apexValue = ApexValueUtil.synthesizeReturnedValue(builder, method); + } + return Optional.ofNullable(apexValue); + } + + private Optional> _applyMethod( + InvocableWithParametersVertex invocableExpression, + ApexValueBuilder builder, + String methodName) { if (METHOD_GET_FIELDS.equalsIgnoreCase(methodName)) { builder.declarationVertex(SyntheticTypedVertex.get(FieldSetMember.TYPE)); return Optional.of(builder.buildFieldSetList(this)); @@ -122,8 +139,22 @@ public Optional> executeMethod( } else { return Optional.of(builder.buildSObjectType()); } - } else { - return Optional.of(ApexValueUtil.synthesizeReturnedValue(builder, method)); } + return Optional.empty(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + FieldSet fieldSet = (FieldSet) o; + return Objects.equals(sObjectType, fieldSet.sObjectType) + && Objects.equals(fieldSetName, fieldSet.fieldSetName); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), sObjectType, fieldSetName); } } diff --git a/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/FieldSetMember.java b/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/FieldSetMember.java index b41129ed6..3ff53194a 100644 --- a/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/FieldSetMember.java +++ b/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/FieldSetMember.java @@ -14,6 +14,7 @@ import com.salesforce.graph.vertex.InvocableWithParametersVertex; import com.salesforce.graph.vertex.MethodCallExpressionVertex; import com.salesforce.graph.vertex.MethodVertex; +import java.util.Objects; import java.util.Optional; import javax.annotation.Nullable; @@ -64,7 +65,10 @@ public FieldSet getFieldSet() { @Override public Optional> apply(MethodCallExpressionVertex vertex, SymbolProvider symbols) { - return Optional.empty(); + ApexValueBuilder builder = ApexValueBuilder.get(symbols).returnedFrom(this, vertex); + final String methodName = vertex.getMethodName(); + + return _applyMethod(builder, methodName); } @Override @@ -79,13 +83,35 @@ public Optional> executeMethod( String methodName = method.getName(); + ApexValue apexValue = _applyMethod(builder, methodName).orElse(null); + + if (apexValue == null) { + apexValue = ApexValueUtil.synthesizeReturnedValue(builder, method); + } + return Optional.ofNullable(apexValue); + } + + private Optional> _applyMethod(ApexValueBuilder builder, String methodName) { if (methodName.equalsIgnoreCase(METHOD_GET_S_OBJECT_FIELD)) { // We don't know the field name, create an indeterminant string ApexStringValue fieldName = builder.deepClone().buildString(); return Optional.of( builder.buildSObjectField(fieldSet.getSObjectType().get(), fieldName)); - } else { - return Optional.of(ApexValueUtil.synthesizeReturnedValue(builder, method)); } + return Optional.empty(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + FieldSetMember that = (FieldSetMember) o; + return Objects.equals(fieldSet, that.fieldSet); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), fieldSet); } } diff --git a/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/SObjectField.java b/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/SObjectField.java index 10e0ca6e9..ec71f0f6f 100644 --- a/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/SObjectField.java +++ b/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/SObjectField.java @@ -17,6 +17,7 @@ import com.salesforce.graph.vertex.InvocableWithParametersVertex; import com.salesforce.graph.vertex.MethodCallExpressionVertex; import com.salesforce.graph.vertex.MethodVertex; +import java.util.Objects; import java.util.Optional; import javax.annotation.Nullable; @@ -118,7 +119,9 @@ public Optional> getFieldname() { @Override public Optional> apply(MethodCallExpressionVertex vertex, SymbolProvider symbols) { - return Optional.empty(); + ApexValueBuilder builder = ApexValueBuilder.get(symbols).returnedFrom(this, vertex); + String methodName = vertex.getMethodName(); + return _applyMethod(vertex, builder, methodName); } @Override @@ -132,6 +135,13 @@ public Optional> executeMethod( .methodVertex(method); String methodName = method.getName(); + return _applyMethod(invocableExpression, builder, methodName); + } + + private Optional> _applyMethod( + InvocableWithParametersVertex invocableExpression, + ApexValueBuilder builder, + String methodName) { if (METHOD_GET_DESCRIBE.equalsIgnoreCase(methodName)) { if (associatedObjectType != null && fieldName != null) { DescribeSObjectResult describeSObjectResult = @@ -145,4 +155,19 @@ public Optional> executeMethod( throw new TodoException(invocableExpression); } } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + SObjectField that = (SObjectField) o; + return Objects.equals(associatedObjectType, that.associatedObjectType) + && Objects.equals(fieldName, that.fieldName); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), associatedObjectType, fieldName); + } } diff --git a/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/SObjectType.java b/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/SObjectType.java index c01cb6236..6b5d08b10 100644 --- a/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/SObjectType.java +++ b/sfge/src/main/java/com/salesforce/graph/symbols/apex/schema/SObjectType.java @@ -101,7 +101,10 @@ public Optional> getType() { @Override public Optional> apply(MethodCallExpressionVertex vertex, SymbolProvider symbols) { - return Optional.empty(); + final String methodName = vertex.getMethodName(); + ApexValueBuilder builder = ApexValueBuilder.get(symbols).returnedFrom(this, vertex); + + return _applyMethod(vertex, builder, methodName); } @Override @@ -115,6 +118,13 @@ public Optional> executeMethod( .methodVertex(method); String methodName = method.getName(); + return _applyMethod(invocableExpression, builder, methodName); + } + + private Optional> _applyMethod( + InvocableWithParametersVertex invocableExpression, + ApexValueBuilder builder, + String methodName) { if (METHOD_GET_DESCRIBE.equalsIgnoreCase(methodName)) { return Optional.of(builder.buildDescribeSObjectResult(this)); } else if (METHOD_NEW_S_OBJECT.equalsIgnoreCase(methodName)) { diff --git a/sfge/src/main/java/com/salesforce/graph/vertex/MethodCallExpressionVertex.java b/sfge/src/main/java/com/salesforce/graph/vertex/MethodCallExpressionVertex.java index 9f138ca38..371239df0 100644 --- a/sfge/src/main/java/com/salesforce/graph/vertex/MethodCallExpressionVertex.java +++ b/sfge/src/main/java/com/salesforce/graph/vertex/MethodCallExpressionVertex.java @@ -8,6 +8,7 @@ import com.salesforce.graph.symbols.SymbolProvider; import com.salesforce.graph.symbols.SymbolProviderVertexVisitor; import com.salesforce.graph.visitor.PathVertexVisitor; +import com.salesforce.graph.visitor.TypedVertexVisitor; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -256,6 +257,11 @@ public String getMethodName() { return (String) properties.get(Schema.METHOD_NAME); } + @Override + public T accept(TypedVertexVisitor visitor) { + return visitor.visit(this); + } + /** * @return true if the method is qualified by a 'this' expression. i.e. Returns true for * "this.aList.size();" @@ -270,6 +276,14 @@ public boolean isThisReference() { } } + /** + * @return - True if the method is qualified by an empty reference expression. i.e., returns + * true for `someMethod()` but not `this.someMethod()` or `a.someMethod()`. + */ + public boolean isEmptyReference() { + return referenceExpression.get() instanceof EmptyReferenceExpressionVertex; + } + private LazyVertex _getReferenceVertex() { return new LazyVertex<>( () -> diff --git a/sfge/src/main/java/com/salesforce/graph/vertex/MethodVertex.java b/sfge/src/main/java/com/salesforce/graph/vertex/MethodVertex.java index 3f0b59fd9..3e843077b 100644 --- a/sfge/src/main/java/com/salesforce/graph/vertex/MethodVertex.java +++ b/sfge/src/main/java/com/salesforce/graph/vertex/MethodVertex.java @@ -54,6 +54,10 @@ protected String initialize() throws ConcurrentException { } } + public boolean isAbstract() { + return getModifierNode().isAbstract(); + } + public boolean isConstructor() { return getBoolean(Schema.CONSTRUCTOR); } @@ -79,6 +83,10 @@ public List getAnnotations() { return annotations.get(); } + public boolean hasAnnotation(String annotation) { + return annotations.get().stream().anyMatch(a -> a.getName().equalsIgnoreCase(annotation)); + } + public LazyVertexList _getAnnotations() { return new LazyVertexList<>( () -> diff --git a/sfge/src/main/java/com/salesforce/graph/vertex/ModifierNodeVertex.java b/sfge/src/main/java/com/salesforce/graph/vertex/ModifierNodeVertex.java index edde204ab..681abd049 100644 --- a/sfge/src/main/java/com/salesforce/graph/vertex/ModifierNodeVertex.java +++ b/sfge/src/main/java/com/salesforce/graph/vertex/ModifierNodeVertex.java @@ -67,6 +67,10 @@ public boolean isGlobal() { return getBoolean(Schema.GLOBAL); } + public boolean isVirtual() { + return getBoolean(Schema.VIRTUAL); + } + public Integer getModifiers() { return (Integer) properties.get(Schema.MODIFIERS); } diff --git a/sfge/src/main/java/com/salesforce/graph/vertex/SuperMethodCallExpressionVertex.java b/sfge/src/main/java/com/salesforce/graph/vertex/SuperMethodCallExpressionVertex.java index e016feba7..9e5fbbd12 100644 --- a/sfge/src/main/java/com/salesforce/graph/vertex/SuperMethodCallExpressionVertex.java +++ b/sfge/src/main/java/com/salesforce/graph/vertex/SuperMethodCallExpressionVertex.java @@ -4,6 +4,7 @@ import com.salesforce.graph.symbols.SymbolProvider; import com.salesforce.graph.symbols.SymbolProviderVertexVisitor; import com.salesforce.graph.visitor.PathVertexVisitor; +import com.salesforce.graph.visitor.TypedVertexVisitor; import java.util.Map; import java.util.function.Consumer; import org.apache.tinkerpop.gremlin.process.traversal.Order; @@ -44,4 +45,9 @@ public void afterVisit(PathVertexVisitor visitor, SymbolProvider symbols) { public void afterVisit(SymbolProviderVertexVisitor visitor) { visitor.afterVisit(this); } + + @Override + public T accept(TypedVertexVisitor visitor) { + return visitor.visit(this); + } } diff --git a/sfge/src/main/java/com/salesforce/graph/vertex/ThisMethodCallExpressionVertex.java b/sfge/src/main/java/com/salesforce/graph/vertex/ThisMethodCallExpressionVertex.java index b697d0011..6d4d091d6 100644 --- a/sfge/src/main/java/com/salesforce/graph/vertex/ThisMethodCallExpressionVertex.java +++ b/sfge/src/main/java/com/salesforce/graph/vertex/ThisMethodCallExpressionVertex.java @@ -4,6 +4,7 @@ import com.salesforce.graph.symbols.SymbolProvider; import com.salesforce.graph.symbols.SymbolProviderVertexVisitor; import com.salesforce.graph.visitor.PathVertexVisitor; +import com.salesforce.graph.visitor.TypedVertexVisitor; import java.util.Map; import java.util.function.Consumer; import org.apache.tinkerpop.gremlin.process.traversal.Order; @@ -44,4 +45,9 @@ public void afterVisit(PathVertexVisitor visitor, SymbolProvider symbols) { public void afterVisit(SymbolProviderVertexVisitor visitor) { visitor.afterVisit(this); } + + @Override + public T accept(TypedVertexVisitor visitor) { + return visitor.visit(this); + } } diff --git a/sfge/src/main/java/com/salesforce/graph/vertex/UserClassVertex.java b/sfge/src/main/java/com/salesforce/graph/vertex/UserClassVertex.java index baf903a5c..02042ce0b 100644 --- a/sfge/src/main/java/com/salesforce/graph/vertex/UserClassVertex.java +++ b/sfge/src/main/java/com/salesforce/graph/vertex/UserClassVertex.java @@ -60,6 +60,21 @@ public List getInterfaceNames() { return getStrings(Schema.INTERFACE_NAMES); } + /** + * TODO: Check whether this class can be safely made to extend {@link FieldWithModifierVertex} + */ + public boolean isAbstract() { + return ((ModifierNodeVertex) getOnlyChild(ASTConstants.NodeType.MODIFIER_NODE)) + .isAbstract(); + } + + /** + * TODO: Check whether this class can be safely made to extend {@link FieldWithModifierVertex} + */ + public boolean isVirtual() { + return ((ModifierNodeVertex) getOnlyChild(ASTConstants.NodeType.MODIFIER_NODE)).isVirtual(); + } + public boolean isTest() { return getBoolean(Schema.IS_TEST); } diff --git a/sfge/src/main/java/com/salesforce/graph/visitor/TypedVertexVisitor.java b/sfge/src/main/java/com/salesforce/graph/visitor/TypedVertexVisitor.java index 1861ca829..7ebc490e8 100644 --- a/sfge/src/main/java/com/salesforce/graph/visitor/TypedVertexVisitor.java +++ b/sfge/src/main/java/com/salesforce/graph/visitor/TypedVertexVisitor.java @@ -1,50 +1,7 @@ package com.salesforce.graph.visitor; import com.salesforce.exception.UnexpectedException; -import com.salesforce.graph.vertex.ArrayLoadExpressionVertex; -import com.salesforce.graph.vertex.AssignmentExpressionVertex; -import com.salesforce.graph.vertex.BaseSFVertex; -import com.salesforce.graph.vertex.BlockStatementVertex; -import com.salesforce.graph.vertex.CatchBlockStatementVertex; -import com.salesforce.graph.vertex.DmlDeleteStatementVertex; -import com.salesforce.graph.vertex.DmlInsertStatementVertex; -import com.salesforce.graph.vertex.DmlUndeleteStatementVertex; -import com.salesforce.graph.vertex.DmlUpdateStatementVertex; -import com.salesforce.graph.vertex.DmlUpsertStatementVertex; -import com.salesforce.graph.vertex.ElseWhenBlockVertex; -import com.salesforce.graph.vertex.EmptyReferenceExpressionVertex; -import com.salesforce.graph.vertex.ExpressionStatementVertex; -import com.salesforce.graph.vertex.FieldDeclarationStatementsVertex; -import com.salesforce.graph.vertex.FieldDeclarationVertex; -import com.salesforce.graph.vertex.FieldVertex; -import com.salesforce.graph.vertex.ForEachStatementVertex; -import com.salesforce.graph.vertex.ForLoopStatementVertex; -import com.salesforce.graph.vertex.IdentifierCaseVertex; -import com.salesforce.graph.vertex.IfBlockStatementVertex; -import com.salesforce.graph.vertex.IfElseBlockStatementVertex; -import com.salesforce.graph.vertex.LiteralCaseVertex; -import com.salesforce.graph.vertex.LiteralExpressionVertex; -import com.salesforce.graph.vertex.MethodCallExpressionVertex; -import com.salesforce.graph.vertex.MethodVertex; -import com.salesforce.graph.vertex.ModifierNodeVertex; -import com.salesforce.graph.vertex.NewKeyValueObjectExpressionVertex; -import com.salesforce.graph.vertex.NewListLiteralExpressionVertex; -import com.salesforce.graph.vertex.NewObjectExpressionVertex; -import com.salesforce.graph.vertex.ParameterVertex; -import com.salesforce.graph.vertex.PrefixExpressionVertex; -import com.salesforce.graph.vertex.ReferenceExpressionVertex; -import com.salesforce.graph.vertex.ReturnStatementVertex; -import com.salesforce.graph.vertex.StandardConditionVertex; -import com.salesforce.graph.vertex.SuperMethodCallExpressionVertex; -import com.salesforce.graph.vertex.SwitchStatementVertex; -import com.salesforce.graph.vertex.ThrowStatementVertex; -import com.salesforce.graph.vertex.TryCatchFinallyBlockStatementVertex; -import com.salesforce.graph.vertex.TypeWhenBlockVertex; -import com.salesforce.graph.vertex.ValueWhenBlockVertex; -import com.salesforce.graph.vertex.VariableDeclarationStatementsVertex; -import com.salesforce.graph.vertex.VariableDeclarationVertex; -import com.salesforce.graph.vertex.VariableExpressionVertex; -import com.salesforce.graph.vertex.WhileLoopStatementVertex; +import com.salesforce.graph.vertex.*; /** * A visitor that allows for distinct return values. Use this class to avoid "instanceof" pattern. @@ -225,6 +182,10 @@ public T visit(SuperMethodCallExpressionVertex vertex) { return defaultVisit(vertex); } + public T visit(ThisMethodCallExpressionVertex vertex) { + return defaultVisit(vertex); + } + public T visit(ThrowStatementVertex vertex) { return defaultVisit(vertex); } diff --git a/sfge/src/main/java/com/salesforce/rules/AbstractRuleRunner.java b/sfge/src/main/java/com/salesforce/rules/AbstractRuleRunner.java index 8acabf95d..fa3e68c87 100644 --- a/sfge/src/main/java/com/salesforce/rules/AbstractRuleRunner.java +++ b/sfge/src/main/java/com/salesforce/rules/AbstractRuleRunner.java @@ -6,6 +6,7 @@ import com.salesforce.graph.JustInTimeGraphProvider; import com.salesforce.graph.Schema; import com.salesforce.graph.build.CaseSafePropertyUtil.H; +import com.salesforce.graph.ops.PathEntryPointUtil; import com.salesforce.graph.vertex.MethodVertex; import com.salesforce.rules.ops.ProgressListenerProvider; import java.util.ArrayList; @@ -85,7 +86,7 @@ private Result runPathBasedRules( if (rules.isEmpty()) { return new Result(); } - List pathEntryPoints = RuleUtil.getPathEntryPoints(g, targets); + List pathEntryPoints = PathEntryPointUtil.getPathEntryPoints(g, targets); if (pathEntryPoints.isEmpty()) { LOGGER.info("No path-based entry points found"); return new Result(); diff --git a/sfge/src/main/java/com/salesforce/rules/RuleUtil.java b/sfge/src/main/java/com/salesforce/rules/RuleUtil.java index 358b70f4e..24546d3d0 100644 --- a/sfge/src/main/java/com/salesforce/rules/RuleUtil.java +++ b/sfge/src/main/java/com/salesforce/rules/RuleUtil.java @@ -3,84 +3,31 @@ import com.salesforce.PackageConstants; import com.salesforce.exception.SfgeException; import com.salesforce.exception.SfgeRuntimeException; -import com.salesforce.graph.ops.MethodUtil; -import com.salesforce.graph.vertex.MethodVertex; -import com.salesforce.rules.AbstractRuleRunner.RuleRunnerTarget; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.reflections.Reflections; public final class RuleUtil { private static final Logger LOGGER = LogManager.getLogger(RuleUtil.class); - /** - * Load all path entry points in the graph. - * - * @param g - * @return - */ - public static List getPathEntryPoints(GraphTraversalSource g) { - return getPathEntryPoints(g, new ArrayList<>()); + public static List getEnabledStaticRules() throws RuleNotFoundException { + return getEnabledRules().stream() + .filter(rule -> rule instanceof AbstractStaticRule) + .collect(Collectors.toList()); } - /** - * Load all path entry points specified by the target objects. An empty list implicitly includes - * all files. - */ - public static List getPathEntryPoints( - GraphTraversalSource g, List targets) { - // Sort the list of targets into full-file targets and method-level targets. - List fileLevelTargets = - targets.stream() - .filter(t -> t.getTargetMethods().isEmpty()) - .map(RuleRunnerTarget::getTargetFile) - .collect(Collectors.toList()); - List methodLevelTargets = - targets.stream() - .filter(t -> !t.getTargetMethods().isEmpty()) - .collect(Collectors.toList()); - - // Internally, we'll use a Set to preserve uniqueness. - Set methods = new HashSet<>(); - - // If there are any explicitly targeted files, we must process them. If there are no - // explicit targets of any kind, - // then all files are implicitly targeted. - if (!fileLevelTargets.isEmpty() || targets.isEmpty()) { - // Use the file-level targets to get aura-enabled methods... - methods.addAll(MethodUtil.getAuraEnabledMethods(g, fileLevelTargets)); - // ...and NamespaceAccessible methods... - methods.addAll(MethodUtil.getNamespaceAccessibleMethods(g, fileLevelTargets)); - // ...and RemoteAction methods... - methods.addAll(MethodUtil.getRemoteActionMethods(g, fileLevelTargets)); - // ...and InvocableMethod methods... - methods.addAll(MethodUtil.getInvocableMethodMethods(g, fileLevelTargets)); - // ...and PageReference methods... - methods.addAll(MethodUtil.getPageReferenceMethods(g, fileLevelTargets)); - // ...and global-exposed methods... - methods.addAll(MethodUtil.getGlobalMethods(g, fileLevelTargets)); - // ...and implementations of Messaging.InboundEmailHandler#handleInboundEmail... - methods.addAll(MethodUtil.getInboundEmailHandlerMethods(g, fileLevelTargets)); - // ...and exposed methods on VF controllers. - methods.addAll(MethodUtil.getExposedControllerMethods(g, fileLevelTargets)); - } - - // Also, if there are any specifically targeted methods, they should be included. - if (!methodLevelTargets.isEmpty()) { - methods.addAll(MethodUtil.getTargetedMethods(g, methodLevelTargets)); - } - // Turn the Set into a List so we can return it. - return new ArrayList<>(methods); + public static List getEnabledPathBasedRules() throws RuleNotFoundException { + return getEnabledRules().stream() + .filter(rule -> rule instanceof AbstractPathBasedRule) + .collect(Collectors.toList()); } public static List getEnabledRules() throws RuleNotFoundException { diff --git a/sfge/src/main/java/com/salesforce/rules/UnusedMethodRule.java b/sfge/src/main/java/com/salesforce/rules/UnusedMethodRule.java new file mode 100644 index 000000000..86227633a --- /dev/null +++ b/sfge/src/main/java/com/salesforce/rules/UnusedMethodRule.java @@ -0,0 +1,236 @@ +package com.salesforce.rules; + +import com.salesforce.apex.jorje.ASTConstants; +import com.salesforce.apex.jorje.ASTConstants.NodeType; +import com.salesforce.graph.Schema; +import com.salesforce.graph.ops.PathEntryPointUtil; +import com.salesforce.graph.ops.directive.EngineDirective; +import com.salesforce.graph.vertex.MethodVertex; +import com.salesforce.graph.vertex.SFVertexFactory; +import com.salesforce.rules.unusedmethod.*; +import java.util.*; +import java.util.stream.Collectors; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversal; +import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; +import org.apache.tinkerpop.gremlin.structure.Vertex; + +public class UnusedMethodRule extends AbstractStaticRule { + private static final String URL = + "https://forcedotcom.github.io/sfdx-scanner/en/v3.x/salesforce-graph-engine/rules/#UnusedMethodRule"; + private static final Logger LOGGER = LogManager.getLogger(UnusedMethodRule.class); + private static final String DESCRIPTION = "Identifies methods that are not invoked"; + private static final String VIOLATION_TEMPLATE = "Method %s in class %s is never invoked"; + + GraphTraversalSource g; + /** A helper object used to track state and caching as the rule executes. */ + RuleStateTracker ruleStateTracker; + + private UnusedMethodRule() { + super(); + } + + public static UnusedMethodRule getInstance() { + return LazyHolder.INSTANCE; + } + + @Override + protected int getSeverity() { + return SEVERITY.LOW.code; + } + + @Override + protected String getDescription() { + return DESCRIPTION; + } + + @Override + protected String getCategory() { + return CATEGORY.PERFORMANCE.name; + } + + @Override + protected String getUrl() { + return URL; + } + + @Override + protected boolean isEnabled() { + return true; + } + + public RuleStateTracker getRuleStateTracker() { + return ruleStateTracker; + } + + @Override + protected List _run( + GraphTraversalSource g, GraphTraversal eligibleVertices) { + reset(g); + List candidateVertices = getCandidateVertices(eligibleVertices); + seekMethodUsages(candidateVertices); + return convertMethodsToViolations(); + } + + /** Reset the rule's state to prepare for a subsequent execution. */ + private void reset(GraphTraversalSource g) { + this.g = g; + this.ruleStateTracker = new RuleStateTracker(g); + } + + /** + * Get a list of all method vertices on non-standard types. All such methods are candidates for + * analysis. + */ + private List getCandidateVertices( + GraphTraversal eligibleVertices) { + return SFVertexFactory.loadVertices( + g, eligibleVertices.hasLabel(NodeType.METHOD).hasNot(Schema.IS_STANDARD)); + } + + /** + * Seek an invocation of each provided method, unless the method is deemed to be ineligible for + * analysis. Eligible and unused methods are tracked in {@link #ruleStateTracker}. + */ + private void seekMethodUsages(List candidateVertices) { + for (MethodVertex candidateVertex : candidateVertices) { + // If the method is one that isn't eligible to be analyzed, skip it. + if (methodIsIneligible(candidateVertex)) { + if (LOGGER.isInfoEnabled()) { + LOGGER.info( + "Skipping vertex " + + candidateVertex.getName() + + ", as it is ineligible for analysis"); + } + continue; + } + + // If the method was determined as eligible, track it as such. + ruleStateTracker.trackEligibleMethod(candidateVertex); + + // Check first for internal usage of the method. + InternalCallValidator internalCallValidator = + new InternalCallValidator(candidateVertex, ruleStateTracker); + if (internalCallValidator.methodUsedInternally()) { + continue; + } + + // Next, check for uses of the method by subclasses, + // if the method is invocable in this way. + SubclassCallValidator subclassCallValidator = + new SubclassCallValidator(candidateVertex, ruleStateTracker); + if (subclassCallValidator.methodUsedBySubclass()) { + continue; + } + + // Next, check for invocations of the method by a superclass, + // if the method is invocable in this way. + SuperclassCallValidator superclassCallValidator = + new SuperclassCallValidator(candidateVertex, ruleStateTracker); + if (superclassCallValidator.methodUsedBySuperclass()) { + continue; + } + + // Next, check for invocations of the method by an inner/outer class, + // if the method is invocable in this way. + InnerClassCallValidator innerClassCallValidator = + new InnerClassCallValidator(candidateVertex, ruleStateTracker); + if (innerClassCallValidator.methodUsedByInnerClass()) { + continue; + } + + // Finally, check for external invocations of the method, if the method + // is invocable in this way. + ExternalCallValidator externalCallValidator = + new ExternalCallValidator(candidateVertex, ruleStateTracker); + if (externalCallValidator.methodUsedExternally()) { + continue; + } + + // If we found no usage of the method, then we should track it as unused. + ruleStateTracker.trackUnusedMethod(candidateVertex); + } + } + + /** Convert every known unused method to a violation, and return them in a list. */ + private List convertMethodsToViolations() { + return ruleStateTracker.getUnusedMethods().stream() + .map( + m -> + new Violation.StaticRuleViolation( + String.format( + VIOLATION_TEMPLATE, + m.getName(), + m.getDefiningType()), + m)) + .collect(Collectors.toList()); + } + + /** + * Returns true if the provided method isn't a valid candidate for analysis by this rule. Used + * for filtering the list of all possible candidates into just the eligible ones. + */ + private boolean methodIsIneligible(MethodVertex vertex) { + // TODO: At this time, only private instance methods and private/protected constructors are + // supported. This limit will be loosened over time, and eventually removed entirely. + if (!(vertex.isPrivate() || (vertex.isProtected() && vertex.isConstructor())) + || vertex.isStatic()) { + return true; + } + + // If we're directed to skip this method, obviously we should do so. + if (directedToSkip(vertex)) { + return true; + } + + // Abstract methods must be implemented by all child classes. + // This rule can detect if those implementations are unused, and another rule exists to + // detect unused abstract classes and interface themselves. As such, inspecting + // abstract methods directly is unnecessary. + if (vertex.isAbstract()) { + return true; + } + + // Private constructors with arity of 0 are ineligible. Creating such a constructor is a + // standard way of preventing utility classes whose only methods are static from being + // instantiated at all, so including such methods in our analysis is likely to generate + // more false positives than true positives. + if (vertex.isConstructor() && vertex.isPrivate() && vertex.getArity() == 0) { + return true; + } + + // Methods whose name starts with this prefix are getters/setters. Getters are typically + // used by VF controllers, and setters are frequently made private to render a property + // immutable. As such, inspecting these methods is likely to generate false or noisy + // positives. + if (vertex.getName().toLowerCase().startsWith(ASTConstants.PROPERTY_METHOD_PREFIX)) { + return true; + } + + // Finally, path entry points should be skipped, because they're definitionally publicly + // accessible, and therefore we must assume that they're used somewhere or other. + // But if the method isn't a path entry point, then it's eligible. + return PathEntryPointUtil.isPathEntryPoint(vertex); + } + + /** + * Helper method for {@link #methodIsIneligible(MethodVertex)}. Indicates whether a method is + * annotated with an engine directive denoting that it should be skipped by this rule. + */ + private boolean directedToSkip(MethodVertex methodVertex) { + List directives = methodVertex.getAllEngineDirectives(); + for (EngineDirective directive : directives) { + if (directive.isAnyDisable() + && directive.matchesRule(this.getClass().getSimpleName())) { + return true; + } + } + return false; + } + + private static final class LazyHolder { + // Postpone initialization until first use. + private static final UnusedMethodRule INSTANCE = new UnusedMethodRule(); + } +} diff --git a/sfge/src/main/java/com/salesforce/rules/fls/apex/operations/SchemaBasedValidationAnalyzer.java b/sfge/src/main/java/com/salesforce/rules/fls/apex/operations/SchemaBasedValidationAnalyzer.java index 80f752499..e9dbbdb05 100644 --- a/sfge/src/main/java/com/salesforce/rules/fls/apex/operations/SchemaBasedValidationAnalyzer.java +++ b/sfge/src/main/java/com/salesforce/rules/fls/apex/operations/SchemaBasedValidationAnalyzer.java @@ -22,8 +22,7 @@ import com.salesforce.graph.vertex.MethodCallExpressionVertex; import com.salesforce.graph.vertex.VariableExpressionVertex; import com.salesforce.rules.fls.apex.operations.FlsConstants.FlsValidationType; -import java.util.Optional; -import java.util.Set; +import java.util.*; /** * Checks if a given vertex is a Schema-based validation such as: @@ -58,8 +57,9 @@ public Set checkForValidation( } // Literals absolutely can't have validations. + HashSet results = Sets.newHashSet(); if (vertex instanceof LiteralExpressionVertex) { - return Sets.newHashSet(); + return results; } final Optional> apexValueOptional = @@ -67,10 +67,21 @@ public Set checkForValidation( if (!apexValueOptional.isPresent()) { // If standard condition does not resolve to an ApexValue, there isn't much we can do - return Sets.newHashSet(); + return results; } ApexValue apexValue = apexValueOptional.get(); + List apexBooleanValues = getDerivedApexValue(parent, vertex, apexValue); + + for (ApexBooleanValue booleanValue : apexBooleanValues) { + results.addAll(convert(booleanValue)); + } + + return results; + } + + private List getDerivedApexValue( + BaseSFVertex parent, BaseSFVertex vertex, ApexValue apexValue) { if (!(apexValue instanceof ApexBooleanValue) && !(apexValue instanceof ApexCustomValue) && !(apexValue instanceof ApexForLoopValue) @@ -84,20 +95,35 @@ public Set checkForValidation( + vertex); } + List apexValues = new ArrayList<>(); + // Handle cases such as // if (myObject) { /*Do Something*/ } + + if (apexValue instanceof ApexBooleanValue) { + apexValues.add((ApexBooleanValue) apexValue); + } // Convert the value to a boolean that represents if the object was initialized if (apexValue instanceof ApexCustomValue) { - apexValue = ApexValueBuilder.getWithoutSymbolProvider().buildBoolean(); + apexValues.add(ApexValueBuilder.getWithoutSymbolProvider().buildBoolean()); } - // Convert the value to a boolean that represents if the object was initialized - // TODO: This might need to be more specific - if (apexValue instanceof ApexForLoopValue || apexValue instanceof ApexSingleValue) { - apexValue = ApexValueBuilder.getWithoutSymbolProvider().buildBoolean(); + if (apexValue instanceof ApexForLoopValue) { + List> forLoopValues = ((ApexForLoopValue) apexValue).getForLoopValues(); + for (ApexValue value : forLoopValues) { + if (value instanceof ApexBooleanValue) { + apexValues.add((ApexBooleanValue) value); + } else { + apexValues.addAll(getDerivedApexValue(parent, vertex, value)); + } + } } - return convert((ApexBooleanValue) apexValue); + // Convert the value to a boolean that represents if the object was initialized + if (apexValue instanceof ApexSingleValue) { + apexValues.add(ApexValueBuilder.getWithoutSymbolProvider().buildBoolean()); + } + return apexValues; } private Set convert(ApexBooleanValue apexBooleanValue) { diff --git a/sfge/src/main/java/com/salesforce/rules/unusedmethod/AbstractCallValidator.java b/sfge/src/main/java/com/salesforce/rules/unusedmethod/AbstractCallValidator.java new file mode 100644 index 000000000..46eb51eb8 --- /dev/null +++ b/sfge/src/main/java/com/salesforce/rules/unusedmethod/AbstractCallValidator.java @@ -0,0 +1,74 @@ +package com.salesforce.rules.unusedmethod; + +import com.salesforce.graph.vertex.BaseSFVertex; +import com.salesforce.graph.vertex.ChainedVertex; +import com.salesforce.graph.vertex.InvocableWithParametersVertex; +import com.salesforce.graph.vertex.MethodVertex; +import com.salesforce.graph.visitor.TypedVertexVisitor; +import java.util.List; + +/** + * Abstract base class for validating that a given method is actually executed. Used as a helper + * class by {@link com.salesforce.rules.UnusedMethodRule}. + */ +public abstract class AbstractCallValidator extends TypedVertexVisitor.DefaultNoOp { + /** The method that we want to verify is actually invoked. */ + protected final MethodVertex targetMethod; + + /** A helper object used to track state and caching as the rule executes. */ + protected final RuleStateTracker ruleStateTracker; + + /** + * @param targetMethod - The method for which we're trying to find usages + * @param ruleStateTracker - Helper object provided by the rule + */ + protected AbstractCallValidator(MethodVertex targetMethod, RuleStateTracker ruleStateTracker) { + this.targetMethod = targetMethod; + this.ruleStateTracker = ruleStateTracker; + } + + /** + * Override the default visit method to return false, since vertex types we're not prepared to + * deal with should not be interpreted as usage of the target method. + */ + @Override + public Boolean defaultVisit(BaseSFVertex vertex) { + return false; + } + + /** + * @return True if the provided method call could plausibly be a call of our target method. + */ + private boolean isValidCall(InvocableWithParametersVertex vertex) { + // Submitting a CallValidator to a vertex will cause the vertex to be visited using the + // methods implemented in the relevant CallValidator subclass. + return vertex.accept(this); + } + + /** + * Indicates whether the parameters provided to the given method call approximately match those + * expects by our target method. + */ + protected boolean parametersAreValid(InvocableWithParametersVertex vertex) { + // If the arity is wrong, then it's not a match, but rather a call to another overload of + // the same method. + // TODO: Long-term, we'll want to validate the parameters' types in addition to their count. + List parameters = vertex.getParameters(); + return parameters.size() == targetMethod.getArity(); + } + + /** Checks all provided method calls to see if any could be a call of the target method. */ + protected boolean validatorDetectsUsage(List potentialCallers) { + // For each call... + for (InvocableWithParametersVertex potentialCaller : potentialCallers) { + // Use our visitor to determine whether this invocation is of the target method. + if (isValidCall(potentialCaller)) { + // If our checks are satisfied, then this method call appears to be an invocation of + // the target method. + return true; + } + } + // If we're here, then we exited the loop without finding a call. + return false; + } +} diff --git a/sfge/src/main/java/com/salesforce/rules/unusedmethod/ExternalCallValidator.java b/sfge/src/main/java/com/salesforce/rules/unusedmethod/ExternalCallValidator.java new file mode 100644 index 000000000..6829ee0a2 --- /dev/null +++ b/sfge/src/main/java/com/salesforce/rules/unusedmethod/ExternalCallValidator.java @@ -0,0 +1,35 @@ +package com.salesforce.rules.unusedmethod; + +import com.salesforce.graph.vertex.MethodVertex; + +/** + * Helper class for {@link com.salesforce.rules.UnusedMethodRule}. Used for determining whether a + * method is called in contexts wholly external to its host class. E.g., `someInstance.method()`. + */ +public final class ExternalCallValidator extends AbstractCallValidator { + /** + * @param targetMethod - The method for which we're trying to find usages + * @param ruleStateTracker - Helper object provided by the rule + */ + public ExternalCallValidator(MethodVertex targetMethod, RuleStateTracker ruleStateTracker) { + super(targetMethod, ruleStateTracker); + } + + /** + * Seeks invocations of the provided method in a context wholly external to its host class. + * + * @return - True if such an invocation could be found. Else false. + */ + public boolean methodUsedExternally() { + // TODO: IMPLEMENT THIS METHOD. + return false; + } + + /** + * @return - True if this method is visible externally, e.g., it's a public method. + */ + private boolean methodVisibleExternally() { + // TODO: IMPLEMENT THIS METHOD: + return false; + } +} diff --git a/sfge/src/main/java/com/salesforce/rules/unusedmethod/InnerClassCallValidator.java b/sfge/src/main/java/com/salesforce/rules/unusedmethod/InnerClassCallValidator.java new file mode 100644 index 000000000..d836e9094 --- /dev/null +++ b/sfge/src/main/java/com/salesforce/rules/unusedmethod/InnerClassCallValidator.java @@ -0,0 +1,36 @@ +package com.salesforce.rules.unusedmethod; + +import com.salesforce.graph.vertex.MethodVertex; + +/** + * Helper class for {@link com.salesforce.rules.UnusedMethodRule}. Used for determining whether a + * method is called by an inner/sibling class. + */ +public final class InnerClassCallValidator extends AbstractCallValidator { + /** + * @param targetMethod - The method for which we're trying to find usages + * @param ruleStateTracker - Helper object provided by the rule + */ + public InnerClassCallValidator(MethodVertex targetMethod, RuleStateTracker ruleStateTracker) { + super(targetMethod, ruleStateTracker); + } + + /** + * Seeks invocations of the provided method by inner classes of its host class. + * + * @return - True if such invocations could be found, else false. + */ + public boolean methodUsedByInnerClass() { + // TODO: IMPLEMENT THIS METHOD. + return false; + } + + /** + * @return - true if the provided method is visible to inner classes of the class where it's + * defined. E.g., if it's a static method. + */ + private boolean methodVisibleToInnerClasses() { + // TODO: IMPLEMENT THIS METHOD: + return false; + } +} diff --git a/sfge/src/main/java/com/salesforce/rules/unusedmethod/InternalCallValidator.java b/sfge/src/main/java/com/salesforce/rules/unusedmethod/InternalCallValidator.java new file mode 100644 index 000000000..b57633d6a --- /dev/null +++ b/sfge/src/main/java/com/salesforce/rules/unusedmethod/InternalCallValidator.java @@ -0,0 +1,75 @@ +package com.salesforce.rules.unusedmethod; + +import com.salesforce.graph.vertex.InvocableWithParametersVertex; +import com.salesforce.graph.vertex.MethodCallExpressionVertex; +import com.salesforce.graph.vertex.MethodVertex; +import com.salesforce.graph.vertex.ThisMethodCallExpressionVertex; +import java.util.List; + +/** + * Helper class for {@link com.salesforce.rules.UnusedMethodRule}. Used for determining whether a + * method is called in within the context of its host class. E.g., `this.method()` or `method()`. + */ +public final class InternalCallValidator extends AbstractCallValidator { + /** + * @param targetMethod - The method for which we're trying to find usages + * @param ruleStateTracker - Helper object provided by the rule + */ + public InternalCallValidator(MethodVertex targetMethod, RuleStateTracker ruleStateTracker) { + super(targetMethod, ruleStateTracker); + } + + /** + * Returns true if the validator's target method could plausibly be called within the class + * where it's defined. E.g., as `this.method()` or simply `method()`. + */ + public boolean methodUsedInternally() { + // Get every method call in the class where the target method is defined. + List potentialInternalCallers = + ruleStateTracker.getMethodCallsByDefiningType(targetMethod.getDefiningType()); + // Check for usage amongst those calls. + return validatorDetectsUsage(potentialInternalCallers); + } + + /** + * Handler for method call expressions (e.g., `this.someMethod()`). + * + * @return True if method call could plausibly be of the target method. + */ + @Override + public Boolean visit(MethodCallExpressionVertex vertex) { + // If the target method is a constructor, then this can't be an invocation. + if (targetMethod.isConstructor()) { + return false; + } + // If the contained reference expression isn't a `this` expression or empty, + // then this isn't an internal call, and therefore can't be an internal invocation + // of the method. + if (!vertex.isThisReference() && !vertex.isEmptyReference()) { + return false; + } + + // If the method's name is wrong, it's not a match. + if (!vertex.getMethodName().equalsIgnoreCase(targetMethod.getName())) { + return false; + } + + // The last check we do is whether the parameters are valid. + return parametersAreValid(vertex); + } + + /** + * Handler for invocations of the `this()` constructor. + * + * @return - True if method call could plausibly be of the target method. + */ + @Override + public Boolean visit(ThisMethodCallExpressionVertex vertex) { + // If the target method isn't a constructor, then this can't be an invocation. + if (!targetMethod.isConstructor()) { + return false; + } + // The last check we do is whether the parameters are valid. + return parametersAreValid(vertex); + } +} diff --git a/sfge/src/main/java/com/salesforce/rules/unusedmethod/RuleStateTracker.java b/sfge/src/main/java/com/salesforce/rules/unusedmethod/RuleStateTracker.java new file mode 100644 index 000000000..1fce2038b --- /dev/null +++ b/sfge/src/main/java/com/salesforce/rules/unusedmethod/RuleStateTracker.java @@ -0,0 +1,124 @@ +package com.salesforce.rules.unusedmethod; + +import com.salesforce.apex.jorje.ASTConstants.NodeType; +import com.salesforce.collections.CollectionUtil; +import com.salesforce.graph.Schema; +import com.salesforce.graph.build.CaseSafePropertyUtil.H; +import com.salesforce.graph.vertex.InvocableWithParametersVertex; +import com.salesforce.graph.vertex.MethodVertex; +import com.salesforce.graph.vertex.SFVertexFactory; +import com.salesforce.graph.vertex.UserClassVertex; +import java.util.*; +import java.util.stream.Collectors; +import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; + +/** + * A helper class for {@link com.salesforce.rules.UnusedMethodRule}, which tracks various elements + * of state as the rule executes. + */ +public class RuleStateTracker { + private final GraphTraversalSource g; + /** + * The set of methods on which analysis was performed. Helps us know whether a method returned + * no violations because it was inspected and an invocation was found, or if it was simply never + * inspected in the first place. + */ + private final Set eligibleMethods; + /** + * The set of methods for which no invocation was found. At the end of execution, we'll generate + * violations from each method in this set. + */ + private final Set unusedMethods; + /** + * A map used to cache every method call expression in a given class. Minimizing redundant + * queries is a very high priority for this rule. + */ + private final Map> methodCallsByDefiningType; + /** + * A map used to cache every subclass of a given class. Minimizing redundant queries is a very + * high priority for this rule. + */ + private final Map> subclassesByDefiningType; + + public RuleStateTracker(GraphTraversalSource g) { + this.g = g; + this.eligibleMethods = new HashSet<>(); + this.unusedMethods = new HashSet<>(); + this.methodCallsByDefiningType = CollectionUtil.newTreeMap(); + this.subclassesByDefiningType = CollectionUtil.newTreeMap(); + } + + /** Mark the provided method vertex as a candidate for rule analysis. */ + public void trackEligibleMethod(MethodVertex methodVertex) { + eligibleMethods.add(methodVertex); + } + + /** Mark the provided method as unused. */ + public void trackUnusedMethod(MethodVertex methodVertex) { + unusedMethods.add(methodVertex); + } + + /** Get all of the methods that were found to be unused. */ + public Set getUnusedMethods() { + return unusedMethods; + } + + /** Get the total number of methods deemed eligible for analysis. */ + public int getEligibleMethodCount() { + return eligibleMethods.size(); + } + + /** Return a list of every method call occurring in the specified class. */ + List getMethodCallsByDefiningType(String definingType) { + // First, check if we've already got anything for the desired type. + // If so, we can just return that. + if (this.methodCallsByDefiningType.containsKey(definingType)) { + return this.methodCallsByDefiningType.get(definingType); + } + // Otherwise, we need to do a query. + // Any node with one of these labels is a method call. + List targetLabels = + Arrays.asList( + NodeType.METHOD_CALL_EXPRESSION, + NodeType.THIS_METHOD_CALL_EXPRESSION, + NodeType.SUPER_METHOD_CALL_EXPRESSION); + List methodCalls = + SFVertexFactory.loadVertices( + g, g.V().where(H.has(targetLabels, Schema.DEFINING_TYPE, definingType))); + // Cache and return the results. + this.methodCallsByDefiningType.put(definingType, methodCalls); + return methodCalls; + } + + /** Get all immediate subclasses of the provided classes. */ + List getSubclasses(String... definingTypes) { + List results = new ArrayList<>(); + // For each type we were given... + for (String definingType : definingTypes) { + // If we've already got results for that type, we can just add those results to the + // overall list. + if (this.subclassesByDefiningType.containsKey(definingType)) { + results.addAll(this.subclassesByDefiningType.get(definingType)); + continue; + } + // Otherwise, we need to do some querying. + List subclassVertices = + SFVertexFactory.loadVertices( + g, + g.V() + .where( + H.has( + NodeType.USER_CLASS, + Schema.DEFINING_TYPE, + definingType)) + .out(Schema.EXTENDED_BY)); + List subclassNames = + subclassVertices.stream() + .map(UserClassVertex::getName) + .collect(Collectors.toList()); + this.subclassesByDefiningType.put(definingType, subclassNames); + results.addAll(subclassNames); + } + return results; + } +} diff --git a/sfge/src/main/java/com/salesforce/rules/unusedmethod/SubclassCallValidator.java b/sfge/src/main/java/com/salesforce/rules/unusedmethod/SubclassCallValidator.java new file mode 100644 index 000000000..481ee75f6 --- /dev/null +++ b/sfge/src/main/java/com/salesforce/rules/unusedmethod/SubclassCallValidator.java @@ -0,0 +1,105 @@ +package com.salesforce.rules.unusedmethod; + +import com.salesforce.exception.UnexpectedException; +import com.salesforce.graph.vertex.*; +import java.util.List; + +/** + * Helper class for {@link com.salesforce.rules.UnusedMethodRule}. Used for determining whether a + * method is called in within a subclass of its host class. E.g., `this.method()` if not overridden, + * or `super.method()` if overridden. + */ +public final class SubclassCallValidator extends AbstractCallValidator { + /** + * @param targetMethod - The method for which we're trying to find usages + * @param ruleStateTracker - Helper object provided by the rule + */ + public SubclassCallValidator(MethodVertex targetMethod, RuleStateTracker ruleStateTracker) { + super(targetMethod, ruleStateTracker); + } + + /** + * Seeks invocations of the provided method that occur within subclasses of the class where it's + * defined. E.g., `this.method()` if not overridden, or `super.method()` if overridden. + * + * @return - True if such an invocation is found, else false. + */ + // TODO: Consider optimizing this method to handle entire classes instead of individual methods. + public boolean methodUsedBySubclass() { + // If the method isn't visible to subclasses, it obviously can't be used by them. + if (!methodVisibleToSubclasses()) { + return false; + } + // Get a list of each subclass of the method's host class. + List subclasses = ruleStateTracker.getSubclasses(targetMethod.getDefiningType()); + // Put the check in a loop, so we can recursively process subclasses if needed. + while (!subclasses.isEmpty()) { + // For each of the subclasses... + for (String subclass : subclasses) { + // Get every method call in that class. + List potentialSubclassCallers = + ruleStateTracker.getMethodCallsByDefiningType(subclass); + // If we can find a usage in those subclasses, we're good. + if (validatorDetectsUsage(potentialSubclassCallers)) { + return true; + } + } + // If we're here, then we've checked all subclasses at this level of inheritance. + // Whether we continue depends on the type of the target method. + if (targetMethod.isConstructor()) { + // If the target method is a constructor, we're done, since constructors are only + // visible to immediate children. + break; + } else { + // For non-constructor methods, nested calls are possible, so we should get every + // subclass of the subclasses and keep going. + subclasses = ruleStateTracker.getSubclasses(subclasses.toArray(new String[] {})); + } + } + // If we're here, then we analyzed the entire subclass inheritance tree and found no + // potential invocations. + return false; + } + + /** + * @return True if the target method is visible to subclasses of the class where it's defined. + */ + private boolean methodVisibleToSubclasses() { + // Private methods are not visible to subclasses. + if (targetMethod.isPrivate()) { + return false; + } + // Other methods are visible to subclasses, but if the host class is neither abstract nor + // virtual, then subclasses can't even exist. + UserClassVertex classVertex = + targetMethod + .getParentClass() + .orElseThrow(() -> new UnexpectedException(targetMethod)); + return classVertex.isAbstract() || classVertex.isVirtual(); + } + + /** + * Handler for method call expressions (e.g., `x.someMethod()`). + * + * @return - True if this could plausibly be an invocation of the target method. + */ + @Override + public Boolean visit(MethodCallExpressionVertex vertex) { + // TODO: IMPLEMENT THIS METHOD + return false; + } + + /** + * Handler for super constructor invocation (e.g., `super()). + * + * @return - True if this could plausibly be an invocation of the target method. + */ + @Override + public Boolean visit(SuperMethodCallExpressionVertex vertex) { + // If the target method isn't a constructor, then this can't be an invocation of it. + if (!targetMethod.isConstructor()) { + return false; + } + return parametersAreValid(vertex); + } +} diff --git a/sfge/src/main/java/com/salesforce/rules/unusedmethod/SuperclassCallValidator.java b/sfge/src/main/java/com/salesforce/rules/unusedmethod/SuperclassCallValidator.java new file mode 100644 index 000000000..758804637 --- /dev/null +++ b/sfge/src/main/java/com/salesforce/rules/unusedmethod/SuperclassCallValidator.java @@ -0,0 +1,38 @@ +package com.salesforce.rules.unusedmethod; + +import com.salesforce.graph.vertex.MethodVertex; + +/** + * Helper class for {@link com.salesforce.rules.UnusedMethodRule}. Used for determining whether a + * method is called in within a superclass of its host class. + */ +public final class SuperclassCallValidator extends AbstractCallValidator { + /** + * @param targetMethod - The method for which we're trying to find usages + * @param ruleStateTracker - Helper object provided by the rule + */ + public SuperclassCallValidator(MethodVertex targetMethod, RuleStateTracker ruleStateTracker) { + super(targetMethod, ruleStateTracker); + } + + /** + * Seeks invocations of the provided method that occur within superclasses of the class where + * it's defined. E.g., the parent class declares the method as abstract and then another method + * invokes it. + * + * @return true if such an invocation could be found, else false. + */ + public boolean methodUsedBySuperclass() { + // TODO: IMPLEMENT THIS METHOD. + return false; + } + + /** + * @return - True if the provided method is visible to superclasses of the class where it's + * defined. E.g., as an implementation of an inherited abstract method. + */ + private boolean methodVisibleToSuperClasses(MethodVertex methodVertex) { + // TODO: IMPLEMENT THIS METHOD. + return false; + } +} diff --git a/sfge/src/test/java/com/salesforce/MainTest.java b/sfge/src/test/java/com/salesforce/MainTest.java index 757545d89..037d6c845 100644 --- a/sfge/src/test/java/com/salesforce/MainTest.java +++ b/sfge/src/test/java/com/salesforce/MainTest.java @@ -10,6 +10,7 @@ import com.salesforce.cli.CliArgParser; import com.salesforce.cli.Result; import com.salesforce.config.UserFacingMessages; +import com.salesforce.messaging.CliMessager; import com.salesforce.rules.RuleRunner; import com.salesforce.rules.Violation; import com.salesforce.testutils.DummyVertex; @@ -17,6 +18,7 @@ import java.util.List; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -66,6 +68,12 @@ void init() throws IOException { Mockito.lenient().when(dependencies.createExecuteArgParser()).thenReturn(executeArgParser); Mockito.lenient().when(dependencies.getGraph()).thenReturn(g); + CliMessager.getInstance().resetMessages(); + } + + @AfterEach + void teardown() { + CliMessager.getInstance().resetMessages(); } @Test diff --git a/sfge/src/test/java/com/salesforce/cli/CliArgParserTest.java b/sfge/src/test/java/com/salesforce/cli/CliArgParserTest.java new file mode 100644 index 000000000..a14c3fe24 --- /dev/null +++ b/sfge/src/test/java/com/salesforce/cli/CliArgParserTest.java @@ -0,0 +1,70 @@ +package com.salesforce.cli; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; + +import com.salesforce.config.UserFacingMessages; +import com.salesforce.rules.RuleUtil; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +public class CliArgParserTest { + + @ValueSource( + strings = { + // Simple lowercase test. + "execute", + // Case-insensitivity test. + "eXeCuTe", + }) + @ParameterizedTest(name = "{displayName}: {0}") + public void getCliAction_getsExecuteEnum(String arg) { + CliArgParser parser = new CliArgParser(); + CliArgParser.CLI_ACTION result = parser.getCliAction(arg); + assertThat(result, equalTo(CliArgParser.CLI_ACTION.EXECUTE)); + } + + @ValueSource( + strings = { + // Simple lowercase test. + "catalog", + // Case-insensitivity test. + "CaTaLoG" + }) + @ParameterizedTest(name = "{displayName}: {0}") + public void getCliAction_getsCatalogEnum(String arg) { + CliArgParser parser = new CliArgParser(); + CliArgParser.CLI_ACTION result = parser.getCliAction(arg); + assertThat(result, equalTo(CliArgParser.CLI_ACTION.CATALOG)); + } + + @Test + public void getCliAction_throwsExpectedError() { + CliArgParser parser = new CliArgParser(); + assertThrows( + CliArgParser.InvocationException.class, + () -> parser.getCliAction("notARealAction"), + String.format(UserFacingMessages.UNRECOGNIZED_ACTION, "notARealAction")); + } + + @CsvSource({ + // As we add new DFA and non-DFA rules, the numbers in these tests + // will increase. + "pathless, 1", + "dfa, 1" + }) + @ParameterizedTest(name = "{displayName}: {0} rules") + public void catalogFlowReturnsExpectedRules(String arg, int ruleCount) { + CliArgParser.CatalogArgParser parser = new CliArgParser.CatalogArgParser(); + try { + parser.parseArgs("catalog", arg); + assertThat(parser.getSelectedRules().size(), equalTo(ruleCount)); + } catch (RuleUtil.RuleNotFoundException rnf) { + fail("Should not throw exception"); + } + } +} diff --git a/sfge/src/test/java/com/salesforce/graph/build/CustomerApexVertexBuilderTest.java b/sfge/src/test/java/com/salesforce/graph/build/CustomerApexVertexBuilderTest.java index 959a3b7e1..73a6d8ebd 100644 --- a/sfge/src/test/java/com/salesforce/graph/build/CustomerApexVertexBuilderTest.java +++ b/sfge/src/test/java/com/salesforce/graph/build/CustomerApexVertexBuilderTest.java @@ -10,7 +10,6 @@ import com.salesforce.apex.jorje.JorjeUtil; import com.salesforce.graph.Schema; import com.salesforce.graph.cache.VertexCacheProvider; -import com.salesforce.graph.ops.MethodUtil; import com.salesforce.graph.vertex.MethodVertex; import com.salesforce.graph.vertex.SFVertexFactory; import java.util.Arrays; @@ -178,11 +177,8 @@ public void testMethodSignature() { .hasLabel(NodeType.METHOD) .has(Schema.DEFINING_TYPE, "MyClass") .not(has(Schema.CONSTRUCTOR, true)) - .not( - has( - Schema.NAME, - MethodUtil.INSTANCE_CONSTRUCTOR_CANONICAL_NAME)) - .not(has(Schema.NAME, MethodUtil.STATIC_CONSTRUCTOR_CANONICAL_NAME)) + .not(has(Schema.NAME, Schema.INSTANCE_CONSTRUCTOR_CANONICAL_NAME)) + .not(has(Schema.NAME, Schema.STATIC_CONSTRUCTOR_CANONICAL_NAME)) .not(has(Schema.NAME, "clone")) .not(has(Schema.IS_STANDARD, true)) .order(Scope.global) diff --git a/sfge/src/test/java/com/salesforce/graph/build/MethodUtilStandardLibraryTest.java b/sfge/src/test/java/com/salesforce/graph/build/MethodUtilStandardLibraryTest.java index e29077376..a73bf386a 100644 --- a/sfge/src/test/java/com/salesforce/graph/build/MethodUtilStandardLibraryTest.java +++ b/sfge/src/test/java/com/salesforce/graph/build/MethodUtilStandardLibraryTest.java @@ -7,6 +7,7 @@ import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.hamcrest.MatcherAssert; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -44,4 +45,43 @@ public void testDescribeSObjectResultMatching(String initializer, String type) { TestRunner.Result result = TestRunner.walkPath(g, sourceCode); MatcherAssert.assertThat(result, TestRunnerMatcher.hasValues("hello")); } + + @Test + public void testSObjectTypeMatching() { + String[] sourceCode = { + "public class MyClass {\n" + + " public static doSomething() {\n" + // Schema.SObjectType.Account is of DescribeSObjectResult type + // unlike Account.SObjectType, which is of SObjectType type. + + " callMethod(Schema.SObjectType.Account);\n" + + " }\n" + + " static void callMethod(DescribeSObjectResult myObj) {\n" + + " System.debug('hello');\n" + + " }\n" + + "}\n" + }; + + TestRunner.Result result = TestRunner.walkPath(g, sourceCode); + MatcherAssert.assertThat(result, TestRunnerMatcher.hasValues("hello")); + } + + @Test + public void testListOfSObjectTypeMatching() { + String[] sourceCode = { + "public class MyClass {\n" + + " public static doSomething() {\n" + + " List objList = new List();\n" + + " objList.add(Schema.SObjectType.Account);\n" + + " callMethod(objList);\n" + + " }\n" + + " static void callMethod(List myObj) {\n" + // Method parameter is discarded + + " System.debug('hello');\n" + + " }\n" + + "}\n" + }; + + TestRunner.Result result = TestRunner.walkPath(g, sourceCode); + MatcherAssert.assertThat(result, TestRunnerMatcher.hasValues("hello")); + } } diff --git a/sfge/src/test/java/com/salesforce/graph/build/MethodUtilTest.java b/sfge/src/test/java/com/salesforce/graph/build/MethodUtilTest.java index 6b664e202..e0aa956d7 100644 --- a/sfge/src/test/java/com/salesforce/graph/build/MethodUtilTest.java +++ b/sfge/src/test/java/com/salesforce/graph/build/MethodUtilTest.java @@ -1,6 +1,5 @@ package com.salesforce.graph.build; -import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.hamcrest.Matchers.hasSize; @@ -13,7 +12,6 @@ import com.salesforce.TestRunner; import com.salesforce.TestUtil; import com.salesforce.apex.jorje.ASTConstants; -import com.salesforce.collections.CollectionUtil; import com.salesforce.exception.TodoException; import com.salesforce.exception.UnexpectedException; import com.salesforce.graph.ApexPath; @@ -41,13 +39,9 @@ import com.salesforce.graph.visitor.PathVertexVisitor; import com.salesforce.graph.visitor.SystemDebugAccumulator; import com.salesforce.matchers.TestRunnerMatcher; -import com.salesforce.metainfo.MetaInfoCollectorTestProvider; -import com.salesforce.metainfo.VisualForceHandlerImpl; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.TreeSet; -import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -1191,90 +1185,6 @@ public void testIsTestClass() { MatcherAssert.assertThat(myTestClass.isTest(), equalTo(true)); } - /** - * Test that UserClass methods that return are PageReference included. UserInterface methods are - * excluded. Test methods are exclude. Methods from test classes are excluded. - */ - @Test - public void testGetPageReferenceMethods() { - String[] sourceCode = { - "public class MyClass {\n" - + " public static PageReference foo() {\n" - + " }\n" - + " public static testMethod PageReference shouldBeExcludedByModifier() {\n" - + " }\n" - + " @isTest\n" - + " public static PageReference shouldBeExcludedByAnnotation() {\n" - + " }\n" - + " public static void bar() {\n" - + " }\n" - + "}\n", - "@isTest\n" - + "public class MyTestClass {\n" - + " public static PageReference foo() {\n" - + " }\n" - + "}\n", - "public interface MyInterface {\n" - + - // This should not be returned, we can't walk an interface's path - " PageReference foo();\n" - + "}\n" - }; - - TestUtil.buildGraph(g, sourceCode); - - List methods = MethodUtil.getPageReferenceMethods(g, new ArrayList<>()); - MatcherAssert.assertThat(methods, hasSize(equalTo(1))); - - MethodVertex method = methods.get(0); - MatcherAssert.assertThat(method.getName(), equalTo("foo")); - } - - /** - * Test that UserClass methods that return are PageReference included. UserInterface methods are - * excluded. Test methods are exclude. Methods from test classes are excluded. - */ - @Test - public void testGetExposedControllerMethods() { - String[] sourceCode = { - "public class MyClass {\n" - + " public static void foo() {\n" - + " }\n" - + " public static testMethod void shouldBeExcludedByModifier() {\n" - + " }\n" - + " @isTest\n" - + " public static void shouldBeExcludedByAnnotation() {\n" - + " }\n" - + "}\n" - }; - - TestUtil.buildGraph(g, sourceCode); - - try { - MetaInfoCollectorTestProvider.setVisualForceHandler( - new VisualForceHandlerImpl() { - @Override - public void loadProjectFiles(List sourceFolders) { - // Intentionally left blank - } - - @Override - public TreeSet getMetaInfoCollected() { - return CollectionUtil.newTreeSetOf("MyClass"); - } - }); - List methodNames = - MethodUtil.getExposedControllerMethods(g, new ArrayList<>()).stream() - .map(m -> m.getName()) - .collect(Collectors.toList()); - - // clone is an autogenerated method. TODO: Exclude - MatcherAssert.assertThat(methodNames, containsInAnyOrder("clone", "foo")); - } finally { - MetaInfoCollectorTestProvider.removeVisualForceHandler(); - } - } - /** * Tests static method on the current class isn't called when when an instance method that can't * be resolved should have been called instead. See {@link diff --git a/sfge/src/test/java/com/salesforce/graph/ops/ApexPathUtilLoopsTest.java b/sfge/src/test/java/com/salesforce/graph/ops/ApexPathUtilLoopsTest.java new file mode 100644 index 000000000..eb26d70bc --- /dev/null +++ b/sfge/src/test/java/com/salesforce/graph/ops/ApexPathUtilLoopsTest.java @@ -0,0 +1,196 @@ +package com.salesforce.graph.ops; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; + +import com.salesforce.TestRunner; +import com.salesforce.TestUtil; +import com.salesforce.graph.symbols.apex.ApexForLoopValue; +import com.salesforce.graph.symbols.apex.ApexIntegerValue; +import com.salesforce.graph.symbols.apex.ApexStringValue; +import com.salesforce.graph.visitor.SystemDebugAccumulator; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; +import org.hamcrest.MatcherAssert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +/** {@link ApexPathUtil} tests specific to loop structures. */ +public class ApexPathUtilLoopsTest { + private GraphTraversalSource g; + + @BeforeEach + public void setup() { + this.g = TestUtil.getGraph(); + } + + @Test + public void testForLoopMethodCallOnIndeterminantList() { + String sourceCode = + "public class MyClass {\n" + + " public void doSomething(String[] myList) {\n" + + " for (Integer i = 0; i < myList.size(); i++) {\n" + + " debug1(myList[i]);\n" + + " }\n" + + " }\n" + + " public void debug1(String s) {\n" + + " System.debug(s);\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexStringValue value = visitor.getSingletonResult(); + MatcherAssert.assertThat(value.isIndeterminant(), equalTo(true)); + } + + @Test + public void testForEachLoopWithNonForLoopMethodCall() { + String sourceCode = + "public class MyClass {\n" + + " public void doSomething() {\n" + + " String[] myList = new String[]{'hi','hello'};\n" + + " for (String myString: myList) {\n" + + " debug1(100);\n" + + " }\n" + + " }\n" + + " public void debug1(Integer int) {\n" + + " System.debug(int);\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexIntegerValue value = visitor.getSingletonResult(); + MatcherAssert.assertThat(value.getValue().get(), equalTo(100)); + } + + @Test + public void testForLoopWithNonForLoopMethodCall() { + String sourceCode = + "public class MyClass {\n" + + " public void doSomething() {\n" + + " String[] myList = new String[]{'hi','hello'};\n" + + " for (Integer i = 0; i < myList.size(); i++) {\n" + + " debug1(100);\n" + + " }\n" + + " }\n" + + " public void debug1(Integer int) {\n" + + " System.debug(int);\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexIntegerValue value = visitor.getSingletonResult(); + MatcherAssert.assertThat(value.getValue().get(), equalTo(100)); + } + + @Test + @Disabled // TODO: Handle values looped within while-loops as a ApexLoopValue + public void testWhileLoopWithMethodCall() { + String sourceCode = + "public class MyClass {\n" + + " public void doSomething() {\n" + + " String[] myList = new String[]{'hi','hello'};\n" + + " Integer i = 0;\n" + + " while (i < myList.size()) {\n" + + " debug1(myList[i]);\n" + + " i++;\n" + + " }\n" + + " }\n" + + " public void debug1(String str) {\n" + + " System.debug(str);\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexForLoopValue value = visitor.getSingletonResult(); + List stringValues = + value.getForLoopValues().stream() + .map(item -> TestUtil.apexValueToString(item)) + .collect(Collectors.toList()); + MatcherAssert.assertThat(stringValues, containsInAnyOrder("hi", "hello")); + } + + @Test + public void testWhileLoopWithMethodCallOnNonIterativeItem() { + String sourceCode = + "public class MyClass {\n" + + " public void doSomething() {\n" + + " String[] myList = new String[]{'hi','hello'};\n" + + " Integer i = 0;\n" + + " while (i < myList.size()) {\n" + + " debug1(100);\n" + + " i++;\n" + + " }\n" + + " }\n" + + " public void debug1(Integer int) {\n" + + " System.debug(int);\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexIntegerValue value = visitor.getSingletonResult(); + MatcherAssert.assertThat(value.getValue().get(), equalTo(100)); + } + + @Test + @Disabled // TODO: Handle do/while loops + public void testDoWhileLoopWithMethodCall() { + String sourceCode = + "public class MyClass {\n" + + " public void doSomething() {\n" + + " String[] myList = new String[]{'hi','hello'};\n" + + " Integer i = 0;\n" + + " do {\n" + + " debug1(myList[i]);\n" + + " i++;\n" + + " } while (i < myList.size());\n" + + " }\n" + + " public void debug1(String str) {\n" + + " System.debug(str);\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexStringValue value = visitor.getSingletonResult(); + MatcherAssert.assertThat(value.getValue().get(), equalTo("hi")); + } + + @Test + @Disabled // TODO: Handle do/while loops + public void testDoWhileLoopWithMethodCallOnNonIterativeItem() { + String sourceCode = + "public class MyClass {\n" + + " public void doSomething() {\n" + + " String[] myList = new String[]{'hi','hello'};\n" + + " Integer i = 0;\n" + + " do {\n" + + " debug1(100);\n" + + " i++;\n" + + " } while (i < myList.size());\n" + + " }\n" + + " public void debug1(Integer int) {\n" + + " System.debug(int);\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexIntegerValue value = visitor.getSingletonResult(); + MatcherAssert.assertThat(value.getValue().get(), equalTo(100)); + } +} diff --git a/sfge/src/test/java/com/salesforce/graph/ops/ApexPathUtilTest.java b/sfge/src/test/java/com/salesforce/graph/ops/ApexPathUtilTest.java index 8f6060ea9..9ae85f92a 100644 --- a/sfge/src/test/java/com/salesforce/graph/ops/ApexPathUtilTest.java +++ b/sfge/src/test/java/com/salesforce/graph/ops/ApexPathUtilTest.java @@ -41,7 +41,6 @@ import org.apache.commons.lang3.tuple.Pair; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.hamcrest.MatcherAssert; -import org.hamcrest.Matchers; import org.hamcrest.core.IsEqual; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; @@ -125,6 +124,25 @@ public void recursionDetected( MatcherAssert.assertThat(recursiveCalls, hasSize(equalTo(0))); } + @Test + public void testSimpleSingleMethodCall() { + String sourceCode = + "public class MyClass {\n" + + " public void doSomething() {\n" + + " debug1('hi');\n" + + " }\n" + + " public void debug1(String s) {\n" + + " System.debug(s);\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexStringValue value = visitor.getSingletonResult(); + MatcherAssert.assertThat(value.getValue().get(), equalTo("hi")); + } + @Test public void testSimpleWithMethodCalls() { String sourceCode = @@ -1005,12 +1023,12 @@ public void test_BDI_BatchNumberSettingsController_line_50() { Map>> results; results = visitor.getSingleResultPerLineByName("a"); - MatcherAssert.assertThat(results.keySet(), hasSize(Matchers.equalTo(1))); + MatcherAssert.assertThat(results.keySet(), hasSize(equalTo(1))); ApexValue apexValue = results.get(7).get(); MatcherAssert.assertThat(TestUtil.apexValueToString(apexValue), IsEqual.equalTo("Hello")); results = visitor.getSingleResultPerLineByName("sObjType"); - MatcherAssert.assertThat(results.keySet(), hasSize(Matchers.equalTo(1))); + MatcherAssert.assertThat(results.keySet(), hasSize(equalTo(1))); SObjectType sObjectType = (SObjectType) results.get(8).get(); MatcherAssert.assertThat( TestUtil.apexValueToString(sObjectType.getType()), IsEqual.equalTo("MyObject__c")); diff --git a/sfge/src/test/java/com/salesforce/graph/ops/MethodUtilTest.java b/sfge/src/test/java/com/salesforce/graph/ops/MethodUtilTest.java index 438ca02ff..859081f29 100644 --- a/sfge/src/test/java/com/salesforce/graph/ops/MethodUtilTest.java +++ b/sfge/src/test/java/com/salesforce/graph/ops/MethodUtilTest.java @@ -8,10 +8,7 @@ import static org.junit.jupiter.api.Assertions.fail; import com.salesforce.TestUtil; -import com.salesforce.apex.jorje.ASTConstants; -import com.salesforce.graph.Schema; import com.salesforce.graph.vertex.MethodVertex; -import com.salesforce.graph.vertex.SFVertexFactory; import com.salesforce.messaging.CliMessager; import com.salesforce.messaging.EventKey; import com.salesforce.rules.AbstractRuleRunner.RuleRunnerTarget; @@ -23,8 +20,6 @@ import org.hamcrest.MatcherAssert; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; public class MethodUtilTest { private GraphTraversalSource g; @@ -334,136 +329,4 @@ public void getTargetMethods_targetMethodInInnerAndOuterClass() { messages, containsString(EventKey.WARNING_MULTIPLE_METHOD_TARGET_MATCHES.getMessageKey())); } - - @ValueSource( - strings = { - Schema.AURA_ENABLED, - Schema.INVOCABLE_METHOD, - Schema.REMOTE_ACTION, - Schema.NAMESPACE_ACCESSIBLE - }) - @ParameterizedTest(name = "{displayName}: {0}") - public void testGetMethodsWithAnnotation(String annotation) { - String[] sourceCode = { - "public class MyClass {\n" - + " @" - + annotation - + "\n" - + " public static void foo() {\n" - + " }\n" - + " @" - + annotation - + "\n" - + " public static testMethod void shouldBeExcludedByModifier() {\n" - + " }\n" - + " @" - + annotation - + "\n" - + " @isTest\n" - + " public static void shouldBeExcludedByAnnotation() {\n" - + " }\n" - + " public static void bar() {\n" - + " }\n" - + "}\n", - "@isTest\n" - + "public class MyTestClass {\n" - + " @" - + annotation - + "\n" - + " public static void foo() {\n" - + " }\n" - + "}\n", - }; - - TestUtil.buildGraph(g, sourceCode, true); - - List methods = - MethodUtil.getMethodsWithAnnotation(g, new ArrayList<>(), annotation); - MatcherAssert.assertThat(methods, hasSize(equalTo(1))); - - MethodVertex method = methods.get(0); - MatcherAssert.assertThat(method.getName(), equalTo("foo")); - MatcherAssert.assertThat(method.isTest(), equalTo(false)); - - for (String excludedName : - new String[] {"shouldBeExcludedByModifier", "shouldBeExcludedByAnnotation"}) { - MethodVertex excludedMethod = - SFVertexFactory.load( - g, - g.V() - .hasLabel(ASTConstants.NodeType.METHOD) - .has(Schema.NAME, excludedName)); - MatcherAssert.assertThat(excludedName, excludedMethod.isTest(), equalTo(true)); - } - } - - @Test - public void testGetGlobalMethods() { - String[] sourceCode = { - "public class MyClass {\n" - + " global static void foo() {\n" - + " }\n" - + " global static testMethod void shouldBeExcludedByModifier() {\n" - + " }\n" - + " @isTest\n" - + " global static void shouldBeExcludedByAnnotation() {\n" - + " }\n" - + " public static void bar() {\n" - + " }\n" - + "}\n", - "@isTest\n" - + "public class MyTestClass {\n" - + " public static void foo() {\n" - + " }\n" - + "}\n", - }; - - TestUtil.buildGraph(g, sourceCode); - - List methods = MethodUtil.getGlobalMethods(g, new ArrayList<>()); - // The `foo` method should be included because it's declared as global. - MatcherAssert.assertThat(methods, hasSize(equalTo(1))); - MatcherAssert.assertThat(methods.get(0).getName(), equalTo("foo")); - - for (String excludedName : - new String[] {"shouldBeExcludedByModifier", "shouldBeExcludedByAnnotation"}) { - MethodVertex excludedMethod = - SFVertexFactory.load( - g, - g.V() - .hasLabel(ASTConstants.NodeType.METHOD) - .has(Schema.NAME, excludedName)); - MatcherAssert.assertThat(excludedName, excludedMethod.isTest(), equalTo(true)); - } - } - - @Test - public void testGetInboundEmailHandlerMethods() { - String[] sourceCode = { - "public class MyClass implements Messaging.InboundEmailHandler {\n" - + " public Messaging.InboundEmailResult handleInboundEmail(Messaging.InboundEmail email, Messaging.InboundEnvelope envelope) {\n" - + " return null;\n" - + " }\n" - + " public Messaging.InboundEmailHandler someSecondaryMethod() {\n" - + " return null;\n" - + " }\n" - + "}\n", - "public class MyClass2 {\n" - + " public Messaging.InboundEmailResult handleInboundEmail(Messaging.InboundEmail email, Messaging.InboundEnvelope envelope) {\n" - + " return null;\n" - + " }\n" - + " public Messaging.InboundEmailHandler someSecondaryMethod() {\n" - + " return null;\n" - + " }\n" - + "}\n" - }; - TestUtil.buildGraph(g, sourceCode); - - List methods = MethodUtil.getInboundEmailHandlerMethods(g, new ArrayList<>()); - // The `MyClass#handleInboundEmail` method should be included because it's an implementation - // of the desired interface. - MatcherAssert.assertThat(methods, hasSize(equalTo(1))); - MatcherAssert.assertThat(methods.get(0).getName(), equalTo("handleInboundEmail")); - MatcherAssert.assertThat(methods.get(0).getDefiningType(), equalTo("MyClass")); - } } diff --git a/sfge/src/test/java/com/salesforce/graph/ops/PathEntryPointUtilTest.java b/sfge/src/test/java/com/salesforce/graph/ops/PathEntryPointUtilTest.java new file mode 100644 index 000000000..af57ead77 --- /dev/null +++ b/sfge/src/test/java/com/salesforce/graph/ops/PathEntryPointUtilTest.java @@ -0,0 +1,542 @@ +package com.salesforce.graph.ops; + +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.salesforce.TestUtil; +import com.salesforce.apex.jorje.ASTConstants; +import com.salesforce.collections.CollectionUtil; +import com.salesforce.graph.Schema; +import com.salesforce.graph.vertex.MethodVertex; +import com.salesforce.graph.vertex.SFVertexFactory; +import com.salesforce.messaging.CliMessager; +import com.salesforce.metainfo.MetaInfoCollectorTestProvider; +import com.salesforce.metainfo.VisualForceHandlerImpl; +import com.salesforce.rules.AbstractRuleRunner; +import java.util.*; +import java.util.stream.Collectors; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; +import org.hamcrest.MatcherAssert; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class PathEntryPointUtilTest { + private static final Logger LOGGER = LogManager.getLogger(PathEntryPointUtilTest.class); + private GraphTraversalSource g; + + @BeforeEach + public void setup() { + this.g = TestUtil.getGraph(); + CliMessager.getInstance().resetMessages(); + } + + @AfterEach + public void teardown() { + CliMessager.getInstance().resetMessages(); + } + + @ValueSource( + strings = { + Schema.AURA_ENABLED, + Schema.INVOCABLE_METHOD, + Schema.REMOTE_ACTION, + Schema.NAMESPACE_ACCESSIBLE + }) + @ParameterizedTest(name = "{displayName}: {0}") + public void getPathEntryPoints_includesAnnotatedMethods(String annotation) { + String sourceCode = + "public class Foo {\n" + + " @" + + annotation + + "\n" + + " public boolean annotatedMethod() {\n" + + " return true;\n" + + " }\n" + + "\n" + + " public boolean nonAnnotatedMethod() {\n" + + " return true;\n" + + " }\n" + + "}\n"; + TestUtil.buildGraph(g, sourceCode, true); + + List entryPoints = PathEntryPointUtil.getPathEntryPoints(g); + + MatcherAssert.assertThat(entryPoints, hasSize(equalTo(1))); + MethodVertex firstVertex = entryPoints.get(0); + assertEquals("annotatedMethod", firstVertex.getName()); + } + + @Test + public void getPathEntryPoints_includesGlobalMethods() { + String sourceCode = + "public class Foo {\n" + + " global static void globalStaticMethod() {\n" + + " }\n" + + " global void globalInstanceMethod() {\n" + + " }\n" + + " public static void publicStaticMethod() {\n" + + " }\n" + + "}\n"; + TestUtil.buildGraph(g, sourceCode, true); + + List entryPoints = PathEntryPointUtil.getPathEntryPoints(g); + + MatcherAssert.assertThat(entryPoints, hasSize(equalTo(2))); + boolean staticMethodFound = false; + boolean instanceMethodFound = false; + for (MethodVertex entrypoint : entryPoints) { + switch (entrypoint.getName()) { + case "globalStaticMethod": + staticMethodFound = true; + break; + case "globalInstanceMethod": + instanceMethodFound = true; + break; + default: + fail("Unexpected method " + entrypoint.getName()); + } + } + assertTrue(staticMethodFound); + assertTrue(instanceMethodFound); + } + + @Test + public void getPathEntryPoints_includesPageReferenceMethods() { + String sourceCode = + "public class Foo {\n" + + " public PageReference pageRefMethod() {\n" + + " return null;\n" + + " }\n" + + "\n" + + " public boolean nonAuraMethod() {\n" + + " return true;\n" + + " }\n" + + "}\n"; + TestUtil.buildGraph(g, sourceCode, true); + + List entryPoints = PathEntryPointUtil.getPathEntryPoints(g); + + MatcherAssert.assertThat(entryPoints, hasSize(equalTo(1))); + MethodVertex firstVertex = entryPoints.get(0); + assertEquals("pageRefMethod", firstVertex.getName()); + } + + @Test + public void getPathEntryPoints_includesInboundEmailHandlerMethods() { + String sourceCode = + "public class MyClass implements Messaging.InboundEmailHandler {\n" + + " public Messaging.InboundEmailResult handleInboundEmail(Messaging.InboundEmail email, Messaging.InboundEnvelope envelope) {\n" + + " return null;\n" + + " }\n" + + " public Messaging.InboundEmailHandler someSecondaryMethod() {\n" + + " return null;\n" + + " }\n" + + "}\n"; + TestUtil.buildGraph(g, sourceCode, true); + + List entryPoints = PathEntryPointUtil.getPathEntryPoints(g); + + MatcherAssert.assertThat(entryPoints, hasSize(equalTo(1))); + MethodVertex firstVertex = entryPoints.get(0); + assertEquals("handleInboundEmail", firstVertex.getName()); + } + + @Test + public void getPathEntryPoints_includesExposedControllerMethods() { + try { + String controllerSourceCode = + "public class ApexControllerClass {\n" + + " public String getSomeStringProperty() {\n" + + " return 'beep';\n" + + " }\n" + + "\n" + + " global String getSomeOtherStringProperty() {\n" + + " return 'boop';\n" + + " }\n" + + "\n" + + " private String getYetAnotherStringProperty() {\n" + + " return 'baap';\n" + + " }\n" + + "}\n"; + + MetaInfoCollectorTestProvider.setVisualForceHandler( + new VisualForceHandlerImpl() { + private TreeSet references = + new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + + @Override + public void loadProjectFiles(List sourceFolders) { + // NO-OP + } + + @Override + public TreeSet getMetaInfoCollected() { + references.add("ApexControllerClass"); + return references; + } + }); + TestUtil.buildGraph(g, controllerSourceCode, true); + + List entryPoints = PathEntryPointUtil.getPathEntryPoints(g); + // TODO: This number is three, because the synthetic clone method is included. This + // might not be the behavior + // we want. If we change our minds, the test should change. + MatcherAssert.assertThat(entryPoints, hasSize(equalTo(3))); + List methodNames = + entryPoints.stream().map(MethodVertex::getName).collect(Collectors.toList()); + MatcherAssert.assertThat( + methodNames, + containsInAnyOrder( + "clone", "getSomeStringProperty", "getSomeOtherStringProperty")); + } finally { + MetaInfoCollectorTestProvider.removeVisualForceHandler(); + } + } + + @Test + public void getPathEntryPoints_includeMethodLevelTargets() { + String sourceCode0 = + "public class MyClass1 {\n" + + " @AuraEnabled\n" + + " public boolean auraMethod() {\n" + + " return true;\n" + + " }\n" + + "\n" + + " public boolean nonIncludedMethod() {\n" + + " return true;\n" + + " }\n" + + " public boolean nonIncludedMethod(boolean param) {\n" + + " return true;\n" + + " }\n" + + "}\n"; + String sourceCode1 = + "public class MyClass2 {\n" + + " public PageReference pageRefMethod() {\n" + + " return null;\n" + + " }\n" + + "\n" + + " public boolean nonIncludedMethod() {\n" + + " return true;\n" + + " }\n" + + " public boolean nonIncludedMethod(boolean param) {\n" + + " return true;\n" + + " }\n" + + "}\n"; + TestUtil.buildGraph(g, new String[] {sourceCode0, sourceCode1}, true); + List targets = new ArrayList<>(); + // Create a target that encompasses both of the `nonIncludedMethod()` definitions in + // MyClass1. + targets.add( + TestUtil.createTarget("TestCode0", Collections.singletonList("nonIncludedMethod"))); + + // TEST: Load the methods encompassed by the targets. + List entryPoints = PathEntryPointUtil.getPathEntryPoints(g, targets); + // Make sure the right number of methods were returned. + MatcherAssert.assertThat(entryPoints, hasSize(equalTo(2))); + + // Sort the vertices, so we can inspect them. + entryPoints.sort( + Comparator.comparing(MethodVertex::getDefiningType) + .thenComparing(MethodVertex::getName) + .thenComparing(MethodVertex::getBeginLine)); + // Make sure that the methods returned were the right ones. + MethodVertex firstVertex = entryPoints.get(0); + assertEquals("nonIncludedMethod", firstVertex.getName()); + assertEquals("MyClass1", firstVertex.getDefiningType()); + assertEquals(7, firstVertex.getBeginLine()); + + MethodVertex secondVertex = entryPoints.get(1); + assertEquals("nonIncludedMethod", secondVertex.getName()); + assertEquals("MyClass1", secondVertex.getDefiningType()); + assertEquals(10, secondVertex.getBeginLine()); + } + + @Test + public void getPathEntryPoints_includeMethodAndFileLevelTargets() { + String sourceCode0 = + "public class MyClass1 {\n" + + " @AuraEnabled\n" + + " public boolean auraMethod() {\n" + + " return true;\n" + + " }\n" + + "\n" + + " public boolean nonIncludedMethod() {\n" + + " return true;\n" + + " }\n" + + " public boolean nonIncludedMethod(boolean param) {\n" + + " return true;\n" + + " }\n" + + "}\n"; + String sourceCode1 = + "public class MyClass2 {\n" + + " public PageReference pageRefMethod() {\n" + + " return null;\n" + + " }\n" + + "\n" + + " public boolean nonIncludedMethod() {\n" + + " return true;\n" + + " }\n" + + " public boolean nonIncludedMethod(boolean param) {\n" + + " return true;\n" + + " }\n" + + "}\n"; + TestUtil.buildGraph(g, new String[] {sourceCode0, sourceCode1}, true); + List targets = new ArrayList<>(); + // Create a target that encompasses both of the `nonIncludedMethod()` definitions in + // MyClass1. + targets.add( + TestUtil.createTarget("TestCode0", Collections.singletonList("nonIncludedMethod"))); + // Create a target that encompasses the entirety of MyClass2. + targets.add(TestUtil.createTarget("TestCode1", new ArrayList<>())); + + // TEST: Load the methods encompassed by the targets. + List entryPoints = PathEntryPointUtil.getPathEntryPoints(g, targets); + + // Make sure the right number of methods were returned. + MatcherAssert.assertThat(entryPoints, hasSize(equalTo(3))); + // Sort the vertices, so we can inspect them. + entryPoints.sort( + Comparator.comparing(MethodVertex::getDefiningType) + .thenComparing(MethodVertex::getName) + .thenComparing(MethodVertex::getBeginLine)); + // Make sure that the methods returned were the right ones. + MethodVertex firstVertex = entryPoints.get(0); + assertEquals("nonIncludedMethod", firstVertex.getName()); + assertEquals("MyClass1", firstVertex.getDefiningType()); + assertEquals(7, firstVertex.getBeginLine()); + + MethodVertex secondVertex = entryPoints.get(1); + assertEquals("nonIncludedMethod", secondVertex.getName()); + assertEquals("MyClass1", secondVertex.getDefiningType()); + assertEquals(10, secondVertex.getBeginLine()); + + MethodVertex thirdVertex = entryPoints.get(2); + assertEquals("pageRefMethod", thirdVertex.getName()); + assertEquals("MyClass2", thirdVertex.getDefiningType()); + assertEquals(2, thirdVertex.getBeginLine()); + } + + @ValueSource( + strings = { + Schema.AURA_ENABLED, + Schema.INVOCABLE_METHOD, + Schema.REMOTE_ACTION, + Schema.NAMESPACE_ACCESSIBLE + }) + @ParameterizedTest(name = "{displayName}: {0}") + public void testGetMethodsWithAnnotation(String annotation) { + String[] sourceCode = { + "public class MyClass {\n" + + " @" + + annotation + + "\n" + + " public static void foo() {\n" + + " }\n" + + " @" + + annotation + + "\n" + + " public static testMethod void shouldBeExcludedByModifier() {\n" + + " }\n" + + " @" + + annotation + + "\n" + + " @isTest\n" + + " public static void shouldBeExcludedByAnnotation() {\n" + + " }\n" + + " public static void bar() {\n" + + " }\n" + + "}\n", + "@isTest\n" + + "public class MyTestClass {\n" + + " @" + + annotation + + "\n" + + " public static void foo() {\n" + + " }\n" + + "}\n", + }; + + TestUtil.buildGraph(g, sourceCode, true); + + List methods = + PathEntryPointUtil.getMethodsWithAnnotation(g, new ArrayList<>(), annotation); + MatcherAssert.assertThat(methods, hasSize(equalTo(1))); + + MethodVertex method = methods.get(0); + MatcherAssert.assertThat(method.getName(), equalTo("foo")); + MatcherAssert.assertThat(method.isTest(), equalTo(false)); + + for (String excludedName : + new String[] {"shouldBeExcludedByModifier", "shouldBeExcludedByAnnotation"}) { + MethodVertex excludedMethod = + SFVertexFactory.load( + g, + g.V() + .hasLabel(ASTConstants.NodeType.METHOD) + .has(Schema.NAME, excludedName)); + MatcherAssert.assertThat(excludedName, excludedMethod.isTest(), equalTo(true)); + } + } + + @Test + public void testGetGlobalMethods() { + String[] sourceCode = { + "public class MyClass {\n" + + " global static void foo() {\n" + + " }\n" + + " global static testMethod void shouldBeExcludedByModifier() {\n" + + " }\n" + + " @isTest\n" + + " global static void shouldBeExcludedByAnnotation() {\n" + + " }\n" + + " public static void bar() {\n" + + " }\n" + + "}\n", + "@isTest\n" + + "public class MyTestClass {\n" + + " public static void foo() {\n" + + " }\n" + + "}\n", + }; + + TestUtil.buildGraph(g, sourceCode); + + List methods = PathEntryPointUtil.getGlobalMethods(g, new ArrayList<>()); + // The `foo` method should be included because it's declared as global. + MatcherAssert.assertThat(methods, hasSize(equalTo(1))); + MatcherAssert.assertThat(methods.get(0).getName(), equalTo("foo")); + + for (String excludedName : + new String[] {"shouldBeExcludedByModifier", "shouldBeExcludedByAnnotation"}) { + MethodVertex excludedMethod = + SFVertexFactory.load( + g, + g.V() + .hasLabel(ASTConstants.NodeType.METHOD) + .has(Schema.NAME, excludedName)); + MatcherAssert.assertThat(excludedName, excludedMethod.isTest(), equalTo(true)); + } + } + + @Test + public void testGetInboundEmailHandlerMethods() { + String[] sourceCode = { + "public class MyClass implements Messaging.InboundEmailHandler {\n" + + " public Messaging.InboundEmailResult handleInboundEmail(Messaging.InboundEmail email, Messaging.InboundEnvelope envelope) {\n" + + " return null;\n" + + " }\n" + + " public Messaging.InboundEmailHandler someSecondaryMethod() {\n" + + " return null;\n" + + " }\n" + + "}\n", + "public class MyClass2 {\n" + + " public Messaging.InboundEmailResult handleInboundEmail(Messaging.InboundEmail email, Messaging.InboundEnvelope envelope) {\n" + + " return null;\n" + + " }\n" + + " public Messaging.InboundEmailHandler someSecondaryMethod() {\n" + + " return null;\n" + + " }\n" + + "}\n" + }; + TestUtil.buildGraph(g, sourceCode); + + List methods = + PathEntryPointUtil.getInboundEmailHandlerMethods(g, new ArrayList<>()); + // The `MyClass#handleInboundEmail` method should be included because it's an implementation + // of the desired interface. + MatcherAssert.assertThat(methods, hasSize(equalTo(1))); + MatcherAssert.assertThat(methods.get(0).getName(), equalTo("handleInboundEmail")); + MatcherAssert.assertThat(methods.get(0).getDefiningType(), equalTo("MyClass")); + } + + /** + * Test that UserClass methods that return are PageReference included. UserInterface methods are + * excluded. Test methods are exclude. Methods from test classes are excluded. + */ + @Test + public void testGetPageReferenceMethods() { + String[] sourceCode = { + "public class MyClass {\n" + + " public static PageReference foo() {\n" + + " }\n" + + " public static testMethod PageReference shouldBeExcludedByModifier() {\n" + + " }\n" + + " @isTest\n" + + " public static PageReference shouldBeExcludedByAnnotation() {\n" + + " }\n" + + " public static void bar() {\n" + + " }\n" + + "}\n", + "@isTest\n" + + "public class MyTestClass {\n" + + " public static PageReference foo() {\n" + + " }\n" + + "}\n", + "public interface MyInterface {\n" + + + // This should not be returned, we can't walk an interface's path + " PageReference foo();\n" + + "}\n" + }; + + TestUtil.buildGraph(g, sourceCode); + + List methods = + PathEntryPointUtil.getPageReferenceMethods(g, new ArrayList<>()); + MatcherAssert.assertThat(methods, hasSize(equalTo(1))); + + MethodVertex method = methods.get(0); + MatcherAssert.assertThat(method.getName(), equalTo("foo")); + } + + /** + * Test that UserClass methods that return are PageReference included. UserInterface methods are + * excluded. Test methods are exclude. Methods from test classes are excluded. + */ + @Test + public void testGetExposedControllerMethods() { + String[] sourceCode = { + "public class MyClass {\n" + + " public static void foo() {\n" + + " }\n" + + " public static testMethod void shouldBeExcludedByModifier() {\n" + + " }\n" + + " @isTest\n" + + " public static void shouldBeExcludedByAnnotation() {\n" + + " }\n" + + "}\n" + }; + + TestUtil.buildGraph(g, sourceCode); + + try { + MetaInfoCollectorTestProvider.setVisualForceHandler( + new VisualForceHandlerImpl() { + @Override + public void loadProjectFiles(List sourceFolders) { + // Intentionally left blank + } + + @Override + public TreeSet getMetaInfoCollected() { + return CollectionUtil.newTreeSetOf("MyClass"); + } + }); + List methodNames = + PathEntryPointUtil.getExposedControllerMethods(g, new ArrayList<>()).stream() + .map(m -> m.getName()) + .collect(Collectors.toList()); + + // clone is an autogenerated method. TODO: Exclude + MatcherAssert.assertThat(methodNames, containsInAnyOrder("clone", "foo")); + } finally { + MetaInfoCollectorTestProvider.removeVisualForceHandler(); + } + } +} diff --git a/sfge/src/test/java/com/salesforce/graph/symbols/apex/ApexClassInstanceValueTest.java b/sfge/src/test/java/com/salesforce/graph/symbols/apex/ApexClassInstanceValueTest.java index 188a12256..ac3ff02e1 100644 --- a/sfge/src/test/java/com/salesforce/graph/symbols/apex/ApexClassInstanceValueTest.java +++ b/sfge/src/test/java/com/salesforce/graph/symbols/apex/ApexClassInstanceValueTest.java @@ -10,6 +10,7 @@ import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.hamcrest.MatcherAssert; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; public class ApexClassInstanceValueTest { @@ -157,4 +158,68 @@ public void testJSONDeserializeFieldsAreIndeterminant() { // TODO: Test what happens if there is inline assignment and the class is deserialized, which // one wins, is it an error? + + @Test + public void testMethodCallOnDeterminant() { + String[] sourceCode = { + "public class MyClass {\n" + + " void doSomething() {\n" + + " Bean myBean = new Bean('hi');\n" + + " System.debug(myBean);\n" + + " System.debug(myBean.getValue());\n" + + " }\n" + + "}\n", + "public class Bean {\n" + + "private String value;\n" + + "public Bean(String val1) {\n" + + " this.value = val1;\n" + + "}\n" + + "public String getValue() {\n" + + " return this.value;\n" + + "}\n" + + "}\n" + }; + + TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexClassInstanceValue instanceValue = visitor.getResult(0); + MatcherAssert.assertThat(instanceValue.getCanonicalType(), equalTo("Bean")); + + ApexStringValue methodCallValue = visitor.getResult(1); + MatcherAssert.assertThat(TestUtil.apexValueToString(methodCallValue), equalTo("hi")); + } + + @Test + @Disabled // TODO: Indeterminant class value should be treated + // as an ApexClassInstanceValue instead of ApexSingleValue + public void testMethodCallOnIndeterminantInstance() { + String[] sourceCode = { + "public class MyClass {\n" + + " void doSomething(Bean bean) {\n" + + " System.debug(bean);\n" + + " System.debug(bean.getValue());\n" + + " }\n" + + "}\n", + "public class Bean {\n" + + "private String value;\n" + + "public Bean(String val1) {\n" + + " this.value = val1;\n" + + "}\n" + + "public String getValue() {\n" + + " return this.value;\n" + + "}\n" + + "}\n" + }; + + TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexClassInstanceValue value = visitor.getResult(0); + MatcherAssert.assertThat(value.isIndeterminant(), equalTo(true)); + MatcherAssert.assertThat(value.getDeclaredType().get(), equalTo("Bean")); + + ApexStringValue methodCallValue = visitor.getResult(1); + MatcherAssert.assertThat(methodCallValue.isIndeterminant(), equalTo(true)); + } } diff --git a/sfge/src/test/java/com/salesforce/graph/symbols/apex/ApexForLoopValueTest.java b/sfge/src/test/java/com/salesforce/graph/symbols/apex/ApexForLoopValueTest.java index 6126cb64c..02adb271c 100644 --- a/sfge/src/test/java/com/salesforce/graph/symbols/apex/ApexForLoopValueTest.java +++ b/sfge/src/test/java/com/salesforce/graph/symbols/apex/ApexForLoopValueTest.java @@ -1,9 +1,6 @@ package com.salesforce.graph.symbols.apex; -import static org.hamcrest.Matchers.contains; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.*; import static org.hamcrest.core.IsNot.not; import com.salesforce.TestRunner; @@ -20,6 +17,7 @@ import java.util.stream.Stream; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -455,7 +453,6 @@ public void testInvokeMethodExecutesOnObjectNotList() { } @Test - @Disabled public void testStdMethodCallOnForLoopVariable() { String sourceCode = "public class MyClass {\n" @@ -463,6 +460,7 @@ public void testStdMethodCallOnForLoopVariable() { + " List fields = new List{Schema.Account.fields.Name,Schema.Account.fields.Phone};\n" + " for (Schema.SObjectField field: fields) {\n" + " System.debug(field.getDescribe());\n" + + " System.debug(field.getDescribe().isCreateable());\n" + " }\n" + " }\n" + "}\n"; @@ -470,19 +468,81 @@ public void testStdMethodCallOnForLoopVariable() { TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); SystemDebugAccumulator visitor = result.getVisitor(); - DescribeFieldResult value = visitor.getSingletonResult(); + ApexForLoopValue forLoopValue1 = visitor.getResult(0); + List> describeForLoopValues = forLoopValue1.getForLoopValues(); + List fieldNames = + describeForLoopValues.stream() + .map( + apexValue -> + TestUtil.apexValueToString( + ((DescribeFieldResult) apexValue).getFieldName())) + .collect(Collectors.toList()); + + MatcherAssert.assertThat(fieldNames, containsInAnyOrder("Name", "Phone")); + + ApexForLoopValue forLoopValue2 = visitor.getResult(1); + List> isCreateableValues = forLoopValue2.getForLoopValues(); + isCreateableValues.forEach( + value -> { + MatcherAssert.assertThat(value, Matchers.instanceOf(ApexBooleanValue.class)); + MatcherAssert.assertThat(value.isIndeterminant(), equalTo(true)); + }); } @Test - @Disabled + @Disabled // TODO: apply() method on ApexClassInstanceValue should have the capability to match + // the method call and convert to ApexValue public void testMethodCallOnForLoopVariable() { String[] sourceCode = { "public class MyClass {\n" + " void doSomething() {\n" + " List beans = new List{new Bean('hi'),new Bean('hello')};\n" - + " for (Bean bean: beans) {\n" - + " String myValue = bean.getValue();\n" - + " System.debug(myValue);\n" + + " for (Bean mybean: beans) {\n" + + " System.debug(mybean);\n" + + " System.debug(mybean.getValue());\n" + + " }\n" + + " }\n" + + "}\n", + "public class Bean {\n" + + "private String value;\n" + + "public Bean(String val1) {\n" + + " this.value = val1;\n" + + "}\n" + + "public String getValue() {\n" + + " return this.value;\n" + + "}\n" + + "}\n" + }; + + TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexForLoopValue value = visitor.getResult(0); + List> forLoopValues = value.getForLoopValues(); + MatcherAssert.assertThat(forLoopValues, hasSize(2)); + ApexClassInstanceValue classInstanceValue = (ApexClassInstanceValue) forLoopValues.get(0); + MatcherAssert.assertThat(classInstanceValue.getCanonicalType(), equalTo("Bean")); + + ApexForLoopValue derivedValue = visitor.getResult(1); + List> derivedForLoopValues = derivedValue.getForLoopValues(); + List valueStrings = + derivedForLoopValues.stream() + .map(apexValue -> TestUtil.apexValueToString(apexValue)) + .collect(Collectors.toList()); + MatcherAssert.assertThat(valueStrings, Matchers.containsInAnyOrder("hi", "hello")); + } + + @Test + @Disabled // TODO: Method invocation on indeterminant forLoop values should lead to + // indeterminant return values + public void testMethodCallOnIndeterminantForLoopVariable() { + String[] sourceCode = { + "public class MyClass {\n" + + " void doSomething(List beans) {\n" + + " System.debug(beans);\n" + + " for (Bean myBean: beans) {\n" + + " System.debug(myBean);\n" + + " System.debug(myBean.getValue());\n" + " }\n" + " }\n" + "}\n", @@ -500,7 +560,21 @@ public void testMethodCallOnForLoopVariable() { TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); SystemDebugAccumulator visitor = result.getVisitor(); - ApexStringValue value = visitor.getSingletonResult(); - MatcherAssert.assertThat(value.getValue().get(), equals("hi")); + ApexListValue listValue = visitor.getResult(0); + MatcherAssert.assertThat(listValue.isIndeterminant(), equalTo(true)); + + ApexForLoopValue value = visitor.getResult(1); + List> forLoopValues = value.getForLoopValues(); + MatcherAssert.assertThat(value.isIndeterminant(), equalTo(true)); + MatcherAssert.assertThat( + forLoopValues, hasSize(1)); // TODO: should this be a single indeterminant value? + MatcherAssert.assertThat(value.getDeclaredType().get(), equalTo("Bean")); + + ApexForLoopValue derivedValue = visitor.getResult(2); + List> derivedForLoopValues = derivedValue.getForLoopValues(); + MatcherAssert.assertThat(derivedValue.isIndeterminant(), equalTo(true)); + MatcherAssert.assertThat( + derivedForLoopValues, + hasSize(1)); // TODO: should this be a single indeterminant value? } } diff --git a/sfge/src/test/java/com/salesforce/graph/symbols/apex/ApexSetValueTest.java b/sfge/src/test/java/com/salesforce/graph/symbols/apex/ApexSetValueTest.java index 279a5ca21..6530b85ea 100644 --- a/sfge/src/test/java/com/salesforce/graph/symbols/apex/ApexSetValueTest.java +++ b/sfge/src/test/java/com/salesforce/graph/symbols/apex/ApexSetValueTest.java @@ -7,12 +7,14 @@ import com.salesforce.TestRunner; import com.salesforce.TestUtil; +import com.salesforce.graph.symbols.apex.schema.DescribeFieldResult; import com.salesforce.graph.visitor.SystemDebugAccumulator; import java.util.List; import java.util.stream.Collectors; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.hamcrest.MatcherAssert; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; public class ApexSetValueTest { @@ -360,4 +362,90 @@ void assertBoolean( MatcherAssert.assertThat(value.isIndeterminant(), equalTo(isIndeterminant)); MatcherAssert.assertThat(value.getValue().isPresent(), equalTo(isPresent)); } + + @Test + public void testForEachWithSet() { + String sourceCode = + "public class MyClass {\n" + + " public void doSomething() {\n" + + " Set fieldsToCheck = new Set{'Name', 'Phone'};\n" + + " for (String fieldToCheck : fieldsToCheck) {\n" + + " System.debug(fieldToCheck);\n" + + " }\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.walkPath(g, sourceCode); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexForLoopValue value = visitor.getSingletonResult(); + List values = + value.getForLoopValues().stream() + .map(a -> TestUtil.apexValueToString(a)) + .collect(Collectors.toList()); + MatcherAssert.assertThat(values.isEmpty(), equalTo(false)); + MatcherAssert.assertThat(values, containsInAnyOrder("Name", "Phone")); + } + + @Test + public void testStdMethodCallOnForLoopVariableWithSet() { + String sourceCode = + "public class MyClass {\n" + + " void doSomething() {\n" + + " Set fields = new Set{Schema.Account.fields.Name,Schema.Account.fields.Phone};\n" + + " for (Schema.SObjectField myField: fields) {\n" + + " System.debug(myField.getDescribe());\n" + + " }\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexForLoopValue value = visitor.getSingletonResult(); + List fieldNames = + value.getForLoopValues().stream() + .map( + item -> + TestUtil.apexValueToString( + ((DescribeFieldResult) item).getFieldName())) + .collect(Collectors.toList()); + + MatcherAssert.assertThat(fieldNames, containsInAnyOrder("Name", "Phone")); + } + + @Test + @Disabled // TODO: Handle method invocations on ApexClassInstanceValue + public void testMethodCallOnForLoopVariableWithSet() { + String[] sourceCode = { + "public class MyClass {\n" + + " void doSomething() {\n" + + " Set beans = new Set{new Bean('hi'),new Bean('hello')};\n" + + " for (Bean bean: beans) {\n" + + " String myValue = bean.getValue();\n" + + " System.debug(myValue);\n" + + " }\n" + + " }\n" + + "}\n", + "public class Bean {\n" + + "private String value;\n" + + "public Bean(String val1) {\n" + + " this.value = val1;\n" + + "}\n" + + "public String getValue() {\n" + + " return this.value;\n" + + "}\n" + + "}\n" + }; + + TestRunner.Result result = TestRunner.get(g, sourceCode).walkPath(); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexForLoopValue value = visitor.getSingletonResult(); + List valueList = + value.getForLoopValues().stream() + .map(item -> TestUtil.apexValueToString(item)) + .collect(Collectors.toList()); + MatcherAssert.assertThat(valueList, containsInAnyOrder("hi", "hello")); + } } diff --git a/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/DescribeFieldResultTest.java b/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/DescribeFieldResultTest.java index 4baeb30c8..738637a3b 100644 --- a/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/DescribeFieldResultTest.java +++ b/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/DescribeFieldResultTest.java @@ -5,11 +5,7 @@ import com.salesforce.TestRunner; import com.salesforce.TestUtil; -import com.salesforce.graph.symbols.apex.ApexBooleanValue; -import com.salesforce.graph.symbols.apex.ApexEnumValue; -import com.salesforce.graph.symbols.apex.ApexListValue; -import com.salesforce.graph.symbols.apex.ApexStringValue; -import com.salesforce.graph.symbols.apex.SystemNames; +import com.salesforce.graph.symbols.apex.*; import com.salesforce.graph.visitor.SystemDebugAccumulator; import java.util.stream.Stream; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; @@ -18,6 +14,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; @@ -232,4 +229,34 @@ public void testSupplierMethods(String testName, String codeSnippet) { TestUtil.apexValueToString(describeSObjectResult.getSObjectType()), equalTo("Account")); } + + @CsvSource({ + "isCreateable,com.salesforce.graph.symbols.apex.ApexBooleanValue", + "getName,com.salesforce.graph.symbols.apex.ApexStringValue", + "getPicklistValues,com.salesforce.graph.symbols.apex.ApexListValue", + "getReferenceTo,com.salesforce.graph.symbols.apex.ApexListValue", + "getSObjectField,com.salesforce.graph.symbols.apex.schema.SObjectField" + }) + @ParameterizedTest + public void testSecondaryInvocationInForLoop(String methodName, String apexValueType) + throws ClassNotFoundException { + String sourceCode = + "public class MyClass {\n" + + " void doSomething() {\n" + + " List fields = new List{Account.Name, Contact.Phone};\n" + + " for (SObjectField myField: fields) {\n" + + " System.debug(myField.getDescribe()." + + methodName + + "());\n" + + " }\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.walkPath(g, sourceCode); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexForLoopValue forLoopValue = visitor.getSingletonResult(); + ApexValue value = forLoopValue.getForLoopValues().get(0); + MatcherAssert.assertThat(value, Matchers.instanceOf(Class.forName(apexValueType))); + } } diff --git a/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/DescribeSObjectResultTest.java b/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/DescribeSObjectResultTest.java index de4c62d31..f0f63bbab 100644 --- a/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/DescribeSObjectResultTest.java +++ b/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/DescribeSObjectResultTest.java @@ -8,17 +8,13 @@ import com.salesforce.TestRunner; import com.salesforce.TestUtil; -import com.salesforce.graph.symbols.apex.ApexBooleanValue; -import com.salesforce.graph.symbols.apex.ApexGlobalDescribeMapValue; -import com.salesforce.graph.symbols.apex.ApexListValue; -import com.salesforce.graph.symbols.apex.ApexMapValue; -import com.salesforce.graph.symbols.apex.ApexStringValue; -import com.salesforce.graph.symbols.apex.SystemNames; +import com.salesforce.graph.symbols.apex.*; import com.salesforce.graph.visitor.SystemDebugAccumulator; import java.util.stream.Stream; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.hamcrest.CoreMatchers; import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -326,4 +322,35 @@ public void testFieldValueCall(String initializer) { // If casting happens successfully, we should be good. ApexStringValue stringValue = visitor.getSingletonResult(); } + + @CsvSource({ + "isDeletable,com.salesforce.graph.symbols.apex.ApexBooleanValue", + "getName,com.salesforce.graph.symbols.apex.ApexStringValue", + "getRecordTypeInfos,com.salesforce.graph.symbols.apex.ApexListValue", + "getRecordTypeInfosByDeveloperName,com.salesforce.graph.symbols.apex.ApexMapValue", + "getRecordTypeInfosByName,com.salesforce.graph.symbols.apex.ApexMapValue", + "getSObjectType,com.salesforce.graph.symbols.apex.schema.SObjectType" + }) + @ParameterizedTest + public void testSecondaryInvocationInForLoop(String methodName, String apexValueType) + throws ClassNotFoundException { + String sourceCode = + "public class MyClass {\n" + + " void doSomething() {\n" + + " List types = new List{MyObject__c.SObjectType, Account.SObjectType};\n" + + " for (SObjectType myType: types) {\n" + + " System.debug(myType.getDescribe()." + + methodName + + "());\n" + + " }\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.walkPath(g, sourceCode); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexForLoopValue forLoopValue = visitor.getSingletonResult(); + ApexValue value = forLoopValue.getForLoopValues().get(0); + MatcherAssert.assertThat(value, Matchers.instanceOf(Class.forName(apexValueType))); + } } diff --git a/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/FieldSetMemberTest.java b/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/FieldSetMemberTest.java index 2cf8f4704..e4539b9e9 100644 --- a/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/FieldSetMemberTest.java +++ b/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/FieldSetMemberTest.java @@ -4,15 +4,15 @@ import com.salesforce.TestRunner; import com.salesforce.TestUtil; -import com.salesforce.graph.symbols.apex.ApexBooleanValue; -import com.salesforce.graph.symbols.apex.ApexEnumValue; -import com.salesforce.graph.symbols.apex.ApexStringValue; +import com.salesforce.graph.symbols.apex.*; import com.salesforce.graph.visitor.SystemDebugAccumulator; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; public class FieldSetMemberTest { @@ -102,4 +102,30 @@ public void testGetSObjectField() { MatcherAssert.assertThat( TestUtil.apexValueToString(value.getAssociatedObjectType()), equalTo("Account")); } + + @CsvSource({ + "getSObjectField,com.salesforce.graph.symbols.apex.schema.SObjectField" + }) // Leaving this parameterized so that we can add future methods we support here. + @ParameterizedTest + public void testSecondaryInvocationInForLoop(String methodName, String apexValueType) + throws ClassNotFoundException { + String sourceCode = + "public class MyClass {\n" + + " void doSomething() {\n" + + " List myFieldMembers = new List{SObjectType.Account.fieldSets.getMap().get('theName').getFields().get(0)};\n" + + " for (FieldSetMember myFieldMember: myFieldMembers) {\n" + + " System.debug(myFieldMember." + + methodName + + "());\n" + + " }\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.walkPath(g, sourceCode); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexForLoopValue forLoopValue = visitor.getSingletonResult(); + ApexValue value = forLoopValue.getForLoopValues().get(0); + MatcherAssert.assertThat(value, Matchers.instanceOf(Class.forName(apexValueType))); + } } diff --git a/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/FieldSetTest.java b/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/FieldSetTest.java index f49a5ca27..5a20b3b3a 100644 --- a/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/FieldSetTest.java +++ b/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/FieldSetTest.java @@ -6,14 +6,17 @@ import com.salesforce.TestRunner; import com.salesforce.TestUtil; import com.salesforce.graph.symbols.apex.ApexFieldSetListValue; +import com.salesforce.graph.symbols.apex.ApexForLoopValue; import com.salesforce.graph.symbols.apex.ApexStringValue; import com.salesforce.graph.symbols.apex.ApexValue; import com.salesforce.graph.visitor.SystemDebugAccumulator; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; public class FieldSetTest { @@ -91,4 +94,31 @@ public void testGetFields() { MatcherAssert.assertThat(value.isIndeterminant(), equalTo(false)); MatcherAssert.assertThat(value.getCanonicalType(), equalTo("List")); } + + @CsvSource({ + "getFields,com.salesforce.graph.symbols.apex.ApexFieldSetListValue", + "getSObjectType,com.salesforce.graph.symbols.apex.schema.SObjectType" + }) + @ParameterizedTest + public void testSecondaryInvocationInForLoop(String methodName, String apexValueType) + throws ClassNotFoundException { + String sourceCode = + "public class MyClass {\n" + + " void doSomething() {\n" + + " List
myFieldSets = new List
{SObjectType.Account.fieldSets.getMap().get('theName')};\n" + + " for (FieldSet myFieldSet: myFieldSets) {\n" + + " System.debug(myFieldSet." + + methodName + + "());\n" + + " }\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.walkPath(g, sourceCode); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexForLoopValue forLoopValue = visitor.getSingletonResult(); + ApexValue value = forLoopValue.getForLoopValues().get(0); + MatcherAssert.assertThat(value, Matchers.instanceOf(Class.forName(apexValueType))); + } } diff --git a/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/SObjectFieldTest.java b/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/SObjectFieldTest.java index 2c89c3aaa..38e7ce8e4 100644 --- a/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/SObjectFieldTest.java +++ b/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/SObjectFieldTest.java @@ -7,8 +7,12 @@ import com.salesforce.TestRunner; import com.salesforce.TestUtil; +import com.salesforce.graph.symbols.apex.ApexForLoopValue; +import com.salesforce.graph.symbols.apex.ApexValue; import com.salesforce.graph.visitor.SystemDebugAccumulator; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -59,4 +63,30 @@ public void testSObjectFieldFormat( equalTo(sObjectTypeName)); assertThat(describeFieldResult.getReturnedFrom().get(), instanceOf(SObjectField.class)); } + + @CsvSource({ + "getDescribe,com.salesforce.graph.symbols.apex.schema.DescribeFieldResult" + }) // Leaving this parameterized so that we can add future methods we support here. + @ParameterizedTest + public void testSecondaryInvocationInForLoop(String methodName, String apexValueType) + throws ClassNotFoundException { + String sourceCode = + "public class MyClass {\n" + + " void doSomething() {\n" + + " List myFields = new List{Account.Name};\n" + + " for (SObjectField myField: myFields) {\n" + + " System.debug(myField." + + methodName + + "());\n" + + " }\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.walkPath(g, sourceCode); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexForLoopValue forLoopValue = visitor.getSingletonResult(); + ApexValue value = forLoopValue.getForLoopValues().get(0); + MatcherAssert.assertThat(value, Matchers.instanceOf(Class.forName(apexValueType))); + } } diff --git a/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/SObjectTypeTest.java b/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/SObjectTypeTest.java index 58842274b..684e471b1 100644 --- a/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/SObjectTypeTest.java +++ b/sfge/src/test/java/com/salesforce/graph/symbols/apex/schema/SObjectTypeTest.java @@ -8,11 +8,13 @@ import com.salesforce.TestRunner; import com.salesforce.TestUtil; +import com.salesforce.graph.symbols.apex.ApexForLoopValue; import com.salesforce.graph.symbols.apex.ApexSingleValue; import com.salesforce.graph.symbols.apex.ApexValue; import com.salesforce.graph.visitor.SystemDebugAccumulator; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -154,4 +156,31 @@ public void testNewSObjectWithUnresolvedObjectType() { MatcherAssert.assertThat(sObjectType.getType().isPresent(), equalTo(false)); MatcherAssert.assertThat(sObjectType.getReturnedFrom().isPresent(), equalTo(false)); } + + @CsvSource({ + "getDescribe,com.salesforce.graph.symbols.apex.schema.DescribeSObjectResult", + "newSObject,com.salesforce.graph.symbols.apex.ApexSingleValue" + }) + @ParameterizedTest + public void testSecondaryInvocationInForLoop(String methodName, String apexValueType) + throws ClassNotFoundException { + String sourceCode = + "public class MyClass {\n" + + " void doSomething() {\n" + + " List myTypes = new List{Account.SObjectType};\n" + + " for (SObjectType myType: myTypes) {\n" + + " System.debug(myType." + + methodName + + "());\n" + + " }\n" + + " }\n" + + "}\n"; + + TestRunner.Result result = TestRunner.walkPath(g, sourceCode); + SystemDebugAccumulator visitor = result.getVisitor(); + + ApexForLoopValue forLoopValue = visitor.getSingletonResult(); + ApexValue value = forLoopValue.getForLoopValues().get(0); + MatcherAssert.assertThat(value, Matchers.instanceOf(Class.forName(apexValueType))); + } } diff --git a/sfge/src/test/java/com/salesforce/graph/visitor/PathScopeVisitorTest.java b/sfge/src/test/java/com/salesforce/graph/visitor/PathScopeVisitorTest.java index 4e7dcf7f5..97ebe632e 100644 --- a/sfge/src/test/java/com/salesforce/graph/visitor/PathScopeVisitorTest.java +++ b/sfge/src/test/java/com/salesforce/graph/visitor/PathScopeVisitorTest.java @@ -1,10 +1,11 @@ package com.salesforce.graph.visitor; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; import com.salesforce.TestRunner; import com.salesforce.TestUtil; +import com.salesforce.exception.UserActionException; import com.salesforce.graph.symbols.apex.ApexListValue; import com.salesforce.graph.symbols.apex.ApexSingleValue; import com.salesforce.graph.symbols.apex.ApexStringValue; @@ -12,6 +13,7 @@ import java.util.Optional; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.hamcrest.MatcherAssert; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -110,4 +112,36 @@ public void testParameterNameShadowsField() { MatcherAssert.assertThat( TestUtil.apexValueToString(value.getValues().get(1)), equalTo("value2")); } + + @Test + public void testVariableNameReuseThrowsUserActionException() { + String sourceCode = + "public class MyClass {\n" + + " public static void doSomething() {\n" + + " String myStr = 'hi';\n" + + " String myStr = 'hello';\n" + + " }\n" + + "}\n"; + + UserActionException thrown = + assertThrows( + UserActionException.class, + () -> TestRunner.walkPath(g, sourceCode), + "UserActionException should've been thrown before this point"); + + MatcherAssert.assertThat(thrown.getMessage(), containsString("MyClass:4")); + } + + @Test + public void testParameterNameAndFieldDoNotClash() { + String sourceCode = + "public class MyClass {\n" + + " String myStr;\n" + + " public void doSomething(String myStr) {\n" + + " this.myStr = myStr;\n" + + " }\n" + + "}\n"; + + Assertions.assertDoesNotThrow(() -> TestRunner.walkPath(g, sourceCode)); + } } diff --git a/sfge/src/test/java/com/salesforce/rules/RuleUtilTest.java b/sfge/src/test/java/com/salesforce/rules/RuleUtilTest.java index 59033c7ce..0668893fb 100644 --- a/sfge/src/test/java/com/salesforce/rules/RuleUtilTest.java +++ b/sfge/src/test/java/com/salesforce/rules/RuleUtilTest.java @@ -1,32 +1,18 @@ package com.salesforce.rules; -import static org.hamcrest.Matchers.containsInAnyOrder; -import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.core.IsNot.not; import static org.hamcrest.core.IsNull.nullValue; import static org.junit.jupiter.api.Assertions.*; import com.salesforce.TestUtil; -import com.salesforce.graph.Schema; -import com.salesforce.graph.vertex.MethodVertex; -import com.salesforce.metainfo.MetaInfoCollectorTestProvider; -import com.salesforce.metainfo.VisualForceHandlerImpl; -import com.salesforce.rules.AbstractRuleRunner.RuleRunnerTarget; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; import java.util.List; -import java.util.TreeSet; -import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.hamcrest.MatcherAssert; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; public class RuleUtilTest { private static final Logger LOGGER = LogManager.getLogger(RuleUtilTest.class); @@ -37,292 +23,11 @@ public void setup() { this.g = TestUtil.getGraph(); } - @ValueSource( - strings = { - Schema.AURA_ENABLED, - Schema.INVOCABLE_METHOD, - Schema.REMOTE_ACTION, - Schema.NAMESPACE_ACCESSIBLE - }) - @ParameterizedTest(name = "{displayName}: {0}") - public void getPathEntryPoints_includesAnnotatedMethods(String annotation) { - String sourceCode = - "public class Foo {\n" - + " @" - + annotation - + "\n" - + " public boolean annotatedMethod() {\n" - + " return true;\n" - + " }\n" - + "\n" - + " public boolean nonAnnotatedMethod() {\n" - + " return true;\n" - + " }\n" - + "}\n"; - TestUtil.buildGraph(g, sourceCode, true); - - List entryPoints = RuleUtil.getPathEntryPoints(g); - - MatcherAssert.assertThat(entryPoints, hasSize(equalTo(1))); - MethodVertex firstVertex = entryPoints.get(0); - assertEquals("annotatedMethod", firstVertex.getName()); - } - - @Test - public void getPathEntryPoints_includesGlobalMethods() { - String sourceCode = - "public class Foo {\n" - + " global static void globalStaticMethod() {\n" - + " }\n" - + " global void globalInstanceMethod() {\n" - + " }\n" - + " public static void publicStaticMethod() {\n" - + " }\n" - + "}\n"; - TestUtil.buildGraph(g, sourceCode, true); - - List entryPoints = RuleUtil.getPathEntryPoints(g); - - MatcherAssert.assertThat(entryPoints, hasSize(equalTo(2))); - boolean staticMethodFound = false; - boolean instanceMethodFound = false; - for (MethodVertex entrypoint : entryPoints) { - switch (entrypoint.getName()) { - case "globalStaticMethod": - staticMethodFound = true; - break; - case "globalInstanceMethod": - instanceMethodFound = true; - break; - default: - fail("Unexpected method " + entrypoint.getName()); - } - } - assertTrue(staticMethodFound); - assertTrue(instanceMethodFound); - } - - @Test - public void getPathEntryPoints_includesPageReferenceMethods() { - String sourceCode = - "public class Foo {\n" - + " public PageReference pageRefMethod() {\n" - + " return null;\n" - + " }\n" - + "\n" - + " public boolean nonAuraMethod() {\n" - + " return true;\n" - + " }\n" - + "}\n"; - TestUtil.buildGraph(g, sourceCode, true); - - List entryPoints = RuleUtil.getPathEntryPoints(g); - - MatcherAssert.assertThat(entryPoints, hasSize(equalTo(1))); - MethodVertex firstVertex = entryPoints.get(0); - assertEquals("pageRefMethod", firstVertex.getName()); - } - - @Test - public void getPathEntryPoints_includesInboundEmailHandlerMethods() { - String sourceCode = - "public class MyClass implements Messaging.InboundEmailHandler {\n" - + " public Messaging.InboundEmailResult handleInboundEmail(Messaging.InboundEmail email, Messaging.InboundEnvelope envelope) {\n" - + " return null;\n" - + " }\n" - + " public Messaging.InboundEmailHandler someSecondaryMethod() {\n" - + " return null;\n" - + " }\n" - + "}\n"; - TestUtil.buildGraph(g, sourceCode, true); - - List entryPoints = RuleUtil.getPathEntryPoints(g); - - MatcherAssert.assertThat(entryPoints, hasSize(equalTo(1))); - MethodVertex firstVertex = entryPoints.get(0); - assertEquals("handleInboundEmail", firstVertex.getName()); - } - - @Test - public void getPathEntryPoints_includesExposedControllerMethods() { - try { - String controllerSourceCode = - "public class ApexControllerClass {\n" - + " public String getSomeStringProperty() {\n" - + " return 'beep';\n" - + " }\n" - + "\n" - + " global String getSomeOtherStringProperty() {\n" - + " return 'boop';\n" - + " }\n" - + "\n" - + " private String getYetAnotherStringProperty() {\n" - + " return 'baap';\n" - + " }\n" - + "}\n"; - - MetaInfoCollectorTestProvider.setVisualForceHandler( - new VisualForceHandlerImpl() { - private TreeSet references = - new TreeSet<>(String.CASE_INSENSITIVE_ORDER); - - @Override - public void loadProjectFiles(List sourceFolders) { - // NO-OP - } - - @Override - public TreeSet getMetaInfoCollected() { - references.add("ApexControllerClass"); - return references; - } - }); - TestUtil.buildGraph(g, controllerSourceCode, true); - - List entryPoints = RuleUtil.getPathEntryPoints(g); - // TODO: This number is three, because the synthetic clone method is included. This - // might not be the behavior - // we want. If we change our minds, the test should change. - MatcherAssert.assertThat(entryPoints, hasSize(equalTo(3))); - List methodNames = - entryPoints.stream().map(MethodVertex::getName).collect(Collectors.toList()); - MatcherAssert.assertThat( - methodNames, - containsInAnyOrder( - "clone", "getSomeStringProperty", "getSomeOtherStringProperty")); - } finally { - MetaInfoCollectorTestProvider.removeVisualForceHandler(); - } - } - - @Test - public void getPathEntryPoints_includeMethodLevelTargets() { - String sourceCode0 = - "public class MyClass1 {\n" - + " @AuraEnabled\n" - + " public boolean auraMethod() {\n" - + " return true;\n" - + " }\n" - + "\n" - + " public boolean nonIncludedMethod() {\n" - + " return true;\n" - + " }\n" - + " public boolean nonIncludedMethod(boolean param) {\n" - + " return true;\n" - + " }\n" - + "}\n"; - String sourceCode1 = - "public class MyClass2 {\n" - + " public PageReference pageRefMethod() {\n" - + " return null;\n" - + " }\n" - + "\n" - + " public boolean nonIncludedMethod() {\n" - + " return true;\n" - + " }\n" - + " public boolean nonIncludedMethod(boolean param) {\n" - + " return true;\n" - + " }\n" - + "}\n"; - TestUtil.buildGraph(g, new String[] {sourceCode0, sourceCode1}, true); - List targets = new ArrayList<>(); - // Create a target that encompasses both of the `nonIncludedMethod()` definitions in - // MyClass1. - targets.add( - TestUtil.createTarget("TestCode0", Collections.singletonList("nonIncludedMethod"))); - - // TEST: Load the methods encompassed by the targets. - List entryPoints = RuleUtil.getPathEntryPoints(g, targets); - // Make sure the right number of methods were returned. - MatcherAssert.assertThat(entryPoints, hasSize(equalTo(2))); - - // Sort the vertices, so we can inspect them. - entryPoints.sort( - Comparator.comparing(MethodVertex::getDefiningType) - .thenComparing(MethodVertex::getName) - .thenComparing(MethodVertex::getBeginLine)); - // Make sure that the methods returned were the right ones. - MethodVertex firstVertex = entryPoints.get(0); - assertEquals("nonIncludedMethod", firstVertex.getName()); - assertEquals("MyClass1", firstVertex.getDefiningType()); - assertEquals(7, firstVertex.getBeginLine()); - - MethodVertex secondVertex = entryPoints.get(1); - assertEquals("nonIncludedMethod", secondVertex.getName()); - assertEquals("MyClass1", secondVertex.getDefiningType()); - assertEquals(10, secondVertex.getBeginLine()); - } - - @Test - public void getPathEntryPoints_includeMethodAndFileLevelTargets() { - String sourceCode0 = - "public class MyClass1 {\n" - + " @AuraEnabled\n" - + " public boolean auraMethod() {\n" - + " return true;\n" - + " }\n" - + "\n" - + " public boolean nonIncludedMethod() {\n" - + " return true;\n" - + " }\n" - + " public boolean nonIncludedMethod(boolean param) {\n" - + " return true;\n" - + " }\n" - + "}\n"; - String sourceCode1 = - "public class MyClass2 {\n" - + " public PageReference pageRefMethod() {\n" - + " return null;\n" - + " }\n" - + "\n" - + " public boolean nonIncludedMethod() {\n" - + " return true;\n" - + " }\n" - + " public boolean nonIncludedMethod(boolean param) {\n" - + " return true;\n" - + " }\n" - + "}\n"; - TestUtil.buildGraph(g, new String[] {sourceCode0, sourceCode1}, true); - List targets = new ArrayList<>(); - // Create a target that encompasses both of the `nonIncludedMethod()` definitions in - // MyClass1. - targets.add( - TestUtil.createTarget("TestCode0", Collections.singletonList("nonIncludedMethod"))); - // Create a target that encompasses the entirety of MyClass2. - targets.add(TestUtil.createTarget("TestCode1", new ArrayList<>())); - - // TEST: Load the methods encompassed by the targets. - List entryPoints = RuleUtil.getPathEntryPoints(g, targets); - - // Make sure the right number of methods were returned. - MatcherAssert.assertThat(entryPoints, hasSize(equalTo(3))); - // Sort the vertices, so we can inspect them. - entryPoints.sort( - Comparator.comparing(MethodVertex::getDefiningType) - .thenComparing(MethodVertex::getName) - .thenComparing(MethodVertex::getBeginLine)); - // Make sure that the methods returned were the right ones. - MethodVertex firstVertex = entryPoints.get(0); - assertEquals("nonIncludedMethod", firstVertex.getName()); - assertEquals("MyClass1", firstVertex.getDefiningType()); - assertEquals(7, firstVertex.getBeginLine()); - - MethodVertex secondVertex = entryPoints.get(1); - assertEquals("nonIncludedMethod", secondVertex.getName()); - assertEquals("MyClass1", secondVertex.getDefiningType()); - assertEquals(10, secondVertex.getBeginLine()); - - MethodVertex thirdVertex = entryPoints.get(2); - assertEquals("pageRefMethod", thirdVertex.getName()); - assertEquals("MyClass2", thirdVertex.getDefiningType()); - assertEquals(2, thirdVertex.getBeginLine()); - } - @Test public void getAllRules_noExceptionThrown() { try { List allRules = RuleUtil.getEnabledRules(); - MatcherAssert.assertThat(allRules, hasSize(1)); + MatcherAssert.assertThat(allRules, hasSize(2)); assertTrue(allRules.contains(ApexFlsViolationRule.getInstance())); } catch (Exception ex) { fail("Unexpected " + ex.getClass().getSimpleName() + ": " + ex.getMessage()); diff --git a/sfge/src/test/java/com/salesforce/rules/fls/apex/FlowBasedFieldLevelFlsViolationTest.java b/sfge/src/test/java/com/salesforce/rules/fls/apex/FlowBasedFieldLevelFlsViolationTest.java index ca30080a9..3c9414992 100644 --- a/sfge/src/test/java/com/salesforce/rules/fls/apex/FlowBasedFieldLevelFlsViolationTest.java +++ b/sfge/src/test/java/com/salesforce/rules/fls/apex/FlowBasedFieldLevelFlsViolationTest.java @@ -1757,7 +1757,6 @@ public void testListOfSObjectFields_singleLevel( @MethodSource("input") @ParameterizedTest(name = "{displayName}: {0}") - @Disabled // TODO: Handle method invocation on forloop values public void testListOfSObjectFields( String operationName, AbstractPathBasedRule rule, @@ -1788,6 +1787,38 @@ public void testListOfSObjectFields( assertNoViolation(rule, sourceCode); } + @MethodSource("input") + @ParameterizedTest(name = "{displayName}: {0}") + public void testSetOfSObjectFields( + String operationName, + AbstractPathBasedRule rule, + FlsValidationType validationType, + String validationCheck, + String dmlOperationLine, + String dmlOperationWithTwoFields, + String dmlOperationAnotherObj) { + String sourceCode = + "public class MyClass {\n" + + " public void foo() {\n" + + " Set fields = new Set{Schema.Account.fields.Name,Schema.Account.fields.Phone};\n" + + " checkValidation(fields);\n" + + " " + + dmlOperationWithTwoFields + + " }\n" + + " public void checkValidation(Set fields) {\n" + + " for (Schema.SObjectField field: fields) {\n" + + " if (!field.getDescribe()." + + validationCheck + + "()) {\n" + + " throw new AccessException();\n" + + " }\n" + + " }\n" + + " }\n" + + "}\n"; + + assertNoViolation(rule, sourceCode); + } + @Test public void testCopyOfIndeterminantSObject() { String sourceCode = diff --git a/sfge/src/test/java/com/salesforce/rules/unusedmethod/BaseUnusedMethodTest.java b/sfge/src/test/java/com/salesforce/rules/unusedmethod/BaseUnusedMethodTest.java new file mode 100644 index 000000000..d334a9751 --- /dev/null +++ b/sfge/src/test/java/com/salesforce/rules/unusedmethod/BaseUnusedMethodTest.java @@ -0,0 +1,128 @@ +package com.salesforce.rules.unusedmethod; + +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.salesforce.TestUtil; +import com.salesforce.rules.AbstractStaticRule; +import com.salesforce.rules.UnusedMethodRule; +import com.salesforce.rules.Violation; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; +import org.hamcrest.MatcherAssert; +import org.junit.jupiter.api.BeforeEach; + +public class BaseUnusedMethodTest { + protected GraphTraversalSource g; + + /* ============== SETUP METHODS ============== */ + @BeforeEach + public void setup() { + this.g = TestUtil.getGraph(); + } + + /* ============== ASSERT VIOLATIONS ============== */ + // TODO: Refactoring opportunity. Long-term, we may want to modularize these methods and put + // them in another class for re-use. + + /** + * Assert that each of the provided method names corresponds to a method that threw a violation. + * + * @param sourceCode - A single source file + */ + protected void assertViolations(String sourceCode, String... methodNames) { + assertViolations(new String[] {sourceCode}, methodNames); + } + + /** + * Assert that each of the provided method names corresponds to a method that threw a violation. + * + * @param sourceCodes - An array of source files + */ + protected void assertViolations(String[] sourceCodes, String... methodNames) { + List> assertions = new ArrayList<>(); + + for (int i = 0; i < methodNames.length; i++) { + final int idx = i; + assertions.add( + v -> { + assertEquals(methodNames[idx], v.getSourceVertexName()); + }); + } + assertViolations(sourceCodes, assertions.toArray(new Consumer[] {})); + } + + /** + * Assert that violations were generated that match the provided checks. + * + * @param sourceCode - A source file + * @param assertions - One or more consumers that perform assertions. The n-th consumer is + * applied to the n-th violation. + */ + protected void assertViolations( + String sourceCode, Consumer... assertions) { + assertViolations(new String[] {sourceCode}, assertions); + } + + /** + * Assert that violations were generated that match the provided checks. + * + * @param sourceCodes - An array of source files + * @param assertions - One or more consumers that perform assertions. The n-th consumer is + * applied to the n-th violation. + */ + protected void assertViolations( + String[] sourceCodes, Consumer... assertions) { + TestUtil.buildGraph(g, sourceCodes, true); + + AbstractStaticRule rule = UnusedMethodRule.getInstance(); + List violations = rule.run(g); + + MatcherAssert.assertThat(violations, hasSize(equalTo(assertions.length))); + for (int i = 0; i < assertions.length; i++) { + assertions[i].accept((Violation.RuleViolation) violations.get(i)); + } + } + + /* ============== ASSERT NO VIOLATIONS ============== */ + + /** + * Assert that the expected number of methods were analyzed, and all were determined to be used. + * + * @param sourceCode - A source file + */ + protected void assertNoViolations(String sourceCode, int eligibleMethodCount) { + assertNoViolations(new String[] {sourceCode}, eligibleMethodCount); + } + + /** + * Assert that the expected number of methods were analyzed, and all were determined to be used. + * + * @param sourceCodes - An array of source files + */ + protected void assertNoViolations(String[] sourceCodes, int eligibleMethodCount) { + TestUtil.buildGraph(g, sourceCodes, true); + + UnusedMethodRule rule = UnusedMethodRule.getInstance(); + List violations = rule.run(g); + + MatcherAssert.assertThat(violations, empty()); + assertEquals(eligibleMethodCount, rule.getRuleStateTracker().getEligibleMethodCount()); + } + + /* ============== ASSERT NO ANALYSIS ATTEMPT ============== */ + protected void assertNoAnalysis(String sourceCode) { + assertNoAnalysis(new String[] {sourceCode}); + } + + protected void assertNoAnalysis(String[] sourceCodes) { + TestUtil.buildGraph(g, sourceCodes, true); + UnusedMethodRule rule = UnusedMethodRule.getInstance(); + List violations = rule.run(g); + + MatcherAssert.assertThat(violations, empty()); + assertEquals(0, rule.getRuleStateTracker().getEligibleMethodCount()); + } +} diff --git a/sfge/src/test/java/com/salesforce/rules/unusedmethod/EdgeCaseTest.java b/sfge/src/test/java/com/salesforce/rules/unusedmethod/EdgeCaseTest.java new file mode 100644 index 000000000..bbc93c3eb --- /dev/null +++ b/sfge/src/test/java/com/salesforce/rules/unusedmethod/EdgeCaseTest.java @@ -0,0 +1,205 @@ +package com.salesforce.rules.unusedmethod; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** A collection of tests for weird edge cases. */ +public class EdgeCaseTest extends BaseUnusedMethodTest { + /** + * If an outer class has a static method, and its inner class has an instance method with the + * same name, then invoking that method without the `this` keyword should still count as using + * the instance method, not the static method. + */ + @Test + @Disabled + public void innerInstanceOverlapsWithOuterStatic_expectViolationForOuter() { + String[] sourceCodes = { + "global virtual class ParentClass {" + // Declare a static method on the outer class with a certain name. + + " public static boolean overlappingName() {\n" + + " return true;\n" + + " }\n" + + " global class InnerOfParent {\n" + // Declare an instance method on the inner class with the same name. + + " public boolean overlappingName() {\n" + + " return true;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean invoker() {" + // Invoke the instance method without using `this`. + + " return overlappingName();\n" + + " }\n" + + " }\n" + + "}\n", + // Declare a class that extends the parent class, thereby inheriting its static method. + "global class ChildClass extends ParentClass {\n" + + " global class InnerOfChild {\n" + // Declare an instance method on the inner class with the same name. + + " public boolean overlappingName() {\n" + + " return true;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean invoker() {\n" + // Invoke the instance method without using `this`. + + " return overlappingName();\n" + + " }\n" + + " }\n" + + "}\n" + }; + // We expect the outer static method to be unused, and both inner methods to be used. + assertViolations( + sourceCodes, + v -> { + assertEquals("overlappingName", v.getSourceVertexName()); + assertEquals("ParentClass", v.getSourceVertex().getDefiningType()); + }); + } + + /** + * If an inner class extends another class and inherits a method whose name matches a static + * method on the outer class, then invoking the method in the inner class without the `this` + * keyword should still count as an invocation of the inherited method, not the outer static + * one. + */ + @ValueSource(strings = {"public", "public static"}) + @ParameterizedTest(name = "{displayName}: parent method {0}") + @Disabled + public void inheritedInnerClassOverlapsWithOuter_expectViolations(String scope) { + String[] sourceCodes = { + // Declare a parent class with a method. + "public virtual class ParentClass {\n" + + String.format(" %s boolMethod() {\n", scope) + + " return true;\n" + + " }\n" + + "}\n", + // Declare an outer class with a static method whose name matches the parent class's + // method. + "public class OuterClass {\n" + + " public static boolean boolMethod() {\n" + + " return false;\n" + + " }\n" + // Declare an inner class that extends the parent class, + // thereby inheriting its methods. + + " public class InnerChild extends ParentClass {\n" + // Give the inner class a method that calls the inherited method. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean invoker() {\n" + + " return boolMethod();\n" + + " }\n" + + " }\n" + + "}\n" + }; + // We expect the inherited method on the parent class to be called, not the static + // method on the outer class. + assertViolations( + sourceCodes, + v -> { + assertEquals("boolMethod", v.getSourceVertexName()); + assertEquals("OuterClass", v.getSourceVertex().getDefiningType()); + }); + } + + /** + * If an outer class defines an inner class, it can reference the class with just the inner + * class name instead of the full class name, even if an unrelated outer class shares the same + * name. In this case, the methods invoked are the ones on the inner class. + */ + @Test + @Disabled + public void innerClassNameOverlapsWithOuter_expectViolations() { + String[] sourceCodes = { + "global class OuterClass {\n" + + " global class OverlappingNameClass {\n" + // Declare a constructor. + + " public OverlappingNameClass(boolean b) {\n" + + " }\n" + // Declare an instance method. + + " public boolean someMethod() {\n" + + " return true;\n" + + " }\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean invokerMethod() {\n" + // Invoke the constructor. + + " OverlappingNameClass instance = new OverlappingNameClass(true);\n" + // Invoke the instance method. + + " return instance.someMethod();\n" + + " }\n" + + "}\n", + // Declare another class with the same name as the other class's inner class. + "global class OverlappingNameClass {\n" + // Give it a constructor with the same signature as the inner class. + + " public OverlappingNameClass(boolaen b) {\n" + + " }\n" + // Give it a method with the same signature as the instance method on the inner + // class. + + " public boolean someMethod() {\n" + + " return true;\n" + + " }\n" + + "}\n" + }; + // All methods on the outer class should be unused. + assertViolations( + sourceCodes, + v -> { + assertEquals("", v.getSourceVertexName()); + assertEquals("OverlappingNameClass", v.getSourceDefiningType()); + assertEquals(1, v.getSourceLineNumber()); + }, + v -> { + assertEquals("someMethod", v.getSourceVertexName()); + assertEquals("OverlappingNameClass", v.getSourceDefiningType()); + assertEquals(4, v.getSourceLineNumber()); + }); + } + + /** + * If a variable shares the same name as a wholly unrelated class, and it has an instance method + * whose name overlaps with that of a static method on that other class, then calling + * `var.theMethod()` invokes the instance method, not the static one. So the static method + * should count as unused. + */ + @Test + @Disabled + public void variableSharesNameWithOtherClass_expectViolation() { + String[] sourceCodes = { + "global class MyClass {\n" + // Declare a static method. + + " public static boolean someMethod() {\n" + + " return true;\n" + + " }\n" + + "}\n", + "global class MyOtherClass {\n" + // Declare an instance method with the same name as the + // other class's static method. + + " public boolean someMethod() {\n" + + " return false;\n" + + " }\n" + + "}\n", + "global class InvokerClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + // This method's param parameter is an instance of MyOtherClass + // whose name is myClass. + + " public boolean invokerMethod(MyOtherClass myClass) {\n" + // Per manual experimentation, this counts as an invocation of the + // instance method, NOT the static method. + + " return myClass.someMethod();\n" + + " }\n" + + "}\n" + }; + assertViolations( + sourceCodes, + v -> { + assertEquals("someMethod", v.getSourceVertexName()); + assertEquals("MyClass", v.getSourceDefiningType()); + }); + } +} diff --git a/sfge/src/test/java/com/salesforce/rules/unusedmethod/ExternalCallsTest.java b/sfge/src/test/java/com/salesforce/rules/unusedmethod/ExternalCallsTest.java new file mode 100644 index 000000000..ef765a576 --- /dev/null +++ b/sfge/src/test/java/com/salesforce/rules/unusedmethod/ExternalCallsTest.java @@ -0,0 +1,178 @@ +package com.salesforce.rules.unusedmethod; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests for methods called on classes that are wholly unrelated to the class where they're defined. + */ +public class ExternalCallsTest extends BaseUnusedMethodTest { + /* =============== SECTION 1: STATIC METHODS =============== */ + /** + * If a class has static methods, and those methods are invoked by another class, then they + * count as used. + */ + @Test + @Disabled + public void staticMethodCalledExternallyWithinMethod_expectNoViolation() { + String[] sourceCodes = { + "global class DefiningClass {\n" + + " public static boolean someMethod() {\n" + + " return true;\n" + + " }\n" + + "}\n", + "global class InvokingClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean anotherMethod() {\n" + + " return DefiningClass.someMethod();\n" + + " }\n" + + "}\n" + }; + assertNoViolations(sourceCodes, 1); + } + + /** + * If a class has static methods, and those methods are invoked to set properties on another + * class, then they count as used. + */ + @Test + @Disabled + public void staticMethodCalledExternallyByProperty_expectNoViolation() { + String[] sourceCodes = { + "global class DefiningClass {\n" + + " public static boolean someMethod() {\n" + + " return true;\n" + + " }\n" + + " public static boolean someOtherMethod() {\n" + + " return true;\n" + + " }\n" + + "}\n", + "global class InvokingClass {\n" + // Reference one method with a static property and the other with an instance + // property. + + " public static boolean b1 = DefiningClass.someMethod();\n" + + " public boolean b2 = DefiningClass.someOtherMethod();\n" + + "}\n" + }; + assertNoViolations(sourceCodes, 2); + } + + /* =============== SECTION 2: INSTANCE METHODS =============== */ + + /** + * If a class has instance methods, and those methods are invoked on an instance of the object, + * then they count as used. + */ + @ValueSource( + strings = { + // Call on a parameter to the method. + "directParam", + // Call on a variable + "directVariable", + // Call on an instance property + "instanceProp", + "this.instanceProp", + // Call on a static property + "staticProp", + "InvokingClass.staticProp", + // Call on a static method return + "staticMethod()", + "InvokingClass.staticMethod()", + // Call on an instance method return + "instanceMethod()", + "this.instanceMethod()", + // Call properties and methods on a middleman parameter + "middlemanParam.instanceMiddlemanProperty", + "middlemanParam.instanceMiddlemanMethod()", + // Call properties and methods on a middleman variable + "middlemanVariable.instanceMiddlemanProperty", + "middlemanVariable.instanceMiddlemanMethod()", + // Call static properties and methods on middleman class + "MiddlemanClass.staticMiddlemanProperty", + "MiddlemanClass.staticMiddlemanMethod()" + }) + @ParameterizedTest(name = "{displayName}: {0}") + @Disabled + public void instanceMethodCalledExternallyWithinMethod_expectNoViolation( + String objectInstance) { + String[] sourceCodes = { + "global class DefiningClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public DefiningClass() {\n" + + " }\n" + + " public boolean testedMethod() {\n" + + " return true;\n" + + " }\n" + + "}\n", + "global class MiddlemanClass {\n" + + " public DefiningClass instanceMiddlemanProperty;\n" + + " public static DefiningClass staticMiddlemanProperty;\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public DefiningClass instanceMiddlemanMethod() {\n" + + " return new DefiningClass();\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public static DefiningClass staticMiddlemanMethod() {\n" + + " return new DefiningClass();\n" + + " }\n" + + "}\n", + "global class InvokingClass {\n" + + " public DefiningClass instanceProp;" + + " public static DefiningClass staticProp;" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public DefiningClass instanceMethod() {\n" + + " return new DefiningClass();\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public static DefiningClass staticMethod() {\n" + + " return new DefiningClass();\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean anotherMethod(DefiningClass directParam, MiddlemanClass middlemanParam) {\n" + + " DefiningClass directVariable = new DefiningClass();\n" + + " MiddlemanClass middlemanVariable = new MiddlemanClass();\n" + + String.format(" return %s.testedMethod();\n", objectInstance) + + " }\n" + + "}\n" + }; + assertNoViolations(sourceCodes, 1); + } + + /* =============== SECTION 3: CONSTRUCTOR METHODS =============== */ + + /** + * If a class has constructors, and those constructors are invoked by other classes, then they + * count as used. (Note: Test cases for both explicitly declared 0-arity and 1-arity + * constructor.) + */ + @CsvSource({"(), ()", "(boolean b), (false)"}) + @ParameterizedTest(name = "{displayName}: {0}/{1}") + @Disabled + public void constructorInvokedExternally_expectNoViolation( + String definingParams, String invokingParams) { + String[] sourceCodes = { + "global class DefiningClass {\n" + + " public boolean prop = false;\n" + + String.format(" public DefiningClass%s {\n", definingParams) + + " }\n", + "global class InvokingClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean someMethod() {\n" + + String.format(" return new DefiningClass%s.prop;\n", invokingParams) + + " }\n" + + "}\n" + }; + assertNoViolations(sourceCodes, 1); + } +} diff --git a/sfge/src/test/java/com/salesforce/rules/unusedmethod/IneligibleMethodExclusionTest.java b/sfge/src/test/java/com/salesforce/rules/unusedmethod/IneligibleMethodExclusionTest.java new file mode 100644 index 000000000..bc2b5cad6 --- /dev/null +++ b/sfge/src/test/java/com/salesforce/rules/unusedmethod/IneligibleMethodExclusionTest.java @@ -0,0 +1,218 @@ +package com.salesforce.rules.unusedmethod; + +import com.salesforce.graph.Schema; +import com.salesforce.metainfo.MetaInfoCollectorTestProvider; +import com.salesforce.metainfo.VisualForceHandlerImpl; +import java.util.List; +import java.util.TreeSet; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Some methods shouldn't even be considered eligible candidates for analysis. These tests make sure + * that this behavior is preserved. + */ +public class IneligibleMethodExclusionTest extends BaseUnusedMethodTest { + + /* ============ SECTION 1: CONSTRUCTORS ============ */ + /** + * When no constructor is declared on a class, a no-parameter constructor is implicitly + * generated. To reduce noise, this should always count as used. + */ + @Test + public void impliedConstructorWithoutInvocation_expectNoAnalysis() { + String sourceCode = "public class MyClass {}"; + assertNoAnalysis(sourceCode); + } + + /** + * When a class has a private, 0-arity constructor, that constructor is ineligible for analysis. + * This pattern is common for utility classes with static methods, and we want to minimize false + * positives. + */ + @Test + public void privateArity0ConstructorWithoutInvocation_expectNoAnalysis() { + String sourceCode = + "public class MyClass {\n" + " private MyClass() {\n" + " }\n" + "}\n"; + assertNoAnalysis(sourceCode); + } + + /* ============ SECTION 2: ENGINE DIRECTIVES ============ */ + @Test + public void applySkipStack_expectNoAnalysis() { + String sourceCode = + "public class MyClass {\n" + // Unused static method, annotated with the directive. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " private static boolean unusedStaticMethod() {\n" + + " return true;\n" + + " }\n" + // Unused instance method, annotated with the directive. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " private boolean unusedInstanceMethod() {\n" + + " return true;\n" + + " }\n" + // Unused constructor, annotated with the method. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " private MyClass () {\n" + + " }\n" + + "}\n"; + assertNoAnalysis(sourceCode); + } + + @Test + public void applySkipClassDirective_expectNoAnalysis() { + String sourceCode = + "/* sfge-disable UnusedMethodRule */\n" + + "public class MyClass {\n" + // Unused static method + + " private static boolean unusedStaticMethod() {\n" + + " return true;\n" + + " }\n" + // Unused instance method + + " private boolean unusedInstanceMethod() {\n" + + " return true;\n" + + " }\n" + // Unused constructor + + " private MyClass () {\n" + + " }\n" + + "}\n"; + assertNoAnalysis(sourceCode); + } + + /* =============== SECTION 3: PATH ENTRY POINTS =============== */ + // REASONING: Public-facing entry points probably aren't explicitly invoked within the codebase, + // but we must assume they're used by external sources. + + /** Global methods are entrypoints, and should count as used. */ + @ValueSource(strings = {"global", "global static"}) + @ParameterizedTest(name = "{displayName}: {0}") + public void globalMethod_expectNoAnalysis(String annotation) { + String sourceCode = + "global class MyClass {\n" + + String.format(" %s boolean someMethod() {\n", annotation) + + " return true;\n" + + " }\n" + + "}\n"; + assertNoAnalysis(sourceCode); + } + + /** public methods on controllers are entrypoints, and should count as used. */ + @Test + public void publicControllerMethod_expectNoAnalysis() { + try { + String sourceCode = + "global class MyController {\n" + + " public String getSomeProperty() {\n" + + " return 'beep';\n" + + " }\n" + + "}\n"; + + MetaInfoCollectorTestProvider.setVisualForceHandler( + new VisualForceHandlerImpl() { + private final TreeSet references = + new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + + @Override + public void loadProjectFiles(List sourceFolders) { + // NO-OP + } + + @Override + public TreeSet getMetaInfoCollected() { + references.add("MyController"); + return references; + } + }); + assertNoAnalysis(sourceCode); + } finally { + MetaInfoCollectorTestProvider.removeVisualForceHandler(); + } + } + + /** Methods returning PageReferences are entrypoints, and should count as used. */ + @Test + public void pageReferenceMethod_expectNoAnalysis() { + String sourceCode = + "global class MyClass {\n" + + " private PageReference someMethod() {\n" + + " return null;\n" + + " }\n" + + "}\n"; + assertNoAnalysis(sourceCode); + } + + /** Certain annotated methods are entrypoints, and should count as used. */ + @ValueSource( + strings = { + Schema.AURA_ENABLED, + Schema.INVOCABLE_METHOD, + Schema.REMOTE_ACTION, + Schema.NAMESPACE_ACCESSIBLE + }) + @ParameterizedTest(name = "{displayName}: {0}") + public void annotatedMethod_expectNoAnalysis(String annotation) { + String sourceCode = + "global class MyClass {\n" + + String.format(" @%s\n", annotation) + + " private boolean someMethod() {\n" + + " return true;\n" + + " }\n" + + "}\n"; + assertNoAnalysis(sourceCode); + } + + /** + * If a class implements Messaging.InboundEmailHandler, its handleInboundEmail() method is an + * entrypoint, and should count as used. + */ + @Test + public void emailHandlerMethod_expectNoAnalysis() { + String sourceCode = + "global class MyClass implements Messaging.InboundEmailhandler {\n" + + " private Messaging.InboundEmailResult handleInboundEmail(Messaging.InboundEmail email, Messaging.InboundEnvelope envelope) {\n" + + " return null;\n" + + " }\n" + + "}\n"; + assertNoAnalysis(sourceCode); + } + + /* =============== SECTION 4: PROPERTY GETTERS AND SETTERS =============== */ + // REASONING: Public setters are often used by Visualforce, and private setters are often + // declared to prevent a variable from being modified entirely. + + @Test + public void getterSetterDeclaration_expectNoAnalysis() { + String sourceCode = + "global class MyClass {\n" + + " public Boolean someProperty {\n" + + " get {\n" + + " return this.someProperty;\n" + + " }\n" + + " private set;\n" + + " }\n" + + "}\n"; + assertNoAnalysis(sourceCode); + } + + /* =============== SECTION 5: ABSTRACT METHOD DECLARATION =============== */ + // REASONING: If an interface/class has abstract methods, then subclasses must implement + // those methods for the code to compile. And if the interface/class isn't + // implemented anywhere, we have separate rules for surfacing that. + + /** Abstract methods on abstract classes/interfaces are abstract, and count as used. */ + @Test + public void abstractMethodDeclaration_expectNoAnalysis() { + String[] sourceCodes = { + "global abstract class AbstractWithPublic {\n" + + " public abstract boolean someMethod();\n" + + "}\n", + "global abstract class AbstractWithProtected {\n" + + " protected abstract boolean someMethod();\n" + + "}\n", + "global interface MyInterface {\n" + " boolean anotherMethod();\n" + "}\n" + }; + assertNoAnalysis(sourceCodes); + } +} diff --git a/sfge/src/test/java/com/salesforce/rules/unusedmethod/InheritanceTest.java b/sfge/src/test/java/com/salesforce/rules/unusedmethod/InheritanceTest.java new file mode 100644 index 000000000..41127449c --- /dev/null +++ b/sfge/src/test/java/com/salesforce/rules/unusedmethod/InheritanceTest.java @@ -0,0 +1,533 @@ +package com.salesforce.rules.unusedmethod; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.salesforce.rules.Violation; +import java.util.function.Consumer; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** Tests for calls of methods on subclasses and superclasses. */ +public class InheritanceTest extends BaseUnusedMethodTest { + + /* =============== SECTION 1: STATIC METHODS =============== */ + /** + * If a subclass invokes a static method on its parent class, then that method counts as used. + */ + // (NOTE: No need for a `protected` case, since methods can't be both + // `protected` and `static`.) + @CsvSource({ + // Invocation in static method, with implicit/explicit `this`, + // and explicit references to the parent and child classes + "static boolean, this.staticMethod()", + "static boolean, staticMethod()", + "static boolean, ParentClass.staticMethod()", + "static boolean, ChildClass.staticMethod()", + // Invocation in instance method, with implicit/explicit class reference. + "boolean, staticMethod()", + "boolean, ParentClass.staticMethod()" + }) + @ParameterizedTest(name = "{displayName}: invoker scope {0}; invocation {1}") + @Disabled + public void staticMethodInvokedInSubclass_expectNoViolation( + String invokerScope, String invocation) { + String[] sourceCodes = { + "global virtual class ParentClass {\n" + + " public static boolean staticMethod() {\n" + + " return true;\n" + + " }\n" + + "}\n", + "global class ChildClass extends ParentClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + String.format(" public %s invokingMethod() {\n", invokerScope) + + String.format(" return %s;\n", invocation) + + " }\n" + + "}\n" + }; + assertNoViolations(sourceCodes, 1); + } + + /** + * If a subclass's inner class invokes a static method on the parent class, then that method + * counts as used. + */ + // (NOTE: No need for a `protected` case, since methods can't be both + // `protected` and `static`.) + @ValueSource( + strings = {"staticMethod()", "ParentClass.staticMethod()", "ChildClass.staticMethod()"}) + @ParameterizedTest(name = "{displayName}: {0}") + @Disabled + public void staticMethodInvokedInSubclassInnerClass_expectNoViolation(String invocation) { + String[] sourceCodes = { + "global virtual class ParentClass {\n" + + " public static boolean staticMethod() {\n" + + " return true;\n" + + " }\n" + + "}\n", + "global class ChildClass extends ParentClass {\n" + + " global class InnerClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean invokingMethod() {\n" + + String.format(" return %s;\n", invocation) + + " }\n" + + " }\n" + + "}\n" + }; + assertNoViolations(sourceCodes, 1); + } + + /* =============== SECTION 2: INSTANCE METHODS =============== */ + + /** + * If a subclass invokes methods it inherited from the parent without overriding them, those + * methods count as used. + */ + @CsvSource({ + // Implicit/explicit `this`, and `super`, for each of the two relevant + // visibility scopes. + "public, parentMethod1, parentMethod2", + "protected, parentMethod1, parentMethod2", + "public, this.parentMethod1, this.parentMethod2", + "protected, this.parentMethod1, this.parentMethod2", + "public, super.parentMethod1, super.parentMethod2", + "protected, super.parentMethod1, super.parentMethod2" + }) + @ParameterizedTest( + name = + "{displayName}: method scopes {0}, method 1 reference {1}, method 2 reference {2}") + @Disabled + public void instanceMethodInvokedWithinNonOverridingSubclass_expectNoViolation( + String scope, String method1Reference, String method2Reference) { + String[] sourceCodes = { + "global virtual class ParentClass {\n" + + String.format(" %s boolean parentMethod1() {\n", scope) + + " return true;\n" + + " }\n" + + String.format(" %s boolean parentMethod2() {\n", scope) + + " return true;\n" + + " }\n" + + "}\n", + "global virtual class ChildClass extends ParentClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean childMethod() {\n" + + String.format(" return %s();\n", method1Reference) + + " }\n" + + "}\n", + "global class GrandchildClass extends ChildClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean grandchildMethod() {\n" + + String.format(" return %s();\n", method2Reference) + + " }\n" + + "}\n" + }; + assertNoViolations(sourceCodes, 2); + } + + /** + * If a subclass inherits a method from a parent without overriding it, and that method is + * called on an instance of the subclass, then the parent method counts as used. + */ + @CsvSource({ + // Both the child and grandchild classes, for each of the two + // relevant visibility scopes. + "public, ChildClass", + "protected, ChildClass", + "public, GrandchildClass", + "protected, GrandchildClass", + }) + @ParameterizedTest(name = "{displayName}: method scope {0}, invoking class {1}") + @Disabled + public void instanceMethodInvokedOnInstanceOfNonOverridingSubclass_expectNoViolation( + String scope, String subclass) { + String[] sourceCodes = { + "global virtual class ParentClass {\n" + + String.format(" %s boolean parentMethod() {\n", scope) + + " return true;\n" + + " }\n" + + "}\n", + "global virtual class ChildClass extends ParentClass {\n}\n", + "global class GrandchildClass extends ChildClass {\n}\n", + "global class InvokerClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + String.format(" public boolean invokerMethod(%s instance) {\n", subclass) + + " return instance.parentMethod();\n" + + " }\n" + + "}\n" + }; + assertNoViolations(sourceCodes, 1); + } + + /** + * If a subclass overrides an inherited method, but calls the `super` version of that method, + * the parent method counts as used. + */ + @ValueSource(strings = {"public", "protected"}) + @ParameterizedTest(name = "{displayName}: method scope {0}") + @Disabled + public void instanceMethodInvokedViaSuperInOverridingSubclass_expectNoViolation(String scope) { + String[] sourceCodes = { + "global virtual class ParentClass {\n" + + String.format(" %s virtual boolean parentMethod1() {\n", scope) + + " return true;\n" + + " }\n" + + String.format(" %s virtual boolean parentMethod2() {\n", scope) + + " return true;\n" + + " }\n" + + "}\n", + "global virtual class ChildClass extends ParentClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public override boolean parentMethod1() {\n" + + " return false;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean childMethod() {\n" + // Invoke the super version instead of the override version. + + " return super.parentMethod1();\n" + + " }\n" + + "}\n", + "global class GrandchildClass extends ChildClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public override boolean parentMethod2() {\n" + + " return false;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean grandchildMethod() {\n" + // Invoke the super version instead of the override version. + + " return super.parentMethod2();\n" + + " }\n" + + "}\n" + }; + assertNoViolations(sourceCodes, 2); + } + + /** + * If a subclass overrides an inherited method and calls its own version of the method rather + * than the `super`, then the original parent method doesn't count as used. + */ + @CsvSource({ + // Implicit/explicit `this`, for each of the two relevant + // visibility scopes. + "public, this.parentMethod", + "protected, this.parentMethod", + "public, parentMethod", + "protected, parentMethod", + }) + @ParameterizedTest(name = "{displayName}: method scope {0}, method reference {1}") + @Disabled + public void overrideMethodInSubclass_expectViolation(String scope, String invocation) { + String[] sourceCodes = { + "global virtual class ParentClass {\n" + + String.format(" %s virtual boolean parentMethod() {\n", scope) + + " return true;\n" + + " }\n" + + "}\n", + "global virtual class ChildClass extends ParentClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public override boolean parentMethod() {\n" + + " return false;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean invokerMethod() {\n" + // This calls the overrider, not the original method, so it shouldn't count. + + String.format(" return %s();\n", invocation) + + " }\n" + + "}\n", + "global class GrandchildClass extends ChildClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean invokerMethod() {\n" + // This calls the overrider, not the original method, so it shouldn't count. + + String.format(" return %s();\n", invocation) + + " }\n" + + "}\n" + }; + assertViolations( + sourceCodes, + v -> { + assertEquals("parentMethod", v.getSourceVertexName()); + assertEquals("ParentClass", v.getSourceVertex().getDefiningType()); + }); + } + + /** + * If an overridden method is invoked on an instance of a subclass, then the overridden version + * of the method on the parent doesn't count as used. + */ + @CsvSource({ + // Child/Grandchild class, for each of the two relevant visibility scopes. + "public, ChildClass", + "protected, ChildClass", + "public, GrandchildClass", + "protected, GrandchildClass", + }) + @ParameterizedTest(name = "{displayName}: method visibility {0}, instance type {1}") + @Disabled + public void overrideMethodOnInstanceOfSubclass_expectViolation( + String scope, String instanceType) { + String[] sourceCodes = { + "global virtual class ParentClass {\n" + + String.format(" %s virtual boolean parentMethod() {\n", scope) + + " return true;\n" + + " }\n" + + "}\n", + "global virtual class ChildClass extends ParentClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public override boolean parentMethod() {\n" + + " return false;\n" + + " }\n" + + "}\n", + "global class GrandchildClass extends ChildClass {\n" + "}\n", + "global class InvokerClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + String.format( + " public boolean invokeMethod(%s instance) {\n", instanceType) + + " return instance.parentMethod();\n" + + " }\n" + + "}\n" + }; + assertViolations( + sourceCodes, + v -> { + assertEquals("parentMethod", v.getSourceVertexName()); + assertEquals("ParentClass", v.getSourceVertex().getDefiningType()); + }); + } + + /** + * If a superclass internally calls an instance method, that counts as invoking all overriding + * versions of that method on subclasses. Reasoning: This is commonly done with abstract classes + * especially, but using elsewhere isn't unheard of. + */ + @CsvSource({ + // All four combinations of implicit/explicit `this` on each method, for each parent type, + // for each of the two relevant visibility scopes. + "public, VirtualParent, 'this.parentMethod1() && this.parentMethod2()", + "protected, VirtualParent, 'this.parentMethod1() && this.parentMethod2()", + "public, VirtualParent, 'this.parentMethod1() && parentMethod2()", + "protected, VirtualParent, 'this.parentMethod1() && parentMethod2()", + "public, VirtualParent, 'this.parentMethod1() && this.parentMethod2()", + "protected, VirtualParent, 'this.parentMethod1() && this.parentMethod2()", + "public, VirtualParent, 'parentMethod1() && parentMethod2()", + "protected, VirtualParent, 'parentMethod1() && parentMethod2()", + "public, AbstractParent, 'this.parentMethod1() && this.parentMethod2()", + "protected, AbstractParent, 'this.parentMethod1() && this.parentMethod2()", + "public, AbstractParent, 'this.parentMethod1() && parentMethod2()", + "protected, AbstractParent, 'this.parentMethod1() && parentMethod2()", + "public, AbstractParent, 'this.parentMethod1() && this.parentMethod2()", + "protected, AbstractParent, 'this.parentMethod1() && this.parentMethod2()", + "public, AbstractParent, 'parentMethod1() && parentMethod2()", + "protected, AbstractParent, 'parentMethod1() && parentMethod2()", + }) + @ParameterizedTest(name = "{displayName}: Method scope {0}; Parent class {1}; invocation {2}") + @Disabled + public void invocationOfOverriddenInstanceMethodInSuperclass_expectNoViolation( + String scope, String parentClass, String invocation) { + String[] sourceCodes = { + // Have a virtual class that defines a set of methods. + "global virtual class VirtualParent {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + // The super method needs to be at least as visible as its override, + // or else the code won't compile. + + String.format(" %s virtual boolean parentMethod1() {\n", scope) + + " return true;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + // The super method needs to be at least as visible as its override, + // or else the code won't compile. + + String.format(" %s virtual boolean parentMethod2() {\n", scope) + + " return true;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean invokerMethod() {\n" + // Invoke both virtual methods. + + String.format(" return %s;\n", invocation) + + " }\n" + + "}\n", + // Have an abstract class that defines the same set of methods. + "global abstract class AbstractParent {\n" + // The super methods need to be at least as visible as their overrides, + // or else the code won't compile. + + String.format(" %s abstract boolean parentMethod1();\n", scope) + + String.format(" %s abstract boolean parentMethod2();\n", scope) + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean invokerMethod() {\n" + // Invoke both abstract methods. + + String.format(" return %s() && %s();\n", invocation) + + " }\n" + + "}\n", + // Have a child class that extends one of the two available parents. Make it abstract + // to guarantee compilation in all test cases. + String.format("global abstract class ChildClass extends %s {\n", parentClass) + // The child class overrides one of the parent methods. + + String.format(" %s override boolean parentMethod1() {\n", scope) + + " return false;\n" + + " }\n" + + "}\n", + "global class GrandchildClass extends ChildClass {\n" + // The grandchild class overrides the other. + + String.format(" %s override boolean parentMethod2() {\n", scope) + + " return false;\n" + + " }\n" + + "}\n" + }; + assertNoViolations(sourceCodes, 2); + } + + /** + * Calling an instance method on a parent class also counts as calling all overrides of that + * method on subclasses. Reasoning: Particularly with abstract classes, it's frequent for + * something typed as a parent class to actually be an instance of the child class. + */ + @ValueSource(strings = {"InterfaceParent", "VirtualParent", "AbstractParent"}) + @ParameterizedTest(name = "{displayName}: Parent class {0}") + @Disabled + public void externalInvocationOfOverriddenMethodOnSuperclass_expectNoViolation( + String parentClass) { + String[] sourceCodes = { + // Have an interface that declares some methods. + "global interface InterfaceParent {\n" + + " public boolean inheritedMethod1();\n" + + " public boolean inheritedMethod2();\n" + + "}\n", + // Have a virtual class that declares the same methods. + "global virtual class VirtualParent {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public virtual boolean inheritedMethod1() {\n" + + " return true;\n\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public virtual boolean inheritedMethod2() {\n" + + " return true;\n" + + " }\n" + + "}\n", + // have an abstract class that declares the same methods. + "global abstract class AbstractParent {\n" + + " public abstract boolean inheritedMethod1();\n" + + " public abstract boolean inheritedMethod2();\n" + + "}\n", + // Have a child class that extends a specified parent class. Make it abstract + // to guarantee compilation. + String.format("global abstract class ChildClass extends %s {\n", parentClass) + // Have the child class extend one of the parent methods. + + " public override boolean inheritedMethod1() {\n" + + " return false;\n" + + " }\n" + + "}\n", + // Have a grandchild class that extends the child class. + "global class GrandchildClass extends ChildClass {\n" + // Have the grandchild class extend the other parent method. + + " public override boolean inheritedMethod2() {\n" + + " return false;\n" + + " }\n" + + "}\n", + // Have an unrelated class that uses an instance of a superclass to invoke a method. + "global class InvokerClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + String.format(" public boolean doInvocation(%s instance) {\n", parentClass) + // Invoke both methods on the instance. + + " return instance.inheritedMethod1() && instance.inheritedMethod2();\n" + + " }\n" + + "}\n" + }; + assertNoViolations(sourceCodes, 2); + } + + /* =============== SECTION 3: CONSTRUCTOR METHODS =============== */ + + /** + * If subclass's constructor calls a `super` constructor, the relevant parent constructor counts + * as used. (Note: Tests for both explicitly-declared 0-arity and 1-arity constructors.) + */ + // TODO: Enable subsequent tests as we implement functionality. + @CsvSource({ + // "public, (), ()", + "protected, (), ()", + // "public, (boolean b), (b)", + "protected, (boolean b), (b)" + }) + @ParameterizedTest(name = "{displayName}: scope {0}; signature{1}") + public void constructorInvokedViaSuperInSubclass_expectNoViolation( + String scope, String paramTypes, String invocationArgs) { + String[] sourceCodes = { + "global virtual class ParentClass {\n" + + String.format(" %s ParentClass%s {\n", scope, paramTypes) + + " }\n" + + "}\n", + "global class ChildClass extends ParentClass {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + String.format(" public ChildClass%s {\n", paramTypes) + + String.format(" super%s;\n", invocationArgs) + + " }\n" + + "}\n" + }; + assertNoViolations(sourceCodes, 1); + } + + /** + * A class's constructor is only available to its immediate children. If a grandchild class + * calls `super()` in its constructor, that refers to the child class, not the parent class. + * (Note: Tests for both explicitly-declared 0-arity and 1-arity constructors.) + */ + // TODO: Enable subsequent tests as we implement functionality. + @CsvSource({ + // "public, (), ()", + "protected, (), ()", + // "public, (boolean b), (b)", + "protected, (boolean b), (b)" + }) + @ParameterizedTest(name = "{displayName}: scope {0}; signature {1}") + public void superConstructorInvokedInGrandchild_expectViolation( + String scope, String paramTypes, String invocationArgs) { + String[] sourceCodes = { + "global virtual class ParentClass {\n" + // Declare a constructor for the parent class. + + String.format(" %s ParentClass%s {\n", scope, paramTypes) + + " }\n" + + "}\n", + "global virtual class ChildClass extends ParentClass {" + // Give the child class a constructor with the same signature, but + // have it do nothing in particular. + + String.format(" %s ChildClass%s {\n", scope, paramTypes) + + " }\n" + + "}\n", + "global class GrandchildClass extends ChildClass {\n" + // Give the grandchild class a constructor with the same signature, and + // have it call the super method. + // Annotate it so it is skipped. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + String.format(" %s GrandchildClass%s {\n", scope, paramTypes) + + String.format(" super%s;\n", invocationArgs) + + " }\n" + + "}\n" + }; + Consumer assertion = + v -> { + assertEquals("", v.getSourceVertexName()); + assertEquals("ParentClass", v.getSourceDefiningType()); + }; + assertViolations(sourceCodes, assertion); + } +} diff --git a/sfge/src/test/java/com/salesforce/rules/unusedmethod/InnerClassCallsTest.java b/sfge/src/test/java/com/salesforce/rules/unusedmethod/InnerClassCallsTest.java new file mode 100644 index 000000000..21be40f36 --- /dev/null +++ b/sfge/src/test/java/com/salesforce/rules/unusedmethod/InnerClassCallsTest.java @@ -0,0 +1,230 @@ +package com.salesforce.rules.unusedmethod; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * A set of tests involving methods being called on and/or by inner classes. These tests are + * important because inner classes can be referenced in a variety of ways by their outer/sibling + * classes. + */ +public class InnerClassCallsTest extends BaseUnusedMethodTest { + + /* =============== SECTION 1: STATIC METHODS =============== */ + // Inner classes can't have static methods, but they can implicitly + // invoke their outer class's static methods in the same way the outer + // class can. + + /** + * If an inner class calls its outer class's static methods, those static methods count as used. + */ + // (NOTE: No need for a `protected` case, since methods can't be both + // `protected` and `static`.) + @CsvSource({ + "public, method1", // Invocation with implicit type reference + "private, method1", // Invocation with implicit type reference + "public, MyClass.method1", // Invocation with explicit class reference + "private, MyClass.method1" // Invocation with explicit class reference + }) + @ParameterizedTest(name = "{displayName}: scope {0}, invocation {1}") + @Disabled + public void staticMethodCalledFromInnerInstance_expectNoViolation( + String scope, String methodCall) { + String sourceCode = + "global class MyClass {\n" + + String.format(" %s static boolean method1() {\n", scope) + + " return true;\n" + + " }\n" + + " global class InnerClass1 {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean method2() {\n" + + String.format(" return %s();\n", methodCall) + + " }\n" + + " }\n" + + "}\n"; + assertNoViolations(sourceCode, 1); + } + + /* =============== SECTION 2: INSTANCE METHODS =============== */ + + /** + * If a class has two inner classes, and one inner class's instance methods are invoked by + * another inner class, then those methods count as used. Specific case: Instance provided as + * method parameter. + */ + @ValueSource(strings = {"MyClass.MyInner1", "MyInner1"}) + @CsvSource({ + "public, MyClass.MyInner1", + "protected, MyClass.MyInner1", + "private, MyClass.MyInner1", + "public, MyInner1", + "protected, MyInner1", + "private, MyInner1" + }) + @ParameterizedTest(name = "{displayName}: method scope {0}, param type {1}") + @Disabled + public void innerInstanceMethodCalledFromSiblingViaParameter_expectNoViolation( + String scope, String paramType) { + String sourceCode = + "global class MyClass {\n" + + " global class MyInner1 {\n" + + String.format(" %s boolean innerMethod1() {\n", scope) + + " return true;\n" + + " }\n" + + " }\n" + + " global class MyInner2 {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + String.format( + " public boolean innerMethod2(%s instance) {\n", paramType) + + " return instance.innerMethod1();\n" + + " }\n" + + " }\n" + + "}\n"; + assertNoViolations(sourceCode, 1); + } + + /** + * If a class has two inner classes, and one inner class's instance methods are invoked by + * another inner class, then those methods count as used. Specific case: Instance available as + * property of invoking inner class. + */ + @CsvSource({ + // The four possible combinations of implicit/explicit outer type reference and + // implicit/explicit `this`, for all three visibility scopes + "public, MyClass.MyInner1, this.instance", + "protected, MyClass.MyInner1, this.instance", + "private, MyClass.MyInner1, this.instance", + "public, MyClass.MyInner1, instance", + "protected, MyClass.MyInner1, instance", + "private, MyClass.MyInner1, instance", + "public, MyInner1, this.instance", + "protected, MyInner1, this.instance", + "private, MyInner1, this.instance", + "public, MyInner1, instance", + "protected, MyInner1, instance", + "private, MyInner1, instance" + }) + @ParameterizedTest(name = "{displayName}: Method scope {0}; Declaration {1}; Reference {2}") + @Disabled + public void innerInstanceMethodCalledFromSiblingViaOwnProperty_expectNoViolation( + String scope, String propType, String propRef) { + String sourceCode = + "global class MyClass {\n" + + " global class MyInner1 {\n" + + String.format(" %s boolean innerMethod1() {\n", scope) + + " return true;\n" + + " }\n" + + " }\n" + + " global class MyInner2() {\n" + + String.format(" public %s instance;\n", propType) + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean innerMethod2 {\n" + + String.format(" return %s.innerMethod1();\n", propRef) + + " }\n" + + " }\n" + + "}\n"; + assertNoViolations(sourceCode, 1); + } + + /** + * If a class has two inner classes, and one inner class's instance methods are invoked by + * another inner class, then those methods count as used. Specific case: Instance available as + * property of outer class. + */ + @CsvSource({ + // Two options for implicit/explicit outer type reference for an instance property, + // for each of the three visibility scopes. + "public, MyClass.MyInner1, outer.outerProp", + "protected, MyClass.MyInner1, outer.outerProp", + "private, MyClass.MyInner1, outer.outerProp", + "public, MyInner1, outer.outerProp", + "protected, MyInner1, outer.outerProp", + "private, MyInner1, outer.outerProp", + // Four combinations of implicit/explicit outer class references, + // for each of the three visibility scopes. + "public, static MyClass.MyInner1, outerProp", + "protected, static MyClass.MyInner1, outerProp", + "private, static MyClass.MyInner1, outerProp", + "public, static MyClass.MyInner1, MyClass.outerProp", + "protected, static MyClass.MyInner1, MyClass.outerProp", + "private, static MyClass.MyInner1, MyClass.outerProp", + "public, static MyInner1, outerProp", + "protected, static MyInner1, outerProp", + "private, static MyInner1, outerProp", + "public, static MyInner1, MyClass.outerProp", + "protected, static MyInner1, MyClass.outerProp", + "private, static MyInner1, MyClass.outerProp" + }) + @ParameterizedTest(name = "{displayName}: Method scope {0}; Declaration {1}; Reference {2}") + @Disabled + public void innerInstanceMethodCalledFromSiblingViaOuterProperty_expectNoViolation( + String scope, String propType, String propRef) { + String sourceCode = + "global class MyClass {\n" + + String.format(" public %s outerProp", propType) + + " global class MyInner1 {\n" + + String.format(" %s boolean innerMethod1() {\n", scope) + + " return true;\n" + + " }\n" + + " }\n" + + " global class MyInner2 {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + // Give this method a parameter for some of the tests. + + " public boolean innerMethod2(MyClass outer) {\n" + + String.format(" return %s.innerMethod1();\n", propRef) + + " }\n" + + " }\n" + + "}\n"; + assertNoViolations(sourceCode, 1); + } + + /* =============== SECTION 3: CONSTRUCTOR METHODS =============== */ + + /** + * If a class has two inner classes, and one inner class's constructor is invoked by another + * inner class, then that constructor counts as used. + */ + @CsvSource({ + // Four combinations of explicit/implicit outer class reference between variable declaration + // and constructor, for each of the three visibility scopes. + "public, MyClass.MyInner1, MyClass.MyInner1", + "protected, MyClass.MyInner1, MyClass.MyInner1", + "private, MyClass.MyInner1, MyClass.MyInner1", + "public, MyClass.MyInner1, MyInner1", + "protected, MyClass.MyInner1, MyInner1", + "private, MyClass.MyInner1, MyInner1", + "public, MyInner1, MyClass.MyInner1", + "protected, MyInner1, MyClass.MyInner1", + "private, MyInner1, MyClass.MyInner1", + "public, MyInner1, MyInner1", + "protected, MyInner1, MyInner1", + "private, MyInner1, MyInner1", + }) + @ParameterizedTest(name = "{displayName}: Method scope {0}; Var type {1}; Constructor {2}") + @Disabled + public void innerConstructorCalledFromSibling_expectNoViolation( + String scope, String varType, String constructor) { + String sourceCode = + "global class MyClass {\n" + + " global class MyInner1 {\n" + + String.format(" %s MyInner1(boolean b) {\n", scope) + + " }\n" + + " }\n" + + " global class MyInner2 {\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public MyInner2() {\n" + + String.format( + " %s instance = new %s(true);\n", varType, constructor) + + " }\n" + + " }\n" + + "}\n"; + assertNoViolations(sourceCode, 1); + } +} diff --git a/sfge/src/test/java/com/salesforce/rules/unusedmethod/InternalCallsTest.java b/sfge/src/test/java/com/salesforce/rules/unusedmethod/InternalCallsTest.java new file mode 100644 index 000000000..11a49f8ba --- /dev/null +++ b/sfge/src/test/java/com/salesforce/rules/unusedmethod/InternalCallsTest.java @@ -0,0 +1,130 @@ +package com.salesforce.rules.unusedmethod; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** A set of tests for methods that are called by the class that defines them. */ +public class InternalCallsTest extends BaseUnusedMethodTest { + + /* =============== SECTION 1: STATIC METHODS =============== */ + + /** + * If a class has static methods that call its other static methods, the called methods count as + * used. + */ + // (NOTE: No need for a `protected` case, since methods can't be both + // `protected` and `static`.) + @CsvSource({ + "public, method1", // Invocation with implicit type reference + "private, method1", // Invocation with implicit type reference + "public, this.method1", // Invocation with explicit `this` reference + "private, this.method1", // Invocation with explicit `this` reference + "public, MyClass.method1", // Invocation with explicit class reference + "private, MyClass.method1" // Invocation with explicit class reference + }) + @ParameterizedTest(name = "{displayName}: scope {0}, invocation {1}") + @Disabled + public void staticMethodCalledFromOwnStatic_expectNoViolation(String scope, String methodCall) { + String sourceCode = + "global class MyClass {\n" + + String.format(" %s static boolean method1() {\n", scope) + + " return true;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public static boolean method2() {\n" + + String.format(" return %s();\n", methodCall) + + " }\n" + + "}\n"; + assertNoViolations(sourceCode, 1); + } + + /** + * If a class has instance methods that call its static methods, those static methods count as + * used. + */ + // (NOTE: No need for a `protected` case, since methods can't be both + // `protected` and `static`.) + @CsvSource({ + "public, method1", // Invocation with implicit type reference + "private, method1", // Invocation with implicit type reference + "public, MyClass.method1", // Invocation with explicit class reference + "private, MyClass.method1" // Invocation with explicit class reference + }) + @ParameterizedTest(name = "{displayName}: method scope {0}, invocation {1}") + @Disabled + public void staticMethodCalledFromOwnInstanceMethod_expectNoViolation( + String scope, String methodCall) { + String sourceCode = + "global class MyClass {\n" + + String.format(" %s static boolean method1() {\n", scope) + + " return true;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean method2() {\n" + + String.format(" return %s();\n", methodCall) + + " }\n" + + "}\n"; + assertNoViolations(sourceCode, 1); + } + + /* =============== SECTION 2: INSTANCE METHODS =============== */ + + /** + * If a class's instance methods call its other instance methods, the called instance methods + * count as used. + */ + // TODO: Enable subsequent tests as we implement functionality. + @CsvSource({ + // "public, method1", // Invocation with implicit type reference + // "protected, method1", // Invocation with implicit type reference + "private, method1", // Invocation with implicit type reference + // "public, this.method1", // Invocation with explicit this reference + // "protected, this.method1", // Invocation with explicit this reference + "private, this.method1" // Invocation with explicit this reference + }) + @ParameterizedTest(name = "{displayName}: scope {0}, invocation {1}") + public void instanceMethodInternallyCalled_expectNoViolation(String scope, String methodCall) { + String sourceCode = + "global class MyClass {\n" + + String.format(" %s boolean method1() {\n", scope) + + " return true;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean method2() {\n" + + String.format(" return %s();\n", methodCall) + + " }\n" + + "}\n"; + + assertNoViolations(sourceCode, 1); + } + + /* =============== SECTION 3: CONSTRUCTOR METHODS =============== */ + + /** If a class internally calls its own constructor, that constructor counts as used. */ + // TODO: Enable subsequent tests as we implement functionality. + @ValueSource( + strings = { + // "public", + "protected", + "private" + }) + @ParameterizedTest(name = "{displayName}: scope {0}") + public void constructorInternallyCalled_expectNoViolation(String scope) { + String sourceCode = + "global class MyClass {\n" + + String.format(" %s MyClass(boolean b, boolean b2) {\n", scope) + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public MyClass(boolean b) {\n" + + " this(b, true);\n" + + " }\n" + + "}\n"; + assertNoViolations(sourceCode, 1); + } +} diff --git a/sfge/src/test/java/com/salesforce/rules/unusedmethod/OverloadsTest.java b/sfge/src/test/java/com/salesforce/rules/unusedmethod/OverloadsTest.java new file mode 100644 index 000000000..01029edfa --- /dev/null +++ b/sfge/src/test/java/com/salesforce/rules/unusedmethod/OverloadsTest.java @@ -0,0 +1,370 @@ +package com.salesforce.rules.unusedmethod; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.salesforce.graph.vertex.MethodVertex; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** Tests for distinguishing between different overloads of the same method. */ +public class OverloadsTest extends BaseUnusedMethodTest { + + /* =============== SECTION 1: INSTANCE METHODS =============== */ + /** + * If there's different overloads of an instance method, then only the ones that are actually + * invoked count as used. Specific case: Methods with different arities. + */ + // TODO: Enable subsequent tests as we implement functionality. + @CsvSource({ + // Provide the arity of the *other* method, since that's the one that is uncalled. + // One set per method, per visibility scope. + // "public, overloadedMethod(), 1", + // "protected, overloadedMethod(), 1", + "private, overloadedMethod(), 1", + // "public, overloadedMethod(false), 0", + // "protected, overloadedMethod(false), 0", + "private, overloadedMethod(false), 0" + }) + @ParameterizedTest(name = "{displayName}: {0} {1}") + public void callInstanceMethodWithDifferentArityOverloads_expectViolation( + String scope, String invocation, int arity) { + String sourceCode = + "global class MyClass {\n" + + String.format(" %s boolean overloadedMethod() {\n", scope) + + " return true;\n" + + " }\n" + + String.format(" %s boolean overloadedMethod(boolean b) {\n", scope) + + " return b;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean methodInvoker() {\n" + + String.format(" return %s;\n", invocation) + + " }\n" + + "}\n"; + assertViolations( + sourceCode, + v -> { + assertEquals(v.getSourceVertexName(), "overloadedMethod"); + assertEquals(((MethodVertex) v.getSourceVertex()).getArity(), arity); + }); + } + + /** + * If there's different overloads of an instance method, then only the ones that are actually + * invoked count as used. Specific case: Methods with the same arity, but different signatures. + */ + @CsvSource({ + // Specify the beginning line of the overload that WASN'T called. + // One test per method, per argument source. + // Argument sources are: + // - Literal values + "overloadedMethod(42), 11", + "overloadedMethod(true), 8", + // - Method parameters (and their instance properties/methods) + "overloadedMethod(iParam), 11", + "overloadedMethod(bParam), 8", + "overloadedMethod(phcParam.iExternalInstanceProp), 11", + "overloadedMethod(phcParam.getIntegerProp()), 11", + "overloadedMethod(phcParam.bExternalInstanceProp), 8", + "overloadedMethod(phcParam.getBooleanProp()), 8", + // - Variables (and their instance properties/methods) + "overloadedMethod(iVar), 11", + "overloadedMethod(bVar), 8", + "overloadedMethod(phcVar.iExternalInstanceProp), 11", + "overloadedMethod(phcVar.getIntegerProp()), 11", + "overloadedMethod(phcVar.bExternalInstanceProp), 8", + "overloadedMethod(phcVar.getBooleanProp()), 8", + // - Internal instance method returns (and their instance properties/methods) + "overloadedMethod(intReturner()), 11", + "overloadedMethod(this.intReturner()), 11", + "overloadedMethod(boolReturner()), 8", + "overloadedMethod(this.boolReturner()), 8", + "overloadedMethod(phcReturner().iExternalInstanceProp), 11", + "overloadedMethod(this.phcReturner().iExternalInstanceProp), 11", + "overloadedMethod(phcReturner().getIntegerProp()), 11", + "overloadedMethod(this.phcReturner().getIntegerProp()), 11", + "overloadedMethod(phcReturner().bExternalInstanceProp), 8", + "overloadedMethod(this.phcReturner().bExternalInstanceProp), 8", + "overloadedMethod(phcReturner().getBooleanProp()), 8", + "overloadedMethod(this.phcReturner().getBooleanProp()), 8", + // - Internal instance properties (and their instance properties/methods) + "overloadedMethod(iInstanceProp), 11", + "overloadedMethod(this.iInstanceProp), 11", + "overloadedMethod(bInstanceProp), 8", + "overloadedMethod(this.bInstanceProp), 8", + "overloadedMethod(phcInstanceProp.iExternalInstanceProp), 11", + "overloadedMethod(this.phcInstanceProp.iExternalInstanceProp), 11", + "overloadedMethod(phcInstanceProp.getIntegerProp()), 11", + "overloadedMethod(this.phcInstanceProp.getIntegerProp()), 11", + "overloadedMethod(phcInstanceProp.bExternalInstanceProp), 8", + "overloadedMethod(this.phcInstanceProp.bExternalInstanceProp), 8", + "overloadedMethod(phcInstanceProp.getBooleanProp()), 8", + "overloadedMethod(this.phcInstanceProp.getBooleanProp()), 8", + // - Internal static method returns (and their instance properties/methods) + "overloadedMethod(staticIntReturner()), 11", + "overloadedMethod(MethodHostClass.staticIntReturner()), 11", + "overloadedMethod(staticBoolReturner()), 8", + "overloadedMethod(MethodHostClass.staticBoolReturner()), 8", + "overloadedMethod(staticPhcReturner().iExternalInstanceProp), 11", + "overloadedMethod(MethodHostClass.staticPhcReturner().iExternalInstanceProp), 11", + "overloadedMethod(staticPhcReturner().getIntegerProp()), 11", + "overloadedMethod(MethodHostClass.staticPhcReturner().getIntegerProp()), 11", + "overloadedMethod(staticPhcReturner().bExternalInstanceProp), 8", + "overloadedMethod(MethodHostClass.staticPhcReturner().bExternalInstanceProp), 8", + "overloadedMethod(staticPhcReturner().getBooleanProp()), 8", + "overloadedMethod(MethodHostClass.staticPhcReturner().getBooleanProp()), 8", + // - Internal static properties (and their instance properties/methods) + "overloadedMethod(iStaticProp), 11", + "overloadedMethod(MethodHostClass.iStaticProp), 11", + "overloadedMethod(bStaticProp), 8", + "overloadedMethod(MethodHostClass.bStaticProp), 8", + "overloadedMethod(phcStaticProp.iExternalInstanceProp), 11", + "overloadedMethod(MethodHostClass.phcStaticProp.iExternalInstanceProp), 11", + "overloadedMethod(phcStaticProp.getIntegerProp()), 11", + "overloadedMethod(MethodHostClass.phcStaticProp.getIntegerProp()), 11", + "overloadedMethod(phcStaticProp.bExternalInstanceProp), 8", + "overloadedMethod(MethodHostClass.phcStaticProp.bExternalInstanceProp), 8", + "overloadedMethod(phcStaticProp.getBooleanProp()), 8", + "overloadedMethod(MethodHostClass.phcStaticProp.getBooleanProp()), 8", + // - External static instance properties + "overloadedMethod(PropertyHostClass.iExternalStaticProp), 11", + "overloadedMethod(PropertyHostClass.bExternalStaticProp), 8" + }) + @ParameterizedTest(name = "{displayName}: invocation of {0}") + @Disabled + public void callInstanceMethodWithDifferentSignatureOverloads_expectViolation( + String invocation, int uncalledBeginLine) { + String[] sourceCodes = { + "global class MethodHostClass {\n" + + " private static integer iStaticProp = 42;\n" + + " private static boolean bStaticProp = false;\n" + + " private static PropertyHostClass phcStaticProp = new PropertyHostClass();\n" + + " private integer iInstanceProp = 32;\n" + + " private boolean bInstanceProp = true;\n" + + " private PropertyHostClass phcInstanceProp = new PropertyHostClass();\n" + + " private boolean overloadedMethod(Integer i) {\n" + + " return true;\n" + + " }\n" + + " private boolean overloadedMethod(boolean b) {\n" + + " return b;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public integer intReturner() {\n" + + " return 7;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean boolReturner() {\n" + + " return true;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public PropertyHostClass phcReturner() {\n" + + " return new PropertyHostClass();\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public static integer staticIntReturner() {\n" + + " return 42;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public static boolean staticBoolReturner() {\n" + + " return false;\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public static PropertyHostClass staticPhcReturner() {\n" + + " return new PropertyHostClass();\n" + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean methodInvoker(Integer iParam, Boolean bParam, PropertyHostClass phcParam) {\n" + + " Integer iVar = 42;\n" + + " Boolean bVar = true;\n" + + " PropertyHostClass phcVar = new PropertyHostClass();\n" + + String.format(" return %s;\n", invocation) + + " }\n" + + "}\n", + "global class PropertyHostClass {\n" + + " public static integer iExternalStaticProp = 11;\n" + + " public static boolean bExternalStaticProp = false;\n" + + " public integer iExternalInstanceProp = 9;\n" + + " public boolean bExternalInstanceProp = true;\n" + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public integer getIntegerProp() {\n" + + " return iExternalInstanceProp;\n" + + " }\n" + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public boolean getBooleanProp() {\n" + + " return bExternalInstanceProp;\n" + + " }\n" + + "}\n" + }; + assertViolations( + sourceCodes, + v -> { + assertEquals(v.getSourceVertexName(), "overloadedMethod"); + assertEquals(v.getSourceVertex().getBeginLine(), uncalledBeginLine); + }); + } + + /* =============== SECTION 2: CONSTRUCTOR METHODS =============== */ + + /** + * If there's different overloads of a constructor, then only the ones that are actually invoked + * count as used. Specific case: Methods with different arities, invoked via the `new` keyword. + */ + @CsvSource({ + // Use the arity of the constructor that ISN'T being called, + // and have one variant per visibility scope. + "public, 'new MyClass(true)', 2", + "protected, 'new MyClass(true)', 2", + "private, 'new MyClass(true)', 2", + "public, 'new MyClass(true, true)', 1", + "protected, 'new MyClass(true, true)', 1", + "private, 'new MyClass(true, true)', 1" + }) + @ParameterizedTest(name = "{displayName}: {0} constructor {1}") + @Disabled + public void callConstructorViaNewWithDifferentArityOverloads_expectViolation( + String scope, String constructor, int arity) { + String sourceCode = + "global class MyClass {\n" + + String.format(" %s MyClass(boolean b) {\n", scope) + + " }\n" + + String.format(" %s MyClass(boolean b, boolean c) {\n", scope) + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public static void constructorInvocation() {\n" + + String.format(" MyClass mc = %s;\n", constructor) + + " }\n" + + "}\n"; + assertViolations( + sourceCode, + v -> { + assertEquals(v.getSourceVertexName(), ""); + assertEquals(((MethodVertex) v.getSourceVertex()).getArity(), arity); + }); + } + + /** + * If there's different overloads of a constructor, then only the ones that are actually invoked + * count as used. Specific case: Methods with the same arity, but different signatures, invoked + * via the `new` keyword + */ + @CsvSource({ + // Use the arity of the constructor that ISN'T being called. + // One test per constructor, per visibility scope. + "public, new MethodHostClass(42), 4", + "protected, new MethodHostClass(42), 4", + "private, new MethodHostClass(42), 4", + "public, new MethodHostClass(true), 2", + "protected, new MethodHostClass(true), 2", + "private, new MethodHostClass(true), 2" + }) + @ParameterizedTest(name = "{displayName}: {0} constructor {1}") + @Disabled + public void callConstructorViaNewWithDifferentSignatureOverloads_expectViolation( + String scope, String constructor, int beginLine) { + String sourceCode = + "global class MethodHostClass {\n" + + String.format(" %s MethodHostClass(boolean b) {\n", scope) + + " }\n" + + String.format(" %s MethodHostClass(Integer i) {\n", scope) + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public void methodInvoker() {\n" + + String.format(" MethodHostClass mhc = %s;\n", constructor) + + " }\n" + + "}\n"; + assertViolations( + sourceCode, + v -> { + assertEquals("", v.getSourceVertexName()); + assertEquals(beginLine, ((MethodVertex) v.getSourceVertex()).getArity()); + }); + } + + /** + * If there's different overloads of a constructor, then only the ones that are actually invoked + * count as used. Specific case: Methods with different arities, invoked via the `this` keyword. + */ + // TODO: Enable subsequent tests as we implement functionality. + @CsvSource({ + // Use the arity of the constructor that ISN'T being called, + // and have one variant per visibility scope. + // "public, this(true), 2", + "protected, this(true), 2", + "private, this(true), 2", + // "public, 'this(true, true)', 1", + "protected, 'this(true, true)', 1", + "private, 'this(true, true)', 1" + }) + @ParameterizedTest(name = "{displayName}: {0} constructor {1}") + public void callConstructorViaThisWithDifferentArityOverloads_expectViolation( + String scope, String constructor, int arity) { + String sourceCode = + "global class MyClass {\n" + + String.format(" %s MyClass(boolean b) {\n", scope) + + " }\n" + + String.format(" %s MyClass(boolean b, boolean c) {\n", scope) + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */" + + " public MyClass() {\n" + + String.format(" %s;\n", constructor) + + " }\n" + + "}\n"; + assertViolations( + sourceCode, + v -> { + assertEquals("", v.getSourceVertexName()); + assertEquals(arity, ((MethodVertex) v.getSourceVertex()).getArity()); + }); + } + + /** + * If there's different overloads of a constructor, then only the ones that are actually invoked + * count as used. Specific case: Methods with the same arity, but different signatures, invoked + * via the `this` keyword + */ + @CsvSource({ + // Use the arity of the constructor that ISN'T being called. + // One test per constructor, per visibility scope. + "public, this(42), 4", + "protected, this(42), 4", + "private, this(42), 4", + "public, this(true), 2", + "protected, this(true), 2", + "private, this(true), 2" + }) + @ParameterizedTest(name = "{displayName}: {0} constructor {1}") + @Disabled + public void callConstructorViaThisWithDifferentSignatureOverloads_expectViolation( + String scope, String constructor, int beginLine) { + String sourceCode = + "global class MethodHostClass {\n" + + String.format(" %s MethodHostClass(boolean b) {\n", scope) + + " }\n" + + String.format(" %s MethodHostClass(Integer i) {\n", scope) + + " }\n" + // Use the engine directive to prevent this method from tripping the rule. + + " /* sfge-disable-stack UnusedMethodRule */\n" + + " public MethodHostClass() {\n" + + String.format(" %s;", constructor) + + " }\n" + + "}\n"; + assertViolations( + sourceCode, + v -> { + assertEquals("", v.getSourceVertexName()); + assertEquals(beginLine, ((MethodVertex) v.getSourceVertex()).getArity()); + }); + } +} diff --git a/sfge/src/test/java/com/salesforce/rules/unusedmethod/SimpleCasesTest.java b/sfge/src/test/java/com/salesforce/rules/unusedmethod/SimpleCasesTest.java new file mode 100644 index 000000000..2aca69e38 --- /dev/null +++ b/sfge/src/test/java/com/salesforce/rules/unusedmethod/SimpleCasesTest.java @@ -0,0 +1,87 @@ +package com.salesforce.rules.unusedmethod; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.salesforce.graph.vertex.MethodVertex; +import com.salesforce.rules.Violation; +import java.util.function.Consumer; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * A collection of tests for simple positive and negative cases. Comparable to smoke tests. If any + * of these unexpectedly fail, something's deeply wrong. + */ +public class SimpleCasesTest extends BaseUnusedMethodTest { + + /** Obviously unused static/instance methods are unused. */ + // TODO: Enable subsequent tests as we implement functionality. + @ValueSource( + strings = { + // "public static", + // "public", + // "protected", // No need for protected static, since those are mutually exclusive. + // "private static", + "private" + }) + @ParameterizedTest(name = "{displayName}: {0}") + public void outerMethodWithoutInvocation_expectViolation(String methodScope) { + String sourceCode = + "global class MyClass {\n" + + String.format(" %s boolean unusedMethod() {\n", methodScope) + + " return true;\n" + + " }\n" + + "}\n"; + assertViolations(sourceCode, "unusedMethod"); + } + + /** Obviously unused inner class instance methods are unused. */ + // TODO: Enable subsequent tests as we implement functionality. + @ValueSource( + strings = { + /*"public", "protected", */ + "private" + }) + @ParameterizedTest(name = "{displayName}: {0}") + public void innerInstanceMethodWithoutInvocation_expectViolation(String scope) { + String sourceCode = + "global class MyClass {\n" + + " global class MyInnerClass {\n" + + String.format(" %s boolean unusedMethod() {\n", scope) + + " return true;\n" + + " }\n" + + " }\n" + + "}\n"; + assertViolations(sourceCode, "unusedMethod"); + } + + /** + * We want public and protected tests for arity of both 0 and 1, but a private test for only the + * arity 1 constructor, since a private constructor with arity 0 is ineligible. + */ + // TODO: Enable subsequent tests as we implement functionality. + @CsvSource({ + // One test per constructor, per visibility scope. + // "public MyClass(), 0", + "protected MyClass(), 0", + // "public MyClass(boolean b) , 1", + "protected MyClass(boolean b), 1", + "private MyClass(boolean b), 1" + }) + @ParameterizedTest(name = "{displayName}: Declared constructor {0}, arity {1}") + public void declaredConstructorWithoutInvocation_expectViolation( + String declaration, int arity) { + String sourceCode = + "global class MyClass {\n" + + String.format(" %s {\n", declaration) + + " }\n" + + "}\n"; + Consumer assertion = + v -> { + assertEquals("", v.getSourceVertexName()); + assertEquals(arity, ((MethodVertex) v.getSourceVertex()).getArity()); + }; + assertViolations(sourceCode, assertion); + } +} diff --git a/src/Constants.ts b/src/Constants.ts index cd9fbfe03..7ebf46c09 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -31,6 +31,11 @@ export enum ENGINE { SFGE = 'sfge' } +export enum RuleType { + PATHLESS = "pathless", + DFA = "dfa" +} + /** * Main engine types that have more than one variation */ @@ -64,6 +69,7 @@ export const PathlessEngineFilters = [ ENGINE.ESLINT_TYPESCRIPT, ENGINE.PMD, ENGINE.RETIRE_JS, + ENGINE.SFGE, ENGINE.CPD ] diff --git a/src/commands/scanner/run.ts b/src/commands/scanner/run.ts index 153684981..1ac74645c 100644 --- a/src/commands/scanner/run.ts +++ b/src/commands/scanner/run.ts @@ -3,7 +3,6 @@ import {Messages, SfdxError} from '@salesforce/core'; import {LooseObject} from '../../types'; import {PathlessEngineFilters} from '../../Constants'; import {CUSTOM_CONFIG} from '../../Constants'; -import {OUTPUT_FORMAT} from '../../lib/RuleManager'; import {ScannerRunCommand, INTERNAL_ERROR_CODE} from '../../lib/ScannerRunCommand'; import {TYPESCRIPT_ENGINE_OPTIONS} from '../../lib/eslint/TypescriptEslintStrategy'; import untildify = require('untildify'); @@ -14,7 +13,7 @@ Messages.importMessagesDirectory(__dirname); // Load the specific messages for this file. Messages from @salesforce/command, @salesforce/core, // or any library that is using the messages framework can also be loaded this way. -const messages = Messages.loadMessages('@salesforce/sfdx-scanner', 'run'); +const messages = Messages.loadMessages('@salesforce/sfdx-scanner', 'run-pathless'); export default class Run extends ScannerRunCommand { // These determine what's displayed when the --help/-h flag is provided. @@ -29,9 +28,9 @@ export default class Run extends ScannerRunCommand { // This defines the flags accepted by this command. protected static flagsConfig = { - verbose: flags.builtin(), - // BEGIN: Flags consumed by ScannerCommand#buildRuleFilters - // These flags are how you choose which rules you're running. + // Include all common flags from the super class. + ...ScannerRunCommand.flagsConfig, + // BEGIN: Filter-related flags. category: flags.array({ char: 'c', description: messages.getMessage('flags.categoryDescription'), @@ -51,26 +50,16 @@ export default class Run extends ScannerRunCommand { longDescription: messages.getMessage('flags.engineDescriptionLong'), options: [...PathlessEngineFilters] }), - // END: Flags consumed by ScannerCommand#buildRuleFilters - // These flags are how you choose which files you're targeting. + // END: Filter-related flags. + // BEGIN: Targeting-related flags. target: flags.array({ char: 't', description: messages.getMessage('flags.targetDescription'), longDescription: messages.getMessage('flags.targetDescriptionLong'), required: true }), - // These flags modify how the process runs, rather than what it consumes. - format: flags.enum({ - char: 'f', - description: messages.getMessage('flags.formatDescription'), - longDescription: messages.getMessage('flags.formatDescriptionLong'), - options: [OUTPUT_FORMAT.CSV, OUTPUT_FORMAT.HTML, OUTPUT_FORMAT.JSON, OUTPUT_FORMAT.JUNIT, OUTPUT_FORMAT.SARIF, OUTPUT_FORMAT.TABLE, OUTPUT_FORMAT.XML] - }), - outfile: flags.string({ - char: 'o', - description: messages.getMessage('flags.outfileDescription'), - longDescription: messages.getMessage('flags.outfileDescriptionLong') - }), + // END: Targeting-related flags. + // BEGIN: Engine config flags. tsconfig: flags.string({ description: messages.getMessage('flags.tsconfigDescription'), longDescription: messages.getMessage('flags.tsconfigDescriptionLong') @@ -92,27 +81,18 @@ export default class Run extends ScannerRunCommand { messageOverride: messages.getMessage('flags.envParamDeprecationWarning') } }), - 'severity-threshold': flags.integer({ - char: 's', - description: messages.getMessage('flags.stDescription'), - longDescription: messages.getMessage('flags.stDescriptionLong'), - exclusive: ['json'], - min: 1, - max: 3 - }), - "normalize-severity": flags.boolean({ - description: messages.getMessage('flags.nsDescription'), - longDescription: messages.getMessage('flags.nsDescriptionLong') - }), + // END: Engine config flags. + // BEGIN: Flags related to results processing. "verbose-violations": flags.boolean({ description: messages.getMessage('flags.verboseViolationsDescription'), longDescription: messages.getMessage('flags.verboseViolationsDescriptionLong') - }), + }) + // END: Flags related to results processing. }; - protected validateCommandFlags(): Promise { + protected validateVariantFlags(): Promise { if (this.flags.tsconfig && this.flags.eslintconfig) { - throw SfdxError.create('@salesforce/sfdx-scanner', 'run', 'validations.tsConfigEslintConfigExclusive', []); + throw SfdxError.create('@salesforce/sfdx-scanner', 'run-pathless', 'validations.tsConfigEslintConfigExclusive', []); } if ((this.flags.pmdconfig || this.flags.eslintconfig) && (this.flags.category || this.flags.ruleset)) { @@ -121,17 +101,16 @@ export default class Run extends ScannerRunCommand { // None of the pathless engines support method-level targeting, so attempting to use it should result in an error. for (const target of (this.flags.target as string[])) { if (target.indexOf('#') > -1) { - throw SfdxError.create('@salesforce/sfdx-scanner', 'run', 'validations.methodLevelTargetingDisallowed', [target]); + throw SfdxError.create('@salesforce/sfdx-scanner', 'run-pathless', 'validations.methodLevelTargetingDisallowed', [target]); } } return Promise.resolve(); } /** - * Gather a map of options that will be passed to the RuleManager without validation. + * Gather engine options that are unique to each sub-variant. */ - protected gatherEngineOptions(): Map { - const options: Map = new Map(); + protected mergeVariantEngineOptions(options: Map): void { if (this.flags.tsconfig) { const tsconfig = normalize(untildify(this.flags.tsconfig as string)); options.set(TYPESCRIPT_ENGINE_OPTIONS.TSCONFIG, tsconfig); @@ -164,9 +143,6 @@ export default class Run extends ScannerRunCommand { if (this.flags["verbose-violations"]) { options.set(CUSTOM_CONFIG.VerboseViolations, "true"); } - - - return options; } protected pathBasedEngines(): boolean { diff --git a/src/commands/scanner/run/dfa.ts b/src/commands/scanner/run/dfa.ts index 8f844d70f..7916fa07c 100644 --- a/src/commands/scanner/run/dfa.ts +++ b/src/commands/scanner/run/dfa.ts @@ -1,13 +1,9 @@ -import path = require('path'); import globby = require('globby'); -import normalize = require('normalize-path'); -import untildify = require('untildify'); import {flags} from '@salesforce/command'; import {Messages, SfdxError} from '@salesforce/core'; import {CUSTOM_CONFIG} from '../../../Constants'; import {SfgeConfig} from '../../../types'; import {ScannerRunCommand} from '../../../lib/ScannerRunCommand'; -import {OUTPUT_FORMAT} from '../../../lib/RuleManager'; import {FileHandler} from '../../../lib/util/FileHandler'; // Initialize Messages with the current plugin directory @@ -39,48 +35,18 @@ export default class Dfa extends ScannerRunCommand { // because the command currently supports only a single engine with a single rule. So no such flags are currently // needed. If, at some point, we add additional rules or engines to this command, those flags will need to be added. protected static flagsConfig = { - verbose: flags.builtin(), + // Include all common flags from the super class. + ...ScannerRunCommand.flagsConfig, // BEGIN: Flags for targeting files. + // NOTE: All run commands have a `--target` flag, but they have differing functionalities, + // and therefore different descriptions, so each command defines this flag separately. target: flags.array({ char: 't', description: messages.getMessage('flags.targetDescription'), longDescription: messages.getMessage('flags.targetDescriptionLong'), required: true }), - projectdir: flags.array({ - char: 'p', - description: messages.getMessage('flags.projectdirDescription'), - longDescription: messages.getMessage('flags.projectdirDescriptionLong'), - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - map: d => normalize(untildify(d)), - required: true - }), // END: Flags for targeting files. - // BEGIN: Flags for result processing. - format: flags.enum({ - char: 'f', - description: messages.getMessage('flags.formatDescription'), - longDescription: messages.getMessage('flags.formatDescriptionLong'), - options: [OUTPUT_FORMAT.CSV, OUTPUT_FORMAT.HTML, OUTPUT_FORMAT.JSON, OUTPUT_FORMAT.JUNIT, OUTPUT_FORMAT.SARIF, OUTPUT_FORMAT.TABLE, OUTPUT_FORMAT.XML] - }), - outfile: flags.string({ - char: 'o', - description: messages.getMessage('flags.outfileDescription'), - longDescription: messages.getMessage('flags.outfileDescriptionLong') - }), - 'severity-threshold': flags.integer({ - char: 's', - description: messages.getMessage('flags.sevthresholdDescription'), - longDescription: messages.getMessage('flags.sevthresholdDescriptionLong'), - exclusive: ['json'], - min: 1, - max: 3 - }), - 'normalize-severity': flags.boolean({ - description: messages.getMessage('flags.normalizesevDescription'), - longDescription: messages.getMessage('flags.normalizesevDescriptionLong') - }), - // END: Flags for result processing. // BEGIN: Config-overrideable engine flags. 'rule-thread-count': flags.integer({ description: messages.getMessage('flags.rulethreadcountDescription'), @@ -106,17 +72,13 @@ export default class Dfa extends ScannerRunCommand { // END: Config-overrideable engine flags. }; - protected async validateCommandFlags(): Promise { + protected async validateVariantFlags(): Promise { const fh = new FileHandler(); - // Entries in the projectdir array must be non-glob paths to existing directories. - for (const dir of (this.flags.projectdir as string[])) { - if (globby.hasMagic(dir)) { - throw SfdxError.create('@salesforce/sfdx-scanner', 'run-dfa', 'validations.projectdirCannotBeGlob', []); - } else if (!(await fh.exists(dir))) { - throw SfdxError.create('@salesforce/sfdx-scanner', 'run-dfa', 'validations.projectdirMustExist', []); - } else if (!(await fh.stats(dir)).isDirectory()) { - throw SfdxError.create('@salesforce/sfdx-scanner', 'run-dfa', 'validations.projectdirMustBeDir', []); - } + // The superclass will validate that --projectdir is well-formed, + // but doesn't require that the flag actually be present. + // So we should make sure it exists here. + if (!this.flags.projectdir || (this.flags.projectdir as string[]).length === 0) { + throw SfdxError.create('@salesforce/sfdx-scanner', 'run-dfa', 'validations.projectdirIsRequired', []); } // Entries in the target array may specify methods, but only if the entry is neither a directory nor a glob. for (const target of (this.flags.target as string[])) { @@ -134,15 +96,14 @@ export default class Dfa extends ScannerRunCommand { } /** - * Gather a map of options that will be passed to the RuleManager without validation. - * @private + * Gather engine options that are unique to each sub-variant. + * @protected + * @override */ - protected gatherEngineOptions(): Map { - const options: Map = new Map(); - const sfgeConfig: SfgeConfig = { - projectDirs: (this.flags.projectdir as string[]).map(p => path.resolve(p)) - }; - + protected mergeVariantEngineOptions(options: Map): void { + // The flags have been validated by now, meaning --projectdir is confirmed as present, + // meaning we can assume the existence of a GraphEngine config in the common options. + const sfgeConfig: SfgeConfig = JSON.parse(options.get(CUSTOM_CONFIG.SfgeConfig)) as SfgeConfig; if (this.flags['rule-thread-count'] != null) { sfgeConfig.ruleThreadCount = this.flags['rule-thread-count'] as number; } @@ -154,7 +115,6 @@ export default class Dfa extends ScannerRunCommand { } sfgeConfig.ruleDisableWarningViolation = this.getBooleanEngineOption(RULE_DISABLE_WARNING_VIOLATION_FLAG); options.set(CUSTOM_CONFIG.SfgeConfig, JSON.stringify(sfgeConfig)); - return options; } /** diff --git a/src/ioc.config.ts b/src/ioc.config.ts index 5b612b7e9..02d9c2c5d 100644 --- a/src/ioc.config.ts +++ b/src/ioc.config.ts @@ -8,7 +8,8 @@ import {LWCEslintEngine} from './lib/eslint/EslintEngine'; import {TypescriptEslintEngine} from './lib/eslint/EslintEngine'; import {CustomEslintEngine} from './lib/eslint/CustomEslintEngine'; import {RetireJsEngine} from './lib/retire-js/RetireJsEngine'; -import {SfgeEngine} from './lib/sfge/SfgeEngine'; +import {SfgeDfaEngine} from './lib/sfge/SfgeDfaEngine'; +import {SfgePathlessEngine} from './lib/sfge/SfgePathlessEngine'; import {CustomPmdEngine, PmdEngine} from './lib/pmd/PmdEngine'; import LocalCatalog from './lib/services/LocalCatalog'; import {Config} from './lib/util/Config'; @@ -40,7 +41,8 @@ export function registerAll(): void { container.registerSingleton(Services.RuleEngine, CustomEslintEngine); container.registerSingleton(Services.RuleEngine, RetireJsEngine); container.registerSingleton(Services.RuleEngine, CpdEngine); - container.registerSingleton(Services.RuleEngine, SfgeEngine); + container.registerSingleton(Services.RuleEngine, SfgeDfaEngine); + container.registerSingleton(Services.RuleEngine, SfgePathlessEngine); container.registerSingleton(Services.RuleCatalog, LocalCatalog); container.registerSingleton(Services.RulePathManager, CustomRulePathManager); } diff --git a/src/lib/DefaultRuleManager.ts b/src/lib/DefaultRuleManager.ts index cef96e01b..c8e4377b4 100644 --- a/src/lib/DefaultRuleManager.ts +++ b/src/lib/DefaultRuleManager.ts @@ -167,7 +167,11 @@ export class DefaultRuleManager implements RuleManager { const dfaEngines = runDescriptorList.filter(descriptor => descriptor.engine.isDfaEngine()).map(descriptor => descriptor.engine.getName()); const pathlessEngines = runDescriptorList.filter(descriptor => !(descriptor.engine.isDfaEngine())).map(descriptor => descriptor.engine.getName()); if (dfaEngines.length > 0 && pathlessEngines.length > 0) { - throw new SfdxError(messages.getMessage(`Pathless engines ${JSON.stringify(pathlessEngines)} cannot be run concurrently with DFA engines ${JSON.stringify(dfaEngines)}`)); + throw SfdxError.create('@salesforce/sfdx-scanner', + 'DefaultRuleManager', + 'error.cannotRunDfaAndNonDfaConcurrently', + [JSON.stringify(dfaEngines), JSON.stringify(pathlessEngines)] + ); } } diff --git a/src/lib/JreSetupManager.ts b/src/lib/JreSetupManager.ts index 78227670c..01bd57367 100644 --- a/src/lib/JreSetupManager.ts +++ b/src/lib/JreSetupManager.ts @@ -3,7 +3,7 @@ import {AsyncCreatable} from '@salesforce/kit'; import {Controller} from '../Controller'; import process = require('process'); -import findJavaHome = require('find-java-home'); +import * as findJavaHome from 'find-java-home'; import childProcess = require('child_process'); import path = require('path'); import {FileHandler} from './util/FileHandler'; @@ -16,9 +16,11 @@ const JAVA_HOME_SYSTEM_VARIABLES = ['JAVA_HOME', 'JRE_HOME', 'JDK_HOME']; // Exported only to be used by tests export class JreSetupManagerDependencies { - autoDetectJavaHome(): Promise { + async autoDetectJavaHome(): Promise { return new Promise((resolve) => { - findJavaHome({allowJre: true}, (err, home) => { + // Returning a void to show that we don't need to handle reject in this case. + // If this gets rejected, we'll simply move on to the next step. + void findJavaHome({allowJre: true}, (err, home) => { resolve(err || (typeof home != 'string') ? null : home); }); }); diff --git a/src/lib/ScannerRunCommand.ts b/src/lib/ScannerRunCommand.ts index 40586d20f..d80f031f6 100644 --- a/src/lib/ScannerRunCommand.ts +++ b/src/lib/ScannerRunCommand.ts @@ -1,11 +1,16 @@ +import {flags} from '@salesforce/command'; import {Messages, SfdxError} from '@salesforce/core'; import {AnyJson} from '@salesforce/ts-types'; import {ScannerCommand} from './ScannerCommand'; -import {RecombinedRuleResults} from '../types'; +import {RecombinedRuleResults, SfgeConfig} from '../types'; import {RunOutputProcessor} from './util/RunOutputProcessor'; import {Controller} from '../Controller'; +import {CUSTOM_CONFIG} from '../Constants'; import {OUTPUT_FORMAT, RunOptions} from './RuleManager'; +import {FileHandler} from './util/FileHandler'; import untildify = require('untildify'); +import globby = require('globby'); +import path = require('path'); import normalize = require('normalize-path'); // Initialize Messages with the current plugin directory @@ -13,12 +18,55 @@ Messages.importMessagesDirectory(__dirname); // Load the specific messages for this file. Messages from @salesforce/command, @salesforce/core, // or any library that is using the messages framework can also be loaded this way. -const messages = Messages.loadMessages('@salesforce/sfdx-scanner', 'run'); +const messages = Messages.loadMessages('@salesforce/sfdx-scanner', 'run-common'); // This code is used for internal errors. export const INTERNAL_ERROR_CODE = 1; export abstract class ScannerRunCommand extends ScannerCommand { + /** + * There are flags that are common to all variants of the run command. We can define those flags + * here to avoid duplicate code. + * @protected + */ + protected static flagsConfig = { + verbose: flags.builtin(), + // BEGIN: Flags related to results processing. + format: flags.enum({ + char: 'f', + description: messages.getMessage('flags.formatDescription'), + longDescription: messages.getMessage('flags.formatDescriptionLong'), + options: [OUTPUT_FORMAT.CSV, OUTPUT_FORMAT.HTML, OUTPUT_FORMAT.JSON, OUTPUT_FORMAT.JUNIT, OUTPUT_FORMAT.SARIF, OUTPUT_FORMAT.TABLE, OUTPUT_FORMAT.XML] + }), + outfile: flags.string({ + char: 'o', + description: messages.getMessage('flags.outfileDescription'), + longDescription: messages.getMessage('flags.outfileDescriptionLong') + }), + 'severity-threshold': flags.integer({ + char: 's', + description: messages.getMessage('flags.sevthresholdDescription'), + longDescription: messages.getMessage('flags.sevthresholdDescriptionLong'), + exclusive: ['json'], + min: 1, + max: 3 + }), + 'normalize-severity': flags.boolean({ + description: messages.getMessage('flags.normalizesevDescription'), + longDescription: messages.getMessage('flags.normalizesevDescriptionLong') + }), + // END: Flags related to results processing. + // BEGIN: Flags related to targeting. + projectdir: flags.array({ + char: 'p', + description: messages.getMessage('flags.projectdirDescription'), + longDescription: messages.getMessage('flags.projectdirDescriptionLong'), + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + map: d => normalize(untildify(d)) + }), + // END: Flags related to targeting. + }; + async runInternal(): Promise { // First, do any validations that can't be handled with out-of-the-box stuff. await this.validateFlags(); @@ -66,11 +114,11 @@ export abstract class ScannerRunCommand extends ScannerCommand { } private async validateFlags(): Promise { - // First, perform any validation of the command's specific flags. - await this.validateCommandFlags(); + // First, validate the flags specific to the sub-variant. + await this.validateVariantFlags(); - // The output flags are common between subclasses. Validate those too. - this.validateOutputFlags(); + // Then, validate the flags that are common to all variants. + await this.validateCommonFlags(); } /** @@ -78,14 +126,24 @@ export abstract class ScannerRunCommand extends ScannerCommand { * @protected * @abstract */ - protected abstract validateCommandFlags(): Promise - - /** - * Validate the output-related flags, which are common to all implementations of {@link ScannerRunCommand} and share - * the same constraints. - * @private - */ - private validateOutputFlags(): void { + protected abstract validateVariantFlags(): Promise + + + private async validateCommonFlags(): Promise { + const fh = new FileHandler(); + // If there's a --projectdir flag, its entries must be non-glob paths pointing + // to existing directories. + if (this.flags.projectdir) { + for (const dir of (this.flags.projectdir as string[])) { + if (globby.hasMagic(dir)) { + throw SfdxError.create('@salesforce/sfdx-scanner', 'run-common', 'validations.projectdirCannotBeGlob', []); + } else if (!(await fh.exists(dir))) { + throw SfdxError.create('@salesforce/sfdx-scanner', 'run-common', 'validations.projectdirMustExist', []); + } else if (!(await fh.stats(dir)).isDirectory()) { + throw SfdxError.create('@salesforce/sfdx-scanner', 'run-common', 'validations.projectdirMustBeDir', []); + } + } + } // If the user explicitly specified both a format and an outfile, we need to do a bit of validation there. if (this.flags.format && this.flags.outfile) { const inferredOutfileFormat = this.inferFormatFromOutfile(); @@ -94,7 +152,7 @@ export abstract class ScannerRunCommand extends ScannerCommand { // If the chosen format is TABLE, we immediately need to exit. There's no way to sensibly write the output // of TABLE to a file. if (chosenFormat === OUTPUT_FORMAT.TABLE) { - throw SfdxError.create('@salesforce/sfdx-scanner', 'run', 'validations.cannotWriteTableToFile', []); + throw SfdxError.create('@salesforce/sfdx-scanner', 'run-common', 'validations.cannotWriteTableToFile', []); } // Otherwise, we want to be liberal with the user. If the chosen format doesn't match the outfile's extension, // just log a message saying so. @@ -145,9 +203,35 @@ export abstract class ScannerRunCommand extends ScannerCommand { /** * Gather a map of options that will be passed to the RuleManager without validation. * @protected + */ + protected gatherEngineOptions(): Map { + const options: Map = this.gatherCommonEngineOptions(); + this.mergeVariantEngineOptions(options); + return options; + } + + /** + * Gather engine options that are shared across sub-variants. + * @private + */ + private gatherCommonEngineOptions(): Map { + const options: Map = new Map(); + // We should only add a GraphEngine config if we were given a --projectdir flag. + if (this.flags.projectdir && (this.flags.projectdir as string[]).length > 0) { + const sfgeConfig: SfgeConfig = { + projectDirs: (this.flags.projectdir as string[]).map(p => path.resolve(p)) + }; + options.set(CUSTOM_CONFIG.SfgeConfig, JSON.stringify(sfgeConfig)); + } + return options; + } + + /** + * Gather engine options that are unique to each sub-variant. + * @protected * @abstract */ - protected abstract gatherEngineOptions(): Map; + protected abstract mergeVariantEngineOptions(commonOptions: Map): void; protected abstract pathBasedEngines(): boolean; } diff --git a/src/lib/formatter/RuleResultRecombinator.ts b/src/lib/formatter/RuleResultRecombinator.ts index 1abf62073..432396692 100644 --- a/src/lib/formatter/RuleResultRecombinator.ts +++ b/src/lib/formatter/RuleResultRecombinator.ts @@ -1,7 +1,7 @@ import {SfdxError} from '@salesforce/core'; import * as path from 'path'; import {EngineExecutionSummary, RecombinedData, RecombinedRuleResults, RuleResult, RuleViolation} from '../../types'; -import {DfaEngineFilters, ENGINE} from '../../Constants'; +import {ENGINE} from '../../Constants'; import {OUTPUT_FORMAT} from '../RuleManager'; import * as wrap from 'word-wrap'; import {FileHandler} from '../util/FileHandler'; @@ -42,10 +42,10 @@ export class RuleResultRecombinator { let formattedResults: string | {columns; rows} = null; switch (format) { case OUTPUT_FORMAT.CSV: - formattedResults = await this.constructCsv(results, executedEngines); + formattedResults = await this.constructCsv(results); break; case OUTPUT_FORMAT.HTML: - formattedResults = await this.constructHtml(results, executedEngines, verboseViolations); + formattedResults = await this.constructHtml(results, verboseViolations); break; case OUTPUT_FORMAT.JSON: formattedResults = this.constructJson(results, verboseViolations); @@ -57,7 +57,7 @@ export class RuleResultRecombinator { formattedResults = await constructSarif(results, executedEngines); break; case OUTPUT_FORMAT.TABLE: - formattedResults = this.constructTable(results, executedEngines); + formattedResults = this.constructTable(results); break; case OUTPUT_FORMAT.XML: formattedResults = this.constructXml(results); @@ -278,16 +278,12 @@ URL: ${url}`; return `${header}\n${body}\n${footer}`; } - private static constructTable(results: RuleResult[], executedEngines: Set): RecombinedData { + private static constructTable(results: RuleResult[]): RecombinedData { // If the results were just an empty string, we can return it. if (results.length === 0) { return ''; } - - // If any of the engines are DFA engines, we should use the DFA columns. Otherwise, we should use the static columns. - // NOTE: This code is predicated on the assumption that DFA and Pathless engines will not be run concurrently. - // If that assumption is ever invalidated, then this code has to change. - const columns = DfaEngineFilters.some(e => executedEngines.has(e)) + const columns = this.violationsAreDfa(results) ? ['Source Location', 'Sink Location', 'Description', 'Category', 'URL'] : ['Location', 'Description', 'Category', 'URL']; @@ -363,18 +359,17 @@ URL: ${url}`; } return JSON.stringify(resultsVerbose.filter(r => r.violations.length > 0)); } - return JSON.stringify(results.filter(r => r.violations.length > 0)); } - private static async constructHtml(results: RuleResult[], executedEngines: Set, verboseViolations = false): Promise { + private static async constructHtml(results: RuleResult[], verboseViolations = false): Promise { // If the results were just an empty string, we can return it. if (results.length === 0) { return ''; } const normalizeSeverity: boolean = results[0].violations.length > 0 && !(results[0].violations[0].normalizedSeverity === undefined); - const isDfa = DfaEngineFilters.some(e => executedEngines.has(e)); + const isDfa = this.violationsAreDfa(results); const violations = []; @@ -438,12 +433,12 @@ URL: ${url}`; return Mustache.render(template, templateData); } - private static async constructCsv(results: RuleResult[], executedEngines: Set): Promise { - // If the results were just an empty string, we can return it. + private static async constructCsv(results: RuleResult[]): Promise { + // If the results were just an empty list, we can return an empty string if (results.length === 0) { return ''; } - const isDfa = DfaEngineFilters.some(e => executedEngines.has(e)); + const isDfa: boolean = this.violationsAreDfa(results); const normalizeSeverity: boolean = results[0].violations.length > 0 && !(results[0].violations[0].normalizedSeverity === undefined) const csvRows = []; @@ -497,4 +492,26 @@ URL: ${url}`; }); }); } + + /** + * For now, either all violations are DFA or all violations are non-DFA. This method + * indicates which is the case. + * NOTE: This method is predicated on the assumption that DFA and non-DFA engines cannot + * run concurrently. If that assumption is ever invalidated, this method must change. + * @param results + * @private + */ + private static violationsAreDfa(results: RuleResult[]): boolean { + for (const result of results) { + if (result.violations.length > 0) { + return !isPathlessViolation(result.violations[0]); + } + } + // Theoretically, it should be impossible to reach this point, because + // it means there are either no results or no violations, and those cases + // should have been handled elsewhere. But in the event this somehow happens, + // we'll treat this null case as being pathless, so we display the pathless columns. + // Note that this decision is entirely arbitrary. + return false; + } } diff --git a/src/lib/sfge/SfgeEngine.ts b/src/lib/sfge/AbstractSfgeEngine.ts similarity index 53% rename from src/lib/sfge/SfgeEngine.ts rename to src/lib/sfge/AbstractSfgeEngine.ts index de1d93c53..497159681 100644 --- a/src/lib/sfge/SfgeEngine.ts +++ b/src/lib/sfge/AbstractSfgeEngine.ts @@ -1,12 +1,11 @@ import {Logger} from '@salesforce/core'; -import {SfgeWrapper} from './SfgeWrapper'; +import {SfgeCatalogWrapper, SfgeExecuteWrapper} from './SfgeWrapper'; import {AbstractRuleEngine} from '../services/RuleEngine'; -import {CUSTOM_CONFIG, ENGINE, Severity} from '../../Constants'; +import {CUSTOM_CONFIG, ENGINE, RuleType, Severity} from '../../Constants'; import {Controller} from '../../Controller'; -import {Catalog, Rule, RuleGroup, RuleResult, RuleTarget, SfgeConfig, TargetPattern} from '../../types'; +import {Catalog, Rule, RuleGroup, RuleResult, RuleTarget, RuleViolation, SfgeConfig, TargetPattern} from '../../types'; import {Config} from '../util/Config'; -import * as EngineUtils from '../util/CommonEngineUtils'; -import { EventCreator } from '../util/EventCreator'; +import {EventCreator} from '../util/EventCreator'; const CATALOG_START = 'CATALOG_START'; const CATALOG_END = 'CATALOG_END'; @@ -21,7 +20,7 @@ type SfgePartialRule = { category: string; } -type SfgeViolation = { +export type SfgeViolation = { ruleName: string; message: string; severity: number; @@ -37,52 +36,87 @@ type SfgeViolation = { sinkFileName: string; }; -export class SfgeEngine extends AbstractRuleEngine { - private static ENGINE_ENUM: ENGINE = ENGINE.SFGE; - private static ENGINE_NAME: string = ENGINE.SFGE.valueOf(); +export abstract class AbstractSfgeEngine extends AbstractRuleEngine { + protected static ENGINE_ENUM: ENGINE = ENGINE.SFGE; + protected static ENGINE_NAME: string = ENGINE.SFGE.valueOf(); private logger: Logger; private config: Config; - private eventCreator: EventCreator; private initialized: boolean; + private eventCreator: EventCreator; + private catalog: Catalog; + + protected abstract convertViolation(sfgeViolation: SfgeViolation): RuleViolation; + + protected abstract getRuleType(): RuleType; + + protected abstract getSubVariantName(): string; /** - * Invokes sync/async initialization required for the engine + * Invokes sync/async initialization required for the engine. + * @override */ public async init(): Promise { if (this.initialized) { return; } - this.logger = await Logger.child(this.getName()); + // Make sure that the sub-variants have different names for their loggers. + // This way, we can implement the method at the abstract level instead of + // the concrete classes. + this.logger = await Logger.child(`${this.getSubVariantName()}`); this.config = await Controller.getConfig(); this.eventCreator = await EventCreator.create({}); this.initialized = true; } /** - * Returns the name of the engine as referenced everywhere within the code + * Returns the name of the engine as referenced everywhere within the code. + * NOTE: By defining this at the abstract class, all engines in this family + * will share the same name and everything that comes with it (e.g., config). + * As such, the user experiences a single unified engine whose catalog includes + * both DFA and non-DFA rules, instead of two discrete engines. + * @override */ public getName(): string { - return SfgeEngine.ENGINE_NAME; + return AbstractSfgeEngine.ENGINE_NAME; } + /** + * Get the patterns that this engine family can match against. + * @override + */ public async getTargetPatterns(): Promise { - return await this.config.getTargetPatterns(SfgeEngine.ENGINE_ENUM); + return await this.config.getTargetPatterns(AbstractSfgeEngine.ENGINE_ENUM); } /** * Fetches the default catalog of rules supported by this engine. + * Different concrete implementations of this class will return + * different catalogs. + * @override */ public async getCatalog(): Promise { - const catalogOutput: string = await SfgeWrapper.getCatalog(); + // If we've already got a catalog, return it immediately. + if (this.catalog) { + return this.catalog; + } + // Each engine sub-variant should only catalog rules it can execute. + const ruleType: RuleType = this.getRuleType(); + const catalogOutput: string = await SfgeCatalogWrapper.getCatalog(ruleType); const ruleOutputStart: number = catalogOutput.indexOf(CATALOG_START) + CATALOG_START.length; const ruleOutputEnd: number = catalogOutput.indexOf(CATALOG_END); const ruleOutput: string = catalogOutput.slice(ruleOutputStart, ruleOutputEnd); const partialRules: SfgePartialRule[] = JSON.parse(ruleOutput) as SfgePartialRule[]; - return this.createCatalogFromPartialRules(partialRules); + this.catalog = this.createCatalogFromPartialRules(partialRules); + return this.catalog; } + /** + * Convert the partial rule descriptions returned by Graph Engine into a {@link Catalog} + * object that Code Analyzer can use. + * @private + */ private createCatalogFromPartialRules(partialRules: SfgePartialRule[]): Catalog { // For each raw rule, we'll want to synthesize an actual rule object. const completeRules: Rule[] = []; @@ -91,17 +125,17 @@ export class SfgeEngine extends AbstractRuleEngine { partialRules.forEach(({name, description, category}) => { completeRules.push({ - engine: ENGINE.SFGE, + engine: AbstractSfgeEngine.ENGINE_ENUM, sourcepackage: "sfge", name, description, - // SFGE rules each belong to exactly one category, so the string must be converted to a singleton array. + // Graph Engine rules each belong to exactly one category, so the string must be converted to a singleton array. categories: [category], - // SFGE does not use rulesets. + // Graph Engine does not use rulests. rulesets: [], - // Currently, all SFGE rules are Apex-specific. + // Currently, all Graph Engine rules are Apex-specific. languages: ["apex"], - // Currently, all SFGE rules are default-enabled. + // Currently, all Graph Engine rules are default-enabled. defaultEnabled: true }); categoryNames.add(category); @@ -119,63 +153,80 @@ export class SfgeEngine extends AbstractRuleEngine { return { rules: completeRules, categories: completeCategories, - // SFGE does not use rulesets. + // Graph Engine does not use rulesets. rulesets: [] }; } - /** - * Helps make decision to run an engine or not based on the Rules, Target paths, and the Engine Options selected per - * run. At this point, filtering should have already happened. - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public shouldEngineRun(ruleGroups: RuleGroup[], rules: Rule[], target: RuleTarget[], engineOptions: Map): boolean { - // If the engine isn't filtered out, there's no reason to not run it. - return true; - } - /** * @param engineOptions - A mapping of keys to values for engineOptions. Not all key/value pairs will apply to all engines. + * @override */ public async run(ruleGroups: RuleGroup[], rules: Rule[], targets: RuleTarget[], engineOptions: Map): Promise { // Make sure we have actual targets to run against. let targetCount = 0; targets.forEach((t) => { if (t.methods.length > 0) { - // If we're targeting individual methods, then each method is counted as a separate target for this purpose. + // If we're targeting individual methods, then each method is counted + // as a separate target for this purpose. targetCount += t.methods.length; } else { targetCount += t.paths.length; } }); + // If there are no targets, there's no point in running the rules. if (targetCount === 0) { - this.logger.trace(`No targets from ${SfgeEngine.ENGINE_NAME} found. Nothing to execute. Returning early.`); + this.logger.trace(`No targets from ${AbstractSfgeEngine.ENGINE_NAME} found. Nothing to execute. Returning early.`); return []; } - this.logger.trace(`About to run ${SfgeEngine.ENGINE_NAME} rules. Targets: ${targetCount} files and/or methods, Selected rules: ${JSON.stringify(rules)}`); + // At this point, the rules have yet to be filtered by DFA/Non-DFA, so it's possible that some provided rules + // aren't actually compatible with this GraphEngine sub-variant. So we should filter out any rules that + // aren't in this engine's catalog. + const catalogRuleNames: Set = new Set(); + const catalog = await this.getCatalog(); + for (const catalogRule of catalog.rules) { + catalogRuleNames.add(catalogRule.name); + } + const filteredRules: Rule[] = []; + for (const rule of rules) { + if (catalogRuleNames.has(rule.name)) { + filteredRules.push(rule); + } else { + this.logger.trace(`Rule ${rule.name} is ineligible to run with this ${this.getSubVariantName()}`); + } + } + + // If there are no rules that can be run, there's nothing left to do. + if (filteredRules.length === 0) { + this.logger.trace(`No eligible rules for ${this.getSubVariantName()}. Nothing to execute. Returning early.`); + return []; + } + this.logger.trace(`About to run ${this.getSubVariantName()} rules. Targets: ${targetCount} files and/or methods, Selected rules: ${JSON.stringify(filteredRules)}`); + + const sfgeConfig: SfgeConfig = JSON.parse(engineOptions.get(CUSTOM_CONFIG.SfgeConfig)) as SfgeConfig; let results: RuleResult[]; try { - // Execute SFGE - const output = await SfgeWrapper.runSfge(targets, rules, JSON.parse(engineOptions.get(CUSTOM_CONFIG.SfgeConfig)) as SfgeConfig); + // Execute graph engine + const output = await SfgeExecuteWrapper.runSfge(targets, filteredRules, sfgeConfig); results = this.processStdout(output); } catch (e) { // Handle errors thrown const message = e instanceof Error ? e.message : e as string; - this.logger.trace(`${SfgeEngine.ENGINE_NAME} evaluation failed. ${message}`); - await this.eventCreator.createUxErrorMessage('error.external.sfgeIncompleteAnalysis', [SfgeEngine.processStderr(message)]); - // Handle output results no matter the outcome + this.logger.trace(`${this.getSubVariantName()} evaluation failed. ${message}`); + await this.eventCreator.createUxErrorMessage('error.external.sfgeIncompleteAnalysis', [AbstractSfgeEngine.processStderr(message)]); + // Handle output results no matter the outcome. results = this.processStdout(message); } - - this.logger.trace(`Found ${results.length} results for ${SfgeEngine.ENGINE_NAME}`); + this.logger.trace(`Found ${results.length} results for ${this.getSubVariantName()}`); return results; } /** * TODO: Not supported yet. Idea is to detect if a custom rule path is supported by this engine. + * @override */ // eslint-disable-next-line @typescript-eslint/no-unused-vars public matchPath(path: string): boolean { @@ -185,41 +236,22 @@ export class SfgeEngine extends AbstractRuleEngine { /** * Returns value of `isEngineEnabled` based on Config or an internal decision. + * @override */ public async isEnabled(): Promise { - return await this.config.isEngineEnabled(SfgeEngine.ENGINE_ENUM); - } - - /** - * Helps decide if an instance of this engine should be included in a run based on the values provided in the --engine - * filter and Engine Options. - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public isEngineRequested(filterValues: string[], engineOptions: Map): boolean { - // If the engine was specifically requested, or there were no engine filters at all, then this engine should be - // treated as requested. That way: - // - SFGE will be included in cataloging for `scanner:rule:list` if no filters are provided or if it's requested, - // - SFGE will be manually excluded from `scanner:run` by virtue of being a DFA engine, - // - SFGE will be manually included in `scanner:run:dfa` by virtue of being a DFA engine. - return EngineUtils.isFilterEmptyOrNameInFilter(this.getName(), filterValues); - } - - public isDfaEngine(): boolean { - // NOTE: If SFGE implements and exposes non-DFA rules, then this will no longer be accurate. In that case, the - // best course of action is probably to divide SFGE into two engines: one for DFA rules and one for pathless rules. - return true; + return await this.config.isEngineEnabled(AbstractSfgeEngine.ENGINE_ENUM); } private static processStderr(output: string): string { // We should handle errors by checking for our error start string. const errorStart = output.indexOf(ERROR_START); if (errorStart === -1) { - // If our error start string is missing altogether, then something went disastrously wrong, and we should - // assume that the entire stderr is relevant. + // If our error start string is missing altogether, then something went disastrously wrong, + // and we should assume that the entire stderr is relevant. return output; } else { - // If the error start string is present, it means we exited cleanly and everything prior to the string is noise - // that can be omitted. + // If the error start string is present, it means we exited cleanly and everything prior + // to the string is noise that can be omitted. return output.slice(errorStart + ERROR_START.length); } } @@ -240,32 +272,21 @@ export class SfgeEngine extends AbstractRuleEngine { return []; } - // Each file should have at most one result, with an array of violations. Use a map to guarantee uniqueness. + // Each file should have at most one result, with an array of violations. + // Use a map to guarantee uniqueness. const resultMap: Map = new Map(); for (const sfgeViolation of sfgeViolations) { - // Index violations by their source file, since the source files were what was actually targeted by the user - // and therefore the more logical choice for how to sort and display violations. + // Index violations by their source file, since the source files were what was + // actually targeted by the user and therefore the more logical choice for how + // to sort and display violations. const indexFile = sfgeViolation.sourceFileName; const result: RuleResult = resultMap.get(indexFile) || { - engine: ENGINE.SFGE.valueOf(), + engine: this.getName(), fileName: indexFile, violations: [] }; - result.violations.push({ - ruleName: sfgeViolation.ruleName, - severity: sfgeViolation.severity, - message: sfgeViolation.message, - category: sfgeViolation.category, - url: sfgeViolation.url, - sinkLine: sfgeViolation.sinkLineNumber || null, - sinkColumn: sfgeViolation.sinkColumnNumber || null, - sinkFileName: sfgeViolation.sinkFileName || "", - sourceLine: sfgeViolation.sourceLineNumber, - sourceColumn: sfgeViolation.sourceColumnNumber, - sourceType: sfgeViolation.sourceType, - sourceMethodName: sfgeViolation.sourceVertexName - }); + result.violations.push(this.convertViolation(sfgeViolation)); resultMap.set(indexFile, result); } return [...resultMap.values()]; diff --git a/src/lib/sfge/SfgeDfaEngine.ts b/src/lib/sfge/SfgeDfaEngine.ts new file mode 100644 index 000000000..6fe0c3b30 --- /dev/null +++ b/src/lib/sfge/SfgeDfaEngine.ts @@ -0,0 +1,67 @@ +import {AbstractSfgeEngine, SfgeViolation} from './AbstractSfgeEngine'; +import {RuleType} from '../../Constants'; +import {Rule, RuleGroup, RuleTarget, RuleViolation} from '../../types'; +import * as EngineUtils from "../util/CommonEngineUtils"; + + +export class SfgeDfaEngine extends AbstractSfgeEngine { + /** + * Helps decide if an instance of this engine should be included in a run/cataloging based on the values + * provided in the --engine filter and Engine Options. + * @override + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public isEngineRequested(filterValues: string[], engineOptions: Map): boolean { + // If `sfge` is requested or there are no engine filters at all, then the DFA variant should be + // treated as requested. This way, it will always be included in cataloging, and it will be treated + // as eligible to run (though excluded from non-DFA scenarios by virtue of being DFA). + return EngineUtils.isFilterEmptyOrNameInFilter(this.getName(), filterValues); + } + + /** + * Helps make decision to run an engine or not based on the Rules, Target paths, and the Engine Options selected per + * run. At this point, filtering should have already happened. + * @override + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public shouldEngineRun(ruleGroups: RuleGroup[], rules: Rule[], target: RuleTarget[], engineOptions: Map): boolean { + // If the engine was requested, there's no reason not to run it. + // By virtue of the constraints imposed on the `scanner:run:dfa` command, all necessary + // config info is guaranteed to be present. + return true; + } + + protected getSubVariantName(): string { + return `${this.getName()}-${RuleType.DFA}`; + } + + protected getRuleType(): RuleType { + return RuleType.DFA; + } + + public isDfaEngine(): boolean { + return true; + } + + /** + * Convert one of GraphEngine's internal violations into a format usable by CodeAnalyzer. + * @override + * @protected + */ + protected convertViolation(sfgeViolation: SfgeViolation): RuleViolation { + return { + ruleName: sfgeViolation.ruleName, + severity: sfgeViolation.severity, + message: sfgeViolation.message, + category: sfgeViolation.category, + url: sfgeViolation.url, + sinkLine: sfgeViolation.sinkLineNumber || null, + sinkColumn: sfgeViolation.sinkColumnNumber || null, + sinkFileName: sfgeViolation.sinkFileName || "", + sourceLine: sfgeViolation.sourceLineNumber, + sourceColumn: sfgeViolation.sourceColumnNumber, + sourceType: sfgeViolation.sourceType, + sourceMethodName: sfgeViolation.sourceVertexName + }; + } +} diff --git a/src/lib/sfge/SfgePathlessEngine.ts b/src/lib/sfge/SfgePathlessEngine.ts new file mode 100644 index 000000000..7d569e80e --- /dev/null +++ b/src/lib/sfge/SfgePathlessEngine.ts @@ -0,0 +1,70 @@ +import {SfdxError} from '@salesforce/core'; +import {AbstractSfgeEngine, SfgeViolation} from "./AbstractSfgeEngine"; +import {Rule, RuleGroup, RuleTarget, RuleViolation, SfgeConfig} from '../../types'; +import {CUSTOM_CONFIG, RuleType} from '../../Constants'; +import * as EngineUtils from "../util/CommonEngineUtils"; + + +export class SfgePathlessEngine extends AbstractSfgeEngine { + /** + * Helps decide if an instance of this engine should be included in a run/cataloging based on the values + * provided in the --engine filter and Engine Options. + * @override + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public isEngineRequested(filterValues: string[], engineOptions: Map): boolean { + // The non-DFA variant must be explicitly requested via `--engine sfge`. Otherwise, + // it should be excluded from cataloging and running. + // NOTE: This was an intentional divergence from the DFA variant's behavior, as a consequence + // of the in-progress nature of many/most non-DFA rules. When we're more confident + // in the state of the engine, this method should be changed so the engine counts + // as requested-by-default the way its DFA cousin does. + return EngineUtils.isValueInFilter(this.getName(), filterValues); + } + + /** + * Helps make decision to run an engine or not based on the Rules, Target paths, and the Engine Options selected per + * run. At this point, filtering should have already happened. + * @override + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public shouldEngineRun(ruleGroups: RuleGroup[], rules: Rule[], target: RuleTarget[], engineOptions: Map): boolean { + // For the non-DFA Graph Engine variant, we need to make sure that we have the + // necessary info to run the engine, since the relevant flags aren't required + // for `scanner:run`. + if (engineOptions.has(CUSTOM_CONFIG.SfgeConfig)) { + const sfgeConfig: SfgeConfig = JSON.parse(engineOptions.get(CUSTOM_CONFIG.SfgeConfig)) as SfgeConfig; + if (sfgeConfig.projectDirs && sfgeConfig.projectDirs.length > 0) { + // If we've got a config with projectDirs, we're set. + return true; + } + } + // If we're here, it's because we're missing the necessary info to run this engine. + // We should throw an error indicating this. + throw SfdxError.create('@salesforce/sfdx-scanner', 'SfgeEngine', 'errors.failedWithoutProjectDir', []); + } + + protected getSubVariantName(): string { + return `${this.getName()}-${RuleType.PATHLESS}`; + } + + protected getRuleType(): RuleType { + return RuleType.PATHLESS; + } + + public isDfaEngine(): boolean { + return false; + } + + protected convertViolation(sfgeViolation: SfgeViolation): RuleViolation { + return { + ruleName: sfgeViolation.ruleName, + message: sfgeViolation.message, + severity: sfgeViolation.severity, + category: sfgeViolation.category, + url: sfgeViolation.url, + line: sfgeViolation.sourceLineNumber, + column: sfgeViolation.sourceColumnNumber + }; + } +} diff --git a/src/lib/sfge/SfgeWrapper.ts b/src/lib/sfge/SfgeWrapper.ts index b795583e8..029162db1 100644 --- a/src/lib/sfge/SfgeWrapper.ts +++ b/src/lib/sfge/SfgeWrapper.ts @@ -2,6 +2,7 @@ import path = require('path'); import {Messages, Logger} from '@salesforce/core'; import {AsyncCreatable} from '@salesforce/kit'; import {Controller} from '../../Controller'; +import {RuleType} from '../../Constants'; import * as JreSetupManager from '../JreSetupManager'; import {uxEvents, EVENTS} from '../ScannerEvents'; import {Rule, SfgeConfig, RuleTarget} from '../../types'; @@ -13,8 +14,8 @@ import {FileHandler} from '../util/FileHandler'; const SFGE_LIB = path.join(__dirname, '..', '..', '..', 'dist', 'sfge', 'lib'); const MAIN_CLASS = "com.salesforce.Main"; -const EXEC_COMMAND = "execute"; -const CATALOG_COMMAND = "catalog"; +const EXEC_ACTION = "execute"; +const CATALOG_ACTION = "catalog"; const SFGE_LOG_FILE = 'sfge.log'; // Initialize Messages with the current plugin directory @@ -33,13 +34,20 @@ const EXIT_NO_VIOLATIONS = 0; */ const EXIT_WITH_VIOLATIONS = 4; -interface SfgeWrapperOptions { +type SfgeWrapperOptions = { + action: string; + spinnerManager: SpinnerManager; + jvmArgs?: string; +} + +type SfgeCatalogOptions = SfgeWrapperOptions & { + ruleType: RuleType; +} + +type SfgeExecuteOptions = SfgeWrapperOptions & { targets: RuleTarget[]; projectDirs: string[]; - command: string; rules: Rule[]; - spinnerManager: SpinnerManager; - jvmArgs?: string, ruleThreadCount?: number; ruleThreadTimeout?: number; ruleDisableWarningViolation?: boolean; @@ -73,8 +81,8 @@ class SfgeSpinnerManager extends AsyncCreatable implements SpinnerManager { public startSpinner(): void { uxEvents.emit( EVENTS.START_SPINNER, - messages.getMessage("spinnerStart", [this.logFilePath]), - messages.getMessage("pleaseWait") + messages.getMessage("messages.spinnerStart", [this.logFilePath]), + messages.getMessage("messages.pleaseWait") ); // TODO: This timer logic should ideally live inside waitOnSpinner() @@ -89,32 +97,20 @@ class SfgeSpinnerManager extends AsyncCreatable implements SpinnerManager { } } -export class SfgeWrapper extends CommandLineSupport { - private logger: Logger; - private initialized: boolean; - private fh: FileHandler; - private targets: RuleTarget[]; - private projectDirs: string[]; - private command: string; - private rules: Rule[]; +abstract class AbstractSfgeWrapper extends CommandLineSupport { + protected logger: Logger; + protected initialized: boolean; + protected fh: FileHandler; + private action: string; private logFilePath: string; private spinnerManager: SpinnerManager; private jvmArgs: string; - private ruleThreadCount: number; - private ruleThreadTimeout: number; - private ruleDisableWarningViolation: boolean; - constructor(options: SfgeWrapperOptions) { + protected constructor(options: SfgeWrapperOptions) { super(options); - this.targets = options.targets; - this.projectDirs = options.projectDirs; - this.command = options.command; - this.rules = options.rules; + this.action = options.action; this.spinnerManager = options.spinnerManager; this.jvmArgs = options.jvmArgs; - this.ruleThreadCount = options.ruleThreadCount; - this.ruleThreadTimeout = options.ruleThreadTimeout; - this.ruleDisableWarningViolation = options.ruleDisableWarningViolation; } protected async init(): Promise { @@ -122,7 +118,7 @@ export class SfgeWrapper extends CommandLineSupport { return; } await super.init(); - this.logger = await Logger.child('SfgeWrapper'); + this.logger = await Logger.child(this.constructor.name); this.fh = new FileHandler(); this.logFilePath = path.join(Controller.getSfdxScannerPath(), SFGE_LOG_FILE); this.initialized = true; @@ -132,12 +128,6 @@ export class SfgeWrapper extends CommandLineSupport { return Promise.resolve([`${SFGE_LIB}/*`]); } - private async createInputFile(input: SfgeInput): Promise { - const inputFile = await this.fh.tmpFileWithCleanup(); - await this.fh.writeFile(inputFile, JSON.stringify(input)); - return inputFile; - } - protected isSuccessfulExitCode(code: number): boolean { return code === EXIT_NO_VIOLATIONS || code === EXIT_WITH_VIOLATIONS; } @@ -146,12 +136,13 @@ export class SfgeWrapper extends CommandLineSupport { * While handling unsuccessful executions, include stdout * and stderr information. * @param args contains information on the outcome of execution + * @override */ protected handleResults(args: ResultHandlerArgs) { if (args.isSuccess) { args.res(args.stdout); } else { - // Pass in both stdout and stderr so that results can be salvaged + // Pass in both stdout and stderr so any partial results can be salvaged. args.rej(args.stdout + ' ' + args.stderr); } } @@ -171,46 +162,90 @@ export class SfgeWrapper extends CommandLineSupport { const command = path.join(javaHome, 'bin', 'java'); const classpath = await this.buildClasspath(); - const inputObject: SfgeInput = this.createInputJson(); - const inputFile = await this.createInputFile(inputObject); - - this.logger.trace(`Stored the names of ${this.targets.length} targeted files and ${this.projectDirs.length} source directories in ${inputFile}`); - this.logger.trace(`Rules to be executed: ${JSON.stringify(inputObject.rulesToRun)}`); - const args = [`-Dsfge_log_name=${this.logFilePath}`, '-cp', classpath.join(path.delimiter)]; if (this.jvmArgs != null) { args.push(this.jvmArgs); } - if (this.ruleThreadCount != null) { - args.push(`-DSFGE_RULE_THREAD_COUNT=${this.ruleThreadCount}`); - } - if (this.ruleThreadTimeout != null) { - args.push(`-DSFGE_RULE_THREAD_TIMEOUT=${this.ruleThreadTimeout}`); - } - if (this.ruleDisableWarningViolation != null) { - args.push(`-DSFGE_RULE_DISABLE_WARNING_VIOLATION=${this.ruleDisableWarningViolation.toString()}`); - } - args.push(MAIN_CLASS, this.command, inputFile); - + args.push(...this.getSupplementalFlags(), MAIN_CLASS, this.action, ...(await this.getSupplementalArgs())); this.logger.trace(`Preparing to execute sfge with command: "${command}", args: "${JSON.stringify(args)}"`); return [command, args]; } - - private async execute(): Promise { + protected async execute(): Promise { return super.runCommand(); } - public static async getCatalog() { - const wrapper = await SfgeWrapper.create({ - targets: [], - projectDirs: [], - command: CATALOG_COMMAND, - rules: [], - // Cataloging shouldn't take very long, so no need for a functional spinner here. + protected abstract getSupplementalFlags(): string[]; + + protected abstract getSupplementalArgs(): Promise; +} + +export class SfgeCatalogWrapper extends AbstractSfgeWrapper { + private ruleType: RuleType; + + constructor(options: SfgeCatalogOptions) { + super(options); + this.ruleType = options.ruleType; + } + + protected getSupplementalArgs(): Promise { + return Promise.resolve([this.ruleType]); + } + + protected getSupplementalFlags(): string[] { + return []; + } + + public static async getCatalog(ruleType: RuleType): Promise { + const wrapper = await SfgeCatalogWrapper.create({ + action: CATALOG_ACTION, + ruleType, + // Cataloging shouldn't take very long, so no need for a functional spinner. spinnerManager: new NoOpSpinnerManager() }); return wrapper.execute(); } +} + +export class SfgeExecuteWrapper extends AbstractSfgeWrapper { + private targets: RuleTarget[]; + private projectDirs: string[]; + private rules: Rule[]; + private ruleThreadCount: number; + private ruleThreadTimeout: number; + private ruleDisableWarningViolation: boolean; + + constructor(options: SfgeExecuteOptions) { + super(options); + this.targets = options.targets; + this.projectDirs = options.projectDirs; + this.rules = options.rules; + this.ruleThreadCount = options.ruleThreadCount; + this.ruleThreadTimeout = options.ruleThreadTimeout; + this.ruleDisableWarningViolation = options.ruleDisableWarningViolation; + } + + protected getSupplementalFlags(): string[] { + const flags: string[] = []; + if (this.ruleThreadCount != null) { + flags.push(`-DSFGE_RULE_THREAD_COUNT=${this.ruleThreadCount}`); + } + if (this.ruleThreadTimeout != null) { + flags.push(`-DSFGE_RULE_THREAD_TIMEOUT=${this.ruleThreadTimeout}`); + } + if (this.ruleDisableWarningViolation != null) { + flags.push(`-DSFGE_RULE_DISABLE_WARNING_VIOLATION=${this.ruleDisableWarningViolation.toString()}`); + } + return flags; + } + + protected async getSupplementalArgs(): Promise { + const inputObject: SfgeInput = this.createInputJson(); + const inputFile = await this.createInputFile(inputObject); + + this.logger.trace(`Stored the names of ${this.targets.length} targeted files and ${this.projectDirs.length} source directories in ${inputFile}`); + this.logger.trace(`Rules to be executed: ${JSON.stringify(inputObject.rulesToRun)}`); + return [inputFile]; + } private createInputJson(): SfgeInput { const inputJson: SfgeInput = { @@ -242,11 +277,17 @@ export class SfgeWrapper extends CommandLineSupport { return inputJson; } + private async createInputFile(input: SfgeInput): Promise { + const inputFile = await this.fh.tmpFileWithCleanup(); + await this.fh.writeFile(inputFile, JSON.stringify(input)); + return inputFile; + } + public static async runSfge(targets: RuleTarget[], rules: Rule[], sfgeConfig: SfgeConfig): Promise { - const wrapper = await SfgeWrapper.create({ + const wrapper = await SfgeExecuteWrapper.create({ targets, projectDirs: sfgeConfig.projectDirs, - command: EXEC_COMMAND, + action: EXEC_ACTION, rules: rules, // Running rules could take quite a while, so we should use a functional spinner. spinnerManager: await SfgeSpinnerManager.create({}), diff --git a/src/lib/util/RunOutputProcessor.ts b/src/lib/util/RunOutputProcessor.ts index 0b861207f..aba288132 100644 --- a/src/lib/util/RunOutputProcessor.ts +++ b/src/lib/util/RunOutputProcessor.ts @@ -8,7 +8,7 @@ import {OUTPUT_FORMAT} from '../RuleManager'; Messages.importMessagesDirectory(__dirname); -const runMessages = Messages.loadMessages('@salesforce/sfdx-scanner', 'run'); +const messages = Messages.loadMessages('@salesforce/sfdx-scanner', 'RunOutputProcessor'); const INTERNAL_ERROR_CODE = 1; export type RunOutputOptions = { @@ -32,7 +32,7 @@ export class RunOutputProcessor { // If the results are an empty string, it means no violations were found. if (results === '') { // Build an appropriate message... - const msg = runMessages.getMessage('output.noViolationsDetected', [[...summaryMap.keys()].join(', ')]); + const msg = messages.getMessage('output.noViolationsDetected', [[...summaryMap.keys()].join(', ')]); // ...log it to the console... this.ux.log(msg); // ...and return it for use with the --json flag. @@ -98,14 +98,14 @@ export class RunOutputProcessor { if ((this.opts.format === OUTPUT_FORMAT.TABLE) || this.opts.outfile) { const summaryMsgs = [...summaryMap.entries()] .map(([engine, summary]) => { - return runMessages.getMessage('output.engineSummaryTemplate', [engine, summary.violationCount, summary.fileCount]); + return messages.getMessage('output.engineSummaryTemplate', [engine, summary.violationCount, summary.fileCount]); }); msgParts = [...msgParts, ...summaryMsgs]; } // If we're supposed to throw an exception in response to violations, we need an extra piece of summary. // Summary to print with --severity-threshold flag if (this.shouldErrorForSeverity(minSev, this.opts.severityForError)) { - msgParts.push(runMessages.getMessage('output.sevThresholdSummary', [this.opts.severityForError])); + msgParts.push(messages.getMessage('output.sevThresholdSummary', [this.opts.severityForError])); } return msgParts; @@ -122,7 +122,7 @@ export class RunOutputProcessor { throw new SfdxError(message, null, null, INTERNAL_ERROR_CODE); } // Return a message indicating the action we took. - return runMessages.getMessage('output.writtenToOutFile', [this.opts.outfile]); + return messages.getMessage('output.writtenToOutFile', [this.opts.outfile]); } private writeToConsole(results: RecombinedData): string { @@ -156,6 +156,6 @@ export class RunOutputProcessor { } // If the output format is table, then we should return a message indicating that the output was logged above. // Otherwise, just return an empty string so the output remains machine-readable. - return format === OUTPUT_FORMAT.TABLE ? runMessages.getMessage('output.writtenToConsole') : ''; + return format === OUTPUT_FORMAT.TABLE ? messages.getMessage('output.writtenToConsole') : ''; } } diff --git a/test/commands/scanner/rule/remove.test.ts b/test/commands/scanner/rule/remove.test.ts index a770ea228..585e15fdc 100644 --- a/test/commands/scanner/rule/remove.test.ts +++ b/test/commands/scanner/rule/remove.test.ts @@ -13,6 +13,16 @@ function getSfdxScannerPath(): string { return Controller.getSfdxScannerPath(); } +/** + * scanner:rule:remove typically prompts the user to confirm that they actually + * want to remove the rules in question. + * For these tests, this constant will indicate a number of milliseconds that + * we should wait before simulating a user's response to that prompt. + * It's important to wait, because if we send the input too early, it will be + * missed by the test, which will eventually just time out and fail. + */ +const waitTime = 5000; + // NOTE: The relative paths are relative to the root of the project instead of to the location of this file, // because the root is the working directory during test evaluation. const parentFolderForJars = path.resolve('test', 'test-jars', 'apex'); @@ -29,7 +39,11 @@ const customPathDescriptor = { const removeTest = setupCommandTest .do(() => { - writeNewCustomPathFile(); + writePopulatedCustomPathFile(); + }) + .finally(() => { + // Clean up after ourselves. + writeEmptyCustomPathFile(); }); describe('scanner:rule:remove', () => { @@ -48,8 +62,8 @@ describe('scanner:rule:remove', () => { describe('Rule Removal', () => { describe('Test Case: Removing a single PMD JAR', () => { removeTest - // We'll wait three seconds then send in a 'y', to simulate the user confirming the request. - .stdin('y\n', 3000) + // We'll wait a few seconds then send in a 'y', to simulate the user confirming the request. + .stdin('y\n', waitTime) .timeout(10000) .command(['scanner:rule:remove', '--path', pathToApexJar1 @@ -70,8 +84,8 @@ describe('scanner:rule:remove', () => { describe('Test Case: Removing multiple PMD JARs', () => { removeTest - // We'll wait three seconds then send in a 'y', to simulate the user confirming the request. - .stdin('y\n', 3000) + // We'll wait a few seconds then send in a 'y', to simulate the user confirming the request. + .stdin('y\n', waitTime) .timeout(10000) .command(['scanner:rule:remove', '--path', [pathToApexJar1, pathToApexJar2].join(',') @@ -92,8 +106,8 @@ describe('scanner:rule:remove', () => { describe('Test Case: Removing an entire folder of PMD JARs', () => { removeTest - // We'll wait three seconds then send in a 'y', to simulate the user confirming the request. - .stdin('y\n', 3000) + // We'll wait a few seconds then send in a 'y', to simulate the user confirming the request. + .stdin('y\n', waitTime) .timeout(10000) .command(['scanner:rule:remove', '--path', parentFolderForJars @@ -114,8 +128,8 @@ describe('scanner:rule:remove', () => { describe('Edge Case: Provided path is not registered as a custom rule', () => { removeTest - // We'll wait three seconds then send in a 'y', to simulate the user confirming the request. - .stdin('y\n', 3000) + // We'll wait a few seconds then send in a 'y', to simulate the user confirming the request. + .stdin('y\n', waitTime) .timeout(10000) .command(['scanner:rule:remove', '--path', pathToApexJar4 @@ -129,8 +143,8 @@ describe('scanner:rule:remove', () => { describe('User prompt', () => { describe('Test Case: User chooses to abort transaction instead of confirming', () => { removeTest - // We'll wait three seconds and then send in a 'n', to simulate the user aborting the request. - .stdin('n\n', 3000) + // We'll wait a few seconds and then send in a 'n', to simulate the user aborting the request. + .stdin('n\n', waitTime) .timeout(10000) .command(['scanner:rule:remove', '--path', pathToApexJar1 @@ -176,7 +190,11 @@ describe('scanner:rule:remove', () => { }); }); -function writeNewCustomPathFile() { +function writeEmptyCustomPathFile() { + fs.writeFileSync(path.join(getSfdxScannerPath(), CUSTOM_PATHS_FILE), "{}"); +} + +function writePopulatedCustomPathFile() { fs.writeFileSync(path.join(getSfdxScannerPath(), CUSTOM_PATHS_FILE), JSON.stringify(customPathDescriptor)); } diff --git a/test/commands/scanner/run.severity.test.ts b/test/commands/scanner/run.severity.test.ts index 6c9384dc4..d5d7de7be 100644 --- a/test/commands/scanner/run.severity.test.ts +++ b/test/commands/scanner/run.severity.test.ts @@ -5,7 +5,7 @@ import path = require('path'); Messages.importMessagesDirectory(__dirname); -const runMessages = Messages.loadMessages('@salesforce/sfdx-scanner', 'run'); +const processorMessages = Messages.loadMessages('@salesforce/sfdx-scanner', 'RunOutputProcessor'); describe('scanner:run', function () { @@ -20,8 +20,8 @@ describe('scanner:run', function () { '--severity-threshold', '3' ]) .it('When no violations are found, no error is thrown', ctx => { - expect(ctx.stdout).to.contain(runMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); - expect(ctx.stderr).to.not.contain(runMessages.getMessage('output.sevThresholdSummary', ['3']), 'Error should not be present'); + expect(ctx.stdout).to.contain(processorMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); + expect(ctx.stderr).to.not.contain(processorMessages.getMessage('output.sevThresholdSummary', ['3']), 'Error should not be present'); }); setupCommandTest @@ -40,7 +40,7 @@ describe('scanner:run', function () { } } - expect(ctx.stderr).not.to.contain(runMessages.getMessage('output.sevThresholdSummary', ['1'])); + expect(ctx.stderr).not.to.contain(processorMessages.getMessage('output.sevThresholdSummary', ['1'])); }); @@ -59,7 +59,7 @@ describe('scanner:run', function () { expect(output[i].violations[j].normalizedSeverity).to.equal(3); } } - expect(ctx.stderr).to.contain(runMessages.getMessage('output.sevThresholdSummary', ['3'])); + expect(ctx.stderr).to.contain(processorMessages.getMessage('output.sevThresholdSummary', ['3'])); }); @@ -88,6 +88,7 @@ describe('scanner:run', function () { describe('--normalize-severity flag', () => { setupCommandTest + .timeout(15000) .command(['scanner:run', '--target', path.join('test', 'code-fixtures'), '--format', 'json', @@ -123,6 +124,7 @@ describe('scanner:run', function () { }); setupCommandTest + .timeout(15000) .command(['scanner:run', '--target', path.join('test', 'code-fixtures'), '--format', 'json' diff --git a/test/commands/scanner/run.test.ts b/test/commands/scanner/run.test.ts index 788a8b2d2..9578ec6b7 100644 --- a/test/commands/scanner/run.test.ts +++ b/test/commands/scanner/run.test.ts @@ -8,7 +8,9 @@ import tildify = require('tildify'); import events = require('../../../messages/EventKeyTemplates'); Messages.importMessagesDirectory(__dirname); -const runMessages = Messages.loadMessages('@salesforce/sfdx-scanner', 'run'); +const processorMessages = Messages.loadMessages('@salesforce/sfdx-scanner', 'RunOutputProcessor'); +const commonMessages = Messages.loadMessages('@salesforce/sfdx-scanner', 'run-common'); +const runMessages = Messages.loadMessages('@salesforce/sfdx-scanner', 'run-pathless'); describe('scanner:run', function () { this.timeout(10000); // TODO why do we get timeouts at the default of 5000? What is so expensive here? @@ -56,7 +58,7 @@ describe('scanner:run', function () { '--format', 'xml' ]) .it('When the file contains no violations, a message is logged to the console', ctx => { - expect(ctx.stdout).to.contain(runMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); + expect(ctx.stdout).to.contain(processorMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); }); }); @@ -188,7 +190,7 @@ describe('scanner:run', function () { if (expectSummary) { expect(summary).to.not.equal(undefined, 'Expected summary to be not undefined'); expect(summary).to.not.equal(null, 'Expected summary to be not null'); - expect(summary).to.contain(runMessages.getMessage('output.engineSummaryTemplate', ['pmd', 2, 1]), 'Summary should be correct'); + expect(summary).to.contain(processorMessages.getMessage('output.engineSummaryTemplate', ['pmd', 2, 1]), 'Summary should be correct'); } // Since it's a CSV, the rows themselves are separated by newline chaacters, and there's a header row we // need to discard. @@ -232,9 +234,9 @@ describe('scanner:run', function () { }) .it('Properly writes CSV to file', ctx => { // Verify that the correct message is displayed to user - expect(ctx.stdout).to.contain(runMessages.getMessage('output.engineSummaryTemplate', ['pmd', 2, 1]), 'Expected summary to be correct'); - expect(ctx.stdout).to.contain(runMessages.getMessage('output.writtenToOutFile', ['testout.csv'])); - expect(ctx.stdout).to.not.contain(runMessages.getMessage('output.noViolationsDetected', [])); + expect(ctx.stdout).to.contain(processorMessages.getMessage('output.engineSummaryTemplate', ['pmd', 2, 1]), 'Expected summary to be correct'); + expect(ctx.stdout).to.contain(processorMessages.getMessage('output.writtenToOutFile', ['testout.csv'])); + expect(ctx.stdout).to.not.contain(processorMessages.getMessage('output.noViolationsDetected', [])); // Verify that the file we wanted was actually created. expect(fs.existsSync('testout.csv')).to.equal(true, 'The command should have created the expected output file'); @@ -249,7 +251,7 @@ describe('scanner:run', function () { '--format', 'csv' ]) .it('When no violations are detected, a message is logged to the console', ctx => { - expect(ctx.stdout).to.contain(runMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); + expect(ctx.stdout).to.contain(processorMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); }); setupCommandTest @@ -265,8 +267,8 @@ describe('scanner:run', function () { } }) .it('When --oufile is provided and no violations are detected, output file should not be created', ctx => { - expect(ctx.stdout).to.contain(runMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); - expect(ctx.stdout).to.not.contain(runMessages.getMessage('output.writtenToOutFile', ['testout.csv'])); + expect(ctx.stdout).to.contain(processorMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); + expect(ctx.stdout).to.not.contain(processorMessages.getMessage('output.writtenToOutFile', ['testout.csv'])); expect(fs.existsSync('testout.csv')).to.be.false; }); }); @@ -313,8 +315,8 @@ describe('scanner:run', function () { }) .it('Properly writes HTML to file', ctx => { // Verify that the correct message is displayed to user - expect(ctx.stdout).to.contain(runMessages.getMessage('output.writtenToOutFile', [outputFile])); - expect(ctx.stdout).to.not.contain(runMessages.getMessage('output.noViolationsDetected', [])); + expect(ctx.stdout).to.contain(processorMessages.getMessage('output.writtenToOutFile', [outputFile])); + expect(ctx.stdout).to.not.contain(processorMessages.getMessage('output.noViolationsDetected', [])); // Verify that the file we wanted was actually created. expect(fs.existsSync(outputFile)).to.equal(true, 'The command should have created the expected output file'); @@ -329,7 +331,7 @@ describe('scanner:run', function () { '--format', 'html' ]) .it('When no violations are detected, a message is logged to the console', ctx => { - expect(ctx.stdout).to.contain(runMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); + expect(ctx.stdout).to.contain(processorMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); }); setupCommandTest @@ -345,8 +347,8 @@ describe('scanner:run', function () { } }) .it('When --oufile is provided and no violations are detected, output file should not be created', ctx => { - expect(ctx.stdout).to.contain(runMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); - expect(ctx.stdout).to.not.contain(runMessages.getMessage('output.writtenToOutFile', [outputFile])); + expect(ctx.stdout).to.contain(processorMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); + expect(ctx.stdout).to.not.contain(processorMessages.getMessage('output.writtenToOutFile', [outputFile])); expect(fs.existsSync(outputFile)).to.be.false; }); }); @@ -388,9 +390,9 @@ describe('scanner:run', function () { }) .it('Properly writes JSON to file', ctx => { // Verify that the correct message is displayed to user - expect(ctx.stdout).to.contain(runMessages.getMessage('output.engineSummaryTemplate', ['pmd', 2, 1]), 'Expected summary to be correct'); - expect(ctx.stdout).to.contain(runMessages.getMessage('output.writtenToOutFile', ['testout.json'])); - expect(ctx.stdout).to.not.contain(runMessages.getMessage('output.noViolationsDetected', [])); + expect(ctx.stdout).to.contain(processorMessages.getMessage('output.engineSummaryTemplate', ['pmd', 2, 1]), 'Expected summary to be correct'); + expect(ctx.stdout).to.contain(processorMessages.getMessage('output.writtenToOutFile', ['testout.json'])); + expect(ctx.stdout).to.not.contain(processorMessages.getMessage('output.noViolationsDetected', [])); // Verify that the file we wanted was actually created. expect(fs.existsSync('testout.json')).to.equal(true, 'The command should have created the expected output file'); @@ -405,7 +407,7 @@ describe('scanner:run', function () { '--format', 'json' ]) .it('When no violations are detected, a message is logged to the console', ctx => { - expect(ctx.stdout).to.contain(runMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); + expect(ctx.stdout).to.contain(processorMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); }); setupCommandTest @@ -421,8 +423,8 @@ describe('scanner:run', function () { } }) .it('When --oufile is provided and no violations are detected, output file should not be created', ctx => { - expect(ctx.stdout).to.contain(runMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); - expect(ctx.stdout).to.not.contain(runMessages.getMessage('output.writtenToOutFile', ['testout.json'])); + expect(ctx.stdout).to.contain(processorMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); + expect(ctx.stdout).to.not.contain(processorMessages.getMessage('output.writtenToOutFile', ['testout.json'])); expect(fs.existsSync('testout.json')).to.be.false; }); @@ -453,7 +455,7 @@ describe('scanner:run', function () { '--format', 'table' ]) .it('When no violations are detected, a message is logged to the console', ctx => { - expect(ctx.stdout).to.contain(runMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); + expect(ctx.stdout).to.contain(processorMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); }); }); @@ -516,8 +518,8 @@ describe('scanner:run', function () { const output = JSON.parse(ctx.stdout); expect(output.status).to.equal(0, 'Should finish properly'); const result = output.result; - expect(result).to.contain(runMessages.getMessage('output.writtenToOutFile', ['testout.xml'])); - expect(result).to.not.contain(runMessages.getMessage('output.noViolationsDetected', [])); + expect(result).to.contain(processorMessages.getMessage('output.writtenToOutFile', ['testout.xml'])); + expect(result).to.not.contain(processorMessages.getMessage('output.noViolationsDetected', [])); // Verify that the file we wanted was actually created. expect(fs.existsSync('testout.xml')).to.equal(true, 'The command should have created the expected output file'); const fileContents = fs.readFileSync('testout.xml').toString(); @@ -541,7 +543,7 @@ describe('scanner:run', function () { .it('--json flag wraps message about no violations occuring', ctx => { const output = JSON.parse(ctx.stdout); expect(output.status).to.equal(0, 'Should have finished properly'); - expect(output.result).to.contain(runMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); + expect(output.result).to.contain(processorMessages.getMessage('output.noViolationsDetected', ['pmd, retire-js'])); }); }); @@ -616,19 +618,19 @@ describe('scanner:run', function () { setupCommandTest .command(['scanner:run', '--target', 'path/that/does/not/matter', '--ruleset', 'ApexUnit', '--outfile', 'NotAValidFileName']) .it('Error thrown when output file is malformed', ctx => { - expect(ctx.stderr).to.contain(`ERROR running scanner:run: ${runMessages.getMessage('validations.outfileMustBeValid')}`); + expect(ctx.stderr).to.contain(`ERROR running scanner:run: ${commonMessages.getMessage('validations.outfileMustBeValid')}`); }); setupCommandTest .command(['scanner:run', '--target', 'path/that/does/not/matter', '--ruleset', 'ApexUnit', '--outfile', 'badtype.pdf']) .it('Error thrown when output file is unsupported type', ctx => { - expect(ctx.stderr).to.contain(`ERROR running scanner:run: ${runMessages.getMessage('validations.outfileMustBeSupportedType')}`); + expect(ctx.stderr).to.contain(`ERROR running scanner:run: ${commonMessages.getMessage('validations.outfileMustBeSupportedType')}`); }); setupCommandTest .command(['scanner:run', '--target', 'path/that/does/not/matter', '--format', 'csv', '--outfile', 'notcsv.xml']) .it('Warning logged when output file format does not match format', ctx => { - expect(ctx.stdout).to.contain(runMessages.getMessage('validations.outfileFormatMismatch', ['csv', 'xml'])); + expect(ctx.stdout).to.contain(commonMessages.getMessage('validations.outfileFormatMismatch', ['csv', 'xml'])); }); }); }); @@ -754,12 +756,12 @@ describe('scanner:run', function () { // there are five retries in addition to the initial run. // Note: Please keep this up-to-date. It will make it way easier to debug if needed. // The following categories are implicitly included: - // - 11 PMD categories + // - 8 PMD categories // - 3 ESLint categories // - 3 ESLint-Typescript categories // - 1 RetireJS category - // For a total of 18 - expect(implicitMessages || []).to.have.lengthOf(18, `Entries for implicitly added categories from all engines:\n ${JSON.stringify(implicitMessages)}`); + // For a total of 15 + expect(implicitMessages || []).to.have.lengthOf(15, `Entries for implicitly added categories from all engines:\n ${JSON.stringify(implicitMessages)}`); // TODO: revisit test, should be improved because of issue above }); }); diff --git a/test/lib/Controller.test.ts b/test/lib/Controller.test.ts index 98f1a5c1f..e49c56f98 100644 --- a/test/lib/Controller.test.ts +++ b/test/lib/Controller.test.ts @@ -13,127 +13,147 @@ describe('Controller.ts tests', () => { TestOverrides.initializeTestSetup(); }); - it('getAllEngines returns enabled/disabled engines', async() => { - const engines: RuleEngine[] = await Controller.getAllEngines(); - const names: string[] = engines.map(e => e.getName()); - - expect(engines.length, names + '').to.equal(9); - expect(names).to.contain(ENGINE.ESLINT); - expect(names).to.contain(ENGINE.ESLINT_LWC); - expect(names).to.contain(ENGINE.ESLINT_TYPESCRIPT); - expect(names).to.contain(ENGINE.ESLINT_CUSTOM); - expect(names).to.contain(ENGINE.PMD); - expect(names).to.contain(ENGINE.PMD_CUSTOM); - expect(names).to.contain(ENGINE.RETIRE_JS); - expect(names).to.contain(ENGINE.CPD); - expect(names).to.contain(ENGINE.SFGE); + describe('#getAllEngines()', () => { + it('Returns literally all engines', async () => { + const engines: RuleEngine[] = await Controller.getAllEngines(); + const names: string[] = engines.map(e => e.constructor.name); + + expect(engines.length, names + '').to.equal(10); + expect(names).to.contain('JavascriptEslintEngine'); + expect(names).to.contain('LWCEslintEngine'); + expect(names).to.contain('TypescriptEslintEngine'); + expect(names).to.contain('CustomEslintEngine'); + expect(names).to.contain('PmdEngine'); + expect(names).to.contain('CustomPmdEngine'); + expect(names).to.contain('RetireJsEngine'); + expect(names).to.contain('CpdEngine'); + expect(names).to.contain('SfgeDfaEngine'); + expect(names).to.contain('SfgePathlessEngine'); + }); }); - it('getEnabledEngines returns only non-custom enabled engines when engineOptions is empty', async() => { - const engines: RuleEngine[] = await Controller.getEnabledEngines(); - const names: string[] = engines.map(e => e.getName()); - - expect(engines.length).to.equal(5); - expect(names).to.contain(ENGINE.ESLINT); - expect(names).to.contain(ENGINE.ESLINT_TYPESCRIPT); - expect(names).to.contain(ENGINE.PMD); - expect(names).to.contain(ENGINE.RETIRE_JS); - expect(names).to.contain(ENGINE.SFGE); - }); - - it('getEnabledEngines returns PMD_CUSTOM when engineOptions contains pmdconfig', async () => { - const engineOptions = new Map([ - [CUSTOM_CONFIG.PmdConfig, "/some/path"] - ]); - - const engines: RuleEngine[] = await Controller.getEnabledEngines(engineOptions); - const names: string[] = engines.map(e => e.getName()); - - expect(engines.length).to.equal(5); - expect(names).to.contain(ENGINE.ESLINT); - expect(names).to.contain(ENGINE.ESLINT_TYPESCRIPT); - expect(names).to.contain(ENGINE.PMD_CUSTOM); - expect(names).to.contain(ENGINE.RETIRE_JS); - expect(names).to.contain(ENGINE.SFGE); - }); - - it('getEnabledEngines returns ESLINT_CUSTOM when engineOptions contains eslintconfig', async () => { - const engineOptions = new Map([ - [CUSTOM_CONFIG.EslintConfig, "/some/path"] - ]); - - const engines: RuleEngine[] = await Controller.getEnabledEngines(engineOptions); - const names: string[] = engines.map(e => e.getName()); - - expect(engines.length).to.equal(4); - expect(names).to.contain(ENGINE.ESLINT_CUSTOM); - expect(names).to.contain(ENGINE.PMD); - expect(names).to.contain(ENGINE.RETIRE_JS); - expect(names).to.contain(ENGINE.SFGE); - }); - - it('getEnabledEngines returns PMD_CUSTOM, ESLINT_CUSTOM when engineOptions contains pmdconfig and eslintconfig', async () => { - const engineOptions = new Map([ - [CUSTOM_CONFIG.EslintConfig, "/some/path"], - [CUSTOM_CONFIG.PmdConfig, "/some/other/path"] - ]); - - const engines: RuleEngine[] = await Controller.getEnabledEngines(engineOptions); - const names: string[] = engines.map(e => e.getName()); - - expect(engines.length).to.equal(4); - expect(names).to.contain(ENGINE.ESLINT_CUSTOM); - expect(names).to.contain(ENGINE.PMD_CUSTOM); - expect(names).to.contain(ENGINE.RETIRE_JS); - expect(names).to.contain(ENGINE.SFGE); - }); - - it('getFilteredEngines filters and includes disabled', async() => { - const engines: RuleEngine[] = await Controller.getFilteredEngines([ENGINE.ESLINT, ENGINE.ESLINT_LWC, ENGINE.PMD]); - const names: string[] = engines.map(e => e.getName()); - - expect(engines.length).to.equal(3); - expect(names).to.contain(ENGINE.ESLINT); - expect(names).to.contain(ENGINE.ESLINT_LWC); - expect(names).to.contain(ENGINE.PMD); - }); - - it('getFilteredEngines uses custom config information to choose the correct instance', async() => { - const engineOptionsWithPmdCustom = new Map([ - [CUSTOM_CONFIG.PmdConfig, '/some/path/to/config'] - ]); - const engines: RuleEngine[] = await Controller.getFilteredEngines([ENGINE.PMD], engineOptionsWithPmdCustom); - const names: string[] = engines.map(e => e.getName()); - - expect(engines.length).to.equal(1); - expect(names).to.contain(ENGINE.PMD_CUSTOM); - }); - - it('getEnabledEngines throws exception when no engines are found', async() => { - // Create a single mocked engine that is disabled - const mockedRuleEngine: RuleEngine = mock(); - when(mockedRuleEngine.getName).thenReturn(() => 'fake-engine'); - when(mockedRuleEngine.isEnabled).thenReturn(() => Promise.resolve(false)); - const ruleEngine: RuleEngine = instance(mockedRuleEngine); - - // Remove everything else from the container and register the mock engine - container.reset(); - container.registerInstance(Services.RuleEngine, ruleEngine); - - try { - await Controller.getEnabledEngines(); - fail('getEnabledEngines should have thrown'); - } catch (e) { - expect(e.message).to.equal('You must enable at least one engine. Your currently disabled engines are: fake-engine.'); - } + describe('#getEnabledEngines()', () => { + it('When engineOptions is empty, returns only engines that are non-custom, enabled, and requested-by-default', async () => { + const engines: RuleEngine[] = await Controller.getEnabledEngines(); + const names: string[] = engines.map(e => e.constructor.name); + + expect(engines.length).to.equal(5); + expect(names).to.contain('JavascriptEslintEngine'); + expect(names).to.contain('TypescriptEslintEngine'); + expect(names).to.contain('PmdEngine'); + expect(names).to.contain('RetireJsEngine'); + expect(names).to.contain('SfgeDfaEngine'); + }); + + it('When engineOptions includes custom pmd config, PmdCustomEngine is included', async () => { + const engineOptions = new Map([ + [CUSTOM_CONFIG.PmdConfig, "/some/path"] + ]); + + const engines: RuleEngine[] = await Controller.getEnabledEngines(engineOptions); + const names: string[] = engines.map(e => e.constructor.name); + + expect(engines.length).to.equal(5); + expect(names).to.contain('JavascriptEslintEngine'); + expect(names).to.contain('TypescriptEslintEngine'); + expect(names).to.contain('CustomPmdEngine'); + expect(names).to.contain('RetireJsEngine'); + expect(names).to.contain('SfgeDfaEngine'); + }); + + it('When engineOptions includes custom eslint config, CustomEslintEngine is included', async () => { + const engineOptions = new Map([ + [CUSTOM_CONFIG.EslintConfig, "/some/path"] + ]); + + const engines: RuleEngine[] = await Controller.getEnabledEngines(engineOptions); + const names: string[] = engines.map(e => e.constructor.name); + + expect(engines.length).to.equal(4); + expect(names).to.contain('CustomEslintEngine'); + expect(names).to.contain('PmdEngine'); + expect(names).to.contain('RetireJsEngine'); + expect(names).to.contain('SfgeDfaEngine'); + }); + + it('When engineOptions includes both custom pmd and custom eslint configs, both custom engines are included', async () => { + const engineOptions = new Map([ + [CUSTOM_CONFIG.EslintConfig, "/some/path"], + [CUSTOM_CONFIG.PmdConfig, "/some/other/path"] + ]); + + const engines: RuleEngine[] = await Controller.getEnabledEngines(engineOptions); + const names: string[] = engines.map(e => e.constructor.name); + + expect(engines.length).to.equal(4); + expect(names).to.contain('CustomEslintEngine'); + expect(names).to.contain('CustomPmdEngine'); + expect(names).to.contain('RetireJsEngine'); + expect(names).to.contain('SfgeDfaEngine'); + }); + + it('When no engines are found, error is thrown', async () => { + // Create a single mocked engine that is disabled + const mockedRuleEngine: RuleEngine = mock(); + when(mockedRuleEngine.getName).thenReturn(() => 'fake-engine'); + when(mockedRuleEngine.isEnabled).thenReturn(() => Promise.resolve(false)); + const ruleEngine: RuleEngine = instance(mockedRuleEngine); + + // Remove everything else from the container and register the mock engine + container.reset(); + container.registerInstance(Services.RuleEngine, ruleEngine); + + try { + await Controller.getEnabledEngines(); + fail('getEnabledEngines should have thrown'); + } catch (e) { + expect(e.message).to.equal('You must enable at least one engine. Your currently disabled engines are: fake-engine.'); + } + }); }); - it('getFilteredEngines throws exception when no engines are found', async() => { - try { - await Controller.getFilteredEngines(['invalid-engine']); - fail('getFilteredEngines should have thrown'); - } catch (e) { - expect(e.message).to.equal(`The filter doesn't match any engines. Filter 'invalid-engine'. Engines: cpd, eslint, eslint-lwc, eslint-typescript, pmd, retire-js, sfge.`); - } + describe('#getFilteredEngines()', () => { + it('If no filtering is provided, only requested-by-default engines are returned', async () => { + const engines: RuleEngine[] = await Controller.getFilteredEngines([]); + const names: string[] = engines.map(e => e.constructor.name); + + expect(engines.length).to.equal(6); + expect(names).to.contain('JavascriptEslintEngine'); + expect(names).to.contain('LWCEslintEngine'); + expect(names).to.contain('TypescriptEslintEngine'); + expect(names).to.contain('PmdEngine'); + expect(names).to.contain('RetireJsEngine'); + expect(names).to.contain('SfgeDfaEngine'); + }) + + it('Even a disabled engine is included when explicitly requested', async () => { + const engines: RuleEngine[] = await Controller.getFilteredEngines([ENGINE.ESLINT, ENGINE.ESLINT_LWC, ENGINE.PMD]); + const names: string[] = engines.map(e => e.getName()); + + expect(engines.length).to.equal(3); + expect(names).to.contain(ENGINE.ESLINT); + expect(names).to.contain(ENGINE.ESLINT_LWC); + expect(names).to.contain(ENGINE.PMD); + }); + + it('When custom config information is provided, the correct instance is returned', async () => { + const engineOptionsWithPmdCustom = new Map([ + [CUSTOM_CONFIG.PmdConfig, '/some/path/to/config'] + ]); + const engines: RuleEngine[] = await Controller.getFilteredEngines([ENGINE.PMD], engineOptionsWithPmdCustom); + const names: string[] = engines.map(e => e.getName()); + + expect(engines.length).to.equal(1); + expect(names).to.contain(ENGINE.PMD_CUSTOM); + }); + + it('When no engines are found, exception is thrown', async () => { + try { + await Controller.getFilteredEngines(['invalid-engine']); + fail('getFilteredEngines should have thrown'); + } catch (e) { + expect(e.message).to.equal(`The filter doesn't match any engines. Filter 'invalid-engine'. Engines: cpd, eslint, eslint-lwc, eslint-typescript, pmd, retire-js, sfge.`); + } + }); }); }); diff --git a/test/lib/RuleManager.test.ts b/test/lib/RuleManager.test.ts index 3472fe855..4b7a9c1c9 100644 --- a/test/lib/RuleManager.test.ts +++ b/test/lib/RuleManager.test.ts @@ -15,7 +15,7 @@ import {RuleCatalog} from '../../src/lib/services/RuleCatalog'; import {RuleEngine} from '../../src/lib/services/RuleEngine'; import {RetireJsEngine} from '../../src/lib/retire-js/RetireJsEngine'; -import {SfgeEngine} from '../../src/lib/sfge/SfgeEngine'; +import {SfgeDfaEngine} from '../../src/lib/sfge/SfgeDfaEngine'; import * as TestOverrides from '../test-related-lib/TestOverrides'; import * as TestUtils from '../TestUtils'; @@ -552,7 +552,7 @@ describe('RuleManager', () => { it('Positive method-level targets are properly matched', async () => { // All tests will use the SFGE engine, since method-level targeting is intended for that engine anyway. - const engine = new SfgeEngine(); + const engine = new SfgeDfaEngine(); await engine.init(); // Targets are all going to be normalized Unix paths, some of which also specify individual methods. diff --git a/test/lib/sfge/SfgeEngine.test.ts b/test/lib/sfge/SfgeDfaEngine.test.ts similarity index 79% rename from test/lib/sfge/SfgeEngine.test.ts rename to test/lib/sfge/SfgeDfaEngine.test.ts index eb7749e17..5ab51a5ae 100644 --- a/test/lib/sfge/SfgeEngine.test.ts +++ b/test/lib/sfge/SfgeDfaEngine.test.ts @@ -3,20 +3,20 @@ import {RuleResult} from '../../../src/types'; import path = require('path'); import fs = require('fs'); import {expect} from 'chai'; -import {SfgeEngine} from '../../../src/lib/sfge/SfgeEngine'; +import {SfgeDfaEngine} from '../../../src/lib/sfge/SfgeDfaEngine'; import * as TestOverrides from '../../test-related-lib/TestOverrides'; TestOverrides.initializeTestSetup(); -class TestableSfgeEngine extends SfgeEngine { +class TestableSfgeEngine extends SfgeDfaEngine { public processStdout(output: string): RuleResult[] { return super.processStdout(output); } } -describe('SfgeEngine', () => { +describe('SfgeDfaEngine', () => { describe('#processStdout()', () => { - it('When Sfge finds violations, they are converted into RuleResult objects', async () => { + it('When GraphEngine finds violations, they are converted into RuleResult objects', async () => { // ==== SETUP ==== const testEngine = new TestableSfgeEngine(); await testEngine.init(); @@ -29,7 +29,7 @@ describe('SfgeEngine', () => { expect(results.length).to.equal(1, 'Should be results'); }); - it('When sfge finds no violations, results are empty', async () => { + it('When GraphEngine finds no violations, results are empty', async () => { // ==== SETUP ==== const testEngine = new TestableSfgeEngine(); await testEngine.init(); diff --git a/test/lib/sfge/SfgePathlessEngine.test.ts b/test/lib/sfge/SfgePathlessEngine.test.ts new file mode 100644 index 000000000..75c6a2512 --- /dev/null +++ b/test/lib/sfge/SfgePathlessEngine.test.ts @@ -0,0 +1,120 @@ +import 'reflect-metadata'; +import {expect} from 'chai'; +import {Messages} from '@salesforce/core'; +import {SfgeConfig} from '../../../src/types'; +import {CUSTOM_CONFIG} from '../../../src/Constants'; +import {SfgePathlessEngine} from '../../../src/lib/sfge/SfgePathlessEngine'; +import * as TestOverrides from '../../test-related-lib/TestOverrides'; + +TestOverrides.initializeTestSetup(); + +Messages.importMessagesDirectory(__dirname); +const messages = Messages.loadMessages('@salesforce/sfdx-scanner', 'SfgeEngine'); + +describe('SfgePathlessEngine', () => { + describe('#isEngineRequested()', () => { + it('Pathless SFGE counts as requested when explicitly requested', async () => { + // ==== SETUP ==== + const engine = new SfgePathlessEngine(); + await engine.init(); + const filteredNames = ['sfge', 'retire-js']; + // ==== TESTED METHOD ==== + const isEngineRequested = engine.isEngineRequested(filteredNames, new Map()); + // ==== ASSERTIONS ==== + expect(isEngineRequested).to.be.true; + }); + + // NOTE: This behavior is based on the in-progress nature of many of this engine's rules. + // If/when we are confident enough to make this a requested-by-default engine, this test must change. + it('Pathless SFGE does not count as requested-by-default', async () => { + // ==== SETUP ==== + const engine = new SfgePathlessEngine(); + await engine.init(); + // An empty array means that no engines were explicitly requested. + const filteredNames = []; + // ==== TESTED METHOD ==== + const isEngineRequested = engine.isEngineRequested(filteredNames, new Map()); + // ==== ASSERTIONS ==== + // Since there were no explicitly requested engines, this engine + // should be excluded, since it's not requested-by-default. + expect(isEngineRequested).to.be.false; + }); + + it('Pathless SFGE does not count as requested when explicitly not requested', async () => { + // ==== SETUP ==== + const engine = new SfgePathlessEngine(); + await engine.init(); + const filteredNames = ['pmd', 'retire-js']; + // ==== TESTED METHOD ==== + const isEngineRequested = engine.isEngineRequested(filteredNames, new Map()); + // ==== ASSERTIONS ==== + // Since we requested specific engines, and sfge isn't one of them, + // the engine should be excluded. + expect(isEngineRequested).to.be.false; + }); + }); + + describe('#shouldEngineRun()', () => { + it('Returns true when SfgeConfig has non-empty projectdirs array', async () => { + // ==== SETUP ==== + const engine = new SfgePathlessEngine(); + await engine.init(); + const sfgeConfig: SfgeConfig = { + projectDirs: ['specific/value/is/irrelevant'] + }; + const engineOptions: Map = new Map(); + engineOptions.set(CUSTOM_CONFIG.SfgeConfig, JSON.stringify(sfgeConfig)); + // ==== TESTED METHOD ==== + // The only parameter that matters should be the engine options. + const shouldEngineRun = engine.shouldEngineRun([], [], [], engineOptions); + // ==== ASSERTIONS ==== + expect(shouldEngineRun).to.be.true; + }); + + it('Throws error when SfgeConfig has empty projectdirs array', async () => { + // ==== SETUP ==== + const engine = new SfgePathlessEngine(); + await engine.init(); + const sfgeConfig: SfgeConfig = { + projectDirs: [] + }; + const engineOptions: Map = new Map(); + engineOptions.set(CUSTOM_CONFIG.SfgeConfig, JSON.stringify(sfgeConfig)); + // ==== TESTED METHOD ==== + const invocationOfShouldEngineRun = () => { + // The only parameter that matters should be the engine options. + return engine.shouldEngineRun([], [], [], engineOptions); + }; + // ==== ASSERTIONS ==== + expect(invocationOfShouldEngineRun).to.throw(messages.getMessage('errors.failedWithoutProjectDir', [])); + }); + + it('Throws error when SfgeConfig lacks projectdirs array', async () => { + // ==== SETUP ==== + const engine = new SfgePathlessEngine(); + await engine.init(); + const engineOptions: Map = new Map(); + engineOptions.set(CUSTOM_CONFIG.SfgeConfig, "{}"); + // ==== TESTED METHOD ==== + const invocationOfShouldEngineRun = () => { + // The only parameter that matters should be the engine options. + return engine.shouldEngineRun([], [], [], engineOptions); + }; + // ==== ASSERTIONS ==== + expect(invocationOfShouldEngineRun).to.throw(messages.getMessage('errors.failedWithoutProjectDir', [])); + }); + + it('Throws error when SfgeConfig is outright absent', async () => { + // ==== SETUP ==== + const engine = new SfgePathlessEngine(); + await engine.init(); + // ==== TESTED METHOD ==== + const invocationOfShouldEngineRun = () => { + // The only parameter that matters should be the engine options. + return engine.shouldEngineRun([], [], [], new Map()); + }; + // ==== ASSERTIONS ==== + expect(invocationOfShouldEngineRun).to.throw(messages.getMessage('errors.failedWithoutProjectDir', [])); + }); + }); +}); diff --git a/test/lib/util/RunOutputProcessor.test.ts b/test/lib/util/RunOutputProcessor.test.ts index 61d63537e..bfff52f8d 100644 --- a/test/lib/util/RunOutputProcessor.test.ts +++ b/test/lib/util/RunOutputProcessor.test.ts @@ -12,7 +12,7 @@ import Sinon = require('sinon'); import fs = require('fs'); Messages.importMessagesDirectory(__dirname); -const runMessages = Messages.loadMessages('@salesforce/sfdx-scanner', 'run'); +const processorMessages = Messages.loadMessages('@salesforce/sfdx-scanner', 'RunOutputProcessor'); const FAKE_SUMMARY_MAP: Map = new Map(); FAKE_SUMMARY_MAP.set('pmd', {fileCount: 1, violationCount: 1}); @@ -144,7 +144,7 @@ describe('RunOutputProcessor', () => { const output: AnyJson = rop.processRunOutput(fakeRes); // We expect that the message logged to the console and the message returned should both be the default - const expectedMsg = runMessages.getMessage('output.noViolationsDetected', ['pmd, eslint']); + const expectedMsg = processorMessages.getMessage('output.noViolationsDetected', ['pmd, eslint']); Sinon.assert.callCount(logSpy, 1); Sinon.assert.callCount(tableSpy, 0); Sinon.assert.calledWith(logSpy, expectedMsg); @@ -163,9 +163,9 @@ describe('RunOutputProcessor', () => { // THIS IS THE PART BEING TESTED. const output: AnyJson = rop.processRunOutput(fakeTableResults); - const expectedTableSummary = `${runMessages.getMessage('output.engineSummaryTemplate', ['pmd', 1, 1])} -${runMessages.getMessage('output.engineSummaryTemplate', ['eslint-typescript', 2, 1])} -${runMessages.getMessage('output.writtenToConsole')}`; + const expectedTableSummary = `${processorMessages.getMessage('output.engineSummaryTemplate', ['pmd', 1, 1])} +${processorMessages.getMessage('output.engineSummaryTemplate', ['eslint-typescript', 2, 1])} +${processorMessages.getMessage('output.writtenToConsole')}`; Sinon.assert.callCount(tableSpy, 1); Sinon.assert.calledWith(tableSpy, FAKE_TABLE_OUTPUT.rows, FAKE_TABLE_OUTPUT.columns); @@ -189,10 +189,10 @@ ${runMessages.getMessage('output.writtenToConsole')}`; Sinon.assert.callCount(tableSpy, 1); Sinon.assert.calledWith(tableSpy, FAKE_TABLE_OUTPUT.rows, FAKE_TABLE_OUTPUT.columns); Sinon.assert.callCount(logSpy, 0); - const expectedTableSummary = `${runMessages.getMessage('output.engineSummaryTemplate', ['pmd', 1, 1])} -${runMessages.getMessage('output.engineSummaryTemplate', ['eslint-typescript', 2, 1])} -${runMessages.getMessage('output.sevThresholdSummary', [1])} -${runMessages.getMessage('output.writtenToConsole')}`; + const expectedTableSummary = `${processorMessages.getMessage('output.engineSummaryTemplate', ['pmd', 1, 1])} +${processorMessages.getMessage('output.engineSummaryTemplate', ['eslint-typescript', 2, 1])} +${processorMessages.getMessage('output.sevThresholdSummary', [1])} +${processorMessages.getMessage('output.writtenToConsole')}`; expect(e.message).to.equal(expectedTableSummary, 'Exception message incorrectly formed'); } }); @@ -236,7 +236,7 @@ ${runMessages.getMessage('output.writtenToConsole')}`; Sinon.assert.callCount(tableSpy, 0); Sinon.assert.callCount(logSpy, 1); Sinon.assert.calledWith(logSpy, FAKE_CSV_OUTPUT); - expect(e.message).to.equal(runMessages.getMessage('output.sevThresholdSummary', [2]), 'Exception message incorrectly formed'); + expect(e.message).to.equal(processorMessages.getMessage('output.sevThresholdSummary', [2]), 'Exception message incorrectly formed'); } }); @@ -280,7 +280,7 @@ ${runMessages.getMessage('output.writtenToConsole')}`; Sinon.assert.callCount(tableSpy, 0); Sinon.assert.callCount(logSpy, 1); Sinon.assert.calledWith(logSpy, FAKE_JSON_OUTPUT); - expect(e.message).to.equal(runMessages.getMessage('output.sevThresholdSummary', [1]), 'Exception message incorrectly formed'); + expect(e.message).to.equal(processorMessages.getMessage('output.sevThresholdSummary', [1]), 'Exception message incorrectly formed'); } }); }); @@ -303,7 +303,7 @@ ${runMessages.getMessage('output.writtenToConsole')}`; const output: AnyJson = rop.processRunOutput(fakeRes); // We expect that the message logged to the console and the message returned should both be the default - const expectedMsg = runMessages.getMessage('output.noViolationsDetected', ['pmd, eslint']); + const expectedMsg = processorMessages.getMessage('output.noViolationsDetected', ['pmd, eslint']); Sinon.assert.callCount(logSpy, 1); Sinon.assert.callCount(tableSpy, 0); Sinon.assert.calledWith(logSpy, expectedMsg); @@ -325,9 +325,9 @@ ${runMessages.getMessage('output.writtenToConsole')}`; // THIS IS THE PART BEING TESTED. const output: AnyJson = rop.processRunOutput(fakeCsvResults); - const expectedCsvSummary = `${runMessages.getMessage('output.engineSummaryTemplate', ['pmd', 1, 1])} -${runMessages.getMessage('output.engineSummaryTemplate', ['eslint-typescript', 2, 1])} -${runMessages.getMessage('output.writtenToOutFile', [fakeFilePath])}`; + const expectedCsvSummary = `${processorMessages.getMessage('output.engineSummaryTemplate', ['pmd', 1, 1])} +${processorMessages.getMessage('output.engineSummaryTemplate', ['eslint-typescript', 2, 1])} +${processorMessages.getMessage('output.writtenToOutFile', [fakeFilePath])}`; Sinon.assert.callCount(tableSpy, 0); Sinon.assert.callCount(logSpy, 1); Sinon.assert.calledWith(logSpy, expectedCsvSummary); @@ -354,10 +354,10 @@ ${runMessages.getMessage('output.writtenToOutFile', [fakeFilePath])}`; Sinon.assert.callCount(logSpy, 0); expect(fakeFiles.length).to.equal(1, 'Should have tried to create one file'); expect(fakeFiles[0]).to.deep.equal({path: fakeFilePath, data: FAKE_CSV_OUTPUT}, 'File-write expectations defied'); - const expectedCsvSummary = `${runMessages.getMessage('output.engineSummaryTemplate', ['pmd', 1, 1])} -${runMessages.getMessage('output.engineSummaryTemplate', ['eslint-typescript', 2, 1])} -${runMessages.getMessage('output.sevThresholdSummary', [1])} -${runMessages.getMessage('output.writtenToOutFile', [fakeFilePath])}`; + const expectedCsvSummary = `${processorMessages.getMessage('output.engineSummaryTemplate', ['pmd', 1, 1])} +${processorMessages.getMessage('output.engineSummaryTemplate', ['eslint-typescript', 2, 1])} +${processorMessages.getMessage('output.sevThresholdSummary', [1])} +${processorMessages.getMessage('output.writtenToOutFile', [fakeFilePath])}`; expect(e.message).to.equal(expectedCsvSummary, 'Summary was wrong'); } }); diff --git a/yarn.lock b/yarn.lock index 27f4cbcfd..a9b5191ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2396,10 +2396,10 @@ find-cache-dir@^3.2.0: make-dir "^3.0.2" pkg-dir "^4.1.0" -find-java-home@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/find-java-home/-/find-java-home-1.1.0.tgz#ec3e00c9028b8bc538d8154f52008c88ae69d55d" - integrity sha512-bSTCKNZ193UM/+ZZoNDzICAEHcVywovkhsWCkZALjCvRXQ+zXTe/XATrrP4CpxkaP6YFhQJOpyRpH0P2U/woDA== +find-java-home@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/find-java-home/-/find-java-home-2.0.0.tgz#9073a14df6f74b951f4f7c2af88cf005bc059737" + integrity sha512-m4Cf5WM5Y9UupofsLgcJuY5oFXVfVnfHx43pg3HJoVdtY2PN4Wfs7ex9svf7W7eLTP+6wmyBToaqGOCffHBHVA== dependencies: which "~1.0.5" winreg "~1.2.2"