Skip to content

Commit 382baa0

Browse files
authored
Breaking: Add support for TypeScript rules (#197)
1 parent ac87852 commit 382baa0

File tree

5 files changed

+182
-23
lines changed

5 files changed

+182
-23
lines changed

README.md

+1-1
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

+40-17
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

+3-1
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": "^8.0.0",
@@ -55,7 +56,8 @@
5556
"mocha": "^9.1.2",
5657
"npm-run-all": "^4.1.5",
5758
"nyc": "^15.1.0",
58-
"release-it": "^14.9.0"
59+
"release-it": "^14.9.0",
60+
"typescript": "^4.4.3"
5961
},
6062
"peerDependencies": {
6163
"eslint": ">=6.0.0"

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

+27
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

+111-4
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;',
@@ -25,6 +26,11 @@ describe('utils', () => {
2526
'module.exports = { create: foo }',
2627
'module.exports = { create: function* foo() {} }',
2728
'module.exports = { create: async function foo() {} }',
29+
30+
// Correct TypeScript helper structure but missing parameterized types (note: we don't support CJS for TypeScript rules):
31+
'module.exports = createESLintRule({ create() {}, meta: {} });',
32+
'module.exports = util.createRule({ create() {}, meta: {} });',
33+
'module.exports = ESLintUtils.RuleCreator(docsUrl)({ create() {}, meta: {} });',
2834
].forEach(noRuleCase => {
2935
it(`returns null for ${noRuleCase}`, () => {
3036
const ast = espree.parse(noRuleCase, { ecmaVersion: 8, range: true });
@@ -39,6 +45,19 @@ describe('utils', () => {
3945
'export const foo = { create() {} }',
4046
'export default { foo: {} }',
4147
'const foo = {}; export default foo',
48+
49+
// Incorrect TypeScript helper structure:
50+
'export default foo()({ create() {}, meta: {} });',
51+
'export default foo().bar({ create() {}, meta: {} });',
52+
'export default foo.bar.baz({ create() {}, meta: {} });',
53+
'export default foo(123);',
54+
'export default foo.bar(123);',
55+
'export default foo.bar()(123);',
56+
57+
// Correct TypeScript helper structure but missing parameterized types:
58+
'export default createESLintRule({ create() {}, meta: {} });',
59+
'export default util.createRule({ create() {}, meta: {} });',
60+
'export default ESLintUtils.RuleCreator(docsUrl)({ create() {}, meta: {} });',
4261
].forEach(noRuleCase => {
4362
it(`returns null for ${noRuleCase}`, () => {
4463
const ast = espree.parse(noRuleCase, { ecmaVersion: 8, range: true, sourceType: 'module' });
@@ -47,9 +66,80 @@ describe('utils', () => {
4766
});
4867
});
4968

50-
describe('the file has a valid rule', () => {
69+
describe('the file does not have a valid rule (TypeScript + TypeScript parser + ESM)', () => {
70+
[
71+
// Incorrect TypeScript helper structure:
72+
'export default foo()<Options, MessageIds>({ create() {}, meta: {} });',
73+
'export default foo().bar<Options, MessageIds>({ create() {}, meta: {} });',
74+
'export default foo.bar.baz<Options, MessageIds>({ create() {}, meta: {} });',
75+
'export default foo<Options, MessageIds>(123);',
76+
'export default foo.bar<Options, MessageIds>(123);',
77+
'export default foo.bar()<Options, MessageIds>(123);',
78+
79+
// Correct TypeScript helper structure but missing parameterized types:
80+
'export default createESLintRule({ create() {}, meta: {} });',
81+
'export default createESLintRule<>({ create() {}, meta: {} });',
82+
'export default createESLintRule<OnlyOneType>({ create() {}, meta: {} });',
83+
'export default util.createRule({ create() {}, meta: {} });',
84+
'export default ESLintUtils.RuleCreator(docsUrl)({ create() {}, meta: {} });',
85+
].forEach(noRuleCase => {
86+
it(`returns null for ${noRuleCase}`, () => {
87+
const ast = typescriptEslintParser.parse(noRuleCase, { ecmaVersion: 8, range: true, sourceType: 'module' });
88+
assert.isNull(utils.getRuleInfo({ ast }), 'Expected no rule to be found');
89+
});
90+
});
91+
});
92+
93+
describe('the file does not have a valid rule (TypeScript + TypeScript parser + CJS)', () => {
94+
[
95+
// Correct TypeScript helper structure but missing parameterized types (note: we don't support CJS for TypeScript rules):
96+
'module.exports = createESLintRule<Options, MessageIds>({ create() {}, meta: {} });',
97+
'module.exports = util.createRule<Options, MessageIds>({ create() {}, meta: {} });',
98+
'module.exports = ESLintUtils.RuleCreator(docsUrl)<Options, MessageIds>({ create() {}, meta: {} });',
99+
].forEach(noRuleCase => {
100+
it(`returns null for ${noRuleCase}`, () => {
101+
const ast = typescriptEslintParser.parse(noRuleCase, { range: true, sourceType: 'script' });
102+
assert.isNull(utils.getRuleInfo({ ast }), 'Expected no rule to be found');
103+
});
104+
});
105+
});
106+
107+
describe('the file has a valid rule (TypeScript + TypeScript parser + ESM)', () => {
108+
const CASES = {
109+
// Util function only
110+
'export default createESLintRule<Options, MessageIds>({ create() {}, meta: {} });': {
111+
create: { type: 'FunctionExpression' },
112+
meta: { type: 'ObjectExpression' },
113+
isNewStyle: true,
114+
},
115+
// Util function from util object
116+
'export default util.createRule<Options, MessageIds>({ create() {}, meta: {} });': {
117+
create: { type: 'FunctionExpression' },
118+
meta: { type: 'ObjectExpression' },
119+
isNewStyle: true,
120+
},
121+
// Util function from util object with additional doc URL argument
122+
'export default ESLintUtils.RuleCreator(docsUrl)<Options, MessageIds>({ create() {}, meta: {} });': {
123+
create: { type: 'FunctionExpression' },
124+
meta: { type: 'ObjectExpression' },
125+
isNewStyle: true,
126+
},
127+
};
128+
129+
Object.keys(CASES).forEach(ruleSource => {
130+
it(ruleSource, () => {
131+
const ast = typescriptEslintParser.parse(ruleSource, { ecmaVersion: 6, range: true, sourceType: 'module' });
132+
const ruleInfo = utils.getRuleInfo({ ast });
133+
assert(
134+
lodash.isMatch(ruleInfo, CASES[ruleSource]),
135+
`Expected \n${inspect(ruleInfo)}\nto match\n${inspect(CASES[ruleSource])}`
136+
);
137+
});
138+
});
139+
});
140+
141+
describe('the file has a valid rule (CJS)', () => {
51142
const CASES = {
52-
// CJS
53143
'module.exports = { create: function foo() {} };': {
54144
create: { type: 'FunctionExpression', id: { name: 'foo' } }, // (This property will actually contain the AST node.)
55145
meta: null,
@@ -125,7 +215,22 @@ describe('utils', () => {
125215
meta: null,
126216
isNewStyle: false,
127217
},
218+
};
219+
220+
Object.keys(CASES).forEach(ruleSource => {
221+
it(ruleSource, () => {
222+
const ast = espree.parse(ruleSource, { ecmaVersion: 6, range: true, sourceType: 'script' });
223+
const ruleInfo = utils.getRuleInfo({ ast });
224+
assert(
225+
lodash.isMatch(ruleInfo, CASES[ruleSource]),
226+
`Expected \n${inspect(ruleInfo)}\nto match\n${inspect(CASES[ruleSource])}`
227+
);
228+
});
229+
});
230+
});
128231

232+
describe('the file has a valid rule (ESM)', () => {
233+
const CASES = {
129234
// ESM (object style)
130235
'export default { create() {} }': {
131236
create: { type: 'FunctionExpression' },
@@ -153,15 +258,17 @@ describe('utils', () => {
153258

154259
Object.keys(CASES).forEach(ruleSource => {
155260
it(ruleSource, () => {
156-
const ast = espree.parse(ruleSource, { ecmaVersion: 6, range: true, sourceType: ruleSource.startsWith('export default') ? 'module' : 'script' });
261+
const ast = espree.parse(ruleSource, { ecmaVersion: 6, range: true, sourceType: 'module' });
157262
const ruleInfo = utils.getRuleInfo({ ast });
158263
assert(
159264
lodash.isMatch(ruleInfo, CASES[ruleSource]),
160265
`Expected \n${inspect(ruleInfo)}\nto match\n${inspect(CASES[ruleSource])}`
161266
);
162267
});
163268
});
269+
});
164270

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

0 commit comments

Comments
 (0)