Skip to content

Instantly share code, notes, and snippets.

@SgtPooki
Last active July 5, 2022 21:24
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save SgtPooki/65e189531f4a5366ef4f80825feb2e5f to your computer and use it in GitHub Desktop.
Save SgtPooki/65e189531f4a5366ef4f80825feb2e5f to your computer and use it in GitHub Desktop.
eslint-plugin-esm: eslint rules to help autofix CJS code you're migrating to ESM
diff --git a/node_modules/eslint-plugin-import/config/recommended-esm.js b/node_modules/eslint-plugin-import/config/recommended-esm.js
new file mode 100644
index 0000000..0b78a13
--- /dev/null
+++ b/node_modules/eslint-plugin-import/config/recommended-esm.js
@@ -0,0 +1,20 @@
+const recommendedConfig = require('./recommended')
+
+/**
+ * The basics.
+ * @type {Object}
+ */
+module.exports = {
+ ...recommendedConfig,
+ rules: {
+ ...recommendedConfig.rules,
+ 'import/esm-extensions': 'error',
+ },
+
+ // need all these for parsing dependencies (even if _your_ code doesn't need
+ // all of them)
+ parserOptions: {
+ ...recommendedConfig.parserOptions,
+ ecmaVersion: 2020,
+ },
+};
diff --git a/node_modules/eslint-plugin-import/lib/index.js b/node_modules/eslint-plugin-import/lib/index.js
index 247818e..232ec8a 100644
--- a/node_modules/eslint-plugin-import/lib/index.js
+++ b/node_modules/eslint-plugin-import/lib/index.js
@@ -14,6 +14,7 @@
'no-relative-parent-imports': require('./rules/no-relative-parent-imports'),
'no-self-import': require('./rules/no-self-import'),
+
'no-cycle': require('./rules/no-cycle'),
'no-named-default': require('./rules/no-named-default'),
'no-named-as-default': require('./rules/no-named-as-default'),
@@ -41,6 +42,7 @@
'no-useless-path-segments': require('./rules/no-useless-path-segments'),
'dynamic-import-chunkname': require('./rules/dynamic-import-chunkname'),
'no-import-module-exports': require('./rules/no-import-module-exports'),
+ 'esm-extensions': require('./rules/esm-extensions'),
// export
'exports-last': require('./rules/exports-last'),
@@ -54,6 +56,7 @@
var configs = exports.configs = {
'recommended': require('../config/recommended'),
+ 'recommended-esm': require('../config/recommended-esm'),
'errors': require('../config/errors'),
'warnings': require('../config/warnings'),
diff --git a/node_modules/eslint-plugin-import/lib/rules/esm-extensions.js b/node_modules/eslint-plugin-import/lib/rules/esm-extensions.js
new file mode 100644
index 0000000..17294cd
--- /dev/null
+++ b/node_modules/eslint-plugin-import/lib/rules/esm-extensions.js
@@ -0,0 +1,205 @@
+"use strict";
+var __assign = (this && this.__assign) || function () {
+ __assign = Object.assign || function(t) {
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
+ s = arguments[i];
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
+ t[p] = s[p];
+ }
+ return t;
+ };
+ return __assign.apply(this, arguments);
+};
+var fs_1 = require("fs");
+var path_1 = require("path");
+var extensions = ['.js', '.ts', '.mjs', '.cjs'];
+extensions.push.apply(extensions, extensions.map(function (ext) { return "/index".concat(ext); }));
+// TypeScript Guards
+var isSimpleLiteralCallee = function (callee) { return callee != null && callee.type === 'Identifier' && callee.name != null; };
+// ReportFixers
+var getEsmImportFixer = function (tokenLiteral, updated) { return function (fixer) {
+ const fixed = fixer.replaceText(excludeParenthesisFromTokenLocation(tokenLiteral), updated)
+ // console.log(`tokenLiteral: `, tokenLiteral);
+ // console.log(`updated: `, updated);
+ // console.log(`fixed: `, fixed);
+ return fixed
+ // return fixer.replaceText(excludeParenthesisFromTokenLocation(tokenLiteral), updated);
+}; };
+// util functions
+var fileExists = function (filePath) {
+ try {
+ (0, fs_1.accessSync)(filePath);
+ return true;
+ }
+ catch (err) {
+ if ((err === null || err === void 0 ? void 0 : err.code) === 'ENOENT') {
+ // known and somewhat expected failure case.
+ return false;
+ }
+ // console.error('Unexpected error attempting to access filepath', filePath);
+ // console.error(err);
+ return false;
+ }
+};
+var excludeParenthesisFromTokenLocation = function (token) {
+ if (token.range == null || token.loc == null) {
+ return token;
+ }
+ var rangeStart = token.range[0] + 1;
+ var rangeEnd = token.range[1] - 1;
+ var locColStart = token.loc.start.column + 1;
+ var locColEnd = token.loc.end.column - 1;
+ var newToken = __assign(__assign({}, token), { range: [rangeStart, rangeEnd], loc: {
+ start: __assign(__assign({}, token.loc.start), { column: locColStart }),
+ end: __assign(__assign({}, token.loc.end), { column: locColEnd }),
+ } });
+
+ return newToken;
+};
+
+var shouldIgnoreImport = function (importedPath) {
+ return typeof importedPath !== 'string' || importedPath[0] !== '.' || importedPath.match(/\.(?:json|css|svg)/) != null
+}
+
+var handleNodeWithImport = function (context, node) {
+ if (node.source == null) {
+ return;
+ }
+ var importSource = node.source;
+ var importedPath = importSource.value;
+ if (shouldIgnoreImport(importedPath)) {
+ return;
+ }
+ var cwd = context.getCwd();
+ var filename = context.getFilename();
+ var relativeFilePath = (0, path_1.relative)(cwd, filename);
+ var relativeSourceFileDir = (0, path_1.dirname)(relativeFilePath);
+ var absoluteSourceFileDir = (0, path_1.resolve)(cwd, relativeSourceFileDir);
+ var importHasJsExtension = importedPath.match(/\.js$/);
+ var importedFileAbsolutePath = (0, path_1.resolve)(absoluteSourceFileDir, importedPath);
+ var correctImportAbsolutePath = null;
+ if (importHasJsExtension == null) {
+ // no extension, try different ones.
+ try {
+ for (var _i = 0, extensions_1 = extensions; _i < extensions_1.length; _i++) {
+ var ext = extensions_1[_i];
+ var path = "".concat(importedFileAbsolutePath).concat(ext);
+ if (fileExists(path)) {
+ correctImportAbsolutePath = path;
+ break;
+ }
+ }
+ }
+ catch (err) {
+ console.error(err);
+ }
+ }
+ else {
+ // extension exists, try to access it.
+ if (fileExists(importedFileAbsolutePath)) {
+ correctImportAbsolutePath = importedFileAbsolutePath;
+ }
+ else if (relativeFilePath.match(/\.ts/) != null) {
+ // if we're in a typescript repo and they're using .js extensions, they wont exist in the source.
+ var typescriptImportedFileAbsolutePath = importedFileAbsolutePath.replace(/\.js/, '.ts');
+ if (fileExists(typescriptImportedFileAbsolutePath)) {
+ correctImportAbsolutePath = importedFileAbsolutePath;
+ }
+ else {
+ console.log('importedFileAbsolutePath doesnt exist', importedFileAbsolutePath);
+ console.log('typescriptImportedFileAbsolutePath doesnt exist', typescriptImportedFileAbsolutePath);
+ console.log('node', node);
+ throw new Error('Workaround not implemented');
+ }
+ }
+ else {
+ console.log('importedFileAbsolutePath doesnt exist', importedFileAbsolutePath);
+ console.log('And the file being checked is not a typescript file:', relativeFilePath);
+ throw new Error('Workaround not implemented');
+ }
+ }
+ var importOrExportLabel = node.type.match(/import/i) != null ? 'import of' : 'export from';
+ if (correctImportAbsolutePath == null) {
+ context.report({
+ message: "Could not determine whether current import path of '".concat(importedPath, "' is valid or not"),
+ node: node
+ });
+ }
+ else {
+ if (importedFileAbsolutePath !== correctImportAbsolutePath) {
+ var correctImportPath = (0, path_1.relative)(absoluteSourceFileDir, correctImportAbsolutePath);
+ if (correctImportPath.match(/^\./) == null) {
+ correctImportPath = './'.concat(correctImportPath);
+ }
+ // console.log(`correctImportAbsolutePath: `, correctImportAbsolutePath);
+ // console.log(`absoluteSourceFileDir: `, absoluteSourceFileDir);
+ var suggestionDesc = "Use '".concat(correctImportPath, "' instead.");
+ var fix = getEsmImportFixer(importSource, correctImportPath);
+ context.report({
+ message: "Invalid ESM ".concat(importOrExportLabel, " '").concat(importedPath, "'. ").concat(suggestionDesc),
+ node: node,
+ suggest: [
+ {
+ desc: suggestionDesc,
+ fix: fix,
+ }
+ ],
+ fix: fix,
+ });
+ }
+ }
+};
+// Rule Listeners
+var getVariableDeclarationListener = function (_a) {
+ var context = _a.context;
+ return function (node) {
+ var sourceCode = context.getSourceCode();
+ var nodeSource = sourceCode.getText(node);
+ if (nodeSource.match(/= require\([^)]+\)/)) {
+ node.declarations.forEach(function (declaration) {
+ if (declaration.init && declaration.init.type === 'CallExpression') {
+ var callee = declaration.init.callee;
+ if (isSimpleLiteralCallee(callee) && callee.name === 'require') {
+ context.report({
+ message: "Do not use require inside of ESM modules",
+ node: node,
+ });
+ }
+ }
+ });
+ }
+ };
+};
+var getImportDeclarationListener = function (_a) {
+ var context = _a.context;
+ return function (node) {
+ handleNodeWithImport(context, node);
+ };
+};
+var getExportDeclarationListener = function (_a) {
+ var context = _a.context, name = _a.name;
+ return function (node) {
+ var sourceCode = context.getSourceCode();
+ var exportSource = sourceCode.getText(node);
+ if (exportSource.match(/ from /) == null) {
+ return;
+ }
+ handleNodeWithImport(context, node);
+ };
+};
+// Rule
+var esmExtensionsRule = {
+ meta: {
+ hasSuggestions: true,
+ fixable: 'code'
+ },
+ create: function (context) { return ({
+ VariableDeclaration: getVariableDeclarationListener({ context: context }),
+ ExportAllDeclaration: getExportDeclarationListener({ context: context, name: 'ExportAllDeclaration' }),
+ ExportDeclaration: getExportDeclarationListener({ context: context, name: 'ExportDeclaration' }),
+ ExportNamedDeclaration: getExportDeclarationListener({ context: context, name: 'ExportNamedDeclaration' }),
+ ImportDeclaration: getImportDeclarationListener({ context: context }),
+ }); }
+};
+module.exports = esmExtensionsRule;
+//# sourceMappingURL=esm-extensions.js.map
/** This file has not been updated to match the above js edit from 2022 JUL 05 */
import type { Rule } from 'eslint'
import type { ImportDeclaration, VariableDeclaration, SimpleLiteral, Expression , Super, Literal, ExportAllDeclaration, ExportNamedDeclaration } from 'estree'
import { accessSync } from 'fs'
import { resolve, dirname, relative } from 'path'
// resolve('', '')
declare module 'estree' {
interface ImportDeclaration {
importKind: string
}
interface SimpleLiteral {
name?: string
}
}
interface GetListenerOptions {
context: Rule.RuleContext
name?: string
}
type ExportDeclaration = ExportAllDeclaration | ExportNamedDeclaration
const extensions = ['.js', '.ts', '.mjs', '.cjs']
extensions.push(...extensions.map((ext) => `/index${ext}`))
// TypeScript Guards
const isSimpleLiteralCallee = (callee: Expression | Super): callee is SimpleLiteral => callee != null && callee.type === 'Identifier' && (callee as unknown as SimpleLiteral).name != null
// ReportFixers
const getEsmImportFixer = (tokenLiteral: Literal, updated: string): Rule.ReportFixer => (fixer) => {
return fixer.replaceText(excludeParenthesisFromTokenLocation(tokenLiteral), updated)
}
// util functions
const fileExists = (filePath: string) => {
type FileAccessError = Error & { code: string, syscall: 'access', errno: number, path: string }
try {
accessSync(filePath)
return true
} catch (err) {
if ((err as FileAccessError)?.code === 'ENOENT') {
// known and somewhat expected failure case.
return false
}
console.error('Unexpected error attempting to access filepath', filePath)
console.error(err)
return false
}
}
const excludeParenthesisFromTokenLocation = (token: Literal): Literal => {
if (token.range == null || token.loc == null) {
return token
}
const rangeStart = token.range[0] + 1
const rangeEnd = token.range[1] - 1
const locColStart = token.loc.start.column + 1
const locColEnd = token.loc.end.column - 1
const newToken: Literal = {
...token,
range: [rangeStart, rangeEnd],
loc: {
start: {...token.loc.start, column: locColStart },
end: {...token.loc.end, column: locColEnd },
},
}
return newToken
}
const handleNodeWithImport = (context: Rule.RuleContext, node: (ImportDeclaration | ExportDeclaration) & Rule.NodeParentExtension) => {
if (node.source == null) {
return
}
const importSource = node.source as Literal
const importedPath = importSource.value
if (typeof importedPath !== 'string' || importedPath[0] !== '.') {
return
}
const cwd = context.getCwd()
const filename = context.getFilename()
const relativeFilePath = relative(cwd, filename)
const relativeSourceFileDir = dirname(relativeFilePath)
const absoluteSourceFileDir = resolve(cwd, relativeSourceFileDir)
const importHasJsExtension = importedPath.match(/\.js$/)
const importedFileAbsolutePath = resolve(absoluteSourceFileDir, importedPath)
let correctImportAbsolutePath = null
if (importHasJsExtension == null) {
// no extension, try different ones.
try {
for (const ext of extensions) {
const path = `${importedFileAbsolutePath}${ext}`
if (fileExists(path)) {
correctImportAbsolutePath = path
break;
}
}
} catch (err) {
console.error(err)
}
} else {
// extension exists, try to access it.
if (fileExists(importedFileAbsolutePath)) {
correctImportAbsolutePath = importedFileAbsolutePath
} else if (relativeFilePath.match(/\.ts/) != null) {
// if we're in a typescript repo and they're using .js extensions, they wont exist in the source.
const typescriptImportedFileAbsolutePath = importedFileAbsolutePath.replace(/\.js/, '.ts')
if (fileExists(typescriptImportedFileAbsolutePath)) {
correctImportAbsolutePath = importedFileAbsolutePath
} else {
console.log('importedFileAbsolutePath doesnt exist', importedFileAbsolutePath)
console.log('typescriptImportedFileAbsolutePath doesnt exist', typescriptImportedFileAbsolutePath)
console.log('node', node)
throw new Error('Workaround not implemented')
}
} else {
console.log('importedFileAbsolutePath doesnt exist', importedFileAbsolutePath)
console.log('And the file being checked is not a typescript file:', relativeFilePath)
throw new Error('Workaround not implemented')
}
}
const importOrExportLabel = node.type.match(/import/i) != null ? 'import of' : 'export from'
if (correctImportAbsolutePath == null) {
context.report({
message: `Could not determine whether current import path of '${importedPath}' is valid or not`,
node
})
} else {
if (importedFileAbsolutePath !== correctImportAbsolutePath) {
const correctImportPath = relative(absoluteSourceFileDir, correctImportAbsolutePath)
const suggestionDesc = `Use '${correctImportPath}' instead.`
const fix = getEsmImportFixer(importSource, correctImportPath)
context.report({
message: `Invalid ESM ${importOrExportLabel} '${importedPath}'. ${suggestionDesc}`,
node,
suggest: [
{
desc: suggestionDesc,
fix,
}
],
fix,
})
}
}
}
// Rule Listeners
const getVariableDeclarationListener = ({ context }: GetListenerOptions) => function (node: VariableDeclaration & Rule.NodeParentExtension) {
const sourceCode = context.getSourceCode()
const nodeSource = sourceCode.getText(node)
if (nodeSource.match(/= require\([^)]+\)/)) {
node.declarations.forEach((declaration) => {
if (declaration.init && declaration.init.type === 'CallExpression') {
const { callee } = declaration.init
if (isSimpleLiteralCallee(callee) && callee.name === 'require') {
context.report({
message: "Do not use require inside of ESM modules",
node,
})
}
}
})
}
}
const getImportDeclarationListener = ({context}: GetListenerOptions) => function (node: ImportDeclaration & Rule.NodeParentExtension) {
handleNodeWithImport(context, node)
}
const getExportDeclarationListener = ({context, name}: GetListenerOptions) => function (node: ExportDeclaration & Rule.NodeParentExtension) {
const sourceCode = context.getSourceCode()
const exportSource = sourceCode.getText(node)
if (exportSource.match(/ from /) == null) {
return
}
handleNodeWithImport(context, node)
}
// Rule
const esmExtensionsRule: Rule.RuleModule = {
meta: {
hasSuggestions: true,
fixable: 'code'
},
create: (context: Rule.RuleContext) => ({
VariableDeclaration: getVariableDeclarationListener({ context }),
ExportAllDeclaration: getExportDeclarationListener({context, name: 'ExportAllDeclaration'}),
ExportDeclaration: getExportDeclarationListener({context, name: 'ExportDeclaration'}),
ExportNamedDeclaration: getExportDeclarationListener({context, name: 'ExportNamedDeclaration'}),
ImportDeclaration: getImportDeclarationListener({context}),
})
}
// @ts-ignore
// ignoring this rule because we use tsconfig.rules.json which is outputting cjs, not esm
export = esmExtensionsRule

Step 1: Install patch-package

npm install -D patch-package

Step 2: copy the contents of the patch file above

curl https://gist.githubusercontent.com/SgtPooki/65e189531f4a5366ef4f80825feb2e5f/raw/8743a14582e8bc4eab6018ed2837048da3c9c8b4/eslint-plugin-import+2.26.0.patch --output patches/eslint-plugin-import+2.26.0.patch --silent

run this command:

npx eslint --plugin 'import' --rule import/esm-extensions:error --ext 'js' src/**/*.js --quiet
{
"extends": "aegir/src/config/tsconfig.aegir.json",
"compilerOptions": {
"outDir": "dist",
"module": "CommonJS",
"target": "ES5",
},
"include": [
"config/eslint/rules",
],
}
@SgtPooki
Copy link
Author

SgtPooki commented May 9, 2022

command to run with

tsc -p tsconfig.rules.json && cp dist/config/eslint/rules/esm-extensions.js node_modules/eslint-plugin-import/lib/rules/esm-extensions.js && patch-package eslint-plugin-import && TIMING=1 eslint --no-eslintrc --plugin "@typescript-eslint" --plugin "import" --parser "@typescript-eslint/parser" --rule 'import/esm-extensions: "error"' --ignore-pattern 'node_modules/*' src/*.ts generated/fetch/**/*.ts

or as package.json script

{
"test-eslint-rule": "tsc -p tsconfig.rules.json && cp dist/config/eslint/rules/esm-extensions.js node_modules/eslint-plugin-import/lib/rules/esm-extensions.js && patch-package eslint-plugin-import && TIMING=1 eslint --no-eslintrc --plugin \"@typescript-eslint\" --plugin \"import\" --parser \"@typescript-eslint/parser\" --rule 'import/esm-extensions: \"error\"' --ignore-pattern 'node_modules/*' --ignore-pattern '!generated/**/*.{j,t}s' generated/fetch/**/*.ts"
}

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