From e4122a3d349a3aeea268e4fef79bcdbde11b2724 Mon Sep 17 00:00:00 2001 From: Roman Claudiu Date: Sun, 8 Nov 2026 16:25:39 +0200 Subject: [PATCH] fix(core): prefer later entries of an extends array for cmkOptions readConfigFile merged the extended tsconfig files in the flattened order of extendedSourceFiles and never overwrote an option that was already set. That is right for a chain of extends, but for an extends array it let the earliest entry win, while TypeScript lets the latest entry win. Resolve the inherited options per extends entry instead, so a later entry (and the files it extends) takes precedence over an earlier one. Fixes #453 --- .changeset/fix-extends-array-precedence.md | 7 +++ packages/core-strada/src/config.test.ts | 61 ++++++++++++++++++++++ packages/core-strada/src/config.ts | 52 +++++++++++++++--- 3 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-extends-array-precedence.md diff --git a/.changeset/fix-extends-array-precedence.md b/.changeset/fix-extends-array-precedence.md new file mode 100644 index 00000000..11dbcc71 --- /dev/null +++ b/.changeset/fix-extends-array-precedence.md @@ -0,0 +1,7 @@ +--- +'@css-modules-kit/core': patch +'@css-modules-kit/ts-plugin': patch +'@css-modules-kit/codegen': patch +--- + +fix(core, ts-plugin, codegen): let a later entry of an `extends` array take precedence over an earlier one for `cmkOptions`, as TypeScript does for `compilerOptions` diff --git a/packages/core-strada/src/config.test.ts b/packages/core-strada/src/config.test.ts index 46111754..ce1e2623 100644 --- a/packages/core-strada/src/config.test.ts +++ b/packages/core-strada/src/config.test.ts @@ -206,6 +206,67 @@ describe('readConfigFile', () => { }); expect(readConfigFile(iff.rootDir).dtsOutDir).toBe(iff.join('generated2')); }); + test('prefers a later entry of an `extends` array over an earlier one', async () => { + const iff = await createIFF({ + 'tsconfig.base1.json': dedent` + { + "compilerOptions": { "module": "esnext" }, + "cmkOptions": { "dtsOutDir": "generated1" } + } + `, + 'tsconfig.base2.json': dedent` + { + "compilerOptions": { "module": "es2015" }, + "cmkOptions": { "dtsOutDir": "generated2" } + } + `, + 'tsconfig.json': dedent` + { + "extends": ["./tsconfig.base1.json", "./tsconfig.base2.json"] + } + `, + }); + expect(readConfigFile(iff.rootDir)).toStrictEqual( + expect.objectContaining({ + dtsOutDir: iff.join('generated2'), + compilerOptions: expect.objectContaining({ + module: ts.ModuleKind.ES2015, + }), + }), + ); + }); + test('prefers the tsconfig extended by a later entry of an `extends` array over an earlier entry', async () => { + const iff = await createIFF({ + 'tsconfig.base1.json': dedent` + { + "cmkOptions": { "dtsOutDir": "generated1", "namedExports": true } + } + `, + 'tsconfig.base2-parent.json': dedent` + { + "cmkOptions": { "dtsOutDir": "generated2-parent", "arbitraryExtensions": true } + } + `, + 'tsconfig.base2.json': dedent` + { + "extends": "./tsconfig.base2-parent.json", + "cmkOptions": { "arbitraryExtensions": false } + } + `, + 'tsconfig.json': dedent` + { + "extends": ["./tsconfig.base1.json", "./tsconfig.base2.json"] + } + `, + }); + expect(readConfigFile(iff.rootDir)).toStrictEqual( + expect.objectContaining({ + dtsOutDir: iff.join('generated2-parent'), + namedExports: true, + arbitraryExtensions: false, + }), + ); + }); test('inherits options from a tsconfig in a package', async () => { const iff = await createIFF({ 'node_modules/some-pkg/tsconfig.json': dedent` diff --git a/packages/core-strada/src/config.ts b/packages/core-strada/src/config.ts index d8117ff2..a204df36 100644 --- a/packages/core-strada/src/config.ts +++ b/packages/core-strada/src/config.ts @@ -270,6 +270,45 @@ function parseTsConfigFile(fileName: string, basePath: string) { }; } +type ParsedTsConfig = ReturnType; + +function readExtendedTsConfigFile( + fileName: string, + basePath: string, + cache: Map, +): ParsedTsConfig | undefined { + if (!cache.has(fileName)) { + cache.set(fileName, isTsConfigFileExists(fileName) ? parseTsConfigFile(fileName, basePath) : undefined); + } + return cache.get(fileName); +} + +/** + * Merges the options of `tsConfig` with the options it inherits through `extends`, with the same precedence as + * TypeScript: the options of the file itself win, and a later entry of an `extends` array wins over an earlier one + * (including the files that the earlier entry extends). + */ +function resolveInheritedConfig( + tsConfig: ParsedTsConfig, + basePath: string, + cache: Map, +): UnnormalizedRawConfig { + // `extendedSourceFiles` is flattened: each entry of `extends` is followed by the files that it extends in turn. + // Those transitively extended files are skipped here, since they are merged when their entry is resolved. + const extendedSourceFiles = tsConfig.extendedSourceFiles ?? []; + let inheritedConfig: UnnormalizedRawConfig = {}; + let i = 0; + while (i < extendedSourceFiles.length) { + const base = readExtendedTsConfigFile(extendedSourceFiles[i]!, basePath, cache); + i++; + if (!base) continue; + const transitivelyExtendedSourceFiles = new Set(base.extendedSourceFiles); + while (i < extendedSourceFiles.length && transitivelyExtendedSourceFiles.has(extendedSourceFiles[i]!)) i++; + inheritedConfig = { ...inheritedConfig, ...resolveInheritedConfig(base, basePath, cache) }; + } + return { ...inheritedConfig, ...tsConfig.config }; +} + const configDirTemplate = /^\$\{configDir\}/iu; // https://github.com/microsoft/TypeScript/blob/7b8cb3bdf82f400642b73173f941335775d6f730/src/compiler/commandLineParser.ts#L3300 function startsWithConfigDirTemplate(path: string) { @@ -298,15 +337,12 @@ export function readConfigFile(project: string): CMKConfig { // The options read from `parsedCommandLine.raw` do not inherit values from the file specified in `extends`. // So here we read the options from those files and merge them into `parsedRawData`. - if (parsedTsConfig.extendedSourceFiles) { - for (const extendedSourceFile of parsedTsConfig.extendedSourceFiles) { - if (isTsConfigFileExists(extendedSourceFile)) { - const base = parseTsConfigFile(extendedSourceFile, basePath); - parsedTsConfig.config = { ...base.config, ...parsedTsConfig.config }; - parsedTsConfig.diagnostics = [...base.diagnostics, ...parsedTsConfig.diagnostics]; - } - } + const extendedTsConfigs = new Map(); + for (const extendedSourceFile of parsedTsConfig.extendedSourceFiles ?? []) { + const base = readExtendedTsConfigFile(extendedSourceFile, basePath, extendedTsConfigs); + if (base) parsedTsConfig.diagnostics = [...base.diagnostics, ...parsedTsConfig.diagnostics]; } + parsedTsConfig.config = resolveInheritedConfig(parsedTsConfig, basePath, extendedTsConfigs); if (parsedTsConfig.config.animation !== undefined && parsedTsConfig.config.keyframes !== undefined) { parsedTsConfig.diagnostics.push({