Skip to content

Commit d2b9372

Browse files
feat: add no-property-in-node rule (#433)
* feat: add no-property-in-node rule * Add ☑️ emoji for recommended-type-checked * tests: valid before invalid * Also check for whether the node has a 'type' * Added docs and example to isAstNodeType * Expanded rule details * Add more valid test cases * Fixed test path to fixtures * Use parserOptions.project: true for eslint-remote-tester on TS files * nit: avoid shadowing name for typePart * <!-- omit from toc --> * Downgraded to typescript-eslint@v5 * Also remove @typescript-eslint/utils * Or rather, make @typescript-eslint/utils a -D * Remove ts-api-utils too * Removed recommended-type-checked * Removed README.md section too * Removed eslint-remote-tester.config.js parserOptions.project too * Redid README.md presets table * Added all-type-checked * Removed file notice
1 parent 6ae0ee6 commit d2b9372

File tree

11 files changed

+381
-49
lines changed

11 files changed

+381
-49
lines changed

.eslint-doc-generatorrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const prettier = require('prettier');
66
module.exports = {
77
ignoreConfig: [
88
'all',
9+
'all-type-checked',
910
'rules',
1011
'rules-recommended',
1112
'tests',

README.md

Lines changed: 53 additions & 46 deletions
Large diffs are not rendered by default.

configs/all-type-checked.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use strict';
2+
3+
const mod = require('../lib/index.js');
4+
5+
module.exports = {
6+
plugins: { 'eslint-plugin': mod },
7+
rules: mod.configs['all-type-checked'].rules,
8+
};

docs/rules/no-property-in-node.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Disallow using `in` to narrow node types instead of looking at properties (`eslint-plugin/no-property-in-node`)
2+
3+
💭 This rule requires type information.
4+
5+
<!-- end auto-generated rule header -->
6+
7+
When working with a node of type `ESTree.Node` or `TSESTree.Node`, it can be tempting to use the `'in'` operator to narrow the node's type.
8+
`'in'` narrowing is susceptible to confusing behavior from quirks of ASTs, such as node properties sometimes being omitted from nodes and other times explicitly being set to `null` or `undefined`.
9+
10+
Instead, checking a node's `type` property is generally considered preferable.
11+
12+
## Rule Details
13+
14+
Examples of **incorrect** code for this rule:
15+
16+
```ts
17+
/* eslint eslint-plugin/no-property-in-node: error */
18+
19+
/** @type {import('eslint').Rule.RuleModule} */
20+
module.exports = {
21+
meta: {
22+
/* ... */
23+
},
24+
create(context) {
25+
return {
26+
'ClassDeclaration, FunctionDeclaration'(node) {
27+
if ('superClass' in node) {
28+
console.log('This is a class declaration:', node);
29+
}
30+
},
31+
};
32+
},
33+
};
34+
```
35+
36+
Examples of **correct** code for this rule:
37+
38+
```ts
39+
/* eslint eslint-plugin/no-property-in-node: error */
40+
41+
/** @type {import('eslint').Rule.RuleModule} */
42+
module.exports = {
43+
meta: {
44+
/* ... */
45+
},
46+
create(context) {
47+
return {
48+
'ClassDeclaration, FunctionDeclaration'(node) {
49+
if (node.type === 'ClassDeclaration') {
50+
console.log('This is a class declaration:', node);
51+
}
52+
},
53+
};
54+
},
55+
};
56+
```

lib/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ const packageMetadata = require('../package');
1515
const PLUGIN_NAME = packageMetadata.name.replace(/^eslint-plugin-/, '');
1616

1717
const configFilters = {
18-
all: () => true,
18+
all: (rule) => !rule.meta.docs.requiresTypeChecking,
19+
'all-type-checked': () => true,
1920
recommended: (rule) => rule.meta.docs.recommended,
2021
rules: (rule) => rule.meta.docs.category === 'Rules',
2122
tests: (rule) => rule.meta.docs.category === 'Tests',

lib/rules/no-property-in-node.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
'use strict';
2+
3+
const typedNodeSourceFileTesters = [
4+
/@types[/\\]estree[/\\]index\.d\.ts/,
5+
/@typescript-eslint[/\\]types[/\\]dist[/\\]generated[/\\]ast-spec\.d\.ts/,
6+
];
7+
8+
/**
9+
* Given a TypeScript type, determines whether the type appears to be for a known
10+
* AST type from the typings of @typescript-eslint/types or estree.
11+
* We check based on two rough conditions:
12+
* - The type has a 'kind' property (as AST node types all do)
13+
* - The type is declared in one of those package's .d.ts types
14+
*
15+
* @example
16+
* ```
17+
* module.exports = {
18+
* create(context) {
19+
* BinaryExpression(node) {
20+
* const type = services.getTypeAtLocation(node.right);
21+
* // ^^^^
22+
* // This variable's type will be TSESTree.BinaryExpression
23+
* }
24+
* }
25+
* }
26+
* ```
27+
*
28+
* @param {import('typescript').Type} type
29+
* @returns Whether the type seems to include a known ESTree or TSESTree AST node.
30+
*/
31+
function isAstNodeType(type) {
32+
return (type.types || [type])
33+
.filter((typePart) => typePart.getProperty('type'))
34+
.flatMap(
35+
(typePart) => (typePart.symbol && typePart.symbol.declarations) || []
36+
)
37+
.some((declaration) => {
38+
const fileName = declaration.getSourceFile().fileName;
39+
return (
40+
fileName &&
41+
typedNodeSourceFileTesters.some((tester) => tester.test(fileName))
42+
);
43+
});
44+
}
45+
46+
/** @type {import('eslint').Rule.RuleModule} */
47+
module.exports = {
48+
meta: {
49+
type: 'suggestion',
50+
docs: {
51+
description:
52+
'disallow using `in` to narrow node types instead of looking at properties',
53+
category: 'Rules',
54+
recommended: false,
55+
requiresTypeChecking: true,
56+
url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/no-property-in-node.md',
57+
},
58+
schema: [],
59+
messages: {
60+
in: 'Prefer checking specific node properties instead of a broad `in`.',
61+
},
62+
},
63+
64+
create(context) {
65+
return {
66+
'BinaryExpression[operator=in]'(node) {
67+
// TODO: Switch this to ESLintUtils.getParserServices with typescript-eslint@>=6
68+
// https://github.com/eslint-community/eslint-plugin-eslint-plugin/issues/269
69+
const services = (context.sourceCode || context).parserServices;
70+
if (!services.program) {
71+
throw new Error(
72+
'You have used a rule which requires parserServices to be generated. You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser.'
73+
);
74+
}
75+
76+
const checker = services.program.getTypeChecker();
77+
const tsNode = services.esTreeNodeToTSNodeMap.get(node.right);
78+
const type = checker.getTypeAtLocation(tsNode);
79+
80+
if (isAstNodeType(type)) {
81+
context.report({ messageId: 'in', node });
82+
}
83+
},
84+
};
85+
},
86+
};

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@
5555
"@eslint/eslintrc": "^2.0.2",
5656
"@eslint/js": "^8.37.0",
5757
"@release-it/conventional-changelog": "^4.3.0",
58-
"@typescript-eslint/parser": "^5.36.2",
58+
"@types/eslint": "^8.56.2",
59+
"@types/estree": "^1.0.5",
60+
"@typescript-eslint/parser": "^5.62.0",
61+
"@typescript-eslint/utils": "^5.62.0",
5962
"chai": "^4.3.6",
6063
"dirty-chai": "^2.0.1",
6164
"eslint": "^8.23.0",
@@ -81,7 +84,7 @@
8184
"nyc": "^15.1.0",
8285
"prettier": "^2.7.1",
8386
"release-it": "^14.14.3",
84-
"typescript": "^5.0.4"
87+
"typescript": "5.1.3"
8588
},
8689
"peerDependencies": {
8790
"eslint": ">=7.0.0"

tests/lib/fixtures/estree.ts

Whitespace-only changes.

tests/lib/fixtures/file.ts

Whitespace-only changes.

tests/lib/fixtures/tsconfig.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"compilerOptions": {
3+
"moduleResolution": "NodeNext"
4+
}
5+
}

0 commit comments

Comments
 (0)