From e2cab872c265f4211750436fad32fe5fd8927c5c Mon Sep 17 00:00:00 2001 From: Josh Black Date: Fri, 16 Aug 2024 10:03:45 -0500 Subject: [PATCH] feat: add use-deprecated-from-deprecated (#204) * feat: add use-deprecated-from-deprecated * chore: add changeset --- .changeset/dry-tips-burn.md | 5 + docs/rules/use-deprecated-from-deprecated.md | 25 ++++ src/index.js | 1 + .../use-deprecated-from-deprecated.test.js | 66 +++++++++ src/rules/use-deprecated-from-deprecated.js | 130 ++++++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 .changeset/dry-tips-burn.md create mode 100644 docs/rules/use-deprecated-from-deprecated.md create mode 100644 src/rules/__tests__/use-deprecated-from-deprecated.test.js create mode 100644 src/rules/use-deprecated-from-deprecated.js diff --git a/.changeset/dry-tips-burn.md b/.changeset/dry-tips-burn.md new file mode 100644 index 00000000..f867a420 --- /dev/null +++ b/.changeset/dry-tips-burn.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-primer-react': minor +--- + +Add use-deprecated-from-deprecated rule diff --git a/docs/rules/use-deprecated-from-deprecated.md b/docs/rules/use-deprecated-from-deprecated.md new file mode 100644 index 00000000..01036ded --- /dev/null +++ b/docs/rules/use-deprecated-from-deprecated.md @@ -0,0 +1,25 @@ +# Use Deprecated from Deprecated + +## Rule Details + +This rule enforces the usage of deprecated imports from `@primer/react/deprecated`. + +👎 Examples of **incorrect** code for this rule + +```jsx +import {Dialog} from '@primer/react' + +function ExampleComponent() { + return {/* ... */} +} +``` + +👍 Examples of **correct** code for this rule: + +```jsx +import {Dialog} from '@primer/react/deprecated' + +function ExampleComponent() { + return {/* ... */} +} +``` diff --git a/src/index.js b/src/index.js index 20e1fb01..d9ce57df 100644 --- a/src/index.js +++ b/src/index.js @@ -10,6 +10,7 @@ module.exports = { 'a11y-link-in-text-block': require('./rules/a11y-link-in-text-block'), 'a11y-remove-disable-tooltip': require('./rules/a11y-remove-disable-tooltip'), 'a11y-use-next-tooltip': require('./rules/a11y-use-next-tooltip'), + 'use-deprecated-from-deprecated': require('./rules/use-deprecated-from-deprecated'), }, configs: { recommended: require('./configs/recommended'), diff --git a/src/rules/__tests__/use-deprecated-from-deprecated.test.js b/src/rules/__tests__/use-deprecated-from-deprecated.test.js new file mode 100644 index 00000000..2b3cbd98 --- /dev/null +++ b/src/rules/__tests__/use-deprecated-from-deprecated.test.js @@ -0,0 +1,66 @@ +'use strict' + +const {RuleTester} = require('eslint') +const rule = require('../use-deprecated-from-deprecated') + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, +}) + +ruleTester.run('use-deprecated-from-deprecated', rule, { + valid: [], + invalid: [ + // Single deprecated import + { + code: `import {Tooltip} from '@primer/react'`, + output: `import {Tooltip} from '@primer/react/deprecated'`, + errors: ['Import deprecated components from @primer/react/deprecated'], + }, + + // Single deprecated import with existing deprecated entrypoint + { + code: `import {Tooltip} from '@primer/react' +import {Dialog} from '@primer/react/deprecated'`, + output: `\nimport {Dialog, Tooltip} from '@primer/react/deprecated'`, + errors: ['Import deprecated components from @primer/react/deprecated'], + }, + + // Multiple deprecated imports + { + code: `import {Dialog, Tooltip} from '@primer/react'`, + output: `import {Dialog, Tooltip} from '@primer/react/deprecated'`, + errors: ['Import deprecated components from @primer/react/deprecated'], + }, + + // Mixed deprecated and non-deprecated imports + { + code: `import {Button, Tooltip} from '@primer/react'`, + output: `import {Button, } from '@primer/react' +import {Tooltip} from '@primer/react/deprecated'`, + errors: ['Import deprecated components from @primer/react/deprecated'], + }, + + // Mixed deprecated and non-deprecated imports with existing deprecated + { + code: `import {Button, Tooltip} from '@primer/react' +import {Dialog} from '@primer/react/deprecated'`, + output: `import {Button, } from '@primer/react' +import {Dialog, Tooltip} from '@primer/react/deprecated'`, + errors: ['Import deprecated components from @primer/react/deprecated'], + }, + + // Multiple mixed deprecated and non-deprecated imports + { + code: `import {Button, Dialog, Tooltip} from '@primer/react'`, + output: `import {Button, } from '@primer/react' +import {Dialog, Tooltip} from '@primer/react/deprecated'`, + errors: ['Import deprecated components from @primer/react/deprecated'], + }, + ], +}) diff --git a/src/rules/use-deprecated-from-deprecated.js b/src/rules/use-deprecated-from-deprecated.js new file mode 100644 index 00000000..5b6c220c --- /dev/null +++ b/src/rules/use-deprecated-from-deprecated.js @@ -0,0 +1,130 @@ +'use strict' + +const url = require('../url') + +const components = [ + { + identifier: 'Dialog', + entrypoint: '@primer/react', + }, + { + identifier: 'Octicon', + entrypoint: '@primer/react', + }, + { + identifier: 'Pagehead', + entrypoint: '@primer/react', + }, + { + identifier: 'TabNav', + entrypoint: '@primer/react', + }, + { + identifier: 'Tooltip', + entrypoint: '@primer/react', + }, +] + +const entrypoints = new Map() + +for (const component of components) { + if (!entrypoints.has(component.entrypoint)) { + entrypoints.set(component.entrypoint, new Set()) + } + entrypoints.get(component.entrypoint).add(component.identifier) +} + +/** + * @type {import('eslint').Rule.RuleModule} + */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Use deprecated components from the `@primer/react/deprecated` entrypoint', + recommended: true, + url: url(module), + }, + fixable: true, + schema: [], + }, + create(context) { + const sourceCode = context.getSourceCode() + + return { + ImportDeclaration(node) { + if (!entrypoints.has(node.source.value)) { + return + } + + const entrypoint = entrypoints.get(node.source.value) + const deprecated = node.specifiers.filter(specifier => { + return entrypoint.has(specifier.imported.name) + }) + + if (deprecated.length === 0) { + return + } + + const deprecatedEntrypoint = node.parent.body.find(node => { + if (node.type !== 'ImportDeclaration') { + return false + } + + return node.source.value === '@primer/react/deprecated' + }) + + // All imports are deprecated + if (deprecated.length === node.specifiers.length) { + context.report({ + node, + message: 'Import deprecated components from @primer/react/deprecated', + *fix(fixer) { + if (deprecatedEntrypoint) { + const lastSpecifier = deprecatedEntrypoint.specifiers[deprecatedEntrypoint.specifiers.length - 1] + + yield fixer.remove(node) + yield fixer.insertTextAfter( + lastSpecifier, + `, ${node.specifiers.map(specifier => specifier.imported.name).join(', ')}`, + ) + } else { + yield fixer.replaceText(node.source, `'@primer/react/deprecated'`) + } + }, + }) + } else { + // There is a mix of deprecated and non-deprecated imports + context.report({ + node, + message: 'Import deprecated components from @primer/react/deprecated', + *fix(fixer) { + for (const specifier of deprecated) { + yield fixer.remove(specifier) + const comma = sourceCode.getTokenAfter(specifier) + if (comma.value === ',') { + yield fixer.remove(comma) + } + } + + if (deprecatedEntrypoint) { + const lastSpecifier = deprecatedEntrypoint.specifiers[deprecatedEntrypoint.specifiers.length - 1] + yield fixer.insertTextAfter( + lastSpecifier, + `, ${deprecated.map(specifier => specifier.imported.name).join(', ')}`, + ) + } else { + yield fixer.insertTextAfter( + node, + `\nimport {${deprecated + .map(specifier => specifier.imported.name) + .join(', ')}} from '@primer/react/deprecated'`, + ) + } + }, + }) + } + }, + } + }, +}