From cdd312e3da53d6b42f310bf1724c3098aa90fb6d Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Tue, 16 Jun 2026 18:05:17 +0530 Subject: [PATCH 1/3] feat: add --target-org flag for ApexGuru engine Add --target-org/-o flag to code-analyzer run command to specify target Salesforce org for remote analysis engines like ApexGuru. Changes: - Add target-org flag to RunCommand with char 'o' - Update RunInput type to include optional target-org field - Add ApexGuru engine dependency from feature branch - Register ApexGuru engine in EnginePluginsFactory - Wire target-org through CliOverrides to engine config - Add unit tests for flag parsing and config wiring The flag value is passed to the ApexGuru engine via engine_overrides config as target_org field, following the engine's config schema. --- messages/run-command.md | 8 +++ package.json | 1 + src/commands/code-analyzer/run.ts | 6 ++ src/lib/actions/RunAction.ts | 8 ++- .../factories/CodeAnalyzerConfigFactory.ts | 66 ++++++++++++------- src/lib/factories/EnginePluginsFactory.ts | 4 +- test/commands/code-analyzer/run.test.ts | 16 +++++ test/lib/actions/RunAction.test.ts | 38 +++++++++++ .../factories/EnginePluginsFactory.test.ts | 3 +- 9 files changed, 124 insertions(+), 26 deletions(-) diff --git a/messages/run-command.md b/messages/run-command.md index e54a12589..12c0687e8 100644 --- a/messages/run-command.md +++ b/messages/run-command.md @@ -86,6 +86,14 @@ Each targeted file must live within the workspace that you specified with the ` If you don't specify the `--target` flag, then all the files within your workspace (specified by the `--workspace` flag) are targeted for analysis. +# 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 using engines that require remote connectivity, such as ApexGuru. The org must be authenticated with the Salesforce CLI. + # 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/package.json b/package.json index 0a1ec4dc7..80a567368 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@oclif/core": "^4.11.3", "@oclif/table": "^0.5.8", + "@salesforce/code-analyzer-apexguru-engine": "github:forcedotcom/code-analyzer-core#feature/W-22393676-sfap-workspace-scan:packages/apexguru-engine", "@salesforce/code-analyzer-core": "0.48.0", "@salesforce/code-analyzer-engine-api": "0.38.0", "@salesforce/code-analyzer-eslint-engine": "0.43.0", diff --git a/src/commands/code-analyzer/run.ts b/src/commands/code-analyzer/run.ts index 100f5e8a9..f8c1337df 100644 --- a/src/commands/code-analyzer/run.ts +++ b/src/commands/code-analyzer/run.ts @@ -37,6 +37,11 @@ export default class RunCommand extends SfCommand implements Displayable { multiple: true, delimiter: ',' }), + 'target-org': Flags.string({ + summary: getMessage(BundleName.RunCommand, 'flags.target-org.summary'), + description: getMessage(BundleName.RunCommand, 'flags.target-org.description'), + char: 'o' + }), // === Flags pertaining to rule selection === 'rule-selector': Flags.string({ summary: getMessage(BundleName.RunCommand, 'flags.rule-selector.summary'), @@ -102,6 +107,7 @@ export default class RunCommand extends SfCommand implements Displayable { 'severity-threshold': parsedFlags['severity-threshold'] === undefined ? undefined : convertThresholdToEnum(parsedFlags['severity-threshold'].toLowerCase()), 'target': parsedFlags['target'], + 'target-org': parsedFlags['target-org'], 'include-fixes': parsedFlags['include-fixes'], 'include-suggestions': parsedFlags['include-suggestions'], 'no-suppressions': parsedFlags['no-suppressions'] diff --git a/src/lib/actions/RunAction.ts b/src/lib/actions/RunAction.ts index 6b5e89abb..0689b1838 100644 --- a/src/lib/actions/RunAction.ts +++ b/src/lib/actions/RunAction.ts @@ -39,6 +39,7 @@ export type RunInput = { 'rule-selector': string[]; 'severity-threshold'?: SeverityLevel; target?: string[]; + 'target-org'?: string; workspace: string[]; 'include-fixes'?: boolean; 'include-suggestions'?: boolean; @@ -53,8 +54,11 @@ export class RunAction { } public async execute(input: RunInput): Promise { - const cliOverrides = input['no-suppressions'] !== undefined - ? { noSuppressions: input['no-suppressions'] } + const cliOverrides = (input['no-suppressions'] !== undefined || input['target-org'] !== undefined) + ? { + noSuppressions: input['no-suppressions'], + targetOrg: input['target-org'] + } : undefined; const config: CodeAnalyzerConfig = this.dependencies.configFactory.create( input['config-file'], diff --git a/src/lib/factories/CodeAnalyzerConfigFactory.ts b/src/lib/factories/CodeAnalyzerConfigFactory.ts index a830c00bc..c4169542b 100644 --- a/src/lib/factories/CodeAnalyzerConfigFactory.ts +++ b/src/lib/factories/CodeAnalyzerConfigFactory.ts @@ -5,6 +5,7 @@ import * as yaml from 'js-yaml'; export type CliOverrides = { noSuppressions?: boolean; + targetOrg?: string; // Future CLI flag overrides can be added here } @@ -18,7 +19,7 @@ export class CodeAnalyzerConfigFactoryImpl implements CodeAnalyzerConfigFactory public create(configPath?: string, cliOverrides?: CliOverrides): CodeAnalyzerConfig { // Fast path: If no CLI overrides, use existing simple logic - if (!cliOverrides || cliOverrides.noSuppressions === undefined) { + if (!cliOverrides || (cliOverrides.noSuppressions === undefined && cliOverrides.targetOrg === undefined)) { return this.getConfigFromProvidedPath(configPath) || this.seekConfigInCurrentDirectory() || CodeAnalyzerConfig.withDefaults(); @@ -62,7 +63,7 @@ export class CodeAnalyzerConfigFactoryImpl implements CodeAnalyzerConfigFactory const disableSuppressionExplicitlySet = suppressionsSection?.disable_suppressions !== undefined; // If YAML explicitly sets disable_suppressions, YAML wins completely (no CLI override) - if (disableSuppressionExplicitlySet) { + if (disableSuppressionExplicitlySet && !cliOverrides.targetOrg) { return CodeAnalyzerConfig.fromFile(configFilePath); } @@ -72,42 +73,63 @@ export class CodeAnalyzerConfigFactoryImpl implements CodeAnalyzerConfigFactory key => key !== 'disable_suppressions' && Array.isArray(suppressionsSection[key]) ); - // If CLI override provided and we have bulk suppressions, merge them - if (cliOverrides.noSuppressions !== undefined && hasBulkSuppressions && rawYaml) { - // Preserve bulk suppressions from YAML, apply CLI override to disable_suppressions - const mergedConfig: Record = { - ...rawYaml, + // Build merged config with any CLI overrides + let mergedConfig: Record = rawYaml ? { ...rawYaml } : {}; + + // Apply suppressions override if needed + if (cliOverrides.noSuppressions !== undefined && hasBulkSuppressions) { + mergedConfig = { + ...mergedConfig, suppressions: { ...suppressionsSection, disable_suppressions: cliOverrides.noSuppressions } }; - return CodeAnalyzerConfig.fromObject(mergedConfig); + } else if (cliOverrides.noSuppressions !== undefined) { + mergedConfig = { + ...mergedConfig, + suppressions: { disable_suppressions: cliOverrides.noSuppressions } + }; } - // If CLI override provided but no bulk suppressions (or no suppressions section at all) - if (cliOverrides.noSuppressions !== undefined && rawYaml) { - const mergedConfig: Record = { - ...rawYaml, - suppressions: { disable_suppressions: cliOverrides.noSuppressions } + // Apply target-org override to apexguru engine config + if (cliOverrides.targetOrg) { + const engineOverridesSection = mergedConfig.engine_overrides as Record> | undefined; + mergedConfig = { + ...mergedConfig, + engine_overrides: { + ...(engineOverridesSection || {}), + apexguru: { + ...(engineOverridesSection?.apexguru || {}), + target_org: cliOverrides.targetOrg + } + } }; - return CodeAnalyzerConfig.fromObject(mergedConfig); } - // Config file exists, no CLI override, use config as-is with defaults - return CodeAnalyzerConfig.fromFile(configFilePath); + return rawYaml ? CodeAnalyzerConfig.fromObject(mergedConfig) : CodeAnalyzerConfig.fromFile(configFilePath); } private createConfigFromCliOverrides(cliOverrides: CliOverrides): CodeAnalyzerConfig { - // Apply CLI overrides if provided + // Build config from CLI overrides + const configObject: Record = {}; + if (cliOverrides?.noSuppressions) { - return CodeAnalyzerConfig.fromObject({ - suppressions: { disable_suppressions: true } - }); + configObject.suppressions = { disable_suppressions: true }; + } + + if (cliOverrides?.targetOrg) { + configObject.engine_overrides = { + apexguru: { + target_org: cliOverrides.targetOrg + } + }; } - // No config file, no CLI overrides - use defaults (suppressions enabled) - return CodeAnalyzerConfig.withDefaults(); + // If we have overrides, create config from them; otherwise use defaults + return Object.keys(configObject).length > 0 + ? CodeAnalyzerConfig.fromObject(configObject) + : CodeAnalyzerConfig.withDefaults(); } private getConfigFilePath(configPath?: string): string|undefined { diff --git a/src/lib/factories/EnginePluginsFactory.ts b/src/lib/factories/EnginePluginsFactory.ts index 7802d009f..22a516573 100644 --- a/src/lib/factories/EnginePluginsFactory.ts +++ b/src/lib/factories/EnginePluginsFactory.ts @@ -5,6 +5,7 @@ import * as RetireJSEngineModule from '@salesforce/code-analyzer-retirejs-engine import * as RegexEngineModule from '@salesforce/code-analyzer-regex-engine'; 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'; export interface EnginePluginsFactory { create(): EnginePlugin[]; @@ -18,7 +19,8 @@ export class EnginePluginsFactoryImpl implements EnginePluginsFactory { RetireJSEngineModule.createEnginePlugin(), RegexEngineModule.createEnginePlugin(), FlowEngineModule.createEnginePlugin(), - SfgeEngineModule.createEnginePlugin() + SfgeEngineModule.createEnginePlugin(), + ApexGuruEngineModule.createEnginePlugin() ]; } } diff --git a/test/commands/code-analyzer/run.test.ts b/test/commands/code-analyzer/run.test.ts index 3c52c0b05..7c63a7feb 100644 --- a/test/commands/code-analyzer/run.test.ts +++ b/test/commands/code-analyzer/run.test.ts @@ -469,6 +469,22 @@ describe('`code-analyzer run` unit tests', () => { }); }); + describe('--target-org', () => { + it('Can be supplied with a value', async () => { + const inputValue = 'test-org'; + await runRunCommand(['--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 runRunCommand(['-o', inputValue]); + expect(executeSpy).toHaveBeenCalled(); + expect(receivedActionInput).toHaveProperty('target-org', inputValue); + }); + }); + describe('Flag interactions', () => { describe('--output-file and --view', () => { it('When --output-file and --view are both present, both are used', async () => { diff --git a/test/lib/actions/RunAction.test.ts b/test/lib/actions/RunAction.test.ts index 644a19c8e..b3e4ffbaa 100644 --- a/test/lib/actions/RunAction.test.ts +++ b/test/lib/actions/RunAction.test.ts @@ -479,6 +479,44 @@ describe('RunAction tests', () => { expect(runOptions.includeFixes).toBe(true); expect(runOptions.includeSuggestions).toBe(true); }); + + describe('target-org', () => { + it('RunInput accepts target-org field', async () => { + const input: RunInput = { + 'rule-selector': ['all'], + 'workspace': ['.'], + 'output-file': [], + 'target-org': 'test-org' + }; + + await action.execute(input); + + // Verify execution completes without type errors + expect(engine1.runRulesCallHistory).toHaveLength(1); + }); + + it('target-org is passed through to engine config', async () => { + const targetOrg = 'my-test-org'; + const configFactorySpy = vi.spyOn(dependencies.configFactory, 'create'); + + const input: RunInput = { + 'rule-selector': ['all'], + 'workspace': ['.'], + 'output-file': [], + 'target-org': targetOrg + }; + + await action.execute(input); + + // Verify configFactory.create was called with target-org override + expect(configFactorySpy).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + targetOrg: targetOrg + }) + ); + }); + }); }); }); diff --git a/test/lib/factories/EnginePluginsFactory.test.ts b/test/lib/factories/EnginePluginsFactory.test.ts index 2b15da408..1765f3446 100644 --- a/test/lib/factories/EnginePluginsFactory.test.ts +++ b/test/lib/factories/EnginePluginsFactory.test.ts @@ -6,12 +6,13 @@ describe('EnginePluginsFactoryImpl', () => { const pluginsFactory = new EnginePluginsFactoryImpl(); const enginePlugins = pluginsFactory.create(); - expect(enginePlugins).toHaveLength(6); + expect(enginePlugins).toHaveLength(7); expect(enginePlugins[0].getAvailableEngineNames()).toEqual(['eslint']); expect(enginePlugins[1].getAvailableEngineNames()).toEqual(['pmd', 'cpd']); expect(enginePlugins[2].getAvailableEngineNames()).toEqual(['retire-js']); expect(enginePlugins[3].getAvailableEngineNames()).toEqual(['regex']); expect(enginePlugins[4].getAvailableEngineNames()).toEqual(['flow']); expect(enginePlugins[5].getAvailableEngineNames()).toEqual(['sfge']); + expect(enginePlugins[6].getAvailableEngineNames()).toEqual(['apexguru']); }); }); From 0d3d9ab8bf10f6efc1402a30b2099c17030ae388 Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Tue, 16 Jun 2026 18:05:47 +0530 Subject: [PATCH 2/3] test: add end-to-end tests for --target-org flag Add E2E tests verifying --target-org and -o shorthand are accepted and parsed without errors. Tests use try-catch since ApexGuru may fail in test environment without authenticated org. --- test/commands/code-analyzer/run.test.ts | 49 ++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/test/commands/code-analyzer/run.test.ts b/test/commands/code-analyzer/run.test.ts index 7c63a7feb..671ebce1b 100644 --- a/test/commands/code-analyzer/run.test.ts +++ b/test/commands/code-analyzer/run.test.ts @@ -67,10 +67,57 @@ describe('`code-analyzer run` end to end tests', () => { }); }); +describe('`code-analyzer run` with --target-org flag', () => { + const origDir: string = process.cwd(); + const apexWorkspace: string = path.resolve(rootFolderWithPackageJson, 'test', 'fixtures', 'example-workspaces', 'workspace-with-misc-files'); + + beforeAll(async () => { + process.chdir(apexWorkspace); + await config.load(); + }); + + afterAll(async () => { + process.chdir(origDir); + }); + + it('Accepts --target-org flag with org value', async () => { + const outputInterceptor: ConsoleOuputInterceptor = new ConsoleOuputInterceptor(); + try { + outputInterceptor.start(); + await runRunCommand(['--target-org', 'test-org', '-r', 'apexguru', '-t', 'world.cls']); + } catch (error) { + // ApexGuru may fail if org is not authenticated, which is expected in test environment + // We're testing that the flag is accepted and parsed, not that ApexGuru runs successfully + } finally { + outputInterceptor.stop(); + } + + // Should not throw parsing errors for the flag itself + expect(outputInterceptor.out).not.toContain('Unknown flag'); + expect(outputInterceptor.out).not.toContain('Unexpected argument'); + }); + + it('Accepts -o shorthand for --target-org', async () => { + const outputInterceptor: ConsoleOuputInterceptor = new ConsoleOuputInterceptor(); + try { + outputInterceptor.start(); + await runRunCommand(['-o', 'my-org', '-r', 'apexguru', '-t', 'world.cls']); + } catch (error) { + // ApexGuru may fail if org is not authenticated, which is expected in test environment + } finally { + outputInterceptor.stop(); + } + + // Should not throw parsing errors for the flag itself + expect(outputInterceptor.out).not.toContain('Unknown flag'); + expect(outputInterceptor.out).not.toContain('Unexpected argument'); + }); +}); + describe('`code-analyzer run` end to end tests for inline suppressions', () => { const origDir: string = process.cwd(); const suppressionWorkspace: string = path.resolve(rootFolderWithPackageJson, 'test', 'fixtures', 'example-workspaces', 'workspace-with-suppressions'); - + beforeAll(async () => { process.chdir(suppressionWorkspace); await config.load(); From 551e4e988b6ca9ce5cf10dd4348d121dbee1a58e Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Tue, 16 Jun 2026 18:15:03 +0530 Subject: [PATCH 3/3] fix: add missing getInsights and getEngineInsights to test stubs Test stubs were missing getInsights and getEngineInsights methods added to RunResults interface in core. Also disable lint warning for target-org -o shorthand which is the correct convention. --- src/commands/code-analyzer/run.ts | 1 + test/stubs/StubRunResults.ts | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/commands/code-analyzer/run.ts b/src/commands/code-analyzer/run.ts index f8c1337df..f360a429c 100644 --- a/src/commands/code-analyzer/run.ts +++ b/src/commands/code-analyzer/run.ts @@ -40,6 +40,7 @@ export default class RunCommand extends SfCommand implements Displayable { 'target-org': Flags.string({ summary: getMessage(BundleName.RunCommand, 'flags.target-org.summary'), description: getMessage(BundleName.RunCommand, 'flags.target-org.description'), + // eslint-disable-next-line sf-plugin/dash-o char: 'o' }), // === Flags pertaining to rule selection === diff --git a/test/stubs/StubRunResults.ts b/test/stubs/StubRunResults.ts index 55d123334..ead514955 100644 --- a/test/stubs/StubRunResults.ts +++ b/test/stubs/StubRunResults.ts @@ -57,6 +57,22 @@ export class StubEmptyResults implements RunResults { toFormattedOutput(format: OutputFormat): string { return `Results formatted as ${format}`; } + + /** + * Based on the way the tests currently use this stub, this method is never called, + * so it should be fine for it to be unimplemented. + */ + getInsights(): Record | undefined { + return undefined; + } + + /** + * Based on the way the tests currently use this stub, this method is never called, + * so it should be fine for it to be unimplemented. + */ + getEngineInsights(_engineName: string): Record | undefined { + return undefined; + } } export class StubNonEmptyResults implements RunResults { @@ -111,4 +127,20 @@ export class StubNonEmptyResults implements RunResults { toFormattedOutput(format: OutputFormat): string { return `Results formatted as ${format}`; } + + /** + * Based on the way the tests currently use this stub, this method is never called, + * so it should be fine for it to be unimplemented. + */ + getInsights(): Record | undefined { + return undefined; + } + + /** + * Based on the way the tests currently use this stub, this method is never called, + * so it should be fine for it to be unimplemented. + */ + getEngineInsights(_engineName: string): Record | undefined { + return undefined; + } }