From 56efd3cd6583756f86b4695102019264c5ceed49 Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger <53019676+kirkwaiblinger@users.noreply.github.com> Date: Tue, 15 Apr 2025 08:44:50 -0600 Subject: [PATCH] redo from main --- .../typescript-eslint/src/config-helper.ts | 173 ++++++++++++------ .../tests/config-helper.test.ts | 92 ++++++++-- 2 files changed, 188 insertions(+), 77 deletions(-) diff --git a/packages/typescript-eslint/src/config-helper.ts b/packages/typescript-eslint/src/config-helper.ts index 6f8f5b9bfb27..ce5d4ea806ed 100644 --- a/packages/typescript-eslint/src/config-helper.ts +++ b/packages/typescript-eslint/src/config-helper.ts @@ -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( - (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; + }, + ); } /** diff --git a/packages/typescript-eslint/tests/config-helper.test.ts b/packages/typescript-eslint/tests/config-helper.test.ts index 1564267090ea..2def1d4256c4 100644 --- a/packages/typescript-eslint/tests/config-helper.test.ts +++ b/packages/typescript-eslint/tests/config-helper.test.ts @@ -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' }, @@ -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' }, }), @@ -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'], @@ -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'], @@ -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', ); }); @@ -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'], @@ -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'], @@ -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' } }, @@ -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' } }, @@ -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' } }]], @@ -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' } }], @@ -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'], }); @@ -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'], @@ -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'], }), @@ -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.", + ); + }); });