From ee7736127c3973d36ce5fb48eb0ac1c7ecf602a9 Mon Sep 17 00:00:00 2001 From: abarseni Date: Wed, 21 Oct 2026 12:51:55 +0200 Subject: [PATCH 1/4] Integrate Flow Analyzer engine into the CLI Adds @salesforce/code-analyzer-flowanalyzer-engine as a dependency, registers it in EnginePluginsFactory, and updates CodeAnalyzerConfigFactory, RulesAction and the rules command accordingly. Updates the rules/run command messages, adds a sample code-analyzer.yml that toggles engines, and updates the related tests. --- code-analyzer.yml | 9 +++++ messages/rules-command.md | 14 ++++++++ messages/run-command.md | 8 ++++- package-lock.json | 31 +++++++++++++++++ package.json | 1 + src/commands/code-analyzer/rules.ts | 9 ++++- src/lib/actions/RulesAction.ts | 6 +++- .../factories/CodeAnalyzerConfigFactory.ts | 9 ++++- src/lib/factories/EnginePluginsFactory.ts | 4 ++- test/commands/code-analyzer/rules.test.ts | 16 +++++++++ test/lib/actions/RulesAction.test.ts | 33 +++++++++++++++++++ .../CodeAnalyzerConfigFactory.test.ts | 12 +++++++ .../factories/EnginePluginsFactory.test.ts | 3 +- 13 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 code-analyzer.yml diff --git a/code-analyzer.yml b/code-analyzer.yml new file mode 100644 index 000000000..4ba92345c --- /dev/null +++ b/code-analyzer.yml @@ -0,0 +1,9 @@ +engines: + pmd: + disable_engine: true + cpd: + disable_engine: true + sfge: + disable_engine: true + flow: + disable_engine: true \ No newline at end of file diff --git a/messages/rules-command.md b/messages/rules-command.md index cd0e4e1b6..97c4e1fc3 100644 --- a/messages/rules-command.md +++ b/messages/rules-command.md @@ -24,6 +24,10 @@ We're continually improving Salesforce Code Analyzer. Tell us what you think! Gi <%= config.bin %> <%= command.id %> --rule-selector eslint:Recommended +- List Flow Analyzer rules from an authenticated org (required for that engine): + + <%= config.bin %> <%= command.id %> --rule-selector flowAnalyzer --target-org my-org + - List all the rules for the "eslint" engine: <%= config.bin %> <%= command.id %> --rule-selector eslint @@ -92,6 +96,16 @@ Each targeted file must live within the workspace specified by the –-workspace If you specify `--workspace` but not `--target`, then all the files within your workspace are targeted. +# flags.target-org.summary + +Target org username or alias for remote analysis engines. + +# flags.target-org.description + +Specify the username or alias of a Salesforce org when listing rules from engines that require remote connectivity, such as Flow Analyzer. The org must be authenticated with the Salesforce CLI. + +Flow Analyzer requires this flag (or `engines.flowAnalyzer.target_org` in your `code-analyzer.yml`). Other engines ignore it. + # flags.rule-selector.summary Selection of rules, based on engine name, severity level, rule name, tag, or a combination of criteria separated by colons. diff --git a/messages/run-command.md b/messages/run-command.md index 12c0687e8..d947c5ac4 100644 --- a/messages/run-command.md +++ b/messages/run-command.md @@ -44,6 +44,10 @@ We're continually improving Salesforce Code Analyzer. Tell us what you think! Gi <%= config.bin %> <%= command.id %> --rule-selector retire-js:Recommended --target ./other-source --target ./force-app/**/*.cls +- Analyze only specific flows with the "flowAnalyzer" engine; target one flow by path, plus every flow whose file name starts with "Account_" in any folder (quote globs so your shell doesn't expand them). Only the targeted flows are sent to your org: + + <%= config.bin %> <%= command.id %> --rule-selector flowAnalyzer --target-org my-org --target ./force-app/main/default/flows/My_Flow.flow-meta.xml --target "./force-app/**/Account_*.flow-meta.xml" + - Specify a custom configuration file and output the results to the "results.csv" file in CSV format; the commands fails if it finds a violation that exceeds the moderate severity level (3): <%= config.bin %> <%= command.id %> --config-file ./code-analyzer2.yml --output-file results.csv --severity-threshold 3 @@ -92,7 +96,9 @@ Target org username or alias for remote analysis engines. # flags.target-org.description -Specify the username or alias of a Salesforce org when using engines that require remote connectivity, such as ApexGuru. The org must be authenticated with the Salesforce CLI. +Specify the username or alias of a Salesforce org when using engines that require remote connectivity, such as ApexGuru or Flow Analyzer. The org must be authenticated with the Salesforce CLI. + +Flow Analyzer requires this flag (or `engines.flowAnalyzer.target_org` in your `code-analyzer.yml`). # flags.rule-selector.summary diff --git a/package-lock.json b/package-lock.json index aee2a8277..5d56f8cce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@salesforce/code-analyzer-engine-api": "0.42.0", "@salesforce/code-analyzer-eslint-engine": "0.47.0", "@salesforce/code-analyzer-flow-engine": "0.41.0", + "@salesforce/code-analyzer-flowanalyzer-engine": "file:../code-analyzer-core/packages/code-analyzer-flowanalyzer-engine", "@salesforce/code-analyzer-pmd-engine": "0.46.0", "@salesforce/code-analyzer-regex-engine": "0.40.0", "@salesforce/code-analyzer-retirejs-engine": "0.39.0", @@ -53,6 +54,32 @@ "node": ">=20.0.0" } }, + "../code-analyzer-core/packages/code-analyzer-flowanalyzer-engine": { + "name": "@salesforce/code-analyzer-flowanalyzer-engine", + "version": "0.1.0-SNAPSHOT", + "license": "BSD-3-Clause", + "dependencies": { + "@salesforce/code-analyzer-engine-api": "0.42.0", + "@salesforce/core": "^8.31.2", + "archiver": "^7.0.1", + "form-data": "^4.0.6" + }, + "devDependencies": { + "@eslint/js": "^9.39.5", + "@types/archiver": "^6.0.4", + "@types/jest": "^30.0.0", + "@types/node": "^20.0.0", + "eslint": "^9.39.5", + "jest": "^30.4.2", + "rimraf": "^6.1.3", + "ts-jest": "^29.4.12", + "typescript": "^5.9.3", + "typescript-eslint": "^8.67.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", @@ -5780,6 +5807,10 @@ "node": ">=20.0.0" } }, + "node_modules/@salesforce/code-analyzer-flowanalyzer-engine": { + "resolved": "../code-analyzer-core/packages/code-analyzer-flowanalyzer-engine", + "link": true + }, "node_modules/@salesforce/code-analyzer-pmd-engine": { "version": "0.46.0", "resolved": "https://registry.npmjs.org/@salesforce/code-analyzer-pmd-engine/-/code-analyzer-pmd-engine-0.46.0.tgz", diff --git a/package.json b/package.json index c4dfe08be..dba3a6537 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@salesforce/code-analyzer-engine-api": "0.42.0", "@salesforce/code-analyzer-eslint-engine": "0.47.0", "@salesforce/code-analyzer-flow-engine": "0.41.0", + "@salesforce/code-analyzer-flowanalyzer-engine": "file:../code-analyzer-core/packages/code-analyzer-flowanalyzer-engine", "@salesforce/code-analyzer-pmd-engine": "0.46.0", "@salesforce/code-analyzer-regex-engine": "0.40.0", "@salesforce/code-analyzer-retirejs-engine": "0.39.0", diff --git a/src/commands/code-analyzer/rules.ts b/src/commands/code-analyzer/rules.ts index f7acba2b4..b8102bc71 100644 --- a/src/commands/code-analyzer/rules.ts +++ b/src/commands/code-analyzer/rules.ts @@ -34,6 +34,12 @@ export default class RulesCommand extends SfCommand implements Displayable multiple: true, delimiter: ',' }), + 'target-org': Flags.string({ + summary: getMessage(BundleName.RulesCommand, 'flags.target-org.summary'), + description: getMessage(BundleName.RulesCommand, 'flags.target-org.description'), + // eslint-disable-next-line sf-plugin/dash-o + char: 'o' + }), 'rule-selector': Flags.string({ summary: getMessage(BundleName.RulesCommand, 'flags.rule-selector.summary'), description: getMessage(BundleName.RulesCommand, 'flags.rule-selector.description'), @@ -75,7 +81,8 @@ export default class RulesCommand extends SfCommand implements Displayable 'output-file': outputFiles, 'rule-selector': parsedFlags['rule-selector'].flatMap(s => s.replace(/\s+/g, ' ').trim().split(' ')), 'workspace': parsedFlags['workspace'], - 'target': parsedFlags['target'] + 'target': parsedFlags['target'], + 'target-org': parsedFlags['target-org'] }; await action.execute(rulesInput); diff --git a/src/lib/actions/RulesAction.ts b/src/lib/actions/RulesAction.ts index da4341d2c..e1312713f 100644 --- a/src/lib/actions/RulesAction.ts +++ b/src/lib/actions/RulesAction.ts @@ -29,6 +29,7 @@ export type RulesInput = { 'output-file'?: string[]; workspace?: string[]; target?: string[]; + 'target-org'?: string; view?: string; } @@ -40,7 +41,10 @@ export class RulesAction { } public async execute(input: RulesInput): Promise { - const config: CodeAnalyzerConfig = this.dependencies.configFactory.create(input['config-file']); + const cliOverrides = input['target-org'] !== undefined + ? {targetOrg: input['target-org']} + : undefined; + const config: CodeAnalyzerConfig = this.dependencies.configFactory.create(input['config-file'], cliOverrides); const logWriter: LogFileWriter = await LogFileWriter.fromConfig(config); this.dependencies.actionSummaryViewer.viewPreExecutionSummary(logWriter.getLogDestination()); // We always add a Logger Listener to the appropriate listeners list, because we should Always Be Logging. diff --git a/src/lib/factories/CodeAnalyzerConfigFactory.ts b/src/lib/factories/CodeAnalyzerConfigFactory.ts index fcb28504d..1f44d373b 100644 --- a/src/lib/factories/CodeAnalyzerConfigFactory.ts +++ b/src/lib/factories/CodeAnalyzerConfigFactory.ts @@ -92,7 +92,7 @@ export class CodeAnalyzerConfigFactoryImpl implements CodeAnalyzerConfigFactory }; } - // Apply target-org override to apexguru engine config + // Apply target-org override to engines that require an explicit org if (cliOverrides.targetOrg) { const enginesSection = mergedConfig.engines as Record> | undefined; mergedConfig = { @@ -102,6 +102,10 @@ export class CodeAnalyzerConfigFactoryImpl implements CodeAnalyzerConfigFactory apexguru: { ...(enginesSection?.apexguru || {}), target_org: cliOverrides.targetOrg + }, + flowAnalyzer: { + ...(enginesSection?.flowAnalyzer || {}), + target_org: cliOverrides.targetOrg } } }; @@ -122,6 +126,9 @@ export class CodeAnalyzerConfigFactoryImpl implements CodeAnalyzerConfigFactory configObject.engines = { apexguru: { target_org: cliOverrides.targetOrg + }, + flowAnalyzer: { + target_org: cliOverrides.targetOrg } }; } diff --git a/src/lib/factories/EnginePluginsFactory.ts b/src/lib/factories/EnginePluginsFactory.ts index b36cdba7a..0c346f610 100644 --- a/src/lib/factories/EnginePluginsFactory.ts +++ b/src/lib/factories/EnginePluginsFactory.ts @@ -7,6 +7,7 @@ import * as FlowEngineModule from '@salesforce/code-analyzer-flow-engine'; import * as SfgeEngineModule from '@salesforce/code-analyzer-sfge-engine'; import * as ApexGuruEngineModule from '@salesforce/code-analyzer-apexguru-engine'; import * as UIBundleEngineModule from '@salesforce/code-analyzer-uibundle-engine'; +import * as FlowAnalyzerEngineModule from '@salesforce/code-analyzer-flowanalyzer-engine'; export interface EnginePluginsFactory { create(): EnginePlugin[]; @@ -22,7 +23,8 @@ export class EnginePluginsFactoryImpl implements EnginePluginsFactory { FlowEngineModule.createEnginePlugin(), SfgeEngineModule.createEnginePlugin(), ApexGuruEngineModule.createEnginePlugin(), - UIBundleEngineModule.createEnginePlugin() + UIBundleEngineModule.createEnginePlugin(), + FlowAnalyzerEngineModule.createEnginePlugin() ]; } } diff --git a/test/commands/code-analyzer/rules.test.ts b/test/commands/code-analyzer/rules.test.ts index 17a66815f..7c7b9f38f 100644 --- a/test/commands/code-analyzer/rules.test.ts +++ b/test/commands/code-analyzer/rules.test.ts @@ -275,6 +275,22 @@ describe('`code-analyzer rules` unit tests', () => { }); }); + describe('--target-org', () => { + it('Can be supplied with a value', async () => { + const inputValue = 'test-org'; + await runRulesCommand(['--target-org', inputValue]); + expect(executeSpy).toHaveBeenCalled(); + expect(receivedActionInput).toHaveProperty('target-org', inputValue); + }); + + it('Can be referenced by its shortname, -o', async () => { + const inputValue = 'my-org'; + await runRulesCommand(['-o', inputValue]); + expect(executeSpy).toHaveBeenCalled(); + expect(receivedActionInput).toHaveProperty('target-org', inputValue); + }); + }); + describe.each([ {flag: '--workspace', shortflag: '-w', property: 'workspace'}, {flag: '--target', shortflag: '-t', property: 'target'}, diff --git a/test/lib/actions/RulesAction.test.ts b/test/lib/actions/RulesAction.test.ts index 1a278e126..687b21e50 100644 --- a/test/lib/actions/RulesAction.test.ts +++ b/test/lib/actions/RulesAction.test.ts @@ -174,6 +174,39 @@ describe('RulesAction tests', () => { expect(viewerCallHistory[0]).toEqual([]); }); + describe('target-org', () => { + it('passes target-org through to engine config', async () => { + const targetOrg = 'my-test-org'; + const configFactorySpy = vi.spyOn(defaultDependencies.configFactory, 'create'); + const action = RulesAction.createAction(defaultDependencies); + const input: RulesInput = { + 'rule-selector': ['all'], + 'target-org': targetOrg + }; + + await action.execute(input); + + expect(configFactorySpy).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + targetOrg: targetOrg + }) + ); + }); + + it('does not include a target-org override when target-org is not provided', async () => { + const configFactorySpy = vi.spyOn(defaultDependencies.configFactory, 'create'); + const action = RulesAction.createAction(defaultDependencies); + const input: RulesInput = { + 'rule-selector': ['all'] + }; + + await action.execute(input); + + expect(configFactorySpy).toHaveBeenCalledWith(undefined, undefined); + }); + }); + it('Throws an error when an engine throws an error', async () => { const dependencies: RulesDependencies = { ...defaultDependencies, diff --git a/test/lib/factories/CodeAnalyzerConfigFactory.test.ts b/test/lib/factories/CodeAnalyzerConfigFactory.test.ts index ba0d0bc18..baef53d0a 100644 --- a/test/lib/factories/CodeAnalyzerConfigFactory.test.ts +++ b/test/lib/factories/CodeAnalyzerConfigFactory.test.ts @@ -290,6 +290,9 @@ describe('CodeAnalyzerConfigFactoryImpl', () => { expect(testedConfig.getEngineOverridesFor('apexguru')).toEqual({ target_org: 'my-org@example.com' }); + expect(testedConfig.getEngineOverridesFor('flowAnalyzer')).toEqual({ + target_org: 'my-org@example.com' + }); }); it('When config file exists (without engines) and targetOrg override is provided, applies it to apexguru engine', () => { @@ -301,6 +304,9 @@ describe('CodeAnalyzerConfigFactoryImpl', () => { expect(testedConfig.getEngineOverridesFor('apexguru')).toEqual({ target_org: 'my-org@example.com' }); + expect(testedConfig.getEngineOverridesFor('flowAnalyzer')).toEqual({ + target_org: 'my-org@example.com' + }); // Original config values should still be loaded expect(testedConfig.getRuleOverridesFor('stubEngine1')).toEqual({ stub1RuleC: { @@ -321,6 +327,9 @@ describe('CodeAnalyzerConfigFactoryImpl', () => { expect(testedConfig.getEngineOverridesFor('apexguru')).toEqual({ target_org: 'my-org@example.com' }); + expect(testedConfig.getEngineOverridesFor('flowAnalyzer')).toEqual({ + target_org: 'my-org@example.com' + }); }); it('When both noSuppressions and targetOrg overrides are provided, both are applied', () => { @@ -334,6 +343,9 @@ describe('CodeAnalyzerConfigFactoryImpl', () => { expect(testedConfig.getEngineOverridesFor('apexguru')).toEqual({ target_org: 'my-org@example.com' }); + expect(testedConfig.getEngineOverridesFor('flowAnalyzer')).toEqual({ + target_org: 'my-org@example.com' + }); }); }); }); diff --git a/test/lib/factories/EnginePluginsFactory.test.ts b/test/lib/factories/EnginePluginsFactory.test.ts index 7c59561b2..09ca596a3 100644 --- a/test/lib/factories/EnginePluginsFactory.test.ts +++ b/test/lib/factories/EnginePluginsFactory.test.ts @@ -6,7 +6,7 @@ describe('EnginePluginsFactoryImpl', () => { const pluginsFactory = new EnginePluginsFactoryImpl(); const enginePlugins = pluginsFactory.create(); - expect(enginePlugins).toHaveLength(8); + expect(enginePlugins).toHaveLength(9); expect(enginePlugins[0].getAvailableEngineNames()).toEqual(['eslint']); expect(enginePlugins[1].getAvailableEngineNames()).toEqual(['pmd', 'cpd']); expect(enginePlugins[2].getAvailableEngineNames()).toEqual(['retire-js']); @@ -15,5 +15,6 @@ describe('EnginePluginsFactoryImpl', () => { expect(enginePlugins[5].getAvailableEngineNames()).toEqual(['sfge']); expect(enginePlugins[6].getAvailableEngineNames()).toEqual(['apexguru']); expect(enginePlugins[7].getAvailableEngineNames()).toEqual(['uibundle']); + expect(enginePlugins[8].getAvailableEngineNames()).toEqual(['flowAnalyzer']); }); }); From 49ed8c8348c6c953b81adb5ede314f897c89345c Mon Sep 17 00:00:00 2001 From: abarseni Date: Wed, 21 Oct 2026 13:12:20 +0200 Subject: [PATCH 2/4] that file isn't needed --- code-analyzer.yml | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 code-analyzer.yml diff --git a/code-analyzer.yml b/code-analyzer.yml deleted file mode 100644 index 4ba92345c..000000000 --- a/code-analyzer.yml +++ /dev/null @@ -1,9 +0,0 @@ -engines: - pmd: - disable_engine: true - cpd: - disable_engine: true - sfge: - disable_engine: true - flow: - disable_engine: true \ No newline at end of file From 5346793846c20c17ad6524af5b1168abc2726876 Mon Sep 17 00:00:00 2001 From: abarseni Date: Mon, 2 Nov 2026 14:03:14 +0100 Subject: [PATCH 3/4] Test that Flow Analyzer without a target org doesn't break scans Runs the real engine plugins with no target org and checks that unrelated scans report no flowAnalyzer errors and that flowAnalyzer offers no rules. --- .../workspace-with-flow/Sample.flow-meta.xml | 44 +++++++++++++++++++ .../factories/EnginePluginsFactory.test.ts | 44 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 test/fixtures/example-workspaces/workspace-with-flow/Sample.flow-meta.xml diff --git a/test/fixtures/example-workspaces/workspace-with-flow/Sample.flow-meta.xml b/test/fixtures/example-workspaces/workspace-with-flow/Sample.flow-meta.xml new file mode 100644 index 000000000..16a0f6fda --- /dev/null +++ b/test/fixtures/example-workspaces/workspace-with-flow/Sample.flow-meta.xml @@ -0,0 +1,44 @@ + + + 68.0 + Default + AL Flow and Test {!$Flow.CurrentDateTime} + + + BuilderType + + LightningFlowBuilder + + + + CanvasMode + + AUTO_LAYOUT_CANVAS + + + + OriginBuilderType + + LightningFlowBuilder + + + AutoLaunchedFlow + + get_Account + + 0 + 0 + false + true + Account + true + + + 0 + 0 + + get_Account + + + Draft + \ No newline at end of file diff --git a/test/lib/factories/EnginePluginsFactory.test.ts b/test/lib/factories/EnginePluginsFactory.test.ts index 09ca596a3..cd15d2d76 100644 --- a/test/lib/factories/EnginePluginsFactory.test.ts +++ b/test/lib/factories/EnginePluginsFactory.test.ts @@ -1,5 +1,8 @@ +import * as path from 'node:path'; +import {CodeAnalyzer, CodeAnalyzerConfig, EventType, LogEvent, LogLevel} from '@salesforce/code-analyzer-core'; import {EnginePluginsFactoryImpl} from '../../../src/lib/factories/EnginePluginsFactory.js'; +const WORKSPACE_WITH_FLOW = path.resolve(import.meta.dirname, '..', '..', 'fixtures', 'example-workspaces', 'workspace-with-flow'); describe('EnginePluginsFactoryImpl', () => { it('Loads expected engines', () => { @@ -17,4 +20,45 @@ describe('EnginePluginsFactoryImpl', () => { expect(enginePlugins[7].getAvailableEngineNames()).toEqual(['uibundle']); expect(enginePlugins[8].getAvailableEngineNames()).toEqual(['flowAnalyzer']); }); + + describe('flowAnalyzer without a target org', () => { + let core: CodeAnalyzer; + let errorLogs: LogEvent[]; + + beforeEach(async () => { + core = new CodeAnalyzer(CodeAnalyzerConfig.withDefaults()); + errorLogs = []; + core.onEvent(EventType.LogEvent, (event: LogEvent) => { + if (event.logLevel <= LogLevel.Warn) { + errorLogs.push(event); + } + }); + // Only load the lightweight regex engine alongside flowAnalyzer to keep this test fast. + const plugins = new EnginePluginsFactoryImpl().create().filter(p => { + const names = p.getAvailableEngineNames(); + return names.includes('regex') || names.includes('flowAnalyzer'); + }); + for (const plugin of plugins) { + await core.addEnginePlugin(plugin); + } + }); + + it('does not break unrelated scans', async () => { + const workspace = await core.createWorkspace([WORKSPACE_WITH_FLOW]); + const ruleSelection = await core.selectRules(['regex'], {workspace}); + const results = await core.run(ruleSelection, {workspace}); + + expect(core.getEngineNames()).toContain('flowAnalyzer'); + expect(results.getViolations().filter(v => v.getRule().getEngineName() === 'flowAnalyzer')).toEqual([]); + expect(errorLogs).toEqual([]); + }); + + it('offers no rules instead of an engine error', async () => { + const workspace = await core.createWorkspace([WORKSPACE_WITH_FLOW]); + const ruleSelection = await core.selectRules(['flowAnalyzer'], {workspace}); + + expect(ruleSelection.getCount()).toEqual(0); + expect(errorLogs).toEqual([]); + }); + }); }); From b1ef2b1abe4267063758beae82e0d0383e60ac12 Mon Sep 17 00:00:00 2001 From: abarseni Date: Tue, 10 Nov 2026 09:58:41 +0100 Subject: [PATCH 4/4] Address all comments --- src/lib/actions/RulesAction.ts | 2 +- src/lib/actions/RunAction.ts | 3 +- .../factories/CodeAnalyzerConfigFactory.ts | 40 +++++++++------- .../CodeAnalyzerConfigFactory.test.ts | 47 +++++++++++++++++-- 4 files changed, 70 insertions(+), 22 deletions(-) diff --git a/src/lib/actions/RulesAction.ts b/src/lib/actions/RulesAction.ts index e1312713f..1cd4af2aa 100644 --- a/src/lib/actions/RulesAction.ts +++ b/src/lib/actions/RulesAction.ts @@ -42,7 +42,7 @@ export class RulesAction { public async execute(input: RulesInput): Promise { const cliOverrides = input['target-org'] !== undefined - ? {targetOrg: input['target-org']} + ? {targetOrg: input['target-org'], ruleSelectors: input['rule-selector']} : undefined; const config: CodeAnalyzerConfig = this.dependencies.configFactory.create(input['config-file'], cliOverrides); const logWriter: LogFileWriter = await LogFileWriter.fromConfig(config); diff --git a/src/lib/actions/RunAction.ts b/src/lib/actions/RunAction.ts index 0ffa794a0..bb8cc12d1 100644 --- a/src/lib/actions/RunAction.ts +++ b/src/lib/actions/RunAction.ts @@ -57,7 +57,8 @@ export class RunAction { const cliOverrides = (input['no-suppressions'] !== undefined || input['target-org'] !== undefined) ? { noSuppressions: input['no-suppressions'], - targetOrg: input['target-org'] + targetOrg: input['target-org'], + ruleSelectors: input['rule-selector'] } : undefined; const config: CodeAnalyzerConfig = this.dependencies.configFactory.create( diff --git a/src/lib/factories/CodeAnalyzerConfigFactory.ts b/src/lib/factories/CodeAnalyzerConfigFactory.ts index 1f44d373b..e302c9c6a 100644 --- a/src/lib/factories/CodeAnalyzerConfigFactory.ts +++ b/src/lib/factories/CodeAnalyzerConfigFactory.ts @@ -6,6 +6,8 @@ import * as yaml from 'js-yaml'; export type CliOverrides = { noSuppressions?: boolean; targetOrg?: string; + // The user's --rule-selector values. Used to keep the opt-in Flow Analyzer engine inactive unless it was asked for. + ruleSelectors?: string[]; // Future CLI flag overrides can be added here } @@ -95,18 +97,13 @@ export class CodeAnalyzerConfigFactoryImpl implements CodeAnalyzerConfigFactory // Apply target-org override to engines that require an explicit org if (cliOverrides.targetOrg) { const enginesSection = mergedConfig.engines as Record> | undefined; + const targetOrgEngines: Record> = this.createTargetOrgEngineOverrides(cliOverrides); mergedConfig = { ...mergedConfig, engines: { ...(enginesSection || {}), - apexguru: { - ...(enginesSection?.apexguru || {}), - target_org: cliOverrides.targetOrg - }, - flowAnalyzer: { - ...(enginesSection?.flowAnalyzer || {}), - target_org: cliOverrides.targetOrg - } + ...Object.fromEntries(Object.entries(targetOrgEngines).map(([engineName, overrides]) => + [engineName, {...(enginesSection?.[engineName] || {}), ...overrides}])) } }; } @@ -114,6 +111,24 @@ export class CodeAnalyzerConfigFactoryImpl implements CodeAnalyzerConfigFactory return rawYaml ? CodeAnalyzerConfig.fromObject(mergedConfig) : CodeAnalyzerConfig.fromFile(configFilePath); } + private createTargetOrgEngineOverrides(cliOverrides: CliOverrides): Record> { + const engines: Record> = { + apexguru: {target_org: cliOverrides.targetOrg} + }; + // Flow Analyzer is opt-in. Giving it a target org activates it, and an activated engine contacts the org when + // Core gathers rules, so only do this when the user selected Flow Analyzer. + if (CodeAnalyzerConfigFactoryImpl.selectsFlowAnalyzer(cliOverrides.ruleSelectors)) { + engines.flowAnalyzer = {target_org: cliOverrides.targetOrg}; + } + return engines; + } + + private static selectsFlowAnalyzer(ruleSelectors: string[] = []): boolean { + return ruleSelectors + .flatMap(selector => selector.toLowerCase().split(/[^a-z0-9_-]+/)) + .some(token => token === 'flowanalyzer' || token === 'flow-analyzer'); + } + private createConfigFromCliOverrides(cliOverrides: CliOverrides): CodeAnalyzerConfig { // Build config from CLI overrides const configObject: Record = {}; @@ -123,14 +138,7 @@ export class CodeAnalyzerConfigFactoryImpl implements CodeAnalyzerConfigFactory } if (cliOverrides?.targetOrg) { - configObject.engines = { - apexguru: { - target_org: cliOverrides.targetOrg - }, - flowAnalyzer: { - target_org: cliOverrides.targetOrg - } - }; + configObject.engines = this.createTargetOrgEngineOverrides(cliOverrides); } // If we have overrides, create config from them; otherwise use defaults diff --git a/test/lib/factories/CodeAnalyzerConfigFactory.test.ts b/test/lib/factories/CodeAnalyzerConfigFactory.test.ts index baef53d0a..3d31d3177 100644 --- a/test/lib/factories/CodeAnalyzerConfigFactory.test.ts +++ b/test/lib/factories/CodeAnalyzerConfigFactory.test.ts @@ -285,7 +285,7 @@ describe('CodeAnalyzerConfigFactoryImpl', () => { describe('targetOrg override', () => { it('When no config file and targetOrg override is provided, applies it to apexguru engine', () => { const factory = new CodeAnalyzerConfigFactoryImpl(); - const testedConfig = factory.create(undefined, { targetOrg: 'my-org@example.com' }); + const testedConfig = factory.create(undefined, { targetOrg: 'my-org@example.com', ruleSelectors: ['flowAnalyzer'] }); expect(testedConfig.getEngineOverridesFor('apexguru')).toEqual({ target_org: 'my-org@example.com' @@ -299,7 +299,7 @@ describe('CodeAnalyzerConfigFactoryImpl', () => { const factory = new CodeAnalyzerConfigFactoryImpl(); const configPath = path.resolve('test', 'fixtures', 'valid-configs', 'config-without-suppressions-field.yml'); - const testedConfig = factory.create(configPath, { targetOrg: 'my-org@example.com' }); + const testedConfig = factory.create(configPath, { targetOrg: 'my-org@example.com', ruleSelectors: ['flowAnalyzer'] }); expect(testedConfig.getEngineOverridesFor('apexguru')).toEqual({ target_org: 'my-org@example.com' @@ -320,7 +320,7 @@ describe('CodeAnalyzerConfigFactoryImpl', () => { const factory = new CodeAnalyzerConfigFactoryImpl(); const configPath = path.resolve('test', 'fixtures', 'valid-configs', 'config-with-suppressions-disabled.yml'); - const testedConfig = factory.create(configPath, { targetOrg: 'my-org@example.com' }); + const testedConfig = factory.create(configPath, { targetOrg: 'my-org@example.com', ruleSelectors: ['flowAnalyzer'] }); // YAML suppressions setting preserved (disabled), targetOrg also applied expect(testedConfig.getSuppressionsEnabled()).toBe(false); @@ -336,7 +336,8 @@ describe('CodeAnalyzerConfigFactoryImpl', () => { const factory = new CodeAnalyzerConfigFactoryImpl(); const testedConfig = factory.create(undefined, { noSuppressions: true, - targetOrg: 'my-org@example.com' + targetOrg: 'my-org@example.com', + ruleSelectors: ['flowAnalyzer'] }); expect(testedConfig.getSuppressionsEnabled()).toBe(false); @@ -347,6 +348,44 @@ describe('CodeAnalyzerConfigFactoryImpl', () => { target_org: 'my-org@example.com' }); }); + + describe('Flow Analyzer opt-in', () => { + it.each([ + ['no selectors', []], + ['Recommended', ['Recommended']], + ['all and a severity', ['all', 'High']], + ['another engine', ['apexguru']], + ['apexguru combined with a tag', ['apexguru:Performance']], + ['a name merely containing flow', ['flow', 'flowanalyzerish']] + ])('When the selection is %s, then Flow Analyzer does not get the target org', (_desc, ruleSelectors) => { + const factory = new CodeAnalyzerConfigFactoryImpl(); + const testedConfig = factory.create(undefined, { targetOrg: 'my-org@example.com', ruleSelectors }); + + expect(testedConfig.getEngineOverridesFor('apexguru')).toEqual({target_org: 'my-org@example.com'}); + expect(testedConfig.getEngineOverridesFor('flowAnalyzer')).toEqual({}); + }); + + it.each([ + ['the engine name', ['flowAnalyzer']], + ['the engine name in any case', ['FLOWANALYZER']], + ['the opt-in tag', ['flow-analyzer']], + ['a selector combining it with others', ['Recommended,flowAnalyzer']], + ['an engine-qualified selector', ['(flowAnalyzer:High)']] + ])('When the selection includes %s, then Flow Analyzer gets the target org', (_desc, ruleSelectors) => { + const factory = new CodeAnalyzerConfigFactoryImpl(); + const testedConfig = factory.create(undefined, { targetOrg: 'my-org@example.com', ruleSelectors }); + + expect(testedConfig.getEngineOverridesFor('flowAnalyzer')).toEqual({target_org: 'my-org@example.com'}); + }); + + it('When a config file already configures Flow Analyzer, then its other settings are kept alongside the target org', () => { + const factory = new CodeAnalyzerConfigFactoryImpl(); + const configPath = path.resolve('test', 'fixtures', 'valid-configs', 'config-without-suppressions-field.yml'); + const testedConfig = factory.create(configPath, { targetOrg: 'my-org@example.com', ruleSelectors: ['flowAnalyzer'] }); + + expect(testedConfig.getEngineOverridesFor('flowAnalyzer')).toEqual({target_org: 'my-org@example.com'}); + }); + }); }); }); });