Skip to content

Commit

Permalink
feat: add no-property-in-node rule (#433)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
JoshuaKGoldberg committed Feb 6, 2024
1 parent 6ae0ee6 commit d2b9372
Show file tree
Hide file tree
Showing 11 changed files with 381 additions and 49 deletions.
1 change: 1 addition & 0 deletions .eslint-doc-generatorrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const prettier = require('prettier');
module.exports = {
ignoreConfig: [
'all',
'all-type-checked',
'rules',
'rules-recommended',
'tests',
Expand Down
99 changes: 53 additions & 46 deletions README.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions configs/all-type-checked.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use strict';

const mod = require('../lib/index.js');

module.exports = {
plugins: { 'eslint-plugin': mod },
rules: mod.configs['all-type-checked'].rules,
};
56 changes: 56 additions & 0 deletions docs/rules/no-property-in-node.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Disallow using `in` to narrow node types instead of looking at properties (`eslint-plugin/no-property-in-node`)

💭 This rule requires type information.

<!-- end auto-generated rule header -->

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.
`'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`.

Instead, checking a node's `type` property is generally considered preferable.

## Rule Details

Examples of **incorrect** code for this rule:

```ts
/* eslint eslint-plugin/no-property-in-node: error */

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
/* ... */
},
create(context) {
return {
'ClassDeclaration, FunctionDeclaration'(node) {
if ('superClass' in node) {
console.log('This is a class declaration:', node);
}
},
};
},
};
```

Examples of **correct** code for this rule:

```ts
/* eslint eslint-plugin/no-property-in-node: error */

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
/* ... */
},
create(context) {
return {
'ClassDeclaration, FunctionDeclaration'(node) {
if (node.type === 'ClassDeclaration') {
console.log('This is a class declaration:', node);
}
},
};
},
};
```
3 changes: 2 additions & 1 deletion lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ const packageMetadata = require('../package');
const PLUGIN_NAME = packageMetadata.name.replace(/^eslint-plugin-/, '');

const configFilters = {
all: () => true,
all: (rule) => !rule.meta.docs.requiresTypeChecking,
'all-type-checked': () => true,
recommended: (rule) => rule.meta.docs.recommended,
rules: (rule) => rule.meta.docs.category === 'Rules',
tests: (rule) => rule.meta.docs.category === 'Tests',
Expand Down
86 changes: 86 additions & 0 deletions lib/rules/no-property-in-node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
'use strict';

const typedNodeSourceFileTesters = [
/@types[/\\]estree[/\\]index\.d\.ts/,
/@typescript-eslint[/\\]types[/\\]dist[/\\]generated[/\\]ast-spec\.d\.ts/,
];

/**
* Given a TypeScript type, determines whether the type appears to be for a known
* AST type from the typings of @typescript-eslint/types or estree.
* We check based on two rough conditions:
* - The type has a 'kind' property (as AST node types all do)
* - The type is declared in one of those package's .d.ts types
*
* @example
* ```
* module.exports = {
* create(context) {
* BinaryExpression(node) {
* const type = services.getTypeAtLocation(node.right);
* // ^^^^
* // This variable's type will be TSESTree.BinaryExpression
* }
* }
* }
* ```
*
* @param {import('typescript').Type} type
* @returns Whether the type seems to include a known ESTree or TSESTree AST node.
*/
function isAstNodeType(type) {
return (type.types || [type])
.filter((typePart) => typePart.getProperty('type'))
.flatMap(
(typePart) => (typePart.symbol && typePart.symbol.declarations) || []
)
.some((declaration) => {
const fileName = declaration.getSourceFile().fileName;
return (
fileName &&
typedNodeSourceFileTesters.some((tester) => tester.test(fileName))
);
});
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'disallow using `in` to narrow node types instead of looking at properties',
category: 'Rules',
recommended: false,
requiresTypeChecking: true,
url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/no-property-in-node.md',
},
schema: [],
messages: {
in: 'Prefer checking specific node properties instead of a broad `in`.',
},
},

create(context) {
return {
'BinaryExpression[operator=in]'(node) {
// TODO: Switch this to ESLintUtils.getParserServices with typescript-eslint@>=6
// https://github.com/eslint-community/eslint-plugin-eslint-plugin/issues/269
const services = (context.sourceCode || context).parserServices;
if (!services.program) {
throw new Error(
'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.'
);
}

const checker = services.program.getTypeChecker();
const tsNode = services.esTreeNodeToTSNodeMap.get(node.right);
const type = checker.getTypeAtLocation(tsNode);

if (isAstNodeType(type)) {
context.report({ messageId: 'in', node });
}
},
};
},
};
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@
"@eslint/eslintrc": "^2.0.2",
"@eslint/js": "^8.37.0",
"@release-it/conventional-changelog": "^4.3.0",
"@typescript-eslint/parser": "^5.36.2",
"@types/eslint": "^8.56.2",
"@types/estree": "^1.0.5",
"@typescript-eslint/parser": "^5.62.0",
"@typescript-eslint/utils": "^5.62.0",
"chai": "^4.3.6",
"dirty-chai": "^2.0.1",
"eslint": "^8.23.0",
Expand All @@ -81,7 +84,7 @@
"nyc": "^15.1.0",
"prettier": "^2.7.1",
"release-it": "^14.14.3",
"typescript": "^5.0.4"
"typescript": "5.1.3"
},
"peerDependencies": {
"eslint": ">=7.0.0"
Expand Down
Empty file added tests/lib/fixtures/estree.ts
Empty file.
Empty file added tests/lib/fixtures/file.ts
Empty file.
5 changes: 5 additions & 0 deletions tests/lib/fixtures/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"compilerOptions": {
"moduleResolution": "NodeNext"
}
}
165 changes: 165 additions & 0 deletions tests/lib/rules/no-property-in-node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
'use strict';

const RuleTester = require('eslint').RuleTester;
const path = require('path');
const rule = require('../../../lib/rules/no-property-in-node');

const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: path.join(__dirname, '../fixtures'),
},
});

ruleTester.run('no-property-in-node', rule, {
valid: [
`'a' in window;`,
`
declare const node: Node;
'a' in node;
`,
`
type Node = { unrelated: true; };
declare const node: Node;
'a' in node;
`,
`
interface Node {
unrelated: true;
};
declare const node: Node;
'a' in node;
`,
`
declare const node: UnresolvedType;
'a' in node;
`,
`
import * as ESTree from 'estree';
declare const loc: ESTree.SourceLocation;
'a' in loc;
`,
`
import * as ESTree from 'estree';
declare const node: ESTree.Node;
a.superClass;
`,
`
import * as ESTree from 'estree';
declare const node: ESTree.Node;
a.type;
`,
`
import * as ESTree from 'estree';
declare const node: ESTree.Node;
a.type === 'ClassDeclaration';
`,
`
import * as ESTree from 'estree';
declare const node: ESTree.ClassDeclaration | ESTree.FunctionDeclaration;
a.type === 'ClassDeclaration';
`,
`
import { TSESTree } from '@typescript-eslint/utils';
declare const node: TSESTree.Node;
node.superClass;
`,
`
import { TSESTree } from '@typescript-eslint/utils';
declare const node: TSESTree.Node;
node.type;
`,
`
import { TSESTree } from '@typescript-eslint/utils';
declare const node: TSESTree.ClassDeclaration | TSESTree.FunctionDeclaration;
node.type === 'ClassDeclaration';
`,
`
import * as eslint from 'eslint';
const listener: eslint.Rule.RuleListener = {
ClassDeclaration(node) {
node.type;
},
};
`,
`
import * as eslint from 'eslint';
const listener: eslint.Rule.RuleListener = {
'ClassDeclaration, FunctionDeclaration'(node) {
node.type === 'ClassDeclaration';
},
};
`,
],
invalid: [
{
code: `
import { TSESTree } from '@typescript-eslint/utils';
declare const node: TSESTree.Node;
'a' in node;
`,
errors: [
{
column: 9,
line: 4,
endColumn: 20,
endLine: 4,
messageId: 'in',
},
],
},
{
code: `
import { TSESTree } from '@typescript-eslint/utils';
type Other = { key: true };
declare const node: TSESTree.Node | Other;
'a' in node;
`,
errors: [
{
column: 9,
line: 5,
endColumn: 20,
endLine: 5,
messageId: 'in',
},
],
},
{
code: `
import * as ESTree from 'estree';
declare const node: ESTree.Node;
'a' in node;
`,
errors: [
{
column: 9,
line: 4,
endColumn: 20,
endLine: 4,
messageId: 'in',
},
],
},
{
code: `
import * as eslint from 'eslint';
const listener: eslint.Rule.RuleListener = {
ClassDeclaration(node) {
'a' in node;
},
};
`,
errors: [
{
column: 13,
line: 5,
endColumn: 24,
endLine: 5,
messageId: 'in',
},
],
},
],
});

0 comments on commit d2b9372

Please sign in to comment.