From 42b87e241bb6d80b25d02d45def62a7f16778be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ya=C3=ABl=20Guilloux?= Date: Fri, 17 Jul 2026 10:13:18 +0200 Subject: [PATCH 1/2] feat(rules): add auto-fix support to multiple rules Add fixers to block-scope-case, catch-error-name, comment-syntax, and lines-around-comment rules: - block-scope-case: wrap case consequent in braces - catch-error-name: rename the catch parameter and all its references, bailing out when the expected name is already used - comment-syntax: fix line/block comment spacing, capitalization, list items, paragraph endings, and JSDoc formatting - lines-around-comment: apply fix for missing blank lines Add test coverage for the new fix behavior. --- src/rules/block-scope-case.ts | 17 ++ src/rules/catch-error-name.ts | 17 +- src/rules/comment-syntax.ts | 134 +++++++++++++-- src/rules/lines-around-comment.ts | 4 + test/rules/block-scope-case.spec.ts | 36 ++++ test/rules/catch-error-name.spec.ts | 53 ++++++ test/rules/comment.spec.ts | 211 ++++++++++++++++++++++++ test/rules/lines-around-comment.spec.ts | 112 +++++++++++++ 8 files changed, 570 insertions(+), 14 deletions(-) diff --git a/src/rules/block-scope-case.ts b/src/rules/block-scope-case.ts index 1853003..132ae6c 100644 --- a/src/rules/block-scope-case.ts +++ b/src/rules/block-scope-case.ts @@ -19,6 +19,8 @@ export default createRule({ }, defaultOptions: [], create: (context) => { + const { sourceCode } = context; + return { SwitchCase: (node) => { if (node.consequent.length > 0 && node.consequent[0].type !== 'BlockStatement') { @@ -29,6 +31,21 @@ export default createRule({ line: node.loc.start.line, }, messageId: 'default', + fix: (fixer) => { + const colonToken = sourceCode.getTokenBefore(node.consequent[0]); + + if (!colonToken) { + return null; + } + + const lastStatement = node.consequent[node.consequent.length - 1]; + const indent = ' '.repeat(node.loc.start.column); + + return [ + fixer.insertTextAfter(colonToken, ' {'), + fixer.insertTextAfter(lastStatement, `\n${indent}}`), + ]; + }, }); } }, diff --git a/src/rules/catch-error-name.ts b/src/rules/catch-error-name.ts index 846fb8a..aedfec8 100644 --- a/src/rules/catch-error-name.ts +++ b/src/rules/catch-error-name.ts @@ -64,11 +64,22 @@ export default createRule({ fixedName: expectedName, }, fix: (fixer) => { - if (node.param) { - return fixer.replaceText(node.param, 'error'); + if (!node.param) { + return null; } - return null; + // renaming would conflict with an existing usage of the expected name + if (new RegExp(`\\b${expectedName}\\b`).test(context.sourceCode.getText(node.body))) { + return null; + } + + const variable = context.sourceCode + .getDeclaredVariables(node) + .find(({ name }) => name === originalName); + + const references = variable?.references.map(({ identifier }) => identifier) ?? []; + + return [node.param, ...references].map((identifier) => fixer.replaceText(identifier, expectedName)); }, }); } diff --git a/src/rules/comment-syntax.ts b/src/rules/comment-syntax.ts index c45e5af..2233fe0 100644 --- a/src/rules/comment-syntax.ts +++ b/src/rules/comment-syntax.ts @@ -1,3 +1,4 @@ +import type { TSESLint } from '@typescript-eslint/utils'; import { createRule } from '../util'; import { oneLine } from '../util/messages'; @@ -134,13 +135,20 @@ export default createRule({ const comments = sourceCode.getAllComments(); for (const comment of comments) { + // start of the comment value, right after the `//` or `/*` + const valueStart = comment.range[0] + 2; + if (comment.type === 'Line') { const firstChar = comment.value?.charCodeAt(0); const secondChar = comment.value[1]; const lastChar = comment.value[comment.value.length - 1]; if (firstChar !== SPACE_CHARCODE && firstChar !== SLASH_CHARCODE && !isRegion(comment.value)) { - context.report({ node: comment, messageId: 'shouldStartWithSpace' }); + context.report({ + node: comment, + messageId: 'shouldStartWithSpace', + fix: (fixer) => fixer.insertTextBeforeRange([valueStart, valueStart], ' '), + }); // if this one fails, the others are interpreted incorrectly continue; @@ -151,11 +159,19 @@ export default createRule({ isCapital(secondChar) && !isCapitalizedOrAllowed(comment.value.slice(1), ignoredWords) ) { - context.report({ node: comment, messageId: 'lineCommentCapital' }); + context.report({ + node: comment, + messageId: 'lineCommentCapital', + fix: (fixer) => fixer.replaceTextRange([valueStart + 1, valueStart + 2], secondChar.toLowerCase()), + }); } if (lastChar === '.' && !comment.value.endsWith('etc.') && !comment.value.endsWith('...')) { - context.report({ node: comment, messageId: 'lineCommentEnding' }); + context.report({ + node: comment, + messageId: 'lineCommentEnding', + fix: (fixer) => fixer.removeRange([comment.range[1] - 1, comment.range[1]]), + }); } continue; @@ -169,9 +185,36 @@ export default createRule({ continue; } + const valueLines = lines; + const indent = ' '.repeat(comment.loc.start.column + 1); + + const lineStartOffsets: number[] = []; + let currentOffset = 0; + + for (const valueLine of valueLines) { + lineStartOffsets.push(currentOffset); + currentOffset += valueLine.length + 1; + } + + // absolute source range of the line at `index` in the sliced `lines` array + const lineRange = (index: number): [number, number] => { + const start = valueStart + lineStartOffsets[index + 1]; + + return [start, start + valueLines[index + 1].length]; + }; + // verify the first char is a '*' if (lines[0] !== STAR) { - context.report({ node: comment, messageId: 'shouldStartWithBlock' }); + context.report({ + node: comment, + messageId: 'shouldStartWithBlock', + fix: (fixer) => { + const content = valueLines[0].replace(/^\*+\s*/, '').trim(); + const range: [number, number] = [valueStart, valueStart + valueLines[0].length]; + + return fixer.replaceTextRange(range, content ? `*\n${indent}* ${content}` : '*'); + }, + }); continue; } @@ -199,12 +242,29 @@ export default createRule({ if (isLastLine) { if (!BLOCK_COMMENT_END.test(line)) { - context.report({ node: comment, messageId: 'shouldEndWithBlock' }); + context.report({ + node: comment, + messageId: 'shouldEndWithBlock', + fix: (fixer) => { + const [start] = lineRange(i); + const contentEnd = start + line.replace(/\s+$/, '').length; + + return fixer.replaceTextRange([contentEnd, comment.range[1] - 2], `\n${indent}`); + }, + }); break; } if (EMPTY_BLOCK_COMMENT_LINE.test(prevLine)) { - context.report({ node: comment, messageId: 'noSpaceBeforeEnd' }); + context.report({ + node: comment, + messageId: 'noSpaceBeforeEnd', + fix: (fixer) => { + const [prevStart, prevEnd] = lineRange(i - 1); + + return fixer.removeRange([prevStart - 1, prevEnd]); + }, + }); break; } @@ -226,7 +286,16 @@ export default createRule({ if (isCurrentLineJSDoc && !insideCodeBlock) { if (prevLine && !EMPTY_BLOCK_COMMENT_LINE.test(prevLine) && !JS_DOC_REGEX.test(prevLine)) { - context.report({ node: comment, messageId: 'spaceBeforeJSDoc', data: { ...lineData } }); + context.report({ + node: comment, + messageId: 'spaceBeforeJSDoc', + data: { ...lineData }, + fix: (fixer) => { + const [start] = lineRange(i); + + return fixer.insertTextBeforeRange([start, start], `${indent}*\n`); + }, + }); break; } @@ -238,7 +307,23 @@ export default createRule({ } if (!isCurrentLineEmpty && !BLOCK_COMMENT_LINE_START.test(line)) { - context.report({ node: comment, messageId: 'invalidBlockCommentLine', data: { ...lineData } }); + context.report({ + node: comment, + messageId: 'invalidBlockCommentLine', + data: { ...lineData }, + fix: (fixer) => { + const [start] = lineRange(i); + const starMatch = line.match(/^\s*\*/); + + if (starMatch) { + return fixer.insertTextAfterRange([start, start + starMatch[0].length], ' '); + } + + const leadingWhitespace = line.match(/^\s*/)?.[0] ?? ''; + + return fixer.insertTextAfterRange([start, start + leadingWhitespace.length], '* '); + }, + }); break; } @@ -249,7 +334,14 @@ export default createRule({ prevListItemIndentation = line.match(LIST_ITEM_INDENTATION)?.[1].length; if (spaces !== 1) { - context.report({ node: comment, messageId: 'invalidListItem', data: { ...lineData } }); + context.report({ + node: comment, + messageId: 'invalidListItem', + data: { ...lineData }, + fix: (fixer) => { + return fixer.replaceTextRange(lineRange(i), line.replace(/^(\s*\*\s*(?:-|\d\.))\s*/, '$1 ')); + }, + }); break; } @@ -276,7 +368,20 @@ export default createRule({ const firstChar = text[0]; if (isLetter(firstChar) && !(isCapital(firstChar) || isCapitalizedOrAllowed(text, ignoredWords))) { - context.report({ node: comment, messageId: 'paragraphCapitalized', data: { ...lineData } }); + context.report({ + node: comment, + messageId: 'paragraphCapitalized', + data: { ...lineData }, + fix: (fixer) => { + const [start] = lineRange(i); + const prefixLength = line.length - text.length; + + return fixer.replaceTextRange( + [start + prefixLength, start + prefixLength + 1], + firstChar.toUpperCase(), + ); + }, + }); break; } } @@ -287,8 +392,14 @@ export default createRule({ if (isNextLineEmpty || isLastContentLine || isNextLineCodeBlock) { const lastChar = line[line.length - 1]; + const appendDot = (fixer: TSESLint.RuleFixer) => { + const [start] = lineRange(i); + + return fixer.insertTextAfterRange([start, start + line.replace(/\s+$/, '').length], '.'); + }; + if (isLastContentLine && !allowedParagraphEndings.some((ending) => ending === lastChar)) { - context.report({ node: comment, messageId: 'shouldEndWithDot', data: { ...lineData } }); + context.report({ node: comment, messageId: 'shouldEndWithDot', data: { ...lineData }, fix: appendDot }); break; } @@ -297,6 +408,7 @@ export default createRule({ node: comment, messageId: 'invalidParagraphEnding', data: { ...lineData, allowedParagraphEndings: `[${allowedParagraphEndings.join(' ')}]` }, + fix: appendDot, }); break; diff --git a/src/rules/lines-around-comment.ts b/src/rules/lines-around-comment.ts index 460470b..a3f099b 100644 --- a/src/rules/lines-around-comment.ts +++ b/src/rules/lines-around-comment.ts @@ -344,6 +344,10 @@ export default createRule({ node: token, messageId, fix(fixer) { + if (messageId === 'after') { + return fixer.insertTextAfterRange(token.range, '\n'); + } + return fixer.insertTextBeforeRange(range, '\n'); }, }); diff --git a/test/rules/block-scope-case.spec.ts b/test/rules/block-scope-case.spec.ts index 35c21a7..a38f4b5 100644 --- a/test/rules/block-scope-case.spec.ts +++ b/test/rules/block-scope-case.spec.ts @@ -43,6 +43,42 @@ ruleTester({ break; } `, + output: stripIndent` + const foo = 1; + + switch (foo) { + case 1: { + break; + } + } + `, + errors: [ + { + messageId, + }, + ], + }, + { + // default case with multiple statements + code: stripIndent` + const foo = 1; + + switch (foo) { + default: + console.log(foo); + break; + } + `, + output: stripIndent` + const foo = 1; + + switch (foo) { + default: { + console.log(foo); + break; + } + } + `, errors: [ { messageId, diff --git a/test/rules/catch-error-name.spec.ts b/test/rules/catch-error-name.spec.ts index 0ee64dd..928507a 100644 --- a/test/rules/catch-error-name.spec.ts +++ b/test/rules/catch-error-name.spec.ts @@ -52,5 +52,58 @@ ruleTester({ } catch (error) {} `, }, + { + // references to the parameter are renamed as well + code: stripIndent` + try {} catch (err) { + console.log(err.message, err); + } + `, + errors: [ + { + messageId: 'default', + }, + ], + output: stripIndent` + try {} catch (error) { + console.log(error.message, error); + } + `, + }, + { + // the configured name is used, not a hardcoded one + code: stripIndent` + try {} catch (error) {} + `, + options: [ + { + name: 'exception', + ignore: [], + }, + ], + errors: [ + { + messageId: 'default', + }, + ], + output: stripIndent` + try {} catch (exception) {} + `, + }, + { + // no fix when the expected name is already used inside the catch block + code: stripIndent` + try {} catch (err) { + const error = 1; + console.log(err, error); + } + `, + errors: [ + { + messageId: 'default', + }, + ], + output: null, + }, ], }); diff --git a/test/rules/comment.spec.ts b/test/rules/comment.spec.ts index 72754cc..d7f6be1 100644 --- a/test/rules/comment.spec.ts +++ b/test/rules/comment.spec.ts @@ -396,6 +396,11 @@ ruleTester({ * -foo */ `, + output: stripIndent` + /** + * - foo + */ + `, errors: [ { messageId: 'invalidListItem', @@ -408,6 +413,11 @@ ruleTester({ * 1.foo */ `, + output: stripIndent` + /** + * 1. foo + */ + `, errors: [ { messageId: 'invalidListItem', @@ -421,6 +431,12 @@ ruleTester({ * misaligned */ `, + output: stripIndent` + /** + * - foo + * Misaligned + */ + `, errors: [ { messageId: 'paragraphCapitalized', @@ -434,12 +450,201 @@ ruleTester({ * misaligned */ `, + output: stripIndent` + /** + * 1. foo + * Misaligned + */ + `, errors: [ { messageId: 'paragraphCapitalized', }, ], }, + { + code: stripIndent` + //foo bar + `, + output: stripIndent` + // foo bar + `, + errors: [ + { + messageId: 'shouldStartWithSpace', + }, + ], + }, + { + code: stripIndent` + // Foo bar + `, + output: stripIndent` + // foo bar + `, + errors: [ + { + messageId: 'lineCommentCapital', + }, + ], + }, + { + code: stripIndent` + // foo bar. + `, + output: stripIndent` + // foo bar + `, + errors: [ + { + messageId: 'lineCommentEnding', + }, + ], + }, + { + code: stripIndent` + /* foo + * bar. + */ + `, + output: stripIndent` + /** + * foo + * bar. + */ + `, + errors: [ + { + messageId: 'shouldStartWithBlock', + }, + ], + }, + { + code: stripIndent` + /** + * Foo. */ + `, + output: stripIndent` + /** + * Foo. + */ + `, + errors: [ + { + messageId: 'shouldEndWithBlock', + }, + ], + }, + { + code: stripIndent` + /** + * Foo. + * + */ + `, + output: stripIndent` + /** + * Foo. + */ + `, + errors: [ + { + messageId: 'noSpaceBeforeEnd', + }, + ], + }, + { + code: stripIndent` + /** + * Foo. + * @param bar + */ + `, + output: stripIndent` + /** + * Foo. + * + * @param bar + */ + `, + errors: [ + { + messageId: 'spaceBeforeJSDoc', + }, + ], + }, + { + code: stripIndent` + /** + *Foo. + */ + `, + output: stripIndent` + /** + * Foo. + */ + `, + errors: [ + { + messageId: 'invalidBlockCommentLine', + }, + ], + }, + { + code: stripIndent` + /** + Foo. + */ + `, + output: stripIndent` + /** + * Foo. + */ + `, + errors: [ + { + messageId: 'invalidBlockCommentLine', + }, + ], + }, + { + code: stripIndent` + /** + * Foo + */ + `, + output: stripIndent` + /** + * Foo. + */ + `, + errors: [ + { + messageId: 'shouldEndWithDot', + }, + ], + }, + { + code: stripIndent` + /** + * Foo + * + * Bar. + */ + `, + output: stripIndent` + /** + * Foo. + * + * Bar. + */ + `, + errors: [ + { + messageId: 'invalidParagraphEnding', + }, + ], + }, ], }); @@ -468,6 +673,12 @@ ruleTesterForJSONC().run(ruleName, rule, { "despicable": 3 } `, + output: stripIndent` + { + // oopsie I've been a baaad boy + "despicable": 3 + } + `, errors: [ 'Line comment cannot start with a capital letter unless the entire word is capitalized.', 'Line comment cannot end with a dot.', diff --git a/test/rules/lines-around-comment.spec.ts b/test/rules/lines-around-comment.spec.ts index 0e20c2c..0cd75a7 100644 --- a/test/rules/lines-around-comment.spec.ts +++ b/test/rules/lines-around-comment.spec.ts @@ -1520,6 +1520,118 @@ ruleTester({ allowBlockStart: true, }, }, + { + // fixer inserts an empty line before the comment + code: stripIndent` + const obj = { + // foo + a: 1, + }; + `, + output: stripIndent` + const obj = { + + // foo + a: 1, + }; + `, + errors: [ + { + messageId: 'before', + }, + ], + options: [ + { + ...defaultOptions, + allowObjectStart: false, + }, + ], + }, + { + // fixer inserts an empty line after the comment + code: stripIndent` + const obj = { + a: 1, + + // foo + }; + `, + output: stripIndent` + const obj = { + a: 1, + + // foo + + }; + `, + errors: [ + { + messageId: 'after', + }, + ], + options: [ + { + ...defaultOptions, + allowObjectEnd: false, + }, + ], + }, + { + // both fixes apply in a single pass + code: stripIndent` + enum MyEnum { + Value, + // some comment + } + `, + output: stripIndent` + enum MyEnum { + Value, + + // some comment + + } + `, + errors: [ + { + messageId: 'before', + }, + { + messageId: 'after', + }, + ], + options: [ + { + ...defaultOptions, + }, + ], + }, + { + // fixer inserts an empty line before a comment in a member expression chain + code: stripIndent` + doSomething() + .a + // foo + .b; + `, + output: stripIndent` + doSomething() + .a + + // foo + .b; + `, + errors: [ + { + messageId: 'before', + }, + ], + options: [ + { + ...defaultOptions, + }, + ], + }, ], }); From c58f157531e9da7624d62ee0907424023a3935de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ya=C3=ABl=20Guilloux?= Date: Fri, 17 Jul 2026 10:15:24 +0200 Subject: [PATCH 2/2] fix: lint --- src/rules/block-scope-case.ts | 5 +---- src/rules/comment-syntax.ts | 8 +++++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/rules/block-scope-case.ts b/src/rules/block-scope-case.ts index 132ae6c..b01c69f 100644 --- a/src/rules/block-scope-case.ts +++ b/src/rules/block-scope-case.ts @@ -41,10 +41,7 @@ export default createRule({ const lastStatement = node.consequent[node.consequent.length - 1]; const indent = ' '.repeat(node.loc.start.column); - return [ - fixer.insertTextAfter(colonToken, ' {'), - fixer.insertTextAfter(lastStatement, `\n${indent}}`), - ]; + return [fixer.insertTextAfter(colonToken, ' {'), fixer.insertTextAfter(lastStatement, `\n${indent}}`)]; }, }); } diff --git a/src/rules/comment-syntax.ts b/src/rules/comment-syntax.ts index 2233fe0..b45c1c4 100644 --- a/src/rules/comment-syntax.ts +++ b/src/rules/comment-syntax.ts @@ -189,6 +189,7 @@ export default createRule({ const indent = ' '.repeat(comment.loc.start.column + 1); const lineStartOffsets: number[] = []; + let currentOffset = 0; for (const valueLine of valueLines) { @@ -399,7 +400,12 @@ export default createRule({ }; if (isLastContentLine && !allowedParagraphEndings.some((ending) => ending === lastChar)) { - context.report({ node: comment, messageId: 'shouldEndWithDot', data: { ...lineData }, fix: appendDot }); + context.report({ + node: comment, + messageId: 'shouldEndWithDot', + data: { ...lineData }, + fix: appendDot, + }); break; }