Skip to content

Commit c318644

Browse files
authored
Add no-thenable rule (#1616)
1 parent 7bf63bb commit c318644

File tree

8 files changed

+1310
-0
lines changed

8 files changed

+1310
-0
lines changed

configs/recommended.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ module.exports = {
4040
'unicorn/no-object-as-default-parameter': 'error',
4141
'unicorn/no-process-exit': 'error',
4242
'unicorn/no-static-only-class': 'error',
43+
'unicorn/no-thenable': 'error',
4344
'unicorn/no-this-assignment': 'error',
4445
'unicorn/no-unreadable-array-destructuring': 'error',
4546
'unicorn/no-unsafe-regex': 'off',

docs/rules/no-thenable.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Disallow `then` property
2+
3+
*This rule is part of the [recommended](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config) config.*
4+
5+
If an object is defined as "thenable", once it's accidentally used in an await expression, it may cause problems:
6+
7+
```js
8+
const foo = {
9+
unicorn: 1,
10+
then() {},
11+
};
12+
13+
const {unicorn} = await foo;
14+
15+
console.log('after'); //<- This will never execute
16+
```
17+
18+
```js
19+
const foo = {
20+
unicorn: 1,
21+
then() {
22+
throw new Error('You shouldn’t have called me')
23+
},
24+
};
25+
26+
const {unicorn} = await foo;
27+
// Error: You shouldn’t have called me
28+
```
29+
30+
If a module has an export named `then`, dynamic `import()` may not work as expected:
31+
32+
```js
33+
// foo.js
34+
35+
export function then () {
36+
throw new Error('You shouldn’t have called me')
37+
}
38+
```
39+
40+
```js
41+
// bar.js
42+
43+
const foo = await import('./foo.js');
44+
// Error: You shouldn’t have called me
45+
```
46+
47+
## Fail
48+
49+
```js
50+
export {then};
51+
```
52+
53+
```js
54+
const foo = {
55+
then() {}
56+
};
57+
```
58+
59+
```js
60+
const foo = {
61+
get then() {}
62+
};
63+
```
64+
65+
```js
66+
foo.then = function () {}
67+
```
68+
69+
```js
70+
class Foo {
71+
then() {}
72+
}
73+
```
74+
75+
```js
76+
class Foo {
77+
static then() {}
78+
}
79+
```
80+
81+
## Pass
82+
83+
```js
84+
export {then as success};
85+
```
86+
87+
```js
88+
const foo = {
89+
success() {}
90+
};
91+
```
92+
93+
```js
94+
class Foo {
95+
success() {}
96+
}
97+
```
98+
99+
```js
100+
const foo = bar.then;
101+
```

readme.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Configure it in `package.json`.
7474
"unicorn/no-object-as-default-parameter": "error",
7575
"unicorn/no-process-exit": "error",
7676
"unicorn/no-static-only-class": "error",
77+
"unicorn/no-thenable": "error",
7778
"unicorn/no-this-assignment": "error",
7879
"unicorn/no-unreadable-array-destructuring": "error",
7980
"unicorn/no-unsafe-regex": "off",
@@ -187,6 +188,7 @@ Each rule has emojis denoting:
187188
| [no-object-as-default-parameter](docs/rules/no-object-as-default-parameter.md) | Disallow the use of objects as default parameters. || | |
188189
| [no-process-exit](docs/rules/no-process-exit.md) | Disallow `process.exit()`. || | |
189190
| [no-static-only-class](docs/rules/no-static-only-class.md) | Forbid classes that only have static members. || 🔧 | |
191+
| [no-thenable](docs/rules/no-thenable.md) | Disallow `then` property. || | |
190192
| [no-this-assignment](docs/rules/no-this-assignment.md) | Disallow assigning `this` to a variable. || | |
191193
| [no-unreadable-array-destructuring](docs/rules/no-unreadable-array-destructuring.md) | Disallow unreadable array destructuring. || 🔧 | |
192194
| [no-unsafe-regex](docs/rules/no-unsafe-regex.md) | Disallow unsafe regular expressions. | | | |

rules/no-thenable.js

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
'use strict';
2+
const {getStaticValue} = require('eslint-utils');
3+
const {methodCallSelector} = require('./selectors/index.js');
4+
const getPropertyName = require('./utils/get-property-name.js');
5+
const getKeyName = require('./utils/get-key-name.js');
6+
7+
const MESSAGE_ID_OBJECT = 'no-thenable-object';
8+
const MESSAGE_ID_EXPORT = 'no-thenable-export';
9+
const MESSAGE_ID_CLASS = 'no-thenable-class';
10+
const messages = {
11+
[MESSAGE_ID_OBJECT]: 'Do not add `then` to an object.',
12+
[MESSAGE_ID_EXPORT]: 'Do not export `then`.',
13+
[MESSAGE_ID_CLASS]: 'Do not add `then` to a class.',
14+
};
15+
16+
const isStringThen = (node, context) => {
17+
const result = getStaticValue(node, context.getScope());
18+
19+
return result && result.value === 'then';
20+
};
21+
22+
const cases = [
23+
// `{then() {}}`,
24+
// `{get then() {}}`,
25+
// `{[computedKey]() {}}`,
26+
// `{get [computedKey]() {}}`,
27+
{
28+
selector: 'ObjectExpression > Property.properties > .key',
29+
test: (node, context) => getKeyName(node.parent, context.getScope()) === 'then',
30+
messageId: MESSAGE_ID_OBJECT,
31+
},
32+
// `class Foo {then}`,
33+
// `class Foo {static then}`,
34+
// `class Foo {get then() {}}`,
35+
// `class Foo {static get then() {}}`,
36+
{
37+
selector: ':matches(PropertyDefinition, MethodDefinition) > .key',
38+
test: (node, context) => getKeyName(node.parent, context.getScope()) === 'then',
39+
messageId: MESSAGE_ID_CLASS,
40+
},
41+
// `foo.then = …`
42+
// `foo[computedKey] = …`
43+
{
44+
selector: 'AssignmentExpression > MemberExpression.left > .property',
45+
test: (node, context) => getPropertyName(node.parent, context.getScope()) === 'then',
46+
messageId: MESSAGE_ID_OBJECT,
47+
},
48+
// `Object.defineProperty(foo, 'then', …)`
49+
// `Reflect.defineProperty(foo, 'then', …)`
50+
{
51+
selector: [
52+
methodCallSelector({
53+
objects: ['Object', 'Reflect'],
54+
method: 'defineProperty',
55+
minimumArguments: 3,
56+
}),
57+
'[arguments.0.type!="SpreadElement"]',
58+
' > .arguments:nth-child(2)',
59+
].join(''),
60+
test: isStringThen,
61+
messageId: MESSAGE_ID_OBJECT,
62+
},
63+
// `Object.fromEntries(['then', …])`
64+
{
65+
selector: [
66+
methodCallSelector({
67+
object: 'Object',
68+
method: 'fromEntries',
69+
argumentsLength: 1,
70+
}),
71+
' > ArrayExpression.arguments:nth-child(1)',
72+
' > .elements:nth-child(1)',
73+
].join(''),
74+
test: isStringThen,
75+
messageId: MESSAGE_ID_OBJECT,
76+
},
77+
// `export {then}`
78+
{
79+
selector: 'ExportSpecifier.specifiers > Identifier.exported[name="then"]',
80+
messageId: MESSAGE_ID_EXPORT,
81+
},
82+
// `export function then() {}`,
83+
// `export class then {}`,
84+
{
85+
selector: 'ExportNamedDeclaration > :matches(FunctionDeclaration, ClassDeclaration).declaration > Identifier[name="then"].id',
86+
messageId: MESSAGE_ID_EXPORT,
87+
},
88+
// `export const … = …`;
89+
{
90+
selector: 'ExportNamedDeclaration > VariableDeclaration.declaration',
91+
messageId: MESSAGE_ID_EXPORT,
92+
getNodes: (node, context) => context.getDeclaredVariables(node).flatMap(({name, identifiers}) => name === 'then' ? identifiers : []),
93+
},
94+
];
95+
96+
/** @param {import('eslint').Rule.RuleContext} context */
97+
const create = context => Object.fromEntries(
98+
cases.map(({selector, test, messageId, getNodes}) => [
99+
selector,
100+
function * (node) {
101+
if (getNodes) {
102+
for (const problematicNode of getNodes(node, context)) {
103+
yield {node: problematicNode, messageId};
104+
}
105+
106+
return;
107+
}
108+
109+
if (test && !test(node, context)) {
110+
return;
111+
}
112+
113+
yield {node, messageId};
114+
},
115+
]),
116+
);
117+
118+
/** @type {import('eslint').Rule.RuleModule} */
119+
module.exports = {
120+
create,
121+
meta: {
122+
type: 'problem',
123+
docs: {
124+
description: 'Disallow `then` property.',
125+
},
126+
schema: [],
127+
messages,
128+
},
129+
};

rules/utils/get-key-name.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use strict';
2+
const {getStaticValue} = require('eslint-utils');
3+
4+
// TODO[@fisker]: Merge this with `./get-property-name.js`
5+
6+
/**
7+
Get the key value of a node.
8+
9+
@param {Node} node - The node.
10+
@param {Scope} [scope] - The scope to start finding the variable. Optional. If this scope was given, it tries to resolve identifier references which are in the given node as much as possible.
11+
*/
12+
function getKeyName(node, scope) {
13+
const {type, key, computed} = node;
14+
15+
/* istanbul ignore next - Prevent unexpected case */
16+
if (
17+
type !== 'Property'
18+
&& type !== 'PropertyDefinition'
19+
&& type !== 'MethodDefinition'
20+
) {
21+
return;
22+
}
23+
24+
if (!computed) {
25+
if (key.type === 'Identifier') {
26+
return key.name;
27+
}
28+
29+
/* istanbul ignore next: It could be `PrivateIdentifier`(ESTree) or `PrivateName`(Babel) when it's in `class` */
30+
return;
31+
}
32+
33+
const result = getStaticValue(key, scope);
34+
return result && result.value;
35+
}
36+
37+
module.exports = getKeyName;

0 commit comments

Comments
 (0)