Skip to content

Commit 3da0276

Browse files
committed
Breaking: Add support for TypeScript rules
1 parent a7b480e commit 3da0276

File tree

5 files changed

+162
-23
lines changed

5 files changed

+162
-23
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# eslint-plugin-eslint-plugin ![CI](https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/workflows/CI/badge.svg) [![NPM version](https://img.shields.io/npm/v/eslint-plugin-eslint-plugin.svg?style=flat)](https://npmjs.org/package/eslint-plugin-eslint-plugin)
22

3-
An ESLint plugin for linting ESLint plugins
3+
An ESLint plugin for linting ESLint plugins. Rules written in CJS, ESM, and TypeScript are all supported.
44

55
## Installation
66

lib/utils.js

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,24 @@ function isRuleTesterConstruction (node) {
8686
const INTERESTING_RULE_KEYS = new Set(['create', 'meta']);
8787

8888
/**
89-
* Helper for `getRuleInfo`. Handles ESM rules.
89+
* Collect properties from an object that have interesting key names into a new object
90+
* @param {Node[]} properties
91+
* @param {Set<String>} interestingKeys
92+
* @returns Object
93+
*/
94+
function collectInterestingProperties (properties, interestingKeys) {
95+
// eslint-disable-next-line unicorn/prefer-object-from-entries
96+
return properties.reduce((parsedProps, prop) => {
97+
const keyValue = module.exports.getKeyName(prop);
98+
if (interestingKeys.has(keyValue)) {
99+
parsedProps[keyValue] = prop.value;
100+
}
101+
return parsedProps;
102+
}, {});
103+
}
104+
105+
/**
106+
* Helper for `getRuleInfo`. Handles ESM and TypeScript rules.
90107
*/
91108
function getRuleExportsESM (ast) {
92109
return ast.body
@@ -95,16 +112,29 @@ function getRuleExportsESM (ast) {
95112
// eslint-disable-next-line unicorn/prefer-object-from-entries
96113
.reduce((currentExports, node) => {
97114
if (node.type === 'ObjectExpression') {
98-
// eslint-disable-next-line unicorn/prefer-object-from-entries
99-
return node.properties.reduce((parsedProps, prop) => {
100-
const keyValue = module.exports.getKeyName(prop);
101-
if (INTERESTING_RULE_KEYS.has(keyValue)) {
102-
parsedProps[keyValue] = prop.value;
103-
}
104-
return parsedProps;
105-
}, {});
115+
// Check `export default { create() {}, meta: {} }`
116+
return collectInterestingProperties(node.properties, INTERESTING_RULE_KEYS);
106117
} else if (isNormalFunctionExpression(node)) {
118+
// Check `export default function() {}`
107119
return { create: node, meta: null, isNewStyle: false };
120+
} else if (
121+
node.type === 'CallExpression' &&
122+
node.typeParameters &&
123+
node.typeParameters.params.length === 2 && // Expecting: <Options, MessageIds>
124+
node.arguments.length === 1 &&
125+
node.arguments[0].type === 'ObjectExpression' &&
126+
// Check various TypeScript rule helper formats.
127+
(
128+
// createESLintRule({ ... })
129+
node.callee.type === 'Identifier' ||
130+
// util.createRule({ ... })
131+
(node.callee.type === 'MemberExpression' && node.callee.object.type === 'Identifier' && node.callee.property.type === 'Identifier') ||
132+
// ESLintUtils.RuleCreator(docsUrl)({ ... })
133+
(node.callee.type === 'CallExpression' && node.callee.callee.type === 'MemberExpression' && node.callee.callee.object.type === 'Identifier' && node.callee.callee.property.type === 'Identifier')
134+
)
135+
) {
136+
// Check `export default someTypeScriptHelper({ create() {}, meta: {} });
137+
return collectInterestingProperties(node.arguments[0].properties, INTERESTING_RULE_KEYS);
108138
}
109139
return currentExports;
110140
}, {});
@@ -136,14 +166,7 @@ function getRuleExportsCJS (ast) {
136166
} else if (node.right.type === 'ObjectExpression') {
137167
// Check `module.exports = { create: function () {}, meta: {} }`
138168

139-
// eslint-disable-next-line unicorn/prefer-object-from-entries
140-
return node.right.properties.reduce((parsedProps, prop) => {
141-
const keyValue = module.exports.getKeyName(prop);
142-
if (INTERESTING_RULE_KEYS.has(keyValue)) {
143-
parsedProps[keyValue] = prop.value;
144-
}
145-
return parsedProps;
146-
}, {});
169+
return collectInterestingProperties(node.right.properties, INTERESTING_RULE_KEYS);
147170
}
148171
return {};
149172
} else if (

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"statements": 99
4040
},
4141
"devDependencies": {
42+
"@typescript-eslint/parser": "^4.32.0",
4243
"chai": "^4.1.0",
4344
"dirty-chai": "^2.0.1",
4445
"eslint": "^7.9.0",
@@ -55,7 +56,7 @@
5556
"mocha": "^9.1.2",
5657
"npm-run-all": "^4.1.5",
5758
"nyc": "^15.1.0",
58-
"release-it": "^14.9.0"
59+
"typescript": "^4.4.3"
5960
},
6061
"peerDependencies": {
6162
"eslint": ">=6.0.0"

tests/lib/rules/require-meta-docs-description.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,30 @@ ruleTester.run('require-meta-docs-description', rule, {
241241
},
242242
],
243243
});
244+
245+
const ruleTesterTypeScript = new RuleTester({
246+
parserOptions: { sourceType: 'module' },
247+
parser: require.resolve('@typescript-eslint/parser'),
248+
});
249+
ruleTesterTypeScript.run('require-meta-docs-description (TypeScript)', rule, {
250+
valid: [
251+
`
252+
export default createESLintRule<Options, MessageIds>({
253+
meta: { docs: { description: 'disallow unused variables' } },
254+
create(context) {}
255+
});
256+
`,
257+
],
258+
invalid: [
259+
{
260+
code: `
261+
export default createESLintRule<Options, MessageIds>({
262+
meta: {},
263+
create(context) {}
264+
});
265+
`,
266+
output: null,
267+
errors: [{ messageId: 'missing', type: 'ObjectExpression' }],
268+
},
269+
],
270+
});

tests/lib/utils.js

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ const eslintScope = require('eslint-scope');
77
const estraverse = require('estraverse');
88
const assert = require('chai').assert;
99
const utils = require('../../lib/utils');
10+
const typescriptEslintParser = require('@typescript-eslint/parser');
1011

1112
describe('utils', () => {
1213
describe('getRuleInfo', () => {
13-
describe('the file does not have a valid rule', () => {
14+
describe('the file does not have a valid rule (CJS)', () => {
1415
[
1516
'',
1617
'module.exports;',
@@ -39,6 +40,19 @@ describe('utils', () => {
3940
'export const foo = { create() {} }',
4041
'export default { foo: {} }',
4142
'const foo = {}; export default foo',
43+
44+
// Incorrect TypeScript helper structure:
45+
'export default foo()({ create() {}, meta: {} });',
46+
'export default foo().bar({ create() {}, meta: {} });',
47+
'export default foo.bar.baz({ create() {}, meta: {} });',
48+
'export default foo(123);',
49+
'export default foo.bar(123);',
50+
'export default foo.bar()(123);',
51+
52+
// Correct TypeScript helper structure but missing parameterized types:
53+
'export default createESLintRule({ create() {}, meta: {} })',
54+
'export default util.createRule({ create() {}, meta: {} });',
55+
'export default ESLintUtils.RuleCreator(docsUrl)({ create() {}, meta: {} });',
4256
].forEach(noRuleCase => {
4357
it(`returns null for ${noRuleCase}`, () => {
4458
const ast = espree.parse(noRuleCase, { ecmaVersion: 8, range: true, sourceType: 'module' });
@@ -47,9 +61,66 @@ describe('utils', () => {
4761
});
4862
});
4963

50-
describe('the file has a valid rule', () => {
64+
describe('the file does not have a valid rule (TypeScript + TypeScript parser)', () => {
65+
[
66+
// Incorrect TypeScript helper structure:
67+
'export default foo()<Options, MessageIds>({ create() {}, meta: {} });',
68+
'export default foo().bar<Options, MessageIds>({ create() {}, meta: {} });',
69+
'export default foo.bar.baz<Options, MessageIds>({ create() {}, meta: {} });',
70+
'export default foo<Options, MessageIds>(123);',
71+
'export default foo.bar<Options, MessageIds>(123);',
72+
'export default foo.bar()<Options, MessageIds>(123);',
73+
74+
// Correct TypeScript helper structure but missing parameterized types:
75+
'export default createESLintRule({ create() {}, meta: {} })',
76+
'export default createESLintRule<>({ create() {}, meta: {} })',
77+
'export default createESLintRule<OnlyOneType>({ create() {}, meta: {} })',
78+
'export default util.createRule({ create() {}, meta: {} });',
79+
'export default ESLintUtils.RuleCreator(docsUrl)({ create() {}, meta: {} });',
80+
].forEach(noRuleCase => {
81+
it(`returns null for ${noRuleCase}`, () => {
82+
const ast = typescriptEslintParser.parse(noRuleCase, { ecmaVersion: 8, range: true, sourceType: 'module' });
83+
assert.isNull(utils.getRuleInfo({ ast }), 'Expected no rule to be found');
84+
});
85+
});
86+
});
87+
88+
describe('the file has a valid rule (TypeScript + TypeScript parser)', () => {
89+
const CASES = {
90+
// Util function only
91+
'export default createESLintRule<Options, MessageIds>({ create() {}, meta: {} })': {
92+
create: { type: 'FunctionExpression' },
93+
meta: { type: 'ObjectExpression' },
94+
isNewStyle: true,
95+
},
96+
// Util function from util object
97+
'export default util.createRule<Options, MessageIds>({ create() {}, meta: {} });': {
98+
create: { type: 'FunctionExpression' },
99+
meta: { type: 'ObjectExpression' },
100+
isNewStyle: true,
101+
},
102+
// Util function from util object with additional doc URL argument
103+
'export default ESLintUtils.RuleCreator(docsUrl)<Options, MessageIds>({ create() {}, meta: {} });': {
104+
create: { type: 'FunctionExpression' },
105+
meta: { type: 'ObjectExpression' },
106+
isNewStyle: true,
107+
},
108+
};
109+
110+
Object.keys(CASES).forEach(ruleSource => {
111+
it(ruleSource, () => {
112+
const ast = typescriptEslintParser.parse(ruleSource, { ecmaVersion: 6, range: true, sourceType: 'module' });
113+
const ruleInfo = utils.getRuleInfo({ ast });
114+
assert(
115+
lodash.isMatch(ruleInfo, CASES[ruleSource]),
116+
`Expected \n${inspect(ruleInfo)}\nto match\n${inspect(CASES[ruleSource])}`
117+
);
118+
});
119+
});
120+
});
121+
122+
describe('the file has a valid rule (CJS)', () => {
51123
const CASES = {
52-
// CJS
53124
'module.exports = { create: function foo() {} };': {
54125
create: { type: 'FunctionExpression', id: { name: 'foo' } }, // (This property will actually contain the AST node.)
55126
meta: null,
@@ -125,7 +196,22 @@ describe('utils', () => {
125196
meta: null,
126197
isNewStyle: false,
127198
},
199+
};
200+
201+
Object.keys(CASES).forEach(ruleSource => {
202+
it(ruleSource, () => {
203+
const ast = espree.parse(ruleSource, { ecmaVersion: 6, range: true, sourceType: 'script' });
204+
const ruleInfo = utils.getRuleInfo({ ast });
205+
assert(
206+
lodash.isMatch(ruleInfo, CASES[ruleSource]),
207+
`Expected \n${inspect(ruleInfo)}\nto match\n${inspect(CASES[ruleSource])}`
208+
);
209+
});
210+
});
211+
});
128212

213+
describe('the file has a valid rule (ESM)', () => {
214+
const CASES = {
129215
// ESM (object style)
130216
'export default { create() {} }': {
131217
create: { type: 'FunctionExpression' },
@@ -153,15 +239,17 @@ describe('utils', () => {
153239

154240
Object.keys(CASES).forEach(ruleSource => {
155241
it(ruleSource, () => {
156-
const ast = espree.parse(ruleSource, { ecmaVersion: 6, range: true, sourceType: ruleSource.startsWith('export default') ? 'module' : 'script' });
242+
const ast = espree.parse(ruleSource, { ecmaVersion: 6, range: true, sourceType: 'module' });
157243
const ruleInfo = utils.getRuleInfo({ ast });
158244
assert(
159245
lodash.isMatch(ruleInfo, CASES[ruleSource]),
160246
`Expected \n${inspect(ruleInfo)}\nto match\n${inspect(CASES[ruleSource])}`
161247
);
162248
});
163249
});
250+
});
164251

252+
describe('the file has a valid rule (different scope options)', () => {
165253
for (const scopeOptions of [
166254
{ ignoreEval: true, ecmaVersion: 6, sourceType: 'script', nodejsScope: true },
167255
{ ignoreEval: true, ecmaVersion: 6, sourceType: 'script' },

0 commit comments

Comments
 (0)