diff --git a/packages/ENGINE-TEMPLATE/package.json b/packages/ENGINE-TEMPLATE/package.json index 41d154fc..734ec461 100644 --- a/packages/ENGINE-TEMPLATE/package.json +++ b/packages/ENGINE-TEMPLATE/package.json @@ -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", diff --git a/packages/code-analyzer-apexguru-engine/package.json b/packages/code-analyzer-apexguru-engine/package.json index f9df9208..6fafbfe2 100644 --- a/packages/code-analyzer-apexguru-engine/package.json +++ b/packages/code-analyzer-apexguru-engine/package.json @@ -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", @@ -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": { diff --git a/packages/code-analyzer-apexguru-engine/src/engine.ts b/packages/code-analyzer-apexguru-engine/src/engine.ts index 98ac3acc..1dd255c6 100644 --- a/packages/code-analyzer-apexguru-engine/src/engine.ts +++ b/packages/code-analyzer-apexguru-engine/src/engine.ts @@ -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'; @@ -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++) { @@ -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) ); @@ -175,7 +180,11 @@ export class ApexGuruEngine extends EngineEventEmitter implements Engine { } } - return { violations: allViolations }; + const insights: Record | undefined = Object.keys(scanMetadataByFile).length > 0 + ? scanMetadataByFile + : undefined; + + return { violations: allViolations, insights }; } finally { // Always cleanup resources to allow process to exit this.apexGuruService.cleanup(); diff --git a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts index 1c60b626..2667b115 100644 --- a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts +++ b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts @@ -7,6 +7,7 @@ import { ApexGuruInitialResponse, ApexGuruQueryResponse, ApexGuruResponseStatus, + ApexGuruScanMetadata, ApexGuruViolation } from '../types'; import * as http from 'node:http'; @@ -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 { + async analyzeApexClass(classContent: string, filePath: string): Promise<{violations: ApexGuruViolation[], scanMetadata?: ApexGuruScanMetadata}> { this.isCancelled = false; let timeoutId: NodeJS.Timeout; const analysisPromise = this.performAnalysis(classContent); @@ -154,14 +155,12 @@ export class ApexGuruService { * Internal analysis implementation (without timeout wrapper) * Performs submit + poll */ - private async performAnalysis(classContent: string): Promise { + 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); } /** @@ -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 { + 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' @@ -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 diff --git a/packages/code-analyzer-apexguru-engine/src/types/index.ts b/packages/code-analyzer-apexguru-engine/src/types/index.ts index c827c34b..36382c47 100644 --- a/packages/code-analyzer-apexguru-engine/src/types/index.ts +++ b/packages/code-analyzer-apexguru-engine/src/types/index.ts @@ -45,6 +45,7 @@ export type ApexGuruInitialResponse = ApexGuruResponse & { */ export type ApexGuruQueryResponse = ApexGuruResponse & { report?: string; // Base64 encoded JSON array of violations + scanMetadata?: ApexGuruScanMetadata; }; /** @@ -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; +}; diff --git a/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts b/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts index 2b174b6f..95641319 100644 --- a/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts +++ b/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts @@ -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); @@ -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); @@ -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', @@ -240,7 +240,7 @@ describe('ApexGuruEngine', () => { severity: 2, resources: [] } - ]); + ]}); (fs.readFile as jest.Mock).mockResolvedValue('public class Test {}'); @@ -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', @@ -272,7 +272,7 @@ describe('ApexGuruEngine', () => { } ] } - ]); + ]}); (fs.readFile as jest.Mock).mockResolvedValue('public class Test {}'); const results = await engine.runRules(['SoqlInALoop'], { @@ -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'); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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', @@ -403,8 +403,8 @@ describe('ApexGuruEngine', () => { severity: 1, resources: [] } - ]) - .mockResolvedValueOnce([ + ]}) + .mockResolvedValueOnce({violations: [ { rule: 'SoqlInALoop', message: 'Violation 2', @@ -421,7 +421,7 @@ describe('ApexGuruEngine', () => { severity: 1, resources: [] } - ]); + ]}); (fs.readFile as jest.Mock).mockResolvedValue('public class Test {}'); @@ -429,5 +429,36 @@ describe('ApexGuruEngine', () => { 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(); + }); }); }); diff --git a/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts b/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts index 5eb34618..0cfc0330 100644 --- a/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts +++ b/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts @@ -139,12 +139,47 @@ describe('ApexGuruService', () => { report: Buffer.from(JSON.stringify(mockViolations)).toString('base64') }); - const violations = await apexGuruService.analyzeApexClass(testClassContent, testFilePath); + const result = await apexGuruService.analyzeApexClass(testClassContent, testFilePath); - expect(violations).toEqual(mockViolations); + expect(result.violations).toEqual(mockViolations); + expect(result.scanMetadata).toBeUndefined(); expect(mockConnection.request).toHaveBeenCalledTimes(2); }); + it('should return scanMetadata when API response includes it', async () => { + const mockViolations = [{ + rule: 'SoqlInALoop', + message: 'SOQL in loop', + locations: [{ startLine: 5 }], + primaryLocationIndex: 0, + resources: [], + severity: 3 + }]; + const mockScanMetadata = { + analysis_mode: 'full' as const, + files_scanned: 1, + violation_breakdown: { SoqlInALoop: 1 }, + violation_count: 1, + report_generated_ms: 1234567890 + }; + + (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + status: ApexGuruResponseStatus.NEW, + requestId: 'req-123' + }); + + (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + status: ApexGuruResponseStatus.SUCCESS, + report: Buffer.from(JSON.stringify(mockViolations)).toString('base64'), + scanMetadata: mockScanMetadata + }); + + const result = await apexGuruService.analyzeApexClass(testClassContent, testFilePath); + + expect(result.violations).toEqual(mockViolations); + expect(result.scanMetadata).toEqual(mockScanMetadata); + }); + it('should submit base64 encoded content', async () => { (mockConnection.request as jest.Mock).mockResolvedValueOnce({ status: ApexGuruResponseStatus.NEW, @@ -198,9 +233,9 @@ describe('ApexGuruService', () => { report: Buffer.from(JSON.stringify(mockViolations)).toString('base64') }); - const violations = await apexGuruService.analyzeApexClass(testClassContent, testFilePath); + const result = await apexGuruService.analyzeApexClass(testClassContent, testFilePath); - expect(violations).toEqual(mockViolations); + expect(result.violations).toEqual(mockViolations); }); it('should throw error when analysis fails', async () => { @@ -298,11 +333,11 @@ describe('ApexGuruService', () => { report: Buffer.from(JSON.stringify(mockViolations)).toString('base64') }); - const violations = await apexGuruService.analyzeApexClass(testClassContent, testFilePath); + const result = await apexGuruService.analyzeApexClass(testClassContent, testFilePath); - expect(violations).toHaveLength(2); - expect(violations[0].rule).toBe('SoqlInALoop'); - expect(violations[1].rule).toBe('DmlInALoop'); + expect(result.violations).toHaveLength(2); + expect(result.violations[0].rule).toBe('SoqlInALoop'); + expect(result.violations[1].rule).toBe('DmlInALoop'); }); it('should stop polling when timeout occurs', async () => { @@ -333,6 +368,46 @@ describe('ApexGuruService', () => { jest.useRealTimers(); }); + + it('When parseReport extracts scanMetadata from API response, then both violations and scanMetadata are returned', async () => { + const mockViolations = [ + { + rule: 'SoqlInALoop', + message: 'SOQL in loop', + locations: [{ startLine: 5 }], + primaryLocationIndex: 0, + resources: ['https://example.com'], + severity: 3 + } + ]; + + const mockScanMetadata = { + analysis_mode: 'full' as const, + files_scanned: 5, + violation_breakdown: { 'SoqlInALoop': 1 }, + violation_count: 1, + report_generated_ms: 1234567890 + }; + + (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + status: ApexGuruResponseStatus.NEW, + requestId: 'req-123' + }); + + (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + status: ApexGuruResponseStatus.SUCCESS, + report: Buffer.from(JSON.stringify(mockViolations)).toString('base64'), + scanMetadata: mockScanMetadata + }); + + const result = await apexGuruService.analyzeApexClass(testClassContent, testFilePath); + + // Test will fail until we update return type and parseReport + expect(result).toHaveProperty('violations'); + expect(result).toHaveProperty('scanMetadata'); + expect((result as any).violations).toEqual(mockViolations); + expect((result as any).scanMetadata).toEqual(mockScanMetadata); + }); }); describe('cleanup', () => { diff --git a/packages/code-analyzer-apexguru-engine/test/types.test.ts b/packages/code-analyzer-apexguru-engine/test/types.test.ts new file mode 100644 index 00000000..9eb3f5ed --- /dev/null +++ b/packages/code-analyzer-apexguru-engine/test/types.test.ts @@ -0,0 +1,39 @@ +import { ApexGuruScanMetadata } from '../src/types'; + +describe('Tests for ApexGuruScanMetadata type', () => { + it('When ApexGuruScanMetadata is created with valid SFAP contract fields, then TypeScript compilation succeeds', () => { + // Create sample metadata object matching SFAP contract + const metadata: ApexGuruScanMetadata = { + analysis_mode: 'full', + files_scanned: 5, + violation_breakdown: { + 'ApexFlsViolationRule': 3, + 'ApexSharingViolationsRule': 2 + }, + violation_count: 5, + report_generated_ms: 1234567890 + }; + + // Assert structure matches expected SFAP contract + expect(metadata.analysis_mode).toBe('full'); + expect(metadata.files_scanned).toBe(5); + expect(metadata.violation_breakdown).toEqual({ + 'ApexFlsViolationRule': 3, + 'ApexSharingViolationsRule': 2 + }); + expect(metadata.violation_count).toBe(5); + expect(metadata.report_generated_ms).toBe(1234567890); + }); + + it('When ApexGuruScanMetadata has analysis_mode as static, then it should be valid', () => { + const metadata: ApexGuruScanMetadata = { + analysis_mode: 'static', + files_scanned: 1, + violation_breakdown: { 'TestRule': 1 }, + violation_count: 1, + report_generated_ms: 9876543210 + }; + + expect(metadata.analysis_mode).toBe('static'); + }); +}); diff --git a/packages/code-analyzer-core/package.json b/packages/code-analyzer-core/package.json index 2de7349b..6fd6f12b 100644 --- a/packages/code-analyzer-core/package.json +++ b/packages/code-analyzer-core/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/code-analyzer-core", "description": "Core Package for the Salesforce Code Analyzer", - "version": "0.48.0", + "version": "0.49.0-SNAPSHOT", "author": "The Salesforce Code Analyzer Team", "license": "BSD-3-Clause", "homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview", @@ -16,7 +16,7 @@ }, "types": "dist/index.d.ts", "dependencies": { - "@salesforce/code-analyzer-engine-api": "0.38.0", + "@salesforce/code-analyzer-engine-api": "0.39.0-SNAPSHOT", "@types/node": "^20.0.0", "csv-stringify": "^6.7.0", "isbinaryfile": "^5.0.7", diff --git a/packages/code-analyzer-core/src/code-analyzer.ts b/packages/code-analyzer-core/src/code-analyzer.ts index 5653fad3..de91ee60 100644 --- a/packages/code-analyzer-core/src/code-analyzer.ts +++ b/packages/code-analyzer-core/src/code-analyzer.ts @@ -521,7 +521,8 @@ export class CodeAnalyzer { getViolationCount: () => filteredViolations.length, getViolationCountOfSeverity: (severity: number) => filteredViolations.filter(v => v.getRule().getSeverityLevel() === severity).length, - getViolations: () => filteredViolations + getViolations: () => filteredViolations, + getInsights: () => originalResults.getInsights() }; } diff --git a/packages/code-analyzer-core/src/output-formats/results/json-run-results-format.ts b/packages/code-analyzer-core/src/output-formats/results/json-run-results-format.ts index b56ceee0..6412e367 100644 --- a/packages/code-analyzer-core/src/output-formats/results/json-run-results-format.ts +++ b/packages/code-analyzer-core/src/output-formats/results/json-run-results-format.ts @@ -18,6 +18,9 @@ export type JsonResultsOutput = { // Array of objects containing information about the violations detected violations: JsonViolationOutput[] + + // Optional insights metadata from each engine, keyed by engine name + insights?: { [engineName: string]: Record } } /** * Type representing violation counts by severity level; this is specifically exported externally. @@ -120,7 +123,7 @@ export class JsonRunResultsFormatter implements RunResultsFormatter { } export function toJsonResultsOutput(results: RunResults, sanitizeFcn: (text: string) => string = t => t): JsonResultsOutput { - return { + const output: JsonResultsOutput = { runDir: results.getRunDirectory(), violationCounts: { total: results.getViolationCount(), @@ -133,6 +136,22 @@ export function toJsonResultsOutput(results: RunResults, sanitizeFcn: (text: str versions: toJsonVersionObject(results), violations: toJsonViolationOutputArray(results.getViolations(), results.getRunDirectory(), sanitizeFcn) }; + const insightsByEngine = toJsonInsightsObject(results); + if (insightsByEngine) { + output.insights = insightsByEngine; + } + return output; +} + +function toJsonInsightsObject(results: RunResults): { [engineName: string]: Record } | undefined { + const insightsByEngine: { [engineName: string]: Record } = {}; + for (const engineName of results.getEngineNames()) { + const insights = results.getEngineInsights(engineName); + if (insights) { + insightsByEngine[engineName] = insights; + } + } + return Object.keys(insightsByEngine).length > 0 ? insightsByEngine : undefined; } function toJsonVersionObject(results: RunResults): JsonVersionOutput { diff --git a/packages/code-analyzer-core/src/output-formats/results/sarif-run-results-format.ts b/packages/code-analyzer-core/src/output-formats/results/sarif-run-results-format.ts index 82124019..f2467108 100644 --- a/packages/code-analyzer-core/src/output-formats/results/sarif-run-results-format.ts +++ b/packages/code-analyzer-core/src/output-formats/results/sarif-run-results-format.ts @@ -33,7 +33,7 @@ function toSarifRun(engineRunResults: EngineRunResults, runDir: string): sarif.R const rules: Rule[] = [... new Set(violations.map(v => v.getRule()))]; const ruleNames: string[] = rules.map(r => r.getName()); - return { + const run: sarif.Run = { tool: { driver: { name: engineRunResults.getEngineName(), @@ -52,6 +52,13 @@ function toSarifRun(engineRunResults: EngineRunResults, runDir: string): sarif.R }, ], }; + + const insights = engineRunResults.getInsights(); + if (insights) { + run.properties = { insights }; + } + + return run; } function toSarifResult(violation: Violation, runDir: string, ruleIndex: number) : sarif.Result { diff --git a/packages/code-analyzer-core/src/results.ts b/packages/code-analyzer-core/src/results.ts index db8d1acb..437a14b0 100644 --- a/packages/code-analyzer-core/src/results.ts +++ b/packages/code-analyzer-core/src/results.ts @@ -102,6 +102,9 @@ export interface EngineRunResults { /** Returns the array of {@link Violation} instances for the engine */ getViolations(): Violation[] + + /** Returns optional insights metadata provided by the engine about its analysis run */ + getInsights(): Record | undefined } /** @@ -135,6 +138,12 @@ export interface RunResults { */ getEngineRunResults(engineName: string): EngineRunResults + /** + * Returns the insights metadata for the specified engine, if any + * @param engineName the name of the engine to return insights for + */ + getEngineInsights(engineName: string): Record | undefined + /** * Returns a formatted string of the results using the specified {@link OutputFormat} * @param format the {@link OutputFormat} to format the results to @@ -386,6 +395,10 @@ export class EngineRunResultsImpl implements EngineRunResults { } return this.cachedViolations; } + + getInsights(): Record | undefined { + return this.apiEngineRunResults.insights; + } } abstract class AbstractErroneousEngineRunResults implements EngineRunResults { @@ -418,6 +431,10 @@ abstract class AbstractErroneousEngineRunResults implements EngineRunResults { public getViolations(): Violation[] { return [this.violation]; } + + public getInsights(): Record | undefined { + return undefined; + } } export class UninstantiableEngineRunResults extends AbstractErroneousEngineRunResults { @@ -463,6 +480,10 @@ class FilteredEngineRunResults implements EngineRunResults { getViolations(): Violation[] { return this.filteredViolations; } + + getInsights(): Record | undefined { + return this.originalResults.getInsights(); + } } export class RunResultsImpl implements RunResults { @@ -523,6 +544,10 @@ export class RunResultsImpl implements RunResults { return engineRunResults; } + getEngineInsights(engineName: string): Record | undefined { + return this.engineRunResultsMap.get(engineName)?.getInsights(); + } + toFormattedOutput(format: OutputFormat): string { return RunResultsFormatter.forFormat(format, this.clock).format(this); } diff --git a/packages/code-analyzer-core/test/output-format.test.ts b/packages/code-analyzer-core/test/output-format.test.ts index 029c23d2..078cc8d2 100644 --- a/packages/code-analyzer-core/test/output-format.test.ts +++ b/packages/code-analyzer-core/test/output-format.test.ts @@ -383,3 +383,69 @@ async function createRulesWithEmptyTags(): Promise { await codeAnalyzer.addEnginePlugin(new stubs.EmptyTagEnginePlugin()); return codeAnalyzer.selectRules(['all']) } + +describe('Insights in output formatters', () => { + it('When engine provides insights, then JSON output includes insights field', async () => { + const codeAnalyzer = new CodeAnalyzer(CodeAnalyzerConfig.withDefaults()); + const stubPlugin = new stubs.StubEnginePlugin(); + await codeAnalyzer.addEnginePlugin(stubPlugin); + const mockInsights = { + '/path/to/Test.cls': { + analysis_mode: 'full', + files_scanned: 1, + violation_breakdown: {}, + violation_count: 0, + report_generated_ms: 1234567890 + } + }; + (stubPlugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1).resultsToReturn = { + violations: [], + insights: mockInsights + }; + const rules = await codeAnalyzer.selectRules(['stubEngine1']); + const results = await codeAnalyzer.run(rules, {workspace: await codeAnalyzer.createWorkspace(['test'])}); + const jsonOutput = JSON.parse(results.toFormattedOutput(OutputFormat.JSON)); + + expect(jsonOutput.insights).toBeDefined(); + expect(jsonOutput.insights['stubEngine1']).toEqual(mockInsights); + }); + + it('When engine provides insights, then SARIF output includes insights in run properties', async () => { + const codeAnalyzer = new CodeAnalyzer(CodeAnalyzerConfig.withDefaults()); + const stubPlugin = new stubs.StubEnginePlugin(); + await codeAnalyzer.addEnginePlugin(stubPlugin); + const mockInsights = { + '/path/to/Test.cls': { + analysis_mode: 'full', + files_scanned: 1, + violation_breakdown: { 'stubRule1A': 1 }, + violation_count: 1, + report_generated_ms: 9876543210 + } + }; + (stubPlugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1).resultsToReturn = { + violations: [stubs.getSampleViolationForStub1RuleA()], + insights: mockInsights + }; + const rules = await codeAnalyzer.selectRules(['stubEngine1']); + const results = await codeAnalyzer.run(rules, {workspace: await codeAnalyzer.createWorkspace(['test'])}); + const sarifOutput = JSON.parse(results.toFormattedOutput(OutputFormat.SARIF)); + + expect(sarifOutput.runs[0].properties).toBeDefined(); + expect(sarifOutput.runs[0].properties.insights).toEqual(mockInsights); + }); + + it('When engine provides no insights, then JSON output has no insights field', async () => { + const codeAnalyzer = new CodeAnalyzer(CodeAnalyzerConfig.withDefaults()); + const stubPlugin = new stubs.StubEnginePlugin(); + await codeAnalyzer.addEnginePlugin(stubPlugin); + (stubPlugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1).resultsToReturn = { + violations: [] + }; + const rules = await codeAnalyzer.selectRules(['stubEngine1']); + const results = await codeAnalyzer.run(rules, {workspace: await codeAnalyzer.createWorkspace(['test'])}); + const jsonOutput = JSON.parse(results.toFormattedOutput(OutputFormat.JSON)); + + expect(jsonOutput.insights).toBeUndefined(); + }); +}); diff --git a/packages/code-analyzer-core/test/results.test.ts b/packages/code-analyzer-core/test/results.test.ts new file mode 100644 index 00000000..a7357160 --- /dev/null +++ b/packages/code-analyzer-core/test/results.test.ts @@ -0,0 +1,121 @@ +import { EngineRunResultsImpl } from '../src/results'; +import { RuleSelection } from '../src/rules'; +import * as engApi from '@salesforce/code-analyzer-engine-api'; + +describe('Tests for EngineRunResults getInsights method', () => { + const mockRuleSelection = { + getRule: jest.fn().mockReturnValue({ + getName: () => 'TestRule', + getSeverityLevel: () => 1 + }) + } as unknown as RuleSelection; + + it('When EngineRunResults has insights in apiEngineRunResults, then getInsights returns insights object', () => { + const mockInsights = { + analysis_mode: 'full', + files_scanned: 5, + violation_breakdown: { 'rule1': 3 }, + violation_count: 3, + report_generated_ms: 1234567890 + }; + + const apiEngineRunResults: engApi.EngineRunResults = { + violations: [], + insights: mockInsights + }; + + const engineRunResults = new EngineRunResultsImpl( + 'test-engine', + '1.0.0', + apiEngineRunResults, + mockRuleSelection + ); + + expect(engineRunResults.getInsights()).toEqual(mockInsights); + }); + + it('When EngineRunResults has no insights, then getInsights returns undefined', () => { + const apiEngineRunResults: engApi.EngineRunResults = { + violations: [] + }; + + const engineRunResults = new EngineRunResultsImpl( + 'test-engine', + '1.0.0', + apiEngineRunResults, + mockRuleSelection + ); + + expect(engineRunResults.getInsights()).toBeUndefined(); + }); +}); + +describe('Tests for RunResults getEngineInsights method', () => { + it('When RunResults has engine with insights, then getEngineInsights returns insights for that engine', () => { + const mockInsights = { + analysis_mode: 'full', + files_scanned: 3, + violation_breakdown: { 'rule1': 2 }, + violation_count: 2, + report_generated_ms: 9876543210 + }; + + const apiEngineRunResults: engApi.EngineRunResults = { + violations: [], + insights: mockInsights + }; + + const mockRuleSelection = { + getRule: jest.fn().mockReturnValue({ + getName: () => 'TestRule', + getSeverityLevel: () => 1 + }) + } as unknown as RuleSelection; + + const engineRunResults = new EngineRunResultsImpl( + 'apexguru', + '1.0.0', + apiEngineRunResults, + mockRuleSelection + ); + + // Create a minimal RunResults mock that has the engine + const runResults = { + engineRunResultsMap: new Map([['apexguru', engineRunResults]]), + getEngineInsights(engineName: string): Record | undefined { + return this.engineRunResultsMap.get(engineName)?.getInsights(); + } + }; + + expect(runResults.getEngineInsights('apexguru')).toEqual(mockInsights); + }); + + it('When RunResults has engine without insights, then getEngineInsights returns undefined', () => { + const apiEngineRunResults: engApi.EngineRunResults = { + violations: [] + }; + + const mockRuleSelection = { + getRule: jest.fn().mockReturnValue({ + getName: () => 'TestRule', + getSeverityLevel: () => 1 + }) + } as unknown as RuleSelection; + + const engineRunResults = new EngineRunResultsImpl( + 'eslint', + '8.0.0', + apiEngineRunResults, + mockRuleSelection + ); + + const runResults = { + engineRunResultsMap: new Map([['eslint', engineRunResults]]), + getEngineInsights(engineName: string): Record | undefined { + return this.engineRunResultsMap.get(engineName)?.getInsights(); + } + }; + + expect(runResults.getEngineInsights('eslint')).toBeUndefined(); + }); +}); diff --git a/packages/code-analyzer-engine-api/package.json b/packages/code-analyzer-engine-api/package.json index 7aca1b3f..0bcb2ff0 100644 --- a/packages/code-analyzer-engine-api/package.json +++ b/packages/code-analyzer-engine-api/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/code-analyzer-engine-api", "description": "Engine API 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", diff --git a/packages/code-analyzer-engine-api/src/results.ts b/packages/code-analyzer-engine-api/src/results.ts index 4307a9ab..b14e6515 100644 --- a/packages/code-analyzer-engine-api/src/results.ts +++ b/packages/code-analyzer-engine-api/src/results.ts @@ -75,4 +75,7 @@ export type Suggestion = { export type EngineRunResults = { /** The array of {@link Violation} instances for the engine */ violations: Violation[] + + /** Optional insights metadata from the engine about the analysis run */ + insights?: Record } \ No newline at end of file diff --git a/packages/code-analyzer-engine-api/test/results.test.ts b/packages/code-analyzer-engine-api/test/results.test.ts new file mode 100644 index 00000000..a7a352c2 --- /dev/null +++ b/packages/code-analyzer-engine-api/test/results.test.ts @@ -0,0 +1,38 @@ +import { EngineRunResults } from '../src/results'; + +describe('Tests for EngineRunResults type', () => { + it('When EngineRunResults has insights field, then it should be optional and accept Record', () => { + // Create mock EngineRunResults with insights + const resultsWithInsights: EngineRunResults = { + violations: [], + insights: { + analysis_mode: 'full', + files_scanned: 5, + violation_breakdown: { 'rule1': 3 }, + violation_count: 3, + report_generated_ms: 1234567890 + } + }; + + // Assert insights is present and matches expected structure + expect(resultsWithInsights.insights).toBeDefined(); + expect(resultsWithInsights.insights).toEqual({ + analysis_mode: 'full', + files_scanned: 5, + violation_breakdown: { 'rule1': 3 }, + violation_count: 3, + report_generated_ms: 1234567890 + }); + }); + + it('When EngineRunResults has no insights field, then it should be valid', () => { + // Create mock EngineRunResults without insights + const resultsWithoutInsights: EngineRunResults = { + violations: [] + }; + + // Assert results is valid + expect(resultsWithoutInsights.violations).toBeDefined(); + expect(resultsWithoutInsights.insights).toBeUndefined(); + }); +}); diff --git a/packages/code-analyzer-eslint-engine/package.json b/packages/code-analyzer-eslint-engine/package.json index 0af286d5..3cb5edf4 100644 --- a/packages/code-analyzer-eslint-engine/package.json +++ b/packages/code-analyzer-eslint-engine/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/code-analyzer-eslint-engine", "description": "Plugin package that adds 'eslint' as an engine into Salesforce Code Analyzer", - "version": "0.43.0", + "version": "0.44.0-SNAPSHOT", "author": "The Salesforce Code Analyzer Team", "license": "BSD-3-Clause", "homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview", @@ -18,8 +18,8 @@ "@lwc/eslint-plugin-lwc": "^3.5.0", "@lwc/eslint-plugin-lwc-platform": "^6.3.0", "@salesforce-ux/eslint-plugin-slds": "^1.2.1", - "@salesforce/code-analyzer-engine-api": "0.38.0", - "@salesforce/code-analyzer-eslint8-engine": "0.15.0", + "@salesforce/code-analyzer-engine-api": "0.39.0-SNAPSHOT", + "@salesforce/code-analyzer-eslint8-engine": "0.16.0-SNAPSHOT", "@salesforce/eslint-config-lwc": "^4.1.2", "@salesforce/eslint-plugin-lightning": "^2.0.0", "@types/node": "^20.0.0", diff --git a/packages/code-analyzer-eslint8-engine/package.json b/packages/code-analyzer-eslint8-engine/package.json index b06aaa08..c441757c 100644 --- a/packages/code-analyzer-eslint8-engine/package.json +++ b/packages/code-analyzer-eslint8-engine/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/code-analyzer-eslint8-engine", "description": "Plugin package that adds 'eslint' (version 8) as an engine into Salesforce Code Analyzer", - "version": "0.15.0", + "version": "0.16.0-SNAPSHOT", "author": "The Salesforce Code Analyzer Team", "license": "BSD-3-Clause", "homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview", @@ -18,7 +18,7 @@ "@eslint/js": "8.57.1", "@lwc/eslint-plugin-lwc": "2.2.0", "@lwc/eslint-plugin-lwc-platform": "5.2.0", - "@salesforce/code-analyzer-engine-api": "0.38.0", + "@salesforce/code-analyzer-engine-api": "0.39.0-SNAPSHOT", "@salesforce/eslint-config-lwc": "3.7.2", "@salesforce/eslint-plugin-lightning": "1.0.1", "@types/node": "^20.0.0", diff --git a/packages/code-analyzer-flow-engine/package.json b/packages/code-analyzer-flow-engine/package.json index a094934a..927616cc 100644 --- a/packages/code-analyzer-flow-engine/package.json +++ b/packages/code-analyzer-flow-engine/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/code-analyzer-flow-engine", "description": "Plugin package that adds 'Flow Scanner' as an engine into Salesforce Code Analyzer", - "version": "0.37.0", + "version": "0.38.0-SNAPSHOT", "author": "The Salesforce Code Analyzer Team", "license": "BSD-3-Clause", "homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview", @@ -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", "@types/node": "^20.0.0", "@types/semver": "^7.7.1", "semver": "^7.7.4" diff --git a/packages/code-analyzer-pmd-engine/package.json b/packages/code-analyzer-pmd-engine/package.json index ec76eb73..79b14468 100644 --- a/packages/code-analyzer-pmd-engine/package.json +++ b/packages/code-analyzer-pmd-engine/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/code-analyzer-pmd-engine", "description": "Plugin package that adds 'pmd' and 'cpd' as engines into Salesforce Code Analyzer", - "version": "0.41.0", + "version": "0.42.0-SNAPSHOT", "author": "The Salesforce Code Analyzer Team", "license": "BSD-3-Clause", "homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview", @@ -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", "@types/node": "^20.0.0", "@types/semver": "^7.7.1", "semver": "^7.7.4" diff --git a/packages/code-analyzer-regex-engine/package.json b/packages/code-analyzer-regex-engine/package.json index f5447418..5afbf6e4 100644 --- a/packages/code-analyzer-regex-engine/package.json +++ b/packages/code-analyzer-regex-engine/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/code-analyzer-regex-engine", "description": "Plugin package that adds 'regex' as an engine into Salesforce Code Analyzer", - "version": "0.36.0", + "version": "0.37.0-SNAPSHOT", "author": "The Salesforce Code Analyzer Team", "license": "BSD-3-Clause", "homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview", @@ -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", "@types/node": "^20.0.0", "isbinaryfile": "^5.0.7", "p-limit": "^3.1.0" diff --git a/packages/code-analyzer-retirejs-engine/package.json b/packages/code-analyzer-retirejs-engine/package.json index 59a61b58..a4d3b27a 100644 --- a/packages/code-analyzer-retirejs-engine/package.json +++ b/packages/code-analyzer-retirejs-engine/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/code-analyzer-retirejs-engine", "description": "Plugin package that adds 'retire-js' as an engine into Salesforce Code Analyzer", - "version": "0.35.0", + "version": "0.36.0-SNAPSHOT", "author": "The Salesforce Code Analyzer Team", "license": "BSD-3-Clause", "homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview", @@ -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", "@types/node": "^20.0.0", "isbinaryfile": "^5.0.7", "node-stream-zip": "^1.15.0", diff --git a/packages/code-analyzer-sfge-engine/package.json b/packages/code-analyzer-sfge-engine/package.json index db268215..82087fbb 100644 --- a/packages/code-analyzer-sfge-engine/package.json +++ b/packages/code-analyzer-sfge-engine/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/code-analyzer-sfge-engine", "description": "Plugin package that adds 'Salesforce Graph Engine' as an engine into Salesforce Code Analyzer", - "version": "0.21.0", + "version": "0.22.0-SNAPSHOT", "author": "The Salesforce Code Analyzer Team", "license": "BSD-3-Clause", "homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview", @@ -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", "@types/node": "^20.0.0", "semver": "^7.7.4" },