Skip to content

fix(typescript-eslint): gracefully handle invalid flat config objects in tseslint.config #10576

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

Closed
Closed
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
88 changes: 58 additions & 30 deletions packages/typescript-eslint/src/config-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export interface ConfigWithExtends extends TSESLint.FlatConfig.Config {

// exported so that users that make configs with tsconfig `declaration: true` can name the type
export type ConfigArray = TSESLint.FlatConfig.ConfigArray;
type Config = TSESLint.FlatConfig.Config;

/**
* Utility function to make it easy to strictly type your "Flat" config file
Expand All @@ -92,53 +93,80 @@ 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 configWithoutAssumptions(configs);
}

function isObject(value: unknown): value is object {
// eslint-disable-next-line @typescript-eslint/internal/eqeq-nullish, eqeqeq
return typeof value === 'object' && value !== null;
}

// Implement the `config()` helper without assuming the runtime type of the input.
function configWithoutAssumptions(configs: unknown[]): ConfigArray {
const flattened = configs.flat(Infinity);
return flattened.flatMap((configWithExtends, configIndex) => {
const { extends: extendsArr, ...config } = configWithExtends;
if (extendsArr == null || extendsArr.length === 0) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null check was updated to use in, to make TS happy.

length check was unnecessary.

return config;
if (!isObject(configWithExtends) || !('extends' in configWithExtends)) {
// `configWithExtends` could be anything, but we'll assume it's a `Config` object for TS purposes.
return configWithExtends as Config;
}
const {
extends: extendsArr,
...config
}: { extends: unknown } & Partial<
Record<'files' | 'ignores' | 'name', unknown>
> = configWithExtends;
const { name } = config;
const nameIsString = typeof name === 'string';
const extendsError = (expected: string) =>
new TypeError(
`tseslint.config(): Config at index ${configIndex} ${
nameIsString ? `"${name}"` : '(anonymous)'
}: Key "extends": Expected ${expected}.`,
);
if (!Array.isArray(extendsArr)) {
throw extendsError('value to be an array');
}
const extendsArrFlattened = extendsArr.flat(
Infinity,
) as ConfigWithExtends[];

const undefinedExtensions = extendsArrFlattened.reduce<number[]>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just change this to be invalidExtensions and report more errors than just the indices?

const extendsArrFlattened = extendsArr.flat(Infinity);
const nonObjectExtensions = extendsArrFlattened.reduce<number[]>(
(acc, extension, extensionIndex) => {
const maybeExtension = extension as
| TSESLint.FlatConfig.Config
| undefined;
if (maybeExtension == null) {
if (!isObject(extension)) {
acc.push(extensionIndex);
}
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}.`,
if (nonObjectExtensions.length) {
throw extendsError(
`array to only contain objects (contains non-objects at the following indices: ${nonObjectExtensions.join(`, `)})`,
);
}

return [
...extendsArrFlattened.map(extension => {
const name = [config.name, extension.name].filter(Boolean).join('__');
...extendsArrFlattened.map((extension: object) => {
const mergedName = [
nameIsString && name,
'name' in extension && extension.name,
]
.filter(Boolean)
.join('__');
return {
...extension,
...(config.files && { files: config.files }),
...(config.ignores && { ignores: config.ignores }),
...(name && { name }),
// `extension` could be any object, but we'll assume it's a `Config` object for TS purposes.
...(extension as Config),
...Object.fromEntries(
Object.entries(config).filter(
([key, value]) =>
['files', 'ignores'].includes(key) &&
// eslint-disable-next-line @typescript-eslint/internal/eqeq-nullish
value !== undefined,
),
),
...(mergedName && { name: mergedName }),
};
}),
config,
// `config` could be any object, but we'll assume it's a `Config` object for TS purposes.
config as Config,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inside this function, config should be unknown, but outside this function, config should be Config even if it's really an invalid value

];
});
}
29 changes: 25 additions & 4 deletions packages/typescript-eslint/tests/config-helper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ describe('config helper', () => {
]);
});

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

it('throws error containing config name when some extensions are undefined', () => {
const extension: TSESLint.FlatConfig.Config = { rules: { rule1: 'error' } };

Expand All @@ -81,8 +92,7 @@ describe('config helper', () => {
},
),
).toThrow(
'Your config at index 1, named "my-config-2", contains undefined ' +
'extensions at the following indices: 0, 2',
'tseslint.config(): Config at index 1 "my-config-2": Key "extends": Expected array to only contain objects (contains non-objects at the following indices: 0, 2).',
);
});

Expand All @@ -107,8 +117,7 @@ describe('config helper', () => {
},
),
).toThrow(
'Your config at index 1 (anonymous) contains undefined extensions at ' +
'the following indices: 0, 2',
'tseslint.config(): Config at index 1 (anonymous): Key "extends": Expected array to only contain objects (contains non-objects at the following indices: 0, 2).',
);
});

Expand Down Expand Up @@ -247,4 +256,16 @@ describe('config helper', () => {
{ rules: { rule: 'error' } },
]);
});

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