Skip to content

feat: map legacy node resolver to the new one with fallback support #272

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/khaki-sites-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eslint-plugin-import-x": minor
---

feat: map legacy node resolver to the new one with fallback support
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ jobs:

include:
- executeLint: true
node: 22
node: lts/*
eslint: 9
os: ubuntu-latest
- legacyNodeResolver: true
node: lts/*
eslint: 9
os: ubuntu-latest
fail-fast: false
Expand All @@ -53,6 +57,10 @@ jobs:
- name: Install Dependencies
run: yarn --immutable

- name: Install Legacy Node Resolver
if: ${{ matrix.legacyNodeResolver }}
run: yarn add -D eslint-import-resolver-node

- name: Build and Test
run: yarn run-s test-compiled test

Expand Down
15 changes: 10 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,25 @@
"watch": "yarn test --watch"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0"
"eslint": "^8.57.0 || ^9.0.0",
"eslint-import-resolver-node": "*"
},
"peerDependenciesMeta": {
"eslint-import-resolver-node": {
"optional": true
}
},
"dependencies": {
"@typescript-eslint/utils": "^8.32.1",
"comment-parser": "^1.4.1",
"debug": "^4.4.1",
"eslint-import-context": "^0.1.5",
"eslint-import-resolver-node": "^0.3.9",
"eslint-import-context": "^0.1.6",
"is-glob": "^4.0.3",
"minimatch": "^9.0.3 || ^10.0.1",
"semver": "^7.7.2",
"stable-hash": "^0.0.5",
"tslib": "^2.8.1",
"unrs-resolver": "^1.7.2"
"unrs-resolver": "^1.7.5"
},
"devDependencies": {
"@1stg/commitlint-config": "^5.0.6",
Expand Down Expand Up @@ -123,7 +128,7 @@
"eslint": "^9.27.0",
"eslint-config-prettier": "^10.1.5",
"eslint-doc-generator": "^2.1.2",
"eslint-import-resolver-typescript": "^4.4.0",
"eslint-import-resolver-typescript": "^4.4.1",
"eslint-import-resolver-webpack": "^0.13.10",
"eslint-import-test-order-redirect": "link:./test/fixtures/order-redirect",
"eslint-plugin-eslint-plugin": "^6.4.0",
Expand Down
1 change: 1 addition & 0 deletions src/rules/no-named-as-default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default createRule<[], MessageId>({
declaration.source.value,
context,
)

if (exportMapOfImported == null) {
return
}
Expand Down
2 changes: 2 additions & 0 deletions src/utils/arraify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const arraify = <T>(value?: T | readonly T[]): T[] | undefined =>
value ? ((Array.isArray(value) ? value : [value]) as T[]) : undefined
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
LegacyImportResolver,
} from 'eslint-import-context'

export * from './arraify.js'
export * from './create-rule.js'
export * from './declared-scope.js'
export * from './docs-url.js'
Expand Down
25 changes: 20 additions & 5 deletions src/utils/legacy-resolver-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ export function normalizeConfigResolvers(
for (const nameOrRecordOrObject of resolverArray) {
if (typeof nameOrRecordOrObject === 'string') {
const name = nameOrRecordOrObject

map.set(name, {
name,
enable: true,
Expand All @@ -64,26 +63,25 @@ export function normalizeConfigResolvers(
} 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)) {
const resolver = requireResolver(name, sourceFile)
if (typeof enableOrOptions === 'boolean') {
map.set(name, {
name,
enable: enableOrOptions,
options: undefined,
resolver: requireResolver(name, sourceFile),
resolver,
})
} else {
map.set(name, {
name,
enable: true,
options: enableOrOptions,
resolver: requireResolver(name, sourceFile),
resolver,
})
}
}
Expand All @@ -98,6 +96,17 @@ export function normalizeConfigResolvers(
return [...map.values()]
}

export const LEGACY_NODE_RESOLVERS = new Set([
'node',
'eslint-import-resolver-node',
])

try {
LEGACY_NODE_RESOLVERS.add(cjsRequire.resolve('eslint-import-resolver-node'))
} catch {
// ignore
}

function requireResolver(name: string, sourceFile: string) {
// Try to resolve package with conventional name
const resolver =
Expand All @@ -106,10 +115,16 @@ function requireResolver(name: string, sourceFile: string) {
tryRequire(path.resolve(getBaseDir(sourceFile), name))

if (!resolver) {
// ignore `node` resolver not found error, we'll use the new one instead
if (LEGACY_NODE_RESOLVERS.has(name)) {
return undefined!
}

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
Expand Down
145 changes: 137 additions & 8 deletions src/utils/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,22 @@ import { fileURLToPath } from 'node:url'
import { setRuleContext } from 'eslint-import-context'
import { stableHash } from 'stable-hash'

import { createNodeResolver } from '../node-resolver.js'
import { cjsRequire } from '../require.js'
import type {
ChildContext,
ImportSettings,
LegacyResolver,
NewResolver,
NodeResolverOptions,
PluginSettings,
RuleContext,
} from '../types.js'

import { arraify } from './arraify.js'
import { makeContextCacheKey } from './export-map.js'
import {
LEGACY_NODE_RESOLVERS,
normalizeConfigResolvers,
resolveWithLegacyResolver,
} from './legacy-resolver-settings.js'
Expand Down Expand Up @@ -114,6 +119,106 @@ function isValidNewResolver(resolver: unknown): resolver is NewResolver {
return true
}

function legacyNodeResolve(
resolverOptions: NodeResolverOptions,
context: ChildContext | RuleContext,
modulePath: string,
sourceFile: string,
) {
const {
extensions,
includeCoreModules,
moduleDirectory,
paths,
preserveSymlinks,
package: packageJson,
packageFilter,
pathFilter,
packageIterator,
...rest
} = resolverOptions

const normalizedExtensions = arraify(extensions)

const modules = arraify(moduleDirectory)

// TODO: change the default behavior to align node itself
const symlinks = preserveSymlinks === false

const resolver = createNodeResolver({
extensions: normalizedExtensions,
builtinModules: includeCoreModules !== false,
modules,
symlinks,
...rest,
})

const resolved = setRuleContext(context, () =>
resolver.resolve(modulePath, sourceFile),
)

if (resolved.found) {
return resolved
}

const normalizedPaths = arraify(paths)

if (normalizedPaths?.length) {
const paths = modules?.length
? normalizedPaths.filter(p => !modules.includes(p))
: normalizedPaths

if (paths.length > 0) {
const resolver = createNodeResolver({
extensions: normalizedExtensions,
builtinModules: includeCoreModules !== false,
modules: paths,
symlinks,
...rest,
})

const resolved = setRuleContext(context, () =>
resolver.resolve(modulePath, sourceFile),
)

if (resolved.found) {
return resolved
}
}
}

if (
[packageJson, packageFilter, pathFilter, packageIterator].some(
it => it != null,
)
) {
let legacyNodeResolver: LegacyResolver
try {
legacyNodeResolver = cjsRequire<LegacyResolver>(
'eslint-import-resolver-node',
)
} catch {
throw new Error(
[
"You're using legacy resolver options which are not supported by the new resolver.",
'Please either:',
'1. Install `eslint-import-resolver-node` as a fallback, or',
'2. Remove legacy options: `package`, `packageFilter`, `pathFilter`, `packageIterator`',
].join('\n'),
)
}
const resolved = resolveWithLegacyResolver(
legacyNodeResolver,
resolverOptions,
modulePath,
sourceFile,
)
if (resolved.found) {
return resolved
}
}
}

function fullResolve(
modulePath: string,
sourceFile: string,
Expand All @@ -140,11 +245,11 @@ function fullResolve(

const cacheKey =
sourceDir +
',' +
'\0' +
childContextHashKey +
',' +
'\0' +
memoizedHash +
',' +
'\0' +
modulePath

const cacheSettings = ModuleCache.getSettings(settings)
Expand All @@ -154,10 +259,7 @@ function fullResolve(
return { found: true, path: cachedPath }
}

if (
Object.hasOwn(settings, 'import-x/resolver-next') &&
settings['import-x/resolver-next']
) {
if (settings['import-x/resolver-next']) {
let configResolvers = settings['import-x/resolver-next']

if (!Array.isArray(configResolvers)) {
Expand Down Expand Up @@ -196,14 +298,41 @@ function fullResolve(
node: settings['import-x/resolve'],
} // backward compatibility

for (const { enable, options, resolver } of normalizeConfigResolvers(
for (const { enable, name, options, resolver } of normalizeConfigResolvers(
configResolvers,
sourceFile,
)) {
if (!enable) {
continue
}

// if the resolver is `eslint-import-resolver-node`, we use the new `node` resolver first
// and try `eslint-import-resolver-node` as fallback instead
if (LEGACY_NODE_RESOLVERS.has(name)) {
const resolverOptions = (options || {}) as NodeResolverOptions
const resolved = legacyNodeResolve(
resolverOptions,
// TODO: enable the following in the next major
// {
// ...resolverOptions,
// extensions:
// resolverOptions.extensions || settings['import-x/extensions'],
// },
context,
modulePath,
sourceFile,
)

if (resolved?.found) {
fileExistsCache.set(cacheKey, resolved.path)
return resolved
}

if (!resolver) {
continue
}
}

const resolved = setRuleContext(context, () =>
resolveWithLegacyResolver(resolver, options, modulePath, sourceFile),
)
Expand Down
2 changes: 1 addition & 1 deletion test/__snapshots__/node-resolver.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ exports[`modules jest => true 2`] = `
"expected": true,
"result": {
"found": true,
"path": "<ROOT>/node_modules/jest/build/index.js",
"path": "<ROOT>/node_modules/jest/build/index.mjs",
},
"source": "jest",
}
Expand Down
Loading