diff --git a/package-lock.json b/package-lock.json index 218a6c16..c2661dcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5794,6 +5794,18 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -9422,6 +9434,7 @@ "@salesforce/code-analyzer-engine-api": "0.35.0", "@types/node": "^20.0.0", "csv-stringify": "^6.6.0", + "isbinaryfile": "^5.0.4", "js-yaml": "^4.1.1", "semver": "^7.7.4", "xmlbuilder": "^15.1.1" @@ -11693,18 +11706,6 @@ "node": ">= 4" } }, - "packages/code-analyzer-regex-engine/node_modules/isbinaryfile": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", - "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, "packages/code-analyzer-regex-engine/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -11959,18 +11960,6 @@ "node": ">= 4" } }, - "packages/code-analyzer-retirejs-engine/node_modules/isbinaryfile": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", - "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, "packages/code-analyzer-retirejs-engine/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", diff --git a/packages/code-analyzer-core/package.json b/packages/code-analyzer-core/package.json index 48235847..fa5a9af5 100644 --- a/packages/code-analyzer-core/package.json +++ b/packages/code-analyzer-core/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/code-analyzer-core", "description": "Core Package for the Salesforce Code Analyzer", - "version": "0.43.0", + "version": "0.44.0-SNAPSHOT", "author": "The Salesforce Code Analyzer Team", "license": "BSD-3-Clause", "homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview", @@ -19,6 +19,7 @@ "@salesforce/code-analyzer-engine-api": "0.35.0", "@types/node": "^20.0.0", "csv-stringify": "^6.6.0", + "isbinaryfile": "^5.0.4", "js-yaml": "^4.1.1", "semver": "^7.7.4", "xmlbuilder": "^15.1.1" diff --git a/packages/code-analyzer-core/src/code-analyzer.ts b/packages/code-analyzer-core/src/code-analyzer.ts index 3120438e..8a0a1e52 100644 --- a/packages/code-analyzer-core/src/code-analyzer.ts +++ b/packages/code-analyzer-core/src/code-analyzer.ts @@ -6,8 +6,10 @@ import { RunResults, RunResultsImpl, UnexpectedErrorEngineRunResults, - UninstantiableEngineRunResults + UninstantiableEngineRunResults, + Violation } from "./results" +import {processSuppressions} from "./suppressions" import {SemVer} from 'semver'; import { EngineLogEvent, @@ -384,9 +386,58 @@ export class CodeAnalyzer { for (const [uninstantiableEngine, error] of this.uninstantiableEnginesMap.entries()) { 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); + 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 + */ + private async applyInlineSuppressions(runResults: RunResultsImpl): Promise { + // Check if suppressions are enabled + if (!this.config.getSuppressionsEnabled()) { + return; // Feature disabled, skip processing + } + + const allViolations = runResults.getViolations(); + + if (allViolations.length === 0) { + return; // No violations to process + } + + this.emitLogEvent(LogLevel.Debug, getMessage('ProcessingInlineSuppressions', allViolations.length)); + + // Process suppressions (returns filtered violations) + const logger = (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(); + const filteredSet = new Set(filteredViolations); + for (const violation of allViolations) { + if (!filteredSet.has(violation)) { + suppressedViolations.add(violation); + } + } + + const suppressedCount = suppressedViolations.size; + if (suppressedCount > 0) { + this.emitLogEvent(LogLevel.Info, getMessage('SuppressedViolationsCount', suppressedCount)); + runResults.applySuppressedViolationsFilter(suppressedViolations); + } else { + this.emitLogEvent(LogLevel.Info, getMessage('NoViolationsSuppressed')); + } + } + /** * Attach a listener callback to one of the events that Code Analyzer may emit * Example usage: diff --git a/packages/code-analyzer-core/src/config.ts b/packages/code-analyzer-core/src/config.ts index 996ca479..d24d68db 100644 --- a/packages/code-analyzer-core/src/config.ts +++ b/packages/code-analyzer-core/src/config.ts @@ -16,6 +16,8 @@ export const FIELDS = { ROOT_WORKING_FOLDER: 'root_working_folder', // Hidden CUSTOM_ENGINE_PLUGIN_MODULES: 'custom_engine_plugin_modules', // Hidden PRESERVE_ALL_WORKING_FOLDERS: 'preserve_all_working_folders', // Hidden + SUPPRESSIONS: 'suppressions', + DISABLE_SUPPRESSIONS: 'disable_suppressions', RULES: 'rules', ENGINES: 'engines', SEVERITY: 'severity', @@ -48,6 +50,13 @@ export type Ignores = { files: string[] } +/** + * Object containing the user specified suppressions configuration + */ +export type Suppressions = { + disable_suppressions: boolean +} + type TopLevelConfig = { config_root: string log_folder: string @@ -55,6 +64,7 @@ type TopLevelConfig = { rules: Record engines: Record ignores: Ignores + suppressions: Suppressions root_working_folder: string, // INTERNAL USE ONLY preserve_all_working_folders: boolean // INTERNAL USE ONLY custom_engine_plugin_modules: string[] // INTERNAL USE ONLY @@ -65,6 +75,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 rules: {}, engines: {}, ignores: { files: [] }, @@ -156,11 +167,12 @@ export class CodeAnalyzerConfig { validateAbsoluteFolder(rawConfig.config_root, FIELDS.CONFIG_ROOT); const configExtractor: engApi.ConfigValueExtractor = new engApi.ConfigValueExtractor(rawConfig, '', configRoot); configExtractor.addKeysThatBypassValidation([FIELDS.CUSTOM_ENGINE_PLUGIN_MODULES, FIELDS.PRESERVE_ALL_WORKING_FOLDERS, FIELDS.ROOT_WORKING_FOLDER]); // Hidden fields bypass validation - configExtractor.validateContainsOnlySpecifiedKeys([FIELDS.CONFIG_ROOT, FIELDS.LOG_FOLDER, FIELDS.LOG_LEVEL, FIELDS.RULES, FIELDS.ENGINES, FIELDS.IGNORES]); + configExtractor.validateContainsOnlySpecifiedKeys([FIELDS.CONFIG_ROOT, FIELDS.LOG_FOLDER, FIELDS.LOG_LEVEL, FIELDS.RULES, FIELDS.ENGINES, FIELDS.IGNORES, FIELDS.SUPPRESSIONS]); const config: TopLevelConfig = { config_root: configRoot, log_folder: configExtractor.extractFolder(FIELDS.LOG_FOLDER, DEFAULT_CONFIG.log_folder)!, log_level: extractLogLevel(configExtractor), + suppressions: extractSuppressionsValue(configExtractor), custom_engine_plugin_modules: configExtractor.extractArray(FIELDS.CUSTOM_ENGINE_PLUGIN_MODULES, engApi.ValueValidator.validateString, DEFAULT_CONFIG.custom_engine_plugin_modules)!, @@ -239,6 +251,15 @@ export class CodeAnalyzerConfig { return this.config.log_level; } + /** + * Returns whether suppression markers should be processed. + * When enabled, code-analyzer-suppress/unsuppress markers in source files will filter out violations. + * Returns true by default unless explicitly disabled via suppressions.disable_suppressions config. + */ + public getSuppressionsEnabled(): boolean { + return !this.config.suppressions.disable_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. @@ -358,6 +379,13 @@ function extractIgnoresValue(configExtractor: engApi.ConfigValueExtractor): Igno return { files }; } +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 }; +} + /** * Validates that a value is a string and is a valid glob pattern. * Throws an error if the pattern is empty or has unbalanced brackets/braces/parentheses. diff --git a/packages/code-analyzer-core/src/messages.ts b/packages/code-analyzer-core/src/messages.ts index dfff6b2a..02ca1e6e 100644 --- a/packages/code-analyzer-core/src/messages.ts +++ b/packages/code-analyzer-core/src/messages.ts @@ -229,7 +229,16 @@ const MESSAGE_CATALOG : MessageCatalog = { `Since preserve_all_working_folders config setting is true, all temporary working folders in %s have been kept.`, EngineWorkingFolderKeptDueToError: - `Since the engine '%s' emitted an error, the following temporary working folder will not be removed: %s` + `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.`, + + NoViolationsSuppressed: + `No violations were suppressed by inline suppression markers.` } /** diff --git a/packages/code-analyzer-core/src/results.ts b/packages/code-analyzer-core/src/results.ts index 111aa2b3..882ed3d9 100644 --- a/packages/code-analyzer-core/src/results.ts +++ b/packages/code-analyzer-core/src/results.ts @@ -276,6 +276,7 @@ export class EngineRunResultsImpl implements EngineRunResults { private readonly engineVersion: string; private readonly apiEngineRunResults: engApi.EngineRunResults; private readonly ruleSelection: RuleSelection; + private cachedViolations: Violation[] | undefined; constructor(engineName: string, engineVersion: string, apiEngineRunResults: engApi.EngineRunResults, ruleSelection: RuleSelection) { this.engineName = engineName; @@ -301,8 +302,13 @@ export class EngineRunResultsImpl implements EngineRunResults { } getViolations(): Violation[] { - return this.apiEngineRunResults.violations.map(v => - new ViolationImpl(v, this.ruleSelection.getRule(this.engineName, v.ruleName))); + // Cache violations to ensure the same objects are returned on multiple calls + // This is critical for Set-based filtering (e.g., inline suppressions) + if (!this.cachedViolations) { + this.cachedViolations = this.apiEngineRunResults.violations.map(v => + new ViolationImpl(v, this.ruleSelection.getRule(this.engineName, v.ruleName))); + } + return this.cachedViolations; } } @@ -350,6 +356,39 @@ export class UnexpectedErrorEngineRunResults extends AbstractErroneousEngineRunR } } +/** + * Wrapper class that filters violations from an existing EngineRunResults + */ +class FilteredEngineRunResults implements EngineRunResults { + private readonly originalResults: EngineRunResults; + private readonly filteredViolations: Violation[]; + + constructor(originalResults: EngineRunResults, filteredViolations: Violation[]) { + this.originalResults = originalResults; + this.filteredViolations = filteredViolations; + } + + getEngineName(): string { + return this.originalResults.getEngineName(); + } + + getEngineVersion(): string { + return this.originalResults.getEngineVersion(); + } + + getViolationCount(): number { + return this.filteredViolations.length; + } + + getViolationCountOfSeverity(severity: SeverityLevel): number { + return this.filteredViolations.filter(v => v.getRule().getSeverityLevel() == severity).length; + } + + getViolations(): Violation[] { + return this.filteredViolations; + } +} + export class RunResultsImpl implements RunResults { private readonly clock: Clock; private readonly runDir: string; @@ -415,4 +454,21 @@ export class RunResultsImpl implements RunResults { addEngineRunResults(engineRunResults: EngineRunResults): void { this.engineRunResultsMap.set(engineRunResults.getEngineName(), engineRunResults); } + + /** + * Applies suppression filtering to all violations in this RunResults + * This method filters out violations that have been suppressed via inline markers + * @param suppressedViolations Set of violations to suppress + */ + applySuppressedViolationsFilter(suppressedViolations: Set): void { + // For each engine, filter its violations + for (const [engineName, originalResults] of this.engineRunResultsMap.entries()) { + const originalViolations = originalResults.getViolations(); + const filteredViolations = originalViolations.filter(v => !suppressedViolations.has(v)); + + // Replace with filtered results + const filteredResults = new FilteredEngineRunResults(originalResults, filteredViolations); + this.engineRunResultsMap.set(engineName, filteredResults); + } + } } diff --git a/packages/code-analyzer-core/src/suppressions/index.ts b/packages/code-analyzer-core/src/suppressions/index.ts new file mode 100644 index 00000000..106d70ca --- /dev/null +++ b/packages/code-analyzer-core/src/suppressions/index.ts @@ -0,0 +1,24 @@ +/** + * Public API for the suppressions module + */ + +export type { + SuppressionMarker, + SuppressionRange, + FileSuppressions, + SuppressionsMap +} from './suppression-types'; + +export { + parseSuppressionMarkers, + buildSuppressionRanges, + parseFileSuppressions +} from './suppression-parser'; + +export { + processSuppressions, + isTextFile, + extractSuppressionsFromFiles +} from './suppression-processor'; + +export type { LoggerCallback } from './suppression-processor'; diff --git a/packages/code-analyzer-core/src/suppressions/suppression-parser.ts b/packages/code-analyzer-core/src/suppressions/suppression-parser.ts new file mode 100644 index 00000000..87da5b08 --- /dev/null +++ b/packages/code-analyzer-core/src/suppressions/suppression-parser.ts @@ -0,0 +1,305 @@ +/** + * Parser for suppression markers in source files + */ + +import { SuppressionMarker, SuppressionRange, FileSuppressions } from './suppression-types'; + +/** + * Regular expressions to match suppression markers (case-insensitive) + * Matches: code-analyzer-suppress(rule) or code-analyzer-suppress() or code-analyzer-suppress + * Also matches: Code-Analyzer-Suppress, CODE-ANALYZER-SUPPRESS, etc. + * The pattern handles complex selectors with nested parentheses like "eslint:(3,4)" + * Pattern breakdown: [^()]* matches chars that aren't parens, (?:\([^)]*\)[^()]*)* handles nested parens + */ +const SUPPRESS_PATTERN = /code-analyzer-suppress(?:\(([^()]*(?:\([^)]*\)[^()]*)*)\))?/gi; +const UNSUPPRESS_PATTERN = /code-analyzer-unsuppress(?:\(([^()]*(?:\([^)]*\)[^()]*)*)\))?/gi; + +/** + * Parses a file's content to extract suppression markers + * @param fileContent The full content of the file as a string + * @param filePath The absolute path to the file (for error reporting) + * @returns Array of SuppressionMarker objects found in the file + */ +export function parseSuppressionMarkers(fileContent: string, _filePath: string): SuppressionMarker[] { + const markers: SuppressionMarker[] = []; + const lines = fileContent.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const lineNumber = i + 1; // Convert to 1-indexed + const line = lines[i]; + + // Find all suppress markers in this line + const suppressMatches = Array.from(line.matchAll(SUPPRESS_PATTERN)); + for (const match of suppressMatches) { + const ruleSelector = normalizeRuleSelector(match[1]); + markers.push({ + type: 'suppress', + ruleSelector, + lineNumber + }); + } + + // Find all unsuppress markers in this line + const unsuppressMatches = Array.from(line.matchAll(UNSUPPRESS_PATTERN)); + for (const match of unsuppressMatches) { + const ruleSelector = normalizeRuleSelector(match[1]); + markers.push({ + type: 'unsuppress', + ruleSelector, + lineNumber + }); + } + } + + return markers; +} + +/** + * Normalizes a rule selector from a marker + * Empty or undefined selectors default to "all" + * Trims whitespace from the selector + */ +function normalizeRuleSelector(selector: string | undefined): string { + if (!selector || selector.trim() === '') { + return 'all'; + } + return selector.trim(); +} + +/** + * Parse a selector into its hierarchical components + */ +function parseSelector(sel: string): { scope: 'all' | 'engine' | 'specific', engine?: string, rule?: string } { + if (sel === 'all') { + return { scope: 'all' }; + } + // Handle special case for severity selectors like "eslint:(3,4)" + const colonIndex = sel.indexOf(':'); + if (colonIndex === -1) { + return { scope: 'engine', engine: sel }; + } + const engine = sel.substring(0, colonIndex); + const rest = sel.substring(colonIndex + 1); + return { scope: 'specific', engine, rule: rest }; +} + +/** + * Checks if an unsuppress selector can hierarchically end a suppress selector + * + * Hierarchical matching rules (CORRECTED): + * - unsuppress(all) ends ANY suppress + * - unsuppress(engine) ends suppress(engine) or suppress(engine:rule) from same engine + * - unsuppress(engine:rule) ends suppress(engine:rule) (exact match only) + * + * More specific unsuppress creates an EXCEPTION (overlapping range), not an ending: + * - unsuppress(engine) does NOT end suppress(all) - creates exception + * - unsuppress(engine:rule) does NOT end suppress(all) - creates exception + * - unsuppress(engine:rule) does NOT end suppress(engine) - creates exception + * + * @param suppressSelector The selector that started the suppression + * @param unsuppressSelector The selector attempting to end it + * @returns true if the unsuppress can hierarchically end this suppress + */ +function canUnsuppressEndSuppress(suppressSelector: string, unsuppressSelector: string): boolean { + // Exact match always works + if (suppressSelector === unsuppressSelector) { + return true; + } + + const suppressParsed = parseSelector(suppressSelector); + const unsuppressParsed = parseSelector(unsuppressSelector); + + // unsuppress(all) ends ANY suppress (broader scope always ends) + if (unsuppressParsed.scope === 'all') { + return true; + } + + // unsuppress(engine) ends suppress(engine) or suppress(engine:rule) from same engine + if (unsuppressParsed.scope === 'engine') { + if (suppressParsed.scope === 'engine' && suppressParsed.engine === unsuppressParsed.engine) { + return true; // Redundant with exact match, but explicit + } + if (suppressParsed.scope === 'specific' && suppressParsed.engine === unsuppressParsed.engine) { + return true; // unsuppress(engine) ends suppress(engine:rule) + } + } + + // More specific unsuppress should NOT end broader suppress + // This creates an exception (overlapping range) instead + // The processor will use specificity rules to determine which range wins + + return false; +} + +/** + * Checks if a suppress selector can hierarchically close an unsuppress selector + * + * Reverse hierarchical matching rules: + * - suppress(all) closes ANY unsuppress + * - suppress(engine) closes unsuppress(engine) or unsuppress(engine:rule) from same engine + * - suppress(engine:rule) closes unsuppress(engine:rule) (exact match only) + * + * @param suppressSelector The selector from the suppress marker + * @param unsuppressSelector The selector from the active unsuppress + * @returns true if the suppress can hierarchically close this unsuppress + */ +function canSuppressCloseUnsuppress(suppressSelector: string, unsuppressSelector: string): boolean { + // Exact match always works + if (suppressSelector === unsuppressSelector) { + return true; + } + + const suppressParsed = parseSelector(suppressSelector); + const unsuppressParsed = parseSelector(unsuppressSelector); + + // suppress(all) closes ANY unsuppress + if (suppressParsed.scope === 'all') { + return true; + } + + // suppress(engine) closes unsuppress(engine) or unsuppress(engine:rule) from same engine + if (suppressParsed.scope === 'engine') { + if (unsuppressParsed.scope === 'engine' && suppressParsed.engine === unsuppressParsed.engine) { + return true; + } + if (unsuppressParsed.scope === 'specific' && suppressParsed.engine === unsuppressParsed.engine) { + return true; + } + } + + // suppress(engine:rule) only closes exact match (handled above) + return false; +} + +/** + * Converts a list of suppression markers into suppression ranges + * This processes suppress/unsuppress markers to create overlapping ranges with specificity-based precedence + * + * Key behaviors: + * - unsuppress(all) ends all active suppressions + * - More specific unsuppress creates EXCEPTION (overlapping range), not ending: + * - unsuppress(engine) does NOT end suppress(all) - creates exception + * - unsuppress(engine:rule) does NOT end suppress(all) or suppress(engine) - creates exception + * - Broader suppress CAN override more specific unsuppress: + * - suppress(all) closes any unsuppress + * - suppress(engine) closes unsuppress(engine) or unsuppress(engine:rule) from same engine + * - The processor uses specificity rules to determine which range wins when checking violations + * + * @param markers Sorted list of markers (by line number) + * @param filePath The file path (for error reporting) + * @returns Array of SuppressionRange objects + */ +export function buildSuppressionRanges(markers: SuppressionMarker[], _filePath: string): SuppressionRange[] { + const ranges: SuppressionRange[] = []; + + // Track which rule selectors are currently active (suppressed or unsuppressed) + // Map: ruleSelector -> {isSuppressed, startLine} + const activeStates = new Map(); + + for (const marker of markers) { + if (marker.type === 'suppress') { + // Check if already suppressed (no-op if so) + const currentState = activeStates.get(marker.ruleSelector); + if (currentState && currentState.isSuppressed) { + continue; // Already suppressed, skip + } + + // Check if this suppress should close any active unsuppressions hierarchically + // For example, suppress(regex) should close unsuppress(regex:AvoidOldApi) + const statesToEnd: string[] = []; + for (const [activeSelector, state] of activeStates.entries()) { + if (!state.isSuppressed && activeSelector !== marker.ruleSelector && + canSuppressCloseUnsuppress(marker.ruleSelector, activeSelector)) { + // Close this unsuppression range (but not for the same selector - handled below) + ranges.push({ + startLine: state.startLine, + endLine: marker.lineNumber - 1, + ruleSelector: activeSelector, + isSuppressed: false + }); + statesToEnd.push(activeSelector); + } + } + + // Remove closed states + for (const selector of statesToEnd) { + activeStates.delete(selector); + } + + // If there was an unsuppression active for this exact selector, close it + if (currentState && !currentState.isSuppressed) { + ranges.push({ + startLine: currentState.startLine, + endLine: marker.lineNumber - 1, + ruleSelector: marker.ruleSelector, + isSuppressed: false + }); + } + + // Start new suppression + activeStates.set(marker.ruleSelector, { + isSuppressed: true, + startLine: marker.lineNumber + }); + } else if (marker.type === 'unsuppress') { + // End active suppression ranges that match hierarchically + const statesToEnd: string[] = []; + + for (const [suppressSelector, state] of activeStates.entries()) { + if (state.isSuppressed && canUnsuppressEndSuppress(suppressSelector, marker.ruleSelector)) { + // Create a suppression range from the suppress marker to the unsuppress marker (exclusive of unsuppress line) + ranges.push({ + startLine: state.startLine, + endLine: marker.lineNumber - 1, + ruleSelector: suppressSelector, + isSuppressed: true + }); + statesToEnd.push(suppressSelector); + } + } + + // Remove ended suppressions + for (const selector of statesToEnd) { + activeStates.delete(selector); + } + + // Now start an unsuppression range for this selector + // This creates the "exception" behavior - marking that this selector is explicitly unsuppressed + if (statesToEnd.length > 0 || marker.ruleSelector !== 'all') { + // Only create unsuppression range if we actually ended something, or if it's a specific selector + activeStates.set(marker.ruleSelector, { + isSuppressed: false, + startLine: marker.lineNumber + }); + } + } + } + + // Any remaining active states extend to the end of the file + for (const [ruleSelector, state] of activeStates.entries()) { + ranges.push({ + startLine: state.startLine, + endLine: undefined, + ruleSelector, + isSuppressed: state.isSuppressed + }); + } + + return ranges; +} + +/** + * Parses a file's content and builds complete suppression information + * @param fileContent The full content of the file as a string + * @param filePath The absolute path to the file + * @returns FileSuppressions object containing all suppression ranges for the file + */ +export function parseFileSuppressions(fileContent: string, filePath: string): FileSuppressions { + const markers = parseSuppressionMarkers(fileContent, filePath); + const ranges = buildSuppressionRanges(markers, filePath); + + return { + filePath, + ranges + }; +} diff --git a/packages/code-analyzer-core/src/suppressions/suppression-processor.ts b/packages/code-analyzer-core/src/suppressions/suppression-processor.ts new file mode 100644 index 00000000..86368e30 --- /dev/null +++ b/packages/code-analyzer-core/src/suppressions/suppression-processor.ts @@ -0,0 +1,287 @@ +/** + * Processes violations and filters out suppressed ones based on suppression markers + */ + +import { Violation } from '../results'; +import { FileSuppressions, SuppressionRange, SuppressionsMap } from './suppression-types'; +import { parseFileSuppressions } from './suppression-parser'; +import fs from 'node:fs'; +import { isBinaryFile } from 'isbinaryfile'; + +/** + * Checks if a file is a text file (not binary) + * Uses the isbinaryfile library (same as used in retire-js and regex engines) + * @param filePath Absolute path to the file + * @returns true if the file appears to be text, false if binary + */ +export async function isTextFile(filePath: string): Promise { + try { + return !(await isBinaryFile(filePath)); + } catch (_err) { + // If we can't read the file, assume it's not text + return false; + } +} + +/** + * Reads and parses suppression information from files that have violations + * Uses caching to avoid re-parsing the same file multiple times + * + * @param filePaths Set of absolute file paths to process + * @param suppressionsMap Map to store/cache suppression information + * @param logger Optional logger callback for error/warning messages + * @returns Updated suppressions map + */ +export async function extractSuppressionsFromFiles( + filePaths: Set, + suppressionsMap: SuppressionsMap = new Map(), + logger?: LoggerCallback +): Promise { + for (const filePath of filePaths) { + // Skip if already processed + if (suppressionsMap.has(filePath)) { + continue; + } + + // Skip non-text files (binary files) + if (!(await isTextFile(filePath))) { + // Store empty suppressions to mark as processed + suppressionsMap.set(filePath, { filePath, ranges: [] }); + continue; + } + + try { + // Read file content + const fileContent = fs.readFileSync(filePath, 'utf-8'); + + // Parse suppressions + const fileSuppressions = parseFileSuppressions(fileContent, filePath); + + // Cache the result + suppressionsMap.set(filePath, fileSuppressions); + } catch (err) { + // If we can't read the file, skip it and log the error + if (logger) { + const errorMsg = err instanceof Error ? err.message : String(err); + logger('error', `Failed to read file for suppression parsing: ${filePath}. Error: ${errorMsg}`); + } + // Store empty suppressions to mark as processed + suppressionsMap.set(filePath, { filePath, ranges: [] }); + } + } + + return suppressionsMap; +} + +/** + * Gets the specificity level of a rule selector + * More specific selectors override less specific ones + */ +function getSelectorSpecificity(selector: string): number { + if (selector === 'all') { + return 1; // Least specific + } + if (selector.includes(':')) { + return 3; // Most specific (engine:rule) + } + return 2; // Medium specific (engine only) +} + +/** + * Checks if a violation should be suppressed based on suppression ranges + * Uses hierarchical matching with specificity rules + * + * @param violation The violation to check + * @param fileSuppressions Suppression information for the file + * @returns true if the violation should be suppressed, false otherwise + */ +export function isViolationSuppressed(violation: Violation, fileSuppressions: FileSuppressions | undefined): boolean { + if (!fileSuppressions || fileSuppressions.ranges.length === 0) { + return false; + } + + const primaryLocation = violation.getPrimaryLocation(); + const file = primaryLocation.getFile(); + const startLine = primaryLocation.getStartLine(); + + // If violation has no file or start line, we can't suppress it + if (!file || startLine === undefined || startLine === null) { + return false; + } + + const endLine = primaryLocation.getEndLine(); + + // Find all ranges that apply to this violation + const applicableRanges = fileSuppressions.ranges.filter(range => + doesRangeOverlapViolation(range, startLine, endLine) && + doesRuleSelectorMatch(range.ruleSelector, violation) + ); + + if (applicableRanges.length === 0) { + return false; // No applicable ranges + } + + // Apply specificity rules: find the most specific applicable range + // If there are multiple ranges at the same specificity, take the one with the highest startLine (most recent) + let mostSpecificRange: SuppressionRange | null = null; + let highestSpecificity = 0; + + for (const range of applicableRanges) { + const specificity = getSelectorSpecificity(range.ruleSelector); + + if (specificity > highestSpecificity || + (specificity === highestSpecificity && mostSpecificRange && + range.startLine > mostSpecificRange.startLine)) { + mostSpecificRange = range; + highestSpecificity = specificity; + } + } + + // Return the isSuppressed value of the most specific range + return mostSpecificRange ? mostSpecificRange.isSuppressed : false; +} + +/** + * Checks if a suppression range overlaps with a violation's location + * According to the spec: "if any part of the violation's primary location range overlaps, + * then the range applies" + * + * @param range The suppression range + * @param violationStartLine The start line of the violation + * @param violationEndLine The end line of the violation (may be undefined for single-line violations) + * @returns true if the range overlaps with the violation + */ +function doesRangeOverlapViolation( + range: SuppressionRange, + violationStartLine: number, + violationEndLine: number | undefined +): boolean { + const violationEnd = violationEndLine ?? violationStartLine; + const rangeEnd = range.endLine ?? Number.MAX_SAFE_INTEGER; + + // Check for overlap: violation overlaps if any part of it is within the suppression range + // Overlap occurs when: violationStart <= rangeEnd AND violationEnd >= rangeStart + return violationStartLine <= rangeEnd && violationEnd >= range.startLine; +} + +/** + * Checks if a rule selector matches a violation's rule + * + * Rule selector matching rules: + * - "all" matches all rules + * - "engineName" matches all rules from that engine (e.g., "pmd", "eslint") + * - "engineName:ruleName" matches specific rule + * - "engineName:(severity1,severity2)" matches rules from engine with those severities + * + * @param ruleSelector The rule selector from suppression marker + * @param violation The violation to check + * @returns true if the selector matches this violation's rule + */ +function doesRuleSelectorMatch(ruleSelector: string, violation: Violation): boolean { + // "all" matches everything + if (ruleSelector === 'all') { + return true; + } + + const rule = violation.getRule(); + const engineName = rule.getEngineName(); + const ruleName = rule.getName(); + const severity = rule.getSeverityLevel(); + + // Check for exact "engineName:ruleName" match + const fullRuleName = `${engineName}:${ruleName}`; + if (ruleSelector === fullRuleName) { + return true; + } + + // Check for engine-only match (e.g., "pmd" matches all pmd rules) + if (ruleSelector === engineName) { + return true; + } + + // Check for severity-based match (e.g., "eslint:(3,4)") + const severityPattern = /^([^:]+):\(([^)]+)\)$/; + const severityMatch = ruleSelector.match(severityPattern); + if (severityMatch) { + const selectorEngine = severityMatch[1]; + const severitiesStr = severityMatch[2]; + + if (selectorEngine === engineName) { + // Parse severities: "3,4" -> [3, 4] + const severities = severitiesStr.split(',').map(s => parseInt(s.trim(), 10)); + if (severities.includes(severity)) { + return true; + } + } + } + + return false; +} + +/** + * Filters violations based on suppression information + * This is the main entry point for the post-processing step + * + * @param violations Array of all violations + * @param suppressionsMap Map of file paths to their suppression information + * @returns Filtered array of violations (those that are not suppressed) + */ +export function filterSuppressedViolations( + violations: Violation[], + suppressionsMap: SuppressionsMap +): Violation[] { + return violations.filter(violation => { + const primaryLocation = violation.getPrimaryLocation(); + const file = primaryLocation.getFile(); + + if (!file) { + // No file location, can't suppress + return true; + } + + const fileSuppressions = suppressionsMap.get(file); + const suppressed = isViolationSuppressed(violation, fileSuppressions); + + return !suppressed; // Keep violations that are NOT suppressed + }); +} + +/** + * Logger callback type for suppression processing + */ +export type LoggerCallback = (level: 'error' | 'warn' | 'debug', message: string) => void; + +/** + * Main function to process violations and apply suppressions + * This is called after all engines have returned results + * + * @param violations Array of all violations from all engines + * @param logger Optional logger callback for error/warning messages + * @returns Filtered violations with suppressions applied + */ +export async function processSuppressions(violations: Violation[], logger?: LoggerCallback): Promise { + if (violations.length === 0) { + return violations; + } + + // Extract unique file paths from violations + const filePaths = new Set(); + for (const violation of violations) { + const primaryLocation = violation.getPrimaryLocation(); + const file = primaryLocation.getFile(); + if (file) { + filePaths.add(file); + } + } + + if (filePaths.size === 0) { + // No files with violations + return violations; + } + + // Parse suppression information from files + const suppressionsMap = await extractSuppressionsFromFiles(filePaths, new Map(), logger); + + // Filter violations + return filterSuppressedViolations(violations, suppressionsMap); +} diff --git a/packages/code-analyzer-core/src/suppressions/suppression-types.ts b/packages/code-analyzer-core/src/suppressions/suppression-types.ts new file mode 100644 index 00000000..4e9a16d0 --- /dev/null +++ b/packages/code-analyzer-core/src/suppressions/suppression-types.ts @@ -0,0 +1,56 @@ +/** + * Types and interfaces for the suppression system + */ + +/** + * Represents a rule selector that can be used in suppression markers + * Examples: "all", "pmd:ApexCrudViolation", "eslint:(3,4)", "regex" + */ +export type RuleSelector = string; + +/** + * Represents a suppression marker found in source code + */ +export interface SuppressionMarker { + /** The type of marker (suppress or unsuppress) */ + type: 'suppress' | 'unsuppress'; + + /** The rule selector specified in the marker */ + ruleSelector: RuleSelector; + + /** The line number where the marker was found (1-indexed) */ + lineNumber: number; +} + +/** + * Represents a range of lines where specific rules are suppressed or unsuppressed + */ +export interface SuppressionRange { + /** The starting line number (1-indexed, inclusive) */ + startLine: number; + + /** The ending line number (1-indexed, inclusive). undefined means end of file */ + endLine: number | undefined; + + /** The rule selector that is affected in this range */ + ruleSelector: RuleSelector; + + /** Whether this is a suppression (true) or unsuppression/exception (false) */ + isSuppressed: boolean; +} + +/** + * Contains all suppression information for a single file + */ +export interface FileSuppressions { + /** The absolute path to the file */ + filePath: string; + + /** List of all suppression and unsuppression ranges in the file */ + ranges: SuppressionRange[]; +} + +/** + * Map of file paths to their suppression information + */ +export type SuppressionsMap = Map; diff --git a/packages/code-analyzer-core/test/config.test.ts b/packages/code-analyzer-core/test/config.test.ts index 07ce276d..b5dfe4ce 100644 --- a/packages/code-analyzer-core/test/config.test.ts +++ b/packages/code-analyzer-core/test/config.test.ts @@ -178,7 +178,7 @@ describe("Tests for creating and accessing configuration values", () => { it("When top level config has an unknown key, then we error", () => { expect(() => CodeAnalyzerConfig.fromObject({doesNotExist: 3})).toThrow( getMessageFromCatalog(SHARED_MESSAGE_CATALOG,'ConfigObjectContainsInvalidKey','', 'doesNotExist', - '["config_root","engines","ignores","log_folder","log_level","rules"]')); + '["config_root","engines","ignores","log_folder","log_level","rules","suppressions"]')); }); it("When engines value is not an object then we throw an error", () => { diff --git a/packages/code-analyzer-core/test/suppressions-integration.test.ts b/packages/code-analyzer-core/test/suppressions-integration.test.ts new file mode 100644 index 00000000..af91003b --- /dev/null +++ b/packages/code-analyzer-core/test/suppressions-integration.test.ts @@ -0,0 +1,393 @@ +/** + * Integration tests for suppression markers with actual test files + * Tests end-to-end suppression behavior with files containing markers + */ + +import * as path from 'node:path'; +import { processSuppressions } from '../src/suppressions/suppression-processor'; +import { Violation } from '../src/results'; +import { Rule } from '../src/rules'; +import { SeverityLevel } from '@salesforce/code-analyzer-engine-api'; + +// Mock implementations +class MockRule implements Rule { + constructor( + private engineName: string, + private name: string, + private severityLevel: SeverityLevel + ) {} + + getEngineName(): string { return this.engineName; } + getName(): string { return this.name; } + getSeverityLevel(): SeverityLevel { return this.severityLevel; } + getTags(): string[] { return []; } + getDescription(): string { return ''; } + getResourceUrls(): string[] { return []; } +} + +class MockCodeLocation { + constructor( + private file: string, + private startLine: number, + private endLine?: number + ) {} + + getFile(): string { return this.file; } + getStartLine(): number { return this.startLine; } + getEndLine(): number | undefined { return this.endLine; } + getStartColumn(): number { return 1; } + getEndColumn(): number | undefined { return undefined; } + getComment(): string | undefined { return undefined; } +} + +class MockViolation implements Violation { + constructor( + private rule: Rule, + private message: string, + private primaryLocation: MockCodeLocation + ) {} + + getRule(): Rule { return this.rule; } + getMessage(): string { return this.message; } + getPrimaryLocation(): MockCodeLocation { return this.primaryLocation; } + getCodeLocations(): MockCodeLocation[] { return [this.primaryLocation]; } + getPrimaryLocationIndex(): number { return 0; } + getResourceUrls(): string[] { return []; } +} + +describe('Suppression Markers Integration Tests', () => { + const testDataDir = path.resolve(__dirname, 'test-data', 'suppression-markers'); + + describe('suppress(all) marker', () => { + it('should suppress all violations after the marker', async () => { + const filePath = path.join(testDataDir, 'file-with-suppress-all.js'); + + // Create violations at different lines + const violations: Violation[] = [ + new MockViolation( + new MockRule('eslint', 'no-magic-numbers', SeverityLevel.Moderate), + 'No magic numbers', + new MockCodeLocation(filePath, 7) // After suppress(all) on line 6 + ), + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 8) // After suppress(all) + ), + new MockViolation( + new MockRule('eslint', 'no-eval', SeverityLevel.Critical), + 'No eval', + new MockCodeLocation(filePath, 9) // After suppress(all) + ) + ]; + + const result = await processSuppressions(violations); + + // All violations should be suppressed + expect(result.length).toBe(0); + }); + }); + + describe('suppress(engine) marker', () => { + it('should suppress only violations from specified engine', async () => { + const filePath = path.join(testDataDir, 'file-with-suppress-engine.js'); + + const violations: Violation[] = [ + new MockViolation( + new MockRule('eslint', 'no-magic-numbers', SeverityLevel.Moderate), + 'No magic numbers', + new MockCodeLocation(filePath, 6) // Before suppress(eslint) on line 7 + ), + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 9) // After suppress(eslint) + ), + new MockViolation( + new MockRule('pmd', 'UnusedVariable', SeverityLevel.Moderate), + 'Unused variable', + new MockCodeLocation(filePath, 9) // After suppress(eslint), but pmd not suppressed + ) + ]; + + const result = await processSuppressions(violations); + + // Only the eslint violation before marker and pmd violation should remain + expect(result.length).toBe(2); + expect(result[0].getRule().getName()).toBe('no-magic-numbers'); // Before marker + expect(result[1].getRule().getEngineName()).toBe('pmd'); // Different engine + }); + }); + + describe('suppress(engine:rule) marker', () => { + it('should suppress only specific rule violations', async () => { + const filePath = path.join(testDataDir, 'file-with-suppress-specific-rule.js'); + + const violations: Violation[] = [ + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 6) // Before suppress marker + ), + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 9) // After suppress(eslint:no-console) + ), + new MockViolation( + new MockRule('eslint', 'no-magic-numbers', SeverityLevel.Moderate), + 'No magic numbers', + new MockCodeLocation(filePath, 10) // After suppress, but different rule + ), + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 11) // After suppress(eslint:no-console) + ) + ]; + + const result = await processSuppressions(violations); + + // Should have: line 6 (before marker) and line 10 (different rule) + expect(result.length).toBe(2); + expect(result[0].getPrimaryLocation().getStartLine()).toBe(6); + expect(result[1].getRule().getName()).toBe('no-magic-numbers'); + }); + }); + + describe('unsuppress marker', () => { + it('should re-enable violation reporting after unsuppress', async () => { + const filePath = path.join(testDataDir, 'file-with-unsuppress.js'); + + const violations: Violation[] = [ + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 7) // After suppress, before unsuppress + ), + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 10) // After unsuppress + ) + ]; + + const result = await processSuppressions(violations); + + // Line 7 should be suppressed, line 10 should NOT be suppressed + expect(result.length).toBe(1); + expect(result[0].getPrimaryLocation().getStartLine()).toBe(10); + }); + }); + + describe('suppress by severity', () => { + it('should suppress violations with specified severities', async () => { + const filePath = path.join(testDataDir, 'file-with-suppress-severity.js'); + + const violations: Violation[] = [ + new MockViolation( + new MockRule('eslint', 'no-magic-numbers', SeverityLevel.Moderate), // Severity 3 + 'No magic numbers', + new MockCodeLocation(filePath, 8) + ), + new MockViolation( + new MockRule('eslint', 'some-low-rule', SeverityLevel.Low), // Severity 4 + 'Low severity', + new MockCodeLocation(filePath, 9) + ), + new MockViolation( + new MockRule('eslint', 'no-eval', SeverityLevel.High), // Severity 2 + 'No eval', + new MockCodeLocation(filePath, 10) + ) + ]; + + const result = await processSuppressions(violations); + + // Only severity 2 (High) should remain (severities 3 and 4 suppressed) + expect(result.length).toBe(1); + expect(result[0].getRule().getSeverityLevel()).toBe(SeverityLevel.High); + }); + }); + + describe('case-insensitive markers', () => { + it('should work with markers in any case', async () => { + const filePath = path.join(testDataDir, 'file-with-case-insensitive.js'); + + const violations: Violation[] = [ + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 7) // After Code-Analyzer-Suppress + ), + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 10) // After CODE-ANALYZER-UNSUPPRESS + ), + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 13) // After code-analyzer-SUPPRESS + ) + ]; + + const result = await processSuppressions(violations); + + // Line 10 should remain (after unsuppress), others suppressed + expect(result.length).toBe(1); + expect(result[0].getPrimaryLocation().getStartLine()).toBe(10); + }); + }); + + describe('hierarchical suppressions with specificity', () => { + it('should apply specificity rules correctly', async () => { + const filePath = path.join(testDataDir, 'file-with-hierarchical-suppressions.js'); + + const violations: Violation[] = [ + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 7) // After suppress(all) + ), + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 10) // After unsuppress(eslint:no-console) - higher specificity + ), + new MockViolation( + new MockRule('eslint', 'no-magic-numbers', SeverityLevel.Moderate), + 'No magic numbers', + new MockCodeLocation(filePath, 12) // After unsuppress(eslint:no-console), still suppressed by 'all' + ) + ]; + + const result = await processSuppressions(violations); + + // Line 10 should remain (unsuppress with higher specificity wins) + // Lines 7 and 12 should be suppressed (still covered by 'all') + expect(result.length).toBe(1); + expect(result[0].getPrimaryLocation().getStartLine()).toBe(10); + }); + }); + + describe('file without markers', () => { + it('should report all violations when no markers present', async () => { + const filePath = path.join(testDataDir, 'file-without-markers.js'); + + const violations: Violation[] = [ + new MockViolation( + new MockRule('eslint', 'no-magic-numbers', SeverityLevel.Moderate), + 'No magic numbers', + new MockCodeLocation(filePath, 6) + ), + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 7) + ), + new MockViolation( + new MockRule('eslint', 'no-eval', SeverityLevel.Critical), + 'No eval', + new MockCodeLocation(filePath, 8) + ) + ]; + + const result = await processSuppressions(violations); + + // All violations should remain (no suppressions) + expect(result.length).toBe(3); + }); + }); + + describe('multiple files with mixed suppressions', () => { + it('should handle suppressions independently per file', async () => { + const file1 = path.join(testDataDir, 'file-with-suppress-all.js'); + const file2 = path.join(testDataDir, 'file-without-markers.js'); + + const violations: Violation[] = [ + // File 1: suppress(all) - should be suppressed + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(file1, 8) + ), + // File 2: no markers - should remain + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(file2, 7) + ) + ]; + + const result = await processSuppressions(violations); + + // Only file2 violation should remain + expect(result.length).toBe(1); + expect(result[0].getPrimaryLocation().getFile()).toBe(file2); + }); + }); + + describe('violations before marker', () => { + it('should not suppress violations that occur before the marker', async () => { + const filePath = path.join(testDataDir, 'file-with-suppress-engine.js'); + + const violations: Violation[] = [ + new MockViolation( + new MockRule('eslint', 'no-magic-numbers', SeverityLevel.Moderate), + 'No magic numbers', + new MockCodeLocation(filePath, 6) // Before suppress(eslint) on line 7 + ), + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 9) // After suppress(eslint) + ) + ]; + + const result = await processSuppressions(violations); + + // Violation on line 6 should remain (before marker) + expect(result.length).toBe(1); + expect(result[0].getPrimaryLocation().getStartLine()).toBe(6); + }); + }); + + describe('unsuppress without suppress', () => { + it('should have no effect when unsuppress markers exist without corresponding suppress markers', async () => { + const filePath = path.join(testDataDir, 'file-with-only-unsuppress.js'); + + const violations: Violation[] = [ + new MockViolation( + new MockRule('eslint', 'no-magic-numbers', SeverityLevel.Moderate), + 'No magic numbers', + new MockCodeLocation(filePath, 7) // Before unsuppress marker + ), + new MockViolation( + new MockRule('eslint', 'no-console', SeverityLevel.High), + 'No console', + new MockCodeLocation(filePath, 10) // After unsuppress(eslint:no-console) on line 9 + ), + new MockViolation( + new MockRule('eslint', 'no-magic-numbers', SeverityLevel.Moderate), + 'No magic numbers', + new MockCodeLocation(filePath, 12) // After first unsuppress + ), + new MockViolation( + new MockRule('eslint', 'no-eval', SeverityLevel.Critical), + 'No eval', + new MockCodeLocation(filePath, 15) // After unsuppress(all) on line 14 + ) + ]; + + const result = await processSuppressions(violations); + + // All violations should remain - unsuppress without suppress has no effect + expect(result.length).toBe(4); + expect(result[0].getPrimaryLocation().getStartLine()).toBe(7); + expect(result[1].getPrimaryLocation().getStartLine()).toBe(10); + expect(result[2].getPrimaryLocation().getStartLine()).toBe(12); + expect(result[3].getPrimaryLocation().getStartLine()).toBe(15); + }); + }); +}); diff --git a/packages/code-analyzer-core/test/suppressions/suppression-parser.test.ts b/packages/code-analyzer-core/test/suppressions/suppression-parser.test.ts new file mode 100644 index 00000000..b53d65cc --- /dev/null +++ b/packages/code-analyzer-core/test/suppressions/suppression-parser.test.ts @@ -0,0 +1,530 @@ +/** + * Unit tests for suppression-parser.ts + */ + +import { describe, it, expect } from '@jest/globals'; +import { + parseSuppressionMarkers, + buildSuppressionRanges, + parseFileSuppressions +} from '../../src/suppressions/suppression-parser'; +import { SuppressionMarker, SuppressionRange } from '../../src/suppressions/suppression-types'; + +describe('parseSuppressionMarkers', () => { + it('should find suppress marker with explicit rule selector', () => { + const content = '// code-analyzer-suppress(pmd:ApexCrudViolation)'; + const markers = parseSuppressionMarkers(content, '/test/file.apex'); + + expect(markers).toHaveLength(1); + expect(markers[0]).toEqual({ + type: 'suppress', + ruleSelector: 'pmd:ApexCrudViolation', + lineNumber: 1 + }); + }); + + it('should find suppress marker with "all" as default when no rule selector', () => { + const content = '// code-analyzer-suppress()'; + const markers = parseSuppressionMarkers(content, '/test/file.js'); + + expect(markers).toHaveLength(1); + expect(markers[0]).toEqual({ + type: 'suppress', + ruleSelector: 'all', + lineNumber: 1 + }); + }); + + it('should find suppress marker without parentheses as "all"', () => { + const content = '// code-analyzer-suppress'; + const markers = parseSuppressionMarkers(content, '/test/file.js'); + + expect(markers).toHaveLength(1); + expect(markers[0]).toEqual({ + type: 'suppress', + ruleSelector: 'all', + lineNumber: 1 + }); + }); + + it('should find unsuppress marker with explicit rule selector', () => { + const content = '// code-analyzer-unsuppress(regex:AvoidOldSalesforceApiVersions)'; + const markers = parseSuppressionMarkers(content, '/test/file.xml'); + + expect(markers).toHaveLength(1); + expect(markers[0]).toEqual({ + type: 'unsuppress', + ruleSelector: 'regex:AvoidOldSalesforceApiVersions', + lineNumber: 1 + }); + }); + + it('should find multiple markers on different lines', () => { + const content = `// code-analyzer-suppress(pmd) +public class Test { + // code-analyzer-unsuppress(pmd) +}`; + const markers = parseSuppressionMarkers(content, '/test/file.apex'); + + expect(markers).toHaveLength(2); + expect(markers[0]).toEqual({ + type: 'suppress', + ruleSelector: 'pmd', + lineNumber: 1 + }); + expect(markers[1]).toEqual({ + type: 'unsuppress', + ruleSelector: 'pmd', + lineNumber: 3 + }); + }); + + it('should find markers in any part of a line, not just comments', () => { + const content = '"fakeProp": "code-analyzer-suppress(rule1) some text"'; + const markers = parseSuppressionMarkers(content, '/test/file.json'); + + expect(markers).toHaveLength(1); + expect(markers[0].ruleSelector).toBe('rule1'); + }); + + it('should handle multiple markers on the same line', () => { + const content = '// code-analyzer-suppress(rule1) and code-analyzer-suppress(rule2)'; + const markers = parseSuppressionMarkers(content, '/test/file.js'); + + expect(markers).toHaveLength(2); + expect(markers[0].ruleSelector).toBe('rule1'); + expect(markers[1].ruleSelector).toBe('rule2'); + }); + + it('should trim whitespace from rule selectors', () => { + const content = '// code-analyzer-suppress( pmd:SomeRule )'; + const markers = parseSuppressionMarkers(content, '/test/file.apex'); + + expect(markers).toHaveLength(1); + expect(markers[0].ruleSelector).toBe('pmd:SomeRule'); + }); + + it('should handle empty file', () => { + const content = ''; + const markers = parseSuppressionMarkers(content, '/test/file.js'); + + expect(markers).toHaveLength(0); + }); + + it('should handle file with no markers', () => { + const content = `public class Test { + private String name; +}`; + const markers = parseSuppressionMarkers(content, '/test/file.apex'); + + expect(markers).toHaveLength(0); + }); + + it('should handle complex rule selectors with special characters', () => { + const content = '// code-analyzer-suppress(eslint:(3,4))'; + const markers = parseSuppressionMarkers(content, '/test/file.js'); + + expect(markers).toHaveLength(1); + expect(markers[0].ruleSelector).toBe('eslint:(3,4)'); + }); + + it('should be case-insensitive for marker names', () => { + const content = `// Code-Analyzer-Suppress(pmd) +// CODE-ANALYZER-UNSUPPRESS(pmd) +// code-ANALYZER-suppress(eslint)`; + const markers = parseSuppressionMarkers(content, '/test/file.js'); + + expect(markers).toHaveLength(3); + expect(markers[0]).toEqual({ + type: 'suppress', + ruleSelector: 'pmd', + lineNumber: 1 + }); + expect(markers[1]).toEqual({ + type: 'unsuppress', + ruleSelector: 'pmd', + lineNumber: 2 + }); + expect(markers[2]).toEqual({ + type: 'suppress', + ruleSelector: 'eslint', + lineNumber: 3 + }); + }); +}); + +describe('buildSuppressionRanges', () => { + it('should create suppression and unsuppression ranges', () => { + const markers: SuppressionMarker[] = [ + { type: 'suppress', ruleSelector: 'pmd', lineNumber: 5 }, + { type: 'unsuppress', ruleSelector: 'pmd', lineNumber: 10 } + ]; + + const ranges = buildSuppressionRanges(markers, '/test/file.apex'); + + expect(ranges).toHaveLength(2); + // First range: suppressed from line 5-9 + expect(ranges[0]).toEqual({ + startLine: 5, + endLine: 9, + ruleSelector: 'pmd', + isSuppressed: true + }); + // Second range: unsuppressed from line 10 onwards + expect(ranges[1]).toEqual({ + startLine: 10, + endLine: undefined, + ruleSelector: 'pmd', + isSuppressed: false + }); + }); + + it('should create range to end of file when no unsuppress', () => { + const markers: SuppressionMarker[] = [ + { type: 'suppress', ruleSelector: 'all', lineNumber: 3 } + ]; + + const ranges = buildSuppressionRanges(markers, '/test/file.js'); + + expect(ranges).toHaveLength(1); + expect(ranges[0]).toEqual({ + startLine: 3, + endLine: undefined, + ruleSelector: 'all', + isSuppressed: true + }); + }); + + it('should handle multiple suppress/unsuppress pairs for same rule', () => { + const markers: SuppressionMarker[] = [ + { type: 'suppress', ruleSelector: 'pmd', lineNumber: 5 }, + { type: 'unsuppress', ruleSelector: 'pmd', lineNumber: 10 }, + { type: 'suppress', ruleSelector: 'pmd', lineNumber: 15 }, + { type: 'unsuppress', ruleSelector: 'pmd', lineNumber: 20 } + ]; + + const ranges = buildSuppressionRanges(markers, '/test/file.apex'); + + expect(ranges).toHaveLength(4); + expect(ranges[0]).toEqual({ + startLine: 5, + endLine: 9, + ruleSelector: 'pmd', + isSuppressed: true + }); + expect(ranges[1]).toEqual({ + startLine: 10, + endLine: 14, + ruleSelector: 'pmd', + isSuppressed: false + }); + expect(ranges[2]).toEqual({ + startLine: 15, + endLine: 19, + ruleSelector: 'pmd', + isSuppressed: true + }); + expect(ranges[3]).toEqual({ + startLine: 20, + endLine: undefined, + ruleSelector: 'pmd', + isSuppressed: false + }); + }); + + it('should handle different rule selectors independently', () => { + const markers: SuppressionMarker[] = [ + { type: 'suppress', ruleSelector: 'pmd', lineNumber: 5 }, + { type: 'suppress', ruleSelector: 'eslint', lineNumber: 7 }, + { type: 'unsuppress', ruleSelector: 'pmd', lineNumber: 10 }, + { type: 'unsuppress', ruleSelector: 'eslint', lineNumber: 12 } + ]; + + const ranges = buildSuppressionRanges(markers, '/test/file.js'); + + expect(ranges).toHaveLength(4); + expect(ranges[0]).toEqual({ + startLine: 5, + endLine: 9, + ruleSelector: 'pmd', + isSuppressed: true + }); + expect(ranges[1]).toEqual({ + startLine: 7, + endLine: 11, + ruleSelector: 'eslint', + isSuppressed: true + }); + expect(ranges[2]).toEqual({ + startLine: 10, + endLine: undefined, + ruleSelector: 'pmd', + isSuppressed: false + }); + expect(ranges[3]).toEqual({ + startLine: 12, + endLine: undefined, + ruleSelector: 'eslint', + isSuppressed: false + }); + }); + + it('should handle unsuppress without matching suppress', () => { + const markers: SuppressionMarker[] = [ + { type: 'unsuppress', ruleSelector: 'pmd', lineNumber: 5 } + ]; + + const ranges = buildSuppressionRanges(markers, '/test/file.apex'); + + expect(ranges).toHaveLength(1); + expect(ranges[0]).toEqual({ + startLine: 5, + endLine: undefined, + ruleSelector: 'pmd', + isSuppressed: false + }); + }); + + it('should handle nested suppress markers (second suppress is no-op)', () => { + const markers: SuppressionMarker[] = [ + { type: 'suppress', ruleSelector: 'all', lineNumber: 5 }, + { type: 'suppress', ruleSelector: 'all', lineNumber: 7 }, // This is ignored + { type: 'unsuppress', ruleSelector: 'all', lineNumber: 10 } + ]; + + const ranges = buildSuppressionRanges(markers, '/test/file.js'); + + expect(ranges).toHaveLength(2); + expect(ranges[0]).toEqual({ + startLine: 5, + endLine: 9, + ruleSelector: 'all', + isSuppressed: true + }); + expect(ranges[1]).toEqual({ + startLine: 10, + endLine: undefined, + ruleSelector: 'all', + isSuppressed: false + }); + }); + + it('should handle empty markers array', () => { + const markers: SuppressionMarker[] = []; + + const ranges = buildSuppressionRanges(markers, '/test/file.js'); + + expect(ranges).toHaveLength(0); + }); + + it('should handle unsuppress(engine) ending suppress(engine:rule)', () => { + // Test symmetrical case: broader unsuppress ends more specific suppress + const markers: SuppressionMarker[] = [ + { type: 'suppress', ruleSelector: 'eslint:no-unused-vars', lineNumber: 2 }, + { type: 'unsuppress', ruleSelector: 'eslint', lineNumber: 5 } + ]; + + const ranges = buildSuppressionRanges(markers, '/test/file.js'); + + expect(ranges).toHaveLength(2); + // suppress(eslint:no-unused-vars) should end at line 4 (before unsuppress(eslint)) + expect(ranges[0]).toEqual({ + startLine: 2, + endLine: 4, + ruleSelector: 'eslint:no-unused-vars', + isSuppressed: true + }); + // unsuppress(eslint) should start at line 5 + expect(ranges[1]).toEqual({ + startLine: 5, + endLine: undefined, + ruleSelector: 'eslint', + isSuppressed: false + }); + }); + + it('should handle suppress(engine) closing unsuppress(engine:rule)', () => { + // Test that broader suppress can close rule-based unsuppress (reverse of previous test) + const markers: SuppressionMarker[] = [ + { type: 'unsuppress', ruleSelector: 'eslint:no-unused-vars', lineNumber: 2 }, + { type: 'suppress', ruleSelector: 'eslint', lineNumber: 5 } + ]; + + const ranges = buildSuppressionRanges(markers, '/test/file.js'); + + expect(ranges).toHaveLength(2); + // unsuppress(eslint:no-unused-vars) should end at line 4 (closed by suppress(eslint)) + expect(ranges[0]).toEqual({ + startLine: 2, + endLine: 4, + ruleSelector: 'eslint:no-unused-vars', + isSuppressed: false + }); + // suppress(eslint) should start at line 5 + expect(ranges[1]).toEqual({ + startLine: 5, + endLine: undefined, + ruleSelector: 'eslint', + isSuppressed: true + }); + }); + + it('should handle suppress(engine) closing unsuppress(engine:(severity))', () => { + // Test that broader suppress can close severity-based unsuppress + const markers: SuppressionMarker[] = [ + { type: 'suppress', ruleSelector: 'all', lineNumber: 2 }, + { type: 'unsuppress', ruleSelector: 'eslint:(3)', lineNumber: 5 }, + { type: 'suppress', ruleSelector: 'eslint', lineNumber: 10 } + ]; + + const ranges = buildSuppressionRanges(markers, '/test/file.js'); + + // Expected ranges: + // 1. all suppressed [2-∞] (continues to EOF; unsuppress(eslint:(3)) doesn't end it) + // 2. eslint:(3) unsuppressed [5-9] (exception within all, closed by suppress(eslint)) + // 3. eslint suppressed [10-∞] (suppress(eslint) closes the unsuppress and starts new suppression) + + expect(ranges.length).toBe(3); + + const allRanges = ranges.filter(r => r.ruleSelector === 'all'); + const eslintSeverityRanges = ranges.filter(r => r.ruleSelector === 'eslint:(3)'); + const eslintRanges = ranges.filter(r => r.ruleSelector === 'eslint'); + + // suppress(all) should continue to EOF (unsuppress(eslint:(3)) doesn't end it) + expect(allRanges).toHaveLength(1); + expect(allRanges[0]).toEqual({ + startLine: 2, + endLine: undefined, + ruleSelector: 'all', + isSuppressed: true + }); + + // unsuppress(eslint:(3)) creates exception [5,9] (closed by suppress(eslint)) + expect(eslintSeverityRanges).toHaveLength(1); + expect(eslintSeverityRanges[0]).toEqual({ + startLine: 5, + endLine: 9, + ruleSelector: 'eslint:(3)', + isSuppressed: false + }); + + // suppress(eslint) starts at line 10 + expect(eslintRanges).toHaveLength(1); + expect(eslintRanges[0]).toEqual({ + startLine: 10, + endLine: undefined, + ruleSelector: 'eslint', + isSuppressed: true + }); + }); + + it('should handle complex XML example from spec with hierarchical suppressions', () => { + // From the spec: tests full hierarchical behavior with overlapping exception ranges + // Line 2: suppress(all) - suppress everything + // Line 4: unsuppress(regex:AvoidOldSalesforceApiVersions) - create EXCEPTION (does NOT end suppress(all)) + // Line 6: suppress(regex) - suppress regex rules (closes the unsuppress exception) + // Line 12: unsuppress(all) - end all suppressions + const markers: SuppressionMarker[] = [ + { type: 'suppress', ruleSelector: 'all', lineNumber: 2 }, + { type: 'unsuppress', ruleSelector: 'regex:AvoidOldSalesforceApiVersions', lineNumber: 4 }, + { type: 'suppress', ruleSelector: 'regex', lineNumber: 6 }, + { type: 'unsuppress', ruleSelector: 'all', lineNumber: 12 } + ]; + + const ranges = buildSuppressionRanges(markers, '/test/file.xml'); + + // Expected ranges (CORRECTED for overlapping exception behavior): + // 1. "all" suppressed [2-11] (continues until unsuppress(all) at line 12) + // 2. "regex:AvoidOldSalesforceApiVersions" unsuppressed [4-5] (exception, closed by suppress(regex)) + // 3. "regex" suppressed [6-11] (ends when unsuppress(all) happens) + // 4. "all" unsuppressed [12-∞] + + expect(ranges.length).toBeGreaterThanOrEqual(3); + + // Find the ranges for each selector + const allRanges = ranges.filter(r => r.ruleSelector === 'all'); + const regexAvoidRanges = ranges.filter(r => r.ruleSelector === 'regex:AvoidOldSalesforceApiVersions'); + const regexRanges = ranges.filter(r => r.ruleSelector === 'regex'); + + // "all" should have suppression [2,11] (continues despite unsuppress at line 4) and unsuppression [12, ∞] + expect(allRanges).toContainEqual({ + startLine: 2, + endLine: 11, + ruleSelector: 'all', + isSuppressed: true + }); + + expect(allRanges).toContainEqual({ + startLine: 12, + endLine: undefined, + ruleSelector: 'all', + isSuppressed: false + }); + + // "regex:AvoidOldSalesforceApiVersions" should be unsuppressed [4,5] + // closed by suppress(regex) at line 6 + expect(regexAvoidRanges).toContainEqual({ + startLine: 4, + endLine: 5, + ruleSelector: 'regex:AvoidOldSalesforceApiVersions', + isSuppressed: false + }); + + // "regex" should be suppressed [6,11] + expect(regexRanges).toContainEqual({ + startLine: 6, + endLine: 11, + ruleSelector: 'regex', + isSuppressed: true + }); + + // Expected behavior when checking violations (with specificity precedence): + // Line 3: any violation → SUPPRESSED by "all" [2,11] + // Line 5: regex:AvoidOldSalesforceApiVersions → NOT SUPPRESSED (unsuppressed [4,5] wins over all [2,11]) + // Line 5: pmd rule → SUPPRESSED by "all" [2,11] (all continues!) + // Line 7: regex:AvoidOldSalesforceApiVersions → SUPPRESSED by "regex" [6,11] (broader suppress closed the unsuppress) + // Line 7: any regex rule → SUPPRESSED by "regex" [6,11] + // Line 13: anything → NOT SUPPRESSED (unsuppress(all) [12,∞]) + }); +}); + +describe('parseFileSuppressions', () => { + it('should parse complete file with suppressions', () => { + const content = `public class Test { + // code-analyzer-suppress(pmd:ApexCrudViolation) + public List load(Set accountIds) { + List contacts = [SELECT Id FROM Contact]; + // code-analyzer-unsuppress(pmd:ApexCrudViolation) + return contacts; + } +}`; + const filePath = '/test/file.apex'; + const fileSuppressions = parseFileSuppressions(content, filePath); + + expect(fileSuppressions.filePath).toBe(filePath); + expect(fileSuppressions.ranges).toHaveLength(2); + expect(fileSuppressions.ranges[0]).toEqual({ + startLine: 2, + endLine: 4, + ruleSelector: 'pmd:ApexCrudViolation', + isSuppressed: true + }); + expect(fileSuppressions.ranges[1]).toEqual({ + startLine: 5, + endLine: undefined, + ruleSelector: 'pmd:ApexCrudViolation', + isSuppressed: false + }); + }); + + it('should handle file with no suppressions', () => { + const content = `public class Test { + private String name; +}`; + const filePath = '/test/file.apex'; + const fileSuppressions = parseFileSuppressions(content, filePath); + + expect(fileSuppressions.filePath).toBe(filePath); + expect(fileSuppressions.ranges).toHaveLength(0); + }); +}); diff --git a/packages/code-analyzer-core/test/suppressions/suppression-processor.test.ts b/packages/code-analyzer-core/test/suppressions/suppression-processor.test.ts new file mode 100644 index 00000000..28b32024 --- /dev/null +++ b/packages/code-analyzer-core/test/suppressions/suppression-processor.test.ts @@ -0,0 +1,810 @@ +/** + * Unit tests for suppression-processor.ts + */ + +import { describe, it, expect, jest } from '@jest/globals'; +import { + isViolationSuppressed, + filterSuppressedViolations +} from '../../src/suppressions/suppression-processor'; +import { FileSuppressions, SuppressionRange } from '../../src/suppressions/suppression-types'; +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 + ) {} + + getEngineName(): string { + return this.engineName; + } + + getName(): string { + return this.name; + } + + getSeverityLevel(): SeverityLevel { + return this.severityLevel; + } + + getTags(): string[] { + return []; + } + + 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 []; + } +} + +describe('isViolationSuppressed', () => { + describe('Basic overlap scenarios', () => { + it('should suppress violation when fully contained in suppression range', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 5, + endLine: 10, + ruleSelector: 'pmd', + isSuppressed: true + } + ] + }; + + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 7, 1, 7, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(true); + }); + + it('should suppress violation when partially overlapping suppression range (violation starts before)', () => { + // Spec: "If ANY part of the violation's primary location range has been suppressed" + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 6, + endLine: 8, + ruleSelector: 'all', + isSuppressed: true + } + ] + }; + + // Violation spans lines 5-7, suppression is 6-8 + // Lines 6-7 are suppressed → entire violation should be suppressed + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 5, 1, 7, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(true); + }); + + it('should suppress violation when partially overlapping suppression range (violation ends after)', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 5, + endLine: 7, + ruleSelector: 'all', + isSuppressed: true + } + ] + }; + + // Violation spans lines 6-10, suppression is 5-7 + // Lines 6-7 are suppressed → entire violation should be suppressed + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 6, 1, 10, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(true); + }); + + it('should suppress violation when suppression range is fully contained within violation', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 6, + endLine: 7, + ruleSelector: 'all', + isSuppressed: true + } + ] + }; + + // Violation spans lines 5-8, suppression is 6-7 + // Lines 6-7 are suppressed → entire violation should be suppressed + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 5, 1, 8, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(true); + }); + + it('should suppress violation when only a single line overlaps', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 5, + endLine: 10, + ruleSelector: 'all', + isSuppressed: true + } + ] + }; + + // Violation spans lines 10-15, suppression is 5-10 + // Line 10 overlaps → entire violation should be suppressed + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 10, 1, 15, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(true); + }); + + it('should NOT suppress violation when no overlap', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 5, + endLine: 10, + ruleSelector: 'all', + isSuppressed: true + } + ] + }; + + // Violation is on lines 15-20, suppression is 5-10 + // No overlap → should NOT be suppressed + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 15, 1, 20, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(false); + }); + }); + + describe('Multi-line violations', () => { + it('should handle multi-line violation spanning 10 lines', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 10, + endLine: 15, + ruleSelector: 'pmd', + isSuppressed: true + } + ] + }; + + // Violation spans lines 5-20, suppression is 10-15 + // Lines 10-15 are suppressed → entire violation should be suppressed + const violation = new MockViolation( + new MockRule('pmd', 'ComplexMethod'), + 'Method too complex', + new MockCodeLocation('/test/file.apex', 5, 1, 20, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(true); + }); + + it('should handle single-line violation (endLine undefined)', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 5, + endLine: 10, + ruleSelector: 'all', + isSuppressed: true + } + ] + }; + + // Single line violation at line 7 + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 7, 1, undefined, undefined) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(true); + }); + + it('should handle suppression range extending to end of file', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 5, + endLine: undefined, // End of file + ruleSelector: 'all', + isSuppressed: true + } + ] + }; + + // Violation at lines 100-105 + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 100, 1, 105, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(true); + }); + }); + + describe('Rule selector matching', () => { + it('should suppress when rule selector is "all"', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 5, + endLine: 10, + ruleSelector: 'all', + isSuppressed: true + } + ] + }; + + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 7, 1, 7, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(true); + }); + + it('should suppress when rule selector matches engine name', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 5, + endLine: 10, + ruleSelector: 'pmd', + isSuppressed: true + } + ] + }; + + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 7, 1, 7, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(true); + }); + + it('should suppress when rule selector matches engine:rule', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 5, + endLine: 10, + ruleSelector: 'pmd:ApexCrudViolation', + isSuppressed: true + } + ] + }; + + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 7, 1, 7, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(true); + }); + + it('should NOT suppress when rule selector does not match', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 5, + endLine: 10, + ruleSelector: 'eslint', + isSuppressed: true + } + ] + }; + + // PMD violation, but only eslint is suppressed + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 7, 1, 7, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(false); + }); + + it('should suppress when rule selector matches severity', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.js', + ranges: [ + { + startLine: 5, + endLine: 10, + ruleSelector: 'eslint:(3,4)', + isSuppressed: true + } + ] + }; + + // Violation with severity 3 + const violation = new MockViolation( + new MockRule('eslint', 'no-unused-vars', 3), + 'Unused variable', + new MockCodeLocation('/test/file.js', 7, 1, 7, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(true); + }); + + it('should NOT suppress when severity does not match', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.js', + ranges: [ + { + startLine: 5, + endLine: 10, + ruleSelector: 'eslint:(3,4)', + isSuppressed: true + } + ] + }; + + // Violation with severity 2 (not in the suppressed list) + const violation = new MockViolation( + new MockRule('eslint', 'no-unused-vars', 2), + 'Unused variable', + new MockCodeLocation('/test/file.js', 7, 1, 7, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(false); + }); + }); + + describe('Hierarchical suppressions with specificity', () => { + it('should use most specific suppression when multiple ranges overlap', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 1, + endLine: 20, + ruleSelector: 'all', + isSuppressed: true + }, + { + startLine: 5, + endLine: undefined, + ruleSelector: 'pmd', + isSuppressed: false // Unsuppressed - exception + } + ] + }; + + // PMD violation at line 10 + // "all" says suppress, "pmd" says don't suppress + // "pmd" is more specific (specificity 2) than "all" (specificity 1) + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 10, 1, 10, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(false); + }); + + it('should use most specific rule when engine:rule overrides engine', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 1, + endLine: 20, + ruleSelector: 'pmd', + isSuppressed: false // Unsuppressed + }, + { + startLine: 5, + endLine: 15, + ruleSelector: 'pmd:ApexCrudViolation', + isSuppressed: true // Re-suppressed + } + ] + }; + + // PMD:ApexCrudViolation violation at line 10 + // "pmd" says don't suppress (specificity 2) + // "pmd:ApexCrudViolation" says suppress (specificity 3 - more specific) + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 10, 1, 10, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(true); + }); + + it('should use most recent range when same specificity', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 1, + endLine: undefined, + ruleSelector: 'pmd', + isSuppressed: true + }, + { + startLine: 10, + endLine: undefined, + ruleSelector: 'pmd', + isSuppressed: false // Unsuppressed later + } + ] + }; + + // Violation at line 15 + // Both ranges have same specificity (2) + // Range with higher startLine (10) should win + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 15, 1, 15, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(false); + }); + + it('should handle complex XML example from spec', () => { + // Lines 2-3: all suppressed + // Lines 4-5: regex:AvoidOldSalesforceApiVersions unsuppressed (exception) + // Lines 6-11: regex suppressed (closes the exception) + // Lines 12+: all unsuppressed + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.xml', + ranges: [ + { + startLine: 2, + endLine: 3, + ruleSelector: 'all', + isSuppressed: true + }, + { + startLine: 4, + endLine: 5, // Closed by suppress(regex) at line 6 + ruleSelector: 'regex:AvoidOldSalesforceApiVersions', + isSuppressed: false + }, + { + startLine: 6, + endLine: 11, + ruleSelector: 'regex', + isSuppressed: true + }, + { + startLine: 12, + endLine: undefined, + ruleSelector: 'all', + isSuppressed: false + } + ] + }; + + // Line 3: PMD violation → suppressed by "all" + const violation1 = new MockViolation( + new MockRule('pmd', 'SomeRule'), + 'Some violation', + new MockCodeLocation('/test/file.xml', 3, 1, 3, 20) + ); + expect(isViolationSuppressed(violation1, fileSuppressions)).toBe(true); + + // Line 5: regex:AvoidOldSalesforceApiVersions → unsuppressed (exception) + const violation2 = new MockViolation( + new MockRule('regex', 'AvoidOldSalesforceApiVersions'), + 'Old API', + new MockCodeLocation('/test/file.xml', 5, 1, 5, 20) + ); + expect(isViolationSuppressed(violation2, fileSuppressions)).toBe(false); + + // Line 7: regex:AvoidOldSalesforceApiVersions → suppressed (regex overrides exception) + const violation3 = new MockViolation( + new MockRule('regex', 'AvoidOldSalesforceApiVersions'), + 'Old API', + new MockCodeLocation('/test/file.xml', 7, 1, 7, 20) + ); + expect(isViolationSuppressed(violation3, fileSuppressions)).toBe(true); + + // Line 13: anything → unsuppressed + const violation4 = new MockViolation( + new MockRule('pmd', 'SomeRule'), + 'Some violation', + new MockCodeLocation('/test/file.xml', 13, 1, 13, 20) + ); + expect(isViolationSuppressed(violation4, fileSuppressions)).toBe(false); + }); + }); + + describe('Edge cases', () => { + it('should return false when fileSuppressions is undefined', () => { + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 7, 1, 7, 20) + ); + + expect(isViolationSuppressed(violation, undefined)).toBe(false); + }); + + it('should return false when ranges array is empty', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [] + }; + + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 7, 1, 7, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(false); + }); + + it('should return false when violation has no file', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 5, + endLine: 10, + ruleSelector: 'all', + isSuppressed: true + } + ] + }; + + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation(undefined, 7, 1, 7, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(false); + }); + + it('should return false when violation has no start line', () => { + const fileSuppressions: FileSuppressions = { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 5, + endLine: 10, + ruleSelector: 'all', + isSuppressed: true + } + ] + }; + + const violation = new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', undefined, 1, 7, 20) + ); + + expect(isViolationSuppressed(violation, fileSuppressions)).toBe(false); + }); + }); +}); + +describe('filterSuppressedViolations', () => { + it('should filter out suppressed violations', () => { + const suppressionsMap = new Map(); + suppressionsMap.set('/test/file.apex', { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 5, + endLine: 10, + ruleSelector: 'pmd', + isSuppressed: true + } + ] + }); + + const violations = [ + new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 7, 1, 7, 20) + ), + new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 15, 1, 15, 20) + ) + ]; + + const filtered = filterSuppressedViolations(violations, suppressionsMap); + + expect(filtered).toHaveLength(1); + expect(filtered[0].getPrimaryLocation().getStartLine()).toBe(15); + }); + + it('should keep all violations when none are suppressed', () => { + const suppressionsMap = new Map(); + suppressionsMap.set('/test/file.apex', { + filePath: '/test/file.apex', + ranges: [ + { + startLine: 5, + endLine: 10, + ruleSelector: 'eslint', + isSuppressed: true + } + ] + }); + + const violations = [ + new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 7, 1, 7, 20) + ), + new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file.apex', 15, 1, 15, 20) + ) + ]; + + const filtered = filterSuppressedViolations(violations, suppressionsMap); + + expect(filtered).toHaveLength(2); + }); + + it('should handle empty violations array', () => { + const suppressionsMap = new Map(); + + const filtered = filterSuppressedViolations([], suppressionsMap); + + expect(filtered).toHaveLength(0); + }); + + it('should handle violations from different files', () => { + const suppressionsMap = new Map(); + suppressionsMap.set('/test/file1.apex', { + filePath: '/test/file1.apex', + ranges: [ + { + startLine: 1, + endLine: undefined, + ruleSelector: 'all', + isSuppressed: true + } + ] + }); + + const violations = [ + new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file1.apex', 7, 1, 7, 20) + ), + new MockViolation( + new MockRule('pmd', 'ApexCrudViolation'), + 'CRUD violation', + new MockCodeLocation('/test/file2.apex', 7, 1, 7, 20) + ) + ]; + + const filtered = filterSuppressedViolations(violations, suppressionsMap); + + // File1 violation suppressed, file2 violation kept + expect(filtered).toHaveLength(1); + expect(filtered[0].getPrimaryLocation().getFile()).toBe('/test/file2.apex'); + }); +}); diff --git a/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-case-insensitive.js b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-case-insensitive.js new file mode 100644 index 00000000..fdc2e91c --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-case-insensitive.js @@ -0,0 +1,13 @@ +/** + * Test file with case-insensitive markers + * Markers should work regardless of case + */ + +// Code-Analyzer-Suppress(eslint:no-console) +console.log('Mixed case suppress'); // Line 7 - Should be suppressed + +// CODE-ANALYZER-UNSUPPRESS(eslint:no-console) +console.log('Upper case unsuppress'); // Line 10 - Should NOT be suppressed + +// code-analyzer-SUPPRESS(eslint:no-console) +console.log('Mixed case suppress again'); // Line 13 - Should be suppressed diff --git a/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-hierarchical-suppressions.js b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-hierarchical-suppressions.js new file mode 100644 index 00000000..547aa537 --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-hierarchical-suppressions.js @@ -0,0 +1,12 @@ +/** + * Test file with hierarchical suppressions + * Tests specificity rules: specific rule > engine > all + */ + +// code-analyzer-suppress(all) +console.log('All suppressed'); // Line 7 - Should be suppressed + +// code-analyzer-unsuppress(eslint:no-console) +console.log('Specific unsuppress wins'); // Line 10 - Should NOT be suppressed (higher specificity) + +const x = 1; // Line 12 - Should be suppressed (no-magic-numbers still suppressed by 'all') diff --git a/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-only-unsuppress.js b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-only-unsuppress.js new file mode 100644 index 00000000..17023833 --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-only-unsuppress.js @@ -0,0 +1,15 @@ +/** + * Test file with ONLY unsuppress markers (no suppress markers) + * This is an edge case - unsuppress without prior suppress should have no effect + * All violations should be reported normally + */ + +const x = 1; // Line 7 - Would normally violate no-magic-numbers + +// code-analyzer-unsuppress(eslint:no-console) +console.log(x); // Line 10 - Should be reported (unsuppress without suppress has no effect) + +const y = 2; // Line 12 - Would normally violate no-magic-numbers + +// code-analyzer-unsuppress(all) +eval('test'); // Line 15 - Should be reported (unsuppress without suppress has no effect) diff --git a/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-suppress-all.js b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-suppress-all.js new file mode 100644 index 00000000..7833b4fa --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-suppress-all.js @@ -0,0 +1,9 @@ +/** + * Test file with suppress(all) marker + * All violations below the marker should be suppressed + */ + +// code-analyzer-suppress(all) +const x = 1; // Would normally violate no-magic-numbers +console.log(x); // Would normally violate no-console +eval('test'); // Would normally violate no-eval diff --git a/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-suppress-engine.js b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-suppress-engine.js new file mode 100644 index 00000000..9fe40ac2 --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-suppress-engine.js @@ -0,0 +1,11 @@ +/** + * Test file with suppress(engine) marker + * Only violations from specified engine should be suppressed + */ + +const y = 2; // Would normally violate no-magic-numbers + +// code-analyzer-suppress(eslint) +const x = 1; // eslint violations suppressed +console.log(x); // eslint violations suppressed +// But pmd violations would still be reported diff --git a/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-suppress-severity.js b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-suppress-severity.js new file mode 100644 index 00000000..eaedd8d6 --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-suppress-severity.js @@ -0,0 +1,10 @@ +/** + * Test file with suppress by severity + * Only violations with specified severity should be suppressed + */ + +// code-analyzer-suppress(eslint:(3,4)) +// Suppresses severity 3 (Moderate) and 4 (Low) violations +const x = 1; // Severity 3 or 4 - Should be suppressed +console.log(x); // Severity varies - suppressed if 3 or 4 +eval('test'); // Severity 2 (High) - Should NOT be suppressed diff --git a/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-suppress-specific-rule.js b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-suppress-specific-rule.js new file mode 100644 index 00000000..30cac5d9 --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-suppress-specific-rule.js @@ -0,0 +1,11 @@ +/** + * Test file with suppress(engine:rule) marker + * Only specific rule violations should be suppressed + */ + +console.log('Before marker'); // Line 6 - Would violate no-console + +// code-analyzer-suppress(eslint:no-console) +console.log('After suppress marker'); // Line 9 - Should be suppressed +const x = 1; // Line 10 - Would still violate no-magic-numbers (not suppressed) +console.log(x); // Line 11 - Should be suppressed (no-console) diff --git a/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-unsuppress.js b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-unsuppress.js new file mode 100644 index 00000000..b4318702 --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/suppression-markers/file-with-unsuppress.js @@ -0,0 +1,10 @@ +/** + * Test file with suppress and unsuppress markers + * Tests that unsuppress re-enables violation reporting + */ + +// code-analyzer-suppress(eslint:no-console) +console.log('Suppressed'); // Line 7 - Should be suppressed + +// code-analyzer-unsuppress(eslint:no-console) +console.log('Not suppressed'); // Line 10 - Should NOT be suppressed diff --git a/packages/code-analyzer-core/test/test-data/suppression-markers/file-without-markers.js b/packages/code-analyzer-core/test/test-data/suppression-markers/file-without-markers.js new file mode 100644 index 00000000..4f7f0cc4 --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/suppression-markers/file-without-markers.js @@ -0,0 +1,8 @@ +/** + * Test file WITHOUT any suppression markers + * All violations should be reported normally + */ + +const x = 1; // Would normally violate no-magic-numbers +console.log(x); // Would normally violate no-console +eval('test'); // Would normally violate no-eval