Skip to content

Instantly share code, notes, and snippets.

@danielweck
Last active November 11, 2022 08:38
Show Gist options
  • Save danielweck/cd63af8e9a8b3492abacc312af9f28fd to your computer and use it in GitHub Desktop.
Save danielweck/cd63af8e9a8b3492abacc312af9f28fd to your computer and use it in GitHub Desktop.
ESLint import resolver for ESM modules via package.json exports map
module.exports = {
settings: {
// optionally, if TypeScript project:
// 'import/parsers': {
// '@typescript-eslint/parser': ['.ts', '.tsx'],
// },
'import/resolver': {
// optionally, if TypeScript project:
// https://github.com/alexgorbatchev/eslint-import-resolver-typescript
// typescript: {
// alwaysTryTypes: false,
// project: ['./PATH/TO/tsconfig.json'],
// },
[path.resolve('./eslint-plugin-import-resolver.js')]: { someConfig: 1 },
},
},
},
const path = require('path');
const { resolve: resolveExports } = require('resolve.exports');
// optionally handle NodeJS built-ins just in case not handled by another ESLint module resolver in the chain
const { builtinModules } = require('module');
const builtins = new Set(builtinModules);
/**
* @param {string} source source
* @param {string} file file
* @param {Object} _config config
*/
const resolve = (source, file, _config) => {
if (builtins.has(source)) {
// return { found: false }; // this also works?
return { found: true, path: null };
}
try {
const moduleId = require.resolve(source, { paths: [path.dirname(file)] });
return { found: true, path: moduleId };
} catch (/** @type {any} */ err) {
if (err.code === 'MODULE_NOT_FOUND' && err.path?.endsWith('/package.json')) {
const { name, module, main, exports } = require(err.path);
const resolved = resolveExports({ name, module, main, exports }, source);
const moduleId = path.join(path.dirname(err.path), resolved);
return { found: true, path: moduleId };
}
return { found: false };
}
};
module.exports = {
interfaceVersion: 2,
resolve,
};
@thepassle
Copy link

Ran into an issue where this resolver would crash other eslint rules because it was trying to resolve builtin modules incorrectly.

Specifically this error, for import/no-cycle:

ops! Something went wrong! :(

ESLint: 8.12.0

Error: ENOENT: no such file or directory, stat 'path'
Occurred while linting /Users/blank/my-project/index.js:1
Rule: "import/no-cycle"
    at Object.statSync (node:fs:1536:3)
    at Function.ExportMap.for (/Users/blank/my-project/node_modules/eslint-plugin-import/lib/ExportMap.js:792:67)
    at /Users/blank/my-project/node_modules/eslint-plugin-import/lib/ExportMap.js:830:142
    at detectCycle (/Users/blank/my-project/node_modules/eslint-plugin-import/lib/rules/no-cycle.js:80:19)
    at checkSourceValue (/Users/blank/my-project/node_modules/eslint-plugin-import/lib/rules/no-cycle.js:113:15)
    at checkSourceValue (/Users/blank/my-project/node_modules/eslint-module-utils/moduleVisitor.js:29:5)
    at checkSource (/Users/blank/my-project/node_modules/eslint-module-utils/moduleVisitor.js:34:5)
    at ruleErrorHandler (/Users/blank/my-project/node_modules/eslint/lib/linter/linter.js:1114:28)
    at /Users/blank/my-project/node_modules/eslint/lib/linter/safe-emitter.js:45:58
    at Array.forEach (<anonymous>)

Fixed it with the following snippet:

const path = require('path');
const { resolve: resolveExports } = require('resolve.exports');
+const { builtinModules } = require('module');

/**
 * @param {string} source source
 * @param {string} file file
 * @param {Object} _config config
 */
const resolve = (source, file, _config) => {
  try {
    const moduleId = require.resolve(source, { paths: [path.dirname(file)] });

+    if (builtinModules.includes(moduleId)) {
+      return { found: false };
+    }

    return { found: true, path: moduleId };
  } catch (/** @type {any} */ err) {
    if (err.code === 'MODULE_NOT_FOUND' && err.path?.endsWith('/package.json')) {
      const { name, module, main, exports } = require(err.path);
      const resolved = resolveExports({ name, module, main, exports }, source);
      const moduleId = path.join(path.dirname(err.path), resolved);
      return { found: true, path: moduleId };
    }
    return { found: false };
  }
};

module.exports = {
  interfaceVersion: 2,
  resolve,
};

@danielweck
Copy link
Author

Thank you. I am actually using a slightly different technique in my project, which I probably figured out after I posted this Gist (note to self: update the Gist!)

I am not sure which technique is "best" (in terms of of how the resolver stack is expected to work). Here's my additions:

const { builtinModules } = require('module');
const builtins = new Set(builtinModules);
// ...
const resolve = (source, file, _config) => {
	if (builtins.has(source)) {
		return { found: true, path: null };
	}
// ...

... as opposed to your fix which returns false after a successful require.resolve():

const { builtinModules } = require('module');
// ...
const resolve = (source, file, _config) => {
// ...
	const moduleId = require.resolve(source, { paths: [path.dirname(file)] });
	if (builtinModules.includes(moduleId)) {
		return { found: false };
	}
// ...

What do you think? (I'll run some tests at my end)

@danielweck
Copy link
Author

danielweck commented Mar 30, 2022

Ok, so I ran some additional tests.

Both "my" and "your" builtinModules solutions work (I tested them one by one, and I added an import typo like fss instead of fs to check false negatives / positives), ... however note in my case I have the following ESLint config:

settings: {
	react: {
		version: '17',
	},
	'import/parsers': {
		'@typescript-eslint/parser': ['.ts', '.tsx'],
	},
	'import/resolver': {
		// https://github.com/alexgorbatchev/eslint-import-resolver-typescript
		typescript: {
			alwaysTryTypes: false,
			project: ['./PATH/TO/tsconfig.json'],
		},
		[path.resolve('./eslint-plugin-import-resolver.cjs')]: { someConfig: 1 },
	},
}

...the important part is the import/resolver > typescript object, which when commented-out triggers either of our builtinModules conditionals (for example, for import { readFileSync } from "fs"). However if I leave my typescript settings in, then the builtinModules conditionals are never reached, as NodeJS module imports such as fs seem to be captured appropriately somewhere else in the ESLint resolver chain, outside of my custom resolver.

@dding-g
Copy link

dding-g commented Apr 25, 2022

Hello.
Thank you for your problem resolve!

I have opinion on catching error.

//...
	catch (/** @type {any} */ err) {
		if (err.code === 'MODULE_NOT_FOUND' && err.path?.endsWith('/package.json')) {
			const { name, module, main, exports } = require(err.path);
			const resolved = resolveExports({ name, module, main, exports }, source);
			const moduleId = path.join(path.dirname(err.path), resolved);
			return { found: true, path: moduleId };
		}
//...

In my case, use subpath on node.

When cannot resolve moduleId in try, err.path is always package.json.

When moduleId cannot be defined in the try syntax, the error.path in the catch syntax is always package.json.
I think, printing a path that cannot be resolved is much useful. also it is already defined in 'source'.
EX:) error Unable to resolve path to module '#utils/error' import/no-unresolved

Then, what kind of error situation should the following syntax be executed?

//...
			const { name, module, main, exports } = require(err.path);
			const resolved = resolveExports({ name, module, main, exports }, source);
			const moduleId = path.join(path.dirname(err.path), resolved);
//...

Thank you. :)

@k7sleeper
Copy link

Thanks for that solution.

I had to change the following line:

const possibleBuildinModuleId = moduleId.startsWith('node:') ? moduleId.slice(5) : moduleId;
if (builtinModules.includes(possibleBuildinModuleId)) {
   return { found: false };
}

@k7sleeper
Copy link

Nevertheless, I get the following error:

 build-scripts\create-run-env\index.js:25:8
  ✖  25:8  Relative imports from parent directories are not allowed. Please either pass what you're importing through at runtime (dependency injection), move index.js to same directory as #build-scripts/constants.js or consider making #build-scripts/constants.js a package.  import/no-relative-parent-imports

  1 error

So, this solution does not cooperate with rule import/no-relative-parent-imports

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment