diff --git a/lib/handle-ts-files.ts b/lib/handle-ts-files.ts index 7cd40dc2..1b93c85b 100644 --- a/lib/handle-ts-files.ts +++ b/lib/handle-ts-files.ts @@ -1,10 +1,7 @@ import path from 'node:path'; import fs from 'node:fs/promises'; -import {getTsconfig} from 'get-tsconfig'; -import micromatch, {type Options} from 'micromatch'; -import {tsconfigDefaults, cacheDirName} from './constants.js'; - -const micromatchOptions: Options = {matchBase: true}; +import {getTsconfig, createFilesMatcher} from 'get-tsconfig'; +import {tsconfigDefaults as tsConfig, cacheDirName} from './constants.js'; /** 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 @returns The unmatched files. */ export async function handleTsconfig({cwd, files}: {cwd: string; files: string[]}) { - const {config: tsConfig = tsconfigDefaults, path: tsConfigPath} = getTsconfig(cwd) ?? {}; - - tsConfig.compilerOptions ??= {}; - const unincludedFiles: string[] = []; for (const filePath of files) { - let hasMatch = false; + const result = getTsconfig(filePath); - if (!tsConfigPath) { + if (!result) { unincludedFiles.push(filePath); continue; } - // If there is no files or include property - TS uses `**/*` as default so all TS files are matched. - // In tsconfig, excludes override includes - so we need to prioritize that matching logic. - if ( - tsConfig - && !tsConfig.include - && !tsConfig.files - ) { - // If we have an excludes property, we need to check it. - // If we match on excluded, then we definitively know that there is no tsconfig match. - if (Array.isArray(tsConfig.exclude)) { - const exclude = Array.isArray(tsConfig.exclude) ? tsConfig.exclude : []; - hasMatch = !micromatch.contains(filePath, exclude, micromatchOptions); - } else { - // Not explicitly excluded and included by tsconfig defaults - hasMatch = true; - } - } else { - // We have either and include or a files property in tsconfig - const include = Array.isArray(tsConfig.include) ? tsConfig.include : []; - const files = Array.isArray(tsConfig.files) ? tsConfig.files : []; - const exclude = Array.isArray(tsConfig.exclude) ? tsConfig.exclude : []; - // If we also have an exlcude we need to check all the arrays, (files, include, exclude) - // this check not excluded and included in one of the file/include array - hasMatch = !micromatch.contains(filePath, exclude, micromatchOptions) && micromatch.isMatch(filePath, [...include, ...files], micromatchOptions); - } + const filesMatcher = createFilesMatcher(result); - if (!hasMatch) { - unincludedFiles.push(filePath); + if (filesMatcher(filePath)) { + continue; } + + unincludedFiles.push(filePath); } const fallbackTsConfigPath = path.join(cwd, 'node_modules', '.cache', cacheDirName, 'tsconfig.xo.json'); diff --git a/test/cli.test.ts b/test/cli.test.ts index 9cf915f2..2db2baa1 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -3,6 +3,8 @@ import path from 'node:path'; import _test, {type TestFn} from 'ava'; // eslint-disable-line ava/use-test import dedent from 'dedent'; import {$, type ExecaError} from 'execa'; +import {pathExists} from 'path-exists'; +import {type TsConfigJson} from 'get-tsconfig'; import {copyTestProject} from './helpers/copy-test-project.js'; const test = _test as TestFn<{cwd: string}>; @@ -352,3 +354,116 @@ test('Config errors bubble up from ESLint when incorrect config options are set' const error = await t.throwsAsync($`node ./dist/cli --cwd ${t.context.cwd}`); t.true((error.stderr as string)?.includes('ConfigError:') && (error.stderr as string)?.includes('Unexpected key "invalidOption" found')); }); + +test('ts in nested directory', async t => { + const filePath = path.join(t.context.cwd, 'nested', 'src', 'test.ts'); + const baseTsConfigPath = path.join(t.context.cwd, 'tsconfig.json'); + const tsConfigNestedPath = path.join(t.context.cwd, 'nested', 'tsconfig.json'); + const tsconfigCachePath = path.join(t.context.cwd, 'node_modules', '.cache', 'xo-linter', 'tsconfig.xo.json'); + + // Remove any previous cache file + await fs.rm(tsconfigCachePath, {force: true}); + + // Write the test.ts file + await fs.mkdir(path.dirname(filePath), {recursive: true}); + await fs.writeFile(filePath, dedent`console.log('hello');\nconst test = 1;\n`, 'utf8'); + + // Copy the base tsconfig to the nested directory + await fs.copyFile(baseTsConfigPath, tsConfigNestedPath); + await fs.rm(baseTsConfigPath); + const tsconfig = JSON.parse(await fs.readFile(tsConfigNestedPath, 'utf8')) as TsConfigJson; + if (tsconfig.compilerOptions) { + tsconfig.compilerOptions.baseUrl = './'; + } + + tsconfig.include = ['src']; + + await fs.writeFile(tsConfigNestedPath, JSON.stringify(tsconfig, null, 2), 'utf8'); + // Add an xo config file in root dir + const xoConfigPath = path.join(t.context.cwd, 'xo.config.js'); + const xoConfig = dedent` + export default [ + { ignores: "xo.config.js" }, + { + rules: { + '@typescript-eslint/no-unused-vars': 'off', + } + } + ] + `; + await fs.writeFile(xoConfigPath, xoConfig, 'utf8'); + await t.notThrowsAsync($`node ./dist/cli --cwd ${t.context.cwd}`); + 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'); +}); + +test('handles mixed project structure with nested tsconfig and root ts files', async t => { + // Set up nested TypeScript files with a tsconfig + const nestedFilePath = path.join(t.context.cwd, 'nested', 'src', 'test.ts'); + const nestedFile2Path = path.join(t.context.cwd, 'nested', 'src', 'test2.ts'); + const baseTsConfigPath = path.join(t.context.cwd, 'tsconfig.json'); + const tsConfigNestedPath = path.join(t.context.cwd, 'nested', 'tsconfig.json'); + const tsconfigCachePath = path.join(t.context.cwd, 'node_modules', '.cache', 'xo-linter', 'tsconfig.xo.json'); + + // Root ts file with no tsconfig + const rootTsFilePath = path.join(t.context.cwd, 'root.ts'); + + // Remove any previous cache file + await fs.rm(tsconfigCachePath, {force: true}); + + // Create directory structure and files + await fs.mkdir(path.dirname(nestedFilePath), {recursive: true}); + await fs.writeFile(nestedFilePath, dedent`console.log('nested file 1');\nconst test1 = 1;\n`, 'utf8'); + await fs.writeFile(nestedFile2Path, dedent`console.log('nested file 2');\nconst test2 = 2;\n`, 'utf8'); + + // Create the root TS file with no accompanying tsconfig + await fs.writeFile(rootTsFilePath, dedent`console.log('root file');\nconst rootVar = 3;\n`, 'utf8'); + + // Copy the base tsconfig to the nested directory only + await fs.copyFile(baseTsConfigPath, tsConfigNestedPath); + await fs.rm(baseTsConfigPath); + const tsconfig = JSON.parse(await fs.readFile(tsConfigNestedPath, 'utf8')) as TsConfigJson; + + if (tsconfig.compilerOptions) { + tsconfig.compilerOptions.baseUrl = './'; + } + + // Configure the nested tsconfig to include only the nested src directory + tsconfig.include = ['src']; + await fs.writeFile(tsConfigNestedPath, JSON.stringify(tsconfig, null, 2), 'utf8'); + + // Add an xo config file in root dir + const xoConfigPath = path.join(t.context.cwd, 'xo.config.js'); + const xoConfig = dedent` + export default [ + { ignores: "xo.config.js" }, + { + rules: { + '@typescript-eslint/no-unused-vars': 'off', + } + } + ] + `; + await fs.writeFile(xoConfigPath, xoConfig, 'utf8'); + + // Run XO on the entire directory structure + await t.notThrowsAsync($`node ./dist/cli --cwd ${t.context.cwd}`); + + // Verify the cache file was created + t.true(await pathExists(tsconfigCachePath), 'tsconfig.xo.json should be created for files not covered by existing tsconfigs'); + + // Check the content of the cached tsconfig + const cachedTsConfig = JSON.parse(await fs.readFile(tsconfigCachePath, 'utf8')) as TsConfigJson; + + // Verify only the root.ts file is in the cached tsconfig (not the nested files) + t.deepEqual(cachedTsConfig.files, [rootTsFilePath], 'tsconfig.xo.json should only contain the root.ts file not covered by existing tsconfig'); + + // Verify the nested files aren't included (they should be covered by the nested tsconfig) + t.false( + cachedTsConfig.files?.includes(nestedFilePath), + 'tsconfig.xo.json should not include files already covered by nested tsconfig', + ); + t.false( + cachedTsConfig.files?.includes(nestedFile2Path), + 'tsconfig.xo.json should not include files already covered by nested tsconfig', + ); +});