From 7cbfcb4006f8e96eaa57596298acb32265ce9d95 Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Wed, 1 Jul 2026 11:06:11 +0530 Subject: [PATCH 01/12] feat(apexguru): gracefully skip runRules when user is not authenticated Instead of throwing when authentication fails, ApexGuru now emits a LogLevel.Warn event and returns empty violations with structured insights ({ skipped: true, skipReason: 'AUTHENTICATION_REQUIRED' }). This allows downstream formatters to surface the skip as a warning. --- .../package.json | 2 +- .../src/engine.ts | 17 ++++++++----- .../test/ApexGuruEngine.test.ts | 24 +++++++++++++++---- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/code-analyzer-apexguru-engine/package.json b/packages/code-analyzer-apexguru-engine/package.json index 551d82a3..10ac6d9d 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.39.0", + "version": "0.40.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-apexguru-engine/src/engine.ts b/packages/code-analyzer-apexguru-engine/src/engine.ts index f7c333f8..e770bc04 100644 --- a/packages/code-analyzer-apexguru-engine/src/engine.ts +++ b/packages/code-analyzer-apexguru-engine/src/engine.ts @@ -97,15 +97,20 @@ export class ApexGuruEngine extends EngineEventEmitter implements Engine { // Get target org alias/username from config (passed by CLI --target-org flag) const targetOrg = this.getTargetOrg(); - // Initialize authentication + // Initialize authentication — skip gracefully if user is not authenticated try { await this.apexGuruService.initialize(targetOrg); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error( - `Failed to authenticate: ${message}\n` + - 'Please authenticate with: sf org login web' - ); + const detail = error instanceof Error ? error.message : String(error); + const message = `ApexGuru skipped: user is not authenticated (${detail}). ` + + 'Run "sf org login web" to authenticate, then re-run the scan.'; + this.emitLogEvent(LogLevel.Warn, message); + this.emitRunRulesProgressEvent(100); + this.apexGuruService.cleanup(); + return { + violations: [], + insights: { skipped: true, skipReason: 'AUTHENTICATION_REQUIRED', message } + }; } // Get workspace root path diff --git a/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts b/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts index 3e1224fd..dc054b77 100644 --- a/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts +++ b/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts @@ -1,6 +1,6 @@ import { ApexGuruEngine } from '../src/engine'; import { ApexGuruService } from '../src/services/ApexGuruService'; -import { RunOptions, Workspace } from '@salesforce/code-analyzer-engine-api'; +import { LogLevel, RunOptions, Workspace } from '@salesforce/code-analyzer-engine-api'; import * as fs from 'node:fs/promises'; // Mock dependencies @@ -189,12 +189,26 @@ describe('ApexGuruEngine', () => { expect(mockApexGuruService.scanWorkspace).toHaveBeenCalledWith('/test/workspace', ['/test/workspace']); }); - it('should throw error if authentication fails', async () => { - mockApexGuruService.initialize.mockRejectedValue(new Error('Auth failed')); + it('should gracefully skip when authentication fails and return empty violations with skip insights', async () => { + mockApexGuruService.initialize.mockRejectedValue(new Error('No default org found')); mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/workspace/Test.cls']); + const logSpy = jest.spyOn(engine as any, 'emitLogEvent'); + const progressSpy = jest.spyOn(engine as any, 'emitRunRulesProgressEvent'); - await expect(engine.runRules(['SoqlInALoop'], mockRunOptions)) - .rejects.toThrow('Failed to authenticate'); + const results = await engine.runRules(['SoqlInALoop'], mockRunOptions); + + expect(results.violations).toEqual([]); + expect(results.insights).toEqual({ + skipped: true, + skipReason: 'AUTHENTICATION_REQUIRED', + message: expect.any(String) + }); + expect(logSpy).toHaveBeenCalledWith( + LogLevel.Warn, + expect.stringContaining('not authenticated') + ); + expect(mockApexGuruService.cleanup).toHaveBeenCalled(); + expect(progressSpy).toHaveBeenCalledWith(100); }); it('should return empty results if no Apex files found', async () => { From ad4e19c2cb6af40e7d49a4c320856dfff29d440b Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Wed, 1 Jul 2026 11:06:55 +0530 Subject: [PATCH 02/12] test(apexguru): verify describeRules returns rules without auth check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock in the contract that rule advertisement is independent of authentication state — describeRules never calls initialize(). --- .../test/ApexGuruEngine.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts b/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts index dc054b77..b797be34 100644 --- a/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts +++ b/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts @@ -156,6 +156,18 @@ describe('ApexGuruEngine', () => { expect(rules.length).toBeGreaterThan(0); expect(rules.find(r => r.name === 'SoqlInALoop')).toBeDefined(); }); + + it('should return rules without attempting authentication regardless of auth state', async () => { + const rules = await engine.describeRules({ + logFolder: '/tmp/logs', + workingFolder: '/tmp/working' + }); + + expect(rules.length).toBeGreaterThan(0); + expect(rules.find(r => r.name === 'SoqlInALoop')).toBeDefined(); + expect(rules.find(r => r.name === 'DmlInALoop')).toBeDefined(); + expect(mockApexGuruService.initialize).not.toHaveBeenCalled(); + }); }); describe('runRules', () => { From d22c7766f485fedd62860e4078d6b797834df83a Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Wed, 1 Jul 2026 11:10:49 +0530 Subject: [PATCH 03/12] feat(core): extract warnings array in JSON output from skipped engine insights When an engine's insights contain { skipped: true, skipReason, message }, the JSON formatter now includes a top-level 'warnings' array with { engine, code, message } objects for machine-readable skip detection. --- packages/code-analyzer-core/package.json | 2 +- .../src/output-formats/index.ts | 3 +- .../results/json-run-results-format.ts | 33 +++++++++++++++ .../test/output-format.test.ts | 42 +++++++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/code-analyzer-core/package.json b/packages/code-analyzer-core/package.json index 4d4fe3da..5fdbc898 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.49.0", + "version": "0.50.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-core/src/output-formats/index.ts b/packages/code-analyzer-core/src/output-formats/index.ts index 4f33564a..f8ecfc49 100644 --- a/packages/code-analyzer-core/src/output-formats/index.ts +++ b/packages/code-analyzer-core/src/output-formats/index.ts @@ -1,3 +1,4 @@ export type { - ViolationCounts + ViolationCounts, + JsonWarningOutput } from "./results/json-run-results-format" 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 6412e367..541d53b3 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 @@ -21,6 +21,20 @@ export type JsonResultsOutput = { // Optional insights metadata from each engine, keyed by engine name insights?: { [engineName: string]: Record } + + // Optional warnings from engines that were skipped (e.g. due to auth failure) + warnings?: JsonWarningOutput[] +} + +export type JsonWarningOutput = { + // The engine that emitted the warning + engine: string + + // A machine-readable code identifying the warning type + code: string + + // A human-readable message describing the warning + message: string } /** * Type representing violation counts by severity level; this is specifically exported externally. @@ -140,6 +154,10 @@ export function toJsonResultsOutput(results: RunResults, sanitizeFcn: (text: str if (insightsByEngine) { output.insights = insightsByEngine; } + const warnings = toJsonWarningsArray(results); + if (warnings.length > 0) { + output.warnings = warnings; + } return output; } @@ -154,6 +172,21 @@ function toJsonInsightsObject(results: RunResults): { [engineName: string]: Reco return Object.keys(insightsByEngine).length > 0 ? insightsByEngine : undefined; } +function toJsonWarningsArray(results: RunResults): JsonWarningOutput[] { + const warnings: JsonWarningOutput[] = []; + for (const engineName of results.getEngineNames()) { + const insights = results.getEngineInsights(engineName); + if (insights && insights['skipped'] === true) { + warnings.push({ + engine: engineName, + code: String(insights['skipReason'] ?? 'UNKNOWN'), + message: String(insights['message'] ?? `Engine ${engineName} was skipped.`) + }); + } + } + return warnings; +} + function toJsonVersionObject(results: RunResults): JsonVersionOutput { const versions: JsonVersionOutput = { [CODE_ANALYZER_CORE_NAME]: results.getCoreVersion() diff --git a/packages/code-analyzer-core/test/output-format.test.ts b/packages/code-analyzer-core/test/output-format.test.ts index 078cc8d2..a54918d6 100644 --- a/packages/code-analyzer-core/test/output-format.test.ts +++ b/packages/code-analyzer-core/test/output-format.test.ts @@ -449,3 +449,45 @@ describe('Insights in output formatters', () => { expect(jsonOutput.insights).toBeUndefined(); }); }); + +describe('Warnings in JSON output from skipped engine insights', () => { + it('When engine insights contain skipped:true, JSON output includes warnings array', async () => { + const codeAnalyzer = new CodeAnalyzer(CodeAnalyzerConfig.withDefaults()); + const stubPlugin = new stubs.StubEnginePlugin(); + await codeAnalyzer.addEnginePlugin(stubPlugin); + (stubPlugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1).resultsToReturn = { + violations: [], + insights: { + skipped: true, + skipReason: 'AUTHENTICATION_REQUIRED', + message: 'ApexGuru skipped: user is not authenticated.' + } + }; + 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.warnings).toBeDefined(); + expect(jsonOutput.warnings).toHaveLength(1); + expect(jsonOutput.warnings[0]).toEqual({ + engine: 'stubEngine1', + code: 'AUTHENTICATION_REQUIRED', + message: 'ApexGuru skipped: user is not authenticated.' + }); + }); + + it('When no engine insights contain skipped:true, JSON output has no warnings array', async () => { + const codeAnalyzer = new CodeAnalyzer(CodeAnalyzerConfig.withDefaults()); + const stubPlugin = new stubs.StubEnginePlugin(); + await codeAnalyzer.addEnginePlugin(stubPlugin); + (stubPlugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1).resultsToReturn = { + violations: [], + insights: { scan: { files_scanned: 5 } } + }; + 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.warnings).toBeUndefined(); + }); +}); From 65128d91f6b6264286357b44276206beacf836f3 Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Wed, 1 Jul 2026 11:11:57 +0530 Subject: [PATCH 04/12] feat(core): add toolConfigurationNotification in SARIF for skipped engines When engine insights contain skipped:true, the SARIF output now includes a toolConfigurationNotification in the invocation with level 'warning', following the SARIF 2.1.0 specification for tool configuration issues. --- .../results/sarif-run-results-format.ts | 8 ++++++ .../test/output-format.test.ts | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+) 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 f2467108..0d89f04a 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 @@ -56,6 +56,14 @@ function toSarifRun(engineRunResults: EngineRunResults, runDir: string): sarif.R const insights = engineRunResults.getInsights(); if (insights) { run.properties = { insights }; + if (insights['skipped'] === true) { + const notification: sarif.Notification = { + level: 'warning', + message: { text: String(insights['message'] ?? `Engine ${engineRunResults.getEngineName()} was skipped.`) }, + descriptor: { id: String(insights['skipReason'] ?? 'UNKNOWN') } + }; + run.invocations![0].toolConfigurationNotifications = [notification]; + } } return run; diff --git a/packages/code-analyzer-core/test/output-format.test.ts b/packages/code-analyzer-core/test/output-format.test.ts index a54918d6..ffb96d6f 100644 --- a/packages/code-analyzer-core/test/output-format.test.ts +++ b/packages/code-analyzer-core/test/output-format.test.ts @@ -410,6 +410,31 @@ describe('Insights in output formatters', () => { expect(jsonOutput.insights['stubEngine1']).toEqual(mockInsights); }); + it('When engine insights contain skipped:true, SARIF output includes toolConfigurationNotification', async () => { + const codeAnalyzer = new CodeAnalyzer(CodeAnalyzerConfig.withDefaults()); + const stubPlugin = new stubs.StubEnginePlugin(); + await codeAnalyzer.addEnginePlugin(stubPlugin); + (stubPlugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1).resultsToReturn = { + violations: [stubs.getSampleViolationForStub1RuleA()], + insights: { + skipped: true, + skipReason: 'AUTHENTICATION_REQUIRED', + message: 'ApexGuru skipped: user is not authenticated.' + } + }; + 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)); + + const run = sarifOutput.runs[0]; + expect(run.invocations[0].toolConfigurationNotifications).toBeDefined(); + expect(run.invocations[0].toolConfigurationNotifications).toHaveLength(1); + const notification = run.invocations[0].toolConfigurationNotifications[0]; + expect(notification.level).toBe('warning'); + expect(notification.message.text).toBe('ApexGuru skipped: user is not authenticated.'); + expect(notification.descriptor.id).toBe('AUTHENTICATION_REQUIRED'); + }); + 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(); From ec7efdb42352f3b83b0b8e561876e419d20206cf Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Wed, 1 Jul 2026 11:48:12 +0530 Subject: [PATCH 05/12] revert: remove all changes to packages/code-analyzer-core Per ticket constraint: "No changes to packages/code-analyzer-core/" Restoring core package to exact dev branch state. --- packages/code-analyzer-core/package.json | 2 +- .../src/output-formats/index.ts | 3 +- .../results/json-run-results-format.ts | 33 --------- .../results/sarif-run-results-format.ts | 8 --- .../test/output-format.test.ts | 67 ------------------- 5 files changed, 2 insertions(+), 111 deletions(-) diff --git a/packages/code-analyzer-core/package.json b/packages/code-analyzer-core/package.json index 5fdbc898..4d4fe3da 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.50.0-SNAPSHOT", + "version": "0.49.0", "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-core/src/output-formats/index.ts b/packages/code-analyzer-core/src/output-formats/index.ts index f8ecfc49..4f33564a 100644 --- a/packages/code-analyzer-core/src/output-formats/index.ts +++ b/packages/code-analyzer-core/src/output-formats/index.ts @@ -1,4 +1,3 @@ export type { - ViolationCounts, - JsonWarningOutput + ViolationCounts } from "./results/json-run-results-format" 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 541d53b3..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 @@ -21,20 +21,6 @@ export type JsonResultsOutput = { // Optional insights metadata from each engine, keyed by engine name insights?: { [engineName: string]: Record } - - // Optional warnings from engines that were skipped (e.g. due to auth failure) - warnings?: JsonWarningOutput[] -} - -export type JsonWarningOutput = { - // The engine that emitted the warning - engine: string - - // A machine-readable code identifying the warning type - code: string - - // A human-readable message describing the warning - message: string } /** * Type representing violation counts by severity level; this is specifically exported externally. @@ -154,10 +140,6 @@ export function toJsonResultsOutput(results: RunResults, sanitizeFcn: (text: str if (insightsByEngine) { output.insights = insightsByEngine; } - const warnings = toJsonWarningsArray(results); - if (warnings.length > 0) { - output.warnings = warnings; - } return output; } @@ -172,21 +154,6 @@ function toJsonInsightsObject(results: RunResults): { [engineName: string]: Reco return Object.keys(insightsByEngine).length > 0 ? insightsByEngine : undefined; } -function toJsonWarningsArray(results: RunResults): JsonWarningOutput[] { - const warnings: JsonWarningOutput[] = []; - for (const engineName of results.getEngineNames()) { - const insights = results.getEngineInsights(engineName); - if (insights && insights['skipped'] === true) { - warnings.push({ - engine: engineName, - code: String(insights['skipReason'] ?? 'UNKNOWN'), - message: String(insights['message'] ?? `Engine ${engineName} was skipped.`) - }); - } - } - return warnings; -} - function toJsonVersionObject(results: RunResults): JsonVersionOutput { const versions: JsonVersionOutput = { [CODE_ANALYZER_CORE_NAME]: results.getCoreVersion() 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 0d89f04a..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 @@ -56,14 +56,6 @@ function toSarifRun(engineRunResults: EngineRunResults, runDir: string): sarif.R const insights = engineRunResults.getInsights(); if (insights) { run.properties = { insights }; - if (insights['skipped'] === true) { - const notification: sarif.Notification = { - level: 'warning', - message: { text: String(insights['message'] ?? `Engine ${engineRunResults.getEngineName()} was skipped.`) }, - descriptor: { id: String(insights['skipReason'] ?? 'UNKNOWN') } - }; - run.invocations![0].toolConfigurationNotifications = [notification]; - } } return run; diff --git a/packages/code-analyzer-core/test/output-format.test.ts b/packages/code-analyzer-core/test/output-format.test.ts index ffb96d6f..078cc8d2 100644 --- a/packages/code-analyzer-core/test/output-format.test.ts +++ b/packages/code-analyzer-core/test/output-format.test.ts @@ -410,31 +410,6 @@ describe('Insights in output formatters', () => { expect(jsonOutput.insights['stubEngine1']).toEqual(mockInsights); }); - it('When engine insights contain skipped:true, SARIF output includes toolConfigurationNotification', async () => { - const codeAnalyzer = new CodeAnalyzer(CodeAnalyzerConfig.withDefaults()); - const stubPlugin = new stubs.StubEnginePlugin(); - await codeAnalyzer.addEnginePlugin(stubPlugin); - (stubPlugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1).resultsToReturn = { - violations: [stubs.getSampleViolationForStub1RuleA()], - insights: { - skipped: true, - skipReason: 'AUTHENTICATION_REQUIRED', - message: 'ApexGuru skipped: user is not authenticated.' - } - }; - 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)); - - const run = sarifOutput.runs[0]; - expect(run.invocations[0].toolConfigurationNotifications).toBeDefined(); - expect(run.invocations[0].toolConfigurationNotifications).toHaveLength(1); - const notification = run.invocations[0].toolConfigurationNotifications[0]; - expect(notification.level).toBe('warning'); - expect(notification.message.text).toBe('ApexGuru skipped: user is not authenticated.'); - expect(notification.descriptor.id).toBe('AUTHENTICATION_REQUIRED'); - }); - 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(); @@ -474,45 +449,3 @@ describe('Insights in output formatters', () => { expect(jsonOutput.insights).toBeUndefined(); }); }); - -describe('Warnings in JSON output from skipped engine insights', () => { - it('When engine insights contain skipped:true, JSON output includes warnings array', async () => { - const codeAnalyzer = new CodeAnalyzer(CodeAnalyzerConfig.withDefaults()); - const stubPlugin = new stubs.StubEnginePlugin(); - await codeAnalyzer.addEnginePlugin(stubPlugin); - (stubPlugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1).resultsToReturn = { - violations: [], - insights: { - skipped: true, - skipReason: 'AUTHENTICATION_REQUIRED', - message: 'ApexGuru skipped: user is not authenticated.' - } - }; - 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.warnings).toBeDefined(); - expect(jsonOutput.warnings).toHaveLength(1); - expect(jsonOutput.warnings[0]).toEqual({ - engine: 'stubEngine1', - code: 'AUTHENTICATION_REQUIRED', - message: 'ApexGuru skipped: user is not authenticated.' - }); - }); - - it('When no engine insights contain skipped:true, JSON output has no warnings array', async () => { - const codeAnalyzer = new CodeAnalyzer(CodeAnalyzerConfig.withDefaults()); - const stubPlugin = new stubs.StubEnginePlugin(); - await codeAnalyzer.addEnginePlugin(stubPlugin); - (stubPlugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1).resultsToReturn = { - violations: [], - insights: { scan: { files_scanned: 5 } } - }; - 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.warnings).toBeUndefined(); - }); -}); From 6814c4d59ebfe5f91e52aef0d60af9b3caaaa004 Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Wed, 1 Jul 2026 11:51:54 +0530 Subject: [PATCH 06/12] fix(apexguru): implement correct insights schema with 3 error codes - Use {status: "skipped", error: {code, message, remediation}} schema per ticket spec (not the flat {skipped, skipReason, message} format) - Handle 3 error paths: NO_ORG_CONNECTION, API_UNAVAILABLE, UNEXPECTED_ERROR - Add status: "completed" on success path for contract consistency - Catch API failures (5xx, timeout, ECONNREFUSED) as API_UNAVAILABLE - Catch all other errors as UNEXPECTED_ERROR - Extract skipWithError() and isApiUnavailableError() helpers - Fix cleanup() ownership: caller handles cleanup, not skipWithError - Update all tests to match correct schema assertions - Add tests: API unavailable, timeout, unexpected error scenarios --- .../src/engine.ts | 58 +++++++++-- .../test/ApexGuruEngine.test.ts | 97 ++++++++++++++++--- 2 files changed, 134 insertions(+), 21 deletions(-) diff --git a/packages/code-analyzer-apexguru-engine/src/engine.ts b/packages/code-analyzer-apexguru-engine/src/engine.ts index e770bc04..b142cf2c 100644 --- a/packages/code-analyzer-apexguru-engine/src/engine.ts +++ b/packages/code-analyzer-apexguru-engine/src/engine.ts @@ -102,15 +102,10 @@ export class ApexGuruEngine extends EngineEventEmitter implements Engine { await this.apexGuruService.initialize(targetOrg); } catch (error) { const detail = error instanceof Error ? error.message : String(error); - const message = `ApexGuru skipped: user is not authenticated (${detail}). ` + - 'Run "sf org login web" to authenticate, then re-run the scan.'; - this.emitLogEvent(LogLevel.Warn, message); - this.emitRunRulesProgressEvent(100); this.apexGuruService.cleanup(); - return { - violations: [], - insights: { skipped: true, skipReason: 'AUTHENTICATION_REQUIRED', message } - }; + return this.skipWithError('NO_ORG_CONNECTION', + `Failed to authenticate: ${detail}`, + "Please authenticate with 'sf org login web' or pass --target-org"); } // Get workspace root path @@ -152,16 +147,59 @@ export class ApexGuruEngine extends EngineEventEmitter implements Engine { // Filter violations to only include selected rules const filteredViolations = allViolations.filter(v => selectedRulesSet.has(v.ruleName)); - // Return insights as scan metadata (workspace-level) - const insights: Record | undefined = scanMetadata ? { scan: scanMetadata } : undefined; + // Return insights with status: "completed" and scan metadata + const insights: Record = { + status: 'completed', + ...(scanMetadata ? { scan: scanMetadata } : {}) + }; return { violations: filteredViolations, insights }; + } catch (error) { + // Catch API failures (5xx, timeout, connection refused) and unexpected errors + const detail = error instanceof Error ? error.message : String(error); + if (this.isApiUnavailableError(error)) { + return this.skipWithError('API_UNAVAILABLE', + `ApexGuru service is unavailable: ${detail}`, + 'The ApexGuru service is temporarily unavailable. Please try again later.'); + } + return this.skipWithError('UNEXPECTED_ERROR', + `An unexpected error occurred: ${detail}`, + 'An unexpected error occurred. Please try again or file a support ticket if the issue persists.'); } finally { // Always cleanup resources this.apexGuruService.cleanup(); } } + /** + * Returns a graceful skip result with structured error insights. + * Emits a warn-level log event and completes progress before returning. + * NOTE: Caller is responsible for cleanup() — do NOT call cleanup here since + * this may be invoked from within a try-finally that already handles cleanup. + */ + private skipWithError(code: string, message: string, remediation: string): EngineRunResults { + this.emitLogEvent(LogLevel.Warn, `ApexGuru skipped: ${message}`); + this.emitRunRulesProgressEvent(100); + return { + violations: [], + insights: { + status: 'skipped', + error: { code, message, remediation } + } + }; + } + + /** + * Determines whether an error is an API unavailability issue (network/timeout/5xx). + */ + private isApiUnavailableError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const msg = error.message.toLowerCase(); + const networkIndicators = ['econnrefused', 'etimedout', 'enotfound', 'socket hang up', + 'connection refused', 'timeout', 'network', '502', '503', '504', '500']; + return networkIndicators.some(indicator => msg.includes(indicator)); + } + /** * Check if file is an Apex file based on extension */ diff --git a/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts b/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts index b797be34..5dd6e224 100644 --- a/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts +++ b/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts @@ -201,7 +201,7 @@ describe('ApexGuruEngine', () => { expect(mockApexGuruService.scanWorkspace).toHaveBeenCalledWith('/test/workspace', ['/test/workspace']); }); - it('should gracefully skip when authentication fails and return empty violations with skip insights', async () => { + it('should gracefully skip with NO_ORG_CONNECTION when authentication fails', async () => { mockApexGuruService.initialize.mockRejectedValue(new Error('No default org found')); mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/workspace/Test.cls']); const logSpy = jest.spyOn(engine as any, 'emitLogEvent'); @@ -211,16 +211,83 @@ describe('ApexGuruEngine', () => { expect(results.violations).toEqual([]); expect(results.insights).toEqual({ - skipped: true, - skipReason: 'AUTHENTICATION_REQUIRED', - message: expect.any(String) + status: 'skipped', + error: { + code: 'NO_ORG_CONNECTION', + message: expect.stringContaining('No default org found'), + remediation: expect.stringContaining('sf org login web') + } }); expect(logSpy).toHaveBeenCalledWith( LogLevel.Warn, - expect.stringContaining('not authenticated') + expect.stringContaining('Failed to authenticate') ); expect(mockApexGuruService.cleanup).toHaveBeenCalled(); expect(progressSpy).toHaveBeenCalledWith(100); + // No SFAP calls should be made after auth failure + expect(mockApexGuruService.scanWorkspace).not.toHaveBeenCalled(); + }); + + it('should gracefully skip with API_UNAVAILABLE when API is unreachable', async () => { + mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/workspace/Test.cls']); + mockApexGuruService.scanWorkspace.mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:443')); + const logSpy = jest.spyOn(engine as any, 'emitLogEvent'); + + const results = await engine.runRules(['SoqlInALoop'], mockRunOptions); + + expect(results.violations).toEqual([]); + expect(results.insights).toEqual({ + status: 'skipped', + error: { + code: 'API_UNAVAILABLE', + message: expect.stringContaining('ECONNREFUSED'), + remediation: expect.stringContaining('temporarily unavailable') + } + }); + expect(logSpy).toHaveBeenCalledWith( + LogLevel.Warn, + expect.stringContaining('unavailable') + ); + expect(mockApexGuruService.cleanup).toHaveBeenCalled(); + }); + + it('should gracefully skip with API_UNAVAILABLE on timeout', async () => { + mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/workspace/Test.cls']); + mockApexGuruService.scanWorkspace.mockRejectedValue(new Error('Request timeout after 300000ms')); + const logSpy = jest.spyOn(engine as any, 'emitLogEvent'); + + const results = await engine.runRules(['SoqlInALoop'], mockRunOptions); + + expect(results.violations).toEqual([]); + expect(results.insights).toEqual({ + status: 'skipped', + error: { + code: 'API_UNAVAILABLE', + message: expect.stringContaining('timeout'), + remediation: expect.stringContaining('try again later') + } + }); + expect(logSpy).toHaveBeenCalledWith(LogLevel.Warn, expect.any(String)); + }); + + it('should gracefully skip with UNEXPECTED_ERROR on non-network errors', async () => { + mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/workspace/Test.cls']); + mockApexGuruService.scanWorkspace.mockRejectedValue(new Error('Unexpected internal failure')); + const logSpy = jest.spyOn(engine as any, 'emitLogEvent'); + + const results = await engine.runRules(['SoqlInALoop'], mockRunOptions); + + expect(results.violations).toEqual([]); + expect(results.insights).toEqual({ + status: 'skipped', + error: { + code: 'UNEXPECTED_ERROR', + message: expect.stringContaining('Unexpected internal failure'), + remediation: expect.stringContaining('file a support ticket') + } + }); + expect(logSpy).toHaveBeenCalledWith(LogLevel.Warn, expect.any(String)); + expect(mockApexGuruService.cleanup).toHaveBeenCalled(); }); it('should return empty results if no Apex files found', async () => { @@ -372,12 +439,17 @@ describe('ApexGuruEngine', () => { it('should cleanup even when error occurs within try block', async () => { mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/workspace/Test.cls']); - // Make scanWorkspace throw an error + // Make scanWorkspace throw an error — should be caught and return skip result mockApexGuruService.scanWorkspace.mockRejectedValue(new Error('Fatal API error')); - await expect(engine.runRules(['SoqlInALoop'], mockRunOptions)) - .rejects.toThrow('Fatal API error'); + const results = await engine.runRules(['SoqlInALoop'], mockRunOptions); + // Should not throw — error is caught and returned as UNEXPECTED_ERROR skip + expect(results.violations).toEqual([]); + expect(results.insights).toEqual({ + status: 'skipped', + error: expect.objectContaining({ code: 'UNEXPECTED_ERROR' }) + }); expect(mockApexGuruService.cleanup).toHaveBeenCalled(); }); @@ -409,7 +481,7 @@ describe('ApexGuruEngine', () => { expect(mockApexGuruService.initialize).toHaveBeenCalledWith('my-org'); }); - it('should populate insights in results when scanMetadata is returned', async () => { + it('should return insights with status completed and scan metadata on success', async () => { mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/workspace/Test.cls']); const mockScanMetadata = { analysis_mode: 'full' as const, @@ -426,16 +498,19 @@ describe('ApexGuruEngine', () => { const results = await engine.runRules(['SoqlInALoop'], mockRunOptions); expect(results.insights).toBeDefined(); + expect(results.insights!['status']).toBe('completed'); expect(results.insights!['scan']).toEqual(mockScanMetadata); }); - it('should not include insights in results when no scanMetadata is returned', async () => { + it('should return insights with status completed even without scan metadata', async () => { mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/workspace/Test.cls']); mockApexGuruService.scanWorkspace.mockResolvedValue({ violations: [] }); const results = await engine.runRules(['SoqlInALoop'], mockRunOptions); - expect(results.insights).toBeUndefined(); + expect(results.insights).toBeDefined(); + expect(results.insights!['status']).toBe('completed'); + expect(results.insights!['scan']).toBeUndefined(); }); it('should use file path from SFAP violation location', async () => { From 6336a661126aa047601730936e4cf5125570b044 Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Wed, 1 Jul 2026 12:56:36 +0530 Subject: [PATCH 07/12] remove error log --- .../src/engine.ts | 8 ------- .../src/services/ApexGuruAuthService.ts | 4 ++-- .../src/services/ApexGuruService.ts | 2 +- .../test/ApexGuruEngine.test.ts | 21 +------------------ .../test/ApexGuruService.test.ts | 11 ++-------- 5 files changed, 6 insertions(+), 40 deletions(-) diff --git a/packages/code-analyzer-apexguru-engine/src/engine.ts b/packages/code-analyzer-apexguru-engine/src/engine.ts index b142cf2c..68b626dd 100644 --- a/packages/code-analyzer-apexguru-engine/src/engine.ts +++ b/packages/code-analyzer-apexguru-engine/src/engine.ts @@ -53,14 +53,6 @@ export class ApexGuruEngine extends EngineEventEmitter implements Engine { async describeRules(describeOptions: DescribeOptions): Promise { this.emitDescribeRulesProgressEvent(0); - // The SFAP API endpoint is environment-specific and supplied externally. - // When unset, this engine has nowhere to scan against, so it advertises no rules. - if (!process.env.SFAP_API_BASE_URL) { - this.emitLogEvent(LogLevel.Debug, 'SFAP API base URL not configured. ApexGuru engine is disabled.'); - this.emitDescribeRulesProgressEvent(100); - return []; - } - // Check if targeted files contain any Apex files if (describeOptions.workspace) { const targetedFiles = await describeOptions.workspace.getTargetedFiles(); diff --git a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruAuthService.ts b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruAuthService.ts index c745e058..443256db 100644 --- a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruAuthService.ts +++ b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruAuthService.ts @@ -40,7 +40,7 @@ export class ApexGuruAuthService { return; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); - this.emitLogEvent(LogLevel.Error, `Failed to authenticate with org '${config.targetOrg}': ${errorMessage}`); + this.emitLogEvent(LogLevel.Fine, `Failed to authenticate with org '${config.targetOrg}': ${errorMessage}`); throw new Error( `Failed to authenticate with org '${config.targetOrg}'. ` + 'Please verify the org alias/username and ensure you are authenticated:\n' + @@ -58,7 +58,7 @@ export class ApexGuruAuthService { this.emitLogEvent(LogLevel.Fine, 'Successfully authenticated to default org'); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); - this.emitLogEvent(LogLevel.Error, `Failed to authenticate: No default org found: ${errorMessage}`); + this.emitLogEvent(LogLevel.Fine, `Failed to authenticate: No default org found: ${errorMessage}`); throw new Error( 'No default org found. Please either:\n' + ' 1. Set a default org: sf config set target-org \n' + diff --git a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts index bfb059a0..74c6bda8 100644 --- a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts +++ b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts @@ -27,7 +27,7 @@ export class ApexGuruService { private readonly initialRetryMs: number; private readonly maxRetryMs: number; private readonly backoffMultiplier: number; - private readonly sfapBaseUrl = process.env.SFAP_API_BASE_URL ?? ''; + private readonly sfapBaseUrl = 'https://dev.api.salesforce.com/platform/scale/v1-beta.1'; private progressCallback?: (progress: number) => void; private isCancelled = false; diff --git a/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts b/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts index 5dd6e224..0b98943d 100644 --- a/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts +++ b/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts @@ -11,12 +11,8 @@ describe('ApexGuruEngine', () => { let engine: ApexGuruEngine; let mockApexGuruService: jest.Mocked; let mockWorkspace: jest.Mocked; - let originalSfapBaseUrl: string | undefined; - beforeEach(() => { jest.clearAllMocks(); - originalSfapBaseUrl = process.env.SFAP_API_BASE_URL; - process.env.SFAP_API_BASE_URL = 'https://example.test/sfap'; mockApexGuruService = { initialize: jest.fn(), @@ -39,11 +35,7 @@ describe('ApexGuruEngine', () => { }); afterEach(() => { - if (originalSfapBaseUrl === undefined) { - delete process.env.SFAP_API_BASE_URL; - } else { - process.env.SFAP_API_BASE_URL = originalSfapBaseUrl; - } + jest.restoreAllMocks(); }); describe('getName', () => { @@ -63,17 +55,6 @@ describe('ApexGuruEngine', () => { }); describe('describeRules', () => { - it('should return empty array when SFAP_API_BASE_URL is not set', async () => { - delete process.env.SFAP_API_BASE_URL; - - const rules = await engine.describeRules({ - logFolder: '/tmp/logs', - workingFolder: '/tmp/working' - }); - - expect(rules).toEqual([]); - }); - it('should return all ApexGuru rules', async () => { const rules = await engine.describeRules({ logFolder: '/tmp/logs', diff --git a/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts b/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts index adf323a4..d8a46b11 100644 --- a/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts +++ b/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts @@ -7,7 +7,7 @@ jest.mock('../src/services/ApexGuruAuthService'); jest.mock('archiver'); jest.mock('node:fs'); -const TEST_SFAP_BASE_URL = 'https://example.test/sfap'; +const TEST_SFAP_BASE_URL = 'https://dev.api.salesforce.com/platform/scale/v1-beta.1'; const mockFetch = jest.fn(); globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; @@ -16,12 +16,9 @@ describe('ApexGuruService', () => { let apexGuruService: ApexGuruService; let mockEmitLogEvent: jest.Mock; let mockAuthService: jest.Mocked; - let originalSfapBaseUrl: string | undefined; beforeEach(() => { jest.clearAllMocks(); - originalSfapBaseUrl = process.env.SFAP_API_BASE_URL; - process.env.SFAP_API_BASE_URL = TEST_SFAP_BASE_URL; mockEmitLogEvent = jest.fn(); mockAuthService = { @@ -45,11 +42,7 @@ describe('ApexGuruService', () => { }); afterEach(() => { - if (originalSfapBaseUrl === undefined) { - delete process.env.SFAP_API_BASE_URL; - } else { - process.env.SFAP_API_BASE_URL = originalSfapBaseUrl; - } + jest.restoreAllMocks(); }); describe('initialize', () => { From 09f99a77fde7e7febc26796e69a1667fb678b910 Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Fri, 10 Jul 2026 14:33:46 +0530 Subject: [PATCH 08/12] apex guru improvements --- .../src/apexguru-rules.ts | 64 +++++++++++ .../src/engine.ts | 99 ++++++++++++++--- .../src/services/ApexGuruService.ts | 103 ++++++++++++++++-- .../test/ApexGuruEngine.test.ts | 97 +++++++++++++++-- .../test/ApexGuruService.test.ts | 11 +- 5 files changed, 336 insertions(+), 38 deletions(-) diff --git a/packages/code-analyzer-apexguru-engine/src/apexguru-rules.ts b/packages/code-analyzer-apexguru-engine/src/apexguru-rules.ts index 43391edb..0b1a15bf 100644 --- a/packages/code-analyzer-apexguru-engine/src/apexguru-rules.ts +++ b/packages/code-analyzer-apexguru-engine/src/apexguru-rules.ts @@ -29,6 +29,26 @@ export const APEXGURU_RULES: RuleDescription[] = [ resourceUrls: ['https://help.salesforce.com/s/articleView?id=xcloud.apexguru_antipattern_dml_in_loop.htm&type=5'] }, + // ================================================================================================================= + // PERFORMANCE RULES - HIGH SEVERITY (PERFORMANCE ONLY - NOT RECOMMENDED) + // ================================================================================================================= + + { + name: 'SoqlInALoopOneHop', + severityLevel: SeverityLevel.High, + tags: [COMMON_TAGS.CATEGORIES.PERFORMANCE, COMMON_TAGS.LANGUAGES.APEX], + description: 'SOQL query reached one method-hop away inside a loop causes performance issues and can hit governor limits', + resourceUrls: ['https://help.salesforce.com/s/articleView?id=xcloud.apexguru_antipattern_soql_in_loop_one_hop.htm&type=5'] + }, + + { + name: 'ExpensiveMethods', + severityLevel: SeverityLevel.High, + tags: [COMMON_TAGS.CATEGORIES.PERFORMANCE, COMMON_TAGS.LANGUAGES.APEX], + description: 'Method accounts for a large share of observed Apex CPU time and is a hotspot for performance work', + resourceUrls: ['https://help.salesforce.com/s/articleView?id=xcloud.apexguru_antipattern_expensive_methods.htm&type=5'] + }, + // ================================================================================================================= // PERFORMANCE RULES - MODERATE SEVERITY (CRITICAL - RECOMMENDED) // ================================================================================================================= @@ -109,6 +129,42 @@ export const APEXGURU_RULES: RuleDescription[] = [ resourceUrls: ['https://help.salesforce.com/s/articleView?id=xcloud.apexguru_antipattern_sobject_map_in_for_loop.htm&type=5'] }, + { + name: 'SoqlWithoutPlatformCache', + severityLevel: SeverityLevel.Moderate, + tags: [COMMON_TAGS.CATEGORIES.PERFORMANCE, COMMON_TAGS.LANGUAGES.APEX], + description: 'Frequently executed SOQL query whose results could be served from Platform Cache to reduce database load', + resourceUrls: ['https://help.salesforce.com/s/articleView?id=xcloud.apexguru_antipattern_soql_without_platform_cache.htm&type=5'] + }, + + // ================================================================================================================= + // PERFORMANCE RULES - LOW SEVERITY (PERFORMANCE ONLY - NOT RECOMMENDED) + // ================================================================================================================= + + { + name: 'LimitsGetHeapsizeMethods', + severityLevel: SeverityLevel.Low, + tags: [COMMON_TAGS.CATEGORIES.PERFORMANCE, COMMON_TAGS.LANGUAGES.APEX], + description: 'Frequent Limits.getHeapSize() calls add runtime overhead', + resourceUrls: ['https://help.salesforce.com/s/articleView?id=xcloud.apexguru_antipattern_limits_getheapsize_methods.htm&type=5'] + }, + + { + name: 'ExpensiveStringComparison', + severityLevel: SeverityLevel.Low, + tags: [COMMON_TAGS.CATEGORIES.PERFORMANCE, COMMON_TAGS.LANGUAGES.APEX], + description: 'Inefficient string comparison wastes CPU time', + resourceUrls: ['https://help.salesforce.com/s/articleView?id=xcloud.apexguru_antipattern_expensive_string_comparison.htm&type=5'] + }, + + { + name: 'ExpensiveDebugStatements', + severityLevel: SeverityLevel.Low, + tags: [COMMON_TAGS.CATEGORIES.PERFORMANCE, COMMON_TAGS.LANGUAGES.APEX], + description: 'Expensive System.debug() statements add runtime overhead', + resourceUrls: ['https://help.salesforce.com/s/articleView?id=xcloud.apexguru_antipattern_expensive_debug_statements.htm&type=5'] + }, + // ================================================================================================================= // BEST PRACTICES - LOW SEVERITY (RECOMMENDED) // ================================================================================================================= @@ -149,6 +205,14 @@ export const APEXGURU_RULES: RuleDescription[] = [ resourceUrls: ['https://help.salesforce.com/s/articleView?id=xcloud.apexguru_antipattern_soql_with_unused_fields.htm&type=5'] }, + { + name: 'WritingFillerStatements', + severityLevel: SeverityLevel.Low, + tags: [COMMON_TAGS.CATEGORIES.BEST_PRACTICES, COMMON_TAGS.LANGUAGES.APEX], + description: 'Filler statements written to inflate code coverage instead of testing real behavior', + resourceUrls: ['https://help.salesforce.com/s/articleView?id=xcloud.apexguru_test_case_antipattern_filler_statements.htm&type=5'] + }, + // ================================================================================================================= // FALLBACK RULE // ================================================================================================================= diff --git a/packages/code-analyzer-apexguru-engine/src/engine.ts b/packages/code-analyzer-apexguru-engine/src/engine.ts index 68b626dd..1cc7c26a 100644 --- a/packages/code-analyzer-apexguru-engine/src/engine.ts +++ b/packages/code-analyzer-apexguru-engine/src/engine.ts @@ -1,5 +1,3 @@ - - import { Engine, EngineEventEmitter, @@ -18,6 +16,7 @@ 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'; import * as fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; import * as path from 'node:path'; /** @@ -118,7 +117,15 @@ export class ApexGuruEngine extends EngineEventEmitter implements Engine { throw new Error('ApexGuru requires a common workspace root, but the targeted files do not share one.'); } - const pathsToZip = [workspaceRoot]; + // Zip only the Apex files the user actually targeted (via --target or the workspace itself). + // Entry names are computed relative to workspaceRoot in createWorkspaceZip so project layout is preserved. + const pathsToZip = apexFiles; + + // TEMP DIAGNOSTIC: log the exact set of files being zipped and shipped to SFAP. + // Pair this with the [apexguru-diag] "Raw SFAP violation paths" log to confirm + // that only what we zipped comes back in violations. Remove once verified. + this.emitLogEvent(LogLevel.Info, + `[apexguru-diag] Zipping ${pathsToZip.length} file(s) for SFAP: ${JSON.stringify(pathsToZip)}`); try { // Set up progress callback for polling @@ -127,21 +134,60 @@ export class ApexGuruEngine extends EngineEventEmitter implements Engine { }); // Scan (creates zip -> submits -> polls -> decodes) - const { violations: apexGuruViolations, scanMetadata } = await this.apexGuruService.scanWorkspace(workspaceRoot, pathsToZip); + const { violations: apexGuruViolations, scanMetadata, analysisMode } = await this.apexGuruService.scanWorkspace(workspaceRoot, pathsToZip); + + // TEMP DIAGNOSTIC: dump the raw file paths ApexGuru returned so we can see + // exactly what SFAP is sending back (inner-class notation? relative? absolute?). + // Remove once the "file does not exist" edge case is fully understood. + const rawFilePaths = apexGuruViolations.map(av => ({ + rule: av.rule, + file: av.locations[av.primaryLocationIndex]?.file ?? av.locations[0]?.file, + startLine: av.locations[av.primaryLocationIndex]?.startLine ?? av.locations[0]?.startLine + })); + this.emitLogEvent(LogLevel.Info, + `[apexguru-diag] Raw SFAP violation paths (${rawFilePaths.length}): ${JSON.stringify(rawFilePaths)}`); + + // SFAP normalizes the paths it returns — it strips the longest common leading directory + // shared by all files in the zip. e.g. if we zip `unpackaged/config/foo.cls` and + // `unpackaged/config/bar.cls`, SFAP returns `foo.cls` and `bar.cls`. Reconstruct the + // real absolute path by matching each returned path as a suffix of what we actually zipped. + const resolveReturnedPath = (returnedFile: string | undefined): string | undefined => { + if (!returnedFile) return undefined; + const normalized = returnedFile.replace(/\\/g, '/'); + const match = pathsToZip.find(absPath => { + const absNormalized = absPath.replace(/\\/g, '/'); + return absNormalized === normalized || absNormalized.endsWith(`/${normalized}`); + }); + return match; + }; // Convert all ApexGuru violations to Code Analyzer format const allViolations = apexGuruViolations.map(av => { - // SFAP response includes file path in location.file - const filePath = av.locations[0]?.file ?? 'unknown'; - return toViolation(av, filePath, runOptions.includeSuggestions ?? false); + const returnedFile = av.locations[av.primaryLocationIndex]?.file ?? av.locations[0]?.file; + const resolvedFile = resolveReturnedPath(returnedFile) ?? returnedFile ?? 'unknown'; + return toViolation(av, resolvedFile, workspaceRoot, runOptions.includeSuggestions ?? false, resolveReturnedPath); + }); + + // Drop violations whose primary code location points at a non-existent file. + // Safety net for edge cases the suffix-match couldn't resolve (e.g. synthetic paths + // like inner-class notation `Foo.InnerHelper.cls`) so we never hand Core a bad path. + const validViolations = allViolations.filter(v => { + const file = v.codeLocations[v.primaryLocationIndex]?.file; + if (file && existsSync(file)) { + return true; + } + this.emitLogEvent(LogLevel.Warn, + `Dropping ${v.ruleName} violation: primary location file does not exist on disk: ${file ?? '(missing)'}`); + return false; }); // Filter violations to only include selected rules - const filteredViolations = allViolations.filter(v => selectedRulesSet.has(v.ruleName)); + const filteredViolations = validViolations.filter(v => selectedRulesSet.has(v.ruleName)); - // Return insights with status: "completed" and scan metadata + // Return insights with status: "completed", analysis mode, and scan metadata const insights: Record = { status: 'completed', + ...(analysisMode ? { analysisMode } : {}), ...(scanMetadata ? { scan: scanMetadata } : {}) }; @@ -222,7 +268,9 @@ export class ApexGuruEngine extends EngineEventEmitter implements Engine { function toViolation( av: ApexGuruViolation, filePath: string, - includeSuggestions: boolean + workspaceRoot: string, + includeSuggestions: boolean, + resolveReturnedPath: (returnedFile: string | undefined) => string | undefined ): Violation { // Map unknown rules to fallback to ensure Core validation passes const ruleName = isKnownRule(av.rule) ? av.rule : FALLBACK_RULE_NAME; @@ -230,14 +278,14 @@ function toViolation( const violation: Violation = { ruleName, message: av.message, - codeLocations: av.locations.map(loc => normalizeLocation(loc, filePath)), + codeLocations: av.locations.map(loc => normalizeLocation(loc, filePath, workspaceRoot, resolveReturnedPath)), primaryLocationIndex: av.primaryLocationIndex, resourceUrls: av.resources }; // Add suggestions if requested and available if (includeSuggestions && av.suggestions?.length) { - violation.suggestions = av.suggestions.map(suggestion => toSuggestion(suggestion, filePath)); + violation.suggestions = av.suggestions.map(suggestion => toSuggestion(suggestion, filePath, workspaceRoot, resolveReturnedPath)); } return violation; @@ -247,9 +295,14 @@ function toViolation( * Convert ApexGuru suggestion to Code Analyzer Suggestion format * Note: suggestion.message contains "// explanation\ncode" - we keep it as-is */ -function toSuggestion(apexGuruSuggestion: ApexGuruSuggestion, filePath: string): Suggestion { +function toSuggestion( + apexGuruSuggestion: ApexGuruSuggestion, + filePath: string, + workspaceRoot: string, + resolveReturnedPath: (returnedFile: string | undefined) => string | undefined +): Suggestion { return { - location: normalizeLocation(apexGuruSuggestion.location, filePath), + location: normalizeLocation(apexGuruSuggestion.location, filePath, workspaceRoot, resolveReturnedPath), message: apexGuruSuggestion.message // Keep "// explanation\ncode" as-is }; } @@ -258,21 +311,31 @@ function toSuggestion(apexGuruSuggestion: ApexGuruSuggestion, filePath: string): * Normalize location by filling in required fields * * SFAP ApexGuru API provides: - * - file (from SFAP response, workspace-relative path) + * - file (from SFAP response, path normalized against zip contents — may have common leading dirs stripped) * - startLine (required) * - comment (optional) * * We fill in: * - startColumn = 1 (required by Code Analyzer, reasonable default if not provided) - * - Use file from location if provided, else use filePath parameter + * - Prefer the fallback filePath (already resolved via suffix-match against zipped files); + * if the location's own file matches a zipped path by suffix, use that instead + * - Resolve any remaining workspace-relative paths against workspaceRoot * - endLine/endColumn are left undefined (optional fields) */ -function normalizeLocation(location: ApexGuruLocation, filePath: string): CodeLocation { +function normalizeLocation( + location: ApexGuruLocation, + filePath: string, + workspaceRoot: string, + resolveReturnedPath: (returnedFile: string | undefined) => string | undefined +): CodeLocation { const startLine = location.startLine ?? 1; const startColumn = location.startColumn ?? 1; // Default to column 1 if not provided + const locationResolved = resolveReturnedPath(location.file); + const rawFile = locationResolved ?? location.file ?? filePath; + const resolvedFile = path.isAbsolute(rawFile) ? rawFile : path.resolve(workspaceRoot, rawFile); return { - file: location.file ?? filePath, // SFAP includes file path in response + file: resolvedFile, startLine, startColumn, endLine: location.endLine, // undefined if not provided (optional) diff --git a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts index 74c6bda8..54e0f4c7 100644 --- a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts +++ b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts @@ -17,6 +17,10 @@ import FormData from 'form-data'; const MAX_ZIP_SIZE_BYTES = 20 * 1024 * 1024; +// Identifies Code Analyzer as the caller on ApexGuru SFAP requests. +// Not sent on the /ide/auth JWT-mint call — that's a separate service. +const APEXGURU_CLIENT_HEADER = { 'x-apexguru-client': 'CodeAnalyzer' } as const; + /** * Service for interacting with SFAP ApexGuru workspace scan APIs */ @@ -27,7 +31,7 @@ export class ApexGuruService { private readonly initialRetryMs: number; private readonly maxRetryMs: number; private readonly backoffMultiplier: number; - private readonly sfapBaseUrl = 'https://dev.api.salesforce.com/platform/scale/v1-beta.1'; + private readonly sfapBaseUrl = 'https://stage.api.salesforce.com//platform/scale/v1-beta.1'; private progressCallback?: (progress: number) => void; private isCancelled = false; @@ -76,7 +80,7 @@ export class ApexGuruService { * the archive preserves project structure (e.g. force-app/main/default/classes/Foo.cls). * Each path in pathsToZip may be a file or a folder; contents are not inspected or filtered. */ - async scanWorkspace(workspaceRoot: string, pathsToZip: string[]): Promise<{violations: ApexGuruViolation[], scanMetadata?: ApexGuruScanMetadata}> { + async scanWorkspace(workspaceRoot: string, pathsToZip: string[]): Promise<{violations: ApexGuruViolation[], scanMetadata?: ApexGuruScanMetadata, analysisMode?: string}> { this.isCancelled = false; let timeoutId: NodeJS.Timeout; const scanPromise = this.performScan(workspaceRoot, pathsToZip); @@ -97,7 +101,7 @@ export class ApexGuruService { /** * Perform the full scan workflow: create zip -> submit -> poll -> decode */ - private async performScan(workspaceRoot: string, pathsToZip: string[]): Promise<{violations: ApexGuruViolation[], scanMetadata?: ApexGuruScanMetadata}> { + private async performScan(workspaceRoot: string, pathsToZip: string[]): Promise<{violations: ApexGuruViolation[], scanMetadata?: ApexGuruScanMetadata, analysisMode?: string}> { // Step 1: Create zip of workspace const zipBuffer = await this.createWorkspaceZip(workspaceRoot, pathsToZip); @@ -171,6 +175,7 @@ export class ApexGuruService { method: 'POST', headers: { 'Authorization': `Bearer ${orgJwt}`, + ...APEXGURU_CLIENT_HEADER, ...form.getHeaders() }, body: form.getBuffer() @@ -178,7 +183,7 @@ export class ApexGuruService { if (!response.ok) { const errorText = await response.text(); - throw new Error(`SFAP API returned ${response.status}: ${errorText}`); + throw new Error(formatHttpError(response.status, errorText)); } const submitResponse = await response.json() as ApexGuruSubmitResponse; @@ -212,13 +217,14 @@ export class ApexGuruService { const response = await fetch(url, { method: 'GET', headers: { - 'Authorization': `Bearer ${orgJwt}` + 'Authorization': `Bearer ${orgJwt}`, + ...APEXGURU_CLIENT_HEADER } }); if (!response.ok) { const errorText = await response.text(); - throw new Error(`SFAP API returned ${response.status}: ${errorText}`); + throw new Error(formatHttpError(response.status, errorText)); } const pollResponse: ApexGuruPollResponse = await response.json() as ApexGuruPollResponse; @@ -251,9 +257,13 @@ export class ApexGuruService { /** * Decode base64 report and parse violations */ - private decodeResults(pollResponse: ApexGuruPollResponse): {violations: ApexGuruViolation[], scanMetadata?: ApexGuruScanMetadata} { + private decodeResults(pollResponse: ApexGuruPollResponse): {violations: ApexGuruViolation[], scanMetadata?: ApexGuruScanMetadata, analysisMode?: string} { if (!pollResponse.report) { - return { violations: [], scanMetadata: pollResponse.scanMetadata ?? undefined }; + return { + violations: [], + scanMetadata: pollResponse.scanMetadata ?? undefined, + analysisMode: pollResponse.analysisMode ?? undefined + }; } try { @@ -262,7 +272,8 @@ export class ApexGuruService { return { violations, - scanMetadata: pollResponse.scanMetadata ?? undefined + scanMetadata: pollResponse.scanMetadata ?? undefined, + analysisMode: pollResponse.analysisMode ?? undefined }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -290,3 +301,77 @@ function isApexSourceFile(entryPath: string): boolean { const lower = entryPath.toLowerCase(); return lower.endsWith('.cls') || lower.endsWith('.trigger'); } + +/** + * Format a non-2xx HTTP response into a concise, single-line error message. + * SFAP maintenance/error pages come back as HTML — strip tags and collapse whitespace + * so the surfaced error isn't a wall of markup. + */ +function formatHttpError(status: number, body: string): string { + const statusText = STATUS_TEXT[status] ?? 'Error'; + const detail = summarizeErrorBody(body); + return detail + ? `SFAP API returned ${status} ${statusText}: ${detail}` + : `SFAP API returned ${status} ${statusText}`; +} + +const STATUS_TEXT: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 408: 'Request Timeout', + 429: 'Too Many Requests', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout' +}; + +const MAX_ERROR_DETAIL_LENGTH = 300; + +/** + * Extract a short, human-readable summary from an error response body. + * Handles HTML (strips tags, keeps text), JSON (extracts common error fields), + * and plain text (collapses whitespace). Truncates to keep logs readable. + */ +function summarizeErrorBody(body: string): string { + if (!body) { + return ''; + } + const trimmed = body.trim(); + + // Try JSON first — SFAP typically returns { message, error, detail } on structured errors + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed) as Record; + const message = parsed.message ?? parsed.error ?? parsed.detail ?? parsed.error_description; + if (typeof message === 'string' && message.trim()) { + return truncate(message.trim()); + } + } catch { + // fall through to text/HTML handling + } + } + + // Strip HTML tags and script/style blocks, then collapse whitespace + const stripped = trimmed + .replace(//gi, '') + .replace(//gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/\s+/g, ' ') + .trim(); + + return truncate(stripped); +} + +function truncate(text: string): string { + return text.length > MAX_ERROR_DETAIL_LENGTH + ? `${text.slice(0, MAX_ERROR_DETAIL_LENGTH)}…` + : text; +} diff --git a/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts b/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts index 0b98943d..4101faf1 100644 --- a/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts +++ b/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts @@ -2,10 +2,15 @@ import { ApexGuruEngine } from '../src/engine'; import { ApexGuruService } from '../src/services/ApexGuruService'; import { LogLevel, RunOptions, Workspace } from '@salesforce/code-analyzer-engine-api'; import * as fs from 'node:fs/promises'; +import * as fsSync from 'node:fs'; // Mock dependencies jest.mock('../src/services/ApexGuruService'); jest.mock('node:fs/promises'); +jest.mock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + existsSync: jest.fn().mockReturnValue(true) +})); describe('ApexGuruEngine', () => { let engine: ApexGuruEngine; @@ -179,7 +184,7 @@ describe('ApexGuruEngine', () => { await engine.runRules(['SoqlInALoop'], mockRunOptions); expect(mockApexGuruService.initialize).toHaveBeenCalledWith(undefined); - expect(mockApexGuruService.scanWorkspace).toHaveBeenCalledWith('/test/workspace', ['/test/workspace']); + expect(mockApexGuruService.scanWorkspace).toHaveBeenCalledWith('/test/workspace', ['/test/workspace/Test.cls']); }); it('should gracefully skip with NO_ORG_CONNECTION when authentication fails', async () => { @@ -293,7 +298,7 @@ describe('ApexGuruEngine', () => { expect(mockApexGuruService.cleanup).toHaveBeenCalled(); }); - it('should scan workspace with all Apex files', async () => { + it('should zip only the targeted Apex files', async () => { mockWorkspace.getTargetedFiles.mockResolvedValue([ '/test/workspace/classes/Test.cls', '/test/workspace/triggers/AccountTrigger.trigger' @@ -301,7 +306,24 @@ describe('ApexGuruEngine', () => { await engine.runRules(['SoqlInALoop'], mockRunOptions); - expect(mockApexGuruService.scanWorkspace).toHaveBeenCalledWith('/test/workspace', ['/test/workspace']); + expect(mockApexGuruService.scanWorkspace).toHaveBeenCalledWith('/test/workspace', [ + '/test/workspace/classes/Test.cls', + '/test/workspace/triggers/AccountTrigger.trigger' + ]); + }); + + it('should exclude non-Apex targeted files from the zip', async () => { + mockWorkspace.getTargetedFiles.mockResolvedValue([ + '/test/workspace/classes/Test.cls', + '/test/workspace/README.md', + '/test/workspace/lwc/foo/foo.js' + ]); + + await engine.runRules(['SoqlInALoop'], mockRunOptions); + + expect(mockApexGuruService.scanWorkspace).toHaveBeenCalledWith('/test/workspace', [ + '/test/workspace/classes/Test.cls' + ]); }); it('should filter violations by selected rules', async () => { @@ -462,7 +484,7 @@ describe('ApexGuruEngine', () => { expect(mockApexGuruService.initialize).toHaveBeenCalledWith('my-org'); }); - it('should return insights with status completed and scan metadata on success', async () => { + it('should return insights with status completed, analysis mode, and scan metadata on success', async () => { mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/workspace/Test.cls']); const mockScanMetadata = { analysis_mode: 'full' as const, @@ -473,17 +495,19 @@ describe('ApexGuruEngine', () => { }; mockApexGuruService.scanWorkspace.mockResolvedValue({ violations: [], - scanMetadata: mockScanMetadata + scanMetadata: mockScanMetadata, + analysisMode: 'full' }); const results = await engine.runRules(['SoqlInALoop'], mockRunOptions); expect(results.insights).toBeDefined(); expect(results.insights!['status']).toBe('completed'); + expect(results.insights!['analysisMode']).toBe('full'); expect(results.insights!['scan']).toEqual(mockScanMetadata); }); - it('should return insights with status completed even without scan metadata', async () => { + it('should return insights with status completed even without scan metadata or analysis mode', async () => { mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/workspace/Test.cls']); mockApexGuruService.scanWorkspace.mockResolvedValue({ violations: [] }); @@ -491,6 +515,7 @@ describe('ApexGuruEngine', () => { expect(results.insights).toBeDefined(); expect(results.insights!['status']).toBe('completed'); + expect(results.insights!['analysisMode']).toBeUndefined(); expect(results.insights!['scan']).toBeUndefined(); }); @@ -513,7 +538,65 @@ describe('ApexGuruEngine', () => { expect(results.violations).toHaveLength(1); expect(results.violations[0].primaryLocationIndex).toBe(0); - expect(results.violations[0].codeLocations[0].file).toBe('force-app/main/default/classes/Test.cls'); + expect(results.violations[0].codeLocations[0].file).toBe('/test/workspace/force-app/main/default/classes/Test.cls'); + }); + + it('should reconstruct the real absolute path when SFAP strips a common leading directory', async () => { + // Simulate the NPSP case: user targeted files under .../NPSP/unpackaged/, + // SFAP stripped "unpackaged/" from the returned path. + mockWorkspace.getWorkspaceRoot.mockReturnValue('/test/workspace'); + const zippedFile = '/test/workspace/unpackaged/config/foo/classes/Bar.cls'; + mockWorkspace.getTargetedFiles.mockResolvedValue([zippedFile]); + (fsSync.existsSync as jest.Mock).mockImplementation((p: string) => p === zippedFile); + mockApexGuruService.scanWorkspace.mockResolvedValue({ + violations: [ + { + rule: 'SoqlInALoop', + message: 'stripped path', + locations: [{ startLine: 5, file: 'config/foo/classes/Bar.cls' }], + primaryLocationIndex: 0, + severity: 1, + resources: [] + } + ] + }); + + const results = await engine.runRules(['SoqlInALoop'], mockRunOptions); + + expect(results.violations).toHaveLength(1); + expect(results.violations[0].codeLocations[0].file).toBe(zippedFile); + }); + + it('should drop violations whose primary location file does not exist on disk', async () => { + mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/workspace/Test.cls']); + // existsSync returns true only for the real file, false for the synthetic inner-class path + (fsSync.existsSync as jest.Mock).mockImplementation((p: string) => + p === '/test/workspace/force-app/main/default/classes/Real.cls'); + mockApexGuruService.scanWorkspace.mockResolvedValue({ + violations: [ + { + rule: 'SoqlInALoop', + message: 'real file', + locations: [{ startLine: 10, file: 'force-app/main/default/classes/Real.cls' }], + primaryLocationIndex: 0, + severity: 1, + resources: [] + }, + { + rule: 'SoqlInALoop', + message: 'synthetic inner-class path', + locations: [{ startLine: 20, file: 'force-app/main/default/classes/Foo.InnerHelper.cls' }], + primaryLocationIndex: 0, + severity: 1, + resources: [] + } + ] + }); + + const results = await engine.runRules(['SoqlInALoop'], mockRunOptions); + + expect(results.violations).toHaveLength(1); + expect(results.violations[0].codeLocations[0].file).toBe('/test/workspace/force-app/main/default/classes/Real.cls'); }); }); }); diff --git a/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts b/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts index d8a46b11..11390344 100644 --- a/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts +++ b/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts @@ -142,6 +142,7 @@ describe('ApexGuruService', () => { expect(result.violations).toEqual(mockViolations); expect(result.scanMetadata).toEqual(mockScanMetadata); + expect(result.analysisMode).toBe('full'); expect(mockFetch).toHaveBeenCalledTimes(2); // submit + poll }); @@ -477,7 +478,8 @@ describe('ApexGuruService', () => { expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ - 'Authorization': 'Bearer mock-jwt-token' + 'Authorization': 'Bearer mock-jwt-token', + 'x-apexguru-client': 'CodeAnalyzer' }) }) ); @@ -488,9 +490,10 @@ describe('ApexGuruService', () => { `${TEST_SFAP_BASE_URL}/apex-guru/scan/scan-endpoint-check`, expect.objectContaining({ method: 'GET', - headers: { - 'Authorization': 'Bearer mock-jwt-token' - } + headers: expect.objectContaining({ + 'Authorization': 'Bearer mock-jwt-token', + 'x-apexguru-client': 'CodeAnalyzer' + }) }) ); }); From 876aceaa6d766b9ac593a416d3d5b3be13bce55b Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Fri, 10 Jul 2026 14:44:09 +0530 Subject: [PATCH 09/12] test fix --- .../src/services/ApexGuruService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts index 54e0f4c7..e2d7a596 100644 --- a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts +++ b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts @@ -31,7 +31,7 @@ export class ApexGuruService { private readonly initialRetryMs: number; private readonly maxRetryMs: number; private readonly backoffMultiplier: number; - private readonly sfapBaseUrl = 'https://stage.api.salesforce.com//platform/scale/v1-beta.1'; + private readonly sfapBaseUrl = 'https://dev.api.salesforce.com//platform/scale/v1-beta.1'; private progressCallback?: (progress: number) => void; private isCancelled = false; From 0a7c0dca29584d20974ebca6debdd90e56d10405 Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Fri, 10 Jul 2026 14:49:19 +0530 Subject: [PATCH 10/12] test fix --- .../src/services/ApexGuruService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts index e2d7a596..5f9c5565 100644 --- a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts +++ b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts @@ -31,7 +31,7 @@ export class ApexGuruService { private readonly initialRetryMs: number; private readonly maxRetryMs: number; private readonly backoffMultiplier: number; - private readonly sfapBaseUrl = 'https://dev.api.salesforce.com//platform/scale/v1-beta.1'; + private readonly sfapBaseUrl = 'https://dev.api.salesforce.com/platform/scale/v1-beta.1'; private progressCallback?: (progress: number) => void; private isCancelled = false; From 6cd4f47b5da2cdb2e59c69c191efea44e9de99c4 Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Fri, 10 Jul 2026 15:05:44 +0530 Subject: [PATCH 11/12] test fix --- .../test/ApexGuruEngine.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts b/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts index 4101faf1..5269938c 100644 --- a/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts +++ b/packages/code-analyzer-apexguru-engine/test/ApexGuruEngine.test.ts @@ -3,6 +3,7 @@ import { ApexGuruService } from '../src/services/ApexGuruService'; import { LogLevel, RunOptions, Workspace } from '@salesforce/code-analyzer-engine-api'; import * as fs from 'node:fs/promises'; import * as fsSync from 'node:fs'; +import * as path from 'node:path'; // Mock dependencies jest.mock('../src/services/ApexGuruService'); @@ -538,7 +539,8 @@ describe('ApexGuruEngine', () => { expect(results.violations).toHaveLength(1); expect(results.violations[0].primaryLocationIndex).toBe(0); - expect(results.violations[0].codeLocations[0].file).toBe('/test/workspace/force-app/main/default/classes/Test.cls'); + expect(results.violations[0].codeLocations[0].file).toBe( + path.resolve('/test/workspace', 'force-app/main/default/classes/Test.cls')); }); it('should reconstruct the real absolute path when SFAP strips a common leading directory', async () => { @@ -569,9 +571,10 @@ describe('ApexGuruEngine', () => { it('should drop violations whose primary location file does not exist on disk', async () => { mockWorkspace.getTargetedFiles.mockResolvedValue(['/test/workspace/Test.cls']); + const realFilePath = path.resolve('/test/workspace', 'force-app/main/default/classes/Real.cls'); // existsSync returns true only for the real file, false for the synthetic inner-class path (fsSync.existsSync as jest.Mock).mockImplementation((p: string) => - p === '/test/workspace/force-app/main/default/classes/Real.cls'); + p === realFilePath); mockApexGuruService.scanWorkspace.mockResolvedValue({ violations: [ { @@ -596,7 +599,7 @@ describe('ApexGuruEngine', () => { const results = await engine.runRules(['SoqlInALoop'], mockRunOptions); expect(results.violations).toHaveLength(1); - expect(results.violations[0].codeLocations[0].file).toBe('/test/workspace/force-app/main/default/classes/Real.cls'); + expect(results.violations[0].codeLocations[0].file).toBe(realFilePath); }); }); }); From 9d664341d5d23f6e7dcf49b4d9c6d8157179cbbf Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Mon, 13 Jul 2026 18:01:33 +0530 Subject: [PATCH 12/12] review comment fixes --- packages/code-analyzer-apexguru-engine/src/engine.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/code-analyzer-apexguru-engine/src/engine.ts b/packages/code-analyzer-apexguru-engine/src/engine.ts index 1cc7c26a..0d5518b7 100644 --- a/packages/code-analyzer-apexguru-engine/src/engine.ts +++ b/packages/code-analyzer-apexguru-engine/src/engine.ts @@ -124,7 +124,7 @@ export class ApexGuruEngine extends EngineEventEmitter implements Engine { // TEMP DIAGNOSTIC: log the exact set of files being zipped and shipped to SFAP. // Pair this with the [apexguru-diag] "Raw SFAP violation paths" log to confirm // that only what we zipped comes back in violations. Remove once verified. - this.emitLogEvent(LogLevel.Info, + this.emitLogEvent(LogLevel.Fine, `[apexguru-diag] Zipping ${pathsToZip.length} file(s) for SFAP: ${JSON.stringify(pathsToZip)}`); try { @@ -144,7 +144,7 @@ export class ApexGuruEngine extends EngineEventEmitter implements Engine { file: av.locations[av.primaryLocationIndex]?.file ?? av.locations[0]?.file, startLine: av.locations[av.primaryLocationIndex]?.startLine ?? av.locations[0]?.startLine })); - this.emitLogEvent(LogLevel.Info, + this.emitLogEvent(LogLevel.Fine, `[apexguru-diag] Raw SFAP violation paths (${rawFilePaths.length}): ${JSON.stringify(rawFilePaths)}`); // SFAP normalizes the paths it returns — it strips the longest common leading directory