From c0b219556b7182afafb0c60c6b11fecd0f8b1255 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:06:56 +0000 Subject: [PATCH 1/9] Add opt-in override-tag autofix support for the eslint plugin --- eslint-plugin/README.md | 8 +- eslint-plugin/src/index.ts | 104 ++++++++++++++++++++++++- eslint-plugin/src/tests/plugin.test.ts | 39 +++++++++- 3 files changed, 145 insertions(+), 6 deletions(-) diff --git a/eslint-plugin/README.md b/eslint-plugin/README.md index 3ec5b892..cf998dca 100644 --- a/eslint-plugin/README.md +++ b/eslint-plugin/README.md @@ -55,10 +55,14 @@ This ESLint plugin provides a rule for validating that TypeScript doc comments c sourceType: "module" }, rules: { - "tsdoc/syntax": "warn" + "tsdoc/syntax": ["warn", { "forbidOverrideTag": true }] } }; ``` - + +The `tsdoc/syntax` rule also supports an opt-in `forbidOverrideTag` option. When enabled, it reports +`@override` TSDoc tags on class members and offers an autofix that removes the tag from the doc comment +and inserts the TypeScript `override` modifier into the declaration. + This package is maintained by the TSDoc project. If you have questions or feedback, please [let us know](https://tsdoc.org/pages/resources/help)! diff --git a/eslint-plugin/src/index.ts b/eslint-plugin/src/index.ts index ab0c35ae..2b0aaa16 100644 --- a/eslint-plugin/src/index.ts +++ b/eslint-plugin/src/index.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { ESLintUtils, type TSESLint } from '@typescript-eslint/utils'; +import { ESLintUtils, type TSESLint, type TSESTree } from '@typescript-eslint/utils'; import type * as eslint from 'eslint'; import { TSDocParser, TextRange, TSDocConfiguration, type ParserContext } from '@microsoft/tsdoc'; @@ -17,6 +17,10 @@ defaultTSDocConfiguration.allTsdocMessageIds.forEach((messageId: string) => { tsdocMessageIds[messageId] = `${messageId}: {{unformattedText}}`; }); +interface ISyntaxRuleOptions { + forbidOverrideTag?: boolean; +} + interface IPlugin { rules: { [x: string]: eslint.Rule.RuleModule }; } @@ -41,17 +45,80 @@ function getRootDirectoryFromContext(context: TSESLint.RuleContext= 0; --i) { + const comment: eslint.Comment = comments[i]; + if (comment.type !== 'Block') { + continue; + } + + const commentText: string = sourceCode.text.slice(comment.range[0], comment.range[1]); + if (commentText.startsWith('/**')) { + return comment; + } + } + + return undefined; +} + +function hasOverrideTag(commentText: string): boolean { + return /@override\b/.test(commentText); +} + +function hasOverrideKeyword( + node: TSESTree.MethodDefinition | TSESTree.PropertyDefinition, + sourceCode: eslint.SourceCode +): boolean { + const nodeText: string = sourceCode.text.slice(node.range[0], node.range[1]); + const keyText: string = sourceCode.text.slice(node.key.range[0], node.key.range[1]); + const keyIndex: number = nodeText.indexOf(keyText); + + if (keyIndex < 0) { + return false; + } + + return /\boverride\b/.test(nodeText.slice(0, keyIndex)); +} + +function removeOverrideTag(commentText: string): string { + return commentText.replace(/@override\b/g, ''); +} + const plugin: IPlugin = { rules: { // NOTE: The actual ESLint rule name will be "tsdoc/syntax". It is calculated by deleting "eslint-plugin-" // from the NPM package name, and then appending this string. syntax: { meta: { + schema: [ + { + type: 'object', + properties: { + forbidOverrideTag: { + type: 'boolean' + } + }, + additionalProperties: false + } + ], messages: { 'error-loading-config-file': 'Error loading TSDoc config file:\n{{details}}', 'error-applying-config': 'Error applying TSDoc configuration: {{details}}', + 'override-tag-not-allowed': + 'Do not use the @override TSDoc tag; use the TypeScript override keyword instead.', ...tsdocMessageIds }, + fixable: 'code', type: 'problem', docs: { description: 'Validates that TypeScript documentation comments conform to the TSDoc standard', @@ -62,6 +129,8 @@ const plugin: IPlugin = { } }, create: (context: eslint.Rule.RuleContext) => { + const options: ISyntaxRuleOptions | undefined = context.options[0] as ISyntaxRuleOptions | undefined; + const forbidOverrideTag: boolean = options?.forbidOverrideTag ?? false; const sourceFilePath: string = context.filename; // If eslint is configured with @typescript-eslint/parser, there is a parser option // to explicitly specify where the tsconfig file is. Use that if available. @@ -150,8 +219,39 @@ const plugin: IPlugin = { } } + function checkOverrideTags(node: TSESTree.Node): void { + if (!forbidOverrideTag || !isSupportedOverrideNode(node)) { + return; + } + + const docComment: eslint.Comment | undefined = getLeadingDocComment(node, sourceCode); + if (!docComment) { + return; + } + + const commentText: string = sourceCode.text.slice(docComment.range[0], docComment.range[1]); + if (!hasOverrideTag(commentText)) { + return; + } + + if (hasOverrideKeyword(node, sourceCode)) { + return; + } + + context.report({ + node, + messageId: 'override-tag-not-allowed', + fix: (fixer: TSESLint.RuleFixer) => [ + fixer.replaceTextRange(docComment.range, removeOverrideTag(commentText)), + fixer.insertTextBefore(node.key, 'override ') + ] + }); + } + return { - Program: checkCommentBlocks + Program: checkCommentBlocks, + MethodDefinition: checkOverrideTags, + PropertyDefinition: checkOverrideTags }; } } diff --git a/eslint-plugin/src/tests/plugin.test.ts b/eslint-plugin/src/tests/plugin.test.ts index 976ac9ce..cad1ec9d 100644 --- a/eslint-plugin/src/tests/plugin.test.ts +++ b/eslint-plugin/src/tests/plugin.test.ts @@ -4,12 +4,27 @@ import { RuleTester } from 'eslint'; import * as plugin from '../index'; -const ruleTester: RuleTester = new RuleTester(); +const parser = require('@typescript-eslint/parser'); + +const ruleTester: RuleTester = new RuleTester({ + languageOptions: { + parser, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module' + } + } +}); + ruleTester.run('"tsdoc/syntax" rule', plugin.rules.syntax, { valid: [ '/**\nA great function!\n */\nfunction foobar() {}\n', '/**\nA great class!\n */\nclass FooBar {}\n', - '/** @jsx h */' + '/** @jsx h */', + { + code: '/**\n * A great method.\n */\nclass FooBar {\n public override foo(): void {}\n}\n', + options: [{ forbidOverrideTag: true }] + } ], invalid: [ { @@ -27,6 +42,26 @@ ruleTester.run('"tsdoc/syntax" rule', plugin.rules.syntax, { messageId: 'tsdoc-code-span-missing-delimiter' } ] + }, + { + code: '/**\n * @override\n */\nclass FooBar {\n foo(): void {}\n}\n', + options: [{ forbidOverrideTag: true }], + output: '/**\n * \n */\nclass FooBar {\n override foo(): void {}\n}\n', + errors: [ + { + messageId: 'override-tag-not-allowed' + } + ] + }, + { + code: '/**\n * @override\n */\nclass FooBar {\n public foo: string;\n}\n', + options: [{ forbidOverrideTag: true }], + output: '/**\n * \n */\nclass FooBar {\n public override foo: string;\n}\n', + errors: [ + { + messageId: 'override-tag-not-allowed' + } + ] } ] }); From d3ecc4784c6d35a0fe670d077c2c014a7567baaa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:09:39 +0000 Subject: [PATCH 2/9] Finalize override-tag autofix support in eslint-plugin --- eslint-plugin/src/index.ts | 66 ++++++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/eslint-plugin/src/index.ts b/eslint-plugin/src/index.ts index 2b0aaa16..0a8d37fb 100644 --- a/eslint-plugin/src/index.ts +++ b/eslint-plugin/src/index.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { ESLintUtils, type TSESLint, type TSESTree } from '@typescript-eslint/utils'; +import { ESLintUtils, type TSESLint } from '@typescript-eslint/utils'; import type * as eslint from 'eslint'; import { TSDocParser, TextRange, TSDocConfiguration, type ParserContext } from '@microsoft/tsdoc'; @@ -45,19 +45,31 @@ function getRootDirectoryFromContext(context: TSESLint.RuleContext= 0; --i) { - const comment: eslint.Comment = comments[i]; + const comment: ICommentLike = comments[i]; if (comment.type !== 'Block') { continue; } @@ -75,12 +87,14 @@ function hasOverrideTag(commentText: string): boolean { return /@override\b/.test(commentText); } -function hasOverrideKeyword( - node: TSESTree.MethodDefinition | TSESTree.PropertyDefinition, - sourceCode: eslint.SourceCode -): boolean { - const nodeText: string = sourceCode.text.slice(node.range[0], node.range[1]); - const keyText: string = sourceCode.text.slice(node.key.range[0], node.key.range[1]); +function hasOverrideKeyword(node: unknown, sourceCode: eslint.SourceCode): boolean { + if (typeof node !== 'object' || node === null || !('range' in node) || !('key' in node)) { + return false; + } + + const nodeWithKey: INodeWithKey = node as INodeWithKey; + const nodeText: string = sourceCode.text.slice(nodeWithKey.range[0], nodeWithKey.range[1]); + const keyText: string = sourceCode.text.slice(nodeWithKey.key.range[0], nodeWithKey.key.range[1]); const keyIndex: number = nodeText.indexOf(keyText); if (keyIndex < 0) { @@ -219,12 +233,13 @@ const plugin: IPlugin = { } } - function checkOverrideTags(node: TSESTree.Node): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function checkOverrideTags(node: any): void { if (!forbidOverrideTag || !isSupportedOverrideNode(node)) { return; } - const docComment: eslint.Comment | undefined = getLeadingDocComment(node, sourceCode); + const docComment: ICommentLike | undefined = getLeadingDocComment(node, sourceCode); if (!docComment) { return; } @@ -241,10 +256,13 @@ const plugin: IPlugin = { context.report({ node, messageId: 'override-tag-not-allowed', - fix: (fixer: TSESLint.RuleFixer) => [ - fixer.replaceTextRange(docComment.range, removeOverrideTag(commentText)), - fixer.insertTextBefore(node.key, 'override ') - ] + fix: (fixer: eslint.Rule.RuleFixer) => { + const commentRange: [number, number] = [docComment.range[0], docComment.range[1]]; + return [ + fixer.replaceTextRange(commentRange, removeOverrideTag(commentText)), + fixer.insertTextBefore(node.key, 'override ') + ]; + } }); } @@ -252,7 +270,7 @@ const plugin: IPlugin = { Program: checkCommentBlocks, MethodDefinition: checkOverrideTags, PropertyDefinition: checkOverrideTags - }; + } as unknown as eslint.Rule.RuleListener; } } } From 28e0af5d1e6637f99abb4906b9525588304cdabc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:17:02 +0000 Subject: [PATCH 3/9] Finalize override-tag autofix support in eslint-plugin --- eslint-plugin/README.md | 17 +++++-- eslint-plugin/src/index.ts | 70 ++++++++++++++++++++++---- eslint-plugin/src/tests/plugin.test.ts | 16 ++++-- eslint-plugin/src/tests/types.d.ts | 4 ++ 4 files changed, 89 insertions(+), 18 deletions(-) create mode 100644 eslint-plugin/src/tests/types.d.ts diff --git a/eslint-plugin/README.md b/eslint-plugin/README.md index cf998dca..5685e355 100644 --- a/eslint-plugin/README.md +++ b/eslint-plugin/README.md @@ -55,14 +55,23 @@ This ESLint plugin provides a rule for validating that TypeScript doc comments c sourceType: "module" }, rules: { - "tsdoc/syntax": ["warn", { "forbidOverrideTag": true }] + "tsdoc/syntax": "warn" } }; ``` -The `tsdoc/syntax` rule also supports an opt-in `forbidOverrideTag` option. When enabled, it reports -`@override` TSDoc tags on class members and offers an autofix that removes the tag from the doc comment -and inserts the TypeScript `override` modifier into the declaration. +To enable the opt-in override-tag check, configure the rule with the `forbidOverrideTag` option: + +```js +{ + rules: { + "tsdoc/syntax": ["warn", { "forbidOverrideTag": true }] + } +} +``` + +When enabled, the rule reports `@override` TSDoc tags on class members and offers an autofix that removes +the tag from the doc comment and inserts the TypeScript `override` modifier into the declaration. This package is maintained by the TSDoc project. If you have questions or feedback, please [let us know](https://tsdoc.org/pages/resources/help)! diff --git a/eslint-plugin/src/index.ts b/eslint-plugin/src/index.ts index 0a8d37fb..a1474284 100644 --- a/eslint-plugin/src/index.ts +++ b/eslint-plugin/src/index.ts @@ -84,28 +84,71 @@ function getLeadingDocComment(node: unknown, sourceCode: eslint.SourceCode): ICo } function hasOverrideTag(commentText: string): boolean { - return /@override\b/.test(commentText); + return /(^|\n)\s*\*?\s*@override\b/m.test(commentText); } -function hasOverrideKeyword(node: unknown, sourceCode: eslint.SourceCode): boolean { +function hasOverrideKeyword(node: unknown): boolean { + return ( + typeof node === 'object' && + node !== null && + 'override' in node && + Boolean((node as { override?: boolean }).override) + ); +} + +function getOverrideInsertionOffset(node: unknown, sourceCode: eslint.SourceCode): number { if (typeof node !== 'object' || node === null || !('range' in node) || !('key' in node)) { - return false; + return -1; } const nodeWithKey: INodeWithKey = node as INodeWithKey; const nodeText: string = sourceCode.text.slice(nodeWithKey.range[0], nodeWithKey.range[1]); - const keyText: string = sourceCode.text.slice(nodeWithKey.key.range[0], nodeWithKey.key.range[1]); - const keyIndex: number = nodeText.indexOf(keyText); + const keyStart: number = nodeWithKey.key.range[0] - nodeWithKey.range[0]; + const prefixText: string = nodeText.slice(0, keyStart); + const nodeData: { accessibility?: string; static?: boolean } = node as { + accessibility?: string; + static?: boolean; + }; + + if (nodeData.static) { + const staticMatch: RegExpMatchArray | null = prefixText.match(/\bstatic\b/); + if (staticMatch && staticMatch.index !== undefined) { + const offsetAfterStatic: number = staticMatch.index + 'static'.length; + const whitespaceMatch: RegExpMatchArray | null = prefixText.slice(offsetAfterStatic).match(/^\s*/); + const whitespaceLength: number = whitespaceMatch ? whitespaceMatch[0].length : 0; + return nodeWithKey.range[0] + offsetAfterStatic + whitespaceLength; + } + } - if (keyIndex < 0) { - return false; + if (nodeData.accessibility) { + const accessibilityText: string = nodeData.accessibility; + const accessibilityIndex: number = prefixText.lastIndexOf(accessibilityText); + if (accessibilityIndex >= 0) { + const offsetAfterAccessibility: number = accessibilityIndex + accessibilityText.length; + const whitespaceMatch: RegExpMatchArray | null = prefixText + .slice(offsetAfterAccessibility) + .match(/^\s*/); + const whitespaceLength: number = whitespaceMatch ? whitespaceMatch[0].length : 0; + return nodeWithKey.range[0] + offsetAfterAccessibility + whitespaceLength; + } } - return /\boverride\b/.test(nodeText.slice(0, keyIndex)); + return nodeWithKey.key.range[0]; } function removeOverrideTag(commentText: string): string { - return commentText.replace(/@override\b/g, ''); + return commentText + .split(/\r?\n/) + .map((line: string) => { + const updatedLine: string = line.replace(/@override\b/g, ''); + if (updatedLine.trim().length > 0) { + return updatedLine; + } + + const commentPrefixMatch: RegExpMatchArray | null = line.match(/^(\s*\*)(.*)$/); + return commentPrefixMatch ? commentPrefixMatch[1] : ''; + }) + .join('\n'); } const plugin: IPlugin = { @@ -249,7 +292,12 @@ const plugin: IPlugin = { return; } - if (hasOverrideKeyword(node, sourceCode)) { + if (hasOverrideKeyword(node)) { + return; + } + + const overrideInsertionOffset: number = getOverrideInsertionOffset(node, sourceCode); + if (overrideInsertionOffset < 0) { return; } @@ -260,7 +308,7 @@ const plugin: IPlugin = { const commentRange: [number, number] = [docComment.range[0], docComment.range[1]]; return [ fixer.replaceTextRange(commentRange, removeOverrideTag(commentText)), - fixer.insertTextBefore(node.key, 'override ') + fixer.insertTextAfterRange([overrideInsertionOffset, overrideInsertionOffset], 'override ') ]; } }); diff --git a/eslint-plugin/src/tests/plugin.test.ts b/eslint-plugin/src/tests/plugin.test.ts index cad1ec9d..1863e3a9 100644 --- a/eslint-plugin/src/tests/plugin.test.ts +++ b/eslint-plugin/src/tests/plugin.test.ts @@ -4,7 +4,7 @@ import { RuleTester } from 'eslint'; import * as plugin from '../index'; -const parser = require('@typescript-eslint/parser'); +import * as parser from '@typescript-eslint/parser'; const ruleTester: RuleTester = new RuleTester({ languageOptions: { @@ -46,7 +46,7 @@ ruleTester.run('"tsdoc/syntax" rule', plugin.rules.syntax, { { code: '/**\n * @override\n */\nclass FooBar {\n foo(): void {}\n}\n', options: [{ forbidOverrideTag: true }], - output: '/**\n * \n */\nclass FooBar {\n override foo(): void {}\n}\n', + output: '/**\n\n */\nclass FooBar {\n override foo(): void {}\n}\n', errors: [ { messageId: 'override-tag-not-allowed' @@ -56,7 +56,17 @@ ruleTester.run('"tsdoc/syntax" rule', plugin.rules.syntax, { { code: '/**\n * @override\n */\nclass FooBar {\n public foo: string;\n}\n', options: [{ forbidOverrideTag: true }], - output: '/**\n * \n */\nclass FooBar {\n public override foo: string;\n}\n', + output: '/**\n\n */\nclass FooBar {\n public override foo: string;\n}\n', + errors: [ + { + messageId: 'override-tag-not-allowed' + } + ] + }, + { + code: '/**\n * @override\n */\nclass FooBar {\n static foo: string;\n}\n', + options: [{ forbidOverrideTag: true }], + output: '/**\n\n */\nclass FooBar {\n static override foo: string;\n}\n', errors: [ { messageId: 'override-tag-not-allowed' diff --git a/eslint-plugin/src/tests/types.d.ts b/eslint-plugin/src/tests/types.d.ts new file mode 100644 index 00000000..877b6100 --- /dev/null +++ b/eslint-plugin/src/tests/types.d.ts @@ -0,0 +1,4 @@ +declare module '@typescript-eslint/parser' { + const parser: any; + export = parser; +} From 77f7ae3414f32070815b0de19ee04ad1c553ce46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:23:38 +0000 Subject: [PATCH 4/9] Use parsed TSDoc state for override-tag detection --- eslint-plugin/src/index.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/eslint-plugin/src/index.ts b/eslint-plugin/src/index.ts index a1474284..e05f045c 100644 --- a/eslint-plugin/src/index.ts +++ b/eslint-plugin/src/index.ts @@ -83,10 +83,6 @@ function getLeadingDocComment(node: unknown, sourceCode: eslint.SourceCode): ICo return undefined; } -function hasOverrideTag(commentText: string): boolean { - return /(^|\n)\s*\*?\s*@override\b/m.test(commentText); -} - function hasOverrideKeyword(node: unknown): boolean { return ( typeof node === 'object' && @@ -288,7 +284,13 @@ const plugin: IPlugin = { } const commentText: string = sourceCode.text.slice(docComment.range[0], docComment.range[1]); - if (!hasOverrideTag(commentText)) { + const textRange: TextRange = TextRange.fromStringRange( + sourceCode.text, + docComment.range[0], + docComment.range[1] + ); + const parserContext: ParserContext = tsdocParser.parseRange(textRange); + if (!parserContext.docComment.modifierTagSet.isOverride()) { return; } @@ -314,11 +316,13 @@ const plugin: IPlugin = { }); } - return { + const listener: eslint.Rule.RuleListener = { Program: checkCommentBlocks, MethodDefinition: checkOverrideTags, PropertyDefinition: checkOverrideTags - } as unknown as eslint.Rule.RuleListener; + }; + + return listener; } } } From 682bbe7c157b0a147534973f42605d668bb52e4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:40:15 +0000 Subject: [PATCH 5/9] Add changefile for eslint-plugin override-tag option --- ...d-forbid-override-tag-option_2026-07-18-01-39.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/eslint-plugin-tsdoc/add-forbid-override-tag-option_2026-07-18-01-39.json diff --git a/common/changes/eslint-plugin-tsdoc/add-forbid-override-tag-option_2026-07-18-01-39.json b/common/changes/eslint-plugin-tsdoc/add-forbid-override-tag-option_2026-07-18-01-39.json new file mode 100644 index 00000000..15b24e7b --- /dev/null +++ b/common/changes/eslint-plugin-tsdoc/add-forbid-override-tag-option_2026-07-18-01-39.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "eslint-plugin-tsdoc", + "comment": "Add a new `forbidOverrideTag` option to the TSDoc ESLint rule to report and autofix `@override` tags.", + "type": "minor" + } + ], + "packageName": "eslint-plugin-tsdoc", + "email": "198982749+Copilot@users.noreply.github.com" +} From 3239ca00023faccff125b9a60329b0a5fd4a38cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:47:44 +0000 Subject: [PATCH 6/9] Address remaining review feedback in eslint-plugin --- eslint-plugin/README.md | 2 +- eslint-plugin/src/index.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/eslint-plugin/README.md b/eslint-plugin/README.md index 5685e355..d2a95580 100644 --- a/eslint-plugin/README.md +++ b/eslint-plugin/README.md @@ -55,7 +55,7 @@ This ESLint plugin provides a rule for validating that TypeScript doc comments c sourceType: "module" }, rules: { - "tsdoc/syntax": "warn" + "tsdoc/syntax": "warn" } }; ``` diff --git a/eslint-plugin/src/index.ts b/eslint-plugin/src/index.ts index e05f045c..3bc3c6da 100644 --- a/eslint-plugin/src/index.ts +++ b/eslint-plugin/src/index.ts @@ -272,8 +272,7 @@ const plugin: IPlugin = { } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function checkOverrideTags(node: any): void { + function checkOverrideTags(node: unknown): void { if (!forbidOverrideTag || !isSupportedOverrideNode(node)) { return; } @@ -304,7 +303,7 @@ const plugin: IPlugin = { } context.report({ - node, + node: node as never, messageId: 'override-tag-not-allowed', fix: (fixer: eslint.Rule.RuleFixer) => { const commentRange: [number, number] = [docComment.range[0], docComment.range[1]]; From b3cca24a8548fcb85a190b50d0723c048b07f130 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 18 Jul 2026 22:59:54 -0700 Subject: [PATCH 7/9] Simplify override-tag detection by reusing the parsed TSDoc context Reuse the ParserContext already produced for each doc comment instead of re-parsing it in a separate visitor, and fix the autofix and test setup so the forbidOverrideTag option actually works. --- common/config/rush/pnpm-lock.yaml | 3 + eslint-plugin/README.md | 8 +- eslint-plugin/package.json | 1 + eslint-plugin/src/index.ts | 224 ++++++++++--------------- eslint-plugin/src/tests/parser.d.ts | 8 + eslint-plugin/src/tests/plugin.test.ts | 44 ++++- eslint-plugin/src/tests/types.d.ts | 4 - 7 files changed, 138 insertions(+), 154 deletions(-) create mode 100644 eslint-plugin/src/tests/parser.d.ts delete mode 100644 eslint-plugin/src/tests/types.d.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index ac24a4cd..69fd27ce 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@types/estree': specifier: 1.0.1 version: 1.0.1 + '@typescript-eslint/parser': + specifier: ~8.56.0 + version: 8.56.1(eslint@9.25.1)(typescript@5.8.3) eslint: specifier: ~9.25.1 version: 9.25.1 diff --git a/eslint-plugin/README.md b/eslint-plugin/README.md index d2a95580..6943886c 100644 --- a/eslint-plugin/README.md +++ b/eslint-plugin/README.md @@ -59,9 +59,9 @@ This ESLint plugin provides a rule for validating that TypeScript doc comments c } }; ``` - + To enable the opt-in override-tag check, configure the rule with the `forbidOverrideTag` option: - + ```js { rules: { @@ -69,9 +69,9 @@ To enable the opt-in override-tag check, configure the rule with the `forbidOver } } ``` - + When enabled, the rule reports `@override` TSDoc tags on class members and offers an autofix that removes the tag from the doc comment and inserts the TypeScript `override` modifier into the declaration. - + This package is maintained by the TSDoc project. If you have questions or feedback, please [let us know](https://tsdoc.org/pages/resources/help)! diff --git a/eslint-plugin/package.json b/eslint-plugin/package.json index c8273c9d..e94223c0 100644 --- a/eslint-plugin/package.json +++ b/eslint-plugin/package.json @@ -36,6 +36,7 @@ "@rushstack/heft": "1.2.7", "@types/eslint": "8.40.1", "@types/estree": "1.0.1", + "@typescript-eslint/parser": "~8.56.0", "eslint": "~9.25.1" } } diff --git a/eslint-plugin/src/index.ts b/eslint-plugin/src/index.ts index 3bc3c6da..36ff2b59 100644 --- a/eslint-plugin/src/index.ts +++ b/eslint-plugin/src/index.ts @@ -45,108 +45,52 @@ function getRootDirectoryFromContext(context: TSESLint.RuleContext= 0; --i) { - const comment: ICommentLike = comments[i]; - if (comment.type !== 'Block') { - continue; - } - - const commentText: string = sourceCode.text.slice(comment.range[0], comment.range[1]); - if (commentText.startsWith('/**')) { - return comment; - } - } - - return undefined; -} - -function hasOverrideKeyword(node: unknown): boolean { - return ( - typeof node === 'object' && - node !== null && - 'override' in node && - Boolean((node as { override?: boolean }).override) - ); -} - -function getOverrideInsertionOffset(node: unknown, sourceCode: eslint.SourceCode): number { - if (typeof node !== 'object' || node === null || !('range' in node) || !('key' in node)) { - return -1; - } - - const nodeWithKey: INodeWithKey = node as INodeWithKey; - const nodeText: string = sourceCode.text.slice(nodeWithKey.range[0], nodeWithKey.range[1]); - const keyStart: number = nodeWithKey.key.range[0] - nodeWithKey.range[0]; - const prefixText: string = nodeText.slice(0, keyStart); - const nodeData: { accessibility?: string; static?: boolean } = node as { - accessibility?: string; - static?: boolean; - }; - - if (nodeData.static) { - const staticMatch: RegExpMatchArray | null = prefixText.match(/\bstatic\b/); - if (staticMatch && staticMatch.index !== undefined) { - const offsetAfterStatic: number = staticMatch.index + 'static'.length; - const whitespaceMatch: RegExpMatchArray | null = prefixText.slice(offsetAfterStatic).match(/^\s*/); - const whitespaceLength: number = whitespaceMatch ? whitespaceMatch[0].length : 0; - return nodeWithKey.range[0] + offsetAfterStatic + whitespaceLength; - } - } - - if (nodeData.accessibility) { - const accessibilityText: string = nodeData.accessibility; - const accessibilityIndex: number = prefixText.lastIndexOf(accessibilityText); - if (accessibilityIndex >= 0) { - const offsetAfterAccessibility: number = accessibilityIndex + accessibilityText.length; - const whitespaceMatch: RegExpMatchArray | null = prefixText - .slice(offsetAfterAccessibility) - .match(/^\s*/); - const whitespaceLength: number = whitespaceMatch ? whitespaceMatch[0].length : 0; - return nodeWithKey.range[0] + offsetAfterAccessibility + whitespaceLength; - } - } - - return nodeWithKey.key.range[0]; +// The comment token type produced by `SourceCode.getAllComments()`. Derived from the ESLint +// types so it stays consistent with the `@types/estree` version they were built against. +type TDocComment = ReturnType[number]; + +// A class member (method or property) that may carry an `@override` doc tag and/or a +// TypeScript `override` modifier. The `override` field is a TypeScript-ESTree extension. +interface IClassMemberNode { + type: 'MethodDefinition' | 'PropertyDefinition'; + key: eslint.Rule.Node; + parent: eslint.Rule.Node; + override?: boolean; } +// Removes the `@override` modifier tag from a doc comment's source text. A line whose only +// remaining content is the comment framing is collapsed to an empty line. function removeOverrideTag(commentText: string): string { return commentText .split(/\r?\n/) .map((line: string) => { - const updatedLine: string = line.replace(/@override\b/g, ''); - if (updatedLine.trim().length > 0) { - return updatedLine; + if (!/@override\b/.test(line)) { + return line; } - const commentPrefixMatch: RegExpMatchArray | null = line.match(/^(\s*\*)(.*)$/); - return commentPrefixMatch ? commentPrefixMatch[1] : ''; + const withoutTag: string = line.replace(/@override\b/g, '').replace(/\s+$/, ''); + return /^\s*\*?\s*$/.test(withoutTag) ? '' : withoutTag; }) .join('\n'); } +// Returns the node or token that the TypeScript `override` keyword should be inserted before. +// `override` must follow accessibility (e.g. `public`) and `static` modifiers, but precede +// `readonly`/`abstract`, so we walk backwards past those starting from the member's key. +function getOverrideInsertionTarget( + node: IClassMemberNode, + sourceCode: eslint.SourceCode +): eslint.Rule.Node | eslint.AST.Token { + let target: eslint.Rule.Node | eslint.AST.Token = node.key; + let token: eslint.AST.Token | null = sourceCode.getTokenBefore(target); + while (token && (token.value === 'readonly' || token.value === 'abstract')) { + target = token; + token = sourceCode.getTokenBefore(token); + } + + return target; +} + const plugin: IPlugin = { rules: { // NOTE: The actual ESLint rule name will be "tsdoc/syntax". It is calculated by deleting "eslint-plugin-" @@ -232,6 +176,51 @@ const plugin: IPlugin = { const tsdocParser: TSDocParser = new TSDocParser(tsdocConfiguration); const sourceCode: eslint.SourceCode = context.sourceCode ?? context.getSourceCode(); + + // Finds the class member (method or property) documented by a doc comment, or undefined + // if the comment does not immediately precede a supported member. + function getDocumentedClassMember(comment: TDocComment): IClassMemberNode | undefined { + const tokenAfter: eslint.AST.Token | null = sourceCode.getTokenAfter(comment); + if (!tokenAfter) { + return undefined; + } + + let node: eslint.Rule.Node | null = sourceCode.getNodeByRangeIndex( + tokenAfter.range[0] + ) as eslint.Rule.Node | null; + while (node) { + if (node.type === 'MethodDefinition' || node.type === 'PropertyDefinition') { + return node as unknown as IClassMemberNode; + } + node = node.parent as eslint.Rule.Node | null; + } + + return undefined; + } + + function reportOverrideTag(comment: TDocComment): void { + const node: IClassMemberNode | undefined = getDocumentedClassMember(comment); + if (!node || !comment.range) { + return; + } + + const commentRange: [number, number] = [comment.range[0], comment.range[1]]; + context.report({ + node: node as unknown as eslint.Rule.Node, + messageId: 'override-tag-not-allowed', + fix: (fixer: eslint.Rule.RuleFixer) => { + const commentText: string = sourceCode.text.slice(commentRange[0], commentRange[1]); + const fixes: eslint.Rule.Fix[] = [ + fixer.replaceTextRange(commentRange, removeOverrideTag(commentText)) + ]; + if (!node.override) { + fixes.push(fixer.insertTextBefore(getOverrideInsertionTarget(node, sourceCode), 'override ')); + } + return fixes; + } + }); + } + function checkCommentBlocks(): void { for (const comment of sourceCode.getAllComments()) { if (comment.type !== 'Block') { @@ -256,6 +245,8 @@ const plugin: IPlugin = { continue; } + // Parse the comment once and reuse the result for both syntax validation and the + // optional `@override` modifier check. const parserContext: ParserContext = tsdocParser.parseRange(textRange); for (const message of parserContext.log.messages) { context.report({ @@ -269,59 +260,16 @@ const plugin: IPlugin = { } }); } - } - } - function checkOverrideTags(node: unknown): void { - if (!forbidOverrideTag || !isSupportedOverrideNode(node)) { - return; - } - - const docComment: ICommentLike | undefined = getLeadingDocComment(node, sourceCode); - if (!docComment) { - return; - } - - const commentText: string = sourceCode.text.slice(docComment.range[0], docComment.range[1]); - const textRange: TextRange = TextRange.fromStringRange( - sourceCode.text, - docComment.range[0], - docComment.range[1] - ); - const parserContext: ParserContext = tsdocParser.parseRange(textRange); - if (!parserContext.docComment.modifierTagSet.isOverride()) { - return; - } - - if (hasOverrideKeyword(node)) { - return; - } - - const overrideInsertionOffset: number = getOverrideInsertionOffset(node, sourceCode); - if (overrideInsertionOffset < 0) { - return; - } - - context.report({ - node: node as never, - messageId: 'override-tag-not-allowed', - fix: (fixer: eslint.Rule.RuleFixer) => { - const commentRange: [number, number] = [docComment.range[0], docComment.range[1]]; - return [ - fixer.replaceTextRange(commentRange, removeOverrideTag(commentText)), - fixer.insertTextAfterRange([overrideInsertionOffset, overrideInsertionOffset], 'override ') - ]; + if (forbidOverrideTag && parserContext.docComment.modifierTagSet.isOverride()) { + reportOverrideTag(comment); } - }); + } } - const listener: eslint.Rule.RuleListener = { - Program: checkCommentBlocks, - MethodDefinition: checkOverrideTags, - PropertyDefinition: checkOverrideTags + return { + Program: checkCommentBlocks }; - - return listener; } } } diff --git a/eslint-plugin/src/tests/parser.d.ts b/eslint-plugin/src/tests/parser.d.ts new file mode 100644 index 00000000..a34f5859 --- /dev/null +++ b/eslint-plugin/src/tests/parser.d.ts @@ -0,0 +1,8 @@ +// The `@typescript-eslint/parser` package ships its types via a `package.json` "exports" +// map, which this project's "classic" module resolution cannot follow. It is only used at +// runtime by the RuleTester, so an ambient declaration is sufficient here. +declare module '@typescript-eslint/parser' { + import type { Linter } from 'eslint'; + const parser: Linter.Parser; + export = parser; +} diff --git a/eslint-plugin/src/tests/plugin.test.ts b/eslint-plugin/src/tests/plugin.test.ts index 1863e3a9..a414f70f 100644 --- a/eslint-plugin/src/tests/plugin.test.ts +++ b/eslint-plugin/src/tests/plugin.test.ts @@ -21,10 +21,13 @@ ruleTester.run('"tsdoc/syntax" rule', plugin.rules.syntax, { '/**\nA great function!\n */\nfunction foobar() {}\n', '/**\nA great class!\n */\nclass FooBar {}\n', '/** @jsx h */', + // A member that already uses the `override` keyword and no `@override` tag is fine. { - code: '/**\n * A great method.\n */\nclass FooBar {\n public override foo(): void {}\n}\n', + code: 'class FooBar {\n /**\n * A great method.\n */\n public override foo(): void {}\n}\n', options: [{ forbidOverrideTag: true }] - } + }, + // Without the option enabled, `@override` tags are not reported. + 'class FooBar {\n /**\n * @override\n */\n foo(): void {}\n}\n' ], invalid: [ { @@ -43,30 +46,55 @@ ruleTester.run('"tsdoc/syntax" rule', plugin.rules.syntax, { } ] }, + // `@override` on a method: remove the tag and add the `override` keyword. + { + code: 'class FooBar {\n /**\n * @override\n */\n foo(): void {}\n}\n', + options: [{ forbidOverrideTag: true }], + output: 'class FooBar {\n /**\n\n */\n override foo(): void {}\n}\n', + errors: [ + { + messageId: 'override-tag-not-allowed' + } + ] + }, + // `@override` on a property with an accessibility modifier. + { + code: 'class FooBar {\n /**\n * @override\n */\n public foo: string;\n}\n', + options: [{ forbidOverrideTag: true }], + output: 'class FooBar {\n /**\n\n */\n public override foo: string;\n}\n', + errors: [ + { + messageId: 'override-tag-not-allowed' + } + ] + }, + // `@override` on a static property. { - code: '/**\n * @override\n */\nclass FooBar {\n foo(): void {}\n}\n', + code: 'class FooBar {\n /**\n * @override\n */\n static foo: string;\n}\n', options: [{ forbidOverrideTag: true }], - output: '/**\n\n */\nclass FooBar {\n override foo(): void {}\n}\n', + output: 'class FooBar {\n /**\n\n */\n static override foo: string;\n}\n', errors: [ { messageId: 'override-tag-not-allowed' } ] }, + // `override` must precede `readonly`, so it is inserted before it. { - code: '/**\n * @override\n */\nclass FooBar {\n public foo: string;\n}\n', + code: 'class FooBar {\n /**\n * @override\n */\n protected readonly foo: string;\n}\n', options: [{ forbidOverrideTag: true }], - output: '/**\n\n */\nclass FooBar {\n public override foo: string;\n}\n', + output: 'class FooBar {\n /**\n\n */\n protected override readonly foo: string;\n}\n', errors: [ { messageId: 'override-tag-not-allowed' } ] }, + // A redundant `@override` tag next to an existing `override` keyword: only remove the tag. { - code: '/**\n * @override\n */\nclass FooBar {\n static foo: string;\n}\n', + code: 'class FooBar {\n /**\n * @override\n */\n override foo(): void {}\n}\n', options: [{ forbidOverrideTag: true }], - output: '/**\n\n */\nclass FooBar {\n static override foo: string;\n}\n', + output: 'class FooBar {\n /**\n\n */\n override foo(): void {}\n}\n', errors: [ { messageId: 'override-tag-not-allowed' diff --git a/eslint-plugin/src/tests/types.d.ts b/eslint-plugin/src/tests/types.d.ts deleted file mode 100644 index 877b6100..00000000 --- a/eslint-plugin/src/tests/types.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module '@typescript-eslint/parser' { - const parser: any; - export = parser; -} From a6348250ef19a5ec60048457efc738382e95fb42 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 18 Jul 2026 23:02:06 -0700 Subject: [PATCH 8/9] Minor code cleanup. --- eslint-plugin/src/index.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/eslint-plugin/src/index.ts b/eslint-plugin/src/index.ts index 36ff2b59..1709d583 100644 --- a/eslint-plugin/src/index.ts +++ b/eslint-plugin/src/index.ts @@ -17,10 +17,6 @@ defaultTSDocConfiguration.allTsdocMessageIds.forEach((messageId: string) => { tsdocMessageIds[messageId] = `${messageId}: {{unformattedText}}`; }); -interface ISyntaxRuleOptions { - forbidOverrideTag?: boolean; -} - interface IPlugin { rules: { [x: string]: eslint.Rule.RuleModule }; } @@ -126,9 +122,11 @@ const plugin: IPlugin = { } }, create: (context: eslint.Rule.RuleContext) => { - const options: ISyntaxRuleOptions | undefined = context.options[0] as ISyntaxRuleOptions | undefined; - const forbidOverrideTag: boolean = options?.forbidOverrideTag ?? false; - const sourceFilePath: string = context.filename; + const { + options: [{ forbidOverrideTag = false } = {}], + filename: sourceFilePath + } = context; + // If eslint is configured with @typescript-eslint/parser, there is a parser option // to explicitly specify where the tsconfig file is. Use that if available. const tsConfigDir: string | undefined = getRootDirectoryFromContext( @@ -192,7 +190,8 @@ const plugin: IPlugin = { if (node.type === 'MethodDefinition' || node.type === 'PropertyDefinition') { return node as unknown as IClassMemberNode; } - node = node.parent as eslint.Rule.Node | null; + + node = node.parent; } return undefined; @@ -216,6 +215,7 @@ const plugin: IPlugin = { if (!node.override) { fixes.push(fixer.insertTextBefore(getOverrideInsertionTarget(node, sourceCode), 'override ')); } + return fixes; } }); From 48e7a42441e240bd1147880927fdb1e78c8877fe Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 18 Jul 2026 23:34:09 -0700 Subject: [PATCH 9/9] Use TSDoc tag ranges for override autofix and handle comment cleanup Locate the @override tag via TSDoc's recorded token range instead of regex, remove the whole comment when it holds nothing else, and drop trailing empty comment lines. Also narrow the ESLint node union to remove several casts. --- eslint-plugin/src/index.ts | 124 ++++++++++++++++++------- eslint-plugin/src/tests/plugin.test.ts | 27 ++++-- 2 files changed, 108 insertions(+), 43 deletions(-) diff --git a/eslint-plugin/src/index.ts b/eslint-plugin/src/index.ts index 1709d583..3348aa32 100644 --- a/eslint-plugin/src/index.ts +++ b/eslint-plugin/src/index.ts @@ -4,7 +4,14 @@ import { ESLintUtils, type TSESLint } from '@typescript-eslint/utils'; import type * as eslint from 'eslint'; -import { TSDocParser, TextRange, TSDocConfiguration, type ParserContext } from '@microsoft/tsdoc'; +import { + TSDocParser, + TextRange, + TSDocConfiguration, + StandardTags, + type ParserContext, + type DocBlockTag +} from '@microsoft/tsdoc'; import type { TSDocConfigFile } from '@microsoft/tsdoc-config'; import { Debug } from './Debug'; @@ -45,39 +52,60 @@ function getRootDirectoryFromContext(context: TSESLint.RuleContext[number]; -// A class member (method or property) that may carry an `@override` doc tag and/or a -// TypeScript `override` modifier. The `override` field is a TypeScript-ESTree extension. -interface IClassMemberNode { - type: 'MethodDefinition' | 'PropertyDefinition'; - key: eslint.Rule.Node; - parent: eslint.Rule.Node; - override?: boolean; -} +// A class member (method or property) that may carry an `@override` doc tag and/or a TypeScript +// `override` modifier. `override` is a TypeScript-ESTree extension not present in the ESTree types. +type TClassMemberNode = ( + | (eslint.Rule.Node & { type: 'MethodDefinition' }) + | (eslint.Rule.Node & { type: 'PropertyDefinition' }) +) & { override?: boolean }; + +// Rewrites a doc comment's source text with the `@override` modifier tag removed. Returns the new +// comment text, or `undefined` when the comment would be left with nothing but its framing (in which +// case the caller should remove the entire comment). The tag range is relative to `commentText`. +function rewriteCommentWithoutOverrideTag( + commentText: string, + tagStart: number, + tagEnd: number +): string | undefined { + // Drop the tag along with any horizontal whitespace immediately surrounding it, so we don't leave + // a dangling space (e.g. `* @override` becomes `*`). + let start: number = tagStart; + let end: number = tagEnd; + while (start > 0 && (commentText[start - 1] === ' ' || commentText[start - 1] === '\t')) { + --start; + } -// Removes the `@override` modifier tag from a doc comment's source text. A line whose only -// remaining content is the comment framing is collapsed to an empty line. -function removeOverrideTag(commentText: string): string { - return commentText - .split(/\r?\n/) - .map((line: string) => { - if (!/@override\b/.test(line)) { - return line; - } + while (end < commentText.length && (commentText[end] === ' ' || commentText[end] === '\t')) { + ++end; + } + + const lines: string[] = (commentText.slice(0, start) + commentText.slice(end)).split('\n'); + + // Drop now-empty comment lines (whitespace and an optional `*`) that trail before the closing `*/`. + while (lines.length > 1 && /^\s*\*?\s*$/.test(lines[lines.length - 2])) { + lines.splice(lines.length - 2, 1); + } + + const cleaned: string = lines.join('\n'); - const withoutTag: string = line.replace(/@override\b/g, '').replace(/\s+$/, ''); - return /^\s*\*?\s*$/.test(withoutTag) ? '' : withoutTag; - }) - .join('\n'); + // If only the `/**` ... `*/` framing remains, signal that the whole comment should be removed. + const remainingContent: string = cleaned + .replace(/^\/\*\*?/, '') + .replace(/\*\/\s*$/, '') + .replace(/^[ \t]*\*/gm, '') + .trim(); + + return remainingContent.length === 0 ? undefined : cleaned; } // Returns the node or token that the TypeScript `override` keyword should be inserted before. // `override` must follow accessibility (e.g. `public`) and `static` modifiers, but precede // `readonly`/`abstract`, so we walk backwards past those starting from the member's key. function getOverrideInsertionTarget( - node: IClassMemberNode, + node: TClassMemberNode, sourceCode: eslint.SourceCode -): eslint.Rule.Node | eslint.AST.Token { - let target: eslint.Rule.Node | eslint.AST.Token = node.key; +): TClassMemberNode['key'] | eslint.AST.Token { + let target: TClassMemberNode['key'] | eslint.AST.Token = node.key; let token: eslint.AST.Token | null = sourceCode.getTokenBefore(target); while (token && (token.value === 'readonly' || token.value === 'abstract')) { target = token; @@ -177,18 +205,20 @@ const plugin: IPlugin = { // Finds the class member (method or property) documented by a doc comment, or undefined // if the comment does not immediately precede a supported member. - function getDocumentedClassMember(comment: TDocComment): IClassMemberNode | undefined { + function getDocumentedClassMember(comment: TDocComment): TClassMemberNode | undefined { const tokenAfter: eslint.AST.Token | null = sourceCode.getTokenAfter(comment); if (!tokenAfter) { return undefined; } + // `getNodeByRangeIndex` is typed as returning a bare ESTree node, but at runtime the + // parent links are populated, so we treat the result as a Rule.Node for the walk. let node: eslint.Rule.Node | null = sourceCode.getNodeByRangeIndex( tokenAfter.range[0] ) as eslint.Rule.Node | null; while (node) { if (node.type === 'MethodDefinition' || node.type === 'PropertyDefinition') { - return node as unknown as IClassMemberNode; + return node; } node = node.parent; @@ -197,21 +227,43 @@ const plugin: IPlugin = { return undefined; } - function reportOverrideTag(comment: TDocComment): void { - const node: IClassMemberNode | undefined = getDocumentedClassMember(comment); + function reportOverrideTag(comment: TDocComment, parserContext: ParserContext): void { + const node: TClassMemberNode | undefined = getDocumentedClassMember(comment); if (!node || !comment.range) { return; } - const commentRange: [number, number] = [comment.range[0], comment.range[1]]; + const overrideTag: DocBlockTag | undefined = parserContext.docComment.modifierTagSet.tryGetTag( + StandardTags.override + ); + if (!overrideTag) { + return; + } + + const [commentStart, commentEnd] = comment.range; + const { pos, end } = overrideTag.getTokenSequence().getContainingTextRange(); + const newCommentText: string | undefined = rewriteCommentWithoutOverrideTag( + sourceCode.text.slice(commentStart, commentEnd), + pos - commentStart, + end - commentStart + ); + context.report({ - node: node as unknown as eslint.Rule.Node, + node, messageId: 'override-tag-not-allowed', fix: (fixer: eslint.Rule.RuleFixer) => { - const commentText: string = sourceCode.text.slice(commentRange[0], commentRange[1]); - const fixes: eslint.Rule.Fix[] = [ - fixer.replaceTextRange(commentRange, removeOverrideTag(commentText)) - ]; + const fixes: eslint.Rule.Fix[] = []; + if (newCommentText === undefined) { + // The comment held nothing but `@override`, so remove it along with the whitespace + // up to the member declaration. + const memberToken: eslint.AST.Token | null = sourceCode.getTokenAfter(comment); + fixes.push( + fixer.removeRange([commentStart, memberToken ? memberToken.range[0] : commentEnd]) + ); + } else { + fixes.push(fixer.replaceTextRange([commentStart, commentEnd], newCommentText)); + } + if (!node.override) { fixes.push(fixer.insertTextBefore(getOverrideInsertionTarget(node, sourceCode), 'override ')); } @@ -262,7 +314,7 @@ const plugin: IPlugin = { } if (forbidOverrideTag && parserContext.docComment.modifierTagSet.isOverride()) { - reportOverrideTag(comment); + reportOverrideTag(comment, parserContext); } } } diff --git a/eslint-plugin/src/tests/plugin.test.ts b/eslint-plugin/src/tests/plugin.test.ts index a414f70f..bcd0ecfb 100644 --- a/eslint-plugin/src/tests/plugin.test.ts +++ b/eslint-plugin/src/tests/plugin.test.ts @@ -46,11 +46,12 @@ ruleTester.run('"tsdoc/syntax" rule', plugin.rules.syntax, { } ] }, - // `@override` on a method: remove the tag and add the `override` keyword. + // `@override` on a method: the comment held only the tag, so it is removed entirely and the + // `override` keyword is added. { code: 'class FooBar {\n /**\n * @override\n */\n foo(): void {}\n}\n', options: [{ forbidOverrideTag: true }], - output: 'class FooBar {\n /**\n\n */\n override foo(): void {}\n}\n', + output: 'class FooBar {\n override foo(): void {}\n}\n', errors: [ { messageId: 'override-tag-not-allowed' @@ -61,7 +62,7 @@ ruleTester.run('"tsdoc/syntax" rule', plugin.rules.syntax, { { code: 'class FooBar {\n /**\n * @override\n */\n public foo: string;\n}\n', options: [{ forbidOverrideTag: true }], - output: 'class FooBar {\n /**\n\n */\n public override foo: string;\n}\n', + output: 'class FooBar {\n public override foo: string;\n}\n', errors: [ { messageId: 'override-tag-not-allowed' @@ -72,7 +73,7 @@ ruleTester.run('"tsdoc/syntax" rule', plugin.rules.syntax, { { code: 'class FooBar {\n /**\n * @override\n */\n static foo: string;\n}\n', options: [{ forbidOverrideTag: true }], - output: 'class FooBar {\n /**\n\n */\n static override foo: string;\n}\n', + output: 'class FooBar {\n static override foo: string;\n}\n', errors: [ { messageId: 'override-tag-not-allowed' @@ -83,18 +84,30 @@ ruleTester.run('"tsdoc/syntax" rule', plugin.rules.syntax, { { code: 'class FooBar {\n /**\n * @override\n */\n protected readonly foo: string;\n}\n', options: [{ forbidOverrideTag: true }], - output: 'class FooBar {\n /**\n\n */\n protected override readonly foo: string;\n}\n', + output: 'class FooBar {\n protected override readonly foo: string;\n}\n', errors: [ { messageId: 'override-tag-not-allowed' } ] }, - // A redundant `@override` tag next to an existing `override` keyword: only remove the tag. + // A redundant `@override` tag next to an existing `override` keyword: remove the comment only. { code: 'class FooBar {\n /**\n * @override\n */\n override foo(): void {}\n}\n', options: [{ forbidOverrideTag: true }], - output: 'class FooBar {\n /**\n\n */\n override foo(): void {}\n}\n', + output: 'class FooBar {\n override foo(): void {}\n}\n', + errors: [ + { + messageId: 'override-tag-not-allowed' + } + ] + }, + // A comment with other content keeps the comment; only the `@override` line is removed, without + // leaving a trailing empty `*` line. + { + code: 'class FooBar {\n /**\n * A great method.\n * @override\n */\n foo(): void {}\n}\n', + options: [{ forbidOverrideTag: true }], + output: 'class FooBar {\n /**\n * A great method.\n */\n override foo(): void {}\n}\n', errors: [ { messageId: 'override-tag-not-allowed'