Repository: un-ts/eslint-plugin-import-x
Pull request #192 (2024-12-02 13:30:02)

feat: implement custom resolver interface v3
The PR implements the new resolver design I proposed in https://github.com/un-ts/eslint-plugin-import-x/issues/40#issuecomment-2381444266

----

## For `eslint-plugin-import-x` users

Like the ESLint flat config allows you to use any js objects (e.g. import and require) as ESLint plugins, the new `eslint-plugin-import-x` resolver settings allow you to use js objects as custom resolvers through the new setting `import-x/resolver-next`:

```js
// eslint.config.js
import { createTsResolver } from '#custom-resolver';
const { createOxcResolver } = require('path/to/a/custom/resolver');

const nodeResolverObject = {
  interfaceVersion: 3,
  name: 'my-custom-eslint-import-resolver',
  resolve(modPath, sourcePath) {
  };
};

module.exports = {
  settings: {
    // multiple resolvers
    'import-x/resolver-next': [
      nodeResolverObject,
      createTsResolver(enhancedResolverOptions),
      createOxcResolver(oxcOptions),
    ],
    // single resolver:
    'import-x/resolver-next': [createOxcResolver(oxcOptions)]
  }
}
```

The new `import-x/resolver-next` no longer accepts strings as the resolver, thus will not be compatible with the ESLint legacy config (a.k.a. `.eslintrc`). Those who are still using the ESLint legacy config should stick with `import-x/resolver`.

In the next major version of `eslint-plugin-import-x` (v5), we will rename the currently existing `import-x/resolver` to `import-x/resolver-legacy` (which still allows the existing ESLint legacy config users to use their existing resolver settings), and `import-x/resolver-next` will become the new `import-x/resolver`. When ESLint v9 (the last ESLint version with ESLint legacy config support) reaches EOL in the future, we will remove `import-x/resolver-legacy`.

We have also made a few breaking changes to the new resolver API design, so you can't use existing custom resolvers directly with `import-x/resolver-next`:

```js
// An example of the current `import-x/resolver` settings
module.exports = {
  settings: {
    'import-x/resolver': {
      node: nodeResolverOpt
      webpack: webpackResolverOpt,
      'custom-resolver': customResolverOpt
    }
  }
}

// When migrating to `import-x/resolver-next`, you CAN'T use legacy versions of resolvers directly:
module.exports = {
  settings: {
    // THIS WON'T WORK, the resolver interface required for `import-x/resolver-next` is different.
    'import-x/resolver-next': [
       require('eslint-import-resolver-node'),
       require('eslint-import-resolver-webpack'),
       require('some-custom-resolver')
    ];
  }
}
```

For easier migration, the PR also introduces a compat utility `importXResolverCompat` that you can use in your `eslint.config.js`:

```js
// eslint.config.js
import eslintPluginImportX, { importXResolverCompat } from 'eslint-plugin-import-x';
// or
const eslintPluginImportX = require('eslint-plugin-import-x');
const { importXResolverCompat } = eslintPluginImportX;

module.exports = {
  settings: {
    // THIS WILL WORK as you have wrapped the previous version of resolvers with the `importXResolverCompat`
    'import-x/resolver-next': [
       importXResolverCompat(require('eslint-import-resolver-node'), nodeResolveOptions),
       importXResolverCompat(require('eslint-import-resolver-webpack'), webpackResolveOptions),
       importXResolverCompat(require('some-custom-resolver'), {})
    ];
  }
}
```

----

## For custom import resolver developers

This is the new API design of the resolver interface:

```ts
export interface NewResolver {
  interfaceVersion: 3,
  name?: string, // This will be included in the debug log
  resolve: (modulePath: string, sourceFile: string) => ResolvedResult
}

// The `ResultNotFound` (returned when not resolved) is the same, no changes
export interface ResultNotFound {
  found: false
  path?: undefined
}

// The `ResultFound` (returned resolve result) is also the same, no changes
export interface ResultFound {
  found: true
  path: string | null
}

export type ResolvedResult = ResultNotFound | ResultFound
```

You will be able to import `NewResolver` from `eslint-plugin-import-x/types`.

The most notable change is that `eslint-plugin-import-x` no longer passes the third argument (`options`) to the `resolve` function.

We encourage custom resolvers' authors to consume the options outside the actual `resolve` function implementation. You can export a factory function to accept the options, this factory function will then be called inside the `eslint.config.js` to get the actual resolver:

```js
// custom-resolver.js
exports.createCustomResolver = (options) => {
  // The options are consumed outside the `resolve` function.
  const resolverInstance = new ResolverFactory(options);

  return {
    name: 'custom-resolver',
    interfaceVersion: 3,
    resolve(mod, source) {
      const found = resolverInstance.resolve(mod, {});

      // Of course, you still have access to the `options` variable here inside
      // the `resolve` function. That's the power of JavaScript Closures~
    }
  }
};

// eslint.config.js
const { createCustomResolver } = require('custom-resolver')

module.exports = {
  settings: {
    'import-x/resolver-next': [
       createCustomResolver(options)
    ];
  }
}
```

This allows you to create a reusable resolver instance to improve the performance. With the existing version of the resolver interface, because the options are passed to the `resolver` function, you will have to create a resolver instance every time the `resolve` function is called:

```js
module.exports = {
  interfaceVersion: 2,
  resolve(mod, source) {
    // every time the `resolve` function is called, a new instance is created
    // This is very slow
    const resolverInstance = ResolverFactory.createResolver({});
    const found = resolverInstance.resolve(mod, {});
  }
}
```

With the factory function pattern, you can create a resolver instance beforehand:

```js
exports.createCustomResolver = (options) => {
  // `enhance-resolve` allows you to create a reusable instance:
  const resolverInstance = ResolverFactory.createResolver({});
  const resolverInstance = enhanceResolve.create({});

  // `oxc-resolver` also allows you to create a reusable instance:
  const resolverInstance = new ResolverFactory({});

  return {
    name: 'custom-resolver',
    interfaceVersion: 3,
    resolve(mod, source) {
      // the same re-usable instance is shared across `resolve` invocations.
      // more performant
      const found = resolverInstance.resolve(mod, {});
    }
  }
};
```

You can make your resolver implements both legacy resolver interface and new resolver interface:

```js
module.exports = {
  // interface version 2
  interfaceVersion: 2,
  resolve(mod, source) {
  },
  // interface version 3
  createCustomResolver(options) {
    // do something w/ options
    return {
      name: 'custom-resolver',
      interfaceVersion: 3,
      resolve(mod, source) {
        // the same re-usable instance is shared across `resolve` invocations.
        // more performant
        const found = resolverInstance.resolve(mod, {});
      }
    }
  } 
}
```

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

Latest commit: 37277675d0047188043fece2518964ce7bcf3b16

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/SukkaW/eslint-plugin-import-x/new/import-resolver-design?filename=.changeset/nice-crews-enjoy.md&value=---%0A%22eslint-plugin-import-x%22%3A%20patch%0A---%0A%0Afeat%3A%20implement%20custom%20resolver%20interface%20v3%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/192/builds/566220) or the icon next to each commit SHA.
SukkaW: Adoption

- [ ] @9romise (eslint-import-resolver-oxc)
- [ ] @opsbr (eslint-import-resolver-typescript-bun)
- [ ] @pzmosquito (eslint-import-resolver-vite)
- [ ] @dmail (@jsenv/importmap-eslint-resolver)
- [ ] @kuoruan (eslint-import-resolver-next)
- [ ] @thepassle (eslint-plugin-import-exports-imports-resolver)
- [ ] @redabacha (eslint-import-resolver-deno)
- [ ] @helljs (eslint-import-resolver-x)

cc myself (eslint-import-resolver-typescript)
romanlex: @SukkaW 
If I understood correctly 
https://github.com/helljs/eslint-import-resolver-x/pull/2

Patch:
diff --git a/.changeset/orange-nails-attack.md b/.changeset/orange-nails-attack.md
new file mode 100644
index 00000000..70d38b08
--- /dev/null
+++ b/.changeset/orange-nails-attack.md
@@ -0,0 +1,184 @@
+The PR implements the new resolver design proposed in https://github.com/un-ts/eslint-plugin-import-x/issues/40#issuecomment-2381444266
+
+----
+
+### For `eslint-plugin-import-x` users
+
+Like the ESLint flat config allows you to use any js objects (e.g. import and require) as ESLint plugins, the new `eslint-plugin-import-x` resolver settings allow you to use any js objects as custom resolvers through the new setting `import-x/resolver-next`:
+
+```js
+// eslint.config.js
+import { createTsResolver } from '#custom-resolver';
+const { createOxcResolver } = require('path/to/a/custom/resolver');
+
+const nodeResolverObject = {
+  interfaceVersion: 3,
+  name: 'my-custom-eslint-import-resolver',
+  resolve(modPath, sourcePath) {
+  };
+};
+
+module.exports = {
+  settings: {
+    // multiple resolvers
+    'import-x/resolver-next': [
+      nodeResolverObject,
+      createTsResolver(enhancedResolverOptions),
+      createOxcResolver(oxcOptions),
+    ],
+    // single resolver:
+    'import-x/resolver-next': [createOxcResolver(oxcOptions)]
+  }
+}
+```
+
+The new `import-x/resolver-next` no longer accepts strings as the resolver, thus will not be compatible with the ESLint legacy config (a.k.a. `.eslintrc`). Those who are still using the ESLint legacy config should stick with `import-x/resolver`.
+
+In the next major version of `eslint-plugin-import-x` (v5), we will rename the currently existing `import-x/resolver` to `import-x/resolver-legacy` (which still allows the existing ESLint legacy config users to use their existing resolver settings), and `import-x/resolver-next` will become the new `import-x/resolver`. When ESLint v9 (the last ESLint version with ESLint legacy config support) reaches EOL in the future, we will remove `import-x/resolver-legacy`.
+
+We have also made a few breaking changes to the new resolver API design, so you can't use existing custom resolvers directly with `import-x/resolver-next`:
+
+```js
+// An example of the current `import-x/resolver` settings
+module.exports = {
+  settings: {
+    'import-x/resolver': {
+      node: nodeResolverOpt
+      webpack: webpackResolverOpt,
+      'custom-resolver': customResolverOpt
+    }
+  }
+}
+
+// When migrating to `import-x/resolver-next`, you CAN'T use legacy versions of resolvers directly:
+module.exports = {
+  settings: {
+    // THIS WON'T WORK, the resolver interface required for `import-x/resolver-next` is different.
+    'import-x/resolver-next': [
+       require('eslint-import-resolver-node'),
+       require('eslint-import-resolver-webpack'),
+       require('some-custom-resolver')
+    ];
+  }
+}
+```
+
+For easier migration, the PR also introduces a compat utility `importXResolverCompat` that you can use in your `eslint.config.js`:
+
+```js
+// eslint.config.js
+import eslintPluginImportX, { importXResolverCompat } from 'eslint-plugin-import-x';
+// or
+const eslintPluginImportX = require('eslint-plugin-import-x');
+const { importXResolverCompat } = eslintPluginImportX;
+
+module.exports = {
+  settings: {
+    // THIS WILL WORK as you have wrapped the previous version of resolvers with the `importXResolverCompat`
+    'import-x/resolver-next': [
+       importXResolverCompat(require('eslint-import-resolver-node'), nodeResolveOptions),
+       importXResolverCompat(require('eslint-import-resolver-webpack'), webpackResolveOptions),
+       importXResolverCompat(require('some-custom-resolver'), {})
+    ];
+  }
+}
+```
+
+### For custom import resolver developers
+
+This is the new API design of the resolver interface:
+
+```ts
+export interface NewResolver {
+  interfaceVersion: 3,
+  name?: string, // This will be included in the debug log
+  resolve: (modulePath: string, sourceFile: string) => ResolvedResult
+}
+
+// The `ResultNotFound` (returned when not resolved) is the same, no changes
+export interface ResultNotFound {
+  found: false
+  path?: undefined
+}
+
+// The `ResultFound` (returned resolve result) is also the same, no changes
+export interface ResultFound {
+  found: true
+  path: string | null
+}
+
+export type ResolvedResult = ResultNotFound | ResultFound
+```
+
+You will be able to import `NewResolver` from `eslint-plugin-import-x/types`.
+
+The most notable change is that `eslint-plugin-import-x` no longer passes the third argument (`options`) to the `resolve` function.
+
+We encourage custom resolvers' authors to consume the options outside the actual `resolve` function implementation. You can export a factory function to accept the options, this factory function will then be called inside the `eslint.config.js` to get the actual resolver:
+
+```js
+// custom-resolver.js
+exports.createCustomResolver = (options) => {
+  // The options are consumed outside the `resolve` function.
+  const resolverInstance = new ResolverFactory(options);
+
+  return {
+    name: 'custom-resolver',
+    interfaceVersion: 3,
+    resolve(mod, source) {
+      const found = resolverInstance.resolve(mod, {});
+
+      // Of course, you still have access to the `options` variable here inside
+      // the `resolve` function. That's the power of JavaScript Closures~
+    }
+  }
+};
+
+// eslint.config.js
+const { createCustomResolver } = require('custom-resolver')
+
+module.exports = {
+  settings: {
+    'import-x/resolver-next': [
+       createCustomResolver(options)
+    ];
+  }
+}
+```
+
+This allows you to create a reusable resolver instance to improve the performance. With the existing version of the resolver interface, because the options are passed to the `resolver` function, you will have to create a resolver instance every time the `resolve` function is called:
+
+```js
+module.exports = {
+  interfaceVersion: 2,
+  resolve(mod, source) {
+    // every time the `resolve` function is called, a new instance is created
+    // This is very slow
+    const resolverInstance = ResolverFactory.createResolver({});
+    const found = resolverInstance.resolve(mod, {});
+  }
+}
+```
+
+With the factory function pattern, you can create a resolver instance beforehand:
+
+```js
+exports.createCustomResolver = (options) => {
+  // `enhance-resolve` allows you to create a reusable instance:
+  const resolverInstance = ResolverFactory.createResolver({});
+  const resolverInstance = enhanceResolve.create({});
+
+  // `oxc-resolver` also allows you to create a reusable instance:
+  const resolverInstance = new ResolverFactory({});
+
+  return {
+    name: 'custom-resolver',
+    interfaceVersion: 3,
+    resolve(mod, source) {
+      // the same re-usable instance is shared across `resolve` invocations.
+      // more performant
+      const found = resolverInstance.resolve(mod, {});
+    }
+  }
+};
+```
diff --git a/src/index.ts b/src/index.ts
index 0c19a94f..5d34ef1b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -71,6 +71,7 @@ import type {
   PluginFlatBaseConfig,
   PluginFlatConfig,
 } from './types'
+import { importXResolverCompat } from './utils'
 
 const rules = {
   'no-unresolved': noUnresolved,
@@ -181,4 +182,5 @@ export = {
   configs,
   flatConfigs,
   rules,
+  importXResolverCompat,
 }
diff --git a/src/types.ts b/src/types.ts
index eee0cdac..e45e57ae 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,9 +1,35 @@
 import type { TSESLint, TSESTree } from '@typescript-eslint/utils'
 import type { ResolveOptions } from 'enhanced-resolve'
 import type { MinimatchOptions } from 'minimatch'
-import type { KebabCase, LiteralUnion } from 'type-fest'
+import type { KebabCase } from 'type-fest'
 
 import type { ImportType as ImportType_, PluginName } from './utils'
+import type {
+  LegacyImportResolver,
+  LegacyResolver,
+} from './utils/legacy-resolver-settings'
+
+export type {
+  LegacyResolver,
+  // ResolverName
+  LegacyResolverName,
+  LegacyResolverName as ResolverName,
+  // ImportResolver
+  LegacyImportResolver,
+  LegacyImportResolver as ImportResolver,
+  // ResolverResolve
+  LegacyResolverResolve,
+  LegacyResolverResolve as ResolverResolve,
+  // ResolverResolveImport
+  LegacyResolverResolveImport,
+  LegacyResolverResolveImport as ResolverResolveImport,
+  // ResolverRecord
+  LegacyResolverRecord,
+  LegacyResolverRecord as ResolverRecord,
+  // ResolverObject
+  LegacyResolverObject,
+  LegacyResolverObject as ResolverObject,
+} from './utils/legacy-resolver-settings'
 
 export type ImportType = ImportType_ | 'object' | 'type'
 
@@ -26,6 +52,20 @@ export type TsResolverOptions = {
   extensions?: string[]
 } & Omit<ResolveOptions, 'fileSystem' | 'useSyncFileSystemCalls'>
 
+// TODO: remove prefix New in the next major version
+export type NewResolverResolve = (
+  modulePath: string,
+  sourceFile: string,
+) => ResolvedResult
+
+// TODO: remove prefix New in the next major version
+export type NewResolver = {
+  interfaceVersion: 3
+  /** optional name for the resolver, this is used in logs/debug output */
+  name?: string
+  resolve: NewResolverResolve
+}
+
 export type FileExtension = `.${string}`
 
 export type DocStyle = 'jsdoc' | 'tomdoc'
@@ -42,63 +82,9 @@ export type ResultFound = {
   path: string | null
 }
 
-export type ResolvedResult = ResultNotFound | ResultFound
-
-export type ResolverResolve<T = unknown> = (
-  modulePath: string,
-  sourceFile: string,
-  config: T,
-) => ResolvedResult
-
-export type ResolverResolveImport<T = unknown> = (
-  modulePath: string,
-  sourceFile: string,
-  config: T,
-) => string | undefined
-
-export type Resolver<T = unknown, U = T> = {
-  interfaceVersion?: 1 | 2
-  resolve: ResolverResolve<T>
-  resolveImport: ResolverResolveImport<U>
-}
-
-export type ResolverName = LiteralUnion<
-  'node' | 'typescript' | 'webpack',
-  string
->
-
-export type ResolverRecord = {
-  node?: boolean | NodeResolverOptions
-  typescript?: boolean | TsResolverOptions
-  webpack?: WebpackResolverOptions
-  [resolve: string]: unknown
-}
+export type Resolver = LegacyResolver | NewResolver
 
-export type ResolverObject = {
-  // node, typescript, webpack...
-  name: ResolverName
-
-  // Enabled by default
-  enable?: boolean
-
-  // Options passed to the resolver
-  options?:
-    | NodeResolverOptions
-    | TsResolverOptions
-    | WebpackResolverOptions
-    | unknown
-
-  // Any object satisfied Resolver type
-  resolver: Resolver
-}
-
-export type ImportResolver =
-  | ResolverName
-  | ResolverRecord
-  | ResolverObject
-  | ResolverName[]
-  | ResolverRecord[]
-  | ResolverObject[]
+export type ResolvedResult = ResultNotFound | ResultFound
 
 export type ImportSettings = {
   cache?: {
@@ -112,7 +98,9 @@ export type ImportSettings = {
   internalRegex?: string
   parsers?: Record<string, readonly FileExtension[]>
   resolve?: NodeResolverOptions
-  resolver?: ImportResolver
+  resolver?: LegacyImportResolver
+  'resolver-legacy'?: LegacyImportResolver
+  'resolver-next'?: NewResolver[]
 }
 
 export type WithPluginName<T extends string | object> = T extends string
diff --git a/src/utils/legacy-resolver-settings.ts b/src/utils/legacy-resolver-settings.ts
new file mode 100644
index 00000000..15d3418c
--- /dev/null
+++ b/src/utils/legacy-resolver-settings.ts
@@ -0,0 +1,219 @@
+// Although the new import resolver settings is still `import-x/resolver-next`, but it won't stop us from calling existing ones legacy~
+
+import { createRequire } from 'node:module'
+import path from 'node:path'
+
+import type { LiteralUnion } from 'type-fest'
+
+import type {
+  NodeResolverOptions,
+  ResolvedResult,
+  TsResolverOptions,
+  WebpackResolverOptions,
+} from '../types'
+
+import { pkgDir } from './pkg-dir'
+import { IMPORT_RESOLVE_ERROR_NAME } from './resolve'
+
+export type LegacyResolverName = LiteralUnion<
+  'node' | 'typescript' | 'webpack',
+  string
+>
+
+export type LegacyResolverResolveImport<T = unknown> = (
+  modulePath: string,
+  sourceFile: string,
+  config: T,
+) => string | undefined
+
+export type LegacyResolverResolve<T = unknown> = (
+  modulePath: string,
+  sourceFile: string,
+  config: T,
+) => ResolvedResult
+
+export type LegacyResolver<T = unknown, U = T> = {
+  interfaceVersion?: 1 | 2
+  resolve: LegacyResolverResolve<T>
+  resolveImport: LegacyResolverResolveImport<U>
+}
+
+export type LegacyResolverObject = {
+  // node, typescript, webpack...
+  name: LegacyResolverName
+
+  // Enabled by default
+  enable?: boolean
+
+  // Options passed to the resolver
+  options?:
+    | NodeResolverOptions
+    | TsResolverOptions
+    | WebpackResolverOptions
+    | unknown
+
+  // Any object satisfied Resolver type
+  resolver: LegacyResolver
+}
+
+export type LegacyResolverRecord = {
+  node?: boolean | NodeResolverOptions
+  typescript?: boolean | TsResolverOptions
+  webpack?: WebpackResolverOptions
+  [resolve: string]: unknown
+}
+
+export type LegacyImportResolver =
+  | LegacyResolverName
+  | LegacyResolverRecord
+  | LegacyResolverObject
+  | LegacyResolverName[]
+  | LegacyResolverRecord[]
+  | LegacyResolverObject[]
+
+export function resolveWithLegacyResolver(
+  resolver: LegacyResolver,
+  config: unknown,
+  modulePath: string,
+  sourceFile: string,
+): ResolvedResult {
+  if (resolver.interfaceVersion === 2) {
+    return resolver.resolve(modulePath, sourceFile, config)
+  }
+
+  try {
+    const resolved = resolver.resolveImport(modulePath, sourceFile, config)
+    if (resolved === undefined) {
+      return {
+        found: false,
+      }
+    }
+    return {
+      found: true,
+      path: resolved,
+    }
+  } catch {
+    return {
+      found: false,
+    }
+  }
+}
+
+export function normalizeConfigResolvers(
+  resolvers: LegacyImportResolver,
+  sourceFile: string,
+) {
+  const resolverArray = Array.isArray(resolvers) ? resolvers : [resolvers]
+  const map = new Map<string, Required<LegacyResolverObject>>()
+
+  for (const nameOrRecordOrObject of resolverArray) {
+    if (typeof nameOrRecordOrObject === 'string') {
+      const name = nameOrRecordOrObject
+
+      map.set(name, {
+        name,
+        enable: true,
+        options: undefined,
+        resolver: requireResolver(name, sourceFile),
+      })
+    } else if (typeof nameOrRecordOrObject === 'object') {
+      if (nameOrRecordOrObject.name && nameOrRecordOrObject.resolver) {
+        const object = nameOrRecordOrObject as LegacyResolverObject
+
+        const { name, enable = true, options, resolver } = object
+        map.set(name, { name, enable, options, resolver })
+      } else {
+        const record = nameOrRecordOrObject as LegacyResolverRecord
+
+        for (const [name, enableOrOptions] of Object.entries(record)) {
+          if (typeof enableOrOptions === 'boolean') {
+            map.set(name, {
+              name,
+              enable: enableOrOptions,
+              options: undefined,
+              resolver: requireResolver(name, sourceFile),
+            })
+          } else {
+            map.set(name, {
+              name,
+              enable: true,
+              options: enableOrOptions,
+              resolver: requireResolver(name, sourceFile),
+            })
+          }
+        }
+      }
+    } else {
+      const err = new Error('invalid resolver config')
+      err.name = IMPORT_RESOLVE_ERROR_NAME
+      throw err
+    }
+  }
+
+  return [...map.values()]
+}
+
+function requireResolver(name: string, sourceFile: string) {
+  // Try to resolve package with conventional name
+  const resolver =
+    tryRequire(`eslint-import-resolver-${name}`, sourceFile) ||
+    tryRequire(name, sourceFile) ||
+    tryRequire(path.resolve(getBaseDir(sourceFile), name))
+
+  if (!resolver) {
+    const err = new Error(`unable to load resolver "${name}".`)
+    err.name = IMPORT_RESOLVE_ERROR_NAME
+    throw err
+  }
+  if (!isLegacyResolverValid(resolver)) {
+    const err = new Error(`${name} with invalid interface loaded as resolver`)
+    err.name = IMPORT_RESOLVE_ERROR_NAME
+    throw err
+  }
+
+  return resolver
+}
+
+function isLegacyResolverValid(resolver: object): resolver is LegacyResolver {
+  if ('interfaceVersion' in resolver && resolver.interfaceVersion === 2) {
+    return (
+      'resolve' in resolver &&
+      !!resolver.resolve &&
+      typeof resolver.resolve === 'function'
+    )
+  }
+  return (
+    'resolveImport' in resolver &&
+    !!resolver.resolveImport &&
+    typeof resolver.resolveImport === 'function'
+  )
+}
+
+function tryRequire<T>(
+  target: string,
+  sourceFile?: string | null,
+): undefined | T {
+  let resolved
+  try {
+    // Check if the target exists
+    if (sourceFile == null) {
+      resolved = require.resolve(target)
+    } else {
+      try {
+        resolved = createRequire(path.resolve(sourceFile)).resolve(target)
+      } catch {
+        resolved = require.resolve(target)
+      }
+    }
+  } catch {
+    // If the target does not exist then just return undefined
+    return undefined
+  }
+
+  // If the target exists then return the loaded module
+  return require(resolved)
+}
+
+function getBaseDir(sourceFile: string): string {
+  return pkgDir(sourceFile) || process.cwd()
+}
diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts
index 3401e3be..08f000cd 100644
--- a/src/utils/resolve.ts
+++ b/src/utils/resolve.ts
@@ -1,21 +1,21 @@
 import fs from 'node:fs'
-import { createRequire } from 'node:module'
 import path from 'node:path'
 
 import stableHash from 'stable-hash'
 
 import type {
   ImportSettings,
+  LegacyResolver,
+  NewResolver,
   PluginSettings,
   RuleContext,
-  Resolver,
-  ImportResolver,
-  ResolverRecord,
-  ResolverObject,
 } from '../types'
 
+import {
+  normalizeConfigResolvers,
+  resolveWithLegacyResolver,
+} from './legacy-resolver-settings'
 import { ModuleCache } from './module-cache'
-import { pkgDir } from './pkg-dir'
 
 export const CASE_SENSITIVE_FS = !fs.existsSync(
   path.resolve(
@@ -24,34 +24,9 @@ export const CASE_SENSITIVE_FS = !fs.existsSync(
   ),
 )
 
-const ERROR_NAME = 'EslintPluginImportResolveError'
+export const IMPORT_RESOLVE_ERROR_NAME = 'EslintPluginImportResolveError'
 
-const fileExistsCache = new ModuleCache()
-
-function tryRequire<T>(
-  target: string,
-  sourceFile?: string | null,
-): undefined | T {
-  let resolved
-  try {
-    // Check if the target exists
-    if (sourceFile == null) {
-      resolved = require.resolve(target)
-    } else {
-      try {
-        resolved = createRequire(path.resolve(sourceFile)).resolve(target)
-      } catch {
-        resolved = require.resolve(target)
-      }
-    }
-  } catch {
-    // If the target does not exist then just return undefined
-    return undefined
-  }
-
-  // If the target exists then return the loaded module
-  return require(resolved)
-}
+export const fileExistsCache = new ModuleCache()
 
 // https://stackoverflow.com/a/27382838
 export function fileExistsWithCaseSync(
@@ -95,6 +70,39 @@ export function fileExistsWithCaseSync(
 let prevSettings: PluginSettings | null = null
 let memoizedHash: string
 
+function isNamedResolver(resolver: unknown): resolver is { name: string } {
+  return !!(
+    typeof resolver === 'object' &&
+    resolver &&
+    'name' in resolver &&
+    typeof resolver.name === 'string' &&
+    resolver.name
+  )
+}
+
+function isValidNewResolver(resolver: unknown): resolver is NewResolver {
+  if (typeof resolver !== 'object' || resolver == null) {
+    return false
+  }
+
+  if (!('resolve' in resolver) || !('interfaceVersion' in resolver)) {
+    return false
+  }
+
+  if (
+    typeof resolver.interfaceVersion !== 'number' ||
+    resolver.interfaceVersion !== 3
+  ) {
+    return false
+  }
+
+  if (typeof resolver.resolve !== 'function') {
+    return false
+  }
+
+  return true
+}
+
 function fullResolve(
   modulePath: string,
   sourceFile: string,
@@ -125,49 +133,63 @@ function fullResolve(
     return { found: true, path: cachedPath }
   }
 
-  function withResolver(resolver: Resolver, config: unknown) {
-    if (resolver.interfaceVersion === 2) {
-      return resolver.resolve(modulePath, sourceFile, config)
-    }
-
-    try {
-      const resolved = resolver.resolveImport(modulePath, sourceFile, config)
-      if (resolved === undefined) {
-        return {
-          found: false,
-        }
+  if (
+    Object.prototype.hasOwnProperty.call(settings, 'import-x/resolver-next') &&
+    settings['import-x/resolver-next']
+  ) {
+    const configResolvers = settings['import-x/resolver-next']
+
+    for (let i = 0, len = configResolvers.length; i < len; i++) {
+      const resolver = configResolvers[i]
+      const resolverName = isNamedResolver(resolver)
+        ? resolver.name
+        : `settings['import-x/resolver-next'][${i}]`
+
+      if (!isValidNewResolver(resolver)) {
+        const err = new TypeError(
+          `${resolverName} is not a valid import resolver for eslint-plugin-import-x!`,
+        )
+        err.name = IMPORT_RESOLVE_ERROR_NAME
+        throw err
       }
-      return {
-        found: true,
-        path: resolved,
-      }
-    } catch {
-      return {
-        found: false,
-      }
-    }
-  }
 
-  const configResolvers = settings['import-x/resolver'] || {
-    node: settings['import-x/resolve'],
-  } // backward compatibility
-
-  const resolvers = normalizeConfigResolvers(configResolvers, sourceFile)
+      const resolved = resolver.resolve(modulePath, sourceFile)
+      if (!resolved.found) {
+        continue
+      }
 
-  for (const { enable, options, resolver } of resolvers) {
-    if (!enable) {
-      continue
+      // else, counts
+      fileExistsCache.set(cacheKey, resolved.path as string | null)
+      return resolved
     }
+  } else {
+    const configResolvers = settings['import-x/resolver-legacy'] ||
+      settings['import-x/resolver'] || {
+        node: settings['import-x/resolve'],
+      } // backward compatibility
+
+    for (const { enable, options, resolver } of normalizeConfigResolvers(
+      configResolvers,
+      sourceFile,
+    )) {
+      if (!enable) {
+        continue
+      }
 
-    const resolved = withResolver(resolver, options)
+      const resolved = resolveWithLegacyResolver(
+        resolver,
+        options,
+        modulePath,
+        sourceFile,
+      )
+      if (!resolved.found) {
+        continue
+      }
 
-    if (!resolved.found) {
-      continue
+      // else, counts
+      fileExistsCache.set(cacheKey, resolved.path as string | null)
+      return resolved
     }
-
-    // else, counts
-    fileExistsCache.set(cacheKey, resolved.path as string | null)
-    return resolved
   }
 
   // failed
@@ -183,100 +205,6 @@ export function relative(
   return fullResolve(modulePath, sourceFile, settings).path
 }
 
-function normalizeConfigResolvers(
-  resolvers: ImportResolver,
-  sourceFile: string,
-) {
-  const resolverArray = Array.isArray(resolvers) ? resolvers : [resolvers]
-  const map = new Map<string, Required<ResolverObject>>()
-
-  for (const nameOrRecordOrObject of resolverArray) {
-    if (typeof nameOrRecordOrObject === 'string') {
-      const name = nameOrRecordOrObject
-
-      map.set(name, {
-        name,
-        enable: true,
-        options: undefined,
-        resolver: requireResolver(name, sourceFile),
-      })
-    } else if (typeof nameOrRecordOrObject === 'object') {
-      if (nameOrRecordOrObject.name && nameOrRecordOrObject.resolver) {
-        const object = nameOrRecordOrObject as ResolverObject
-
-        const { name, enable = true, options, resolver } = object
-        map.set(name, { name, enable, options, resolver })
-      } else {
-        const record = nameOrRecordOrObject as ResolverRecord
-
-        for (const [name, enableOrOptions] of Object.entries(record)) {
-          if (typeof enableOrOptions === 'boolean') {
-            map.set(name, {
-              name,
-              enable: enableOrOptions,
-              options: undefined,
-              resolver: requireResolver(name, sourceFile),
-            })
-          } else {
-            map.set(name, {
-              name,
-              enable: true,
-              options: enableOrOptions,
-              resolver: requireResolver(name, sourceFile),
-            })
-          }
-        }
-      }
-    } else {
-      const err = new Error('invalid resolver config')
-      err.name = ERROR_NAME
-      throw err
-    }
-  }
-
-  return [...map.values()]
-}
-
-function getBaseDir(sourceFile: string): string {
-  return pkgDir(sourceFile) || process.cwd()
-}
-
-function requireResolver(name: string, sourceFile: string) {
-  // Try to resolve package with conventional name
-  const resolver =
-    tryRequire(`eslint-import-resolver-${name}`, sourceFile) ||
-    tryRequire(name, sourceFile) ||
-    tryRequire(path.resolve(getBaseDir(sourceFile), name))
-
-  if (!resolver) {
-    const err = new Error(`unable to load resolver "${name}".`)
-    err.name = ERROR_NAME
-    throw err
-  }
-  if (!isResolverValid(resolver)) {
-    const err = new Error(`${name} with invalid interface loaded as resolver`)
-    err.name = ERROR_NAME
-    throw err
-  }
-
-  return resolver
-}
-
-function isResolverValid(resolver: object): resolver is Resolver {
-  if ('interfaceVersion' in resolver && resolver.interfaceVersion === 2) {
-    return (
-      'resolve' in resolver &&
-      !!resolver.resolve &&
-      typeof resolver.resolve === 'function'
-    )
-  }
-  return (
-    'resolveImport' in resolver &&
-    !!resolver.resolveImport &&
-    typeof resolver.resolveImport === 'function'
-  )
-}
-
 const erroredContexts = new Set<RuleContext>()
 
 /**
@@ -294,7 +222,7 @@ export function resolve(p: string, context: RuleContext) {
       // The `err.stack` string starts with `err.name` followed by colon and `err.message`.
       // We're filtering out the default `err.name` because it adds little value to the message.
       let errMessage = error.message
-      if (error.name !== ERROR_NAME && error.stack) {
+      if (error.name !== IMPORT_RESOLVE_ERROR_NAME && error.stack) {
         errMessage = error.stack.replace(/^Error: /, '')
       }
       context.report({
@@ -309,3 +237,29 @@ export function resolve(p: string, context: RuleContext) {
     }
   }
 }
+
+export function importXResolverCompat(
+  resolver: LegacyResolver | NewResolver,
+  resolverOptions: unknown = {},
+): NewResolver {
+  // Somehow the resolver is already using v3 interface
+  if (isValidNewResolver(resolver)) {
+    return resolver
+  }
+
+  return {
+    // deliberately not providing the name, because we can't get the name from legacy resolvers
+    // By omitting the name, the log will use identifiable name like `settings['import-x/resolver-next'][0]`
+    // name: 'import-x-resolver-compat',
+    interfaceVersion: 3,
+    resolve: (modulePath, sourceFile) => {
+      const resolved = resolveWithLegacyResolver(
+        resolver,
+        resolverOptions,
+        modulePath,
+        sourceFile,
+      )
+      return resolved
+    },
+  }
+}


Test patch:
diff --git a/test/fixtures/foo-bar-resolver-v3.js b/test/fixtures/foo-bar-resolver-v3.js
new file mode 100644
index 00000000..24c0344a
--- /dev/null
+++ b/test/fixtures/foo-bar-resolver-v3.js
@@ -0,0 +1,16 @@
+var path = require('path')
+
+exports.foobarResolver = (/** @type {import('eslint-plugin-import-x/types').NewResolver} */ {
+  name: 'resolver-foo-bar',
+  interfaceVersion: 3,
+  resolve: function (modulePath, sourceFile) {
+    var sourceFileName = path.basename(sourceFile)
+    if (sourceFileName === 'foo.js') {
+      return { found: true, path: path.join(__dirname, 'bar.jsx') }
+    }
+    if (sourceFileName === 'exception.js') {
+      throw new Error('foo-bar-resolver-v3 resolve test exception')
+    }
+    return { found: false }
+  }
+})
diff --git a/test/utils/resolve.spec.ts b/test/utils/resolve.spec.ts
index 2bc7a63f..64bdbb89 100644
--- a/test/utils/resolve.spec.ts
+++ b/test/utils/resolve.spec.ts
@@ -3,17 +3,18 @@ import path from 'node:path'
 import { setTimeout } from 'node:timers/promises'
 
 import type { TSESLint } from '@typescript-eslint/utils'
-import eslintPkg from 'eslint/package.json'
-import semver from 'semver'
 
 import { testContext, testFilePath } from '../utils'
 
+import eslintPluginImportX from 'eslint-plugin-import-x'
 import {
   CASE_SENSITIVE_FS,
   fileExistsWithCaseSync,
   resolve,
 } from 'eslint-plugin-import-x/utils'
 
+const { importXResolverCompat } = eslintPluginImportX
+
 describe('resolve', () => {
   it('throws on bad parameters', () => {
     expect(
@@ -104,6 +105,58 @@ describe('resolve', () => {
     expect(testContextReports.length).toBe(0)
   })
 
+  it('resolves via a custom resolver with interface version 3', () => {
+    const context = testContext({
+      'import-x/resolver-next': [
+        require('../fixtures/foo-bar-resolver-v3').foobarResolver,
+      ],
+    })
+    const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
+    context.report = reportInfo => {
+      testContextReports.push(reportInfo)
+    }
+
+    expect(resolve('../fixtures/foo', context)).toBe(testFilePath('./bar.jsx'))
+
+    testContextReports.length = 0
+    expect(
+      resolve('../fixtures/exception', {
+        ...context,
+        physicalFilename: testFilePath('exception.js'),
+      }),
+    ).toBeUndefined()
+    expect(testContextReports[0]).toBeInstanceOf(Object)
+    expect(
+      'message' in testContextReports[0] && testContextReports[0].message,
+    ).toMatch('Resolve error: foo-bar-resolver-v3 resolve test exception')
+    expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
+
+    testContextReports.length = 0
+    expect(
+      resolve('../fixtures/not-found', {
+        ...context,
+        physicalFilename: testFilePath('not-found.js'),
+      }),
+    ).toBeUndefined()
+    expect(testContextReports.length).toBe(0)
+  })
+
+  it('importXResolverCompat()', () => {
+    let context = testContext({
+      'import-x/resolver-next': [
+        importXResolverCompat(require('../fixtures/foo-bar-resolver-v2')),
+      ],
+    })
+    expect(resolve('../fixtures/foo', context)).toBe(testFilePath('./bar.jsx'))
+
+    context = testContext({
+      'import-x/resolver-next': [
+        importXResolverCompat(require('../fixtures/foo-bar-resolver-v1')),
+      ],
+    })
+    expect(resolve('../fixtures/foo', context)).toBe(testFilePath('./bar.jsx'))
+  })
+
   it('reports invalid import-x/resolver config', () => {
     const context = testContext({
       // @ts-expect-error - testing
@@ -179,165 +232,199 @@ describe('resolve', () => {
     expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
   })
 
-  // context.getPhysicalFilename() is available in ESLint 7.28+
-  ;(semver.satisfies(eslintPkg.version, '>= 7.28') ? describe : describe.skip)(
-    'getPhysicalFilename()',
-    () => {
-      it('resolves via a custom resolver with interface version 1', () => {
-        const context = testContext({
-          'import-x/resolver': './foo-bar-resolver-v1',
-        })
+  describe('getPhysicalFilename()', () => {
+    it('resolves via a custom resolver with interface version 1', () => {
+      const context = testContext({
+        'import-x/resolver': './foo-bar-resolver-v1',
+      })
 
-        expect(resolve('../fixtures/foo', context)).toBe(
-          testFilePath('./bar.jsx'),
-        )
+      expect(resolve('../fixtures/foo', context)).toBe(
+        testFilePath('./bar.jsx'),
+      )
 
-        expect(
-          resolve('../fixtures/exception', {
-            ...context,
-            physicalFilename: testFilePath('exception.js'),
-          }),
-        ).toBeUndefined()
+      expect(
+        resolve('../fixtures/exception', {
+          ...context,
+          physicalFilename: testFilePath('exception.js'),
+        }),
+      ).toBeUndefined()
 
-        expect(
-          resolve('../fixtures/not-found', {
-            ...context,
-            physicalFilename: testFilePath('not-found.js'),
-          }),
-        ).toBeUndefined()
+      expect(
+        resolve('../fixtures/not-found', {
+          ...context,
+          physicalFilename: testFilePath('not-found.js'),
+        }),
+      ).toBeUndefined()
+    })
+
+    it('resolves via a custom resolver with interface version 1 assumed if not specified', () => {
+      const context = testContext({
+        'import-x/resolver': './foo-bar-resolver-no-version',
       })
 
-      it('resolves via a custom resolver with interface version 1 assumed if not specified', () => {
-        const context = testContext({
-          'import-x/resolver': './foo-bar-resolver-no-version',
-        })
+      expect(resolve('../fixtures/foo', context)).toBe(
+        testFilePath('./bar.jsx'),
+      )
 
-        expect(resolve('../fixtures/foo', context)).toBe(
-          testFilePath('./bar.jsx'),
-        )
+      expect(
+        resolve('../fixtures/exception', {
+          ...context,
+          physicalFilename: testFilePath('exception.js'),
+        }),
+      ).toBeUndefined()
 
-        expect(
-          resolve('../fixtures/exception', {
-            ...context,
-            physicalFilename: testFilePath('exception.js'),
-          }),
-        ).toBeUndefined()
+      expect(
+        resolve('../fixtures/not-found', {
+          ...context,
+          physicalFilename: testFilePath('not-found.js'),
+        }),
+      ).toBeUndefined()
+    })
 
-        expect(
-          resolve('../fixtures/not-found', {
-            ...context,
-            physicalFilename: testFilePath('not-found.js'),
-          }),
-        ).toBeUndefined()
+    it('resolves via a custom resolver with interface version 2', () => {
+      const context = testContext({
+        'import-x/resolver': './foo-bar-resolver-v2',
       })
+      const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
+      context.report = reportInfo => {
+        testContextReports.push(reportInfo)
+      }
 
-      it('resolves via a custom resolver with interface version 2', () => {
-        const context = testContext({
-          'import-x/resolver': './foo-bar-resolver-v2',
-        })
-        const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
-        context.report = reportInfo => {
-          testContextReports.push(reportInfo)
-        }
+      expect(resolve('../fixtures/foo', context)).toBe(
+        testFilePath('./bar.jsx'),
+      )
 
-        expect(resolve('../fixtures/foo', context)).toBe(
-          testFilePath('./bar.jsx'),
-        )
+      testContextReports.length = 0
+      expect(
+        resolve('../fixtures/exception', {
+          ...context,
+          physicalFilename: testFilePath('exception.js'),
+        }),
+      ).toBeUndefined()
+      expect(testContextReports[0]).toBeInstanceOf(Object)
+      expect(
+        'message' in testContextReports[0] && testContextReports[0].message,
+      ).toMatch('Resolve error: foo-bar-resolver-v2 resolve test exception')
+      expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
 
-        testContextReports.length = 0
-        expect(
-          resolve('../fixtures/exception', {
-            ...context,
-            physicalFilename: testFilePath('exception.js'),
-          }),
-        ).toBeUndefined()
-        expect(testContextReports[0]).toBeInstanceOf(Object)
-        expect(
-          'message' in testContextReports[0] && testContextReports[0].message,
-        ).toMatch('Resolve error: foo-bar-resolver-v2 resolve test exception')
-        expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
+      testContextReports.length = 0
+      expect(
+        resolve('../fixtures/not-found', {
+          ...context,
+          physicalFilename: testFilePath('not-found.js'),
+        }),
+      ).toBeUndefined()
+      expect(testContextReports.length).toBe(0)
+    })
 
-        testContextReports.length = 0
-        expect(
-          resolve('../fixtures/not-found', {
-            ...context,
-            physicalFilename: testFilePath('not-found.js'),
-          }),
-        ).toBeUndefined()
-        expect(testContextReports.length).toBe(0)
+    it('resolves via a custom resolver with interface version 3', () => {
+      const context = testContext({
+        'import-x/resolver-next': [
+          require('../fixtures/foo-bar-resolver-v3').foobarResolver,
+        ],
       })
+      const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
+      context.report = reportInfo => {
+        testContextReports.push(reportInfo)
+      }
 
-      it('reports invalid import-x/resolver config', () => {
-        const context = testContext({
-          // @ts-expect-error - testing
-          'import-x/resolver': 123.456,
-        })
-        const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
-        context.report = reportInfo => {
-          testContextReports.push(reportInfo)
-        }
-
-        testContextReports.length = 0
-        expect(resolve('../fixtures/foo', context)).toBeUndefined()
-        expect(testContextReports[0]).toBeInstanceOf(Object)
-        expect(
-          'message' in testContextReports[0] && testContextReports[0].message,
-        ).toMatch('Resolve error: invalid resolver config')
-        expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
-      })
+      expect(resolve('../fixtures/foo', context)).toBe(
+        testFilePath('./bar.jsx'),
+      )
 
-      it('reports loaded resolver with invalid interface', () => {
-        const resolverName = './foo-bar-resolver-invalid'
-        const context = testContext({
-          'import-x/resolver': resolverName,
-        })
-        const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
-        context.report = reportInfo => {
-          testContextReports.push(reportInfo)
-        }
-        expect(resolve('../fixtures/foo', context)).toBeUndefined()
-        expect(testContextReports[0]).toBeInstanceOf(Object)
-        expect(
-          'message' in testContextReports[0] && testContextReports[0].message,
-        ).toMatch(
-          `Resolve error: ${resolverName} with invalid interface loaded as resolver`,
-        )
-        expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
+      testContextReports.length = 0
+      expect(
+        resolve('../fixtures/exception', {
+          ...context,
+          physicalFilename: testFilePath('exception.js'),
+        }),
+      ).toBeUndefined()
+      expect(testContextReports[0]).toBeInstanceOf(Object)
+      expect(
+        'message' in testContextReports[0] && testContextReports[0].message,
+      ).toMatch('Resolve error: foo-bar-resolver-v3 resolve test exception')
+      expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
+
+      testContextReports.length = 0
+      expect(
+        resolve('../fixtures/not-found', {
+          ...context,
+          physicalFilename: testFilePath('not-found.js'),
+        }),
+      ).toBeUndefined()
+      expect(testContextReports.length).toBe(0)
+    })
+
+    it('reports invalid import-x/resolver config', () => {
+      const context = testContext({
+        // @ts-expect-error - testing
+        'import-x/resolver': 123.456,
       })
+      const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
+      context.report = reportInfo => {
+        testContextReports.push(reportInfo)
+      }
+
+      testContextReports.length = 0
+      expect(resolve('../fixtures/foo', context)).toBeUndefined()
+      expect(testContextReports[0]).toBeInstanceOf(Object)
+      expect(
+        'message' in testContextReports[0] && testContextReports[0].message,
+      ).toMatch('Resolve error: invalid resolver config')
+      expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
+    })
 
-      it('respects import-x/resolve extensions', () => {
-        const context = testContext({
-          'import-x/resolve': { extensions: ['.jsx'] },
-        })
+    it('reports loaded resolver with invalid interface', () => {
+      const resolverName = './foo-bar-resolver-invalid'
+      const context = testContext({
+        'import-x/resolver': resolverName,
+      })
+      const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
+      context.report = reportInfo => {
+        testContextReports.push(reportInfo)
+      }
+      expect(resolve('../fixtures/foo', context)).toBeUndefined()
+      expect(testContextReports[0]).toBeInstanceOf(Object)
+      expect(
+        'message' in testContextReports[0] && testContextReports[0].message,
+      ).toMatch(
+        `Resolve error: ${resolverName} with invalid interface loaded as resolver`,
+      )
+      expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
+    })
 
-        expect(resolve('./jsx/MyCoolComponent', context)).toBe(
-          testFilePath('./jsx/MyCoolComponent.jsx'),
-        )
+    it('respects import-x/resolve extensions', () => {
+      const context = testContext({
+        'import-x/resolve': { extensions: ['.jsx'] },
       })
 
-      it('reports load exception in a user resolver', () => {
-        const context = testContext({
-          'import-x/resolver': './load-error-resolver',
-        })
-        const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
-        context.report = reportInfo => {
-          testContextReports.push(reportInfo)
-        }
+      expect(resolve('./jsx/MyCoolComponent', context)).toBe(
+        testFilePath('./jsx/MyCoolComponent.jsx'),
+      )
+    })
 
-        expect(
-          resolve('../fixtures/exception', {
-            ...context,
-            physicalFilename: testFilePath('exception.js'),
-          }),
-        ).toBeUndefined()
-        expect(testContextReports[0]).toBeInstanceOf(Object)
-        expect(
-          'message' in testContextReports[0] && testContextReports[0].message,
-        ).toMatch('Resolve error: SyntaxError: TEST SYNTAX ERROR')
-        expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
+    it('reports load exception in a user resolver', () => {
+      const context = testContext({
+        'import-x/resolver': './load-error-resolver',
       })
-    },
-  )
+      const testContextReports: Array<TSESLint.ReportDescriptor<string>> = []
+      context.report = reportInfo => {
+        testContextReports.push(reportInfo)
+      }
+
+      expect(
+        resolve('../fixtures/exception', {
+          ...context,
+          physicalFilename: testFilePath('exception.js'),
+        }),
+      ).toBeUndefined()
+      expect(testContextReports[0]).toBeInstanceOf(Object)
+      expect(
+        'message' in testContextReports[0] && testContextReports[0].message,
+      ).toMatch('Resolve error: SyntaxError: TEST SYNTAX ERROR')
+      expect(testContextReports[0].loc).toEqual({ line: 1, column: 0 })
+    })
+  })
 
   const caseDescribe = CASE_SENSITIVE_FS ? describe.skip : describe
 
@@ -563,10 +650,7 @@ describe('resolve', () => {
       ).toBe(testFilePath('./bar.jsx'))
     })
 
-    // context.getPhysicalFilename() is available in ESLint 7.28+
-    ;(semver.satisfies(eslintPkg.version, '>= 7.28')
-      ? describe
-      : describe.skip)('getPhysicalFilename()', () => {
+    describe('getPhysicalFilename()', () => {
       it('as resolver package name(s)', () => {
         expect(
           resolve(