suppressions-service.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. /**
  2. * @fileoverview Manages the suppressed violations.
  3. * @author Iacovos Constantinou
  4. */
  5. "use strict";
  6. //-----------------------------------------------------------------------------
  7. // Requirements
  8. //-----------------------------------------------------------------------------
  9. const fs = require("node:fs");
  10. const path = require("node:path");
  11. const { calculateStatsPerFile } = require("../eslint/eslint-helpers");
  12. const stringify = require("json-stable-stringify-without-jsonify");
  13. //------------------------------------------------------------------------------
  14. // Typedefs
  15. //------------------------------------------------------------------------------
  16. // For VSCode IntelliSense
  17. /** @typedef {import("../types").Linter.LintMessage} LintMessage */
  18. /** @typedef {import("../types").ESLint.LintResult} LintResult */
  19. /** @typedef {Record<string, Record<string, { count: number; }>>} SuppressedViolations */
  20. //-----------------------------------------------------------------------------
  21. // Exports
  22. //-----------------------------------------------------------------------------
  23. /**
  24. * Manages the suppressed violations.
  25. */
  26. class SuppressionsService {
  27. filePath = "";
  28. cwd = "";
  29. /**
  30. * Creates a new instance of SuppressionsService.
  31. * @param {Object} options The options.
  32. * @param {string} [options.filePath] The location of the suppressions file.
  33. * @param {string} [options.cwd] The current working directory.
  34. */
  35. constructor({ filePath, cwd }) {
  36. this.filePath = filePath;
  37. this.cwd = cwd;
  38. }
  39. /**
  40. * Updates the suppressions file based on the current violations and the provided rules.
  41. * If no rules are provided, all violations are suppressed.
  42. * @param {LintResult[]|undefined} results The lint results.
  43. * @param {string[]|undefined} rules The rules to suppress.
  44. * @returns {Promise<void>}
  45. */
  46. async suppress(results, rules) {
  47. const suppressions = await this.load();
  48. for (const result of results) {
  49. const relativeFilePath = this.getRelativeFilePath(result.filePath);
  50. const violationsByRule = SuppressionsService.countViolationsByRule(
  51. result.messages,
  52. );
  53. for (const ruleId in violationsByRule) {
  54. if (rules && !rules.includes(ruleId)) {
  55. continue;
  56. }
  57. suppressions[relativeFilePath] ??= {};
  58. suppressions[relativeFilePath][ruleId] =
  59. violationsByRule[ruleId];
  60. }
  61. }
  62. return this.save(suppressions);
  63. }
  64. /**
  65. * Removes old, unused suppressions for violations that do not occur anymore.
  66. * @param {LintResult[]} results The lint results.
  67. * @returns {Promise<void>} No return value.
  68. */
  69. async prune(results) {
  70. const suppressions = await this.load();
  71. const { unused } = this.applySuppressions(results, suppressions);
  72. for (const file in unused) {
  73. if (!suppressions[file]) {
  74. continue;
  75. }
  76. for (const rule in unused[file]) {
  77. if (!suppressions[file][rule]) {
  78. continue;
  79. }
  80. const suppressionsCount = suppressions[file][rule].count;
  81. const violationsCount = unused[file][rule].count;
  82. if (suppressionsCount === violationsCount) {
  83. // Remove unused rules
  84. delete suppressions[file][rule];
  85. } else {
  86. // Update the count to match the new number of violations
  87. suppressions[file][rule].count -= violationsCount;
  88. }
  89. }
  90. // Cleanup files with no rules
  91. if (Object.keys(suppressions[file]).length === 0) {
  92. delete suppressions[file];
  93. }
  94. }
  95. for (const file of Object.keys(suppressions)) {
  96. const absolutePath = path.resolve(this.cwd, file);
  97. if (!fs.existsSync(absolutePath)) {
  98. delete suppressions[file];
  99. }
  100. }
  101. return this.save(suppressions);
  102. }
  103. /**
  104. * Checks the provided suppressions against the lint results.
  105. *
  106. * For each file, counts the number of violations per rule.
  107. * For each rule in each file, compares the number of violations against the counter from the suppressions file.
  108. * If the number of violations is less or equal to the counter, messages are moved to `LintResult#suppressedMessages` and ignored.
  109. * Otherwise, all violations are reported as usual.
  110. * @param {LintResult[]} results The lint results.
  111. * @param {SuppressedViolations} suppressions The suppressions.
  112. * @returns {{
  113. * results: LintResult[],
  114. * unused: SuppressedViolations
  115. * }} The updated results and the unused suppressions.
  116. */
  117. applySuppressions(results, suppressions) {
  118. /**
  119. * We copy the results to avoid modifying the original objects
  120. * We remove only result messages that are matched and hence suppressed
  121. * We leave the rest untouched to minimize the risk of losing parts of the original data
  122. */
  123. const filtered = structuredClone(results);
  124. const unused = {};
  125. for (const result of filtered) {
  126. const relativeFilePath = this.getRelativeFilePath(result.filePath);
  127. if (!suppressions[relativeFilePath]) {
  128. continue;
  129. }
  130. const violationsByRule = SuppressionsService.countViolationsByRule(
  131. result.messages,
  132. );
  133. let wasSuppressed = false;
  134. for (const ruleId in violationsByRule) {
  135. if (!suppressions[relativeFilePath][ruleId]) {
  136. continue;
  137. }
  138. const suppressionsCount =
  139. suppressions[relativeFilePath][ruleId].count;
  140. const violationsCount = violationsByRule[ruleId].count;
  141. // Suppress messages if the number of violations is less or equal to the suppressions count
  142. if (violationsCount <= suppressionsCount) {
  143. SuppressionsService.suppressMessagesByRule(result, ruleId);
  144. wasSuppressed = true;
  145. }
  146. // Update the count to match the new number of violations, otherwise remove the rule entirely
  147. if (violationsCount < suppressionsCount) {
  148. unused[relativeFilePath] ??= {};
  149. unused[relativeFilePath][ruleId] ??= {};
  150. unused[relativeFilePath][ruleId].count =
  151. suppressionsCount - violationsCount;
  152. }
  153. }
  154. // Mark as unused all the suppressions that were not matched against a rule
  155. for (const ruleId in suppressions[relativeFilePath]) {
  156. if (violationsByRule[ruleId]) {
  157. continue;
  158. }
  159. unused[relativeFilePath] ??= {};
  160. unused[relativeFilePath][ruleId] =
  161. suppressions[relativeFilePath][ruleId];
  162. }
  163. // Recalculate stats if messages were suppressed
  164. if (wasSuppressed) {
  165. Object.assign(result, calculateStatsPerFile(result.messages));
  166. }
  167. }
  168. return {
  169. results: filtered,
  170. unused,
  171. };
  172. }
  173. /**
  174. * Loads the suppressions file.
  175. * @throws {Error} If the suppressions file cannot be parsed.
  176. * @returns {Promise<SuppressedViolations>} The suppressions.
  177. */
  178. async load() {
  179. try {
  180. const data = await fs.promises.readFile(this.filePath, "utf8");
  181. return JSON.parse(data);
  182. } catch (err) {
  183. if (err.code === "ENOENT") {
  184. return {};
  185. }
  186. throw new Error(
  187. `Failed to parse suppressions file at ${this.filePath}`,
  188. {
  189. cause: err,
  190. },
  191. );
  192. }
  193. }
  194. /**
  195. * Updates the suppressions file.
  196. * @param {SuppressedViolations} suppressions The suppressions to save.
  197. * @returns {Promise<void>}
  198. * @private
  199. */
  200. save(suppressions) {
  201. return fs.promises.writeFile(
  202. this.filePath,
  203. stringify(suppressions, { space: 2 }),
  204. );
  205. }
  206. /**
  207. * Counts the violations by rule, ignoring warnings.
  208. * @param {LintMessage[]} messages The messages to count.
  209. * @returns {Record<string, number>} The number of violations by rule.
  210. */
  211. static countViolationsByRule(messages) {
  212. return messages.reduce((totals, message) => {
  213. if (message.severity === 2 && message.ruleId) {
  214. totals[message.ruleId] ??= { count: 0 };
  215. totals[message.ruleId].count++;
  216. }
  217. return totals;
  218. }, {});
  219. }
  220. /**
  221. * Returns the relative path of a file to the current working directory.
  222. * Always in POSIX format for consistency and interoperability.
  223. * @param {string} filePath The file path.
  224. * @returns {string} The relative file path.
  225. */
  226. getRelativeFilePath(filePath) {
  227. return path
  228. .relative(this.cwd, filePath)
  229. .split(path.sep)
  230. .join(path.posix.sep);
  231. }
  232. /**
  233. * Moves the messages matching the rule to `LintResult#suppressedMessages` and updates the stats.
  234. * @param {LintResult} result The result to update.
  235. * @param {string} ruleId The rule to suppress.
  236. * @returns {void}
  237. */
  238. static suppressMessagesByRule(result, ruleId) {
  239. const suppressedMessages = result.messages.filter(
  240. message => message.ruleId === ruleId,
  241. );
  242. result.suppressedMessages = result.suppressedMessages.concat(
  243. suppressedMessages.map(message => {
  244. message.suppressions = [
  245. {
  246. kind: "file",
  247. justification: "",
  248. },
  249. ];
  250. return message;
  251. }),
  252. );
  253. result.messages = result.messages.filter(
  254. message => message.ruleId !== ruleId,
  255. );
  256. }
  257. }
  258. module.exports = { SuppressionsService };