eslint-config-bevry
Version:
Intelligent, self-configuring ESLint configuration that automatically analyzes your project structure, dependencies, and metadata to apply optimal linting rules for JavaScript, TypeScript, React, Node.js, and more.
454 lines (401 loc) • 14.3 kB
JavaScript
// ESLint Core
import { globalIgnores } from 'eslint/config'
import globals from 'globals' // eslint-disable-line
// ESLint Plugins
import eslintJS from '@eslint/js'
import eslintReact from 'eslint-plugin-react'
import eslintReactHooks from 'eslint-plugin-react-hooks'
import eslintPrettier from 'eslint-config-prettier/flat'
import eslintTypescript from 'typescript-eslint'
import eslintNode from 'eslint-plugin-n'
import eslintImport from 'eslint-plugin-import'
import eslintJSDoc from 'eslint-plugin-jsdoc'
import babelParser from '@babel/eslint-parser'
import babelPlugin from '@babel/eslint-plugin'
// Our dependencies
import versionClean from 'version-clean'
// Node.js dependencies
import { join } from 'node:path'
import { cwd } from 'node:process'
// Local imports
import * as rules from './rules.js'
// Local paths
import { readJSON } from '@bevry/json'
import filedirname from 'filedirname'
const [, dirname] = filedirname()
const bevryRootPath = join(dirname, '..')
const userRootPath = cwd()
const bevryPackagePath = join(bevryRootPath, 'package.json')
const userPackagePath = join(userRootPath, 'package.json')
// ------------------------------------
// Prepare
/**
* Error thrown when a dependency that should be inlined is found in the user's package.json
*/
class InlinedError extends Error {
/**
* Creates an InlinedError instance
* @param {string} dependency - The name of the dependency that is now inlined
*/
constructor(dependency) {
super(
`the development dependency ${dependency} is now inlined within eslint-config-bevry\nrun: npm uninstall --save-dev ${dependency}`,
)
}
}
/**
* Reads a JSON file, returning an empty object if reading failed.
* @param {string} path - The path to the JSON file to read
* @param {object} [fallback] - The fallback value to return if reading fails
* @returns {Promise<object>} The parsed JSON object or the fallback value
*/
function readJSONFallback(path, fallback = {}) {
return readJSON(path).catch(() => fallback)
}
const bevryPackage = await readJSON(bevryPackagePath)
const userPackage = await readJSONFallback(userPackagePath)
// https://eslint.org/docs/latest/use/configure/configuration-files#configuration-objects
/** @type {import('eslint').Linter.Config} */
const config = {
name: bevryPackage.name,
// version: bevryPackage.version,
files: userPackage.eslintConfig?.files || [],
// don't use ignores, as it doesn't help us with matched patterns from extended configurations, instead use globalIgnores
extends: [
globalIgnores([
// '**/*.d.ts', <-- now that we fixed the rules that conflict with typescript with v6.1.1, ignoring these isn't necessary anymore
'**/vendor/',
'**/node_modules/',
'**/edition-*/',
...(userPackage.eslintConfig?.ignores || []),
]),
rules.jsBefore,
eslintJS.configs.recommended,
rules.jsAfter,
],
languageOptions: {
// ecmaVersion: null,
// sourceType: null,
globals: {},
// parser: null,
parserOptions: {
ecmaFeatures: {},
},
},
linterOptions: {
// noInlineConfig: false,
// reportUnusedDisableDirectives: 'warn',
// reportUnusedInlineConfigs: 'off',
},
// processor: {}
// plugins: {},
rules: {},
settings: {},
// env has been deprecated: https://eslint.org/docs/latest/use/configure/migration-guide#configuring-language-options
}
// ------------------------------------
// Load
// dependency helpers
const deps = {}
const versions = {}
/**
* Check if a dependency is included in the caller's package.json
* @param {string} name package name/identifier for the dependency
* @returns {boolean} true if the dependency is present, false otherwise
*/
function hasDep(name) {
return Boolean(deps[name])
}
/**
* Throws if any of these inlined dependencies are present in the caller's package.json
* @param {...string} names - The package names or identifiers to check for inlining.
* @throws {InlinedError} if any of the specified dependencies are present
*/
function inlinedDeps(...names) {
if (userPackage.name === 'eslint-config-bevry') return
for (const name of names) {
if (hasDep(name)) {
throw new InlinedError(name)
}
}
}
// Load the dependencies and versions
Object.assign(
deps,
userPackage.dependencies || {},
userPackage.devDependencies || {},
)
Object.keys(deps).forEach((name) => {
const range = deps[name]
const version = versionClean(range) || null // resolve to null in case of github references
versions[name] = version
})
// extract some common items
const keywords = userPackage.keywords || []
// ------------------------------------
// Deprecations
inlinedDeps(
// javascript
'@eslint/js', // inlined
// import
'eslint-plugin-import', // inlined
// node
'eslint-plugin-n', // inlined
// react
'eslint-plugin-react', // inlined
'eslint-plugin-react-hooks', // inlined
// babel
// https://babeljs.io/docs/babel-eslint-plugin
'@babel/eslint-parser', // inlined
'@babel/eslint-plugin', // inlined
'babel-eslint', // deprecated: @babel/eslint-parser
'eslint-plugin-babel', // deprecated: @babel/eslint-plugin
// typescript
'typescript-eslint', // inlined
'@typescript-eslint/eslint-plugin', // deprecated: typescript-eslint
// jsdoc
'eslint-plugin-jsdoc', // inlined
// prettier, must come last as it modifies things from nearly all other plugins
'eslint-config-prettier', // inlined
'eslint-plugin-prettier', // deprecated: boundation calls prettier separately, as such we just use eslint-config-prettier to disable formatting conflicts
)
// the below are no longer supported by us
// http://flowtype.org which is the old domain is now 404, the new domains is https://flow.org and it still gets updated, however it hasn't been updated for eslint v9 yet, and typescript won, with native typescript support now in node.js and deno
// https://flow.org/en/docs/tools/eslint/
// 'flow-bin',
// 'hermes-eslint', // the required parser, not compatible with eslint v9
// 'eslint-plugin-fb-flow', // the required plugin/config, not compatible with eslint v9
// 'eslint-plugin-flow-vars', // the odl plugin, last update 4 years ago: https://github.com/gajus/eslint-plugin-flowtype
// baseui, the baseui ecosystem has been abandoned: github issues have been disabled, and a notice of limited engagement is in the readme
// 'baseui',
// 'eslint-plugin-baseui', // not comaptible with eslint v9
// ------------------------------------
// Enhancements
// prepare
const ecmascriptNextYear = new Date().getFullYear() - 1
const ecmascriptNextVersion = 'latest'
let ecmascriptVersionSource = ecmascriptNextVersion
let ecmascriptVersionTarget = ecmascriptVersionSource
let sourceType = 'script',
react = hasDep('react'),
typescript = hasDep('typescript'),
babel = hasDep('@babel/core'),
jsx = false
const prettier = Boolean(userPackage.prettier) || hasDep('prettier'),
browser = Boolean(userPackage.browser) || keywords.includes('browser'),
node = Boolean(userPackage.engines?.node) || keywords.includes('node'),
worker =
keywords.includes('worker') ||
keywords.includes('workers') ||
keywords.includes('webworker') ||
keywords.includes('webworkers')
// @todo to support `n/no-unsupported-features/es-syntax` (which crazily uses Node.js version instead of ecmaVersion) need to use @bevry/nodejs-versions to compare all Node.js versions against the range... or, go through each edition with node.js engines and split on || and get the highest value
/**
* Ensure the ecmascript version is coerced to an eslint valid ecmascript version
* @param {string} [version] - The version string to coerce (e.g., 'esnext', 'latest', '2020')
* @returns {string|number} The coerced version as a string or number, or empty string if invalid
*/
function coerceEcmascriptVersion(version = '') {
if (version === 'esnext' || version === 'latest' || version === 'next') {
return ecmascriptNextVersion
} else if (version) {
version = versionClean(version)
if (version) {
version = Number(version)
if (version >= ecmascriptNextYear) {
return ecmascriptNextVersion
} else {
return version
}
}
}
return ''
}
// editions
if (userPackage.editions) {
const sourceEdition = userPackage.editions[0]
const sourceEditionTags = sourceEdition.tags || sourceEdition.syntaxes || []
const ecmascriptVersionTag = coerceEcmascriptVersion(
sourceEditionTags.find((tag) => tag.startsWith('es')),
)
const ecmascriptVersionEngine = coerceEcmascriptVersion(
userPackage.engines?.ecmascript,
)
ecmascriptVersionSource =
ecmascriptVersionTag || ecmascriptVersionEngine || ecmascriptNextVersion
ecmascriptVersionTarget =
ecmascriptVersionEngine || ecmascriptVersionTag || ecmascriptNextVersion
if (sourceEditionTags.includes('typescript')) {
typescript = true
}
if (
sourceEditionTags.includes('module') ||
sourceEditionTags.includes('import')
) {
sourceType = 'module'
} else if (
sourceEditionTags.includes('script') ||
sourceEditionTags.includes('require')
) {
// commonjs is also supported
sourceType = 'script'
}
if (sourceEditionTags.includes('react')) {
react = true
}
if (sourceEditionTags.includes('babel')) {
babel = true
}
if (sourceEditionTags.includes('jsx') || sourceEditionTags.includes('tsx')) {
jsx = true
}
if (config.files.length === 0) {
config.files.push(
`${sourceEdition.directory || '.'}/**/*.{js,cjs,mjs,jsx,mjsx,ts,cts,mts,tsx,mtsx}`,
)
}
} else if (config.files.length === 0) {
config.files.push('**/*.{js,cjs,mjs,jsx,mjsx,ts,cts,mts,tsx,mtsx}')
}
// adjust parser and syntax to the desired ecmascript version
config.languageOptions.ecmaVersion = ecmascriptVersionSource
config.languageOptions.sourceType = sourceType
config.languageOptions.parserOptions.ecmaFeatures.jsx = jsx
// adjust globals to the lowest ecmascript version
if (ecmascriptVersionTarget === 'latest' || ecmascriptVersionTarget >= 2021) {
config.languageOptions.globals = {
...config.languageOptions.globals,
...globals.es2021,
}
} else if (ecmascriptVersionTarget >= 2020) {
config.languageOptions.globals = {
...config.languageOptions.globals,
...globals.es2020,
}
} else if (ecmascriptVersionTarget >= 2017) {
config.languageOptions.globals = {
...config.languageOptions.globals,
...globals.es2017,
}
} else if (ecmascriptVersionTarget >= 2015) {
config.languageOptions.globals = {
...config.languageOptions.globals,
...globals.es2015,
}
} else if (ecmascriptVersionTarget <= 5) {
config.languageOptions.globals = {
...config.languageOptions.globals,
...globals.es5,
}
}
if (worker) {
config.languageOptions.globals = {
...config.languageOptions.globals,
...globals.worker,
...globals.serviceworker,
}
}
// import
if (sourceType === 'module') {
// https://github.com/import-js/eslint-plugin-import?tab=readme-ov-file#config---flat-with-config-in-typescript-eslint
config.extends.push(eslintImport.flatConfigs.recommended)
if (react) {
config.extends.push(eslintImport.flatConfigs.react)
}
config.extends.push(eslintImport.flatConfigs.typescript, rules.importAfter)
}
// node
if (node) {
// https://github.com/eslint-community/eslint-plugin-n#-configs
if (sourceType === 'module') {
config.extends.push(eslintNode.configs['flat/recommended-module'])
} else {
config.extends.push(eslintNode.configs['flat/recommended-script'])
}
// these globals should be handeld by the plugin above, but add them anyway
config.languageOptions.globals = {
...config.languageOptions.globals,
...globals.node,
...globals.nodeBuiltin,
}
config.extends.push(rules.nodeAfter)
if (typescript || babel) {
config.extends.push(rules.nodeCompiled)
}
}
if (browser) {
config.languageOptions.globals = {
...config.languageOptions.globals,
...globals.browser,
}
}
// react
if (react) {
config.extends.push(eslintReact.configs.flat.recommended)
if (jsx) {
config.extends.push(eslintReact.configs.flat['jsx-runtime'])
}
config.extends.push(eslintReactHooks.configs['recommended-latest'])
}
// babel
// https://babeljs.io/docs/babel-eslint-plugin
if (babel) {
config.languageOptions.parser = babelParser
config.plugins.babel = babelPlugin // this already includes the rules and their config, so it seems that the docs are out of date
}
// typescript
if (typescript) {
Object.assign(config.languageOptions.parserOptions, {
projectService: true,
tsconfigRootDir: userRootPath,
})
config.extends.push(
// https://typescript-eslint.io/users/configs
// https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/src/configs/eslintrc/stylistic-type-checked.ts
// we extend eslint recommended, and as such, we need to handle disabling it accordingly
eslintTypescript.configs.eslintRecommended,
// our project is a typescript project, so it has type checking
// eslintTypescript.configs.recommendedTypeChecked,
// our project is a typescript project, so it has type checking
eslintTypescript.configs.strictTypeChecked,
// our project is a typescript project, so style it accordingly, this is different from the stylistic plugin and different from prettier
eslintTypescript.configs.stylisticTypeChecked,
// our overrides
rules.typescriptAfter,
)
if (ecmascriptVersionTarget <= 5) {
config.extends.push(rules.typescriptEs5)
}
if (ecmascriptVersionTarget <= 2015) {
config.extends.push(rules.typescriptEs2015)
}
config.extends.push(rules.typescriptTests)
} else {
// jsdoc without typescript
}
// jsdoc
// https://www.npmjs.com/package/eslint-plugin-jsdoc
if (typescript) {
config.extends.push(eslintJSDoc.configs['flat/recommended-typescript'])
} else {
// config.extends.push(eslintJSDoc.configs['flat/recommended-typescript-flavor']) <-- consider using this instead of typescript style jsdoc rules and types in javascript files
config.extends.push(eslintJSDoc.configs['flat/recommended'])
}
// prettier
if (prettier) {
config.extends.push(eslintPrettier)
}
// this is after typescript, as we need to override defaults from typescript configs
if (ecmascriptVersionSource <= 5) {
config.extends.push(rules.es5)
}
// user rules overrides
if (userPackage.eslintConfig?.rules) {
config.extends.push({
name: 'eslint-config-bevry/package-json',
rules: userPackage.eslintConfig.rules,
})
}
// ------------------------------------
// Export
export default config