diff --git a/packages/code-analyzer-regex-engine/src/config.ts b/packages/code-analyzer-regex-engine/src/config.ts index 21d94556..4fe205c1 100644 --- a/packages/code-analyzer-regex-engine/src/config.ts +++ b/packages/code-analyzer-regex-engine/src/config.ts @@ -55,7 +55,7 @@ export type RegexRule = { export const FILE_EXT_PATTERN: RegExp = /^[.][a-zA-Z0-9]+$/; export const RULE_NAME_PATTERN: RegExp = /^[A-Za-z@][A-Za-z_0-9@\-/]*$/; -export const REGEX_STRING_PATTERN: RegExp = /^\/(.*?)\/(.*)$/; +export const REGEX_STRING_PATTERN: RegExp = /^\/(.*)\/(.*)$/; export const DEFAULT_TAGS: string[] = ['Recommended']; export const DEFAULT_SEVERITY_LEVEL: SeverityLevel = SeverityLevel.Moderate; diff --git a/packages/code-analyzer-regex-engine/src/engine.ts b/packages/code-analyzer-regex-engine/src/engine.ts index 96991d44..4e2a2c85 100644 --- a/packages/code-analyzer-regex-engine/src/engine.ts +++ b/packages/code-analyzer-regex-engine/src/engine.ts @@ -26,10 +26,12 @@ export class RegexEngine extends Engine { static readonly NAME = "regex"; readonly regexRules: RegexRules; private readonly textFilesCache: Map = new Map(); + private readonly ruleResourceUrls: Map; - constructor(regexRules: RegexRules) { + constructor(regexRules: RegexRules, ruleResourceUrls: Map) { super(); this.regexRules = regexRules; + this.ruleResourceUrls = ruleResourceUrls } getName(): string { @@ -47,7 +49,7 @@ export class RegexEngine extends Engine { const ruleDescriptions: RuleDescription[] = []; for (const [ruleName, regexRule] of Object.entries(this.regexRules)) { if (!textFiles || textFiles.some(fileName => this.shouldScanFile(fileName, ruleName))){ - ruleDescriptions.push(toRuleDescription(ruleName, regexRule)); + ruleDescriptions.push(this.toRuleDescription(ruleName, regexRule)); } } return ruleDescriptions; @@ -64,6 +66,17 @@ export class RegexEngine extends Engine { return workspaceTextFiles; } + private toRuleDescription(ruleName: string, regexRule: RegexRule): RuleDescription { + return { + name: ruleName, + severityLevel: regexRule.severity, + type: RuleType.Standard, + tags: regexRule.tags, + description: regexRule.description, + resourceUrls: this.ruleResourceUrls.get(ruleName) ?? [] + } + } + async runRules(ruleNames: string[], runOptions: RunOptions): Promise { const textFiles: string[] = await this.getTextFiles(runOptions.workspace); const ruleRunPromises: Promise[] = textFiles.map(file => this.runRulesForFile(file, ruleNames)); @@ -79,8 +92,8 @@ export class RegexEngine extends Engine { } private shouldScanFile(fileName: string, ruleName: string): boolean { - const ext = path.extname(fileName).toLowerCase(); - const fileExtensions = this.regexRules[ruleName].file_extensions; + const ext: string = path.extname(fileName).toLowerCase(); + const fileExtensions: string[] | undefined = this.regexRules[ruleName].file_extensions; return !fileExtensions || fileExtensions.includes(ext); } @@ -151,15 +164,4 @@ type AsyncFilterFnc = (value: T) => Promise; async function filterAsync(array: T[], filterFcn: AsyncFilterFnc): Promise { const mask: boolean[] = await Promise.all(array.map(filterFcn)); return array.filter((_, index) => mask[index]); -} - -function toRuleDescription(ruleName: string, regexRule: RegexRule): RuleDescription { - return { - name: ruleName, - severityLevel: regexRule.severity, - type: RuleType.Standard, - tags: regexRule.tags, - description: regexRule.description, - resourceUrls: [] // Empty for now. We might allow users to add in resourceUrls if we see a valid use case in the future. - } } \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/src/messages.ts b/packages/code-analyzer-regex-engine/src/messages.ts index cd6db15a..f18d7446 100644 --- a/packages/code-analyzer-regex-engine/src/messages.ts +++ b/packages/code-analyzer-regex-engine/src/messages.ts @@ -10,6 +10,18 @@ const MESSAGE_CATALOG : { [key: string]: string } = { TrailingWhitespaceRuleMessage: `Found trailing whitespace at the end of a line of code.`, + AvoidTermsWithImplicitBiasRuleDescription: + `"Detects usage of terms that reinforce implicit bias.`, + + AvoidTermsWithImplicitBiasRuleMessage: + `A term with implicit bias was found. Avoid using any of the following terms: %s`, + + AvoidOldSalesforceApiVersionsRuleDescription: + `Detects usages of Salesforce API versions that are 3 or more years old.`, + + AvoidOldSalesforceApiVersionsRuleMessage: + `Found the use of a Salesforce API version that is 3 or more years old. Avoid using an API version that is <= %d.0.`, + RuleViolationMessage: `A match of the regular expression %s was found for rule '%s': %s`, diff --git a/packages/code-analyzer-regex-engine/src/plugin.ts b/packages/code-analyzer-regex-engine/src/plugin.ts index 253aaa10..138b8b96 100644 --- a/packages/code-analyzer-regex-engine/src/plugin.ts +++ b/packages/code-analyzer-regex-engine/src/plugin.ts @@ -1,20 +1,21 @@ import {ConfigObject, Engine, EnginePluginV1, SeverityLevel,} from "@salesforce/code-analyzer-engine-api"; import {getMessage} from "./messages"; import {RegexEngine} from "./engine"; -import {RegexEngineConfig, RegexRules, validateAndNormalizeConfig} from "./config"; - -export const BASE_REGEX_RULES: RegexRules = { - NoTrailingWhitespace: { - regex: /[ \t]+((?=\r?\n)|(?=$))/g, - file_extensions: ['.cls', '.trigger'], - description: getMessage('TrailingWhitespaceRuleDescription'), - violation_message: getMessage('TrailingWhitespaceRuleMessage'), - severity: SeverityLevel.Info, - tags: ['Recommended', 'CodeStyle'] - } -} +import {RegexEngineConfig, RegexRule, RegexRules, validateAndNormalizeConfig} from "./config"; +import {Clock, RealClock} from "./utils"; + +export const RULE_RESOURCE_URLS: Map = new Map([ + ['AvoidTermsWithImplicitBias',['https://www.salesforce.com/news/stories/salesforce-updates-technical-language-in-ongoing-effort-to-address-implicit-bias/']] +]); +export const TERMS_WITH_IMPLICIT_BIAS: string[] = ['whitelist', 'blacklist', 'brownout', 'blackout', 'slave']; export class RegexEnginePlugin extends EnginePluginV1 { + private clock: Clock = new RealClock(); + + // For testing purposes only: + _setClock(clock: Clock): void { + this.clock = clock; + } getAvailableEngineNames(): string[] { return [RegexEngine.NAME]; @@ -26,9 +27,71 @@ export class RegexEnginePlugin extends EnginePluginV1 { } const config: RegexEngineConfig = validateAndNormalizeConfig(rawConfig); const allRules: RegexRules = { - ... BASE_REGEX_RULES, + ... createBaseRegexRules(this.clock.now()), ... config.custom_rules } - return new RegexEngine(allRules); + return new RegexEngine(allRules, RULE_RESOURCE_URLS); + } +} + +export function createBaseRegexRules(now: Date) { + return { + NoTrailingWhitespace: { + regex: /[ \t]+((?=\r?\n)|(?=$))/g, + file_extensions: ['.cls', '.trigger'], + description: getMessage('TrailingWhitespaceRuleDescription'), + violation_message: getMessage('TrailingWhitespaceRuleMessage'), + severity: SeverityLevel.Info, + tags: ['Recommended', 'CodeStyle'] + }, + AvoidTermsWithImplicitBias: { + regex: /\b(((black|white)\s*list\w*)|((black|brown)\s*out\w*)|(slaves?\b))/gi, + description: getMessage('AvoidTermsWithImplicitBiasRuleDescription'), + violation_message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + severity: SeverityLevel.Info, + tags: ['Recommended'] + }, + AvoidOldSalesforceApiVersions: createAvoidOldSalesforceApiVersionsRule(now) + } +} + +function createAvoidOldSalesforceApiVersionsRule(now: Date): RegexRule { + const apiVersionFromThreeYearsAgo: number = getSalesforceApiVersionFor(subtractThreeYears(now)); + return { + regex: generateRegexForAvoidOldSalesforceApiVersionsRule(apiVersionFromThreeYearsAgo), + file_extensions: ['.xml'], + description: getMessage('AvoidOldSalesforceApiVersionsRuleDescription'), + violation_message: getMessage('AvoidOldSalesforceApiVersionsRuleMessage', apiVersionFromThreeYearsAgo), + tags: ["Recommended", "Security"], + severity: SeverityLevel.High + }; +} + +function generateRegexForAvoidOldSalesforceApiVersionsRule(apiVersionFromThreeYearsAgo: number): RegExp { + if (apiVersionFromThreeYearsAgo < 20 || apiVersionFromThreeYearsAgo >= 100) { + throw new Error("This method only works for API versions that are >= 20.0 and < 100.0. Please contact Salesforce to fix this method."); + } + const tensDigit: number = Math.floor(apiVersionFromThreeYearsAgo / 10); + const onesDigit: number = apiVersionFromThreeYearsAgo % 10; + const forbiddenVersionsPattern: string = `([1-9]|[1-${tensDigit-1}][0-9]|${tensDigit}[0-${onesDigit}])(\\.[0-9])?`; + // Note using (?<= ... ) and (?=< ... ) so that the violation location is just the version number instead of the entire thing + return new RegExp(`(?<=)${forbiddenVersionsPattern}(?=<\\/apiVersion>)`, 'g'); +} + +function subtractThreeYears(date: Date): Date{ + const pastDate = new Date(date); + pastDate.setFullYear(pastDate.getFullYear() - 3); + return pastDate; +} + +function getSalesforceApiVersionFor(date: Date): number { + const year: number = date.getUTCFullYear(); + const month: number = date.getUTCMonth(); + if (month >= 1 && month < 5) { // Feb through May (Spring release) + return (year - 2004) * 3; + } else if (month >= 5 && month < 9) { // Jun through Sep (Summer release) + return (year - 2004) * 3 + 1; + } else { // Oct through Jan (Winter release) + return (year - 2004) * 3 + 2; } } \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/src/utils.ts b/packages/code-analyzer-regex-engine/src/utils.ts new file mode 100644 index 00000000..1b0dcf5e --- /dev/null +++ b/packages/code-analyzer-regex-engine/src/utils.ts @@ -0,0 +1,9 @@ +export interface Clock { + now(): Date; +} + +export class RealClock implements Clock { + now(): Date { + return new Date(); + } +} \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/test/engine.test.ts b/packages/code-analyzer-regex-engine/test/engine.test.ts index 715b17f4..fe78a63d 100644 --- a/packages/code-analyzer-regex-engine/test/engine.test.ts +++ b/packages/code-analyzer-regex-engine/test/engine.test.ts @@ -12,11 +12,11 @@ import { } from "@salesforce/code-analyzer-engine-api"; import {getMessage} from "../src/messages"; import { - RegexRules, - DEFAULT_TAGS, + RegexRules, + DEFAULT_TAGS, DEFAULT_SEVERITY_LEVEL } from "../src/config"; -import {BASE_REGEX_RULES} from "../src/plugin"; +import {createBaseRegexRules, RULE_RESOURCE_URLS, TERMS_WITH_IMPLICIT_BIAS} from "../src/plugin"; changeWorkingDirectoryToPackageRoot(); @@ -47,7 +47,7 @@ const EXPECTED_NoTrailingWhitespace_RULE_DESCRIPTION: RuleDescription = { resourceUrls: [] }; -const EXPECTED_NoTodos_RULE_DESCRIPTION = { +const EXPECTED_NoTodos_RULE_DESCRIPTION: RuleDescription = { name: "NoTodos", severityLevel: DEFAULT_SEVERITY_LEVEL, type: RuleType.Standard, @@ -65,15 +65,35 @@ const EXPECTED_NoHellos_RULE_DESCRIPTION = { resourceUrls: [] }; -describe("Tests for RegexEngine's getName and describeRules methods", () => { - let engine: RegexEngine; - beforeAll(() => { - engine = new RegexEngine({ - ... BASE_REGEX_RULES, - ... SAMPLE_CUSTOM_RULES - }); - }); +const EXPECTED_AvoidTermsWithImplicitBias_RULE_DESCRIPTION: RuleDescription = { + name: "AvoidTermsWithImplicitBias", + description: getMessage('AvoidTermsWithImplicitBiasRuleDescription'), + severityLevel: SeverityLevel.Info, + type: RuleType.Standard, + tags: ['Recommended'], + resourceUrls: ['https://www.salesforce.com/news/stories/salesforce-updates-technical-language-in-ongoing-effort-to-address-implicit-bias/'], +} + +const EXPECTED_AvoidOldSalesforceApiVersions_RULE_DESCRIPTION: RuleDescription = { + name: "AvoidOldSalesforceApiVersions", + description: getMessage('AvoidOldSalesforceApiVersionsRuleDescription'), + severityLevel: SeverityLevel.High, + type: RuleType.Standard, + tags: ['Recommended', 'Security'], + resourceUrls: [] +} + +const SAMPLE_DATE: Date = new Date(Date.UTC(2024, 8, 1, 0, 0, 0)); + +let engine: RegexEngine; +beforeAll(() => { + engine = new RegexEngine({ + ... createBaseRegexRules(SAMPLE_DATE), + ... SAMPLE_CUSTOM_RULES + }, RULE_RESOURCE_URLS); +}); +describe("Tests for RegexEngine's getName and describeRules methods", () => { it('Engine name is accessible and correct', () => { const name: string = engine.getName(); expect(name).toEqual("regex"); @@ -81,15 +101,17 @@ describe("Tests for RegexEngine's getName and describeRules methods", () => { it('Calling describeRules without workspace, returns all available rules', async () => { const rulesDescriptions: RuleDescription[] = await engine.describeRules({}); - expect(rulesDescriptions).toHaveLength(3); + expect(rulesDescriptions).toHaveLength(5); expect(rulesDescriptions[0]).toMatchObject(EXPECTED_NoTrailingWhitespace_RULE_DESCRIPTION); - expect(rulesDescriptions[1]).toMatchObject(EXPECTED_NoTodos_RULE_DESCRIPTION); - expect(rulesDescriptions[2]).toMatchObject(EXPECTED_NoHellos_RULE_DESCRIPTION); + expect(rulesDescriptions[1]).toMatchObject(EXPECTED_AvoidTermsWithImplicitBias_RULE_DESCRIPTION) + expect(rulesDescriptions[2]).toMatchObject(EXPECTED_AvoidOldSalesforceApiVersions_RULE_DESCRIPTION) + expect(rulesDescriptions[3]).toMatchObject(EXPECTED_NoTodos_RULE_DESCRIPTION); + expect(rulesDescriptions[4]).toMatchObject(EXPECTED_NoHellos_RULE_DESCRIPTION); }); it("When workspace contains zero applicable files, then describeRules returns no rules", async () => { const rulesDescriptions: RuleDescription[] = await engine.describeRules({workspace: new Workspace([ - path.resolve(__dirname, 'test-data', 'workspaceWithNoTextFiles') // + path.resolve(__dirname, 'test-data', 'workspaceWithNoTextFiles') ])}); expect(rulesDescriptions).toHaveLength(0); }); @@ -98,32 +120,28 @@ describe("Tests for RegexEngine's getName and describeRules methods", () => { const rulesDescriptions: RuleDescription[] = await engine.describeRules({workspace: new Workspace([ path.resolve(__dirname, 'test-data', 'sampleWorkspace', 'dummy3.js') ])}); - expect(rulesDescriptions).toHaveLength(2); - expect(rulesDescriptions[0]).toMatchObject(EXPECTED_NoTodos_RULE_DESCRIPTION); - expect(rulesDescriptions[1]).toMatchObject(EXPECTED_NoHellos_RULE_DESCRIPTION); + + expect(rulesDescriptions).toHaveLength(3); + expect(rulesDescriptions[0]).toMatchObject(EXPECTED_AvoidTermsWithImplicitBias_RULE_DESCRIPTION); + expect(rulesDescriptions[1]).toMatchObject(EXPECTED_NoTodos_RULE_DESCRIPTION); + expect(rulesDescriptions[2]).toMatchObject(EXPECTED_NoHellos_RULE_DESCRIPTION); }); it("When workspace contains files are applicable to all available rules, then describeRules returns all rules", async () => { const rulesDescriptions: RuleDescription[] = await engine.describeRules({workspace: new Workspace([ path.resolve(__dirname, 'test-data', 'sampleWorkspace') ])}); - expect(rulesDescriptions).toHaveLength(3); + expect(rulesDescriptions).toHaveLength(5); expect(rulesDescriptions[0]).toMatchObject(EXPECTED_NoTrailingWhitespace_RULE_DESCRIPTION); - expect(rulesDescriptions[1]).toMatchObject(EXPECTED_NoTodos_RULE_DESCRIPTION); - expect(rulesDescriptions[2]).toMatchObject(EXPECTED_NoHellos_RULE_DESCRIPTION); + expect(rulesDescriptions[1]).toMatchObject(EXPECTED_AvoidTermsWithImplicitBias_RULE_DESCRIPTION) + expect(rulesDescriptions[2]).toMatchObject(EXPECTED_AvoidOldSalesforceApiVersions_RULE_DESCRIPTION) + expect(rulesDescriptions[3]).toMatchObject(EXPECTED_NoTodos_RULE_DESCRIPTION); + expect(rulesDescriptions[4]).toMatchObject(EXPECTED_NoHellos_RULE_DESCRIPTION); }); }); describe('Tests for runRules', () => { - let engine: RegexEngine; - beforeAll(() => { - engine = new RegexEngine({ - ... BASE_REGEX_RULES, - ... SAMPLE_CUSTOM_RULES - }); - }); - - it('if runRules() is called on a directory with no apex files, it should correctly return no violations', async () => { + it('if runRules() is called on a directory with no Apex files, it should correctly return no violations', async () => { const runOptions: RunOptions = { workspace: new Workspace([ path.resolve(__dirname, "test-data", "apexClassWhitespace", "1_notApexClassWithWhitespace") @@ -132,11 +150,11 @@ describe('Tests for runRules', () => { expect(runResults.violations).toHaveLength(0); }); - it("Ensure runRules when called on a list Apex classes, properly emits violations", async () => { + it("Ensure runRules when called on a directory of Apex classes, it properly emits violations", async () => { const runOptions: RunOptions = {workspace: new Workspace([ path.resolve(__dirname, "test-data", "apexClassWhitespace") ])}; - const runResults: EngineRunResults = await engine.runRules(["NoTrailingWhitespace", "NoTodos"], runOptions); + const runResults: EngineRunResults = await engine.runRules(["NoTrailingWhitespace", "NoTodos", "AvoidOldSalesforceApiVersions"], runOptions); const expectedViolations: Violation[] = [ { @@ -188,7 +206,44 @@ describe('Tests for runRules', () => { endLine: 7, endColumn: 4 }] - } + }, + { + ruleName: "AvoidOldSalesforceApiVersions", + message: getMessage('AvoidOldSalesforceApiVersionsRuleMessage', 52), + primaryLocationIndex: 0, + codeLocations: [{ + file: path.resolve(__dirname, "test-data", "apexClassWhitespace", "2_apexClasses", "myClass.cls-meta.xml"), + startLine: 4, + startColumn: 17, + endLine: 4, + endColumn: 21 + }] + }, + { + ruleName: "AvoidOldSalesforceApiVersions", + message: getMessage('AvoidOldSalesforceApiVersionsRuleMessage', 52), + primaryLocationIndex: 0, + codeLocations: [{ + file: path.resolve(__dirname, "test-data", "apexClassWhitespace", "3_apexClassWithoutWhitespace", "myOuterClass.cls-meta.xml"), + startLine: 3, + startColumn: 17, + endLine: 3, + endColumn: 21 + }] + }, + { + ruleName: "AvoidOldSalesforceApiVersions", + message: getMessage('AvoidOldSalesforceApiVersionsRuleMessage', 52), + primaryLocationIndex: 0, + codeLocations: [{ + file: path.resolve(__dirname, "test-data", "apexClassWhitespace", "lwc_component.js-meta.xml"), + startLine: 3, + startColumn: 17, + endLine: 3, + endColumn: 21 + }] + }, + ]; expect(runResults.violations).toHaveLength(expectedViolations.length); @@ -197,6 +252,244 @@ describe('Tests for runRules', () => { } }); + it("Ensure when runRules is called on a directory of files with inclusivity rule violations, engine emits violations correctly", async () => { + const runOptions: RunOptions = {workspace: new Workspace([ + path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace") + ])}; + const runResults: EngineRunResults = await engine.runRules(["AvoidTermsWithImplicitBias"], runOptions); + const expectedViolations: Violation[] = [ + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy1.cls'), + startLine: 8, + startColumn: 4, + endLine: 8, + endColumn: 13 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy1.cls'), + startLine: 8, + startColumn: 15, + endLine: 8, + endColumn: 26 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy1.cls'), + startLine: 6, + startColumn: 6, + endLine: 6, + endColumn: 11 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy2.ts'), + startLine: 2, + startColumn: 17, + endLine: 2, + endColumn: 28 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy2.ts'), + startLine: 4, + startColumn: 7, + endLine: 4, + endColumn: 21 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy2.ts'), + startLine: 5, + startColumn: 13, + endLine: 5, + endColumn: 27 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy2.ts'), + startLine: 5, + startColumn: 32, + endLine: 5, + endColumn: 41 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy2.ts'), + startLine: 5, + startColumn: 43, + endLine: 5, + endColumn: 52 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy3.js'), + startLine: 1, + startColumn: 17, + endLine: 1, + endColumn: 25 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy3.js'), + startLine: 3, + startColumn: 18, + endLine: 3, + endColumn: 23 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy4.txt'), + startLine: 1, + startColumn: 1, + endLine: 1, + endColumn: 9 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy4.txt'), + startLine: 5, + startColumn: 1, + endLine: 5, + endColumn: 11 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy6.mjs'), + startLine: 3, + startColumn: 18, + endLine: 3, + endColumn: 23 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy7.trigger'), + startLine: 1, + startColumn: 4, + endLine: 1, + endColumn: 16 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy8.tsx'), + startLine: 3, + startColumn: 7, + endLine: 3, + endColumn: 21 + } + ] + }, + { + ruleName: "AvoidTermsWithImplicitBias", + message: getMessage('AvoidTermsWithImplicitBiasRuleMessage', JSON.stringify(TERMS_WITH_IMPLICIT_BIAS)), + primaryLocationIndex: 0, + codeLocations: [ + { + file: path.resolve(__dirname, "test-data", "inclusivityRuleWorkspace", 'dummy9.jsx'), + startLine: 3, + startColumn: 22, + endLine: 3, + endColumn: 28 + } + ] + }, + ] + expect(runResults.violations).toHaveLength(expectedViolations.length); + for (const expectedViolation of expectedViolations) { + expect(runResults.violations).toContainEqual(expectedViolation); + } + + }); + it("When workspace contains files that violate custom rules, then emit violation correctly", async () => { const runOptions: RunOptions = {workspace: new Workspace([ path.resolve(__dirname, "test-data", "sampleWorkspace") @@ -273,15 +566,17 @@ describe('Tests for runRules', () => { const runOptions: RunOptions = {workspace: new Workspace([ path.resolve(__dirname, "test-data", "sampleWorkspace") ])}; - const runResults1: EngineRunResults = await engine.runRules(["NoTrailingWhitespace"], runOptions); - const runResults2: EngineRunResults = await engine.runRules(["NoTodos"], runOptions); - const runResults3: EngineRunResults = await engine.runRules(["NoTrailingWhitespace", "NoTodos"], runOptions); + const ruleNames: string[] = ['NoTrailingWhitespace', 'AvoidTermsWithImplicitBias', 'AvoidOldSalesforceApiVersions', 'NoHellos', 'NoTodos'] + const individualRunViolations: Violation[] = [] - expect(runResults1.violations).toHaveLength(1); - expect(runResults2.violations).toHaveLength(2); - expect(runResults3.violations).toHaveLength(3); - expect(runResults3.violations).toContainEqual(runResults1.violations[0]); - expect(runResults3.violations).toContainEqual(runResults2.violations[0]); - expect(runResults3.violations).toContainEqual(runResults2.violations[1]); + for (const rule of ruleNames) { + individualRunViolations.push(... (await engine.runRules([rule], runOptions)).violations); + } + + const combinedRunViolations: Violation[] = (await engine.runRules(ruleNames, runOptions)).violations + expect(individualRunViolations.length).toEqual(combinedRunViolations.length) + for (const individualRunViolation of individualRunViolations) { + expect(combinedRunViolations).toContainEqual(individualRunViolation); + } }); }); \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/test/plugin.test.ts b/packages/code-analyzer-regex-engine/test/plugin.test.ts index bb9007e6..e88a23b4 100644 --- a/packages/code-analyzer-regex-engine/test/plugin.test.ts +++ b/packages/code-analyzer-regex-engine/test/plugin.test.ts @@ -8,8 +8,10 @@ import { SHARED_MESSAGE_CATALOG } from "@salesforce/code-analyzer-engine-api"; import {getMessage} from "../src/messages"; -import {FILE_EXT_PATTERN, REGEX_STRING_PATTERN, RegexRules, RULE_NAME_PATTERN} from "../src/config"; -import {BASE_REGEX_RULES} from "../src/plugin"; +import {FILE_EXT_PATTERN, REGEX_STRING_PATTERN, RegexRule, RegexRules, RULE_NAME_PATTERN} from "../src/config"; +import {createBaseRegexRules} from "../src/plugin"; +import {FixedClock} from "./test-helpers"; +import {RealClock} from "../src/utils"; const SAMPLE_RAW_CUSTOM_RULE_DEFINITION = { regex: String.raw`/TODO:\s/gi`, @@ -21,10 +23,14 @@ const SAMPLE_RAW_CUSTOM_RULE_NO_FILE_EXTS_DEFINITION = { regex: String.raw`/hello/gi`, description: "Detects hellos in project", } + +const SAMPLE_DATE: Date = new Date(Date.UTC(2024, 8, 1, 0, 0, 0)); + describe('RegexEnginePlugin No Custom Config Tests' , () => { let enginePlugin: RegexEnginePlugin; beforeAll(() => { enginePlugin = new RegexEnginePlugin() + enginePlugin._setClock(new FixedClock(SAMPLE_DATE)); }); it('Check that I can get all available engine names', () => { @@ -41,6 +47,45 @@ describe('RegexEnginePlugin No Custom Config Tests' , () => { await expect(enginePlugin.createEngine('OtherEngine', {})).rejects.toThrow( getMessage('CantCreateEngineWithUnknownEngineName', 'OtherEngine')); }); + + type ApiVersionTestCase = { + date: Date + expectedSource: string + expectedVersion: number + }; + const apiVersionTestCases: ApiVersionTestCase[] = [ + { date: new Date(Date.UTC(2024,8,15)), expectedSource: '(?<=)([1-9]|[1-4][0-9]|5[0-2])(\\.[0-9])?(?=<\\/apiVersion>)', expectedVersion: 52 }, // Summer'24 - 3 years = Summer'21 (52.0) + { date: new Date(Date.UTC(2022,2,1)), expectedSource: '(?<=)([1-9]|[1-3][0-9]|4[0-5])(\\.[0-9])?(?=<\\/apiVersion>)', expectedVersion: 45 }, // Spring'22 - 3 years = Spring'19 (45.0) + { date: new Date(Date.UTC(2023,5,2)), expectedSource: '(?<=)([1-9]|[1-3][0-9]|4[0-9])(\\.[0-9])?(?=<\\/apiVersion>)', expectedVersion: 49 }, // Summer'23 - 3 years = Summer'20 (49.0) + { date: new Date(Date.UTC(2025,9,3)), expectedSource: '(?<=)([1-9]|[1-4][0-9]|5[0-6])(\\.[0-9])?(?=<\\/apiVersion>)', expectedVersion: 56 }, // Winter'26 - 3 years = Winter'23 (56.0) + { date: new Date(Date.UTC(2028,3,7)), expectedSource: '(?<=)([1-9]|[1-5][0-9]|6[0-3])(\\.[0-9])?(?=<\\/apiVersion>)', expectedVersion: 63 } // Spring'28 - 3 years = Spring'25 (63.0) + ]; + it.each(apiVersionTestCases)('RegexEnginePlugin produces engine with AvoidOldSalesforceApiVersions that depends on current date', async (testCase: ApiVersionTestCase) => { + enginePlugin._setClock(new FixedClock(testCase.date)); + const engine: RegexEngine = await enginePlugin.createEngine('regex', {}) as RegexEngine; + const regexRules: RegexRules = engine._getRegexRules(); + const apiVersionRule: RegexRule = regexRules['AvoidOldSalesforceApiVersions']; + expect(apiVersionRule).toBeDefined(); + expect(apiVersionRule.regex.source).toEqual(testCase.expectedSource); + expect(apiVersionRule.violation_message).toEqual(getMessage('AvoidOldSalesforceApiVersionsRuleMessage', testCase.expectedVersion)); + }); + + + it('Throw an error, if date is outside valid range to generate a valid API version regular expression for AvoidOldSalesforceApiVersions rule', async () => { + enginePlugin._setClock(new FixedClock(new Date(Date.UTC(2006,1,1)))); + await expect(enginePlugin.createEngine('regex', {})).rejects.toThrow( + "This method only works for API versions that are >= 20.0 and < 100.0. Please contact Salesforce to fix this method." + ); + enginePlugin._setClock(new FixedClock(new Date(Date.UTC(2050,1,1)))); + await expect(enginePlugin.createEngine('regex', {})).rejects.toThrow( + "This method only works for API versions that are >= 20.0 and < 100.0. Please contact Salesforce to fix this method." + ); + }); + + it('Test will generate a valid API version now, but will throw error if 3 year old API version is >100', async () => { + enginePlugin._setClock(new RealClock()); + await expect(enginePlugin.createEngine('regex', {})).resolves.toBeDefined(); // if this throws at some point in the future, then at that time we will need to rewrite our rule + }); }); describe('RegexEnginePlugin Custom Config Tests', () => { @@ -60,7 +105,7 @@ describe('RegexEnginePlugin Custom Config Tests', () => { const customNoTodoRuleRegex: RegExp = /TODO:\s/gi; const customNoHelloRuleRegex: RegExp = /hello/gi; const expRegexRules: RegexRules = { - ...BASE_REGEX_RULES, + ...createBaseRegexRules(SAMPLE_DATE), NoTodos: { regex: customNoTodoRuleRegex, description: SAMPLE_RAW_CUSTOM_RULE_DEFINITION.description, @@ -400,6 +445,7 @@ describe('RegexEnginePlugin Custom Config Tests', () => { {input: '/[0-9]{2,}[A-Z]{2,}?/g', expected: new RegExp('[0-9]{2,}[A-Z]{2,}?', 'g')}, {input: '/[^a-zA-Z0-9]{3,6}/g', expected: new RegExp('[^a-zA-Z0-9]{3,6}', 'g')}, {input: '/(alpha|beta)\\d{2,4}?/gi', expected: new RegExp('(alpha|beta)\\d{2,4}?', 'gi')}, + {input: '/\\/\\/[ \\t]*TODO/gi', expected: new RegExp('\\/\\/[ \\t]*TODO', 'gi')}, ]; it.each(patternTestCases)('Verify regular expression construction for $input', async (testCase: PATTERN_TESTCASE) => { const rawConfig: ConfigObject = { diff --git a/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/2_apexClasses/myClass.cls b/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/2_apexClasses/myClass.cls index 890758df..1024612b 100644 --- a/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/2_apexClasses/myClass.cls +++ b/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/2_apexClasses/myClass.cls @@ -1,5 +1,5 @@ public class myCls { - // Additional myOuterClass code here + // Additional myOuterClass code here class myInnerClass { // myInnerClass code here } diff --git a/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/2_apexClasses/myClass.cls-meta.xml b/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/2_apexClasses/myClass.cls-meta.xml new file mode 100644 index 00000000..b9e3d514 --- /dev/null +++ b/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/2_apexClasses/myClass.cls-meta.xml @@ -0,0 +1,5 @@ + + + + 51.0 + \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/2_apexClasses/myOuterClass.cls-meta.xml b/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/2_apexClasses/myOuterClass.cls-meta.xml new file mode 100644 index 00000000..c579258c --- /dev/null +++ b/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/2_apexClasses/myOuterClass.cls-meta.xml @@ -0,0 +1,4 @@ + + + 52 + \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/3_apexClassWithoutWhitespace/myOuterClass.cls-meta.xml b/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/3_apexClassWithoutWhitespace/myOuterClass.cls-meta.xml new file mode 100644 index 00000000..ef424964 --- /dev/null +++ b/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/3_apexClassWithoutWhitespace/myOuterClass.cls-meta.xml @@ -0,0 +1,4 @@ + + + 50.9 + \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/lwc_component.js-meta.xml b/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/lwc_component.js-meta.xml new file mode 100644 index 00000000..5d558c65 --- /dev/null +++ b/packages/code-analyzer-regex-engine/test/test-data/apexClassWhitespace/lwc_component.js-meta.xml @@ -0,0 +1,10 @@ + + + 45.0 + true + + lightning__AppPage + lightning__RecordPage + lightning__HomePage + + \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy1.cls b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy1.cls new file mode 100644 index 00000000..376f5b86 --- /dev/null +++ b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy1.cls @@ -0,0 +1,8 @@ +public class dummy { + // Additional myOuterClass code here + class myInnerClass { + // myInnerClass code here + } +} // slave + +/* blackouts, whitelisted*/ \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy2.ts b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy2.ts new file mode 100644 index 00000000..329efcbf --- /dev/null +++ b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy2.ts @@ -0,0 +1,5 @@ +/*TODO: write code */ +/* protect from blacklisted IPs*/ + +const whitelisted_ip = "my IP"; +console.log(whitelisted_ip) // black out, brown out \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy3.js b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy3.js new file mode 100644 index 00000000..fd0937f7 --- /dev/null +++ b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy3.js @@ -0,0 +1,3 @@ +const a = 3; // brownout +// eslint-disable-next-line no-undef +console.log(a * "slave"); \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy4.txt b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy4.txt new file mode 100644 index 00000000..b2035500 --- /dev/null +++ b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy4.txt @@ -0,0 +1,5 @@ +brownout, + + + +whitelists \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy5.cjs b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy5.cjs new file mode 100644 index 00000000..14ad5ed5 --- /dev/null +++ b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy5.cjs @@ -0,0 +1,3 @@ +const a = 3; +// eslint-disable-next-line no-undef +console.log(a * "slavery"); \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy6.mjs b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy6.mjs new file mode 100644 index 00000000..10030f07 --- /dev/null +++ b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy6.mjs @@ -0,0 +1,3 @@ +const a = 3; +// eslint-disable-next-line no-undef +console.log(a * "slave"); \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy7.trigger b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy7.trigger new file mode 100644 index 00000000..2c7efe44 --- /dev/null +++ b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy7.trigger @@ -0,0 +1 @@ +/* black out, white out*/ \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy8.tsx b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy8.tsx new file mode 100644 index 00000000..305139fd --- /dev/null +++ b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy8.tsx @@ -0,0 +1,3 @@ +/*TODO: write code */ + +const blacklisted_ip = "my IP"; diff --git a/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy9.jsx b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy9.jsx new file mode 100644 index 00000000..34aceb1c --- /dev/null +++ b/packages/code-analyzer-regex-engine/test/test-data/inclusivityRuleWorkspace/dummy9.jsx @@ -0,0 +1,3 @@ +const a = 8; +// eslint-disable-next-line no-undef +console.log(a * a * "slaves"); \ No newline at end of file diff --git a/packages/code-analyzer-regex-engine/test/test-data/sampleWorkspace/dummy4.txt b/packages/code-analyzer-regex-engine/test/test-data/sampleWorkspace/dummy4.txt index b6fc4c62..ce013625 100644 --- a/packages/code-analyzer-regex-engine/test/test-data/sampleWorkspace/dummy4.txt +++ b/packages/code-analyzer-regex-engine/test/test-data/sampleWorkspace/dummy4.txt @@ -1 +1 @@ -hello \ No newline at end of file +hello diff --git a/packages/code-analyzer-regex-engine/test/test-data/sampleWorkspace/dummy5.xml b/packages/code-analyzer-regex-engine/test/test-data/sampleWorkspace/dummy5.xml new file mode 100644 index 00000000..e69de29b diff --git a/packages/code-analyzer-regex-engine/test/test-helpers.ts b/packages/code-analyzer-regex-engine/test/test-helpers.ts index 4d98690e..17a4ff73 100644 --- a/packages/code-analyzer-regex-engine/test/test-helpers.ts +++ b/packages/code-analyzer-regex-engine/test/test-helpers.ts @@ -1,5 +1,6 @@ import process from "node:process"; import path from "node:path"; +import { Clock } from "../src/utils"; export function changeWorkingDirectoryToPackageRoot() { let original_working_directory: string; @@ -16,3 +17,15 @@ export function changeWorkingDirectoryToPackageRoot() { process.chdir(original_working_directory); }); } + +export class FixedClock implements Clock { + private readonly fixedTimestamp: Date; + + constructor(fixedTimestamp: Date) { + this.fixedTimestamp = fixedTimestamp; + } + + now(): Date { + return this.fixedTimestamp; + } +} \ No newline at end of file