Skip to content

Commit

Permalink
feat(eslint-plugin): [no-unsafe-argument] add rule (#3256)
Browse files Browse the repository at this point in the history
Fixes #791
  • Loading branch information
bradzacher committed Apr 2, 2021
1 parent 62dfcc6 commit b1aa7dc
Show file tree
Hide file tree
Showing 7 changed files with 562 additions and 1 deletion.
1 change: 1 addition & 0 deletions packages/eslint-plugin/README.md
Expand Up @@ -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: |
Expand Down
69 changes: 69 additions & 0 deletions 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<any>` to an argument declared as `Set<string>`.

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<string>, arg2: Map<string, string>): void;
foo(new Set<any>(), new Map<any, string>());
```

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<string>, arg2: Map<string, string>): void;
foo(new Set<string>(), new Map<string, string>());
```

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<unkown>, arg3: unknown[]): void;
foo(1 as any, new Set<any>(), [] as any[]);
```

## Related to

- [`no-explicit-any`](./no-explicit-any.md)
- TSLint: [`no-unsafe-any`](https://palantir.github.io/tslint/rules/no-unsafe-any/)
2 changes: 1 addition & 1 deletion 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

Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.ts
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
220 changes: 220 additions & 0 deletions 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),
},
});
}
}
}
}
},
};
},
});

0 comments on commit b1aa7dc

Please sign in to comment.