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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/code-analyzer-core/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@salesforce/code-analyzer-core",
"description": "Core Package for the Salesforce Code Analyzer",
"version": "0.45.0",
"version": "0.46.0-SNAPSHOT",
"author": "The Salesforce Code Analyzer Team",
"license": "BSD-3-Clause",
"homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview",
Expand Down
171 changes: 140 additions & 31 deletions packages/code-analyzer-core/src/code-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
UninstantiableEngineRunResults,
Violation
} from "./results"
import {processSuppressions} from "./suppressions"
import {processSuppressions, extractSuppressionsFromFiles, SuppressionsMap, LoggerCallback} from "./suppressions"
import {SemVer} from 'semver';
import {
EngineLogEvent,
Expand Down Expand Up @@ -120,6 +120,11 @@ export class CodeAnalyzer {
private readonly engineConfigDescriptions: Map<string, ConfigDescription> = new Map();
private readonly rulesCache: Map<string, RuleImpl[]> = new Map();
private readonly engineRuleDiscoveryProgressAggregator: EngineProgressAggregator = new EngineProgressAggregator();
// Caching for per-engine suppression processing to avoid duplicate file processing
private readonly suppressionsMap: SuppressionsMap = new Map();
private readonly fileProcessingPromises: Map<string, Promise<void>> = new Map();
// Track total suppressed violations for aggregate logging
private totalSuppressedViolations: number = 0;

constructor(config: CodeAnalyzerConfig, fileSystem: FileSystem = new RealFileSystem(), nodeVersion: string = process.version) {
this.validateEnvironment(nodeVersion);
Expand Down Expand Up @@ -348,6 +353,15 @@ export class CodeAnalyzer {
// up a bunch of RunResults promises and then does a Promise.all on them. Otherwise, the progress events may
// override each other.

// Reset suppression counter for this run
this.totalSuppressedViolations = 0;

// Clear suppression caches from previous runs to prevent unbounded memory growth
// Each run typically analyzes a different workspace, so caching across runs provides minimal benefit
// while keeping stale data in memory.
this.suppressionsMap.clear();
this.fileProcessingPromises.clear();

this.emitLogEvent(LogLevel.Debug, getMessage('RunningWithWorkspace', JSON.stringify({
filesAndFolders: runOptions.workspace.getRawFilesAndFolders(),
targets: runOptions.workspace.getRawTargets()
Expand Down Expand Up @@ -395,55 +409,147 @@ export class CodeAnalyzer {
runResults.addEngineRunResults(new UninstantiableEngineRunResults(uninstantiableEngine, error));
}

// Process inline suppressions (post-processing step)
// This filters out violations that have been suppressed via inline markers
await this.applyInlineSuppressions(runResults);
// Note: Inline suppressions are now applied per-engine in runEngineAndValidateResults() before EngineResultsEvent is emitted

// Log aggregate suppression count if any violations were suppressed
if (this.config.getSuppressionsEnabled()) {
if (this.totalSuppressedViolations > 0) {
this.emitLogEvent(LogLevel.Info, getMessage('SuppressedViolationsCount', this.totalSuppressedViolations));
} else {
this.emitLogEvent(LogLevel.Info, getMessage('NoViolationsSuppressed'));
}
}

return runResults;
}

/**
* Applies suppression filtering to the run results
* This processes suppression markers in source files and filters out suppressed violations
* @param runResults The run results to apply suppressions to
* Applies suppression filtering to a single engine's results
* This processes suppression markers in source files and returns a filtered version of the engine results
* This method handles race conditions by caching suppression ranges per file
* @param engineRunResults The engine run results to apply suppressions to
* @returns Filtered engine run results with suppressions applied
*/
private async applyInlineSuppressions(runResults: RunResultsImpl): Promise<void> {
private async applyInlineSuppressionsToEngineResults(
engineRunResults: EngineRunResults
): Promise<EngineRunResults> {
// Check if suppressions are enabled
if (!this.config.getSuppressionsEnabled()) {
return; // Feature disabled, skip processing
return engineRunResults; // Feature disabled, return original results
}

const allViolations = runResults.getViolations();
const violations = engineRunResults.getViolations();

if (violations.length === 0) {
return engineRunResults; // No violations to process
}

// Extract unique file paths from violations for race condition handling
const filePaths = new Set<string>();
for (const violation of violations) {
const primaryLocation = violation.getPrimaryLocation();
const file = primaryLocation.getFile();
if (file) {
filePaths.add(file);
}
}

if (allViolations.length === 0) {
return; // No violations to process
if (filePaths.size === 0) {
return engineRunResults; // No files with violations
}

this.emitLogEvent(LogLevel.Debug, getMessage('ProcessingInlineSuppressions', allViolations.length));
// Process files with race condition handling to pre-populate the shared map
await this.processFilesForSuppressions(filePaths);

// Process suppressions (returns filtered violations)
const logger = (level: 'error' | 'warn' | 'debug', message: string) => {
// Use processSuppressions with the pre-populated shared map
// This will skip re-parsing files already in the map and just filter violations
const logger: LoggerCallback = (level: 'error' | 'warn' | 'debug', message: string) => {
const logLevel = level === 'error' ? LogLevel.Error : level === 'warn' ? LogLevel.Warn : LogLevel.Debug;
this.emitLogEvent(logLevel, message);
};
const filteredViolations = await processSuppressions(allViolations, logger);

// Calculate which violations were suppressed
const suppressedViolations = new Set<Violation>();
const filteredSet = new Set(filteredViolations);
for (const violation of allViolations) {
if (!filteredSet.has(violation)) {
suppressedViolations.add(violation);
}
const filteredViolations = await processSuppressions(violations, logger, this.suppressionsMap);

// Calculate how many violations were suppressed
const suppressedCount = violations.length - filteredViolations.length;

// If all violations remain (nothing suppressed), return original results
if (suppressedCount === 0) {
return engineRunResults;
}

const suppressedCount = suppressedViolations.size;
if (suppressedCount > 0) {
this.emitLogEvent(LogLevel.Info, getMessage('SuppressedViolationsCount', suppressedCount));
runResults.applySuppressedViolationsFilter(suppressedViolations);
} else {
this.emitLogEvent(LogLevel.Info, getMessage('NoViolationsSuppressed'));
// Track suppressed violations for aggregate logging
this.totalSuppressedViolations += suppressedCount;

// Return filtered results using FilteredEngineRunResults wrapper
return this.createFilteredEngineRunResults(engineRunResults, filteredViolations);
}

/**
* Creates a FilteredEngineRunResults wrapper
* This is a temporary method until FilteredEngineRunResults is exported from results.ts
*/
private createFilteredEngineRunResults(
originalResults: EngineRunResults,
filteredViolations: Violation[]
): EngineRunResults {
// We need to create an instance that implements EngineRunResults
// but filters the violations
return {
getEngineName: () => originalResults.getEngineName(),
getEngineVersion: () => originalResults.getEngineVersion(),
getViolationCount: () => filteredViolations.length,
getViolationCountOfSeverity: (severity: number) =>
filteredViolations.filter(v => v.getRule().getSeverityLevel() === severity).length,
getViolations: () => filteredViolations
};
}

/**
* Processes files for suppression markers with race condition handling
* Uses caching to avoid processing the same file multiple times when multiple engines
* return violations for the same file
* @param filePaths Set of file paths that need suppression information
*/
private async processFilesForSuppressions(filePaths: Set<string>): Promise<void> {
const logger: LoggerCallback = (level: 'error' | 'warn' | 'debug', message: string) => {
const logLevel = level === 'error' ? LogLevel.Error : level === 'warn' ? LogLevel.Warn : LogLevel.Debug;
this.emitLogEvent(logLevel, message);
};

const processingPromises: Promise<void>[] = [];

for (const filePath of filePaths) {
// If already in cache, skip
if (this.suppressionsMap.has(filePath)) {
continue;
}

// If currently being processed, await that promise
let processingPromise = this.fileProcessingPromises.get(filePath);

if (!processingPromise) {
// Start new processing - wrap extractSuppressionsFromFiles call
processingPromise = extractSuppressionsFromFiles(
new Set([filePath]),
this.suppressionsMap,
logger
).then(() => {
// Clean up the promise from tracking map since it's done
this.fileProcessingPromises.delete(filePath);
}).catch((err) => {
// Clean up on error too
this.fileProcessingPromises.delete(filePath);
throw err;
});

this.fileProcessingPromises.set(filePath, processingPromise);
}

processingPromises.push(processingPromise);
}

// Wait for all file processing to complete
await Promise.all(processingPromises);
}

/**
Expand Down Expand Up @@ -565,7 +671,10 @@ export class CodeAnalyzer {
}

validateEngineRunResults(engineName, apiEngineRunResults, ruleSelection);
const engineRunResults: EngineRunResults = new EngineRunResultsImpl(engineName, await engine.getEngineVersion(), apiEngineRunResults, ruleSelection);
let engineRunResults: EngineRunResults = new EngineRunResultsImpl(engineName, await engine.getEngineVersion(), apiEngineRunResults, ruleSelection);

// Apply inline suppressions per-engine BEFORE emitting EngineResultsEvent
engineRunResults = await this.applyInlineSuppressionsToEngineResults(engineRunResults);

this.emitEvent<EngineRunProgressEvent>({
type: EventType.EngineRunProgressEvent, timestamp: this.clock.now(), engineName: engineName, percentComplete: 100
Expand Down
13 changes: 13 additions & 0 deletions packages/code-analyzer-core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,12 @@ export class CodeAnalyzerConfig {
valueType: 'object',
defaultValue: { files: [] },
wasSuppliedByUser: !deepEquals(this.config.ignores, DEFAULT_CONFIG.ignores)
},
suppressions: {
descriptionText: getMessage('ConfigFieldDescription_suppressions'),
valueType: 'object',
defaultValue: { disable_suppressions: false },
wasSuppliedByUser: !deepEquals(this.config.suppressions, DEFAULT_CONFIG.suppressions)
}
}
};
Expand Down Expand Up @@ -260,6 +266,13 @@ export class CodeAnalyzerConfig {
return !this.config.suppressions.disable_suppressions;
}

/**
* Returns the suppressions configuration object.
*/
public getSuppressions(): Suppressions {
return this.config.suppressions;
}

/**
* Returns the absolute path folder where all path based values within the configuration may be relative to.
* Typically, this is set as the folder where a configuration file was loaded from, but doesn't have to be.
Expand Down
3 changes: 2 additions & 1 deletion packages/code-analyzer-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export type {
EngineOverrides,
Ignores,
RuleOverrides,
RuleOverride
RuleOverride,
Suppressions
} from "./config"


Expand Down
11 changes: 8 additions & 3 deletions packages/code-analyzer-core/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ const MESSAGE_CATALOG : MessageCatalog = {
` - "**/*.test.js"\n` +
`-------------------------------------------`,

ConfigFieldDescription_suppressions:
`Configuration for inline suppression markers in source code.\n` +
` disable_suppressions: Boolean to disable processing of suppression markers.\n` +
`---- [Example usage]: ---------------------\n` +
`suppressions:\n` +
` disable_suppressions: false\n` +
`-------------------------------------------`,

GenericEngineConfigOverview:
`%s ENGINE CONFIGURATION`,

Expand Down Expand Up @@ -231,9 +239,6 @@ const MESSAGE_CATALOG : MessageCatalog = {
EngineWorkingFolderKeptDueToError:
`Since the engine '%s' emitted an error, the following temporary working folder will not be removed: %s`,

ProcessingInlineSuppressions:
`Processing inline suppressions for %d violation(s).`,

SuppressedViolationsCount:
`%d violation(s) were suppressed by inline suppression markers.`,

Expand Down
3 changes: 2 additions & 1 deletion packages/code-analyzer-core/src/suppressions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export {
export {
processSuppressions,
isTextFile,
extractSuppressionsFromFiles
extractSuppressionsFromFiles,
filterSuppressedViolations
} from './suppression-processor';

export type { LoggerCallback } from './suppression-processor';
Loading
Loading