diff --git a/packages/eslint-plugin/src/rules/no-unsafe-argument.ts b/packages/eslint-plugin/src/rules/no-unsafe-argument.ts index 23c8b7bd508..160519e2768 100644 --- a/packages/eslint-plugin/src/rules/no-unsafe-argument.ts +++ b/packages/eslint-plugin/src/rules/no-unsafe-argument.ts @@ -11,7 +11,31 @@ type MessageIds = | 'unsafeArraySpread' | 'unsafeSpread'; +const enum RestTypeKind { + Array, + Tuple, + Other, +} +type RestType = + | { + type: ts.Type; + kind: RestTypeKind.Array; + index: number; + } + | { + typeArguments: readonly ts.Type[]; + kind: RestTypeKind.Tuple; + index: number; + } + | { + type: ts.Type; + kind: RestTypeKind.Other; + index: number; + }; + class FunctionSignature { + private parameterTypeIndex = 0; + public static create( checker: ts.TypeChecker, tsNode: ts.CallLikeExpression, @@ -22,18 +46,34 @@ class FunctionSignature { } const paramTypes: ts.Type[] = []; - let restType: ts.Type | null = null; + let restType: RestType | null = null; - for (const param of signature.getParameters()) { + const parameters = signature.getParameters(); + for (let i = 0; i < parameters.length; i += 1) { + const param = parameters[i]; 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]; + restType = { + type: checker.getTypeArguments(type)[0], + kind: RestTypeKind.Array, + index: i, + }; + } else if (checker.isTupleType(type)) { + restType = { + typeArguments: checker.getTypeArguments(type), + kind: RestTypeKind.Tuple, + index: i, + }; } else { - restType = type; + restType = { + type, + kind: RestTypeKind.Other, + index: i, + }; } break; } @@ -48,12 +88,41 @@ class FunctionSignature { private constructor( private paramTypes: ts.Type[], - private restType: ts.Type | null, + private restType: RestType | null, ) {} - public getParameterType(index: number): ts.Type | null { + public getNextParameterType(): ts.Type | null { + const index = this.parameterTypeIndex; + this.parameterTypeIndex += 1; + if (index >= this.paramTypes.length || this.hasConsumedArguments) { - return this.restType; + if (this.restType == null) { + return null; + } + + switch (this.restType.kind) { + case RestTypeKind.Tuple: { + const typeArguments = this.restType.typeArguments; + if (this.hasConsumedArguments) { + // all types consumed by a rest - just assume it's the last type + // there is one edge case where this is wrong, but we ignore it because + // it's rare and really complicated to handle + // eg: function foo(...a: [number, ...string[], number]) + return typeArguments[typeArguments.length - 1]; + } + + const typeIndex = index - this.restType.index; + if (typeIndex >= typeArguments.length) { + return typeArguments[typeArguments.length - 1]; + } + + return typeArguments[typeIndex]; + } + + case RestTypeKind.Array: + case RestTypeKind.Other: + return this.restType.type; + } } return this.paramTypes[index]; } @@ -112,12 +181,7 @@ export default util.createRule<[], MessageIds>({ return; } - let parameterTypeIndex = 0; - for ( - let i = 0; - i < node.arguments.length; - i += 1, parameterTypeIndex += 1 - ) { + for (let i = 0; i < node.arguments.length; i += 1) { const argument = node.arguments[i]; switch (argument.type) { @@ -146,15 +210,9 @@ export default util.createRule<[], MessageIds>({ const spreadTypeArguments = checker.getTypeArguments( spreadArgType, ); - for ( - let j = 0; - j < spreadTypeArguments.length; - j += 1, parameterTypeIndex += 1 - ) { + for (let j = 0; j < spreadTypeArguments.length; j += 1) { const tupleType = spreadTypeArguments[j]; - const parameterType = signature.getParameterType( - parameterTypeIndex, - ); + const parameterType = signature.getNextParameterType(); if (parameterType == null) { continue; } @@ -188,7 +246,7 @@ export default util.createRule<[], MessageIds>({ } default: { - const parameterType = signature.getParameterType(i); + const parameterType = signature.getNextParameterType(); if (parameterType == null) { continue; } diff --git a/packages/eslint-plugin/tests/rules/no-unsafe-argument.test.ts b/packages/eslint-plugin/tests/rules/no-unsafe-argument.test.ts index 395f47344cb..a9a6bc3eacc 100644 --- a/packages/eslint-plugin/tests/rules/no-unsafe-argument.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unsafe-argument.test.ts @@ -11,9 +11,19 @@ const ruleTester = new RuleTester({ ruleTester.run('no-unsafe-argument', rule, { valid: [ + // unknown function should be ignored + ` +doesNotExist(1 as any); + `, + // non-function call should be ignored + ` +const foo = 1; +foo(1 as any); + `, + // too many arguments should be ignored as this is a TS error ` declare function foo(arg: number): void; -foo(1); +foo(1, 1 as any, 2 as any); `, ` declare function foo(arg: number, arg2: string): void; @@ -60,6 +70,21 @@ foo(new Set(), ...x); declare function foo(arg1: unknown, arg2: Set, arg3: unknown[]): void; foo(1 as any, new Set(), [] as any[]); `, + ` +declare function foo(...params: [number, string, any]): void; +foo(1, 'a', 1 as any); + `, + // Unfortunately - we cannot handle this case because TS infers `params` to be a tuple type + // that tuple type is the same as the type of + ` +declare function foo(...params: E): void; + +foo('a', 'b', 1 as any); + `, + ` +declare function toHaveBeenCalledWith(...params: E): void; +toHaveBeenCalledWith(1 as any); + `, ], invalid: [ { @@ -264,5 +289,61 @@ foo(new Set(), ...x); }, ], }, + { + code: ` +declare function foo(...params: [number, string, any]): void; +foo(1 as any, 'a' as any, 1 as any); + `, + errors: [ + { + messageId: 'unsafeArgument', + line: 3, + column: 5, + endColumn: 13, + data: { + sender: 'any', + receiver: 'number', + }, + }, + { + messageId: 'unsafeArgument', + line: 3, + column: 15, + endColumn: 25, + data: { + sender: 'any', + receiver: 'string', + }, + }, + ], + }, + { + code: ` +declare function foo(param1: string, ...params: [number, string, any]): void; +foo('a', 1 as any, 'a' as any, 1 as any); + `, + errors: [ + { + messageId: 'unsafeArgument', + line: 3, + column: 10, + endColumn: 18, + data: { + sender: 'any', + receiver: 'number', + }, + }, + { + messageId: 'unsafeArgument', + line: 3, + column: 20, + endColumn: 30, + data: { + sender: 'any', + receiver: 'string', + }, + }, + ], + }, ], });