Skip to content

fix: tsconfigRootDir bugs #11463

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

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
4 changes: 1 addition & 3 deletions packages/typescript-estree/src/create-program/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,7 @@ export function getCanonicalFileName(filePath: string): CanonicalPath {
}

export function ensureAbsolutePath(p: string, tsconfigRootDir: string): string {
return path.isAbsolute(p)
? p
: path.join(tsconfigRootDir || process.cwd(), p);
return path.resolve(tsconfigRootDir, p);
}

export function canonicalDirname(p: CanonicalPath): CanonicalPath {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,37 @@ export function createParseSettings(
): MutableParseSettings {
const codeFullText = enforceCodeString(code);
const singleRun = inferSingleRun(tsestreeOptions);
const tsconfigRootDir =
typeof tsestreeOptions.tsconfigRootDir === 'string'
? tsestreeOptions.tsconfigRootDir
: getInferredTSConfigRootDir();
const tsconfigRootDir = (() => {
let tsconfigRootDir;
if (tsestreeOptions.tsconfigRootDir == null) {
tsconfigRootDir = getInferredTSConfigRootDir();
if (!path.isAbsolute(tsconfigRootDir)) {
throw new Error(
`inferred tsconfigRootDir should have be an absolute path, but received: ${JSON.stringify(
tsconfigRootDir,
)}. This is a bug in typescript-eslint! Please report it to us at https://github.com/typescript-eslint/typescript-eslint/issues/new/choose.`,
);
}
} else if (typeof tsestreeOptions.tsconfigRootDir === 'string') {
tsconfigRootDir = tsestreeOptions.tsconfigRootDir;
if (!path.isAbsolute(tsconfigRootDir)) {
throw new Error(
`parserOptions.tsconfigRootDir must be an absolute path, but received: ${JSON.stringify(
tsconfigRootDir,
)}. This is a bug in your configuration; please supply an absolute path.`,
);
}
} else {
throw new Error(
`If provided, parserOptions.tsconfigRootDir must be a string, but received a value of type "${typeof tsestreeOptions.tsconfigRootDir}"`,
);
}

// Deal with any funny business around trailing path separators (a/b/) or relative path segments (/a/b/../c)
// We already know it's absolute, so we can safely use path.resolve
return path.resolve(tsconfigRootDir);
})();

const passedLoggerFn = typeof tsestreeOptions.loggerFn === 'function';
const filePath = ensureAbsolutePath(
typeof tsestreeOptions.filePath === 'string' &&
Expand Down
37 changes: 34 additions & 3 deletions packages/typescript-estree/tests/lib/createParseSettings.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import path from 'node:path';

import {
addCandidateTSConfigRootDir,
clearCandidateTSConfigRootDirs,
Expand Down Expand Up @@ -71,30 +73,59 @@
clearCandidateTSConfigRootDirs();
});

it('errors on non-absolute path', () => {
expect(() =>
createParseSettings('', { tsconfigRootDir: 'a/b/c' }),
).toThrowErrorMatchingInlineSnapshot(
`[Error: parserOptions.tsconfigRootDir must be an absolute path, but received: "a/b/c". This is a bug in your configuration; please supply an absolute path.]`,
);
});

it('normalizes crazy tsconfigRootDir', () => {
const parseSettings = createParseSettings('', {
tsconfigRootDir: '/a/b////..//c///',
});

expect(parseSettings.tsconfigRootDir).toBe(

Check failure on line 89 in packages/typescript-estree/tests/lib/createParseSettings.test.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests (windows-latest, 20, typescript-estree)

tests/lib/createParseSettings.test.ts > createParseSettings > tsconfigRootDir > normalizes crazy tsconfigRootDir

AssertionError: expected 'D:\a\c' to be '\a\c' // Object.is equality Expected: "\a\c" Received: "D:\a\c" ❯ tests/lib/createParseSettings.test.ts:89:45
process.platform !== 'win32' ? '/a/c' : '\\a\\c',
);
});

it('errors on invalid tsconfigRootDir', () => {
expect(() =>
createParseSettings('', {
// @ts-expect-error -- testing invalid input
tsconfigRootDir: 42,
}),
).toThrowErrorMatchingInlineSnapshot(
`[Error: If provided, parserOptions.tsconfigRootDir must be a string, but received a value of type "number"]`,
);
});

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

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

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

Check failure on line 110 in packages/typescript-estree/tests/lib/createParseSettings.test.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests (windows-latest, 20, typescript-estree)

tests/lib/createParseSettings.test.ts > createParseSettings > tsconfigRootDir > uses the provided tsconfigRootDir when it exists and no candidates exist

AssertionError: expected 'D:\a\b\c' to be '\a\b\c' // Object.is equality Expected: "\a\b\c" Received: "D:\a\b\c" ❯ tests/lib/createParseSettings.test.ts:110:45
});

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

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

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

Check failure on line 119 in packages/typescript-estree/tests/lib/createParseSettings.test.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests (windows-latest, 20, typescript-estree)

tests/lib/createParseSettings.test.ts > createParseSettings > tsconfigRootDir > uses the provided tsconfigRootDir when it exists and a candidate exists

AssertionError: expected 'D:\a\b\c' to be '\a\b\c' // Object.is equality Expected: "\a\b\c" Received: "D:\a\b\c" ❯ tests/lib/createParseSettings.test.ts:119:45
});

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

const parseSettings = createParseSettings('');

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

Check failure on line 128 in packages/typescript-estree/tests/lib/createParseSettings.test.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests (windows-latest, 20, typescript-estree)

tests/lib/createParseSettings.test.ts > createParseSettings > tsconfigRootDir > uses the inferred candidate when no tsconfigRootDir is provided and a candidate exists

AssertionError: expected 'D:\a\b\c' to be '\a\b\c' // Object.is equality Expected: "\a\b\c" Received: "D:\a\b\c" ❯ tests/lib/createParseSettings.test.ts:128:45
});
});
});
76 changes: 53 additions & 23 deletions packages/typescript-estree/tests/lib/getProjectConfigFiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,16 @@ vi.mock(import('node:fs'), async importOriginal => {
});

const parseSettings = {
filePath: './repos/repo/packages/package/file.ts',
filePath: path.normalize('/repos/repo/packages/package/file.ts'),
tsconfigMatchCache: new ExpiringCache<string, string>(1),
tsconfigRootDir: './repos/repo',
tsconfigRootDir: path.normalize('/repos/repo'),
};

describe(getProjectConfigFiles, () => {
beforeEach(() => {
parseSettings.tsconfigMatchCache.clear();
vi.clearAllMocks();
vi.resetModules();
});

afterAll(() => {
Expand Down Expand Up @@ -66,24 +67,24 @@ describe(getProjectConfigFiles, () => {
const actual = getProjectConfigFiles(parseSettings, true);

expect(actual).toStrictEqual([
path.normalize('repos/repo/packages/package/tsconfig.json'),
path.normalize('/repos/repo/packages/package/tsconfig.json'),
]);
expect(mockExistsSync).toHaveBeenCalledOnce();
});

it('returns a nearby parent tsconfig.json when it was previously cached by a different directory search', () => {
mockExistsSync.mockImplementation(
input => input === path.normalize('a/tsconfig.json'),
input => input === path.normalize('/a/tsconfig.json'),
);

const tsconfigMatchCache = new ExpiringCache<string, string>(1);

// This should call to fs.existsSync three times: c, b, a
getProjectConfigFiles(
{
filePath: './a/b/c/d.ts',
filePath: path.normalize('/a/b/c/d.ts'),
tsconfigMatchCache,
tsconfigRootDir: './a',
tsconfigRootDir: path.normalize('/a'),
},
true,
);
Expand All @@ -92,30 +93,30 @@ describe(getProjectConfigFiles, () => {
// Then it should retrieve c from cache, pointing to a
const actual = getProjectConfigFiles(
{
filePath: './a/b/c/e/f.ts',
filePath: path.normalize('/a/b/c/e/f.ts'),
tsconfigMatchCache,
tsconfigRootDir: './a',
tsconfigRootDir: path.normalize('/a'),
},
true,
);

expect(actual).toStrictEqual([path.normalize('a/tsconfig.json')]);
expect(actual).toStrictEqual([path.normalize('/a/tsconfig.json')]);
expect(mockExistsSync).toHaveBeenCalledTimes(4);
});

it('returns a distant parent tsconfig.json when it was previously cached by a different directory search', () => {
mockExistsSync.mockImplementation(
input => input === path.normalize('a/tsconfig.json'),
input => input === path.normalize('/a/tsconfig.json'),
);

const tsconfigMatchCache = new ExpiringCache<string, string>(1);

// This should call to fs.existsSync 4 times: d, c, b, a
getProjectConfigFiles(
{
filePath: './a/b/c/d/e.ts',
filePath: path.normalize('/a/b/c/d/e.ts'),
tsconfigMatchCache,
tsconfigRootDir: './a',
tsconfigRootDir: path.normalize('/a'),
},
true,
);
Expand All @@ -124,14 +125,14 @@ describe(getProjectConfigFiles, () => {
// Then it should retrieve b from cache, pointing to a
const actual = getProjectConfigFiles(
{
filePath: './a/b/f/g/h.ts',
filePath: path.normalize('/a/b/f/g/h.ts'),
tsconfigMatchCache,
tsconfigRootDir: './a',
tsconfigRootDir: path.normalize('/a'),
},
true,
);

expect(actual).toStrictEqual([path.normalize('a/tsconfig.json')]);
expect(actual).toStrictEqual([path.normalize('/a/tsconfig.json')]);
expect(mockExistsSync).toHaveBeenCalledTimes(6);
});
});
Expand All @@ -143,39 +144,68 @@ describe(getProjectConfigFiles, () => {
const actual = getProjectConfigFiles(parseSettings, true);

expect(actual).toStrictEqual([
path.normalize('repos/repo/packages/package/tsconfig.json'),
path.normalize('/repos/repo/packages/package/tsconfig.json'),
]);
});

it('returns a parent tsconfig.json when matched', () => {
mockExistsSync.mockImplementation(
filePath => filePath === path.normalize('repos/repo/tsconfig.json'),
filePath => filePath === path.normalize('/repos/repo/tsconfig.json'),
);

const actual = getProjectConfigFiles(parseSettings, true);

expect(actual).toStrictEqual([
path.normalize('repos/repo/tsconfig.json'),
path.normalize('/repos/repo/tsconfig.json'),
]);
});

it('throws when searching hits .', () => {
it('throws when searching hits .', async () => {
// ensure posix-style paths are used for consistent snapshot.
vi.mock('path', async () => {
const actual = await vi.importActual<typeof path>('path');
return {
...actual.posix,
default: actual.posix,
};
});

const { getProjectConfigFiles } = await import(
'../../src/parseSettings/getProjectConfigFiles.js'
);

mockExistsSync.mockReturnValue(false);

expect(() =>
getProjectConfigFiles(parseSettings, true),
).toThrowErrorMatchingInlineSnapshot(
`[Error: project was set to \`true\` but couldn't find any tsconfig.json relative to './repos/repo/packages/package/file.ts' within './repos/repo'.]`,
`[Error: project was set to \`true\` but couldn't find any tsconfig.json relative to '/repos/repo/packages/package/file.ts' within '/repos/repo'.]`,
);
});

it('throws when searching passes the tsconfigRootDir', () => {
it('throws when searching passes the tsconfigRootDir', async () => {
// ensure posix-style paths are used for consistent snapshot.
vi.mock('path', async () => {
const actual = await vi.importActual<typeof path>('path');
return {
...actual.posix,
default: actual.posix,
};
});

const { getProjectConfigFiles } = await import(
'../../src/parseSettings/getProjectConfigFiles.js'
);

mockExistsSync.mockReturnValue(false);

expect(() =>
getProjectConfigFiles({ ...parseSettings, tsconfigRootDir: '/' }, true),
getProjectConfigFiles(
{ ...parseSettings, tsconfigRootDir: path.normalize('/') },
true,
),
).toThrowErrorMatchingInlineSnapshot(
`[Error: project was set to \`true\` but couldn't find any tsconfig.json relative to './repos/repo/packages/package/file.ts' within '/'.]`,
`[Error: project was set to \`true\` but couldn't find any tsconfig.json relative to '/repos/repo/packages/package/file.ts' within '/'.]`,
);
});
});
Expand Down
Loading