Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .git2gus/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@
"type:security": "BUG P0",
"type:feedback": "",
"type:duplicate": ""
}
},
"statusWhenClosed": "CLOSED"
}
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
npm run lint
2 changes: 1 addition & 1 deletion SHA256.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ make sure that their SHA values match the values in the list below.
shasum -a 256 <location_of_the_downloaded_file>

3. Confirm that the SHA in your output matches the value in this list of SHAs.
a26fa56962cc53dfd3deeb0a32f184218e71b0110eb96b1a56966d363538fee6 ./extensions/sfdx-code-analyzer-vscode-1.9.0.vsix
92d8b8bf23aec05328349ceb9b471fd004fe3d530f584b066d56cca6bf41c009 ./extensions/sfdx-code-analyzer-vscode-1.10.0.vsix
4. Change the filename extension for the file that you downloaded from .zip to
.vsix.

Expand Down
2,442 changes: 1,990 additions & 452 deletions package-lock.json

Large diffs are not rendered by default.

40 changes: 19 additions & 21 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"color": "#ECECEC",
"theme": "light"
},
"version": "1.10.0",
"version": "1.11.0",
"publisher": "salesforce",
"license": "BSD-3-Clause",
"engines": {
Expand All @@ -35,38 +35,40 @@
"dependencies": {
"@salesforce/vscode-service-provider": "^1.5.0",
"@types/jest": "^30.0.0",
"@types/semver": "^7.7.0",
"@types/semver": "^7.7.1",
"@types/tmp": "^0.2.6",
"diff": "^5.2.0",
"glob": "^11.0.3",
"semver": "^7.7.2",
"tmp": "^0.2.4"
"tmp": "^0.2.5"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@types/diff": "^5.2.3",
"@eslint/js": "^9.35.0",
"@types/chai": "^4.3.20",
"@types/diff": "^5.2.3",
"@types/mocha": "^10.0.10",
"@types/node": "^22.16.4",
"@types/node": "^22.18.5",
"@types/sinon": "^10.0.20",
"@types/vscode": "^1.90.0",
"@vscode/test-cli": "^0.0.11",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.6.0",
"chai": "^4.5.0",
"esbuild": "^0.25.8",
"eslint": "^9.32.0",
"jest": "^30.0.5",
"jest-mock-vscode": "^4.6.0",
"esbuild": "^0.25.9",
"eslint": "^9.35.0",
"husky": "^9.1.7",
"jest": "^30.1.3",
"jest-mock-vscode": "^4.7.0",
"mocha": "^10.8.2",
"npm-run-all": "^4.1.5",
"ovsx": "^0.10.5",
"proxyquire": "^2.1.3",
"rimraf": "*",
"sinon": "^15.2.0",
"ts-jest": "^29.4.1",
"ts-jest": "^29.4.2",
"ts-node": "^10.9.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.39.0"
"typescript-eslint": "^8.44.0"
},
"extensionDependencies": [
"salesforce.salesforcedx-vscode-core"
Expand All @@ -89,7 +91,8 @@
"clean": "npm run precompile && rimraf coverage",
"showcoverage": "npm run showcoverage-unit && npm run showcoverage-legacy",
"showcoverage-unit": "open ./coverage/unit/lcov-report/index.html",
"showcoverage-legacy": "open ./coverage/legacy/lcov-report/index.html"
"showcoverage-legacy": "open ./coverage/legacy/lcov-report/index.html",
"prepare": "husky"
},
"activationEvents": [
"workspaceContains:sfdx-project.json",
Expand Down Expand Up @@ -138,11 +141,6 @@
"type": "boolean",
"default": false,
"description": "Scan files on open automatically."
},
"codeAnalyzer.apexGuru.enabled": {
"type": "boolean",
"default": false,
"description": "(Pilot) Discover critical problems and performance issues in your Apex code with ApexGuru, which analyzes your Apex files for you. This feature is in a closed pilot; contact your account representative to learn more."
}
}
},
Expand Down Expand Up @@ -186,7 +184,7 @@
},
{
"command": "sfca.runApexGuruAnalysisOnCurrentFile",
"when": "sfca.extensionActivated && sfca.apexGuruEnabled && resourceExtname =~ /\\.cls|\\.trigger|\\.apex/"
"when": "sfca.extensionActivated && sfca.shouldShowApexGuruButtons && resourceExtname =~ /\\.cls|\\.trigger|\\.apex/"
}
],
"editor/context": [
Expand All @@ -200,7 +198,7 @@
},
{
"command": "sfca.runApexGuruAnalysisOnCurrentFile",
"when": "sfca.extensionActivated && sfca.apexGuruEnabled && resourceExtname =~ /\\.cls|\\.trigger|\\.apex/"
"when": "sfca.extensionActivated && sfca.shouldShowApexGuruButtons && resourceExtname =~ /\\.cls|\\.trigger|\\.apex/"
}
],
"explorer/context": [
Expand All @@ -214,7 +212,7 @@
},
{
"command": "sfca.runApexGuruAnalysisOnSelectedFile",
"when": "sfca.extensionActivated && sfca.apexGuruEnabled && explorerResourceIsFolder == false && resourceExtname =~ /\\.cls|\\.trigger|\\.apex/"
"when": "sfca.extensionActivated && sfca.shouldShowApexGuruButtons && explorerResourceIsFolder == false && resourceExtname =~ /\\.cls|\\.trigger|\\.apex/"
}
]
},
Expand Down
30 changes: 7 additions & 23 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {PMDSupressionsCodeActionProvider} from './lib/pmd/pmd-suppressions-code-
import {ApplyViolationFixesActionProvider} from './lib/apply-violation-fixes-action-provider';
import {ApplyViolationFixesAction} from './lib/apply-violation-fixes-action';
import {ViolationSuggestionsHoverProvider} from './lib/violation-suggestions-hover-provider';
import {ApexGuruService, LiveApexGuruService} from './lib/apexguru/apex-guru-service';
import {ApexGuruAccess, ApexGuruService, LiveApexGuruService} from './lib/apexguru/apex-guru-service';
import {ApexGuruRunAction} from './lib/apexguru/apex-guru-run-action';
import {OrgConnectionService} from './lib/external-services/org-connection-service';

Expand Down Expand Up @@ -246,15 +246,12 @@ export async function activate(context: vscode.ExtensionContext): Promise<SFCAEx
// =================================================================================================================
const apexGuruService: ApexGuruService = new LiveApexGuruService(orgConnectionService, fileHandler, logger);
const apexGuruRunAction: ApexGuruRunAction = new ApexGuruRunAction(taskWithProgressRunner, apexGuruService, diagnosticManager, telemetryService, display);

// TODO: This is temporary and will change soon when we remove pilot flag and instead add a watch to org auth changes
const isApexGuruEnabled: () => Promise<boolean> = async () => settingsManager.getApexGuruEnabled() &&
// Currently we don't watch for changes here when a user has apex guru enabled already. That is,
// if the user logs into an org post activation of this extension, it won't show the command until they
// refresh or toggle the "ApexGuru enabled" setting off and back on. At some point we might want to see
// if it is possible to monitor changes to the users org so we can re-trigger this check.
await apexGuruService.isApexGuruAvailable();
await establishVariableInContext(Constants.CONTEXT_VAR_APEX_GURU_ENABLED, isApexGuruEnabled);
apexGuruService.onAccessChange((access: ApexGuruAccess) => {
logger.debug(`Access to ApexGuru has been set '${access}'.`);
void vscode.commands.executeCommand('setContext', Constants.CONTEXT_VAR_SHOULD_SHOW_APEX_GURU_BUTTONS,
access === ApexGuruAccess.ENABLED || access === ApexGuruAccess.ELIGIBLE);
});
void apexGuruService.updateAvailability(); // This asyncronously triggers the first AccessChanged Event to establish the context variable

// COMMAND_RUN_APEX_GURU_ON_FILE: Invokable by 'explorer/context' menu only when: "sfca.apexGuruEnabled && explorerResourceIsFolder == false && resourceExtname =~ /\\.cls|\\.trigger|\\.apex/"
registerCommand(Constants.COMMAND_RUN_APEX_GURU_ON_FILE, async (selection: vscode.Uri, multiSelect?: vscode.Uri[]) => {
Expand Down Expand Up @@ -319,19 +316,6 @@ export function _isValidFileForAnalysis(documentUri: vscode.Uri): boolean {
return allowedFileTypes.includes(path.extname(documentUri.fsPath));
}

// TODO: This is only used by apex guru right now and is tied to the pilot setting. Soon we will be removing the pilot
// setting and instead we should be adding a watch to the onOrgChange event of the OrgConnectionService instead.
// Inside our package.json you'll see things like:
// "when": "sfca.apexGuruEnabled"
// which helps determine when certain commands and menus are available.
// To make these "context variables" set and stay updated when settings change, use this helper function:
async function establishVariableInContext(varUsedInPackageJson: string, getValueFcn: () => Promise<boolean>): Promise<void> {
await vscode.commands.executeCommand('setContext', varUsedInPackageJson, await getValueFcn());
vscode.workspace.onDidChangeConfiguration(async () => {
await vscode.commands.executeCommand('setContext', varUsedInPackageJson, await getValueFcn());
});
}

async function getActiveDocument(): Promise<vscode.TextDocument | null> {
// Note that the active editor window could be the output window instead of the actual file editor, so we
// force focus it first to ensure we are getting the correct editor
Expand Down
12 changes: 11 additions & 1 deletion src/lib/apexguru/apex-guru-run-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { TelemetryService } from "../external-services/telemetry-service";
import { Display } from "../display";
import { messages } from "../messages";
import { getErrorMessage, getErrorMessageWithStack } from "../utils";
import { APEX_GURU_ENGINE_NAME, ApexGuruService } from "./apex-guru-service";
import { APEX_GURU_ENGINE_NAME, ApexGuruAccess, ApexGuruAvailability, ApexGuruService } from "./apex-guru-service";

export class ApexGuruRunAction {
private readonly taskWithProgressRunner: TaskWithProgressRunner;
Expand All @@ -33,6 +33,16 @@ export class ApexGuruRunAction {
const startTime: number = Date.now();

try {
const availability: ApexGuruAvailability = this.apexGuruService.getAvailability();
if (availability.access !== ApexGuruAccess.ENABLED) {
this.display.displayError(availability.message);
this.telemetryService.sendCommandEvent(Constants.TELEM_APEX_GURU_FILE_ANALYSIS_NOT_ENABLED, {
executedCommand: commandName,
access: availability.access
});
return;
}

progressReporter.reportProgress({
message: messages.apexGuru.runningAnalysis
});
Expand Down
95 changes: 86 additions & 9 deletions src/lib/apexguru/apex-guru-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import {CodeLocation, Fix, Suggestion, Violation} from '../diagnostics';
import {CodeLocation, Fix, normalizeViolation, Suggestion, Violation} from '../diagnostics';
import {Logger} from "../logger";
import {getErrorMessage, indent} from '../utils';
import {HttpMethods, HttpRequest, OrgConnectionService} from '../external-services/org-connection-service';
import {HttpMethods, HttpRequest, OrgConnectionService, OrgUserInfo} from '../external-services/org-connection-service';
import {FileHandler} from '../fs-utils';
import { messages } from '../messages';
import { EventEmitter } from 'node:stream';

export const APEX_GURU_ENGINE_NAME: string = 'apexguru';
const APEX_GURU_MAX_TIMEOUT_SECONDS = 60;
Expand All @@ -24,16 +25,43 @@ const RESPONSE_STATUS = {
}

export interface ApexGuruService {
isApexGuruAvailable(): Promise<boolean>;
getAvailability(): ApexGuruAvailability;
updateAvailability(): Promise<void>;
onAccessChange(callback: (access: ApexGuruAccess) => void): void;
scan(absFileToScan: string): Promise<Violation[]>;
}

export type ApexGuruAvailability = {
access: ApexGuruAccess,
message: string
}

export enum ApexGuruAccess {
// In this case, ApexGuru scans are allowed
ENABLED = "enabled",

// In this case, the org is eligible to be enabled, but an admin hasn't set the permissions yet, so we should still
// show the scan button but then show a message with the instructions sent from the validate endpoint.
ELIGIBLE = "eligible-but-not-enabled",

// In this case, the org is not eligible for ApexGuru at all, so we should not show the scan button at all.
INELIGIBLE = "ineligible",

// In this case, the user has not authed into an org, so we should not show the scan button at all.
NOT_AUTHED = "not-authed"
}

const ACCESS_CHANGED_EVENT = "apexGuruAccessChanged";

export class LiveApexGuruService implements ApexGuruService {
private readonly orgConnectionService: OrgConnectionService;
private readonly fileHandler: FileHandler;
private readonly logger: Logger;
private readonly maxTimeoutSeconds: number;
private readonly retryIntervalMillis: number;
private readonly eventEmitter: EventEmitter = new EventEmitter();
private availability?: ApexGuruAvailability;

constructor(
orgConnectionService: OrgConnectionService,
fileHandler: FileHandler,
Expand All @@ -45,14 +73,61 @@ export class LiveApexGuruService implements ApexGuruService {
this.logger = logger;
this.maxTimeoutSeconds = maxTimeoutSeconds;
this.retryIntervalMillis = retryIntervalMillis;

// Every time an org is changed (authed or unauthed) then we recalculate the availability asyncronously
orgConnectionService.onOrgChange((_orgUserInfo: OrgUserInfo) => {
void this.updateAvailability();
});
}

getAvailability(): ApexGuruAvailability {
if (this.availability === undefined) {
// This should never happen in production because updateAvailability must be called prior to enabling
// the user to even have access to any of the ApexGuru scan buttons. If it does, we should investigate.
throw new Error('The getAvailability method should not be called until updateAvailability is first called');
}
return this.availability;
}

onAccessChange(callback: (access: ApexGuruAccess) => void): void {
this.eventEmitter.addListener(ACCESS_CHANGED_EVENT, callback);
}

async isApexGuruAvailable(): Promise<boolean> {
async updateAvailability(): Promise<void> {
if (!this.orgConnectionService.isAuthed()) {
return false;
this.setAvailability({
access: ApexGuruAccess.NOT_AUTHED,
message: messages.apexGuru.noOrgAuthed
});
return;
}

const response: ApexGuruResponse = await this.request('GET', await this.getValidateEndpoint());
return response.status === RESPONSE_STATUS.SUCCESS;

if (response.status === RESPONSE_STATUS.SUCCESS) {
this.setAvailability({
access: ApexGuruAccess.ENABLED,

// This message isn't used anywhere except for debugging purposes and it allows us to make message field
// a string instead of a string | undefined.
message: "ApexGuru access is enabled."
});
} else {
this.setAvailability({
access: response.status === RESPONSE_STATUS.FAILED ? ApexGuruAccess.ELIGIBLE : ApexGuruAccess.INELIGIBLE,

// There should always be a message on failed and error responses, but adding this here just in case
message: response.message ?? `ApexGuru access is not enabled. Response: ${JSON.stringify(response)}`
});
}
}

private setAvailability(availability: ApexGuruAvailability) {
const oldAccess: ApexGuruAccess | undefined = this.availability?.access;
this.availability = availability;
if (availability.access !== oldAccess) {
this.eventEmitter.emit(ACCESS_CHANGED_EVENT, availability.access);
}
}

async scan(absFileToScan: string): Promise<Violation[]> {
Expand All @@ -63,7 +138,9 @@ export class LiveApexGuruService implements ApexGuruService {
const payloadStr: string = decodeFromBase64(queryResponse.report);
this.logger.debug(`ApexGuru Analysis completed for Request Id: ${requestId}\n\nDecoded Response Payload:\n${payloadStr}`);
const apexGuruViolations: ApexGuruViolation[] = parsePayload(payloadStr);
return apexGuruViolations.map(v => toViolation(v, absFileToScan));

const lineLengths: number[] = fileContent.split(/\r?\n/).map(l => l.length);
return apexGuruViolations.map(v => toViolation(v, absFileToScan, lineLengths));
}

private async initiateRequest(fileContent: string): Promise<string> {
Expand Down Expand Up @@ -149,7 +226,7 @@ export function parsePayload(payloadStr: string): ApexGuruViolation[] {
}
}

function toViolation(apexGuruViolation: ApexGuruViolation, file: string): Violation {
function toViolation(apexGuruViolation: ApexGuruViolation, file: string, lineLengths: number[]): Violation {
const codeAnalyzerViolation: Violation = {
rule: apexGuruViolation.rule,
engine: APEX_GURU_ENGINE_NAME,
Expand All @@ -168,7 +245,7 @@ function toViolation(apexGuruViolation: ApexGuruViolation, file: string): Violat
return f;
})
};
return codeAnalyzerViolation;
return normalizeViolation(codeAnalyzerViolation, lineLengths);
}

function addFile(apexGuruLocation: CodeLocation, filePath: string): CodeLocation {
Expand Down
6 changes: 4 additions & 2 deletions src/lib/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,10 @@ export class CliCommandExecutorImpl implements CliCommandExecutor {

let childProcess: cp.ChildProcessWithoutNullStreams;
try {
childProcess = IS_WINDOWS ? cp.spawn(command, wrapArgsWithSpacesWithQuotes(args), {shell: true}) :
cp.spawn(command, args);
childProcess =
IS_WINDOWS
? cp.spawn(command, wrapArgsWithSpacesWithQuotes(args), {shell: true, env: {...process.env, NO_COLOR: '1'}})
: cp.spawn(command, args, {env: {...process.env, NO_COLOR: '1'}});
} catch (err) {
this.logger.logAtLevel(vscode.LogLevel.Error, `Failed to execute the following command:\n` +
indent(`${command} ${wrapArgsWithSpacesWithQuotes(args).join(' ')}`) + `\n\n` +
Expand Down
Loading
Loading