From c4709c2861f8ea4e18c289218a75224d6bb76193 Mon Sep 17 00:00:00 2001 From: Sam Estep Date: Mon, 13 Nov 2023 08:12:38 -0500 Subject: [PATCH] feat: add `no-unsafe-unary-minus` rule (#7390) * feat: add `no-unsafe-unary-minus` rule * Cover the early return case * Write more tests * Rewrite to use only public TypeScript API * Handle `any`, `never`, and generics * Replace functions with `declare` in docs --- .../docs/rules/no-unsafe-unary-minus.md | 50 ++++++++++++++++ packages/eslint-plugin/src/configs/all.ts | 1 + packages/eslint-plugin/src/rules/index.ts | 2 + .../src/rules/no-unsafe-unary-minus.ts | 58 +++++++++++++++++++ .../tests/rules/no-unsafe-unary-minus.test.ts | 47 +++++++++++++++ .../no-unsafe-unary-minus.shot | 14 +++++ 6 files changed, 172 insertions(+) create mode 100644 packages/eslint-plugin/docs/rules/no-unsafe-unary-minus.md create mode 100644 packages/eslint-plugin/src/rules/no-unsafe-unary-minus.ts create mode 100644 packages/eslint-plugin/tests/rules/no-unsafe-unary-minus.test.ts create mode 100644 packages/eslint-plugin/tests/schema-snapshots/no-unsafe-unary-minus.shot diff --git a/packages/eslint-plugin/docs/rules/no-unsafe-unary-minus.md b/packages/eslint-plugin/docs/rules/no-unsafe-unary-minus.md new file mode 100644 index 00000000000..94745d2c71c --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-unsafe-unary-minus.md @@ -0,0 +1,50 @@ +--- +description: 'Require unary negation to take a number.' +--- + +> 🛑 This file is source code, not the primary documentation location! 🛑 +> +> See **https://typescript-eslint.io/rules/no-unsafe-unary-minus** for documentation. + +TypeScript does not prevent you from putting a minus sign before things other than numbers: + +```ts +const s = 'hello'; +const x = -s; // x is NaN +``` + +This rule restricts the unary `-` operator to `number | bigint`. + +## Examples + +### ❌ Incorrect + +```ts +declare const a: string; +-a; + +declare const b: {}; +-b; +``` + +### ✅ Correct + +```ts +-42; +-42n; + +declare const a: number; +-a; + +declare const b: number; +-b; + +declare const c: number | bigint; +-c; + +declare const d: any; +-d; + +declare const e: 1 | 2; +-e; +``` diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index f7a3cc3dbcb..7717b386cc9 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -125,6 +125,7 @@ export = { '@typescript-eslint/no-unsafe-enum-comparison': 'error', '@typescript-eslint/no-unsafe-member-access': 'error', '@typescript-eslint/no-unsafe-return': 'error', + '@typescript-eslint/no-unsafe-unary-minus': 'error', 'no-unused-expressions': 'off', '@typescript-eslint/no-unused-expressions': 'error', 'no-unused-vars': 'off', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 9d87b8cac41..5423a7b8207 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -87,6 +87,7 @@ import noUnsafeDeclarationMerging from './no-unsafe-declaration-merging'; import noUnsafeEnumComparison from './no-unsafe-enum-comparison'; import noUnsafeMemberAccess from './no-unsafe-member-access'; import noUnsafeReturn from './no-unsafe-return'; +import noUnsafeUnaryMinus from './no-unsafe-unary-minus'; import noUnusedExpressions from './no-unused-expressions'; import noUnusedVars from './no-unused-vars'; import noUseBeforeDefine from './no-use-before-define'; @@ -224,6 +225,7 @@ export default { 'no-unsafe-enum-comparison': noUnsafeEnumComparison, 'no-unsafe-member-access': noUnsafeMemberAccess, 'no-unsafe-return': noUnsafeReturn, + 'no-unsafe-unary-minus': noUnsafeUnaryMinus, 'no-unused-expressions': noUnusedExpressions, 'no-unused-vars': noUnusedVars, 'no-use-before-define': noUseBeforeDefine, diff --git a/packages/eslint-plugin/src/rules/no-unsafe-unary-minus.ts b/packages/eslint-plugin/src/rules/no-unsafe-unary-minus.ts new file mode 100644 index 00000000000..05d489d47fa --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unsafe-unary-minus.ts @@ -0,0 +1,58 @@ +import * as tsutils from 'ts-api-utils'; +import * as ts from 'typescript'; + +import * as util from '../util'; + +type Options = []; +type MessageIds = 'unaryMinus'; + +export default util.createRule({ + name: 'no-unsafe-unary-minus', + meta: { + type: 'problem', + docs: { + description: 'Require unary negation to take a number', + requiresTypeChecking: true, + }, + messages: { + unaryMinus: 'Invalid type "{{type}}" of template literal expression.', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + return { + UnaryExpression(node): void { + if (node.operator !== '-') { + return; + } + const services = util.getParserServices(context); + const argType = util.getConstrainedTypeAtLocation( + services, + node.argument, + ); + const checker = services.program.getTypeChecker(); + if ( + tsutils + .unionTypeParts(argType) + .some( + type => + !tsutils.isTypeFlagSet( + type, + ts.TypeFlags.Any | + ts.TypeFlags.Never | + ts.TypeFlags.BigIntLike | + ts.TypeFlags.NumberLike, + ), + ) + ) { + context.report({ + messageId: 'unaryMinus', + node, + data: { type: checker.typeToString(argType) }, + }); + } + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/no-unsafe-unary-minus.test.ts b/packages/eslint-plugin/tests/rules/no-unsafe-unary-minus.test.ts new file mode 100644 index 00000000000..d3ea7b067fb --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-unsafe-unary-minus.test.ts @@ -0,0 +1,47 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import rule from '../../src/rules/no-unsafe-unary-minus'; +import { getFixturesRootDir } from '../RuleTester'; + +const rootDir = getFixturesRootDir(); +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2015, + tsconfigRootDir: rootDir, + project: './tsconfig.json', + }, + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('no-unsafe-unary-minus', rule, { + valid: [ + '+42;', + '-42;', + '-42n;', + '(a: number) => -a;', + '(a: bigint) => -a;', + '(a: number | bigint) => -a;', + '(a: any) => -a;', + '(a: 1 | 2) => -a;', + '(a: string) => +a;', + '(a: number[]) => -a[0];', + '(t: T & number) => -t;', + '(a: { x: number }) => -a.x;', + '(a: never) => -a;', + '(t: T) => -t;', + ], + invalid: [ + { code: '(a: string) => -a;', errors: [{ messageId: 'unaryMinus' }] }, + { code: '(a: {}) => -a;', errors: [{ messageId: 'unaryMinus' }] }, + { code: '(a: number[]) => -a;', errors: [{ messageId: 'unaryMinus' }] }, + { code: "-'hello';", errors: [{ messageId: 'unaryMinus' }] }, + { code: '-`hello`;', errors: [{ messageId: 'unaryMinus' }] }, + { + code: '(a: { x: number }) => -a;', + errors: [{ messageId: 'unaryMinus' }], + }, + { code: '(a: unknown) => -a;', errors: [{ messageId: 'unaryMinus' }] }, + { code: '(a: void) => -a;', errors: [{ messageId: 'unaryMinus' }] }, + { code: '(t: T) => -t;', errors: [{ messageId: 'unaryMinus' }] }, + ], +}); diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-unsafe-unary-minus.shot b/packages/eslint-plugin/tests/schema-snapshots/no-unsafe-unary-minus.shot new file mode 100644 index 00000000000..e1e80578618 --- /dev/null +++ b/packages/eslint-plugin/tests/schema-snapshots/no-unsafe-unary-minus.shot @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Rule schemas should be convertible to TS types for documentation purposes no-unsafe-unary-minus 1`] = ` +" +# SCHEMA: + +[] + + +# TYPES: + +/** No options declared */ +type Options = [];" +`;