diff --git a/packages/rstack/src/configLayers.ts b/packages/rstack/src/configLayers.ts index e1375e1..8795a91 100644 --- a/packages/rstack/src/configLayers.ts +++ b/packages/rstack/src/configLayers.ts @@ -6,7 +6,11 @@ import type { ConfigParams as LibConfigParams, RslibConfig } from '@rslib/core'; import type { RslintConfig } from '@rslint/core'; import type { UserConfig as RspressConfig } from '@rspress/core'; import type { RstestConfig } from '@rstest/core'; -import type { Configs } from './config.ts'; +import { + type Configs, + type RstackConfig, + normalizeRstackConfig, +} from './config.ts'; import type { FmtConfig } from './fmt/types.ts'; import type { StagedConfig } from './staged.ts'; @@ -26,6 +30,35 @@ type ConfigArgs = K extends 'app' ? [params: LibConfigParams] : []; +/** Expand inherited configs before their children, preserving every occurrence. */ +export const flattenConfigLayers = ( + configs: readonly RstackConfig[], +): Configs[] => { + const layers: Configs[] = []; + const ancestors = new Map(); + + const visit = (config: RstackConfig, path: string): void => { + if (config.extends?.length) { + const ancestorPath = ancestors.get(config); + if (ancestorPath !== undefined) { + throw new Error( + `Circular config inheritance at ${path}: references ${ancestorPath}.`, + ); + } + + ancestors.set(config, path); + config.extends.forEach((inherited, index) => { + visit(inherited, `${path}.extends[${index}]`); + }); + ancestors.delete(config); + } + layers.push(normalizeRstackConfig(config)); + }; + + configs.forEach((config, index) => visit(config, `extends[${index}]`)); + return layers; +}; + /** * Resolve one tool from ordered, normalized config layers. Lint factories are * already wrapped by define.lint. This function does not merge the results. diff --git a/packages/rstack/tests/config/flatten.test.ts b/packages/rstack/tests/config/flatten.test.ts new file mode 100644 index 0000000..583ff13 --- /dev/null +++ b/packages/rstack/tests/config/flatten.test.ts @@ -0,0 +1,49 @@ +import { expect, rs, test } from 'rstack/test'; +import type { RstackConfig } from '../../src/config.ts'; +import { flattenConfigLayers } from '../../src/configLayers.ts'; + +test('flattens nested configs in order and preserves repeated references without resolving factories', () => { + const app = rs.fn(() => ({})); + const base = { app, fmt: { printWidth: 80 } }; + const left = { extends: [base], fmt: { singleQuote: true } }; + const nested = { extends: [left], fmt: { semi: false } }; + const right = { extends: [base], fmt: { tabWidth: 4 } }; + const configs: readonly RstackConfig[] = [nested, right, base]; + + expect(flattenConfigLayers(configs)).toEqual([ + base, + { fmt: { singleQuote: true } }, + { fmt: { semi: false } }, + base, + { fmt: { tabWidth: 4 } }, + base, + ]); + expect(app).not.toHaveBeenCalled(); + expect(configs).toEqual([ + { + extends: [{ extends: [base], fmt: { singleQuote: true } }], + fmt: { semi: false }, + }, + { extends: [base], fmt: { tabWidth: 4 } }, + base, + ]); +}); + +test('reports the inheritance positions of a direct cycle', () => { + const config: RstackConfig = {}; + config.extends = [config]; + + expect(() => flattenConfigLayers([config])).toThrow( + 'Circular config inheritance at extends[0].extends[0]: references extends[0].', + ); +}); + +test('reports the inheritance positions of an indirect cycle', () => { + const first: RstackConfig = {}; + const second: RstackConfig = { extends: [first] }; + first.extends = [second]; + + expect(() => flattenConfigLayers([first])).toThrow( + 'Circular config inheritance at extends[0].extends[0].extends[0]: references extends[0].', + ); +});