diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c68199d4..0636ad67 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -45,3 +45,13 @@ jobs: node-version: 'lts/*' - run: npm install - run: npm run test:remote + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + - run: npm install + - run: npm run typecheck diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b9c4391b..cc25a226 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -38,6 +38,7 @@ jobs: if: ${{ steps.release.outputs.release_created }} - run: | npm install --force + npm run build npm publish --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 281b1c34..302cc701 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,11 @@ -.idea -coverage +.idea/ +coverage/ .vscode node_modules/ npm-debug.log yarn.lock .eslintcache +dist/ # eslint-remote-tester eslint-remote-tester-results diff --git a/eslint.config.ts b/eslint.config.ts index 36cdf25c..ad8f1362 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -5,7 +5,6 @@ import { FlatCompat } from '@eslint/eslintrc'; import { defineConfig } from 'eslint/config'; import markdown from 'eslint-plugin-markdown'; import pluginN from 'eslint-plugin-n'; -// @ts-expect-error - eslint-plugin is not typed yet import eslintPlugin from './lib/index.js'; const dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -17,7 +16,7 @@ const compat = new FlatCompat({ export default defineConfig([ // Global ignores { - ignores: ['node_modules', 'coverage'], + ignores: ['node_modules', 'coverage', 'dist'], }, // Global settings { diff --git a/lib/index.js b/lib/index.ts similarity index 70% rename from lib/index.js rename to lib/index.ts index f512a196..1fbcb0af 100644 --- a/lib/index.js +++ b/lib/index.ts @@ -2,12 +2,10 @@ * @fileoverview An ESLint plugin for linting ESLint plugins * @author Teddy Katz */ +import { createRequire } from 'node:module'; -// ------------------------------------------------------------------------------ -// Requirements -// ------------------------------------------------------------------------------ +import type { ESLint, Linter, Rule } from 'eslint'; -import packageMetadata from '../package.json' with { type: 'json' }; import consistentOutput from './rules/consistent-output.js'; import fixerReturn from './rules/fixer-return.js'; import metaPropertyOrdering from './rules/meta-property-ordering.js'; @@ -41,20 +39,56 @@ import requireMetaType from './rules/require-meta-type.js'; import testCasePropertyOrdering from './rules/test-case-property-ordering.js'; import testCaseShorthandStrings from './rules/test-case-shorthand-strings.js'; +const require = createRequire(import.meta.url); + +const packageMetadata = require("../package.json") as { + name: string; + version: string; +}; + const PLUGIN_NAME = packageMetadata.name.replace(/^eslint-plugin-/, ''); +const CONFIG_NAMES = [ + 'all', + 'all-type-checked', + 'recommended', + 'rules', + 'tests', + 'rules-recommended', + 'tests-recommended', +] as const; +type ConfigName = (typeof CONFIG_NAMES)[number]; -const configFilters = { - all: (rule) => !rule.meta.docs.requiresTypeChecking, +const configFilters: Record boolean> = { + all: (rule: Rule.RuleModule) => + !( + rule.meta?.docs && + 'requiresTypeChecking' in rule.meta.docs && + 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', - 'rules-recommended': (rule) => + recommended: (rule: Rule.RuleModule) => !!rule.meta?.docs?.recommended, + rules: (rule: Rule.RuleModule) => rule.meta?.docs?.category === 'Rules', + tests: (rule: Rule.RuleModule) => rule.meta?.docs?.category === 'Tests', + 'rules-recommended': (rule: Rule.RuleModule) => configFilters.recommended(rule) && configFilters.rules(rule), - 'tests-recommended': (rule) => + 'tests-recommended': (rule: Rule.RuleModule) => configFilters.recommended(rule) && configFilters.tests(rule), }; +const createConfig = (configName: ConfigName): Linter.Config => ({ + name: `${PLUGIN_NAME}/${configName}`, + plugins: { + get [PLUGIN_NAME](): ESLint.Plugin { + return plugin; + }, + }, + rules: Object.fromEntries( + (Object.keys(allRules) as (keyof typeof allRules)[]) + .filter((ruleName) => configFilters[configName](allRules[ruleName])) + .map((ruleName) => [`${PLUGIN_NAME}/${ruleName}`, 'error']), + ), +}); + // ------------------------------------------------------------------------------ // Plugin Definition // ------------------------------------------------------------------------------ @@ -93,34 +127,23 @@ const allRules = { 'require-meta-type': requireMetaType, 'test-case-property-ordering': testCasePropertyOrdering, 'test-case-shorthand-strings': testCaseShorthandStrings, -}; +} satisfies Record; -/** @type {import("eslint").ESLint.Plugin} */ const plugin = { meta: { name: packageMetadata.name, version: packageMetadata.version, }, rules: allRules, - configs: {}, // assigned later -}; - -// configs -Object.assign( - plugin.configs, - Object.keys(configFilters).reduce((configs, configName) => { - return Object.assign(configs, { - [configName]: { - name: `${PLUGIN_NAME}/${configName}`, - plugins: { [PLUGIN_NAME]: plugin }, - rules: Object.fromEntries( - Object.keys(allRules) - .filter((ruleName) => configFilters[configName](allRules[ruleName])) - .map((ruleName) => [`${PLUGIN_NAME}/${ruleName}`, 'error']), - ), - }, - }); - }, {}), -); + configs: { + all: createConfig('all'), + 'all-type-checked': createConfig('all-type-checked'), + recommended: createConfig('recommended'), + rules: createConfig('rules'), + tests: createConfig('tests'), + 'rules-recommended': createConfig('rules-recommended'), + 'tests-recommended': createConfig('tests-recommended'), + }, +} satisfies ESLint.Plugin; export default plugin; diff --git a/lib/rules/consistent-output.js b/lib/rules/consistent-output.ts similarity index 78% rename from lib/rules/consistent-output.js rename to lib/rules/consistent-output.ts index 3c12e0ee..6495fec3 100644 --- a/lib/rules/consistent-output.js +++ b/lib/rules/consistent-output.ts @@ -2,15 +2,17 @@ * @fileoverview Enforce consistent use of `output` assertions in rule tests * @author Teddy Katz */ +import type { Rule } from 'eslint'; import { getKeyName, getTestInfo } from '../utils.js'; +const keyNameMapper = (property: Parameters[0]) => + getKeyName(property); + // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -20,7 +22,7 @@ const rule = { recommended: false, url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/consistent-output.md', }, - fixable: null, // or "code" or "whitespace" + fixable: undefined, // or "code" or "whitespace" schema: [ { type: 'string', @@ -37,20 +39,18 @@ const rule = { }, create(context) { - // ---------------------------------------------------------------------- - // Public - // ---------------------------------------------------------------------- - const always = context.options[0] && context.options[0] === 'always'; + const always: boolean = + context.options[0] && context.options[0] === 'always'; return { Program(ast) { getTestInfo(context, ast).forEach((testRun) => { const readableCases = testRun.invalid.filter( - (testCase) => testCase.type === 'ObjectExpression', + (testCase) => testCase?.type === 'ObjectExpression', ); const casesWithoutOutput = readableCases.filter( (testCase) => - !testCase.properties.map(getKeyName).includes('output'), + !testCase.properties.map(keyNameMapper).includes('output'), ); if ( diff --git a/lib/rules/fixer-return.js b/lib/rules/fixer-return.ts similarity index 75% rename from lib/rules/fixer-return.js rename to lib/rules/fixer-return.ts index 3fb92911..97aedf04 100644 --- a/lib/rules/fixer-return.js +++ b/lib/rules/fixer-return.ts @@ -2,21 +2,37 @@ * @fileoverview require fixer functions to return a fix * @author 薛定谔的猫 */ - import { getStaticValue } from '@eslint-community/eslint-utils'; +import type { Rule } from 'eslint'; +import type { + ArrowFunctionExpression, + FunctionExpression, + Identifier, + Node, + Position, + SourceLocation, +} from 'estree'; import { getContextIdentifiers, isAutoFixerFunction, isSuggestionFixerFunction, } from '../utils.js'; +import type { FunctionInfo } from '../types.js'; + +const DEFAULT_FUNC_INFO: FunctionInfo = { + upper: null, + codePath: null, + hasReturnWithFixer: false, + hasYieldWithFixer: false, + shouldCheck: false, + node: null, +}; // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { @@ -25,7 +41,7 @@ const rule = { recommended: true, url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/fixer-return.md', }, - fixable: null, + fixable: undefined, schema: [], messages: { missingFix: 'Fixer function never returned a fix.', @@ -33,28 +49,24 @@ const rule = { }, create(context) { - let funcInfo = { - upper: null, - codePath: null, - hasReturnWithFixer: false, - hasYieldWithFixer: false, - shouldCheck: false, - node: null, - }; - let contextIdentifiers; + let funcInfo: FunctionInfo = DEFAULT_FUNC_INFO; + let contextIdentifiers = new Set(); /** * As we exit the fix() function, ensure we have returned or yielded a real fix by this point. * If not, report the function as a violation. * - * @param {ASTNode} node - A node to check. - * @param {Location} loc - Optional location to report violation on. - * @returns {void} + * @param node - A node to check. + * @param loc - Optional location to report violation on. */ function ensureFunctionReturnedFix( - node, - loc = (node.id || node).loc.start, - ) { + node: ArrowFunctionExpression | FunctionExpression, + loc: Position | SourceLocation | undefined = (node.type === + 'FunctionExpression' && node.id + ? node.id + : node + ).loc?.start, + ): void { if ( (node.generator && !funcInfo.hasYieldWithFixer) || // Generator function never yielded a fix (!node.generator && !funcInfo.hasReturnWithFixer) // Non-generator function never returned a fix @@ -70,10 +82,9 @@ const rule = { /** * Check if a returned/yielded node is likely to be a fix or not. * A fix is an object created by fixer.replaceText() for example and returned by the fix function. - * @param {ASTNode} node - node to check - * @returns {boolean} + * @param node - node to check */ - function isFix(node) { + function isFix(node: Node): boolean { if (node.type === 'ArrayExpression' && node.elements.length === 0) { // An empty array is not a fix. return false; @@ -104,22 +115,22 @@ const rule = { }, // Stacks this function's information. - onCodePathStart(codePath, node) { + onCodePathStart(codePath: Rule.CodePath, node: Node) { funcInfo = { upper: funcInfo, codePath, hasYieldWithFixer: false, hasReturnWithFixer: false, shouldCheck: - isAutoFixerFunction(node, contextIdentifiers) || - isSuggestionFixerFunction(node, contextIdentifiers), + isAutoFixerFunction(node, contextIdentifiers, context) || + isSuggestionFixerFunction(node, contextIdentifiers, context), node, }; }, // Pops this function's information. onCodePathEnd() { - funcInfo = funcInfo.upper; + funcInfo = funcInfo.upper ?? DEFAULT_FUNC_INFO; }, // Yield in generators @@ -147,7 +158,7 @@ const rule = { 'ArrowFunctionExpression:exit'(node) { if (funcInfo.shouldCheck) { const sourceCode = context.sourceCode; - const loc = sourceCode.getTokenBefore(node.body).loc; // Show violation on arrow (=>). + const loc = sourceCode.getTokenBefore(node.body)?.loc; // Show violation on arrow (=>). if (node.expression) { // When the return is implied (no curly braces around the body), we have to check the single body node directly. if (!isFix(node.body)) { diff --git a/lib/rules/meta-property-ordering.js b/lib/rules/meta-property-ordering.ts similarity index 75% rename from lib/rules/meta-property-ordering.js rename to lib/rules/meta-property-ordering.ts index e66d9320..88125d70 100644 --- a/lib/rules/meta-property-ordering.js +++ b/lib/rules/meta-property-ordering.ts @@ -1,6 +1,7 @@ /** * @fileoverview Enforces the order of meta properties */ +import type { Rule } from 'eslint'; import { getKeyName, getRuleInfo } from '../utils.js'; @@ -16,12 +17,13 @@ const defaultOrder = [ 'messages', ]; +const keyNameMapper = (property: Parameters[0]) => + getKeyName(property); + // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -52,24 +54,29 @@ const rule = { return {}; } - const order = context.options[0] || defaultOrder; + const order: string[] = context.options[0] || defaultOrder; - const orderMap = new Map(order.map((name, i) => [name, i])); + const orderMap = new Map( + order.map((name, i) => [name, i]), + ); return { Program() { - if (!ruleInfo.meta || ruleInfo.meta.properties.length < 2) { + if ( + !ruleInfo.meta || + ruleInfo.meta.type !== 'ObjectExpression' || + ruleInfo.meta.properties.length < 2 + ) { return; } const props = ruleInfo.meta.properties; - let last; + let last = Number.NEGATIVE_INFINITY; const violatingProps = props.filter((prop) => { - const curr = orderMap.has(getKeyName(prop)) - ? orderMap.get(getKeyName(prop)) - : Number.POSITIVE_INFINITY; + const curr = + orderMap.get(getKeyName(prop)) ?? Number.POSITIVE_INFINITY; return last > (last = curr); }); @@ -80,7 +87,8 @@ const rule = { const knownProps = props .filter((prop) => orderMap.has(getKeyName(prop))) .sort( - (a, b) => orderMap.get(getKeyName(a)) - orderMap.get(getKeyName(b)), + (a, b) => + orderMap.get(getKeyName(a))! - orderMap.get(getKeyName(b))!, ); const unknownProps = props.filter( (prop) => !orderMap.has(getKeyName(prop)), @@ -91,7 +99,7 @@ const rule = { node: violatingProp, messageId: 'inconsistentOrder', data: { - order: knownProps.map(getKeyName).join(', '), + order: knownProps.map(keyNameMapper).join(', '), }, fix(fixer) { const expectedProps = [...knownProps, ...unknownProps]; diff --git a/lib/rules/no-deprecated-context-methods.js b/lib/rules/no-deprecated-context-methods.ts similarity index 77% rename from lib/rules/no-deprecated-context-methods.js rename to lib/rules/no-deprecated-context-methods.ts index 490dd096..bd2a126f 100644 --- a/lib/rules/no-deprecated-context-methods.js +++ b/lib/rules/no-deprecated-context-methods.ts @@ -2,6 +2,8 @@ * @fileoverview Disallows usage of deprecated methods on rule context objects * @author Teddy Katz */ +import type { Rule } from 'eslint'; +import type { Identifier, MemberExpression } from 'estree'; import { getContextIdentifiers } from '../utils.js'; @@ -26,14 +28,12 @@ const DEPRECATED_PASSTHROUGHS = { getTokensAfter: 'getTokensAfter', getTokensBefore: 'getTokensBefore', getTokensBetween: 'getTokensBetween', -}; +} satisfies Record; // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -66,30 +66,29 @@ const rule = { contextId.parent.type === 'MemberExpression' && contextId === contextId.parent.object && contextId.parent.property.type === 'Identifier' && - Object.prototype.hasOwnProperty.call( - DEPRECATED_PASSTHROUGHS, - contextId.parent.property.name, - ), + contextId.parent.property.name in DEPRECATED_PASSTHROUGHS, ) - .forEach((contextId) => - context.report({ + .forEach((contextId) => { + const parentPropertyName = ( + (contextId.parent as MemberExpression).property as Identifier + ).name as keyof typeof DEPRECATED_PASSTHROUGHS; + return context.report({ node: contextId.parent, messageId: 'newFormat', data: { contextName: contextId.name, - original: contextId.parent.property.name, - replacement: - DEPRECATED_PASSTHROUGHS[contextId.parent.property.name], + original: parentPropertyName, + replacement: DEPRECATED_PASSTHROUGHS[parentPropertyName], }, fix: (fixer) => [ fixer.insertTextAfter(contextId, '.getSourceCode()'), fixer.replaceText( - contextId.parent.property, - DEPRECATED_PASSTHROUGHS[contextId.parent.property.name], + (contextId.parent as MemberExpression).property, + DEPRECATED_PASSTHROUGHS[parentPropertyName], ), ], - }), - ); + }); + }); }, }; }, diff --git a/lib/rules/no-deprecated-report-api.js b/lib/rules/no-deprecated-report-api.ts similarity index 85% rename from lib/rules/no-deprecated-report-api.js rename to lib/rules/no-deprecated-report-api.ts index cae105ac..91a3b622 100644 --- a/lib/rules/no-deprecated-report-api.js +++ b/lib/rules/no-deprecated-report-api.ts @@ -2,15 +2,15 @@ * @fileoverview Disallow the version of `context.report()` with multiple arguments * @author Teddy Katz */ +import type { Rule } from 'eslint'; +import type { Node } from 'estree'; import { getContextIdentifiers, getReportInfo } from '../utils.js'; // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -29,7 +29,7 @@ const rule = { create(context) { const sourceCode = context.sourceCode; - let contextIdentifiers; + let contextIdentifiers: Set; // ---------------------------------------------------------------------- // Public @@ -56,8 +56,10 @@ const rule = { node: node.callee.property, messageId: 'useNewAPI', fix(fixer) { - const openingParen = sourceCode.getTokenBefore(node.arguments[0]); - const closingParen = sourceCode.getLastToken(node); + const openingParen = sourceCode.getTokenBefore( + node.arguments[0], + )!; + const closingParen = sourceCode.getLastToken(node)!; const reportInfo = getReportInfo(node, context); if (!reportInfo) { @@ -68,7 +70,8 @@ const rule = { [openingParen.range[1], closingParen.range[0]], `{${Object.keys(reportInfo) .map( - (key) => `${key}: ${sourceCode.getText(reportInfo[key])}`, + (key) => + `${key}: ${sourceCode.getText(reportInfo[key as keyof typeof reportInfo])}`, ) .join(', ')}}`, ); diff --git a/lib/rules/no-identical-tests.js b/lib/rules/no-identical-tests.ts similarity index 51% rename from lib/rules/no-identical-tests.js rename to lib/rules/no-identical-tests.ts index 5c192e01..d60b5a48 100644 --- a/lib/rules/no-identical-tests.js +++ b/lib/rules/no-identical-tests.ts @@ -2,15 +2,15 @@ * @fileoverview disallow identical tests * @author 薛定谔的猫 */ +import type { Rule } from 'eslint'; +import type { Expression, SpreadElement } from 'estree'; import { getTestInfo } from '../utils.js'; // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { @@ -27,20 +27,12 @@ const rule = { }, create(context) { - // ---------------------------------------------------------------------- - // Public - // ---------------------------------------------------------------------- const sourceCode = context.sourceCode; - // ---------------------------------------------------------------------- - // Helpers - // ---------------------------------------------------------------------- /** * Create a unique cache key - * @param {object} test - * @returns {string} */ - function toKey(test) { + function toKey(test: Expression | SpreadElement): string { if (test.type !== 'ObjectExpression') { return JSON.stringify([test.type, sourceCode.getText(test)]); } @@ -55,28 +47,30 @@ const rule = { getTestInfo(context, ast).forEach((testRun) => { [testRun.valid, testRun.invalid].forEach((tests) => { const cache = new Set(); - tests.forEach((test) => { - const key = toKey(test); - if (cache.has(key)) { - context.report({ - node: test, - messageId: 'identical', - fix(fixer) { - const start = sourceCode.getTokenBefore(test); - const end = sourceCode.getTokenAfter(test); - return fixer.removeRange( - // should remove test's trailing comma - [ - start.range[1], - end.value === ',' ? end.range[1] : test.range[1], - ], - ); - }, - }); - } else { - cache.add(key); - } - }); + tests + .filter((test) => !!test) + .forEach((test) => { + const key = toKey(test); + if (cache.has(key)) { + context.report({ + node: test, + messageId: 'identical', + fix(fixer) { + const start = sourceCode.getTokenBefore(test)!; + const end = sourceCode.getTokenAfter(test)!; + return fixer.removeRange( + // should remove test's trailing comma + [ + start.range[1], + end.value === ',' ? end.range[1] : test.range![1], + ], + ); + }, + }); + } else { + cache.add(key); + } + }); }); }); }, diff --git a/lib/rules/no-meta-replaced-by.js b/lib/rules/no-meta-replaced-by.ts similarity index 95% rename from lib/rules/no-meta-replaced-by.js rename to lib/rules/no-meta-replaced-by.ts index 8ceb6f41..56d04f6f 100644 --- a/lib/rules/no-meta-replaced-by.js +++ b/lib/rules/no-meta-replaced-by.ts @@ -1,15 +1,14 @@ /** * @fileoverview Disallows the usage of `meta.replacedBy` property */ +import type { Rule } from 'eslint'; import { evaluateObjectProperties, getKeyName, getRuleInfo } from '../utils.js'; // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { diff --git a/lib/rules/no-meta-schema-default.js b/lib/rules/no-meta-schema-default.ts similarity index 74% rename from lib/rules/no-meta-schema-default.js rename to lib/rules/no-meta-schema-default.ts index 89aafc0b..46a29b0b 100644 --- a/lib/rules/no-meta-schema-default.js +++ b/lib/rules/no-meta-schema-default.ts @@ -1,4 +1,6 @@ import { getStaticValue } from '@eslint-community/eslint-utils'; +import type { Rule } from 'eslint'; +import type { Expression, SpreadElement } from 'estree'; import { getMetaSchemaNode, @@ -9,9 +11,7 @@ import { // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -31,7 +31,7 @@ const rule = { const sourceCode = context.sourceCode; const { scopeManager } = sourceCode; const ruleInfo = getRuleInfo(sourceCode); - if (!ruleInfo) { + if (!ruleInfo || !ruleInfo.meta) { return {}; } @@ -43,31 +43,32 @@ const rule = { const schemaProperty = getMetaSchemaNodeProperty(schemaNode, scopeManager); if (schemaProperty?.type === 'ObjectExpression') { - checkSchemaElement(schemaProperty, true); + checkSchemaElement(schemaProperty); } else if (schemaProperty?.type === 'ArrayExpression') { for (const element of schemaProperty.elements) { - checkSchemaElement(element, true); + checkSchemaElement(element); } } return {}; - function checkSchemaElement(node) { - if (node.type !== 'ObjectExpression') { + function checkSchemaElement(node: Expression | SpreadElement | null) { + if (node?.type !== 'ObjectExpression') { return; } - for (const { type, key, value } of node.properties) { - if (type !== 'Property') { + for (const property of node.properties) { + if (property.type !== 'Property') { continue; } + const { key, value } = property; const staticKey = key.type === 'Identifier' ? { value: key.name } : getStaticValue(key); if (!staticKey?.value) { continue; } - switch (key.name ?? key.value) { + switch (staticKey.value) { case 'allOf': case 'anyOf': case 'oneOf': { @@ -81,9 +82,12 @@ const rule = { } case 'properties': { - if (Array.isArray(value.properties)) { + if ('properties' in value && Array.isArray(value.properties)) { for (const property of value.properties) { - if (property.value?.type === 'ObjectExpression') { + if ( + 'value' in property && + property.value.type === 'ObjectExpression' + ) { checkSchemaElement(property.value); } } @@ -93,8 +97,7 @@ const rule = { } case 'elements': { - checkSchemaElement(value); - + checkSchemaElement(value as Expression | SpreadElement); break; } diff --git a/lib/rules/no-missing-message-ids.js b/lib/rules/no-missing-message-ids.ts similarity index 83% rename from lib/rules/no-missing-message-ids.js rename to lib/rules/no-missing-message-ids.ts index 81dfdf0a..3ece7a2a 100644 --- a/lib/rules/no-missing-message-ids.js +++ b/lib/rules/no-missing-message-ids.ts @@ -1,9 +1,12 @@ +import type { Rule } from 'eslint'; +import type { Identifier, Node } from 'estree'; + import { collectReportViolationAndSuggestionData, findPossibleVariableValues, getContextIdentifiers, - getMessagesNode, getMessageIdNodeById, + getMessagesNode, getReportInfo, getRuleInfo, } from '../utils.js'; @@ -11,9 +14,7 @@ import { // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { @@ -23,7 +24,7 @@ const rule = { recommended: true, url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/no-missing-message-ids.md', }, - fixable: null, + fixable: undefined, schema: [], messages: { missingMessage: @@ -41,7 +42,7 @@ const rule = { const messagesNode = getMessagesNode(ruleInfo, scopeManager); - let contextIdentifiers; + let contextIdentifiers: Set; if (!messagesNode || messagesNode.type !== 'ObjectExpression') { // If we can't find `meta.messages`, disable the rule. @@ -54,7 +55,7 @@ const rule = { }, CallExpression(node) { - const scope = sourceCode.getScope(node); + const scope = context.sourceCode.getScope(node); // Check for messageId properties used in known calls to context.report(); if ( node.callee.type === 'MemberExpression' && @@ -69,13 +70,16 @@ const rule = { const reportMessagesAndDataArray = collectReportViolationAndSuggestionData(reportInfo); - for (const { messageId } of reportMessagesAndDataArray.filter( - (obj) => obj.messageId, - )) { + for (const messageId of reportMessagesAndDataArray + .map((obj) => obj.messageId) + .filter((messageId) => !!messageId)) { const values = messageId.type === 'Literal' ? [messageId] - : findPossibleVariableValues(messageId, scopeManager); + : findPossibleVariableValues( + messageId as Identifier, + scopeManager, + ); // Look for any possible string values we found for this messageId. values.forEach((val) => { diff --git a/lib/rules/no-missing-placeholders.js b/lib/rules/no-missing-placeholders.ts similarity index 84% rename from lib/rules/no-missing-placeholders.js rename to lib/rules/no-missing-placeholders.ts index 522ed207..fa39d453 100644 --- a/lib/rules/no-missing-placeholders.js +++ b/lib/rules/no-missing-placeholders.ts @@ -3,23 +3,23 @@ * @author Teddy Katz */ import { getStaticValue } from '@eslint-community/eslint-utils'; +import type { Rule } from 'eslint'; +import type { Node } from 'estree'; import { collectReportViolationAndSuggestionData, + getContextIdentifiers, getKeyName, + getMessageIdNodeById, + getMessagesNode, getReportInfo, getRuleInfo, - getMessagesNode, - getMessageIdNodeById, - getContextIdentifiers, } from '../utils.js'; // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { @@ -28,7 +28,7 @@ const rule = { recommended: true, url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/no-missing-placeholders.md', }, - fixable: null, + fixable: undefined, schema: [], messages: { placeholderDoesNotExist: @@ -40,7 +40,7 @@ const rule = { const sourceCode = context.sourceCode; const { scopeManager } = sourceCode; - let contextIdentifiers; + let contextIdentifiers: Set; const ruleInfo = getRuleInfo(sourceCode); if (!ruleInfo) { @@ -96,9 +96,10 @@ const rule = { messageId, data, } of reportMessagesAndDataArray.filter((obj) => obj.message)) { + if (!message) continue; const messageStaticValue = getStaticValue(message, scope); if ( - ((message.type === 'Literal' && + ((message?.type === 'Literal' && typeof message.value === 'string') || (messageStaticValue && typeof messageStaticValue.value === 'string')) && @@ -107,20 +108,21 @@ const rule = { // Same regex as the one ESLint uses // https://github.com/eslint/eslint/blob/e5446449d93668ccbdb79d78cc69f165ce4fde07/lib/eslint.js#L990 const PLACEHOLDER_MATCHER = /{{\s*([^{}]+?)\s*}}/g; - let match; + let match: RegExpExecArray | null; - while ( - (match = PLACEHOLDER_MATCHER.exec( - message.value || messageStaticValue.value, - )) - ) { + const messageText: string = + // @ts-expect-error + message.value || messageStaticValue.value; + while ((match = PLACEHOLDER_MATCHER.exec(messageText))) { const matchingProperty = data && - data.properties.find((prop) => getKeyName(prop) === match[1]); + data.properties.find( + (prop) => getKeyName(prop) === match![1], + ); if (!matchingProperty) { context.report({ - node: data || messageId || message, + node: (data || messageId || message) as Node, messageId: 'placeholderDoesNotExist', data: { missingKey: match[1] }, }); diff --git a/lib/rules/no-only-tests.js b/lib/rules/no-only-tests.ts similarity index 88% rename from lib/rules/no-only-tests.js rename to lib/rules/no-only-tests.ts index 87246d94..6ed93ce5 100644 --- a/lib/rules/no-only-tests.js +++ b/lib/rules/no-only-tests.ts @@ -3,11 +3,11 @@ import { isOpeningBraceToken, isClosingBraceToken, } from '@eslint-community/eslint-utils'; +import type { Rule } from 'eslint'; import { getTestInfo } from '../utils.js'; -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { @@ -30,7 +30,7 @@ const rule = { Program(ast) { for (const testRun of getTestInfo(context, ast)) { for (const test of [...testRun.valid, ...testRun.invalid]) { - if (test.type === 'ObjectExpression') { + if (test?.type === 'ObjectExpression') { // Test case object: { code: 'const x = 123;', ... } const onlyProperty = test.properties.find( @@ -56,15 +56,15 @@ const rule = { sourceCode.getTokenBefore(onlyProperty); const tokenAfter = sourceCode.getTokenAfter(onlyProperty); - if ( - (isCommaToken(tokenBefore) && + if ((tokenBefore && tokenAfter) && + ((isCommaToken(tokenBefore) && isCommaToken(tokenAfter)) || // In middle of properties (isOpeningBraceToken(tokenBefore) && - isCommaToken(tokenAfter)) // At beginning of properties + isCommaToken(tokenAfter))) // At beginning of properties ) { yield fixer.remove(tokenAfter); // Remove extra comma. } - if ( + if ((tokenBefore && tokenAfter) && isCommaToken(tokenBefore) && isClosingBraceToken(tokenAfter) ) { @@ -79,7 +79,7 @@ const rule = { }); } } else if ( - test.type === 'CallExpression' && + test?.type === 'CallExpression' && test.callee.type === 'MemberExpression' && test.callee.object.type === 'Identifier' && test.callee.object.name === 'RuleTester' && diff --git a/lib/rules/no-property-in-node.js b/lib/rules/no-property-in-node.ts similarity index 85% rename from lib/rules/no-property-in-node.js rename to lib/rules/no-property-in-node.ts index 86f2fa41..7ef564a2 100644 --- a/lib/rules/no-property-in-node.js +++ b/lib/rules/no-property-in-node.ts @@ -1,3 +1,6 @@ +import type { Rule } from 'eslint'; +import type { Type } from 'typescript'; + const defaultTypedNodeSourceFileTesters = [ /@types[/\\]estree[/\\]index\.d\.ts/, /@typescript-eslint[/\\]types[/\\]dist[/\\]generated[/\\]ast-spec\.d\.ts/, @@ -23,11 +26,12 @@ const defaultTypedNodeSourceFileTesters = [ * } * ``` * - * @param {import('typescript').Type} type - * @param {RegExp[]} typedNodeSourceFileTesters * @returns Whether the type seems to include a known ESTree or TSESTree AST node. */ -function isAstNodeType(type, typedNodeSourceFileTesters) { +function isAstNodeType( + type: Type & { types?: Type[] }, + typedNodeSourceFileTesters: RegExp[], +): boolean { return (type.types || [type]) .filter((typePart) => typePart.getProperty('type')) .flatMap( @@ -42,8 +46,7 @@ function isAstNodeType(type, typedNodeSourceFileTesters) { }); } -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -51,6 +54,7 @@ const rule = { 'disallow using `in` to narrow node types instead of looking at properties', category: 'Rules', recommended: false, + // @ts-expect-error -- need to augment the type of `Rule.RuleMetaData` to include `requiresTypeChecking` requiresTypeChecking: true, url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/no-property-in-node.md', }, @@ -75,11 +79,14 @@ const rule = { }, create(context) { + const additionalNodeTypeFiles: string[] = + context.options[0]?.additionalNodeTypeFiles ?? []; + const typedNodeSourceFileTesters = [ ...defaultTypedNodeSourceFileTesters, - ...(context.options[0]?.additionalNodeTypeFiles?.map( - (filePath) => new RegExp(filePath), - ) ?? []), + ...additionalNodeTypeFiles.map( + (filePath: string) => new RegExp(filePath), + ), ]; return { diff --git a/lib/rules/no-unused-message-ids.js b/lib/rules/no-unused-message-ids.ts similarity index 74% rename from lib/rules/no-unused-message-ids.js rename to lib/rules/no-unused-message-ids.ts index 8b434d1c..e260e5ce 100644 --- a/lib/rules/no-unused-message-ids.js +++ b/lib/rules/no-unused-message-ids.ts @@ -1,3 +1,6 @@ +import type { Rule } from 'eslint'; +import type { Identifier, Node } from 'estree'; + import { collectReportViolationAndSuggestionData, findPossibleVariableValues, @@ -13,8 +16,7 @@ import { // Rule Definition // ------------------------------------------------------------------------------ -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { @@ -23,7 +25,7 @@ const rule = { recommended: true, url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/no-unused-message-ids.md', }, - fixable: null, + fixable: undefined, schema: [], messages: { unusedMessage: 'The messageId "{{messageId}}" is never used.', @@ -38,8 +40,8 @@ const rule = { return {}; } - const messageIdsUsed = new Set(); - let contextIdentifiers; + const messageIdsUsed = new Set(); + let contextIdentifiers: Set; let hasSeenUnknownMessageId = false; let hasSeenViolationReport = false; @@ -54,7 +56,7 @@ const rule = { contextIdentifiers = getContextIdentifiers(scopeManager, ast); }, - 'Program:exit'(ast) { + 'Program:exit'() { if (hasSeenUnknownMessageId || !hasSeenViolationReport) { /* Bail out when the rule is likely to have false positives. @@ -64,7 +66,7 @@ const rule = { return; } - const scope = sourceCode.getScope(ast); + const scope = sourceCode.getScope(sourceCode.ast); const messageIdNodesUnused = messageIdNodes.filter( (node) => !messageIdsUsed.has(getKeyName(node, scope)), @@ -76,7 +78,7 @@ const rule = { node: messageIdNode, messageId: 'unusedMessage', data: { - messageId: getKeyName(messageIdNode, scope), + messageId: getKeyName(messageIdNode, scope)!, }, }); } @@ -99,13 +101,15 @@ const rule = { const reportMessagesAndDataArray = collectReportViolationAndSuggestionData(reportInfo); - for (const { messageId } of reportMessagesAndDataArray.filter( - (obj) => obj.messageId, - )) { + for (const messageId of reportMessagesAndDataArray + .map((obj) => obj.messageId) + .filter((messageId) => !!messageId)) { const values = messageId.type === 'Literal' ? [messageId] - : findPossibleVariableValues(messageId, scopeManager); + : messageId.type === 'Identifier' + ? findPossibleVariableValues(messageId, scopeManager) + : []; if ( values.length === 0 || values.some((val) => val.type !== 'Literal') @@ -113,7 +117,11 @@ const rule = { // When a dynamic messageId is used and we can't detect its value, disable the rule to avoid false positives. hasSeenUnknownMessageId = true; } - values.forEach((val) => messageIdsUsed.add(val.value)); + values + .filter((value) => value.type === 'Literal') + .map((value) => value.value) + .filter((value) => typeof value === 'string') + .forEach((value) => messageIdsUsed.add(value)); } } }, @@ -127,18 +135,26 @@ const rule = { const values = node.value.type === 'Literal' ? [node.value] - : findPossibleVariableValues(node.value, scopeManager); + : findPossibleVariableValues( + node.value as Identifier, + scopeManager, + ); if ( values.length === 0 || values.some((val) => val.type !== 'Literal') || - isVariableFromParameter(node.value, scopeManager) + (node.value.type === 'Identifier' && + isVariableFromParameter(node.value, scopeManager)) ) { // When a dynamic messageId is used and we can't detect its value, disable the rule to avoid false positives. hasSeenUnknownMessageId = true; } - values.forEach((val) => messageIdsUsed.add(val.value)); + values + .filter((val) => val.type === 'Literal') + .map((val) => val.value) + .filter((val) => typeof val === 'string') + .forEach((val) => messageIdsUsed.add(val)); } }, }; diff --git a/lib/rules/no-unused-placeholders.js b/lib/rules/no-unused-placeholders.ts similarity index 82% rename from lib/rules/no-unused-placeholders.js rename to lib/rules/no-unused-placeholders.ts index 914eba56..a9dc14cd 100644 --- a/lib/rules/no-unused-placeholders.js +++ b/lib/rules/no-unused-placeholders.ts @@ -2,8 +2,9 @@ * @fileoverview Disallow unused placeholders in rule report messages * @author 薛定谔的猫 */ - import { getStaticValue } from '@eslint-community/eslint-utils'; +import type { Rule } from 'eslint'; +import type { Node } from 'estree'; import { collectReportViolationAndSuggestionData, @@ -18,9 +19,7 @@ import { // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { @@ -29,7 +28,7 @@ const rule = { recommended: true, url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/no-unused-placeholders.md', }, - fixable: null, + fixable: undefined, schema: [], messages: { placeholderUnused: @@ -41,7 +40,7 @@ const rule = { const sourceCode = context.sourceCode; const { scopeManager } = sourceCode; - let contextIdentifiers; + let contextIdentifiers = new Set(); const ruleInfo = getRuleInfo(sourceCode); if (!ruleInfo) { @@ -94,30 +93,32 @@ const rule = { for (const { message, data } of reportMessagesAndDataArray.filter( (obj) => obj.message, )) { - const messageStaticValue = getStaticValue(message, scope); + const messageStaticValue = getStaticValue(message!, scope); if ( - ((message.type === 'Literal' && + ((message?.type === 'Literal' && typeof message.value === 'string') || (messageStaticValue && typeof messageStaticValue.value === 'string')) && data && data.type === 'ObjectExpression' ) { - const messageValue = message.value || messageStaticValue.value; + const messageValue: string = + // @ts-expect-error + message.value || messageStaticValue.value; // https://github.com/eslint/eslint/blob/2874d75ed8decf363006db25aac2d5f8991bd969/lib/linter.js#L986 const PLACEHOLDER_MATCHER = /{{\s*([^{}]+?)\s*}}/g; - const placeholdersInMessage = new Set(); + const placeholdersInMessage = new Set(); - messageValue.replaceAll( - PLACEHOLDER_MATCHER, - (fullMatch, term) => { - placeholdersInMessage.add(term); - }, - ); + const matches = messageValue.matchAll(PLACEHOLDER_MATCHER); + for (const match of matches) { + if (match[1]) { + placeholdersInMessage.add(match[1]); + } + } data.properties.forEach((prop) => { const key = getKeyName(prop); - if (!placeholdersInMessage.has(key)) { + if (key && !placeholdersInMessage.has(key)) { context.report({ node: prop, messageId: 'placeholderUnused', diff --git a/lib/rules/no-useless-token-range.js b/lib/rules/no-useless-token-range.ts similarity index 63% rename from lib/rules/no-useless-token-range.js rename to lib/rules/no-useless-token-range.ts index 0ae826cb..653ef53d 100644 --- a/lib/rules/no-useless-token-range.js +++ b/lib/rules/no-useless-token-range.ts @@ -2,15 +2,22 @@ * @fileoverview Disallow unnecessary calls to `sourceCode.getFirstToken()` and `sourceCode.getLastToken()` * @author Teddy Katz */ +import type { Rule } from 'eslint'; +import type { + CallExpression, + Expression, + MemberExpression, + Node, + Property, + SpreadElement, +} from 'estree'; import { getKeyName, getSourceCodeIdentifiers } from '../utils.js'; // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -37,10 +44,10 @@ const rule = { /** * Determines whether a second argument to getFirstToken or getLastToken changes the output of the function. * This occurs when the second argument exists and is not an object literal, or has keys other than `includeComments`. - * @param {ASTNode} arg The second argument to `sourceCode.getFirstToken()` or `sourceCode.getLastToken()` - * @returns {boolean} `true` if the argument affects the output of getFirstToken or getLastToken + * @param arg The second argument to `sourceCode.getFirstToken()` or `sourceCode.getLastToken()` + * @returns `true` if the argument affects the output of getFirstToken or getLastToken */ - function affectsGetTokenOutput(arg) { + function affectsGetTokenOutput(arg: Expression | SpreadElement): boolean { if (!arg) { return false; } @@ -51,30 +58,29 @@ const rule = { arg.properties.length >= 2 || (arg.properties.length === 1 && (getKeyName(arg.properties[0]) !== 'includeComments' || - arg.properties[0].value.type !== 'Literal')) + (arg.properties[0].type === 'Property' && + arg.properties[0].value.type !== 'Literal'))) ); } /** * Determines whether a node is a MemberExpression that accesses the `range` property - * @param {ASTNode} node The node - * @returns {boolean} `true` if the node is a MemberExpression that accesses the `range` property + * @param node The node + * @returns `true` if the node is a MemberExpression that accesses the `range` property */ - function isRangeAccess(node) { + function isRangeAccess(node: MemberExpression): boolean { return ( - node.type === 'MemberExpression' && - node.property.type === 'Identifier' && - node.property.name === 'range' + node.property.type === 'Identifier' && node.property.name === 'range' ); } /** * Determines whether a MemberExpression accesses the `start` property (either `.range[0]` or `.start`). * Note that this will also work correctly if the `.range` MemberExpression is passed. - * @param {ASTNode} memberExpression The MemberExpression node to check - * @returns {boolean} `true` if this node accesses either `.range[0]` or `.start` + * @param memberExpression The MemberExpression node to check + * @returns `true` if this node accesses either `.range[0]` or `.start` */ - function isStartAccess(memberExpression) { + function isStartAccess(memberExpression: MemberExpression): boolean { if ( isRangeAccess(memberExpression) && memberExpression.parent.type === 'MemberExpression' @@ -87,6 +93,7 @@ const rule = { (memberExpression.computed && memberExpression.property.type === 'Literal' && memberExpression.property.value === 0 && + memberExpression.object.type === 'MemberExpression' && isRangeAccess(memberExpression.object)) ); } @@ -94,10 +101,10 @@ const rule = { /** * Determines whether a MemberExpression accesses the `start` property (either `.range[1]` or `.end`). * Note that this will also work correctly if the `.range` MemberExpression is passed. - * @param {ASTNode} memberExpression The MemberExpression node to check - * @returns {boolean} `true` if this node accesses either `.range[1]` or `.end` + * @param memberExpression The MemberExpression node to check + * @returns `true` if this node accesses either `.range[1]` or `.end` */ - function isEndAccess(memberExpression) { + function isEndAccess(memberExpression: MemberExpression): boolean { if ( isRangeAccess(memberExpression) && memberExpression.parent.type === 'MemberExpression' @@ -110,6 +117,7 @@ const rule = { (memberExpression.computed && memberExpression.property.type === 'Literal' && memberExpression.property.value === 1 && + memberExpression.object.type === 'MemberExpression' && isRangeAccess(memberExpression.object)) ); } @@ -139,32 +147,35 @@ const rule = { identifier.parent.property.name === 'getLastToken')), ) .forEach((identifier) => { - const fullRangeAccess = isRangeAccess( - identifier.parent.parent.parent, - ) - ? identifier.parent.parent.parent.parent - : identifier.parent.parent.parent; - const replacementText = - sourceCode.text.slice( - fullRangeAccess.range[0], - identifier.parent.parent.range[0], - ) + - sourceCode.getText(identifier.parent.parent.arguments[0]) + - sourceCode.text.slice( - identifier.parent.parent.range[1], - fullRangeAccess.range[1], - ); - context.report({ - node: identifier.parent.parent, - messageId: 'useReplacement', - data: { replacementText }, - fix(fixer) { - return fixer.replaceText( - identifier.parent.parent, - sourceCode.getText(identifier.parent.parent.arguments[0]), + const callExpression = identifier.parent.parent; + if (callExpression.type === 'CallExpression') { + const fullRangeAccess = + identifier.parent.parent.parent.type === 'MemberExpression' && + isRangeAccess(identifier.parent.parent.parent) + ? identifier.parent.parent.parent.parent + : identifier.parent.parent.parent; + const replacementText = + sourceCode.text.slice( + fullRangeAccess.range![0], + identifier.parent.parent.range![0], + ) + + sourceCode.getText(callExpression.arguments[0]) + + sourceCode.text.slice( + identifier.parent.parent.range![1], + fullRangeAccess.range![1], ); - }, - }); + context.report({ + node: identifier.parent.parent, + messageId: 'useReplacement', + data: { replacementText }, + fix(fixer) { + return fixer.replaceText( + identifier.parent.parent, + sourceCode.getText(callExpression.arguments[0]), + ); + }, + }); + } }); }, }; diff --git a/lib/rules/prefer-message-ids.js b/lib/rules/prefer-message-ids.ts similarity index 81% rename from lib/rules/prefer-message-ids.js rename to lib/rules/prefer-message-ids.ts index fc5cc51f..1d1ccedb 100644 --- a/lib/rules/prefer-message-ids.js +++ b/lib/rules/prefer-message-ids.ts @@ -1,4 +1,6 @@ import { getStaticValue } from '@eslint-community/eslint-utils'; +import type { Rule } from 'eslint'; +import type { Node } from 'estree'; import { collectReportViolationAndSuggestionData, @@ -11,9 +13,7 @@ import { // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { @@ -23,7 +23,7 @@ const rule = { recommended: true, url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/prefer-message-ids.md', }, - fixable: null, + fixable: undefined, schema: [], messages: { messagesMissing: @@ -40,7 +40,7 @@ const rule = { return {}; } - let contextIdentifiers; + let contextIdentifiers: Set; // ---------------------------------------------------------------------- // Public @@ -57,10 +57,11 @@ const rule = { const metaNode = ruleInfo.meta; const messagesNode = metaNode && + metaNode.type === 'ObjectExpression' && metaNode.properties && - metaNode.properties.find( - (p) => p.type === 'Property' && getKeyName(p) === 'messages', - ); + metaNode.properties + .filter((p) => p.type === 'Property') + .find((p) => getKeyName(p) === 'messages'); if (!messagesNode) { context.report({ @@ -76,6 +77,7 @@ const rule = { } if ( + staticValue.value && typeof staticValue.value === 'object' && staticValue.value.constructor === Object && Object.keys(staticValue.value).length === 0 @@ -98,11 +100,12 @@ const rule = { return; } - const reportMessagesAndDataArray = - collectReportViolationAndSuggestionData(reportInfo).filter( - (obj) => obj.message, - ); - for (const { message } of reportMessagesAndDataArray) { + const reportMessages = collectReportViolationAndSuggestionData( + reportInfo, + ) + .map((obj) => obj.message) + .filter((message) => !!message); + for (const message of reportMessages) { context.report({ node: message.parent, messageId: 'foundMessage', diff --git a/lib/rules/prefer-object-rule.js b/lib/rules/prefer-object-rule.ts similarity index 89% rename from lib/rules/prefer-object-rule.js rename to lib/rules/prefer-object-rule.ts index ec0a0470..d07d7cf2 100644 --- a/lib/rules/prefer-object-rule.js +++ b/lib/rules/prefer-object-rule.ts @@ -1,15 +1,14 @@ /** * @author Brad Zacher */ +import type { Rule } from 'eslint'; import { getRuleInfo } from '../utils.js'; // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -26,10 +25,6 @@ const rule = { }, create(context) { - // ---------------------------------------------------------------------- - // Public - // ---------------------------------------------------------------------- - const sourceCode = context.sourceCode; const ruleInfo = getRuleInfo(sourceCode); if (!ruleInfo) { @@ -48,7 +43,6 @@ const rule = { *fix(fixer) { // note - we intentionally don't worry about formatting here, as otherwise we have // to indent the function correctly - if ( ruleInfo.create.type === 'FunctionExpression' || ruleInfo.create.type === 'FunctionDeclaration' @@ -59,7 +53,7 @@ const rule = { ); /* istanbul ignore if */ - if (!openParenToken) { + if (!openParenToken || !ruleInfo.create.range) { // this shouldn't happen, but guarding against crashes just in case return null; } diff --git a/lib/rules/prefer-output-null.js b/lib/rules/prefer-output-null.js deleted file mode 100644 index 7fa78a66..00000000 --- a/lib/rules/prefer-output-null.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @fileoverview disallows invalid RuleTester test cases where the `output` matches the `code` - * @author 薛定谔的猫 - */ - -import { getTestInfo } from '../utils.js'; - -// ------------------------------------------------------------------------------ -// Rule Definition -// ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'disallow invalid RuleTester test cases where the `output` matches the `code`', - category: 'Tests', - recommended: true, - url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/prefer-output-null.md', - }, - fixable: 'code', - schema: [], - messages: { - useOutputNull: - 'Use `output: null` to assert that a test case is not autofixed.', - }, - }, - - create(context) { - // ---------------------------------------------------------------------- - // Public - // ---------------------------------------------------------------------- - - const sourceCode = context.sourceCode; - - return { - Program(ast) { - getTestInfo(context, ast).forEach((testRun) => { - testRun.invalid.forEach((test) => { - /** - * Get a test case's giving keyname node. - * @param {string} the keyname to find. - * @returns {Node} found node; if not found, return null; - */ - function getTestInfoProperty(key) { - if (test.type === 'ObjectExpression') { - return test.properties.find( - (item) => item.type === 'Property' && item.key.name === key, - ); - } - return null; - } - - const code = getTestInfoProperty('code'); - const output = getTestInfoProperty('output'); - - if ( - output && - sourceCode.getText(output.value) === - sourceCode.getText(code.value) - ) { - context.report({ - node: output, - messageId: 'useOutputNull', - fix: (fixer) => fixer.replaceText(output.value, 'null'), - }); - } - }); - }); - }, - }; - }, -}; - -export default rule; diff --git a/lib/rules/prefer-output-null.ts b/lib/rules/prefer-output-null.ts new file mode 100644 index 00000000..1b2cc8f6 --- /dev/null +++ b/lib/rules/prefer-output-null.ts @@ -0,0 +1,83 @@ +/** + * @fileoverview disallows invalid RuleTester test cases where the `output` matches the `code` + * @author 薛定谔的猫 + */ + +import type { Rule } from 'eslint'; +import type { Property } from 'estree'; + +import { getTestInfo } from '../utils.js'; + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ +const rule: Rule.RuleModule = { + meta: { + type: 'suggestion', + docs: { + description: + 'disallow invalid RuleTester test cases where the `output` matches the `code`', + category: 'Tests', + recommended: true, + url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/prefer-output-null.md', + }, + fixable: 'code', + schema: [], + messages: { + useOutputNull: + 'Use `output: null` to assert that a test case is not autofixed.', + }, + }, + + create(context) { + const sourceCode = context.sourceCode; + + return { + Program(ast) { + getTestInfo(context, ast).forEach((testRun) => { + testRun.invalid + .filter((test) => !!test) + .forEach((test) => { + /** + * Get a test case's given key name node. + * @param the keyname to find. + * @returns found node; if not found, return null; + */ + function getTestInfoProperty(key: string): Property | null { + if (test.type === 'ObjectExpression') { + return ( + test.properties + .filter((item) => item.type === 'Property') + .find( + (item) => + item.key.type === 'Identifier' && + item.key.name === key, + ) ?? null + ); + } + return null; + } + + const code = getTestInfoProperty('code'); + const output = getTestInfoProperty('output'); + + if ( + output && + code && + sourceCode.getText(output.value) === + sourceCode.getText(code.value) + ) { + context.report({ + node: output, + messageId: 'useOutputNull', + fix: (fixer) => fixer.replaceText(output.value, 'null'), + }); + } + }); + }); + }, + }; + }, +}; + +export default rule; diff --git a/lib/rules/prefer-placeholders.js b/lib/rules/prefer-placeholders.ts similarity index 83% rename from lib/rules/prefer-placeholders.js rename to lib/rules/prefer-placeholders.ts index 18868883..d8a9d217 100644 --- a/lib/rules/prefer-placeholders.js +++ b/lib/rules/prefer-placeholders.ts @@ -4,6 +4,8 @@ */ import { findVariable } from '@eslint-community/eslint-utils'; +import type { Rule } from 'eslint'; +import type { Node } from 'estree'; import { collectReportViolationAndSuggestionData, @@ -14,9 +16,7 @@ import { // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -25,7 +25,7 @@ const rule = { recommended: false, url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/prefer-placeholders.md', }, - fixable: null, + fixable: undefined, schema: [], messages: { usePlaceholders: @@ -34,15 +34,11 @@ const rule = { }, create(context) { - let contextIdentifiers; + let contextIdentifiers = new Set(); const sourceCode = context.sourceCode; const { scopeManager } = sourceCode; - // ---------------------------------------------------------------------- - // Public - // ---------------------------------------------------------------------- - return { Program(ast) { contextIdentifiers = getContextIdentifiers(scopeManager, ast); @@ -60,16 +56,17 @@ const rule = { return; } - const reportMessagesAndDataArray = - collectReportViolationAndSuggestionData(reportInfo).filter( - (obj) => obj.message, - ); - for (let { message: messageNode } of reportMessagesAndDataArray) { + const reportMessages = collectReportViolationAndSuggestionData( + reportInfo, + ).map((obj) => obj.message); + for (let messageNode of reportMessages.filter( + (message) => !!message, + )) { if (messageNode.type === 'Identifier') { // See if we can find the variable declaration. const variable = findVariable( - scopeManager.acquire(messageNode) || scopeManager.globalScope, + scopeManager.acquire(messageNode) || scopeManager.globalScope!, messageNode, ); diff --git a/lib/rules/prefer-replace-text.js b/lib/rules/prefer-replace-text.ts similarity index 77% rename from lib/rules/prefer-replace-text.js rename to lib/rules/prefer-replace-text.ts index 3d5db946..1a96d65e 100644 --- a/lib/rules/prefer-replace-text.js +++ b/lib/rules/prefer-replace-text.ts @@ -2,19 +2,27 @@ * @fileoverview prefer using `replaceText()` instead of `replaceTextRange()` * @author 薛定谔的猫 */ +import type { Rule } from 'eslint'; +import type { Identifier, Node } from 'estree'; +import type { FunctionInfo } from '../types.js'; import { getContextIdentifiers, isAutoFixerFunction, isSuggestionFixerFunction, } from '../utils.js'; +const DEFAULT_FUNC_INFO: FunctionInfo = { + upper: null, + codePath: null, + shouldCheck: false, + node: null, +}; + // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -24,7 +32,7 @@ const rule = { recommended: false, url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/prefer-replace-text.md', }, - fixable: null, + fixable: undefined, schema: [], messages: { useReplaceText: 'Use replaceText instead of replaceTextRange.', @@ -33,13 +41,8 @@ const rule = { create(context) { const sourceCode = context.sourceCode; - let funcInfo = { - upper: null, - codePath: null, - shouldCheck: false, - node: null, - }; - let contextIdentifiers; + let funcInfo = DEFAULT_FUNC_INFO; + let contextIdentifiers: Set; return { Program(ast) { @@ -50,20 +53,20 @@ const rule = { }, // Stacks this function's information. - onCodePathStart(codePath, node) { + onCodePathStart(codePath: Rule.CodePath, node: Node) { funcInfo = { upper: funcInfo, codePath, shouldCheck: - isAutoFixerFunction(node, contextIdentifiers) || - isSuggestionFixerFunction(node, contextIdentifiers), + isAutoFixerFunction(node, contextIdentifiers, context) || + isSuggestionFixerFunction(node, contextIdentifiers, context), node, }; }, // Pops this function's information. onCodePathEnd() { - funcInfo = funcInfo.upper; + funcInfo = funcInfo.upper ?? DEFAULT_FUNC_INFO; }, // Checks the replaceTextRange arguments. diff --git a/lib/rules/report-message-format.js b/lib/rules/report-message-format.ts similarity index 78% rename from lib/rules/report-message-format.js rename to lib/rules/report-message-format.ts index 7d6790b5..4ba7506d 100644 --- a/lib/rules/report-message-format.js +++ b/lib/rules/report-message-format.ts @@ -2,8 +2,9 @@ * @fileoverview enforce a consistent format for rule report messages * @author Teddy Katz */ - import { getStaticValue } from '@eslint-community/eslint-utils'; +import type { Rule, Scope } from 'eslint'; +import type { Expression, Node, Pattern, SpreadElement } from 'estree'; import { getContextIdentifiers, @@ -15,9 +16,7 @@ import { // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -26,7 +25,7 @@ const rule = { recommended: false, url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/report-message-format.md', }, - fixable: null, + fixable: undefined, schema: [ { description: 'Format that all report messages must match.', @@ -41,14 +40,16 @@ const rule = { create(context) { const pattern = new RegExp(context.options[0] || ''); - let contextIdentifiers; + let contextIdentifiers: Set; /** * Report a message node if it doesn't match the given formatting - * @param {ASTNode} message The message AST node - * @returns {void} + * @param message The message AST node */ - function processMessageNode(message, scope) { + function processMessageNode( + message: Expression | Pattern | SpreadElement, + scope: Scope.Scope, + ): void { const staticValue = getStaticValue(message, scope); if ( (message.type === 'Literal' && @@ -56,8 +57,10 @@ const rule = { !pattern.test(message.value)) || (message.type === 'TemplateLiteral' && message.quasis.length === 1 && - !pattern.test(message.quasis[0].value.cooked)) || - (staticValue && !pattern.test(staticValue.value)) + !pattern.test(message.quasis[0].value.cooked ?? '')) || + (staticValue && + typeof staticValue.value === 'string' && + !pattern.test(staticValue.value)) ) { context.report({ node: message, @@ -73,10 +76,6 @@ const rule = { return {}; } - // ---------------------------------------------------------------------- - // Public - // ---------------------------------------------------------------------- - return { Program(ast) { const scope = sourceCode.getScope(ast); @@ -89,10 +88,9 @@ const rule = { ruleInfo && ruleInfo.meta && ruleInfo.meta.type === 'ObjectExpression' && - ruleInfo.meta.properties.find( - (prop) => - prop.type === 'Property' && getKeyName(prop) === 'messages', - ); + ruleInfo.meta.properties + .filter((prop) => prop.type === 'Property') + .find((prop) => getKeyName(prop) === 'messages'); if ( !messagesObject || @@ -125,13 +123,12 @@ const rule = { if (suggest && suggest.type === 'ArrayExpression') { suggest.elements .flatMap((obj) => - obj.type === 'ObjectExpression' ? obj.properties : [], + !!obj && obj.type === 'ObjectExpression' ? obj.properties : [], ) + .filter((prop) => prop.type === 'Property') .filter( (prop) => - prop.type === 'Property' && - prop.key.type === 'Identifier' && - prop.key.name === 'message', + prop.key.type === 'Identifier' && prop.key.name === 'message', ) .map((prop) => prop.value) .forEach((it) => processMessageNode(it, scope)); diff --git a/lib/rules/require-meta-default-options.js b/lib/rules/require-meta-default-options.ts similarity index 75% rename from lib/rules/require-meta-default-options.js rename to lib/rules/require-meta-default-options.ts index 24c7e2cc..c8640f55 100644 --- a/lib/rules/require-meta-default-options.js +++ b/lib/rules/require-meta-default-options.ts @@ -1,3 +1,5 @@ +import type { Rule } from 'eslint'; + import { evaluateObjectProperties, getKeyName, @@ -6,8 +8,7 @@ import { getRuleInfo, } from '../utils.js'; -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -45,10 +46,9 @@ const rule = { return {}; } - const metaDefaultOptions = evaluateObjectProperties( - metaNode, - scopeManager, - ).find((p) => p.type === 'Property' && getKeyName(p) === 'defaultOptions'); + const metaDefaultOptions = evaluateObjectProperties(metaNode, scopeManager) + .filter((p) => p.type === 'Property') + .find((p) => getKeyName(p) === 'defaultOptions'); if ( schemaProperty.type === 'ArrayExpression' && @@ -67,13 +67,17 @@ const rule = { } if (!metaDefaultOptions) { - context.report({ - node: metaNode, - messageId: 'missingDefaultOptions', - fix(fixer) { - return fixer.insertTextAfter(schemaProperty, ', defaultOptions: []'); - }, - }); + metaNode && + context.report({ + node: metaNode, + messageId: 'missingDefaultOptions', + fix(fixer) { + return fixer.insertTextAfter( + schemaProperty, + ', defaultOptions: []', + ); + }, + }); return {}; } @@ -87,8 +91,11 @@ const rule = { const isArrayRootSchema = schemaProperty.type === 'ObjectExpression' && - schemaProperty.properties.find((property) => property.key.name === 'type') - ?.value.value === 'array'; + schemaProperty.properties + .filter((property) => property.type === 'Property') + // @ts-expect-error + .find((property) => property.key.name === 'type')?.value.value === + 'array'; if (metaDefaultOptions.value.elements.length === 0 && !isArrayRootSchema) { context.report({ diff --git a/lib/rules/require-meta-docs-description.js b/lib/rules/require-meta-docs-description.ts similarity index 90% rename from lib/rules/require-meta-docs-description.js rename to lib/rules/require-meta-docs-description.ts index 4f1dc6ea..dd0af4eb 100644 --- a/lib/rules/require-meta-docs-description.js +++ b/lib/rules/require-meta-docs-description.ts @@ -1,15 +1,11 @@ import { getStaticValue } from '@eslint-community/eslint-utils'; +import type { Rule } from 'eslint'; import { getMetaDocsProperty, getRuleInfo } from '../utils.js'; -// ------------------------------------------------------------------------------ -// Rule Definition -// ------------------------------------------------------------------------------ - const DEFAULT_PATTERN = new RegExp('^(enforce|require|disallow)'); -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -19,7 +15,7 @@ const rule = { recommended: false, url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/require-meta-docs-description.md', }, - fixable: null, + fixable: undefined, schema: [ { type: 'object', @@ -94,7 +90,7 @@ const rule = { context.report({ node: descriptionNode.value, messageId: 'mismatch', - data: { pattern }, + data: { pattern: String(pattern) }, }); } }, diff --git a/lib/rules/require-meta-docs-recommended.js b/lib/rules/require-meta-docs-recommended.ts similarity index 79% rename from lib/rules/require-meta-docs-recommended.js rename to lib/rules/require-meta-docs-recommended.ts index 3038ea3c..ae3f7d8e 100644 --- a/lib/rules/require-meta-docs-recommended.js +++ b/lib/rules/require-meta-docs-recommended.ts @@ -1,4 +1,6 @@ import { getStaticValue } from '@eslint-community/eslint-utils'; +import type { Rule } from 'eslint'; +import type { ObjectExpression } from 'estree'; import { getMetaDocsProperty, @@ -6,26 +8,31 @@ import { isUndefinedIdentifier, } from '../utils.js'; -/** - * @param {import('eslint').Rule.RuleFixer} fixer - * @param {import('estree').ObjectExpression} objectNode - * @param {boolean} recommendedValue - */ -function insertRecommendedProperty(fixer, objectNode, recommendedValue) { +function insertRecommendedProperty( + fixer: Rule.RuleFixer, + objectNode: ObjectExpression, + recommendedValue: boolean, +) { if (objectNode.properties.length === 0) { return fixer.replaceText( objectNode, `{ recommended: ${recommendedValue} }`, ); } + const lastProperty = objectNode.properties.at(-1); + if (!lastProperty) { + return fixer.replaceText( + objectNode, + `{ recommended: ${recommendedValue} }`, + ); + } return fixer.insertTextAfter( - objectNode.properties.at(-1), + lastProperty, `, recommended: ${recommendedValue}`, ); } -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -35,7 +42,7 @@ const rule = { recommended: false, url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/require-meta-docs-recommended.md', }, - fixable: null, + fixable: undefined, hasSuggestions: true, schema: [ { @@ -74,18 +81,19 @@ const rule = { } = getMetaDocsProperty('recommended', ruleInfo, scopeManager); if (!descriptionNode) { - const suggestions = - docsNode?.value?.type === 'ObjectExpression' + const docNodeValue = docsNode?.value; + const suggestions: Rule.SuggestionReportDescriptor[] = + docNodeValue?.type === 'ObjectExpression' ? [ { messageId: 'setRecommendedTrue', fix: (fixer) => - insertRecommendedProperty(fixer, docsNode.value, true), + insertRecommendedProperty(fixer, docNodeValue, true), }, { messageId: 'setRecommendedFalse', fix: (fixer) => - insertRecommendedProperty(fixer, docsNode.value, false), + insertRecommendedProperty(fixer, docNodeValue, false), }, ] : []; diff --git a/lib/rules/require-meta-docs-url.js b/lib/rules/require-meta-docs-url.ts similarity index 91% rename from lib/rules/require-meta-docs-url.js rename to lib/rules/require-meta-docs-url.ts index 41e3a350..ae09e8f1 100644 --- a/lib/rules/require-meta-docs-url.js +++ b/lib/rules/require-meta-docs-url.ts @@ -1,9 +1,10 @@ /** * @author Toru Nagashima */ - import path from 'node:path'; + import { getStaticValue } from '@eslint-community/eslint-utils'; +import type { Rule } from 'eslint'; import { getMetaDocsProperty, @@ -16,8 +17,7 @@ import { // Rule Definition // ----------------------------------------------------------------------------- -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -50,8 +50,8 @@ const rule = { /** * Creates AST event handlers for require-meta-docs-url. - * @param {RuleContext} context - The rule context. - * @returns {Object} AST event handlers. + * @param context - The rule context. + * @returns AST event handlers. */ create(context) { const options = context.options[0] || {}; @@ -67,10 +67,10 @@ const rule = { /** * Check whether a given URL is the expected URL. - * @param {string} url The URL to check. - * @returns {boolean} `true` if the node is the expected URL. + * @param url The URL to check. + * @returns `true` if the node is the expected URL. */ - function isExpectedUrl(url) { + function isExpectedUrl(url: string | undefined | null): boolean { return Boolean( typeof url === 'string' && (expectedUrl === undefined || url === expectedUrl), @@ -102,7 +102,11 @@ const rule = { return; } - if (isExpectedUrl(staticValue && staticValue.value)) { + if ( + staticValue && + typeof staticValue.value === 'string' && + isExpectedUrl(staticValue.value) + ) { return; } diff --git a/lib/rules/require-meta-fixable.js b/lib/rules/require-meta-fixable.ts similarity index 86% rename from lib/rules/require-meta-fixable.js rename to lib/rules/require-meta-fixable.ts index 67607aca..d3d23bf7 100644 --- a/lib/rules/require-meta-fixable.js +++ b/lib/rules/require-meta-fixable.ts @@ -2,8 +2,9 @@ * @fileoverview require rules to implement a `meta.fixable` property * @author Teddy Katz */ - import { getStaticValue } from '@eslint-community/eslint-utils'; +import type { Rule } from 'eslint'; +import type { Node } from 'estree'; import { evaluateObjectProperties, @@ -15,9 +16,7 @@ import { // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { @@ -57,8 +56,8 @@ const rule = { const sourceCode = context.sourceCode; const { scopeManager } = sourceCode; const ruleInfo = getRuleInfo(sourceCode); - let contextIdentifiers; - let usesFixFunctions; + let contextIdentifiers: Set; + let usesFixFunctions = false; if (!ruleInfo) { return {}; @@ -87,9 +86,9 @@ const rule = { const scope = sourceCode.getScope(ast); const metaFixableProp = ruleInfo && - evaluateObjectProperties(ruleInfo.meta, scopeManager).find( - (prop) => getKeyName(prop) === 'fixable', - ); + evaluateObjectProperties(ruleInfo.meta, scopeManager) + .filter((prop) => prop.type === 'Property') + .find((prop) => getKeyName(prop) === 'fixable'); if (metaFixableProp) { const staticValue = getStaticValue(metaFixableProp.value, scope); @@ -99,7 +98,9 @@ const rule = { } if ( - !['code', 'whitespace', null, undefined].includes(staticValue.value) + staticValue.value && + (typeof staticValue.value !== 'string' || + !['code', 'whitespace'].includes(staticValue.value)) ) { // `fixable` property has an invalid value. context.report({ @@ -111,7 +112,8 @@ const rule = { if ( usesFixFunctions && - !['code', 'whitespace'].includes(staticValue.value) + (typeof staticValue.value !== 'string' || + !['code', 'whitespace'].includes(staticValue.value)) ) { // Rule is fixable but `fixable` property does not have a fixable value. context.report({ @@ -121,6 +123,7 @@ const rule = { } else if ( catchNoFixerButFixableProperty && !usesFixFunctions && + typeof staticValue.value === 'string' && ['code', 'whitespace'].includes(staticValue.value) ) { // Rule is NOT fixable but `fixable` property has a fixable value. diff --git a/lib/rules/require-meta-has-suggestions.js b/lib/rules/require-meta-has-suggestions.ts similarity index 88% rename from lib/rules/require-meta-has-suggestions.js rename to lib/rules/require-meta-has-suggestions.ts index 18b483ec..c031bfcd 100644 --- a/lib/rules/require-meta-has-suggestions.js +++ b/lib/rules/require-meta-has-suggestions.ts @@ -1,4 +1,6 @@ import { getStaticValue } from '@eslint-community/eslint-utils'; +import type { Rule } from 'eslint'; +import type { Node, Property } from 'estree'; import { evaluateObjectProperties, @@ -11,9 +13,7 @@ import { // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { @@ -40,15 +40,15 @@ const rule = { if (!ruleInfo) { return {}; } - let contextIdentifiers; - let ruleReportsSuggestions; + let contextIdentifiers: Set; + let ruleReportsSuggestions = false; /** * Check if a "suggest" object property from a rule violation report should be considered to contain suggestions. - * @param {Node} node - the "suggest" object property to check - * @returns {boolean} whether this property should be considered to contain suggestions + * @param node - the "suggest" object property to check + * @returns whether this property should be considered to contain suggestions */ - function doesPropertyContainSuggestions(node) { + function doesPropertyContainSuggestions(node: Property): boolean { const scope = sourceCode.getScope(node); const staticValue = getStaticValue(node.value, scope); if ( @@ -84,7 +84,9 @@ const rule = { const suggestProp = evaluateObjectProperties( node.arguments[0], scopeManager, - ).find((prop) => getKeyName(prop) === 'suggest'); + ) + .filter((prop) => prop.type === 'Property') + .find((prop) => getKeyName(prop) === 'suggest'); if (suggestProp && doesPropertyContainSuggestions(suggestProp)) { ruleReportsSuggestions = true; } @@ -107,7 +109,9 @@ const rule = { const hasSuggestionsProperty = evaluateObjectProperties( metaNode, scopeManager, - ).find((prop) => getKeyName(prop) === 'hasSuggestions'); + ) + .filter((prop) => prop.type === 'Property') + .find((prop) => getKeyName(prop) === 'hasSuggestions'); const hasSuggestionsStaticValue = hasSuggestionsProperty && getStaticValue(hasSuggestionsProperty.value, scope); @@ -133,6 +137,7 @@ const rule = { 'hasSuggestions: true, ', ); } + return null; }, }); } else if ( @@ -153,6 +158,7 @@ const rule = { 'true', ); } + return null; }, }); } diff --git a/lib/rules/require-meta-schema-description.js b/lib/rules/require-meta-schema-description.ts similarity index 79% rename from lib/rules/require-meta-schema-description.js rename to lib/rules/require-meta-schema-description.ts index b15669ec..dba60771 100644 --- a/lib/rules/require-meta-schema-description.js +++ b/lib/rules/require-meta-schema-description.ts @@ -1,4 +1,6 @@ import { getStaticValue } from '@eslint-community/eslint-utils'; +import type { Rule } from 'eslint'; +import type { Expression, SpreadElement } from 'estree'; import { getMetaSchemaNode, @@ -9,9 +11,7 @@ import { // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -51,15 +51,20 @@ const rule = { return {}; - function checkSchemaElement(node, isRoot) { - if (node.type !== 'ObjectExpression') { + function checkSchemaElement( + node: Expression | SpreadElement | null, + isRoot = false, + ): void { + if (!node || node.type !== 'ObjectExpression') { return; } let hadChildren = false; let hadDescription = false; - for (const { key, value } of node.properties) { + for (const { key, value } of node.properties.filter( + (prop) => prop.type === 'Property', + )) { if (!key) { continue; } @@ -69,6 +74,7 @@ const rule = { continue; } + // @ts-expect-error switch (key.name ?? key.value) { case 'description': { hadDescription = true; @@ -90,9 +96,12 @@ const rule = { case 'properties': { hadChildren = true; - if (Array.isArray(value.properties)) { + if ('properties' in value && Array.isArray(value.properties)) { for (const property of value.properties) { - if (property.value?.type === 'ObjectExpression') { + if ( + 'value' in property && + property.value?.type === 'ObjectExpression' + ) { checkSchemaElement(property.value); } } diff --git a/lib/rules/require-meta-schema.js b/lib/rules/require-meta-schema.ts similarity index 96% rename from lib/rules/require-meta-schema.js rename to lib/rules/require-meta-schema.ts index da65ad9a..909fdd58 100644 --- a/lib/rules/require-meta-schema.js +++ b/lib/rules/require-meta-schema.ts @@ -1,3 +1,6 @@ +import type { Rule } from 'eslint'; +import type { Node } from 'estree'; + import { getContextIdentifiers, getMetaSchemaNode, @@ -10,9 +13,7 @@ import { // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -55,7 +56,7 @@ const rule = { return {}; } - let contextIdentifiers; + let contextIdentifiers: Set; const metaNode = ruleInfo.meta; // Options diff --git a/lib/rules/require-meta-type.js b/lib/rules/require-meta-type.ts similarity index 83% rename from lib/rules/require-meta-type.js rename to lib/rules/require-meta-type.ts index 31ad1aba..7001936b 100644 --- a/lib/rules/require-meta-type.js +++ b/lib/rules/require-meta-type.ts @@ -2,8 +2,8 @@ * @fileoverview require rules to implement a `meta.type` property * @author 薛定谔的猫 */ - import { getStaticValue } from '@eslint-community/eslint-utils'; +import type { Rule } from 'eslint'; import { evaluateObjectProperties, getKeyName, getRuleInfo } from '../utils.js'; @@ -12,9 +12,7 @@ const VALID_TYPES = new Set(['problem', 'suggestion', 'layout']); // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { @@ -23,7 +21,7 @@ const rule = { recommended: true, url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/require-meta-type.md', }, - fixable: null, + fixable: undefined, schema: [], messages: { missing: @@ -34,10 +32,6 @@ const rule = { }, create(context) { - // ---------------------------------------------------------------------- - // Public - // ---------------------------------------------------------------------- - const sourceCode = context.sourceCode; const ruleInfo = getRuleInfo(sourceCode); if (!ruleInfo) { @@ -50,9 +44,9 @@ const rule = { const { scopeManager } = sourceCode; const metaNode = ruleInfo.meta; - const typeNode = evaluateObjectProperties(metaNode, scopeManager).find( - (p) => p.type === 'Property' && getKeyName(p) === 'type', - ); + const typeNode = evaluateObjectProperties(metaNode, scopeManager) + .filter((p) => p.type === 'Property') + .find((p) => getKeyName(p) === 'type'); if (!typeNode) { context.report({ @@ -68,7 +62,10 @@ const rule = { return; } - if (!VALID_TYPES.has(staticValue.value)) { + if ( + typeof staticValue.value !== 'string' || + !VALID_TYPES.has(staticValue.value) + ) { context.report({ node: typeNode.value, messageId: 'unexpected' }); } }, diff --git a/lib/rules/test-case-property-ordering.js b/lib/rules/test-case-property-ordering.ts similarity index 85% rename from lib/rules/test-case-property-ordering.js rename to lib/rules/test-case-property-ordering.ts index 73728e9a..50eee786 100644 --- a/lib/rules/test-case-property-ordering.js +++ b/lib/rules/test-case-property-ordering.ts @@ -2,6 +2,7 @@ * @fileoverview Requires the properties of a test case to be placed in a consistent order. * @author 薛定谔的猫 */ +import type { Rule } from 'eslint'; import { getKeyName, getTestInfo } from '../utils.js'; @@ -18,12 +19,13 @@ const defaultOrder = [ 'errors', ]; +const keyNameMapper = (property: Parameters[0]) => + getKeyName(property); + // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -52,7 +54,7 @@ const rule = { // ---------------------------------------------------------------------- // Public // ---------------------------------------------------------------------- - const order = context.options[0] || defaultOrder; + const order: string[] = context.options[0] || defaultOrder; const sourceCode = context.sourceCode; return { @@ -60,10 +62,14 @@ const rule = { getTestInfo(context, ast).forEach((testRun) => { [testRun.valid, testRun.invalid].forEach((tests) => { tests.forEach((test) => { - const properties = (test && test.properties) || []; - const keyNames = properties.map(getKeyName); + const properties = + (test && test.type === 'ObjectExpression' && test.properties) || + []; + const keyNames = properties + .map(keyNameMapper) + .filter((keyName) => keyName !== null); - for (let i = 0, lastChecked; i < keyNames.length; i++) { + for (let i = 0, lastChecked = 0; i < keyNames.length; i++) { const current = order.indexOf(keyNames[i]); // current < lastChecked to catch unordered; diff --git a/lib/rules/test-case-shorthand-strings.js b/lib/rules/test-case-shorthand-strings.ts similarity index 61% rename from lib/rules/test-case-shorthand-strings.js rename to lib/rules/test-case-shorthand-strings.ts index 2d67dd8e..64352316 100644 --- a/lib/rules/test-case-shorthand-strings.js +++ b/lib/rules/test-case-shorthand-strings.ts @@ -2,15 +2,15 @@ * @fileoverview Enforce consistent usage of shorthand strings for test cases with no options * @author Teddy Katz */ +import type { Rule } from 'eslint'; import { getKeyName, getTestInfo } from '../utils.js'; +import type { TestInfo } from '../types.js'; // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -45,11 +45,11 @@ const rule = { /** * Reports test cases as necessary - * @param {object[]} cases A list of test case nodes - * @returns {void} + * @param cases A list of test case nodes */ - function reportTestCases(cases) { + function reportTestCases(cases: TestInfo['valid']): void { const caseInfoList = cases + .filter((testCase) => !!testCase) .map((testCase) => { if ( testCase.type === 'Literal' || @@ -69,7 +69,7 @@ const rule = { } return null; }) - .filter(Boolean); + .filter((testCase) => !!testCase); const isConsistent = new Set(caseInfoList.map((caseInfo) => caseInfo.shorthand)).size <= 1; @@ -77,37 +77,47 @@ const rule = { (caseInfo) => caseInfo.needsLongform, ); - caseInfoList - .filter( - { - 'as-needed': (caseInfo) => - !caseInfo.shorthand && !caseInfo.needsLongform, - never: (caseInfo) => caseInfo.shorthand, - consistent: isConsistent - ? () => false - : (caseInfo) => caseInfo.shorthand, - 'consistent-as-needed': (caseInfo) => - caseInfo.shorthand === hasCaseNeedingLongform, - }[shorthandOption], - ) - .forEach((badCaseInfo) => { - context.report({ - node: badCaseInfo.node, - messageId: 'useShorthand', - data: { - preferred: badCaseInfo.shorthand ? 'an object' : 'a string', - actual: badCaseInfo.shorthand ? 'a string' : 'an object', - }, - fix(fixer) { - return fixer.replaceText( - badCaseInfo.node, - badCaseInfo.shorthand - ? `{code: ${sourceCode.getText(badCaseInfo.node)}}` - : sourceCode.getText(badCaseInfo.node.properties[0].value), - ); - }, - }); + let caseInfoFilter: (caseInfo: (typeof caseInfoList)[number]) => boolean; + switch (shorthandOption) { + case 'as-needed': + caseInfoFilter = (caseInfo) => + !caseInfo.shorthand && !caseInfo.needsLongform; + break; + case 'never': + caseInfoFilter = (caseInfo) => caseInfo.shorthand; + break; + case 'consistent': + caseInfoFilter = isConsistent + ? () => false + : (caseInfo) => caseInfo.shorthand; + break; + case 'consistent-as-needed': + caseInfoFilter = (caseInfo) => + caseInfo.shorthand === hasCaseNeedingLongform; + break; + default: + return; // invalid option + } + + caseInfoList.filter(caseInfoFilter).forEach((badCaseInfo) => { + context.report({ + node: badCaseInfo.node, + messageId: 'useShorthand', + data: { + preferred: badCaseInfo.shorthand ? 'an object' : 'a string', + actual: badCaseInfo.shorthand ? 'a string' : 'an object', + }, + fix(fixer) { + return fixer.replaceText( + badCaseInfo.node, + badCaseInfo.shorthand + ? `{code: ${sourceCode.getText(badCaseInfo.node)}}` + : // @ts-expect-error + sourceCode.getText(badCaseInfo.node.properties[0].value), + ); + }, }); + }); } // ---------------------------------------------------------------------- diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 00000000..72c9899a --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,83 @@ +import type { Rule } from 'eslint'; +import type { + ArrayPattern, + ArrowFunctionExpression, + AssignmentPattern, + Expression, + FunctionDeclaration, + FunctionExpression, + MaybeNamedClassDeclaration, + MaybeNamedFunctionDeclaration, + Node, + ObjectPattern, + Pattern, + Property, + RestElement, + SpreadElement, +} from 'estree'; + +export interface FunctionInfo { + codePath: Rule.CodePath | null; + hasReturnWithFixer?: boolean; + hasYieldWithFixer?: boolean; + node: Node | null; + shouldCheck: boolean; + upper: FunctionInfo | null; +} + +export interface PartialRuleInfo { + create?: + | Node + | MaybeNamedFunctionDeclaration + | MaybeNamedClassDeclaration + | null; + isNewStyle?: boolean; + meta?: Expression | Pattern | FunctionDeclaration; +} + +export interface RuleInfo extends PartialRuleInfo { + create: FunctionExpression | ArrowFunctionExpression | FunctionDeclaration; + isNewStyle: boolean; +} + +export type TestInfo = { + invalid: (Expression | SpreadElement | null)[]; + valid: (Expression | SpreadElement | null)[]; +}; + +export type ViolationAndSuppressionData = { + messageId?: + | Expression + | SpreadElement + | ObjectPattern + | ArrayPattern + | RestElement + | AssignmentPattern; + message?: + | Expression + | SpreadElement + | ObjectPattern + | ArrayPattern + | RestElement + | AssignmentPattern; + data?: + | Expression + | SpreadElement + | ObjectPattern + | ArrayPattern + | RestElement + | AssignmentPattern; + fix?: + | Expression + | SpreadElement + | ObjectPattern + | ArrayPattern + | RestElement + | AssignmentPattern; +}; + +export type MetaDocsProperty = { + docsNode: Property | undefined; + metaNode: Node | undefined; + metaPropertyNode: Property | undefined; +}; diff --git a/lib/utils.js b/lib/utils.ts similarity index 58% rename from lib/utils.js rename to lib/utils.ts index 23e1e4b8..75355270 100644 --- a/lib/utils.js +++ b/lib/utils.ts @@ -1,27 +1,71 @@ import { getStaticValue, findVariable } from '@eslint-community/eslint-utils'; +import type { Rule, Scope, SourceCode } from 'eslint'; import estraverse from 'estraverse'; +import type { + ArrowFunctionExpression, + AssignmentProperty, + CallExpression, + Directive, + Expression, + FunctionDeclaration, + FunctionExpression, + Identifier, + MaybeNamedClassDeclaration, + MaybeNamedFunctionDeclaration, + MemberExpression, + ModuleDeclaration, + Node, + ObjectExpression, + Pattern, + Program, + Property, + SpreadElement, + Statement, + Super, + TSExportAssignment, + VariableDeclarator, +} from 'estree'; + +import type { + MetaDocsProperty, + PartialRuleInfo, + RuleInfo, + TestInfo, + ViolationAndSuppressionData, +} from './types.js'; const functionTypes = new Set([ 'FunctionExpression', 'ArrowFunctionExpression', 'FunctionDeclaration', ]); +const isFunctionType = ( + node: + | MaybeNamedClassDeclaration + | MaybeNamedFunctionDeclaration + | Node + | null + | undefined, +): node is FunctionExpression | ArrowFunctionExpression | FunctionDeclaration => + !!node && functionTypes.has(node.type); /** * Determines whether a node is a 'normal' (i.e. non-async, non-generator) function expression. - * @param {ASTNode} node The node in question - * @returns {boolean} `true` if the node is a normal function expression + * @param node The node in question + * @returns `true` if the node is a normal function expression */ -function isNormalFunctionExpression(node) { - return functionTypes.has(node.type) && !node.generator && !node.async; +function isNormalFunctionExpression( + node: FunctionExpression | ArrowFunctionExpression | FunctionDeclaration, +): boolean { + return !node.generator && !node.async; } /** * Determines whether a node is constructing a RuleTester instance * @param {ASTNode} node The node in question - * @returns {boolean} `true` if the node is probably constructing a RuleTester instance + * @returns `true` if the node is probably constructing a RuleTester instance */ -function isRuleTesterConstruction(node) { +function isRuleTesterConstruction(node: Expression | Super): boolean { return ( node.type === 'NewExpression' && ((node.callee.type === 'Identifier' && node.callee.name === 'RuleTester') || @@ -31,34 +75,46 @@ function isRuleTesterConstruction(node) { ); } -const INTERESTING_RULE_KEYS = new Set(['create', 'meta']); +const interestingRuleKeys = ['create', 'meta'] as const; +type InterestingRuleKey = (typeof interestingRuleKeys)[number]; +const INTERESTING_RULE_KEYS = new Set(interestingRuleKeys); + +const isInterestingRuleKey = (key: string): key is InterestingRuleKey => + INTERESTING_RULE_KEYS.has(key as InterestingRuleKey); /** * Collect properties from an object that have interesting key names into a new object - * @param {Node[]} properties - * @param {Set} interestingKeys - * @returns Object + * @param properties + * @param interestingKeys */ -function collectInterestingProperties(properties, interestingKeys) { - return properties.reduce((parsedProps, prop) => { - const keyValue = getKeyName(prop); - if (interestingKeys.has(keyValue)) { - // In TypeScript, unwrap any usage of `{} as const`. - parsedProps[keyValue] = - prop.value.type === 'TSAsExpression' - ? prop.value.expression - : prop.value; - } - return parsedProps; - }, {}); +function collectInterestingProperties( + properties: (Property | SpreadElement)[], + interestingKeys: Set, +): Record { + return properties.reduce>( + (parsedProps, prop) => { + const keyValue = getKeyName(prop); + if ( + prop.type === 'Property' && + keyValue && + interestingKeys.has(keyValue as T) + ) { + // In TypeScript, unwrap any usage of `{} as const`. + parsedProps[keyValue] = + prop.value.type === 'TSAsExpression' + ? prop.value.expression + : prop.value; + } + return parsedProps; + }, + {}, + ); } /** * Check if there is a return statement that returns an object somewhere inside the given node. - * @param {Node} node - * @returns {boolean} */ -function hasObjectReturn(node) { +function hasObjectReturn(node: Node): boolean { let foundMatch = false; estraverse.traverse(node, { enter(child) { @@ -77,11 +133,13 @@ function hasObjectReturn(node) { /** * Determine if the given node is likely to be a function-style rule. - * @param {*} node - * @returns {boolean} + * @param node */ -function isFunctionRule(node) { +function isFunctionRule( + node: Node | MaybeNamedFunctionDeclaration | MaybeNamedClassDeclaration, +): boolean { return ( + isFunctionType(node) && // Is a function expression or declaration. isNormalFunctionExpression(node) && // Is a function definition. node.params.length === 1 && // The function has a single `context` argument. hasObjectReturn(node) // Returns an object containing the visitor functions. @@ -90,10 +148,10 @@ function isFunctionRule(node) { /** * Check if the given node is a function call representing a known TypeScript rule creator format. - * @param {Node} node - * @returns {boolean} */ -function isTypeScriptRuleHelper(node) { +function isTypeScriptRuleHelper( + node: Node | MaybeNamedFunctionDeclaration | MaybeNamedClassDeclaration, +): node is CallExpression & { arguments: ObjectExpression[] } { return ( node.type === 'CallExpression' && node.arguments.length === 1 && @@ -116,8 +174,18 @@ function isTypeScriptRuleHelper(node) { /** * Helper for `getRuleInfo`. Handles ESM and TypeScript rules. */ -function getRuleExportsESM(ast, scopeManager) { - const possibleNodes = []; +function getRuleExportsESM( + ast: Omit & { + body: (Directive | Statement | ModuleDeclaration | TSExportAssignment)[]; + }, + scopeManager: Scope.ScopeManager, +): PartialRuleInfo { + const possibleNodes: ( + | Node + | MaybeNamedClassDeclaration + | Expression + | MaybeNamedFunctionDeclaration + )[] = []; for (const statement of ast.body) { switch (statement.type) { @@ -140,16 +208,16 @@ function getRuleExportsESM(ast, scopeManager) { if (statement.declaration) { const nodes = statement.declaration.type === 'VariableDeclaration' - ? statement.declaration.declarations.map( - (declarator) => declarator.init, - ) + ? statement.declaration.declarations + .map((declarator) => declarator.init) + .filter((init) => !!init) : [statement.declaration]; // named exports like `export const rule = { ... };` // skip if it's function-style to avoid false positives // refs: https://github.com/eslint-community/eslint-plugin-eslint-plugin/issues/450 possibleNodes.push( - ...nodes.filter((node) => node && !functionTypes.has(node.type)), + ...nodes.filter((node) => node && !isFunctionType(node)), ); } break; @@ -157,7 +225,7 @@ function getRuleExportsESM(ast, scopeManager) { } } - return possibleNodes.reduce((currentExports, node) => { + return possibleNodes.reduce((currentExports, node) => { if (node.type === 'ObjectExpression') { // Check `export default { create() {}, meta: {} }` return collectInterestingProperties( @@ -166,7 +234,7 @@ function getRuleExportsESM(ast, scopeManager) { ); } else if (isFunctionRule(node)) { // Check `export default function(context) { return { ... }; }` - return { create: node, meta: null, isNewStyle: false }; + return { create: node, meta: undefined, isNewStyle: false }; } else if (isTypeScriptRuleHelper(node)) { // Check `export default someTypeScriptHelper({ create() {}, meta: {} }); return collectInterestingProperties( @@ -185,7 +253,7 @@ function getRuleExportsESM(ast, scopeManager) { ); } else if (isFunctionRule(possibleRule)) { // Check `const possibleRule = function(context) { return { ... } }; export default possibleRule;` - return { create: possibleRule, meta: null, isNewStyle: false }; + return { create: possibleRule, meta: undefined, isNewStyle: false }; } else if (isTypeScriptRuleHelper(possibleRule)) { // Check `const possibleRule = someTypeScriptHelper({ ... }); export default possibleRule; return collectInterestingProperties( @@ -196,13 +264,16 @@ function getRuleExportsESM(ast, scopeManager) { } } return currentExports; - }, {}); + }, {} as PartialRuleInfo); } /** * Helper for `getRuleInfo`. Handles CJS rules. */ -function getRuleExportsCJS(ast, scopeManager) { +function getRuleExportsCJS( + ast: Program, + scopeManager: Scope.ScopeManager, +): PartialRuleInfo { let exportsVarOverridden = false; let exportsIsFunction = false; return ast.body @@ -210,20 +281,21 @@ function getRuleExportsCJS(ast, scopeManager) { .map((statement) => statement.expression) .filter((expression) => expression.type === 'AssignmentExpression') .filter((expression) => expression.left.type === 'MemberExpression') - - .reduce((currentExports, node) => { + .reduce((currentExports, node) => { + const leftExpression = node.left; + if (leftExpression.type !== 'MemberExpression') return currentExports; if ( - node.left.object.type === 'Identifier' && - node.left.object.name === 'module' && - node.left.property.type === 'Identifier' && - node.left.property.name === 'exports' + leftExpression.object.type === 'Identifier' && + leftExpression.object.name === 'module' && + leftExpression.property.type === 'Identifier' && + leftExpression.property.name === 'exports' ) { exportsVarOverridden = true; if (isFunctionRule(node.right)) { // Check `module.exports = function (context) { return { ... }; }` exportsIsFunction = true; - return { create: node.right, meta: null, isNewStyle: false }; + return { create: node.right, meta: undefined, isNewStyle: false }; } else if (node.right.type === 'ObjectExpression') { // Check `module.exports = { create: function () {}, meta: {} }` @@ -243,73 +315,82 @@ function getRuleExportsCJS(ast, scopeManager) { ); } else if (isFunctionRule(possibleRule)) { // Check `const possibleRule = function(context) { return { ... } }; module.exports = possibleRule;` - return { create: possibleRule, meta: null, isNewStyle: false }; + return { + create: possibleRule, + meta: undefined, + isNewStyle: false, + }; } } } return {}; } else if ( !exportsIsFunction && - node.left.object.type === 'MemberExpression' && - node.left.object.object.type === 'Identifier' && - node.left.object.object.name === 'module' && - node.left.object.property.type === 'Identifier' && - node.left.object.property.name === 'exports' && - node.left.property.type === 'Identifier' && - INTERESTING_RULE_KEYS.has(node.left.property.name) + leftExpression.object.type === 'MemberExpression' && + leftExpression.object.object.type === 'Identifier' && + leftExpression.object.object.name === 'module' && + leftExpression.object.property.type === 'Identifier' && + leftExpression.object.property.name === 'exports' && + leftExpression.property.type === 'Identifier' && + isInterestingRuleKey(leftExpression.property.name) ) { // Check `module.exports.create = () => {}` - currentExports[node.left.property.name] = node.right; + currentExports[leftExpression.property.name] = node.right; } else if ( !exportsVarOverridden && - node.left.object.type === 'Identifier' && - node.left.object.name === 'exports' && - node.left.property.type === 'Identifier' && - INTERESTING_RULE_KEYS.has(node.left.property.name) + leftExpression.object.type === 'Identifier' && + leftExpression.object.name === 'exports' && + leftExpression.property.type === 'Identifier' && + isInterestingRuleKey(leftExpression.property.name) ) { // Check `exports.create = () => {}` - currentExports[node.left.property.name] = node.right; + currentExports[leftExpression.property.name] = node.right; } return currentExports; - }, {}); + }, {} as PartialRuleInfo); } /** * Find the value of a property in an object by its property key name. - * @param {Object} obj - * @param {String} keyName + * @param obj * @returns property value */ -function findObjectPropertyValueByKeyName(obj, keyName) { +function findObjectPropertyValueByKeyName( + obj: ObjectExpression, + keyName: String, +): Property['value'] | undefined { const property = obj.properties.find( - (prop) => prop.key.type === 'Identifier' && prop.key.name === keyName, - ); + (prop) => + prop.type === 'Property' && + prop.key.type === 'Identifier' && + prop.key.name === keyName, + ) as Property | undefined; return property ? property.value : undefined; } /** * Get the first value (or function) that a variable is initialized to. - * @param {Node} node - the Identifier node for the variable. - * @param {ScopeManager} scopeManager + * @param node - the Identifier node for the variable. * @returns the first value (or function) that the given variable is initialized to. */ -function findVariableValue(node, scopeManager) { +function findVariableValue( + node: Identifier, + scopeManager: Scope.ScopeManager, +): Expression | FunctionDeclaration | undefined { const variable = findVariable( - scopeManager.acquire(node) || scopeManager.globalScope, + scopeManager.acquire(node) || scopeManager.globalScope!, node, ); if (variable && variable.defs && variable.defs[0] && variable.defs[0].node) { - if ( - variable.defs[0].node.type === 'VariableDeclarator' && - variable.defs[0].node.init - ) { + const variableDefNode: Node = variable.defs[0].node; + if (variableDefNode.type === 'VariableDeclarator' && variableDefNode.init) { // Given node `x`, get `123` from `const x = 123;`. - return variable.defs[0].node.init; - } else if (variable.defs[0].node.type === 'FunctionDeclaration') { + return variableDefNode.init; + } else if (variableDefNode.type === 'FunctionDeclaration') { // Given node `foo`, get `function foo() {}` from `function foo() {}`. - return variable.defs[0].node; + return variableDefNode; } } } @@ -319,15 +400,14 @@ function findVariableValue(node, scopeManager) { * If a ternary conditional expression is involved, retrieve the elements that may exist on both sides of it. * Ex: [a, b, c] will return [a, b, c] * Ex: foo ? [a, b, c] : [d, e, f] will return [a, b, c, d, e, f] - * @param {Node} node - * @returns {Node[]} the list of elements + * @returns the list of elements */ -function collectArrayElements(node) { +function collectArrayElements(node: Node): Node[] { if (!node) { return []; } if (node.type === 'ArrayExpression') { - return node.elements; + return node.elements.filter((element) => element !== null); } if (node.type === 'ConditionalExpression') { return [ @@ -340,58 +420,66 @@ function collectArrayElements(node) { /** * Performs static analysis on an AST to try to determine the final value of `module.exports`. -* @param {{ast: ASTNode, scopeManager?: ScopeManager}} sourceCode The object contains `Program` AST node, and optional `scopeManager` -* @returns {Object} An object with keys `meta`, `create`, and `isNewStyle`. `meta` and `create` correspond to the AST nodes +* @param sourceCode The object contains `Program` AST node, and optional `scopeManager` +* @returns An object with keys `meta`, `create`, and `isNewStyle`. `meta` and `create` correspond to the AST nodes for the final values of `module.exports.meta` and `module.exports.create`. `isNewStyle` will be `true` if `module.exports` is an object, and `false` if `module.exports` is just the `create` function. If no valid ESLint rule info can be extracted from the file, the return value will be `null`. */ -export function getRuleInfo({ ast, scopeManager }) { +export function getRuleInfo({ + ast, + scopeManager, +}: { + ast: Program; + scopeManager: Scope.ScopeManager; +}): RuleInfo | null { const exportNodes = ast.sourceType === 'module' ? getRuleExportsESM(ast, scopeManager) : getRuleExportsCJS(ast, scopeManager); - const createExists = Object.prototype.hasOwnProperty.call( - exportNodes, - 'create', - ); + const createExists = 'create' in exportNodes; if (!createExists) { return null; } // If create/meta are defined in variables, get their values. - for (const key of Object.keys(exportNodes)) { - if (exportNodes[key] && exportNodes[key].type === 'Identifier') { - const value = findVariableValue(exportNodes[key], scopeManager); + for (const key of interestingRuleKeys) { + const exportNode = exportNodes[key]; + if (exportNode && exportNode.type === 'Identifier') { + const value = findVariableValue(exportNode, scopeManager); if (value) { exportNodes[key] = value; } } } - const createIsFunction = isNormalFunctionExpression(exportNodes.create); - if (!createIsFunction) { + const { create, ...remainingExportNodes } = exportNodes; + if (!(isFunctionType(create) && isNormalFunctionExpression(create))) { return null; } - return Object.assign({ isNewStyle: true, meta: null }, exportNodes); + return { isNewStyle: true, create, ...remainingExportNodes }; } /** * Gets all the identifiers referring to the `context` variable in a rule source file. Note that this function will * only work correctly after traversing the AST has started (e.g. in the first `Program` node). - * @param {RuleContext} scopeManager - * @param {ASTNode} ast The `Program` node for the file - * @returns {Set} A Set of all `Identifier` nodes that are references to the `context` value for the file + * @param scopeManager + * @param ast The `Program` node for the file + * @returns A Set of all `Identifier` nodes that are references to the `context` value for the file */ -export function getContextIdentifiers(scopeManager, ast) { +export function getContextIdentifiers( + scopeManager: Scope.ScopeManager, + ast: Program, +): Set { const ruleInfo = getRuleInfo({ ast, scopeManager }); + const firstCreateParam = ruleInfo?.create.params[0]; if ( !ruleInfo || - ruleInfo.create.params.length === 0 || - ruleInfo.create.params[0].type !== 'Identifier' + ruleInfo.create?.params.length === 0 || + firstCreateParam?.type !== 'Identifier' ) { return new Set(); } @@ -399,19 +487,22 @@ export function getContextIdentifiers(scopeManager, ast) { return new Set( scopeManager .getDeclaredVariables(ruleInfo.create) - .find((variable) => variable.name === ruleInfo.create.params[0].name) + .find((variable) => variable.name === firstCreateParam.name)! .references.map((ref) => ref.identifier), ); } /** * Gets the key name of a Property, if it can be determined statically. - * @param {ASTNode} node The `Property` node - * @param {Scope} scope - * @returns {string|null} The key name, or `null` if the name cannot be determined statically. + * @param node The `Property` node + * @param scope + * @returns The key name, or `null` if the name cannot be determined statically. */ -export function getKeyName(property, scope) { - if (!property.key) { +export function getKeyName( + property: Property | SpreadElement, + scope?: Scope.Scope, +): string | null { + if (!('key' in property)) { // likely a SpreadElement or another non-standard node return null; } @@ -420,7 +511,9 @@ export function getKeyName(property, scope) { // Variable key: { [myVariable]: 'hello world' } if (scope) { const staticValue = getStaticValue(property.key, scope); - return staticValue ? staticValue.value : null; + return staticValue && typeof staticValue.value === 'string' + ? staticValue.value + : null; } // TODO: ensure scope is always passed to getKeyName() so we don't need to handle the case where it's not passed. return null; @@ -434,7 +527,7 @@ export function getKeyName(property, scope) { property.key.type === 'TemplateLiteral' && property.key.quasis.length === 1 ) { - return property.key.quasis[0].value.cooked; + return property.key.quasis[0].value.cooked ?? null; } return null; } @@ -442,10 +535,11 @@ export function getKeyName(property, scope) { /** * Extracts the body of a function if the given node is a function * - * @param {ASTNode} node - * @returns {ExpressionStatement[]} + * @param node */ -export function extractFunctionBody(node) { +function extractFunctionBody( + node: Expression | SpreadElement, +): (Statement | Expression)[] { if ( node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression' @@ -463,17 +557,16 @@ export function extractFunctionBody(node) { /** * Checks the given statements for possible test info * - * @param {RuleContext} context The `context` variable for the source file itself - * @param {ASTNode[]} statements The statements to check - * @param {Set} variableIdentifiers - * @returns {CallExpression[]} + * @param context The `context` variable for the source file itself + * @param statements The statements to check + * @param variableIdentifiers */ -export function checkStatementsForTestInfo( - context, - statements, - variableIdentifiers = new Set(), -) { - const sourceCode = context.sourceCode || context.getSourceCode(); // TODO: just use context.sourceCode when dropping eslint < v9 +function checkStatementsForTestInfo( + context: Rule.RuleContext, + statements: (ModuleDeclaration | Statement | Directive | Expression)[], + variableIdentifiers = new Set(), +): CallExpression[] { + const sourceCode = context.sourceCode; const runCalls = []; for (const statement of statements) { @@ -497,9 +590,7 @@ export function checkStatementsForTestInfo( isRuleTesterConstruction(declarator.init) && declarator.id.type === 'Identifier' ) { - const vars = sourceCode.getDeclaredVariables - ? sourceCode.getDeclaredVariables(declarator) - : context.getDeclaredVariables(declarator); + const vars = sourceCode.getDeclaredVariables(declarator); vars.forEach((variable) => { variable.references .filter((ref) => ref.isRead()) @@ -565,20 +656,20 @@ export function checkStatementsForTestInfo( /** * Performs static analysis on an AST to try to find test cases - * @param {RuleContext} context The `context` variable for the source file itself - * @param {ASTNode} ast The `Program` node for the file. - * @returns {object} An object with `valid` and `invalid` keys containing a list of AST nodes corresponding to tests + * @param context The `context` variable for the source file itself + * @param ast The `Program` node for the file. + * @returns A list of objects with `valid` and `invalid` keys containing a list of AST nodes corresponding to tests */ -export function getTestInfo(context, ast) { +export function getTestInfo( + context: Rule.RuleContext, + ast: Program, +): TestInfo[] { const runCalls = checkStatementsForTestInfo(context, ast.body); return runCalls - .filter( - (call) => - call.arguments.length >= 3 && - call.arguments[2].type === 'ObjectExpression', - ) + .filter((call) => call.arguments.length >= 3) .map((call) => call.arguments[2]) + .filter((call) => call.type === 'ObjectExpression') .map((run) => { const validProperty = run.properties.find( (prop) => getKeyName(prop) === 'valid', @@ -589,11 +680,15 @@ export function getTestInfo(context, ast) { return { valid: - validProperty && validProperty.value.type === 'ArrayExpression' + validProperty && + validProperty.type !== 'SpreadElement' && + validProperty.value.type === 'ArrayExpression' ? validProperty.value.elements.filter(Boolean) : [], invalid: - invalidProperty && invalidProperty.value.type === 'ArrayExpression' + invalidProperty && + invalidProperty.type !== 'SpreadElement' && + invalidProperty.value.type === 'ArrayExpression' ? invalidProperty.value.elements.filter(Boolean) : [], }; @@ -602,10 +697,15 @@ export function getTestInfo(context, ast) { /** * Gets information on a report, given the ASTNode of context.report(). - * @param {ASTNode} node The ASTNode of context.report() - * @param {Context} context + * @param node The ASTNode of context.report() */ -export function getReportInfo(node, context) { +export function getReportInfo( + node: CallExpression, + context: Rule.RuleContext, +): + | Record + | Record + | null { const reportArgs = node.arguments; // If there is exactly one argument, the API expects an object. @@ -619,21 +719,24 @@ export function getReportInfo(node, context) { if (reportArgs.length === 1) { if (reportArgs[0].type === 'ObjectExpression') { - return reportArgs[0].properties.reduce((reportInfo, property) => { - const propName = getKeyName(property); + return reportArgs[0].properties.reduce>( + (reportInfo, property) => { + const propName = getKeyName(property); - if (propName !== null) { - return Object.assign(reportInfo, { [propName]: property.value }); - } - return reportInfo; - }, {}); + if (propName !== null && 'value' in property) { + return Object.assign(reportInfo, { [propName]: property.value }); + } + return reportInfo; + }, + {}, + ); } return null; } - let keys; - const sourceCode = context.sourceCode || context.getSourceCode(); // TODO: use context.sourceCode when dropping eslint < v9 - const scope = sourceCode.getScope?.(node) || context.getScope(); // TODO: just use sourceCode.getScope() when dropping eslint < v9 + let keys: string[]; + const sourceCode = context.sourceCode; + const scope = sourceCode.getScope(node); const secondArgStaticValue = getStaticValue(reportArgs[1], scope); if ( @@ -664,11 +767,14 @@ export function getReportInfo(node, context) { /** * Gets a set of all `sourceCode` identifiers. - * @param {ScopeManager} scopeManager - * @param {ASTNode} ast The AST of the file. This must have `parent` properties. - * @returns {Set} A set of all identifiers referring to the `SourceCode` object. + * @param scopeManager + * @param ast The AST of the file. This must have `parent` properties. + * @returns A set of all identifiers referring to the `SourceCode` object. */ -export function getSourceCodeIdentifiers(scopeManager, ast) { +export function getSourceCodeIdentifiers( + scopeManager: Scope.ScopeManager, + ast: Program, +): Set { return new Set( [...getContextIdentifiers(scopeManager, ast)] .filter( @@ -694,27 +800,37 @@ export function getSourceCodeIdentifiers(scopeManager, ast) { /** * Insert a given property into a given object literal. - * @param {SourceCodeFixer} fixer The fixer. - * @param {Node} node The ObjectExpression node to insert a property. - * @param {string} propertyText The property code to insert. - * @returns {void} + * @param fixer The fixer. + * @param node The ObjectExpression node to insert a property. + * @param propertyText The property code to insert. */ -export function insertProperty(fixer, node, propertyText, sourceCode) { +export function insertProperty( + fixer: Rule.RuleFixer, + node: ObjectExpression, + propertyText: string, + sourceCode: SourceCode, +): Rule.Fix { if (node.properties.length === 0) { return fixer.replaceText(node, `{\n${propertyText}\n}`); } + const lastProperty = node.properties.at(-1); + if (!lastProperty) { + return fixer.replaceText(node, `{\n${propertyText}\n}`); + } return fixer.insertTextAfter( - sourceCode.getLastToken(node.properties.at(-1)), + sourceCode.getLastToken(lastProperty)!, `,\n${propertyText}`, ); } /** * Collect all context.report({...}) violation/suggestion-related nodes into a standardized array for convenience. - * @param {Object} reportInfo - Result of getReportInfo(). + * @param reportInfo - Result of getReportInfo(). * @returns {messageId?: String, message?: String, data?: Object, fix?: Function}[] */ -export function collectReportViolationAndSuggestionData(reportInfo) { +export function collectReportViolationAndSuggestionData( + reportInfo: NonNullable>, +): ViolationAndSuppressionData[] { return [ // Violation message { @@ -746,29 +862,38 @@ export function collectReportViolationAndSuggestionData(reportInfo) { /** * Whether the provided node represents an autofixer function. - * @param {Node} node - * @param {Node[]} contextIdentifiers - * @returns {boolean} + * @param node + * @param contextIdentifiers */ -export function isAutoFixerFunction(node, contextIdentifiers) { +export function isAutoFixerFunction( + node: Node, + contextIdentifiers: Set, + context: Rule.RuleContext, +): node is FunctionExpression | ArrowFunctionExpression { const parent = node.parent; return ( ['FunctionExpression', 'ArrowFunctionExpression'].includes(node.type) && parent.parent.type === 'ObjectExpression' && parent.parent.parent.type === 'CallExpression' && - contextIdentifiers.has(parent.parent.parent.callee.object) && + parent.parent.parent.callee.type === 'MemberExpression' && + contextIdentifiers.has(parent.parent.parent.callee.object as Identifier) && + parent.parent.parent.callee.property.type === 'Identifier' && parent.parent.parent.callee.property.name === 'report' && - getReportInfo(parent.parent.parent).fix === node + getReportInfo(parent.parent.parent, context)?.fix === node ); } /** * Whether the provided node represents a suggestion fixer function. - * @param {Node} node - * @param {Node[]} contextIdentifiers - * @returns {boolean} + * @param node + * @param contextIdentifiers + * @param context */ -export function isSuggestionFixerFunction(node, contextIdentifiers) { +export function isSuggestionFixerFunction( + node: Node, + contextIdentifiers: Set, + context: Rule.RuleContext, +): boolean { const parent = node.parent; return ( (node.type === 'FunctionExpression' || @@ -784,30 +909,38 @@ export function isSuggestionFixerFunction(node, contextIdentifiers) { parent.parent.parent.parent.parent.type === 'ObjectExpression' && parent.parent.parent.parent.parent.parent.type === 'CallExpression' && contextIdentifiers.has( + // @ts-expect-error -- Property 'object' does not exist on type 'Expression | Super'. Property 'object' does not exist on type 'ClassExpression'.ts(2339) parent.parent.parent.parent.parent.parent.callee.object, ) && + // @ts-expect-error -- Property 'property' does not exist on type 'Expression | Super'. Property 'property' does not exist on type 'ClassExpression'.ts(2339) parent.parent.parent.parent.parent.parent.callee.property.name === 'report' && - getReportInfo(parent.parent.parent.parent.parent.parent).suggest === - parent.parent.parent + getReportInfo(parent.parent.parent.parent.parent.parent, context) + ?.suggest === parent.parent.parent ); } /** * List all properties contained in an object. * Evaluates and includes any properties that may be behind spreads. - * @param {Node} objectNode - * @param {ScopeManager} scopeManager - * @returns {Node[]} the list of all properties that could be found + * @param objectNode + * @param scopeManager + * @returns the list of all properties that could be found */ -export function evaluateObjectProperties(objectNode, scopeManager) { +export function evaluateObjectProperties( + objectNode: Node | undefined, + scopeManager: Scope.ScopeManager, +): (Property | SpreadElement)[] { if (!objectNode || objectNode.type !== 'ObjectExpression') { return []; } return objectNode.properties.flatMap((property) => { if (property.type === 'SpreadElement') { - const value = findVariableValue(property.argument, scopeManager); + const value = findVariableValue( + property.argument as Identifier, + scopeManager, + ); if (value && value.type === 'ObjectExpression') { return value.properties; } @@ -817,42 +950,53 @@ export function evaluateObjectProperties(objectNode, scopeManager) { }); } -export function getMetaDocsProperty(propertyName, ruleInfo, scopeManager) { - const metaNode = ruleInfo.meta; +export function getMetaDocsProperty( + propertyName: string, + ruleInfo: RuleInfo, + scopeManager: Scope.ScopeManager, +): MetaDocsProperty { + const metaNode = ruleInfo.meta ?? undefined; - const docsNode = evaluateObjectProperties(metaNode, scopeManager).find( - (p) => p.type === 'Property' && getKeyName(p) === 'docs', - ); + const docsNode = evaluateObjectProperties(metaNode, scopeManager) + .filter((node) => node.type === 'Property') + .find((p) => getKeyName(p) === 'docs'); const metaPropertyNode = evaluateObjectProperties( docsNode?.value, scopeManager, - ).find((p) => p.type === 'Property' && getKeyName(p) === propertyName); + ) + .filter((node) => node.type === 'Property') + .find((p) => getKeyName(p) === propertyName); return { docsNode, metaNode, metaPropertyNode }; } /** * Get the `meta.messages` node from a rule. - * @param {RuleInfo} ruleInfo - * @param {ScopeManager} scopeManager - * @returns {Node|undefined} + * @param ruleInfo + * @param scopeManager */ -export function getMessagesNode(ruleInfo, scopeManager) { +export function getMessagesNode( + ruleInfo: RuleInfo | null, + scopeManager: Scope.ScopeManager, +): ObjectExpression | undefined { if (!ruleInfo) { return; } - const metaNode = ruleInfo.meta; - const messagesNode = evaluateObjectProperties(metaNode, scopeManager).find( - (p) => p.type === 'Property' && getKeyName(p) === 'messages', - ); + const metaNode = ruleInfo.meta ?? undefined; + const messagesNode = evaluateObjectProperties(metaNode, scopeManager) + .filter((node) => node.type === 'Property') + .find((p) => getKeyName(p) === 'messages'); if (messagesNode) { if (messagesNode.value.type === 'ObjectExpression') { return messagesNode.value; } - const value = findVariableValue(messagesNode.value, scopeManager); + const value = findVariableValue( + messagesNode.value as Identifier, + scopeManager, + ); if (value && value.type === 'ObjectExpression') { return value; } @@ -861,11 +1005,13 @@ export function getMessagesNode(ruleInfo, scopeManager) { /** * Get the list of messageId properties from `meta.messages` for a rule. - * @param {RuleInfo} ruleInfo - * @param {ScopeManager} scopeManager - * @returns {Node[]|undefined} + * @param ruleInfo + * @param scopeManager */ -export function getMessageIdNodes(ruleInfo, scopeManager) { +export function getMessageIdNodes( + ruleInfo: RuleInfo, + scopeManager: Scope.ScopeManager, +): (Property | SpreadElement)[] | undefined { const messagesNode = getMessagesNode(ruleInfo, scopeManager); return messagesNode && messagesNode.type === 'ObjectExpression' @@ -875,25 +1021,36 @@ export function getMessageIdNodes(ruleInfo, scopeManager) { /** * Get the messageId property from a rule's `meta.messages` that matches the given `messageId`. - * @param {String} messageId - the messageId to check for - * @param {RuleInfo} ruleInfo - * @param {ScopeManager} scopeManager - * @param {Scope} scope - * @returns {Node|undefined} The matching messageId property from `meta.messages`. + * @param messageId - the messageId to check for + * @param ruleInfo + * @param scopeManager + * @param scope + * @returns The matching messageId property from `meta.messages`. */ -export function getMessageIdNodeById(messageId, ruleInfo, scopeManager, scope) { - return getMessageIdNodes(ruleInfo, scopeManager).find( - (p) => p.type === 'Property' && getKeyName(p, scope) === messageId, - ); +export function getMessageIdNodeById( + messageId: string, + ruleInfo: RuleInfo, + scopeManager: Scope.ScopeManager, + scope: Scope.Scope, +): Property | undefined { + return getMessageIdNodes(ruleInfo, scopeManager) + ?.filter((node) => node.type === 'Property') + .find((p) => getKeyName(p, scope) === messageId); } -export function getMetaSchemaNode(metaNode, scopeManager) { - return evaluateObjectProperties(metaNode, scopeManager).find( - (p) => p.type === 'Property' && getKeyName(p) === 'schema', - ); +export function getMetaSchemaNode( + metaNode: Node | undefined, + scopeManager: Scope.ScopeManager, +): Property | undefined { + return evaluateObjectProperties(metaNode, scopeManager) + .filter((node) => node.type === 'Property') + .find((p) => getKeyName(p) === 'schema'); } -export function getMetaSchemaNodeProperty(schemaNode, scopeManager) { +export function getMetaSchemaNodeProperty( + schemaNode: AssignmentProperty | Property | undefined, + scopeManager: Scope.ScopeManager, +): Node | null { if (!schemaNode) { return null; } @@ -901,7 +1058,7 @@ export function getMetaSchemaNodeProperty(schemaNode, scopeManager) { let { value } = schemaNode; if (value.type === 'Identifier' && value.name !== 'undefined') { const variable = findVariable( - scopeManager.acquire(value) || scopeManager.globalScope, + scopeManager.acquire(value) || scopeManager.globalScope!, value, ); @@ -914,10 +1071,10 @@ export function getMetaSchemaNodeProperty(schemaNode, scopeManager) { variable.defs[0].node.type !== 'VariableDeclarator' || !variable.defs[0].node.init ) { - return; + return null; } - value = variable.defs[0].node.init; + value = (variable.defs[0].node as VariableDeclarator).init! as Expression; } return value; @@ -925,13 +1082,16 @@ export function getMetaSchemaNodeProperty(schemaNode, scopeManager) { /** * Get the possible values that a variable was initialized to at some point. - * @param {Node} node - the Identifier node for the variable. - * @param {ScopeManager} scopeManager - * @returns {Node[]} the values that the given variable could be initialized to. + * @param node - the Identifier node for the variable. + * @param scopeManager + * @returns the values that the given variable could be initialized to. */ -export function findPossibleVariableValues(node, scopeManager) { +export function findPossibleVariableValues( + node: Identifier, + scopeManager: Scope.ScopeManager, +): Node[] { const variable = findVariable( - scopeManager.acquire(node) || scopeManager.globalScope, + scopeManager.acquire(node) || scopeManager.globalScope!, node, ); return ((variable && variable.references) || []).flatMap((ref) => { @@ -949,22 +1109,25 @@ export function findPossibleVariableValues(node, scopeManager) { } /** - * @param {Node} node - * @returns {boolean} Whether the node is an Identifier with name `undefined`. + * @param node + * @returns Whether the node is an Identifier with name `undefined`. */ -export function isUndefinedIdentifier(node) { +export function isUndefinedIdentifier(node: Node): boolean { return node.type === 'Identifier' && node.name === 'undefined'; } /** * Check whether a variable's definition is from a function parameter. - * @param {Node} node - the Identifier node for the variable. - * @param {ScopeManager} scopeManager - * @returns {boolean} whether the variable comes from a function parameter + * @param node - the Identifier node for the variable. + * @param scopeManager + * @returns whether the variable comes from a function parameter */ -export function isVariableFromParameter(node, scopeManager) { +export function isVariableFromParameter( + node: Identifier, + scopeManager: Scope.ScopeManager, +): boolean { const variable = findVariable( - scopeManager.acquire(node) || scopeManager.globalScope, + scopeManager.acquire(node) || scopeManager.globalScope!, node, ); diff --git a/package.json b/package.json index aaa146d1..cf9f6e1d 100644 --- a/package.json +++ b/package.json @@ -3,28 +3,30 @@ "version": "6.5.0", "description": "An ESLint plugin for linting ESLint plugins", "author": "Teddy Katz", - "main": "./lib/index.js", + "main": "./dist/index.js", "type": "module", "exports": { - ".": "./lib/index.js", + ".": "./dist/index.js", "./package.json": "./package.json" }, "license": "MIT", "scripts": { + "build": "tsup", "lint": "npm-run-all --continue-on-error --aggregate-output --parallel lint:*", "lint:docs": "markdownlint \"**/*.md\"", - "lint:eslint-docs": "npm-run-all \"update:eslint-docs -- --check\"", + "lint:eslint-docs": "npm-run-all -s build \"update:eslint-docs -- --check\"", "lint:js": "eslint --cache --ignore-pattern \"**/*.md\" .", "lint:js-docs": "eslint --no-inline-config \"**/*.md\"", "lint:package-json": "npmPkgJsonLint .", "release": "release-it", "test": "vitest run --coverage", - "test:remote": "eslint-remote-tester -c ./eslint-remote-tester.config.ts", + "test:remote": "eslint-remote-tester", + "typecheck": "tsc", "update:eslint-docs": "eslint-doc-generator" }, "files": [ "CHANGELOG.md", - "lib/" + "dist/" ], "keywords": [ "eslint", @@ -51,7 +53,11 @@ "@eslint/js": "^9.31.0", "@release-it/conventional-changelog": "^9.0.3", "@types/eslint-plugin-markdown": "^2.0.2", + "@types/eslint-scope": "^8.3.0", + "@types/espree": "^10.1.0", + "@types/estraverse": "^5.1.7", "@types/estree": "^1.0.8", + "@types/lodash": "^4.17.18", "@types/node": "^20.19.0", "@typescript-eslint/parser": "^8.34.1", "@typescript-eslint/utils": "^8.34.1", @@ -75,6 +81,7 @@ "npm-run-all2": "^7.0.1", "prettier": "^3.4.1", "release-it": "^17.2.0", + "tsup": "^8.5.0", "typescript": "^5.8.3", "vitest": "^3.2.4" }, diff --git a/tests/lib/fixtures/tsconfig.json b/tests/lib/fixtures/tsconfig.json index 403ce01b..baf78709 100644 --- a/tests/lib/fixtures/tsconfig.json +++ b/tests/lib/fixtures/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "module": "NodeNext", "moduleResolution": "NodeNext" }, "include": ["*.ts"] diff --git a/tests/lib/index.js b/tests/lib/index.js deleted file mode 100644 index 96f52e44..00000000 --- a/tests/lib/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import { assert, describe, it } from 'vitest'; - -import plugin from '../../lib/index.js'; - -const RULE_NAMES = Object.keys(plugin.rules); - -describe('exported plugin', () => { - describe('has a meta.docs.url property on each rule', () => { - RULE_NAMES.forEach((ruleName) => { - it(ruleName, () => { - assert.match( - plugin.rules[ruleName].meta.docs.url, - /^https:\/\/github.com\/eslint-community\/eslint-plugin-eslint-plugin\/tree\/HEAD\/docs\/rules\/[\w-]+\.md$/, - ); - }); - }); - }); -}); diff --git a/tests/lib/index.ts b/tests/lib/index.ts new file mode 100644 index 00000000..0eeda49d --- /dev/null +++ b/tests/lib/index.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import plugin from '../../lib/index.js'; + +describe('exported plugin', () => { + describe('has a meta.docs.url property on each rule', () => { + it.each(Object.entries(plugin.rules))('$0', (_, rule) => + expect(rule.meta?.docs?.url).toMatch( + /^https:\/\/github.com\/eslint-community\/eslint-plugin-eslint-plugin\/tree\/HEAD\/docs\/rules\/[\w-]+\.md$/, + ), + ); + }); +}); diff --git a/tests/lib/rule-setup.js b/tests/lib/rule-setup.ts similarity index 70% rename from tests/lib/rule-setup.js rename to tests/lib/rule-setup.ts index ab097bc3..dfa0fbba 100644 --- a/tests/lib/rule-setup.js +++ b/tests/lib/rule-setup.ts @@ -1,24 +1,24 @@ import { readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { assert, describe, it } from 'vitest'; import plugin from '../../lib/index.js'; -const RULE_NAMES = Object.keys(plugin.rules); -const dirname = path.dirname(fileURLToPath(import.meta.url)); +const RULE_NAMES = Object.keys(plugin.rules) as Array< + keyof typeof plugin.rules +>; describe('rule setup is correct', () => { it('should have a list of exported rules and rules directory that match', () => { - const filePath = path.join(dirname, '..', 'lib', 'rules'); + const filePath = path.join(import.meta.dirname, '..', 'lib', 'rules'); const files = readdirSync(filePath); assert.deepStrictEqual( RULE_NAMES, files .filter((file) => !file.startsWith('.')) - .map((file) => file.replace('.js', '')), + .map((file) => file.replace('.ts', '')), ); }); @@ -29,25 +29,26 @@ describe('rule setup is correct', () => { it('has the right properties', () => { const ALLOWED_CATEGORIES = ['Rules', 'Tests']; assert.ok( - ALLOWED_CATEGORIES.includes(rule.meta.docs.category), + !rule.meta?.docs?.category || + ALLOWED_CATEGORIES.includes(rule.meta.docs.category), 'has an allowed category', ); }); it('should have the right contents', () => { const filePath = path.join( - dirname, + import.meta.dirname, '..', '..', 'lib', 'rules', - `${ruleName}.js`, + `${ruleName}.ts`, ); const file = readFileSync(filePath, 'utf8'); assert.ok( - file.includes("/** @type {import('eslint').Rule.RuleModule} */"), - 'includes jsdoc comment for rule type', + file.includes("const rule: Rule.RuleModule"), + 'is defined as type RuleModule', ); }); }); @@ -55,19 +56,19 @@ describe('rule setup is correct', () => { }); it('should have tests for all rules', () => { - const filePath = path.join(dirname, 'rules'); + const filePath = path.join(import.meta.dirname, 'rules'); const files = readdirSync(filePath); assert.deepStrictEqual( RULE_NAMES, files .filter((file) => !file.startsWith('.')) - .map((file) => file.replace('.js', '')), + .map((file) => file.replace('.ts', '')), ); }); it('should have documentation for all rules', () => { - const filePath = path.join(dirname, '..', '..', 'docs', 'rules'); + const filePath = path.join(import.meta.dirname, '..', '..', 'docs', 'rules'); const files = readdirSync(filePath); assert.deepStrictEqual( diff --git a/tests/lib/rules/consistent-output.js b/tests/lib/rules/consistent-output.ts similarity index 100% rename from tests/lib/rules/consistent-output.js rename to tests/lib/rules/consistent-output.ts diff --git a/tests/lib/rules/fixer-return.js b/tests/lib/rules/fixer-return.ts similarity index 100% rename from tests/lib/rules/fixer-return.js rename to tests/lib/rules/fixer-return.ts diff --git a/tests/lib/rules/meta-property-ordering.js b/tests/lib/rules/meta-property-ordering.ts similarity index 100% rename from tests/lib/rules/meta-property-ordering.js rename to tests/lib/rules/meta-property-ordering.ts diff --git a/tests/lib/rules/no-deprecated-context-methods.js b/tests/lib/rules/no-deprecated-context-methods.ts similarity index 100% rename from tests/lib/rules/no-deprecated-context-methods.js rename to tests/lib/rules/no-deprecated-context-methods.ts diff --git a/tests/lib/rules/no-deprecated-report-api.js b/tests/lib/rules/no-deprecated-report-api.ts similarity index 100% rename from tests/lib/rules/no-deprecated-report-api.js rename to tests/lib/rules/no-deprecated-report-api.ts diff --git a/tests/lib/rules/no-identical-tests.js b/tests/lib/rules/no-identical-tests.ts similarity index 100% rename from tests/lib/rules/no-identical-tests.js rename to tests/lib/rules/no-identical-tests.ts diff --git a/tests/lib/rules/no-meta-replaced-by.js b/tests/lib/rules/no-meta-replaced-by.ts similarity index 83% rename from tests/lib/rules/no-meta-replaced-by.js rename to tests/lib/rules/no-meta-replaced-by.ts index 9ec810fa..f27fa93d 100644 --- a/tests/lib/rules/no-meta-replaced-by.js +++ b/tests/lib/rules/no-meta-replaced-by.ts @@ -13,7 +13,7 @@ import { RuleTester } from 'eslint'; // Tests // ------------------------------------------------------------------------------ -const valid = [ +const valid: string[] = [ 'module.exports = {};', ` module.exports = { @@ -34,8 +34,7 @@ const valid = [ create(context) {}, }; `, - { - code: ` + ` module.exports = { meta: { deprecated: { @@ -51,11 +50,9 @@ const valid = [ create(context) {}, }; `, - errors: 0, - }, ]; -const invalid = [ +const invalid: RuleTester.InvalidTestCase[] = [ { code: ` module.exports = { @@ -109,7 +106,13 @@ const invalid = [ }, ]; -const testToESM = (test) => { +type ValidTest = (typeof valid)[number]; +type InvalidTest = (typeof invalid)[number]; +type TestCase = ValidTest | InvalidTest; + +function testToESM(test: ValidTest): ValidTest; +function testToESM(test: InvalidTest): InvalidTest; +function testToESM(test: TestCase): TestCase { if (typeof test === 'string') { return test.replace('module.exports =', 'export default'); } @@ -120,7 +123,7 @@ const testToESM = (test) => { ...test, code, }; -}; +} new RuleTester({ languageOptions: { sourceType: 'commonjs' }, @@ -132,6 +135,6 @@ new RuleTester({ new RuleTester({ languageOptions: { sourceType: 'module' }, }).run('no-meta-replaced-by', rule, { - valid: valid.map(testToESM), - invalid: invalid.map(testToESM), + valid: valid.map((testCase) => testToESM(testCase)), + invalid: invalid.map((testCase) => testToESM(testCase)), }); diff --git a/tests/lib/rules/no-meta-schema-default.js b/tests/lib/rules/no-meta-schema-default.ts similarity index 100% rename from tests/lib/rules/no-meta-schema-default.js rename to tests/lib/rules/no-meta-schema-default.ts diff --git a/tests/lib/rules/no-missing-message-ids.js b/tests/lib/rules/no-missing-message-ids.ts similarity index 100% rename from tests/lib/rules/no-missing-message-ids.js rename to tests/lib/rules/no-missing-message-ids.ts diff --git a/tests/lib/rules/no-missing-placeholders.js b/tests/lib/rules/no-missing-placeholders.ts similarity index 97% rename from tests/lib/rules/no-missing-placeholders.js rename to tests/lib/rules/no-missing-placeholders.ts index 2791fee2..f725590e 100644 --- a/tests/lib/rules/no-missing-placeholders.js +++ b/tests/lib/rules/no-missing-placeholders.ts @@ -12,10 +12,14 @@ import { RuleTester } from 'eslint'; /** * Create an error for the given key - * @param {string} missingKey The placeholder that is missing - * @returns {object} An expected error + * @param missingKey The placeholder that is missing + * @returns An expected error */ -function error(missingKey, type, extra) { +function error( + missingKey: string, + type?: string, + extra?: Partial, +): RuleTester.TestCaseError { return { type, message: `The placeholder {{${missingKey}}} is missing (must provide it in the report's \`data\` object).`, diff --git a/tests/lib/rules/no-only-tests.js b/tests/lib/rules/no-only-tests.ts similarity index 100% rename from tests/lib/rules/no-only-tests.js rename to tests/lib/rules/no-only-tests.ts diff --git a/tests/lib/rules/no-property-in-node.js b/tests/lib/rules/no-property-in-node.ts similarity index 100% rename from tests/lib/rules/no-property-in-node.js rename to tests/lib/rules/no-property-in-node.ts diff --git a/tests/lib/rules/no-unused-message-ids.js b/tests/lib/rules/no-unused-message-ids.ts similarity index 100% rename from tests/lib/rules/no-unused-message-ids.js rename to tests/lib/rules/no-unused-message-ids.ts diff --git a/tests/lib/rules/no-unused-placeholders.js b/tests/lib/rules/no-unused-placeholders.ts similarity index 97% rename from tests/lib/rules/no-unused-placeholders.js rename to tests/lib/rules/no-unused-placeholders.ts index b9d55158..9f16166b 100644 --- a/tests/lib/rules/no-unused-placeholders.js +++ b/tests/lib/rules/no-unused-placeholders.ts @@ -12,10 +12,13 @@ import { RuleTester } from 'eslint'; /** * Create an error for the given key - * @param {string} unusedKey The placeholder that is unused - * @returns {object} An expected error + * @param unusedKey The placeholder that is unused + * @returns An expected error */ -function error(unusedKey, extra) { +function error( + unusedKey: string, + extra?: Partial, +): RuleTester.TestCaseError { return { type: 'Property', // The property in the report's `data` object for the unused placeholder. message: `The placeholder {{${unusedKey}}} is unused (does not exist in the actual message).`, diff --git a/tests/lib/rules/no-useless-token-range.js b/tests/lib/rules/no-useless-token-range.ts similarity index 95% rename from tests/lib/rules/no-useless-token-range.js rename to tests/lib/rules/no-useless-token-range.ts index 7b6f44e7..72499c80 100644 --- a/tests/lib/rules/no-useless-token-range.js +++ b/tests/lib/rules/no-useless-token-range.ts @@ -12,10 +12,10 @@ import { RuleTester } from 'eslint'; /** * Wraps a code sample as an eslint rule - * @param {string} code source text given a `sourceCode` variable - * @returns {string} rule code containing that source text + * @param code source text given a `sourceCode` variable + * @returns rule code containing that source text */ -function wrapRule(code) { +function wrapRule(code: string): string { return ` module.exports = { create(context) { diff --git a/tests/lib/rules/prefer-message-ids.js b/tests/lib/rules/prefer-message-ids.ts similarity index 100% rename from tests/lib/rules/prefer-message-ids.js rename to tests/lib/rules/prefer-message-ids.ts diff --git a/tests/lib/rules/prefer-object-rule.js b/tests/lib/rules/prefer-object-rule.ts similarity index 100% rename from tests/lib/rules/prefer-object-rule.js rename to tests/lib/rules/prefer-object-rule.ts diff --git a/tests/lib/rules/prefer-output-null.js b/tests/lib/rules/prefer-output-null.ts similarity index 100% rename from tests/lib/rules/prefer-output-null.js rename to tests/lib/rules/prefer-output-null.ts diff --git a/tests/lib/rules/prefer-placeholders.js b/tests/lib/rules/prefer-placeholders.ts similarity index 100% rename from tests/lib/rules/prefer-placeholders.js rename to tests/lib/rules/prefer-placeholders.ts diff --git a/tests/lib/rules/prefer-replace-text.js b/tests/lib/rules/prefer-replace-text.ts similarity index 100% rename from tests/lib/rules/prefer-replace-text.js rename to tests/lib/rules/prefer-replace-text.ts diff --git a/tests/lib/rules/report-message-format.js b/tests/lib/rules/report-message-format.ts similarity index 99% rename from tests/lib/rules/report-message-format.js rename to tests/lib/rules/report-message-format.ts index be260092..dd7f9e61 100644 --- a/tests/lib/rules/report-message-format.js +++ b/tests/lib/rules/report-message-format.ts @@ -218,7 +218,7 @@ ruleTester.run('report-message-format', rule, { }; `, options: ['foo'], - languageOptions: { sourceType: 'module' }, + languageOptions: { sourceType: 'module' as const }, }, { // With message as variable. diff --git a/tests/lib/rules/require-meta-default-options.js b/tests/lib/rules/require-meta-default-options.ts similarity index 100% rename from tests/lib/rules/require-meta-default-options.js rename to tests/lib/rules/require-meta-default-options.ts diff --git a/tests/lib/rules/require-meta-docs-description.js b/tests/lib/rules/require-meta-docs-description.ts similarity index 100% rename from tests/lib/rules/require-meta-docs-description.js rename to tests/lib/rules/require-meta-docs-description.ts diff --git a/tests/lib/rules/require-meta-docs-recommended.js b/tests/lib/rules/require-meta-docs-recommended.ts similarity index 100% rename from tests/lib/rules/require-meta-docs-recommended.js rename to tests/lib/rules/require-meta-docs-recommended.ts diff --git a/tests/lib/rules/require-meta-docs-url.js b/tests/lib/rules/require-meta-docs-url.ts similarity index 100% rename from tests/lib/rules/require-meta-docs-url.js rename to tests/lib/rules/require-meta-docs-url.ts diff --git a/tests/lib/rules/require-meta-fixable.js b/tests/lib/rules/require-meta-fixable.ts similarity index 100% rename from tests/lib/rules/require-meta-fixable.js rename to tests/lib/rules/require-meta-fixable.ts diff --git a/tests/lib/rules/require-meta-has-suggestions.js b/tests/lib/rules/require-meta-has-suggestions.ts similarity index 100% rename from tests/lib/rules/require-meta-has-suggestions.js rename to tests/lib/rules/require-meta-has-suggestions.ts diff --git a/tests/lib/rules/require-meta-schema-description.js b/tests/lib/rules/require-meta-schema-description.ts similarity index 100% rename from tests/lib/rules/require-meta-schema-description.js rename to tests/lib/rules/require-meta-schema-description.ts diff --git a/tests/lib/rules/require-meta-schema.js b/tests/lib/rules/require-meta-schema.ts similarity index 100% rename from tests/lib/rules/require-meta-schema.js rename to tests/lib/rules/require-meta-schema.ts diff --git a/tests/lib/rules/require-meta-type.js b/tests/lib/rules/require-meta-type.ts similarity index 99% rename from tests/lib/rules/require-meta-type.js rename to tests/lib/rules/require-meta-type.ts index 7d44b041..a2326db2 100644 --- a/tests/lib/rules/require-meta-type.js +++ b/tests/lib/rules/require-meta-type.ts @@ -66,16 +66,6 @@ ruleTester.run('require-meta-type', rule, { create(context) {} }; `, - { - code: ` - const create = {}; - module.exports = { - meta: {}, - create, - }; - `, - errors: [{ messageId: 'missing' }], - }, // Spread. ` const extra = { type: 'problem' }; @@ -85,6 +75,16 @@ ruleTester.run('require-meta-type', rule, { }; `, 'module.exports = {};', // No rule. + // No `create` function. + { + code: ` + const create = {}; + module.exports = { + meta: {}, + create, + }; + `, + }, ], invalid: [ diff --git a/tests/lib/rules/test-case-property-ordering.js b/tests/lib/rules/test-case-property-ordering.ts similarity index 100% rename from tests/lib/rules/test-case-property-ordering.js rename to tests/lib/rules/test-case-property-ordering.ts diff --git a/tests/lib/rules/test-case-shorthand-strings.js b/tests/lib/rules/test-case-shorthand-strings.ts similarity index 97% rename from tests/lib/rules/test-case-shorthand-strings.js rename to tests/lib/rules/test-case-shorthand-strings.ts index c65d0c18..0b989397 100644 --- a/tests/lib/rules/test-case-shorthand-strings.js +++ b/tests/lib/rules/test-case-shorthand-strings.ts @@ -12,10 +12,10 @@ import { RuleTester } from 'eslint'; /** * Returns the code for some valid test cases - * @param {string[]} cases The code representation of valid test cases - * @returns {string} Code representing the test cases + * @param cases The code representation of valid test cases + * @returns Code representing the test cases */ -function getTestCases(cases) { +function getTestCases(cases: string[]): string { return ` new RuleTester().run('foo', bar, { valid: [ diff --git a/tests/lib/utils.js b/tests/lib/utils.ts similarity index 76% rename from tests/lib/utils.js rename to tests/lib/utils.ts index 22e2a539..292e392d 100644 --- a/tests/lib/utils.js +++ b/tests/lib/utils.ts @@ -8,6 +8,39 @@ import lodash from 'lodash'; import { assert, describe, it } from 'vitest'; import * as utils from '../../lib/utils.js'; +import type { + ArrayExpression, + ArrowFunctionExpression, + AssignmentExpression, + AssignmentPattern, + BlockStatement, + CallExpression, + ExpressionStatement, + FunctionDeclaration, + FunctionExpression, + Identifier, + IfStatement, + Literal, + MemberExpression, + ObjectExpression, + Program, + Property, + SpreadElement, + VariableDeclaration, +} from 'estree'; +import type { Rule, Scope } from 'eslint'; +import type { RuleInfo } from '../../lib/types.js'; + +type MockRuleInfo = { + create: { + id?: { name: string }; + type: string; + }; + meta?: { + type: string; + } | undefined; + isNewStyle: boolean; +}; describe('utils', () => { describe('getRuleInfo', () => { @@ -58,7 +91,10 @@ describe('utils', () => { 'const rule = { create: function() {} }; exports.rule = rule;', ].forEach((noRuleCase) => { it(`returns null for ${noRuleCase}`, () => { - const ast = espree.parse(noRuleCase, { ecmaVersion: 8, range: true }); + const ast = espree.parse(noRuleCase, { + ecmaVersion: 8, + range: true, + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); assert.isNull( utils.getRuleInfo({ ast, scopeManager }), @@ -108,7 +144,7 @@ describe('utils', () => { ecmaVersion: 8, range: true, sourceType: 'module', - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); assert.isNull( utils.getRuleInfo({ ast, scopeManager }), @@ -139,7 +175,7 @@ describe('utils', () => { ecmaVersion: 8, range: true, sourceType: 'module', - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); assert.isNull( utils.getRuleInfo({ ast, scopeManager }), @@ -160,7 +196,7 @@ describe('utils', () => { const ast = typescriptEslintParser.parse(noRuleCase, { range: true, sourceType: 'script', - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); assert.isNull( utils.getRuleInfo({ ast, scopeManager }), @@ -171,7 +207,7 @@ describe('utils', () => { }); describe('the file has a valid rule (TypeScript + TypeScript parser + ESM)', () => { - const CASES = { + const CASES: Record = { // Util function only 'export default createESLintRule({ create() {}, meta: {} });': { @@ -336,11 +372,11 @@ describe('utils', () => { ecmaVersion: 6, range: true, sourceType: 'module', - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); const ruleInfo = utils.getRuleInfo({ ast, scopeManager }); assert( - lodash.isMatch(ruleInfo, CASES[ruleSource]), + ruleInfo && lodash.isMatch(ruleInfo, CASES[ruleSource]), `Expected \n${inspect(ruleInfo)}\nto match\n${inspect( CASES[ruleSource], )}`, @@ -350,15 +386,13 @@ describe('utils', () => { }); describe('the file has a valid rule (CJS)', () => { - const CASES = { + const CASES: Record = { 'module.exports = { create: function foo() {} };': { create: { type: 'FunctionExpression', id: { name: 'foo' } }, // (This property will actually contain the AST node.) - meta: null, isNewStyle: true, }, 'module.exports = { create: () => { } };': { create: { type: 'ArrowFunctionExpression' }, - meta: null, isNewStyle: true, }, 'module.exports = { create() {}, meta: { } };': { @@ -380,12 +414,10 @@ describe('utils', () => { 'module.exports = { create: () => { } }; exports.create = function foo() {}; exports.meta = {};': { create: { type: 'ArrowFunctionExpression' }, - meta: null, isNewStyle: true, }, 'exports.meta = {}; module.exports = { create: () => { } };': { create: { type: 'ArrowFunctionExpression' }, - meta: null, isNewStyle: true, }, 'module.exports = { create: () => { } }; module.exports.meta = {};': { @@ -405,44 +437,43 @@ describe('utils', () => { }, 'module.exports = { create: (context) => { } }; exports.meta = {};': { create: { type: 'ArrowFunctionExpression' }, - meta: null, isNewStyle: true, }, 'module.exports = function foo(context) { return {}; }': { create: { type: 'FunctionExpression', id: { name: 'foo' } }, - meta: null, + meta: undefined, isNewStyle: false, }, 'module.exports = function foo(slightlyDifferentContextName) { return {}; }': { create: { type: 'FunctionExpression', id: { name: 'foo' } }, - meta: null, + meta: undefined, isNewStyle: false, }, 'module.exports = function foo({ report }) { return {}; }': { create: { type: 'FunctionExpression', id: { name: 'foo' } }, - meta: null, + meta: undefined, isNewStyle: false, }, 'module.exports = (context) => { return {}; }': { create: { type: 'ArrowFunctionExpression' }, - meta: null, + meta: undefined, isNewStyle: false, }, 'module.exports = (context) => { if (foo) { return {}; } }': { create: { type: 'ArrowFunctionExpression' }, - meta: null, + meta: undefined, isNewStyle: false, }, 'exports.meta = {}; module.exports = (context) => { return {}; }': { create: { type: 'ArrowFunctionExpression' }, - meta: null, + meta: undefined, isNewStyle: false, }, 'module.exports = (context) => { return {}; }; module.exports.meta = {};': { create: { type: 'ArrowFunctionExpression' }, - meta: null, + meta: undefined, isNewStyle: false, }, 'const create = function(context) { return {}; }; const meta = {}; module.exports = { create, meta };': @@ -458,7 +489,7 @@ describe('utils', () => { }, 'const rule = function(context) {return{};}; module.exports = rule;': { create: { type: 'FunctionExpression' }, - meta: null, + meta: undefined, isNewStyle: false, }, }; @@ -469,11 +500,11 @@ describe('utils', () => { ecmaVersion: 6, range: true, sourceType: 'script', - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); const ruleInfo = utils.getRuleInfo({ ast, scopeManager }); assert( - lodash.isMatch(ruleInfo, CASES[ruleSource]), + ruleInfo && lodash.isMatch(ruleInfo, CASES[ruleSource]), `Expected \n${inspect(ruleInfo)}\nto match\n${inspect( CASES[ruleSource], )}`, @@ -483,11 +514,10 @@ describe('utils', () => { }); describe('the file has a valid rule (ESM)', () => { - const CASES = { + const CASES: Record = { // ESM (object style) 'export default { create() {} }': { create: { type: 'FunctionExpression' }, - meta: null, isNewStyle: true, }, 'export default { create() {}, meta: {} }': { @@ -532,22 +562,21 @@ describe('utils', () => { // ESM (function style) 'export default function (context) { return {}; }': { create: { type: 'FunctionDeclaration' }, - meta: null, isNewStyle: false, }, 'export default function (context) { if (foo) { return {}; } }': { create: { type: 'FunctionDeclaration' }, - meta: null, + meta: undefined, isNewStyle: false, }, 'export default (context) => { return {}; }': { create: { type: 'ArrowFunctionExpression' }, - meta: null, + meta: undefined, isNewStyle: false, }, 'const rule = function(context) {return {};}; export default rule;': { create: { type: 'FunctionExpression' }, - meta: null, + meta: undefined, isNewStyle: false, }, }; @@ -558,11 +587,11 @@ describe('utils', () => { ecmaVersion: 6, range: true, sourceType: 'module', - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); const ruleInfo = utils.getRuleInfo({ ast, scopeManager }); assert( - lodash.isMatch(ruleInfo, CASES[ruleSource]), + ruleInfo && lodash.isMatch(ruleInfo, CASES[ruleSource]), `Expected \n${inspect(ruleInfo)}\nto match\n${inspect( CASES[ruleSource], )}`, @@ -581,7 +610,7 @@ describe('utils', () => { }, { ignoreEval: true, ecmaVersion: 6, sourceType: 'script' }, { ignoreEval: true, ecmaVersion: 6, sourceType: 'module' }, - ]) { + ] as eslintScope.AnalyzeOptions[]) { const ast = espree.parse( ` const create = (context) => {}; @@ -589,7 +618,7 @@ describe('utils', () => { module.exports = { create, meta }; `, { ecmaVersion: 6, range: true }, - ); + ) as unknown as Program; const expected = { create: { type: 'ArrowFunctionExpression' }, meta: { type: 'ObjectExpression' }, @@ -599,7 +628,7 @@ describe('utils', () => { const scopeManager = eslintScope.analyze(ast, scopeOptions); const ruleInfo = utils.getRuleInfo({ ast, scopeManager }); assert( - lodash.isMatch(ruleInfo, expected), + ruleInfo && lodash.isMatch(ruleInfo, expected), `Expected \n${inspect(ruleInfo)}\nto match\n${inspect(expected)}`, ); }); @@ -607,14 +636,18 @@ describe('utils', () => { }); describe('the file has newer syntax', () => { - const CASES = [ + const CASES: { + source: string; + options: { sourceType: 'script' | 'module' }; + expected: MockRuleInfo; + }[] = [ { source: 'module.exports = function(context) { class Foo { @someDecorator() someProp }; return {}; };', options: { sourceType: 'script' }, expected: { create: { type: 'FunctionExpression' }, - meta: null, + meta: undefined, isNewStyle: false, }, }, @@ -624,7 +657,7 @@ describe('utils', () => { options: { sourceType: 'module' }, expected: { create: { type: 'FunctionDeclaration' }, - meta: null, + meta: undefined, isNewStyle: false, }, }, @@ -635,11 +668,11 @@ describe('utils', () => { const ast = typescriptEslintParser.parse( testCase.source, testCase.options, - ); + ) as unknown as Program; const scopeManager = eslintScope.analyze(ast); const ruleInfo = utils.getRuleInfo({ ast, scopeManager }); assert( - lodash.isMatch(ruleInfo, testCase.expected), + ruleInfo && lodash.isMatch(ruleInfo, testCase.expected), `Expected \n${inspect(ruleInfo)}\nto match\n${inspect( testCase.expected, )}`, @@ -651,43 +684,71 @@ describe('utils', () => { }); describe('getContextIdentifiers', () => { - const CASES = { + type ContextIdentifierMapFn = (ast: Program) => Identifier[]; + const CASES: Record = { 'module.exports = context => { context; context; context; return {}; }'( ast, ) { + const expression = (ast.body[0] as ExpressionStatement) + .expression as AssignmentExpression; + const blockStatement = (expression.right as ArrowFunctionExpression) + .body as BlockStatement; return [ - ast.body[0].expression.right.body.body[0].expression, - ast.body[0].expression.right.body.body[1].expression, - ast.body[0].expression.right.body.body[2].expression, + (blockStatement.body[0] as ExpressionStatement) + .expression as Identifier, + (blockStatement.body[1] as ExpressionStatement) + .expression as Identifier, + (blockStatement.body[2] as ExpressionStatement) + .expression as Identifier, ]; }, 'module.exports = { meta: {}, create(context, foo = context) {} }'(ast) { + const expression = (ast.body[0] as ExpressionStatement) + .expression as AssignmentExpression; + const functionExpression = ( + (expression.right as ObjectExpression).properties[1] as Property + ).value as FunctionExpression; return [ - ast.body[0].expression.right.properties[1].value.params[1].right, + (functionExpression.params[1] as AssignmentPattern) + .right as Identifier, ]; }, 'module.exports = { meta: {}, create(notContext) { notContext; notContext; notContext; } }'( ast, ) { + const expression = (ast.body[0] as ExpressionStatement) + .expression as AssignmentExpression; + const functionExpression = ( + (expression.right as ObjectExpression).properties[1] as Property + ).value as FunctionExpression; return [ - ast.body[0].expression.right.properties[1].value.body.body[0] - .expression, - ast.body[0].expression.right.properties[1].value.body.body[1] - .expression, - ast.body[0].expression.right.properties[1].value.body.body[2] - .expression, + (functionExpression.body.body[0] as ExpressionStatement) + .expression as Identifier, + (functionExpression.body.body[1] as ExpressionStatement) + .expression as Identifier, + (functionExpression.body.body[2] as ExpressionStatement) + .expression as Identifier, ]; }, 'const create = function(context) { context }; module.exports = { meta: {}, create };'( ast, ) { - return [ast.body[0].declarations[0].init.body.body[0].expression]; + const declaration = ast.body[0] as VariableDeclaration; + const functionExpression = declaration.declarations[0] + .init as FunctionExpression; + return [ + (functionExpression?.body.body[0] as ExpressionStatement) + .expression as Identifier, + ]; }, }; Object.keys(CASES).forEach((ruleSource) => { it(ruleSource, () => { - const ast = espree.parse(ruleSource, { ecmaVersion: 6, range: true }); + const ast = espree.parse(ruleSource, { + ecmaVersion: 6, + range: true, + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast, { ignoreEval: true, ecmaVersion: 6, @@ -713,7 +774,16 @@ describe('utils', () => { }); describe('getKeyName', () => { - const CASES = { + const CASES: Record< + string, + | string + | null + | { + getNode: (ast: Program) => Property | SpreadElement; + result: string; + resultWithoutScope?: string | null; + } + > = { '({ foo: 1 })': 'foo', '({ "foo": 1 })': 'foo', '({ ["foo"]: 1 })': 'foo', @@ -730,7 +800,9 @@ describe('utils', () => { '({ [key]: 1 })': null, 'const key = "foo"; ({ [key]: 1 });': { getNode(ast) { - return ast.body[1].expression.properties[0]; + const expression = (ast.body[1] as ExpressionStatement) + .expression as ObjectExpression; + return expression.properties[0]; }, result: 'foo', resultWithoutScope: null, @@ -738,7 +810,10 @@ describe('utils', () => { }; Object.keys(CASES).forEach((objectSource) => { it(objectSource, () => { - const ast = espree.parse(objectSource, { ecmaVersion: 6, range: true }); + const ast = espree.parse(objectSource, { + ecmaVersion: 6, + range: true, + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast, { ignoreEval: true, ecmaVersion: 6, @@ -764,9 +839,11 @@ describe('utils', () => { ); } } else { + const expression = (ast.body[0] as ExpressionStatement) + .expression as ObjectExpression; assert.strictEqual( utils.getKeyName( - ast.body[0].expression.properties[0], + expression.properties[0], scopeManager.globalScope, ), caseInfo, @@ -775,15 +852,20 @@ describe('utils', () => { }); }); - const CASES_ES9 = { + const CASES_ES9: Record = { '({ ...foo })': null, }; Object.keys(CASES_ES9).forEach((objectSource) => { it(objectSource, () => { - const ast = espree.parse(objectSource, { ecmaVersion: 9, range: true }); + const ast = espree.parse(objectSource, { + ecmaVersion: 9, + range: true, + }) as unknown as Program; + const expression = (ast.body[0] as ExpressionStatement) + .expression as ObjectExpression; assert.strictEqual( - utils.getKeyName(ast.body[0].expression.properties[0]), + utils.getKeyName(expression.properties[0]), CASES_ES9[objectSource], ); }); @@ -808,7 +890,7 @@ describe('utils', () => { const ast = espree.parse(noTestsCase, { ecmaVersion: 8, range: true, - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast, { ignoreEval: true, ecmaVersion: 6, @@ -820,7 +902,7 @@ describe('utils', () => { getDeclaredVariables: scopeManager.getDeclaredVariables.bind(scopeManager), }, - }; // mock object + } as unknown as Rule.RuleContext; // mock object assert.deepEqual( utils.getTestInfo(context, ast), [], @@ -831,7 +913,7 @@ describe('utils', () => { }); describe('the file has valid tests', () => { - const CASES = { + const CASES: Record = { 'new RuleTester().run(bar, baz, { valid: [foo], invalid: [bar, baz] })': { valid: 1, invalid: 2 }, 'var foo = new RuleTester(); foo.run(bar, baz, { valid: [foo], invalid: [bar] })': @@ -880,7 +962,10 @@ describe('utils', () => { Object.keys(CASES).forEach((testSource) => { it(testSource, () => { - const ast = espree.parse(testSource, { ecmaVersion: 6, range: true }); + const ast = espree.parse(testSource, { + ecmaVersion: 6, + range: true, + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast, { ignoreEval: true, ecmaVersion: 6, @@ -892,7 +977,7 @@ describe('utils', () => { getDeclaredVariables: scopeManager.getDeclaredVariables.bind(scopeManager), }, - }; // mock object + } as unknown as Rule.RuleContext; // mock object const testInfo = utils.getTestInfo(context, ast); assert.strictEqual( @@ -917,7 +1002,7 @@ describe('utils', () => { }); describe('the file has multiple test runs', () => { - const CASES = { + const CASES: Record = { [` new RuleTester().run(foo, bar, { valid: [foo], invalid: [] }); new RuleTester().run(foo, bar, { valid: [], invalid: [foo, bar] }); @@ -1080,7 +1165,10 @@ describe('utils', () => { Object.keys(CASES).forEach((testSource) => { it(testSource, () => { - const ast = espree.parse(testSource, { ecmaVersion: 6, range: true }); + const ast = espree.parse(testSource, { + ecmaVersion: 6, + range: true, + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast, { ignoreEval: true, ecmaVersion: 6, @@ -1092,7 +1180,7 @@ describe('utils', () => { getDeclaredVariables: scopeManager.getDeclaredVariables.bind(scopeManager), }, - }; // mock object + } as unknown as Rule.RuleContext; // mock object const testInfo = utils.getTestInfo(context, ast); assert.strictEqual( @@ -1123,7 +1211,28 @@ describe('utils', () => { }); describe('getReportInfo', () => { - const CASES = new Map([ + type GetReportInfoFn = { + (args: readonly (Identifier | ObjectExpression)[]): { + node: Identifier | ObjectExpression; + message: Identifier | ObjectExpression; + data: Identifier | ObjectExpression; + fix: Identifier | ObjectExpression; + loc?: Identifier | ObjectExpression; + }; + (): null; + (): { + node: { type: string; name: string; start: number; end: number }; + message: { + type: string; + name: string; + start: number; + end: number; + }; + }; + }; + + // @ts-expect-error - These types need some more work + const CASES = new Map([ [[], () => null], [['foo', 'bar'], () => null], [ @@ -1166,28 +1275,30 @@ describe('utils', () => { for (const args of CASES.keys()) { it(args.join(', '), () => { - const node = espree.parse(`context.report(${args.join(', ')})`, { + const program = espree.parse(`context.report(${args.join(', ')})`, { ecmaVersion: 6, loc: false, range: false, - }).body[0].expression; - const parsedArgs = node.arguments; + }) as unknown as Program; + const node = (program.body[0] as ExpressionStatement) + .expression as CallExpression; + const parsedArgs = node.arguments as (Identifier | ObjectExpression)[]; const context = { sourceCode: { getScope() { return {}; }, }, - }; // mock object + } as unknown as Rule.RuleContext; // mock object const reportInfo = utils.getReportInfo(node, context); - assert.deepEqual(reportInfo, CASES.get(args)(parsedArgs)); + assert.deepEqual(reportInfo, CASES.get(args)?.(parsedArgs)); }); } }); describe('getSourceCodeIdentifiers', () => { - const CASES = { + const CASES: Record = { 'module.exports = context => { const sourceCode = context.getSourceCode(); sourceCode; foo; return {}; }': 2, 'module.exports = context => { const x = 1, sc = context.getSourceCode(); sc; sc; sc; sourceCode; return {}; }': 4, 'module.exports = context => { const sourceCode = context.getNotSourceCode(); return {}; }': 0, @@ -1195,7 +1306,10 @@ describe('utils', () => { Object.keys(CASES).forEach((testSource) => { it(testSource, () => { - const ast = espree.parse(testSource, { ecmaVersion: 6, range: true }); + const ast = espree.parse(testSource, { + ecmaVersion: 6, + range: true, + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast, { ignoreEval: true, ecmaVersion: 6, @@ -1205,7 +1319,9 @@ describe('utils', () => { estraverse.traverse(ast, { enter(node, parent) { - node.parent = parent; + if (parent) { + node.parent = parent; + } }, }); @@ -1218,7 +1334,17 @@ describe('utils', () => { }); describe('collectReportViolationAndSuggestionData', () => { - const CASES = [ + type Data = { + message?: { type: string; value: string }; + messageId?: { type: string; value: string }; + data?: { type: string; properties?: { key: { name: string } }[] }; + fix?: { type: string }; + }; + type TestCase = { + code: string; + shouldMatch: Data[]; + }; + const CASES: TestCase[] = [ { // One suggestion. code: ` @@ -1350,19 +1476,22 @@ describe('utils', () => { const ast = espree.parse(testCase.code, { ecmaVersion: 6, range: true, - }); + }) as unknown as Program; const context = { sourceCode: { getScope() { return {}; }, }, - }; // mock object - const reportNode = ast.body[0].expression; + } as unknown as Rule.RuleContext; // mock object + const reportNode = (ast.body[0] as ExpressionStatement) + .expression as CallExpression; const reportInfo = utils.getReportInfo(reportNode, context); - const data = utils.collectReportViolationAndSuggestionData(reportInfo); + const data = + reportInfo && + utils.collectReportViolationAndSuggestionData(reportInfo); assert( - lodash.isMatch(data, testCase.shouldMatch), + data && lodash.isMatch(data, testCase.shouldMatch), `Expected \n${inspect(data)}\nto match\n${inspect( testCase.shouldMatch, )}`, @@ -1372,36 +1501,55 @@ describe('utils', () => { }); describe('isAutoFixerFunction / isSuggestionFixerFunction', () => { - const CASES = { + type TestCase = { + expected: boolean; + node: ArrayExpression | FunctionExpression; + context: Identifier | undefined; + fn: + | typeof utils.isAutoFixerFunction + | typeof utils.isSuggestionFixerFunction; + }; + + const getReportCallExpression = (ast: Program): CallExpression => + (ast.body[0] as ExpressionStatement).expression as CallExpression; + const getReportParamObjectExpression = (ast: Program): ObjectExpression => + getReportCallExpression(ast).arguments[0] as ObjectExpression; + const getReportParamObjectProperty = (ast: Program): Property => + getReportParamObjectExpression(ast).properties[0] as Property; + const getReportCalleeIdentifier = (ast: Program): Identifier => + (getReportCallExpression(ast).callee as MemberExpression) + .object as Identifier; + + const CASES: Record TestCase> = { // isAutoFixerFunction 'context.report({ fix(fixer) {} });'(ast) { return { expected: true, - node: ast.body[0].expression.arguments[0].properties[0].value, - context: ast.body[0].expression.callee.object, + node: getReportParamObjectProperty(ast).value as FunctionExpression, + context: getReportCalleeIdentifier(ast), fn: utils.isAutoFixerFunction, }; }, 'context.notReport({ fix(fixer) {} });'(ast) { return { expected: false, - node: ast.body[0].expression.arguments[0].properties[0].value, - context: ast.body[0].expression.callee.object, + node: getReportParamObjectProperty(ast).value as FunctionExpression, + context: getReportCalleeIdentifier(ast), fn: utils.isAutoFixerFunction, }; }, 'context.report({ notFix(fixer) {} });'(ast) { return { expected: false, - node: ast.body[0].expression.arguments[0].properties[0].value, - context: ast.body[0].expression.callee.object, + node: getReportParamObjectProperty(ast).value as FunctionExpression, + context: getReportCalleeIdentifier(ast), fn: utils.isAutoFixerFunction, }; }, 'notContext.report({ notFix(fixer) {} });'(ast) { return { expected: false, - node: ast.body[0].expression.arguments[0].properties[0].value, + node: getReportParamObjectProperty(ast).value as FunctionExpression, context: undefined, fn: utils.isAutoFixerFunction, }; @@ -1411,43 +1559,59 @@ describe('utils', () => { 'context.report({ suggest: [{ fix(fixer) {} }] });'(ast) { return { expected: true, - node: ast.body[0].expression.arguments[0].properties[0].value - .elements[0].properties[0].value, - context: ast.body[0].expression.callee.object, + node: ( + ( + (getReportParamObjectProperty(ast).value as ArrayExpression) + .elements[0] as ObjectExpression + ).properties[0] as Property + ).value as FunctionExpression, + context: getReportCalleeIdentifier(ast), fn: utils.isSuggestionFixerFunction, }; }, 'context.notReport({ suggest: [{ fix(fixer) {} }] });'(ast) { return { expected: false, - node: ast.body[0].expression.arguments[0].properties[0].value - .elements[0].properties[0].value, - context: ast.body[0].expression.callee.object, + node: ( + ( + (getReportParamObjectProperty(ast).value as ArrayExpression) + .elements[0] as ObjectExpression + ).properties[0] as Property + ).value as FunctionExpression, + context: getReportCalleeIdentifier(ast), fn: utils.isSuggestionFixerFunction, }; }, 'context.report({ notSuggest: [{ fix(fixer) {} }] });'(ast) { return { expected: false, - node: ast.body[0].expression.arguments[0].properties[0].value - .elements[0].properties[0].value, - context: ast.body[0].expression.callee.object, + node: ( + ( + (getReportParamObjectProperty(ast).value as ArrayExpression) + .elements[0] as ObjectExpression + ).properties[0] as Property + ).value as FunctionExpression, + context: getReportCalleeIdentifier(ast), fn: utils.isSuggestionFixerFunction, }; }, 'context.report({ suggest: [{ notFix(fixer) {} }] });'(ast) { return { expected: false, - node: ast.body[0].expression.arguments[0].properties[0].value - .elements[0].properties[0].value, - context: ast.body[0].expression.callee.object, + node: ( + ( + (getReportParamObjectProperty(ast).value as ArrayExpression) + .elements[0] as ObjectExpression + ).properties[0] as Property + ).value as FunctionExpression, + context: getReportCalleeIdentifier(ast), fn: utils.isSuggestionFixerFunction, }; }, 'notContext.report({ suggest: [{ fix(fixer) {} }] });'(ast) { return { expected: false, - node: ast.body[0].expression.arguments[0].properties[0].value, + node: getReportParamObjectProperty(ast).value as ArrayExpression, context: undefined, fn: utils.isSuggestionFixerFunction, }; @@ -1456,18 +1620,32 @@ describe('utils', () => { Object.keys(CASES).forEach((ruleSource) => { it(ruleSource, () => { - const ast = espree.parse(ruleSource, { ecmaVersion: 6, range: true }); + const ast = espree.parse(ruleSource, { + ecmaVersion: 6, + range: true, + }) as unknown as Program; + const context = { + sourceCode: { + getScope() { + return {}; + }, + }, + } as unknown as Rule.RuleContext; // mock object // Add parent to each node. estraverse.traverse(ast, { enter(node, parent) { - node.parent = parent; + if (parent) { + node.parent = parent; + } }, }); const testCase = CASES[ruleSource](ast); - const contextIdentifiers = new Set([testCase.context]); - const result = testCase.fn(testCase.node, contextIdentifiers); + const contextIdentifiers = new Set( + [testCase.context].filter((node) => !!node), + ); + const result = testCase.fn(testCase.node, contextIdentifiers, context); assert.strictEqual(result, testCase.expected); }); }); @@ -1475,19 +1653,29 @@ describe('utils', () => { describe('evaluateObjectProperties', function () { it('behaves correctly with simple object expression', function () { + const getObjectExpression = (ast: Program): ObjectExpression => + (ast.body[0] as VariableDeclaration).declarations[0] + .init as ObjectExpression; const ast = espree.parse('const obj = { a: 123, b: foo() };', { ecmaVersion: 9, range: true, - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); const result = utils.evaluateObjectProperties( - ast.body[0].declarations[0].init, + getObjectExpression(ast), scopeManager, ); - assert.deepEqual(result, ast.body[0].declarations[0].init.properties); + assert.deepEqual(result, getObjectExpression(ast).properties); }); it('behaves correctly with spreads of objects', function () { + const getObjectExpression = ( + ast: Program, + bodyElement: number, + ): ObjectExpression => + (ast.body[bodyElement] as VariableDeclaration).declarations[0] + .init as ObjectExpression; + const ast = espree.parse( ` const extra1 = { a: 123 }; @@ -1498,29 +1686,33 @@ describe('utils', () => { ecmaVersion: 9, range: true, }, - ); + ) as unknown as Program; const scopeManager = eslintScope.analyze(ast); const result = utils.evaluateObjectProperties( - ast.body[2].declarations[0].init, + getObjectExpression(ast, 2), scopeManager, ); assert.deepEqual(result, [ - ...ast.body[0].declarations[0].init.properties, // First spread properties - ...ast.body[2].declarations[0].init.properties.filter( + ...getObjectExpression(ast, 0).properties, // First spread properties + ...getObjectExpression(ast, 2).properties.filter( (property) => property.type !== 'SpreadElement', ), // Non-spread properties - ...ast.body[1].declarations[0].init.properties, // Second spread properties + ...getObjectExpression(ast, 1).properties, // Second spread properties ]); }); it('behaves correctly with non-variable spreads', function () { + const getObjectExpression = (ast: Program): ObjectExpression => + (ast.body[1] as VariableDeclaration).declarations[0] + .init as ObjectExpression; + const ast = espree.parse(`function foo() {} const obj = { ...foo() };`, { ecmaVersion: 9, range: true, - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); const result = utils.evaluateObjectProperties( - ast.body[1].declarations[0].init, + getObjectExpression(ast), scopeManager, ); assert.deepEqual(result, []); @@ -1530,10 +1722,11 @@ describe('utils', () => { const ast = espree.parse(`const obj = { ...foo };`, { ecmaVersion: 9, range: true, - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); const result = utils.evaluateObjectProperties( - ast.body[0].declarations[0].init, + (ast.body[0] as VariableDeclaration).declarations[0] + .init as ObjectExpression, scopeManager, ); assert.deepEqual(result, []); @@ -1543,7 +1736,7 @@ describe('utils', () => { const ast = espree.parse(`foo();`, { ecmaVersion: 9, range: true, - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); const result = utils.evaluateObjectProperties(ast.body[0], scopeManager); assert.deepEqual(result, []); @@ -1551,12 +1744,26 @@ describe('utils', () => { }); describe('getMessagesNode', function () { - [ + type TestCase = { + code: string; + getResult: ((ast: Program) => ObjectExpression) | (() => void); + }; + const CASES: TestCase[] = [ { code: 'module.exports = { meta: { messages: {} }, create(context) {} };', getResult(ast) { - return ast.body[0].expression.right.properties[0].value.properties[0] - .value; + return ( + ( + ( + ( + ( + (ast.body[0] as ExpressionStatement) + .expression as AssignmentExpression + ).right as ObjectExpression + ).properties[0] as Property + ).value as ObjectExpression + ).properties[0] as Property + ).value as ObjectExpression; }, }, { @@ -1566,7 +1773,8 @@ describe('utils', () => { module.exports = { meta: { messages }, create(context) {} }; `, getResult(ast) { - return ast.body[0].declarations[0].init; + return (ast.body[0] as VariableDeclaration).declarations[0] + .init as ObjectExpression; }, }, { @@ -1576,24 +1784,32 @@ describe('utils', () => { module.exports = { meta: { ...extra }, create(context) {} }; `, getResult(ast) { - return ast.body[0].declarations[0].init.properties[0].value; + return ( + ( + (ast.body[0] as VariableDeclaration).declarations[0] + .init as ObjectExpression + ).properties[0] as Property + ).value as ObjectExpression; }, }, { code: `module.exports = { meta: FOO, create(context) {} };`, - getResult() {}, // returns undefined + getResult() { + return undefined; + }, // returns undefined }, { code: `module.exports = { create(context) {} };`, getResult() {}, // returns undefined }, - ].forEach((testCase) => { + ]; + CASES.forEach((testCase) => { describe(testCase.code, () => { it('returns the right node', () => { const ast = espree.parse(testCase.code, { ecmaVersion: 9, range: true, - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); const ruleInfo = utils.getRuleInfo({ ast, scopeManager }); assert.strictEqual( @@ -1606,12 +1822,28 @@ describe('utils', () => { }); describe('getMessageIdNodes', function () { - [ + type TestCase = { + code: string; + getResult: (ast: Program) => Property[]; + }; + const CASES: TestCase[] = [ { code: 'module.exports = { meta: { messages: { foo: "hello world" } }, create(context) {} };', getResult(ast) { - return ast.body[0].expression.right.properties[0].value.properties[0] - .value.properties; + return ( + ( + ( + ( + ( + ( + (ast.body[0] as ExpressionStatement) + .expression as AssignmentExpression + ).right as ObjectExpression + ).properties[0] as Property + ).value as ObjectExpression + ).properties[0] as Property + ).value as ObjectExpression + ).properties as Property[]; }, }, { @@ -1621,7 +1853,10 @@ describe('utils', () => { module.exports = { meta: { messages }, create(context) {} }; `, getResult(ast) { - return ast.body[0].declarations[0].init.properties; + return ( + (ast.body[0] as VariableDeclaration).declarations[0] + .init as ObjectExpression + ).properties as Property[]; }, }, { @@ -1632,20 +1867,24 @@ describe('utils', () => { module.exports = { meta: { ...extra }, create(context) {} }; `, getResult(ast) { - return ast.body[0].declarations[0].init.properties; + return ( + (ast.body[0] as VariableDeclaration).declarations[0] + .init as ObjectExpression + ).properties as Property[]; }, }, - ].forEach((testCase) => { + ]; + CASES.forEach((testCase) => { describe(testCase.code, () => { it('returns the right node', () => { const ast = espree.parse(testCase.code, { ecmaVersion: 9, range: true, - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); const ruleInfo = utils.getRuleInfo({ ast, scopeManager }); assert.deepEqual( - utils.getMessageIdNodes(ruleInfo, scopeManager), + ruleInfo && utils.getMessageIdNodes(ruleInfo, scopeManager), testCase.getResult(ast), ); }); @@ -1654,7 +1893,15 @@ describe('utils', () => { }); describe('getMessageIdNodeById', function () { - [ + type TestCase = { + code: string; + run: ( + ruleInfo: RuleInfo, + scopeManager: Scope.ScopeManager, + ) => Property | undefined; + getResult: ((ast: Program) => Property) | (() => void); + }; + const CASES: TestCase[] = [ { code: 'module.exports = { meta: { messages: { foo: "hello world" } }, create(context) {} };', run(ruleInfo, scopeManager) { @@ -1662,12 +1909,24 @@ describe('utils', () => { 'foo', ruleInfo, scopeManager, - scopeManager.globalScope, + scopeManager.globalScope!, ); }, getResult(ast) { - return ast.body[0].expression.right.properties[0].value.properties[0] - .value.properties[0]; + return ( + ( + ( + ( + ( + ( + (ast.body[0] as ExpressionStatement) + .expression as AssignmentExpression + ).right as ObjectExpression + ).properties[0] as Property + ).value as ObjectExpression + ).properties[0] as Property + ).value as ObjectExpression + ).properties[0] as Property; }, }, { @@ -1677,22 +1936,24 @@ describe('utils', () => { 'bar', ruleInfo, scopeManager, - scopeManager.globalScope, + scopeManager.globalScope!, ); }, getResult() {}, // returns undefined }, - ].forEach((testCase) => { + ]; + + CASES.forEach((testCase) => { describe(testCase.code, () => { it('returns the right node', () => { const ast = espree.parse(testCase.code, { ecmaVersion: 9, range: true, - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); const ruleInfo = utils.getRuleInfo({ ast, scopeManager }); assert.strictEqual( - testCase.run(ruleInfo, scopeManager), + ruleInfo && testCase.run(ruleInfo, scopeManager), testCase.getResult(ast), ); }); @@ -1707,26 +1968,39 @@ describe('utils', () => { const ast = espree.parse(code, { ecmaVersion: 9, range: true, - }); + }) as unknown as Program; // Add parent to each node. estraverse.traverse(ast, { enter(node, parent) { - node.parent = parent; + if (parent) { + node.parent = parent; + } }, }); const scopeManager = eslintScope.analyze(ast); assert.deepEqual( utils.findPossibleVariableValues( - ast.body[0].declarations[0].id, + (ast.body[0] as VariableDeclaration).declarations[0].id as Identifier, scopeManager, ), [ - ast.body[0].declarations[0].init, - ast.body[1].expression.right, - ast.body[2].expression.right, - ast.body[3].consequent.body[0].expression.right, + (ast.body[0] as VariableDeclaration).declarations[0].init as Literal, + ( + (ast.body[1] as ExpressionStatement) + .expression as AssignmentExpression + ).right, + ( + (ast.body[2] as ExpressionStatement) + .expression as AssignmentExpression + ).right, + ( + ( + ((ast.body[3] as IfStatement).consequent as BlockStatement) + .body[0] as ExpressionStatement + ).expression as AssignmentExpression + ).right, ], ); }); @@ -1739,12 +2013,17 @@ describe('utils', () => { const ast = espree.parse(code, { ecmaVersion: 9, range: true, - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); assert.ok( utils.isVariableFromParameter( - ast.body[0].body.body[1].expression.arguments[0], + ( + ( + (ast.body[0] as FunctionDeclaration).body + .body[1] as ExpressionStatement + ).expression as CallExpression + ).arguments[0] as Identifier, scopeManager, ), ); @@ -1755,12 +2034,13 @@ describe('utils', () => { const ast = espree.parse(code, { ecmaVersion: 9, range: true, - }); + }) as unknown as Program; const scopeManager = eslintScope.analyze(ast); assert.notOk( utils.isVariableFromParameter( - ast.body[1].expression.arguments[0], + ((ast.body[1] as ExpressionStatement).expression as CallExpression) + .arguments[0] as Identifier, scopeManager, ), ); diff --git a/tests/utils/test-setup.js b/tests/utils/test-setup.ts similarity index 100% rename from tests/utils/test-setup.js rename to tests/utils/test-setup.ts diff --git a/tsconfig.json b/tsconfig.json index 977142eb..1411ce1d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,11 @@ "target": "ES2024", "verbatimModuleSyntax": true, "erasableSyntaxOnly": true, - "forceConsistentCasingInFileNames": true - } + "forceConsistentCasingInFileNames": true, + "paths": { + "eslint-plugin-eslint-plugin": ["./lib/index.ts"] + }, + "types": ["eslint-scope", "espree", "estree", "lodash", "node"] + }, + "include": ["lib/**/*", "tests/**/*", "types/**/*"] } diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 00000000..3542a8da --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + bundle: false, + clean: true, + dts: true, + entry: ['lib/**/*.ts'], + format: ['esm'], + outDir: 'dist', +}); diff --git a/types/estree.d.ts b/types/estree.d.ts new file mode 100644 index 00000000..93339cfa --- /dev/null +++ b/types/estree.d.ts @@ -0,0 +1,34 @@ +import { Program as EstreeProgram } from 'estree'; + +/** + * This file augments the `estree` types to include a couple of types that are not built-in to `estree` that we're using. + * This is necessary because the `estree` types are used by ESLint, and ESLint does not natively support + * TypeScript types. Since we're only using a couple of them, we can just add them here, rather than + * installing typescript estree types. + * + * This also adds support for the AST mutation that ESLint does to add parent nodes. + */ +declare module 'estree' { + interface BaseNode { + parent: Node; + } + + interface TSAsExpression extends BaseExpression { + type: 'TSAsExpression'; + expression: Expression | Identifier; + } + + interface TSExportAssignment extends BaseNode { + type: 'TSExportAssignment'; + expression: Expression; + } + + interface ExpressionMap { + TSAsExpression: TSAsExpression; + } + + interface NodeMap { + TSAsExpression: TSAsExpression; + TSExportAssignment: TSExportAssignment; + } +} diff --git a/vitest.config.ts b/vitest.config.ts index 56cb0fcc..5a9a60c0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,9 +2,9 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['tests/lib/**/*.js'], + include: ['tests/lib/**/*.ts'], exclude: ['tests/lib/fixtures/**'], - setupFiles: ['tests/utils/test-setup.js'], + setupFiles: ['tests/utils/test-setup.ts'], clearMocks: true, coverage: { all: true,