Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions messages/run-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/commands/code-analyzer/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export default class RunCommand extends SfCommand<void> 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'),
// eslint-disable-next-line sf-plugin/dash-o
char: 'o'
}),
// === Flags pertaining to rule selection ===
'rule-selector': Flags.string({
summary: getMessage(BundleName.RunCommand, 'flags.rule-selector.summary'),
Expand Down Expand Up @@ -102,6 +108,7 @@ export default class RunCommand extends SfCommand<void> 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']
Expand Down
8 changes: 6 additions & 2 deletions src/lib/actions/RunAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -53,8 +54,11 @@ export class RunAction {
}

public async execute(input: RunInput): Promise<void> {
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'],
Expand Down
66 changes: 44 additions & 22 deletions src/lib/factories/CodeAnalyzerConfigFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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();
Expand Down Expand Up @@ -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);
}

Expand All @@ -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<string, unknown> = {
...rawYaml,
// Build merged config with any CLI overrides
let mergedConfig: Record<string, unknown> = 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<string, unknown> = {
...rawYaml,
suppressions: { disable_suppressions: cliOverrides.noSuppressions }
// Apply target-org override to apexguru engine config
if (cliOverrides.targetOrg) {
const engineOverridesSection = mergedConfig.engine_overrides as Record<string, Record<string, unknown>> | 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<string, unknown> = {};

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 {
Expand Down
4 changes: 3 additions & 1 deletion src/lib/factories/EnginePluginsFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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';

Check failure on line 8 in src/lib/factories/EnginePluginsFactory.ts

View workflow job for this annotation

GitHub Actions / run_tests / unit-tests (lts/*, lts, ubuntu-latest)

Cannot find module '@salesforce/code-analyzer-apexguru-engine' or its corresponding type declarations.

Check failure on line 8 in src/lib/factories/EnginePluginsFactory.ts

View workflow job for this annotation

GitHub Actions / run_tests / build-installable-tarball

Cannot find module '@salesforce/code-analyzer-apexguru-engine' or its corresponding type declarations.

Check failure on line 8 in src/lib/factories/EnginePluginsFactory.ts

View workflow job for this annotation

GitHub Actions / run_tests / unit-tests (lts/*, lts, macos-latest)

Cannot find module '@salesforce/code-analyzer-apexguru-engine' or its corresponding type declarations.

Check failure on line 8 in src/lib/factories/EnginePluginsFactory.ts

View workflow job for this annotation

GitHub Actions / run_tests / unit-tests (lts/*, lts, windows-latest)

Cannot find module '@salesforce/code-analyzer-apexguru-engine' or its corresponding type declarations.

export interface EnginePluginsFactory {
create(): EnginePlugin[];
Expand All @@ -18,7 +19,8 @@
RetireJSEngineModule.createEnginePlugin(),
RegexEngineModule.createEnginePlugin(),
FlowEngineModule.createEnginePlugin(),
SfgeEngineModule.createEnginePlugin()
SfgeEngineModule.createEnginePlugin(),
ApexGuruEngineModule.createEnginePlugin()
];
}
}
65 changes: 64 additions & 1 deletion test/commands/code-analyzer/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -469,6 +516,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 () => {
Expand Down
38 changes: 38 additions & 0 deletions test/lib/actions/RunAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
);
});
});
});
});

Expand Down
3 changes: 2 additions & 1 deletion test/lib/factories/EnginePluginsFactory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
32 changes: 32 additions & 0 deletions test/stubs/StubRunResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown> | undefined {
return undefined;
}
}

export class StubNonEmptyResults implements RunResults {
Expand Down Expand Up @@ -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<string, unknown> | 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<string, unknown> | undefined {
return undefined;
}
}
Loading