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
2 changes: 1 addition & 1 deletion packages/ENGINE-TEMPLATE/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"types": "dist/index.d.ts",
"dependencies": {
"@types/node": "^20.0.0",
"@salesforce/code-analyzer-engine-api": "0.38.0"
"@salesforce/code-analyzer-engine-api": "0.39.0-SNAPSHOT"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
Expand Down
4 changes: 2 additions & 2 deletions packages/code-analyzer-apexguru-engine/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@salesforce/code-analyzer-apexguru-engine",
"description": "ApexGuru Engine Package for the Salesforce Code Analyzer",
"version": "0.38.0",
"version": "0.39.0-SNAPSHOT",
"author": "The Salesforce Code Analyzer Team",
"license": "BSD-3-Clause",
"homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview",
Expand All @@ -13,7 +13,7 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"dependencies": {
"@salesforce/code-analyzer-engine-api": "0.38.0",
"@salesforce/code-analyzer-engine-api": "0.39.0-SNAPSHOT",
"@salesforce/core": "^8.28.3"
},
"devDependencies": {
Expand Down
15 changes: 12 additions & 3 deletions packages/code-analyzer-apexguru-engine/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
LogLevel
} from '@salesforce/code-analyzer-engine-api';
import { ApexGuruService } from './services/ApexGuruService';
import { ApexGuruViolation, ApexGuruLocation, ApexGuruFix, ApexGuruSuggestion } from './types';
import { ApexGuruViolation, ApexGuruLocation, ApexGuruFix, ApexGuruSuggestion, ApexGuruScanMetadata } from './types';
import { ApexGuruEngineConfig, DEFAULT_APEXGURU_ENGINE_CONFIG } from './config';
import { ENGINE_NAME, APEXGURU_FILE_EXTENSIONS } from './constants';
import { APEXGURU_RULES, isKnownRule, FALLBACK_RULE_NAME } from './apexguru-rules';
Expand Down Expand Up @@ -122,6 +122,7 @@ export class ApexGuruEngine extends EngineEventEmitter implements Engine {
try {
// Analyze each file
const allViolations: Violation[] = [];
const scanMetadataByFile: { [filePath: string]: ApexGuruScanMetadata } = {};
let filesProcessed = 0;

for (let i = 0; i < apexFiles.length; i++) {
Expand All @@ -142,11 +143,15 @@ export class ApexGuruEngine extends EngineEventEmitter implements Engine {
});

const fileContent = await fs.readFile(filePath, 'utf-8');
const apexGuruViolations: ApexGuruViolation[] = await this.apexGuruService.analyzeApexClass(
const { violations: apexGuruViolations, scanMetadata } = await this.apexGuruService.analyzeApexClass(
fileContent,
filePath
);

if (scanMetadata) {
scanMetadataByFile[filePath] = scanMetadata;
}

const violations = apexGuruViolations.map(av =>
toViolation(av, filePath, runOptions.includeFixes ?? false, runOptions.includeSuggestions ?? false)
);
Expand Down Expand Up @@ -175,7 +180,11 @@ export class ApexGuruEngine extends EngineEventEmitter implements Engine {
}
}

return { violations: allViolations };
const insights: Record<string, unknown> | undefined = Object.keys(scanMetadataByFile).length > 0
? scanMetadataByFile
: undefined;

return { violations: allViolations, insights };
} finally {
// Always cleanup resources to allow process to exit
this.apexGuruService.cleanup();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ApexGuruInitialResponse,
ApexGuruQueryResponse,
ApexGuruResponseStatus,
ApexGuruScanMetadata,
ApexGuruViolation
} from '../types';
import * as http from 'node:http';
Expand Down Expand Up @@ -132,7 +133,7 @@ export class ApexGuruService {
* Submit Apex class for analysis and wait for results
* Wraps submit + poll together with a single timeout (api_timeout_ms)
*/
async analyzeApexClass(classContent: string, filePath: string): Promise<ApexGuruViolation[]> {
async analyzeApexClass(classContent: string, filePath: string): Promise<{violations: ApexGuruViolation[], scanMetadata?: ApexGuruScanMetadata}> {
this.isCancelled = false;
let timeoutId: NodeJS.Timeout;
const analysisPromise = this.performAnalysis(classContent);
Expand All @@ -154,14 +155,12 @@ export class ApexGuruService {
* Internal analysis implementation (without timeout wrapper)
* Performs submit + poll
*/
private async performAnalysis(classContent: string): Promise<ApexGuruViolation[]> {
private async performAnalysis(classContent: string): Promise<{violations: ApexGuruViolation[], scanMetadata?: ApexGuruScanMetadata}> {
// Step 1: Submit request
const requestId = await this.submitAnalysis(classContent);

// Step 2: Poll for results
const violations = await this.pollForResults(requestId);

return violations;
return await this.pollForResults(requestId);
}

/**
Expand Down Expand Up @@ -207,7 +206,7 @@ export class ApexGuruService {
* Poll for analysis results with exponential backoff
* Note: Timeout is handled by analyzeApexClass wrapper, not here
*/
private async pollForResults(requestId: string): Promise<ApexGuruViolation[]> {
private async pollForResults(requestId: string): Promise<{violations: ApexGuruViolation[], scanMetadata?: ApexGuruScanMetadata}> {
const connection: Connection = this.authService.getConnection();
const apiVersion = this.authService.getApiVersion();
const url = requestId === 'pending'
Expand Down Expand Up @@ -247,7 +246,8 @@ export class ApexGuruService {

// Check if analysis is complete
if (response.status === ApexGuruResponseStatus.SUCCESS && response.report) {
return this.parseReport(response.report);
const violations = this.parseReport(response.report);
return { violations, scanMetadata: response.scanMetadata };
}

// Check for failures
Expand Down
22 changes: 22 additions & 0 deletions packages/code-analyzer-apexguru-engine/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type ApexGuruInitialResponse = ApexGuruResponse & {
*/
export type ApexGuruQueryResponse = ApexGuruResponse & {
report?: string; // Base64 encoded JSON array of violations
scanMetadata?: ApexGuruScanMetadata;
};

/**
Expand Down Expand Up @@ -104,3 +105,24 @@ export type OrgJwtResponse = {
jwt: string;
message?: string | null;
};

/**
* Scan metadata from SFAP ApexGuru API response
* Provides insights about the analysis run
*/
export type ApexGuruScanMetadata = {
/** Analysis mode used: 'full' or 'static' */
analysis_mode: 'full' | 'static';

/** Number of files scanned in this analysis */
files_scanned: number;

/** Breakdown of violation counts by rule name */
violation_breakdown: { [ruleName: string]: number };

/** Total number of violations found */
violation_count: number;

/** Timestamp when report was generated (milliseconds since epoch) */
report_generated_ms: number;
};
65 changes: 48 additions & 17 deletions packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ describe('ApexGuruEngine', () => {

it('should authenticate and validate', async () => {
mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/Test.cls']);
mockApexGuruService.analyzeApexClass.mockResolvedValue([]);
mockApexGuruService.analyzeApexClass.mockResolvedValue({violations: []});
(fs.readFile as jest.Mock).mockResolvedValue('public class Test {}');

await engine.runRules(['SoqlInALoop'], mockRunOptions);
Expand Down Expand Up @@ -201,7 +201,7 @@ describe('ApexGuruEngine', () => {
'/test/Test.cls',
'/test/Controller.cls'
]);
mockApexGuruService.analyzeApexClass.mockResolvedValue([]);
mockApexGuruService.analyzeApexClass.mockResolvedValue({violations: []});
(fs.readFile as jest.Mock).mockResolvedValue('public class Test {}');

await engine.runRules(['SoqlInALoop'], mockRunOptions);
Expand All @@ -215,7 +215,7 @@ describe('ApexGuruEngine', () => {
mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/Test.cls']);

// ApexGuru API returns 3 violations but only 2 match selected rules
mockApexGuruService.analyzeApexClass.mockResolvedValue([
mockApexGuruService.analyzeApexClass.mockResolvedValue({violations: [
{
rule: 'SoqlInALoop',
message: 'SOQL in loop',
Expand All @@ -240,7 +240,7 @@ describe('ApexGuruEngine', () => {
severity: 2,
resources: []
}
]);
]});

(fs.readFile as jest.Mock).mockResolvedValue('public class Test {}');

Expand All @@ -257,7 +257,7 @@ describe('ApexGuruEngine', () => {

it('should include suggestions when includeSuggestions is true', async () => {
mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/Test.cls']);
mockApexGuruService.analyzeApexClass.mockResolvedValue([
mockApexGuruService.analyzeApexClass.mockResolvedValue({violations: [
{
rule: 'SoqlInALoop',
message: 'SOQL in loop',
Expand All @@ -272,7 +272,7 @@ describe('ApexGuruEngine', () => {
}
]
}
]);
]});
(fs.readFile as jest.Mock).mockResolvedValue('public class Test {}');

const results = await engine.runRules(['SoqlInALoop'], {
Expand All @@ -287,7 +287,7 @@ describe('ApexGuruEngine', () => {

it('should emit progress events', async () => {
mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/Test.cls']);
mockApexGuruService.analyzeApexClass.mockResolvedValue([]);
mockApexGuruService.analyzeApexClass.mockResolvedValue({violations: []});
(fs.readFile as jest.Mock).mockResolvedValue('public class Test {}');

const progressSpy = jest.spyOn(engine as any, 'emitRunRulesProgressEvent');
Expand All @@ -300,7 +300,7 @@ describe('ApexGuruEngine', () => {

it('should set progress callback on ApexGuru service', async () => {
mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/Test.cls']);
mockApexGuruService.analyzeApexClass.mockResolvedValue([]);
mockApexGuruService.analyzeApexClass.mockResolvedValue({violations: []});
(fs.readFile as jest.Mock).mockResolvedValue('public class Test {}');

await engine.runRules(['SoqlInALoop'], mockRunOptions);
Expand All @@ -319,9 +319,9 @@ describe('ApexGuruEngine', () => {

// Second file fails
mockApexGuruService.analyzeApexClass
.mockResolvedValueOnce([])
.mockResolvedValueOnce({violations: []})
.mockRejectedValueOnce(new Error('Analysis failed'))
.mockResolvedValueOnce([]);
.mockResolvedValueOnce({violations: []});


const results = await engine.runRules(['SoqlInALoop'], mockRunOptions);
Expand All @@ -333,7 +333,7 @@ describe('ApexGuruEngine', () => {

it('should always cleanup resources', async () => {
mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/Test.cls']);
mockApexGuruService.analyzeApexClass.mockResolvedValue([]);
mockApexGuruService.analyzeApexClass.mockResolvedValue({violations: []});
(fs.readFile as jest.Mock).mockResolvedValue('public class Test {}');

await engine.runRules(['SoqlInALoop'], mockRunOptions);
Expand All @@ -360,7 +360,7 @@ describe('ApexGuruEngine', () => {
mockWorkspace.getTargetedFiles.mockResolvedValue([
'/test/AccountTrigger.trigger'
]);
mockApexGuruService.analyzeApexClass.mockResolvedValue([]);
mockApexGuruService.analyzeApexClass.mockResolvedValue({violations: []});
(fs.readFile as jest.Mock).mockResolvedValue('trigger AccountTrigger on Account {}');

await engine.runRules(['SoqlInALoop'], mockRunOptions);
Expand All @@ -376,7 +376,7 @@ describe('ApexGuruEngine', () => {
const engineWithConfig = new ApexGuruEngine({ target_org: 'my-org', api_timeout_ms: 120000, api_initial_retry_ms: 2000, api_max_retry_ms: 60000, api_backoff_multiplier: 2 });

mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/Test.cls']);
mockApexGuruService.analyzeApexClass.mockResolvedValue([]);
mockApexGuruService.analyzeApexClass.mockResolvedValue({violations: []});
(fs.readFile as jest.Mock).mockResolvedValue('public class Test {}');

await engineWithConfig.runRules(['SoqlInALoop'], mockRunOptions);
Expand All @@ -394,7 +394,7 @@ describe('ApexGuruEngine', () => {

// First file returns 1 violation, second file returns 2 violations
mockApexGuruService.analyzeApexClass
.mockResolvedValueOnce([
.mockResolvedValueOnce({violations: [
{
rule: 'SoqlInALoop',
message: 'Violation 1',
Expand All @@ -403,8 +403,8 @@ describe('ApexGuruEngine', () => {
severity: 1,
resources: []
}
])
.mockResolvedValueOnce([
]})
.mockResolvedValueOnce({violations: [
{
rule: 'SoqlInALoop',
message: 'Violation 2',
Expand All @@ -421,13 +421,44 @@ describe('ApexGuruEngine', () => {
severity: 1,
resources: []
}
]);
]});

(fs.readFile as jest.Mock).mockResolvedValue('public class Test {}');

const results = await engine.runRules(['SoqlInALoop', 'DmlInALoop'], mockRunOptions);

expect(results.violations).toHaveLength(3);
});

it('should populate insights in results when scanMetadata is returned', async () => {
mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/Test.cls']);
const mockScanMetadata = {
analysis_mode: 'full' as const,
files_scanned: 1,
violation_breakdown: { SoqlInALoop: 1 },
violation_count: 1,
report_generated_ms: 1234567890
};
mockApexGuruService.analyzeApexClass.mockResolvedValue({
violations: [],
scanMetadata: mockScanMetadata
});
(fs.readFile as jest.Mock).mockResolvedValue('public class Test {}');

const results = await engine.runRules(['SoqlInALoop'], mockRunOptions);

expect(results.insights).toBeDefined();
expect(results.insights!['/test/Test.cls']).toEqual(mockScanMetadata);
});

it('should not include insights in results when no scanMetadata is returned', async () => {
mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/Test.cls']);
mockApexGuruService.analyzeApexClass.mockResolvedValue({violations: []});
(fs.readFile as jest.Mock).mockResolvedValue('public class Test {}');

const results = await engine.runRules(['SoqlInALoop'], mockRunOptions);

expect(results.insights).toBeUndefined();
});
});
});
Loading
Loading