Skip to content

Commit e280d32

Browse files
authored
Improve tsconfig handling (#810)
1 parent 412e3ae commit e280d32

File tree

2 files changed

+124
-38
lines changed

2 files changed

+124
-38
lines changed

‎lib/handle-ts-files.ts

Lines changed: 9 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import path from 'node:path';
22
import fs from 'node:fs/promises';
3-
import {getTsconfig} from 'get-tsconfig';
4-
import micromatch, {type Options} from 'micromatch';
5-
import {tsconfigDefaults, cacheDirName} from './constants.js';
6-
7-
const micromatchOptions: Options = {matchBase: true};
3+
import {getTsconfig, createFilesMatcher} from 'get-tsconfig';
4+
import {tsconfigDefaults as tsConfig, cacheDirName} from './constants.js';
85

96
/**
107
This function checks if the files are matched by the tsconfig include, exclude, and it returns the unmatched files.
@@ -15,49 +12,23 @@ If no tsconfig is found, it will create a fallback tsconfig file in the `node_mo
1512
@returns The unmatched files.
1613
*/
1714
export async function handleTsconfig({cwd, files}: {cwd: string; files: string[]}) {
18-
const {config: tsConfig = tsconfigDefaults, path: tsConfigPath} = getTsconfig(cwd) ?? {};
19-
20-
tsConfig.compilerOptions ??= {};
21-
2215
const unincludedFiles: string[] = [];
2316

2417
for (const filePath of files) {
25-
let hasMatch = false;
18+
const result = getTsconfig(filePath);
2619

27-
if (!tsConfigPath) {
20+
if (!result) {
2821
unincludedFiles.push(filePath);
2922
continue;
3023
}
3124

32-
// If there is no files or include property - TS uses `**/*` as default so all TS files are matched.
33-
// In tsconfig, excludes override includes - so we need to prioritize that matching logic.
34-
if (
35-
tsConfig
36-
&& !tsConfig.include
37-
&& !tsConfig.files
38-
) {
39-
// If we have an excludes property, we need to check it.
40-
// If we match on excluded, then we definitively know that there is no tsconfig match.
41-
if (Array.isArray(tsConfig.exclude)) {
42-
const exclude = Array.isArray(tsConfig.exclude) ? tsConfig.exclude : [];
43-
hasMatch = !micromatch.contains(filePath, exclude, micromatchOptions);
44-
} else {
45-
// Not explicitly excluded and included by tsconfig defaults
46-
hasMatch = true;
47-
}
48-
} else {
49-
// We have either and include or a files property in tsconfig
50-
const include = Array.isArray(tsConfig.include) ? tsConfig.include : [];
51-
const files = Array.isArray(tsConfig.files) ? tsConfig.files : [];
52-
const exclude = Array.isArray(tsConfig.exclude) ? tsConfig.exclude : [];
53-
// If we also have an exlcude we need to check all the arrays, (files, include, exclude)
54-
// this check not excluded and included in one of the file/include array
55-
hasMatch = !micromatch.contains(filePath, exclude, micromatchOptions) && micromatch.isMatch(filePath, [...include, ...files], micromatchOptions);
56-
}
25+
const filesMatcher = createFilesMatcher(result);
5726

58-
if (!hasMatch) {
59-
unincludedFiles.push(filePath);
27+
if (filesMatcher(filePath)) {
28+
continue;
6029
}
30+
31+
unincludedFiles.push(filePath);
6132
}
6233

6334
const fallbackTsConfigPath = path.join(cwd, 'node_modules', '.cache', cacheDirName, 'tsconfig.xo.json');

‎test/cli.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import path from 'node:path';
33
import _test, {type TestFn} from 'ava'; // eslint-disable-line ava/use-test
44
import dedent from 'dedent';
55
import {$, type ExecaError} from 'execa';
6+
import {pathExists} from 'path-exists';
7+
import {type TsConfigJson} from 'get-tsconfig';
68
import {copyTestProject} from './helpers/copy-test-project.js';
79

810
const test = _test as TestFn<{cwd: string}>;
@@ -352,3 +354,116 @@ test('Config errors bubble up from ESLint when incorrect config options are set'
352354
const error = await t.throwsAsync<ExecaError>($`node ./dist/cli --cwd ${t.context.cwd}`);
353355
t.true((error.stderr as string)?.includes('ConfigError:') && (error.stderr as string)?.includes('Unexpected key "invalidOption" found'));
354356
});
357+
358+
test('ts in nested directory', async t => {
359+
const filePath = path.join(t.context.cwd, 'nested', 'src', 'test.ts');
360+
const baseTsConfigPath = path.join(t.context.cwd, 'tsconfig.json');
361+
const tsConfigNestedPath = path.join(t.context.cwd, 'nested', 'tsconfig.json');
362+
const tsconfigCachePath = path.join(t.context.cwd, 'node_modules', '.cache', 'xo-linter', 'tsconfig.xo.json');
363+
364+
// Remove any previous cache file
365+
await fs.rm(tsconfigCachePath, {force: true});
366+
367+
// Write the test.ts file
368+
await fs.mkdir(path.dirname(filePath), {recursive: true});
369+
await fs.writeFile(filePath, dedent`console.log('hello');\nconst test = 1;\n`, 'utf8');
370+
371+
// Copy the base tsconfig to the nested directory
372+
await fs.copyFile(baseTsConfigPath, tsConfigNestedPath);
373+
await fs.rm(baseTsConfigPath);
374+
const tsconfig = JSON.parse(await fs.readFile(tsConfigNestedPath, 'utf8')) as TsConfigJson;
375+
if (tsconfig.compilerOptions) {
376+
tsconfig.compilerOptions.baseUrl = './';
377+
}
378+
379+
tsconfig.include = ['src'];
380+
381+
await fs.writeFile(tsConfigNestedPath, JSON.stringify(tsconfig, null, 2), 'utf8');
382+
// Add an xo config file in root dir
383+
const xoConfigPath = path.join(t.context.cwd, 'xo.config.js');
384+
const xoConfig = dedent`
385+
export default [
386+
{ ignores: "xo.config.js" },
387+
{
388+
rules: {
389+
'@typescript-eslint/no-unused-vars': 'off',
390+
}
391+
}
392+
]
393+
`;
394+
await fs.writeFile(xoConfigPath, xoConfig, 'utf8');
395+
await t.notThrowsAsync($`node ./dist/cli --cwd ${t.context.cwd}`);
396+
t.false(await pathExists(tsconfigCachePath), 'tsconfig.xo.json should not be created in the cache directory when tsconfig.json is present in the nested directory');
397+
});
398+
399+
test('handles mixed project structure with nested tsconfig and root ts files', async t => {
400+
// Set up nested TypeScript files with a tsconfig
401+
const nestedFilePath = path.join(t.context.cwd, 'nested', 'src', 'test.ts');
402+
const nestedFile2Path = path.join(t.context.cwd, 'nested', 'src', 'test2.ts');
403+
const baseTsConfigPath = path.join(t.context.cwd, 'tsconfig.json');
404+
const tsConfigNestedPath = path.join(t.context.cwd, 'nested', 'tsconfig.json');
405+
const tsconfigCachePath = path.join(t.context.cwd, 'node_modules', '.cache', 'xo-linter', 'tsconfig.xo.json');
406+
407+
// Root ts file with no tsconfig
408+
const rootTsFilePath = path.join(t.context.cwd, 'root.ts');
409+
410+
// Remove any previous cache file
411+
await fs.rm(tsconfigCachePath, {force: true});
412+
413+
// Create directory structure and files
414+
await fs.mkdir(path.dirname(nestedFilePath), {recursive: true});
415+
await fs.writeFile(nestedFilePath, dedent`console.log('nested file 1');\nconst test1 = 1;\n`, 'utf8');
416+
await fs.writeFile(nestedFile2Path, dedent`console.log('nested file 2');\nconst test2 = 2;\n`, 'utf8');
417+
418+
// Create the root TS file with no accompanying tsconfig
419+
await fs.writeFile(rootTsFilePath, dedent`console.log('root file');\nconst rootVar = 3;\n`, 'utf8');
420+
421+
// Copy the base tsconfig to the nested directory only
422+
await fs.copyFile(baseTsConfigPath, tsConfigNestedPath);
423+
await fs.rm(baseTsConfigPath);
424+
const tsconfig = JSON.parse(await fs.readFile(tsConfigNestedPath, 'utf8')) as TsConfigJson;
425+
426+
if (tsconfig.compilerOptions) {
427+
tsconfig.compilerOptions.baseUrl = './';
428+
}
429+
430+
// Configure the nested tsconfig to include only the nested src directory
431+
tsconfig.include = ['src'];
432+
await fs.writeFile(tsConfigNestedPath, JSON.stringify(tsconfig, null, 2), 'utf8');
433+
434+
// Add an xo config file in root dir
435+
const xoConfigPath = path.join(t.context.cwd, 'xo.config.js');
436+
const xoConfig = dedent`
437+
export default [
438+
{ ignores: "xo.config.js" },
439+
{
440+
rules: {
441+
'@typescript-eslint/no-unused-vars': 'off',
442+
}
443+
}
444+
]
445+
`;
446+
await fs.writeFile(xoConfigPath, xoConfig, 'utf8');
447+
448+
// Run XO on the entire directory structure
449+
await t.notThrowsAsync($`node ./dist/cli --cwd ${t.context.cwd}`);
450+
451+
// Verify the cache file was created
452+
t.true(await pathExists(tsconfigCachePath), 'tsconfig.xo.json should be created for files not covered by existing tsconfigs');
453+
454+
// Check the content of the cached tsconfig
455+
const cachedTsConfig = JSON.parse(await fs.readFile(tsconfigCachePath, 'utf8')) as TsConfigJson;
456+
457+
// Verify only the root.ts file is in the cached tsconfig (not the nested files)
458+
t.deepEqual(cachedTsConfig.files, [rootTsFilePath], 'tsconfig.xo.json should only contain the root.ts file not covered by existing tsconfig');
459+
460+
// Verify the nested files aren't included (they should be covered by the nested tsconfig)
461+
t.false(
462+
cachedTsConfig.files?.includes(nestedFilePath),
463+
'tsconfig.xo.json should not include files already covered by nested tsconfig',
464+
);
465+
t.false(
466+
cachedTsConfig.files?.includes(nestedFile2Path),
467+
'tsconfig.xo.json should not include files already covered by nested tsconfig',
468+
);
469+
});

0 commit comments

Comments
 (0)