| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302 |
- /**
- * @fileoverview Manages the suppressed violations.
- * @author Iacovos Constantinou
- */
- "use strict";
- //-----------------------------------------------------------------------------
- // Requirements
- //-----------------------------------------------------------------------------
- const fs = require("node:fs");
- const path = require("node:path");
- const { calculateStatsPerFile } = require("../eslint/eslint-helpers");
- const stringify = require("json-stable-stringify-without-jsonify");
- //------------------------------------------------------------------------------
- // Typedefs
- //------------------------------------------------------------------------------
- // For VSCode IntelliSense
- /** @typedef {import("../types").Linter.LintMessage} LintMessage */
- /** @typedef {import("../types").ESLint.LintResult} LintResult */
- /** @typedef {Record<string, Record<string, { count: number; }>>} SuppressedViolations */
- //-----------------------------------------------------------------------------
- // Exports
- //-----------------------------------------------------------------------------
- /**
- * Manages the suppressed violations.
- */
- class SuppressionsService {
- filePath = "";
- cwd = "";
- /**
- * Creates a new instance of SuppressionsService.
- * @param {Object} options The options.
- * @param {string} [options.filePath] The location of the suppressions file.
- * @param {string} [options.cwd] The current working directory.
- */
- constructor({ filePath, cwd }) {
- this.filePath = filePath;
- this.cwd = cwd;
- }
- /**
- * Updates the suppressions file based on the current violations and the provided rules.
- * If no rules are provided, all violations are suppressed.
- * @param {LintResult[]|undefined} results The lint results.
- * @param {string[]|undefined} rules The rules to suppress.
- * @returns {Promise<void>}
- */
- async suppress(results, rules) {
- const suppressions = await this.load();
- for (const result of results) {
- const relativeFilePath = this.getRelativeFilePath(result.filePath);
- const violationsByRule = SuppressionsService.countViolationsByRule(
- result.messages,
- );
- for (const ruleId in violationsByRule) {
- if (rules && !rules.includes(ruleId)) {
- continue;
- }
- suppressions[relativeFilePath] ??= {};
- suppressions[relativeFilePath][ruleId] =
- violationsByRule[ruleId];
- }
- }
- return this.save(suppressions);
- }
- /**
- * Removes old, unused suppressions for violations that do not occur anymore.
- * @param {LintResult[]} results The lint results.
- * @returns {Promise<void>} No return value.
- */
- async prune(results) {
- const suppressions = await this.load();
- const { unused } = this.applySuppressions(results, suppressions);
- for (const file in unused) {
- if (!suppressions[file]) {
- continue;
- }
- for (const rule in unused[file]) {
- if (!suppressions[file][rule]) {
- continue;
- }
- const suppressionsCount = suppressions[file][rule].count;
- const violationsCount = unused[file][rule].count;
- if (suppressionsCount === violationsCount) {
- // Remove unused rules
- delete suppressions[file][rule];
- } else {
- // Update the count to match the new number of violations
- suppressions[file][rule].count -= violationsCount;
- }
- }
- // Cleanup files with no rules
- if (Object.keys(suppressions[file]).length === 0) {
- delete suppressions[file];
- }
- }
- for (const file of Object.keys(suppressions)) {
- const absolutePath = path.resolve(this.cwd, file);
- if (!fs.existsSync(absolutePath)) {
- delete suppressions[file];
- }
- }
- return this.save(suppressions);
- }
- /**
- * Checks the provided suppressions against the lint results.
- *
- * For each file, counts the number of violations per rule.
- * For each rule in each file, compares the number of violations against the counter from the suppressions file.
- * If the number of violations is less or equal to the counter, messages are moved to `LintResult#suppressedMessages` and ignored.
- * Otherwise, all violations are reported as usual.
- * @param {LintResult[]} results The lint results.
- * @param {SuppressedViolations} suppressions The suppressions.
- * @returns {{
- * results: LintResult[],
- * unused: SuppressedViolations
- * }} The updated results and the unused suppressions.
- */
- applySuppressions(results, suppressions) {
- /**
- * We copy the results to avoid modifying the original objects
- * We remove only result messages that are matched and hence suppressed
- * We leave the rest untouched to minimize the risk of losing parts of the original data
- */
- const filtered = structuredClone(results);
- const unused = {};
- for (const result of filtered) {
- const relativeFilePath = this.getRelativeFilePath(result.filePath);
- if (!suppressions[relativeFilePath]) {
- continue;
- }
- const violationsByRule = SuppressionsService.countViolationsByRule(
- result.messages,
- );
- let wasSuppressed = false;
- for (const ruleId in violationsByRule) {
- if (!suppressions[relativeFilePath][ruleId]) {
- continue;
- }
- const suppressionsCount =
- suppressions[relativeFilePath][ruleId].count;
- const violationsCount = violationsByRule[ruleId].count;
- // Suppress messages if the number of violations is less or equal to the suppressions count
- if (violationsCount <= suppressionsCount) {
- SuppressionsService.suppressMessagesByRule(result, ruleId);
- wasSuppressed = true;
- }
- // Update the count to match the new number of violations, otherwise remove the rule entirely
- if (violationsCount < suppressionsCount) {
- unused[relativeFilePath] ??= {};
- unused[relativeFilePath][ruleId] ??= {};
- unused[relativeFilePath][ruleId].count =
- suppressionsCount - violationsCount;
- }
- }
- // Mark as unused all the suppressions that were not matched against a rule
- for (const ruleId in suppressions[relativeFilePath]) {
- if (violationsByRule[ruleId]) {
- continue;
- }
- unused[relativeFilePath] ??= {};
- unused[relativeFilePath][ruleId] =
- suppressions[relativeFilePath][ruleId];
- }
- // Recalculate stats if messages were suppressed
- if (wasSuppressed) {
- Object.assign(result, calculateStatsPerFile(result.messages));
- }
- }
- return {
- results: filtered,
- unused,
- };
- }
- /**
- * Loads the suppressions file.
- * @throws {Error} If the suppressions file cannot be parsed.
- * @returns {Promise<SuppressedViolations>} The suppressions.
- */
- async load() {
- try {
- const data = await fs.promises.readFile(this.filePath, "utf8");
- return JSON.parse(data);
- } catch (err) {
- if (err.code === "ENOENT") {
- return {};
- }
- throw new Error(
- `Failed to parse suppressions file at ${this.filePath}`,
- {
- cause: err,
- },
- );
- }
- }
- /**
- * Updates the suppressions file.
- * @param {SuppressedViolations} suppressions The suppressions to save.
- * @returns {Promise<void>}
- * @private
- */
- save(suppressions) {
- return fs.promises.writeFile(
- this.filePath,
- stringify(suppressions, { space: 2 }),
- );
- }
- /**
- * Counts the violations by rule, ignoring warnings.
- * @param {LintMessage[]} messages The messages to count.
- * @returns {Record<string, number>} The number of violations by rule.
- */
- static countViolationsByRule(messages) {
- return messages.reduce((totals, message) => {
- if (message.severity === 2 && message.ruleId) {
- totals[message.ruleId] ??= { count: 0 };
- totals[message.ruleId].count++;
- }
- return totals;
- }, {});
- }
- /**
- * Returns the relative path of a file to the current working directory.
- * Always in POSIX format for consistency and interoperability.
- * @param {string} filePath The file path.
- * @returns {string} The relative file path.
- */
- getRelativeFilePath(filePath) {
- return path
- .relative(this.cwd, filePath)
- .split(path.sep)
- .join(path.posix.sep);
- }
- /**
- * Moves the messages matching the rule to `LintResult#suppressedMessages` and updates the stats.
- * @param {LintResult} result The result to update.
- * @param {string} ruleId The rule to suppress.
- * @returns {void}
- */
- static suppressMessagesByRule(result, ruleId) {
- const suppressedMessages = result.messages.filter(
- message => message.ruleId === ruleId,
- );
- result.suppressedMessages = result.suppressedMessages.concat(
- suppressedMessages.map(message => {
- message.suppressions = [
- {
- kind: "file",
- justification: "",
- },
- ];
- return message;
- }),
- );
- result.messages = result.messages.filter(
- message => message.ruleId !== ruleId,
- );
- }
- }
- module.exports = { SuppressionsService };
|