diff --git a/packages/rstack/src/configLayers.ts b/packages/rstack/src/configLayers.ts new file mode 100644 index 00000000..f97ae2b5 --- /dev/null +++ b/packages/rstack/src/configLayers.ts @@ -0,0 +1,58 @@ +import type { + ConfigParams as AppConfigParams, + RsbuildConfig, +} from '@rsbuild/core'; +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 { FmtConfig } from './fmt/types.ts'; +import type { StagedConfig } from './staged.ts'; + +type ConfigValues = { + app: RsbuildConfig; + lib: RslibConfig; + doc: RspressConfig; + test: RstestConfig; + lint: RslintConfig; + fmt: FmtConfig; + staged: StagedConfig; +}; + +type ConfigArgs = K extends 'app' + ? [params: AppConfigParams] + : K extends 'lib' + ? [params: LibConfigParams] + : []; + +/** + * Resolve one tool from ordered, normalized config layers. Lint factories are + * already wrapped by define.lint; merging belongs to the tool adapters. + */ +export const resolveConfigLayers = async ( + layers: readonly Configs[], + kind: K, + ...args: ConfigArgs +): Promise => { + const configs: ConfigValues[K][] = []; + + for (const layer of layers) { + const definition = layer[kind]; + if (definition === undefined) { + continue; + } + + // A staged function generates tasks from file names; it is not a factory. + if (kind !== 'staged' && typeof definition === 'function') { + const factory = definition as ( + ...args: ConfigArgs + ) => ConfigValues[K] | Promise; + configs.push(await factory(...args)); + } else { + configs.push(definition as ConfigValues[K]); + } + } + + return configs; +}; diff --git a/packages/rstack/tests/config/layers.test.ts b/packages/rstack/tests/config/layers.test.ts new file mode 100644 index 00000000..769ba8fd --- /dev/null +++ b/packages/rstack/tests/config/layers.test.ts @@ -0,0 +1,56 @@ +import type { ConfigParams } from 'rstack/app'; +import { expect, rs, test } from 'rstack/test'; +import { resolveConfigLayers } from '../../src/configLayers.ts'; + +test('resolves only the selected tool sequentially with native parameters', async () => { + const order: string[] = []; + const base = async ({ env }: ConfigParams) => { + await Promise.resolve(); + order.push('base'); + return { root: env }; + }; + const project = ({ command }: ConfigParams) => { + order.push('project'); + return { root: command }; + }; + const unrelated = rs.fn(() => ({})); + const layers = [{ app: base }, { app: project, fmt: unrelated }]; + + expect( + await resolveConfigLayers(layers, 'app', { + command: 'build', + env: 'production', + }), + ).toEqual([{ root: 'production' }, { root: 'build' }]); + expect(order).toEqual(['base', 'project']); + expect(unrelated).not.toHaveBeenCalled(); +}); + +test('skips missing definitions while preserving explicitly empty configs', async () => { + const layers = [{}, { test: {}, lint: [] }, { test: () => ({ retry: 1 }) }]; + + expect(await resolveConfigLayers(layers, 'test')).toEqual([{}, { retry: 1 }]); + expect(await resolveConfigLayers(layers, 'lint')).toEqual([[]]); + expect(await resolveConfigLayers(layers, 'fmt')).toEqual([]); +}); + +test('preserves staged task generators without invoking them', async () => { + const generator = rs.fn(() => ['rs fmt']); + const tasks = { '*.ts': 'rs lint' }; + const layers = [{ staged: tasks }, { staged: generator }]; + + expect(await resolveConfigLayers(layers, 'staged')).toEqual([ + tasks, + generator, + ]); + expect(generator).not.toHaveBeenCalled(); +}); + +test('stops at a failed factory and preserves its error', async () => { + const error = new Error('Invalid shared config'); + const project = rs.fn(() => ({})); + const layers = [{ fmt: () => Promise.reject(error) }, { fmt: project }]; + + await expect(resolveConfigLayers(layers, 'fmt')).rejects.toBe(error); + expect(project).not.toHaveBeenCalled(); +});