From d114b9aa0b1ac9cfaaec540530e96c94eb39ec56 Mon Sep 17 00:00:00 2001 From: Sam Estep Date: Mon, 31 Jul 2023 14:04:45 -0400 Subject: [PATCH 1/6] feat: add `no-unsafe-unary-minus` rule --- .../docs/rules/no-unsafe-unary-minus.md | 37 +++++++++++++ packages/eslint-plugin/src/configs/all.ts | 1 + packages/eslint-plugin/src/rules/index.ts | 2 + .../src/rules/no-unsafe-unary-minus.ts | 55 +++++++++++++++++++ .../tests/rules/no-unsafe-unary-minus.test.ts | 30 ++++++++++ .../no-unsafe-unary-minus.shot | 14 +++++ 6 files changed, 139 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 000000000000..1288ae60af50 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-unsafe-unary-minus.md @@ -0,0 +1,37 @@ +--- +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 +const f = (a: string) => -a; +const g = (a: {}) => -a; +``` + +### ✅ Correct + +```ts +const a = -42; +const b = -42n; +const f1 = (a: number) => -a; +const f2 = (a: bigint) => -a; +const f3 = (a: number | bigint) => -a; +const f4 = (a: any) => -a; +const f5 = (a: 1 | 2) => -a; +``` diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index d0bd265b0996..a5661a0ba8bf 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -123,6 +123,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 44aedd6198e1..a143c0554b9f 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -86,6 +86,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'; @@ -221,6 +222,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 000000000000..e9bdf0587524 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unsafe-unary-minus.ts @@ -0,0 +1,55 @@ +import type * as ts from 'typescript'; + +import * as util from '../util'; + +interface TypeChecker extends ts.TypeChecker { + // https://github.com/microsoft/TypeScript/issues/9879 + isTypeAssignableTo(source: ts.Type, target: ts.Type): boolean; + getUnionType(types: ts.Type[]): ts.Type; +} + +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 = services.getTypeAtLocation(node.argument); + const checker = services.program.getTypeChecker() as TypeChecker; + if ( + !checker.isTypeAssignableTo( + argType, + checker.getUnionType([ + checker.getNumberType(), // first exposed in TypeScript v5.1 + checker.getBigIntType(), // first added in TypeScript v5.1 + ]), + ) + ) { + 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 000000000000..914388702ed4 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-unsafe-unary-minus.test.ts @@ -0,0 +1,30 @@ +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;', + '-42n;', + '(a: number) => -a;', + '(a: bigint) => -a;', + '(a: number | bigint) => -a;', + '(a: any) => -a;', + '(a: 1 | 2) => -a;', + ], + invalid: [ + { code: '(a: string) => -a;', errors: [{ messageId: 'unaryMinus' }] }, + { code: '(a: {}) => -a;', 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 000000000000..e1e805786185 --- /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 = [];" +`; From ff17b9ecab0ea4a3fec6901e72df3163626fd890 Mon Sep 17 00:00:00 2001 From: Sam Estep Date: Mon, 31 Jul 2023 14:57:49 -0400 Subject: [PATCH 2/6] Cover the early return case --- packages/eslint-plugin/tests/rules/no-unsafe-unary-minus.test.ts | 1 + 1 file changed, 1 insertion(+) 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 index 914388702ed4..92556606fe26 100644 --- a/packages/eslint-plugin/tests/rules/no-unsafe-unary-minus.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unsafe-unary-minus.test.ts @@ -15,6 +15,7 @@ const ruleTester = new RuleTester({ ruleTester.run('no-unsafe-unary-minus', rule, { valid: [ + '+42;', '-42;', '-42n;', '(a: number) => -a;', From 3ff8dae899f6cf9b650babfd9d704930753d8106 Mon Sep 17 00:00:00 2001 From: Sam Estep Date: Sat, 28 Oct 2023 12:54:44 -0400 Subject: [PATCH 3/6] Write more tests --- .../tests/rules/no-unsafe-unary-minus.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 index 92556606fe26..0ab8a4b9bd75 100644 --- a/packages/eslint-plugin/tests/rules/no-unsafe-unary-minus.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unsafe-unary-minus.test.ts @@ -23,9 +23,25 @@ ruleTester.run('no-unsafe-unary-minus', rule, { '(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' }] }, ], }); From 1d431f85ad17ffadfa408749d19c68f486cba215 Mon Sep 17 00:00:00 2001 From: Sam Estep Date: Sun, 29 Oct 2023 12:59:06 -0400 Subject: [PATCH 4/6] Rewrite to use only public TypeScript API --- .../src/rules/no-unsafe-unary-minus.ts | 27 +++++++++---------- .../tests/rules/no-unsafe-unary-minus.test.ts | 4 +-- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-unsafe-unary-minus.ts b/packages/eslint-plugin/src/rules/no-unsafe-unary-minus.ts index e9bdf0587524..f78deea008f6 100644 --- a/packages/eslint-plugin/src/rules/no-unsafe-unary-minus.ts +++ b/packages/eslint-plugin/src/rules/no-unsafe-unary-minus.ts @@ -1,13 +1,8 @@ -import type * as ts from 'typescript'; +import * as tsutils from 'ts-api-utils'; +import * as ts from 'typescript'; import * as util from '../util'; -interface TypeChecker extends ts.TypeChecker { - // https://github.com/microsoft/TypeScript/issues/9879 - isTypeAssignableTo(source: ts.Type, target: ts.Type): boolean; - getUnionType(types: ts.Type[]): ts.Type; -} - type Options = []; type MessageIds = 'unaryMinus'; @@ -33,15 +28,17 @@ export default util.createRule({ } const services = util.getParserServices(context); const argType = services.getTypeAtLocation(node.argument); - const checker = services.program.getTypeChecker() as TypeChecker; + const checker = services.program.getTypeChecker(); if ( - !checker.isTypeAssignableTo( - argType, - checker.getUnionType([ - checker.getNumberType(), // first exposed in TypeScript v5.1 - checker.getBigIntType(), // first added in TypeScript v5.1 - ]), - ) + tsutils + .unionTypeParts(argType) + .some( + type => + !tsutils.isTypeFlagSet( + type, + ts.TypeFlags.BigIntLike | ts.TypeFlags.NumberLike, + ), + ) ) { context.report({ messageId: 'unaryMinus', 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 index 0ab8a4b9bd75..d3ea7b067fbc 100644 --- a/packages/eslint-plugin/tests/rules/no-unsafe-unary-minus.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unsafe-unary-minus.test.ts @@ -25,7 +25,7 @@ ruleTester.run('no-unsafe-unary-minus', rule, { '(a: 1 | 2) => -a;', '(a: string) => +a;', '(a: number[]) => -a[0];', - '(t: T & number) => -t;', + '(t: T & number) => -t;', '(a: { x: number }) => -a.x;', '(a: never) => -a;', '(t: T) => -t;', @@ -42,6 +42,6 @@ ruleTester.run('no-unsafe-unary-minus', rule, { }, { code: '(a: unknown) => -a;', errors: [{ messageId: 'unaryMinus' }] }, { code: '(a: void) => -a;', errors: [{ messageId: 'unaryMinus' }] }, - { code: '(t: T) => -t;', errors: [{ messageId: 'unaryMinus' }] }, + { code: '(t: T) => -t;', errors: [{ messageId: 'unaryMinus' }] }, ], }); From bc0430f5824b63bb518d4591c751cc4de2c4ea4a Mon Sep 17 00:00:00 2001 From: Sam Estep Date: Sun, 29 Oct 2023 13:53:08 -0400 Subject: [PATCH 5/6] Handle `any`, `never`, and generics --- .../eslint-plugin/src/rules/no-unsafe-unary-minus.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-unsafe-unary-minus.ts b/packages/eslint-plugin/src/rules/no-unsafe-unary-minus.ts index f78deea008f6..05d489d47fab 100644 --- a/packages/eslint-plugin/src/rules/no-unsafe-unary-minus.ts +++ b/packages/eslint-plugin/src/rules/no-unsafe-unary-minus.ts @@ -27,7 +27,10 @@ export default util.createRule({ return; } const services = util.getParserServices(context); - const argType = services.getTypeAtLocation(node.argument); + const argType = util.getConstrainedTypeAtLocation( + services, + node.argument, + ); const checker = services.program.getTypeChecker(); if ( tsutils @@ -36,7 +39,10 @@ export default util.createRule({ type => !tsutils.isTypeFlagSet( type, - ts.TypeFlags.BigIntLike | ts.TypeFlags.NumberLike, + ts.TypeFlags.Any | + ts.TypeFlags.Never | + ts.TypeFlags.BigIntLike | + ts.TypeFlags.NumberLike, ), ) ) { From 86b634a5fba26676e9f2d3b04c07f18385dc3aa2 Mon Sep 17 00:00:00 2001 From: Sam Estep Date: Sun, 29 Oct 2023 15:07:28 -0400 Subject: [PATCH 6/6] Replace functions with `declare` in docs --- .../docs/rules/no-unsafe-unary-minus.md | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/no-unsafe-unary-minus.md b/packages/eslint-plugin/docs/rules/no-unsafe-unary-minus.md index 1288ae60af50..94745d2c71c9 100644 --- a/packages/eslint-plugin/docs/rules/no-unsafe-unary-minus.md +++ b/packages/eslint-plugin/docs/rules/no-unsafe-unary-minus.md @@ -20,18 +20,31 @@ This rule restricts the unary `-` operator to `number | bigint`. ### ❌ Incorrect ```ts -const f = (a: string) => -a; -const g = (a: {}) => -a; +declare const a: string; +-a; + +declare const b: {}; +-b; ``` ### ✅ Correct ```ts -const a = -42; -const b = -42n; -const f1 = (a: number) => -a; -const f2 = (a: bigint) => -a; -const f3 = (a: number | bigint) => -a; -const f4 = (a: any) => -a; -const f5 = (a: 1 | 2) => -a; +-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; ```