Repository: un-ts/eslint-plugin-import-x
Pull request #169 (2024-10-13 00:31:33)

`extensions`: added `checkTypeImports` option
upstream has implemented this in https://github.com/import-js/eslint-plugin-import/commit/d225176343d491db07a1c9e6e521ea90f169c928

basically ported the feature and test cases from there

idk how to add this to changelog, would be nice if someone did that for me :>

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 18adea462e3180a18343c77d2b9d6cda064e57c0

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/teidesu/eslint-plugin-import-x/new/check-type-imports?filename=.changeset/chilled-shrimps-tickle.md&value=---%0A%22eslint-plugin-import-x%22%3A%20patch%0A---%0A%0A%60extensions%60%3A%20added%20%60checkTypeImports%60%20option%0A)


codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/un-ts/eslint-plugin-import-x/pr/169/builds/550590) or the icon next to each commit SHA.

Patch:
diff --git a/.changeset/fast-carpets-teach.md b/.changeset/fast-carpets-teach.md
new file mode 100644
index 00000000..b7868cd3
--- /dev/null
+++ b/.changeset/fast-carpets-teach.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-import-x": patch
+---
+
+Perf: avoid regexp during parser choosing
diff --git a/.changeset/hot-jokes-grab.md b/.changeset/hot-jokes-grab.md
new file mode 100644
index 00000000..6e92068b
--- /dev/null
+++ b/.changeset/hot-jokes-grab.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-import-x": patch
+---
+
+Add extra guard for rule `no-named-as-default`. A few guards are borrowed from https://github.com/import-js/eslint-plugin-import/pull/3032, but we don't sync the rest of changes from upstream since we have already implemented a way more performant check.
diff --git a/.changeset/old-years-rest.md b/.changeset/old-years-rest.md
new file mode 100644
index 00000000..9eb51373
--- /dev/null
+++ b/.changeset/old-years-rest.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-import-x": patch
+---
+
+More test cases for `no-named-export` and `no-defualt-export` rule specifically with non-module `sourceType`
diff --git a/.changeset/orange-dryers-mix.md b/.changeset/orange-dryers-mix.md
new file mode 100644
index 00000000..e1c7fc1d
--- /dev/null
+++ b/.changeset/orange-dryers-mix.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-import-x": patch
+---
+
+Fix `export` when there is only one `TSDeclareFunction` (https://github.com/import-js/eslint-plugin-import/pull/3065)
diff --git a/.changeset/shaggy-mayflies-applaud.md b/.changeset/shaggy-mayflies-applaud.md
new file mode 100644
index 00000000..f263fd3a
--- /dev/null
+++ b/.changeset/shaggy-mayflies-applaud.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-import-x": patch
+---
+
+Prevent `ExportMap`'s cache is being tainted by incompatible parser (e.g. old `babel-eslint`). The cache is now skipped w/ incompatible parsers, which might introduce performance impacts only for those who are using incompatible parsers. (https://github.com/import-js/eslint-plugin-import/pull/3062)
diff --git a/.changeset/spicy-mangos-pull.md b/.changeset/spicy-mangos-pull.md
new file mode 100644
index 00000000..397ace3a
--- /dev/null
+++ b/.changeset/spicy-mangos-pull.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-import-x": patch
+---
+
+Docs: fix a few typos here and there
diff --git a/.changeset/tidy-crews-sit.md b/.changeset/tidy-crews-sit.md
new file mode 100644
index 00000000..a32fbbf0
--- /dev/null
+++ b/.changeset/tidy-crews-sit.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-import-x": minor
+---
+
+Add new rule option `checkTypedImports` for `extensions`, backports https://github.com/import-js/eslint-plugin-import/pull/2817
diff --git a/.changeset/tough-elephants-divide.md b/.changeset/tough-elephants-divide.md
new file mode 100644
index 00000000..8eb27cc3
--- /dev/null
+++ b/.changeset/tough-elephants-divide.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-import-x": patch
+---
+
+Properly fix espree parser w/ ESLint Flat Config
diff --git a/docs/rules/extensions.md b/docs/rules/extensions.md
index 2242a57c..097006f8 100644
--- a/docs/rules/extensions.md
+++ b/docs/rules/extensions.md
@@ -56,6 +56,8 @@ For example, `["error", "never", { "svg": "always" }]` would require that all ex
 In that case, if you still want to specify extensions, you can do so inside the **pattern** property.
 Default value of `ignorePackages` is `false`.
 
+By default, `import type` and `export type` style imports/exports are ignored. If you want to check them as well, you can set the `checkTypeImports` option to `true`.
+
 ### Exception
 
 When disallowing the use of certain extensions this rule makes an exception and allows the use of extension when the file would not be resolvable without extension.
@@ -104,6 +106,13 @@ import express from 'express/index'
 import * as path from 'path'
 ```
 
+The following patterns are considered problems when the configuration is set to "never" and the option "checkTypeImports" is set to `true`:
+
+```js
+import type { Foo } from './foo.ts';
+export type { Foo } from './foo.ts';
+```
+
 The following patterns are considered problems when configuration set to "always":
 
 ```js
@@ -166,6 +175,13 @@ import express from 'express'
 import foo from '@/foo'
 ```
 
+The following patterns are considered problems when the configuration is set to "always" and the option "checkTypeImports" is set to `true`:
+
+```js
+import type { Foo } from './foo';
+export type { Foo } from './foo';
+```
+
 ## When Not To Use It
 
 If you are not concerned about a consistent usage of file extension.
diff --git a/docs/rules/no-relative-packages.md b/docs/rules/no-relative-packages.md
index 8a4efc70..2d990ca5 100644
--- a/docs/rules/no-relative-packages.md
+++ b/docs/rules/no-relative-packages.md
@@ -6,8 +6,7 @@
 
 Use this rule to prevent importing packages through relative paths.
 
-It's useful in Yarn/Lerna workspaces, were it's possible to import a sibling
-package using `../package` relative path, while direct `package` is the correct one.
+It's useful in a monorepo setup, where it's possible to import a sibling package using `../package` relative path, while direct `package` is the correct one.
 
 ## Examples
 
diff --git a/docs/rules/no-restricted-paths.md b/docs/rules/no-restricted-paths.md
index a2f6f015..f199f47c 100644
--- a/docs/rules/no-restricted-paths.md
+++ b/docs/rules/no-restricted-paths.md
@@ -5,7 +5,7 @@
 Some projects contain files which are not always meant to be executed in the same environment.
 For example consider a web application that contains specific code for the server and some specific code for the browser/client. In this case you don’t want to import server-only files in your client code.
 
-In order to prevent such scenarios this rule allows you to define restricted zones where you can forbid files from imported if they match a specific path.
+In order to prevent such scenarios this rule allows you to define restricted zones where you can forbid files from being imported if they match a specific path.
 
 ## Rule Details
 
diff --git a/src/rules/export.ts b/src/rules/export.ts
index 82492c4c..4eb28b42 100644
--- a/src/rules/export.ts
+++ b/src/rules/export.ts
@@ -1,3 +1,4 @@
+import { AST_NODE_TYPES } from '@typescript-eslint/utils'
 import type { TSESTree } from '@typescript-eslint/utils'
 
 import {
@@ -30,37 +31,24 @@ const rootProgram = 'root'
 const tsTypePrefix = 'type:'
 
 /**
- * Detect function overloads like:
+ * Remove function overloads like:
+ *
  * ```ts
  * export function foo(a: number);
  * export function foo(a: string);
  * export function foo(a: number|string) { return a; }
  * ```
  */
-function isTypescriptFunctionOverloads(nodes: Set<TSESTree.Node>) {
-  const nodesArr = [...nodes]
-
-  const idents = nodesArr.flatMap(node =>
-    'declaration' in node && node.declaration?.type === 'TSDeclareFunction'
-      ? node.declaration.id!.name
-      : [],
-  )
-
-  if (new Set(idents).size !== idents.length) {
-    return true
-  }
-
-  const types = new Set(nodesArr.map(node => `${node.parent!.type}` as const))
-  if (!types.has('TSDeclareFunction')) {
-    return false
-  }
-  if (types.size === 1) {
-    return true
-  }
-  if (types.size === 2 && types.has('FunctionDeclaration')) {
-    return true
+function removeTypescriptFunctionOverloads(nodes: Set<TSESTree.Node>) {
+  for (const node of nodes) {
+    const declType =
+      node.type === AST_NODE_TYPES.ExportDefaultDeclaration
+        ? node.declaration.type
+        : node.parent?.type
+    if (declType === AST_NODE_TYPES.TSDeclareFunction) {
+      nodes.delete(node)
+    }
   }
-  return false
 }
 
 /**
@@ -277,14 +265,17 @@ export = createRule<[], MessageId>({
       'Program:exit'() {
         for (const [, named] of namespace) {
           for (const [name, nodes] of named) {
+            if (nodes.size === 0) {
+              continue
+            }
+
+            removeTypescriptFunctionOverloads(nodes)
+
             if (nodes.size <= 1) {
               continue
             }
 
-            if (
-              isTypescriptFunctionOverloads(nodes) ||
-              isTypescriptNamespaceMerging(nodes)
-            ) {
+            if (isTypescriptNamespaceMerging(nodes)) {
               continue
             }
 
diff --git a/src/rules/extensions.ts b/src/rules/extensions.ts
index a1450d06..4a3d7ad1 100644
--- a/src/rules/extensions.ts
+++ b/src/rules/extensions.ts
@@ -27,6 +27,9 @@ const properties = {
     ignorePackages: {
       type: 'boolean' as const,
     },
+    checkTypeImports: {
+      type: 'boolean' as const,
+    },
   },
 }
 
@@ -38,6 +41,7 @@ type NormalizedOptions = {
   defaultConfig?: DefaultConfig
   pattern?: Record<FileExtension, DefaultConfig>
   ignorePackages?: boolean
+  checkTypeImports?: boolean
 }
 
 type Options = DefaultConfig | NormalizedOptions
@@ -47,6 +51,7 @@ function buildProperties(context: RuleContext<MessageId, Options[]>) {
     defaultConfig: 'never',
     pattern: {},
     ignorePackages: false,
+    checkTypeImports: false,
   }
 
   for (const obj of context.options) {
@@ -57,7 +62,11 @@ function buildProperties(context: RuleContext<MessageId, Options[]>) {
     }
 
     // If this is not the new structure, transfer all props to result.pattern
-    if (obj.pattern === undefined && obj.ignorePackages === undefined) {
+    if (
+      obj.pattern === undefined &&
+      obj.ignorePackages === undefined &&
+      obj.checkTypeImports === undefined
+    ) {
       Object.assign(result.pattern, obj)
       continue
     }
@@ -71,6 +80,10 @@ function buildProperties(context: RuleContext<MessageId, Options[]>) {
     if (obj.ignorePackages !== undefined) {
       result.ignorePackages = obj.ignorePackages
     }
+
+    if (obj.checkTypeImports !== undefined) {
+      result.checkTypeImports = obj.checkTypeImports
+    }
   }
 
   if (result.defaultConfig === 'ignorePackages') {
@@ -213,8 +226,9 @@ export = createRule<Options[], MessageId>({
         if (!extension || !importPath.endsWith(`.${extension}`)) {
           // ignore type-only imports and exports
           if (
-            ('importKind' in node && node.importKind === 'type') ||
-            ('exportKind' in node && node.exportKind === 'type')
+            !props.checkTypeImports &&
+            (('importKind' in node && node.importKind === 'type') ||
+              ('exportKind' in node && node.exportKind === 'type'))
           ) {
             return
           }
diff --git a/src/rules/no-named-as-default.ts b/src/rules/no-named-as-default.ts
index 673f44e8..d2cd1190 100644
--- a/src/rules/no-named-as-default.ts
+++ b/src/rules/no-named-as-default.ts
@@ -49,6 +49,17 @@ export = createRule<[], MessageId>({
           return
         }
 
+        if (!exportMapOfImported.hasDefault) {
+          // The rule is triggered for default imports/exports, so if the imported module has no default
+          // this means we're dealing with incorrect source code anyway
+          return
+        }
+
+        if (!exportMapOfImported.has(nameValue)) {
+          // The name used locally for the default import was not even used in the imported module.
+          return
+        }
+
         if (
           exportMapOfImported.exports.has('default') &&
           exportMapOfImported.exports.has(nameValue)
diff --git a/src/utils/export-map.ts b/src/utils/export-map.ts
index 1b3efb2e..22c74974 100644
--- a/src/utils/export-map.ts
+++ b/src/utils/export-map.ts
@@ -128,7 +128,11 @@ export class ExportMap {
 
     exportMap.mtime = stats().mtime.valueOf()
 
-    exportCache.set(cacheKey, exportMap)
+    // If the visitor keys were not populated, then we shouldn't save anything to the cache,
+    // since the parse results may not be reliable.
+    if (exportMap.visitorKeys) {
+      exportCache.set(cacheKey, exportMap)
+    }
 
     return exportMap
   }
diff --git a/src/utils/parse.ts b/src/utils/parse.ts
index f1ce213a..61b94383 100644
--- a/src/utils/parse.ts
+++ b/src/utils/parse.ts
@@ -29,7 +29,7 @@ function withoutProjectParserOptions(
 const log = debug('eslint-plugin-import-x:parse')
 
 function keysFromParser(
-  parserPath: string | TSESLint.Parser.ParserModule,
+  _parserPath: string | TSESLint.Parser.ParserModule,
   parserInstance: TSESLint.Parser.ParserModule,
   parsedResult?: TSESLint.Parser.ParseResult,
 ) {
@@ -37,8 +37,14 @@ function keysFromParser(
   if (parsedResult && parsedResult.visitorKeys) {
     return parsedResult.visitorKeys
   }
-  if (typeof parserPath === 'string' && /.*espree.*/.test(parserPath)) {
-    // @ts-expect-error - no type yet
+
+  // The espree parser doesn't have the `parseForESLint` function, so we don't ended up with a
+  // `parsedResult` here, but it does expose the visitor keys on the parser instance that we can use.
+  if (
+    parserInstance &&
+    'VisitorKeys' in parserInstance &&
+    parserInstance.VisitorKeys
+  ) {
     return parserInstance.VisitorKeys as TSESLint.SourceCode.VisitorKeys
   }
   return null


Test patch:
diff --git a/test/fixtures/order-redirect-scoped/module/package.json b/test/fixtures/order-redirect-scoped/module/package.json
index 6c4325e8..2ee43c26 100644
--- a/test/fixtures/order-redirect-scoped/module/package.json
+++ b/test/fixtures/order-redirect-scoped/module/package.json
@@ -1,5 +1,5 @@
 {
-  "name": "order-redirect-module",
+  "name": "order-redirect-module-scoped",
   "private": true,
   "main": "../other-module/file.js"
 }
diff --git a/test/rules/export.spec.ts b/test/rules/export.spec.ts
index 10df5bd1..f9ce211e 100644
--- a/test/rules/export.spec.ts
+++ b/test/rules/export.spec.ts
@@ -58,6 +58,14 @@ ruleTester.run('export', rule, {
         }
       `,
     },
+    {
+      code: `
+      export default function foo(param: string): boolean;
+      export default function foo(param: string, param1?: number): boolean {
+        return param && param1;
+      }
+    `,
+    },
   ],
 
   invalid: [
@@ -160,6 +168,15 @@ ruleTester.run('export', rule, {
         },
       },
     }),
+
+    test({
+      code: `
+        export default function a(): void;
+        export default function a() {}
+        export { x as default };
+      `,
+      errors: ['Multiple default exports.', 'Multiple default exports.'],
+    }),
   ],
 })
 
@@ -510,7 +527,7 @@ describe('TypeScript', () => {
       }),
       test({
         code: `
-            export function Foo();
+            export function Foo() { };
             export class Foo { }
             export namespace Foo { }
           `,
@@ -529,7 +546,7 @@ describe('TypeScript', () => {
       test({
         code: `
             export const Foo = 'bar';
-            export function Foo();
+            export function Foo() { };
             export namespace Foo { }
           `,
         errors: [
diff --git a/test/rules/extensions.spec.ts b/test/rules/extensions.spec.ts
index 871c1c5a..81b172c0 100644
--- a/test/rules/extensions.spec.ts
+++ b/test/rules/extensions.spec.ts
@@ -5,6 +5,15 @@ import { test, testFilePath } from '../utils'
 import rule from 'eslint-plugin-import-x/rules/extensions'
 
 const ruleTester = new TSESLintRuleTester()
+const ruleTesterWithTypeScriptImports = new TSESLintRuleTester({
+  settings: {
+    'import/resolver': {
+      typescript: {
+        alwaysTryTypes: true,
+      },
+    },
+  },
+})
 
 ruleTester.run('extensions', rule, {
   valid: [
@@ -694,6 +703,58 @@ describe('TypeScript', () => {
           { ts: 'never', tsx: 'never', js: 'never', jsx: 'never' },
         ],
       }),
+      test({
+        code: 'import type T from "./typescript-declare";',
+        errors: ['Missing file extension for "./typescript-declare"'],
+        options: [
+          'always',
+          {
+            ts: 'never',
+            tsx: 'never',
+            js: 'never',
+            jsx: 'never',
+            checkTypeImports: true,
+          },
+        ],
+      }),
+      test({
+        code: 'export type { MyType } from "./typescript-declare";',
+        errors: ['Missing file extension for "./typescript-declare"'],
+        options: [
+          'always',
+          {
+            ts: 'never',
+            tsx: 'never',
+            js: 'never',
+            jsx: 'never',
+            checkTypeImports: true,
+          },
+        ],
+      }),
+    ],
+  })
+  ruleTesterWithTypeScriptImports.run('extensions', rule, {
+    valid: [
+      test({
+        code: 'import type { MyType } from "./typescript-declare.ts";',
+        options: ['always', { checkTypeImports: true }],
+      }),
+      test({
+        code: 'export type { MyType } from "./typescript-declare.ts";',
+        options: ['always', { checkTypeImports: true }],
+      }),
+    ],
+    invalid: [
+      test({
+        code: 'import type { MyType } from "./typescript-declare";',
+        errors: ['Missing file extension for "./typescript-declare"'],
+        options: ['always', { checkTypeImports: true }],
+      }),
+      test({
+        code: 'export type { MyType } from "./typescript-declare";',
+        errors: ['Missing file extension for "./typescript-declare"'],
+        options: ['always', { checkTypeImports: true }],
+      }),
     ],
   })
 })
diff --git a/test/rules/no-cycle.spec.ts b/test/rules/no-cycle.spec.ts
index 3ed48947..3727c94f 100644
--- a/test/rules/no-cycle.spec.ts
+++ b/test/rules/no-cycle.spec.ts
@@ -11,8 +11,8 @@ const error = (message: string) => ({ message })
 
 const test = <T extends ValidTestCase>(def: T) =>
   _test({
-    ...def,
     filename: testFilePath('./cycles/depth-zero.js'),
+    ...def,
   })
 
 ruleTester.run('no-cycle', rule, {
diff --git a/test/rules/no-default-export.spec.ts b/test/rules/no-default-export.spec.ts
index e90d57f8..e38ce7b6 100644
--- a/test/rules/no-default-export.spec.ts
+++ b/test/rules/no-default-export.spec.ts
@@ -8,6 +8,17 @@ const ruleTester = new TSESLintRuleTester()
 
 ruleTester.run('no-default-export', rule, {
   valid: [
+    test({
+      code: 'module.exports = function foo() {}',
+      languageOptions: {
+        parserOptions: {
+          sourceType: 'script',
+        },
+      },
+    }),
+    test({
+      code: 'module.exports = function foo() {}',
+    }),
     test({
       code: `
         export const foo = 'foo';
diff --git a/test/rules/no-named-export.spec.ts b/test/rules/no-named-export.spec.ts
index 444615c2..55829764 100644
--- a/test/rules/no-named-export.spec.ts
+++ b/test/rules/no-named-export.spec.ts
@@ -8,6 +8,17 @@ const ruleTester = new TSESLintRuleTester()
 
 ruleTester.run('no-named-export', rule, {
   valid: [
+    test({
+      code: 'module.export.foo = function () {}',
+    }),
+    test({
+      code: 'module.exports = function foo() {}',
+      languageOptions: {
+        parserOptions: {
+          sourceType: 'script',
+        },
+      },
+    }),
     test({
       code: 'export default function bar() {};',
     }),
diff --git a/test/utils/export-map.spec.ts b/test/utils/export-map.spec.ts
index 7ad26266..c60f77c2 100644
--- a/test/utils/export-map.spec.ts
+++ b/test/utils/export-map.spec.ts
@@ -30,6 +30,17 @@ describe('ExportMap', () => {
     )
   })
 
+  it('does not return a cached copy if the parse does not yield a visitor keys', function () {
+    const mockContext = {
+      ...fakeContext,
+      parserPath: 'not-real',
+    }
+    expect(ExportMap.get('./named-exports', mockContext)).toBeDefined()
+    expect(ExportMap.get('./named-exports', mockContext)).not.toBe(
+      ExportMap.get('./named-exports', mockContext),
+    )
+  })
+
   it('does not return a cached copy after modification', done => {
     const firstAccess = ExportMap.get('./mutator', fakeContext)
     expect(firstAccess).toBeDefined()