Skip to content
Original file line number Diff line number Diff line change
@@ -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"
}
3 changes: 3 additions & 0 deletions common/config/rush/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)!
1 change: 1 addition & 0 deletions eslint-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
173 changes: 171 additions & 2 deletions eslint-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -41,17 +48,98 @@ function getRootDirectoryFromContext(context: TSESLint.RuleContext<string, unkno
return rootDirectory;
}

// 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<eslint.SourceCode['getAllComments']>[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',
Expand All @@ -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(
Expand Down Expand Up @@ -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') {
Expand All @@ -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({
Expand All @@ -147,6 +312,10 @@ const plugin: IPlugin = {
}
});
}

if (forbidOverrideTag && parserContext.docComment.modifierTagSet.isOverride()) {
reportOverrideTag(comment, parserContext);
}
}
}

Expand Down
8 changes: 8 additions & 0 deletions eslint-plugin/src/tests/parser.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
90 changes: 88 additions & 2 deletions eslint-plugin/src/tests/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -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'
}
]
}
]
});
Loading