Skip to content

feat(typescript-estree): infer tsconfigRootDir from call stack #11370

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
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
1 change: 1 addition & 0 deletions packages/typescript-eslint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.36.0",
"@typescript-eslint/parser": "8.36.0",
"@typescript-eslint/typescript-estree": "8.36.0",
"@typescript-eslint/utils": "8.36.0"
},
"peerDependencies": {
Expand Down
16 changes: 16 additions & 0 deletions packages/typescript-eslint/src/getTSConfigRootDirFromStack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { fileURLToPath } from 'node:url';

export function getTSConfigRootDirFromStack(stack: string): string | undefined {
for (const line of stack.split('\n').map(line => line.trim())) {
const candidate = /(\S+)eslint\.config\.(c|m)?(j|t)s/.exec(line)?.[1];
if (!candidate) {
continue;
}

return candidate.startsWith('file://')
? fileURLToPath(candidate)
: candidate;
}

return undefined;
}
35 changes: 33 additions & 2 deletions packages/typescript-eslint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import type { TSESLint } from '@typescript-eslint/utils';

import pluginBase from '@typescript-eslint/eslint-plugin';
import rawPlugin from '@typescript-eslint/eslint-plugin/use-at-your-own-risk/raw-plugin';
import { addCandidateTSConfigRootDir } from '@typescript-eslint/typescript-estree';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this adds dependency on a transitive dep -- which will break consumers.
the dep needs to be listed in the package.json

there's something with our lint config -- there should be like import/no-extraneous-deps or something that's covering this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think import-x would have caught this 😄


import { config } from './config-helper';
import { getTSConfigRootDirFromStack } from './getTSConfigRootDirFromStack';

export const parser: TSESLint.FlatConfig.Parser = rawPlugin.parser;

Expand Down Expand Up @@ -36,7 +38,7 @@ export const plugin: TSESLint.FlatConfig.Plugin = pluginBase as Omit<
'configs'
>;

export const configs = {
export const configs = createConfigsGetters({
/**
* Enables each the rules provided as a part of typescript-eslint. Note that many rules are not applicable in all codebases, or are meant to be configured.
* @see {@link https://typescript-eslint.io/users/configs#all}
Expand Down Expand Up @@ -120,7 +122,36 @@ export const configs = {
*/
stylisticTypeCheckedOnly:
rawPlugin.flatConfigs['flat/stylistic-type-checked-only'],
};
});

function createConfigsGetters<T extends object>(values: T): T {
const configs = {};

Object.defineProperties(
configs,
Object.fromEntries(
Object.entries(values).map(([key, value]: [string, unknown]) => [
key,
{
enumerable: true,
get: () => {
const candidateRootDir = getTSConfigRootDirFromStack(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
new Error().stack!,
);
if (candidateRootDir) {
addCandidateTSConfigRootDir(candidateRootDir);
}

return value;
},
},
]),
),
);

return configs as T;
}

export type Config = TSESLint.FlatConfig.ConfigFile;

Expand Down
49 changes: 49 additions & 0 deletions packages/typescript-eslint/tests/configs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,25 @@ import type {
} from '@typescript-eslint/utils/ts-eslint';

import rules from '@typescript-eslint/eslint-plugin/use-at-your-own-risk/rules';
import { clearCandidateTSConfigRootDirs } from '@typescript-eslint/typescript-estree';

import tseslint from '../src/index.js';

vi.mock('@typescript-eslint/typescript-estree', async () => ({
...(await vi.importActual('@typescript-eslint/typescript-estree')),
get addCandidateTSConfigRootDir() {
return mockAddCandidateTSConfigRootDir;
},
}));

const mockGetTSConfigRootDirFromStack = vi.fn();

vi.mock('../src/getTSConfigRootDirFromStack', () => ({
get getTSConfigRootDirFromStack() {
return mockGetTSConfigRootDirFromStack;
},
}));

const RULE_NAME_PREFIX = '@typescript-eslint/';
const EXTENSION_RULES = Object.entries(rules)
.filter(([, rule]) => rule.meta.docs.extendsBaseRule)
Expand Down Expand Up @@ -384,3 +400,36 @@ describe('stylistic-type-checked-only.ts', () => {
},
);
});

const mockAddCandidateTSConfigRootDir = vi.fn();

describe('Candidate tsconfigRootDirs', () => {
beforeEach(() => {
clearCandidateTSConfigRootDirs();
mockAddCandidateTSConfigRootDir.mockClear();
});

describe.each(Object.keys(tseslint.configs))('%s', configKey => {
it('does not populate a candidate tsconfigRootDir when accessed and one cannot be inferred from the stack', () => {
mockGetTSConfigRootDirFromStack.mockReturnValue(undefined);

// eslint-disable-next-line @typescript-eslint/no-unused-expressions
tseslint.configs[configKey as keyof typeof tseslint.configs];

expect(mockAddCandidateTSConfigRootDir).not.toHaveBeenCalled();
});

it('populates a candidate tsconfigRootDir when accessed and one can be inferred from the stack', () => {
const tsconfigRootDir = 'a/b/c/';

mockGetTSConfigRootDirFromStack.mockReturnValue(tsconfigRootDir);

// eslint-disable-next-line @typescript-eslint/no-unused-expressions
tseslint.configs[configKey as keyof typeof tseslint.configs];

expect(mockAddCandidateTSConfigRootDir).toHaveBeenCalledWith(
tsconfigRootDir,
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import path from 'node:path';

import { getTSConfigRootDirFromStack } from '../src/getTSConfigRootDirFromStack';

const isWindows = process.platform === 'win32';

describe(getTSConfigRootDirFromStack, () => {
it('returns undefined when no file path seems to be an ESLint config', () => {
const actual = getTSConfigRootDirFromStack(
[
`Error`,
' at file:///other.config.js',
' at ModuleJob.run',
'at async NodeHfs.walk(...)',
].join('\n'),
);

expect(actual).toBeUndefined();
});

it.runIf(!isWindows)(
'returns a Posix config file path when a file:// path to an ESLint config is in the stack',
() => {
const actual = getTSConfigRootDirFromStack(
[
`Error`,
' at file:///path/to/file/eslint.config.js',
' at ModuleJob.run',
'at async NodeHfs.walk(...)',
].join('\n'),
);

expect(actual).toBe('/path/to/file/');
},
);

it.each(['cjs', 'cts', 'js', 'mjs', 'mts', 'ts'])(
'returns the path to the config file when its extension is %s',
extension => {
const expected = isWindows ? 'C:\\path\\to\\file\\' : '/path/to/file/';

const actual = getTSConfigRootDirFromStack(
[
`Error`,
` at ${path.join(expected, `eslint.config.${extension}`)}`,
' at ModuleJob.run',
'at async NodeHfs.walk(...)',
].join('\n'),
);

expect(actual).toBe(expected);
},
);
});
3 changes: 3 additions & 0 deletions packages/typescript-eslint/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"extends": "../../tsconfig.build.json",
"compilerOptions": {},
"references": [
{
"path": "../typescript-estree/tsconfig.build.json"
},
{
"path": "../utils/tsconfig.build.json"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/typescript-eslint/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../typescript-estree"
},
{
"path": "../utils"
},
Expand Down
2 changes: 2 additions & 0 deletions packages/typescript-estree/src/clear-caches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
clearDefaultProjectMatchedFiles,
clearProgramCache as clearProgramCacheOriginal,
} from './parser';
import { clearCandidateTSConfigRootDirs } from './parseSettings/candidateTSConfigRootDirs';
import {
clearTSConfigMatchCache,
clearTSServerProjectService,
Expand All @@ -17,6 +18,7 @@ import { clearGlobCache } from './parseSettings/resolveProjectList';
* - In custom lint tooling that iteratively lints one project at a time to prevent OOMs.
*/
export function clearCaches(): void {
clearCandidateTSConfigRootDirs();
clearDefaultProjectMatchedFiles();
clearProgramCacheOriginal();
clearWatchCaches();
Expand Down
4 changes: 4 additions & 0 deletions packages/typescript-estree/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export type {
ParserServicesWithTypeInformation,
TSESTreeOptions,
} from './parser-options';
export {
addCandidateTSConfigRootDir,
clearCandidateTSConfigRootDirs,
} from './parseSettings/candidateTSConfigRootDirs';
export { simpleTraverse } from './simple-traverse';
export * from './ts-estree';
export { typescriptVersionIsAtLeast } from './version-check';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const candidateTSConfigRootDirs = new Set<string>();

export function addCandidateTSConfigRootDir(candidate: string): void {
candidateTSConfigRootDirs.add(candidate);
}

export function clearCandidateTSConfigRootDirs(): void {
candidateTSConfigRootDirs.clear();
}

export function getInferredTSConfigRootDir(): string {
const entries = [...candidateTSConfigRootDirs];

switch (entries.length) {
case 0:
return process.cwd();

case 1:
return entries[0];

default:
throw new Error(
[
'No tsconfigRootDir was set, and multiple candidate TSConfigRootDirs are present:',
...entries.map(candidate => ` - ${candidate}`),
"You'll need to explicitly set tsconfigRootDir in your parser options.",
'See: https://typescript-eslint.io/packages/parser/#tsconfigrootdir',
].join('\n'),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { MutableParseSettings } from './index';
import { ensureAbsolutePath } from '../create-program/shared';
import { validateDefaultProjectForFilesGlob } from '../create-program/validateDefaultProjectForFilesGlob';
import { isSourceFile } from '../source-files';
import { getInferredTSConfigRootDir } from './candidateTSConfigRootDirs';
import {
DEFAULT_TSCONFIG_CACHE_DURATION_SECONDS,
ExpiringCache,
Expand Down Expand Up @@ -52,7 +53,7 @@ export function createParseSettings(
const tsconfigRootDir =
typeof tsestreeOptions.tsconfigRootDir === 'string'
? tsestreeOptions.tsconfigRootDir
: process.cwd();
: getInferredTSConfigRootDir();
const passedLoggerFn = typeof tsestreeOptions.loggerFn === 'function';
const filePath = ensureAbsolutePath(
typeof tsestreeOptions.filePath === 'string' &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { addCandidateTSConfigRootDir } from '../../src';
import {
clearCandidateTSConfigRootDirs,
getInferredTSConfigRootDir,
} from '../../src/parseSettings/candidateTSConfigRootDirs';

describe(getInferredTSConfigRootDir, () => {
beforeEach(() => {
clearCandidateTSConfigRootDirs();
});

it('returns process.cwd() when there are no candidates', () => {
const actual = getInferredTSConfigRootDir();

expect(actual).toBe(process.cwd());
});

it('returns the candidate when there is one candidate', () => {
const candidate = 'a/b/c';
addCandidateTSConfigRootDir(candidate);

const actual = getInferredTSConfigRootDir();

expect(actual).toBe(candidate);
});

it('throws an error when there are multiple candidates', () => {
addCandidateTSConfigRootDir('a');
addCandidateTSConfigRootDir('b');

expect(() => getInferredTSConfigRootDir())
.toThrowErrorMatchingInlineSnapshot(`
[Error: No tsconfigRootDir was set, and multiple candidate TSConfigRootDirs are present:
- a
- b
You'll need to explicitly set tsconfigRootDir in your parser options.
See: https://typescript-eslint.io/packages/parser/#tsconfigrootdir]
`);
});
});
36 changes: 36 additions & 0 deletions packages/typescript-estree/tests/lib/createParseSettings.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
addCandidateTSConfigRootDir,
clearCandidateTSConfigRootDirs,
} from '../../src/parseSettings/candidateTSConfigRootDirs';
import { createParseSettings } from '../../src/parseSettings/createParseSettings';

const projectService = { service: true };
Expand Down Expand Up @@ -61,4 +65,36 @@ describe(createParseSettings, () => {
);
});
});

describe('tsconfigRootDir', () => {
beforeEach(() => {
clearCandidateTSConfigRootDirs();
});

it('uses the provided tsconfigRootDir when it exists and no candidates exist', () => {
const tsconfigRootDir = 'a/b/c';

const parseSettings = createParseSettings('', { tsconfigRootDir });

expect(parseSettings.tsconfigRootDir).toBe(tsconfigRootDir);
});

it('uses the provided tsconfigRootDir when it exists and a candidate exists', () => {
addCandidateTSConfigRootDir('candidate');
const tsconfigRootDir = 'a/b/c';

const parseSettings = createParseSettings('', { tsconfigRootDir });

expect(parseSettings.tsconfigRootDir).toBe(tsconfigRootDir);
});

it('uses the inferred candidate when no tsconfigRootDir is provided and a candidate exists', () => {
const tsconfigRootDir = 'a/b/c';
addCandidateTSConfigRootDir(tsconfigRootDir);

const parseSettings = createParseSettings('');

expect(parseSettings.tsconfigRootDir).toBe(tsconfigRootDir);
});
});
});
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -19802,6 +19802,7 @@ __metadata:
dependencies:
"@typescript-eslint/eslint-plugin": 8.36.0
"@typescript-eslint/parser": 8.36.0
"@typescript-eslint/typescript-estree": 8.36.0
"@typescript-eslint/utils": 8.36.0
"@vitest/coverage-v8": ^3.1.3
eslint: "*"
Expand Down
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy