diff --git a/.changeset/breezy-insects-scream.md b/.changeset/breezy-insects-scream.md new file mode 100644 index 00000000..11ac60d8 --- /dev/null +++ b/.changeset/breezy-insects-scream.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-primer-react': minor +--- + +Add eslint rule for discouraging use of wildcard imports from @primer/react diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..c409f7b3 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +**/dist/** +**/node_modules/** diff --git a/docs/rules/no-wildcard-imports.md b/docs/rules/no-wildcard-imports.md new file mode 100644 index 00000000..12779abe --- /dev/null +++ b/docs/rules/no-wildcard-imports.md @@ -0,0 +1,25 @@ +# No Wildcard Imports + +## Rule Details + +This rule enforces that no wildcard imports are used from `@primer/react`. + +👎 Examples of **incorrect** code for this rule + +```jsx +import {Dialog} from '@primer/react/lib-esm/Dialog/Dialog' + +function ExampleComponent() { + return {/* ... */} +} +``` + +👍 Examples of **correct** code for this rule: + +```jsx +import {Dialog} from '@primer/react/experimental' + +function ExampleComponent() { + return {/* ... */} +} +``` diff --git a/src/index.js b/src/index.js index 5fe6b86f..bc284b02 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ module.exports = { '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'), + 'no-wildcard-imports': require('./rules/no-wildcard-imports'), 'no-unnecessary-components': require('./rules/no-unnecessary-components'), 'prefer-action-list-item-onselect': require('./rules/prefer-action-list-item-onselect'), }, diff --git a/src/rules/__tests__/no-wildcard-imports.test.js b/src/rules/__tests__/no-wildcard-imports.test.js new file mode 100644 index 00000000..dfe3fff4 --- /dev/null +++ b/src/rules/__tests__/no-wildcard-imports.test.js @@ -0,0 +1,363 @@ +'use strict' + +const {RuleTester} = require('eslint') +const rule = require('../no-wildcard-imports') + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, +}) + +ruleTester.run('no-wildcard-imports', rule, { + valid: [`import {Button} from '@primer/react'`], + invalid: [ + // Test unknown path from wildcard import + { + code: `import type {UnknownImport} from '@primer/react/lib-esm/unknown-path'`, + errors: [ + { + messageId: 'unknownWildcardImport', + }, + ], + }, + + // Test type import + { + code: `import type {SxProp} from '@primer/react/lib-esm/sx'`, + output: `import type {SxProp} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/sx', + }, + }, + ], + }, + + // Test multiple type imports + { + code: `import type {BetterSystemStyleObject, SxProp, BetterCssProperties} from '@primer/react/lib-esm/sx'`, + output: `import type {BetterSystemStyleObject, SxProp, BetterCssProperties} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/sx', + }, + }, + ], + }, + + // Test import alias + { + code: `import type {SxProp as RenamedSxProp} from '@primer/react/lib-esm/sx'`, + output: `import type {SxProp as RenamedSxProp} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/sx', + }, + }, + ], + }, + + // Test default import + { + code: `import useIsomorphicLayoutEffect from '@primer/react/lib-esm/useIsomorphicLayoutEffect'`, + output: `import {useIsomorphicLayoutEffect} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/useIsomorphicLayoutEffect', + }, + }, + ], + }, + + // Test multiple wildcard imports into single entrypoint + { + code: `import useResizeObserver from '@primer/react/lib-esm/hooks/useResizeObserver' + import useIsomorphicLayoutEffect from '@primer/react/lib-esm/useIsomorphicLayoutEffect'`, + output: `import {useResizeObserver} from '@primer/react' + import {useIsomorphicLayoutEffect} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/hooks/useResizeObserver', + }, + }, + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/useIsomorphicLayoutEffect', + }, + }, + ], + }, + + // Test migrations + + // Components -------------------------------------------------------------- + { + code: `import {ButtonBase} from '@primer/react/lib-esm/Button/ButtonBase'; +import type {ButtonBaseProps} from '@primer/react/lib-esm/Button/ButtonBase'`, + output: `import {ButtonBase} from '@primer/react' +import type {ButtonBaseProps} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/Button/ButtonBase', + }, + }, + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/Button/ButtonBase', + }, + }, + ], + }, + { + code: `import type {ButtonBaseProps} from '@primer/react/lib-esm/Button/types'`, + output: `import type {ButtonBaseProps} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/Button/types', + }, + }, + ], + }, + { + code: `import {Dialog} from '@primer/react/lib-esm/Dialog/Dialog'`, + output: `import {Dialog} from '@primer/react/experimental'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/Dialog/Dialog', + }, + }, + ], + }, + { + code: `import {SelectPanel} from '@primer/react/lib-esm/SelectPanel/SelectPanel'`, + output: `import {SelectPanel} from '@primer/react/experimental'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/SelectPanel/SelectPanel', + }, + }, + ], + }, + { + code: `import type {SelectPanelProps} from '@primer/react/lib-esm/SelectPanel/SelectPanel'`, + output: `import type {SelectPanelProps} from '@primer/react/experimental'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/SelectPanel/SelectPanel', + }, + }, + ], + }, + { + code: `import type {LabelColorOptions} from '@primer/react/lib-esm/Label/Label'`, + output: `import type {LabelColorOptions} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/Label/Label', + }, + }, + ], + }, + { + code: `import VisuallyHidden from '@primer/react/lib-esm/_VisuallyHidden'`, + output: `import {VisuallyHidden} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/_VisuallyHidden', + }, + }, + ], + }, + { + code: `import type {IssueLabelTokenProps} from '@primer/react/lib-esm/Token/IssueLabelToken'`, + output: `import type {IssueLabelTokenProps} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/Token/IssueLabelToken', + }, + }, + ], + }, + { + code: `import type {TokenSizeKeys} from '@primer/react/lib-esm/Token/TokenBase'`, + output: `import type {TokenSizeKeys} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/Token/TokenBase', + }, + }, + ], + }, + { + code: `import type {ItemProps} from '@primer/react/lib-esm/deprecated/ActionList'`, + output: `import type {ActionListItemProps} from '@primer/react/deprecated'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/deprecated/ActionList', + }, + }, + ], + }, + { + code: `import type {GroupedListProps} from '@primer/react/lib-esm/deprecated/ActionList/List'`, + output: `import type {ActionListGroupedListProps} from '@primer/react/deprecated'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/deprecated/ActionList/List', + }, + }, + ], + }, + { + code: `import {ItemInput} from '@primer/react/lib-esm/deprecated/ActionList/List'`, + output: `import {ActionListItemInput} from '@primer/react/deprecated'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/deprecated/ActionList/List', + }, + }, + ], + }, + { + code: `import type {ItemProps} from '@primer/react/lib-esm/deprecated/ActionList/Item'`, + output: `import type {ActionListItemProps} from '@primer/react/deprecated'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/deprecated/ActionList/Item', + }, + }, + ], + }, + + // Hooks ------------------------------------------------------------------- + + // @primer/react/lib-esm/useIsomorphicLayoutEffect + { + code: `import useIsomorphicLayoutEffect from '@primer/react/lib-esm/useIsomorphicLayoutEffect'`, + output: `import {useIsomorphicLayoutEffect} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/useIsomorphicLayoutEffect', + }, + }, + ], + }, + + // @primer/react/lib-esm/hooks/useResizeObserver + { + code: `import useResizeObserver from '@primer/react/lib-esm/hooks/useResizeObserver'`, + output: `import {useResizeObserver} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/hooks/useResizeObserver', + }, + }, + ], + }, + + // @primer/react/lib-esm/hooks/useProvidedRefOrCreate + { + code: `import useProvidedRefOrCreate from '@primer/react/lib-esm/hooks/useProvidedRefOrCreate'`, + output: `import {useProvidedRefOrCreate} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/hooks/useProvidedRefOrCreate', + }, + }, + ], + }, + + // @primer/react/lib-esm/hooks/useResponsiveValue + { + code: `import useResponsiveValue from '@primer/react/lib-esm/hooks/useResponsiveValue'`, + output: `import {useResponsiveValue} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/hooks/useResponsiveValue', + }, + }, + ], + }, + + // Utilities --------------------------------------------------------------- + + // @primer/react/lib-esm/sx + { + code: `import type {BetterSystemStyleObject, SxProp, BetterCssProperties} from '@primer/react/lib-esm/sx'`, + output: `import type {BetterSystemStyleObject, SxProp, BetterCssProperties} from '@primer/react'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/sx', + }, + }, + ], + }, + // @primer/react/lib-esm/FeatureFlags/DefaultFeatureFlags + { + code: `import {DefaultFeatureFlags} from '@primer/react/lib-esm/FeatureFlags/DefaultFeatureFlags'`, + output: `import {DefaultFeatureFlags} from '@primer/react/experimental'`, + errors: [ + { + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: '@primer/react/lib-esm/FeatureFlags/DefaultFeatureFlags', + }, + }, + ], + }, + ], +}) diff --git a/src/rules/no-wildcard-imports.js b/src/rules/no-wildcard-imports.js new file mode 100644 index 00000000..b57e3474 --- /dev/null +++ b/src/rules/no-wildcard-imports.js @@ -0,0 +1,368 @@ +'use strict' + +const url = require('../url') + +const wildcardImports = new Map([ + // Components + [ + '@primer/react/lib-esm/Button/ButtonBase', + [ + { + type: 'type', + name: 'ButtonBaseProps', + from: '@primer/react', + }, + { + name: 'ButtonBase', + from: '@primer/react', + }, + ], + ], + [ + '@primer/react/lib-esm/Button/types', + [ + { + type: 'type', + name: 'ButtonBaseProps', + from: '@primer/react', + }, + ], + ], + [ + '@primer/react/lib-esm/Dialog/Dialog', + [ + { + name: 'Dialog', + from: '@primer/react/experimental', + }, + ], + ], + [ + '@primer/react/lib-esm/SelectPanel/SelectPanel', + [ + { + name: 'SelectPanel', + from: '@primer/react/experimental', + }, + { + type: 'type', + name: 'SelectPanelProps', + from: '@primer/react/experimental', + }, + ], + ], + [ + '@primer/react/lib-esm/Label/Label', + [ + { + type: 'type', + name: 'LabelColorOptions', + from: '@primer/react', + }, + ], + ], + [ + '@primer/react/lib-esm/_VisuallyHidden', + [ + { + name: 'default', + from: '@primer/react', + as: 'VisuallyHidden', + }, + ], + ], + [ + '@primer/react/lib-esm/Token/IssueLabelToken', + [ + { + type: 'type', + name: 'IssueLabelTokenProps', + from: '@primer/react', + }, + ], + ], + [ + '@primer/react/lib-esm/Token/TokenBase', + [ + { + type: 'type', + name: 'TokenSizeKeys', + from: '@primer/react', + }, + ], + ], + [ + '@primer/react/lib-esm/deprecated/ActionList', + [ + { + type: 'type', + name: 'ItemProps', + from: '@primer/react/deprecated', + as: 'ActionListItemProps', + }, + ], + ], + [ + '@primer/react/lib-esm/deprecated/ActionList/List', + [ + { + type: 'type', + name: 'GroupedListProps', + from: '@primer/react/deprecated', + as: 'ActionListGroupedListProps', + }, + { + name: 'ItemInput', + from: '@primer/react/deprecated', + as: 'ActionListItemInput', + }, + ], + ], + [ + '@primer/react/lib-esm/deprecated/ActionList/Item', + [ + { + type: 'type', + name: 'ItemProps', + from: '@primer/react/deprecated', + as: 'ActionListItemProps', + }, + ], + ], + + // Hooks + [ + '@primer/react/lib-esm/useIsomorphicLayoutEffect', + [ + { + name: 'default', + from: '@primer/react', + as: 'useIsomorphicLayoutEffect', + }, + ], + ], + [ + '@primer/react/lib-esm/hooks/useResizeObserver', + [ + { + name: 'default', + from: '@primer/react', + as: 'useResizeObserver', + }, + ], + ], + [ + '@primer/react/lib-esm/hooks/useProvidedRefOrCreate', + [ + { + name: 'default', + from: '@primer/react', + as: 'useProvidedRefOrCreate', + }, + ], + ], + [ + '@primer/react/lib-esm/hooks/useResponsiveValue', + [ + { + name: 'default', + from: '@primer/react', + as: 'useResponsiveValue', + }, + ], + ], + + // Utilities + [ + '@primer/react/lib-esm/sx', + [ + { + type: 'type', + name: 'BetterSystemStyleObject', + from: '@primer/react', + }, + { + type: 'type', + name: 'SxProp', + from: '@primer/react', + }, + { + type: 'type', + name: 'BetterCssProperties', + from: '@primer/react', + }, + ], + ], + [ + '@primer/react/lib-esm/FeatureFlags/DefaultFeatureFlags', + [ + { + name: 'DefaultFeatureFlags', + from: '@primer/react/experimental', + }, + ], + ], + [ + '@primer/react/lib-esm/theme', + [ + { + name: 'default', + from: '@primer/react', + as: 'theme', + }, + ], + ], +]) + +/** + * @type {import('eslint').Rule.RuleModule} + */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Wildcard imports are discouraged. Import from a main entrypoint instead', + recommended: true, + url: url(module), + }, + fixable: true, + schema: [], + messages: { + unknownWildcardImport: + 'Wildcard imports from @primer/react are not allowed. Import from @primer/react, @primer/react/experimental, or @primer/react/deprecated instead', + knownWildcardImport: + 'Wildcard import {{ specifier }} from {{ wildcardEntrypoint }} are not allowed. Import from @primer/react, @primer/react/experimental, or @primer/react/deprecated instead', + wildcardMigration: + 'Wildcard imports from {{ wildcardEntrypoint }} are not allowed. Import from @primer/react, @primer/react/experimental, or @primer/react/deprecated instead', + }, + }, + create(context) { + return { + ImportDeclaration(node) { + if (!node.source.value.startsWith('@primer/react/lib-esm')) { + return + } + + const wildcardImportMigrations = wildcardImports.get(node.source.value) + if (!wildcardImportMigrations) { + context.report({ + node, + messageId: 'unknownWildcardImport', + }) + return + } + + /** + * Maps entrypoint to array of changes. This tuple contains the new + * imported name from the entrypoint along with the existing local name + * @type {Map>} + */ + const changes = new Map() + + for (const specifier of node.specifiers) { + const migration = wildcardImportMigrations.find(migration => { + if (specifier.type === 'ImportDefaultSpecifier') { + return migration.name === 'default' + } + return specifier.imported.name === migration.name + }) + + // If we do not have a migration, we should report an error even if we + // cannot autofix it + if (!migration) { + context.report({ + node, + messageId: 'unknownWildcardImport', + }) + return + } + + if (!changes.has(migration.from)) { + changes.set(migration.from, []) + } + + if (migration.as) { + changes.get(migration.from).push([migration.as, migration.as, migration.type]) + } else { + changes.get(migration.from).push([migration.name, specifier.local.name, migration.type]) + } + } + + if (changes.length === 0) { + return + } + + context.report({ + node, + messageId: 'wildcardMigration', + data: { + wildcardEntrypoint: node.source.value, + }, + *fix(fixer) { + for (const [entrypoint, importSpecifiers] of changes) { + const typeSpecifiers = importSpecifiers.filter(([, , type]) => { + return type === 'type' + }) + + // If all imports are type imports, emit emit as `import type {specifier} from '...'` + if (typeSpecifiers.length === importSpecifiers.length) { + const namedSpecifiers = typeSpecifiers.filter(([imported]) => { + return imported !== 'default' + }) + const defaultSpecifier = typeSpecifiers.find(([imported]) => { + return imported === 'default' + }) + + if (namedSpecifiers.length > 0 && !defaultSpecifier) { + const specifiers = namedSpecifiers.map(([imported, local]) => { + if (imported !== local) { + return `${imported} as ${local}` + } + return imported + }) + yield fixer.replaceText(node, `import type {${specifiers.join(', ')}} from '${entrypoint}'`) + } else if (namedSpecifiers.length > 0 && defaultSpecifier) { + yield fixer.replaceText( + node, + `import type ${defaultSpecifier[1]}, ${specifiers.join(', ')} from '${entrypoint}'`, + ) + } else if (defaultSpecifier && namedSpecifiers.length === 0) { + yield fixer.replaceText(node, `import type ${defaultSpecifier[1]} from '${entrypoint}'`) + } + + return + } + + // Otherwise, we have a mix of type and value imports to emit + const valueSpecifiers = importSpecifiers.filter(([, , type]) => { + return type !== 'type' + }) + + if (valueSpecifiers.length === 0) { + return + } + + const specifiers = valueSpecifiers.map(([imported, local]) => { + if (imported !== local) { + return `${imported} as ${local}` + } + return imported + }) + yield fixer.replaceText(node, `import {${specifiers.join(', ')}} from '${entrypoint}'`) + + if (typeSpecifiers.length > 0) { + const specifiers = valueSpecifiers.map(([imported, local]) => { + if (imported !== local) { + return `${imported} as ${local}` + } + return imported + }) + yield fixer.insertTextAfter(node, `import type {${specifiers.join(', ')}} from '${entrypoint}'`) + } + } + }, + }) + }, + } + }, +}