Skip to content
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
47 changes: 9 additions & 38 deletions lib/handle-ts-files.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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');
Expand Down
115 changes: 115 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}>;
Expand Down Expand Up @@ -352,3 +354,116 @@ test('Config errors bubble up from ESLint when incorrect config options are set'
const error = await t.throwsAsync<ExecaError>($`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',
);
});