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" +} 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 3ec5b892..6943886c 100644 --- a/eslint-plugin/README.md +++ b/eslint-plugin/README.md @@ -60,5 +60,18 @@ 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: { + "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/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 ab0c35ae..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'; @@ -41,17 +48,98 @@ 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. `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; + } + + 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'); + + // 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: TClassMemberNode, + sourceCode: eslint.SourceCode +): 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; + 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-" // 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,7 +150,11 @@ const plugin: IPlugin = { } }, create: (context: eslint.Rule.RuleContext) => { - 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( @@ -110,6 +202,77 @@ 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): 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; + } + + node = node.parent; + } + + return undefined; + } + + function reportOverrideTag(comment: TDocComment, parserContext: ParserContext): void { + const node: TClassMemberNode | undefined = getDocumentedClassMember(comment); + if (!node || !comment.range) { + return; + } + + 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, + messageId: 'override-tag-not-allowed', + fix: (fixer: eslint.Rule.RuleFixer) => { + 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 ')); + } + + return fixes; + } + }); + } + function checkCommentBlocks(): void { for (const comment of sourceCode.getAllComments()) { if (comment.type !== 'Block') { @@ -134,6 +297,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({ @@ -147,6 +312,10 @@ const plugin: IPlugin = { } }); } + + if (forbidOverrideTag && parserContext.docComment.modifierTagSet.isOverride()) { + reportOverrideTag(comment, parserContext); + } } } 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 976ac9ce..bcd0ecfb 100644 --- a/eslint-plugin/src/tests/plugin.test.ts +++ b/eslint-plugin/src/tests/plugin.test.ts @@ -4,12 +4,30 @@ import { RuleTester } from 'eslint'; import * as plugin from '../index'; -const ruleTester: RuleTester = new RuleTester(); +import * as parser from '@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 */', + // A member that already uses the `override` keyword and no `@override` tag is fine. + { + 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: [ { @@ -27,6 +45,74 @@ ruleTester.run('"tsdoc/syntax" rule', plugin.rules.syntax, { messageId: 'tsdoc-code-span-missing-delimiter' } ] + }, + // `@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 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 public override foo: string;\n}\n', + errors: [ + { + messageId: 'override-tag-not-allowed' + } + ] + }, + // `@override` on a static property. + { + code: 'class FooBar {\n /**\n * @override\n */\n static foo: string;\n}\n', + options: [{ forbidOverrideTag: true }], + output: 'class FooBar {\n static override foo: string;\n}\n', + errors: [ + { + messageId: 'override-tag-not-allowed' + } + ] + }, + // `override` must precede `readonly`, so it is inserted before it. + { + code: 'class FooBar {\n /**\n * @override\n */\n protected readonly foo: string;\n}\n', + options: [{ forbidOverrideTag: true }], + 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: remove the comment only. + { + code: 'class FooBar {\n /**\n * @override\n */\n override foo(): void {}\n}\n', + options: [{ forbidOverrideTag: true }], + 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' + } + ] } ] });