Repository: un-ts/eslint-plugin-import-x (ts)

Issue:
[feat] migrate `react-x/prefer-react-namespace-import` to `import-x/prefer-namespace-import` Rule with Custom Patterns
**Context**  
This rule was previously proposed here https://github.com/Rel1cx/eslint-react/issues/803. In the `eslint-react`'s context only includes `"react"` and `"react-dom"` packages, which are less sensitive to tree-shaking needs. We propose migrating this rule to a more generalized implementation under `import-x` to serve broader use cases.  

**Rationale**  
While modern bundlers support tree-shaking for both import styles, many projects still rely on `import * as mod from "source"` to enforce tree-shaking or just for consistent code style. This rule remains necessary for such codebases.  

**Proposed Rule**  
Implement a **`import-x/prefer-namespace-import`** rule with a `patterns` configuration to match scoped/prefixed packages (e.g., `@scope/*`, `prefix-*`) and auto-fix them to namespace imports.  

**Configuration Example**  
```jsonc  
{  
  "rules": {  
    "import-x/prefer-namespace-import": [  
      "error",  
      {  
        "patterns": ["foo"],  // Simple string match  
        // OR RegExp patterns  
        "patterns": ["/^@scope/", "/^prefix-/"]  
      }  
    ]  
  }  
}  
```  

**Auto-Fix Example**  
```ts  
// Before  
import foo from "foo";  
  
// After auto-fix  
import * as foo from "foo";  
```  


Pull request:
feat: port `react-x/prefer-react-namespace-import` into `prefer-namespace-import`
close #258
related #385

https://eslint-react.xyz/docs/rules/prefer-react-namespace-import
<!-- ELLIPSIS_HIDDEN -->


----

> [!IMPORTANT]
> Introduces `prefer-namespace-import` rule to enforce namespace imports for specified modules, with documentation and tests.
> 
>   - **New Rule**:
>     - Added `prefer-namespace-import` rule in `src/rules/prefer-namespace-import.ts` to enforce namespace imports for specified modules.
>     - Rule is automatically fixable and configurable with patterns for module matching.
>   - **Documentation**:
>     - Added `docs/rules/prefer-namespace-import.md` with rule details and examples.
>     - Updated `README.md` to include the new rule in the rules list.
>   - **Tests**:
>     - Added tests for `prefer-namespace-import` in `test/rules/prefer-namespace-import.spec.ts` to verify rule enforcement and autofix behavior.
>   - **Configuration**:
>     - Updated `src/index.ts` to include the new rule in the plugin's ruleset.
> 
> <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=un-ts%2Feslint-plugin-import-x&utm_source=github&utm_medium=referral)<sup> for 6e30b35190c3003fe325605b493a738c1c407f4a. You can [customize](https://app.ellipsis.dev/un-ts/settings/summaries) this summary. It will automatically update as commits are pushed.</sup>

----


<!-- ELLIPSIS_HIDDEN -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced a new ESLint rule enforcing namespace imports for modules matching user-defined patterns.  
- **Documentation**
  - Added detailed documentation and usage examples for the new rule.  
  - Updated the README to include the new rule entry.  
- **Tests**
  - Added comprehensive tests verifying rule enforcement and autofix functionality.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Patch:
diff --git a/.changeset/light-llamas-return.md b/.changeset/light-llamas-return.md
new file mode 100644
index 00000000..8bb008be
--- /dev/null
+++ b/.changeset/light-llamas-return.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-import-x": minor
+---
+
+feat: port [`react-x/prefer-react-namespace-import`](https://eslint-react.xyz/docs/rules/prefer-react-namespace-import) into `prefer-namespace-import`
diff --git a/README.md b/README.md
index 12f10e34..a0b8b586 100644
--- a/README.md
+++ b/README.md
@@ -298,26 +298,27 @@ settings:
 
 ### Style guide
 
-| Name                                                                             | Description                                                                 | 💼  | ⚠️          | 🚫  | 🔧  | 💡  | ❌  |
-| :------------------------------------------------------------------------------- | :-------------------------------------------------------------------------- | :-- | :---------- | :-- | :-- | :-- | :-- |
-| [consistent-type-specifier-style](docs/rules/consistent-type-specifier-style.md) | Enforce or ban the use of inline type-only markers for named imports.       |     |             |     | 🔧  |     |     |
-| [dynamic-import-chunkname](docs/rules/dynamic-import-chunkname.md)               | Enforce a leading comment with the webpackChunkName for dynamic imports.    |     |             |     |     | 💡  |     |
-| [exports-last](docs/rules/exports-last.md)                                       | Ensure all exports appear after other statements.                           |     |             |     |     |     |     |
-| [extensions](docs/rules/extensions.md)                                           | Ensure consistent use of file extension within the import path.             |     |             |     | 🔧  | 💡  |     |
-| [first](docs/rules/first.md)                                                     | Ensure all imports appear before other statements.                          |     |             |     | 🔧  |     |     |
-| [group-exports](docs/rules/group-exports.md)                                     | Prefer named exports to be grouped together in a single export declaration. |     |             |     |     |     |     |
-| [imports-first](docs/rules/imports-first.md)                                     | Replaced by `import-x/first`.                                               |     |             |     | 🔧  |     | ❌  |
-| [max-dependencies](docs/rules/max-dependencies.md)                               | Enforce the maximum number of dependencies a module can have.               |     |             |     |     |     |     |
-| [newline-after-import](docs/rules/newline-after-import.md)                       | Enforce a newline after import statements.                                  |     |             |     | 🔧  |     |     |
-| [no-anonymous-default-export](docs/rules/no-anonymous-default-export.md)         | Forbid anonymous values as default exports.                                 |     |             |     |     |     |     |
-| [no-default-export](docs/rules/no-default-export.md)                             | Forbid default exports.                                                     |     |             |     |     |     |     |
-| [no-duplicates](docs/rules/no-duplicates.md)                                     | Forbid repeated import of the same module in multiple places.               |     | ☑️ 🚸 ☑️ 🚸 |     | 🔧  |     |     |
-| [no-named-default](docs/rules/no-named-default.md)                               | Forbid named default exports.                                               |     |             |     |     |     |     |
-| [no-named-export](docs/rules/no-named-export.md)                                 | Forbid named exports.                                                       |     |             |     |     |     |     |
-| [no-namespace](docs/rules/no-namespace.md)                                       | Forbid namespace (a.k.a. "wildcard" `*`) imports.                           |     |             |     | 🔧  |     |     |
-| [no-unassigned-import](docs/rules/no-unassigned-import.md)                       | Forbid unassigned imports.                                                  |     |             |     |     |     |     |
-| [order](docs/rules/order.md)                                                     | Enforce a convention in module import order.                                |     |             |     | 🔧  |     |     |
-| [prefer-default-export](docs/rules/prefer-default-export.md)                     | Prefer a default export if module exports a single name or multiple names.  |     |             |     |     |     |     |
+| Name                                                                             | Description                                                                          | 💼  | ⚠️          | 🚫  | 🔧  | 💡  | ❌  |
+| :------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------- | :-- | :---------- | :-- | :-- | :-- | :-- |
+| [consistent-type-specifier-style](docs/rules/consistent-type-specifier-style.md) | Enforce or ban the use of inline type-only markers for named imports.                |     |             |     | 🔧  |     |     |
+| [dynamic-import-chunkname](docs/rules/dynamic-import-chunkname.md)               | Enforce a leading comment with the webpackChunkName for dynamic imports.             |     |             |     |     | 💡  |     |
+| [exports-last](docs/rules/exports-last.md)                                       | Ensure all exports appear after other statements.                                    |     |             |     |     |     |     |
+| [extensions](docs/rules/extensions.md)                                           | Ensure consistent use of file extension within the import path.                      |     |             |     | 🔧  | 💡  |     |
+| [first](docs/rules/first.md)                                                     | Ensure all imports appear before other statements.                                   |     |             |     | 🔧  |     |     |
+| [group-exports](docs/rules/group-exports.md)                                     | Prefer named exports to be grouped together in a single export declaration.          |     |             |     |     |     |     |
+| [imports-first](docs/rules/imports-first.md)                                     | Replaced by `import-x/first`.                                                        |     |             |     | 🔧  |     | ❌  |
+| [max-dependencies](docs/rules/max-dependencies.md)                               | Enforce the maximum number of dependencies a module can have.                        |     |             |     |     |     |     |
+| [newline-after-import](docs/rules/newline-after-import.md)                       | Enforce a newline after import statements.                                           |     |             |     | 🔧  |     |     |
+| [no-anonymous-default-export](docs/rules/no-anonymous-default-export.md)         | Forbid anonymous values as default exports.                                          |     |             |     |     |     |     |
+| [no-default-export](docs/rules/no-default-export.md)                             | Forbid default exports.                                                              |     |             |     |     |     |     |
+| [no-duplicates](docs/rules/no-duplicates.md)                                     | Forbid repeated import of the same module in multiple places.                        |     | ☑️ 🚸 ☑️ 🚸 |     | 🔧  |     |     |
+| [no-named-default](docs/rules/no-named-default.md)                               | Forbid named default exports.                                                        |     |             |     |     |     |     |
+| [no-named-export](docs/rules/no-named-export.md)                                 | Forbid named exports.                                                                |     |             |     |     |     |     |
+| [no-namespace](docs/rules/no-namespace.md)                                       | Forbid namespace (a.k.a. "wildcard" `*`) imports.                                    |     |             |     | 🔧  |     |     |
+| [no-unassigned-import](docs/rules/no-unassigned-import.md)                       | Forbid unassigned imports.                                                           |     |             |     |     |     |     |
+| [order](docs/rules/order.md)                                                     | Enforce a convention in module import order.                                         |     |             |     | 🔧  |     |     |
+| [prefer-default-export](docs/rules/prefer-default-export.md)                     | Prefer a default export if module exports a single name or multiple names.           |     |             |     |     |     |     |
+| [prefer-namespace-import](docs/rules/prefer-namespace-import.md)                 | Enforce using namespace imports for specific modules, like `react`/`react-dom`, etc. |     |             |     | 🔧  |     |     |
 
 <!-- end auto-generated rules list -->
 
diff --git a/docs/rules/prefer-namespace-import.md b/docs/rules/prefer-namespace-import.md
new file mode 100644
index 00000000..4ec80bc7
--- /dev/null
+++ b/docs/rules/prefer-namespace-import.md
@@ -0,0 +1,46 @@
+# import-x/prefer-namespace-import
+
+🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
+
+<!-- end auto-generated rule header -->
+
+Enforce using namespace imports for specific modules, like `react`/`react-dom`, etc.
+
+## Rule Details
+
+### rule schema
+
+```jsonc
+{
+  "import-x/prefer-namespace-import": [
+    "error", // or "off", "warn"
+    {
+      "patterns": [
+        // Exact match
+        "foo",
+        // RegExp
+        "/^prefix-/",
+      ],
+    },
+  ],
+}
+```
+
+### Config Options
+
+`patterns` is an array of strings or `RegExp` patterns that specify which modules should be imported using namespace imports.
+
+#### Example
+
+```js
+/*eslint import-x/prefer-namespace-import: [2, { patterns: ['react'] }]*/
+
+// bad
+import React from 'react'
+
+// good
+import * as React from 'react'
+
+// ignored
+import ReactDOM from 'react-dom'
+```
diff --git a/src/index.ts b/src/index.ts
index 7d6c71ea..493c8a8e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -65,6 +65,7 @@ import noUselessPathSegments from './rules/no-useless-path-segments.js'
 import noWebpackLoaderSyntax from './rules/no-webpack-loader-syntax.js'
 import order from './rules/order.js'
 import preferDefaultExport from './rules/prefer-default-export.js'
+import preferNamespaceImport from './rules/prefer-namespace-import.js'
 import unambiguous from './rules/unambiguous.js'
 // configs
 import type {
@@ -111,6 +112,7 @@ const rules = {
   order,
   'newline-after-import': newlineAfterImport,
   'prefer-default-export': preferDefaultExport,
+  'prefer-namespace-import': preferNamespaceImport,
   'no-default-export': noDefaultExport,
   'no-named-export': noNamedExport,
   'no-dynamic-require': noDynamicRequire,
diff --git a/src/rules/prefer-default-export.ts b/src/rules/prefer-default-export.ts
index 46760363..63b359a2 100644
--- a/src/rules/prefer-default-export.ts
+++ b/src/rules/prefer-default-export.ts
@@ -6,7 +6,7 @@ export interface Options {
   target?: 'single' | 'any'
 }
 
-type MessageId = 'single' | 'any'
+export type MessageId = 'single' | 'any'
 
 export default createRule<[Options?], MessageId>({
   name: 'prefer-default-export',
diff --git a/src/rules/prefer-namespace-import.ts b/src/rules/prefer-namespace-import.ts
new file mode 100644
index 00000000..87a97d19
--- /dev/null
+++ b/src/rules/prefer-namespace-import.ts
@@ -0,0 +1,110 @@
+import { createRule } from '../utils/index.js'
+
+export interface Options {
+  patterns?: readonly string[]
+}
+
+export type MessageId = 'preferNamespaceImport'
+
+export default createRule<[Options?], MessageId>({
+  name: 'prefer-namespace-import',
+  meta: {
+    type: 'problem',
+    docs: {
+      category: 'Style guide',
+      description:
+        'Enforce using namespace imports for specific modules, like `react`/`react-dom`, etc.',
+    },
+    fixable: 'code',
+    schema: [
+      {
+        type: 'object',
+        additionalProperties: false,
+        properties: {
+          patterns: {
+            type: 'array',
+            items: {
+              type: 'string',
+            },
+            uniqueItems: true,
+          },
+        },
+      },
+    ],
+    messages: {
+      preferNamespaceImport:
+        'Prefer importing {{specifier}} as \'import * as {{specifier}} from "{{source}}"\';',
+    },
+  },
+  defaultOptions: [],
+  create(context) {
+    const { patterns } = context.options[0] ?? {}
+    if (!patterns?.length) {
+      return {}
+    }
+    const regexps = patterns.map(toRegExp)
+    return {
+      ImportDefaultSpecifier(node) {
+        const importSource = node.parent.source.value
+        if (!regexps.some(exp => exp.test(importSource))) {
+          return
+        }
+        const defaultSpecifier = node.local.name
+        const hasOtherSpecifiers = node.parent.specifiers.length > 1
+        context.report({
+          messageId: 'preferNamespaceImport',
+          node: hasOtherSpecifiers ? node : node.parent,
+          data: {
+            source: importSource,
+            specifier: defaultSpecifier,
+          },
+          fix(fixer) {
+            const importDeclarationText = context.sourceCode.getText(
+              node.parent,
+            )
+            const localName = node.local.name
+            if (!hasOtherSpecifiers) {
+              return fixer.replaceText(node, `* as ${localName}`)
+            }
+            const isTypeImport = node.parent.importKind === 'type'
+            const importStringPrefix = `import${isTypeImport ? ' type' : ''}`
+            // remove the default specifier and prepend the namespace import specifier
+            const rightBraceIndex = importDeclarationText.indexOf('}') + 1
+            const specifiers = importDeclarationText.slice(
+              importDeclarationText.indexOf('{'),
+              rightBraceIndex,
+            )
+            const remainingText = importDeclarationText.slice(rightBraceIndex)
+            return fixer.replaceText(
+              node.parent,
+              [
+                `${importStringPrefix} * as ${localName} ${remainingText.trimStart()}`,
+                `${importStringPrefix} ${specifiers}${remainingText}`,
+              ].join('\n'),
+            )
+          },
+        })
+      },
+    }
+  },
+})
+
+/** Regular expression for matching a RegExp string. */
+const REGEXP_STR = /^\/(.+)\/([A-Za-z]*)$/u
+
+/**
+ * Convert a string to the `RegExp`. Normal strings (e.g. `"foo"`) is converted
+ * to `/^foo$/` of `RegExp`. Strings like `"/^foo/i"` are converted to `/^foo/i`
+ * of `RegExp`.
+ *
+ * @param string The string to convert.
+ * @returns Returns the `RegExp`.
+ * @see https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/src/utils/regexp.ts
+ */
+function toRegExp(string: string): { test(s: string): boolean } {
+  const [, pattern, flags = 'u'] = REGEXP_STR.exec(string) ?? []
+  if (pattern != null) {
+    return new RegExp(pattern, flags)
+  }
+  return { test: s => s === string }
+}


Test patch:
diff --git a/test/rules/prefer-namespace-import.spec.ts b/test/rules/prefer-namespace-import.spec.ts
new file mode 100644
index 00000000..0d100c6a
--- /dev/null
+++ b/test/rules/prefer-namespace-import.spec.ts
@@ -0,0 +1,162 @@
+import { RuleTester as TSESLintRuleTester } from '@typescript-eslint/rule-tester'
+import type { TSESLint } from '@typescript-eslint/utils'
+
+import {
+  createRuleTestCaseFunctions,
+  getNonDefaultParsers,
+  parsers,
+  testFilePath,
+} from '../utils.js'
+
+import { cjsRequire as require } from 'eslint-plugin-import-x'
+import rule from 'eslint-plugin-import-x/rules/prefer-namespace-import'
+
+const ruleTester = new TSESLintRuleTester()
+
+const { tValid, tInvalid } = createRuleTestCaseFunctions<typeof rule>()
+
+const options = [{ patterns: ['/^@scope/', '/^prefix-/', 'specific'] }] as const
+
+ruleTester.run('prefer-namespace-import', rule, {
+  valid: [
+    tValid({
+      code: `import * as Name from '@scope/name';`,
+    }),
+    tValid({
+      code: `import * as Name from 'prefix-name';`,
+    }),
+    tValid({
+      code: `import * as Name from 'specific';`,
+    }),
+    tValid({
+      code: `
+          import * as Name1 from '@scope/name';
+          import * as Name2 from 'prefix-name';
+          import * as Name2 from 'specific';
+          `,
+    }),
+    tValid({
+      code: `import Name from 'other-name';`,
+      options,
+    }),
+  ],
+  invalid: [
+    tInvalid({
+      code: `
+import Name1 from '@scope/name';
+import Name2 from 'prefix-name';
+import Name3 from 'prefix-name' with { type: 'json' };
+import Name4, { name4 } from 'prefix-name' with { type: 'json' };
+import Name5 from 'specific';
+import Name6 from 'other-name';
+          `,
+      errors: [
+        {
+          messageId: 'preferNamespaceImport',
+          data: { source: '@scope/name', specifier: 'Name1' },
+        },
+        {
+          messageId: 'preferNamespaceImport',
+          data: { source: 'prefix-name', specifier: 'Name2' },
+        },
+        {
+          messageId: 'preferNamespaceImport',
+          data: { source: 'prefix-name', specifier: 'Name3' },
+        },
+        {
+          messageId: 'preferNamespaceImport',
+          data: { source: 'prefix-name', specifier: 'Name4' },
+        },
+        {
+          messageId: 'preferNamespaceImport',
+          data: { source: 'specific', specifier: 'Name5' },
+        },
+      ],
+      options,
+      output: `
+import * as Name1 from '@scope/name';
+import * as Name2 from 'prefix-name';
+import * as Name3 from 'prefix-name' with { type: 'json' };
+import * as Name4 from 'prefix-name' with { type: 'json' };
+import { name4 } from 'prefix-name' with { type: 'json' };
+import * as Name5 from 'specific';
+import Name6 from 'other-name';
+          `,
+    }),
+  ],
+})
+
+describe('TypeScript', () => {
+  for (const parser of getNonDefaultParsers()) {
+    const parserConfig = {
+      languageOptions: {
+        ...(parser === parsers.BABEL && {
+          parser: require<TSESLint.Parser.LooseParserModule>(parsers.BABEL),
+        }),
+      },
+      filename: testFilePath('foo.ts'),
+    }
+
+    ruleTester.run('prefer-namespace-import', rule, {
+      valid: [
+        tValid({
+          code: `
+          import type * as Name1 from '@scope/name';
+          import type * as Name2 from 'prefix-name';
+          `,
+          ...parserConfig,
+        }),
+        tValid({
+          code: `import type Name from 'other-name';`,
+          options,
+          ...parserConfig,
+        }),
+      ],
+      invalid: [
+        tInvalid({
+          code: `
+import type Name1 from '@scope/name';
+import type Name2 from 'prefix-name';
+import type Name3 from 'prefix-name' with { type: 'json' };
+import Name4, { type name4 } from 'prefix-name' with { type: 'json' };
+import type Name5 from 'specific';
+import type Name6 from 'other-name';
+          `,
+          errors: [
+            {
+              messageId: 'preferNamespaceImport',
+              data: { source: '@scope/name', specifier: 'Name1' },
+            },
+            {
+              messageId: 'preferNamespaceImport',
+              data: { source: 'prefix-name', specifier: 'Name2' },
+            },
+            {
+              messageId: 'preferNamespaceImport',
+              data: { source: 'prefix-name', specifier: 'Name3' },
+            },
+            {
+              messageId: 'preferNamespaceImport',
+              data: { source: 'prefix-name', specifier: 'Name4' },
+            },
+            {
+              messageId: 'preferNamespaceImport',
+              data: { source: 'specific', specifier: 'Name5' },
+            },
+          ],
+          options,
+          output: `
+import type * as Name1 from '@scope/name';
+import type * as Name2 from 'prefix-name';
+import type * as Name3 from 'prefix-name' with { type: 'json' };
+import * as Name4 from 'prefix-name' with { type: 'json' };
+import { type name4 } from 'prefix-name' with { type: 'json' };
+import type * as Name5 from 'specific';
+import type Name6 from 'other-name';
+          `,
+          ...parserConfig,
+        }),
+      ],
+    })
+  }
+})
diff --git a/test/utils.ts b/test/utils.ts
index 83a758f2..cd3fd2cb 100644
--- a/test/utils.ts
+++ b/test/utils.ts
@@ -56,14 +56,6 @@ function createRuleTestCase<TTestCase extends TSESLintValidTestCase<unknown[]>>(
   return {
     filename: TEST_FILENAME,
     ...t,
-    languageOptions: {
-      ...t.languageOptions,
-      parserOptions: {
-        sourceType: 'module',
-        ecmaVersion: 9,
-        ...t.languageOptions?.parserOptions,
-      },
-    },
   }
 }