From 375a6c3a559a0eb0ec94d4bbdfceeee3e68f8808 Mon Sep 17 00:00:00 2001 From: xbinaryx Date: Tue, 2 Jun 2026 23:27:24 +0300 Subject: [PATCH 1/4] fix: remap locations for unused disable directives --- docs/processors/markdown.md | 4 ++ src/processor.js | 99 +++++++++++++++++++++++++++++---- src/types.ts | 8 ++- tests/fixtures/eslint.config.js | 3 + tests/plugin.test.js | 67 ++++++++++++++++++++++ tests/types/types.test.ts | 15 ++++- 6 files changed, 182 insertions(+), 14 deletions(-) diff --git a/docs/processors/markdown.md b/docs/processors/markdown.md index b1e2877ec..8a70d18b7 100644 --- a/docs/processors/markdown.md +++ b/docs/processors/markdown.md @@ -183,6 +183,8 @@ Comment bodies are passed through unmodified, so the plugin supports any [config This example enables the `alert` global variable, disables the `no-alert` rule, and configures the `quotes` rule to prefer single quotes: + + ````markdown @@ -195,6 +197,8 @@ alert('Hello, world!'); Each code block in a file is linted separately, so configuration comments apply only to the code block that immediately follows. + + ````markdown Assuming `no-alert` is enabled in `eslint.config.js`, the first code block will have no error from `no-alert`: diff --git a/src/processor.js b/src/processor.js index a499bcd53..e7e0a0448 100644 --- a/src/processor.js +++ b/src/processor.js @@ -16,7 +16,7 @@ import { fromMarkdown } from "mdast-util-from-markdown"; /** * @import { Node, Parent, Code, Html } from "mdast"; * @import { Linter, Rule, AST } from "eslint"; - * @import { Block, RangeMap } from "./types.js"; + * @import { Block, Comment, RangeMap } from "./types.js"; * @typedef {Linter.LintMessage} Message * @typedef {Rule.Fix} Fix * @typedef {AST.Range} Range @@ -33,6 +33,8 @@ const UNSATISFIABLE_RULES = new Set([ const SUPPORTS_AUTOFIX = true; const BOM = "\uFEFF"; +const unusedDirectiveMessagePattern = + /^Unused eslint-(?:disable|enable) directive/u; /** * @type {Map} @@ -141,7 +143,7 @@ function getIndentText(text, node) { * delta at the beginning of each line. * @param {string} text The text of the file. * @param {Code} node A Markdown code block AST node. - * @param {string[]} comments List of configuration comment strings that will be + * @param {Comment[]} comments List of configuration comment objects that will be * inserted at the beginning of the code block. * @returns {RangeMap[]} A list of offset-based adjustments, where lookups are * done based on the `js` key, which represents the range in the linted JS, @@ -179,7 +181,7 @@ function getBlockRangeMap(text, node, comments) { * of the linted JS and start the JS offset lookup keys at this index. */ const commentLength = comments.reduce( - (len, comment) => len + comment.length + 1, + (len, comment) => len + comment.text.length + 1, 0, ); @@ -240,6 +242,74 @@ function getBlockRangeMap(text, node, comments) { return rangeMap; } +/** + * Determines whether a message reports an unused directive. + * @param {Message} message The message to check. + * @returns {boolean} True if the message reports an unused directive. + */ +function isUnusedDirectiveMessage(message) { + return ( + message.ruleId === null && + unusedDirectiveMessagePattern.test(message.message) + ); +} + +/** + * Finds the inserted comment that contains the given generated line. + * @param {Block} block The code block to search. + * @param {number} line The generated JS line number. + * @returns {Comment | undefined} The matching inserted comment location. + */ +function findCommentLocation(block, line) { + let currentJsLine = 1; + let foundComment; + + for (const comment of block.comments) { + const commentLines = comment.text.split("\n").length; + + if (line >= currentJsLine && line < currentJsLine + commentLines) { + foundComment = comment; + break; + } + + currentJsLine += commentLines; + } + + return foundComment; +} + +/** + * Adjusts an unused directive message in an inserted JS comment. + * @param {Block} block The code block containing the inserted comment. + * @param {Message} message The message to adjust. + * @returns {Message} The adjusted message, if it can be mapped. + */ +function adjustCommentMessage(block, message) { + const comment = findCommentLocation(block, message.line); + + if (!comment) { + return message; + } + + const { start, end } = comment.position; + const { fix, ...messageWithoutFix } = message; + + const adjustedMessage = /** @type {Message} */ ({ + ...messageWithoutFix, + line: start.line, + column: start.column, + }); + + if (fix) { + adjustedMessage.fix = { + range: [start.offset, end.offset], + text: fix.text, + }; + } + + return adjustedMessage; +} + const codeBlockFileNameRegex = /filename=(?["'])(?.*?)\1/u; /** @@ -281,7 +351,7 @@ function preprocess(sourceText, filename) { * block immediately follows such a sequence, insert the comments at the * top of the code block. Any non-ESLint comment or other node type breaks * and empties the sequence. - * @type {string[]} + * @type {Comment[]} */ let htmlComments = []; @@ -297,16 +367,19 @@ function preprocess(sourceText, filename) { */ code(node) { if (node.lang) { - /** @type {string[]} */ + /** @type {Comment[]} */ const comments = []; for (const comment of htmlComments) { - if (comment.trim() === "eslint-skip") { + if (comment.text.trim() === "eslint-skip") { htmlComments = []; return; } - comments.push(`/*${comment}*/`); + comments.push({ + text: `/*${comment.text}*/`, + position: comment.position, + }); } htmlComments = []; @@ -329,7 +402,7 @@ function preprocess(sourceText, filename) { const comment = getComment(node.value); if (comment) { - htmlComments.push(comment); + htmlComments.push({ text: comment, position: node.position }); } else { htmlComments = []; } @@ -348,7 +421,9 @@ function preprocess(sourceText, filename) { return { filename: fileNameFromMeta(block) ?? `${index}.${fileExtension}`, - text: [...block.comments, block.value, ""].join("\n"), + text: [...block.comments.map(c => c.text), block.value, ""].join( + "\n", + ), }; }); } @@ -390,7 +465,7 @@ function adjustFix(block, fix) { */ function adjustBlock(block) { const leadingCommentLines = block.comments.reduce( - (count, comment) => count + comment.split("\n").length, + (count, comment) => count + comment.text.split("\n").length, 0, ); @@ -413,7 +488,9 @@ function adjustBlock(block) { const lineInCode = message.line - leadingCommentLines; if (lineInCode < 1 || lineInCode >= block.rangeMap.length) { - return null; + return isUnusedDirectiveMessage(message) + ? adjustCommentMessage(block, message) + : null; } /** @type {Pick} */ diff --git a/src/types.ts b/src/types.ts index 64f143e58..d4b3cd3e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,7 @@ import type { Yaml, } from "mdast"; import type { InlineMath, Math } from "mdast-util-math"; +import type { Position } from "unist"; import type { LanguageContext, LanguageOptions, @@ -64,9 +65,14 @@ export interface RangeMap { md: number; } +export interface Comment { + text: string; + position: Position; +} + export interface BlockBase { baseIndentText: string; - comments: string[]; + comments: Comment[]; rangeMap: RangeMap[]; } diff --git a/tests/fixtures/eslint.config.js b/tests/fixtures/eslint.config.js index cbe969781..5b85a3aa0 100644 --- a/tests/fixtures/eslint.config.js +++ b/tests/fixtures/eslint.config.js @@ -9,6 +9,9 @@ export default [ languageOptions: { globals: globals.browser, }, + linterOptions: { + reportUnusedDisableDirectives: "off", + }, rules: { "eol-last": "error", "no-console": "error", diff --git a/tests/plugin.test.js b/tests/plugin.test.js index f76f96bef..945df068e 100644 --- a/tests/plugin.test.js +++ b/tests/plugin.test.js @@ -1569,6 +1569,73 @@ describe("FlatESLint", () => { ); assert.strictEqual(results[0].messages[0].line, 5); }); + + describe("unused disable directives", () => { + let unusedDisableESLint; + + beforeEach(() => { + unusedDisableESLint = initFlatESLint("eslint.config.js", { + overrideConfig: { + linterOptions: { + reportUnusedDisableDirectives: "error", + }, + }, + }); + }); + + it("reports unused disable directives", async () => { + const code = [ + "", + "", + "```js", + "const answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + assert.deepStrictEqual(results[0].messages[0].fix, { + range: [0, 34], + text: " ", + }); + }); + + it("reports unused disable directives correctly among multiple comments", async () => { + const code = [ + "# Title", + "", + "", + "", + "", + "```js", + "const message = 'single quotes';", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 4); + assert.strictEqual(results[0].messages[0].column, 1); + }); + }); }); describe("should fix code", () => { diff --git a/tests/types/types.test.ts b/tests/types/types.test.ts index 3d2636208..2f527e023 100644 --- a/tests/types/types.test.ts +++ b/tests/types/types.test.ts @@ -8,6 +8,7 @@ import type { Json, RangeMap, Block, + Comment, } from "@eslint/markdown"; import type { Plugin, SourceLocation, SourceRange } from "@eslint/core"; import type { Linter } from "eslint"; @@ -58,7 +59,15 @@ const validBlock: Block = { // `BlockBase` properties baseIndentText: " ", - comments: ["// A comment"], + comments: [ + { + text: "eslint-disable", + position: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 15, offset: 14 }, + }, + }, + ], rangeMap: [{ indent: 2, js: 0, md: 4 }], }; @@ -72,7 +81,9 @@ validBlock.data satisfies CodeData | undefined; // Verify `Block` has `BlockBase` properties validBlock.baseIndentText satisfies string; -validBlock.comments satisfies string[]; +validBlock.comments satisfies Comment[]; +validBlock.comments[0].text satisfies string; +validBlock.comments[0].position satisfies Position; validBlock.rangeMap satisfies RangeMap[]; // Verify `RangeMap` structure From e86c64767836569eb908e1c187afcc16c3a08d16 Mon Sep 17 00:00:00 2001 From: xbinaryx Date: Tue, 9 Jun 2026 05:33:50 +0300 Subject: [PATCH 2/4] address review comments --- src/processor.js | 58 +++--- tests/plugin.test.js | 483 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 515 insertions(+), 26 deletions(-) diff --git a/src/processor.js b/src/processor.js index e7e0a0448..d7a49c6e3 100644 --- a/src/processor.js +++ b/src/processor.js @@ -255,43 +255,36 @@ function isUnusedDirectiveMessage(message) { } /** - * Finds the inserted comment that contains the given generated line. - * @param {Block} block The code block to search. - * @param {number} line The generated JS line number. - * @returns {Comment | undefined} The matching inserted comment location. + * Adjusts an unused directive message in an inserted JS comment. + * @param {Block} block The code block containing the inserted comment. + * @param {Message} message The message to adjust. + * @returns {Message} The adjusted message, if it can be mapped. */ -function findCommentLocation(block, line) { - let currentJsLine = 1; +function adjustCommentMessage(block, message) { + let currentLine = 1; + let jsOffset = 0; let foundComment; for (const comment of block.comments) { const commentLines = comment.text.split("\n").length; - if (line >= currentJsLine && line < currentJsLine + commentLines) { + if ( + message.line >= currentLine && + message.line < currentLine + commentLines + ) { foundComment = comment; break; } - currentJsLine += commentLines; + currentLine += commentLines; + jsOffset += comment.text.length + 1; } - return foundComment; -} - -/** - * Adjusts an unused directive message in an inserted JS comment. - * @param {Block} block The code block containing the inserted comment. - * @param {Message} message The message to adjust. - * @returns {Message} The adjusted message, if it can be mapped. - */ -function adjustCommentMessage(block, message) { - const comment = findCommentLocation(block, message.line); - - if (!comment) { + if (!foundComment) { return message; } - const { start, end } = comment.position; + const { start, end } = foundComment.position; const { fix, ...messageWithoutFix } = message; const adjustedMessage = /** @type {Message} */ ({ @@ -301,10 +294,23 @@ function adjustCommentMessage(block, message) { }); if (fix) { - adjustedMessage.fix = { - range: [start.offset, end.offset], - text: fix.text, - }; + const isFullRemoval = + fix.range[0] <= jsOffset && + fix.range[1] >= jsOffset + foundComment.text.length; + + if (isFullRemoval) { + adjustedMessage.fix = { + range: [start.offset, end.offset], + text: fix.text, + }; + } else { + const offsetDelta = start.offset + 4 - (jsOffset + 2); + + adjustedMessage.fix = { + range: [fix.range[0] + offsetDelta, fix.range[1] + offsetDelta], + text: fix.text, + }; + } } return adjustedMessage; diff --git a/tests/plugin.test.js b/tests/plugin.test.js index 945df068e..907799930 100644 --- a/tests/plugin.test.js +++ b/tests/plugin.test.js @@ -454,6 +454,277 @@ describe("LegacyESLint", () => { ); assert.strictEqual(results[0].messages[0].line, 5); }); + + describe("unused disable directives", () => { + let unusedDisableESLint; + + beforeEach(() => { + unusedDisableESLint = initLegacyESLint("eslintrc.json", { + reportUnusedDisableDirectives: "error", + }); + }); + + it("reports unused disable directives", async () => { + const code = [ + "", + "", + "```js", + "var answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + assert.deepStrictEqual(results[0].messages[0].fix, { + range: [0, 34], + text: " ", + }); + }); + + it("reports unused disable directives correctly among multiple comments", async () => { + const code = [ + "# Title", + "", + "", + "", + "", + "```js", + "var message = 'single quotes';", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 4); + assert.strictEqual(results[0].messages[0].column, 1); + }); + + it("reports unused disable-next-line directives", async () => { + const code = [ + "", + "", + "```js", + "var answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + }); + + it("reports unused disable directives when paired with eslint-enable", async () => { + const code = [ + "", + "", + "", + "```js", + "var answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + }); + + it("reports unused disable directives spanning multiple lines", async () => { + const code = [ + "", + "", + "```js", + "var answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + }); + }); + + describe("autofixing unused disable directives", () => { + let fixUnusedEslint; + + beforeEach(() => { + fixUnusedEslint = initLegacyESLint("eslintrc.json", { + fix: true, + reportUnusedDisableDirectives: "error", + }); + }); + + it("removes the entire HTML comment when all rules are unused", async () => { + const code = [ + "", + "", + "```js", + "var answer = 42;", + "```", + ].join("\n"); + + const expected = [ + " ", + "", + "```js", + "var answer = 42;", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("partially removes unused rules from the end of an HTML comment", async () => { + const code = [ + "", + "", + "```js", + "var answer = missingVariable;", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "var answer = missingVariable;", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("partially removes unused rules from the beginning of an HTML comment", async () => { + const code = [ + "", + "", + "```js", + "var answer = missingVariable;", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "var answer = missingVariable;", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("partially removes unused rules from the middle of an HTML comment", async () => { + const code = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("removes unused rules from a multiline HTML comment", async () => { + const code = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + }); }); describe("should fix code", () => { @@ -1635,6 +1906,218 @@ describe("FlatESLint", () => { assert.strictEqual(results[0].messages[0].line, 4); assert.strictEqual(results[0].messages[0].column, 1); }); + + it("reports unused disable-next-line directives", async () => { + const code = [ + "", + "", + "```js", + "const answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + }); + + it("reports unused disable directives when paired with eslint-enable", async () => { + const code = [ + "", + "", + "", + "```js", + "const answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + }); + + it("reports unused disable directives spanning multiple lines", async () => { + const code = [ + "", + "", + "```js", + "const answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + }); + }); + + describe("autofixing unused disable directives", () => { + let fixUnusedEslint; + + beforeEach(() => { + fixUnusedEslint = initFlatESLint("eslint.config.js", { + fix: true, + overrideConfig: { + linterOptions: { + reportUnusedDisableDirectives: "error", + }, + }, + }); + }); + + it("removes the entire HTML comment when all rules are unused", async () => { + const code = [ + "", + "", + "```js", + "const answer = 42;", + "```", + ].join("\n"); + + const expected = [ + " ", + "", + "```js", + "const answer = 42;", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("partially removes unused rules from the end of an HTML comment", async () => { + const code = [ + "", + "", + "```js", + "const answer = missingVariable;", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "const answer = missingVariable;", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("partially removes unused rules from the beginning of an HTML comment", async () => { + const code = [ + "", + "", + "```js", + "const answer = missingVariable;", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "const answer = missingVariable;", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("partially removes unused rules from the middle of an HTML comment", async () => { + const code = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("removes unused rules from a multiline HTML comment", async () => { + const code = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); }); }); From 4ae8ad0936bf8dedf1a8c6946440bba1bf3cd1eb Mon Sep 17 00:00:00 2001 From: xbinaryx Date: Fri, 12 Jun 2026 04:39:50 +0300 Subject: [PATCH 3/4] document magic numbers for comment offset delta --- src/processor.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/processor.js b/src/processor.js index d7a49c6e3..c098c2b51 100644 --- a/src/processor.js +++ b/src/processor.js @@ -304,6 +304,7 @@ function adjustCommentMessage(block, message) { text: fix.text, }; } else { + // '4' is the length of '