diff --git a/.changeset/xref-compose-layers.md b/.changeset/xref-compose-layers.md new file mode 100644 index 000000000..c0321a082 --- /dev/null +++ b/.changeset/xref-compose-layers.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Compose every synthesized bundler config through one shared layering (`profile` → `tools.rsbuild` → `tools.rspack` → framework invariants), so a `tools` escape-hatch value reaches the MCP Apps Rsbuild config exactly as it reaches artifact scripts, hooks, MCP entries, the routed CLI bin, and the package build, and `output.cleanDistPath` stays off on every path. No artifact or `inspect --bundler` output changes. (#495) diff --git a/packages/agent-bundle/src/build/compose-layers.ts b/packages/agent-bundle/src/build/compose-layers.ts new file mode 100644 index 000000000..53acc8d10 --- /dev/null +++ b/packages/agent-bundle/src/build/compose-layers.ts @@ -0,0 +1,51 @@ +import type { AgentBundleToolsConfig } from '../core/types.ts'; + +/** The `tools.rspack` hatch shape: an Rspack config fragment, a mutator, or an array of either. */ +export type ToolsRspackHatch = NonNullable; + +/** The `tools.rsbuild` hatch shape: an Rsbuild environment-config fragment. */ +export type ToolsRsbuildFragment = NonNullable; + +/** + * The composition order every bundler config agent-bundle synthesizes + * follows, whichever engine lowers it: the framework profile first, the + * consumer `tools.rsbuild` fragment over it, the consumer `tools.rspack` + * hatch over that, and the framework invariant layer last, where no hatch + * value can reach it. Rslib's "raw user config highest" priority and + * Rspress's `builderConfig` position place the consumer exactly here. + * + * The layers are returned rather than merged because Rslib (`lib: [{ id }]` + * entries merged by `mergeRslibConfig`) and Rsbuild (`mergeRsbuildConfig`) + * take different containers; each caller lifts every layer into its own + * container and hands them to its engine's merge in this order. `lift` maps + * the two hatch fragments into the caller's layer type — the only place the + * workspace `@rsbuild/core` hatch types cross into the executing engine's. + */ +export const composeToolsLayers = (options: { + readonly invariants: Layer; + readonly lift: { + readonly rsbuild: (fragment: ToolsRsbuildFragment) => Layer; + readonly rspack: (hatch: ToolsRspackHatch) => Layer; + }; + readonly profile: Layer; + readonly tools?: AgentBundleToolsConfig; +}): readonly Layer[] => Object.freeze([ + options.profile, + ...(options.tools?.rsbuild === undefined ? [] : [options.lift.rsbuild(options.tools.rsbuild)]), + ...(options.tools?.rspack === undefined ? [] : [options.lift.rspack(options.tools.rspack)]), + options.invariants, +]); + +/** + * The invariant layer shared by every synthesized config. Dist cleaning + * stays off no matter what the consumer asks for: every surface of one + * target builds into one shared staged root, so an environment cleaning its + * dist path would delete sibling outputs already emitted there (the artifact + * is published atomically from the staged root instead). `enforce` is the + * engine-specific Rspack mutator appended after the consumer's, so a hatch + * mutator can neither strip the generated modules nor undo the invariants. + */ +export const frameworkInvariantLayer = (enforce: Mutator): { + readonly output: { readonly cleanDistPath: false }; + readonly tools: { readonly rspack: Mutator }; +} => ({ output: { cleanDistPath: false }, tools: { rspack: enforce } }); diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index 58a71f410..b325ff3e3 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -6,6 +6,7 @@ import { extname, resolve } from 'node:path'; import type { AgentBundleToolsConfig, NormalizedMcpApp } from '../core/types.ts'; import { stableJson } from '../core/digest.ts'; import type { AgentBundleMeta } from '../meta.ts'; +import { composeToolsLayers, frameworkInvariantLayer } from './compose-layers.ts'; import { listArtifactFiles, resolveArtifactDestination } from './emit.ts'; import { generatedMetaModulePath, @@ -171,10 +172,10 @@ export const planCompiledMcpApps = ( /** * One Rsbuild instance with one environment per app compiles every view in * a single parallel run instead of a sequential per-app build loop. The - * consumer escape hatch merges over this synthesized profile with the - * framework invariant hook appended last; the resolved-config assertions in - * `compileMcpApps` bound what the hatch may change. `inspect --bundler` - * surfaces exactly this composition. + * consumer escape hatch merges over this synthesized profile in the shared + * `composeToolsLayers` order, framework invariant hook last; the + * resolved-config assertions in `compileMcpApps` bound what the hatch may + * change. `inspect --bundler` surfaces exactly this composition. */ export const composeMcpAppsRsbuildConfig = ( sources: readonly Pick[], @@ -228,15 +229,16 @@ export const composeMcpAppsRsbuildConfig = ( ]; return config; }; - return mergeRsbuildConfig( + // The hatch types are this engine's own, so the layers lift unchanged. + return mergeRsbuildConfig(...composeToolsLayers({ + invariants: frameworkInvariantLayer(enforceInvariants), + lift: { + rsbuild: (fragment) => fragment, + rspack: (hatch) => ({ tools: { rspack: hatch } }), + }, profile, - ...(options.tools?.rsbuild === undefined ? [] : [options.tools.rsbuild]), - ...(options.tools?.rspack === undefined ? [] : [{ tools: { rspack: options.tools.rspack } }]), - // Merged last so the hatch cannot reach either invariant: dist cleaning - // would delete sibling outputs already emitted into the shared staged - // target root, so it stays off no matter what the consumer asks for. - { output: { cleanDistPath: false }, tools: { rspack: enforceInvariants } }, - ); + ...(options.tools === undefined ? {} : { tools: options.tools }), + })); }; export const compileMcpApps = async ( diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 42fbd7f99..36a471bd1 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -11,6 +11,7 @@ import { isErrno } from '../core/errors.ts'; import { isRecord } from '../core/strict-json.ts'; import type { AgentBundleToolsConfig } from '../core/types.ts'; import type { AgentBundleMeta } from '../meta.ts'; +import { composeToolsLayers, frameworkInvariantLayer } from './compose-layers.ts'; import { mcpEntryRuntimeSpecifier } from './entry-shell.ts'; import { generatedMetaModulePath, @@ -414,13 +415,12 @@ const assertExecutableConfig = ( }; /** - * Composes the full Rslib lib config for one synthesized entry: the - * framework profile, the consumer `tools` escape hatch merged over it - * (Rslib's "raw user config highest" priority), and the invariant enforcer - * hook appended last, all composed with Rslib's own `mergeRslibConfig` - * keyed by the synthesized lib id. `buildWithRslib` lowers exactly this - * composition and `inspect --bundler` surfaces it, so the two can never - * drift. + * Composes the full Rslib lib config for one synthesized entry in the + * shared `composeToolsLayers` order — the framework profile, the consumer + * `tools` escape hatch over it, the invariant enforcer hook last — with + * Rslib's own `mergeRslibConfig` keyed by the synthesized lib id. + * `buildWithRslib` lowers exactly this composition and `inspect --bundler` + * surfaces it, so the two can never drift. */ export const composeEntryLibConfig = ( entry: RslibEntry, @@ -567,20 +567,17 @@ export const composeEntryLibConfig = ( ...(entry.tsconfigPath === undefined ? {} : { tsconfigPath: entry.tsconfigPath }), }, }; - const merged = mergeRslibConfig( - { lib: [profile] }, - options.tools?.rsbuild === undefined - ? undefined - : { lib: [{ ...asRslibEnvironmentFragment(options.tools.rsbuild), id: libId }] }, - options.tools?.rspack === undefined - ? undefined - : { lib: [{ id: libId, tools: { rspack: asRslibRspackHatch(options.tools.rspack) } }] }, - // Merged last so the hatch cannot reach either invariant. Dist cleaning - // would delete sibling entries already emitted into the shared staged - // root, so it stays off no matter what the consumer asks for; the - // emitted output is published atomically from a staged root instead. - { lib: [{ id: libId, output: { cleanDistPath: false }, tools: { rspack: enforceInvariants } }] }, - ); + // Every layer is keyed by the synthesized lib id so `mergeRslibConfig` + // folds them into one lib entry in `composeToolsLayers` order. + const merged = mergeRslibConfig(...composeToolsLayers({ + invariants: { id: libId, ...frameworkInvariantLayer(enforceInvariants) }, + lift: { + rsbuild: (fragment) => ({ ...asRslibEnvironmentFragment(fragment), id: libId }), + rspack: (hatch) => ({ id: libId, tools: { rspack: asRslibRspackHatch(hatch) } }), + }, + profile, + ...(options.tools === undefined ? {} : { tools: options.tools }), + }).map((lib) => ({ lib: [lib] }))); const lib = merged.lib?.[0]; if (merged.lib?.length !== 1 || lib === undefined) { throw new Error(`Rslib config composition did not merge one lib entry for ${JSON.stringify(libId)}.`); diff --git a/packages/agent-bundle/tests/compose-layers.test.ts b/packages/agent-bundle/tests/compose-layers.test.ts new file mode 100644 index 000000000..7929a9ce0 --- /dev/null +++ b/packages/agent-bundle/tests/compose-layers.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from '@rstest/core'; + +import { composeToolsLayers, frameworkInvariantLayer } from '../src/build/compose-layers.ts'; +import { composeMcpAppsRsbuildConfig } from '../src/build/mcp-apps.ts'; +import { composeEntryLibConfig, type RslibEntry } from '../src/build/rslib.ts'; +import type { AgentBundleToolsConfig } from '../src/core/types.ts'; +import type { AgentBundleMeta } from '../src/meta.ts'; + +const meta: AgentBundleMeta = Object.freeze({ + name: 'layers-fixture', + packageName: undefined, + packageVersion: undefined, + version: '1.0.0', +}); + +const entry: RslibEntry = Object.freeze({ + name: 'tool', + outputRelativePath: 'scripts/tool.mjs', + source: '/project/src/tool.ts', + sourceInputs: ['/project/src/tool.ts'], +}); + +const app = Object.freeze({ name: 'dashboard', source: '/project/src/apps/dashboard.tsx', template: undefined }); + +/** + * A hatch touching every layer: `tools.rsbuild` sets a profile-owned output + * option (reaches the resolved config), asks to clean the dist path (the + * invariant must win), and `tools.rspack` contributes both a config fragment + * and a mutator (must run before the framework invariant mutator). + */ +const rspackFragment = Object.freeze({ resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } }); +const rspackMutator = (): void => undefined; +const tools: AgentBundleToolsConfig = { + rsbuild: { output: { cleanDistPath: true, legalComments: 'linked' } }, + rspack: [rspackFragment, rspackMutator], +}; + +const invariantMutatorOf = (config: { readonly tools?: { readonly rspack?: unknown } }): readonly unknown[] => { + const rspack = config.tools?.rspack; + return Array.isArray(rspack) ? rspack : [rspack]; +}; + +describe('composeToolsLayers', () => { + it('orders profile, tools.rsbuild, tools.rspack, invariants and omits absent hatch layers', () => { + const lift = { rsbuild: (fragment: unknown) => ({ layer: 'rsbuild', fragment }), rspack: (hatch: unknown) => ({ layer: 'rspack', hatch }) }; + expect(composeToolsLayers({ invariants: 'invariants', lift, profile: 'profile', tools })).toEqual([ + 'profile', + { layer: 'rsbuild', fragment: tools.rsbuild }, + { layer: 'rspack', hatch: tools.rspack }, + 'invariants', + ]); + expect(composeToolsLayers({ invariants: 'invariants', lift, profile: 'profile' })).toEqual(['profile', 'invariants']); + expect(composeToolsLayers({ invariants: 'invariants', lift, profile: 'profile', tools: { rspack: rspackMutator } })) + .toEqual(['profile', { layer: 'rspack', hatch: rspackMutator }, 'invariants']); + }); + + it('pins the invariant layer to cleanDistPath off plus the engine mutator', () => { + const enforce = (): void => undefined; + expect(frameworkInvariantLayer(enforce)).toEqual({ output: { cleanDistPath: false }, tools: { rspack: enforce } }); + }); +}); + +describe('the shared layering reaches every synthesized config the same way', () => { + const lib = composeEntryLibConfig(entry, { meta, outputRoot: '/staged/portable', tools }); + const apps = composeMcpAppsRsbuildConfig([app], { meta, outDir: '/staged/portable', tools }); + + it('lets a tools.rsbuild fragment reach the MCP Apps config exactly as it reaches an entry lib', () => { + expect(lib.output?.legalComments).toBe('linked'); + expect(apps.output?.legalComments).toBe('linked'); + // The profile beneath the fragment survives the merge on both paths. + expect(lib.output?.filename).toEqual({ js: 'scripts/tool.mjs' }); + expect(apps.output?.filename).toEqual({ css: '[name].css', html: '[name].html', js: '[name].js' }); + }); + + it('applies the tools.rspack hatch before the framework invariant mutator on both paths', () => { + for (const config of [lib, apps]) { + const mutators = invariantMutatorOf(config); + expect(mutators.slice(0, 2)).toEqual([rspackFragment, rspackMutator]); + expect(mutators).toHaveLength(3); + const enforce = mutators[2]; + expect(typeof enforce).toBe('function'); + expect((enforce as { readonly name: string }).name).toBe('enforceInvariants'); + } + }); + + it('keeps the invariants above the hatch: cleanDistPath stays off on both paths', () => { + expect(lib.output?.cleanDistPath).toBe(false); + expect(apps.output?.cleanDistPath).toBe(false); + }); + + it('composes only the profile and the invariants without a hatch', () => { + const bareLib = composeEntryLibConfig(entry, { meta, outputRoot: '/staged/portable' }); + const bareApps = composeMcpAppsRsbuildConfig([app], { meta, outDir: '/staged/portable' }); + for (const config of [bareLib, bareApps]) { + const mutators = invariantMutatorOf(config); + expect(mutators).toHaveLength(1); + expect((mutators[0] as { readonly name: string }).name).toBe('enforceInvariants'); + expect(config.output?.cleanDistPath).toBe(false); + } + }); +});