Skip to content

Commit 78e7cf4

Browse files
kirkwaiblingerphaux
authored andcommitted
fix(typescript-eslint): gracefully handle invalid flat config objects in config helper (typescript-eslint#11070)
redo from main
1 parent baa4b47 commit 78e7cf4

File tree

2 files changed

+188
-77
lines changed

2 files changed

+188
-77
lines changed

packages/typescript-eslint/src/config-helper.ts

Lines changed: 113 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -92,70 +92,123 @@ export type ConfigArray = TSESLint.FlatConfig.ConfigArray;
9292
export function config(
9393
...configs: InfiniteDepthConfigWithExtends[]
9494
): ConfigArray {
95-
const flattened =
96-
// @ts-expect-error -- intentionally an infinite type
97-
configs.flat(Infinity) as ConfigWithExtends[];
98-
return flattened.flatMap((configWithExtends, configIndex) => {
99-
const { extends: extendsArr, ...config } = configWithExtends;
100-
if (extendsArr == null || extendsArr.length === 0) {
101-
return config;
102-
}
103-
const extendsArrFlattened = extendsArr.flat(
104-
Infinity,
105-
) as ConfigWithExtends[];
106-
107-
const undefinedExtensions = extendsArrFlattened.reduce<number[]>(
108-
(acc, extension, extensionIndex) => {
109-
const maybeExtension = extension as
110-
| TSESLint.FlatConfig.Config
111-
| undefined;
112-
if (maybeExtension == null) {
113-
acc.push(extensionIndex);
95+
return configImpl(...configs);
96+
}
97+
98+
// Implementation of the config function without assuming the runtime type of
99+
// the input.
100+
function configImpl(...configs: unknown[]): ConfigArray {
101+
const flattened = configs.flat(Infinity);
102+
return flattened.flatMap(
103+
(
104+
configWithExtends,
105+
configIndex,
106+
): TSESLint.FlatConfig.Config | TSESLint.FlatConfig.Config[] => {
107+
if (
108+
configWithExtends == null ||
109+
typeof configWithExtends !== 'object' ||
110+
!('extends' in configWithExtends)
111+
) {
112+
// Unless the object is a config object with extends key, just forward it
113+
// along to eslint.
114+
return configWithExtends as TSESLint.FlatConfig.Config;
115+
}
116+
117+
const { extends: extendsArr, ..._config } = configWithExtends;
118+
const config = _config as {
119+
name?: unknown;
120+
extends?: unknown;
121+
files?: unknown;
122+
ignores?: unknown;
123+
};
124+
125+
if (extendsArr == null) {
126+
// If the extends value is nullish, just forward along the rest of the
127+
// config object to eslint.
128+
return config as TSESLint.FlatConfig.Config;
129+
}
130+
131+
const name = ((): string | undefined => {
132+
if ('name' in configWithExtends && configWithExtends.name != null) {
133+
if (typeof configWithExtends.name !== 'string') {
134+
throw new Error(
135+
`tseslint.config(): Config at index ${configIndex} has a 'name' property that is not a string.`,
136+
);
137+
}
138+
return configWithExtends.name;
114139
}
115-
return acc;
116-
},
117-
[],
118-
);
119-
if (undefinedExtensions.length) {
120-
const configName =
121-
configWithExtends.name != null
122-
? `, named "${configWithExtends.name}",`
123-
: ' (anonymous)';
124-
const extensionIndices = undefinedExtensions.join(', ');
125-
throw new Error(
126-
`Your config at index ${configIndex}${configName} contains undefined` +
127-
` extensions at the following indices: ${extensionIndices}.`,
128-
);
129-
}
130-
131-
const configArray = [];
132-
133-
for (const extension of extendsArrFlattened) {
134-
const name = [config.name, extension.name].filter(Boolean).join('__');
135-
if (isPossiblyGlobalIgnores(extension)) {
136-
// If it's a global ignores, then just pass it along
137-
configArray.push({
138-
...extension,
139-
...(name && { name }),
140-
});
141-
} else {
142-
configArray.push({
143-
...extension,
144-
...(config.files && { files: config.files }),
145-
...(config.ignores && { ignores: config.ignores }),
146-
...(name && { name }),
147-
});
140+
return undefined;
141+
})();
142+
const nameErrorPhrase =
143+
name != null ? `, named "${name}",` : ' (anonymous)';
144+
145+
if (!Array.isArray(extendsArr)) {
146+
throw new TypeError(
147+
`tseslint.config(): Config at index ${configIndex}${nameErrorPhrase} has an 'extends' property that is not an array.`,
148+
);
148149
}
149-
}
150150

151-
// If the base config could form a global ignores object, then we mustn't include
152-
// it in the output. Otherwise, we must add it in order for it to have effect.
153-
if (!isPossiblyGlobalIgnores(config)) {
154-
configArray.push(config);
155-
}
151+
const extendsArrFlattened = (extendsArr as unknown[]).flat(Infinity);
152+
153+
const nonObjectExtensions = [];
154+
for (const [extensionIndex, extension] of extendsArrFlattened.entries()) {
155+
// special error message to be clear we don't support eslint's stringly typed extends.
156+
// https://eslint.org/docs/latest/use/configure/configuration-files#extending-configurations
157+
if (typeof extension === 'string') {
158+
throw new Error(
159+
`tseslint.config(): Config at index ${configIndex}${nameErrorPhrase} has an 'extends' array that contains a string (${JSON.stringify(extension)}) at index ${extensionIndex}.` +
160+
" This is a feature of eslint's `defineConfig()` helper and is not supported by typescript-eslint." +
161+
' Please provide a config object instead.',
162+
);
163+
}
164+
if (extension == null || typeof extension !== 'object') {
165+
nonObjectExtensions.push(extensionIndex);
166+
}
167+
}
168+
if (nonObjectExtensions.length > 0) {
169+
const extensionIndices = nonObjectExtensions.join(', ');
170+
throw new Error(
171+
`tseslint.config(): Config at index ${configIndex}${nameErrorPhrase} contains non-object` +
172+
` extensions at the following indices: ${extensionIndices}.`,
173+
);
174+
}
175+
176+
const configArray = [];
177+
178+
for (const _extension of extendsArrFlattened) {
179+
const extension = _extension as {
180+
name?: unknown;
181+
files?: unknown;
182+
ignores?: unknown;
183+
};
184+
const resolvedConfigName = [name, extension.name]
185+
.filter(Boolean)
186+
.join('__');
187+
if (isPossiblyGlobalIgnores(extension)) {
188+
// If it's a global ignores, then just pass it along
189+
configArray.push({
190+
...extension,
191+
...(resolvedConfigName !== '' ? { name: resolvedConfigName } : {}),
192+
});
193+
} else {
194+
configArray.push({
195+
...extension,
196+
...(config.files ? { files: config.files } : {}),
197+
...(config.ignores ? { ignores: config.ignores } : {}),
198+
...(resolvedConfigName !== '' ? { name: resolvedConfigName } : {}),
199+
});
200+
}
201+
}
202+
203+
// If the base config could form a global ignores object, then we mustn't include
204+
// it in the output. Otherwise, we must add it in order for it to have effect.
205+
if (!isPossiblyGlobalIgnores(config)) {
206+
configArray.push(config);
207+
}
156208

157-
return configArray;
158-
});
209+
return configArray as ConfigArray;
210+
},
211+
);
159212
}
160213

161214
/**

packages/typescript-eslint/tests/config-helper.test.ts

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import type { TSESLint } from '@typescript-eslint/utils';
22

3-
import plugin from '../src/index';
3+
import tseslint from '../src/index';
44

55
describe('config helper', () => {
66
it('works without extends', () => {
77
expect(
8-
plugin.config({
8+
tseslint.config({
99
files: ['file'],
1010
ignores: ['ignored'],
1111
rules: { rule: 'error' },
@@ -21,7 +21,7 @@ describe('config helper', () => {
2121

2222
it('flattens extended configs', () => {
2323
expect(
24-
plugin.config({
24+
tseslint.config({
2525
extends: [{ rules: { rule1: 'error' } }, { rules: { rule2: 'error' } }],
2626
rules: { rule: 'error' },
2727
}),
@@ -34,7 +34,7 @@ describe('config helper', () => {
3434

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

6565
expect(() =>
66-
plugin.config(
66+
tseslint.config(
6767
{
6868
extends: [extension],
6969
files: ['common-file'],
@@ -81,7 +81,7 @@ describe('config helper', () => {
8181
},
8282
),
8383
).toThrow(
84-
'Your config at index 1, named "my-config-2", contains undefined ' +
84+
'tseslint.config(): Config at index 1, named "my-config-2", contains non-object ' +
8585
'extensions at the following indices: 0, 2',
8686
);
8787
});
@@ -90,7 +90,7 @@ describe('config helper', () => {
9090
const extension: TSESLint.FlatConfig.Config = { rules: { rule1: 'error' } };
9191

9292
expect(() =>
93-
plugin.config(
93+
tseslint.config(
9494
{
9595
extends: [extension],
9696
files: ['common-file'],
@@ -107,14 +107,14 @@ describe('config helper', () => {
107107
},
108108
),
109109
).toThrow(
110-
'Your config at index 1 (anonymous) contains undefined extensions at ' +
110+
'tseslint.config(): Config at index 1 (anonymous) contains non-object extensions at ' +
111111
'the following indices: 0, 2',
112112
);
113113
});
114114

115115
it('flattens extended configs with config name', () => {
116116
expect(
117-
plugin.config({
117+
tseslint.config({
118118
extends: [{ rules: { rule1: 'error' } }, { rules: { rule2: 'error' } }],
119119
files: ['common-file'],
120120
ignores: ['common-ignored'],
@@ -145,7 +145,7 @@ describe('config helper', () => {
145145

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

177177
it('merges config items names', () => {
178178
expect(
179-
plugin.config({
179+
tseslint.config({
180180
extends: [
181181
{ name: 'extension-1', rules: { rule1: 'error' } },
182182
{ rules: { rule2: 'error' } },
@@ -210,7 +210,7 @@ describe('config helper', () => {
210210

211211
it('allows nested arrays in the config function', () => {
212212
expect(
213-
plugin.config(
213+
tseslint.config(
214214
{ rules: { rule1: 'error' } },
215215
[{ rules: { rule2: 'error' } }],
216216
[[{ rules: { rule3: 'error' } }]],
@@ -228,7 +228,7 @@ describe('config helper', () => {
228228

229229
it('allows nested arrays in extends', () => {
230230
expect(
231-
plugin.config({
231+
tseslint.config({
232232
extends: [
233233
{ rules: { rule1: 'error' } },
234234
[{ rules: { rule2: 'error' } }],
@@ -249,7 +249,7 @@ describe('config helper', () => {
249249
});
250250

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

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

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

311311
it('handles name field when global-ignoring in extension', () => {
312312
expect(
313-
plugin.config({
313+
tseslint.config({
314314
extends: [{ ignores: ['files/**/*'], name: 'global-ignore-stuff' }],
315315
ignores: ['ignored'],
316316
}),
317317
).toEqual([{ ignores: ['files/**/*'], name: 'global-ignore-stuff' }]);
318318
});
319+
320+
it('throws error when extends is not an array', () => {
321+
expect(() =>
322+
tseslint.config({
323+
// @ts-expect-error purposely testing invalid values
324+
extends: 42,
325+
}),
326+
).toThrow(
327+
"tseslint.config(): Config at index 0 (anonymous) has an 'extends' property that is not an array.",
328+
);
329+
});
330+
331+
it.each([undefined, null, 'not a config object', 42])(
332+
'passes invalid arguments through unchanged',
333+
config => {
334+
expect(
335+
tseslint.config(
336+
// @ts-expect-error purposely testing invalid values
337+
config,
338+
),
339+
).toStrictEqual([config]);
340+
},
341+
);
342+
343+
it('gives a special error message for string extends', () => {
344+
expect(() =>
345+
tseslint.config({
346+
// @ts-expect-error purposely testing invalid values
347+
extends: ['some-string'],
348+
}),
349+
).toThrow(
350+
'tseslint.config(): Config at index 0 (anonymous) has an \'extends\' array that contains a string ("some-string") at index 0. ' +
351+
"This is a feature of eslint's `defineConfig()` helper and is not supported by typescript-eslint. " +
352+
'Please provide a config object instead.',
353+
);
354+
});
355+
356+
it('strips nullish extends arrays from the config object', () => {
357+
expect(
358+
tseslint.config({
359+
// @ts-expect-error purposely testing invalid values
360+
extends: null,
361+
files: ['files'],
362+
}),
363+
).toEqual([{ files: ['files'] }]);
364+
});
365+
366+
it('complains when given an object with an invalid name', () => {
367+
expect(() =>
368+
tseslint.config({
369+
extends: [],
370+
// @ts-expect-error purposely testing invalid values
371+
name: 42,
372+
}),
373+
).toThrow(
374+
"tseslint.config(): Config at index 0 has a 'name' property that is not a string.",
375+
);
376+
});
319377
});

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy