diff --git a/eslint.config.mjs b/eslint.config.mjs index b4d50a9435af..966d32aa8418 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -95,7 +95,7 @@ export default tseslint.config( }, rules: { // make sure we're not leveraging any deprecated APIs - 'deprecation/deprecation': 'error', + '@typescript-eslint/deprecation': 'error', // TODO: https://github.com/typescript-eslint/typescript-eslint/issues/8538 '@typescript-eslint/no-confusing-void-expression': 'off', diff --git a/packages/eslint-plugin/docs/rules/deprecation.mdx b/packages/eslint-plugin/docs/rules/deprecation.mdx new file mode 100644 index 000000000000..4e4633147800 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/deprecation.mdx @@ -0,0 +1,33 @@ +--- +description: 'Prevent usage of deprecated members' +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +> 🛑 This file is source code, not the primary documentation location! 🛑 +> +> See **https://typescript-eslint.io/rules/deprecation** for documentation. + +This rule supersedes the `deprecation/deprecation` rule from `eslint-plugin-deprecation` + + + + +```ts +escape('Hello'); // The signature '(string: string): string' of 'escape' is deprecated: A legacy feature for browser compatibility +unescape('Hello'); // The signature '(string: string): string' of 'unescape' is deprecated: A legacy feature for browser compatibility +RegExp.lastMatch; // 'lastMatch' is deprecated: A legacy feature for browser compatibility + +/** + * @deprecated for some reason + */ +declare const someValue: string; + +console.log(someValue); // 'someValue' is deprecated: for some reason + +new Buffer(38); // 'Buffer' is deprecated. since v10.0.0 - Use `Buffer.alloc()` instead (also see `Buffer.allocUnsafe()`). +``` + + + diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 5c1e1d7725ac..bceea0ba26f5 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -29,6 +29,7 @@ export = { '@typescript-eslint/consistent-type-imports': 'error', 'default-param-last': 'off', '@typescript-eslint/default-param-last': 'error', + '@typescript-eslint/deprecation': 'error', 'dot-notation': 'off', '@typescript-eslint/dot-notation': 'error', '@typescript-eslint/explicit-function-return-type': 'error', diff --git a/packages/eslint-plugin/src/configs/disable-type-checked.ts b/packages/eslint-plugin/src/configs/disable-type-checked.ts index 9a45d83452cf..45a91be9a216 100644 --- a/packages/eslint-plugin/src/configs/disable-type-checked.ts +++ b/packages/eslint-plugin/src/configs/disable-type-checked.ts @@ -13,6 +13,7 @@ export = { '@typescript-eslint/await-thenable': 'off', '@typescript-eslint/consistent-return': 'off', '@typescript-eslint/consistent-type-exports': 'off', + '@typescript-eslint/deprecation': 'off', '@typescript-eslint/dot-notation': 'off', '@typescript-eslint/naming-convention': 'off', '@typescript-eslint/no-array-delete': 'off', diff --git a/packages/eslint-plugin/src/configs/strict-type-checked-only.ts b/packages/eslint-plugin/src/configs/strict-type-checked-only.ts index 53f13d96748f..eaa8296a0160 100644 --- a/packages/eslint-plugin/src/configs/strict-type-checked-only.ts +++ b/packages/eslint-plugin/src/configs/strict-type-checked-only.ts @@ -11,6 +11,7 @@ export = { extends: ['./configs/base', './configs/eslint-recommended'], rules: { '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/deprecation': 'error', '@typescript-eslint/no-array-delete': 'error', '@typescript-eslint/no-base-to-string': 'error', '@typescript-eslint/no-confusing-void-expression': 'error', diff --git a/packages/eslint-plugin/src/configs/strict-type-checked.ts b/packages/eslint-plugin/src/configs/strict-type-checked.ts index 7987868204db..5a3eba335729 100644 --- a/packages/eslint-plugin/src/configs/strict-type-checked.ts +++ b/packages/eslint-plugin/src/configs/strict-type-checked.ts @@ -16,6 +16,7 @@ export = { { minimumDescriptionLength: 10 }, ], '@typescript-eslint/ban-types': 'error', + '@typescript-eslint/deprecation': 'error', 'no-array-constructor': 'off', '@typescript-eslint/no-array-constructor': 'error', '@typescript-eslint/no-array-delete': 'error', diff --git a/packages/eslint-plugin/src/rules/deprecation.ts b/packages/eslint-plugin/src/rules/deprecation.ts new file mode 100644 index 000000000000..f08983b4ab55 --- /dev/null +++ b/packages/eslint-plugin/src/rules/deprecation.ts @@ -0,0 +1,298 @@ +import type { + ParserServicesWithTypeInformation, + TSESTree, +} from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import type { RuleContext } from '@typescript-eslint/utils/ts-eslint'; +import type { + EntityName, + JSDocComment, + JSDocMemberName, + NodeArray, + Symbol as TSSymbol, + TypeChecker, +} from 'typescript'; +import { + getAllJSDocTags, + isIdentifier, + isJSDocDeprecatedTag, + isJSDocLinkLike, + isJSDocMemberName, + isQualifiedName, + isShorthandPropertyAssignment, + TypeFormatFlags, +} from 'typescript'; + +import { createRule, getParserServices } from '../util'; + +type Options = []; +type MessageIds = + | 'deprecated' + | 'deprecatedWithReason' + | 'deprecatedSignature' + | 'deprecatedSignatureWithReason'; + +function shouldIgnoreIdentifier(node: TSESTree.Identifier): boolean { + switch (node.parent.type) { + case AST_NODE_TYPES.FunctionDeclaration: + case AST_NODE_TYPES.TSDeclareFunction: + case AST_NODE_TYPES.ClassDeclaration: + case AST_NODE_TYPES.TSInterfaceDeclaration: + case AST_NODE_TYPES.TSTypeAliasDeclaration: + case AST_NODE_TYPES.Property: + return true; + case AST_NODE_TYPES.VariableDeclarator: + return node.parent.init !== node; + case AST_NODE_TYPES.TSPropertySignature: + case AST_NODE_TYPES.PropertyDefinition: + return node.parent.key === node; + } + return false; +} + +function formatEntityName(name: EntityName | JSDocMemberName): string { + let current = ''; + let currentName: EntityName | JSDocMemberName | undefined = name; + + while (currentName) { + if (isQualifiedName(currentName) || isJSDocMemberName(currentName)) { + if (current === '') { + current = currentName.right.text; + } else { + current = `${currentName.right.text}#${current}`; + } + currentName = currentName.left; + continue; + } + if (isIdentifier(currentName)) { + if (current === '') { + return currentName.text; + } + current = `${currentName.text}#${current}`; + currentName = undefined; + continue; + } + break; + } + // + return current; +} + +function formatComments(comment: string | NodeArray): string { + if (typeof comment === 'string') { + return comment; + } + + // TODO: Implement a detection algorithm to detect "Use X instead", resolve types and give a different error message + /* + const links = comment.filter( + isJSDocLinkLike, + ); + if (links.length === 1) { + const link = links[0]; + + if (link.name !== undefined) { + return `Use '${formatEntityName(link.name)}' instead.`; + } + } + */ + + return comment + .map(single => { + if (isJSDocLinkLike(single)) { + if (single.name) { + return formatEntityName(single.name); + } + return single.text; + } + return single.text; + }) + .join(''); +} + +function handleMaybeDeprecatedSymbol( + ctx: Readonly>, + services: ParserServicesWithTypeInformation, + checker: TypeChecker, + node: TSESTree.Node, + sym: TSSymbol, + name: string, +): void { + if ( + node.type === AST_NODE_TYPES.Identifier && + (node.parent.type === AST_NODE_TYPES.CallExpression || + node.parent.type === AST_NODE_TYPES.NewExpression) && + node.parent.callee === node + ) { + /* + Function call + We should in this case check the resolved signature instead + */ + + const tsParent = services.esTreeNodeToTSNodeMap.get(node.parent); + const sig = checker.getResolvedSignature(tsParent); + if (sig === undefined) { + return; + } + const decl = sig.getDeclaration(); + if ((decl as undefined | typeof decl) === undefined) { + // May happen if we have an implicit constructor on a class + return; + } + + for (const tag of getAllJSDocTags(decl, isJSDocDeprecatedTag)) { + if (tag.comment) { + ctx.report({ + messageId: 'deprecatedSignatureWithReason', + node, + data: { + name, + signature: checker.signatureToString( + sig, + tsParent, + TypeFormatFlags.WriteTypeArgumentsOfSignature, + ), + reason: formatComments(tag.comment), + }, + }); + return; + } + ctx.report({ + messageId: 'deprecatedSignature', + node, + data: { + name, + signature: checker.signatureToString( + sig, + tsParent, + TypeFormatFlags.WriteTypeArgumentsOfSignature, + ), + }, + }); + } + return; + } + + for (const decl of sym.getDeclarations() ?? []) { + for (const tag of getAllJSDocTags(decl, isJSDocDeprecatedTag)) { + if (tag.comment) { + ctx.report({ + messageId: 'deprecatedWithReason', + node, + data: { + name, + reason: formatComments(tag.comment), + }, + }); + return; + } + ctx.report({ + messageId: 'deprecated', + node, + data: { + name, + }, + }); + } + } +} + +export default createRule({ + name: 'deprecation', + meta: { + docs: { + description: 'Disallow usage of deprecated APIs', + requiresTypeChecking: true, + recommended: 'strict', + }, + messages: { + deprecated: `'{{name}}' is deprecated.`, + deprecatedWithReason: `'{{name}}' is deprecated: {{reason}}`, + deprecatedSignature: `The signature '{{signature}}' of '{{name}}' is deprecated.`, + deprecatedSignatureWithReason: `The signature '{{signature}}' of '{{name}}' is deprecated: {{reason}}`, + }, + schema: [], + type: 'problem', + }, + defaultOptions: [], + create(ctx) { + const services = getParserServices(ctx); + const checker = services.program.getTypeChecker(); + + return { + // TODO: Support a[b] syntax + Property(node): void { + const par = services.esTreeNodeToTSNodeMap.get(node); + + if (node.key.type !== AST_NODE_TYPES.Identifier) { + return; + } + + if (isShorthandPropertyAssignment(par)) { + const sym = checker.getTypeAtLocation(par.name).getSymbol(); + if (sym === undefined) { + return; + } + + handleMaybeDeprecatedSymbol( + ctx, + services, + checker, + node, + sym, + node.key.name, + ); + } + return; + }, + Identifier(node): void { + if (shouldIgnoreIdentifier(node)) { + return; + } + + const sym = services.getSymbolAtLocation(node); + if (sym === undefined) { + // Types unavailable + return; + } + + try { + handleMaybeDeprecatedSymbol( + ctx, + services, + checker, + node, + sym, + node.name, + ); + } catch { + return; + } + }, + MemberExpression(node): void { + if (node.property.type === AST_NODE_TYPES.PrivateIdentifier) { + const identifier = node.property; + + const sym = services.getSymbolAtLocation(identifier); + if (sym === undefined) { + // Types unavailable + return; + } + + try { + handleMaybeDeprecatedSymbol( + ctx, + services, + checker, + identifier, + sym, + `#${identifier.name}`, + ); + } catch { + return; + } + } + }, + }; + }, +}); diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index c167013211c0..0faf30ce7b44 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -16,6 +16,7 @@ import consistentTypeDefinitions from './consistent-type-definitions'; import consistentTypeExports from './consistent-type-exports'; import consistentTypeImports from './consistent-type-imports'; import defaultParamLast from './default-param-last'; +import deprecation from './deprecation'; import dotNotation from './dot-notation'; import explicitFunctionReturnType from './explicit-function-return-type'; import explicitMemberAccessibility from './explicit-member-accessibility'; @@ -140,6 +141,7 @@ export default { 'consistent-type-exports': consistentTypeExports, 'consistent-type-imports': consistentTypeImports, 'default-param-last': defaultParamLast, + deprecation: deprecation, 'dot-notation': dotNotation, 'explicit-function-return-type': explicitFunctionReturnType, 'explicit-member-accessibility': explicitMemberAccessibility, diff --git a/packages/eslint-plugin/tests/rules/deprecation.test.ts b/packages/eslint-plugin/tests/rules/deprecation.test.ts new file mode 100644 index 000000000000..d6904f17b2d9 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/deprecation.test.ts @@ -0,0 +1,318 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import rule from '../../src/rules/deprecation'; +import { getFixturesRootDir } from '../RuleTester'; + +const rootPath = getFixturesRootDir(); + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + sourceType: 'module', + tsconfigRootDir: rootPath, + project: './tsconfig.json', + }, +}); + +ruleTester.run('deprecation', rule, { + valid: [ + ` +declare const b: string; +if (false as boolean) { + /** + * @deprecated + */ + const b = ''; +} + +const a = b; + `.trim(), + ` +const a = 'a'; + `.trim(), + ` +/** + * @deprecated + */ +const a = 'a'; + `.trim(), + ` +/** + * @deprecated + */ +class A {} + `.trim(), + ` +/** + * @deprecated + */ +interface A {} + `.trim(), + ` +/** + * @deprecated + */ +declare class A {} + `.trim(), + ` +class A { + /** + * @deprecated + */ + b: string; +} + `.trim(), + ` +declare class A { + /** + * @deprecated + */ + b: string; +} + `.trim(), + ` +interface A { + /** + * @deprecated + */ + b: string; +} + `.trim(), + ` +class A { + /** + * @deprecated + */ + b: string; +} + +class B extends A { + b: string; +} + `.trim(), + ` +/** @deprecated */ +declare function a(val: string): string; +declare function a(val: number): number; + `.trim(), + ` +/** @deprecated */ +declare function a(val: string): string; +declare function a(val: number): number; + +a(2); + `.trim(), + ` +/** @deprecated */ +declare function a(val: K): K; +declare function a(val: number): number; +declare function a(val: boolean): boolean; + +a(2); + `.trim(), + ], + invalid: [ + { + code: ` +/** + * @deprecated EXAMPLE + */ +const a = 'a'; + +console.log(a); + `.trim(), + errors: [ + { + messageId: 'deprecatedWithReason', + data: { + name: 'a', + reason: 'EXAMPLE', + }, + line: 6, + }, + ], + }, + { + code: ` +const a = { + /** + * @deprecated + */ + b: 'Hi!', +}; + +const c = a.b; + `.trim(), + errors: [ + { + messageId: 'deprecated', + line: 8, + data: { + name: 'b', + }, + }, + ], + }, + { + code: ` +/** + * @deprecated + */ +const a = { + b: 'Hi!', +}; + +const b = { + a, +}; + `.trim(), + errors: [ + { + messageId: 'deprecated', + data: { + name: 'a', + line: 9, + }, + }, + ], + }, + { + code: ` +const a = { + /** + * @deprecated + */ + b: 'Hi!', +}; + +function c(d: string = a.b) {} + `.trim(), + errors: [ + { + messageId: 'deprecated', + data: { + name: 'b', + }, + line: 8, + }, + ], + }, + { + code: ` +/** + * @deprecated + */ +type C = string; + +class A {} + `.trim(), + errors: [ + { + line: 6, + messageId: 'deprecated', + data: { + name: 'C', + }, + }, + ], + }, + { + code: ` +class A { + /** + * @deprecated + */ + #b: string; + + constructor() { + this.#b = 'Hi!'; + } +} + `.trim(), + errors: [ + { + line: 8, + messageId: 'deprecated', + data: { + name: '#b', + }, + }, + ], + }, + { + code: ` +declare namespace a { + /** + * @deprecated + */ + const a: string; +} +declare namespace a { + const a: string; +} + +const b = a.a; + `, + errors: [ + { + messageId: 'deprecated', + line: 12, + data: { + name: 'a', + }, + }, + ], + }, + { + code: ` +/** @deprecated */ +declare function a(val: K): K; +declare function a(val: number): number; +declare function a(val: boolean): boolean; + +a('B'); + `.trim(), + errors: [ + { + messageId: 'deprecatedSignature', + line: 6, + data: { + signature: `<"B">(val: "B"): "B"`, + name: 'a', + }, + }, + ], + }, + { + code: ` +class A { + /** @deprecated */ + constructor(value: string) {} +} + +new A('VALUE'); + `, + errors: [ + { + messageId: 'deprecatedSignature', + }, + ], + }, + { + code: ` +declare interface A { + /** @deprecated */ + new (value: string): A; +} +declare const A: A; + +new A('VALUE'); + `, + errors: [ + { + messageId: 'deprecatedSignature', + }, + ], + }, + ], +}); diff --git a/packages/typescript-eslint/src/configs/all.ts b/packages/typescript-eslint/src/configs/all.ts index f01ac17c8ddb..19dad3f81543 100644 --- a/packages/typescript-eslint/src/configs/all.ts +++ b/packages/typescript-eslint/src/configs/all.ts @@ -38,6 +38,7 @@ export default ( '@typescript-eslint/consistent-type-imports': 'error', 'default-param-last': 'off', '@typescript-eslint/default-param-last': 'error', + '@typescript-eslint/deprecation': 'error', 'dot-notation': 'off', '@typescript-eslint/dot-notation': 'error', '@typescript-eslint/explicit-function-return-type': 'error', diff --git a/packages/typescript-eslint/src/configs/disable-type-checked.ts b/packages/typescript-eslint/src/configs/disable-type-checked.ts index 9df504415e37..43f946a69afc 100644 --- a/packages/typescript-eslint/src/configs/disable-type-checked.ts +++ b/packages/typescript-eslint/src/configs/disable-type-checked.ts @@ -16,6 +16,7 @@ export default ( '@typescript-eslint/await-thenable': 'off', '@typescript-eslint/consistent-return': 'off', '@typescript-eslint/consistent-type-exports': 'off', + '@typescript-eslint/deprecation': 'off', '@typescript-eslint/dot-notation': 'off', '@typescript-eslint/naming-convention': 'off', '@typescript-eslint/no-array-delete': 'off', diff --git a/packages/typescript-eslint/src/configs/strict-type-checked-only.ts b/packages/typescript-eslint/src/configs/strict-type-checked-only.ts index 415dd3eb342b..1bd10ccfa719 100644 --- a/packages/typescript-eslint/src/configs/strict-type-checked-only.ts +++ b/packages/typescript-eslint/src/configs/strict-type-checked-only.ts @@ -20,6 +20,7 @@ export default ( name: 'typescript-eslint/strict-type-checked-only', rules: { '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/deprecation': 'error', '@typescript-eslint/no-array-delete': 'error', '@typescript-eslint/no-base-to-string': 'error', '@typescript-eslint/no-confusing-void-expression': 'error', diff --git a/packages/typescript-eslint/src/configs/strict-type-checked.ts b/packages/typescript-eslint/src/configs/strict-type-checked.ts index 61d0a4d579a2..acee87d8c307 100644 --- a/packages/typescript-eslint/src/configs/strict-type-checked.ts +++ b/packages/typescript-eslint/src/configs/strict-type-checked.ts @@ -25,6 +25,7 @@ export default ( { minimumDescriptionLength: 10 }, ], '@typescript-eslint/ban-types': 'error', + '@typescript-eslint/deprecation': 'error', 'no-array-constructor': 'off', '@typescript-eslint/no-array-constructor': 'error', '@typescript-eslint/no-array-delete': 'error',