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}'`)
+ }
+ }
+ },
+ })
+ },
+ }
+ },
+}