From 10b9eebbcd7d4a94d4377be63a57054449699da8 Mon Sep 17 00:00:00 2001 From: neverland Date: Sat, 19 Sep 2026 08:31:23 +0800 Subject: [PATCH 1/2] refactor(config): add internal config layer primitives --- packages/rstack/src/configLayers.ts | 71 ++++++++ packages/rstack/tests/config/layers.test.ts | 170 ++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 packages/rstack/src/configLayers.ts create mode 100644 packages/rstack/tests/config/layers.test.ts diff --git a/packages/rstack/src/configLayers.ts b/packages/rstack/src/configLayers.ts new file mode 100644 index 00000000..4bb403c4 --- /dev/null +++ b/packages/rstack/src/configLayers.ts @@ -0,0 +1,71 @@ +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'; + +/** Internal definitions, including the deferred lint factory created by define.lint. */ +export type ConfigLayer = Readonly; + +/** Ordered from the lowest to the highest precedence; no tool merge rules here. */ +export type ConfigLayers = readonly ConfigLayer[]; + +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] + : []; + +/** Collect definitions without running factories or modifying the supplied layers. */ +export const createConfigLayers = ( + project: ConfigLayer, + inherited: ConfigLayers = [], +): ConfigLayers => [...inherited, project]; + +/** + * Resolve only the requested tool, in layer order. Missing definitions contribute + * nothing; defaults and merging belong to the tool adapter. Each call evaluates + * its factories anew, without caching across native parameters or loads. + */ +export const resolveConfigLayers = async ( + layers: ConfigLayers, + 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..0f79d934 --- /dev/null +++ b/packages/rstack/tests/config/layers.test.ts @@ -0,0 +1,170 @@ +import type { ConfigParams } from 'rstack/app'; +import { expect, rs, test } from 'rstack/test'; +import { + createConfigLayers, + resolveConfigLayers, + type ConfigLayer, +} from '../../src/configLayers.ts'; + +test('collects inherited layers before the project without resolving definitions', () => { + const factory = rs.fn(() => ({})); + const base = Object.freeze({ app: factory }); + const team = Object.freeze({ test: factory }); + const project = Object.freeze({ app: factory }); + const inherited = Object.freeze([base, team]); + + const layers = createConfigLayers(project, inherited); + + expect(layers).toEqual([base, team, project]); + expect(layers[0]).toBe(base); + expect(layers[2]).toBe(project); + expect(inherited).toEqual([base, team]); + expect(createConfigLayers(project)).toEqual([project]); + expect(factory).not.toHaveBeenCalled(); +}); + +test.each(['app', 'lib'] as const)( + 'passes native parameters unchanged to every %s factory', + async (kind) => { + const params: ConfigParams = { + command: 'build', + env: 'production', + envMode: 'staging', + meta: { caller: 'test' }, + }; + const baseConfig = { root: 'base' }; + const projectConfig = { root: 'project' }; + const base = rs.fn((_params: ConfigParams) => baseConfig); + const project = rs.fn((_params: ConfigParams) => + Promise.resolve(projectConfig), + ); + const layers = createConfigLayers({ [kind]: project }, [{ [kind]: base }]); + + const configs = await resolveConfigLayers(layers, kind, params); + + expect(configs).toEqual([baseConfig, projectConfig]); + expect(configs[0]).toBe(baseConfig); + expect(configs[1]).toBe(projectConfig); + expect(base).toHaveBeenCalledExactlyOnceWith(params); + expect(project).toHaveBeenCalledExactlyOnceWith(params); + expect(base.mock.calls[0][0]).toBe(params); + expect(project.mock.calls[0][0]).toBe(params); + }, +); + +test('awaits layers sequentially and only resolves the selected tool', async () => { + const started = Promise.withResolvers(); + const ready = Promise.withResolvers(); + const baseConfig = Object.freeze({ retry: 2 }); + const projectConfig = Object.freeze({ retry: 1 }); + const unrelated = rs.fn(() => { + throw new Error('Unselected tool must stay lazy'); + }); + const project = rs.fn(() => projectConfig); + const layers = createConfigLayers({ test: project, app: unrelated }, [ + { + async test() { + started.resolve(); + await ready.promise; + return baseConfig; + }, + fmt: unrelated, + }, + ]); + + const resolving = resolveConfigLayers(layers, 'test'); + await started.promise; + try { + expect(project).not.toHaveBeenCalled(); + } finally { + ready.resolve(); + } + const configs = await resolving; + + expect(configs).toEqual([baseConfig, projectConfig]); + expect(configs[0]).toBe(baseConfig); + expect(configs[1]).toBe(projectConfig); + expect(project).toHaveBeenCalledExactlyOnceWith(); + expect(unrelated).not.toHaveBeenCalled(); +}); + +test.each(['doc', 'test', 'lint', 'fmt'] as const)( + 'resolves %s values and factories without injecting arguments', + async (kind) => { + const values = { + doc: { title: 'Docs' }, + test: { retry: 2 }, + lint: [], + fmt: { singleQuote: true }, + }; + const factory = rs.fn(() => Promise.resolve(values[kind])); + const layers = createConfigLayers({ [kind]: factory }, [ + { [kind]: values[kind] }, + ]); + + const configs = await resolveConfigLayers(layers, kind); + + expect(configs).toEqual([values[kind], values[kind]]); + expect(configs[0]).toBe(values[kind]); + expect(configs[1]).toBe(values[kind]); + expect(factory).toHaveBeenCalledExactlyOnceWith(); + }, +); + +test('skips missing definitions while preserving explicitly empty configs', async () => { + const emptyConfig = {}; + const emptyLint: ConfigLayer = { lint: [] }; + const layers = createConfigLayers({ test: emptyConfig }, [{}, emptyLint]); + + expect(await resolveConfigLayers(layers, 'test')).toEqual([emptyConfig]); + expect(await resolveConfigLayers(layers, 'lint')).toEqual([[]]); + expect(await resolveConfigLayers(layers, 'fmt')).toEqual([]); +}); + +test('preserves staged task generators without invoking them', async () => { + const task = rs.fn((_files: readonly string[]) => 'rs lint'); + const generator = rs.fn((_files: readonly string[]) => + Promise.resolve(['rs fmt']), + ); + const tasks = { '*.ts': task }; + const layers = createConfigLayers({ staged: generator }, [{ staged: tasks }]); + + const configs = await resolveConfigLayers(layers, 'staged'); + + expect(configs[0]).toBe(tasks); + expect(configs[1]).toBe(generator); + expect(task).not.toHaveBeenCalled(); + 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 = createConfigLayers({ fmt: project }, [ + { + fmt: () => Promise.reject(error), + }, + ]); + + await expect(resolveConfigLayers(layers, 'fmt')).rejects.toBe(error); + expect(project).not.toHaveBeenCalled(); +}); + +test('keeps simultaneous resolutions with different parameters independent', async () => { + const factory = rs.fn(async ({ env }: ConfigParams) => { + await Promise.resolve(); + return { root: env }; + }); + const layers = createConfigLayers({ app: factory }); + + const configs = await Promise.all([ + resolveConfigLayers(layers, 'app', { command: 'build', env: 'production' }), + resolveConfigLayers(layers, 'app', { command: 'dev', env: 'development' }), + ]); + + expect(configs).toEqual([ + [{ root: 'production' }], + [{ root: 'development' }], + ]); + expect(factory).toHaveBeenCalledTimes(2); +}); From 4eeae9048f8bd62425d494031edb43015dfc2261 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 21 Sep 2026 14:22:33 +0800 Subject: [PATCH 2/2] refactor(config): simplify config layer resolution and tests --- packages/rstack/src/configLayers.ts | 19 +-- packages/rstack/tests/config/layers.test.ts | 174 ++++---------------- 2 files changed, 33 insertions(+), 160 deletions(-) diff --git a/packages/rstack/src/configLayers.ts b/packages/rstack/src/configLayers.ts index 4bb403c4..f97ae2b5 100644 --- a/packages/rstack/src/configLayers.ts +++ b/packages/rstack/src/configLayers.ts @@ -10,12 +10,6 @@ import type { Configs } from './config.ts'; import type { FmtConfig } from './fmt/types.ts'; import type { StagedConfig } from './staged.ts'; -/** Internal definitions, including the deferred lint factory created by define.lint. */ -export type ConfigLayer = Readonly; - -/** Ordered from the lowest to the highest precedence; no tool merge rules here. */ -export type ConfigLayers = readonly ConfigLayer[]; - type ConfigValues = { app: RsbuildConfig; lib: RslibConfig; @@ -32,19 +26,12 @@ type ConfigArgs = K extends 'app' ? [params: LibConfigParams] : []; -/** Collect definitions without running factories or modifying the supplied layers. */ -export const createConfigLayers = ( - project: ConfigLayer, - inherited: ConfigLayers = [], -): ConfigLayers => [...inherited, project]; - /** - * Resolve only the requested tool, in layer order. Missing definitions contribute - * nothing; defaults and merging belong to the tool adapter. Each call evaluates - * its factories anew, without caching across native parameters or loads. + * 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: ConfigLayers, + layers: readonly Configs[], kind: K, ...args: ConfigArgs ): Promise => { diff --git a/packages/rstack/tests/config/layers.test.ts b/packages/rstack/tests/config/layers.test.ts index 0f79d934..769ba8fd 100644 --- a/packages/rstack/tests/config/layers.test.ts +++ b/packages/rstack/tests/config/layers.test.ts @@ -1,170 +1,56 @@ import type { ConfigParams } from 'rstack/app'; import { expect, rs, test } from 'rstack/test'; -import { - createConfigLayers, - resolveConfigLayers, - type ConfigLayer, -} from '../../src/configLayers.ts'; +import { resolveConfigLayers } from '../../src/configLayers.ts'; -test('collects inherited layers before the project without resolving definitions', () => { - const factory = rs.fn(() => ({})); - const base = Object.freeze({ app: factory }); - const team = Object.freeze({ test: factory }); - const project = Object.freeze({ app: factory }); - const inherited = Object.freeze([base, team]); - - const layers = createConfigLayers(project, inherited); - - expect(layers).toEqual([base, team, project]); - expect(layers[0]).toBe(base); - expect(layers[2]).toBe(project); - expect(inherited).toEqual([base, team]); - expect(createConfigLayers(project)).toEqual([project]); - expect(factory).not.toHaveBeenCalled(); -}); - -test.each(['app', 'lib'] as const)( - 'passes native parameters unchanged to every %s factory', - async (kind) => { - const params: ConfigParams = { +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', - envMode: 'staging', - meta: { caller: 'test' }, - }; - const baseConfig = { root: 'base' }; - const projectConfig = { root: 'project' }; - const base = rs.fn((_params: ConfigParams) => baseConfig); - const project = rs.fn((_params: ConfigParams) => - Promise.resolve(projectConfig), - ); - const layers = createConfigLayers({ [kind]: project }, [{ [kind]: base }]); - - const configs = await resolveConfigLayers(layers, kind, params); - - expect(configs).toEqual([baseConfig, projectConfig]); - expect(configs[0]).toBe(baseConfig); - expect(configs[1]).toBe(projectConfig); - expect(base).toHaveBeenCalledExactlyOnceWith(params); - expect(project).toHaveBeenCalledExactlyOnceWith(params); - expect(base.mock.calls[0][0]).toBe(params); - expect(project.mock.calls[0][0]).toBe(params); - }, -); - -test('awaits layers sequentially and only resolves the selected tool', async () => { - const started = Promise.withResolvers(); - const ready = Promise.withResolvers(); - const baseConfig = Object.freeze({ retry: 2 }); - const projectConfig = Object.freeze({ retry: 1 }); - const unrelated = rs.fn(() => { - throw new Error('Unselected tool must stay lazy'); - }); - const project = rs.fn(() => projectConfig); - const layers = createConfigLayers({ test: project, app: unrelated }, [ - { - async test() { - started.resolve(); - await ready.promise; - return baseConfig; - }, - fmt: unrelated, - }, - ]); - - const resolving = resolveConfigLayers(layers, 'test'); - await started.promise; - try { - expect(project).not.toHaveBeenCalled(); - } finally { - ready.resolve(); - } - const configs = await resolving; - - expect(configs).toEqual([baseConfig, projectConfig]); - expect(configs[0]).toBe(baseConfig); - expect(configs[1]).toBe(projectConfig); - expect(project).toHaveBeenCalledExactlyOnceWith(); + }), + ).toEqual([{ root: 'production' }, { root: 'build' }]); + expect(order).toEqual(['base', 'project']); expect(unrelated).not.toHaveBeenCalled(); }); -test.each(['doc', 'test', 'lint', 'fmt'] as const)( - 'resolves %s values and factories without injecting arguments', - async (kind) => { - const values = { - doc: { title: 'Docs' }, - test: { retry: 2 }, - lint: [], - fmt: { singleQuote: true }, - }; - const factory = rs.fn(() => Promise.resolve(values[kind])); - const layers = createConfigLayers({ [kind]: factory }, [ - { [kind]: values[kind] }, - ]); - - const configs = await resolveConfigLayers(layers, kind); - - expect(configs).toEqual([values[kind], values[kind]]); - expect(configs[0]).toBe(values[kind]); - expect(configs[1]).toBe(values[kind]); - expect(factory).toHaveBeenCalledExactlyOnceWith(); - }, -); - test('skips missing definitions while preserving explicitly empty configs', async () => { - const emptyConfig = {}; - const emptyLint: ConfigLayer = { lint: [] }; - const layers = createConfigLayers({ test: emptyConfig }, [{}, emptyLint]); + const layers = [{}, { test: {}, lint: [] }, { test: () => ({ retry: 1 }) }]; - expect(await resolveConfigLayers(layers, 'test')).toEqual([emptyConfig]); + 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 task = rs.fn((_files: readonly string[]) => 'rs lint'); - const generator = rs.fn((_files: readonly string[]) => - Promise.resolve(['rs fmt']), - ); - const tasks = { '*.ts': task }; - const layers = createConfigLayers({ staged: generator }, [{ staged: tasks }]); + const generator = rs.fn(() => ['rs fmt']); + const tasks = { '*.ts': 'rs lint' }; + const layers = [{ staged: tasks }, { staged: generator }]; - const configs = await resolveConfigLayers(layers, 'staged'); - - expect(configs[0]).toBe(tasks); - expect(configs[1]).toBe(generator); - expect(task).not.toHaveBeenCalled(); + 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 = createConfigLayers({ fmt: project }, [ - { - fmt: () => Promise.reject(error), - }, - ]); + const layers = [{ fmt: () => Promise.reject(error) }, { fmt: project }]; await expect(resolveConfigLayers(layers, 'fmt')).rejects.toBe(error); expect(project).not.toHaveBeenCalled(); }); - -test('keeps simultaneous resolutions with different parameters independent', async () => { - const factory = rs.fn(async ({ env }: ConfigParams) => { - await Promise.resolve(); - return { root: env }; - }); - const layers = createConfigLayers({ app: factory }); - - const configs = await Promise.all([ - resolveConfigLayers(layers, 'app', { command: 'build', env: 'production' }), - resolveConfigLayers(layers, 'app', { command: 'dev', env: 'development' }), - ]); - - expect(configs).toEqual([ - [{ root: 'production' }], - [{ root: 'development' }], - ]); - expect(factory).toHaveBeenCalledTimes(2); -});