Skip to content

fix(typescript-eslint): gracefully handle invalid flat config objects in config helper #11070

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
173 changes: 113 additions & 60 deletions packages/typescript-eslint/src/config-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,70 +92,123 @@ export type ConfigArray = TSESLint.FlatConfig.ConfigArray;
export function config(
...configs: InfiniteDepthConfigWithExtends[]
): ConfigArray {
const flattened =
// @ts-expect-error -- intentionally an infinite type
configs.flat(Infinity) as ConfigWithExtends[];
return flattened.flatMap((configWithExtends, configIndex) => {
const { extends: extendsArr, ...config } = configWithExtends;
if (extendsArr == null || extendsArr.length === 0) {
return config;
}
const extendsArrFlattened = extendsArr.flat(
Infinity,
) as ConfigWithExtends[];

const undefinedExtensions = extendsArrFlattened.reduce<number[]>(
(acc, extension, extensionIndex) => {
const maybeExtension = extension as
| TSESLint.FlatConfig.Config
| undefined;
if (maybeExtension == null) {
acc.push(extensionIndex);
return configImpl(...configs);
}

// Implementation of the config function without assuming the runtime type of
// the input.
function configImpl(...configs: unknown[]): ConfigArray {
const flattened = configs.flat(Infinity);
return flattened.flatMap(
(
configWithExtends,
configIndex,
): TSESLint.FlatConfig.Config | TSESLint.FlatConfig.Config[] => {
if (
configWithExtends == null ||
typeof configWithExtends !== 'object' ||
!('extends' in configWithExtends)
) {
// Unless the object is a config object with extends key, just forward it
// along to eslint.
return configWithExtends as TSESLint.FlatConfig.Config;
}

const { extends: extendsArr, ..._config } = configWithExtends;
const config = _config as {
name?: unknown;
extends?: unknown;
files?: unknown;
ignores?: unknown;
};

if (extendsArr == null) {
// If the extends value is nullish, just forward along the rest of the
// config object to eslint.
return config as TSESLint.FlatConfig.Config;
}

const name = ((): string | undefined => {
if ('name' in configWithExtends && configWithExtends.name != null) {
if (typeof configWithExtends.name !== 'string') {
throw new Error(
`tseslint.config(): Config at index ${configIndex} has a 'name' property that is not a string.`,
);
}
return configWithExtends.name;
}
return acc;
},
[],
);
if (undefinedExtensions.length) {
const configName =
configWithExtends.name != null
? `, named "${configWithExtends.name}",`
: ' (anonymous)';
const extensionIndices = undefinedExtensions.join(', ');
throw new Error(
`Your config at index ${configIndex}${configName} contains undefined` +
` extensions at the following indices: ${extensionIndices}.`,
);
}

const configArray = [];

for (const extension of extendsArrFlattened) {
const name = [config.name, extension.name].filter(Boolean).join('__');
if (isPossiblyGlobalIgnores(extension)) {
// If it's a global ignores, then just pass it along
configArray.push({
...extension,
...(name && { name }),
});
} else {
configArray.push({
...extension,
...(config.files && { files: config.files }),
...(config.ignores && { ignores: config.ignores }),
...(name && { name }),
});
return undefined;
})();
const nameErrorPhrase =
name != null ? `, named "${name}",` : ' (anonymous)';

if (!Array.isArray(extendsArr)) {
throw new TypeError(
`tseslint.config(): Config at index ${configIndex}${nameErrorPhrase} has an 'extends' property that is not an array.`,
);
}
}

// If the base config could form a global ignores object, then we mustn't include
// it in the output. Otherwise, we must add it in order for it to have effect.
if (!isPossiblyGlobalIgnores(config)) {
configArray.push(config);
}
const extendsArrFlattened = (extendsArr as unknown[]).flat(Infinity);

const nonObjectExtensions = [];
for (const [extensionIndex, extension] of extendsArrFlattened.entries()) {
// special error message to be clear we don't support eslint's stringly typed extends.
// https://eslint.org/docs/latest/use/configure/configuration-files#extending-configurations
if (typeof extension === 'string') {
throw new Error(
`tseslint.config(): Config at index ${configIndex}${nameErrorPhrase} has an 'extends' array that contains a string (${JSON.stringify(extension)}) at index ${extensionIndex}.` +
" This is a feature of eslint's `defineConfig()` helper and is not supported by typescript-eslint." +
' Please provide a config object instead.',
);
}
if (extension == null || typeof extension !== 'object') {
nonObjectExtensions.push(extensionIndex);
}
}
if (nonObjectExtensions.length > 0) {
const extensionIndices = nonObjectExtensions.join(', ');
throw new Error(
`tseslint.config(): Config at index ${configIndex}${nameErrorPhrase} contains non-object` +
` extensions at the following indices: ${extensionIndices}.`,
);
}

const configArray = [];

for (const _extension of extendsArrFlattened) {
const extension = _extension as {
name?: unknown;
files?: unknown;
ignores?: unknown;
};
const resolvedConfigName = [name, extension.name]
.filter(Boolean)
.join('__');
if (isPossiblyGlobalIgnores(extension)) {
// If it's a global ignores, then just pass it along
configArray.push({
...extension,
...(resolvedConfigName !== '' ? { name: resolvedConfigName } : {}),
});
} else {
configArray.push({
...extension,
...(config.files ? { files: config.files } : {}),
...(config.ignores ? { ignores: config.ignores } : {}),
...(resolvedConfigName !== '' ? { name: resolvedConfigName } : {}),
});
}
}

// If the base config could form a global ignores object, then we mustn't include
// it in the output. Otherwise, we must add it in order for it to have effect.
if (!isPossiblyGlobalIgnores(config)) {
configArray.push(config);
}

return configArray;
});
return configArray as ConfigArray;
},
);
}

/**
Expand Down
92 changes: 75 additions & 17 deletions packages/typescript-eslint/tests/config-helper.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { TSESLint } from '@typescript-eslint/utils';

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

describe('config helper', () => {
it('works without extends', () => {
expect(
plugin.config({
tseslint.config({
files: ['file'],
ignores: ['ignored'],
rules: { rule: 'error' },
Expand All @@ -21,7 +21,7 @@ describe('config helper', () => {

it('flattens extended configs', () => {
expect(
plugin.config({
tseslint.config({
extends: [{ rules: { rule1: 'error' } }, { rules: { rule2: 'error' } }],
rules: { rule: 'error' },
}),
Expand All @@ -34,7 +34,7 @@ describe('config helper', () => {

it('flattens extended configs with files and ignores', () => {
expect(
plugin.config({
tseslint.config({
extends: [{ rules: { rule1: 'error' } }, { rules: { rule2: 'error' } }],
files: ['common-file'],
ignores: ['common-ignored'],
Expand Down Expand Up @@ -63,7 +63,7 @@ describe('config helper', () => {
const extension: TSESLint.FlatConfig.Config = { rules: { rule1: 'error' } };

expect(() =>
plugin.config(
tseslint.config(
{
extends: [extension],
files: ['common-file'],
Expand All @@ -81,7 +81,7 @@ describe('config helper', () => {
},
),
).toThrow(
'Your config at index 1, named "my-config-2", contains undefined ' +
'tseslint.config(): Config at index 1, named "my-config-2", contains non-object ' +
'extensions at the following indices: 0, 2',
);
});
Expand All @@ -90,7 +90,7 @@ describe('config helper', () => {
const extension: TSESLint.FlatConfig.Config = { rules: { rule1: 'error' } };

expect(() =>
plugin.config(
tseslint.config(
{
extends: [extension],
files: ['common-file'],
Expand All @@ -107,14 +107,14 @@ describe('config helper', () => {
},
),
).toThrow(
'Your config at index 1 (anonymous) contains undefined extensions at ' +
'tseslint.config(): Config at index 1 (anonymous) contains non-object extensions at ' +
'the following indices: 0, 2',
);
});

it('flattens extended configs with config name', () => {
expect(
plugin.config({
tseslint.config({
extends: [{ rules: { rule1: 'error' } }, { rules: { rule2: 'error' } }],
files: ['common-file'],
ignores: ['common-ignored'],
Expand Down Expand Up @@ -145,7 +145,7 @@ describe('config helper', () => {

it('flattens extended configs with names if base config is unnamed', () => {
expect(
plugin.config({
tseslint.config({
extends: [
{ name: 'extension-1', rules: { rule1: 'error' } },
{ rules: { rule2: 'error' } },
Expand Down Expand Up @@ -176,7 +176,7 @@ describe('config helper', () => {

it('merges config items names', () => {
expect(
plugin.config({
tseslint.config({
extends: [
{ name: 'extension-1', rules: { rule1: 'error' } },
{ rules: { rule2: 'error' } },
Expand Down Expand Up @@ -210,7 +210,7 @@ describe('config helper', () => {

it('allows nested arrays in the config function', () => {
expect(
plugin.config(
tseslint.config(
{ rules: { rule1: 'error' } },
[{ rules: { rule2: 'error' } }],
[[{ rules: { rule3: 'error' } }]],
Expand All @@ -228,7 +228,7 @@ describe('config helper', () => {

it('allows nested arrays in extends', () => {
expect(
plugin.config({
tseslint.config({
extends: [
{ rules: { rule1: 'error' } },
[{ rules: { rule2: 'error' } }],
Expand All @@ -249,7 +249,7 @@ describe('config helper', () => {
});

it('does not create global ignores in extends', () => {
const configWithIgnores = plugin.config({
const configWithIgnores = tseslint.config({
extends: [{ rules: { rule1: 'error' } }, { rules: { rule2: 'error' } }],
ignores: ['ignored'],
});
Expand All @@ -265,7 +265,7 @@ describe('config helper', () => {
});

it('creates noop config in extends', () => {
const configWithMetadata = plugin.config({
const configWithMetadata = tseslint.config({
extends: [{ rules: { rule1: 'error' } }, { rules: { rule2: 'error' } }],
files: ['file'],
ignores: ['ignored'],
Expand Down Expand Up @@ -297,7 +297,7 @@ describe('config helper', () => {

it('does not create global ignores when extending empty configs', () => {
expect(
plugin.config({
tseslint.config({
extends: [{ rules: { rule1: 'error' } }, {}],
ignores: ['ignored'],
}),
Expand All @@ -310,10 +310,68 @@ describe('config helper', () => {

it('handles name field when global-ignoring in extension', () => {
expect(
plugin.config({
tseslint.config({
extends: [{ ignores: ['files/**/*'], name: 'global-ignore-stuff' }],
ignores: ['ignored'],
}),
).toEqual([{ ignores: ['files/**/*'], name: 'global-ignore-stuff' }]);
});

it('throws error when extends is not an array', () => {
expect(() =>
tseslint.config({
// @ts-expect-error purposely testing invalid values
extends: 42,
}),
).toThrow(
"tseslint.config(): Config at index 0 (anonymous) has an 'extends' property that is not an array.",
);
});

it.each([undefined, null, 'not a config object', 42])(
'passes invalid arguments through unchanged',
config => {
expect(
tseslint.config(
// @ts-expect-error purposely testing invalid values
config,
),
).toStrictEqual([config]);
},
);

it('gives a special error message for string extends', () => {
expect(() =>
tseslint.config({
// @ts-expect-error purposely testing invalid values
extends: ['some-string'],
}),
).toThrow(
'tseslint.config(): Config at index 0 (anonymous) has an \'extends\' array that contains a string ("some-string") at index 0. ' +
"This is a feature of eslint's `defineConfig()` helper and is not supported by typescript-eslint. " +
'Please provide a config object instead.',
);
});

it('strips nullish extends arrays from the config object', () => {
expect(
tseslint.config({
// @ts-expect-error purposely testing invalid values
extends: null,
files: ['files'],
}),
).toEqual([{ files: ['files'] }]);
});

it('complains when given an object with an invalid name', () => {
expect(() =>
tseslint.config({
extends: [],
// @ts-expect-error purposely testing invalid values
name: 42,
}),
).toThrow(
"tseslint.config(): Config at index 0 has a 'name' property that is not a string.",
);
});
});