Skip to content

Commit 41d0206

Browse files
aryaemami59antfu
andauthored
feat: Add support for TS config files (#18134)
* Initial working implementation of TS config files * Simplify implementation * Use typescript's `transpile()` instead of `tsx` to avoid side effects * Remove unnecessary nullish coalescing * Use `jiti` instead of TypeScript * Add missing section related to `importedConfigFileModificationTime` * Enable `esmResolve` for `jiti` * Make `jiti` an optional peer dependency * Throw an error if `jiti` is not installed * Add `jiti` to `ignoreDependencies` in `knip.jsonc` * Fix broken unit tests caused by stubbed `process.version` Co-authored-by: Anthony Fu <[email protected]> * Prioritize JavaScript config files over TypeScript config files * Add some basic tests for loading TS config files * Fix minor type issues in JSDocs * Partially Inline the `FlatConfig` type from `@types/eslint` to reuse - This is not only done to reduce some potential redundancy down the line, but to ensure that TS config files are able to handle `type` imports as that is something most people are likely going to do. * Stringify `mtime` * Check if ESLint is running in Deno or Bun Note: `isRunningInBun` and `isRunningInDeno` are functions to make treeshaking for consuming libraries easier. * Try a different approach for loading TS config files - This is done for mainly 2 reasons:   1. We don't know how many runtime environments are going to support loading TS files natively in the future, so this saves us having to check for every single one.   2. This also ensures that we give the user the option of passing their own TS loader of choice through `NODE_OPTIONS` in CLI for example: `NODE_OPTIONS=--import=tsx/esm`, without ESLint getting in the way and potentially causing conflicts between multiple loaders. * Add tests for `lintFiles` with TS config files * Revert "Try a different approach for loading TS config files" This reverts commit 209c791. * Convert `Severity` type to a `const enum` * Add tests for TS config files containing `const enum`s * Add `ESLintNameSpace` local `namespace` * Add tests for TS config files containing importing a local `namespace` * Fix minor type issues in JSDocs * Add tests for passing TS config files to `overrideConfigFile` * Bump `jiti` to version 1.21.1 * Bump `jiti` to version 1.21.6 * Add basic docs for loading TypeScript configuration files * Fix lint issues * Bump `jiti` to version 2.0.0-beta.1 * Remove `esmResolve` as it has been removed in v2 of `jiti` * Fix types for `createCustomTeardown` * Remove `interopDefault: true` - `interopDefault: true` does not work well with `jiti.import()`. * Fix type of `eslint` in test file * Fix lint issues in test file * Add more tests - Added tests for top-level `await`, interoperability, and mixed ESM/CJS syntax. * Defer to v10 in docs * Bump `jiti` to version 2.0.0-beta.3 * Fix import of `jiti` * Fix `jiti` link in docs * Fix `jiti` link in docs * Add `unstable_ts_config` to `activeFlags` * Fix issues in `configuration-files.md` * Fix `LintResults` in `eslint.js` * Change `LintResults` to `LintResult` in `eslint.js` * Remove warning regarding top-level `await` * Explicitly check for `.ts`, `.mts` or `.cts` extensions * Check if `"unstable_ts_config"` flag is enabled * Fix import and export assignment tests * Fix remaining issues in `configuration-files.md` * Fix test title * De-duplicate test * Revert "Fix broken unit tests caused by stubbed `process.version`" This reverts commit f339591. * Remove unnecessary checks for `"unstable_ts_config"` flag * Fix type of `findConfigFile` * Make sure tests fail when the correct config file is not loaded * Add a test for `"unstable_ts_config"` - Added a test to check that the TS config file is not loaded when the `"unstable_ts_config"` flag is not set. * Remove fallback to `default` when loading `jiti.createJiti` * Update `configuration-files.md` * Update description of `"unstable_ts_config"` flag * Add comment explaining purpose of `helper.ts` * Disable `moduleCache` * Add test to check reloading logic for TS config files * Add comment for disabling `moduleCache` * Rework `hasUnstableTSConfigFlag` logic * Fix unit tests * Fallback to `default` when loading `jiti` * Rework logic for loading TS config files - Rework the logic to ensure `overrideConfigFile` does not load TS config files unless the `unstable_ts_config` flag is set. * Remove default value for `hasUnstableTSConfigFlag` parameter * Remove type assertion * Remove unnecessary `process?.versions?.bun` check * Remove unnecessary `LintResults` type * Fix parameter type of `eslintWithPlugins` - We avoid potential repetition by using the new [`@import JSDoc tags`](https://devblogs.microsoft.com/typescript/announcing-typescript-5-5/#the-jsdoc-import-tag) released in TypeScript 5.5. * Add tests for loading TypeScript config files that export a promise * Update `jiti` peer dependency to allow any version * Warn about using an outdated version of `jiti` * Fix minor issue in `configuration-files.md` * Add section about `--flag` in `configuration-files.md` * Reapply "Fix broken unit tests caused by stubbed `process.version`" This reverts commit f369145. * Switch to `jiti` v1 until `jiti` v2 becomes stable * Add commented-out unit tests for `jiti` v2 * Throw a `TypeError` if `jiti.import` is not a function * Change `TypeError` to `Error` --------- Co-authored-by: Anthony Fu <[email protected]>
1 parent aac191e commit 41d0206

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1846
-85
lines changed

docs/src/use/configure/configuration-files.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ The ESLint configuration file may be named any of the following:
2121
* `eslint.config.js`
2222
* `eslint.config.mjs`
2323
* `eslint.config.cjs`
24+
* `eslint.config.ts` (requires [additional setup](#typescript-configuration-files))
25+
* `eslint.config.mts` (requires [additional setup](#typescript-configuration-files))
26+
* `eslint.config.cts` (requires [additional setup](#typescript-configuration-files))
2427

2528
It should be placed in the root directory of your project and export an array of [configuration objects](#configuration-objects). Here's an example:
2629

@@ -495,3 +498,84 @@ npx eslint --config some-other-file.js **/*.js
495498
```
496499

497500
In this case, ESLint does not search for `eslint.config.js` and instead uses `some-other-file.js`.
501+
502+
## TypeScript Configuration Files
503+
504+
::: warning
505+
This feature is currently experimental and may change in future versions.
506+
:::
507+
508+
You need to enable this feature through the `unstable_ts_config` feature flag:
509+
510+
```bash
511+
npx eslint --flag unstable_ts_config
512+
```
513+
514+
For Deno and Bun, TypeScript configuration files are natively supported; for Node.js, you must install the optional dev dependency [`jiti`](https://github.com/unjs/jiti) in your project (this dependency is not automatically installed by ESLint):
515+
516+
```bash
517+
npm install -D jiti
518+
# or
519+
yarn add --dev jiti
520+
# or
521+
pnpm add -D jiti
522+
```
523+
524+
You can then create a configuration file with a `.ts`, `.mts`, or `.cts` extension, and export an array of [configuration objects](#configuration-objects). Here's an example in ESM format:
525+
526+
```ts
527+
import js from "@eslint/js";
528+
import type { Linter } from "eslint";
529+
530+
export default [
531+
js.configs.recommended,
532+
{
533+
rules: {
534+
"no-console": [0],
535+
},
536+
},
537+
] satisfies Linter.FlatConfig[];
538+
```
539+
540+
Here's an example in CommonJS format:
541+
542+
```ts
543+
import type { Linter } from "eslint";
544+
const eslint = require("@eslint/js");
545+
546+
const config: Linter.FlatConfig[] = [
547+
eslint.configs.recommended,
548+
{
549+
rules: {
550+
"no-console": [0],
551+
},
552+
},
553+
];
554+
555+
module.exports = config;
556+
```
557+
558+
::: important
559+
ESLint does not perform type checking on your configuration file and does not apply any settings from `tsconfig.json`.
560+
:::
561+
562+
::: warning
563+
As of now, [`jiti`](https://github.com/unjs/jiti) does not support [Top-level `await`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await#top_level_await)
564+
:::
565+
566+
### Configuration File Precedence
567+
568+
If you have multiple ESLint configuration files, ESLint prioritizes JavaScript files over TypeScript files. The order of precedence is as follows:
569+
570+
1. `eslint.config.js`
571+
2. `eslint.config.mjs`
572+
3. `eslint.config.cjs`
573+
4. `eslint.config.ts`
574+
5. `eslint.config.mts`
575+
6. `eslint.config.cts`
576+
577+
To override this behavior, use the `--config` or `-c` command line option to specify a different configuration file:
578+
579+
```bash
580+
npx eslint --flag unstable_ts_config --config eslint.config.ts
581+
```

knip.jsonc

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@
2828
"rollup-plugin-node-polyfills",
2929

3030
// FIXME: not sure why is eslint-config-eslint reported as unused
31-
"eslint-config-eslint"
31+
"eslint-config-eslint",
32+
33+
// Optional peer dependency used for loading TypeScript configuration files
34+
"jiti"
3235
]
3336
},
3437
"docs": {

lib/cli.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -340,16 +340,17 @@ const cli = {
340340
/**
341341
* Calculates the command string for the --inspect-config operation.
342342
* @param {string} configFile The path to the config file to inspect.
343+
* @param {boolean} hasUnstableTSConfigFlag `true` if the `unstable_ts_config` flag is enabled, `false` if it's not.
343344
* @returns {Promise<string>} The command string to execute.
344345
*/
345-
async calculateInspectConfigFlags(configFile) {
346+
async calculateInspectConfigFlags(configFile, hasUnstableTSConfigFlag) {
346347

347348
// find the config file
348349
const {
349350
configFilePath,
350351
basePath,
351352
error
352-
} = await locateConfigFileToUse({ cwd: process.cwd(), configFile });
353+
} = await locateConfigFileToUse({ cwd: process.cwd(), configFile }, hasUnstableTSConfigFlag);
353354

354355
if (error) {
355356
throw error;
@@ -454,7 +455,7 @@ const cli = {
454455
try {
455456
const flatOptions = await translateOptions(options, "flat");
456457
const spawn = require("cross-spawn");
457-
const flags = await cli.calculateInspectConfigFlags(flatOptions.overrideConfigFile);
458+
const flags = await cli.calculateInspectConfigFlags(flatOptions.overrideConfigFile, flatOptions.flags ? flatOptions.flags.includes("unstable_ts_config") : false);
458459

459460
spawn.sync("npx", ["@eslint/config-inspector@latest", ...flags], { encoding: "utf8", stdio: "inherit" });
460461
} catch (error) {

lib/eslint/eslint.js

Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ const { Retrier } = require("@humanwhocodes/retry");
6363
/** @typedef {import("../shared/types").RuleConf} RuleConf */
6464
/** @typedef {import("../shared/types").Rule} Rule */
6565
/** @typedef {ReturnType<ConfigArray.extractConfig>} ExtractedConfig */
66+
/** @typedef {import('../cli-engine/cli-engine').CLIEngine} CLIEngine */
67+
/** @typedef {import('./legacy-eslint').CLIEngineLintReport} CLIEngineLintReport */
6668

6769
/**
6870
* The options with which to configure the ESLint instance.
@@ -86,7 +88,7 @@ const { Retrier } = require("@humanwhocodes/retry");
8688
* when a string.
8789
* @property {Record<string,Plugin>} [plugins] An array of plugin implementations.
8890
* @property {boolean} [stats] True enables added statistics on lint results.
89-
* @property {boolean} warnIgnored Show warnings when the file list includes ignored files
91+
* @property {boolean} [warnIgnored] Show warnings when the file list includes ignored files
9092
* @property {boolean} [passOnNoPatterns=false] When set to true, missing patterns cause
9193
* the linting operation to short circuit and not report any failures.
9294
*/
@@ -100,8 +102,18 @@ const FLAT_CONFIG_FILENAMES = [
100102
"eslint.config.mjs",
101103
"eslint.config.cjs"
102104
];
105+
const FLAT_CONFIG_FILENAMES_WITH_TS = [
106+
...FLAT_CONFIG_FILENAMES,
107+
"eslint.config.ts",
108+
"eslint.config.mts",
109+
"eslint.config.cts"
110+
];
103111
const debug = require("debug")("eslint:eslint");
104112
const privateMembers = new WeakMap();
113+
114+
/**
115+
* @type {Map<string, string>}
116+
*/
105117
const importedConfigFileModificationTime = new Map();
106118
const removedFormatters = new Set([
107119
"checkstyle",
@@ -262,28 +274,59 @@ function compareResultsByFilePath(a, b) {
262274
* Searches from the current working directory up until finding the
263275
* given flat config filename.
264276
* @param {string} cwd The current working directory to search from.
277+
* @param {boolean} hasUnstableTSConfigFlag `true` if the `unstable_ts_config` flag is enabled, `false` if it's not.
265278
* @returns {Promise<string|undefined>} The filename if found or `undefined` if not.
266279
*/
267-
function findFlatConfigFile(cwd) {
280+
function findFlatConfigFile(cwd, hasUnstableTSConfigFlag) {
281+
const filenames = hasUnstableTSConfigFlag ? FLAT_CONFIG_FILENAMES_WITH_TS : FLAT_CONFIG_FILENAMES;
282+
268283
return findUp(
269-
FLAT_CONFIG_FILENAMES,
284+
filenames,
270285
{ cwd }
271286
);
272287
}
273288

289+
/**
290+
* Check if the file is a TypeScript file.
291+
* @param {string} filePath The file path to check.
292+
* @returns {boolean} `true` if the file is a TypeScript file, `false` if it's not.
293+
*/
294+
function isFileTS(filePath) {
295+
const fileExtension = path.extname(filePath);
296+
297+
return /^\.[mc]?ts$/u.test(fileExtension);
298+
}
299+
300+
/**
301+
* Check if ESLint is running in Bun.
302+
* @returns {boolean} `true` if the ESLint is running Bun, `false` if it's not.
303+
*/
304+
function isRunningInBun() {
305+
return !!globalThis.Bun;
306+
}
307+
308+
/**
309+
* Check if ESLint is running in Deno.
310+
* @returns {boolean} `true` if the ESLint is running in Deno, `false` if it's not.
311+
*/
312+
function isRunningInDeno() {
313+
return !!globalThis.Deno;
314+
}
315+
274316
/**
275317
* Load the config array from the given filename.
276318
* @param {string} filePath The filename to load from.
319+
* @param {boolean} hasUnstableTSConfigFlag `true` if the `unstable_ts_config` flag is enabled, `false` if it's not.
277320
* @returns {Promise<any>} The config loaded from the config file.
278321
*/
279-
async function loadFlatConfigFile(filePath) {
322+
async function loadFlatConfigFile(filePath, hasUnstableTSConfigFlag) {
280323
debug(`Loading config from ${filePath}`);
281324

282325
const fileURL = pathToFileURL(filePath);
283326

284327
debug(`Config file URL is ${fileURL}`);
285328

286-
const mtime = (await fs.stat(filePath)).mtime.getTime();
329+
const mtime = (await fs.stat(filePath)).mtime.getTime().toString();
287330

288331
/*
289332
* Append a query with the config file's modification time (`mtime`) in order
@@ -314,7 +357,37 @@ async function loadFlatConfigFile(filePath) {
314357
delete require.cache[filePath];
315358
}
316359

317-
const config = (await import(fileURL)).default;
360+
const isTS = isFileTS(filePath) && hasUnstableTSConfigFlag;
361+
362+
const isBun = isRunningInBun();
363+
364+
const isDeno = isRunningInDeno();
365+
366+
if (isTS && !isDeno && !isBun) {
367+
368+
const createJiti = await import("jiti").then(jitiModule => jitiModule.default, () => {
369+
throw new Error("The 'jiti' library is required for loading TypeScript configuration files. Make sure to install it.");
370+
});
371+
372+
/*
373+
* Disabling `moduleCache` allows us to reload a
374+
* config file when the last modified timestamp changes.
375+
*/
376+
377+
const jiti = createJiti(__filename, { moduleCache: false });
378+
379+
if (typeof jiti?.import !== "function") {
380+
throw new Error("You are using an outdated version of the 'jiti' library. Please update to the latest version of 'jiti' to ensure compatibility and access to the latest features.");
381+
}
382+
383+
const config = await jiti.import(fileURL.href);
384+
385+
importedConfigFileModificationTime.set(filePath, mtime);
386+
387+
return config?.default ?? config;
388+
}
389+
390+
const config = (await import(fileURL.href)).default;
318391

319392
importedConfigFileModificationTime.set(filePath, mtime);
320393

@@ -326,11 +399,12 @@ async function loadFlatConfigFile(filePath) {
326399
* override config file was passed, and if so, using it; otherwise, as long
327400
* as override config file is not explicitly set to `false`, it will search
328401
* upwards from the cwd for a file named `eslint.config.js`.
329-
* @param {import("./eslint").ESLintOptions} options The ESLint instance options.
330-
* @returns {{configFilePath:string|undefined,basePath:string,error:Error|null}} Location information for
402+
* @param {ESLintOptions} options The ESLint instance options.
403+
* @param {boolean} hasUnstableTSConfigFlag `true` if the `unstable_ts_config` flag is enabled, `false` if it's not.
404+
* @returns {Promise<{configFilePath:string|undefined;basePath:string;error:Error|null}>} Location information for
331405
* the config file.
332406
*/
333-
async function locateConfigFileToUse({ configFile, cwd }) {
407+
async function locateConfigFileToUse({ configFile, cwd }, hasUnstableTSConfigFlag) {
334408

335409
// determine where to load config file from
336410
let configFilePath;
@@ -342,7 +416,7 @@ async function locateConfigFileToUse({ configFile, cwd }) {
342416
configFilePath = path.resolve(cwd, configFile);
343417
} else if (configFile !== false) {
344418
debug("Searching for eslint.config.js");
345-
configFilePath = await findFlatConfigFile(cwd);
419+
configFilePath = await findFlatConfigFile(cwd, hasUnstableTSConfigFlag);
346420

347421
if (configFilePath) {
348422
basePath = path.resolve(path.dirname(configFilePath));
@@ -364,8 +438,8 @@ async function locateConfigFileToUse({ configFile, cwd }) {
364438
/**
365439
* Calculates the config array for this run based on inputs.
366440
* @param {ESLint} eslint The instance to create the config array for.
367-
* @param {import("./eslint").ESLintOptions} options The ESLint instance options.
368-
* @returns {FlatConfigArray} The config array for `eslint``.
441+
* @param {ESLintOptions} options The ESLint instance options.
442+
* @returns {Promise<typeof FlatConfigArray>} The config array for `eslint``.
369443
*/
370444
async function calculateConfigArray(eslint, {
371445
cwd,
@@ -383,7 +457,9 @@ async function calculateConfigArray(eslint, {
383457
return slots.configs;
384458
}
385459

386-
const { configFilePath, basePath, error } = await locateConfigFileToUse({ configFile, cwd });
460+
const hasUnstableTSConfigFlag = eslint.hasFlag("unstable_ts_config");
461+
462+
const { configFilePath, basePath, error } = await locateConfigFileToUse({ configFile, cwd }, hasUnstableTSConfigFlag);
387463

388464
// config file is required to calculate config
389465
if (error) {
@@ -394,7 +470,7 @@ async function calculateConfigArray(eslint, {
394470

395471
// load config file
396472
if (configFilePath) {
397-
const fileConfig = await loadFlatConfigFile(configFilePath);
473+
const fileConfig = await loadFlatConfigFile(configFilePath, hasUnstableTSConfigFlag);
398474

399475
if (Array.isArray(fileConfig)) {
400476
configs.push(...fileConfig);
@@ -1144,7 +1220,7 @@ class ESLint {
11441220

11451221
/**
11461222
* The main formatter method.
1147-
* @param {LintResults[]} results The lint results to format.
1223+
* @param {LintResult[]} results The lint results to format.
11481224
* @param {ResultsMeta} resultsMeta Warning count and max threshold.
11491225
* @returns {string} The formatted lint results.
11501226
*/
@@ -1190,12 +1266,12 @@ class ESLint {
11901266
/**
11911267
* Finds the config file being used by this instance based on the options
11921268
* passed to the constructor.
1193-
* @returns {string|undefined} The path to the config file being used or
1269+
* @returns {Promise<string|undefined>} The path to the config file being used or
11941270
* `undefined` if no config file is being used.
11951271
*/
11961272
async findConfigFile() {
11971273
const options = privateMembers.get(this).options;
1198-
const { configFilePath } = await locateConfigFileToUse(options);
1274+
const { configFilePath } = await locateConfigFileToUse(options, this.hasFlag("unstable_ts_config"));
11991275

12001276
return configFilePath;
12011277
}

lib/shared/flags.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
* @type {Map<string, string>}
1010
*/
1111
const activeFlags = new Map([
12-
["test_only", "Used only for testing."]
12+
["test_only", "Used only for testing."],
13+
["unstable_ts_config", "Enable TypeScript configuration files."]
1314
]);
1415

1516
/**

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@
132132
"globals": "^15.0.0",
133133
"got": "^11.8.3",
134134
"gray-matter": "^4.0.3",
135+
"jiti": "^1.21.6",
135136
"js-yaml": "^4.1.0",
136137
"knip": "^5.21.0",
137138
"lint-staged": "^11.0.0",
@@ -165,6 +166,14 @@
165166
"webpack-cli": "^4.5.0",
166167
"yorkie": "^2.0.0"
167168
},
169+
"peerDependencies": {
170+
"jiti": "*"
171+
},
172+
"peerDependenciesMeta": {
173+
"jiti": {
174+
"optional": true
175+
}
176+
},
168177
"keywords": [
169178
"ast",
170179
"lint",

0 commit comments

Comments
 (0)