diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 69ea06c2308..32b0cbb7231 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -143,6 +143,7 @@ Pro Tip: For larger codebases you may want to consider splitting our linting int | [`@typescript-eslint/no-unnecessary-type-arguments`](./docs/rules/no-unnecessary-type-arguments.md) | Enforces that type arguments will not be used if not required | | :wrench: | :thought_balloon: | | [`@typescript-eslint/no-unnecessary-type-assertion`](./docs/rules/no-unnecessary-type-assertion.md) | Warns if a type assertion does not change the type of an expression | :heavy_check_mark: | :wrench: | :thought_balloon: | | [`@typescript-eslint/no-unnecessary-type-constraint`](./docs/rules/no-unnecessary-type-constraint.md) | Disallows unnecessary constraints on generic types | | :wrench: | | +| [`@typescript-eslint/no-unsafe-argument`](./docs/rules/no-unsafe-argument.md) | Disallows calling an function with an any type value | | | :thought_balloon: | | [`@typescript-eslint/no-unsafe-assignment`](./docs/rules/no-unsafe-assignment.md) | Disallows assigning any to variables and properties | :heavy_check_mark: | | :thought_balloon: | | [`@typescript-eslint/no-unsafe-call`](./docs/rules/no-unsafe-call.md) | Disallows calling an any type value | :heavy_check_mark: | | :thought_balloon: | | [`@typescript-eslint/no-unsafe-member-access`](./docs/rules/no-unsafe-member-access.md) | Disallows member access on any typed variables | :heavy_check_mark: | | :thought_balloon: | diff --git a/packages/eslint-plugin/docs/rules/no-unsafe-argument.md b/packages/eslint-plugin/docs/rules/no-unsafe-argument.md new file mode 100644 index 00000000000..d8e3456d18a --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-unsafe-argument.md @@ -0,0 +1,69 @@ +# Disallows calling an function with an any type value (`no-unsafe-argument`) + +Despite your best intentions, the `any` type can sometimes leak into your codebase. +Call a function with `any` typed argument are not checked at all by TypeScript, so it creates a potential safety hole, and source of bugs in your codebase. + +## Rule Details + +This rule disallows calling a function with `any` in its arguments, and it will disallow spreading `any[]`. +This rule also disallows spreading a tuple type with one of its elements typed as `any`. +This rule also compares the argument's type to the variable's type to ensure you don't pass an unsafe `any` in a generic position to a receiver that's expecting a specific type. For example, it will error if you assign `Set` to an argument declared as `Set`. + +Examples of **incorrect** code for this rule: + +```ts +declare function foo(arg1: string, arg2: number, arg2: string): void; + +const anyTyped = 1 as any; + +foo(...anyTyped); +foo(anyTyped, 1, 'a'); + +const anyArray: any[] = []; +foo(...anyArray); + +const tuple1 = ['a', anyTyped, 'b'] as const; +foo(...tuple1); + +const tuple2 = [1] as const; +foo('a', ...tuple, anyTyped); + +declare function bar(arg1: string, arg2: number, ...rest: string[]): void; +const x = [1, 2] as [number, ...number[]]; +foo('a', ...x, anyTyped); + +declare function baz(arg1: Set, arg2: Map): void; +foo(new Set(), new Map()); +``` + +Examples of **correct** code for this rule: + +```ts +declare function foo(arg1: string, arg2: number, arg2: string): void; + +foo('a', 1, 'b'); + +const tuple1 = ['a', 1, 'b'] as const; +foo(...tuple1); + +declare function bar(arg1: string, arg2: number, ...rest: string[]): void; +const array: string[] = ['a']; +bar('a', 1, ...array); + +declare function baz(arg1: Set, arg2: Map): void; +foo(new Set(), new Map()); +``` + +There are cases where the rule allows passing an argument of `any` to `unknown`. + +Example of `any` to `unknown` assignment that are allowed. + +```ts +declare function foo(arg1: unknown, arg2: Set, arg3: unknown[]): void; +foo(1 as any, new Set(), [] as any[]); +``` + +## Related to + +- [`no-explicit-any`](./no-explicit-any.md) +- TSLint: [`no-unsafe-any`](https://palantir.github.io/tslint/rules/no-unsafe-any/) diff --git a/packages/eslint-plugin/docs/rules/no-unsafe-return.md b/packages/eslint-plugin/docs/rules/no-unsafe-return.md index 9810be3cf16..225593eb02d 100644 --- a/packages/eslint-plugin/docs/rules/no-unsafe-return.md +++ b/packages/eslint-plugin/docs/rules/no-unsafe-return.md @@ -1,7 +1,7 @@ # Disallows returning any from a function (`no-unsafe-return`) Despite your best intentions, the `any` type can sometimes leak into your codebase. -Returned `any` typed values not checked at all by TypeScript, so it creates a potential safety hole, and source of bugs in your codebase. +Returned `any` typed values are not checked at all by TypeScript, so it creates a potential safety hole, and source of bugs in your codebase. ## Rule Details diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 98afc4ae3a4..b21d14efc9f 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -99,6 +99,7 @@ export = { '@typescript-eslint/no-unnecessary-type-arguments': 'error', '@typescript-eslint/no-unnecessary-type-assertion': 'error', '@typescript-eslint/no-unnecessary-type-constraint': 'error', + '@typescript-eslint/no-unsafe-argument': 'error', '@typescript-eslint/no-unsafe-assignment': 'error', '@typescript-eslint/no-unsafe-call': 'error', '@typescript-eslint/no-unsafe-member-access': 'error', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index ffa70e57a29..be78db26f80 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -68,6 +68,7 @@ import noUnnecessaryQualifier from './no-unnecessary-qualifier'; import noUnnecessaryTypeArguments from './no-unnecessary-type-arguments'; import noUnnecessaryTypeAssertion from './no-unnecessary-type-assertion'; import noUnnecessaryTypeConstraint from './no-unnecessary-type-constraint'; +import noUnsafeArgument from './no-unsafe-argument'; import noUnsafeAssignment from './no-unsafe-assignment'; import noUnsafeCall from './no-unsafe-call'; import noUnsafeMemberAccess from './no-unsafe-member-access'; @@ -185,6 +186,7 @@ export default { 'no-unnecessary-type-arguments': noUnnecessaryTypeArguments, 'no-unnecessary-type-assertion': noUnnecessaryTypeAssertion, 'no-unnecessary-type-constraint': noUnnecessaryTypeConstraint, + 'no-unsafe-argument': noUnsafeArgument, 'no-unsafe-assignment': noUnsafeAssignment, 'no-unsafe-call': noUnsafeCall, 'no-unsafe-member-access': noUnsafeMemberAccess, diff --git a/packages/eslint-plugin/src/rules/no-unsafe-argument.ts b/packages/eslint-plugin/src/rules/no-unsafe-argument.ts new file mode 100644 index 00000000000..23c8b7bd508 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unsafe-argument.ts @@ -0,0 +1,220 @@ +import { + AST_NODE_TYPES, + TSESTree, +} from '@typescript-eslint/experimental-utils'; +import * as ts from 'typescript'; +import * as util from '../util'; + +type MessageIds = + | 'unsafeArgument' + | 'unsafeTupleSpread' + | 'unsafeArraySpread' + | 'unsafeSpread'; + +class FunctionSignature { + public static create( + checker: ts.TypeChecker, + tsNode: ts.CallLikeExpression, + ): FunctionSignature | null { + const signature = checker.getResolvedSignature(tsNode); + if (!signature) { + return null; + } + + const paramTypes: ts.Type[] = []; + let restType: ts.Type | null = null; + + for (const param of signature.getParameters()) { + const type = checker.getTypeOfSymbolAtLocation(param, tsNode); + + const decl = param.getDeclarations()?.[0]; + if (decl && ts.isParameter(decl) && decl.dotDotDotToken) { + // is a rest param + if (checker.isArrayType(type)) { + restType = checker.getTypeArguments(type)[0]; + } else { + restType = type; + } + break; + } + + paramTypes.push(type); + } + + return new this(paramTypes, restType); + } + + private hasConsumedArguments = false; + + private constructor( + private paramTypes: ts.Type[], + private restType: ts.Type | null, + ) {} + + public getParameterType(index: number): ts.Type | null { + if (index >= this.paramTypes.length || this.hasConsumedArguments) { + return this.restType; + } + return this.paramTypes[index]; + } + + public consumeRemainingArguments(): void { + this.hasConsumedArguments = true; + } +} + +export default util.createRule<[], MessageIds>({ + name: 'no-unsafe-argument', + meta: { + type: 'problem', + docs: { + description: 'Disallows calling an function with an any type value', + category: 'Possible Errors', + // TODO - enable this with next breaking + recommended: false, + requiresTypeChecking: true, + }, + messages: { + unsafeArgument: + 'Unsafe argument of type `{{sender}}` assigned to a parameter of type `{{receiver}}`.', + unsafeTupleSpread: + 'Unsafe spread of a tuple type. The {{index}} element is of type `{{sender}}` and is assigned to a parameter of type `{{reciever}}`.', + unsafeArraySpread: 'Unsafe spread of an `any` array type.', + unsafeSpread: 'Unsafe spread of an `any` type.', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + const { program, esTreeNodeToTSNodeMap } = util.getParserServices(context); + const checker = program.getTypeChecker(); + + return { + 'CallExpression, NewExpression'( + node: TSESTree.CallExpression | TSESTree.NewExpression, + ): void { + if (node.arguments.length === 0) { + return; + } + + // ignore any-typed calls as these are caught by no-unsafe-call + if ( + util.isTypeAnyType( + checker.getTypeAtLocation(esTreeNodeToTSNodeMap.get(node.callee)), + ) + ) { + return; + } + + const tsNode = esTreeNodeToTSNodeMap.get(node); + const signature = FunctionSignature.create(checker, tsNode); + if (!signature) { + return; + } + + let parameterTypeIndex = 0; + for ( + let i = 0; + i < node.arguments.length; + i += 1, parameterTypeIndex += 1 + ) { + const argument = node.arguments[i]; + + switch (argument.type) { + // spreads consume + case AST_NODE_TYPES.SpreadElement: { + const spreadArgType = checker.getTypeAtLocation( + esTreeNodeToTSNodeMap.get(argument.argument), + ); + + if (util.isTypeAnyType(spreadArgType)) { + // foo(...any) + context.report({ + node: argument, + messageId: 'unsafeSpread', + }); + } else if (util.isTypeAnyArrayType(spreadArgType, checker)) { + // foo(...any[]) + + // TODO - we could break down the spread and compare the array type against each argument + context.report({ + node: argument, + messageId: 'unsafeArraySpread', + }); + } else if (checker.isTupleType(spreadArgType)) { + // foo(...[tuple1, tuple2]) + const spreadTypeArguments = checker.getTypeArguments( + spreadArgType, + ); + for ( + let j = 0; + j < spreadTypeArguments.length; + j += 1, parameterTypeIndex += 1 + ) { + const tupleType = spreadTypeArguments[j]; + const parameterType = signature.getParameterType( + parameterTypeIndex, + ); + if (parameterType == null) { + continue; + } + const result = util.isUnsafeAssignment( + tupleType, + parameterType, + checker, + ); + if (result) { + context.report({ + node: argument, + messageId: 'unsafeTupleSpread', + data: { + sender: checker.typeToString(tupleType), + receiver: checker.typeToString(parameterType), + }, + }); + } + } + if (spreadArgType.target.hasRestElement) { + // the last element was a rest - so all remaining defined arguments can be considered "consumed" + // all remaining arguments should be compared against the rest type (if one exists) + signature.consumeRemainingArguments(); + } + } else { + // something that's iterable + // handling this will be pretty complex - so we ignore it for now + // TODO - handle generic iterable case + } + break; + } + + default: { + const parameterType = signature.getParameterType(i); + if (parameterType == null) { + continue; + } + + const argumentType = checker.getTypeAtLocation( + esTreeNodeToTSNodeMap.get(argument), + ); + const result = util.isUnsafeAssignment( + argumentType, + parameterType, + checker, + ); + if (result) { + context.report({ + node: argument, + messageId: 'unsafeArgument', + data: { + sender: checker.typeToString(argumentType), + receiver: checker.typeToString(parameterType), + }, + }); + } + } + } + } + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/no-unsafe-argument.test.ts b/packages/eslint-plugin/tests/rules/no-unsafe-argument.test.ts new file mode 100644 index 00000000000..395f47344cb --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-unsafe-argument.test.ts @@ -0,0 +1,268 @@ +import rule from '../../src/rules/no-unsafe-argument'; +import { RuleTester, getFixturesRootDir } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: getFixturesRootDir(), + }, +}); + +ruleTester.run('no-unsafe-argument', rule, { + valid: [ + ` +declare function foo(arg: number): void; +foo(1); + `, + ` +declare function foo(arg: number, arg2: string): void; +foo(1, 'a'); + `, + ` +declare function foo(arg: any): void; +foo(1 as any); + `, + ` +declare function foo(arg: unknown): void; +foo(1 as any); + `, + ` +declare function foo(...arg: number[]): void; +foo(1, 2, 3); + `, + ` +declare function foo(...arg: any[]): void; +foo(1, 2, 3, 4 as any); + `, + ` +declare function foo(arg: number, arg2: number): void; +const x = [1, 2] as const; +foo(...x); + `, + ` +declare function foo(arg: any, arg2: number): void; +const x = [1 as any, 2] as const; +foo(...x); + `, + ` +declare function foo(arg1: string, arg2: string): void; +const x: string[] = []; +foo(...x); + `, + ` +declare function foo(arg1: Set, arg2: Map): void; + +const x = [new Map()] as const; +foo(new Set(), ...x); + `, + ` +declare function foo(arg1: unknown, arg2: Set, arg3: unknown[]): void; +foo(1 as any, new Set(), [] as any[]); + `, + ], + invalid: [ + { + code: ` +declare function foo(arg: number): void; +foo(1 as any); + `, + errors: [ + { + messageId: 'unsafeArgument', + line: 3, + column: 5, + endColumn: 13, + data: { + sender: 'any', + receiver: 'number', + }, + }, + ], + }, + { + code: ` +declare function foo(arg1: number, arg2: string): void; +foo(1, 1 as any); + `, + errors: [ + { + messageId: 'unsafeArgument', + line: 3, + column: 8, + endColumn: 16, + data: { + sender: 'any', + receiver: 'string', + }, + }, + ], + }, + { + code: ` +declare function foo(...arg: number[]): void; +foo(1, 2, 3, 1 as any); + `, + errors: [ + { + messageId: 'unsafeArgument', + line: 3, + column: 14, + endColumn: 22, + data: { + sender: 'any', + receiver: 'number', + }, + }, + ], + }, + { + code: ` +declare function foo(arg: string, ...arg: number[]): void; +foo(1 as any, 1 as any); + `, + errors: [ + { + messageId: 'unsafeArgument', + line: 3, + column: 5, + endColumn: 13, + data: { + sender: 'any', + receiver: 'string', + }, + }, + { + messageId: 'unsafeArgument', + line: 3, + column: 15, + endColumn: 23, + data: { + sender: 'any', + receiver: 'number', + }, + }, + ], + }, + { + code: ` +declare function foo(arg1: string, arg2: number): void; + +foo(...(x as any)); + `, + errors: [ + { + messageId: 'unsafeSpread', + line: 4, + column: 5, + endColumn: 18, + }, + ], + }, + { + code: ` +declare function foo(arg1: string, arg2: number): void; + +foo(...(x as any[])); + `, + errors: [ + { + messageId: 'unsafeArraySpread', + line: 4, + column: 5, + endColumn: 20, + }, + ], + }, + { + code: ` +declare function foo(arg1: string, arg2: number): void; + +const x = ['a', 1 as any] as const; +foo(...x); + `, + errors: [ + { + messageId: 'unsafeTupleSpread', + line: 5, + column: 5, + endColumn: 9, + data: { + sender: 'any', + receiver: 'number', + }, + }, + ], + }, + { + code: ` +declare function foo(arg1: string, arg2: number, arg2: string): void; + +const x = [1] as const; +foo('a', ...x, 1 as any); + `, + errors: [ + { + messageId: 'unsafeArgument', + line: 5, + column: 16, + endColumn: 24, + data: { + sender: 'any', + receiver: 'string', + }, + }, + ], + }, + { + code: ` +declare function foo(arg1: string, arg2: number, ...rest: string[]): void; + +const x = [1, 2] as [number, ...number[]]; +foo('a', ...x, 1 as any); + `, + errors: [ + { + messageId: 'unsafeArgument', + line: 5, + column: 16, + endColumn: 24, + data: { + sender: 'any', + receiver: 'string', + }, + }, + ], + }, + { + code: ` +declare function foo(arg1: Set, arg2: Map): void; + +const x = [new Map()] as const; +foo(new Set(), ...x); + `, + errors: [ + { + messageId: 'unsafeArgument', + line: 5, + column: 5, + endColumn: 19, + data: { + sender: 'Set', + receiver: 'Set', + }, + }, + { + messageId: 'unsafeTupleSpread', + line: 5, + column: 21, + endColumn: 25, + data: { + sender: 'Map', + receiver: 'Map', + }, + }, + ], + }, + ], +});