diff --git a/packages/code-analyzer-core/src/code-analyzer.ts b/packages/code-analyzer-core/src/code-analyzer.ts index 3c592284..c94593d4 100644 --- a/packages/code-analyzer-core/src/code-analyzer.ts +++ b/packages/code-analyzer-core/src/code-analyzer.ts @@ -10,6 +10,7 @@ import { Violation } from "./results" import {processSuppressions, extractSuppressionsFromFiles, SuppressionsMap, LoggerCallback} from "./suppressions" +import {applyBulkSuppressions, BulkSuppressionQuotas} from "./suppressions/bulk-suppression-processor" import {SemVer} from 'semver'; import { EngineLogEvent, @@ -48,6 +49,14 @@ export interface Workspace { */ getWorkspaceId(): string + /** + * Returns the longest root folder that contains all the workspace paths or null if one does not exist. + * For example, if the workspace was constructed with "/some/folder/subFolder/file1.txt" and + * "/some/folder/file2.txt", then the workspace root folder would be equal to "/some/folder". + * Returns null if a root folder does not exist (e.g., paths from different drives). + */ + getWorkspaceRoot(): string | null + /** * Returns the unique list of files and folders that were used to construct the workspace. */ @@ -123,8 +132,13 @@ export class CodeAnalyzer { // Caching for per-engine suppression processing to avoid duplicate file processing private readonly suppressionsMap: SuppressionsMap = new Map(); private readonly fileProcessingPromises: Map> = new Map(); - // Track total suppressed violations for aggregate logging - private totalSuppressedViolations: number = 0; + // Track suppressed violations for aggregate logging (separated by type) + private totalInlineSuppressedViolations: number = 0; + private totalBulkSuppressedViolations: number = 0; + // Bulk suppression quota tracking (shared across files, scoped to config paths) + private readonly bulkSuppressionQuotas: BulkSuppressionQuotas = new Map(); + // Current workspace root (set during run, used for bulk suppression path resolution) + private currentWorkspaceRoot: string | null = null; constructor(config: CodeAnalyzerConfig, fileSystem: FileSystem = new RealFileSystem(), nodeVersion: string = process.version) { this.validateEnvironment(nodeVersion); @@ -353,14 +367,20 @@ 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; + // Reset suppression counters for this run + this.totalInlineSuppressedViolations = 0; + this.totalBulkSuppressedViolations = 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.bulkSuppressionQuotas.clear(); + + // Store workspace root for bulk suppression path resolution (consistent with ignores feature) + // Falls back to config root if workspace root is null (e.g., files from different drives) + this.currentWorkspaceRoot = runOptions.workspace.getWorkspaceRoot() || this.config.getConfigRoot(); this.emitLogEvent(LogLevel.Debug, getMessage('RunningWithWorkspace', JSON.stringify({ filesAndFolders: runOptions.workspace.getRawFilesAndFolders(), @@ -408,12 +428,16 @@ export class CodeAnalyzer { for (const [uninstantiableEngine, error] of this.uninstantiableEnginesMap.entries()) { runResults.addEngineRunResults(new UninstantiableEngineRunResults(uninstantiableEngine, error)); } - - // 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() && this.totalSuppressedViolations > 0) { - this.emitLogEvent(LogLevel.Info, getMessage('SuppressedViolationsCount', this.totalSuppressedViolations)); + if (!this.config.getSuppressionsEnabled()) { + return runResults; + } + // Note: Inline and bulk suppressions are now applied per-engine in runEngineAndValidateResults() before EngineResultsEvent is emitted + // Log aggregate suppression counts if any violations were suppressed (separate messages for inline vs bulk) + if (this.totalInlineSuppressedViolations > 0) { + this.emitLogEvent(LogLevel.Info, getMessage('InlineSuppressedViolationsCount', this.totalInlineSuppressedViolations)); + } + if (this.totalBulkSuppressedViolations > 0) { + this.emitLogEvent(LogLevel.Info, getMessage('BulkSuppressedViolationsCount', this.totalBulkSuppressedViolations)); } return runResults; @@ -473,8 +497,8 @@ export class CodeAnalyzer { return engineRunResults; } - // Track suppressed violations for aggregate logging - this.totalSuppressedViolations += suppressedCount; + // Track inline suppressed violations for aggregate logging + this.totalInlineSuppressedViolations += suppressedCount; // Return filtered results using FilteredEngineRunResults wrapper return this.createFilteredEngineRunResults(engineRunResults, filteredViolations); @@ -500,6 +524,55 @@ export class CodeAnalyzer { }; } + /** + * Applies bulk suppression filtering to a single engine's results + * This processes bulk suppression rules from config and returns a filtered version of the engine results + * @param engineRunResults The engine run results to apply bulk suppressions to + * @returns Filtered engine run results with bulk suppressions applied + */ + private applyBulkSuppressionsToEngineResults( + engineRunResults: EngineRunResults + ): EngineRunResults { + // Check if suppressions are enabled + if (!this.config.getSuppressionsEnabled()) { + return engineRunResults; + } + + const violations = engineRunResults.getViolations(); + if (violations.length === 0) { + return engineRunResults; + } + + const bulkConfig = this.config.getBulkSuppressions(); + if (Object.keys(bulkConfig).length === 0) { + return engineRunResults; // No bulk suppressions configured + } + + // Use workspace root for path resolution (consistent with ignores feature) + // This is set during run() and should never be null at this point + const workspaceRoot = this.currentWorkspaceRoot || this.config.getConfigRoot(); + + const bulkResult = applyBulkSuppressions( + violations, + bulkConfig, + this.bulkSuppressionQuotas, + workspaceRoot + ); + + const suppressedCount = bulkResult.suppressedCount; + + // If nothing was suppressed, return original results + if (suppressedCount === 0) { + return engineRunResults; + } + + // Track bulk suppressed violations for aggregate logging + this.totalBulkSuppressedViolations += suppressedCount; + + // Return filtered results + return this.createFilteredEngineRunResults(engineRunResults, bulkResult.unsuppressedViolations); + } + /** * Processes files for suppression markers with race condition handling * Uses caching to avoid processing the same file multiple times when multiple engines @@ -672,6 +745,9 @@ export class CodeAnalyzer { // Apply inline suppressions per-engine BEFORE emitting EngineResultsEvent engineRunResults = await this.applyInlineSuppressionsToEngineResults(engineRunResults); + // Apply bulk suppressions per-engine AFTER inline suppressions, still BEFORE emitting EngineResultsEvent + engineRunResults = this.applyBulkSuppressionsToEngineResults(engineRunResults); + this.emitEvent({ type: EventType.EngineRunProgressEvent, timestamp: this.clock.now(), engineName: engineName, percentComplete: 100 }); @@ -845,6 +921,10 @@ class WorkspaceImpl implements Workspace { return this.delegate.getWorkspaceId(); } + getWorkspaceRoot(): string | null { + return this.delegate.getWorkspaceRoot(); + } + getRawFilesAndFolders(): string[] { return this.delegate.getRawFilesAndFolders(); } diff --git a/packages/code-analyzer-core/src/config.ts b/packages/code-analyzer-core/src/config.ts index f67cb814..fc6eb5e3 100644 --- a/packages/code-analyzer-core/src/config.ts +++ b/packages/code-analyzer-core/src/config.ts @@ -50,11 +50,21 @@ export type Ignores = { files: string[] } +/** + * Rule for bulk suppression of violations + */ +export type BulkSuppressionRule = { + rule_selector: string; + max_suppressed_violations?: number | null; + reason?: string; +}; + /** * Object containing the user specified suppressions configuration */ export type Suppressions = { - disable_suppressions: boolean + disable_suppressions: boolean; + bulk_suppressions?: Record; } type TopLevelConfig = { @@ -75,7 +85,7 @@ export const DEFAULT_CONFIG: TopLevelConfig = { config_root: process.cwd(), log_folder: os.tmpdir(), log_level: LogLevel.Debug, - suppressions: { disable_suppressions: false }, // Suppressions enabled by default + suppressions: { disable_suppressions: false }, // Suppressions enabled by default, no bulk suppressions by default rules: {}, engines: {}, ignores: { files: [] }, @@ -273,6 +283,13 @@ export class CodeAnalyzerConfig { return this.config.suppressions; } + /** + * Returns the bulk suppressions configuration (file paths mapped to suppression rules). + */ + public getBulkSuppressions(): Record { + return this.config.suppressions.bulk_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. @@ -306,6 +323,15 @@ export class CodeAnalyzerConfig { return this.config.root_working_folder; } + /** + * Returns the names of engines that have at least one rule override in the configuration. + * Used when writing config output to preserve rule overrides (e.g. disabled rules) for engines + * that may have no selected rules. + */ + public getEngineNamesWithRuleOverrides(): string[] { + return Object.keys(this.config.rules); + } + /** * Returns a {@link RuleOverrides} instance containing the user specified overrides for all rules associated with the specified engine * @param engineName name of the engine @@ -394,9 +420,66 @@ function extractIgnoresValue(configExtractor: engApi.ConfigValueExtractor): Igno function extractSuppressionsValue(configExtractor: engApi.ConfigValueExtractor): Suppressions { const suppressionsExtractor: engApi.ConfigValueExtractor = configExtractor.extractObjectAsExtractor(FIELDS.SUPPRESSIONS, DEFAULT_CONFIG.suppressions); - suppressionsExtractor.validateContainsOnlySpecifiedKeys([FIELDS.DISABLE_SUPPRESSIONS]); + const disable_suppressions: boolean = suppressionsExtractor.extractBoolean(FIELDS.DISABLE_SUPPRESSIONS, DEFAULT_CONFIG.suppressions.disable_suppressions) || false; - return { disable_suppressions }; + + // Extract bulk suppressions - all keys except 'disable_suppressions' and 'bulk_suppressions' are file/folder paths + const bulk_suppressions: Record = {}; + const suppressionKeys = suppressionsExtractor.getKeys(); + + for (const key of suppressionKeys) { + if (key === FIELDS.DISABLE_SUPPRESSIONS || key === 'bulk_suppressions') { + continue; // Skip the disable_suppressions flag and bulk_suppressions placeholder from default config + } + + // key is a file/folder path, value should be an array of suppression rules + const rulesArray = suppressionsExtractor.extractArray( + key, + (value, fieldPath) => validateBulkSuppressionRule(value, fieldPath) + ); + + if (rulesArray && rulesArray.length > 0) { + bulk_suppressions[key] = rulesArray; + } + } + + return { disable_suppressions, bulk_suppressions }; +} + +function validateBulkSuppressionRule(value: unknown, fieldPath: string): BulkSuppressionRule { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(getMessage('InvalidBulkSuppressionRule', fieldPath, 'Expected an object')); + } + + const rule = value as Record; + + // rule_selector is required + if (!rule.rule_selector || typeof rule.rule_selector !== 'string') { + throw new Error(getMessage('InvalidBulkSuppressionRule', fieldPath, 'rule_selector is required and must be a string')); + } + + // max_suppressed_violations is optional, can be number or null + if (rule.max_suppressed_violations !== undefined && + rule.max_suppressed_violations !== null && + typeof rule.max_suppressed_violations !== 'number') { + throw new Error(getMessage('InvalidBulkSuppressionRule', fieldPath, 'max_suppressed_violations must be a number or null')); + } + + // max_suppressed_violations must be non-negative if specified + if (typeof rule.max_suppressed_violations === 'number' && rule.max_suppressed_violations < 0) { + throw new Error(getMessage('InvalidBulkSuppressionRule', fieldPath, 'max_suppressed_violations must be a non-negative number')); + } + + // reason is optional + if (rule.reason !== undefined && typeof rule.reason !== 'string') { + throw new Error(getMessage('InvalidBulkSuppressionRule', fieldPath, 'reason must be a string')); + } + + return { + rule_selector: rule.rule_selector as string, + max_suppressed_violations: rule.max_suppressed_violations as number | null | undefined, + reason: rule.reason as string | undefined + }; } /** diff --git a/packages/code-analyzer-core/src/messages.ts b/packages/code-analyzer-core/src/messages.ts index dc97ca57..baeccf6d 100644 --- a/packages/code-analyzer-core/src/messages.ts +++ b/packages/code-analyzer-core/src/messages.ts @@ -152,6 +152,9 @@ const MESSAGE_CATALOG : MessageCatalog = { InvalidGlobPattern: `The configuration field '%s' contains an invalid glob pattern '%s': %s`, + InvalidBulkSuppressionRule: + `The bulk suppression rule at '%s' is invalid: %s`, + RulePropertyOverridden: `The %s value of rule '%s' of engine '%s' was overridden according to the specified configuration. The old value '%s' was replaced with the new value '%s'.`, @@ -240,7 +243,13 @@ const MESSAGE_CATALOG : MessageCatalog = { `Since the engine '%s' emitted an error, the following temporary working folder will not be removed: %s`, SuppressedViolationsCount: - `%d violation(s) were suppressed by inline suppression markers.` + `%d violation(s) were suppressed by inline suppression markers.`, + + InlineSuppressedViolationsCount: + `%d violation(s) were suppressed by inline suppression markers.`, + + BulkSuppressedViolationsCount: + `%d violation(s) were suppressed by bulk suppressions.` } /** diff --git a/packages/code-analyzer-core/src/suppressions/bulk-suppression-processor.ts b/packages/code-analyzer-core/src/suppressions/bulk-suppression-processor.ts new file mode 100644 index 00000000..eaacb60c --- /dev/null +++ b/packages/code-analyzer-core/src/suppressions/bulk-suppression-processor.ts @@ -0,0 +1,246 @@ +import {Violation} from "../results"; +import {BulkSuppressionRule} from "../config"; +import {toSelector, Selector} from "../selectors"; +import {SeverityLevel} from "../rules"; +import * as path from 'node:path'; + +/** + * Tracks the number of suppressions applied per config path and rule selector combination + * Key format: "configPath|ruleSelector" - shared across all files matching the config path + */ +export type BulkSuppressionQuotas = Map; // key: "configPath|ruleSelector" + +/** + * Bulk suppression rule paired with its config path (for quota tracking) + */ +type RuleWithConfigPath = { + rule: BulkSuppressionRule; + configPath: string; // The path from config that matched (e.g., "src/" or "src/file.js") + ruleIndex: number; // Index in the rules array for unique quota tracking +}; + +/** + * Result of applying bulk suppressions to a list of violations + */ +export type BulkSuppressionResult = { + unsuppressedViolations: Violation[]; + suppressedCount: number; +}; + +/** + * Applies bulk suppressions to violations based on config, updating quota tracking + * + * IMPORTANT: This function mutates the quotas Map as a side effect. The Map tracks + * suppression counts across multiple calls and should be shared across all engines + * in a single analysis run to maintain accurate quota limits. + * + * @param violations List of violations to process + * @param bulkConfig Bulk suppression configuration from YAML + * @param quotas Shared quota tracker (MUTATED by this function - maintains state across calls) + * @param workspaceRoot Root directory for resolving relative paths + * @returns Object containing unsuppressed violations and count of suppressions applied + */ +export function applyBulkSuppressions( + violations: Violation[], + bulkConfig: Record, + quotas: BulkSuppressionQuotas, + workspaceRoot: string +): BulkSuppressionResult { + if (Object.keys(bulkConfig).length === 0) { + return { unsuppressedViolations: violations, suppressedCount: 0 }; + } + + // Sort violations for deterministic processing within this engine + const sortedViolations = [...violations].sort((a, b) => { + const fileA = a.getPrimaryLocation().getFile() || ''; + const fileB = b.getPrimaryLocation().getFile() || ''; + if (fileA !== fileB) { + return fileA.localeCompare(fileB); + } + + const lineA = a.getPrimaryLocation().getStartLine() || 0; + const lineB = b.getPrimaryLocation().getStartLine() || 0; + return lineA - lineB; + }); + + const unsuppressedViolations: Violation[] = []; + let suppressedCount = 0; + + for (const violation of sortedViolations) { + const violationFile = violation.getPrimaryLocation().getFile(); + if (!violationFile) { + // Skip violations id no file path is present + unsuppressedViolations.push(violation); + continue; + } + + // Find matching suppression rules for this violation's file + const matchingRules = findMatchingBulkSuppressionRules(violationFile, bulkConfig, workspaceRoot); + + let suppressed = false; + for (const ruleWithPath of matchingRules) { + if (shouldSuppressViolation(violation, ruleWithPath.rule, quotas, ruleWithPath.configPath, ruleWithPath.ruleIndex)) { + suppressed = true; + suppressedCount++; + break; // Violation suppressed, move to next + } + } + + if (!suppressed) { + unsuppressedViolations.push(violation); + } + } + + + return { unsuppressedViolations, suppressedCount }; +} + +/** + * Finds bulk suppression rules that match the given file path + * Returns rules paired with their config paths for shared quota tracking + * @param violationFile Absolute file path from violation + * @param bulkConfig Bulk suppression configuration + * @param workspaceRoot Root directory for resolving relative paths + * @param logger Optional logger callback for debug messages + * @returns Array of rules with their matching config paths + */ +function findMatchingBulkSuppressionRules( + violationFile: string, + bulkConfig: Record, + workspaceRoot: string +): RuleWithConfigPath[] { + const matchingRules: RuleWithConfigPath[] = []; + + for (const [configPath, rules] of Object.entries(bulkConfig)) { + const matches = doesFileMatchConfigPath(violationFile, configPath, workspaceRoot); + + if (matches) { + // Add each rule with its config path and index for unique quota tracking + for (let i = 0; i < rules.length; i++) { + matchingRules.push({ rule: rules[i], configPath, ruleIndex: i }); + } + } + } + + return matchingRules; +} + +/** + * Checks if a file path matches a config path (file or folder) + * Uses Node's path module for proper path operations, then normalizes separators for comparison. + * This follows the same pattern as the ignores feature in workspace.ts for cross-platform compatibility. + * + * @param violationFile Absolute file path from violation + * @param configPath Relative or absolute path from config (file or folder) + * @param workspaceRoot Root directory for resolving relative paths + * @returns true if the file matches + */ +function doesFileMatchConfigPath( + violationFile: string, + configPath: string, + workspaceRoot: string +): boolean { + // Config paths can be relative (recommended, like .gitignore) or absolute + // If relative, resolve against workspace root + // If absolute, use as-is + const absoluteConfigPath = path.resolve(workspaceRoot, configPath); + + // Normalize paths for consistent comparison + const normalizedViolationFile = path.normalize(violationFile); + const normalizedConfigPath = path.normalize(absoluteConfigPath); + + // Normalize to POSIX separators for cross-platform comparison + // Convert all backslashes to forward slashes for consistent comparison + const comparisonViolationFile = normalizedViolationFile.replace(/\\/g, '/'); + const comparisonConfigPath = normalizedConfigPath.replace(/\\/g, '/'); + + // Check if it's an exact file match + if (comparisonViolationFile === comparisonConfigPath) { + return true; + } + + // Check if violation file is within config folder + // Add separator to ensure we match whole directory names + // e.g., "src/utils" should match "src/utils/file.js" but not "src/utils2/file.js" + const configPathWithSep = comparisonConfigPath.endsWith('/') + ? comparisonConfigPath + : comparisonConfigPath + '/'; + + return comparisonViolationFile.startsWith(configPathWithSep); +} + +/** + * Determines if a violation should be suppressed based on a bulk suppression rule + * Updates the quota tracker if suppression is applied + * Uses shared quota across all files matching the config path, but each array entry gets independent quota + * @param violation The violation to check + * @param rule The bulk suppression rule to apply + * @param quotas Quota tracker (mutated if suppressed) - shared across config path + * @param configPath Config path from YAML (e.g., "src/" or "src/file.js") for quota tracking + * @param ruleIndex Index of rule in the array (for unique quota when duplicates exist) + * @returns true if violation should be suppressed + */ +function shouldSuppressViolation( + violation: Violation, + rule: BulkSuppressionRule, + quotas: BulkSuppressionQuotas, + configPath: string, + ruleIndex: number +): boolean { + // Check if the rule selector matches this violation + if (!ruleMatches(violation, rule.rule_selector)) { + return false; + } + + // Quota key includes rule index to give each array entry independent quota + // This allows duplicate rules to each have their own quota allocation + const quotaKey = `${configPath}|${ruleIndex}|${rule.rule_selector}`; + const currentCount = quotas.get(quotaKey) || 0; + const maxLimit = rule.max_suppressed_violations; + + // If maxLimit is null/undefined, suppress all matching violations + if (maxLimit === null || maxLimit === undefined) { + quotas.set(quotaKey, currentCount + 1); + return true; + } + + // Check if we've reached the quota limit (shared across all files in config path) + if (currentCount < maxLimit) { + quotas.set(quotaKey, currentCount + 1); + return true; + } + + // Quota exceeded, don't suppress + return false; +} + +/** + * Checks if a rule selector matches a violation + * Uses the same rule selector matching logic as rule selection + * @param violation The violation to check + * @param ruleSelector The rule selector string (e.g., "pmd:UnusedMethod", "3,4", etc.) + * @returns true if the violation matches the selector + */ +function ruleMatches(violation: Violation, ruleSelector: string): boolean { + try { + const selector: Selector = toSelector(ruleSelector); + const rule = violation.getRule(); + + // Build selectables array from rule (same as Rule.matchesRuleSelector) + const sevNumber: number = rule.getSeverityLevel().valueOf(); + const sevName: string = SeverityLevel[sevNumber]; + const selectables: string[] = [ + "all", + rule.getEngineName().toLowerCase(), + rule.getName().toLowerCase(), + sevName.toLowerCase(), + String(sevNumber), + ...rule.getTags().map(t => t.toLowerCase()) + ]; + + return selector.matchesSelectables(selectables); + } catch (_error) { + // If selector is invalid, don't match + return false; + } +} diff --git a/packages/code-analyzer-core/src/suppressions/index.ts b/packages/code-analyzer-core/src/suppressions/index.ts index fee3ef4f..5560b985 100644 --- a/packages/code-analyzer-core/src/suppressions/index.ts +++ b/packages/code-analyzer-core/src/suppressions/index.ts @@ -23,3 +23,12 @@ export { } from './suppression-processor'; export type { LoggerCallback } from './suppression-processor'; + +export { + applyBulkSuppressions +} from './bulk-suppression-processor'; + +export type { + BulkSuppressionQuotas, + BulkSuppressionResult +} from './bulk-suppression-processor'; diff --git a/packages/code-analyzer-core/test/bulk-suppressions-e2e.test.ts b/packages/code-analyzer-core/test/bulk-suppressions-e2e.test.ts new file mode 100644 index 00000000..73cd2806 --- /dev/null +++ b/packages/code-analyzer-core/test/bulk-suppressions-e2e.test.ts @@ -0,0 +1,290 @@ +/** + * End-to-end integration tests for bulk suppressions with actual engine execution + * These tests run real stub engines to verify bulk suppressions work in the full flow + * with YAML config files and test workspaces + */ + +import * as path from 'node:path'; +import { CodeAnalyzer } from '../src/code-analyzer'; +import { CodeAnalyzerConfig } from '../src/config'; +import * as stubs from './stubs'; +import { changeWorkingDirectoryToPackageRoot, FakeFileSystem } from './test-helpers'; + +changeWorkingDirectoryToPackageRoot(); + +describe('Bulk Suppressions E2E Tests (with YAML config and test workspace)', () => { + const testWorkspaceDir = path.resolve(__dirname, 'test-data', 'bulk-suppressions-workspace'); + const configFile = path.join(testWorkspaceDir, 'code-analyzer.yml'); + + describe('file-level bulk suppressions', () => { + it('should suppress violations up to quota limit for specific file', async () => { + // Config has: file1.js with max_suppressed_violations: 2 + // We'll create 3 violations in file1.js + // Expected: 2 suppressed, 1 unsuppressed + + const config = await CodeAnalyzerConfig.fromFile(configFile); + const codeAnalyzer = new CodeAnalyzer(config, new FakeFileSystem()); + + const plugin = new stubs.StubEnginePlugin(); + await codeAnalyzer.addEnginePlugin(plugin); + + const file1Path = path.join(testWorkspaceDir, 'file1.js'); + const workspace = await codeAnalyzer.createWorkspace([file1Path]); + const ruleSelection = await codeAnalyzer.selectRules(['stubEngine1'], { workspace }); + + // Create 3 violations in file1.js + const stubEngine1 = plugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1; + stubEngine1.resultsToReturn = { + violations: [ + { + ruleName: 'stub1RuleA', + message: 'Violation 1', + codeLocations: [{ file: file1Path, startLine: 3, startColumn: 1 }], + primaryLocationIndex: 0 + }, + { + ruleName: 'stub1RuleA', + message: 'Violation 2', + codeLocations: [{ file: file1Path, startLine: 6, startColumn: 1 }], + primaryLocationIndex: 0 + }, + { + ruleName: 'stub1RuleA', + message: 'Violation 3', + codeLocations: [{ file: file1Path, startLine: 10, startColumn: 1 }], + primaryLocationIndex: 0 + } + ] + }; + + const results = await codeAnalyzer.run(ruleSelection, { workspace }); + const violations = results.getViolations(); + + // Only 1 violation should remain (quota was 2) + expect(violations.length).toBe(1); + expect(violations[0].getPrimaryLocation().getStartLine()).toBe(10); + }); + + it('should suppress all violations with null quota limit', async () => { + // Config has: file2.js with max_suppressed_violations: null (unlimited) + // Expected: all violations suppressed + + const config = await CodeAnalyzerConfig.fromFile(configFile); + const codeAnalyzer = new CodeAnalyzer(config, new FakeFileSystem()); + + const plugin = new stubs.StubEnginePlugin(); + await codeAnalyzer.addEnginePlugin(plugin); + + const file2Path = path.join(testWorkspaceDir, 'file2.js'); + const workspace = await codeAnalyzer.createWorkspace([file2Path]); + const ruleSelection = await codeAnalyzer.selectRules(['stubEngine1'], { workspace }); + + const stubEngine1 = plugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1; + stubEngine1.resultsToReturn = { + violations: [ + { + ruleName: 'stub1RuleA', + message: 'Violation 1', + codeLocations: [{ file: file2Path, startLine: 3, startColumn: 1 }], + primaryLocationIndex: 0 + } + ] + }; + + const results = await codeAnalyzer.run(ruleSelection, { workspace }); + const violations = results.getViolations(); + + // All violations should be suppressed + expect(violations.length).toBe(0); + }); + }); + + describe('folder-level bulk suppressions with shared quota', () => { + it('should share quota across all files in folder', async () => { + // Config has: src/controllers/ with max_suppressed_violations: 2 (shared) + // We have controller1.js with 2 violations and controller2.js with 1 violation + // Expected: First 2 violations suppressed (across files), 1 remains + + const config = await CodeAnalyzerConfig.fromFile(configFile); + const codeAnalyzer = new CodeAnalyzer(config, new FakeFileSystem()); + + const plugin = new stubs.StubEnginePlugin(); + await codeAnalyzer.addEnginePlugin(plugin); + + const controller1Path = path.join(testWorkspaceDir, 'src', 'controllers', 'controller1.js'); + const controller2Path = path.join(testWorkspaceDir, 'src', 'controllers', 'controller2.js'); + // Pass testWorkspaceDir as workspace folder to set correct workspace root + const workspace = await codeAnalyzer.createWorkspace([testWorkspaceDir], [controller1Path, controller2Path]); + const ruleSelection = await codeAnalyzer.selectRules(['stubEngine1'], { workspace }); + + const stubEngine1 = plugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1; + stubEngine1.resultsToReturn = { + violations: [ + { + ruleName: 'stub1RuleA', + message: 'Violation in controller1 line 3', + codeLocations: [{ file: controller1Path, startLine: 3, startColumn: 1 }], + primaryLocationIndex: 0 + }, + { + ruleName: 'stub1RuleA', + message: 'Violation in controller1 line 6', + codeLocations: [{ file: controller1Path, startLine: 6, startColumn: 1 }], + primaryLocationIndex: 0 + }, + { + ruleName: 'stub1RuleA', + message: 'Violation in controller2 line 3', + codeLocations: [{ file: controller2Path, startLine: 3, startColumn: 1 }], + primaryLocationIndex: 0 + } + ] + }; + + const results = await codeAnalyzer.run(ruleSelection, { workspace }); + const violations = results.getViolations(); + + // Only 1 violation should remain (quota of 2 shared across folder) + expect(violations.length).toBe(1); + expect(violations[0].getPrimaryLocation().getFile()).toBe(controller2Path); + expect(violations[0].getPrimaryLocation().getStartLine()).toBe(3); + }); + }); + + describe('multiple rules with duplicate selectors', () => { + it('should give each array entry independent quota', async () => { + // Create a custom config with duplicate selectors + const customConfig = CodeAnalyzerConfig.fromObject({ + suppressions: { + disable_suppressions: false, + 'file1.js': [ + { rule_selector: 'all', max_suppressed_violations: 2 }, + { rule_selector: 'all', max_suppressed_violations: 1 } // Duplicate + ] + }, + log_level: 'error' + }); + + const codeAnalyzer = new CodeAnalyzer(customConfig, new FakeFileSystem()); + const plugin = new stubs.StubEnginePlugin(); + await codeAnalyzer.addEnginePlugin(plugin); + + const file1Path = path.join(testWorkspaceDir, 'file1.js'); + const workspace = await codeAnalyzer.createWorkspace([file1Path]); + const ruleSelection = await codeAnalyzer.selectRules(['stubEngine1'], { workspace }); + + // Create 4 violations + const stubEngine1 = plugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1; + stubEngine1.resultsToReturn = { + violations: [ + { + ruleName: 'stub1RuleA', + message: 'Violation 1', + codeLocations: [{ file: file1Path, startLine: 3, startColumn: 1 }], + primaryLocationIndex: 0 + }, + { + ruleName: 'stub1RuleA', + message: 'Violation 2', + codeLocations: [{ file: file1Path, startLine: 6, startColumn: 1 }], + primaryLocationIndex: 0 + }, + { + ruleName: 'stub1RuleA', + message: 'Violation 3', + codeLocations: [{ file: file1Path, startLine: 10, startColumn: 1 }], + primaryLocationIndex: 0 + }, + { + ruleName: 'stub1RuleA', + message: 'Violation 4', + codeLocations: [{ file: file1Path, startLine: 11, startColumn: 1 }], + primaryLocationIndex: 0 + } + ] + }; + + const results = await codeAnalyzer.run(ruleSelection, { workspace }); + const violations = results.getViolations(); + + // Should suppress 3 total (2 + 1), leaving 1 unsuppressed + expect(violations.length).toBe(1); + expect(violations[0].getPrimaryLocation().getStartLine()).toBe(11); + }); + }); + + describe('workspace root path resolution', () => { + it('should resolve paths relative to workspace root, not config file location', async () => { + // This test verifies Bug #2 fix: paths in config are relative to workspace root + // Config is in testWorkspaceDir but paths should be relative to workspace root + + const config = await CodeAnalyzerConfig.fromFile(configFile); + const codeAnalyzer = new CodeAnalyzer(config, new FakeFileSystem()); + + const plugin = new stubs.StubEnginePlugin(); + await codeAnalyzer.addEnginePlugin(plugin); + + const file1Path = path.join(testWorkspaceDir, 'file1.js'); + const workspace = await codeAnalyzer.createWorkspace([file1Path]); + const ruleSelection = await codeAnalyzer.selectRules(['stubEngine1'], { workspace }); + + const stubEngine1 = plugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1; + stubEngine1.resultsToReturn = { + violations: [ + { + ruleName: 'stub1RuleA', + message: 'Violation', + codeLocations: [{ file: file1Path, startLine: 3, startColumn: 1 }], + primaryLocationIndex: 0 + } + ] + }; + + const results = await codeAnalyzer.run(ruleSelection, { workspace }); + const violations = results.getViolations(); + + // Should be suppressed because config path "file1.js" is relative to workspace root + expect(violations.length).toBe(0); + }); + }); + + describe('with suppressions disabled', () => { + it('should not suppress any violations when disable_suppressions is true', async () => { + const config = CodeAnalyzerConfig.fromObject({ + suppressions: { + disable_suppressions: true, + 'file1.js': [ + { rule_selector: 'all', max_suppressed_violations: null } + ] + }, + log_level: 'error' + }); + + const codeAnalyzer = new CodeAnalyzer(config, new FakeFileSystem()); + const plugin = new stubs.StubEnginePlugin(); + await codeAnalyzer.addEnginePlugin(plugin); + + const file1Path = path.join(testWorkspaceDir, 'file1.js'); + const workspace = await codeAnalyzer.createWorkspace([file1Path]); + const ruleSelection = await codeAnalyzer.selectRules(['stubEngine1'], { workspace }); + + const stubEngine1 = plugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1; + stubEngine1.resultsToReturn = { + violations: [ + { + ruleName: 'stub1RuleA', + message: 'Violation', + codeLocations: [{ file: file1Path, startLine: 3, startColumn: 1 }], + primaryLocationIndex: 0 + } + ] + }; + + const results = await codeAnalyzer.run(ruleSelection, { workspace }); + const violations = results.getViolations(); + + // Should NOT be suppressed (suppressions disabled) + expect(violations.length).toBe(1); + }); + }); +}); diff --git a/packages/code-analyzer-core/test/config.test.ts b/packages/code-analyzer-core/test/config.test.ts index cee2ba8e..3dfa8044 100644 --- a/packages/code-analyzer-core/test/config.test.ts +++ b/packages/code-analyzer-core/test/config.test.ts @@ -293,6 +293,16 @@ describe("Tests for creating and accessing configuration values", () => { }); }); + it("When config has rule overrides, getEngineNamesWithRuleOverrides returns engine names that have at least one rule override", () => { + const conf: CodeAnalyzerConfig = CodeAnalyzerConfig.fromFile(path.join(TEST_DATA_DIR, 'sample-config-with-disabled-rule.yaml')); + expect(conf.getEngineNamesWithRuleOverrides()).toEqual(['stubEngine1', 'stubEngine2']); + }); + + it("When config has no rule overrides, getEngineNamesWithRuleOverrides returns empty array", () => { + const conf: CodeAnalyzerConfig = CodeAnalyzerConfig.withDefaults(); + expect(conf.getEngineNamesWithRuleOverrides()).toEqual([]); + }); + it("When log_folder does not exist, then throw an error", () => { const nonExistingFolder: string = path.resolve(__dirname, "doesNotExist"); expect(() => CodeAnalyzerConfig.fromObject({log_folder: nonExistingFolder})).toThrow( @@ -632,3 +642,140 @@ describe("Tests for glob pattern validation in ignores", () => { })).toThrow(getMessage('InvalidGlobPattern', 'ignores.files[2]', '**/invalid{pattern', 'unclosed brace {')); }); }); + +describe("Tests for bulk suppressions configuration", () => { + it("When suppressions.disable_suppressions is set correctly, config is valid", () => { + const conf: CodeAnalyzerConfig = CodeAnalyzerConfig.fromYamlString(` +suppressions: + disable_suppressions: true +`); + expect(conf.getSuppressions().disable_suppressions).toEqual(true); + }); + + it("When bulk suppressions are configured with valid values, config is valid", () => { + const conf: CodeAnalyzerConfig = CodeAnalyzerConfig.fromYamlString(` +suppressions: + disable_suppressions: false + "src/file.js": + - rule_selector: "eslint:no-console" + max_suppressed_violations: 5 + reason: "Legacy code" +`); + expect(conf.getBulkSuppressions()).toEqual({ + "src/file.js": [{ + rule_selector: "eslint:no-console", + max_suppressed_violations: 5, + reason: "Legacy code" + }] + }); + }); + + it("When max_suppressed_violations is null, config is valid", () => { + const conf: CodeAnalyzerConfig = CodeAnalyzerConfig.fromYamlString(` +suppressions: + "src/file.js": + - rule_selector: "eslint:no-console" + max_suppressed_violations: null +`); + expect(conf.getBulkSuppressions()["src/file.js"][0].max_suppressed_violations).toBeNull(); + }); + + it("When max_suppressed_violations is 0, config is valid", () => { + const conf: CodeAnalyzerConfig = CodeAnalyzerConfig.fromYamlString(` +suppressions: + "src/file.js": + - rule_selector: "eslint:no-console" + max_suppressed_violations: 0 +`); + expect(conf.getBulkSuppressions()["src/file.js"][0].max_suppressed_violations).toEqual(0); + }); + + it("When max_suppressed_violations is negative, then we throw an error", () => { + expect(() => CodeAnalyzerConfig.fromYamlString(` +suppressions: + "src/file.js": + - rule_selector: "eslint:no-console" + max_suppressed_violations: -1 +`)).toThrow('max_suppressed_violations must be a non-negative number'); + }); + + it("When max_suppressed_violations is negative decimal, then we throw an error", () => { + expect(() => CodeAnalyzerConfig.fromYamlString(` +suppressions: + "src/utils/": + - rule_selector: "pmd:UnusedMethod" + max_suppressed_violations: -5.5 +`)).toThrow('max_suppressed_violations must be a non-negative number'); + }); + + it("When max_suppressed_violations is a large negative number, then we throw an error", () => { + expect(() => CodeAnalyzerConfig.fromObject({ + suppressions: { + "file.js": [{ + rule_selector: "all", + max_suppressed_violations: -9999 + }] + } + })).toThrow('max_suppressed_violations must be a non-negative number'); + }); + + it("When rule_selector is missing, then we throw an error", () => { + expect(() => CodeAnalyzerConfig.fromYamlString(` +suppressions: + "src/file.js": + - max_suppressed_violations: 5 +`)).toThrow('rule_selector is required and must be a string'); + }); + + it("When rule_selector is not a string, then we throw an error", () => { + expect(() => CodeAnalyzerConfig.fromObject({ + suppressions: { + "file.js": [{ + rule_selector: 123, + max_suppressed_violations: 5 + }] + } + })).toThrow('rule_selector is required and must be a string'); + }); + + it("When max_suppressed_violations is not a number or null, then we throw an error", () => { + expect(() => CodeAnalyzerConfig.fromObject({ + suppressions: { + "file.js": [{ + rule_selector: "all", + max_suppressed_violations: "invalid" + }] + } + })).toThrow('max_suppressed_violations must be a number or null'); + }); + + it("When reason is not a string, then we throw an error", () => { + expect(() => CodeAnalyzerConfig.fromObject({ + suppressions: { + "file.js": [{ + rule_selector: "all", + max_suppressed_violations: 5, + reason: 123 + }] + } + })).toThrow('reason must be a string'); + }); + + it("When bulk suppression rule is not an object, then we throw an error", () => { + expect(() => CodeAnalyzerConfig.fromObject({ + suppressions: { + "file.js": ["invalid"] + } + })).toThrow('Expected an object'); + }); + + it("When bulk suppression value for file is not an array, then we throw an error", () => { + expect(() => CodeAnalyzerConfig.fromObject({ + suppressions: { + "file.js": { + rule_selector: "all" + } + } + })).toThrow("must be of type 'array'"); + }); +}); diff --git a/packages/code-analyzer-core/test/stubs.ts b/packages/code-analyzer-core/test/stubs.ts index 0dee059c..52e0296f 100644 --- a/packages/code-analyzer-core/test/stubs.ts +++ b/packages/code-analyzer-core/test/stubs.ts @@ -13,6 +13,10 @@ export class StubWorkspace implements Workspace { return "dummyId"; } + getWorkspaceRoot(): string | null { + return SAMPLE_WORKSPACE_FOLDER; + } + getRawFilesAndFolders(): string[] { return [path.join(SAMPLE_WORKSPACE_FOLDER)]; } diff --git a/packages/code-analyzer-core/test/suppressions/bulk-suppression-processor.test.ts b/packages/code-analyzer-core/test/suppressions/bulk-suppression-processor.test.ts new file mode 100644 index 00000000..7464f108 --- /dev/null +++ b/packages/code-analyzer-core/test/suppressions/bulk-suppression-processor.test.ts @@ -0,0 +1,814 @@ +/** + * Unit tests for bulk-suppression-processor.ts + */ + +import { describe, it, expect } from '@jest/globals'; +import { + applyBulkSuppressions, + BulkSuppressionQuotas +} from '../../src/suppressions/bulk-suppression-processor'; +import { BulkSuppressionRule } from '../../src/config'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +/** + * Helper to create platform-appropriate absolute paths for testing + * On Windows: C:\workspace\... + * On Unix: /workspace/... + */ +function createTestAbsolutePath(...segments: string[]): string { + if (os.platform() === 'win32') { + // Windows: start with C:\ drive + return path.join('C:', path.sep, ...segments); + } else { + // Unix: start with / + return path.join(path.sep, ...segments); + } +} +import { Violation, CodeLocation } from '../../src/results'; +import { Rule, SeverityLevel } from '../../src/rules'; + +// Mock implementations for testing +class MockCodeLocation implements CodeLocation { + constructor( + private file?: string, + private startLine?: number, + private startColumn?: number, + private endLine?: number, + private endColumn?: number + ) {} + + getFile(): string | undefined { + return this.file; + } + + getStartLine(): number | undefined { + return this.startLine; + } + + getStartColumn(): number | undefined { + return this.startColumn; + } + + getEndLine(): number | undefined { + return this.endLine; + } + + getEndColumn(): number | undefined { + return this.endColumn; + } + + getComment(): string | undefined { + return undefined; + } +} + +class MockRule implements Rule { + constructor( + private engineName: string, + private name: string, + private severityLevel: SeverityLevel = SeverityLevel.High, + private tags: string[] = [] + ) {} + + getEngineName(): string { + return this.engineName; + } + + getName(): string { + return this.name; + } + + getSeverityLevel(): SeverityLevel { + return this.severityLevel; + } + + getTags(): string[] { + return this.tags; + } + + getDescription(): string { + return 'Mock rule'; + } + + getResourceUrls(): string[] { + return []; + } +} + +class MockViolation implements Violation { + constructor( + private rule: Rule, + private message: string, + private primaryLocation: CodeLocation + ) {} + + getRule(): Rule { + return this.rule; + } + + getMessage(): string { + return this.message; + } + + getCodeLocations(): CodeLocation[] { + return [this.primaryLocation]; + } + + getPrimaryLocation(): CodeLocation { + return this.primaryLocation; + } + + getPrimaryLocationIndex(): number { + return 0; + } + + getResourceUrls(): string[] { + return []; + } + + getFixes(): any[] { + return []; + } + + getSuggestions(): any[] { + return []; + } +} + +describe('applyBulkSuppressions', () => { + const workspaceRoot = createTestAbsolutePath('workspace'); + + describe('Basic suppression scenarios', () => { + it('should suppress violations matching rule selector with no quota limit', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ), + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 20, 1, 20, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'pmd:UnusedMethod', + max_suppressed_violations: null + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.unsuppressedViolations).toHaveLength(0); + expect(result.suppressedCount).toBe(2); + // Quota key uses config path with rule index + expect(quotas.get('src/file1.apex|0|pmd:UnusedMethod')).toBe(2); + }); + + it('should suppress violations up to quota limit', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ), + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 20, 1, 20, 20) + ), + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 30, 1, 30, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'pmd:UnusedMethod', + max_suppressed_violations: 2 + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.unsuppressedViolations).toHaveLength(1); + expect(result.suppressedCount).toBe(2); + expect(result.unsuppressedViolations[0].getPrimaryLocation().getStartLine()).toBe(30); + // Quota key uses config path with rule index + expect(quotas.get('src/file1.apex|0|pmd:UnusedMethod')).toBe(2); + }); + + it('should not suppress violations when quota is already exhausted', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'pmd:UnusedMethod', + max_suppressed_violations: 1 + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + // Quota key uses config path with rule index + quotas.set('src/file1.apex|0|pmd:UnusedMethod', 1); // Quota already used + + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.unsuppressedViolations).toHaveLength(1); + expect(result.suppressedCount).toBe(0); + expect(quotas.get('src/file1.apex|0|pmd:UnusedMethod')).toBe(1); + }); + + it('should not suppress violations that do not match rule selector', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ), + new MockViolation( + new MockRule('eslint', 'no-unused-vars'), + 'Unused variable', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 20, 1, 20, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'pmd:UnusedMethod', + max_suppressed_violations: null + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.unsuppressedViolations).toHaveLength(1); + expect(result.suppressedCount).toBe(1); + expect(result.unsuppressedViolations[0].getRule().getEngineName()).toBe('eslint'); + }); + }); + + describe('File path matching', () => { + it('should match violations in exact file path', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'pmd', + max_suppressed_violations: null + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.suppressedCount).toBe(1); + }); + + it('should match violations in folder path', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'folder', 'file1.apex'), 10, 1, 10, 20) + ), + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'folder', 'file2.apex'), 20, 1, 20, 20) + ) + ]; + + const bulkConfig = { + 'src/folder': [ + { + rule_selector: 'pmd', + max_suppressed_violations: null + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.suppressedCount).toBe(2); + }); + + it('should not match violations outside the configured path', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'other', 'file1.apex'), 10, 1, 10, 20) + ) + ]; + + const bulkConfig = { + 'src/folder': [ + { + rule_selector: 'pmd', + max_suppressed_violations: null + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.suppressedCount).toBe(0); + expect(result.unsuppressedViolations).toHaveLength(1); + }); + + it('should support absolute paths in config', () => { + // Config paths can be absolute (in addition to relative paths) + // This provides flexibility for users who need to specify exact file locations + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ) + ]; + + // Create config with absolute path key + const absolutePath = path.join(workspaceRoot, 'src', 'file1.apex'); + const bulkConfig = { + [absolutePath]: [ + { + rule_selector: 'pmd', + max_suppressed_violations: null + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + // Should suppress - absolute paths are supported + expect(result.suppressedCount).toBe(1); + expect(result.unsuppressedViolations).toHaveLength(0); + }); + }); + + describe('Rule selector matching', () => { + it('should match violations by engine name', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ), + new MockViolation( + new MockRule('eslint', 'no-unused-vars'), + 'Unused variable', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 20, 1, 20, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'pmd', + max_suppressed_violations: null + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.suppressedCount).toBe(1); + expect(result.unsuppressedViolations).toHaveLength(1); + expect(result.unsuppressedViolations[0].getRule().getEngineName()).toBe('eslint'); + }); + + it('should match violations by engine:rule', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ), + new MockViolation( + new MockRule('pmd', 'UnusedVariable'), + 'Unused variable', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 20, 1, 20, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'pmd:UnusedMethod', + max_suppressed_violations: null + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.suppressedCount).toBe(1); + expect(result.unsuppressedViolations).toHaveLength(1); + expect(result.unsuppressedViolations[0].getRule().getName()).toBe('UnusedVariable'); + }); + + it('should match violations by severity level', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'Rule1', SeverityLevel.High), + 'High severity', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ), + new MockViolation( + new MockRule('pmd', 'Rule2', SeverityLevel.Moderate), + 'Moderate severity', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 20, 1, 20, 20) + ), + new MockViolation( + new MockRule('pmd', 'Rule3', SeverityLevel.Low), + 'Low severity', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 30, 1, 30, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: '3,4', + max_suppressed_violations: null + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.suppressedCount).toBe(2); + expect(result.unsuppressedViolations).toHaveLength(1); + expect(result.unsuppressedViolations[0].getRule().getSeverityLevel()).toBe(SeverityLevel.High); + }); + + it('should match violations by tag', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'Rule1', SeverityLevel.High, ['Recommended']), + 'Tagged violation', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ), + new MockViolation( + new MockRule('pmd', 'Rule2', SeverityLevel.High, ['Security']), + 'Security violation', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 20, 1, 20, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'Recommended', + max_suppressed_violations: null + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.suppressedCount).toBe(1); + expect(result.unsuppressedViolations).toHaveLength(1); + expect(result.unsuppressedViolations[0].getRule().getTags()).toContain('Security'); + }); + }); + + describe('Multiple rules per file', () => { + it('should apply multiple rules to the same file', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ), + new MockViolation( + new MockRule('eslint', 'no-unused-vars'), + 'Unused variable', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 20, 1, 20, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'pmd', + max_suppressed_violations: null + }, + { + rule_selector: 'eslint', + max_suppressed_violations: null + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.suppressedCount).toBe(2); + expect(result.unsuppressedViolations).toHaveLength(0); + }); + + it('should apply first matching rule when multiple rules match', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'pmd', + max_suppressed_violations: 1 + }, + { + rule_selector: 'pmd:UnusedMethod', + max_suppressed_violations: 1 + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.suppressedCount).toBe(1); + // Quota key uses config path with rule index (first rule at index 0 is used) + expect(quotas.get('src/file1.apex|0|pmd')).toBe(1); + expect(quotas.get('src/file1.apex|1|pmd:UnusedMethod')).toBeUndefined(); + }); + }); + + describe('Deterministic ordering', () => { + it('should process violations in deterministic order (file, then line)', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file2.apex'), 10, 1, 10, 20) + ), + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 20, 1, 20, 20) + ), + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ) + ]; + + const bulkConfig = { + 'src': [ + { + rule_selector: 'pmd', + max_suppressed_violations: 2 + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.suppressedCount).toBe(2); + expect(result.unsuppressedViolations).toHaveLength(1); + // Should suppress file1:10 and file1:20 (sorted by file, then line) + expect(result.unsuppressedViolations[0].getPrimaryLocation().getFile()).toBe(path.join(workspaceRoot, 'src', 'file2.apex')); + }); + }); + + describe('Quota tracking across engines', () => { + it('should share quota across multiple files in same folder', () => { + // Critical test: Folder-level quota should be shared across all files in folder + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method in file1', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'controllers', 'file1.apex'), 10, 1, 10, 20) + ), + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method in file2', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'controllers', 'file2.apex'), 20, 1, 20, 20) + ), + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method in file3', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'controllers', 'file3.apex'), 30, 1, 30, 20) + ) + ]; + + const bulkConfig = { + 'src/controllers': [ // Folder-level suppression + { + rule_selector: 'pmd:UnusedMethod', + max_suppressed_violations: 2 // Total across ALL files in folder + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + // Should suppress first 2 violations (across different files), reject 3rd + expect(result.suppressedCount).toBe(2); + expect(result.unsuppressedViolations).toHaveLength(1); + expect(result.unsuppressedViolations[0].getPrimaryLocation().getFile()).toBe(path.join(workspaceRoot, 'src', 'controllers', 'file3.apex')); + + // Quota is shared at folder level, not per-file (rule index 0) + expect(quotas.get('src/controllers|0|pmd:UnusedMethod')).toBe(2); + }); + + it('should maintain quota state across multiple calls', () => { + const violations1 = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ) + ]; + + const violations2 = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 20, 1, 20, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'pmd:UnusedMethod', + max_suppressed_violations: 1 + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + + // First call - should suppress + const result1 = applyBulkSuppressions(violations1, bulkConfig, quotas, workspaceRoot); + expect(result1.suppressedCount).toBe(1); + // Quota key uses config path with rule index + expect(quotas.get('src/file1.apex|0|pmd:UnusedMethod')).toBe(1); + + // Second call - quota exhausted, should not suppress + const result2 = applyBulkSuppressions(violations2, bulkConfig, quotas, workspaceRoot); + expect(result2.suppressedCount).toBe(0); + expect(result2.unsuppressedViolations).toHaveLength(1); + }); + }); + + describe('Edge cases', () => { + it('should return all violations when bulkConfig is empty', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ) + ]; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, {}, quotas, workspaceRoot); + + expect(result.unsuppressedViolations).toHaveLength(1); + expect(result.suppressedCount).toBe(0); + }); + + it('should return early when violations array is empty', () => { + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'pmd', + max_suppressed_violations: null + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions([], bulkConfig, quotas, workspaceRoot); + + expect(result.unsuppressedViolations).toHaveLength(0); + expect(result.suppressedCount).toBe(0); + }); + + it('should skip violations without file path', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(undefined, 10, 1, 10, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'pmd', + max_suppressed_violations: null + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.unsuppressedViolations).toHaveLength(1); + expect(result.suppressedCount).toBe(0); + }); + + it('should handle invalid rule selector gracefully', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'invalid:::selector', + max_suppressed_violations: null + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + // Should not suppress due to invalid selector + expect(result.unsuppressedViolations).toHaveLength(1); + expect(result.suppressedCount).toBe(0); + }); + + it('should handle max_suppressed_violations of 0', () => { + const violations = [ + new MockViolation( + new MockRule('pmd', 'UnusedMethod'), + 'Unused method', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'file1.apex'), 10, 1, 10, 20) + ) + ]; + + const bulkConfig = { + 'src/file1.apex': [ + { + rule_selector: 'pmd', + max_suppressed_violations: 0 + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + expect(result.unsuppressedViolations).toHaveLength(1); + expect(result.suppressedCount).toBe(0); + }); + }); +}); diff --git a/packages/code-analyzer-core/test/suppressions/bulk-suppression-workspace-root.test.ts b/packages/code-analyzer-core/test/suppressions/bulk-suppression-workspace-root.test.ts new file mode 100644 index 00000000..a96d4094 --- /dev/null +++ b/packages/code-analyzer-core/test/suppressions/bulk-suppression-workspace-root.test.ts @@ -0,0 +1,373 @@ +import {applyBulkSuppressions, BulkSuppressionQuotas} from "../../src/suppressions/bulk-suppression-processor"; +import {BulkSuppressionRule} from "../../src/config"; +import {Violation, CodeLocation} from "../../src/results"; +import {RuleImpl} from "../../src/rules"; +import * as path from 'node:path'; +import * as os from 'node:os'; + +/** + * Helper to create platform-appropriate absolute paths for testing + * On Windows: C:\Users\... + * On Unix: /Users/... + */ +function createTestAbsolutePath(...segments: string[]): string { + if (os.platform() === 'win32') { + // Windows: start with C:\ drive + return path.join('C:', path.sep, ...segments); + } else { + // Unix: start with / + return path.join(path.sep, ...segments); + } +} + +/** + * Tests specifically for Bug #2: Workspace Root vs Config Root inconsistency + * + * BUG: Bulk suppressions were using config.getConfigRoot() while ignores feature + * uses workspace.getWorkspaceRoot(). This caused path resolution inconsistency. + * + * SCENARIO: User runs from /workspace/dreamhouse with config at + * /workspace/dreamhouse/force-app/main/default/react-components/code-analyzer.yml + * + * EXPECTED: Paths in config should be relative to workspace root (consistent with ignores) + * ACTUAL (Bug): Paths were relative to config root (where config file lives) + */ + +// Mock violation and code location for testing +class MockCodeLocation implements CodeLocation { + constructor( + private file: string, + private startLine: number, + private startColumn: number, + private endLine: number, + private endColumn: number + ) {} + + getFile(): string { return this.file; } + getStartLine(): number { return this.startLine; } + getStartColumn(): number { return this.startColumn; } + getEndLine(): number { return this.endLine; } + getEndColumn(): number { return this.endColumn; } + getComment(): string | undefined { return undefined; } +} + +class MockViolation implements Violation { + constructor( + private rule: RuleImpl, + private message: string, + private primaryLocation: CodeLocation, + private otherLocations: CodeLocation[] = [] + ) {} + + getRule(): RuleImpl { return this.rule; } + getMessage(): string { return this.message; } + getPrimaryLocation(): CodeLocation { return this.primaryLocation; } + getCodeLocations(): CodeLocation[] { + return [this.primaryLocation, ...this.otherLocations]; + } + getPrimaryLocationIndex(): number { return 0; } + getResourceUrls(): string[] { return []; } + getFixes(): any[] { return []; } + getSuggestions(): any[] { return []; } +} + +// Mock rule for testing +const mockRule = { + getName: () => 'no-console', + getEngineName: () => 'eslint', + getSeverityLevel: () => 3, + getTags: () => ['Recommended'], + getDescription: () => 'Mock rule', + getResourceUrls: () => [], + matchesRuleSelector: () => true +} as unknown as RuleImpl; + +describe('Bulk Suppressions - Workspace Root Path Resolution (Bug #2)', () => { + /** + * BUG #2 TEST: Config in subdirectory, workspace root at parent level + * + * Setup mirrors real-world scenario: + * - Workspace root: /Users/user/workspace/dreamhouse + * - Config file at: /Users/user/workspace/dreamhouse/force-app/main/default/react-components/code-analyzer.yml + * - Violation file: /Users/user/workspace/dreamhouse/force-app/main/default/react-components/utils.js + * + * Config path should be: "force-app/main/default/react-components/utils.js" (relative to workspace root) + * NOT: "utils.js" (relative to config root) + */ + it('BUG #2: Paths in config are resolved relative to WORKSPACE ROOT, not config file location', () => { + // Workspace root (where -w flag points) - use platform-appropriate paths + const workspaceRoot = createTestAbsolutePath('Users', 'user', 'workspace', 'dreamhouse'); + + // Violation file path (absolute) - use platform-appropriate paths + const violationFilePath = path.join(workspaceRoot, 'force-app', 'main', 'default', 'react-components', 'utils.js'); + + // Create violation + const violation = new MockViolation( + mockRule, + 'Unexpected console statement', + new MockCodeLocation(violationFilePath, 10, 1, 10, 20) + ); + + // Bulk config with path relative to WORKSPACE ROOT (consistent with ignores) + // This is what user should write in config file (always use forward slashes) + const bulkConfig: Record = { + 'force-app/main/default/react-components/utils.js': [ + { + rule_selector: 'eslint:all', + max_suppressed_violations: 3 + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + + // Apply suppressions using workspace root (Bug #2 fix) + const result = applyBulkSuppressions([violation], bulkConfig, quotas, workspaceRoot); + + // Violation should be suppressed + expect(result.unsuppressedViolations).toHaveLength(0); + expect(result.suppressedCount).toBe(1); + }); + + it('BUG #2: Same directory level - workspace root equals config directory (worked before bug fix)', () => { + // This case worked even with Bug #2 because workspace root === config root + const workspaceRoot = createTestAbsolutePath('Users', 'user', 'workspace', 'project'); + const violationFilePath = path.join(workspaceRoot, 'utils.js'); + + const violation = new MockViolation( + mockRule, + 'Unexpected console statement', + new MockCodeLocation(violationFilePath, 10, 1, 10, 20) + ); + + const bulkConfig: Record = { + 'utils.js': [ + { + rule_selector: 'eslint:all', + max_suppressed_violations: 3 + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions([violation], bulkConfig, quotas, workspaceRoot); + + // Should work regardless of bug + expect(result.unsuppressedViolations).toHaveLength(0); + expect(result.suppressedCount).toBe(1); + }); + + it('BUG #2: Config in subdirectory with WRONG path (relative to config root) does NOT suppress', () => { + // This demonstrates the bug - if user writes path relative to config file location + const workspaceRoot = path.join(path.sep, 'Users', 'user', 'workspace', 'dreamhouse'); + const violationFilePath = path.join(workspaceRoot, 'force-app', 'main', 'default', 'react-components', 'utils.js'); + + const violation = new MockViolation( + mockRule, + 'Unexpected console statement', + new MockCodeLocation(violationFilePath, 10, 1, 10, 20) + ); + + // WRONG: Path relative to config file location (where old bug would require) + const bulkConfig: Record = { + 'utils.js': [ // This is relative to config dir, not workspace root + { + rule_selector: 'eslint:all', + max_suppressed_violations: 3 + } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions([violation], bulkConfig, quotas, workspaceRoot); + + // Should NOT suppress because path doesn't match + expect(result.unsuppressedViolations).toHaveLength(1); + expect(result.suppressedCount).toBe(0); + }); + + it('BUG #2: Multiple files at different levels all resolve correctly from workspace root', () => { + const workspaceRoot = createTestAbsolutePath('workspace', 'project'); + + // Violations at different directory levels + const violation1 = new MockViolation( + mockRule, + 'Console in nested file', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'utils.js'), 10, 1, 10, 20) + ); + + const violation2 = new MockViolation( + mockRule, + 'Console in deeper nested file', + new MockCodeLocation(path.join(workspaceRoot, 'force-app', 'main', 'default', 'lwc', 'component.js'), 20, 1, 20, 20) + ); + + const violation3 = new MockViolation( + mockRule, + 'Console in root file', + new MockCodeLocation(path.join(workspaceRoot, 'index.js'), 30, 1, 30, 20) + ); + + // All paths relative to workspace root + const bulkConfig: Record = { + 'src/utils.js': [ + { rule_selector: 'eslint:all', max_suppressed_violations: 1 } + ], + 'force-app/main/default/lwc/component.js': [ + { rule_selector: 'eslint:all', max_suppressed_violations: 1 } + ], + 'index.js': [ + { rule_selector: 'eslint:all', max_suppressed_violations: 1 } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions( + [violation1, violation2, violation3], + bulkConfig, + quotas, + workspaceRoot + ); + + // All should be suppressed + expect(result.unsuppressedViolations).toHaveLength(0); + expect(result.suppressedCount).toBe(3); + }); + + it('BUG #2: Folder-level suppression from workspace root matches nested files', () => { + const workspaceRoot = createTestAbsolutePath('workspace', 'project'); + + // Violations in nested folder + const violation1 = new MockViolation( + mockRule, + 'Console', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'utils', 'helper.js'), 10, 1, 10, 20) + ); + + const violation2 = new MockViolation( + mockRule, + 'Console', + new MockCodeLocation(path.join(workspaceRoot, 'src', 'utils', 'formatter.js'), 20, 1, 20, 20) + ); + + // Suppress entire folder (path relative to workspace root) + const bulkConfig: Record = { + 'src/utils/': [ + { rule_selector: 'eslint:all', max_suppressed_violations: 10 } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions( + [violation1, violation2], + bulkConfig, + quotas, + workspaceRoot + ); + + // Both files in folder should be suppressed + expect(result.unsuppressedViolations).toHaveLength(0); + expect(result.suppressedCount).toBe(2); + }); + + it('Config paths can be relative to workspace root (recommended)', () => { + // This test documents the recommended usage: paths relative to workspace root + const workspaceRoot = createTestAbsolutePath('workspace', 'project'); + const violationFilePath = path.join(workspaceRoot, 'src', 'file.js'); + + const violation = new MockViolation( + mockRule, + 'Console', + new MockCodeLocation(violationFilePath, 10, 1, 10, 20) + ); + + // RECOMMENDED: Use relative path from workspace root + const bulkConfig: Record = { + 'src/file.js': [ // Relative to workspace root + { rule_selector: 'eslint:all', max_suppressed_violations: 1 } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions([violation], bulkConfig, quotas, workspaceRoot); + + // Should suppress because path is correctly specified + expect(result.unsuppressedViolations).toHaveLength(0); + expect(result.suppressedCount).toBe(1); + }); + + it('BUG #2: Path resolution consistent with ignores feature behavior', () => { + /** + * This test documents that bulk suppressions now behave the same as ignores: + * - Both use workspace.getWorkspaceRoot() for path resolution + * - Both resolve paths relative to workspace root + * - Both fall back to absolute paths if workspace root is null + */ + const workspaceRoot = createTestAbsolutePath('Users', 'user', 'dreamhouse'); + + // Imagine ignores config: ["force-app/test/**"] + // Imagine bulk suppressions: "force-app/main/default/utils.js" + // Both should resolve relative to workspace root + + const violation = new MockViolation( + mockRule, + 'Console', + new MockCodeLocation(path.join(workspaceRoot, 'force-app', 'main', 'default', 'utils.js'), 10, 1, 10, 20) + ); + + const bulkConfig: Record = { + 'force-app/main/default/utils.js': [ + { rule_selector: 'eslint:all', max_suppressed_violations: 1 } + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions([violation], bulkConfig, quotas, workspaceRoot); + + expect(result.unsuppressedViolations).toHaveLength(0); + expect(result.suppressedCount).toBe(1); + }); + + it('Duplicate rule selectors have independent quotas', () => { + /** + * When config has duplicate rule_selector entries, each should get its own quota. + * This allows users to specify multiple quota allocations for the same selector. + * + * Example use case: + * suppressions: + * force-app/utils.js: + * - rule_selector: no-magic-numbers,pmd + * max_suppressed_violations: 3 + * - rule_selector: no-magic-numbers,pmd + * max_suppressed_violations: 2 + * + * Expected: 5 total violations suppressed (3 + 2), not 3 with shared quota + */ + const workspaceRoot = createTestAbsolutePath('Users', 'user', 'workspace'); + const filePath = path.join(workspaceRoot, 'force-app', 'utils.js'); + + // Create 6 violations that would match the duplicate selectors + const violations: Violation[] = []; + for (let i = 1; i <= 6; i++) { + violations.push(new MockViolation( + mockRule, + `Violation ${i}`, + new MockCodeLocation(filePath, i, 1, i, 10) + )); + } + + const bulkConfig: Record = { + 'force-app/utils.js': [ + { rule_selector: 'eslint:all', max_suppressed_violations: 3 }, + { rule_selector: 'eslint:all', max_suppressed_violations: 2 } // Duplicate selector + ] + }; + + const quotas: BulkSuppressionQuotas = new Map(); + const result = applyBulkSuppressions(violations, bulkConfig, quotas, workspaceRoot); + + // First rule suppresses 3, second rule suppresses 2 = 5 total + expect(result.suppressedCount).toBe(5); + expect(result.unsuppressedViolations).toHaveLength(1); + }); +}); diff --git a/packages/code-analyzer-core/test/suppressions/suppression-parser.test.ts b/packages/code-analyzer-core/test/suppressions/suppression-parser.test.ts index f0864fec..78511bff 100644 --- a/packages/code-analyzer-core/test/suppressions/suppression-parser.test.ts +++ b/packages/code-analyzer-core/test/suppressions/suppression-parser.test.ts @@ -306,6 +306,41 @@ describe('buildSuppressionRanges', () => { }); }); + it('should create unsuppression range when closing broader suppress (exception behavior)', () => { + const markers: SuppressionMarker[] = [ + createMarker('suppress', 'all', 1), + createMarker('suppress', 'pmd:UnusedMethod', 5), + createMarker('unsuppress', 'pmd:UnusedMethod', 10) + ]; + + const ranges = buildSuppressionRanges(markers, '/test/file.apex'); + + // Critical: unsuppress must create a range to act as exception against broader suppress(all) + expect(ranges).toHaveLength(3); + + // Sort ranges by startLine for deterministic test assertions + const sortedRanges = ranges.sort((a, b) => a.startLine - b.startLine); + + expect(sortedRanges[0]).toMatchObject({ + startLine: 1, + endLine: undefined, + ruleSelectorString: 'all', + isSuppressed: true + }); + expect(sortedRanges[1]).toMatchObject({ + startLine: 5, + endLine: 9, + ruleSelectorString: 'pmd:UnusedMethod', + isSuppressed: true + }); + expect(sortedRanges[2]).toMatchObject({ + startLine: 10, + endLine: undefined, + ruleSelectorString: 'pmd:UnusedMethod', + isSuppressed: false + }); + }); + it('should handle empty markers array', () => { const markers: SuppressionMarker[] = []; diff --git a/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/code-analyzer.yml b/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/code-analyzer.yml new file mode 100644 index 00000000..61bceb8f --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/code-analyzer.yml @@ -0,0 +1,17 @@ +suppressions: + disable_suppressions: false + # Suppress 2 violations in file1.js + file1.js: + - rule_selector: all + max_suppressed_violations: 2 + reason: Test file-level suppression with quota + # Suppress all violations in file2.js + file2.js: + - rule_selector: all + max_suppressed_violations: null + reason: Test unlimited suppression + # Suppress 2 violations across all files in src/controllers folder + src/controllers/: + - rule_selector: all + max_suppressed_violations: 2 + reason: Test folder-level shared quota diff --git a/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/file1.js b/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/file1.js new file mode 100644 index 00000000..6d0024f4 --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/file1.js @@ -0,0 +1,12 @@ +// Test file 1 with multiple violations +function unusedFunction1() { + console.log('violation on line 3'); +} + +function unusedFunction2() { + console.log('violation on line 6'); +} + +function unusedFunction3() { + console.log('violation on line 10'); +} diff --git a/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/file2.js b/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/file2.js new file mode 100644 index 00000000..49c609ab --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/file2.js @@ -0,0 +1,4 @@ +// Test file 2 with violations +function anotherUnusedFunction() { + console.log('violation on line 3'); +} diff --git a/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/src/controllers/controller1.js b/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/src/controllers/controller1.js new file mode 100644 index 00000000..f6f4a9a0 --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/src/controllers/controller1.js @@ -0,0 +1,8 @@ +// Controller 1 +function controllerMethod1() { + console.log('violation on line 3'); +} + +function controllerMethod2() { + console.log('violation on line 6'); +} diff --git a/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/src/controllers/controller2.js b/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/src/controllers/controller2.js new file mode 100644 index 00000000..0805e923 --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/bulk-suppressions-workspace/src/controllers/controller2.js @@ -0,0 +1,4 @@ +// Controller 2 +function controllerMethod3() { + console.log('violation on line 3'); +}