diff --git a/README.md b/README.md index ae5ab34e..ee5a8523 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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) -An ESLint plugin for linting ESLint plugins +An ESLint plugin for linting ESLint plugins. Rules written in CJS, ESM, and TypeScript are all supported. ## Installation diff --git a/lib/utils.js b/lib/utils.js index 24f699da..5d757a10 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -86,7 +86,24 @@ function isRuleTesterConstruction (node) { const INTERESTING_RULE_KEYS = new Set(['create', 'meta']); /** - * Helper for `getRuleInfo`. Handles ESM rules. + * Collect properties from an object that have interesting key names into a new object + * @param {Node[]} properties + * @param {Set} interestingKeys + * @returns Object + */ +function collectInterestingProperties (properties, interestingKeys) { + // eslint-disable-next-line unicorn/prefer-object-from-entries + return properties.reduce((parsedProps, prop) => { + const keyValue = module.exports.getKeyName(prop); + if (interestingKeys.has(keyValue)) { + parsedProps[keyValue] = prop.value; + } + return parsedProps; + }, {}); +} + +/** + * Helper for `getRuleInfo`. Handles ESM and TypeScript rules. */ function getRuleExportsESM (ast) { return ast.body @@ -95,16 +112,29 @@ function getRuleExportsESM (ast) { // eslint-disable-next-line unicorn/prefer-object-from-entries .reduce((currentExports, node) => { if (node.type === 'ObjectExpression') { - // eslint-disable-next-line unicorn/prefer-object-from-entries - return node.properties.reduce((parsedProps, prop) => { - const keyValue = module.exports.getKeyName(prop); - if (INTERESTING_RULE_KEYS.has(keyValue)) { - parsedProps[keyValue] = prop.value; - } - return parsedProps; - }, {}); + // Check `export default { create() {}, meta: {} }` + return collectInterestingProperties(node.properties, INTERESTING_RULE_KEYS); } else if (isNormalFunctionExpression(node)) { + // Check `export default function() {}` return { create: node, meta: null, isNewStyle: false }; + } else if ( + node.type === 'CallExpression' && + node.typeParameters && + node.typeParameters.params.length === 2 && // Expecting: + node.arguments.length === 1 && + node.arguments[0].type === 'ObjectExpression' && + // Check various TypeScript rule helper formats. + ( + // createESLintRule({ ... }) + node.callee.type === 'Identifier' || + // util.createRule({ ... }) + (node.callee.type === 'MemberExpression' && node.callee.object.type === 'Identifier' && node.callee.property.type === 'Identifier') || + // ESLintUtils.RuleCreator(docsUrl)({ ... }) + (node.callee.type === 'CallExpression' && node.callee.callee.type === 'MemberExpression' && node.callee.callee.object.type === 'Identifier' && node.callee.callee.property.type === 'Identifier') + ) + ) { + // Check `export default someTypeScriptHelper({ create() {}, meta: {} }); + return collectInterestingProperties(node.arguments[0].properties, INTERESTING_RULE_KEYS); } return currentExports; }, {}); @@ -136,14 +166,7 @@ function getRuleExportsCJS (ast) { } else if (node.right.type === 'ObjectExpression') { // Check `module.exports = { create: function () {}, meta: {} }` - // eslint-disable-next-line unicorn/prefer-object-from-entries - return node.right.properties.reduce((parsedProps, prop) => { - const keyValue = module.exports.getKeyName(prop); - if (INTERESTING_RULE_KEYS.has(keyValue)) { - parsedProps[keyValue] = prop.value; - } - return parsedProps; - }, {}); + return collectInterestingProperties(node.right.properties, INTERESTING_RULE_KEYS); } return {}; } else if ( diff --git a/package.json b/package.json index 62415efe..7a831577 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "statements": 99 }, "devDependencies": { + "@typescript-eslint/parser": "^4.32.0", "chai": "^4.1.0", "dirty-chai": "^2.0.1", "eslint": "^7.9.0", @@ -55,7 +56,8 @@ "mocha": "^9.1.2", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", - "release-it": "^14.9.0" + "release-it": "^14.9.0", + "typescript": "^4.4.3" }, "peerDependencies": { "eslint": ">=6.0.0" diff --git a/tests/lib/rules/require-meta-docs-description.js b/tests/lib/rules/require-meta-docs-description.js index 6c9aceba..815692b1 100644 --- a/tests/lib/rules/require-meta-docs-description.js +++ b/tests/lib/rules/require-meta-docs-description.js @@ -241,3 +241,30 @@ ruleTester.run('require-meta-docs-description', rule, { }, ], }); + +const ruleTesterTypeScript = new RuleTester({ + parserOptions: { sourceType: 'module' }, + parser: require.resolve('@typescript-eslint/parser'), +}); +ruleTesterTypeScript.run('require-meta-docs-description (TypeScript)', rule, { + valid: [ + ` + export default createESLintRule({ + meta: { docs: { description: 'disallow unused variables' } }, + create(context) {} + }); + `, + ], + invalid: [ + { + code: ` + export default createESLintRule({ + meta: {}, + create(context) {} + }); + `, + output: null, + errors: [{ messageId: 'missing', type: 'ObjectExpression' }], + }, + ], +}); diff --git a/tests/lib/utils.js b/tests/lib/utils.js index 9579ddef..396fd9c9 100644 --- a/tests/lib/utils.js +++ b/tests/lib/utils.js @@ -7,10 +7,11 @@ const eslintScope = require('eslint-scope'); const estraverse = require('estraverse'); const assert = require('chai').assert; const utils = require('../../lib/utils'); +const typescriptEslintParser = require('@typescript-eslint/parser'); describe('utils', () => { describe('getRuleInfo', () => { - describe('the file does not have a valid rule', () => { + describe('the file does not have a valid rule (CJS)', () => { [ '', 'module.exports;', @@ -25,6 +26,11 @@ describe('utils', () => { 'module.exports = { create: foo }', 'module.exports = { create: function* foo() {} }', 'module.exports = { create: async function foo() {} }', + + // Correct TypeScript helper structure but missing parameterized types (note: we don't support CJS for TypeScript rules): + 'module.exports = createESLintRule({ create() {}, meta: {} });', + 'module.exports = util.createRule({ create() {}, meta: {} });', + 'module.exports = ESLintUtils.RuleCreator(docsUrl)({ create() {}, meta: {} });', ].forEach(noRuleCase => { it(`returns null for ${noRuleCase}`, () => { const ast = espree.parse(noRuleCase, { ecmaVersion: 8, range: true }); @@ -39,6 +45,19 @@ describe('utils', () => { 'export const foo = { create() {} }', 'export default { foo: {} }', 'const foo = {}; export default foo', + + // Incorrect TypeScript helper structure: + 'export default foo()({ create() {}, meta: {} });', + 'export default foo().bar({ create() {}, meta: {} });', + 'export default foo.bar.baz({ create() {}, meta: {} });', + 'export default foo(123);', + 'export default foo.bar(123);', + 'export default foo.bar()(123);', + + // Correct TypeScript helper structure but missing parameterized types: + 'export default createESLintRule({ create() {}, meta: {} });', + 'export default util.createRule({ create() {}, meta: {} });', + 'export default ESLintUtils.RuleCreator(docsUrl)({ create() {}, meta: {} });', ].forEach(noRuleCase => { it(`returns null for ${noRuleCase}`, () => { const ast = espree.parse(noRuleCase, { ecmaVersion: 8, range: true, sourceType: 'module' }); @@ -47,9 +66,80 @@ describe('utils', () => { }); }); - describe('the file has a valid rule', () => { + describe('the file does not have a valid rule (TypeScript + TypeScript parser + ESM)', () => { + [ + // Incorrect TypeScript helper structure: + 'export default foo()({ create() {}, meta: {} });', + 'export default foo().bar({ create() {}, meta: {} });', + 'export default foo.bar.baz({ create() {}, meta: {} });', + 'export default foo(123);', + 'export default foo.bar(123);', + 'export default foo.bar()(123);', + + // Correct TypeScript helper structure but missing parameterized types: + 'export default createESLintRule({ create() {}, meta: {} });', + 'export default createESLintRule<>({ create() {}, meta: {} });', + 'export default createESLintRule({ create() {}, meta: {} });', + 'export default util.createRule({ create() {}, meta: {} });', + 'export default ESLintUtils.RuleCreator(docsUrl)({ create() {}, meta: {} });', + ].forEach(noRuleCase => { + it(`returns null for ${noRuleCase}`, () => { + const ast = typescriptEslintParser.parse(noRuleCase, { ecmaVersion: 8, range: true, sourceType: 'module' }); + assert.isNull(utils.getRuleInfo({ ast }), 'Expected no rule to be found'); + }); + }); + }); + + describe('the file does not have a valid rule (TypeScript + TypeScript parser + CJS)', () => { + [ + // Correct TypeScript helper structure but missing parameterized types (note: we don't support CJS for TypeScript rules): + 'module.exports = createESLintRule({ create() {}, meta: {} });', + 'module.exports = util.createRule({ create() {}, meta: {} });', + 'module.exports = ESLintUtils.RuleCreator(docsUrl)({ create() {}, meta: {} });', + ].forEach(noRuleCase => { + it(`returns null for ${noRuleCase}`, () => { + const ast = typescriptEslintParser.parse(noRuleCase, { range: true, sourceType: 'script' }); + assert.isNull(utils.getRuleInfo({ ast }), 'Expected no rule to be found'); + }); + }); + }); + + describe('the file has a valid rule (TypeScript + TypeScript parser + ESM)', () => { + const CASES = { + // Util function only + 'export default createESLintRule({ create() {}, meta: {} });': { + create: { type: 'FunctionExpression' }, + meta: { type: 'ObjectExpression' }, + isNewStyle: true, + }, + // Util function from util object + 'export default util.createRule({ create() {}, meta: {} });': { + create: { type: 'FunctionExpression' }, + meta: { type: 'ObjectExpression' }, + isNewStyle: true, + }, + // Util function from util object with additional doc URL argument + 'export default ESLintUtils.RuleCreator(docsUrl)({ create() {}, meta: {} });': { + create: { type: 'FunctionExpression' }, + meta: { type: 'ObjectExpression' }, + isNewStyle: true, + }, + }; + + Object.keys(CASES).forEach(ruleSource => { + it(ruleSource, () => { + const ast = typescriptEslintParser.parse(ruleSource, { ecmaVersion: 6, range: true, sourceType: 'module' }); + const ruleInfo = utils.getRuleInfo({ ast }); + assert( + lodash.isMatch(ruleInfo, CASES[ruleSource]), + `Expected \n${inspect(ruleInfo)}\nto match\n${inspect(CASES[ruleSource])}` + ); + }); + }); + }); + + describe('the file has a valid rule (CJS)', () => { const CASES = { - // CJS 'module.exports = { create: function foo() {} };': { create: { type: 'FunctionExpression', id: { name: 'foo' } }, // (This property will actually contain the AST node.) meta: null, @@ -125,7 +215,22 @@ describe('utils', () => { meta: null, isNewStyle: false, }, + }; + + Object.keys(CASES).forEach(ruleSource => { + it(ruleSource, () => { + const ast = espree.parse(ruleSource, { ecmaVersion: 6, range: true, sourceType: 'script' }); + const ruleInfo = utils.getRuleInfo({ ast }); + assert( + lodash.isMatch(ruleInfo, CASES[ruleSource]), + `Expected \n${inspect(ruleInfo)}\nto match\n${inspect(CASES[ruleSource])}` + ); + }); + }); + }); + describe('the file has a valid rule (ESM)', () => { + const CASES = { // ESM (object style) 'export default { create() {} }': { create: { type: 'FunctionExpression' }, @@ -153,7 +258,7 @@ describe('utils', () => { Object.keys(CASES).forEach(ruleSource => { it(ruleSource, () => { - const ast = espree.parse(ruleSource, { ecmaVersion: 6, range: true, sourceType: ruleSource.startsWith('export default') ? 'module' : 'script' }); + const ast = espree.parse(ruleSource, { ecmaVersion: 6, range: true, sourceType: 'module' }); const ruleInfo = utils.getRuleInfo({ ast }); assert( lodash.isMatch(ruleInfo, CASES[ruleSource]), @@ -161,7 +266,9 @@ describe('utils', () => { ); }); }); + }); + describe('the file has a valid rule (different scope options)', () => { for (const scopeOptions of [ { ignoreEval: true, ecmaVersion: 6, sourceType: 'script', nodejsScope: true }, { ignoreEval: true, ecmaVersion: 6, sourceType: 'script' },