Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(eslint-plugin): added new rule unbound-method #204

1 change: 1 addition & 0 deletions packages/eslint-plugin/README.md
Expand Up @@ -137,6 +137,7 @@ Then you should add `airbnb` (or `airbnb-base`) to your `extends` section of `.e
| [`@typescript-eslint/no-this-alias`](./docs/rules/no-this-alias.md) | Disallow aliasing `this` (`no-this-assignment` from TSLint) | | |
| [`@typescript-eslint/no-triple-slash-reference`](./docs/rules/no-triple-slash-reference.md) | Disallow `/// <reference path="" />` comments (`no-reference` from TSLint) | :heavy_check_mark: | |
| [`@typescript-eslint/no-type-alias`](./docs/rules/no-type-alias.md) | Disallow the use of type aliases (`interface-over-type-literal` from TSLint) | | |
| [`@typescript-eslint/no-unbound-method`](./docs/rules/no-unbound-method.md) | Enforces unbound methods are called with their expected scope. (`no-unbound-method` from TSLint) | :heavy_check_mark: | |
| [`@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 (`no-unnecessary-type-assertion` from TSLint) | | :wrench: |
| [`@typescript-eslint/no-unused-vars`](./docs/rules/no-unused-vars.md) | Disallow unused variables (`no-unused-variable` from TSLint) | :heavy_check_mark: | |
| [`@typescript-eslint/no-use-before-define`](./docs/rules/no-use-before-define.md) | Disallow the use of variables before they are defined | :heavy_check_mark: | |
Expand Down
7 changes: 4 additions & 3 deletions packages/eslint-plugin/ROADMAP.md
@@ -1,10 +1,10 @@
# Roadmap

✅ (29) = done<br>
✅ (30) = done<br>
🌟 (79) = in ESLint core<br>
🔌 (33) = in another plugin<br>
🌓 (16) = implementations differ or ESLint version is missing functionality<br>
🛑 (68) = unimplemented
🛑 (67) = unimplemented

## TSLint rules

Expand Down Expand Up @@ -75,7 +75,7 @@
| [`no-submodule-imports`] | 🌓 | [`import/no-internal-modules`] (slightly different) |
| [`no-switch-case-fall-through`] | 🌟 | [`no-fallthrough`][no-fallthrough] |
| [`no-this-assignment`] | ✅ | [`@typescript-eslint/no-this-alias`] |
| [`no-unbound-method`] | 🛑 | N/A |
| [`no-unbound-method`] | | [`@typescript-eslint/no-unbound-method`] |
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
| [`no-unnecessary-class`] | ✅ | [`@typescript-eslint/no-extraneous-class`] |
| [`no-unsafe-any`] | 🛑 | N/A |
| [`no-unsafe-finally`] | 🌟 | [`no-unsafe-finally`][no-unsafe-finally] |
Expand Down Expand Up @@ -567,6 +567,7 @@ Relevant plugins: [`chai-expect-keywords`](https://github.com/gavinaiken/eslint-
[`@typescript-eslint/no-namespace`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-namespace.md
[`@typescript-eslint/no-non-null-assertion`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-non-null-assertion.md
[`@typescript-eslint/no-triple-slash-reference`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-triple-slash-reference.md
[`@typescript-eslint/no-unbound-method`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-unbound-method.md
[`@typescript-eslint/no-unnecessary-type-assertion`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-unnecessary-type-assertion.md
[`@typescript-eslint/no-var-requires`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-var-requires.md
[`@typescript-eslint/type-annotation-spacing`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/type-annotation-spacing.md
Expand Down
92 changes: 92 additions & 0 deletions packages/eslint-plugin/docs/rules/no-unbound-method.md
@@ -0,0 +1,92 @@
# Enforces unbound methods are called with their expected scope (no-unbound-method)

Warns when a method is used outside of a method call.

Class functions don't preserve the class scope when passed as standalone variables.

## Rule Details

Examples of **incorrect** code for this rule

```ts
class MyClass {
public log(): void {
console.log(this);
}
}

const instance = new MyClass();

// This logs the global scope (`window`/`global`), not the class instance
const myLog = instance.log;
myLog();

// This log might later be called with an incorrect scope
const { log } = instance;
```

Examples of **correct** code for this rule

```ts
class MyClass {
public logUnbound(): void {
console.log(this);
}

public logBound = () => console.log(this);
}

const instance = new MyClass();

// logBound will always be bound with the correct scope
const { logBound } = instance;
logBound();

// .bind and lambdas will also add a correct scope
const dotBindLog = instance.log.bind(instance);
const innerLog = () => instance.log();
```

## Options

The rule accepts an options object with the following property:

- `ignoreStatic` to not check whether `static` methods are correctly bound

### `ignoreStatic`

Examples of **correct** code for this rule with `{ ignoreStatic: true }`:

```ts
class OtherClass {
static log() {
console.log(OtherClass);
}
}

// With `ignoreStatic`, statics are assumed to not rely on a particular scope
const { log } = OtherClass;

log();
```

### Example

```json
{
"@typescript-eslint/no-unbound-method": [
"error",
{
"ignoreStatic": true
}
]
}
```

## When Not To Use It

If your code intentionally waits to bind methods after use, such as by passing a `scope: this` along with the method, you can disable this rule.

## Related To

- TSLint: [no-unbound-method](https://palantir.github.io/tslint/rules/no-unbound-method/)
131 changes: 131 additions & 0 deletions packages/eslint-plugin/lib/rules/no-unbound-method.js
@@ -0,0 +1,131 @@
/**
* @fileoverview Enforces unbound methods are called with their expected scope.
* @author Josh Goldberg <https://github.com/joshuakgoldberg>
*/
'use strict';

const tsutils = require('tsutils');
const ts = require('typescript');

const util = require('../util');

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

const defaultOptions = [
{
ignoreStatic: false
}
];

/**
* @type {import("eslint").Rule.RuleModule}
*/
module.exports = {
meta: {
docs: {
description:
'Enforces unbound methods are called with their expected scope.',
extraDescription: [util.tslintRule('no-unbound-method')],
category: 'TypeScript',
url: util.metaDocsUrl('no-unbound-method'),
recommended: 'error'
},
fixable: null,
messages: {
unbound:
'Avoid referencing unbound methods which may cause unintentional scoping of `this`.'
},
schema: [
{
type: 'object',
properties: {
ignoreStatic: {
type: 'boolean'
}
},
additionalProperties: false
}
],
type: 'problem'
},

create(context) {
const { ignoreStatic } = util.applyDefault(
defaultOptions,
context.options
)[0];
const parserServices = util.getParserServices(context);
const checker = parserServices.program.getTypeChecker();

return {
MemberExpression(node) {
if (isSafeUse(node)) {
return;
}

const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node);
const symbol = checker.getSymbolAtLocation(originalNode);

if (isDangerousMethod(symbol, ignoreStatic)) {
context.report({
messageId: 'unbound',
node
});
}
}
};
}
};

function isDangerousMethod(symbol, ignoreStatic) {
const declaration =
typeof symbol === 'undefined' ? undefined : symbol.valueDeclaration;
if (typeof declaration === 'undefined') {
return false;
}

switch (declaration.kind) {
case ts.SyntaxKind.MethodDeclaration:
case ts.SyntaxKind.MethodSignature:
return !(
ignoreStatic &&
tsutils.hasModifier(declaration.modifiers, ts.SyntaxKind.StaticKeyword)
);
}

return false;
}

function isSafeUse(node) {
const { parent } = node;

switch (parent.type) {
case 'IfStatement':
case 'ForStatement':
case 'MemberExpression':
case 'UpdateExpression':
case 'WhileStatement':
return true;

case 'CallExpression':
return parent.callee === node;

case 'ConditionalExpression':
return parent.test === node;

case 'LogicalExpression':
return parent.operator !== '||';

case 'TaggedTemplateExpression':
return parent.tag === node;

case 'TSNonNullExpression':
case 'TSAsExpression':
case 'TSTypeAssertionExpression':
return isSafeUse(parent);
}

return false;
}